Merge branch 'develop' of github.com:nocobase/nocobase into feat-main-datasource-mssql

This commit is contained in:
aaaaaajie 2025-04-14 20:24:19 +08:00
commit 1e4de52282
145 changed files with 9558 additions and 415 deletions

View File

@ -5,6 +5,39 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [v1.6.19](https://github.com/nocobase/nocobase/compare/v1.6.18...v1.6.19) - 2025-04-14
### 🐛 Bug Fixes
- **[client]**
- Fix the issue of preview images being obscured ([#6651](https://github.com/nocobase/nocobase/pull/6651)) by @zhangzhonghe
- In the form block, the default value of the field configuration will first be displayed as the original variable string and then disappear ([#6649](https://github.com/nocobase/nocobase/pull/6649)) by @zhangzhonghe
## [v1.6.18](https://github.com/nocobase/nocobase/compare/v1.6.17...v1.6.18) - 2025-04-11
### 🚀 Improvements
- **[client]**
- Add default type fallback API for `Variable.Input` ([#6644](https://github.com/nocobase/nocobase/pull/6644)) by @mytharcher
- Optimize prompts for unconfigured pages ([#6641](https://github.com/nocobase/nocobase/pull/6641)) by @zhangzhonghe
- **[Workflow: Delay node]** Support to use variable for duration ([#6621](https://github.com/nocobase/nocobase/pull/6621)) by @mytharcher
- **[Workflow: Custom action event]** Add refresh settings for trigger workflow button by @mytharcher
### 🐛 Bug Fixes
- **[client]**
- subtable description overlapping with add new button ([#6646](https://github.com/nocobase/nocobase/pull/6646)) by @katherinehhh
- dashed underline caused by horizontal form layout in modal ([#6639](https://github.com/nocobase/nocobase/pull/6639)) by @katherinehhh
- **[File storage: S3(Pro)]** Fix missing await for next call. by @jiannx
- **[Email manager]** Fix missing await for next call. by @jiannx
## [v1.6.17](https://github.com/nocobase/nocobase/compare/v1.6.16...v1.6.17) - 2025-04-09
### 🚀 Improvements

View File

@ -5,6 +5,39 @@
格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/),
并且本项目遵循 [语义化版本](https://semver.org/spec/v2.0.0.html)。
## [v1.6.19](https://github.com/nocobase/nocobase/compare/v1.6.18...v1.6.19) - 2025-04-14
### 🐛 修复
- **[client]**
- 修复预览图片被遮挡的问题 ([#6651](https://github.com/nocobase/nocobase/pull/6651)) by @zhangzhonghe
- 表单区块中,字段配置的默认值会先显示为原始变量字符串然后再消失 ([#6649](https://github.com/nocobase/nocobase/pull/6649)) by @zhangzhonghe
## [v1.6.18](https://github.com/nocobase/nocobase/compare/v1.6.17...v1.6.18) - 2025-04-11
### 🚀 优化
- **[client]**
- 为 `Variable.Input` 组件增加默认退避类型的 API ([#6644](https://github.com/nocobase/nocobase/pull/6644)) by @mytharcher
- 优化未配置页面时的提示 ([#6641](https://github.com/nocobase/nocobase/pull/6641)) by @zhangzhonghe
- **[工作流:延时节点]** 支持延迟时间使用变量 ([#6621](https://github.com/nocobase/nocobase/pull/6621)) by @mytharcher
- **[工作流:自定义操作事件]** 为触发工作流按钮增加刷新配置项 by @mytharcher
### 🐛 修复
- **[client]**
- 子表格中描述信息与操作按钮遮挡 ([#6646](https://github.com/nocobase/nocobase/pull/6646)) by @katherinehhh
- 弹窗表单在 horizontal 布局下初始宽度计算错误,导致出现提示和 下划虚线 ([#6639](https://github.com/nocobase/nocobase/pull/6639)) by @katherinehhh
- **[文件存储S3 (Pro)]** 修复next调用缺少await by @jiannx
- **[邮件管理]** 修复next调用缺少await by @jiannx
## [v1.6.17](https://github.com/nocobase/nocobase/compare/v1.6.16...v1.6.17) - 2025-04-09
### 🚀 优化

View File

@ -461,7 +461,7 @@ exports.initEnv = function initEnv() {
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');
fs.existsSync(pkgDir) && fs.rmdirSync(pkgDir, { force: true });
fs.existsSync(pkgDir) && fs.rmdirSync(pkgDir, { recursive: true, force: true });
};
exports.generatePlugins = function () {

View File

@ -10,11 +10,11 @@
import { useFieldSchema } from '@formily/react';
import React from 'react';
import { withDynamicSchemaProps } from '../hoc/withDynamicSchemaProps';
import { DatePickerProvider, ActionBarProvider, SchemaComponentOptions } from '../schema-component';
import { FilterCollectionField } from '../modules/blocks/filter-blocks/FilterCollectionField';
import { ActionBarProvider, DatePickerProvider, SchemaComponentOptions } from '../schema-component';
import { DefaultValueProvider } from '../schema-settings';
import { CollectOperators } from './CollectOperators';
import { FormBlockProvider } from './FormBlockProvider';
import { FilterCollectionField } from '../modules/blocks/filter-blocks/FilterCollectionField';
export const FilterFormBlockProvider = withDynamicSchemaProps((props) => {
const filedSchema = useFieldSchema();
@ -35,7 +35,7 @@ export const FilterFormBlockProvider = withDynamicSchemaProps((props) => {
}}
>
<DefaultValueProvider isAllowToSetDefaultValue={() => false}>
<FormBlockProvider name="filter-form" {...props}></FormBlockProvider>
<FormBlockProvider name="filter-form" {...props} confirmBeforeClose={false}></FormBlockProvider>
</DefaultValueProvider>
</ActionBarProvider>
</DatePickerProvider>

View File

@ -546,9 +546,11 @@ export const useFilterBlockActionProps = () => {
const { doFilter } = useDoFilter();
const actionField = useField();
actionField.data = actionField.data || {};
const form = useForm();
return {
async onClick() {
await form.submit();
actionField.data.loading = true;
await doFilter();
actionField.data.loading = false;

View File

@ -18,6 +18,7 @@ import { useCollectionFieldUISchema, useIsInNocoBaseRecursionFieldContext } from
import { useDynamicComponentProps } from '../../hoc/withDynamicSchemaProps';
import { useCompile, useComponent } from '../../schema-component';
import { useIsAllowToSetDefaultValue } from '../../schema-settings/hooks/useIsAllowToSetDefaultValue';
import { isVariable } from '../../variables/utils/isVariable';
import { CollectionFieldProvider, useCollectionField } from './CollectionFieldProvider';
type Props = {
@ -135,7 +136,14 @@ const CollectionFieldInternalField = (props) => {
if (!uiSchema) return null;
return <Component {...props} {...dynamicProps} />;
const mergedProps = { ...props, ...dynamicProps };
// Prevent displaying the variable string first, then the variable value
if (isVariable(mergedProps.value) && mergedProps.value === fieldSchema.default) {
mergedProps.value = undefined;
}
return <Component {...mergedProps} />;
};
export const CollectionField = connect((props) => {

View File

@ -1085,5 +1085,8 @@
"Select data blocks to refresh": "Aggiorna blocchi di dati",
"After successful submission, the selected data blocks will be automatically refreshed.": "Dopo una soumission réussie, les blocs de données sélectionnés seront automatiquement actualisés.",
"No pages yet, please configure first": "Nessuna pagina ancora, si prega di configurare prima",
"Click the \"UI Editor\" icon in the upper right corner to enter the UI Editor mode": "Cliquez sur l'icône \"Éditeur d'interface utilisateur\" dans le coin supérieur droit pour entrer en mode Éditeur d'interface utilisateur"
"Click the \"UI Editor\" icon in the upper right corner to enter the UI Editor mode": "Cliquez sur l'icône \"Éditeur d'interface utilisateur\" dans le coin supérieur droit pour entrer en mode Éditeur d'interface utilisateur",
"Refresh data blocks": "Aggiorna blocchi di dati",
"Select data blocks to refresh": "Aggiorna blocchi di dati",
"After successful submission, the selected data blocks will be automatically refreshed.": "Dopo una soumission réussie, les blocs de données sélectionnés seront automatiquement actualisés."
}

View File

@ -787,5 +787,8 @@
"Select data blocks to refresh": "Selecionar blocos de dados para atualizar",
"After successful submission, the selected data blocks will be automatically refreshed.": "Após a atualização em massa bem sucedida.",
"No pages yet, please configure first": "Ainda não há páginas, por favor configure primeiro",
"Click the \"UI Editor\" icon in the upper right corner to enter the UI Editor mode": "Cliquez sur l'icône \"Éditeur d'interface utilisateur\" dans le coin supérieur droit pour entrer en mode Éditeur d'interface utilisateur"
"Click the \"UI Editor\" icon in the upper right corner to enter the UI Editor mode": "Cliquez sur l'icône \"Éditeur d'interface utilisateur\" dans le coin supérieur droit pour entrer en mode Éditeur d'interface utilisateur",
"Refresh data blocks": "Atualizar blocos de dados",
"Select data blocks to refresh": "Selecionar blocos de dados para atualizar",
"After successful submission, the selected data blocks will be automatically refreshed.": "Após a atualização em massa bem sucedida."
}

View File

@ -616,5 +616,8 @@
"Select data blocks to refresh": "Выберите блоки данных для обновления",
"After successful submission, the selected data blocks will be automatically refreshed.": "После успешной отправки выбранные блоки данных будут автоматически обновлены.",
"No pages yet, please configure first": "Пока нет страниц, пожалуйста, настройте сначала",
"Click the \"UI Editor\" icon in the upper right corner to enter the UI Editor mode": "Нажмите на значок \"Редактор пользовательского интерфейса\" в правом верхнем углу, чтобы войти в режим редактора пользовательского интерфейса"
"Click the \"UI Editor\" icon in the upper right corner to enter the UI Editor mode": "Нажмите на значок \"Редактор пользовательского интерфейса\" в правом верхнем углу, чтобы войти в режим редактора пользовательского интерфейса",
"Refresh data blocks": "Обновить блоки данных",
"Select data blocks to refresh": "Выберите блоки данных для обновления",
"After successful submission, the selected data blocks will be automatically refreshed.": "После успешной отправки выбранные блоки данных будут автоматически обновлены."
}

View File

@ -614,5 +614,8 @@
"Select data blocks to refresh": "Veri bloklarını yenilemek için seçin",
"After successful submission, the selected data blocks will be automatically refreshed.": "Başarılı bir şekilde gönderildikten sonra, seçilen veri blokları otomatik olarak yenilenecektir.",
"No pages yet, please configure first": "Henüz sayfa yok, lütfen önce yapılandırın",
"Click the \"UI Editor\" icon in the upper right corner to enter the UI Editor mode": "Kullanıcı arayüzü düzenleyici moduna girmek için sağ üst köşedeki \"Kullanıcı Arayüzü Düzenleyici\" simgesine tıklayın"
"Click the \"UI Editor\" icon in the upper right corner to enter the UI Editor mode": "Kullanıcı arayüzü düzenleyici moduna girmek için sağ üst köşedeki \"Kullanıcı Arayüzü Düzenleyici\" simgesine tıklayın",
"Refresh data blocks": "Yenile veri blokları",
"Select data blocks to refresh": "Veri bloklarını yenilemek için seçin",
"After successful submission, the selected data blocks will be automatically refreshed.": "Başarılı bir şekilde gönderildikten sonra, seçilen veri blokları otomatik olarak yenilenecektir."
}

View File

@ -830,5 +830,8 @@
"Select data blocks to refresh": "Виберіть блоки даних для оновлення",
"After successful submission, the selected data blocks will be automatically refreshed.": "Після успішної подачі вибрані блоки даних будуть автоматично оновлені.",
"No pages yet, please configure first": "Ще немає сторінок, будь ласка, спочатку налаштуйте",
"Click the \"UI Editor\" icon in the upper right corner to enter the UI Editor mode": "Натисніть на значок \"Редактор користувацького інтерфейсу\" в правому верхньому куті, щоб увійти в режим редактора користувацького інтерфейсу."
"Click the \"UI Editor\" icon in the upper right corner to enter the UI Editor mode": "Натисніть на значок \"Редактор користувацького інтерфейсу\" в правому верхньому куті, щоб увійти в режим редактора користувацького інтерфейсу.",
"Refresh data blocks": "Оновити дані блоків",
"Select data blocks to refresh": "Виберіть блоки даних для оновлення",
"After successful submission, the selected data blocks will be automatically refreshed.": "Після успішної подачі вибрані блоки даних будуть автоматично оновлені."
}

View File

@ -1102,5 +1102,6 @@
"Colon":"冒号",
"After successful submission, the selected data blocks will be automatically refreshed.": "提交成功后,会自动刷新这里选中的数据区块。",
"No pages yet, please configure first": "暂无页面,请先配置",
"Click the \"UI Editor\" icon in the upper right corner to enter the UI Editor mode": "点击右上角的“界面配置”图标,进入界面配置模式"
"Click the \"UI Editor\" icon in the upper right corner to enter the UI Editor mode": "点击右上角的“界面配置”图标,进入界面配置模式",
"After successful submission, the selected data blocks will be automatically refreshed.": "提交成功后,会自动刷新这里选中的数据区块。"
}

View File

@ -921,5 +921,8 @@
"Select data blocks to refresh": "選擇要刷新的數據區塊",
"After successful submission, the selected data blocks will be automatically refreshed.": "提交成功後,選中的數據區塊將自動刷新。",
"No pages yet, please configure first": "尚未配置頁面,請先配置",
"Click the \"UI Editor\" icon in the upper right corner to enter the UI Editor mode": "點擊右上角的 \"介面設定\" 圖示進入介面設定模式"
"Click the \"UI Editor\" icon in the upper right corner to enter the UI Editor mode": "點擊右上角的 \"介面設定\" 圖示進入介面設定模式",
"Refresh data blocks": "刷新數據區塊",
"Select data blocks to refresh": "選擇要刷新的數據區塊",
"After successful submission, the selected data blocks will be automatically refreshed.": "提交成功後,選中的數據區塊將自動刷新。"
}

View File

@ -94,7 +94,11 @@ export const FilterCollectionFieldInternalField: React.FC = (props: Props) => {
// @ts-ignore
field.dataSource = uiSchema.enum;
const originalProps =
compile({ ...(operator?.schema?.['x-component-props'] || {}), ...(uiSchema['x-component-props'] || {}) }) || {};
compile({
...(operator?.schema?.['x-component-props'] || {}),
...(uiSchema['x-component-props'] || {}),
...(fieldSchema?.['x-component-props'] || {}),
}) || {};
field.componentProps = merge(field.componentProps || {}, originalProps, dynamicProps || {});
}, [uiSchemaOrigin]);

View File

@ -0,0 +1,212 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* 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 { findFirstPageRoute, NocoBaseDesktopRouteType } from '..';
import { NocoBaseDesktopRoute } from '../convertRoutesToSchema';
describe('findFirstPageRoute', () => {
// 基本测试:空路由数组
it('should return undefined for empty routes array', () => {
const result = findFirstPageRoute([]);
expect(result).toBeUndefined();
});
// 基本测试undefined 路由数组
it('should return undefined for undefined routes', () => {
const result = findFirstPageRoute(undefined);
expect(result).toBeUndefined();
});
// 测试:只有一个页面路由
it('should find the first page route when there is only one page', () => {
const routes: NocoBaseDesktopRoute[] = [
{
id: 1,
schemaUid: 'page1',
type: NocoBaseDesktopRouteType.page,
title: 'Page 1',
},
];
const result = findFirstPageRoute(routes);
expect(result).toEqual(routes[0]);
});
// 测试:多个页面路由
it('should find the first page route when there are multiple pages', () => {
const routes: NocoBaseDesktopRoute[] = [
{
id: 1,
schemaUid: 'page1',
type: NocoBaseDesktopRouteType.page,
title: 'Page 1',
},
{
id: 2,
schemaUid: 'page2',
type: NocoBaseDesktopRouteType.page,
title: 'Page 2',
},
];
const result = findFirstPageRoute(routes);
expect(result).toEqual(routes[0]);
});
// 测试:不同类型的路由混合
it('should find the first page route among mixed route types', () => {
const routes: NocoBaseDesktopRoute[] = [
{
id: 1,
schemaUid: 'link1',
type: NocoBaseDesktopRouteType.link,
title: 'Link 1',
},
{
id: 2,
schemaUid: 'page1',
type: NocoBaseDesktopRouteType.page,
title: 'Page 1',
},
];
const result = findFirstPageRoute(routes);
expect(result).toEqual(routes[1]);
});
// 测试:隐藏的菜单项
it('should ignore hidden menu items', () => {
const routes: NocoBaseDesktopRoute[] = [
{
id: 1,
schemaUid: 'page1',
type: NocoBaseDesktopRouteType.page,
title: 'Page 1',
hideInMenu: true,
},
{
id: 2,
schemaUid: 'page2',
type: NocoBaseDesktopRouteType.page,
title: 'Page 2',
},
];
const result = findFirstPageRoute(routes);
expect(result).toEqual(routes[1]);
});
// 测试:嵌套路由
it('should find page route in nested group', () => {
const routes: NocoBaseDesktopRoute[] = [
{
id: 1,
type: NocoBaseDesktopRouteType.group,
title: 'Group 1',
children: [
{
id: 11,
schemaUid: 'page1',
type: NocoBaseDesktopRouteType.page,
title: 'Page 1',
},
],
},
];
const result = findFirstPageRoute(routes);
expect(result).toEqual(routes[0].children[0]);
});
// 测试:多层嵌套路由
it('should find page route in deeply nested groups', () => {
const routes: NocoBaseDesktopRoute[] = [
{
id: 1,
type: NocoBaseDesktopRouteType.group,
title: 'Group 1',
children: [
{
id: 11,
type: NocoBaseDesktopRouteType.group,
title: 'Group 1-1',
children: [
{
id: 111,
schemaUid: 'page1',
type: NocoBaseDesktopRouteType.page,
title: 'Page 1',
},
],
},
],
},
];
const result = findFirstPageRoute(routes);
expect(result).toEqual(routes[0].children[0].children[0]);
});
// 测试:复杂路由结构
it('should find the first visible page in a complex route structure', () => {
const routes: NocoBaseDesktopRoute[] = [
{
id: 1,
type: NocoBaseDesktopRouteType.group,
title: 'Group 1',
hideInMenu: true,
children: [
{
id: 11,
schemaUid: 'page1',
type: NocoBaseDesktopRouteType.page,
title: 'Page 1',
},
],
},
{
id: 2,
type: NocoBaseDesktopRouteType.group,
title: 'Group 2',
children: [
{
id: 21,
schemaUid: 'page2',
type: NocoBaseDesktopRouteType.page,
title: 'Page 2',
},
],
},
];
const result = findFirstPageRoute(routes);
expect(result).toEqual(routes[1].children[0]);
});
// 测试:空组
it('should skip empty groups and find page in next group', () => {
const routes: NocoBaseDesktopRoute[] = [
{
id: 1,
type: NocoBaseDesktopRouteType.group,
title: 'Empty Group',
children: [],
},
{
id: 2,
schemaUid: 'page1',
type: NocoBaseDesktopRouteType.page,
title: 'Page 1',
},
];
const result = findFirstPageRoute(routes);
expect(result).toEqual(routes[1]);
});
});

View File

@ -723,27 +723,11 @@ export const InternalAdminLayout = () => {
);
};
function getDefaultPageUid(routes: NocoBaseDesktopRoute[]) {
// Find the first route of type "page"
for (const route of routes) {
if (route.type === NocoBaseDesktopRouteType.page) {
return route.schemaUid;
}
if (route.children?.length) {
const result = getDefaultPageUid(route.children);
if (result) {
return result;
}
}
}
}
const NavigateToDefaultPage: FC = (props) => {
const { allAccessRoutes } = useAllAccessDesktopRoutes();
const location = useLocationNoUpdate();
const defaultPageUid = getDefaultPageUid(allAccessRoutes);
const defaultPageUid = findFirstPageRoute(allAccessRoutes)?.schemaUid;
return (
<>
@ -975,16 +959,17 @@ function findRouteById(id: string, treeArray: any[]) {
return null;
}
function findFirstPageRoute(routes: NocoBaseDesktopRoute[]) {
export function findFirstPageRoute(routes: NocoBaseDesktopRoute[]) {
if (!routes) return;
for (const route of routes) {
for (const route of routes.filter((item) => !item.hideInMenu)) {
if (route.type === NocoBaseDesktopRouteType.page) {
return route;
}
if (route.children?.length) {
return findFirstPageRoute(route.children);
if (route.type === NocoBaseDesktopRouteType.group && route.children?.length) {
const result = findFirstPageRoute(route.children);
if (result) return result;
}
}
}

View File

@ -121,7 +121,6 @@ export const InternalActionDrawer: React.FC<ActionDrawerProps> = observer(
},
[footerNodeName],
);
return (
<ActionContextNoRerender>
<zIndexContext.Provider value={zIndex}>
@ -129,7 +128,7 @@ export const InternalActionDrawer: React.FC<ActionDrawerProps> = observer(
<Drawer
zIndex={zIndex}
width={openSizeWidthMap.get(openSize)}
title={t(field.title, { ns: NAMESPACE_UI_SCHEMA })}
title={typeof field.title === 'string' ? t(field.title, { ns: NAMESPACE_UI_SCHEMA }) : field.title}
{...others}
{...drawerProps}
rootStyle={rootStyle}

View File

@ -72,7 +72,7 @@ const InternalActionBar: FC = (props: any) => {
<Portal>
<DndContext>
<div
style={{ display: 'flex', alignItems: 'center', gap: 8, ...style, marginTop: 0 }}
style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 0, ...style }}
{...others}
className={cx(others.className, 'nb-action-bar')}
>

View File

@ -39,15 +39,17 @@ const formItemWrapCss = css`
.ant-description-textarea img {
max-width: 100%;
}
&.ant-formily-item-layout-vertical .ant-formily-item-label {
&.ant-formily-item-layout-horizontal.ant-formily-item-label-wrap {
.ant-formily-item-label {
display: inline;
.ant-formily-item-label-tooltip-icon {
display: inline;
}
padding-right: 5px;
.ant-formily-item-label-tooltip-icon,
.ant-formily-item-label-content {
display: inline;
}
}
}
`;
const formItemLabelCss = css`

View File

@ -86,11 +86,6 @@ const useParseDefaultValue = () => {
field &&
((isVariable(fieldSchema.default) && field.value == null) || field.value === fieldSchema.default || forceUpdate)
) {
// 一个变量字符串如果显示出来会比较奇怪
if (isVariable(field.value)) {
await field.reset({ forceClear: true });
}
field.loading = true;
const collectionField = !fieldSchema.name.toString().includes('.') && collection?.getField(fieldSchema.name);

View File

@ -20,6 +20,7 @@ import { useAttach, useComponent } from '../..';
import { useApp } from '../../../application';
import { getCardItemSchema } from '../../../block-provider';
import { useTemplateBlockContext } from '../../../block-provider/TemplateBlockProvider';
import { useDataBlockProps } from '../../../data-source';
import { useDataBlockRequest } from '../../../data-source/data-block/DataBlockRequestProvider';
import { NocoBaseRecursionField } from '../../../formily/NocoBaseRecursionField';
import { withDynamicSchemaProps } from '../../../hoc/withDynamicSchemaProps';
@ -150,12 +151,15 @@ const WithForm = (props: WithFormProps) => {
const linkageRules: any[] =
(getLinkageRules(fieldSchema) || fieldSchema.parent?.['x-linkage-rules'])?.filter((k) => !k.disabled) || [];
// 关闭弹窗之前,如果有未保存的数据,是否要二次确认
const { confirmBeforeClose = true } = useDataBlockProps() || ({} as any);
useEffect(() => {
const id = uid();
form.addEffects(id, () => {
onFormInputChange(() => {
setFormValueChanged?.(true);
setFormValueChanged?.(confirmBeforeClose);
});
});
@ -166,7 +170,7 @@ const WithForm = (props: WithFormProps) => {
return () => {
form.removeEffects(id);
};
}, [form, props.disabled, setFormValueChanged]);
}, [form, props.disabled, setFormValueChanged, confirmBeforeClose]);
useEffect(() => {
if (loading) {
@ -219,17 +223,19 @@ const WithForm = (props: WithFormProps) => {
const WithoutForm = (props) => {
const fieldSchema = useFieldSchema();
const { setFormValueChanged } = useActionContext();
// 关闭弹窗之前,如果有未保存的数据,是否要二次确认
const { confirmBeforeClose = true } = useDataBlockProps() || ({} as any);
const form = useMemo(
() =>
createForm({
disabled: props.disabled,
effects() {
onFormInputChange((form) => {
setFormValueChanged?.(true);
setFormValueChanged?.(confirmBeforeClose);
});
},
}),
[],
[confirmBeforeClose],
);
return fieldSchema['x-decorator'] === 'FormV2' ? (
<FormDecorator form={form} {...props} />

View File

@ -158,7 +158,7 @@ const InternalList = withSkeletonComponent(
>
<AntdList
{...props}
pagination={!meta || !field.value?.length ? false : paginationProps}
pagination={!meta || !field.value?.length || count <= field.value?.length ? false : paginationProps}
loading={service?.loading}
>
{field.value?.length

View File

@ -8,3 +8,4 @@
*/
export * from './List';
export { useListBlockContext } from './List.Decorator';

View File

@ -11,6 +11,7 @@ import { DeleteOutlined, DownloadOutlined, InboxOutlined, LoadingOutlined, PlusO
import { Field } from '@formily/core';
import { connect, mapProps, mapReadPretty, useField } from '@formily/react';
import { Alert, Upload as AntdUpload, Button, Modal, Progress, Space, Tooltip } from 'antd';
import { createGlobalStyle } from 'antd-style';
import useUploadStyle from 'antd/es/upload/style';
import cls from 'classnames';
import { saveAs } from 'file-saver';
@ -36,6 +37,12 @@ import {
import { useStyles } from './style';
import type { ComposedUpload, DraggerProps, DraggerV2Props, UploadProps } from './type';
const LightBoxGlobalStyle = createGlobalStyle`
.ReactModal__Overlay.ReactModal__Overlay--after-open {
z-index: 3000 !important; // 避免预览图片时被遮挡
}
`;
attachmentFileTypes.add({
match(file) {
return matchMimetype(file, 'image/*');
@ -62,6 +69,8 @@ attachmentFileTypes.add({
[index, list],
);
return (
<>
<LightBoxGlobalStyle />
<LightBox
// discourageDownloads={true}
mainSrc={list[index]?.url}
@ -85,6 +94,7 @@ attachmentFileTypes.add({
</button>,
]}
/>
</>
);
},
});

View File

@ -398,9 +398,8 @@ export function Input(props: VariableInputProps) {
const disabled = props.disabled || form.disabled;
return wrapSSR(
<>
<Space.Compact style={style} className={classNames(componentCls, hashId, className)}>
{/* 确保所有ant input样式都已加载 */}
<AntInput style={{ display: 'none' }} />
{variable ? (
<div
className={cx(
@ -497,6 +496,9 @@ export function Input(props: VariableInputProps) {
)}
</Cascader>
)}
</Space.Compact>,
</Space.Compact>
{/* 确保所有ant input样式都已加载, 放到Compact中会导致Compact中的Input样式不对 */}
<AntInput style={{ display: 'none' }} />
</>,
);
}

View File

@ -7,9 +7,9 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React, { useRef, useState } from 'react';
import { css } from '@emotion/css';
import { Button, Input } from 'antd';
import React, { useRef, useState } from 'react';
import { VariableSelect } from './VariableSelect';
// NOTE: https://stackoverflow.com/questions/23892547/what-is-the-best-way-to-trigger-onchange-event-in-react-js/46012210#46012210
@ -80,6 +80,7 @@ export function RawTextArea(props): JSX.Element {
setOptions={setOptions}
onInsert={onInsert}
changeOnSelect={changeOnSelect}
disabled={others.disabled}
/>
</Button.Group>
</div>

View File

@ -9,7 +9,7 @@
import { css, cx } from '@emotion/css';
import { useForm } from '@formily/react';
import { Space, theme } from 'antd';
import { Input as AntInput, Space, theme } from 'antd';
import type { CascaderProps, DefaultOptionType } from 'antd/lib/cascader';
import useInputStyle from 'antd/es/input/style';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
@ -422,6 +422,7 @@ export function TextArea(props: TextAreaProps) {
);
const disabled = props.disabled || form.disabled;
return wrapSSR(
<>
<Space.Compact
className={cx(
componentCls,
@ -510,7 +511,10 @@ export function TextArea(props: TextAreaProps) {
fieldNames={fieldNames || defaultFieldNames}
disabled={disabled}
/>
</Space.Compact>,
</Space.Compact>
{/* 确保所有ant input样式都已加载, 放到Compact中会导致Compact中的Input样式不对 */}
<AntInput style={{ display: 'none' }} />
</>,
);
}

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

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,263 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This program is offered under a commercial license.
* For more information, see <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, TableProps } from 'antd';
import { departmentCollection } from '../collections/departments';
import { useDepartmentTranslation } from '../locale';
import { useField } from '@formily/react';
import { Field } from '@formily/core';
import { uid } from '@formily/shared';
import { getDepartmentTitle } from '../utils';
const ExpandMetaContext = createContext<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),
},
] as TableProps['columns']
}
rowSelection={{
selectedRowKeys: (field?.value || []).map((dept: any) => dept.id),
onChange: (keys, depts) => field?.setValue?.(depts),
getCheckboxProps: (record: any) => ({
disabled: disabled(record),
}),
}}
pagination={{
showSizeChanger: true,
...pagination,
onChange(page, pageSize) {
run({
...(ctx?.params?.[0] || {}),
page,
pageSize,
});
},
}}
dataSource={hasFilter ? data?.data || [] : treeData}
expandable={{
onExpand: (expanded, record) => {
loadData({
key: record.id,
children: record.children,
});
},
expandedRowKeys: expandedKeys,
onExpandedRowsChange: (keys) => setExpandedKeys(keys),
}}
/>
);
};
const RequestProvider: React.FC<{
useDataSource: any;
}> = (props) => {
const [expandedKeys, setExpandedKeys] = useState([]);
const [hasFilter, setHasFilter] = useState(false);
const { useDataSource } = props;
const service = useDataSource({
manual: true,
});
useEffect(() => {
service.run({
filter: {
parentId: null,
},
pageSize: 10,
});
}, []);
return (
<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: ['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,281 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { UserDataResourceManager } from '@nocobase/plugin-user-data-sync';
import { MockDatabase, MockServer, createMockServer } from '@nocobase/test';
import PluginUserDataSyncServer from '@nocobase/plugin-user-data-sync';
describe('department data sync', async () => {
let app: MockServer;
let db: MockDatabase;
let resourceManager: UserDataResourceManager;
beforeEach(async () => {
app = await createMockServer({
plugins: ['user-data-sync', 'users', 'departments'],
});
db = app.db;
const plugin = app.pm.get('user-data-sync') as PluginUserDataSyncServer;
resourceManager = plugin.resourceManager;
});
afterEach(async () => {
await db.clean({ drop: true });
await app.destroy();
});
it('should create department', async () => {
await resourceManager.updateOrCreate({
sourceName: 'test',
dataType: 'department',
records: [
{
uid: '1',
title: 'test',
},
{
uid: '2',
title: 'sub-test',
parentUid: '1',
},
],
});
const department = await db.getRepository('departments').findOne({
filter: {
title: 'test',
},
appends: ['children'],
});
expect(department).toBeTruthy();
expect(department.title).toBe('test');
expect(department.children).toHaveLength(1);
expect(department.children[0].title).toBe('sub-test');
});
it('should update department', async () => {
await resourceManager.updateOrCreate({
sourceName: 'test',
dataType: 'department',
records: [
{
uid: '1',
title: 'test',
},
],
});
const department = await db.getRepository('departments').findOne({
filter: {
title: 'test',
},
appends: ['children'],
});
expect(department).toBeTruthy();
expect(department.children).toHaveLength(0);
await resourceManager.updateOrCreate({
sourceName: 'test',
dataType: 'department',
records: [
{
uid: '1',
title: 'test2',
},
{
uid: '2',
title: 'sub-test',
parentUid: '1',
},
],
});
const department2 = await db.getRepository('departments').findOne({
filter: {
id: department.id,
},
appends: ['children'],
});
expect(department2).toBeTruthy();
expect(department2.title).toBe('test2');
expect(department2.children).toHaveLength(1);
await resourceManager.updateOrCreate({
sourceName: 'test',
dataType: 'department',
records: [
{
uid: '2',
title: 'sub-test',
},
],
});
const department3 = await db.getRepository('departments').findOne({
filter: {
id: department2.children[0].id,
},
appends: ['parent'],
});
expect(department3).toBeTruthy();
expect(department3.parent).toBeNull();
});
it('should update user department', async () => {
await resourceManager.updateOrCreate({
sourceName: 'test',
dataType: 'department',
records: [
{
uid: '12',
title: 'test',
},
],
});
const department = await db.getRepository('departments').findOne({
filter: {
title: 'test',
},
});
expect(department).toBeTruthy();
await resourceManager.updateOrCreate({
sourceName: 'test',
dataType: 'user',
records: [
{
uid: '1',
nickname: 'test',
email: 'test@nocobase.com',
departments: ['12'],
},
],
});
const user = await db.getRepository('users').findOne({
filter: {
email: 'test@nocobase.com',
},
appends: ['departments'],
});
expect(user).toBeTruthy();
expect(user.departments).toHaveLength(1);
expect(user.departments[0].id).toBe(department.id);
const departmentUser = await db.getRepository('departmentsUsers').findOne({
filter: {
departmentId: department.id,
userId: user.id,
},
});
expect(departmentUser).toBeTruthy();
expect(departmentUser.isOwner).toBe(false);
expect(departmentUser.isMain).toBe(false);
await resourceManager.updateOrCreate({
sourceName: 'test',
dataType: 'user',
records: [
{
uid: '1',
nickname: 'test',
email: 'test@nocobase.com',
departments: [
{
uid: '12',
isOwner: true,
isMain: true,
},
],
},
],
});
const departmentUser2 = await db.getRepository('departmentsUsers').findOne({
filter: {
departmentId: department.id,
userId: user.id,
},
});
expect(departmentUser2).toBeTruthy();
expect(departmentUser2.isOwner).toBe(true);
expect(departmentUser2.isMain).toBe(true);
await resourceManager.updateOrCreate({
sourceName: 'test',
dataType: 'user',
records: [
{
uid: '2',
nickname: 'test2',
email: 'test2@nocobase.com',
departments: [
{
uid: '12',
isOwner: true,
isMain: false,
},
],
},
],
});
const user2 = await db.getRepository('users').findOne({
filter: {
email: 'test2@nocobase.com',
},
appends: ['departments'],
});
expect(user2).toBeTruthy();
expect(user2.departments).toHaveLength(1);
expect(user2.departments[0].id).toBe(department.id);
const departmentUser3 = await db.getRepository('departmentsUsers').findOne({
filter: {
departmentId: department.id,
userId: user2.id,
},
});
expect(departmentUser3).toBeTruthy();
expect(departmentUser3.isOwner).toBe(true);
expect(departmentUser3.isMain).toBe(false);
});
it('should update department custom field', async () => {
const departmemntCollection = db.getCollection('departments');
departmemntCollection.addField('customField', { type: 'string' });
await db.sync({
alter: true,
});
await resourceManager.updateOrCreate({
sourceName: 'test',
dataType: 'department',
records: [
{
uid: '1',
title: 'test',
customField: 'testField',
},
],
});
const department = await db.getRepository('departments').findOne({
filter: {
title: 'test',
},
});
expect(department).toBeTruthy();
expect(department.customField).toBe('testField');
await resourceManager.updateOrCreate({
sourceName: 'test',
dataType: 'user',
matchKey: 'email',
records: [
{
uid: '1',
nickname: 'test',
email: 'test@nocobase.com',
customField: 'testField2',
},
],
});
const department2 = await db.getRepository('users').findOne({
filter: {
id: department.id,
},
});
expect(department2).toBeTruthy();
expect(department2.customField).toBe('testField2');
});
});

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: ['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: ['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: ['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: ['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: ['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';

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