mirror of
https://github.com/portainer/portainer.git
synced 2026-06-23 04:30:16 +00:00
feat(homeView) add age sort option as default [C9S-150] (#2546)
This commit is contained in:
committed by
GitHub
parent
d749d05359
commit
1ea8c1cb4e
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user