diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e16271476..958e0d3e98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md index 8ab0be9a2f..933a774f55 100644 --- a/CHANGELOG.zh-CN.md +++ b/CHANGELOG.zh-CN.md @@ -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 ### 🚀 优化 diff --git a/packages/core/cli/src/util.js b/packages/core/cli/src/util.js index 742c60cb41..35b496db1f 100644 --- a/packages/core/cli/src/util.js +++ b/packages/core/cli/src/util.js @@ -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 () { diff --git a/packages/core/client/src/block-provider/FilterFormBlockProvider.tsx b/packages/core/client/src/block-provider/FilterFormBlockProvider.tsx index f6eacc4ede..1a46420a4b 100644 --- a/packages/core/client/src/block-provider/FilterFormBlockProvider.tsx +++ b/packages/core/client/src/block-provider/FilterFormBlockProvider.tsx @@ -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) => { }} > false}> - + diff --git a/packages/core/client/src/block-provider/hooks/index.ts b/packages/core/client/src/block-provider/hooks/index.ts index 9dfead7b02..ce2227b54e 100644 --- a/packages/core/client/src/block-provider/hooks/index.ts +++ b/packages/core/client/src/block-provider/hooks/index.ts @@ -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; diff --git a/packages/core/client/src/data-source/collection-field/CollectionField.tsx b/packages/core/client/src/data-source/collection-field/CollectionField.tsx index b36f758c8f..532a27666f 100644 --- a/packages/core/client/src/data-source/collection-field/CollectionField.tsx +++ b/packages/core/client/src/data-source/collection-field/CollectionField.tsx @@ -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 ; + 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 ; }; export const CollectionField = connect((props) => { diff --git a/packages/core/client/src/locale/it-IT.json b/packages/core/client/src/locale/it-IT.json index 36026621d2..d6b0f39bf9 100644 --- a/packages/core/client/src/locale/it-IT.json +++ b/packages/core/client/src/locale/it-IT.json @@ -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." } diff --git a/packages/core/client/src/locale/pt-BR.json b/packages/core/client/src/locale/pt-BR.json index ce5cff7955..cf7a17d36a 100644 --- a/packages/core/client/src/locale/pt-BR.json +++ b/packages/core/client/src/locale/pt-BR.json @@ -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." } diff --git a/packages/core/client/src/locale/ru-RU.json b/packages/core/client/src/locale/ru-RU.json index 205f5a91ad..a86f02e638 100644 --- a/packages/core/client/src/locale/ru-RU.json +++ b/packages/core/client/src/locale/ru-RU.json @@ -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.": "После успешной отправки выбранные блоки данных будут автоматически обновлены." } diff --git a/packages/core/client/src/locale/tr-TR.json b/packages/core/client/src/locale/tr-TR.json index 8e1fc32b21..7a8dfc99cb 100644 --- a/packages/core/client/src/locale/tr-TR.json +++ b/packages/core/client/src/locale/tr-TR.json @@ -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." } diff --git a/packages/core/client/src/locale/uk-UA.json b/packages/core/client/src/locale/uk-UA.json index 06202eff15..cf54fe03c6 100644 --- a/packages/core/client/src/locale/uk-UA.json +++ b/packages/core/client/src/locale/uk-UA.json @@ -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.": "Після успішної подачі вибрані блоки даних будуть автоматично оновлені." } diff --git a/packages/core/client/src/locale/zh-CN.json b/packages/core/client/src/locale/zh-CN.json index 82c073d220..35f67a241d 100644 --- a/packages/core/client/src/locale/zh-CN.json +++ b/packages/core/client/src/locale/zh-CN.json @@ -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.": "提交成功后,会自动刷新这里选中的数据区块。" } diff --git a/packages/core/client/src/locale/zh-TW.json b/packages/core/client/src/locale/zh-TW.json index e7e169dc15..5abc83e35d 100644 --- a/packages/core/client/src/locale/zh-TW.json +++ b/packages/core/client/src/locale/zh-TW.json @@ -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.": "提交成功後,選中的數據區塊將自動刷新。" } diff --git a/packages/core/client/src/modules/blocks/filter-blocks/FilterCollectionField.tsx b/packages/core/client/src/modules/blocks/filter-blocks/FilterCollectionField.tsx index a3e45281d1..3a8d26d7ef 100644 --- a/packages/core/client/src/modules/blocks/filter-blocks/FilterCollectionField.tsx +++ b/packages/core/client/src/modules/blocks/filter-blocks/FilterCollectionField.tsx @@ -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]); diff --git a/packages/core/client/src/route-switch/antd/admin-layout/__tests__/findFirstPageRoute.test.tsx b/packages/core/client/src/route-switch/antd/admin-layout/__tests__/findFirstPageRoute.test.tsx new file mode 100644 index 0000000000..5bfd4554d1 --- /dev/null +++ b/packages/core/client/src/route-switch/antd/admin-layout/__tests__/findFirstPageRoute.test.tsx @@ -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]); + }); +}); diff --git a/packages/core/client/src/route-switch/antd/admin-layout/index.tsx b/packages/core/client/src/route-switch/antd/admin-layout/index.tsx index 121f85d61c..16e7df68af 100644 --- a/packages/core/client/src/route-switch/antd/admin-layout/index.tsx +++ b/packages/core/client/src/route-switch/antd/admin-layout/index.tsx @@ -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; } } } diff --git a/packages/core/client/src/schema-component/antd/action/Action.Drawer.tsx b/packages/core/client/src/schema-component/antd/action/Action.Drawer.tsx index 7ee5fa3a9a..fa8e7442a9 100644 --- a/packages/core/client/src/schema-component/antd/action/Action.Drawer.tsx +++ b/packages/core/client/src/schema-component/antd/action/Action.Drawer.tsx @@ -121,7 +121,6 @@ export const InternalActionDrawer: React.FC = observer( }, [footerNodeName], ); - return ( @@ -129,7 +128,7 @@ export const InternalActionDrawer: React.FC = observer( {
diff --git a/packages/core/client/src/schema-component/antd/form-item/FormItem.tsx b/packages/core/client/src/schema-component/antd/form-item/FormItem.tsx index 7d4ad52d7c..6c015a2416 100644 --- a/packages/core/client/src/schema-component/antd/form-item/FormItem.tsx +++ b/packages/core/client/src/schema-component/antd/form-item/FormItem.tsx @@ -39,13 +39,15 @@ const formItemWrapCss = css` .ant-description-textarea img { max-width: 100%; } - &.ant-formily-item-layout-vertical .ant-formily-item-label { - display: inline; - .ant-formily-item-label-tooltip-icon { - display: inline; - } - .ant-formily-item-label-content { + &.ant-formily-item-layout-horizontal.ant-formily-item-label-wrap { + .ant-formily-item-label { display: inline; + padding-right: 5px; + + .ant-formily-item-label-tooltip-icon, + .ant-formily-item-label-content { + display: inline; + } } } `; diff --git a/packages/core/client/src/schema-component/antd/form-item/hooks/useParseDefaultValue.ts b/packages/core/client/src/schema-component/antd/form-item/hooks/useParseDefaultValue.ts index cda06cd3c0..f663975902 100644 --- a/packages/core/client/src/schema-component/antd/form-item/hooks/useParseDefaultValue.ts +++ b/packages/core/client/src/schema-component/antd/form-item/hooks/useParseDefaultValue.ts @@ -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); diff --git a/packages/core/client/src/schema-component/antd/form-v2/Form.tsx b/packages/core/client/src/schema-component/antd/form-v2/Form.tsx index 62f024ccf1..715160493f 100644 --- a/packages/core/client/src/schema-component/antd/form-v2/Form.tsx +++ b/packages/core/client/src/schema-component/antd/form-v2/Form.tsx @@ -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' ? ( diff --git a/packages/core/client/src/schema-component/antd/list/List.tsx b/packages/core/client/src/schema-component/antd/list/List.tsx index d6197f27bd..4f6db0faea 100644 --- a/packages/core/client/src/schema-component/antd/list/List.tsx +++ b/packages/core/client/src/schema-component/antd/list/List.tsx @@ -158,7 +158,7 @@ const InternalList = withSkeletonComponent( > {field.value?.length diff --git a/packages/core/client/src/schema-component/antd/list/index.ts b/packages/core/client/src/schema-component/antd/list/index.ts index 5e8ff290de..afe657ed04 100644 --- a/packages/core/client/src/schema-component/antd/list/index.ts +++ b/packages/core/client/src/schema-component/antd/list/index.ts @@ -8,3 +8,4 @@ */ export * from './List'; +export { useListBlockContext } from './List.Decorator'; diff --git a/packages/core/client/src/schema-component/antd/upload/Upload.tsx b/packages/core/client/src/schema-component/antd/upload/Upload.tsx index 6a1893ce3e..6c42d7bd68 100644 --- a/packages/core/client/src/schema-component/antd/upload/Upload.tsx +++ b/packages/core/client/src/schema-component/antd/upload/Upload.tsx @@ -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,29 +69,32 @@ attachmentFileTypes.add({ [index, list], ); return ( - onSwitchIndex(null)} - onMovePrevRequest={() => onSwitchIndex((index + list.length - 1) % list.length)} - onMoveNextRequest={() => onSwitchIndex((index + 1) % list.length)} - imageTitle={list[index]?.title} - toolbarButtons={[ - , - ]} - /> + <> + + onSwitchIndex(null)} + onMovePrevRequest={() => onSwitchIndex((index + list.length - 1) % list.length)} + onMoveNextRequest={() => onSwitchIndex((index + 1) % list.length)} + imageTitle={list[index]?.title} + toolbarButtons={[ + , + ]} + /> + ); }, }); diff --git a/packages/core/client/src/schema-component/antd/variable/Input.tsx b/packages/core/client/src/schema-component/antd/variable/Input.tsx index 9a95ae2ded..23993aad16 100644 --- a/packages/core/client/src/schema-component/antd/variable/Input.tsx +++ b/packages/core/client/src/schema-component/antd/variable/Input.tsx @@ -398,105 +398,107 @@ export function Input(props: VariableInputProps) { const disabled = props.disabled || form.disabled; return wrapSSR( - - {/* 确保所有ant input样式都已加载 */} - - {variable ? ( -
+ <> + + {variable ? (
- - {variableText.map((item, index) => { - return ( - - {index ? ' / ' : ''} - {item} - - ); - })} - -
- {!disabled ? ( - - - - ) : null} -
- ) : ( -
- {children && (isFieldValue || !nullable) ? ( - children - ) : ConstantComponent ? ( - - ) : null} -
- )} - {hideVariableButton ? null : ( - - {button ?? ( - + {variableText.map((item, index) => { + return ( + + {index ? ' / ' : ''} + {item} + + ); + })} + +
+ {!disabled ? ( + + + + ) : null} + + ) : ( +
+ {children && (isFieldValue || !nullable) ? ( + children + ) : ConstantComponent ? ( + + ) : null} +
+ )} + {hideVariableButton ? null : ( + + {button ?? ( + - )} - - )} - , + type={variable ? 'primary' : 'default'} + disabled={disabled} + /> + )} + + )} + + {/* 确保所有ant input样式都已加载, 放到Compact中会导致Compact中的Input样式不对 */} + + , ); } diff --git a/packages/core/client/src/schema-component/antd/variable/RawTextArea.tsx b/packages/core/client/src/schema-component/antd/variable/RawTextArea.tsx index f37165bef8..f75c91e6d5 100644 --- a/packages/core/client/src/schema-component/antd/variable/RawTextArea.tsx +++ b/packages/core/client/src/schema-component/antd/variable/RawTextArea.tsx @@ -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} /> diff --git a/packages/core/client/src/schema-component/antd/variable/TextArea.tsx b/packages/core/client/src/schema-component/antd/variable/TextArea.tsx index 1a43e849b4..8669c8a3b6 100644 --- a/packages/core/client/src/schema-component/antd/variable/TextArea.tsx +++ b/packages/core/client/src/schema-component/antd/variable/TextArea.tsx @@ -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,95 +422,99 @@ export function TextArea(props: TextAreaProps) { ); const disabled = props.disabled || form.disabled; return wrapSSR( - .x-button { - height: min-content; - } - `, - )} - > - {addonBefore && ( -
- {addonBefore} -
- )} -
+ .x-button { + height: min-content; } `, )} - ref={inputRef} - contentEditable={!disabled} - dangerouslySetInnerHTML={{ __html: html }} - /> - - , + > + {addonBefore && ( +
+ {addonBefore} +
+ )} +
+ + + {/* 确保所有ant input样式都已加载, 放到Compact中会导致Compact中的Input样式不对 */} + + , ); } diff --git a/packages/plugins/@nocobase/plugin-departments/.npmignore b/packages/plugins/@nocobase/plugin-departments/.npmignore new file mode 100644 index 0000000000..65f5e8779f --- /dev/null +++ b/packages/plugins/@nocobase/plugin-departments/.npmignore @@ -0,0 +1,2 @@ +/node_modules +/src diff --git a/packages/plugins/@nocobase/plugin-departments/README.md b/packages/plugins/@nocobase/plugin-departments/README.md new file mode 100644 index 0000000000..cb4fb63505 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-departments/README.md @@ -0,0 +1 @@ +# @nocobase/plugin-department diff --git a/packages/plugins/@nocobase/plugin-departments/client.d.ts b/packages/plugins/@nocobase/plugin-departments/client.d.ts new file mode 100644 index 0000000000..6c459cbac4 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-departments/client.d.ts @@ -0,0 +1,2 @@ +export * from './dist/client'; +export { default } from './dist/client'; diff --git a/packages/plugins/@nocobase/plugin-departments/client.js b/packages/plugins/@nocobase/plugin-departments/client.js new file mode 100644 index 0000000000..b6e3be70e6 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-departments/client.js @@ -0,0 +1 @@ +module.exports = require('./dist/client/index.js'); diff --git a/packages/plugins/@nocobase/plugin-departments/package.json b/packages/plugins/@nocobase/plugin-departments/package.json new file mode 100644 index 0000000000..2f2400e325 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-departments/package.json @@ -0,0 +1,19 @@ +{ + "name": "@nocobase/plugin-departments", + "displayName": "Departments", + "displayName.zh-CN": "部门", + "description": "Organize users by departments, set hierarchical relationships, link roles to control permissions, and use departments as variables in workflows and expressions.", + "description.zh-CN": "以部门来组织用户,设定上下级关系,绑定角色控制权限,并支持作为变量用于工作流和表达式。", + "version": "1.7.0-alpha.10", + "main": "dist/server/index.js", + "peerDependencies": { + "@nocobase/actions": "1.x", + "@nocobase/client": "1.x", + "@nocobase/server": "1.x", + "@nocobase/test": "1.x" + }, + "keywords": [ + "Users & permissions" + ], + "gitHead": "ce89d10eec858c413f60e001e83c7c8cf2645f5e" +} diff --git a/packages/plugins/@nocobase/plugin-departments/server.d.ts b/packages/plugins/@nocobase/plugin-departments/server.d.ts new file mode 100644 index 0000000000..c41081ddc6 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-departments/server.d.ts @@ -0,0 +1,2 @@ +export * from './dist/server'; +export { default } from './dist/server'; diff --git a/packages/plugins/@nocobase/plugin-departments/server.js b/packages/plugins/@nocobase/plugin-departments/server.js new file mode 100644 index 0000000000..972842039a --- /dev/null +++ b/packages/plugins/@nocobase/plugin-departments/server.js @@ -0,0 +1 @@ +module.exports = require('./dist/server/index.js'); diff --git a/packages/plugins/@nocobase/plugin-departments/src/client/ResourcesProvider.tsx b/packages/plugins/@nocobase/plugin-departments/src/client/ResourcesProvider.tsx new file mode 100644 index 0000000000..2ad0daac83 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-departments/src/client/ResourcesProvider.tsx @@ -0,0 +1,110 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This program is offered under a commercial license. + * For more information, see + */ + +import { CollectionProvider_deprecated, ResourceActionContext, TableBlockContext, useRequest } from '@nocobase/client'; +import React, { useContext, useEffect, useMemo } from 'react'; +import { departmentCollection } from './collections/departments'; +import { userCollection } from './collections/users'; +import { FormContext } from '@formily/react'; +import { createForm } from '@formily/core'; + +export const ResourcesContext = React.createContext<{ + user: any; + setUser?: (user: any) => void; + department: any; // department name + setDepartment?: (department: any) => void; + departmentsResource?: any; + usersResource?: any; +}>({ + user: {}, + department: {}, +}); + +export const ResourcesProvider: React.FC = (props) => { + const [user, setUser] = React.useState(null); + const [department, setDepartment] = React.useState(null); + + const userService = useRequest({ + resource: 'users', + action: 'list', + params: { + appends: ['departments', 'departments.parent(recursively=true)'], + filter: department + ? { + 'departments.id': department.id, + } + : {}, + pageSize: 20, + }, + }); + + useEffect(() => { + userService.run(); + }, [department]); + + const departmentRequest = { + resource: 'departments', + action: 'list', + params: { + paginate: false, + filter: { + parentId: null, + }, + }, + }; + const departmentService = useRequest(departmentRequest); + + return ( + + {props.children} + + ); +}; + +export const DepartmentsListProvider: React.FC = (props) => { + const { departmentsResource } = useContext(ResourcesContext); + const { service } = departmentsResource || {}; + return ( + + {props.children} + + ); +}; + +export const UsersListProvider: React.FC = (props) => { + const { usersResource } = useContext(ResourcesContext); + const { service } = usersResource || {}; + const form = useMemo(() => createForm(), []); + const field = form.createField({ name: 'table' }); + return ( + + + {props.children} + + + ); +}; diff --git a/packages/plugins/@nocobase/plugin-departments/src/client/collections/departments.ts b/packages/plugins/@nocobase/plugin-departments/src/client/collections/departments.ts new file mode 100644 index 0000000000..38105c0b2b --- /dev/null +++ b/packages/plugins/@nocobase/plugin-departments/src/client/collections/departments.ts @@ -0,0 +1,115 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This program is offered under a commercial license. + * For more information, see + */ + +export const departmentCollection = { + name: 'departments', + fields: [ + { + type: 'bigInt', + name: 'id', + primaryKey: true, + autoIncrement: true, + interface: 'id', + uiSchema: { + type: 'id', + title: '{{t("ID")}}', + }, + }, + { + name: 'title', + type: 'string', + interface: 'input', + uiSchema: { + type: 'string', + title: '{{t("Department name")}}', + 'x-component': 'Input', + required: true, + }, + }, + { + name: 'parent', + type: 'belongsTo', + interface: 'm2o', + collectionName: 'departments', + foreignKey: 'parentId', + target: 'departments', + targetKey: 'id', + treeParent: true, + uiSchema: { + title: '{{t("Superior department")}}', + 'x-component': 'DepartmentSelect', + // 'x-component-props': { + // multiple: false, + // fieldNames: { + // label: 'title', + // value: 'id', + // }, + // }, + }, + }, + { + interface: 'm2m', + type: 'belongsToMany', + name: 'roles', + target: 'roles', + collectionName: 'departments', + through: 'departmentsRoles', + foreignKey: 'departmentId', + otherKey: 'roleName', + targetKey: 'name', + sourceKey: 'id', + uiSchema: { + title: '{{t("Roles")}}', + 'x-component': 'AssociationField', + 'x-component-props': { + multiple: true, + fieldNames: { + label: 'title', + value: 'name', + }, + }, + }, + }, + { + interface: 'm2m', + type: 'belongsToMany', + name: 'owners', + collectionName: 'departments', + target: 'users', + through: 'departmentsUsers', + foreignKey: 'departmentId', + otherKey: 'userId', + targetKey: 'id', + sourceKey: 'id', + scope: { + isOwner: true, + }, + uiSchema: { + title: '{{t("Owners")}}', + 'x-component': 'AssociationField', + 'x-component-props': { + multiple: true, + fieldNames: { + label: 'nickname', + value: 'id', + }, + }, + }, + }, + ], +}; diff --git a/packages/plugins/@nocobase/plugin-departments/src/client/collections/users.ts b/packages/plugins/@nocobase/plugin-departments/src/client/collections/users.ts new file mode 100644 index 0000000000..ba309455a7 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-departments/src/client/collections/users.ts @@ -0,0 +1,142 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This program is offered under a commercial license. + * For more information, see + */ + +export const userCollection = { + name: 'users', + fields: [ + { + name: 'id', + type: 'bigInt', + autoIncrement: true, + primaryKey: true, + allowNull: false, + uiSchema: { type: 'number', title: '{{t("ID")}}', 'x-component': 'InputNumber', 'x-read-pretty': true }, + interface: 'id', + }, + { + interface: 'input', + type: 'string', + name: 'nickname', + uiSchema: { + type: 'string', + title: '{{t("Nickname")}}', + 'x-component': 'Input', + }, + }, + { + interface: 'input', + type: 'string', + name: 'username', + unique: true, + uiSchema: { + type: 'string', + title: '{{t("Username")}}', + 'x-component': 'Input', + 'x-validator': { username: true }, + required: true, + }, + }, + { + interface: 'email', + type: 'string', + name: 'email', + unique: true, + uiSchema: { + type: 'string', + title: '{{t("Email")}}', + 'x-component': 'Input', + 'x-validator': 'email', + required: true, + }, + }, + { + interface: 'phone', + type: 'string', + name: 'phone', + unique: true, + uiSchema: { + type: 'string', + title: '{{t("Phone")}}', + 'x-component': 'Input', + 'x-validator': 'phone', + required: true, + }, + }, + { + interface: 'm2m', + type: 'belongsToMany', + name: 'roles', + target: 'roles', + foreignKey: 'userId', + otherKey: 'roleName', + onDelete: 'CASCADE', + sourceKey: 'id', + targetKey: 'name', + through: 'rolesUsers', + uiSchema: { + type: 'array', + title: '{{t("Roles")}}', + 'x-component': 'AssociationField', + 'x-component-props': { + multiple: true, + fieldNames: { + label: 'title', + value: 'name', + }, + }, + }, + }, + { + name: 'departments', + type: 'belongsToMany', + interface: 'm2m', + target: 'departments', + foreignKey: 'userId', + otherKey: 'departmentId', + onDelete: 'CASCADE', + sourceKey: 'id', + targetKey: 'id', + through: 'departmentsUsers', + uiSchema: { + type: 'array', + title: '{{t("Departments")}}', + 'x-component': 'DepartmentField', + }, + }, + { + interface: 'm2m', + type: 'belongsToMany', + name: 'mainDepartment', + target: 'departments', + foreignKey: 'userId', + otherKey: 'departmentId', + onDelete: 'CASCADE', + sourceKey: 'id', + targetKey: 'id', + through: 'departmentsUsers', + throughScope: { + isMain: true, + }, + uiSchema: { + type: 'array', + title: '{{t("Main department")}}', + 'x-component': 'DepartmentField', + }, + }, + ], +}; diff --git a/packages/plugins/@nocobase/plugin-departments/src/client/components/DepartmentOwnersField.tsx b/packages/plugins/@nocobase/plugin-departments/src/client/components/DepartmentOwnersField.tsx new file mode 100644 index 0000000000..afcb651250 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-departments/src/client/components/DepartmentOwnersField.tsx @@ -0,0 +1,35 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This program is offered under a commercial license. + * For more information, see + */ + +import { SchemaSettings } from '@nocobase/client'; +import { enableLink, fieldComponent, titleField } from './fieldSettings'; + +export const DepartmentOwnersFieldSettings = new SchemaSettings({ + name: 'fieldSettings:component:DepartmentOwnersField', + items: [ + { + ...fieldComponent, + }, + { + ...titleField, + }, + { + ...enableLink, + }, + ], +}); diff --git a/packages/plugins/@nocobase/plugin-departments/src/client/components/ReadOnlyAssociationField.tsx b/packages/plugins/@nocobase/plugin-departments/src/client/components/ReadOnlyAssociationField.tsx new file mode 100644 index 0000000000..d843d62847 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-departments/src/client/components/ReadOnlyAssociationField.tsx @@ -0,0 +1,27 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This program is offered under a commercial license. + * For more information, see + */ + +import React from 'react'; +import { useDepartmentTranslation } from '../locale'; +import { AssociationField } from '@nocobase/client'; +import { connect, mapReadPretty } from '@formily/react'; + +export const ReadOnlyAssociationField = connect(() => { + const { t } = useDepartmentTranslation(); + return
{t('This field is currently not supported for use in form blocks.')}
; +}, mapReadPretty(AssociationField.ReadPretty)); diff --git a/packages/plugins/@nocobase/plugin-departments/src/client/components/UserDepartmentsField.tsx b/packages/plugins/@nocobase/plugin-departments/src/client/components/UserDepartmentsField.tsx new file mode 100644 index 0000000000..e866411db2 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-departments/src/client/components/UserDepartmentsField.tsx @@ -0,0 +1,35 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This program is offered under a commercial license. + * For more information, see + */ + +import { SchemaSettings } from '@nocobase/client'; +import { enableLink, fieldComponent, titleField } from './fieldSettings'; + +export const UserDepartmentsFieldSettings = new SchemaSettings({ + name: 'fieldSettings:component:UserDepartmentsField', + items: [ + { + ...fieldComponent, + }, + { + ...titleField, + }, + { + ...enableLink, + }, + ], +}); diff --git a/packages/plugins/@nocobase/plugin-departments/src/client/components/UserMainDepartmentField.tsx b/packages/plugins/@nocobase/plugin-departments/src/client/components/UserMainDepartmentField.tsx new file mode 100644 index 0000000000..0e8355e408 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-departments/src/client/components/UserMainDepartmentField.tsx @@ -0,0 +1,35 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This program is offered under a commercial license. + * For more information, see + */ + +import { SchemaSettings } from '@nocobase/client'; +import { enableLink, fieldComponent, titleField } from './fieldSettings'; + +export const UserMainDepartmentFieldSettings = new SchemaSettings({ + name: 'fieldSettings:component:UserMainDepartmentField', + items: [ + { + ...fieldComponent, + }, + { + ...titleField, + }, + { + ...enableLink, + }, + ], +}); diff --git a/packages/plugins/@nocobase/plugin-departments/src/client/components/fieldSettings.ts b/packages/plugins/@nocobase/plugin-departments/src/client/components/fieldSettings.ts new file mode 100644 index 0000000000..bc637afa40 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-departments/src/client/components/fieldSettings.ts @@ -0,0 +1,185 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This program is offered under a commercial license. + * For more information, see + */ + +import { + useCollectionField, + useCollectionManager_deprecated, + useCollection_deprecated, + useCompile, + useDesignable, + useFieldComponentName, + useFieldModeOptions, + useIsAddNewForm, + useTitleFieldOptions, +} from '@nocobase/client'; +import { useDepartmentTranslation } from '../locale'; +import { Field } from '@formily/core'; +import { useField, useFieldSchema, ISchema } from '@formily/react'; + +export const titleField: any = { + name: 'titleField', + type: 'select', + useComponentProps() { + const { t } = useDepartmentTranslation(); + const field = useField(); + const { dn } = useDesignable(); + const options = useTitleFieldOptions(); + const { uiSchema, fieldSchema: tableColumnSchema, collectionField: tableColumnField } = useColumnSchema(); + const schema = useFieldSchema(); + const fieldSchema = tableColumnSchema || schema; + const targetCollectionField = useCollectionField(); + const collectionField = tableColumnField || targetCollectionField; + const fieldNames = { + ...collectionField?.uiSchema?.['x-component-props']?.['fieldNames'], + ...field?.componentProps?.fieldNames, + ...fieldSchema?.['x-component-props']?.['fieldNames'], + }; + return { + title: t('Title field'), + options, + value: fieldNames?.label, + onChange(label) { + const schema = { + ['x-uid']: fieldSchema['x-uid'], + }; + const newFieldNames = { + ...collectionField?.uiSchema?.['x-component-props']?.['fieldNames'], + ...fieldSchema['x-component-props']?.['fieldNames'], + label, + }; + fieldSchema['x-component-props'] = fieldSchema['x-component-props'] || {}; + fieldSchema['x-component-props']['fieldNames'] = newFieldNames; + schema['x-component-props'] = fieldSchema['x-component-props']; + field.componentProps.fieldNames = fieldSchema['x-component-props']?.fieldNames; + const path = field.path?.splice(field.path?.length - 1, 1); + field.form.query(`${path.concat(`*.` + fieldSchema.name)}`).forEach((f) => { + f.componentProps.fieldNames = fieldNames; + }); + dn.emit('patch', { + schema, + }); + dn.refresh(); + }, + }; + }, +}; + +export const isCollectionFieldComponent = (schema: ISchema) => { + return schema['x-component'] === 'CollectionField'; +}; + +const useColumnSchema = () => { + const { getField } = useCollection_deprecated(); + const compile = useCompile(); + const columnSchema = useFieldSchema(); + const { getCollectionJoinField } = useCollectionManager_deprecated(); + const fieldSchema = columnSchema.reduceProperties((buf, s) => { + if (isCollectionFieldComponent(s)) { + return s; + } + return buf; + }, null); + if (!fieldSchema) { + return {}; + } + + const collectionField = getField(fieldSchema.name) || getCollectionJoinField(fieldSchema?.['x-collection-field']); + return { columnSchema, fieldSchema, collectionField, uiSchema: compile(collectionField?.uiSchema) }; +}; + +export const enableLink = { + name: 'enableLink', + type: 'switch', + useVisible() { + const field = useField(); + return field.readPretty; + }, + useComponentProps() { + const { t } = useDepartmentTranslation(); + const field = useField(); + const { fieldSchema: tableColumnSchema } = useColumnSchema(); + const schema = useFieldSchema(); + const fieldSchema = tableColumnSchema || schema; + const { dn } = useDesignable(); + return { + title: t('Enable link'), + checked: fieldSchema['x-component-props']?.enableLink !== false, + onChange(flag) { + fieldSchema['x-component-props'] = { + ...fieldSchema?.['x-component-props'], + enableLink: flag, + }; + field.componentProps['enableLink'] = flag; + dn.emit('patch', { + schema: { + 'x-uid': fieldSchema['x-uid'], + 'x-component-props': { + ...fieldSchema?.['x-component-props'], + }, + }, + }); + dn.refresh(); + }, + }; + }, +}; + +export const fieldComponent: any = { + name: 'fieldComponent', + type: 'select', + useComponentProps() { + const { t } = useDepartmentTranslation(); + const field = useField(); + const { fieldSchema: tableColumnSchema, collectionField } = useColumnSchema(); + const schema = useFieldSchema(); + const fieldSchema = tableColumnSchema || schema; + const fieldModeOptions = useFieldModeOptions({ fieldSchema: tableColumnSchema, collectionField }); + // const isAddNewForm = useIsAddNewForm(); + // const fieldMode = useFieldComponentName(); + const { dn } = useDesignable(); + return { + title: t('Field component'), + options: fieldModeOptions, + value: 'Select', + onChange(mode) { + const schema = { + ['x-uid']: fieldSchema['x-uid'], + }; + fieldSchema['x-component-props'] = fieldSchema['x-component-props'] || {}; + fieldSchema['x-component-props']['mode'] = mode; + schema['x-component-props'] = fieldSchema['x-component-props']; + field.componentProps = field.componentProps || {}; + field.componentProps.mode = mode; + + // 子表单状态不允许设置默认值 + // if (isSubMode(fieldSchema) && isAddNewForm) { + // // @ts-ignore + // schema.default = null; + // fieldSchema.default = null; + // field?.setInitialValue?.(null); + // field?.setValue?.(null); + // } + + void dn.emit('patch', { + schema, + }); + dn.refresh(); + }, + }; + }, +}; diff --git a/packages/plugins/@nocobase/plugin-departments/src/client/components/index.ts b/packages/plugins/@nocobase/plugin-departments/src/client/components/index.ts new file mode 100644 index 0000000000..a9a4c4c641 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-departments/src/client/components/index.ts @@ -0,0 +1,22 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This program is offered under a commercial license. + * For more information, see + */ + +export * from './ReadOnlyAssociationField'; +export * from './UserDepartmentsField'; +export * from './UserMainDepartmentField'; +export * from './DepartmentOwnersField'; diff --git a/packages/plugins/@nocobase/plugin-departments/src/client/departments/AggregateSearch.tsx b/packages/plugins/@nocobase/plugin-departments/src/client/departments/AggregateSearch.tsx new file mode 100644 index 0000000000..fccec99e2b --- /dev/null +++ b/packages/plugins/@nocobase/plugin-departments/src/client/departments/AggregateSearch.tsx @@ -0,0 +1,216 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This program is offered under a commercial license. + * For more information, see + */ + +import React, { useContext } from 'react'; +import { Input, Button, Empty, MenuProps, Dropdown, theme } from 'antd'; +import { useDepartmentTranslation } from '../locale'; +import { createStyles, useAPIClient, useRequest } from '@nocobase/client'; +import { ResourcesContext } from '../ResourcesProvider'; + +const useStyles = createStyles(({ css }) => { + return { + searchDropdown: css` + .ant-dropdown-menu { + max-height: 500px; + overflow-y: scroll; + } + `, + }; +}); + +export const AggregateSearch: React.FC = () => { + const { t } = useDepartmentTranslation(); + const { token } = theme.useToken(); + const { setDepartment, setUser } = useContext(ResourcesContext); + const [open, setOpen] = React.useState(false); + const [keyword, setKeyword] = React.useState(''); + const [users, setUsers] = React.useState([]); + const [departments, setDepartments] = React.useState([]); + const [moreUsers, setMoreUsers] = React.useState(true); + const [moreDepartments, setMoreDepartments] = React.useState(true); + const { styles } = useStyles(); + const limit = 10; + + const api = useAPIClient(); + const service = useRequest( + (params) => + api + .resource('departments') + .aggregateSearch(params) + .then((res) => res?.data?.data), + { + manual: true, + onSuccess: (data, params) => { + const { + values: { type }, + } = params[0] || {}; + if (!data) { + return; + } + if ((!type || type === 'user') && data['users'].length < limit) { + setMoreUsers(false); + } + if ((!type || type === 'department') && data['departments'].length < limit) { + setMoreDepartments(false); + } + setUsers((users) => [...users, ...data['users']]); + setDepartments((departments) => [...departments, ...data['departments']]); + }, + }, + ); + const { run } = service; + + const handleSearch = (keyword: string) => { + setKeyword(keyword); + setUsers([]); + setDepartments([]); + setMoreUsers(true); + setMoreDepartments(true); + if (!keyword) { + return; + } + run({ + values: { keyword, limit }, + }); + setOpen(true); + }; + + const handleChange = (e) => { + if (e.target.value) { + return; + } + setUser(null); + setKeyword(''); + setOpen(false); + service.mutate({}); + setUsers([]); + setDepartments([]); + }; + + const getTitle = (department: any) => { + const title = department.title; + const parent = department.parent; + if (parent) { + return getTitle(parent) + ' / ' + title; + } + return title; + }; + + const LoadMore: React.FC<{ type: string; last: number }> = (props) => { + return ( + + ); + }; + + const getItems = () => { + const items: MenuProps['items'] = []; + if (!users.length && !departments.length) { + return [ + { + key: '0', + label: , + disabled: true, + }, + ]; + } + if (users.length) { + items.push({ + key: '0', + type: 'group', + label: t('Users'), + children: users.map((user: { nickname: string; username: string; phone?: string; email?: string }) => ({ + key: user.username, + label: ( +
setUser(user)}> +
{user.nickname || user.username}
+
+ {`${user.username}${user.phone ? ' | ' + user.phone : ''}${user.email ? ' | ' + user.email : ''}`} +
+
+ ), + })), + }); + if (moreUsers) { + items.push({ + type: 'group', + key: '0-loadMore', + label: , + }); + } + } + if (departments.length) { + items.push({ + key: '1', + type: 'group', + label: t('Departments'), + children: departments.map((department: any) => ({ + key: department.id, + label:
setDepartment(department)}>{getTitle(department)}
, + })), + }); + if (moreDepartments) { + items.push({ + type: 'group', + key: '1-loadMore', + label: , + }); + } + } + return items; + }; + + return ( + setOpen(open)} + > + { + if (!keyword) { + setOpen(false); + } + }} + onFocus={() => setDepartment(null)} + onSearch={handleSearch} + onChange={handleChange} + placeholder={t('Search for departments, users')} + style={{ marginBottom: '20px' }} + /> + + ); +}; diff --git a/packages/plugins/@nocobase/plugin-departments/src/client/departments/Department.tsx b/packages/plugins/@nocobase/plugin-departments/src/client/departments/Department.tsx new file mode 100644 index 0000000000..b8673777ab --- /dev/null +++ b/packages/plugins/@nocobase/plugin-departments/src/client/departments/Department.tsx @@ -0,0 +1,149 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This program is offered under a commercial license. + * For more information, see + */ + +import React, { createContext, useContext, useState } from 'react'; +import { Row, Button, Divider, theme } from 'antd'; +import { UserOutlined } from '@ant-design/icons'; +import { useDepartmentTranslation } from '../locale'; +import { NewDepartment } from './NewDepartment'; +import { DepartmentTree } from './DepartmentTree'; +import { ResourcesContext } from '../ResourcesProvider'; +import { AggregateSearch } from './AggregateSearch'; +import { useDepartmentManager } from '../hooks'; +import { + ActionContextProvider, + RecordProvider, + SchemaComponent, + SchemaComponentOptions, + useAPIClient, + useActionContext, + useRecord, + useResourceActionContext, +} from '@nocobase/client'; +import { useForm, useField } from '@formily/react'; +import { DepartmentOwnersField } from './DepartmentOwnersField'; + +export const DepartmentTreeContext = createContext({} as ReturnType); + +export const useCreateDepartment = () => { + const form = useForm(); + const field = useField(); + const ctx = useActionContext(); + const { refreshAsync } = useResourceActionContext(); + const api = useAPIClient(); + const { expandedKeys, setLoadedKeys, setExpandedKeys } = useContext(DepartmentTreeContext); + return { + async run() { + try { + await form.submit(); + field.data = field.data || {}; + field.data.loading = true; + await api.resource('departments').create({ values: form.values }); + ctx.setVisible(false); + await form.reset(); + field.data.loading = false; + const expanded = [...expandedKeys]; + setLoadedKeys([]); + setExpandedKeys([]); + await refreshAsync(); + setExpandedKeys(expanded); + } catch (error) { + if (field.data) { + field.data.loading = false; + } + } + }, + }; +}; + +export const useUpdateDepartment = () => { + const field = useField(); + const form = useForm(); + const ctx = useActionContext(); + const { refreshAsync } = useResourceActionContext(); + const api = useAPIClient(); + const { id: filterByTk } = useRecord() as any; + const { expandedKeys, setLoadedKeys, setExpandedKeys } = useContext(DepartmentTreeContext); + const { department, setDepartment } = useContext(ResourcesContext); + return { + async run() { + await form.submit(); + field.data = field.data || {}; + field.data.loading = true; + try { + await api.resource('departments').update({ filterByTk, values: form.values }); + setDepartment({ department, ...form.values }); + ctx.setVisible(false); + await form.reset(); + const expanded = [...expandedKeys]; + setLoadedKeys([]); + setExpandedKeys([]); + await refreshAsync(); + setExpandedKeys(expanded); + } catch (e) { + console.log(e); + } finally { + field.data.loading = false; + } + }, + }; +}; + +export const Department: React.FC = () => { + const { t } = useDepartmentTranslation(); + const [visible, setVisible] = useState(false); + const [drawer, setDrawer] = useState({} as any); + const { department, setDepartment } = useContext(ResourcesContext); + const { token } = theme.useToken(); + const departmentManager = useDepartmentManager({ + label: ({ node }) => , + }); + + return ( + + + + + + + + + + + + + + + + + ); +}; diff --git a/packages/plugins/@nocobase/plugin-departments/src/client/departments/DepartmentBlock.tsx b/packages/plugins/@nocobase/plugin-departments/src/client/departments/DepartmentBlock.tsx new file mode 100644 index 0000000000..01b379d59a --- /dev/null +++ b/packages/plugins/@nocobase/plugin-departments/src/client/departments/DepartmentBlock.tsx @@ -0,0 +1,40 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This program is offered under a commercial license. + * For more information, see + */ + +import React from 'react'; +import { SchemaComponent } from '@nocobase/client'; +import { DepartmentManagement } from './DepartmentManagement'; +import { uid } from '@formily/shared'; + +export const DepartmentBlock: React.FC = () => { + return ( + + ); +}; diff --git a/packages/plugins/@nocobase/plugin-departments/src/client/departments/DepartmentField.tsx b/packages/plugins/@nocobase/plugin-departments/src/client/departments/DepartmentField.tsx new file mode 100644 index 0000000000..76763a15bb --- /dev/null +++ b/packages/plugins/@nocobase/plugin-departments/src/client/departments/DepartmentField.tsx @@ -0,0 +1,48 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This program is offered under a commercial license. + * For more information, see + */ + +import React, { useContext } from 'react'; +import { useField } from '@formily/react'; +import { Field } from '@formily/core'; +import { ResourcesContext } from '../ResourcesProvider'; +import { getDepartmentTitle } from '../utils'; +import { EllipsisWithTooltip } from '@nocobase/client'; + +export const DepartmentField: React.FC = () => { + const { setDepartment } = useContext(ResourcesContext); + const field = useField(); + const values = field.value || []; + const deptsMap = values.reduce((mp: { [id: number]: any }, dept: any) => { + mp[dept.id] = dept; + return mp; + }, {}); + const depts = values.map((dept: { id: number; title: string }, index: number) => ( + + { + e.preventDefault(); + setDepartment(deptsMap[dept.id]); + }} + > + {getDepartmentTitle(dept)} + + {index !== values.length - 1 ? , : ''} + + )); + return {depts}; +}; diff --git a/packages/plugins/@nocobase/plugin-departments/src/client/departments/DepartmentManagement.tsx b/packages/plugins/@nocobase/plugin-departments/src/client/departments/DepartmentManagement.tsx new file mode 100644 index 0000000000..5bae972bf8 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-departments/src/client/departments/DepartmentManagement.tsx @@ -0,0 +1,44 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This program is offered under a commercial license. + * For more information, see + */ + +import React from 'react'; +import { Col, Row } from 'antd'; +import { Department } from './Department'; +import { Member } from './Member'; +import { SchemaComponentOptions } from '@nocobase/client'; +import { SuperiorDepartmentSelect, DepartmentSelect } from './DepartmentTreeSelect'; +import { DepartmentsListProvider, UsersListProvider } from '../ResourcesProvider'; + +export const DepartmentManagement: React.FC = () => { + return ( + + + + + + + + + + + + + + + ); +}; diff --git a/packages/plugins/@nocobase/plugin-departments/src/client/departments/DepartmentOwnersField.tsx b/packages/plugins/@nocobase/plugin-departments/src/client/departments/DepartmentOwnersField.tsx new file mode 100644 index 0000000000..6faffb38f3 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-departments/src/client/departments/DepartmentOwnersField.tsx @@ -0,0 +1,115 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This program is offered under a commercial license. + * For more information, see + */ + +import { + ActionContextProvider, + ResourceActionProvider, + SchemaComponent, + useActionContext, + useRecord, +} from '@nocobase/client'; +import React, { useEffect, useRef, useState } from 'react'; +import { Select } from 'antd'; +import { Field } from '@formily/core'; +import { useField } from '@formily/react'; +import { departmentOwnersSchema } from './schemas/departments'; + +export const DepartmentOwnersField: React.FC = () => { + const [visible, setVisible] = useState(false); + const department = useRecord() as any; + const field = useField(); + const [value, setValue] = useState([]); + const selectedRows = useRef([]); + const handleSelect = (_: number[], rows: any[]) => { + selectedRows.current = rows; + }; + + const useSelectOwners = () => { + const { setVisible } = useActionContext(); + return { + run() { + const selected = field.value || []; + field.setValue([...selected, ...selectedRows.current]); + selectedRows.current = []; + setVisible(false); + }, + }; + }; + + useEffect(() => { + if (!field.value) { + return; + } + setValue( + field.value.map((owner: any) => ({ + value: owner.id, + label: owner.nickname || owner.username, + })), + ); + }, [field.value]); + + const RequestProvider: React.FC = (props) => ( + owner.id), + }, + } + : {}, + }, + }} + > + {props.children} + + ); + + return ( + + ; + } + console.log(collectionField); + return ( +
+ + + {collectionField?.target && collectionField?.target !== 'attachments' && ( + + + + + + { + return s['x-component'] === 'AssociationField.Selector'; + }} + /> + + + + + + )} + +
+ ); +}; + +const FileManageReadPretty = connect((props) => { + const { value } = props; + const fieldSchema = useFieldSchema(); + const componentMode = fieldSchema?.['x-component-props']?.['componentMode']; + const { getField } = useCollection_deprecated(); + const { getCollectionJoinField } = useCollectionManager_deprecated(); + const collectionField = getField(fieldSchema.name) || getCollectionJoinField(fieldSchema['x-collection-field']); + if (componentMode === 'url') { + return {value}; + } + return ( + {collectionField ? : null} + ); +}); + +export const AttachmentUrl = connect(InnerAttachmentUrl, mapReadPretty(FileManageReadPretty)); diff --git a/packages/plugins/@nocobase/plugin-field-attachment-url/src/client/hook/index.ts b/packages/plugins/@nocobase/plugin-field-attachment-url/src/client/hook/index.ts new file mode 100644 index 0000000000..412cd9c469 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-field-attachment-url/src/client/hook/index.ts @@ -0,0 +1,106 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { useFieldSchema } from '@formily/react'; +import { useCollectionField, useDesignable, useRequest } from '@nocobase/client'; +import { cloneDeep, uniqBy } from 'lodash'; +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; + +function useStorageRules(storage) { + const name = storage ?? ''; + const { loading, data } = useRequest( + { + url: `storages:getBasicInfo/${name}`, + }, + { + refreshDeps: [name], + }, + ); + return (!loading && data?.data) || null; +} +export function useAttachmentUrlFieldProps(props) { + const field = useCollectionField(); + const rules = useStorageRules(field?.storage); + return { + ...props, + rules, + action: `${field.target}:create${field.storage ? `?attachmentField=${field.collectionName}.${field.name}` : ''}`, + toValueItem: (data) => { + return data?.thumbnailRule ? `${data?.url}${data?.thumbnailRule}` : data?.url; + }, + getThumbnailURL: (file) => { + return file?.url; + }, + }; +} + +export const useInsertSchema = (component) => { + const fieldSchema = useFieldSchema(); + const { insertAfterBegin } = useDesignable(); + const insert = useCallback( + (ss) => { + const schema = fieldSchema.reduceProperties((buf, s) => { + if (s['x-component'] === 'AssociationField.' + component) { + return s; + } + return buf; + }, null); + if (!schema) { + insertAfterBegin(cloneDeep(ss)); + } + }, + [component, fieldSchema, insertAfterBegin], + ); + return insert; +}; + +export const useAttachmentTargetProps = () => { + const { t } = useTranslation(); + // TODO(refactor): whitelist should be changed to storage property,url is signed by plugin-s3-pro, this enmus is from plugin-file-manager + const buildInStorage = ['local', 'ali-oss', 's3', 'tx-cos']; + return { + service: { + resource: 'collections', + params: { + filter: { + 'options.template': 'file', + }, + paginate: false, + }, + }, + manual: false, + fieldNames: { + label: 'title', + value: 'name', + }, + mapOptions: (value) => { + if (value.name === 'attachments') { + return { + ...value, + title: t('Attachments'), + }; + } + return value; + }, + toOptionsItem: (data) => { + data.unshift({ + name: 'attachments', + title: t('Attachments'), + }); + return uniqBy( + data.filter((v) => v.name), + 'name', + ); + }, + optionFilter: (option) => { + return !option.storage || buildInStorage.includes(option.storage); + }, + }; +}; diff --git a/packages/plugins/@nocobase/plugin-field-attachment-url/src/client/index.tsx b/packages/plugins/@nocobase/plugin-field-attachment-url/src/client/index.tsx new file mode 100644 index 0000000000..c535c97f67 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-field-attachment-url/src/client/index.tsx @@ -0,0 +1,38 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { Plugin, lazy } from '@nocobase/client'; +import { AttachmentURLFieldInterface } from './interfaces/attachment-url'; +import { useAttachmentUrlFieldProps } from './hook'; +// import { AttachmentUrl } from './component/AttachmentUrl'; +const { AttachmentUrl } = lazy(() => import('./component/AttachmentUrl'), 'AttachmentUrl'); + +import { attachmentUrlComponentFieldSettings } from './settings'; +export class PluginFieldAttachmentUrlClient extends Plugin { + async afterAdd() { + // await this.app.pm.add() + } + + async beforeLoad() {} + + // You can get and modify the app instance here + async load() { + this.app.dataSourceManager.addFieldInterfaces([AttachmentURLFieldInterface]); + this.app.addScopes({ useAttachmentUrlFieldProps }); + + this.app.addComponents({ AttachmentUrl }); + this.app.schemaSettingsManager.add(attachmentUrlComponentFieldSettings); + + // this.app.addProvider() + // this.app.addProviders() + // this.app.router.add() + } +} + +export default PluginFieldAttachmentUrlClient; diff --git a/packages/plugins/@nocobase/plugin-field-attachment-url/src/client/interfaces/attachment-url.tsx b/packages/plugins/@nocobase/plugin-field-attachment-url/src/client/interfaces/attachment-url.tsx new file mode 100644 index 0000000000..781da59f6b --- /dev/null +++ b/packages/plugins/@nocobase/plugin-field-attachment-url/src/client/interfaces/attachment-url.tsx @@ -0,0 +1,79 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { CollectionFieldInterface, interfacesProperties } from '@nocobase/client'; +import { ISchema } from '@formily/react'; +import { useAttachmentTargetProps } from '../hook'; +import { tStr } from '../locale'; + +const { defaultProps, operators } = interfacesProperties; + +export const defaultToolbar = [ + 'headings', + 'bold', + 'italic', + 'strike', + 'link', + 'list', + 'ordered-list', + 'check', + 'quote', + 'line', + 'code', + 'inline-code', + 'upload', + 'fullscreen', +]; + +export class AttachmentURLFieldInterface extends CollectionFieldInterface { + name = 'attachmentURL'; + type = 'object'; + group = 'media'; + title = tStr('Attachment (URL)'); + default = { + type: 'string', + // name, + uiSchema: { + type: 'string', + // title, + 'x-component': 'AttachmentUrl', + 'x-use-component-props': 'useAttachmentUrlFieldProps', + }, + }; + availableTypes = ['string', 'text']; + properties = { + ...defaultProps, + target: { + required: true, + default: 'attachments', + type: 'string', + title: tStr('Which file collection should it be uploaded to'), + 'x-decorator': 'FormItem', + 'x-component': 'RemoteSelect', + 'x-use-component-props': useAttachmentTargetProps, + }, + targetKey: { + 'x-hidden': true, + default: 'id', + type: 'string', + }, + }; + schemaInitialize(schema: ISchema, { block }) { + schema['x-component-props'] = schema['x-component-props'] || {}; + schema['x-component-props']['mode'] = 'AttachmentUrl'; + if (['Table', 'Kanban'].includes(block)) { + schema['x-component-props']['ellipsis'] = true; + schema['x-component-props']['size'] = 'small'; + } + } + filterable = { + operators: operators.bigField, + }; + titleUsable = true; +} diff --git a/packages/plugins/@nocobase/plugin-field-attachment-url/src/client/locale.ts b/packages/plugins/@nocobase/plugin-field-attachment-url/src/client/locale.ts new file mode 100644 index 0000000000..a26dd0158f --- /dev/null +++ b/packages/plugins/@nocobase/plugin-field-attachment-url/src/client/locale.ts @@ -0,0 +1,30 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +// @ts-ignore +import pkg from '../../package.json'; +import { useApp } from '@nocobase/client'; +import { useTranslation } from 'react-i18next'; + +export const NAMESPACE = 'attachmentUrl'; + +export function useT() { + const app = useApp(); + return (str: string) => app.i18n.t(str, { ns: [pkg.name, 'client'] }); +} + +export function tStr(key: string) { + return `{{t(${JSON.stringify(key)}, { ns: ['${pkg.name}', 'client'], nsMode: 'fallback' })}}`; +} + +export function useAttachmentUrlTranslation() { + return useTranslation([NAMESPACE, 'client'], { + nsMode: 'fallback', + }); +} diff --git a/packages/plugins/@nocobase/plugin-field-attachment-url/src/client/schema.ts b/packages/plugins/@nocobase/plugin-field-attachment-url/src/client/schema.ts new file mode 100644 index 0000000000..f5ae80d0ce --- /dev/null +++ b/packages/plugins/@nocobase/plugin-field-attachment-url/src/client/schema.ts @@ -0,0 +1,53 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +export default { + Selector: { + type: 'void', + 'x-component': 'AssociationField.Selector', + title: '{{ t("Select record") }}', + 'x-component-props': { + className: 'nb-record-picker-selector', + }, + properties: { + grid: { + type: 'void', + 'x-component': 'Grid', + 'x-initializer': 'popup:tableSelector:addBlock', + properties: {}, + }, + footer: { + 'x-component': 'Action.Container.Footer', + 'x-component-props': {}, + properties: { + actions: { + type: 'void', + 'x-component': 'ActionBar', + 'x-component-props': {}, + properties: { + submit: { + title: '{{ t("Submit") }}', + 'x-action': 'submit', + 'x-component': 'Action', + 'x-use-component-props': 'usePickActionProps', + // 'x-designer': 'Action.Designer', + 'x-toolbar': 'ActionSchemaToolbar', + 'x-settings': 'actionSettings:submit', + 'x-component-props': { + type: 'primary', + htmlType: 'submit', + }, + }, + }, + }, + }, + }, + }, + }, +}; diff --git a/packages/plugins/@nocobase/plugin-field-attachment-url/src/client/settings/index.ts b/packages/plugins/@nocobase/plugin-field-attachment-url/src/client/settings/index.ts new file mode 100644 index 0000000000..52463073c2 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-field-attachment-url/src/client/settings/index.ts @@ -0,0 +1,171 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { Field } from '@formily/core'; +import { useField, useFieldSchema, useForm } from '@formily/react'; +import { useTranslation } from 'react-i18next'; +import { useColumnSchema, useIsFieldReadPretty, SchemaSettings, useDesignable } from '@nocobase/client'; + +const fieldComponent: any = { + name: 'fieldComponent', + type: 'select', + useComponentProps() { + const { t } = useTranslation(); + const field = useField(); + const { fieldSchema: tableColumnSchema } = useColumnSchema(); + const schema = useFieldSchema(); + const fieldSchema = tableColumnSchema || schema; + const { dn } = useDesignable(); + + return { + title: t('Field component'), + options: [ + { label: t('URL'), value: 'url' }, + { label: t('Preview'), value: 'preview' }, + ], + value: fieldSchema['x-component-props']['componentMode'] || 'preview', + onChange(componentMode) { + const schema = { + ['x-uid']: fieldSchema['x-uid'], + }; + fieldSchema['x-component-props'] = fieldSchema['x-component-props'] || {}; + fieldSchema['x-component-props']['componentMode'] = componentMode; + schema['x-component-props'] = fieldSchema['x-component-props']; + field.componentProps = field.componentProps || {}; + field.componentProps.componentMode = componentMode; + void dn.emit('patch', { + schema, + }); + dn.refresh(); + }, + }; + }, + useVisible() { + const readPretty = useIsFieldReadPretty(); + const { fieldSchema: tableColumnSchema } = useColumnSchema(); + return readPretty; + }, +}; + +export const attachmentUrlComponentFieldSettings = new SchemaSettings({ + name: 'fieldSettings:component:AttachmentUrl', + items: [ + { + name: 'quickUpload', + type: 'switch', + useComponentProps() { + const { t } = useTranslation(); + const field = useField(); + const { fieldSchema: tableColumnSchema } = useColumnSchema(); + const schema = useFieldSchema(); + const fieldSchema = tableColumnSchema || schema; + const { dn, refresh } = useDesignable(); + return { + title: t('Quick upload'), + checked: fieldSchema['x-component-props']?.quickUpload !== (false as boolean), + onChange(value) { + const schema = { + ['x-uid']: fieldSchema['x-uid'], + }; + field.componentProps.quickUpload = value; + fieldSchema['x-component-props'] = fieldSchema['x-component-props'] || {}; + fieldSchema['x-component-props'].quickUpload = value; + schema['x-component-props'] = fieldSchema['x-component-props']; + dn.emit('patch', { + schema, + }); + refresh(); + }, + }; + }, + useVisible() { + const { fieldSchema: tableColumnSchema } = useColumnSchema(); + const field = useField(); + const form = useForm(); + const isReadPretty = tableColumnSchema?.['x-read-pretty'] || field.readPretty || form.readPretty; + return !isReadPretty && !field.componentProps.underFilter; + }, + }, + { + name: 'selectFile', + type: 'switch', + useComponentProps() { + const { t } = useTranslation(); + const field = useField(); + const { fieldSchema: tableColumnSchema } = useColumnSchema(); + const schema = useFieldSchema(); + const fieldSchema = tableColumnSchema || schema; + const { dn, refresh } = useDesignable(); + return { + title: t('Select file'), + checked: fieldSchema['x-component-props']?.selectFile !== (false as boolean), + onChange(value) { + const schema = { + ['x-uid']: fieldSchema['x-uid'], + }; + field.componentProps.selectFile = value; + fieldSchema['x-component-props'] = fieldSchema['x-component-props'] || {}; + fieldSchema['x-component-props'].selectFile = value; + schema['x-component-props'] = fieldSchema['x-component-props']; + dn.emit('patch', { + schema, + }); + refresh(); + }, + }; + }, + useVisible() { + const { fieldSchema: tableColumnSchema } = useColumnSchema(); + const field = useField(); + const form = useForm(); + const isReadPretty = tableColumnSchema?.['x-read-pretty'] || field.readPretty || form.readPretty; + return !isReadPretty && !field.componentProps.underFilter; + }, + }, + fieldComponent, + { + name: 'size', + type: 'select', + useVisible() { + const readPretty = useIsFieldReadPretty(); + const { fieldSchema: tableColumnSchema } = useColumnSchema(); + return readPretty && !tableColumnSchema; + }, + useComponentProps() { + const { t } = useTranslation(); + const field = useField(); + const fieldSchema = useFieldSchema(); + const { dn } = useDesignable(); + return { + title: t('Size'), + options: [ + { label: t('Large'), value: 'large' }, + { label: t('Default'), value: 'default' }, + { label: t('Small'), value: 'small' }, + ], + value: field?.componentProps?.size || 'default', + onChange(size) { + const schema = { + ['x-uid']: fieldSchema['x-uid'], + }; + fieldSchema['x-component-props'] = fieldSchema['x-component-props'] || {}; + fieldSchema['x-component-props']['size'] = size; + schema['x-component-props'] = fieldSchema['x-component-props']; + field.componentProps = field.componentProps || {}; + field.componentProps.size = size; + dn.emit('patch', { + schema, + }); + dn.refresh(); + }, + }; + }, + }, + ], +}); diff --git a/packages/plugins/@nocobase/plugin-field-attachment-url/src/index.ts b/packages/plugins/@nocobase/plugin-field-attachment-url/src/index.ts new file mode 100644 index 0000000000..be99a2ff1a --- /dev/null +++ b/packages/plugins/@nocobase/plugin-field-attachment-url/src/index.ts @@ -0,0 +1,11 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +export * from './server'; +export { default } from './server'; diff --git a/packages/plugins/@nocobase/plugin-field-attachment-url/src/locale/en-US.json b/packages/plugins/@nocobase/plugin-field-attachment-url/src/locale/en-US.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/packages/plugins/@nocobase/plugin-field-attachment-url/src/locale/en-US.json @@ -0,0 +1 @@ +{} diff --git a/packages/plugins/@nocobase/plugin-field-attachment-url/src/locale/zh-CN.json b/packages/plugins/@nocobase/plugin-field-attachment-url/src/locale/zh-CN.json new file mode 100644 index 0000000000..78def6ea14 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-field-attachment-url/src/locale/zh-CN.json @@ -0,0 +1,4 @@ +{ + "Which file collection should it be uploaded to":"上传到文件表", + "Attachment (URL)":"附件 (URL)" +} diff --git a/packages/plugins/@nocobase/plugin-field-attachment-url/src/server/collections/.gitkeep b/packages/plugins/@nocobase/plugin-field-attachment-url/src/server/collections/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/plugins/@nocobase/plugin-field-attachment-url/src/server/index.ts b/packages/plugins/@nocobase/plugin-field-attachment-url/src/server/index.ts new file mode 100644 index 0000000000..be989de7c3 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-field-attachment-url/src/server/index.ts @@ -0,0 +1,10 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +export { default } from './plugin'; diff --git a/packages/plugins/@nocobase/plugin-field-attachment-url/src/server/plugin.ts b/packages/plugins/@nocobase/plugin-field-attachment-url/src/server/plugin.ts new file mode 100644 index 0000000000..372274c091 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-field-attachment-url/src/server/plugin.ts @@ -0,0 +1,28 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { Plugin } from '@nocobase/server'; + +export class PluginFieldAttachmentUrlServer extends Plugin { + async afterAdd() {} + + async beforeLoad() {} + + async load() {} + + async install() {} + + async afterEnable() {} + + async afterDisable() {} + + async remove() {} +} + +export default PluginFieldAttachmentUrlServer; diff --git a/packages/plugins/@nocobase/plugin-localization/src/server/plugin.ts b/packages/plugins/@nocobase/plugin-localization/src/server/plugin.ts index 7bac0fcf63..8d04cac1b3 100644 --- a/packages/plugins/@nocobase/plugin-localization/src/server/plugin.ts +++ b/packages/plugins/@nocobase/plugin-localization/src/server/plugin.ts @@ -25,7 +25,7 @@ export class PluginLocalizationServer extends Plugin { addNewTexts = async (texts: { text: string; module: string }[], options?: any) => { texts = await this.resources.filterExists(texts, options?.transaction); - this.db + await this.db .getModel('localizationTexts') .bulkCreate( texts.map(({ text, module }) => ({ diff --git a/packages/plugins/@nocobase/plugin-workflow-manual/src/client/WorkflowTodo.tsx b/packages/plugins/@nocobase/plugin-workflow-manual/src/client/WorkflowTodo.tsx index beeae222c4..491a82d89c 100644 --- a/packages/plugins/@nocobase/plugin-workflow-manual/src/client/WorkflowTodo.tsx +++ b/packages/plugins/@nocobase/plugin-workflow-manual/src/client/WorkflowTodo.tsx @@ -33,9 +33,12 @@ import { useActionContext, useCurrentUserContext, useFormBlockContext, - useTableBlockContext, + useListBlockContext, List, OpenModeProvider, + ActionContextProvider, + useRequest, + CollectionRecordProvider, } from '@nocobase/client'; import WorkflowPlugin, { DetailsBlockProvider, @@ -46,12 +49,15 @@ import WorkflowPlugin, { EXECUTION_STATUS, JOB_STATUS, WorkflowTitle, + TASK_STATUS, + usePopupRecordContext, } from '@nocobase/plugin-workflow/client'; import { NAMESPACE, useLang } from '../locale'; import { FormBlockProvider } from './instruction/FormBlockProvider'; import { ManualFormType, manualFormTypes } from './instruction/SchemaConfig'; import { TaskStatusOptionsMap } from '../common/constants'; +import { useNavigate, useParams } from 'react-router-dom'; function TaskStatusColumn(props) { const recordData = useCollectionRecordData(); @@ -291,11 +297,12 @@ function useSubmit() { const { values, submit } = useForm(); const field = useField(); const buttonSchema = useFieldSchema(); - const { service } = useTableBlockContext(); + const { service } = useListBlockContext(); const { userJob, execution } = useFlowContext(); const { name: actionKey } = buttonSchema; const { name: formKey } = buttonSchema.parent.parent; const { assignedValues = {} } = buttonSchema?.['x-action-settings'] ?? {}; + return { async run() { if (execution.status || userJob.status) { @@ -611,57 +618,37 @@ function ContentDetailWithTitle(props) { function TaskItem() { const token = useAntdToken(); - const [visible, setVisible] = useState(false); const record = useCollectionRecordData(); - const { t } = useTranslation(); - // const { defaultOpenMode } = useOpenModeContext(); - // const { openPopup } = usePopupUtils(); - // const { isPopupVisibleControlledByURL } = usePopupSettings(); - const onOpen = useCallback((e: React.MouseEvent) => { - const targetElement = e.target as Element; // 将事件目标转换为Element类型 - const currentTargetElement = e.currentTarget as Element; - if (currentTargetElement.contains(targetElement)) { - setVisible(true); - // if (!isPopupVisibleControlledByURL()) { - // } else { - // openPopup({ - // // popupUidUsedInURL: 'job', - // customActionSchema: { - // type: 'void', - // 'x-uid': 'job-view', - // 'x-action-context': { - // dataSource: 'main', - // collection: 'workflowManualTasks', - // doNotUpdateContext: true, - // }, - // properties: {}, - // }, - // }); - // } - } - e.stopPropagation(); - }, []); + const navigate = useNavigate(); + const { setRecord } = usePopupRecordContext(); + const onOpen = useCallback( + (e: React.MouseEvent) => { + const targetElement = e.target as Element; // 将事件目标转换为Element类型 + const currentTargetElement = e.currentTarget as Element; + if (currentTargetElement.contains(targetElement)) { + setRecord(record); + navigate(`./${record.id}`); + } + e.stopPropagation(); + }, + [navigate, record.id], + ); return ( - <> - } - className={css` - .ant-card-extra { - color: ${token.colorTextDescription}; - } - `} - > - - - - - - + } + className={css` + .ant-card-extra { + color: ${token.colorTextDescription}; + } + `} + > + + ); } @@ -734,7 +721,9 @@ function TodoExtraActions() { export const manualTodo = { title: `{{t("My manual tasks", { ns: "${NAMESPACE}" })}}`, collection: 'workflowManualTasks', + action: 'listMine', useActionParams: useTodoActionParams, - component: TaskItem, - extraActions: TodoExtraActions, + Actions: TodoExtraActions, + Item: TaskItem, + Detail: Drawer, }; diff --git a/packages/plugins/@nocobase/plugin-workflow-manual/src/server/Plugin.ts b/packages/plugins/@nocobase/plugin-workflow-manual/src/server/Plugin.ts index 7338d0958e..a63b5aba87 100644 --- a/packages/plugins/@nocobase/plugin-workflow-manual/src/server/Plugin.ts +++ b/packages/plugins/@nocobase/plugin-workflow-manual/src/server/Plugin.ts @@ -16,13 +16,14 @@ import * as jobActions from './actions'; import ManualInstruction from './ManualInstruction'; import { MANUAL_TASK_TYPE } from '../common/constants'; +import { Model } from '@nocobase/database'; -interface WorkflowManualTaskModel { - id: number; - userId: number; - workflowId: number; - executionId: number; - status: number; +class WorkflowManualTaskModel extends Model { + declare id: number; + declare userId: number; + declare workflowId: number; + declare executionId: number; + declare status: number; } export default class extends Plugin { @@ -55,7 +56,13 @@ export default class extends Plugin { const workflowPlugin = this.app.pm.get(WorkflowPlugin) as WorkflowPlugin; workflowPlugin.registerInstruction('manual', ManualInstruction); - this.db.on('workflowManualTasks.afterSave', async (task: WorkflowManualTaskModel, options) => { + this.db.on('workflowManualTasks.afterSave', async (task: WorkflowManualTaskModel, { transaction }) => { + // const allCount = await (task.constructor as typeof WorkflowManualTaskModel).count({ + // where: { + // userId: task.userId, + // }, + // transaction, + // }); await workflowPlugin.toggleTaskStatus( { type: MANUAL_TASK_TYPE, @@ -63,8 +70,9 @@ export default class extends Plugin { userId: task.userId, workflowId: task.workflowId, }, - Boolean(task.status), - options, + task.status === JOB_STATUS.PENDING, + // allCount, + { transaction }, ); }); } diff --git a/packages/plugins/@nocobase/plugin-workflow-manual/src/server/actions.ts b/packages/plugins/@nocobase/plugin-workflow-manual/src/server/actions.ts index 33dccdbeb5..a9ac4c9768 100644 --- a/packages/plugins/@nocobase/plugin-workflow-manual/src/server/actions.ts +++ b/packages/plugins/@nocobase/plugin-workflow-manual/src/server/actions.ts @@ -7,7 +7,7 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -import { Context, utils } from '@nocobase/actions'; +import actions, { Context, utils } from '@nocobase/actions'; import WorkflowPlugin, { EXECUTION_STATUS, JOB_STATUS } from '@nocobase/plugin-workflow'; import ManualInstruction from './ManualInstruction'; @@ -111,3 +111,24 @@ export async function submit(context: Context, next) { plugin.resume(task.job); } + +export async function listMine(context, next) { + context.action.mergeParams({ + filter: { + userId: context.state.currentUser.id, + $or: [ + { + 'workflow.enabled': true, + }, + { + 'workflow.enabled': false, + status: { + $ne: JOB_STATUS.PENDING, + }, + }, + ], + }, + }); + + return actions.list(context, next); +} diff --git a/packages/plugins/@nocobase/plugin-workflow-response-message/.npmignore b/packages/plugins/@nocobase/plugin-workflow-response-message/.npmignore new file mode 100644 index 0000000000..65f5e8779f --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow-response-message/.npmignore @@ -0,0 +1,2 @@ +/node_modules +/src diff --git a/packages/plugins/@nocobase/plugin-workflow-response-message/README.md b/packages/plugins/@nocobase/plugin-workflow-response-message/README.md new file mode 100644 index 0000000000..8ced21e948 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow-response-message/README.md @@ -0,0 +1 @@ +# @nocobase/plugin-workflow-response-message diff --git a/packages/plugins/@nocobase/plugin-workflow-response-message/client.d.ts b/packages/plugins/@nocobase/plugin-workflow-response-message/client.d.ts new file mode 100644 index 0000000000..6c459cbac4 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow-response-message/client.d.ts @@ -0,0 +1,2 @@ +export * from './dist/client'; +export { default } from './dist/client'; diff --git a/packages/plugins/@nocobase/plugin-workflow-response-message/client.js b/packages/plugins/@nocobase/plugin-workflow-response-message/client.js new file mode 100644 index 0000000000..b6e3be70e6 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow-response-message/client.js @@ -0,0 +1 @@ +module.exports = require('./dist/client/index.js'); diff --git a/packages/plugins/@nocobase/plugin-workflow-response-message/package.json b/packages/plugins/@nocobase/plugin-workflow-response-message/package.json new file mode 100644 index 0000000000..be505e66a4 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow-response-message/package.json @@ -0,0 +1,21 @@ +{ + "name": "@nocobase/plugin-workflow-response-message", + "version": "1.7.0-alpha.10", + "displayName": "Workflow: Response message", + "displayName.zh-CN": "工作流:响应消息", + "description": "Used for assemble response message and showing to client in form event and request interception workflows.", + "description.zh-CN": "用于在表单事件和请求拦截工作流中组装并向客户端显示响应消息。", + "main": "dist/server/index.js", + "homepage": "https://docs.nocobase.com/handbook/workflow-response-message", + "homepage.zh-CN": "https://docs-cn.nocobase.com/handbook/workflow-response-message", + "peerDependencies": { + "@nocobase/client": "1.x", + "@nocobase/server": "1.x", + "@nocobase/test": "1.x", + "@nocobase/utils": "1.x" + }, + "keywords": [ + "Workflow" + ], + "gitHead": "080fc78c1a744d47e010b3bbe5840446775800e4" +} diff --git a/packages/plugins/@nocobase/plugin-workflow-response-message/server.d.ts b/packages/plugins/@nocobase/plugin-workflow-response-message/server.d.ts new file mode 100644 index 0000000000..c41081ddc6 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow-response-message/server.d.ts @@ -0,0 +1,2 @@ +export * from './dist/server'; +export { default } from './dist/server'; diff --git a/packages/plugins/@nocobase/plugin-workflow-response-message/server.js b/packages/plugins/@nocobase/plugin-workflow-response-message/server.js new file mode 100644 index 0000000000..972842039a --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow-response-message/server.js @@ -0,0 +1 @@ +module.exports = require('./dist/server/index.js'); diff --git a/packages/plugins/@nocobase/plugin-workflow-response-message/src/client/ResponseMessageInstruction.tsx b/packages/plugins/@nocobase/plugin-workflow-response-message/src/client/ResponseMessageInstruction.tsx new file mode 100644 index 0000000000..efdd6a7241 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow-response-message/src/client/ResponseMessageInstruction.tsx @@ -0,0 +1,87 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This program is offered under a commercial license. + * For more information, see + */ + +import React from 'react'; +import { InfoCircleOutlined } from '@ant-design/icons'; +import { Alert, Space } from 'antd'; + +import { + Instruction, + RadioWithTooltip, + WorkflowVariableInput, + WorkflowVariableTextArea, +} from '@nocobase/plugin-workflow/client'; + +import { NAMESPACE } from '../locale'; + +export default class extends Instruction { + title = `{{t("Response message", { ns: "${NAMESPACE}" })}}`; + type = 'response-message'; + group = 'extended'; + description = `{{t("Add response message, will be send to client when process of request ends.", { ns: "${NAMESPACE}" })}}`; + icon = (); + fieldset = { + message: { + type: 'string', + title: `{{t("Message content", { ns: "${NAMESPACE}" })}}`, + description: `{{t('Supports variables in template.', { ns: "${NAMESPACE}", name: '{{name}}' })}}`, + 'x-decorator': 'FormItem', + 'x-component': 'WorkflowVariableTextArea', + }, + info: { + type: 'void', + 'x-component': 'Space', + 'x-component-props': { + direction: 'vertical', + }, + properties: { + success: { + type: 'void', + 'x-component': 'Alert', + 'x-component-props': { + type: 'success', + showIcon: true, + description: `{{t('If the workflow ends normally, the response message will return a success status by default.', { ns: "${NAMESPACE}" })}}`, + }, + }, + failure: { + type: 'void', + 'x-component': 'Alert', + 'x-component-props': { + type: 'error', + showIcon: true, + description: `{{t('If you want to return a failure status, please add an "End Process" node downstream to terminate the workflow.', { ns: "${NAMESPACE}" })}}`, + }, + }, + }, + }, + }; + scope = {}; + components = { + RadioWithTooltip, + WorkflowVariableTextArea, + WorkflowVariableInput, + Alert, + Space, + }; + isAvailable({ workflow, upstream, branchIndex }) { + return ( + workflow.type === 'request-interception' || (['action', 'custom-action'].includes(workflow.type) && workflow.sync) + ); + } +} diff --git a/packages/plugins/@nocobase/plugin-workflow-response-message/src/client/index.tsx b/packages/plugins/@nocobase/plugin-workflow-response-message/src/client/index.tsx new file mode 100644 index 0000000000..258f6c8d8b --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow-response-message/src/client/index.tsx @@ -0,0 +1,31 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This program is offered under a commercial license. + * For more information, see + */ + +import { Plugin } from '@nocobase/client'; +import WorkflowPlugin from '@nocobase/plugin-workflow/client'; + +import ResponseMessageInstruction from './ResponseMessageInstruction'; + +export class PluginWorkflowResponseMessageClient extends Plugin { + async load() { + const workflowPlugin = this.app.pm.get('workflow') as WorkflowPlugin; + workflowPlugin.registerInstruction('response-message', ResponseMessageInstruction); + } +} + +export default PluginWorkflowResponseMessageClient; diff --git a/packages/plugins/@nocobase/plugin-workflow-response-message/src/index.ts b/packages/plugins/@nocobase/plugin-workflow-response-message/src/index.ts new file mode 100644 index 0000000000..7d69462f4f --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow-response-message/src/index.ts @@ -0,0 +1,20 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This program is offered under a commercial license. + * For more information, see + */ + +export * from './server'; +export { default } from './server'; diff --git a/packages/plugins/@nocobase/plugin-workflow-response-message/src/locale/en-US.json b/packages/plugins/@nocobase/plugin-workflow-response-message/src/locale/en-US.json new file mode 100644 index 0000000000..11832d6610 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow-response-message/src/locale/en-US.json @@ -0,0 +1,6 @@ +{ + "Response message": "Response message", + "Add response message, will be send to client when process of request ends.": "Add response message, will be send to client when process of request ends.", + "Message content": "Message content", + "Supports variables in template.": "Supports variables in template." +} diff --git a/packages/plugins/@nocobase/plugin-workflow-response-message/src/locale/index.ts b/packages/plugins/@nocobase/plugin-workflow-response-message/src/locale/index.ts new file mode 100644 index 0000000000..543b392f21 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow-response-message/src/locale/index.ts @@ -0,0 +1,25 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This program is offered under a commercial license. + * For more information, see + */ + +import { i18n } from '@nocobase/client'; + +export const NAMESPACE = '@nocobase/plugin-workflow-response-message'; + +export function lang(key: string, options = {}) { + return i18n.t(key, { ...options, ns: NAMESPACE }); +} diff --git a/packages/plugins/@nocobase/plugin-workflow-response-message/src/locale/zh-CN.json b/packages/plugins/@nocobase/plugin-workflow-response-message/src/locale/zh-CN.json new file mode 100644 index 0000000000..c8b986cace --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow-response-message/src/locale/zh-CN.json @@ -0,0 +1,8 @@ +{ + "Response message": "响应消息", + "Add response message, will be send to client when process of request ends.": "添加响应消息,将在请求处理结束时发送给客户端。", + "Message content": "消息内容", + "Supports variables in template.": "支持模板变量。", + "If the workflow ends normally, the response message will return a success status by default.": "如果工作流正常结束,响应消息默认返回成功状态。", + "If you want to return a failure status, please add an \"End Process\" node downstream to terminate the workflow.": "如果希望返回失败状态,请在下游添加“结束流程”节点终止工作流。" +} diff --git a/packages/plugins/@nocobase/plugin-workflow-response-message/src/server/Plugin.ts b/packages/plugins/@nocobase/plugin-workflow-response-message/src/server/Plugin.ts new file mode 100644 index 0000000000..cdd6eece72 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow-response-message/src/server/Plugin.ts @@ -0,0 +1,31 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This program is offered under a commercial license. + * For more information, see + */ + +import { Plugin } from '@nocobase/server'; +import PluginWorkflowServer from '@nocobase/plugin-workflow'; + +import ResponseMessageInstruction from './ResponseMessageInstruction'; + +export class PluginWorkflowResponseMessageServer extends Plugin { + async load() { + const workflowPlugin = this.app.pm.get(PluginWorkflowServer) as PluginWorkflowServer; + workflowPlugin.registerInstruction('response-message', ResponseMessageInstruction); + } +} + +export default PluginWorkflowResponseMessageServer; diff --git a/packages/plugins/@nocobase/plugin-workflow-response-message/src/server/ResponseMessageInstruction.ts b/packages/plugins/@nocobase/plugin-workflow-response-message/src/server/ResponseMessageInstruction.ts new file mode 100644 index 0000000000..e277a44e35 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow-response-message/src/server/ResponseMessageInstruction.ts @@ -0,0 +1,55 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This program is offered under a commercial license. + * For more information, see + */ + +import { Instruction, Processor, JOB_STATUS, FlowNodeModel } from '@nocobase/plugin-workflow'; + +interface Config { + message?: string; +} + +export default class extends Instruction { + async run(node: FlowNodeModel, prevJob, processor: Processor) { + const { httpContext } = processor.options; + + if (!httpContext) { + return { + status: JOB_STATUS.RESOLVED, + result: null, + }; + } + + if (!httpContext.state) { + httpContext.state = {}; + } + + if (!httpContext.state.messages) { + httpContext.state.messages = []; + } + + const message = processor.getParsedValue(node.config.message, node.id); + + if (message) { + httpContext.state.messages.push({ message }); + } + + return { + status: JOB_STATUS.RESOLVED, + result: message, + }; + } +} diff --git a/packages/plugins/@nocobase/plugin-workflow-response-message/src/server/__tests__/instruction.test.ts b/packages/plugins/@nocobase/plugin-workflow-response-message/src/server/__tests__/instruction.test.ts new file mode 100644 index 0000000000..3d4c4caf10 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow-response-message/src/server/__tests__/instruction.test.ts @@ -0,0 +1,346 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This program is offered under a commercial license. + * For more information, see + */ + +import Database from '@nocobase/database'; +import { MockServer } from '@nocobase/test'; +import { EXECUTION_STATUS, JOB_STATUS } from '@nocobase/plugin-workflow'; +import { getApp } from '@nocobase/plugin-workflow-test'; + +import Plugin from '..'; + +describe('workflow > instructions > response-message', () => { + let app: MockServer; + let db: Database; + let PostRepo; + let WorkflowModel; + let workflow; + let users; + let userAgents; + + beforeEach(async () => { + app = await getApp({ + plugins: ['users', 'auth', 'error-handler', 'workflow-request-interceptor', Plugin], + }); + + db = app.db; + + PostRepo = db.getCollection('posts').repository; + + WorkflowModel = db.getModel('workflows'); + workflow = await WorkflowModel.create({ + enabled: true, + type: 'request-interception', + config: { + global: true, + actions: ['create'], + collection: 'posts', + }, + }); + + const UserModel = db.getCollection('users').model; + users = await UserModel.bulkCreate([ + { id: 2, nickname: 'a' }, + { id: 3, nickname: 'b' }, + ]); + + userAgents = await Promise.all(users.map((user) => app.agent().login(user))); + }); + + afterEach(() => app.destroy()); + + describe('no end, pass flow', () => { + it('no message', async () => { + const res1 = await userAgents[0].resource('posts').create({ + values: { title: 't1' }, + }); + + expect(res1.status).toBe(200); + expect(res1.body).toMatchObject({ data: { title: 't1' } }); + expect(res1.body.messages).toBeUndefined(); + + const post = await PostRepo.findOne(); + expect(post).toBeDefined(); + expect(post.title).toBe('t1'); + + const [e1] = await workflow.getExecutions(); + expect(e1.status).toBe(EXECUTION_STATUS.RESOLVED); + const jobs = await e1.getJobs(); + expect(jobs.length).toBe(0); + }); + + it('has node, but null message', async () => { + const n1 = await workflow.createNode({ + type: 'response-message', + }); + + const res1 = await userAgents[0].resource('posts').create({ + values: { title: 't1' }, + }); + + expect(res1.status).toBe(200); + expect(res1.body).toMatchObject({ data: { title: 't1' } }); + expect(res1.body.messages).toBeUndefined(); + + const post = await PostRepo.findOne(); + expect(post).toBeDefined(); + expect(post.title).toBe('t1'); + + const [e1] = await workflow.getExecutions(); + expect(e1.status).toBe(EXECUTION_STATUS.RESOLVED); + const jobs = await e1.getJobs(); + expect(jobs.length).toBe(1); + }); + + it('has node, but empty message', async () => { + const n1 = await workflow.createNode({ + type: 'response-message', + config: { + message: '', + }, + }); + + const res1 = await userAgents[0].resource('posts').create({ + values: { title: 't1' }, + }); + + expect(res1.status).toBe(200); + expect(res1.body).toMatchObject({ data: { title: 't1' } }); + expect(res1.body.messages).toBeUndefined(); + + const post = await PostRepo.findOne(); + expect(post).toBeDefined(); + expect(post.title).toBe('t1'); + + const [e1] = await workflow.getExecutions(); + expect(e1.status).toBe(EXECUTION_STATUS.RESOLVED); + const jobs = await e1.getJobs(); + expect(jobs.length).toBe(1); + }); + + it('single static message', async () => { + const n1 = await workflow.createNode({ + type: 'response-message', + config: { + message: 'm1', + }, + }); + const res1 = await userAgents[0].resource('posts').create({ + values: { title: 't1' }, + }); + + expect(res1.status).toBe(200); + expect(res1.body).toMatchObject({ data: { title: 't1' } }); + expect(res1.body.messages).toEqual([{ message: 'm1' }]); + + const post = await PostRepo.findOne(); + expect(post).toBeDefined(); + expect(post.title).toBe('t1'); + + const [e1] = await workflow.getExecutions(); + expect(e1.status).toBe(EXECUTION_STATUS.RESOLVED); + const jobs = await e1.getJobs(); + expect(jobs.length).toBe(1); + }); + + it('multiple static messages', async () => { + const n1 = await workflow.createNode({ + type: 'response-message', + config: { + message: 'm1', + }, + }); + const n2 = await workflow.createNode({ + type: 'response-message', + config: { + message: 'm2', + }, + upstreamId: n1.id, + }); + await n1.setDownstream(n2); + + const res1 = await userAgents[0].resource('posts').create({ + values: { title: 't1' }, + }); + + expect(res1.status).toBe(200); + expect(res1.body).toMatchObject({ data: { title: 't1' } }); + expect(res1.body.messages).toEqual([{ message: 'm1' }, { message: 'm2' }]); + + const post = await PostRepo.findOne(); + expect(post).toBeDefined(); + expect(post.title).toBe('t1'); + + const [e1] = await workflow.getExecutions(); + expect(e1.status).toBe(EXECUTION_STATUS.RESOLVED); + const jobs = await e1.getJobs(); + expect(jobs.length).toBe(2); + }); + + it('single dynamic message', async () => { + const n1 = await workflow.createNode({ + type: 'response-message', + config: { + message: 'new post "{{ $context.params.values.title }}" by {{ $context.user.nickname }}', + }, + }); + const res1 = await userAgents[0].resource('posts').create({ + values: { title: 't1' }, + }); + + expect(res1.status).toBe(200); + expect(res1.body).toMatchObject({ data: { title: 't1' } }); + expect(res1.body.messages).toEqual([{ message: `new post "t1" by ${users[0].nickname}` }]); + + const post = await PostRepo.findOne(); + expect(post).toBeDefined(); + expect(post.title).toBe('t1'); + + const [e1] = await workflow.getExecutions(); + expect(e1.status).toBe(EXECUTION_STATUS.RESOLVED); + const jobs = await e1.getJobs(); + expect(jobs.length).toBe(1); + }); + }); + + describe('end as success', () => { + it('no message', async () => { + const n1 = await workflow.createNode({ + type: 'end', + config: { + endStatus: JOB_STATUS.RESOLVED, + }, + }); + + const res1 = await userAgents[0].resource('posts').create({ + values: { title: 't1' }, + }); + + expect(res1.status).toBe(200); + expect(res1.body.data).toBeUndefined(); + expect(res1.body.messages).toBeUndefined(); + + const post = await PostRepo.findOne(); + expect(post).toBeNull(); + + const [e1] = await workflow.getExecutions(); + expect(e1.status).toBe(EXECUTION_STATUS.RESOLVED); + const jobs = await e1.getJobs(); + expect(jobs.length).toBe(1); + + const posts = await PostRepo.find(); + expect(posts.length).toBe(0); + }); + + it('single static message', async () => { + const n1 = await workflow.createNode({ + type: 'response-message', + config: { + message: 'm1', + }, + }); + + const n2 = await workflow.createNode({ + type: 'end', + config: { + endStatus: JOB_STATUS.RESOLVED, + }, + upstreamId: n1.id, + }); + + await n1.setDownstream(n2); + + const res1 = await userAgents[0].resource('posts').create({ + values: { title: 't1' }, + }); + + expect(res1.status).toBe(200); + expect(res1.body.data).toBeUndefined(); + expect(res1.body.messages).toEqual([{ message: 'm1' }]); + + const post = await PostRepo.findOne(); + expect(post).toBeNull(); + + const [e1] = await workflow.getExecutions(); + expect(e1.status).toBe(EXECUTION_STATUS.RESOLVED); + const jobs = await e1.getJobs(); + expect(jobs.length).toBe(2); + }); + }); + + describe('end as failure', () => { + it('no message', async () => { + const n1 = await workflow.createNode({ + type: 'end', + config: { + endStatus: JOB_STATUS.FAILED, + }, + }); + + const res1 = await userAgents[0].resource('posts').create({ + values: { title: 't1' }, + }); + + expect(res1.status).toBe(400); + expect(res1.body.data).toBeUndefined(); + expect(res1.body.messages).toBeUndefined(); + + const post = await PostRepo.findOne(); + expect(post).toBeNull(); + + const [e1] = await workflow.getExecutions(); + expect(e1.status).toBe(EXECUTION_STATUS.FAILED); + const jobs = await e1.getJobs(); + expect(jobs.length).toBe(1); + }); + + it('single static message', async () => { + const n1 = await workflow.createNode({ + type: 'response-message', + config: { + message: 'm1', + }, + }); + + const n2 = await workflow.createNode({ + type: 'end', + config: { + endStatus: JOB_STATUS.FAILED, + }, + upstreamId: n1.id, + }); + + await n1.setDownstream(n2); + + const res1 = await userAgents[0].resource('posts').create({ + values: { title: 't1' }, + }); + + expect(res1.status).toBe(400); + expect(res1.body.data).toBeUndefined(); + expect(res1.body.errors).toEqual([{ message: 'm1' }]); + + const post = await PostRepo.findOne(); + expect(post).toBeNull(); + + const [e1] = await workflow.getExecutions(); + expect(e1.status).toBe(EXECUTION_STATUS.FAILED); + const jobs = await e1.getJobs(); + expect(jobs.length).toBe(2); + }); + }); +}); diff --git a/packages/plugins/@nocobase/plugin-workflow-response-message/src/server/__tests__/multiple.test.ts b/packages/plugins/@nocobase/plugin-workflow-response-message/src/server/__tests__/multiple.test.ts new file mode 100644 index 0000000000..a97be3e1d6 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow-response-message/src/server/__tests__/multiple.test.ts @@ -0,0 +1,185 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This program is offered under a commercial license. + * For more information, see + */ + +import Database from '@nocobase/database'; +import { MockServer } from '@nocobase/test'; +import { EXECUTION_STATUS, JOB_STATUS } from '@nocobase/plugin-workflow'; +import { getApp } from '@nocobase/plugin-workflow-test'; + +import Plugin from '..'; + +describe('workflow > multiple workflows', () => { + let app: MockServer; + let db: Database; + let PostRepo; + let WorkflowModel; + let workflow; + let users; + let userAgents; + + beforeEach(async () => { + app = await getApp({ + plugins: ['users', 'auth', 'error-handler', 'workflow-request-interceptor', Plugin], + }); + + db = app.db; + + PostRepo = db.getCollection('posts').repository; + + WorkflowModel = db.getModel('workflows'); + workflow = await WorkflowModel.create({ + enabled: true, + type: 'request-interception', + config: { + global: true, + actions: ['create'], + collection: 'posts', + }, + }); + + const UserModel = db.getCollection('users').model; + users = await UserModel.bulkCreate([ + { id: 2, nickname: 'a' }, + { id: 3, nickname: 'b' }, + ]); + + userAgents = await Promise.all(users.map((user) => app.agent().login(user))); + }); + + afterEach(() => app.destroy()); + + describe('order', () => { + it('workflow 2 run first and pass, workflow 1 ends as success', async () => { + const n1 = await workflow.createNode({ + type: 'response-message', + config: { + message: 'm1', + }, + }); + const n2 = await workflow.createNode({ + type: 'end', + config: { + endStatus: JOB_STATUS.RESOLVED, + }, + upstreamId: n1.id, + }); + await n1.setDownstream(n2); + + const w2 = await WorkflowModel.create({ + enabled: true, + type: 'request-interception', + config: { + action: 'create', + collection: 'posts', + }, + }); + + const n3 = await w2.createNode({ + type: 'response-message', + config: { + message: 'm2', + }, + }); + + const res1 = await userAgents[0].resource('posts').create({ + values: { title: 't1' }, + triggerWorkflows: w2.key, + }); + + expect(res1.status).toBe(200); + expect(res1.body.data).toBeUndefined(); + expect(res1.body.messages).toEqual([{ message: 'm2' }, { message: 'm1' }]); + + const post = await PostRepo.findOne(); + expect(post).toBeNull(); + + const [e1] = await workflow.getExecutions(); + expect(e1.status).toBe(EXECUTION_STATUS.RESOLVED); + const j1s = await e1.getJobs(); + expect(j1s.length).toBe(2); + + const [e2] = await w2.getExecutions(); + expect(e2.status).toBe(EXECUTION_STATUS.RESOLVED); + const j2s = await e2.getJobs(); + expect(j2s.length).toBe(1); + }); + + it('local workflow in trigger key order', async () => { + const w1 = await WorkflowModel.create({ + enabled: true, + type: 'request-interception', + config: { + action: 'create', + collection: 'posts', + }, + }); + + const n1 = await w1.createNode({ + type: 'response-message', + config: { + message: 'm1', + }, + }); + + const w2 = await WorkflowModel.create({ + enabled: true, + type: 'request-interception', + config: { + action: 'create', + collection: 'posts', + }, + }); + + const n2 = await w2.createNode({ + type: 'response-message', + config: { + message: 'm2', + }, + }); + + const n3 = await w2.createNode({ + type: 'end', + config: { + endStatus: JOB_STATUS.RESOLVED, + }, + upstreamId: n2.id, + }); + + await n2.setDownstream(n3); + + const res1 = await userAgents[0].resource('posts').create({ + values: { title: 't1' }, + triggerWorkflows: [w2.key, w1.key].join(), + }); + + expect(res1.status).toBe(200); + expect(res1.body.data).toBeUndefined(); + expect(res1.body.messages).toEqual([{ message: 'm2' }]); + + const post = await PostRepo.findOne(); + expect(post).toBeNull(); + + const e1s = await w1.getExecutions(); + expect(e1s.length).toBe(0); + const [e2] = await w2.getExecutions(); + expect(e2.status).toBe(EXECUTION_STATUS.RESOLVED); + const jobs = await e2.getJobs(); + expect(jobs.length).toBe(2); + }); + }); +}); diff --git a/packages/plugins/@nocobase/plugin-workflow-response-message/src/server/index.ts b/packages/plugins/@nocobase/plugin-workflow-response-message/src/server/index.ts new file mode 100644 index 0000000000..b0c269d075 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow-response-message/src/server/index.ts @@ -0,0 +1,19 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This program is offered under a commercial license. + * For more information, see + */ + +export { default } from './Plugin'; diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client/WorkflowTasks.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client/WorkflowTasks.tsx index 6d0f60a1a0..17d435fa26 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/client/WorkflowTasks.tsx +++ b/packages/plugins/@nocobase/plugin-workflow/src/client/WorkflowTasks.tsx @@ -11,14 +11,17 @@ import { PageHeader } from '@ant-design/pro-layout'; import { Badge, Button, Layout, Menu, Tabs, Tooltip } from 'antd'; import classnames from 'classnames'; import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; -import { Link, Outlet, useNavigate, useParams } from 'react-router-dom'; +import { Link, useNavigate, useParams } from 'react-router-dom'; import { + ActionContextProvider, + CollectionRecordProvider, css, PinnedPluginListProvider, SchemaComponent, SchemaComponentContext, SchemaComponentOptions, + useAPIClient, useApp, useCompile, useDocumentTitle, @@ -44,9 +47,11 @@ const contentClass = css` export interface TaskTypeOptions { title: string; collection: string; + action: string; useActionParams: Function; - component: React.ComponentType; - extraActions?: React.ComponentType; + Actions?: React.ComponentType; + Item: React.ComponentType; + Detail: React.ComponentType; // children?: TaskTypeOptions[]; } @@ -65,7 +70,7 @@ function MenuLink({ type }: any) { return ( , + right: , } : {} } @@ -157,16 +162,45 @@ function useCurrentTaskType() { ); } +function PopupContext(props: any) { + const { popupId } = useParams(); + const { record } = usePopupRecordContext(); + const navigate = useNavigate(); + if (!popupId) { + return null; + } + return ( + { + if (!visible) { + navigate(-1); + } + }} + openMode="modal" + > + {props.children} + + ); +} + +const PopupRecordContext = createContext({ record: null, setRecord: (record) => {} }); +export function usePopupRecordContext() { + return useContext(PopupRecordContext); +} + export function WorkflowTasks() { const compile = useCompile(); const { setTitle } = useDocumentTitle(); const navigate = useNavigate(); - const { taskType, status = TASK_STATUS.PENDING } = useParams(); + const apiClient = useAPIClient(); + const { taskType, status = TASK_STATUS.PENDING, popupId } = useParams(); const { token } = useToken(); + const [currentRecord, setCurrentRecord] = useState(null); const items = useTaskTypeItems(); - const { title, collection, useActionParams, component: Component } = useCurrentTaskType(); + const { title, collection, action = 'list', useActionParams, Item, Detail } = useCurrentTaskType(); const params = useActionParams(status); @@ -180,6 +214,24 @@ export function WorkflowTasks() { } }, [items, navigate, status, taskType]); + useEffect(() => { + if (popupId && !currentRecord) { + apiClient + .resource(collection) + .get({ + filterByTk: popupId, + }) + .then((res) => { + if (res.data?.data) { + setCurrentRecord(res.data.data); + } + }) + .catch((err) => { + console.error(err); + }); + } + }, [popupId, collection, currentRecord, apiClient]); + const typeKey = taskType ?? items[0].key; return ( @@ -205,84 +257,95 @@ export function WorkflowTasks() { } `} > - - + + .itemCss:not(:last-child) { - border-bottom: none; - } - `, - locale: { - emptyText: `{{ t("No data yet", { ns: "${NAMESPACE}" }) }}`, - }, + properties: { + header: { + type: 'void', + 'x-component': 'PageHeader', + 'x-component-props': { + className: classnames('pageHeaderCss'), + style: { + background: token.colorBgContainer, + padding: '12px 24px 0 24px', }, - properties: { - item: { - type: 'object', - 'x-decorator': 'List.Item', - 'x-component': Component, - 'x-read-pretty': true, + title, + }, + properties: { + tabs: { + type: 'void', + 'x-component': 'StatusTabs', + }, + }, + }, + content: { + type: 'void', + 'x-component': 'Layout.Content', + 'x-component-props': { + className: contentClass, + style: { + padding: `${token.paddingPageVertical}px ${token.paddingPageHorizontal}px`, + }, + }, + properties: { + list: { + type: 'array', + 'x-component': 'List', + 'x-component-props': { + className: css` + > .itemCss:not(:last-child) { + border-bottom: none; + } + `, + locale: { + emptyText: `{{ t("No data yet", { ns: "${NAMESPACE}" }) }}`, + }, + }, + properties: { + item: { + type: 'object', + 'x-decorator': 'List.Item', + 'x-component': Item, + 'x-read-pretty': true, + }, }, }, }, }, + popup: { + type: 'void', + 'x-decorator': PopupContext, + 'x-component': Detail, + }, }, - }, - }} - /> - - + }} + /> + + ); @@ -296,7 +359,7 @@ function WorkflowTasksLink() { return types.length ? (