diff --git a/frontend/src/components/App/Home/ClusterTable.tsx b/frontend/src/components/App/Home/ClusterTable.tsx index 8c58bbde6a9..f45dc873531 100644 --- a/frontend/src/components/App/Home/ClusterTable.tsx +++ b/frontend/src/components/App/Home/ClusterTable.tsx @@ -19,11 +19,17 @@ import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; import { useTheme } from '@mui/material/styles'; import Typography from '@mui/material/Typography'; -import { useMemo } from 'react'; +import { + MRT_ColumnFiltersState, + MRT_SortingState, + MRT_VisibilityState, +} from 'material-react-table'; +import { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { generatePath, useHistory } from 'react-router-dom'; import { getClusterAppearanceFromMeta } from '../../../helpers/clusterAppearance'; import { isElectron } from '../../../helpers/isElectron'; +import { loadTableSettings, storeTableSettings } from '../../../helpers/tableSettings'; import { formatClusterPathParam } from '../../../lib/cluster'; import { useClustersConf, useClustersVersion } from '../../../lib/k8s'; import { ApiError } from '../../../lib/k8s/api/v2/ApiError'; @@ -34,6 +40,7 @@ import { useTypedSelector } from '../../../redux/hooks'; import { Loader } from '../../common'; import Link from '../../common/Link'; import Table from '../../common/Table'; +import { useLocalStorageState } from '../../globalSearch/useLocalStorageState'; import ClusterBadge from '../../Sidebar/ClusterBadge'; import ClusterContextMenu from './ClusterContextMenu'; import { MULTI_HOME_ENABLED } from './config'; @@ -115,6 +122,8 @@ export interface ClusterTableProps { /** * ClusterTable component displays a table of clusters with their status, origin, and version. */ +const CLUSTER_TABLE_ID = 'home-clusters'; + export default function ClusterTable({ customNameClusters, versions, @@ -125,6 +134,54 @@ export default function ClusterTable({ const history = useHistory(); const { t } = useTranslation(['translation']); + const [columnVisibility, setColumnVisibility] = useState(() => { + const visibility: Record = {}; + const stored = loadTableSettings(CLUSTER_TABLE_ID); + stored.forEach(({ id, show }) => (visibility[id] = show)); + return visibility; + }); + + const [sorting, setSorting] = useLocalStorageState( + `table_sorting.${CLUSTER_TABLE_ID}`, + [{ id: 'name', desc: false }] + ); + + const [columnFilters, setColumnFilters] = useLocalStorageState( + `table_filters.${CLUSTER_TABLE_ID}`, + [] + ); + + const handleColumnVisibilityChange = useCallback( + (updater: MRT_VisibilityState | ((old: MRT_VisibilityState) => MRT_VisibilityState)) => { + setColumnVisibility(oldCols => { + const newCols = typeof updater === 'function' ? updater(oldCols) : updater; + const colsToStore = Object.entries(newCols).map(([id, show]) => ({ + id, + show: (show ?? true) as boolean, + })); + storeTableSettings(CLUSTER_TABLE_ID, colsToStore); + return newCols; + }); + }, + [] + ); + + const handleSortingChange = useCallback( + (updater: MRT_SortingState | ((old: MRT_SortingState) => MRT_SortingState)) => { + setSorting(old => (typeof updater === 'function' ? updater(old) : updater)); + }, + [setSorting] + ); + + const handleColumnFiltersChange = useCallback( + ( + updater: MRT_ColumnFiltersState | ((old: MRT_ColumnFiltersState) => MRT_ColumnFiltersState) + ) => { + setColumnFilters(old => (typeof updater === 'function' ? updater(old) : updater)); + }, + [setColumnFilters] + ); + /** * Gets the origin of a cluster. * @@ -206,6 +263,7 @@ export default function ClusterTable({ }, }, { + id: 'origin', header: t('Origin'), accessorFn: cluster => getOrigin(cluster), Cell: ({ row: { original } }) => ( @@ -213,6 +271,7 @@ export default function ClusterTable({ ), }, { + id: 'status', header: t('Status'), accessorFn: cluster => errors[cluster?.name] === null ? 'Active' : errors[cluster?.name]?.message, @@ -220,8 +279,13 @@ export default function ClusterTable({ ), }, - { header: t('Warnings'), accessorFn: cluster => warningLabels[cluster?.name] }, { + id: 'warnings', + header: t('Warnings'), + accessorFn: cluster => warningLabels[cluster?.name], + }, + { + id: 'version', header: t('glossary|Kubernetes Version'), accessorFn: ({ name }) => versions[name]?.gitVersion || '⋯', }, @@ -250,9 +314,14 @@ export default function ClusterTable({ } : false } - initialState={{ - sorting: [{ id: 'name', desc: false }], + state={{ + columnVisibility, + sorting, + columnFilters, }} + onColumnVisibilityChange={handleColumnVisibilityChange} + onSortingChange={handleSortingChange} + onColumnFiltersChange={handleColumnFiltersChange} muiToolbarAlertBannerProps={{ sx: theme => ({ background: theme.palette.background.muted, diff --git a/frontend/src/components/App/Home/__snapshots__/index.Base.stories.storyshot b/frontend/src/components/App/Home/__snapshots__/index.Base.stories.storyshot index 6161baf64ea..fdf23255c7b 100644 --- a/frontend/src/components/App/Home/__snapshots__/index.Base.stories.storyshot +++ b/frontend/src/components/App/Home/__snapshots__/index.Base.stories.storyshot @@ -329,7 +329,7 @@

0 ⋯ @@ -737,7 +737,7 @@

0 ⋯ @@ -845,7 +845,7 @@

0 ⋯ diff --git a/frontend/src/components/common/Resource/ResourceTable.tsx b/frontend/src/components/common/Resource/ResourceTable.tsx index 7f2ed6d5ab2..933ccdaeb7b 100644 --- a/frontend/src/components/common/Resource/ResourceTable.tsx +++ b/frontend/src/components/common/Resource/ResourceTable.tsx @@ -36,6 +36,7 @@ import { useState, } from 'react'; import { useTranslation } from 'react-i18next'; +import { loadTableSettings, storeTableSettings } from '../../../helpers/tableSettings'; import { useSelectedClusters } from '../../../lib/k8s'; import { ApiError } from '../../../lib/k8s/api/v2/ApiError'; import { KubeObject } from '../../../lib/k8s/KubeObject'; @@ -209,44 +210,6 @@ function TableFromResourceClass( ); } -/** - * Store the table settings in local storage. - * - * @param tableId - The ID of the table. - * @param columns - The columns to store. - * @returns void - */ -function storeTableSettings(tableId: string, columns: { id?: string; show: boolean }[]) { - if (!tableId) { - console.debug('storeTableSettings: tableId is empty!', new Error().stack); - return; - } - - const columnsWithIds = columns.map((c, i) => ({ id: i.toString(), ...c })); - // Delete the entry if there are no settings to store. - if (columnsWithIds.length === 0) { - localStorage.removeItem(`table_settings.${tableId}`); - return; - } - localStorage.setItem(`table_settings.${tableId}`, JSON.stringify(columnsWithIds)); -} - -/** - * Load the table settings from local storage for a given table ID. - * - * @param tableId - The ID of the table. - * @returns The table settings for the given table ID. - */ -function loadTableSettings(tableId: string): { id: string; show: boolean }[] { - if (!tableId) { - console.debug('loadTableSettings: tableId is empty!', new Error().stack); - return []; - } - - const settings = JSON.parse(localStorage.getItem(`table_settings.${tableId}`) || '[]'); - return settings; -} - /** * Here we figure out which columns are visible and not visible * We can control it using show property in the columns prop {@link ResourceTableColumn} diff --git a/frontend/src/helpers/tableSettings.test.ts b/frontend/src/helpers/tableSettings.test.ts new file mode 100644 index 00000000000..8c69666bd7e --- /dev/null +++ b/frontend/src/helpers/tableSettings.test.ts @@ -0,0 +1,93 @@ +/* + * Copyright 2025 The Kubernetes Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { loadTableSettings, storeTableSettings } from './tableSettings'; + +describe('tableSettings', () => { + beforeEach(() => { + localStorage.clear(); + }); + + describe('storeTableSettings', () => { + it('stores column visibility settings in localStorage', () => { + const columns = [ + { id: 'name', show: true }, + { id: 'status', show: false }, + ]; + + storeTableSettings('test-table', columns); + + const stored = JSON.parse(localStorage.getItem('table_settings.test-table') || '[]'); + expect(stored).toEqual([ + { id: 'name', show: true }, + { id: 'status', show: false }, + ]); + }); + + it('assigns numeric IDs to columns without IDs', () => { + const columns = [{ show: true }, { show: false }]; + + storeTableSettings('test-table', columns); + + const stored = JSON.parse(localStorage.getItem('table_settings.test-table') || '[]'); + expect(stored).toEqual([ + { id: '0', show: true }, + { id: '1', show: false }, + ]); + }); + + it('removes the entry when columns array is empty', () => { + localStorage.setItem('table_settings.test-table', JSON.stringify([{ id: '0', show: true }])); + + storeTableSettings('test-table', []); + + expect(localStorage.getItem('table_settings.test-table')).toBeNull(); + }); + + it('does nothing when tableId is empty', () => { + storeTableSettings('', [{ id: 'name', show: true }]); + + // No key should have been written for an empty tableId + expect(localStorage.getItem('table_settings.')).toBeNull(); + }); + }); + + describe('loadTableSettings', () => { + it('returns stored settings', () => { + const settings = [ + { id: 'name', show: true }, + { id: 'status', show: false }, + ]; + localStorage.setItem('table_settings.test-table', JSON.stringify(settings)); + + const result = loadTableSettings('test-table'); + + expect(result).toEqual(settings); + }); + + it('returns empty array when no settings exist', () => { + const result = loadTableSettings('nonexistent-table'); + + expect(result).toEqual([]); + }); + + it('returns empty array when tableId is empty', () => { + const result = loadTableSettings(''); + + expect(result).toEqual([]); + }); + }); +}); diff --git a/frontend/src/helpers/tableSettings.ts b/frontend/src/helpers/tableSettings.ts new file mode 100644 index 00000000000..044182a348d --- /dev/null +++ b/frontend/src/helpers/tableSettings.ts @@ -0,0 +1,52 @@ +/* + * Copyright 2025 The Kubernetes Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Store the table column visibility settings in local storage. + * + * @param tableId - The ID of the table. + * @param columns - The columns to store. + */ +export function storeTableSettings(tableId: string, columns: { id?: string; show: boolean }[]) { + if (!tableId) { + console.debug('storeTableSettings: tableId is empty!', new Error().stack); + return; + } + + const columnsWithIds = columns.map((c, i) => ({ id: i.toString(), ...c })); + // Delete the entry if there are no settings to store. + if (columnsWithIds.length === 0) { + localStorage.removeItem(`table_settings.${tableId}`); + return; + } + localStorage.setItem(`table_settings.${tableId}`, JSON.stringify(columnsWithIds)); +} + +/** + * Load the table column visibility settings from local storage for a given table ID. + * + * @param tableId - The ID of the table. + * @returns The table settings for the given table ID. + */ +export function loadTableSettings(tableId: string): { id: string; show: boolean }[] { + if (!tableId) { + console.debug('loadTableSettings: tableId is empty!', new Error().stack); + return []; + } + + const settings = JSON.parse(localStorage.getItem(`table_settings.${tableId}`) || '[]'); + return settings; +}