feat(plugin-workflow): allow manual form button to be configured with preset values (#2225)

* refactor(client): split to small components

* fix(client): fix component warning

* feat(plugin-workflow): allow form button to be configured more than one for each type

* test(plugin-workflow): add test cases

* chore(plugin-workflow): add modal tips

* fix(plugin-workflow): fix test bugs

* fix(plugin-workflow): fix manual button configuration and params

* test(plugin-workflow): fix test cases

* fix(plugin-workflow): fix manual form values

* refactor(plugin-workflow): adjust component

* fix(plugin-workflow): fix typo

* refactor(plugin-workflow): avoid one more load when manual node resume

* fix(plugin-workflow): fix currentUser to be plain object

* chore(plugin-workflow): clean code

* fix(plugin-workflow): fix typo
This commit is contained in:
Junyi 2023-07-18 11:50:24 +07:00 committed by GitHub
parent 9f8460ca22
commit a17c1ad4e4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 1359 additions and 659 deletions

View File

@ -127,10 +127,10 @@ export const useSelfAndChildrenCollections = (collectionName: string) => {
return options;
};
export const useCollectionFilterOptions = (collectionName: string) => {
export const useCollectionFilterOptions = (collection: any) => {
const { getCollectionFields, getInterface } = useCollectionManager();
return useMemo(() => {
const fields = getCollectionFields(collectionName);
const fields = getCollectionFields(collection);
const field2option = (field, depth) => {
if (!field.interface) {
return;
@ -179,7 +179,7 @@ export const useCollectionFilterOptions = (collectionName: string) => {
};
const options = getOptions(fields, 1);
return options;
}, [collectionName]);
}, [collection]);
};
export const useLinkageCollectionFilterOptions = (collectionName: string) => {

View File

@ -103,6 +103,7 @@ export const ActionBar = observer(
alignItems: 'center',
width: '100%',
overflow: 'hidden',
flexWrap: 'wrap',
}}
>
<DndContext>

View File

@ -78,13 +78,13 @@ const filterOption = (input, option) => (option?.label ?? '').toLowerCase().incl
const InternalSelect = connect(
(props: Props) => {
const { objectValue, loading, value, ...others } = props;
const { objectValue, loading, value, rawOptions, ...others } = props;
let mode: any = props.multiple ? 'multiple' : props.mode;
if (mode && !['multiple', 'tags'].includes(mode)) {
mode = undefined;
}
if (objectValue) {
return <ObjectSelect {...others} value={value} mode={mode} loading={loading} />;
return <ObjectSelect rawOptions={rawOptions} {...others} value={value} mode={mode} loading={loading} />;
}
const toValue = (v) => {
if (['tags', 'multiple'].includes(props.mode) || props.multiple) {

View File

@ -26,7 +26,7 @@ export const Tabs: any = observer(
key,
label: <RecursionField name={key} schema={schema} onlyRenderSelf />,
children: (
<PaneRoot active={key === contextProps.activeKey}>
<PaneRoot {...(PaneRoot !== React.Fragment ? { active: key === contextProps.activeKey } : {})}>
<RecursionField name={key} schema={schema} onlyRenderProperties />
</PaneRoot>
),

View File

@ -1,3 +1,4 @@
import React, { createContext, useContext } from 'react';
import { connect, mapReadPretty } from '@formily/react';
import { IField } from '../../../collection-manager';
@ -6,6 +7,20 @@ import { JSONInput } from './JSONInput';
import { RawTextArea } from './RawTextArea';
import { TextArea } from './TextArea';
const VariableScopeContext = createContext([]);
export function VariableScopeProvider({ scope = [], children }) {
return (
<VariableScopeContext.Provider value={scope}>
{children}
</VariableScopeContext.Provider>
)
}
export function useVariableScope() {
return useContext(VariableScopeContext);
}
export function Variable() {
return null;
}

View File

@ -11,7 +11,7 @@ import {
useCollectionField,
useCollectionFilterOptions,
} from '../../../collection-manager';
import { Variable, useCompile, useComponent } from '../../../schema-component';
import { Variable, useCompile, useComponent, useVariableScope } from '../../../schema-component';
import { DeletedField } from '../DeletedField';
const InternalField: React.FC = (props) => {
@ -91,8 +91,9 @@ export const AssignedField = (props: any) => {
const collectionField = getField(fieldSchema.name);
const [options, setOptions] = useState<any[]>([]);
const collection = useCollection();
const fields = compile(useCollectionFilterOptions(collection?.name));
const fields = compile(useCollectionFilterOptions(collection));
const userFields = compile(useCollectionFilterOptions('users'));
const scope = useVariableScope();
useEffect(() => {
const opt = [
{
@ -113,8 +114,9 @@ export const AssignedField = (props: any) => {
children: null,
});
}
setOptions(opt);
}, [fields, userFields]);
const next = opt.concat(scope);
setOptions(next);
}, [fields, userFields, scope]);
return (
<Variable.Input

View File

@ -1,5 +1,5 @@
import { PlusOutlined } from '@ant-design/icons';
import { cx, useAPIClient, useCompile } from '@nocobase/client';
import { cx, css, useAPIClient, useCompile } from '@nocobase/client';
import { Button, Dropdown, MenuProps } from 'antd';
import React, { useCallback, useMemo } from 'react';
import { useFlowContext } from './FlowContext';
@ -82,7 +82,7 @@ export function AddButton({ upstream, branchIndex = null }: AddButtonProps) {
const menu = useMemo<MenuProps>(() => {
return {
onClick: (ev) => onCreate(ev),
onClick: onCreate,
items: compile(groups),
};
}, [groups, onCreate]);
@ -92,8 +92,18 @@ export function AddButton({ upstream, branchIndex = null }: AddButtonProps) {
}
return (
<div className={cx(styles.addButtonClass)}>
<Dropdown trigger={['click']} menu={menu} disabled={workflow.executed}>
<div className={styles.addButtonClass}>
<Dropdown
trigger={['click']}
menu={menu}
disabled={workflow.executed}
overlayClassName={css`
.ant-dropdown-menu-root{
max-height: 30em;
overflow-y: auto;
}
`}
>
<Button shape="circle" icon={<PlusOutlined />} />
</Dropdown>
</div>

View File

@ -14,14 +14,14 @@ function InnerCollectionBlockInitializer({ insert, collection, dataSource, ...pr
const { getTemplateSchemaByMode } = useSchemaTemplateManager();
const { getCollection } = useCollectionManager();
const items = useRecordCollectionDataSourceItems('FormItem') as SchemaInitializerItemOptions[];
const resovledCollection = getCollection(collection);
const resolvedCollection = getCollection(collection);
async function onConfirm({ item }) {
const template = item.template ? await getTemplateSchemaByMode(item) : null;
const result = {
type: 'void',
name: resovledCollection.name,
title: resovledCollection.title,
name: resolvedCollection.name,
title: resolvedCollection.title,
'x-decorator': 'DetailsBlockProvider',
'x-decorator-props': {
collection,

View File

@ -90,6 +90,8 @@ export default {
Manual: '人工处理',
'Could be used for manually submitting data, and determine whether to continue or exit. Workflow will generate a todo item for assigned user when it reaches a manual node, and continue processing after user submits the form.':
'可用于人工提交数据,并决定是否继续或退出流程。工作流在执行到人工节点时会为被指派的用户生成待办事项,直到用户提交对应表单后继续处理该流程。',
'Values preset in this form will override user submitted ones when continue or reject.':
'表单中预设的字段值会在用户提交继续或拒绝时覆盖相应字段的值。',
'Extended types': '扩展类型',
'Node type': '节点类型',
Calculation: '运算',

View File

@ -13,7 +13,7 @@ import {
import { collection, filter } from '../schemas/collection';
import { NAMESPACE, lang } from '../locale';
import { FilterDynamicComponent } from '../components/FilterDynamicComponent';
import { BaseTypeSets, nodesOptions, triggerOptions, useWorkflowVariableOptions } from '../variable';
import { BaseTypeSets, defaultFieldNames, nodesOptions, triggerOptions, useWorkflowVariableOptions } from '../variable';
import { FieldsSelect } from '../components/FieldsSelect';
import { ValueBlock } from '../components/ValueBlock';
import { useNodeContext } from '.';
@ -299,7 +299,7 @@ export default {
ValueBlock,
AssociatedConfig,
},
useVariables({ id, title }, { types }) {
useVariables({ id, title }, { types, fieldNames = defaultFieldNames }) {
if (
types &&
!types.some((type) => type in BaseTypeSets || Object.values(BaseTypeSets).some((set) => set.has(type)))
@ -307,8 +307,8 @@ export default {
return null;
}
return {
value: `${id}`,
label: title,
[fieldNames.value]: `${id}`,
[fieldNames.label]: title,
};
},
useInitializers(node): SchemaInitializerItemOptions | null {

View File

@ -1,5 +1,5 @@
import { FormItem, FormLayout } from '@formily/antd-v5';
import { SchemaInitializerItemOptions, Variable, css, useCollectionManager } from '@nocobase/client';
import { SchemaInitializerItemOptions, Variable, css, defaultFieldNames, useCollectionManager } from '@nocobase/client';
import { Evaluator, evaluators, getOptions } from '@nocobase/evaluators/client';
import { parse } from '@nocobase/utils/client';
import { Radio } from 'antd';
@ -192,8 +192,7 @@ export default {
RadioWithTooltip,
DynamicConfig,
},
useVariables({ id, title }, options) {
const { types } = options ?? {};
useVariables({ id, title }, { types, fieldNames = defaultFieldNames }) {
if (
types &&
!types.some((type) => type in BaseTypeSets || Object.values(BaseTypeSets).some((set) => set.has(type)))
@ -201,8 +200,8 @@ export default {
return null;
}
return {
value: id,
label: title,
[fieldNames.value]: `${id}`,
[fieldNames.label]: title,
};
},
useInitializers(node): SchemaInitializerItemOptions {

View File

@ -6,7 +6,7 @@ import { Branch } from '../Branch';
import { useFlowContext } from '../FlowContext';
import { NAMESPACE, lang } from '../locale';
import useStyles from '../style';
import { VariableOption, nodesOptions, triggerOptions, useWorkflowVariableOptions } from '../variable';
import { VariableOption, defaultFieldNames, nodesOptions, triggerOptions, useWorkflowVariableOptions } from '../variable';
function findOption(options: VariableOption[], paths: string[]) {
let opts = options;
@ -64,7 +64,7 @@ export default {
return (
<NodeDefaultView data={data}>
<div className={cx(styles.nodeSubtreeClass)}>
<div className={styles.nodeSubtreeClass}>
<div
className={cx(
styles.branchBlockClass,
@ -75,7 +75,7 @@ export default {
>
<Branch from={data} entry={entry} branchIndex={entry?.branchIndex ?? 0} />
<div className={cx(styles.branchClass)}>
<div className={styles.branchClass}>
<div className="workflow-branch-lines" />
<div className={cx(styles.addButtonClass, styles.loopLineClass)}>
<ArrowUpOutlined />
@ -103,13 +103,15 @@ export default {
return null;
}
const { fieldNames = defaultFieldNames } = options;
// const { workflow } = useFlowContext();
// const current = useNodeContext();
// const upstreams = useAvailableUpstreams(current);
// find target data model by path described in `config.target`
// 1. get options from $context/$jobsMapByNodeId
// 2. route to sub-options and use as loop target options
let targetOption: VariableOption = { key: 'item', value: 'item', label: lang('Loop target') };
let targetOption: VariableOption = { key: 'item', [fieldNames.value]: 'item', [fieldNames.label]: lang('Loop target') };
if (typeof target === 'string' && target.startsWith('{{') && target.endsWith('}}')) {
const paths = target
@ -120,10 +122,10 @@ export default {
const targetOptions = [nodesOptions, triggerOptions].map((item: any) => {
const opts = item.useOptions(options).filter(Boolean);
return {
label: compile(item.title),
value: item.value,
[fieldNames.label]: compile(item.title),
[fieldNames.value]: item.value,
key: item.value,
children: opts,
[fieldNames.children]: opts,
disabled: opts && !opts.length,
};
});
@ -135,8 +137,8 @@ export default {
return [
targetOption,
{ key: 'index', value: 'index', label: lang('Loop index') },
{ key: 'length', value: 'length', label: lang('Loop length') },
{ key: 'index', [fieldNames.value]: 'index', [fieldNames.label]: lang('Loop index') },
{ key: 'length', [fieldNames.value]: 'length', [fieldNames.label]: lang('Loop length') },
];
},
};

View File

@ -34,11 +34,8 @@ function InternalFormBlockInitializer({ insert, schema, ...others }) {
type: 'primary',
useAction: '{{ useSubmit }}',
},
'x-designer': 'Action.Designer',
'x-designer-props': {
type: 'record',
},
'x-action': `${JOB_STATUS.RESOLVED}`,
'x-designer': 'ManualActionDesigner',
'x-designer-props': {},
},
},
...schema,
@ -48,6 +45,10 @@ function InternalFormBlockInitializer({ insert, schema, ...others }) {
delete result['x-acl-action'];
const [formKey] = Object.keys(result.properties);
result.properties[formKey].properties.actions['x-decorator'] = 'ActionBarProvider';
result.properties[formKey].properties.actions['x-component-props'].style = {
marginTop: '1.5em',
flexWrap: 'wrap',
};
traverseSchema(result, (node) => {
if (node['x-uid']) {
delete node['x-uid'];

View File

@ -1,8 +1,12 @@
import { default as React, useContext, useMemo, useState } from 'react';
import { ISchema, Schema, useFieldSchema, useForm } from '@formily/react';
import React, { useContext, useEffect, useMemo, useState } from 'react';
import { createForm } from '@formily/core';
import { FormProvider, ISchema, Schema, useFieldSchema, useForm } from '@formily/react';
import { FormLayout } from '@formily/antd-v5';
import { Alert, Button, Modal, Space } from 'antd';
import { useTranslation } from 'react-i18next';
import {
Action,
ActionContextProvider,
GeneralSchemaDesigner,
InitializerWithSwitch,
@ -12,22 +16,24 @@ import {
SchemaInitializerItemOptions,
SchemaInitializerProvider,
SchemaSettings,
VariableScopeProvider,
gridRowColWrap,
useCompile,
useFormBlockContext,
useSchemaOptionsContext,
} from '@nocobase/client';
import { Registry, lodash } from '@nocobase/utils/client';
import { Button } from 'antd';
import { instructions, useAvailableUpstreams, useNodeContext } from '..';
import { useFlowContext } from '../../FlowContext';
import { JOB_STATUS } from '../../constants';
import { useFlowContext } from '../../FlowContext';
import { NAMESPACE, lang } from '../../locale';
import { useTrigger } from '../../triggers';
import { DetailsBlockProvider } from './DetailsBlockProvider';
import { FormBlockProvider } from './FormBlockProvider';
import createForm from './forms/create';
import customForm from './forms/custom';
import updateForm from './forms/update';
import createRecordForm from './forms/create';
import customRecordForm from './forms/custom';
import updateRecordForm from './forms/update';
import { useWorkflowVariableOptions } from '../../variable';
type ValueOf<T> = T[keyof T];
@ -68,9 +74,9 @@ export type ManualFormType = {
export const manualFormTypes = new Registry<ManualFormType>();
manualFormTypes.register('customForm', customForm);
manualFormTypes.register('createForm', createForm);
manualFormTypes.register('updateForm', updateForm);
manualFormTypes.register('customForm', customRecordForm);
manualFormTypes.register('createForm', createRecordForm);
manualFormTypes.register('updateForm', updateRecordForm);
function useTriggerInitializers(): SchemaInitializerItemOptions | null {
const { workflow } = useFlowContext();
@ -79,7 +85,7 @@ function useTriggerInitializers(): SchemaInitializerItemOptions | null {
}
const blockTypeNames = {
customForm: customForm.title,
customForm: customRecordForm.title,
record: `{{t("Data record", { ns: "${NAMESPACE}" })}}`,
};
@ -159,6 +165,142 @@ function AddBlockButton(props: any) {
return <SchemaInitializer.Button {...props} wrap={gridRowColWrap} items={items} title="{{t('Add block')}}" />;
}
function AssignedFieldValues() {
const ctx = useContext(SchemaComponentContext);
const { t } = useTranslation();
const fieldSchema = useFieldSchema();
const scope = useWorkflowVariableOptions({ fieldNames: { label: 'title', value: 'name' } });
const [open, setOpen] = useState(false);
const [initialSchema, setInitialSchema] = useState(fieldSchema?.['x-action-settings']?.assignedValues?.schema ?? {
type: 'void',
'x-component': 'Grid',
'x-initializer': 'CustomFormItemInitializers',
properties: {},
});
const [schema, setSchema] = useState<Schema>(null);
const { components } = useSchemaOptionsContext();
useEffect(() => {
setSchema(new Schema({
properties: {
grid: initialSchema
},
}));
}, [initialSchema]);
const form = useMemo(
() => {
const initialValues = fieldSchema?.['x-action-settings']?.assignedValues?.values;
return createForm({
initialValues: lodash.cloneDeep(initialValues),
values: lodash.cloneDeep(initialValues),
});
},
[],
);
const title = t('Assign field values');
function onCancel() {
setOpen(false);
}
function onSubmit() {
if (!fieldSchema['x-action-settings']) {
fieldSchema['x-action-settings'] = {};
}
if (!fieldSchema['x-action-settings'].assignedValues) {
fieldSchema['x-action-settings'].assignedValues = {};
}
fieldSchema['x-action-settings'].assignedValues.schema = initialSchema;
fieldSchema['x-action-settings'].assignedValues.values = form.values;
setOpen(false);
setTimeout(() => {
ctx.refresh?.();
}, 300);
}
return (
<>
<SchemaSettings.Item onClick={() => setOpen(true)}>
{title}
</SchemaSettings.Item>
<Modal
width={'50%'}
title={title}
open={open}
onCancel={onCancel}
footer={
<Space>
<Button onClick={onCancel}>{t('Cancel')}</Button>
<Button type="primary" onClick={onSubmit}>{t('Submit')}</Button>
</Space>
}
>
<VariableScopeProvider scope={scope}>
<FormProvider form={form}>
<FormLayout layout={'vertical'}>
<Alert message={lang('Values preset in this form will override user submitted ones when continue or reject.')} />
<br />
{open && schema && (
<SchemaComponentContext.Provider
value={{
...ctx,
refresh() {
setInitialSchema(lodash.get(schema.toJSON(), 'properties.grid'));
}
}}
>
<SchemaComponent schema={schema} components={components} />
</SchemaComponentContext.Provider>
)}
</FormLayout>
</FormProvider>
</VariableScopeProvider>
</Modal>
</>
);
}
function ManualActionDesigner(props) {
return (
<GeneralSchemaDesigner {...props} disableInitializer>
<Action.Designer.ButtonEditor />
<AssignedFieldValues />
<SchemaSettings.Divider />
<SchemaSettings.Remove
removeParentsIfNoChildren
breakRemoveOn={{
'x-component': 'ActionBar',
}}
/>
</GeneralSchemaDesigner>
);
}
function ContinueInitializer({ action, actionProps, insert, ...props }) {
return (
<SchemaInitializer.Item
{...props}
onClick={() => {
insert({
type: 'void',
title: props.title,
'x-decorator': 'ManualActionStatusProvider',
'x-decorator-props': {
value: action,
},
'x-component': 'Action',
'x-component-props': {
...actionProps,
useAction: '{{ useSubmit }}',
},
'x-designer': 'ManualActionDesigner',
'x-action-settings': {},
});
}}
/>
);
}
function ActionInitializer({ action, actionProps, ...props }) {
return (
<InitializerWithSwitch
@ -192,7 +334,7 @@ function AddActionButton(props) {
key: JOB_STATUS.RESOLVED,
type: 'item',
title: `{{t("Continue the process", { ns: "${NAMESPACE}" })}}`,
component: ActionInitializer,
component: ContinueInitializer,
action: JOB_STATUS.RESOLVED,
actionProps: {
type: 'primary',
@ -205,7 +347,6 @@ function AddActionButton(props) {
component: ActionInitializer,
action: JOB_STATUS.REJECTED,
actionProps: {
type: 'primary',
danger: true,
},
},
@ -342,6 +483,7 @@ export function SchemaConfig({ value, onChange }) {
return props.children;
},
SimpleDesigner,
ManualActionDesigner,
}}
scope={{
useSubmit,

View File

@ -1,24 +1,23 @@
import { css } from '@emotion/css';
import { observer, useField, useFieldSchema, useForm } from '@formily/react';
import { dayjs } from '@nocobase/utils/client';
import { Spin, Tag } from 'antd';
import { Space, Spin, Tag } from 'antd';
import React, { createContext, useContext, useEffect, useState } from 'react';
import {
CollectionManagerProvider,
FormBlockContext,
SchemaComponent,
SchemaComponentContext,
TableBlockProvider,
useAPIClient,
useActionContext,
useCollectionManager,
useCompile,
useCurrentUserContext,
useFormBlockContext,
useRecord,
useTableBlockContext,
} from '@nocobase/client';
import { uid } from '@nocobase/utils/client';
import { instructions, useAvailableUpstreams } from '..';
import { FlowContext, useFlowContext } from '../../FlowContext';
import { JobStatusOptions, JobStatusOptionsMap } from '../../constants';
@ -177,7 +176,6 @@ const todoCollection = {
'x-component-props': {
showTime: true,
},
'x-read-pretty': true,
},
},
],
@ -217,8 +215,6 @@ export const WorkflowTodo: React.FC & { Drawer: React.FC; Decorator: React.FC }
}}
schema={{
type: 'void',
// name: uid(),
'x-component': 'div',
properties: {
actions: {
type: 'void',
@ -293,7 +289,7 @@ export const WorkflowTodo: React.FC & { Drawer: React.FC; Decorator: React.FC }
'x-component': 'TableV2.Column',
properties: {
createdAt: {
type: 'number',
type: 'string',
'x-component': 'CollectionField',
'x-read-pretty': true,
},
@ -317,7 +313,6 @@ export const WorkflowTodo: React.FC & { Drawer: React.FC; Decorator: React.FC }
'x-component': 'TableV2.Column',
properties: {
status: {
type: 'number',
'x-component': 'CollectionField',
'x-read-pretty': true,
},
@ -382,13 +377,14 @@ const ManualActionStatusContext = createContext<number | null>(null);
function ManualActionStatusProvider({ value, children }) {
const { userJob } = useFlowContext();
const button = useField();
const buttonSchema = useFieldSchema();
useEffect(() => {
if (userJob.status) {
button.disabled = true;
button.visible = userJob.status === value;
button.visible = userJob.status === value && userJob.result._ === buttonSchema.name;
}
}, [userJob.status, value, button]);
}, [userJob, value, button]);
return <ManualActionStatusContext.Provider value={value}>{children}</ManualActionStatusContext.Provider>;
}
@ -398,21 +394,21 @@ function useSubmit() {
const { setVisible } = useActionContext();
const { values, submit } = useForm();
const buttonSchema = useFieldSchema();
const nextStatus = useContext(ManualActionStatusContext);
const { service } = useTableBlockContext();
const { userJob } = useFlowContext();
const { updateAssociationValues } = useContext(FormBlockContext);
const { name: actionKey } = buttonSchema;
const { name: formKey } = buttonSchema.parent.parent;
return {
async run() {
if (userJob.status) {
return;
}
await submit();
const { name } = buttonSchema.parent.parent.toJSON();
await api.resource('users_jobs').submit({
filterByTk: userJob.id,
values: {
status: nextStatus,
result: { [name]: values },
result: { [formKey]: values, _: actionKey },
},
updateAssociationValues,
});
setVisible(false);
service.refresh();
@ -520,40 +516,33 @@ function useDetailsBlockProps() {
return { form };
}
function FooterStatus() {
const compile = useCompile();
const { status, updatedAt } = useRecord();
const statusOption = JobStatusOptionsMap[status];
return status ? (
<Space>
<time
className={css`
margin-right: 0.5em;
`}
>
{dayjs(updatedAt).format('YYYY-MM-DD HH:mm:ss')}
</time>
<Tag icon={statusOption.icon} color={statusOption.color}>{compile(statusOption.label)}</Tag>
</Space>
) : null;
}
function Drawer() {
const ctx = useContext(SchemaComponentContext);
const { id, node, workflow, status, updatedAt } = useRecord();
const statusOption = JobStatusOptionsMap[status];
const footerSchema = status
? {
date: {
type: 'void',
'x-component': 'time',
'x-component-props': {
className: css`
margin-right: 0.5em;
`,
},
'x-content': dayjs(updatedAt).format('YYYY-MM-DD HH:mm:ss'),
},
status: {
type: 'void',
'x-component': 'Tag',
'x-component-props': {
icon: statusOption.icon,
color: statusOption.color,
},
'x-content': statusOption.label,
},
}
: null;
const { id, node, workflow, status } = useRecord();
return (
<SchemaComponentContext.Provider value={{ ...ctx, reset() {}, designable: false }}>
<SchemaComponent
components={{
Tag,
FooterStatus,
FlowContextProvider,
}}
schema={{
@ -572,7 +561,12 @@ function Drawer() {
footer: {
type: 'void',
'x-component': 'Action.Drawer.Footer',
properties: footerSchema,
properties: {
content: {
type: 'void',
'x-component': 'FooterStatus',
}
},
},
},
}}
@ -599,10 +593,16 @@ function Decorator({ params = {}, children }) {
dragSort: false,
};
[nodeCollection, workflowCollection, todoCollection].forEach((collection) => {
if (!collections.find((item) => item.name === collection.name)) {
collections.push(collection);
}
});
return (
<CollectionManagerProvider
{...cm}
collections={[...collections, nodeCollection, workflowCollection, todoCollection]}
collections={[...collections]}
>
<TableBlockProvider {...blockProps}>{children}</TableBlockProvider>
</CollectionManagerProvider>

View File

@ -71,7 +71,11 @@ export default {
type: 'create',
title: formBlock['x-component-props']?.title || formKey,
actions: findSchema(formSchema.properties.actions, (item) => item['x-component'] === 'Action').map(
(item) => item['x-decorator-props'].value,
(item) => ({
status: item['x-decorator-props'].value,
values: item['x-action-settings']?.assignedValues?.values,
key: item.name,
}),
),
collection: formBlock['x-decorator-props'].collection,
};

View File

@ -105,6 +105,7 @@ function CustomFormBlockInitializer({ insert, ...props }) {
layout: 'one-column',
style: {
marginTop: '1.5em',
flexWrap: 'wrap',
},
},
'x-initializer': 'AddActionButton',
@ -121,8 +122,7 @@ function CustomFormBlockInitializer({ insert, ...props }) {
type: 'primary',
useAction: '{{ useSubmit }}',
},
'x-designer': 'Action.Designer',
'x-action': `${JOB_STATUS.RESOLVED}`,
'x-designer': 'ManualActionDesigner',
},
},
},
@ -371,7 +371,11 @@ export default {
type: 'custom',
title: formBlock['x-component-props']?.title || formKey,
actions: findSchema(formSchema.properties.actions, (item) => item['x-component'] === 'Action').map(
(item) => item['x-decorator-props'].value,
(item) => ({
status: item['x-decorator-props'].value,
values: item['x-action-settings']?.assignedValues?.values,
key: item.name,
}),
),
collection: formBlock['x-decorator-props'].collection,
};

View File

@ -114,7 +114,11 @@ export default {
type: 'update',
title: formBlock['x-component-props']?.title || formKey,
actions: findSchema(formSchema.properties.actions, (item) => item['x-component'] === 'Action').map(
(item) => item['x-decorator-props'].value,
(item) => ({
status: item['x-decorator-props'].value,
values: item['x-action-settings']?.assignedValues?.values,
key: item.name,
}),
),
};
});

View File

@ -1,7 +1,7 @@
import { BlockInitializers, SchemaInitializerItemOptions, useCollectionManager, useCompile } from '@nocobase/client';
import { CollectionBlockInitializer } from '../../components/CollectionBlockInitializer';
import { getCollectionFieldOptions } from '../../variable';
import { defaultFieldNames, getCollectionFieldOptions } from '../../variable';
import { NAMESPACE } from '../../locale';
import { SchemaConfig, SchemaConfigButton } from './SchemaConfig';
import { ModeConfig } from './ModeConfig';
@ -85,7 +85,7 @@ export default {
ModeConfig,
AssigneesSelect,
},
useVariables({ id, title, config }, { types }) {
useVariables({ id, title, config }, { types, fieldNames = defaultFieldNames }) {
const compile = useCompile();
const { getCollectionFields } = useCollectionManager();
const formKeys = Object.keys(config.forms ?? {});
@ -119,9 +119,9 @@ export default {
return options.length
? {
value: `${id}`,
label: title,
children: options,
[fieldNames.value]: `${id}`,
[fieldNames.label]: title,
[fieldNames.children]: options,
}
: null;
},

View File

@ -4,7 +4,7 @@ import { ScheduleConfig } from './ScheduleConfig';
import { SCHEDULE_MODE } from './constants';
import { NAMESPACE, lang } from '../../locale';
import { CollectionBlockInitializer } from '../../components/CollectionBlockInitializer';
import { getCollectionFieldOptions } from '../../variable';
import { defaultFieldNames, getCollectionFieldOptions } from '../../variable';
import { FieldsSelect } from '../../components/FieldsSelect';
export default {
@ -26,6 +26,7 @@ export default {
useVariables(config, opts) {
const compile = useCompile();
const { getCollectionFields } = useCollectionManager();
const { fieldNames = defaultFieldNames } = opts;
const options: any[] = [];
if (!opts?.types || opts.types.includes('date')) {
options.push({ key: 'date', value: 'date', label: lang('Trigger time') });

View File

@ -6,17 +6,37 @@ import { triggers } from './triggers';
export type VariableOption = {
key?: string;
value: string;
label: string;
value?: string;
label?: string;
children?: VariableOptions;
[key: string]: any;
};
export type VariableOptions = VariableOption[] | null;
export type VariableDataType =
string |
{
type: string;
options?: { entity?: boolean; collection?: string }
} |
((field: any, appends?: string[]) => boolean);
export type OptionsOfUseVariableOptions = {
types?: VariableDataType[];
fieldNames?: {
label?: string;
value?: string;
children?: string;
};
}
export const defaultFieldNames = { label: 'label', value: 'value', children: 'children' } as const;
export const nodesOptions = {
label: `{{t("Node result", { ns: "${NAMESPACE}" })}}`,
value: '$jobsMapByNodeId',
useOptions(options) {
useOptions(options: OptionsOfUseVariableOptions) {
const current = useNodeContext();
const upstreams = useAvailableUpstreams(current);
const result: VariableOption[] = [];
@ -34,7 +54,7 @@ export const nodesOptions = {
export const triggerOptions = {
label: `{{t("Trigger variables", { ns: "${NAMESPACE}" })}}`,
value: '$context',
useOptions(options) {
useOptions(options: OptionsOfUseVariableOptions) {
const { workflow } = useFlowContext();
const trigger = triggers.get(workflow.type);
return trigger?.useVariables?.(workflow.config, options) ?? null;
@ -44,7 +64,8 @@ export const triggerOptions = {
export const scopeOptions = {
label: `{{t("Scope variables", { ns: "${NAMESPACE}" })}}`,
value: '$scopes',
useOptions(options) {
useOptions(options: OptionsOfUseVariableOptions) {
const { fieldNames = defaultFieldNames } = options;
const current = useNodeContext();
const scopes = useUpstreamScopes(current);
const result: VariableOption[] = [];
@ -54,9 +75,9 @@ export const scopeOptions = {
if (subOptions) {
result.push({
key: node.id.toString(),
value: node.id.toString(),
label: node.title ?? `#${node.id}`,
children: subOptions,
[fieldNames.value]: node.id.toString(),
[fieldNames.label]: node.title ?? `#${node.id}`,
[fieldNames.children]: subOptions,
});
}
});
@ -67,14 +88,14 @@ export const scopeOptions = {
export const systemOptions = {
label: `{{t("System variables", { ns: "${NAMESPACE}" })}}`,
value: '$system',
useOptions({ types }) {
useOptions({ types, fieldNames = defaultFieldNames }: OptionsOfUseVariableOptions) {
return [
...(!types || types.includes('date')
? [
{
key: 'now',
value: 'now',
label: lang('System time'),
[fieldNames.label]: lang('System time'),
[fieldNames.value]: 'now',
},
]
: []),
@ -169,16 +190,18 @@ function filterTypedFields({ fields, types, appends, compile, getCollectionField
});
}
export function useWorkflowVariableOptions(options = {}) {
export function useWorkflowVariableOptions(options: OptionsOfUseVariableOptions = {}) {
const fieldNames = Object.assign({}, defaultFieldNames, options.fieldNames ?? {});
const opts = Object.assign(options, { fieldNames });
const compile = useCompile();
const result = [scopeOptions, nodesOptions, triggerOptions, systemOptions].map((item: any) => {
const opts = item.useOptions(options).filter(Boolean);
const children = item.useOptions(opts).filter(Boolean);
return {
label: compile(item.label),
value: item.value,
key: item.value,
children: opts,
disabled: opts && !opts.length,
[fieldNames.label]: compile(item.label),
[fieldNames.value]: item.value,
key: item[fieldNames.value],
[fieldNames.children]: children,
disabled: children && !children.length,
};
});
@ -245,15 +268,13 @@ async function loadChildren(option) {
collection: option.field.target,
types: option.types,
appends: getNextAppends(option.field, option.appends),
sourceKey: option.field.key,
compile: this.compile,
getCollectionFields: this.getCollectionFields,
...this,
});
option.loadChildren = null;
if (result.length) {
option.children = result;
} else {
option.isLeaf = true;
option.loadChildren = null;
const matchingType = option.types?.some((type) => matchFieldType(option.field, type, 0));
if (!matchingType) {
option.disabled = true;
@ -262,10 +283,10 @@ async function loadChildren(option) {
}
export function getCollectionFieldOptions(options): VariableOption[] {
const { fields, collection, types, appends = [], compile, getCollectionFields } = options;
const { fields, collection, types, appends = [], compile, getCollectionFields, fieldNames = defaultFieldNames } = options;
const normalizedFields = getNormalizedFields(collection, { compile, getCollectionFields });
const computedFields = fields ?? normalizedFields;
const boundLoadChildren = loadChildren.bind({ compile, getCollectionFields });
const boundLoadChildren = loadChildren.bind({ compile, getCollectionFields, fieldNames });
const result: VariableOption[] = filterTypedFields({
fields: computedFields,
@ -276,13 +297,14 @@ export function getCollectionFieldOptions(options): VariableOption[] {
getCollectionFields,
}).map((field) => {
const label = compile(field.uiSchema?.title || field.name);
// console.log('===', label, field);
const nextAppends = getNextAppends(field, appends);
// TODO: no matching fields in next appends should consider isLeaf as true
const isLeaf = !isAssociationField(field) || (!nextAppends.length && !appends.includes(field.name));
return {
label,
[fieldNames.label]: label,
key: field.name,
value: field.name,
[fieldNames.value]: field.name,
isLeaf,
loadChildren: isLeaf ? null : boundLoadChildren,
field,

View File

@ -343,9 +343,9 @@ export default class Processor {
};
}
public getParsedValue(value, node?) {
public getParsedValue(value, node?, additionalScope?: object) {
const template = parse(value);
const scope = this.getScope(node);
const scope = Object.assign(this.getScope(node), additionalScope);
template.parameters.forEach(({ key }) => {
appendArrayColumn(scope, key);
});

View File

@ -51,6 +51,293 @@ describe('workflow > instructions > manual', () => {
afterEach(() => db.close());
describe('actions configuration', () => {
it('no action configured', async () => {
const n1 = await workflow.createNode({
type: 'manual',
config: {
assignees: [users[0].id],
forms: {
f1: {
},
},
},
});
const post = await PostRepo.create({ values: { title: 't1' } });
await sleep(500);
const [pending] = await workflow.getExecutions();
expect(pending.status).toBe(EXECUTION_STATUS.STARTED);
const [j1] = await pending.getJobs();
expect(j1.status).toBe(JOB_STATUS.PENDING);
const usersJobs = await UserJobModel.findAll();
expect(usersJobs.length).toBe(1);
expect(usersJobs[0].status).toBe(JOB_STATUS.PENDING);
expect(usersJobs[0].userId).toBe(users[0].id);
expect(usersJobs[0].jobId).toBe(j1.id);
const res1 = await userAgents[0].resource('users_jobs').submit({
filterByTk: usersJobs[0].id,
values: {
result: { f1: { a: 1 }, _: 'resolve' },
},
});
expect(res1.status).toBe(400);
});
it('no actionKey provided', async () => {
const n1 = await workflow.createNode({
type: 'manual',
config: {
assignees: [users[0].id],
forms: {
f1: {
actions: [
{ status: JOB_STATUS.RESOLVED, key: 'resolve' },
],
},
},
},
});
const post = await PostRepo.create({ values: { title: 't1' } });
await sleep(500);
const [pending] = await workflow.getExecutions();
expect(pending.status).toBe(EXECUTION_STATUS.STARTED);
const [j1] = await pending.getJobs();
expect(j1.status).toBe(JOB_STATUS.PENDING);
const usersJobs = await UserJobModel.findAll();
expect(usersJobs.length).toBe(1);
expect(usersJobs[0].status).toBe(JOB_STATUS.PENDING);
expect(usersJobs[0].userId).toBe(users[0].id);
expect(usersJobs[0].jobId).toBe(j1.id);
const res1 = await userAgents[0].resource('users_jobs').submit({
filterByTk: usersJobs[0].id,
values: {
result: { f1: { a: 1 } },
},
});
expect(res1.status).toBe(400);
});
it('values resolved will be overrided by action assigned', async () => {
const n1 = await workflow.createNode({
type: 'manual',
config: {
assignees: [users[0].id],
forms: {
f1: {
actions: [
{
status: JOB_STATUS.RESOLVED,
key: 'resolve',
values: { a: 2 },
},
],
},
},
},
});
const post = await PostRepo.create({ values: { title: 't1' } });
await sleep(500);
const [pending] = await workflow.getExecutions();
expect(pending.status).toBe(EXECUTION_STATUS.STARTED);
const [j1] = await pending.getJobs();
expect(j1.status).toBe(JOB_STATUS.PENDING);
const usersJobs = await UserJobModel.findAll();
expect(usersJobs.length).toBe(1);
expect(usersJobs[0].status).toBe(JOB_STATUS.PENDING);
expect(usersJobs[0].userId).toBe(users[0].id);
expect(usersJobs[0].jobId).toBe(j1.id);
const res1 = await userAgents[0].resource('users_jobs').submit({
filterByTk: usersJobs[0].id,
values: {
result: { f1: { a: 1 }, _: 'resolve' },
},
});
await sleep(500);
const [execution] = await workflow.getExecutions();
expect(execution.status).toBe(EXECUTION_STATUS.RESOLVED);
const [job] = await execution.getJobs();
expect(job.status).toBe(JOB_STATUS.RESOLVED);
expect(job.result).toEqual({ f1: { a: 2 }, _: 'resolve' });
});
it('values rejected will not be overrided by action assigned', async () => {
const n1 = await workflow.createNode({
type: 'manual',
config: {
assignees: [users[0].id],
forms: {
f1: {
actions: [
{
status: JOB_STATUS.REJECTED,
key: 'reject',
values: { a: 2 },
},
],
},
},
},
});
const post = await PostRepo.create({ values: { title: 't1' } });
await sleep(500);
const [pending] = await workflow.getExecutions();
expect(pending.status).toBe(EXECUTION_STATUS.STARTED);
const [j1] = await pending.getJobs();
expect(j1.status).toBe(JOB_STATUS.PENDING);
const usersJobs = await UserJobModel.findAll();
expect(usersJobs.length).toBe(1);
expect(usersJobs[0].status).toBe(JOB_STATUS.PENDING);
expect(usersJobs[0].userId).toBe(users[0].id);
expect(usersJobs[0].jobId).toBe(j1.id);
const res1 = await userAgents[0].resource('users_jobs').submit({
filterByTk: usersJobs[0].id,
values: {
result: { f1: { a: 1 }, _: 'reject' },
},
});
await sleep(500);
const [execution] = await workflow.getExecutions();
expect(execution.status).toBe(EXECUTION_STATUS.REJECTED);
const [job] = await execution.getJobs();
expect(job.status).toBe(JOB_STATUS.REJECTED);
expect(job.result).toEqual({ f1: { a: 1 }, _: 'reject' });
});
it('values saved as pending will not be overrided by action assigned', async () => {
const n1 = await workflow.createNode({
type: 'manual',
config: {
assignees: [users[0].id],
forms: {
f1: {
actions: [
{
status: JOB_STATUS.PENDING,
key: 'save',
values: { a: 2 },
},
],
},
},
},
});
const post = await PostRepo.create({ values: { title: 't1' } });
await sleep(500);
const [pending] = await workflow.getExecutions();
expect(pending.status).toBe(EXECUTION_STATUS.STARTED);
const [j1] = await pending.getJobs();
expect(j1.status).toBe(JOB_STATUS.PENDING);
const usersJobs = await UserJobModel.findAll();
expect(usersJobs.length).toBe(1);
expect(usersJobs[0].status).toBe(JOB_STATUS.PENDING);
expect(usersJobs[0].userId).toBe(users[0].id);
expect(usersJobs[0].jobId).toBe(j1.id);
const res1 = await userAgents[0].resource('users_jobs').submit({
filterByTk: usersJobs[0].id,
values: {
result: { f1: { a: 1 }, _: 'save' },
},
});
await sleep(500);
const [execution] = await workflow.getExecutions();
expect(execution.status).toBe(EXECUTION_STATUS.STARTED);
const [job] = await execution.getJobs();
expect(job.status).toBe(JOB_STATUS.PENDING);
expect(job.result).toEqual({ f1: { a: 1 }, _: 'save' });
});
it('variable within assigned values should work when resolve', async () => {
const n1 = await workflow.createNode({
type: 'manual',
config: {
assignees: [users[0].id],
forms: {
f1: {
actions: [
{
status: JOB_STATUS.RESOLVED,
key: 'resolve',
values: {
a: '{{currentUser.id}}',
b: '{{currentRecord.id}}',
c: '{{currentTime}}',
d: '{{$context.data.title}}',
},
},
],
},
},
},
});
const post = await PostRepo.create({ values: { title: 't1' } });
await sleep(500);
const [pending] = await workflow.getExecutions();
expect(pending.status).toBe(EXECUTION_STATUS.STARTED);
const [j1] = await pending.getJobs();
expect(j1.status).toBe(JOB_STATUS.PENDING);
const usersJobs = await UserJobModel.findAll();
expect(usersJobs.length).toBe(1);
expect(usersJobs[0].status).toBe(JOB_STATUS.PENDING);
expect(usersJobs[0].userId).toBe(users[0].id);
expect(usersJobs[0].jobId).toBe(j1.id);
const now = new Date();
const res1 = await userAgents[0].resource('users_jobs').submit({
filterByTk: usersJobs[0].id,
values: {
result: { f1: { a: 2, id: 3 }, _: 'resolve' },
},
});
await sleep(500);
const [execution] = await workflow.getExecutions();
expect(execution.status).toBe(EXECUTION_STATUS.RESOLVED);
const [job] = await execution.getJobs();
expect(job.status).toBe(JOB_STATUS.RESOLVED);
expect(job.result).toMatchObject({ f1: { a: users[0].id, id: 3, b: 3, d: post.title }, _: 'resolve' });
const time = new Date(job.result.f1.c);
expect(time.getTime() - now.getTime()).toBeLessThan(1000);
});
});
describe('mode: 0 (single record)', () => {
it('the only user assigned could submit', async () => {
const n1 = await workflow.createNode({
@ -59,7 +346,9 @@ describe('workflow > instructions > manual', () => {
assignees: [users[0].id],
forms: {
f1: {
actions: [JOB_STATUS.RESOLVED],
actions: [
{ status: JOB_STATUS.RESOLVED, key: 'resolve' },
],
},
},
},
@ -82,15 +371,14 @@ describe('workflow > instructions > manual', () => {
const res1 = await agent.resource('users_jobs').submit({
filterByTk: usersJobs[0].id,
values: { status: JOB_STATUS.RESOLVED },
values: { result: { f1: {}, _: 'resolve' } },
});
expect(res1.status).toBe(401);
const res2 = await userAgents[1].resource('users_jobs').submit({
filterByTk: usersJobs[0].id,
values: {
status: JOB_STATUS.RESOLVED,
result: { f1: {} },
result: { f1: {}, _: 'resolve' },
},
});
expect(res2.status).toBe(403);
@ -98,8 +386,7 @@ describe('workflow > instructions > manual', () => {
const res3 = await userAgents[0].resource('users_jobs').submit({
filterByTk: usersJobs[0].id,
values: {
status: JOB_STATUS.RESOLVED,
result: { f1: { a: 1 } },
result: { f1: { a: 1 }, _: 'resolve' },
},
});
expect(res3.status).toBe(202);
@ -108,18 +395,17 @@ describe('workflow > instructions > manual', () => {
const [j2] = await pending.getJobs();
expect(j2.status).toBe(JOB_STATUS.RESOLVED);
expect(j2.result).toEqual({ f1: { a: 1 } });
expect(j2.result).toEqual({ f1: { a: 1 }, _: 'resolve' });
const usersJobsAfter = await UserJobModel.findAll();
expect(usersJobsAfter.length).toBe(1);
expect(usersJobsAfter[0].status).toBe(JOB_STATUS.RESOLVED);
expect(usersJobsAfter[0].result).toEqual({ f1: { a: 1 } });
expect(usersJobsAfter[0].result).toEqual({ f1: { a: 1 }, _: 'resolve' });
const res4 = await userAgents[0].resource('users_jobs').submit({
filterByTk: usersJobs[0].id,
values: {
status: JOB_STATUS.RESOLVED,
result: { f1: { a: 2 } },
result: { f1: { a: 2 }, _: 'resolve' },
},
});
expect(res4.status).toBe(400);
@ -131,7 +417,11 @@ describe('workflow > instructions > manual', () => {
config: {
assignees: [users[0].id, users[1].id],
forms: {
f1: { actions: [JOB_STATUS.RESOLVED] },
f1: {
actions: [
{ status: JOB_STATUS.RESOLVED, key: 'resolve' },
]
},
},
},
});
@ -150,8 +440,7 @@ describe('workflow > instructions > manual', () => {
const res1 = await userAgents[1].resource('users_jobs').submit({
filterByTk: usersJobs.find((item) => item.userId === users[1].id).id,
values: {
status: JOB_STATUS.RESOLVED,
result: { f1: { a: 1 } },
result: { f1: { a: 1 }, _: 'resolve' },
},
});
expect(res1.status).toBe(202);
@ -160,13 +449,12 @@ describe('workflow > instructions > manual', () => {
const [j2] = await pending.getJobs();
expect(j2.status).toBe(JOB_STATUS.RESOLVED);
expect(j2.result).toEqual({ f1: { a: 1 } });
expect(j2.result).toEqual({ f1: { a: 1 }, _: 'resolve' });
const res2 = await userAgents[0].resource('users_jobs').submit({
filterByTk: usersJobs.find((item) => item.userId === users[0].id).id,
values: {
status: JOB_STATUS.RESOLVED,
result: { f1: { a: 1 } },
result: { f1: { a: 1 }, _: 'resolve' },
},
});
expect(res2.status).toBe(400);
@ -178,7 +466,11 @@ describe('workflow > instructions > manual', () => {
config: {
assignees: [users[0].id],
forms: {
f1: { actions: [JOB_STATUS.RESOLVED] },
f1: {
actions: [
{ status: JOB_STATUS.RESOLVED, key: 'resolve' },
],
},
},
},
});
@ -196,8 +488,7 @@ describe('workflow > instructions > manual', () => {
const res = await userAgents[0].resource('users_jobs').submit({
filterByTk: usersJobs[0].get('id'),
values: {
status: JOB_STATUS.RESOLVED,
result: { f1: { a: 1 } },
result: { f1: { a: 1 }, _: 'resolve' },
},
});
expect(res.status).toBe(202);
@ -208,7 +499,7 @@ describe('workflow > instructions > manual', () => {
expect(execution.status).toBe(EXECUTION_STATUS.RESOLVED);
const [job] = await execution.getJobs();
expect(job.status).toBe(JOB_STATUS.RESOLVED);
expect(job.result).toEqual({ f1: { a: 1 } });
expect(job.result).toEqual({ f1: { a: 1 }, _: 'resolve' });
});
});
@ -220,7 +511,11 @@ describe('workflow > instructions > manual', () => {
assignees: [users[0].id, users[1].id],
mode: 1,
forms: {
f1: { actions: [JOB_STATUS.RESOLVED] },
f1: {
actions: [
{ status: JOB_STATUS.RESOLVED, key: 'resolve' },
],
},
},
},
});
@ -238,8 +533,7 @@ describe('workflow > instructions > manual', () => {
const res1 = await userAgents[0].resource('users_jobs').submit({
filterByTk: pendingJobs[0].get('id'),
values: {
status: JOB_STATUS.RESOLVED,
result: { f1: { a: 1 } },
result: { f1: { a: 1 }, _: 'resolve' },
},
});
expect(res1.status).toBe(202);
@ -259,8 +553,7 @@ describe('workflow > instructions > manual', () => {
const res2 = await userAgents[1].resource('users_jobs').submit({
filterByTk: pendingJobs[1].get('id'),
values: {
status: JOB_STATUS.RESOLVED,
result: { f1: { a: 2 } },
result: { f1: { a: 2 }, _: 'resolve' },
},
});
expect(res2.status).toBe(202);
@ -281,7 +574,11 @@ describe('workflow > instructions > manual', () => {
assignees: [users[0].id, users[1].id],
mode: 1,
forms: {
f1: { actions: [JOB_STATUS.REJECTED] },
f1: {
actions: [
{ status: JOB_STATUS.REJECTED, key: 'reject' },
],
},
},
},
});
@ -299,8 +596,7 @@ describe('workflow > instructions > manual', () => {
const res1 = await userAgents[0].resource('users_jobs').submit({
filterByTk: pendingJobs[0].get('id'),
values: {
status: JOB_STATUS.REJECTED,
result: { f1: { a: 0 } },
result: { f1: { a: 0 }, _: 'reject' },
},
});
expect(res1.status).toBe(202);
@ -320,8 +616,7 @@ describe('workflow > instructions > manual', () => {
const res2 = await userAgents[1].resource('users_jobs').submit({
filterByTk: pendingJobs[1].get('id'),
values: {
status: JOB_STATUS.REJECTED,
result: { f1: { a: 0 } },
result: { f1: { a: 0 }, _: 'reject' },
},
});
expect(res2.status).toBe(400);
@ -334,7 +629,12 @@ describe('workflow > instructions > manual', () => {
assignees: [users[0].id, users[1].id],
mode: 1,
forms: {
f1: { actions: [JOB_STATUS.RESOLVED, JOB_STATUS.REJECTED] },
f1: {
actions: [
{ status: JOB_STATUS.RESOLVED, key: 'resolve' },
{ status: JOB_STATUS.REJECTED, key: 'reject' },
],
},
},
},
});
@ -352,8 +652,7 @@ describe('workflow > instructions > manual', () => {
const res1 = await userAgents[0].resource('users_jobs').submit({
filterByTk: pendingJobs[0].get('id'),
values: {
status: JOB_STATUS.RESOLVED,
result: { f1: { a: 1 } },
result: { f1: { a: 1 }, _: 'resolve' },
},
});
expect(res1.status).toBe(202);
@ -373,8 +672,7 @@ describe('workflow > instructions > manual', () => {
const res2 = await userAgents[1].resource('users_jobs').submit({
filterByTk: pendingJobs[1].get('id'),
values: {
status: JOB_STATUS.REJECTED,
result: { f1: { a: 0 } },
result: { f1: { a: 0 }, _: 'reject' },
},
});
expect(res2.status).toBe(202);
@ -397,7 +695,12 @@ describe('workflow > instructions > manual', () => {
assignees: [users[0].id, users[1].id],
mode: -1,
forms: {
f1: { actions: [JOB_STATUS.RESOLVED, JOB_STATUS.REJECTED] },
f1: {
actions: [
{ status: JOB_STATUS.RESOLVED, key: 'resolve' },
{ status: JOB_STATUS.REJECTED, key: 'reject' },
],
},
},
},
});
@ -415,8 +718,7 @@ describe('workflow > instructions > manual', () => {
const res1 = await userAgents[0].resource('users_jobs').submit({
filterByTk: pendingJobs[0].get('id'),
values: {
status: JOB_STATUS.RESOLVED,
result: { f1: { a: 1 } },
result: { f1: { a: 1 }, _: 'resolve' },
},
});
expect(res1.status).toBe(202);
@ -432,8 +734,7 @@ describe('workflow > instructions > manual', () => {
const res2 = await userAgents[1].resource('users_jobs').submit({
filterByTk: pendingJobs[1].get('id'),
values: {
status: JOB_STATUS.REJECTED,
result: { f1: { a: 0 } },
result: { f1: { a: 0 }, _: 'reject' },
},
});
expect(res2.status).toBe(400);
@ -446,7 +747,12 @@ describe('workflow > instructions > manual', () => {
assignees: [users[0].id, users[1].id],
mode: -1,
forms: {
f1: { actions: [JOB_STATUS.RESOLVED, JOB_STATUS.REJECTED] },
f1: {
actions: [
{ status: JOB_STATUS.RESOLVED, key: 'resolve' },
{ status: JOB_STATUS.REJECTED, key: 'reject' },
],
},
},
},
});
@ -464,8 +770,7 @@ describe('workflow > instructions > manual', () => {
const res1 = await userAgents[0].resource('users_jobs').submit({
filterByTk: pendingJobs[0].get('id'),
values: {
status: JOB_STATUS.REJECTED,
result: { f1: { a: 0 } },
result: { f1: { a: 0 }, _: 'reject' },
},
});
expect(res1.status).toBe(202);
@ -481,8 +786,7 @@ describe('workflow > instructions > manual', () => {
const res2 = await userAgents[1].resource('users_jobs').submit({
filterByTk: pendingJobs[1].get('id'),
values: {
status: JOB_STATUS.RESOLVED,
result: { f1: { a: 1 } },
result: { f1: { a: 1 }, _: 'resolve' },
},
});
expect(res2.status).toBe(202);
@ -503,7 +807,11 @@ describe('workflow > instructions > manual', () => {
assignees: [users[0].id, users[1].id],
mode: -1,
forms: {
f1: { actions: [JOB_STATUS.REJECTED] },
f1: {
actions: [
{ status: JOB_STATUS.REJECTED, key: 'reject' },
],
},
},
},
});
@ -521,8 +829,7 @@ describe('workflow > instructions > manual', () => {
const res1 = await userAgents[0].resource('users_jobs').submit({
filterByTk: pendingJobs[0].get('id'),
values: {
status: JOB_STATUS.REJECTED,
result: { f1: { a: 0 } },
result: { f1: { a: 0 }, _: 'reject' },
},
});
expect(res1.status).toBe(202);
@ -538,8 +845,7 @@ describe('workflow > instructions > manual', () => {
const res2 = await userAgents[1].resource('users_jobs').submit({
filterByTk: pendingJobs[1].get('id'),
values: {
status: JOB_STATUS.REJECTED,
result: { f1: { a: 0 } },
result: { f1: { a: 0 }, _: 'reject' },
},
});
expect(res2.status).toBe(202);
@ -565,7 +871,11 @@ describe('workflow > instructions > manual', () => {
config: {
assignees: [users[0].id, users[1].id],
forms: {
f1: { actions: [JOB_STATUS.RESOLVED] },
f1: {
actions: [
{ status: JOB_STATUS.RESOLVED, key: 'resolve' },
],
},
},
},
});
@ -594,8 +904,7 @@ describe('workflow > instructions > manual', () => {
const res1 = await userAgents[0].resource('users_jobs').submit({
filterByTk: pendingJobs[0].get('id'),
values: {
status: JOB_STATUS.RESOLVED,
result: { f1: { number: 1 } },
result: { f1: { number: 1 }, _: 'resolve' },
},
});
expect(res1.status).toBe(202);
@ -615,8 +924,18 @@ describe('workflow > instructions > manual', () => {
config: {
assignees: [users[0].id, users[1].id],
forms: {
f1: { actions: [JOB_STATUS.RESOLVED, JOB_STATUS.PENDING] },
f2: { actions: [JOB_STATUS.RESOLVED, JOB_STATUS.PENDING] },
f1: {
actions: [
{ status: JOB_STATUS.RESOLVED, key: 'resolve' },
{ status: JOB_STATUS.PENDING, key: 'pending' },
],
},
f2: {
actions: [
{ status: JOB_STATUS.RESOLVED, key: 'resolve' },
{ status: JOB_STATUS.PENDING, key: 'pending' },
],
},
},
},
});
@ -634,8 +953,7 @@ describe('workflow > instructions > manual', () => {
const res1 = await userAgents[0].resource('users_jobs').submit({
filterByTk: pendingJobs[0].get('id'),
values: {
status: JOB_STATUS.PENDING,
result: { f1: { number: 1 } },
result: { f1: { number: 1 }, _: 'pending' },
},
});
expect(res1.status).toBe(202);
@ -651,8 +969,7 @@ describe('workflow > instructions > manual', () => {
const res2 = await userAgents[0].resource('users_jobs').submit({
filterByTk: pendingJobs[0].get('id'),
values: {
status: JOB_STATUS.PENDING,
result: { f2: { number: 2 } },
result: { f2: { number: 2 }, _: 'pending' },
},
});
expect(res2.status).toBe(202);
@ -671,8 +988,7 @@ describe('workflow > instructions > manual', () => {
const res3 = await userAgents[0].resource('users_jobs').submit({
filterByTk: pendingJobs[0].get('id'),
values: {
status: JOB_STATUS.RESOLVED,
result: { f2: { number: 3 } },
result: { f2: { number: 3 }, _: 'resolve' },
},
});
expect(res3.status).toBe(202);
@ -697,7 +1013,9 @@ describe('workflow > instructions > manual', () => {
forms: {
f1: {
type: 'create',
actions: [JOB_STATUS.RESOLVED],
actions: [
{ status: JOB_STATUS.RESOLVED, key: 'resolve' },
],
collection: 'comments',
},
},
@ -717,8 +1035,7 @@ describe('workflow > instructions > manual', () => {
const res1 = await userAgents[0].resource('users_jobs').submit({
filterByTk: pendingJobs[0].get('id'),
values: {
status: JOB_STATUS.RESOLVED,
result: { f1: { status: 1 } },
result: { f1: { status: 1 }, _: 'resolve' },
},
});
expect(res1.status).toBe(202);
@ -744,7 +1061,10 @@ describe('workflow > instructions > manual', () => {
forms: {
f1: {
type: 'create',
actions: [JOB_STATUS.RESOLVED, JOB_STATUS.PENDING],
actions: [
{ status: JOB_STATUS.RESOLVED, key: 'resolve' },
{ status: JOB_STATUS.PENDING, key: 'pending' },
],
collection: 'comments',
},
},
@ -764,8 +1084,7 @@ describe('workflow > instructions > manual', () => {
const res1 = await userAgents[0].resource('users_jobs').submit({
filterByTk: pendingJobs[0].get('id'),
values: {
status: JOB_STATUS.PENDING,
result: { f1: { status: 1 } },
result: { f1: { status: 1 }, _: 'pending' },
},
});
expect(res1.status).toBe(202);
@ -784,8 +1103,7 @@ describe('workflow > instructions > manual', () => {
const res2 = await userAgents[0].resource('users_jobs').submit({
filterByTk: pendingJobs[0].get('id'),
values: {
status: JOB_STATUS.RESOLVED,
result: { f1: { status: 1 } },
result: { f1: { status: 1 }, _: 'resolve' },
},
});
@ -811,7 +1129,9 @@ describe('workflow > instructions > manual', () => {
forms: {
f1: {
type: 'update',
actions: [JOB_STATUS.RESOLVED],
actions: [
{ status: JOB_STATUS.RESOLVED, key: 'resolve' },
],
collection: 'posts',
},
},
@ -831,8 +1151,7 @@ describe('workflow > instructions > manual', () => {
const res1 = await userAgents[0].resource('users_jobs').submit({
filterByTk: pendingJobs[0].get('id'),
values: {
status: JOB_STATUS.RESOLVED,
result: { f1: { title: 't2' } },
result: { f1: { title: 't2' }, _: 'resolve' },
},
});
expect(res1.status).toBe(202);

View File

@ -30,15 +30,17 @@ export async function submit(context: Context, next) {
}
const { forms = {} } = userJob.node.config;
const [formKey] = Object.keys(values.result ?? {});
const [formKey] = Object.keys(values.result ?? {}).filter(key => key !== '_');
const actionKey = values.result?._;
const actionItem = forms[formKey]?.actions?.find((item) => item.key === actionKey);
// NOTE: validate status
if (
userJob.status !== JOB_STATUS.PENDING ||
userJob.job.status !== JOB_STATUS.PENDING ||
userJob.execution.status !== EXECUTION_STATUS.STARTED ||
!userJob.workflow.enabled ||
!forms[formKey]?.actions?.includes(values.status)
!actionKey || actionItem?.status == null
) {
return context.throw(400);
}
@ -52,10 +54,17 @@ export async function submit(context: Context, next) {
if (!assignees.includes(currentUser.id) || userJob.userId !== currentUser.id) {
return context.throw(403);
}
const presetValues = processor.getParsedValue(actionItem.values ?? {}, null, {
currentUser: currentUser.toJSON(),
currentRecord: values.result[formKey],
currentTime: new Date(),
});
userJob.set({
status: values.status,
result: values.status ? values.result : Object.assign(userJob.result ?? {}, values.result),
status: actionItem.status,
result: actionItem.status > JOB_STATUS.PENDING
? { [formKey]: Object.assign(values.result[formKey], presetValues), _: actionKey }
: Object.assign(userJob.result ?? {}, values.result),
});
const handler = instruction.formTypes.get(forms[formKey].type);
@ -72,8 +81,11 @@ export async function submit(context: Context, next) {
await next();
userJob.job.execution = userJob.execution;
userJob.job.latestUserJob = userJob;
// NOTE: resume the process and no `await` for quick returning
processor.logger.info(`manual node (${userJob.nodeId}) action trigger execution (${userJob.execution.id}) to resume`);
plugin.resume(userJob.job);
}

View File

@ -7,7 +7,8 @@ export default async function (this: ManualInstruction, instance, { collection }
throw new Error(`collection ${collection} for create data on manual node not found`);
}
const [values] = Object.values(instance.result);
const { _, ...form } = instance.result;
const [values] = Object.values(form);
await repo.create({
values: {
...((values as { [key: string]: any }) ?? {}),

View File

@ -7,11 +7,12 @@ export default async function (this: ManualInstruction, instance, { collection,
throw new Error(`collection ${collection} for update data on manual node not found`);
}
const [values] = Object.values(instance.result as { [formKey: string]: { [key: string]: any } });
const { _, ...form } = instance.result;
const [values] = Object.values(form);
await repo.update({
filter: processor.getParsedValue(filter),
values: {
...(values ?? {}),
...((values as { [key: string]: any }) ?? {}),
updatedBy: instance.userId,
},
context: {

View File

@ -0,0 +1,78 @@
import { Migration } from '@nocobase/server';
function findSchema(root, filter, onlyLeaf = false) {
const result = [];
if (!root) {
return result;
}
if (filter(root) && (!onlyLeaf || !root.properties)) {
result.push(root);
return result;
}
if (root.properties) {
Object.keys(root.properties).forEach((key) => {
result.push(...findSchema(root.properties[key], filter));
});
}
return result;
}
function migrateConfig(config): object {
const { forms = {}, schema = {} } = config;
const root = { properties: schema };
Object.keys(forms).forEach((key) => {
const form = forms[key];
const formSchema = findSchema(root, (item) => item.name === key);
const actions = findSchema(formSchema[0], (item) => item['x-component'] === 'Action');
form.actions = actions.map((action) => {
action['x-designer'] = 'ManualActionDesigner';
action['x-action-settings'] = {};
delete action['x-action'];
return {
status: action['x-decorator-props'].value,
values: {},
key: action.name,
};
});
});
return config;
}
export default class extends Migration {
async up() {
const match = await this.app.version.satisfies('<0.11.0-alpha.2');
if (!match) {
return;
}
const { db } = this.context;
const NodeRepo = db.getRepository('flow_nodes');
await db.sequelize.transaction(async (transaction) => {
const nodes = await NodeRepo.find({
filter: {
type: 'manual',
},
transaction,
});
console.log('%d nodes need to be migrated.', nodes.length);
await nodes.reduce(
(promise, node) =>
promise.then(() => {
node.set('config', {
...migrateConfig(node.config),
});
node.changed('config', true);
return node.save({
silent: true,
transaction,
});
}),
Promise.resolve(),
);
});
}
}