Merge branch 'next' into develop

This commit is contained in:
nocobase[bot] 2025-04-08 14:18:39 +00:00
commit 8d89174f31
21 changed files with 162 additions and 32 deletions

View File

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

View File

@ -8,3 +8,4 @@
*/
export * from './i18n';
export * from './constant';

View File

@ -37,8 +37,8 @@ test.describe('linkage rules', () => {
await page.getByText('Add condition', { exact: true }).click();
await page.getByTestId('select-filter-field').click();
await page.getByRole('menuitemcheckbox', { name: 'singleLineText' }).click();
await page.getByLabel('Linkage rules').locator('input[type="text"]').click();
await page.getByLabel('Linkage rules').locator('input[type="text"]').fill('123');
await page.getByLabel('Linkage rules').getByRole('tabpanel').getByRole('textbox').click();
await page.getByLabel('Linkage rules').getByRole('tabpanel').getByRole('textbox').fill('123');
// action禁用 longText 字段
await page.getByText('Add property').click();

View File

@ -85,6 +85,7 @@ test.describe('configure fields', () => {
await page.getByRole('menuitem', { name: 'manyToOne2 right' }).hover();
await page.getByRole('menuitem', { name: 'manyToOne3' }).click();
await page.mouse.move(600, 0);
await page.reload();
await expect(page.getByLabel('block-item-CollectionField-general-form-general.manyToOne1-manyToOne1')).toHaveText(
`manyToOne1:${record.manyToOne1.id}`,

View File

@ -41,6 +41,7 @@ test.describe('where list block can be added', () => {
await page.getByLabel('schema-initializer-Grid-').nth(1).hover();
await page.getByRole('menuitem', { name: 'Role name' }).click();
await page.mouse.move(300, 0);
await page.reload();
await expect(page.getByLabel('block-item-CollectionField-').getByText('Root')).toBeVisible();
await expect(page.getByLabel('block-item-CollectionField-').getByText('Admin')).toBeVisible();
await expect(page.getByLabel('block-item-CollectionField-').getByText('Member')).toBeVisible();

View File

@ -13,14 +13,16 @@ import { T4334 } from '../templatesOfBug';
// fix https://nocobase.height.app/T-2187
test('action linkage by row data', async ({ page, mockPage }) => {
await mockPage(T4334).goto();
const adminEditAction = page.getByLabel('action-Action.Link-Edit-update-roles-table-admin');
const adminEditAction = page
.getByLabel('action-Action.Link-Edit-update-roles-table-admin')
.locator('.nb-action-title');
const adminEditActionStyle = await adminEditAction.evaluate((element) => {
const computedStyle = window.getComputedStyle(element);
return {
opacity: computedStyle.opacity,
};
});
const rootEditAction = page.getByLabel('action-Action.Link-Edit-update-roles-table-root');
const rootEditAction = page.getByLabel('action-Action.Link-Edit-update-roles-table-root').locator('.nb-action-title');
const rootEditActionStyle = await rootEditAction.evaluate((element) => {
const computedStyle = window.getComputedStyle(element);
return {

View File

@ -156,6 +156,7 @@ test.describe('configure columns', () => {
await page.getByRole('menuitem', { name: 'manyToOne2 right' }).hover();
await page.getByRole('menuitem', { name: 'manyToOne3' }).click();
await page.mouse.move(600, 0);
await page.reload();
// 2. Click on the association field, create a details block in the popup, display the ID field, and assert if it's correct
await page
@ -194,6 +195,7 @@ test.describe('configure columns', () => {
await page.getByLabel('schema-initializer-Grid-details:configureFields-emptyCollection').hover();
await page.getByRole('menuitem', { name: 'ID', exact: true }).click();
await page.mouse.move(600, 0);
await expect(page.getByLabel('block-item-CollectionField-')).toHaveText(
`ID:${record.manyToOne1.manyToOne2.manyToOne3.id}`,
);
@ -212,6 +214,7 @@ test.describe('configure columns', () => {
await page.getByRole('menuitem', { name: 'manyToOne2 right' }).hover();
await page.getByRole('menuitem', { name: 'manyToOne3' }).click();
await page.mouse.move(600, 0);
await page.reload();
// 2. 点击每一个关系字段,创建一个详情区块,显示 ID 字段,断言 ID 是否正确
await page
@ -307,8 +310,8 @@ test.describe('configure actions column', () => {
await page.getByLabel('designer-schema-initializer-TableV2.Column-TableV2.ActionColumnDesigner-').hover();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByText('Actions', { exact: true }).hover({ force: true });
await page.getByLabel('designer-schema-initializer-TableV2.Column-TableV2.ActionColumnDesigner-').hover();
// await page.getByText('Actions', { exact: true }).hover({ force: true });
// await page.getByLabel('designer-schema-initializer-TableV2.Column-TableV2.ActionColumnDesigner-').hover();
await page.getByRole('menuitem', { name: 'Duplicate' }).click();
await page.mouse.move(300, 0);

View File

@ -36,7 +36,8 @@ test.describe('popup router', () => {
await page.locator('.ant-drawer-mask').click();
// expect to be back to the first page
await page.getByText('Users单层子页面Configure').hover();
await page.getByLabel('block-item-CardItem-users-').getByText('Users 单层子页面Configure').hover();
await expect(
page.getByRole('button', { name: 'designer-schema-settings-CardItem-blockSettings:table-users' }),
).toBeVisible();
@ -60,7 +61,7 @@ test.describe('popup router', () => {
await page.locator('.ant-drawer-mask').click();
// expect to be back to the first page
await page.getByText('Users单层子页面Configure').hover();
await page.getByLabel('block-item-CardItem-users-').getByText('Users 单层子页面Configure').hover();
await expect(
page.getByRole('button', { name: 'designer-schema-settings-CardItem-blockSettings:table-users' }),
).toBeVisible();

View File

@ -10,6 +10,7 @@
import { observer, RecursionField, useField, useFieldSchema } from '@formily/react';
import { Drawer } from 'antd';
import classNames from 'classnames';
import { useTranslation } from 'react-i18next';
import React, { FC, startTransition, useCallback, useEffect, useMemo, useState } from 'react';
import { ErrorBoundary, FallbackProps } from 'react-error-boundary';
import { NocoBaseRecursionField } from '../../../formily/NocoBaseRecursionField';
@ -22,6 +23,7 @@ import { useActionContext } from './hooks';
import { useSetAriaLabelForDrawer } from './hooks/useSetAriaLabelForDrawer';
import { ActionDrawerProps, ComposedActionDrawer, OpenSize } from './types';
import { getZIndex, useZIndexContext, zIndexContext } from './zIndexContext';
import { NAMESPACE_UI_SCHEMA } from '../../../i18n/constant';
const MemoizeRecursionField = React.memo(RecursionField);
MemoizeRecursionField.displayName = 'MemoizeRecursionField';
@ -81,6 +83,7 @@ export const InternalActionDrawer: React.FC<ActionDrawerProps> = observer(
const { visible, setVisible, openSize = 'middle', drawerProps } = useActionContext();
const schema = useFieldSchema();
const field = useField();
const { t } = useTranslation();
const { componentCls, hashId } = useStyles();
const tabContext = useTabsContext();
const parentZIndex = useZIndexContext();
@ -126,7 +129,7 @@ export const InternalActionDrawer: React.FC<ActionDrawerProps> = observer(
<Drawer
zIndex={zIndex}
width={openSizeWidthMap.get(openSize)}
title={field.title}
title={t(field.title, { ns: NAMESPACE_UI_SCHEMA })}
{...others}
{...drawerProps}
rootStyle={rootStyle}

View File

@ -48,6 +48,7 @@ import { ActionContextProvider } from './context';
import { useGetAriaLabelOfAction } from './hooks/useGetAriaLabelOfAction';
import { ActionContextProps, ActionProps, ComposedAction } from './types';
import { linkageAction, setInitialActionState } from './utils';
import { NAMESPACE_UI_SCHEMA } from '../../../i18n/constant';
// 这个要放到最下面,否则会导致前端单测失败
import { useApp } from '../../../application';
@ -563,6 +564,7 @@ const RenderButtonInner = observer(
onlyIcon,
...others
} = props;
const { t } = useTranslation();
const debouncedClick = useCallback(
debounce(
(e: React.MouseEvent, checkPortal = true) => {
@ -584,7 +586,8 @@ const RenderButtonInner = observer(
return null;
}
const actionTitle = title || field?.title;
const rawTitle = title ?? field?.title;
const actionTitle = typeof rawTitle === 'string' ? t(rawTitle, { ns: NAMESPACE_UI_SCHEMA }) : rawTitle;
const { opacity, ...restButtonStyle } = buttonStyle;
const linkStyle = isLink && opacity ? { opacity } : undefined;
return (

View File

@ -9,8 +9,10 @@
import { Card, CardProps } from 'antd';
import React, { useMemo, useRef, useEffect, createContext, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useToken } from '../../../style';
import { MarkdownReadPretty } from '../markdown';
import { NAMESPACE_UI_SCHEMA } from '../../../i18n/constant';
export const BlockItemCardContext = createContext({});
@ -22,6 +24,7 @@ export const BlockItemCard = React.forwardRef<HTMLDivElement, CardProps | any>((
}, [token.marginBlock]);
const [titleHeight, setTitleHeight] = useState(0);
const titleRef = useRef<HTMLDivElement | null>(null);
const { t } = useTranslation();
useEffect(() => {
const timer = setTimeout(() => {
if (titleRef.current) {
@ -38,10 +41,10 @@ export const BlockItemCard = React.forwardRef<HTMLDivElement, CardProps | any>((
}, [blockTitle, description]);
const title = (blockTitle || description) && (
<div ref={titleRef} style={{ padding: '8px 0px 8px' }}>
<span>{blockTitle}</span>
<span> {t(blockTitle, { ns: NAMESPACE_UI_SCHEMA })}</span>
{description && (
<MarkdownReadPretty
value={props.description}
value={t(description, { ns: NAMESPACE_UI_SCHEMA })}
style={{
overflowWrap: 'break-word',
whiteSpace: 'normal',

View File

@ -27,6 +27,8 @@ import { useEnsureOperatorsValid } from './SchemaSettingOptions';
import useLazyLoadDisplayAssociationFieldsOfForm from './hooks/useLazyLoadDisplayAssociationFieldsOfForm';
import { useLinkageRulesForSubTableOrSubForm } from './hooks/useLinkageRulesForSubTableOrSubForm';
import useParseDefaultValue from './hooks/useParseDefaultValue';
import { useTranslation } from 'react-i18next';
import { NAMESPACE_UI_SCHEMA } from '../../../i18n/constant';
Item.displayName = 'FormilyFormItem';
@ -64,7 +66,7 @@ export const FormItem: any = withDynamicSchemaProps(
const schema = useFieldSchema();
const { addActiveFieldName } = useFormActiveFields() || {};
const { wrapperStyle }: { wrapperStyle: any } = useDataFormItemProps();
const { t } = useTranslation();
useParseDefaultValue();
useLazyLoadDisplayAssociationFieldsOfForm();
useLinkageRulesForSubTableOrSubForm();
@ -72,18 +74,20 @@ export const FormItem: any = withDynamicSchemaProps(
useEffect(() => {
addActiveFieldName?.(schema.name as string);
}, [addActiveFieldName, schema.name]);
field.title = t(field.title, { ns: NAMESPACE_UI_SCHEMA });
const showTitle = schema['x-decorator-props']?.showTitle ?? true;
const extra = useMemo(() => {
if (field.description && field.description !== '') {
return typeof field.description === 'string' ? (
<div
dangerouslySetInnerHTML={{
__html: HTMLEncode(field.description).split('\n').join('<br/>'),
__html: HTMLEncode(t(field.description, { ns: NAMESPACE_UI_SCHEMA }))
.split('\n')
.join('<br/>'),
}}
/>
) : (
field.description
t(field.description, { ns: NAMESPACE_UI_SCHEMA })
);
}
}, [field.description]);

View File

@ -52,6 +52,7 @@ import { useStyles } from './Page.style';
import { PageDesigner, PageTabDesigner } from './PageTabDesigner';
import { PopupRouteContextResetter } from './PopupRouteContextResetter';
import { transformMultiColumnToSingleColumn } from '@nocobase/utils/client';
import { NAMESPACE_UI_SCHEMA } from '../../../i18n/constant';
interface PageProps {
currentTabUid: string;
@ -431,7 +432,8 @@ const NocoBasePageHeader = React.memo(({ activeKey, className }: { activeKey: st
const { token } = useToken();
useEffect(() => {
const title = t(fieldSchema.title) || t(currentRoute?.title);
const title =
t(fieldSchema.title, { ns: NAMESPACE_UI_SCHEMA }) || t(currentRoute?.title, { ns: NAMESPACE_UI_SCHEMA });
if (title) {
setDocumentTitle(title);
setPageTitle(title);

View File

@ -9,8 +9,11 @@
import { useField } from '@formily/react';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { NAMESPACE_UI_SCHEMA } from '../../../i18n/constant';
export const TableColumn = (props) => {
const field = useField();
return <div role="button">{field.title}</div>;
const { t } = useTranslation();
return <div role="button">{t(field.title, { ns: NAMESPACE_UI_SCHEMA })}</div>;
};

View File

@ -67,6 +67,7 @@ import { useAssociationFieldContext } from '../association-field/hooks';
import { TableSkeleton } from './TableSkeleton';
import { extractIndex, isCollectionFieldComponent, isColumnComponent } from './utils';
import { withTooltipComponent } from '../../../hoc/withTooltipComponent';
import { NAMESPACE_UI_SCHEMA } from '../../../i18n/constant';
type BodyRowComponentProps = {
rowIndex?: number;
@ -164,6 +165,7 @@ const useTableColumns = (
props: { showDel?: any; isSubTable?: boolean; optimizeTextCellRender: boolean },
paginationProps,
) => {
const { t } = useTranslation();
const { token } = useToken();
const field = useArrayField(props);
const schema = useFieldSchema();
@ -213,11 +215,10 @@ const useTableColumns = (
const dataIndex = collectionFields?.length > 0 ? collectionFields[0].name : columnSchema.name;
const columnHidden = !!columnSchema['x-component-props']?.['columnHidden'];
const { uiSchema, defaultValue, interface: _interface } = collection?.getField(dataIndex) || {};
columnSchema.title = t(columnSchema?.title, { ns: NAMESPACE_UI_SCHEMA });
if (uiSchema) {
uiSchema.default = defaultValue;
}
return {
title: (
<RefreshComponentProvider refresh={refresh}>

View File

@ -12,6 +12,7 @@ import { observer, RecursionField, Schema, useField, useFieldSchema } from '@for
import { Tabs as AntdTabs, TabPaneProps, TabsProps } from 'antd';
import classNames from 'classnames';
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useSchemaInitializerRender } from '../../../application';
import { withDynamicSchemaProps } from '../../../hoc/withDynamicSchemaProps';
import { Icon } from '../../../icon';
@ -22,6 +23,7 @@ import { useTabsContext } from './context';
import { TabsDesigner } from './Tabs.Designer';
import { useMobileLayout } from '../../../route-switch/antd/admin-layout';
import { transformMultiColumnToSingleColumn } from '@nocobase/utils/client';
import { NAMESPACE_UI_SCHEMA } from '../../../i18n/constant';
const MemoizeRecursionField = React.memo(RecursionField);
MemoizeRecursionField.displayName = 'MemoizeRecursionField';
@ -136,14 +138,15 @@ Tabs.TabPane = withDynamicSchemaProps(
(props: TabPaneProps & { icon?: any; hidden?: boolean }) => {
const Designer = useDesigner();
const field = useField();
const { t } = useTranslation();
if (props.hidden) {
return null;
}
return (
<SortableItem className={classNames('nb-action-link', designerCss, props.className)}>
{props.icon && <Icon style={{ marginRight: 2 }} type={props.icon} />} {props.tab || field.title}
{props.icon && <Icon style={{ marginRight: 2 }} type={props.icon} />}{' '}
{props.tab || t(field.title, { ns: NAMESPACE_UI_SCHEMA })}
<Designer />
</SortableItem>
);

View File

@ -37,8 +37,8 @@ test.describe('data will be updated && Assign field values && after successful s
await page.getByRole('menuitem', { name: 'After successful submission' }).click();
await page.getByLabel('Manually close').check();
await page.getByLabel('Redirect to').check();
await page.locator('input[type="text"]').click();
await page.locator('input[type="text"]').fill('/admin/pm/list/local/');
await page.getByLabel('textbox').click();
await page.getByLabel('textbox').fill('/admin/pm/list/local/');
await page.getByRole('button', { name: 'OK', exact: true }).click();
await page.getByLabel('action-Action-Bulk update-customize:bulkUpdate-general-table').click();
const [request] = await Promise.all([

View File

@ -25,9 +25,7 @@ test.describe('custom request action', () => {
await page.getByLabel('designer-schema-settings-CustomRequestAction-actionSettings:customRequest-').hover();
await page.getByRole('menuitem', { name: 'Edit button' }).click();
// 应该只显示标题输入框
await expect(page.getByText('Button title')).toBeVisible();
await expect(page.getByText('Button icon')).not.toBeVisible();
await expect(page.getByText('Button background color')).not.toBeVisible();
});

View File

@ -8,10 +8,18 @@
*/
import { useFieldSchema } from '@formily/react';
import { Action, Icon, useCompile, useComponent, withDynamicSchemaProps, ACLActionProvider } from '@nocobase/client';
import {
Action,
Icon,
useComponent,
withDynamicSchemaProps,
ACLActionProvider,
NAMESPACE_UI_SCHEMA,
} from '@nocobase/client';
import { Avatar } from 'antd';
import { createStyles } from 'antd-style';
import React, { useContext } from 'react';
import { useTranslation } from 'react-i18next';
import { WorkbenchBlockContext } from './WorkbenchBlock';
import { WorkbenchLayout } from './workbenchBlockSettings';
@ -40,8 +48,8 @@ function Button() {
const backgroundColor = fieldSchema['x-component-props']?.['iconColor'];
const { layout, ellipsis = true } = useContext(WorkbenchBlockContext);
const { styles, cx } = useStyles();
const compile = useCompile();
const title = compile(fieldSchema.title);
const { t } = useTranslation();
const title = t(fieldSchema.title, { ns: NAMESPACE_UI_SCHEMA });
return layout === WorkbenchLayout.Grid ? (
<div title={title} className={cx(styles.avatar)}>
<Avatar style={{ backgroundColor }} size={48} icon={<Icon type={icon} />} />

View File

@ -10,7 +10,7 @@
import { Cache } from '@nocobase/cache';
import { Repository, Transaction, Transactionable } from '@nocobase/database';
import { uid } from '@nocobase/utils';
import lodash from 'lodash';
import { default as _, default as lodash } from 'lodash';
import { ChildOptions, SchemaNode, TargetPosition } from './dao/ui_schema_node_dao';
export interface GetJsonSchemaOptions {
@ -297,6 +297,23 @@ export class UiSchemaRepository extends Repository {
);
}
async emitAfterSaveEvent(s, options) {
if (!s?.schema) {
return;
}
const keys = ['title', 'description', 'x-component-props.title', 'x-decorator-props.title'];
let r = false;
for (const key of keys) {
if (_.get(s?.schema, key)) {
r = true;
break;
}
}
if (r) {
await this.database.emitAsync(`${this.collection.name}.afterSave`, s, options);
}
}
@transaction()
async patch(newSchema: any, options?) {
const { transaction } = options;
@ -305,8 +322,8 @@ export class UiSchemaRepository extends Repository {
if (!newSchema['properties']) {
const s = await this.model.findByPk(rootUid, { transaction });
s.set('schema', { ...s.toJSON(), ...newSchema });
// console.log(s.toJSON());
await s.save({ transaction, hooks: false });
await this.emitAfterSaveEvent(s, options);
if (newSchema['x-server-hooks']) {
await this.database.emitAsync(`${this.collection.name}.afterSave`, s, options);
}
@ -488,8 +505,14 @@ export class UiSchemaRepository extends Repository {
}
const result = await this[`insert${lodash.upperFirst(position)}`](target, schema, options);
const s = await this.model.findByPk(schema, { transaction });
await this.emitAfterSaveEvent(s, options);
// clear target schema path cache
await this.clearXUidPathCache(result['x-uid'], transaction);
return result;
}
@ -869,6 +892,8 @@ WHERE TreeTable.depth = 1 AND TreeTable.ancestor = :ancestor and TreeTable.sort
},
);
await this.emitAfterSaveEvent(nodeModel, { transaction });
if (schema['x-server-hooks']) {
await this.database.emitAsync(`${this.collection.name}.afterSave`, nodeModel, { transaction });
}

View File

@ -9,6 +9,8 @@
import { MagicAttributeModel } from '@nocobase/database';
import { Plugin } from '@nocobase/server';
import PluginLocalizationServer from '@nocobase/plugin-localization';
import { tval } from '@nocobase/utils';
import { uid } from '@nocobase/utils';
import path, { resolve } from 'path';
import { uiSchemaActions } from './actions/ui-schema-action';
@ -17,6 +19,19 @@ import UiSchemaRepository from './repository';
import { ServerHooks } from './server-hooks';
import { ServerHookModel } from './server-hooks/model';
export const compile = (title: string) => (title || '').replace(/{{\s*t\(["|'|`](.*)["|'|`]\)\s*}}/g, '$1');
function extractFields(obj) {
return [
obj.title,
obj.description,
obj['x-component-props']?.title,
obj['x-component-props']?.description,
obj['x-decorator-props']?.title,
obj['x-decorator-props']?.description,
].filter((value) => value !== undefined && value !== '');
}
export class PluginUISchemaStorageServer extends Plugin {
serverHooks: ServerHooks;
@ -28,7 +43,7 @@ export class PluginUISchemaStorageServer extends Plugin {
async beforeLoad() {
const db = this.app.db;
const pm = this.app.pm;
this.serverHooks = new ServerHooks(db);
this.app.db.registerModels({ MagicAttributeModel, UiSchemaModel, ServerHookModel });
@ -51,6 +66,19 @@ export class PluginUISchemaStorageServer extends Plugin {
}
});
db.on('uiSchemas.afterSave', async function setUid(model, options) {
const localizationPlugin = pm.get('localization') as PluginLocalizationServer;
const texts = [];
const changedFields = extractFields(model.toJSON());
if (!changedFields.length) {
return;
}
changedFields.forEach((field) => {
field && texts.push({ text: compile(field), module: `resources.ui-schema-storage` });
});
await localizationPlugin?.addNewTexts?.(texts, options);
});
db.on('uiSchemas.afterCreate', async function insertSchema(model, options) {
const { transaction } = options;
const uiSchemaRepository = db.getCollection('uiSchemas').repository as UiSchemaRepository;
@ -125,6 +153,34 @@ export class PluginUISchemaStorageServer extends Plugin {
]);
await this.importCollections(resolve(__dirname, 'collections'));
// this.registerLocalizationSource();
}
registerLocalizationSource() {
const localizationPlugin = this.app.pm.get('localization') as PluginLocalizationServer;
if (!localizationPlugin) {
return;
}
localizationPlugin.sourceManager.registerSource('ui-schema-storage', {
title: tval('UiSchema'),
sync: async (ctx) => {
const uiSchemas = await ctx.db.getRepository('uiSchemas').find({
raw: true,
});
const resources = {};
uiSchemas.forEach((route: { schema?: any }) => {
const changedFields = extractFields(route.schema);
if (changedFields.length) {
changedFields.forEach((field) => {
resources[field] = '';
});
}
});
return {
'ui-schema-storage': resources,
};
},
});
}
}