diff --git a/CHANGELOG.md b/CHANGELOG.md index ef6d9fde84..e0058471f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,342 @@ 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.16](https://github.com/nocobase/nocobase/compare/v1.6.15...v1.6.16) - 2025-04-03 + +### 🐛 Bug Fixes + +- **[client]** + - x-disabled property not taking effect on form fields ([#6610](https://github.com/nocobase/nocobase/pull/6610)) by @katherinehhh + + - field label display issue to prevent truncation by colon ([#6599](https://github.com/nocobase/nocobase/pull/6599)) by @katherinehhh + +- **[database]** When deleting one-to-many records, both `filter` and `filterByTk` are passed and `filter` includes an association field, the `filterByTk` is ignored ([#6606](https://github.com/nocobase/nocobase/pull/6606)) by @2013xile + +## [v1.6.15](https://github.com/nocobase/nocobase/compare/v1.6.14...v1.6.15) - 2025-04-01 + +### 🚀 Improvements + +- **[database]** + - Add trim option for text field ([#6603](https://github.com/nocobase/nocobase/pull/6603)) by @mytharcher + + - Add trim option for string field ([#6565](https://github.com/nocobase/nocobase/pull/6565)) by @mytharcher + +- **[File manager]** Add trim option for text fields of storages collection ([#6604](https://github.com/nocobase/nocobase/pull/6604)) by @mytharcher + +- **[Workflow]** Improve code ([#6589](https://github.com/nocobase/nocobase/pull/6589)) by @mytharcher + +- **[Workflow: Approval]** Support to use block template for approval process form by @mytharcher + +### 🐛 Bug Fixes + +- **[database]** Avoid "datetimeNoTz" field changes when value not changed in updating record ([#6588](https://github.com/nocobase/nocobase/pull/6588)) by @mytharcher + +- **[client]** + - association field (select) displaying N/A when exposing related collection fields ([#6582](https://github.com/nocobase/nocobase/pull/6582)) by @katherinehhh + + - Fix `disabled` property not works when `SchemaInitializerItem` has `items` ([#6597](https://github.com/nocobase/nocobase/pull/6597)) by @mytharcher + + - cascade issue: 'The value of xxx cannot be in array format' when deleting and re-selecting ([#6585](https://github.com/nocobase/nocobase/pull/6585)) by @katherinehhh + +- **[Collection field: Many to many (array)]** Issue of filtering by fields in an association collection with a many to many (array) field ([#6596](https://github.com/nocobase/nocobase/pull/6596)) by @2013xile + +- **[Public forms]** View permissions include list and get ([#6607](https://github.com/nocobase/nocobase/pull/6607)) by @chenos + +- **[Authentication]** token assignment in `AuthProvider` ([#6593](https://github.com/nocobase/nocobase/pull/6593)) by @2013xile + +- **[Workflow]** Fix sync option display incorrectly ([#6595](https://github.com/nocobase/nocobase/pull/6595)) by @mytharcher + +- **[Block: Map]** map management validation should not pass with space input ([#6575](https://github.com/nocobase/nocobase/pull/6575)) by @katherinehhh + +- **[Workflow: Approval]** + - Fix client variables to use in approval form by @mytharcher + + - Fix branch mode when `endOnReject` configured as `true` by @mytharcher + +## [v1.6.14](https://github.com/nocobase/nocobase/compare/v1.6.13...v1.6.14) - 2025-03-29 + +### 🐛 Bug Fixes + +- **[Calendar]** missing data on boundary dates in weekly calendar view ([#6587](https://github.com/nocobase/nocobase/pull/6587)) by @katherinehhh + +- **[Auth: OIDC]** Incorrect redirection occurs when the callback path is the string 'null' by @2013xile + +- **[Workflow: Approval]** Fix approval node configuration is incorrect after schema changed by @mytharcher + +## [v1.6.13](https://github.com/nocobase/nocobase/compare/v1.6.12...v1.6.13) - 2025-03-28 + +### 🚀 Improvements + +- **[Async task manager]** optimize import/export buttons in Pro ([#6531](https://github.com/nocobase/nocobase/pull/6531)) by @chenos + +- **[Action: Export records Pro]** optimize import/export buttons in Pro by @katherinehhh + +- **[Migration manager]** allow skip automatic backup and restore for migration by @gchust + +### 🐛 Bug Fixes + +- **[client]** linkage conflict between same-named association fields in different sub-tables within the same form ([#6577](https://github.com/nocobase/nocobase/pull/6577)) by @katherinehhh + +- **[Action: Batch edit]** Click the batch edit button, configure the pop-up window, and then open it again, the pop-up window is blank ([#6578](https://github.com/nocobase/nocobase/pull/6578)) by @zhangzhonghe + +## [v1.6.12](https://github.com/nocobase/nocobase/compare/v1.6.11...v1.6.12) - 2025-03-27 + +### 🐛 Bug Fixes + +- **[Block: Multi-step form]** + - the submit button has the same color in its default and highlighted by @jiannx + + - fixed the bug that form reset is invalid when the field is associated with other field by @jiannx + +- **[Workflow: Approval]** Fix approval form values to submit by @mytharcher + +## [v1.6.11](https://github.com/nocobase/nocobase/compare/v1.6.10...v1.6.11) - 2025-03-27 + +### 🚀 Improvements + +- **[client]** + - Optimize 502 error message ([#6547](https://github.com/nocobase/nocobase/pull/6547)) by @chenos + + - Only support plain text file to preview ([#6563](https://github.com/nocobase/nocobase/pull/6563)) by @mytharcher + +- **[Collection field: Sequence]** support setting sequence as the title field for calendar block ([#6562](https://github.com/nocobase/nocobase/pull/6562)) by @katherinehhh + +- **[Workflow: Approval]** Support to skip validator in settings by @mytharcher + +### 🐛 Bug Fixes + +- **[client]** + - issue with date field display in data scope filtering ([#6564](https://github.com/nocobase/nocobase/pull/6564)) by @katherinehhh + + - The 'Ellipsis overflow content' option requires a page refresh for the toggle state to take effect ([#6520](https://github.com/nocobase/nocobase/pull/6520)) by @zhangzhonghe + + - Unable to open another modal within a modal ([#6535](https://github.com/nocobase/nocobase/pull/6535)) by @zhangzhonghe + +- **[API documentation]** API document page cannot scroll ([#6566](https://github.com/nocobase/nocobase/pull/6566)) by @zhangzhonghe + +- **[Workflow]** Make sure workflow key is generated before save ([#6567](https://github.com/nocobase/nocobase/pull/6567)) by @mytharcher + +- **[Workflow: Post-action event]** Multiple records in bulk action should trigger multiple times ([#6559](https://github.com/nocobase/nocobase/pull/6559)) by @mytharcher + +- **[Authentication]** Localization issue for fields of sign up page ([#6556](https://github.com/nocobase/nocobase/pull/6556)) by @2013xile + +- **[Public forms]** issue with public form page title displaying 'Loading...' ([#6569](https://github.com/nocobase/nocobase/pull/6569)) by @katherinehhh + +## [v1.6.10](https://github.com/nocobase/nocobase/compare/v1.6.9...v1.6.10) - 2025-03-25 + +### 🐛 Bug Fixes + +- **[client]** + - Unable to use 'Current User' variable when adding a link page ([#6536](https://github.com/nocobase/nocobase/pull/6536)) by @zhangzhonghe + + - field assignment with null value is ineffective ([#6549](https://github.com/nocobase/nocobase/pull/6549)) by @katherinehhh + + - `yarn doc` command error ([#6540](https://github.com/nocobase/nocobase/pull/6540)) by @gchust + + - Remove the 'Allow multiple selection' option from dropdown single-select fields in filter forms ([#6515](https://github.com/nocobase/nocobase/pull/6515)) by @zhangzhonghe + + - Relational field's data range linkage is not effective ([#6530](https://github.com/nocobase/nocobase/pull/6530)) by @zhangzhonghe + +- **[Collection: Tree]** Migration issue for plugin-collection-tree ([#6537](https://github.com/nocobase/nocobase/pull/6537)) by @2013xile + +- **[Action: Custom request]** Unable to download UTF-8 encoded files ([#6541](https://github.com/nocobase/nocobase/pull/6541)) by @2013xile + +## [v1.6.9](https://github.com/nocobase/nocobase/compare/v1.6.8...v1.6.9) - 2025-03-23 + +### 🐛 Bug Fixes + +- **[client]** action button transparency causing setting display issue on hover ([#6529](https://github.com/nocobase/nocobase/pull/6529)) by @katherinehhh + +## [v1.6.8](https://github.com/nocobase/nocobase/compare/v1.6.7...v1.6.8) - 2025-03-22 + +### 🐛 Bug Fixes + +- **[server]** The upgrade command may cause workflow errors ([#6524](https://github.com/nocobase/nocobase/pull/6524)) by @gchust + +- **[client]** the height of the subtable in the form is set along with the form height ([#6518](https://github.com/nocobase/nocobase/pull/6518)) by @katherinehhh + +- **[Authentication]** + - X-Authenticator missing ([#6526](https://github.com/nocobase/nocobase/pull/6526)) by @chenos + + - Trim authenticator options ([#6527](https://github.com/nocobase/nocobase/pull/6527)) by @2013xile + +- **[Block: Map]** map block key management issue causing request failures due to invisible characters ([#6521](https://github.com/nocobase/nocobase/pull/6521)) by @katherinehhh + +- **[Backup manager]** Restoration may cause workflow execution errors by @gchust + +- **[WeCom]** Resolve environment variables and secrets when retrieving notification configuration. by @2013xile + +## [v1.6.7](https://github.com/nocobase/nocobase/compare/v1.6.6...v1.6.7) - 2025-03-20 + +### 🚀 Improvements + +- **[Workflow: mailer node]** Add secure field config description. ([#6510](https://github.com/nocobase/nocobase/pull/6510)) by @sheldon66 + +- **[Notification: Email]** Add secure field config description. ([#6501](https://github.com/nocobase/nocobase/pull/6501)) by @sheldon66 + +- **[Calendar]** Calendar plugin with optional settings to enable or disable quick event creation ([#6391](https://github.com/nocobase/nocobase/pull/6391)) by @Cyx649312038 + +### 🐛 Bug Fixes + +- **[client]** time field submission error in Chinese locale (invalid input syntax for type time) ([#6511](https://github.com/nocobase/nocobase/pull/6511)) by @katherinehhh + +- **[File manager]** Unable to access files stored in COS ([#6512](https://github.com/nocobase/nocobase/pull/6512)) by @chenos + +- **[Block: Map]** secret key fields not triggering validation in map management ([#6509](https://github.com/nocobase/nocobase/pull/6509)) by @katherinehhh + +- **[WEB client]** The path in the route management table is different from the actual path ([#6483](https://github.com/nocobase/nocobase/pull/6483)) by @zhangzhonghe + +- **[Action: Export records Pro]** Unable to export attachments by @chenos + +- **[Workflow: Approval]** + - Fix null user caused crash by @mytharcher + + - Fix error thrown when add query node result by @mytharcher + +## [v1.6.6](https://github.com/nocobase/nocobase/compare/v1.6.5...v1.6.6) - 2025-03-18 + +### 🎉 New Features + +- **[client]** support long text fields as title fields for association field ([#6495](https://github.com/nocobase/nocobase/pull/6495)) by @katherinehhh + +- **[Workflow: Aggregate node]** Support to configure precision for aggregation result ([#6491](https://github.com/nocobase/nocobase/pull/6491)) by @mytharcher + +### 🚀 Improvements + +- **[File storage: S3(Pro)]** Change the text 'Access URL Base' to 'Base URL' by @zhangzhonghe + +### 🐛 Bug Fixes + +- **[evaluators]** Revert round decimal places to 9 ([#6492](https://github.com/nocobase/nocobase/pull/6492)) by @mytharcher + +- **[File manager]** encode url ([#6497](https://github.com/nocobase/nocobase/pull/6497)) by @chenos + +- **[Data source: Main]** Unable to create a MySQL view. ([#6477](https://github.com/nocobase/nocobase/pull/6477)) by @aaaaaajie + +- **[Workflow]** Fix legacy tasks count after workflow deleted ([#6493](https://github.com/nocobase/nocobase/pull/6493)) by @mytharcher + +- **[Embed NocoBase]** Page displays blank by @zhangzhonghe + +- **[Backup manager]** + - Upload files have not been restored when creating sub-app from backup template by @gchust + + - MySQL database restore failure caused by GTID set overlap by @gchust + +- **[Workflow: Approval]** + - Change returned approval as todo by @mytharcher + + - Fix action button missed in process table by @mytharcher + +## [v1.6.5](https://github.com/nocobase/nocobase/compare/v1.6.4...v1.6.5) - 2025-03-17 + +### 🚀 Improvements + +- **[File manager]** Simplify file URL generating logic and API ([#6472](https://github.com/nocobase/nocobase/pull/6472)) by @mytharcher + +- **[File storage: S3(Pro)]** Change to a simple way to generate file URL by @mytharcher + +- **[Backup manager]** Allow restore backup between pre release and release version of the same version by @gchust + +### 🐛 Bug Fixes + +- **[client]** + - rich text field not clearing data on submission ([#6486](https://github.com/nocobase/nocobase/pull/6486)) by @katherinehhh + + - The color of the icons in the upper right corner of the page does not change with the theme ([#6482](https://github.com/nocobase/nocobase/pull/6482)) by @zhangzhonghe + + - Clicking the reset button on the filter form cannot clear the filtering conditions of the grid card block ([#6475](https://github.com/nocobase/nocobase/pull/6475)) by @zhangzhonghe + +- **[Workflow: Manual node]** + - Fix migration ([#6484](https://github.com/nocobase/nocobase/pull/6484)) by @mytharcher + + - Change migration name to ensure rerun ([#6487](https://github.com/nocobase/nocobase/pull/6487)) by @mytharcher + + - Fix workflow title field in filter ([#6480](https://github.com/nocobase/nocobase/pull/6480)) by @mytharcher + + - Fix migration error when id column is not exists ([#6470](https://github.com/nocobase/nocobase/pull/6470)) by @chenos + + - Avoid collection synchronized from fields ([#6478](https://github.com/nocobase/nocobase/pull/6478)) by @mytharcher + +- **[Workflow: Aggregate node]** Fix round on null result ([#6473](https://github.com/nocobase/nocobase/pull/6473)) by @mytharcher + +- **[Workflow]** Don't count tasks when workflow deleted ([#6474](https://github.com/nocobase/nocobase/pull/6474)) by @mytharcher + +- **[Backup manager]** Not able to start server when missing default backup settings by @gchust + +- **[Workflow: Approval]** + - Fix file association field error in process form by @mytharcher + + - Fix tasks count based on hooks by @mytharcher + +## [v1.6.4](https://github.com/nocobase/nocobase/compare/v1.6.3...v1.6.4) - 2025-03-14 + +### 🎉 New Features + +- **[client]** Cascade Selection Component Add Data Scope Setting ([#6386](https://github.com/nocobase/nocobase/pull/6386)) by @Cyx649312038 + +### 🚀 Improvements + +- **[utils]** Move `md5` to utils ([#6468](https://github.com/nocobase/nocobase/pull/6468)) by @mytharcher + +### 🐛 Bug Fixes + +- **[client]** In the tree block, when unchecked, the data in the data block is not being cleared ([#6460](https://github.com/nocobase/nocobase/pull/6460)) by @zhangzhonghe + +- **[File manager]** Unable to delete files stored in S3. ([#6467](https://github.com/nocobase/nocobase/pull/6467)) by @chenos + +- **[Workflow]** Remove bind workflow settings button from data picker ([#6455](https://github.com/nocobase/nocobase/pull/6455)) by @mytharcher + +- **[File storage: S3(Pro)]** Resolve issue with inaccessible S3 Pro signed URLs by @chenos + +- **[Workflow: Approval]** Avoid page crash when no applicant in approval process table by @mytharcher + +## [v1.6.3](https://github.com/nocobase/nocobase/compare/v1.6.2...v1.6.3) - 2025-03-13 + +### 🎉 New Features + +- **[WeCom]** Allows setting a custom tooltip for the sign-in button by @2013xile + +### 🐛 Bug Fixes + +- **[client]** + - Fix special character in image URL caused not showing ([#6459](https://github.com/nocobase/nocobase/pull/6459)) by @mytharcher + + - incorrect page number when adding data after subtable page size change ([#6437](https://github.com/nocobase/nocobase/pull/6437)) by @katherinehhh + + - The logo style is inconsistent with the previous one ([#6444](https://github.com/nocobase/nocobase/pull/6444)) by @zhangzhonghe + +- **[Workflow: Manual node]** Fix error thrown in migration ([#6445](https://github.com/nocobase/nocobase/pull/6445)) by @mytharcher + +- **[Data visualization]** Removed fields appear when adding custom filter fields ([#6450](https://github.com/nocobase/nocobase/pull/6450)) by @2013xile + +- **[File manager]** Fix a few issues of file manager ([#6436](https://github.com/nocobase/nocobase/pull/6436)) by @mytharcher + +- **[Action: Custom request]** custom request server-side permission validation error ([#6438](https://github.com/nocobase/nocobase/pull/6438)) by @katherinehhh + +- **[Data source manager]** switching data source in role management does not load corresponding collections ([#6431](https://github.com/nocobase/nocobase/pull/6431)) by @katherinehhh + +- **[Action: Batch edit]** Fix workflow can not be triggered in bulk edit submission ([#6440](https://github.com/nocobase/nocobase/pull/6440)) by @mytharcher + +- **[Workflow: Custom action event]** Remove `only` in E2E test case by @mytharcher + +- **[Workflow: Approval]** + - Fix association data not showing in approval form by @mytharcher + + - Fix error thrown when approve on external data source by @mytharcher + +## [v1.6.2](https://github.com/nocobase/nocobase/compare/v1.6.1...v1.6.2) - 2025-03-12 + +### 🐛 Bug Fixes + +- **[client]** date field range selection excludes the max date ([#6418](https://github.com/nocobase/nocobase/pull/6418)) by @katherinehhh + +- **[Notification: In-app message]** Avoid wrong receivers configuration query all users ([#6424](https://github.com/nocobase/nocobase/pull/6424)) by @sheldon66 + +- **[Workflow: Manual node]** + - Fix migration which missed table prefix and schema logic ([#6425](https://github.com/nocobase/nocobase/pull/6425)) by @mytharcher + + - Change version limit of migration to `<1.7.0` ([#6430](https://github.com/nocobase/nocobase/pull/6430)) by @mytharcher + ## [v1.6.1](https://github.com/nocobase/nocobase/compare/v1.6.0...v1.6.1) - 2025-03-11 ### 🐛 Bug Fixes diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md index 05ee1f6f00..e5cff93429 100644 --- a/CHANGELOG.zh-CN.md +++ b/CHANGELOG.zh-CN.md @@ -5,6 +5,342 @@ 格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/), 并且本项目遵循 [语义化版本](https://semver.org/spec/v2.0.0.html)。 +## [v1.6.16](https://github.com/nocobase/nocobase/compare/v1.6.15...v1.6.16) - 2025-04-03 + +### 🐛 修复 + +- **[client]** + - 表单字段设置不可编辑不起作用 ([#6610](https://github.com/nocobase/nocobase/pull/6610)) by @katherinehhh + + - 表单字段标题因冒号导致的截断问题 ([#6599](https://github.com/nocobase/nocobase/pull/6599)) by @katherinehhh + +- **[database]** 删除一对多记录时,同时传递 `filter` 和 `filterByTk` 参数,`filter` 包含关系字段时,`filterByTk` 参数失效 ([#6606](https://github.com/nocobase/nocobase/pull/6606)) by @2013xile + +## [v1.6.15](https://github.com/nocobase/nocobase/compare/v1.6.14...v1.6.15) - 2025-04-01 + +### 🚀 优化 + +- **[database]** + - 为多行文本类型字段增加去除首尾空白字符的选项 ([#6603](https://github.com/nocobase/nocobase/pull/6603)) by @mytharcher + + - 为单行文本增加自动去除首尾空白字符的选项 ([#6565](https://github.com/nocobase/nocobase/pull/6565)) by @mytharcher + +- **[文件管理器]** 为存储引擎表的文本字段增加去除首尾空白字符的选项 ([#6604](https://github.com/nocobase/nocobase/pull/6604)) by @mytharcher + +- **[工作流]** 优化代码 ([#6589](https://github.com/nocobase/nocobase/pull/6589)) by @mytharcher + +- **[工作流:审批]** 支持审批表单使用区块模板 by @mytharcher + +### 🐛 修复 + +- **[database]** 避免“日期时间(无时区)”字段在值未变动的更新时触发值改变 ([#6588](https://github.com/nocobase/nocobase/pull/6588)) by @mytharcher + +- **[client]** + - 关系字段(select)放出关系表字段时默认显示 N/A ([#6582](https://github.com/nocobase/nocobase/pull/6582)) by @katherinehhh + + - 修复 `SchemaInitializerItem` 配置了 `items` 时 `disabled` 属性无效的问题 ([#6597](https://github.com/nocobase/nocobase/pull/6597)) by @mytharcher + + - 级联组件删除后重新选择时出现 'The value of xxx cannot be in array format' ([#6585](https://github.com/nocobase/nocobase/pull/6585)) by @katherinehhh + +- **[数据表字段:多对多 (数组)]** 主表筛选带有多对多(数组)字段的关联表中的字段报错的问题 ([#6596](https://github.com/nocobase/nocobase/pull/6596)) by @2013xile + +- **[公开表单]** 查看权限包括 list 和 get ([#6607](https://github.com/nocobase/nocobase/pull/6607)) by @chenos + +- **[用户认证]** `AuthProvider` 中的 token 赋值 ([#6593](https://github.com/nocobase/nocobase/pull/6593)) by @2013xile + +- **[工作流]** 修复同步选项展示问题 ([#6595](https://github.com/nocobase/nocobase/pull/6595)) by @mytharcher + +- **[区块:地图]** 地图管理必填校验不应通过空格输入 ([#6575](https://github.com/nocobase/nocobase/pull/6575)) by @katherinehhh + +- **[工作流:审批]** + - 修复审批表单中的前端变量 by @mytharcher + + - 修复分支模式下配置拒绝则结束时的流程问题 by @mytharcher + +## [v1.6.14](https://github.com/nocobase/nocobase/compare/v1.6.13...v1.6.14) - 2025-03-29 + +### 🐛 修复 + +- **[日历]** 日历区块以周为视图时,边界日期不显示数据 ([#6587](https://github.com/nocobase/nocobase/pull/6587)) by @katherinehhh + +- **[认证:OIDC]** 回调路径是字符串'null'时导致跳转不正确 by @2013xile + +- **[工作流:审批]** 修复审批节点界面配置变更后数据未同步的问题 by @mytharcher + +## [v1.6.13](https://github.com/nocobase/nocobase/compare/v1.6.12...v1.6.13) - 2025-03-28 + +### 🚀 优化 + +- **[异步任务管理器]** 优化 Pro 导入导出按钮异步逻辑 ([#6531](https://github.com/nocobase/nocobase/pull/6531)) by @chenos + +- **[操作:导出记录 Pro]** 优化 Pro 导入导出按钮 by @katherinehhh + +- **[迁移管理]** 允许执行迁移时跳过自动备份还原 by @gchust + +### 🐛 修复 + +- **[client]** 同一表单中不同关系字段的同名关系字段的联动互相影响 ([#6577](https://github.com/nocobase/nocobase/pull/6577)) by @katherinehhh + +- **[操作:批量编辑]** 点击批量编辑按钮,配置完弹窗再打开,弹窗是空白的 ([#6578](https://github.com/nocobase/nocobase/pull/6578)) by @zhangzhonghe + +## [v1.6.12](https://github.com/nocobase/nocobase/compare/v1.6.11...v1.6.12) - 2025-03-27 + +### 🐛 修复 + +- **[区块:分步表单]** + - 提交按钮默认和高亮情况下颜色一样 by @jiannx + + - 修复当字段与其他表单字段存在关联时,表单重置无效 by @jiannx + +- **[工作流:审批]** 修复审批表单提交值的问题 by @mytharcher + +## [v1.6.11](https://github.com/nocobase/nocobase/compare/v1.6.10...v1.6.11) - 2025-03-27 + +### 🚀 优化 + +- **[client]** + - 优化 502 错误提示 ([#6547](https://github.com/nocobase/nocobase/pull/6547)) by @chenos + + - 仅支持纯文本文件预览 ([#6563](https://github.com/nocobase/nocobase/pull/6563)) by @mytharcher + +- **[数据表字段:自动编码]** 支持使用 sequence 作为日历区块的标题字段 ([#6562](https://github.com/nocobase/nocobase/pull/6562)) by @katherinehhh + +- **[工作流:审批]** 支持审批处理按钮跳过表单验证的设置 by @mytharcher + +### 🐛 修复 + +- **[client]** + - 数据范围中筛选日期字段显示异常 ([#6564](https://github.com/nocobase/nocobase/pull/6564)) by @katherinehhh + + - 选项“省略超出长度的内容”需要刷新页面,开关的状态才生效 ([#6520](https://github.com/nocobase/nocobase/pull/6520)) by @zhangzhonghe + + - 在弹窗中无法再次打开弹窗 ([#6535](https://github.com/nocobase/nocobase/pull/6535)) by @zhangzhonghe + +- **[API 文档]** API 文档页面不能滚动 ([#6566](https://github.com/nocobase/nocobase/pull/6566)) by @zhangzhonghe + +- **[工作流]** 确保创建工作流之前 key 已生成 ([#6567](https://github.com/nocobase/nocobase/pull/6567)) by @mytharcher + +- **[工作流:操作后事件]** 多行记录的批量操作需要触发多次 ([#6559](https://github.com/nocobase/nocobase/pull/6559)) by @mytharcher + +- **[用户认证]** 注册页面字段的本地化问题 ([#6556](https://github.com/nocobase/nocobase/pull/6556)) by @2013xile + +- **[公开表单]** 公开表单页面标题不应该显示 Loading... ([#6569](https://github.com/nocobase/nocobase/pull/6569)) by @katherinehhh + +## [v1.6.10](https://github.com/nocobase/nocobase/compare/v1.6.9...v1.6.10) - 2025-03-25 + +### 🐛 修复 + +- **[client]** + - 添加链接页面时,无法使用“当前用户”变量 ([#6536](https://github.com/nocobase/nocobase/pull/6536)) by @zhangzhonghe + + - 字段赋值对字段进行“空值”赋值无效 ([#6549](https://github.com/nocobase/nocobase/pull/6549)) by @katherinehhh + + - `yarn doc` 命令报错 ([#6540](https://github.com/nocobase/nocobase/pull/6540)) by @gchust + + - 筛选表单中,移除下拉单选字段的“允许多选”选项 ([#6515](https://github.com/nocobase/nocobase/pull/6515)) by @zhangzhonghe + + - 关系字段的数据范围联动不生效 ([#6530](https://github.com/nocobase/nocobase/pull/6530)) by @zhangzhonghe + +- **[数据表:树]** 树表插件的迁移脚本问题 ([#6537](https://github.com/nocobase/nocobase/pull/6537)) by @2013xile + +- **[操作:自定义请求]** 无法下载utf8编码的文件 ([#6541](https://github.com/nocobase/nocobase/pull/6541)) by @2013xile + +## [v1.6.9](https://github.com/nocobase/nocobase/compare/v1.6.8...v1.6.9) - 2025-03-23 + +### 🐛 修复 + +- **[client]** 操作按钮透明状态导致 hover 时按钮 setting 显示异常 ([#6529](https://github.com/nocobase/nocobase/pull/6529)) by @katherinehhh + +## [v1.6.8](https://github.com/nocobase/nocobase/compare/v1.6.7...v1.6.8) - 2025-03-22 + +### 🐛 修复 + +- **[server]** Upgrade 命令可能造成工作流报错 ([#6524](https://github.com/nocobase/nocobase/pull/6524)) by @gchust + +- **[client]** 表单中的子表格高度会随主表单高度一同设置 ([#6518](https://github.com/nocobase/nocobase/pull/6518)) by @katherinehhh + +- **[用户认证]** + - X-Authenticator 缺失 ([#6526](https://github.com/nocobase/nocobase/pull/6526)) by @chenos + + - 移除认证器配置项前后的空格、换行符 ([#6527](https://github.com/nocobase/nocobase/pull/6527)) by @2013xile + +- **[区块:地图]** 地图区块 密钥管理中不可见字符导致的密钥请求失败的问题 ([#6521](https://github.com/nocobase/nocobase/pull/6521)) by @katherinehhh + +- **[备份管理器]** 还原过程中可能引起工作流执行报错 by @gchust + +- **[企业微信]** 获取通知配置时需要解析环境变量和密钥 by @2013xile + +## [v1.6.7](https://github.com/nocobase/nocobase/compare/v1.6.6...v1.6.7) - 2025-03-20 + +### 🚀 优化 + +- **[工作流:邮件发送节点]** 增加安全字段配置描述。 ([#6510](https://github.com/nocobase/nocobase/pull/6510)) by @sheldon66 + +- **[通知:电子邮件]** 增加安全字段配置描述。 ([#6501](https://github.com/nocobase/nocobase/pull/6501)) by @sheldon66 + +- **[日历]** 日历插件添加开启或关闭快速创建事件可选设置 ([#6391](https://github.com/nocobase/nocobase/pull/6391)) by @Cyx649312038 + +### 🐛 修复 + +- **[client]** 时间字段在中文语言下提交时报错 invalid input syntax for type time ([#6511](https://github.com/nocobase/nocobase/pull/6511)) by @katherinehhh + +- **[文件管理器]** COS 存储的文件无法访问 ([#6512](https://github.com/nocobase/nocobase/pull/6512)) by @chenos + +- **[区块:地图]** 地图管理中密钥必填校验失败 ([#6509](https://github.com/nocobase/nocobase/pull/6509)) by @katherinehhh + +- **[WEB 客户端]** 路由管理表格中的路径与实际路径不一样 ([#6483](https://github.com/nocobase/nocobase/pull/6483)) by @zhangzhonghe + +- **[操作:导出记录 Pro]** 无法导出附件 by @chenos + +- **[工作流:审批]** + - 修复空用户造成页面崩溃 by @mytharcher + + - 修复审批人界面配置添加查询节点时的页面崩溃 by @mytharcher + +## [v1.6.6](https://github.com/nocobase/nocobase/compare/v1.6.5...v1.6.6) - 2025-03-18 + +### 🎉 新特性 + +- **[client]** 支持长文本字段作为关系字段的标题字段 ([#6495](https://github.com/nocobase/nocobase/pull/6495)) by @katherinehhh + +- **[工作流:聚合查询节点]** 支持为聚合结果配置精度选项 ([#6491](https://github.com/nocobase/nocobase/pull/6491)) by @mytharcher + +### 🚀 优化 + +- **[文件存储:S3 (Pro)]** 将文案“访问 URL 基础”改为“基础 URL” by @zhangzhonghe + +### 🐛 修复 + +- **[evaluators]** 将表达式计算保留小数调整回 9 位 ([#6492](https://github.com/nocobase/nocobase/pull/6492)) by @mytharcher + +- **[文件管理器]** URL 转义 ([#6497](https://github.com/nocobase/nocobase/pull/6497)) by @chenos + +- **[数据源:主数据库]** 无法创建 MySQL 视图 ([#6477](https://github.com/nocobase/nocobase/pull/6477)) by @aaaaaajie + +- **[工作流]** 修复历史遗留任务数量工作流删除后统计错误 ([#6493](https://github.com/nocobase/nocobase/pull/6493)) by @mytharcher + +- **[嵌入 NocoBase]** 页面显示空白 by @zhangzhonghe + +- **[备份管理器]** + - 通过多应用模板创建子应用时备份中的上传文件未被正确还原 by @gchust + + - 还原 MySQL 数据库备份时由于 GTID 集合重叠导致的失败 by @gchust + +- **[工作流:审批]** + - 将退回的审批单据列入待办 by @mytharcher + + - 修复审批过程表格中发起人查看按钮消失的问题 by @mytharcher + +## [v1.6.5](https://github.com/nocobase/nocobase/compare/v1.6.4...v1.6.5) - 2025-03-17 + +### 🚀 优化 + +- **[文件管理器]** 简化生成文件 URL 的逻辑和 API ([#6472](https://github.com/nocobase/nocobase/pull/6472)) by @mytharcher + +- **[文件存储:S3 (Pro)]** 优化生成文件 URL 的方法 by @mytharcher + +- **[备份管理器]** 允许在相同版本的预发布和发布版本之间恢复备份 by @gchust + +### 🐛 修复 + +- **[client]** + - 富文本字段清空后提交时数据未删除 ([#6486](https://github.com/nocobase/nocobase/pull/6486)) by @katherinehhh + + - 页面右上角图标的颜色不会随主题变化 ([#6482](https://github.com/nocobase/nocobase/pull/6482)) by @zhangzhonghe + + - 点击筛选表单的重置按钮无法清除网格卡片区块的筛选条件 ([#6475](https://github.com/nocobase/nocobase/pull/6475)) by @zhangzhonghe + +- **[工作流:人工处理节点]** + - 修复迁移脚本 ([#6484](https://github.com/nocobase/nocobase/pull/6484)) by @mytharcher + + - 修改迁移脚本确保执行 ([#6487](https://github.com/nocobase/nocobase/pull/6487)) by @mytharcher + + - 修复区块的筛选组件中工作流标题项 ([#6480](https://github.com/nocobase/nocobase/pull/6480)) by @mytharcher + + - 修复 id 列不存在时迁移脚本报错 ([#6470](https://github.com/nocobase/nocobase/pull/6470)) by @chenos + + - 避免历史表被关系字段同步出来 ([#6478](https://github.com/nocobase/nocobase/pull/6478)) by @mytharcher + +- **[工作流:聚合查询节点]** 修复对聚合结果为 null 时取整报错 ([#6473](https://github.com/nocobase/nocobase/pull/6473)) by @mytharcher + +- **[工作流]** 不统计已删除的工作流的待办 ([#6474](https://github.com/nocobase/nocobase/pull/6474)) by @mytharcher + +- **[备份管理器]** 默认的备份设置不存在时服务器无法启动 by @gchust + +- **[工作流:审批]** + - 修复审批表单中文件字段报错问题 by @mytharcher + + - 基于钩子事件修复待办任务数量 by @mytharcher + +## [v1.6.4](https://github.com/nocobase/nocobase/compare/v1.6.3...v1.6.4) - 2025-03-14 + +### 🎉 新特性 + +- **[client]** 级联选择组件添加数据范围设置 ([#6386](https://github.com/nocobase/nocobase/pull/6386)) by @Cyx649312038 + +### 🚀 优化 + +- **[utils]** 将 `md5` 方法移到通用包 ([#6468](https://github.com/nocobase/nocobase/pull/6468)) by @mytharcher + +### 🐛 修复 + +- **[client]** 在树区块中,取消选中时,数据区块的数据没有被清空 ([#6460](https://github.com/nocobase/nocobase/pull/6460)) by @zhangzhonghe + +- **[文件管理器]** 无法删除 s3 文件存储的文件 ([#6467](https://github.com/nocobase/nocobase/pull/6467)) by @chenos + +- **[工作流]** 在数据选择器中移除绑定工作流的配置按钮 ([#6455](https://github.com/nocobase/nocobase/pull/6455)) by @mytharcher + +- **[文件存储:S3 (Pro)]** 修复 s3 pro 的签名 url 无法访问的问题 by @chenos + +- **[工作流:审批]** 避免审批流程表格中由于没有发起人时的页面崩溃 by @mytharcher + +## [v1.6.3](https://github.com/nocobase/nocobase/compare/v1.6.2...v1.6.3) - 2025-03-13 + +### 🎉 新特性 + +- **[企业微信]** 支持自定义登录按钮提示 by @2013xile + +### 🐛 修复 + +- **[client]** + - 修复图片中特殊字符导致不显示的问题 ([#6459](https://github.com/nocobase/nocobase/pull/6459)) by @mytharcher + + - 子表格切换分页数后新增数据页码显示错误 ([#6437](https://github.com/nocobase/nocobase/pull/6437)) by @katherinehhh + + - Logo 的样式与之前的不一致 ([#6444](https://github.com/nocobase/nocobase/pull/6444)) by @zhangzhonghe + +- **[工作流:人工处理节点]** 修复迁移脚本报错 ([#6445](https://github.com/nocobase/nocobase/pull/6445)) by @mytharcher + +- **[数据可视化]** 添加自定义筛选字段时会出现已移除字段 ([#6450](https://github.com/nocobase/nocobase/pull/6450)) by @2013xile + +- **[文件管理器]** 修复文件管理一些问题 ([#6436](https://github.com/nocobase/nocobase/pull/6436)) by @mytharcher + +- **[操作:自定义请求]** 自定义请求的服务端权限校验错误 ([#6438](https://github.com/nocobase/nocobase/pull/6438)) by @katherinehhh + +- **[数据源管理]** 角色管理中切换数据源没有加载对应数据表 ([#6431](https://github.com/nocobase/nocobase/pull/6431)) by @katherinehhh + +- **[操作:批量编辑]** 修复批量编辑提交时未能触发工作流的问题 ([#6440](https://github.com/nocobase/nocobase/pull/6440)) by @mytharcher + +- **[工作流:自定义操作事件]** 移除 E2E 测试中的 `only` by @mytharcher + +- **[工作流:审批]** + - 修复审批表单中关系数据未展示的问题 by @mytharcher + + - 修复外部数据源审批时的报错 by @mytharcher + +## [v1.6.2](https://github.com/nocobase/nocobase/compare/v1.6.1...v1.6.2) - 2025-03-12 + +### 🐛 修复 + +- **[client]** 表单日期字段日期范围,最大日期可选范围少一天 ([#6418](https://github.com/nocobase/nocobase/pull/6418)) by @katherinehhh + +- **[通知:站内信]** 避免错误的接收人配置导致查询出全部用户 ([#6424](https://github.com/nocobase/nocobase/pull/6424)) by @sheldon66 + +- **[工作流:人工处理节点]** + - 修复遗漏表前缀和 schema 的迁移脚本 ([#6425](https://github.com/nocobase/nocobase/pull/6425)) by @mytharcher + + - 调整迁移脚本版本范围限制为 `<1.7.0` ([#6430](https://github.com/nocobase/nocobase/pull/6430)) by @mytharcher + ## [v1.6.1](https://github.com/nocobase/nocobase/compare/v1.6.0...v1.6.1) - 2025-03-11 ### 🐛 修复 diff --git a/LICENSE.txt b/LICENSE.txt index b3c87c1e83..babf2bf053 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,4 +1,4 @@ -Updated Date: February 20, 2025 +Updated Date: April 1, 2025 NocoBase License Agreement @@ -88,7 +88,7 @@ Except for Third-Party Open Source Software, the Company owns all copyrights, tr 6.6 Can sell plugins developed for Software in the Marketplace. -6.7 The User with an Enterprise Edition License can sell Upper Layer Application to their clients. +6.7 The User with a Professional or Enterprise Edition License can sell Upper Layer Application to their clients. 6.8 Not restricted by the AGPL-3.0 agreement. @@ -106,9 +106,9 @@ Except for Third-Party Open Source Software, the Company owns all copyrights, tr 7.4 It is not allowed to provide any form of no-code, zero-code, low-code platform SaaS products to the public using the original or modified Software. -7.5 It is not allowed for the User withot an Enterprise Edition license to sell Upper Layer Application to clients without a Commercial license. +7.5 It is not allowed for the User withot a Professional or Enterprise Edition license to sell Upper Layer Application to clients without a Commercial license. -7.6 It is not allowed for the User with an Enterprise Edition license to sell Upper Layer Application to clients without a Commercial license with access to further development and configuration. +7.6 It is not allowed for the User with a Professional or Enterprise Edition license to sell Upper Layer Application to clients without a Commercial license with access to further development and configuration. 7.7 It is not allowed to publicly sell plugins developed for Software outside of the Marketplace. diff --git a/README.ja-JP.md b/README.ja-JP.md index 2fff33bddd..49e5c1be42 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -2,14 +2,10 @@ https://github.com/user-attachments/assets/cf08bfe5-e6e6-453c-8b96-350a6a8bed17 -## ご協力ありがとうございます! +

nocobase%2Fnocobase | Trendshift - NocoBase - Scalability-first, open-source no-code platform | Product Hunt - -## リリースノート - -リリースノートは[ブログ](https://www.nocobase.com/ja/blog/timeline)で随時更新され、週ごとにまとめて公開しています。 +

## NocoBaseはなに? @@ -28,6 +24,16 @@ https://docs-cn.nocobase.com/ コミュニティ: https://forum.nocobase.com/ +チュートリアル: +https://www.nocobase.com/ja/tutorials + +顧客のストーリー: +https://www.nocobase.com/ja/blog/tags/customer-stories + +## リリースノート + +リリースノートは[ブログ](https://www.nocobase.com/ja/blog/timeline)で随時更新され、週ごとにまとめて公開しています。 + ## 他の製品との違い ### 1. データモデル駆動 diff --git a/README.md b/README.md index 1314bec051..a2cfdb2bc4 100644 --- a/README.md +++ b/README.md @@ -2,19 +2,14 @@ English | [中文](./README.zh-CN.md) | [日本語](./README.ja-JP.md) https://github.com/user-attachments/assets/a50c100a-4561-4e06-b2d2-d48098659ec0 -## We'd love your support! - +

nocobase%2Fnocobase | Trendshift - NocoBase - Scalability-first, open-source no-code platform | Product Hunt - -## Release Notes - -Our [blog](https://www.nocobase.com/en/blog/timeline) is regularly updated with release notes and provides a weekly summary. +

## What is NocoBase -NocoBase is a scalability-first, open-source no-code development platform. +NocoBase is an extensibility-first, open-source no-code development platform. Instead of investing years of time and millions of dollars in research and development, deploy NocoBase in a few minutes and you'll have a private, controllable, and extremely scalable no-code development platform! Homepage: @@ -29,6 +24,17 @@ https://docs.nocobase.com/ Forum: https://forum.nocobase.com/ +Tutorials: +https://www.nocobase.com/en/tutorials + +Use Cases: +https://www.nocobase.com/en/blog/tags/customer-stories + + +## Release Notes + +Our [blog](https://www.nocobase.com/en/blog/timeline) is regularly updated with release notes and provides a weekly summary. + ## Distinctive features ### 1. Data model-driven diff --git a/README.zh-CN.md b/README.zh-CN.md index 66e9c281d7..728695efc7 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -2,13 +2,10 @@ https://github.com/nocobase/nocobase/assets/1267426/29623e45-9a48-4598-bb9e-9dd173ade553 -## 感谢支持 +

nocobase%2Fnocobase | Trendshift - NocoBase - Scalability-first, open-source no-code platform | Product Hunt - -## 发布日志 -我们的[博客](https://www.nocobase.com/cn/blog/timeline)会及时更新发布日志,并每周进行汇总。 +

## NocoBase 是什么 @@ -27,6 +24,15 @@ https://docs-cn.nocobase.com/ 社区: https://forum.nocobase.com/ +教程: +https://www.nocobase.com/cn/tutorials + +用户故事: +https://www.nocobase.com/cn/blog/tags/customer-stories + +## 发布日志 +我们的[博客](https://www.nocobase.com/cn/blog/timeline)会及时更新发布日志,并每周进行汇总。 + ## 与众不同之处 ### 1. 数据模型驱动 diff --git a/docker/nocobase-full/nocobase.conf b/docker/nocobase-full/nocobase.conf index 61ddc70335..7de9bbb630 100644 --- a/docker/nocobase-full/nocobase.conf +++ b/docker/nocobase-full/nocobase.conf @@ -17,7 +17,7 @@ server { server_name _; root /app/nocobase/packages/app/client/dist; index index.html; - client_max_body_size 20M; + client_max_body_size 0; access_log /var/log/nginx/nocobase.log apm; diff --git a/docker/nocobase/nocobase.conf b/docker/nocobase/nocobase.conf index 7c0c4d2c41..d178440bf9 100644 --- a/docker/nocobase/nocobase.conf +++ b/docker/nocobase/nocobase.conf @@ -17,7 +17,7 @@ server { server_name _; root /app/nocobase/node_modules/@nocobase/app/dist/client; index index.html; - client_max_body_size 1000M; + client_max_body_size 0; access_log /var/log/nginx/nocobase.log apm; gzip on; diff --git a/lerna.json b/lerna.json index da6bf915aa..994c7d1426 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "1.7.0-beta.1", + "version": "1.7.0-alpha.10", "npmClient": "yarn", "useWorkspaces": true, "npmClientArgs": ["--ignore-engines"], diff --git a/package.json b/package.json index 02fefd9951..b530c0c628 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,9 @@ "react": "^18.0.0", "react-dom": "^18.0.0", "nwsapi": "2.2.7", - "antd": "5.12.8", + "antd": "5.24.2", + "@formily/antd-v5": "1.2.3", + "dayjs": "1.11.13", "@ant-design/icons": "^5.6.1" }, "config": { @@ -93,4 +95,4 @@ "yarn": "1.22.19" }, "dependencies": {} -} +} \ No newline at end of file diff --git a/packages/core/acl/package.json b/packages/core/acl/package.json index 7fa0c0541c..cddc749497 100644 --- a/packages/core/acl/package.json +++ b/packages/core/acl/package.json @@ -1,13 +1,13 @@ { "name": "@nocobase/acl", - "version": "1.7.0-beta.1", + "version": "1.7.0-alpha.10", "description": "", "license": "AGPL-3.0", "main": "./lib/index.js", "types": "./lib/index.d.ts", "dependencies": { - "@nocobase/resourcer": "1.7.0-beta.1", - "@nocobase/utils": "1.7.0-beta.1", + "@nocobase/resourcer": "1.7.0-alpha.10", + "@nocobase/utils": "1.7.0-alpha.10", "minimatch": "^5.1.1" }, "repository": { diff --git a/packages/core/acl/src/__tests__/acl-role.test.ts b/packages/core/acl/src/__tests__/acl-role.test.ts new file mode 100644 index 0000000000..88b914361e --- /dev/null +++ b/packages/core/acl/src/__tests__/acl-role.test.ts @@ -0,0 +1,579 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * 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 { ACL } from '..'; +describe('multiple roles merge', () => { + let acl: ACL; + beforeEach(() => { + acl = new ACL(); + }); + describe('filter merge', () => { + test('should allow all(params:{}) when filter1 = undefined, filter2 is not exists', () => { + acl.setAvailableAction('edit', { + type: 'old-data', + }); + acl.define({ + role: 'role1', + actions: { + 'posts:view': { + filter: undefined, + }, + }, + }); + acl.define({ + role: 'role2', + actions: {}, + }); + const canResult = acl.can({ roles: ['role1', 'role2'], resource: 'posts', action: 'view' }); + expect(canResult).toStrictEqual({ + role: 'role1', + resource: 'posts', + action: 'view', + params: {}, + }); + }); + test('should allow all(params:{}) when filter1 = undefined, filter2 = {}', () => { + acl.setAvailableAction('edit', { + type: 'old-data', + }); + acl.define({ + role: 'role1', + actions: { + 'posts:view': { + filter: undefined, + }, + }, + }); + acl.define({ + role: 'role2', + actions: { + 'posts:view': { + filter: {}, + }, + }, + }); + const canResult = acl.can({ roles: ['role1', 'role2'], resource: 'posts', action: 'view' }); + expect(canResult).toStrictEqual({ + role: 'role1', + resource: 'posts', + action: 'view', + params: {}, + }); + }); + test('should allow all(params={}) when filter1 = {}, filter2 = {}', () => { + acl.setAvailableAction('edit', { + type: 'old-data', + }); + acl.define({ + role: 'role1', + actions: { + 'posts:view': { + filter: {}, + }, + }, + }); + acl.define({ + role: 'role2', + actions: { + 'posts:view': { + filter: {}, + }, + }, + }); + const canResult = acl.can({ roles: ['role1', 'role2'], resource: 'posts', action: 'view' }); + expect(canResult).toStrictEqual({ + role: 'role1', + resource: 'posts', + action: 'view', + params: {}, + }); + }); + test('should union filter(params.filter={$or:[{id:1}, {id:2}]}) when filter1 = {id: 1}, filter2 = {id: 2}', () => { + acl.setAvailableAction('edit', { + type: 'old-data', + }); + acl.define({ + role: 'role1', + actions: { + 'posts:view': { + filter: { id: 1 }, + }, + }, + }); + acl.define({ + role: 'role2', + actions: { + 'posts:view': { + filter: { id: 2 }, + }, + }, + }); + const canResult = acl.can({ roles: ['role1', 'role2'], resource: 'posts', action: 'view' }); + expect(canResult).toStrictEqual({ + role: 'role1', + resource: 'posts', + action: 'view', + params: { + filter: { + $or: expect.arrayContaining([{ id: 1 }, { id: 2 }]), + }, + }, + }); + }); + + test('should union filter(filter={$or:[{id:1}, {name: zhangsan}]}) when filter1 = {id: 1}, filter2 = {name: zhangsan}', () => { + acl.setAvailableAction('edit', { + type: 'old-data', + }); + acl.define({ + role: 'role1', + actions: { + 'posts:view': { + filter: { id: 1 }, + }, + }, + }); + acl.define({ + role: 'role2', + actions: { + 'posts:view': { + filter: { name: 'zhangsan' }, + }, + }, + }); + const canResult = acl.can({ roles: ['role1', 'role2'], resource: 'posts', action: 'view' }); + expect(canResult).toStrictEqual({ + role: 'role1', + resource: 'posts', + action: 'view', + params: { + filter: { + $or: expect.arrayContaining([{ id: 1 }, { name: 'zhangsan' }]), + }, + }, + }); + }); + + test('should union filter(filter={$or:[{id:1}, {name: zhangsan}]}) when filter1 = {id: 1}, filter2 = { $or: [{name: zhangsan}]', () => { + acl.setAvailableAction('edit', { + type: 'old-data', + }); + acl.define({ + role: 'role1', + actions: { + 'posts:view': { + filter: { id: 1 }, + }, + }, + }); + acl.define({ + role: 'role2', + actions: { + 'posts:view': { + filter: { name: 'zhangsan' }, + }, + }, + }); + const canResult = acl.can({ roles: ['role1', 'role2'], resource: 'posts', action: 'view' }); + expect(canResult).toStrictEqual({ + role: 'role1', + resource: 'posts', + action: 'view', + params: { + filter: { + $or: expect.arrayContaining([{ id: 1 }, { name: 'zhangsan' }]), + }, + }, + }); + }); + }); + + describe('feilds merge', () => { + test('should allow all(params={}) when fields1 = undefined, fields2 is not exists', () => { + acl.setAvailableAction('edit', { + type: 'old-data', + }); + acl.define({ + role: 'role1', + actions: { + 'posts:view': { + fields: [], + }, + }, + }); + acl.define({ + role: 'role2', + actions: { + 'posts:view': {}, + }, + }); + const canResult = acl.can({ roles: ['role1', 'role2'], resource: 'posts', action: 'view' }); + expect(canResult).toStrictEqual({ + role: 'role1', + resource: 'posts', + action: 'view', + params: {}, + }); + }); + test('should allow all(params={}) when fields1 = undefined, fields2 is not exists', () => { + acl.setAvailableAction('edit', { + type: 'old-data', + }); + acl.define({ + role: 'role1', + actions: { + 'posts:view': { + fields: undefined, + }, + }, + }); + acl.define({ + role: 'role2', + actions: { + 'posts:view': {}, + }, + }); + const canResult = acl.can({ roles: ['role1', 'role2'], resource: 'posts', action: 'view' }); + expect(canResult).toStrictEqual({ + role: 'role1', + resource: 'posts', + action: 'view', + params: {}, + }); + }); + test('should allow all(params={}) when fields1 = [], fields2 =[]', () => { + acl.setAvailableAction('edit', { + type: 'old-data', + }); + acl.define({ + role: 'role1', + actions: { + 'posts:view': { + fields: [], + }, + }, + }); + acl.define({ + role: 'role2', + actions: { + 'posts:view': { + fields: [], + }, + }, + }); + const canResult = acl.can({ roles: ['role1', 'role2'], resource: 'posts', action: 'view' }); + expect(canResult).toStrictEqual({ + role: 'role1', + resource: 'posts', + action: 'view', + params: {}, + }); + }); + test('should union fields(params={ fields: [a,b]}) when fields1 = [a], fields2 =[b]', () => { + acl.setAvailableAction('edit', { + type: 'old-data', + }); + acl.define({ + role: 'role1', + actions: { + 'posts:view': { + fields: ['a'], + }, + }, + }); + acl.define({ + role: 'role2', + actions: { + 'posts:view': { + fields: ['b'], + }, + }, + }); + const canResult = acl.can({ roles: ['role1', 'role2'], resource: 'posts', action: 'view' }); + expect(canResult).toStrictEqual({ + role: 'role1', + resource: 'posts', + action: 'view', + params: { + fields: expect.arrayContaining(['a', 'b']), + }, + }); + }); + test('should union no repeat fields(params={ fields: [a,b,c]}) when fields1 = [a,b], fields2 =[b,c]', () => { + acl.setAvailableAction('edit', { + type: 'old-data', + }); + acl.define({ + role: 'role1', + actions: { + 'posts:view': { + fields: ['a', 'b'], + }, + }, + }); + acl.define({ + role: 'role2', + actions: { + 'posts:view': { + fields: ['b', 'c'], + }, + }, + }); + const canResult = acl.can({ roles: ['role1', 'role2'], resource: 'posts', action: 'view' }); + expect(canResult).toStrictEqual({ + role: 'role1', + resource: 'posts', + action: 'view', + params: { + fields: expect.arrayContaining(['a', 'b', 'c']), + }, + }); + expect(canResult.params.fields.length).toStrictEqual(3); + }); + }); + + describe('whitelist', () => { + test('should union whitelist(params={ fields: [a,b,c]}) when fields1 = [a,b], fields2 =[c]', () => { + acl.setAvailableAction('update'); + acl.define({ + role: 'role1', + actions: { + 'posts:update': { + whitelist: ['a', 'b'], + }, + }, + }); + acl.define({ + role: 'role2', + actions: { + 'posts:update': { + whitelist: ['c'], + }, + }, + }); + const canResult = acl.can({ resource: 'posts', action: 'update', roles: ['role1', 'role2'] }); + expect(canResult).toStrictEqual({ + role: 'role1', + resource: 'posts', + action: 'update', + params: { + whitelist: expect.arrayContaining(['a', 'b', 'c']), + }, + }); + }); + }); + + describe('appends', () => { + test('should union appends(params={ appends: [a,b,c]}) when appends = [a,b], appends =[c]', () => { + acl.setAvailableAction('update'); + acl.define({ + role: 'role1', + actions: { + 'posts:update': { + appends: ['a', 'b'], + }, + }, + }); + acl.define({ + role: 'role2', + actions: { + 'posts:update': { + appends: ['c'], + }, + }, + }); + const canResult = acl.can({ resource: 'posts', action: 'update', roles: ['role1', 'role2'] }); + expect(canResult).toStrictEqual({ + role: 'role1', + resource: 'posts', + action: 'update', + params: { + appends: expect.arrayContaining(['a', 'b', 'c']), + }, + }); + }); + test('should union appends(params={ appends: [a,b]}) when appends = [a,b], appends =[]', () => { + acl.setAvailableAction('update'); + acl.define({ + role: 'role1', + actions: { + 'posts:update': { + appends: ['a', 'b'], + }, + }, + }); + acl.define({ + role: 'role2', + actions: { + 'posts:update': { + appends: [], + }, + }, + }); + const canResult = acl.can({ resource: 'posts', action: 'update', roles: ['role1', 'role2'] }); + expect(canResult).toStrictEqual({ + role: 'role1', + resource: 'posts', + action: 'update', + params: { + appends: expect.arrayContaining(['a', 'b']), + }, + }); + }); + }); + + describe('filter & fields merge', () => { + test('should allow all(params={}) when actions1 = {filter: {}}, actions2 = {fields: []}', () => { + acl.setAvailableAction('view', { + type: 'old-data', + }); + acl.define({ + role: 'role1', + actions: { + 'posts:view': { + filter: {}, + }, + }, + }); + acl.define({ + role: 'role2', + actions: { + 'posts:view': { + fields: [], + }, + }, + }); + const canResult = acl.can({ roles: ['role1', 'role2'], resource: 'posts', action: 'view' }); + expect(canResult).toStrictEqual({ + role: 'role1', + resource: 'posts', + action: 'view', + params: {}, + }); + }); + test('should allow all(params={}) when actions1 = {filter: {}}, actions2 = {fields: [a]}', () => { + acl.setAvailableAction('view', { + type: 'old-data', + }); + acl.define({ + role: 'role1', + actions: { + 'posts:view': { + filter: {}, + }, + }, + }); + acl.define({ + role: 'role2', + actions: { + 'posts:view': { + fields: ['a'], + }, + }, + }); + const canResult = acl.can({ roles: ['role1', 'role2'], resource: 'posts', action: 'view' }); + expect(canResult).toStrictEqual({ + role: 'role1', + resource: 'posts', + action: 'view', + params: {}, + }); + }); + test('should allow all(params={}) when actions1 = {filter: {a:1}}, actions2 = {fields: []}', () => { + acl.setAvailableAction('view', { + type: 'old-data', + }); + acl.define({ + role: 'role1', + actions: { + 'posts:view': { + filter: { a: 1 }, + }, + }, + }); + acl.define({ + role: 'role2', + actions: { + 'posts:view': { + fields: [], + }, + }, + }); + const canResult = acl.can({ roles: ['role1', 'role2'], resource: 'posts', action: 'view' }); + expect(canResult).toStrictEqual({ + role: 'role1', + resource: 'posts', + action: 'view', + params: {}, + }); + }); + + test('should allow all(params={}) when actions1 = {filter: {a:1}}, actions2 = {fields: [a]}', () => { + acl.setAvailableAction('view', { + type: 'old-data', + }); + acl.define({ + role: 'role1', + actions: { + 'posts:view': { + filter: { a: 1 }, + }, + }, + }); + acl.define({ + role: 'role2', + actions: { + 'posts:view': { + fields: ['a'], + }, + }, + }); + const canResult = acl.can({ roles: ['role1', 'role2'], resource: 'posts', action: 'view' }); + expect(canResult).toStrictEqual({ + role: 'role1', + resource: 'posts', + action: 'view', + params: {}, + }); + }); + + test('should union filter&fields(params={ filter:{ $or:[{a:1},{a:2}]}, fields:[a,b]}) when actions1={filter:{a:1}, fields:[a]}, actions2={filter: {a:1}},fields:[b]}', () => { + acl.setAvailableAction('view', { + type: 'old-data', + }); + acl.define({ + role: 'role1', + actions: { + 'posts:view': { + filter: { a: 1 }, + fields: ['a'], + }, + }, + }); + acl.define({ + role: 'role2', + actions: { + 'posts:view': { + filter: { a: 2 }, + fields: ['b'], + }, + }, + }); + const canResult = acl.can({ roles: ['role1', 'role2'], resource: 'posts', action: 'view' }); + expect(canResult).toStrictEqual({ + role: 'role1', + resource: 'posts', + action: 'view', + params: expect.objectContaining({ + filter: { $or: expect.arrayContaining([{ a: 1 }, { a: 2 }]) }, + fields: expect.arrayContaining(['a', 'b']), + }), + }); + }); + }); +}); diff --git a/packages/core/acl/src/acl-role.ts b/packages/core/acl/src/acl-role.ts index e2f4b53a03..a430a2033a 100644 --- a/packages/core/acl/src/acl-role.ts +++ b/packages/core/acl/src/acl-role.ts @@ -7,11 +7,11 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ +import { default as _, default as lodash } from 'lodash'; +import minimatch from 'minimatch'; import { ACL, DefineOptions } from './acl'; import { ACLAvailableStrategy, AvailableStrategyOptions } from './acl-available-strategy'; import { ACLResource } from './acl-resource'; -import lodash from 'lodash'; -import minimatch from 'minimatch'; export interface RoleActionParams { fields?: string[]; @@ -185,12 +185,12 @@ export class ACLRole { } } - return { + return _.cloneDeep({ role: this.name, strategy: this.strategy, actions, snippets: Array.from(this.snippets), - }; + }); } protected getResourceActionFromPath(path: string) { diff --git a/packages/core/acl/src/acl.ts b/packages/core/acl/src/acl.ts index 00e86e5ceb..1c2ef37895 100644 --- a/packages/core/acl/src/acl.ts +++ b/packages/core/acl/src/acl.ts @@ -19,6 +19,7 @@ import { AllowManager, ConditionFunc } from './allow-manager'; import FixedParamsManager, { Merger } from './fixed-params-manager'; import SnippetManager, { SnippetOptions } from './snippet-manager'; import { NoPermissionError } from './errors/no-permission-error'; +import { mergeAclActionParams, removeEmptyParams } from './utils'; interface CanResult { role: string; @@ -54,11 +55,12 @@ export interface ListenerContext { type Listener = (ctx: ListenerContext) => void; interface CanArgs { - role: string; + role?: string; resource: string; action: string; rawResourceName?: string; ctx?: any; + roles?: string[]; } export class ACL extends EventEmitter { @@ -169,6 +171,10 @@ export class ACL extends EventEmitter { return this.roles.get(name); } + getRoles(names: string[]): ACLRole[] { + return names.map((name) => this.getRole(name)).filter((x) => Boolean(x)); + } + removeRole(name: string) { return this.roles.delete(name); } @@ -202,6 +208,36 @@ export class ACL extends EventEmitter { } can(options: CanArgs): CanResult | null { + if (options.role) { + return lodash.cloneDeep(this.getCanByRole(options)); + } + if (options.roles?.length) { + return lodash.cloneDeep(this.getCanByRoles(options)); + } + + return null; + } + + private getCanByRoles(options: CanArgs) { + let canResult: CanResult | null = null; + + for (const role of options.roles) { + const result = this.getCanByRole({ + role, + ...options, + }); + if (!canResult) { + canResult = result; + canResult && removeEmptyParams(canResult.params); + } else if (canResult && result) { + canResult.params = mergeAclActionParams(canResult.params, result.params); + } + } + + return canResult; + } + + private getCanByRole(options: CanArgs) { const { role, resource, action, rawResourceName } = options; const aclRole = this.roles.get(role); @@ -351,9 +387,12 @@ export class ACL extends EventEmitter { } ctx.can = (options: Omit) => { - const canResult = acl.can({ role: roleName, ...options }); - - return canResult; + const roles = ctx.state.currentRoles || [roleName]; + const can = acl.can({ roles, ...options }); + if (!can) { + return null; + } + return can; }; ctx.permission = { @@ -370,7 +409,7 @@ export class ACL extends EventEmitter { * @internal */ async getActionParams(ctx) { - const roleName = ctx.state.currentRole || 'anonymous'; + const roleNames = ctx.state.currentRoles?.length ? ctx.state.currentRoles : 'anonymous'; const { resourceName: rawResourceName, actionName } = ctx.action; let resourceName = rawResourceName; @@ -386,11 +425,11 @@ export class ACL extends EventEmitter { } ctx.can = (options: Omit) => { - const can = this.can({ role: roleName, ...options }); - if (!can) { - return null; + const can = this.can({ roles: roleNames, ...options }); + if (can) { + return lodash.cloneDeep(can); } - return lodash.cloneDeep(can); + return null; }; ctx.permission = { @@ -421,6 +460,23 @@ export class ACL extends EventEmitter { } } + // 检查 $or 条件中的 createdById + if (params?.filter?.$or?.length) { + const checkCreatedById = (items) => { + return items.some( + (x) => + 'createdById' in x || x.$or?.some((y) => 'createdById' in y) || x.$and?.some((y) => 'createdById' in y), + ); + }; + + if (checkCreatedById(params.filter.$or)) { + const collection = ctx.db.getCollection(resourceName); + if (!collection || !collection.getField('createdById')) { + throw new NoPermissionError('createdById field not found'); + } + } + } + return params; } diff --git a/packages/core/acl/src/index.ts b/packages/core/acl/src/index.ts index 269a8f6678..7def1f2dca 100644 --- a/packages/core/acl/src/index.ts +++ b/packages/core/acl/src/index.ts @@ -14,3 +14,4 @@ export * from './acl-resource'; export * from './acl-role'; export * from './skip-middleware'; export * from './errors'; +export * from './utils'; diff --git a/packages/core/acl/src/utils/acl-role.ts b/packages/core/acl/src/utils/acl-role.ts new file mode 100644 index 0000000000..406ce36dec --- /dev/null +++ b/packages/core/acl/src/utils/acl-role.ts @@ -0,0 +1,213 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * 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 { assign } from '@nocobase/utils'; +import _ from 'lodash'; +import { ACLRole } from '../acl-role'; + +export function mergeRole(roles: ACLRole[]) { + const result: Record = { + roles: [], + strategy: {}, + actions: null, + snippets: [], + resources: null, + }; + const allSnippets: string[][] = []; + for (const role of roles) { + const jsonRole = role.toJSON(); + result.roles = mergeRoleNames(result.roles, jsonRole.role); + result.strategy = mergeRoleStrategy(result.strategy, jsonRole.strategy); + result.actions = mergeRoleActions(result.actions, jsonRole.actions); + result.resources = mergeRoleResources(result.resources, [...role.resources.keys()]); + if (_.isArray(jsonRole.snippets)) { + allSnippets.push(jsonRole.snippets); + } + } + result.snippets = mergeRoleSnippets(allSnippets); + return result; +} + +function mergeRoleNames(sourceRoleNames, newRoleName) { + return newRoleName ? sourceRoleNames.concat(newRoleName) : sourceRoleNames; +} + +function mergeRoleStrategy(sourceStrategy, newStrategy) { + if (!newStrategy) { + return sourceStrategy; + } + if (_.isArray(newStrategy.actions)) { + if (!sourceStrategy.actions) { + sourceStrategy.actions = newStrategy.actions; + } else { + const actions = sourceStrategy.actions.concat(newStrategy.actions); + return { + ...sourceStrategy, + actions: [...new Set(actions)], + }; + } + } + return sourceStrategy; +} + +function mergeRoleActions(sourceActions, newActions) { + if (_.isEmpty(sourceActions)) return newActions; + if (_.isEmpty(newActions)) return sourceActions; + + const result = {}; + [...new Set(Reflect.ownKeys(sourceActions).concat(Reflect.ownKeys(newActions)))].forEach((key) => { + if (_.has(sourceActions, key) && _.has(newActions, key)) { + result[key] = mergeAclActionParams(sourceActions[key], newActions[key]); + return; + } + result[key] = _.has(sourceActions, key) ? sourceActions[key] : newActions[key]; + }); + + return result; +} + +function mergeRoleSnippets(allRoleSnippets: string[][]): string[] { + if (!allRoleSnippets.length) { + return []; + } + + const allSnippets = allRoleSnippets.flat(); + const isExclusion = (value) => value.startsWith('!'); + const includes = new Set(allSnippets.filter((x) => !isExclusion(x))); + const excludes = new Set(allSnippets.filter(isExclusion)); + + // 统计 xxx.* 在多少个角色中存在 + const domainRoleMap = new Map>(); + allRoleSnippets.forEach((roleSnippets, i) => { + roleSnippets + .filter((x) => x.endsWith('.*') && !isExclusion(x)) + .forEach((include) => { + const domain = include.slice(0, -1); + if (!domainRoleMap.has(domain)) { + domainRoleMap.set(domain, new Set()); + } + domainRoleMap.get(domain).add(i); + }); + }); + + // 处理黑名单交集(只有所有角色都有 `!xxx` 才保留) + const excludesSet = new Set(); + for (const snippet of excludes) { + if (allRoleSnippets.every((x) => x.includes(snippet))) { + excludesSet.add(snippet); + } + } + + for (const [domain, indexes] of domainRoleMap.entries()) { + const fullDomain = `${domain}.*`; + + // xxx.* 存在时,覆盖 !xxx.* + if (includes.has(fullDomain)) { + excludesSet.delete(`!${fullDomain}`); + } + + // 计算 !xxx.yyy,当所有 xxx.* 角色都包含 !xxx.yyy 时才保留 + for (const roleIndex of indexes) { + for (const exclude of allRoleSnippets[roleIndex]) { + if (exclude.startsWith(`!${domain}`) && exclude !== `!${fullDomain}`) { + if ([...indexes].every((i) => allRoleSnippets[i].includes(exclude))) { + excludesSet.add(exclude); + } + } + } + } + } + + // 确保 !xxx.yyy 只有在 xxx.* 存在时才有效,同时解决 [xxx] 和 [!xxx] 冲突 + if (includes.size > 0) { + for (const x of [...excludesSet]) { + const exactMatch = x.slice(1); + const segments = exactMatch.split('.'); + if (segments.length > 1 && segments[1] !== '*') { + const parentDomain = segments[0] + '.*'; + if (!includes.has(parentDomain)) { + excludesSet.delete(x); + } + } + } + } + + return [...includes, ...excludesSet]; +} + +function mergeRoleResources(sourceResources, newResources) { + if (sourceResources === null) { + return newResources; + } + + return [...new Set(sourceResources.concat(newResources))]; +} + +export function mergeAclActionParams(sourceParams, targetParams) { + if (_.isEmpty(sourceParams) || _.isEmpty(targetParams)) { + return {}; + } + + // source 和 target 其中之一没有 fields 字段时, 最终希望没有此字段 + removeUnmatchedParams(sourceParams, targetParams, ['fields', 'whitelist', 'appends']); + + const andMerge = (x, y) => { + if (_.isEmpty(x) || _.isEmpty(y)) { + return []; + } + return _.uniq(x.concat(y)).filter(Boolean); + }; + + const mergedParams = assign(targetParams, sourceParams, { + own: (x, y) => x || y, + filter: (x, y) => { + if (_.isEmpty(x) || _.isEmpty(y)) { + return {}; + } + const xHasOr = _.has(x, '$or'), + yHasOr = _.has(y, '$or'); + let $or = [x, y]; + if (xHasOr && !yHasOr) { + $or = [...x.$or, y]; + } else if (!xHasOr && yHasOr) { + $or = [x, ...y.$or]; + } else if (xHasOr && yHasOr) { + $or = [...x.$or, ...y.$or]; + } + + return { $or: _.uniqWith($or, _.isEqual) }; + }, + fields: andMerge, + whitelist: andMerge, + appends: 'union', + }); + removeEmptyParams(mergedParams); + return mergedParams; +} + +export function removeEmptyParams(params) { + if (!_.isObject(params)) { + return; + } + Object.keys(params).forEach((key) => { + if (_.isEmpty(params[key])) { + delete params[key]; + } + }); +} + +function removeUnmatchedParams(source, target, keys: string[]) { + for (const key of keys) { + if (_.has(source, key) && !_.has(target, key)) { + delete source[key]; + } + if (!_.has(source, key) && _.has(target, key)) { + delete target[key]; + } + } +} diff --git a/packages/core/acl/src/utils/index.ts b/packages/core/acl/src/utils/index.ts new file mode 100644 index 0000000000..48071b231d --- /dev/null +++ b/packages/core/acl/src/utils/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 * from './acl-role'; diff --git a/packages/core/actions/package.json b/packages/core/actions/package.json index 8167ec8769..441dc3e596 100644 --- a/packages/core/actions/package.json +++ b/packages/core/actions/package.json @@ -1,14 +1,14 @@ { "name": "@nocobase/actions", - "version": "1.7.0-beta.1", + "version": "1.7.0-alpha.10", "description": "", "license": "AGPL-3.0", "main": "./lib/index.js", "types": "./lib/index.d.ts", "dependencies": { - "@nocobase/cache": "1.7.0-beta.1", - "@nocobase/database": "1.7.0-beta.1", - "@nocobase/resourcer": "1.7.0-beta.1" + "@nocobase/cache": "1.7.0-alpha.10", + "@nocobase/database": "1.7.0-alpha.10", + "@nocobase/resourcer": "1.7.0-alpha.10" }, "repository": { "type": "git", diff --git a/packages/core/app/package.json b/packages/core/app/package.json index 856aaf634b..34ab32b74f 100644 --- a/packages/core/app/package.json +++ b/packages/core/app/package.json @@ -1,17 +1,17 @@ { "name": "@nocobase/app", - "version": "1.7.0-beta.1", + "version": "1.7.0-alpha.10", "description": "", "license": "AGPL-3.0", "main": "./lib/index.js", "types": "./lib/index.d.ts", "dependencies": { - "@nocobase/database": "1.7.0-beta.1", - "@nocobase/preset-nocobase": "1.7.0-beta.1", - "@nocobase/server": "1.7.0-beta.1" + "@nocobase/database": "1.7.0-alpha.10", + "@nocobase/preset-nocobase": "1.7.0-alpha.10", + "@nocobase/server": "1.7.0-alpha.10" }, "devDependencies": { - "@nocobase/client": "1.7.0-beta.1" + "@nocobase/client": "1.7.0-alpha.10" }, "repository": { "type": "git", diff --git a/packages/core/app/src/__tests__/commands.test.ts b/packages/core/app/src/__tests__/commands.test.ts index f300d4cbda..946df74d9c 100644 --- a/packages/core/app/src/__tests__/commands.test.ts +++ b/packages/core/app/src/__tests__/commands.test.ts @@ -7,7 +7,7 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -import { mockDatabase } from '@nocobase/database'; +import { createMockDatabase, mockDatabase } from '@nocobase/database'; import { uid } from '@nocobase/utils'; import axios from 'axios'; import execa from 'execa'; @@ -64,7 +64,7 @@ const createDatabase = async () => { if (process.env.DB_DIALECT === 'sqlite') { return 'nocobase'; } - const db = mockDatabase(); + const db = await createMockDatabase(); const name = `d_${uid()}`; await db.sequelize.query(`CREATE DATABASE ${name}`); await db.close(); diff --git a/packages/core/auth/package.json b/packages/core/auth/package.json index 3555efe3d7..8730279583 100644 --- a/packages/core/auth/package.json +++ b/packages/core/auth/package.json @@ -1,16 +1,16 @@ { "name": "@nocobase/auth", - "version": "1.7.0-beta.1", + "version": "1.7.0-alpha.10", "description": "", "license": "AGPL-3.0", "main": "./lib/index.js", "types": "./lib/index.d.ts", "dependencies": { - "@nocobase/actions": "1.7.0-beta.1", - "@nocobase/cache": "1.7.0-beta.1", - "@nocobase/database": "1.7.0-beta.1", - "@nocobase/resourcer": "1.7.0-beta.1", - "@nocobase/utils": "1.7.0-beta.1", + "@nocobase/actions": "1.7.0-alpha.10", + "@nocobase/cache": "1.7.0-alpha.10", + "@nocobase/database": "1.7.0-alpha.10", + "@nocobase/resourcer": "1.7.0-alpha.10", + "@nocobase/utils": "1.7.0-alpha.10", "@types/jsonwebtoken": "^8.5.8", "jsonwebtoken": "^8.5.1" }, diff --git a/packages/core/auth/src/__tests__/middleware.test.ts b/packages/core/auth/src/__tests__/middleware.test.ts index e4640e96d2..440b14c6c0 100644 --- a/packages/core/auth/src/__tests__/middleware.test.ts +++ b/packages/core/auth/src/__tests__/middleware.test.ts @@ -22,7 +22,7 @@ describe('middleware', () => { app = await createMockServer({ registerActions: true, acl: true, - plugins: ['users', 'auth', 'acl', 'field-sort', 'data-source-manager', 'error-handler'], + plugins: ['users', 'auth', 'acl', 'field-sort', 'data-source-manager', 'error-handler', 'system-settings'], }); // app.plugin(ApiKeysPlugin); diff --git a/packages/core/auth/src/base/auth.ts b/packages/core/auth/src/base/auth.ts index 566a8e52df..e9a32af8f9 100644 --- a/packages/core/auth/src/base/auth.ts +++ b/packages/core/auth/src/base/auth.ts @@ -267,6 +267,24 @@ export class BaseAuth extends Auth { return null; } + async signNewToken(userId: number) { + const tokenInfo = await this.tokenController.add({ userId }); + const expiresIn = Math.floor((await this.tokenController.getConfig()).tokenExpirationTime / 1000); + const token = this.jwt.sign( + { + userId, + temp: true, + iat: Math.floor(tokenInfo.issuedTime / 1000), + signInTime: tokenInfo.signInTime, + }, + { + jwtid: tokenInfo.jti, + expiresIn, + }, + ); + return token; + } + async signIn() { let user: Model; try { @@ -282,20 +300,7 @@ export class BaseAuth extends Auth { code: AuthErrorCode.NOT_EXIST_USER, }); } - const tokenInfo = await this.tokenController.add({ userId: user.id }); - const expiresIn = Math.floor((await this.tokenController.getConfig()).tokenExpirationTime / 1000); - const token = this.jwt.sign( - { - userId: user.id, - temp: true, - iat: Math.floor(tokenInfo.issuedTime / 1000), - signInTime: tokenInfo.signInTime, - }, - { - jwtid: tokenInfo.jti, - expiresIn, - }, - ); + const token = await this.signNewToken(user.id); return { user, token, diff --git a/packages/core/build/package.json b/packages/core/build/package.json index df07c50109..1bec2a720d 100644 --- a/packages/core/build/package.json +++ b/packages/core/build/package.json @@ -1,6 +1,6 @@ { "name": "@nocobase/build", - "version": "1.7.0-beta.1", + "version": "1.7.0-alpha.10", "description": "Library build tool based on rollup.", "main": "lib/index.js", "types": "./lib/index.d.ts", diff --git a/packages/core/cache/package.json b/packages/core/cache/package.json index 13c86fa2db..e9d9b21e92 100644 --- a/packages/core/cache/package.json +++ b/packages/core/cache/package.json @@ -1,12 +1,12 @@ { "name": "@nocobase/cache", - "version": "1.7.0-beta.1", + "version": "1.7.0-alpha.10", "description": "", "license": "AGPL-3.0", "main": "./lib/index.js", "types": "./lib/index.d.ts", "dependencies": { - "@nocobase/lock-manager": "1.6.0-alpha.6", + "@nocobase/lock-manager": "1.7.0-alpha.10", "bloom-filters": "^3.0.1", "cache-manager": "^5.2.4", "cache-manager-redis-yet": "^4.1.2" diff --git a/packages/core/cli/nocobase.conf.tpl b/packages/core/cli/nocobase.conf.tpl index a40343cc02..141ca74282 100644 --- a/packages/core/cli/nocobase.conf.tpl +++ b/packages/core/cli/nocobase.conf.tpl @@ -17,7 +17,7 @@ server { server_name _; root {{cwd}}/node_modules/@nocobase/app/dist/client; index index.html; - client_max_body_size 1000M; + client_max_body_size 0; access_log /var/log/nginx/nocobase.log apm; gzip on; diff --git a/packages/core/cli/package.json b/packages/core/cli/package.json index 2fcf70074a..54f51b79f6 100644 --- a/packages/core/cli/package.json +++ b/packages/core/cli/package.json @@ -1,6 +1,6 @@ { "name": "@nocobase/cli", - "version": "1.7.0-beta.1", + "version": "1.7.0-alpha.10", "description": "", "license": "AGPL-3.0", "main": "./src/index.js", @@ -8,7 +8,7 @@ "nocobase": "./bin/index.js" }, "dependencies": { - "@nocobase/app": "1.7.0-beta.1", + "@nocobase/app": "1.7.0-alpha.10", "@nocobase/license-kit": "^0.2.3", "@types/fs-extra": "^11.0.1", "@umijs/utils": "3.5.20", @@ -26,7 +26,7 @@ "tsx": "^4.19.0" }, "devDependencies": { - "@nocobase/devtools": "1.7.0-beta.1" + "@nocobase/devtools": "1.7.0-alpha.10" }, "repository": { "type": "git", diff --git a/packages/core/cli/src/commands/index.js b/packages/core/cli/src/commands/index.js index dc0e0fe951..1d50eba26b 100644 --- a/packages/core/cli/src/commands/index.js +++ b/packages/core/cli/src/commands/index.js @@ -18,6 +18,7 @@ module.exports = (cli) => { generateAppDir(); require('./global')(cli); require('./create-nginx-conf')(cli); + require('./locale')(cli); require('./build')(cli); require('./tar')(cli); require('./dev')(cli); diff --git a/packages/core/cli/src/commands/locale.js b/packages/core/cli/src/commands/locale.js new file mode 100644 index 0000000000..6a947d7167 --- /dev/null +++ b/packages/core/cli/src/commands/locale.js @@ -0,0 +1,81 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +const { Command } = require('commander'); +const fg = require('fast-glob'); +const fs = require('fs-extra'); +const path = require('path'); +const _ = require('lodash'); +const deepmerge = require('deepmerge'); +const { getCronstrueLocale } = require('./locale/cronstrue'); +const { getReactJsCron } = require('./locale/react-js-cron'); + +function sortJSON(json) { + if (Array.isArray(json)) { + return json.map(sortJSON); + } else if (typeof json === 'object' && json !== null) { + const sortedKeys = Object.keys(json).sort(); + const sortedObject = {}; + sortedKeys.forEach((key) => { + sortedObject[key] = sortJSON(json[key]); + }); + return sortedObject; + } + return json; +} + +/** + * + * @param {Command} cli + */ +module.exports = (cli) => { + const locale = cli.command('locale'); + locale.command('generate').action(async (options) => { + const cwd = path.resolve(process.cwd(), 'node_modules', '@nocobase'); + const files = await fg('./*/src/locale/*.json', { + cwd, + }); + let locales = {}; + await fs.mkdirp(path.resolve(process.cwd(), 'storage/locales')); + for (const file of files) { + const locale = path.basename(file, '.json'); + const pkg = path.basename(path.dirname(path.dirname(path.dirname(file)))); + _.set(locales, [locale.replace(/_/g, '-'), `@nocobase/${pkg}`], await fs.readJSON(path.resolve(cwd, file))); + if (locale.includes('_')) { + await fs.rename( + path.resolve(cwd, file), + path.resolve(cwd, path.dirname(file), `${locale.replace(/_/g, '-')}.json`), + ); + } + } + const zhCN = locales['zh-CN']; + const enUS = locales['en-US']; + for (const key1 in zhCN) { + for (const key2 in zhCN[key1]) { + if (!_.get(enUS, [key1, key2])) { + _.set(enUS, [key1, key2], key2); + } + } + } + for (const locale of Object.keys(locales)) { + locales[locale] = deepmerge(enUS, locales[locale]); + locales[locale]['cronstrue'] = getCronstrueLocale(locale); + locales[locale]['react-js-cron'] = getReactJsCron(locale); + } + locales = sortJSON(locales); + for (const locale of Object.keys(locales)) { + await fs.writeFile( + path.resolve(process.cwd(), 'storage/locales', `${locale}.json`), + JSON.stringify(sortJSON(locales[locale]), null, 2), + ); + } + }); + + locale.command('sync').action(async (options) => {}); +}; diff --git a/packages/core/cli/src/commands/locale/cronstrue.js b/packages/core/cli/src/commands/locale/cronstrue.js new file mode 100644 index 0000000000..b59d41c8ea --- /dev/null +++ b/packages/core/cli/src/commands/locale/cronstrue.js @@ -0,0 +1,122 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +const methods = [ + 'atX0SecondsPastTheMinuteGt20', + 'atX0MinutesPastTheHourGt20', + 'commaMonthX0ThroughMonthX1', + 'commaYearX0ThroughYearX1', + 'use24HourTimeFormatByDefault', + 'anErrorOccuredWhenGeneratingTheExpressionD', + 'everyMinute', + 'everyHour', + 'atSpace', + 'everyMinuteBetweenX0AndX1', + 'at', + 'spaceAnd', + 'everySecond', + 'everyX0Seconds', + 'secondsX0ThroughX1PastTheMinute', + 'atX0SecondsPastTheMinute', + 'everyX0Minutes', + 'minutesX0ThroughX1PastTheHour', + 'atX0MinutesPastTheHour', + 'everyX0Hours', + 'betweenX0AndX1', + 'atX0', + 'commaEveryDay', + 'commaEveryX0DaysOfTheWeek', + 'commaX0ThroughX1', + 'commaAndX0ThroughX1', + 'first', + 'second', + 'third', + 'fourth', + 'fifth', + 'commaOnThe', + 'spaceX0OfTheMonth', + 'lastDay', + 'commaOnTheLastX0OfTheMonth', + 'commaOnlyOnX0', + 'commaAndOnX0', + 'commaEveryX0Months', + 'commaOnlyInX0', + 'commaOnTheLastDayOfTheMonth', + 'commaOnTheLastWeekdayOfTheMonth', + 'commaDaysBeforeTheLastDayOfTheMonth', + 'firstWeekday', + 'weekdayNearestDayX0', + 'commaOnTheX0OfTheMonth', + 'commaEveryX0Days', + 'commaBetweenDayX0AndX1OfTheMonth', + 'commaOnDayX0OfTheMonth', + 'commaEveryHour', + 'commaEveryX0Years', + 'commaStartingX0', + 'daysOfTheWeek', + 'monthsOfTheYear', +]; + +const langs = { + af: 'af', + ar: 'ar', + be: 'be', + ca: 'ca', + cs: 'cs', + da: 'da', + de: 'de', + 'en-US': 'en', + es: 'es', + fa: 'fa', + fi: 'fi', + fr: 'fr', + he: 'he', + hu: 'hu', + id: 'id', + it: 'it', + 'ja-JP': 'ja', + ko: 'ko', + nb: 'nb', + nl: 'nl', + pl: 'pl', + pt_BR: 'pt_BR', + pt_PT: 'pt_PT', + ro: 'ro', + 'ru-RU': 'ru', + sk: 'sk', + sl: 'sl', + sv: 'sv', + sw: 'sw', + 'th-TH': 'th', + 'tr-TR': 'tr', + uk: 'uk', + 'zh-CN': 'zh_CN', + 'zh-TW': 'zh_TW', +}; + +exports.getCronstrueLocale = (lang) => { + const lng = langs[lang] || 'en'; + const Locale = require(`cronstrue/locales/${lng}`); + let locale; + if (Locale?.default) { + locale = Locale.default.locales[lng]; + } else { + const L = Locale[lng]; + locale = new L(); + } + const items = {}; + for (const method of methods) { + try { + items[method] = locale[method](); + } catch (error) { + // empty + } + } + return items; +}; diff --git a/packages/core/cli/src/commands/locale/react-js-cron/en-US.json b/packages/core/cli/src/commands/locale/react-js-cron/en-US.json new file mode 100644 index 0000000000..c518d108a4 --- /dev/null +++ b/packages/core/cli/src/commands/locale/react-js-cron/en-US.json @@ -0,0 +1,75 @@ +{ + "everyText": "every", + "emptyMonths": "every month", + "emptyMonthDays": "every day of the month", + "emptyMonthDaysShort": "day of the month", + "emptyWeekDays": "every day of the week", + "emptyWeekDaysShort": "day of the week", + "emptyHours": "every hour", + "emptyMinutes": "every minute", + "emptyMinutesForHourPeriod": "every", + "yearOption": "year", + "monthOption": "month", + "weekOption": "week", + "dayOption": "day", + "hourOption": "hour", + "minuteOption": "minute", + "rebootOption": "reboot", + "prefixPeriod": "Every", + "prefixMonths": "in", + "prefixMonthDays": "on", + "prefixWeekDays": "on", + "prefixWeekDaysForMonthAndYearPeriod": "and", + "prefixHours": "at", + "prefixMinutes": ":", + "prefixMinutesForHourPeriod": "at", + "suffixMinutesForHourPeriod": "minute(s)", + "errorInvalidCron": "Invalid cron expression", + "clearButtonText": "Clear", + "weekDays": [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday" + ], + "months": [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December" + ], + "altWeekDays": [ + "SUN", + "MON", + "TUE", + "WED", + "THU", + "FRI", + "SAT" + ], + "altMonths": [ + "JAN", + "FEB", + "MAR", + "APR", + "MAY", + "JUN", + "JUL", + "AUG", + "SEP", + "OCT", + "NOV", + "DEC" + ] +} \ No newline at end of file diff --git a/packages/core/cli/src/commands/locale/react-js-cron/index.js b/packages/core/cli/src/commands/locale/react-js-cron/index.js new file mode 100644 index 0000000000..c006afe6a2 --- /dev/null +++ b/packages/core/cli/src/commands/locale/react-js-cron/index.js @@ -0,0 +1,17 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +exports.getReactJsCron = (lang) => { + const langs = { + 'en-US': require('./en-US.json'), + 'zh-CN': require('./zh-CN.json'), + 'z-TW': require('./zh-TW.json'), + } + return langs[lang] || langs['en-US']; +} diff --git a/packages/core/cli/src/commands/locale/react-js-cron/zh-CN.json b/packages/core/cli/src/commands/locale/react-js-cron/zh-CN.json new file mode 100644 index 0000000000..3deea0f35c --- /dev/null +++ b/packages/core/cli/src/commands/locale/react-js-cron/zh-CN.json @@ -0,0 +1,33 @@ +{ + "everyText": "每", + "emptyMonths": "每月", + "emptyMonthDays": "每日(月)", + "emptyMonthDaysShort": "每日", + "emptyWeekDays": "每天(周)", + "emptyWeekDaysShort": "每天(周)", + "emptyHours": "每小时", + "emptyMinutes": "每分钟", + "emptyMinutesForHourPeriod": "每", + "yearOption": "年", + "monthOption": "月", + "weekOption": "周", + "dayOption": "天", + "hourOption": "小时", + "minuteOption": "分钟", + "rebootOption": "重启", + "prefixPeriod": "每", + "prefixMonths": "的", + "prefixMonthDays": "的", + "prefixWeekDays": "的", + "prefixWeekDaysForMonthAndYearPeriod": "或者", + "prefixHours": "的", + "prefixMinutes": ":", + "prefixMinutesForHourPeriod": "的", + "suffixMinutesForHourPeriod": "分钟", + "errorInvalidCron": "不符合 cron 规则的表达式", + "clearButtonText": "清空", + "weekDays": ["周日", "周一", "周二", "周三", "周四", "周五", "周六"], + "months": ["一月", "二月", "三月", "四月", "五月", "六月", "七月", "八月", "九月", "十月", "十一月", "十二月"], + "altWeekDays": ["周日", "周一", "周二", "周三", "周四", "周五", "周六"], + "altMonths": ["一月", "二月", "三月", "四月", "五月", "六月", "七月", "八月", "九月", "十月", "十一月", "十二月"] +} diff --git a/packages/core/cli/src/commands/locale/react-js-cron/zh-TW.json b/packages/core/cli/src/commands/locale/react-js-cron/zh-TW.json new file mode 100644 index 0000000000..ddc825b472 --- /dev/null +++ b/packages/core/cli/src/commands/locale/react-js-cron/zh-TW.json @@ -0,0 +1,33 @@ +{ + "everyText": "每", + "emptyMonths": "每月", + "emptyMonthDays": "每日(月)", + "emptyMonthDaysShort": "每日", + "emptyWeekDays": "每天(週)", + "emptyWeekDaysShort": "每天(週)", + "emptyHours": "每小時", + "emptyMinutes": "每分鐘", + "emptyMinutesForHourPeriod": "每", + "yearOption": "年", + "monthOption": "月", + "weekOption": "週", + "dayOption": "天", + "hourOption": "小時", + "minuteOption": "分鐘", + "rebootOption": "重啟", + "prefixPeriod": "每", + "prefixMonths": "的", + "prefixMonthDays": "的", + "prefixWeekDays": "的", + "prefixWeekDaysForMonthAndYearPeriod": "或者", + "prefixHours": "的", + "prefixMinutes": ":", + "prefixMinutesForHourPeriod": "的", + "suffixMinutesForHourPeriod": "分鐘", + "errorInvalidCron": "不符合 cron 規則的表示式", + "clearButtonText": "清空", + "weekDays": ["週日", "週一", "週二", "週三", "週四", "週五", "週六"], + "months": ["一月", "二月", "三月", "四月", "五月", "六月", "七月", "八月", "九月", "十月", "十一月", "十二月"], + "altWeekDays": ["週日", "週一", "週二", "週三", "週四", "週五", "週六"], + "altMonths": ["一月", "二月", "三月", "四月", "五月", "六月", "七月", "八月", "九月", "十月", "十一月", "十二月"] +} diff --git a/packages/core/cli/src/util.js b/packages/core/cli/src/util.js index 523c077781..9cc4fa8891 100644 --- a/packages/core/cli/src/util.js +++ b/packages/core/cli/src/util.js @@ -462,6 +462,8 @@ exports.initEnv = function initEnv() { process.env.SOCKET_PATH = generateGatewayPath(); fs.mkdirpSync(dirname(process.env.SOCKET_PATH), { force: true, recursive: true }); fs.mkdirpSync(process.env.PM2_HOME, { force: true, recursive: true }); + const pkgDir = resolve(process.cwd(), 'storage/plugins', '@nocobase/plugin-multi-app-manager'); + fs.existsSync(pkgDir) && fs.rmdirSync(pkgDir, { force: true }); }; exports.generatePlugins = function () { diff --git a/packages/core/client/package.json b/packages/core/client/package.json index 0f361837f1..20d8a898e7 100644 --- a/packages/core/client/package.json +++ b/packages/core/client/package.json @@ -1,6 +1,6 @@ { "name": "@nocobase/client", - "version": "1.7.0-beta.1", + "version": "1.7.0-alpha.10", "license": "AGPL-3.0", "main": "lib/index.js", "module": "es/index.mjs", @@ -17,7 +17,7 @@ "@dnd-kit/modifiers": "^6.0.0", "@dnd-kit/sortable": "^6.0.0", "@emotion/css": "^11.7.1", - "@formily/antd-v5": "1.1.9", + "@formily/antd-v5": "1.2.3", "@formily/core": "^2.2.27", "@formily/grid": "^2.2.27", "@formily/json-schema": "^2.2.27", @@ -27,11 +27,11 @@ "@formily/reactive-react": "^2.2.27", "@formily/shared": "^2.2.27", "@formily/validator": "^2.2.27", - "@nocobase/evaluators": "1.7.0-beta.1", - "@nocobase/sdk": "1.7.0-beta.1", - "@nocobase/utils": "1.7.0-beta.1", + "@nocobase/evaluators": "1.7.0-alpha.10", + "@nocobase/sdk": "1.7.0-alpha.10", + "@nocobase/utils": "1.7.0-alpha.10", "ahooks": "^3.7.2", - "antd": "5.12.8", + "antd": "5.24.2", "antd-style": "3.7.1", "axios": "^1.7.0", "bignumber.js": "^9.1.2", diff --git a/packages/core/client/src/acl/ACLProvider.tsx b/packages/core/client/src/acl/ACLProvider.tsx index 6dfa112361..19d8433475 100644 --- a/packages/core/client/src/acl/ACLProvider.tsx +++ b/packages/core/client/src/acl/ACLProvider.tsx @@ -74,6 +74,7 @@ export const ACLRolesCheckProvider = (props) => { url: 'roles:check', }, { + manual: !api.auth.token, onSuccess(data) { if (!data?.data?.snippets.includes('ui.*')) { setDesignable(false); @@ -102,6 +103,11 @@ export const useRoleRecheck = () => { }; }; +export const useCurrentRoleMode = () => { + const ctx = useContext(ACLContext); + return ctx?.data?.data?.roleMode; +}; + export const useACLContext = () => { return useContext(ACLContext); }; diff --git a/packages/core/client/src/acl/Configuration/ConfigureCenter.tsx b/packages/core/client/src/acl/Configuration/ConfigureCenter.tsx index ddb4bb16c5..f6c9704fb9 100644 --- a/packages/core/client/src/acl/Configuration/ConfigureCenter.tsx +++ b/packages/core/client/src/acl/Configuration/ConfigureCenter.tsx @@ -7,7 +7,7 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -import { Checkbox, message, Table } from 'antd'; +import { Checkbox, message, Table, TableProps } from 'antd'; import { omit } from 'lodash'; import React, { createContext, useContext, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -102,44 +102,46 @@ export const SettingsCenterConfigure = () => { expandable={{ defaultExpandAllRows: true, }} - columns={[ - { - dataIndex: 'title', - title: t('Plugin name'), - render: (value) => { - return compile(value); + columns={ + [ + { + dataIndex: 'title', + title: t('Plugin name'), + render: (value) => { + return compile(value); + }, }, - }, - { - dataIndex: 'accessible', - title: ( - <> - { - const values = allAclSnippets.map((v) => '!' + v); - if (!allChecked) { - await resource.remove({ - values, - }); - } else { - await resource.add({ - values, - }); - } - refresh(); - message.success(t('Saved successfully')); - }} - />{' '} - {t('Accessible')} - - ), - render: (_, record) => { - const checked = !snippets.includes('!' + record.aclSnippet); - return handleChange(checked, record)} />; + { + dataIndex: 'accessible', + title: ( + <> + { + const values = allAclSnippets.map((v) => '!' + v); + if (!allChecked) { + await resource.remove({ + values, + }); + } else { + await resource.add({ + values, + }); + } + refresh(); + message.success(t('Saved successfully')); + }} + />{' '} + {t('Accessible')} + + ), + render: (_, record) => { + const checked = !snippets.includes('!' + record.aclSnippet); + return handleChange(checked, record)} />; + }, }, - }, - ]} + ] as TableProps['columns'] + } dataSource={settings .filter((v) => { return v.isTopLevel !== false; diff --git a/packages/core/client/src/acl/Configuration/MenuConfigure.tsx b/packages/core/client/src/acl/Configuration/MenuConfigure.tsx index 9f31491b89..2ab40c0f4d 100644 --- a/packages/core/client/src/acl/Configuration/MenuConfigure.tsx +++ b/packages/core/client/src/acl/Configuration/MenuConfigure.tsx @@ -7,7 +7,7 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -import { Checkbox, message, Table } from 'antd'; +import { Checkbox, message, Table, TableProps } from 'antd'; import { uniq } from 'lodash'; import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -121,40 +121,42 @@ export const MenuConfigure = () => { expandable={{ defaultExpandAllRows: true, }} - columns={[ - { - dataIndex: 'title', - title: t('Menu item title'), - }, - { - dataIndex: 'accessible', - title: ( - <> - { - if (allChecked) { - await resource.set({ - values: [], - }); - } else { - await resource.set({ - values: allUids, - }); - } - refresh(); - message.success(t('Saved successfully')); - }} - />{' '} - {t('Accessible')} - - ), - render: (_, schema) => { - const checked = uids.includes(schema.uid); - return handleChange(checked, schema)} />; + columns={ + [ + { + dataIndex: 'title', + title: t('Menu item title'), }, - }, - ]} + { + dataIndex: 'accessible', + title: ( + <> + { + if (allChecked) { + await resource.set({ + values: [], + }); + } else { + await resource.set({ + values: allUids, + }); + } + refresh(); + message.success(t('Saved successfully')); + }} + />{' '} + {t('Accessible')} + + ), + render: (_, schema: { uid: string }) => { + const checked = uids.includes(schema.uid); + return handleChange(checked, schema)} />; + }, + }, + ] as TableProps['columns'] + } dataSource={translateTitle(items)} /> ); diff --git a/packages/core/client/src/acl/Configuration/RolesResourcesActions.tsx b/packages/core/client/src/acl/Configuration/RolesResourcesActions.tsx index da896e68d5..3dab660bf7 100644 --- a/packages/core/client/src/acl/Configuration/RolesResourcesActions.tsx +++ b/packages/core/client/src/acl/Configuration/RolesResourcesActions.tsx @@ -10,7 +10,7 @@ import { FormItem, FormLayout } from '@formily/antd-v5'; import { ArrayField } from '@formily/core'; import { connect, useField, useForm } from '@formily/react'; -import { Checkbox, Table, Tag } from 'antd'; +import { Checkbox, Table, Tag, TableProps } from 'antd'; import { isEmpty } from 'lodash'; import React, { createContext } from 'react'; import { useTranslation } from 'react-i18next'; @@ -105,48 +105,50 @@ export const RolesResourcesActions = connect((props) => { className={antTableCell} size={'small'} pagination={false} - columns={[ - { - dataIndex: 'displayName', - title: t('Action display name'), - render: (value) => compile(value), - }, - { - dataIndex: 'onNewRecord', - title: t('Action type'), - render: (onNewRecord) => - onNewRecord ? ( - {t('Action on new records')} - ) : ( - {t('Action on existing records')} - ), - }, - { - dataIndex: 'enabled', - title: t('Allow'), - render: (enabled, action) => ( - { - toggleAction(action.name); - }} - /> - ), - }, - { - dataIndex: 'scope', - title: t('Data scope'), - render: (value, action) => - !action.onNewRecord && ( - { - setScope(action.name, scope); + columns={ + [ + { + dataIndex: 'displayName', + title: t('Action display name'), + render: (value) => compile(value), + }, + { + dataIndex: 'onNewRecord', + title: t('Action type'), + render: (onNewRecord) => + onNewRecord ? ( + {t('Action on new records')} + ) : ( + {t('Action on existing records')} + ), + }, + { + dataIndex: 'enabled', + title: t('Allow'), + render: (enabled, action) => ( + { + toggleAction(action.name); }} /> ), - }, - ]} + }, + { + dataIndex: 'scope', + title: t('Data scope'), + render: (value, action) => + !action.onNewRecord && ( + { + setScope(action.name, scope); + }} + /> + ), + }, + ] as TableProps['columns'] + } dataSource={availableActions?.map((item) => { let enabled = false; let scope = null; @@ -169,60 +171,62 @@ export const RolesResourcesActions = connect((props) => { className={antTableCell} pagination={false} dataSource={fieldPermissions} - columns={[ - { - dataIndex: ['uiSchema', 'title'], - title: t('Field display name'), - render: (value) => compile(value), - }, - ...availableActionsWithFields.map((action) => { - const checked = allChecked?.[action.name]; - return { - dataIndex: action.name, - title: ( - <> + columns={ + [ + { + dataIndex: ['uiSchema', 'title'], + title: t('Field display name'), + render: (value) => compile(value), + }, + ...availableActionsWithFields.map((action) => { + const checked = allChecked?.[action.name]; + return { + dataIndex: action.name, + title: ( + <> + { + const item = actionMap[action.name] || { + name: action.name, + }; + if (checked) { + item.fields = []; + } else { + item.fields = collectionFields?.map?.((item) => item.name); + } + actionMap[action.name] = item; + onChange(Object.values(actionMap)); + }} + />{' '} + {compile(action.displayName)} + + ), + render: (checked, field) => ( { const item = actionMap[action.name] || { name: action.name, }; + const fields: string[] = item.fields || []; if (checked) { - item.fields = []; + const index = fields.indexOf(field.name); + fields.splice(index, 1); } else { - item.fields = collectionFields?.map?.((item) => item.name); + fields.push(field.name); } + item.fields = fields; actionMap[action.name] = item; onChange(Object.values(actionMap)); }} - />{' '} - {compile(action.displayName)} - - ), - render: (checked, field) => ( - { - const item = actionMap[action.name] || { - name: action.name, - }; - const fields: string[] = item.fields || []; - if (checked) { - const index = fields.indexOf(field.name); - fields.splice(index, 1); - } else { - fields.push(field.name); - } - item.fields = fields; - actionMap[action.name] = item; - onChange(Object.values(actionMap)); - }} - /> - ), - }; - }), - ]} + /> + ), + }; + }), + ] as TableProps['columns'] + } /> diff --git a/packages/core/client/src/acl/Configuration/StrategyActions.tsx b/packages/core/client/src/acl/Configuration/StrategyActions.tsx index df501db691..4efa5a8716 100644 --- a/packages/core/client/src/acl/Configuration/StrategyActions.tsx +++ b/packages/core/client/src/acl/Configuration/StrategyActions.tsx @@ -9,7 +9,7 @@ import { ArrayField } from '@formily/core'; import { connect, useField } from '@formily/react'; -import { Checkbox, Select, Table, Tag } from 'antd'; +import { Checkbox, Select, Table, Tag, TableProps } from 'antd'; import React from 'react'; import { useTranslation } from 'react-i18next'; import { useCompile } from '../..'; @@ -55,62 +55,64 @@ export const StrategyActions = connect((props) => { size={'small'} pagination={false} rowKey={'name'} - columns={[ - { - dataIndex: 'displayName', - title: t('Action display name'), - render: (value) => compile(value), - }, - { - dataIndex: 'onNewRecord', - title: t('Action type'), - render: (onNewRecord) => - onNewRecord ? ( - {t('Action on new records')} - ) : ( - {t('Action on existing records')} - ), - }, - { - dataIndex: 'enabled', - title: t('Allow'), - render: (enabled, action) => ( - { - if (enabled) { - delete scopes[action.name]; - } else { - scopes[action.name] = 'all'; - } - onChange(toFieldValue(scopes)); - }} - /> - ), - }, - { - dataIndex: 'scope', - title: t('Data scope'), - render: (scope, action) => - !action.onNewRecord && ( - { + scopes[action.name] = value; + onChange(toFieldValue(scopes)); + }} + /> + ), + }, + ] as TableProps['columns'] + } dataSource={availableActions?.map((item) => { let scope = 'all'; let enabled = false; diff --git a/packages/core/client/src/api-client/APIClient.ts b/packages/core/client/src/api-client/APIClient.ts index 1cbbcdacdd..f85c766e10 100644 --- a/packages/core/client/src/api-client/APIClient.ts +++ b/packages/core/client/src/api-client/APIClient.ts @@ -139,7 +139,19 @@ export class APIClient extends APIClientSDK { if (typeof error?.response?.data === 'string') { const tempElement = document.createElement('div'); tempElement.innerHTML = error?.response?.data; - return [{ message: tempElement.textContent || tempElement.innerText }]; + let message = tempElement.textContent || tempElement.innerText; + if (message.includes('Error occurred while trying')) { + message = 'The application may be starting up. Please try again later.'; + return [{ code: 'APP_WARNING', message }]; + } + if (message.includes('502 Bad Gateway')) { + message = 'The application may be starting up. Please try again later.'; + return [{ code: 'APP_WARNING', message }]; + } + return [{ message }]; + } + if (error?.response?.data?.error) { + return [error?.response?.data?.error]; } return ( error?.response?.data?.errors || diff --git a/packages/core/client/src/application/Application.tsx b/packages/core/client/src/application/Application.tsx index c441f1ae92..ae517e9b52 100644 --- a/packages/core/client/src/application/Application.tsx +++ b/packages/core/client/src/application/Application.tsx @@ -108,6 +108,7 @@ export class Application { public name: string; public favicon: string; public globalVars: Record = {}; + public globalVarCtxs: Record = {}; public jsonLogic: JsonLogic; loading = true; maintained = false; @@ -350,23 +351,9 @@ export class Application { setTimeout(() => resolve(null), 1000); }); } - const toError = (error) => { - if (typeof error?.response?.data === 'string') { - const tempElement = document.createElement('div'); - tempElement.innerHTML = error?.response?.data; - return { message: tempElement.textContent || tempElement.innerText }; - } - if (error?.response?.data?.error) { - return error?.response?.data?.error; - } - if (error?.response?.data?.errors?.[0]) { - return error?.response?.data?.errors?.[0]; - } - return { message: error?.message }; - }; this.error = { code: 'LOAD_ERROR', - ...toError(error), + ...this.apiClient.toErrMessages(error)?.[0], }; console.error(error, this.error); } @@ -508,13 +495,20 @@ export class Application { ); } - addGlobalVar(key: string, value: any) { + addGlobalVar(key: string, value: any, varCtx?: any) { set(this.globalVars, key, value); + if (varCtx) { + set(this.globalVarCtxs, key, varCtx); + } } getGlobalVar(key) { return get(this.globalVars, key); } + + getGlobalVarCtx(key) { + return get(this.globalVarCtxs, key); + } addUserCenterSettingsItem(item: SchemaSettingsItemType & { aclSnippet?: string }) { const useVisibleProp = item.useVisible || (() => true); const useVisible = () => { diff --git a/packages/core/client/src/application/RouterManager.tsx b/packages/core/client/src/application/RouterManager.tsx index a8b08dafa0..cf16f2c9e4 100644 --- a/packages/core/client/src/application/RouterManager.tsx +++ b/packages/core/client/src/application/RouterManager.tsx @@ -9,6 +9,7 @@ import { get, set } from 'lodash'; import React, { ComponentType, createContext, useContext } from 'react'; +import { matchRoutes } from 'react-router'; import { BrowserRouterProps, createBrowserRouter, @@ -42,6 +43,7 @@ export type RouterOptions = (HashRouterOptions | BrowserRouterOptions | MemoryRo export type ComponentTypeAndString = ComponentType | string; export interface RouteType extends Omit { Component?: ComponentTypeAndString; + skipAuthCheck?: boolean; } export type RenderComponentType = (Component: ComponentTypeAndString, props?: any) => React.ReactNode; @@ -134,6 +136,18 @@ export class RouterManager { this.options.basename = basename; } + matchRoutes(pathname: string) { + const routes = Object.values(this.routes); + // @ts-ignore + return matchRoutes(routes, pathname, this.basename); + } + + isSkippedAuthCheckRoute(pathname: string) { + const matchedRoutes = this.matchRoutes(pathname); + return matchedRoutes.some((match) => { + return match?.route?.skipAuthCheck === true; + }); + } /** * @internal */ diff --git a/packages/core/client/src/application/__tests__/RouterManager.test.tsx b/packages/core/client/src/application/__tests__/RouterManager.test.tsx index 2ec1850a8e..0cf2419754 100644 --- a/packages/core/client/src/application/__tests__/RouterManager.test.tsx +++ b/packages/core/client/src/application/__tests__/RouterManager.test.tsx @@ -30,7 +30,7 @@ describe('Router', () => { let router: RouterManager; beforeEach(() => { - router = new RouterManager({ type: 'memory', initialEntries: ['/'] }, app); + router = new RouterManager({ type: 'memory', initialEntries: ['/'], basename: '/nocobase/apps/test1' }, app); }); it('basic', () => { @@ -132,6 +132,38 @@ describe('Router', () => { router.add('test', route); expect(router.getRoutesTree()).toEqual([{ path: '/', element: , children: undefined }]); }); + + it('add skipAuthCheck route', () => { + router.add('skip-auth-check', { path: '/skip-auth-check', Component: 'Hello', skipAuthCheck: true }); + router.add('not-skip-auth-check', { path: '/not-skip-auth-check', Component: 'Hello' }); + + const RouterComponent = router.getRouterComponent(); + const BaseLayout: FC = (props) => { + return
BaseLayout {props.children}
; + }; + render(); + router.navigate('/skip-auth-check'); + const state = router.state; + const { pathname, search } = state.location; + const isSkipedAuthCheck = router.isSkippedAuthCheckRoute(pathname); + expect(isSkipedAuthCheck).toBe(true); + }); + + it('add not skipAuthCheck route', () => { + router.add('skip-auth-check', { path: '/skip-auth-check', Component: 'Hello', skipAuthCheck: true }); + router.add('not-skip-auth-check', { path: '/not-skip-auth-check', Component: 'Hello' }); + + const RouterComponent = router.getRouterComponent(); + const BaseLayout: FC = (props) => { + return
BaseLayout {props.children}
; + }; + render(); + router.navigate('/not-skip-auth-check'); + const state = router.state; + const { pathname, search } = state.location; + const isSkipedAuthCheck = router.isSkippedAuthCheckRoute(pathname); + expect(isSkipedAuthCheck).toBe(false); + }); }); describe('remove', () => { diff --git a/packages/core/client/src/application/components/defaultComponents.tsx b/packages/core/client/src/application/components/defaultComponents.tsx index aeb2c475b9..31801e97ed 100644 --- a/packages/core/client/src/application/components/defaultComponents.tsx +++ b/packages/core/client/src/application/components/defaultComponents.tsx @@ -11,10 +11,11 @@ import React, { FC } from 'react'; import { MainComponent } from './MainComponent'; const Loading: FC = () =>
Loading...
; -const AppError: FC<{ error: Error }> = ({ error }) => { +const AppError: FC<{ error: Error & { title?: string } }> = ({ error }) => { + const title = error?.title || 'App Error'; return (
-
App Error
+
{title}
{error?.message} {process.env.__TEST__ && error?.stack}
diff --git a/packages/core/client/src/application/hooks/index.ts b/packages/core/client/src/application/hooks/index.ts index 57fb89faff..3116f55c87 100644 --- a/packages/core/client/src/application/hooks/index.ts +++ b/packages/core/client/src/application/hooks/index.ts @@ -12,3 +12,4 @@ export * from './useAppSpin'; export * from './usePlugin'; export * from './useRouter'; export * from './useGlobalVariable'; +export * from './useAclSnippets'; diff --git a/packages/core/client/src/application/hooks/useGlobalVariable.ts b/packages/core/client/src/application/hooks/useGlobalVariable.ts index 1ef7ef76d1..037c5256ca 100644 --- a/packages/core/client/src/application/hooks/useGlobalVariable.ts +++ b/packages/core/client/src/application/hooks/useGlobalVariable.ts @@ -29,3 +29,22 @@ export const useGlobalVariable = (key: string) => { return variable; }; + +export const useGlobalVariableCtx = (key: string) => { + const app = useApp(); + + const variable = useMemo(() => { + return app?.getGlobalVarCtx?.(key); + }, [app, key]); + + if (isFunction(variable)) { + try { + return variable(); + } catch (error) { + console.error(`Error calling global variable function for key: ${key}`, error); + return undefined; + } + } + + return variable; +}; diff --git a/packages/core/client/src/application/schema-initializer/components/SchemaInitializerItem.tsx b/packages/core/client/src/application/schema-initializer/components/SchemaInitializerItem.tsx index 436fc54f37..dfdedad636 100644 --- a/packages/core/client/src/application/schema-initializer/components/SchemaInitializerItem.tsx +++ b/packages/core/client/src/application/schema-initializer/components/SchemaInitializerItem.tsx @@ -63,7 +63,7 @@ export const SchemaInitializerItem = memo( className: className, label: children || compile(title), onClick: (info) => { - if (info.key !== name) return; + if (disabled || info.key !== name) return; if (closeInitializerMenuWhenClick) { setVisible?.(false); } @@ -73,10 +73,10 @@ export const SchemaInitializerItem = memo( children: childrenItems, }, ]; - }, [name, style, className, children, title, onClick, icon, childrenItems]); + }, [name, disabled, style, className, children, title, onClick, icon, childrenItems]); if (items && items.length > 0) { - return ; + return ; } return (
{ export const DetailsBlockProvider = withDynamicSchemaProps((props) => { const { params, parseVariableLoading } = useCompatDetailsBlockParams(props); const record = useCollectionRecordData(); - const { association, dataSource, action } = props; - const { getCollection } = useCollectionManager_deprecated(dataSource); + const { association, action } = props; const { __collection } = record || {}; const { designable } = useDesignable(); const collectionName = props.collection; diff --git a/packages/core/client/src/block-provider/TableBlockProvider.tsx b/packages/core/client/src/block-provider/TableBlockProvider.tsx index d1ae360457..7df1fecbfc 100644 --- a/packages/core/client/src/block-provider/TableBlockProvider.tsx +++ b/packages/core/client/src/block-provider/TableBlockProvider.tsx @@ -62,6 +62,7 @@ interface Props { children?: any; expandFlag?: boolean; dragSortBy?: string; + enableIndexÏColumn?: boolean; } const InternalTableBlockProvider = (props: Props) => { @@ -74,6 +75,7 @@ const InternalTableBlockProvider = (props: Props) => { expandFlag: propsExpandFlag = false, fieldNames, collection, + enableIndexÏColumn, } = props; const field: any = useField(); const { resource, service } = useBlockRequestContext(); @@ -131,6 +133,7 @@ const InternalTableBlockProvider = (props: Props) => { allIncludesChildren, setExpandFlag: setExpandFlagValue, heightProps, + enableIndexÏColumn, }), [ allIncludesChildren, @@ -146,6 +149,7 @@ const InternalTableBlockProvider = (props: Props) => { service, setExpandFlagValue, showIndex, + enableIndexÏColumn, ], ); diff --git a/packages/core/client/src/block-provider/hooks/index.ts b/packages/core/client/src/block-provider/hooks/index.ts index 168374889f..9dfead7b02 100644 --- a/packages/core/client/src/block-provider/hooks/index.ts +++ b/packages/core/client/src/block-provider/hooks/index.ts @@ -167,7 +167,7 @@ export function useCollectValuesToSubmit() { if (parsedValue !== null && parsedValue !== undefined) { assignedValues[key] = transformVariableValue(parsedValue, { targetCollectionField: collectionField }); } - } else if (value != null && value !== '') { + } else if (value !== '') { assignedValues[key] = value; } }); @@ -203,6 +203,12 @@ export function useCollectValuesToSubmit() { ]); } +function interpolateVariables(str: string, scope: Record): string { + return str.replace(/\{\{\s*([a-zA-Z0-9_$-.]+?)\s*\}\}/g, (_, key) => { + return scope[key] !== undefined ? String(scope[key]) : ''; + }); +} + export const useCreateActionProps = () => { const filterByTk = useFilterByTk(); const record = useCollectionRecord(); @@ -219,11 +225,20 @@ export const useCreateActionProps = () => { const collectValues = useCollectValuesToSubmit(); const action = record.isNew ? actionField.componentProps.saveMode || 'create' : 'update'; const filterKeys = actionField.componentProps.filterKeys?.checked || []; + const localVariables = useLocalVariables(); + const variables = useVariables(); return { async onClick() { const { onSuccess, skipValidator, triggerWorkflows } = actionSchema?.['x-action-settings'] ?? {}; - const { manualClose, redirecting, redirectTo, successMessage, actionAfterSuccess } = onSuccess || {}; + const { + manualClose, + redirecting, + redirectTo: rawRedirectTo, + successMessage, + actionAfterSuccess, + } = onSuccess || {}; + if (!skipValidator) { await form.submit(); } @@ -241,6 +256,15 @@ export const useCreateActionProps = () => { : undefined, updateAssociationValues, }); + let redirectTo = rawRedirectTo; + if (rawRedirectTo) { + const { exp, scope: expScope } = await replaceVariables(rawRedirectTo, { + variables, + localVariables: [...localVariables, { name: '$record', ctx: new Proxy(data?.data?.data, {}) }], + }); + redirectTo = interpolateVariables(exp, expScope); + } + if (actionAfterSuccess === 'previous' || (!actionAfterSuccess && redirecting !== true)) { setVisible?.(false); } @@ -338,7 +362,7 @@ export const useAssociationCreateActionProps = () => { if (parsedValue) { assignedValues[key] = transformVariableValue(parsedValue, { targetCollectionField: collectionField }); } - } else if (value != null && value !== '') { + } else if (value !== '') { assignedValues[key] = value; } }); @@ -468,6 +492,10 @@ const useDoFilter = () => { block.defaultFilter, ]); + if (_.isEmpty(storedFilter[uid])) { + block.clearSelection?.(); + } + if (doNothingWhenFilterIsEmpty && _.isEmpty(storedFilter[uid])) { return; } @@ -584,7 +612,13 @@ export const useCustomizeUpdateActionProps = () => { skipValidator, triggerWorkflows, } = actionSchema?.['x-action-settings'] ?? {}; - const { manualClose, redirecting, redirectTo, successMessage, actionAfterSuccess } = onSuccess || {}; + const { + manualClose, + redirecting, + redirectTo: rawRedirectTo, + successMessage, + actionAfterSuccess, + } = onSuccess || {}; const assignedValues = {}; const waitList = Object.keys(originalAssignedValues).map(async (key) => { const value = originalAssignedValues[key]; @@ -601,7 +635,7 @@ export const useCustomizeUpdateActionProps = () => { if (parsedValue) { assignedValues[key] = transformVariableValue(parsedValue, { targetCollectionField: collectionField }); } - } else if (value != null && value !== '') { + } else if (value !== '') { assignedValues[key] = value; } }); @@ -610,7 +644,7 @@ export const useCustomizeUpdateActionProps = () => { if (skipValidator === false) { await form.submit(); } - await resource.update({ + const result = await resource.update({ filterByTk, values: { ...assignedValues }, // TODO(refactor): should change to inject by plugin @@ -618,6 +652,16 @@ export const useCustomizeUpdateActionProps = () => { ? triggerWorkflows.map((row) => [row.workflowKey, row.context].filter(Boolean).join('!')).join(',') : undefined, }); + + let redirectTo = rawRedirectTo; + if (rawRedirectTo) { + const { exp, scope: expScope } = await replaceVariables(rawRedirectTo, { + variables, + localVariables: [...localVariables, { name: '$record', ctx: new Proxy(result?.data?.data?.[0], {}) }], + }); + redirectTo = interpolateVariables(exp, expScope); + } + if (actionAfterSuccess === 'previous' || (!actionAfterSuccess && redirecting !== true)) { setVisible?.(false); } @@ -704,7 +748,7 @@ export const useCustomizeBulkUpdateActionProps = () => { if (parsedValue) { assignedValues[key] = transformVariableValue(parsedValue, { targetCollectionField: collectionField }); } - } else if (value != null && value !== '') { + } else if (value !== '') { assignedValues[key] = value; } }); @@ -909,7 +953,13 @@ export const useUpdateActionProps = () => { skipValidator, triggerWorkflows, } = actionSchema?.['x-action-settings'] ?? {}; - const { manualClose, redirecting, redirectTo, successMessage, actionAfterSuccess } = onSuccess || {}; + const { + manualClose, + redirecting, + redirectTo: rawRedirectTo, + successMessage, + actionAfterSuccess, + } = onSuccess || {}; const assignedValues = {}; const waitList = Object.keys(originalAssignedValues).map(async (key) => { const value = originalAssignedValues[key]; @@ -926,7 +976,7 @@ export const useUpdateActionProps = () => { if (parsedValue) { assignedValues[key] = transformVariableValue(parsedValue, { targetCollectionField: collectionField }); } - } else if (value != null && value !== '') { + } else if (value !== '') { assignedValues[key] = value; } }); @@ -948,7 +998,7 @@ export const useUpdateActionProps = () => { actionField.data = field.data || {}; actionField.data.loading = true; try { - await resource.update({ + const result = await resource.update({ filterByTk, values: { ...values, @@ -967,6 +1017,15 @@ export const useUpdateActionProps = () => { if (callBack) { callBack?.(); } + let redirectTo = rawRedirectTo; + if (rawRedirectTo) { + const { exp, scope: expScope } = await replaceVariables(rawRedirectTo, { + variables, + localVariables: [...localVariables, { name: '$record', ctx: new Proxy(result?.data?.data?.[0], {}) }], + }); + redirectTo = interpolateVariables(exp, expScope); + } + if (actionAfterSuccess === 'previous' || (!actionAfterSuccess && redirecting !== true)) { setVisible?.(false); } @@ -1153,6 +1212,7 @@ export const useDetailsPaginationProps = () => { current: ctx.service?.data?.meta?.page || 1, pageSize: 1, showSizeChanger: false, + align: 'center', async onChange(page) { const params = ctx.service?.params?.[0]; ctx.service.run({ ...params, page }); @@ -1178,6 +1238,7 @@ export const useDetailsPaginationProps = () => { total: count, pageSize: 1, showSizeChanger: false, + align: 'center', async onChange(page) { const params = ctx.service?.params?.[0]; ctx.service.run({ ...params, page }); @@ -1349,6 +1410,7 @@ export const useAssociationFilterBlockProps = () => { [filterKey]: value, }; } else { + block.clearSelection?.(); if (block.dataLoadingMode === 'manual') { return block.clearData(); } @@ -1442,6 +1504,8 @@ async function doReset({ const target = targets.find((target) => target.uid === block.uid); if (!target) return; + block.clearSelection?.(); + if (block.dataLoadingMode === 'manual') { return block.clearData(); } diff --git a/packages/core/client/src/collection-manager/Configuration/AddFieldAction.tsx b/packages/core/client/src/collection-manager/Configuration/AddFieldAction.tsx index 0fb0ad2965..2e44491840 100644 --- a/packages/core/client/src/collection-manager/Configuration/AddFieldAction.tsx +++ b/packages/core/client/src/collection-manager/Configuration/AddFieldAction.tsx @@ -25,6 +25,7 @@ import { useCollectionManager_deprecated } from '../hooks'; import useDialect from '../hooks/useDialect'; import * as components from './components'; import { useFieldInterfaceOptions } from './interfaces'; +import { ItemType, MenuItemType } from 'antd/es/menu/interface'; const getSchema = (schema: CollectionFieldInterface, record: any, compile) => { if (!schema) { @@ -231,7 +232,7 @@ export const AddFieldAction = (props) => { }, [getTemplate, record]); const items = useMemo(() => { return getFieldOptions() - .map((option) => { + .map((option): ItemType & { title: string; children?: ItemType[] } => { if (option?.children?.length === 0) { return null; } diff --git a/packages/core/client/src/collection-manager/action-hooks.ts b/packages/core/client/src/collection-manager/action-hooks.ts index cac488e796..422c207fc5 100644 --- a/packages/core/client/src/collection-manager/action-hooks.ts +++ b/packages/core/client/src/collection-manager/action-hooks.ts @@ -157,6 +157,8 @@ export const useCollectionFilterOptions = (collection: any, dataSource?: string) const option = { name: field.name, title: field?.uiSchema?.title || field.name, + label: field?.uiSchema?.title || field.name, + value: field.name, schema: field?.uiSchema, operators: operators?.filter?.((operator) => { diff --git a/packages/core/client/src/collection-manager/interfaces/input.ts b/packages/core/client/src/collection-manager/interfaces/input.ts index ac5897adb7..40d131e5be 100644 --- a/packages/core/client/src/collection-manager/interfaces/input.ts +++ b/packages/core/client/src/collection-manager/interfaces/input.ts @@ -62,6 +62,12 @@ export class InputFieldInterface extends CollectionFieldInterface { hasDefaultValue = true; properties = { ...defaultProps, + trim: { + type: 'boolean', + 'x-content': '{{t("Automatically remove heading and tailing spaces")}}', + 'x-decorator': 'FormItem', + 'x-component': 'Checkbox', + }, layout: { type: 'void', title: '{{t("Index")}}', diff --git a/packages/core/client/src/collection-manager/interfaces/properties/operators.ts b/packages/core/client/src/collection-manager/interfaces/properties/operators.ts index ce0b6d411f..80b4e31527 100644 --- a/packages/core/client/src/collection-manager/interfaces/properties/operators.ts +++ b/packages/core/client/src/collection-manager/interfaces/properties/operators.ts @@ -129,12 +129,12 @@ export const enumType = [ label: '{{t("is")}}', value: '$eq', selected: true, - schema: { 'x-component': 'Select' }, + schema: { 'x-component': 'Select', 'x-component-props': { mode: null } }, }, { label: '{{t("is not")}}', value: '$ne', - schema: { 'x-component': 'Select' }, + schema: { 'x-component': 'Select', 'x-component-props': { mode: null } }, }, { label: '{{t("is any of")}}', diff --git a/packages/core/client/src/collection-manager/interfaces/textarea.ts b/packages/core/client/src/collection-manager/interfaces/textarea.ts index 26785ed0fb..19f48bc4c1 100644 --- a/packages/core/client/src/collection-manager/interfaces/textarea.ts +++ b/packages/core/client/src/collection-manager/interfaces/textarea.ts @@ -28,8 +28,15 @@ export class TextareaFieldInterface extends CollectionFieldInterface { }; availableTypes = ['text', 'json', 'string']; hasDefaultValue = true; + titleUsable = true; properties = { ...defaultProps, + trim: { + type: 'boolean', + 'x-content': '{{t("Automatically remove heading and tailing spaces")}}', + 'x-decorator': 'FormItem', + 'x-component': 'Checkbox', + }, }; schemaInitialize(schema: ISchema, { block }) { if (['Table', 'Kanban'].includes(block)) { diff --git a/packages/core/client/src/collection-manager/templates/components/PresetFields.tsx b/packages/core/client/src/collection-manager/templates/components/PresetFields.tsx index 675617818e..5f52ca897b 100644 --- a/packages/core/client/src/collection-manager/templates/components/PresetFields.tsx +++ b/packages/core/client/src/collection-manager/templates/components/PresetFields.tsx @@ -96,7 +96,7 @@ export const PresetFields = observer( rowSelection={{ type: 'checkbox', selectedRowKeys, - getCheckboxProps: (record) => ({ + getCheckboxProps: (record: { name: string }) => ({ name: record.name, disabled: props?.disabled || props?.presetFieldsDisabledIncludes?.includes?.(record.name), }), diff --git a/packages/core/client/src/common/SelectWithTitle.tsx b/packages/core/client/src/common/SelectWithTitle.tsx index 4bd43648a3..b3e0be59a8 100644 --- a/packages/core/client/src/common/SelectWithTitle.tsx +++ b/packages/core/client/src/common/SelectWithTitle.tsx @@ -18,7 +18,14 @@ export interface SelectWithTitleProps { onChange?: (...args: any[]) => void; } -export function SelectWithTitle({ title, defaultValue, onChange, options, fieldNames }: SelectWithTitleProps) { +export function SelectWithTitle({ + title, + defaultValue, + onChange, + options, + fieldNames, + ...others +}: SelectWithTitleProps) { const [open, setOpen] = useState(false); const timerRef = useRef(null); return ( @@ -36,6 +43,7 @@ export function SelectWithTitle({ title, defaultValue, onChange, options, fieldN > {title} + ); +} + export function AfterSuccess() { const { dn } = useDesignable(); const { t } = useTranslation(); const fieldSchema = useFieldSchema(); const { onSuccess } = fieldSchema?.['x-action-settings'] || {}; + const environmentVariables = useGlobalVariable('$env'); + const templatePlugin: any = usePlugin('@nocobase/plugin-block-template'); + const isInBlockTemplateConfigPage = templatePlugin?.isInBlockTemplateConfigPage?.(); + return ( useVariableProps(environmentVariables), + }, + blocksToRefresh: { + type: 'array', + title: t('Refresh data blocks'), + 'x-decorator': 'FormItem', + 'x-use-decorator-props': () => { + return { + tooltip: t('After successful submission, the selected data blocks will be automatically refreshed.'), + }; + }, + 'x-component': BlocksSelector, + 'x-hidden': isInBlockTemplateConfigPage, // 模板配置页面暂不支持该配置 }, }, } as ISchema diff --git a/packages/core/client/src/schema-component/antd/action/Action.Drawer.style.ts b/packages/core/client/src/schema-component/antd/action/Action.Drawer.style.ts index 1974d0dd94..532b942c2e 100644 --- a/packages/core/client/src/schema-component/antd/action/Action.Drawer.style.ts +++ b/packages/core/client/src/schema-component/antd/action/Action.Drawer.style.ts @@ -25,7 +25,7 @@ export const useStyles = genStyleHook('nb-action-drawer', (token) => { }, }, '&.nb-record-picker-selector': { - '.ant-drawer-wrapper-body': { + '.ant-drawer-content': { backgroundColor: token.colorBgLayout, }, '.nb-block-item': { 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 49d5eaaf60..7ee5fa3a9a 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 @@ -10,6 +10,7 @@ import { observer, RecursionField, useField, useFieldSchema } from '@formily/react'; import { Drawer } from 'antd'; import classNames from 'classnames'; +import { useTranslation } from 'react-i18next'; import React, { FC, startTransition, useCallback, useEffect, useMemo, useState } from 'react'; import { ErrorBoundary, FallbackProps } from 'react-error-boundary'; import { NocoBaseRecursionField } from '../../../formily/NocoBaseRecursionField'; @@ -22,6 +23,7 @@ import { useActionContext } from './hooks'; import { useSetAriaLabelForDrawer } from './hooks/useSetAriaLabelForDrawer'; import { ActionDrawerProps, ComposedActionDrawer, OpenSize } from './types'; import { getZIndex, useZIndexContext, zIndexContext } from './zIndexContext'; +import { NAMESPACE_UI_SCHEMA } from '../../../i18n/constant'; const MemoizeRecursionField = React.memo(RecursionField); MemoizeRecursionField.displayName = 'MemoizeRecursionField'; @@ -81,6 +83,7 @@ export const InternalActionDrawer: React.FC = observer( const { visible, setVisible, openSize = 'middle', drawerProps } = useActionContext(); const schema = useFieldSchema(); const field = useField(); + const { t } = useTranslation(); const { componentCls, hashId } = useStyles(); const tabContext = useTabsContext(); const parentZIndex = useZIndexContext(); @@ -126,7 +129,7 @@ export const InternalActionDrawer: React.FC = observer( { + return ( + + + {icon && typeof icon === 'string' ? : icon} + + {onlyIcon ? children[1] : children} + + ); + }, +); +WrapperComponent.displayName = 'WrapperComponentLink'; export const ActionLink: ComposedAction = withDynamicSchemaProps( observer((props: any) => { return ( - + ); }), { displayName: 'ActionLink' }, diff --git a/packages/core/client/src/schema-component/antd/action/Action.Modal.tsx b/packages/core/client/src/schema-component/antd/action/Action.Modal.tsx index 09562cb54d..d7d6724def 100644 --- a/packages/core/client/src/schema-component/antd/action/Action.Modal.tsx +++ b/packages/core/client/src/schema-component/antd/action/Action.Modal.tsx @@ -179,4 +179,17 @@ ActionModal.Footer = observer( { displayName: 'ActionModal.Footer' }, ); +ActionModal.FootBar = observer( + () => { + const field = useField(); + const schema = useFieldSchema(); + return ( +
+ +
+ ); + }, + { displayName: 'ActionModal.FootBar' }, +); + export default ActionModal; diff --git a/packages/core/client/src/schema-component/antd/action/Action.Page.style.ts b/packages/core/client/src/schema-component/antd/action/Action.Page.style.ts index 198c7c3df7..0d98c7215a 100644 --- a/packages/core/client/src/schema-component/antd/action/Action.Page.style.ts +++ b/packages/core/client/src/schema-component/antd/action/Action.Page.style.ts @@ -20,7 +20,8 @@ export const useActionPageStyle = genStyleHook('nb-action-page', (token) => { right: 0, bottom: 0, backgroundColor: token.colorBgLayout, - overflow: 'auto', + overflowX: 'hidden', + overflowY: 'auto', '.ant-tabs-nav': { background: token.colorBgContainer, diff --git a/packages/core/client/src/schema-component/antd/action/Action.style.ts b/packages/core/client/src/schema-component/antd/action/Action.style.ts index ccf047cf03..14f991d228 100644 --- a/packages/core/client/src/schema-component/antd/action/Action.style.ts +++ b/packages/core/client/src/schema-component/antd/action/Action.style.ts @@ -34,6 +34,9 @@ const useStyles = genStyleHook('nb-action', (token) => { background: 'var(--colorBgSettingsHover)', border: '0', pointerEvents: 'none', + '&.nb-in-template': { + background: 'var(--colorTemplateBgSettingsHover)', + }, '> .general-schema-designer-icons': { position: 'absolute', right: '2px', diff --git a/packages/core/client/src/schema-component/antd/action/Action.tsx b/packages/core/client/src/schema-component/antd/action/Action.tsx index cb7657877a..53c0f11b22 100644 --- a/packages/core/client/src/schema-component/antd/action/Action.tsx +++ b/packages/core/client/src/schema-component/antd/action/Action.tsx @@ -48,13 +48,15 @@ import { ActionContextProvider } from './context'; import { useGetAriaLabelOfAction } from './hooks/useGetAriaLabelOfAction'; import { ActionContextProps, ActionProps, ComposedAction } from './types'; import { linkageAction, setInitialActionState } from './utils'; +import { NAMESPACE_UI_SCHEMA } from '../../../i18n/constant'; // 这个要放到最下面,否则会导致前端单测失败 import { useApp } from '../../../application'; +import { useAllDataBlocks } from '../page/AllDataBlocksProvider'; const useA = () => { return { - async run() {}, + async run() { }, }; }; @@ -100,6 +102,8 @@ export const Action: ComposedAction = withDynamicSchemaProps( const { getAriaLabel } = useGetAriaLabelOfAction(title); const parentRecordData = useCollectionParentRecordData(); const app = useApp(); + const { getAllDataBlocks } = useAllDataBlocks(); + useEffect(() => { if (field.stateOfLinkageRules) { setInitialActionState(field); @@ -130,6 +134,26 @@ export const Action: ComposedAction = withDynamicSchemaProps( [onMouseEnter], ); + const handleClick = useMemo(() => { + return onClick && (async (e, callback) => { + await onClick?.(e, callback); + + // 执行完 onClick 之后,刷新数据区块 + const blocksToRefresh = fieldSchema['x-action-settings']?.onSuccess?.blocksToRefresh || [] + if (blocksToRefresh.length > 0) { + getAllDataBlocks().forEach((block) => { + if (blocksToRefresh.includes(block.uid)) { + try { + block.service?.refresh(); + } catch (error) { + console.error('Failed to refresh block:', block.uid, error); + } + } + }); + } + }); + }, [onClick, fieldSchema, getAllDataBlocks]); + return ( = observer(function Com(prop const aclCtx = useACLActionParamsContext(); const { run, element, disabled: disableAction } = useAction?.(actionCallback) || ({} as any); const disabled = form.disabled || field.disabled || field.data?.disabled || propsDisabled || disableAction; - const buttonStyle = useMemo(() => { return { ...style, @@ -538,6 +561,8 @@ const RenderButtonInner = observer( Designer: React.ElementType; designerProps: any; title: string; + isLink?: boolean; + onlyIcon?: boolean; }) => { const { designable, @@ -558,8 +583,11 @@ const RenderButtonInner = observer( Designer, designerProps, title, + isLink, + onlyIcon, ...others } = props; + const { t } = useTranslation(); const debouncedClick = useCallback( debounce( (e: React.MouseEvent, checkPortal = true) => { @@ -581,8 +609,10 @@ const RenderButtonInner = observer( return null; } - const actionTitle = title || field?.title; - + const rawTitle = title ?? field?.title; + const actionTitle = typeof rawTitle === 'string' ? t(rawTitle, { ns: NAMESPACE_UI_SCHEMA }) : rawTitle; + const { opacity, ...restButtonStyle } = buttonStyle; + const linkStyle = isLink && opacity ? { opacity } : undefined; return ( : icon} + icon={typeof icon === 'string' ? : icon} disabled={disabled} - style={buttonStyle} + style={isLink ? restButtonStyle : buttonStyle} onClick={process.env.__E2E__ ? handleButtonClick : debouncedClick} // E2E 中的点击操作都是很快的,如果加上 debounce 会导致 E2E 测试失败 component={tarComponent || Button} className={classnames(componentCls, hashId, className, 'nb-action')} type={type === 'danger' ? undefined : type} + title={actionTitle} > - {actionTitle && {actionTitle}} + {!onlyIcon && actionTitle && ( + + {actionTitle} + + )} ); diff --git a/packages/core/client/src/schema-component/antd/action/hooks/useGetAfterSuccessVariablesOptions.ts b/packages/core/client/src/schema-component/antd/action/hooks/useGetAfterSuccessVariablesOptions.ts new file mode 100644 index 0000000000..d89abc64bb --- /dev/null +++ b/packages/core/client/src/schema-component/antd/action/hooks/useGetAfterSuccessVariablesOptions.ts @@ -0,0 +1,66 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * 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 { useMemo } from 'react'; +import { useCollection_deprecated, useCollectionFilterOptions } from '../../../../collection-manager'; +import { useCollectionRecordData } from '../../../../data-source'; +import { useTranslation } from 'react-i18next'; +import { useCompile } from '../../../'; +import { useBlockContext } from '../../../../block-provider/BlockProvider'; +import { usePopupVariable } from '../../../../schema-settings/VariableInput/hooks'; +import { useCurrentRoleVariable } from '../../../../schema-settings/VariableInput/hooks'; + +export const useAfterSuccessOptions = () => { + const collection = useCollection_deprecated(); + const { t } = useTranslation(); + const fieldsOptions = useCollectionFilterOptions(collection); + const userFieldOptions = useCollectionFilterOptions('users', 'main'); + const compile = useCompile(); + const recordData = useCollectionRecordData(); + const { name: blockType } = useBlockContext() || {}; + const [fields, userFields] = useMemo(() => { + return [compile(fieldsOptions), compile(userFieldOptions)]; + }, [fieldsOptions, userFieldOptions]); + const { settings: popupRecordSettings, shouldDisplayPopupRecord } = usePopupVariable(); + const { currentRoleSettings } = useCurrentRoleVariable(); + const record = useCollectionRecordData(); + return useMemo(() => { + return [ + (record || blockType === 'form') && { + value: '$record', + label: t('Response record', { ns: 'client' }), + children: [...fields], + }, + recordData && { + value: 'currentRecord', + label: t('Current record', { ns: 'client' }), + children: [...fields], + }, + shouldDisplayPopupRecord && { + ...popupRecordSettings, + }, + { + value: 'currentUser', + label: t('Current user', { ns: 'client' }), + children: userFields, + }, + currentRoleSettings, + { + value: 'currentTime', + label: t('Current time', { ns: 'client' }), + children: null, + }, + { + value: '$nToken', + label: 'API token', + children: null, + }, + ].filter(Boolean); + }, [recordData, t, fields, blockType, userFields]); +}; diff --git a/packages/core/client/src/schema-component/antd/action/hooks/useSetAriaLabelForDrawer.ts b/packages/core/client/src/schema-component/antd/action/hooks/useSetAriaLabelForDrawer.ts index 0846c17817..4b28f6509d 100644 --- a/packages/core/client/src/schema-component/antd/action/hooks/useSetAriaLabelForDrawer.ts +++ b/packages/core/client/src/schema-component/antd/action/hooks/useSetAriaLabelForDrawer.ts @@ -18,7 +18,7 @@ export function useSetAriaLabelForDrawer(visible: boolean) { if (visible) { // 因为 Action 是点击后渲染内容,所以需要延迟一下 setTimeout(() => { - const wrappers = [...document.querySelectorAll('.ant-drawer-wrapper-body')]; + const wrappers = [...document.querySelectorAll('.ant-drawer-body')]; const masks = [...document.querySelectorAll('.ant-drawer-mask')]; // 如果存在多个 mask,最后一个 mask 为当前打开的 mask;wrapper 也是同理 const currentMask = masks[masks.length - 1]; diff --git a/packages/core/client/src/schema-component/antd/action/index.tsx b/packages/core/client/src/schema-component/antd/action/index.tsx index 80766dbb69..1048a88186 100644 --- a/packages/core/client/src/schema-component/antd/action/index.tsx +++ b/packages/core/client/src/schema-component/antd/action/index.tsx @@ -16,5 +16,6 @@ export * from './hooks/useGetAriaLabelOfAction'; export * from './hooks/useGetAriaLabelOfDrawer'; export * from './hooks/useGetAriaLabelOfModal'; export * from './hooks/useGetAriaLabelOfPopover'; +export * from './hooks/useGetAfterSuccessVariablesOptions'; export * from './types'; export * from './zIndexContext'; diff --git a/packages/core/client/src/schema-component/antd/appends-tree-select/AppendsTreeSelect.tsx b/packages/core/client/src/schema-component/antd/appends-tree-select/AppendsTreeSelect.tsx index e62aea2aac..e372f451f9 100644 --- a/packages/core/client/src/schema-component/antd/appends-tree-select/AppendsTreeSelect.tsx +++ b/packages/core/client/src/schema-component/antd/appends-tree-select/AppendsTreeSelect.tsx @@ -7,12 +7,13 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -import { CloseCircleFilled } from '@ant-design/icons'; +import { CloseCircleFilled, DownOutlined } from '@ant-design/icons'; import { Tag, TreeSelect } from 'antd'; -import type { DefaultOptionType, TreeSelectProps } from 'rc-tree-select/es/TreeSelect'; +import type { TreeSelectProps } from 'rc-tree-select/es/TreeSelect'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { CollectionFieldOptions_deprecated, parseCollectionName, useApp, useCompile } from '../../..'; +import { DefaultOptionType } from 'antd/es/select'; export type AppendsTreeSelectProps = { value: string[] | string; @@ -261,6 +262,7 @@ export const AppendsTreeSelect: React.FC} /> ); }; diff --git a/packages/core/client/src/schema-component/antd/association-field/AssociationFieldProvider.tsx b/packages/core/client/src/schema-component/antd/association-field/AssociationFieldProvider.tsx index 190a5e6f23..740d66fd5f 100644 --- a/packages/core/client/src/schema-component/antd/association-field/AssociationFieldProvider.tsx +++ b/packages/core/client/src/schema-component/antd/association-field/AssociationFieldProvider.tsx @@ -14,6 +14,7 @@ import React, { useEffect, useMemo, useState } from 'react'; import { useAPIClient, useRequest } from '../../../api-client'; import { useCollectionManager } from '../../../data-source/collection'; import { markRecordAsNew } from '../../../data-source/collection-record/isNewRecord'; +import { getDataSourceHeaders } from '../../../data-source/utils'; import { useKeepAlive } from '../../../route-switch/antd/admin-layout/KeepAlive'; import { useSchemaComponentContext } from '../../hooks'; import { AssociationFieldContext } from './context'; @@ -67,9 +68,11 @@ export const AssociationFieldProvider = observer( if (_.isUndefined(ids) || _.isNil(ids) || _.isNaN(ids)) { return Promise.reject(null); } + return api.request({ resource: collectionField.target, action: Array.isArray(ids) ? 'list' : 'get', + headers: getDataSourceHeaders(cm?.dataSource?.key), params: { filter: { [targetKey]: ids, diff --git a/packages/core/client/src/schema-component/antd/association-field/AssociationSelect.tsx b/packages/core/client/src/schema-component/antd/association-field/AssociationSelect.tsx index 57debf2402..ed9969a523 100644 --- a/packages/core/client/src/schema-component/antd/association-field/AssociationSelect.tsx +++ b/packages/core/client/src/schema-component/antd/association-field/AssociationSelect.tsx @@ -14,22 +14,28 @@ import { uid } from '@formily/shared'; import { Space, message } from 'antd'; import { isEqual } from 'lodash'; import { isFunction } from 'mathjs'; -import React, { useEffect, useState, useContext } from 'react'; +import React, { useContext, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ClearCollectionFieldContext, NocoBaseRecursionField, RecordProvider, + SchemaComponentContext, useAPIClient, useCollectionRecordData, - SchemaComponentContext, + useCollectionManager_deprecated, } from '../../../'; -import { Action } from '../action'; +import { VariablePopupRecordProvider } from '../../../modules/variable/variablesProvider/VariablePopupRecordProvider'; import { isVariable } from '../../../variables/utils/isVariable'; import { getInnermostKeyAndValue } from '../../common/utils/uitls'; +import { Action } from '../action'; import { RemoteSelect, RemoteSelectProps } from '../remote-select'; import useServiceOptions, { useAssociationFieldContext } from './hooks'; -import { VariablePopupRecordProvider } from '../../../modules/variable/variablesProvider/VariablePopupRecordProvider'; + +const removeIfKeyEmpty = (obj, filterTargetKey) => { + if (!obj || typeof obj !== 'object' || !filterTargetKey || Array.isArray(obj)) return obj; + return !obj[filterTargetKey] ? null : obj; +}; export const AssociationFieldAddNewer = (props) => { const schemaComponentCtxValue = useContext(SchemaComponentContext); @@ -69,6 +75,11 @@ export const filterAnalyses = (filters): any[] => { return results; }; +function getFieldPath(str) { + const lastIndex = str.lastIndexOf('.'); + return lastIndex === -1 ? str : str.slice(0, lastIndex); +} + const InternalAssociationSelect = observer( (props: AssociationSelectProps) => { const { objectValue = true, addMode: propsAddMode, ...rest } = props; @@ -88,6 +99,9 @@ const InternalAssociationSelect = observer( const resource = api.resource(collectionField.target); const recordData = useCollectionRecordData(); const schemaComponentCtxValue = useContext(SchemaComponentContext); + const { getCollection } = useCollectionManager_deprecated(); + const associationCollection = getCollection(collectionField.target); + const { filterTargetKey } = associationCollection; useEffect(() => { const initValue = isVariable(field.value) ? undefined : field.value; @@ -100,11 +114,14 @@ const InternalAssociationSelect = observer( //支持深层次子表单 onFieldInputValueChange('*', (fieldPath: any) => { const linkageFields = filterAnalyses(field.componentProps?.service?.params?.filter) || []; + const linageFieldEntire = getFieldPath(fieldPath.address.entire); + const targetFieldEntire = getFieldPath(field.address.entire); if ( linkageFields.includes(fieldPath?.props?.name) && field.value && isEqual(fieldPath?.indexes, field?.indexes) && - fieldPath?.props?.name !== field.props.name + fieldPath?.props?.name !== field.props.name && + (!field?.indexes?.length || isEqual(linageFieldEntire, targetFieldEntire)) ) { field.setValue(null); setInnerValue(null); @@ -151,7 +168,6 @@ const InternalAssociationSelect = observer(
); }; - console.log(fieldSchema); return (
@@ -160,7 +176,7 @@ const InternalAssociationSelect = observer( {...rest} size={'middle'} objectValue={objectValue} - value={value || innerValue} + value={removeIfKeyEmpty(value || innerValue, filterTargetKey)} service={service} onChange={(value) => { const val = value?.length !== 0 ? value : null; diff --git a/packages/core/client/src/schema-component/antd/association-field/InternalCascadeSelect.tsx b/packages/core/client/src/schema-component/antd/association-field/InternalCascadeSelect.tsx index a03c545b2d..b59dec00ad 100644 --- a/packages/core/client/src/schema-component/antd/association-field/InternalCascadeSelect.tsx +++ b/packages/core/client/src/schema-component/antd/association-field/InternalCascadeSelect.tsx @@ -13,6 +13,8 @@ import { FormProvider, connect, createSchemaField, observer, useField, useFieldS import { uid } from '@formily/shared'; import { Select as AntdSelect, Input, Space, Spin, Tag } from 'antd'; import dayjs from 'dayjs'; +import { css } from '@emotion/css'; +import { debounce } from 'lodash'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useAPIClient, useCollectionManager_deprecated } from '../../../'; @@ -130,7 +132,7 @@ const CascadeSelect = connect((props) => { const response = await resource.list({ pageSize: 200, params: service?.params, - filter: mergeFilter([filter]), + filter: mergeFilter([filter, service?.params?.filter]), tree: !filter.parentId ? true : undefined, }); return response?.data?.data; @@ -152,7 +154,11 @@ const CascadeSelect = connect((props) => { } else { associationField.value = option; } - onChange?.(options); + if (options.length === 1 && !options[0].value) { + onChange?.(null); + } else { + onChange?.(options); + } }; const onDropdownVisibleChange = async (visible, selectedValue, index) => { @@ -238,28 +244,38 @@ export const InternalCascadeSelect = observer( const fieldSchema = useFieldSchema(); const { loading, data: formData } = useDataBlockRequest() || {}; const initialValue = formData?.data?.[fieldSchema.name]; + + const handleFormValuesChange = debounce((form) => { + if (collectionField.interface === 'm2o') { + // 对 m2o 类型字段,提取最后一个非 null 值 + const value = extractLastNonNullValueObjects(form.values?.[fieldSchema.name]); + setTimeout(() => { + form.setValuesIn(fieldSchema.name, value); + field.value = value; + }); + } else { + // 对 select_array 类型字段,过滤掉空对象 + const value = extractLastNonNullValueObjects(form.values?.select_array).filter( + (v) => v && Object.keys(v).length > 0, + ); + setTimeout(() => { + field.value = value; + }); + } + }, 300); + useEffect(() => { const id = uid(); selectForm.addEffects(id, () => { onFormValuesChange((form) => { - if (collectionField.interface === 'm2o') { - const value = extractLastNonNullValueObjects(form.values?.[fieldSchema.name]); - setTimeout(() => { - form.setValuesIn(fieldSchema.name, value); - field.value = value; - }); - } else { - const value = extractLastNonNullValueObjects(form.values?.select_array).filter( - (v) => v && Object.keys(v).length > 0, - ); - setTimeout(() => { - field.value = value; - }); - } + handleFormValuesChange(form); }); }); + return () => { selectForm.removeEffects(id); + // 清除防抖定时器 + handleFormValuesChange.cancel(); }; }, []); @@ -282,6 +298,24 @@ export const InternalCascadeSelect = observer( items: { type: 'void', 'x-component': 'Space', + 'x-component-props': { + style: { + width: '100%', + display: 'flex', + }, + className: css` + .ant-formily-item-control { + max-width: 100% !important; + } + .ant-space-item:nth-child(1) { + flex: 0.1; + } + + .ant-space-item:nth-child(2) { + flex: 3; + } + `, + }, properties: { sort: { type: 'void', diff --git a/packages/core/client/src/schema-component/antd/association-field/InternalPicker.tsx b/packages/core/client/src/schema-component/antd/association-field/InternalPicker.tsx index afe85386bc..43f32ea728 100644 --- a/packages/core/client/src/schema-component/antd/association-field/InternalPicker.tsx +++ b/packages/core/client/src/schema-component/antd/association-field/InternalPicker.tsx @@ -8,11 +8,13 @@ */ import { observer, useField, useFieldSchema } from '@formily/react'; +import { transformMultiColumnToSingleColumn } from '@nocobase/utils/client'; import { Select, Space } from 'antd'; import { differenceBy, unionBy } from 'lodash'; import React, { useContext, useMemo, useState } from 'react'; import { FormProvider, + PopupSettingsProvider, RecordPickerContext, RecordPickerProvider, SchemaComponentOptions, @@ -24,6 +26,7 @@ import { NocoBaseRecursionField, RecordProvider, useCollectionRecordData, + useMobileLayout, } from '../../..'; import { useFormBlockContext } from '../../../block-provider/FormBlockProvider'; import { @@ -117,6 +120,7 @@ export const InternalPicker = observer( collectionField, currentFormCollection: collectionName, }; + const { isMobileLayout } = useMobileLayout(); const getValue = () => { if (multiple == null) return null; @@ -147,8 +151,20 @@ export const InternalPicker = observer( }, }; }; + const scope = useMemo( + () => ({ + usePickActionProps, + useTableSelectorProps, + }), + [], + ); + const newSchema = useMemo( + () => (isMobileLayout ? transformMultiColumnToSingleColumn(fieldSchema) : fieldSchema), + [isMobileLayout, fieldSchema], + ); + return ( - <> +
- - - Users + + + + + Users +
{ expect(container).toMatchInlineSnapshot(`