Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import type { ReactNode } from 'react';
import { SortByDirection } from '@patternfly/react-table';
import { act, renderHook } from '@testing-library/react';
import { MemoryRouter, useSearchParams } from 'react-router';
import type { ConsoleDataViewColumn } from '../types';
import { findSortColumnIndex, useConsoleDataViewSort } from '../useConsoleDataViewSort';

const columns: ConsoleDataViewColumn<unknown>[] = [
{ id: 'name', title: 'Name', sort: 'metadata.name', cell: 'Name' },
{
id: 'requester',
title: 'Requester',
sort: "metadata.annotations['openshift.io/requester']",
cell: 'Requester',
},
{ id: 'created', title: 'Created', sort: 'metadata.creationTimestamp', cell: 'Created' },
];

describe('findSortColumnIndex', () => {
it('matches a column by id', () => {
expect(findSortColumnIndex(columns, 'requester')).toBe(1);
});

it('matches a column by title for existing URLs', () => {
expect(findSortColumnIndex(columns, 'Requester')).toBe(1);
});

it('returns -1 when the key is missing', () => {
expect(findSortColumnIndex(columns, null)).toBe(-1);
expect(findSortColumnIndex(columns, 'unknown')).toBe(-1);
});
});

describe('useConsoleDataViewSort', () => {
const wrapper =
(initialEntry: string) =>
({ children }: { children: ReactNode }) => (
<MemoryRouter initialEntries={[initialEntry]}>{children}</MemoryRouter>
);

const wrapperWithSearchControl = (initialEntry: string) => {
const searchParamsApi: { clearSortBy: () => void } = { clearSortBy: () => undefined };

const SearchParamsBridge = () => {
const [searchParams, setSearchParams] = useSearchParams();
searchParamsApi.clearSortBy = () => {
const next = new URLSearchParams(searchParams);
next.delete('sortBy');
setSearchParams(next, { replace: true });
};
return null;
};

return {
wrapper: ({ children }: { children: ReactNode }) => (
<MemoryRouter initialEntries={[initialEntry]}>
<SearchParamsBridge />
{children}
</MemoryRouter>
),
clearSortBy: () => searchParamsApi.clearSortBy(),
};
};

it('restores sort from the sortBy query param after columns are rebuilt', () => {
const { result, rerender } = renderHook(
({ cols }) => useConsoleDataViewSort({ columns: cols }),
{
wrapper: wrapper('/k8s/cluster/projects?sortBy=Requester&orderBy=asc'),
initialProps: { cols: columns },
},
);

expect(result.current.sortBy).toEqual({ index: 1, direction: SortByDirection.asc });

rerender({ cols: [...columns] });

expect(result.current.sortBy).toEqual({ index: 1, direction: SortByDirection.asc });
});

it('keeps the current sort column when columns rebuild without a sortBy param', () => {
const { wrapper: testWrapper, clearSortBy } = wrapperWithSearchControl(
'/k8s/cluster/projects?sortBy=requester&orderBy=desc',
);
const { result, rerender } = renderHook(
({ cols }) => useConsoleDataViewSort({ columns: cols }),
{
wrapper: testWrapper,
initialProps: { cols: columns },
},
);

expect(result.current.sortBy.index).toBe(1);
expect(result.current.sortBy.direction).toBe(SortByDirection.desc);

act(() => {
clearSortBy();
});

rerender({ cols: columns.map((c) => ({ ...c })) });

expect(result.current.sortBy.index).toBe(1);
expect(result.current.sortBy.direction).toBe(SortByDirection.desc);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it('follows the selected column id when columns are reordered without a sortBy param', () => {
const { wrapper: testWrapper, clearSortBy } = wrapperWithSearchControl(
'/k8s/cluster/projects?sortBy=requester&orderBy=desc',
);
const { result, rerender } = renderHook(
({ cols }) => useConsoleDataViewSort({ columns: cols }),
{
wrapper: testWrapper,
initialProps: { cols: columns },
},
);

expect(result.current.sortBy.index).toBe(1);

act(() => {
clearSortBy();
});

rerender({ cols: [columns[0], columns[2], columns[1]].map((c) => ({ ...c })) });

expect(result.current.sortBy.index).toBe(2);
expect(result.current.sortBy.direction).toBe(SortByDirection.desc);
});

it('falls back to the default sort when the selected column is removed and sortBy is absent', () => {
const { wrapper: testWrapper, clearSortBy } = wrapperWithSearchControl(
'/k8s/cluster/projects?sortBy=requester&orderBy=desc',
);
const { result, rerender } = renderHook(
({ cols }) => useConsoleDataViewSort({ columns: cols }),
{
wrapper: testWrapper,
initialProps: { cols: columns },
},
);

expect(result.current.sortBy.index).toBe(1);

act(() => {
clearSortBy();
});

rerender({ cols: [columns[0], columns[2]].map((c) => ({ ...c })) });

expect(result.current.sortBy).toEqual({ index: 0, direction: SortByDirection.asc });
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,8 @@ export const useConsoleDataViewData = <
});

const dataViewColumns = useMemo<ConsoleDataViewColumn<TData>[]>(() => {
// Calculate selection state across all filtered items
// Selection header only needs the filtered count; using the full array here
// rebuilt columns (and reset sort) on every watch update / row delete.
const totalCount = filteredData.length;

return activeColumns.map(({ id, title, sort, props, resizableProps }, index) => {
Expand Down Expand Up @@ -137,7 +138,10 @@ export const useConsoleDataViewData = <
),
} satisfies ConsoleDataViewColumn<TData>;
});
}, [activeColumns, t, isResizable, selection, filteredData]);
// filteredData is read only for the placeholder select-all handler, which is replaced later.
// Depend on length so a row delete does not rebuild columns and reset sort.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeColumns, t, isResizable, selection, filteredData.length]);

const { sortBy, onSort } = useConsoleDataViewSort<TData>({
columns: dataViewColumns,
Expand All @@ -152,13 +156,13 @@ export const useConsoleDataViewData = <
}

if (typeof sortColumn.sort === 'string') {
return filteredData.sort(
return [...filteredData].sort(
sortResourceByValue(sortDirection, (obj) => _.get(obj, sortColumn.sort as string)),
);
}

if (typeof sortColumn.sort === 'function') {
return sortColumn.sort(filteredData, sortDirection);
return sortColumn.sort([...filteredData], sortDirection);
}

return filteredData;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { BaseSyntheticEvent } from 'react';
import { useCallback, useState, useEffect } from 'react';
import { useCallback, useState, useEffect, useRef } from 'react';
import type { ISortBy } from '@patternfly/react-table';
import { SortByDirection } from '@patternfly/react-table';
import * as _ from 'lodash';
Expand All @@ -9,6 +9,19 @@ import type { ConsoleDataViewColumn } from './types';
export const getSortByDirection = (value: string): SortByDirection =>
value === SortByDirection.desc.valueOf() ? SortByDirection.desc : SortByDirection.asc;

const getColumnSortKey = <TData>(column?: ConsoleDataViewColumn<TData>): string | null =>
column?.id || column?.title || null;

export const findSortColumnIndex = <TData>(
columns: ConsoleDataViewColumn<TData>[],
sortKey: string | null,
): number => {
if (!sortKey || columns.length === 0) {
return -1;
}
return columns.findIndex((column) => column.id === sortKey || column.title === sortKey);
};

export const useConsoleDataViewSort = <TData>({
columns,
sortColumnIndex,
Expand All @@ -19,21 +32,20 @@ export const useConsoleDataViewSort = <TData>({
sortDirection?: SortByDirection;
}) => {
const [searchParams, setSearchParams] = useSearchParams();
const selectedColumnKeyRef = useRef<string | null>(null);

// Initialize sort state from URL params or defaults
const getInitialSortState = useCallback<() => ISortBy>(() => {
const sortByParam = searchParams.get('sortBy');
const orderByParam = searchParams.get('orderBy');

if (sortByParam && columns.length > 0) {
const columnIndex = _.findIndex(columns, { title: sortByParam });
const columnIndex = findSortColumnIndex(columns, sortByParam);

if (columnIndex >= 0) {
return {
index: columnIndex,
direction: getSortByDirection(orderByParam),
};
}
if (columnIndex >= 0) {
return {
index: columnIndex,
direction: getSortByDirection(orderByParam),
};
}

return {
Expand All @@ -47,11 +59,13 @@ export const useConsoleDataViewSort = <TData>({
const applySort = useCallback(
(index: number, direction: SortByDirection) => {
const sortColumn = columns[index];
const sortKey = getColumnSortKey(sortColumn);

if (sortColumn) {
if (sortColumn && sortKey) {
selectedColumnKeyRef.current = sortKey;
setSearchParams((prev) => {
const newParams = new URLSearchParams(prev);
newParams.set('sortBy', sortColumn.title);
newParams.set('sortBy', sortKey);
newParams.set('orderBy', direction);
return newParams;
});
Expand All @@ -65,12 +79,35 @@ export const useConsoleDataViewSort = <TData>({
// Update sort state when columns change or URL params change
useEffect(() => {
const newSortState = getInitialSortState();
const sortByParam = searchParams.get('sortBy');

if (sortByParam) {
selectedColumnKeyRef.current = getColumnSortKey(columns[newSortState.index]);
setSortBy((prevSortState) =>
_.isEqual(prevSortState, newSortState) ? prevSortState : newSortState,
);
return;
}

// Data refreshes rebuild `columns`. If the URL lost sortBy (or never had it after
// a same-route navigation), keep the selected column by id instead of snapping to Name.
const resolvedIndex = findSortColumnIndex(columns, selectedColumnKeyRef.current);
if (resolvedIndex >= 0) {
const preservedSortState: ISortBy = {
index: resolvedIndex,
direction: sortBy.direction ?? SortByDirection.asc,
};
setSortBy((prevSortState) =>
_.isEqual(prevSortState, preservedSortState) ? prevSortState : preservedSortState,
);
return;
}

selectedColumnKeyRef.current = getColumnSortKey(columns[newSortState.index]);
setSortBy((prevSortState) =>
// Only update if the state actually changed
_.isEqual(prevSortState, newSortState) ? prevSortState : newSortState,
);
}, [getInitialSortState]);
}, [getInitialSortState, searchParams, columns, sortBy.direction]);

const onSort = useCallback(
(event: BaseSyntheticEvent, index: number, direction: SortByDirection) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import {
getClusterResourceListPath,
isOnClusterResourceListPage,
} from '../delete-namespace-modal-utils';

describe('isOnClusterResourceListPage', () => {
it('returns true for the Projects list path', () => {
expect(isOnClusterResourceListPage('/k8s/cluster/projects', 'projects')).toBe(true);
});

it('returns true when the list path has a trailing slash', () => {
expect(isOnClusterResourceListPage('/k8s/cluster/projects/', 'projects')).toBe(true);
});

it('returns false for a project details path', () => {
expect(isOnClusterResourceListPage('/k8s/cluster/projects/my-app', 'projects')).toBe(false);
});

it('returns false for a namespaced page of the deleted project', () => {
expect(isOnClusterResourceListPage('/k8s/ns/my-app/pods', 'projects')).toBe(false);
});
});

describe('getClusterResourceListPath', () => {
it('builds the cluster-scoped list path from the model plural', () => {
expect(getClusterResourceListPath('projects')).toBe('/k8s/cluster/projects');
expect(getClusterResourceListPath('namespaces')).toBe('/k8s/cluster/namespaces');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/** Cluster-scoped list page for Project / Namespace, e.g. `/k8s/cluster/projects`. */
export const getClusterResourceListPath = (plural: string): string => `/k8s/cluster/${plural}`;

/** True when the user is already on that list page (query params must be preserved). */
export const isOnClusterResourceListPage = (pathname: string, plural: string): boolean => {
const listPath = getClusterResourceListPath(plural);
return pathname === listPath || pathname === `${listPath}/`;
};
10 changes: 9 additions & 1 deletion frontend/public/components/modals/delete-namespace-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ import { useUserPreference } from '@console/shared/src/hooks/useUserPreference';
import type { ModalComponentProps } from '@console/shared/src/types/modal';
import { setActiveNamespace, formatNamespaceRoute } from '../../actions/ui';
import { getActiveNamespace } from '../../reducers/ui';
import {
getClusterResourceListPath,
isOnClusterResourceListPage,
} from './delete-namespace-modal-utils';

const DeleteNamespaceModal: OverlayComponent<DeleteNamespaceModalProps> = ({
kind,
Expand Down Expand Up @@ -61,7 +65,11 @@ const DeleteNamespaceModal: OverlayComponent<DeleteNamespaceModalProps> = ({
setLastNamespace(ALL_NAMESPACES_KEY);
}
closeOverlay();
navigate(`/k8s/cluster/${kind.plural}`);
// Stay on the list page so sort/filter/pagination query params are kept.
// Redirect only when the user is on a details (or other) page for the deleted resource.
if (!isOnClusterResourceListPage(window.location.pathname, kind.plural)) {
navigate(getClusterResourceListPath(kind.plural));
}
})
.catch(() => {
/* do nothing */
Expand Down