From 656f212f77c85c61f63267e732f7aad93fe7bb43 Mon Sep 17 00:00:00 2001 From: Aviv Turgeman Date: Tue, 5 Nov 2024 18:03:56 +0200 Subject: [PATCH] CNV-45822: Tree view part 1 Signed-off-by: Aviv Turgeman --- locales/en/plugin__kubevirt-plugin.json | 5 + .../details/VirtualMachineNavPage.tsx | 29 +-- .../list/VirtualMachinesList.tsx | 13 +- .../VirtualMachineRowLayout.tsx | 4 + .../tree/VirtualMachineTreeView.scss | 25 +++ .../tree/VirtualMachineTreeView.tsx | 137 ++++++++++++++ .../tree/components/TreeViewToolbar.tsx | 80 ++++++++ .../tree/hooks/useHideNamespaceBar.ts | 18 ++ .../tree/hooks/useSyncClicksEffects.ts | 41 ++++ .../tree/hooks/useTreeViewData.ts | 61 ++++++ .../tree/hooks/useTreeViewSearch.ts | 41 ++++ .../tree/icons/PausedVirtualMachineIcon.tsx | 29 +++ .../tree/icons/RunningVirtualMachineIcon.tsx | 29 +++ .../tree/icons/StoppedVirtualMachineIcon.tsx | 36 ++++ src/views/virtualmachines/tree/icons/utils.ts | 18 ++ .../virtualmachines/tree/utils/constants.ts | 9 + .../virtualmachines/tree/utils/utils.tsx | 176 ++++++++++++++++++ 17 files changed, 735 insertions(+), 16 deletions(-) create mode 100644 src/views/virtualmachines/tree/VirtualMachineTreeView.scss create mode 100644 src/views/virtualmachines/tree/VirtualMachineTreeView.tsx create mode 100644 src/views/virtualmachines/tree/components/TreeViewToolbar.tsx create mode 100644 src/views/virtualmachines/tree/hooks/useHideNamespaceBar.ts create mode 100644 src/views/virtualmachines/tree/hooks/useSyncClicksEffects.ts create mode 100644 src/views/virtualmachines/tree/hooks/useTreeViewData.ts create mode 100644 src/views/virtualmachines/tree/hooks/useTreeViewSearch.ts create mode 100644 src/views/virtualmachines/tree/icons/PausedVirtualMachineIcon.tsx create mode 100644 src/views/virtualmachines/tree/icons/RunningVirtualMachineIcon.tsx create mode 100644 src/views/virtualmachines/tree/icons/StoppedVirtualMachineIcon.tsx create mode 100644 src/views/virtualmachines/tree/icons/utils.ts create mode 100644 src/views/virtualmachines/tree/utils/constants.ts create mode 100644 src/views/virtualmachines/tree/utils/utils.tsx diff --git a/locales/en/plugin__kubevirt-plugin.json b/locales/en/plugin__kubevirt-plugin.json index d4759bfea..9c1ac2beb 100644 --- a/locales/en/plugin__kubevirt-plugin.json +++ b/locales/en/plugin__kubevirt-plugin.json @@ -264,6 +264,7 @@ "Clone template": "Clone template", "Clone volume": "Clone volume", "Cloning": "Cloning", + "Close": "Close", "Close header": "Close header", "Closing it will cause the upload to fail. You may still navigate the console.": "Closing it will cause the upload to fail. You may still navigate the console.", "Cloud-init": "Cloud-init", @@ -273,6 +274,7 @@ "Cluster provided": "Cluster provided", "Cluster scope migrations": "Cluster scope migrations", "Collapse": "Collapse", + "Collapse all": "Collapse all", "Column management": "Column management", "Complete time": "Complete time", "Complete time:": "Complete time:", @@ -521,6 +523,7 @@ "Example: For Windows, get a link to the ": "Example: For Windows, get a link to the ", "Example: quay.io/containerdisks/centos:7-2009": "Example: quay.io/containerdisks/centos:7-2009", "Example: your company name": "Example: your company name", + "Expand all": "Expand all", "Explore {{kind}} list": "Explore {{kind}} list", "Expose RDP Service": "Expose RDP Service", "Failed": "Failed", @@ -875,6 +878,7 @@ "Only one disk can be bootable at a time, this option is disabled if the VirtualMachine is running or if this disk is the current boot source": "Only one disk can be bootable at a time, this option is disabled if the VirtualMachine is running or if this disk is the current boot source", "Only one disk can be bootable at a time. The bootable flag will be removed from \"{{initialBootDiskName}}\" and placed on this disk.": "Only one disk can be bootable at a time. The bootable flag will be removed from \"{{initialBootDiskName}}\" and placed on this disk.", "Only VM-related alerts in your project will be shown": "Only VM-related alerts in your project will be shown", + "Open": "Open", "Open Console": "Open Console", "Open logs in a new window": "Open logs in a new window", "Open web console": "Open web console", @@ -1040,6 +1044,7 @@ "Scripts": "Scripts", "SCSI": "SCSI", "SCSI persistent reservation": "SCSI persistent reservation", + "Search": "Search", "Search by {{filterName}}...": "Search by {{filterName}}...", "Search by labels...": "Search by labels...", "Search by name...": "Search by name...", diff --git a/src/views/virtualmachines/details/VirtualMachineNavPage.tsx b/src/views/virtualmachines/details/VirtualMachineNavPage.tsx index 2592e1248..fbc13c05c 100644 --- a/src/views/virtualmachines/details/VirtualMachineNavPage.tsx +++ b/src/views/virtualmachines/details/VirtualMachineNavPage.tsx @@ -6,6 +6,8 @@ import { SidebarEditorProvider } from '@kubevirt-utils/components/SidebarEditor/ import useInstanceTypeExpandSpec from '@kubevirt-utils/resources/vm/hooks/useInstanceTypeExpandSpec'; import { isInstanceTypeVM } from '@kubevirt-utils/resources/vm/utils/instanceTypes'; import { useK8sWatchResource } from '@openshift-console/dynamic-plugin-sdk'; +import { useSignals } from '@preact/signals-react/runtime'; +import VirtualMachineTreeView from '@virtualmachines/tree/VirtualMachineTreeView'; import { useVirtualMachineTabs } from './hooks/useVirtualMachineTabs'; import VirtualMachineNavPageTitle from './VirtualMachineNavPageTitle'; @@ -32,21 +34,24 @@ const VirtualMachineNavPage: React.FC = ({ const [instanceTypeExpandedSpec] = useInstanceTypeExpandSpec(vm); const pages = useVirtualMachineTabs(); + useSignals(); return ( - - -
- + + -
-
+
+ +
+ + ); }; diff --git a/src/views/virtualmachines/list/VirtualMachinesList.tsx b/src/views/virtualmachines/list/VirtualMachinesList.tsx index fd71afe60..54a31a1a9 100644 --- a/src/views/virtualmachines/list/VirtualMachinesList.tsx +++ b/src/views/virtualmachines/list/VirtualMachinesList.tsx @@ -34,6 +34,7 @@ import { import { Flex, FlexItem, Pagination } from '@patternfly/react-core'; import { useSignals } from '@preact/signals-react/runtime'; import useQuery from '@virtualmachines/details/tabs/metrics/NetworkCharts/hook/useQuery'; +import VirtualMachineTreeView from '@virtualmachines/tree/VirtualMachineTreeView'; import { OBJECTS_FETCHING_LIMIT } from '@virtualmachines/utils'; import { useVMListFilters } from '../utils'; @@ -162,12 +163,16 @@ const VirtualMachinesList: FC = ({ kind, namespace }) const allVMsSelected = data?.length === selectedVMs.value.length; if (loaded && noVMs) { - return ; + return ( + + + + ); } return ( - <> - {/* All of this table and components should be replaced to our own fitted components */} + /* All of this table and components should be replaced to our own fitted components */ + @@ -246,7 +251,7 @@ const VirtualMachinesList: FC = ({ kind, namespace }) /> - + ); }; diff --git a/src/views/virtualmachines/list/components/VirtualMachineRow/VirtualMachineRowLayout.tsx b/src/views/virtualmachines/list/components/VirtualMachineRow/VirtualMachineRowLayout.tsx index a2746614d..775ca33e9 100644 --- a/src/views/virtualmachines/list/components/VirtualMachineRow/VirtualMachineRowLayout.tsx +++ b/src/views/virtualmachines/list/components/VirtualMachineRow/VirtualMachineRowLayout.tsx @@ -12,6 +12,7 @@ import { Checkbox } from '@patternfly/react-core'; import VirtualMachineActions from '@virtualmachines/actions/components/VirtualMachineActions/VirtualMachineActions'; import useVirtualMachineActionsProvider from '@virtualmachines/actions/hooks/useVirtualMachineActionsProvider'; import { deselectVM, isVMSelected, selectVM } from '@virtualmachines/list/selectedVMs'; +import { setSelectedTreeItem, treeDataMap } from '@virtualmachines/tree/utils/utils'; import VirtualMachineStatus from '../VirtualMachineStatus/VirtualMachineStatus'; import { VMStatusConditionLabelList } from '../VMStatusConditionLabel'; @@ -43,6 +44,9 @@ const VirtualMachineRowLayout: React.FC< { + setSelectedTreeItem(treeDataMap.value[`${getNamespace(obj)}/${getName(obj)}`]); + }} groupVersionKind={VirtualMachineModelGroupVersionKind} name={getName(obj)} namespace={getNamespace(obj)} diff --git a/src/views/virtualmachines/tree/VirtualMachineTreeView.scss b/src/views/virtualmachines/tree/VirtualMachineTreeView.scss new file mode 100644 index 000000000..388b05ab4 --- /dev/null +++ b/src/views/virtualmachines/tree/VirtualMachineTreeView.scss @@ -0,0 +1,25 @@ +.vms-tree-view { + overflow-x: hidden; +} + +.vms-tree-view-body, +.vms-tree-view-toolbar, +.vms-tree-view-toolbar-content { + padding: 0; +} + +.pf-v5-c-tree-view__node .pf-v5-c-tree-view__node-count, +.vms-tree-view-toolbar-buttons { + display: flex; + flex-grow: 1; + flex-direction: row-reverse; +} + +.vms-tree-view-toolbar-buttons { + align-self: center; + margin-right: var(--pf-global--spacer--sm); + + &__padding { + padding: var(--pf-global--spacer--sm); + } +} diff --git a/src/views/virtualmachines/tree/VirtualMachineTreeView.tsx b/src/views/virtualmachines/tree/VirtualMachineTreeView.tsx new file mode 100644 index 000000000..82e0587cd --- /dev/null +++ b/src/views/virtualmachines/tree/VirtualMachineTreeView.tsx @@ -0,0 +1,137 @@ +import React, { FC, MouseEvent, useMemo } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom-v5-compat'; + +import VirtualMachineModel from '@kubevirt-ui/kubevirt-api/console/models/VirtualMachineModel'; +import { useQueryParamsMethods } from '@kubevirt-utils/components/ListPageFilter/hooks/useQueryParamsMethods'; +import { convertResourceArrayToMap, getResourceUrl } from '@kubevirt-utils/resources/shared'; +import { getContentScrollableElement } from '@kubevirt-utils/utils/utils'; +import { FilterValue, useActiveNamespace } from '@openshift-console/dynamic-plugin-sdk'; +import { + Drawer, + DrawerContent, + DrawerContentBody, + DrawerPanelBody, + DrawerPanelContent, + TreeView, + TreeViewDataItem, +} from '@patternfly/react-core'; +import { TEXT_FILTER_LABELS_ID } from '@virtualmachines/list/hooks/constants'; + +import TreeViewToolbar from './components/TreeViewToolbar'; +import { useHideNamespaceBar } from './hooks/useHideNamespaceBar'; +import { useSyncClicksEffects } from './hooks/useSyncClicksEffects'; +import { useTreeViewData } from './hooks/useTreeViewData'; +import { useTreeViewSearch } from './hooks/useTreeViewSearch'; +import { + FOLDER_SELECTOR_PREFIX, + PROJECT_SELECTOR_PREFIX, + TREE_VIEW_PANEL_ID, + VM_FOLDER_LABEL, +} from './utils/constants'; +import { + createTreeViewData, + selectedTreeItem, + setSelectedTreeItem, + treeViewOpen, +} from './utils/utils'; + +import './VirtualMachineTreeView.scss'; + +type VirtualMachineTreeViewProps = { + onFilterChange?: (type: string, value: FilterValue) => void; +}; + +const VirtualMachineTreeView: FC = ({ children, onFilterChange }) => { + const [activeNamespace] = useActiveNamespace(); + const navigate = useNavigate(); + const location = useLocation(); + + const { setOrRemoveQueryArgument } = useQueryParamsMethods(); + const { isAdmin, loaded, loadError, projectNames, vms } = useTreeViewData(); + const vmsMapper = useMemo(() => convertResourceArrayToMap(vms, true), [vms]); + + const treeData = useMemo( + () => createTreeViewData(projectNames, vms, activeNamespace, isAdmin, location.pathname), + [projectNames, vms, activeNamespace, isAdmin, location.pathname], + ); + + const { filteredItems, onSearch, setShowAll, showAll } = useTreeViewSearch(treeData); + + useSyncClicksEffects(activeNamespace, loaded, location); + useHideNamespaceBar(); + + if (loadError) return <>{children}; + + const onSelect = (_event: MouseEvent, treeViewItem: TreeViewDataItem) => { + setSelectedTreeItem(treeViewItem); + onFilterChange?.(TEXT_FILTER_LABELS_ID, null); + + const treeItemName = treeViewItem.name as string; + if (treeViewItem.id.startsWith(FOLDER_SELECTOR_PREFIX)) { + const [_, folderNamespace] = treeViewItem.id.split('/'); + navigate( + getResourceUrl({ + activeNamespace: folderNamespace, + model: VirtualMachineModel, + }), + ); + setOrRemoveQueryArgument(TEXT_FILTER_LABELS_ID, `${VM_FOLDER_LABEL}=${treeItemName}`); + return onFilterChange?.(TEXT_FILTER_LABELS_ID, { + all: [`${VM_FOLDER_LABEL}=${treeItemName}`], + }); + } + + if (treeViewItem.id.startsWith(PROJECT_SELECTOR_PREFIX)) { + return navigate( + getResourceUrl({ + activeNamespace: treeItemName, + model: VirtualMachineModel, + }), + ); + } + + const [vmNamespace, vmName] = treeViewItem.id.split('/'); + return navigate( + getResourceUrl({ + activeNamespace: vmNamespace, + model: VirtualMachineModel, + resource: vmsMapper?.[vmNamespace]?.[vmName], + }), + ); + }; + + return ( + + + + + } + activeItems={selectedTreeItem.value} + allExpanded={showAll} + data={!treeViewOpen.value ? [] : filteredItems ?? treeData} + hasBadges={loaded} + hasSelectableNodes + onSelect={onSelect} + /> + + + } + > + {children} + + + ); +}; + +export default VirtualMachineTreeView; diff --git a/src/views/virtualmachines/tree/components/TreeViewToolbar.tsx b/src/views/virtualmachines/tree/components/TreeViewToolbar.tsx new file mode 100644 index 000000000..2e583072c --- /dev/null +++ b/src/views/virtualmachines/tree/components/TreeViewToolbar.tsx @@ -0,0 +1,80 @@ +import React, { ChangeEvent, Dispatch, FC, SetStateAction } from 'react'; + +import { useKubevirtTranslation } from '@kubevirt-utils/hooks/useKubevirtTranslation'; +import { + Button, + ButtonVariant, + Toolbar, + ToolbarContent, + ToolbarItem, + Tooltip, + TreeViewSearch, +} from '@patternfly/react-core'; +import { PanelCloseIcon, PanelOpenIcon } from '@patternfly/react-icons'; + +import { + CLOSED_DRAWER_SIZE, + OPEN_DRAWER_SIZE, + PANEL_WIDTH_PROPERTY, + TREE_VIEW_PANEL_ID, + TREE_VIEW_SEARCH_ID, +} from '../utils/constants'; +import { treeViewOpen } from '../utils/utils'; + +type TreeViewToolbarProps = { + onSearch: (event: ChangeEvent) => void; + setShowAll: Dispatch>; + showAll: boolean; +}; + +const TreeViewToolbar: FC = ({ onSearch, setShowAll, showAll }) => { + const { t } = useKubevirtTranslation(); + + const toggleDrawer = () => { + treeViewOpen.value = !treeViewOpen.value; + const panel = document.getElementById(TREE_VIEW_PANEL_ID); + panel.style.setProperty( + PANEL_WIDTH_PROPERTY, + treeViewOpen.value ? OPEN_DRAWER_SIZE : CLOSED_DRAWER_SIZE, + ); + }; + + return ( + + + + {treeViewOpen.value && ( + + )} + + + + + + {treeViewOpen.value && ( + + )} + + + + ); +}; + +export default TreeViewToolbar; diff --git a/src/views/virtualmachines/tree/hooks/useHideNamespaceBar.ts b/src/views/virtualmachines/tree/hooks/useHideNamespaceBar.ts new file mode 100644 index 000000000..d8a1aad7d --- /dev/null +++ b/src/views/virtualmachines/tree/hooks/useHideNamespaceBar.ts @@ -0,0 +1,18 @@ +import { useEffect } from 'react'; + +export const useHideNamespaceBar = () => { + useEffect(() => { + const namespaceBar = document.querySelector('.co-namespace-bar') as HTMLElement; + + const originalDisplay = namespaceBar ? namespaceBar.style.display : ''; + + if (namespaceBar) { + namespaceBar.style.display = 'none'; + } + return () => { + if (namespaceBar) { + namespaceBar.style.display = originalDisplay; + } + }; + }, []); +}; diff --git a/src/views/virtualmachines/tree/hooks/useSyncClicksEffects.ts b/src/views/virtualmachines/tree/hooks/useSyncClicksEffects.ts new file mode 100644 index 000000000..a57eac5c2 --- /dev/null +++ b/src/views/virtualmachines/tree/hooks/useSyncClicksEffects.ts @@ -0,0 +1,41 @@ +import { useEffect } from 'react'; +import { Location } from 'react-router-dom-v5-compat'; + +import { ALL_NAMESPACES, ALL_NAMESPACES_SESSION_KEY } from '@kubevirt-utils/hooks/constants'; + +import { FOLDER_SELECTOR_PREFIX, PROJECT_SELECTOR_PREFIX } from '../utils/constants'; +import { setSelectedTreeItem, treeDataMap } from '../utils/utils'; + +export const useSyncClicksEffects = ( + activeNamespace: string, + loaded: boolean, + location: Location, +) => { + useEffect(() => { + const pathname = location.pathname; + if (loaded) { + const dataMap = treeDataMap.value; + if (pathname.startsWith(`/k8s/${ALL_NAMESPACES}`)) { + setSelectedTreeItem(dataMap[ALL_NAMESPACES_SESSION_KEY]); + return; + } + + const vmName = pathname.split('/')[5]; + if (vmName) { + setSelectedTreeItem(dataMap[`${activeNamespace}/${vmName}`]); + return; + } + + const queryParams = new URLSearchParams(location.search); + const folderFilterName = queryParams.get('labels')?.split('=')?.[1]; + if (folderFilterName) { + setSelectedTreeItem( + dataMap[`${FOLDER_SELECTOR_PREFIX}/${activeNamespace}/${folderFilterName}`], + ); + return; + } + + setSelectedTreeItem(dataMap[`${PROJECT_SELECTOR_PREFIX}/${activeNamespace}`]); + } + }, [activeNamespace, loaded, location.search, location.pathname]); +}; diff --git a/src/views/virtualmachines/tree/hooks/useTreeViewData.ts b/src/views/virtualmachines/tree/hooks/useTreeViewData.ts new file mode 100644 index 000000000..9b66c3b10 --- /dev/null +++ b/src/views/virtualmachines/tree/hooks/useTreeViewData.ts @@ -0,0 +1,61 @@ +import { useMemo } from 'react'; + +import { VirtualMachineModelGroupVersionKind } from '@kubevirt-ui/kubevirt-api/console'; +import { V1VirtualMachine } from '@kubevirt-ui/kubevirt-api/kubevirt'; +import { useIsAdmin } from '@kubevirt-utils/hooks/useIsAdmin'; +import useProjects from '@kubevirt-utils/hooks/useProjects'; +import { useK8sWatchResource, useK8sWatchResources } from '@openshift-console/dynamic-plugin-sdk'; +import { OBJECTS_FETCHING_LIMIT } from '@virtualmachines/utils'; + +type UseTreeViewData = { + isAdmin: boolean; + loaded: boolean; + loadError: any; + projectNames: string[]; + vms: V1VirtualMachine[]; +}; + +export const useTreeViewData = (): UseTreeViewData => { + const isAdmin = useIsAdmin(); + + const [projectNames, projectNamesLoaded, projectNamesError] = useProjects(); + + const [allVMs, allVMsLoaded] = useK8sWatchResource({ + groupVersionKind: VirtualMachineModelGroupVersionKind, + isList: true, + limit: OBJECTS_FETCHING_LIMIT, + }); + + // user has limited access, so we can only get vms from allowed namespaces + const allowedResources = useK8sWatchResources<{ [key: string]: V1VirtualMachine[] }>( + Object.fromEntries( + projectNamesLoaded && !isAdmin + ? (projectNames || []).map((namespace) => [ + namespace, + { + groupVersionKind: VirtualMachineModelGroupVersionKind, + isList: true, + namespace, + }, + ]) + : [], + ), + ); + + const memoizedVMs = useMemo( + () => (isAdmin ? allVMs : Object.values(allowedResources).flatMap((resource) => resource.data)), + [allVMs, allowedResources, isAdmin], + ); + + return { + isAdmin, + loaded: + projectNamesLoaded && + (isAdmin + ? allVMsLoaded + : Object.values(allowedResources).some((resource) => resource.loaded)), + loadError: projectNamesError, + projectNames, + vms: memoizedVMs, + }; +}; diff --git a/src/views/virtualmachines/tree/hooks/useTreeViewSearch.ts b/src/views/virtualmachines/tree/hooks/useTreeViewSearch.ts new file mode 100644 index 000000000..0b06a295c --- /dev/null +++ b/src/views/virtualmachines/tree/hooks/useTreeViewSearch.ts @@ -0,0 +1,41 @@ +import { ChangeEvent, Dispatch, SetStateAction, useCallback, useState } from 'react'; + +import { TreeViewDataItem } from '@patternfly/react-core'; + +import { filterItems } from '../utils/utils'; + +type UseTreeViewSearch = (treeData: TreeViewDataItem[]) => { + filteredItems: TreeViewDataItem[]; + onSearch: (event: ChangeEvent) => void; + setShowAll: Dispatch>; + showAll: boolean; +}; + +export const useTreeViewSearch: UseTreeViewSearch = (treeData) => { + const [filteredItems, setFilteredItems] = useState(); + const [showAll, setShowAll] = useState(); + + const onSearch = useCallback( + (event: ChangeEvent) => { + const input = event.target.value; + if (input === '') { + return setFilteredItems(treeData); + } + + const filtered = treeData + .map((opt) => Object.assign({}, opt)) + .filter((item) => filterItems(item, input)); + + setFilteredItems(filtered); + setShowAll(true); + }, + [treeData], + ); + + return { + filteredItems, + onSearch, + setShowAll, + showAll, + }; +}; diff --git a/src/views/virtualmachines/tree/icons/PausedVirtualMachineIcon.tsx b/src/views/virtualmachines/tree/icons/PausedVirtualMachineIcon.tsx new file mode 100644 index 000000000..c8311f02a --- /dev/null +++ b/src/views/virtualmachines/tree/icons/PausedVirtualMachineIcon.tsx @@ -0,0 +1,29 @@ +import React, { FC } from 'react'; + +const PausedVirtualMachineIcon: FC = () => { + return ( + + + + + + ); +}; + +export default PausedVirtualMachineIcon; diff --git a/src/views/virtualmachines/tree/icons/RunningVirtualMachineIcon.tsx b/src/views/virtualmachines/tree/icons/RunningVirtualMachineIcon.tsx new file mode 100644 index 000000000..540c47715 --- /dev/null +++ b/src/views/virtualmachines/tree/icons/RunningVirtualMachineIcon.tsx @@ -0,0 +1,29 @@ +import React, { FC } from 'react'; + +const RunningVirtualMachineIcon: FC = () => { + return ( + + + + + + ); +}; + +export default RunningVirtualMachineIcon; diff --git a/src/views/virtualmachines/tree/icons/StoppedVirtualMachineIcon.tsx b/src/views/virtualmachines/tree/icons/StoppedVirtualMachineIcon.tsx new file mode 100644 index 000000000..7f8500f4f --- /dev/null +++ b/src/views/virtualmachines/tree/icons/StoppedVirtualMachineIcon.tsx @@ -0,0 +1,36 @@ +import React, { FC } from 'react'; + +const StoppedVirtualMachineIcon: FC = () => { + return ( + + + + + + + + + + + + + ); +}; + +export default StoppedVirtualMachineIcon; diff --git a/src/views/virtualmachines/tree/icons/utils.ts b/src/views/virtualmachines/tree/icons/utils.ts new file mode 100644 index 000000000..c5cc696b8 --- /dev/null +++ b/src/views/virtualmachines/tree/icons/utils.ts @@ -0,0 +1,18 @@ +import { VirtualMachineIcon } from '@patternfly/react-icons'; +import { printableVMStatus } from '@virtualmachines/utils'; + +import PausedVirtualMachineIcon from './PausedVirtualMachineIcon'; +import RunningVirtualMachineIcon from './RunningVirtualMachineIcon'; +import StoppedVirtualMachineIcon from './StoppedVirtualMachineIcon'; + +const statusIconMapper = { + [printableVMStatus.Paused]: PausedVirtualMachineIcon, + [printableVMStatus.Running]: RunningVirtualMachineIcon, + [printableVMStatus.Stopped]: StoppedVirtualMachineIcon, +}; + +export const statusIcon = new Proxy(statusIconMapper, { + get(target, prop: string) { + return target[prop] ?? VirtualMachineIcon; + }, +}); diff --git a/src/views/virtualmachines/tree/utils/constants.ts b/src/views/virtualmachines/tree/utils/constants.ts new file mode 100644 index 000000000..973f61248 --- /dev/null +++ b/src/views/virtualmachines/tree/utils/constants.ts @@ -0,0 +1,9 @@ +export const VM_FOLDER_LABEL = 'vm.openshift.io/folder'; +export const PROJECT_SELECTOR_PREFIX = 'projectSelector'; +export const FOLDER_SELECTOR_PREFIX = 'folderSelector'; + +export const TREE_VIEW_PANEL_ID = 'vms-tree-view-panel'; +export const TREE_VIEW_SEARCH_ID = 'vms-tree-view-search-input'; +export const OPEN_DRAWER_SIZE = '400px'; +export const CLOSED_DRAWER_SIZE = '30px'; +export const PANEL_WIDTH_PROPERTY = '--pf-v5-c-drawer__panel--md--FlexBasis'; diff --git a/src/views/virtualmachines/tree/utils/utils.tsx b/src/views/virtualmachines/tree/utils/utils.tsx new file mode 100644 index 000000000..04f0beb36 --- /dev/null +++ b/src/views/virtualmachines/tree/utils/utils.tsx @@ -0,0 +1,176 @@ +import React from 'react'; + +import { V1VirtualMachine } from '@kubevirt-ui/kubevirt-api/kubevirt'; +import { ALL_NAMESPACES_SESSION_KEY, ALL_PROJECTS } from '@kubevirt-utils/hooks/constants'; +import { getLabel, getName, getNamespace } from '@kubevirt-utils/resources/shared'; +import { TreeViewDataItem } from '@patternfly/react-core'; +import { FolderIcon, FolderOpenIcon, ProjectDiagramIcon } from '@patternfly/react-icons'; +import { signal } from '@preact/signals-react'; + +import { statusIcon } from '../icons/utils'; + +import { FOLDER_SELECTOR_PREFIX, PROJECT_SELECTOR_PREFIX, VM_FOLDER_LABEL } from './constants'; + +export const treeViewOpen = signal(true); +export const treeDataMap = signal>(null); +export const selectedTreeItem = signal(null); +export const setSelectedTreeItem = (selected: TreeViewDataItem) => + (selectedTreeItem.value = [selected]); +const buildProjectMap = ( + vms: V1VirtualMachine[], + currentPageVMName: string, + treeViewDataMap: Record, +) => { + const projectMap: Record< + string, + { folders: Record; ungrouped: TreeViewDataItem[] } + > = {}; + + vms.forEach((vm) => { + const vmNamespace = getNamespace(vm); + const vmName = getName(vm); + const folder = getLabel(vm, VM_FOLDER_LABEL); + const vmTreeItemID = `${vmNamespace}/${vmName}`; + const VMStatusIcon = statusIcon[vm?.status?.printableStatus]; + + const vmTreeItem: TreeViewDataItem = { + defaultExpanded: currentPageVMName && currentPageVMName === vmName, + icon: , + id: vmTreeItemID, + name: vmName, + }; + + if (!treeViewDataMap[vmTreeItemID]) { + treeViewDataMap[vmTreeItemID] = vmTreeItem; + } + + if (!projectMap[vmNamespace]) { + projectMap[vmNamespace] = { folders: {}, ungrouped: [] }; + } + + if (folder) { + if (!projectMap[vmNamespace].folders[folder]) { + projectMap[vmNamespace].folders[folder] = []; + } + return projectMap[vmNamespace].folders[folder].push(vmTreeItem); + } + + projectMap[vmNamespace].ungrouped.push(vmTreeItem); + }); + + return projectMap; +}; + +const createFolderTreeItems = ( + folders: Record, + project: string, + currentPageVMName: string, + treeViewDataMap: Record, +): TreeViewDataItem[] => + Object.entries(folders).map(([folder, vmItems]) => { + const folderTreeItemID = `${FOLDER_SELECTOR_PREFIX}/${project}/${folder}`; + const folderExpanded = + currentPageVMName && vmItems.some((item) => (item.name as string) === currentPageVMName); + + const folderTreeItem: TreeViewDataItem = { + children: vmItems, + defaultExpanded: folderExpanded, + expandedIcon: , + icon: , + id: folderTreeItemID, + name: folder, + }; + + if (!treeViewDataMap[folderTreeItemID]) { + treeViewDataMap[folderTreeItemID] = folderTreeItem; + } + + return folderTreeItem; + }); + +const createProjectTreeItem = ( + project: string, + projectMap: Record, + activeNamespace: string, + currentPageVMName: string, + treeViewDataMap: Record, +): TreeViewDataItem => { + const projectFolders = createFolderTreeItems( + projectMap[project]?.folders || {}, + project, + currentPageVMName, + treeViewDataMap, + ); + + const projectChildren = [...projectFolders, ...(projectMap[project]?.ungrouped || [])]; + + const projectTreeItemID = `${PROJECT_SELECTOR_PREFIX}/${project}`; + const projectTreeItem: TreeViewDataItem = { + children: projectChildren, + defaultExpanded: project === activeNamespace, + icon: , + id: projectTreeItemID, + name: project, + }; + + if (!treeViewDataMap[projectTreeItemID]) { + treeViewDataMap[projectTreeItemID] = projectTreeItem; + } + + return projectTreeItem; +}; + +const createAllNamespacesTreeItem = ( + treeViewData: TreeViewDataItem[], + treeViewDataMap: Record, +): TreeViewDataItem => { + const allNamespacesTreeItem: TreeViewDataItem = { + children: treeViewData, + defaultExpanded: true, + icon: , + id: ALL_NAMESPACES_SESSION_KEY, + name: ALL_PROJECTS, + }; + if (!treeViewDataMap[ALL_NAMESPACES_SESSION_KEY]) { + treeViewDataMap[ALL_NAMESPACES_SESSION_KEY] = allNamespacesTreeItem; + } + treeDataMap.value = treeViewDataMap; + return allNamespacesTreeItem; +}; + +export const createTreeViewData = ( + projectNames: string[], + vms: V1VirtualMachine[], + activeNamespace: string, + isAdmin: boolean, + pathname: string, +): TreeViewDataItem[] => { + const currentPageVMName = pathname.split('/')[5]; + const treeViewDataMap: Record = {}; + const projectMap = buildProjectMap(vms, currentPageVMName, treeViewDataMap); + + const treeViewData = projectNames.map((project) => + createProjectTreeItem(project, projectMap, activeNamespace, currentPageVMName, treeViewDataMap), + ); + + if (isAdmin) { + const allNamespacesTreeItem = createAllNamespacesTreeItem(treeViewData, treeViewDataMap); + return [allNamespacesTreeItem]; + } + + treeDataMap.value = treeViewDataMap; + return treeViewData; +}; + +export const filterItems = (item: TreeViewDataItem, input: string) => { + if ((item.name as string).toLowerCase().includes(input.toLowerCase())) { + return true; + } + if (item.children) { + return ( + (item.children = item.children + .map((opt) => Object.assign({}, opt)) + .filter((child) => filterItems(child, input))).length > 0 + ); + } +};