Skip to content

Commit

Permalink
Merge pull request #1289 from yaacov/store-filters-in-user-settings
Browse files Browse the repository at this point in the history
🐾 Allow to store table filter state in local storage
  • Loading branch information
yaacov authored Jul 18, 2024
2 parents 86e4372 + 459ad42 commit 63555fd
Show file tree
Hide file tree
Showing 9 changed files with 99 additions and 60 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
105 changes: 74 additions & 31 deletions packages/common/src/components/FilterGroup/useUrlFilters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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.
*
Expand All @@ -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];
};
7 changes: 7 additions & 0 deletions packages/common/src/components/Page/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export interface UserSettings {
fields?: FieldSettings;
pagination?: PaginationSettings;
filters?: FiltersSettings;
}

export interface FieldSettings {
Expand All @@ -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;
}
5 changes: 4 additions & 1 deletion packages/common/src/components/Page/useFields.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 }) => ({
Expand Down
10 changes: 9 additions & 1 deletion packages/common/src/components/Page/userSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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 });
},
},
};
};
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,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();
};

Expand Down
2 changes: 2 additions & 0 deletions packages/common/src/utils/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<GlobalActionToolbarProps<T>>[]} [GlobalActionToolbarItems=[]] - Optional toolbar items with global actions.
Expand Down Expand Up @@ -176,12 +175,6 @@ export interface StandardPageProps<T> {
*/
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.
*/
Expand Down Expand Up @@ -250,7 +243,6 @@ export interface StandardPageProps<T> {
* @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<GlobalActionToolbarProps<T>>[]} [props.GlobalActionToolbarItems=[]] - Optional toolbar items with global actions.
Expand Down Expand Up @@ -281,7 +273,6 @@ export function StandardPage<T>({
pagination = DEFAULT_PER_PAGE,
page: initialPage,
userSettings,
filterPrefix = '',
extraSupportedMatchers,
HeaderMapper = DefaultHeader<T>,
GlobalActionToolbarItems = [],
Expand All @@ -300,7 +291,7 @@ export function StandardPage<T>({

const [selectedFilters, setSelectedFilters] = useUrlFilters({
fields: fieldsMetadata,
filterPrefix,
userSettings,
});
const clearAllFilters = () => setSelectedFilters({});
const [fields, setFields] = useFields(namespace, fieldsMetadata, userSettings?.fields);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down

0 comments on commit 63555fd

Please sign in to comment.