feat: make commercial plugins free (#6663)

* feat: make commercial plugins free

* fix: rm pkg

* fix: deps

* fix: deps

* fix: deps

* fix: test error

* fix: test error

* fix: test error
This commit is contained in:
chenos 2025-04-14 22:56:34 +08:00 committed by GitHub
parent 7c1ccc73f6
commit 1db56657e8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
112 changed files with 8766 additions and 2 deletions

View File

@ -460,8 +460,16 @@ exports.initEnv = function initEnv() {
process.env.SOCKET_PATH = generateGatewayPath();
fs.mkdirpSync(dirname(process.env.SOCKET_PATH), { force: true, recursive: true });
fs.mkdirpSync(process.env.PM2_HOME, { force: true, recursive: true });
const pkgDir = resolve(process.cwd(), 'storage/plugins', '@nocobase/plugin-multi-app-manager');
const pkgs = [
'@nocobase/plugin-multi-app-manager',
'@nocobase/plugin-departments',
'@nocobase/plugin-field-attachment-url',
'@nocobase/plugin-workflow-response-message',
];
for (const pkg of pkgs) {
const pkgDir = resolve(process.cwd(), 'storage/plugins', pkg);
fs.existsSync(pkgDir) && fs.rmdirSync(pkgDir, { recursive: true, force: true });
}
};
exports.generatePlugins = function () {

View File

@ -0,0 +1,2 @@
/node_modules
/src

View File

@ -0,0 +1 @@
# @nocobase/plugin-department

View File

@ -0,0 +1,2 @@
export * from './dist/client';
export { default } from './dist/client';

View File

@ -0,0 +1 @@
module.exports = require('./dist/client/index.js');

View File

@ -0,0 +1,22 @@
{
"name": "@nocobase/plugin-departments",
"displayName": "Departments",
"displayName.zh-CN": "部门",
"description": "Organize users by departments, set hierarchical relationships, link roles to control permissions, and use departments as variables in workflows and expressions.",
"description.zh-CN": "以部门来组织用户,设定上下级关系,绑定角色控制权限,并支持作为变量用于工作流和表达式。",
"version": "1.6.19",
"main": "dist/server/index.js",
"devDependencies": {
"@nocobase/plugin-user-data-sync": "1.x"
},
"peerDependencies": {
"@nocobase/actions": "1.x",
"@nocobase/client": "1.x",
"@nocobase/server": "1.x",
"@nocobase/test": "1.x"
},
"keywords": [
"Users & permissions"
],
"gitHead": "080fc78c1a744d47e010b3bbe5840446775800e4"
}

View File

@ -0,0 +1,2 @@
export * from './dist/server';
export { default } from './dist/server';

View File

@ -0,0 +1 @@
module.exports = require('./dist/server/index.js');

View File

@ -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 <https://www.nocobase.com/agreement>
*/
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 (
<ResourcesContext.Provider
value={{
user,
setUser,
department,
setDepartment,
usersResource: { service: userService },
departmentsResource: { service: departmentService },
}}
>
{props.children}
</ResourcesContext.Provider>
);
};
export const DepartmentsListProvider: React.FC = (props) => {
const { departmentsResource } = useContext(ResourcesContext);
const { service } = departmentsResource || {};
return (
<ResourceActionContext.Provider value={{ ...service }}>
<CollectionProvider_deprecated collection={departmentCollection}>{props.children}</CollectionProvider_deprecated>
</ResourceActionContext.Provider>
);
};
export const UsersListProvider: React.FC = (props) => {
const { usersResource } = useContext(ResourcesContext);
const { service } = usersResource || {};
const form = useMemo(() => createForm(), []);
const field = form.createField({ name: 'table' });
return (
<FormContext.Provider value={form}>
<TableBlockContext.Provider value={{ service, field }}>
<CollectionProvider_deprecated collection={userCollection}>{props.children}</CollectionProvider_deprecated>
</TableBlockContext.Provider>
</FormContext.Provider>
);
};

View File

@ -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 <https://www.nocobase.com/agreement>
*/
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',
},
},
},
},
],
};

View File

@ -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 <https://www.nocobase.com/agreement>
*/
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',
},
},
],
};

View File

@ -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 <https://www.nocobase.com/agreement>
*/
import { SchemaSettings } from '@nocobase/client';
import { enableLink, fieldComponent, titleField } from './fieldSettings';
export const DepartmentOwnersFieldSettings = new SchemaSettings({
name: 'fieldSettings:component:DepartmentOwnersField',
items: [
{
...fieldComponent,
},
{
...titleField,
},
{
...enableLink,
},
],
});

View File

@ -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 <https://www.nocobase.com/agreement>
*/
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 <div style={{ color: '#ccc' }}>{t('This field is currently not supported for use in form blocks.')} </div>;
}, mapReadPretty(AssociationField.ReadPretty));

View File

@ -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 <https://www.nocobase.com/agreement>
*/
import { SchemaSettings } from '@nocobase/client';
import { enableLink, fieldComponent, titleField } from './fieldSettings';
export const UserDepartmentsFieldSettings = new SchemaSettings({
name: 'fieldSettings:component:UserDepartmentsField',
items: [
{
...fieldComponent,
},
{
...titleField,
},
{
...enableLink,
},
],
});

View File

@ -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 <https://www.nocobase.com/agreement>
*/
import { SchemaSettings } from '@nocobase/client';
import { enableLink, fieldComponent, titleField } from './fieldSettings';
export const UserMainDepartmentFieldSettings = new SchemaSettings({
name: 'fieldSettings:component:UserMainDepartmentField',
items: [
{
...fieldComponent,
},
{
...titleField,
},
{
...enableLink,
},
],
});

View File

@ -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 <https://www.nocobase.com/agreement>
*/
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<Field>();
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<Field>();
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<Field>();
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();
},
};
},
};

View File

@ -0,0 +1,22 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This program is offered under a commercial license.
* For more information, see <https://www.nocobase.com/agreement>
*/
export * from './ReadOnlyAssociationField';
export * from './UserDepartmentsField';
export * from './UserMainDepartmentField';
export * from './DepartmentOwnersField';

View File

@ -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 <https://www.nocobase.com/agreement>
*/
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 (
<Button
type="link"
style={{ padding: '0 8px' }}
onClick={(e) => {
setOpen(true);
run({
values: { keyword, limit, ...props },
});
}}
>
{t('Load more')}
</Button>
);
};
const getItems = () => {
const items: MenuProps['items'] = [];
if (!users.length && !departments.length) {
return [
{
key: '0',
label: <Empty description={t('No results')} image={Empty.PRESENTED_IMAGE_SIMPLE} />,
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: (
<div onClick={() => setUser(user)}>
<div>{user.nickname || user.username}</div>
<div
style={{
fontSize: token.fontSizeSM,
color: token.colorTextDescription,
}}
>
{`${user.username}${user.phone ? ' | ' + user.phone : ''}${user.email ? ' | ' + user.email : ''}`}
</div>
</div>
),
})),
});
if (moreUsers) {
items.push({
type: 'group',
key: '0-loadMore',
label: <LoadMore type="user" last={users[users.length - 1].id} />,
});
}
}
if (departments.length) {
items.push({
key: '1',
type: 'group',
label: t('Departments'),
children: departments.map((department: any) => ({
key: department.id,
label: <div onClick={() => setDepartment(department)}>{getTitle(department)}</div>,
})),
});
if (moreDepartments) {
items.push({
type: 'group',
key: '1-loadMore',
label: <LoadMore type="department" last={departments[departments.length - 1].id} />,
});
}
}
return items;
};
return (
<Dropdown
menu={{ items: getItems() }}
overlayClassName={styles.searchDropdown}
trigger={['click']}
open={open}
onOpenChange={(open) => setOpen(open)}
>
<Input.Search
allowClear
onClick={() => {
if (!keyword) {
setOpen(false);
}
}}
onFocus={() => setDepartment(null)}
onSearch={handleSearch}
onChange={handleChange}
placeholder={t('Search for departments, users')}
style={{ marginBottom: '20px' }}
/>
</Dropdown>
);
};

View File

@ -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 <https://www.nocobase.com/agreement>
*/
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<typeof useDepartmentManager>);
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 }) => <DepartmentTree.Item node={node} setVisible={setVisible} setDrawer={setDrawer} />,
});
return (
<SchemaComponentOptions scope={{ useCreateDepartment, useUpdateDepartment }}>
<DepartmentTreeContext.Provider value={departmentManager}>
<Row>
<AggregateSearch />
<Button
type="text"
icon={<UserOutlined />}
style={{
textAlign: 'left',
marginBottom: '5px',
background: department ? '' : token.colorBgTextHover,
}}
onClick={() => {
setDepartment(null);
}}
block
>
{t('All users')}
</Button>
<NewDepartment />
</Row>
<Divider style={{ margin: '12px 0' }} />
<DepartmentTree />
<ActionContextProvider value={{ visible, setVisible }}>
<RecordProvider record={drawer.node || {}}>
<SchemaComponent scope={{ t }} components={{ DepartmentOwnersField }} schema={drawer.schema || {}} />
</RecordProvider>
</ActionContextProvider>
</DepartmentTreeContext.Provider>
</SchemaComponentOptions>
);
};

View File

@ -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 <https://www.nocobase.com/agreement>
*/
import React from 'react';
import { SchemaComponent } from '@nocobase/client';
import { DepartmentManagement } from './DepartmentManagement';
import { uid } from '@formily/shared';
export const DepartmentBlock: React.FC = () => {
return (
<SchemaComponent
components={{ DepartmentManagement }}
schema={{
type: 'void',
properties: {
[uid()]: {
type: 'void',
'x-decorator': 'CardItem',
'x-component': 'DepartmentManagement',
},
},
}}
/>
);
};

View File

@ -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 <https://www.nocobase.com/agreement>
*/
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<Field>();
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) => (
<span key={index}>
<a
onClick={(e) => {
e.preventDefault();
setDepartment(deptsMap[dept.id]);
}}
>
{getDepartmentTitle(dept)}
</a>
{index !== values.length - 1 ? <span style={{ marginRight: 4, color: '#aaa' }}>,</span> : ''}
</span>
));
return <EllipsisWithTooltip ellipsis={true}>{depts}</EllipsisWithTooltip>;
};

View File

@ -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 <https://www.nocobase.com/agreement>
*/
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 (
<SchemaComponentOptions components={{ SuperiorDepartmentSelect, DepartmentSelect }}>
<Row gutter={48} style={{ flexWrap: 'nowrap' }}>
<Col span={6} style={{ borderRight: '1px solid #eee', minWidth: '300px' }}>
<DepartmentsListProvider>
<Department />
</DepartmentsListProvider>
</Col>
<Col flex="auto" style={{ overflow: 'hidden' }}>
<UsersListProvider>
<Member />
</UsersListProvider>
</Col>
</Row>
</SchemaComponentOptions>
);
};

View File

@ -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 <https://www.nocobase.com/agreement>
*/
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<Field>();
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) => (
<ResourceActionProvider
collection="users"
request={{
resource: `departments/${department.id}/members`,
action: 'list',
params: {
filter: field.value?.length
? {
id: {
$notIn: field.value.map((owner: any) => owner.id),
},
}
: {},
},
}}
>
{props.children}
</ResourceActionProvider>
);
return (
<ActionContextProvider value={{ visible, setVisible }}>
<Select
open={false}
onChange={(value) => {
if (!value) {
field.setValue([]);
return;
}
field.setValue(
value.map(({ label, value }: { label: string; value: string }) => ({
id: value,
nickname: label,
})),
);
}}
mode="multiple"
value={value}
labelInValue={true}
onDropdownVisibleChange={(open) => setVisible(open)}
/>
<SchemaComponent
schema={departmentOwnersSchema}
components={{ RequestProvider }}
scope={{ department, handleSelect, useSelectOwners }}
/>
</ActionContextProvider>
);
};

View File

@ -0,0 +1,261 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This program is offered under a commercial license.
* For more information, see <https://www.nocobase.com/agreement>
*/
import {
CollectionContext,
CollectionProvider_deprecated,
ResourceActionContext,
SchemaComponent,
mergeFilter,
removeNullCondition,
useFilterFieldOptions,
useFilterFieldProps,
useResourceActionContext,
} from '@nocobase/client';
import React, { createContext, useContext, useEffect, useState } from 'react';
import { useDepartmentManager } from '../hooks';
import { Table, TablePaginationConfig } from 'antd';
import { departmentCollection } from '../collections/departments';
import { useDepartmentTranslation } from '../locale';
import { useField } from '@formily/react';
import { Field } from '@formily/core';
import { uid } from '@formily/shared';
import { getDepartmentTitle } from '../utils';
const ExpandMetaContext = createContext<any>({});
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<Field>();
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<Field>();
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 (
<Table
rowKey="id"
columns={[
{
dataIndex: 'title',
title: t('Department name'),
render: (text, record) => (hasFilter ? getDepartmentTitle(record) : text),
},
]}
rowSelection={{
selectedRowKeys: (field?.value || []).map((dept: any) => dept.id),
onChange: (keys, depts) => field?.setValue?.(depts),
getCheckboxProps: (record: any) => ({
disabled: disabled(record),
}),
}}
pagination={{
showSizeChanger: true,
...pagination,
onChange(page, pageSize) {
run({
...(ctx?.params?.[0] || {}),
page,
pageSize,
});
},
}}
dataSource={hasFilter ? data?.data || [] : treeData}
expandable={{
onExpand: (expanded, record) => {
loadData({
key: record.id,
children: record.children,
});
},
expandedRowKeys: expandedKeys,
onExpandedRowsChange: (keys) => setExpandedKeys(keys),
}}
/>
);
};
const RequestProvider: React.FC<{
useDataSource: any;
}> = (props) => {
const [expandedKeys, setExpandedKeys] = useState([]);
const [hasFilter, setHasFilter] = useState(false);
const { useDataSource } = props;
const service = useDataSource({
manual: true,
});
useEffect(() => {
service.run({
filter: {
parentId: null,
},
pageSize: 10,
});
}, []);
return (
<ResourceActionContext.Provider value={{ ...service }}>
<CollectionProvider_deprecated collection={departmentCollection}>
<ExpandMetaContext.Provider value={{ expandedKeys, setExpandedKeys, hasFilter, setHasFilter }}>
{props.children}
</ExpandMetaContext.Provider>
</CollectionProvider_deprecated>
</ResourceActionContext.Provider>
);
};
export const DepartmentTable: React.FC<{
useDataSource: any;
useDisabled?: (record: any) => boolean;
}> = ({ useDataSource, useDisabled }) => {
return (
<SchemaComponent
scope={{ useDisabled, useFilterActionProps }}
components={{ InternalDepartmentTable, RequestProvider }}
schema={{
type: 'void',
properties: {
[uid()]: {
type: 'void',
'x-component': 'RequestProvider',
'x-component-props': {
useDataSource,
},
properties: {
actions: {
type: 'void',
'x-component': 'ActionBar',
'x-component-props': {
style: {
marginBottom: 16,
},
},
properties: {
filter: {
type: 'void',
title: '{{ t("Filter") }}',
default: {
$and: [{ title: { $includes: '' } }],
},
'x-action': 'filter',
'x-component': 'Filter.Action',
'x-use-component-props': 'useFilterActionProps',
'x-component-props': {
icon: 'FilterOutlined',
},
'x-align': 'left',
},
},
},
departments: {
type: 'array',
'x-component': 'InternalDepartmentTable',
'x-component-props': {
useDisabled: '{{ useDisabled }}',
},
},
},
},
},
}}
/>
);
};

View File

@ -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 <https://www.nocobase.com/agreement>
*/
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<DepartmentTreeProps>;
} = () => {
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 (
<div
className={css`
height: 65vh;
overflow: auto;
.ant-tree-node-content-wrapper {
overflow: hidden;
}
`}
>
{treeData?.length ? (
<Tree.DirectoryTree
loadData={loadData}
treeData={treeData}
loadedKeys={loadedKeys}
onSelect={handleSelect}
selectedKeys={[department?.id]}
onExpand={handleExpand}
onLoad={handleLoad}
expandedKeys={expandedKeys}
expandAction={false}
showIcon={false}
fieldNames={{
key: 'id',
}}
/>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
</div>
);
};
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 (
<div style={{ display: 'flex', justifyContent: 'space-between', overflow: 'hidden' }}>
<div style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{node.title}</div>
<Dropdown
menu={{
items: [
{
label: t('New sub department'),
key: 'new-sub',
},
{
label: t('Edit department'),
key: 'edit',
},
{
label: t('Delete department'),
key: 'delete',
},
],
onClick: handleClick,
}}
>
<div style={{ marginLeft: '15px' }}>
<MoreOutlined />
</div>
</Dropdown>
</div>
);
};

View File

@ -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 <https://www.nocobase.com/agreement>
*/
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<Field>();
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 (
<TreeSelect
value={value}
onSelect={(_: any, node: any) => {
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 <DepartmentTreeSelect {...departmentManager} originData={data?.data} />;
};
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 <DepartmentTreeSelect {...departmentManager} originData={data?.data} />;
};

View File

@ -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 <https://www.nocobase.com/agreement>
*/
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 <Checkbox.ReadPretty value={dept?.departmentsUsers.isOwner} />;
};

View File

@ -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 <https://www.nocobase.com/agreement>
*/
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 (
<ResourceActionProvider
{...{
collection: 'users',
request: {
resource: 'users',
action: 'listExcludeDept',
params: {
departmentId: department?.id,
},
},
}}
>
{props.children}
</ResourceActionProvider>
);
};
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 (
<SchemaComponent
scope={{
useAddMembersActionProps,
department,
handleSelect,
useAddMembersFilterActionProps,
}}
components={{ AddMembersListProvider }}
schema={addMembersSchema}
/>
);
};
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 ? <SchemaComponent scope={{ useRemoveMemberAction }} schema={rowRemoveActionSchema} /> : null;
};
const MemberActions = () => {
const { department } = useContext(ResourcesContext);
return department ? <SchemaComponent schema={membersActionSchema} /> : 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 ? <h2>{t(department?.title || 'All users')}</h2> : <h2>{t('Search results')}</h2>}
<SchemaComponent
scope={{
useBulkRemoveMembersAction,
t,
useShowTotal,
useRefreshActionProps,
useMemberFilterActionProps,
useTableBlockProps,
}}
components={{
MemberActions,
AddMembers,
RowRemoveAction,
DepartmentField,
IsOwnerField,
UserDepartmentsField,
}}
schema={schema}
/>
</>
);
};

View File

@ -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 <https://www.nocobase.com/agreement>
*/
import { SchemaComponent } from '@nocobase/client';
import React from 'react';
import { useDepartmentTranslation } from '../locale';
export const NewDepartment: React.FC = () => {
const { t } = useDepartmentTranslation();
return (
<SchemaComponent
scope={{ t }}
schema={{
type: 'void',
properties: {
newDepartment: {
type: 'void',
title: t('New department'),
'x-component': 'Action',
'x-component-props': {
type: 'text',
icon: 'PlusOutlined',
style: {
width: '100%',
textAlign: 'left',
},
},
properties: {
drawer: {
type: 'void',
'x-component': 'Action.Drawer',
'x-decorator': 'Form',
title: t('New department'),
properties: {
title: {
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
required: true,
},
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 }}',
},
},
},
},
},
},
},
},
},
}}
/>
);
};

View File

@ -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 <https://www.nocobase.com/agreement>
*/
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<Field>();
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 (
<ActionContextProvider value={{ visible, setVisible }}>
<>
{(field?.value || []).map((dept) => (
<Tag style={{ padding: '5px 8px', background: 'transparent', marginBottom: '5px' }} key={dept.id}>
<span style={{ marginRight: '5px' }}>{dept.title}</span>
{dept.isMain ? (
<Tag color="processing" bordered={false}>
{t('Main')}
</Tag>
) : (
''
)}
{/* {dept.isOwner ? ( */}
{/* <Tag color="gold" bordered={false}> */}
{/* {t('Owner')} */}
{/* </Tag> */}
{/* ) : ( */}
{/* '' */}
{/* )} */}
<Dropdown
menu={{
items: [
...(dept.isMain
? []
: [
{
label: t('Set as main department'),
key: 'setMain',
},
]),
// {
// label: dept.isOwner ? t('Remove owner role') : t('Set as owner'),
// key: dept.isOwner ? 'removeOwner' : 'setOwner',
// },
{
label: t('Remove'),
key: 'remove',
},
],
onClick: ({ key }) => handleClick(key, dept),
}}
>
<div style={{ float: 'right' }}>
<MoreOutlined />
</div>
</Dropdown>
</Tag>
))}
<Button icon={<PlusOutlined />} onClick={() => setVisible(true)} />
</>
<SchemaComponent
schema={userDepartmentsSchema}
components={{ DepartmentTable }}
scope={{ user, useDataSource, useAddDepartments, useDisabled }}
/>
</ActionContextProvider>
);
};

View File

@ -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 <https://www.nocobase.com/agreement>
*/
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 }}',
},
},
},
},
},
},
},
};

View File

@ -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 <https://www.nocobase.com/agreement>
*/
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 }}',
},
},
},
},
},
},
},
};

View File

@ -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 <https://www.nocobase.com/agreement>
*/
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,
};
};

View File

@ -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 <https://www.nocobase.com/agreement>
*/
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';

View File

@ -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 <https://www.nocobase.com/agreement>
*/
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,
};
};

View File

@ -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<ArrayField>();
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';
}

View File

@ -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 <https://www.nocobase.com/agreement>
*/
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 (
<SchemaComponentContext.Provider value={{ ...scCtx, designable: false }}>
<ResourcesProvider>
<DepartmentBlock />
</ResourcesProvider>
</SchemaComponentContext.Provider>
);
},
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;

View File

@ -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 <https://www.nocobase.com/agreement>
*/
import { useTranslation } from 'react-i18next';
export function useDepartmentTranslation() {
return useTranslation(['departments', 'client'], { nsMode: 'fallback' });
}

View File

@ -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 <https://www.nocobase.com/agreement>
*/
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 (
<ResourceActionContext.Provider value={{ ...service }}>
<CollectionProvider_deprecated collection={departmentCollection}>
<SchemaComponent
schema={schema}
components={{ DepartmentTable, DepartmentTitle }}
scope={{
useFilterActionProps,
t,
useRemoveDepartment,
useBulkRemoveDepartments,
useDataSource,
useDisabled,
useAddDepartments,
}}
/>
</CollectionProvider_deprecated>
</ResourceActionContext.Provider>
);
};

View File

@ -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 <https://www.nocobase.com/agreement>
*/
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 }}',
},
},
},
},
},
},
},
},
},
});

View File

@ -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 <https://www.nocobase.com/agreement>
*/
export const getDepartmentTitle = (record: any) => {
const title = record.title;
const parent = record.parent;
if (parent) {
return getDepartmentTitle(parent) + ' / ' + title;
}
return title;
};

View File

@ -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 <https://www.nocobase.com/agreement>
*/
export * from './server';
export { default } from './server';

View File

@ -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."
}

View File

@ -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.": "该字段目前不支持在表单区块中使用。"
}

View File

@ -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 <https://www.nocobase.com/agreement>
*/
import { Database, Repository } from '@nocobase/database';
import { MockServer, createMockServer } from '@nocobase/test';
describe('actions', () => {
let app: MockServer;
let db: Database;
let repo: Repository;
let agent: any;
beforeAll(async () => {
app = await createMockServer({
plugins: ['field-sort', 'users', 'departments'],
});
db = app.db;
repo = db.getRepository('departments');
agent = app.agent();
});
afterAll(async () => {
await app.destroy();
});
afterEach(async () => {
await repo.destroy({ truncate: true });
});
it('should list users exclude department', async () => {
const dept = await repo.create({
values: {
title: 'Test department',
members: [1],
},
});
const res = await agent.resource('users').listExcludeDept({
departmentId: dept.id,
});
expect(res.status).toBe(200);
expect(res.body.data.length).toBe(0);
});
it('should list users exclude department with filter', async () => {
let res = await agent.resource('users').listExcludeDept({
departmentId: 1,
});
expect(res.status).toBe(200);
expect(res.body.data.length).toBe(1);
res = await agent.resource('users').listExcludeDept({
departmentId: 1,
filter: {
id: 1,
},
});
expect(res.status).toBe(200);
expect(res.body.data.length).toBe(1);
res = await agent.resource('users').listExcludeDept({
departmentId: 1,
filter: {
id: 2,
},
});
expect(res.status).toBe(200);
expect(res.body.data.length).toBe(0);
});
it('should set main department', async () => {
const depts = await repo.create({
values: [
{
title: 'Dept1',
members: [1],
},
{
title: 'Dept2',
members: [1],
},
],
});
const deptUsers = db.getRepository('departmentsUsers');
await deptUsers.update({
filter: {
departmentId: depts[0].id,
userId: 1,
},
values: {
isMain: true,
},
});
const res = await agent.resource('users').setMainDepartment({
values: {
userId: 1,
departmentId: depts[1].id,
},
});
expect(res.status).toBe(200);
const records = await deptUsers.find({
filter: {
userId: 1,
},
});
const dept1 = records.find((record: any) => record.departmentId === depts[0].id);
const dept2 = records.find((record: any) => record.departmentId === depts[1].id);
expect(dept1.isMain).toBe(false);
expect(dept2.isMain).toBe(true);
});
});

View File

@ -0,0 +1,278 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import PluginUserDataSyncServer, { UserDataResourceManager } from '@nocobase/plugin-user-data-sync';
import { MockDatabase, MockServer, createMockServer } from '@nocobase/test';
describe('department data sync', async () => {
let app: MockServer;
let db: MockDatabase;
let resourceManager: UserDataResourceManager;
beforeEach(async () => {
app = await createMockServer({
plugins: ['field-sort', 'user-data-sync', 'users', 'departments'],
});
db = app.db;
const plugin = app.pm.get('user-data-sync') as PluginUserDataSyncServer;
resourceManager = plugin.resourceManager;
});
afterEach(async () => {
await db.clean({ drop: true });
await app.destroy();
});
it('should create department', async () => {
await resourceManager.updateOrCreate({
sourceName: 'test',
dataType: 'department',
records: [
{
uid: '1',
title: 'test',
},
{
uid: '2',
title: 'sub-test',
parentUid: '1',
},
],
});
const department = await db.getRepository('departments').findOne({
filter: {
title: 'test',
},
appends: ['children'],
});
expect(department).toBeTruthy();
expect(department.title).toBe('test');
expect(department.children).toHaveLength(1);
expect(department.children[0].title).toBe('sub-test');
});
it('should update department', async () => {
await resourceManager.updateOrCreate({
sourceName: 'test',
dataType: 'department',
records: [
{
uid: '1',
title: 'test',
},
],
});
const department = await db.getRepository('departments').findOne({
filter: {
title: 'test',
},
appends: ['children'],
});
expect(department).toBeTruthy();
expect(department.children).toHaveLength(0);
await resourceManager.updateOrCreate({
sourceName: 'test',
dataType: 'department',
records: [
{
uid: '1',
title: 'test2',
},
{
uid: '2',
title: 'sub-test',
parentUid: '1',
},
],
});
const department2 = await db.getRepository('departments').findOne({
filter: {
id: department.id,
},
appends: ['children'],
});
expect(department2).toBeTruthy();
expect(department2.title).toBe('test2');
expect(department2.children).toHaveLength(1);
await resourceManager.updateOrCreate({
sourceName: 'test',
dataType: 'department',
records: [
{
uid: '2',
title: 'sub-test',
},
],
});
const department3 = await db.getRepository('departments').findOne({
filter: {
id: department2.children[0].id,
},
appends: ['parent'],
});
expect(department3).toBeTruthy();
expect(department3.parent).toBeNull();
});
it('should update user department', async () => {
await resourceManager.updateOrCreate({
sourceName: 'test',
dataType: 'department',
records: [
{
uid: '12',
title: 'test',
},
],
});
const department = await db.getRepository('departments').findOne({
filter: {
title: 'test',
},
});
expect(department).toBeTruthy();
await resourceManager.updateOrCreate({
sourceName: 'test',
dataType: 'user',
records: [
{
uid: '1',
nickname: 'test',
email: 'test@nocobase.com',
departments: ['12'],
},
],
});
const user = await db.getRepository('users').findOne({
filter: {
email: 'test@nocobase.com',
},
appends: ['departments'],
});
expect(user).toBeTruthy();
expect(user.departments).toHaveLength(1);
expect(user.departments[0].id).toBe(department.id);
const departmentUser = await db.getRepository('departmentsUsers').findOne({
filter: {
departmentId: department.id,
userId: user.id,
},
});
expect(departmentUser).toBeTruthy();
expect(departmentUser.isOwner).toBe(false);
expect(departmentUser.isMain).toBe(false);
await resourceManager.updateOrCreate({
sourceName: 'test',
dataType: 'user',
records: [
{
uid: '1',
nickname: 'test',
email: 'test@nocobase.com',
departments: [
{
uid: '12',
isOwner: true,
isMain: true,
},
],
},
],
});
const departmentUser2 = await db.getRepository('departmentsUsers').findOne({
filter: {
departmentId: department.id,
userId: user.id,
},
});
expect(departmentUser2).toBeTruthy();
expect(departmentUser2.isOwner).toBe(true);
expect(departmentUser2.isMain).toBe(true);
await resourceManager.updateOrCreate({
sourceName: 'test',
dataType: 'user',
records: [
{
uid: '2',
nickname: 'test2',
email: 'test2@nocobase.com',
departments: [
{
uid: '12',
isOwner: true,
isMain: false,
},
],
},
],
});
const user2 = await db.getRepository('users').findOne({
filter: {
email: 'test2@nocobase.com',
},
appends: ['departments'],
});
expect(user2).toBeTruthy();
expect(user2.departments).toHaveLength(1);
expect(user2.departments[0].id).toBe(department.id);
const departmentUser3 = await db.getRepository('departmentsUsers').findOne({
filter: {
departmentId: department.id,
userId: user2.id,
},
});
expect(departmentUser3).toBeTruthy();
expect(departmentUser3.isOwner).toBe(true);
expect(departmentUser3.isMain).toBe(false);
});
it('should update department custom field', async () => {
const departmemntCollection = db.getCollection('departments');
departmemntCollection.addField('customField', { type: 'string' });
await db.sync({
alter: true,
});
await resourceManager.updateOrCreate({
sourceName: 'test',
dataType: 'department',
records: [
{
uid: '1',
title: 'test',
customField: 'testField',
},
],
});
const department = await db.getRepository('departments').findOne({
filter: {
title: 'test',
},
});
expect(department).toBeTruthy();
expect(department.customField).toBe('testField');
await resourceManager.updateOrCreate({
sourceName: 'test',
dataType: 'department',
records: [
{
uid: '1',
title: 'test',
customField: 'testField2',
},
],
});
const department2 = await db.getRepository('departments').findOne({
filter: {
id: department.id,
},
});
expect(department2).toBeTruthy();
expect(department2.customField).toBe('testField2');
});
});

View File

@ -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 <https://www.nocobase.com/agreement>
*/
import { Database, Repository } from '@nocobase/database';
import { MockServer, createMockServer } from '@nocobase/test';
describe('destroy department check', () => {
let app: MockServer;
let db: Database;
let repo: Repository;
let agent: any;
beforeAll(async () => {
app = await createMockServer({
plugins: ['field-sort', 'users', 'departments'],
});
db = app.db;
repo = db.getRepository('departments');
agent = app.agent();
});
afterAll(async () => {
await app.destroy();
});
afterEach(async () => {
await repo.destroy({ truncate: true });
});
it('should check if it has sub departments', async () => {
const dept = await repo.create({
values: {
title: 'Department',
children: [{ title: 'Sub department' }],
},
});
const res = await agent.resource('departments').destroy({
filterByTk: dept.id,
});
expect(res.status).toBe(400);
expect(res.text).toBe('The department has sub-departments, please delete them first');
});
it('should check if it has members', async () => {
const dept = await repo.create({
values: {
title: 'Department',
members: [1],
},
});
const res = await agent.resource('departments').destroy({
filterByTk: dept.id,
});
expect(res.status).toBe(400);
expect(res.text).toBe('The department has members, please remove them first');
});
});

View File

@ -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 <https://www.nocobase.com/agreement>
*/
import { Database, Repository } from '@nocobase/database';
import { MockServer, createMockServer } from '@nocobase/test';
describe('set department owners', () => {
let app: MockServer;
let db: Database;
let repo: Repository;
let agent: any;
beforeAll(async () => {
app = await createMockServer({
plugins: ['field-sort', 'users', 'departments'],
});
db = app.db;
repo = db.getRepository('departments');
agent = app.agent();
});
afterAll(async () => {
await app.destroy();
});
afterEach(async () => {
await repo.destroy({ truncate: true });
});
it('should set department owners', async () => {
await db.getRepository('users').create({
values: {
username: 'test',
},
});
const dept = await repo.create({
values: {
title: 'Department',
members: [1, 2],
},
});
await agent.resource('departments').update({
filterByTk: dept.id,
values: {
owners: [{ id: 1 }],
},
});
const deptUser = await db.getRepository('departmentsUsers').findOne({
filter: {
userId: 1,
departmentId: dept.id,
},
});
expect(deptUser.isOwner).toBe(true);
await agent.resource('departments').update({
filterByTk: dept.id,
values: {
owners: [{ id: 2 }],
},
});
const deptUser1 = await db.getRepository('departmentsUsers').findOne({
filter: {
userId: 1,
departmentId: dept.id,
},
});
expect(deptUser1.isOwner).toBe(false);
const deptUser2 = await db.getRepository('departmentsUsers').findOne({
filter: {
userId: 2,
departmentId: dept.id,
},
});
expect(deptUser2.isOwner).toBe(true);
});
});

View File

@ -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 <https://www.nocobase.com/agreement>
*/
import { Database, Repository } from '@nocobase/database';
import { MockServer, createMockServer } from '@nocobase/test';
import { setDepartmentsInfo } from '../middlewares';
describe('set departments info', () => {
let app: MockServer;
let db: Database;
let repo: Repository;
let agent: any;
let ctx: any;
beforeAll(async () => {
app = await createMockServer({
plugins: ['field-sort', 'users', 'departments', 'acl', 'data-source-manager'],
});
db = app.db;
repo = db.getRepository('departments');
agent = app.agent();
ctx = {
db,
cache: app.cache,
state: {},
};
});
afterAll(async () => {
await app.destroy();
});
afterEach(async () => {
await repo.destroy({ truncate: true });
});
it('should set departments roles', async () => {
ctx.state.currentUser = await db.getRepository('users').findOne({
filterByTk: 1,
});
const role = await db.getRepository('roles').create({
values: {
name: 'test-role',
title: 'Test role',
},
});
await repo.create({
values: {
title: 'Department',
roles: [role.name],
members: [1],
},
});
await setDepartmentsInfo(ctx, () => {});
expect(ctx.state.attachRoles.length).toBe(1);
expect(ctx.state.attachRoles[0].name).toBe('test-role');
});
});

View File

@ -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 <https://www.nocobase.com/agreement>
*/
import { Database, Repository } from '@nocobase/database';
import { MockServer, createMockServer } from '@nocobase/test';
describe('set main department', () => {
let app: MockServer;
let db: Database;
let repo: Repository;
let agent: any;
beforeAll(async () => {
app = await createMockServer({
plugins: ['field-sort', 'users', 'departments'],
});
db = app.db;
repo = db.getRepository('departments');
agent = app.agent();
});
afterAll(async () => {
await app.destroy();
});
afterEach(async () => {
await repo.destroy({ truncate: true });
await db.getRepository('departmentsUsers').destroy({ truncate: true });
});
it('should set main department when add department members', async () => {
const dept = await repo.create({
values: {
title: 'Department',
},
});
await db.getRepository('users').create({
values: {
username: 'test',
},
});
await agent.resource('departments.members', dept.id).add({
values: [1, 2],
});
const throughRepo = db.getRepository('departmentsUsers');
const deptUsers = await throughRepo.find({
filter: {
userId: {
$in: [1, 2],
},
departmentId: dept.id,
},
});
for (const item of deptUsers) {
expect(item.isMain).toBe(true);
}
const dept2 = await repo.create({
values: {
title: 'Department2',
},
});
await agent.resource('departments.members', dept2.id).add({
values: [2],
});
const deptUsers2 = await throughRepo.find({
filter: {
userId: 2,
},
});
expect(deptUsers2.length).toBe(2);
expect(deptUsers2.find((i: any) => i.departmentId === dept.id).isMain).toBe(true);
expect(deptUsers2.find((i: any) => i.departmentId === dept2.id).isMain).toBe(false);
});
it('should set main department when remove department members', async () => {
const depts = await repo.create({
values: [
{
title: 'Department',
},
{
title: 'Department2',
},
],
});
await agent.resource('departments.members', depts[0].id).add({
values: [1],
});
await agent.resource('departments.members', depts[1].id).add({
values: [1],
});
const throughRepo = db.getRepository('departmentsUsers');
const deptUsers = await throughRepo.find({
filter: {
userId: 1,
},
});
expect(deptUsers.length).toBe(2);
expect(deptUsers.find((i: any) => i.departmentId === depts[0].id).isMain).toBe(true);
expect(deptUsers.find((i: any) => i.departmentId === depts[1].id).isMain).toBe(false);
await agent.resource('departments.members', depts[0].id).remove({
values: [1],
});
const deptUsers2 = await throughRepo.find({
filter: {
userId: 1,
},
});
expect(deptUsers2.length).toBe(1);
expect(deptUsers2[0].departmentId).toBe(depts[1].id);
expect(deptUsers2[0].isMain).toBe(true);
});
it('should set main department when add user departments', async () => {
const depts = await repo.create({
values: [
{
title: 'Department',
},
{
title: 'Department2',
},
],
});
await agent.resource('users.departments', 1).add({
values: depts.map((dept: any) => dept.id),
});
const throughRepo = db.getRepository('departmentsUsers');
const deptUsers = await throughRepo.find({
filter: {
userId: 1,
},
});
expect(deptUsers.length).toBe(2);
expect(deptUsers.find((i: any) => i.departmentId === depts[0].id).isMain).toBe(true);
expect(deptUsers.find((i: any) => i.departmentId === depts[1].id).isMain).toBe(false);
});
it('should set main department when remove user departments', async () => {
const depts = await repo.create({
values: [
{
title: 'Department',
},
{
title: 'Department2',
},
],
});
await agent.resource('users.departments', 1).add({
values: depts.map((dept: any) => dept.id),
});
await agent.resource('users.departments', 1).remove({
values: [depts[0].id],
});
const throughRepo = db.getRepository('departmentsUsers');
const deptUsers = await throughRepo.find({
filter: {
userId: 1,
},
});
expect(deptUsers.length).toBe(1);
expect(deptUsers[0].departmentId).toBe(depts[1].id);
expect(deptUsers[0].isMain).toBe(true);
});
});

View File

@ -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 <https://www.nocobase.com/agreement>
*/
import { Database, Repository } from '@nocobase/database';
import { MockServer, createMockServer } from '@nocobase/test';
describe('update department isLeaf', () => {
let app: MockServer;
let db: Database;
let repo: Repository;
let agent: any;
beforeAll(async () => {
app = await createMockServer({
plugins: ['field-sort', 'users', 'departments'],
});
db = app.db;
repo = db.getRepository('departments');
agent = app.agent();
});
afterAll(async () => {
await app.destroy();
});
afterEach(async () => {
await repo.destroy({ truncate: true });
});
it('should update isLeaf when create sub department', async () => {
const res = await agent.resource('departments').create({
values: {
title: 'Department',
},
});
const dept = res.body.data;
expect(dept).toBeTruthy();
expect(dept.isLeaf).toBe(true);
await agent.resource('departments').create({
values: {
title: 'Sub Department',
parent: dept,
},
});
const record = await repo.findOne({
filterByTk: dept.id,
});
expect(record.isLeaf).toBe(false);
});
it('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);
});
});

View File

@ -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 <https://www.nocobase.com/agreement>
*/
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();
};

View File

@ -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 <https://www.nocobase.com/agreement>
*/
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();
};

View File

@ -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 <https://www.nocobase.com/agreement>
*/
import { defineCollection } from '@nocobase/database';
export default defineCollection({
name: 'departmentsRoles',
dumpRules: 'required',
migrationRules: ['overwrite'],
});

View File

@ -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 <https://www.nocobase.com/agreement>
*/
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,
],
});

View File

@ -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 <https://www.nocobase.com/agreement>
*/
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,
},
],
});

View File

@ -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 <https://www.nocobase.com/agreement>
*/
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',
},
],
});

View File

@ -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 <https://www.nocobase.com/agreement>
*/
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],
});

View File

@ -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<RecordResourceChanged[]> {
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<RecordResourceChanged[]> {
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<RecordResourceChanged[]> {
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<string> {
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);
}
}
}
}

View File

@ -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 <https://www.nocobase.com/agreement>
*/
export { default } from './plugin';

View File

@ -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 <https://www.nocobase.com/agreement>
*/
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();
};

View File

@ -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 <https://www.nocobase.com/agreement>
*/
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';

View File

@ -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 <https://www.nocobase.com/agreement>
*/
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}`);
}
};

View File

@ -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 <https://www.nocobase.com/agreement>
*/
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();
}
};

View File

@ -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 <https://www.nocobase.com/agreement>
*/
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();
};

View File

@ -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 <https://www.nocobase.com/agreement>
*/
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,
},
});
}
}
};

View File

@ -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 <https://www.nocobase.com/agreement>
*/
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();
};

View File

@ -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 <https://www.nocobase.com/agreement>
*/
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,
},
});
}
}
}

View File

@ -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 <https://www.nocobase.com/agreement>
*/
import { Model } from '@nocobase/database';
export class DepartmentModel extends Model {
getOwners() {
return this.getMembers({
through: {
where: {
isOwner: true,
},
},
});
}
}

View File

@ -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 <https://www.nocobase.com/agreement>
*/
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<any>('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;

View File

@ -0,0 +1,2 @@
/node_modules
/src

View File

@ -0,0 +1 @@
# @nocobase/plugin-field-attachment-url

View File

@ -0,0 +1,2 @@
export * from './dist/client';
export { default } from './dist/client';

View File

@ -0,0 +1 @@
module.exports = require('./dist/client/index.js');

View File

@ -0,0 +1,20 @@
{
"name": "@nocobase/plugin-field-attachment-url",
"version": "1.6.19",
"main": "dist/server/index.js",
"displayName": "Collection field: Attachment(URL)",
"displayName.zh-CN": "数据表字段附件URL",
"description": "Supports attachments in URL format.",
"description.zh-CN": "支持 URL 格式的附件。",
"peerDependencies": {
"@nocobase/client": "1.x",
"@nocobase/server": "1.x",
"@nocobase/test": "1.x"
},
"devDependencies": {
"@nocobase/plugin-file-manager": "1.x"
},
"keywords": [
"Collection fields"
]
}

View File

@ -0,0 +1,2 @@
export * from './dist/server';
export { default } from './dist/server';

View File

@ -0,0 +1 @@
module.exports = require('./dist/server/index.js');

View File

@ -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();
});

View File

@ -0,0 +1,249 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
// CSS modules
type CSSModuleClasses = { readonly [key: string]: string };
declare module '*.module.css' {
const classes: CSSModuleClasses;
export default classes;
}
declare module '*.module.scss' {
const classes: CSSModuleClasses;
export default classes;
}
declare module '*.module.sass' {
const classes: CSSModuleClasses;
export default classes;
}
declare module '*.module.less' {
const classes: CSSModuleClasses;
export default classes;
}
declare module '*.module.styl' {
const classes: CSSModuleClasses;
export default classes;
}
declare module '*.module.stylus' {
const classes: CSSModuleClasses;
export default classes;
}
declare module '*.module.pcss' {
const classes: CSSModuleClasses;
export default classes;
}
declare module '*.module.sss' {
const classes: CSSModuleClasses;
export default classes;
}
// CSS
declare module '*.css' { }
declare module '*.scss' { }
declare module '*.sass' { }
declare module '*.less' { }
declare module '*.styl' { }
declare module '*.stylus' { }
declare module '*.pcss' { }
declare module '*.sss' { }
// Built-in asset types
// see `src/node/constants.ts`
// images
declare module '*.apng' {
const src: string;
export default src;
}
declare module '*.png' {
const src: string;
export default src;
}
declare module '*.jpg' {
const src: string;
export default src;
}
declare module '*.jpeg' {
const src: string;
export default src;
}
declare module '*.jfif' {
const src: string;
export default src;
}
declare module '*.pjpeg' {
const src: string;
export default src;
}
declare module '*.pjp' {
const src: string;
export default src;
}
declare module '*.gif' {
const src: string;
export default src;
}
declare module '*.svg' {
const src: string;
export default src;
}
declare module '*.ico' {
const src: string;
export default src;
}
declare module '*.webp' {
const src: string;
export default src;
}
declare module '*.avif' {
const src: string;
export default src;
}
// media
declare module '*.mp4' {
const src: string;
export default src;
}
declare module '*.webm' {
const src: string;
export default src;
}
declare module '*.ogg' {
const src: string;
export default src;
}
declare module '*.mp3' {
const src: string;
export default src;
}
declare module '*.wav' {
const src: string;
export default src;
}
declare module '*.flac' {
const src: string;
export default src;
}
declare module '*.aac' {
const src: string;
export default src;
}
declare module '*.opus' {
const src: string;
export default src;
}
declare module '*.mov' {
const src: string;
export default src;
}
declare module '*.m4a' {
const src: string;
export default src;
}
declare module '*.vtt' {
const src: string;
export default src;
}
// fonts
declare module '*.woff' {
const src: string;
export default src;
}
declare module '*.woff2' {
const src: string;
export default src;
}
declare module '*.eot' {
const src: string;
export default src;
}
declare module '*.ttf' {
const src: string;
export default src;
}
declare module '*.otf' {
const src: string;
export default src;
}
// other
declare module '*.webmanifest' {
const src: string;
export default src;
}
declare module '*.pdf' {
const src: string;
export default src;
}
declare module '*.txt' {
const src: string;
export default src;
}
// wasm?init
declare module '*.wasm?init' {
const initWasm: (options?: WebAssembly.Imports) => Promise<WebAssembly.Instance>;
export default initWasm;
}
// web worker
declare module '*?worker' {
const workerConstructor: {
new(options?: { name?: string }): Worker;
};
export default workerConstructor;
}
declare module '*?worker&inline' {
const workerConstructor: {
new(options?: { name?: string }): Worker;
};
export default workerConstructor;
}
declare module '*?worker&url' {
const src: string;
export default src;
}
declare module '*?sharedworker' {
const sharedWorkerConstructor: {
new(options?: { name?: string }): SharedWorker;
};
export default sharedWorkerConstructor;
}
declare module '*?sharedworker&inline' {
const sharedWorkerConstructor: {
new(options?: { name?: string }): SharedWorker;
};
export default sharedWorkerConstructor;
}
declare module '*?sharedworker&url' {
const src: string;
export default src;
}
declare module '*?raw' {
const src: string;
export default src;
}
declare module '*?url' {
const src: string;
export default src;
}
declare module '*?inline' {
const src: string;
export default src;
}

View File

@ -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 <Input {...props} />;
}
console.log(collectionField);
return (
<div style={{ width: '100%', overflow: 'auto' }}>
<AssociationField.FileSelector
toValueItem={toValueItem}
value={options}
quickUpload={fieldSchema['x-component-props']?.quickUpload !== false}
selectFile={
collectionField?.target && collectionField?.target !== 'attachments'
? fieldSchema['x-component-props']?.selectFile !== false
: false
}
action={`${collectionField?.target || 'attachments'}:create`}
onSelect={handleSelect}
onChange={onChange}
disabled={disabled}
/>
<ActionContextProvider
value={{
openMode: 'drawer',
visible: visibleSelector,
setVisible: setVisibleSelector,
modalProps: {
getContainer: others?.getContainer || modalProps?.getContainer,
},
formValueChanged: false,
}}
>
{collectionField?.target && collectionField?.target !== 'attachments' && (
<RecordPickerProvider {...pickerProps}>
<CollectionProvider_deprecated name={collectionField?.target} dataSource={'main'}>
<FormProvider>
<TableSelectorParamsProvider params={{}}>
<SchemaComponentOptions scope={{ usePickActionProps, useTableSelectorProps }}>
<RecursionField
onlyRenderProperties
basePath={field.address}
schema={fieldSchema}
filterProperties={(s) => {
return s['x-component'] === 'AssociationField.Selector';
}}
/>
</SchemaComponentOptions>
</TableSelectorParamsProvider>
</FormProvider>
</CollectionProvider_deprecated>
</RecordPickerProvider>
)}
</ActionContextProvider>
</div>
);
};
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 <EllipsisWithTooltip ellipsis>{value}</EllipsisWithTooltip>;
}
return (
<EllipsisWithTooltip ellipsis>{collectionField ? <Upload.ReadPretty {...props} /> : null}</EllipsisWithTooltip>
);
});
export const AttachmentUrl = connect(InnerAttachmentUrl, mapReadPretty(FileManageReadPretty));

View File

@ -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<any>(
{
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 propertyurl 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);
},
};
};

View File

@ -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;

View File

@ -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;
}

View File

@ -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',
});
}

View File

@ -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',
},
},
},
},
},
},
},
},
};

View File

@ -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<Field>();
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<Field>();
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<Field>();
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<Field>();
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();
},
};
},
},
],
});

View File

@ -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';

View File

@ -0,0 +1,4 @@
{
"Which file collection should it be uploaded to":"上传到文件表",
"Attachment (URL)":"附件 (URL)"
}

View File

@ -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';

View File

@ -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;

View File

@ -0,0 +1,2 @@
/node_modules
/src

View File

@ -0,0 +1 @@
# @nocobase/plugin-workflow-response-message

View File

@ -0,0 +1,2 @@
export * from './dist/client';
export { default } from './dist/client';

View File

@ -0,0 +1 @@
module.exports = require('./dist/client/index.js');

View File

@ -0,0 +1,24 @@
{
"name": "@nocobase/plugin-workflow-response-message",
"version": "1.6.19",
"displayName": "Workflow: Response message",
"displayName.zh-CN": "工作流:响应消息",
"description": "Used for assemble response message and showing to client in form event and request interception workflows.",
"description.zh-CN": "用于在表单事件和请求拦截工作流中组装并向客户端显示响应消息。",
"main": "dist/server/index.js",
"homepage": "https://docs.nocobase.com/handbook/workflow-response-message",
"homepage.zh-CN": "https://docs-cn.nocobase.com/handbook/workflow-response-message",
"peerDependencies": {
"@nocobase/client": "1.x",
"@nocobase/server": "1.x",
"@nocobase/test": "1.x",
"@nocobase/utils": "1.x"
},
"devDependencies": {
"@nocobase/plugin-workflow": "1.x"
},
"keywords": [
"Workflow"
],
"gitHead": "080fc78c1a744d47e010b3bbe5840446775800e4"
}

View File

@ -0,0 +1,2 @@
export * from './dist/server';
export { default } from './dist/server';

View File

@ -0,0 +1 @@
module.exports = require('./dist/server/index.js');

Some files were not shown because too many files have changed in this diff Show More