fix(gitops): align list component with current design [BE-12888] (#2445)
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user