mirror of
https://gitee.com/nocobase/nocobase.git
synced 2025-05-05 13:39:24 +08:00
Merge branch 'develop' into T-1952
This commit is contained in:
commit
048f05156a
78
CHANGELOG.md
78
CHANGELOG.md
@ -5,6 +5,84 @@ 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
|
||||
|
@ -5,6 +5,84 @@
|
||||
格式基于 [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
|
||||
|
||||
### 🐛 修复
|
||||
|
@ -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.
|
||||
|
||||
|
@ -2,14 +2,10 @@
|
||||
|
||||
https://github.com/user-attachments/assets/cf08bfe5-e6e6-453c-8b96-350a6a8bed17
|
||||
|
||||
## ご協力ありがとうございます!
|
||||
<p align="center">
|
||||
<a href="https://trendshift.io/repositories/4112" target="_blank"><img src="https://trendshift.io/api/badge/repositories/4112" alt="nocobase%2Fnocobase | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
|
||||
<a href="https://www.producthunt.com/posts/nocobase?embed=true&utm_source=badge-top-post-topic-badge&utm_medium=badge&utm_souce=badge-nocobase" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/top-post-topic-badge.svg?post_id=456520&theme=light&period=weekly&topic_id=267" alt="NocoBase - Scalability-first, open-source no-code platform | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
|
||||
## リリースノート
|
||||
|
||||
リリースノートは[ブログ](https://www.nocobase.com/ja/blog/timeline)で随時更新され、週ごとにまとめて公開しています。
|
||||
</p>
|
||||
|
||||
## 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. データモデル駆動
|
||||
|
22
README.md
22
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!
|
||||
|
||||
<p align="center">
|
||||
<a href="https://trendshift.io/repositories/4112" target="_blank"><img src="https://trendshift.io/api/badge/repositories/4112" alt="nocobase%2Fnocobase | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
|
||||
<a href="https://www.producthunt.com/posts/nocobase?embed=true&utm_source=badge-top-post-topic-badge&utm_medium=badge&utm_souce=badge-nocobase" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/top-post-topic-badge.svg?post_id=456520&theme=light&period=weekly&topic_id=267" alt="NocoBase - Scalability-first, open-source no-code platform | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
|
||||
## Release Notes
|
||||
|
||||
Our [blog](https://www.nocobase.com/en/blog/timeline) is regularly updated with release notes and provides a weekly summary.
|
||||
</p>
|
||||
|
||||
## 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
|
||||
|
@ -2,13 +2,10 @@
|
||||
|
||||
https://github.com/nocobase/nocobase/assets/1267426/29623e45-9a48-4598-bb9e-9dd173ade553
|
||||
|
||||
## 感谢支持
|
||||
<p align="center">
|
||||
<a href="https://trendshift.io/repositories/4112" target="_blank"><img src="https://trendshift.io/api/badge/repositories/4112" alt="nocobase%2Fnocobase | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
|
||||
<a href="https://www.producthunt.com/posts/nocobase?embed=true&utm_source=badge-top-post-topic-badge&utm_medium=badge&utm_souce=badge-nocobase" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/top-post-topic-badge.svg?post_id=456520&theme=light&period=weekly&topic_id=267" alt="NocoBase - Scalability-first, open-source no-code platform | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
|
||||
## 发布日志
|
||||
我们的[博客](https://www.nocobase.com/cn/blog/timeline)会及时更新发布日志,并每周进行汇总。
|
||||
</p>
|
||||
|
||||
## 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. 数据模型驱动
|
||||
|
@ -150,6 +150,9 @@ export class APIClient extends APIClientSDK {
|
||||
}
|
||||
return [{ message }];
|
||||
}
|
||||
if (error?.response?.data?.error) {
|
||||
return [error?.response?.data?.error];
|
||||
}
|
||||
return (
|
||||
error?.response?.data?.errors ||
|
||||
error?.response?.data?.messages ||
|
||||
|
@ -11,10 +11,11 @@ import React, { FC } from 'react';
|
||||
import { MainComponent } from './MainComponent';
|
||||
|
||||
const Loading: FC = () => <div>Loading...</div>;
|
||||
const AppError: FC<{ error: Error }> = ({ error }) => {
|
||||
const AppError: FC<{ error: Error & { title?: string } }> = ({ error }) => {
|
||||
const title = error?.title || 'App Error';
|
||||
return (
|
||||
<div>
|
||||
<div>App Error</div>
|
||||
<div>{title}</div>
|
||||
{error?.message}
|
||||
{process.env.__TEST__ && error?.stack}
|
||||
</div>
|
||||
|
@ -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 <SchemaInitializerMenu items={menuItems}></SchemaInitializerMenu>;
|
||||
return <SchemaInitializerMenu disabled={disabled} items={menuItems}></SchemaInitializerMenu>;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
|
@ -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")}}',
|
||||
|
@ -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")}}',
|
||||
|
@ -31,6 +31,12 @@ export class TextareaFieldInterface extends CollectionFieldInterface {
|
||||
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)) {
|
||||
|
@ -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<any>(null);
|
||||
return (
|
||||
@ -36,6 +43,7 @@ export function SelectWithTitle({ title, defaultValue, onChange, options, fieldN
|
||||
>
|
||||
{title}
|
||||
<Select
|
||||
{...others}
|
||||
open={open}
|
||||
data-testid={`select-${title}`}
|
||||
popupMatchSelectWidth={false}
|
||||
|
@ -104,12 +104,31 @@ const CollectionFieldInternalField = (props) => {
|
||||
const dynamicProps = useDynamicComponentProps(uiSchema?.['x-use-component-props'], props);
|
||||
|
||||
useEffect(() => {
|
||||
// There seems to be a bug in formily where after setting a field to readPretty, switching to editable,
|
||||
// then back to readPretty, and refreshing the page, the field remains in editable state. The expected state is readPretty.
|
||||
// This code is meant to fix this issue.
|
||||
/**
|
||||
* There seems to be a bug in formily where after setting a field to readPretty, switching to editable,
|
||||
* then back to readPretty, and refreshing the page, the field remains in editable state. The expected state is readPretty.
|
||||
* This code is meant to fix this issue.
|
||||
*/
|
||||
if (fieldSchema['x-read-pretty'] === true && !field.readPretty) {
|
||||
field.readPretty = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* This solves the issue: After creating a form and setting a field to "read-only", the field remains editable when refreshing the page and reopening the dialog.
|
||||
*
|
||||
* Note: This might be a bug in Formily
|
||||
* When both x-disabled and x-read-pretty exist in the Schema:
|
||||
* - If x-disabled appears before x-read-pretty in the Schema JSON, the disabled state becomes ineffective
|
||||
* - The reason is that during field instance initialization, field.disabled is set before field.readPretty, which causes the pattern value to be changed to 'editable'
|
||||
* - This issue is related to the order of JSON fields, which might return different orders in different environments (databases), thus making the issue inconsistent to reproduce
|
||||
*
|
||||
* Reference to Formily source code:
|
||||
* 1. Setting readPretty may cause pattern to be changed to 'editable': https://github.com/alibaba/formily/blob/d4bb96c40e7918210b1bd7d57b8fadee0cfe4b26/packages/core/src/models/BaseField.ts#L208-L224
|
||||
* 2. The execution order of the each method depends on the order of JSON fields: https://github.com/alibaba/formily/blob/123d536b6076196e00b4e02ee160d72480359f54/packages/json-schema/src/schema.ts#L486-L519
|
||||
*/
|
||||
if (fieldSchema['x-disabled'] === true) {
|
||||
field.disabled = true;
|
||||
}
|
||||
field.data = field.data || {};
|
||||
field.data.dataSource = uiSchema?.enum;
|
||||
}, [field, fieldSchema]);
|
||||
|
@ -259,6 +259,7 @@
|
||||
"Parent collection fields": "父表字段",
|
||||
"Basic": "基本类型",
|
||||
"Single line text": "单行文本",
|
||||
"Automatically remove heading and tailing spaces": "自动去除首尾空白字符",
|
||||
"Long text": "多行文本",
|
||||
"Phone": "手机号码",
|
||||
"Email": "电子邮箱",
|
||||
@ -1098,5 +1099,6 @@
|
||||
"Font Weight": "字体粗细",
|
||||
"Font Style": "字体样式",
|
||||
"Italic": "斜体",
|
||||
"Response record":"响应结果记录"
|
||||
"Response record":"响应结果记录",
|
||||
"Colon":"冒号"
|
||||
}
|
||||
|
@ -96,7 +96,7 @@ export const FilterCollectionFieldInternalField: React.FC = (props: Props) => {
|
||||
const originalProps =
|
||||
compile({ ...(operator?.schema?.['x-component-props'] || {}), ...(uiSchema['x-component-props'] || {}) }) || {};
|
||||
|
||||
field.componentProps = merge(originalProps, field.componentProps || {}, dynamicProps || {});
|
||||
field.componentProps = merge(field.componentProps || {}, originalProps, dynamicProps || {});
|
||||
}, [uiSchemaOrigin]);
|
||||
|
||||
if (!uiSchemaOrigin) return null;
|
||||
|
@ -74,7 +74,7 @@ const useErrorProps = (app: Application, error: any) => {
|
||||
}
|
||||
};
|
||||
|
||||
const AppError: FC<{ error: Error; app: Application }> = observer(
|
||||
const AppError: FC<{ error: Error & { title?: string }; app: Application }> = observer(
|
||||
({ app, error }) => {
|
||||
const props = getProps(app);
|
||||
return (
|
||||
@ -87,7 +87,7 @@ const AppError: FC<{ error: Error; app: Application }> = observer(
|
||||
transform: translate(0, -50%);
|
||||
`}
|
||||
status="error"
|
||||
title={app.i18n.t('App error')}
|
||||
title={error?.title || app.i18n.t('App error', { ns: 'client' })}
|
||||
subTitle={app.i18n.t(error?.message)}
|
||||
{...props}
|
||||
extra={[
|
||||
|
@ -538,6 +538,7 @@ const RenderButtonInner = observer(
|
||||
designerProps: any;
|
||||
title: string;
|
||||
isLink?: boolean;
|
||||
onlyIcon?: boolean;
|
||||
}) => {
|
||||
const {
|
||||
designable,
|
||||
@ -559,6 +560,7 @@ const RenderButtonInner = observer(
|
||||
designerProps,
|
||||
title,
|
||||
isLink,
|
||||
onlyIcon,
|
||||
...others
|
||||
} = props;
|
||||
const debouncedClick = useCallback(
|
||||
@ -602,7 +604,7 @@ const RenderButtonInner = observer(
|
||||
type={type === 'danger' ? undefined : type}
|
||||
title={actionTitle}
|
||||
>
|
||||
{actionTitle && (
|
||||
{!onlyIcon && actionTitle && (
|
||||
<span className={icon ? 'nb-action-title' : null} style={linkStyle}>
|
||||
{actionTitle}
|
||||
</span>
|
||||
|
@ -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,
|
||||
|
@ -23,6 +23,7 @@ import {
|
||||
SchemaComponentContext,
|
||||
useAPIClient,
|
||||
useCollectionRecordData,
|
||||
useCollectionManager_deprecated,
|
||||
} from '../../../';
|
||||
import { VariablePopupRecordProvider } from '../../../modules/variable/variablesProvider/VariablePopupRecordProvider';
|
||||
import { isVariable } from '../../../variables/utils/isVariable';
|
||||
@ -31,6 +32,11 @@ import { Action } from '../action';
|
||||
import { RemoteSelect, RemoteSelectProps } from '../remote-select';
|
||||
import useServiceOptions, { useAssociationFieldContext } from './hooks';
|
||||
|
||||
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);
|
||||
return (
|
||||
@ -93,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;
|
||||
@ -167,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;
|
||||
|
@ -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 '../../../';
|
||||
@ -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',
|
||||
|
@ -107,7 +107,7 @@ AssociationFilter.BlockDesigner = AssociationFilterBlockDesigner;
|
||||
AssociationFilter.useAssociationField = () => {
|
||||
const fieldSchema = useFieldSchema();
|
||||
const collection = useCollection();
|
||||
return React.useMemo(() => collection.getField(fieldSchema.name as any), [fieldSchema.name]);
|
||||
return React.useMemo(() => collection?.getField(fieldSchema?.name as any), [fieldSchema?.name]);
|
||||
};
|
||||
|
||||
export class AssociationFilterPlugin extends Plugin {
|
||||
|
@ -54,7 +54,7 @@ describe('CollectionSelect', () => {
|
||||
role="button"
|
||||
>
|
||||
<div
|
||||
class="css-1yh5po ant-formily-item ant-formily-item-layout-horizontal ant-formily-item-feedback-layout-loose ant-formily-item-label-align-right ant-formily-item-control-align-left css-dev-only-do-not-override-1rquknz"
|
||||
class="css-9mlexe ant-formily-item ant-formily-item-layout-horizontal ant-formily-item-feedback-layout-loose ant-formily-item-label-align-right ant-formily-item-control-align-left css-dev-only-do-not-override-1rquknz"
|
||||
>
|
||||
<div
|
||||
class="ant-formily-item-label"
|
||||
@ -195,7 +195,7 @@ describe('CollectionSelect', () => {
|
||||
role="button"
|
||||
>
|
||||
<div
|
||||
class="css-1yh5po ant-formily-item ant-formily-item-layout-horizontal ant-formily-item-feedback-layout-loose ant-formily-item-label-align-right ant-formily-item-control-align-left css-dev-only-do-not-override-1rquknz"
|
||||
class="css-9mlexe ant-formily-item ant-formily-item-layout-horizontal ant-formily-item-feedback-layout-loose ant-formily-item-label-align-right ant-formily-item-control-align-left css-dev-only-do-not-override-1rquknz"
|
||||
>
|
||||
<div
|
||||
class="ant-formily-item-label"
|
||||
|
@ -65,7 +65,6 @@ export const DynamicComponent = (props: Props) => {
|
||||
...props.style,
|
||||
},
|
||||
utc: false,
|
||||
underFilter: true,
|
||||
}),
|
||||
name: 'value',
|
||||
'x-read-pretty': false,
|
||||
|
@ -37,6 +37,15 @@ const formItemWrapCss = css`
|
||||
.ant-description-textarea img {
|
||||
max-width: 100%;
|
||||
}
|
||||
&.ant-formily-item-layout-vertical .ant-formily-item-label {
|
||||
display: inline;
|
||||
.ant-formily-item-label-tooltip-icon {
|
||||
display: inline;
|
||||
}
|
||||
.ant-formily-item-label-content {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const formItemLabelCss = css`
|
||||
@ -44,7 +53,7 @@ const formItemLabelCss = css`
|
||||
padding: 0px !important;
|
||||
}
|
||||
> .ant-formily-item-label {
|
||||
display: none;
|
||||
display: none !important;
|
||||
}
|
||||
`;
|
||||
|
||||
@ -83,7 +92,6 @@ export const FormItem: any = withDynamicSchemaProps(
|
||||
[formItemLabelCss]: showTitle === false,
|
||||
});
|
||||
}, [showTitle]);
|
||||
|
||||
// 联动规则中的“隐藏保留值”的效果
|
||||
if (field.data?.hidden) {
|
||||
return null;
|
||||
|
@ -50,19 +50,20 @@ const FormComponent: React.FC<FormProps> = (props) => {
|
||||
labelAlign = 'left',
|
||||
labelWidth = 120,
|
||||
labelWrap = true,
|
||||
colon = true,
|
||||
} = cardItemSchema?.['x-component-props'] || {};
|
||||
const { isMobileLayout } = useMobileLayout();
|
||||
const newSchema = useMemo(
|
||||
() => (isMobileLayout ? transformMultiColumnToSingleColumn(fieldSchema) : fieldSchema),
|
||||
[fieldSchema, isMobileLayout],
|
||||
);
|
||||
|
||||
return (
|
||||
<FieldContext.Provider value={undefined}>
|
||||
<FormContext.Provider value={form}>
|
||||
<FormLayout
|
||||
layout={layout}
|
||||
{...others}
|
||||
colon={colon}
|
||||
labelAlign={labelAlign}
|
||||
labelWidth={layout === 'horizontal' ? labelWidth : null}
|
||||
labelWrap={labelWrap}
|
||||
|
@ -20,7 +20,6 @@ import { FilterBlockProvider } from '../../../filter-provider/FilterProvider';
|
||||
import {
|
||||
NocoBaseRecursionField,
|
||||
RefreshComponentProvider,
|
||||
useRefreshComponent,
|
||||
useRefreshFieldSchema,
|
||||
} from '../../../formily/NocoBaseRecursionField';
|
||||
import { DndContext, DndContextProps } from '../../common/dnd-context';
|
||||
@ -379,11 +378,9 @@ export const Grid: any = observer(
|
||||
}, [fieldSchema, render, InitializerComponent, showDivider]);
|
||||
|
||||
const refreshFieldSchema = useRefreshFieldSchema();
|
||||
const refreshComponent = useRefreshComponent();
|
||||
const refresh = useCallback(() => {
|
||||
refreshFieldSchema?.();
|
||||
refreshComponent?.();
|
||||
}, [refreshComponent, refreshFieldSchema]);
|
||||
}, [refreshFieldSchema]);
|
||||
|
||||
return (
|
||||
<RefreshComponentProvider refresh={refresh}>
|
||||
|
@ -11,22 +11,40 @@ import { LoadingOutlined } from '@ant-design/icons';
|
||||
import { connect, mapProps, mapReadPretty } from '@formily/react';
|
||||
import { Input as AntdInput } from 'antd';
|
||||
import { InputProps, TextAreaProps } from 'antd/es/input';
|
||||
import React from 'react';
|
||||
import React, { useCallback } from 'react';
|
||||
import { JSONTextAreaProps, Json } from './Json';
|
||||
import { InputReadPrettyComposed, ReadPretty } from './ReadPretty';
|
||||
|
||||
export { ReadPretty as InputReadPretty } from './ReadPretty';
|
||||
|
||||
type ComposedInput = React.FC<InputProps> & {
|
||||
type ComposedInput = React.FC<NocoBaseInputProps> & {
|
||||
ReadPretty: InputReadPrettyComposed['Input'];
|
||||
TextArea: React.FC<TextAreaProps> & { ReadPretty: InputReadPrettyComposed['TextArea'] };
|
||||
URL: React.FC<InputProps> & { ReadPretty: InputReadPrettyComposed['URL'] };
|
||||
JSON: React.FC<JSONTextAreaProps> & { ReadPretty: InputReadPrettyComposed['JSON'] };
|
||||
};
|
||||
|
||||
export type NocoBaseInputProps = InputProps & {
|
||||
trim?: boolean;
|
||||
};
|
||||
|
||||
function InputInner(props: NocoBaseInputProps) {
|
||||
const { onChange, trim, ...others } = props;
|
||||
const handleChange = useCallback(
|
||||
(ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (trim) {
|
||||
ev.target.value = ev.target.value.trim();
|
||||
}
|
||||
onChange?.(ev);
|
||||
},
|
||||
[onChange, trim],
|
||||
);
|
||||
return <AntdInput {...others} onChange={handleChange} />;
|
||||
}
|
||||
|
||||
export const Input: ComposedInput = Object.assign(
|
||||
connect(
|
||||
AntdInput,
|
||||
InputInner,
|
||||
mapProps((props, field) => {
|
||||
return {
|
||||
...props,
|
||||
|
@ -1,4 +1,3 @@
|
||||
|
||||
import React from 'react';
|
||||
import { mockApp } from '@nocobase/client/demo-utils';
|
||||
import { SchemaComponent, Plugin, ISchema } from '@nocobase/client';
|
||||
@ -15,15 +14,25 @@ const schema: ISchema = {
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Input',
|
||||
},
|
||||
trim: {
|
||||
type: 'string',
|
||||
title: `Trim heading and tailing spaces`,
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Input',
|
||||
'x-component-props': {
|
||||
trim: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
const Demo = () => {
|
||||
return <SchemaComponent schema={schema} />;
|
||||
};
|
||||
|
||||
class DemoPlugin extends Plugin {
|
||||
async load() {
|
||||
this.app.router.add('root', { path: '/', Component: Demo })
|
||||
this.app.router.add('root', { path: '/', Component: Demo });
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -10,6 +10,7 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { useForm } from '@formily/react';
|
||||
import { Space, theme } from 'antd';
|
||||
import type { CascaderProps, DefaultOptionType } from 'antd/lib/cascader';
|
||||
import useInputStyle from 'antd/es/input/style';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { renderToString } from 'react-dom/server';
|
||||
@ -110,7 +111,7 @@ function renderHTML(exp: string, keyLabelMap, delimiters: [string, string] = ['{
|
||||
});
|
||||
}
|
||||
|
||||
function createOptionsValueLabelMap(options: any[], fieldNames = { value: 'value', label: 'label' }) {
|
||||
function createOptionsValueLabelMap(options: any[], fieldNames: CascaderProps['fieldNames'] = defaultFieldNames) {
|
||||
const map = new Map<string, string[]>();
|
||||
for (const option of options) {
|
||||
map.set(option[fieldNames.value], [option[fieldNames.label]]);
|
||||
@ -220,10 +221,24 @@ function useVariablesFromValue(value: string, delimiters: [string, string] = ['{
|
||||
}, [value, delimitersString]);
|
||||
}
|
||||
|
||||
export function TextArea(props) {
|
||||
export type TextAreaProps = {
|
||||
value?: string;
|
||||
scope?: Partial<DefaultOptionType>[] | (() => Partial<DefaultOptionType>[]);
|
||||
onChange?(value: string): void;
|
||||
disabled?: boolean;
|
||||
changeOnSelect?: CascaderProps['changeOnSelect'];
|
||||
style?: React.CSSProperties;
|
||||
fieldNames?: CascaderProps['fieldNames'];
|
||||
trim?: boolean;
|
||||
delimiters?: [string, string];
|
||||
addonBefore?: React.ReactNode;
|
||||
};
|
||||
|
||||
export function TextArea(props: TextAreaProps) {
|
||||
const { wrapSSR, hashId, componentCls } = useStyles();
|
||||
const { scope, onChange, changeOnSelect, style, fieldNames, delimiters = ['{{', '}}'], addonBefore } = props;
|
||||
const value = typeof props.value === 'string' ? props.value : props.value == null ? '' : props.value.toString();
|
||||
const { scope, changeOnSelect, style, fieldNames, delimiters = ['{{', '}}'], addonBefore, trim = true } = props;
|
||||
const value =
|
||||
typeof props.value === 'string' ? props.value : props.value == null ? '' : (props.value as any).toString();
|
||||
const variables = useVariablesFromValue(value, delimiters);
|
||||
const inputRef = useRef<HTMLDivElement>(null);
|
||||
const [options, setOptions] = useState([]);
|
||||
@ -241,6 +256,14 @@ export function TextArea(props) {
|
||||
const { token } = theme.useToken();
|
||||
const delimitersString = delimiters.join(' ');
|
||||
|
||||
const onChange = useCallback(
|
||||
(target: HTMLDivElement) => {
|
||||
const v = getValue(target, delimiters);
|
||||
props.onChange?.(trim ? v.trim() : v);
|
||||
},
|
||||
[delimitersString, props.onChange, trim],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
preloadOptions(scope, variables)
|
||||
.then((preloaded) => {
|
||||
@ -324,9 +347,9 @@ export function TextArea(props) {
|
||||
|
||||
setChanged(true);
|
||||
setRange(getCurrentRange(current));
|
||||
onChange(getValue(current, delimiters));
|
||||
onChange(current);
|
||||
},
|
||||
[keyLabelMap, onChange, range, delimitersString],
|
||||
[keyLabelMap, onChange, range],
|
||||
);
|
||||
|
||||
const onInput = useCallback(
|
||||
@ -336,9 +359,9 @@ export function TextArea(props) {
|
||||
}
|
||||
setChanged(true);
|
||||
setRange(getCurrentRange(currentTarget));
|
||||
onChange(getValue(currentTarget, delimiters));
|
||||
onChange(currentTarget);
|
||||
},
|
||||
[ime, onChange, delimitersString],
|
||||
[ime, onChange],
|
||||
);
|
||||
|
||||
const onBlur = useCallback(function ({ currentTarget }) {
|
||||
@ -360,9 +383,9 @@ export function TextArea(props) {
|
||||
setIME(false);
|
||||
setChanged(true);
|
||||
setRange(getCurrentRange(currentTarget));
|
||||
onChange(getValue(currentTarget, delimiters));
|
||||
onChange(currentTarget);
|
||||
},
|
||||
[onChange, delimitersString],
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const onPaste = useCallback(
|
||||
@ -393,9 +416,9 @@ export function TextArea(props) {
|
||||
setChanged(true);
|
||||
pasteHTML(ev.currentTarget, sanitizedHTML);
|
||||
setRange(getCurrentRange(ev.currentTarget));
|
||||
onChange(getValue(ev.currentTarget, delimiters));
|
||||
onChange(ev.currentTarget);
|
||||
},
|
||||
[onChange, delimitersString],
|
||||
[onChange],
|
||||
);
|
||||
const disabled = props.disabled || form.disabled;
|
||||
return wrapSSR(
|
||||
|
@ -96,7 +96,6 @@ export const conditionAnalyses = async (
|
||||
) => {
|
||||
const type = Object.keys(ruleGroup)[0] || '$and';
|
||||
const conditions = ruleGroup[type];
|
||||
|
||||
let results = conditions.map(async (condition) => {
|
||||
if ('$and' in condition || '$or' in condition) {
|
||||
return await conditionAnalyses({ ruleGroup: condition, variables, localVariables }, jsonLogic);
|
||||
@ -147,7 +146,10 @@ export const conditionAnalyses = async (
|
||||
if (type === '$and') {
|
||||
return every(results, (v) => v);
|
||||
} else {
|
||||
return some(results, (v) => v);
|
||||
if (results.length) {
|
||||
return some(results, (v) => v);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -471,7 +471,6 @@ export const useFilterFormItemInitializerFields = (options?: any) => {
|
||||
'x-collection-field': `${name}.${field.name}`,
|
||||
'x-component-props': {
|
||||
utc: false,
|
||||
underFilter: true,
|
||||
},
|
||||
};
|
||||
if (isAssocField(field)) {
|
||||
@ -486,7 +485,7 @@ export const useFilterFormItemInitializerFields = (options?: any) => {
|
||||
'x-decorator': 'FormItem',
|
||||
'x-use-decorator-props': 'useFormItemProps',
|
||||
'x-collection-field': `${name}.${field.name}`,
|
||||
'x-component-props': { ...field.uiSchema?.['x-component-props'], utc: false, underFilter: true },
|
||||
'x-component-props': { ...field.uiSchema?.['x-component-props'], utc: false },
|
||||
};
|
||||
}
|
||||
const resultItem = {
|
||||
@ -581,7 +580,7 @@ const associationFieldToMenu = (
|
||||
interface: field.interface,
|
||||
},
|
||||
'x-component': 'CollectionField',
|
||||
'x-component-props': { utc: false, underFilter: true },
|
||||
'x-component-props': { utc: false },
|
||||
'x-read-pretty': false,
|
||||
'x-decorator': 'FormItem',
|
||||
'x-collection-field': `${collectionName}.${schemaName}`,
|
||||
@ -698,7 +697,7 @@ export const useFilterInheritsFormItemInitializerFields = (options?) => {
|
||||
'x-component': 'CollectionField',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-collection-field': `${name}.${field.name}`,
|
||||
'x-component-props': { utc: false, underFilter: true },
|
||||
'x-component-props': { utc: false },
|
||||
'x-read-pretty': field?.uiSchema?.['x-read-pretty'],
|
||||
};
|
||||
return {
|
||||
|
@ -568,13 +568,14 @@ export interface SchemaSettingsSelectItemProps
|
||||
extends Omit<SchemaSettingsItemProps, 'onChange' | 'onClick'>,
|
||||
Omit<SelectWithTitleProps, 'title' | 'defaultValue'> {
|
||||
value?: SelectWithTitleProps['defaultValue'];
|
||||
optionRender?: (option: any, info: { index: number }) => React.ReactNode;
|
||||
}
|
||||
export const SchemaSettingsSelectItem: FC<SchemaSettingsSelectItemProps> = (props) => {
|
||||
const { title, options, value, onChange, ...others } = props;
|
||||
const { title, options, value, onChange, optionRender, ...others } = props;
|
||||
|
||||
return (
|
||||
<SchemaSettingsItem title={title} {...others}>
|
||||
<SelectWithTitle {...{ title, defaultValue: value, onChange, options }} />
|
||||
<SelectWithTitle {...{ title, defaultValue: value, onChange, options, optionRender }} />
|
||||
</SchemaSettingsItem>
|
||||
);
|
||||
};
|
||||
|
@ -107,20 +107,30 @@ export const SchemaSettingsLayoutItem = function LayoutItem() {
|
||||
},
|
||||
},
|
||||
},
|
||||
colon: {
|
||||
type: 'boolean',
|
||||
'x-content': t('Colon'),
|
||||
required: true,
|
||||
default: fieldSchema?.['x-component-props']?.colon !== false,
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Checkbox',
|
||||
},
|
||||
},
|
||||
} as ISchema
|
||||
}
|
||||
onSubmit={({ layout, labelAlign, labelWidth, labelWrap }) => {
|
||||
onSubmit={({ layout, labelAlign, labelWidth, labelWrap, colon }) => {
|
||||
const componentProps = fieldSchema['x-component-props'] || {};
|
||||
componentProps.layout = layout;
|
||||
componentProps.labelAlign = labelAlign;
|
||||
componentProps.labelWidth = layout === 'horizontal' ? labelWidth : null;
|
||||
componentProps.labelWrap = labelWrap;
|
||||
componentProps.colon = colon;
|
||||
fieldSchema['x-component-props'] = componentProps;
|
||||
field.componentProps.layout = layout;
|
||||
field.componentProps.labelAlign = labelAlign;
|
||||
field.componentProps.labelWidth = labelWidth;
|
||||
field.componentProps.labelWrap = labelWrap;
|
||||
field.componentProps.colon = colon;
|
||||
dn.emit('patch', {
|
||||
schema: {
|
||||
['x-uid']: fieldSchema['x-uid'],
|
||||
|
@ -8,7 +8,7 @@
|
||||
*/
|
||||
import { extractTemplateVariable } from '@nocobase/json-template-parser';
|
||||
|
||||
export const REGEX_OF_VARIABLE = /^\s*\{\{\s*([a-zA-Z0-9_$-.]+?)\s*\}\}\s*$/g;
|
||||
export const REGEX_OF_VARIABLE = /^\s*\{\{\s*([\p{L}0-9_$-.]+?)\s*\}\}\s*$/u;
|
||||
export const REGEX_OF_VARIABLE_IN_EXPRESSION = /\{\{\s*([a-zA-Z0-9_$-.]+?)\s*\}\}/g;
|
||||
|
||||
export const isVariable = (str: unknown) => {
|
||||
|
@ -105,4 +105,18 @@ describe('string field', () => {
|
||||
name2: 'n2111',
|
||||
});
|
||||
});
|
||||
|
||||
it('trim', async () => {
|
||||
const collection = db.collection({
|
||||
name: 'tests',
|
||||
fields: [{ type: 'string', name: 'name', trim: true }],
|
||||
});
|
||||
await db.sync();
|
||||
const model = await collection.model.create({
|
||||
name: ' n1\n ',
|
||||
});
|
||||
expect(model.toJSON()).toMatchObject({
|
||||
name: 'n1',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -52,4 +52,18 @@ describe('text field', () => {
|
||||
});
|
||||
await Test.sync();
|
||||
});
|
||||
|
||||
it('trim', async () => {
|
||||
const collection = db.collection({
|
||||
name: 'tests',
|
||||
fields: [{ type: 'text', name: 'name', trim: true }],
|
||||
});
|
||||
await db.sync();
|
||||
const model = await collection.model.create({
|
||||
name: ' n1\n ',
|
||||
});
|
||||
expect(model.toJSON()).toMatchObject({
|
||||
name: 'n1',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -159,6 +159,7 @@ describe('has many repository', () => {
|
||||
name: 'posts',
|
||||
fields: [
|
||||
{ type: 'string', name: 'title' },
|
||||
{ type: 'belongsTo', name: 'user' },
|
||||
{ type: 'belongsToMany', name: 'tags', through: 'posts_tags' },
|
||||
{ type: 'hasMany', name: 'comments' },
|
||||
{ type: 'string', name: 'status' },
|
||||
@ -480,6 +481,51 @@ describe('has many repository', () => {
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
test('destroy by pk and filter with association', async () => {
|
||||
const u1 = await User.repository.create({
|
||||
values: { name: 'u1' },
|
||||
});
|
||||
|
||||
const UserPostRepository = new HasManyRepository(User, 'posts', u1.id);
|
||||
|
||||
const p1 = await UserPostRepository.create({
|
||||
values: {
|
||||
title: 't1',
|
||||
status: 'published',
|
||||
user: u1,
|
||||
},
|
||||
});
|
||||
|
||||
const p2 = await UserPostRepository.create({
|
||||
values: {
|
||||
title: 't2',
|
||||
status: 'draft',
|
||||
user: u1,
|
||||
},
|
||||
});
|
||||
|
||||
await UserPostRepository.destroy({
|
||||
filterByTk: p1.id,
|
||||
filter: {
|
||||
user: {
|
||||
id: u1.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
await UserPostRepository.findOne({
|
||||
filterByTk: p1.id,
|
||||
}),
|
||||
).toBeNull();
|
||||
|
||||
expect(
|
||||
await UserPostRepository.findOne({
|
||||
filterByTk: p2.id,
|
||||
}),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
test('destroy by pk', async () => {
|
||||
const u1 = await User.repository.create({
|
||||
values: { name: 'u1' },
|
||||
|
@ -54,19 +54,18 @@ export class BelongsToArrayAssociation {
|
||||
return this.db.getModel(this.targetName);
|
||||
}
|
||||
|
||||
generateInclude() {
|
||||
if (this.db.sequelize.getDialect() !== 'postgres') {
|
||||
throw new Error('Filtering by many to many (array) associations is only supported on postgres');
|
||||
}
|
||||
generateInclude(parentAs?: string) {
|
||||
const targetCollection = this.db.getCollection(this.targetName);
|
||||
const targetField = targetCollection.getField(this.targetKey);
|
||||
const sourceCollection = this.db.getCollection(this.source.name);
|
||||
const foreignField = sourceCollection.getField(this.foreignKey);
|
||||
const queryInterface = this.db.sequelize.getQueryInterface();
|
||||
const left = queryInterface.quoteIdentifiers(`${this.as}.${targetField.columnName()}`);
|
||||
const right = queryInterface.quoteIdentifiers(`${this.source.collection.name}.${foreignField.columnName()}`);
|
||||
const asLeft = parentAs ? `${parentAs}->${this.as}` : this.as;
|
||||
const asRight = parentAs || this.source.collection.name;
|
||||
const left = queryInterface.quoteIdentifiers(`${asLeft}.${targetField.columnName()}`);
|
||||
const right = queryInterface.quoteIdentifiers(`${asRight}.${foreignField.columnName()}`);
|
||||
return {
|
||||
on: this.db.sequelize.literal(`${left}=any(${right})`),
|
||||
on: this.db.queryInterface.generateJoinOnForJSONArray(left, right),
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -82,6 +82,31 @@ const queryParentSQL = (options: {
|
||||
SELECT ${q(targetKeyField)} AS ${q(targetKey)}, ${q(foreignKeyField)} AS ${q(foreignKey)} FROM cte`;
|
||||
};
|
||||
|
||||
const processIncludes = (includes: any[], model: any, parentAs = '') => {
|
||||
includes.forEach((include: { association: string; include?: any[] }, index: number) => {
|
||||
// Process current level
|
||||
const association = model.associations[include.association];
|
||||
if (association?.generateInclude) {
|
||||
includes[index] = {
|
||||
...include,
|
||||
...association.generateInclude(parentAs),
|
||||
};
|
||||
}
|
||||
|
||||
// Recursively process nested includes if they exist
|
||||
if (include.include && Array.isArray(include.include) && include.include.length > 0) {
|
||||
// Get the associated model for the next level
|
||||
const nextModel = association?.target;
|
||||
if (!nextModel) {
|
||||
return;
|
||||
}
|
||||
processIncludes(include.include, nextModel, parentAs ? `${parentAs}->${association.as}` : association.as);
|
||||
}
|
||||
});
|
||||
|
||||
return includes;
|
||||
};
|
||||
|
||||
export class EagerLoadingTree {
|
||||
public root: EagerLoadingNode;
|
||||
db: Database;
|
||||
@ -252,16 +277,6 @@ export class EagerLoadingTree {
|
||||
throw new Error(`Model ${node.model.name} does not have primary key`);
|
||||
}
|
||||
|
||||
includeForFilter.forEach((include: { association: string }, index: number) => {
|
||||
const association = node.model.associations[include.association];
|
||||
if (association?.associationType == 'BelongsToArray') {
|
||||
includeForFilter[index] = {
|
||||
...include,
|
||||
...association.generateInclude(),
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// find all ids
|
||||
const ids = (
|
||||
await node.model.findAll({
|
||||
@ -270,7 +285,7 @@ export class EagerLoadingTree {
|
||||
attributes: [primaryKeyField],
|
||||
group: `${node.model.name}.${primaryKeyField}`,
|
||||
transaction,
|
||||
include: includeForFilter,
|
||||
include: processIncludes(includeForFilter, node.model),
|
||||
} as any)
|
||||
).map((row) => {
|
||||
return { row, pk: row[primaryKeyField] };
|
||||
|
@ -29,26 +29,24 @@ export class DatetimeNoTzField extends Field {
|
||||
return DatetimeNoTzTypeMySQL;
|
||||
}
|
||||
|
||||
return DataTypes.STRING;
|
||||
return DataTypes.DATE;
|
||||
}
|
||||
|
||||
init() {
|
||||
beforeSave = async (instance, options) => {
|
||||
const { name, defaultToCurrentTime, onUpdateToCurrentTime } = this.options;
|
||||
|
||||
this.beforeSave = async (instance, options) => {
|
||||
const value = instance.get(name);
|
||||
const value = instance.get(name);
|
||||
|
||||
if (!value && instance.isNewRecord && defaultToCurrentTime) {
|
||||
instance.set(name, new Date());
|
||||
return;
|
||||
}
|
||||
if (!value && instance.isNewRecord && defaultToCurrentTime) {
|
||||
instance.set(name, new Date());
|
||||
return;
|
||||
}
|
||||
|
||||
if (onUpdateToCurrentTime) {
|
||||
instance.set(name, new Date());
|
||||
return;
|
||||
}
|
||||
};
|
||||
}
|
||||
if (onUpdateToCurrentTime) {
|
||||
instance.set(name, new Date());
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
additionalSequelizeOptions(): {} {
|
||||
const { name } = this.options;
|
||||
@ -57,17 +55,14 @@ export class DatetimeNoTzField extends Field {
|
||||
const timezone = this.database.options.rawTimezone || '+00:00';
|
||||
|
||||
const isPg = this.database.inDialect('postgres');
|
||||
const isMySQLCompatibleDialect = this.database.isMySQLCompatibleDialect();
|
||||
|
||||
return {
|
||||
get() {
|
||||
const val = this.getDataValue(name);
|
||||
|
||||
if (val instanceof Date) {
|
||||
if (isPg) {
|
||||
return moment(val).format('YYYY-MM-DD HH:mm:ss');
|
||||
}
|
||||
// format to YYYY-MM-DD HH:mm:ss
|
||||
const momentVal = moment(val).utcOffset(timezone);
|
||||
const momentVal = moment(val);
|
||||
return momentVal.format('YYYY-MM-DD HH:mm:ss');
|
||||
}
|
||||
|
||||
@ -75,18 +70,24 @@ export class DatetimeNoTzField extends Field {
|
||||
},
|
||||
|
||||
set(val) {
|
||||
if (typeof val === 'string' && isIso8601(val)) {
|
||||
const momentVal = moment(val).utcOffset(timezone);
|
||||
val = momentVal.format('YYYY-MM-DD HH:mm:ss');
|
||||
if (val == null) {
|
||||
return this.setDataValue(name, null);
|
||||
}
|
||||
|
||||
if (val && val instanceof Date) {
|
||||
// format to YYYY-MM-DD HH:mm:ss
|
||||
const momentVal = moment(val).utcOffset(timezone);
|
||||
val = momentVal.format('YYYY-MM-DD HH:mm:ss');
|
||||
const dateOffset = new Date().getTimezoneOffset();
|
||||
const momentVal = moment(val);
|
||||
if ((typeof val === 'string' && isIso8601(val)) || val instanceof Date) {
|
||||
momentVal.utcOffset(timezone);
|
||||
momentVal.utcOffset(-dateOffset, true);
|
||||
}
|
||||
|
||||
return this.setDataValue(name, val);
|
||||
if (isMySQLCompatibleDialect) {
|
||||
momentVal.millisecond(0);
|
||||
}
|
||||
|
||||
const date = momentVal.toDate();
|
||||
|
||||
return this.setDataValue(name, date);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
@ -8,7 +8,7 @@
|
||||
*/
|
||||
|
||||
import { DataTypes } from 'sequelize';
|
||||
import { BaseColumnFieldOptions, Field } from './field';
|
||||
import { BaseColumnFieldOptions, Field, FieldContext } from './field';
|
||||
|
||||
export class StringField extends Field {
|
||||
get dataType() {
|
||||
@ -18,9 +18,20 @@ export class StringField extends Field {
|
||||
|
||||
return DataTypes.STRING;
|
||||
}
|
||||
|
||||
additionalSequelizeOptions() {
|
||||
const { name, trim } = this.options;
|
||||
|
||||
return {
|
||||
set(value) {
|
||||
this.setDataValue(name, trim ? value?.trim() : value);
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface StringFieldOptions extends BaseColumnFieldOptions {
|
||||
type: 'string';
|
||||
length?: number;
|
||||
trim?: boolean;
|
||||
}
|
||||
|
@ -23,9 +23,20 @@ export class TextField extends Field {
|
||||
this.options.defaultValue = null;
|
||||
}
|
||||
}
|
||||
|
||||
additionalSequelizeOptions() {
|
||||
const { name, trim } = this.options;
|
||||
|
||||
return {
|
||||
set(value) {
|
||||
this.setDataValue(name, trim ? value?.trim() : value);
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface TextFieldOptions extends BaseColumnFieldOptions {
|
||||
type: 'text';
|
||||
length?: 'tiny' | 'medium' | 'long';
|
||||
trim?: boolean;
|
||||
}
|
||||
|
@ -141,4 +141,8 @@ export default class MysqlQueryInterface extends QueryInterface {
|
||||
await this.db.sequelize.query(sql, { transaction });
|
||||
}
|
||||
}
|
||||
|
||||
public generateJoinOnForJSONArray(left: string, right: string) {
|
||||
return this.db.sequelize.literal(`JSON_CONTAINS(${right}, JSON_ARRAY(${left}))`);
|
||||
}
|
||||
}
|
||||
|
@ -232,4 +232,8 @@ $BODY$
|
||||
|
||||
return res[0]['show_create_table'];
|
||||
}
|
||||
|
||||
public generateJoinOnForJSONArray(left: string, right: string) {
|
||||
return this.db.sequelize.literal(`${left}=any(${right})`);
|
||||
}
|
||||
}
|
||||
|
@ -83,4 +83,9 @@ export default abstract class QueryInterface {
|
||||
// @ts-ignore
|
||||
return this.db.sequelize.getQueryInterface().queryGenerator.quoteIdentifier(identifier);
|
||||
}
|
||||
|
||||
public generateJoinOnForJSONArray(left: string, right: string) {
|
||||
const dialect = this.db.sequelize.getDialect();
|
||||
throw new Error(`Filtering by many to many (array) associations is not supported on ${dialect}`);
|
||||
}
|
||||
}
|
||||
|
@ -146,4 +146,8 @@ export default class SqliteQueryInterface extends QueryInterface {
|
||||
WHERE name = '${tableName}';`;
|
||||
await this.db.sequelize.query(sql, { transaction });
|
||||
}
|
||||
|
||||
public generateJoinOnForJSONArray(left: string, right: string) {
|
||||
return this.db.sequelize.literal(`${left} in (SELECT value from json_each(${right}))`);
|
||||
}
|
||||
}
|
||||
|
@ -72,7 +72,13 @@ export class HasManyRepository extends MultipleRelationRepository {
|
||||
const filterResult = this.parseFilter(options['filter'], options);
|
||||
|
||||
if (filterResult.include && filterResult.include.length > 0) {
|
||||
return await this.destroyByFilter(options['filter'], transaction);
|
||||
return await this.destroyByFilter(
|
||||
{
|
||||
filter: options['filter'],
|
||||
filterByTk: options['filterByTk'],
|
||||
},
|
||||
transaction,
|
||||
);
|
||||
}
|
||||
|
||||
where.push(filterResult.where);
|
||||
|
@ -179,9 +179,15 @@ export abstract class MultipleRelationRepository extends RelationRepository {
|
||||
return false;
|
||||
}
|
||||
|
||||
protected async destroyByFilter(filter: Filter, transaction?: Transaction) {
|
||||
protected async destroyByFilter(
|
||||
options: {
|
||||
filter?: Filter;
|
||||
filterByTk?: TargetKey | TargetKey[];
|
||||
},
|
||||
transaction?: Transaction,
|
||||
) {
|
||||
const instances = await this.find({
|
||||
filter: filter,
|
||||
...options,
|
||||
transaction,
|
||||
});
|
||||
|
||||
|
@ -7,7 +7,7 @@
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import axios, { AxiosError, AxiosInstance, AxiosRequestConfig, AxiosResponse, RawAxiosRequestHeaders } from 'axios';
|
||||
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse, RawAxiosRequestHeaders } from 'axios';
|
||||
import qs from 'qs';
|
||||
|
||||
export interface ActionParams {
|
||||
|
@ -10,6 +10,7 @@
|
||||
import dayjs from 'dayjs';
|
||||
import advancedFormat from 'dayjs/plugin/advancedFormat';
|
||||
import customParseFormat from 'dayjs/plugin/customParseFormat';
|
||||
import duration from 'dayjs/plugin/duration';
|
||||
import IsBetween from 'dayjs/plugin/isBetween';
|
||||
import IsSameOrAfter from 'dayjs/plugin/isSameOrAfter';
|
||||
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore';
|
||||
@ -35,5 +36,6 @@ dayjs.extend(weekOfYear);
|
||||
dayjs.extend(weekYear);
|
||||
dayjs.extend(customParseFormat);
|
||||
dayjs.extend(advancedFormat);
|
||||
dayjs.extend(duration);
|
||||
|
||||
export { dayjs };
|
||||
|
@ -51,7 +51,11 @@ describe('union role: full permissions', async () => {
|
||||
roles: [role1.name, role2.name],
|
||||
},
|
||||
});
|
||||
|
||||
await rootAgent.resource('roles').setSystemRoleMode({
|
||||
values: {
|
||||
roleMode: SystemRoleMode.allowUseUnion,
|
||||
},
|
||||
});
|
||||
agent = await app.agent().login(user, UNION_ROLE_KEY);
|
||||
});
|
||||
|
||||
@ -415,15 +419,15 @@ describe('union role: full permissions', async () => {
|
||||
const rootAgent = await app.agent().login(rootUser);
|
||||
let rolesResponse = await agent.resource('roles').check();
|
||||
expect(rolesResponse.status).toBe(200);
|
||||
expect(rolesResponse.body.data.roleMode).toStrictEqual(SystemRoleMode.default);
|
||||
expect(rolesResponse.body.data.roleMode).toStrictEqual(SystemRoleMode.allowUseUnion);
|
||||
await rootAgent.resource('roles').setSystemRoleMode({
|
||||
values: {
|
||||
roleMode: SystemRoleMode.allowUseUnion,
|
||||
roleMode: SystemRoleMode.default,
|
||||
},
|
||||
});
|
||||
rolesResponse = await agent.resource('roles').check();
|
||||
expect(rolesResponse.status).toBe(200);
|
||||
expect(rolesResponse.body.data.roleMode).toStrictEqual(SystemRoleMode.allowUseUnion);
|
||||
expect(rolesResponse.body.data.roleMode).toStrictEqual(SystemRoleMode.default);
|
||||
});
|
||||
|
||||
it(`should response no permission when createdById field is missing in data tables`, async () => {
|
||||
@ -501,4 +505,29 @@ describe('union role: full permissions', async () => {
|
||||
expect(getRolesResponse.statusCode).toBe(200);
|
||||
expect(getRolesResponse.body.meta.allowedActions.update.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should login successfully when use __union__ role in allowUseUnion mode #1906', async () => {
|
||||
const rootAgent = await app.agent().login(rootUser);
|
||||
await rootAgent.resource('roles').setSystemRoleMode({
|
||||
values: {
|
||||
roleMode: SystemRoleMode.allowUseUnion,
|
||||
},
|
||||
});
|
||||
agent = await app.agent().login(user);
|
||||
const createRoleResponse = await agent.resource('roles').check();
|
||||
expect(createRoleResponse.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('should currentRole not be __union__ when default role mode #1907', async () => {
|
||||
const rootAgent = await app.agent().login(rootUser);
|
||||
await rootAgent.resource('roles').setSystemRoleMode({
|
||||
values: {
|
||||
roleMode: SystemRoleMode.default,
|
||||
},
|
||||
});
|
||||
agent = await app.agent().login(user, UNION_ROLE_KEY);
|
||||
const createRoleResponse = await agent.resource('roles').check();
|
||||
expect(createRoleResponse.statusCode).toBe(200);
|
||||
expect(createRoleResponse.body.data.role).not.toBe(UNION_ROLE_KEY);
|
||||
});
|
||||
});
|
||||
|
@ -14,7 +14,7 @@ import { UNION_ROLE_KEY } from '../constants';
|
||||
import { SystemRoleMode } from '../enum';
|
||||
|
||||
export async function setCurrentRole(ctx: Context, next) {
|
||||
const currentRole = ctx.get('X-Role');
|
||||
let currentRole = ctx.get('X-Role');
|
||||
|
||||
if (currentRole === 'anonymous') {
|
||||
ctx.state.currentRole = currentRole;
|
||||
@ -49,7 +49,8 @@ export async function setCurrentRole(ctx: Context, next) {
|
||||
ctx.state.currentUser.roles = userRoles;
|
||||
const systemSettings = await ctx.db.getRepository('systemSettings').findOne();
|
||||
const roleMode = systemSettings?.get('roleMode') || SystemRoleMode.default;
|
||||
if (ctx.state.currentRole === UNION_ROLE_KEY && roleMode === SystemRoleMode.default) {
|
||||
if ([currentRole, ctx.state.currentRole].includes(UNION_ROLE_KEY) && roleMode === SystemRoleMode.default) {
|
||||
currentRole = userRoles[0].name;
|
||||
ctx.state.currentRole = userRoles[0].name;
|
||||
ctx.headers['x-role'] = userRoles[0].name;
|
||||
} else if (roleMode === SystemRoleMode.onlyUseUnion) {
|
||||
@ -85,7 +86,7 @@ export async function setCurrentRole(ctx: Context, next) {
|
||||
// 2. If the X-Role is not set, or the X-Role does not belong to the user, use the default role
|
||||
if (!role) {
|
||||
const defaultRole = userRoles.find((role) => role?.rolesUsers?.default);
|
||||
role = (defaultRole || userRoles[0])?.name;
|
||||
role = (defaultRole || userRoles.find((x) => x.name !== UNION_ROLE_KEY))?.name;
|
||||
}
|
||||
ctx.state.currentRole = role;
|
||||
ctx.state.currentRoles = [role];
|
||||
|
@ -0,0 +1,34 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { expect, test } from '@nocobase/test/e2e';
|
||||
import { afterConfiguringTheModalWhenReopeningItTheContentShouldPersist } from './utils';
|
||||
|
||||
test.describe('refresh', () => {
|
||||
test('After configuring the modal, when reopening it, the content should persist', async ({ mockPage, page }) => {
|
||||
await mockPage(afterConfiguringTheModalWhenReopeningItTheContentShouldPersist).goto();
|
||||
|
||||
// 1. 点击 Bulk edit 按钮,打开弹窗
|
||||
await page.getByLabel('action-Action-Bulk edit-').click();
|
||||
|
||||
// 2. 新增一个表单区块
|
||||
await page.getByLabel('schema-initializer-Grid-popup').hover();
|
||||
await page.getByRole('menuitem', { name: 'form Form' }).click();
|
||||
|
||||
// 3. 新增一个名为 Nickname 的字段
|
||||
await page.getByLabel('schema-initializer-Grid-bulkEditForm:configureFields-users').hover();
|
||||
await page.getByRole('menuitem', { name: 'Nickname' }).click();
|
||||
|
||||
// 4. 关闭弹窗,然后再打开,刚才新增的字段应该还在
|
||||
await page.getByLabel('drawer-Action.Container-users-Bulk edit-mask').click();
|
||||
await page.getByLabel('action-Action-Bulk edit-').click();
|
||||
await expect(page.getByLabel('block-item-BulkEditField-').getByText('Nickname')).toBeVisible();
|
||||
await page.getByLabel('block-item-BulkEditField-').click();
|
||||
});
|
||||
});
|
@ -1243,3 +1243,388 @@ export const theAddBlockButtonInDrawerShouldBeVisible = {
|
||||
'x-index': 1,
|
||||
},
|
||||
};
|
||||
export const afterConfiguringTheModalWhenReopeningItTheContentShouldPersist = {
|
||||
pageSchema: {
|
||||
type: 'void',
|
||||
'x-component': 'Page',
|
||||
name: 'rjzvy4bmawn',
|
||||
'x-uid': '1rs9caegbf2',
|
||||
'x-async': false,
|
||||
properties: {
|
||||
tab: {
|
||||
type: 'void',
|
||||
'x-component': 'Grid',
|
||||
'x-initializer': 'page:addBlock',
|
||||
properties: {
|
||||
bmsmf8futai: {
|
||||
_isJSONSchemaObject: true,
|
||||
version: '2.0',
|
||||
type: 'void',
|
||||
'x-component': 'Grid.Row',
|
||||
'x-app-version': '1.6.11',
|
||||
'x-uid': 'x84k7qs6jko',
|
||||
'x-async': false,
|
||||
'x-index': 4,
|
||||
},
|
||||
noe2oca30hc: {
|
||||
_isJSONSchemaObject: true,
|
||||
version: '2.0',
|
||||
type: 'void',
|
||||
'x-component': 'Grid.Row',
|
||||
'x-app-version': '1.6.11',
|
||||
'x-uid': 't7jxa830ps6',
|
||||
'x-async': false,
|
||||
'x-index': 5,
|
||||
},
|
||||
w2hnq7rau9p: {
|
||||
_isJSONSchemaObject: true,
|
||||
version: '2.0',
|
||||
type: 'void',
|
||||
'x-component': 'Grid.Row',
|
||||
'x-app-version': '1.6.11',
|
||||
'x-uid': '0fjjtg8z7ws',
|
||||
'x-async': false,
|
||||
'x-index': 7,
|
||||
},
|
||||
fcfs4oot86g: {
|
||||
_isJSONSchemaObject: true,
|
||||
version: '2.0',
|
||||
type: 'void',
|
||||
'x-component': 'Grid.Row',
|
||||
'x-app-version': '1.6.11',
|
||||
'x-uid': 'nklv7lonpgn',
|
||||
'x-async': false,
|
||||
'x-index': 8,
|
||||
},
|
||||
i22fydav355: {
|
||||
_isJSONSchemaObject: true,
|
||||
version: '2.0',
|
||||
type: 'void',
|
||||
'x-component': 'Grid.Row',
|
||||
'x-app-version': '1.6.11',
|
||||
'x-uid': 'fz4g6cr9jvr',
|
||||
'x-async': false,
|
||||
'x-index': 10,
|
||||
},
|
||||
row_6u7y7uccrvz: {
|
||||
_isJSONSchemaObject: true,
|
||||
version: '2.0',
|
||||
type: 'void',
|
||||
'x-component': 'Grid.Row',
|
||||
'x-index': 12,
|
||||
'x-uid': '7tzumo4nec7',
|
||||
'x-async': false,
|
||||
},
|
||||
higfesvgj7g: {
|
||||
_isJSONSchemaObject: true,
|
||||
version: '2.0',
|
||||
type: 'void',
|
||||
'x-component': 'Grid.Row',
|
||||
'x-app-version': '1.6.11',
|
||||
'x-uid': '8hpa6qf3sez',
|
||||
'x-async': false,
|
||||
'x-index': 13,
|
||||
},
|
||||
'37myao9n0wc': {
|
||||
_isJSONSchemaObject: true,
|
||||
version: '2.0',
|
||||
type: 'void',
|
||||
'x-component': 'Grid.Row',
|
||||
'x-app-version': '1.6.11',
|
||||
'x-uid': 'uw1dp2qxd3y',
|
||||
'x-async': false,
|
||||
'x-index': 14,
|
||||
},
|
||||
uvfd76q4ye9: {
|
||||
_isJSONSchemaObject: true,
|
||||
version: '2.0',
|
||||
type: 'void',
|
||||
'x-component': 'Grid.Row',
|
||||
'x-app-version': '1.6.11',
|
||||
'x-uid': 'nc56fu33m42',
|
||||
'x-async': false,
|
||||
'x-index': 15,
|
||||
},
|
||||
miidizeqgot: {
|
||||
_isJSONSchemaObject: true,
|
||||
version: '2.0',
|
||||
type: 'void',
|
||||
'x-component': 'Grid.Row',
|
||||
'x-app-version': '1.6.11',
|
||||
'x-uid': 'jf4qarrcs0z',
|
||||
'x-async': false,
|
||||
'x-index': 16,
|
||||
},
|
||||
hxmr87i5imu: {
|
||||
_isJSONSchemaObject: true,
|
||||
version: '2.0',
|
||||
type: 'void',
|
||||
'x-component': 'Grid.Row',
|
||||
'x-app-version': '1.6.11',
|
||||
'x-uid': 'l3kdiqd9a7k',
|
||||
'x-async': false,
|
||||
'x-index': 17,
|
||||
},
|
||||
pa8dwdi4h5a: {
|
||||
_isJSONSchemaObject: true,
|
||||
version: '2.0',
|
||||
type: 'void',
|
||||
'x-component': 'Grid.Row',
|
||||
'x-app-version': '1.6.11',
|
||||
'x-uid': 'uz5wcet83qn',
|
||||
'x-async': false,
|
||||
'x-index': 18,
|
||||
},
|
||||
pno0a05tbnp: {
|
||||
_isJSONSchemaObject: true,
|
||||
version: '2.0',
|
||||
type: 'void',
|
||||
'x-component': 'Grid.Row',
|
||||
'x-app-version': '1.6.11',
|
||||
'x-uid': 'b4bakhhasp3',
|
||||
'x-async': false,
|
||||
'x-index': 19,
|
||||
},
|
||||
uj09g5xgnr1: {
|
||||
_isJSONSchemaObject: true,
|
||||
version: '2.0',
|
||||
type: 'void',
|
||||
'x-component': 'Grid.Row',
|
||||
'x-app-version': '1.6.11',
|
||||
'x-uid': 'qks035fnfl6',
|
||||
'x-async': false,
|
||||
'x-index': 20,
|
||||
},
|
||||
giobcwj316k: {
|
||||
_isJSONSchemaObject: true,
|
||||
version: '2.0',
|
||||
type: 'void',
|
||||
'x-component': 'Grid.Row',
|
||||
'x-app-version': '1.6.11',
|
||||
'x-uid': 'awwsb89nyso',
|
||||
'x-async': false,
|
||||
'x-index': 22,
|
||||
},
|
||||
oznewtbvuyw: {
|
||||
_isJSONSchemaObject: true,
|
||||
version: '2.0',
|
||||
type: 'void',
|
||||
'x-component': 'Grid.Row',
|
||||
'x-app-version': '1.6.11',
|
||||
properties: {
|
||||
bwtax0bnnp3: {
|
||||
_isJSONSchemaObject: true,
|
||||
version: '2.0',
|
||||
type: 'void',
|
||||
'x-component': 'Grid.Col',
|
||||
'x-app-version': '1.6.11',
|
||||
properties: {
|
||||
c0bypj7wg5q: {
|
||||
_isJSONSchemaObject: true,
|
||||
version: '2.0',
|
||||
type: 'void',
|
||||
'x-decorator': 'TableBlockProvider',
|
||||
'x-acl-action': 'users:list',
|
||||
'x-use-decorator-props': 'useTableBlockDecoratorProps',
|
||||
'x-decorator-props': {
|
||||
collection: 'users',
|
||||
dataSource: 'main',
|
||||
action: 'list',
|
||||
params: {
|
||||
pageSize: 20,
|
||||
},
|
||||
rowKey: 'id',
|
||||
showIndex: true,
|
||||
dragSort: false,
|
||||
},
|
||||
'x-toolbar': 'BlockSchemaToolbar',
|
||||
'x-settings': 'blockSettings:table',
|
||||
'x-component': 'CardItem',
|
||||
'x-filter-targets': [],
|
||||
'x-app-version': '1.6.11',
|
||||
properties: {
|
||||
actions: {
|
||||
_isJSONSchemaObject: true,
|
||||
version: '2.0',
|
||||
type: 'void',
|
||||
'x-initializer': 'table:configureActions',
|
||||
'x-component': 'ActionBar',
|
||||
'x-component-props': {
|
||||
style: {
|
||||
marginBottom: 'var(--nb-spacing)',
|
||||
},
|
||||
},
|
||||
'x-app-version': '1.6.11',
|
||||
properties: {
|
||||
'1dlvhzr308c': {
|
||||
_isJSONSchemaObject: true,
|
||||
version: '2.0',
|
||||
type: 'void',
|
||||
title: '{{t("Bulk edit")}}',
|
||||
'x-component': 'Action',
|
||||
'x-action': 'customize:bulkEdit',
|
||||
'x-action-settings': {
|
||||
updateMode: 'selected',
|
||||
},
|
||||
'x-component-props': {
|
||||
openMode: 'drawer',
|
||||
icon: 'EditOutlined',
|
||||
},
|
||||
'x-align': 'right',
|
||||
'x-decorator': 'BulkEditActionDecorator',
|
||||
'x-toolbar': 'ActionSchemaToolbar',
|
||||
'x-settings': 'actionSettings:bulkEdit',
|
||||
'x-acl-action': 'update',
|
||||
'x-acl-action-props': {
|
||||
skipScopeCheck: true,
|
||||
},
|
||||
'x-app-version': '1.6.11',
|
||||
properties: {
|
||||
drawer: {
|
||||
_isJSONSchemaObject: true,
|
||||
version: '2.0',
|
||||
type: 'void',
|
||||
title: '{{t("Bulk edit")}}',
|
||||
'x-component': 'Action.Container',
|
||||
'x-component-props': {
|
||||
className: 'nb-action-popup',
|
||||
},
|
||||
'x-app-version': '1.6.11',
|
||||
properties: {
|
||||
tabs: {
|
||||
_isJSONSchemaObject: true,
|
||||
version: '2.0',
|
||||
type: 'void',
|
||||
'x-component': 'Tabs',
|
||||
'x-component-props': {},
|
||||
'x-initializer': 'popup:addTab',
|
||||
'x-initializer-props': {
|
||||
gridInitializer: 'popup:bulkEdit:addBlock',
|
||||
},
|
||||
'x-app-version': '1.6.11',
|
||||
properties: {
|
||||
tab1: {
|
||||
_isJSONSchemaObject: true,
|
||||
version: '2.0',
|
||||
type: 'void',
|
||||
title: '{{t("Bulk edit")}}',
|
||||
'x-component': 'Tabs.TabPane',
|
||||
'x-designer': 'Tabs.Designer',
|
||||
'x-component-props': {},
|
||||
'x-app-version': '1.6.11',
|
||||
properties: {
|
||||
grid: {
|
||||
_isJSONSchemaObject: true,
|
||||
version: '2.0',
|
||||
type: 'void',
|
||||
'x-component': 'Grid',
|
||||
'x-initializer': 'popup:bulkEdit:addBlock',
|
||||
'x-app-version': '1.6.11',
|
||||
'x-uid': '5ejbu8v5ol8',
|
||||
'x-async': false,
|
||||
'x-index': 1,
|
||||
},
|
||||
},
|
||||
'x-uid': 'gfheiqtl7f7',
|
||||
'x-async': false,
|
||||
'x-index': 1,
|
||||
},
|
||||
},
|
||||
'x-uid': 'if2rcx1dy2n',
|
||||
'x-async': false,
|
||||
'x-index': 1,
|
||||
},
|
||||
},
|
||||
'x-uid': 'cxyi8q6lm3n',
|
||||
'x-async': false,
|
||||
'x-index': 1,
|
||||
},
|
||||
},
|
||||
'x-uid': 'vbvf13xq15t',
|
||||
'x-async': false,
|
||||
'x-index': 1,
|
||||
},
|
||||
},
|
||||
'x-uid': 'amlzm32jhwg',
|
||||
'x-async': false,
|
||||
'x-index': 1,
|
||||
},
|
||||
f232o2ds23n: {
|
||||
_isJSONSchemaObject: true,
|
||||
version: '2.0',
|
||||
type: 'array',
|
||||
'x-initializer': 'table:configureColumns',
|
||||
'x-component': 'TableV2',
|
||||
'x-use-component-props': 'useTableBlockProps',
|
||||
'x-component-props': {
|
||||
rowKey: 'id',
|
||||
rowSelection: {
|
||||
type: 'checkbox',
|
||||
},
|
||||
},
|
||||
'x-app-version': '1.6.11',
|
||||
properties: {
|
||||
actions: {
|
||||
_isJSONSchemaObject: true,
|
||||
version: '2.0',
|
||||
type: 'void',
|
||||
title: '{{ t("Actions") }}',
|
||||
'x-action-column': 'actions',
|
||||
'x-decorator': 'TableV2.Column.ActionBar',
|
||||
'x-component': 'TableV2.Column',
|
||||
'x-toolbar': 'TableColumnSchemaToolbar',
|
||||
'x-initializer': 'table:configureItemActions',
|
||||
'x-settings': 'fieldSettings:TableColumn',
|
||||
'x-toolbar-props': {
|
||||
initializer: 'table:configureItemActions',
|
||||
},
|
||||
'x-app-version': '1.6.11',
|
||||
properties: {
|
||||
'153lpq30p5f': {
|
||||
_isJSONSchemaObject: true,
|
||||
version: '2.0',
|
||||
type: 'void',
|
||||
'x-decorator': 'DndContext',
|
||||
'x-component': 'Space',
|
||||
'x-component-props': {
|
||||
split: '|',
|
||||
},
|
||||
'x-app-version': '1.6.11',
|
||||
'x-uid': '4mha1dmmyz9',
|
||||
'x-async': false,
|
||||
'x-index': 1,
|
||||
},
|
||||
},
|
||||
'x-uid': 'afmceivuaf0',
|
||||
'x-async': false,
|
||||
'x-index': 1,
|
||||
},
|
||||
},
|
||||
'x-uid': 'iu0xkmeuc5z',
|
||||
'x-async': false,
|
||||
'x-index': 2,
|
||||
},
|
||||
},
|
||||
'x-uid': 'ylor106s9ok',
|
||||
'x-async': false,
|
||||
'x-index': 1,
|
||||
},
|
||||
},
|
||||
'x-uid': 'rl50hidu14n',
|
||||
'x-async': false,
|
||||
'x-index': 1,
|
||||
},
|
||||
},
|
||||
'x-uid': 'xxhug2yumqf',
|
||||
'x-async': false,
|
||||
'x-index': 23,
|
||||
},
|
||||
},
|
||||
name: 'h63eibc46on',
|
||||
'x-uid': 'u9g23o0ohgk',
|
||||
'x-async': true,
|
||||
'x-index': 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
@ -1,176 +1,24 @@
|
||||
import { PinnedPluginListProvider, SchemaComponentOptions, useApp } from '@nocobase/client';
|
||||
/**
|
||||
* 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 { PinnedPluginListProvider, SchemaComponentOptions, useRequest } from '@nocobase/client';
|
||||
import React from 'react';
|
||||
import { AsyncTasks } from './components/AsyncTasks';
|
||||
import React, { useEffect, useState, createContext, useContext, useCallback } from 'react';
|
||||
import { message } from 'antd';
|
||||
import { useT } from './locale';
|
||||
|
||||
export const AsyncTaskContext = createContext<any>(null);
|
||||
|
||||
export const useAsyncTask = () => {
|
||||
const context = useContext(AsyncTaskContext);
|
||||
if (!context) {
|
||||
throw new Error('useAsyncTask must be used within AsyncTaskManagerProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const AsyncTaskManagerProvider = (props) => {
|
||||
const app = useApp();
|
||||
const t = useT();
|
||||
const [tasks, setTasks] = useState<any[]>([]);
|
||||
const [popoverVisible, setPopoverVisible] = useState(false);
|
||||
const [hasProcessingTasks, setHasProcessingTasks] = useState(false);
|
||||
const [cancellingTasks, setCancellingTasks] = useState<Set<string>>(new Set());
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [currentError, setCurrentError] = useState<any>(null);
|
||||
const [resultModalVisible, setResultModalVisible] = useState(false);
|
||||
const [currentTask, setCurrentTask] = useState(null);
|
||||
const [wsAuthorized, setWsAuthorized] = useState(() => app.isWsAuthorized);
|
||||
|
||||
useEffect(() => {
|
||||
setHasProcessingTasks(tasks.some((task) => task.status.type !== 'success' && task.status.type !== 'failed'));
|
||||
}, [tasks]);
|
||||
|
||||
const handleTaskMessage = useCallback((event: CustomEvent) => {
|
||||
const tasks = event.detail;
|
||||
setTasks(tasks ? tasks.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) : []);
|
||||
}, []);
|
||||
|
||||
const handleTaskCreated = useCallback((event: CustomEvent) => {
|
||||
const taskData = event.detail;
|
||||
setTasks((prev) => {
|
||||
const newTasks = [taskData, ...prev];
|
||||
return newTasks.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
});
|
||||
setPopoverVisible(true);
|
||||
}, []);
|
||||
|
||||
const handleTaskProgress = useCallback((event: CustomEvent) => {
|
||||
const { taskId, progress } = event.detail;
|
||||
setTasks((prev) => prev.map((task) => (task.taskId === taskId ? { ...task, progress } : task)));
|
||||
}, []);
|
||||
|
||||
const handleTaskStatus = useCallback((event: CustomEvent) => {
|
||||
const { taskId, status } = event.detail;
|
||||
if (status.type === 'cancelled') {
|
||||
setTasks((prev) => prev.filter((task) => task.taskId !== taskId));
|
||||
} else {
|
||||
setTasks((prev) => {
|
||||
const newTasks = prev.map((task) => {
|
||||
if (task.taskId === taskId) {
|
||||
if (status.type === 'success' && task.status.type !== 'success') {
|
||||
message.success(t('Task completed'));
|
||||
}
|
||||
if (status.type === 'failed' && task.status.type !== 'failed') {
|
||||
message.error(t('Task failed'));
|
||||
}
|
||||
return { ...task, status };
|
||||
}
|
||||
return task;
|
||||
});
|
||||
return newTasks;
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleWsAuthorized = useCallback(() => {
|
||||
setWsAuthorized(true);
|
||||
}, []);
|
||||
|
||||
const handleTaskCancelled = useCallback((event: CustomEvent) => {
|
||||
const { taskId } = event.detail;
|
||||
setCancellingTasks((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(taskId);
|
||||
return newSet;
|
||||
});
|
||||
message.success(t('Task cancelled'));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
app.eventBus.addEventListener('ws:message:async-tasks', handleTaskMessage);
|
||||
app.eventBus.addEventListener('ws:message:async-tasks:created', handleTaskCreated);
|
||||
app.eventBus.addEventListener('ws:message:async-tasks:progress', handleTaskProgress);
|
||||
app.eventBus.addEventListener('ws:message:async-tasks:status', handleTaskStatus);
|
||||
app.eventBus.addEventListener('ws:message:authorized', handleWsAuthorized);
|
||||
app.eventBus.addEventListener('ws:message:async-tasks:cancelled', handleTaskCancelled);
|
||||
|
||||
if (wsAuthorized) {
|
||||
app.ws.send(
|
||||
JSON.stringify({
|
||||
type: 'request:async-tasks:list',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return () => {
|
||||
app.eventBus.removeEventListener('ws:message:async-tasks', handleTaskMessage);
|
||||
app.eventBus.removeEventListener('ws:message:async-tasks:created', handleTaskCreated);
|
||||
app.eventBus.removeEventListener('ws:message:async-tasks:progress', handleTaskProgress);
|
||||
app.eventBus.removeEventListener('ws:message:async-tasks:status', handleTaskStatus);
|
||||
app.eventBus.removeEventListener('ws:message:authorized', handleWsAuthorized);
|
||||
app.eventBus.removeEventListener('ws:message:async-tasks:cancelled', handleTaskCancelled);
|
||||
};
|
||||
}, [
|
||||
app,
|
||||
handleTaskMessage,
|
||||
handleTaskCreated,
|
||||
handleTaskProgress,
|
||||
handleTaskStatus,
|
||||
handleWsAuthorized,
|
||||
handleTaskCancelled,
|
||||
wsAuthorized,
|
||||
]);
|
||||
|
||||
const handleCancelTask = async (taskId: string) => {
|
||||
setCancellingTasks((prev) => new Set(prev).add(taskId));
|
||||
try {
|
||||
app.ws.send(
|
||||
JSON.stringify({
|
||||
type: 'request:async-tasks:cancel',
|
||||
payload: { taskId },
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to cancel task:', error);
|
||||
setCancellingTasks((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(taskId);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const contextValue = {
|
||||
tasks,
|
||||
popoverVisible,
|
||||
setPopoverVisible,
|
||||
hasProcessingTasks,
|
||||
cancellingTasks,
|
||||
modalVisible,
|
||||
setModalVisible,
|
||||
currentError,
|
||||
setCurrentError,
|
||||
resultModalVisible,
|
||||
setResultModalVisible,
|
||||
currentTask,
|
||||
setCurrentTask,
|
||||
handleCancelTask,
|
||||
};
|
||||
|
||||
return (
|
||||
<AsyncTaskContext.Provider value={contextValue}>
|
||||
<PinnedPluginListProvider
|
||||
items={
|
||||
tasks.length > 0
|
||||
? {
|
||||
asyncTasks: { order: 300, component: 'AsyncTasks', pin: true, snippet: '*' },
|
||||
}
|
||||
: {}
|
||||
}
|
||||
>
|
||||
<SchemaComponentOptions components={{ AsyncTasks }}>{props.children}</SchemaComponentOptions>
|
||||
</PinnedPluginListProvider>
|
||||
</AsyncTaskContext.Provider>
|
||||
<PinnedPluginListProvider
|
||||
items={{
|
||||
asyncTasks: { order: 300, component: 'AsyncTasks', pin: true, snippet: '*' },
|
||||
}}
|
||||
>
|
||||
<SchemaComponentOptions components={{ AsyncTasks }}>{props.children}</SchemaComponentOptions>
|
||||
</PinnedPluginListProvider>
|
||||
);
|
||||
};
|
||||
|
@ -1,13 +1,31 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { Button, Popover, Table, Tag, Progress, Space, Tooltip, Popconfirm, Modal, Empty } from 'antd';
|
||||
import { createStyles, Icon, useApp, usePlugin } from '@nocobase/client';
|
||||
/**
|
||||
* 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 {
|
||||
createStyles,
|
||||
Icon,
|
||||
useAPIClient,
|
||||
useApp,
|
||||
usePlugin,
|
||||
useRequest,
|
||||
useCollectionManager,
|
||||
useCompile,
|
||||
} from '@nocobase/client';
|
||||
import { Button, Empty, Modal, Popconfirm, Popover, Progress, Space, Table, Tag, Tooltip } from 'antd';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { useCurrentAppInfo } from '@nocobase/client';
|
||||
import dayjs from 'dayjs';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import { useT } from '../locale';
|
||||
import { useAsyncTask } from '../AsyncTaskManagerProvider';
|
||||
import { useCurrentAppInfo } from '@nocobase/client';
|
||||
|
||||
const useStyles = createStyles(({ token }) => {
|
||||
return {
|
||||
button: {
|
||||
@ -34,93 +52,27 @@ const renderTaskResult = (status, t) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const AsyncTasks = () => {
|
||||
const {
|
||||
tasks,
|
||||
popoverVisible,
|
||||
setPopoverVisible,
|
||||
hasProcessingTasks,
|
||||
cancellingTasks,
|
||||
modalVisible,
|
||||
setModalVisible,
|
||||
currentError,
|
||||
setCurrentError,
|
||||
resultModalVisible,
|
||||
setResultModalVisible,
|
||||
currentTask,
|
||||
setCurrentTask,
|
||||
handleCancelTask,
|
||||
} = useAsyncTask();
|
||||
const useAsyncTask = () => {
|
||||
const { data, refreshAsync, loading } = useRequest<any>({
|
||||
url: 'asyncTasks:list',
|
||||
});
|
||||
return { loading, tasks: data?.data || [], refresh: refreshAsync };
|
||||
};
|
||||
|
||||
const plugin = usePlugin<any>('async-task-manager');
|
||||
const AsyncTasksButton = (props) => {
|
||||
const { popoverVisible, setPopoverVisible, tasks, refresh, loading, hasProcessingTasks } = props;
|
||||
const app = useApp();
|
||||
const api = useAPIClient();
|
||||
const appInfo = useCurrentAppInfo();
|
||||
const t = useT();
|
||||
const { styles } = useStyles();
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (popoverVisible) {
|
||||
const popoverElements = document.querySelectorAll('.ant-popover');
|
||||
const buttonElement = document.querySelector('.sync-task-button');
|
||||
let clickedInside = false;
|
||||
|
||||
popoverElements.forEach((element) => {
|
||||
if (element.contains(event.target as Node)) {
|
||||
clickedInside = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (buttonElement?.contains(event.target as Node)) {
|
||||
clickedInside = true;
|
||||
}
|
||||
|
||||
if (!clickedInside) {
|
||||
setPopoverVisible(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('click', handleClickOutside);
|
||||
};
|
||||
}, [popoverVisible, setPopoverVisible]);
|
||||
|
||||
const plugin = usePlugin<any>('async-task-manager');
|
||||
const cm = useCollectionManager();
|
||||
const compile = useCompile();
|
||||
const showTaskResult = (task) => {
|
||||
setCurrentTask(task);
|
||||
setResultModalVisible(true);
|
||||
setPopoverVisible(false);
|
||||
};
|
||||
|
||||
const renderTaskResultModal = () => {
|
||||
if (!currentTask) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { payload } = currentTask.status;
|
||||
const renderer = plugin.taskResultRendererManager.get(currentTask.title.actionType);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('Task result')}
|
||||
open={resultModalVisible}
|
||||
footer={[
|
||||
<Button key="close" onClick={() => setResultModalVisible(false)}>
|
||||
{t('Close')}
|
||||
</Button>,
|
||||
]}
|
||||
onCancel={() => setResultModalVisible(false)}
|
||||
>
|
||||
{renderer ? (
|
||||
React.createElement(renderer, { payload, task: currentTask })
|
||||
) : (
|
||||
<div>{t(`No renderer available for this task type, payload: ${payload}`)}</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('Created at'),
|
||||
@ -140,7 +92,7 @@ export const AsyncTasks = () => {
|
||||
if (!title) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
const collection = cm.getCollection(title.collection);
|
||||
const actionTypeMap = {
|
||||
export: t('Export'),
|
||||
import: t('Import'),
|
||||
@ -156,7 +108,7 @@ export const AsyncTasks = () => {
|
||||
};
|
||||
|
||||
const taskTemplate = taskTypeMap[title.actionType] || `${actionText}`;
|
||||
return taskTemplate.replace('{collection}', title.collection);
|
||||
return taskTemplate.replace('{collection}', compile(collection?.title || title.collection));
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -274,7 +226,7 @@ export const AsyncTasks = () => {
|
||||
width: 180,
|
||||
render: (_, record: any) => {
|
||||
const actions = [];
|
||||
const isTaskCancelling = cancellingTasks.has(record.taskId);
|
||||
const isTaskCancelling = false;
|
||||
|
||||
if ((record.status.type === 'running' || record.status.type === 'pending') && record.cancelable) {
|
||||
actions.push(
|
||||
@ -282,7 +234,15 @@ export const AsyncTasks = () => {
|
||||
key="cancel"
|
||||
title={t('Confirm cancel')}
|
||||
description={t('Confirm cancel description')}
|
||||
onConfirm={() => handleCancelTask(record.taskId)}
|
||||
onConfirm={async () => {
|
||||
await api.request({
|
||||
url: 'asyncTasks:cancel',
|
||||
params: {
|
||||
filterByTk: record.taskId,
|
||||
},
|
||||
});
|
||||
refresh();
|
||||
}}
|
||||
okText={t('Confirm')}
|
||||
cancelText={t('Cancel')}
|
||||
disabled={isTaskCancelling}
|
||||
@ -309,8 +269,16 @@ export const AsyncTasks = () => {
|
||||
icon={<Icon type="DownloadOutlined" />}
|
||||
onClick={() => {
|
||||
const token = app.apiClient.auth.token;
|
||||
const collection = cm.getCollection(record.title.collection);
|
||||
const compiledTitle = compile(collection?.title);
|
||||
const suffix = record?.title?.actionType === 'export-attachments' ? '-attachments.zip' : '.xlsx';
|
||||
const fileText = `${compiledTitle}${suffix}`;
|
||||
const filename =
|
||||
record?.title?.actionType !== 'create migration' ? encodeURIComponent(fileText) : null;
|
||||
const url = app.getApiUrl(
|
||||
`asyncTasks:fetchFile/${record.taskId}?token=${token}&__appName=${appInfo?.data?.name || app.name}`,
|
||||
`asyncTasks:fetchFile/${record.taskId}?token=${token}&__appName=${encodeURIComponent(
|
||||
appInfo?.data?.name || app.name,
|
||||
)}${filename ? `&filename=${filename}` : ''}`,
|
||||
);
|
||||
window.open(url);
|
||||
}}
|
||||
@ -325,7 +293,19 @@ export const AsyncTasks = () => {
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<Icon type="EyeOutlined" />}
|
||||
onClick={() => showTaskResult(record)}
|
||||
onClick={() => {
|
||||
showTaskResult(record);
|
||||
const { payload } = record.status;
|
||||
const renderer = plugin.taskResultRendererManager.get(record.title.actionType);
|
||||
Modal.info({
|
||||
title: t('Task result'),
|
||||
content: renderer ? (
|
||||
React.createElement(renderer, { payload, task: record })
|
||||
) : (
|
||||
<div>{t(`No renderer available for this task type, payload: ${payload}`)}</div>
|
||||
),
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t('View result')}
|
||||
</Button>,
|
||||
@ -341,9 +321,22 @@ export const AsyncTasks = () => {
|
||||
size="small"
|
||||
icon={<Icon type="ExclamationCircleOutlined" />}
|
||||
onClick={() => {
|
||||
setCurrentError(record.status.errors);
|
||||
setModalVisible(true);
|
||||
setPopoverVisible(false);
|
||||
Modal.info({
|
||||
title: t('Error Details'),
|
||||
content: record.status.errors?.map((error, index) => (
|
||||
<div key={index} style={{ marginBottom: 16 }}>
|
||||
<div style={{ color: '#ff4d4f', marginBottom: 8 }}>{error.message}</div>
|
||||
{error.code && (
|
||||
<div style={{ color: '#999', fontSize: 12 }}>
|
||||
{t('Error code')}: {error.code}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)),
|
||||
closable: true,
|
||||
width: 400,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t('Error details')}
|
||||
@ -357,9 +350,9 @@ export const AsyncTasks = () => {
|
||||
];
|
||||
|
||||
const content = (
|
||||
<div style={{ width: tasks.length > 0 ? 800 : 200 }}>
|
||||
<div style={{ maxHeight: '70vh', overflow: 'auto', width: tasks.length > 0 ? 800 : 200 }}>
|
||||
{tasks.length > 0 ? (
|
||||
<Table columns={columns} dataSource={tasks} size="small" pagination={false} rowKey="taskId" />
|
||||
<Table loading={loading} columns={columns} dataSource={tasks} size="small" pagination={false} rowKey="taskId" />
|
||||
) : (
|
||||
<div style={{ padding: '24px 0', display: 'flex', justifyContent: 'center' }}>
|
||||
<Empty description={t('No tasks')} image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
@ -383,30 +376,53 @@ export const AsyncTasks = () => {
|
||||
onClick={() => setPopoverVisible(!popoverVisible)}
|
||||
/>
|
||||
</Popover>
|
||||
{renderTaskResultModal()}
|
||||
|
||||
<Modal
|
||||
title={t('Error Details')}
|
||||
open={modalVisible}
|
||||
onCancel={() => setModalVisible(false)}
|
||||
footer={[
|
||||
<Button key="ok" type="primary" onClick={() => setModalVisible(false)}>
|
||||
{t('OK')}
|
||||
</Button>,
|
||||
]}
|
||||
width={400}
|
||||
>
|
||||
{currentError?.map((error, index) => (
|
||||
<div key={index} style={{ marginBottom: 16 }}>
|
||||
<div style={{ color: '#ff4d4f', marginBottom: 8 }}>{error.message}</div>
|
||||
{error.code && (
|
||||
<div style={{ color: '#999', fontSize: 12 }}>
|
||||
{t('Error code')}: {error.code}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const AsyncTasks = () => {
|
||||
const { tasks, refresh, ...others } = useAsyncTask();
|
||||
const app = useApp();
|
||||
const [popoverVisible, setPopoverVisible] = useState(false);
|
||||
const [hasProcessingTasks, setHasProcessingTasks] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setHasProcessingTasks(tasks.some((task) => task.status.type !== 'success' && task.status.type !== 'failed'));
|
||||
}, [tasks]);
|
||||
|
||||
const handleTaskCreated = useCallback(async () => {
|
||||
setPopoverVisible(true);
|
||||
}, []);
|
||||
const handleTaskProgress = useCallback(() => {
|
||||
refresh();
|
||||
console.log('handleTaskProgress');
|
||||
}, []);
|
||||
const handleTaskStatus = useCallback(() => {
|
||||
refresh();
|
||||
console.log('handleTaskStatus');
|
||||
}, []);
|
||||
const handleTaskCancelled = useCallback(() => {
|
||||
refresh();
|
||||
console.log('handleTaskCancelled');
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
app.eventBus.addEventListener('ws:message:async-tasks:created', handleTaskCreated);
|
||||
app.eventBus.addEventListener('ws:message:async-tasks:progress', handleTaskProgress);
|
||||
app.eventBus.addEventListener('ws:message:async-tasks:status', handleTaskStatus);
|
||||
app.eventBus.addEventListener('ws:message:async-tasks:cancelled', handleTaskCancelled);
|
||||
|
||||
return () => {
|
||||
app.eventBus.removeEventListener('ws:message:async-tasks:created', handleTaskCreated);
|
||||
app.eventBus.removeEventListener('ws:message:async-tasks:progress', handleTaskProgress);
|
||||
app.eventBus.removeEventListener('ws:message:async-tasks:status', handleTaskStatus);
|
||||
app.eventBus.removeEventListener('ws:message:async-tasks:cancelled', handleTaskCancelled);
|
||||
};
|
||||
}, [app, handleTaskCancelled, handleTaskCreated, handleTaskProgress, handleTaskStatus]);
|
||||
|
||||
return (
|
||||
tasks?.length > 0 && (
|
||||
<AsyncTasksButton {...{ tasks, refresh, popoverVisible, setPopoverVisible, hasProcessingTasks, ...others }} />
|
||||
)
|
||||
);
|
||||
};
|
||||
|
@ -1,9 +1,18 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { Plugin } from '@nocobase/server';
|
||||
import { BaseTaskManager } from './base-task-manager';
|
||||
import { AsyncTasksManager } from './interfaces/async-task-manager';
|
||||
import { CommandTaskType } from './command-task-type';
|
||||
import asyncTasksResource from './resourcers/async-tasks';
|
||||
import { throttle } from 'lodash';
|
||||
import { BaseTaskManager } from './base-task-manager';
|
||||
import { CommandTaskType } from './command-task-type';
|
||||
import { AsyncTasksManager } from './interfaces/async-task-manager';
|
||||
import asyncTasksResource from './resourcers/async-tasks';
|
||||
|
||||
export class PluginAsyncExportServer extends Plugin {
|
||||
private progressThrottles: Map<string, Function> = new Map();
|
||||
@ -20,7 +29,7 @@ export class PluginAsyncExportServer extends Plugin {
|
||||
});
|
||||
|
||||
this.app.container.get<AsyncTasksManager>('AsyncTaskManager').registerTaskType(CommandTaskType);
|
||||
this.app.acl.allow('asyncTasks', ['get', 'fetchFile'], 'loggedIn');
|
||||
this.app.acl.allow('asyncTasks', ['list', 'get', 'fetchFile', 'cancel'], 'loggedIn');
|
||||
}
|
||||
|
||||
getThrottledProgressEmitter(taskId: string, userId: string) {
|
||||
|
@ -1,8 +1,26 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import _ from 'lodash';
|
||||
import { basename } from 'path';
|
||||
|
||||
export default {
|
||||
name: 'asyncTasks',
|
||||
actions: {
|
||||
async list(ctx, next) {
|
||||
const userId = ctx.auth.user.id;
|
||||
const asyncTaskManager = ctx.app.container.get('AsyncTaskManager');
|
||||
const tasks = await asyncTaskManager.getTasksByTag('userId', userId);
|
||||
ctx.body = _.orderBy(tasks, 'createdAt', 'desc');
|
||||
await next();
|
||||
},
|
||||
async get(ctx, next) {
|
||||
const { filterByTk } = ctx.action.params;
|
||||
const taskManager = ctx.app.container.get('AsyncTaskManager');
|
||||
@ -11,8 +29,29 @@ export default {
|
||||
ctx.body = taskStatus;
|
||||
await next();
|
||||
},
|
||||
async fetchFile(ctx, next) {
|
||||
async cancel(ctx, next) {
|
||||
const { filterByTk } = ctx.action.params;
|
||||
const userId = ctx.auth.user.id;
|
||||
const asyncTaskManager = ctx.app.container.get('AsyncTaskManager');
|
||||
|
||||
const task = asyncTaskManager.getTask(filterByTk);
|
||||
|
||||
if (!task) {
|
||||
ctx.body = 'ok';
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
if (task.tags['userId'] != userId) {
|
||||
ctx.throw(403);
|
||||
}
|
||||
|
||||
const cancelled = await asyncTaskManager.cancelTask(filterByTk);
|
||||
ctx.body = cancelled;
|
||||
await next();
|
||||
},
|
||||
async fetchFile(ctx, next) {
|
||||
const { filterByTk, filename } = ctx.action.params;
|
||||
const taskManager = ctx.app.container.get('AsyncTaskManager');
|
||||
const taskStatus = await taskManager.getTaskStatus(filterByTk);
|
||||
// throw error if task is not success
|
||||
@ -28,10 +67,12 @@ export default {
|
||||
|
||||
// send file to client
|
||||
ctx.body = fs.createReadStream(filePath);
|
||||
|
||||
// 处理文件名
|
||||
let finalFileName = filename ? filename : basename(filePath);
|
||||
finalFileName = encodeURIComponent(finalFileName); // 避免中文或特殊字符问题
|
||||
ctx.set({
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Content-Disposition': `attachment; filename=${basename(filePath)}`,
|
||||
'Content-Disposition': `attachment; filename=${finalFileName}`,
|
||||
});
|
||||
|
||||
await next();
|
||||
|
@ -13,15 +13,13 @@ import React, { useEffect } from 'react';
|
||||
export const AuthProvider: React.FC = (props) => {
|
||||
const searchString = useLocationSearch();
|
||||
const app = useApp();
|
||||
const params = new URLSearchParams(searchString);
|
||||
const authenticator = params.get('authenticator');
|
||||
const token = params.get('token');
|
||||
if (token) {
|
||||
app.apiClient.auth.setToken(token);
|
||||
app.apiClient.auth.setAuthenticator(authenticator);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(searchString);
|
||||
const authenticator = params.get('authenticator');
|
||||
const token = params.get('token');
|
||||
if (token) {
|
||||
app.apiClient.auth.setToken(token);
|
||||
app.apiClient.auth.setAuthenticator(authenticator);
|
||||
}
|
||||
});
|
||||
return <>{props.children}</>;
|
||||
};
|
||||
|
@ -34,9 +34,12 @@ export const BlockTemplatePage = () => {
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
margin: -token.margin,
|
||||
marginTop: -token.marginXL,
|
||||
padding: token.paddingSM,
|
||||
marginTop: -token.marginXXL,
|
||||
marginLeft: -token.marginLG,
|
||||
marginRight: -token.marginLG,
|
||||
padding: token.paddingLG,
|
||||
paddingTop: token.paddingMD,
|
||||
paddingBottom: token.paddingMD,
|
||||
background: token.colorBgContainer,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
@ -54,7 +57,13 @@ export const BlockTemplatePage = () => {
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginTop: token.marginXL, position: 'relative', zIndex: 0 /** create a new z-index context */ }}>
|
||||
<div
|
||||
style={{
|
||||
marginTop: token.marginMD,
|
||||
position: 'relative',
|
||||
zIndex: 0 /** create a new z-index context */,
|
||||
}}
|
||||
>
|
||||
<BlockTemplateInfoContext.Provider value={data?.data}>
|
||||
<RemoteSchemaComponent uid={schemaUid} />
|
||||
</BlockTemplateInfoContext.Provider>
|
||||
|
@ -180,14 +180,14 @@ function shouldDeleteNoComponentSchema(schema: ISchema) {
|
||||
return true;
|
||||
}
|
||||
const properties = schema?.properties;
|
||||
return properties && Object.values(properties).some((s) => s['x-component'] === undefined);
|
||||
return properties && Object.values(properties).some((s) => s['x-component'] == null);
|
||||
}
|
||||
|
||||
function cleanSchema(schema?: any) {
|
||||
const properties = schema?.properties || {};
|
||||
for (const key of Object.keys(properties)) {
|
||||
// 如果x-component是undefined
|
||||
if (schema.properties[key]['x-component'] === undefined && shouldDeleteNoComponentSchema(schema.properties[key])) {
|
||||
// 如果x-component是undefined/null
|
||||
if (schema.properties[key]['x-component'] == null && shouldDeleteNoComponentSchema(schema.properties[key])) {
|
||||
delete schema.properties[key];
|
||||
}
|
||||
// 如果x-component是Grid.Row,且内部无任何内容,则删除
|
||||
@ -337,7 +337,7 @@ export function getFullSchema(
|
||||
for (const key in schema.properties) {
|
||||
const property = schema.properties[key];
|
||||
schema.properties[key] = getFullSchema(property, templateschemacache, templateInfos, savedSchemaUids);
|
||||
if (schema.properties[key]['x-component'] === undefined) {
|
||||
if (schema.properties[key]['x-component'] == null) {
|
||||
delete schema.properties[key]; // 说明已经从模板中删除了
|
||||
}
|
||||
}
|
||||
|
@ -1,8 +1,18 @@
|
||||
/**
|
||||
* 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 { defineCollection } from '@nocobase/database';
|
||||
|
||||
export default defineCollection({
|
||||
dumpRules: 'required',
|
||||
name: 'blockTemplateLinks',
|
||||
migrationRules: ['overwrite', 'schema-only'],
|
||||
fields: [
|
||||
{
|
||||
type: 'string',
|
||||
|
@ -13,6 +13,7 @@ export default defineCollection({
|
||||
dumpRules: 'required',
|
||||
name: 'blockTemplates',
|
||||
autoGenId: false,
|
||||
migrationRules: ['overwrite', 'schema-only'],
|
||||
fields: [
|
||||
{
|
||||
type: 'uid',
|
||||
|
@ -376,7 +376,7 @@ function shouldDeleteNoComponentSchema(schema: Schema) {
|
||||
return true;
|
||||
}
|
||||
const properties = schema?.properties;
|
||||
return properties && Object.values(properties).some((s) => s['x-component'] === undefined);
|
||||
return properties && Object.values(properties).some((s) => s['x-component'] == null);
|
||||
}
|
||||
|
||||
export function cleanSchema(schema?: Schema, templateId?: string) {
|
||||
@ -390,7 +390,7 @@ export function cleanSchema(schema?: Schema, templateId?: string) {
|
||||
}
|
||||
for (const key of Object.keys(properties)) {
|
||||
if (
|
||||
schema.properties[key]['x-component'] === undefined &&
|
||||
schema.properties[key]['x-component'] == null &&
|
||||
!schema.properties[key]['x-template-root-uid'] &&
|
||||
shouldDeleteNoComponentSchema(schema.properties[key])
|
||||
) {
|
||||
|
@ -9,13 +9,13 @@
|
||||
import { FileImageOutlined, LeftOutlined } from '@ant-design/icons';
|
||||
import { useActionContext } from '@nocobase/client';
|
||||
import { Html5Qrcode } from 'html5-qrcode';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ScanBox } from './ScanBox';
|
||||
import { useScanner } from './useScanner';
|
||||
|
||||
const qrcodeEleId = 'qrcode';
|
||||
export const QRCodeScannerInner = (props) => {
|
||||
export const QRCodeScannerInner = ({ setVisible }) => {
|
||||
const containerRef = useRef<HTMLDivElement>();
|
||||
const imgUploaderRef = useRef<HTMLInputElement>();
|
||||
const { t } = useTranslation('block-workbench');
|
||||
@ -23,9 +23,17 @@ export const QRCodeScannerInner = (props) => {
|
||||
const vw = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0);
|
||||
const vh = Math.max(document.documentElement.clientHeight || 0, window.innerHeight || 0);
|
||||
|
||||
const onScanSuccess = useCallback(
|
||||
(text) => {
|
||||
setVisible(false);
|
||||
},
|
||||
[setVisible],
|
||||
);
|
||||
|
||||
const { startScanFile } = useScanner({
|
||||
onScannerSizeChanged: setOriginVideoSize,
|
||||
elementId: qrcodeEleId,
|
||||
onScanSuccess,
|
||||
});
|
||||
|
||||
const getBoxStyle = (): React.CSSProperties => {
|
||||
@ -174,7 +182,7 @@ export const QRCodeScanner = (props) => {
|
||||
|
||||
return visible && cameraAvaliable ? (
|
||||
<div style={style}>
|
||||
<QRCodeScannerInner />
|
||||
<QRCodeScannerInner setVisible={setVisible} />
|
||||
<LeftOutlined style={backIconStyle} onClick={() => setVisible(false)} />
|
||||
<div style={titleStyle}>{t('Scan QR code')}</div>
|
||||
</div>
|
||||
|
@ -20,7 +20,7 @@ function removeStringIfStartsWith(text: string, prefix: string): string {
|
||||
return text;
|
||||
}
|
||||
|
||||
export function useScanner({ onScannerSizeChanged, elementId }) {
|
||||
export function useScanner({ onScannerSizeChanged, elementId, onScanSuccess }) {
|
||||
const app = useApp();
|
||||
const mobileManager = app.pm.get(MobileManager);
|
||||
const basename = mobileManager.mobileRouter.basename.replace(/\/+$/, '');
|
||||
@ -50,12 +50,17 @@ export function useScanner({ onScannerSizeChanged, elementId }) {
|
||||
},
|
||||
},
|
||||
(text) => {
|
||||
if (text?.startsWith('http')) {
|
||||
window.location.href = text;
|
||||
return;
|
||||
}
|
||||
navigate(removeStringIfStartsWith(text, basename));
|
||||
onScanSuccess && onScanSuccess(text);
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
},
|
||||
[navigate, onScannerSizeChanged, viewPoint, basename],
|
||||
[navigate, onScannerSizeChanged, viewPoint, basename, onScanSuccess],
|
||||
);
|
||||
const stopScanner = useCallback(async (scanner: Html5Qrcode) => {
|
||||
const state = scanner.getState();
|
||||
@ -69,13 +74,18 @@ export function useScanner({ onScannerSizeChanged, elementId }) {
|
||||
await stopScanner(scanner);
|
||||
try {
|
||||
const { decodedText } = await scanner.scanFileV2(file, false);
|
||||
if (decodedText?.startsWith('http')) {
|
||||
window.location.href = decodedText;
|
||||
return;
|
||||
}
|
||||
navigate(removeStringIfStartsWith(decodedText, basename));
|
||||
onScanSuccess && onScanSuccess(decodedText);
|
||||
} catch (error) {
|
||||
alert(t('QR code recognition failed, please scan again'));
|
||||
startScanCamera(scanner);
|
||||
}
|
||||
},
|
||||
[stopScanner, scanner, navigate, basename, t, startScanCamera],
|
||||
[stopScanner, scanner, navigate, basename, t, startScanCamera, onScanSuccess],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -108,7 +108,7 @@ const useEvents = (
|
||||
title: string;
|
||||
},
|
||||
date: Date,
|
||||
view: (typeof Weeks)[number],
|
||||
view: (typeof Weeks)[number] | any = 'month',
|
||||
) => {
|
||||
const parseExpression = useLazy<typeof import('cron-parser').parseExpression>(
|
||||
() => import('cron-parser'),
|
||||
@ -132,8 +132,8 @@ const useEvents = (
|
||||
const intervalTime = end.diff(start, 'millisecond', true);
|
||||
|
||||
const dateM = dayjs(date);
|
||||
const startDate = dateM.clone().startOf('month');
|
||||
const endDate = startDate.clone().endOf('month');
|
||||
const startDate = dateM.clone().startOf(view);
|
||||
const endDate = startDate.clone().endOf(view);
|
||||
|
||||
/**
|
||||
* view === month 时,会显示当月日程
|
||||
@ -425,7 +425,6 @@ export const Calendar: any = withDynamicSchemaProps(
|
||||
};
|
||||
};
|
||||
const BigCalendar = reactBigCalendar?.BigCalendar;
|
||||
|
||||
return wrapSSR(
|
||||
<div className={`${hashId} ${containerClassName}`} style={{ height: height || 700 }}>
|
||||
<PopupContextProvider visible={visible} setVisible={setVisible}>
|
||||
|
@ -55,6 +55,12 @@ describe('Web client desktopRoutes', async () => {
|
||||
},
|
||||
});
|
||||
|
||||
await rootAgent.resource('roles').setSystemRoleMode({
|
||||
values: {
|
||||
roleMode: SystemRoleMode.allowUseUnion,
|
||||
},
|
||||
});
|
||||
|
||||
agent = await app.agent().login(user, UNION_ROLE_KEY);
|
||||
});
|
||||
|
||||
|
@ -26,13 +26,16 @@ export class ErrorHandler {
|
||||
message += `: ${err.cause.message}`;
|
||||
}
|
||||
|
||||
const errorData: { message: string; code: string; title?: string } = {
|
||||
message,
|
||||
code: err.code,
|
||||
};
|
||||
|
||||
if (err?.title) {
|
||||
errorData.title = err.title;
|
||||
}
|
||||
ctx.body = {
|
||||
errors: [
|
||||
{
|
||||
message,
|
||||
code: err.code,
|
||||
},
|
||||
],
|
||||
errors: [errorData],
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -324,4 +324,100 @@ describe('issues', () => {
|
||||
}
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
test('filtering by fields of a relation collection with m2m array field', async () => {
|
||||
await db.getRepository('collections').create({
|
||||
values: {
|
||||
name: 'tags',
|
||||
fields: [
|
||||
{
|
||||
name: 'id',
|
||||
type: 'bigInt',
|
||||
autoIncrement: true,
|
||||
primaryKey: true,
|
||||
allowNull: false,
|
||||
},
|
||||
{
|
||||
name: 'title',
|
||||
type: 'string',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
await db.getRepository('collections').create({
|
||||
values: {
|
||||
name: 'users',
|
||||
fields: [
|
||||
{
|
||||
name: 'id',
|
||||
type: 'bigInt',
|
||||
autoIncrement: true,
|
||||
primaryKey: true,
|
||||
allowNull: false,
|
||||
},
|
||||
{
|
||||
name: 'username',
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
name: 'tags',
|
||||
type: 'belongsToArray',
|
||||
foreignKey: 'tag_ids',
|
||||
target: 'tags',
|
||||
targetKey: 'id',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
await db.getRepository('collections').create({
|
||||
values: {
|
||||
name: 'projects',
|
||||
fields: [
|
||||
{
|
||||
name: 'id',
|
||||
type: 'bigInt',
|
||||
autoIncrement: true,
|
||||
primaryKey: true,
|
||||
allowNull: false,
|
||||
},
|
||||
{
|
||||
name: 'title',
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
name: 'users',
|
||||
type: 'belongsTo',
|
||||
foreignKey: 'user_id',
|
||||
target: 'users',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
// @ts-ignore
|
||||
await db.getRepository('collections').load();
|
||||
await db.sync();
|
||||
await db.getRepository('tags').create({
|
||||
values: [{ title: 'a' }, { title: 'b' }, { title: 'c' }],
|
||||
});
|
||||
await db.getRepository('users').create({
|
||||
values: { id: 1, username: 'a' },
|
||||
});
|
||||
await db.getRepository('projects').create({
|
||||
values: { id: 1, title: 'p1', user_id: 1 },
|
||||
});
|
||||
await expect(
|
||||
db.getRepository('projects').findOne({
|
||||
appends: ['users', 'users.tags'],
|
||||
filter: {
|
||||
$and: [
|
||||
{
|
||||
users: {
|
||||
username: 'a',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
).resolves.toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
@ -207,15 +207,8 @@ describe('m2m array api, bigInt targetKey', () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
if (db.sequelize.getDialect() === 'postgres') {
|
||||
const res = await search;
|
||||
expect(res.length).toBe(1);
|
||||
} else {
|
||||
expect(search).rejects.toThrowError();
|
||||
}
|
||||
if (db.sequelize.getDialect() !== 'postgres') {
|
||||
return;
|
||||
}
|
||||
const res1 = await search;
|
||||
expect(res1.length).toBe(1);
|
||||
const search2 = db.getRepository('users').find({
|
||||
filter: {
|
||||
'tags.title': {
|
||||
@ -223,12 +216,8 @@ describe('m2m array api, bigInt targetKey', () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
if (db.sequelize.getDialect() === 'postgres') {
|
||||
const res = await search2;
|
||||
expect(res.length).toBe(2);
|
||||
} else {
|
||||
expect(search2).rejects.toThrowError();
|
||||
}
|
||||
const res2 = await search2;
|
||||
expect(res2.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should create with belongsToArray', async () => {
|
||||
|
@ -186,15 +186,8 @@ describe('m2m array api, string targetKey', () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
if (db.sequelize.getDialect() === 'postgres') {
|
||||
const res = await search;
|
||||
expect(res.length).toBe(1);
|
||||
} else {
|
||||
expect(search).rejects.toThrowError();
|
||||
}
|
||||
if (db.sequelize.getDialect() !== 'postgres') {
|
||||
return;
|
||||
}
|
||||
const res1 = await search;
|
||||
expect(res1.length).toBe(1);
|
||||
const search2 = db.getRepository('users').find({
|
||||
filter: {
|
||||
'tags.title': {
|
||||
@ -202,12 +195,8 @@ describe('m2m array api, string targetKey', () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
if (db.sequelize.getDialect() === 'postgres') {
|
||||
const res = await search2;
|
||||
expect(res.length).toBe(2);
|
||||
} else {
|
||||
expect(search2).rejects.toThrowError();
|
||||
}
|
||||
const res2 = await search2;
|
||||
expect(res2.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should create with belongsToArray', async () => {
|
||||
|
@ -21,6 +21,7 @@ export default defineCollection({
|
||||
type: 'string',
|
||||
name: 'title',
|
||||
translation: true,
|
||||
trim: true,
|
||||
},
|
||||
{
|
||||
title: '英文标识',
|
||||
@ -28,6 +29,7 @@ export default defineCollection({
|
||||
type: 'uid',
|
||||
name: 'name',
|
||||
unique: true,
|
||||
trim: true,
|
||||
},
|
||||
{
|
||||
comment: '类型标识,如 local/ali-oss 等',
|
||||
@ -51,12 +53,14 @@ export default defineCollection({
|
||||
type: 'text',
|
||||
name: 'path',
|
||||
defaultValue: '',
|
||||
trim: true,
|
||||
},
|
||||
{
|
||||
comment: '访问地址前缀',
|
||||
type: 'string',
|
||||
name: 'baseUrl',
|
||||
defaultValue: '',
|
||||
trim: true,
|
||||
},
|
||||
// TODO(feature): 需要使用一个实现了可设置默认值的字段
|
||||
{
|
||||
|
@ -104,10 +104,6 @@ export const GanttBlockProvider = (props) => {
|
||||
const collection = cm.getCollection(props.collection, props.dataSource);
|
||||
const params = { filter: props.params?.filter, paginate: false, sort: [collection?.primaryKey || 'id'] };
|
||||
|
||||
if (collection?.tree) {
|
||||
params['tree'] = true;
|
||||
}
|
||||
|
||||
return (
|
||||
<div aria-label="block-item-gantt" role="button">
|
||||
<TableBlockProvider {...props} params={params}>
|
||||
|
@ -0,0 +1,2 @@
|
||||
/node_modules
|
||||
/src
|
@ -0,0 +1 @@
|
||||
# @nocobase/plugin-locale-tester
|
2
packages/plugins/@nocobase/plugin-locale-tester/client.d.ts
vendored
Normal file
2
packages/plugins/@nocobase/plugin-locale-tester/client.d.ts
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './dist/client';
|
||||
export { default } from './dist/client';
|
@ -0,0 +1 @@
|
||||
module.exports = require('./dist/client/index.js');
|
13
packages/plugins/@nocobase/plugin-locale-tester/package.json
Normal file
13
packages/plugins/@nocobase/plugin-locale-tester/package.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "@nocobase/plugin-locale-tester",
|
||||
"displayName": "Locale tester",
|
||||
"displayName.zh-CN": "翻译测试工具",
|
||||
"version": "1.7.0-alpha.10",
|
||||
"homepage": "https://github.com/nocobase/locales",
|
||||
"main": "dist/server/index.js",
|
||||
"peerDependencies": {
|
||||
"@nocobase/client": "1.x",
|
||||
"@nocobase/server": "1.x",
|
||||
"@nocobase/test": "1.x"
|
||||
}
|
||||
}
|
2
packages/plugins/@nocobase/plugin-locale-tester/server.d.ts
vendored
Normal file
2
packages/plugins/@nocobase/plugin-locale-tester/server.d.ts
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './dist/server';
|
||||
export { default } from './dist/server';
|
@ -0,0 +1 @@
|
||||
module.exports = require('./dist/server/index.js');
|
249
packages/plugins/@nocobase/plugin-locale-tester/src/client/client.d.ts
vendored
Normal file
249
packages/plugins/@nocobase/plugin-locale-tester/src/client/client.d.ts
vendored
Normal file
@ -0,0 +1,249 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
// CSS modules
|
||||
type CSSModuleClasses = { readonly [key: string]: string };
|
||||
|
||||
declare module '*.module.css' {
|
||||
const classes: CSSModuleClasses;
|
||||
export default classes;
|
||||
}
|
||||
declare module '*.module.scss' {
|
||||
const classes: CSSModuleClasses;
|
||||
export default classes;
|
||||
}
|
||||
declare module '*.module.sass' {
|
||||
const classes: CSSModuleClasses;
|
||||
export default classes;
|
||||
}
|
||||
declare module '*.module.less' {
|
||||
const classes: CSSModuleClasses;
|
||||
export default classes;
|
||||
}
|
||||
declare module '*.module.styl' {
|
||||
const classes: CSSModuleClasses;
|
||||
export default classes;
|
||||
}
|
||||
declare module '*.module.stylus' {
|
||||
const classes: CSSModuleClasses;
|
||||
export default classes;
|
||||
}
|
||||
declare module '*.module.pcss' {
|
||||
const classes: CSSModuleClasses;
|
||||
export default classes;
|
||||
}
|
||||
declare module '*.module.sss' {
|
||||
const classes: CSSModuleClasses;
|
||||
export default classes;
|
||||
}
|
||||
|
||||
// CSS
|
||||
declare module '*.css' { }
|
||||
declare module '*.scss' { }
|
||||
declare module '*.sass' { }
|
||||
declare module '*.less' { }
|
||||
declare module '*.styl' { }
|
||||
declare module '*.stylus' { }
|
||||
declare module '*.pcss' { }
|
||||
declare module '*.sss' { }
|
||||
|
||||
// Built-in asset types
|
||||
// see `src/node/constants.ts`
|
||||
|
||||
// images
|
||||
declare module '*.apng' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.png' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.jpg' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.jpeg' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.jfif' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.pjpeg' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.pjp' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.gif' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.svg' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.ico' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.webp' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.avif' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
// media
|
||||
declare module '*.mp4' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.webm' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.ogg' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.mp3' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.wav' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.flac' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.aac' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.opus' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.mov' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.m4a' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.vtt' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
// fonts
|
||||
declare module '*.woff' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.woff2' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.eot' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.ttf' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.otf' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
// other
|
||||
declare module '*.webmanifest' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.pdf' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.txt' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
// wasm?init
|
||||
declare module '*.wasm?init' {
|
||||
const initWasm: (options?: WebAssembly.Imports) => Promise<WebAssembly.Instance>;
|
||||
export default initWasm;
|
||||
}
|
||||
|
||||
// web worker
|
||||
declare module '*?worker' {
|
||||
const workerConstructor: {
|
||||
new(options?: { name?: string }): Worker;
|
||||
};
|
||||
export default workerConstructor;
|
||||
}
|
||||
|
||||
declare module '*?worker&inline' {
|
||||
const workerConstructor: {
|
||||
new(options?: { name?: string }): Worker;
|
||||
};
|
||||
export default workerConstructor;
|
||||
}
|
||||
|
||||
declare module '*?worker&url' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
declare module '*?sharedworker' {
|
||||
const sharedWorkerConstructor: {
|
||||
new(options?: { name?: string }): SharedWorker;
|
||||
};
|
||||
export default sharedWorkerConstructor;
|
||||
}
|
||||
|
||||
declare module '*?sharedworker&inline' {
|
||||
const sharedWorkerConstructor: {
|
||||
new(options?: { name?: string }): SharedWorker;
|
||||
};
|
||||
export default sharedWorkerConstructor;
|
||||
}
|
||||
|
||||
declare module '*?sharedworker&url' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
declare module '*?raw' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
declare module '*?url' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
declare module '*?inline' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
@ -0,0 +1,124 @@
|
||||
/**
|
||||
* 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 { useForm } from '@formily/react';
|
||||
import { ActionProps, ISchema, Plugin, SchemaComponent, useAPIClient, useApp, useRequest } from '@nocobase/client';
|
||||
import { Alert, App as AntdApp, Card, Spin } from 'antd';
|
||||
import React from 'react';
|
||||
import { useT } from './locale';
|
||||
|
||||
function LocaleTester() {
|
||||
const { data, loading } = useRequest<any>({
|
||||
url: 'localeTester:get',
|
||||
});
|
||||
const t = useT();
|
||||
|
||||
const schema: ISchema = {
|
||||
type: 'void',
|
||||
name: 'root',
|
||||
properties: {
|
||||
test: {
|
||||
type: 'void',
|
||||
'x-component': 'FormV2',
|
||||
properties: {
|
||||
locale: {
|
||||
type: 'string',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Input.JSON',
|
||||
'x-component-props': {
|
||||
autoSize: { minRows: 20, maxRows: 30 },
|
||||
},
|
||||
default: data?.data?.locale,
|
||||
title: t('Translations'),
|
||||
},
|
||||
button: {
|
||||
type: 'void',
|
||||
'x-component': 'Action',
|
||||
title: t('Submit'),
|
||||
'x-use-component-props': 'useSubmitActionProps',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const useSubmitActionProps = () => {
|
||||
const form = useForm();
|
||||
const api = useAPIClient();
|
||||
const { message } = AntdApp.useApp();
|
||||
const app = useApp();
|
||||
|
||||
return {
|
||||
type: 'primary',
|
||||
htmlType: 'submit',
|
||||
async onClick() {
|
||||
await form.submit();
|
||||
const values = form.values;
|
||||
await api.request({
|
||||
url: 'localeTester:updateOrCreate',
|
||||
method: 'post',
|
||||
params: {
|
||||
filterKeys: ['id'],
|
||||
},
|
||||
data: {
|
||||
id: data?.data?.id,
|
||||
locale: values.locale,
|
||||
},
|
||||
});
|
||||
message.success(app.i18n.t('Saved successfully!'));
|
||||
window.location.reload();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <Spin />;
|
||||
}
|
||||
return (
|
||||
<Card>
|
||||
<Alert
|
||||
style={{ marginBottom: 12 }}
|
||||
description={
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: t(
|
||||
`Please go to <a target="_blank" href="https://github.com/nocobase/locales">nocobase/locales</a> to get the language file that needs translation, then paste it below and provide the translation.`,
|
||||
),
|
||||
}}
|
||||
></div>
|
||||
}
|
||||
/>
|
||||
<SchemaComponent schema={schema} scope={{ useSubmitActionProps }} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export class PluginLocaleTesterClient extends Plugin {
|
||||
async afterAdd() {
|
||||
// await this.app.pm.add()
|
||||
}
|
||||
|
||||
async beforeLoad() {}
|
||||
|
||||
// You can get and modify the app instance here
|
||||
async load() {
|
||||
this.app.pluginSettingsManager.add('locale-tester', {
|
||||
title: this.t('Locale tester'),
|
||||
icon: 'TranslationOutlined',
|
||||
Component: LocaleTester,
|
||||
});
|
||||
// this.app.addComponents({})
|
||||
// this.app.addScopes({})
|
||||
// this.app.addProvider()
|
||||
// this.app.addProviders()
|
||||
// this.app.router.add()
|
||||
}
|
||||
}
|
||||
|
||||
export default PluginLocaleTesterClient;
|
@ -0,0 +1,21 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
// @ts-ignore
|
||||
import pkg from './../../package.json';
|
||||
import { useApp } from '@nocobase/client';
|
||||
|
||||
export function useT() {
|
||||
const app = useApp();
|
||||
return (str: string) => app.i18n.t(str, { ns: [pkg.name, 'client'] });
|
||||
}
|
||||
|
||||
export function tStr(key: string) {
|
||||
return `{{t(${JSON.stringify(key)}, { ns: ['${pkg.name}', 'client'], nsMode: 'fallback' })}}`;
|
||||
}
|
11
packages/plugins/@nocobase/plugin-locale-tester/src/index.ts
Normal file
11
packages/plugins/@nocobase/plugin-locale-tester/src/index.ts
Normal file
@ -0,0 +1,11 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
export * from './server';
|
||||
export { default } from './server';
|
@ -0,0 +1,5 @@
|
||||
{
|
||||
"Locale": "Locale",
|
||||
"Locale tester": "Locale tester",
|
||||
"Please go to <a target=\"_blank\" href=\"https://github.com/nocobase/locales\">nocobase/locales</a> to get the language file that needs translation, then paste it below and provide the translation.": "Please go to <a target=\"_blank\" href=\"https://github.com/nocobase/locales\">nocobase/locales</a> to get the language file that needs translation, then paste it below and provide the translation."
|
||||
}
|
@ -0,0 +1,5 @@
|
||||
{
|
||||
"Translations": "翻译",
|
||||
"Locale tester": "翻译测试工具",
|
||||
"Please go to <a target=\"_blank\" href=\"https://github.com/nocobase/locales\">nocobase/locales</a> to get the language file that needs translation, then paste it below and provide the translation.": "请前往 <a target=\"_blank\" href=\"https://github.com/nocobase/locales\">nocobase/locales</a> 获取需要翻译的语言文件,粘贴到下方并进行翻译。"
|
||||
}
|
@ -0,0 +1,21 @@
|
||||
/**
|
||||
* 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 { defineCollection } from '@nocobase/database';
|
||||
|
||||
export default defineCollection({
|
||||
name: 'localeTester',
|
||||
autoGenId: true,
|
||||
fields: [
|
||||
{
|
||||
type: 'json',
|
||||
name: 'locale',
|
||||
},
|
||||
],
|
||||
});
|
@ -0,0 +1,10 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
export { default } from './plugin';
|
@ -0,0 +1,59 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { Plugin } from '@nocobase/server';
|
||||
import _ from 'lodash';
|
||||
|
||||
export class PluginLocaleTesterServer extends Plugin {
|
||||
async afterAdd() {}
|
||||
|
||||
async beforeLoad() {
|
||||
this.app.acl.registerSnippet({
|
||||
name: `pm.${this.name}`,
|
||||
actions: ['localeTester:*'],
|
||||
});
|
||||
}
|
||||
|
||||
async load() {
|
||||
this.app.resourceManager.use(async (ctx, next) => {
|
||||
await next();
|
||||
const { resourceName, actionName } = ctx.action;
|
||||
if (resourceName === 'app' && actionName === 'getLang') {
|
||||
const repository = this.db.getRepository('localeTester');
|
||||
const record = await repository.findOne();
|
||||
const locale = record?.locale || {};
|
||||
if (locale['cronstrue']) {
|
||||
_.set(ctx.body, 'cronstrue', locale['cronstrue']);
|
||||
}
|
||||
if (locale['react-js-cron']) {
|
||||
_.set(ctx.body, 'cron', locale['react-js-cron']);
|
||||
}
|
||||
Object.keys(locale).forEach((key) => {
|
||||
if (key === 'cronstrue' || key === 'react-js-cron') {
|
||||
return;
|
||||
}
|
||||
const value = locale[key];
|
||||
_.set(ctx.body, ['resources', key], value);
|
||||
const k = key.replace('@nocobase/', '').replace('@nocobase/plugin-', '');
|
||||
_.set(ctx.body, ['resources', k], value);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async install() {}
|
||||
|
||||
async afterEnable() {}
|
||||
|
||||
async afterDisable() {}
|
||||
|
||||
async remove() {}
|
||||
}
|
||||
|
||||
export default PluginLocaleTesterServer;
|
@ -32,15 +32,26 @@ const BaseConfiguration: React.FC<BaseConfigurationProps> = ({ type, children })
|
||||
return apiClient.resource(MapConfigurationResourceKey);
|
||||
}, [apiClient]);
|
||||
|
||||
function removeInvisibleCharsFromObject(obj: Record<string, string>): Record<string, string> {
|
||||
const cleanObj: Record<string, string> = {};
|
||||
function removeInvisibleCharsFromObject(obj: Record<string, string>): Record<string, string | null> {
|
||||
const cleanObj: Record<string, string | null> = {};
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
cleanObj[key] = typeof value === 'string' ? value.replace(/[\p{C}\p{Z}\p{Zl}\p{Zp}]+/gu, '') : value;
|
||||
if (typeof value === 'string') {
|
||||
// 去除不可见字符
|
||||
const cleanedValue = value.replace(/[\p{C}\p{Z}\p{Zl}\p{Zp}]+/gu, '');
|
||||
// 如果清理后为空字符串,则赋值为 null
|
||||
cleanObj[key] = cleanedValue || null;
|
||||
}
|
||||
}
|
||||
|
||||
return cleanObj;
|
||||
}
|
||||
|
||||
const onSubmit = async (values) => {
|
||||
// 移除不可见字符并更新表单值
|
||||
const result = removeInvisibleCharsFromObject(values);
|
||||
form.setFieldsValue(result);
|
||||
|
||||
// 等待表单值更新完成后再校验
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await form.validateFields();
|
||||
resource
|
||||
.set({
|
||||
|
@ -16,6 +16,7 @@ import { useTranslation } from 'react-i18next';
|
||||
const MobilePicker = connect(
|
||||
(props) => {
|
||||
const { value, onChange, disabled, options = [], mode } = props;
|
||||
console.log(props);
|
||||
const { t } = useTranslation();
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [selected, setSelected] = useState(value || []);
|
||||
@ -42,7 +43,7 @@ const MobilePicker = connect(
|
||||
disabled={disabled}
|
||||
value={value}
|
||||
dropdownStyle={{ display: 'none' }}
|
||||
multiple={mode === 'multiple'}
|
||||
multiple={['multiple', 'tags'].includes(mode)}
|
||||
onClear={() => {
|
||||
setVisible(false);
|
||||
onChange(null);
|
||||
@ -77,10 +78,10 @@ const MobilePicker = connect(
|
||||
}}
|
||||
>
|
||||
<CheckList
|
||||
multiple={mode === 'multiple'}
|
||||
multiple={['multiple', 'tags'].includes(mode)}
|
||||
value={Array.isArray(selected) ? selected : [selected] || []}
|
||||
onChange={(val) => {
|
||||
if (mode === 'multiple') {
|
||||
if (['multiple', 'tags'].includes(mode)) {
|
||||
setSelected(val);
|
||||
} else {
|
||||
setSelected(val[0]);
|
||||
@ -96,7 +97,7 @@ const MobilePicker = connect(
|
||||
))}
|
||||
</CheckList>
|
||||
</div>
|
||||
{mode === 'multiple' && (
|
||||
{['multiple', 'tags'].includes(mode) && (
|
||||
<Button block color="primary" onClick={handleConfirm} style={{ marginTop: '16px' }}>
|
||||
{t('Confirm')}
|
||||
</Button>
|
||||
|
@ -54,7 +54,11 @@ describe('union role mobileRoutes', async () => {
|
||||
roles: [role1.name, role2.name],
|
||||
},
|
||||
});
|
||||
|
||||
await rootAgent.resource('roles').setSystemRoleMode({
|
||||
values: {
|
||||
roleMode: SystemRoleMode.allowUseUnion,
|
||||
},
|
||||
});
|
||||
agent = await app.agent().login(user, UNION_ROLE_KEY);
|
||||
});
|
||||
|
||||
|
@ -30,8 +30,16 @@ import {
|
||||
} from '../../observables';
|
||||
import InfiniteScrollContent from './InfiniteScrollContent';
|
||||
|
||||
function removeStringIfStartsWith(text: string, prefix: string): string {
|
||||
if (text.startsWith(prefix)) {
|
||||
return text.slice(prefix.length);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
const MobileMessagePageInner = (props: { displayPageHeader?: boolean }) => {
|
||||
const app = useApp();
|
||||
const basename = app.router.basename.replace(/\/+$/, '');
|
||||
const { t } = useLocalTranslation();
|
||||
const navigate = useNavigate();
|
||||
const ctx = useCurrentUserContext();
|
||||
@ -57,7 +65,7 @@ const MobileMessagePageInner = (props: { displayPageHeader?: boolean }) => {
|
||||
if (url) {
|
||||
if (url.startsWith('/m/')) navigate(url.substring(2));
|
||||
else if (url.startsWith('/')) {
|
||||
navigate(url);
|
||||
navigate(removeStringIfStartsWith(url, basename));
|
||||
inboxVisible.value = false;
|
||||
} else {
|
||||
window.location.href = url;
|
||||
|
@ -147,6 +147,7 @@ export function AdminPublicFormPage() {
|
||||
}}
|
||||
>
|
||||
<Breadcrumb
|
||||
style={{ marginLeft: '10px' }}
|
||||
items={[
|
||||
{
|
||||
title: <Link to={`/admin/settings/public-forms`}>{t('Public forms', { ns: NAMESPACE })}</Link>,
|
||||
|
@ -33,7 +33,7 @@ import {
|
||||
import { Input, Modal, Spin } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import React, { createContext, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { isDesktop } from 'react-device-detect';
|
||||
import { isDesktop, isMobile } from 'react-device-detect';
|
||||
import { useParams } from 'react-router';
|
||||
import { usePublicSubmitActionProps } from '../hooks';
|
||||
import { UnEnabledFormPlaceholder, UnFoundFormPlaceholder } from './UnEnabledFormPlaceholder';
|
||||
@ -129,9 +129,6 @@ const PublicFormMessageProvider = ({ children }) => {
|
||||
</PublicFormMessageContext.Provider>
|
||||
);
|
||||
};
|
||||
function isMobile() {
|
||||
return window.matchMedia('(max-width: 768px)').matches;
|
||||
}
|
||||
|
||||
const AssociationFieldMobile = (props) => {
|
||||
return <AssociationField {...props} popupMatchSelectWidth={true} />;
|
||||
@ -165,7 +162,6 @@ const mobileComponents = {
|
||||
function InternalPublicForm() {
|
||||
const params = useParams();
|
||||
const apiClient = useAPIClient();
|
||||
const isMobileMedia = isMobile();
|
||||
const { error, data, loading, run } = useRequest<any>(
|
||||
{
|
||||
url: `publicForms:getMeta/${params.name}`,
|
||||
@ -243,7 +239,7 @@ function InternalPublicForm() {
|
||||
if (!data?.data) {
|
||||
return <UnEnabledFormPlaceholder />;
|
||||
}
|
||||
const components = isMobileMedia ? mobileComponents : {};
|
||||
const components = isMobile ? mobileComponents : {};
|
||||
return (
|
||||
<ACLCustomContext.Provider value={{ allowAll: true }}>
|
||||
<PublicAPIClientProvider>
|
||||
|
@ -172,7 +172,7 @@ export class PluginPublicFormsServer extends Plugin {
|
||||
skip: true,
|
||||
};
|
||||
} else if (
|
||||
(actionName === 'list' && ctx.PublicForm['targetCollections'].includes(resourceName)) ||
|
||||
(['list', 'get'].includes(actionName) && ctx.PublicForm['targetCollections'].includes(resourceName)) ||
|
||||
(collection?.options.template === 'file' && actionName === 'create') ||
|
||||
(resourceName === 'storages' && ['getBasicInfo', 'createPresignedUrl'].includes(actionName)) ||
|
||||
(resourceName === 'vditor' && ['check'].includes(actionName)) ||
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user