+
.x-button {
+ height: min-content;
}
`,
)}
- ref={inputRef}
- contentEditable={!disabled}
- dangerouslySetInnerHTML={{ __html: html }}
- />
-
- ,
+ >
+ {addonBefore && (
+
+ {addonBefore}
+
+ )}
+
+
+
+ {/* 确保所有ant input样式都已加载, 放到Compact中会导致Compact中的Input样式不对 */}
+
+ >,
);
}
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..2f2400e325
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-departments/package.json
@@ -0,0 +1,19 @@
+{
+ "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.7.0-alpha.10",
+ "main": "dist/server/index.js",
+ "peerDependencies": {
+ "@nocobase/actions": "1.x",
+ "@nocobase/client": "1.x",
+ "@nocobase/server": "1.x",
+ "@nocobase/test": "1.x"
+ },
+ "keywords": [
+ "Users & permissions"
+ ],
+ "gitHead": "ce89d10eec858c413f60e001e83c7c8cf2645f5e"
+}
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..e1e3a768d2
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-departments/src/client/departments/DepartmentTable.tsx
@@ -0,0 +1,263 @@
+/**
+ * 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, TableProps } 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),
+ },
+ ] as TableProps['columns']
+ }
+ 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..8b4cd926da
--- /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: ['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..64e73eb978
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-departments/src/server/__tests__/data-sync.test.ts
@@ -0,0 +1,281 @@
+/**
+ * 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 { UserDataResourceManager } from '@nocobase/plugin-user-data-sync';
+import { MockDatabase, MockServer, createMockServer } from '@nocobase/test';
+import PluginUserDataSyncServer from '@nocobase/plugin-user-data-sync';
+
+describe('department data sync', async () => {
+ let app: MockServer;
+ let db: MockDatabase;
+ let resourceManager: UserDataResourceManager;
+
+ beforeEach(async () => {
+ app = await createMockServer({
+ plugins: ['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: 'user',
+ matchKey: 'email',
+ records: [
+ {
+ uid: '1',
+ nickname: 'test',
+ email: 'test@nocobase.com',
+ customField: 'testField2',
+ },
+ ],
+ });
+ const department2 = await db.getRepository('users').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..02e85c4f02
--- /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: ['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..047b024708
--- /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: ['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..987d77c938
--- /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: ['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..c65750f36b
--- /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: ['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..71c91b39b9
--- /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: ['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('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-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..87082d9a9e
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-field-attachment-url/package.json
@@ -0,0 +1,17 @@
+{
+ "name": "@nocobase/plugin-field-attachment-url",
+ "version": "1.7.0-alpha.10",
+ "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"
+ },
+ "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-localization/src/server/plugin.ts b/packages/plugins/@nocobase/plugin-localization/src/server/plugin.ts
index 7bac0fcf63..8d04cac1b3 100644
--- a/packages/plugins/@nocobase/plugin-localization/src/server/plugin.ts
+++ b/packages/plugins/@nocobase/plugin-localization/src/server/plugin.ts
@@ -25,7 +25,7 @@ export class PluginLocalizationServer extends Plugin {
addNewTexts = async (texts: { text: string; module: string }[], options?: any) => {
texts = await this.resources.filterExists(texts, options?.transaction);
- this.db
+ await this.db
.getModel('localizationTexts')
.bulkCreate(
texts.map(({ text, module }) => ({
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..491a82d89c 100644
--- a/packages/plugins/@nocobase/plugin-workflow-manual/src/client/WorkflowTodo.tsx
+++ b/packages/plugins/@nocobase/plugin-workflow-manual/src/client/WorkflowTodo.tsx
@@ -33,9 +33,12 @@ import {
useActionContext,
useCurrentUserContext,
useFormBlockContext,
- useTableBlockContext,
+ useListBlockContext,
List,
OpenModeProvider,
+ ActionContextProvider,
+ useRequest,
+ CollectionRecordProvider,
} from '@nocobase/client';
import WorkflowPlugin, {
DetailsBlockProvider,
@@ -46,12 +49,15 @@ import WorkflowPlugin, {
EXECUTION_STATUS,
JOB_STATUS,
WorkflowTitle,
+ TASK_STATUS,
+ usePopupRecordContext,
} 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 { useNavigate, useParams } from 'react-router-dom';
function TaskStatusColumn(props) {
const recordData = useCollectionRecordData();
@@ -291,11 +297,12 @@ function useSubmit() {
const { values, submit } = useForm();
const field = useField();
const buttonSchema = useFieldSchema();
- const { service } = useTableBlockContext();
+ const { service } = useListBlockContext();
const { userJob, execution } = useFlowContext();
const { name: actionKey } = buttonSchema;
const { name: formKey } = buttonSchema.parent.parent;
const { assignedValues = {} } = buttonSchema?.['x-action-settings'] ?? {};
+
return {
async run() {
if (execution.status || userJob.status) {
@@ -611,57 +618,37 @@ function ContentDetailWithTitle(props) {
function TaskItem() {
const token = useAntdToken();
- const [visible, setVisible] = useState(false);
const record = useCollectionRecordData();
- const { t } = useTranslation();
- // const { defaultOpenMode } = useOpenModeContext();
- // const { openPopup } = usePopupUtils();
- // const { isPopupVisibleControlledByURL } = usePopupSettings();
- const onOpen = useCallback((e: React.MouseEvent) => {
- const targetElement = e.target as Element; // 将事件目标转换为Element类型
- const currentTargetElement = e.currentTarget as Element;
- if (currentTargetElement.contains(targetElement)) {
- setVisible(true);
- // if (!isPopupVisibleControlledByURL()) {
- // } else {
- // openPopup({
- // // popupUidUsedInURL: 'job',
- // customActionSchema: {
- // type: 'void',
- // 'x-uid': 'job-view',
- // 'x-action-context': {
- // dataSource: 'main',
- // collection: 'workflowManualTasks',
- // doNotUpdateContext: true,
- // },
- // properties: {},
- // },
- // });
- // }
- }
- e.stopPropagation();
- }, []);
+ const navigate = useNavigate();
+ const { setRecord } = usePopupRecordContext();
+ const onOpen = useCallback(
+ (e: React.MouseEvent) => {
+ const targetElement = e.target as Element; // 将事件目标转换为Element类型
+ const currentTargetElement = e.currentTarget as Element;
+ if (currentTargetElement.contains(targetElement)) {
+ setRecord(record);
+ navigate(`./${record.id}`);
+ }
+ e.stopPropagation();
+ },
+ [navigate, record.id],
+ );
return (
- <>
- }
- className={css`
- .ant-card-extra {
- color: ${token.colorTextDescription};
- }
- `}
- >
-
-
-
-
-
- >
+ }
+ className={css`
+ .ant-card-extra {
+ color: ${token.colorTextDescription};
+ }
+ `}
+ >
+
+
);
}
@@ -734,7 +721,9 @@ function TodoExtraActions() {
export const manualTodo = {
title: `{{t("My manual tasks", { ns: "${NAMESPACE}" })}}`,
collection: 'workflowManualTasks',
+ action: 'listMine',
useActionParams: useTodoActionParams,
- component: TaskItem,
- extraActions: TodoExtraActions,
+ Actions: TodoExtraActions,
+ Item: TaskItem,
+ Detail: Drawer,
};
diff --git a/packages/plugins/@nocobase/plugin-workflow-manual/src/server/Plugin.ts b/packages/plugins/@nocobase/plugin-workflow-manual/src/server/Plugin.ts
index 7338d0958e..a63b5aba87 100644
--- a/packages/plugins/@nocobase/plugin-workflow-manual/src/server/Plugin.ts
+++ b/packages/plugins/@nocobase/plugin-workflow-manual/src/server/Plugin.ts
@@ -16,13 +16,14 @@ import * as jobActions from './actions';
import ManualInstruction from './ManualInstruction';
import { MANUAL_TASK_TYPE } from '../common/constants';
+import { Model } from '@nocobase/database';
-interface WorkflowManualTaskModel {
- id: number;
- userId: number;
- workflowId: number;
- executionId: number;
- status: number;
+class WorkflowManualTaskModel extends Model {
+ declare id: number;
+ declare userId: number;
+ declare workflowId: number;
+ declare executionId: number;
+ declare status: number;
}
export default class extends Plugin {
@@ -55,7 +56,13 @@ export default class extends Plugin {
const workflowPlugin = this.app.pm.get(WorkflowPlugin) as WorkflowPlugin;
workflowPlugin.registerInstruction('manual', ManualInstruction);
- this.db.on('workflowManualTasks.afterSave', async (task: WorkflowManualTaskModel, options) => {
+ this.db.on('workflowManualTasks.afterSave', async (task: WorkflowManualTaskModel, { transaction }) => {
+ // const allCount = await (task.constructor as typeof WorkflowManualTaskModel).count({
+ // where: {
+ // userId: task.userId,
+ // },
+ // transaction,
+ // });
await workflowPlugin.toggleTaskStatus(
{
type: MANUAL_TASK_TYPE,
@@ -63,8 +70,9 @@ export default class extends Plugin {
userId: task.userId,
workflowId: task.workflowId,
},
- Boolean(task.status),
- options,
+ task.status === JOB_STATUS.PENDING,
+ // allCount,
+ { transaction },
);
});
}
diff --git a/packages/plugins/@nocobase/plugin-workflow-manual/src/server/actions.ts b/packages/plugins/@nocobase/plugin-workflow-manual/src/server/actions.ts
index 33dccdbeb5..a9ac4c9768 100644
--- a/packages/plugins/@nocobase/plugin-workflow-manual/src/server/actions.ts
+++ b/packages/plugins/@nocobase/plugin-workflow-manual/src/server/actions.ts
@@ -7,7 +7,7 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
-import { Context, utils } from '@nocobase/actions';
+import actions, { Context, utils } from '@nocobase/actions';
import WorkflowPlugin, { EXECUTION_STATUS, JOB_STATUS } from '@nocobase/plugin-workflow';
import ManualInstruction from './ManualInstruction';
@@ -111,3 +111,24 @@ export async function submit(context: Context, next) {
plugin.resume(task.job);
}
+
+export async function listMine(context, next) {
+ context.action.mergeParams({
+ filter: {
+ userId: context.state.currentUser.id,
+ $or: [
+ {
+ 'workflow.enabled': true,
+ },
+ {
+ 'workflow.enabled': false,
+ status: {
+ $ne: JOB_STATUS.PENDING,
+ },
+ },
+ ],
+ },
+ });
+
+ return actions.list(context, next);
+}
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..be505e66a4
--- /dev/null
+++ b/packages/plugins/@nocobase/plugin-workflow-response-message/package.json
@@ -0,0 +1,21 @@
+{
+ "name": "@nocobase/plugin-workflow-response-message",
+ "version": "1.7.0-alpha.10",
+ "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"
+ },
+ "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..3d4c4caf10
--- /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 { MockServer } from '@nocobase/test';
+import { EXECUTION_STATUS, JOB_STATUS } from '@nocobase/plugin-workflow';
+import { getApp } from '@nocobase/plugin-workflow-test';
+
+import Plugin from '..';
+
+describe('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..a97be3e1d6
--- /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 { MockServer } from '@nocobase/test';
+import { EXECUTION_STATUS, JOB_STATUS } from '@nocobase/plugin-workflow';
+import { getApp } from '@nocobase/plugin-workflow-test';
+
+import Plugin from '..';
+
+describe('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/src/client/WorkflowTasks.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client/WorkflowTasks.tsx
index 6d0f60a1a0..17d435fa26 100644
--- a/packages/plugins/@nocobase/plugin-workflow/src/client/WorkflowTasks.tsx
+++ b/packages/plugins/@nocobase/plugin-workflow/src/client/WorkflowTasks.tsx
@@ -11,14 +11,17 @@ import { PageHeader } from '@ant-design/pro-layout';
import { Badge, Button, Layout, Menu, Tabs, Tooltip } from 'antd';
import classnames from 'classnames';
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
-import { Link, Outlet, useNavigate, useParams } from 'react-router-dom';
+import { Link, useNavigate, useParams } from 'react-router-dom';
import {
+ ActionContextProvider,
+ CollectionRecordProvider,
css,
PinnedPluginListProvider,
SchemaComponent,
SchemaComponentContext,
SchemaComponentOptions,
+ useAPIClient,
useApp,
useCompile,
useDocumentTitle,
@@ -44,9 +47,11 @@ const contentClass = css`
export interface TaskTypeOptions {
title: string;
collection: string;
+ action: string;
useActionParams: Function;
- component: React.ComponentType;
- extraActions?: React.ComponentType;
+ Actions?: React.ComponentType;
+ Item: React.ComponentType;
+ Detail: React.ComponentType;
// children?: TaskTypeOptions[];
}
@@ -65,7 +70,7 @@ function MenuLink({ type }: any) {
return (
,
+ right: ,
}
: {}
}
@@ -157,16 +162,45 @@ function useCurrentTaskType() {
);
}
+function PopupContext(props: any) {
+ const { popupId } = useParams();
+ const { record } = usePopupRecordContext();
+ const navigate = useNavigate();
+ if (!popupId) {
+ return null;
+ }
+ return (
+ {
+ if (!visible) {
+ navigate(-1);
+ }
+ }}
+ openMode="modal"
+ >
+ {props.children}
+
+ );
+}
+
+const PopupRecordContext = createContext({ record: null, setRecord: (record) => {} });
+export function usePopupRecordContext() {
+ return useContext(PopupRecordContext);
+}
+
export function WorkflowTasks() {
const compile = useCompile();
const { setTitle } = useDocumentTitle();
const navigate = useNavigate();
- const { taskType, status = TASK_STATUS.PENDING } = useParams();
+ const apiClient = useAPIClient();
+ const { taskType, status = TASK_STATUS.PENDING, popupId } = useParams();
const { token } = useToken();
+ const [currentRecord, setCurrentRecord] = useState(null);
const items = useTaskTypeItems();
- const { title, collection, useActionParams, component: Component } = useCurrentTaskType();
+ const { title, collection, action = 'list', useActionParams, Item, Detail } = useCurrentTaskType();
const params = useActionParams(status);
@@ -180,6 +214,24 @@ export function WorkflowTasks() {
}
}, [items, navigate, status, taskType]);
+ useEffect(() => {
+ if (popupId && !currentRecord) {
+ apiClient
+ .resource(collection)
+ .get({
+ filterByTk: popupId,
+ })
+ .then((res) => {
+ if (res.data?.data) {
+ setCurrentRecord(res.data.data);
+ }
+ })
+ .catch((err) => {
+ console.error(err);
+ });
+ }
+ }, [popupId, collection, currentRecord, apiClient]);
+
const typeKey = taskType ?? items[0].key;
return (
@@ -205,84 +257,95 @@ export function WorkflowTasks() {
}
`}
>
-
-
+
+ .itemCss:not(:last-child) {
- border-bottom: none;
- }
- `,
- locale: {
- emptyText: `{{ t("No data yet", { ns: "${NAMESPACE}" }) }}`,
- },
+ properties: {
+ header: {
+ type: 'void',
+ 'x-component': 'PageHeader',
+ 'x-component-props': {
+ className: classnames('pageHeaderCss'),
+ style: {
+ background: token.colorBgContainer,
+ padding: '12px 24px 0 24px',
},
- properties: {
- item: {
- type: 'object',
- 'x-decorator': 'List.Item',
- 'x-component': Component,
- 'x-read-pretty': true,
+ title,
+ },
+ properties: {
+ tabs: {
+ type: 'void',
+ 'x-component': 'StatusTabs',
+ },
+ },
+ },
+ content: {
+ type: 'void',
+ 'x-component': 'Layout.Content',
+ 'x-component-props': {
+ className: contentClass,
+ style: {
+ padding: `${token.paddingPageVertical}px ${token.paddingPageHorizontal}px`,
+ },
+ },
+ properties: {
+ list: {
+ type: 'array',
+ 'x-component': 'List',
+ 'x-component-props': {
+ className: css`
+ > .itemCss:not(:last-child) {
+ border-bottom: none;
+ }
+ `,
+ locale: {
+ emptyText: `{{ t("No data yet", { ns: "${NAMESPACE}" }) }}`,
+ },
+ },
+ properties: {
+ item: {
+ type: 'object',
+ 'x-decorator': 'List.Item',
+ 'x-component': Item,
+ 'x-read-pretty': true,
+ },
},
},
},
},
+ popup: {
+ type: 'void',
+ 'x-decorator': PopupContext,
+ 'x-component': Detail,
+ },
},
- },
- }}
- />
-
-
+ }}
+ />
+
+
);
@@ -296,7 +359,7 @@ function WorkflowTasksLink() {
return types.length ? (