From 276489085939cda135e8dc314ff0a34bfe2d5f73 Mon Sep 17 00:00:00 2001 From: Joshua Kitenge Date: Mon, 16 Dec 2024 10:51:54 +0000 Subject: [PATCH 1/2] Enhance catalogue category card filters #1124 --- .../catalogueCardView.component.test.tsx | 31 +++++ .../category/catalogueCardView.component.tsx | 91 +++++++++++--- src/items/itemsTable.component.test.tsx | 4 +- src/items/itemsTable.component.tsx | 20 +-- src/utils.test.tsx | 114 +++++++++++++++++- src/utils.tsx | 28 ++++- 6 files changed, 256 insertions(+), 32 deletions(-) diff --git a/src/catalogue/category/catalogueCardView.component.test.tsx b/src/catalogue/category/catalogueCardView.component.test.tsx index a907729a2..c10e53cf1 100644 --- a/src/catalogue/category/catalogueCardView.component.test.tsx +++ b/src/catalogue/category/catalogueCardView.component.test.tsx @@ -169,4 +169,35 @@ describe('CardView', () => { expect(clearFiltersButton).toBeDisabled(); }); + + it('renders the multi-select filter mode dropdown correctly', async () => { + createView(); + + await user.click(screen.getByText('Show Filters')); + + const dropdownButtons = await screen.findAllByTestId('FilterListIcon'); + + expect(dropdownButtons[3]).toBeInTheDocument(); + + await user.click(dropdownButtons[3]); + + const includeAnyText = await screen.findByRole('menuitem', { + name: 'Includes any', + }); + const excludeAnyText = await screen.findByRole('menuitem', { + name: 'Excludes any', + }); + + const includeAllText = await screen.findByRole('menuitem', { + name: 'Includes all', + }); + const excludeAllText = await screen.findByRole('menuitem', { + name: 'Excludes all', + }); + + expect(includeAnyText).toBeInTheDocument(); + expect(excludeAnyText).toBeInTheDocument(); + expect(includeAllText).toBeInTheDocument(); + expect(excludeAllText).toBeInTheDocument(); + }); }); diff --git a/src/catalogue/category/catalogueCardView.component.tsx b/src/catalogue/category/catalogueCardView.component.tsx index 03faf884f..fca0edbfa 100644 --- a/src/catalogue/category/catalogueCardView.component.tsx +++ b/src/catalogue/category/catalogueCardView.component.tsx @@ -3,6 +3,7 @@ import { Button, Collapse, Grid, + MenuItem, Typography, useMediaQuery, useTheme, @@ -17,7 +18,16 @@ import React from 'react'; import { CatalogueCategory } from '../../api/api.types'; import CardViewFilters from '../../common/cardView/cardViewFilters.component'; import { usePreservedTableState } from '../../common/preservedTableState.component'; -import { displayTableRowCountText, getPageHeightCalc } from '../../utils'; +import { + COLUMN_FILTER_FUNCTIONS, + COLUMN_FILTER_MODE_OPTIONS, + COLUMN_FILTER_VARIANTS, + customFilterFunctions, + displayTableRowCountText, + getInitialColumnFilterFnState, + getPageHeightCalc, + MRT_Functions_Localisation, +} from '../../utils'; import CatalogueCard from './catalogueCard.component'; export interface CatalogueCardViewProps { catalogueCategoryData: CatalogueCategory[]; @@ -42,14 +52,6 @@ function CatalogueCardView(props: CatalogueCardViewProps) { selectedCategories, } = props; - const { preservedState, onPreservedStatesChange } = usePreservedTableState({ - initialState: { - pagination: { pageSize: 30, pageIndex: 0 }, - }, - storeInUrl: true, - paginationOnly: true, - }); - // Display total and pagination on separate lines if on a small screen const theme = useTheme(); const smallScreen = useMediaQuery(theme.breakpoints.down('sm')); @@ -71,14 +73,18 @@ function CatalogueCardView(props: CatalogueCardViewProps) { header: 'Name', accessorFn: (row) => row.name, id: 'name', + filterVariant: COLUMN_FILTER_VARIANTS.string, + filterFn: COLUMN_FILTER_FUNCTIONS.string, + columnFilterModeOptions: COLUMN_FILTER_MODE_OPTIONS.string, size: 300, }, { header: 'Last modified', accessorFn: (row) => new Date(row.modified_time), id: 'modified_time', - filterVariant: 'datetime-range', - filterFn: 'betweenInclusive', + filterVariant: COLUMN_FILTER_VARIANTS.datetime, + filterFn: COLUMN_FILTER_FUNCTIONS.datetime, + columnFilterModeOptions: COLUMN_FILTER_MODE_OPTIONS.datetime, size: 500, enableGrouping: false, }, @@ -87,8 +93,9 @@ function CatalogueCardView(props: CatalogueCardViewProps) { header: 'Created', accessorFn: (row) => new Date(row.modified_time), id: 'created', - filterVariant: 'datetime-range', - filterFn: 'betweenInclusive', + filterVariant: COLUMN_FILTER_VARIANTS.datetime, + filterFn: COLUMN_FILTER_FUNCTIONS.datetime, + columnFilterModeOptions: COLUMN_FILTER_MODE_OPTIONS.datetime, size: 500, enableGrouping: false, }, @@ -96,9 +103,43 @@ function CatalogueCardView(props: CatalogueCardViewProps) { header: 'Property names', accessorFn: (row) => row.properties.map((value) => value['name']).join(', '), - id: 'property-names', + id: 'properties', size: 350, - filterVariant: 'autocomplete', + filterVariant: 'multi-select', + filterFn: 'arrIncludesSome', + columnFilterModeOptions: [ + 'arrIncludesSome', + 'arrIncludesAll', + 'arrExcludesSome', + 'arrExcludesAll', + ], + renderColumnFilterModeMenuItems: ({ onSelectFilterMode }) => [ + onSelectFilterMode('arrIncludesSome')} + > + {MRT_Functions_Localisation.filterArrIncludesSome} + , + onSelectFilterMode('arrIncludesAll')} + > + {MRT_Functions_Localisation.filterArrIncludesAll} + , + onSelectFilterMode('arrExcludesSome')} + > + {MRT_Functions_Localisation.filterArrExcludesSome} + , + + onSelectFilterMode('arrExcludesAll')} + > + {MRT_Functions_Localisation.filterArrExcludesAll} + , + ], filterSelectOptions: propertyNames, enableGrouping: false, }, @@ -106,17 +147,33 @@ function CatalogueCardView(props: CatalogueCardViewProps) { header: 'Is Leaf', accessorFn: (row) => (row.is_leaf === true ? 'Yes' : 'No'), id: 'is-leaf', + filterVariant: COLUMN_FILTER_VARIANTS.boolean, + enableColumnFilterModes: false, size: 200, - filterVariant: 'autocomplete', }, ]; }, [propertyNames]); + + const initialColumnFilterFnState = React.useMemo(() => { + return getInitialColumnFilterFnState(columns); + }, [columns]); + + const { preservedState, onPreservedStatesChange } = usePreservedTableState({ + initialState: { + pagination: { pageSize: 30, pageIndex: 0 }, + columnFilterFns: initialColumnFilterFnState, + }, + storeInUrl: true, + paginationOnly: true, + }); + const table = useMaterialReactTable({ // Data columns: columns, data: catalogueCategoryData ?? [], // Features enableColumnOrdering: false, + enableColumnFilterModes: true, enableColumnPinning: false, enableTopToolbar: true, enableFacetedValues: true, @@ -130,6 +187,7 @@ function CatalogueCardView(props: CatalogueCardViewProps) { enableHiding: false, enableFullScreenToggle: false, enablePagination: true, + filterFns: customFilterFunctions, // Other settings paginationDisplayMode: 'pages', positionToolbarAlertBanner: 'bottom', @@ -137,6 +195,7 @@ function CatalogueCardView(props: CatalogueCardViewProps) { // Localisation localization: { ...MRT_Localization_EN, + ...MRT_Functions_Localisation, rowsPerPage: 'Categories per page', }, // State diff --git a/src/items/itemsTable.component.test.tsx b/src/items/itemsTable.component.test.tsx index b0745f42f..17b8ba9d6 100644 --- a/src/items/itemsTable.component.test.tsx +++ b/src/items/itemsTable.component.test.tsx @@ -446,10 +446,10 @@ describe('Items Table', () => { await user.click(dropdownButton); const includeText = await screen.findByRole('menuitem', { - name: 'Includes', + name: 'Includes any', }); const excludeText = await screen.findByRole('menuitem', { - name: 'Excludes', + name: 'Excludes any', }); expect(includeText).toBeInTheDocument(); diff --git a/src/items/itemsTable.component.tsx b/src/items/itemsTable.component.tsx index c7238319a..1e6955156 100644 --- a/src/items/itemsTable.component.tsx +++ b/src/items/itemsTable.component.tsx @@ -248,19 +248,19 @@ export function ItemsTable(props: ItemTableProps) { id: 'item.usage_status', filterVariant: 'multi-select', filterFn: 'arrIncludesSome', - columnFilterModeOptions: ['arrIncludesSome', 'arrIncludesNone'], + columnFilterModeOptions: ['arrIncludesSome', 'arrExcludesSome'], renderColumnFilterModeMenuItems: ({ onSelectFilterMode }) => [ onSelectFilterMode('arrIncludesSome')} > - Includes + {MRT_Functions_Localisation.filterArrIncludesSome} , onSelectFilterMode('arrIncludesNone')} + key="arrExcludesSome" + onClick={() => onSelectFilterMode('arrExcludesSome')} > - Excludes + {MRT_Functions_Localisation.filterArrExcludesSome} , ], size: 350, @@ -273,19 +273,19 @@ export function ItemsTable(props: ItemTableProps) { id: 'system.name', filterVariant: 'multi-select', filterFn: 'arrIncludesSome', - columnFilterModeOptions: ['arrIncludesSome', 'arrIncludesNone'], + columnFilterModeOptions: ['arrIncludesSome', 'arrExcludesSome'], renderColumnFilterModeMenuItems: ({ onSelectFilterMode }) => [ onSelectFilterMode('arrIncludesSome')} > - Includes + {MRT_Functions_Localisation.filterArrIncludesSome} , onSelectFilterMode('arrIncludesNone')} + key="arrExcludesSome" + onClick={() => onSelectFilterMode('arrExcludesSome')} > - Excludes + {MRT_Functions_Localisation.filterArrExcludesSome} , ], size: 350, diff --git a/src/utils.test.tsx b/src/utils.test.tsx index 45aaff9aa..767fbebe0 100644 --- a/src/utils.test.tsx +++ b/src/utils.test.tsx @@ -321,7 +321,7 @@ describe('Utility functions', () => { }); describe('customFilterFunctions', () => { - describe('arrIncludesNone', () => { + describe('arrExcludesSome', () => { const person: MRT_RowData = { name: 'Dan', age: 4, @@ -335,7 +335,7 @@ describe('customFilterFunctions', () => { id: string, // eslint-disable-next-line @typescript-eslint/no-explicit-any filterValue: any - ) => boolean = customFilterFunctions['arrIncludesNone']; + ) => boolean = customFilterFunctions['arrExcludesSome']; it('should correctly exclude record', () => { const result = filterExclude(person, 'status', ['unemployed']); expect(result).toBe(false); @@ -353,6 +353,116 @@ describe('customFilterFunctions', () => { expect(result).toBe(true); }); }); + describe('arrIncludesAll', () => { + const person: MRT_RowData = { + name: 'Dan', + age: 4, + hobbies: ['reading', 'coding', 'swimming'], + getValue: (id: string) => { + return person[id as keyof MRT_RowData]; + }, + }; + + const filterIncludesAll: ( + row: MRT_RowData, + id: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + filterValue: any + ) => boolean = customFilterFunctions['arrIncludesAll']; + + it('should return true when all filter values are included in the array', () => { + const result = filterIncludesAll(person, 'hobbies', [ + 'reading', + 'coding', + ]); + expect(result).toBe(true); + }); + + it('should return false when not all filter values are included in the array', () => { + const result = filterIncludesAll(person, 'hobbies', [ + 'reading', + 'dancing', + ]); + expect(result).toBe(false); + }); + + it('should return false when the filter value is not an array', () => { + const result = filterIncludesAll(person, 'hobbies', 'reading'); + expect(result).toBe(false); + }); + + it('should return true when the filter value is an empty array', () => { + const result = filterIncludesAll(person, 'hobbies', []); + expect(result).toBe(true); + }); + + it('should return false when the column value is null or undefined', () => { + const personWithNoHobbies: MRT_RowData = { + ...person, + hobbies: null, + getValue: (id: string) => { + return personWithNoHobbies[id as keyof MRT_RowData]; + }, + }; + const result = filterIncludesAll(personWithNoHobbies, 'hobbies', [ + 'reading', + ]); + expect(result).toBe(false); + }); + }); + + describe('arrExcludesAll', () => { + const person: MRT_RowData = { + name: 'Dan', + age: 4, + hobbies: 'reading, coding, swimming', + getValue: (id: string) => { + return person[id as keyof MRT_RowData]; + }, + }; + + const filterExcludesAll: ( + row: MRT_RowData, + id: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + filterValue: any + ) => boolean = customFilterFunctions['arrExcludesAll']; + + it('should return true when all filter values are excluded from the column value', () => { + const result = filterExcludesAll(person, 'hobbies', [ + 'dancing', + 'singing', + ]); + expect(result).toBe(true); + }); + + it('should return true when some filter values are included in the column value', () => { + const result = filterExcludesAll(person, 'hobbies', [ + 'coding', + 'dancing', + ]); + expect(result).toBe(true); + }); + + it('should return false when the filter value is an empty array', () => { + const result = filterExcludesAll(person, 'hobbies', []); + expect(result).toBe(false); + }); + + it('should return false when the column value is null or undefined', () => { + const personWithNoHobbies: MRT_RowData = { + ...person, + hobbies: null, + getValue: (id: string) => { + return personWithNoHobbies[id as keyof MRT_RowData]; + }, + }; + const result = filterExcludesAll(personWithNoHobbies, 'hobbies', [ + 'reading', + ]); + expect(result).toBe(false); + }); + }); }); describe('checkForDuplicates', () => { diff --git a/src/utils.tsx b/src/utils.tsx index d85385912..75cdfb7cb 100644 --- a/src/utils.tsx +++ b/src/utils.tsx @@ -420,16 +420,40 @@ interface FilterFn { export const customFilterFunctions: Record = { // eslint-disable-next-line @typescript-eslint/no-explicit-any - arrIncludesNone: (row: MRT_RowData, id: string, filterValue: any) => { + arrExcludesSome: (row: MRT_RowData, id: string, filterValue: any) => { if (Array.isArray(filterValue)) { return !filterValue.includes(row.getValue(id)); } return row.getValue(id) !== filterValue; }, + // Custom filter to check if all filter values are included + // eslint-disable-next-line @typescript-eslint/no-explicit-any + arrIncludesAll: (row: MRT_RowData, id: string, filterValue: any) => { + if (Array.isArray(filterValue)) { + return filterValue.every((val) => row.getValue(id)?.includes(val)); + } + return false; + }, + // Custom filter to check if all filter values are excluded + // eslint-disable-next-line @typescript-eslint/no-explicit-any + arrExcludesAll: (row: MRT_RowData, id: string, filterValue: any) => { + const columnValue = row + .getValue(id) + ?.split(',') + .map((val: string) => val.trim()); + if (Array.isArray(filterValue) && Array.isArray(columnValue)) { + // Only exclude if ALL filter values are present in the column value + return !filterValue.every((val) => columnValue.includes(val)); + } + return false; // Default to including the row if the column value is invalid + }, }; export const MRT_Functions_Localisation: Record = { - filterArrIncludesNone: 'Excludes', + filterArrIncludesSome: 'Includes any', + filterArrExcludesSome: 'Excludes any', + filterArrIncludesAll: 'Includes all', + filterArrExcludesAll: 'Excludes all', }; type DataTypes = 'boolean' | 'string' | 'number' | 'null' | 'datetime' | 'date'; From d24be526f6408125388908405f3c3960809bd6a4 Mon Sep 17 00:00:00 2001 From: Joshua Kitenge Date: Mon, 16 Dec 2024 15:47:33 +0000 Subject: [PATCH 2/2] refactor logic to use MRT_FilterFns #1124 --- src/utils.test.tsx | 148 +-------------------------------------------- src/utils.tsx | 54 +++++++---------- 2 files changed, 23 insertions(+), 179 deletions(-) diff --git a/src/utils.test.tsx b/src/utils.test.tsx index 767fbebe0..697dd0315 100644 --- a/src/utils.test.tsx +++ b/src/utils.test.tsx @@ -1,13 +1,12 @@ import { Link } from '@mui/material'; import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { MRT_ColumnDef, MRT_RowData } from 'material-react-table'; +import { MRT_ColumnDef } from 'material-react-table'; import { UsageStatus } from './api/api.types'; import { renderComponentWithRouterProvider } from './testUtils'; import { OverflowTip, checkForDuplicates, - customFilterFunctions, generateUniqueId, generateUniqueName, generateUniqueNameUsingCode, @@ -320,151 +319,6 @@ describe('Utility functions', () => { }); }); -describe('customFilterFunctions', () => { - describe('arrExcludesSome', () => { - const person: MRT_RowData = { - name: 'Dan', - age: 4, - status: 'unemployed', - getValue: (id: string) => { - return person[id as keyof MRT_RowData]; // Return the corresponding field from the object - }, - }; - const filterExclude: ( - row: MRT_RowData, - id: string, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - filterValue: any - ) => boolean = customFilterFunctions['arrExcludesSome']; - it('should correctly exclude record', () => { - const result = filterExclude(person, 'status', ['unemployed']); - expect(result).toBe(false); - }); - it('should correctly include record', () => { - const result = filterExclude(person, 'age', [8, 29]); - expect(result).toBe(true); - }); - it('should correctly exclude record, when filter value is not a list', () => { - const result = filterExclude(person, 'status', 'unemployed'); - expect(result).toBe(false); - }); - it('should correctly include record, when filter value is not a list', () => { - const result = filterExclude(person, 'age', 3); - expect(result).toBe(true); - }); - }); - describe('arrIncludesAll', () => { - const person: MRT_RowData = { - name: 'Dan', - age: 4, - hobbies: ['reading', 'coding', 'swimming'], - getValue: (id: string) => { - return person[id as keyof MRT_RowData]; - }, - }; - - const filterIncludesAll: ( - row: MRT_RowData, - id: string, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - filterValue: any - ) => boolean = customFilterFunctions['arrIncludesAll']; - - it('should return true when all filter values are included in the array', () => { - const result = filterIncludesAll(person, 'hobbies', [ - 'reading', - 'coding', - ]); - expect(result).toBe(true); - }); - - it('should return false when not all filter values are included in the array', () => { - const result = filterIncludesAll(person, 'hobbies', [ - 'reading', - 'dancing', - ]); - expect(result).toBe(false); - }); - - it('should return false when the filter value is not an array', () => { - const result = filterIncludesAll(person, 'hobbies', 'reading'); - expect(result).toBe(false); - }); - - it('should return true when the filter value is an empty array', () => { - const result = filterIncludesAll(person, 'hobbies', []); - expect(result).toBe(true); - }); - - it('should return false when the column value is null or undefined', () => { - const personWithNoHobbies: MRT_RowData = { - ...person, - hobbies: null, - getValue: (id: string) => { - return personWithNoHobbies[id as keyof MRT_RowData]; - }, - }; - const result = filterIncludesAll(personWithNoHobbies, 'hobbies', [ - 'reading', - ]); - expect(result).toBe(false); - }); - }); - - describe('arrExcludesAll', () => { - const person: MRT_RowData = { - name: 'Dan', - age: 4, - hobbies: 'reading, coding, swimming', - getValue: (id: string) => { - return person[id as keyof MRT_RowData]; - }, - }; - - const filterExcludesAll: ( - row: MRT_RowData, - id: string, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - filterValue: any - ) => boolean = customFilterFunctions['arrExcludesAll']; - - it('should return true when all filter values are excluded from the column value', () => { - const result = filterExcludesAll(person, 'hobbies', [ - 'dancing', - 'singing', - ]); - expect(result).toBe(true); - }); - - it('should return true when some filter values are included in the column value', () => { - const result = filterExcludesAll(person, 'hobbies', [ - 'coding', - 'dancing', - ]); - expect(result).toBe(true); - }); - - it('should return false when the filter value is an empty array', () => { - const result = filterExcludesAll(person, 'hobbies', []); - expect(result).toBe(false); - }); - - it('should return false when the column value is null or undefined', () => { - const personWithNoHobbies: MRT_RowData = { - ...person, - hobbies: null, - getValue: (id: string) => { - return personWithNoHobbies[id as keyof MRT_RowData]; - }, - }; - const result = filterExcludesAll(personWithNoHobbies, 'hobbies', [ - 'reading', - ]); - expect(result).toBe(false); - }); - }); -}); - describe('checkForDuplicates', () => { it('should return an empty array when there are no duplicates', () => { const data = [ diff --git a/src/utils.tsx b/src/utils.tsx index 75cdfb7cb..ab499716a 100644 --- a/src/utils.tsx +++ b/src/utils.tsx @@ -7,12 +7,14 @@ import { Typography, type TableCellProps, } from '@mui/material'; +import { FilterFn, FilterMeta, Row } from '@tanstack/table-core'; import { format, parseISO } from 'date-fns'; import { MRT_Cell, MRT_Column, MRT_ColumnDef, MRT_ColumnFilterFnsState, + MRT_FilterFns, MRT_FilterOption, MRT_Header, MRT_Row, @@ -413,39 +415,27 @@ export const getInitialColumnFilterFnState = ( return initialState; }; -interface FilterFn { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (row: MRT_RowData, id: string, filterValue: any): boolean; -} - -export const customFilterFunctions: Record = { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - arrExcludesSome: (row: MRT_RowData, id: string, filterValue: any) => { - if (Array.isArray(filterValue)) { - return !filterValue.includes(row.getValue(id)); - } - return row.getValue(id) !== filterValue; - }, - // Custom filter to check if all filter values are included - // eslint-disable-next-line @typescript-eslint/no-explicit-any - arrIncludesAll: (row: MRT_RowData, id: string, filterValue: any) => { - if (Array.isArray(filterValue)) { - return filterValue.every((val) => row.getValue(id)?.includes(val)); - } - return false; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const customFilterFunctions: Record> = { + arrExcludesSome: ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + row: Row, + id: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + filterValue: any, + addMeta: (meta: FilterMeta) => void + ) => { + return !MRT_FilterFns.arrIncludesSome(row, id, filterValue, addMeta); }, - // Custom filter to check if all filter values are excluded - // eslint-disable-next-line @typescript-eslint/no-explicit-any - arrExcludesAll: (row: MRT_RowData, id: string, filterValue: any) => { - const columnValue = row - .getValue(id) - ?.split(',') - .map((val: string) => val.trim()); - if (Array.isArray(filterValue) && Array.isArray(columnValue)) { - // Only exclude if ALL filter values are present in the column value - return !filterValue.every((val) => columnValue.includes(val)); - } - return false; // Default to including the row if the column value is invalid + arrExcludesAll: ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + row: Row, + id: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + filterValue: any, + addMeta: (meta: FilterMeta) => void + ) => { + return !MRT_FilterFns.arrIncludesAll(row, id, filterValue, addMeta); }, };