497 lines
15 KiB
TypeScript

/**
* 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 React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { Link, useNavigate } from 'react-router-dom';
import { Alert, App, Breadcrumb, Button, Dropdown, Result, Spin, Switch, Tag, Tooltip } from 'antd';
import { DownOutlined, EllipsisOutlined, RightOutlined } from '@ant-design/icons';
import { NoticeType } from 'antd/es/message/interface';
import { useField, useForm } from '@formily/react';
import {
ActionContextProvider,
ResourceActionProvider,
SchemaComponent,
cx,
useActionContext,
useApp,
useCancelAction,
useDocumentTitle,
useNavigateNoUpdate,
useResourceActionContext,
useResourceContext,
useCompile,
css,
usePlugin,
} from '@nocobase/client';
import { dayjs } from '@nocobase/utils/client';
import PluginWorkflowClient from '.';
import { CanvasContent } from './CanvasContent';
import { ExecutionStatusColumn } from './components/ExecutionStatus';
import { ExecutionLink } from './ExecutionLink';
import { CurrentWorkflowContext, FlowContext, useFlowContext } from './FlowContext';
import { lang, NAMESPACE } from './locale';
import { executionSchema } from './schemas/executions';
import useStyles from './style';
import { linkNodes, getWorkflowDetailPath } from './utils';
import { Fieldset } from './components/Fieldset';
import { useRefreshActionProps } from './hooks/useRefreshActionProps';
import { useTrigger } from './triggers';
import { ExecutionStatusOptionsMap } from './constants';
import { HideVariableContext } from './variable';
function ExecutionResourceProvider({ request, filter = {}, ...others }) {
const { workflow } = useFlowContext();
const props = {
...others,
request: {
...request,
params: {
...request?.params,
filter: {
...request?.params?.filter,
key: workflow.key,
},
},
},
};
return <ResourceActionProvider {...props} />;
}
function ExecutedStatusMessage({ data, option }) {
const compile = useCompile();
const statusText = compile(option.label);
return (
<Trans ns={NAMESPACE} values={{ statusText }}>
{'Workflow executed, the result status is '}
<Tag color={option.color}>{'{{statusText}}'}</Tag>
<Link to={`/admin/workflow/executions/${data.id}`}>View the execution</Link>
</Trans>
);
}
function getExecutedStatusMessage({ id, status }) {
const option = ExecutionStatusOptionsMap[status];
if (!option) {
return null;
}
return {
type: 'info' as NoticeType,
content: <ExecutedStatusMessage data={{ id }} option={option} />,
};
}
function useExecuteConfirmAction() {
const { workflow } = useFlowContext();
const form = useForm();
const { resource } = useResourceContext();
const ctx = useActionContext();
const navigate = useNavigateNoUpdate();
const { message: messageApi } = App.useApp();
return {
async run() {
const { autoRevision, ...values } = form.values;
// Not executed, could choose to create new version (by default)
// Executed, stay in current version, and refresh
await form.submit();
const {
data: { data },
} = await resource.execute({
filterByTk: workflow.id,
values,
...(!workflow.executed && autoRevision ? { autoRevision: 1 } : {}),
});
form.reset();
ctx.setFormValueChanged(false);
ctx.setVisible(false);
messageApi?.open(getExecutedStatusMessage(data.execution));
if (data.newVersionId) {
navigate(`/admin/workflow/workflows/${data.newVersionId}`);
}
},
};
}
function ActionDisabledProvider({ children }) {
const field = useField<any>();
const { workflow } = useFlowContext();
const trigger = useTrigger();
const valid = trigger.validate(workflow.config);
let message = '';
switch (true) {
case !valid:
message = lang('The trigger is not configured correctly, please check the trigger configuration.');
break;
case !trigger.triggerFieldset:
message = lang('This type of trigger has not been supported to be executed manually.');
break;
default:
break;
}
field.setPattern(message ? 'disabled' : 'editable');
return message ? <Tooltip title={message}>{children}</Tooltip> : children;
}
function ExecuteActionButton() {
const { workflow } = useFlowContext();
const trigger = useTrigger();
return (
<CurrentWorkflowContext.Provider value={workflow}>
<HideVariableContext.Provider value={true}>
<SchemaComponent
components={{
Alert,
Fieldset,
ActionDisabledProvider,
...trigger.components,
}}
scope={{
useCancelAction,
useExecuteConfirmAction,
}}
schema={{
name: `trigger-modal-${workflow.type}-${workflow.id}`,
type: 'void',
'x-decorator': 'ActionDisabledProvider',
'x-component': 'Action',
'x-component-props': {
openSize: 'small',
},
title: `{{t('Execute manually', { ns: "${NAMESPACE}" })}}`,
properties: {
drawer: {
type: 'void',
'x-decorator': 'FormV2',
'x-component': 'Action.Modal',
title: `{{t('Execute manually', { ns: "${NAMESPACE}" })}}`,
properties: {
...(Object.keys(trigger.triggerFieldset ?? {}).length
? {
alert: {
type: 'void',
'x-component': 'Alert',
'x-component-props': {
message: `{{t('Trigger variables need to be filled for executing.', { ns: "${NAMESPACE}" })}}`,
className: css`
margin-bottom: 1em;
`,
},
},
}
: {
description: {
type: 'void',
'x-component': 'p',
'x-content': `{{t('This will perform all the actions configured in the workflow. Are you sure you want to continue?', { ns: "${NAMESPACE}" })}}`,
},
}),
fieldset: {
type: 'void',
'x-decorator': 'FormItem',
'x-component': 'Fieldset',
title: `{{t('Trigger variables', { ns: "${NAMESPACE}" })}}`,
properties: trigger.triggerFieldset,
},
...(workflow.executed
? {}
: {
autoRevision: {
type: 'boolean',
'x-decorator': 'FormItem',
'x-component': 'Checkbox',
'x-content': `{{t('Automatically create a new version after execution', { ns: "${NAMESPACE}" })}}`,
default: true,
},
}),
footer: {
type: 'void',
'x-component': 'Action.Modal.Footer',
properties: {
cancel: {
type: 'void',
title: `{{t('Cancel')}}`,
'x-component': 'Action',
'x-component-props': {
useAction: '{{useCancelAction}}',
},
},
submit: {
type: 'void',
title: `{{t('Confirm')}}`,
'x-component': 'Action',
'x-component-props': {
type: 'primary',
useAction: '{{useExecuteConfirmAction}}',
},
},
},
},
},
},
},
}}
/>
</HideVariableContext.Provider>
</CurrentWorkflowContext.Provider>
);
}
function WorkflowMenu() {
const { workflow, revisions } = useFlowContext();
const [historyVisible, setHistoryVisible] = useState(false);
const navigate = useNavigate();
const { t } = useTranslation();
const { modal } = App.useApp();
const app = useApp();
const { resource } = useResourceContext();
const { message } = App.useApp();
const onRevision = useCallback(async () => {
const {
data: { data: revision },
} = await resource.revision({
filterByTk: workflow.id,
filter: {
key: workflow.key,
},
});
message.success(t('Operation succeeded'));
navigate(`/admin/workflow/workflows/${revision.id}`);
}, [resource, workflow.id, workflow.key, message, t, navigate]);
const onDelete = useCallback(async () => {
const content = workflow.current
? lang('Delete a main version will cause all other revisions to be deleted too.')
: '';
modal.confirm({
title: t('Are you sure you want to delete it?'),
content,
async onOk() {
await resource.destroy({
filterByTk: workflow.id,
});
message.success(t('Operation succeeded'));
navigate(
workflow.current
? app.pluginSettingsManager.getRoutePath('workflow')
: getWorkflowDetailPath(revisions.find((item) => item.current)?.id),
);
},
});
}, [workflow, modal, t, resource, message, navigate, app.pluginSettingsManager, revisions]);
const onMenuCommand = useCallback(
({ key }) => {
switch (key) {
case 'history':
setHistoryVisible(true);
return;
case 'revision':
return onRevision();
case 'delete':
return onDelete();
default:
break;
}
},
[onDelete, onRevision],
);
const revisionable =
workflow.executed &&
!revisions.find((item) => !item.executed && new Date(item.createdAt) > new Date(workflow.createdAt));
return (
<>
<Dropdown
menu={{
items: [
{
key: 'key',
label: `Key: ${workflow.key}`,
disabled: true,
},
{
type: 'divider',
},
{
role: 'button',
'aria-label': 'history',
key: 'history',
label: lang('Execution history'),
disabled: !workflow.allExecuted,
},
{
role: 'button',
'aria-label': 'revision',
key: 'revision',
label: lang('Copy to new version'),
disabled: !revisionable,
},
{
type: 'divider',
},
{ role: 'button', 'aria-label': 'delete', danger: true, key: 'delete', label: t('Delete') },
] as any[],
onClick: onMenuCommand,
}}
>
<Button aria-label="more" type="text" icon={<EllipsisOutlined />} />
</Dropdown>
<ActionContextProvider value={{ visible: historyVisible, setVisible: setHistoryVisible }}>
<SchemaComponent
schema={executionSchema}
components={{
ExecutionResourceProvider,
ExecutionLink,
ExecutionStatusColumn,
}}
scope={{
useRefreshActionProps,
}}
/>
</ActionContextProvider>
</>
);
}
export function WorkflowCanvas() {
const navigate = useNavigate();
const app = useApp();
const { data, refresh, loading } = useResourceActionContext();
const { resource } = useResourceContext();
const { setTitle } = useDocumentTitle();
const { styles } = useStyles();
const workflowPlugin = usePlugin(PluginWorkflowClient);
const { nodes = [], revisions = [], ...workflow } = data?.data ?? {};
linkNodes(nodes);
useEffect(() => {
const { title } = data?.data ?? {};
setTitle?.(`${lang('Workflow')}${title ? `: ${title}` : ''}`);
}, [data?.data, setTitle]);
const onSwitchVersion = useCallback(
({ key }) => {
if (key != workflow.id) {
navigate(getWorkflowDetailPath(key));
}
},
[workflow.id, navigate],
);
const onToggle = useCallback(
async (value) => {
await resource.update({
filterByTk: workflow.id,
values: {
enabled: value,
},
});
refresh();
},
[resource, workflow.id, refresh],
);
if (!data?.data) {
if (loading) {
return <Spin />;
}
return (
<Result status="404" title="Not found" extra={<Button onClick={() => navigate(-1)}>{lang('Go back')}</Button>} />
);
}
const entry = nodes.find((item) => !item.upstream);
return (
<FlowContext.Provider
value={{
workflow,
revisions,
nodes,
refresh,
}}
>
<div className="workflow-toolbar">
<header>
<Breadcrumb
items={[
{ title: <Link to={app.pluginSettingsManager.getRoutePath('workflow')}>{lang('Workflow')}</Link> },
{
title: (
<Tooltip title={`Key: ${workflow.key}`}>
<strong>{workflow.title}</strong>
</Tooltip>
),
},
]}
/>
{workflow.sync ? (
<Tag color="orange">{lang('Synchronously')}</Tag>
) : (
<Tag color="cyan">{lang('Asynchronously')}</Tag>
)}
</header>
<aside>
<ExecuteActionButton />
<Dropdown
className="workflow-versions"
trigger={['click']}
menu={{
onClick: onSwitchVersion,
defaultSelectedKeys: [`${workflow.id}`],
className: cx(styles.dropdownClass, styles.workflowVersionDropdownClass),
items: revisions
.sort((a, b) => b.id - a.id)
.map((item, index) => ({
role: 'button',
'aria-label': `version-${index}`,
key: `${item.id}`,
icon: item.current ? <RightOutlined /> : null,
className: cx({
executed: item.executed,
unexecuted: !item.executed,
enabled: item.enabled,
}),
label: (
<>
<strong>{`#${item.id}`}</strong>
<time>{dayjs(item.createdAt).fromNow()}</time>
</>
),
})),
}}
>
<Button type="text" aria-label="version">
<label>{lang('Version')}</label>
<span>{workflow?.id ? `#${workflow.id}` : null}</span>
<DownOutlined />
</Button>
</Dropdown>
<Switch
checked={workflow.enabled}
onChange={onToggle}
checkedChildren={lang('On')}
unCheckedChildren={lang('Off')}
/>
<WorkflowMenu />
</aside>
</div>
<CanvasContent entry={entry} />
</FlowContext.Provider>
);
}