refactor(plugin-workflow): change canvas card and adjust styles (#1529)

* refactor(plugin-workflow): optimize workflow canvas

* feat(plugin-workflow): allow click on node card to open config and adjust styles

* fix(plugin-workflow): fix collection trigger linkages
This commit is contained in:
Junyi 2023-03-04 00:19:11 +08:00 committed by GitHub
parent 4d44e31b31
commit 37998d03ad
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 516 additions and 379 deletions

View File

@ -123,6 +123,8 @@ ReadPretty.JSON = (props) => {
<pre
className={cx(prefixCls, props.className, css`
margin-bottom: 0;
line-height: 1.5;
font-size: 90%;
`)}
style={props.style}
>

View File

@ -20,7 +20,7 @@ interface AddButtonProps {
export function AddButton({ upstream, branchIndex = null }: AddButtonProps) {
const compile = useCompile();
const api = useAPIClient();
const { workflow, onNodeAdded } = useFlowContext() ?? {};
const { workflow, refresh } = useFlowContext() ?? {};
if (!workflow) {
return null;
}
@ -30,8 +30,9 @@ export function AddButton({ upstream, branchIndex = null }: AddButtonProps) {
const type = keyPath.pop();
const config = {};
const [optionKey] = keyPath;
const instruction = instructions.get(type);
if (optionKey) {
const { value } = instructions.get(type)?.options?.find(item => item.key === optionKey) ?? {};
const { value } = instruction.options?.find(item => item.key === optionKey) ?? {};
Object.assign(config, value);
}
@ -40,47 +41,46 @@ export function AddButton({ upstream, branchIndex = null }: AddButtonProps) {
type,
upstreamId: upstream?.id ?? null,
branchIndex,
title: compile(instruction.title),
config
}
});
onNodeAdded(node);
refresh();
}
const groups = [
{ value: 'control', name: `{{t("Control", { ns: "${NAMESPACE}" })}}` },
{ value: 'collection', name: `{{t("Collection operations", { ns: "${NAMESPACE}" })}}` },
{ value: 'manual', name: `{{t("Manual", { ns: "${NAMESPACE}" })}}` },
{ value: 'extended', name: `{{t("Extended types", { ns: "${NAMESPACE}" })}}` },
];
const instructionList = (Array.from(instructions.getValues()) as Instruction[]);
const groups = [
{ key: 'control', label: `{{t("Control", { ns: "${NAMESPACE}" })}}` },
{ key: 'collection', label: `{{t("Collection operations", { ns: "${NAMESPACE}" })}}` },
{ key: 'manual', label: `{{t("Manual", { ns: "${NAMESPACE}" })}}` },
{ key: 'extended', label: `{{t("Extended types", { ns: "${NAMESPACE}" })}}` },
]
.filter(group => instructionList.filter(item => item.group === group.key).length)
.map(group => {
const groupInstructions = instructionList.filter(item => item.group === group.key);
return {
...group,
type: 'group',
children: groupInstructions.map(item => ({
key: item.type,
label: item.title,
type: item.options ? 'subMenu' : null,
children: item.options ? item.options.map(option => ({
key: option.key,
label: option.label,
})) : null
}))
};
});
return (
<div className={cx(addButtonClass)}>
<Dropdown
trigger={['click']}
overlay={
<Menu onClick={ev => onCreate(ev)}>
{groups.map(group => {
const groupInstructions = instructionList.filter(item => item.group === group.value);
return groupInstructions.length ? (
<Menu.ItemGroup key={group.value} title={compile(group.name)}>
{groupInstructions.map(item => item.options
? (
<Menu.SubMenu key={item.type} title={compile(item.title)}>
{item.options.map(option => (
<Menu.Item key={option.key}>{compile(option.label)}</Menu.Item>
))}
</Menu.SubMenu>
)
: (
<Menu.Item key={item.type}>{compile(item.title)}</Menu.Item>
))}
</Menu.ItemGroup>
) : null;
})}
</Menu>
}
overlay={<Menu onClick={ev => onCreate(ev)} items={compile(groups)} />}
disabled={workflow.executed}
>
<Button shape="circle" icon={<PlusOutlined />} />

View File

@ -117,8 +117,7 @@ export function WorkflowCanvas() {
<FlowContext.Provider value={{
workflow,
nodes,
onNodeAdded: refresh,
onNodeRemoved: refresh
refresh,
}}>
<div className="workflow-toolbar">
<header>

View File

@ -33,8 +33,8 @@ export default observer(({ value, disabled, onChange }: any) => {
const compile = useCompile();
const form = useForm();
const { getCollection, getCollectionFields } = useCollectionManager();
const { values: data } = useForm();
const collectionName = data?.config?.collection;
const { values: config } = useForm();
const collectionName = config?.collection;
const fields = getCollectionFields(collectionName)
.filter(field => (
!field.hidden

View File

@ -17,10 +17,9 @@ export default {
type: 'calculation',
group: 'control',
fieldset: {
'config.engine': {
engine: {
type: 'string',
title: `{{t("Calculation engine", { ns: "${NAMESPACE}" })}}`,
name: 'config.engine',
'x-decorator': 'FormItem',
'x-component': 'RadioWithTooltip',
'x-component-props': {
@ -29,10 +28,9 @@ export default {
required: true,
default: 'math.js'
},
'config.expression': {
expression: {
type: 'string',
title: `{{t("Calculation expression", { ns: "${NAMESPACE}" })}}`,
name: 'config.expression',
'x-decorator': 'FormItem',
'x-component': 'Variable.TextArea',
'x-component-props': {
@ -40,7 +38,7 @@ export default {
},
['x-validator'](value, rules, { form }) {
const { values } = form;
const { evaluate } = evaluators.get(values.config.engine) as Evaluator;
const { evaluate } = evaluators.get(values.engine) as Evaluator;
const exp = value.trim().replace(/{{([^{}]+)}}/g, '1');
try {
evaluate(exp);
@ -50,7 +48,7 @@ export default {
}
},
'x-reactions': {
dependencies: ['config.engine'],
dependencies: ['engine'],
fulfill: {
schema: {
description: '{{renderReference($deps[0])}}',

View File

@ -304,9 +304,8 @@ export default {
type: 'condition',
group: 'control',
fieldset: {
'config.rejectOnFalse': {
rejectOnFalse: {
type: 'boolean',
name: 'config.rejectOnFalse',
title: `{{t("Mode", { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormItem',
'x-component': 'Radio.Group',
@ -324,10 +323,9 @@ export default {
}
],
},
'config.engine': {
engine: {
type: 'string',
title: `{{t("Calculation engine", { ns: "${NAMESPACE}" })}}`,
name: 'config.engine',
'x-decorator': 'FormItem',
'x-component': 'RadioWithTooltip',
'x-component-props': {
@ -339,14 +337,13 @@ export default {
required: true,
default: 'basic',
},
'config.calculation': {
calculation: {
type: 'string',
name: 'config.calculation',
title: `{{t("Condition", { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormItem',
'x-component': 'CalculationConfig',
'x-reactions': {
dependencies: ['config.engine'],
dependencies: ['engine'],
fulfill: {
state: {
visible: '{{$deps[0] === "basic"}}'
@ -355,10 +352,9 @@ export default {
},
required: true
},
'config.expression': {
expression: {
type: 'string',
title: `{{t("Condition expression", { ns: "${NAMESPACE}" })}}`,
name: 'config.expression',
'x-decorator': 'FormItem',
'x-component': 'Variable.TextArea',
'x-component-props': {
@ -366,7 +362,7 @@ export default {
},
['x-validator'](value, rules, { form }) {
const { values } = form;
const { evaluate } = evaluators.get(values.config.engine);
const { evaluate } = evaluators.get(values.engine);
const exp = value.trim().replace(/{{([^{}]+)}}/g, '1');
try {
evaluate(exp);
@ -376,7 +372,7 @@ export default {
}
},
'x-reactions': {
dependencies: ['config.engine'],
dependencies: ['engine'],
fulfill: {
state: {
visible: '{{$deps[0] !== "basic"}}'

View File

@ -14,10 +14,7 @@ export default {
type: 'create',
group: 'collection',
fieldset: {
'config.collection': {
...collection,
name: 'config.collection'
},
collection,
// multiple: {
// type: 'boolean',
// title: '多条数据',
@ -28,7 +25,7 @@ export default {
// disabled: true
// }
// },
'config.params.values': values
'params.values': values
},
view: {

View File

@ -7,18 +7,16 @@ export default {
type: 'delay',
group: 'control',
fieldset: {
'config.duration': {
duration: {
type: 'number',
name: 'config.duration',
title: `{{t("Duration", { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormItem',
'x-component': 'Duration',
default: 60000,
required: true
},
'config.endStatus': {
endStatus: {
type: 'number',
name: 'config.endStatus',
title: `{{t("End Status", { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormItem',
'x-component': 'Select',

View File

@ -8,10 +8,9 @@ export default {
type: 'destroy',
group: 'collection',
fieldset: {
'config.collection': collection,
'config.params': {
collection,
params: {
type: 'object',
name: 'config.params',
title: '',
'x-decorator': 'FormItem',
properties: {

View File

@ -1,18 +1,18 @@
import React, { useContext } from 'react';
import React, { useState, useContext } from 'react';
import {
CloseOutlined,
DeleteOutlined,
} from '@ant-design/icons';
import { css, cx } from '@emotion/css';
import { ISchema, useForm } from '@formily/react';
import { Button, message, Modal, Tag, Alert } from 'antd';
import { Button, message, Modal, Tag, Alert, Input } from 'antd';
import { useTranslation } from 'react-i18next';
import parse from 'json-templates';
import { Registry } from '@nocobase/utils/client';
import { SchemaComponent, SchemaInitializerItemOptions, useActionContext, useAPIClient, useCompile, useRequest, useResourceActionContext } from '@nocobase/client';
import { ActionContext, SchemaComponent, SchemaInitializerItemOptions, useActionContext, useAPIClient, useCompile, useRequest, useResourceActionContext } from '@nocobase/client';
import { nodeBlockClass, nodeCardClass, nodeClass, nodeHeaderClass, nodeMetaClass, nodeTitleClass } from '../style';
import { nodeBlockClass, nodeCardClass, nodeClass, nodeMetaClass, nodeTitleClass } from '../style';
import { AddButton } from '../AddButton';
import { useFlowContext } from '../FlowContext';
@ -76,17 +76,11 @@ function useUpdateAction() {
message.error(lang('Node in executed workflow cannot be modified'));
return;
}
// TODO: how to do validation separately for each field? especially disabled for dynamic fields?
try {
await form.submit();
} catch (err) {
return;
}
await form.submit();
await api.resource('flow_nodes', data.id).update?.({
filterByTk: data.id,
values: {
title: form.values.title,
config: form.values.config
config: form.values
}
});
ctx.setVisible(false);
@ -153,7 +147,7 @@ export function Node({ data }) {
export function RemoveButton() {
const { t } = useTranslation();
const api = useAPIClient();
const { workflow, nodes, onNodeRemoved } = useFlowContext() ?? {};
const { workflow, nodes, refresh } = useFlowContext() ?? {};
const current = useNodeContext();
if (!workflow) {
return null;
@ -162,10 +156,10 @@ export function RemoveButton() {
async function onRemove() {
async function onOk() {
const { data: { data: node } } = await resource.destroy?.({
await resource.destroy?.({
filterByTk: current.id
});
onNodeRemoved(node);
refresh();
}
const usingNodes = nodes.filter(node => {
@ -225,6 +219,7 @@ export function JobButton() {
className={cx('workflow-node-job-button', css`
border: 2px solid #d9d9d9;
border-radius: 50%;
cursor: not-allowed;
`)}
/>
);
@ -312,131 +307,161 @@ export function JobButton() {
}
export function NodeDefaultView(props) {
const compile = useCompile();
const { workflow } = useFlowContext() ?? {};
if (!workflow) {
return null;
}
const { data, children } = props;
const compile = useCompile();
const api = useAPIClient();
const { workflow, refresh } = useFlowContext() ?? {};
const instruction = instructions.get(data.type);
const detailText = workflow.executed ? '{{t("View")}}' : '{{t("Configure")}}';
const typeTitle = compile(instruction.title);
const [editingTitle, setEditingTitle] = useState<string>(data.title ?? typeTitle);
const [editingConfig, setEditingConfig] = useState(false);
async function onChangeTitle(next) {
const title = next || typeTitle;
setEditingTitle(title);
if (title === data.title) {
return;
}
await api.resource('flow_nodes').update?.({
filterByTk: data.id,
values: {
title
}
});
refresh();
}
function onOpenDrawer(ev) {
if (ev.target === ev.currentTarget) {
setEditingConfig(true);
return;
}
const whiteSet = new Set(['workflow-node-meta', 'workflow-node-config-button', 'ant-input-disabled']);
for (let el = ev.target; el && el !== ev.currentTarget; el = el.parentNode) {
if ((Array.from(el.classList) as string[]).some((name: string) => whiteSet.has(name))) {
setEditingConfig(true);
ev.stopPropagation();
return;
}
}
}
return (
<div className={cx(nodeClass, `workflow-node-type-${data.type}`)}>
<div className={cx(nodeCardClass)}>
<div className={cx(nodeHeaderClass)}>
<div className={cx(nodeMetaClass)}>
<Tag>{compile(instruction.title)}</Tag>
</div>
<h4 className={cx(nodeTitleClass)}>
<strong>{data.title}</strong>
<span className="workflow-node-id">#{data.id}</span>
</h4>
<RemoveButton />
<JobButton />
<div className={cx(nodeCardClass, { configuring: editingConfig })} onClick={onOpenDrawer}>
<div className={cx(nodeMetaClass, 'workflow-node-meta')}>
<Tag>{typeTitle}</Tag>
<span className="workflow-node-id">{data.id}</span>
</div>
<SchemaComponent
scope={instruction.scope}
components={instruction.components}
schema={{
type: 'void',
properties: {
...(instruction.view ? { view: instruction.view } : {}),
config: {
type: 'void',
title: detailText,
'x-component': 'Action.Link',
'x-component-props': {
type: 'primary',
<div>
<Input.TextArea
disabled={workflow.executed}
value={editingTitle}
onChange={(ev) => setEditingTitle(ev.target.value)}
onBlur={(ev) => onChangeTitle(ev.target.value)}
autoSize
/>
</div>
<RemoveButton />
<JobButton />
<ActionContext.Provider value={{ visible: editingConfig, setVisible: setEditingConfig }}>
<SchemaComponent
scope={instruction.scope}
components={instruction.components}
schema={{
type: 'void',
properties: {
...(instruction.view ? { view: instruction.view } : {}),
config: {
type: 'void',
'x-content': detailText,
'x-component': Button,
'x-component-props': {
type: 'link',
className: 'workflow-node-config-button'
},
},
properties: {
[`${instruction.type}_${data.id}`]: {
type: 'void',
title: instruction.title,
'x-component': 'Action.Drawer',
'x-decorator': 'Form',
'x-decorator-props': {
disabled: workflow.executed,
useValues(options) {
const d = useNodeContext();
return useRequest(() => {
return Promise.resolve({ data: d });
}, options);
}
},
properties: {
...(workflow.executed ? {
alert: {
'x-component': Alert,
'x-component-props': {
type: 'warning',
showIcon: true,
message: `{{t("Node in executed workflow cannot be modified", { ns: "${NAMESPACE}" })}}`,
className: css`
width: 100%;
font-size: 85%;
margin-bottom: 2em;
`
},
}
} : {}),
title: {
type: 'string',
name: 'title',
title: '{{t("Name")}}',
'x-decorator': 'FormItem',
'x-component': 'Input',
},
config: {
[`${instruction.type}_${data.id}`]: {
type: 'void',
title: instruction.title,
'x-component': 'Action.Drawer',
'x-decorator': 'Form',
'x-decorator-props': {
disabled: workflow.executed,
useValues(options) {
const { config } = useNodeContext();
return useRequest(() => {
return Promise.resolve({ data: config });
}, options);
}
},
properties: {
...(workflow.executed ? {
alert: {
type: 'void',
name: 'config',
'x-component': 'fieldset',
'x-component': Alert,
'x-component-props': {
type: 'warning',
showIcon: true,
message: `{{t("Node in executed workflow cannot be modified", { ns: "${NAMESPACE}" })}}`,
className: css`
.ant-input,
.ant-select,
.ant-cascader-picker,
.ant-picker,
.ant-input-number,
.ant-input-affix-wrapper{
width: auto;
min-width: 6em;
}
width: 100%;
font-size: 85%;
margin-bottom: 2em;
`
},
properties: instruction.fieldset
}
} : {}),
fieldset: {
type: 'void',
'x-component': 'fieldset',
'x-component-props': {
className: css`
.ant-input,
.ant-select,
.ant-cascader-picker,
.ant-picker,
.ant-input-number,
.ant-input-affix-wrapper{
width: auto;
min-width: 6em;
}
`
},
actions: workflow.executed
? null
: {
type: 'void',
'x-component': 'Action.Drawer.Footer',
properties: {
cancel: {
title: '{{t("Cancel")}}',
'x-component': 'Action',
'x-component-props': {
useAction: '{{ cm.useCancelAction }}',
},
},
submit: {
title: '{{t("Submit")}}',
'x-component': 'Action',
'x-component-props': {
type: 'primary',
useAction: useUpdateAction,
},
properties: instruction.fieldset
},
actions: workflow.executed
? null
: {
type: 'void',
'x-component': 'Action.Drawer.Footer',
properties: {
cancel: {
title: '{{t("Cancel")}}',
'x-component': 'Action',
'x-component-props': {
useAction: '{{ cm.useCancelAction }}',
},
},
}
submit: {
title: '{{t("Submit")}}',
'x-component': 'Action',
'x-component-props': {
type: 'primary',
useAction: useUpdateAction,
},
},
},
}
} as ISchema
}
}
} as ISchema
}
}
}}
/>
}}
/>
</ActionContext.Provider>
</div>
{children}
</div>

View File

@ -34,9 +34,8 @@ export default {
type: 'manual',
group: 'manual',
fieldset: {
'config.assignees': {
assignees: {
type: 'array',
name: 'config.assignees',
title: `{{t("Assignees", { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormItem',
'x-component': 'AssigneesSelect',
@ -53,15 +52,14 @@ export default {
required: true,
default: [],
},
'config.mode': {
mode: {
type: 'number',
name: 'config.mode',
title: `{{t("Mode", { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormItem',
'x-component': 'ModeConfig',
default: 1,
'x-reactions': {
dependencies: ['config.assignees'],
dependencies: ['assignees'],
fulfill: {
state: {
visible: '{{$deps[0].length > 1}}',
@ -69,7 +67,7 @@ export default {
},
}
},
'config.schema': {
schema: {
type: 'void',
title: `{{t("User interface", { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormItem',

View File

@ -1,6 +1,6 @@
import React, { useState } from "react";
import { css, cx } from "@emotion/css";
import { PlusOutlined, QuestionCircleOutlined } from '@ant-design/icons';
import { PlusOutlined } from '@ant-design/icons';
import { Button, Tooltip } from "antd";
import { NodeDefaultView } from ".";
@ -17,9 +17,8 @@ export default {
type: 'parallel',
group: 'control',
fieldset: {
'config.mode': {
mode: {
type: 'string',
name: 'config.mode',
title: `{{t("Mode", { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormItem',
'x-component': 'RadioWithTooltip',
@ -50,7 +49,7 @@ export default {
},
render(data) {
const { id, config: { mode } } = data;
const { nodes } = useFlowContext();
const { workflow, nodes } = useFlowContext();
const branches = nodes.reduce((result, node) => {
if (node.upstreamId === id && node.branchIndex != null) {
return result.concat(node);
@ -90,6 +89,7 @@ export default {
shape="circle"
icon={<PlusOutlined />}
onClick={() => setBranchCount(branchCount - 1)}
disabled={workflow.executed}
/>
</div>
)
@ -104,7 +104,9 @@ export default {
height: 2em;
`}
>
<Tooltip title={lang('Add branch')}>
<Tooltip title={lang('Add branch')} className={css`
visibility: ${workflow.executed ? 'hidden' : 'visible'}
`}>
<Button
icon={<PlusOutlined />}
className={css`
@ -117,6 +119,7 @@ export default {
}
`}
onClick={() => setBranchCount(branchCount + 1)}
disabled={workflow.executed}
/>
</Tooltip>
</div>

View File

@ -14,7 +14,7 @@ export default {
type: 'query',
group: 'collection',
fieldset: {
'config.collection': collection,
collection,
// 'config.multiple': {
// type: 'boolean',
// title: `{{t("Multiple records", { ns: "${NAMESPACE}" })}}`,
@ -25,9 +25,8 @@ export default {
// disabled: true
// }
// },
'config.params': {
params: {
type: 'object',
name: 'config.params',
title: '',
'x-decorator': 'FormItem',
properties: {

View File

@ -11,9 +11,8 @@ export default {
type: 'request',
group: 'extended',
fieldset: {
'config.method': {
method: {
type: 'string',
name: 'config.method',
required: true,
title: `{{t("HTTP method", { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormItem',
@ -31,9 +30,8 @@ export default {
],
default: 'POST'
},
'config.url': {
url: {
type: 'string',
name: 'config.url',
required: true,
title: `{{t("URL", { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormItem',
@ -52,9 +50,8 @@ export default {
placeholder: 'https://www.nocobase.com',
},
},
'config.headers': {
headers: {
type: 'array',
name: 'config.headers',
'x-component': 'ArrayItems',
'x-decorator': 'FormItem',
title: `{{t("Headers", { ns: "${NAMESPACE}" })}}`,
@ -99,9 +96,8 @@ export default {
},
},
},
'config.params': {
params: {
type: 'array',
name: 'config.params',
'x-component': 'ArrayItems',
'x-decorator': 'FormItem',
title: `{{t("Parameters", { ns: "${NAMESPACE}" })}}`,
@ -145,9 +141,8 @@ export default {
},
},
},
'config.data': {
data: {
type: 'string',
name: 'config.data',
title: `{{t("Body", { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormItem',
'x-decorator-props': {},
@ -159,15 +154,14 @@ export default {
},
placeholder: `{{t("Input request data", { ns: "${NAMESPACE}" })}}`,
className: css`
font-size: 85%;
font-size: 90%;
font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
`
},
description: `{{t("Only support standard JSON data", { ns: "${NAMESPACE}" })}}`,
},
'config.timeout': {
timeout: {
type: 'number',
name: 'config.timeout',
title: `{{t("Timeout config", { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormItem',
'x-decorator-props': {},
@ -179,9 +173,8 @@ export default {
defaultValue: 5000,
},
},
'config.ignoreFail': {
ignoreFail: {
type: 'boolean',
name: 'config.ignoreFail',
title: `{{t("Ignore fail request and continue workflow", { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormItem',
'x-component': 'Checkbox',

View File

@ -12,12 +12,19 @@ export default {
type: 'update',
group: 'collection',
fieldset: {
'config.collection': collection,
'config.params.filter': {
...filter,
title: `{{t("Only update records matching conditions", { ns: "${NAMESPACE}" })}}`,
},
'config.params.values': values
collection,
params: {
type: 'object',
title: '',
'x-decorator': 'FormItem',
properties: {
filter: {
...filter,
title: `{{t("Only update records matching conditions", { ns: "${NAMESPACE}" })}}`,
},
values
}
}
},
view: {

View File

@ -6,7 +6,6 @@ import { NAMESPACE } from "../locale";
export const collection = {
type: 'string',
title: '{{t("Collection")}}',
name: 'config.collection',
required: true,
'x-reactions': ['{{useCollectionDataSource()}}'],
'x-decorator': 'FormItem',
@ -19,7 +18,6 @@ export const collection = {
export const values = {
type: 'object',
title: '{{t("Fields values")}}',
name: 'config.params.values',
'x-decorator': 'FormItem',
'x-decorator-props': {
labelAlign: 'left',
@ -34,7 +32,6 @@ export const values = {
export const filter = {
type: 'object',
title: '{{t("Filter")}}',
name: 'config.params.filter',
'x-decorator': 'FormItem',
'x-decorator-props': {
labelAlign: 'left',
@ -46,7 +43,7 @@ export const filter = {
'x-component-props': {
useProps() {
const { values } = useForm();
const options = useCollectionFilterOptions(values.config?.collection);
const options = useCollectionFilterOptions(values?.collection);
return {
options,
className: css`

View File

@ -157,19 +157,27 @@ export const nodeClass = css`
`;
export const nodeCardClass = css`
position: relative;
width: 20em;
background: #fff;
padding: 1em;
box-shadow: 0 .25em .5em rgba(0, 0, 0, .1);
border-radius: .5em;
cursor: pointer;
transition: box-shadow .3s ease;
&.configuring{
box-shadow: 0 .25em 1em rgba(0, 100, 200, .25);
}
.workflow-node-remove-button,
.workflow-node-job-button{
position: absolute;
right: -.5em;
top: -.5em;
}
.workflow-node-remove-button{
right: .5em;
top: .5em;
color: #999;
opacity: 0;
transition: opacity .3s ease;
@ -185,8 +193,8 @@ export const nodeCardClass = css`
.workflow-node-job-button{
display: flex;
top: 0;
right: 0;
top: 1em;
right: 1em;
width: 1.25rem;
height: 1.25rem;
min-width: 1.25rem;
@ -200,7 +208,32 @@ export const nodeCardClass = css`
}
}
.ant-input{
font-weight: bold;
&:not(:focus){
transition: background-color .3s ease, border-color .3s ease;
border-color: #f7f7f7;
background-color: #f7f7f7;
&:not(:disabled):hover{
border-color: #eee;
background-color: #eee;
}
&:disabled:hover{
border-color: #f7f7f7;
}
}
}
.workflow-node-config-button{
padding: 0;
}
&:hover{
box-shadow: 0 .25em .5em rgba(0, 0, 0, .25);
.workflow-node-remove-button{
opacity: 1;
}
@ -213,13 +246,20 @@ export const nodeHeaderClass = css`
export const nodeMetaClass = css`
margin-bottom: .5em;
.workflow-node-id{
color: #999;
&:before{
content: "#"
}
}
`;
export const nodeTitleClass = css`
display: flex;
align-items: center;
font-weight: normal;
.workflow-node-id{
color: #999;
}
@ -234,4 +274,10 @@ export const nodeSubtreeClass = css`
export const addButtonClass = css`
flex-shrink: 0;
padding: 2em 0;
> .ant-btn{
&:disabled{
visibility: hidden;
}
}
`;

View File

@ -1,7 +1,6 @@
import React from 'react';
import { Select } from 'antd';
import { onFieldValueChange } from '@formily/core';
import { observer, useForm, useFormEffects } from '@formily/react';
import { observer, useForm } from '@formily/react';
import {
SchemaInitializerItemOptions,
@ -16,26 +15,17 @@ import { CollectionBlockInitializer } from '../components/CollectionBlockInitial
import { CollectionFieldInitializers } from '../components/CollectionFieldInitializers';
import { NAMESPACE, useWorkflowTranslation } from '../locale';
const FieldsSelect = observer((props) => {
const FieldsSelect = observer((props: any) => {
const { filter = () => true, ...others } = props;
const compile = useCompile();
const { getCollectionFields } = useCollectionManager();
const { values, clearFormGraph, setValuesIn } = useForm();
const fields = getCollectionFields(values?.config?.collection);
useFormEffects(() => {
onFieldValueChange('config.collection', (field) => {
clearFormGraph('config.changed');
setValuesIn('config.condition', null);
});
});
const { values } = useForm();
const fields = getCollectionFields(values?.collection);
return (
<Select {...props}>
<Select {...others}>
{fields
.filter(field => (
!field.hidden
&& (field.uiSchema ? !field.uiSchema['x-read-pretty'] : true)
&& !['linkTo', 'hasOne', 'hasMany', 'belongsToMany'].includes(field.type)
))
.filter(filter)
.map(field => (
<Select.Option key={field.name} value={field.name}>{compile(field.uiSchema?.title)}</Select.Option>
))}
@ -61,40 +51,33 @@ export default {
title: `{{t("Collection event", { ns: "${NAMESPACE}" })}}`,
type: 'collection',
fieldset: {
'config.collection': {
collection: {
...collection,
['x-reactions']: [
...collection['x-reactions'],
{
target: 'config.mode',
target: 'changed',
effects: ['onFieldValueChange'],
fulfill: {
state: {
visible: '{{!!$self.value}}',
},
value: []
}
}
},
{
target: 'config.changed',
target: 'condition',
effects: ['onFieldValueChange'],
fulfill: {
state: {
visible: '{{!!$self.value}}',
},
}
},
{
target: 'config.condition',
fulfill: {
state: {
visible: '{{!!$self.value}}',
},
value: null
}
}
}
]
},
'config.mode': {
mode: {
type: 'number',
title: `{{t("Trigger on", { ns: "${NAMESPACE}" })}}`,
name: 'config.mode',
'x-decorator': 'FormItem',
'x-component': 'Select',
'x-component-props': {
@ -104,7 +87,15 @@ export default {
required: true,
'x-reactions': [
{
target: 'config.changed',
dependencies: ['collection'],
fulfill: {
state: {
visible: '{{!!$deps[0]}}',
},
}
},
{
target: 'changed',
fulfill: {
state: {
disabled: `{{!($self.value & ${COLLECTION_TRIGGER_MODE.UPDATED})}}`,
@ -113,23 +104,69 @@ export default {
},
]
},
'config.changed': {
changed: {
type: 'array',
name: 'changed',
title: `{{t("Changed fields", { ns: "${NAMESPACE}" })}}`,
description: `{{t("Triggered only if one of the selected fields changes. If unselected, it means that it will be triggered when any field changes. When record is added or deleted, any field is considered to have been changed.", { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormItem',
'x-component': 'FieldsSelect',
'x-component-props': {
mode: 'multiple',
placeholder: '{{t("Select Field")}}'
}
placeholder: '{{t("Select Field")}}',
filter(field) {
return (
!field.hidden
&& (field.uiSchema ? !field.uiSchema['x-read-pretty'] : true)
&& !['linkTo', 'hasOne', 'hasMany', 'belongsToMany'].includes(field.type)
);
}
},
'x-reactions': [
{
dependencies: ['collection'],
fulfill: {
state: {
visible: '{{!!$deps[0]}}',
},
}
},
]
},
'config.condition': {
condition: {
...filter,
name: 'config.condition',
title: `{{t("Only triggers when match conditions", { ns: "${NAMESPACE}" })}}`
}
title: `{{t("Only triggers when match conditions", { ns: "${NAMESPACE}" })}}`,
'x-reactions': [
{
dependencies: ['collection'],
fulfill: {
state: {
visible: '{{!!$deps[0]}}',
},
}
},
]
},
// appends: {
// type: 'array',
// title: `{{t("Prefetch fields", { ns: "${NAMESPACE}" })}}`,
// description: `{{t("Triggered only if one of the selected fields changes. If unselected, it means that it will be triggered when any field changes. When record is added or deleted, any field is considered to have been changed.", { ns: "${NAMESPACE}" })}}`,
// 'x-decorator': 'FormItem',
// 'x-component': 'FieldsSelect',
// 'x-component-props': {
// mode: 'multiple',
// placeholder: '{{t("Select Field")}}'
// },
// 'x-reactions': [
// {
// dependencies: ['collection'],
// fulfill: {
// state: {
// visible: '{{!!$deps[0]}}',
// },
// }
// },
// ]
// },
},
scope: {
useCollectionDataSource

View File

@ -1,14 +1,14 @@
import React, { useState } from "react";
import { css, cx } from "@emotion/css";
import { ISchema, useForm } from "@formily/react";
import { Registry } from "@nocobase/utils/client";
import { message, Tag, Alert } from "antd";
import React from "react";
import { message, Tag, Alert, Button, Input } from "antd";
import { useTranslation } from "react-i18next";
import { InfoOutlined } from '@ant-design/icons';
import { SchemaComponent, SchemaInitializerItemOptions, useActionContext, useAPIClient, useCompile, useRequest, useResourceActionContext } from '@nocobase/client';
import { ActionContext, SchemaComponent, SchemaInitializerItemOptions, useActionContext, useAPIClient, useCompile, useRequest, useResourceActionContext } from '@nocobase/client';
import { nodeCardClass, nodeHeaderClass, nodeMetaClass, nodeTitleClass } from "../style";
import { nodeCardClass, nodeMetaClass, nodeTitleClass } from "../style";
import { useFlowContext } from "../FlowContext";
import collection from './collection';
import schedule from "./schedule/";
@ -16,7 +16,6 @@ import { lang, NAMESPACE } from "../locale";
function useUpdateConfigAction() {
const { t } = useTranslation();
const form = useForm();
const api = useAPIClient();
const { workflow } = useFlowContext() ?? {};
@ -25,13 +24,15 @@ function useUpdateConfigAction() {
return {
async run() {
if (workflow.executed) {
message.error(t('Trigger in executed workflow cannot be modified'));
message.error(lang('Trigger in executed workflow cannot be modified'));
return;
}
await form.submit();
await api.resource('workflows').update?.({
filterByTk: workflow.id,
values: form.values
values: {
config: form.values
}
});
ctx.setVisible(false);
refresh();
@ -127,108 +128,151 @@ function TriggerExecution() {
}
export const TriggerConfig = () => {
const { t } = useTranslation();
const api = useAPIClient();
const compile = useCompile();
const { workflow } = useFlowContext();
const { workflow, refresh } = useFlowContext();
if (!workflow || !workflow.type) {
return null;
}
const { type, config, executed } = workflow;
const { title, fieldset, scope, components } = triggers.get(type);
const { title, type, config, executed } = workflow;
const { title: typeTitle, fieldset, scope, components } = triggers.get(type);
const detailText = executed ? '{{t("View")}}' : '{{t("Configure")}}';
const titleText = `${lang('Trigger')}: ${compile(title)}`;
const titleText = `${lang('Trigger')}: ${compile(typeTitle)}`;
const [editingTitle, setEditingTitle] = useState<string>(title ?? typeTitle);
const [editingConfig, setEditingConfig] = useState(false);
async function onChangeTitle(next) {
const t = next || typeTitle;
setEditingTitle(t);
if (t === title) {
return;
}
await api.resource('workflows').update?.({
filterByTk: workflow.id,
values: {
title: t
}
});
refresh();
}
function onOpenDrawer(ev) {
if (ev.target === ev.currentTarget) {
setEditingConfig(true);
return;
}
const whiteSet = new Set(['workflow-node-meta', 'workflow-node-config-button', 'ant-input-disabled']);
for (let el = ev.target; el && el !== ev.currentTarget; el = el.parentNode) {
if (Array.from(el.classList).some((name: string) => whiteSet.has(name))) {
setEditingConfig(true);
ev.stopPropagation();
return;
}
}
}
return (
<div className={cx(nodeCardClass)}>
<div className={cx(nodeHeaderClass)}>
<div className={cx(nodeMetaClass)}>
<Tag color="gold">{lang('Trigger')}</Tag>
</div>
<h4>{compile(title)}</h4>
<TriggerExecution />
<div className={cx(nodeCardClass)} onClick={onOpenDrawer}>
<div className={cx(nodeMetaClass, 'workflow-node-meta')}>
<Tag color="gold">{titleText}</Tag>
</div>
<SchemaComponent
schema={{
type: 'void',
title: detailText,
'x-component': 'Action.Link',
name: 'drawer',
properties: {
drawer: {
type: 'void',
title: titleText,
'x-component': 'Action.Drawer',
'x-decorator': 'Form',
'x-decorator-props': {
disabled: workflow.executed,
useValues(options) {
return useRequest(() => Promise.resolve({
data: { config },
}), options);
<div>
<Input.TextArea
value={editingTitle}
onChange={(ev) => setEditingTitle(ev.target.value)}
onBlur={(ev) => onChangeTitle(ev.target.value)}
autoSize
/>
</div>
<TriggerExecution />
<ActionContext.Provider value={{ visible: editingConfig, setVisible: setEditingConfig }}>
<SchemaComponent
schema={{
type: 'void',
properties: {
config: {
type: 'void',
'x-content': detailText,
'x-component': Button,
'x-component-props': {
type: 'link',
className: 'workflow-node-config-button'
},
},
properties: {
config: {
type: 'void',
name: 'config',
'x-component': 'fieldset',
'x-component-props': {
className: css`
.ant-select{
width: auto;
min-width: 6em;
}
`
drawer: {
type: 'void',
title: titleText,
'x-component': 'Action.Drawer',
'x-decorator': 'Form',
'x-decorator-props': {
disabled: workflow.executed,
useValues(options) {
return useRequest(() => Promise.resolve({
data: config,
}), options);
},
properties: {
...(executed ? {
alert: {
'x-component': Alert,
'x-component-props': {
type: 'warning',
showIcon: true,
message: `{{t("Trigger in executed workflow cannot be modified", { ns: "${NAMESPACE}" })}}`,
className: css`
width: 100%;
font-size: 85%;
margin-bottom: 2em;
`
},
}
} : {}),
...fieldset
}
},
actions: executed
? null
: {
type: 'void',
'x-component': 'Action.Drawer.Footer',
properties: {
cancel: {
title: '{{t("Cancel")}}',
'x-component': 'Action',
properties: {
...(executed ? {
alert: {
'x-component': Alert,
'x-component-props': {
useAction: '{{ cm.useCancelAction }}',
type: 'warning',
showIcon: true,
message: `{{t("Trigger in executed workflow cannot be modified", { ns: "${NAMESPACE}" })}}`,
className: css`
width: 100%;
font-size: 85%;
margin-bottom: 2em;
`
},
}
} : {}),
fieldset: {
type: 'void',
'x-component': 'fieldset',
'x-component-props': {
className: css`
.ant-select{
width: auto;
min-width: 6em;
}
`
},
submit: {
title: '{{t("Submit")}}',
'x-component': 'Action',
'x-component-props': {
type: 'primary',
useAction: useUpdateConfigAction
properties: fieldset
},
actions: executed
? null
: {
type: 'void',
'x-component': 'Action.Drawer.Footer',
properties: {
cancel: {
title: '{{t("Cancel")}}',
'x-component': 'Action',
'x-component-props': {
useAction: '{{ cm.useCancelAction }}',
},
},
submit: {
title: '{{t("Submit")}}',
'x-component': 'Action',
'x-component-props': {
type: 'primary',
useAction: useUpdateConfigAction
}
}
}
}
}
}
}
}
}}
scope={scope}
components={components}
/>
}}
scope={scope}
components={components}
/>
</ActionContext.Provider>
</div>
);
}

View File

@ -10,7 +10,7 @@ export const DateFieldsSelect: React.FC<any> = observer((props) => {
const compile = useCompile();
const { getCollectionFields } = useCollectionManager();
const { values } = useForm();
const fields = getCollectionFields(values?.config?.collection);
const fields = getCollectionFields(values?.collection);
return (
<Select

View File

@ -33,7 +33,7 @@ const ModeFieldsets = {
'x-component': 'RepeatField',
'x-reactions': [
{
target: 'config.endsOn',
target: 'endsOn',
fulfill: {
state: {
visible: '{{!!$self.value}}',
@ -41,7 +41,7 @@ const ModeFieldsets = {
}
},
{
target: 'config.limit',
target: 'limit',
fulfill: {
state: {
visible: '{{!!$self.value}}',
@ -79,7 +79,7 @@ const ModeFieldsets = {
...collection['x-reactions'],
{
// only full path works
target: 'config.startsOn',
target: 'startsOn',
fulfill: {
state: {
visible: '{{!!$self.value}}',
@ -95,7 +95,7 @@ const ModeFieldsets = {
'x-component': 'OnField',
'x-reactions': [
{
target: 'config.repeat',
target: 'repeat',
fulfill: {
state: {
visible: '{{!!$self.value}}',
@ -113,7 +113,7 @@ const ModeFieldsets = {
'x-component': 'RepeatField',
'x-reactions': [
{
target: 'config.endsOn',
target: 'endsOn',
fulfill: {
state: {
visible: '{{!!$self.value}}',
@ -121,7 +121,7 @@ const ModeFieldsets = {
}
},
{
target: 'config.limit',
target: 'limit',
fulfill: {
state: {
visible: '{{!!$self.value}}',
@ -157,16 +157,15 @@ const scheduleModeOptions = [
export const ScheduleConfig = () => {
const { values = {}, clearFormGraph } = useForm();
const { config = {} } = values;
const [mode, setMode] = useState(config.mode);
const [mode, setMode] = useState(values.mode);
useFormEffects(() => {
onFieldValueChange('config.mode', (field) => {
onFieldValueChange('mode', (field) => {
setMode(field.value);
clearFormGraph('config.collection');
clearFormGraph('config.startsOn');
clearFormGraph('config.repeat');
clearFormGraph('config.endsOn');
clearFormGraph('config.limit');
clearFormGraph('collection');
clearFormGraph('startsOn');
clearFormGraph('repeat');
clearFormGraph('endsOn');
clearFormGraph('limit');
});
});
@ -182,7 +181,8 @@ export const ScheduleConfig = () => {
'x-component-props': {
options: scheduleModeOptions
},
required: true
required: true,
default: SCHEDULE_MODE.STATIC
}}
/>
<SchemaComponent

View File

@ -14,8 +14,7 @@ export default {
type: 'schedule',
fieldset: {
config: {
type: 'object',
name: 'config',
type: 'void',
'x-component': 'ScheduleConfig',
'x-component-props': {
}