Merge branch 'next' into develop

This commit is contained in:
nocobase[bot]
2026-08-16 03:24:13 +00:00
11 changed files with 1152 additions and 957 deletions
@@ -125,6 +125,11 @@ describe('PluginWorkflowCCClientV2 task type registration', () => {
status: 1,
},
});
expect(taskType.useActionParams('pending', 'workflow-1')).toMatchObject({
filter: {
$and: [{ status: 0 }, { 'workflow.key': 'workflow-1' }],
},
});
const get = vi.fn();
const apiClient = {
@@ -210,9 +210,9 @@ describe('workflow-cc v2 task type', () => {
expect(holder.collectionFilterProps[0]).toEqual(
expect.objectContaining({
collection: holder.workflowCcTasksCollection,
filterableFieldNames: ['title', 'workflow'],
filterableFieldNames: ['title'],
initialValue: {
$and: [{ title: { $includes: '' } }, { 'workflow.title': { $includes: '' } }],
$and: [{ title: { $includes: '' } }],
},
}),
);
@@ -305,6 +305,32 @@ describe('workflow-cc v2 task type', () => {
expect(button).toBeDisabled();
});
it('marks only the selected workflow as read and uses its pending count', async () => {
holder.counts = { cc: { pending: 0 } };
holder.read.mockResolvedValue({});
const Actions = ccTaskType.Actions as React.ComponentType<{
reload?: () => Promise<void>;
workflowKey?: string;
workflowPendingCount?: number;
}>;
renderWithApp(
<Actions reload={vi.fn().mockResolvedValue(undefined)} workflowKey="workflow-1" workflowPendingCount={2} />,
);
const button = screen.getByRole('button', { name: /Mark all as read/ });
expect(button).toBeEnabled();
fireEvent.click(button);
await waitFor(() =>
expect(holder.read).toHaveBeenCalledWith({
filter: {
'workflow.key': 'workflow-1',
},
}),
);
});
it('does not run a duplicate count reload after Mark all as read when task-center reload is provided', async () => {
const reload = vi.fn().mockResolvedValue(undefined);
holder.read.mockResolvedValue({});
@@ -17,6 +17,7 @@ import {
useWorkflowTaskCounts,
useWorkflowTaskRecord,
type TaskTypeOptions,
type WorkflowTaskActionsProps,
type WorkflowTaskApiClient,
type WorkflowTaskDetailModalProps,
type WorkflowTaskFlowContext,
@@ -67,7 +68,7 @@ const TASK_POPUP_APPENDS = ['node', 'job', 'workflow', 'workflow.nodes', 'execut
const CC_TASK_FILTER_QUERY_KEY = 'workflowCcTasksFilter';
const DEFAULT_CC_TASK_FILTER = {
$and: [{ title: { $includes: '' } }, { 'workflow.title': { $includes: '' } }],
$and: [{ title: { $includes: '' } }],
};
const MOBILE_FILTER_POPOVER_WIDTH = 312;
const MOBILE_FILTER_CONTENT_MIN_WIDTH = 288;
@@ -603,7 +604,7 @@ function WorkflowCcTaskFilterAction({ onlyIcon }: { onlyIcon?: boolean; reload?:
initialValue={initialValue}
onChange={handleFilterChange}
t={t}
filterableFieldNames={['title', 'workflow']}
filterableFieldNames={['title']}
buttonText={onlyIcon ? '' : undefined}
showCount={false}
popoverMinWidth={onlyIcon ? MOBILE_FILTER_CONTENT_MIN_WIDTH : undefined}
@@ -631,7 +632,7 @@ function WorkflowCcTaskFilterAction({ onlyIcon }: { onlyIcon?: boolean; reload?:
return onlyIcon ? <Tooltip title={filterText}>{filter}</Tooltip> : filter;
}
function WorkflowCcTaskActions({ onlyIcon, reload }: { onlyIcon?: boolean; reload?: () => Promise<void> }) {
function WorkflowCcTaskActions({ onlyIcon, reload, workflowKey, workflowPendingCount }: WorkflowTaskActionsProps) {
const ctx = useFlowContext() as WorkflowTaskFlowContext | undefined;
const taskTypes = getWorkflowTaskRegistry(ctx);
const { counts, reload: reloadCounts } = useWorkflowTaskCounts(ctx, taskTypes);
@@ -641,7 +642,7 @@ function WorkflowCcTaskActions({ onlyIcon, reload }: { onlyIcon?: boolean; reloa
const [readAllSubmitted, setReadAllSubmitted] = useState(false);
const [readAllSubmitting, setReadAllSubmitting] = useState(false);
const readAllSubmittingRef = useRef(false);
const pendingCount = counts[TASK_TYPE_CC]?.pending;
const pendingCount = workflowPendingCount ?? counts[TASK_TYPE_CC]?.pending;
const readAllDisabled = readAllSubmitting || readAllSubmitted || pendingCount === 0;
useEffect(() => {
@@ -670,7 +671,15 @@ function WorkflowCcTaskActions({ onlyIcon, reload }: { onlyIcon?: boolean; reloa
readAllSubmittingRef.current = true;
setReadAllSubmitting(true);
try {
await ctx?.api.resource('workflowCcTasks').read?.();
await ctx?.api.resource('workflowCcTasks').read?.({
...(workflowKey
? {
filter: {
'workflow.key': workflowKey,
},
}
: {}),
});
setReadAllSubmitted(true);
if (reload) {
await reload();
@@ -713,9 +722,13 @@ function WorkflowCcTaskActions({ onlyIcon, reload }: { onlyIcon?: boolean; reloa
);
}
export function useCcTaskActionParams(status: WorkflowTaskStatus) {
export function useCcTaskActionParams(status: WorkflowTaskStatus, workflowKey?: string) {
const statusParams = STATUS_FILTER_MAP[status] ?? {};
const filter = mergeFilters(statusParams.filter as Record<string, unknown> | undefined, readTaskFilter());
const workflowFilter = workflowKey ? { 'workflow.key': workflowKey } : undefined;
const filter = mergeFilters(
mergeFilters(statusParams.filter as Record<string, unknown> | undefined, workflowFilter),
readTaskFilter(),
);
return {
...statusParams,
filter,
@@ -137,6 +137,19 @@ describe('PluginWorkflowManualClientV2 task type registration', () => {
appends: expect.any(Array),
except: ['node.config', 'workflow.config', 'workflow.options'],
});
expect(taskType.useActionParams('pending', 'workflow-1')).toEqual({
filter: {
$and: [
{
status: TASK_STATUS.PENDING,
'execution.status': 0,
},
{ 'workflow.key': 'workflow-1' },
],
},
appends: expect.any(Array),
except: ['node.config', 'workflow.config', 'workflow.options'],
});
expect(taskType).not.toHaveProperty('alwaysShow');
});
});
@@ -180,7 +180,7 @@ describe('workflow-manual v2 task type', () => {
expect(holder.collectionFilterProps[0]).toEqual(
expect.objectContaining({
collection: holder.filterCollection,
filterableFieldNames: ['title', 'workflow'],
filterableFieldNames: ['title'],
}),
);
expect(holder.navigate).toHaveBeenCalledWith(expect.stringContaining('workflowManualTasksFilter='), {
@@ -50,7 +50,7 @@ const TASK_LIST_APPENDS = [
const TASK_LIST_EXCEPT = ['node.config', 'workflow.config', 'workflow.options'];
const MANUAL_TASK_FILTER_QUERY_KEY = 'workflowManualTasksFilter';
const DEFAULT_MANUAL_TASK_FILTER = {
$and: [{ title: { $includes: '' } }, { 'workflow.title': { $includes: '' } }],
$and: [{ title: { $includes: '' } }],
};
const STATUS_FILTER_MAP: Partial<Record<WorkflowTaskStatus, WorkflowTaskRequestParams>> = {
@@ -230,7 +230,7 @@ function WorkflowManualTaskFilterAction({ onlyIcon }: { onlyIcon?: boolean }) {
initialValue={readTaskFilter() ?? DEFAULT_MANUAL_TASK_FILTER}
onChange={handleFilterChange}
t={t}
filterableFieldNames={['title', 'workflow']}
filterableFieldNames={['title']}
buttonText={onlyIcon ? '' : undefined}
showCount={false}
buttonProps={{ 'aria-label': onlyIcon ? filterText : undefined }}
@@ -362,9 +362,13 @@ function WorkflowManualTaskDetail() {
);
}
export function useManualTaskActionParams(status: WorkflowTaskStatus) {
export function useManualTaskActionParams(status: WorkflowTaskStatus, workflowKey?: string) {
const statusParams = STATUS_FILTER_MAP[status] ?? {};
const filter = mergeFilters(statusParams.filter as RecordObject | undefined, readTaskFilter());
const workflowFilter = workflowKey ? { 'workflow.key': workflowKey } : undefined;
const filter = mergeFilters(
mergeFilters(statusParams.filter as RecordObject | undefined, workflowFilter),
readTaskFilter(),
);
return {
...statusParams,
filter,
@@ -12,23 +12,7 @@ import { useMobileLayout } from '@nocobase/client-v2';
import { LeftOutlined, RightOutlined } from '@ant-design/icons';
import { css } from '@emotion/css';
import { useMemoizedFn } from 'ahooks';
import {
App,
Badge,
Button,
Flex,
Layout,
List,
Menu,
Modal,
Result,
Segmented,
Select,
Tabs,
Typography,
theme,
} from 'antd';
import type { MenuProps } from 'antd';
import { App, Button, Flex, Layout, List, Modal, Result, Segmented, Select, Tabs, Typography, theme } from 'antd';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { getWorkflowTasksPath } from '../constants';
@@ -52,6 +36,11 @@ import {
type WorkflowTaskStatus,
} from '../taskCenter';
import { useT } from '../locale';
import {
WorkflowTaskFilterProvider,
WorkflowTaskNavigation,
useWorkflowTaskFilterContext,
} from '../../shared/WorkflowTaskNavigation';
interface WorkflowTasksRouteParams {
taskType?: string;
@@ -59,8 +48,6 @@ interface WorkflowTasksRouteParams {
popupId?: string;
}
const MOBILE_TASK_TYPE_MENU_HEIGHT = 42;
interface PendingWorkflowTaskPopupRecord {
popupId: string;
record: WorkflowTaskRecord;
@@ -146,94 +133,15 @@ function useCurrentTaskType(
};
}
function TaskTypeMenu(props: {
taskTypes: ReturnType<typeof getWorkflowTaskRegistry>;
counts: ReturnType<typeof useWorkflowTaskCounts>['counts'];
selectedKey?: string;
status: WorkflowTaskStatus;
mobile: boolean;
}) {
const { taskTypes, counts, selectedKey, status, mobile } = props;
const navigate = useNavigate();
const t = useT();
const { token } = theme.useToken();
const route = useWorkflowTasksRoute();
const taskTypeKeys = useMemo(() => getAvailableWorkflowTaskTypeKeys(taskTypes, counts), [counts, taskTypes]);
const items = useMemo<MenuProps['items']>(
() =>
taskTypeKeys.map((key) => {
const type = taskTypes?.get(key);
return {
key,
label: (
<Flex align="center" justify="space-between" gap={token.marginSM}>
<span>{type?.title ? t(type.title) : key}</span>
<Badge count={counts[key]?.pending || 0} size="small" />
</Flex>
),
};
}),
[counts, t, taskTypeKeys, taskTypes, token.marginSM],
);
const handleMenuClick = useMemoizedFn(({ key }: { key: string }) => {
navigate(
withCurrentLocationSuffix(getWorkflowTasksPath(key, TASK_STATUS.PENDING, undefined, route.isMobileRoute), route),
);
});
if (!items?.length) {
return null;
}
if (mobile) {
return (
<Menu
data-testid="workflow-task-type-menu"
mode="horizontal"
selectedKeys={selectedKey ? [selectedKey] : []}
items={items}
onClick={handleMenuClick}
style={{
background: token.colorBgContainer,
borderBottomColor: token.colorBorderSecondary,
height: MOBILE_TASK_TYPE_MENU_HEIGHT,
lineHeight: `${MOBILE_TASK_TYPE_MENU_HEIGHT}px`,
minHeight: MOBILE_TASK_TYPE_MENU_HEIGHT,
minWidth: 0,
}}
/>
);
}
return (
<Layout.Sider
theme="light"
breakpoint="md"
collapsedWidth={0}
style={{
background: token.colorBgContainer,
borderRight: `${token.lineWidth}px ${token.lineType} ${token.colorBorderSecondary}`,
}}
>
<Menu
mode="inline"
selectedKeys={selectedKey ? [selectedKey] : []}
items={items}
onClick={handleMenuClick}
style={{ height: '100%', borderInlineEnd: 0 }}
/>
</Layout.Sider>
);
}
function TaskStatusControls(props: {
type: TaskTypeOptions;
status: WorkflowTaskStatus;
mobile: boolean;
reload: () => Promise<void>;
workflowKey?: string;
workflowPendingCount?: number;
}) {
const { type, status, mobile, reload } = props;
const { type, status, mobile, reload, workflowKey, workflowPendingCount } = props;
const navigate = useNavigate();
const t = useT();
const { token } = theme.useToken();
@@ -270,7 +178,9 @@ function TaskStatusControls(props: {
onChange={handleStatusChange}
style={{ minWidth: 0 }}
/>
{Actions ? <Actions onlyIcon reload={reload} /> : null}
{Actions ? (
<Actions onlyIcon reload={reload} workflowKey={workflowKey} workflowPendingCount={workflowPendingCount} />
) : null}
</Flex>
);
}
@@ -281,7 +191,13 @@ function TaskStatusControls(props: {
onChange={handleStatusChange}
items={statusItems.map(({ key, label }) => ({ key, label }))}
tabBarStyle={{ marginBottom: 0 }}
tabBarExtraContent={Actions ? { right: <Actions reload={reload} /> } : undefined}
tabBarExtraContent={
Actions
? {
right: <Actions reload={reload} workflowKey={workflowKey} workflowPendingCount={workflowPendingCount} />,
}
: undefined
}
/>
);
}
@@ -533,12 +449,16 @@ function WorkflowTaskMobileDetailPage(props: { children: React.ReactNode; onClos
);
}
function WorkflowTasksPageContent() {
const ctx = useFlowContext() as WorkflowTaskFlowContext | undefined;
const taskTypes = getWorkflowTaskRegistry(ctx);
const countsState = useWorkflowTaskCounts(ctx, taskTypes);
function WorkflowTasksPageContent(props: {
availableTaskTypeKeys: string[];
countsState: ReturnType<typeof useWorkflowTaskCounts>;
ctx?: WorkflowTaskFlowContext;
currentTaskType?: TaskTypeOptions;
currentTaskTypeKey?: string;
taskTypes: ReturnType<typeof getWorkflowTaskRegistry>;
}) {
const { availableTaskTypeKeys, countsState, ctx, currentTaskType, currentTaskTypeKey, taskTypes } = props;
const { counts, reload: reloadCounts } = countsState;
const { currentTaskType, currentTaskTypeKey, availableTaskTypeKeys } = useCurrentTaskType(taskTypes, counts);
const route = useWorkflowTasksRoute();
const { isMobileLayout } = useMobileLayout();
const mobile = route.isMobileRoute || isMobileLayout;
@@ -546,12 +466,15 @@ function WorkflowTasksPageContent() {
const { message } = App.useApp();
const t = useT();
const { token } = theme.useToken();
const { selectedWorkflow } = useWorkflowTaskFilterContext();
const [records, setRecords] = useState<WorkflowTaskRecord[]>([]);
const [currentRecord, setCurrentRecord] = useState<WorkflowTaskRecord | null>(() =>
getPendingWorkflowTaskPopupRecord(currentTaskTypeKey, route.popupId),
);
const [total, setTotal] = useState(0);
const listSignature = `${currentTaskTypeKey ?? ''}\n${route.status}\n${route.search}`;
const listSignature = `${currentTaskTypeKey ?? ''}\n${route.status}\n${selectedWorkflow?.workflowKey ?? ''}\n${
route.search
}`;
const [paginationState, setPaginationState] = useState({ signature: '', page: 1 });
const page = paginationState.signature === listSignature ? paginationState.page : 1;
const [loading, setLoading] = useState(false);
@@ -617,7 +540,7 @@ function WorkflowTasksPageContent() {
setLoading(true);
try {
const params = currentTaskType.useActionParams?.(route.status) ?? {};
const params = currentTaskType.useActionParams?.(route.status, selectedWorkflow?.workflowKey) ?? {};
const response = await list({
page,
pageSize: WORKFLOW_TASKS_PAGE_SIZE,
@@ -656,7 +579,15 @@ function WorkflowTasksPageContent() {
return () => {
listRequestSeqRef.current += 1;
};
}, [currentTaskTypeKey, loadRecords, page, route.search, route.status, showLoadFailed]);
}, [
currentTaskTypeKey,
loadRecords,
page,
route.search,
route.status,
selectedWorkflow?.workflowKey,
showLoadFailed,
]);
const loadPopupRecord = useMemoizedFn(async () => {
const requestSeq = ++popupRequestSeqRef.current;
@@ -779,6 +710,25 @@ function WorkflowTasksPageContent() {
setPaginationState({ signature: listSignature, page: nextPage });
});
const navigationTaskTypes = useMemo(
() =>
availableTaskTypeKeys.map((key) => ({
key,
title: taskTypes?.get(key)?.title ? t(taskTypes.get(key)?.title as string) : key,
count: counts[key]?.pending || 0,
})),
[availableTaskTypeKeys, counts, t, taskTypes],
);
const handleTaskTypeSelect = useMemoizedFn((nextTypeKey: string) => {
navigate(
withCurrentLocationSuffix(
getWorkflowTasksPath(nextTypeKey, TASK_STATUS.PENDING, undefined, route.isMobileRoute),
route,
),
);
});
if (!currentTaskType || !currentTaskTypeKey) {
return (
<Result
@@ -799,7 +749,14 @@ function WorkflowTasksPageContent() {
{t(currentTaskType.title)}
</Typography.Title>
)}
<TaskStatusControls type={currentTaskType} status={route.status} mobile={mobile} reload={refresh} />
<TaskStatusControls
type={currentTaskType}
status={route.status}
mobile={mobile}
reload={refresh}
workflowKey={selectedWorkflow?.workflowKey}
workflowPendingCount={selectedWorkflow?.stats.pending}
/>
</Flex>
);
const DetailModal = currentTaskType.DetailModal ?? WorkflowTaskDetailModal;
@@ -821,6 +778,15 @@ function WorkflowTasksPageContent() {
background: token.colorBgLayout,
}}
>
{renderMobileDetailPage ? null : (
<WorkflowTaskNavigation
currentTypeKey={currentTaskTypeKey}
mobile={mobile}
onTaskTypeSelect={handleTaskTypeSelect}
taskTypes={navigationTaskTypes}
t={t}
/>
)}
{mobile && !renderMobileDetailPage ? (
<Layout.Header
style={{
@@ -831,26 +797,9 @@ function WorkflowTasksPageContent() {
borderBottom: `${token.lineWidth}px ${token.lineType} ${token.colorBorderSecondary}`,
}}
>
<Flex vertical gap={0}>
<TaskTypeMenu
taskTypes={taskTypes}
counts={counts}
selectedKey={currentTaskTypeKey}
status={route.status}
mobile
/>
<div style={{ padding: token.paddingSM }}>{header}</div>
</Flex>
<div style={{ padding: token.paddingSM }}>{header}</div>
</Layout.Header>
) : mobile ? null : (
<TaskTypeMenu
taskTypes={taskTypes}
counts={counts}
selectedKey={currentTaskTypeKey}
status={route.status}
mobile={false}
/>
)}
) : null}
<Layout style={{ background: token.colorBgLayout }}>
<Layout.Content
style={{
@@ -917,5 +866,22 @@ function WorkflowTasksPageContent() {
}
export default function WorkflowTasksPage() {
return <WorkflowTasksPageContent />;
const ctx = useFlowContext() as WorkflowTaskFlowContext | undefined;
const taskTypes = getWorkflowTaskRegistry(ctx);
const countsState = useWorkflowTaskCounts(ctx, taskTypes);
const currentTaskTypeState = useCurrentTaskType(taskTypes, countsState.counts);
const loadWorkflowTaskStats = useMemoizedFn(async (params) => {
const listMine = ctx?.api.resource('userWorkflowTaskStats').listMine;
return listMine ? listMine(params) : { data: { data: [], meta: {} } };
});
return (
<WorkflowTaskFilterProvider
eventBus={ctx?.app?.eventBus}
loadWorkflowTaskStats={loadWorkflowTaskStats}
typeKey={currentTaskTypeState.currentTaskTypeKey}
>
<WorkflowTasksPageContent {...currentTaskTypeState} countsState={countsState} ctx={ctx} taskTypes={taskTypes} />
</WorkflowTaskFilterProvider>
);
}
@@ -129,10 +129,13 @@ function createMultiTaskTypes(taskTypeMap: Record<string, TaskTypeOptions>) {
}
function makeCtx(taskTypes: WorkflowTaskRegistry, resourceMap: Record<string, WorkflowTaskResource>) {
const workflowTaskStats = {
listMine: vi.fn().mockResolvedValue({ data: { data: [], meta: { hasNext: false } } }),
};
return {
api: {
resource: (name: string) => {
const resource = resourceMap[name];
const resource = resourceMap[name] ?? (name === 'userWorkflowTaskStats' ? workflowTaskStats : undefined);
if (!resource) {
throw new Error(`Missing resource mock: ${name}`);
}
@@ -179,7 +182,7 @@ describe('WorkflowTasksPage', () => {
await screen.findByText('Task A');
expect(taskType.useActionParams).toHaveBeenCalledWith('pending');
expect(taskType.useActionParams).toHaveBeenCalledWith('pending', undefined);
expect(demoTasks.listMine).toHaveBeenCalledWith({
page: 1,
pageSize: 20,
@@ -188,6 +191,99 @@ describe('WorkflowTasksPage', () => {
});
});
it('loads workflow groups and filters the task list by the selected workflow', async () => {
const useActionParams = vi.fn((status: string, workflowKey?: string) => ({
filter: workflowKey ? { $and: [{ status }, { 'workflow.key': workflowKey }] } : { status },
}));
const { registry } = createTaskTypes({ useActionParams });
const demoTasks = {
listMine: vi.fn().mockResolvedValue({
data: { data: [{ id: 1, title: 'Task A' }], meta: { count: 1 } },
}),
};
const userWorkflowTasks = {
listMine: vi.fn().mockResolvedValue({ data: [{ type: 'demo', stats: { pending: 1, all: 1 } }] }),
};
const userWorkflowTaskStats = {
listMine: vi.fn().mockResolvedValue({
data: {
data: [{ workflowKey: 'workflow-a', title: 'Workflow A', stats: { pending: 1, all: 1 } }],
meta: { hasNext: false },
},
}),
};
holder.ctx = makeCtx(registry, { demoTasks, userWorkflowTasks, userWorkflowTaskStats });
renderWithApp(<WorkflowTasksPage />);
fireEvent.click(await screen.findByText('Workflow A'));
await waitFor(() => {
expect(useActionParams).toHaveBeenLastCalledWith('pending', 'workflow-a');
expect(demoTasks.listMine).toHaveBeenLastCalledWith(
expect.objectContaining({
filter: { $and: [{ status: 'pending' }, { 'workflow.key': 'workflow-a' }] },
page: 1,
}),
);
});
expect(userWorkflowTaskStats.listMine).toHaveBeenCalledWith({
type: 'demo',
page: 1,
pageSize: 200,
});
});
it('searches and incrementally loads workflow groups', async () => {
const { registry } = createTaskTypes();
const demoTasks = {
listMine: vi.fn().mockResolvedValue({ data: { data: [], meta: { count: 0 } } }),
};
const userWorkflowTasks = {
listMine: vi.fn().mockResolvedValue({ data: [{ type: 'demo', stats: { pending: 1, all: 1 } }] }),
};
const userWorkflowTaskStats = {
listMine: vi.fn().mockImplementation((params: { page: number; search?: string }) =>
Promise.resolve({
data: {
data: [
{
workflowKey: params.search ? 'searched' : `workflow-${params.page}`,
title: params.search ? 'Searched workflow' : `Workflow ${params.page}`,
stats: { pending: 1, all: 1 },
},
],
meta: { hasNext: !params.search && params.page === 1 },
},
}),
),
};
holder.ctx = makeCtx(registry, { demoTasks, userWorkflowTasks, userWorkflowTaskStats });
renderWithApp(<WorkflowTasksPage />);
fireEvent.click(await screen.findByText('Load more'));
await waitFor(() =>
expect(userWorkflowTaskStats.listMine).toHaveBeenCalledWith({ type: 'demo', page: 2, pageSize: 200 }),
);
expect(await screen.findByText('Workflow 2')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Search workflows' }));
const searchInput = screen.getByRole('textbox', { name: 'Search workflows' });
fireEvent.change(searchInput, { target: { value: 'Searched' } });
fireEvent.submit(searchInput.closest('form') as HTMLFormElement);
await waitFor(() =>
expect(userWorkflowTaskStats.listMine).toHaveBeenCalledWith({
type: 'demo',
page: 1,
pageSize: 200,
search: 'Searched',
}),
);
expect(await screen.findByText('Searched workflow')).toBeInTheDocument();
});
it('loads popup records through getPopupRecord for direct detail routes', async () => {
holder.params = { taskType: 'demo', status: 'pending', popupId: '7' };
const getPopupRecord = vi.fn().mockResolvedValue({ data: { data: { id: 7, title: 'Popup task' } } });
@@ -332,7 +428,8 @@ describe('WorkflowTasksPage', () => {
renderWithApp(<WorkflowTasksPage />);
await screen.findByTestId('workflow-tasks-mobile');
fireEvent.click(screen.getByText('Other tasks'));
fireEvent.click(screen.getByRole('button', { name: 'Select workflow' }));
fireEvent.click(await screen.findByText('Other tasks'));
expect(holder.navigate).toHaveBeenCalledWith('/admin/workflow/tasks/other/pending');
expect(holder.navigate).not.toHaveBeenCalledWith('/mobile/page/workflow-tasks/other/pending');
@@ -952,7 +1049,7 @@ describe('WorkflowTasksPage', () => {
minHeight: '0',
});
expect(screen.queryByText('Workflow tasks')).not.toBeInTheDocument();
expect(screen.getByTestId('workflow-task-type-menu')).toHaveStyle({ height: '42px', minHeight: '42px' });
expect(screen.getByRole('button', { name: 'Select workflow' })).toBeInTheDocument();
expect(screen.getByTestId('workflow-task-list-region')).toHaveStyle({
background: '#f5f5f5',
display: 'flex',
@@ -35,6 +35,8 @@ export type WorkflowTaskCounts = Record<string, WorkflowTaskStats>;
export interface WorkflowTaskActionsProps {
onlyIcon?: boolean;
reload?: () => Promise<void>;
workflowKey?: string;
workflowPendingCount?: number;
}
export interface WorkflowTaskDetailModalProps {
@@ -99,7 +101,7 @@ export interface TaskTypeOptions {
title: string;
collection: string;
action?: string;
useActionParams?: (status: WorkflowTaskStatus) => WorkflowTaskRequestParams | undefined;
useActionParams?: (status: WorkflowTaskStatus, workflowKey?: string) => WorkflowTaskRequestParams | undefined;
Actions?: ComponentType<WorkflowTaskActionsProps>;
DetailModal?: ComponentType<WorkflowTaskDetailModalProps>;
Item: ComponentType;
@@ -6,29 +6,13 @@
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { CheckCircleOutlined, DownOutlined, RightOutlined, SearchOutlined } from '@ant-design/icons';
import { CheckCircleOutlined } from '@ant-design/icons';
import { PageHeader } from '@ant-design/pro-layout';
import { observer } from '@nocobase/flow-engine';
import {
App,
Badge,
Button,
Drawer,
Flex,
Input,
Layout,
Menu,
Result,
Segmented,
Spin,
Tabs,
theme,
Tooltip,
} from 'antd';
import type { InputRef, MenuProps } from 'antd';
import { App, Badge, Button, Flex, Layout, Result, Segmented, Tabs, theme, Tooltip } from 'antd';
import { NavBar, Toast } from 'antd-mobile';
import classnames from 'classnames';
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { Link, useLocation, useNavigate, useParams } from 'react-router-dom';
import {
@@ -63,8 +47,15 @@ import {
} from '@nocobase/plugin-mobile/client';
import PluginWorkflowClient from '.';
import {
WorkflowTaskFilterProvider as SharedWorkflowTaskFilterProvider,
WorkflowTaskNavigation,
useWorkflowTaskFilterContext,
} from '../shared/WorkflowTaskNavigation';
import { lang, NAMESPACE } from './locale';
export { useWorkflowTaskFilterContext } from '../shared/WorkflowTaskNavigation';
const layoutClass = css`
height: 100%;
overflow: hidden;
@@ -88,34 +79,6 @@ type TaskStats = { pending: number; all: number };
type Stats = Record<string, TaskStats>;
type WorkflowTaskStatsItem = {
workflowKey: string;
title: string;
stats: {
pending: number;
all: number;
};
};
type WorkflowTaskSelection = {
typeKey: string;
workflow: WorkflowTaskStatsItem;
};
type WorkflowTaskFilterContextValue = {
selectedWorkflow: WorkflowTaskStatsItem | null;
selectWorkflow: (workflow: WorkflowTaskStatsItem | null) => void;
workflows: WorkflowTaskStatsItem[];
loading: boolean;
loadingMore: boolean;
hasNext: boolean;
error: boolean;
search: string;
setSearch: (search: string) => void;
loadMore: () => void;
reload: () => void;
};
const TasksCountsContext = createContext<{ reload: () => void; counts: Stats; total: number }>({
reload() {},
counts: {},
@@ -126,226 +89,24 @@ export function useTasksCountsContext() {
return useContext(TasksCountsContext);
}
const WorkflowTaskFilterContext = createContext<WorkflowTaskFilterContextValue>({
selectedWorkflow: null,
selectWorkflow() {},
workflows: [],
loading: false,
loadingMore: false,
hasNext: false,
error: false,
search: '',
setSearch() {},
loadMore() {},
reload() {},
});
export function useWorkflowTaskFilterContext() {
return useContext(WorkflowTaskFilterContext);
}
const WORKFLOW_TASK_STATS_PAGE_SIZE = 200;
function WorkflowTaskFilterProvider({ children }: React.PropsWithChildren) {
const apiClient = useAPIClient();
const app = useApp();
const type = useCurrentTaskType();
const typeKey = type?.key;
const [workflowSelection, setWorkflowSelection] = useState<WorkflowTaskSelection | null>(null);
const [workflows, setWorkflows] = useState<WorkflowTaskStatsItem[]>([]);
const [page, setPage] = useState(1);
const [hasNext, setHasNext] = useState(false);
const [loading, setLoading] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState(false);
const [search, setSearch] = useState('');
const requestIdRef = useRef(0);
const previousTypeKeyRef = useRef(typeKey);
const selectedWorkflow = workflowSelection?.typeKey === typeKey ? workflowSelection.workflow : null;
const selectWorkflow = useCallback(
(workflow: WorkflowTaskStatsItem | null) => {
setWorkflowSelection(workflow && typeKey ? { typeKey, workflow } : null);
},
[typeKey],
const loadWorkflowTaskStats = useCallback(
(params) => apiClient.resource('userWorkflowTaskStats').listMine(params),
[apiClient],
);
const loadPage = useCallback(
async (nextPage: number, append: boolean) => {
if (!typeKey) {
setWorkflows([]);
setHasNext(false);
return;
}
const requestId = ++requestIdRef.current;
if (append) {
setLoadingMore(true);
} else {
setLoading(true);
setHasNext(false);
}
setError(false);
try {
const response = await apiClient.resource('userWorkflowTaskStats').listMine({
type: typeKey,
page: nextPage,
pageSize: WORKFLOW_TASK_STATS_PAGE_SIZE,
...(search ? { search } : {}),
});
if (requestId !== requestIdRef.current) {
return;
}
const rows = (response.data?.data ?? []) as WorkflowTaskStatsItem[];
setWorkflowSelection((selection) => {
if (!selection || selection.typeKey !== typeKey) {
return selection;
}
const workflow = rows.find((item) => item.workflowKey === selection.workflow.workflowKey);
return workflow ? { ...selection, workflow } : selection;
});
setWorkflows((previous) => {
if (!append) {
return rows;
}
const result = new Map(previous.map((item) => [item.workflowKey, item]));
rows.forEach((item) => result.set(item.workflowKey, item));
return Array.from(result.values());
});
setPage(nextPage);
setHasNext(Boolean(response.data?.meta?.hasNext));
} catch (err) {
if (requestId === requestIdRef.current) {
setError(true);
console.error(err);
}
} finally {
if (requestId === requestIdRef.current) {
setLoading(false);
setLoadingMore(false);
}
}
},
[apiClient, search, typeKey],
);
const reload = useCallback(() => {
loadPage(1, false);
}, [loadPage]);
const loadMore = useCallback(() => {
if (!hasNext || loading || loadingMore) {
return;
}
loadPage(page + 1, true);
}, [hasNext, loadPage, loading, loadingMore, page]);
useEffect(() => {
if (previousTypeKeyRef.current === typeKey) {
return;
}
previousTypeKeyRef.current = typeKey;
requestIdRef.current += 1;
setWorkflowSelection(null);
setWorkflows([]);
setPage(1);
setHasNext(false);
setSearch('');
}, [typeKey]);
useEffect(() => {
reload();
}, [reload]);
useEffect(() => {
let timer: ReturnType<typeof setTimeout> | undefined;
const onTaskUpdate = ({ detail }: CustomEvent) => {
if (detail?.type !== typeKey) {
return;
}
if (timer) {
clearTimeout(timer);
}
timer = setTimeout(reload, 300);
};
app.eventBus.addEventListener('ws:message:workflow:tasks:updated', onTaskUpdate);
return () => {
if (timer) {
clearTimeout(timer);
}
app.eventBus.removeEventListener('ws:message:workflow:tasks:updated', onTaskUpdate);
};
}, [app.eventBus, reload, typeKey]);
const value = useMemo<WorkflowTaskFilterContextValue>(
() => ({
selectedWorkflow,
selectWorkflow,
workflows,
loading,
loadingMore,
hasNext,
error,
search,
setSearch,
loadMore,
reload,
}),
[error, hasNext, loadMore, loading, loadingMore, reload, search, selectWorkflow, selectedWorkflow, workflows],
);
return <WorkflowTaskFilterContext.Provider value={value}>{children}</WorkflowTaskFilterContext.Provider>;
}
function MenuLink({ type }: any) {
const mobilePage = useMobilePage();
return (
<Link
replace
to={
mobilePage
? `/page/workflow-tasks/${type}/${TASK_STATUS.PENDING}`
: `/admin/workflow/tasks/${type}/${TASK_STATUS.PENDING}`
}
<SharedWorkflowTaskFilterProvider
eventBus={app.eventBus}
loadWorkflowTaskStats={loadWorkflowTaskStats}
typeKey={typeKey}
>
<TaskTypeLabel type={type} />
</Link>
);
}
function TaskTypeLabel({ type }: { type: string }) {
const workflowPlugin = usePlugin(PluginWorkflowClient);
const compile = useCompile();
const { counts } = useContext(TasksCountsContext);
const { token } = useToken();
const typeTitle = compile(workflowPlugin.taskTypes.get(type)?.title);
return (
<span
className={css`
display: flex;
gap: ${token.marginXS}px;
align-items: center;
justify-content: space-between;
width: 100%;
min-width: 0;
> span:first-child {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
> .ant-badge {
flex: none;
}
`}
>
<span>{typeTitle}</span>
<Badge count={counts[type]?.pending || 0} size="small" />
</span>
{children}
</SharedWorkflowTaskFilterProvider>
);
}
@@ -444,12 +205,7 @@ function useAvailableTaskTypeItems() {
() =>
types
.filter((key: string) => workflowPlugin.taskTypes.get(key)?.alwaysShow || Boolean(counts[key]?.all))
.map((key: string) => {
return {
key,
label: <MenuLink type={key} />,
};
}),
.map((key: string) => ({ key })),
[counts, types, workflowPlugin.taskTypes],
);
}
@@ -464,519 +220,6 @@ function useCurrentTaskType() {
);
}
function WorkflowMenuItemLabel({ title, count }: { title: React.ReactNode; count: number }) {
const { token } = useToken();
return (
<span
className={css`
display: flex;
gap: ${token.marginXS}px;
align-items: center;
justify-content: space-between;
width: 100%;
min-width: 0;
> span:first-child {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
`}
>
<span>{title}</span>
<span
className={css`
flex: none;
color: ${token.colorTextTertiary};
font-size: ${token.fontSizeSM}px;
font-variant-numeric: tabular-nums;
`}
>
{count}
</span>
</span>
);
}
function WorkflowAllMenuItemLabel({
search,
searchValue,
onSearchValueChange,
onSearch,
}: {
search: string;
searchValue: string;
onSearchValueChange: (value: string) => void;
onSearch: (value: string) => void;
}) {
const { token } = useToken();
const [searchExpanded, setSearchExpanded] = useState(Boolean(search));
const [searchFocused, setSearchFocused] = useState(false);
const searchInputRef = useRef<InputRef>(null);
const focusAfterExpandRef = useRef(false);
const isComposingRef = useRef(false);
const suppressSubmitRef = useRef(false);
useEffect(() => {
if (search) {
setSearchExpanded(true);
}
}, [search]);
useEffect(() => {
if (searchExpanded && focusAfterExpandRef.current) {
focusAfterExpandRef.current = false;
searchInputRef.current?.focus();
}
}, [searchExpanded]);
const expandSearchWithFocus = useCallback(() => {
focusAfterExpandRef.current = true;
setSearchExpanded(true);
}, []);
const collapseSearch = useCallback(() => {
if (!searchFocused && !searchValue) {
setSearchExpanded(false);
}
}, [searchFocused, searchValue]);
return (
<div
onMouseLeave={collapseSearch}
className={css`
display: flex;
align-items: center;
width: 100%;
height: ${token.controlHeight}px;
`}
>
{searchExpanded ? (
<form
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
if (isComposingRef.current || suppressSubmitRef.current) {
suppressSubmitRef.current = false;
return;
}
onSearch(searchValue.trim());
}}
className={css`
width: 100%;
`}
>
<Input
ref={searchInputRef}
allowClear
size="small"
value={searchValue}
placeholder={lang('Search workflows')}
aria-label={lang('Search workflows')}
onCompositionStart={() => {
isComposingRef.current = true;
}}
onCompositionEnd={() => {
isComposingRef.current = false;
}}
onFocus={() => setSearchFocused(true)}
onBlur={() => {
setSearchFocused(false);
if (!searchValue) {
setSearchExpanded(false);
}
}}
onChange={(event) => onSearchValueChange(event.target.value)}
onKeyDown={(event) => {
event.stopPropagation();
if (event.key === 'Enter') {
suppressSubmitRef.current = event.nativeEvent.isComposing || isComposingRef.current;
}
if (event.key === 'Escape') {
onSearchValueChange('');
onSearch('');
setSearchExpanded(false);
}
}}
/>
</form>
) : (
<>
<span
className={css`
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`}
>
{lang('All workflows')}
</span>
<button
type="button"
aria-label={lang('Search workflows')}
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
expandSearchWithFocus();
}}
onMouseEnter={() => setSearchExpanded(true)}
onFocus={expandSearchWithFocus}
className={css`
display: inline-flex;
flex: none;
align-items: center;
justify-content: flex-end;
width: ${token.controlHeightSM}px;
height: ${token.controlHeightSM}px;
padding: 0;
color: ${token.colorTextSecondary};
cursor: pointer;
background: transparent;
border: 0;
border-radius: ${token.borderRadiusSM}px;
&:hover,
&:focus-visible {
color: ${token.colorPrimary};
}
&:focus-visible {
outline: 2px solid ${token.colorPrimaryBorder};
outline-offset: -2px;
}
`}
>
<SearchOutlined />
</button>
</>
)}
</div>
);
}
function TaskNavigationContent({ onWorkflowSelect }: { onWorkflowSelect?: () => void }) {
const { taskType, status = TASK_STATUS.PENDING } = useParams();
const { token } = useToken();
const items = useAvailableTaskTypeItems();
const typeKey = taskType ?? items[0]?.key;
const navigate = useNavigate();
const mobilePage = useMobilePage();
const {
selectedWorkflow,
selectWorkflow,
workflows,
loading,
loadingMore,
hasNext,
error,
search,
setSearch,
loadMore,
reload,
} = useWorkflowTaskFilterContext();
const [searchValue, setSearchValue] = useState(search);
const typeMenuKey = typeKey ? `type:${typeKey}` : undefined;
useEffect(() => {
setSearchValue(search);
}, [search]);
const handleWorkflowSelect = useCallback(
(workflow: WorkflowTaskStatsItem | null) => {
selectWorkflow(workflow);
onWorkflowSelect?.();
},
[onWorkflowSelect, selectWorkflow],
);
const handleMenuClick = useCallback<NonNullable<MenuProps['onClick']>>(
({ key }) => {
if (key === 'workflow:all') {
handleWorkflowSelect(null);
return;
}
if (key === 'action:retry') {
reload();
return;
}
if (key === 'action:loadMore') {
loadMore();
return;
}
if (!key.startsWith('workflow:')) {
return;
}
const workflowKey = key.slice('workflow:'.length);
const workflow = workflows.find((item) => item.workflowKey === workflowKey);
if (workflow) {
handleWorkflowSelect(workflow);
}
},
[handleWorkflowSelect, loadMore, reload, workflows],
);
const handleOpenChange = useCallback<NonNullable<MenuProps['onOpenChange']>>(
(openKeys) => {
const nextTypeMenuKey = [...openKeys].reverse().find((key) => key.startsWith('type:') && key !== typeMenuKey);
if (!nextTypeMenuKey) {
selectWorkflow(null);
return;
}
const nextType = nextTypeMenuKey.slice('type:'.length);
if (nextType === typeKey) {
return;
}
navigate(
mobilePage
? `/page/workflow-tasks/${nextType}/${TASK_STATUS.PENDING}`
: `/admin/workflow/tasks/${nextType}/${TASK_STATUS.PENDING}`,
);
},
[mobilePage, navigate, selectWorkflow, typeKey, typeMenuKey],
);
useEffect(() => {
if (!items.length || taskType) {
return;
}
navigate(mobilePage ? `/page/workflow-tasks/${typeKey}/${status}` : `/admin/workflow/tasks/${typeKey}/${status}`, {
replace: true,
});
}, [items.length, mobilePage, navigate, status, taskType, typeKey]);
const workflowMenuItems: NonNullable<MenuProps['items']> = [
{
key: 'workflow:all',
label: (
<WorkflowAllMenuItemLabel
search={search}
searchValue={searchValue}
onSearchValueChange={(value) => {
setSearchValue(value);
if (!value && search) {
setSearch('');
}
}}
onSearch={setSearch}
/>
),
},
...workflows.map((workflow) => ({
key: `workflow:${workflow.workflowKey}`,
label: <WorkflowMenuItemLabel title={workflow.title} count={workflow.stats.pending} />,
})),
];
if (loading) {
workflowMenuItems.push({
key: 'status:loading',
disabled: true,
label: (
<Flex justify="center">
<Spin size="small" />
</Flex>
),
});
}
if (error) {
workflowMenuItems.push({ key: 'action:retry', label: <Flex justify="center">{lang('Retry')}</Flex> });
}
if (hasNext && !error) {
workflowMenuItems.push({
key: 'action:loadMore',
disabled: loadingMore,
label: <Flex justify="center">{loadingMore ? <Spin size="small" /> : lang('Load more')}</Flex>,
});
}
return (
<Menu
mode="inline"
inlineIndent={token.padding}
expandIcon={null}
openKeys={typeMenuKey ? [typeMenuKey] : []}
selectedKeys={[selectedWorkflow ? `workflow:${selectedWorkflow.workflowKey}` : 'workflow:all']}
onClick={handleMenuClick}
onOpenChange={handleOpenChange}
items={items.map(({ key }) => ({
key: `type:${key}`,
label: <TaskTypeLabel type={key} />,
children:
key === typeKey
? workflowMenuItems
: [
{
key: `placeholder:${key}`,
disabled: true,
className: 'workflow-task-menu-placeholder',
label: null,
},
],
}))}
className={css`
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
overflow: hidden;
background: ${token.colorBgContainer};
border-inline-end: 0 !important;
> .ant-menu-submenu {
flex: none;
min-height: 0;
}
> .ant-menu-submenu-open {
display: flex;
flex: 0 1 auto;
flex-direction: column;
min-height: ${token.controlHeightLG + token.marginXXS * 2}px;
overflow: hidden;
}
> .ant-menu-submenu-open > .ant-menu-submenu-title {
flex: none;
}
> .ant-menu-submenu-open > .ant-menu-sub.ant-menu-inline {
flex: 1;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
}
.ant-menu-sub.ant-menu-inline {
width: calc(100% - ${token.marginXXS * 2}px);
margin-inline: ${token.marginXXS}px;
padding-block: ${token.paddingXXS}px;
background: ${token.colorFillTertiary} !important;
border-radius: ${token.borderRadius}px;
}
.ant-menu-submenu-title {
padding-inline-end: ${token.padding}px;
}
.ant-menu-sub.ant-menu-inline > .ant-menu-item {
height: ${token.controlHeight}px;
margin-block: 0;
line-height: ${token.controlHeight}px;
}
.ant-menu-title-content {
min-width: 0;
}
.workflow-task-menu-placeholder {
display: none !important;
}
`}
/>
);
}
function MobileTaskNavigation() {
const [open, setOpen] = useState(false);
const compile = useCompile();
const type = useCurrentTaskType();
const { selectedWorkflow } = useWorkflowTaskFilterContext();
const { token } = useToken();
return (
<>
<div
style={{
background: token.colorBgContainer,
padding: `0 ${token.paddingPageHorizontal}px ${token.paddingXXS}px`,
}}
>
<Button
block
type="text"
aria-expanded={open}
aria-label={lang('Select workflow')}
onClick={() => setOpen(true)}
className={css`
height: ${token.controlHeight}px;
padding-inline: ${token.paddingSM}px;
background: ${token.colorFillAlter};
border: 0;
&:hover,
&:focus-visible {
background: ${token.colorFillSecondary} !important;
}
`}
>
<Flex
align="center"
justify="space-between"
gap={token.marginXS}
className={css`
width: 100%;
min-width: 0;
`}
>
<Flex
align="center"
gap={token.marginXXS}
className={css`
min-width: 0;
`}
>
<span>{compile(type.title)}</span>
{selectedWorkflow ? (
<>
<RightOutlined
className={css`
flex: none;
color: ${token.colorTextTertiary};
font-size: ${token.fontSizeSM}px;
`}
/>
<span
className={css`
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`}
>
{selectedWorkflow.title}
</span>
</>
) : null}
</Flex>
<DownOutlined
className={css`
flex: none;
margin-inline-start: auto;
color: ${token.colorTextSecondary};
`}
/>
</Flex>
</Button>
</div>
<Drawer
title={lang('Workflow tasks')}
placement="left"
width="min(360px, 88vw)"
open={open}
onClose={() => setOpen(false)}
styles={{ body: { padding: 0 } }}
>
<TaskNavigationContent onWorkflowSelect={() => setOpen(false)} />
</Drawer>
</>
);
}
function PopupContext(props: any) {
const { taskType, status = TASK_STATUS.PENDING, popupId } = useParams();
const { record } = usePopupRecordContext();
@@ -1214,35 +457,58 @@ function TaskPageContent() {
);
}
function TaskMenu() {
const { token } = useToken();
function TaskNavigation({ forceMobile }: { forceMobile?: boolean }) {
const workflowPlugin = usePlugin(PluginWorkflowClient);
const compile = useCompile();
const { counts } = useContext(TasksCountsContext);
const { taskType, status = TASK_STATUS.PENDING } = useParams();
const items = useAvailableTaskTypeItems();
const currentTypeKey = taskType ?? items[0]?.key;
const navigate = useNavigate();
const mobilePage = useMobilePage();
const { isMobileLayout } = useMobileLayout();
const mobile = forceMobile ?? Boolean(mobilePage || isMobileLayout);
const taskTypes = useMemo(
() =>
items.map(({ key }) => ({
key,
title: compile(workflowPlugin.taskTypes.get(key)?.title),
count: counts[key]?.pending || 0,
})),
[compile, counts, items, workflowPlugin.taskTypes],
);
return isMobileLayout ? (
<Layout.Header
style={{
background: token.colorBgContainer,
height: 'auto',
padding: `${token.paddingXXS}px 0 0`,
lineHeight: 'normal',
}}
>
<MobileTaskNavigation />
</Layout.Header>
) : (
<Layout.Sider
theme="light"
width={220}
breakpoint="md"
collapsedWidth="0"
zeroWidthTriggerStyle={{ top: 24 }}
style={{
background: token.colorBgContainer,
borderInlineEnd: `1px solid ${token.colorBorderSecondary}`,
}}
>
<TaskNavigationContent />
</Layout.Sider>
const handleTaskTypeSelect = useCallback(
(nextTypeKey: string) => {
navigate(
mobilePage || forceMobile
? `/page/workflow-tasks/${nextTypeKey}/${TASK_STATUS.PENDING}`
: `/admin/workflow/tasks/${nextTypeKey}/${TASK_STATUS.PENDING}`,
);
},
[forceMobile, mobilePage, navigate],
);
useEffect(() => {
if (!items.length || taskType || !currentTypeKey) {
return;
}
navigate(
mobilePage || forceMobile
? `/page/workflow-tasks/${currentTypeKey}/${status}`
: `/admin/workflow/tasks/${currentTypeKey}/${status}`,
{ replace: true },
);
}, [currentTypeKey, forceMobile, items.length, mobilePage, navigate, status, taskType]);
return (
<WorkflowTaskNavigation
currentTypeKey={currentTypeKey}
mobile={mobile}
onTaskTypeSelect={handleTaskTypeSelect}
taskTypes={taskTypes}
t={lang}
/>
);
}
@@ -1262,7 +528,7 @@ export function WorkflowTasks() {
<TasksCountsProvider>
<WorkflowTaskFilterProvider>
<Layout className={layoutClass}>
<TaskMenu />
<TaskNavigation />
<Layout
className={css`
> div {
@@ -1504,7 +770,7 @@ export function WorkflowTasksMobile() {
<NavBar className="nb-workflow-tasks-back-action" onBack={() => navigate(-1)}>
{lang('Workflow tasks')}
</NavBar>
<MobileTaskNavigation />
<TaskNavigation forceMobile />
</MobilePageHeader>
<MobilePageContentContainer
className={css`
@@ -0,0 +1,803 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { DownOutlined, RightOutlined, SearchOutlined } from '@ant-design/icons';
import { css } from '@emotion/css';
import { Badge, Button, Drawer, Flex, Input, Layout, Menu, Spin, theme } from 'antd';
import type { InputRef, MenuProps } from 'antd';
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
export interface WorkflowTaskStatsItem {
workflowKey: string;
title: string;
stats: {
pending: number;
all: number;
};
}
export interface WorkflowTaskNavigationType {
count: number;
key: string;
title: React.ReactNode;
}
interface WorkflowTaskSelection {
typeKey: string;
workflow: WorkflowTaskStatsItem;
}
interface WorkflowTaskStatsListParams {
page: number;
pageSize: number;
search?: string;
type: string;
}
export type LoadWorkflowTaskStats = (params: WorkflowTaskStatsListParams) => Promise<unknown>;
export interface WorkflowTaskFilterContextValue {
selectedWorkflow: WorkflowTaskStatsItem | null;
selectWorkflow: (workflow: WorkflowTaskStatsItem | null) => void;
workflows: WorkflowTaskStatsItem[];
loading: boolean;
loadingMore: boolean;
hasNext: boolean;
error: boolean;
search: string;
setSearch: (search: string) => void;
loadMore: () => void;
reload: () => void;
}
const WorkflowTaskFilterContext = createContext<WorkflowTaskFilterContextValue>({
selectedWorkflow: null,
selectWorkflow() {},
workflows: [],
loading: false,
loadingMore: false,
hasNext: false,
error: false,
search: '',
setSearch() {},
loadMore() {},
reload() {},
});
export function useWorkflowTaskFilterContext() {
return useContext(WorkflowTaskFilterContext);
}
const WORKFLOW_TASK_STATS_PAGE_SIZE = 200;
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function normalizeWorkflowTaskStatsResponse(response: unknown) {
const responseData = isRecord(response) ? response.data : undefined;
const payload = isRecord(responseData) ? responseData : undefined;
const rows = Array.isArray(responseData) ? responseData : payload?.data;
const meta = isRecord(payload?.meta) ? payload.meta : undefined;
return {
rows: Array.isArray(rows)
? rows.filter(
(item): item is WorkflowTaskStatsItem =>
isRecord(item) &&
typeof item.workflowKey === 'string' &&
typeof item.title === 'string' &&
isRecord(item.stats),
)
: [],
hasNext: meta?.hasNext === true,
};
}
export function WorkflowTaskFilterProvider(
props: React.PropsWithChildren<{
eventBus?: EventTarget;
loadWorkflowTaskStats: LoadWorkflowTaskStats;
typeKey?: string;
}>,
) {
const { children, eventBus, loadWorkflowTaskStats, typeKey } = props;
const [workflowSelection, setWorkflowSelection] = useState<WorkflowTaskSelection | null>(null);
const [workflows, setWorkflows] = useState<WorkflowTaskStatsItem[]>([]);
const [page, setPage] = useState(1);
const [hasNext, setHasNext] = useState(false);
const [loading, setLoading] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState(false);
const [search, setSearch] = useState('');
const requestIdRef = useRef(0);
const previousTypeKeyRef = useRef(typeKey);
const selectedWorkflow = workflowSelection?.typeKey === typeKey ? workflowSelection.workflow : null;
const selectWorkflow = useCallback(
(workflow: WorkflowTaskStatsItem | null) => {
setWorkflowSelection(workflow && typeKey ? { typeKey, workflow } : null);
},
[typeKey],
);
const loadPage = useCallback(
async (nextPage: number, append: boolean) => {
if (!typeKey) {
setWorkflows([]);
setHasNext(false);
return;
}
const requestId = ++requestIdRef.current;
if (append) {
setLoadingMore(true);
} else {
setLoading(true);
setHasNext(false);
}
setError(false);
try {
const response = await loadWorkflowTaskStats({
type: typeKey,
page: nextPage,
pageSize: WORKFLOW_TASK_STATS_PAGE_SIZE,
...(search ? { search } : {}),
});
if (requestId !== requestIdRef.current) {
return;
}
const result = normalizeWorkflowTaskStatsResponse(response);
setWorkflowSelection((selection) => {
if (!selection || selection.typeKey !== typeKey) {
return selection;
}
const workflow = result.rows.find((item) => item.workflowKey === selection.workflow.workflowKey);
return workflow ? { ...selection, workflow } : selection;
});
setWorkflows((previous) => {
if (!append) {
return result.rows;
}
const merged = new Map(previous.map((item) => [item.workflowKey, item]));
result.rows.forEach((item) => merged.set(item.workflowKey, item));
return Array.from(merged.values());
});
setPage(nextPage);
setHasNext(result.hasNext);
} catch (loadError) {
if (requestId === requestIdRef.current) {
setError(true);
console.error('Failed to load workflow task stats', loadError);
}
} finally {
if (requestId === requestIdRef.current) {
setLoading(false);
setLoadingMore(false);
}
}
},
[loadWorkflowTaskStats, search, typeKey],
);
const reload = useCallback(() => {
loadPage(1, false);
}, [loadPage]);
const loadMore = useCallback(() => {
if (!hasNext || loading || loadingMore) {
return;
}
loadPage(page + 1, true);
}, [hasNext, loadPage, loading, loadingMore, page]);
useEffect(() => {
if (previousTypeKeyRef.current === typeKey) {
return;
}
previousTypeKeyRef.current = typeKey;
requestIdRef.current += 1;
setWorkflowSelection(null);
setWorkflows([]);
setPage(1);
setHasNext(false);
setSearch('');
}, [typeKey]);
useEffect(() => {
reload();
}, [reload]);
useEffect(() => {
if (!eventBus) {
return;
}
let timer: ReturnType<typeof setTimeout> | undefined;
const onTaskUpdate: EventListener = (event) => {
if (!('detail' in event) || !isRecord((event as CustomEvent<unknown>).detail)) {
return;
}
const detail = (event as CustomEvent<Record<string, unknown>>).detail;
if (detail.type !== typeKey) {
return;
}
if (timer) {
clearTimeout(timer);
}
timer = setTimeout(reload, 300);
};
eventBus.addEventListener('ws:message:workflow:tasks:updated', onTaskUpdate);
return () => {
if (timer) {
clearTimeout(timer);
}
eventBus.removeEventListener('ws:message:workflow:tasks:updated', onTaskUpdate);
};
}, [eventBus, reload, typeKey]);
const value = useMemo<WorkflowTaskFilterContextValue>(
() => ({
selectedWorkflow,
selectWorkflow,
workflows,
loading,
loadingMore,
hasNext,
error,
search,
setSearch,
loadMore,
reload,
}),
[error, hasNext, loadMore, loading, loadingMore, reload, search, selectWorkflow, selectedWorkflow, workflows],
);
return <WorkflowTaskFilterContext.Provider value={value}>{children}</WorkflowTaskFilterContext.Provider>;
}
function NavigationItemLabel({ count, title }: Pick<WorkflowTaskNavigationType, 'count' | 'title'>) {
const { token } = theme.useToken();
return (
<span
className={css`
display: flex;
gap: ${token.marginXS}px;
align-items: center;
justify-content: space-between;
width: 100%;
min-width: 0;
> span:first-child {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
> .ant-badge {
flex: none;
}
`}
>
<span>{title}</span>
<Badge count={count} size="small" />
</span>
);
}
function WorkflowItemLabel({ title, count }: { title: React.ReactNode; count: number }) {
const { token } = theme.useToken();
return (
<span
className={css`
display: flex;
gap: ${token.marginXS}px;
align-items: center;
justify-content: space-between;
width: 100%;
min-width: 0;
> span:first-child {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
`}
>
<span>{title}</span>
<span
className={css`
flex: none;
color: ${token.colorTextTertiary};
font-size: ${token.fontSizeSM}px;
font-variant-numeric: tabular-nums;
`}
>
{count}
</span>
</span>
);
}
function AllWorkflowsLabel(props: {
search: string;
searchValue: string;
setSearchValue: (value: string) => void;
setSearch: (value: string) => void;
t: (key: string) => string;
}) {
const { search, searchValue, setSearchValue, setSearch, t } = props;
const { token } = theme.useToken();
const [searchExpanded, setSearchExpanded] = useState(Boolean(search));
const [searchFocused, setSearchFocused] = useState(false);
const searchInputRef = useRef<InputRef>(null);
const focusAfterExpandRef = useRef(false);
const isComposingRef = useRef(false);
const suppressSubmitRef = useRef(false);
useEffect(() => {
if (search) {
setSearchExpanded(true);
}
}, [search]);
useEffect(() => {
if (searchExpanded && focusAfterExpandRef.current) {
focusAfterExpandRef.current = false;
searchInputRef.current?.focus();
}
}, [searchExpanded]);
const expandSearchWithFocus = useCallback(() => {
focusAfterExpandRef.current = true;
setSearchExpanded(true);
}, []);
const collapseSearch = useCallback(() => {
if (!searchFocused && !searchValue) {
setSearchExpanded(false);
}
}, [searchFocused, searchValue]);
return (
<div
onMouseLeave={collapseSearch}
className={css`
display: flex;
align-items: center;
width: 100%;
height: ${token.controlHeight}px;
`}
>
{searchExpanded ? (
<form
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
if (isComposingRef.current || suppressSubmitRef.current) {
suppressSubmitRef.current = false;
return;
}
setSearch(searchValue.trim());
}}
className={css`
width: 100%;
`}
>
<Input
ref={searchInputRef}
allowClear
size="small"
value={searchValue}
placeholder={t('Search workflows')}
aria-label={t('Search workflows')}
onCompositionStart={() => {
isComposingRef.current = true;
}}
onCompositionEnd={() => {
isComposingRef.current = false;
}}
onFocus={() => setSearchFocused(true)}
onBlur={() => {
setSearchFocused(false);
if (!searchValue) {
setSearchExpanded(false);
}
}}
onChange={(event) => {
const value = event.target.value;
setSearchValue(value);
if (!value && search) {
setSearch('');
}
}}
onKeyDown={(event) => {
event.stopPropagation();
if (event.key === 'Enter') {
suppressSubmitRef.current = event.nativeEvent.isComposing || isComposingRef.current;
}
if (event.key === 'Escape') {
setSearchValue('');
setSearch('');
setSearchExpanded(false);
}
}}
/>
</form>
) : (
<>
<span
className={css`
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`}
>
{t('All workflows')}
</span>
<button
type="button"
aria-label={t('Search workflows')}
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
expandSearchWithFocus();
}}
onMouseEnter={() => setSearchExpanded(true)}
onFocus={expandSearchWithFocus}
className={css`
display: inline-flex;
flex: none;
align-items: center;
justify-content: flex-end;
width: ${token.controlHeightSM}px;
height: ${token.controlHeightSM}px;
padding: 0;
color: ${token.colorTextSecondary};
cursor: pointer;
background: transparent;
border: 0;
border-radius: ${token.borderRadiusSM}px;
&:hover,
&:focus-visible {
color: ${token.colorPrimary};
}
&:focus-visible {
outline: ${token.lineWidthFocus}px solid ${token.colorPrimaryBorder};
outline-offset: -${token.lineWidthFocus}px;
}
`}
>
<SearchOutlined />
</button>
</>
)}
</div>
);
}
function WorkflowTaskNavigationMenu(props: {
currentTypeKey?: string;
onTaskTypeSelect: (typeKey: string) => void;
onWorkflowSelect?: () => void;
taskTypes: WorkflowTaskNavigationType[];
t: (key: string) => string;
}) {
const { currentTypeKey, onTaskTypeSelect, onWorkflowSelect, taskTypes, t } = props;
const { token } = theme.useToken();
const {
selectedWorkflow,
selectWorkflow,
workflows,
loading,
loadingMore,
hasNext,
error,
search,
setSearch,
loadMore,
reload,
} = useWorkflowTaskFilterContext();
const [searchValue, setSearchValue] = useState(search);
const typeMenuKey = currentTypeKey ? `type:${currentTypeKey}` : undefined;
useEffect(() => {
setSearchValue(search);
}, [search]);
const handleWorkflowSelect = useCallback(
(workflow: WorkflowTaskStatsItem | null) => {
selectWorkflow(workflow);
onWorkflowSelect?.();
},
[onWorkflowSelect, selectWorkflow],
);
const handleMenuClick = useCallback<NonNullable<MenuProps['onClick']>>(
({ key }) => {
if (key === 'workflow:all') {
handleWorkflowSelect(null);
return;
}
if (key === 'action:retry') {
reload();
return;
}
if (key === 'action:loadMore') {
loadMore();
return;
}
if (!key.startsWith('workflow:')) {
return;
}
const workflowKey = key.slice('workflow:'.length);
const workflow = workflows.find((item) => item.workflowKey === workflowKey);
if (workflow) {
handleWorkflowSelect(workflow);
}
},
[handleWorkflowSelect, loadMore, reload, workflows],
);
const handleOpenChange = useCallback<NonNullable<MenuProps['onOpenChange']>>(
(openKeys) => {
const nextTypeMenuKey = [...openKeys].reverse().find((key) => key.startsWith('type:') && key !== typeMenuKey);
if (!nextTypeMenuKey) {
selectWorkflow(null);
return;
}
const nextType = nextTypeMenuKey.slice('type:'.length);
if (nextType !== currentTypeKey) {
onTaskTypeSelect(nextType);
}
},
[currentTypeKey, onTaskTypeSelect, selectWorkflow, typeMenuKey],
);
const workflowMenuItems: NonNullable<MenuProps['items']> = [
{
key: 'workflow:all',
label: (
<AllWorkflowsLabel
search={search}
searchValue={searchValue}
setSearchValue={setSearchValue}
setSearch={setSearch}
t={t}
/>
),
},
...workflows.map((workflow) => ({
key: `workflow:${workflow.workflowKey}`,
label: <WorkflowItemLabel title={workflow.title} count={workflow.stats.pending} />,
})),
];
if (loading) {
workflowMenuItems.push({
key: 'status:loading',
disabled: true,
label: (
<Flex justify="center">
<Spin size="small" />
</Flex>
),
});
}
if (error) {
workflowMenuItems.push({ key: 'action:retry', label: <Flex justify="center">{t('Retry')}</Flex> });
}
if (hasNext && !error) {
workflowMenuItems.push({
key: 'action:loadMore',
disabled: loadingMore,
label: <Flex justify="center">{loadingMore ? <Spin size="small" /> : t('Load more')}</Flex>,
});
}
return (
<Menu
data-testid="workflow-task-navigation-menu"
mode="inline"
inlineIndent={token.padding}
expandIcon={null}
openKeys={typeMenuKey ? [typeMenuKey] : []}
selectedKeys={[selectedWorkflow ? `workflow:${selectedWorkflow.workflowKey}` : 'workflow:all']}
onClick={handleMenuClick}
onOpenChange={handleOpenChange}
items={taskTypes.map((type) => ({
key: `type:${type.key}`,
label: <NavigationItemLabel title={type.title} count={type.count} />,
children:
type.key === currentTypeKey
? workflowMenuItems
: [
{
key: `placeholder:${type.key}`,
disabled: true,
className: 'workflow-task-menu-placeholder',
label: null,
},
],
}))}
className={css`
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
overflow: hidden;
background: ${token.colorBgContainer};
border-inline-end: 0 !important;
> .ant-menu-submenu {
flex: none;
min-height: 0;
}
> .ant-menu-submenu-open {
display: flex;
flex: 0 1 auto;
flex-direction: column;
min-height: ${token.controlHeightLG + token.marginXXS * 2}px;
overflow: hidden;
}
> .ant-menu-submenu-open > .ant-menu-submenu-title {
flex: none;
}
> .ant-menu-submenu-open > .ant-menu-sub.ant-menu-inline {
flex: 1;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
}
.ant-menu-sub.ant-menu-inline {
width: calc(100% - ${token.marginXXS * 2}px);
margin-inline: ${token.marginXXS}px;
padding-block: ${token.paddingXXS}px;
background: ${token.colorFillTertiary} !important;
border-radius: ${token.borderRadius}px;
}
.ant-menu-submenu-title {
padding-inline-end: ${token.padding}px;
}
.ant-menu-sub.ant-menu-inline > .ant-menu-item {
height: ${token.controlHeight}px;
margin-block: 0;
line-height: ${token.controlHeight}px;
}
.ant-menu-title-content {
min-width: 0;
}
.workflow-task-menu-placeholder {
display: none !important;
}
`}
/>
);
}
export function WorkflowTaskNavigation(props: {
currentTypeKey?: string;
mobile: boolean;
onTaskTypeSelect: (typeKey: string) => void;
taskTypes: WorkflowTaskNavigationType[];
t: (key: string) => string;
}) {
const { currentTypeKey, mobile, onTaskTypeSelect, taskTypes, t } = props;
const { selectedWorkflow } = useWorkflowTaskFilterContext();
const { token } = theme.useToken();
const [open, setOpen] = useState(false);
const currentType = taskTypes.find((type) => type.key === currentTypeKey);
if (mobile) {
return (
<Layout.Header
style={{
background: token.colorBgContainer,
height: 'auto',
lineHeight: 'normal',
padding: `${token.paddingXXS}px ${token.padding}px`,
}}
>
<Button
block
type="text"
aria-expanded={open}
aria-label={t('Select workflow')}
onClick={() => setOpen(true)}
className={css`
height: ${token.controlHeight}px;
padding-inline: ${token.paddingSM}px;
background: ${token.colorFillAlter};
border: 0;
&:hover,
&:focus-visible {
background: ${token.colorFillSecondary} !important;
}
`}
>
<Flex align="center" justify="space-between" gap={token.marginXS} style={{ minWidth: 0, width: '100%' }}>
<Flex align="center" gap={token.marginXXS} style={{ minWidth: 0 }}>
<span>{currentType?.title}</span>
{selectedWorkflow ? (
<>
<RightOutlined aria-hidden style={{ color: token.colorTextTertiary, flex: 'none' }} />
<span
style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{selectedWorkflow.title}
</span>
</>
) : null}
</Flex>
<DownOutlined
aria-hidden
style={{ color: token.colorTextSecondary, flex: 'none', marginInlineStart: 'auto' }}
/>
</Flex>
</Button>
<Drawer
title={t('Workflow tasks')}
placement="left"
open={open}
onClose={() => setOpen(false)}
styles={{ body: { padding: 0 } }}
>
<WorkflowTaskNavigationMenu
currentTypeKey={currentTypeKey}
onTaskTypeSelect={onTaskTypeSelect}
onWorkflowSelect={() => setOpen(false)}
taskTypes={taskTypes}
t={t}
/>
</Drawer>
</Layout.Header>
);
}
return (
<Layout.Sider
theme="light"
breakpoint="md"
collapsedWidth={0}
style={{
background: token.colorBgContainer,
borderInlineEnd: `${token.lineWidth}px ${token.lineType} ${token.colorBorderSecondary}`,
}}
>
<WorkflowTaskNavigationMenu
currentTypeKey={currentTypeKey}
onTaskTypeSelect={onTaskTypeSelect}
taskTypes={taskTypes}
t={t}
/>
</Layout.Sider>
);
}