Merge branch 'develop' into feat/json-templates

This commit is contained in:
Sheldon Guo 2025-02-27 07:02:04 +08:00 committed by GitHub
commit 2608e8d4a9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
161 changed files with 4989 additions and 2052 deletions

View File

@ -5,6 +5,62 @@ 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.5.16](https://github.com/nocobase/nocobase/compare/v1.5.15...v1.5.16) - 2025-02-26
### 🚀 Improvements
- **[Backup manager]** Allow restoring backup to an application even it is missing some plugins by @gchust
### 🐛 Bug Fixes
- **[client]** rich text field component cannot be fully cleared ([#6287](https://github.com/nocobase/nocobase/pull/6287)) by @katherinehhh
- **[File manager]**
- Fix migration and add test cases ([#6288](https://github.com/nocobase/nocobase/pull/6288)) by @mytharcher
- Fix `path` column type of file collection ([#6294](https://github.com/nocobase/nocobase/pull/6294)) by @mytharcher
- Fix migration and add test cases ([#6288](https://github.com/nocobase/nocobase/pull/6288)) by @mytharcher
## [v1.5.15](https://github.com/nocobase/nocobase/compare/v1.5.14...v1.5.15) - 2025-02-25
### 🚀 Improvements
- **[File manager]**
- Increase URL length to 1024 ([#6275](https://github.com/nocobase/nocobase/pull/6275)) by @mytharcher
- File names during upload will change from random to the original name with a random suffix. ([#6217](https://github.com/nocobase/nocobase/pull/6217)) by @chenos
- **[Block: Action panel]** Optimize mobile styles ([#6270](https://github.com/nocobase/nocobase/pull/6270)) by @zhangzhonghe
### 🐛 Bug Fixes
- **[cli]** Improve internal logic of nocobase upgrade command ([#6280](https://github.com/nocobase/nocobase/pull/6280)) by @chenos
## [v1.5.14](https://github.com/nocobase/nocobase/compare/v1.5.13...v1.5.14) - 2025-02-24
### 🐛 Bug Fixes
- **[Backup manager]** The delete icon of the restore from local operation dialog is not working by @gchust
## [v1.5.13](https://github.com/nocobase/nocobase/compare/v1.5.12...v1.5.13) - 2025-02-22
### 🐛 Bug Fixes
- **[client]** Fix uploaded file missed when one by one ([#6260](https://github.com/nocobase/nocobase/pull/6260)) by @mytharcher
- **[Workflow: Pre-action event]** Fix error message from response message node not shown by @mytharcher
## [v1.5.12](https://github.com/nocobase/nocobase/compare/v1.5.11...v1.5.12) - 2025-02-21
### 🚀 Improvements
- **[Workflow]** Hide node id from node card in workflow canvas ([#6251](https://github.com/nocobase/nocobase/pull/6251)) by @mytharcher
### 🐛 Bug Fixes
- **[File manager]** Upgrade AWS SDK version to fix MinIO upload bug ([#6253](https://github.com/nocobase/nocobase/pull/6253)) by @mytharcher
## [v1.5.11](https://github.com/nocobase/nocobase/compare/v1.5.10...v1.5.11) - 2025-02-20
### 🎉 New Features

View File

@ -5,6 +5,62 @@
格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/),
并且本项目遵循 [语义化版本](https://semver.org/spec/v2.0.0.html)。
## [v1.5.16](https://github.com/nocobase/nocobase/compare/v1.5.15...v1.5.16) - 2025-02-26
### 🚀 优化
- **[备份管理器]** 允许还原备份到缺少部分插件的应用 by @gchust
### 🐛 修复
- **[client]** 富文本字段组件无法删除清空所有内容 ([#6287](https://github.com/nocobase/nocobase/pull/6287)) by @katherinehhh
- **[文件管理器]**
- 修复迁移脚本并补充测试用例 ([#6288](https://github.com/nocobase/nocobase/pull/6288)) by @mytharcher
- 修复文件表 `path` 列的类型 ([#6294](https://github.com/nocobase/nocobase/pull/6294)) by @mytharcher
- 修复迁移脚本并补充测试用例 ([#6288](https://github.com/nocobase/nocobase/pull/6288)) by @mytharcher
## [v1.5.15](https://github.com/nocobase/nocobase/compare/v1.5.14...v1.5.15) - 2025-02-25
### 🚀 优化
- **[文件管理器]**
- URL 字段长度增加为 1024 ([#6275](https://github.com/nocobase/nocobase/pull/6275)) by @mytharcher
- 文件上传时生成的文件名由随机改成文件名加随机后缀。 ([#6217](https://github.com/nocobase/nocobase/pull/6217)) by @chenos
- **[区块:操作面板]** 优化移动端样式 ([#6270](https://github.com/nocobase/nocobase/pull/6270)) by @zhangzhonghe
### 🐛 修复
- **[cli]** 优化 nocobase upgrade 命令行 ([#6280](https://github.com/nocobase/nocobase/pull/6280)) by @chenos
## [v1.5.14](https://github.com/nocobase/nocobase/compare/v1.5.13...v1.5.14) - 2025-02-24
### 🐛 修复
- **[备份管理器]** 在"从本地备份还原"操作弹窗中,点击删除图标不会清空文件列表 by @gchust
## [v1.5.13](https://github.com/nocobase/nocobase/compare/v1.5.12...v1.5.13) - 2025-02-22
### 🐛 修复
- **[client]** 修复逐个上传文件后之前的文件消失的问题 ([#6260](https://github.com/nocobase/nocobase/pull/6260)) by @mytharcher
- **[工作流:操作前事件]** 修复响应消息节点的错误消息不显示的问题 by @mytharcher
## [v1.5.12](https://github.com/nocobase/nocobase/compare/v1.5.11...v1.5.12) - 2025-02-21
### 🚀 优化
- **[工作流]** 在工作流画布的节点上隐藏节点 ID ([#6251](https://github.com/nocobase/nocobase/pull/6251)) by @mytharcher
### 🐛 修复
- **[文件管理器]** 升级 AWS SDK 版本以修复 MinIO 上传问题 ([#6253](https://github.com/nocobase/nocobase/pull/6253)) by @mytharcher
## [v1.5.11](https://github.com/nocobase/nocobase/compare/v1.5.10...v1.5.11) - 2025-02-20
### 🎉 新特性

View File

@ -59,8 +59,8 @@ FROM node:20-bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends wget gnupg \
&& rm -rf /var/lib/apt/lists/*
RUN sh -c 'echo "deb http://mirrors.ustc.edu.cn/postgresql/repos/apt bookworm-pgdg main" > /etc/apt/sources.list.d/pgdg.list'
RUN wget --quiet -O - http://mirrors.ustc.edu.cn/postgresql/repos/apt/ACCC4CF8.asc | apt-key add -
RUN sh -c 'echo "deb http://mirrors.aliyun.com/postgresql/repos/apt bookworm-pgdg main" > /etc/apt/sources.list.d/pgdg.list'
RUN wget --quiet -O - http://mirrors.aliyun.com/postgresql/repos/apt/ACCC4CF8.asc | apt-key add -
RUN apt-get update && apt-get install -y --no-install-recommends \
nginx \

View File

@ -44,15 +44,16 @@
"run:example": "tsx -r dotenv/config -r tsconfig-paths/register ./examples/index.ts"
},
"resolutions": {
"@types/react": "^17.0.0",
"@types/react-dom": "^17.0.0",
"@types/react": "18.3.18",
"@types/react-dom": "^18.0.0",
"@typescript-eslint/parser": "^6.2.0",
"react-router-dom": "^6.11.2",
"react-router": "^6.11.2",
"react-router-dom": "6.28.1",
"react-router": "6.28.1",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"nwsapi": "2.2.7",
"antd": "5.12.8"
"antd": "5.12.8",
"@ant-design/icons": "^5.6.1"
},
"config": {
"ghooks": {
@ -72,13 +73,14 @@
"@commitlint/cli": "^16.1.0",
"@commitlint/config-conventional": "^16.0.0",
"@commitlint/prompt-cli": "^16.1.0",
"@types/react": "^17.0.0",
"@types/react-dom": "^17.0.0",
"@types/react": "18.3.18",
"@types/react-dom": "^18.0.0",
"auto-changelog": "^2.4.0",
"eslint-plugin-jest-dom": "^5.0.1",
"eslint-plugin-testing-library": "^5.11.0",
"ghooks": "^2.0.4",
"lint-staged": "^13.2.3",
"patch-package": "^8.0.0",
"pretty-format": "^24.0.0",
"pretty-quick": "^3.1.0",
"react": "^18.0.0",

View File

@ -68,6 +68,9 @@ export abstract class Auth implements IAuth {
}
async skipCheck() {
if (this.ctx.skipAuthCheck === true) {
return true;
}
const token = this.ctx.getBearerToken();
if (!token && this.ctx.app.options.acl === false) {
return true;

View File

@ -7,10 +7,10 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { Collection, Model } from '@nocobase/database';
import { Cache } from '@nocobase/cache';
import { Collection, Model } from '@nocobase/database';
import jwt from 'jsonwebtoken';
import { Auth, AuthConfig, AuthErrorCode, AuthError } from '../auth';
import { Auth, AuthConfig, AuthError, AuthErrorCode } from '../auth';
import { JwtService } from './jwt-service';
import { ITokenControlService } from './token-control-service';
@ -72,6 +72,7 @@ export class BaseAuth extends Auth {
async check(): ReturnType<Auth['check']> {
const token = this.ctx.getBearerToken();
const cache = this.ctx.cache as Cache;
if (!token) {
this.ctx.throw(401, {
@ -100,6 +101,41 @@ export class BaseAuth extends Auth {
const { userId, roleName, iat, temp, jti, exp, signInTime } = payload ?? {};
const user = userId
? await cache.wrap(this.getCacheKey(userId), () =>
this.userRepository.findOne({
filter: {
id: userId,
},
raw: true,
}),
)
: null;
if (roleName) {
this.ctx.headers['x-role'] = roleName;
}
const blocked = await this.jwt.blacklist.has(jti ?? token);
if (blocked) {
this.ctx.throw(401, {
message: this.ctx.t('Your session has expired. Please sign in again.', { ns: localeNamespace }),
code: AuthErrorCode.BLOCKED_TOKEN,
});
}
// api token check first
if (!temp) {
if (tokenStatus === 'valid') {
return user;
} else {
this.ctx.throw(401, {
message: this.ctx.t('Your session has expired. Please sign in again.', { ns: localeNamespace }),
code: AuthErrorCode.INVALID_TOKEN,
});
}
}
const tokenPolicy = await this.tokenController.getConfig();
if (signInTime && Date.now() - signInTime > tokenPolicy.sessionExpirationTime) {
@ -113,36 +149,6 @@ export class BaseAuth extends Auth {
tokenStatus = 'expired';
}
const blocked = await this.jwt.blacklist.has(jti ?? token);
if (blocked) {
this.ctx.throw(401, {
message: this.ctx.t('Your session has expired. Please sign in again.', { ns: localeNamespace }),
code: AuthErrorCode.BLOCKED_TOKEN,
});
}
if (roleName) {
this.ctx.headers['x-role'] = roleName;
}
const cache = this.ctx.cache as Cache;
const user = await cache.wrap(this.getCacheKey(userId), () =>
this.userRepository.findOne({
filter: {
id: userId,
},
raw: true,
}),
);
if (!temp && tokenStatus !== 'valid') {
this.ctx.throw(401, {
message: this.ctx.t('Your session has expired. Please sign in again.', { ns: localeNamespace }),
code: AuthErrorCode.INVALID_TOKEN,
});
}
if (tokenStatus === 'valid' && user.passwordChangeTz && iat * 1000 < user.passwordChangeTz) {
this.ctx.throw(401, {
message: this.ctx.t('User password changed, please signin again.', { ns: localeNamespace }),

View File

@ -14,6 +14,14 @@ const { existsSync, mkdirSync, readFileSync, appendFileSync } = require('fs');
const { readFile, writeFile } = require('fs').promises;
const { createStoragePluginsSymlink, createDevPluginsSymlink } = require('@nocobase/utils/plugin-symlink');
function runPatchPackage() {
// run yarn patch-package
// console.log('patching third party packages...');
run('yarn', ['patch-package'], {
stdio: 'pipe',
});
}
function writeToExclude() {
const excludePath = resolve(process.cwd(), '.git', 'info', 'exclude');
const content = 'packages/pro-plugins/\n';
@ -47,6 +55,7 @@ module.exports = (cli) => {
.allowUnknownOption()
.option('--skip-umi')
.action(async (options) => {
runPatchPackage();
writeToExclude();
generatePlugins();
generatePlaywrightPath(true);

View File

@ -52,8 +52,14 @@ module.exports = (cli) => {
}
};
const pkg = require('../../package.json');
let distTag = 'latest';
if (pkg.version.includes('alpha')) {
distTag = 'alpha';
} else if (pkg.version.includes('beta')) {
distTag = 'beta';
}
// get latest version
const { stdout } = await run('npm', ['info', options.next ? '@nocobase/cli@next' : '@nocobase/cli', 'version'], {
const { stdout } = await run('npm', ['info', `@nocobase/cli@${distTag}`, 'version'], {
stdio: 'pipe',
});
if (pkg.version === stdout) {
@ -62,13 +68,7 @@ module.exports = (cli) => {
await rmAppDir();
return;
}
const currentY = 1 * pkg.version.split('.')[1];
const latestY = 1 * stdout.split('.')[1];
if (options.next || currentY > latestY) {
await run('yarn', ['add', '@nocobase/cli@next', '@nocobase/devtools@next', '-W']);
} else {
await run('yarn', ['add', '@nocobase/cli', '@nocobase/devtools', '-W']);
}
await run('yarn', ['add', `@nocobase/cli@${distTag}`, `@nocobase/devtools@${distTag}`, '-W']);
await run('yarn', ['install']);
await downloadPro();
await runAppCommand('upgrade');

View File

@ -101,7 +101,7 @@ const Demo = () => {
return (
<div>
<div>{render()}</div>
<div>{render({ style: { color: 'red' } })}</div>
<div>{render({ mode: 'inline', style: { color: 'red' } })}</div>
</div>
);
};

View File

@ -8,7 +8,7 @@
"dependencies": {
"@ahooksjs/use-url-state": "3.5.1",
"@ant-design/cssinjs": "^1.11.1",
"@ant-design/icons": "^5.1.4",
"@ant-design/icons": "^5.6.1",
"@ant-design/pro-layout": "^7.16.11",
"@antv/g2plot": "^2.4.18",
"@budibase/handlebars-helpers": "^0.14.0",
@ -43,6 +43,7 @@
"flat": "^5.0.2",
"i18next": "^22.4.9",
"i18next-http-backend": "^2.1.1",
"ignore": "^5.2.0",
"json5": "^2.2.3",
"lodash": "4.17.21",
"lru-cache": "6.0.0",

View File

@ -29,7 +29,6 @@ import { useDataSourceKey } from '../data-source/data-source/DataSourceProvider'
import { SchemaComponentOptions, useDesignable } from '../schema-component';
import { useApp } from '../application';
import { NavigateToSigninWithRedirect } from '../user/CurrentUserProvider';
// 注意: 必须要对 useBlockRequestContext 进行引用,否则会导致 Data sources 页面报错,原因未知
useBlockRequestContext;
@ -89,9 +88,6 @@ export const ACLRolesCheckProvider = (props) => {
if (result.loading) {
return render();
}
if (result.error) {
return <NavigateToSigninWithRedirect />;
}
return <ACLContext.Provider value={result}>{props.children}</ACLContext.Provider>;
};
@ -308,30 +304,32 @@ export const ACLActionProvider = (props) => {
const collection = useCollection();
const recordPkValue = useRecordPkValue();
const resource = useResourceName();
const { parseAction } = useACLRoleContext();
const { parseAction, uiButtonSchemasBlacklist } = useACLRoleContext();
const schema = useFieldSchema();
const currentUid = schema['x-uid'];
let actionPath = schema['x-acl-action'];
const editablePath = ['create', 'update', 'destroy', 'importXlsx'];
if (!actionPath && resource && schema['x-action']) {
if (!actionPath && resource && schema['x-action'] && editablePath.includes(schema['x-action'])) {
actionPath = `${resource}:${schema['x-action']}`;
}
if (!actionPath?.includes(':')) {
if (actionPath && !actionPath?.includes(':')) {
actionPath = `${resource}:${actionPath}`;
}
const params = useMemo(
() => parseAction(actionPath, { schema, recordPkValue }),
() => actionPath && parseAction(actionPath, { schema, recordPkValue }),
[parseAction, actionPath, schema, recordPkValue],
);
if (uiButtonSchemasBlacklist.includes(currentUid)) {
return <ACLActionParamsContext.Provider value={false}>{props.children}</ACLActionParamsContext.Provider>;
}
if (!actionPath) {
return <>{props.children}</>;
}
if (!resource) {
return <>{props.children}</>;
}
if (!params) {
return <ACLActionParamsContext.Provider value={params}>{props.children}</ACLActionParamsContext.Provider>;
}

View File

@ -27,6 +27,8 @@ export type ResourceActionOptions<P = any> = {
action?: string;
params?: P;
url?: string;
skipNotify?: boolean | ((error: any) => boolean);
skipAuth?: boolean;
};
export type UseRequestService<P> = AxiosRequestConfig<P> | ResourceActionOptions<P> | FunctionService;

View File

@ -29,7 +29,8 @@ import { WebSocketClient, WebSocketClientOptions } from './WebSocketClient';
import { AppComponent, BlankComponent, defaultAppComponents } from './components';
import { SchemaInitializer, SchemaInitializerManager } from './schema-initializer';
import * as schemaInitializerComponents from './schema-initializer/components';
import { SchemaSettings, SchemaSettingsManager } from './schema-settings';
import { SchemaSettings, SchemaSettingsManager, SchemaSettingsItemType } from './schema-settings';
import { compose, normalizeContainer } from './utils';
import { defineGlobalDeps } from './utils/globalDeps';
import { getRequireJs } from './utils/requirejs';
@ -46,6 +47,7 @@ import { AppSchemaComponentProvider } from './AppSchemaComponentProvider';
import type { Plugin } from './Plugin';
import { getOperators } from './globalOperators';
import type { RequireJS } from './utils/requirejs';
import { useAclSnippets } from './hooks/useAclSnippets';
type JsonLogic = {
addOperation: (name: string, fn?: any) => void;
@ -236,7 +238,7 @@ export class Application {
this.addComponents({
Link,
Navigate: Navigate as ComponentType,
NavLink,
NavLink: NavLink as ComponentType,
});
}
@ -497,4 +499,20 @@ export class Application {
getGlobalVar(key) {
return get(this.globalVars, key);
}
addUserCenterSettingsItem(item: SchemaSettingsItemType & { aclSnippet?: string }) {
const useVisibleProp = item.useVisible || (() => true);
const useVisible = () => {
const { allow } = useAclSnippets();
const visible = useVisibleProp();
if (!visible) {
return false;
}
return item.aclSnippet ? allow(item.aclSnippet) : true;
};
this.schemaSettingsManager.addItem('userCenterSettings', item.name, {
...item,
useVisible: useVisible,
});
}
}

View File

@ -0,0 +1,25 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { useACLRoleContext } from '../../acl/ACLProvider';
import ignore from 'ignore';
export const useAclSnippets = () => {
const { allowAll, snippets } = useACLRoleContext();
return {
allow: (aclSnippet) => {
if (aclSnippet) {
const ig = ignore().add(snippets);
const appAllowed = allowAll || ig.ignores(aclSnippet);
return appAllowed;
}
return true;
},
};
};

View File

@ -52,7 +52,8 @@ export const useSchemaInitializerStyles = genStyleHook('nb-schema-initializer',
},
},
[`${componentCls}-item-content`]: {
marginLeft: token.marginXS,
// 相当于 Menu 的 iconMarginInlineEnd参见https://github.com/ant-design/ant-design/blob/6a62d9e7eaf3e683c673091e39fe65ba3204d94b/components/menu/style/index.ts#L942
marginLeft: token.controlHeightSM - token.fontSize,
},
};
});

View File

@ -7,16 +7,15 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { useMemo } from 'react';
import { useApp } from '../../hooks';
import { SchemaSettingOptions } from '../types';
import React from 'react';
import { SchemaSettingsWrapper } from '../components';
import { SchemaSettingsProps } from '../../../schema-settings';
import { Schema } from '@formily/json-schema';
import { GeneralField } from '@formily/core';
import { Schema } from '@formily/json-schema';
import React, { useMemo } from 'react';
import { Designable } from '../../../schema-component';
import { SchemaSettingsProps } from '../../../schema-settings';
import { useApp } from '../../hooks';
import { SchemaSettingsWrapper } from '../components';
import { SchemaSettings } from '../SchemaSettings';
import { SchemaSettingOptions } from '../types';
type UseSchemaSettingsRenderOptions<T = {}> = Omit<SchemaSettingOptions<T>, 'name' | 'items'> &
Omit<SchemaSettingsProps, 'title' | 'children'> & {

View File

@ -22,6 +22,7 @@ import {
export interface SchemaSettingOptions<T = {}> {
name: string;
mode?: 'inline' | 'dropdown';
Component?: ComponentType<T>;
componentProps?: T;
items: SchemaSettingsItemType[];

View File

@ -63,7 +63,7 @@ export abstract class CollectionTemplate {
/** UI configurable CollectionOptions parameters (fields for adding or editing Collection forms) */
configurableProperties?: Record<string, ISchema>;
/** Available field types for the current template */
availableFieldInterfaces?: AvailableFieldInterfacesInclude | AvailableFieldInterfacesExclude;
availableFieldInterfaces?: AvailableFieldInterfacesInclude & AvailableFieldInterfacesExclude;
/** Whether it is a divider */
divider?: boolean;
/** Template description */

View File

@ -44,7 +44,7 @@ export const useMenuItem = () => {
const renderItems = useRef<() => JSX.Element>(null);
const shouldRerender = useRef(false);
const Component = useCallback(({ limitCount }) => {
const Component = useCallback(({ limitCount }: { limitCount?: number }) => {
if (!shouldRerender.current) {
return null;
}

View File

@ -7,13 +7,17 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React, { ComponentType, lazy as ReactLazy } from 'react';
import React, { lazy as ReactLazy } from 'react';
import { Spin } from 'antd';
import { get } from 'lodash';
import { useImported, loadableResource } from 'react-imported-component';
export const LAZY_COMPONENT_KEY = Symbol('LAZY_COMPONENT_KEY');
type LazyComponentType<M extends Record<string, any>, K extends keyof M> = {
[P in K]: M[P];
};
/**
* Lazily loads a React component or multiple components.
*
@ -31,16 +35,14 @@ export const LAZY_COMPONENT_KEY = Symbol('LAZY_COMPONENT_KEY');
* @param {...K[]} componentNames - The names of the components to be lazy-loaded from the module.
* @returns {Record<K, React.LazyExoticComponent<M[K]>>} An object containing the lazy-loaded components.
*/
export function lazy<M extends ComponentType<any>>(
factory: () => Promise<{ default: M }>,
): React.LazyExoticComponent<M>;
export function lazy<M extends Record<'default', any>>(factory: () => Promise<M>): M['default'];
export function lazy<M extends Record<string, any>, K extends keyof M & string>(
export function lazy<M extends Record<string, any>, K extends keyof M = keyof M>(
factory: () => Promise<M>,
...componentNames: K[]
): Record<K, React.LazyExoticComponent<M[K]>>;
): LazyComponentType<M, K>;
export function lazy<M extends Record<string, any>, K extends keyof M & string>(
export function lazy<M extends Record<string, any>, K extends keyof M>(
factory: () => Promise<M>,
...componentNames: K[]
) {
@ -73,14 +75,14 @@ export function lazy<M extends Record<string, any>, K extends keyof M & string>(
};
}),
);
acc[name] = (props) => (
acc[name] = ((props) => (
<React.Suspense fallback={<Spin />}>
<LazyComponent {...props} />
</React.Suspense>
);
)) as M[K];
return acc;
},
{} as Record<K, React.ComponentType<any>>,
{} as LazyComponentType<M, K>,
);
}

View File

@ -0,0 +1,861 @@
{
"Display <1><0>10</0><1>20</1><2>50</2><3>100</3></1> items per page": "Visualizza <1><0>10</0><1>20</1><2>50</2><3>100</3></1> articoli per pagina",
"Meet <1><0>All</0><1>Any</1></1> conditions in the group": "Soddisfa<1><0>Tutte</0><1>Qualsiasi</1></1>condizioni nel gruppo",
"Open in<1><0>Modal</0><1>Drawer</1><2>Window</2></1>": "Apri in<1><0>Modale</0><1>Cassetto</1><2>Finestra</2></1>",
"{{count}} filter items": "{{Count}} filtri elementi",
"{{count}} more items": "{{Count}} altri elementi",
"Total {{count}} items": "{{count}} elementi totali",
"Today": "Oggi",
"Yesterday": "Ieri",
"Tomorrow": "Domani",
"Month": "Mese",
"Week": "Settimana",
"This week": "Questa settimana",
"This month": "Questo mese",
"This year": "Quest'anno",
"Next year": "Anno prossimo",
"Last week": "Settimana scorsa",
"Next week": "Prossima settimana",
"Last month": "Mese scorso",
"Next month": "Mese prossimo",
"Last quarter": "Ultimo trimestre",
"This quarter": "Questo trimestre",
"Next quarter": "Prossimo trimestre",
"Last year": "Anno scorso",
"Last 7 days": "Ultimi 7 giorni",
"Last 30 days": "Ultimi 30 giorni",
"Last 90 days": "Ultimi 90 giorni",
"Next 7 days": "Prossimi 7 giorni",
"Next 30 days": "Prossimi 30 giorni",
"Next 90 days": "Prossimi 90 giorni",
"Work week": "Settimana lavorativa",
"Day": "Giorno",
"Agenda": "Agenda",
"Date": "Data",
"Time": "Tempo",
"Event": "Evento",
"None": "Nessuno",
"Unconnected": "Non collegato",
"System settings": "Impostazioni di sistema",
"System title": "Titolo del sistema",
"Settings": "Impostazioni",
"Logo": "Logo",
"Add menu item": "Aggiungi voce di menu",
"Page": "Pagina",
"Name": "Nome",
"Icon": "Icona",
"Group": "Gruppo",
"Link": "Collegamento",
"Save conditions": "Salva condizioni",
"Edit menu item": "Modifica voce di menu",
"Move to": "Passa a",
"Insert left": "Inserisci a sinistra",
"Insert right": "Inserire a destra",
"Insert inner": "Inserire dentro",
"Delete": "Eliminare",
"Disassociate": "Dissociare",
"Disassociate record": "Dissociare il record",
"Are you sure you want to disassociate it?": "Sei sicuro di voler dissociare?",
"UI editor": "Editor UI",
"Collection": "Raccolta",
"Collection selector": "Selettore di raccolta",
"Providing certain collections as options for users, typically used in polymorphic or inheritance scenarios": "Fornire alcune raccolte come opzioni per gli utenti, in genere utilizzati negli scenari polimorfici o ereditari",
"Collections & Fields": "Raccolte e campi",
"All collections": "Tutte le raccolte",
"Add category": "Aggiungi categoria",
"Enable child collections": "Abilita raccolte figlie",
"Allow adding records to the current collection": "Consenti l'aggiunta di record alla raccolta corrente",
"Delete category": "Elimina categoria",
"Edit category": "Modifica categoria",
"Collection category": "Categoria raccolta",
"Collection template": "Modello raccolta",
"Sort": "Ordina",
"Categories": "Categorie",
"Visible": "Visibile",
"Read only": "Solo lettura",
"Easy reading": "Lettura facile",
"Hidden": "Nascosto",
"Hidden(reserved value)": "Nascosto (valore riservato)",
"Not required": "Non richiesto",
"Value": "Valore",
"Disabled": "Disabilitato",
"Enabled": "Abilitato",
"Problematic": "Problematico",
"Setting": "Impostazioni",
"On": "Acceso",
"Off": "Spento",
"Empty": "Vuoto",
"Linkage rule": "Regola di collegamento",
"Linkage rules": "Regole di collegamento",
"Condition": "Condizione",
"Properties": "Proprietà",
"Add linkage rule": "Aggiungi regola di collegamento",
"Add property": "Aggiungi proprietà",
"Category name": "Nome della categoria",
"Roles & Permissions": "Ruoli e autorizzazioni",
"Edit profile": "Modifica profilo",
"Change password": "Cambia password",
"Old password": "Vecchia password",
"New password": "Nuova password",
"Switch role": "Cambia ruolo",
"Super admin": "Super Admin",
"Language": "Lingua",
"Allow sign up": "Consenti iscrizione",
"Enable SMS authentication": "Abilita autenticazione SMS",
"Sign out": "Disconnessione",
"Cancel": "Annulla",
"Submit": "Invia",
"Close": "Chiudi",
"Set the data scope": "Imposta l'ambito dei dati",
"Set data loading mode": "Imposta modalità di caricamento dei dati",
"Load all data when filter is empty": "Carica tutti i dati quando il filtro è vuoto",
"Do not load data when filter is empty": "Non caricare i dati quando il filtro è vuoto",
"Data loading mode": "Modalità di caricamento dei dati",
"Data blocks": "Blocchi dati",
"Filter blocks": "Blocchi filtro",
"Table": "Tabella",
"Table OID(Inheritance)": "Tabella OID (eredità)",
"Form": "Modulo",
"List": "Lista",
"Grid Card": "Scheda griglia",
"pixels": "pixel",
"Screen size": "Dimensione dello schermo",
"Display title": "Visualizza titolo",
"Set the count of columns displayed in a row": "Imposta il conteggio delle colonne visualizzate in una riga",
"Column": "Colonna",
"Phone device": "Telefono",
"Tablet device": "Tablet",
"Desktop device": "Desktop",
"Large screen device": "Schermo di grandi dimensioni",
"Collapse": "Collassa",
"Select data source": "Seleziona origine dati",
"Calendar": "Calendario",
"Delete events": "Elimina eventi",
"This event": "Questo evento",
"This and following events": "Questo e seguenti eventi",
"All events": "Tutti gli eventi",
"Delete this event?": "Eliminare questo evento?",
"Delete Event": "Elimina evento",
"Kanban": "Kanban",
"Gantt": "Gantt",
"Create gantt block": "Crea blocco Gantt",
"Progress field": "Campo avanzamento",
"Time scale": "Scala del tempo",
"Hour": "Ora",
"Quarter of day": "Quarto del giorno",
"Half of day": "Metà del giorno",
"Year": "Anno",
"QuarterYear": "Quarto dell' anno",
"Select grouping field": "Seleziona il campo di raggruppamento",
"Media": "Media",
"Markdown": "Markdown",
"Wysiwyg": "Wysiwyg",
"Chart blocks": "Blocchi grafici",
"Column chart": "Grafico a colonne",
"Bar chart": "Grafico a barre",
"Line chart": "Grafico a linee",
"Pie chart": "Grafico a torta",
"Area chart": "Grafico ad area",
"Other chart": "Altro grafico",
"Other blocks": "Altri blocchi",
"In configuration": "In configurazione",
"Chart title": "Titolo grafico",
"Chart type": "Tipo grafico",
"Chart config": "Configurazione grafico",
"Templates": "Modelli",
"Select template": "Seleziona modello",
"Action logs": "Registri eventi",
"Create template": "Crea modello",
"Edit markdown": "Modifica Markdown",
"Add block": "Aggiungi blocco",
"Add new": "Aggiungi nuovo",
"Add record": "Aggiungi record",
"Add child": "Aggiungi figlio",
"Collapse all": "Collassare tutto",
"Expand all": "Espandere tutto",
"Expand/Collapse": "Espandere/Collassare",
"Default collapse": "Collassa di default",
"Tree table": "Tabella ad albero",
"Custom field display name": "Nome visualizzato campo personalizzato ",
"Display fields": "Visualizza campi",
"Edit record": "Modifica record",
"Delete menu item": "Elimina voce di menu",
"Add page": "Aggiungi pagina",
"Add group": "Aggiungi gruppo",
"Add link": "Aggiungi link",
"Insert above": "Inserisci sopra",
"Insert below": "Inserisci sotto",
"Save": "Salva",
"Delete block": "Elimina blocco",
"Are you sure you want to delete it?": "Sei sicuro di volerlo eliminare?",
"This is a demo text, **supports Markdown syntax**.": "Questo è un testo demo, ** supporta la sintassi di Markdown **.",
"Filter": "Filtro",
"Connect data blocks": "Collega blocchi di dati",
"Action type": "Tipo di operazione",
"Actions": "Operazioni",
"Insert": "Inserisci",
"Insert if not exists": "Inserisci se non esiste",
"Insert if not exists, or update": "Inserisci se non esiste o aggiorna",
"Determine whether a record exists by the following fields": "Determina se un record esiste dai seguenti campi",
"Update": "Aggiorna",
"Update record": "Aggiorna record",
"View": "Visualizza",
"View record": "Visualizza record",
"Refresh": "Refresh",
"Data changes": "Modifiche ai dati",
"Field name": "Nome campo",
"Before change": "Prima delle modifiche",
"After change": "Dopo le modifiche",
"Delete record": "Elimina record",
"Delete collection": "Elimina raccolta",
"Create collection": "Crea raccolta",
"Collection display name": "Nome visualizzato raccolta",
"Collection name": "Nome raccolta",
"Inherits": "Eredita",
"Primary key, unique identifier, self growth": "Chiave primaria, identificatore univoco, auto-incremento",
"Store the creation user of each record": "Memorizza l'utente della creazione di ogni record",
"Store the last update user of each record": "Memorizza l'ultimo utente di aggiornamento di ogni record",
"Store the creation time of each record": "Memorizza l'orario di creazione di ogni record",
"Store the last update time of each record": "Memorizza l'ultimo orario di aggiornamento di ogni record",
"More options": "Più opzioni",
"Records can be sorted": "I record possono essere ordinati",
"Calendar collection": "Raccolta calendario",
"General collection": "Raccolta generale",
"Connect to database view": "Connetti alla vista del database",
"Sync from database": "Sincronizza dal database",
"Source collections": "Sorgente raccolte",
"Field source": "Sorgente campo",
"Preview": "Anteprima",
"Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.": "Generato casualmente e può essere modificato. Supporta lettere, numeri e underscore, deve iniziare con una lettera.",
"Edit": "Modifica",
"Edit collection": "Modifica raccolta",
"Configure fields": "Configura campi",
"Configure columns": "Configura colonne",
"Edit field": "Modifica campo",
"Override": "Forza",
"Override field": "Forza campo",
"Configure fields of {{title}}": "Configura campi di {{title}}",
"Association fields filter": "Filtro associazione campi",
"PK & FK fields": "Campi PK e FK",
"Association fields": "Campi associazione",
"Choices fields": "Campi scelte",
"System fields": "Campi di sistema",
"General fields": "Campi generali",
"Inherited fields": "Campi ereditati",
"Parent collection fields": "Campi raccolta padre",
"Basic": "Di base",
"Single line text": "Testo a riga singola",
"Long text": "Testo lungo",
"Phone": "Telefono",
"Email": "E-mail",
"Number": "Numero",
"Integer": "Intero",
"Percent": "Percentuale",
"Password": "Password",
"Advanced type": "Avanzato",
"Formula": "Formula",
"Formula description": "Calcola un valore in ciascun record in base ad altri campi nello stesso record.",
"Choices": "Scelte",
"Checkbox": "Casella di controllo",
"Single select": "Selezione singola",
"Multiple select": "Selezione multipla",
"Radio group": "Gruppo radio",
"Checkbox group": "Gruppo cassella di controllo",
"China region": "Regione cinese",
"Date & Time": "Data e ora",
"Datetime": "DateTime",
"Relation": "Relazione",
"Link to": "Collegamento a",
"Link to description": "Utilizzato per creare relazioni tra raccolte in modo rapido e compatibile con gli scenari più comuni. Adatto per un uso da non sviluppatore. Se presente come campo, è una selezione a discesa utilizzata per selezionare i record dalla raccolta di destinazione. Una volta creato, genererà contemporaneamente i campi associati dell'attuale raccolta nella raccolta di destinazione.",
"Sub-table": "Sotto-tabella",
"Sub-details": "Sotto-dettagli",
"Sub-form(Popover)": "Sotto-modulo (Popover)",
"System info": "Informazioni di sistema",
"Created at": "Creato il",
"Last updated at": "Ultimo aggiornamento il",
"Created by": "Creato da",
"Last updated by": "Ultimo aggiornamento da",
"Add field": "Aggiungi campo",
"Field display name": "Nome visualizzazione campo",
"Field type": "Tipo campo",
"Field interface": "Interfaccia campo",
"Date format": "Formato data",
"Year/Month/Day": "Anno/Mese/Giorno",
"Year-Month-Day": "Anno-Mese-Giorno",
"Day/Month/Year": "Giorno/Mese/Anno",
"Show time": "Mostra orario",
"Time format": "Formato tempo",
"12 hour": "12 ore",
"24 hour": "24 ore",
"Relationship type": "Tipo di relazione",
"Inverse relationship type": "Tipo di relazione inversa",
"Source collection": "Raccolta sorgente",
"Source key": "Chiave sorgente",
"Target collection": "Raccolta di destinazione",
"Through collection": "Attraverso la raccolta",
"Target key": "Chiave di destinazione",
"Foreign key": "Chiave esterna",
"One to one": "Uno a uno",
"One to many": "Uno a molti",
"Many to one": "Molti a uno",
"Many to many": "Molti a molti",
"Foreign key 1": "Chiave esterna 1",
"Foreign key 2": "Chiave esterna 2",
"One to one description": "Usato per creare relazioni one-to-one. Ad esempio, un utente ha un profilo.",
"One to many description": "Utilizzato per creare una relazione da uno a molti. Ad esempio, un paese avrà molte città e una città può essere solo in un paese. Se presente come campo, è una sotto-tabella che mostra i record della raccolta associata. Se creato, un campo molti-a-uno viene generato automaticamente nella raccolta associata.",
"Many to one description": "Utilizzato per creare relazioni molti-a-uno. Ad esempio, una città può appartenere a un solo paese e un paese può avere molte città. Se presente come campo, è una selezione a discesa utilizzata per selezionare il record dalla raccolta associata. Una volta creato, un campo da uno a molti viene generato automaticamente nella raccolta associata.",
"Many to many description": "Utilizzato per creare relazioni molti-a-molti. Ad esempio, uno studente avrà molti insegnanti e un insegnante avrà molti studenti. Se presente come campo, è una selezione a discesa utilizzata per selezionare i record dalla raccolta associata.",
"Generated automatically if left blank": "Generato automaticamente se lasciato vuoto",
"Display association fields": "Visualizza campi di associazione",
"Display field title": "Visualizza titolo campo",
"Field component": "Componente campo",
"Allow multiple": "Consenti multipli",
"Quick upload": "Caricamento rapido",
"Select file": "Seleziona file",
"Subtable": "Sotto-tabella",
"Sub-form": "Sotto-modulo",
"Field mode": "Modalità campo",
"Allow add new data": "Consenti aggiunta nuovi dati",
"Record picker": "Record Picker",
"Toggles the subfield mode": "Attiva la modalità Subfield",
"Selector mode": "Modalità selettore",
"Subtable mode": "Modalità sotto-tabella",
"Subform mode": "Modalità sotto-modulo",
"Edit block title": "Modifica titolo blocco",
"Block title": "Titolo blocco",
"Pattern": "Modello",
"Operator": "Operatore",
"Editable": "Modificabile",
"Readonly": "Solo lettura",
"Easy-reading": "Lettura facile",
"Add filter": "Aggiungi filtro",
"Add filter group": "Aggiungi gruppo di filtri",
"Comparision": "Confronto",
"is": "è",
"is not": "non lo è",
"contains": "contiene",
"does not contain": "non contiene",
"starts with": "inizia con",
"not starts with": "non inizia con",
"ends with": "termina con",
"not ends with": "non termina con",
"is empty": "è vuoto",
"is not empty": "non è vuoto",
"Edit chart": "Modifica grafico",
"Add text": "Aggiungi testo",
"Filterable fields": "Campi filtrabili",
"Edit button": "Pulsante Modifica",
"Hide": "Nascondi",
"Enable actions": "Abilita operazioni",
"Import": "Importa",
"Export": "Esporta",
"Customize": "Personalizza",
"Custom": "Personalizzato",
"Function": "Funzione",
"Popup form": "Modulo Popup",
"Flexible popup": "Popup flessibile",
"Configure actions": "Configura operazioni",
"Display order number": "Visualizza numero ordinamento",
"Enable drag and drop sorting": "Abilita l'ordinamento con drag and drop",
"Triggered when the row is clicked": "Attivato quando si fa clic sulla riga",
"Add tab": "Aggiungi scheda",
"Disable tabs": "Disabilita le schede",
"Details": "Dettagli",
"Edit form": "Modifica modulo",
"Create form": "Crea modulo",
"Form (Edit)": "Modulo (modifica)",
"Form (Add new)": "Modulo (aggiungi nuovo)",
"Edit tab": "Modifica scheda",
"Relationship blocks": "Blocchi di relazione",
"Select record": "Seleziona Record",
"Display name": "Visualizza nome",
"Select icon": "Seleziona icona",
"Custom column name": "Nome colonna personalizzato",
"Edit description": "Modifica descrizione",
"Required": "Richiesto",
"Unique": "Unico",
"Primary": "Primario",
"Auto increment": "Incremento automatico",
"Label field": "Campo etichetta",
"Default is the ID field": "L'impostazione predefinita è il campo ID",
"Set default sorting rules": "Imposta le regole di ordinamento predefinite",
"Set validation rules": "Imposta le regole di convalida",
"Max length": "Lunghezza massima",
"Min length": "Lunghezza minima",
"Maximum": "Massimo",
"Minimum": "Minimo",
"Max length must greater than min length": "La lunghezza massima deve essere maggiore della lunghezza minima",
"Min length must less than max length": "La lunghezza minima deve essere inferiore della lunghezza massima",
"Maximum must greater than minimum": "Il massimo deve essere maggiore del minimo",
"Minimum must less than maximum": "Il minimo deve essere minore del massimo",
"Validation rule": "Regola di convalida",
"Add validation rule": "Aggiungi regola di convalida",
"Format": "Formato",
"Regular expression": "Espressione regolare",
"Error message": "Messaggio di errore",
"Length": "Lunghezza",
"The field value cannot be greater than ": "Il valore del campo non può essere maggiore di",
"The field value cannot be less than ": "Il valore del campo non può essere inferiore a",
"The field value is not an integer number": "Il valore del campo non è un numero intero",
"Set default value": "Imposta valore predefinito",
"Default value": "Valore predefinito",
"is before": "è prima",
"is after": "è dopo",
"is on or after": "a partire dal",
"is on or before": "entro il",
"is between": "è tra",
"Upload": "Upload",
"Select level": "Seleziona livello",
"Province": "Provincia",
"City": "Città",
"Area": "Zona",
"Street": "Strada",
"Village": "Villaggio",
"Must select to the last level": "Deve selezionare all'ultimo livello",
"Move {{title}} to": "Sposta {{title}} a",
"Target position": "Posizione di destinazione",
"After": "Dopo",
"Before": "Prima",
"Add {{type}} before \"{{title}}\"": "Aggiungi {{type}} prima di \"{{title}}\"",
"Add {{type}} after \"{{title}}\"": "Aggiungi {{type}} dopo \"{{title}}\"",
"Add {{type}} in \"{{title}}\"": "Aggiungi {{type}} in \"{{title}}\"",
"Original name": "Nome originale",
"Custom name": "Nome personalizzato",
"Custom Title": "Titolo personalizzato",
"Options": "Opzioni",
"Option value": "Valore opzione",
"Option label": "Etichetta opzione",
"Color": "Colore",
"Background Color": "Colore sfondo",
"Text Align": "Allineamento testo",
"Add option": "Aggiungi opzione",
"Related collection": "Raccolta correlata",
"Allow linking to multiple records": "Consenti il collegamento a più record",
"Allow uploading multiple files": "Consenti il caricamento di più file",
"Configure calendar": "Configura calendario",
"Title field": "Campo titolo",
"Custom title": "Titolo personalizzato",
"Daily": "Quotidiano",
"Weekly": "Settimanale",
"Monthly": "Mensile",
"Yearly": "Annuale",
"Repeats": "Ripeti",
"Show lunar": "Mostra lunare",
"Start date field": "Campo data di inizio",
"End date field": "Campo data di fine",
"Navigate": "Naviga",
"Title": "Titolo",
"Description": "Descrizione",
"Select view": "Seleziona vista",
"Reset": "Reset",
"Importable fields": "Campi importabili",
"Exportable fields": "Campi esportabili",
"Saved successfully": "Salvataggio riuscito",
"Nickname": "Soprannome",
"Sign in": "Registrazione",
"Sign in via account": "Accedi tramite account",
"Sign in via phone": "Accedi via telefono",
"Create an account": "Crea un account",
"Sign up": "Iscrizione",
"Confirm password": "Conferma password",
"Log in with an existing account": "Accedi con account esistente",
"Signed up successfully. It will jump to the login page.": "Registrazione riuscita. Reindirizzamento alla pagina di accesso.",
"Password mismatch": "Password non corretta",
"Users": "Utenti",
"Verification code": "Codice di verifica",
"Send code": "Invia codice",
"Retry after {{count}} seconds": "Riprova dopo {{count}} secondi",
"Roles": "Ruoli",
"Add role": "Aggiungi ruolo",
"Role name": "Nome ruolo",
"Configure": "Configura",
"Configure permissions": "Configura permessi",
"Edit role": "Modifica ruolo",
"Action permissions": "Permessi su operazioni",
"Menu permissions": "Permessi su menu",
"Menu item name": "Nome voce di menu",
"Allow access": "Consenti accesso",
"Action name": "Nome operazione",
"Allow action": "Consenti operazione",
"Action scope": "Ambito operazione",
"Operate on new data": "Operare su nuovi dati",
"Operate on existing data": "Operare su dati esistenti",
"Yes": "Si",
"No": "No",
"Red": "Rosso",
"Magenta": "Magenta",
"Volcano": "Vulcano",
"Orange": "Arancione",
"Gold": "Oro",
"Lime": "Lime",
"Green": "Verde",
"Cyan": "Ciano",
"Blue": "Blu",
"Geek blue": "Geek Blue",
"Purple": "Viola",
"Default": "Predefinito",
"Add card": "Aggiungi scheda",
"edit title": "modifica titolo",
"Turn pages": "Volta pagine",
"Others": "Altri",
"Other records": "Altri record",
"Save as template": "Salva come modello",
"Save as block template": "Salva come modello blocco",
"Block templates": "Modelli blocco",
"Block template": "Modello blocco",
"Convert reference to duplicate": "Converti il riferimento a duplicato",
"Template name": "Nome modello",
"Block type": "Tipo blocco",
"No blocks to connect": "Nessun blocco per connettersi",
"Action column": "Colonna operazioni",
"Records per page": "Record per pagina",
"(Fields only)": "(Solo campi)",
"Button title": "Titolo pulsante",
"Button icon": "Icona pulsante",
"Submitted successfully": "Invio riuscito",
"Operation succeeded": "L'operazione è riuscita",
"Operation failed": "Operazione non riuscita",
"Open mode": "Modalità aperta",
"Popup size": "Dimensione popup",
"Small": "Piccolo",
"Middle": "Medio",
"Large": "Grande",
"Size": "Misura",
"Oversized": "Oversize",
"Auto": "Auto",
"Object Fit": "Adattato all'oggetto",
"Cover": "Cover",
"Fill": "Riempi",
"Contain": "Contiene",
"Scale Down": "Ridimensiona",
"Menu item title": "Titolo voce di menu",
"Menu item icon": "Icona voce di menu",
"Target": "Destinazione",
"Position": "Posizione",
"Insert before": "Inserire prima",
"Insert after": "Inserire dopo",
"UI Editor": "Editor UI",
"ASC": "Asc",
"DESC": "Desc",
"Add sort field": "Aggiungi campo di ordinamento",
"ID": "ID",
"Identifier for program usage. Support letters, numbers and underscores, must start with an letter.": "Identificatore per l'utilizzo del programma. Supporta lettere, numeri e underscore, deve iniziare con una lettera.",
"Drawer": "Cassetto",
"Dialog": "Dialogo",
"Delete action": "Elimina operazione",
"Custom column title": "Titolo colonna personalizzata",
"Column title": "Titolo colonna",
"Original title: ": "Titolo originale: ",
"Delete table column": "Elimina colonna della tabella",
"Skip required validation": "Salta convalida richiesta",
"Form values": "Valori modulo",
"Fields values": "Valori campi",
"The field has been deleted": "Il campo è stato eliminato",
"When submitting the following fields, the saved values are": "Quando si inviano i seguenti campi, i valori salvati sono",
"After successful submission": "Dopo una invio riuscito",
"Then": "Poi",
"Stay on current page": "Resta sulla pagina corrente",
"Redirect to": "Reindirizza a",
"Save action": "Salva operazione",
"Exists": "Esiste",
"Add condition": "Aggiungi condizione",
"Add condition group": "Aggiungi gruppo di condizioni",
"exists": "esiste",
"not exists": "non esiste",
"Style": "Stile",
"=": "=",
"≠": "≠",
">": ">",
"≥": "≥",
"<": "<",
"≤": "≤",
"Role UID": "Ruolo UID",
"Precision": "Precisione",
"Formula mode": "Modalità formula",
"Expression": "Espressione",
"Input +, -, *, /, ( ) to calculate, input @ to open field variables.": "Input +, -, *, /, () per calcolare, input @ per aprire le variabili campo.",
"Formula error.": "Errore formula.",
"Rich Text": "Testo ricco",
"Junction collection": "Raccolta giunzione",
"Leave it blank, unless you need a custom intermediate table": "Lascialo vuoto, a meno che tu non abbia bisogno di una tabella intermedia personalizzata",
"Fields": "Campi",
"Edit field title": "Modifica titolo campo",
"Field title": "Titolo campo",
"Original field title: ": "Titolo campo originale:",
"Edit tooltip": "Modifica suggerimento",
"Delete field": "Elimina campo",
"Select collection": "Seleziona raccolta",
"Blank block": "Blocco vuoto",
"Duplicate template": "Modello duplicato",
"Reference template": "Modello di riferimento",
"Create calendar block": "Crea blocco calendario",
"Create kanban block": "Crea blocco kanban",
"Grouping field": "Campo di raggruppamento",
"Single select and radio fields can be used as the grouping field": "I campi di selezione singoli e radio possono essere utilizzati come campo di raggruppamento",
"Tab name": "Nome della scheda",
"Current record blocks": "Blocchi record attuale",
"Popup message": "Messaggio popup",
"Delete role": "Elimina il ruolo",
"Role display name": "Nome visualizzato ruolo",
"Default role": "Ruolo predefinito",
"All collections use general action permissions by default; permission configured individually will override the default one.": "Tutte le raccolte utilizzano i permessi di operazioni generali per impostazione predefinita; I permessi configurati individualmente sovrascriveranno quelli predefiniti.",
"Allows configuration of the whole system, including UI, collections, permissions, etc.": "Consente la configurazione dell'intero sistema, tra cui interfaccia utente, raccolte, permessi, ecc.",
"New menu items are allowed to be accessed by default.": "È possibile accedere a nuove voci di menu per impostazione predefinita.",
"Global permissions": "Permessi globali",
"General permissions": "Permessi generali",
"Global action permissions": "Permessi operazioni globali",
"General action permissions": "Permessi operazioni generali",
"Plugin settings permissions": "Permessi impostazioni plugin",
"Allow to desgin pages": "Consenti progettazione pagine",
"Allow to manage plugins": "Consenti gestione plugin",
"Allow to configure plugins": "Consenti configurazione plugin",
"Allows to configure interface": "Consente di configurare l'interfaccia",
"Allows to install, activate, disable plugins": "Consente di installare, attivare, disabilitare i plugin",
"Allows to configure plugins": "Consente di configurare i plugin",
"Action display name": "Nome visualizzazione azione",
"Allow": "Permetti",
"Data scope": "Ambito dei dati",
"Action on new records": "Operazione su nuovi record",
"Action on existing records": "Operazione su record esistenti",
"All records": "Tutti i record",
"Own records": "Record propri",
"Permission policy": "Policy di autorizzazione",
"Individual": "Individuale",
"General": "Generale",
"Accessible": "Accessibile",
"Configure permission": "Configura permesso",
"Action permission": "Permesso operazione",
"Field permission": "Permesso campo",
"Scope name": "Nome ambito",
"Unsaved changes": "Modifiche non salvate",
"Are you sure you don't want to save?": "Sei sicuro di non voler salvare?",
"Dragging": "Trascina",
"Popup": "Popup",
"Trigger workflow": "Trigger flusso di lavoro",
"Request API": "Richiesta API",
"Assign field values": "Assegna valori del campo",
"Constant value": "Valore costante",
"Dynamic value": "Valore dinamico",
"Current user": "Utente attuale",
"Current role": "Ruolo attuale",
"Current record": "Record attuale",
"Current collection": "Raccolta attuale",
"Other collections": "Altre raccolte",
"Current popup record": "Record popup attuale",
"Parent popup record": "Record popup padre",
"Associated records": "Record associati",
"Parent record": "Record padre",
"Current time": "Ora attuale",
"System variables": "Variabili di sistema",
"Date variables": "Variabili della data",
"Message popup close method": "Metodo di chiusura popup di messaggio",
"Automatic close": "Chiudi automaticamente",
"Manually close": "Chiudi manualmente",
"After successful update": "Dopo un aggiornamento riuscito",
"Save record": "Salva record",
"Updated successfully": "Aggiornamento riuscito",
"After successful save": "Dopo un salvataggio riuscito",
"After clicking the custom button, the following field values will be assigned according to the following form.": "Dopo aver fatto clic sul pulsante personalizza, i seguenti valori verranno assegnati in base al seguente modulo.",
"After clicking the custom button, the following fields of the current record will be saved according to the following form.": "Dopo aver fatto clic sul pulsante personalizza, i seguenti campi del record corrente verranno salvati in base al seguente modulo.",
"Button background color": "Colore sfondo del pulsante",
"Highlight": "Evidenzia",
"Danger red": "Pericolo rosso",
"Custom request": "Personalizza richiesta",
"Request settings": "Impostazioni richiesta",
"Request URL": "URL richiesta",
"Request method": "Metodo richiesta",
"Request query parameters": "Parametri richiesta query",
"Request headers": "Intestazioni richiesta",
"Request body": "Corpo richiesta",
"Request success": "Successo richiesta",
"Invalid JSON format": "Formato JSON non valido",
"After successful request": "Dopo una richiesta riuscita",
"Add exportable field": "Aggiungi campo esportabile",
"Audit logs": "Registri audit",
"Record ID": "ID record",
"User": "Utente",
"Field": "Campo",
"Select": "Seleziona",
"Select field": "Seleziona campo",
"Field value changes": "Modifiche valore del campo",
"One to one (has one)": "Uno a uno (ne ha uno)",
"One to one (belongs to)": "Uno a uno (appartiene a)",
"Use the same time zone (GMT) for all users": "Usa lo stesso fuso orario (GMT) per tutti gli utenti",
"Province/city/area name": "Nome provincia/città/area",
"Enabled languages": "Lingue abilitate",
"View all plugins": "Visualizza tutti i plugin",
"Print": "Stampa",
"Done": "Fatto",
"Sign up successfully, and automatically jump to the sign in page": "Iscriviti correttamente e reindirizza automaticamente alla pagina di accesso",
"File manager": "File Manager",
"ACL": "ACL",
"Collection manager": "Responsabile della raccolta",
"Plugin manager": "Plugin Manager",
"Local": "Locale",
"Built-in": "Incorporato",
"Marketplace": "Marketplace",
"Add plugin": "Aggiungi plugin",
"Plugin source": "Sorgente plugin",
"Upgrade": "Aggiornamento",
"Plugin dependencies check failed": "Controllo delle dipendenze del plugin non riuscito",
"More details": "Maggiori dettagli",
"Upload new version": "Carica nuova versione",
"Version": "Versione",
"Npm package": "Pacchetto Npm",
"Npm package name": "Nome pacchetto Npm",
"Upload plugin": "Carica plugin",
"Official plugin": "Plugin ufficiale",
"Add type": "Aggiungi tipo",
"Changelog": "Changelog",
"Dependencies check": "Controllo delle dipendenze",
"Update plugin": "Aggiorna plugin",
"Installing": "Installazione",
"The deletion was successful.": "Cancellazione riuscita.",
"Plugin Zip File": "File zip plugin",
"Compressed file url": "URL file compresso",
"Last updated": "Ultimo aggiornamento",
"PackageName": "Nome pacchetto",
"DisplayName": "Nome da visualizzare",
"Readme": "Readme",
"Dependencies compatibility check": "Controllo compatibilità delle dipendenze",
"Plugin dependencies check failed, you should change the dependent version to meet the version requirements.": "Controllo delle dipendenze del plugin non riuscito, è necessario modificare la versione dipendente per soddisfare i requisiti della versione.",
"Version range": "Range versione",
"Plugin's version": "Versione plugin",
"Result": "Risultato",
"No CHANGELOG.md file": "Nessun file Changelog.md",
"No README.md file": "Nessun file readme.md",
"Homepage": "Homepage",
"Drag and drop the file here or click to upload, file size should not exceed 30M": "Trascina e rilascia il file qui o fai clic per caricare, la dimensione del file non deve superare i 30M",
"Dependencies check failed, can't enable.": "Il controllo delle dipendenze non è riuscito, impossibile abilitare.",
"Plugin starting...": "Avvio plugin...",
"Plugin stopping...": "Interruzione plugin ...",
"Are you sure to delete this plugin?": "Sei sicuro di eliminare questo plugin?",
"Are you sure to disable this plugin?": "Sei sicuro di disabilitare questo plugin?",
"re-download file": "ri-scarica file",
"Not enabled": "Non abilitato",
"Search plugin": "Ricerca plugin",
"Author": "Autore",
"Plugin loading failed. Please check the server logs.": "Il caricamento del plugin non è riuscito. Si prega di controllare i registri del server.",
"Coming soon...": "Prossimamente...",
"All plugin settings": "Tutte le impostazioni del plugin",
"Bookmark": "Segnalibro",
"Manage all settings": "Gestisci tutte le impostazioni",
"Create inverse field in the target collection": "Crea campo inverso nella raccolta di destinazione",
"Inverse field name": "Nome campo inverso",
"Inverse field display name": "Nome visualizzazione campo inverso",
"Bulk update": "Aggiornamento di massa",
"After successful bulk update": "Dopo un aggiornamento di massa riuscito",
"Bulk edit": "Modifica di massa",
"Data will be updated": "I dati verranno aggiornati",
"Selected": "Selezionato",
"All": "Tutto",
"Update selected data?": "Aggiornare i dati selezionati?",
"Update all data?": "Aggiornare tutti i dati?",
"Remains the same": "Rimane lo stesso",
"Changed to": "Cambiato in",
"Clear": "Pulisci",
"Add attach": "Aggiungi allegato",
"Please select the records to be updated": "Si prega di selezionare i record da aggiornare",
"Selector": "Selettore",
"Inner": "Interno",
"Search and select collection": "Cerca e seleziona la raccolta",
"Please fill in the iframe URL": "Si prega di compilare l'URL iFrame",
"Fix block": "Fissa blocco",
"Plugin name": "Nome plugin",
"Plugin tab name": "Nome scheda plugin",
"AutoGenId": "Campo ID generato automaticamente",
"CreatedBy": "Creato da",
"UpdatedBy": "Aggiornato da",
"CreatedAt": "Creato il",
"UpdatedAt": "Aggiornato il",
"Column width": "Larghezza colonna",
"Sortable": "Ordinabile",
"Enable link": "Abilita link",
"This is likely a NocoBase internals bug. Please open an issue at <1>here</1>": "Questo sembra un bug interno di NocoBase. Si prega di aprire un ticket <1>qui</1>",
"Render Failed": "Rendering non riuscito",
"App error": "Errore app",
"Feedback": "Feedback",
"Try again": "Riprova",
"Download logs": "Download registri",
"Data template": "Modello dati",
"Duplicate": "Duplica",
"Duplicating": "Duplicazione",
"Duplicate mode": "Modalità duplicazione",
"Quick duplicate": "Duplicazione veloce",
"Duplicate and continue": "Duplica e continua",
"Please configure the duplicate fields": "Si prega di configurare i campi duplicati",
"Add": "Aggiungi",
"Add new mode": "Modalità aggiungi nuovo",
"Quick add": "Aggiunta rapida",
"Modal add": "Aggiunta modale",
"Save mode": "Modalità salvataggio",
"First or create": "Prima o crea",
"Update or create": "Aggiorna o crea",
"Find by the following fields": "Trova dai seguenti campi",
"Create": "Crea",
"Current form": "Modulo corrente",
"Current object": "Oggetto corrente",
"Linkage with form fields": "Collegamento con i campi del modulo",
"Allow add new, update and delete actions": "Consenti aggiungi nuovo, aggiorna ed elimina",
"Date display format": "Formato di visualizzazione della data",
"Assign data scope for the template": "Assegna l'ambito dei dati per il modello",
"Table selected records": "Tabella record selezionati",
"Tag": "Etichetta",
"Tag color field": "Campo colore etichetta",
"Sync successfully": "Sincronizzazione riuscita",
"Sync from form fields": "Sincronizzazione dai campi del modulo",
"Select all": "Seleziona tutto",
"Restart": "Ricomincia",
"Restart application": "Riavvia applicazione",
"Cascade Select": "Seleziona in cascata",
"Execute": "Esegui",
"Please use a valid SELECT or WITH AS statement": "Si prega di utilizzare un' istruzione SELECT o WITH AS valida",
"Please confirm the SQL statement first": "Si prega di confermare prima l'istruzione SQL",
"Automatically drop objects that depend on the collection (such as views), and in turn all objects that depend on those objects": "Elimina automaticamente gli oggetti che dipendono dalla raccolta (come le viste) e, a loro volta, tutti gli oggetti che dipendono da tali oggetti",
"Sign in with another account": "Accedi con un altro account",
"Return to the main application": "Torna alla applicazione principale",
"Permission deined": "Permesso negato",
"loading": "caricamento",
"name is required": "nome richiesto",
"data source": "sorgente dati",
"Data source": "Sorgente dati",
"DataSource": "Sorgente Dati",
"The {{type}} \"{{name}}\" may have been deleted. Please remove this {{blockType}}.": "Il {{type}} \"{{name}}\" potrebbe essere stato eliminato. Si prega di rimuovere {{blockType}}.",
"Preset fields": "Campi preimpostati",
"Home page": "Home page",
"Handbook": "Manuale",
"License": "Licenza",
"Generic properties": "Proprietà generiche",
"Specific properties": "Proprietà specifiche",
"Used for drag and drop sorting scenarios, supporting grouping sorting": "Utilizzato per scenari con drag and drop, supporta ordinamento raggruppato",
"Grouped sorting": "Ordinamento raggruppato",
"When a field is selected for grouping, it will be grouped first before sorting.": "Quando viene selezionato un campo per il raggruppamento, verrà raggruppato prima dell'ordinamento.",
"Departments": "Dipartimenti",
"Main department": "Dipartimento principale",
"Department name": "Nome del dipartimento",
"Superior department": "Dipartimento superiore",
"Owners": "Proprietari",
"Plugin settings": "Impostazioni plugin",
"Menu": "Menu",
"Drag and drop sorting field": "Campi ordinamento drag and drop",
"This variable has been deprecated and can be replaced with \"Current form\"": "Questa variabile è stata deprecata e può essere sostituita con \"Current form\"",
"The value of this variable is derived from the query string of the page URL. This variable can only be used normally when the page has a query string.": "Il valore di questa variabile deriva dalla stringa di ricerca nell'URL della pagina. Questa variabile può essere utilizzata normalmente solo quando la pagina ha una stringa di ricerca.",
"URL search params": "Parametri di ricerca URL",
"Expand All": "Espandere tutto",
"Search": "Ricerca",
"Clear default value": "Cancella il valore predefinito",
"Open in new window": "Apri in una nuova finestra",
"Sorry, the page you visited does not exist.": "Spiacente, la pagina che hai visitato non esiste.",
"is none of": "non è nessuno di",
"is any of": "è uno di",
"Plugin dependency version mismatch": "Mancata corrispondenza della versione della dipendenza del plugin",
"The current dependency version of the plugin does not match the version of the application and may not work properly. Are you sure you want to continue enabling the plugin?": "L'attuale versione della dipendenza del plugin non corrisponde alla versione dell'applicazione e potrebbe non funzionare correttamente. Sei sicuro di voler continuare a abilitare il plugin?",
"Allow multiple selection": "Consenti selezione multipla",
"Parent object": "Oggetto padre",
"Skip getting the total number of table records during paging to speed up loading. It is recommended to enable this option for data tables with a large amount of data": "Ometti calcolo del numero totale di record della tabella durante l'impaginazione per accelerare il caricamento. Si consiglia di abilitare questa opzione per tabelle con grandi quantità di dati",
"Enable secondary confirmation": "Abilita conferma secondaria",
"Notification": "Notifica",
"Ellipsis overflow content": "Contenuto Ellipsis overflow",
"Hide column": "Nascondi colonna",
"In configuration mode, the entire column becomes transparent. In non-configuration mode, the entire column will be hidden. Even if the entire column is hidden, its configured default values and other settings will still take effect.": "In modalità di configurazione, l'intera colonna diventa trasparente. In modalità non di configurazione, l'intera colonna verrà nascosta. Anche se l'intera colonna è nascosta, i suoi valori predefiniti configurati e le altre impostazioni avranno comunque effetto."
}

View File

@ -346,7 +346,7 @@
"Subform mode": "子表单模式",
"Field mode": "字段组件",
"Allow add new data": "允许添加数据",
"Edit block title": "编辑区块标题",
"Edit block title & description": "编辑区块标题和描述",
"Block title": "区块标题",
"Pattern": "模式",
"Operator": "运算符",

View File

@ -14,6 +14,7 @@ export const DestroyActionInitializer = (props) => {
const schema = {
title: '{{ t("Delete") }}',
'x-action': 'destroy',
'x-acl-action': 'destroy',
'x-component': 'Action',
'x-use-component-props': 'useDestroyActionProps',
'x-toolbar': 'ActionSchemaToolbar',

View File

@ -20,6 +20,7 @@ export const LinkActionInitializer = (props) => {
'x-settings': 'actionSettings:link',
'x-component': props?.['x-component'] || 'Action.Link',
'x-use-component-props': 'useLinkActionProps',
'x-decorator': 'ACLActionProvider',
};
const itemConfig = useSchemaInitializerItem();

View File

@ -16,7 +16,11 @@ import { useSchemaToolbar } from '../../../application';
import { SchemaSettings } from '../../../application/schema-settings/SchemaSettings';
import { useCollection_deprecated } from '../../../collection-manager';
import { ButtonEditor, RemoveButton } from '../../../schema-component/antd/action/Action.Designer';
import { SchemaSettingsLinkageRules, SchemaSettingsModalItem } from '../../../schema-settings';
import {
SchemaSettingsLinkageRules,
SchemaSettingsModalItem,
SchemaSettingAccessControl,
} from '../../../schema-settings';
import { useURLAndHTMLSchema } from './useURLAndHTMLSchema';
export const SchemaSettingsActionLinkItem: FC = () => {
@ -103,6 +107,7 @@ export const customizeLinkActionSettings = new SchemaSettings({
};
},
},
SchemaSettingAccessControl,
{
name: 'remove',
sort: 100,

View File

@ -28,6 +28,7 @@ export const PopupActionInitializer = (props) => {
openMode: defaultOpenMode,
refreshDataBlockRequest: true,
},
'x-decorator': 'ACLActionProvider',
properties: {
drawer: {
type: 'void',

View File

@ -20,6 +20,7 @@ export const UpdateActionInitializer = (props) => {
type: 'void',
title: '{{ t("Edit") }}',
'x-action': 'update',
'x-acl-action': 'update',
'x-toolbar': 'ActionSchemaToolbar',
'x-settings': 'actionSettings:edit',
'x-component': 'Action',

View File

@ -14,7 +14,7 @@ import { useCollection_deprecated } from '../../../collection-manager';
import { useCollection } from '../../../data-source';
import { ButtonEditor, RemoveButton } from '../../../schema-component/antd/action/Action.Designer';
import { SchemaSettingOpenModeSchemaItems } from '../../../schema-items';
import { SchemaSettingsLinkageRules } from '../../../schema-settings';
import { SchemaSettingsLinkageRules, SchemaSettingAccessControl } from '../../../schema-settings';
import { useOpenModeContext } from '../../popup/OpenModeProvider';
import { useCurrentPopupRecord } from '../../variable/variablesProvider/VariablePopupRecordProvider';
@ -57,6 +57,7 @@ export const customizePopupActionSettings = new SchemaSettings({
};
},
},
SchemaSettingAccessControl,
{
name: 'remove',
sort: 100,

View File

@ -34,6 +34,7 @@ test.describe('where to open a popup and what can be added to it', () => {
// add blocks
await page.getByLabel('schema-initializer-Grid-popup:addNew:addBlock-general').hover();
await page.getByText('Markdown').click();
await page.waitForTimeout(500);
await page.getByLabel('schema-initializer-Grid-popup:addNew:addBlock-general').hover();
await page.getByText('Form').hover();
await page.getByRole('menuitem', { name: 'Current collection' }).click();

View File

@ -29,20 +29,12 @@ export class PMPlugin extends Plugin {
}
addSettings() {
// this.app.pluginSettingsManager.add('acl', {
// title: '{{t("Access control")}}',
// icon: 'LockOutlined',
// Component: ACLPane,
// aclSnippet: 'pm.acl.roles',
// });
// Replaced by plugin-block-template
// this.app.pluginSettingsManager.add('ui-schema-storage', {
// title: '{{t("Block templates")}}',
// icon: 'LayoutOutlined',
// Component: BlockTemplatesPane,
// aclSnippet: 'pm.ui-schema-storage.block-templates',
// });
this.app.pluginSettingsManager.add('ui-schema-storage', {
title: '{{t("Block templates")}}',
icon: 'LayoutOutlined',
Component: BlockTemplatesPane,
aclSnippet: 'pm.ui-schema-storage.block-templates',
});
this.app.pluginSettingsManager.add('system-settings', {
icon: 'SettingOutlined',
title: '{{t("System settings")}}',

View File

@ -0,0 +1,46 @@
/**
* 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 React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useAPIClient, useSystemSettings, SchemaSettingsSelectItem } from '../../..';
import locale from '../../../locale';
export const LanguageSettings = () => {
const { t, i18n } = useTranslation();
const api = useAPIClient();
const { data } = useSystemSettings() || {};
const enabledLanguages: string[] = useMemo(() => data?.data?.enabledLanguages || [], [data?.data?.enabledLanguages]);
if (enabledLanguages.length < 2) {
return null;
}
return (
<SchemaSettingsSelectItem
title={t('Language')}
options={Object.keys(locale)
.filter((lang) => enabledLanguages.includes(lang))
.map((lang) => {
return {
label: locale[lang].label,
value: lang,
};
})}
value={i18n.language}
onChange={async (lang) => {
await api.resource('users').updateLang({
values: {
appLang: lang,
},
});
api.auth.setLocale(lang);
window.location.reload();
}}
/>
);
};

View File

@ -0,0 +1,43 @@
/**
* 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 React from 'react';
import { UserOutlined } from '@ant-design/icons';
import { css } from '@emotion/css';
import { useToken, useSchemaSettingsRender } from '../../../';
export const UserCenterButton = () => {
const { token } = useToken();
return (
<div
className="nb-user-center"
style={{ display: 'inline-block', verticalAlign: 'top', width: '46px', height: '46px' }}
>
<span
data-testid="user-center-button"
className={css`
max-width: 160px;
overflow: hidden;
display: inline-block;
line-height: 12px;
white-space: nowrap;
text-overflow: ellipsis;
`}
style={{ cursor: 'pointer', padding: '16px', color: token.colorTextHeaderMenu }}
>
<UserOutlined />
</span>
</div>
);
};
export function UserCenter() {
const { render } = useSchemaSettingsRender('userCenterSettings');
return <div style={{ display: 'inline-block' }}>{render()}</div>;
}

View File

@ -14,7 +14,6 @@ import React, {
createContext,
FC,
memo,
// @ts-ignore
startTransition,
useCallback,
useContext,
@ -27,10 +26,8 @@ import { Outlet } from 'react-router-dom';
import {
ACLRolesCheckProvider,
CurrentAppInfoProvider,
CurrentUser,
findByUid,
findMenuItem,
NavigateIfNotSignIn,
PinnedPluginList,
RemoteCollectionManagerProvider,
RemoteSchemaComponent,
@ -58,7 +55,8 @@ import { useMenuTranslation } from '../../../schema-component/antd/menu/locale';
import { Help } from '../../../user/Help';
import { KeepAlive } from './KeepAlive';
import { convertRoutesToSchema, NocoBaseDesktopRoute, NocoBaseDesktopRouteType } from './convertRoutesToSchema';
import { userCenterSettings } from './userCenterSettings';
import { UserCenter } from './UserCenterButton';
export { KeepAlive, NocoBaseDesktopRouteType };
const RouteContext = createContext<NocoBaseDesktopRoute | null>(null);
@ -529,7 +527,7 @@ export const InternalAdminLayout = () => {
<Divider type="vertical" />
</ConfigProvider>
<Help />
<CurrentUser />
<UserCenter />
</div>
</div>
</Layout.Header>
@ -544,7 +542,6 @@ export const AdminProvider = (props) => {
<CurrentPageUidProvider>
<CurrentTabUidProvider>
<IsSubPageClosedByPageMenuProvider>
<NavigateIfNotSignIn>
<ACLRolesCheckProvider>
<MenuSchemaRequestProvider>
<RemoteCollectionManagerProvider>
@ -554,7 +551,6 @@ export const AdminProvider = (props) => {
</RemoteCollectionManagerProvider>
</MenuSchemaRequestProvider>
</ACLRolesCheckProvider>
</NavigateIfNotSignIn>
</IsSubPageClosedByPageMenuProvider>
</CurrentTabUidProvider>
</CurrentPageUidProvider>
@ -574,6 +570,7 @@ export class AdminLayoutPlugin extends Plugin {
await this.app.pm.add(RemoteSchemaTemplateManagerPlugin);
}
async load() {
this.app.schemaSettingsManager.add(userCenterSettings);
this.app.addComponents({ AdminLayout, AdminDynamicPage });
}
}

View File

@ -0,0 +1,26 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { UserCenterButton } from './UserCenterButton';
import { SchemaSettings } from '../../../application/schema-settings/SchemaSettings';
import { LanguageSettings } from './LanguageSettings';
const userCenterSettings = new SchemaSettings({
name: 'userCenterSettings',
Component: UserCenterButton,
items: [
{
name: 'langue',
Component: LanguageSettings,
sort: 350,
},
],
});
export { userCenterSettings };

View File

@ -28,12 +28,14 @@ export const createPortalProvider = (id: string | symbol) => {
<Fragment>
{props.children}
<Observer>
{() => {
{
(() => {
if (!props.id) return <></>;
const portal = PortalMap.get(props.id);
if (portal) return createPortal(portal, document.body);
return <></>;
}}
}) as unknown as React.ReactNode
}
</Observer>
</Fragment>
);

View File

@ -10,7 +10,6 @@
import { observer, RecursionField, useField, useFieldSchema } from '@formily/react';
import { Drawer } from 'antd';
import classNames from 'classnames';
// @ts-ignore
import React, { FC, startTransition, useCallback, useEffect, useMemo, useState } from 'react';
import { ErrorBoundary, FallbackProps } from 'react-error-boundary';
import { NocoBaseRecursionField } from '../../../formily/NocoBaseRecursionField';

View File

@ -11,9 +11,8 @@ import { css } from '@emotion/css';
import { observer, useField, useFieldSchema } from '@formily/react';
import { Modal, ModalProps } from 'antd';
import classNames from 'classnames';
import React, { FC, startTransition, useEffect, useState } from 'react';
import { ErrorBoundary, FallbackProps } from 'react-error-boundary';
// @ts-ignore
import React, { FC, startTransition, useEffect, useMemo, useState } from 'react';
import { NocoBaseRecursionField } from '../../../formily/NocoBaseRecursionField';
import { useToken } from '../../../style';
import { ErrorFallback } from '../error-fallback';
@ -85,17 +84,6 @@ export const InternalActionModal: React.FC<ActionDrawerProps<ModalProps>> = obse
return buf;
});
const { hidden } = useCurrentPopupContext();
const styles: any = useMemo(() => {
return {
mask: {
display: hidden ? 'none' : 'block',
},
content: {
display: hidden ? 'none' : 'block',
},
};
}, [hidden]);
const showFooter = !!footerSchema;
if (process.env.__E2E__) {
useSetAriaLabelForModal(visible);
@ -108,12 +96,11 @@ export const InternalActionModal: React.FC<ActionDrawerProps<ModalProps>> = obse
<zIndexContext.Provider value={zIndex}>
<TabsContextProvider {...tabContext} tabBarExtraContent={null}>
<Modal
zIndex={zIndex}
zIndex={hidden ? -1 : zIndex}
width={actualWidth}
title={field.title}
{...(others as ModalProps)}
{...modalProps}
styles={styles}
style={{
...modalProps?.style,
...others?.style,

View File

@ -8,7 +8,6 @@
*/
import { observer, useFieldSchema } from '@formily/react';
// @ts-ignore
import React, { FC, startTransition, useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import { ActionContextNoRerender, useActionContext } from '.';

View File

@ -18,24 +18,12 @@ import { NocoBaseRecursionField } from '../../../formily/NocoBaseRecursionField'
import { useAssociationFieldContext, useInsertSchema } from './hooks';
import schema from './schema';
const InternalNesterCss = css`
& .ant-formily-item-layout-vertical {
margin-bottom: 10px;
}
.ant-card-body {
padding: 15px 20px 5px;
}
.ant-divider-horizontal {
margin: 10px 0;
}
`;
const InternalNesterCardCss = css`
.ant-card-bordered {
border: none;
}
.ant-card-body {
padding: 0px 20px 20px 0px;
padding: 0px 20px 0px 0px;
}
`;
@ -56,6 +44,20 @@ export const InternalNester = observer(
labelWrap = true,
} = fieldSchema?.['x-component-props'] || {};
const InternalNesterCss = css`
margin-top: 0.4em;
& .ant-formily-item-layout-vertical {
margin-bottom: 10px;
}
.ant-card-body {
padding: ${token.padding}px ${token.paddingLG}px;
}
.ant-divider-horizontal {
margin: 10px 0;
}
`;
useEffect(() => {
insertNester(schema.Nester);
}, []);

View File

@ -48,7 +48,7 @@ export const AssociationFilterItem = withDynamicSchemaProps(
const [searchVisible, setSearchVisible] = useState(false);
const defaultActiveKeyCollapse = useMemo<React.Key[]>(
const defaultActiveKeyCollapse = useMemo<string[]>(
() => (defaultCollapse && collectionField?.name ? [collectionField.name] : []),
[collectionField?.name, defaultCollapse],
);

View File

@ -7,7 +7,7 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { Card, CardProps, Space } from 'antd';
import { Card, CardProps } from 'antd';
import React, { useMemo, useRef, useEffect, createContext, useState } from 'react';
import { useToken } from '../../../style';
import { MarkdownReadPretty } from '../markdown';
@ -38,9 +38,23 @@ export const BlockItemCard = React.forwardRef<HTMLDivElement, CardProps | any>((
}, [blockTitle, description]);
const title = (blockTitle || description) && (
<div ref={titleRef}>
<div ref={titleRef} style={{ padding: '4px 0px 4px' }}>
<span>{blockTitle}</span>
{description && <MarkdownReadPretty value={props.description} style={{ fontWeight: 400 }} />}
{description && (
<MarkdownReadPretty
value={props.description}
style={{
overflowWrap: 'break-word',
whiteSpace: 'normal',
fontWeight: 400,
color: '#777',
lineHeight: '1.6',
padding: '4px 12px',
backgroundColor: token.colorFillTertiary,
borderRadius: '4px',
}}
/>
)}
</div>
);
return (

View File

@ -11,8 +11,19 @@ import { genStyleHook } from '../__builtins__';
const useStyles = genStyleHook('nb-grid-card', (token) => {
const { componentCls } = token;
return {
[componentCls]: {
'.nb-action-bar': {
borderRadius: token.borderRadiusBlock,
marginBottom: `${token.marginBlock / 2}px !important`,
},
'.ant-list-pagination': {
borderRadius: token.borderRadiusBlock,
marginTop: `${token.marginBlock / 2}px !important`,
},
'& > .nb-block-item': {
marginBottom: token.marginLG,
'& > .nb-action-bar:has(:first-child:not(:empty))': {

View File

@ -28,15 +28,10 @@ const itemCss = css`
const gridCardCss = css`
height: 100%;
> .ant-card-body {
padding: 24px 24px 0px;
height: 100%;
button {
margin-bottom: 0px !important;
margin-top: 5px;
}
}
.nb-action-bar {
padding: 5px 0;
padding-top: 5px;
}
`;

View File

@ -17,6 +17,7 @@ import { getCardItemSchema } from '../../../block-provider';
import { NocoBaseRecursionField } from '../../../formily/NocoBaseRecursionField';
import { withDynamicSchemaProps } from '../../../hoc/withDynamicSchemaProps';
import { withSkeletonComponent } from '../../../hoc/withSkeletonComponent';
import { useToken } from '../../../style/useToken';
import { SortableItem } from '../../common';
import { SchemaComponentOptions } from '../../core';
import { useDesigner, useProps } from '../../hooks';
@ -141,6 +142,7 @@ const InternalGridCard = withSkeletonComponent(
},
[fieldSchema.properties],
);
const { token } = useToken();
const onPaginationChange: PaginationProps['onChange'] = useCallback(
(page, pageSize) => {
@ -207,7 +209,7 @@ const InternalGridCard = withSkeletonComponent(
...columnCount,
sm: columnCount.xs,
xl: columnCount.lg,
gutter: [rowGutter, rowGutter],
gutter: [token.marginBlock / 2, token.marginBlock / 2],
}}
renderItem={(item, index) => {
return (

View File

@ -18,7 +18,7 @@ describe('IconPicker', () => {
const button = container.querySelector('button') as HTMLButtonElement;
await userEvent.click(button);
expect(screen.queryAllByRole('img').length).toBe(422);
expect(screen.queryAllByRole('img').length).toBe(448);
});
it('should display the selected icon', async () => {
@ -50,9 +50,9 @@ describe('IconPicker', () => {
const searchInput = screen.queryByRole('search') as HTMLInputElement;
await waitFor(() => expect(searchInput).toBeInTheDocument());
expect(screen.queryAllByRole('img').length).toBe(422);
expect(screen.queryAllByRole('img').length).toBe(448);
await userEvent.type(searchInput, 'left');
await waitFor(() => expect(screen.queryAllByRole('img').length).toBeLessThan(422));
await waitFor(() => expect(screen.queryAllByRole('img').length).toBeLessThan(448));
await userEvent.clear(searchInput);
await userEvent.type(searchInput, 'abcd');
await waitFor(() => {

View File

@ -463,11 +463,23 @@ export const MenuDesigner = () => {
afterEnd: 'insertAfter',
};
// 'beforeEnd' 表示的是插入到一个分组的里面
const options =
position === 'beforeEnd'
? {
targetScope: {
parentId: current.__route__.id,
},
}
: {
targetId: current.__route__.id,
};
await moveRoute({
sourceId: (fieldSchema as any).__route__.id,
targetId: current.__route__.id,
sortField: 'sort',
method: positionToMethod[position],
...options,
});
dn.loadAPIClientEvents();

View File

@ -398,7 +398,12 @@ const HeaderMenu = React.memo<{
},
);
const SideMenu = React.memo<any>(
type SideMenuProps = Omit<MenuProps, 'mode'> & {
mode: 'mix' | MenuProps['mode'];
[key: string]: any;
};
const SideMenu = React.memo<SideMenuProps>(
({
mode,
sideMenuSchema,

View File

@ -30,9 +30,15 @@ export const getDatePickerLabels = (props): string => {
return isArr(labels) ? labels.join('~') : labels;
};
export const getLabelFormatValue = (labelUiSchema: ISchema, value: any, isTag = false, TitleRenderer?: any): any => {
export const getLabelFormatValue = (
labelUiSchema: ISchema,
value: any,
isTag = false,
targetTitleCollectionField?,
TitleRenderer?: any,
): any => {
if (TitleRenderer) {
return <TitleRenderer value={value} />;
return <TitleRenderer value={value} collectionField={targetTitleCollectionField} />;
}
if (Array.isArray(labelUiSchema?.enum) && value) {
const opt: any = labelUiSchema.enum.find((option: any) => option.value === value);

View File

@ -18,7 +18,6 @@ const ReactQuill = lazy(() => import('react-quill'));
export const RichText = connect(
(props) => {
const { underFilter } = props;
const { wrapSSR, hashId, componentCls } = useStyles();
const modules = {
toolbar: [['bold', 'italic', 'underline', 'link'], [{ list: 'ordered' }, { list: 'bullet' }], ['clean']],
@ -37,10 +36,7 @@ export const RichText = connect(
'image',
];
const { value, defaultValue, onChange, disabled } = props;
const resultValue = isVariable(value || defaultValue) ? undefined : value || defaultValue || '';
if (underFilter) {
return <Input {...props} />;
}
const resultValue = isVariable(value || defaultValue) ? undefined : value || '';
return wrapSSR(
<ReactQuill
className={`${componentCls} ${hashId}`}

View File

@ -8,7 +8,6 @@
*/
import _ from 'lodash';
// @ts-ignore
import React, { FC, startTransition, useEffect, useState } from 'react';
import { useKeepAlive } from '../../../route-switch/antd/admin-layout/KeepAlive';

View File

@ -52,7 +52,7 @@ export const Tabs: any = React.memo((props: TabsProps) => {
const tabBarExtraContent = useMemo(
() => ({
right: render(),
left: contextProps?.tabBarExtraContent,
left: contextProps?.tabBarExtraContent as React.ReactNode,
}),
[contextProps?.tabBarExtraContent, render],
);

View File

@ -41,6 +41,9 @@ attachmentFileTypes.add({
return matchMimetype(file, 'image/*');
},
getThumbnailURL(file) {
if (file.preview) {
return file.preview;
}
if (file.url) {
return `${file.url}${file.thumbnailRule || ''}`;
}
@ -400,8 +403,8 @@ export function Uploader({ rules, ...props }: UploadProps) {
if (pendingFiles.length) {
setUploadedList(valueList);
} else {
onChange?.([...(value || []), ...valueList]);
setUploadedList([]);
onChange?.(valueList);
}
}
} else {
@ -413,7 +416,7 @@ export function Uploader({ rules, ...props }: UploadProps) {
}
}
},
[multiple, uploadedList, toValueItem, onChange],
[multiple, value, uploadedList, toValueItem, onChange],
);
const onDeletePending = useCallback((file) => {

View File

@ -24,7 +24,8 @@ describe('Upload', () => {
render(<App2 />);
});
it('upload single', async () => {
// TODO: skip due to not pass but works in browser
it.skip('upload single', async () => {
await renderAppOptions({
designable: true,
enableUserListDataBlock: true,
@ -119,11 +120,12 @@ describe('Upload', () => {
await userEvent.upload(document.querySelector('input[type="file"]'), file);
await waitFor(() => {
expect(document.querySelectorAll('.ant-upload-list-item-image')).toHaveLength(1);
expect(document.querySelectorAll('.ant-upload-list-item-image')).toHaveLength(2);
});
});
it('upload multi', async () => {
// TODO: skip due to not pass but works in browser
it.skip('upload multi', async () => {
await renderAppOptions({
designable: true,
enableUserListDataBlock: true,

View File

@ -447,6 +447,8 @@ export function TextArea(props) {
onPaste={onPaste}
onCompositionStart={onCompositionStart}
onCompositionEnd={onCompositionEnd}
// should use data-placeholder here, but not sure if it is safe to make the change, so add ignore here
// @ts-ignore
placeholder={props.placeholder}
style={style}
className={cx(

View File

@ -35,13 +35,14 @@ interface TextAreaWithGlobalScopeProps {
password?: boolean;
number?: boolean;
boolean?: boolean;
expression?: boolean;
value?: any;
scope?: string | object;
[key: string]: any;
}
export const TextAreaWithGlobalScope = connect((props: TextAreaWithGlobalScopeProps) => {
const { supportsLineBreak, password, number, boolean, ...others } = props;
const { supportsLineBreak, password, number, boolean, input, expression = true, ...others } = props;
const scope = useEnvironmentVariableOptions(props.scope);
const fieldNames = { value: 'name', label: 'title' };
@ -57,5 +58,13 @@ export const TextAreaWithGlobalScope = connect((props: TextAreaWithGlobalScopePr
if (boolean) {
return <Variable.Input {...props} scope={scope} fieldNames={fieldNames} />;
}
if (input) {
return <Variable.Input {...others} scope={scope} fieldNames={fieldNames} />;
}
if (expression) {
return <TextArea {...others} scope={scope} fieldNames={fieldNames} />;
}
return <Variable.Input {...others} scope={scope} fieldNames={fieldNames} />;
}, mapReadPretty(Input.ReadPretty));

View File

@ -416,7 +416,7 @@ export const DataBlockInitializer: FC<DataBlockInitializerProps> = (props) => {
},
children,
},
];
] as MenuProps['items'];
}, [searchedChildren, hideChildrenIfSingleCollection, name, compile, title, icon, onClick, props]);
if (childItems.length > 1 || (childItems.length === 1 && childItems[0].children?.length > 0)) {

View File

@ -15,7 +15,6 @@ import classNames from 'classnames';
import React, {
createContext,
FC,
//@ts-ignore
startTransition,
useCallback,
useEffect,

View File

@ -416,7 +416,9 @@ export async function replaceVariables(
}
const waitForParsing = value.match(REGEX_OF_VARIABLE_IN_EXPRESSION)?.map(async (item) => {
const { value: parsedValue } = await variables.parseVariable(item, localVariables);
const { value: parsedValue } = await variables.parseVariable(item, localVariables, {
doNotRequest: item.includes('$nForm'),
});
// 在开头加 `_` 是为了保证 id 不能以数字开头,否则在解析表达式的时候(不是解析变量)会报错
const id = `_${uid()}`;

View File

@ -0,0 +1,86 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { useFieldSchema } from '@formily/react';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { App } from 'antd';
import { SchemaSettingsActionModalItem } from './SchemaSettings';
import { useAPIClient } from '../api-client/hooks/useAPIClient';
import { useRequest } from '../api-client';
import { useACLContext } from '../acl';
export function AccessControl() {
const { t } = useTranslation();
const fieldSchema = useFieldSchema();
const apiClient = useAPIClient();
const resource = apiClient.resource('uiSchemas.roles', fieldSchema['x-uid']);
const { message } = App.useApp();
const { refresh, data }: any = useRequest(
{
url: `/uiSchemas/${fieldSchema['x-uid']}/roles:list`,
},
{
manual: true,
},
);
const { refresh: refreshRoleCheck } = useACLContext();
const AccessControl = (
<SchemaSettingsActionModalItem
scope={t}
title={t('Access control')}
schema={{
type: 'object',
properties: {
roles: {
type: 'array',
title: t('Roles'),
'x-decorator': 'FormItem',
'x-decorator-props': {
tooltip: t('If not set, all roles can see this action'),
},
'x-component': 'RemoteSelect',
'x-component-props': {
multiple: true,
objectValue: true,
dataSource: 'main',
service: {
resource: 'roles',
},
manual: false,
fieldNames: {
label: 'title',
value: 'name',
},
},
},
},
}}
initialValues={{
roles: data?.data,
}}
beforeOpen={() => !data && refresh()}
onSubmit={async ({ roles }) => {
await resource.set({ values: roles.map((v) => v.name) });
await refreshRoleCheck();
return message.success(t('Saved successfully'));
}}
/>
);
return AccessControl;
}
export const SchemaSettingAccessControl = {
name: 'accessControl',
Component: AccessControl,
useVisible() {
const fieldSchema = useFieldSchema();
return fieldSchema['x-decorator'] === 'ACLActionProvider';
},
};

View File

@ -22,6 +22,7 @@ import {
CascaderProps,
ConfigProvider,
Dropdown,
Menu,
MenuItemProps,
MenuProps,
Modal,
@ -34,7 +35,6 @@ import React, {
FC,
ReactNode,
createContext,
// @ts-ignore
startTransition,
useCallback,
useContext,
@ -119,6 +119,7 @@ export interface SchemaSettingsProps {
field?: GeneralField;
fieldSchema?: Schema;
children?: ReactNode;
mode?: 'inline' | 'dropdown';
}
interface SchemaSettingsContextProps<T = any> {
@ -167,7 +168,7 @@ export const SchemaSettingsProvider: React.FC<SchemaSettingsProviderProps> = (pr
return <SchemaSettingsContext.Provider value={value}>{children}</SchemaSettingsContext.Provider>;
};
export const SchemaSettingsDropdown: React.FC<SchemaSettingsProps> = React.memo((props) => {
const InternalSchemaSettingsDropdown: React.FC<SchemaSettingsProps> = React.memo((props) => {
const { title, dn, ...others } = props;
const [visible, setVisible] = useState(false);
const { Component, getMenuItems } = useMenuItem();
@ -232,6 +233,25 @@ export const SchemaSettingsDropdown: React.FC<SchemaSettingsProps> = React.memo(
);
});
const InternalSchemaSettingsMenu: React.FC<SchemaSettingsProps> = React.memo((props) => {
const { title, dn, ...others } = props;
const [visible, setVisible] = useState(true);
const { Component, getMenuItems } = useMenuItem();
const items = getMenuItems(() => props.children);
return (
<SchemaSettingsProvider visible={visible} setVisible={setVisible} dn={dn} {...others}>
<Component />
<Menu items={items} />
</SchemaSettingsProvider>
);
});
export const SchemaSettingsDropdown: React.FC<SchemaSettingsProps> = React.memo((props) => {
const { mode } = props;
return mode === 'inline' ? <InternalSchemaSettingsMenu {...props} /> : <InternalSchemaSettingsDropdown {...props} />;
});
SchemaSettingsDropdown.displayName = 'SchemaSettingsDropdown';
const findGridSchema = (fieldSchema) => {
@ -705,8 +725,8 @@ export const SchemaSettingsActionModalItem: FC<SchemaSettingsActionModalItemProp
}
return result;
}, {});
await onSubmit?.(cloneDeep(visibleValues));
setVisible(false);
await onSubmit?.(cloneDeep(visibleValues));
} catch (err) {
console.error(err);
}

View File

@ -21,11 +21,11 @@ export function SchemaSettingsBlockTitleItem() {
return (
<SchemaSettingsModalItem
title={t('Edit block title')}
title={t('Edit block title & description')}
schema={
{
type: 'object',
title: t('Edit block title'),
title: t('Edit block title & description'),
properties: {
title: {
title: t('title'),

View File

@ -0,0 +1,68 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { expect, test } from '@nocobase/test/e2e';
import { accessControlActionWithTable } from './template';
test.describe('Access control', () => {
test('popup、link、custom request support access control', async ({ page, mockPage, mockRecord }) => {
const nocoPage = await mockPage(accessControlActionWithTable).waitForInit();
await nocoPage.goto();
await page.getByLabel('block-item-CardItem-users-').hover();
//popup
await page.getByLabel('action-Action-Popup-customize').hover();
await page.getByLabel('designer-schema-settings-Action-actionSettings:popup-users').hover();
await expect(page.getByRole('menuitem', { name: 'Access control' })).toBeVisible();
await page.getByLabel('designer-schema-settings-Action-actionSettings:popup-users').hover();
await page.mouse.move(300, 0);
//link
await page.getByLabel('action-Action-Link-customize:').hover();
await page.getByLabel('designer-schema-settings-Action-actionSettings:link-users').hover();
await expect(page.getByRole('menuitem', { name: 'Access control' })).toBeVisible();
await page.mouse.move(300, 0);
// custom request
await page.getByLabel('action-CustomRequestAction-').hover();
await page.getByLabel('designer-schema-settings-CustomRequestAction-actionSettings:customRequest-users').hover();
await expect(page.getByRole('menuitem', { name: 'Access control' })).toBeVisible();
await page.mouse.move(300, 0);
});
test('access control with role ', async ({ page, mockPage, mockRecord }) => {
const nocoPage = await mockPage(accessControlActionWithTable).waitForInit();
await nocoPage.goto();
await page.getByLabel('block-item-CardItem-users-').hover();
//popup only member can see
await page.getByLabel('action-Action-Popup-customize').hover();
await page.getByLabel('designer-schema-settings-Action-actionSettings:popup-users').hover();
await page.getByRole('menuitem', { name: 'Access control' }).click();
await page.getByLabel('block-item-RemoteSelect-users').click();
await page.getByText('Member').click();
await page.getByRole('option', { name: 'Member' }).locator('div').click();
await page.getByLabel('block-item-RemoteSelect-users').click();
await page.getByRole('button', { name: 'Submit' }).click();
//root 角色有权限
await expect(page.getByLabel('action-Action-Popup-customize')).toBeVisible();
//切换 为admin
await page.getByTestId('user-center-button').click();
await page.getByText('Switch roleRoot').click();
await page.getByText('Admin', { exact: true }).click();
await expect(page.getByLabel('action-Action-Popup-customize')).not.toBeVisible();
// 切换 为 member
await page.getByTestId('user-center-button').click();
await page.getByText('Switch roleAdmin').click();
await page.getByText('Member').click();
await expect(page.getByLabel('action-Action-Popup-customize')).toBeVisible();
});
});

View File

@ -10,6 +10,7 @@
import { expect, test } from '@nocobase/test/e2e';
import {
formFieldDependsOnSubtableFieldsWithLinkageRules,
whenClearingARelationshipFieldTheValueOfTheAssociatedFieldShouldBeCleared,
whenSetToHideRetainedValueItShouldNotImpactTheFieldSDefaultValueVariables,
} from './template';
@ -81,6 +82,25 @@ test.describe('linkage rules', () => {
page.getByRole('button', { name: 'block-item-CardItem-roles-' }).getByRole('row', { name: '123456789' }),
).toBeVisible();
});
test('When clearing a relationship field, the value of the associated field should be cleared', async ({
page,
mockPage,
}) => {
await mockPage(whenClearingARelationshipFieldTheValueOfTheAssociatedFieldShouldBeCleared).goto();
// 1. 点击 Edit 按钮打开编辑表单弹窗
await page.getByLabel('action-Action.Link-Edit-').click();
// 2. 清空 roles 字段的值nickname 字段的值应该被清空
await page.getByTestId('select-object-multiple').hover();
await page.getByLabel('icon-close-select').click();
await expect(
page
.getByRole('button', { name: 'block-item-CollectionField-users-form-users.nickname-Nickname' })
.getByRole('textbox'),
).toBeEmpty();
});
});
function calcResult(record) {

View File

@ -1534,3 +1534,684 @@ export const whenSetToHideRetainedValueItShouldNotImpactTheFieldSDefaultValueVar
'x-async': true,
},
};
export const whenClearingARelationshipFieldTheValueOfTheAssociatedFieldShouldBeCleared = {
pageSchema: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Page',
'x-app-version': '1.5.16',
properties: {
'3fiy31f6txn': {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Grid',
'x-initializer': 'page:addBlock',
'x-app-version': '1.5.16',
properties: {
'0rcyz4b3efw': {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Grid.Row',
'x-app-version': '1.5.16',
properties: {
u3mg5s9lzv6: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Grid.Col',
'x-app-version': '1.5.16',
properties: {
ezm6z8plx83: {
_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.5.16',
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.5.16',
'x-uid': 'tt6xllenhim',
'x-async': false,
'x-index': 1,
},
jv0ofysjinb: {
_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.5.16',
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.5.16',
properties: {
bngzha8iw7k: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-decorator': 'DndContext',
'x-component': 'Space',
'x-component-props': {
split: '|',
},
'x-app-version': '1.5.16',
properties: {
tkzbokuc018: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
title: '{{ t("Edit") }}',
'x-action': 'update',
'x-toolbar': 'ActionSchemaToolbar',
'x-settings': 'actionSettings:edit',
'x-component': 'Action.Link',
'x-component-props': {
openMode: 'drawer',
icon: 'EditOutlined',
},
'x-action-context': {
dataSource: 'main',
collection: 'users',
},
'x-decorator': 'ACLActionProvider',
'x-designer-props': {
linkageAction: true,
},
properties: {
drawer: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
title: '{{ t("Edit record") }}',
'x-component': 'Action.Container',
'x-component-props': {
className: 'nb-action-popup',
},
properties: {
tabs: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Tabs',
'x-component-props': {},
'x-initializer': 'popup:addTab',
properties: {
tab1: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
title: '{{t("Edit")}}',
'x-component': 'Tabs.TabPane',
'x-designer': 'Tabs.Designer',
'x-component-props': {},
properties: {
grid: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Grid',
'x-initializer': 'popup:common:addBlock',
properties: {
q2nw699fdes: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Grid.Row',
'x-app-version': '1.5.16',
properties: {
'8rf37n96jy1': {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Grid.Col',
'x-app-version': '1.5.16',
properties: {
'83f2yyxkgew': {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-acl-action-props': {
skipScopeCheck: false,
},
'x-acl-action': 'users:update',
'x-decorator': 'FormBlockProvider',
'x-use-decorator-props':
'useEditFormBlockDecoratorProps',
'x-decorator-props': {
action: 'get',
dataSource: 'main',
collection: 'users',
},
'x-toolbar': 'BlockSchemaToolbar',
'x-settings': 'blockSettings:editForm',
'x-component': 'CardItem',
'x-app-version': '1.5.16',
properties: {
ztcsjl3kutq: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'FormV2',
'x-use-component-props': 'useEditFormBlockProps',
'x-app-version': '1.5.16',
properties: {
grid: {
'x-uid': 'jk5os75m30w',
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Grid',
'x-initializer': 'form:configureFields',
'x-app-version': '1.5.16',
'x-linkage-rules': [
{
condition: {
$and: [],
},
actions: [
{
targetFields: ['nickname'],
operator: 'value',
value: {
mode: 'express',
value: '{{$nForm.roles.title}}',
result: '{{$nForm.roles.title}}',
},
},
],
},
],
properties: {
al791aryfpm: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Grid.Row',
'x-app-version': '1.5.16',
properties: {
s2o1ubyy97y: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Grid.Col',
'x-app-version': '1.5.16',
properties: {
roles: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'string',
'x-toolbar':
'FormItemSchemaToolbar',
'x-settings':
'fieldSettings:FormItem',
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
'x-collection-field': 'users.roles',
'x-component-props': {
fieldNames: {
label: 'name',
value: 'name',
},
},
'x-app-version': '1.5.16',
'x-uid': 'y5r3pw94du5',
'x-async': false,
'x-index': 1,
},
},
'x-uid': '1mlc7m6h27k',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'l5bz655ppju',
'x-async': false,
'x-index': 1,
},
jsl0ebbnp4x: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Grid.Row',
'x-app-version': '1.5.16',
properties: {
e19ihogekwg: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Grid.Col',
'x-app-version': '1.5.16',
properties: {
nickname: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'string',
'x-toolbar':
'FormItemSchemaToolbar',
'x-settings':
'fieldSettings:FormItem',
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
'x-collection-field':
'users.nickname',
'x-component-props': {},
'x-app-version': '1.5.16',
'x-uid': 'nr2wsugko9w',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'vmsrhpme9qo',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'b7v16npd5y2',
'x-async': false,
'x-index': 2,
},
},
'x-async': false,
'x-index': 1,
},
p3zp9livz6w: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-initializer': 'editForm:configureActions',
'x-component': 'ActionBar',
'x-component-props': {
layout: 'one-column',
},
'x-app-version': '1.5.16',
'x-uid': 'd3qzyf73gu6',
'x-async': false,
'x-index': 2,
},
},
'x-uid': 'j4j1fy7tkmh',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 't4mmnie8cll',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'bnugjy38s7u',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'tb4c9r9s844',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'dlbfog0xc8b',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'nvl54jerwsi',
'x-async': false,
'x-index': 1,
},
},
'x-uid': '8d1h9bm3lnb',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'f7xutqb1iyw',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'kquc4rim0g2',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'sb10ua8lfsl',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'wa210opt4kb',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'sumvmy4epmf',
'x-async': false,
'x-index': 2,
},
},
'x-uid': 'jyssn2zrfs6',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'n1iy8cfg2nj',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'q9we7w410nr',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'hajsh396zq6',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'utv8yv0hu55',
'x-async': true,
'x-index': 1,
},
};
export const accessControlActionWithTable = {
pageSchema: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Page',
'x-app-version': '1.6.0-beta.9',
properties: {
fvgd0c2akgf: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Grid',
'x-initializer': 'page:addBlock',
'x-app-version': '1.6.0-beta.9',
properties: {
'0c4zy47hhyq': {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Grid.Row',
'x-app-version': '1.6.0-beta.9',
properties: {
b1y881c771g: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Grid.Col',
'x-app-version': '1.6.0-beta.9',
properties: {
hccefuo80kx: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-acl-action': 'users:view',
'x-decorator': 'DetailsBlockProvider',
'x-use-decorator-props': 'useDetailsWithPaginationDecoratorProps',
'x-decorator-props': {
dataSource: 'main',
collection: 'users',
readPretty: true,
action: 'list',
params: {
pageSize: 1,
},
},
'x-toolbar': 'BlockSchemaToolbar',
'x-settings': 'blockSettings:detailsWithPagination',
'x-component': 'CardItem',
'x-app-version': '1.6.0-beta.9',
properties: {
fctprt7i7ut: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Details',
'x-read-pretty': true,
'x-use-component-props': 'useDetailsWithPaginationProps',
'x-app-version': '1.6.0-beta.9',
properties: {
b2xrbq4a060: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-initializer': 'details:configureActions',
'x-component': 'ActionBar',
'x-component-props': {
style: {
marginBottom: 24,
},
},
'x-app-version': '1.6.0-beta.9',
properties: {
e2ccvx3c7o5: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
title: '{{ t("Popup") }}',
'x-action': 'customize:popup',
'x-toolbar': 'ActionSchemaToolbar',
'x-settings': 'actionSettings:popup',
'x-component': 'Action',
'x-component-props': {
openMode: 'drawer',
refreshDataBlockRequest: true,
},
'x-decorator': 'ACLActionProvider',
'x-action-context': {
dataSource: 'main',
collection: 'users',
},
'x-app-version': '1.6.0-beta.9',
properties: {
drawer: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
title: '{{ t("Popup") }}',
'x-component': 'Action.Container',
'x-component-props': {
className: 'nb-action-popup',
},
'x-app-version': '1.6.0-beta.9',
properties: {
tabs: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Tabs',
'x-component-props': {},
'x-initializer': 'popup:addTab',
'x-app-version': '1.6.0-beta.9',
properties: {
tab1: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
title: '{{t("Details")}}',
'x-component': 'Tabs.TabPane',
'x-designer': 'Tabs.Designer',
'x-component-props': {},
'x-app-version': '1.6.0-beta.9',
properties: {
grid: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Grid',
'x-initializer': 'popup:common:addBlock',
'x-app-version': '1.6.0-beta.9',
'x-uid': '3qfg5qfvsth',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'n0574ydwdua',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'm0039599o9q',
'x-async': false,
'x-index': 1,
},
},
'x-uid': '5t7ol82dt74',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'gn76wlmk619',
'x-async': false,
'x-index': 1,
},
'3jat4vour4y': {
_isJSONSchemaObject: true,
version: '2.0',
title: '{{ t("Custom request") }}',
'x-component': 'CustomRequestAction',
'x-action': 'customize:form:request',
'x-toolbar': 'ActionSchemaToolbar',
'x-settings': 'actionSettings:customRequest',
'x-decorator': 'CustomRequestAction.Decorator',
'x-action-settings': {
onSuccess: {
manualClose: false,
redirecting: false,
successMessage: '{{t("Request success")}}',
},
},
type: 'void',
'x-app-version': '1.6.0-beta.9',
'x-uid': 'gu0shkseqoa',
'x-async': false,
'x-index': 2,
},
'0tevnuro5d4': {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
title: '{{ t("Link") }}',
'x-action': 'customize:link',
'x-toolbar': 'ActionSchemaToolbar',
'x-settings': 'actionSettings:link',
'x-component': 'Action',
'x-use-component-props': 'useLinkActionProps',
'x-decorator': 'ACLActionProvider',
'x-app-version': '1.6.0-beta.9',
'x-uid': 'swtrz2mpnm4',
'x-async': false,
'x-index': 3,
},
},
'x-uid': '32u6fnlj0ti',
'x-async': false,
'x-index': 1,
},
grid: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Grid',
'x-initializer': 'details:configureFields',
'x-app-version': '1.6.0-beta.9',
'x-uid': '21ksski5wgs',
'x-async': false,
'x-index': 2,
},
pagination: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Pagination',
'x-use-component-props': 'useDetailsPaginationProps',
'x-app-version': '1.6.0-beta.9',
'x-uid': '2cz3ilk7hmv',
'x-async': false,
'x-index': 3,
},
},
'x-uid': 'f6xosucy75q',
'x-async': false,
'x-index': 1,
},
},
'x-uid': '33tsqeap83o',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'ppglkb7uvt8',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'af78vam04ux',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'askp9xe9uag',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'huon5vcb8u8',
'x-async': true,
'x-index': 1,
},
};

View File

@ -26,6 +26,7 @@ export * from './SchemaSettingsRenderEngine';
export * from './hooks/useGetAriaLabelOfDesigner';
export * from './hooks/useIsAllowToSetDefaultValue';
export * from './SchemaSettingsLayoutItem';
export * from './SchemaSettingAccessControl';
export { default as useParseDataScopeFilter } from './hooks/useParseDataScopeFilter';
export * from './isPatternDisabled';
export { SchemaSettingsPlugin } from './SchemaSettingsPlugin';

View File

@ -7,219 +7,12 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { UserOutlined } from '@ant-design/icons';
import { css } from '@emotion/css';
import { error } from '@nocobase/utils/client';
import { App, Dropdown, Menu, MenuProps } from 'antd';
import React, { createContext, useCallback, useMemo as useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useACLRoleContext, useAPIClient, useCurrentUserContext, useToken } from '..';
import { useNavigateNoUpdate } from '../application/CustomRouterContextProvider';
import { useChangePassword } from './ChangePassword';
import { useCurrentUserSettingsMenu } from './CurrentUserSettingsMenuProvider';
import { useEditProfile } from './EditProfile';
import { useLanguageSettings } from './LanguageSettings';
import { useSwitchRole } from './SwitchRole';
import { createContext } from 'react';
import { SelectWithTitle } from '../common';
const useNickname = () => {
const { data } = useCurrentUserContext();
const { token } = useToken();
return useEffect(() => {
return {
key: 'nickname',
disabled: true,
label: (
<span aria-disabled="false" style={{ cursor: 'text', color: token.colorTextDescription }}>
{data?.data?.nickname || data?.data?.username || data?.data?.email}
</span>
),
};
}, [data?.data?.email, data?.data?.nickname, data?.data?.username, data?.data.version, token.colorTextDescription]);
};
/**
* @note If you want to change here, Note the Setting block on the mobile side
*/
export const SettingsMenu: React.FC<{
redirectUrl?: string;
}> = (props) => {
const { addMenuItem, getMenuItems } = useCurrentUserSettingsMenu();
const { redirectUrl = '' } = props;
const { allowAll, snippets } = useACLRoleContext();
const appAllowed = allowAll || snippets?.includes('app');
const navigate = useNavigateNoUpdate();
const api = useAPIClient();
const { t } = useTranslation();
const silenceApi = useAPIClient();
const check = useCallback(async () => {
return await new Promise((resolve) => {
const heartbeat = setInterval(() => {
silenceApi
.silent()
.resource('app')
.getInfo()
.then((res) => {
if (res?.status === 200) {
resolve('ok');
clearInterval(heartbeat);
}
return res;
})
.catch((err) => {
error(err);
});
}, 3000);
});
}, [silenceApi]);
const nickname = useNickname();
const editProfile = useEditProfile();
const changePassword = useChangePassword();
const switchRole = useSwitchRole();
const languageSettings = useLanguageSettings();
const { modal } = App.useApp();
const controlApp = useEffect<MenuProps['items']>(() => {
if (!appAllowed) {
return [];
}
return [
{
key: 'cache',
label: t('Clear cache'),
onClick: async () => {
await api.resource('app').clearCache();
window.location.reload();
},
},
{
key: 'reboot',
label: t('Restart application'),
onClick: async () => {
modal.confirm({
title: t('Restart application'),
// content: t('The will interrupt service, it may take a few seconds to restart. Are you sure to continue?'),
okText: t('Restart'),
okButtonProps: {
danger: true,
},
onOk: async () => {
await api.resource('app').restart();
},
});
},
},
{
key: 'divider_4',
type: 'divider',
},
];
}, [api, appAllowed, check, modal, t]);
useEffect(() => {
const items = [
nickname,
{
key: 'divider_1',
type: 'divider',
},
editProfile,
changePassword,
editProfile ||
(changePassword && {
key: 'divider_2',
type: 'divider',
}),
switchRole,
{
key: 'divider_3',
type: 'divider',
},
...controlApp,
{
key: 'signout',
label: t('Sign out'),
onClick: async () => {
const { data } = await api.auth.signOut();
if (data?.data?.redirect) {
window.location.href = data.data.redirect;
} else {
navigate(`/signin?redirect=${encodeURIComponent(redirectUrl)}`);
}
},
},
];
items.forEach((item) => {
if (item) {
addMenuItem(item);
}
});
if (languageSettings) {
addMenuItem(languageSettings, { before: 'divider_3' });
}
}, [
addMenuItem,
api.auth,
editProfile,
changePassword,
controlApp,
languageSettings,
navigate,
redirectUrl,
switchRole,
t,
nickname,
]);
return <Menu items={getMenuItems()} />;
export const SettingsMenuProvider = (props) => {
return SelectWithTitle;
};
export const DropdownVisibleContext = createContext(null);
DropdownVisibleContext.displayName = 'DropdownVisibleContext';
export const CurrentUser = () => {
const [visible, setVisible] = useState(false);
const { token } = useToken();
return (
<div
className={css`
display: inline-block;
vertical-align: top;
width: 46px;
height: 46px;
&:hover {
background: rgba(255, 255, 255, 0.1) !important;
}
`}
>
<DropdownVisibleContext.Provider value={{ visible, setVisible }}>
<Dropdown
open={visible}
onOpenChange={(visible) => {
setVisible(visible);
}}
dropdownRender={() => {
return <SettingsMenu />;
}}
>
<span
data-testid="user-center-button"
className={css`
max-width: 160px;
overflow: hidden;
display: inline-block;
line-height: 12px;
white-space: nowrap;
text-overflow: ellipsis;
`}
style={{ cursor: 'pointer', padding: '16px', color: token.colorTextHeaderMenu }}
>
<UserOutlined />
</span>
</Dropdown>
</DropdownVisibleContext.Provider>
</div>
);
};

View File

@ -8,10 +8,9 @@
*/
import React, { createContext, useContext, useMemo } from 'react';
import { Navigate } from 'react-router-dom';
import { useACLRoleContext } from '../acl';
import { ReturnTypeOfUseRequest, useAPIClient, useRequest } from '../api-client';
import { useAppSpin, useLocationNoUpdate } from '../application';
import { useAppSpin } from '../application';
import { useCompile } from '../schema-component';
export const CurrentUserContext = createContext<ReturnTypeOfUseRequest>(null);
@ -44,13 +43,7 @@ export const CurrentUserProvider = (props) => {
api
.request({
url: '/auth:check',
skipNotify: (error) => {
const errs = api.toErrMessages(error);
if (errs.find((error: { code?: string }) => error.code === 'EMPTY_TOKEN')) {
return true;
}
return false;
},
skipNotify: true,
skipAuth: true,
})
.then((res) => res?.data),
@ -63,18 +56,3 @@ export const CurrentUserProvider = (props) => {
return <CurrentUserContext.Provider value={result}>{props.children}</CurrentUserContext.Provider>;
};
export const NavigateToSigninWithRedirect = () => {
const { pathname, search } = useLocationNoUpdate();
const redirect = `?redirect=${pathname}${search}`;
return <Navigate replace to={`/signin${redirect}`} />;
};
export const NavigateIfNotSignIn = ({ children }) => {
const result = useCurrentUserContext();
if (result.loading === false && !result.data?.data?.id) {
return <NavigateToSigninWithRedirect />;
}
return <>{children}</>;
};

View File

@ -1,56 +0,0 @@
/**
* 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 { MenuProps } from 'antd';
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { SelectWithTitle, useAPIClient, useSystemSettings } from '..';
import locale from '../locale';
export const useLanguageSettings = () => {
const { t, i18n } = useTranslation();
const api = useAPIClient();
const { data } = useSystemSettings() || {};
const enabledLanguages: string[] = useMemo(() => data?.data?.enabledLanguages || [], [data?.data?.enabledLanguages]);
const result = useMemo<MenuProps['items'][0]>(() => {
return {
key: 'language',
eventKey: 'LanguageSettings',
label: (
<SelectWithTitle
title={t('Language')}
options={Object.keys(locale)
.filter((lang) => enabledLanguages.includes(lang))
.map((lang) => {
return {
label: locale[lang].label,
value: lang,
};
})}
defaultValue={i18n.language}
onChange={async (lang) => {
await api.resource('users').updateLang({
values: {
appLang: lang,
},
});
api.auth.setLocale(lang);
window.location.reload();
}}
/>
),
};
}, [api, enabledLanguages, i18n, t]);
if (enabledLanguages.length < 2) {
return null;
}
return result;
};

View File

@ -1,50 +0,0 @@
/**
* 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 { MenuProps } from 'antd';
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useAPIClient } from '../api-client';
import { SelectWithTitle } from '../common';
import { useCurrentRoles } from './CurrentUserProvider';
export const useSwitchRole = () => {
const api = useAPIClient();
const roles = useCurrentRoles();
const { t } = useTranslation();
const result = useMemo<MenuProps['items'][0]>(() => {
return {
key: 'role',
eventKey: 'SwitchRole',
label: (
<SelectWithTitle
title={t('Switch role')}
fieldNames={{
label: 'title',
value: 'name',
}}
options={roles}
defaultValue={api.auth.role}
onChange={async (roleName) => {
api.auth.setRole(roleName);
await api.resource('users').setDefaultRole({ values: { roleName } });
location.reload();
window.location.reload();
}}
/>
),
};
}, [api, roles, t]);
if (roles.length <= 1) {
return null;
}
return result;
};

View File

@ -1,58 +0,0 @@
/**
* 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 { render } from '@nocobase/test/client';
import React from 'react';
import { SettingsMenu } from '../CurrentUser';
import { useCurrentUserSettingsMenu } from '../CurrentUserSettingsMenuProvider';
const AppContextProvider = (props) => {
return <div></div>;
};
// TODO: AppContextProvider 没有提供足够的上下文环境
describe.skip('CurrentUserSettingsMenuProvider', () => {
const wrapper = ({ children }) => {
return (
<AppContextProvider>
<SettingsMenu />
{children}
</AppContextProvider>
);
};
const TestComponent = () => {
const { getMenuItems } = useCurrentUserSettingsMenu();
getMenuItems();
return <div>Test</div>;
};
it('should throw error when CurrentUserSettingsMenuProvider is not provided', () => {
expect(() => {
render(<TestComponent />);
}).toThrowErrorMatchingInlineSnapshot(
'"CurrentUser: You should use `CurrentUserSettingsMenuProvider` in the root of your app."',
);
});
it('should not throw error when providing context', () => {
expect(() => {
render(<TestComponent />, { wrapper });
}).not.toThrow();
});
// TODO: result.current 是 null会报错暂时不知道哪里出了问题
// it.skip('add menu item', () => {
// const { result } = renderHook(() => useCurrentUserSettingsMenu(), {
// wrapper,
// });
// expect(result.current.getMenuItems()).not.toHaveLength(0);
// });
});

View File

@ -24,8 +24,10 @@
},
"resolutions": {
"cytoscape": "3.28.0",
"@types/react": "^18.0.0",
"@types/react": "18.3.18",
"@types/react-dom": "^18.0.0",
"react-router-dom": "6.28.1",
"react-router": "6.28.1",
"antd": "5.12.8",
"rollup": "4.24.0"
},

View File

@ -65,6 +65,10 @@ export class InheritedCollection extends Collection {
parentFields() {
const fields = new Map<string, Field>();
if (!this.parents) {
return fields;
}
for (const parent of this.parents) {
if (parent.isInherited()) {
for (const [name, field] of (<InheritedCollection>parent).parentFields()) {

View File

@ -1336,6 +1336,11 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
},
logger: this._logger.child({ module: 'database' }),
});
// NOTE: to avoid listener number warning (default to 10)
// See: https://nodejs.org/api/events.html#emittersetmaxlistenersn
db.setMaxListeners(100);
return db;
}
}

View File

@ -363,6 +363,10 @@ export class Gateway extends EventEmitter {
const mainApp = AppSupervisor.getInstance().bootMainApp(options.mainAppOptions);
// NOTE: to avoid listener number warning (default to 10)
// See: https://nodejs.org/api/events.html#emittersetmaxlistenersn
mainApp.setMaxListeners(50);
let runArgs: any = [process.argv, { throwError: true, from: 'node' }];
if (!isMainThread) {

View File

@ -0,0 +1,40 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useCurrentRoles, useAPIClient, SchemaSettingsItem, SelectWithTitle } from '@nocobase/client';
export const SwitchRole = () => {
const { t } = useTranslation();
const api = useAPIClient();
const roles = useCurrentRoles();
if (roles.length <= 1) {
return null;
}
return (
<SchemaSettingsItem eventKey="SwitchRole" title="SwitchRole">
<SelectWithTitle
title={t('Switch role')}
fieldNames={{
label: 'title',
value: 'name',
}}
options={roles}
defaultValue={api.auth.role}
onChange={async (roleName) => {
api.auth.setRole(roleName);
await api.resource('users').setDefaultRole({ values: { roleName } });
location.reload();
window.location.reload();
}}
/>
</SchemaSettingsItem>
);
};

View File

@ -9,9 +9,10 @@
import { Plugin, lazy } from '@nocobase/client';
import { ACLSettingsUI } from './ACLSettingsUI';
// import { RolesManagement } from './RolesManagement';
const { RolesManagement } = lazy(() => import('./RolesManagement'), 'RolesManagement');
import { RolesManager } from './roles-manager';
import { SwitchRole } from './SwitchRole';
const { RolesManagement } = lazy(() => import('./RolesManagement'), 'RolesManagement');
export class PluginACLClient extends Plugin {
rolesManager = new RolesManager();
@ -25,6 +26,18 @@ export class PluginACLClient extends Plugin {
aclSnippet: 'pm.acl.roles',
sort: 3,
});
// 个人中心注册 切换角色
this.app.addUserCenterSettingsItem({
name: 'divider_switchRole',
type: 'divider',
sort: 200,
});
this.app.addUserCenterSettingsItem({
name: 'switchRole',
Component: SwitchRole,
sort: 300,
});
}
}

View File

@ -230,6 +230,66 @@ describe('list action with acl', () => {
expect(data.meta.allowedActions.destroy).toEqual([]);
});
it('should list items meta permissions by m2m association field', async () => {
const userRole = app.acl.define({
role: 'user',
});
const Tag = app.db.collection({
name: 'tags',
fields: [{ type: 'string', name: 'name' }],
});
app.db.extendCollection({
name: 'posts',
fields: [
{
type: 'belongsToMany',
name: 'tags',
through: 'posts_tags',
},
],
});
await app.db.sync();
await Tag.repository.create({
values: [{ name: 'a' }, { name: 'b' }, { name: 'c' }],
});
await Post.repository.create({
values: [
{ title: 'p1', tags: [1, 2] },
{ title: 'p2', tags: [1, 3] },
{ title: 'p3', tags: [2, 3] },
],
});
userRole.grantAction('posts:view', {});
userRole.grantAction('posts:update', {
filter: {
$and: [
{
tags: {
name: {
$includes: 'c',
},
},
},
],
},
});
// @ts-ignore
const response = await (await app.agent().login(users[0].id, 'user'))
.set('X-With-ACL-Meta', true)
.resource('posts')
.list();
const data = response.body;
expect(data.meta.allowedActions.view).toEqual([1, 2, 3]);
expect(data.meta.allowedActions.update).toEqual([2, 3]);
expect(data.meta.allowedActions.destroy).toEqual([]);
});
it('should list items with meta permission', async () => {
const userRole = app.acl.define({
role: 'user',

View File

@ -43,6 +43,23 @@ export async function checkAction(ctx, next) {
}
const availableActions = ctx.app.acl.getAvailableActions();
let uiButtonSchemasBlacklist = [];
if (currentRole !== 'root') {
const eqCurrentRoleList = await ctx.db
.getRepository('uiButtonSchemasRoles')
.find({
filter: { 'roleName.$eq': currentRole },
})
.then((list) => list.map((v) => v.uid));
const NECurrentRoleList = await ctx.db
.getRepository('uiButtonSchemasRoles')
.find({
filter: { 'roleName.$ne': currentRole },
})
.then((list) => list.map((v) => v.uid));
uiButtonSchemasBlacklist = NECurrentRoleList.filter((uid) => !eqCurrentRoleList.includes(uid));
}
ctx.body = {
...role.toJSON(),
@ -53,6 +70,7 @@ export async function checkAction(ctx, next) {
allowConfigure: roleInstance.get('allowConfigure'),
allowMenuItemIds: roleInstance.get('menuUiSchemas').map((uiSchema) => uiSchema.get('x-uid')),
allowAnonymous: !!anonymous,
uiButtonSchemasBlacklist,
};
await next();

View File

@ -265,6 +265,7 @@ function createWithACLMetaMiddleware() {
}),
],
include: conditions.map((condition) => condition.include).flat(),
raw: true,
});
const allowedActions = inspectActions
@ -273,7 +274,9 @@ function createWithACLMetaMiddleware() {
return [action, ids];
}
return [action, results.filter((item) => Boolean(item.get(action))).map((item) => item.get(primaryKeyField))];
let actionIds = results.filter((item) => Boolean(item[action])).map((item) => item[primaryKeyField]);
actionIds = Array.from(new Set(actionIds));
return [action, actionIds];
})
.reduce((acc, [action, ids]) => {
acc[action] = ids;

View File

@ -201,7 +201,7 @@ export const bulkEditFormItemSettings = new SchemaSettings({
const { form } = useFormBlockContext();
const isFormReadPretty = useIsFormReadPretty();
const validateSchema = useValidateSchema();
return form && !isFormReadPretty && validateSchema;
return form && !isFormReadPretty && Boolean(validateSchema);
},
},
fieldComponentSettingsItem,

View File

@ -7,7 +7,7 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { Action, useAPIClient, useRequest, withDynamicSchemaProps } from '@nocobase/client';
import { Action, useAPIClient, useRequest, withDynamicSchemaProps, ACLActionProvider } from '@nocobase/client';
import React from 'react';
import { useFieldSchema } from '@formily/react';
import { listByCurrentRoleUrl } from '../constants';
@ -16,23 +16,23 @@ import { CustomRequestActionDesigner } from './CustomRequestActionDesigner';
export const CustomRequestActionACLDecorator = (props) => {
const apiClient = useAPIClient();
const isRoot = apiClient.auth.role === 'root';
const fieldSchema = useFieldSchema();
const { data } = useRequest<{ data: string[] }>(
{
url: listByCurrentRoleUrl,
},
{
manual: isRoot,
cacheKey: listByCurrentRoleUrl,
},
);
const requestId = fieldSchema?.['x-custom-request-id'] || fieldSchema?.['x-uid'];
if (!isRoot && !data?.data?.includes(requestId)) {
return null;
}
// const isRoot = apiClient.auth.role === 'root';
// const fieldSchema = useFieldSchema();
// const { data } = useRequest<{ data: string[] }>(
// {
// url: listByCurrentRoleUrl,
// },
// {
// manual: isRoot,
// cacheKey: listByCurrentRoleUrl,
// },
// );
return props.children;
// // if (!isRoot && !data?.data?.includes(fieldSchema?.['x-uid'])) {
// // return null;
// // }
return <ACLActionProvider>{props.children}</ACLActionProvider>;
};
const components = {

View File

@ -17,15 +17,13 @@ import {
useCollection_deprecated,
useDataSourceKey,
useDesignable,
useRequest,
SchemaSettingAccessControl,
} from '@nocobase/client';
import { App } from 'antd';
import React, { useMemo } from 'react';
import { listByCurrentRoleUrl } from '../constants';
import { useCustomRequestVariableOptions, useGetCustomRequest } from '../hooks';
import { useCustomRequestsResource } from '../hooks/useCustomRequestsResource';
import { useTranslation } from '../locale';
import { CustomRequestACLSchema, CustomRequestConfigurationFieldsSchema } from '../schemas';
import { CustomRequestConfigurationFieldsSchema } from '../schemas';
export function CustomRequestSettingsItem() {
const { t } = useTranslation();
@ -89,63 +87,6 @@ export function CustomRequestSettingsItem() {
);
}
export function CustomRequestACL() {
const { t } = useTranslation();
const fieldSchema = useFieldSchema();
const customRequestsResource = useCustomRequestsResource();
const { message } = App.useApp();
const { data, refresh } = useGetCustomRequest();
const { dn } = useDesignable();
const { refresh: refreshRoleCustomKeys } = useRequest<{ data: string[] }>(
{
url: listByCurrentRoleUrl,
},
{
manual: true,
cacheKey: listByCurrentRoleUrl,
},
);
return (
<>
<SchemaSettingsActionModalItem
title={t('Access control')}
schema={CustomRequestACLSchema}
initialValues={{
roles: data?.data?.roles,
}}
beforeOpen={() => !data && refresh()}
onSubmit={async ({ roles }) => {
const isSelfRequest =
!fieldSchema['x-custom-request-id'] || fieldSchema['x-custom-request-id'] === fieldSchema['x-uid'];
if (!isSelfRequest) {
fieldSchema['x-custom-request-id'] = fieldSchema['x-uid'];
await dn.emit('patch', {
schema: {
'x-uid': fieldSchema['x-uid'],
'x-custom-request-id': fieldSchema['x-uid'],
},
});
}
await customRequestsResource.updateOrCreate({
values: {
key: fieldSchema['x-uid'],
roles,
},
filterKeys: ['key'],
});
refresh();
refreshRoleCustomKeys();
dn.refresh();
return message.success(t('Saved successfully'));
}}
/>
</>
);
}
/**
* @deprecated
*/
@ -160,10 +101,7 @@ export const customRequestActionSettings = new SchemaSettings({
name: 'request settings',
Component: CustomRequestSettingsItem,
},
{
name: 'accessControl',
Component: CustomRequestACL,
},
SchemaSettingAccessControl,
],
},
],

View File

@ -19,8 +19,9 @@ import {
useCollection,
useCollectionRecord,
useSchemaToolbar,
SchemaSettingAccessControl,
} from '@nocobase/client';
import { CustomRequestACL, CustomRequestSettingsItem } from './components/CustomRequestActionDesigner';
import { CustomRequestSettingsItem } from './components/CustomRequestActionDesigner';
export const customizeCustomRequestActionSettings = new SchemaSettings({
name: 'actionSettings:customRequest',
@ -64,8 +65,10 @@ export const customizeCustomRequestActionSettings = new SchemaSettings({
Component: CustomRequestSettingsItem,
},
{
name: 'accessControl',
Component: CustomRequestACL,
...SchemaSettingAccessControl,
useVisible() {
return true;
},
},
{
name: 'refreshDataBlockRequest',

View File

@ -1,39 +0,0 @@
/**
* 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 { DEFAULT_DATA_SOURCE_KEY } from '@nocobase/client';
import { generateNTemplate } from '../locale';
export const CustomRequestACLSchema = {
type: 'object',
properties: {
roles: {
type: 'array',
title: generateNTemplate('Roles'),
'x-decorator': 'FormItem',
'x-decorator-props': {
tooltip: generateNTemplate('If not set, all roles can see this action'),
},
'x-component': 'RemoteSelect',
'x-component-props': {
multiple: true,
objectValue: true,
dataSource: DEFAULT_DATA_SOURCE_KEY,
service: {
resource: 'roles',
},
manual: false,
fieldNames: {
label: 'title',
value: 'name',
},
},
},
},
};

View File

@ -8,4 +8,3 @@
*/
export * from './CustomRequestConfigurationFields';
export * from './CustomRequestACL';

View File

@ -11,7 +11,7 @@ import { ExclamationCircleFilled, LoadingOutlined } from '@ant-design/icons';
import { css } from '@nocobase/client';
import { Button, Modal, Space, Spin } from 'antd';
import { saveAs } from 'file-saver';
import React from 'react';
import React, { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { NAMESPACE } from './constants';
import { useImportContext } from './context';
@ -25,6 +25,7 @@ export const ImportModal = (props: any) => {
const { t } = useTranslation(NAMESPACE);
const { importModalVisible, importStatus, importResult, setImportModalVisible } = useImportContext();
const { data: fileData, meta } = importResult ?? {};
const doneHandler = () => {
setImportModalVisible(false);
};
@ -33,6 +34,34 @@ export const ImportModal = (props: any) => {
const blob = new Blob([arrayBuffer], { type: 'application/x-xls' });
saveAs(blob, `fail.xlsx`);
};
const renderResult = (importResult) => {
if (!importResult) {
return null;
}
const { data, meta } = importResult;
if (meta) {
return t('{{successCount}} records have been successfully imported', {
...(meta ?? {}),
});
}
const stats = data;
const parts = [
`${t('Total records')}: ${stats.total || 0}`,
`${t('Successfully imported')}: ${stats.success || 0}`,
];
if (stats.skipped > 0) {
parts.push(`${t('Skipped')}: ${stats.skipped}`);
}
if (stats.updated > 0) {
parts.push(`${t('Updated')}: ${stats.updated}`);
}
return parts.join(', ');
};
return (
<Modal
title={t('Import Data')}
@ -57,11 +86,8 @@ export const ImportModal = (props: any) => {
{importStatus === ImportStatus.IMPORTED && (
<Space direction="vertical" align="center">
<ExclamationCircleFilled style={{ fontSize: 72, color: '#1890ff' }} />
<p>
{t('{{successCount}} records have been successfully imported', {
...(meta ?? {}),
})}
</p>
<p>{renderResult(importResult)}</p>
<Space>
{meta?.failureCount > 0 && (
<Button onClick={downloadFailureDataHandler}>{t('To download the failure data')}</Button>

View File

@ -173,6 +173,8 @@ export const useImportStartAction = () => {
);
}
const importMode = importSchema?.['x-action-settings']?.importMode || 'auto';
setVisible(false);
setImportModalVisible(true);
setImportStatus(ImportStatus.IMPORTING);
@ -181,13 +183,13 @@ export const useImportStartAction = () => {
const { data } = await (newResource as any).importXlsx(
{
values: formData,
mode: importMode,
},
{
timeout: 10 * 60 * 1000,
},
);
setImportResult(data);
form.reset();
if (!data.data.taskId) {

View File

@ -39,5 +39,13 @@
"Header mismatch at column {{column}}: expected \"{{expected}}\", but got \"{{actual}}\"": "第 {{column}} 列的表头不匹配:预期 \"{{expected}}\",实际是 \"{{actual}}\"",
"No data to import": "没有数据可导入",
"Failed to import row {{row}}, {{message}}, row data: {{data}}": "导入第 {{row}} 行失败,{{message}},行数据:{{data}}",
"import-error": "导入第 {{rowIndex}} 行失败,行数据:{{rowData}}, 原因:{{causeMessage}}"
"import-error": "导入第 {{rowIndex}} 行失败,行数据:{{rowData}}, 原因:{{causeMessage}}",
"Import completed": "导入完成:{{success}} 条记录已导入,{{updated}} 条记录已更新,{{skipped}} 条记录已跳过,共 {{total}} 条记录",
"Successfully imported": "成功导入",
"Updated records": "已更新记录",
"Skipped records": "已跳过记录",
"Total records": "总记录数",
"View result": "查看结果",
"ImportResult": "已导入 {{success}} 条,更新 {{updated}} 条,跳过 {{skipped}} 条,共 {{total}} 条",
"Task result": "任务结果"
}

View File

@ -50,36 +50,34 @@ export function authCheckMiddleware({ app }: { app: Application }) {
};
const errHandler = (error) => {
const newToken = error?.response?.headers?.['x-new-token'];
const errors = error?.response?.data?.errors;
const firstError = Array.isArray(errors) ? errors[0] : null;
const state = app.router.state;
const { pathname, search } = state.location;
const basename = app.router.basename;
if (newToken) {
app.apiClient.auth.setToken(newToken);
}
if (error.status === 401 && !error.config?.skipAuth) {
const requestToken = error?.config?.headers?.Authorization?.replace(/^Bearer\s+/gi, '');
const currentToken = app.apiClient.auth.getToken();
// if (currentToken && currentToken !== requestToken) {
// error.config.skipNotify = true;
// return app.apiClient.request(error.config);
// }
app.apiClient.auth.setToken('');
const errors = error?.response?.data?.errors;
const firstError = Array.isArray(errors) ? errors[0] : null;
if (!firstError) {
throw error;
}
// if the code
if (firstError?.code === AuthErrorCode.SKIP_TOKEN_RENEW) {
throw error;
if (error.status === 401) {
app.apiClient.auth.setToken('');
if (pathname === app.getHref('signin') && firstError?.code !== AuthErrorCode.EMPTY_TOKEN && error.config) {
error.config.skipNotify = false;
}
if (firstError?.code === 'USER_HAS_NO_ROLES_ERR') {
// use app error to show error message
error.config.skipNotify = true;
app.error = firstError;
}
}
if (error.status === 401 && !error.config?.skipAuth) {
if (!firstError || firstError?.code === AuthErrorCode.SKIP_TOKEN_RENEW) {
throw error;
}
const state = app.router.state;
const { pathname, search } = state.location;
const basename = app.router.basename;
if (pathname !== app.getHref('signin')) {
const redirectPath = removeBasename(pathname, basename);

View File

@ -4,7 +4,7 @@
"displayName.zh-CN": "区块:模板",
"description": "Create and manage block templates for reuse on pages.",
"description.zh-CN": "创建和管理区块模板,用于在页面中重复使用。",
"version": "1.6.0-alpha.14",
"version": "1.6.0-alpha.28",
"license": "AGPL-3.0",
"main": "dist/server/index.js",
"homepage": "https://docs.nocobase.com/handbook/block-template",

View File

@ -15,6 +15,7 @@ import {
useResource,
ISchema,
SchemaInitializerItemType,
useCurrentUserContext,
} from '@nocobase/client';
import React, { createContext, useContext, useEffect } from 'react';
import PluginBlockTemplateClient from '..';
@ -50,6 +51,7 @@ export const BlockTemplateMenusProvider = ({ children }) => {
const isMobile = window.location.pathname.startsWith(mobilePlugin.mobileBasename);
const location = useLocation();
const previousPathRef = React.useRef(location.pathname);
const user = useCurrentUserContext();
const { data, loading, refresh } = useRequest<{
data: {
@ -74,6 +76,7 @@ export const BlockTemplateMenusProvider = ({ children }) => {
},
{
cacheKey: 'blockTemplates',
manual: true,
},
);
@ -87,6 +90,12 @@ export const BlockTemplateMenusProvider = ({ children }) => {
previousPathRef.current = location.pathname;
}, [location.pathname, refresh]);
useEffect(() => {
if (user?.data) {
refresh();
}
}, [user, refresh]);
const handleTemplateClick = useMemoizedFn(async ({ item }, options?: any, insert?: any) => {
const { uid } = item;
const { data } = await api.request({

View File

@ -28,7 +28,7 @@ export async function templateDataMiddleware(ctx: Context, next) {
ctx.action.resourceName === 'uiSchemas' &&
['getProperties', 'getJsonSchema', 'getParentJsonSchema'].includes(ctx.action.actionName)
) {
const schema = ctx.body?.data;
const schema = ctx.body;
const schemaRepository = ctx.db.getRepository<UiSchemaRepository>('uiSchemas');
const blockTemplateRepository = ctx.db.getRepository('blockTemplates');

View File

@ -17,7 +17,7 @@ export class PluginBlockTemplateServer extends Plugin {
this.app.resourceManager.registerActionHandler('blockTemplates:destroy', destroy);
this.app.resourceManager.registerActionHandler('blockTemplates:link', link);
this.app.resourceManager.registerActionHandler('blockTemplates:saveSchema', saveSchema);
this.app.use(templateDataMiddleware);
this.app.resourceManager.use(templateDataMiddleware);
}
async load() {}

View File

@ -20,11 +20,11 @@ export function CustomSchemaSettingsBlockTitleItem() {
return (
<SchemaSettingsModalItem
title={t('Edit block title')}
title={t('Edit block title & description')}
schema={
{
type: 'object',
title: t('Edit block title'),
title: t('Edit block title & description'),
properties: {
title: {
title: t('Block title'),

View File

@ -8,7 +8,7 @@
*/
import { useFieldSchema } from '@formily/react';
import { Action, Icon, useCompile, useComponent, withDynamicSchemaProps } from '@nocobase/client';
import { Action, Icon, useCompile, useComponent, withDynamicSchemaProps, ACLActionProvider } from '@nocobase/client';
import { Avatar } from 'antd';
import { createStyles } from 'antd-style';
import React, { useContext } from 'react';
@ -18,13 +18,20 @@ import { WorkbenchLayout } from './workbenchBlockSettings';
const useStyles = createStyles(({ token, css }) => ({
// 支持 css object 的写法
action: css`
display: flex;
background-color: transparent;
border: 0;
height: auto;
box-shadow: none;
padding-top: 8px;
`,
avatar: css`
width: 4em;
`,
title: css`
margin-top: ${token.marginSM}px;
width: 100%;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
`,
@ -39,8 +46,8 @@ function Button() {
const compile = useCompile();
const title = compile(fieldSchema.title);
return layout === WorkbenchLayout.Grid ? (
<div title={title} style={{ width: '100%', overflow: 'hidden' }} className="nb-action-panel-container">
<Avatar style={{ backgroundColor }} size={54} icon={<Icon type={icon} />} />
<div title={title} className={cx(styles.avatar)}>
<Avatar style={{ backgroundColor }} size={48} icon={<Icon type={icon} />} />
<div className={cx(styles.title)}>{title}</div>
</div>
) : (
@ -54,12 +61,15 @@ export const WorkbenchAction = withDynamicSchemaProps((props) => {
const fieldSchema = useFieldSchema();
const Component = useComponent(props?.targetComponent) || Action;
return (
<ACLActionProvider>
<Component
className={cx(className, styles.action, 'nb-action-panel')}
{...others}
type="text"
icon={null}
title={<Button />}
confirmTitle={fieldSchema.title}
/>
</ACLActionProvider>
);
});

View File

@ -7,23 +7,21 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { css } from '@emotion/css';
import { observer, useFieldSchema } from '@formily/react';
import {
CollectionContext,
createStyles,
DataSourceContext,
DndContext,
Icon,
NocoBaseRecursionField,
useBlockHeight,
useDesignable,
useOpenModeContext,
useSchemaInitializerRender,
withDynamicSchemaProps,
useBlockHeightProps,
useOpenModeContext,
} from '@nocobase/client';
import { Avatar, List, Space, theme } from 'antd';
import React, { createContext, useEffect, useState, useRef, useMemo, useLayoutEffect } from 'react';
import { Avatar, Space } from 'antd';
import { Grid, List } from 'antd-mobile';
import React, { createContext } from 'react';
import { WorkbenchLayout } from './workbenchBlockSettings';
const ConfigureActionsButton = observer(
@ -46,105 +44,27 @@ const ResponsiveSpace = () => {
const isMobileMedia = isMobile();
const { isMobile: underMobileCtx } = useOpenModeContext() || {};
const { itemsPerRow = 4 } = fieldSchema.parent['x-decorator-props'] || {};
const isUnderMobile = isMobileMedia || underMobileCtx;
const containerRef = useRef(null); // 引用容器
const [containerWidth, setContainerWidth] = useState(0); // 容器宽度
// 使用 ResizeObserver 动态获取容器宽度
useEffect(() => {
const handleResize = () => {
if (containerRef.current) {
setContainerWidth(containerRef.current.offsetWidth); // 更新宽度
if (underMobileCtx || isMobileMedia) {
return (
<Grid columns={itemsPerRow} gap={gap}>
{fieldSchema.mapProperties((s, key) => {
return (
<Grid.Item style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }} key={key}>
<NocoBaseRecursionField name={key} schema={s} />
</Grid.Item>
);
})}
</Grid>
);
}
};
// 初始化 ResizeObserver
const resizeObserver = new ResizeObserver(handleResize);
// 监听容器宽度变化
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
handleResize(); // 初始化时获取一次宽度
}
return () => {
if (containerRef.current) {
resizeObserver.unobserve(containerRef.current);
}
};
}, []);
useLayoutEffect(() => {
if (!containerRef.current) return;
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
setContainerWidth(entry.contentRect.width); // 更新宽度
}
});
observer.observe(containerRef.current);
return () => {
observer.unobserve(containerRef.current);
};
}, []);
// 计算每个元素的宽度
const itemWidth = useMemo(() => {
if (isUnderMobile) {
const totalGapWidth = gap * itemsPerRow;
const availableWidth = containerWidth - totalGapWidth;
return availableWidth / itemsPerRow;
}
return 70;
}, [itemsPerRow, gap, containerWidth]);
// 计算 Avatar 的宽度
const avatarSize = useMemo(() => {
return isUnderMobile ? (Math.floor(itemWidth * 0.8) > 70 ? 60 : Math.floor(itemWidth * 0.8)) : 54; // Avatar 大小为 item 宽度的 60%
}, [itemWidth, itemsPerRow, containerWidth]);
return (
<div ref={containerRef} style={{ width: '100%' }}>
<Space
wrap
style={{ width: '100%', display: 'flex' }}
size={gap}
align="start"
className={css`
.ant-space-item {
width: ${isUnderMobile ? itemWidth + 'px' : '100%'}
display: flex;
.ant-nb-action {
padding: ${isUnderMobile ? '4px 0px' : null};
}
.nb-action-panel-container {
width: ${itemWidth}px !important;
}
.ant-avatar-circle {
width: ${avatarSize}px !important;
height: ${avatarSize}px !important;
line-height: ${avatarSize}px !important;
}
}
`}
>
{fieldSchema.mapProperties((s, key) => (
<div
key={key}
style={
isUnderMobile && {
flexBasis: `${itemWidth}px`,
flexShrink: 0,
flexGrow: 0,
display: 'flex',
}
}
>
<NocoBaseRecursionField name={key} schema={s} />
</div>
))}
<Space wrap size={gap} align="start">
{fieldSchema.mapProperties((s, key) => {
return <NocoBaseRecursionField name={key} schema={s} />;
})}
</Space>
</div>
);
};
@ -157,36 +77,17 @@ const InternalIcons = () => {
{layout === WorkbenchLayout.Grid ? (
<ResponsiveSpace />
) : (
<List itemLayout="horizontal">
<List>
{fieldSchema.mapProperties((s, key) => {
const icon = s['x-component-props']?.['icon'];
const backgroundColor = s['x-component-props']?.['iconColor'];
return (
<List.Item
key={key}
className={css`
.ant-list-item-meta-avatar {
margin-inline-end: 0px !important;
}
.ant-list-item-meta-title {
overflow: hidden;
text-overflow: ellipsis;
font-size: 14px;
margin: 0 0 0 0;
}
.ant-list-item-meta-title button {
font-size: 14px;
overflow: hidden;
text-overflow: ellipsis;
width: 100%;
text-align: left;
}
`}
prefix={<Avatar style={{ backgroundColor }} icon={<Icon type={icon} />} />}
onClick={() => {}}
>
<List.Item.Meta
avatar={<Avatar style={{ backgroundColor }} icon={<Icon type={icon} />} />}
title={<NocoBaseRecursionField name={key} schema={s} key={key} />}
></List.Item.Meta>
<NocoBaseRecursionField name={key} schema={s} />
</List.Item>
);
})}
@ -199,45 +100,56 @@ const InternalIcons = () => {
export const WorkbenchBlockContext = createContext({ layout: 'grid' });
const useStyles = createStyles(({ token, css }) => ({
containerClass: css`
&.list {
margin: -${token.paddingLG}px;
border-radius: ${(token as any).borderRadiusBlock}px;
overflow: hidden;
.adm-list {
--padding-left: ${token.paddingLG}px;
--padding-right: ${token.paddingLG}px;
.adm-list-item-content-main {
display: flex;
button {
background-color: transparent;
border: none;
height: auto;
box-shadow: none;
padding: 16px 32px;
margin: -12px -32px;
width: calc(100% + 64px);
text-align: start;
color: ${token.colorText};
}
}
}
button[aria-label*='schema-initializer-WorkbenchBlock.ActionBar-workbench:configureActions'] {
margin-bottom: ${token.paddingLG}px;
margin-left: ${token.paddingLG}px;
}
}
`,
}));
export const WorkbenchBlock: any = withDynamicSchemaProps(
(props) => {
const fieldSchema = useFieldSchema();
const { layout = 'grid' } = fieldSchema['x-component-props'] || {};
const { title } = fieldSchema['x-decorator-props'] || {};
const targetHeight = useBlockHeight();
const { token } = theme.useToken();
const { designable } = useDesignable();
const titleHeight = title ? token.fontSizeLG * token.lineHeightLG + token.padding * 2 - 1 : 0;
const internalHeight = 2 * token.paddingLG + token.controlHeight + token.marginLG + titleHeight;
const warperHeight =
targetHeight - (designable ? internalHeight : 2 * token.paddingLG + token.marginLG + titleHeight);
const targetWarperHeight = warperHeight > 0 ? warperHeight + 'px' : '100%';
const { styles } = useStyles();
return (
<div
className="nb-action-penal-container"
style={{ height: targetHeight ? targetHeight - 2 * token.paddingLG - gap - titleHeight : '100%' }}
>
<div
className={css`
.nb-action-panel-warp {
height: ${targetHeight ? targetWarperHeight : '100%'};
overflow-y: auto;
margin-left: -24px;
margin-right: -24px;
padding-left: 24px;
padding-right: 24px;
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
`}
>
<div className={`nb-action-penal-container ${layout} ${styles.containerClass}`}>
<WorkbenchBlockContext.Provider value={{ layout }}>
<DataSourceContext.Provider value={undefined}>
<CollectionContext.Provider value={undefined}>{props.children}</CollectionContext.Provider>
</DataSourceContext.Provider>
</WorkbenchBlockContext.Provider>
</div>
</div>
);
},
{ displayName: 'WorkbenchBlock' },

View File

@ -13,6 +13,7 @@ import {
SchemaSettingsActionLinkItem,
useSchemaInitializer,
ModalActionSchemaInitializerItem,
SchemaSettingAccessControl,
} from '@nocobase/client';
import React from 'react';
import { useTranslation } from 'react-i18next';
@ -30,6 +31,12 @@ export const workbenchActionSettingsCustomRequest = new SchemaSettings({
name: 'editLink',
Component: SchemaSettingsActionLinkItem,
},
{
...SchemaSettingAccessControl,
useVisible() {
return true;
},
},
{
sort: 800,
name: 'd1',

View File

@ -14,6 +14,7 @@ import {
useSchemaInitializer,
useSchemaInitializerItem,
ModalActionSchemaInitializerItem,
SchemaSettingAccessControl,
} from '@nocobase/client';
import React from 'react';
import { useTranslation } from 'react-i18next';
@ -32,6 +33,12 @@ export const workbenchActionSettingsLink = new SchemaSettings({
name: 'editLink',
Component: SchemaSettingsActionLinkItem,
},
{
...SchemaSettingAccessControl,
useVisible() {
return true;
},
},
{
sort: 800,
name: 'd1',

View File

@ -14,6 +14,7 @@ import {
useSchemaInitializer,
useOpenModeContext,
ModalActionSchemaInitializerItem,
SchemaSettingAccessControl,
} from '@nocobase/client';
import React from 'react';
import { useTranslation } from 'react-i18next';
@ -44,6 +45,12 @@ export const workbenchActionSettingsPopup = new SchemaSettings({
};
},
},
{
...SchemaSettingAccessControl,
useVisible() {
return true;
},
},
{
sort: 800,
name: 'd1',

View File

@ -14,6 +14,7 @@ import {
useSchemaInitializer,
useSchemaInitializerItem,
ModalActionSchemaInitializerItem,
SchemaSettingAccessControl,
} from '@nocobase/client';
import React from 'react';
import { useTranslation } from 'react-i18next';
@ -28,6 +29,12 @@ export const workbenchActionSettingsScanQrCode = new SchemaSettings({
return { hasIconColor: true };
},
},
{
...SchemaSettingAccessControl,
useVisible() {
return true;
},
},
{
name: 'd1',
type: 'divider',

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