From 7249e02ca9b4b604b9074d8af7705055d81f1837 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Gra=CC=88=C3=9Fl?= Date: Mon, 11 Aug 2025 02:23:27 +0200 Subject: [PATCH 01/10] feat(TableStateProvider): Allow establishing new context --- .../TableStateProvider/TableStateProvider.js | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/components/TableStateProvider/TableStateProvider.js b/src/components/TableStateProvider/TableStateProvider.js index fbe3c08..91c0c7b 100644 --- a/src/components/TableStateProvider/TableStateProvider.js +++ b/src/components/TableStateProvider/TableStateProvider.js @@ -16,7 +16,7 @@ import { TableContext } from '~/hooks/useTableState/constants'; * @group Components * */ -const TableStateProvider = ({ children }) => { +const TableStateProvider = ({ parentContext, children }) => { const state = useState(); const observers = useRef({}); const serialisers = useRef({}); @@ -26,6 +26,7 @@ const TableStateProvider = ({ children }) => { return ( { TableStateProvider.propTypes = { children: propTypes.node, + parentContext: propTypes.object, }; -const TableStateProviderWrapper = ({ children }) => { +const TableStateProviderWrapper = ({ isNewContext, children }) => { const tableContext = useContext(TableContext); - const Wrapper = tableContext ? React.Fragment : TableStateProvider; + const Wrapper = + tableContext && !isNewContext ? React.Fragment : TableStateProvider; - return {children}; + return ( + + {children} + + ); }; TableStateProviderWrapper.propTypes = { children: propTypes.node, + isNewContext: propTypes.bool, }; export default TableStateProviderWrapper; From 277597f4d659d30f1561f3f34ebfd15001ca14de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Gra=CC=88=C3=9Fl?= Date: Mon, 11 Aug 2025 02:39:39 +0200 Subject: [PATCH 02/10] refactor(useItems): Improve query key --- src/hooks/useItems/useItems.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/hooks/useItems/useItems.js b/src/hooks/useItems/useItems.js index 7c57b66..b75a368 100644 --- a/src/hooks/useItems/useItems.js +++ b/src/hooks/useItems/useItems.js @@ -29,10 +29,11 @@ const useItems = ( externalTotal, ) => { const tableState = useRawTableState(); - const { filter, sort, pagination } = tableState || {}; const serialisedTableState = useSerialisedTableState(); - const useInternalState = typeof externalItems === 'function'; + const { filters, sort, pagination } = + serialisedTableState || tableState || {}; + const useInternalState = typeof externalItems === 'function'; const queryFn = useCallback(async () => { const [items, total] = await externalItems( serialisedTableState, @@ -50,9 +51,9 @@ const useItems = ( isFetching: internalLoading, error: internalError, } = useQuery({ - queryKey: ['items', serialisedTableState, filter, sort, pagination], + queryKey: ['items', filters, sort, pagination], queryFn, - enabled: useInternalState, + enabled: useInternalState && (!!tableState || !!serialisedTableState), refetchOnWindowFocus: false, }); From ff4d17ca12794cd881c5ff96764dee88ee17f14e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Gra=CC=88=C3=9Fl?= Date: Mon, 11 Aug 2025 02:40:46 +0200 Subject: [PATCH 03/10] refactor(useFilterConfig): Resolve props only once --- src/hooks/useFilterConfig/hooks/useResolvedProps.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/hooks/useFilterConfig/hooks/useResolvedProps.js b/src/hooks/useFilterConfig/hooks/useResolvedProps.js index 18ba1e8..adc6bde 100644 --- a/src/hooks/useFilterConfig/hooks/useResolvedProps.js +++ b/src/hooks/useFilterConfig/hooks/useResolvedProps.js @@ -22,7 +22,7 @@ const resolveObjectsProps = async (objects, propsToResolve) => { // TODO this hook may be useful elsewhere as well, move it higher up and/or into som utils hook folder const useResolvedProps = (objects, propsToResolve) => { - const [resolvedObjects, setResolvedObjects] = useState([]); + const [resolvedObjects, setResolvedObjects] = useState(); useDeepCompareEffect(() => { const resolveObjects = async (objects, propsToResolve) => { @@ -34,8 +34,10 @@ const useResolvedProps = (objects, propsToResolve) => { setResolvedObjects(newResolvedObjects); }; - resolveObjects(objects, propsToResolve); - }, [objects, propsToResolve]); + if (!resolvedObjects) { + resolveObjects(objects, propsToResolve); + } + }, [objects, propsToResolve, resolvedObjects]); return resolvedObjects; }; From 519d71e07585a6d0bd47846c8f7f68d11e4f9002 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Gra=CC=88=C3=9Fl?= Date: Mon, 11 Aug 2025 02:41:53 +0200 Subject: [PATCH 04/10] feat(useFilterConfig): Make item request returns work --- src/hooks/useFilterConfig/hooks/useResolvedProps.js | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/hooks/useFilterConfig/hooks/useResolvedProps.js b/src/hooks/useFilterConfig/hooks/useResolvedProps.js index adc6bde..3f02067 100644 --- a/src/hooks/useFilterConfig/hooks/useResolvedProps.js +++ b/src/hooks/useFilterConfig/hooks/useResolvedProps.js @@ -8,9 +8,16 @@ const resolveObjectsProps = async (objects, propsToResolve) => { let newObject = { ...object }; for (const prop of propsToResolve) { - // TODO Allow to pass function arguments when resolving props if (typeof object[prop] === 'function') { - newObject[prop] = await object[prop](); + const resolvedProp = await object[prop](); + if ( + Array.isArray(resolvedProp[0]) && + typeof resolvedProp[1] === 'number' + ) { + newObject[prop] = resolvedProp[0]; + } else { + newObject[prop] = resolvedProp; + } } } From 638d08351ca7884a9be6a1fbbafa01c948f4ce55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Gra=CC=88=C3=9Fl?= Date: Mon, 11 Aug 2025 02:43:07 +0200 Subject: [PATCH 05/10] fix(useFilterConfig): Properly clear filters --- src/hooks/useFilterConfig/hooks/useEventHandlers.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/hooks/useFilterConfig/hooks/useEventHandlers.js b/src/hooks/useFilterConfig/hooks/useEventHandlers.js index ec764d6..29140e1 100644 --- a/src/hooks/useFilterConfig/hooks/useEventHandlers.js +++ b/src/hooks/useFilterConfig/hooks/useEventHandlers.js @@ -36,10 +36,16 @@ const useEventHandlers = ({ const onFilterDelete = useCallback( async (_event, chips, clearAll = false) => { if (clearAll) { + const filtersToClear = Object.keys(activeFilters); + if (resetOnClear) { - reset(); + for (const filter of filtersToClear) { + reset(filter); + } } else { - clear(); + for (const filter of filtersToClear) { + clear(filter); + } } } else { deselect( From ee9fec46cafd0467a095456d744a508a5aaea6ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Gra=CC=88=C3=9Fl?= Date: Mon, 11 Aug 2025 02:46:23 +0200 Subject: [PATCH 06/10] feat(useFilterConfig): Implement async filter cache --- .../helpers/filterChipHelpers.js | 33 +++++++++++++++-- .../hooks/useAsyncFilterCache.js | 29 +++++++++++++++ .../useFilterConfig/hooks/useEventHandlers.js | 3 ++ .../useFilterConfig/hooks/useFilterModal.js | 12 +++++- src/hooks/useFilterConfig/useFilterConfig.js | 37 ++++++++++++++----- 5 files changed, 98 insertions(+), 16 deletions(-) create mode 100644 src/hooks/useFilterConfig/hooks/useAsyncFilterCache.js diff --git a/src/hooks/useFilterConfig/helpers/filterChipHelpers.js b/src/hooks/useFilterConfig/helpers/filterChipHelpers.js index 4e79672..bce5987 100644 --- a/src/hooks/useFilterConfig/helpers/filterChipHelpers.js +++ b/src/hooks/useFilterConfig/helpers/filterChipHelpers.js @@ -4,12 +4,29 @@ import { getFilterConfigItem } from './filterConfigHelpers'; const filterChipTemplates = (configItem, value, filterTypeHelpers) => filterTypeHelpers?.filterChips(configItem, value); -export const toFilterChips = (filterConfig, filterTypes, activeFilters) => +export const toFilterChips = ( + filterConfig, + filterTypes, + activeFilters, + asyncItems, +) => Object.entries(activeFilters || {}) .map(([filter, value]) => { const configItem = getFilterConfigItem(filterConfig, filter); + const configItemWithAsyncItems = { + ...(configItem || {}), + items: [ + ...(configItem?.items || []), + ...(asyncItems?.[stringToId(configItem?.label)] || []), + ], + }; + return configItem && isNotEmpty(value) - ? filterChipTemplates(configItem, value, filterTypes[configItem.type]) + ? filterChipTemplates( + configItemWithAsyncItems, + value, + filterTypes[configItem.type], + ) : undefined; }) .filter((v) => !!v); @@ -19,14 +36,22 @@ export const toDeselectValue = ( filterTypes, chip, activeFilters, + + asyncItems, ) => { const configItem = getFilterConfigItem( filterConfig, stringToId(chip.category), ); - + const configItemWithAsyncItems = { + ...(configItem || {}), + items: [ + ...(configItem?.items || []), + ...(asyncItems?.[stringToId(configItem.label)] || []), + ], + }; return filterTypes[configItem.type]?.toDeselectValue( - configItem, + configItemWithAsyncItems, chip, activeFilters, ); diff --git a/src/hooks/useFilterConfig/hooks/useAsyncFilterCache.js b/src/hooks/useFilterConfig/hooks/useAsyncFilterCache.js new file mode 100644 index 0000000..66d1c31 --- /dev/null +++ b/src/hooks/useFilterConfig/hooks/useAsyncFilterCache.js @@ -0,0 +1,29 @@ +import { useRef, useCallback } from 'react'; + +/** + * This hook is used to cache items that have been requested to show in the FilterModal + * This is needed in order to retain information to render the filters chip and be able to remove it. + * + * Ideally we would not need to do this and a filters value can stand on it's own. + * + */ +const useAsyncFilterCache = () => { + const asyncItems = useRef({}); + + const setAsyncFilterCache = useCallback((filter, itemsToCache) => { + const newItemsToCache = itemsToCache.filter( + ({ label }) => + !(asyncItems.current?.[filter] || []) + .map(({ label }) => label) + .includes(label), + ); + + asyncItems.current[filter] = asyncItems.current[filter] + ? [...(asyncItems?.current[filter] || []), ...newItemsToCache] + : newItemsToCache; + }, []); + + return [asyncItems?.current, setAsyncFilterCache]; +}; + +export default useAsyncFilterCache; diff --git a/src/hooks/useFilterConfig/hooks/useEventHandlers.js b/src/hooks/useFilterConfig/hooks/useEventHandlers.js index 29140e1..ab66c73 100644 --- a/src/hooks/useFilterConfig/hooks/useEventHandlers.js +++ b/src/hooks/useFilterConfig/hooks/useEventHandlers.js @@ -10,6 +10,7 @@ const useEventHandlers = ({ onDeleteFilter, resetOnClear, filterTypes, + asyncItems, selectionActions: { select, deselect, reset, clear }, }) => { const onFilterUpdate = useCallback( @@ -54,6 +55,7 @@ const useEventHandlers = ({ filterTypes, chips[0], activeFilters, + asyncItems, ), ); } @@ -68,6 +70,7 @@ const useEventHandlers = ({ deselect, resetOnClear, filterTypes, + asyncItems, ], ); diff --git a/src/hooks/useFilterConfig/hooks/useFilterModal.js b/src/hooks/useFilterConfig/hooks/useFilterModal.js index 44e5892..9f56d4d 100644 --- a/src/hooks/useFilterConfig/hooks/useFilterModal.js +++ b/src/hooks/useFilterConfig/hooks/useFilterModal.js @@ -1,10 +1,16 @@ import { useState, useCallback } from 'react'; import { stringToId } from '../helpers'; -const useFilterModal = ({ filterConfig, activeFilters, onFilterUpdate }) => { +const useFilterModal = ({ + serialisers, + filterConfig, + activeFilters, + onFilterUpdate, + setAsyncItems, +}) => { const [modalFilter, setModalFilter] = useState(); const isFilterModalOpen = !!modalFilter; - const filter = filterConfig.find( + const filter = filterConfig?.find( ({ label }) => stringToId(label) === modalFilter, ); @@ -27,6 +33,8 @@ const useFilterModal = ({ filterConfig, activeFilters, onFilterUpdate }) => { onChange: (values) => onFilterUpdate(stringToId(filter.label), undefined, values), onClose: closeFilterModal, + tableOptions: { serialisers }, + setAsyncItems, }, }; }; diff --git a/src/hooks/useFilterConfig/useFilterConfig.js b/src/hooks/useFilterConfig/useFilterConfig.js index 9e14710..4eca7f2 100644 --- a/src/hooks/useFilterConfig/useFilterConfig.js +++ b/src/hooks/useFilterConfig/useFilterConfig.js @@ -10,6 +10,8 @@ import { toFilterChips } from './helpers/filterChipHelpers'; import useEventHandlers from './hooks/useEventHandlers'; import useFilterOptions from './hooks/useFilterOptions'; import useFilterModal from './hooks/useFilterModal'; +import useAsyncFilterCache from './hooks/useAsyncFilterCache'; + import { TABLE_STATE_NAMESPACE } from './constants'; /** @@ -42,6 +44,7 @@ const useFilterConfig = (options) => { enableFilters, filterTypes, } = useFilterOptions(options); + const [asyncItems, setAsyncItems] = useAsyncFilterCache(); const { selection: activeFilters, ...selectionActions } = useSelectionManager( initialActiveFilters, @@ -53,20 +56,29 @@ const useFilterConfig = (options) => { activeFilters, selectionActions, filterTypes, + asyncItems, }); const { isFilterModalOpen, openFilterModal, filterModalProps } = - useFilterModal({ filterConfig, activeFilters, onFilterUpdate }); + useFilterModal({ + filterConfig: options.filters?.filterConfig, + activeFilters, + onFilterUpdate, + serialisers: options.serialisers, + setAsyncItems, + }); const builtFilterConfig = useMemo( () => - toFilterConfig( - filterConfig, - filterTypes, - activeFilters, - onFilterUpdate, - openFilterModal, - ), + filterConfig + ? toFilterConfig( + filterConfig, + filterTypes, + activeFilters, + onFilterUpdate, + openFilterModal, + ) + : [], [filterConfig, activeFilters, onFilterUpdate, filterTypes, openFilterModal], ); @@ -76,7 +88,7 @@ const useFilterConfig = (options) => { serialisers?.filters ? { serialiser: (state) => - serialisers.filters(state, filterConfig.map(toIdedFilters)), + serialisers.filters(state, filterConfig?.map(toIdedFilters)), } : {}, ); @@ -94,7 +106,12 @@ const useFilterConfig = (options) => { toolbarProps: { filterConfig: builtFilterConfig, activeFiltersConfig: { - filters: toFilterChips(filterConfig, filterTypes, activeFilters), + filters: toFilterChips( + filterConfig, + filterTypes, + activeFilters, + asyncItems, + ), onDelete: onFilterDelete, }, }, From f0661dd403b8eaf96eaf54c048a11ceb9efa83ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Gra=CC=88=C3=9Fl?= Date: Mon, 11 Aug 2025 02:47:27 +0200 Subject: [PATCH 07/10] feat(FilterModal): Improve item handling/fetching --- src/components/FilterModal/FilterModal.js | 44 +++++++++++++++---- src/components/FilterModal/filters.js | 7 +++ src/components/FilterModal/helpers.js | 5 +++ .../FilterModal/hooks/useFetchItems.js | 33 +++++++------- 4 files changed, 63 insertions(+), 26 deletions(-) create mode 100644 src/components/FilterModal/filters.js diff --git a/src/components/FilterModal/FilterModal.js b/src/components/FilterModal/FilterModal.js index 77b20fa..9b6995c 100644 --- a/src/components/FilterModal/FilterModal.js +++ b/src/components/FilterModal/FilterModal.js @@ -8,12 +8,16 @@ import { Button, } from '@patternfly/react-core'; -import { TableToolsTable, TableStateProvider } from '~/components'; - -import { filterOption, filterGroup } from './columns'; -import { convertToSelectValues, convertToFilterValues } from './helpers'; +import { + TableToolsTable, + TableStateProvider, + StaticTableToolsTable, +} from '~/components'; import useFetchItems from './hooks/useFetchItems'; +import { convertToSelectValues, convertToFilterValues } from './helpers'; +import { filterOption, filterGroup } from './columns'; +import filters from './filters'; const FilterModal = ({ isFilterModalOpen, @@ -21,7 +25,13 @@ const FilterModal = ({ onClose, activeFilters, onChange, + setAsyncItems, + tableOptions, }) => { + const items = filter.modal?.items || filter.items; + const isAsync = typeof (filter.modal?.items || filter.items) === 'function'; + const TableComponent = isAsync ? TableToolsTable : StaticTableToolsTable; + const title = filter?.modal?.title || `Filter by ${filter.label}`; const defaultColumns = filter.type === 'group' ? [filterOption, filterGroup] : [filterOption]; @@ -29,11 +39,14 @@ const FilterModal = ({ modal: { columns = defaultColumns, applyLabel = 'Apply selected' } = {}, } = filter; + const fetchItems = useFetchItems({ + items, + filter, + setAsyncItems, + }); const [selectedFilterValues, setSelectedFilterValues] = useState(activeFilters); - // TODO Replace this with using the "StaticTableToolsTable" instead for cases where there is no function to fetch - const fetchItems = useFetchItems(filter); const selected = convertToSelectValues(activeFilters, filter); const onSelect = useCallback( @@ -51,13 +64,24 @@ const FilterModal = ({ > - ({ + ...item, + id: item.label, + })) + } columns={columns} + filters={{ + filterConfig: filters, + }} options={{ selected, onSelect, + ...tableOptions, }} /> @@ -92,6 +116,8 @@ FilterModal.propTypes = { onChange: propTypes.func, isFilterModalOpen: propTypes.bool, onClose: propTypes.func, + setAsyncItems: propTypes.func, + tableOptions: propTypes.object, }; /** @@ -106,7 +132,7 @@ const FilterModalWithProvider = (props) => { // TODO Pass down "primary table" state return ( - + ); diff --git a/src/components/FilterModal/filters.js b/src/components/FilterModal/filters.js new file mode 100644 index 0000000..de52ef4 --- /dev/null +++ b/src/components/FilterModal/filters.js @@ -0,0 +1,7 @@ +export const label = { + type: 'text', + label: 'Name', + filterAttribute: 'label', +}; + +export default [label]; diff --git a/src/components/FilterModal/helpers.js b/src/components/FilterModal/helpers.js index b540973..9c1f329 100644 --- a/src/components/FilterModal/helpers.js +++ b/src/components/FilterModal/helpers.js @@ -64,3 +64,8 @@ export const convertToSelectValues = (filterValues, filter) => { return filterValues; } }; + +export const labelToid = (item) => ({ + ...item, + id: item.label, +}); diff --git a/src/components/FilterModal/hooks/useFetchItems.js b/src/components/FilterModal/hooks/useFetchItems.js index 21c5f2b..3e0917a 100644 --- a/src/components/FilterModal/hooks/useFetchItems.js +++ b/src/components/FilterModal/hooks/useFetchItems.js @@ -1,26 +1,25 @@ import { useCallback } from 'react'; -import { fetchStatic } from '../helpers'; +import { stringToId } from '~/hooks/useFilterConfig/helpers'; +import { labelToid } from '../helpers'; + +const useFetchItems = ({ items, filter, setAsyncItems }) => { + const id = stringToId(filter.label); -const useFetchItems = ({ - items: filterItems, - groups: groupItems, - modal: { items: modalItems } = {}, - type, -}) => { const fetchItems = useCallback( - (...args) => { - if (modalItems) { - return modalItems(...args); - } else if ( - typeof filterItems === 'function' || - typeof groupItems === 'function' - ) { - return (filterItems || groupItems)(...args); + async (...args) => { + const filterItems = await items(...args); + // TODO extract identifying "table tools request returns" + if (Array.isArray(filterItems[0]) && typeof filterItems[1] === 'number') { + setAsyncItems(id, filterItems[0]); + + return [filterItems[0].map(labelToid), filterItems[1]]; } else { - return fetchStatic(filterItems || groupItems, type, ...args); + setAsyncItems(id, filterItems); + + return [filterItems.map(labelToid), filterItems.length]; } }, - [modalItems, filterItems, groupItems, type], + [id, items, setAsyncItems], ); return fetchItems; From 0247722d726e2adf16919782df7c30e0904e0fa4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Gra=CC=88=C3=9Fl?= Date: Mon, 11 Aug 2025 02:47:55 +0200 Subject: [PATCH 08/10] docs(stories): Add FilterModal experiments --- .../TableToolsTableExperiments.stories.js | 129 +++++++++++++++++- 1 file changed, 128 insertions(+), 1 deletion(-) diff --git a/src/components/TableToolsTable/TableToolsTableExperiments.stories.js b/src/components/TableToolsTable/TableToolsTableExperiments.stories.js index 7b723f8..b51f994 100644 --- a/src/components/TableToolsTable/TableToolsTableExperiments.stories.js +++ b/src/components/TableToolsTable/TableToolsTableExperiments.stories.js @@ -1,9 +1,11 @@ -import React, { useCallback, useEffect } from 'react'; +import React, { useCallback, useEffect, useMemo } from 'react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { Card, CardBody, Spinner, Button, Label } from '@patternfly/react-core'; import defaultStoryMeta from '~/support/defaultStoryMeta'; import columns from '~/support/factories/columns'; +import { genres } from '~/support/factories/items'; + import paginationSerialiser from '~/components/StaticTableToolsTable/helpers/serialisers/pagination'; import sortSerialiser from '~/components/StaticTableToolsTable/helpers/serialisers/sort'; import filtersSerialiser from '~/components/StaticTableToolsTable/helpers/serialisers/filters'; @@ -170,6 +172,131 @@ export const BulkSelectStory = { render: (args) => , }; +const genreFilterOptions = genres.map((genre) => ({ + label: genre, + value: genre, +})); + +const FilterModalExample = () => { + const { fetch: fetchGenre } = useExampleDataQuery({ + endpoint: '/api/genres', + skip: true, + }); + const { + loading, + result: { data, meta: { total } = {} } = {}, + error, + itemIdsInTable, + } = useExampleDataQuery({ + endpoint: '/api', + useTableState: true, + }); + + const genreItemFetch = useCallback( + async ( + { filters, pagination, sort } = { + pagination: { offset: 0, limit: 15 }, + }, + ) => { + const params = { + ...(filters ? { filters } : {}), + ...(pagination ? pagination : {}), + ...(sort ? { sort } : {}), + }; + const genresJson = await fetchGenre(params); + + return [ + genresJson.data.map((genre) => ({ + label: genre, + value: genre, + })), + genresJson.meta.total, + ]; + }, + [fetchGenre], + ); + + const filters = useMemo(() => { + return [ + { + type: 'checkbox', + label: 'Genre with fetched items', + filterAttribute: 'genre', + // This function is used in two ways + // It is called without a serialisedTableState and tableState when it is called for the filter dropdown + // It is also called from the table in the modal WITH a serialisedTableState + items: genreItemFetch, + modal: true, + }, + { + type: 'checkbox', + label: 'Genre with static items and modal', + filterAttribute: 'genre', + items: genreFilterOptions.slice(0, 30), + modal: true, + }, + { + type: 'checkbox', + label: 'Genre with fetched items and modal items', + filterAttribute: 'genre', + items: genreFilterOptions.slice(0, 20), + modal: { + items: async ( + _serialisedState, + { pagination: { state } = { page: 1, perPage: 10 } } = {}, + ) => { + const offset = (state?.page - 1) * state?.perPage; + const limit = state?.perPage; + const genresResponse = await fetch( + `/api/genres?offset=${offset}&limit=${limit}`, + ); + const genresJson = await genresResponse.json(); + + return [ + genresJson.data.map((genre) => ({ + label: genre, + value: genre, + })), + genresJson.meta.total, + ]; + }, + }, + }, + ]; + }, [genreItemFetch]); + + return ( + + ); +}; + +export const FilterModalStory = { + decorators: [ + (Story) => ( + + + + + + ), + ], + render: (args) => , +}; + const AllEmptyExample = () => { const { loading } = useExampleDataQuery({ endpoint: '/api', From 947990a23a140f33f44905fc4acfbfe27040590e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Gra=CC=88=C3=9Fl?= Date: Mon, 11 Aug 2025 18:43:44 +0200 Subject: [PATCH 09/10] fix(FilterModal): Ensure tableOptions are passed on --- src/components/FilterModal/columns.js | 1 + .../TableToolsTableExperiments.stories.js | 18 ++++++++++++++---- .../useFilterConfig/hooks/useFilterModal.js | 5 ++++- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/components/FilterModal/columns.js b/src/components/FilterModal/columns.js index 125e103..34d7fb4 100644 --- a/src/components/FilterModal/columns.js +++ b/src/components/FilterModal/columns.js @@ -4,6 +4,7 @@ const FilterGroupText = ({ group }) => group; export const filterOption = { title: '', Component: FilterOptionText, + exportKey: 'label', }; export const filterGroup = { diff --git a/src/components/TableToolsTable/TableToolsTableExperiments.stories.js b/src/components/TableToolsTable/TableToolsTableExperiments.stories.js index b51f994..8204954 100644 --- a/src/components/TableToolsTable/TableToolsTableExperiments.stories.js +++ b/src/components/TableToolsTable/TableToolsTableExperiments.stories.js @@ -192,6 +192,10 @@ const FilterModalExample = () => { useTableState: true, }); + // This function is used in two ways + // It is called without a serialisedTableState and tableState when it is called for the filter dropdown + // If now separate fetch function is provided it is also called from the table in the modal WITH a serialisedTableState + // In case the request for filter items does require any state of the table it should be explicitly added and NOT use the passed serialisedTableState or tableState params const genreItemFetch = useCallback( async ( { filters, pagination, sort } = { @@ -222,11 +226,17 @@ const FilterModalExample = () => { type: 'checkbox', label: 'Genre with fetched items', filterAttribute: 'genre', - // This function is used in two ways - // It is called without a serialisedTableState and tableState when it is called for the filter dropdown - // It is also called from the table in the modal WITH a serialisedTableState items: genreItemFetch, - modal: true, + modal: { + tableOptions: { + exporter: async () => + (await genreItemFetch({ pagination: { limit: 'max' } }))[0], + itemIdsInTable: async () => + (await genreItemFetch({ pagination: { limit: 'max' } }))[0].map( + ({ value: id }) => id, + ), + }, + }, }, { type: 'checkbox', diff --git a/src/hooks/useFilterConfig/hooks/useFilterModal.js b/src/hooks/useFilterConfig/hooks/useFilterModal.js index 9f56d4d..6945f3b 100644 --- a/src/hooks/useFilterConfig/hooks/useFilterModal.js +++ b/src/hooks/useFilterConfig/hooks/useFilterModal.js @@ -33,7 +33,10 @@ const useFilterModal = ({ onChange: (values) => onFilterUpdate(stringToId(filter.label), undefined, values), onClose: closeFilterModal, - tableOptions: { serialisers }, + tableOptions: { + serialisers, + ...((filter?.modal || {}).tableOptions || {}), + }, setAsyncItems, }, }; From 064b9d0c91ecef0e1d7a88cf5e8033ed2a99452a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Gra=CC=88=C3=9Fl?= Date: Thu, 14 Aug 2025 13:05:38 +0200 Subject: [PATCH 10/10] fix(useFilterConfig): Fix groups filter --- .../TableToolsTableExperiments.stories.js | 25 +++++ .../helpers/filterChipHelpers.js | 17 ++-- src/hooks/useFilterConfig/helpers/helpers.js | 12 ++- .../useFilterConfig/hooks/useResolvedProps.js | 8 +- src/support/api.js | 3 + src/support/factories/filters.js | 91 ++++++++++--------- src/support/mswHandler.js | 2 + 7 files changed, 100 insertions(+), 58 deletions(-) diff --git a/src/components/TableToolsTable/TableToolsTableExperiments.stories.js b/src/components/TableToolsTable/TableToolsTableExperiments.stories.js index 8204954..9f00093 100644 --- a/src/components/TableToolsTable/TableToolsTableExperiments.stories.js +++ b/src/components/TableToolsTable/TableToolsTableExperiments.stories.js @@ -182,6 +182,10 @@ const FilterModalExample = () => { endpoint: '/api/genres', skip: true, }); + const { fetch: fetchYearsByDecade } = useExampleDataQuery({ + endpoint: '/api/years-by-decade', + skip: true, + }); const { loading, result: { data, meta: { total } = {} } = {}, @@ -222,6 +226,25 @@ const FilterModalExample = () => { const filters = useMemo(() => { return [ + { + type: 'group', + label: 'Years by decade', + filterSerialiser: (_filterConfigItem, value) => { + const allYears = Object.entries(value).reduce( + (years, [, groupYears]) => [...years, ...Object.keys(groupYears)], + [], + ); + + return `.releaseYear in [${allYears.join(', ')}]`; + }, + groups: async () => { + const yearsByDecade = await fetchYearsByDecade(); + return yearsByDecade; + }, + modal: { + title: 'Select years to filter by', + }, + }, { type: 'checkbox', label: 'Genre with fetched items', @@ -231,6 +254,8 @@ const FilterModalExample = () => { tableOptions: { exporter: async () => (await genreItemFetch({ pagination: { limit: 'max' } }))[0], + // TODO Find way to put items fetch here to the async cache + // Or find different way to allow enabling "select all" in the modal itemIdsInTable: async () => (await genreItemFetch({ pagination: { limit: 'max' } }))[0].map( ({ value: id }) => id, diff --git a/src/hooks/useFilterConfig/helpers/filterChipHelpers.js b/src/hooks/useFilterConfig/helpers/filterChipHelpers.js index bce5987..f5bfa3b 100644 --- a/src/hooks/useFilterConfig/helpers/filterChipHelpers.js +++ b/src/hooks/useFilterConfig/helpers/filterChipHelpers.js @@ -9,14 +9,15 @@ export const toFilterChips = ( filterTypes, activeFilters, asyncItems, -) => - Object.entries(activeFilters || {}) +) => { + return Object.entries(activeFilters || {}) .map(([filter, value]) => { const configItem = getFilterConfigItem(filterConfig, filter); + const itemsProp = configItem.type === 'groups' ? 'groups' : 'items'; const configItemWithAsyncItems = { ...(configItem || {}), - items: [ - ...(configItem?.items || []), + [itemsProp]: [ + ...(configItem?.[itemsProp] || []), ...(asyncItems?.[stringToId(configItem?.label)] || []), ], }; @@ -30,23 +31,25 @@ export const toFilterChips = ( : undefined; }) .filter((v) => !!v); +}; export const toDeselectValue = ( filterConfig, filterTypes, chip, activeFilters, - asyncItems, ) => { const configItem = getFilterConfigItem( filterConfig, stringToId(chip.category), ); + const itemsProp = configItem.type === 'groups' ? 'groups' : 'items'; + const configItemWithAsyncItems = { ...(configItem || {}), - items: [ - ...(configItem?.items || []), + [itemsProp]: [ + ...(configItem?.[itemsProp] || []), ...(asyncItems?.[stringToId(configItem.label)] || []), ], }; diff --git a/src/hooks/useFilterConfig/helpers/helpers.js b/src/hooks/useFilterConfig/helpers/helpers.js index d5dd222..2546b4f 100644 --- a/src/hooks/useFilterConfig/helpers/helpers.js +++ b/src/hooks/useFilterConfig/helpers/helpers.js @@ -16,10 +16,14 @@ export const defaultOnChange = (handler, label) => ({ }); export const flattenConfigItems = (configItem) => - (configItem.items || configItem.groups).flatMap((parentItem) => [ - parentItem, - ...parentItem.items.map((item) => ({ ...item, parent: parentItem })), - ]); + // TODO This is a hack. Somewhere an `items` prop with an empty array is introduced to group filters + // it should be either one or the other, but a filter should never have to have both `items` and `groups` + [...(configItem.items || []), ...(configItem.groups || [])].flatMap( + (parentItem) => [ + parentItem, + ...parentItem.items.map((item) => ({ ...item, parent: parentItem })), + ], + ); export const configItemItemByLabel = (configItem, label) => configItem.items.find(({ label: itemLabel }) => itemLabel === label); diff --git a/src/hooks/useFilterConfig/hooks/useResolvedProps.js b/src/hooks/useFilterConfig/hooks/useResolvedProps.js index 3f02067..043bd7d 100644 --- a/src/hooks/useFilterConfig/hooks/useResolvedProps.js +++ b/src/hooks/useFilterConfig/hooks/useResolvedProps.js @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useRef, useState } from 'react'; import { useDeepCompareEffect } from 'use-deep-compare'; const resolveObjectsProps = async (objects, propsToResolve) => { @@ -29,19 +29,21 @@ const resolveObjectsProps = async (objects, propsToResolve) => { // TODO this hook may be useful elsewhere as well, move it higher up and/or into som utils hook folder const useResolvedProps = (objects, propsToResolve) => { + const resolving = useRef(false); const [resolvedObjects, setResolvedObjects] = useState(); useDeepCompareEffect(() => { const resolveObjects = async (objects, propsToResolve) => { + resolving.current = true; const newResolvedObjects = await resolveObjectsProps( objects, propsToResolve, ); - + resolving.current = false; setResolvedObjects(newResolvedObjects); }; - if (!resolvedObjects) { + if (!resolvedObjects && !resolving.current) { resolveObjects(objects, propsToResolve); } }, [objects, propsToResolve, resolvedObjects]); diff --git a/src/support/api.js b/src/support/api.js index 14abc48..b8bdb53 100644 --- a/src/support/api.js +++ b/src/support/api.js @@ -2,6 +2,8 @@ import { faker } from '@faker-js/faker/locale/de'; import { jsonquery } from '@jsonquerylang/jsonquery'; import itemsFactory, { genres } from '~/support/factories/items'; +import { yearsByDecade } from '~/support/factories/filters'; + import { buildTree } from '~/support/factories/tableTree'; const DEFAULT_LIMIT = 10; @@ -44,5 +46,6 @@ const queriedItems = (itemsToQuery) => { export const apiHandler = queriedItems(items); export const apiGenresHandler = queriedItems(genres); +export const apiYearsByDecadeHandler = () => yearsByDecade; export const apiTreehandler = async () => buildTree({ items }); export const apiSelectionHandler = () => selectedItemIds; diff --git a/src/support/factories/filters.js b/src/support/factories/filters.js index a906564..04ef9c6 100644 --- a/src/support/factories/filters.js +++ b/src/support/factories/filters.js @@ -2,6 +2,50 @@ import NumberFilter from '~/support/components/NumberFilter'; import { genres, items } from './items'; +export const yearsByDecade = [ + { + label: '80s', + value: '80s', + groupSelectable: true, + items: [...new Array(10)].map((_, idx) => ({ + label: `198${idx}`, + value: `198${idx}`, + })), + }, + { + label: '90s', + value: '90s', + items: [...new Array(10)].map((_, idx) => ({ + label: `199${idx}`, + value: `199${idx}`, + })), + }, + { + label: '00s', + value: '00s', + items: [...new Array(10)].map((_, idx) => ({ + label: `200${idx}`, + value: `200${idx}`, + })), + }, + { + label: '10s', + value: '10s', + items: [...new Array(10)].map((_, idx) => ({ + label: `201${idx}`, + value: `201${idx}`, + })), + }, + { + label: '20s', + value: '20s', + items: [...new Array(5)].map((_, idx) => ({ + label: `201${idx}`, + value: `201${idx}`, + })), + }, +]; + export const title = { type: 'text', label: 'Title', @@ -94,7 +138,7 @@ export const invalidFilter = { filter: (items) => items, }; -export const yearsByDecade = { +export const yearsByDecadeFilter = { type: 'group', label: 'Years by decade', filterSerialiser: (_filterConfigItem, value) => { @@ -105,48 +149,7 @@ export const yearsByDecade = { return `.releaseYear in [${allYears.join(', ')}]`; }, - groups: [ - { - label: '80s', - value: '80s', - items: [...new Array(10)].map((_, idx) => ({ - label: `198${idx}`, - value: `198${idx}`, - })), - }, - { - label: '90s', - value: '90s', - items: [...new Array(10)].map((_, idx) => ({ - label: `199${idx}`, - value: `199${idx}`, - })), - }, - { - label: '00s', - value: '00s', - items: [...new Array(10)].map((_, idx) => ({ - label: `200${idx}`, - value: `200${idx}`, - })), - }, - { - label: '10s', - value: '10s', - items: [...new Array(10)].map((_, idx) => ({ - label: `201${idx}`, - value: `201${idx}`, - })), - }, - { - label: '20s', - value: '20s', - items: [...new Array(5)].map((_, idx) => ({ - label: `201${idx}`, - value: `201${idx}`, - })), - }, - ], + groups: yearsByDecade, modal: { title: 'Select years to filter by', }, @@ -235,7 +238,7 @@ export default [ rating, decade, invalidFilter, - yearsByDecade, + yearsByDecadeFilter, genreWithModal, genreWithFetchedItems, genreWithFetchedItemsAndModalItems, diff --git a/src/support/mswHandler.js b/src/support/mswHandler.js index 54de8eb..c476c39 100644 --- a/src/support/mswHandler.js +++ b/src/support/mswHandler.js @@ -4,6 +4,7 @@ import { apiHandler, apiTreehandler, apiGenresHandler, + apiYearsByDecadeHandler, apiSelectionHandler, } from './api'; @@ -25,6 +26,7 @@ export default [ http.get('/api', withAllParams(apiHandler)), http.get('/api/tree', withAllParams(apiTreehandler)), http.get('/api/genres', withAllParams(apiGenresHandler)), + http.get('/api/years-by-decade', withAllParams(apiYearsByDecadeHandler)), http.get('/api/error', async () => { await delay(DEFAULT_DELAY); return HttpResponse.json(