fix(gitops): align list component with current design [BE-12888] (#2445)

This commit is contained in:
Chaim Lev-Ari
2026-04-26 16:54:51 +03:00
committed by GitHub
parent 718e11ccd0
commit 86f6aba362
18 changed files with 468 additions and 80 deletions
+9 -1
View File
@@ -11,6 +11,7 @@ import { sidebarModule } from './react/views/sidebar';
import environmentsModule from './environments';
import { helpersModule } from './helpers';
import { AccessHeaders, requiresAuthHook } from './authorization-guard';
import { filterParam, paginationParams } from './helpers/stateParamHelper';
async function initAuthentication(Authentication) {
return await Authentication.init();
@@ -298,9 +299,16 @@ angular
docs: '/user/home',
},
};
var workflows = {
name: 'portainer.workflows',
url: '/workflows',
url: '/workflows?search&sort&order&page&pageSize&status&type&platform',
params: {
...paginationParams('name'),
status: filterParam(),
type: filterParam(),
platform: filterParam(),
},
views: {
'content@': {
component: 'workflowsView',
+13
View File
@@ -0,0 +1,13 @@
export function filterParam(defaultValue = null) {
return { value: defaultValue, squash: true, dynamic: true };
}
export function paginationParams(defaultSort = 'name') {
return {
search: filterParam(),
sort: filterParam(defaultSort),
order: filterParam('asc'),
page: filterParam('0'),
pageSize: filterParam(),
};
}
@@ -1,4 +1,4 @@
import React from 'react';
import { ReactNode } from 'react';
import clsx from 'clsx';
import { AutomationTestingProps } from '@/types';
@@ -18,10 +18,11 @@ interface Props<TSortKey extends string> {
onSearchChange: (term: string) => void;
sortOptions: SortOption<TSortKey>[];
searchPlaceholder?: string;
actionButton?: React.ReactNode;
actionButton?: ReactNode;
groupFilter: string | null;
groupOptions?: Record<string, DropdownOption[]>;
onGroupFilterChange: (value: string | null) => void;
headerButtons?: ReactNode;
}
export function GroupSortTableHeader<TSortKey extends string>({
@@ -35,6 +36,7 @@ export function GroupSortTableHeader<TSortKey extends string>({
groupFilter,
groupOptions,
onGroupFilterChange,
headerButtons,
'data-cy': dataCy,
}: Props<TSortKey> & AutomationTestingProps) {
return (
@@ -42,7 +44,7 @@ export function GroupSortTableHeader<TSortKey extends string>({
className={clsx(
'flex 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-4'
'border-0 border-b border-solid border-gray-5 th-dark:border-gray-9'
)}
>
<SortByGroup
@@ -55,6 +57,7 @@ export function GroupSortTableHeader<TSortKey extends string>({
dataCy={dataCy}
/>
<div className="ml-auto flex items-center gap-2">
{headerButtons}
<SearchBar
value={searchTerm}
placeholder={searchPlaceholder}
@@ -63,18 +63,21 @@ export function SortByGroup<TSortKey extends string>({
);
}
const baseBtn =
'px-4 py-1.5 text-xs align-middle font-medium transition-colors';
const activeBtn =
'z-10 border-none rounded-md font-medium ' +
'bg-white text-gray-8 ' +
'th-dark:bg-gray-iron-10 th-dark:text-white ' +
'th-highcontrast:bg-white th-highcontrast:text-black';
const inactiveBtn =
'text-muted border-none rounded-md ' +
'bg-gray-4 hover:bg-gray-2 ' +
'th-dark:bg-gray-iron-11 th-dark:text-white th-dark:hover:bg-gray-iron-9 ' +
'th-highcontrast:bg-black th-highcontrast:text-white th-highcontrast:hover:bg-white th-highcontrast:hover:text-black';
const baseBtn = clsx(
'px-4 py-1.5 align-middle text-xs font-medium transition-colors'
);
const activeBtn = clsx(
'z-10 rounded-md border-none font-medium',
'bg-white text-gray-8',
'th-dark:bg-gray-iron-10 th-dark:text-white',
'th-highcontrast:border th-highcontrast:border-solid th-highcontrast:bg-transparent th-highcontrast:text-white'
);
const inactiveBtn = clsx(
'text-muted rounded-md border-none',
'bg-gray-4 hover:bg-gray-2',
'th-dark:bg-gray-iron-11 th-dark:text-white th-dark:hover:bg-gray-iron-9',
'th-highcontrast:bg-black th-highcontrast:text-white th-highcontrast:hover:bg-white th-highcontrast:hover:text-black'
);
interface SortOptionItemProps<TSortKey extends string> {
option: SortOption<TSortKey>;
@@ -113,7 +116,11 @@ function SortOptionItem<TSortKey extends string>({
options={groupOptions?.[option.key]}
selected={groupFilter}
onSelect={onGroupFilterChange}
badge={isActive ? groupFilter : undefined}
badge={
isActive
? getGroupLabel(groupOptions, option.key, groupFilter)
: undefined
}
className={className}
data-cy={`${dataCy}-sort-by-${option.key.toLowerCase()}-button`}
onClick={() => {
@@ -140,3 +147,17 @@ function SortOptionItem<TSortKey extends string>({
</button>
);
}
function getGroupLabel(
groupOptions: Record<string, DropdownOption[]> | undefined,
groupKey: string,
filter: string | null
): string | null {
if (!filter) {
return null;
}
const option = groupOptions?.[groupKey]?.find((o) => o.key === filter);
if (!option) return null;
return option.label ?? option.key;
}
@@ -60,18 +60,14 @@ export function SortableList<T>({
sortBy={activeSortKey}
onSortChange={(key) => {
tableState.setSortBy(key, false);
tableState.setGroupFilter(null);
tableState.setPage(0);
}}
searchTerm={tableState.search}
onSearchChange={(value) => {
tableState.setSearch(value);
tableState.setPage(0);
}}
groupFilter={tableState.groupFilter}
onGroupFilterChange={(value) => {
tableState.setGroupFilter(value);
tableState.setPage(0);
}}
groupOptions={groupOptions}
sortOptions={sortOptions}
@@ -1,9 +1,12 @@
import { PropsWithChildren } from 'react';
import { WidgetBody } from '@@/Widget';
import { Widget } from '@@/Widget/Widget';
export function SortableListCard({ children }: PropsWithChildren<unknown>) {
return (
<div className="flex flex-col rounded-lg shadow-[0_1px_2px_rgba(16,24,40,.06),0_6px_16px_rgba(16,24,40,.08)]">
{children}
</div>
<Widget className="th-dark:!border-gray-8">
<WidgetBody className="overflow-hidden !p-0">{children}</WidgetBody>
</Widget>
);
}
@@ -31,7 +31,7 @@ export function SortableListGroup<T>({
className={clsx(
'flex items-center gap-2 px-5 py-2.5',
'bg-gray-2 th-highcontrast:bg-black th-dark:bg-gray-iron-11',
'border-0 border-b border-solid border-gray-4 th-dark:border-gray-8'
'border-0 border-b border-solid border-gray-5 th-dark:border-gray-9'
)}
>
{group.icon && (
@@ -6,7 +6,7 @@ interface Props {
export function SortableListItem({ children }: Props) {
return (
<div className="border-0 border-b border-solid border-gray-3 px-5 py-3 th-dark:border-gray-8">
<div className="border-0 border-b border-solid border-gray-5 px-5 py-3 th-dark:border-gray-9">
{children}
</div>
);
@@ -20,7 +20,7 @@ export function SortableListPager({
const safePage = Math.min(page, totalPages - 1);
return (
<div className="flex items-center justify-between px-5 py-3 text-xs text-gray-7 th-dark:text-gray-4">
<div className="flex items-center justify-between px-5 py-3 text-xs text-gray-7 th-highcontrast:text-gray-4 th-dark:text-gray-4">
<SortableListPagerInfo
page={safePage}
totalCount={totalCount}
@@ -35,7 +35,7 @@ export function SortableListPagerInfo({
<select
className={clsx(
'rounded border border-solid border-gray-4 bg-transparent px-2 py-1',
'text-xs text-gray-7 th-dark:border-gray-7 th-dark:text-gray-4',
'text-xs text-gray-7 th-highcontrast:text-gray-4 th-dark:border-gray-7 th-dark:text-gray-4',
'cursor-pointer focus:outline-none'
)}
value={pageSize}
@@ -0,0 +1,130 @@
import { renderHook, act } from '@testing-library/react-hooks';
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { useTableStateFromUrl } from './useTableStateFromUrl';
const mockSetUrlState = vi.fn();
const mockSetStoredPageSize = vi.fn();
let mockUrlParams: Record<string, string | undefined> = {};
let mockStoredPageSize = 10;
vi.mock('@/react/hooks/useParamState', () => ({
useParamsState: (
parseParams: (params: Record<string, string | undefined>) => unknown
) => [parseParams(mockUrlParams), mockSetUrlState] as const,
}));
vi.mock('@/react/hooks/useLocalStorage', () => ({
useLocalStorage: () => [mockStoredPageSize, mockSetStoredPageSize],
}));
describe('useTableStateFromUrl', () => {
beforeEach(() => {
mockUrlParams = {};
mockStoredPageSize = 10;
mockSetUrlState.mockReset();
mockSetStoredPageSize.mockReset();
});
it('returns defaults when URL params are absent', () => {
const { result } = renderHook(() =>
useTableStateFromUrl({ localStorageKey: 'test', defaultSort: 'name' })
);
expect(result.current.search).toBe('');
expect(result.current.sortBy).toEqual({ id: 'name', desc: false });
expect(result.current.page).toBe(0);
expect(result.current.pageSize).toBe(10);
});
it('setSearch updates search and resets page to 0', () => {
const { result } = renderHook(() =>
useTableStateFromUrl({ localStorageKey: 'test', defaultSort: 'name' })
);
act(() => {
result.current.setSearch('hello');
});
expect(mockSetUrlState).toHaveBeenCalledWith({ search: 'hello', page: 0 });
});
it('setSortBy updates sort and order and resets page to 0', () => {
const { result } = renderHook(() =>
useTableStateFromUrl({ localStorageKey: 'test', defaultSort: 'name' })
);
act(() => {
result.current.setSortBy('age', true);
});
expect(mockSetUrlState).toHaveBeenCalledWith({
sort: 'age',
order: 'desc',
page: 0,
});
});
it('setPage updates page only', () => {
const { result } = renderHook(() =>
useTableStateFromUrl({ localStorageKey: 'test', defaultSort: 'name' })
);
act(() => {
result.current.setPage(3);
});
expect(mockSetUrlState).toHaveBeenCalledWith({ page: 3 });
});
it('setPageSize updates localStorage and URL pageSize, resets page to 0', () => {
const { result } = renderHook(() =>
useTableStateFromUrl({ localStorageKey: 'test', defaultSort: 'name' })
);
act(() => {
result.current.setPageSize(25);
});
expect(mockSetStoredPageSize).toHaveBeenCalledWith(25);
expect(mockSetUrlState).toHaveBeenCalledWith({ pageSize: 25, page: 0 });
});
it('falls back to 0 for invalid page URL param', () => {
mockUrlParams = { page: 'abc' };
const { result } = renderHook(() =>
useTableStateFromUrl({ localStorageKey: 'test', defaultSort: 'name' })
);
expect(result.current.page).toBe(0);
});
it('falls back to localStorage pageSize for invalid pageSize URL param', () => {
mockUrlParams = { pageSize: 'abc' };
mockStoredPageSize = 20;
const { result } = renderHook(() =>
useTableStateFromUrl({ localStorageKey: 'test', defaultSort: 'name' })
);
expect(result.current.pageSize).toBe(20);
});
it('sanitizes out-of-range URL params to safe defaults', () => {
mockUrlParams = { page: '-3', pageSize: '0', order: 'sideways' };
mockStoredPageSize = 15;
const defaultSort = 'sorting';
const { result } = renderHook(() =>
useTableStateFromUrl({ localStorageKey: 'test', defaultSort })
);
expect(result.current.page).toBe(0);
expect(result.current.pageSize).toBe(15);
expect(result.current.sortBy?.desc).toBe(false);
expect(result.current.search).toBe('');
expect(result.current.sortBy?.id).toBe(defaultSort);
});
});
@@ -0,0 +1,106 @@
import { useLocalStorage } from '@/react/hooks/useLocalStorage';
import { useParamsState } from '@/react/hooks/useParamState';
import { BasicTableSettings } from './types';
type CoreUrlState = {
search: string;
sort: string;
order: 'asc' | 'desc';
page: number;
pageSize: number | null;
};
type Extra = {
search: string;
setSearch(value: string): void;
page: number;
setPage(page: number): void;
};
export function useTableStateFromUrl<
TParsed extends Record<string, unknown> = Record<never, never>,
TExtra extends Record<string, unknown> = Record<never, never>,
>({
localStorageKey,
defaultSort = 'name',
parseExtra,
buildExtra,
}: {
localStorageKey: string;
defaultSort?: string;
parseExtra?: (params: Record<string, string | undefined>) => TParsed;
buildExtra?: (
urlState: CoreUrlState & TParsed,
setUrlState: (s: Partial<CoreUrlState & TParsed>) => void
) => TExtra;
}): BasicTableSettings & TExtra & Extra {
const [storedPageSize, setStoredPageSize] = useLocalStorage(
`datatable_settings_${localStorageKey}_pageSize`,
10
);
const [urlState, setUrlState] = useParamsState((params) => ({
search: params.search ?? '',
sort: params.sort ?? defaultSort,
order: (params.order === 'desc' ? 'desc' : 'asc') as 'asc' | 'desc',
page: Math.max(0, parseIntOrDefault(params.page, 0)),
pageSize: parsePositiveIntOrNull(params.pageSize),
...(parseExtra ? parseExtra(params) : ({} as TParsed)),
}));
const pageSize = urlState.pageSize ?? storedPageSize;
const extra = buildExtra ? buildExtra(urlState, setUrlState) : ({} as TExtra);
const tableState = {
search: urlState.search,
setSearch: (search: string) => setCoreState({ search, page: 0 }),
sortBy: { id: urlState.sort, desc: urlState.order === 'desc' },
setSortBy: (id, desc) =>
setCoreState({
sort: id ?? defaultSort,
order: desc ? 'desc' : 'asc',
page: 0,
}),
page: urlState.page,
setPage: (page) => setCoreState({ page }),
pageSize,
setPageSize: (size) => {
setStoredPageSize(size);
setCoreState({ pageSize: size, page: 0 });
},
...extra,
} satisfies BasicTableSettings & TExtra & Extra;
return tableState;
function setCoreState(partial: Partial<CoreUrlState>) {
return setUrlState(partial as Partial<CoreUrlState & TParsed>);
}
}
export function parseIntOrDefault<T>(
raw: string | undefined,
fallback: T
): number | T {
if (!raw) return fallback;
const n = parseInt(raw, 10);
return Number.isFinite(n) ? n : fallback;
}
export function parsePositiveIntOrNull(raw: string | undefined): number | null {
const n = parseIntOrDefault(raw, null);
return n !== null && n > 0 ? n : null;
}
export function asEnum<T>(
value: string | undefined,
allowed: Set<T>
): T | null {
return allowed.has(value as T) ? (value as T) : null;
}
@@ -7,16 +7,12 @@ import { Icon } from '@@/Icon';
import { Link } from '@@/Link';
import { SortableListItem } from '@@/SortableList/SortableListItem';
import { Workflow } from './types';
import { Workflow, WorkflowType } from './types';
import { StatusBadge, TypeBadge } from './WorkflowBadges';
import { WorkflowSubRow } from './WorkflowSubRow/WorkflowSubRow';
export function WorkflowCard({ item }: { item: Workflow }) {
const { to, params } = getStackLink(item);
const syncLabel = item.lastSyncDate
? moment.unix(item.lastSyncDate).fromNow()
: '-';
const syncTitle = item.type === 'edgeStack' ? 'Oldest sync' : 'Last sync';
return (
<SortableListItem>
@@ -38,12 +34,7 @@ export function WorkflowCard({ item }: { item: Workflow }) {
<StatusBadge status={item.status} />
<TypeBadge type={item.type} />
</div>
<div className="flex items-center gap-1.5 text-xs text-gray-5">
<Icon icon={WatchIcon} size="xs" />
<span>
{syncTitle}: {syncLabel}
</span>
</div>
<SyncLabel type={item.type} date={item.lastSyncDate} />
</div>
<WorkflowSubRow item={item} />
{item.statusMessage && (
@@ -58,6 +49,20 @@ export function WorkflowCard({ item }: { item: Workflow }) {
);
}
function SyncLabel({ type, date }: { type: WorkflowType; date: number }) {
const syncLabel = date ? moment.unix(date).fromNow() : '-';
const syncTitle = type === 'edgeStack' ? 'Oldest sync' : 'Last sync';
return (
<div className="flex items-center gap-1.5 text-xs text-gray-7 th-highcontrast:text-gray-3 th-dark:text-gray-3">
<Icon icon={WatchIcon} size="xs" />
<span>
{syncTitle}: {syncLabel}
</span>
</div>
);
}
function getStackLink(item: Workflow): { to: string; params: object } {
if (item.type === 'edgeStack') {
return { to: 'edge.stacks.edit', params: { stackId: item.id } };
@@ -4,19 +4,19 @@ import { ReactNode } from 'react';
import { WorkflowStatus } from '../types';
const STATUS_DOT_CLASSES: Record<WorkflowStatus, string> = {
healthy: 'bg-success-7',
error: 'bg-error-7',
syncing: 'bg-warning-7',
paused: 'bg-warning-5',
unknown: 'bg-gray-4',
healthy: 'bg-success-7 th-highcontrast:bg-success-3',
error: 'bg-error-7 th-highcontrast:bg-error-3',
syncing: 'bg-warning-7 th-highcontrast:bg-warning-3',
paused: 'bg-warning-5 th-highcontrast:bg-yellow-3',
unknown: 'bg-gray-4 th-highcontrast:bg-gray-3',
};
const STATUS_BLOCK_CLASSES: Record<WorkflowStatus, string> = {
healthy: 'bg-success-1 th-dark:bg-success-11',
error: 'bg-error-1 th-dark:bg-error-11',
syncing: 'bg-warning-1 th-dark:bg-warning-11',
paused: 'bg-gray-2 th-dark:bg-gray-iron-11',
unknown: 'bg-gray-2 th-dark:bg-gray-iron-11',
healthy: 'bg-success-1 th-dark:bg-success-11 th-highcontrast:bg-success-11',
error: 'bg-error-1 th-dark:bg-error-11 th-highcontrast:bg-error-11',
syncing: 'bg-warning-1 th-dark:bg-warning-11 th-highcontrast:bg-warning-11',
paused: 'bg-gray-2 th-dark:bg-gray-iron-11 th-highcontrast:bg-gray-iron-11',
unknown: 'bg-gray-2 th-dark:bg-gray-iron-11 th-highcontrast:bg-gray-iron-11',
};
export function Block({
@@ -11,9 +11,9 @@ export function WorkflowSubRow({ item }: { item: Workflow }) {
const status = deriveSubRowStatuses(item);
return (
<div className="overflow-hidden rounded border border-solid border-gray-3 text-xs th-dark:border-gray-8">
<div className="overflow-hidden rounded border border-solid border-gray-3 text-xs th-dark:border-gray-9">
<table className="w-full table-fixed border-collapse">
<thead className="border-0 border-b border-solid border-gray-3 bg-gray-2 th-dark:border-gray-8 th-dark:bg-gray-iron-11">
<thead className="border-0 border-b border-solid border-gray-3 bg-gray-2 th-dark:border-gray-9 th-dark:bg-gray-iron-11">
<tr>
<Th>Source</Th>
<Th divider>Artifacts</Th>
@@ -66,10 +66,12 @@ function SourceCell({
<Block status={status} className="flex items-start gap-2">
<Dot status={status} className="mt-1.5" />
<div className="min-w-0">
<p className="m-0 font-semibold text-gray-9 th-dark:text-white">
<p className="m-0 font-semibold text-gray-9 th-highcontrast:text-white th-dark:text-white">
{name}
</p>
<p className="m-0 mt-0.5 break-all text-gray-5">{url}</p>
<p className="m-0 mt-0.5 break-all text-gray-7 th-highcontrast:text-gray-3 th-dark:text-gray-3">
{url}
</p>
</div>
</Block>
);
@@ -85,7 +87,9 @@ function ArtifactCell({
return (
<Block status={status} className="flex items-center gap-2">
<Dot status={status} />
<span className="font-mono text-gray-7 th-dark:text-gray-4">{path}</span>
<span className="font-mono text-gray-7 th-highcontrast:text-gray-3 th-dark:text-gray-4">
{path}
</span>
</Block>
);
}
@@ -94,9 +98,9 @@ function Th({ children, divider }: { children: ReactNode; divider?: boolean }) {
return (
<th
className={clsx(
'w-1/3 px-4 py-2 text-left text-[10px] font-semibold uppercase tracking-wider text-gray-5',
'w-1/3 px-4 py-2 text-left text-sm font-semibold uppercase tracking-wider text-gray-7 th-highcontrast:text-white th-dark:text-white',
divider &&
'border-0 border-l border-solid border-gray-3 th-dark:border-gray-8'
'border-0 border-l border-solid border-gray-3 th-dark:border-gray-9'
)}
>
{children}
@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react';
import { useMemo } from 'react';
import { PageHeader } from '@@/PageHeader';
import { StatusSummaryBar } from '@@/StatusSummaryBar/StatusSummaryBar';
@@ -7,18 +7,13 @@ import {
SortableGroup,
SortOption,
} from '@@/SortableList/SortableList';
import { useSortableListState } from '@@/SortableList/sortable-list.store';
import { useWorkflows } from '../queries/useWorkflows';
import { useWorkflowsSummary } from '../queries/useWorkflowsSummary';
import { WorkflowCard } from './WorkflowCard';
import {
DeploymentPlatform,
Workflow,
WorkflowStatus,
WorkflowType,
} from './types';
import { Workflow, WorkflowStatus } from './types';
import { useListState } from './useListState';
const STATUS_CONFIG: Array<{
key: WorkflowStatus;
@@ -60,8 +55,8 @@ const GROUP_FIELD: Record<string, (item: Workflow) => string> = {
};
export function WorkflowsView() {
const [statusFilter, setStatusFilter] = useState<WorkflowStatus | null>(null);
const tableState = useSortableListState('workflows', 'name');
const tableState = useListState();
const sortBy = tableState.sortBy?.id ?? 'name';
const workflowsQuery = useWorkflows({
@@ -70,9 +65,9 @@ export function WorkflowsView() {
order: tableState.sortBy?.desc ? 'desc' : 'asc',
start: tableState.page * tableState.pageSize,
limit: tableState.pageSize,
status: statusFilter ?? groupFilter<WorkflowStatus>('status'),
type: groupFilter<WorkflowType>('type'),
platform: groupFilter<DeploymentPlatform>('platform'),
status: tableState.status ?? undefined,
type: tableState.type ?? undefined,
platform: tableState.platform ?? undefined,
});
const summaryQuery = useWorkflowsSummary();
@@ -92,7 +87,6 @@ export function WorkflowsView() {
...GROUP_OPTIONS,
status: statusSegments,
}),
[statusSegments]
);
@@ -111,11 +105,8 @@ export function WorkflowsView() {
<StatusSummaryBar
total={summaryTotal}
segments={statusSegments}
value={statusFilter}
onChange={(v) => {
setStatusFilter(v);
tableState.setPage(0);
}}
value={tableState.status}
onChange={tableState.setStatus}
radioGroupName="workflows-status"
/>
<SortableList
@@ -135,10 +126,6 @@ export function WorkflowsView() {
</div>
</>
);
function groupFilter<T>(key: string) {
return sortBy === key ? (tableState.groupFilter as T | null) : undefined;
}
}
function buildGroups(
@@ -0,0 +1,108 @@
import {
asEnum,
useTableStateFromUrl,
} from '@@/datatables/useTableStateFromUrl';
import { WorkflowStatus, WorkflowType, DeploymentPlatform } from './types';
const DEFAULT_SORT = 'name' as const;
const WORKFLOW_STATUSES = new Set<WorkflowStatus>([
'healthy',
'error',
'syncing',
'paused',
'unknown',
]);
const WORKFLOW_TYPES = new Set<WorkflowType>(['stack', 'edgeStack']);
const DEPLOYMENT_PLATFORMS = new Set<DeploymentPlatform>([
'dockerStandalone',
'dockerSwarm',
'kubernetes',
]);
export function useListState() {
return useTableStateFromUrl({
localStorageKey: 'workflows',
defaultSort: DEFAULT_SORT,
parseExtra: (params) => ({
status: asEnum(params.status, WORKFLOW_STATUSES),
type: asEnum(params.type, WORKFLOW_TYPES),
platform: asEnum(params.platform, DEPLOYMENT_PLATFORMS),
}),
buildExtra: (urlState, setUrlState) => {
const sortKey = toSortKey(urlState.sort);
return {
status: urlState.status,
type: urlState.type,
platform: urlState.platform,
setStatus: (v: WorkflowStatus | null) =>
setUrlState({ status: v, page: 0 }),
groupFilter: getGroupFilter(sortKey, urlState),
setGroupFilter: (value: string | null) => {
if (sortKey === 'status') {
setUrlState({ status: value as WorkflowStatus | null, page: 0 });
} else if (sortKey === 'type') {
setUrlState({
type: value as WorkflowType | null,
page: 0,
});
} else if (sortKey === 'platform') {
setUrlState({
platform: value as DeploymentPlatform | null,
page: 0,
});
}
},
setSortBy: (id: string, desc: boolean) =>
setUrlState({
sort: id ?? DEFAULT_SORT,
order: desc ? 'desc' : 'asc',
// Clear status filter only if was previously grouped by status
...(id === 'status' ? { status: null } : {}),
type: null,
platform: null,
page: 0,
}),
};
},
});
}
function getGroupFilter(
sortKey: SortKey,
urlState: {
status: WorkflowStatus | null;
type: WorkflowType | null;
platform: DeploymentPlatform | null;
}
) {
switch (sortKey) {
case 'status':
return urlState.status;
case 'platform':
return urlState.platform;
case 'type':
return urlState.type;
case 'name':
case 'lastSyncDate':
return null;
}
}
const SORT_KEYS = [
'name',
'status',
'type',
'platform',
'lastSyncDate',
] as const;
type SortKey = (typeof SORT_KEYS)[number];
function toSortKey(sort: string): SortKey {
return (SORT_KEYS as readonly string[]).includes(sort)
? (sort as SortKey)
: DEFAULT_SORT;
}
+5 -1
View File
@@ -67,14 +67,18 @@ function InnerSidebar() {
label="Home"
data-cy="portainerSidebar-home"
/>
<EnvironmentSidebar />
<SidebarItem
to="portainer.workflows"
icon={GitBranch}
label="Workflows"
data-cy="portainerSidebar-workflows"
/>
<EnvironmentSidebar />
{isAdmin && <EdgeComputeSidebar />}
<SettingsSidebar
isPureAdmin={isPureAdmin}
isAdmin={isAdmin}