diff --git a/locales/en/plugin__forklift-console-plugin.json b/locales/en/plugin__forklift-console-plugin.json index a2b5be64d..90c3f0cc1 100644 --- a/locales/en/plugin__forklift-console-plugin.json +++ b/locales/en/plugin__forklift-console-plugin.json @@ -2,32 +2,38 @@ "AddProvider": "Add Provider", "Any": "Any", "Cancel": "Cancel", + "Clusters": "Clusters", + "False": "False", "FilterByName": "Filter by name", "FilterByNamespace": "Filter by namespace", "FilterByStatus": "Filter by status", "FilterByType": "Filter by type", "FilterByUrl": "Filter by endpoint", + "Hosts": "Hosts", "ManageColumns": "Manage columns", "Mappings for VM Import": "Mappings for VM Import", "Name": "Name", "Namespace": "Namespace", - "No": "No", + "Networks": "Newtworks", "Openshift": "Openshift", "Ovirt": "oVirt", "Plans for VM Import": "Plans for VM Import", + "Providers": "Providers", "Providers for VM Import": "Providers for VM Import", - "Virtualization": "Virtualization" "Ready": "Ready", "Reorder": "Reorder", "RestoreDefaultColums": "Restore default colums", "Save": "Save", "SelectFilter": "Select Filter", "Status": "Status", + "Storage": "Storage", "Success": "Success", "TableColumnManagement": "Table column management", "Type": "Type", + "True": "True", + "Unknown": "Unknown", "Url": "Endpoint", "Virtualization": "Virtualization", "Vsphere": "vSphere", - "Yes": "Yes" -} + "VMs": "VMs" +} \ No newline at end of file diff --git a/package.json b/package.json index c45b988c9..90a166975 100644 --- a/package.json +++ b/package.json @@ -72,8 +72,8 @@ "prettier": "^2.7.1", "prettier-stylelint": "^0.4.2", "q": "^1.5.1", - "react": "^18.2.0", - "react-dom": "^18.2.0", + "react": "^17.0.1", + "react-dom": "^17.0.1", "react-helmet": "^6.1.0", "react-i18next": "^11.8.11", "react-query": "^3.39.2", @@ -106,7 +106,7 @@ "HostsPage": "./extensions/HostsPageWrapper", "PlanWizard": "./extensions/PlanWizardWrapper", "VMMigrationDetails": "./extensions/VMMigrationDetailsWrapper", - "ProvidersRes": "./Providers/ProvidersPage" + "ProvidersRes": "./extensions/NewProvidersWrapper" }, "dependencies": { "@console/pluginAPI": "*" diff --git a/src/Providers/ProviderRow.tsx b/src/Providers/ProviderRow.tsx new file mode 100644 index 000000000..f3a031f59 --- /dev/null +++ b/src/Providers/ProviderRow.tsx @@ -0,0 +1,82 @@ +import React from 'react'; +import { RowProps } from 'src/components/TableView'; +import { useTranslation } from 'src/internal/i18n'; +import { NAME, NAMESPACE, READY, TYPE, URL } from 'src/utils/constants'; + +import { StatusIcon } from '@migtools/lib-ui'; +import { ResourceLink } from '@openshift-console/dynamic-plugin-sdk'; +import { Button, Popover } from '@patternfly/react-core'; +import { Td, Tr } from '@patternfly/react-table'; + +import { MergedProvider } from './data'; + +interface CellProps { + value: string; + entity: MergedProvider; + kind: string; +} +const StatusCell = ({ value, entity: { conditions } }: CellProps) => { + const { t } = useTranslation(); + const existingConditions = Object.values(conditions).filter(Boolean); + const toState = (value) => + value === 'True' ? 'Ok' : value === 'False' ? 'Error' : 'Unknown'; + return ( + + {existingConditions.length > 0 + ? existingConditions.map(({ message, status }) => { + return ( + + ); + }) + : 'No information'} + + } + > + + + ); +}; + +const TextCell = ({ value }: { value: string }) => <>{value}; + +const ProviderLink = ({ value, entity, kind }: CellProps) => ( + +); + +const cellCreator = { + [NAME]: ProviderLink, + [READY]: StatusCell, + [URL]: TextCell, + [TYPE]: TextCell, + [NAMESPACE]: ({ value }: CellProps) => ( + + ), +}; + +const ProviderRow = (kind: string) => + function ProviderRow({ columns, entity }: RowProps) { + return ( + + {columns.map(({ id }) => ( + + {cellCreator?.[id]?.({ + kind, + value: entity[id], + entity, + }) ?? } + + ))} + + ); + }; + +export default ProviderRow; diff --git a/src/Providers/ProvidersPage.tsx b/src/Providers/ProvidersPage.tsx index 0c4a14b95..5b9102e8b 100644 --- a/src/Providers/ProvidersPage.tsx +++ b/src/Providers/ProvidersPage.tsx @@ -1,9 +1,27 @@ import React, { useMemo, useState } from 'react'; +import { + AttributeValueFilter, + createMetaMatcher, + EnumFilter, + FreetextFilter, + PrimaryFilters, +} from 'src/components/Filter'; +import { ManageColumnsToolbar, TableView } from 'src/components/TableView'; +import { Field } from 'src/components/types'; import { useTranslation } from 'src/internal/i18n'; -import { ProviderResource } from 'src/internal/k8s'; -import { useMockableK8sWatchResource } from 'src/utils/fetch'; +import { + CLUSTER_COUNT, + HOST_COUNT, + NAME, + NAMESPACE, + NETWORK_COUNT, + READY, + STORAGE_COUNT, + TYPE, + URL, + VM_COUNT, +} from 'src/utils/constants'; -import { MOCK_CLUSTER_PROVIDERS } from '@app/queries/mocks/providers.mock'; import { Button, Level, @@ -16,105 +34,103 @@ import { } from '@patternfly/react-core'; import { FilterIcon } from '@patternfly/react-icons'; -import AttributeValueFilter from './components/AttributeValueFilter'; -import EnumFilter from './components/EnumFilter'; -import FreetextFilter from './components/FreetextFilter'; -import { ManageColumnsToolbar } from './components/ManageColumnsToolbar'; -import PrimaryFilters from './components/PrimaryFilters'; -import ProviderRow from './components/ProviderRow'; -import { createMetaMatcher, Field } from './components/shared'; -import { NAME, NAMESPACE, READY, TYPE, URL } from './components/shared'; -import TableView from './components/TableView'; - -const useProviders = ({ kind, namespace }) => { - const [providers, loaded, error] = useMockableK8sWatchResource( - { kind, namespace }, - MOCK_CLUSTER_PROVIDERS, - ); +import { MergedProvider, useProvidersWithInventory } from './data'; +import ProviderRow from './ProviderRow'; - // 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 defaultFields: Field[] = [ +const fieldsMetadata: Field[] = [ { id: NAME, - tKey: 'plugin__forklift-console-plugin~Name', + tKey: 'Name', isVisible: true, isIdentity: true, filter: { type: 'freetext', - placeholderKey: 'plugin__forklift-console-plugin~FilterByName', + placeholderKey: 'FilterByName', }, sortable: true, - toValue: (provider) => provider?.metadata?.name ?? '', }, { id: NAMESPACE, - tKey: 'plugin__forklift-console-plugin~Namespace', + tKey: 'Namespace', isVisible: true, isIdentity: true, filter: { type: 'freetext', - placeholderKey: 'plugin__forklift-console-plugin~FilterByNamespace', + placeholderKey: 'FilterByNamespace', }, sortable: true, - toValue: (provider) => provider?.metadata?.namespace ?? '', }, { id: READY, - tKey: 'plugin__forklift-console-plugin~Ready', + tKey: 'Ready', isVisible: true, filter: { type: 'enum', primary: true, - placeholderKey: 'plugin__forklift-console-plugin~Ready', + placeholderKey: 'Ready', values: [ - { id: 'Yes', tKey: 'plugin__forklift-console-plugin~Yes' }, - { id: 'No', tKey: 'plugin__forklift-console-plugin~No' }, + { id: 'True', tKey: 'True' }, + { id: 'False', tKey: 'False' }, + { id: 'Unknown', tKey: 'Unknown' }, ], }, sortable: true, - toValue: (provider) => - provider?.status?.conditions?.find(({ type }) => type === 'Ready') - ?.status === 'True' - ? 'Yes' - : 'No', }, { id: URL, - tKey: 'plugin__forklift-console-plugin~Url', + tKey: 'Url', isVisible: true, filter: { type: 'freetext', - placeholderKey: 'plugin__forklift-console-plugin~FilterByUrl', + placeholderKey: 'FilterByUrl', }, sortable: true, - toValue: (provider) => provider?.spec?.url ?? '', }, { id: TYPE, - tKey: 'plugin__forklift-console-plugin~Type', + tKey: 'Type', isVisible: true, filter: { type: 'enum', primary: true, - placeholderKey: 'plugin__forklift-console-plugin~Type', + placeholderKey: 'Type', values: [ - { id: 'vsphere', tKey: 'plugin__forklift-console-plugin~Vsphere' }, - { id: 'ovirt', tKey: 'plugin__forklift-console-plugin~Ovirt' }, - { id: 'openshift', tKey: 'plugin__forklift-console-plugin~Openshift' }, + { id: 'vsphere', tKey: 'Vsphere' }, + { id: 'ovirt', tKey: 'Ovirt' }, + { id: 'openshift', tKey: 'Openshift' }, ], }, sortable: true, - toValue: (provider) => provider?.spec?.type ?? '', + }, + { + id: VM_COUNT, + tKey: 'VMs', + isVisible: true, + sortable: true, + }, + { + id: NETWORK_COUNT, + tKey: 'Networks', + isVisible: true, + sortable: true, + }, + { + id: CLUSTER_COUNT, + tKey: 'Clusters', + isVisible: true, + sortable: true, + }, + { + id: HOST_COUNT, + tKey: 'Hosts', + isVisible: false, + sortable: true, + }, + { + id: STORAGE_COUNT, + tKey: 'Storage', + isVisible: false, + sortable: true, }, ]; @@ -134,24 +150,25 @@ const useFields = (namespace, defaultFields) => { export const ProvidersPage = ({ namespace, kind }: ProvidersPageProps) => { const { t } = useTranslation(); - const [providers, loaded, error] = useProviders({ kind, namespace }); + const [providers, loaded, error] = useProvidersWithInventory({ + kind, + namespace, + }); const [selectedFilters, setSelectedFilters] = useState({}); - const [fields, setFields] = useFields(namespace, defaultFields); + const [fields, setFields] = useFields(namespace, fieldsMetadata); - console.error('Providers', defaultFields, fields, namespace, kind); + console.error('Providers', providers, fields, namespace, kind); return ( <> - - {t('plugin__forklift-console-plugin~Providers')} - + {t('Providers')} @@ -161,21 +178,23 @@ export const ProvidersPage = ({ namespace, kind }: ProvidersPageProps) => { } breakpoint="xl"> field.filter.primary)} + filterTypes={fields.filter((field) => field.filter?.primary)} onFilterUpdate={setSelectedFilters} selectedFilters={selectedFilters} supportedFilters={{ enum: EnumFilter }} /> !field.filter.primary)} + filterTypes={fields.filter( + ({ filter }) => filter && !filter.primary, + )} onFilterUpdate={setSelectedFilters} selectedFilters={selectedFilters} supportedFilters={{ freetext: FreetextFilter }} /> @@ -185,13 +204,13 @@ export const ProvidersPage = ({ namespace, kind }: ProvidersPageProps) => { {loaded && error && } {!loaded && } {loaded && !error && ( - - resources={providers.filter( + + entities={providers.filter( createMetaMatcher(selectedFilters, fields), )} - fields={fields} - visibleFields={fields.filter(({ isVisible }) => isVisible)} - aria-label={t('plugin__forklift-console-plugin~Providers')} + allColumns={fields} + visibleColumns={fields.filter(({ isVisible }) => isVisible)} + aria-label={t('Providers')} Row={ProviderRow(kind)} /> )} diff --git a/src/Providers/components/ProviderRow.tsx b/src/Providers/components/ProviderRow.tsx deleted file mode 100644 index df4079cc3..000000000 --- a/src/Providers/components/ProviderRow.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import React from 'react'; -import { useTranslation } from 'src/internal/i18n'; -import { ProviderResource } from 'src/internal/k8s'; - -import { getMostSeriousCondition, getStatusType } from '@app/common/helpers'; -import { IStatusCondition } from '@app/queries/types'; -import { StatusIcon } from '@migtools/lib-ui'; -import { ResourceLink } from '@openshift-console/dynamic-plugin-sdk'; -import { Button, Popover } from '@patternfly/react-core'; -import { Td, Tr } from '@patternfly/react-table'; - -import { NAME, NAMESPACE, READY, TYPE, URL } from './shared'; -import { RowProps } from './TableView'; -interface CellProps { - value: string; - resource: ProviderResource; - t(key: string): string; - kind: string; -} -const StatusCell = ({ value, resource, t }: CellProps) => { - return ( - - {resource?.status?.conditions?.map((condition) => { - const severity = getMostSeriousCondition([ - condition as IStatusCondition, - ]); - return ( - - ); - }) ?? 'No information'} - - } - > - - - ); -}; - -const TextCell = ({ value }: CellProps) => <>{value}; - -const ProviderLink = ({ value, resource, kind }: CellProps) => ( - -); - -const cellCreator = { - [NAME]: ProviderLink, - [READY]: StatusCell, - [URL]: TextCell, - [TYPE]: TextCell, - [NAMESPACE]: ({ value }: CellProps) => ( - - ), -}; - -const ProviderRow = (kind: string) => - function ProviderRow({ columns, resource }: RowProps) { - const { t } = useTranslation(); - - return ( - - {columns.map(({ id, tKey, toValue }) => ( - - {cellCreator?.[id]?.({ - kind, - t, - value: toValue(resource), - resource, - }) ?? null} - - ))} - - ); - }; - -export default ProviderRow; diff --git a/src/Providers/components/TableView.tsx b/src/Providers/components/TableView.tsx deleted file mode 100644 index 97c22e9b5..000000000 --- a/src/Providers/components/TableView.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import React, { useState } from 'react'; -import { useTranslation } from 'src/internal/i18n'; - -import { TableComposable, Tbody, Th, Thead, Tr } from '@patternfly/react-table'; - -import { NAME } from './shared'; -import { buildSort, Field, localeCompare, SortType } from './shared'; - -function compareWith( - sortType: SortType, - locale: string, - toValue: (resource: T) => string, -): (a: T, b: T) => number { - return (a, b) => { - const aValue: string = toValue?.(a) + '' ?? ''; - const bValue: string = toValue?.(b) + '' ?? ''; - const compareValue = localeCompare(aValue, bValue, locale); - return sortType.isAsc ? compareValue : -compareValue; - }; -} - -const find = (fields: Field[], id: string): Field => - fields.find((field) => field.id === id); - -function TableView({ - fields, - visibleFields: columns, - resources, - 'aria-label': ariaLabel, - nameColumnId = NAME, - Row, -}: TableViewProps) { - const { t, i18n } = useTranslation(); - // sort state is local (no sorting in toolbar) - const [activeSort, setActiveSort] = useState({ - isAsc: false, - id: nameColumnId, - tKey: find(fields, nameColumnId)?.tKey ?? nameColumnId, - }); - - resources.sort( - compareWith( - activeSort, - i18n.resolvedLanguage, - find(fields, activeSort.id)?.toValue, - ), - ); - - return ( - - - - {columns.map(({ id, tKey, sortable }, columnIndex) => ( - - {t(tKey)} - - ))} - - - - {resources.map((resource, index) => ( - - ))} - - - ); -} - -export interface RowProps { - columns: Field[]; - resource: T; -} - -interface TableViewProps { - fields: Field[]; - visibleFields: Field[]; - resources: T[]; - 'aria-label': string; - nameColumnId?: string; - Row(props: RowProps): JSX.Element; -} - -export default TableView; diff --git a/src/Providers/components/shared.ts b/src/Providers/components/shared.ts deleted file mode 100644 index 6919c1bad..000000000 --- a/src/Providers/components/shared.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { ProviderResource } from 'src/internal/k8s'; - -import { ThSortType } from '@patternfly/react-table/dist/esm/components/Table/base'; - -export const localeCompare = (a: string, b: string, locale: string): number => - a.localeCompare(b, locale, { numeric: true }); - -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 const createMatcher = - ({ - selectedFilters, - filterType, - matchValue, - fields, - }: { - selectedFilters: { [id: string]: string[] }; - filterType: string; - matchValue: (value: string) => (filterValue: string) => boolean; - fields: Field[]; - }) => - (provider: ProviderResource): boolean => - fields - .filter(({ filter: { type } }) => type === filterType) - .filter(({ id }) => selectedFilters[id] && selectedFilters[id]?.length) - .map(({ id, toValue }) => ({ - value: toValue(provider), - filters: selectedFilters[id], - })) - .map(({ value, filters }) => filters.some(matchValue(value))) - .every(Boolean); - -const defaultValueMatchers = [ - { - filterType: 'freetext', - matchValue: (value: string) => (filter: string) => value?.includes(filter), - }, - { - filterType: 'enum', - matchValue: (value: string) => (filter: string) => value === filter, - }, -]; - -export const createMetaMatcher = - ( - selectedFilters: { [id: string]: string[] }, - fields: Field[], - valueMatchers: { - filterType: string; - matchValue: (value: string) => (filter: string) => boolean; - }[] = defaultValueMatchers, - ) => - (provider: ProviderResource): boolean => - valueMatchers - .map(({ filterType, matchValue }) => - createMatcher({ selectedFilters, filterType, matchValue, fields }), - ) - .map((match) => match(provider)) - .every(Boolean); - -export interface SortType { - isAsc: boolean; - id: string; - tKey: string; -} - -export interface FilterDef { - type: string; - placeholderKey: string; - values?: { id: string; tKey: string }[]; - tKey?: string; - primary?: boolean; -} -export interface Field { - id: string; - tKey: string; - isVisible?: boolean; - isIdentity?: boolean; - sortable?: boolean; - filter: FilterDef; - toValue: (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 READY = 'ready'; -export const TYPE = 'type'; -export const URL = 'url'; -export const NAMESPACE = 'namespace'; diff --git a/src/Providers/data.ts b/src/Providers/data.ts new file mode 100644 index 000000000..d94872e86 --- /dev/null +++ b/src/Providers/data.ts @@ -0,0 +1,140 @@ +import { useMemo } from 'react'; +import { ProviderResource } from 'src/internal/k8s'; +import { + CONNECTED, + INVENTORY, + NAME, + NAMESPACE, + READY, + TYPE, + UID, + URL, + VALIDATED, +} from 'src/utils/constants'; +import { useProviders } from 'src/utils/fetch'; + +import { useInventoryProvidersQuery } from '@app/queries'; +import { + IOpenShiftProvider, + IProvidersByType, + IRHVProvider, + IVMwareProvider, +} from '@app/queries/types'; + +const conditionState = (state: string) => + state === 'True' || state === 'False' ? state : 'Unknown'; + +interface Condition { + status: string; + message: string; +} +interface SupportedConditions { + Ready?: Condition; + Validated?: Condition; + ConnectionTested?: Condition; + InventoryCreated?: Condition; +} + +interface FlattenedProvider { + [NAME]: string; + [NAMESPACE]: string; + [URL]: string; + [TYPE]: string; + [UID]: string; +} + +interface FlattenedConditions { + [READY]: string; + conditions: { + [READY]: Condition; + [CONNECTED]: Condition; + [VALIDATED]: Condition; + [INVENTORY]: Condition; + }; +} + +interface MergedInventory { + clusterCount: number; + hostCount: number; + vmCount: number; + networkCount: number; + storageCount: number; +} + +export type MergedProvider = FlattenedProvider & + MergedInventory & + FlattenedConditions; + +const mergeData = ( + resources: ProviderResource[], + inventory: IProvidersByType, +) => + resources + .map( + ({ + metadata: { name, namespace, uid } = {}, + status: { conditions = [] } = {}, + spec: { url, type } = {}, + }): [ + FlattenedProvider, + IVMwareProvider & IRHVProvider & IOpenShiftProvider, + SupportedConditions, + ] => [ + { + name, + namespace, + url, + type, + uid, + }, + inventory?.[type].find(({ uid: otherUid }) => otherUid === uid) ?? {}, + Object.fromEntries( + conditions.map(({ type, status, message }) => [ + type, + { status: conditionState(status), message }, + ]), + ), + ], + ) + .map( + ([ + provider, + { + clusterCount, + hostCount, + vmCount, + networkCount, + datastoreCount, + storageDomainCount, + }, + { Ready, Validated, ConnectionTested, InventoryCreated }, + ]): MergedProvider => ({ + ...provider, + clusterCount, + hostCount, + vmCount, + networkCount, + storageCount: storageDomainCount ?? datastoreCount, + ready: Ready?.status ?? 'Unknown', + conditions: { + ready: Ready, + inventory: InventoryCreated, + validated: Validated, + connected: ConnectionTested, + }, + }), + ); + +export const useProvidersWithInventory = ({ + kind, + namespace, +}): [MergedProvider[], boolean, boolean] => { + const [resources, loaded, error] = useProviders({ kind, namespace }); + const { data, isSuccess, isError } = useInventoryProvidersQuery(); + const providersWithInventory = useMemo( + () => (resources && data ? mergeData(resources, data) : []), + [resources, data], + ); + + return [providersWithInventory, loaded && isSuccess, error || isError]; +}; diff --git a/src/Providers/components/AttributeValueFilter.tsx b/src/components/Filter/AttributeValueFilter.tsx similarity index 77% rename from src/Providers/components/AttributeValueFilter.tsx rename to src/components/Filter/AttributeValueFilter.tsx index 0c55e57a2..ab4860e96 100644 --- a/src/Providers/components/AttributeValueFilter.tsx +++ b/src/components/Filter/AttributeValueFilter.tsx @@ -10,7 +10,7 @@ import { ToolbarItem, } from '@patternfly/react-core'; -import { FilterDef } from './shared'; +import { MetaFilterProps } from './types'; interface IdOption extends SelectOptionObject { id: string; @@ -22,7 +22,7 @@ const toSelectOption = (id: string, label: string): IdOption => ({ toString: () => label, }); -const AttributeValueFilter = ({ +export const AttributeValueFilter = ({ selectedFilters, onFilterUpdate, filterTypes, @@ -86,28 +86,3 @@ const AttributeValueFilter = ({ ); }; - -export interface FieldFilterProps { - filterId: string; - onFilterUpdate(values: string[]); - placeholderLabel: string; - selectedFilters: string[]; - showFilter: boolean; - title: string; - supportedValues?: { id: string; tKey?: string }[]; -} - -export interface MetaFilterProps { - selectedFilters: { [id: string]: string[] }; - filterTypes: { - id: string; - tKey: string; - filter: FilterDef; - }[]; - onFilterUpdate(filters: { [id: string]: string[] }): void; - supportedFilters: { - [type: string]: (props: FieldFilterProps) => JSX.Element; - }; -} - -export default AttributeValueFilter; diff --git a/src/Providers/components/EnumFilter.tsx b/src/components/Filter/EnumFilter.tsx similarity index 95% rename from src/Providers/components/EnumFilter.tsx rename to src/components/Filter/EnumFilter.tsx index c2f71e30f..321adfb36 100644 --- a/src/Providers/components/EnumFilter.tsx +++ b/src/components/Filter/EnumFilter.tsx @@ -1,5 +1,6 @@ import React, { useMemo, useState } from 'react'; import { useTranslation } from 'src/internal/i18n'; +import { localeCompare } from 'src/utils/helpers'; import { Select, @@ -10,8 +11,7 @@ import { ToolbarFilter, } from '@patternfly/react-core'; -import { FieldFilterProps } from './AttributeValueFilter'; -import { localeCompare } from './shared'; +import { FieldFilterProps } from './types'; export const useUnique = ({ supportedEnumValues, @@ -69,7 +69,7 @@ export const useUnique = ({ return { filterNames, onFilterUpdate, selectedFilters }; }; -const EnumFilter = ({ +export const EnumFilter = ({ selectedFilters: selectedEnumIds = [], onFilterUpdate: onSelectedEnumIdsChange, supportedValues: supportedEnumValues = [], @@ -131,5 +131,3 @@ const EnumFilter = ({ ); }; - -export default EnumFilter; diff --git a/src/Providers/components/FreetextFilter.tsx b/src/components/Filter/FreetextFilter.tsx similarity index 90% rename from src/Providers/components/FreetextFilter.tsx rename to src/components/Filter/FreetextFilter.tsx index 64b5342e1..fb3474369 100644 --- a/src/Providers/components/FreetextFilter.tsx +++ b/src/components/Filter/FreetextFilter.tsx @@ -2,9 +2,9 @@ import React, { useState } from 'react'; import { InputGroup, SearchInput, ToolbarFilter } from '@patternfly/react-core'; -import { FieldFilterProps } from './AttributeValueFilter'; +import { FieldFilterProps } from './types'; -const FreetextFilter = ({ +export const FreetextFilter = ({ filterId, selectedFilters, onFilterUpdate, @@ -45,5 +45,3 @@ const FreetextFilter = ({ ); }; - -export default FreetextFilter; diff --git a/src/Providers/components/PrimaryFilters.tsx b/src/components/Filter/PrimaryFilters.tsx similarity index 89% rename from src/Providers/components/PrimaryFilters.tsx rename to src/components/Filter/PrimaryFilters.tsx index 2871e5413..80ff0bd19 100644 --- a/src/Providers/components/PrimaryFilters.tsx +++ b/src/components/Filter/PrimaryFilters.tsx @@ -3,9 +3,9 @@ import { useTranslation } from 'src/internal/i18n'; import { ToolbarGroup } from '@patternfly/react-core'; -import { MetaFilterProps } from './AttributeValueFilter'; +import { MetaFilterProps } from './types'; -const PrimaryFilters = ({ +export const PrimaryFilters = ({ selectedFilters, onFilterUpdate, filterTypes, @@ -39,5 +39,3 @@ const PrimaryFilters = ({ ); }; - -export default PrimaryFilters; diff --git a/src/components/Filter/helpers.ts b/src/components/Filter/helpers.ts new file mode 100644 index 000000000..3c3b2df18 --- /dev/null +++ b/src/components/Filter/helpers.ts @@ -0,0 +1,52 @@ +import { Field } from '../types'; + +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); + +const defaultValueMatchers = [ + { + filterType: 'freetext', + matchValue: (value: string) => (filter: string) => value?.includes(filter), + }, + { + filterType: 'enum', + matchValue: (value: string) => (filter: string) => value === filter, + }, +]; + +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/index.ts b/src/components/Filter/index.ts new file mode 100644 index 000000000..7c92a009a --- /dev/null +++ b/src/components/Filter/index.ts @@ -0,0 +1,5 @@ +export * from './AttributeValueFilter'; +export * from './EnumFilter'; +export * from './FreetextFilter'; +export * from './helpers'; +export * from './PrimaryFilters'; diff --git a/src/components/Filter/types.ts b/src/components/Filter/types.ts new file mode 100644 index 000000000..db11b37e1 --- /dev/null +++ b/src/components/Filter/types.ts @@ -0,0 +1,30 @@ +export interface FilterDef { + type: string; + placeholderKey: string; + values?: { id: string; tKey: string }[]; + tKey?: string; + primary?: boolean; +} + +export interface FieldFilterProps { + filterId: string; + onFilterUpdate(values: string[]); + placeholderLabel: string; + selectedFilters: string[]; + showFilter: boolean; + title: string; + supportedValues?: { id: string; tKey?: string }[]; +} + +export interface MetaFilterProps { + selectedFilters: { [id: string]: string[] }; + filterTypes: { + id: string; + tKey: string; + filter: FilterDef; + }[]; + onFilterUpdate(filters: { [id: string]: string[] }): void; + supportedFilters: { + [type: string]: (props: FieldFilterProps) => JSX.Element; + }; +} diff --git a/src/Providers/components/ManageColumnsToolbar.tsx b/src/components/TableView/ManageColumnsToolbar.tsx similarity index 95% rename from src/Providers/components/ManageColumnsToolbar.tsx rename to src/components/TableView/ManageColumnsToolbar.tsx index ba95dfbf5..c454647dd 100644 --- a/src/Providers/components/ManageColumnsToolbar.tsx +++ b/src/components/TableView/ManageColumnsToolbar.tsx @@ -23,16 +23,16 @@ import { } from '@patternfly/react-core'; import { ColumnsIcon } from '@patternfly/react-icons'; -import { Field } from './shared'; +import { Field } from '../types'; export const ManageColumnsToolbar = ({ - fields, - setFields, - defaultFields, + columns, + setColumns, + defaultColumns, }: { - fields: Field[]; - defaultFields: Field[]; - setFields(fileds: Field[]): void; + columns: Field[]; + defaultColumns: Field[]; + setColumns(columns: Field[]): void; }) => { const { t } = useTranslation(); const [manageColumns, setManageColumns] = useState(false); @@ -51,9 +51,9 @@ export const ManageColumnsToolbar = ({ showModal={manageColumns} onClose={() => setManageColumns(false)} description="Selected columns will be displayed in the table." - columns={fields} - onChange={setFields} - defaultColumns={defaultFields} + columns={columns} + onChange={setColumns} + defaultColumns={defaultColumns} /> ); diff --git a/src/components/TableView/TableView.tsx b/src/components/TableView/TableView.tsx new file mode 100644 index 000000000..427d7991d --- /dev/null +++ b/src/components/TableView/TableView.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { useTranslation } from 'src/internal/i18n'; +import { UID } from 'src/utils/constants'; + +import { TableComposable, Tbody, Th, Thead, Tr } from '@patternfly/react-table'; + +import { Field } from '../types'; + +import { buildSort, useSort } from './sort'; +import { RowProps } from './types'; + +export function TableView({ + uidFieldId = UID, + allColumns, + visibleColumns, + entities, + 'aria-label': ariaLabel, + Row, +}: TableViewProps) { + const { t } = useTranslation(); + + const [activeSort, setActiveSort, comparator] = useSort(allColumns); + + entities.sort(comparator); + + return ( + + + + {visibleColumns.map(({ id, tKey, sortable }, columnIndex) => ( + + {t(tKey)} + + ))} + + + + {entities.map((entity, index) => ( + + ))} + + + ); +} + +interface TableViewProps { + allColumns: Field[]; + visibleColumns: Field[]; + entities: T[]; + 'aria-label': string; + uidFieldId?: string; + Row(props: RowProps): JSX.Element; +} 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..a4f12bffc --- /dev/null +++ b/src/components/TableView/sort.ts @@ -0,0 +1,75 @@ +import { 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'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const universalComparator = (a: any, b: any, locale: string) => + localeCompare(String(a ?? ''), String(b ?? ''), locale); + +export function compareWith( + sortType: SortType, + locale: string, + fieldComparator: (a: T, b: T, locale: string) => number, +): (a: T, b: T) => number { + return (a: T, b: T) => { + const comparator = fieldComparator ?? universalComparator; + const compareValue = comparator(a[sortType.id], b[sortType.id], locale); + return sortType.isAsc ? compareValue : -compareValue; + }; +} + +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, + tKey: firstField?.tKey, + }); + + const comparator = compareWith( + activeSort, + i18n.resolvedLanguage, + fields.find((field) => field.id === activeSort.id)?.comparator, + ); + + return [activeSort, setActiveSort, comparator]; +}; + +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, +}); diff --git a/src/components/TableView/types.ts b/src/components/TableView/types.ts new file mode 100644 index 000000000..224344228 --- /dev/null +++ b/src/components/TableView/types.ts @@ -0,0 +1,12 @@ +import { Field } from '../types'; + +export interface Column { + id: string; + tKey: 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..c1723d49f --- /dev/null +++ b/src/components/types.ts @@ -0,0 +1,17 @@ +import { FilterDef } from './Filter/types'; + +export interface SortType { + isAsc: boolean; + id: string; + tKey: string; +} + +export interface Field { + id: string; + tKey: string; + isVisible?: boolean; + isIdentity?: boolean; + sortable?: boolean; + filter?: FilterDef; + comparator?: (a: any, b: any, locale: string) => number; +} diff --git a/src/extensions/NewProvidersWrapper.tsx b/src/extensions/NewProvidersWrapper.tsx new file mode 100644 index 000000000..5485f04aa --- /dev/null +++ b/src/extensions/NewProvidersWrapper.tsx @@ -0,0 +1,30 @@ +import * as React from 'react'; +import { QueryCache, QueryClient, QueryClientProvider } from 'react-query'; +import { ReactQueryDevtools } from 'react-query/devtools'; +import ProvidersPage from 'src/Providers/ProvidersPage'; + +const queryCache = new QueryCache(); +const queryClient = new QueryClient({ + queryCache, + defaultOptions: { + queries: { + refetchOnMount: false, + refetchOnWindowFocus: false, + }, + }, +}); + +const App: React.FunctionComponent = ({ + namespace, + kind, +}: { + namespace: string; + kind: string; +}) => ( + + + {process.env.NODE_ENV !== 'test' ? : null} + +); + +export default App; diff --git a/src/utils/constants.ts b/src/utils/constants.ts new file mode 100644 index 000000000..e4ffe5e46 --- /dev/null +++ b/src/utils/constants.ts @@ -0,0 +1,14 @@ +export const NAME = 'name'; +export const READY = 'ready'; +export const CONNECTED = 'connected'; +export const INVENTORY = 'inventory'; +export const VALIDATED = 'validated'; +export const TYPE = 'type'; +export const URL = 'url'; +export const NAMESPACE = 'namespace'; +export const UID = 'uid'; +export const CLUSTER_COUNT = 'clusterCount'; +export const HOST_COUNT = 'hostCount'; +export const VM_COUNT = 'vmCount'; +export const NETWORK_COUNT = 'networkCount'; +export const STORAGE_COUNT = 'storageCount'; diff --git a/src/utils/fetch.ts b/src/utils/fetch.ts index e9fa27832..916f32a1d 100644 --- a/src/utils/fetch.ts +++ b/src/utils/fetch.ts @@ -1,11 +1,17 @@ -import { useK8sWatchResource } from '@openshift-console/dynamic-plugin-sdk'; +import { ProviderResource } from 'src/internal/k8s'; + +import { MOCK_CLUSTER_PROVIDERS } from '@app/queries/mocks/providers.mock'; +import { + useK8sWatchResource, + WatchK8sResult, +} from '@openshift-console/dynamic-plugin-sdk'; const isMock = process.env.DATA_SOURCE === 'mock'; export function useMockableK8sWatchResource( { kind, namespace }, mockData: T[] = [], -) { +): WatchK8sResult { return isMock ? [mockData, true, false] : useK8sWatchResource({ @@ -15,3 +21,9 @@ export function useMockableK8sWatchResource( namespace, }); } + +export const useProviders = ({ kind, namespace }) => + useMockableK8sWatchResource( + { kind, namespace }, + MOCK_CLUSTER_PROVIDERS as ProviderResource[], + ); diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts new file mode 100644 index 000000000..b578b6801 --- /dev/null +++ b/src/utils/helpers.ts @@ -0,0 +1,2 @@ +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 b2855f76c..3f2d76946 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6950,13 +6950,14 @@ rc@^1.0.1, rc@^1.1.6: minimist "^1.2.0" strip-json-comments "~2.0.1" -react-dom@^18.2.0: - version "18.2.0" - resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.2.0.tgz#22aaf38708db2674ed9ada224ca4aa708d821e3d" - integrity sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g== +react-dom@^17.0.1: + version "17.0.2" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-17.0.2.tgz#ecffb6845e3ad8dbfcdc498f0d0a939736502c23" + integrity sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA== dependencies: loose-envify "^1.1.0" - scheduler "^0.23.0" + object-assign "^4.1.1" + scheduler "^0.20.2" react-dropzone@9.0.0: version "9.0.0" @@ -7079,13 +7080,6 @@ react@^17.0.1: loose-envify "^1.1.0" object-assign "^4.1.1" -react@^18.2.0: - version "18.2.0" - resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5" - integrity sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ== - dependencies: - loose-envify "^1.1.0" - read-pkg-up@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-1.0.1.tgz#9d63c13276c065918d57f002a57f40a1b643fb02" @@ -7520,12 +7514,13 @@ saxes@^5.0.1: dependencies: xmlchars "^2.2.0" -scheduler@^0.23.0: - version "0.23.0" - resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.0.tgz#ba8041afc3d30eb206a487b6b384002e4e61fdfe" - integrity sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw== +scheduler@^0.20.2: + version "0.20.2" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.20.2.tgz#4baee39436e34aa93b4874bddcbf0fe8b8b50e91" + integrity sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ== dependencies: loose-envify "^1.1.0" + object-assign "^4.1.1" schema-utils@^3.1.0, schema-utils@^3.1.1: version "3.1.1"