From 17e00609a45451b25e81d2748f398be0387495ec Mon Sep 17 00:00:00 2001 From: Radoslaw Szwajkowski Date: Fri, 28 Oct 2022 16:25:47 +0200 Subject: [PATCH] Provide reusable components for creating standard list page Components created: 1. standard list page component a) page skeleton following openshift console layout and practice b) generic component parametrized by the entity type c) design is view independent and follows the approach used in oVirt PR 1600 and 1592. Currently only table view implementation is provided. 2. primary filters component a) implementation of PatternFly 4 filter group pattern[1] extended to support all filters implementing FilterTypeProps interface. b) use case: displaying few (1-3) most important filters. The filters are grouped but displayed independently. 3. attribute-value filter component a) implementation of PatternFly 4 attribute-value filter pattern[2] b) use case: grouping all other filters in a space efficient way c) supports all filters implementing FilterTypeProps interface. 4. filter components: a) free text filter - substring search based on multiple terms - search terms confirmed by 'Enter' key or by button b) enum based filter - exact match based on checkboxes selected 5. table view component a) parametrized by the entity type b) row mapper component is entity-specific which allows complex customizations c) sorting capabilities (via arrows in the table header) 6. dialog for managing column visibility and order a) based on openshift console solution [3] and PatternFly 4 demo[4] b) toggle column visibility (except identity columns) c) re-order columns using drag and drop Updated libraries: 1. Downgrade @testing-library/react to ^12.0 since ^13.0 requires react >= 18. 2. Bump @openshift/dynamic-plugin-sdk* to 1.0.0 [1] https://www.patternfly.org/v4/guidelines/filters/#filter-group [2] https://www.patternfly.org/v4/guidelines/filters#attribute-value-filter [3] https://github.com/openshift/console/blob/release-4.12/frontend/public/components/modals/column-management-modal.tsx [4] https://www.patternfly.org/v4/components/table/react-demos#column-management-with-draggable Reference-Url: https://github.com/oVirt/ovirt-web-ui/pull/1600 Reference-Url: https://github.com/oVirt/ovirt-web-ui/pull/1592 Signed-off-by: Radoslaw Szwajkowski --- jest.config.ts | 8 + .../en/plugin__forklift-console-plugin.json | 17 + package.json | 7 +- src/__mocks__/react-i18next.ts | 3 + .../Filter/AttributeValueFilter.tsx | 94 +++ src/components/Filter/EnumFilter.tsx | 158 +++++ src/components/Filter/FreetextFilter.tsx | 53 ++ src/components/Filter/PrimaryFilters.tsx | 48 ++ .../Filter/__tests__/matchers.test.ts | 103 +++ .../Filter/__tests__/useUnique.test.tsx | 51 ++ src/components/Filter/helpers.ts | 9 + src/components/Filter/index.ts | 5 + src/components/Filter/matchers.ts | 74 +++ src/components/Filter/types.ts | 60 ++ src/components/StandardPage/ResultStates.tsx | 69 ++ src/components/StandardPage/StandardPage.tsx | 164 +++++ .../__tests__/ResultStates.test.tsx | 19 + .../__tests__/StandardPage.test.tsx | 91 +++ .../__snapshots__/ResultStates.test.tsx.snap | 55 ++ .../__snapshots__/StandardPage.test.tsx.snap | 600 ++++++++++++++++++ .../StandardPage/__tests__/useFields.test.tsx | 50 ++ src/components/StandardPage/index.ts | 2 + src/components/StandardPage/useFields.tsx | 28 + .../TableView/ManageColumnsToolbar.tsx | 191 ++++++ src/components/TableView/TableView.tsx | 96 +++ .../TableView/__tests__/sort.test.ts | 137 ++++ .../TableView/__tests__/useSort.test.tsx | 56 ++ src/components/TableView/index.ts | 3 + src/components/TableView/sort.ts | 112 ++++ src/components/TableView/types.ts | 12 + src/components/types.ts | 18 + src/utils/constants.ts | 3 + src/utils/helpers.ts | 7 + yarn.lock | 70 +- 34 files changed, 2451 insertions(+), 22 deletions(-) create mode 100644 src/components/Filter/AttributeValueFilter.tsx create mode 100644 src/components/Filter/EnumFilter.tsx create mode 100644 src/components/Filter/FreetextFilter.tsx create mode 100644 src/components/Filter/PrimaryFilters.tsx create mode 100644 src/components/Filter/__tests__/matchers.test.ts create mode 100644 src/components/Filter/__tests__/useUnique.test.tsx create mode 100644 src/components/Filter/helpers.ts create mode 100644 src/components/Filter/index.ts create mode 100644 src/components/Filter/matchers.ts create mode 100644 src/components/Filter/types.ts create mode 100644 src/components/StandardPage/ResultStates.tsx create mode 100644 src/components/StandardPage/StandardPage.tsx create mode 100644 src/components/StandardPage/__tests__/ResultStates.test.tsx create mode 100644 src/components/StandardPage/__tests__/StandardPage.test.tsx create mode 100644 src/components/StandardPage/__tests__/__snapshots__/ResultStates.test.tsx.snap create mode 100644 src/components/StandardPage/__tests__/__snapshots__/StandardPage.test.tsx.snap create mode 100644 src/components/StandardPage/__tests__/useFields.test.tsx create mode 100644 src/components/StandardPage/index.ts create mode 100644 src/components/StandardPage/useFields.tsx create mode 100644 src/components/TableView/ManageColumnsToolbar.tsx create mode 100644 src/components/TableView/TableView.tsx create mode 100644 src/components/TableView/__tests__/sort.test.ts create mode 100644 src/components/TableView/__tests__/useSort.test.tsx create mode 100644 src/components/TableView/index.ts create mode 100644 src/components/TableView/sort.ts create mode 100644 src/components/TableView/types.ts create mode 100644 src/components/types.ts create mode 100644 src/utils/constants.ts create mode 100644 src/utils/helpers.ts diff --git a/jest.config.ts b/jest.config.ts index b62c9d184..91e27f93a 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -12,14 +12,22 @@ const config: Config.InitialOptions = { testMatch: ['/src/**/*.{test,spec}.{js,jsx,ts,tsx}'], moduleNameMapper: { '\\.(css|less|scss|svg)$': '/src/__mocks__/dummy.ts', + '@console/*': '/src/__mocks__/dummy.ts', + 'react-i18next': '/src/__mocks__/react-i18next.ts', ...pathsToModuleNameMapper(compilerOptions.paths, { prefix: '/', }), }, + modulePaths: [''], moduleFileExtensions: ['js', 'jsx', 'ts', 'tsx'], transform: { '^.+\\.[t|j]sx?$': 'ts-jest', }, transformIgnorePatterns: ['/node_modules/(?!(@patternfly|@openshift-console\\S*?)/.*)'], + globals: { + 'ts-jest': { + isolatedModules: true, + }, + }, }; export default config; diff --git a/locales/en/plugin__forklift-console-plugin.json b/locales/en/plugin__forklift-console-plugin.json index 200685fe7..e0815e3ad 100644 --- a/locales/en/plugin__forklift-console-plugin.json +++ b/locales/en/plugin__forklift-console-plugin.json @@ -1,6 +1,23 @@ { + "Cancel": "Cancel", + "Clear all filters": "Clear all filters", + "Filter by name": "Filter by name", + "Filter by namespace": "Filter by namespace", + "Loading": "Loading", + "Manage Columns": "Manage Columns", "Mappings for VM Import": "Mappings for VM Import", + "Name": "Name", + "Namespace": "Namespace", + "No results found": "No results found", + "No results match the filter criteria. Clear all filters and try again.": "No results match the filter criteria. Clear all filters and try again.", "Plans for VM Import": "Plans for VM Import", "Providers for VM Import": "Providers for VM Import", + "Reorder": "Reorder", + "Restore default colums": "Restore default colums", + "Save": "Save", + "Select Filter": "Select Filter", + "Selected columns will be displayed in the table.": "Selected columns will be displayed in the table.", + "Table column management": "Table column management", + "Unable to retrieve data": "Unable to retrieve data", "Virtualization": "Virtualization" } diff --git a/package.json b/package.json index a7120f64f..edfd644b9 100644 --- a/package.json +++ b/package.json @@ -29,12 +29,13 @@ "@migtools/lib-ui": "^8.4.1", "@openshift-console/dynamic-plugin-sdk": "0.0.17", "@openshift-console/dynamic-plugin-sdk-webpack": "0.0.8", - "@openshift/dynamic-plugin-sdk": "~1.0.0-alpha15", - "@openshift/dynamic-plugin-sdk-webpack": "~1.0.0-alpha10", + "@openshift/dynamic-plugin-sdk": "~1.0.0", + "@openshift/dynamic-plugin-sdk-webpack": "~1.0.0", "@patternfly/react-core": "4.175.4", "@patternfly/react-table": "^4.93.1", "@testing-library/jest-dom": "^5.16.5", - "@testing-library/react": "^13.3.0", + "@testing-library/react": "^12.0.0", + "@testing-library/react-hooks": "^8.0.1", "@testing-library/user-event": "^14.4.3", "@types/ejs": "^3.0.6", "@types/express": "^4.17.12", diff --git a/src/__mocks__/react-i18next.ts b/src/__mocks__/react-i18next.ts index bee10325a..5b298425e 100644 --- a/src/__mocks__/react-i18next.ts +++ b/src/__mocks__/react-i18next.ts @@ -5,4 +5,7 @@ */ export const useTranslation = () => ({ t: (k: string) => k, + i18n: { + resolvedLanguage: 'en', + }, }); diff --git a/src/components/Filter/AttributeValueFilter.tsx b/src/components/Filter/AttributeValueFilter.tsx new file mode 100644 index 000000000..4a92cb6be --- /dev/null +++ b/src/components/Filter/AttributeValueFilter.tsx @@ -0,0 +1,94 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'src/internal/i18n'; + +import { + Select, + SelectOption, + SelectOptionObject, + SelectVariant, + ToolbarGroup, + ToolbarItem, +} from '@patternfly/react-core'; + +import { MetaFilterProps } from './types'; + +interface IdOption extends SelectOptionObject { + id: string; +} + +const toSelectOption = (id: string, label: string): IdOption => ({ + id, + compareTo: (other: IdOption): boolean => id === other?.id, + toString: () => label, +}); + +/** + * Implementation of PatternFly 4 attribute-value filter pattern. + * Accepts any filter matching FilterTypeProps interface. + * + * @see FilterTypeProps + */ +export const AttributeValueFilter = ({ + selectedFilters, + onFilterUpdate, + fieldFilters, + supportedFilterTypes = {}, +}: MetaFilterProps) => { + const { t } = useTranslation(); + const [currentFilter, setCurrentFilter] = useState(fieldFilters?.[0]); + const [expanded, setExpanded] = useState(false); + + const selectOptionToFilter = (selectedId) => + fieldFilters.find(({ fieldId }) => fieldId === selectedId) ?? currentFilter; + + const onFilterTypeSelect = (event, value, isPlaceholder) => { + if (!isPlaceholder) { + setCurrentFilter(selectOptionToFilter(value?.id)); + setExpanded(!expanded); + } + }; + + return ( + + + + + + {fieldFilters.map(({ fieldId: id, toFieldLabel, filterDef: filter }) => { + const FilterType = supportedFilterTypes[filter.type]; + return ( + FilterType && ( + + onFilterUpdate({ + ...selectedFilters, + [id]: values, + }) + } + placeholderLabel={filter.toPlaceholderLabel(t)} + selectedFilters={selectedFilters[id] ?? []} + showFilter={currentFilter?.fieldId === id} + title={filter?.toLabel?.(t) ?? toFieldLabel(t)} + supportedValues={filter.values} + /> + ) + ); + })} + + ); +}; diff --git a/src/components/Filter/EnumFilter.tsx b/src/components/Filter/EnumFilter.tsx new file mode 100644 index 000000000..829dba4f1 --- /dev/null +++ b/src/components/Filter/EnumFilter.tsx @@ -0,0 +1,158 @@ +import React, { useMemo, useState } from 'react'; +import { useTranslation } from 'src/internal/i18n'; +import { localeCompare } from 'src/utils/helpers'; + +import { + Select, + SelectOption, + SelectOptionObject, + SelectVariant, + ToolbarChip, + ToolbarFilter, +} from '@patternfly/react-core'; + +import { FilterTypeProps } from './types'; + +/** + * One label may map to multiple enum ids due to translation or by design (i.e. "Unknown") + * Aggregate enums with the same label and display them as a single option. + * + * @returns { uniqueEnumLabels, onUniqueFilterUpdate, selectedUniqueEnumLabels }; + */ +export const useUnique = ({ + supportedEnumValues, + onSelectedEnumIdsChange, + selectedEnumIds, +}: { + supportedEnumValues: { + id: string; + toLabel(t: (key: string) => string): string; + }[]; + onSelectedEnumIdsChange: (values: string[]) => void; + selectedEnumIds: string[]; +}): { + uniqueEnumLabels: string[]; + onUniqueFilterUpdate: (selectedEnumLabels: string[]) => void; + selectedUniqueEnumLabels: string[]; +} => { + const { t, i18n } = useTranslation(); + + const translatedEnums = useMemo( + () => + supportedEnumValues.map((it) => ({ + // fallback to ID + label: it.toLabel?.(t) ?? it.id, + id: it.id, + })), + + [supportedEnumValues], + ); + + // group filters with the same label + const labelToIds = useMemo( + () => + translatedEnums.reduce((acc, { label, id }) => { + acc[label] = [...(acc?.[label] ?? []), id]; + return acc; + }, {}), + [translatedEnums], + ); + + // for easy reverse lookup + const idToLabel = useMemo( + () => + translatedEnums.reduce((acc, { label, id }) => { + acc[id] = label; + return acc; + }, {}), + [translatedEnums], + ); + + const uniqueEnumLabels = useMemo( + () => + Object.entries(labelToIds) + .map(([label]) => label) + .sort((a, b) => localeCompare(a, b, i18n.resolvedLanguage)), + [labelToIds], + ); + + const onUniqueFilterUpdate = useMemo( + () => + (labels: string[]): void => + onSelectedEnumIdsChange(labels.flatMap((label) => labelToIds[label] ?? [])), + [onSelectedEnumIdsChange, labelToIds], + ); + + const selectedUniqueEnumLabels = useMemo( + () => [...new Set(selectedEnumIds.map((id) => idToLabel[id]).filter(Boolean))] as string[], + [selectedEnumIds, idToLabel], + ); + + return { uniqueEnumLabels, onUniqueFilterUpdate, selectedUniqueEnumLabels }; +}; + +/** + * Select one or many enum values from the list. + * FilterTypeProps are interpeted as follows: + * 1) selectedFilters - selected enum IDs (not translated constant identifiers) + * 2) onFilterUpdate - accepts the list of selected enum IDs + * 3) supportedValues - supported enum values + */ +export const EnumFilter = ({ + selectedFilters: selectedEnumIds = [], + onFilterUpdate: onSelectedEnumIdsChange, + supportedValues: supportedEnumValues = [], + title, + placeholderLabel, + filterId, + showFilter, +}: FilterTypeProps) => { + const [isExpanded, setExpanded] = useState(false); + const { uniqueEnumLabels, onUniqueFilterUpdate, selectedUniqueEnumLabels } = useUnique({ + supportedEnumValues, + onSelectedEnumIdsChange, + selectedEnumIds, + }); + + const deleteFilter = (label: string | ToolbarChip | SelectOptionObject): void => + onUniqueFilterUpdate(selectedUniqueEnumLabels.filter((filterLabel) => filterLabel !== label)); + + const hasFilter = (label: string | SelectOptionObject): boolean => + !!selectedUniqueEnumLabels.find((filterLabel) => filterLabel === label); + + const addFilter = (label: string | SelectOptionObject): void => { + if (typeof label === 'string') { + onUniqueFilterUpdate([...selectedUniqueEnumLabels, label]); + } + }; + + return ( + deleteFilter(option)} + deleteChipGroup={() => onUniqueFilterUpdate([])} + categoryName={title} + showToolbarItem={showFilter} + > + + + ); +}; diff --git a/src/components/Filter/FreetextFilter.tsx b/src/components/Filter/FreetextFilter.tsx new file mode 100644 index 000000000..6b607ea37 --- /dev/null +++ b/src/components/Filter/FreetextFilter.tsx @@ -0,0 +1,53 @@ +import React, { useState } from 'react'; + +import { InputGroup, SearchInput, ToolbarFilter } from '@patternfly/react-core'; + +import { FilterTypeProps } from './types'; + +/** + * Filter using text provided by the user. + * Text needs to be submitted/confirmed by clicking search button or by pressing Enter key. + * + * FilterTypeProps are interpeted as follows: + * 1) selectedFilters - list of strings provided by the user + * 2) onFilterUpdate - accepts the list of strings (from user input) + */ +export const FreetextFilter = ({ + filterId, + selectedFilters, + onFilterUpdate, + title, + showFilter, + placeholderLabel, +}: FilterTypeProps) => { + const [inputValue, setInputValue] = useState(''); + const onTextInput = (): void => { + if (!inputValue || selectedFilters.includes(inputValue)) { + return; + } + onFilterUpdate([...selectedFilters, inputValue]); + setInputValue(''); + }; + return ( + + onFilterUpdate(selectedFilters?.filter((value) => value !== option) ?? []) + } + deleteChipGroup={() => onFilterUpdate([])} + categoryName={title} + showToolbarItem={showFilter} + > + + setInputValue('')} + /> + + + ); +}; diff --git a/src/components/Filter/PrimaryFilters.tsx b/src/components/Filter/PrimaryFilters.tsx new file mode 100644 index 000000000..5f36f2a63 --- /dev/null +++ b/src/components/Filter/PrimaryFilters.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { useTranslation } from 'src/internal/i18n'; + +import { ToolbarGroup } from '@patternfly/react-core'; + +import { MetaFilterProps } from './types'; + +/** + * Implementation of PatternFly 4 filter group pattern. + * Extended to use any filter matching FilterTypeProps interface(not only enum based selection). + * + * @see FilterTypeProps + */ +export const PrimaryFilters = ({ + selectedFilters, + onFilterUpdate, + fieldFilters, + supportedFilterTypes = {}, +}: MetaFilterProps) => { + const { t } = useTranslation(); + + return ( + + {fieldFilters.map(({ fieldId: id, toFieldLabel, filterDef: filter }) => { + const FilterType = supportedFilterTypes[filter.type]; + return ( + FilterType && ( + + onFilterUpdate({ + ...selectedFilters, + [id]: values, + }) + } + placeholderLabel={filter.toPlaceholderLabel(t)} + selectedFilters={selectedFilters[id] ?? []} + title={filter?.toLabel?.(t) ?? toFieldLabel(t)} + showFilter={true} + supportedValues={filter.values} + /> + ) + ); + })} + + ); +}; diff --git a/src/components/Filter/__tests__/matchers.test.ts b/src/components/Filter/__tests__/matchers.test.ts new file mode 100644 index 000000000..9c096bb1e --- /dev/null +++ b/src/components/Filter/__tests__/matchers.test.ts @@ -0,0 +1,103 @@ +import { NAME, NAMESPACE } from 'src/utils/constants'; + +import { createMatcher, createMetaMatcher, freetextMatcher } from '../matchers'; + +const matchFreetext = ( + selectedFilters, + filter = { + type: 'freetext', + toPlaceholderLabel: () => NAME, + }, +) => + createMatcher({ + selectedFilters, + ...freetextMatcher, + fields: [ + { + id: NAME, + toLabel: () => NAME, + filter, + }, + ], + }); + +describe('standard matchers', () => { + it('matches the entity by single letter', () => { + const match = matchFreetext({ [NAME]: ['b', 'c', 'd'] }); + expect(match({ [NAME]: 'bar' })).toBeTruthy(); + }); + + it('is not matching the entity because the value does not include selected substrings', () => { + const match = matchFreetext({ [NAME]: ['b', 'c', 'd'] }); + expect(match({ [NAME]: 'foo' })).toBeFalsy(); + }); + + it('is not matching the entity because entity has no such field', () => { + const match = matchFreetext({ [NAME]: ['b', 'c', 'd'] }); + expect(match({})).toBeFalsy(); + }); + + it('is not matching the entity because entity is nullish', () => { + const match = matchFreetext({ [NAME]: ['b', 'c', 'd'] }); + expect(match(null)).toBeFalsy(); + }); + + it('matches the entity because column has no filter', () => { + const match = matchFreetext({ [NAME]: ['b', 'c', 'd'] }, null); + expect(match({ [NAME]: 'bar' })).toBeTruthy(); + }); + + it('matches the entity because column has a different filter', () => { + const match = matchFreetext( + { [NAME]: ['b', 'c', 'd'] }, + { + type: 'enum', + toPlaceholderLabel: () => NAME, + }, + ); + expect(match({ [NAME]: 'bar' })).toBeTruthy(); + }); + + it('matches the entity because no filters are selected', () => { + const match = matchFreetext({}); + expect(match({ [NAME]: 'bar' })).toBeTruthy(); + }); +}); + +const matchBothFieldsFreetext = () => + createMetaMatcher( + { + [NAME]: ['oo'], + [NAMESPACE]: ['ar'], + }, + [ + { + id: NAME, + toLabel: () => NAME, + filter: { + type: 'freetext', + toPlaceholderLabel: () => NAME, + }, + }, + { + id: NAMESPACE, + toLabel: () => NAMESPACE, + filter: { + type: 'freetext', + toPlaceholderLabel: () => NAMESPACE, + }, + }, + ], + ); + +describe('meta matchers', () => { + it('matches the entity on both columns', () => { + const matchBoth = matchBothFieldsFreetext(); + expect(matchBoth({ [NAME]: 'foo', [NAMESPACE]: 'bar' })).toBeTruthy(); + }); + + it('is not matching because of namespace column', () => { + const matchBoth = matchBothFieldsFreetext(); + expect(matchBoth({ [NAME]: 'foo', [NAMESPACE]: 'foo' })).toBeFalsy(); + }); +}); diff --git a/src/components/Filter/__tests__/useUnique.test.tsx b/src/components/Filter/__tests__/useUnique.test.tsx new file mode 100644 index 000000000..af9cd6735 --- /dev/null +++ b/src/components/Filter/__tests__/useUnique.test.tsx @@ -0,0 +1,51 @@ +import { cleanup, renderHook } from '@testing-library/react-hooks'; + +import { useUnique } from '../EnumFilter'; + +afterEach(cleanup); + +const testEnumValues = [ + { id: 'True', toLabel: () => 'TrueTranslated' }, + { id: 'AlsoTrue', toLabel: () => 'TrueTranslated' }, + { id: 'False', toLabel: () => 'FalseTranslated' }, +]; + +describe('aggregate filters with the same labels', () => { + it('selects an aggregated filter(no other filters selected)', () => { + const onSelectedEnumIdsChange = jest.fn(); + const { + result: { + current: { uniqueEnumLabels, onUniqueFilterUpdate, selectedUniqueEnumLabels }, + }, + } = renderHook(() => + useUnique({ + supportedEnumValues: testEnumValues, + onSelectedEnumIdsChange, + selectedEnumIds: [], + }), + ); + expect(uniqueEnumLabels).toStrictEqual(['FalseTranslated', 'TrueTranslated']); + expect(selectedUniqueEnumLabels).toStrictEqual([]); + onUniqueFilterUpdate(['TrueTranslated']); + expect(onSelectedEnumIdsChange).toBeCalledWith(['True', 'AlsoTrue']); + }); + + it('selects a standard filter(one filter already selected)', () => { + const onSelectedEnumIdsChange = jest.fn(); + const { + result: { + current: { uniqueEnumLabels, onUniqueFilterUpdate, selectedUniqueEnumLabels }, + }, + } = renderHook(() => + useUnique({ + supportedEnumValues: testEnumValues, + onSelectedEnumIdsChange, + selectedEnumIds: ['True', 'AlsoTrue'], + }), + ); + expect(uniqueEnumLabels).toStrictEqual(['FalseTranslated', 'TrueTranslated']); + expect(selectedUniqueEnumLabels).toStrictEqual(['TrueTranslated']); + onUniqueFilterUpdate(['TrueTranslated', 'FalseTranslated']); + expect(onSelectedEnumIdsChange).toBeCalledWith(['True', 'AlsoTrue', 'False']); + }); +}); diff --git a/src/components/Filter/helpers.ts b/src/components/Filter/helpers.ts new file mode 100644 index 000000000..5a15b3b21 --- /dev/null +++ b/src/components/Filter/helpers.ts @@ -0,0 +1,9 @@ +import { Field } from '../types'; + +import { FieldFilter } from './types'; + +export const toFieldFilter = ({ + id: fieldId, + toLabel: toFieldLabel, + filter: filterDef, +}: Field): FieldFilter => ({ fieldId, toFieldLabel, filterDef }); diff --git a/src/components/Filter/index.ts b/src/components/Filter/index.ts new file mode 100644 index 000000000..e9a8744ef --- /dev/null +++ b/src/components/Filter/index.ts @@ -0,0 +1,5 @@ +export * from './AttributeValueFilter'; +export * from './EnumFilter'; +export * from './FreetextFilter'; +export * from './matchers'; +export * from './PrimaryFilters'; diff --git a/src/components/Filter/matchers.ts b/src/components/Filter/matchers.ts new file mode 100644 index 000000000..0f524bd9f --- /dev/null +++ b/src/components/Filter/matchers.ts @@ -0,0 +1,74 @@ +import { Field } from '../types'; + +/** + * Create matcher for one filter type. + * Features: + * 1) fields that use different filter type are skipped + * 2) positive match if there are no selected filter values or no fields support the chosen filter type (vacuous truth) + * 3) all fields need to pass the test (AND condition) + * 4) a field is accepted if at least one filter value returns positive match (OR condtion) + */ +export const createMatcher = + ({ + selectedFilters, + filterType, + matchValue, + fields, + }: { + selectedFilters: { [id: string]: string[] }; + filterType: string; + matchValue: (value: string) => (filterValue: string) => boolean; + fields: Field[]; + }) => + (entity): boolean => + fields + .filter(({ filter }) => filter?.type === filterType) + .filter(({ id }) => selectedFilters[id] && selectedFilters[id]?.length) + .map(({ id }) => ({ + value: entity?.[id], + filters: selectedFilters[id], + })) + .map(({ value, filters }) => filters.some(matchValue(value))) + .every(Boolean); + +/** + * The value is accepted if it contains the filter as substring. + */ +export const freetextMatcher = { + filterType: 'freetext', + matchValue: (value: string) => (filter: string) => value?.includes(filter), +}; + +/** + * The value is accepted if it matches exactly the filter (both are constants) + */ +const enumMatcher = { + filterType: 'enum', + matchValue: (value: string) => (filter: string) => value === filter, +}; + +const defaultValueMatchers = [freetextMatcher, enumMatcher]; + +/** + * Create matcher for multiple filter types. + * Positive match requires that all sub-matchers (per filter type) return positve result (AND condtion). + * No filter values or no filter types also yields a positve result(vacuous truth). + * + * @see createMatcher + */ +export const createMetaMatcher = + ( + selectedFilters: { [id: string]: string[] }, + fields: Field[], + valueMatchers: { + filterType: string; + matchValue: (value: string) => (filter: string) => boolean; + }[] = defaultValueMatchers, + ) => + (entity): boolean => + valueMatchers + .map(({ filterType, matchValue }) => + createMatcher({ selectedFilters, filterType, matchValue, fields }), + ) + .map((match) => match(entity)) + .every(Boolean); diff --git a/src/components/Filter/types.ts b/src/components/Filter/types.ts new file mode 100644 index 000000000..36c512f0b --- /dev/null +++ b/src/components/Filter/types.ts @@ -0,0 +1,60 @@ +export interface FilterDef { + type: string; + toPlaceholderLabel(t: (key: string) => string): string; + values?: { id: string; toLabel(t: (key: string) => string): string }[]; + toLabel?(t: (key: string) => string): string; + primary?: boolean; +} + +/** + * Components implmeneting this interface may be added to complex filters. + * + * @see PrimaryFilters + * @see AttributeValueFilter + */ +export interface FilterTypeProps { + filterId: string; + /** + * Interpration of filter values is filter specific. + * @param values list of selected filter values + */ + onFilterUpdate(values: string[]); + placeholderLabel: string; + /** + * List of selected values (filter specific). + */ + selectedFilters: string[]; + showFilter: boolean; + title: string; + /** + * (Optional) List of supported values (if limited) + */ + supportedValues?: { + id: string; + toLabel(t: (key: string) => string): string; + }[]; +} + +/** + * Field ID to filter defintion mapping. + */ +export type FieldFilter = { + fieldId: string; + toFieldLabel(t: (key: string) => string): string; + filterDef: FilterDef; +}; + +export interface MetaFilterProps { + /** + * Field-to-filter values mapping where: + * 1) key: ID of the field that the filter should apply to. + * 2) value: a string array with filter-specific interpretation. + * i.e. { NAME: ["foo", "bar"]} + */ + selectedFilters: { [id: string]: string[] }; + fieldFilters: FieldFilter[]; + onFilterUpdate(filters: { [id: string]: string[] }): void; + supportedFilterTypes: { + [type: string]: (props: FilterTypeProps) => JSX.Element; + }; +} diff --git a/src/components/StandardPage/ResultStates.tsx b/src/components/StandardPage/ResultStates.tsx new file mode 100644 index 000000000..747bb2b72 --- /dev/null +++ b/src/components/StandardPage/ResultStates.tsx @@ -0,0 +1,69 @@ +import React from 'react'; +import { useTranslation } from 'src/internal/i18n'; + +import { + Button, + EmptyState, + EmptyStateBody, + EmptyStateIcon, + EmptyStatePrimary, + Spinner, + Title, +} from '@patternfly/react-core'; +import { ExclamationCircleIcon, SearchIcon } from '@patternfly/react-icons'; + +export const ErrorState = () => { + const { t } = useTranslation(); + return ( + + + + {t('Unable to retrieve data')} + + + ); +}; + +export const Loading = () => { + const { t } = useTranslation(); + return ( + + + + {t('Loading')} + + + ); +}; + +export const NoResultsFound = () => { + const { t } = useTranslation(); + return ( + + + + {t('No results found')} + + + ); +}; + +export const NoResultsMatchFilter = ({ clearAllFilters }: { clearAllFilters: () => void }) => { + const { t } = useTranslation(); + return ( + + + + {t('No results found')} + + + {t('No results match the filter criteria. Clear all filters and try again.')} + + + + + + ); +}; diff --git a/src/components/StandardPage/StandardPage.tsx b/src/components/StandardPage/StandardPage.tsx new file mode 100644 index 000000000..35184108d --- /dev/null +++ b/src/components/StandardPage/StandardPage.tsx @@ -0,0 +1,164 @@ +import React, { useMemo, useState } from 'react'; +import { + AttributeValueFilter, + createMetaMatcher, + EnumFilter, + FreetextFilter, + PrimaryFilters, +} from 'src/components/Filter'; +import { FilterTypeProps } from 'src/components/Filter/types'; +import { ManageColumnsToolbar, RowProps, TableView } from 'src/components/TableView'; +import { Field } from 'src/components/types'; +import { useTranslation } from 'src/internal/i18n'; + +import { + Level, + LevelItem, + PageSection, + Title, + Toolbar, + ToolbarContent, + ToolbarToggleGroup, +} from '@patternfly/react-core'; +import { FilterIcon } from '@patternfly/react-icons'; + +import { toFieldFilter } from '../Filter/helpers'; + +import { ErrorState, Loading, NoResultsFound, NoResultsMatchFilter } from './ResultStates'; +import { useFields } from './useFields'; + +/** + * @param T type to be displayed in the list + */ +export interface StandardPageProps { + /** + * Component displayed close to the top right corner. By convention it's usually "add" or "create" button. + */ + addButton?: JSX.Element; + /** + * Source of data. Tuple should consist of: + * @param T[] array of items + * @param loading flag that indicates if loading is in progress + * @param error flag indicating error + */ + dataSource: [T[], boolean, boolean]; + /** + * Fields to be displayed (from the provided type T). + */ + fieldsMetadata: Field[]; + /** + * Currently used namespace. + */ + namespace: string; + /** + * Maps entity of type T to a table row. + */ + RowMapper: React.FunctionComponent>; + /** + * Filter types that will be used. + * Default are: EnumFilter and FreetextFilter + */ + supportedFilters?: { + [type: string]: (props: FilterTypeProps) => JSX.Element; + }; + title: string; + /** + * Information displayed when the data source returned no items. + */ + customNoResultsFound?: JSX.Element; + /** + * Information displayed when the data source returned some items but due to applied filters no items can be shown. + */ + customNoResultsMatchFilter?: JSX.Element; +} + +/** + * Standard list page. + */ +export function StandardPage({ + namespace, + dataSource: [flattenData, loaded, error], + RowMapper, + title, + addButton, + fieldsMetadata, + supportedFilters = { + enum: EnumFilter, + freetext: FreetextFilter, + }, + customNoResultsFound, + customNoResultsMatchFilter, +}: StandardPageProps) { + const { t } = useTranslation(); + const [selectedFilters, setSelectedFilters] = useState({}); + const clearAllFilters = () => setSelectedFilters({}); + const [fields, setFields] = useFields(namespace, fieldsMetadata); + + const filteredData = useMemo( + () => flattenData.filter(createMetaMatcher(selectedFilters, fields)), + [flattenData, selectedFilters, fields], + ); + + const errorFetchingData = loaded && error; + const noResults = loaded && !error && flattenData.length == 0; + const noMatchingResults = loaded && !error && filteredData.length === 0 && flattenData.length > 0; + + return ( + <> + + + + {title} + + {addButton && {addButton}} + + + + + + } breakpoint="xl"> + field.filter?.primary).map(toFieldFilter)} + onFilterUpdate={setSelectedFilters} + selectedFilters={selectedFilters} + supportedFilterTypes={supportedFilters} + /> + filter && !filter.primary) + .map(toFieldFilter)} + onFilterUpdate={setSelectedFilters} + selectedFilters={selectedFilters} + supportedFilterTypes={supportedFilters} + /> + + + + + + entities={filteredData} + allColumns={fields} + visibleColumns={fields.filter(({ isVisible }) => isVisible)} + aria-label={title} + Row={RowMapper} + > + {[ + !loaded && , + errorFetchingData && , + noResults && (customNoResultsFound ?? ), + noMatchingResults && + (customNoResultsMatchFilter ?? ( + + )), + ].filter(Boolean)} + + + + ); +} + +export default StandardPage; diff --git a/src/components/StandardPage/__tests__/ResultStates.test.tsx b/src/components/StandardPage/__tests__/ResultStates.test.tsx new file mode 100644 index 000000000..f7c1762a2 --- /dev/null +++ b/src/components/StandardPage/__tests__/ResultStates.test.tsx @@ -0,0 +1,19 @@ +import * as React from 'react'; + +import { cleanup, fireEvent, render } from '@testing-library/react'; + +import { NoResultsMatchFilter } from '../ResultStates'; + +afterEach(cleanup); + +test('NoResultsMatchFilter', async () => { + const clear = jest.fn(); + const { asFragment, getByRole } = render(); + const firstRender = asFragment(); + + expect(firstRender).toMatchSnapshot(); + + fireEvent.click(getByRole('button')); + + expect(clear).toBeCalledTimes(1); +}); diff --git a/src/components/StandardPage/__tests__/StandardPage.test.tsx b/src/components/StandardPage/__tests__/StandardPage.test.tsx new file mode 100644 index 000000000..f9d4f4059 --- /dev/null +++ b/src/components/StandardPage/__tests__/StandardPage.test.tsx @@ -0,0 +1,91 @@ +import * as React from 'react'; +import { RowProps } from 'src/components/TableView'; +import { NAME, NAMESPACE } from 'src/utils/constants'; + +import { Td, Tr } from '@patternfly/react-table'; +import { cleanup, render } from '@testing-library/react'; + +import { StandardPage } from '..'; + +afterEach(cleanup); + +function SimpleRow({ columns, entity }: RowProps) { + return ( + + {columns.map(({ id, toLabel }) => ( + s)}> + {String(entity[id] ?? '')} + + ))} + + ); +} + +test('empty result set returned, no filters ', async () => { + interface Named { + name: string; + } + const dataSource: [Named[], boolean, boolean] = [[], true, false]; + const { asFragment } = render( + + RowMapper={SimpleRow} + dataSource={dataSource} + fieldsMetadata={[ + { + id: NAME, + toLabel: (t) => t('Name'), + }, + ]} + namespace={undefined} + title="Simple" + >, + ); + expect(asFragment()).toMatchSnapshot(); +}); + +test('single entry returned, both filters ', async () => { + interface Simple { + name: string; + namespace: string; + } + const dataSource: [Simple[], boolean, boolean] = [ + [{ name: 'foo_name', namespace: 'bar_namespace' }], + true, + false, + ]; + const { asFragment, queryByText } = render( + + RowMapper={SimpleRow} + dataSource={dataSource} + fieldsMetadata={[ + { + id: NAME, + toLabel: (t) => t('Name'), + isIdentity: true, + isVisible: true, + filter: { + primary: true, + type: 'freetext', + toPlaceholderLabel: (t) => t('Filter by name'), + }, + }, + { + id: NAMESPACE, + toLabel: (t) => t('Namespace'), + isIdentity: true, + isVisible: true, + filter: { + type: 'freetext', + toPlaceholderLabel: (t) => t('Filter by namespace'), + }, + }, + ]} + namespace={'some_namespace'} + title="Simple" + >, + ); + expect(queryByText('foo_name')).toBeTruthy(); + // namespace column should be hidden + expect(queryByText('bar_namespace')).toBeFalsy(); + expect(asFragment()).toMatchSnapshot(); +}); diff --git a/src/components/StandardPage/__tests__/__snapshots__/ResultStates.test.tsx.snap b/src/components/StandardPage/__tests__/__snapshots__/ResultStates.test.tsx.snap new file mode 100644 index 000000000..a9c8f9399 --- /dev/null +++ b/src/components/StandardPage/__tests__/__snapshots__/ResultStates.test.tsx.snap @@ -0,0 +1,55 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`NoResultsMatchFilter 1`] = ` + +
+
+ +

+ No results found +

+
+ No results match the filter criteria. Clear all filters and try again. +
+
+ +
+
+
+
+`; diff --git a/src/components/StandardPage/__tests__/__snapshots__/StandardPage.test.tsx.snap b/src/components/StandardPage/__tests__/__snapshots__/StandardPage.test.tsx.snap new file mode 100644 index 000000000..fced9b688 --- /dev/null +++ b/src/components/StandardPage/__tests__/__snapshots__/StandardPage.test.tsx.snap @@ -0,0 +1,600 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`empty result set returned, no filters 1`] = ` + +
+
+
+

+ Simple +

+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+
+ + + + + + + + + + +
+
+
+
+ +

+ No results found +

+
+
+
+
+
+
+`; + +exports[`single entry returned, both filters 1`] = ` + +
+
+
+

+ Simple +

+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+ + + + + + +
+ +
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+ + + + + + +
+ +
+
+
+
+
+
+ +
+
+
+
+
+
+
+ + + + + + + + + + + + +
+ Name +
+ foo_name +
+
+
+`; diff --git a/src/components/StandardPage/__tests__/useFields.test.tsx b/src/components/StandardPage/__tests__/useFields.test.tsx new file mode 100644 index 000000000..54ae8cedf --- /dev/null +++ b/src/components/StandardPage/__tests__/useFields.test.tsx @@ -0,0 +1,50 @@ +import { NAME, NAMESPACE } from 'src/utils/constants'; + +import { cleanup, renderHook } from '@testing-library/react-hooks'; + +import { useFields } from '../useFields'; + +afterEach(cleanup); + +describe('manage fields', () => { + it('gets initialized from the defaults', () => { + const { + result: { + current: [fields], + }, + } = renderHook(() => useFields(undefined, [{ id: NAME, toLabel: () => '' }])); + expect(fields).toMatchObject([{ id: NAME, isVisible: false }]); + }); + it('enables namespace column visibility if no namespace is chosen', () => { + const { + result: { + current: [fields], + }, + } = renderHook(() => + useFields(undefined, [ + { id: NAME, toLabel: () => '', isVisible: true }, + { id: NAMESPACE, toLabel: () => '', isVisible: false }, + ]), + ); + expect(fields).toMatchObject([ + { id: NAME, isVisible: true }, + { id: NAMESPACE, isVisible: true }, + ]); + }); + it('disables namespace column visibility if a namespace is chosen', () => { + const { + result: { + current: [fields], + }, + } = renderHook(() => + useFields('some_namespace', [ + { id: NAME, toLabel: () => '', isVisible: true }, + { id: NAMESPACE, toLabel: () => '', isVisible: true }, + ]), + ); + expect(fields).toMatchObject([ + { id: NAME, isVisible: true }, + { id: NAMESPACE, isVisible: false }, + ]); + }); +}); diff --git a/src/components/StandardPage/index.ts b/src/components/StandardPage/index.ts new file mode 100644 index 000000000..22faab973 --- /dev/null +++ b/src/components/StandardPage/index.ts @@ -0,0 +1,2 @@ +export * from './ResultStates'; +export * from './StandardPage'; diff --git a/src/components/StandardPage/useFields.tsx b/src/components/StandardPage/useFields.tsx new file mode 100644 index 000000000..c40239fb3 --- /dev/null +++ b/src/components/StandardPage/useFields.tsx @@ -0,0 +1,28 @@ +import { useMemo, useState } from 'react'; +import { NAMESPACE } from 'src/utils/constants'; + +import { Field } from '../types'; + +/** + * Keeps the list of fields. Toggles the visibility of the namespace field based on currently used namspace. + * + * @param currentNamespace + * @param defaultFields used for initialization + * @returns [fields, setFields] + */ +export const useFields = ( + currentNamespace: string, + defaultFields: Field[], +): [Field[], React.Dispatch>] => { + const [fields, setFields] = useState(defaultFields.map((it) => ({ ...it }))); + const namespaceAwareFields: Field[] = useMemo( + () => + fields.map(({ id, isVisible = false, ...rest }) => ({ + id, + ...rest, + isVisible: id === NAMESPACE ? !currentNamespace : isVisible, + })), + [currentNamespace, fields], + ); + return [namespaceAwareFields, setFields]; +}; diff --git a/src/components/TableView/ManageColumnsToolbar.tsx b/src/components/TableView/ManageColumnsToolbar.tsx new file mode 100644 index 000000000..c23659aad --- /dev/null +++ b/src/components/TableView/ManageColumnsToolbar.tsx @@ -0,0 +1,191 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'src/internal/i18n'; + +import { + Button, + DataList, + DataListCell, + DataListCheck, + DataListControl, + DataListDragButton, + DataListItem, + DataListItemCells, + DataListItemRow, + DragDrop, + Draggable, + Droppable, + Modal, + Text, + TextContent, + TextVariants, + ToolbarItem, + Tooltip, +} from '@patternfly/react-core'; +import { ColumnsIcon } from '@patternfly/react-icons'; + +import { Field } from '../types'; + +export interface ManageColumnsToolbarProps { + /** Read only. State maintained by parent component. */ + columns: Field[]; + /** Read only. The defaults used for initialization.*/ + defaultColumns: Field[]; + /** Setter to modify state in the parent.*/ + setColumns(columns: Field[]): void; +} + +/** + * Toggles a modal dialog for managing columns visibility and order. + */ +export const ManageColumnsToolbar = ({ + columns, + setColumns, + defaultColumns, +}: ManageColumnsToolbarProps) => { + const { t } = useTranslation(); + const [isOpen, setIsOpen] = useState(false); + return ( + + + + + setIsOpen(false)} + description={t('Selected columns will be displayed in the table.')} + columns={columns} + onChange={setColumns} + defaultColumns={defaultColumns} + /> + + ); +}; + +interface ManagedColumnsProps { + showModal: boolean; + description: string; + onClose(): void; + /** Setter to modify state in the parent.*/ + onChange(colums: Field[]): void; + /** Read only. State maintained by parent component. */ + columns: Field[]; + /** Read only. The defaults used for initialization.*/ + defaultColumns: Field[]; +} +/** + * Modal dialog for managing columns. + * Supported features: + * 1. toggle column visibility (disabled for identity columns that should always be displayed to uniquely identify a row) + * 2. re-order the columns using drag and drop + */ +const ManageColumns = ({ + showModal, + description, + onClose, + onChange, + columns, + defaultColumns, +}: ManagedColumnsProps) => { + const { t } = useTranslation(); + const [editedColumns, setEditedColumns] = useState(columns); + const restoreDefaults = () => setEditedColumns([...defaultColumns]); + const onDrop = (source: { index: number }, dest: { index: number }) => { + const draggedItem = editedColumns[source?.index]; + const itemCurrentlyAtDestination = editedColumns[dest?.index]; + if (!draggedItem || !itemCurrentlyAtDestination) { + return false; + } + const base = editedColumns.filter(({ id }) => id !== draggedItem?.id); + setEditedColumns([ + ...base.slice(0, dest.index), + draggedItem, + ...base.slice(dest.index, base.length), + ]); + return true; + }; + const onSelect = (updatedId: string, updatedValue: boolean): void => { + setEditedColumns( + editedColumns.map(({ id, isVisible, ...rest }) => ({ + id, + ...rest, + isVisible: id === updatedId ? updatedValue : isVisible, + })), + ); + }; + const onSave = () => { + onChange(editedColumns); + onClose(); + }; + + return ( + + {description} + + } + onClose={onClose} + actions={[ + , + , + , + ]} + > + + + + {editedColumns.map(({ id, isVisible, isIdentity, toLabel }) => ( + + + + + + onSelect(id, value)} + otherControls + /> + + + {toLabel(t)} + , + ]} + /> + + + + ))} + + + + + ); +}; diff --git a/src/components/TableView/TableView.tsx b/src/components/TableView/TableView.tsx new file mode 100644 index 000000000..9cd4e5832 --- /dev/null +++ b/src/components/TableView/TableView.tsx @@ -0,0 +1,96 @@ +import React, { ReactNode } from 'react'; +import { useTranslation } from 'src/internal/i18n'; +import { UID } from 'src/utils/constants'; + +import { Bullseye } from '@patternfly/react-core'; +import { TableComposable, Tbody, Td, Th, Thead, Tr } from '@patternfly/react-table'; + +import { Field } from '../types'; + +import { buildSort, useSort } from './sort'; +import { RowProps } from './types'; + +/** + * Displays provided list of entities as table. Supported features: + * 1) sorting via arrow buttons in the header + * 2) stable row keys based on entity[uidFieldId] + * 3) (if present) display nodes passed via children prop instead of entities (extension point to handle empty state end related corner cases) + * + * @see useSort + */ +export function TableView({ + uidFieldId = UID, + allColumns, + visibleColumns, + entities, + 'aria-label': ariaLabel, + Row, + children, +}: TableViewProps) { + const { t } = useTranslation(); + + const [activeSort, setActiveSort, comparator] = useSort(allColumns); + + entities.sort(comparator); + + return ( + + + + {visibleColumns.map(({ id, toLabel, sortable }, columnIndex) => ( + + {toLabel(t)} + + ))} + + + + {children.length > 0 && ( + + + {children} + + + )} + {children.length === 0 && + entities.map((entity, index) => ( + + ))} + + + ); +} + +interface TableViewProps { + /** + * Both visible and hidden columns. Note that hidden columns are required for maintaining sort order. + */ + allColumns: Field[]; + visibleColumns: Field[]; + entities: T[]; + 'aria-label': string; + /** + * entity[uidFieldId] is used to uniquely identify a row. Defaults to UID column. + */ + uidFieldId?: string; + /** + * Maps entities to table rows. + */ + Row(props: RowProps): JSX.Element; + /** + * Nodes to be displayed instead of the entities. + * Extension point to handle empty state and related cases. + */ + children?: ReactNode[]; +} diff --git a/src/components/TableView/__tests__/sort.test.ts b/src/components/TableView/__tests__/sort.test.ts new file mode 100644 index 000000000..1a3c58a03 --- /dev/null +++ b/src/components/TableView/__tests__/sort.test.ts @@ -0,0 +1,137 @@ +import { NAME, NAMESPACE } from 'src/utils/constants'; + +import { SortByDirection } from '@patternfly/react-table'; + +import { buildSort, compareWith, universalComparator } from '../sort'; + +describe('universal comparator', () => { + it('works for nullish data', () => { + expect(universalComparator(null, undefined, 'en')).toBe(0); + }); + it('uses numeric option', () => { + expect(universalComparator('a10', 'a5', 'en')).toBeGreaterThan(0); + }); +}); + +describe('compareWith comparator factory', () => { + it('works without custom comparator', () => { + expect( + compareWith( + { id: NAME, isAsc: true, toLabel: () => NAME }, + 'en', + undefined, + )({ name: 'name_a' }, { name: 'name_b' }), + ).toBeLessThan(0); + }); + + it('works for nullish entities', () => { + expect( + compareWith({ id: NAME, isAsc: true, toLabel: () => NAME }, 'en', undefined)(null, undefined), + ).toBe(0); + }); + + it('treats all values equal if sortType is not defined', () => { + expect( + compareWith({ id: undefined, isAsc: false, toLabel: undefined }, 'en', undefined)('a', 'b'), + ).toBe(0); + }); + + it('reverts sorting order based on sortType.isAsc', () => { + expect( + compareWith( + { id: NAME, isAsc: false, toLabel: () => NAME }, + 'en', + undefined, + )({ name: 'name_a' }, { name: 'name_b' }), + ).toBeGreaterThan(0); + }); + + it('uses custom field comparator if provided', () => { + expect( + compareWith( + { id: NAME, isAsc: true, toLabel: () => NAME }, + 'en', + (a, b) => a.localeCompare(b), // no numeric + )({ name: 'a10' }, { name: 'a5' }), + ).toBeLessThan(0); + }); +}); + +describe('buildSort factory', () => { + const NameColumn = { id: NAME, toLabel: () => NAME }; + const NamespaceColumn = { id: NAMESPACE, toLabel: () => NAMESPACE }; + it('sorts ascending', () => { + const setActiveSort = jest.fn(); + const { sortBy, onSort, columnIndex } = buildSort({ + columnIndex: 0, + columns: [NameColumn, NamespaceColumn], + activeSort: { + id: NAME, + isAsc: true, + toLabel: () => NAME, + }, + setActiveSort, + }); + expect(columnIndex).toBe(0); + expect(sortBy).toStrictEqual({ index: 0, direction: 'asc' }); + onSort(undefined, 1, SortByDirection.asc, undefined); + expect(setActiveSort).toBeCalledWith({ + isAsc: true, + id: NAMESPACE, + toLabel: NamespaceColumn.toLabel, + }); + }); + + it('sorts descending', () => { + const setActiveSort = jest.fn(); + const { sortBy, onSort, columnIndex } = buildSort({ + columnIndex: 1, + columns: [NameColumn, NamespaceColumn], + activeSort: { + id: NAME, + isAsc: false, + toLabel: () => NAME, + }, + setActiveSort, + }); + expect(columnIndex).toBe(1); + expect(sortBy).toStrictEqual({ index: 0, direction: 'desc' }); + onSort(undefined, 1, SortByDirection.desc, undefined); + expect(setActiveSort).toBeCalledWith({ + isAsc: false, + id: NAMESPACE, + toLabel: NamespaceColumn.toLabel, + }); + }); + + it('shows no sorting if activeSort column cannot be found', () => { + const setActiveSort = jest.fn(); + const { sortBy } = buildSort({ + columnIndex: 1, + columns: [NameColumn, NamespaceColumn], + activeSort: { + id: undefined, + isAsc: undefined, + toLabel: undefined, + }, + setActiveSort, + }); + expect(sortBy).toStrictEqual({ index: undefined, direction: 'desc' }); + }); + + it('skips sort callbeck if column cannot be found', () => { + const setActiveSort = jest.fn(); + const { onSort } = buildSort({ + columnIndex: 1, + columns: [NameColumn, NamespaceColumn], + activeSort: { + id: NAME, + isAsc: false, + toLabel: () => NAME, + }, + setActiveSort, + }); + onSort(undefined, 100, SortByDirection.desc, undefined); + expect(setActiveSort).toBeCalledTimes(0); + }); +}); diff --git a/src/components/TableView/__tests__/useSort.test.tsx b/src/components/TableView/__tests__/useSort.test.tsx new file mode 100644 index 000000000..a10680971 --- /dev/null +++ b/src/components/TableView/__tests__/useSort.test.tsx @@ -0,0 +1,56 @@ +import { NAME } from 'src/utils/constants'; + +import { cleanup, renderHook } from '@testing-library/react-hooks'; + +import { useSort } from '../sort'; + +afterEach(cleanup); + +describe('useSort hook', () => { + const NameColumn = { id: NAME, toLabel: () => NAME, isIdentity: true }; + it('uses first identity column as default sort', () => { + const { + result: { + current: [activeSort], + }, + } = renderHook(() => useSort([{ id: 'Foo', toLabel: () => '' }, NameColumn])); + + expect(activeSort).toMatchObject({ + id: NAME, + toLabel: NameColumn.toLabel, + isAsc: false, + }); + }); + + it('uses first column as default sort if there is no identity', () => { + const { + result: { + current: [activeSort], + }, + } = renderHook(() => useSort([{ id: 'Foo', toLabel: undefined }])); + + expect(activeSort).toMatchObject({ + id: 'Foo', + toLabel: undefined, + isAsc: false, + }); + }); + + it('works if no columns(the sort is not defined)', () => { + const { + result: { + current: [activeSort, setActiveSort, comparator], + }, + } = renderHook(() => useSort([])); + + expect(activeSort).toMatchObject({ + id: undefined, + toLabel: undefined, + isAsc: false, + }); + + expect(setActiveSort).toBeDefined(); + + expect(comparator('a', 'b')).toBe(0); + }); +}); diff --git a/src/components/TableView/index.ts b/src/components/TableView/index.ts new file mode 100644 index 000000000..3931d636e --- /dev/null +++ b/src/components/TableView/index.ts @@ -0,0 +1,3 @@ +export * from './ManageColumnsToolbar'; +export * from './TableView'; +export * from './types'; diff --git a/src/components/TableView/sort.ts b/src/components/TableView/sort.ts new file mode 100644 index 000000000..a2629d943 --- /dev/null +++ b/src/components/TableView/sort.ts @@ -0,0 +1,112 @@ +import { useMemo, useState } from 'react'; +import { useTranslation } from 'src/internal/i18n'; +import { localeCompare } from 'src/utils/helpers'; + +import { ThSortType } from '@patternfly/react-table/dist/esm/components/Table/base'; + +import { Field, SortType } from '../types'; + +import { Column } from './types'; + +/** + * Compares all types by converting them to string. + * Nullish entities are converted to empty string. + * @see localeCompare + * @param locale to be used by string comparator + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const universalComparator = (a: any, b: any, locale: string) => + localeCompare(String(a ?? ''), String(b ?? ''), locale); + +/** + * Creates a comparator function based on provided current sort definition. + * If there is no sort defined then comparator considers all entities equal. + * + * @see universalComparator + * @param currentSort + * @param locale defaults to "en" + * @param fieldComparator (optional) custom field comparator. Defaults to universal string based comparator. + * @returns comparator function + */ +export function compareWith( + currentSort: SortType, + locale: string, + fieldComparator: (a, b, locale: string) => number, +): (a, b) => number { + return (a, b) => { + if (!currentSort?.id) { + return 0; + } + const comparator = fieldComparator ?? universalComparator; + const compareValue = comparator(a?.[currentSort.id], b?.[currentSort.id], locale ?? 'en'); + return currentSort.isAsc ? compareValue : -compareValue; + }; +} + +/** + * Hook for maintaining sort state. Supported features: + * 1) by default sort by the first identity column or by the first column available if there is no identity column + * 2) build comparator based on the current active sort definition + * + * @param fields (read only) field metadata + * @returns [activeSort, setActiveSort, comparator] + */ +export const useSort = ( + fields: Field[], +): [SortType, (sort: SortType) => void, (a, b) => number] => { + const { i18n } = useTranslation(); + + // by default sort by the first identity column (if any) + const [firstField] = [...fields].sort( + (a, b) => Number(Boolean(b.isIdentity)) - Number(Boolean(a.isIdentity)), + ); + + const [activeSort, setActiveSort] = useState({ + isAsc: false, + id: firstField?.id, + toLabel: firstField?.toLabel, + }); + + const comparator = useMemo( + () => + compareWith( + activeSort, + i18n.resolvedLanguage, + fields.find((field) => field.id === activeSort.id)?.comparator, + ), + [fields], + ); + + return [activeSort, setActiveSort, comparator]; +}; + +/** + * Builds table specific sort definition based on provided active sort definition. + * @see ThSortType + */ +export const buildSort = ({ + columnIndex, + columns, + activeSort, + setActiveSort, +}: { + columnIndex: number; + columns: Column[]; + activeSort: SortType; + setActiveSort: (sort: SortType) => void; +}): ThSortType => ({ + sortBy: { + index: + columns.find(({ id }) => id === activeSort.id) && + columns.findIndex(({ id }) => id === activeSort.id), + direction: activeSort.isAsc ? 'asc' : 'desc', + }, + onSort: (_event, index, direction) => { + columns[index]?.id && + setActiveSort({ + isAsc: direction === 'asc', + ...columns[index], + }); + }, + columnIndex, +}); diff --git a/src/components/TableView/types.ts b/src/components/TableView/types.ts new file mode 100644 index 000000000..da520ee99 --- /dev/null +++ b/src/components/TableView/types.ts @@ -0,0 +1,12 @@ +import { Field } from '../types'; + +export interface Column { + id: string; + toLabel(t: (key: string) => string): string; + sortable?: boolean; +} + +export interface RowProps { + columns: Field[]; + entity: T; +} diff --git a/src/components/types.ts b/src/components/types.ts new file mode 100644 index 000000000..bc14e90cf --- /dev/null +++ b/src/components/types.ts @@ -0,0 +1,18 @@ +import { FilterDef } from './Filter/types'; + +export interface SortType { + isAsc: boolean; + id: string; + toLabel(t: (key: string) => string): string; +} + +export interface Field { + id: string; + toLabel(t: (key: string) => string): string; + isVisible?: boolean; + isIdentity?: boolean; + sortable?: boolean; + filter?: FilterDef; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + comparator?: (a: any, b: any, locale: string) => number; +} diff --git a/src/utils/constants.ts b/src/utils/constants.ts new file mode 100644 index 000000000..9125e9645 --- /dev/null +++ b/src/utils/constants.ts @@ -0,0 +1,3 @@ +export const NAME = 'name'; +export const UID = 'uid'; +export const NAMESPACE = 'namespace'; diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts new file mode 100644 index 000000000..f5dc9cfaf --- /dev/null +++ b/src/utils/helpers.ts @@ -0,0 +1,7 @@ +/** + * Uses native string localCompare method with numeric option enabled. + * + * @param locale to be used by string comparator + */ +export const localeCompare = (a: string, b: string, locale: string): number => + a.localeCompare(b, locale, { numeric: true }); diff --git a/yarn.lock b/yarn.lock index b2d770612..16ef81be2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -690,21 +690,22 @@ typesafe-actions "^4.2.1" whatwg-fetch "2.x" -"@openshift/dynamic-plugin-sdk-webpack@~1.0.0-alpha10": - version "1.0.0-alpha10" - resolved "https://registry.yarnpkg.com/@openshift/dynamic-plugin-sdk-webpack/-/dynamic-plugin-sdk-webpack-1.0.0-alpha10.tgz#16b23b5318a564fb5a545a248776db16c6a9c7d9" - integrity sha512-Mc+bxD2ccGI145WIyNBtLGMGqjoI/BtE/tox0/wBQCP8/cs8xT7kWq99g8qOP1bleKDN1qzgw4DW8fRKopBLXA== +"@openshift/dynamic-plugin-sdk-webpack@~1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@openshift/dynamic-plugin-sdk-webpack/-/dynamic-plugin-sdk-webpack-1.0.0.tgz#665fcda7601bee1c0718b7f8b75126c061e18c32" + integrity sha512-yEoqGSDLfaqGYKZseYMY/HvWzQIfUrQpQXjB+aMYv4hxxwF0dw3DAQYmZIauItJObGvMyqlxlCnTRMZCNtIcxg== dependencies: glob "^7.2.0" lodash "^4.17.21" yup "^0.32.11" -"@openshift/dynamic-plugin-sdk@~1.0.0-alpha15": - version "1.0.0-alpha15" - resolved "https://registry.yarnpkg.com/@openshift/dynamic-plugin-sdk/-/dynamic-plugin-sdk-1.0.0-alpha15.tgz#34183954d524b86c910035938efe7cf0df0bc854" - integrity sha512-d3+Toj20VxnWbdlE/tLES+Jiyzt4IL//81+f4iUJmXLHsYZtV4vVheGuobyL3qu6ilKjNF1A1juxUW4E6/bSNA== +"@openshift/dynamic-plugin-sdk@~1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@openshift/dynamic-plugin-sdk/-/dynamic-plugin-sdk-1.0.0.tgz#7f05f7023a3eff7973067541fa0c23dcbcefd765" + integrity sha512-lkb2ZlbFXziPzVWOykfSAzeA1siGxMlzIDzd1wBh0RikjVr7a07U6gjrOcByaeC45AdUc2SNGRTERWoobj4THQ== dependencies: lodash-es "^4.17.21" + semver "^7.3.7" yup "^0.32.11" "@patternfly/patternfly@4.122.2": @@ -857,10 +858,10 @@ dependencies: "@sinonjs/commons" "^1.7.0" -"@testing-library/dom@^8.5.0": - version "8.17.1" - resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-8.17.1.tgz#2d7af4ff6dad8d837630fecd08835aee08320ad7" - integrity sha512-KnH2MnJUzmFNPW6RIKfd+zf2Wue8mEKX0M3cpX6aKl5ZXrJM1/c/Pc8c2xDNYQCnJO48Sm5ITbMXgqTr3h4jxQ== +"@testing-library/dom@^8.0.0": + version "8.19.0" + resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-8.19.0.tgz#bd3f83c217ebac16694329e413d9ad5fdcfd785f" + integrity sha512-6YWYPPpxG3e/xOo6HIWwB/58HukkwIVTOaZ0VwdMVjhRUX/01E4FtQbck9GazOOj7MXHc5RBzMrU86iBJHbI+A== dependencies: "@babel/code-frame" "^7.10.4" "@babel/runtime" "^7.12.5" @@ -886,14 +887,22 @@ lodash "^4.17.15" redent "^3.0.0" -"@testing-library/react@^13.3.0": - version "13.3.0" - resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-13.3.0.tgz#bf298bfbc5589326bbcc8052b211f3bb097a97c5" - integrity sha512-DB79aA426+deFgGSjnf5grczDPiL4taK3hFaa+M5q7q20Kcve9eQottOG5kZ74KEr55v0tU2CQormSSDK87zYQ== +"@testing-library/react-hooks@^8.0.1": + version "8.0.1" + resolved "https://registry.yarnpkg.com/@testing-library/react-hooks/-/react-hooks-8.0.1.tgz#0924bbd5b55e0c0c0502d1754657ada66947ca12" + integrity sha512-Aqhl2IVmLt8IovEVarNDFuJDVWVvhnr9/GCU6UUnrYXwgDFF9h2L2o2P9KBni1AST5sT6riAyoukFLyjQUgD/g== dependencies: "@babel/runtime" "^7.12.5" - "@testing-library/dom" "^8.5.0" - "@types/react-dom" "^18.0.0" + react-error-boundary "^3.1.0" + +"@testing-library/react@^12.0.0": + version "12.1.5" + resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-12.1.5.tgz#bb248f72f02a5ac9d949dea07279095fa577963b" + integrity sha512-OfTXCJUFgjd/digLUuPxa0+/3ZxsQmE7ub9kcbW/wi96Bh3o/p5vrETcBGfP17NWPGqeYYl5LTRpwyGoMC4ysg== + dependencies: + "@babel/runtime" "^7.12.5" + "@testing-library/dom" "^8.0.0" + "@types/react-dom" "<18.0.0" "@testing-library/user-event@^14.4.3": version "14.4.3" @@ -1190,7 +1199,14 @@ resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc" integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw== -"@types/react-dom@^18.0.0", "@types/react-dom@^18.0.6": +"@types/react-dom@<18.0.0": + version "17.0.17" + resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-17.0.17.tgz#2e3743277a793a96a99f1bf87614598289da68a1" + integrity sha512-VjnqEmqGnasQKV0CWLevqMTXBYG9GbwuE6x3VetERLh0cq2LTptFE73MrQi2S7GkKXCf2GgwItB/melLnxfnsg== + dependencies: + "@types/react" "^17" + +"@types/react-dom@^18.0.6": version "18.0.6" resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.0.6.tgz#36652900024842b74607a17786b6662dd1e103a1" integrity sha512-/5OFZgfIPSwy+YuIBP/FgJnQnsxhZhjjrnxudMddeblOouIodEQ75X14Rr4wGSG/bknL+Omy9iWlLo1u/9GzAA== @@ -1237,6 +1253,15 @@ "@types/scheduler" "*" csstype "^3.0.2" +"@types/react@^17": + version "17.0.50" + resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.50.tgz#39abb4f7098f546cfcd6b51207c90c4295ee81fc" + integrity sha512-ZCBHzpDb5skMnc1zFXAXnL3l1FAdi+xZvwxK+PkglMmBrwjpp9nKaWuEvrGnSifCJmBFGxZOOFuwC6KH/s0NuA== + dependencies: + "@types/prop-types" "*" + "@types/scheduler" "*" + csstype "^3.0.2" + "@types/retry@0.12.0": version "0.12.0" resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.0.tgz#2b35eccfcee7d38cd72ad99232fbd58bffb3c84d" @@ -7041,6 +7066,13 @@ react-dropzone@9.0.0: prop-types "^15.6.2" prop-types-extra "^1.1.0" +react-error-boundary@^3.1.0: + version "3.1.4" + resolved "https://registry.yarnpkg.com/react-error-boundary/-/react-error-boundary-3.1.4.tgz#255db92b23197108757a888b01e5b729919abde0" + integrity sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA== + dependencies: + "@babel/runtime" "^7.12.5" + react-fast-compare@^3.1.1: version "3.2.0" resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.0.tgz#641a9da81b6a6320f270e89724fb45a0b39e43bb"