From 459ad4270a8227091d64e6c0d42e2a7f86096169 Mon Sep 17 00:00:00 2001 From: yaacov Date: Thu, 18 Jul 2024 16:57:29 +0300 Subject: [PATCH] Allow to store table filter state in local storage Signed-off-by: yaacov --- .../__tests__/useUrlFilters.test.tsx | 16 --- .../components/FilterGroup/useUrlFilters.ts | 105 ++++++++++++------ packages/common/src/components/Page/types.ts | 7 ++ .../common/src/components/Page/useFields.tsx | 5 +- .../src/components/Page/userSettings.ts | 10 +- .../TableView/ManageColumnsModal.tsx | 2 +- packages/common/src/utils/types.ts | 2 + .../src/components/page/StandardPage.tsx | 11 +- .../Plans/views/list/PlansListPage.tsx | 1 + 9 files changed, 99 insertions(+), 60 deletions(-) diff --git a/packages/common/src/components/FilterGroup/__tests__/useUrlFilters.test.tsx b/packages/common/src/components/FilterGroup/__tests__/useUrlFilters.test.tsx index 01e7332e6..35d9d6d9e 100644 --- a/packages/common/src/components/FilterGroup/__tests__/useUrlFilters.test.tsx +++ b/packages/common/src/components/FilterGroup/__tests__/useUrlFilters.test.tsx @@ -51,22 +51,6 @@ describe('parse filters from the URL on initialization', () => { ); expect(Object.entries(selectedFilters).length).toStrictEqual(0); }); - - it('supports prefixing params', () => { - // eslint-disable-next-line @cspell/spellchecker - window.location.search = '?barname=%5B"foo"%5D'; - const { - result: { - current: [selectedFilters], - }, - } = renderHook(() => - useUrlFilters({ - fields: [{ resourceFieldId: NAME, label: NAME }], - filterPrefix: 'bar', - }), - ); - expect(selectedFilters[NAME]).toStrictEqual(['foo']); - }); }); describe('display currently selected filters in the URL', () => { diff --git a/packages/common/src/components/FilterGroup/useUrlFilters.ts b/packages/common/src/components/FilterGroup/useUrlFilters.ts index 4b7bede30..5dc8bcf11 100644 --- a/packages/common/src/components/FilterGroup/useUrlFilters.ts +++ b/packages/common/src/components/FilterGroup/useUrlFilters.ts @@ -2,11 +2,14 @@ import { useMemo, useState } from 'react'; import { useSearchParams } from '../../hooks/useSearchParams'; import { ResourceField } from '../../utils'; +import { UserSettings } from '../Page'; import { GlobalFilters } from './types'; /** - * @returns parsed object or undefined if exception was thrown + * Safely parses a JSON string. + * @param {string} jsonString - The JSON string to parse. + * @returns {any} - The parsed JSON object or null if parsing fails. */ const safeParse = (str: string) => { try { @@ -16,6 +19,59 @@ const safeParse = (str: string) => { } }; +/** + * Filters and validates the search parameters. + * @param {Array} fields - The fields containing resourceFieldId. + * @param {Object} searchParams - The search parameters to filter and validate. + * @returns {Object} - An object with valid filters. + */ +function getValidFilters(fields, searchParams) { + const validFilters = fields + .map(({ resourceFieldId }) => { + const params = safeParse(searchParams[`${resourceFieldId}`]); + return { resourceFieldId, params }; + }) + // Valid filter values are arrays + .filter(({ params }) => Array.isArray(params) && params.length) + .map(({ resourceFieldId, params }) => [resourceFieldId, params]); + + return Object.fromEntries(validFilters); +} + +/** + * Converts filters to search parameters. + * @param {Array} fields - The fields containing resourceFieldId. + * @param {Object} filters - The filters to convert. + * @returns {Object} - The search parameters. + */ +function convertFiltersToSearchParams(fields, filters) { + const searchParams = fields + .map(({ resourceFieldId }) => ({ resourceFieldId, filters: filters[resourceFieldId] })) + .map(({ resourceFieldId, filters }) => [ + resourceFieldId, + Array.isArray(filters) && filters.length ? JSON.stringify(filters) : undefined, + ]); + + return Object.fromEntries(searchParams); +} + +/** + * Sets the state and updates the URL search parameters. + * @param {Function} setSelectedFilters - The function to set selected filters. + * @param {Function} updateSearchParams - The function to update search parameters. + * @param {Array} fields - The fields containing resourceFieldId. + * @returns {Function} - The function to set state and update URL. + */ +function createSetStateAndUrl(setSelectedFilters, updateSearchParams, updateUserSettings, fields) { + return (filters) => { + if (updateUserSettings) { + updateUserSettings(filters); + } + setSelectedFilters(filters); + updateSearchParams(convertFiltersToSearchParams(fields, filters)); + }; +} + /** * Init and maintain a set of filters on the search part of the URL. * @@ -24,46 +80,33 @@ const safeParse = (str: string) => { * 3. the single source of truth is the internal filter state maintained by the hook * * @param fields list of supported fields(read-only meta-data) - * @param filterPrefix prefix for the field IDs to avoid name conflicts with other query params in the URL * @returns [selectedFilters, setSelectedFilters] */ export const useUrlFilters = ({ fields, - filterPrefix = '', + userSettings, }: { fields: ResourceField[]; - filterPrefix?: string; + userSettings?: UserSettings; }): [GlobalFilters, (filters: GlobalFilters) => void] => { - const [searchParams, updateSearchParams] = useSearchParams(); - const [selectedFilters, setSelectedFilters] = useState(() => - Object.fromEntries( - fields - .map(({ resourceFieldId }) => ({ - resourceFieldId, - // discard any corrupted filters i.e. partially copy-pasted - params: safeParse(searchParams[`${filterPrefix}${resourceFieldId}`]), - })) - // discard filters with invalid structure (basic validation) - // each filter should validate if values make sense (i.e. enum values in range) - .filter(({ params }) => Array.isArray(params) && params.length) - .map(({ resourceFieldId, params }) => [resourceFieldId, params]), + const persistentFieldIds = fields.filter((f) => f?.isPersistent).map((f) => f.resourceFieldId); + const persistentFilters = Object.fromEntries( + Object.entries(userSettings?.filters.data || {}).filter(([key]) => + persistentFieldIds.includes(key), ), ); + + const [searchParams, updateSearchParams] = useSearchParams(); + const [selectedFilters, setSelectedFilters] = useState(() => ({ + ...persistentFilters, + ...getValidFilters(fields, searchParams), + })); + const updateUserSettings = userSettings?.filters.save; + const setStateAndUrl = useMemo( - () => (filters: GlobalFilters) => { - setSelectedFilters(filters); - updateSearchParams( - Object.fromEntries( - fields - .map(({ resourceFieldId }) => ({ resourceFieldId, filters: filters[resourceFieldId] })) - .map(({ resourceFieldId, filters }) => [ - resourceFieldId, - Array.isArray(filters) && filters.length ? JSON.stringify(filters) : undefined, - ]), - ), - ); - }, - [setSelectedFilters, updateSearchParams], + () => createSetStateAndUrl(setSelectedFilters, updateSearchParams, updateUserSettings, fields), + [setSelectedFilters, updateSearchParams, fields], ); + return [selectedFilters, setStateAndUrl]; }; diff --git a/packages/common/src/components/Page/types.ts b/packages/common/src/components/Page/types.ts index 965008140..31a35c7c2 100644 --- a/packages/common/src/components/Page/types.ts +++ b/packages/common/src/components/Page/types.ts @@ -1,6 +1,7 @@ export interface UserSettings { fields?: FieldSettings; pagination?: PaginationSettings; + filters?: FiltersSettings; } export interface FieldSettings { @@ -14,3 +15,9 @@ export interface PaginationSettings { save: (perPage: number) => void; clear: () => void; } + +export interface FiltersSettings { + data: { [k: string]: undefined }; + save: (filters: { [k: string]: undefined }) => void; + clear: () => void; +} diff --git a/packages/common/src/components/Page/useFields.tsx b/packages/common/src/components/Page/useFields.tsx index c57586ce9..93b772ca9 100644 --- a/packages/common/src/components/Page/useFields.tsx +++ b/packages/common/src/components/Page/useFields.tsx @@ -53,7 +53,7 @@ export const useFields = ( // used to detect duplicates const idsToBeVisited = new Set(savedIds); - return [ + const stateFields = [ // put fields saved via user settings (if any) ...fieldsFromSettings // ignore duplicates:ID is removed from the helper map on the first visit @@ -70,7 +70,10 @@ export const useFields = ( .filter(({ resourceFieldId }) => !savedIds.has(resourceFieldId)) .map((it) => ({ ...it })), ]; + + return stateFields; }); + const namespaceAwareFields: ResourceField[] = useMemo( () => fields.map(({ resourceFieldId, isVisible = false, ...rest }) => ({ diff --git a/packages/common/src/components/Page/userSettings.ts b/packages/common/src/components/Page/userSettings.ts index 0e3c0e298..859a3c43e 100644 --- a/packages/common/src/components/Page/userSettings.ts +++ b/packages/common/src/components/Page/userSettings.ts @@ -43,7 +43,7 @@ const sanitizeFields = (fields: unknown): { resourceFieldId: string; isVisible?: */ export const loadUserSettings = ({ pageId }): UserSettings => { const key = `${process.env.PLUGIN_NAME}-${pageId}`; - const { fields, perPage } = parseOrClean(key); + const { fields, perPage, filters } = parseOrClean(key); return { fields: { @@ -66,5 +66,13 @@ export const loadUserSettings = ({ pageId }): UserSettings => { saveRestOrRemoveKey(key, { perPage, rest }); }, }, + filters: { + data: filters, + save: (filters) => saveToLocalStorage(key, JSON.stringify({ ...parseOrClean(key), filters })), + clear: () => { + const { filters, ...rest } = parseOrClean(key); + saveRestOrRemoveKey(key, { filters, rest }); + }, + }, }; }; diff --git a/packages/common/src/components/TableView/ManageColumnsModal.tsx b/packages/common/src/components/TableView/ManageColumnsModal.tsx index dcc7f359d..d9896b97c 100644 --- a/packages/common/src/components/TableView/ManageColumnsModal.tsx +++ b/packages/common/src/components/TableView/ManageColumnsModal.tsx @@ -123,7 +123,7 @@ export const ManageColumnsModal = ({ }; const onSave = () => { // assume that action resourceFields are always at the end - onChange([...editedColumns, ...resourceFields.filter((col) => col.isAction)]); + onChange([...editedColumns, ...resourceFields.filter((col) => col.isAction || col.isHidden)]); onClose(); }; diff --git a/packages/common/src/utils/types.ts b/packages/common/src/utils/types.ts index 6c3ff8ee4..399e54d8d 100644 --- a/packages/common/src/utils/types.ts +++ b/packages/common/src/utils/types.ts @@ -41,6 +41,8 @@ export interface ResourceField { isHidden?: boolean; sortable?: boolean; filter?: FilterDef; + // if true then the field filters state should persist between sessions + isPersistent?: boolean; // eslint-disable-next-line @typescript-eslint/no-explicit-any compareFn?: (a: any, b: any, locale: string) => number; } diff --git a/packages/forklift-console-plugin/src/components/page/StandardPage.tsx b/packages/forklift-console-plugin/src/components/page/StandardPage.tsx index baa056d9b..14996dc07 100644 --- a/packages/forklift-console-plugin/src/components/page/StandardPage.tsx +++ b/packages/forklift-console-plugin/src/components/page/StandardPage.tsx @@ -88,7 +88,6 @@ const reduceValueFilters = ( * @property {JSX.Element} [customNoResultsFound] - Optional custom message to display when no results are found. * @property {JSX.Element} [customNoResultsMatchFilter] - Optional custom message to display when no results match the filter. * @property {number | 'on' | 'off'} [pagination=DEFAULT_PER_PAGE] - Controls the display of pagination controls. - * @property {string} [filterPrefix=''] - Prefix for filters stored in the query params part of the URL. * @property {UserSettings} [userSettings] - User settings store to initialize the page according to user preferences. * @property {ReactNode} [alerts] - Optional alerts section below the page title. * @property {FC>[]} [GlobalActionToolbarItems=[]] - Optional toolbar items with global actions. @@ -176,12 +175,6 @@ export interface StandardPageProps { */ page: number; - /** - * Prefix for filters stored in the query params part of the URL. - * By default no prefix is used - the field ID is used directly. - */ - filterPrefix?: string; - /** * User settings store to initialize the page according to user preferences. */ @@ -250,7 +243,6 @@ export interface StandardPageProps { * @param {JSX.Element} [props.customNoResultsFound] - Optional custom message to display when no results are found. * @param {JSX.Element} [props.customNoResultsMatchFilter] - Optional custom message to display when no results match the filter. * @param {number | 'on' | 'off'} [props.pagination=DEFAULT_PER_PAGE] - Controls the display of pagination controls. - * @param {string} [props.filterPrefix=''] - Prefix for filters stored in the query params part of the URL. * @param {UserSettings} [props.userSettings] - User settings store to initialize the page according to user preferences. * @param {ReactNode} [props.alerts] - Optional alerts section below the page title. * @param {FC>[]} [props.GlobalActionToolbarItems=[]] - Optional toolbar items with global actions. @@ -281,7 +273,6 @@ export function StandardPage({ pagination = DEFAULT_PER_PAGE, page: initialPage, userSettings, - filterPrefix = '', extraSupportedMatchers, HeaderMapper = DefaultHeader, GlobalActionToolbarItems = [], @@ -300,7 +291,7 @@ export function StandardPage({ const [selectedFilters, setSelectedFilters] = useUrlFilters({ fields: fieldsMetadata, - filterPrefix, + userSettings, }); const clearAllFilters = () => setSelectedFilters({}); const [fields, setFields] = useFields(namespace, fieldsMetadata, userSettings?.fields); diff --git a/packages/forklift-console-plugin/src/modules/Plans/views/list/PlansListPage.tsx b/packages/forklift-console-plugin/src/modules/Plans/views/list/PlansListPage.tsx index 545185787..e1be496eb 100644 --- a/packages/forklift-console-plugin/src/modules/Plans/views/list/PlansListPage.tsx +++ b/packages/forklift-console-plugin/src/modules/Plans/views/list/PlansListPage.tsx @@ -120,6 +120,7 @@ export const fieldsMetadataFactory: ResourceFieldFactory = (t) => [ jsonPath: '$.obj.spec.archived', label: t('Archived'), isHidden: true, + isPersistent: true, filter: { type: 'slider', standalone: true,