feat(homeView) add age sort option as default [C9S-150] (#2546)

This commit is contained in:
bernard-portainer
2026-05-05 08:17:06 +12:00
committed by GitHub
parent d749d05359
commit 1ea8c1cb4e
9 changed files with 89 additions and 17 deletions
@@ -117,6 +117,7 @@ export function GroupSortTable<TItem extends object>({
<Widget className="overflow-clip [&_table]:bg-transparent" data-cy={dataCy}>
<GroupSortTableHeader
sortBy={sortBy}
sortDesc={tableState.sortBy?.desc ?? false}
onSortChange={handleSortChange}
searchTerm={tableState.search}
onSearchChange={(value) => {
@@ -185,11 +186,16 @@ export function GroupSortTable<TItem extends object>({
return renderRow(row);
}
const header = renderGroupHeader(groupKey, groupCountByKey[groupKey] ?? 0);
if (header == null) {
return renderRow(row);
}
return (
<>
<tr>
<td colSpan={Number.MAX_SAFE_INTEGER} className="!p-0">
{renderGroupHeader(groupKey, groupCountByKey[groupKey] ?? 0)}
{header}
</td>
</tr>
{renderRow(row)}
@@ -199,7 +205,9 @@ export function GroupSortTable<TItem extends object>({
function handleSortChange(key: string) {
tableState.setPage(1);
tableState.setSortBy(key, false);
const newDesc =
tableState.sortBy?.id === key ? !tableState.sortBy.desc : false;
tableState.setSortBy(key, newDesc);
}
function handleGroupFilterChange(value: string | null) {
@@ -29,13 +29,20 @@ type SortKey = (typeof sortOptions)[number]['key'];
export function Interactive() {
const [sortBy, setSortBy] = useState<SortKey>('name');
const [sortDesc, setSortDesc] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [groupFilter, setGroupFilter] = useState<string | null>(null);
function handleSortChange(key: SortKey) {
setSortDesc((prev) => (sortBy === key ? !prev : false));
setSortBy(key);
}
return (
<GroupSortTableHeader
sortBy={sortBy}
onSortChange={setSortBy}
sortDesc={sortDesc}
onSortChange={handleSortChange}
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
sortOptions={[...sortOptions]}
@@ -50,12 +57,19 @@ export function Interactive() {
export function WithGroupFilter() {
const [sortBy, setSortBy] = useState<SortKey>('group');
const [sortDesc, setSortDesc] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
function handleSortChange(key: SortKey) {
setSortDesc((prev) => (sortBy === key ? !prev : false));
setSortBy(key);
}
return (
<GroupSortTableHeader
sortBy={sortBy}
onSortChange={setSortBy}
sortDesc={sortDesc}
onSortChange={handleSortChange}
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
sortOptions={[...sortOptions]}
@@ -70,13 +84,20 @@ export function WithGroupFilter() {
export function WithActionButton() {
const [sortBy, setSortBy] = useState<SortKey>('name');
const [sortDesc, setSortDesc] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [groupFilter, setGroupFilter] = useState<string | null>(null);
function handleSortChange(key: SortKey) {
setSortDesc((prev) => (sortBy === key ? !prev : false));
setSortBy(key);
}
return (
<GroupSortTableHeader
sortBy={sortBy}
onSortChange={setSortBy}
sortDesc={sortDesc}
onSortChange={handleSortChange}
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
sortOptions={[...sortOptions]}
@@ -21,6 +21,7 @@ function renderHeader(
) {
const props = {
sortBy: 'Group' as string,
sortDesc: false,
onSortChange: vi.fn(),
searchTerm: '',
onSearchChange: vi.fn(),
@@ -13,6 +13,7 @@ export type { SortOption };
interface Props<TSortKey extends string> {
sortBy: TSortKey;
sortDesc: boolean;
onSortChange: (key: TSortKey) => void;
searchTerm: string;
onSearchChange: (term: string) => void;
@@ -27,6 +28,7 @@ interface Props<TSortKey extends string> {
export function GroupSortTableHeader<TSortKey extends string>({
sortBy,
sortDesc,
onSortChange,
searchTerm,
onSearchChange,
@@ -43,12 +45,12 @@ export function GroupSortTableHeader<TSortKey extends string>({
<div
className={clsx(
'flex flex-wrap items-center justify-between gap-3 px-5 py-3',
'bg-gray-2 th-highcontrast:bg-black th-dark:bg-gray-iron-10',
'border-0 border-b border-solid border-gray-5 th-dark:border-gray-9'
'bg-gray-2 th-highcontrast:bg-black th-dark:bg-gray-iron-10'
)}
>
<SortByGroup
sortBy={sortBy}
sortDesc={sortDesc}
onSortChange={onSortChange}
sortOptions={sortOptions}
groupFilter={groupFilter}
@@ -39,6 +39,7 @@ vi.mock('@reach/menu-button', () => {
setOpen(false);
}
}
document.addEventListener('mousedown', handleDocDown);
return () => document.removeEventListener('mousedown', handleDocDown);
}, [isOpen]);
@@ -60,10 +61,12 @@ vi.mock('@reach/menu-button', () => {
[key: string]: unknown;
}) {
const ctx = useContext(MenuCtx);
function handleClick() {
externalOnClick?.();
ctx?.setOpen(!ctx.isOpen);
}
return (
<button type="button" onClick={handleClick} {...props}>
{children}
@@ -97,10 +100,12 @@ vi.mock('@reach/menu-button', () => {
className?: string;
}) {
const ctx = useContext(MenuCtx);
function handleClick() {
onSelect?.();
ctx?.setOpen(false);
}
return (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/interactive-supports-focus
<div role="menuitem" onClick={handleClick} className={className}>
@@ -139,6 +144,7 @@ function renderComponent({
withTestRouter(() => (
<SortByGroup
sortBy={sortBy}
sortDesc={false}
onSortChange={onSortChange}
sortOptions={sortOptions}
groupFilter={groupFilter}
@@ -162,13 +168,13 @@ describe('SortByGroup', () => {
expect(onSortChange).toHaveBeenCalledExactlyOnceWith('Name');
});
test('clicking the already-active button does not call onSortChange', async () => {
test('clicking the already-active non-grouped button calls onSortChange to toggle sort order', async () => {
const user = userEvent.setup();
const { onSortChange } = renderComponent({ sortBy: 'Name' });
await user.click(screen.getByRole('button', { name: /^Name$/i }));
await user.click(screen.getByRole('button', { name: /^Name Asc/i }));
expect(onSortChange).not.toHaveBeenCalled();
expect(onSortChange).toHaveBeenCalledExactlyOnceWith('Name');
});
});
@@ -6,10 +6,13 @@ export interface SortOption<TSortKey extends string = string> {
key: TSortKey;
label: string;
grouped?: boolean;
descendingLabel?: string;
ascendingLabel?: string;
}
export interface SortByGroupProps<TSortKey extends string> {
sortBy: TSortKey;
sortDesc: boolean;
onSortChange: (key: TSortKey) => void;
sortOptions: SortOption<TSortKey>[];
groupFilter: string | null;
@@ -20,6 +23,7 @@ export interface SortByGroupProps<TSortKey extends string> {
export function SortByGroup<TSortKey extends string>({
sortBy,
sortDesc,
onSortChange,
sortOptions,
groupFilter,
@@ -49,6 +53,7 @@ export function SortByGroup<TSortKey extends string>({
key={option.key}
option={option}
isActive={sortBy === option.key}
sortDesc={sortDesc}
isFirst={index === 0}
isLast={index === sortOptions.length - 1}
onSortChange={onSortChange}
@@ -82,6 +87,7 @@ const inactiveBtn = clsx(
interface SortOptionItemProps<TSortKey extends string> {
option: SortOption<TSortKey>;
isActive: boolean;
sortDesc: boolean;
isFirst: boolean;
isLast: boolean;
onSortChange: (key: TSortKey) => void;
@@ -94,6 +100,7 @@ interface SortOptionItemProps<TSortKey extends string> {
function SortOptionItem<TSortKey extends string>({
option,
isActive,
sortDesc,
isFirst,
isLast,
onSortChange,
@@ -132,19 +139,30 @@ function SortOptionItem<TSortKey extends string>({
);
}
const badge = isActive
? sortDesc
? option.descendingLabel || 'Desc'
: option.ascendingLabel || 'Asc'
: null;
return (
<button
type="button"
className={className}
onClick={() => {
onSortChange(option.key);
if (!isActive) {
onSortChange(option.key);
onGroupFilterChange(null);
}
}}
data-cy={`${dataCy}-sort-by-${option.key.toLowerCase()}-button`}
>
{option.label}
{badge && (
<span className="py-0.2 ml-1 rounded-md bg-blue-7 px-1 text-[10px] font-normal text-white">
{badge}
</span>
)}
</button>
);
}
@@ -58,8 +58,11 @@ export function SortableList<T>({
<SortableListCard>
<GroupSortTableHeader
sortBy={activeSortKey}
sortDesc={tableState.sortBy?.desc ?? false}
onSortChange={(key) => {
tableState.setSortBy(key, false);
const newDesc =
tableState.sortBy?.id === key ? !tableState.sortBy.desc : false;
tableState.setSortBy(key, newDesc);
}}
searchTerm={tableState.search}
onSearchChange={(value) => {
@@ -521,7 +521,7 @@ test('URL param groupBy=platform&filter=docker activates Docker platform filter
});
});
test('URL param filter without groupBy is ignored (default Group sort used)', async () => {
test('URL param filter without groupBy is ignored (default Age sort used)', async () => {
let capturedParams: URLSearchParams | null = null;
// filter present but no groupBy — the component should bail out early
@@ -540,12 +540,12 @@ test('URL param filter without groupBy is ignored (default Group sort used)', as
]
);
// status[] should not be sent; sort defaults to Group
// status[] should not be sent; sort defaults to Age
await waitFor(() => {
expect(capturedParams).not.toBeNull();
});
expect(capturedParams!.getAll('status[]')).toHaveLength(0);
expect(capturedParams!.get('sort')).toBe('Group');
expect(capturedParams!.get('sort')).toBe('Age');
});
test('selecting a sort/filter updates URL via stateService.go', async () => {
@@ -33,6 +33,7 @@ import { KubeconfigButton } from '@/react/portainer/HomeView/EnvironmentList/Kub
import { EnvironmentCard } from '@/react/portainer/HomeView/EnvironmentList/EnvironmentItem/EnvironmentCard';
import { GroupSortTable } from '@@/GroupSortTable/GroupSortTable';
import { SortOption } from '@@/GroupSortTable/SortByGroup';
import { GroupSortTableGroupRow } from '@@/GroupSortTable/GroupSortTableGroupRow';
import { useGroupSortTableState } from '@@/GroupSortTable/useGroupSortTableState';
@@ -57,6 +58,7 @@ const HEALTH_SORT_ORDER: Record<string, number> = {
};
const columns: ColumnDef<EnvironmentRow>[] = [
{ id: 'Age', accessorKey: 'age' },
{ id: 'Platform', accessorKey: 'platformName' },
{ id: 'Group', accessorKey: 'groupName' },
{
@@ -69,7 +71,13 @@ const columns: ColumnDef<EnvironmentRow>[] = [
{ id: 'Name', accessorKey: 'Name' },
];
const SORT_OPTIONS = [
const SORT_OPTIONS: SortOption[] = [
{
key: 'Age',
label: 'Age',
descendingLabel: 'Newest',
ascendingLabel: 'Oldest',
},
{ key: 'Group', label: 'Group', grouped: true },
{ key: 'Platform', label: 'Platform', grouped: true },
{ key: 'Health', label: 'Health', grouped: true },
@@ -129,7 +137,7 @@ export function EnvironmentList({
const tableState = useGroupSortTableState(
storageKey,
'Group',
'Age',
DEFAULT_PAGE_LIMIT
);
@@ -188,6 +196,8 @@ export function EnvironmentList({
const environmentRows = useMemo<EnvironmentRow[]>(() => {
const rows = environments.map((env) => ({
...env,
// Use Environment ID to sort age as lower ID = older environment
age: env.Id,
groupName: groupNameById.get(env.GroupId) ?? 'Unassigned',
platformName:
PlatformType[getPlatformType(env.Type, env.ContainerEngine)],
@@ -359,6 +369,8 @@ export function EnvironmentList({
} else if (sortId === 'Health' && healthDetails[groupKey]) {
icon = getHealthIcon(healthDetails[groupKey].type, 'md');
description = healthDetails[groupKey].description;
} else if (sortId === 'Age') {
return null;
} else {
icon = getGroupIcon('md');
}
@@ -377,6 +389,7 @@ export function EnvironmentList({
}
type EnvironmentRow = Environment & {
age: number;
groupName: string;
platformName: string;
healthLabel: string;