feat: plugin-workfow, api regular (#1103)

Reviewed-on: daoyoucloud/tachybase#1103
Co-authored-by: bai.zixv <bai.zixv@foxmail.com>
Co-committed-by: bai.zixv <bai.zixv@foxmail.com>
This commit is contained in:
bai.zixv 2024-06-04 16:59:10 +08:00 committed by sealday
parent b2970245d9
commit 5251aec157
17 changed files with 676 additions and 6 deletions

View File

@ -152,7 +152,7 @@ class Spreadsheet {
this.options = { showBottomBar: true, ...options };
this.sheetIndex = 1;
this.datas = [];
// debugger;
if (typeof selectors === 'string') {
this.targetEl = document.querySelector(selectors) as HTMLElement;
} else {

View File

@ -0,0 +1,168 @@
import {
SchemaInitializerItemType,
useCollectionDataSource,
useCollectionManager_deprecated,
useCompile,
} from '@tachybase/client';
import {
CheckboxGroupWithTooltip,
CollectionBlockInitializer,
FieldsSelect,
getCollectionFieldOptions,
RadioWithTooltip,
} from '../../..';
import { lang, tval } from '../../../locale';
import { Trigger } from '../../../triggers';
const enum ACTION_TYPES {
CREATE = 'create',
UPDATE = 'update',
UPSERT = 'updateOrCreate',
DESTROY = 'destroy',
}
export class APIRegularTrigger extends Trigger {
title = lang('API Regular');
description = lang('Trigger when an API call is made.');
fieldset = {
collection: {
type: 'string',
title: tval('Collection'),
required: true,
'x-decorator': 'FormItem',
'x-component': 'DataSourceCollectionCascader',
'x-reactions': [
{
target: 'changed',
effects: ['onFieldValueChange'],
fulfill: {
state: {
value: [],
},
},
},
],
},
global: {
type: 'boolean',
title: tval('Trigger mode'),
'x-decorator': 'FormItem',
'x-component': 'RadioWithTooltip',
'x-component-props': {
direction: 'vertical',
options: [
{
label: tval('Local mode, triggered before executing the actions bound to this workflow'),
value: false,
},
{
label: tval('Global mode, triggered before executing the following actions'),
value: true,
},
],
},
default: false,
},
actions: {
type: 'number',
title: tval('Select actions'),
'x-decorator': 'FormItem',
'x-component': 'CheckboxGroupWithTooltip',
'x-component-props': {
direction: 'vertical',
options: [
{ label: tval('Create record action'), value: ACTION_TYPES.CREATE },
{ label: tval('Update record action'), value: ACTION_TYPES.UPDATE },
{ label: tval('Delete record action'), value: ACTION_TYPES.DESTROY },
],
},
required: true,
'x-reactions': [
{
dependencies: ['collection', 'global'],
fulfill: {
state: {
visible: '{{!!$deps[0] && !!$deps[1]}}',
},
},
},
],
},
};
scope = { useCollectionDataSource };
components = {
FieldsSelect: FieldsSelect,
RadioWithTooltip: RadioWithTooltip,
CheckboxGroupWithTooltip: CheckboxGroupWithTooltip,
};
isActionTriggerable = (config, context) => {
const { global } = config;
return !global && !context.direct;
};
useVariables(config, options) {
// eslint-disable-next-line react-hooks/rules-of-hooks
const compile = useCompile();
// eslint-disable-next-line react-hooks/rules-of-hooks
const { getCollectionFields } = useCollectionManager_deprecated();
// eslint-disable-next-line react-hooks/rules-of-hooks
const langTriggerData = lang('Trigger data');
// eslint-disable-next-line react-hooks/rules-of-hooks
const langUserSubmittedForm = lang('User submitted action');
// eslint-disable-next-line react-hooks/rules-of-hooks
const langRoleSubmittedForm = lang('Role of user submitted action');
const rootFields = [
{
collectionName: config.collection,
name: 'data',
type: 'hasOne',
target: config.collection,
uiSchema: {
title: langTriggerData,
},
},
{
collectionName: 'users',
name: 'user',
type: 'hasOne',
target: 'users',
uiSchema: {
title: langUserSubmittedForm,
},
},
{
name: 'roleName',
uiSchema: {
title: langRoleSubmittedForm,
},
},
];
const result = getCollectionFieldOptions({
// depth,
appends: ['data', 'user', ...(config.appends?.map((item) => `data.${item}`) || [])],
...options,
fields: rootFields,
compile,
getCollectionFields,
});
return result;
}
useInitializers(config): SchemaInitializerItemType | null {
if (!config.collection) {
return null;
}
return {
name: 'triggerData',
type: 'item',
key: 'triggerData',
title: tval('Trigger data'),
Component: CollectionBlockInitializer,
collection: config.collection,
dataPath: '$context.data',
};
}
}

View File

@ -0,0 +1,12 @@
import { Plugin } from '@tachybase/client';
import PluginWorkflow from '../../..';
import { NAMESPACE_TRIGGER_API_REGULAR } from '../../../../common/constants';
import { APIRegularTrigger } from './APIRegular.trigger';
export class KitAPIRegularConfiguration extends Plugin {
async load() {
const pluginWorkflow = this.app.pm.get(PluginWorkflow);
pluginWorkflow.registerTrigger(NAMESPACE_TRIGGER_API_REGULAR, APIRegularTrigger);
}
}

View File

@ -0,0 +1,11 @@
import { Plugin } from '@tachybase/client';
import { KitAPIRegularConfiguration } from './configuration/kit';
import { KitAPIRegularUsage } from './usage/kit';
export class PluginAPIRegularClient extends Plugin {
async afterAdd() {
await this.app.pm.add(KitAPIRegularConfiguration);
await this.app.pm.add(KitAPIRegularUsage);
}
}

View File

@ -0,0 +1,32 @@
import React from 'react';
import { BlockInitializer, useSchemaInitializerItem } from '@tachybase/client';
import { tval } from '../../../locale';
export const APIRegularInitializer = () => {
const itemConfig = useSchemaInitializerItem();
return <BlockInitializer {...itemConfig} schema={schema} item={itemConfig} />;
};
const schema = {
type: 'void',
title: tval('Regular workflow'),
'x-component': 'Action',
'x-use-component-props': 'usePropsAPIRegular',
'x-align': 'right',
'x-acl-action': 'update',
'x-decorator': 'ACLActionProvider',
'x-acl-action-props': {
skipScopeCheck: true,
},
'x-action': 'customize:APIRegular',
'x-toolbar': 'ActionSchemaToolbar',
'x-settings': 'actionSettings:APIRegular',
'x-action-settings': {
bindWorkflow: false,
updateMode: 'selected',
},
'x-component-props': {
icon: 'CarryOutOutlined',
},
};

View File

@ -0,0 +1,105 @@
import {
ActionDesigner,
SchemaSettings,
SchemaSettingsItemType,
useDesignable,
useRequest,
useSchemaToolbar,
} from '@tachybase/client';
import { useFieldSchema } from '@tachybase/schema';
import { useTranslation } from 'react-i18next';
import { NAMESPACE_TRIGGER_API_REGULAR } from '../../../../common/constants';
const schemaSettingsItems: SchemaSettingsItemType[] = [
{
name: 'editButton',
Component: ActionDesigner.ButtonEditor,
useComponentProps() {
const { buttonEditorProps } = useSchemaToolbar();
return buttonEditorProps;
},
},
{
name: 'Date',
type: 'select',
useComponentProps() {
const { dn } = useDesignable();
const { t } = useTranslation();
const fieldSchema = useFieldSchema();
return {
title: t('Data will be updated'),
options: [
{ label: t('Selected'), value: 'selected' },
{ label: t('All'), value: 'all' },
],
value: fieldSchema['x-action-settings']?.['updateMode'],
onChange: (value) => {
fieldSchema['x-action-settings']['updateMode'] = value;
dn.emit('patch', {
schema: {
'x-uid': fieldSchema['x-uid'],
'x-action-settings': fieldSchema['x-action-settings'],
},
});
},
};
},
},
{
name: 'Bind workflow',
type: 'select',
useComponentProps() {
const { dn } = useDesignable();
const { t } = useTranslation();
const fieldSchema = useFieldSchema();
const { data } = useRequest<any>({
resource: 'workflows',
action: 'list',
params: {
filter: {
type: NAMESPACE_TRIGGER_API_REGULAR,
},
},
});
// FIXME 不会实时生效
const options =
data?.data?.map((workflow) => ({
label: workflow.title,
value: workflow.id,
})) ?? [];
return {
title: t('Bind workflow'),
value: fieldSchema['x-action-settings'].bindWorkflow,
options: options.concat({
label: t('disabled'),
value: false,
}),
onChange(value) {
fieldSchema['x-action-settings'].bindWorkflow = value;
dn.emit('patch', {
schema: {
'x-uid': fieldSchema['x-uid'],
'x-action-settings': fieldSchema['x-action-settings'],
},
});
},
};
},
},
{
name: 'remove',
sort: 100,
Component: ActionDesigner.RemoveButton as any,
useComponentProps() {
const { removeButtonProps } = useSchemaToolbar();
return removeButtonProps;
},
},
];
export const APIRegularActionSettings = new SchemaSettings({
name: 'actionSettings:APIRegular',
items: schemaSettingsItems,
});

View File

@ -0,0 +1,36 @@
import { Plugin, useCollection } from '@tachybase/client';
import { tval } from '../../../locale';
import { APIRegularInitializer } from './APIRegular.schema';
import { APIRegularActionSettings } from './BulkUpdateAction.Settings';
import { usePropsAPIRegular } from './utils';
export class KitAPIRegularUsage extends Plugin {
async load() {
this.app.addComponents({
APIRegularInitializer,
});
this.app.addScopes({
usePropsAPIRegular,
});
this.app.schemaSettingsManager.add(APIRegularActionSettings);
['table', 'details'].forEach((block) => {
this.app.schemaInitializerManager.addItem(`${block}:configureActions`, 'customize.APIRegular', {
title: tval('Regular workflow'),
Component: 'APIRegularInitializer',
name: 'apiRegular',
useVisible() {
const collection = useCollection();
return (
(collection.template !== 'view' || collection?.writableView) &&
collection.template !== 'file' &&
collection.template !== 'sql'
);
},
});
});
}
}

View File

@ -0,0 +1,124 @@
import { useContext } from 'react';
import {
isVariable,
transformVariableValue,
useBlockRequestContext,
useCollection_deprecated,
useCompile,
useLocalVariables,
useRecord,
useRequest,
useTableBlockContext,
useVariables,
} from '@tachybase/client';
import { SchemaExpressionScopeContext, useField, useFieldSchema } from '@tachybase/schema';
import { App } from 'antd';
import { useNavigate } from 'react-router-dom';
import { lang } from '../../../locale';
export const usePropsAPIRegular = () => {
const { field, resource, __parent, service } = useBlockRequestContext();
const actionSchema = useFieldSchema();
const expressionScope = useContext(SchemaExpressionScopeContext);
const tableBlockContext = useTableBlockContext();
const { rowKey } = tableBlockContext;
const navigate = useNavigate();
const compile = useCompile();
const actionField: any = useField();
const { modal } = App.useApp();
const variables = useVariables();
const record = useRecord();
const { name, getField } = useCollection_deprecated();
const localVariables = useLocalVariables();
const { run } = useRequest(
{
resource: 'workflows',
action: 'trigger',
},
{
manual: true,
},
);
return {
async onClick() {
const selectedRecordKeys =
tableBlockContext.field?.data?.selectedRowKeys ?? expressionScope?.selectedRecordKeys ?? [];
const {
bindWorkflow = false,
assignedValues: originalAssignedValues = {},
updateMode,
} = actionSchema?.['x-action-settings'] ?? {};
actionField.data = field.data || {};
actionField.data.loading = true;
if (!bindWorkflow) {
return modal.info({
title: lang('Not bind workflow!'),
});
}
const assignedValues = {};
const waitList = Object.keys(originalAssignedValues).map(async (key) => {
const value = originalAssignedValues[key];
const collectionField = getField(key);
if (process.env.NODE_ENV !== 'production') {
if (!collectionField) {
throw new Error(`usePropsAPIRegular: field "${key}" not found in collection "${name}"`);
}
}
if (isVariable(value)) {
const result = await variables?.parseVariable(value, localVariables);
if (result) {
assignedValues[key] = transformVariableValue(result, { targetCollectionField: collectionField });
}
} else if (value != null && value !== '') {
assignedValues[key] = value;
}
});
await Promise.all(waitList);
modal.confirm({
title: lang('Bulk update', { ns: 'client' }),
content:
updateMode === 'selected'
? lang('Update selected data?', { ns: 'client' })
: lang('Update all data?', { ns: 'client' }),
async onOk() {
run({
filterByTk: bindWorkflow,
});
actionField.data.loading = false;
},
async onCancel() {
actionField.data.loading = false;
},
});
},
};
};
function updateData() {
// const updateData: { filter?: any; values: any; forceUpdate: boolean } = {
// values,
// filter,
// forceUpdate: false,
// };
// if (updateMode === 'selected') {
// if (!selectedRecordKeys?.length) {
// message.error(t('Please select the records to be updated'));
// return;
// }
// updateData.filter = { $and: [{ [rowKey || 'id']: { $in: selectedRecordKeys } }] };
// }
// if (!updateData.filter) {
// updateData.forceUpdate = true;
// }
}

View File

@ -28,7 +28,6 @@ export class JSONParseInstruction extends Instruction {
title: tval('Query expression'),
'x-decorator': 'FormItem',
'x-component': 'Input',
required: true,
},
model: {
type: 'array',

View File

@ -5,6 +5,7 @@ import { Registry } from '@tachybase/utils/client';
import { ExecutionPage } from './ExecutionPage';
import { PluginActionTrigger } from './features/action-trigger';
import { PluginAggregate } from './features/aggregate';
import { PluginAPIRegularClient } from './features/api-regular';
import { PluginDelay } from './features/delay';
import { PluginDaynamicCalculation } from './features/dynamic-calculation';
import PluginWorkflowJSParseClient from './features/js-parse';
@ -79,6 +80,7 @@ export default class PluginWorkflowClient extends Plugin {
await this.pm.add(PluginActionTrigger);
await this.pm.add(PluginWorkflowJsonParseClient);
await this.pm.add(PluginWorkflowJSParseClient);
await this.pm.add(PluginAPIRegularClient);
}
async load() {

View File

@ -1,2 +1,5 @@
export const NAMESPACE_INSTRUCTION_JSON_PARSE = 'json-parse';
export const NAMESPACE_INSTRUCTION_JS_PARSE = 'js-parse';
// api-fetch
export const NAMESPACE_TRIGGER_API_REGULAR = 'api-regular';

View File

@ -10,6 +10,7 @@ import initActions from './actions';
import { EXECUTION_STATUS } from './constants';
import { PluginActionTrigger } from './features/action-trigger/Plugin';
import { PluginAggregate } from './features/aggregate/Plugin';
import PluginWorkflowAPIRegularServer from './features/api-regular/plugin';
import { PluginDelay } from './features/delay/Plugin';
import { PluginDynamicCalculation } from './features/dynamic-calculation/Plugin';
import PluginWorkflowJSParseServer from './features/js-parse/plugin';
@ -66,6 +67,7 @@ export default class PluginWorkflowServer extends Plugin {
pluginActionTrigger: PluginActionTrigger;
pluginJSONParse: PluginWorkflowJSONParseServer;
pluginJSParse: PluginWorkflowJSParseServer;
pluginAPIRegular: PluginWorkflowAPIRegularServer;
constructor(app: Application, options?: PluginOptions) {
super(app, options);
@ -80,6 +82,7 @@ export default class PluginWorkflowServer extends Plugin {
this.pluginActionTrigger = new PluginActionTrigger(app, options);
this.pluginJSONParse = new PluginWorkflowJSONParseServer(app, options);
this.pluginJSParse = new PluginWorkflowJSParseServer(app, options);
this.pluginAPIRegular = new PluginWorkflowAPIRegularServer(app, options);
}
getLogger(workflowId: ID): Logger {
@ -305,6 +308,7 @@ export default class PluginWorkflowServer extends Plugin {
await this.pluginRequest.load();
await this.pluginJSONParse.load();
await this.pluginJSParse.load();
await this.pluginAPIRegular.load();
}
toggle(workflow: WorkflowModel, enable?: boolean) {

View File

@ -0,0 +1,156 @@
import { Context as ActionContext, Next } from '@tachybase/actions';
import { parseCollectionName } from '@tachybase/data-source-manager';
import { Model, modelAssociationByKey } from '@tachybase/database';
import Application, { DefaultContext } from '@tachybase/server';
import { get } from 'lodash';
import { BelongsTo, HasOne } from 'sequelize';
import WorkflowPlugin, { toJSON, WorkflowModel } from '../..';
import { NAMESPACE_TRIGGER_API_REGULAR } from '../../../common/constants';
import Trigger from '../../triggers';
interface Context extends ActionContext, DefaultContext {}
export class APIRegularTrigger extends Trigger {
constructor(workflow: WorkflowPlugin) {
super(workflow);
workflow.app.use(this.middleware, { after: 'dataSource' });
}
async middleware(context: Context, next: Next) {
const {
resourceName,
actionName,
params: { triggerWorkflows },
} = context.action;
if (resourceName === 'workflows' && actionName === 'trigger') {
return this.triggerAction(context, next);
}
await next();
if (!triggerWorkflows) {
return;
}
// if (!['create', 'update'].includes(actionName)) {
// return;
// }
return this.trigger(context);
}
async triggerAction(context: Context, next: Next) {
const { triggerWorkflows } = context.action.params;
if (!triggerWorkflows) {
return context.throw(400);
}
context.status = 202;
await next();
this.trigger(context);
}
private async trigger(context: Context) {
const { triggerWorkflows = '', values } = context.action.params;
const dataSourceHeader = context.get('x-data-source') || 'main';
const { currentUser, currentRole } = context.state;
const { model: UserModel } = this.workflow.db.getCollection('users');
const userInfo = {
user: UserModel.build(currentUser).desensitize(),
roleName: currentRole,
};
const triggers = triggerWorkflows.split(',').map((trigger) => trigger.split('!'));
const workflowRepo = this.workflow.db.getRepository('workflows');
const workflows = (
await workflowRepo.find({
filter: {
key: triggers.map((trigger) => trigger[0]),
current: true,
type: NAMESPACE_TRIGGER_API_REGULAR,
enabled: true,
},
})
).filter((workflow) => Boolean(workflow.config.collection));
const syncGroup = [];
const asyncGroup = [];
for (const workflow of workflows) {
const { collection, appends = [] } = workflow.config;
const [dataSourceName, collectionName] = parseCollectionName(collection);
const trigger = triggers.find((trigger) => trigger[0] == workflow.key);
const event = [workflow];
if (context.action.resourceName !== 'workflows') {
if (!context.body) {
continue;
}
if (dataSourceName !== dataSourceHeader) {
continue;
}
const { body: data } = context;
for (const row of Array.isArray(data) ? data : [data]) {
let payload = row;
if (trigger[1]) {
const paths = trigger[1].split('.');
for (const field of paths) {
if (payload.get(field)) {
payload = payload.get(field);
} else {
const association = <HasOne | BelongsTo>modelAssociationByKey(payload, field);
payload = await payload[association.accessors.get]();
}
}
}
const model = payload.constructor;
if (payload instanceof Model) {
if (collectionName !== model.collection.name) {
continue;
}
if (appends.length) {
payload = await model.collection.repository.findOne({
filterByTk: payload.get(model.primaryKeyAttribute),
appends,
});
}
}
// this.workflow.trigger(workflow, { data: toJSON(payload), ...userInfo });
event.push({ data: toJSON(payload), ...userInfo });
}
} else {
const { model, repository } = (<Application>context.app).dataSourceManager.dataSources
.get(dataSourceName)
.collectionManager.getCollection(collectionName);
let data = trigger[1] ? get(values, trigger[1]) : values;
const pk = get(data, model.primaryKeyAttribute);
if (appends.length && pk != null) {
data = await repository.findOne({
filterByTk: pk,
appends,
});
}
// this.workflow.trigger(workflow, {
// data,
// ...userInfo,
// });
event.push({ data, ...userInfo });
}
(workflow.sync ? syncGroup : asyncGroup).push(event);
}
for (const event of syncGroup) {
await this.workflow.trigger(event[0], event[1], { httpContext: context });
}
for (const event of asyncGroup) {
this.workflow.trigger(event[0], event[1]);
}
}
on(workflow: WorkflowModel): void {}
off(workflow: WorkflowModel): void {}
}

View File

@ -0,0 +1 @@
export { default } from './plugin';

View File

@ -0,0 +1,14 @@
import { Plugin } from '@tachybase/server';
import WorkflowPlugin from '../..';
import { NAMESPACE_TRIGGER_API_REGULAR } from '../../../common/constants';
import { APIRegularTrigger } from './APIRegular.trigger';
export class PluginWorkflowAPIRegularServer extends Plugin {
async load() {
const pluginWorkflow: any = this.app.pm.get(WorkflowPlugin);
pluginWorkflow.registerTrigger(NAMESPACE_TRIGGER_API_REGULAR, new APIRegularTrigger(pluginWorkflow));
}
}
export default PluginWorkflowAPIRegularServer;

View File

@ -10,7 +10,10 @@ export class JSONParseInstruction extends Instruction {
const data = processor.getParsedValue(source, node.id);
const query = this.engine;
try {
let result = query ? await query(expression, data) : data;
let result = data;
if (expression) {
result = query ? await query(expression, data) : data;
}
if (typeof result === 'object' && result && model?.length) {
if (Array.isArray(result)) {
@ -39,7 +42,7 @@ function mapModel(data, model) {
}
const result = model.reduce((acc, { path, alias }) => {
const key = alias ?? path.replace(/\./g, '_');
const key = alias || path.replace(/\./g, '_');
const value = _.get(data, path);
acc[key] = value;

View File

@ -1,6 +1,6 @@
import axios, { AxiosRequestConfig } from 'axios';
import { Processor, Instruction, JOB_STATUS, FlowNodeModel } from '../..';
import { FlowNodeModel, Instruction, JOB_STATUS, Processor } from '../..';
export interface Header {
name: string;
@ -42,13 +42,13 @@ async function request(config) {
export default class extends Instruction {
async run(node: FlowNodeModel, prevJob, processor: Processor) {
const config = processor.getParsedValue(node.config, node.id) as RequestConfig;
const { workflow } = processor.execution;
const sync = this.workflow.isWorkflowSync(workflow);
if (sync) {
try {
const response = await request(config);
return {
status: JOB_STATUS.RESOLVED,
result: response.data,