Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Provide reusable components for creating standard list page #64

Merged
merged 1 commit into from
Oct 28, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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