mirror of
https://github.com/nocobase/nocobase.git
synced 2026-09-01 14:57:36 +08:00
Merge branch 'next' into develop
This commit is contained in:
+5
@@ -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 = {
|
||||
|
||||
+28
-2
@@ -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,
|
||||
|
||||
+13
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+98
-132
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+101
-4
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user