feat(plugin-workflow-cc): add new plugin (#7201)

This commit is contained in:
Junyi
2025-07-12 17:53:02 +08:00
committed by GitHub
parent ecb32f5d55
commit bf00da846a
26 changed files with 2043 additions and 0 deletions
@@ -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-*
@@ -0,0 +1,2 @@
/node_modules
/src
@@ -0,0 +1,16 @@
# @nocobase/plugin-workflow-cc
## 分支
和 nocobase 仓库一样,也分为 main 和 next 两个分支
- main:只处理缺陷修复
- next:新插件、新特性、不兼容性变更等
## 提测
- 提交 PR 之后,会自动生成 PR 测试环境,测试通过后再合并到对应分支
```bash
pr-<number>-<pro-tpl>.test.nocobase.com
```
@@ -0,0 +1,2 @@
export * from './dist/client';
export { default } from './dist/client';
@@ -0,0 +1 @@
module.exports = require('./dist/client/index.js');
@@ -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"
]
}
@@ -0,0 +1,2 @@
export * from './dist/server';
export { default } from './dist/server';
@@ -0,0 +1 @@
module.exports = require('./dist/server/index.js');
@@ -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<WebAssembly.Instance>;
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;
}
@@ -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 <ExtendCollectionsProvider collections={[ccCollection]}>{props.children}</ExtendCollectionsProvider>;
}
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;
@@ -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 <Spin />;
}
const nodeComponents = {};
nodes.forEach((item) => {
const instruction = workflowPlugin.instructions.get(item.type);
Object.assign(nodeComponents, instruction.components);
});
return (
<SchemaComponentProvider scope={scope} components={components} designable={!executed}>
<SchemaComponent
scope={{
useReadAction() {
return { run() {} };
},
}}
components={{
...nodeComponents,
}}
schema={data as ISchema}
/>
</SchemaComponentProvider>
);
}
export function SchemaConfig({ value, onChange }) {
return (
<SchemaComponent
components={{
SchemaContent,
}}
schema={
{
name: 'drawer',
type: 'void',
title: `{{t("User interface", { ns: "${NAMESPACE}" })}}`,
'x-component': 'Action.Container',
'x-component-props': {
className: css`
.ant-drawer-body {
background: var(--nb-box-bg);
}
`,
},
properties: {
ccDetail: {
type: 'string',
'x-component': 'SchemaContent',
},
},
} as ISchema
}
/>
);
}
const ConfigChangedContext = React.createContext<any>({});
const ForceReadHint = observer(() => {
const { initialValues, values } = useForm();
return initialValues.forceRead != values.forceRead ? (
<Typography.Paragraph type="secondary">
<Typography.Text type="warning">{lang('Force read changes unsaved, click "Submit" to save.')}</Typography.Text>
</Typography.Paragraph>
) : null;
});
export function SchemaConfigButton(props) {
const executed = useWorkflowExecuted();
const { setFormValueChanged } = useActionContext();
const [visible, setVisible] = useState(false);
const { t } = usePluginTranslation();
return (
<ConfigChangedContext.Provider value={{ setFormValueChanged }}>
<Button type="primary" onClick={() => setVisible(true)} disabled={false}>
{t(executed ? 'View user interface' : 'Configure user interface')}
</Button>
<ActionContextProvider value={{ visible, setVisible, formValueChanged: false }}>
{props.children}
</ActionContextProvider>
<ForceReadHint />
</ConfigChangedContext.Provider>
);
}
@@ -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 <https://www.nocobase.com/agreement>
*/
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 = (<EyeOutlined />);
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,
};
}
@@ -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 <https://www.nocobase.com/agreement>
*/
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);
}
@@ -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<any>(null);
const [record, setRecord] = useState<any>(useCollectionRecordData());
const [node, setNode] = useState<any>(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 ? (
<CollectionRecordProvider record={record}>
<FlowContext.Provider value={flowContext}>
<NodeContext.Provider value={node}>
<RemoteSchemaComponent
components={{
DetailsBlockProvider,
...nodeComponents,
}}
scope={{
useDetailsBlockProps,
}}
uid={node.config?.ccDetail}
noForm
/>
</NodeContext.Provider>
</FlowContext.Provider>
</CollectionRecordProvider>
) : (
<Spin />
);
}
function Drawer() {
const ctx = useContext(SchemaComponentContext);
const record = useCollectionRecordData();
const { id, node, workflow, status } = record || {};
return record ? (
<SchemaComponentContext.Provider value={{ ...ctx, reset() {}, designable: false }}>
<SchemaComponent
components={{
FlowContextProvider,
}}
scope={{
useReadAction,
useReadActionProps,
}}
schema={{
type: 'void',
name: `manual-detail-drawer-${id}-${status}`,
'x-component': 'Action.Container',
'x-component-props': {
className: css`
.ant-modal-content {
background: var(--nb-box-bg);
}
.ant-modal-header {
background: none;
}
`,
},
title: node?.title ?? `${workflow?.title} - ${`#${node?.id}`}`,
properties: {
tabs: {
type: 'void',
'x-component': 'FlowContextProvider',
},
footer: {
type: 'void',
'x-component': 'Action.Container.Footer',
properties: {
content: {
type: 'void',
'x-component': 'ActionBar',
'x-component-props': {
style: {
marginBottom: 0,
},
},
properties: {
read: {
type: 'void',
'x-component': 'Action',
'x-use-component-props': 'useReadActionProps',
'x-component-props': {
useAction: '{{useReadAction}}',
},
},
},
},
},
},
},
}}
/>
</SchemaComponentContext.Provider>
) : null;
}
function ContentDetail(props) {
const { t } = useTranslation();
const token = useAntdToken();
return (
<ConfigProvider
theme={{
token: {
fontSizeLG: 14,
},
}}
>
<Descriptions
{...props}
column={1}
items={[
{
key: 'createdAt',
label: t('Created at'),
children: (
<SchemaComponent
schema={{
name: 'createdAt',
type: 'string',
'x-component': 'CollectionField',
'x-read-pretty': true,
}}
/>
),
},
{
key: 'status',
label: t('Status', { ns: 'workflow' }),
children: (
<SchemaComponent
components={
{
// TaskStatusColumn,
}
}
schema={{
name: 'status',
type: 'number',
// 'x-decorator': 'TaskStatusColumn',
'x-component': 'CollectionField',
'x-read-pretty': true,
}}
/>
),
},
]}
className={css`
.ant-descriptions-header {
margin-bottom: 0.5em;
.ant-descriptions-extra {
color: ${token.colorTextDescription};
}
}
.ant-descriptions-item-label {
width: 6em;
}
`}
/>
</ConfigProvider>
);
}
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 (
<ContentDetail
title={<RecordTitle dataIndex={['title', 'node.title']} />}
extra={<RecordTitle dataIndex={'workflow.title'} />}
/>
);
}
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 (
<Card
onClick={onOpen}
hoverable
size="small"
title={record.title}
extra={<WorkflowTitle {...record.workflow} />}
className={css`
.ant-card-extra {
color: ${token.colorTextDescription};
}
`}
>
<ContentDetail />
</Card>
);
}
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 (
<SchemaComponent
scope={{
useReadAllAction,
useReadAllActionProps,
}}
schema={{
name: 'actions',
type: 'void',
'x-component': 'ActionBar',
properties: {
refresh: {
type: 'void',
title: '{{ t("Refresh") }}',
'x-component': 'Action',
'x-use-component-props': 'useRefreshActionProps',
'x-component-props': {
icon: 'ReloadOutlined',
...props,
},
},
filter: {
type: 'void',
title: '{{t("Filter")}}',
'x-component': 'Filter.Action',
'x-use-component-props': 'useFilterActionProps',
'x-component-props': {
icon: 'FilterOutlined',
...props,
},
default: {
$and: [{ title: { $includes: '' } }, { 'workflow.title': { $includes: '' } }],
},
},
readAll: {
type: 'void',
title: `{{t("Mark all as read", { ns: "${NAMESPACE}" })}}`,
'x-component': 'Action',
'x-use-component-props': 'useReadAllActionProps',
'x-component-props': {
useAction: '{{useReadAllAction}}',
icon: 'CheckOutlined',
...props,
},
},
},
}}
/>
);
}
export const ccTodo = {
title: `{{t("CC to me", { ns: "${NAMESPACE}" })}}`,
collection: 'workflowCcTasks',
action: 'listMine',
useActionParams: useTodoActionParams,
Actions: TodoExtraActions,
Item: TaskItem,
Detail: Drawer,
};
@@ -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,
},
},
},
],
};
@@ -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,
};
@@ -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';
@@ -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"
}
@@ -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": "抄送我的"
}
@@ -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<number[]> {
const users: Set<number> = 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(),
};
}
}
@@ -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);
});
});
});
@@ -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),
});
}
@@ -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 <https://www.nocobase.com/agreement>
*/
import { CollectionOptions } from '@nocobase/database';
import collectionOptions from '../../common/collections/workflowCcTasks';
export default collectionOptions as CollectionOptions;
@@ -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';
@@ -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;