Skip to content

Commit

Permalink
Merge pull request #64 from rszwajko/providersV4
Browse files Browse the repository at this point in the history
Provide reusable components for creating standard list page
  • Loading branch information
yaacov authored Oct 28, 2022
2 parents 1128dc0 + 17e0060 commit 8094d85
Show file tree
Hide file tree
Showing 34 changed files with 2,451 additions and 22 deletions.
8 changes: 8 additions & 0 deletions jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,22 @@ const config: Config.InitialOptions = {
testMatch: ['<rootDir>/src/**/*.{test,spec}.{js,jsx,ts,tsx}'],
moduleNameMapper: {
'\\.(css|less|scss|svg)$': '<rootDir>/src/__mocks__/dummy.ts',
'@console/*': '<rootDir>/src/__mocks__/dummy.ts',
'react-i18next': '<rootDir>/src/__mocks__/react-i18next.ts',
...pathsToModuleNameMapper(compilerOptions.paths, {
prefix: '<rootDir>/',
}),
},
modulePaths: ['<rootDir>'],
moduleFileExtensions: ['js', 'jsx', 'ts', 'tsx'],
transform: {
'^.+\\.[t|j]sx?$': 'ts-jest',
},
transformIgnorePatterns: ['<rootDir>/node_modules/(?!(@patternfly|@openshift-console\\S*?)/.*)'],
globals: {
'ts-jest': {
isolatedModules: true,
},
},
};
export default config;
17 changes: 17 additions & 0 deletions locales/en/plugin__forklift-console-plugin.json
Original file line number Diff line number Diff line change
@@ -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"
}
7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions src/__mocks__/react-i18next.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,7 @@
*/
export const useTranslation = () => ({
t: (k: string) => k,
i18n: {
resolvedLanguage: 'en',
},
});
94 changes: 94 additions & 0 deletions src/components/Filter/AttributeValueFilter.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<ToolbarGroup variant="filter-group">
<ToolbarItem>
<Select
onSelect={onFilterTypeSelect}
onToggle={setExpanded}
isOpen={expanded}
variant={SelectVariant.single}
aria-label={t('Select Filter')}
selections={
currentFilter && toSelectOption(currentFilter.fieldId, currentFilter.toFieldLabel(t))
}
>
{fieldFilters.map(({ fieldId, toFieldLabel }) => (
<SelectOption key={fieldId} value={toSelectOption(fieldId, toFieldLabel(t))} />
))}
</Select>
</ToolbarItem>

{fieldFilters.map(({ fieldId: id, toFieldLabel, filterDef: filter }) => {
const FilterType = supportedFilterTypes[filter.type];
return (
FilterType && (
<FilterType
key={id}
filterId={id}
onFilterUpdate={(values) =>
onFilterUpdate({
...selectedFilters,
[id]: values,
})
}
placeholderLabel={filter.toPlaceholderLabel(t)}
selectedFilters={selectedFilters[id] ?? []}
showFilter={currentFilter?.fieldId === id}
title={filter?.toLabel?.(t) ?? toFieldLabel(t)}
supportedValues={filter.values}
/>
)
);
})}
</ToolbarGroup>
);
};
158 changes: 158 additions & 0 deletions src/components/Filter/EnumFilter.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<ToolbarFilter
key={filterId}
chips={selectedUniqueEnumLabels}
deleteChip={(category, option) => deleteFilter(option)}
deleteChipGroup={() => onUniqueFilterUpdate([])}
categoryName={title}
showToolbarItem={showFilter}
>
<Select
variant={SelectVariant.checkbox}
aria-label={placeholderLabel}
onSelect={(event, option, isPlaceholder) => {
if (isPlaceholder) {
return;
}
hasFilter(option) ? deleteFilter(option) : addFilter(option);
}}
selections={selectedUniqueEnumLabels}
placeholderText={placeholderLabel}
isOpen={isExpanded}
onToggle={setExpanded}
>
{uniqueEnumLabels.map((label) => (
<SelectOption key={label} value={label} />
))}
</Select>
</ToolbarFilter>
);
};
53 changes: 53 additions & 0 deletions src/components/Filter/FreetextFilter.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<ToolbarFilter
key={filterId}
chips={selectedFilters ?? []}
deleteChip={(category, option) =>
onFilterUpdate(selectedFilters?.filter((value) => value !== option) ?? [])
}
deleteChipGroup={() => onFilterUpdate([])}
categoryName={title}
showToolbarItem={showFilter}
>
<InputGroup>
<SearchInput
placeholder={placeholderLabel}
value={inputValue}
onChange={setInputValue}
onSearch={onTextInput}
onClear={() => setInputValue('')}
/>
</InputGroup>
</ToolbarFilter>
);
};
Loading

0 comments on commit 8094d85

Please sign in to comment.