From aac0ed64813ad72ed150d721e68eac530b1b492a 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 page Functional changes: 1. display all providers in one table 2. use only 'Ready' condition to describe the state of the provider Components created: 1. primary filters component for displaying few (1-3) most important filters. The filters are grouped but displayed independently. 2. attribute-value filter implementation for grouping all other filters in a space efficient way 3. default filter types: a) free text filter - substring search based on multiple terms - search terms confirmed by 'Enter' key b) enum based filter - exact match based on checkboxes selected 4. generic table component providing sorting capabilities Reference-Url: https://github.com/oVirt/ovirt-web-ui/pull/1600 Reference-Url: https://github.com/oVirt/ovirt-web-ui/pull/1592 Reference-Url: https://www.patternfly.org/v4/guidelines/filters#attribute-value-filter --- console-extensions.json | 30 +++ .../en/plugin__forklift-console-plugin.json | 19 ++ package.json | 5 +- src/Providers/ProvidersPage.tsx | 178 ++++++++++++++++++ .../components/AttributeValueFilter.tsx | 113 +++++++++++ src/Providers/components/EnumFilter.tsx | 138 ++++++++++++++ src/Providers/components/FreetextFilter.tsx | 49 +++++ src/Providers/components/PrimaryFilters.tsx | 43 +++++ src/Providers/components/ProviderRow.tsx | 74 ++++++++ src/Providers/components/TableView.tsx | 104 ++++++++++ src/Providers/components/shared.ts | 123 ++++++++++++ 11 files changed, 874 insertions(+), 2 deletions(-) create mode 100644 src/Providers/ProvidersPage.tsx create mode 100644 src/Providers/components/AttributeValueFilter.tsx create mode 100644 src/Providers/components/EnumFilter.tsx create mode 100644 src/Providers/components/FreetextFilter.tsx create mode 100644 src/Providers/components/PrimaryFilters.tsx create mode 100644 src/Providers/components/ProviderRow.tsx create mode 100644 src/Providers/components/TableView.tsx create mode 100644 src/Providers/components/shared.ts diff --git a/console-extensions.json b/console-extensions.json index 6a3ba73c5..604bb8da1 100644 --- a/console-extensions.json +++ b/console-extensions.json @@ -31,6 +31,36 @@ ] } }, + { + "type": "console.navigation/resource-ns", + "properties": { + "id": "providers", + "section": "virtualization", + "name": "%plugin__forklift-console-plugin~Providers for VM Import%", + "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": { diff --git a/locales/en/plugin__forklift-console-plugin.json b/locales/en/plugin__forklift-console-plugin.json index 200685fe7..5aa96e014 100644 --- a/locales/en/plugin__forklift-console-plugin.json +++ b/locales/en/plugin__forklift-console-plugin.json @@ -1,6 +1,25 @@ { + "AddProvider": "Add Provider", + "Any": "Any", + "FilterByName": "Filter by name", + "FilterByStatus": "Filter by status", + "FilterByType": "Filter by type", + "FilterByUrl": "Filter by endpoint", "Mappings for VM Import": "Mappings for VM Import", + "Name": "Name", + "No": "No", + "Openshift": "Openshift", + "Ovirt": "oVirt", "Plans for VM Import": "Plans for VM Import", "Providers for VM Import": "Providers for VM Import", "Virtualization": "Virtualization" + "Ready": "Ready", + "SelectFilter": "Select Filter", + "Status": "Status", + "Success": "Success", + "Type": "Type", + "Url": "Endpoint", + "Virtualization": "Virtualization", + "Vsphere": "vSphere", + "Yes": "Yes" } diff --git a/package.json b/package.json index 45e726699..c45b988c9 100644 --- a/package.json +++ b/package.json @@ -105,10 +105,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..fdfaf5fde --- /dev/null +++ b/src/Providers/ProvidersPage.tsx @@ -0,0 +1,178 @@ +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, + ToolbarToggleGroup, +} from '@patternfly/react-core'; +import { FilterIcon } from '@patternfly/react-icons/dist/esm/icons'; + +import AttributeValueFilter from './components/AttributeValueFilter'; +import EnumFilter from './components/EnumFilter'; +import FreetextFilter from './components/FreetextFilter'; +import PrimaryFilters from './components/PrimaryFilters'; +import ProviderRow from './components/ProviderRow'; +import { createMetaMatcher, Field } from './components/shared'; +import { NAME, READY, TYPE, URL } from './components/shared'; +import TableView from './components/TableView'; + +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~FilterByName', + }, + sortable: true, + toValue: (provider) => provider?.metadata?.name ?? '', + }, + { + id: READY, + tKey: 'plugin__forklift-console-plugin~Ready', + filter: { + type: 'enum', + primary: true, + placeholderKey: 'plugin__forklift-console-plugin~Ready', + values: [ + { id: 'Yes', tKey: 'plugin__forklift-console-plugin~Yes' }, + { id: 'No', tKey: 'plugin__forklift-console-plugin~No' }, + ], + }, + sortable: true, + toValue: (provider) => + provider?.status?.conditions?.find(({ type }) => type === 'Ready') + ?.status === 'True' + ? 'Yes' + : 'No', + }, + { + id: URL, + tKey: 'plugin__forklift-console-plugin~Url', + filter: { + type: 'freetext', + placeholderKey: 'plugin__forklift-console-plugin~FilterByUrl', + }, + sortable: true, + toValue: (provider) => provider?.spec?.url ?? '', + }, + { + id: TYPE, + tKey: 'plugin__forklift-console-plugin~Type', + filter: { + type: 'enum', + primary: true, + placeholderKey: 'plugin__forklift-console-plugin~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' }, + ], + }, + sortable: true, + toValue: (provider) => provider?.spec?.type ?? '', + }, +]; + +export const ProvidersPage = ({ namespace, kind }: ProvidersPageProps) => { + const { t } = useTranslation(); + const [providers, loaded, error] = useProviders({ kind, namespace }); + const [selectedFilters, setSelectedFilters] = useState({}); + + console.error('Providers', providers, fields, selectedFilters); + + return ( + <> + + + + + {t('plugin__forklift-console-plugin~Providers')} + + + + + + + + + setSelectedFilters({})}> + + } breakpoint="xl"> + field.filter.primary)} + onFilterUpdate={setSelectedFilters} + selectedFilters={selectedFilters} + supportedFilters={{ enum: EnumFilter }} + /> + !field.filter.primary)} + onFilterUpdate={setSelectedFilters} + selectedFilters={selectedFilters} + supportedFilters={{ freetext: FreetextFilter }} + /> + + + + + {loaded && error && } + {!loaded && } + {loaded && !error && ( + + resources={providers.filter( + createMetaMatcher(selectedFilters, fields), + )} + fields={fields} + aria-label={t('plugin__forklift-console-plugin~Providers')} + Row={ProviderRow} + /> + )} + + + ); +}; + +const Errors = () => <> Erorrs!; + +const Loading = () => <> Loading!; + +type ProvidersPageProps = { + kind: string; + namespace: string; +}; + +export default ProvidersPage; diff --git a/src/Providers/components/AttributeValueFilter.tsx b/src/Providers/components/AttributeValueFilter.tsx new file mode 100644 index 000000000..0c55e57a2 --- /dev/null +++ b/src/Providers/components/AttributeValueFilter.tsx @@ -0,0 +1,113 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'src/internal/i18n'; + +import { + Select, + SelectOption, + SelectOptionObject, + SelectVariant, + ToolbarGroup, + ToolbarItem, +} from '@patternfly/react-core'; + +import { FilterDef } 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 AttributeValueFilter = ({ + selectedFilters, + onFilterUpdate, + filterTypes, + supportedFilters = {}, +}: MetaFilterProps) => { + const { t } = useTranslation(); + const [currentFilterType, setCurrentFilterType] = useState(filterTypes[0]); + const [expanded, setExpanded] = useState(false); + + const selectOptionToFilter = (selectedId) => + filterTypes.find(({ id }) => id === selectedId) ?? currentFilterType; + + const onFilterTypeSelect = (event, value, isPlaceholder) => { + if (!isPlaceholder) { + setCurrentFilterType(selectOptionToFilter(value?.id)); + setExpanded(!expanded); + } + }; + + return ( + + + + + + {filterTypes.map(({ id, tKey: fieldKey, filter }) => { + const FieldFilter = supportedFilters[filter.type]; + return ( + FieldFilter && ( + + onFilterUpdate({ + ...selectedFilters, + [id]: values, + }) + } + placeholderLabel={t(filter.placeholderKey)} + selectedFilters={selectedFilters[id] ?? []} + showFilter={currentFilterType?.id === id} + title={t(filter.tKey ?? fieldKey)} + supportedValues={filter.values} + /> + ) + ); + })} + + ); +}; + +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/Providers/components/EnumFilter.tsx new file mode 100644 index 000000000..762dc92a0 --- /dev/null +++ b/src/Providers/components/EnumFilter.tsx @@ -0,0 +1,138 @@ +import React, { useMemo, useState } from 'react'; +import { useTranslation } from 'src/internal/i18n'; + +import { + Select, + SelectOption, + SelectOptionObject, + SelectVariant, + ToolbarChip, + ToolbarFilter, +} from '@patternfly/react-core'; + +import { FieldFilterProps } from './AttributeValueFilter'; +import { localeCompare } from './shared'; + +export const useUnique = ({ + supportedEnumValues, + onSelectedEnumIdsChange, + selectedEnumIds, +}) => { + const { t, i18n } = useTranslation(); + + const translated = useMemo( + () => + supportedEnumValues.map(({ id, tKey }) => ({ + // fallback to ID + label: tKey ? t(tKey) : id, + id, + })), + + [supportedEnumValues], + ); + + // one label may map to multiple filter ids i.e. "Unknown" + // aggregate filters with the same label + const labelToIds = useMemo( + () => + translated.reduce((acc, { label, id }) => { + acc[label] = [...(acc?.[label] ?? []), id]; + return acc; + }, {}), + [translated], + ); + + const idToLabel = useMemo( + () => + translated.reduce((acc, { label, id }) => { + acc[id] = label; + return acc; + }, {}), + [translated], + ); + + const filterNames = useMemo( + () => + Object.entries(labelToIds) + .map(([label]) => label) + .sort((a, b) => localeCompare(a, b, i18n.resolvedLanguage)), + [labelToIds], + ); + + const onFilterUpdate = (labels: string[]): void => + onSelectedEnumIdsChange(labels.flatMap((label) => labelToIds[label] ?? [])); + + const selectedFilters = [ + ...new Set(selectedEnumIds.map((id) => idToLabel[id]).filter(Boolean)), + ] as string[]; + + console.warn('non-unique', supportedEnumValues, selectedEnumIds); + console.warn('unique', filterNames, selectedFilters, idToLabel, labelToIds); + return { filterNames, onFilterUpdate, selectedFilters }; +}; + +const EnumFilter = ({ + selectedFilters: selectedEnumIds = [], + onFilterUpdate: onSelectedEnumIdsChange, + supportedValues: supportedEnumValues = [], + title, + placeholderLabel, + filterId, + showFilter, +}: FieldFilterProps) => { + const [isExpanded, setExpanded] = useState(false); + const { filterNames, onFilterUpdate, selectedFilters } = useUnique({ + supportedEnumValues, + onSelectedEnumIdsChange, + selectedEnumIds, + }); + + const deleteFilter = ( + label: string | ToolbarChip | SelectOptionObject, + ): void => + onFilterUpdate( + selectedFilters.filter((filterName) => filterName !== label), + ); + + const hasFilter = (label: string | SelectOptionObject): boolean => + !!selectedFilters.find((filterName) => filterName === label); + + const addFilter = (label: string | SelectOptionObject): void => { + if (typeof label === 'string') { + onFilterUpdate([...selectedFilters, label]); + } + }; + + return ( + deleteFilter(option)} + deleteChipGroup={() => onFilterUpdate([])} + categoryName={title} + showToolbarItem={showFilter} + > + + + ); +}; + +export default EnumFilter; diff --git a/src/Providers/components/FreetextFilter.tsx b/src/Providers/components/FreetextFilter.tsx new file mode 100644 index 000000000..64b5342e1 --- /dev/null +++ b/src/Providers/components/FreetextFilter.tsx @@ -0,0 +1,49 @@ +import React, { useState } from 'react'; + +import { InputGroup, SearchInput, ToolbarFilter } from '@patternfly/react-core'; + +import { FieldFilterProps } from './AttributeValueFilter'; + +const FreetextFilter = ({ + filterId, + selectedFilters, + onFilterUpdate, + title, + showFilter, + placeholderLabel, +}: FieldFilterProps) => { + const [inputValue, setInputValue] = useState(''); + const onTextInput = (): void => { + if (!inputValue || selectedFilters.includes(inputValue)) { + return; + } + onFilterUpdate([...selectedFilters, inputValue]); + setInputValue(''); + }; + return ( + + onFilterUpdate( + selectedFilters?.filter((value) => value !== option) ?? [], + ) + } + deleteChipGroup={() => onFilterUpdate([])} + categoryName={title} + showToolbarItem={showFilter} + > + + setInputValue('')} + /> + + + ); +}; + +export default FreetextFilter; diff --git a/src/Providers/components/PrimaryFilters.tsx b/src/Providers/components/PrimaryFilters.tsx new file mode 100644 index 000000000..2871e5413 --- /dev/null +++ b/src/Providers/components/PrimaryFilters.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { useTranslation } from 'src/internal/i18n'; + +import { ToolbarGroup } from '@patternfly/react-core'; + +import { MetaFilterProps } from './AttributeValueFilter'; + +const PrimaryFilters = ({ + selectedFilters, + onFilterUpdate, + filterTypes, + supportedFilters = {}, +}: MetaFilterProps) => { + const { t } = useTranslation(); + + return ( + + {filterTypes.map(({ id, tKey: fieldKey, filter }) => { + const FieldFilter = supportedFilters[filter.type]; + return ( + FieldFilter && ( + + onFilterUpdate({ + ...selectedFilters, + [id]: values, + }) + } + placeholderLabel={t(filter.placeholderKey)} + selectedFilters={selectedFilters[id] ?? []} + title={t(filter.tKey ?? fieldKey)} + showFilter={true} + supportedValues={filter.values} + /> + ) + ); + })} + + ); +}; + +export default PrimaryFilters; diff --git a/src/Providers/components/ProviderRow.tsx b/src/Providers/components/ProviderRow.tsx new file mode 100644 index 000000000..faba0ccae --- /dev/null +++ b/src/Providers/components/ProviderRow.tsx @@ -0,0 +1,74 @@ +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 { Button, Popover } from '@patternfly/react-core'; +import { Td, Tr } from '@patternfly/react-table'; + +import { NAME, READY, TYPE, URL } from './shared'; +import { RowProps } from './TableView'; +interface CellProps { + value: string; + resource: ProviderResource; +} + +const StatusCell = ({ value, resource }: CellProps) => { + const { t } = useTranslation(); + + return ( + + {resource?.status?.conditions?.map((condition) => { + const severity = getMostSeriousCondition([ + condition as IStatusCondition, + ]); + return ( + + ); + }) ?? 'No information'} + + } + > + + + ); +}; + +const TextCell = ({ value }: CellProps) => <>{value}; + +const cellCreator = { + [NAME]: TextCell, + [READY]: StatusCell, + [URL]: TextCell, + [TYPE]: TextCell, +}; + +const ProviderRow = ({ columns, resource }: RowProps) => { + const { t } = useTranslation(); + + return ( + + {columns.map(({ id, tKey, toValue }) => ( + + {cellCreator?.[id]({ value: toValue(resource), resource }) ?? null} + + ))} + + ); +}; + +export default ProviderRow; diff --git a/src/Providers/components/TableView.tsx b/src/Providers/components/TableView.tsx new file mode 100644 index 000000000..ba8835452 --- /dev/null +++ b/src/Providers/components/TableView.tsx @@ -0,0 +1,104 @@ +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, + 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, + }); + + // in future handle column re-ordering and hiding + const columns = fields; + + 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[]; + 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 new file mode 100644 index 000000000..c396f4629 --- /dev/null +++ b/src/Providers/components/shared.ts @@ -0,0 +1,123 @@ +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; + 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';