Merge branch 'develop' into T-1952

This commit is contained in:
katherinehhh 2025-04-08 17:39:28 +08:00
commit 048f05156a
112 changed files with 2240 additions and 655 deletions

View File

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

View File

@ -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
### 🐛 修复

View File

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

View File

@ -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&#0045;first&#0044;&#0032;open&#0045;source&#0032;no&#0045;code&#0032;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. データモデル駆動

View File

@ -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&#0045;first&#0044;&#0032;open&#0045;source&#0032;no&#0045;code&#0032;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

View File

@ -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&#0045;first&#0044;&#0032;open&#0045;source&#0032;no&#0045;code&#0032;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. 数据模型驱动

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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)) {

View File

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

View File

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

View File

@ -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":"冒号"
}

View File

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

View File

@ -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={[

View File

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

View File

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

View File

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

View File

@ -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;
}
if (options.length === 1 && !options[0].value) {
onChange?.(null);
} else {
onChange?.(options);
}
};
const onDropdownVisibleChange = async (visible, selectedValue, index) => {
@ -238,17 +244,17 @@ export const InternalCascadeSelect = observer(
const fieldSchema = useFieldSchema();
const { loading, data: formData } = useDataBlockRequest() || {};
const initialValue = formData?.data?.[fieldSchema.name];
useEffect(() => {
const id = uid();
selectForm.addEffects(id, () => {
onFormValuesChange((form) => {
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,
);
@ -256,10 +262,20 @@ export const InternalCascadeSelect = observer(
field.value = value;
});
}
}, 300);
useEffect(() => {
const id = uid();
selectForm.addEffects(id, () => {
onFormValuesChange((form) => {
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',

View File

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

View File

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

View File

@ -65,7 +65,6 @@ export const DynamicComponent = (props: Props) => {
...props.style,
},
utc: false,
underFilter: true,
}),
name: 'value',
'x-read-pretty': false,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,8 +146,11 @@ export const conditionAnalyses = async (
if (type === '$and') {
return every(results, (v) => v);
} else {
if (results.length) {
return some(results, (v) => v);
}
return true;
}
};
/**

View File

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

View File

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

View File

@ -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'],

View File

@ -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) => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -29,13 +29,12 @@ 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);
if (!value && instance.isNewRecord && defaultToCurrentTime) {
@ -48,7 +47,6 @@ export class DatetimeNoTzField extends Field {
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);
},
};
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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
? {
items={{
asyncTasks: { order: 300, component: 'AsyncTasks', pin: true, snippet: '*' },
}
: {}
}
}}
>
<SchemaComponentOptions components={{ AsyncTasks }}>{props.children}</SchemaComponentOptions>
</PinnedPluginListProvider>
</AsyncTaskContext.Provider>
);
};

View File

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

View File

@ -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) {

View File

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

View File

@ -13,8 +13,6 @@ import React, { useEffect } from 'react';
export const AuthProvider: React.FC = (props) => {
const searchString = useLocationSearch();
const app = useApp();
useEffect(() => {
const params = new URLSearchParams(searchString);
const authenticator = params.get('authenticator');
const token = params.get('token');
@ -22,6 +20,6 @@ export const AuthProvider: React.FC = (props) => {
app.apiClient.auth.setToken(token);
app.apiClient.auth.setAuthenticator(authenticator);
}
});
return <>{props.children}</>;
};

View File

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

View File

@ -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]; // 说明已经从模板中删除了
}
}

View File

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

View File

@ -13,6 +13,7 @@ export default defineCollection({
dumpRules: 'required',
name: 'blockTemplates',
autoGenId: false,
migrationRules: ['overwrite', 'schema-only'],
fields: [
{
type: 'uid',

View File

@ -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])
) {

View File

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

View File

@ -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(() => {

View File

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

View File

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

View File

@ -26,13 +26,16 @@ export class ErrorHandler {
message += `: ${err.cause.message}`;
}
ctx.body = {
errors: [
{
const errorData: { message: string; code: string; title?: string } = {
message,
code: err.code,
},
],
};
if (err?.title) {
errorData.title = err.title;
}
ctx.body = {
errors: [errorData],
};
}

View File

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

View File

@ -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 () => {

View File

@ -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 () => {

View File

@ -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): 需要使用一个实现了可设置默认值的字段
{

View File

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

View File

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

View File

@ -0,0 +1 @@
# @nocobase/plugin-locale-tester

View File

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

View File

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

View File

@ -0,0 +1,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"
}
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,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;

View File

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

View File

@ -0,0 +1,11 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
export * from './server';
export { default } from './server';

View File

@ -0,0 +1,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."
}

View File

@ -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> 获取需要翻译的语言文件,粘贴到下方并进行翻译。"
}

View File

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

View File

@ -0,0 +1,10 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
export { default } from './plugin';

View File

@ -0,0 +1,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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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