diff --git a/app/portainer/__module.js b/app/portainer/__module.js index b0a076916..ce89b96dc 100644 --- a/app/portainer/__module.js +++ b/app/portainer/__module.js @@ -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', diff --git a/app/portainer/helpers/stateParamHelper.js b/app/portainer/helpers/stateParamHelper.js new file mode 100644 index 000000000..2588f45fb --- /dev/null +++ b/app/portainer/helpers/stateParamHelper.js @@ -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(), + }; +} diff --git a/app/react/components/GroupSortTable/GroupSortTableHeader.tsx b/app/react/components/GroupSortTable/GroupSortTableHeader.tsx index 9928a203f..25efd69ab 100644 --- a/app/react/components/GroupSortTable/GroupSortTableHeader.tsx +++ b/app/react/components/GroupSortTable/GroupSortTableHeader.tsx @@ -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 { onSearchChange: (term: string) => void; sortOptions: SortOption[]; searchPlaceholder?: string; - actionButton?: React.ReactNode; + actionButton?: ReactNode; groupFilter: string | null; groupOptions?: Record; onGroupFilterChange: (value: string | null) => void; + headerButtons?: ReactNode; } export function GroupSortTableHeader({ @@ -35,6 +36,7 @@ export function GroupSortTableHeader({ groupFilter, groupOptions, onGroupFilterChange, + headerButtons, 'data-cy': dataCy, }: Props & AutomationTestingProps) { return ( @@ -42,7 +44,7 @@ export function GroupSortTableHeader({ 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' )} > ({ dataCy={dataCy} />
+ {headerButtons} ({ ); } -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 { option: SortOption; @@ -113,7 +116,11 @@ function SortOptionItem({ 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({ ); } + +function getGroupLabel( + groupOptions: Record | 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; +} diff --git a/app/react/components/SortableList/SortableList.tsx b/app/react/components/SortableList/SortableList.tsx index 8501207a8..87c9d999a 100644 --- a/app/react/components/SortableList/SortableList.tsx +++ b/app/react/components/SortableList/SortableList.tsx @@ -60,18 +60,14 @@ export function SortableList({ 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} diff --git a/app/react/components/SortableList/SortableListCard.tsx b/app/react/components/SortableList/SortableListCard.tsx index 81600a347..3054e8b77 100644 --- a/app/react/components/SortableList/SortableListCard.tsx +++ b/app/react/components/SortableList/SortableListCard.tsx @@ -1,9 +1,12 @@ import { PropsWithChildren } from 'react'; +import { WidgetBody } from '@@/Widget'; +import { Widget } from '@@/Widget/Widget'; + export function SortableListCard({ children }: PropsWithChildren) { return ( -
- {children} -
+ + {children} + ); } diff --git a/app/react/components/SortableList/SortableListGroup.tsx b/app/react/components/SortableList/SortableListGroup.tsx index 1835f031d..c7f1e3b80 100644 --- a/app/react/components/SortableList/SortableListGroup.tsx +++ b/app/react/components/SortableList/SortableListGroup.tsx @@ -31,7 +31,7 @@ export function SortableListGroup({ 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 && ( diff --git a/app/react/components/SortableList/SortableListItem.tsx b/app/react/components/SortableList/SortableListItem.tsx index 86b69bb9e..4e3aacd5a 100644 --- a/app/react/components/SortableList/SortableListItem.tsx +++ b/app/react/components/SortableList/SortableListItem.tsx @@ -6,7 +6,7 @@ interface Props { export function SortableListItem({ children }: Props) { return ( -
+
{children}
); diff --git a/app/react/components/SortableList/SortableListPager.tsx b/app/react/components/SortableList/SortableListPager.tsx index 33def182e..955e4fafa 100644 --- a/app/react/components/SortableList/SortableListPager.tsx +++ b/app/react/components/SortableList/SortableListPager.tsx @@ -20,7 +20,7 @@ export function SortableListPager({ const safePage = Math.min(page, totalPages - 1); return ( -
+
= {}; +let mockStoredPageSize = 10; + +vi.mock('@/react/hooks/useParamState', () => ({ + useParamsState: ( + parseParams: (params: Record) => 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); + }); +}); diff --git a/app/react/components/datatables/useTableStateFromUrl.ts b/app/react/components/datatables/useTableStateFromUrl.ts new file mode 100644 index 000000000..1f922be7d --- /dev/null +++ b/app/react/components/datatables/useTableStateFromUrl.ts @@ -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 = Record, + TExtra extends Record = Record, +>({ + localStorageKey, + defaultSort = 'name', + parseExtra, + buildExtra, +}: { + localStorageKey: string; + defaultSort?: string; + parseExtra?: (params: Record) => TParsed; + buildExtra?: ( + urlState: CoreUrlState & TParsed, + setUrlState: (s: Partial) => 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) { + return setUrlState(partial as Partial); + } +} + +export function parseIntOrDefault( + 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( + value: string | undefined, + allowed: Set +): T | null { + return allowed.has(value as T) ? (value as T) : null; +} diff --git a/app/react/portainer/gitops/WorkflowsView/WorkflowCard.tsx b/app/react/portainer/gitops/WorkflowsView/WorkflowCard.tsx index feea740ad..6ee020593 100644 --- a/app/react/portainer/gitops/WorkflowsView/WorkflowCard.tsx +++ b/app/react/portainer/gitops/WorkflowsView/WorkflowCard.tsx @@ -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 ( @@ -38,12 +34,7 @@ export function WorkflowCard({ item }: { item: Workflow }) {
-
- - - {syncTitle}: {syncLabel} - -
+
{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 ( +
+ + + {syncTitle}: {syncLabel} + +
+ ); +} + function getStackLink(item: Workflow): { to: string; params: object } { if (item.type === 'edgeStack') { return { to: 'edge.stacks.edit', params: { stackId: item.id } }; diff --git a/app/react/portainer/gitops/WorkflowsView/WorkflowSubRow/Block.tsx b/app/react/portainer/gitops/WorkflowsView/WorkflowSubRow/Block.tsx index bb796e145..43f5e7085 100644 --- a/app/react/portainer/gitops/WorkflowsView/WorkflowSubRow/Block.tsx +++ b/app/react/portainer/gitops/WorkflowsView/WorkflowSubRow/Block.tsx @@ -4,19 +4,19 @@ import { ReactNode } from 'react'; import { WorkflowStatus } from '../types'; const STATUS_DOT_CLASSES: Record = { - 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 = { - 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({ diff --git a/app/react/portainer/gitops/WorkflowsView/WorkflowSubRow/WorkflowSubRow.tsx b/app/react/portainer/gitops/WorkflowsView/WorkflowSubRow/WorkflowSubRow.tsx index d976d2622..6eeb4babc 100644 --- a/app/react/portainer/gitops/WorkflowsView/WorkflowSubRow/WorkflowSubRow.tsx +++ b/app/react/portainer/gitops/WorkflowsView/WorkflowSubRow/WorkflowSubRow.tsx @@ -11,9 +11,9 @@ export function WorkflowSubRow({ item }: { item: Workflow }) { const status = deriveSubRowStatuses(item); return ( -
+
- + @@ -66,10 +66,12 @@ function SourceCell({
-

+

{name}

-

{url}

+

+ {url} +

); @@ -85,7 +87,9 @@ function ArtifactCell({ return ( - {path} + + {path} + ); } @@ -94,9 +98,9 @@ function Th({ children, divider }: { children: ReactNode; divider?: boolean }) { return (
Source Artifacts {children} diff --git a/app/react/portainer/gitops/WorkflowsView/WorkflowsView.tsx b/app/react/portainer/gitops/WorkflowsView/WorkflowsView.tsx index c12312623..ce1b4a2ac 100644 --- a/app/react/portainer/gitops/WorkflowsView/WorkflowsView.tsx +++ b/app/react/portainer/gitops/WorkflowsView/WorkflowsView.tsx @@ -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> = { }; export function WorkflowsView() { - const [statusFilter, setStatusFilter] = useState(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('status'), - type: groupFilter('type'), - platform: groupFilter('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() { { - setStatusFilter(v); - tableState.setPage(0); - }} + value={tableState.status} + onChange={tableState.setStatus} radioGroupName="workflows-status" /> ); - - function groupFilter(key: string) { - return sortBy === key ? (tableState.groupFilter as T | null) : undefined; - } } function buildGroups( diff --git a/app/react/portainer/gitops/WorkflowsView/useListState.ts b/app/react/portainer/gitops/WorkflowsView/useListState.ts new file mode 100644 index 000000000..1afd7e6b4 --- /dev/null +++ b/app/react/portainer/gitops/WorkflowsView/useListState.ts @@ -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([ + 'healthy', + 'error', + 'syncing', + 'paused', + 'unknown', +]); +const WORKFLOW_TYPES = new Set(['stack', 'edgeStack']); +const DEPLOYMENT_PLATFORMS = new Set([ + '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; +} diff --git a/app/react/sidebar/Sidebar.tsx b/app/react/sidebar/Sidebar.tsx index 7cee81037..08136b558 100644 --- a/app/react/sidebar/Sidebar.tsx +++ b/app/react/sidebar/Sidebar.tsx @@ -67,14 +67,18 @@ function InnerSidebar() { label="Home" data-cy="portainerSidebar-home" /> + + + - + {isAdmin && } +