diff --git a/packages/plugins/@nocobase/plugin-block-workbench/package.json b/packages/plugins/@nocobase/plugin-block-workbench/package.json
index 7b5d0f2e30..86ddad6569 100644
--- a/packages/plugins/@nocobase/plugin-block-workbench/package.json
+++ b/packages/plugins/@nocobase/plugin-block-workbench/package.json
@@ -1,6 +1,6 @@
{
"name": "@nocobase/plugin-block-workbench",
- "version": "1.6.18",
+ "version": "1.6.24",
"displayName": "Block: Action panel",
"displayName.zh-CN": "区块:操作面板",
"description": "Centrally manages and displays various actions, allowing users to efficiently perform tasks. It supports extensibility, with current action types including pop-ups, links, scanning, and custom requests.",
diff --git a/packages/plugins/@nocobase/plugin-calendar/package.json b/packages/plugins/@nocobase/plugin-calendar/package.json
index 86122f9019..6fdb263b35 100644
--- a/packages/plugins/@nocobase/plugin-calendar/package.json
+++ b/packages/plugins/@nocobase/plugin-calendar/package.json
@@ -1,6 +1,6 @@
{
"name": "@nocobase/plugin-calendar",
- "version": "1.6.18",
+ "version": "1.6.24",
"displayName": "Calendar",
"displayName.zh-CN": "日历",
"description": "Provides callendar collection template and block for managing date data, typically for date/time related information such as events, appointments, tasks, and so on.",
diff --git a/packages/plugins/@nocobase/plugin-charts/package.json b/packages/plugins/@nocobase/plugin-charts/package.json
index 6efa2a2a5c..0970478ef7 100644
--- a/packages/plugins/@nocobase/plugin-charts/package.json
+++ b/packages/plugins/@nocobase/plugin-charts/package.json
@@ -4,7 +4,7 @@
"displayName.zh-CN": "图表(废弃)",
"description": "The plugin has been deprecated, please use the data visualization plugin instead.",
"description.zh-CN": "已废弃插件,请使用数据可视化插件代替。",
- "version": "1.6.18",
+ "version": "1.6.24",
"main": "./dist/server/index.js",
"license": "AGPL-3.0",
"devDependencies": {
diff --git a/packages/plugins/@nocobase/plugin-client/package.json b/packages/plugins/@nocobase/plugin-client/package.json
index 681ffd2dd6..afdbfbb526 100644
--- a/packages/plugins/@nocobase/plugin-client/package.json
+++ b/packages/plugins/@nocobase/plugin-client/package.json
@@ -4,7 +4,7 @@
"displayName.zh-CN": "WEB 客户端",
"description": "Provides a client interface for the NocoBase server",
"description.zh-CN": "为 NocoBase 服务端提供客户端界面",
- "version": "1.6.18",
+ "version": "1.6.24",
"main": "./dist/server/index.js",
"license": "AGPL-3.0",
"devDependencies": {
diff --git a/packages/plugins/@nocobase/plugin-collection-sql/package.json b/packages/plugins/@nocobase/plugin-collection-sql/package.json
index 9fbe1ebf79..b5c6297900 100644
--- a/packages/plugins/@nocobase/plugin-collection-sql/package.json
+++ b/packages/plugins/@nocobase/plugin-collection-sql/package.json
@@ -4,7 +4,7 @@
"displayName.zh-CN": "数据表: SQL",
"description": "Provides SQL collection template",
"description.zh-CN": "提供 SQL 数据表模板",
- "version": "1.6.18",
+ "version": "1.6.24",
"homepage": "https://docs-cn.nocobase.com/handbook/collection-sql",
"homepage.zh-CN": "https://docs-cn.nocobase.com/handbook/collection-sql",
"main": "dist/server/index.js",
diff --git a/packages/plugins/@nocobase/plugin-collection-sql/src/server/sql-collection/sql-collection.ts b/packages/plugins/@nocobase/plugin-collection-sql/src/server/sql-collection/sql-collection.ts
index f34ad946ba..947aaac6d9 100644
--- a/packages/plugins/@nocobase/plugin-collection-sql/src/server/sql-collection/sql-collection.ts
+++ b/packages/plugins/@nocobase/plugin-collection-sql/src/server/sql-collection/sql-collection.ts
@@ -42,7 +42,7 @@ export class SQLCollection extends Collection {
}
unavailableActions(): Array
{
- return ['create', 'update', 'destroy'];
+ return ['create', 'update', 'destroy', 'importXlsx', 'destroyMany', 'updateMany'];
}
public collectionSchema() {
diff --git a/packages/plugins/@nocobase/plugin-collection-tree/package.json b/packages/plugins/@nocobase/plugin-collection-tree/package.json
index 3a8c755ba6..d9ec2de7aa 100644
--- a/packages/plugins/@nocobase/plugin-collection-tree/package.json
+++ b/packages/plugins/@nocobase/plugin-collection-tree/package.json
@@ -1,6 +1,6 @@
{
"name": "@nocobase/plugin-collection-tree",
- "version": "1.6.18",
+ "version": "1.6.24",
"displayName": "Collection: Tree",
"displayName.zh-CN": "数据表:树",
"description": "Provides tree collection template",
diff --git a/packages/plugins/@nocobase/plugin-data-source-main/package.json b/packages/plugins/@nocobase/plugin-data-source-main/package.json
index 5915bdc438..f416c97033 100644
--- a/packages/plugins/@nocobase/plugin-data-source-main/package.json
+++ b/packages/plugins/@nocobase/plugin-data-source-main/package.json
@@ -4,7 +4,7 @@
"displayName.zh-CN": "数据源:主数据库",
"description": "NocoBase main database, supports relational databases such as PostgreSQL, MySQL, MariaDB and so on.",
"description.zh-CN": "NocoBase 主数据库,支持 PostgreSQL、MySQL、MariaDB 等关系型数据库。",
- "version": "1.6.18",
+ "version": "1.6.24",
"main": "./dist/server/index.js",
"homepage": "https://docs.nocobase.com/handbook/data-source-main",
"homepage.zh-CN": "https://docs-cn.nocobase.com/handbook/data-source-main",
diff --git a/packages/plugins/@nocobase/plugin-data-source-manager/package.json b/packages/plugins/@nocobase/plugin-data-source-manager/package.json
index 36a9e9c460..04529fd14d 100644
--- a/packages/plugins/@nocobase/plugin-data-source-manager/package.json
+++ b/packages/plugins/@nocobase/plugin-data-source-manager/package.json
@@ -1,6 +1,6 @@
{
"name": "@nocobase/plugin-data-source-manager",
- "version": "1.6.18",
+ "version": "1.6.24",
"main": "dist/server/index.js",
"displayName": "Data source manager",
"displayName.zh-CN": "数据源管理",
diff --git a/packages/plugins/@nocobase/plugin-data-visualization/package.json b/packages/plugins/@nocobase/plugin-data-visualization/package.json
index 5ad8673ee1..b08244059f 100644
--- a/packages/plugins/@nocobase/plugin-data-visualization/package.json
+++ b/packages/plugins/@nocobase/plugin-data-visualization/package.json
@@ -1,6 +1,6 @@
{
"name": "@nocobase/plugin-data-visualization",
- "version": "1.6.18",
+ "version": "1.6.24",
"displayName": "Data visualization",
"displayName.zh-CN": "数据可视化",
"description": "Provides data visualization feature, including chart block and chart filter block, support line charts, area charts, bar charts and more than a dozen kinds of charts, you can also extend more chart types.",
diff --git a/packages/plugins/@nocobase/plugin-departments/.npmignore b/packages/plugins/@nocobase/plugin-departments/.npmignore
new file mode 100644
index 0000000000..65f5e8779f
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-departments/.npmignore
@@ -0,0 +1,2 @@
+/node_modules
+/src
diff --git a/packages/plugins/@nocobase/plugin-departments/README.md b/packages/plugins/@nocobase/plugin-departments/README.md
new file mode 100644
index 0000000000..cb4fb63505
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-departments/README.md
@@ -0,0 +1 @@
+# @nocobase/plugin-department
diff --git a/packages/plugins/@nocobase/plugin-departments/client.d.ts b/packages/plugins/@nocobase/plugin-departments/client.d.ts
new file mode 100644
index 0000000000..6c459cbac4
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-departments/client.d.ts
@@ -0,0 +1,2 @@
+export * from './dist/client';
+export { default } from './dist/client';
diff --git a/packages/plugins/@nocobase/plugin-departments/client.js b/packages/plugins/@nocobase/plugin-departments/client.js
new file mode 100644
index 0000000000..b6e3be70e6
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-departments/client.js
@@ -0,0 +1 @@
+module.exports = require('./dist/client/index.js');
diff --git a/packages/plugins/@nocobase/plugin-departments/package.json b/packages/plugins/@nocobase/plugin-departments/package.json
new file mode 100644
index 0000000000..c2115b0571
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-departments/package.json
@@ -0,0 +1,22 @@
+{
+ "name": "@nocobase/plugin-departments",
+ "displayName": "Departments",
+ "displayName.zh-CN": "部门",
+ "description": "Organize users by departments, set hierarchical relationships, link roles to control permissions, and use departments as variables in workflows and expressions.",
+ "description.zh-CN": "以部门来组织用户,设定上下级关系,绑定角色控制权限,并支持作为变量用于工作流和表达式。",
+ "version": "1.6.24",
+ "main": "dist/server/index.js",
+ "devDependencies": {
+ "@nocobase/plugin-user-data-sync": "1.6.24"
+ },
+ "peerDependencies": {
+ "@nocobase/actions": "1.x",
+ "@nocobase/client": "1.x",
+ "@nocobase/server": "1.x",
+ "@nocobase/test": "1.x"
+ },
+ "keywords": [
+ "Users & permissions"
+ ],
+ "gitHead": "080fc78c1a744d47e010b3bbe5840446775800e4"
+}
diff --git a/packages/plugins/@nocobase/plugin-departments/server.d.ts b/packages/plugins/@nocobase/plugin-departments/server.d.ts
new file mode 100644
index 0000000000..c41081ddc6
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-departments/server.d.ts
@@ -0,0 +1,2 @@
+export * from './dist/server';
+export { default } from './dist/server';
diff --git a/packages/plugins/@nocobase/plugin-departments/server.js b/packages/plugins/@nocobase/plugin-departments/server.js
new file mode 100644
index 0000000000..972842039a
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-departments/server.js
@@ -0,0 +1 @@
+module.exports = require('./dist/server/index.js');
diff --git a/packages/plugins/@nocobase/plugin-departments/src/client/ResourcesProvider.tsx b/packages/plugins/@nocobase/plugin-departments/src/client/ResourcesProvider.tsx
new file mode 100644
index 0000000000..2ad0daac83
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-departments/src/client/ResourcesProvider.tsx
@@ -0,0 +1,110 @@
+/**
+ * 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 { CollectionProvider_deprecated, ResourceActionContext, TableBlockContext, useRequest } from '@nocobase/client';
+import React, { useContext, useEffect, useMemo } from 'react';
+import { departmentCollection } from './collections/departments';
+import { userCollection } from './collections/users';
+import { FormContext } from '@formily/react';
+import { createForm } from '@formily/core';
+
+export const ResourcesContext = React.createContext<{
+ user: any;
+ setUser?: (user: any) => void;
+ department: any; // department name
+ setDepartment?: (department: any) => void;
+ departmentsResource?: any;
+ usersResource?: any;
+}>({
+ user: {},
+ department: {},
+});
+
+export const ResourcesProvider: React.FC = (props) => {
+ const [user, setUser] = React.useState(null);
+ const [department, setDepartment] = React.useState(null);
+
+ const userService = useRequest({
+ resource: 'users',
+ action: 'list',
+ params: {
+ appends: ['departments', 'departments.parent(recursively=true)'],
+ filter: department
+ ? {
+ 'departments.id': department.id,
+ }
+ : {},
+ pageSize: 20,
+ },
+ });
+
+ useEffect(() => {
+ userService.run();
+ }, [department]);
+
+ const departmentRequest = {
+ resource: 'departments',
+ action: 'list',
+ params: {
+ paginate: false,
+ filter: {
+ parentId: null,
+ },
+ },
+ };
+ const departmentService = useRequest(departmentRequest);
+
+ return (
+
+ {props.children}
+
+ );
+};
+
+export const DepartmentsListProvider: React.FC = (props) => {
+ const { departmentsResource } = useContext(ResourcesContext);
+ const { service } = departmentsResource || {};
+ return (
+
+ {props.children}
+
+ );
+};
+
+export const UsersListProvider: React.FC = (props) => {
+ const { usersResource } = useContext(ResourcesContext);
+ const { service } = usersResource || {};
+ const form = useMemo(() => createForm(), []);
+ const field = form.createField({ name: 'table' });
+ return (
+
+
+ {props.children}
+
+
+ );
+};
diff --git a/packages/plugins/@nocobase/plugin-departments/src/client/collections/departments.ts b/packages/plugins/@nocobase/plugin-departments/src/client/collections/departments.ts
new file mode 100644
index 0000000000..38105c0b2b
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-departments/src/client/collections/departments.ts
@@ -0,0 +1,115 @@
+/**
+ * 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
+ */
+
+export const departmentCollection = {
+ name: 'departments',
+ fields: [
+ {
+ type: 'bigInt',
+ name: 'id',
+ primaryKey: true,
+ autoIncrement: true,
+ interface: 'id',
+ uiSchema: {
+ type: 'id',
+ title: '{{t("ID")}}',
+ },
+ },
+ {
+ name: 'title',
+ type: 'string',
+ interface: 'input',
+ uiSchema: {
+ type: 'string',
+ title: '{{t("Department name")}}',
+ 'x-component': 'Input',
+ required: true,
+ },
+ },
+ {
+ name: 'parent',
+ type: 'belongsTo',
+ interface: 'm2o',
+ collectionName: 'departments',
+ foreignKey: 'parentId',
+ target: 'departments',
+ targetKey: 'id',
+ treeParent: true,
+ uiSchema: {
+ title: '{{t("Superior department")}}',
+ 'x-component': 'DepartmentSelect',
+ // 'x-component-props': {
+ // multiple: false,
+ // fieldNames: {
+ // label: 'title',
+ // value: 'id',
+ // },
+ // },
+ },
+ },
+ {
+ interface: 'm2m',
+ type: 'belongsToMany',
+ name: 'roles',
+ target: 'roles',
+ collectionName: 'departments',
+ through: 'departmentsRoles',
+ foreignKey: 'departmentId',
+ otherKey: 'roleName',
+ targetKey: 'name',
+ sourceKey: 'id',
+ uiSchema: {
+ title: '{{t("Roles")}}',
+ 'x-component': 'AssociationField',
+ 'x-component-props': {
+ multiple: true,
+ fieldNames: {
+ label: 'title',
+ value: 'name',
+ },
+ },
+ },
+ },
+ {
+ interface: 'm2m',
+ type: 'belongsToMany',
+ name: 'owners',
+ collectionName: 'departments',
+ target: 'users',
+ through: 'departmentsUsers',
+ foreignKey: 'departmentId',
+ otherKey: 'userId',
+ targetKey: 'id',
+ sourceKey: 'id',
+ scope: {
+ isOwner: true,
+ },
+ uiSchema: {
+ title: '{{t("Owners")}}',
+ 'x-component': 'AssociationField',
+ 'x-component-props': {
+ multiple: true,
+ fieldNames: {
+ label: 'nickname',
+ value: 'id',
+ },
+ },
+ },
+ },
+ ],
+};
diff --git a/packages/plugins/@nocobase/plugin-departments/src/client/collections/users.ts b/packages/plugins/@nocobase/plugin-departments/src/client/collections/users.ts
new file mode 100644
index 0000000000..ba309455a7
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-departments/src/client/collections/users.ts
@@ -0,0 +1,142 @@
+/**
+ * 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
+ */
+
+export const userCollection = {
+ name: 'users',
+ fields: [
+ {
+ name: 'id',
+ type: 'bigInt',
+ autoIncrement: true,
+ primaryKey: true,
+ allowNull: false,
+ uiSchema: { type: 'number', title: '{{t("ID")}}', 'x-component': 'InputNumber', 'x-read-pretty': true },
+ interface: 'id',
+ },
+ {
+ interface: 'input',
+ type: 'string',
+ name: 'nickname',
+ uiSchema: {
+ type: 'string',
+ title: '{{t("Nickname")}}',
+ 'x-component': 'Input',
+ },
+ },
+ {
+ interface: 'input',
+ type: 'string',
+ name: 'username',
+ unique: true,
+ uiSchema: {
+ type: 'string',
+ title: '{{t("Username")}}',
+ 'x-component': 'Input',
+ 'x-validator': { username: true },
+ required: true,
+ },
+ },
+ {
+ interface: 'email',
+ type: 'string',
+ name: 'email',
+ unique: true,
+ uiSchema: {
+ type: 'string',
+ title: '{{t("Email")}}',
+ 'x-component': 'Input',
+ 'x-validator': 'email',
+ required: true,
+ },
+ },
+ {
+ interface: 'phone',
+ type: 'string',
+ name: 'phone',
+ unique: true,
+ uiSchema: {
+ type: 'string',
+ title: '{{t("Phone")}}',
+ 'x-component': 'Input',
+ 'x-validator': 'phone',
+ required: true,
+ },
+ },
+ {
+ interface: 'm2m',
+ type: 'belongsToMany',
+ name: 'roles',
+ target: 'roles',
+ foreignKey: 'userId',
+ otherKey: 'roleName',
+ onDelete: 'CASCADE',
+ sourceKey: 'id',
+ targetKey: 'name',
+ through: 'rolesUsers',
+ uiSchema: {
+ type: 'array',
+ title: '{{t("Roles")}}',
+ 'x-component': 'AssociationField',
+ 'x-component-props': {
+ multiple: true,
+ fieldNames: {
+ label: 'title',
+ value: 'name',
+ },
+ },
+ },
+ },
+ {
+ name: 'departments',
+ type: 'belongsToMany',
+ interface: 'm2m',
+ target: 'departments',
+ foreignKey: 'userId',
+ otherKey: 'departmentId',
+ onDelete: 'CASCADE',
+ sourceKey: 'id',
+ targetKey: 'id',
+ through: 'departmentsUsers',
+ uiSchema: {
+ type: 'array',
+ title: '{{t("Departments")}}',
+ 'x-component': 'DepartmentField',
+ },
+ },
+ {
+ interface: 'm2m',
+ type: 'belongsToMany',
+ name: 'mainDepartment',
+ target: 'departments',
+ foreignKey: 'userId',
+ otherKey: 'departmentId',
+ onDelete: 'CASCADE',
+ sourceKey: 'id',
+ targetKey: 'id',
+ through: 'departmentsUsers',
+ throughScope: {
+ isMain: true,
+ },
+ uiSchema: {
+ type: 'array',
+ title: '{{t("Main department")}}',
+ 'x-component': 'DepartmentField',
+ },
+ },
+ ],
+};
diff --git a/packages/plugins/@nocobase/plugin-departments/src/client/components/DepartmentOwnersField.tsx b/packages/plugins/@nocobase/plugin-departments/src/client/components/DepartmentOwnersField.tsx
new file mode 100644
index 0000000000..afcb651250
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-departments/src/client/components/DepartmentOwnersField.tsx
@@ -0,0 +1,35 @@
+/**
+ * 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 { SchemaSettings } from '@nocobase/client';
+import { enableLink, fieldComponent, titleField } from './fieldSettings';
+
+export const DepartmentOwnersFieldSettings = new SchemaSettings({
+ name: 'fieldSettings:component:DepartmentOwnersField',
+ items: [
+ {
+ ...fieldComponent,
+ },
+ {
+ ...titleField,
+ },
+ {
+ ...enableLink,
+ },
+ ],
+});
diff --git a/packages/plugins/@nocobase/plugin-departments/src/client/components/ReadOnlyAssociationField.tsx b/packages/plugins/@nocobase/plugin-departments/src/client/components/ReadOnlyAssociationField.tsx
new file mode 100644
index 0000000000..d843d62847
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-departments/src/client/components/ReadOnlyAssociationField.tsx
@@ -0,0 +1,27 @@
+/**
+ * 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 { useDepartmentTranslation } from '../locale';
+import { AssociationField } from '@nocobase/client';
+import { connect, mapReadPretty } from '@formily/react';
+
+export const ReadOnlyAssociationField = connect(() => {
+ const { t } = useDepartmentTranslation();
+ return {t('This field is currently not supported for use in form blocks.')}
;
+}, mapReadPretty(AssociationField.ReadPretty));
diff --git a/packages/plugins/@nocobase/plugin-departments/src/client/components/UserDepartmentsField.tsx b/packages/plugins/@nocobase/plugin-departments/src/client/components/UserDepartmentsField.tsx
new file mode 100644
index 0000000000..e866411db2
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-departments/src/client/components/UserDepartmentsField.tsx
@@ -0,0 +1,35 @@
+/**
+ * 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 { SchemaSettings } from '@nocobase/client';
+import { enableLink, fieldComponent, titleField } from './fieldSettings';
+
+export const UserDepartmentsFieldSettings = new SchemaSettings({
+ name: 'fieldSettings:component:UserDepartmentsField',
+ items: [
+ {
+ ...fieldComponent,
+ },
+ {
+ ...titleField,
+ },
+ {
+ ...enableLink,
+ },
+ ],
+});
diff --git a/packages/plugins/@nocobase/plugin-departments/src/client/components/UserMainDepartmentField.tsx b/packages/plugins/@nocobase/plugin-departments/src/client/components/UserMainDepartmentField.tsx
new file mode 100644
index 0000000000..0e8355e408
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-departments/src/client/components/UserMainDepartmentField.tsx
@@ -0,0 +1,35 @@
+/**
+ * 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 { SchemaSettings } from '@nocobase/client';
+import { enableLink, fieldComponent, titleField } from './fieldSettings';
+
+export const UserMainDepartmentFieldSettings = new SchemaSettings({
+ name: 'fieldSettings:component:UserMainDepartmentField',
+ items: [
+ {
+ ...fieldComponent,
+ },
+ {
+ ...titleField,
+ },
+ {
+ ...enableLink,
+ },
+ ],
+});
diff --git a/packages/plugins/@nocobase/plugin-departments/src/client/components/fieldSettings.ts b/packages/plugins/@nocobase/plugin-departments/src/client/components/fieldSettings.ts
new file mode 100644
index 0000000000..bc637afa40
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-departments/src/client/components/fieldSettings.ts
@@ -0,0 +1,185 @@
+/**
+ * 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 {
+ useCollectionField,
+ useCollectionManager_deprecated,
+ useCollection_deprecated,
+ useCompile,
+ useDesignable,
+ useFieldComponentName,
+ useFieldModeOptions,
+ useIsAddNewForm,
+ useTitleFieldOptions,
+} from '@nocobase/client';
+import { useDepartmentTranslation } from '../locale';
+import { Field } from '@formily/core';
+import { useField, useFieldSchema, ISchema } from '@formily/react';
+
+export const titleField: any = {
+ name: 'titleField',
+ type: 'select',
+ useComponentProps() {
+ const { t } = useDepartmentTranslation();
+ const field = useField();
+ const { dn } = useDesignable();
+ const options = useTitleFieldOptions();
+ const { uiSchema, fieldSchema: tableColumnSchema, collectionField: tableColumnField } = useColumnSchema();
+ const schema = useFieldSchema();
+ const fieldSchema = tableColumnSchema || schema;
+ const targetCollectionField = useCollectionField();
+ const collectionField = tableColumnField || targetCollectionField;
+ const fieldNames = {
+ ...collectionField?.uiSchema?.['x-component-props']?.['fieldNames'],
+ ...field?.componentProps?.fieldNames,
+ ...fieldSchema?.['x-component-props']?.['fieldNames'],
+ };
+ return {
+ title: t('Title field'),
+ options,
+ value: fieldNames?.label,
+ onChange(label) {
+ const schema = {
+ ['x-uid']: fieldSchema['x-uid'],
+ };
+ const newFieldNames = {
+ ...collectionField?.uiSchema?.['x-component-props']?.['fieldNames'],
+ ...fieldSchema['x-component-props']?.['fieldNames'],
+ label,
+ };
+ fieldSchema['x-component-props'] = fieldSchema['x-component-props'] || {};
+ fieldSchema['x-component-props']['fieldNames'] = newFieldNames;
+ schema['x-component-props'] = fieldSchema['x-component-props'];
+ field.componentProps.fieldNames = fieldSchema['x-component-props']?.fieldNames;
+ const path = field.path?.splice(field.path?.length - 1, 1);
+ field.form.query(`${path.concat(`*.` + fieldSchema.name)}`).forEach((f) => {
+ f.componentProps.fieldNames = fieldNames;
+ });
+ dn.emit('patch', {
+ schema,
+ });
+ dn.refresh();
+ },
+ };
+ },
+};
+
+export const isCollectionFieldComponent = (schema: ISchema) => {
+ return schema['x-component'] === 'CollectionField';
+};
+
+const useColumnSchema = () => {
+ const { getField } = useCollection_deprecated();
+ const compile = useCompile();
+ const columnSchema = useFieldSchema();
+ const { getCollectionJoinField } = useCollectionManager_deprecated();
+ const fieldSchema = columnSchema.reduceProperties((buf, s) => {
+ if (isCollectionFieldComponent(s)) {
+ return s;
+ }
+ return buf;
+ }, null);
+ if (!fieldSchema) {
+ return {};
+ }
+
+ const collectionField = getField(fieldSchema.name) || getCollectionJoinField(fieldSchema?.['x-collection-field']);
+ return { columnSchema, fieldSchema, collectionField, uiSchema: compile(collectionField?.uiSchema) };
+};
+
+export const enableLink = {
+ name: 'enableLink',
+ type: 'switch',
+ useVisible() {
+ const field = useField();
+ return field.readPretty;
+ },
+ useComponentProps() {
+ const { t } = useDepartmentTranslation();
+ const field = useField();
+ const { fieldSchema: tableColumnSchema } = useColumnSchema();
+ const schema = useFieldSchema();
+ const fieldSchema = tableColumnSchema || schema;
+ const { dn } = useDesignable();
+ return {
+ title: t('Enable link'),
+ checked: fieldSchema['x-component-props']?.enableLink !== false,
+ onChange(flag) {
+ fieldSchema['x-component-props'] = {
+ ...fieldSchema?.['x-component-props'],
+ enableLink: flag,
+ };
+ field.componentProps['enableLink'] = flag;
+ dn.emit('patch', {
+ schema: {
+ 'x-uid': fieldSchema['x-uid'],
+ 'x-component-props': {
+ ...fieldSchema?.['x-component-props'],
+ },
+ },
+ });
+ dn.refresh();
+ },
+ };
+ },
+};
+
+export const fieldComponent: any = {
+ name: 'fieldComponent',
+ type: 'select',
+ useComponentProps() {
+ const { t } = useDepartmentTranslation();
+ const field = useField();
+ const { fieldSchema: tableColumnSchema, collectionField } = useColumnSchema();
+ const schema = useFieldSchema();
+ const fieldSchema = tableColumnSchema || schema;
+ const fieldModeOptions = useFieldModeOptions({ fieldSchema: tableColumnSchema, collectionField });
+ // const isAddNewForm = useIsAddNewForm();
+ // const fieldMode = useFieldComponentName();
+ const { dn } = useDesignable();
+ return {
+ title: t('Field component'),
+ options: fieldModeOptions,
+ value: 'Select',
+ onChange(mode) {
+ const schema = {
+ ['x-uid']: fieldSchema['x-uid'],
+ };
+ fieldSchema['x-component-props'] = fieldSchema['x-component-props'] || {};
+ fieldSchema['x-component-props']['mode'] = mode;
+ schema['x-component-props'] = fieldSchema['x-component-props'];
+ field.componentProps = field.componentProps || {};
+ field.componentProps.mode = mode;
+
+ // 子表单状态不允许设置默认值
+ // if (isSubMode(fieldSchema) && isAddNewForm) {
+ // // @ts-ignore
+ // schema.default = null;
+ // fieldSchema.default = null;
+ // field?.setInitialValue?.(null);
+ // field?.setValue?.(null);
+ // }
+
+ void dn.emit('patch', {
+ schema,
+ });
+ dn.refresh();
+ },
+ };
+ },
+};
diff --git a/packages/plugins/@nocobase/plugin-departments/src/client/components/index.ts b/packages/plugins/@nocobase/plugin-departments/src/client/components/index.ts
new file mode 100644
index 0000000000..a9a4c4c641
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-departments/src/client/components/index.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
+ */
+
+export * from './ReadOnlyAssociationField';
+export * from './UserDepartmentsField';
+export * from './UserMainDepartmentField';
+export * from './DepartmentOwnersField';
diff --git a/packages/plugins/@nocobase/plugin-departments/src/client/departments/AggregateSearch.tsx b/packages/plugins/@nocobase/plugin-departments/src/client/departments/AggregateSearch.tsx
new file mode 100644
index 0000000000..fccec99e2b
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-departments/src/client/departments/AggregateSearch.tsx
@@ -0,0 +1,216 @@
+/**
+ * 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, { useContext } from 'react';
+import { Input, Button, Empty, MenuProps, Dropdown, theme } from 'antd';
+import { useDepartmentTranslation } from '../locale';
+import { createStyles, useAPIClient, useRequest } from '@nocobase/client';
+import { ResourcesContext } from '../ResourcesProvider';
+
+const useStyles = createStyles(({ css }) => {
+ return {
+ searchDropdown: css`
+ .ant-dropdown-menu {
+ max-height: 500px;
+ overflow-y: scroll;
+ }
+ `,
+ };
+});
+
+export const AggregateSearch: React.FC = () => {
+ const { t } = useDepartmentTranslation();
+ const { token } = theme.useToken();
+ const { setDepartment, setUser } = useContext(ResourcesContext);
+ const [open, setOpen] = React.useState(false);
+ const [keyword, setKeyword] = React.useState('');
+ const [users, setUsers] = React.useState([]);
+ const [departments, setDepartments] = React.useState([]);
+ const [moreUsers, setMoreUsers] = React.useState(true);
+ const [moreDepartments, setMoreDepartments] = React.useState(true);
+ const { styles } = useStyles();
+ const limit = 10;
+
+ const api = useAPIClient();
+ const service = useRequest(
+ (params) =>
+ api
+ .resource('departments')
+ .aggregateSearch(params)
+ .then((res) => res?.data?.data),
+ {
+ manual: true,
+ onSuccess: (data, params) => {
+ const {
+ values: { type },
+ } = params[0] || {};
+ if (!data) {
+ return;
+ }
+ if ((!type || type === 'user') && data['users'].length < limit) {
+ setMoreUsers(false);
+ }
+ if ((!type || type === 'department') && data['departments'].length < limit) {
+ setMoreDepartments(false);
+ }
+ setUsers((users) => [...users, ...data['users']]);
+ setDepartments((departments) => [...departments, ...data['departments']]);
+ },
+ },
+ );
+ const { run } = service;
+
+ const handleSearch = (keyword: string) => {
+ setKeyword(keyword);
+ setUsers([]);
+ setDepartments([]);
+ setMoreUsers(true);
+ setMoreDepartments(true);
+ if (!keyword) {
+ return;
+ }
+ run({
+ values: { keyword, limit },
+ });
+ setOpen(true);
+ };
+
+ const handleChange = (e) => {
+ if (e.target.value) {
+ return;
+ }
+ setUser(null);
+ setKeyword('');
+ setOpen(false);
+ service.mutate({});
+ setUsers([]);
+ setDepartments([]);
+ };
+
+ const getTitle = (department: any) => {
+ const title = department.title;
+ const parent = department.parent;
+ if (parent) {
+ return getTitle(parent) + ' / ' + title;
+ }
+ return title;
+ };
+
+ const LoadMore: React.FC<{ type: string; last: number }> = (props) => {
+ return (
+
+ );
+ };
+
+ const getItems = () => {
+ const items: MenuProps['items'] = [];
+ if (!users.length && !departments.length) {
+ return [
+ {
+ key: '0',
+ label: ,
+ disabled: true,
+ },
+ ];
+ }
+ if (users.length) {
+ items.push({
+ key: '0',
+ type: 'group',
+ label: t('Users'),
+ children: users.map((user: { nickname: string; username: string; phone?: string; email?: string }) => ({
+ key: user.username,
+ label: (
+ setUser(user)}>
+
{user.nickname || user.username}
+
+ {`${user.username}${user.phone ? ' | ' + user.phone : ''}${user.email ? ' | ' + user.email : ''}`}
+
+
+ ),
+ })),
+ });
+ if (moreUsers) {
+ items.push({
+ type: 'group',
+ key: '0-loadMore',
+ label: ,
+ });
+ }
+ }
+ if (departments.length) {
+ items.push({
+ key: '1',
+ type: 'group',
+ label: t('Departments'),
+ children: departments.map((department: any) => ({
+ key: department.id,
+ label: setDepartment(department)}>{getTitle(department)}
,
+ })),
+ });
+ if (moreDepartments) {
+ items.push({
+ type: 'group',
+ key: '1-loadMore',
+ label: ,
+ });
+ }
+ }
+ return items;
+ };
+
+ return (
+ setOpen(open)}
+ >
+ {
+ if (!keyword) {
+ setOpen(false);
+ }
+ }}
+ onFocus={() => setDepartment(null)}
+ onSearch={handleSearch}
+ onChange={handleChange}
+ placeholder={t('Search for departments, users')}
+ style={{ marginBottom: '20px' }}
+ />
+
+ );
+};
diff --git a/packages/plugins/@nocobase/plugin-departments/src/client/departments/Department.tsx b/packages/plugins/@nocobase/plugin-departments/src/client/departments/Department.tsx
new file mode 100644
index 0000000000..b8673777ab
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-departments/src/client/departments/Department.tsx
@@ -0,0 +1,149 @@
+/**
+ * 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, { createContext, useContext, useState } from 'react';
+import { Row, Button, Divider, theme } from 'antd';
+import { UserOutlined } from '@ant-design/icons';
+import { useDepartmentTranslation } from '../locale';
+import { NewDepartment } from './NewDepartment';
+import { DepartmentTree } from './DepartmentTree';
+import { ResourcesContext } from '../ResourcesProvider';
+import { AggregateSearch } from './AggregateSearch';
+import { useDepartmentManager } from '../hooks';
+import {
+ ActionContextProvider,
+ RecordProvider,
+ SchemaComponent,
+ SchemaComponentOptions,
+ useAPIClient,
+ useActionContext,
+ useRecord,
+ useResourceActionContext,
+} from '@nocobase/client';
+import { useForm, useField } from '@formily/react';
+import { DepartmentOwnersField } from './DepartmentOwnersField';
+
+export const DepartmentTreeContext = createContext({} as ReturnType);
+
+export const useCreateDepartment = () => {
+ const form = useForm();
+ const field = useField();
+ const ctx = useActionContext();
+ const { refreshAsync } = useResourceActionContext();
+ const api = useAPIClient();
+ const { expandedKeys, setLoadedKeys, setExpandedKeys } = useContext(DepartmentTreeContext);
+ return {
+ async run() {
+ try {
+ await form.submit();
+ field.data = field.data || {};
+ field.data.loading = true;
+ await api.resource('departments').create({ values: form.values });
+ ctx.setVisible(false);
+ await form.reset();
+ field.data.loading = false;
+ const expanded = [...expandedKeys];
+ setLoadedKeys([]);
+ setExpandedKeys([]);
+ await refreshAsync();
+ setExpandedKeys(expanded);
+ } catch (error) {
+ if (field.data) {
+ field.data.loading = false;
+ }
+ }
+ },
+ };
+};
+
+export const useUpdateDepartment = () => {
+ const field = useField();
+ const form = useForm();
+ const ctx = useActionContext();
+ const { refreshAsync } = useResourceActionContext();
+ const api = useAPIClient();
+ const { id: filterByTk } = useRecord() as any;
+ const { expandedKeys, setLoadedKeys, setExpandedKeys } = useContext(DepartmentTreeContext);
+ const { department, setDepartment } = useContext(ResourcesContext);
+ return {
+ async run() {
+ await form.submit();
+ field.data = field.data || {};
+ field.data.loading = true;
+ try {
+ await api.resource('departments').update({ filterByTk, values: form.values });
+ setDepartment({ department, ...form.values });
+ ctx.setVisible(false);
+ await form.reset();
+ const expanded = [...expandedKeys];
+ setLoadedKeys([]);
+ setExpandedKeys([]);
+ await refreshAsync();
+ setExpandedKeys(expanded);
+ } catch (e) {
+ console.log(e);
+ } finally {
+ field.data.loading = false;
+ }
+ },
+ };
+};
+
+export const Department: React.FC = () => {
+ const { t } = useDepartmentTranslation();
+ const [visible, setVisible] = useState(false);
+ const [drawer, setDrawer] = useState({} as any);
+ const { department, setDepartment } = useContext(ResourcesContext);
+ const { token } = theme.useToken();
+ const departmentManager = useDepartmentManager({
+ label: ({ node }) => ,
+ });
+
+ return (
+
+
+
+
+ }
+ style={{
+ textAlign: 'left',
+ marginBottom: '5px',
+ background: department ? '' : token.colorBgTextHover,
+ }}
+ onClick={() => {
+ setDepartment(null);
+ }}
+ block
+ >
+ {t('All users')}
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/packages/plugins/@nocobase/plugin-departments/src/client/departments/DepartmentBlock.tsx b/packages/plugins/@nocobase/plugin-departments/src/client/departments/DepartmentBlock.tsx
new file mode 100644
index 0000000000..01b379d59a
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-departments/src/client/departments/DepartmentBlock.tsx
@@ -0,0 +1,40 @@
+/**
+ * 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 { SchemaComponent } from '@nocobase/client';
+import { DepartmentManagement } from './DepartmentManagement';
+import { uid } from '@formily/shared';
+
+export const DepartmentBlock: React.FC = () => {
+ return (
+
+ );
+};
diff --git a/packages/plugins/@nocobase/plugin-departments/src/client/departments/DepartmentField.tsx b/packages/plugins/@nocobase/plugin-departments/src/client/departments/DepartmentField.tsx
new file mode 100644
index 0000000000..76763a15bb
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-departments/src/client/departments/DepartmentField.tsx
@@ -0,0 +1,48 @@
+/**
+ * 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, { useContext } from 'react';
+import { useField } from '@formily/react';
+import { Field } from '@formily/core';
+import { ResourcesContext } from '../ResourcesProvider';
+import { getDepartmentTitle } from '../utils';
+import { EllipsisWithTooltip } from '@nocobase/client';
+
+export const DepartmentField: React.FC = () => {
+ const { setDepartment } = useContext(ResourcesContext);
+ const field = useField();
+ const values = field.value || [];
+ const deptsMap = values.reduce((mp: { [id: number]: any }, dept: any) => {
+ mp[dept.id] = dept;
+ return mp;
+ }, {});
+ const depts = values.map((dept: { id: number; title: string }, index: number) => (
+
+ {
+ e.preventDefault();
+ setDepartment(deptsMap[dept.id]);
+ }}
+ >
+ {getDepartmentTitle(dept)}
+
+ {index !== values.length - 1 ? , : ''}
+
+ ));
+ return {depts};
+};
diff --git a/packages/plugins/@nocobase/plugin-departments/src/client/departments/DepartmentManagement.tsx b/packages/plugins/@nocobase/plugin-departments/src/client/departments/DepartmentManagement.tsx
new file mode 100644
index 0000000000..5bae972bf8
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-departments/src/client/departments/DepartmentManagement.tsx
@@ -0,0 +1,44 @@
+/**
+ * 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 { Col, Row } from 'antd';
+import { Department } from './Department';
+import { Member } from './Member';
+import { SchemaComponentOptions } from '@nocobase/client';
+import { SuperiorDepartmentSelect, DepartmentSelect } from './DepartmentTreeSelect';
+import { DepartmentsListProvider, UsersListProvider } from '../ResourcesProvider';
+
+export const DepartmentManagement: React.FC = () => {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/packages/plugins/@nocobase/plugin-departments/src/client/departments/DepartmentOwnersField.tsx b/packages/plugins/@nocobase/plugin-departments/src/client/departments/DepartmentOwnersField.tsx
new file mode 100644
index 0000000000..6faffb38f3
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-departments/src/client/departments/DepartmentOwnersField.tsx
@@ -0,0 +1,115 @@
+/**
+ * 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 {
+ ActionContextProvider,
+ ResourceActionProvider,
+ SchemaComponent,
+ useActionContext,
+ useRecord,
+} from '@nocobase/client';
+import React, { useEffect, useRef, useState } from 'react';
+import { Select } from 'antd';
+import { Field } from '@formily/core';
+import { useField } from '@formily/react';
+import { departmentOwnersSchema } from './schemas/departments';
+
+export const DepartmentOwnersField: React.FC = () => {
+ const [visible, setVisible] = useState(false);
+ const department = useRecord() as any;
+ const field = useField();
+ const [value, setValue] = useState([]);
+ const selectedRows = useRef([]);
+ const handleSelect = (_: number[], rows: any[]) => {
+ selectedRows.current = rows;
+ };
+
+ const useSelectOwners = () => {
+ const { setVisible } = useActionContext();
+ return {
+ run() {
+ const selected = field.value || [];
+ field.setValue([...selected, ...selectedRows.current]);
+ selectedRows.current = [];
+ setVisible(false);
+ },
+ };
+ };
+
+ useEffect(() => {
+ if (!field.value) {
+ return;
+ }
+ setValue(
+ field.value.map((owner: any) => ({
+ value: owner.id,
+ label: owner.nickname || owner.username,
+ })),
+ );
+ }, [field.value]);
+
+ const RequestProvider: React.FC = (props) => (
+ owner.id),
+ },
+ }
+ : {},
+ },
+ }}
+ >
+ {props.children}
+
+ );
+
+ return (
+
+
+ );
+};
diff --git a/packages/plugins/@nocobase/plugin-departments/src/client/departments/DepartmentTable.tsx b/packages/plugins/@nocobase/plugin-departments/src/client/departments/DepartmentTable.tsx
new file mode 100644
index 0000000000..e86ca90309
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-departments/src/client/departments/DepartmentTable.tsx
@@ -0,0 +1,261 @@
+/**
+ * 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 {
+ CollectionContext,
+ CollectionProvider_deprecated,
+ ResourceActionContext,
+ SchemaComponent,
+ mergeFilter,
+ removeNullCondition,
+ useFilterFieldOptions,
+ useFilterFieldProps,
+ useResourceActionContext,
+} from '@nocobase/client';
+import React, { createContext, useContext, useEffect, useState } from 'react';
+import { useDepartmentManager } from '../hooks';
+import { Table, TablePaginationConfig } from 'antd';
+import { departmentCollection } from '../collections/departments';
+import { useDepartmentTranslation } from '../locale';
+import { useField } from '@formily/react';
+import { Field } from '@formily/core';
+import { uid } from '@formily/shared';
+import { getDepartmentTitle } from '../utils';
+
+const ExpandMetaContext = createContext({});
+
+export const useFilterActionProps = () => {
+ const { setHasFilter, setExpandedKeys } = useContext(ExpandMetaContext);
+ const { t } = useDepartmentTranslation();
+ const collection = useContext(CollectionContext);
+ const options = useFilterFieldOptions(collection.fields);
+ const service = useResourceActionContext();
+ const { run, defaultRequest } = service;
+ const field = useField();
+ const { params } = defaultRequest || {};
+
+ return {
+ options,
+ onSubmit: async (values: any) => {
+ // filter parameter for the block
+ const defaultFilter = params.filter;
+ // filter parameter for the filter action
+ const filter = removeNullCondition(values?.filter);
+ run({
+ ...params,
+ page: 1,
+ pageSize: 10,
+ filter: mergeFilter([filter, defaultFilter]),
+ });
+ const items = filter?.$and || filter?.$or;
+ if (items?.length) {
+ field.title = t('{{count}} filter items', { count: items?.length || 0 });
+ setHasFilter(true);
+ } else {
+ field.title = t('Filter');
+ setHasFilter(false);
+ }
+ },
+ onReset() {
+ run({
+ ...(params || {}),
+ filter: {
+ ...(params?.filter || {}),
+ parentId: null,
+ },
+ page: 1,
+ pageSize: 10,
+ });
+ field.title = t('Filter');
+ setHasFilter(false);
+ setExpandedKeys([]);
+ },
+ };
+};
+
+const useDefaultDisabled = () => {
+ return {
+ disabled: () => false,
+ };
+};
+
+const InternalDepartmentTable: React.FC<{
+ useDisabled?: () => {
+ disabled: (record: any) => boolean;
+ };
+}> = ({ useDisabled = useDefaultDisabled }) => {
+ const { t } = useDepartmentTranslation();
+ const ctx = useResourceActionContext();
+ console.log(ctx);
+ const { run, data, loading, defaultRequest } = ctx;
+ const { resource, resourceOf, params } = defaultRequest || {};
+ const { treeData, initData, loadData } = useDepartmentManager({
+ resource,
+ resourceOf,
+ params,
+ });
+ const field = useField();
+ const { disabled } = useDisabled();
+ const { hasFilter, expandedKeys, setExpandedKeys } = useContext(ExpandMetaContext);
+
+ useEffect(() => {
+ if (hasFilter) {
+ return;
+ }
+ initData(data?.data);
+ }, [data, initData, loading, hasFilter]);
+
+ const pagination: TablePaginationConfig = {};
+ if (params?.pageSize) {
+ pagination.defaultPageSize = params.pageSize;
+ }
+ if (!pagination.total && data?.meta) {
+ const { count, page, pageSize } = data.meta;
+ pagination.total = count;
+ pagination.current = page;
+ pagination.pageSize = pageSize;
+ }
+
+ return (
+ (hasFilter ? getDepartmentTitle(record) : text),
+ },
+ ]}
+ rowSelection={{
+ selectedRowKeys: (field?.value || []).map((dept: any) => dept.id),
+ onChange: (keys, depts) => field?.setValue?.(depts),
+ getCheckboxProps: (record: any) => ({
+ disabled: disabled(record),
+ }),
+ }}
+ pagination={{
+ showSizeChanger: true,
+ ...pagination,
+ onChange(page, pageSize) {
+ run({
+ ...(ctx?.params?.[0] || {}),
+ page,
+ pageSize,
+ });
+ },
+ }}
+ dataSource={hasFilter ? data?.data || [] : treeData}
+ expandable={{
+ onExpand: (expanded, record) => {
+ loadData({
+ key: record.id,
+ children: record.children,
+ });
+ },
+ expandedRowKeys: expandedKeys,
+ onExpandedRowsChange: (keys) => setExpandedKeys(keys),
+ }}
+ />
+ );
+};
+
+const RequestProvider: React.FC<{
+ useDataSource: any;
+}> = (props) => {
+ const [expandedKeys, setExpandedKeys] = useState([]);
+ const [hasFilter, setHasFilter] = useState(false);
+ const { useDataSource } = props;
+ const service = useDataSource({
+ manual: true,
+ });
+ useEffect(() => {
+ service.run({
+ filter: {
+ parentId: null,
+ },
+ pageSize: 10,
+ });
+ }, []);
+ return (
+
+
+
+ {props.children}
+
+
+
+ );
+};
+
+export const DepartmentTable: React.FC<{
+ useDataSource: any;
+ useDisabled?: (record: any) => boolean;
+}> = ({ useDataSource, useDisabled }) => {
+ return (
+
+ );
+};
diff --git a/packages/plugins/@nocobase/plugin-departments/src/client/departments/DepartmentTree.tsx b/packages/plugins/@nocobase/plugin-departments/src/client/departments/DepartmentTree.tsx
new file mode 100644
index 0000000000..03ac4080b6
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-departments/src/client/departments/DepartmentTree.tsx
@@ -0,0 +1,181 @@
+/**
+ * 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, { useContext, useEffect } from 'react';
+import { Tree, Dropdown, App, Empty } from 'antd';
+import { MoreOutlined } from '@ant-design/icons';
+import { useAPIClient, useResourceActionContext } from '@nocobase/client';
+import { useDepartmentTranslation } from '../locale';
+import { editDepartmentSchema, newSubDepartmentSchema } from './schemas/departments';
+import { ResourcesContext } from '../ResourcesProvider';
+import { DepartmentTreeContext } from './Department';
+import { css } from '@emotion/css';
+
+type DepartmentTreeProps = {
+ node: {
+ id: number;
+ title: string;
+ parent?: any;
+ };
+ setVisible: (visible: boolean) => void;
+ setDrawer: (schema: any) => void;
+};
+
+export const DepartmentTree: React.FC & {
+ Item: React.FC;
+} = () => {
+ const { data, loading } = useResourceActionContext();
+ const { department, setDepartment, setUser } = useContext(ResourcesContext);
+ const { treeData, nodeMap, loadData, loadedKeys, setLoadedKeys, initData, expandedKeys, setExpandedKeys } =
+ useContext(DepartmentTreeContext);
+ const handleSelect = (keys: number[]) => {
+ if (!keys.length) {
+ return;
+ }
+ const node = nodeMap[keys[0]];
+ setDepartment(node);
+ setUser(null);
+ };
+
+ const handleExpand = (keys: number[]) => {
+ setExpandedKeys(keys);
+ };
+
+ const handleLoad = (keys: number[]) => {
+ setLoadedKeys(keys);
+ };
+
+ useEffect(() => {
+ initData(data?.data);
+ }, [data, initData, loading]);
+
+ useEffect(() => {
+ if (!department) {
+ return;
+ }
+ const getIds = (node: any) => {
+ if (node.parent) {
+ return [node.parent.id, ...getIds(node.parent)];
+ }
+ return [];
+ };
+ const newKeys = getIds(department);
+ setExpandedKeys((keys) => Array.from(new Set([...keys, ...newKeys])));
+ }, [department, setExpandedKeys]);
+
+ return (
+
+ {treeData?.length ? (
+
+ ) : (
+
+ )}
+
+ );
+};
+
+DepartmentTree.Item = function DepartmentTreeItem({ node, setVisible, setDrawer }: DepartmentTreeProps) {
+ const { t } = useDepartmentTranslation();
+ const { refreshAsync } = useResourceActionContext();
+ const { setLoadedKeys, expandedKeys, setExpandedKeys } = useContext(DepartmentTreeContext);
+ const { modal, message } = App.useApp();
+ const api = useAPIClient();
+ const deleteDepartment = () => {
+ modal.confirm({
+ title: t('Delete'),
+ content: t('Are you sure you want to delete it?'),
+ onOk: async () => {
+ await api.resource('departments').destroy({ filterByTk: node.id });
+ message.success(t('Deleted successfully'));
+ setExpandedKeys((keys) => keys.filter((k) => k !== node.id));
+ const expanded = [...expandedKeys];
+ setLoadedKeys([]);
+ setExpandedKeys([]);
+ await refreshAsync();
+ setExpandedKeys(expanded);
+ },
+ });
+ };
+ const openDrawer = (schema: any) => {
+ setDrawer({ schema, node });
+ setVisible(true);
+ };
+ const handleClick = ({ key, domEvent }) => {
+ domEvent.stopPropagation();
+ switch (key) {
+ case 'new-sub':
+ openDrawer(newSubDepartmentSchema);
+ break;
+ case 'edit':
+ openDrawer(editDepartmentSchema);
+ break;
+ case 'delete':
+ deleteDepartment();
+ }
+ };
+ return (
+
+
{node.title}
+
+
+
+
+
+
+ );
+};
diff --git a/packages/plugins/@nocobase/plugin-departments/src/client/departments/DepartmentTreeSelect.tsx b/packages/plugins/@nocobase/plugin-departments/src/client/departments/DepartmentTreeSelect.tsx
new file mode 100644
index 0000000000..b66b511548
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-departments/src/client/departments/DepartmentTreeSelect.tsx
@@ -0,0 +1,136 @@
+/**
+ * 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, { useCallback, useContext, useEffect } from 'react';
+import { TreeSelect } from 'antd';
+import { useField } from '@formily/react';
+import { Field } from '@formily/core';
+import { useRecord } from '@nocobase/client';
+import { ResourcesContext } from '../ResourcesProvider';
+import { useDepartmentManager } from '../hooks/departments-manager';
+
+export const DepartmentTreeSelect: React.FC<{
+ originData: any;
+ treeData: any[];
+ [key: string]: any;
+}> = (props) => {
+ const field = useField();
+ const [value, setValue] = React.useState({ label: null, value: null });
+ const { treeData, initData, getByKeyword, loadData, loadedKeys, setLoadedKeys, originData } = props;
+
+ const handleSearch = async (keyword: string) => {
+ if (!keyword) {
+ initData(originData);
+ return;
+ }
+ await getByKeyword(keyword);
+ };
+
+ const getTitle = useCallback((record: any) => {
+ const title = record.title;
+ const parent = record.parent;
+ if (parent) {
+ return getTitle(parent) + ' / ' + title;
+ }
+ return title;
+ }, []);
+
+ useEffect(() => {
+ initData(originData);
+ }, [originData, initData]);
+
+ useEffect(() => {
+ if (!field.value) {
+ setValue({ label: null, value: null });
+ return;
+ }
+ setValue({
+ label: getTitle(field.value) || field.value.label,
+ value: field.value.id,
+ });
+ }, [field.value, getTitle]);
+
+ return (
+ {
+ field.setValue(node);
+ }}
+ onChange={(value: any) => {
+ if (!value) {
+ field.setValue(null);
+ }
+ }}
+ treeData={treeData}
+ treeLoadedKeys={loadedKeys}
+ onTreeLoad={(keys: any[]) => setLoadedKeys(keys)}
+ loadData={(node: any) => loadData({ key: node.id, children: node.children })}
+ fieldNames={{
+ value: 'id',
+ }}
+ showSearch
+ allowClear
+ treeNodeFilterProp="title"
+ onSearch={handleSearch}
+ labelInValue={true}
+ />
+ );
+};
+
+export const DepartmentSelect: React.FC = () => {
+ const departmentManager = useDepartmentManager();
+ const { departmentsResource } = useContext(ResourcesContext);
+ const {
+ service: { data },
+ } = departmentsResource || {};
+ return ;
+};
+
+export const SuperiorDepartmentSelect: React.FC = () => {
+ const departmentManager = useDepartmentManager();
+ const { setTreeData, getChildrenIds } = departmentManager;
+ const record = useRecord() as any;
+ const { departmentsResource } = useContext(ResourcesContext);
+ const {
+ service: { data },
+ } = departmentsResource || {};
+
+ useEffect(() => {
+ if (!record.id) {
+ return;
+ }
+ const childrenIds = getChildrenIds(record.id);
+ childrenIds.push(record.id);
+ setTreeData((treeData) => {
+ const setDisabled = (treeData: any[]) => {
+ return treeData.map((node) => {
+ if (childrenIds.includes(node.id)) {
+ node.disabled = true;
+ }
+ if (node.children) {
+ node.children = setDisabled(node.children);
+ }
+ return node;
+ });
+ };
+ return setDisabled(treeData);
+ });
+ }, [setTreeData, record.id, getChildrenIds]);
+
+ return ;
+};
diff --git a/packages/plugins/@nocobase/plugin-departments/src/client/departments/IsOwnerField.tsx b/packages/plugins/@nocobase/plugin-departments/src/client/departments/IsOwnerField.tsx
new file mode 100644
index 0000000000..e685d2d89a
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-departments/src/client/departments/IsOwnerField.tsx
@@ -0,0 +1,30 @@
+/**
+ * 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, { useContext } from 'react';
+import { useDepartmentTranslation } from '../locale';
+import { Checkbox, useRecord } from '@nocobase/client';
+import { ResourcesContext } from '../ResourcesProvider';
+
+export const IsOwnerField: React.FC = () => {
+ const { department } = useContext(ResourcesContext);
+ const record = useRecord() as any;
+ const dept = (record.departments || []).find((dept: any) => dept?.id === department?.id);
+
+ return ;
+};
diff --git a/packages/plugins/@nocobase/plugin-departments/src/client/departments/Member.tsx b/packages/plugins/@nocobase/plugin-departments/src/client/departments/Member.tsx
new file mode 100644
index 0000000000..a23aa2bde8
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-departments/src/client/departments/Member.tsx
@@ -0,0 +1,235 @@
+/**
+ * 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, { useContext, useRef, useEffect, useMemo } from 'react';
+import { useDepartmentTranslation } from '../locale';
+import {
+ CollectionContext,
+ ResourceActionProvider,
+ SchemaComponent,
+ useAPIClient,
+ useActionContext,
+ useFilterFieldOptions,
+ useFilterFieldProps,
+ useRecord,
+ useResourceActionContext,
+ useTableBlockContext,
+} from '@nocobase/client';
+import { membersActionSchema, addMembersSchema, rowRemoveActionSchema, getMembersSchema } from './schemas/users';
+import { App } from 'antd';
+import { DepartmentField } from './DepartmentField';
+import { IsOwnerField } from './IsOwnerField';
+import { UserDepartmentsField } from './UserDepartmentsField';
+import { ResourcesContext } from '../ResourcesProvider';
+import { useTableBlockProps } from '../hooks/useTableBlockProps';
+
+const AddMembersListProvider: React.FC = (props) => {
+ const { department } = useContext(ResourcesContext);
+ return (
+
+ {props.children}
+
+ );
+};
+
+const useAddMembersFilterActionProps = () => {
+ const collection = useContext(CollectionContext);
+ const options = useFilterFieldOptions(collection.fields);
+ const service = useResourceActionContext();
+ return useFilterFieldProps({
+ options,
+ params: service.state?.params?.[0] || service.params,
+ service,
+ });
+};
+
+export const AddMembers: React.FC = () => {
+ const { department } = useContext(ResourcesContext);
+ // This resource is the list of members of the current department.
+ const {
+ service: { refresh },
+ } = useTableBlockContext();
+ const selectedKeys = useRef([]);
+ const api = useAPIClient();
+
+ const useAddMembersActionProps = () => {
+ const { department } = useContext(ResourcesContext);
+ const { setVisible } = useActionContext();
+ return {
+ async onClick() {
+ const selected = selectedKeys.current;
+ if (!selected?.length) {
+ return;
+ }
+ await api.resource('departments.members', department.id).add({
+ values: selected,
+ });
+ selectedKeys.current = [];
+ refresh();
+ setVisible?.(false);
+ },
+ };
+ };
+
+ const handleSelect = (keys: any[]) => {
+ selectedKeys.current = keys;
+ };
+
+ return (
+
+ );
+};
+
+const useBulkRemoveMembersAction = () => {
+ const { t } = useDepartmentTranslation();
+ const { message } = App.useApp();
+ const api = useAPIClient();
+ const {
+ service: { refresh },
+ field,
+ } = useTableBlockContext();
+ const { department } = useContext(ResourcesContext);
+ return {
+ async run() {
+ const selected = field?.data?.selectedRowKeys;
+ if (!selected?.length) {
+ message.warning(t('Please select members'));
+ return;
+ }
+ await api.resource('departments.members', department.id).remove({
+ values: selected,
+ });
+ field.data.selectedRowKeys = [];
+ refresh();
+ },
+ };
+};
+
+const useRemoveMemberAction = () => {
+ const api = useAPIClient();
+ const { department } = useContext(ResourcesContext);
+ const { id } = useRecord() as any;
+ const {
+ service: { refresh },
+ } = useTableBlockContext();
+ return {
+ async run() {
+ await api.resource('departments.members', department.id).remove({
+ values: [id],
+ });
+ refresh();
+ },
+ };
+};
+
+const useShowTotal = () => {
+ const {
+ service: { data },
+ } = useTableBlockContext();
+ const { t } = useDepartmentTranslation();
+ return t('Total {{count}} members', { count: data?.meta?.count });
+};
+
+const useRefreshActionProps = () => {
+ const { service } = useTableBlockContext();
+ return {
+ async onClick() {
+ service?.refresh?.();
+ },
+ };
+};
+
+const RowRemoveAction = () => {
+ const { department } = useContext(ResourcesContext);
+ return department ? : null;
+};
+
+const MemberActions = () => {
+ const { department } = useContext(ResourcesContext);
+ return department ? : null;
+};
+
+const useMemberFilterActionProps = () => {
+ const collection = useContext(CollectionContext);
+ const options = useFilterFieldOptions(collection.fields);
+ const { service } = useTableBlockContext();
+ return useFilterFieldProps({
+ options,
+ params: service.state?.params?.[0] || service.params,
+ service,
+ });
+};
+
+export const Member: React.FC = () => {
+ const { t } = useDepartmentTranslation();
+ const { department, user } = useContext(ResourcesContext);
+ const {
+ service: { data, setState },
+ } = useTableBlockContext();
+
+ useEffect(() => {
+ setState?.({ selectedRowKeys: [] });
+ }, [data, setState]);
+
+ const schema = useMemo(() => getMembersSchema(department, user), [department, user]);
+
+ return (
+ <>
+ {!user ? {t(department?.title || 'All users')}
: {t('Search results')}
}
+
+ >
+ );
+};
diff --git a/packages/plugins/@nocobase/plugin-departments/src/client/departments/NewDepartment.tsx b/packages/plugins/@nocobase/plugin-departments/src/client/departments/NewDepartment.tsx
new file mode 100644
index 0000000000..08f4e78a9f
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-departments/src/client/departments/NewDepartment.tsx
@@ -0,0 +1,97 @@
+/**
+ * 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 { SchemaComponent } from '@nocobase/client';
+import React from 'react';
+import { useDepartmentTranslation } from '../locale';
+
+export const NewDepartment: React.FC = () => {
+ const { t } = useDepartmentTranslation();
+ return (
+
+ );
+};
diff --git a/packages/plugins/@nocobase/plugin-departments/src/client/departments/UserDepartmentsField.tsx b/packages/plugins/@nocobase/plugin-departments/src/client/departments/UserDepartmentsField.tsx
new file mode 100644
index 0000000000..e72869046a
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-departments/src/client/departments/UserDepartmentsField.tsx
@@ -0,0 +1,273 @@
+/**
+ * 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 {
+ ActionContextProvider,
+ SchemaComponent,
+ useAPIClient,
+ useRecord,
+ useRequest,
+ useResourceActionContext,
+ useTableBlockContext,
+} from '@nocobase/client';
+import React, { useState } from 'react';
+import { Tag, Button, Dropdown, App } from 'antd';
+import { PlusOutlined, MoreOutlined } from '@ant-design/icons';
+import { Field } from '@formily/core';
+import { useField, useForm } from '@formily/react';
+import { userDepartmentsSchema } from './schemas/users';
+import { getDepartmentTitle } from '../utils';
+import { useDepartmentTranslation } from '../locale';
+import { DepartmentTable } from './DepartmentTable';
+
+const useDataSource = (options?: any) => {
+ const defaultRequest = {
+ resource: 'departments',
+ action: 'list',
+ params: {
+ appends: ['parent(recursively=true)'],
+ // filter: {
+ // parentId: null,
+ // },
+ sort: ['createdAt'],
+ },
+ };
+ const service = useRequest(defaultRequest, options);
+ return {
+ ...service,
+ defaultRequest,
+ };
+};
+
+export const UserDepartmentsField: React.FC = () => {
+ const { modal, message } = App.useApp();
+ const { t } = useDepartmentTranslation();
+ const [visible, setVisible] = useState(false);
+ const user = useRecord() as any;
+ const field = useField();
+ const {
+ service: { refresh },
+ } = useTableBlockContext();
+
+ const formatData = (data: any[]) => {
+ if (!data?.length) {
+ return [];
+ }
+
+ return data.map((department) => ({
+ ...department,
+ isMain: department.departmentsUsers?.isMain,
+ isOwner: department.departmentsUsers?.isOwner,
+ title: getDepartmentTitle(department),
+ }));
+ };
+
+ const api = useAPIClient();
+ useRequest(
+ () =>
+ api
+ .resource(`users.departments`, user.id)
+ .list({
+ appends: ['parent(recursively=true)'],
+ paginate: false,
+ })
+ .then((res) => {
+ const data = formatData(res?.data?.data);
+ field.setValue(data);
+ }),
+ {
+ ready: user.id,
+ },
+ );
+
+ const useAddDepartments = () => {
+ const api = useAPIClient();
+ const drawerForm = useForm();
+ const { departments } = drawerForm.values || {};
+ return {
+ async run() {
+ await api.resource('users.departments', user.id).add({
+ values: departments.map((dept: any) => dept.id),
+ });
+ drawerForm.reset();
+ field.setValue([
+ ...field.value,
+ ...departments.map((dept: any, index: number) => ({
+ ...dept,
+ isMain: index === 0 && field.value.length === 0,
+ title: getDepartmentTitle(dept),
+ })),
+ ]);
+ setVisible(false);
+ refresh();
+ },
+ };
+ };
+
+ const removeDepartment = (dept: any) => {
+ modal.confirm({
+ title: t('Remove department'),
+ content: t('Are you sure you want to remove it?'),
+ onOk: async () => {
+ await api.resource('users.departments', user.id).remove({ values: [dept.id] });
+ message.success(t('Deleted successfully'));
+ field.setValue(
+ field.value
+ .filter((d: any) => d.id !== dept.id)
+ .map((d: any, index: number) => ({
+ ...d,
+ isMain: (dept.isMain && index === 0) || d.isMain,
+ })),
+ );
+ refresh();
+ },
+ });
+ };
+
+ const setMainDepartment = async (dept: any) => {
+ await api.resource('users').setMainDepartment({
+ values: {
+ userId: user.id,
+ departmentId: dept.id,
+ },
+ });
+ message.success(t('Set successfully'));
+ field.setValue(
+ field.value.map((d: any) => ({
+ ...d,
+ isMain: d.id === dept.id,
+ })),
+ );
+ refresh();
+ };
+
+ const setOwner = async (dept: any) => {
+ await api.resource('departments').setOwner({
+ values: {
+ userId: user.id,
+ departmentId: dept.id,
+ },
+ });
+ message.success(t('Set successfully'));
+ field.setValue(
+ field.value.map((d: any) => ({
+ ...d,
+ isOwner: d.id === dept.id ? true : d.isOwner,
+ })),
+ );
+ refresh();
+ };
+
+ const removeOwner = async (dept: any) => {
+ await api.resource('departments').removeOwner({
+ values: {
+ userId: user.id,
+ departmentId: dept.id,
+ },
+ });
+ message.success(t('Set successfully'));
+ field.setValue(
+ field.value.map((d: any) => ({
+ ...d,
+ isOwner: d.id === dept.id ? false : d.isOwner,
+ })),
+ );
+ refresh();
+ };
+
+ const handleClick = (key: string, dept: any) => {
+ switch (key) {
+ case 'setMain':
+ setMainDepartment(dept);
+ break;
+ case 'setOwner':
+ setOwner(dept);
+ break;
+ case 'removeOwner':
+ removeOwner(dept);
+ break;
+ case 'remove':
+ removeDepartment(dept);
+ }
+ };
+
+ const useDisabled = () => ({
+ disabled: (record: any) => {
+ return field.value.some((dept: any) => dept.id === record.id);
+ },
+ });
+
+ return (
+
+ <>
+ {(field?.value || []).map((dept) => (
+
+ {dept.title}
+ {dept.isMain ? (
+
+ {t('Main')}
+
+ ) : (
+ ''
+ )}
+ {/* {dept.isOwner ? ( */}
+ {/* */}
+ {/* {t('Owner')} */}
+ {/* */}
+ {/* ) : ( */}
+ {/* '' */}
+ {/* )} */}
+ handleClick(key, dept),
+ }}
+ >
+
+
+
+
+
+ ))}
+ } onClick={() => setVisible(true)} />
+ >
+
+
+ );
+};
diff --git a/packages/plugins/@nocobase/plugin-departments/src/client/departments/schemas/departments.ts b/packages/plugins/@nocobase/plugin-departments/src/client/departments/schemas/departments.ts
new file mode 100644
index 0000000000..32272647c7
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-departments/src/client/departments/schemas/departments.ts
@@ -0,0 +1,291 @@
+/**
+ * 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 { useEffect } from 'react';
+import { uid } from '@formily/shared';
+import { useAPIClient, useActionContext, useRecord, useRequest } from '@nocobase/client';
+
+export const newSubDepartmentSchema = {
+ type: 'object',
+ properties: {
+ [uid()]: {
+ type: 'void',
+ 'x-component': 'Action.Drawer',
+ 'x-decorator': 'Form',
+ 'x-decorator-props': {
+ useValues(options: any) {
+ const ctx = useActionContext();
+ const record = useRecord();
+ return useRequest(() => Promise.resolve({ data: { parent: { ...record } } }), {
+ ...options,
+ refreshDeps: [ctx.visible],
+ });
+ },
+ },
+ title: '{{t("New sub department")}}',
+ properties: {
+ title: {
+ 'x-component': 'CollectionField',
+ 'x-decorator': 'FormItem',
+ },
+ parent: {
+ 'x-component': 'CollectionField',
+ 'x-decorator': 'FormItem',
+ 'x-collection-field': 'departments.parent',
+ 'x-component-props': {
+ component: 'DepartmentSelect',
+ },
+ },
+ roles: {
+ 'x-component': 'CollectionField',
+ 'x-decorator': 'FormItem',
+ 'x-collection-field': 'departments.roles',
+ },
+ footer: {
+ type: 'void',
+ 'x-component': 'Action.Drawer.Footer',
+ properties: {
+ cancel: {
+ title: '{{t("Cancel")}}',
+ 'x-component': 'Action',
+ 'x-component-props': {
+ useAction: '{{ cm.useCancelAction }}',
+ },
+ },
+ submit: {
+ title: '{{t("Submit")}}',
+ 'x-component': 'Action',
+ 'x-component-props': {
+ type: 'primary',
+ useAction: '{{ useCreateDepartment }}',
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+};
+
+export const editDepartmentSchema = {
+ type: 'object',
+ properties: {
+ [uid()]: {
+ type: 'void',
+ 'x-component': 'Action.Drawer',
+ 'x-decorator': 'Form',
+ 'x-decorator-props': {
+ useValues(options: any) {
+ const api = useAPIClient();
+ const ctx = useActionContext();
+ const record = useRecord();
+ const result = useRequest(
+ () =>
+ api
+ .resource('departments')
+ .get({
+ filterByTk: record['id'],
+ appends: ['parent(recursively=true)', 'roles', 'owners'],
+ })
+ .then((res: any) => res?.data),
+ { ...options, manual: true },
+ );
+ useEffect(() => {
+ if (ctx.visible) {
+ result.run();
+ }
+ }, [ctx.visible]);
+ return result;
+ },
+ },
+ title: '{{t("Edit department")}}',
+ properties: {
+ title: {
+ 'x-component': 'CollectionField',
+ 'x-decorator': 'FormItem',
+ },
+ parent: {
+ 'x-component': 'CollectionField',
+ 'x-decorator': 'FormItem',
+ 'x-collection-field': 'departments.parent',
+ 'x-component-props': {
+ component: 'SuperiorDepartmentSelect',
+ },
+ },
+ roles: {
+ 'x-component': 'CollectionField',
+ 'x-decorator': 'FormItem',
+ 'x-collection-field': 'departments.roles',
+ },
+ owners: {
+ title: '{{t("Owners")}}',
+ 'x-component': 'DepartmentOwnersField',
+ 'x-decorator': 'FormItem',
+ },
+ footer: {
+ type: 'void',
+ 'x-component': 'Action.Drawer.Footer',
+ properties: {
+ cancel: {
+ title: '{{t("Cancel")}}',
+ 'x-component': 'Action',
+ 'x-component-props': {
+ useAction: '{{ cm.useCancelAction }}',
+ },
+ },
+ submit: {
+ title: '{{t("Submit")}}',
+ 'x-component': 'Action',
+ 'x-component-props': {
+ type: 'primary',
+ useAction: '{{ useUpdateDepartment }}',
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+};
+
+export const departmentOwnersSchema = {
+ type: 'void',
+ properties: {
+ drawer: {
+ title: '{{t("Select Owners")}}',
+ 'x-component': 'Action.Drawer',
+ properties: {
+ resource: {
+ type: 'void',
+ 'x-decorator': 'FormItem',
+ 'x-component': 'RequestProvider',
+ properties: {
+ actions: {
+ type: 'void',
+ 'x-component': 'ActionBar',
+ 'x-component-props': {
+ style: {
+ marginBottom: 16,
+ },
+ },
+ properties: {
+ filter: {
+ type: 'void',
+ title: '{{ t("Filter") }}',
+ default: {
+ $and: [{ username: { $includes: '' } }, { nickname: { $includes: '' } }],
+ },
+ 'x-action': 'filter',
+ 'x-component': 'Filter.Action',
+ 'x-use-component-props': 'useFilterActionProps',
+ 'x-component-props': {
+ icon: 'FilterOutlined',
+ },
+ 'x-align': 'left',
+ },
+ },
+ },
+ table: {
+ type: 'void',
+ 'x-component': 'Table.Void',
+ 'x-component-props': {
+ rowKey: 'id',
+ rowSelection: {
+ type: 'checkbox',
+ onChange: '{{ handleSelect }}',
+ },
+ useDataSource: '{{ cm.useDataSourceFromRAC }}',
+ },
+ properties: {
+ username: {
+ type: 'void',
+ 'x-decorator': 'Table.Column.Decorator',
+ 'x-component': 'Table.Column',
+ properties: {
+ username: {
+ type: 'string',
+ 'x-component': 'CollectionField',
+ 'x-read-pretty': true,
+ },
+ },
+ },
+ nickname: {
+ type: 'void',
+ 'x-decorator': 'Table.Column.Decorator',
+ 'x-component': 'Table.Column',
+ properties: {
+ nickname: {
+ type: 'string',
+ 'x-component': 'CollectionField',
+ 'x-read-pretty': true,
+ },
+ },
+ },
+ phone: {
+ type: 'void',
+ 'x-decorator': 'Table.Column.Decorator',
+ 'x-component': 'Table.Column',
+ properties: {
+ phone: {
+ type: 'string',
+ 'x-component': 'CollectionField',
+ 'x-read-pretty': true,
+ },
+ },
+ },
+ email: {
+ type: 'void',
+ 'x-decorator': 'Table.Column.Decorator',
+ 'x-component': 'Table.Column',
+ properties: {
+ email: {
+ type: 'string',
+ 'x-component': 'CollectionField',
+ 'x-read-pretty': true,
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ footer: {
+ type: 'void',
+ 'x-component': 'Action.Drawer.Footer',
+ properties: {
+ cancel: {
+ title: '{{t("Cancel")}}',
+ 'x-component': 'Action',
+ 'x-component-props': {
+ useAction: '{{ cm.useCancelAction }}',
+ },
+ },
+ confirm: {
+ title: '{{t("Confirm")}}',
+ 'x-component': 'Action',
+ 'x-component-props': {
+ type: 'primary',
+ useAction: '{{ useSelectOwners }}',
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+};
diff --git a/packages/plugins/@nocobase/plugin-departments/src/client/departments/schemas/users.ts b/packages/plugins/@nocobase/plugin-departments/src/client/departments/schemas/users.ts
new file mode 100644
index 0000000000..66c2115d0e
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-departments/src/client/departments/schemas/users.ts
@@ -0,0 +1,464 @@
+/**
+ * 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 { uid } from '@formily/shared';
+
+export const membersActionSchema = {
+ type: 'void',
+ 'x-component': 'Space',
+ properties: {
+ remove: {
+ type: 'void',
+ title: '{{t("Remove")}}',
+ 'x-component': 'Action',
+ 'x-component-props': {
+ icon: 'UserDeleteOutlined',
+ confirm: {
+ title: "{{t('Remove members')}}",
+ content: "{{t('Are you sure you want to remove these members?')}}",
+ },
+ style: {
+ marginRight: 8,
+ },
+ useAction: '{{ useBulkRemoveMembersAction }}',
+ },
+ },
+ create: {
+ type: 'void',
+ title: '{{t("Add members")}}',
+ 'x-component': 'Action',
+ 'x-component-props': {
+ type: 'primary',
+ icon: 'UserAddOutlined',
+ },
+ properties: {
+ drawer: {
+ type: 'void',
+ 'x-component': 'AddMembers',
+ },
+ },
+ },
+ },
+};
+
+export const rowRemoveActionSchema = {
+ type: 'void',
+ properties: {
+ remove: {
+ title: '{{ t("Remove") }}',
+ 'x-component': 'Action.Link',
+ 'x-component-props': {
+ confirm: {
+ title: "{{t('Remove member')}}",
+ content: "{{t('Are you sure you want to remove it?')}}",
+ },
+ useAction: '{{ useRemoveMemberAction }}',
+ },
+ },
+ },
+};
+
+export const getMembersSchema = (department: any, user: any) => ({
+ type: 'void',
+ 'x-component': 'CardItem',
+ 'x-component-props': {
+ heightMode: 'fullHeight',
+ },
+ properties: {
+ ...(!user
+ ? {
+ actions: {
+ type: 'void',
+ 'x-component': 'ActionBar',
+ 'x-component-props': {
+ style: {
+ marginBottom: 16,
+ },
+ },
+ properties: {
+ [uid()]: {
+ type: 'void',
+ title: '{{ t("Filter") }}',
+ 'x-action': 'filter',
+ 'x-component': 'Filter.Action',
+ 'x-use-component-props': 'useMemberFilterActionProps',
+ 'x-component-props': {
+ icon: 'FilterOutlined',
+ },
+ 'x-align': 'left',
+ },
+ refresh: {
+ type: 'void',
+ title: '{{ t("Refresh") }}',
+ 'x-action': 'refresh',
+ 'x-component': 'Action',
+ 'x-use-component-props': 'useRefreshActionProps',
+ 'x-component-props': {
+ icon: 'ReloadOutlined',
+ },
+ },
+ actions: {
+ type: 'void',
+ 'x-component': 'MemberActions',
+ },
+ },
+ },
+ }
+ : {}),
+ table: {
+ type: 'array',
+ 'x-component': 'TableV2',
+ 'x-use-component-props': 'useTableBlockProps',
+ 'x-component-props': {
+ rowKey: 'id',
+ rowSelection: {
+ type: 'checkbox',
+ },
+ pagination: {
+ showTotal: '{{ useShowTotal }}',
+ },
+ },
+ properties: {
+ nickname: {
+ type: 'void',
+ 'x-decorator': 'Table.Column.Decorator',
+ 'x-component': 'Table.Column',
+ properties: {
+ nickname: {
+ type: 'string',
+ 'x-component': 'CollectionField',
+ 'x-read-pretty': true,
+ },
+ },
+ },
+ username: {
+ type: 'void',
+ 'x-decorator': 'Table.Column.Decorator',
+ 'x-component': 'Table.Column',
+ properties: {
+ username: {
+ type: 'string',
+ 'x-component': 'CollectionField',
+ 'x-read-pretty': true,
+ },
+ },
+ },
+ departments: {
+ type: 'void',
+ 'x-decorator': 'Table.Column.Decorator',
+ 'x-component': 'Table.Column',
+ properties: {
+ departments: {
+ type: 'string',
+ 'x-component': 'CollectionField',
+ 'x-read-pretty': true,
+ },
+ },
+ },
+ ...(department
+ ? {
+ isOwner: {
+ type: 'void',
+ 'x-decorator': 'Table.Column.Decorator',
+ 'x-component': 'Table.Column',
+ 'x-component-props': {
+ style: {
+ minWidth: 100,
+ },
+ },
+ title: '{{t("Owner")}}',
+ properties: {
+ isOwner: {
+ type: 'boolean',
+ 'x-component': 'IsOwnerField',
+ },
+ },
+ },
+ }
+ : {}),
+ phone: {
+ type: 'void',
+ 'x-decorator': 'Table.Column.Decorator',
+ 'x-component': 'Table.Column',
+ properties: {
+ phone: {
+ type: 'string',
+ 'x-component': 'CollectionField',
+ 'x-read-pretty': true,
+ },
+ },
+ },
+ email: {
+ type: 'void',
+ 'x-decorator': 'Table.Column.Decorator',
+ 'x-component': 'Table.Column',
+ properties: {
+ email: {
+ type: 'string',
+ 'x-component': 'CollectionField',
+ 'x-read-pretty': true,
+ },
+ },
+ },
+ actions: {
+ type: 'void',
+ title: '{{t("Actions")}}',
+ 'x-component': 'Table.Column',
+ 'x-component-props': {
+ fixed: 'right',
+ },
+ properties: {
+ actions: {
+ type: 'void',
+ 'x-component': 'Space',
+ 'x-component-props': {
+ split: '|',
+ },
+ properties: {
+ update: {
+ type: 'void',
+ title: '{{t("Configure")}}',
+ 'x-component': 'Action.Link',
+ 'x-component-props': {
+ type: 'primary',
+ },
+ properties: {
+ drawer: {
+ type: 'void',
+ 'x-component': 'Action.Drawer',
+ 'x-decorator': 'FormV2',
+ title: '{{t("Configure")}}',
+ properties: {
+ departments: {
+ title: '{{t("Departments")}}',
+ 'x-decorator': 'FormItem',
+ 'x-component': 'UserDepartmentsField',
+ },
+ // footer: {
+ // type: 'void',
+ // 'x-component': 'Action.Drawer.Footer',
+ // properties: {
+ // cancel: {
+ // title: '{{t("Cancel")}}',
+ // 'x-component': 'Action',
+ // 'x-component-props': {
+ // useAction: '{{ cm.useCancelAction }}',
+ // },
+ // },
+ // submit: {
+ // title: '{{t("Submit")}}',
+ // 'x-component': 'Action',
+ // 'x-component-props': {
+ // type: 'primary',
+ // // useAction: '{{ useSetDepartments }}',
+ // },
+ // },
+ // },
+ // },
+ },
+ },
+ },
+ },
+ ...(department
+ ? {
+ remove: {
+ type: 'void',
+ 'x-component': 'RowRemoveAction',
+ },
+ }
+ : {}),
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+});
+
+export const addMembersSchema = {
+ type: 'object',
+ properties: {
+ drawer: {
+ type: 'void',
+ 'x-component': 'Action.Drawer',
+ 'x-decorator': 'Form',
+ title: '{{t("Add members")}}',
+ properties: {
+ resource: {
+ type: 'void',
+ 'x-decorator': 'FormItem',
+ 'x-component': 'AddMembersListProvider',
+ properties: {
+ actions: {
+ type: 'void',
+ 'x-component': 'ActionBar',
+ 'x-component-props': {
+ style: {
+ marginBottom: 16,
+ },
+ },
+ properties: {
+ filter: {
+ type: 'void',
+ title: '{{ t("Filter") }}',
+ default: {
+ $and: [{ username: { $includes: '' } }, { nickname: { $includes: '' } }],
+ },
+ 'x-action': 'filter',
+ 'x-component': 'Filter.Action',
+ 'x-use-component-props': 'useAddMembersFilterActionProps',
+ 'x-component-props': {
+ icon: 'FilterOutlined',
+ },
+ 'x-align': 'left',
+ },
+ },
+ },
+ table: {
+ type: 'void',
+ 'x-component': 'Table.Void',
+ 'x-component-props': {
+ rowKey: 'id',
+ rowSelection: {
+ type: 'checkbox',
+ onChange: '{{ handleSelect }}',
+ },
+ useDataSource: '{{ cm.useDataSourceFromRAC }}',
+ },
+ properties: {
+ username: {
+ type: 'void',
+ 'x-decorator': 'Table.Column.Decorator',
+ 'x-component': 'Table.Column',
+ properties: {
+ username: {
+ type: 'string',
+ 'x-component': 'CollectionField',
+ 'x-read-pretty': true,
+ },
+ },
+ },
+ nickname: {
+ type: 'void',
+ 'x-decorator': 'Table.Column.Decorator',
+ 'x-component': 'Table.Column',
+ properties: {
+ nickname: {
+ type: 'string',
+ 'x-component': 'CollectionField',
+ 'x-read-pretty': true,
+ },
+ },
+ },
+ phone: {
+ type: 'void',
+ 'x-decorator': 'Table.Column.Decorator',
+ 'x-component': 'Table.Column',
+ properties: {
+ phone: {
+ type: 'string',
+ 'x-component': 'CollectionField',
+ 'x-read-pretty': true,
+ },
+ },
+ },
+ email: {
+ type: 'void',
+ 'x-decorator': 'Table.Column.Decorator',
+ 'x-component': 'Table.Column',
+ properties: {
+ email: {
+ type: 'string',
+ 'x-component': 'CollectionField',
+ 'x-read-pretty': true,
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ footer: {
+ type: 'void',
+ 'x-component': 'Action.Drawer.Footer',
+ properties: {
+ cancel: {
+ title: '{{t("Cancel")}}',
+ 'x-component': 'Action',
+ 'x-component-props': {
+ useAction: '{{ cm.useCancelAction }}',
+ },
+ },
+ submit: {
+ title: '{{t("Submit")}}',
+ 'x-component': 'Action',
+ 'x-component-props': {
+ type: 'primary',
+ },
+ 'x-use-component-props': 'useAddMembersActionProps',
+ },
+ },
+ },
+ },
+ },
+ },
+};
+
+export const userDepartmentsSchema = {
+ type: 'void',
+ properties: {
+ drawer: {
+ title: '{{t("Select Departments")}}',
+ 'x-decorator': 'Form',
+ 'x-component': 'Action.Drawer',
+ properties: {
+ table: {
+ type: 'void',
+ 'x-decorator': 'FormItem',
+ 'x-component': 'DepartmentTable',
+ 'x-component-props': {
+ useDataSource: '{{ useDataSource }}',
+ useDisabled: '{{ useDisabled }}',
+ },
+ },
+ footer: {
+ type: 'void',
+ 'x-component': 'Action.Drawer.Footer',
+ properties: {
+ cancel: {
+ title: '{{t("Cancel")}}',
+ 'x-component': 'Action',
+ 'x-component-props': {
+ useAction: '{{ cm.useCancelAction }}',
+ },
+ },
+ confirm: {
+ title: '{{t("Submit")}}',
+ 'x-component': 'Action',
+ 'x-component-props': {
+ type: 'primary',
+ useAction: '{{ useAddDepartments }}',
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+};
diff --git a/packages/plugins/@nocobase/plugin-departments/src/client/hooks/departments-manager.ts b/packages/plugins/@nocobase/plugin-departments/src/client/hooks/departments-manager.ts
new file mode 100644
index 0000000000..57d99ee9aa
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-departments/src/client/hooks/departments-manager.ts
@@ -0,0 +1,75 @@
+/**
+ * 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 { useAPIClient } from '@nocobase/client';
+import { TreeManagerOptions, useTreeManager } from './tree-manager';
+import deepmerge from 'deepmerge';
+
+type DepartmentManagerOptions = {
+ resource?: string;
+ resourceOf?: string;
+ params?: any;
+} & TreeManagerOptions;
+
+export const useDepartmentManager = (options?: DepartmentManagerOptions) => {
+ const { resource = 'departments', resourceOf, params = {} } = options || {};
+ const api = useAPIClient();
+ const resourceAPI = api.resource(resource, resourceOf);
+ const treeManager = useTreeManager(options);
+ const { setTreeData, updateTreeData, setLoadedKeys, initData } = treeManager;
+ const loadData = async ({ key, children }) => {
+ if (children?.length) {
+ return;
+ }
+ const { data } = await resourceAPI.list(
+ deepmerge(params, {
+ paginate: false,
+ appends: ['parent(recursively=true)'],
+ filter: {
+ parentId: key,
+ },
+ }),
+ );
+ if (!data?.data?.length) {
+ return;
+ }
+ setTreeData(updateTreeData(key, data?.data));
+ };
+
+ const getByKeyword = async (keyword: string) => {
+ const { data } = await resourceAPI.list(
+ deepmerge(params, {
+ paginate: false,
+ filter: {
+ title: {
+ $includes: keyword,
+ },
+ },
+ appends: ['parent(recursively=true)'],
+ pageSize: 100,
+ }),
+ );
+ initData(data?.data);
+ };
+
+ return {
+ ...treeManager,
+ loadData,
+ getByKeyword,
+ };
+};
diff --git a/packages/plugins/@nocobase/plugin-departments/src/client/hooks/index.ts b/packages/plugins/@nocobase/plugin-departments/src/client/hooks/index.ts
new file mode 100644
index 0000000000..72af4ce551
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-departments/src/client/hooks/index.ts
@@ -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.
+ */
+
+/**
+ * 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 {
+ CollectionContext,
+ useActionContext,
+ useFilterFieldOptions,
+ useFilterFieldProps,
+ useResourceActionContext,
+ useResourceContext,
+} from '@nocobase/client';
+import { useContext } from 'react';
+import { useForm, useField } from '@formily/react';
+
+export const useCreateAction = () => {
+ const form = useForm();
+ const field = useField();
+ const ctx = useActionContext();
+ const { refresh } = useResourceActionContext();
+ const { resource } = useResourceContext();
+ return {
+ async run() {
+ try {
+ await form.submit();
+ field.data = field.data || {};
+ field.data.loading = true;
+ await resource.create({ values: form.values });
+ ctx.setVisible(false);
+ await form.reset();
+ field.data.loading = false;
+ refresh();
+ } catch (error) {
+ if (field.data) {
+ field.data.loading = false;
+ }
+ }
+ },
+ };
+};
+
+export const useFilterActionProps = () => {
+ const collection = useContext(CollectionContext);
+ const options = useFilterFieldOptions(collection.fields);
+ const service = useResourceActionContext();
+ return useFilterFieldProps({
+ options,
+ params: service.state?.params?.[0] || service.params,
+ service,
+ });
+};
+
+export * from './tree-manager';
+export * from './departments-manager';
diff --git a/packages/plugins/@nocobase/plugin-departments/src/client/hooks/tree-manager.ts b/packages/plugins/@nocobase/plugin-departments/src/client/hooks/tree-manager.ts
new file mode 100644
index 0000000000..d2ef32c348
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-departments/src/client/hooks/tree-manager.ts
@@ -0,0 +1,142 @@
+/**
+ * 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, { useCallback, useState } from 'react';
+
+export type TreeManagerOptions = {
+ label?: React.FC<{ node: any }>;
+};
+
+export const useTreeManager = (options?: TreeManagerOptions) => {
+ const { label } = options || {};
+ const [treeData, setTreeData] = useState([]);
+ const [nodeMap, setNodeMap] = useState({});
+ const [expandedKeys, setExpandedKeys] = useState([]);
+ const [loadedKeys, setLoadedKeys] = useState([]);
+
+ const buildNodeMap = useCallback((data: any[]) => {
+ const mp = {};
+ const setNodeMapFromChild = (node: any) => {
+ let child = node ? { ...node } : null;
+ while (child) {
+ const parentId = child.parentId || 'root';
+ if (mp[parentId]) {
+ mp[parentId].childrenMap[child.id] = child;
+ } else {
+ mp[parentId] = {
+ ...(child.parent || { id: parentId }),
+ childrenMap: {
+ [child.id]: child,
+ },
+ };
+ }
+ child = child.parent;
+ }
+ };
+ const setNodeMapFromParent = (node: any) => {
+ const childrenMap = {};
+ if (node.children && node.children.length) {
+ node.children.forEach((child: any) => {
+ childrenMap[child.id] = child;
+ setNodeMapFromParent(child);
+ });
+ }
+ mp[node.id] = {
+ ...node,
+ childrenMap,
+ };
+ };
+ if (!(data && data.length)) {
+ return mp;
+ }
+ data.forEach((node) => {
+ setNodeMapFromChild(node);
+ setNodeMapFromParent(node);
+ });
+ return mp;
+ }, []);
+
+ const constructTreeData = useCallback((nodeMap: { [parentId: string | number]: any }) => {
+ const getChildren = (id: any) => {
+ if (!nodeMap[id]) {
+ return null;
+ }
+ if (nodeMap[id].isLeaf) {
+ return null;
+ }
+ return Object.values(nodeMap[id]?.childrenMap || {}).map((node: any) => {
+ return {
+ ...node,
+ title: label ? React.createElement(label, { node }) : node.title,
+ children: getChildren(node.id),
+ };
+ });
+ };
+ return getChildren('root');
+ }, []);
+
+ const initData = useCallback(
+ (data: any[]) => {
+ const mp = buildNodeMap(data);
+ setNodeMap(mp);
+ const treeData = constructTreeData(mp) || [];
+ setTreeData(treeData);
+ // setLoadedKeys([]);
+ },
+ [setTreeData, buildNodeMap, constructTreeData],
+ );
+
+ const updateTreeData = (key: any, children: any[]) => {
+ const mp = buildNodeMap(children);
+ const newMap = { ...mp, ...nodeMap };
+ children.forEach((node) => {
+ newMap[key].childrenMap[node.id] = node;
+ });
+ setNodeMap(newMap);
+ return constructTreeData(newMap);
+ };
+
+ const getChildrenIds = useCallback(
+ (id: any) => {
+ if (!nodeMap[id]) {
+ return [];
+ }
+ const ids = [];
+ ids.push(...Object.keys(nodeMap[id].childrenMap).map((id) => Number(id)));
+ Object.keys(nodeMap[id].childrenMap).forEach((id) => {
+ ids.push(...getChildrenIds(id));
+ });
+ return ids;
+ },
+ [nodeMap],
+ );
+
+ return {
+ initData,
+ treeData,
+ setTreeData,
+ nodeMap,
+ updateTreeData,
+ constructTreeData,
+ getChildrenIds,
+ loadedKeys,
+ setLoadedKeys,
+ expandedKeys,
+ setExpandedKeys,
+ };
+};
diff --git a/packages/plugins/@nocobase/plugin-departments/src/client/hooks/useTableBlockProps.ts b/packages/plugins/@nocobase/plugin-departments/src/client/hooks/useTableBlockProps.ts
new file mode 100644
index 0000000000..fe5b35eb8a
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-departments/src/client/hooks/useTableBlockProps.ts
@@ -0,0 +1,175 @@
+/**
+ * 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 { ArrayField } from '@formily/core';
+import { useField, useFieldSchema } from '@formily/react';
+import { isEqual } from 'lodash';
+import { useCallback, useEffect, useRef } from 'react';
+import {
+ useTableBlockContext,
+ findFilterTargets,
+ DataBlock,
+ useFilterBlock,
+ mergeFilter,
+ removeNullCondition,
+} from '@nocobase/client';
+
+export const useTableBlockProps = () => {
+ const field = useField();
+ const fieldSchema = useFieldSchema();
+ const ctx = useTableBlockContext();
+ const { getDataBlocks } = useFilterBlock();
+ const isLoading = ctx?.service?.loading;
+
+ const ctxRef = useRef(null);
+ ctxRef.current = ctx;
+
+ useEffect(() => {
+ if (!isLoading) {
+ const serviceResponse = ctx?.service?.data;
+ const data = serviceResponse?.data || [];
+ const meta = serviceResponse?.meta || {};
+ const selectedRowKeys = ctx?.field?.data?.selectedRowKeys;
+
+ if (!isEqual(field.value, data)) {
+ field.value = data;
+ field?.setInitialValue(data);
+ }
+ field.data = field.data || {};
+
+ if (!isEqual(field.data.selectedRowKeys, selectedRowKeys)) {
+ field.data.selectedRowKeys = selectedRowKeys;
+ }
+
+ field.componentProps.pagination = field.componentProps.pagination || {};
+ field.componentProps.pagination.pageSize = meta?.pageSize;
+ field.componentProps.pagination.total = meta?.count;
+ field.componentProps.pagination.current = meta?.page;
+ }
+ }, [field, ctx?.service?.data, isLoading, ctx?.field?.data?.selectedRowKeys]);
+
+ return {
+ bordered: ctx.bordered,
+ childrenColumnName: ctx.childrenColumnName,
+ loading: ctx?.service?.loading,
+ showIndex: ctx.showIndex,
+ dragSort: ctx.dragSort && ctx.dragSortBy,
+ rowKey: ctx.rowKey || fieldSchema?.['x-component-props']?.rowKey || 'id',
+ pagination: fieldSchema?.['x-component-props']?.pagination === false ? false : field.componentProps.pagination,
+ onRowSelectionChange: useCallback((selectedRowKeys, selectedRowData) => {
+ ctx.field.data = ctx?.field?.data || {};
+ ctx.field.data.selectedRowKeys = selectedRowKeys;
+ ctx.field.data.selectedRowData = selectedRowData;
+ ctx?.field?.onRowSelect?.(selectedRowKeys);
+ }, []),
+ onRowDragEnd: useCallback(
+ async ({ from, to }) => {
+ await ctx.resource.move({
+ sourceId: from[ctx.rowKey || 'id'],
+ targetId: to[ctx.rowKey || 'id'],
+ sortField: ctx.dragSort && ctx.dragSortBy,
+ });
+ ctx.service.refresh();
+ // ctx.resource
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ },
+ [ctx.rowKey, ctx.dragSort, ctx.dragSortBy],
+ ),
+ onChange: useCallback(
+ ({ current, pageSize }, filters, sorter) => {
+ const globalSort = fieldSchema.parent?.['x-decorator-props']?.['params']?.['sort'];
+ const sort = sorter.order
+ ? sorter.order === `ascend`
+ ? [sorter.field]
+ : [`-${sorter.field}`]
+ : globalSort || ctxRef.current.dragSortBy;
+ const currentPageSize = pageSize || fieldSchema.parent?.['x-decorator-props']?.['params']?.pageSize;
+ const args = { ...ctxRef.current?.service?.params?.[0], page: current || 1, pageSize: currentPageSize };
+ if (sort) {
+ args['sort'] = sort;
+ }
+ ctxRef.current?.service.run(args);
+ },
+ [fieldSchema.parent],
+ ),
+ onClickRow: useCallback(
+ (record, setSelectedRow, selectedRow) => {
+ const { targets, uid } = findFilterTargets(fieldSchema);
+ const dataBlocks = getDataBlocks();
+
+ // 如果是之前创建的区块是没有 x-filter-targets 属性的,所以这里需要判断一下避免报错
+ if (!targets || !targets.some((target) => dataBlocks.some((dataBlock) => dataBlock.uid === target.uid))) {
+ // 当用户已经点击过某一行,如果此时再把相连接的区块给删除的话,行的高亮状态就会一直保留。
+ // 这里暂时没有什么比较好的方法,只是在用户再次点击的时候,把高亮状态给清除掉。
+ setSelectedRow((prev) => (prev.length ? [] : prev));
+ return;
+ }
+
+ const currentBlock = dataBlocks.find((block) => block.uid === fieldSchema.parent['x-uid']);
+
+ dataBlocks.forEach((block) => {
+ const target = targets.find((target) => target.uid === block.uid);
+ if (!target) return;
+
+ const isForeignKey = block.foreignKeyFields?.some((field) => field.name === target.field);
+ const sourceKey = getSourceKey(currentBlock, target.field);
+ const recordKey = isForeignKey ? sourceKey : ctx.rowKey;
+ const value = [record[recordKey]];
+
+ const param = block.service.params?.[0] || {};
+ // 保留原有的 filter
+ const storedFilter = block.service.params?.[1]?.filters || {};
+
+ if (selectedRow.includes(record[ctx.rowKey])) {
+ if (block.dataLoadingMode === 'manual') {
+ return block.clearData();
+ }
+ delete storedFilter[uid];
+ } else {
+ storedFilter[uid] = {
+ $and: [
+ {
+ [target.field || ctx.rowKey]: {
+ [target.field ? '$in' : '$eq']: value,
+ },
+ },
+ ],
+ };
+ }
+
+ const mergedFilter = mergeFilter([
+ ...Object.values(storedFilter).map((filter) => removeNullCondition(filter)),
+ block.defaultFilter,
+ ]);
+
+ return block.doFilter(
+ {
+ ...param,
+ page: 1,
+ filter: mergedFilter,
+ },
+ { filters: storedFilter },
+ );
+ });
+
+ // 更新表格的选中状态
+ setSelectedRow((prev) => (prev?.includes(record[ctx.rowKey]) ? [] : [record[ctx.rowKey]]));
+ },
+ [ctx.rowKey, fieldSchema, getDataBlocks],
+ ),
+ onExpand: useCallback((expanded, record) => {
+ ctx?.field.onExpandClick?.(expanded, record);
+ }, []),
+ };
+};
+
+function getSourceKey(currentBlock: DataBlock, field: string) {
+ const associationField = currentBlock?.associatedFields?.find((item) => item.foreignKey === field);
+ return associationField?.sourceKey || 'id';
+}
diff --git a/packages/plugins/@nocobase/plugin-departments/src/client/index.tsx b/packages/plugins/@nocobase/plugin-departments/src/client/index.tsx
new file mode 100644
index 0000000000..798fddecd6
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-departments/src/client/index.tsx
@@ -0,0 +1,76 @@
+/**
+ * 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 { Plugin, SchemaComponentContext, useSchemaComponentContext } from '@nocobase/client';
+import { tval } from '@nocobase/utils/client';
+import { DepartmentBlock } from './departments/DepartmentBlock';
+import React from 'react';
+import { ResourcesProvider } from './ResourcesProvider';
+import ACLPlugin from '@nocobase/plugin-acl/client';
+import { RoleDepartmentsManager } from './roles/RoleDepartmentsManager';
+import {
+ UserDepartmentsFieldSettings,
+ ReadOnlyAssociationField,
+ UserMainDepartmentFieldSettings,
+ DepartmentOwnersFieldSettings,
+} from './components';
+
+export class PluginDepartmentsClient extends Plugin {
+ async afterAdd() {
+ // await this.app.pm.add()
+ }
+
+ async beforeLoad() {}
+
+ // You can get and modify the app instance here
+ async load() {
+ this.app.addComponents({
+ UserDepartmentsField: ReadOnlyAssociationField,
+ UserMainDepartmentField: ReadOnlyAssociationField,
+ DepartmentOwnersField: ReadOnlyAssociationField,
+ });
+ this.app.schemaSettingsManager.add(UserDepartmentsFieldSettings);
+ this.app.schemaSettingsManager.add(UserMainDepartmentFieldSettings);
+ this.app.schemaSettingsManager.add(DepartmentOwnersFieldSettings);
+
+ this.app.pluginSettingsManager.add('users-permissions.departments', {
+ icon: 'ApartmentOutlined',
+ title: tval('Departments', { ns: 'departments' }),
+ Component: () => {
+ const scCtx = useSchemaComponentContext();
+ return (
+
+
+
+
+
+ );
+ },
+ sort: 2,
+ aclSnippet: 'pm.departments',
+ });
+
+ const acl = this.app.pm.get(ACLPlugin);
+ acl.rolesManager.add('departments', {
+ title: tval('Departments', { ns: 'departments' }),
+ Component: RoleDepartmentsManager,
+ });
+ }
+}
+
+export default PluginDepartmentsClient;
diff --git a/packages/plugins/@nocobase/plugin-departments/src/client/locale.ts b/packages/plugins/@nocobase/plugin-departments/src/client/locale.ts
new file mode 100644
index 0000000000..2899c172d4
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-departments/src/client/locale.ts
@@ -0,0 +1,23 @@
+/**
+ * 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 { useTranslation } from 'react-i18next';
+
+export function useDepartmentTranslation() {
+ return useTranslation(['departments', 'client'], { nsMode: 'fallback' });
+}
diff --git a/packages/plugins/@nocobase/plugin-departments/src/client/roles/RoleDepartmentsManager.tsx b/packages/plugins/@nocobase/plugin-departments/src/client/roles/RoleDepartmentsManager.tsx
new file mode 100644
index 0000000000..30d8badc1d
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-departments/src/client/roles/RoleDepartmentsManager.tsx
@@ -0,0 +1,178 @@
+/**
+ * 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, { useContext, useEffect, useMemo } from 'react';
+import { App } from 'antd';
+import { useDepartmentTranslation } from '../locale';
+import {
+ CollectionManagerContext,
+ CollectionProvider_deprecated,
+ ResourceActionContext,
+ SchemaComponent,
+ useAPIClient,
+ useActionContext,
+ useRecord,
+ useRequest,
+ useResourceActionContext,
+} from '@nocobase/client';
+import { RolesManagerContext } from '@nocobase/plugin-acl/client';
+import { departmentCollection } from '../collections/departments';
+import { getDepartmentsSchema } from './schemas/departments';
+import { useFilterActionProps } from '../hooks';
+import { DepartmentTable } from '../departments/DepartmentTable';
+import { useForm } from '@formily/react';
+
+const useRemoveDepartment = () => {
+ const api = useAPIClient();
+ const { role } = useContext(RolesManagerContext);
+ const { id } = useRecord();
+ const { refresh } = useResourceActionContext();
+ return {
+ async run() {
+ await api.resource(`roles/${role?.name}/departments`).remove({
+ values: [id],
+ });
+ refresh();
+ },
+ };
+};
+
+const useBulkRemoveDepartments = () => {
+ const { t } = useDepartmentTranslation();
+ const { message } = App.useApp();
+ const api = useAPIClient();
+ const { state, setState, refresh } = useResourceActionContext();
+ const { role } = useContext(RolesManagerContext);
+
+ return {
+ async run() {
+ const selected = state?.selectedRowKeys;
+ if (!selected?.length) {
+ message.warning(t('Please select departments'));
+ return;
+ }
+ await api.resource(`roles/${role?.name}/departments`).remove({
+ values: selected,
+ });
+ setState?.({ selectedRowKeys: [] });
+ refresh();
+ },
+ };
+};
+
+const DepartmentTitle: React.FC = () => {
+ const record = useRecord();
+ const getTitle = (record: any) => {
+ const title = record.title;
+ const parent = record.parent;
+ if (parent) {
+ return getTitle(parent) + ' / ' + title;
+ }
+ return title;
+ };
+
+ return <>{getTitle(record)}>;
+};
+
+const useDataSource = (options?: any) => {
+ const defaultRequest = {
+ resource: 'departments',
+ action: 'list',
+ params: {
+ // filter: {
+ // parentId: null,
+ // },
+ appends: ['roles', 'parent(recursively=true)'],
+ sort: ['createdAt'],
+ },
+ };
+ const service = useRequest(defaultRequest, options);
+ return {
+ ...service,
+ defaultRequest,
+ };
+};
+
+const useDisabled = () => {
+ const { role } = useContext(RolesManagerContext);
+ return {
+ disabled: (record: any) => record?.roles?.some((r: { name: string }) => r.name === role?.name),
+ };
+};
+
+const useAddDepartments = () => {
+ const { role } = useContext(RolesManagerContext);
+ const api = useAPIClient();
+ const form = useForm();
+ const { setVisible } = useActionContext();
+ const { refresh } = useResourceActionContext();
+ const { departments } = form.values || {};
+ return {
+ async run() {
+ await api.resource('roles.departments', role.name).add({
+ values: departments.map((dept: any) => dept.id),
+ });
+ form.reset();
+ setVisible(false);
+ refresh();
+ },
+ };
+};
+
+export const RoleDepartmentsManager: React.FC = () => {
+ const { t } = useDepartmentTranslation();
+ const { role } = useContext(RolesManagerContext);
+ const service = useRequest(
+ {
+ resource: `roles/${role?.name}/departments`,
+ action: 'list',
+ params: {
+ appends: ['parent', 'parent.parent(recursively=true)'],
+ },
+ },
+ {
+ ready: !!role,
+ },
+ );
+
+ useEffect(() => {
+ service.run();
+ }, [role]);
+
+ const schema = useMemo(() => getDepartmentsSchema(), [role]);
+
+ return (
+
+
+
+
+
+ );
+};
diff --git a/packages/plugins/@nocobase/plugin-departments/src/client/roles/schemas/departments.ts b/packages/plugins/@nocobase/plugin-departments/src/client/roles/schemas/departments.ts
new file mode 100644
index 0000000000..240a37635c
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-departments/src/client/roles/schemas/departments.ts
@@ -0,0 +1,171 @@
+/**
+ * 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 { uid } from '@formily/shared';
+
+export const getDepartmentsSchema = () => ({
+ type: 'void',
+ properties: {
+ actions: {
+ type: 'void',
+ 'x-component': 'ActionBar',
+ 'x-component-props': {
+ style: {
+ marginBottom: 16,
+ },
+ },
+ properties: {
+ [uid()]: {
+ type: 'void',
+ title: '{{ t("Filter") }}',
+ 'x-action': 'filter',
+ 'x-component': 'Filter.Action',
+ 'x-use-component-props': 'useFilterActionProps',
+ 'x-component-props': {
+ icon: 'FilterOutlined',
+ },
+ 'x-align': 'left',
+ },
+ actions: {
+ type: 'void',
+ 'x-component': 'Space',
+ properties: {
+ remove: {
+ type: 'void',
+ title: '{{t("Remove")}}',
+ 'x-component': 'Action',
+ 'x-component-props': {
+ icon: 'MinusOutlined',
+ confirm: {
+ title: "{{t('Remove')}}",
+ content: "{{t('Are you sure you want to remove these departments?')}}",
+ },
+ style: {
+ marginRight: 8,
+ },
+ useAction: '{{ useBulkRemoveDepartments }}',
+ },
+ },
+ create: {
+ type: 'void',
+ title: '{{t("Add departments")}}',
+ 'x-component': 'Action',
+ 'x-component-props': {
+ type: 'primary',
+ icon: 'PlusOutlined',
+ },
+ properties: {
+ drawer: {
+ type: 'void',
+ 'x-component': 'Action.Drawer',
+ 'x-decorator': 'FormV2',
+ title: '{{t("Add departments")}}',
+ properties: {
+ table: {
+ type: 'void',
+ 'x-decorator': 'FormItem',
+ 'x-component': 'DepartmentTable',
+ 'x-component-props': {
+ useDataSource: '{{ useDataSource }}',
+ useDisabled: '{{ useDisabled }}',
+ },
+ },
+ footer: {
+ type: 'void',
+ 'x-component': 'Action.Drawer.Footer',
+ properties: {
+ cancel: {
+ title: '{{t("Cancel")}}',
+ 'x-component': 'Action',
+ 'x-component-props': {
+ useAction: '{{ cm.useCancelAction }}',
+ },
+ },
+ submit: {
+ title: '{{t("Submit")}}',
+ 'x-component': 'Action',
+ 'x-component-props': {
+ type: 'primary',
+ useAction: '{{ useAddDepartments }}',
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ table: {
+ type: 'void',
+ 'x-component': 'Table.Void',
+ 'x-component-props': {
+ rowKey: 'id',
+ rowSelection: {
+ type: 'checkbox',
+ },
+ useDataSource: '{{ cm.useDataSourceFromRAC }}',
+ },
+ properties: {
+ title: {
+ type: 'void',
+ title: '{{t("Department name")}}',
+ 'x-decorator': 'Table.Column.Decorator',
+ 'x-component': 'Table.Column',
+ properties: {
+ title: {
+ type: 'string',
+ 'x-component': 'DepartmentTitle',
+ },
+ },
+ },
+ actions: {
+ type: 'void',
+ title: '{{t("Actions")}}',
+ 'x-component': 'Table.Column',
+ properties: {
+ actions: {
+ type: 'void',
+ 'x-component': 'Space',
+ 'x-component-props': {
+ split: '|',
+ },
+ properties: {
+ remove: {
+ type: 'void',
+ title: '{{ t("Remove") }}',
+ 'x-component': 'Action.Link',
+ 'x-component-props': {
+ confirm: {
+ title: "{{t('Remove department')}}",
+ content: "{{t('Are you sure you want to remove it?')}}",
+ },
+ useAction: '{{ useRemoveDepartment }}',
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+});
diff --git a/packages/plugins/@nocobase/plugin-departments/src/client/utils.ts b/packages/plugins/@nocobase/plugin-departments/src/client/utils.ts
new file mode 100644
index 0000000000..52f6fb7663
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-departments/src/client/utils.ts
@@ -0,0 +1,26 @@
+/**
+ * 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
+ */
+
+export const getDepartmentTitle = (record: any) => {
+ const title = record.title;
+ const parent = record.parent;
+ if (parent) {
+ return getDepartmentTitle(parent) + ' / ' + title;
+ }
+ return title;
+};
diff --git a/packages/plugins/@nocobase/plugin-departments/src/index.ts b/packages/plugins/@nocobase/plugin-departments/src/index.ts
new file mode 100644
index 0000000000..7d69462f4f
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-departments/src/index.ts
@@ -0,0 +1,20 @@
+/**
+ * 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
+ */
+
+export * from './server';
+export { default } from './server';
diff --git a/packages/plugins/@nocobase/plugin-departments/src/locale/en-US.json b/packages/plugins/@nocobase/plugin-departments/src/locale/en-US.json
new file mode 100644
index 0000000000..f872121a10
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-departments/src/locale/en-US.json
@@ -0,0 +1,35 @@
+{
+ "Department": "Department",
+ "All users": "All users",
+ "New department": "New department",
+ "Add department": "Add department",
+ "Add departments": "Add departments",
+ "New sub department": "New sub department",
+ "Edit department": "Edit department",
+ "Delete department": "Delete department",
+ "Departments": "Departments",
+ "Main department": "Main department",
+ "Owner": "Owner",
+ "Department name": "Department name",
+ "Superior department": "Superior department",
+ "Owners": "Owners",
+ "Add members": "Add members",
+ "Search for departments, users": "Search for departments, users",
+ "Search results": "Search results",
+ "Departments management": "Departments management",
+ "Roles management": "Roles management",
+ "Remove members": "Remove members",
+ "Remove member": "Remove member",
+ "Remove departments": "Remove departments",
+ "Remove department": "Remove department",
+ "Are you sure you want to remove it?": "Are you sure you want to remove it?",
+ "Are you sure you want to remove these members?": "Are you sure you want to remove these members?",
+ "Are you sure you want to remove these departments?": "Are you sure you want to remove these departments?",
+ "Please select members": "Please select members",
+ "Please select departments": "Please select departments",
+ "The department has sub-departments, please delete them first": "The department has sub-departments, please delete them first",
+ "The department has members, please remove them first": "The department has members, please remove them first",
+ "Main": "Main",
+ "Set as main department": "Set as main department",
+ "This field is currently not supported for use in form blocks.": "This field is currently not supported for use in form blocks."
+}
diff --git a/packages/plugins/@nocobase/plugin-departments/src/locale/zh-CN.json b/packages/plugins/@nocobase/plugin-departments/src/locale/zh-CN.json
new file mode 100644
index 0000000000..be5393b399
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-departments/src/locale/zh-CN.json
@@ -0,0 +1,35 @@
+{
+ "Department": "部门",
+ "All users": "所有用户",
+ "New department": "新建部门",
+ "New sub department": "新建子部门",
+ "Add department": "添加部门",
+ "Add departments": "添加部门",
+ "Edit department": "编辑部门",
+ "Delete department": "删除部门",
+ "Departments": "部门",
+ "Main department": "主属部门",
+ "Owner": "负责人",
+ "Department name": "部门名称",
+ "Superior department": "上级部门",
+ "Owners": "负责人",
+ "Add members": "添加成员",
+ "Search for departments, users": "搜索部门、用户",
+ "Search results": "搜索结果",
+ "Departments management": "部门管理",
+ "Roles management": "角色管理",
+ "Remove members": "移除成员",
+ "Remove member": "移除成员",
+ "Remove departments": "移除部门",
+ "Remove department": "移除部门",
+ "Are you sure you want to remove it?": "你确定要移除吗?",
+ "Are you sure you want to remove these members?": "你确定要移除这些成员吗?",
+ "Are you sure you want to remove these departments?": "你确定要移除这些部门吗?",
+ "Please select members": "请选择成员",
+ "Please select departments": "请选择部门",
+ "The department has sub-departments, please delete them first": "部门下有子部门,请先删除子部门",
+ "The department has members, please remove them first": "部门下有成员,请先移除",
+ "Main": "主属部门",
+ "Set as main department": "设置为主属部门",
+ "This field is currently not supported for use in form blocks.": "该字段目前不支持在表单区块中使用。"
+}
diff --git a/packages/plugins/@nocobase/plugin-departments/src/server/__tests__/actions.test.ts b/packages/plugins/@nocobase/plugin-departments/src/server/__tests__/actions.test.ts
new file mode 100644
index 0000000000..1cc536139b
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-departments/src/server/__tests__/actions.test.ts
@@ -0,0 +1,125 @@
+/**
+ * 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 { Database, Repository } from '@nocobase/database';
+import { MockServer, createMockServer } from '@nocobase/test';
+
+describe('actions', () => {
+ let app: MockServer;
+ let db: Database;
+ let repo: Repository;
+ let agent: any;
+
+ beforeAll(async () => {
+ app = await createMockServer({
+ plugins: ['field-sort', 'users', 'departments'],
+ });
+ db = app.db;
+ repo = db.getRepository('departments');
+ agent = app.agent();
+ });
+
+ afterAll(async () => {
+ await app.destroy();
+ });
+
+ afterEach(async () => {
+ await repo.destroy({ truncate: true });
+ });
+
+ it('should list users exclude department', async () => {
+ const dept = await repo.create({
+ values: {
+ title: 'Test department',
+ members: [1],
+ },
+ });
+ const res = await agent.resource('users').listExcludeDept({
+ departmentId: dept.id,
+ });
+ expect(res.status).toBe(200);
+ expect(res.body.data.length).toBe(0);
+ });
+
+ it('should list users exclude department with filter', async () => {
+ let res = await agent.resource('users').listExcludeDept({
+ departmentId: 1,
+ });
+ expect(res.status).toBe(200);
+ expect(res.body.data.length).toBe(1);
+
+ res = await agent.resource('users').listExcludeDept({
+ departmentId: 1,
+ filter: {
+ id: 1,
+ },
+ });
+ expect(res.status).toBe(200);
+ expect(res.body.data.length).toBe(1);
+
+ res = await agent.resource('users').listExcludeDept({
+ departmentId: 1,
+ filter: {
+ id: 2,
+ },
+ });
+ expect(res.status).toBe(200);
+ expect(res.body.data.length).toBe(0);
+ });
+
+ it('should set main department', async () => {
+ const depts = await repo.create({
+ values: [
+ {
+ title: 'Dept1',
+ members: [1],
+ },
+ {
+ title: 'Dept2',
+ members: [1],
+ },
+ ],
+ });
+ const deptUsers = db.getRepository('departmentsUsers');
+ await deptUsers.update({
+ filter: {
+ departmentId: depts[0].id,
+ userId: 1,
+ },
+ values: {
+ isMain: true,
+ },
+ });
+ const res = await agent.resource('users').setMainDepartment({
+ values: {
+ userId: 1,
+ departmentId: depts[1].id,
+ },
+ });
+ expect(res.status).toBe(200);
+ const records = await deptUsers.find({
+ filter: {
+ userId: 1,
+ },
+ });
+ const dept1 = records.find((record: any) => record.departmentId === depts[0].id);
+ const dept2 = records.find((record: any) => record.departmentId === depts[1].id);
+ expect(dept1.isMain).toBe(false);
+ expect(dept2.isMain).toBe(true);
+ });
+});
diff --git a/packages/plugins/@nocobase/plugin-departments/src/server/__tests__/data-sync.test.ts b/packages/plugins/@nocobase/plugin-departments/src/server/__tests__/data-sync.test.ts
new file mode 100644
index 0000000000..0471a66632
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-departments/src/server/__tests__/data-sync.test.ts
@@ -0,0 +1,278 @@
+/**
+ * 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 PluginUserDataSyncServer, { UserDataResourceManager } from '@nocobase/plugin-user-data-sync';
+import { MockDatabase, MockServer, createMockServer } from '@nocobase/test';
+
+describe('department data sync', async () => {
+ let app: MockServer;
+ let db: MockDatabase;
+ let resourceManager: UserDataResourceManager;
+
+ beforeEach(async () => {
+ app = await createMockServer({
+ plugins: ['field-sort', 'user-data-sync', 'users', 'departments'],
+ });
+ db = app.db;
+ const plugin = app.pm.get('user-data-sync') as PluginUserDataSyncServer;
+ resourceManager = plugin.resourceManager;
+ });
+
+ afterEach(async () => {
+ await db.clean({ drop: true });
+ await app.destroy();
+ });
+
+ it('should create department', async () => {
+ await resourceManager.updateOrCreate({
+ sourceName: 'test',
+ dataType: 'department',
+ records: [
+ {
+ uid: '1',
+ title: 'test',
+ },
+ {
+ uid: '2',
+ title: 'sub-test',
+ parentUid: '1',
+ },
+ ],
+ });
+ const department = await db.getRepository('departments').findOne({
+ filter: {
+ title: 'test',
+ },
+ appends: ['children'],
+ });
+ expect(department).toBeTruthy();
+ expect(department.title).toBe('test');
+ expect(department.children).toHaveLength(1);
+ expect(department.children[0].title).toBe('sub-test');
+ });
+
+ it('should update department', async () => {
+ await resourceManager.updateOrCreate({
+ sourceName: 'test',
+ dataType: 'department',
+ records: [
+ {
+ uid: '1',
+ title: 'test',
+ },
+ ],
+ });
+ const department = await db.getRepository('departments').findOne({
+ filter: {
+ title: 'test',
+ },
+ appends: ['children'],
+ });
+ expect(department).toBeTruthy();
+ expect(department.children).toHaveLength(0);
+ await resourceManager.updateOrCreate({
+ sourceName: 'test',
+ dataType: 'department',
+ records: [
+ {
+ uid: '1',
+ title: 'test2',
+ },
+ {
+ uid: '2',
+ title: 'sub-test',
+ parentUid: '1',
+ },
+ ],
+ });
+ const department2 = await db.getRepository('departments').findOne({
+ filter: {
+ id: department.id,
+ },
+ appends: ['children'],
+ });
+ expect(department2).toBeTruthy();
+ expect(department2.title).toBe('test2');
+ expect(department2.children).toHaveLength(1);
+ await resourceManager.updateOrCreate({
+ sourceName: 'test',
+ dataType: 'department',
+ records: [
+ {
+ uid: '2',
+ title: 'sub-test',
+ },
+ ],
+ });
+ const department3 = await db.getRepository('departments').findOne({
+ filter: {
+ id: department2.children[0].id,
+ },
+ appends: ['parent'],
+ });
+ expect(department3).toBeTruthy();
+ expect(department3.parent).toBeNull();
+ });
+
+ it('should update user department', async () => {
+ await resourceManager.updateOrCreate({
+ sourceName: 'test',
+ dataType: 'department',
+ records: [
+ {
+ uid: '12',
+ title: 'test',
+ },
+ ],
+ });
+ const department = await db.getRepository('departments').findOne({
+ filter: {
+ title: 'test',
+ },
+ });
+ expect(department).toBeTruthy();
+ await resourceManager.updateOrCreate({
+ sourceName: 'test',
+ dataType: 'user',
+ records: [
+ {
+ uid: '1',
+ nickname: 'test',
+ email: 'test@nocobase.com',
+ departments: ['12'],
+ },
+ ],
+ });
+ const user = await db.getRepository('users').findOne({
+ filter: {
+ email: 'test@nocobase.com',
+ },
+ appends: ['departments'],
+ });
+ expect(user).toBeTruthy();
+ expect(user.departments).toHaveLength(1);
+ expect(user.departments[0].id).toBe(department.id);
+ const departmentUser = await db.getRepository('departmentsUsers').findOne({
+ filter: {
+ departmentId: department.id,
+ userId: user.id,
+ },
+ });
+ expect(departmentUser).toBeTruthy();
+ expect(departmentUser.isOwner).toBe(false);
+ expect(departmentUser.isMain).toBe(false);
+ await resourceManager.updateOrCreate({
+ sourceName: 'test',
+ dataType: 'user',
+ records: [
+ {
+ uid: '1',
+ nickname: 'test',
+ email: 'test@nocobase.com',
+ departments: [
+ {
+ uid: '12',
+ isOwner: true,
+ isMain: true,
+ },
+ ],
+ },
+ ],
+ });
+ const departmentUser2 = await db.getRepository('departmentsUsers').findOne({
+ filter: {
+ departmentId: department.id,
+ userId: user.id,
+ },
+ });
+ expect(departmentUser2).toBeTruthy();
+ expect(departmentUser2.isOwner).toBe(true);
+ expect(departmentUser2.isMain).toBe(true);
+ await resourceManager.updateOrCreate({
+ sourceName: 'test',
+ dataType: 'user',
+ records: [
+ {
+ uid: '2',
+ nickname: 'test2',
+ email: 'test2@nocobase.com',
+ departments: [
+ {
+ uid: '12',
+ isOwner: true,
+ isMain: false,
+ },
+ ],
+ },
+ ],
+ });
+ const user2 = await db.getRepository('users').findOne({
+ filter: {
+ email: 'test2@nocobase.com',
+ },
+ appends: ['departments'],
+ });
+ expect(user2).toBeTruthy();
+ expect(user2.departments).toHaveLength(1);
+ expect(user2.departments[0].id).toBe(department.id);
+ const departmentUser3 = await db.getRepository('departmentsUsers').findOne({
+ filter: {
+ departmentId: department.id,
+ userId: user2.id,
+ },
+ });
+ expect(departmentUser3).toBeTruthy();
+ expect(departmentUser3.isOwner).toBe(true);
+ expect(departmentUser3.isMain).toBe(false);
+ });
+
+ it('should update department custom field', async () => {
+ const departmemntCollection = db.getCollection('departments');
+ departmemntCollection.addField('customField', { type: 'string' });
+ await db.sync({
+ alter: true,
+ });
+ await resourceManager.updateOrCreate({
+ sourceName: 'test',
+ dataType: 'department',
+ records: [
+ {
+ uid: '1',
+ title: 'test',
+ customField: 'testField',
+ },
+ ],
+ });
+ const department = await db.getRepository('departments').findOne({
+ filter: {
+ title: 'test',
+ },
+ });
+ expect(department).toBeTruthy();
+ expect(department.customField).toBe('testField');
+ await resourceManager.updateOrCreate({
+ sourceName: 'test',
+ dataType: 'department',
+ records: [
+ {
+ uid: '1',
+ title: 'test',
+ customField: 'testField2',
+ },
+ ],
+ });
+ const department2 = await db.getRepository('departments').findOne({
+ filter: {
+ id: department.id,
+ },
+ });
+ expect(department2).toBeTruthy();
+ expect(department2.customField).toBe('testField2');
+ });
+});
diff --git a/packages/plugins/@nocobase/plugin-departments/src/server/__tests__/destroy-department-check.test.ts b/packages/plugins/@nocobase/plugin-departments/src/server/__tests__/destroy-department-check.test.ts
new file mode 100644
index 0000000000..80355b551e
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-departments/src/server/__tests__/destroy-department-check.test.ts
@@ -0,0 +1,72 @@
+/**
+ * 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 { Database, Repository } from '@nocobase/database';
+import { MockServer, createMockServer } from '@nocobase/test';
+
+describe('destroy department check', () => {
+ let app: MockServer;
+ let db: Database;
+ let repo: Repository;
+ let agent: any;
+
+ beforeAll(async () => {
+ app = await createMockServer({
+ plugins: ['field-sort', 'users', 'departments'],
+ });
+ db = app.db;
+ repo = db.getRepository('departments');
+ agent = app.agent();
+ });
+
+ afterAll(async () => {
+ await app.destroy();
+ });
+
+ afterEach(async () => {
+ await repo.destroy({ truncate: true });
+ });
+
+ it('should check if it has sub departments', async () => {
+ const dept = await repo.create({
+ values: {
+ title: 'Department',
+ children: [{ title: 'Sub department' }],
+ },
+ });
+ const res = await agent.resource('departments').destroy({
+ filterByTk: dept.id,
+ });
+ expect(res.status).toBe(400);
+ expect(res.text).toBe('The department has sub-departments, please delete them first');
+ });
+
+ it('should check if it has members', async () => {
+ const dept = await repo.create({
+ values: {
+ title: 'Department',
+ members: [1],
+ },
+ });
+ const res = await agent.resource('departments').destroy({
+ filterByTk: dept.id,
+ });
+ expect(res.status).toBe(400);
+ expect(res.text).toBe('The department has members, please remove them first');
+ });
+});
diff --git a/packages/plugins/@nocobase/plugin-departments/src/server/__tests__/set-department-owners.test.ts b/packages/plugins/@nocobase/plugin-departments/src/server/__tests__/set-department-owners.test.ts
new file mode 100644
index 0000000000..7775b6e873
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-departments/src/server/__tests__/set-department-owners.test.ts
@@ -0,0 +1,91 @@
+/**
+ * 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 { Database, Repository } from '@nocobase/database';
+import { MockServer, createMockServer } from '@nocobase/test';
+
+describe('set department owners', () => {
+ let app: MockServer;
+ let db: Database;
+ let repo: Repository;
+ let agent: any;
+
+ beforeAll(async () => {
+ app = await createMockServer({
+ plugins: ['field-sort', 'users', 'departments'],
+ });
+ db = app.db;
+ repo = db.getRepository('departments');
+ agent = app.agent();
+ });
+
+ afterAll(async () => {
+ await app.destroy();
+ });
+
+ afterEach(async () => {
+ await repo.destroy({ truncate: true });
+ });
+
+ it('should set department owners', async () => {
+ await db.getRepository('users').create({
+ values: {
+ username: 'test',
+ },
+ });
+ const dept = await repo.create({
+ values: {
+ title: 'Department',
+ members: [1, 2],
+ },
+ });
+ await agent.resource('departments').update({
+ filterByTk: dept.id,
+ values: {
+ owners: [{ id: 1 }],
+ },
+ });
+ const deptUser = await db.getRepository('departmentsUsers').findOne({
+ filter: {
+ userId: 1,
+ departmentId: dept.id,
+ },
+ });
+ expect(deptUser.isOwner).toBe(true);
+ await agent.resource('departments').update({
+ filterByTk: dept.id,
+ values: {
+ owners: [{ id: 2 }],
+ },
+ });
+ const deptUser1 = await db.getRepository('departmentsUsers').findOne({
+ filter: {
+ userId: 1,
+ departmentId: dept.id,
+ },
+ });
+ expect(deptUser1.isOwner).toBe(false);
+ const deptUser2 = await db.getRepository('departmentsUsers').findOne({
+ filter: {
+ userId: 2,
+ departmentId: dept.id,
+ },
+ });
+ expect(deptUser2.isOwner).toBe(true);
+ });
+});
diff --git a/packages/plugins/@nocobase/plugin-departments/src/server/__tests__/set-departments-info.test.ts b/packages/plugins/@nocobase/plugin-departments/src/server/__tests__/set-departments-info.test.ts
new file mode 100644
index 0000000000..b6417fbefa
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-departments/src/server/__tests__/set-departments-info.test.ts
@@ -0,0 +1,73 @@
+/**
+ * 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 { Database, Repository } from '@nocobase/database';
+import { MockServer, createMockServer } from '@nocobase/test';
+import { setDepartmentsInfo } from '../middlewares';
+
+describe('set departments info', () => {
+ let app: MockServer;
+ let db: Database;
+ let repo: Repository;
+ let agent: any;
+ let ctx: any;
+
+ beforeAll(async () => {
+ app = await createMockServer({
+ plugins: ['field-sort', 'users', 'departments', 'acl', 'data-source-manager'],
+ });
+ db = app.db;
+ repo = db.getRepository('departments');
+ agent = app.agent();
+ ctx = {
+ db,
+ cache: app.cache,
+ state: {},
+ };
+ });
+
+ afterAll(async () => {
+ await app.destroy();
+ });
+
+ afterEach(async () => {
+ await repo.destroy({ truncate: true });
+ });
+
+ it('should set departments roles', async () => {
+ ctx.state.currentUser = await db.getRepository('users').findOne({
+ filterByTk: 1,
+ });
+ const role = await db.getRepository('roles').create({
+ values: {
+ name: 'test-role',
+ title: 'Test role',
+ },
+ });
+ await repo.create({
+ values: {
+ title: 'Department',
+ roles: [role.name],
+ members: [1],
+ },
+ });
+ await setDepartmentsInfo(ctx, () => {});
+ expect(ctx.state.attachRoles.length).toBe(1);
+ expect(ctx.state.attachRoles[0].name).toBe('test-role');
+ });
+});
diff --git a/packages/plugins/@nocobase/plugin-departments/src/server/__tests__/set-main-department.test.ts b/packages/plugins/@nocobase/plugin-departments/src/server/__tests__/set-main-department.test.ts
new file mode 100644
index 0000000000..46a5b9d310
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-departments/src/server/__tests__/set-main-department.test.ts
@@ -0,0 +1,183 @@
+/**
+ * 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 { Database, Repository } from '@nocobase/database';
+import { MockServer, createMockServer } from '@nocobase/test';
+
+describe('set main department', () => {
+ let app: MockServer;
+ let db: Database;
+ let repo: Repository;
+ let agent: any;
+
+ beforeAll(async () => {
+ app = await createMockServer({
+ plugins: ['field-sort', 'users', 'departments'],
+ });
+ db = app.db;
+ repo = db.getRepository('departments');
+ agent = app.agent();
+ });
+
+ afterAll(async () => {
+ await app.destroy();
+ });
+
+ afterEach(async () => {
+ await repo.destroy({ truncate: true });
+ await db.getRepository('departmentsUsers').destroy({ truncate: true });
+ });
+
+ it('should set main department when add department members', async () => {
+ const dept = await repo.create({
+ values: {
+ title: 'Department',
+ },
+ });
+ await db.getRepository('users').create({
+ values: {
+ username: 'test',
+ },
+ });
+ await agent.resource('departments.members', dept.id).add({
+ values: [1, 2],
+ });
+ const throughRepo = db.getRepository('departmentsUsers');
+ const deptUsers = await throughRepo.find({
+ filter: {
+ userId: {
+ $in: [1, 2],
+ },
+ departmentId: dept.id,
+ },
+ });
+ for (const item of deptUsers) {
+ expect(item.isMain).toBe(true);
+ }
+
+ const dept2 = await repo.create({
+ values: {
+ title: 'Department2',
+ },
+ });
+ await agent.resource('departments.members', dept2.id).add({
+ values: [2],
+ });
+ const deptUsers2 = await throughRepo.find({
+ filter: {
+ userId: 2,
+ },
+ });
+ expect(deptUsers2.length).toBe(2);
+ expect(deptUsers2.find((i: any) => i.departmentId === dept.id).isMain).toBe(true);
+ expect(deptUsers2.find((i: any) => i.departmentId === dept2.id).isMain).toBe(false);
+ });
+
+ it('should set main department when remove department members', async () => {
+ const depts = await repo.create({
+ values: [
+ {
+ title: 'Department',
+ },
+ {
+ title: 'Department2',
+ },
+ ],
+ });
+ await agent.resource('departments.members', depts[0].id).add({
+ values: [1],
+ });
+ await agent.resource('departments.members', depts[1].id).add({
+ values: [1],
+ });
+ const throughRepo = db.getRepository('departmentsUsers');
+ const deptUsers = await throughRepo.find({
+ filter: {
+ userId: 1,
+ },
+ });
+ expect(deptUsers.length).toBe(2);
+ expect(deptUsers.find((i: any) => i.departmentId === depts[0].id).isMain).toBe(true);
+ expect(deptUsers.find((i: any) => i.departmentId === depts[1].id).isMain).toBe(false);
+
+ await agent.resource('departments.members', depts[0].id).remove({
+ values: [1],
+ });
+ const deptUsers2 = await throughRepo.find({
+ filter: {
+ userId: 1,
+ },
+ });
+ expect(deptUsers2.length).toBe(1);
+ expect(deptUsers2[0].departmentId).toBe(depts[1].id);
+ expect(deptUsers2[0].isMain).toBe(true);
+ });
+
+ it('should set main department when add user departments', async () => {
+ const depts = await repo.create({
+ values: [
+ {
+ title: 'Department',
+ },
+ {
+ title: 'Department2',
+ },
+ ],
+ });
+ await agent.resource('users.departments', 1).add({
+ values: depts.map((dept: any) => dept.id),
+ });
+ const throughRepo = db.getRepository('departmentsUsers');
+ const deptUsers = await throughRepo.find({
+ filter: {
+ userId: 1,
+ },
+ });
+ expect(deptUsers.length).toBe(2);
+ expect(deptUsers.find((i: any) => i.departmentId === depts[0].id).isMain).toBe(true);
+ expect(deptUsers.find((i: any) => i.departmentId === depts[1].id).isMain).toBe(false);
+ });
+
+ it('should set main department when remove user departments', async () => {
+ const depts = await repo.create({
+ values: [
+ {
+ title: 'Department',
+ },
+ {
+ title: 'Department2',
+ },
+ ],
+ });
+ await agent.resource('users.departments', 1).add({
+ values: depts.map((dept: any) => dept.id),
+ });
+ await agent.resource('users.departments', 1).remove({
+ values: [depts[0].id],
+ });
+ const throughRepo = db.getRepository('departmentsUsers');
+ const deptUsers = await throughRepo.find({
+ filter: {
+ userId: 1,
+ },
+ });
+ expect(deptUsers.length).toBe(1);
+ expect(deptUsers[0].departmentId).toBe(depts[1].id);
+ expect(deptUsers[0].isMain).toBe(true);
+ });
+});
diff --git a/packages/plugins/@nocobase/plugin-departments/src/server/__tests__/update-department-is-leaf.test.ts b/packages/plugins/@nocobase/plugin-departments/src/server/__tests__/update-department-is-leaf.test.ts
new file mode 100644
index 0000000000..7115d45456
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-departments/src/server/__tests__/update-department-is-leaf.test.ts
@@ -0,0 +1,102 @@
+/**
+ * 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 { Database, Repository } from '@nocobase/database';
+import { MockServer, createMockServer } from '@nocobase/test';
+
+describe('update department isLeaf', () => {
+ let app: MockServer;
+ let db: Database;
+ let repo: Repository;
+ let agent: any;
+
+ beforeAll(async () => {
+ app = await createMockServer({
+ plugins: ['field-sort', 'users', 'departments'],
+ });
+ db = app.db;
+ repo = db.getRepository('departments');
+ agent = app.agent();
+ });
+
+ afterAll(async () => {
+ await app.destroy();
+ });
+
+ afterEach(async () => {
+ await repo.destroy({ truncate: true });
+ });
+
+ it('should update isLeaf when create sub department', async () => {
+ const res = await agent.resource('departments').create({
+ values: {
+ title: 'Department',
+ },
+ });
+ const dept = res.body.data;
+ expect(dept).toBeTruthy();
+ expect(dept.isLeaf).toBe(true);
+
+ await agent.resource('departments').create({
+ values: {
+ title: 'Sub Department',
+ parent: dept,
+ },
+ });
+ const record = await repo.findOne({
+ filterByTk: dept.id,
+ });
+ expect(record.isLeaf).toBe(false);
+ });
+
+ it.runIf(process.env.DB_DIALECT !== 'sqlite')('should update isLeaf when update department', async () => {
+ const res = await agent.resource('departments').create({
+ values: {
+ title: 'Department',
+ },
+ });
+ const res2 = await agent.resource('departments').create({
+ values: {
+ title: 'Department2',
+ },
+ });
+ const dept1 = res.body.data;
+ const dept2 = res2.body.data;
+ const res3 = await agent.resource('departments').create({
+ values: {
+ title: 'Sub Department',
+ parent: dept1,
+ },
+ });
+ const subDept = res3.body.data;
+ await agent.resource('departments').update({
+ filterByTk: subDept.id,
+ values: {
+ parent: dept2,
+ },
+ });
+ const record1 = await repo.findOne({
+ filterByTk: dept1.id,
+ });
+ expect(record1.isLeaf).toBe(true);
+ const record2 = await repo.findOne({
+ filterByTk: dept2.id,
+ });
+ expect(record2.isLeaf).toBe(false);
+ });
+});
diff --git a/packages/plugins/@nocobase/plugin-departments/src/server/actions/departments.ts b/packages/plugins/@nocobase/plugin-departments/src/server/actions/departments.ts
new file mode 100644
index 0000000000..280fd8ab18
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-departments/src/server/actions/departments.ts
@@ -0,0 +1,97 @@
+/**
+ * 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 { Context, Next } from '@nocobase/actions';
+import { DepartmentModel } from '../models/department';
+
+export const getAppendsOwners = async (ctx: Context, next: Next) => {
+ const { filterByTk, appends } = ctx.action.params;
+ const repo = ctx.db.getRepository('departments');
+ const department: DepartmentModel = await repo.findOne({
+ filterByTk,
+ appends,
+ });
+ const owners = await department.getOwners();
+ department.setDataValue('owners', owners);
+ ctx.body = department;
+ await next();
+};
+
+export const aggregateSearch = async (ctx: Context, next: Next) => {
+ const { keyword, type, last = 0, limit = 10 } = ctx.action.params.values || {};
+ let users = [];
+ let departments = [];
+ if (!type || type === 'user') {
+ const repo = ctx.db.getRepository('users');
+ users = await repo.find({
+ filter: {
+ id: { $gt: last },
+ $or: [
+ { username: { $includes: keyword } },
+ { nickname: { $includes: keyword } },
+ { phone: { $includes: keyword } },
+ { email: { $includes: keyword } },
+ ],
+ },
+ limit,
+ });
+ }
+ if (!type || type === 'department') {
+ const repo = ctx.db.getRepository('departments');
+ departments = await repo.find({
+ filter: {
+ id: { $gt: last },
+ title: { $includes: keyword },
+ },
+ appends: ['parent(recursively=true)'],
+ limit,
+ });
+ }
+ ctx.body = { users, departments };
+ await next();
+};
+
+export const setOwner = async (ctx: Context, next: Next) => {
+ const { userId, departmentId } = ctx.action.params.values || {};
+ const throughRepo = ctx.db.getRepository('departmentsUsers');
+ await throughRepo.update({
+ filter: {
+ userId,
+ departmentId,
+ },
+ values: {
+ isOwner: true,
+ },
+ });
+ await next();
+};
+
+export const removeOwner = async (ctx: Context, next: Next) => {
+ const { userId, departmentId } = ctx.action.params.values || {};
+ const throughRepo = ctx.db.getRepository('departmentsUsers');
+ await throughRepo.update({
+ filter: {
+ userId,
+ departmentId,
+ },
+ values: {
+ isOwner: false,
+ },
+ });
+ await next();
+};
diff --git a/packages/plugins/@nocobase/plugin-departments/src/server/actions/users.ts b/packages/plugins/@nocobase/plugin-departments/src/server/actions/users.ts
new file mode 100644
index 0000000000..38681fa574
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-departments/src/server/actions/users.ts
@@ -0,0 +1,133 @@
+/**
+ * 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 { Context, DEFAULT_PAGE, DEFAULT_PER_PAGE, Next } from '@nocobase/actions';
+
+export const listExcludeDept = async (ctx: Context, next: Next) => {
+ const { departmentId, page = DEFAULT_PAGE, pageSize = DEFAULT_PER_PAGE } = ctx.action.params;
+ const repo = ctx.db.getRepository('users');
+ const members = await repo.find({
+ fields: ['id'],
+ filter: {
+ 'departments.id': departmentId,
+ },
+ });
+ const memberIds = members.map((member: { id: number }) => member.id);
+ if (memberIds.length) {
+ ctx.action.mergeParams({
+ filter: {
+ id: {
+ $notIn: memberIds,
+ },
+ },
+ });
+ }
+ const { filter } = ctx.action.params;
+ const [rows, count] = await repo.findAndCount({
+ context: ctx,
+ offset: (page - 1) * pageSize,
+ limit: +pageSize,
+ filter,
+ });
+ ctx.body = {
+ count,
+ rows,
+ page: Number(page),
+ pageSize: Number(pageSize),
+ totalPage: Math.ceil(count / pageSize),
+ };
+ await next();
+};
+
+export const setDepartments = async (ctx: Context, next: Next) => {
+ const { values = {} } = ctx.action.params;
+ const { userId, departments = [] } = values;
+ const repo = ctx.db.getRepository('users');
+ const throughRepo = ctx.db.getRepository('departmentsUsers');
+ const user = await repo.findOne({ filterByTk: userId });
+ if (!user) {
+ ctx.throw(400, ctx.t('User does not exist'));
+ }
+ const departmentIds = departments.map((department: any) => department.id);
+ const main = departments.find((department: any) => department.isMain);
+ const owners = departments.filter((department: any) => department.isOwner);
+ await ctx.db.sequelize.transaction(async (t) => {
+ await user.setDepartments(departmentIds, {
+ through: {
+ isMain: false,
+ isOwner: false,
+ },
+ transaction: t,
+ });
+ if (main) {
+ await throughRepo.update({
+ filter: {
+ userId,
+ departmentId: main.id,
+ },
+ values: {
+ isMain: true,
+ },
+ transaction: t,
+ });
+ }
+ if (owners.length) {
+ await throughRepo.update({
+ filter: {
+ userId,
+ departmentId: {
+ $in: owners.map((owner: any) => owner.id),
+ },
+ },
+ values: {
+ isOwner: true,
+ },
+ transaction: t,
+ });
+ }
+ });
+ await next();
+};
+
+export const setMainDepartment = async (ctx: Context, next: Next) => {
+ const { userId, departmentId } = ctx.action.params.values || {};
+ const throughRepo = ctx.db.getRepository('departmentsUsers');
+ await ctx.db.sequelize.transaction(async (t) => {
+ await throughRepo.update({
+ filter: {
+ userId,
+ isMain: true,
+ },
+ values: {
+ isMain: false,
+ },
+ transaction: t,
+ });
+ await throughRepo.update({
+ filter: {
+ userId,
+ departmentId,
+ },
+ values: {
+ isMain: true,
+ },
+ transaction: t,
+ });
+ });
+ await next();
+};
diff --git a/packages/plugins/@nocobase/plugin-departments/src/server/collections/.gitkeep b/packages/plugins/@nocobase/plugin-departments/src/server/collections/.gitkeep
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/packages/plugins/@nocobase/plugin-departments/src/server/collections/departmentRoles.ts b/packages/plugins/@nocobase/plugin-departments/src/server/collections/departmentRoles.ts
new file mode 100644
index 0000000000..b0f44497c2
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-departments/src/server/collections/departmentRoles.ts
@@ -0,0 +1,25 @@
+/**
+ * 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 { defineCollection } from '@nocobase/database';
+
+export default defineCollection({
+ name: 'departmentsRoles',
+ dumpRules: 'required',
+ migrationRules: ['overwrite'],
+});
diff --git a/packages/plugins/@nocobase/plugin-departments/src/server/collections/departments.ts b/packages/plugins/@nocobase/plugin-departments/src/server/collections/departments.ts
new file mode 100644
index 0000000000..a5881b35c9
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-departments/src/server/collections/departments.ts
@@ -0,0 +1,156 @@
+/**
+ * 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 { defineCollection } from '@nocobase/database';
+
+export const ownersField = {
+ interface: 'm2m',
+ type: 'belongsToMany',
+ name: 'owners',
+ collectionName: 'departments',
+ target: 'users',
+ through: 'departmentsUsers',
+ foreignKey: 'departmentId',
+ otherKey: 'userId',
+ targetKey: 'id',
+ sourceKey: 'id',
+ throughScope: {
+ isOwner: true,
+ },
+ uiSchema: {
+ type: 'm2m',
+ title: '{{t("Owners")}}',
+ 'x-component': 'DepartmentOwnersField',
+ 'x-component-props': {
+ multiple: true,
+ fieldNames: {
+ label: 'nickname',
+ value: 'id',
+ },
+ },
+ },
+};
+
+export default defineCollection({
+ name: 'departments',
+ migrationRules: ['overwrite'],
+ title: '{{t("Departments")}}',
+ dumpRules: 'required',
+ tree: 'adjacency-list',
+ template: 'tree',
+ shared: true,
+ sortable: true,
+ model: 'DepartmentModel',
+ createdBy: true,
+ updatedBy: true,
+ logging: true,
+ fields: [
+ {
+ type: 'bigInt',
+ name: 'id',
+ primaryKey: true,
+ autoIncrement: true,
+ interface: 'id',
+ uiSchema: {
+ type: 'number',
+ title: '{{t("ID")}}',
+ 'x-component': 'InputNumber',
+ 'x-read-pretty': true,
+ },
+ },
+ {
+ type: 'string',
+ name: 'title',
+ interface: 'input',
+ uiSchema: {
+ type: 'string',
+ title: '{{t("Department name")}}',
+ 'x-component': 'Input',
+ },
+ },
+ {
+ type: 'boolean',
+ name: 'isLeaf',
+ },
+ {
+ type: 'belongsTo',
+ name: 'parent',
+ target: 'departments',
+ foreignKey: 'parentId',
+ treeParent: true,
+ onDelete: 'CASCADE',
+ interface: 'm2o',
+ uiSchema: {
+ type: 'm2o',
+ title: '{{t("Superior department")}}',
+ 'x-component': 'AssociationField',
+ 'x-component-props': {
+ multiple: false,
+ fieldNames: {
+ label: 'title',
+ value: 'id',
+ },
+ },
+ },
+ },
+ {
+ type: 'hasMany',
+ name: 'children',
+ target: 'departments',
+ foreignKey: 'parentId',
+ treeChildren: true,
+ onDelete: 'CASCADE',
+ },
+ {
+ type: 'belongsToMany',
+ name: 'members',
+ target: 'users',
+ through: 'departmentsUsers',
+ foreignKey: 'departmentId',
+ otherKey: 'userId',
+ targetKey: 'id',
+ sourceKey: 'id',
+ onDelete: 'CASCADE',
+ },
+ {
+ interface: 'm2m',
+ type: 'belongsToMany',
+ name: 'roles',
+ target: 'roles',
+ through: 'departmentsRoles',
+ foreignKey: 'departmentId',
+ otherKey: 'roleName',
+ targetKey: 'name',
+ sourceKey: 'id',
+ onDelete: 'CASCADE',
+ uiSchema: {
+ type: 'm2m',
+ title: '{{t("Roles")}}',
+ 'x-component': 'AssociationField',
+ 'x-component-props': {
+ multiple: true,
+ fieldNames: {
+ label: 'title',
+ value: 'name',
+ },
+ },
+ },
+ },
+ ownersField,
+ ],
+});
diff --git a/packages/plugins/@nocobase/plugin-departments/src/server/collections/departmentsUsers.ts b/packages/plugins/@nocobase/plugin-departments/src/server/collections/departmentsUsers.ts
new file mode 100644
index 0000000000..70d5534d2c
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-departments/src/server/collections/departmentsUsers.ts
@@ -0,0 +1,39 @@
+/**
+ * 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 { defineCollection } from '@nocobase/database';
+
+export default defineCollection({
+ name: 'departmentsUsers',
+ dumpRules: 'required',
+ migrationRules: ['schema-only'],
+ fields: [
+ {
+ type: 'boolean',
+ name: 'isOwner', // Weather the user is the owner of the department
+ allowNull: false,
+ defaultValue: false,
+ },
+ {
+ type: 'boolean',
+ name: 'isMain', // Weather this is the main department of the user
+ allowNull: false,
+ defaultValue: false,
+ },
+ ],
+});
diff --git a/packages/plugins/@nocobase/plugin-departments/src/server/collections/roles.ts b/packages/plugins/@nocobase/plugin-departments/src/server/collections/roles.ts
new file mode 100644
index 0000000000..bac06fa60b
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-departments/src/server/collections/roles.ts
@@ -0,0 +1,36 @@
+/**
+ * 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 { extendCollection } from '@nocobase/database';
+
+export default extendCollection({
+ name: 'roles',
+ fields: [
+ {
+ type: 'belongsToMany',
+ name: 'departments',
+ target: 'departments',
+ foreignKey: 'roleName',
+ otherKey: 'departmentId',
+ onDelete: 'CASCADE',
+ sourceKey: 'name',
+ targetKey: 'id',
+ through: 'departmentsRoles',
+ },
+ ],
+});
diff --git a/packages/plugins/@nocobase/plugin-departments/src/server/collections/users.ts b/packages/plugins/@nocobase/plugin-departments/src/server/collections/users.ts
new file mode 100644
index 0000000000..c5bcf488cc
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-departments/src/server/collections/users.ts
@@ -0,0 +1,79 @@
+/**
+ * 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 { extendCollection } from '@nocobase/database';
+
+export const departmentsField = {
+ collectionName: 'users',
+ interface: 'm2m',
+ type: 'belongsToMany',
+ name: 'departments',
+ target: 'departments',
+ foreignKey: 'userId',
+ otherKey: 'departmentId',
+ onDelete: 'CASCADE',
+ sourceKey: 'id',
+ targetKey: 'id',
+ through: 'departmentsUsers',
+ uiSchema: {
+ type: 'm2m',
+ title: '{{t("Departments")}}',
+ 'x-component': 'UserDepartmentsField',
+ 'x-component-props': {
+ multiple: true,
+ fieldNames: {
+ label: 'title',
+ value: 'name',
+ },
+ },
+ },
+};
+
+export const mainDepartmentField = {
+ collectionName: 'users',
+ interface: 'm2m',
+ type: 'belongsToMany',
+ name: 'mainDepartment',
+ target: 'departments',
+ foreignKey: 'userId',
+ otherKey: 'departmentId',
+ onDelete: 'CASCADE',
+ sourceKey: 'id',
+ targetKey: 'id',
+ through: 'departmentsUsers',
+ throughScope: {
+ isMain: true,
+ },
+ uiSchema: {
+ type: 'm2m',
+ title: '{{t("Main department")}}',
+ 'x-component': 'UserMainDepartmentField',
+ 'x-component-props': {
+ multiple: false,
+ fieldNames: {
+ label: 'title',
+ value: 'name',
+ },
+ },
+ },
+};
+
+export default extendCollection({
+ name: 'users',
+ fields: [departmentsField, mainDepartmentField],
+});
diff --git a/packages/plugins/@nocobase/plugin-departments/src/server/department-data-sync-resource.ts b/packages/plugins/@nocobase/plugin-departments/src/server/department-data-sync-resource.ts
new file mode 100644
index 0000000000..69d0d414df
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-departments/src/server/department-data-sync-resource.ts
@@ -0,0 +1,333 @@
+/**
+ * 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 { Model } from '@nocobase/database';
+import lodash from 'lodash';
+import {
+ FormatDepartment,
+ FormatUserDepartment,
+ OriginRecord,
+ PrimaryKey,
+ RecordResourceChanged,
+ SyncAccept,
+ UserDataResource,
+} from '@nocobase/plugin-user-data-sync';
+
+export class DepartmentDataSyncResource extends UserDataResource {
+ name = 'departments';
+ accepts: SyncAccept[] = ['user', 'department'];
+
+ get userRepo() {
+ return this.db.getRepository('users');
+ }
+
+ get deptRepo() {
+ return this.db.getRepository('departments');
+ }
+
+ get deptUserRepo() {
+ return this.db.getRepository('departmentsUsers');
+ }
+
+ getFlteredSourceDepartment(sourceDepartment: FormatDepartment) {
+ const deleteProps = [
+ 'id',
+ 'uid',
+ 'createdAt',
+ 'updatedAt',
+ 'sort',
+ 'createdById',
+ 'updatedById',
+ 'isDeleted',
+ 'parentId',
+ 'parentUid',
+ ];
+ return lodash.omit(sourceDepartment, deleteProps);
+ }
+
+ async update(record: OriginRecord, resourcePks: PrimaryKey[]): Promise {
+ const { dataType, metaData, sourceName } = record;
+ if (dataType === 'user') {
+ const sourceUser = metaData;
+ if (sourceUser.isDeleted) {
+ if (!resourcePks || !resourcePks.length) {
+ return [];
+ } else {
+ return resourcePks.map((id) => ({ resourcesPk: id, isDeleted: true }));
+ }
+ }
+ const resources = record.resources.filter((r) => r.resource === 'users');
+ if (!resources.length) {
+ return [];
+ }
+ const user = await this.userRepo.findOne({
+ filterByTk: resources[0].resourcePk,
+ });
+ if (!user) {
+ if (!resourcePks || !resourcePks.length) {
+ return [];
+ } else {
+ return resourcePks.map((id) => ({ resourcesPk: id, isDeleted: true }));
+ }
+ } else {
+ return await this.updateUserDepartments(user, resourcePks, sourceUser.departments, sourceName);
+ }
+ } else if (dataType === 'department') {
+ const sourceDepartment = metaData;
+ const department = await this.deptRepo.findOne({
+ filterByTk: resourcePks[0],
+ });
+ if (!department) {
+ if (sourceDepartment.isDeleted) {
+ return [{ resourcesPk: resourcePks[0], isDeleted: true }];
+ }
+ const result = await this.create(record);
+ return [...result, { resourcesPk: resourcePks[0], isDeleted: true }];
+ }
+ await this.updateDepartment(department, sourceDepartment, sourceName);
+ } else {
+ this.logger.warn(`update department: unsupported data type: ${dataType}`);
+ }
+ return [];
+ }
+
+ async create(record: OriginRecord): Promise {
+ const { dataType, metaData, sourceName } = record;
+ if (dataType === 'user') {
+ const sourceUser = metaData;
+ if (sourceUser.isDeleted) {
+ return [];
+ }
+ const resources = record.resources.filter((r) => r.resource === 'users');
+ if (!resources.length) {
+ return [];
+ }
+ const user = await this.userRepo.findOne({
+ filterByTk: resources[0].resourcePk,
+ });
+ return await this.updateUserDepartments(user, [], sourceUser.departments, sourceName);
+ } else if (dataType === 'department') {
+ const sourceDepartment = metaData;
+ const newDepartmentId = await this.createDepartment(sourceDepartment, sourceName);
+ return [{ resourcesPk: newDepartmentId, isDeleted: false }];
+ } else {
+ this.logger.warn(`create department: unsupported data type: ${dataType}`);
+ }
+ return [];
+ }
+
+ async getDepartmentIdsBySourceUks(sourceUks: PrimaryKey[], sourceName: string) {
+ const syncDepartmentRecords = await this.syncRecordRepo.find({
+ filter: {
+ sourceName,
+ dataType: 'department',
+ sourceUk: { $in: sourceUks },
+ 'resources.resource': this.name,
+ },
+ appends: ['resources'],
+ });
+ const departmentIds = syncDepartmentRecords
+ .filter((record) => record.resources?.length)
+ .map((record) => record.resources[0].resourcePk);
+ return departmentIds;
+ }
+
+ async getDepartmentIdBySourceUk(sourceUk: PrimaryKey, sourceName: string) {
+ const syncDepartmentRecord = await this.syncRecordRepo.findOne({
+ filter: {
+ sourceName,
+ dataType: 'department',
+ sourceUk,
+ 'resources.resource': this.name,
+ },
+ appends: ['resources'],
+ });
+ if (syncDepartmentRecord && syncDepartmentRecord.resources?.length) {
+ return syncDepartmentRecord.resources[0].resourcePk;
+ }
+ }
+
+ async updateUserDepartments(
+ user: any,
+ currentDepartmentIds: PrimaryKey[],
+ sourceDepartments: (PrimaryKey | FormatUserDepartment)[],
+ sourceName: string,
+ ): Promise {
+ if (!this.deptRepo) {
+ return [];
+ }
+ if (!sourceDepartments || !sourceDepartments.length) {
+ const userDepartments = await user.getDepartments();
+ if (userDepartments.length) {
+ await user.removeDepartments(userDepartments);
+ }
+ if (currentDepartmentIds && currentDepartmentIds.length) {
+ return currentDepartmentIds.map((id) => ({ resourcesPk: id, isDeleted: true }));
+ } else {
+ return [];
+ }
+ } else {
+ const sourceDepartmentIds = sourceDepartments.map((sourceDepartment) => {
+ if (typeof sourceDepartment === 'string' || typeof sourceDepartment === 'number') {
+ return sourceDepartment;
+ }
+ return sourceDepartment.uid;
+ });
+ const newDepartmentIds = await this.getDepartmentIdsBySourceUks(sourceDepartmentIds, sourceName);
+ const newDepartments = await this.deptRepo.find({
+ filter: { id: { $in: newDepartmentIds } },
+ });
+ const realCurrentDepartments = await user.getDepartments();
+ // 需要删除的部门
+ const toRealRemoveDepartments = realCurrentDepartments.filter((currnetDepartment) => {
+ return !newDepartments.find((newDepartment) => newDepartment.id === currnetDepartment.id);
+ });
+ if (toRealRemoveDepartments.length) {
+ await user.removeDepartments(toRealRemoveDepartments);
+ }
+ // 需要添加的部门
+ const toRealAddDepartments = newDepartments.filter((newDepartment) => {
+ if (realCurrentDepartments.length === 0) {
+ return true;
+ }
+ return !realCurrentDepartments.find((currentDepartment) => currentDepartment.id === newDepartment.id);
+ });
+ if (toRealAddDepartments.length) {
+ await user.addDepartments(toRealAddDepartments);
+ }
+ // 更新部门主管和主部门
+ for (const sourceDepartment of sourceDepartments) {
+ this.logger.debug('update dept owner: ' + JSON.stringify(sourceDepartment));
+ let isOwner = false;
+ let isMain = false;
+ let uid;
+ if (typeof sourceDepartment !== 'string' && typeof sourceDepartment !== 'number') {
+ isOwner = sourceDepartment.isOwner || false;
+ isMain = sourceDepartment.isMain || false;
+ uid = sourceDepartment.uid;
+ } else {
+ uid = sourceDepartment;
+ }
+ const deptId = await this.getDepartmentIdBySourceUk(uid, sourceName);
+ this.logger.debug('update dept owner: ' + JSON.stringify({ deptId, isOwner, isMain, userId: user.id }));
+ if (!deptId) {
+ continue;
+ }
+ await this.deptUserRepo.update({
+ filter: {
+ userId: user.id,
+ departmentId: deptId,
+ },
+ values: {
+ isOwner,
+ isMain,
+ },
+ });
+ }
+ const recordResourceChangeds: RecordResourceChanged[] = [];
+ if (currentDepartmentIds !== undefined && currentDepartmentIds.length > 0) {
+ // 需要删除的部门ID
+ const toRemoveDepartmentIds = currentDepartmentIds.filter(
+ (currentDepartmentId) => !newDepartmentIds.includes(currentDepartmentId),
+ );
+ recordResourceChangeds.push(
+ ...toRemoveDepartmentIds.map((departmentId) => {
+ return { resourcesPk: departmentId, isDeleted: true };
+ }),
+ );
+ // 需要添加的部门ID
+ const toAddDepartmentIds = newDepartmentIds.filter(
+ (newDepartmentId) => !currentDepartmentIds.includes(newDepartmentId),
+ );
+ recordResourceChangeds.push(
+ ...toAddDepartmentIds.map((departmentId) => {
+ return { resourcesPk: departmentId, isDeleted: false };
+ }),
+ );
+ } else {
+ recordResourceChangeds.push(
+ ...toRealAddDepartments.map((department) => {
+ return {
+ resourcesPk: department.id,
+ isDeleted: false,
+ };
+ }),
+ );
+ }
+ return recordResourceChangeds;
+ }
+ }
+
+ async updateDepartment(department: Model, sourceDepartment: FormatDepartment, sourceName: string) {
+ if (sourceDepartment.isDeleted) {
+ // 删除部门
+ await department.destroy();
+ return;
+ }
+ let dataChanged = false;
+ const filteredSourceDepartment = this.getFlteredSourceDepartment(sourceDepartment);
+ lodash.forOwn(filteredSourceDepartment, (value, key) => {
+ if (department[key] !== value) {
+ department[key] = value;
+ dataChanged = true;
+ }
+ });
+ if (dataChanged) {
+ await department.save();
+ }
+ await this.updateParentDepartment(department, sourceDepartment.parentUid, sourceName);
+ }
+
+ async createDepartment(sourceDepartment: FormatDepartment, sourceName: string): Promise {
+ const filteredSourceDepartment = this.getFlteredSourceDepartment(sourceDepartment);
+ const department = await this.deptRepo.create({
+ values: filteredSourceDepartment,
+ });
+ await this.updateParentDepartment(department, sourceDepartment.parentUid, sourceName);
+ return department.id;
+ }
+
+ async updateParentDepartment(department: Model, parentUid: string, sourceName: string) {
+ if (!parentUid) {
+ const parentDepartment = await department.getParent();
+ if (parentDepartment) {
+ await department.setParent(null);
+ }
+ } else {
+ const syncDepartmentRecord = await this.syncRecordRepo.findOne({
+ filter: {
+ sourceName,
+ dataType: 'department',
+ sourceUk: parentUid,
+ 'resources.resource': this.name,
+ },
+ appends: ['resources'],
+ });
+ if (syncDepartmentRecord && syncDepartmentRecord.resources?.length) {
+ const parentDepartment = await this.deptRepo.findOne({
+ filterByTk: syncDepartmentRecord.resources[0].resourcePk,
+ });
+ if (!parentDepartment) {
+ await department.setParent(null);
+ return;
+ }
+ const parent = await department.getParent();
+ if (parent) {
+ if (parentDepartment.id !== parent.id) {
+ await department.setParent(parentDepartment);
+ }
+ } else {
+ await department.setParent(parentDepartment);
+ }
+ } else {
+ await department.setParent(null);
+ }
+ }
+ }
+}
diff --git a/packages/plugins/@nocobase/plugin-departments/src/server/index.ts b/packages/plugins/@nocobase/plugin-departments/src/server/index.ts
new file mode 100644
index 0000000000..61787e8e89
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-departments/src/server/index.ts
@@ -0,0 +1,19 @@
+/**
+ * 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
+ */
+
+export { default } from './plugin';
diff --git a/packages/plugins/@nocobase/plugin-departments/src/server/middlewares/destroy-department-check.ts b/packages/plugins/@nocobase/plugin-departments/src/server/middlewares/destroy-department-check.ts
new file mode 100644
index 0000000000..ade251e3d9
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-departments/src/server/middlewares/destroy-department-check.ts
@@ -0,0 +1,48 @@
+/**
+ * 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 { Context, Next } from '@nocobase/actions';
+
+const destroyCheck = async (ctx: Context) => {
+ const { filterByTk } = ctx.action.params;
+ const repo = ctx.db.getRepository('departments');
+ const children = await repo.count({
+ filter: {
+ parentId: filterByTk,
+ },
+ });
+ if (children) {
+ ctx.throw(400, ctx.t('The department has sub-departments, please delete them first', { ns: 'departments' }));
+ }
+ const members = await ctx.db.getRepository('departmentsUsers').count({
+ filter: {
+ departmentId: filterByTk,
+ },
+ });
+ if (members) {
+ ctx.throw(400, ctx.t('The department has members, please remove them first', { ns: 'departments' }));
+ }
+};
+
+export const destroyDepartmentCheck = async (ctx: Context, next: Next) => {
+ const { resourceName, actionName } = ctx.action.params;
+ if (resourceName === 'departments' && actionName === 'destroy') {
+ await destroyCheck(ctx as any);
+ }
+ await next();
+};
diff --git a/packages/plugins/@nocobase/plugin-departments/src/server/middlewares/index.ts b/packages/plugins/@nocobase/plugin-departments/src/server/middlewares/index.ts
new file mode 100644
index 0000000000..8d2e037c65
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-departments/src/server/middlewares/index.ts
@@ -0,0 +1,24 @@
+/**
+ * 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
+ */
+
+export * from './destroy-department-check';
+export * from './reset-user-departments-cache';
+export * from './set-department-owners';
+export * from './update-department-isleaf';
+export * from './set-departments-roles';
+export * from './set-main-department';
diff --git a/packages/plugins/@nocobase/plugin-departments/src/server/middlewares/reset-user-departments-cache.ts b/packages/plugins/@nocobase/plugin-departments/src/server/middlewares/reset-user-departments-cache.ts
new file mode 100644
index 0000000000..0606093be1
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-departments/src/server/middlewares/reset-user-departments-cache.ts
@@ -0,0 +1,41 @@
+/**
+ * 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 { Context, Next } from '@nocobase/actions';
+import { Cache } from '@nocobase/cache';
+
+export const resetUserDepartmentsCache = async (ctx: Context, next: Next) => {
+ await next();
+ const { associatedName, resourceName, associatedIndex, actionName, values } = ctx.action.params;
+ const cache = ctx.app.cache as Cache;
+ if (
+ associatedName === 'departments' &&
+ resourceName === 'members' &&
+ ['add', 'remove', 'set'].includes(actionName) &&
+ values?.length
+ ) {
+ // Delete cache when the members of a department changed
+ for (const memberId of values) {
+ await cache.del(`departments:${memberId}`);
+ }
+ }
+
+ if (associatedName === 'users' && resourceName === 'departments' && ['add', 'remove', 'set'].includes(actionName)) {
+ await cache.del(`departments:${associatedIndex}`);
+ }
+};
diff --git a/packages/plugins/@nocobase/plugin-departments/src/server/middlewares/set-department-owners.ts b/packages/plugins/@nocobase/plugin-departments/src/server/middlewares/set-department-owners.ts
new file mode 100644
index 0000000000..56a0da774f
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-departments/src/server/middlewares/set-department-owners.ts
@@ -0,0 +1,59 @@
+/**
+ * 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 { Context, Next } from '@nocobase/actions';
+import lodash from 'lodash';
+
+const setOwners = async (ctx: Context, filterByTk: any, owners: any[]) => {
+ const throughRepo = ctx.db.getRepository('departmentsUsers');
+ await ctx.db.sequelize.transaction(async (t) => {
+ await throughRepo.update({
+ filter: {
+ departmentId: filterByTk,
+ },
+ values: {
+ isOwner: false,
+ },
+ transaction: t,
+ });
+ await throughRepo.update({
+ filter: {
+ departmentId: filterByTk,
+ userId: {
+ $in: owners.map((owner: any) => owner.id),
+ },
+ },
+ values: {
+ isOwner: true,
+ },
+ transaction: t,
+ });
+ });
+};
+
+export const setDepartmentOwners = async (ctx: Context, next: Next) => {
+ const { filterByTk, values = {}, resourceName, actionName } = ctx.action.params;
+ const { owners } = values;
+ if (resourceName === 'departments' && actionName === 'update' && owners) {
+ ctx.action.params.values = lodash.omit(values, ['owners']);
+ await next();
+ await setOwners(ctx as any, filterByTk, owners);
+ } else {
+ return next();
+ }
+};
diff --git a/packages/plugins/@nocobase/plugin-departments/src/server/middlewares/set-departments-roles.ts b/packages/plugins/@nocobase/plugin-departments/src/server/middlewares/set-departments-roles.ts
new file mode 100644
index 0000000000..5171d69ae0
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-departments/src/server/middlewares/set-departments-roles.ts
@@ -0,0 +1,60 @@
+/**
+ * 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 { Context, Next } from '@nocobase/actions';
+import { Cache } from '@nocobase/cache';
+import { Model, Repository } from '@nocobase/database';
+
+export const setDepartmentsInfo = async (ctx: Context, next: Next) => {
+ const currentUser = ctx.state.currentUser;
+ if (!currentUser) {
+ return next();
+ }
+
+ const cache = ctx.cache as Cache;
+ const repo = ctx.db.getRepository('users.departments', currentUser.id) as unknown as Repository;
+ const departments = (await cache.wrap(`departments:${currentUser.id}`, () =>
+ repo.find({
+ appends: ['owners', 'roles', 'parent(recursively=true)'],
+ raw: true,
+ }),
+ )) as Model[];
+ if (!departments.length) {
+ return next();
+ }
+ ctx.state.currentUser.departments = departments;
+ ctx.state.currentUser.mainDeparmtent = departments.find((dept) => dept.isMain);
+
+ const departmentIds = departments.map((dept) => dept.id);
+ const roleRepo = ctx.db.getRepository('roles');
+ const roles = await roleRepo.find({
+ filter: {
+ 'departments.id': {
+ $in: departmentIds,
+ },
+ },
+ });
+ if (!roles.length) {
+ return next();
+ }
+ const rolesMap = new Map();
+ roles.forEach((role: any) => rolesMap.set(role.name, role));
+ ctx.state.attachRoles = Array.from(rolesMap.values());
+
+ await next();
+};
diff --git a/packages/plugins/@nocobase/plugin-departments/src/server/middlewares/set-main-department.ts b/packages/plugins/@nocobase/plugin-departments/src/server/middlewares/set-main-department.ts
new file mode 100644
index 0000000000..b5f7083fa3
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-departments/src/server/middlewares/set-main-department.ts
@@ -0,0 +1,101 @@
+/**
+ * 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 { Context, Next } from '@nocobase/actions';
+
+export const setMainDepartment = async (ctx: Context, next: Next) => {
+ await next();
+ const { associatedName, resourceName, associatedIndex, actionName, values } = ctx.action.params;
+ if (associatedName === 'departments' && resourceName === 'members' && values?.length) {
+ const throughRepo = ctx.db.getRepository('departmentsUsers');
+ const usersHasMain = await throughRepo.find({
+ filter: {
+ userId: {
+ $in: values,
+ },
+ isMain: true,
+ },
+ });
+ const userIdsHasMain = usersHasMain.map((item) => item.userId);
+ if (actionName === 'add' || actionName === 'set') {
+ await throughRepo.update({
+ filter: {
+ userId: {
+ $in: values.filter((id) => !userIdsHasMain.includes(id)),
+ },
+ departmentId: associatedIndex,
+ },
+ values: {
+ isMain: true,
+ },
+ });
+ return;
+ }
+
+ if (actionName === 'remove') {
+ const userIdsHasNoMain = values.filter((id) => !userIdsHasMain.includes(id));
+ for (const userId of userIdsHasNoMain) {
+ const firstDept = await throughRepo.findOne({
+ filter: {
+ userId,
+ },
+ });
+ if (firstDept) {
+ await throughRepo.update({
+ filter: {
+ userId,
+ departmentId: firstDept.departmentId,
+ },
+ values: {
+ isMain: true,
+ },
+ });
+ }
+ }
+ }
+ }
+
+ if (associatedName === 'users' && resourceName === 'departments' && ['add', 'remove', 'set'].includes(actionName)) {
+ const throughRepo = ctx.db.getRepository('departmentsUsers');
+ const hasMain = await throughRepo.findOne({
+ filter: {
+ userId: associatedIndex,
+ isMain: true,
+ },
+ });
+ if (hasMain) {
+ return;
+ }
+ const firstDept = await throughRepo.findOne({
+ filter: {
+ userId: associatedIndex,
+ },
+ });
+ if (firstDept) {
+ await throughRepo.update({
+ filter: {
+ userId: associatedIndex,
+ departmentId: firstDept.departmentId,
+ },
+ values: {
+ isMain: true,
+ },
+ });
+ }
+ }
+};
diff --git a/packages/plugins/@nocobase/plugin-departments/src/server/middlewares/update-department-isleaf.ts b/packages/plugins/@nocobase/plugin-departments/src/server/middlewares/update-department-isleaf.ts
new file mode 100644
index 0000000000..9d0cbdf947
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-departments/src/server/middlewares/update-department-isleaf.ts
@@ -0,0 +1,88 @@
+/**
+ * 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 { Context, Next } from '@nocobase/actions';
+import { Repository } from '@nocobase/database';
+
+const updateIsLeafWhenAddChild = async (repo: Repository, parent: any) => {
+ if (parent && parent.isLeaf !== false) {
+ await repo.update({
+ filter: {
+ id: parent.id,
+ },
+ values: {
+ isLeaf: false,
+ },
+ });
+ }
+};
+
+const updateIsLeafWhenChangeChild = async (
+ repo: Repository,
+ oldParentId: number | null,
+ newParentId: number | null,
+) => {
+ if (oldParentId && oldParentId !== newParentId) {
+ const hasChild = await repo.count({
+ filter: {
+ parentId: oldParentId,
+ },
+ });
+ if (!hasChild) {
+ await repo.update({
+ filter: {
+ id: oldParentId,
+ },
+ values: {
+ isLeaf: true,
+ },
+ });
+ }
+ }
+};
+
+export const updateDepartmentIsLeaf = async (ctx: Context, next: Next) => {
+ const { filterByTk, values = {}, resourceName, actionName } = ctx.action.params;
+ const repo = ctx.db.getRepository('departments');
+ const { parent } = values;
+ if (resourceName === 'departments' && actionName === 'create') {
+ ctx.action.params.values = { ...values, isLeaf: true };
+ await next();
+ await updateIsLeafWhenAddChild(repo, parent);
+ return;
+ }
+
+ if (resourceName === 'departments' && actionName === 'update') {
+ const department = await repo.findOne({ filterByTk });
+ await next();
+ await Promise.all([
+ updateIsLeafWhenChangeChild(repo, department.parentId, parent?.id),
+ updateIsLeafWhenAddChild(repo, parent),
+ ]);
+ return;
+ }
+
+ if (resourceName === 'departments' && actionName === 'destroy') {
+ const department = await repo.findOne({ filterByTk });
+ await next();
+ await updateIsLeafWhenChangeChild(repo, department.parentId, null);
+ return;
+ }
+
+ return next();
+};
diff --git a/packages/plugins/@nocobase/plugin-departments/src/server/migrations/update-field-uischemas-20240307124823.ts b/packages/plugins/@nocobase/plugin-departments/src/server/migrations/update-field-uischemas-20240307124823.ts
new file mode 100644
index 0000000000..542d5865bf
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-departments/src/server/migrations/update-field-uischemas-20240307124823.ts
@@ -0,0 +1,96 @@
+/**
+ * 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 { Migration } from '@nocobase/server';
+import { departmentsField, mainDepartmentField } from '../collections/users';
+import { ownersField } from '../collections/departments';
+
+export default class UpdateFieldUISchemasMigration extends Migration {
+ async up() {
+ const result = await this.app.version.satisfies('<=0.20.0-alpha.6');
+
+ if (!result) {
+ return;
+ }
+
+ const fieldRepo = this.db.getRepository('fields');
+ const departmentsFieldInstance = await fieldRepo.findOne({
+ filter: {
+ name: 'departments',
+ collectionName: 'users',
+ },
+ });
+ if (departmentsFieldInstance) {
+ const options = {
+ ...departmentsFieldInstance.options,
+ uiSchema: departmentsField.uiSchema,
+ };
+ await fieldRepo.update({
+ filter: {
+ name: 'departments',
+ collectionName: 'users',
+ },
+ values: {
+ options,
+ },
+ });
+ }
+ const mainDepartmentFieldInstance = await fieldRepo.findOne({
+ filter: {
+ name: 'mainDepartment',
+ collectionName: 'users',
+ },
+ });
+ if (mainDepartmentFieldInstance) {
+ const options = {
+ ...mainDepartmentFieldInstance.options,
+ uiSchema: mainDepartmentField.uiSchema,
+ };
+ await fieldRepo.update({
+ filter: {
+ name: 'mainDepartment',
+ collectionName: 'users',
+ },
+ values: {
+ options,
+ },
+ });
+ }
+ const ownersFieldInstance = await fieldRepo.findOne({
+ filter: {
+ name: 'owners',
+ collectionName: 'departments',
+ },
+ });
+ if (ownersFieldInstance) {
+ const options = {
+ ...ownersFieldInstance.options,
+ uiSchema: ownersField.uiSchema,
+ };
+ await fieldRepo.update({
+ filter: {
+ name: 'owners',
+ collectionName: 'departments',
+ },
+ values: {
+ options,
+ },
+ });
+ }
+ }
+}
diff --git a/packages/plugins/@nocobase/plugin-departments/src/server/models/department.ts b/packages/plugins/@nocobase/plugin-departments/src/server/models/department.ts
new file mode 100644
index 0000000000..fddd8fc0ce
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-departments/src/server/models/department.ts
@@ -0,0 +1,31 @@
+/**
+ * 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 { Model } from '@nocobase/database';
+
+export class DepartmentModel extends Model {
+ getOwners() {
+ return this.getMembers({
+ through: {
+ where: {
+ isOwner: true,
+ },
+ },
+ });
+ }
+}
diff --git a/packages/plugins/@nocobase/plugin-departments/src/server/plugin.ts b/packages/plugins/@nocobase/plugin-departments/src/server/plugin.ts
new file mode 100644
index 0000000000..6aeaf51fe3
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-departments/src/server/plugin.ts
@@ -0,0 +1,156 @@
+/**
+ * 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 { Cache } from '@nocobase/cache';
+import { InstallOptions, Plugin } from '@nocobase/server';
+import { aggregateSearch, removeOwner, setOwner } from './actions/departments';
+import { listExcludeDept, setMainDepartment } from './actions/users';
+import { departmentsField, mainDepartmentField } from './collections/users';
+import {
+ destroyDepartmentCheck,
+ resetUserDepartmentsCache,
+ setDepartmentOwners,
+ setMainDepartment as setMainDepartmentMiddleware,
+ updateDepartmentIsLeaf,
+} from './middlewares';
+import { setDepartmentsInfo } from './middlewares/set-departments-roles';
+import { DepartmentModel } from './models/department';
+import { DepartmentDataSyncResource } from './department-data-sync-resource';
+import PluginUserDataSyncServer from '@nocobase/plugin-user-data-sync';
+import { DataSource } from '@nocobase/data-source-manager';
+
+export class PluginDepartmentsServer extends Plugin {
+ afterAdd() {}
+
+ beforeLoad() {
+ this.app.db.registerModels({ DepartmentModel });
+
+ this.app.acl.addFixedParams('collections', 'destroy', () => {
+ return {
+ filter: {
+ 'name.$notIn': ['departments', 'departmentsUsers', 'departmentsRoles'],
+ },
+ };
+ });
+ }
+
+ async load() {
+ this.app.resourceManager.registerActionHandlers({
+ 'users:listExcludeDept': listExcludeDept,
+ 'users:setMainDepartment': setMainDepartment,
+ 'departments:aggregateSearch': aggregateSearch,
+ 'departments:setOwner': setOwner,
+ 'departments:removeOwner': removeOwner,
+ });
+
+ this.app.acl.allow('users', ['setMainDepartment', 'listExcludeDept'], 'loggedIn');
+ this.app.acl.registerSnippet({
+ name: `pm.${this.name}`,
+ actions: [
+ 'departments:*',
+ 'roles:list',
+ 'users:list',
+ 'users:listExcludeDept',
+ 'users:setMainDepartment',
+ 'users.departments:*',
+ 'roles.departments:*',
+ 'departments.members:*',
+ ],
+ });
+
+ this.app.resourceManager.use(setDepartmentsInfo, {
+ tag: 'setDepartmentsInfo',
+ before: 'setCurrentRole',
+ after: 'auth',
+ });
+ this.app.dataSourceManager.afterAddDataSource((dataSource: DataSource) => {
+ dataSource.resourceManager.use(setDepartmentsInfo, {
+ tag: 'setDepartmentsInfo',
+ before: 'setCurrentRole',
+ after: 'auth',
+ });
+ });
+
+ this.app.resourceManager.use(setDepartmentOwners);
+ this.app.resourceManager.use(destroyDepartmentCheck);
+ this.app.resourceManager.use(updateDepartmentIsLeaf);
+ this.app.resourceManager.use(resetUserDepartmentsCache);
+ this.app.resourceManager.use(setMainDepartmentMiddleware);
+
+ // Delete cache when the departments of a user changed
+ this.app.db.on('departmentsUsers.afterSave', async (model) => {
+ const cache = this.app.cache as Cache;
+ await cache.del(`departments:${model.get('userId')}`);
+ });
+ this.app.db.on('departmentsUsers.afterDestroy', async (model) => {
+ const cache = this.app.cache as Cache;
+ await cache.del(`departments:${model.get('userId')}`);
+ });
+ this.app.on('beforeSignOut', ({ userId }) => {
+ this.app.cache.del(`departments:${userId}`);
+ });
+
+ const userDataSyncPlugin = this.app.pm.get('user-data-sync') as PluginUserDataSyncServer;
+ if (userDataSyncPlugin && userDataSyncPlugin.enabled) {
+ userDataSyncPlugin.resourceManager.registerResource(new DepartmentDataSyncResource(this.db, this.app.logger), {
+ // write department records after writing user records
+ after: 'users',
+ });
+ }
+ }
+
+ async install(options?: InstallOptions) {
+ const collectionRepo = this.db.getRepository('collections');
+ if (collectionRepo) {
+ await collectionRepo.db2cm('departments');
+ }
+ const fieldRepo = this.db.getRepository('fields');
+ if (fieldRepo) {
+ const isDepartmentsFieldExists = await fieldRepo.count({
+ filter: {
+ name: 'departments',
+ collectionName: 'users',
+ },
+ });
+ if (!isDepartmentsFieldExists) {
+ await fieldRepo.create({
+ values: departmentsField,
+ });
+ }
+ const isMainDepartmentFieldExists = await fieldRepo.count({
+ filter: {
+ name: 'mainDepartment',
+ collectionName: 'users',
+ },
+ });
+ if (!isMainDepartmentFieldExists) {
+ await fieldRepo.create({
+ values: mainDepartmentField,
+ });
+ }
+ }
+ }
+
+ async afterEnable() {}
+
+ async afterDisable() {}
+
+ async remove() {}
+}
+
+export default PluginDepartmentsServer;
diff --git a/packages/plugins/@nocobase/plugin-disable-pm-add/package.json b/packages/plugins/@nocobase/plugin-disable-pm-add/package.json
index 55b397b83d..d02e0ee6ff 100644
--- a/packages/plugins/@nocobase/plugin-disable-pm-add/package.json
+++ b/packages/plugins/@nocobase/plugin-disable-pm-add/package.json
@@ -1,6 +1,6 @@
{
"name": "@nocobase/plugin-disable-pm-add",
- "version": "1.6.18",
+ "version": "1.6.24",
"main": "./dist/server/index.js",
"peerDependencies": {
"@nocobase/client": "1.x",
diff --git a/packages/plugins/@nocobase/plugin-environment-variables/package.json b/packages/plugins/@nocobase/plugin-environment-variables/package.json
index 8f26ee577e..d7e6712c19 100644
--- a/packages/plugins/@nocobase/plugin-environment-variables/package.json
+++ b/packages/plugins/@nocobase/plugin-environment-variables/package.json
@@ -1,6 +1,6 @@
{
"name": "@nocobase/plugin-environment-variables",
- "version": "1.6.18",
+ "version": "1.6.24",
"main": "dist/server/index.js",
"peerDependencies": {
"@nocobase/client": "1.x",
diff --git a/packages/plugins/@nocobase/plugin-error-handler/package.json b/packages/plugins/@nocobase/plugin-error-handler/package.json
index b3c4e7a91f..5c1646afa0 100644
--- a/packages/plugins/@nocobase/plugin-error-handler/package.json
+++ b/packages/plugins/@nocobase/plugin-error-handler/package.json
@@ -4,7 +4,7 @@
"displayName.zh-CN": "错误处理器",
"description": "Handling application errors and exceptions.",
"description.zh-CN": "处理应用程序中的错误和异常。",
- "version": "1.6.18",
+ "version": "1.6.24",
"license": "AGPL-3.0",
"main": "./dist/server/index.js",
"devDependencies": {
diff --git a/packages/plugins/@nocobase/plugin-field-attachment-url/.npmignore b/packages/plugins/@nocobase/plugin-field-attachment-url/.npmignore
new file mode 100644
index 0000000000..65f5e8779f
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-field-attachment-url/.npmignore
@@ -0,0 +1,2 @@
+/node_modules
+/src
diff --git a/packages/plugins/@nocobase/plugin-field-attachment-url/README.md b/packages/plugins/@nocobase/plugin-field-attachment-url/README.md
new file mode 100644
index 0000000000..806a995fd7
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-field-attachment-url/README.md
@@ -0,0 +1 @@
+# @nocobase/plugin-field-attachment-url
diff --git a/packages/plugins/@nocobase/plugin-field-attachment-url/client.d.ts b/packages/plugins/@nocobase/plugin-field-attachment-url/client.d.ts
new file mode 100644
index 0000000000..6c459cbac4
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-field-attachment-url/client.d.ts
@@ -0,0 +1,2 @@
+export * from './dist/client';
+export { default } from './dist/client';
diff --git a/packages/plugins/@nocobase/plugin-field-attachment-url/client.js b/packages/plugins/@nocobase/plugin-field-attachment-url/client.js
new file mode 100644
index 0000000000..b6e3be70e6
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-field-attachment-url/client.js
@@ -0,0 +1 @@
+module.exports = require('./dist/client/index.js');
diff --git a/packages/plugins/@nocobase/plugin-field-attachment-url/package.json b/packages/plugins/@nocobase/plugin-field-attachment-url/package.json
new file mode 100644
index 0000000000..fb3180c391
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-field-attachment-url/package.json
@@ -0,0 +1,20 @@
+{
+ "name": "@nocobase/plugin-field-attachment-url",
+ "version": "1.6.24",
+ "main": "dist/server/index.js",
+ "displayName": "Collection field: Attachment(URL)",
+ "displayName.zh-CN": "数据表字段:附件(URL)",
+ "description": "Supports attachments in URL format.",
+ "description.zh-CN": "支持 URL 格式的附件。",
+ "peerDependencies": {
+ "@nocobase/client": "1.x",
+ "@nocobase/server": "1.x",
+ "@nocobase/test": "1.x"
+ },
+ "devDependencies": {
+ "@nocobase/plugin-file-manager": "1.6.24"
+ },
+ "keywords": [
+ "Collection fields"
+ ]
+}
diff --git a/packages/plugins/@nocobase/plugin-field-attachment-url/server.d.ts b/packages/plugins/@nocobase/plugin-field-attachment-url/server.d.ts
new file mode 100644
index 0000000000..c41081ddc6
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-field-attachment-url/server.d.ts
@@ -0,0 +1,2 @@
+export * from './dist/server';
+export { default } from './dist/server';
diff --git a/packages/plugins/@nocobase/plugin-field-attachment-url/server.js b/packages/plugins/@nocobase/plugin-field-attachment-url/server.js
new file mode 100644
index 0000000000..972842039a
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-field-attachment-url/server.js
@@ -0,0 +1 @@
+module.exports = require('./dist/server/index.js');
diff --git a/packages/plugins/@nocobase/plugin-field-attachment-url/src/client/__e2e__/createField.test.ts b/packages/plugins/@nocobase/plugin-field-attachment-url/src/client/__e2e__/createField.test.ts
new file mode 100644
index 0000000000..89e1432ff4
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-field-attachment-url/src/client/__e2e__/createField.test.ts
@@ -0,0 +1,28 @@
+/**
+ * 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 { expect, test } from '@nocobase/test/e2e';
+
+test('create Attachment (URL) field', async ({ page }) => {
+ await page.goto('/admin/settings/data-source-manager/main/collections?type=main');
+ await page.getByLabel('action-Action.Link-Configure fields-collections-users', { exact: true }).click();
+ await page.getByRole('button', { name: 'plus Add field' }).click();
+ await page.getByRole('menuitem', { name: 'Attachment (URL)' }).click();
+ const displayName = `a${Math.random().toString(36).substring(7)}`;
+ const name = `a${Math.random().toString(36).substring(7)}`;
+ await page.getByLabel('block-item-Input-fields-Field display name').getByRole('textbox').fill(displayName);
+ await page.getByLabel('block-item-Input-fields-Field name').getByRole('textbox').fill(name);
+ await expect(page.getByLabel('block-item-RemoteSelect-')).toBeVisible();
+ await page.getByLabel('action-Action-Submit-fields-').click();
+ await expect(page.getByText(name)).toBeVisible();
+
+ // 删除
+ await page.getByLabel(`action-CollectionFields-Delete-fields-${name}`).click();
+ await page.getByRole('button', { name: 'OK', exact: true }).click();
+});
diff --git a/packages/plugins/@nocobase/plugin-field-attachment-url/src/client/client.d.ts b/packages/plugins/@nocobase/plugin-field-attachment-url/src/client/client.d.ts
new file mode 100644
index 0000000000..4e96f83fa1
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-field-attachment-url/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-field-attachment-url/src/client/component/AttachmentUrl.tsx b/packages/plugins/@nocobase/plugin-field-attachment-url/src/client/component/AttachmentUrl.tsx
new file mode 100644
index 0000000000..0cfa332988
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-field-attachment-url/src/client/component/AttachmentUrl.tsx
@@ -0,0 +1,184 @@
+/**
+ * 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 { RecursionField, connect, mapReadPretty, useField, useFieldSchema } from '@formily/react';
+import React, { useContext, useEffect, useState } from 'react';
+import {
+ FormProvider,
+ RecordPickerContext,
+ RecordPickerProvider,
+ SchemaComponentOptions,
+ useActionContext,
+ TableSelectorParamsProvider,
+ useTableSelectorProps as useTsp,
+ EllipsisWithTooltip,
+ CollectionProvider_deprecated,
+ useCollection_deprecated,
+ useCollectionManager_deprecated,
+ Upload,
+ useFieldNames,
+ ActionContextProvider,
+ AssociationField,
+ Input,
+} from '@nocobase/client';
+import schema from '../schema';
+import { useInsertSchema } from '../hook';
+
+const defaultToValueItem = (data) => {
+ return data?.thumbnailRule ? `${data?.url}${data?.thumbnailRule}` : data?.url;
+};
+
+const InnerAttachmentUrl = (props) => {
+ const { value, onChange, toValueItem = defaultToValueItem, disabled, underFilter, ...others } = props;
+ const fieldSchema = useFieldSchema();
+ const [visibleSelector, setVisibleSelector] = useState(false);
+ const [selectedRows, setSelectedRows] = useState([]);
+ const insertSelector = useInsertSchema('Selector');
+ const fieldNames = useFieldNames(props);
+ const field: any = useField();
+ const [options, setOptions] = useState();
+ const { getField } = useCollection_deprecated();
+ const collectionField = getField(field.props.name);
+ const { modalProps } = useActionContext();
+ const handleSelect = (ev) => {
+ ev.stopPropagation();
+ ev.preventDefault();
+ insertSelector(schema.Selector);
+ setVisibleSelector(true);
+ setSelectedRows([]);
+ };
+
+ useEffect(() => {
+ if (value && Object.keys(value).length > 0) {
+ setOptions(value);
+ } else {
+ setOptions(null);
+ }
+ }, [value, fieldNames?.label]);
+
+ const pickerProps = {
+ size: 'small',
+ fieldNames,
+ multiple: false,
+ association: {
+ target: collectionField?.target,
+ },
+ options,
+ onChange: props?.onChange,
+ selectedRows,
+ setSelectedRows,
+ collectionField,
+ };
+ const usePickActionProps = () => {
+ const { setVisible } = useActionContext();
+ const { selectedRows, onChange } = useContext(RecordPickerContext);
+ return {
+ onClick() {
+ onChange(toValueItem(selectedRows?.[0]) || null);
+ setVisible(false);
+ },
+ };
+ };
+ const useTableSelectorProps = () => {
+ const {
+ multiple,
+ options,
+ setSelectedRows,
+ selectedRows: rcSelectRows = [],
+ onChange,
+ } = useContext(RecordPickerContext);
+ const { onRowSelectionChange, rowKey = 'id', ...others } = useTsp();
+ const { setVisible } = useActionContext();
+ return {
+ ...others,
+ rowKey,
+ rowSelection: {
+ type: multiple ? 'checkbox' : 'radio',
+ selectedRowKeys: rcSelectRows?.filter((item) => options?.[rowKey] !== item[rowKey]).map((item) => item[rowKey]),
+ },
+ onRowSelectionChange(selectedRowKeys, selectedRows) {
+ setSelectedRows?.(selectedRows);
+ onRowSelectionChange?.(selectedRowKeys, selectedRows);
+ onChange(toValueItem(selectedRows?.[0]) || null);
+ setVisible(false);
+ },
+ };
+ };
+ if (underFilter) {
+ return ;
+ }
+ console.log(collectionField);
+ return (
+
+
+
+ {collectionField?.target && collectionField?.target !== 'attachments' && (
+
+
+
+
+
+ {
+ return s['x-component'] === 'AssociationField.Selector';
+ }}
+ />
+
+
+
+
+
+ )}
+
+
+ );
+};
+
+const FileManageReadPretty = connect((props) => {
+ const { value } = props;
+ const fieldSchema = useFieldSchema();
+ const componentMode = fieldSchema?.['x-component-props']?.['componentMode'];
+ const { getField } = useCollection_deprecated();
+ const { getCollectionJoinField } = useCollectionManager_deprecated();
+ const collectionField = getField(fieldSchema.name) || getCollectionJoinField(fieldSchema['x-collection-field']);
+ if (componentMode === 'url') {
+ return {value};
+ }
+ return (
+ {collectionField ? : null}
+ );
+});
+
+export const AttachmentUrl = connect(InnerAttachmentUrl, mapReadPretty(FileManageReadPretty));
diff --git a/packages/plugins/@nocobase/plugin-field-attachment-url/src/client/hook/index.ts b/packages/plugins/@nocobase/plugin-field-attachment-url/src/client/hook/index.ts
new file mode 100644
index 0000000000..412cd9c469
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-field-attachment-url/src/client/hook/index.ts
@@ -0,0 +1,106 @@
+/**
+ * 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 { useFieldSchema } from '@formily/react';
+import { useCollectionField, useDesignable, useRequest } from '@nocobase/client';
+import { cloneDeep, uniqBy } from 'lodash';
+import { useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
+
+function useStorageRules(storage) {
+ const name = storage ?? '';
+ const { loading, data } = useRequest(
+ {
+ url: `storages:getBasicInfo/${name}`,
+ },
+ {
+ refreshDeps: [name],
+ },
+ );
+ return (!loading && data?.data) || null;
+}
+export function useAttachmentUrlFieldProps(props) {
+ const field = useCollectionField();
+ const rules = useStorageRules(field?.storage);
+ return {
+ ...props,
+ rules,
+ action: `${field.target}:create${field.storage ? `?attachmentField=${field.collectionName}.${field.name}` : ''}`,
+ toValueItem: (data) => {
+ return data?.thumbnailRule ? `${data?.url}${data?.thumbnailRule}` : data?.url;
+ },
+ getThumbnailURL: (file) => {
+ return file?.url;
+ },
+ };
+}
+
+export const useInsertSchema = (component) => {
+ const fieldSchema = useFieldSchema();
+ const { insertAfterBegin } = useDesignable();
+ const insert = useCallback(
+ (ss) => {
+ const schema = fieldSchema.reduceProperties((buf, s) => {
+ if (s['x-component'] === 'AssociationField.' + component) {
+ return s;
+ }
+ return buf;
+ }, null);
+ if (!schema) {
+ insertAfterBegin(cloneDeep(ss));
+ }
+ },
+ [component, fieldSchema, insertAfterBegin],
+ );
+ return insert;
+};
+
+export const useAttachmentTargetProps = () => {
+ const { t } = useTranslation();
+ // TODO(refactor): whitelist should be changed to storage property,url is signed by plugin-s3-pro, this enmus is from plugin-file-manager
+ const buildInStorage = ['local', 'ali-oss', 's3', 'tx-cos'];
+ return {
+ service: {
+ resource: 'collections',
+ params: {
+ filter: {
+ 'options.template': 'file',
+ },
+ paginate: false,
+ },
+ },
+ manual: false,
+ fieldNames: {
+ label: 'title',
+ value: 'name',
+ },
+ mapOptions: (value) => {
+ if (value.name === 'attachments') {
+ return {
+ ...value,
+ title: t('Attachments'),
+ };
+ }
+ return value;
+ },
+ toOptionsItem: (data) => {
+ data.unshift({
+ name: 'attachments',
+ title: t('Attachments'),
+ });
+ return uniqBy(
+ data.filter((v) => v.name),
+ 'name',
+ );
+ },
+ optionFilter: (option) => {
+ return !option.storage || buildInStorage.includes(option.storage);
+ },
+ };
+};
diff --git a/packages/plugins/@nocobase/plugin-field-attachment-url/src/client/index.tsx b/packages/plugins/@nocobase/plugin-field-attachment-url/src/client/index.tsx
new file mode 100644
index 0000000000..c535c97f67
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-field-attachment-url/src/client/index.tsx
@@ -0,0 +1,38 @@
+/**
+ * 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, lazy } from '@nocobase/client';
+import { AttachmentURLFieldInterface } from './interfaces/attachment-url';
+import { useAttachmentUrlFieldProps } from './hook';
+// import { AttachmentUrl } from './component/AttachmentUrl';
+const { AttachmentUrl } = lazy(() => import('./component/AttachmentUrl'), 'AttachmentUrl');
+
+import { attachmentUrlComponentFieldSettings } from './settings';
+export class PluginFieldAttachmentUrlClient extends Plugin {
+ async afterAdd() {
+ // await this.app.pm.add()
+ }
+
+ async beforeLoad() {}
+
+ // You can get and modify the app instance here
+ async load() {
+ this.app.dataSourceManager.addFieldInterfaces([AttachmentURLFieldInterface]);
+ this.app.addScopes({ useAttachmentUrlFieldProps });
+
+ this.app.addComponents({ AttachmentUrl });
+ this.app.schemaSettingsManager.add(attachmentUrlComponentFieldSettings);
+
+ // this.app.addProvider()
+ // this.app.addProviders()
+ // this.app.router.add()
+ }
+}
+
+export default PluginFieldAttachmentUrlClient;
diff --git a/packages/plugins/@nocobase/plugin-field-attachment-url/src/client/interfaces/attachment-url.tsx b/packages/plugins/@nocobase/plugin-field-attachment-url/src/client/interfaces/attachment-url.tsx
new file mode 100644
index 0000000000..781da59f6b
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-field-attachment-url/src/client/interfaces/attachment-url.tsx
@@ -0,0 +1,79 @@
+/**
+ * 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 { CollectionFieldInterface, interfacesProperties } from '@nocobase/client';
+import { ISchema } from '@formily/react';
+import { useAttachmentTargetProps } from '../hook';
+import { tStr } from '../locale';
+
+const { defaultProps, operators } = interfacesProperties;
+
+export const defaultToolbar = [
+ 'headings',
+ 'bold',
+ 'italic',
+ 'strike',
+ 'link',
+ 'list',
+ 'ordered-list',
+ 'check',
+ 'quote',
+ 'line',
+ 'code',
+ 'inline-code',
+ 'upload',
+ 'fullscreen',
+];
+
+export class AttachmentURLFieldInterface extends CollectionFieldInterface {
+ name = 'attachmentURL';
+ type = 'object';
+ group = 'media';
+ title = tStr('Attachment (URL)');
+ default = {
+ type: 'string',
+ // name,
+ uiSchema: {
+ type: 'string',
+ // title,
+ 'x-component': 'AttachmentUrl',
+ 'x-use-component-props': 'useAttachmentUrlFieldProps',
+ },
+ };
+ availableTypes = ['string', 'text'];
+ properties = {
+ ...defaultProps,
+ target: {
+ required: true,
+ default: 'attachments',
+ type: 'string',
+ title: tStr('Which file collection should it be uploaded to'),
+ 'x-decorator': 'FormItem',
+ 'x-component': 'RemoteSelect',
+ 'x-use-component-props': useAttachmentTargetProps,
+ },
+ targetKey: {
+ 'x-hidden': true,
+ default: 'id',
+ type: 'string',
+ },
+ };
+ schemaInitialize(schema: ISchema, { block }) {
+ schema['x-component-props'] = schema['x-component-props'] || {};
+ schema['x-component-props']['mode'] = 'AttachmentUrl';
+ if (['Table', 'Kanban'].includes(block)) {
+ schema['x-component-props']['ellipsis'] = true;
+ schema['x-component-props']['size'] = 'small';
+ }
+ }
+ filterable = {
+ operators: operators.bigField,
+ };
+ titleUsable = true;
+}
diff --git a/packages/plugins/@nocobase/plugin-field-attachment-url/src/client/locale.ts b/packages/plugins/@nocobase/plugin-field-attachment-url/src/client/locale.ts
new file mode 100644
index 0000000000..a26dd0158f
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-field-attachment-url/src/client/locale.ts
@@ -0,0 +1,30 @@
+/**
+ * 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.
+ */
+
+// @ts-ignore
+import pkg from '../../package.json';
+import { useApp } from '@nocobase/client';
+import { useTranslation } from 'react-i18next';
+
+export const NAMESPACE = 'attachmentUrl';
+
+export function useT() {
+ const app = useApp();
+ return (str: string) => app.i18n.t(str, { ns: [pkg.name, 'client'] });
+}
+
+export function tStr(key: string) {
+ return `{{t(${JSON.stringify(key)}, { ns: ['${pkg.name}', 'client'], nsMode: 'fallback' })}}`;
+}
+
+export function useAttachmentUrlTranslation() {
+ return useTranslation([NAMESPACE, 'client'], {
+ nsMode: 'fallback',
+ });
+}
diff --git a/packages/plugins/@nocobase/plugin-field-attachment-url/src/client/schema.ts b/packages/plugins/@nocobase/plugin-field-attachment-url/src/client/schema.ts
new file mode 100644
index 0000000000..f5ae80d0ce
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-field-attachment-url/src/client/schema.ts
@@ -0,0 +1,53 @@
+/**
+ * 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 {
+ Selector: {
+ type: 'void',
+ 'x-component': 'AssociationField.Selector',
+ title: '{{ t("Select record") }}',
+ 'x-component-props': {
+ className: 'nb-record-picker-selector',
+ },
+ properties: {
+ grid: {
+ type: 'void',
+ 'x-component': 'Grid',
+ 'x-initializer': 'popup:tableSelector:addBlock',
+ properties: {},
+ },
+ footer: {
+ 'x-component': 'Action.Container.Footer',
+ 'x-component-props': {},
+ properties: {
+ actions: {
+ type: 'void',
+ 'x-component': 'ActionBar',
+ 'x-component-props': {},
+ properties: {
+ submit: {
+ title: '{{ t("Submit") }}',
+ 'x-action': 'submit',
+ 'x-component': 'Action',
+ 'x-use-component-props': 'usePickActionProps',
+ // 'x-designer': 'Action.Designer',
+ 'x-toolbar': 'ActionSchemaToolbar',
+ 'x-settings': 'actionSettings:submit',
+ 'x-component-props': {
+ type: 'primary',
+ htmlType: 'submit',
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+};
diff --git a/packages/plugins/@nocobase/plugin-field-attachment-url/src/client/settings/index.ts b/packages/plugins/@nocobase/plugin-field-attachment-url/src/client/settings/index.ts
new file mode 100644
index 0000000000..52463073c2
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-field-attachment-url/src/client/settings/index.ts
@@ -0,0 +1,171 @@
+/**
+ * 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 { Field } from '@formily/core';
+import { useField, useFieldSchema, useForm } from '@formily/react';
+import { useTranslation } from 'react-i18next';
+import { useColumnSchema, useIsFieldReadPretty, SchemaSettings, useDesignable } from '@nocobase/client';
+
+const fieldComponent: any = {
+ name: 'fieldComponent',
+ type: 'select',
+ useComponentProps() {
+ const { t } = useTranslation();
+ const field = useField();
+ const { fieldSchema: tableColumnSchema } = useColumnSchema();
+ const schema = useFieldSchema();
+ const fieldSchema = tableColumnSchema || schema;
+ const { dn } = useDesignable();
+
+ return {
+ title: t('Field component'),
+ options: [
+ { label: t('URL'), value: 'url' },
+ { label: t('Preview'), value: 'preview' },
+ ],
+ value: fieldSchema['x-component-props']['componentMode'] || 'preview',
+ onChange(componentMode) {
+ const schema = {
+ ['x-uid']: fieldSchema['x-uid'],
+ };
+ fieldSchema['x-component-props'] = fieldSchema['x-component-props'] || {};
+ fieldSchema['x-component-props']['componentMode'] = componentMode;
+ schema['x-component-props'] = fieldSchema['x-component-props'];
+ field.componentProps = field.componentProps || {};
+ field.componentProps.componentMode = componentMode;
+ void dn.emit('patch', {
+ schema,
+ });
+ dn.refresh();
+ },
+ };
+ },
+ useVisible() {
+ const readPretty = useIsFieldReadPretty();
+ const { fieldSchema: tableColumnSchema } = useColumnSchema();
+ return readPretty;
+ },
+};
+
+export const attachmentUrlComponentFieldSettings = new SchemaSettings({
+ name: 'fieldSettings:component:AttachmentUrl',
+ items: [
+ {
+ name: 'quickUpload',
+ type: 'switch',
+ useComponentProps() {
+ const { t } = useTranslation();
+ const field = useField();
+ const { fieldSchema: tableColumnSchema } = useColumnSchema();
+ const schema = useFieldSchema();
+ const fieldSchema = tableColumnSchema || schema;
+ const { dn, refresh } = useDesignable();
+ return {
+ title: t('Quick upload'),
+ checked: fieldSchema['x-component-props']?.quickUpload !== (false as boolean),
+ onChange(value) {
+ const schema = {
+ ['x-uid']: fieldSchema['x-uid'],
+ };
+ field.componentProps.quickUpload = value;
+ fieldSchema['x-component-props'] = fieldSchema['x-component-props'] || {};
+ fieldSchema['x-component-props'].quickUpload = value;
+ schema['x-component-props'] = fieldSchema['x-component-props'];
+ dn.emit('patch', {
+ schema,
+ });
+ refresh();
+ },
+ };
+ },
+ useVisible() {
+ const { fieldSchema: tableColumnSchema } = useColumnSchema();
+ const field = useField();
+ const form = useForm();
+ const isReadPretty = tableColumnSchema?.['x-read-pretty'] || field.readPretty || form.readPretty;
+ return !isReadPretty && !field.componentProps.underFilter;
+ },
+ },
+ {
+ name: 'selectFile',
+ type: 'switch',
+ useComponentProps() {
+ const { t } = useTranslation();
+ const field = useField();
+ const { fieldSchema: tableColumnSchema } = useColumnSchema();
+ const schema = useFieldSchema();
+ const fieldSchema = tableColumnSchema || schema;
+ const { dn, refresh } = useDesignable();
+ return {
+ title: t('Select file'),
+ checked: fieldSchema['x-component-props']?.selectFile !== (false as boolean),
+ onChange(value) {
+ const schema = {
+ ['x-uid']: fieldSchema['x-uid'],
+ };
+ field.componentProps.selectFile = value;
+ fieldSchema['x-component-props'] = fieldSchema['x-component-props'] || {};
+ fieldSchema['x-component-props'].selectFile = value;
+ schema['x-component-props'] = fieldSchema['x-component-props'];
+ dn.emit('patch', {
+ schema,
+ });
+ refresh();
+ },
+ };
+ },
+ useVisible() {
+ const { fieldSchema: tableColumnSchema } = useColumnSchema();
+ const field = useField();
+ const form = useForm();
+ const isReadPretty = tableColumnSchema?.['x-read-pretty'] || field.readPretty || form.readPretty;
+ return !isReadPretty && !field.componentProps.underFilter;
+ },
+ },
+ fieldComponent,
+ {
+ name: 'size',
+ type: 'select',
+ useVisible() {
+ const readPretty = useIsFieldReadPretty();
+ const { fieldSchema: tableColumnSchema } = useColumnSchema();
+ return readPretty && !tableColumnSchema;
+ },
+ useComponentProps() {
+ const { t } = useTranslation();
+ const field = useField();
+ const fieldSchema = useFieldSchema();
+ const { dn } = useDesignable();
+ return {
+ title: t('Size'),
+ options: [
+ { label: t('Large'), value: 'large' },
+ { label: t('Default'), value: 'default' },
+ { label: t('Small'), value: 'small' },
+ ],
+ value: field?.componentProps?.size || 'default',
+ onChange(size) {
+ const schema = {
+ ['x-uid']: fieldSchema['x-uid'],
+ };
+ fieldSchema['x-component-props'] = fieldSchema['x-component-props'] || {};
+ fieldSchema['x-component-props']['size'] = size;
+ schema['x-component-props'] = fieldSchema['x-component-props'];
+ field.componentProps = field.componentProps || {};
+ field.componentProps.size = size;
+ dn.emit('patch', {
+ schema,
+ });
+ dn.refresh();
+ },
+ };
+ },
+ },
+ ],
+});
diff --git a/packages/plugins/@nocobase/plugin-field-attachment-url/src/index.ts b/packages/plugins/@nocobase/plugin-field-attachment-url/src/index.ts
new file mode 100644
index 0000000000..be99a2ff1a
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-field-attachment-url/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-field-attachment-url/src/locale/en-US.json b/packages/plugins/@nocobase/plugin-field-attachment-url/src/locale/en-US.json
new file mode 100644
index 0000000000..0967ef424b
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-field-attachment-url/src/locale/en-US.json
@@ -0,0 +1 @@
+{}
diff --git a/packages/plugins/@nocobase/plugin-field-attachment-url/src/locale/zh-CN.json b/packages/plugins/@nocobase/plugin-field-attachment-url/src/locale/zh-CN.json
new file mode 100644
index 0000000000..78def6ea14
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-field-attachment-url/src/locale/zh-CN.json
@@ -0,0 +1,4 @@
+{
+ "Which file collection should it be uploaded to":"上传到文件表",
+ "Attachment (URL)":"附件 (URL)"
+}
diff --git a/packages/plugins/@nocobase/plugin-field-attachment-url/src/server/collections/.gitkeep b/packages/plugins/@nocobase/plugin-field-attachment-url/src/server/collections/.gitkeep
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/packages/plugins/@nocobase/plugin-field-attachment-url/src/server/index.ts b/packages/plugins/@nocobase/plugin-field-attachment-url/src/server/index.ts
new file mode 100644
index 0000000000..be989de7c3
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-field-attachment-url/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-field-attachment-url/src/server/plugin.ts b/packages/plugins/@nocobase/plugin-field-attachment-url/src/server/plugin.ts
new file mode 100644
index 0000000000..372274c091
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-field-attachment-url/src/server/plugin.ts
@@ -0,0 +1,28 @@
+/**
+ * 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';
+
+export class PluginFieldAttachmentUrlServer extends Plugin {
+ async afterAdd() {}
+
+ async beforeLoad() {}
+
+ async load() {}
+
+ async install() {}
+
+ async afterEnable() {}
+
+ async afterDisable() {}
+
+ async remove() {}
+}
+
+export default PluginFieldAttachmentUrlServer;
diff --git a/packages/plugins/@nocobase/plugin-field-china-region/package.json b/packages/plugins/@nocobase/plugin-field-china-region/package.json
index 8dd971f583..826d923a4b 100644
--- a/packages/plugins/@nocobase/plugin-field-china-region/package.json
+++ b/packages/plugins/@nocobase/plugin-field-china-region/package.json
@@ -1,6 +1,6 @@
{
"name": "@nocobase/plugin-field-china-region",
- "version": "1.6.18",
+ "version": "1.6.24",
"displayName": "Collection field: administrative divisions of China",
"displayName.zh-CN": "数据表字段:中国行政区划",
"description": "Provides data and field type for administrative divisions of China.",
diff --git a/packages/plugins/@nocobase/plugin-field-formula/package.json b/packages/plugins/@nocobase/plugin-field-formula/package.json
index 43dd6ce1ad..0542680371 100644
--- a/packages/plugins/@nocobase/plugin-field-formula/package.json
+++ b/packages/plugins/@nocobase/plugin-field-formula/package.json
@@ -4,7 +4,7 @@
"displayName.zh-CN": "数据表字段:公式",
"description": "Configure and store the results of calculations between multiple field values in the same record, supporting both Math.js and Excel formula functions.",
"description.zh-CN": "可以配置并存储同一条记录的多字段值之间的计算结果,支持 Math.js 和 Excel formula functions 两种引擎",
- "version": "1.6.18",
+ "version": "1.6.24",
"license": "AGPL-3.0",
"main": "./dist/server/index.js",
"homepage": "https://docs.nocobase.com/handbook/field-formula",
diff --git a/packages/plugins/@nocobase/plugin-field-m2m-array/package.json b/packages/plugins/@nocobase/plugin-field-m2m-array/package.json
index 84aa0cf600..cf375ca357 100644
--- a/packages/plugins/@nocobase/plugin-field-m2m-array/package.json
+++ b/packages/plugins/@nocobase/plugin-field-m2m-array/package.json
@@ -4,7 +4,7 @@
"displayName.zh-CN": "数据表字段:多对多 (数组)",
"description": "Allows to create many to many relationships between two models by storing an array of unique keys of the target model.",
"description.zh-CN": "支持通过在数组中存储目标表唯一键的方式建立多对多关系。",
- "version": "1.6.18",
+ "version": "1.6.24",
"main": "dist/server/index.js",
"peerDependencies": {
"@nocobase/client": "1.x",
diff --git a/packages/plugins/@nocobase/plugin-field-markdown-vditor/package.json b/packages/plugins/@nocobase/plugin-field-markdown-vditor/package.json
index 645ba6f10f..357e4c6999 100644
--- a/packages/plugins/@nocobase/plugin-field-markdown-vditor/package.json
+++ b/packages/plugins/@nocobase/plugin-field-markdown-vditor/package.json
@@ -4,7 +4,7 @@
"displayName.zh-CN": "数据表字段:Markdown(Vditor)",
"description": "Used to store Markdown and render it using Vditor editor, supports common Markdown syntax such as list, code, quote, etc., and supports uploading images, recordings, etc.It also allows for instant rendering, where what you see is what you get.",
"description.zh-CN": "用于存储 Markdown,并使用 Vditor 编辑器渲染,支持常见 Markdown 语法,如列表,代码,引用等,并支持上传图片,录音等。同时可以做到即时渲染,所见即所得。",
- "version": "1.6.18",
+ "version": "1.6.24",
"license": "AGPL-3.0",
"main": "dist/server/index.js",
"homepage": "https://docs.nocobase.com/handbook/field-markdown-vditor",
diff --git a/packages/plugins/@nocobase/plugin-field-sequence/package.json b/packages/plugins/@nocobase/plugin-field-sequence/package.json
index 3e58048b16..04d96d52f8 100644
--- a/packages/plugins/@nocobase/plugin-field-sequence/package.json
+++ b/packages/plugins/@nocobase/plugin-field-sequence/package.json
@@ -4,7 +4,7 @@
"displayName.zh-CN": "数据表字段:自动编码",
"description": "Automatically generate codes based on configured rules, supporting combinations of dates, numbers, and text.",
"description.zh-CN": "根据配置的规则自动生成编码,支持日期、数字、文本的组合。",
- "version": "1.6.18",
+ "version": "1.6.24",
"license": "AGPL-3.0",
"main": "./dist/server/index.js",
"homepage": "https://docs.nocobase.com/handbook/field-sequence",
diff --git a/packages/plugins/@nocobase/plugin-field-sort/package.json b/packages/plugins/@nocobase/plugin-field-sort/package.json
index 1a9e89648e..076702eee8 100644
--- a/packages/plugins/@nocobase/plugin-field-sort/package.json
+++ b/packages/plugins/@nocobase/plugin-field-sort/package.json
@@ -1,6 +1,6 @@
{
"name": "@nocobase/plugin-field-sort",
- "version": "1.6.18",
+ "version": "1.6.24",
"license": "AGPL-3.0",
"displayName": "Collection field: Sort",
"displayName.zh-CN": "数据表字段:排序",
diff --git a/packages/plugins/@nocobase/plugin-file-manager/package.json b/packages/plugins/@nocobase/plugin-file-manager/package.json
index c336bee746..28f7f4fad1 100644
--- a/packages/plugins/@nocobase/plugin-file-manager/package.json
+++ b/packages/plugins/@nocobase/plugin-file-manager/package.json
@@ -1,6 +1,6 @@
{
"name": "@nocobase/plugin-file-manager",
- "version": "1.6.18",
+ "version": "1.6.24",
"displayName": "File manager",
"displayName.zh-CN": "文件管理器",
"description": "Provides files storage services with files collection template and attachment field.",
@@ -15,15 +15,15 @@
"@formily/core": "2.x",
"@formily/react": "2.x",
"@formily/shared": "2.x",
- "@koa/multer": "^3.0.0",
- "@types/koa-multer": "^1.0.1",
+ "@koa/multer": "^3.1.0",
+ "@types/koa-multer": "^1.0.4",
"@types/multer": "^1.4.5",
"antd": "5.x",
"cos-nodejs-sdk-v5": "^2.11.14",
"koa-static": "^5.0.0",
"mime-match": "^1.0.2",
"mkdirp": "~0.5.4",
- "multer": "^1.4.2",
+ "multer": "^1.4.5-lts.2",
"multer-aliyun-oss": "2.1.3",
"multer-cos": "^1.0.3",
"multer-s3": "^3.0.1",
diff --git a/packages/plugins/@nocobase/plugin-file-manager/src/server/index.ts b/packages/plugins/@nocobase/plugin-file-manager/src/server/index.ts
index 10daee88fe..2390d1852d 100644
--- a/packages/plugins/@nocobase/plugin-file-manager/src/server/index.ts
+++ b/packages/plugins/@nocobase/plugin-file-manager/src/server/index.ts
@@ -11,7 +11,7 @@ import { StorageEngine } from 'multer';
export * from '../constants';
export { AttachmentModel, default, PluginFileManagerServer, StorageModel } from './server';
-
+export { cloudFilenameGetter } from './utils';
export { StorageType } from './storages';
export { StorageEngine };
diff --git a/packages/plugins/@nocobase/plugin-gantt/package.json b/packages/plugins/@nocobase/plugin-gantt/package.json
index 87279097c2..e467bb3798 100644
--- a/packages/plugins/@nocobase/plugin-gantt/package.json
+++ b/packages/plugins/@nocobase/plugin-gantt/package.json
@@ -1,6 +1,6 @@
{
"name": "@nocobase/plugin-gantt",
- "version": "1.6.18",
+ "version": "1.6.24",
"displayName": "Block: Gantt",
"displayName.zh-CN": "区块:甘特图",
"description": "Provides Gantt block.",
diff --git a/packages/plugins/@nocobase/plugin-gantt/src/client/components/calendar/calendar.tsx b/packages/plugins/@nocobase/plugin-gantt/src/client/components/calendar/calendar.tsx
index ec81615d9f..4024a5faa8 100644
--- a/packages/plugins/@nocobase/plugin-gantt/src/client/components/calendar/calendar.tsx
+++ b/packages/plugins/@nocobase/plugin-gantt/src/client/components/calendar/calendar.tsx
@@ -52,12 +52,7 @@ export const Calendar: React.FC = ({
const date = dateSetup.dates[i];
const bottomValue = date.getFullYear();
bottomValues.push(
-
+
{bottomValue}
,
);
@@ -98,7 +93,7 @@ export const Calendar: React.FC = ({
key={date.getTime()}
y={headerHeight * 0.8}
x={columnWidth * i + columnWidth * 0.5}
- className={cx('calendarBottomText')}
+ className={styles.calendarBottomText}
>
{quarter}
,
@@ -118,7 +113,7 @@ export const Calendar: React.FC = ({
x1Line={columnWidth * i}
y1Line={0}
y2Line={topDefaultHeight}
- xText={Math.abs(xText)}
+ xText={Math.abs(xText) - 200}
yText={topDefaultHeight * 0.9}
/>,
);
@@ -139,7 +134,7 @@ export const Calendar: React.FC = ({
key={bottomValue + date.getFullYear()}
y={headerHeight * 0.8}
x={columnWidth * i + columnWidth * 0.5}
- className={cx('calendarBottomText')}
+ className={styles.calendarBottomText}
>
{bottomValue}
,
@@ -189,7 +184,7 @@ export const Calendar: React.FC = ({
key={date.getTime()}
y={headerHeight * 0.8}
x={columnWidth * (i + +rtl)}
- className={cx('calendarBottomText')}
+ className={styles.calendarBottomText}
>
{bottomValue}
,
diff --git a/packages/plugins/@nocobase/plugin-gantt/src/client/components/calendar/style.tsx b/packages/plugins/@nocobase/plugin-gantt/src/client/components/calendar/style.tsx
index b222628f6f..306b91e70d 100644
--- a/packages/plugins/@nocobase/plugin-gantt/src/client/components/calendar/style.tsx
+++ b/packages/plugins/@nocobase/plugin-gantt/src/client/components/calendar/style.tsx
@@ -23,6 +23,9 @@ const useStyles = createStyles(({ token, css }) => {
background: ${colorFillAlterSolid};
border-bottom: 1px solid ${token.colorBorderSecondary};
`,
+ calendarBottomText: css`
+ font-size: 11px;
+ `,
nbGanttCalendar: css`
.calendarbottomtext: {
textanchor: middle;
diff --git a/packages/plugins/@nocobase/plugin-gantt/src/client/components/gantt/gantt.tsx b/packages/plugins/@nocobase/plugin-gantt/src/client/components/gantt/gantt.tsx
index 27ad8c0123..4764299ba3 100644
--- a/packages/plugins/@nocobase/plugin-gantt/src/client/components/gantt/gantt.tsx
+++ b/packages/plugins/@nocobase/plugin-gantt/src/client/components/gantt/gantt.tsx
@@ -47,7 +47,7 @@ import { TaskGantt } from './task-gantt';
import { TaskGanttContentProps } from './task-gantt-content';
const getColumnWidth = (dataSetLength: any, clientWidth: any) => {
- const columnWidth = clientWidth / dataSetLength > 50 ? Math.floor(clientWidth / dataSetLength) + 20 : 50;
+ const columnWidth = clientWidth / dataSetLength > 50 ? Math.floor(clientWidth / dataSetLength) + 20 : 60;
return columnWidth;
};
diff --git a/packages/plugins/@nocobase/plugin-graph-collection-manager/package.json b/packages/plugins/@nocobase/plugin-graph-collection-manager/package.json
index ff72787e33..530058a257 100644
--- a/packages/plugins/@nocobase/plugin-graph-collection-manager/package.json
+++ b/packages/plugins/@nocobase/plugin-graph-collection-manager/package.json
@@ -4,7 +4,7 @@
"displayName.zh-CN": "可视化数据表管理",
"description": "An ER diagram-like tool. Currently only the Master database is supported.",
"description.zh-CN": "类似 ER 图的工具,目前只支持主数据库。",
- "version": "1.6.18",
+ "version": "1.6.24",
"license": "AGPL-3.0",
"main": "./dist/server/index.js",
"homepage": "https://docs.nocobase.com/handbook/graph-collection-manager",
diff --git a/packages/plugins/@nocobase/plugin-kanban/package.json b/packages/plugins/@nocobase/plugin-kanban/package.json
index 83008b05c5..955ffb5676 100644
--- a/packages/plugins/@nocobase/plugin-kanban/package.json
+++ b/packages/plugins/@nocobase/plugin-kanban/package.json
@@ -1,6 +1,6 @@
{
"name": "@nocobase/plugin-kanban",
- "version": "1.6.18",
+ "version": "1.6.24",
"main": "dist/server/index.js",
"homepage": "https://docs.nocobase.com/handbook/block-kanban",
"homepage.zh-CN": "https://docs-cn.nocobase.com/handbook/block-kanban",
diff --git a/packages/plugins/@nocobase/plugin-locale-tester/package.json b/packages/plugins/@nocobase/plugin-locale-tester/package.json
index a4384e0b78..02b13d480d 100644
--- a/packages/plugins/@nocobase/plugin-locale-tester/package.json
+++ b/packages/plugins/@nocobase/plugin-locale-tester/package.json
@@ -2,7 +2,7 @@
"name": "@nocobase/plugin-locale-tester",
"displayName": "Locale tester",
"displayName.zh-CN": "翻译测试工具",
- "version": "1.6.18",
+ "version": "1.6.24",
"homepage": "https://github.com/nocobase/locales",
"main": "dist/server/index.js",
"peerDependencies": {
diff --git a/packages/plugins/@nocobase/plugin-localization/package.json b/packages/plugins/@nocobase/plugin-localization/package.json
index d8e56e5a6a..0a8c6d7e3e 100644
--- a/packages/plugins/@nocobase/plugin-localization/package.json
+++ b/packages/plugins/@nocobase/plugin-localization/package.json
@@ -1,6 +1,6 @@
{
"name": "@nocobase/plugin-localization",
- "version": "1.6.18",
+ "version": "1.6.24",
"main": "dist/server/index.js",
"homepage": "https://docs.nocobase.com/handbook/localization-management",
"homepage.zh-CN": "https://docs-cn.nocobase.com/handbook/localization-management",
diff --git a/packages/plugins/@nocobase/plugin-logger/package.json b/packages/plugins/@nocobase/plugin-logger/package.json
index 933c8dcc4c..e70872b7dc 100644
--- a/packages/plugins/@nocobase/plugin-logger/package.json
+++ b/packages/plugins/@nocobase/plugin-logger/package.json
@@ -4,7 +4,7 @@
"displayName.zh-CN": "日志",
"description": "Server-side logs, mainly including API request logs and system runtime logs, and allows to package and download log files.",
"description.zh-CN": "服务端日志,主要包括接口请求日志和系统运行日志,并支持打包和下载日志文件。",
- "version": "1.6.18",
+ "version": "1.6.24",
"license": "AGPL-3.0",
"main": "dist/server/index.js",
"homepage": "https://docs.nocobase.com/handbook/logger",
diff --git a/packages/plugins/@nocobase/plugin-map/package.json b/packages/plugins/@nocobase/plugin-map/package.json
index ade2f04c7a..191bfca4b5 100644
--- a/packages/plugins/@nocobase/plugin-map/package.json
+++ b/packages/plugins/@nocobase/plugin-map/package.json
@@ -2,7 +2,7 @@
"name": "@nocobase/plugin-map",
"displayName": "Block: Map",
"displayName.zh-CN": "区块:地图",
- "version": "1.6.18",
+ "version": "1.6.24",
"description": "Map block, support Gaode map and Google map, you can also extend more map types.",
"description.zh-CN": "地图区块,支持高德地图和 Google 地图,你也可以扩展更多地图类型。",
"license": "AGPL-3.0",
diff --git a/packages/plugins/@nocobase/plugin-mobile-client/package.json b/packages/plugins/@nocobase/plugin-mobile-client/package.json
index 33c8e27c13..4235b9bd46 100644
--- a/packages/plugins/@nocobase/plugin-mobile-client/package.json
+++ b/packages/plugins/@nocobase/plugin-mobile-client/package.json
@@ -1,6 +1,6 @@
{
"name": "@nocobase/plugin-mobile-client",
- "version": "1.6.18",
+ "version": "1.6.24",
"main": "./dist/server/index.js",
"homepage": "https://docs.nocobase.com/handbook/mobile-client",
"homepage.zh-CN": "https://docs-cn.nocobase.com/handbook/mobile-client",
diff --git a/packages/plugins/@nocobase/plugin-mobile/package.json b/packages/plugins/@nocobase/plugin-mobile/package.json
index 87c6885f17..1840df2ec3 100644
--- a/packages/plugins/@nocobase/plugin-mobile/package.json
+++ b/packages/plugins/@nocobase/plugin-mobile/package.json
@@ -1,6 +1,6 @@
{
"name": "@nocobase/plugin-mobile",
- "version": "1.6.18",
+ "version": "1.6.24",
"main": "dist/server/index.js",
"homepage": "https://docs.nocobase.com/handbook/mobile",
"homepage.zh-CN": "https://docs-cn.nocobase.com/handbook/mobile",
diff --git a/packages/plugins/@nocobase/plugin-mobile/src/client/pages/dynamic-page/header/navigation-bar/actions/mobile-navigation-bar-action/styles.ts b/packages/plugins/@nocobase/plugin-mobile/src/client/pages/dynamic-page/header/navigation-bar/actions/mobile-navigation-bar-action/styles.ts
index 06751439b7..ee2bdba69d 100644
--- a/packages/plugins/@nocobase/plugin-mobile/src/client/pages/dynamic-page/header/navigation-bar/actions/mobile-navigation-bar-action/styles.ts
+++ b/packages/plugins/@nocobase/plugin-mobile/src/client/pages/dynamic-page/header/navigation-bar/actions/mobile-navigation-bar-action/styles.ts
@@ -29,6 +29,10 @@ export const useStyles = genStyleHook('nb-mobile-navigation-bar-action', (token)
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
+
+ '& > span': {
+ position: 'relative',
+ },
},
'.nb-navigation-bar-action-title': {
fontSize: 17,
diff --git a/packages/plugins/@nocobase/plugin-mock-collections/package.json b/packages/plugins/@nocobase/plugin-mock-collections/package.json
index aaedd39af2..bd164833ab 100644
--- a/packages/plugins/@nocobase/plugin-mock-collections/package.json
+++ b/packages/plugins/@nocobase/plugin-mock-collections/package.json
@@ -2,7 +2,7 @@
"name": "@nocobase/plugin-mock-collections",
"displayName": "mock-collections",
"description": "mock-collections",
- "version": "1.6.18",
+ "version": "1.6.24",
"main": "./dist/server/index.js",
"license": "AGPL-3.0",
"peerDependencies": {
diff --git a/packages/plugins/@nocobase/plugin-mock-collections/src/server/collection-templates/index.ts b/packages/plugins/@nocobase/plugin-mock-collections/src/server/collection-templates/index.ts
index 254f1a88c3..f1838da1a2 100644
--- a/packages/plugins/@nocobase/plugin-mock-collections/src/server/collection-templates/index.ts
+++ b/packages/plugins/@nocobase/plugin-mock-collections/src/server/collection-templates/index.ts
@@ -199,7 +199,7 @@ export default {
value: 'formula.js',
label: 'Formula.js',
tooltip: '{{t("Formula.js supports most Microsoft Excel formula functions.")}}',
- link: 'https://formulajs.info/functions/',
+ link: 'https://docs.nocobase.com/handbook/calculation-engines/formula',
},
],
default: 'formula.js',
diff --git a/packages/plugins/@nocobase/plugin-multi-app-manager/package.json b/packages/plugins/@nocobase/plugin-multi-app-manager/package.json
index 320e1a9185..51f7082e33 100644
--- a/packages/plugins/@nocobase/plugin-multi-app-manager/package.json
+++ b/packages/plugins/@nocobase/plugin-multi-app-manager/package.json
@@ -4,7 +4,7 @@
"displayName.zh-CN": "多应用管理器",
"description": "Dynamically create multiple apps without separate deployments.",
"description.zh-CN": "无需单独部署即可动态创建多个应用。",
- "version": "1.6.18",
+ "version": "1.6.24",
"license": "AGPL-3.0",
"main": "./dist/server/index.js",
"homepage": "https://docs.nocobase.com/handbook/multi-app-manager",
diff --git a/packages/plugins/@nocobase/plugin-multi-app-share-collection/package.json b/packages/plugins/@nocobase/plugin-multi-app-share-collection/package.json
index 389092c88c..8b33c1dd9d 100644
--- a/packages/plugins/@nocobase/plugin-multi-app-share-collection/package.json
+++ b/packages/plugins/@nocobase/plugin-multi-app-share-collection/package.json
@@ -4,7 +4,7 @@
"displayName.zh-CN": "多应用数据表共享",
"description": "",
"description.zh-CN": "",
- "version": "1.6.18",
+ "version": "1.6.24",
"main": "./dist/server/index.js",
"devDependencies": {
"@formily/react": "2.x",
diff --git a/packages/plugins/@nocobase/plugin-notification-email/package.json b/packages/plugins/@nocobase/plugin-notification-email/package.json
index 4ef0b5e20f..b53930a445 100644
--- a/packages/plugins/@nocobase/plugin-notification-email/package.json
+++ b/packages/plugins/@nocobase/plugin-notification-email/package.json
@@ -1,6 +1,6 @@
{
"name": "@nocobase/plugin-notification-email",
- "version": "1.6.18",
+ "version": "1.6.24",
"displayName": "Notification: Email",
"displayName.zh-CN": "通知:电子邮件",
"description": "Used for sending email notifications with built-in SMTP transport.",
diff --git a/packages/plugins/@nocobase/plugin-notification-in-app-message/package.json b/packages/plugins/@nocobase/plugin-notification-in-app-message/package.json
index a41e03716c..b06f8a4403 100644
--- a/packages/plugins/@nocobase/plugin-notification-in-app-message/package.json
+++ b/packages/plugins/@nocobase/plugin-notification-in-app-message/package.json
@@ -1,6 +1,6 @@
{
"name": "@nocobase/plugin-notification-in-app-message",
- "version": "1.6.18",
+ "version": "1.6.24",
"displayName": "Notification: In-app message",
"displayName.zh-CN": "通知:站内信",
"description": "It supports users in receiving real-time message notifications within the NocoBase application.",
diff --git a/packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/components/mobile/MobileTabBarMessageItem.tsx b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/components/mobile/MobileTabBarMessageItem.tsx
index 8531d4a1aa..4233489589 100644
--- a/packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/components/mobile/MobileTabBarMessageItem.tsx
+++ b/packages/plugins/@nocobase/plugin-notification-in-app-message/src/client/components/mobile/MobileTabBarMessageItem.tsx
@@ -7,11 +7,11 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
-import React, { useEffect } from 'react';
import { observer } from '@formily/reactive-react';
-import { useNavigate, useLocation } from 'react-router-dom';
import { MobileTabBarItem } from '@nocobase/plugin-mobile/client';
-import { unreadMsgsCountObs, startMsgSSEStreamWithRetry, updateUnreadMsgsCount } from '../../observables';
+import React, { useEffect } from 'react';
+import { useLocation, useNavigate } from 'react-router-dom';
+import { startMsgSSEStreamWithRetry, unreadMsgsCountObs, updateUnreadMsgsCount } from '../../observables';
const InnerMobileTabBarMessageItem = (props) => {
const navigate = useNavigate();
@@ -19,9 +19,32 @@ const InnerMobileTabBarMessageItem = (props) => {
const onClick = () => {
navigate('/page/in-app-message');
};
+
useEffect(() => {
- startMsgSSEStreamWithRetry();
+ const disposes: Array<() => void> = [];
+ disposes.push(startMsgSSEStreamWithRetry());
+ const disposeAll = () => {
+ while (disposes.length > 0) {
+ const dispose = disposes.pop();
+ dispose && dispose();
+ }
+ };
+
+ const onVisibilityChange = () => {
+ if (document.visibilityState === 'visible') {
+ disposes.push(startMsgSSEStreamWithRetry());
+ } else {
+ disposeAll();
+ }
+ };
+
+ document.addEventListener('visibilitychange', onVisibilityChange);
+ return () => {
+ disposeAll();
+ document.removeEventListener('visibilitychange', onVisibilityChange);
+ };
}, []);
+
const selected = props.url && location.pathname.startsWith(props.url);
return (
diff --git a/packages/plugins/@nocobase/plugin-notification-manager/package.json b/packages/plugins/@nocobase/plugin-notification-manager/package.json
index 3b31084461..bea91897cf 100644
--- a/packages/plugins/@nocobase/plugin-notification-manager/package.json
+++ b/packages/plugins/@nocobase/plugin-notification-manager/package.json
@@ -4,7 +4,7 @@
"description": "Provides a unified management service that includes channel configuration, logging, and other features, supporting the configuration of various notification channels, including in-app message and email.",
"displayName.zh-CN": "通知管理",
"description.zh-CN": "提供统一的管理服务,涵盖渠道配置、日志记录等功能,支持多种通知渠道的配置,包括站内信和电子邮件等。",
- "version": "1.6.18",
+ "version": "1.6.24",
"homepage": "https://docs.nocobase.com/handbook/notification-manager",
"homepage.zh-CN": "https://docs-cn.nocobase.com/handbook/notification-manager",
"main": "dist/server/index.js",
diff --git a/packages/plugins/@nocobase/plugin-notifications/package.json b/packages/plugins/@nocobase/plugin-notifications/package.json
index 8093ab0bbf..727a7cac7d 100644
--- a/packages/plugins/@nocobase/plugin-notifications/package.json
+++ b/packages/plugins/@nocobase/plugin-notifications/package.json
@@ -1,6 +1,6 @@
{
"name": "@nocobase/plugin-notifications",
- "version": "1.6.18",
+ "version": "1.6.24",
"description": "",
"license": "AGPL-3.0",
"main": "./dist/server/index.js",
diff --git a/packages/plugins/@nocobase/plugin-public-forms/package.json b/packages/plugins/@nocobase/plugin-public-forms/package.json
index 7f70a879e6..b2aca0544f 100644
--- a/packages/plugins/@nocobase/plugin-public-forms/package.json
+++ b/packages/plugins/@nocobase/plugin-public-forms/package.json
@@ -1,6 +1,6 @@
{
"name": "@nocobase/plugin-public-forms",
- "version": "1.6.18",
+ "version": "1.6.24",
"main": "dist/server/index.js",
"displayName": "Public forms",
"displayName.zh-CN": "公开表单",
diff --git a/packages/plugins/@nocobase/plugin-sample-hello/package.json b/packages/plugins/@nocobase/plugin-sample-hello/package.json
index 7965e5604e..7ae3d7adc5 100644
--- a/packages/plugins/@nocobase/plugin-sample-hello/package.json
+++ b/packages/plugins/@nocobase/plugin-sample-hello/package.json
@@ -1,6 +1,6 @@
{
"name": "@nocobase/plugin-sample-hello",
- "version": "1.6.18",
+ "version": "1.6.24",
"main": "./dist/server/index.js",
"displayName": "Hello",
"displayName.zh-CN": "Hello",
diff --git a/packages/plugins/@nocobase/plugin-snapshot-field/package.json b/packages/plugins/@nocobase/plugin-snapshot-field/package.json
index e71da42f99..9e79aaef22 100644
--- a/packages/plugins/@nocobase/plugin-snapshot-field/package.json
+++ b/packages/plugins/@nocobase/plugin-snapshot-field/package.json
@@ -4,7 +4,7 @@
"displayName.zh-CN": "数据表字段:关系快照",
"description": "When adding a new record, create a snapshot for its relational record and save in the new record. The snapshot will not be updated when the relational record is updated.",
"description.zh-CN": "在添加数据时,为它的关系数据创建快照,并保存在当前的数据中。关系数据更新时,快照不会更新。",
- "version": "1.6.18",
+ "version": "1.6.24",
"license": "AGPL-3.0",
"main": "./dist/server/index.js",
"homepage": "https://docs.nocobase.com/handbook/field-snapshot",
diff --git a/packages/plugins/@nocobase/plugin-system-settings/package.json b/packages/plugins/@nocobase/plugin-system-settings/package.json
index 5f5ee89ad5..00fb1ab7f4 100644
--- a/packages/plugins/@nocobase/plugin-system-settings/package.json
+++ b/packages/plugins/@nocobase/plugin-system-settings/package.json
@@ -4,7 +4,7 @@
"displayName.zh-CN": "系统设置",
"description": "Used to adjust the system title, logo, language, etc.",
"description.zh-CN": "用于调整系统的标题、LOGO、语言等。",
- "version": "1.6.18",
+ "version": "1.6.24",
"license": "AGPL-3.0",
"main": "./dist/server/index.js",
"homepage": "https://docs.nocobase.com/handbook/system-settings",
diff --git a/packages/plugins/@nocobase/plugin-theme-editor/package.json b/packages/plugins/@nocobase/plugin-theme-editor/package.json
index f42af10078..348b86456e 100644
--- a/packages/plugins/@nocobase/plugin-theme-editor/package.json
+++ b/packages/plugins/@nocobase/plugin-theme-editor/package.json
@@ -1,6 +1,6 @@
{
"name": "@nocobase/plugin-theme-editor",
- "version": "1.6.18",
+ "version": "1.6.24",
"main": "dist/server/index.js",
"homepage": "https://docs.nocobase.com/handbook/theme-editor",
"homepage.zh-CN": "https://docs-cn.nocobase.com/handbook/theme-editor",
diff --git a/packages/plugins/@nocobase/plugin-ui-schema-storage/package.json b/packages/plugins/@nocobase/plugin-ui-schema-storage/package.json
index 9679ab1a9d..655643c48b 100644
--- a/packages/plugins/@nocobase/plugin-ui-schema-storage/package.json
+++ b/packages/plugins/@nocobase/plugin-ui-schema-storage/package.json
@@ -4,7 +4,7 @@
"displayName.zh-CN": "UI schema 存储服务",
"description": "Provides centralized UI schema storage service.",
"description.zh-CN": "提供中心化的 UI schema 存储服务。",
- "version": "1.6.18",
+ "version": "1.6.24",
"license": "AGPL-3.0",
"main": "./dist/server/index.js",
"homepage": "https://docs.nocobase.com/handbook/ui-schema-storage",
diff --git a/packages/plugins/@nocobase/plugin-user-data-sync/package.json b/packages/plugins/@nocobase/plugin-user-data-sync/package.json
index 03cb20970e..bd9bdb93bf 100644
--- a/packages/plugins/@nocobase/plugin-user-data-sync/package.json
+++ b/packages/plugins/@nocobase/plugin-user-data-sync/package.json
@@ -4,7 +4,7 @@
"displayName.zh-CN": "用户数据同步",
"description": "Reigster and manage extensible user data synchronization sources, with HTTP API provided by default. Support for synchronizing data to resources such as users and departments.",
"description.zh-CN": "注册和管理可扩展的用户数据同步来源,默认提供 HTTP API。支持向用户和部门等资源同步数据。",
- "version": "1.6.18",
+ "version": "1.6.24",
"main": "dist/server/index.js",
"peerDependencies": {
"@nocobase/client": "1.x",
diff --git a/packages/plugins/@nocobase/plugin-users/package.json b/packages/plugins/@nocobase/plugin-users/package.json
index 2fb21d7032..81bec97900 100644
--- a/packages/plugins/@nocobase/plugin-users/package.json
+++ b/packages/plugins/@nocobase/plugin-users/package.json
@@ -4,14 +4,14 @@
"displayName.zh-CN": "用户",
"description": "Provides basic user model, as well as created by and updated by fields.",
"description.zh-CN": "提供了基础的用户模型,以及创建人和最后更新人字段。",
- "version": "1.6.18",
+ "version": "1.6.24",
"license": "AGPL-3.0",
"main": "./dist/server/index.js",
"homepage": "https://docs.nocobase.com/handbook/users",
"homepage.zh-CN": "https://docs-cn.nocobase.com/handbook/users",
"devDependencies": {
- "@types/jsonwebtoken": "^8.5.8",
- "jsonwebtoken": "^8.5.1"
+ "@types/jsonwebtoken": "^9.0.9",
+ "jsonwebtoken": "^9.0.2"
},
"peerDependencies": {
"@nocobase/actions": "1.x",
diff --git a/packages/plugins/@nocobase/plugin-verification/package.json b/packages/plugins/@nocobase/plugin-verification/package.json
index 33b6cb96bb..1c9a5292ff 100644
--- a/packages/plugins/@nocobase/plugin-verification/package.json
+++ b/packages/plugins/@nocobase/plugin-verification/package.json
@@ -4,7 +4,7 @@
"displayName.zh-CN": "验证码",
"description": "verification setting.",
"description.zh-CN": "验证码配置。",
- "version": "1.6.18",
+ "version": "1.6.24",
"license": "AGPL-3.0",
"main": "./dist/server/index.js",
"homepage": "https://docs.nocobase.com/handbook/verification",
diff --git a/packages/plugins/@nocobase/plugin-workflow-action-trigger/package.json b/packages/plugins/@nocobase/plugin-workflow-action-trigger/package.json
index 127a9e742c..e285a0070f 100644
--- a/packages/plugins/@nocobase/plugin-workflow-action-trigger/package.json
+++ b/packages/plugins/@nocobase/plugin-workflow-action-trigger/package.json
@@ -4,7 +4,7 @@
"displayName.zh-CN": "工作流:操作后事件",
"description": "Triggered after the completion of a request initiated through an action button or API, such as after adding, updating, deleting data, or \"submit to workflow\". Suitable for data processing, sending notifications, etc., after actions are completed.",
"description.zh-CN": "通过操作按钮或 API 发起请求并在执行完成后触发,比如新增、更新、删除数据或者“提交至工作流”之后。适用于在操作完成后进行数据处理、发送通知等。",
- "version": "1.6.18",
+ "version": "1.6.24",
"license": "AGPL-3.0",
"main": "./dist/server/index.js",
"homepage": "https://docs.nocobase.com/plugins/workflow-action-trigger",
diff --git a/packages/plugins/@nocobase/plugin-workflow-aggregate/package.json b/packages/plugins/@nocobase/plugin-workflow-aggregate/package.json
index f3f2229cd1..f3affa7100 100644
--- a/packages/plugins/@nocobase/plugin-workflow-aggregate/package.json
+++ b/packages/plugins/@nocobase/plugin-workflow-aggregate/package.json
@@ -4,7 +4,7 @@
"displayName.zh-CN": "工作流:聚合查询节点",
"description": "Used to aggregate data against the database in workflow, such as: statistics, sum, average, etc.",
"description.zh-CN": "可用于在工作流中对数据库进行聚合查询,如:统计数量、求和、平均值等。",
- "version": "1.6.18",
+ "version": "1.6.24",
"license": "AGPL-3.0",
"main": "./dist/server/index.js",
"homepage": "https://docs.nocobase.com/handbook/workflow-aggregate",
diff --git a/packages/plugins/@nocobase/plugin-workflow-delay/package.json b/packages/plugins/@nocobase/plugin-workflow-delay/package.json
index 6613f935fa..5fa6bf2c2e 100644
--- a/packages/plugins/@nocobase/plugin-workflow-delay/package.json
+++ b/packages/plugins/@nocobase/plugin-workflow-delay/package.json
@@ -4,7 +4,7 @@
"displayName.zh-CN": "工作流:延时节点",
"description": "Could be used in workflow parallel branch for waiting other branches.",
"description.zh-CN": "可用于工作流并行分支中等待其他分支执行完成。",
- "version": "1.6.18",
+ "version": "1.6.24",
"license": "AGPL-3.0",
"main": "./dist/server/index.js",
"homepage": "https://docs.nocobase.com/handbook/workflow-delay",
diff --git a/packages/plugins/@nocobase/plugin-workflow-dynamic-calculation/package.json b/packages/plugins/@nocobase/plugin-workflow-dynamic-calculation/package.json
index a945d7c9a4..77c2a63b73 100644
--- a/packages/plugins/@nocobase/plugin-workflow-dynamic-calculation/package.json
+++ b/packages/plugins/@nocobase/plugin-workflow-dynamic-calculation/package.json
@@ -4,7 +4,7 @@
"displayName.zh-CN": "工作流:动态表达式计算节点",
"description": "Useful plugin for doing dynamic calculation based on expression collection records in workflow.",
"description.zh-CN": "用于在工作流中进行基于数据行的动态表达式计算。",
- "version": "1.6.18",
+ "version": "1.6.24",
"license": "AGPL-3.0",
"main": "./dist/server/index.js",
"homepage": "https://docs.nocobase.com/handbook/workflow-dynamic-calculation",
diff --git a/packages/plugins/@nocobase/plugin-workflow-loop/package.json b/packages/plugins/@nocobase/plugin-workflow-loop/package.json
index 570d83f19c..6f175a04ab 100644
--- a/packages/plugins/@nocobase/plugin-workflow-loop/package.json
+++ b/packages/plugins/@nocobase/plugin-workflow-loop/package.json
@@ -4,7 +4,7 @@
"displayName.zh-CN": "工作流:循环节点",
"description": "Used to repeat the sub-process processing of each value in an array, and can also be used for fixed times of sub-process processing.",
"description.zh-CN": "用于对一个数组中的每个值进行重复的子流程处理,也可用于固定次数的重复子流程处理。",
- "version": "1.6.18",
+ "version": "1.6.24",
"license": "AGPL-3.0",
"main": "./dist/server/index.js",
"homepage": "https://docs.nocobase.com/handbook/workflow-loop",
diff --git a/packages/plugins/@nocobase/plugin-workflow-mailer/package.json b/packages/plugins/@nocobase/plugin-workflow-mailer/package.json
index 7a2ce623e2..6bc0fb0b8e 100644
--- a/packages/plugins/@nocobase/plugin-workflow-mailer/package.json
+++ b/packages/plugins/@nocobase/plugin-workflow-mailer/package.json
@@ -4,7 +4,7 @@
"displayName.zh-CN": "工作流:邮件发送节点",
"description": "Send email in workflow.",
"description.zh-CN": "可用于在工作流中发送电子邮件。",
- "version": "1.6.18",
+ "version": "1.6.24",
"license": "AGPL-3.0",
"main": "./dist/server/index.js",
"homepage": "https://docs.nocobase.com/handbook/workflow-smtp-mailer",
diff --git a/packages/plugins/@nocobase/plugin-workflow-manual/package.json b/packages/plugins/@nocobase/plugin-workflow-manual/package.json
index e8b8d57737..6875371cd6 100644
--- a/packages/plugins/@nocobase/plugin-workflow-manual/package.json
+++ b/packages/plugins/@nocobase/plugin-workflow-manual/package.json
@@ -4,7 +4,7 @@
"displayName.zh-CN": "工作流:人工处理节点",
"description": "Could be used for workflows which some of decisions are made by users.",
"description.zh-CN": "用于人工控制部分决策的流程。",
- "version": "1.6.18",
+ "version": "1.6.24",
"license": "AGPL-3.0",
"main": "./dist/server/index.js",
"homepage": "https://docs.nocobase.com/handbook/workflow-manual",
diff --git a/packages/plugins/@nocobase/plugin-workflow-manual/src/client/WorkflowTodo.tsx b/packages/plugins/@nocobase/plugin-workflow-manual/src/client/WorkflowTodo.tsx
index beeae222c4..e3aef3c91e 100644
--- a/packages/plugins/@nocobase/plugin-workflow-manual/src/client/WorkflowTodo.tsx
+++ b/packages/plugins/@nocobase/plugin-workflow-manual/src/client/WorkflowTodo.tsx
@@ -44,14 +44,13 @@ import WorkflowPlugin, {
useAvailableUpstreams,
useFlowContext,
EXECUTION_STATUS,
- JOB_STATUS,
WorkflowTitle,
} from '@nocobase/plugin-workflow/client';
import { NAMESPACE, useLang } from '../locale';
import { FormBlockProvider } from './instruction/FormBlockProvider';
import { ManualFormType, manualFormTypes } from './instruction/SchemaConfig';
-import { TaskStatusOptionsMap } from '../common/constants';
+import { TaskStatusOptionsMap, TASK_STATUS } from '../common/constants';
function TaskStatusColumn(props) {
const recordData = useCollectionRecordData();
@@ -667,11 +666,11 @@ function TaskItem() {
const StatusFilterMap = {
pending: {
- status: JOB_STATUS.PENDING,
+ status: TASK_STATUS.PENDING,
'execution.status': EXECUTION_STATUS.STARTED,
},
completed: {
- status: JOB_STATUS.RESOLVED,
+ status: [TASK_STATUS.RESOLVED, TASK_STATUS.REJECTED],
},
};
diff --git a/packages/plugins/@nocobase/plugin-workflow-manual/src/common/constants.ts b/packages/plugins/@nocobase/plugin-workflow-manual/src/common/constants.ts
index dd4acc5d20..a59b843700 100644
--- a/packages/plugins/@nocobase/plugin-workflow-manual/src/common/constants.ts
+++ b/packages/plugins/@nocobase/plugin-workflow-manual/src/common/constants.ts
@@ -14,7 +14,7 @@ export const MANUAL_TASK_TYPE = 'manual';
export const TASK_STATUS = {
PENDING: 0,
RESOLVED: 1,
- REJECTED: -1,
+ REJECTED: -5,
};
export const TaskStatusOptions = [
diff --git a/packages/plugins/@nocobase/plugin-workflow-notification/package.json b/packages/plugins/@nocobase/plugin-workflow-notification/package.json
index 3df41ce945..61147328a2 100644
--- a/packages/plugins/@nocobase/plugin-workflow-notification/package.json
+++ b/packages/plugins/@nocobase/plugin-workflow-notification/package.json
@@ -4,7 +4,7 @@
"displayName.zh-CN": "工作流:通知节点",
"description": "Send notification in workflow.",
"description.zh-CN": "可用于在工作流中发送各类通知。",
- "version": "1.6.18",
+ "version": "1.6.24",
"license": "AGPL-3.0",
"main": "./dist/server/index.js",
"homepage": "https://docs.nocobase.com/handbook/workflow-smtp-mailer",
diff --git a/packages/plugins/@nocobase/plugin-workflow-parallel/package.json b/packages/plugins/@nocobase/plugin-workflow-parallel/package.json
index 9e9536b659..b45aaa8300 100644
--- a/packages/plugins/@nocobase/plugin-workflow-parallel/package.json
+++ b/packages/plugins/@nocobase/plugin-workflow-parallel/package.json
@@ -4,7 +4,7 @@
"displayName.zh-CN": "工作流:并行分支节点",
"description": "Could be used for parallel execution of branch processes in the workflow.",
"description.zh-CN": "用于在工作流中需要并行执行的分支流程。",
- "version": "1.6.18",
+ "version": "1.6.24",
"license": "AGPL-3.0",
"main": "./dist/server/index.js",
"homepage": "https://docs.nocobase.com/handbook/workflow-parallel",
diff --git a/packages/plugins/@nocobase/plugin-workflow-request/package.json b/packages/plugins/@nocobase/plugin-workflow-request/package.json
index 4c98a636e6..7183458087 100644
--- a/packages/plugins/@nocobase/plugin-workflow-request/package.json
+++ b/packages/plugins/@nocobase/plugin-workflow-request/package.json
@@ -4,7 +4,7 @@
"displayName.zh-CN": "工作流:HTTP 请求节点",
"description": "Send HTTP requests to any HTTP service for data interaction in workflow.",
"description.zh-CN": "可用于在工作流中向任意 HTTP 服务发送请求,进行数据交互。",
- "version": "1.6.18",
+ "version": "1.6.24",
"license": "AGPL-3.0",
"main": "./dist/server/index.js",
"homepage": "https://docs.nocobase.com/handbook/workflow-request",
diff --git a/packages/plugins/@nocobase/plugin-workflow-response-message/.npmignore b/packages/plugins/@nocobase/plugin-workflow-response-message/.npmignore
new file mode 100644
index 0000000000..65f5e8779f
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-workflow-response-message/.npmignore
@@ -0,0 +1,2 @@
+/node_modules
+/src
diff --git a/packages/plugins/@nocobase/plugin-workflow-response-message/README.md b/packages/plugins/@nocobase/plugin-workflow-response-message/README.md
new file mode 100644
index 0000000000..8ced21e948
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-workflow-response-message/README.md
@@ -0,0 +1 @@
+# @nocobase/plugin-workflow-response-message
diff --git a/packages/plugins/@nocobase/plugin-workflow-response-message/client.d.ts b/packages/plugins/@nocobase/plugin-workflow-response-message/client.d.ts
new file mode 100644
index 0000000000..6c459cbac4
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-workflow-response-message/client.d.ts
@@ -0,0 +1,2 @@
+export * from './dist/client';
+export { default } from './dist/client';
diff --git a/packages/plugins/@nocobase/plugin-workflow-response-message/client.js b/packages/plugins/@nocobase/plugin-workflow-response-message/client.js
new file mode 100644
index 0000000000..b6e3be70e6
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-workflow-response-message/client.js
@@ -0,0 +1 @@
+module.exports = require('./dist/client/index.js');
diff --git a/packages/plugins/@nocobase/plugin-workflow-response-message/package.json b/packages/plugins/@nocobase/plugin-workflow-response-message/package.json
new file mode 100644
index 0000000000..7ef7054f91
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-workflow-response-message/package.json
@@ -0,0 +1,24 @@
+{
+ "name": "@nocobase/plugin-workflow-response-message",
+ "version": "1.6.24",
+ "displayName": "Workflow: Response message",
+ "displayName.zh-CN": "工作流:响应消息",
+ "description": "Used for assemble response message and showing to client in form event and request interception workflows.",
+ "description.zh-CN": "用于在表单事件和请求拦截工作流中组装并向客户端显示响应消息。",
+ "main": "dist/server/index.js",
+ "homepage": "https://docs.nocobase.com/handbook/workflow-response-message",
+ "homepage.zh-CN": "https://docs-cn.nocobase.com/handbook/workflow-response-message",
+ "peerDependencies": {
+ "@nocobase/client": "1.x",
+ "@nocobase/server": "1.x",
+ "@nocobase/test": "1.x",
+ "@nocobase/utils": "1.x"
+ },
+ "devDependencies": {
+ "@nocobase/plugin-workflow": "1.6.24"
+ },
+ "keywords": [
+ "Workflow"
+ ],
+ "gitHead": "080fc78c1a744d47e010b3bbe5840446775800e4"
+}
diff --git a/packages/plugins/@nocobase/plugin-workflow-response-message/server.d.ts b/packages/plugins/@nocobase/plugin-workflow-response-message/server.d.ts
new file mode 100644
index 0000000000..c41081ddc6
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-workflow-response-message/server.d.ts
@@ -0,0 +1,2 @@
+export * from './dist/server';
+export { default } from './dist/server';
diff --git a/packages/plugins/@nocobase/plugin-workflow-response-message/server.js b/packages/plugins/@nocobase/plugin-workflow-response-message/server.js
new file mode 100644
index 0000000000..972842039a
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-workflow-response-message/server.js
@@ -0,0 +1 @@
+module.exports = require('./dist/server/index.js');
diff --git a/packages/plugins/@nocobase/plugin-workflow-response-message/src/client/ResponseMessageInstruction.tsx b/packages/plugins/@nocobase/plugin-workflow-response-message/src/client/ResponseMessageInstruction.tsx
new file mode 100644
index 0000000000..efdd6a7241
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-workflow-response-message/src/client/ResponseMessageInstruction.tsx
@@ -0,0 +1,87 @@
+/**
+ * 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 { InfoCircleOutlined } from '@ant-design/icons';
+import { Alert, Space } from 'antd';
+
+import {
+ Instruction,
+ RadioWithTooltip,
+ WorkflowVariableInput,
+ WorkflowVariableTextArea,
+} from '@nocobase/plugin-workflow/client';
+
+import { NAMESPACE } from '../locale';
+
+export default class extends Instruction {
+ title = `{{t("Response message", { ns: "${NAMESPACE}" })}}`;
+ type = 'response-message';
+ group = 'extended';
+ description = `{{t("Add response message, will be send to client when process of request ends.", { ns: "${NAMESPACE}" })}}`;
+ icon = ();
+ fieldset = {
+ message: {
+ type: 'string',
+ title: `{{t("Message content", { ns: "${NAMESPACE}" })}}`,
+ description: `{{t('Supports variables in template.', { ns: "${NAMESPACE}", name: '{{name}}' })}}`,
+ 'x-decorator': 'FormItem',
+ 'x-component': 'WorkflowVariableTextArea',
+ },
+ info: {
+ type: 'void',
+ 'x-component': 'Space',
+ 'x-component-props': {
+ direction: 'vertical',
+ },
+ properties: {
+ success: {
+ type: 'void',
+ 'x-component': 'Alert',
+ 'x-component-props': {
+ type: 'success',
+ showIcon: true,
+ description: `{{t('If the workflow ends normally, the response message will return a success status by default.', { ns: "${NAMESPACE}" })}}`,
+ },
+ },
+ failure: {
+ type: 'void',
+ 'x-component': 'Alert',
+ 'x-component-props': {
+ type: 'error',
+ showIcon: true,
+ description: `{{t('If you want to return a failure status, please add an "End Process" node downstream to terminate the workflow.', { ns: "${NAMESPACE}" })}}`,
+ },
+ },
+ },
+ },
+ };
+ scope = {};
+ components = {
+ RadioWithTooltip,
+ WorkflowVariableTextArea,
+ WorkflowVariableInput,
+ Alert,
+ Space,
+ };
+ isAvailable({ workflow, upstream, branchIndex }) {
+ return (
+ workflow.type === 'request-interception' || (['action', 'custom-action'].includes(workflow.type) && workflow.sync)
+ );
+ }
+}
diff --git a/packages/plugins/@nocobase/plugin-workflow-response-message/src/client/index.tsx b/packages/plugins/@nocobase/plugin-workflow-response-message/src/client/index.tsx
new file mode 100644
index 0000000000..258f6c8d8b
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-workflow-response-message/src/client/index.tsx
@@ -0,0 +1,31 @@
+/**
+ * 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 { Plugin } from '@nocobase/client';
+import WorkflowPlugin from '@nocobase/plugin-workflow/client';
+
+import ResponseMessageInstruction from './ResponseMessageInstruction';
+
+export class PluginWorkflowResponseMessageClient extends Plugin {
+ async load() {
+ const workflowPlugin = this.app.pm.get('workflow') as WorkflowPlugin;
+ workflowPlugin.registerInstruction('response-message', ResponseMessageInstruction);
+ }
+}
+
+export default PluginWorkflowResponseMessageClient;
diff --git a/packages/plugins/@nocobase/plugin-workflow-response-message/src/index.ts b/packages/plugins/@nocobase/plugin-workflow-response-message/src/index.ts
new file mode 100644
index 0000000000..7d69462f4f
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-workflow-response-message/src/index.ts
@@ -0,0 +1,20 @@
+/**
+ * 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
+ */
+
+export * from './server';
+export { default } from './server';
diff --git a/packages/plugins/@nocobase/plugin-workflow-response-message/src/locale/en-US.json b/packages/plugins/@nocobase/plugin-workflow-response-message/src/locale/en-US.json
new file mode 100644
index 0000000000..11832d6610
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-workflow-response-message/src/locale/en-US.json
@@ -0,0 +1,6 @@
+{
+ "Response message": "Response message",
+ "Add response message, will be send to client when process of request ends.": "Add response message, will be send to client when process of request ends.",
+ "Message content": "Message content",
+ "Supports variables in template.": "Supports variables in template."
+}
diff --git a/packages/plugins/@nocobase/plugin-workflow-response-message/src/locale/index.ts b/packages/plugins/@nocobase/plugin-workflow-response-message/src/locale/index.ts
new file mode 100644
index 0000000000..543b392f21
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-workflow-response-message/src/locale/index.ts
@@ -0,0 +1,25 @@
+/**
+ * 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';
+
+export const NAMESPACE = '@nocobase/plugin-workflow-response-message';
+
+export function lang(key: string, options = {}) {
+ return i18n.t(key, { ...options, ns: NAMESPACE });
+}
diff --git a/packages/plugins/@nocobase/plugin-workflow-response-message/src/locale/zh-CN.json b/packages/plugins/@nocobase/plugin-workflow-response-message/src/locale/zh-CN.json
new file mode 100644
index 0000000000..c8b986cace
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-workflow-response-message/src/locale/zh-CN.json
@@ -0,0 +1,8 @@
+{
+ "Response message": "响应消息",
+ "Add response message, will be send to client when process of request ends.": "添加响应消息,将在请求处理结束时发送给客户端。",
+ "Message content": "消息内容",
+ "Supports variables in template.": "支持模板变量。",
+ "If the workflow ends normally, the response message will return a success status by default.": "如果工作流正常结束,响应消息默认返回成功状态。",
+ "If you want to return a failure status, please add an \"End Process\" node downstream to terminate the workflow.": "如果希望返回失败状态,请在下游添加“结束流程”节点终止工作流。"
+}
diff --git a/packages/plugins/@nocobase/plugin-workflow-response-message/src/server/Plugin.ts b/packages/plugins/@nocobase/plugin-workflow-response-message/src/server/Plugin.ts
new file mode 100644
index 0000000000..cdd6eece72
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-workflow-response-message/src/server/Plugin.ts
@@ -0,0 +1,31 @@
+/**
+ * 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 { Plugin } from '@nocobase/server';
+import PluginWorkflowServer from '@nocobase/plugin-workflow';
+
+import ResponseMessageInstruction from './ResponseMessageInstruction';
+
+export class PluginWorkflowResponseMessageServer extends Plugin {
+ async load() {
+ const workflowPlugin = this.app.pm.get(PluginWorkflowServer) as PluginWorkflowServer;
+ workflowPlugin.registerInstruction('response-message', ResponseMessageInstruction);
+ }
+}
+
+export default PluginWorkflowResponseMessageServer;
diff --git a/packages/plugins/@nocobase/plugin-workflow-response-message/src/server/ResponseMessageInstruction.ts b/packages/plugins/@nocobase/plugin-workflow-response-message/src/server/ResponseMessageInstruction.ts
new file mode 100644
index 0000000000..e277a44e35
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-workflow-response-message/src/server/ResponseMessageInstruction.ts
@@ -0,0 +1,55 @@
+/**
+ * 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 { Instruction, Processor, JOB_STATUS, FlowNodeModel } from '@nocobase/plugin-workflow';
+
+interface Config {
+ message?: string;
+}
+
+export default class extends Instruction {
+ async run(node: FlowNodeModel, prevJob, processor: Processor) {
+ const { httpContext } = processor.options;
+
+ if (!httpContext) {
+ return {
+ status: JOB_STATUS.RESOLVED,
+ result: null,
+ };
+ }
+
+ if (!httpContext.state) {
+ httpContext.state = {};
+ }
+
+ if (!httpContext.state.messages) {
+ httpContext.state.messages = [];
+ }
+
+ const message = processor.getParsedValue(node.config.message, node.id);
+
+ if (message) {
+ httpContext.state.messages.push({ message });
+ }
+
+ return {
+ status: JOB_STATUS.RESOLVED,
+ result: message,
+ };
+ }
+}
diff --git a/packages/plugins/@nocobase/plugin-workflow-response-message/src/server/__tests__/instruction.test.ts b/packages/plugins/@nocobase/plugin-workflow-response-message/src/server/__tests__/instruction.test.ts
new file mode 100644
index 0000000000..500a70cefe
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-workflow-response-message/src/server/__tests__/instruction.test.ts
@@ -0,0 +1,346 @@
+/**
+ * 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 Database from '@nocobase/database';
+import { EXECUTION_STATUS, JOB_STATUS } from '@nocobase/plugin-workflow';
+import { getApp } from '@nocobase/plugin-workflow-test';
+import { MockServer } from '@nocobase/test';
+
+import Plugin from '..';
+
+describe.skip('workflow > instructions > response-message', () => {
+ let app: MockServer;
+ let db: Database;
+ let PostRepo;
+ let WorkflowModel;
+ let workflow;
+ let users;
+ let userAgents;
+
+ beforeEach(async () => {
+ app = await getApp({
+ plugins: ['users', 'auth', 'error-handler', 'workflow-request-interceptor', Plugin],
+ });
+
+ db = app.db;
+
+ PostRepo = db.getCollection('posts').repository;
+
+ WorkflowModel = db.getModel('workflows');
+ workflow = await WorkflowModel.create({
+ enabled: true,
+ type: 'request-interception',
+ config: {
+ global: true,
+ actions: ['create'],
+ collection: 'posts',
+ },
+ });
+
+ const UserModel = db.getCollection('users').model;
+ users = await UserModel.bulkCreate([
+ { id: 2, nickname: 'a' },
+ { id: 3, nickname: 'b' },
+ ]);
+
+ userAgents = await Promise.all(users.map((user) => app.agent().login(user)));
+ });
+
+ afterEach(() => app.destroy());
+
+ describe('no end, pass flow', () => {
+ it('no message', async () => {
+ const res1 = await userAgents[0].resource('posts').create({
+ values: { title: 't1' },
+ });
+
+ expect(res1.status).toBe(200);
+ expect(res1.body).toMatchObject({ data: { title: 't1' } });
+ expect(res1.body.messages).toBeUndefined();
+
+ const post = await PostRepo.findOne();
+ expect(post).toBeDefined();
+ expect(post.title).toBe('t1');
+
+ const [e1] = await workflow.getExecutions();
+ expect(e1.status).toBe(EXECUTION_STATUS.RESOLVED);
+ const jobs = await e1.getJobs();
+ expect(jobs.length).toBe(0);
+ });
+
+ it('has node, but null message', async () => {
+ const n1 = await workflow.createNode({
+ type: 'response-message',
+ });
+
+ const res1 = await userAgents[0].resource('posts').create({
+ values: { title: 't1' },
+ });
+
+ expect(res1.status).toBe(200);
+ expect(res1.body).toMatchObject({ data: { title: 't1' } });
+ expect(res1.body.messages).toBeUndefined();
+
+ const post = await PostRepo.findOne();
+ expect(post).toBeDefined();
+ expect(post.title).toBe('t1');
+
+ const [e1] = await workflow.getExecutions();
+ expect(e1.status).toBe(EXECUTION_STATUS.RESOLVED);
+ const jobs = await e1.getJobs();
+ expect(jobs.length).toBe(1);
+ });
+
+ it('has node, but empty message', async () => {
+ const n1 = await workflow.createNode({
+ type: 'response-message',
+ config: {
+ message: '',
+ },
+ });
+
+ const res1 = await userAgents[0].resource('posts').create({
+ values: { title: 't1' },
+ });
+
+ expect(res1.status).toBe(200);
+ expect(res1.body).toMatchObject({ data: { title: 't1' } });
+ expect(res1.body.messages).toBeUndefined();
+
+ const post = await PostRepo.findOne();
+ expect(post).toBeDefined();
+ expect(post.title).toBe('t1');
+
+ const [e1] = await workflow.getExecutions();
+ expect(e1.status).toBe(EXECUTION_STATUS.RESOLVED);
+ const jobs = await e1.getJobs();
+ expect(jobs.length).toBe(1);
+ });
+
+ it('single static message', async () => {
+ const n1 = await workflow.createNode({
+ type: 'response-message',
+ config: {
+ message: 'm1',
+ },
+ });
+ const res1 = await userAgents[0].resource('posts').create({
+ values: { title: 't1' },
+ });
+
+ expect(res1.status).toBe(200);
+ expect(res1.body).toMatchObject({ data: { title: 't1' } });
+ expect(res1.body.messages).toEqual([{ message: 'm1' }]);
+
+ const post = await PostRepo.findOne();
+ expect(post).toBeDefined();
+ expect(post.title).toBe('t1');
+
+ const [e1] = await workflow.getExecutions();
+ expect(e1.status).toBe(EXECUTION_STATUS.RESOLVED);
+ const jobs = await e1.getJobs();
+ expect(jobs.length).toBe(1);
+ });
+
+ it('multiple static messages', async () => {
+ const n1 = await workflow.createNode({
+ type: 'response-message',
+ config: {
+ message: 'm1',
+ },
+ });
+ const n2 = await workflow.createNode({
+ type: 'response-message',
+ config: {
+ message: 'm2',
+ },
+ upstreamId: n1.id,
+ });
+ await n1.setDownstream(n2);
+
+ const res1 = await userAgents[0].resource('posts').create({
+ values: { title: 't1' },
+ });
+
+ expect(res1.status).toBe(200);
+ expect(res1.body).toMatchObject({ data: { title: 't1' } });
+ expect(res1.body.messages).toEqual([{ message: 'm1' }, { message: 'm2' }]);
+
+ const post = await PostRepo.findOne();
+ expect(post).toBeDefined();
+ expect(post.title).toBe('t1');
+
+ const [e1] = await workflow.getExecutions();
+ expect(e1.status).toBe(EXECUTION_STATUS.RESOLVED);
+ const jobs = await e1.getJobs();
+ expect(jobs.length).toBe(2);
+ });
+
+ it('single dynamic message', async () => {
+ const n1 = await workflow.createNode({
+ type: 'response-message',
+ config: {
+ message: 'new post "{{ $context.params.values.title }}" by {{ $context.user.nickname }}',
+ },
+ });
+ const res1 = await userAgents[0].resource('posts').create({
+ values: { title: 't1' },
+ });
+
+ expect(res1.status).toBe(200);
+ expect(res1.body).toMatchObject({ data: { title: 't1' } });
+ expect(res1.body.messages).toEqual([{ message: `new post "t1" by ${users[0].nickname}` }]);
+
+ const post = await PostRepo.findOne();
+ expect(post).toBeDefined();
+ expect(post.title).toBe('t1');
+
+ const [e1] = await workflow.getExecutions();
+ expect(e1.status).toBe(EXECUTION_STATUS.RESOLVED);
+ const jobs = await e1.getJobs();
+ expect(jobs.length).toBe(1);
+ });
+ });
+
+ describe('end as success', () => {
+ it('no message', async () => {
+ const n1 = await workflow.createNode({
+ type: 'end',
+ config: {
+ endStatus: JOB_STATUS.RESOLVED,
+ },
+ });
+
+ const res1 = await userAgents[0].resource('posts').create({
+ values: { title: 't1' },
+ });
+
+ expect(res1.status).toBe(200);
+ expect(res1.body.data).toBeUndefined();
+ expect(res1.body.messages).toBeUndefined();
+
+ const post = await PostRepo.findOne();
+ expect(post).toBeNull();
+
+ const [e1] = await workflow.getExecutions();
+ expect(e1.status).toBe(EXECUTION_STATUS.RESOLVED);
+ const jobs = await e1.getJobs();
+ expect(jobs.length).toBe(1);
+
+ const posts = await PostRepo.find();
+ expect(posts.length).toBe(0);
+ });
+
+ it('single static message', async () => {
+ const n1 = await workflow.createNode({
+ type: 'response-message',
+ config: {
+ message: 'm1',
+ },
+ });
+
+ const n2 = await workflow.createNode({
+ type: 'end',
+ config: {
+ endStatus: JOB_STATUS.RESOLVED,
+ },
+ upstreamId: n1.id,
+ });
+
+ await n1.setDownstream(n2);
+
+ const res1 = await userAgents[0].resource('posts').create({
+ values: { title: 't1' },
+ });
+
+ expect(res1.status).toBe(200);
+ expect(res1.body.data).toBeUndefined();
+ expect(res1.body.messages).toEqual([{ message: 'm1' }]);
+
+ const post = await PostRepo.findOne();
+ expect(post).toBeNull();
+
+ const [e1] = await workflow.getExecutions();
+ expect(e1.status).toBe(EXECUTION_STATUS.RESOLVED);
+ const jobs = await e1.getJobs();
+ expect(jobs.length).toBe(2);
+ });
+ });
+
+ describe('end as failure', () => {
+ it('no message', async () => {
+ const n1 = await workflow.createNode({
+ type: 'end',
+ config: {
+ endStatus: JOB_STATUS.FAILED,
+ },
+ });
+
+ const res1 = await userAgents[0].resource('posts').create({
+ values: { title: 't1' },
+ });
+
+ expect(res1.status).toBe(400);
+ expect(res1.body.data).toBeUndefined();
+ expect(res1.body.messages).toBeUndefined();
+
+ const post = await PostRepo.findOne();
+ expect(post).toBeNull();
+
+ const [e1] = await workflow.getExecutions();
+ expect(e1.status).toBe(EXECUTION_STATUS.FAILED);
+ const jobs = await e1.getJobs();
+ expect(jobs.length).toBe(1);
+ });
+
+ it('single static message', async () => {
+ const n1 = await workflow.createNode({
+ type: 'response-message',
+ config: {
+ message: 'm1',
+ },
+ });
+
+ const n2 = await workflow.createNode({
+ type: 'end',
+ config: {
+ endStatus: JOB_STATUS.FAILED,
+ },
+ upstreamId: n1.id,
+ });
+
+ await n1.setDownstream(n2);
+
+ const res1 = await userAgents[0].resource('posts').create({
+ values: { title: 't1' },
+ });
+
+ expect(res1.status).toBe(400);
+ expect(res1.body.data).toBeUndefined();
+ expect(res1.body.errors).toEqual([{ message: 'm1' }]);
+
+ const post = await PostRepo.findOne();
+ expect(post).toBeNull();
+
+ const [e1] = await workflow.getExecutions();
+ expect(e1.status).toBe(EXECUTION_STATUS.FAILED);
+ const jobs = await e1.getJobs();
+ expect(jobs.length).toBe(2);
+ });
+ });
+});
diff --git a/packages/plugins/@nocobase/plugin-workflow-response-message/src/server/__tests__/multiple.test.ts b/packages/plugins/@nocobase/plugin-workflow-response-message/src/server/__tests__/multiple.test.ts
new file mode 100644
index 0000000000..21f207c449
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-workflow-response-message/src/server/__tests__/multiple.test.ts
@@ -0,0 +1,185 @@
+/**
+ * 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 Database from '@nocobase/database';
+import { EXECUTION_STATUS, JOB_STATUS } from '@nocobase/plugin-workflow';
+import { getApp } from '@nocobase/plugin-workflow-test';
+import { MockServer } from '@nocobase/test';
+
+import Plugin from '..';
+
+describe.skip('workflow > multiple workflows', () => {
+ let app: MockServer;
+ let db: Database;
+ let PostRepo;
+ let WorkflowModel;
+ let workflow;
+ let users;
+ let userAgents;
+
+ beforeEach(async () => {
+ app = await getApp({
+ plugins: ['users', 'auth', 'error-handler', 'workflow-request-interceptor', Plugin],
+ });
+
+ db = app.db;
+
+ PostRepo = db.getCollection('posts').repository;
+
+ WorkflowModel = db.getModel('workflows');
+ workflow = await WorkflowModel.create({
+ enabled: true,
+ type: 'request-interception',
+ config: {
+ global: true,
+ actions: ['create'],
+ collection: 'posts',
+ },
+ });
+
+ const UserModel = db.getCollection('users').model;
+ users = await UserModel.bulkCreate([
+ { id: 2, nickname: 'a' },
+ { id: 3, nickname: 'b' },
+ ]);
+
+ userAgents = await Promise.all(users.map((user) => app.agent().login(user)));
+ });
+
+ afterEach(() => app.destroy());
+
+ describe('order', () => {
+ it('workflow 2 run first and pass, workflow 1 ends as success', async () => {
+ const n1 = await workflow.createNode({
+ type: 'response-message',
+ config: {
+ message: 'm1',
+ },
+ });
+ const n2 = await workflow.createNode({
+ type: 'end',
+ config: {
+ endStatus: JOB_STATUS.RESOLVED,
+ },
+ upstreamId: n1.id,
+ });
+ await n1.setDownstream(n2);
+
+ const w2 = await WorkflowModel.create({
+ enabled: true,
+ type: 'request-interception',
+ config: {
+ action: 'create',
+ collection: 'posts',
+ },
+ });
+
+ const n3 = await w2.createNode({
+ type: 'response-message',
+ config: {
+ message: 'm2',
+ },
+ });
+
+ const res1 = await userAgents[0].resource('posts').create({
+ values: { title: 't1' },
+ triggerWorkflows: w2.key,
+ });
+
+ expect(res1.status).toBe(200);
+ expect(res1.body.data).toBeUndefined();
+ expect(res1.body.messages).toEqual([{ message: 'm2' }, { message: 'm1' }]);
+
+ const post = await PostRepo.findOne();
+ expect(post).toBeNull();
+
+ const [e1] = await workflow.getExecutions();
+ expect(e1.status).toBe(EXECUTION_STATUS.RESOLVED);
+ const j1s = await e1.getJobs();
+ expect(j1s.length).toBe(2);
+
+ const [e2] = await w2.getExecutions();
+ expect(e2.status).toBe(EXECUTION_STATUS.RESOLVED);
+ const j2s = await e2.getJobs();
+ expect(j2s.length).toBe(1);
+ });
+
+ it('local workflow in trigger key order', async () => {
+ const w1 = await WorkflowModel.create({
+ enabled: true,
+ type: 'request-interception',
+ config: {
+ action: 'create',
+ collection: 'posts',
+ },
+ });
+
+ const n1 = await w1.createNode({
+ type: 'response-message',
+ config: {
+ message: 'm1',
+ },
+ });
+
+ const w2 = await WorkflowModel.create({
+ enabled: true,
+ type: 'request-interception',
+ config: {
+ action: 'create',
+ collection: 'posts',
+ },
+ });
+
+ const n2 = await w2.createNode({
+ type: 'response-message',
+ config: {
+ message: 'm2',
+ },
+ });
+
+ const n3 = await w2.createNode({
+ type: 'end',
+ config: {
+ endStatus: JOB_STATUS.RESOLVED,
+ },
+ upstreamId: n2.id,
+ });
+
+ await n2.setDownstream(n3);
+
+ const res1 = await userAgents[0].resource('posts').create({
+ values: { title: 't1' },
+ triggerWorkflows: [w2.key, w1.key].join(),
+ });
+
+ expect(res1.status).toBe(200);
+ expect(res1.body.data).toBeUndefined();
+ expect(res1.body.messages).toEqual([{ message: 'm2' }]);
+
+ const post = await PostRepo.findOne();
+ expect(post).toBeNull();
+
+ const e1s = await w1.getExecutions();
+ expect(e1s.length).toBe(0);
+ const [e2] = await w2.getExecutions();
+ expect(e2.status).toBe(EXECUTION_STATUS.RESOLVED);
+ const jobs = await e2.getJobs();
+ expect(jobs.length).toBe(2);
+ });
+ });
+});
diff --git a/packages/plugins/@nocobase/plugin-workflow-response-message/src/server/index.ts b/packages/plugins/@nocobase/plugin-workflow-response-message/src/server/index.ts
new file mode 100644
index 0000000000..b0c269d075
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-workflow-response-message/src/server/index.ts
@@ -0,0 +1,19 @@
+/**
+ * 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
+ */
+
+export { default } from './Plugin';
diff --git a/packages/plugins/@nocobase/plugin-workflow-sql/package.json b/packages/plugins/@nocobase/plugin-workflow-sql/package.json
index 99a08424fb..de671cd25a 100644
--- a/packages/plugins/@nocobase/plugin-workflow-sql/package.json
+++ b/packages/plugins/@nocobase/plugin-workflow-sql/package.json
@@ -4,7 +4,7 @@
"displayName.zh-CN": "工作流:SQL 节点",
"description": "Execute SQL statements in workflow.",
"description.zh-CN": "可用于在工作流中对数据库执行任意 SQL 语句。",
- "version": "1.6.18",
+ "version": "1.6.24",
"license": "AGPL-3.0",
"main": "./dist/server/index.js",
"homepage": "https://docs.nocobase.com/handbook/workflow-sql",
diff --git a/packages/plugins/@nocobase/plugin-workflow-test/package.json b/packages/plugins/@nocobase/plugin-workflow-test/package.json
index c5cc8d6d72..d725590e1f 100644
--- a/packages/plugins/@nocobase/plugin-workflow-test/package.json
+++ b/packages/plugins/@nocobase/plugin-workflow-test/package.json
@@ -2,7 +2,7 @@
"name": "@nocobase/plugin-workflow-test",
"displayName": "Workflow: test kit",
"displayName.zh-CN": "工作流:测试工具包",
- "version": "1.6.18",
+ "version": "1.6.24",
"license": "AGPL-3.0",
"main": "dist/server/index.js",
"types": "./dist/server/index.d.ts",
diff --git a/packages/plugins/@nocobase/plugin-workflow-test/src/e2e/e2eCollectionModel.ts b/packages/plugins/@nocobase/plugin-workflow-test/src/e2e/e2eCollectionModel.ts
index 10709e0ae7..9dbbd3aded 100644
--- a/packages/plugins/@nocobase/plugin-workflow-test/src/e2e/e2eCollectionModel.ts
+++ b/packages/plugins/@nocobase/plugin-workflow-test/src/e2e/e2eCollectionModel.ts
@@ -882,7 +882,7 @@ export const builtinExpression = {
value: 'formula.js',
label: 'Formula.js',
tooltip: '{{t("Formula.js supports most Microsoft Excel formula functions.")}}',
- link: 'https://formulajs.info/functions/',
+ link: 'https://docs.nocobase.com/handbook/calculation-engines/formula',
},
],
default: 'formula.js',
diff --git a/packages/plugins/@nocobase/plugin-workflow/package.json b/packages/plugins/@nocobase/plugin-workflow/package.json
index 6382594960..02873b2408 100644
--- a/packages/plugins/@nocobase/plugin-workflow/package.json
+++ b/packages/plugins/@nocobase/plugin-workflow/package.json
@@ -4,13 +4,13 @@
"displayName.zh-CN": "工作流",
"description": "A powerful BPM tool that provides foundational support for business automation, with the capability to extend unlimited triggers and nodes.",
"description.zh-CN": "一个强大的 BPM 工具,为业务自动化提供基础支持,并且可任意扩展更多的触发器和节点。",
- "version": "1.6.18",
+ "version": "1.6.24",
"license": "AGPL-3.0",
"main": "./dist/server/index.js",
"homepage": "https://docs.nocobase.com/handbook/workflow",
"homepage.zh-CN": "https://docs-cn.nocobase.com/handbook/workflow",
"dependencies": {
- "@nocobase/plugin-workflow-test": "1.6.18"
+ "@nocobase/plugin-workflow-test": "1.6.24"
},
"devDependencies": {
"@ant-design/icons": "5.x",
diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client/components/TriggerCollectionRecordSelect.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client/components/TriggerCollectionRecordSelect.tsx
index ca1f085b11..42d964426c 100644
--- a/packages/plugins/@nocobase/plugin-workflow/src/client/components/TriggerCollectionRecordSelect.tsx
+++ b/packages/plugins/@nocobase/plugin-workflow/src/client/components/TriggerCollectionRecordSelect.tsx
@@ -21,19 +21,19 @@ export function TriggerCollectionRecordSelect(props) {
const [dataSourceName, collectionName] = parseCollectionName(workflow.config.collection);
const { collectionManager } = app.dataSourceManager.getDataSource(dataSourceName);
const collection = collectionManager.getCollection(collectionName);
- const render = (props) => (
+ const render = (p) => (
);
return (
@@ -42,6 +42,7 @@ export function TriggerCollectionRecordSelect(props) {
onChange={props.onChange}
nullable={false}
changeOnSelect
+ {...props}
render={render}
/>
);
diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client/variable.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client/variable.tsx
index 87d358cc34..d70b3c4e82 100644
--- a/packages/plugins/@nocobase/plugin-workflow/src/client/variable.tsx
+++ b/packages/plugins/@nocobase/plugin-workflow/src/client/variable.tsx
@@ -125,20 +125,8 @@ export const systemOptions = {
export const BaseTypeSets = {
boolean: new Set(['checkbox']),
number: new Set(['integer', 'number', 'percent']),
- string: new Set([
- 'input',
- 'password',
- 'email',
- 'phone',
- 'select',
- 'radioGroup',
- 'text',
- 'markdown',
- 'richText',
- 'expression',
- 'time',
- ]),
- date: new Set(['date', 'createdAt', 'updatedAt']),
+ string: new Set(['input', 'password', 'email', 'phone', 'select', 'radioGroup', 'text', 'markdown', 'richText']),
+ date: new Set(['datetime', 'datetimeNoTz', 'dateOnly', 'createdAt', 'updatedAt']),
};
// { type: 'reference', options: { collection: 'users', multiple: false } }
diff --git a/packages/plugins/@nocobase/plugin-workflow/src/server/triggers/ScheduleTrigger/index.ts b/packages/plugins/@nocobase/plugin-workflow/src/server/triggers/ScheduleTrigger/index.ts
index 375ef784ab..a2343e8fb2 100644
--- a/packages/plugins/@nocobase/plugin-workflow/src/server/triggers/ScheduleTrigger/index.ts
+++ b/packages/plugins/@nocobase/plugin-workflow/src/server/triggers/ScheduleTrigger/index.ts
@@ -9,6 +9,7 @@
import Trigger from '..';
import type Plugin from '../../Plugin';
+import { WorkflowModel } from '../../types';
import DateFieldScheduleTrigger from './DateFieldScheduleTrigger';
import StaticScheduleTrigger from './StaticScheduleTrigger';
import { SCHEDULE_MODE } from './utils';
@@ -67,19 +68,15 @@ export default class ScheduleTrigger extends Trigger {
// return !existed.length;
// }
- validateContext(values) {
- if (!values?.mode) {
- return {
- mode: 'Mode is required',
- };
- }
- const trigger = this.getTrigger(values.mode);
+ validateContext(values, workflow: WorkflowModel) {
+ const { mode } = workflow.config;
+ const trigger = this.getTrigger(mode);
if (!trigger) {
return {
mode: 'Mode in invalid',
};
}
- return trigger.validateContext?.(values);
+ return trigger.validateContext?.(values, workflow);
}
}
diff --git a/packages/plugins/@nocobase/plugin-workflow/src/server/triggers/index.ts b/packages/plugins/@nocobase/plugin-workflow/src/server/triggers/index.ts
index 24c01d874e..50e462fd33 100644
--- a/packages/plugins/@nocobase/plugin-workflow/src/server/triggers/index.ts
+++ b/packages/plugins/@nocobase/plugin-workflow/src/server/triggers/index.ts
@@ -20,7 +20,7 @@ export abstract class Trigger {
return true;
}
duplicateConfig?(workflow: WorkflowModel, options: Transactionable): object | Promise