From 88dd7918c1c39c819c2c8755f5b0da883b03e067 Mon Sep 17 00:00:00 2001 From: Radoslaw Szwajkowski Date: Fri, 26 Aug 2022 11:48:30 +0200 Subject: [PATCH] Convert Providers screen to Console convention Reference-Url: https://github.com/oVirt/ovirt-web-ui/pull/1600 Reference-Url: https://github.com/oVirt/ovirt-web-ui/pull/1592 --- console-extensions.json | 32 +++- .../en/plugin__forklift-console-plugin.json | 11 +- package.json | 5 +- src/Providers/ProvidersPage.tsx | 148 ++++++++++++++++++ src/Providers/components/Filters.tsx | 104 ++++++++++++ src/Providers/components/FreetextFilter.tsx | 80 ++++++++++ src/Providers/components/ProviderRow.tsx | 51 ++++++ .../components/ProvidersTableView.tsx | 94 +++++++++++ src/Providers/components/shared.ts | 74 +++++++++ 9 files changed, 594 insertions(+), 5 deletions(-) create mode 100644 src/Providers/ProvidersPage.tsx create mode 100644 src/Providers/components/Filters.tsx create mode 100644 src/Providers/components/FreetextFilter.tsx create mode 100644 src/Providers/components/ProviderRow.tsx create mode 100644 src/Providers/components/ProvidersTableView.tsx create mode 100644 src/Providers/components/shared.ts diff --git a/console-extensions.json b/console-extensions.json index a501eb1c8..3fda1aba5 100644 --- a/console-extensions.json +++ b/console-extensions.json @@ -8,6 +8,36 @@ "insertAfter": "workloads" } }, + { + "type": "console.navigation/resource-ns", + "properties": { + "id": "providers", + "section": "migrationtoolkit", + "name": "%plugin__forklift-console-plugin~Providers%", + "model": { + "group": "forklift.konveyor.io", + "kind": "Provider", + "version": "v1beta1" + }, + "dataAttributes": { + "data-quickstart-id": "qs-nav-providers", + "data-test-id": "providers-nav-item" + } + } + }, + { + "type": "console.page/resource/list", + "properties": { + "component": { + "$codeRef": "ProvidersRes" + }, + "model": { + "group": "forklift.konveyor.io", + "kind": "Provider", + "version": "v1beta1" + } + } + }, { "type": "console.navigation/href", "properties": { @@ -113,4 +143,4 @@ "exact": true } } -] +] \ No newline at end of file diff --git a/locales/en/plugin__forklift-console-plugin.json b/locales/en/plugin__forklift-console-plugin.json index b42eab80a..08c596e45 100644 --- a/locales/en/plugin__forklift-console-plugin.json +++ b/locales/en/plugin__forklift-console-plugin.json @@ -1,7 +1,14 @@ { + "AddProvider": "Add Provider", + "Any": "Any", "Migration Toolkit": "Migration Toolkit", + "Name": "Name", "NetworkMaps": "NetworkMaps", "Plans": "Plans", "Providers": "Providers", - "StorageMaps": "StorageMaps" -} + "SelectFilter": "Select Filter", + "StorageMaps": "StorageMaps", + "Status": "Status", + "Type": "Type", + "Url": "Endpoint" +} \ No newline at end of file diff --git a/package.json b/package.json index 0627cc619..1d3d55fda 100644 --- a/package.json +++ b/package.json @@ -104,10 +104,11 @@ "MappingsPage": "./extensions/MappingsWrapper", "HostsPage": "./extensions/HostsPageWrapper", "PlanWizard": "./extensions/PlanWizardWrapper", - "VMMigrationDetails": "./extensions/VMMigrationDetailsWrapper" + "VMMigrationDetails": "./extensions/VMMigrationDetailsWrapper", + "ProvidersRes": "./Providers/ProvidersPage" }, "dependencies": { "@console/pluginAPI": "*" } } -} +} \ No newline at end of file diff --git a/src/Providers/ProvidersPage.tsx b/src/Providers/ProvidersPage.tsx new file mode 100644 index 000000000..c4f92ab67 --- /dev/null +++ b/src/Providers/ProvidersPage.tsx @@ -0,0 +1,148 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'src/internal/i18n'; +import { ProviderResource } from 'src/internal/k8s'; + +import { MOCK_CLUSTER_PROVIDERS } from '@app/queries/mocks/providers.mock'; +import { useK8sWatchResource } from '@openshift-console/dynamic-plugin-sdk'; +import { + Button, + Level, + LevelItem, + PageSection, + Title, + Toolbar, + ToolbarContent, +} from '@patternfly/react-core'; + +import Filters from './components/Filters'; +import ProvidersTableView from './components/ProvidersTableView'; +import { Field } from './components/shared'; +import { NAME, STATUS, TYPE, URL } from './components/shared'; + +const isMock = process.env.DATA_SOURCE === 'mock'; + +const useProviders = ({ kind, namespace }) => { + const [providers, loaded, error] = isMock + ? [MOCK_CLUSTER_PROVIDERS, true, false] + : useK8sWatchResource({ + kind, + isList: true, + namespaced: true, + namespace, + }); + + // const inventoryProvidersQuery = useInventoryProvidersQuery(); + // providers.map(p => enhanceWithInventory(inventoryProvidersQuery)) + + // const allErrorTitles = [ + // 'Cannot load providers from cluster API', + // 'Cannot load providers from inventory API', + // ]; + + return [providers, loaded, error]; +}; + +const fields: Field[] = [ + { + id: NAME, + tKey: 'plugin__forklift-console-plugin~Name', + filter: { + type: 'freetext', + placeholderKey: 'plugin__forklift-console-plugin~Any', + }, + sortable: true, + from: (provider) => provider?.metadata?.name ?? '', + }, + { + id: STATUS, + tKey: 'plugin__forklift-console-plugin~Status', + filter: { + type: 'enum', + placeholderKey: 'plugin__forklift-console-plugin~Any', + values: [{ id: '', tKey: '' }], + }, + sortable: true, + from: (provider) => provider?.status?.conditions?.[0]?.type ?? '', + }, + { + id: URL, + tKey: 'plugin__forklift-console-plugin~Url', + filter: { + type: 'freetext', + placeholderKey: 'plugin__forklift-console-plugin~Any', + }, + sortable: true, + from: (provider) => provider?.spec?.url ?? '', + }, + { + id: TYPE, + tKey: 'plugin__forklift-console-plugin~Type', + filter: { + type: 'enum', + placeholderKey: 'plugin__forklift-console-plugin~Any', + values: [{ id: '', tKey: '' }], + }, + sortable: true, + from: (provider) => provider?.spec?.type ?? '', + }, +]; + +export const ProvidersPage: React.FunctionComponent = ({ + namespace, + kind, +}) => { + const { t } = useTranslation(); + const [providers, loaded, error] = useProviders({ kind, namespace }); + const [selectedFilters, setSelectedFilters] = useState([]); + + console.error('Providers', providers, loaded, error, fields); + + return ( + <> + + + + {t('Providers')} + + + + + + + + setSelectedFilters([])}> + + ({ + id, + tKey, + filter, + }))} + onFilterUpdate={setSelectedFilters} + selectedFilters={selectedFilters} + /> + + + + {loaded && error && } + {!loaded && } + {loaded && !error && ( + + )} + + + ); +}; + +const Errors = () => <> Erorrs!; + +const Loading = () => <> Loading!; + +type ProvidersPageProps = { + kind: string; + namespace: string; +}; + +export default ProvidersPage; diff --git a/src/Providers/components/Filters.tsx b/src/Providers/components/Filters.tsx new file mode 100644 index 000000000..3a60a29c7 --- /dev/null +++ b/src/Providers/components/Filters.tsx @@ -0,0 +1,104 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'src/internal/i18n'; + +import { + Select, + SelectOption, + SelectOptionObject, + SelectVariant, + ToolbarGroup, + ToolbarItem, + ToolbarToggleGroup, +} from '@patternfly/react-core'; +import { FilterIcon } from '@patternfly/react-icons/dist/esm/icons'; + +import FreetextFilter from './FreetextFilter'; +import { EnumFilterDef, FreetextFilterDef } from './shared'; + +interface IdOption extends SelectOptionObject { + id: string; +} + +const toSelectOption = (id: string, label: string): IdOption => ({ + id, + compareTo: (other: IdOption): boolean => id === other?.id, + toString: () => label, +}); + +const Filters: React.FunctionComponent = ({ + selectedFilters, + onFilterUpdate, + filterTypes, +}) => { + const { t } = useTranslation(); + const [currentFilterType, setCurrentFilterType] = useState(filterTypes[0]); + const [expanded, setExpanded] = useState(false); + + // const nameFilter = filterTypes.find(({ id }) => id === textBasedFilterId); + const selectOptionToFilter = (selectedId) => + filterTypes.find(({ id }) => id === selectedId) ?? currentFilterType; + + const onFilterTypeSelect = (event, value, isPlaceholder) => { + if (!isPlaceholder) { + setCurrentFilterType(selectOptionToFilter(value?.id)); + setExpanded(!expanded); + } + }; + + return ( + } breakpoint="xl"> + + + + + + {filterTypes.map( + ({ id, tKey: fieldKey, filter: { type, tKey, placeholderKey } }) => { + switch (type) { + case 'freetext': + return ( + + ); + // case 'enum': + // return <>enum; + } + }, + )} + + + ); +}; + +interface FiltersProps { + selectedFilters: any[]; + filterTypes: { + id: string; + tKey: string; + filter: EnumFilterDef | FreetextFilterDef; + }[]; + onFilterUpdate(filter: any): void; +} + +export default Filters; diff --git a/src/Providers/components/FreetextFilter.tsx b/src/Providers/components/FreetextFilter.tsx new file mode 100644 index 000000000..cf1488692 --- /dev/null +++ b/src/Providers/components/FreetextFilter.tsx @@ -0,0 +1,80 @@ +import React, { useState } from 'react'; + +import { + Button, + ButtonVariant, + InputGroup, + TextInput, + ToolbarFilter, +} from '@patternfly/react-core'; +import { SearchIcon } from '@patternfly/react-icons/dist/esm/icons'; + +interface FreetextFilterProps { + filterId: string; + selectedFilters: any; + onFilterUpdate(obj: any): void; + title: string; + showFilter: boolean; + placeholderLabel: string; +} + +const FreetextFilter: React.FunctionComponent = ({ + filterId, + selectedFilters, + onFilterUpdate, + title, + showFilter, + placeholderLabel, +}) => { + const [inputValue, setInputValue] = useState(''); + const onTextInput = (): void => { + if (!inputValue || selectedFilters?.[filterId]?.includes(inputValue)) { + return; + } + onFilterUpdate({ + ...selectedFilters, + [filterId]: [...(selectedFilters?.[filterId] ?? []), inputValue], + }); + setInputValue(''); + }; + return ( + + onFilterUpdate({ + ...selectedFilters, + [filterId]: + selectedFilters?.[filterId]?.filter?.( + (value) => value !== option, + ) ?? [], + }) + } + deleteChipGroup={() => + onFilterUpdate({ ...selectedFilters, [filterId]: [] }) + } + categoryName={title} + showToolbarItem={showFilter} + > + + event?.key === 'Enter' && onTextInput()} + /> + + + + ); +}; + +export default FreetextFilter; diff --git a/src/Providers/components/ProviderRow.tsx b/src/Providers/components/ProviderRow.tsx new file mode 100644 index 000000000..38c64c719 --- /dev/null +++ b/src/Providers/components/ProviderRow.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { useTranslation } from 'src/internal/i18n'; +import { ProviderResource } from 'src/internal/k8s'; + +import { Td, Tr } from '@patternfly/react-table'; + +import { NAME, STATUS, TYPE, URL } from './shared'; +import { Field } from './shared'; + +interface CellProps { + value: string; +} + +const NameCell: React.FunctionComponent = ({ value }) => ( + {value} +); + +const TextCell: React.FunctionComponent = ({ value }) => ( + <>{value} +); + +const cellCreator = { + [NAME]: NameCell, + [STATUS]: TextCell, + [URL]: TextCell, + [TYPE]: TextCell, +}; + +const ProviderRow: React.FunctionComponent = ({ + columns, + provider, +}) => { + const { t } = useTranslation(); + + return ( + + {columns.map(({ id, tKey, from }) => ( + + {cellCreator?.[id]({ value: from(provider) }) ?? null} + + ))} + + ); +}; + +interface ProviderRowProps { + columns: Field[]; + provider: ProviderResource; +} + +export default ProviderRow; diff --git a/src/Providers/components/ProvidersTableView.tsx b/src/Providers/components/ProvidersTableView.tsx new file mode 100644 index 000000000..b112a3a0c --- /dev/null +++ b/src/Providers/components/ProvidersTableView.tsx @@ -0,0 +1,94 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'src/internal/i18n'; +import { ProviderResource } from 'src/internal/k8s'; + +import { TableComposable, Tbody, Th, Thead, Tr } from '@patternfly/react-table'; + +import ProviderRow from './ProviderRow'; +import { NAME } from './shared'; +import { buildSort, Field, SortType } from './shared'; + +const localeCompare = (a: string, b: string, locale: string): number => + a.localeCompare(b, locale, { numeric: true }); + +const compareWith: ( + sort: SortType, + locale: string, + from: (provider: ProviderResource) => string, +) => (a: ProviderResource, b: ProviderResource) => number = + (sortType, locale, from) => (a, b) => { + const aValue = from?.(a) ?? ''; + const bValue = from?.(b) ?? ''; + const compareValue = localeCompare(aValue, bValue, locale); + return sortType.isAsc ? compareValue : -compareValue; + }; + +const ProvidersTableView: React.FunctionComponent = ({ + fields, + providers, +}) => { + const { t, i18n } = useTranslation(); + // sort state is local (no sorting in toolbar) + const [activeSort, setActiveSort] = useState({ + isAsc: false, + id: NAME, + tKey: NAME, + }); + + // in future handle column re-ordering and hiding + const columns = fields; + + providers.sort( + compareWith( + activeSort, + i18n.resolvedLanguage, + fields.find(({ id }) => id == activeSort.id)?.from, + ), + ); + + return ( + + + + {columns.map(({ id, tKey, sortable }, columnIndex) => ( + + {t(tKey)} + + ))} + + + + {providers.map((provider, index) => ( + + ))} + + + ); +}; + +interface ProvidersTableViewProps { + fields: Field[]; + providers: ProviderResource[]; +} + +export default ProvidersTableView; diff --git a/src/Providers/components/shared.ts b/src/Providers/components/shared.ts new file mode 100644 index 000000000..ebc623ce9 --- /dev/null +++ b/src/Providers/components/shared.ts @@ -0,0 +1,74 @@ +import { ThSortType } from '@patternfly/react-table/dist/esm/components/Table/base'; +import { ProviderResource } from 'src/internal/k8s'; + +export const buildSort = ({ + columnIndex, + columns, + activeSort, + setActiveSort, +}: { + columnIndex: number; + columns: Column[]; + activeSort: SortType; + setActiveSort: (sort: SortType) => void; +}): ThSortType => ({ + sortBy: { + index: 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, +}); + +export interface SortType { + isAsc: boolean; + id: string; + tKey: string; +} + +export interface EnumFilterDef { + type: 'enum'; + placeholderKey: string; + values: { id: string; tKey: string }[]; + tKey?: string; +} + +export interface FreetextFilterDef { + type: 'freetext'; + placeholderKey: string; + tKey?: string; +} + +export interface Field { + id: string; + tKey: string; + sortable?: boolean; + filter: EnumFilterDef | FreetextFilterDef; + from: (provider: ProviderResource) => string; +} + +export interface Column { + id: string; + tKey: string; + sortable?: boolean; +} + +export interface SortableTableProps { + activeSort: SortType; + ['aria-label']?: string; + columns: Column[]; + fields: { [key: string]: Field }; + children: React.ReactNode[]; + setActiveSort(sort: SortType): void; +} + +export const NAME = 'name'; +export const STATUS = 'status'; +export const TYPE = 'type'; +export const URL = 'url';