From bf00da846ae039a1ad2cd496f375ca0f8b04ed91 Mon Sep 17 00:00:00 2001 From: Junyi Date: Sat, 12 Jul 2025 17:53:02 +0800 Subject: [PATCH] feat(plugin-workflow-cc): add new plugin (#7201) --- .../@nocobase/plugin-workflow-cc/.gitignore | 45 ++ .../@nocobase/plugin-workflow-cc/.npmignore | 2 + .../@nocobase/plugin-workflow-cc/README.md | 16 + .../@nocobase/plugin-workflow-cc/client.d.ts | 2 + .../@nocobase/plugin-workflow-cc/client.js | 1 + .../@nocobase/plugin-workflow-cc/package.json | 24 + .../@nocobase/plugin-workflow-cc/server.d.ts | 2 + .../@nocobase/plugin-workflow-cc/server.js | 1 + .../plugin-workflow-cc/src/client/client.d.ts | 249 ++++++++++ .../plugin-workflow-cc/src/client/index.tsx | 68 +++ .../src/client/instruction/SchemaConfig.tsx | 238 ++++++++++ .../src/client/instruction/index.tsx | 146 ++++++ .../plugin-workflow-cc/src/client/locale.ts | 29 ++ .../plugin-workflow-cc/src/client/tasks.tsx | 448 ++++++++++++++++++ .../src/common/collections/workflowCcTasks.ts | 144 ++++++ .../src/common/constants.ts | 17 + .../@nocobase/plugin-workflow-cc/src/index.ts | 11 + .../plugin-workflow-cc/src/locale/en-US.json | 15 + .../plugin-workflow-cc/src/locale/zh-CN.json | 15 + .../src/server/CCInstruction.ts | 95 ++++ .../src/server/__tests__/instruction.test.ts | 295 ++++++++++++ .../plugin-workflow-cc/src/server/actions.ts | 90 ++++ .../src/server/collections/.gitkeep | 0 .../src/server/collections/workflowCcTasks.ts | 22 + .../plugin-workflow-cc/src/server/index.ts | 10 + .../plugin-workflow-cc/src/server/plugin.ts | 58 +++ 26 files changed, 2043 insertions(+) create mode 100644 packages/plugins/@nocobase/plugin-workflow-cc/.gitignore create mode 100644 packages/plugins/@nocobase/plugin-workflow-cc/.npmignore create mode 100644 packages/plugins/@nocobase/plugin-workflow-cc/README.md create mode 100644 packages/plugins/@nocobase/plugin-workflow-cc/client.d.ts create mode 100644 packages/plugins/@nocobase/plugin-workflow-cc/client.js create mode 100644 packages/plugins/@nocobase/plugin-workflow-cc/package.json create mode 100644 packages/plugins/@nocobase/plugin-workflow-cc/server.d.ts create mode 100644 packages/plugins/@nocobase/plugin-workflow-cc/server.js create mode 100644 packages/plugins/@nocobase/plugin-workflow-cc/src/client/client.d.ts create mode 100644 packages/plugins/@nocobase/plugin-workflow-cc/src/client/index.tsx create mode 100644 packages/plugins/@nocobase/plugin-workflow-cc/src/client/instruction/SchemaConfig.tsx create mode 100644 packages/plugins/@nocobase/plugin-workflow-cc/src/client/instruction/index.tsx create mode 100644 packages/plugins/@nocobase/plugin-workflow-cc/src/client/locale.ts create mode 100644 packages/plugins/@nocobase/plugin-workflow-cc/src/client/tasks.tsx create mode 100644 packages/plugins/@nocobase/plugin-workflow-cc/src/common/collections/workflowCcTasks.ts create mode 100644 packages/plugins/@nocobase/plugin-workflow-cc/src/common/constants.ts create mode 100644 packages/plugins/@nocobase/plugin-workflow-cc/src/index.ts create mode 100644 packages/plugins/@nocobase/plugin-workflow-cc/src/locale/en-US.json create mode 100644 packages/plugins/@nocobase/plugin-workflow-cc/src/locale/zh-CN.json create mode 100644 packages/plugins/@nocobase/plugin-workflow-cc/src/server/CCInstruction.ts create mode 100644 packages/plugins/@nocobase/plugin-workflow-cc/src/server/__tests__/instruction.test.ts create mode 100644 packages/plugins/@nocobase/plugin-workflow-cc/src/server/actions.ts create mode 100644 packages/plugins/@nocobase/plugin-workflow-cc/src/server/collections/.gitkeep create mode 100644 packages/plugins/@nocobase/plugin-workflow-cc/src/server/collections/workflowCcTasks.ts create mode 100644 packages/plugins/@nocobase/plugin-workflow-cc/src/server/index.ts create mode 100644 packages/plugins/@nocobase/plugin-workflow-cc/src/server/plugin.ts diff --git a/packages/plugins/@nocobase/plugin-workflow-cc/.gitignore b/packages/plugins/@nocobase/plugin-workflow-cc/.gitignore new file mode 100644 index 00000000000..e6713802058 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow-cc/.gitignore @@ -0,0 +1,45 @@ +node_modules/ +lib/ +esm/ +es/ +.env +.env.e2e +.DS_Store +yarn-error.log +lerna-debug.log +/.vscode +/.idea +*.sqlite +coverage +.umi +.umi-production +/uploads +.env.test +docs-dist/ +.npmrc +dist/ +docker/**/storage +cache/diskstore-* +*.nbdump +storage/duplicator/* +storage/backups/* +**/.dumi/tmp +**/.dumi/tmp-test +**/.dumi/tmp-production +packages/core/client/docs/contributing.md +packages/core/app/client/src/.plugins +storage/plugins +storage/tar +storage/tmp +storage/app.watch.ts +storage/logs-e2e +storage/uploads-e2e +storage/.pm2-* +tsconfig.paths.json +/playwright +/storage/playwright +.swc +ncc-cache/ +yarn--** +v8-compile-cache-** +vitest.config.mts.timestamp-* diff --git a/packages/plugins/@nocobase/plugin-workflow-cc/.npmignore b/packages/plugins/@nocobase/plugin-workflow-cc/.npmignore new file mode 100644 index 00000000000..65f5e8779f4 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow-cc/.npmignore @@ -0,0 +1,2 @@ +/node_modules +/src diff --git a/packages/plugins/@nocobase/plugin-workflow-cc/README.md b/packages/plugins/@nocobase/plugin-workflow-cc/README.md new file mode 100644 index 00000000000..87c7edf7af6 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow-cc/README.md @@ -0,0 +1,16 @@ +# @nocobase/plugin-workflow-cc + +## 分支 + +和 nocobase 仓库一样,也分为 main 和 next 两个分支 + +- main:只处理缺陷修复 +- next:新插件、新特性、不兼容性变更等 + +## 提测 + +- 提交 PR 之后,会自动生成 PR 测试环境,测试通过后再合并到对应分支 + +```bash +pr--.test.nocobase.com +``` diff --git a/packages/plugins/@nocobase/plugin-workflow-cc/client.d.ts b/packages/plugins/@nocobase/plugin-workflow-cc/client.d.ts new file mode 100644 index 00000000000..6c459cbac4c --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow-cc/client.d.ts @@ -0,0 +1,2 @@ +export * from './dist/client'; +export { default } from './dist/client'; diff --git a/packages/plugins/@nocobase/plugin-workflow-cc/client.js b/packages/plugins/@nocobase/plugin-workflow-cc/client.js new file mode 100644 index 00000000000..b6e3be70e63 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow-cc/client.js @@ -0,0 +1 @@ +module.exports = require('./dist/client/index.js'); diff --git a/packages/plugins/@nocobase/plugin-workflow-cc/package.json b/packages/plugins/@nocobase/plugin-workflow-cc/package.json new file mode 100644 index 00000000000..a8ef828aafe --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow-cc/package.json @@ -0,0 +1,24 @@ +{ + "name": "@nocobase/plugin-workflow-cc", + "displayName": "Workflow: CC", + "displayName.zh-CN": "工作流:抄送", + "description": "Information within the workflow can be freely configured into a view interface and made accessible to specific users. A CC task will be generated for them in the tasks center.", + "description.zh-CN": "可将流程中的信息自由配置为查看界面,提供给特定的用户查看,将在待办中心为其生成抄送任务。", + "homepage": "https://docs.nocobase.com/handbook/workflow-cc", + "homepage.zh-CN": "https://docs-cn.nocobase.com/handbook/workflow-cc", + "version": "1.8.3", + "main": "dist/server/index.js", + "peerDependencies": { + "@nocobase/actions": "1.x", + "@nocobase/client": "1.x", + "@nocobase/plugin-ui-schema-storage": "1.x", + "@nocobase/plugin-users": "1.x", + "@nocobase/plugin-workflow": "1.x", + "@nocobase/server": "1.x", + "@nocobase/test": "1.x", + "@nocobase/utils": "1.x" + }, + "keywords": [ + "Workflow" + ] +} diff --git a/packages/plugins/@nocobase/plugin-workflow-cc/server.d.ts b/packages/plugins/@nocobase/plugin-workflow-cc/server.d.ts new file mode 100644 index 00000000000..c41081ddc6d --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow-cc/server.d.ts @@ -0,0 +1,2 @@ +export * from './dist/server'; +export { default } from './dist/server'; diff --git a/packages/plugins/@nocobase/plugin-workflow-cc/server.js b/packages/plugins/@nocobase/plugin-workflow-cc/server.js new file mode 100644 index 00000000000..972842039a6 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow-cc/server.js @@ -0,0 +1 @@ +module.exports = require('./dist/server/index.js'); diff --git a/packages/plugins/@nocobase/plugin-workflow-cc/src/client/client.d.ts b/packages/plugins/@nocobase/plugin-workflow-cc/src/client/client.d.ts new file mode 100644 index 00000000000..4e96f83fa12 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow-cc/src/client/client.d.ts @@ -0,0 +1,249 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +// CSS modules +type CSSModuleClasses = { readonly [key: string]: string }; + +declare module '*.module.css' { + const classes: CSSModuleClasses; + export default classes; +} +declare module '*.module.scss' { + const classes: CSSModuleClasses; + export default classes; +} +declare module '*.module.sass' { + const classes: CSSModuleClasses; + export default classes; +} +declare module '*.module.less' { + const classes: CSSModuleClasses; + export default classes; +} +declare module '*.module.styl' { + const classes: CSSModuleClasses; + export default classes; +} +declare module '*.module.stylus' { + const classes: CSSModuleClasses; + export default classes; +} +declare module '*.module.pcss' { + const classes: CSSModuleClasses; + export default classes; +} +declare module '*.module.sss' { + const classes: CSSModuleClasses; + export default classes; +} + +// CSS +declare module '*.css' { } +declare module '*.scss' { } +declare module '*.sass' { } +declare module '*.less' { } +declare module '*.styl' { } +declare module '*.stylus' { } +declare module '*.pcss' { } +declare module '*.sss' { } + +// Built-in asset types +// see `src/node/constants.ts` + +// images +declare module '*.apng' { + const src: string; + export default src; +} +declare module '*.png' { + const src: string; + export default src; +} +declare module '*.jpg' { + const src: string; + export default src; +} +declare module '*.jpeg' { + const src: string; + export default src; +} +declare module '*.jfif' { + const src: string; + export default src; +} +declare module '*.pjpeg' { + const src: string; + export default src; +} +declare module '*.pjp' { + const src: string; + export default src; +} +declare module '*.gif' { + const src: string; + export default src; +} +declare module '*.svg' { + const src: string; + export default src; +} +declare module '*.ico' { + const src: string; + export default src; +} +declare module '*.webp' { + const src: string; + export default src; +} +declare module '*.avif' { + const src: string; + export default src; +} + +// media +declare module '*.mp4' { + const src: string; + export default src; +} +declare module '*.webm' { + const src: string; + export default src; +} +declare module '*.ogg' { + const src: string; + export default src; +} +declare module '*.mp3' { + const src: string; + export default src; +} +declare module '*.wav' { + const src: string; + export default src; +} +declare module '*.flac' { + const src: string; + export default src; +} +declare module '*.aac' { + const src: string; + export default src; +} +declare module '*.opus' { + const src: string; + export default src; +} +declare module '*.mov' { + const src: string; + export default src; +} +declare module '*.m4a' { + const src: string; + export default src; +} +declare module '*.vtt' { + const src: string; + export default src; +} + +// fonts +declare module '*.woff' { + const src: string; + export default src; +} +declare module '*.woff2' { + const src: string; + export default src; +} +declare module '*.eot' { + const src: string; + export default src; +} +declare module '*.ttf' { + const src: string; + export default src; +} +declare module '*.otf' { + const src: string; + export default src; +} + +// other +declare module '*.webmanifest' { + const src: string; + export default src; +} +declare module '*.pdf' { + const src: string; + export default src; +} +declare module '*.txt' { + const src: string; + export default src; +} + +// wasm?init +declare module '*.wasm?init' { + const initWasm: (options?: WebAssembly.Imports) => Promise; + export default initWasm; +} + +// web worker +declare module '*?worker' { + const workerConstructor: { + new(options?: { name?: string }): Worker; + }; + export default workerConstructor; +} + +declare module '*?worker&inline' { + const workerConstructor: { + new(options?: { name?: string }): Worker; + }; + export default workerConstructor; +} + +declare module '*?worker&url' { + const src: string; + export default src; +} + +declare module '*?sharedworker' { + const sharedWorkerConstructor: { + new(options?: { name?: string }): SharedWorker; + }; + export default sharedWorkerConstructor; +} + +declare module '*?sharedworker&inline' { + const sharedWorkerConstructor: { + new(options?: { name?: string }): SharedWorker; + }; + export default sharedWorkerConstructor; +} + +declare module '*?sharedworker&url' { + const src: string; + export default src; +} + +declare module '*?raw' { + const src: string; + export default src; +} + +declare module '*?url' { + const src: string; + export default src; +} + +declare module '*?inline' { + const src: string; + export default src; +} diff --git a/packages/plugins/@nocobase/plugin-workflow-cc/src/client/index.tsx b/packages/plugins/@nocobase/plugin-workflow-cc/src/client/index.tsx new file mode 100644 index 00000000000..49b9537ad13 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow-cc/src/client/index.tsx @@ -0,0 +1,68 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import React from 'react'; +import { + ButtonEditor, + ExtendCollectionsProvider, + Plugin, + RemoveButton, + SchemaSettings, + useSchemaToolbar, +} from '@nocobase/client'; +import PluginWorkflowClient from '@nocobase/plugin-workflow/client'; +import CCInstruction from './instruction'; +import { addBlockButton } from './instruction/SchemaConfig'; +import ccCollection from '../common/collections/workflowCcTasks'; +import { TASK_TYPE_CC } from '../common/constants'; +import { ccTodo } from './tasks'; + +function WorkflowCCProvider(props) { + return {props.children}; +} + +export class PluginWorkflowCCClient extends Plugin { + // You can get and modify the app instance here + async load() { + this.app.addProvider(WorkflowCCProvider); + + const workflow = this.app.pm.get(PluginWorkflowClient); + workflow.registerInstruction('cc', CCInstruction); + workflow.registerTaskType(TASK_TYPE_CC, ccTodo); + + this.app.schemaInitializerManager.add(addBlockButton); + // this.app.schemaInitializerManager.add(addActionButton); + this.app.schemaSettingsManager.add( + new SchemaSettings({ + name: 'actionSettings:workflow:cc:read', + items: [ + { + name: 'editButton', + Component: ButtonEditor, + useComponentProps() { + const { buttonEditorProps } = useSchemaToolbar(); + return buttonEditorProps; + }, + }, + { + name: 'delete', + sort: 100, + Component: RemoveButton as any, + useComponentProps() { + const { removeButtonProps } = useSchemaToolbar(); + return removeButtonProps; + }, + }, + ], + }), + ); + } +} + +export default PluginWorkflowCCClient; diff --git a/packages/plugins/@nocobase/plugin-workflow-cc/src/client/instruction/SchemaConfig.tsx b/packages/plugins/@nocobase/plugin-workflow-cc/src/client/instruction/SchemaConfig.tsx new file mode 100644 index 00000000000..28d9372bb89 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow-cc/src/client/instruction/SchemaConfig.tsx @@ -0,0 +1,238 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import React, { useState } from 'react'; +import { uid } from '@formily/shared'; +import { ISchema, observer, useForm } from '@formily/react'; +import { Button, Spin, Typography } from 'antd'; + +import { + ActionContextProvider, + css, + gridRowColWrap, + SchemaComponent, + SchemaComponentProvider, + SchemaInitializer, + SchemaInitializerItemType, + useActionContext, + useAPIClient, + usePlugin, + useRequest, + useSchemaOptionsContext, +} from '@nocobase/client'; +import PluginWorkflowClient, { + useAvailableUpstreams, + useFlowContext, + useNodeContext, + useTrigger, + useWorkflowExecuted, +} from '@nocobase/plugin-workflow/client'; +import { NAMESPACE } from '../../common/constants'; +import { lang, usePluginTranslation } from '../locale'; + +function useTriggerInitializers(): SchemaInitializerItemType | null { + const { workflow } = useFlowContext(); + const trigger = useTrigger(); + return trigger.useInitializers ? trigger.useInitializers(workflow.config) : null; +} + +export const addBlockButton = new SchemaInitializer({ + name: 'workflowCc:popup:configureUserInterface:addBlock', + wrap: gridRowColWrap, + title: '{{t("Add block")}}', + items: [ + { + type: 'itemGroup', + name: 'dataBlocks', + title: '{{t("Data blocks")}}', + hideIfNoChildren: true, + useChildren() { + const workflowPlugin = usePlugin(PluginWorkflowClient); + const current = useNodeContext(); + const nodes = useAvailableUpstreams(current); + const triggerInitializers = [useTriggerInitializers()].filter(Boolean); + const nodeBlockInitializers = nodes + .map((node) => { + const instruction = workflowPlugin.instructions.get(node.type); + return instruction?.useInitializers?.(node); + }) + .filter(Boolean); + const dataBlockInitializers: any = [ + ...triggerInitializers, + ...(nodeBlockInitializers.length + ? [ + { + name: 'nodes', + type: 'subMenu', + title: `{{t("Node result", { ns: "workflow" })}}`, + children: nodeBlockInitializers, + }, + ] + : []), + ].filter(Boolean); + return dataBlockInitializers; + }, + }, + { + type: 'itemGroup', + name: 'otherBlocks', + title: '{{t("Other blocks")}}', + children: [ + { + name: 'markdown', + title: '{{t("Markdown")}}', + Component: 'MarkdownBlockInitializer', + }, + ], + }, + ], +}); + +function SchemaContent({ value, onChange }) { + const api = useAPIClient(); + const workflowPlugin = usePlugin(PluginWorkflowClient); + const { components, scope } = useSchemaOptionsContext(); + const executed = useWorkflowExecuted(); + const node = useNodeContext(); + const nodes = useAvailableUpstreams(node); + + const { data, loading } = useRequest( + async () => { + if (value) { + const { data } = await api.request({ url: `uiSchemas:getJsonSchema/${value}` }); + if (data.data?.['x-uid'] === value) { + return data.data; + } + } + + const id = value ?? uid(); + // const content = createEditFormBlockUISchema(); + const newSchema = { + type: 'void', + name: id, + 'x-uid': id, + 'x-component': 'Grid', + 'x-initializer': 'workflowCc:popup:configureUserInterface:addBlock', + properties: { + [uid()]: { + type: 'void', + 'x-component': 'Grid.Row', + properties: { + [uid()]: { + type: 'void', + 'x-component': 'Grid.Col', + 'x-component-props': { + span: 24, + }, + properties: { + // [uid()]: content, + }, + }, + }, + }, + }, + }; + await api.resource('uiSchemas').insert({ + values: newSchema, + }); + onChange(id); + return newSchema; + }, + { + refreshDeps: [value], + }, + ); + + if (loading) { + return ; + } + + const nodeComponents = {}; + nodes.forEach((item) => { + const instruction = workflowPlugin.instructions.get(item.type); + Object.assign(nodeComponents, instruction.components); + }); + + return ( + + + + ); +} + +export function SchemaConfig({ value, onChange }) { + return ( + + ); +} + +const ConfigChangedContext = React.createContext({}); + +const ForceReadHint = observer(() => { + const { initialValues, values } = useForm(); + + return initialValues.forceRead != values.forceRead ? ( + + {lang('Force read changes unsaved, click "Submit" to save.')} + + ) : null; +}); + +export function SchemaConfigButton(props) { + const executed = useWorkflowExecuted(); + const { setFormValueChanged } = useActionContext(); + const [visible, setVisible] = useState(false); + const { t } = usePluginTranslation(); + return ( + + + + {props.children} + + + + ); +} diff --git a/packages/plugins/@nocobase/plugin-workflow-cc/src/client/instruction/index.tsx b/packages/plugins/@nocobase/plugin-workflow-cc/src/client/instruction/index.tsx new file mode 100644 index 00000000000..f126309ffef --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow-cc/src/client/instruction/index.tsx @@ -0,0 +1,146 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This program is offered under a commercial license. + * For more information, see + */ + +import React from 'react'; +import { ArrayItems } from '@formily/antd-v5'; +import { uid } from '@formily/shared'; +import _ from 'lodash'; +import { EyeOutlined } from '@ant-design/icons'; + +import { css } from '@nocobase/client'; + +import { + Instruction, + WorkflowVariableTextArea, + UsersAddition, + UsersSelect, + useNodeContext, +} from '@nocobase/plugin-workflow/client'; + +import { NAMESPACE } from '../../common/constants'; +// import NotificationFieldset from '../NotificationFieldset'; +// import { SchemaConfig, SchemaConfigButton } from './SchemaConfig'; +import { SchemaConfig, SchemaConfigButton } from './SchemaConfig'; + +export default class extends Instruction { + title = `{{t("CC", { ns: "${NAMESPACE}" })}}`; + type = 'cc'; + group = 'manual'; + description = `{{t("Information within the workflow can be freely configured into a view interface and made accessible to specific users. A CC task will be generated for them in the tasks center.", { ns: "${NAMESPACE}" })}}`; + icon = (); + fieldset = { + users: { + type: 'array', + title: `{{t("Recipients", { ns: "${NAMESPACE}" })}}`, + 'x-decorator': 'FormItem', + 'x-component': 'ArrayItems', + 'x-component-props': { + className: css` + &[disabled] { + > .ant-formily-array-base-addition { + display: none; + } + > .ant-formily-array-items-item .ant-space-item:not(:nth-child(2)) { + display: none; + } + } + `, + }, + items: { + type: 'void', + 'x-component': 'Space', + 'x-component-props': { + className: css` + width: 100%; + &.ant-space.ant-space-horizontal { + flex-wrap: nowrap; + } + > .ant-space-item:nth-child(2) { + flex-grow: 1; + } + `, + }, + properties: { + sort: { + type: 'void', + 'x-decorator': 'FormItem', + 'x-component': 'ArrayItems.SortHandle', + }, + input: { + type: 'string', + 'x-decorator': 'FormItem', + 'x-component': 'UsersSelect', + }, + remove: { + type: 'void', + 'x-decorator': 'FormItem', + 'x-component': 'ArrayItems.Remove', + }, + }, + }, + required: true, + properties: { + add: { + type: 'void', + 'x-component': 'UsersAddition', + }, + }, + }, + ccDetail: { + type: 'void', + title: `{{t("User interface", { ns: "${NAMESPACE}" })}}`, + 'x-decorator': 'FormItem', + 'x-component': 'SchemaConfigButton', + properties: { + ccDetail: { + type: 'void', + 'x-component': 'SchemaConfig', + }, + }, + required: true, + }, + title: { + type: 'string', + title: `{{t("Task title", { ns: "${NAMESPACE}" })}}`, + 'x-decorator': 'FormItem', + 'x-component': 'WorkflowVariableTextArea', + description: `{{t("Title of each CC item in tasks center. Could use variables in string template. Default to node title.", { ns: "${NAMESPACE}" })}}`, + default: '{{useNodeContext().title}}', + }, + notifications: { + type: 'void', + 'x-component': 'NotificationFieldset', + }, + }; + createDefaultConfig() { + return { + ccDetail: uid(), + }; + } + scope = { + useNodeContext, + }; + components = { + ArrayItems, + UsersSelect, + UsersAddition, + SchemaConfigButton, + SchemaConfig, + WorkflowVariableTextArea, + }; +} diff --git a/packages/plugins/@nocobase/plugin-workflow-cc/src/client/locale.ts b/packages/plugins/@nocobase/plugin-workflow-cc/src/client/locale.ts new file mode 100644 index 00000000000..b08d5a57e30 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow-cc/src/client/locale.ts @@ -0,0 +1,29 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This program is offered under a commercial license. + * For more information, see + */ + +import { i18n } from '@nocobase/client'; +import { useTranslation } from 'react-i18next'; +import { NAMESPACE } from '../common/constants'; + +export function lang(key: string, options = {}) { + return i18n.t(key, { ...options, ns: NAMESPACE }); +} + +export function usePluginTranslation() { + return useTranslation(NAMESPACE); +} diff --git a/packages/plugins/@nocobase/plugin-workflow-cc/src/client/tasks.tsx b/packages/plugins/@nocobase/plugin-workflow-cc/src/client/tasks.tsx new file mode 100644 index 00000000000..a0543b66bdf --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow-cc/src/client/tasks.tsx @@ -0,0 +1,448 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import React, { useCallback, useContext, useEffect, useState } from 'react'; +import { useAntdToken } from 'antd-style'; +import { Card, ConfigProvider, Descriptions, Spin } from 'antd'; +import { useNavigate } from 'react-router-dom'; +import { get } from 'lodash'; + +import { + SchemaComponentContext, + useCollectionRecordData, + SchemaComponent, + css, + useCurrentUserContext, + useAPIClient, + usePlugin, + useFormBlockContext, + RemoteSchemaComponent, + CollectionRecordProvider, + useActionContext, + useListBlockContext, +} from '@nocobase/client'; +import PluginWorkflowClient, { + DetailsBlockProvider, + FlowContext, + linkNodes, + NodeContext, + useAvailableUpstreams, + usePopupRecordContext, + useTasksCountsContext, + WorkflowTitle, +} from '@nocobase/plugin-workflow/client'; + +import { NAMESPACE, TASK_STATUS, TASK_TYPE_CC } from '../common/constants'; +import { useTranslation } from 'react-i18next'; +import { lang } from './locale'; + +function useDetailsBlockProps() { + const { form } = useFormBlockContext(); + return { form }; +} + +function useReadAction() { + const record = useCollectionRecordData(); + const ctx = useActionContext(); + const api = useAPIClient(); + const { id } = useCollectionRecordData(); + const { service } = useListBlockContext() || {}; + const action = record.status ? 'unread' : 'read'; + return { + async run() { + try { + await api.resource('workflowCcTasks')[action]({ + filterByTk: id, + }); + ctx.setVisible(false); + ctx.setSubmitted(true); + service?.refresh?.(); + } catch (error) { + console.error('Failed to mark task as read:', error); + } + }, + }; +} + +function useReadActionProps(props) { + const record = useCollectionRecordData(); + return record.status + ? { + title: lang('Mark as unread'), + } + : { + type: 'primary', + title: lang('Mark as read'), + }; +} + +function useReadAllAction() { + const api = useAPIClient(); + const ctx = useListBlockContext(); + + return { + async run() { + try { + await api.resource('workflowCcTasks').read(); + ctx?.service?.refresh?.(); + } catch (error) { + console.error('Failed to mark task as read:', error); + } + }, + }; +} + +function useReadAllActionProps(props) { + const { counts } = useTasksCountsContext(); + return { + ...props, + disabled: !counts[TASK_TYPE_CC].pending, + }; +} + +function FlowContextProvider(props) { + const workflowPlugin = usePlugin(PluginWorkflowClient); + const api = useAPIClient(); + const { id } = useCollectionRecordData() || {}; + const [flowContext, setFlowContext] = useState(null); + const [record, setRecord] = useState(useCollectionRecordData()); + const [node, setNode] = useState(null); + + useEffect(() => { + if (!id) { + return; + } + api + .resource('workflowCcTasks') + .get?.({ + filterByTk: id, + appends: ['node', 'job', 'workflow', 'workflow.nodes', 'execution', 'execution.jobs'], + }) + .then(({ data }) => { + const { node, workflow: { nodes = [], ...workflow } = {}, execution } = data?.data ?? {}; + linkNodes(nodes); + setNode(node); + setFlowContext({ + workflow, + nodes, + execution, + }); + setRecord(data?.data); + return; + }); + }, [api, id]); + + const upstreams = useAvailableUpstreams(flowContext?.nodes.find((item) => item.id === node.id)); + const nodeComponents = upstreams.reduce( + (components, { type }) => Object.assign(components, workflowPlugin.instructions.get(type).components), + {}, + ); + + return node && flowContext ? ( + + + + + + + + ) : ( + + ); +} + +function Drawer() { + const ctx = useContext(SchemaComponentContext); + const record = useCollectionRecordData(); + const { id, node, workflow, status } = record || {}; + + return record ? ( + + + + ) : null; +} + +function ContentDetail(props) { + const { t } = useTranslation(); + const token = useAntdToken(); + return ( + + + ), + }, + { + key: 'status', + label: t('Status', { ns: 'workflow' }), + children: ( + + ), + }, + ]} + className={css` + .ant-descriptions-header { + margin-bottom: 0.5em; + .ant-descriptions-extra { + color: ${token.colorTextDescription}; + } + } + .ant-descriptions-item-label { + width: 6em; + } + `} + /> + + ); +} + +function RecordTitle(props) { + const record = useCollectionRecordData(); + if (Array.isArray(props.dataIndex)) { + for (const index of props.dataIndex) { + const title = get(record, index); + if (title) { + return title; + } + } + } + return get(record, props.dataIndex); +} + +function ContentDetailWithTitle(props) { + return ( + } + extra={} + /> + ); +} + +function TaskItem() { + const token = useAntdToken(); + const record = useCollectionRecordData(); + const navigate = useNavigate(); + const { setRecord } = usePopupRecordContext(); + const onOpen = useCallback( + (e: React.MouseEvent) => { + const targetElement = e.target as Element; // 将事件目标转换为Element类型 + const currentTargetElement = e.currentTarget as Element; + if (currentTargetElement.contains(targetElement)) { + setRecord(record); + navigate(`./${record.id}`); + } + e.stopPropagation(); + }, + [navigate, record, setRecord], + ); + + return ( + } + className={css` + .ant-card-extra { + color: ${token.colorTextDescription}; + } + `} + > + + + ); +} + +const StatusFilterMap = { + pending: { + status: TASK_STATUS.UNREAD, + }, + completed: { + status: TASK_STATUS.READ, + }, +}; + +function useTodoActionParams(status) { + const { data: user } = useCurrentUserContext(); + const filter = StatusFilterMap[status] ?? {}; + return { + filter, + appends: [ + 'node.id', + 'node.title', + 'workflow.id', + 'workflow.title', + 'workflow.enabled', + 'execution.id', + 'execution.status', + ], + except: ['node.config', 'workflow.config', 'workflow.options', 'execution.context', 'execution.output'], + }; +} + +function TodoExtraActions(props) { + return ( + + ); +} + +export const ccTodo = { + title: `{{t("CC to me", { ns: "${NAMESPACE}" })}}`, + collection: 'workflowCcTasks', + action: 'listMine', + useActionParams: useTodoActionParams, + Actions: TodoExtraActions, + Item: TaskItem, + Detail: Drawer, +}; diff --git a/packages/plugins/@nocobase/plugin-workflow-cc/src/common/collections/workflowCcTasks.ts b/packages/plugins/@nocobase/plugin-workflow-cc/src/common/collections/workflowCcTasks.ts new file mode 100644 index 00000000000..02b4b7c24e3 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow-cc/src/common/collections/workflowCcTasks.ts @@ -0,0 +1,144 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { NAMESPACE } from '../constants'; + +export default { + name: 'workflowCcTasks', + dumpRules: { + group: 'log', + }, + migrationRules: ['schema-only'], + shared: true, + createdAt: true, + updatedAt: true, + fields: [ + { + type: 'bigInt', + name: 'id', + primaryKey: true, + autoIncrement: true, + }, + { + type: 'belongsTo', + name: 'job', + target: 'jobs', + foreignKey: 'jobId', + primaryKey: false, + }, + { + type: 'belongsTo', + name: 'user', + target: 'users', + foreignKey: 'userId', + primaryKey: false, + }, + { + type: 'string', + name: 'title', + interface: 'input', + uiSchema: { + type: 'string', + title: `{{t("Task title", { ns: "${NAMESPACE}" })}}`, + 'x-component': 'Input', + }, + }, + { + type: 'belongsTo', + name: 'execution', + onDelete: 'CASCADE', + }, + { + type: 'belongsTo', + name: 'node', + target: 'flow_nodes', + }, + { + type: 'belongsTo', + name: 'workflow', + target: 'workflows', + foreignKey: 'workflowId', + onDelete: 'CASCADE', + interface: 'm2o', + uiSchema: { + type: 'object', + title: `{{t("Workflow", { ns: "workflow" })}}`, + 'x-component': 'AssociationField', + 'x-component-props': { + fieldNames: { + label: 'title', + value: 'id', + }, + }, + }, + }, + { + type: 'integer', + name: 'status', + interface: 'select', + uiSchema: { + type: 'number', + title: `{{t("Status", { ns: "${NAMESPACE}" })}}`, + 'x-component': 'Select', + enum: [ + { + label: `{{t("Unread", { ns: "${NAMESPACE}" })}}`, + value: 0, + color: 'gold', + }, + { + label: `{{t("Read", { ns: "${NAMESPACE}" })}}`, + value: 1, + color: 'green', + }, + ], + }, + defaultValue: 0, + }, + { + type: 'date', + name: 'readAt', + interface: 'datetime', + uiSchema: { + type: 'string', + title: `{{t("Read at", { ns: "${NAMESPACE}" })}}`, + 'x-component': 'DatePicker', + 'x-component-props': { + showTime: true, + }, + }, + }, + { + type: 'date', + name: 'createdAt', + interface: 'createdAt', + uiSchema: { + type: 'datetime', + title: '{{t("Created at")}}', + 'x-component': 'DatePicker', + 'x-component-props': { + showTime: true, + }, + }, + }, + { + type: 'date', + name: 'updatedAt', + interface: 'updatedAt', + uiSchema: { + type: 'datetime', + title: '{{t("Updated at")}}', + 'x-component': 'DatePicker', + 'x-component-props': { + showTime: true, + }, + }, + }, + ], +}; diff --git a/packages/plugins/@nocobase/plugin-workflow-cc/src/common/constants.ts b/packages/plugins/@nocobase/plugin-workflow-cc/src/common/constants.ts new file mode 100644 index 00000000000..55282ad4aab --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow-cc/src/common/constants.ts @@ -0,0 +1,17 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +export const NAMESPACE = '@nocobase/plugin-workflow-cc'; + +export const TASK_TYPE_CC = 'cc'; + +export const TASK_STATUS = { + UNREAD: 0, + READ: 1, +}; diff --git a/packages/plugins/@nocobase/plugin-workflow-cc/src/index.ts b/packages/plugins/@nocobase/plugin-workflow-cc/src/index.ts new file mode 100644 index 00000000000..be99a2ff1ae --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow-cc/src/index.ts @@ -0,0 +1,11 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +export * from './server'; +export { default } from './server'; diff --git a/packages/plugins/@nocobase/plugin-workflow-cc/src/locale/en-US.json b/packages/plugins/@nocobase/plugin-workflow-cc/src/locale/en-US.json new file mode 100644 index 00000000000..ef23d3dbac6 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow-cc/src/locale/en-US.json @@ -0,0 +1,15 @@ +{ + "CC": "CC", + "Information within the workflow can be freely configured into a view interface and made accessible to specific users. A CC task will be generated for them in the tasks center.": "Information within the workflow can be freely configured into a view interface and made accessible to specific users. A CC task will be generated for them in the tasks center.", + "Recipients": "Recipients", + "User interface": "User interface", + "View user interface": "View user interface", + "Configure user interface": "Configure user interface", + "Title of each CC item in tasks center. Could use variables in string template. Default to node title.": "Title of each CC item in tasks center. Could use variables in string template. Default to node title.", + "Read": "Read", + "Unread": "Unread", + "Mark as read": "Mark as read", + "Mark as unread": "Mark as unread", + "Mark all as read": "Mark all as read", + "CC to me": "CC to me" +} diff --git a/packages/plugins/@nocobase/plugin-workflow-cc/src/locale/zh-CN.json b/packages/plugins/@nocobase/plugin-workflow-cc/src/locale/zh-CN.json new file mode 100644 index 00000000000..2664644729a --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow-cc/src/locale/zh-CN.json @@ -0,0 +1,15 @@ +{ + "CC": "抄送", + "Information within the workflow can be freely configured into a view interface and made accessible to specific users. A CC task will be generated for them in the tasks center.": "可将流程中的信息自由配置为查看界面,提供给特定的用户查看,将在待办中心为其生成抄送任务。", + "Recipients": "接收人", + "User interface": "用户界面", + "View user interface": "查看用户界面", + "Configure user interface": "配置用户界面", + "Title of each CC item in tasks center. Could use variables in string template. Default to node title.": "待办中心每个抄送项的标题。可以在字符串模板中使用变量。默认为节点标题。", + "Read": "已读", + "Unread": "未读", + "Mark as read": "标为已读", + "Mark as unread": "标为未读", + "Mark all as read": "全部标为已读", + "CC to me": "抄送我的" +} diff --git a/packages/plugins/@nocobase/plugin-workflow-cc/src/server/CCInstruction.ts b/packages/plugins/@nocobase/plugin-workflow-cc/src/server/CCInstruction.ts new file mode 100644 index 00000000000..53c79972a31 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow-cc/src/server/CCInstruction.ts @@ -0,0 +1,95 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { isValidFilter, uid } from '@nocobase/utils'; +import { UiSchemaRepository } from '@nocobase/plugin-ui-schema-storage'; +import { Instruction, JOB_STATUS } from '@nocobase/plugin-workflow'; +import { TASK_STATUS } from '../common/constants'; + +async function getUsers(config, { db, transaction }): Promise { + const users: Set = new Set(); + const UserRepo = db.getRepository('users'); + for (const item of config) { + if (typeof item === 'object') { + if (!isValidFilter(item.filter)) { + continue; + } + const result = await UserRepo.find({ + ...item, + fields: ['id'], + transaction, + }); + result.forEach((item) => users.add(item.id)); + } else { + users.add(item); + } + } + + return [...users]; +} + +export default class CCInstruction extends Instruction { + static type = 'cc'; + + async run(node, prevJob, processor) { + const { db } = processor.options.plugin; + const job = processor.saveJob({ + status: JOB_STATUS.RESOLVED, + nodeId: node.id, + nodeKey: node.key, + upstreamId: prevJob?.id ?? null, + }); + const usersConfig = processor + .getParsedValue(node.config.users ?? [], node.id) + .flat() + .filter(Boolean); + const users = await getUsers(usersConfig, { db, transaction: processor.mainTransaction }); + + const RecordRepo = db.getRepository('workflowCcTasks'); + const title = node.config.title ? processor.getParsedValue(node.config.title, node.id) : node.title; + const records = await RecordRepo.createMany({ + records: users.map((userId, index) => ({ + userId, + jobId: job.id, + nodeId: node.id, + executionId: job.executionId, + workflowId: node.workflowId, + status: TASK_STATUS.UNREAD, + title, + })), + transaction: processor.mainTransaction, + }); + + // const { notifications = [] } = node.config; + // if (notifications.length) { + // records + // .filter((record) => record.status === TASK_STATUS.UNREAD) + // .forEach((record) => { + // this.notify(record, context, node, processor); + // }); + // } + + return job; + } + + async duplicateConfig(node, { transaction }) { + const uiSchemaRepo = this.workflow.app.db.getRepository('uiSchemas') as UiSchemaRepository; + if (!node.config.ccDetail) { + return node.config; + } + const result = await uiSchemaRepo.duplicate(node.config.ccDetail, { + transaction, + }); + + return { + ...node.config, + ccDetail: result?.['x-uid'] ?? uid(), + }; + } +} diff --git a/packages/plugins/@nocobase/plugin-workflow-cc/src/server/__tests__/instruction.test.ts b/packages/plugins/@nocobase/plugin-workflow-cc/src/server/__tests__/instruction.test.ts new file mode 100644 index 00000000000..f6a1144cfa5 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow-cc/src/server/__tests__/instruction.test.ts @@ -0,0 +1,295 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import Database from '@nocobase/database'; +import { MockServer } from '@nocobase/test'; + +import PluginWorkflow, { EXECUTION_STATUS, JOB_STATUS } from '@nocobase/plugin-workflow'; +import { getApp, sleep } from '@nocobase/plugin-workflow-test'; +import Plugin from '..'; +import { TASK_STATUS, TASK_TYPE_CC } from '../../common/constants'; + +describe('workflow > instructions > cc', () => { + let app: MockServer; + let db: Database; + let WorkflowModel; + let workflow; + let workflowPlugin: PluginWorkflow; + let ccPlugin: Plugin; + let root; + let rootAgent; + let users; + let userAgents; + let AssignmentModel; + + beforeEach(async () => { + app = await getApp({ + plugins: ['users', 'auth', [Plugin, { packageName: '@nocobase/plugin-workflow-cc' }]], + }); + + db = app.db; + + workflowPlugin = app.pm.get(PluginWorkflow); + ccPlugin = app.pm.get(Plugin); + + WorkflowModel = db.getCollection('workflows').model; + AssignmentModel = db.getCollection('workflowCcTasks').model; + + const UserRepo = db.getRepository('users'); + + root = await UserRepo.findOne({}); + + users = await UserRepo.createMany({ + records: [ + { id: 2, nickname: 'a', email: 'a@test.com', password: 'password' }, + { id: 3, nickname: 'b', email: 'b@test.com', password: 'password' }, + { id: 4, nickname: 'c', email: 'c@test.com', password: 'password' }, + ], + }); + + rootAgent = await app.agent().login(root); + userAgents = await Promise.all(users.map((user) => app.agent().login(user))); + + workflow = await WorkflowModel.create({ + enabled: true, + type: 'syncTrigger', + }); + }); + + afterEach(async () => { + await app.destroy(); + }); + + describe('recipients', () => { + it('should do nothing when no recipients configured', async () => { + await workflow.createNode({ + type: 'cc', + config: { + title: 'test title', + }, + }); + + await workflowPlugin.trigger(workflow, {}); + + const [execution] = await workflow.getExecutions(); + expect(execution.status).toBe(EXECUTION_STATUS.RESOLVED); + + const jobs = await execution.getJobs(); + expect(jobs.length).toBe(1); + expect(jobs[0].status).toBe(EXECUTION_STATUS.RESOLVED); + + const assignmentCount = await AssignmentModel.count(); + expect(assignmentCount).toBe(0); + }); + + it('task should be created to static user', async () => { + await workflow.createNode({ + type: 'cc', + config: { + title: 'test title', + users: [users[0].id, users[1].id], + }, + }); + + await workflowPlugin.trigger(workflow, {}); + + const [execution] = await workflow.getExecutions(); + expect(execution.status).toBe(EXECUTION_STATUS.RESOLVED); + + const jobs = await execution.getJobs(); + expect(jobs.length).toBe(1); + expect(jobs[0].status).toBe(JOB_STATUS.RESOLVED); + + const assignments = await AssignmentModel.findAll(); + expect(assignments.length).toBe(2); + expect(assignments.map((item) => item.userId).sort()).toEqual([users[0].id, users[1].id].sort()); + assignments.forEach((item) => { + expect(item.status).toBe(TASK_STATUS.UNREAD); + }); + }); + + it('task should be created to user by variable', async () => { + await workflow.createNode({ + type: 'cc', + config: { + title: 'test title', + users: ['{{$context.data.userId}}'], + }, + }); + + await workflowPlugin.trigger(workflow, { + data: { + userId: users[0].id, + }, + }); + + const [execution] = await workflow.getExecutions(); + expect(execution.status).toBe(EXECUTION_STATUS.RESOLVED); + + const jobs = await execution.getJobs(); + expect(jobs.length).toBe(1); + expect(jobs[0].status).toBe(JOB_STATUS.RESOLVED); + + const assignments = await AssignmentModel.findAll(); + expect(assignments.length).toBe(1); + expect(assignments[0].userId).toBe(users[0].id); + expect(assignments[0].status).toBe(TASK_STATUS.UNREAD); + }); + + it('task should be created to user by query', async () => { + await workflow.createNode({ + type: 'cc', + config: { + title: 'test title', + users: [ + { + filter: { + id: ['{{$context.data.userId}}'], + }, + }, + ], + }, + }); + + await workflowPlugin.trigger(workflow, { + data: { + userId: users[0].id, + }, + }); + + const [execution] = await workflow.getExecutions(); + expect(execution.status).toBe(EXECUTION_STATUS.RESOLVED); + + const jobs = await execution.getJobs(); + expect(jobs.length).toBe(1); + expect(jobs[0].status).toBe(JOB_STATUS.RESOLVED); + + const assignments = await AssignmentModel.findAll(); + expect(assignments.length).toBe(1); + expect(assignments[0].userId).toBe(users[0].id); + expect(assignments[0].status).toBe(TASK_STATUS.UNREAD); + }); + }); + + describe('task title', () => { + it('should use node title as task title', async () => { + await workflow.createNode({ + type: 'cc', + config: { + title: 'test title for {{$context.a}}', + users: [users[0].id, users[1].id], + }, + }); + await workflowPlugin.trigger(workflow, { a: 123 }); + + const [execution] = await workflow.getExecutions(); + expect(execution.status).toBe(EXECUTION_STATUS.RESOLVED); + + const jobs = await execution.getJobs(); + expect(jobs.length).toBe(1); + expect(jobs[0].status).toBe(JOB_STATUS.RESOLVED); + + const assignments = await AssignmentModel.findAll(); + expect(assignments.length).toBe(2); + expect(assignments.map((item) => item.title)).toEqual(['test title for 123', 'test title for 123']); + }); + }); + + describe('actions', () => { + let UserTasksRepo; + beforeEach(async () => { + UserTasksRepo = db.getRepository('userWorkflowTasks'); + await workflow.createNode({ + type: 'cc', + config: { + users: [users[0].id, users[1].id], + }, + }); + + await workflowPlugin.trigger(workflow, {}); + }); + + it('should read a task', async () => { + const uts1 = await UserTasksRepo.find({ sort: 'userId' }); + expect(uts1.length).toBe(2); + expect(uts1[0].type).toBe(TASK_TYPE_CC); + expect(uts1[1].type).toBe(TASK_TYPE_CC); + expect(uts1[0].stats).toEqual({ pending: 1, all: 1 }); + expect(uts1[1].stats).toEqual({ pending: 1, all: 1 }); + + const [assignment] = await AssignmentModel.findAll({ order: [['userId', 'ASC']] }); + expect(assignment.status).toBe(TASK_STATUS.UNREAD); + + const res1 = await userAgents[0].resource('workflowCcTasks').read({ + filterByTk: assignment.id, + }); + expect(res1.status).toBe(200); + expect(res1.body.data.length).toBe(1); + expect(res1.body.data[0].status).toBe(TASK_STATUS.READ); + + const uts2 = await UserTasksRepo.find({ sort: 'userId' }); + expect(uts2.length).toBe(2); + expect(uts2[0].type).toBe(TASK_TYPE_CC); + expect(uts2[1].type).toBe(TASK_TYPE_CC); + expect(uts2[0].stats).toEqual({ pending: 0, all: 1 }); + expect(uts2[1].stats).toEqual({ pending: 1, all: 1 }); + + await assignment.reload(); + expect(assignment.status).toBe(TASK_STATUS.READ); + + const res2 = await userAgents[0].resource('workflowCcTasks').read({ + filterByTk: assignment.id, + }); + expect(res2.status).toBe(200); + expect(res2.body.data.length).toBe(0); + }); + + it('should unread a task', async () => { + const [assignment] = await AssignmentModel.findAll({ order: [['userId', 'ASC']] }); + expect(assignment.status).toBe(TASK_STATUS.UNREAD); + + const res1 = await userAgents[0].resource('workflowCcTasks').unread({ + filterByTk: assignment.id, + }); + expect(res1.status).toBe(200); + await assignment.reload(); + expect(assignment.status).toBe(TASK_STATUS.UNREAD); + + await assignment.update({ status: TASK_STATUS.READ }); + + const res2 = await userAgents[0].resource('workflowCcTasks').unread({ + filterByTk: assignment.id, + }); + expect(res2.status).toBe(200); + expect(res2.body.data.length).toBe(1); + expect(res2.body.data[0].status).toBe(TASK_STATUS.UNREAD); + + await assignment.reload(); + expect(assignment.status).toBe(TASK_STATUS.UNREAD); + }); + + it('should read all tasks', async () => { + const res = await userAgents[0].resource('workflowCcTasks').read(); + expect(res.status).toBe(200); + + const [a1, a2] = await AssignmentModel.findAll({ order: [['userId', 'ASC']] }); + expect(a1.status).toBe(TASK_STATUS.READ); + expect(a2.status).toBe(TASK_STATUS.UNREAD); + }); + + it('should not access others task', async () => { + const [assignment] = await AssignmentModel.findAll({ order: [['userId', 'ASC']] }); + + const res = await userAgents[2].resource('workflowCcTasks').read({ + filterByTk: assignment.id, + }); + expect(res.status).toBe(403); + }); + }); +}); diff --git a/packages/plugins/@nocobase/plugin-workflow-cc/src/server/actions.ts b/packages/plugins/@nocobase/plugin-workflow-cc/src/server/actions.ts new file mode 100644 index 00000000000..8707ccf1621 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow-cc/src/server/actions.ts @@ -0,0 +1,90 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import actions, { Context } from '@nocobase/actions'; + +const workflowCcTasks = { + async get(context: Context, next) { + context.action.mergeParams({ + filter: { + userId: context.state.currentUser.id, + }, + }); + return actions.get(context, next); + }, + + async listMine(context: Context, next) { + context.action.mergeParams({ + filter: { + userId: context.state.currentUser.id, + }, + }); + return actions.list(context, next); + }, + + async read(context: Context, next) { + const { filterByTk } = context.action.params; + if (filterByTk) { + const repository = context.app.db.getRepository('workflowCcTasks'); + const item = await repository.findOne({ where: { id: filterByTk } }); + if (!item) { + return context.throw(404, 'Task not found'); + } + if (item.userId !== context.state.currentUser.id) { + return context.throw(403, 'You do not have permission to access this task'); + } + } + context.action.mergeParams({ + filterByTk, + filter: { + userId: context.state.currentUser.id, + status: 0, + }, + values: { + status: 1, + readAt: new Date(), + }, + }); + return actions.update(context, next); + }, + + async unread(context: Context, next) { + const { filterByTk } = context.action.params; + if (!filterByTk) { + return context.throw(400, 'filterByTk is required for unread action'); + } + context.action.mergeParams({ + filterByTk, + filter: { + userId: context.state.currentUser.id, + }, + values: { + status: 0, + readAt: null, + }, + }); + return actions.update(context, next); + }, +}; + +function make(name, mod) { + return Object.keys(mod).reduce( + (result, key) => ({ + ...result, + [`${name}:${key}`]: mod[key], + }), + {}, + ); +} + +export function initActions(app) { + app.resourceManager.registerActionHandlers({ + ...make('workflowCcTasks', workflowCcTasks), + }); +} diff --git a/packages/plugins/@nocobase/plugin-workflow-cc/src/server/collections/.gitkeep b/packages/plugins/@nocobase/plugin-workflow-cc/src/server/collections/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/plugins/@nocobase/plugin-workflow-cc/src/server/collections/workflowCcTasks.ts b/packages/plugins/@nocobase/plugin-workflow-cc/src/server/collections/workflowCcTasks.ts new file mode 100644 index 00000000000..345567b834e --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow-cc/src/server/collections/workflowCcTasks.ts @@ -0,0 +1,22 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This program is offered under a commercial license. + * For more information, see + */ + +import { CollectionOptions } from '@nocobase/database'; +import collectionOptions from '../../common/collections/workflowCcTasks'; + +export default collectionOptions as CollectionOptions; diff --git a/packages/plugins/@nocobase/plugin-workflow-cc/src/server/index.ts b/packages/plugins/@nocobase/plugin-workflow-cc/src/server/index.ts new file mode 100644 index 00000000000..be989de7c3c --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow-cc/src/server/index.ts @@ -0,0 +1,10 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +export { default } from './plugin'; diff --git a/packages/plugins/@nocobase/plugin-workflow-cc/src/server/plugin.ts b/packages/plugins/@nocobase/plugin-workflow-cc/src/server/plugin.ts new file mode 100644 index 00000000000..b110dfd09b4 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow-cc/src/server/plugin.ts @@ -0,0 +1,58 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { Plugin } from '@nocobase/server'; +import { Model } from '@nocobase/database'; + +import PluginWorkflowServer from '@nocobase/plugin-workflow'; +import CCInstruction from './CCInstruction'; +import { TASK_STATUS, TASK_TYPE_CC } from '../common/constants'; +import { initActions } from './actions'; + +export class PluginWorkflowCCServer extends Plugin { + onRecordSave = async (record: Model, { transaction }) => { + // TODO: no applicant should be deprecated + if (!record.userId) { + return; + } + const CCModel = record.constructor as unknown as Model; + const pending = await CCModel.count({ + where: { + userId: record.userId, + status: TASK_STATUS.UNREAD, + }, + transaction, + }); + const all = await CCModel.count({ + where: { + userId: record.userId, + }, + transaction, + }); + await (this.app.pm.get(PluginWorkflowServer) as PluginWorkflowServer).updateTasksStats( + record.userId, + TASK_TYPE_CC, + { pending, all }, + { transaction }, + ); + }; + + async load() { + initActions(this.app); + + this.app.acl.allow('workflowCcTasks', ['list', 'listMine', 'get', 'read', 'unread', 'readAll'], 'loggedIn'); + + const workflowPlugin = this.app.pm.get(PluginWorkflowServer) as PluginWorkflowServer; + workflowPlugin.registerInstruction('cc', CCInstruction); + + this.app.db.on('workflowCcTasks.afterSave', this.onRecordSave); + } +} + +export default PluginWorkflowCCServer;