diff --git a/web/src/features/perses-dashboards/pages/dashboard-list-page/DashboardList.tsx b/web/src/features/perses-dashboards/pages/dashboard-list-page/DashboardList.tsx index 4c39685b6..26e746312 100644 --- a/web/src/features/perses-dashboards/pages/dashboard-list-page/DashboardList.tsx +++ b/web/src/features/perses-dashboards/pages/dashboard-list-page/DashboardList.tsx @@ -19,7 +19,7 @@ import { useDataViewSort } from '@patternfly/react-data-view/dist/dynamic/Hooks' import RhUiEllipsisVerticalFillIcon from '@patternfly/react-icons/dist/esm/icons/rh-ui-ellipsis-vertical-fill-icon'; import { ActionsColumn } from '@patternfly/react-table'; import type { DashboardResource } from '@perses-dev/client'; -import { type FC, memo, type ReactNode, useCallback, useMemo, useState } from 'react'; +import { type FC, memo, useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Link, useSearchParams } from 'react-router'; @@ -31,10 +31,14 @@ import { import { useDashboardsData } from '@/features/perses-dashboards/hooks/useDashboardsData'; import { usePersesDashboardAccess } from '@/features/perses-dashboards/hooks/usePersesDashboardAccess'; import { DashboardListFrame } from '@/features/perses-dashboards/pages/dashboard-list-page/DashboardListFrame'; +import { + type DashboardRow, + type DashboardRowNameLink, + sortDashboardData, +} from '@/features/perses-dashboards/pages/dashboard-list-page/sort-dashboards'; import { useTableColumns } from '@/shared/components/table/hooks/useTableColumns'; import { rowFilter, useTableFilters } from '@/shared/components/table/hooks/useTableFilters'; import { useTablePagination } from '@/shared/components/table/hooks/useTablePagination'; -import { directedSort, localeCompareSort } from '@/shared/components/table/sort-utils'; import { TableFilter, TableFilterOption, @@ -131,62 +135,12 @@ const DashboardActionsCell = memo( DashboardActionsCell.displayName = 'DashboardActionsCell'; -interface DashboardRowNameLink { - link: ReactNode; - label: string; -} - -interface DashboardRow { - name: DashboardRowNameLink; - tags: ReactNode; - project: string; - created: ReactNode; - modified: ReactNode; - // Raw values for sorting - createdAt?: string; - updatedAt?: string; - // Reference to original dashboard data - dashboard: DashboardResource; -} - interface DashboardRowFilters { name?: string; 'project-filter'?: string; tags?: string; } -const sortDashboardData = ( - data: DashboardRow[], - sortBy: string | undefined, - direction: 'asc' | 'desc' | undefined, -): DashboardRow[] => { - if (!sortBy || !direction) { - return data; - } - if (sortBy === rowFilter('name')) { - return [...data].sort((a, b) => localeCompareSort(a.name.label, b.name.label, direction)); - } - if (sortBy === rowFilter('project')) { - return [...data].sort((a, b) => localeCompareSort(a.project, b.project, direction)); - } - if (sortBy === rowFilter('created')) { - return [...data].sort((a, b) => localeCompareSort(a.createdAt, b.createdAt, direction)); - } - if (sortBy === rowFilter('modified')) { - return [...data].sort((a, b) => localeCompareSort(a.updatedAt, b.updatedAt, direction)); - } - if (sortBy === rowFilter('tags')) { - return [...data].sort((a, b) => - directedSort( - (a.dashboard.metadata?.tags?.length || 0) - (b.dashboard.metadata?.tags?.length || 0), - direction, - ), - ); - } - - return data; -}; - interface DashboardsTableProps { persesDashboards: DashboardResource[]; persesDashboardsLoading: boolean; @@ -216,7 +170,19 @@ const DashboardsTable: FC = ({ const columnKeys = useMemo( () => [ - { label: t('Dashboard'), key: rowFilter('name') }, + { label: t('Display Name'), key: rowFilter('name') }, + { + label: t('Name'), + key: rowFilter('id'), + props: { + info: { + tooltip: t( + 'This is the immutable, unique identifier for the dashboard. It cannot be changed after the dashboard is created.', + ), + ariaLabel: t('More information on Dashboard ID'), + }, + }, + }, { label: t('Project'), key: rowFilter('project') }, { label: t('Tags'), key: rowFilter('tags') }, { label: t('Created on'), key: rowFilter('created') }, @@ -233,7 +199,8 @@ const DashboardsTable: FC = ({ } return persesDashboards.map((board) => { const metadata = board?.metadata; - const displayName = board?.spec?.display?.name || metadata?.name; + const id = metadata?.name; + const displayName = board?.spec?.display?.name || id; const dashboardsParams = `?dashboard=${metadata?.name}&project=${metadata?.project}`; const dashboardName: DashboardRowNameLink = { link: ( @@ -263,6 +230,7 @@ const DashboardsTable: FC = ({ return { name: dashboardName, + id: id, project: board?.metadata?.project || '', tags: dashboardTags, created: , @@ -331,8 +299,9 @@ const DashboardsTable: FC = ({ const pageRows: DataViewTr[] = useMemo(() => { return sortedAndFilteredData .slice((page - 1) * perPage, (page - 1) * perPage + perPage) - .map(({ name, project, tags, created, modified, dashboard }) => [ + .map(({ name, id, project, tags, created, modified, dashboard }) => [ name.link, + id, project, tags, created, diff --git a/web/src/features/perses-dashboards/pages/dashboard-list-page/sort-dashboards.spec.ts b/web/src/features/perses-dashboards/pages/dashboard-list-page/sort-dashboards.spec.ts new file mode 100644 index 000000000..a30bf4825 --- /dev/null +++ b/web/src/features/perses-dashboards/pages/dashboard-list-page/sort-dashboards.spec.ts @@ -0,0 +1,104 @@ +import type { DashboardResource } from '@perses-dev/client'; + +import { + type DashboardRow, + sortDashboardData, +} from '@/features/perses-dashboards/pages/dashboard-list-page/sort-dashboards'; + +const makeDashboard = (tags: string[]): DashboardResource => + ({ metadata: { tags } }) as unknown as DashboardResource; + +const makeRow = (overrides: Partial & { id: string }): DashboardRow => ({ + name: { link: null, label: overrides.id }, + tags: null, + project: '', + created: null, + modified: null, + dashboard: makeDashboard([]), + ...overrides, +}); + +const rowA = makeRow({ + id: 'a-dashboard', + name: { link: null, label: 'Charlie' }, + project: 'project-b', + createdAt: '2026-01-03T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + dashboard: makeDashboard(['one']), +}); + +const rowB = makeRow({ + id: 'b-dashboard', + name: { link: null, label: 'Alpha' }, + project: 'project-a', + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-03T00:00:00Z', + dashboard: makeDashboard(['one', 'two', 'three']), +}); + +const rowC = makeRow({ + id: 'c-dashboard', + name: { link: null, label: 'Bravo' }, + project: 'project-c', + createdAt: '2026-01-02T00:00:00Z', + updatedAt: '2026-01-02T00:00:00Z', + dashboard: makeDashboard(['one', 'two']), +}); + +const rows = [rowA, rowB, rowC]; + +describe('sortDashboardData', () => { + it('returns the data unchanged when sortBy is undefined', () => { + expect(sortDashboardData(rows, undefined, 'asc')).toBe(rows); + }); + + it('returns the data unchanged when direction is undefined', () => { + expect(sortDashboardData(rows, 'row-filter-name', undefined)).toBe(rows); + }); + + it('returns the data unchanged for an unrecognized sortBy', () => { + expect(sortDashboardData(rows, 'row-filter-unknown', 'asc')).toBe(rows); + }); + + it('sorts by name ascending and descending', () => { + const asc = sortDashboardData(rows, 'row-filter-name', 'asc'); + expect(asc.map((r) => r.name.label)).toEqual(['Alpha', 'Bravo', 'Charlie']); + + const desc = sortDashboardData(rows, 'row-filter-name', 'desc'); + expect(desc.map((r) => r.name.label)).toEqual(['Charlie', 'Bravo', 'Alpha']); + }); + + it('sorts by id', () => { + const asc = sortDashboardData(rows, 'row-filter-id', 'asc'); + expect(asc.map((r) => r.id)).toEqual(['a-dashboard', 'b-dashboard', 'c-dashboard']); + }); + + it('sorts by project', () => { + const asc = sortDashboardData(rows, 'row-filter-project', 'asc'); + expect(asc.map((r) => r.project)).toEqual(['project-a', 'project-b', 'project-c']); + }); + + it('sorts by created date', () => { + const asc = sortDashboardData(rows, 'row-filter-created', 'asc'); + expect(asc.map((r) => r.id)).toEqual(['b-dashboard', 'c-dashboard', 'a-dashboard']); + }); + + it('sorts by modified date', () => { + const asc = sortDashboardData(rows, 'row-filter-modified', 'asc'); + expect(asc.map((r) => r.id)).toEqual(['a-dashboard', 'c-dashboard', 'b-dashboard']); + }); + + it('sorts by number of tags', () => { + const asc = sortDashboardData(rows, 'row-filter-tags', 'asc'); + expect(asc.map((r) => r.id)).toEqual(['a-dashboard', 'c-dashboard', 'b-dashboard']); + + const desc = sortDashboardData(rows, 'row-filter-tags', 'desc'); + expect(desc.map((r) => r.id)).toEqual(['b-dashboard', 'c-dashboard', 'a-dashboard']); + }); + + it('does not mutate the original array', () => { + const original = [...rows]; + sortDashboardData(rows, 'row-filter-name', 'desc'); + expect(rows).toEqual(original); + }); +}); diff --git a/web/src/features/perses-dashboards/pages/dashboard-list-page/sort-dashboards.ts b/web/src/features/perses-dashboards/pages/dashboard-list-page/sort-dashboards.ts new file mode 100644 index 000000000..f8a11818a --- /dev/null +++ b/web/src/features/perses-dashboards/pages/dashboard-list-page/sort-dashboards.ts @@ -0,0 +1,59 @@ +import type { DashboardResource } from '@perses-dev/client'; +import type { ReactNode } from 'react'; + +import { rowFilter } from '@/shared/components/table/hooks/useTableFilters'; +import { directedSort, localeCompareSort } from '@/shared/components/table/sort-utils'; + +export interface DashboardRowNameLink { + link: ReactNode; + label: string; +} + +export interface DashboardRow { + name: DashboardRowNameLink; + id: string; + tags: ReactNode; + project: string; + created: ReactNode; + modified: ReactNode; + // Raw values for sorting + createdAt?: string; + updatedAt?: string; + // Reference to original dashboard data + dashboard: DashboardResource; +} + +export const sortDashboardData = ( + data: DashboardRow[], + sortBy: string | undefined, + direction: 'asc' | 'desc' | undefined, +): DashboardRow[] => { + if (!sortBy || !direction) { + return data; + } + if (sortBy === rowFilter('name')) { + return [...data].sort((a, b) => localeCompareSort(a.name.label, b.name.label, direction)); + } + if (sortBy === rowFilter('id')) { + return [...data].sort((a, b) => localeCompareSort(a.id, b.id, direction)); + } + if (sortBy === rowFilter('project')) { + return [...data].sort((a, b) => localeCompareSort(a.project, b.project, direction)); + } + if (sortBy === rowFilter('created')) { + return [...data].sort((a, b) => localeCompareSort(a.createdAt, b.createdAt, direction)); + } + if (sortBy === rowFilter('modified')) { + return [...data].sort((a, b) => localeCompareSort(a.updatedAt, b.updatedAt, direction)); + } + if (sortBy === rowFilter('tags')) { + return [...data].sort((a, b) => + directedSort( + (a.dashboard.metadata?.tags?.length || 0) - (b.dashboard.metadata?.tags?.length || 0), + direction, + ), + ); + } + + return data; +};