Skip to content

Commit 8094d85

Browse files
authored
Merge pull request #64 from rszwajko/providersV4
Provide reusable components for creating standard list page
2 parents 1128dc0 + 17e0060 commit 8094d85

34 files changed

+2451
-22
lines changed

jest.config.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,22 @@ const config: Config.InitialOptions = {
1212
testMatch: ['<rootDir>/src/**/*.{test,spec}.{js,jsx,ts,tsx}'],
1313
moduleNameMapper: {
1414
'\\.(css|less|scss|svg)$': '<rootDir>/src/__mocks__/dummy.ts',
15+
'@console/*': '<rootDir>/src/__mocks__/dummy.ts',
16+
'react-i18next': '<rootDir>/src/__mocks__/react-i18next.ts',
1517
...pathsToModuleNameMapper(compilerOptions.paths, {
1618
prefix: '<rootDir>/',
1719
}),
1820
},
21+
modulePaths: ['<rootDir>'],
1922
moduleFileExtensions: ['js', 'jsx', 'ts', 'tsx'],
2023
transform: {
2124
'^.+\\.[t|j]sx?$': 'ts-jest',
2225
},
2326
transformIgnorePatterns: ['<rootDir>/node_modules/(?!(@patternfly|@openshift-console\\S*?)/.*)'],
27+
globals: {
28+
'ts-jest': {
29+
isolatedModules: true,
30+
},
31+
},
2432
};
2533
export default config;
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,23 @@
11
{
2+
"Cancel": "Cancel",
3+
"Clear all filters": "Clear all filters",
4+
"Filter by name": "Filter by name",
5+
"Filter by namespace": "Filter by namespace",
6+
"Loading": "Loading",
7+
"Manage Columns": "Manage Columns",
28
"Mappings for VM Import": "Mappings for VM Import",
9+
"Name": "Name",
10+
"Namespace": "Namespace",
11+
"No results found": "No results found",
12+
"No results match the filter criteria. Clear all filters and try again.": "No results match the filter criteria. Clear all filters and try again.",
313
"Plans for VM Import": "Plans for VM Import",
414
"Providers for VM Import": "Providers for VM Import",
15+
"Reorder": "Reorder",
16+
"Restore default colums": "Restore default colums",
17+
"Save": "Save",
18+
"Select Filter": "Select Filter",
19+
"Selected columns will be displayed in the table.": "Selected columns will be displayed in the table.",
20+
"Table column management": "Table column management",
21+
"Unable to retrieve data": "Unable to retrieve data",
522
"Virtualization": "Virtualization"
623
}

package.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,13 @@
2929
"@migtools/lib-ui": "^8.4.1",
3030
"@openshift-console/dynamic-plugin-sdk": "0.0.17",
3131
"@openshift-console/dynamic-plugin-sdk-webpack": "0.0.8",
32-
"@openshift/dynamic-plugin-sdk": "~1.0.0-alpha15",
33-
"@openshift/dynamic-plugin-sdk-webpack": "~1.0.0-alpha10",
32+
"@openshift/dynamic-plugin-sdk": "~1.0.0",
33+
"@openshift/dynamic-plugin-sdk-webpack": "~1.0.0",
3434
"@patternfly/react-core": "4.175.4",
3535
"@patternfly/react-table": "^4.93.1",
3636
"@testing-library/jest-dom": "^5.16.5",
37-
"@testing-library/react": "^13.3.0",
37+
"@testing-library/react": "^12.0.0",
38+
"@testing-library/react-hooks": "^8.0.1",
3839
"@testing-library/user-event": "^14.4.3",
3940
"@types/ejs": "^3.0.6",
4041
"@types/express": "^4.17.12",

src/__mocks__/react-i18next.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,7 @@
55
*/
66
export const useTranslation = () => ({
77
t: (k: string) => k,
8+
i18n: {
9+
resolvedLanguage: 'en',
10+
},
811
});
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import React, { useState } from 'react';
2+
import { useTranslation } from 'src/internal/i18n';
3+
4+
import {
5+
Select,
6+
SelectOption,
7+
SelectOptionObject,
8+
SelectVariant,
9+
ToolbarGroup,
10+
ToolbarItem,
11+
} from '@patternfly/react-core';
12+
13+
import { MetaFilterProps } from './types';
14+
15+
interface IdOption extends SelectOptionObject {
16+
id: string;
17+
}
18+
19+
const toSelectOption = (id: string, label: string): IdOption => ({
20+
id,
21+
compareTo: (other: IdOption): boolean => id === other?.id,
22+
toString: () => label,
23+
});
24+
25+
/**
26+
* Implementation of PatternFly 4 attribute-value filter pattern.
27+
* Accepts any filter matching FilterTypeProps interface.
28+
*
29+
* @see FilterTypeProps
30+
*/
31+
export const AttributeValueFilter = ({
32+
selectedFilters,
33+
onFilterUpdate,
34+
fieldFilters,
35+
supportedFilterTypes = {},
36+
}: MetaFilterProps) => {
37+
const { t } = useTranslation();
38+
const [currentFilter, setCurrentFilter] = useState(fieldFilters?.[0]);
39+
const [expanded, setExpanded] = useState(false);
40+
41+
const selectOptionToFilter = (selectedId) =>
42+
fieldFilters.find(({ fieldId }) => fieldId === selectedId) ?? currentFilter;
43+
44+
const onFilterTypeSelect = (event, value, isPlaceholder) => {
45+
if (!isPlaceholder) {
46+
setCurrentFilter(selectOptionToFilter(value?.id));
47+
setExpanded(!expanded);
48+
}
49+
};
50+
51+
return (
52+
<ToolbarGroup variant="filter-group">
53+
<ToolbarItem>
54+
<Select
55+
onSelect={onFilterTypeSelect}
56+
onToggle={setExpanded}
57+
isOpen={expanded}
58+
variant={SelectVariant.single}
59+
aria-label={t('Select Filter')}
60+
selections={
61+
currentFilter && toSelectOption(currentFilter.fieldId, currentFilter.toFieldLabel(t))
62+
}
63+
>
64+
{fieldFilters.map(({ fieldId, toFieldLabel }) => (
65+
<SelectOption key={fieldId} value={toSelectOption(fieldId, toFieldLabel(t))} />
66+
))}
67+
</Select>
68+
</ToolbarItem>
69+
70+
{fieldFilters.map(({ fieldId: id, toFieldLabel, filterDef: filter }) => {
71+
const FilterType = supportedFilterTypes[filter.type];
72+
return (
73+
FilterType && (
74+
<FilterType
75+
key={id}
76+
filterId={id}
77+
onFilterUpdate={(values) =>
78+
onFilterUpdate({
79+
...selectedFilters,
80+
[id]: values,
81+
})
82+
}
83+
placeholderLabel={filter.toPlaceholderLabel(t)}
84+
selectedFilters={selectedFilters[id] ?? []}
85+
showFilter={currentFilter?.fieldId === id}
86+
title={filter?.toLabel?.(t) ?? toFieldLabel(t)}
87+
supportedValues={filter.values}
88+
/>
89+
)
90+
);
91+
})}
92+
</ToolbarGroup>
93+
);
94+
};

src/components/Filter/EnumFilter.tsx

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import React, { useMemo, useState } from 'react';
2+
import { useTranslation } from 'src/internal/i18n';
3+
import { localeCompare } from 'src/utils/helpers';
4+
5+
import {
6+
Select,
7+
SelectOption,
8+
SelectOptionObject,
9+
SelectVariant,
10+
ToolbarChip,
11+
ToolbarFilter,
12+
} from '@patternfly/react-core';
13+
14+
import { FilterTypeProps } from './types';
15+
16+
/**
17+
* One label may map to multiple enum ids due to translation or by design (i.e. "Unknown")
18+
* Aggregate enums with the same label and display them as a single option.
19+
*
20+
* @returns { uniqueEnumLabels, onUniqueFilterUpdate, selectedUniqueEnumLabels };
21+
*/
22+
export const useUnique = ({
23+
supportedEnumValues,
24+
onSelectedEnumIdsChange,
25+
selectedEnumIds,
26+
}: {
27+
supportedEnumValues: {
28+
id: string;
29+
toLabel(t: (key: string) => string): string;
30+
}[];
31+
onSelectedEnumIdsChange: (values: string[]) => void;
32+
selectedEnumIds: string[];
33+
}): {
34+
uniqueEnumLabels: string[];
35+
onUniqueFilterUpdate: (selectedEnumLabels: string[]) => void;
36+
selectedUniqueEnumLabels: string[];
37+
} => {
38+
const { t, i18n } = useTranslation();
39+
40+
const translatedEnums = useMemo(
41+
() =>
42+
supportedEnumValues.map((it) => ({
43+
// fallback to ID
44+
label: it.toLabel?.(t) ?? it.id,
45+
id: it.id,
46+
})),
47+
48+
[supportedEnumValues],
49+
);
50+
51+
// group filters with the same label
52+
const labelToIds = useMemo(
53+
() =>
54+
translatedEnums.reduce((acc, { label, id }) => {
55+
acc[label] = [...(acc?.[label] ?? []), id];
56+
return acc;
57+
}, {}),
58+
[translatedEnums],
59+
);
60+
61+
// for easy reverse lookup
62+
const idToLabel = useMemo(
63+
() =>
64+
translatedEnums.reduce((acc, { label, id }) => {
65+
acc[id] = label;
66+
return acc;
67+
}, {}),
68+
[translatedEnums],
69+
);
70+
71+
const uniqueEnumLabels = useMemo(
72+
() =>
73+
Object.entries(labelToIds)
74+
.map(([label]) => label)
75+
.sort((a, b) => localeCompare(a, b, i18n.resolvedLanguage)),
76+
[labelToIds],
77+
);
78+
79+
const onUniqueFilterUpdate = useMemo(
80+
() =>
81+
(labels: string[]): void =>
82+
onSelectedEnumIdsChange(labels.flatMap((label) => labelToIds[label] ?? [])),
83+
[onSelectedEnumIdsChange, labelToIds],
84+
);
85+
86+
const selectedUniqueEnumLabels = useMemo(
87+
() => [...new Set(selectedEnumIds.map((id) => idToLabel[id]).filter(Boolean))] as string[],
88+
[selectedEnumIds, idToLabel],
89+
);
90+
91+
return { uniqueEnumLabels, onUniqueFilterUpdate, selectedUniqueEnumLabels };
92+
};
93+
94+
/**
95+
* Select one or many enum values from the list.
96+
* FilterTypeProps are interpeted as follows:
97+
* 1) selectedFilters - selected enum IDs (not translated constant identifiers)
98+
* 2) onFilterUpdate - accepts the list of selected enum IDs
99+
* 3) supportedValues - supported enum values
100+
*/
101+
export const EnumFilter = ({
102+
selectedFilters: selectedEnumIds = [],
103+
onFilterUpdate: onSelectedEnumIdsChange,
104+
supportedValues: supportedEnumValues = [],
105+
title,
106+
placeholderLabel,
107+
filterId,
108+
showFilter,
109+
}: FilterTypeProps) => {
110+
const [isExpanded, setExpanded] = useState(false);
111+
const { uniqueEnumLabels, onUniqueFilterUpdate, selectedUniqueEnumLabels } = useUnique({
112+
supportedEnumValues,
113+
onSelectedEnumIdsChange,
114+
selectedEnumIds,
115+
});
116+
117+
const deleteFilter = (label: string | ToolbarChip | SelectOptionObject): void =>
118+
onUniqueFilterUpdate(selectedUniqueEnumLabels.filter((filterLabel) => filterLabel !== label));
119+
120+
const hasFilter = (label: string | SelectOptionObject): boolean =>
121+
!!selectedUniqueEnumLabels.find((filterLabel) => filterLabel === label);
122+
123+
const addFilter = (label: string | SelectOptionObject): void => {
124+
if (typeof label === 'string') {
125+
onUniqueFilterUpdate([...selectedUniqueEnumLabels, label]);
126+
}
127+
};
128+
129+
return (
130+
<ToolbarFilter
131+
key={filterId}
132+
chips={selectedUniqueEnumLabels}
133+
deleteChip={(category, option) => deleteFilter(option)}
134+
deleteChipGroup={() => onUniqueFilterUpdate([])}
135+
categoryName={title}
136+
showToolbarItem={showFilter}
137+
>
138+
<Select
139+
variant={SelectVariant.checkbox}
140+
aria-label={placeholderLabel}
141+
onSelect={(event, option, isPlaceholder) => {
142+
if (isPlaceholder) {
143+
return;
144+
}
145+
hasFilter(option) ? deleteFilter(option) : addFilter(option);
146+
}}
147+
selections={selectedUniqueEnumLabels}
148+
placeholderText={placeholderLabel}
149+
isOpen={isExpanded}
150+
onToggle={setExpanded}
151+
>
152+
{uniqueEnumLabels.map((label) => (
153+
<SelectOption key={label} value={label} />
154+
))}
155+
</Select>
156+
</ToolbarFilter>
157+
);
158+
};
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import React, { useState } from 'react';
2+
3+
import { InputGroup, SearchInput, ToolbarFilter } from '@patternfly/react-core';
4+
5+
import { FilterTypeProps } from './types';
6+
7+
/**
8+
* Filter using text provided by the user.
9+
* Text needs to be submitted/confirmed by clicking search button or by pressing Enter key.
10+
*
11+
* FilterTypeProps are interpeted as follows:
12+
* 1) selectedFilters - list of strings provided by the user
13+
* 2) onFilterUpdate - accepts the list of strings (from user input)
14+
*/
15+
export const FreetextFilter = ({
16+
filterId,
17+
selectedFilters,
18+
onFilterUpdate,
19+
title,
20+
showFilter,
21+
placeholderLabel,
22+
}: FilterTypeProps) => {
23+
const [inputValue, setInputValue] = useState('');
24+
const onTextInput = (): void => {
25+
if (!inputValue || selectedFilters.includes(inputValue)) {
26+
return;
27+
}
28+
onFilterUpdate([...selectedFilters, inputValue]);
29+
setInputValue('');
30+
};
31+
return (
32+
<ToolbarFilter
33+
key={filterId}
34+
chips={selectedFilters ?? []}
35+
deleteChip={(category, option) =>
36+
onFilterUpdate(selectedFilters?.filter((value) => value !== option) ?? [])
37+
}
38+
deleteChipGroup={() => onFilterUpdate([])}
39+
categoryName={title}
40+
showToolbarItem={showFilter}
41+
>
42+
<InputGroup>
43+
<SearchInput
44+
placeholder={placeholderLabel}
45+
value={inputValue}
46+
onChange={setInputValue}
47+
onSearch={onTextInput}
48+
onClear={() => setInputValue('')}
49+
/>
50+
</InputGroup>
51+
</ToolbarFilter>
52+
);
53+
};

0 commit comments

Comments
 (0)