From 037c00f3ec8e5d49a84d69b041c6ba39cb5e3972 Mon Sep 17 00:00:00 2001 From: Oleksandr Dubenko Date: Fri, 20 Dec 2024 16:11:33 +0100 Subject: [PATCH] frontend: Refactor prepareSidebarItems into a hook Signed-off-by: Oleksandr Dubenko --- .../src/components/Sidebar/NavigationTabs.tsx | 4 +- frontend/src/components/Sidebar/Sidebar.tsx | 23 +- .../src/components/Sidebar/prepareRoutes.ts | 320 ----------------- .../src/components/Sidebar/useSidebarItems.ts | 322 ++++++++++++++++++ 4 files changed, 327 insertions(+), 342 deletions(-) delete mode 100644 frontend/src/components/Sidebar/prepareRoutes.ts create mode 100644 frontend/src/components/Sidebar/useSidebarItems.ts diff --git a/frontend/src/components/Sidebar/NavigationTabs.tsx b/frontend/src/components/Sidebar/NavigationTabs.tsx index b6a8fdd3dc..f69da3841a 100644 --- a/frontend/src/components/Sidebar/NavigationTabs.tsx +++ b/frontend/src/components/Sidebar/NavigationTabs.tsx @@ -9,7 +9,7 @@ import { getCluster, getClusterPrefixedPath } from '../../lib/util'; import { useTypedSelector } from '../../redux/reducers/reducers'; import Tabs from '../common/Tabs'; import { SidebarItemProps } from '../Sidebar'; -import prepareRoutes from './prepareRoutes'; +import { useSidebarItems } from './useSidebarItems'; function searchNameInSubList(sublist: SidebarItemProps['subList'], name: string): boolean { if (!sublist) { @@ -54,7 +54,7 @@ export default function NavigationTabs() { } let defaultIndex = null; - const listItems = prepareRoutes(t, sidebar.selected.sidebar || ''); + const listItems = useSidebarItems(sidebar.selected.sidebar ?? undefined); let navigationItem = listItems.find(item => item.name === sidebar.selected.item); if (!navigationItem) { const parent = findParentOfSubList(listItems, sidebar.selected.item); diff --git a/frontend/src/components/Sidebar/Sidebar.tsx b/frontend/src/components/Sidebar/Sidebar.tsx index 529db8ab55..3c67ab16d5 100644 --- a/frontend/src/components/Sidebar/Sidebar.tsx +++ b/frontend/src/components/Sidebar/Sidebar.tsx @@ -10,13 +10,11 @@ import { useTranslation } from 'react-i18next'; import { useDispatch } from 'react-redux'; import { useHistory } from 'react-router-dom'; import helpers from '../../helpers'; -import { useCluster } from '../../lib/k8s'; import { createRouteURL } from '../../lib/router'; import { useTypedSelector } from '../../redux/reducers/reducers'; import { ActionButton } from '../common'; import CreateButton from '../common/Resource/CreateButton'; import NavigationTabs from './NavigationTabs'; -import prepareRoutes from './prepareRoutes'; import SidebarItem from './SidebarItem'; import { DefaultSidebars, @@ -24,6 +22,7 @@ import { setWhetherSidebarOpen, SidebarEntry, } from './sidebarSlice'; +import { useSidebarItems } from './useSidebarItems'; import VersionButton from './VersionButton'; export const drawerWidth = 240; @@ -154,8 +153,6 @@ const DefaultLinkArea = memo((props: { sidebarName: string; isOpen: boolean }) = }); export default function Sidebar() { - const { t, i18n } = useTranslation(['glossary', 'translation']); - const sidebar = useTypedSelector(state => state.sidebar); const { isOpen, @@ -165,24 +162,10 @@ export default function Sidebar() { isTemporary: isTemporaryDrawer, } = useSidebarInfo(); const isNarrowOnly = isNarrow && !canExpand; - const arePluginsLoaded = useTypedSelector(state => state.plugins.loaded); const namespaces = useTypedSelector(state => state.filter.namespaces); const dispatch = useDispatch(); - const cluster = useCluster(); - const items = React.useMemo(() => { - // If the sidebar is null, then it means it should not be visible. - if (sidebar.selected.sidebar === null) { - return []; - } - return prepareRoutes(t, sidebar.selected.sidebar || ''); - }, [ - cluster, - sidebar.selected, - sidebar.entries, - sidebar.filters, - i18n.language, - arePluginsLoaded, - ]); + + const items = useSidebarItems(sidebar?.selected?.sidebar ?? undefined); const search = namespaces.size !== 0 ? `?namespace=${[...namespaces].join('+')}` : ''; diff --git a/frontend/src/components/Sidebar/prepareRoutes.ts b/frontend/src/components/Sidebar/prepareRoutes.ts deleted file mode 100644 index 7cec3befa3..0000000000 --- a/frontend/src/components/Sidebar/prepareRoutes.ts +++ /dev/null @@ -1,320 +0,0 @@ -import _ from 'lodash'; -import helpers from '../../helpers'; -import { createRouteURL } from '../../lib/router'; -import { getCluster } from '../../lib/util'; -import store from '../../redux/stores/store'; -import { DefaultSidebars, SidebarItemProps } from '../Sidebar'; - -// @todo: We should convert this to a hook so we can update it automatically when -// needed. -function prepareRoutes( - t: (arg: string) => string, - sidebarToReturn: string = DefaultSidebars.IN_CLUSTER -) { - // We do not show the home view if there is only one cluster and we cannot - // add new clusters, as it is redundant. - const clusters = store.getState()?.config?.clusters || {}; - const showHome = helpers.isElectron() || Object.keys(clusters).length !== 1; - const defaultClusterURL = createRouteURL('cluster', { cluster: Object.keys(clusters)[0] }); - - const homeItems: SidebarItemProps[] = [ - { - name: 'home', - icon: showHome ? 'mdi:home' : 'mdi:hexagon-multiple-outline', - label: showHome ? t('translation|Home') : t('glossary|Cluster'), - url: showHome ? '/' : defaultClusterURL, - divider: !showHome, - }, - { - name: 'notifications', - icon: 'mdi:bell', - label: t('translation|Notifications'), - url: '/notifications', - }, - { - name: 'settings', - icon: 'mdi:cog', - label: t('translation|Settings'), - url: '/settings/general', - subList: [ - { - name: 'settingsGeneral', - label: t('translation|General'), - url: '/settings/general', - }, - { - name: 'plugins', - label: t('translation|Plugins'), - url: '/settings/plugins', - }, - { - name: 'settingsCluster', - label: t('glossary|Cluster'), - url: '/settings/cluster', - }, - ], - }, - ]; - const inClusterItems: SidebarItemProps[] = [ - { - name: 'home', - icon: 'mdi:home', - label: t('translation|Home'), - url: '/', - divider: true, - hide: !showHome, - }, - { - name: 'cluster', - label: t('glossary|Cluster'), - subtitle: getCluster() || undefined, - icon: 'mdi:hexagon-multiple-outline', - subList: [ - { - name: 'namespaces', - label: t('glossary|Namespaces'), - }, - { - name: 'nodes', - label: t('glossary|Nodes'), - }, - ], - }, - { - name: 'workloads', - label: t('glossary|Workloads'), - icon: 'mdi:circle-slice-2', - subList: [ - { - name: 'Pods', - label: t('glossary|Pods'), - }, - { - name: 'Deployments', - label: t('glossary|Deployments'), - }, - { - name: 'StatefulSets', - label: t('glossary|Stateful Sets'), - }, - { - name: 'DaemonSets', - label: t('glossary|Daemon Sets'), - }, - { - name: 'ReplicaSets', - label: t('glossary|Replica Sets'), - }, - { - name: 'Jobs', - label: t('glossary|Jobs'), - }, - { - name: 'CronJobs', - label: t('glossary|CronJobs'), - }, - ], - }, - { - name: 'storage', - label: t('glossary|Storage'), - icon: 'mdi:database', - subList: [ - { - name: 'persistentVolumeClaims', - label: t('glossary|Persistent Volume Claims'), - }, - { - name: 'persistentVolumes', - label: t('glossary|Persistent Volumes'), - }, - { - name: 'storageClasses', - label: t('glossary|Storage Classes'), - }, - ], - }, - { - name: 'network', - label: t('glossary|Network'), - icon: 'mdi:folder-network-outline', - subList: [ - { - name: 'services', - label: t('glossary|Services'), - }, - { - name: 'endpoints', - label: t('glossary|Endpoints'), - }, - { - name: 'ingresses', - label: t('glossary|Ingresses'), - }, - { - name: 'ingressclasses', - label: t('glossary|Ingress Classes'), - }, - { - name: 'portforwards', - label: t('glossary|Port Forwarding'), - hide: !helpers.isElectron(), - }, - { - name: 'NetworkPolicies', - label: t('glossary|Network Policies'), - }, - ], - }, - { - name: 'security', - label: t('glossary|Security'), - icon: 'mdi:lock', - subList: [ - { - name: 'serviceAccounts', - label: t('glossary|Service Accounts'), - }, - { - name: 'roles', - label: t('glossary|Roles'), - }, - { - name: 'roleBindings', - label: t('glossary|Role Bindings'), - }, - ], - }, - { - name: 'config', - label: t('glossary|Configuration'), - icon: 'mdi:format-list-checks', - subList: [ - { - name: 'configMaps', - label: t('glossary|Config Maps'), - }, - { - name: 'secrets', - label: t('glossary|Secrets'), - }, - { - name: 'horizontalPodAutoscalers', - label: t('glossary|HPAs'), - }, - { - name: 'verticalPodAutoscalers', - label: t('glossary|VPAs'), - }, - { - name: 'podDisruptionBudgets', - label: t('glossary|Pod Disruption Budgets'), - }, - { - name: 'resourceQuotas', - label: t('glossary|Resource Quotas'), - }, - { - name: 'limitRanges', - label: t('glossary|Limit Ranges'), - }, - { - name: 'priorityClasses', - label: t('glossary|Priority Classes'), - }, - { - name: 'runtimeClasses', - label: t('glossary|Runtime Classes'), - }, - { - name: 'leases', - label: t('glossary|Leases'), - }, - { - name: 'mutatingWebhookConfigurations', - label: t('glossary|Mutating Webhook Configurations'), - }, - { - name: 'validatingWebhookConfigurations', - label: t('glossary|Validating Webhook Configurations'), - }, - ], - }, - { - name: 'crds', - label: t('glossary|Custom Resources'), - icon: 'mdi:puzzle', - subList: [ - { - name: 'crs', - label: t('translation|Instances'), - }, - ], - }, - { - name: 'map', - icon: 'mdi:map', - label: t('glossary|Map (beta)'), - }, - ]; - - const sidebars: { [key: string]: SidebarItemProps[] } = { - [DefaultSidebars.IN_CLUSTER]: _.cloneDeep(inClusterItems), - [DefaultSidebars.HOME]: _.cloneDeep(homeItems), - }; - - const items = store.getState().sidebar.entries; - const filters = store.getState().sidebar.filters; - - for (const i of Object.values(items)) { - const item = _.cloneDeep(i); - // For back-compatibility reasons, the default sidebar is the in-cluster one. - const desiredSidebar = item.sidebar || DefaultSidebars.IN_CLUSTER; - let itemsSidebar = sidebars[desiredSidebar]; - if (!itemsSidebar) { - itemsSidebar = []; - sidebars[desiredSidebar] = itemsSidebar; - } - - const parent = item.parent ? itemsSidebar.find(({ name }) => name === item.parent) : null; - let placement = itemsSidebar; - if (parent) { - if (!parent['subList']) { - parent['subList'] = []; - } - - placement = parent['subList']; - } - - placement.push(item); - } - - // Filter the routes, if we have any filters. - // @todo: We need to deprecate this and implement a list processor. - const filteredRoutes = []; - const defaultRoutes: SidebarItemProps[] = sidebars[DefaultSidebars.IN_CLUSTER]; - for (const route of defaultRoutes) { - const routeFiltered = - !route.hide && filters.length > 0 && filters.filter(f => f(route)).length !== filters.length; - if (routeFiltered) { - continue; - } - - const newSubList = route.subList?.filter( - subRoute => - !subRoute.hide && - !(filters.length > 0 && filters.filter(f => f(subRoute)).length !== filters.length) - ); - route.subList = newSubList; - - filteredRoutes.push(route); - } - - if (!sidebarToReturn || sidebarToReturn === DefaultSidebars.IN_CLUSTER) { - return filteredRoutes; - } - - return sidebars[sidebarToReturn]; -} - -export default prepareRoutes; diff --git a/frontend/src/components/Sidebar/useSidebarItems.ts b/frontend/src/components/Sidebar/useSidebarItems.ts new file mode 100644 index 0000000000..e3cad33fff --- /dev/null +++ b/frontend/src/components/Sidebar/useSidebarItems.ts @@ -0,0 +1,322 @@ +import _ from 'lodash'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import helpers from '../../helpers'; +import { createRouteURL } from '../../lib/router'; +import { getCluster } from '../../lib/util'; +import { useTypedSelector } from '../../redux/reducers/reducers'; +import { DefaultSidebars, SidebarEntry, SidebarItemProps } from '.'; + +export const useSidebarItems = (sidebarName: string = DefaultSidebars.IN_CLUSTER) => { + const clusters = useTypedSelector(state => state.config.clusters) ?? {}; + const customSidebarEntries = useTypedSelector(state => state.sidebar.entries); + const customSidebarFilters = useTypedSelector(state => state.sidebar.filters); + const shouldShowHomeItem = helpers.isElectron() || Object.keys(clusters).length !== 1; + const { t } = useTranslation(); + + const sidebars = useMemo(() => { + const homeItems: SidebarItemProps[] = [ + { + name: 'home', + icon: shouldShowHomeItem ? 'mdi:home' : 'mdi:hexagon-multiple-outline', + label: shouldShowHomeItem ? t('translation|Home') : t('glossary|Cluster'), + url: shouldShowHomeItem + ? '/' + : createRouteURL('cluster', { cluster: Object.keys(clusters)[0] }), + divider: !shouldShowHomeItem, + }, + { + name: 'notifications', + icon: 'mdi:bell', + label: t('translation|Notifications'), + url: '/notifications', + }, + { + name: 'settings', + icon: 'mdi:cog', + label: t('translation|Settings'), + url: '/settings/general', + subList: [ + { + name: 'settingsGeneral', + label: t('translation|General'), + url: '/settings/general', + }, + { + name: 'plugins', + label: t('translation|Plugins'), + url: '/settings/plugins', + }, + { + name: 'settingsCluster', + label: t('glossary|Cluster'), + url: '/settings/cluster', + }, + ], + }, + ]; + const inClusterItems: SidebarItemProps[] = [ + { + name: 'home', + icon: 'mdi:home', + label: t('translation|Home'), + url: '/', + divider: true, + hide: !shouldShowHomeItem, + }, + { + name: 'cluster', + label: t('glossary|Cluster'), + subtitle: getCluster() || undefined, + icon: 'mdi:hexagon-multiple-outline', + subList: [ + { + name: 'namespaces', + label: t('glossary|Namespaces'), + }, + { + name: 'nodes', + label: t('glossary|Nodes'), + }, + ], + }, + { + name: 'workloads', + label: t('glossary|Workloads'), + icon: 'mdi:circle-slice-2', + subList: [ + { + name: 'Pods', + label: t('glossary|Pods'), + }, + { + name: 'Deployments', + label: t('glossary|Deployments'), + }, + { + name: 'StatefulSets', + label: t('glossary|Stateful Sets'), + }, + { + name: 'DaemonSets', + label: t('glossary|Daemon Sets'), + }, + { + name: 'ReplicaSets', + label: t('glossary|Replica Sets'), + }, + { + name: 'Jobs', + label: t('glossary|Jobs'), + }, + { + name: 'CronJobs', + label: t('glossary|CronJobs'), + }, + ], + }, + { + name: 'storage', + label: t('glossary|Storage'), + icon: 'mdi:database', + subList: [ + { + name: 'persistentVolumeClaims', + label: t('glossary|Persistent Volume Claims'), + }, + { + name: 'persistentVolumes', + label: t('glossary|Persistent Volumes'), + }, + { + name: 'storageClasses', + label: t('glossary|Storage Classes'), + }, + ], + }, + { + name: 'network', + label: t('glossary|Network'), + icon: 'mdi:folder-network-outline', + subList: [ + { + name: 'services', + label: t('glossary|Services'), + }, + { + name: 'endpoints', + label: t('glossary|Endpoints'), + }, + { + name: 'ingresses', + label: t('glossary|Ingresses'), + }, + { + name: 'ingressclasses', + label: t('glossary|Ingress Classes'), + }, + { + name: 'portforwards', + label: t('glossary|Port Forwarding'), + hide: !helpers.isElectron(), + }, + { + name: 'NetworkPolicies', + label: t('glossary|Network Policies'), + }, + ], + }, + { + name: 'security', + label: t('glossary|Security'), + icon: 'mdi:lock', + subList: [ + { + name: 'serviceAccounts', + label: t('glossary|Service Accounts'), + }, + { + name: 'roles', + label: t('glossary|Roles'), + }, + { + name: 'roleBindings', + label: t('glossary|Role Bindings'), + }, + ], + }, + { + name: 'config', + label: t('glossary|Configuration'), + icon: 'mdi:format-list-checks', + subList: [ + { + name: 'configMaps', + label: t('glossary|Config Maps'), + }, + { + name: 'secrets', + label: t('glossary|Secrets'), + }, + { + name: 'horizontalPodAutoscalers', + label: t('glossary|HPAs'), + }, + { + name: 'verticalPodAutoscalers', + label: t('glossary|VPAs'), + }, + { + name: 'podDisruptionBudgets', + label: t('glossary|Pod Disruption Budgets'), + }, + { + name: 'resourceQuotas', + label: t('glossary|Resource Quotas'), + }, + { + name: 'limitRanges', + label: t('glossary|Limit Ranges'), + }, + { + name: 'priorityClasses', + label: t('glossary|Priority Classes'), + }, + { + name: 'runtimeClasses', + label: t('glossary|Runtime Classes'), + }, + { + name: 'leases', + label: t('glossary|Leases'), + }, + { + name: 'mutatingWebhookConfigurations', + label: t('glossary|Mutating Webhook Configurations'), + }, + { + name: 'validatingWebhookConfigurations', + label: t('glossary|Validating Webhook Configurations'), + }, + ], + }, + { + name: 'crds', + label: t('glossary|Custom Resources'), + icon: 'mdi:puzzle', + subList: [ + { + name: 'crs', + label: t('translation|Instances'), + }, + ], + }, + { + name: 'map', + icon: 'mdi:map', + label: t('glossary|Map (beta)'), + }, + ]; + + const sidebars: Record = { + [DefaultSidebars.HOME]: homeItems, + [DefaultSidebars.IN_CLUSTER]: inClusterItems, + }; + + // Set of all the entries that need to be added + const entriesToAdd = new Set(_.cloneDeep(Object.values(customSidebarEntries))); + + // Takes entry from the set and places it in the appropriate sidebar or a parent item + // Recursively looks for child items + const placeSidebarEntry = (entry: SidebarItemProps, parentEntry?: SidebarItemProps) => { + // Skip entries with parents, they're handled in a separate loop + if (entry.parent && !parentEntry) return; + + // Add entry to the sidebar or parent item + if (parentEntry) { + parentEntry.subList ??= []; + parentEntry.subList.push(entry); + } else { + const entrySidebarName = entry.sidebar ?? DefaultSidebars.IN_CLUSTER; + sidebars[entrySidebarName] ??= []; + sidebars[entrySidebarName].push(entry); + } + entriesToAdd.delete(entry); + + // Find and place all child entries + entriesToAdd.forEach(maybeChildEntry => { + if (maybeChildEntry.parent === entry.name) { + placeSidebarEntry(maybeChildEntry, entry); + } + }); + }; + + entriesToAdd.forEach(entry => placeSidebarEntry(entry)); + + if (entriesToAdd.size > 0) { + console.error(`Couldn't find where to put some sidebar entries`, entriesToAdd.values()); + } + + // Filter in-cluster sidebar + if (customSidebarFilters.length > 0) { + const filterSublist = (item: SidebarItemProps, filter: any) => { + if (item.subList) { + item.subList = item.subList.filter(it => filter(it)); + item.subList = item.subList.map(it => filterSublist(it, filter)); + } + + return item; + }; + + customSidebarFilters.forEach(customFilter => { + sidebars[DefaultSidebars.IN_CLUSTER] = sidebars[DefaultSidebars.IN_CLUSTER] + .filter(it => customFilter(it)) + .map(it => filterSublist(it, customFilter)); + }); + } + + return sidebars; + }, [customSidebarEntries, shouldShowHomeItem, Object.keys(clusters).join(',')]); + + return sidebars[sidebarName]; +};