Merge branch 'next' into develop

This commit is contained in:
nocobase[bot] 2025-04-30 10:30:06 +00:00
commit 461525353b
11 changed files with 132 additions and 80 deletions

View File

@ -72,7 +72,7 @@ describe('SchemaInitializerDivider', () => {
expect(onSubmit).toBeCalled(); expect(onSubmit).toBeCalled();
}); });
it('item mode', async () => { it.skip('item mode', async () => {
const onSubmit = vitest.fn(); const onSubmit = vitest.fn();
const Demo = () => { const Demo = () => {
return ( return (

View File

@ -52,6 +52,7 @@ import { useBlockRequestContext, useFilterByTk, useParamsFromRecord } from '../B
import { useOperators } from '../CollectOperators'; import { useOperators } from '../CollectOperators';
import { useDetailsBlockContext } from '../DetailsBlockProvider'; import { useDetailsBlockContext } from '../DetailsBlockProvider';
import { TableFieldResource } from '../TableFieldProvider'; import { TableFieldResource } from '../TableFieldProvider';
import { getVariableValue } from '../../common/getVariableValue';
export * from './useBlockHeightProps'; export * from './useBlockHeightProps';
export * from './useDataBlockParentRecord'; export * from './useDataBlockParentRecord';
@ -232,12 +233,6 @@ export function useCollectValuesToSubmit() {
]); ]);
} }
export function interpolateVariables(str: string, scope: Record<string, any>): string {
return str.replace(/\{\{\s*([a-zA-Z0-9_$.-]+?)\s*\}\}/g, (_, key) => {
return scope[key] !== undefined ? String(scope[key]) : '';
});
}
export const useCreateActionProps = () => { export const useCreateActionProps = () => {
const filterByTk = useFilterByTk(); const filterByTk = useFilterByTk();
const record = useCollectionRecord(); const record = useCollectionRecord();
@ -287,11 +282,10 @@ export const useCreateActionProps = () => {
}); });
let redirectTo = rawRedirectTo; let redirectTo = rawRedirectTo;
if (rawRedirectTo) { if (rawRedirectTo) {
const { exp, scope: expScope } = await replaceVariables(rawRedirectTo, { redirectTo = await getVariableValue(rawRedirectTo, {
variables, variables,
localVariables: [...localVariables, { name: '$record', ctx: new Proxy(data?.data?.data, {}) }], localVariables: [...localVariables, { name: '$record', ctx: new Proxy(data?.data?.data, {}) }],
}); });
redirectTo = interpolateVariables(exp, expScope);
} }
if (actionAfterSuccess === 'previous' || (!actionAfterSuccess && redirecting !== true)) { if (actionAfterSuccess === 'previous' || (!actionAfterSuccess && redirecting !== true)) {
@ -686,11 +680,11 @@ export const useCustomizeUpdateActionProps = () => {
let redirectTo = rawRedirectTo; let redirectTo = rawRedirectTo;
if (rawRedirectTo) { if (rawRedirectTo) {
const { exp, scope: expScope } = await replaceVariables(rawRedirectTo, { // eslint-disable-next-line react-hooks/rules-of-hooks
redirectTo = await getVariableValue(rawRedirectTo, {
variables, variables,
localVariables: [...localVariables, { name: '$record', ctx: new Proxy(result?.data?.data?.[0], {}) }], localVariables: [...localVariables, { name: '$record', ctx: new Proxy(result?.data?.data, {}) }],
}); });
redirectTo = interpolateVariables(exp, expScope);
} }
if (actionAfterSuccess === 'previous' || (!actionAfterSuccess && redirecting !== true)) { if (actionAfterSuccess === 'previous' || (!actionAfterSuccess && redirecting !== true)) {
@ -1050,11 +1044,11 @@ export const useUpdateActionProps = () => {
} }
let redirectTo = rawRedirectTo; let redirectTo = rawRedirectTo;
if (rawRedirectTo) { if (rawRedirectTo) {
const { exp, scope: expScope } = await replaceVariables(rawRedirectTo, { // eslint-disable-next-line react-hooks/rules-of-hooks
redirectTo = await getVariableValue(rawRedirectTo, {
variables, variables,
localVariables: [...localVariables, { name: '$record', ctx: new Proxy(result?.data?.data?.[0], {}) }], localVariables: [...localVariables, { name: '$record', ctx: new Proxy(result?.data?.data, {}) }],
}); });
redirectTo = interpolateVariables(exp, expScope);
} }
if (actionAfterSuccess === 'previous' || (!actionAfterSuccess && redirecting !== true)) { if (actionAfterSuccess === 'previous' || (!actionAfterSuccess && redirecting !== true)) {

View File

@ -0,0 +1,23 @@
/**
* 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 { evaluators } from '@nocobase/evaluators/client';
import { replaceVariables } from '../schema-settings/LinkageRules/bindLinkageRulesToFiled';
export const getVariableValue = async (text: string, scopes) => {
if (!text) {
return text;
}
const { evaluate } = evaluators.get('string');
const { exp, scope: expScope } = await replaceVariables(text, scopes);
const result = evaluate(exp, { now: () => new Date().toString(), ...expScope });
return result;
};

View File

@ -10,3 +10,4 @@
export * from './AppNotFound'; export * from './AppNotFound';
export * from './SelectWithTitle'; export * from './SelectWithTitle';
export * from './useFieldComponentName'; export * from './useFieldComponentName';
export * from './getVariableValue';

View File

@ -31,7 +31,11 @@ test.describe('Link', () => {
await expect( await expect(
page.getByRole('button', { name: 'designer-schema-settings-Action.Link-actionSettings:link-users' }), page.getByRole('button', { name: 'designer-schema-settings-Action.Link-actionSettings:link-users' }),
).toHaveCount(1); ).toHaveCount(1);
await page.getByRole('button', { name: 'designer-schema-settings-Action.Link-actionSettings:link-users' }).hover(); await page
.getByRole('button', { name: 'designer-schema-settings-Action.Link-actionSettings:link-users' })
.first()
.hover();
await page.getByLabel('designer-schema-settings-Action.Link-actionSettings:link-users').first().hover();
await page.getByRole('menuitem', { name: 'Edit link' }).click(); await page.getByRole('menuitem', { name: 'Edit link' }).click();
await page.getByLabel('block-item-users-URL').getByLabel('textbox').click(); await page.getByLabel('block-item-users-URL').getByLabel('textbox').click();
await page await page
@ -106,7 +110,7 @@ test.describe('Link', () => {
await page.getByLabel('block-item-users-URL').getByLabel('textbox').fill(otherPageUrl); await page.getByLabel('block-item-users-URL').getByLabel('textbox').fill(otherPageUrl);
await page.getByRole('button', { name: 'OK', exact: true }).click(); await page.getByRole('button', { name: 'OK', exact: true }).click();
await page.getByLabel('action-Action.Link-Link-').click(); await page.getByLabel('action-Action.Link-Link-').first().click();
expect(page.url().endsWith(otherPageUrl)).toBe(true); expect(page.url().endsWith(otherPageUrl)).toBe(true);
// 开启 “Open in new window” 选项后,点击链接按钮会在新窗口打开 // 开启 “Open in new window” 选项后,点击链接按钮会在新窗口打开

View File

@ -23,7 +23,7 @@ test.describe('options of Select field in linkage rule', () => {
// 去掉联动规则恢复选项 // 去掉联动规则恢复选项
await page.getByLabel('block-item-CardItem-general-').hover(); await page.getByLabel('block-item-CardItem-general-').hover();
await page.getByLabel('designer-schema-settings-CardItem-blockSettings:createForm-general').hover(); await page.getByLabel('designer-schema-settings-CardItem-blockSettings:createForm-general').hover();
await page.getByRole('menuitem', { name: 'Linkage rules' }).click(); await page.getByRole('menuitem', { name: 'Field linkage rules' }).click();
await page.getByRole('switch', { name: 'On Off' }).click(); await page.getByRole('switch', { name: 'On Off' }).click();
await page.getByRole('button', { name: 'OK' }).click(); await page.getByRole('button', { name: 'OK' }).click();
await page.reload(); await page.reload();

View File

@ -19,7 +19,8 @@ test('tabs', async ({ page, mockPage }) => {
await page.getByText('tab 1').hover(); await page.getByText('tab 1').hover();
await page.getByRole('button', { name: 'designer-drag-handler-Page-tab' }).dragTo(page.getByText('tab 2')); await page.getByRole('button', { name: 'designer-drag-handler-Page-tab' }).dragTo(page.getByText('tab 2'));
await expect(page.getByText('tab 1')).toBeVisible(); await page.waitForTimeout(500);
await expect(page.getByText('tab 2')).toBeVisible();
tab1Box = await page.getByText('tab 1').boundingBox(); tab1Box = await page.getByText('tab 1').boundingBox();
tab2Box = await page.getByText('tab 2').boundingBox(); tab2Box = await page.getByText('tab 2').boundingBox();

View File

@ -7,7 +7,7 @@
* For more information, please refer to: https://www.nocobase.com/agreement. * For more information, please refer to: https://www.nocobase.com/agreement.
*/ */
import { ISchema, useField, useFieldSchema } from '@formily/react'; import { ISchema, useField, useFieldSchema, useForm } from '@formily/react';
import { isValid, uid } from '@formily/shared'; import { isValid, uid } from '@formily/shared';
import { ModalProps, Select } from 'antd'; import { ModalProps, Select } from 'antd';
import React, { useCallback, useMemo } from 'react'; import React, { useCallback, useMemo } from 'react';
@ -18,7 +18,12 @@ import { useGlobalVariable } from '../../../application/hooks/useGlobalVariable'
import { SchemaSettingOptions, SchemaSettings } from '../../../application/schema-settings'; import { SchemaSettingOptions, SchemaSettings } from '../../../application/schema-settings';
import { useSchemaToolbar } from '../../../application/schema-toolbar'; import { useSchemaToolbar } from '../../../application/schema-toolbar';
import { useCollectionManager_deprecated, useCollection_deprecated } from '../../../collection-manager'; import { useCollectionManager_deprecated, useCollection_deprecated } from '../../../collection-manager';
import { highlightBlock, startScrollEndTracking, stopScrollEndTracking, unhighlightBlock } from '../../../filter-provider/highlightBlock'; import {
highlightBlock,
startScrollEndTracking,
stopScrollEndTracking,
unhighlightBlock,
} from '../../../filter-provider/highlightBlock';
import { FlagProvider } from '../../../flag-provider'; import { FlagProvider } from '../../../flag-provider';
import { SaveMode } from '../../../modules/actions/submit/createSubmitActionSettings'; import { SaveMode } from '../../../modules/actions/submit/createSubmitActionSettings';
import { useOpenModeContext } from '../../../modules/popup/OpenModeProvider'; import { useOpenModeContext } from '../../../modules/popup/OpenModeProvider';
@ -38,6 +43,8 @@ import { useAllDataBlocks } from '../page/AllDataBlocksProvider';
import { useLinkageAction } from './hooks'; import { useLinkageAction } from './hooks';
import { useAfterSuccessOptions } from './hooks/useGetAfterSuccessVariablesOptions'; import { useAfterSuccessOptions } from './hooks/useGetAfterSuccessVariablesOptions';
import { requestSettingsSchema } from './utils'; import { requestSettingsSchema } from './utils';
import { useVariableOptions } from '../../../schema-settings/VariableInput/hooks/useVariableOptions';
import { useCollectionRecordData } from '../../../data-source';
const MenuGroup = (props) => { const MenuGroup = (props) => {
return props.children; return props.children;
@ -307,7 +314,7 @@ const hideDialog = (dialogClassName: string) => {
dialogWrap.style.opacity = '0'; dialogWrap.style.opacity = '0';
dialogWrap.style.transition = 'opacity 0.5s ease'; dialogWrap.style.transition = 'opacity 0.5s ease';
} }
} };
const showDialog = (dialogClassName: string) => { const showDialog = (dialogClassName: string) => {
const dialogMask = document.querySelector<HTMLElement>(`.${dialogClassName} > .ant-modal-mask`); const dialogMask = document.querySelector<HTMLElement>(`.${dialogClassName} > .ant-modal-mask`);
@ -320,7 +327,7 @@ const showDialog = (dialogClassName: string) => {
dialogWrap.style.opacity = '1'; dialogWrap.style.opacity = '1';
dialogWrap.style.transition = 'opacity 0.5s ease'; dialogWrap.style.transition = 'opacity 0.5s ease';
} }
} };
export const BlocksSelector = (props) => { export const BlocksSelector = (props) => {
const { getAllDataBlocks } = useAllDataBlocks(); const { getAllDataBlocks } = useAllDataBlocks();
@ -330,31 +337,33 @@ export const BlocksSelector = (props) => {
// 转换 allDataBlocks 为 Select 选项 // 转换 allDataBlocks 为 Select 选项
const options = useMemo(() => { const options = useMemo(() => {
return allDataBlocks.map(block => { return allDataBlocks
// 防止列表中出现已关闭的弹窗中的区块 .map((block) => {
if (!block.dom?.isConnected) { // 防止列表中出现已关闭的弹窗中的区块
return null; if (!block.dom?.isConnected) {
} return null;
const title = `${compile(block.collection.title)} #${block.uid.slice(0, 4)}`;
return {
label: title,
value: block.uid,
onMouseEnter() {
block.highlightBlock();
hideDialog('dialog-after-successful-submission');
startScrollEndTracking(block.dom, () => {
highlightBlock(block.dom.cloneNode(true) as HTMLElement, block.dom.getBoundingClientRect());
});
},
onMouseLeave() {
block.unhighlightBlock();
showDialog('dialog-after-successful-submission');
stopScrollEndTracking(block.dom);
unhighlightBlock();
} }
}
}).filter(Boolean); const title = `${compile(block.collection.title)} #${block.uid.slice(0, 4)}`;
return {
label: title,
value: block.uid,
onMouseEnter() {
block.highlightBlock();
hideDialog('dialog-after-successful-submission');
startScrollEndTracking(block.dom, () => {
highlightBlock(block.dom.cloneNode(true) as HTMLElement, block.dom.getBoundingClientRect());
});
},
onMouseLeave() {
block.unhighlightBlock();
showDialog('dialog-after-successful-submission');
stopScrollEndTracking(block.dom);
unhighlightBlock();
},
};
})
.filter(Boolean);
}, [allDataBlocks, t]); }, [allDataBlocks, t]);
return ( return (
@ -367,7 +376,7 @@ export const BlocksSelector = (props) => {
onChange={props.onChange} onChange={props.onChange}
/> />
); );
} };
export function AfterSuccess() { export function AfterSuccess() {
const { dn } = useDesignable(); const { dn } = useDesignable();
@ -380,21 +389,21 @@ export function AfterSuccess() {
return ( return (
<SchemaSettingsModalItem <SchemaSettingsModalItem
dialogRootClassName='dialog-after-successful-submission' dialogRootClassName="dialog-after-successful-submission"
width={700} width={700}
title={t('After successful submission')} title={t('After successful submission')}
initialValues={ initialValues={
onSuccess onSuccess
? { ? {
actionAfterSuccess: onSuccess?.redirecting ? 'redirect' : 'previous', actionAfterSuccess: onSuccess?.redirecting ? 'redirect' : 'previous',
...onSuccess, ...onSuccess,
} }
: { : {
manualClose: false, manualClose: false,
redirecting: false, redirecting: false,
successMessage: '{{t("Saved successfully")}}', successMessage: '{{t("Saved successfully")}}',
actionAfterSuccess: 'previous', actionAfterSuccess: 'previous',
} }
} }
schema={ schema={
{ {
@ -704,6 +713,20 @@ export const actionSettingsItems: SchemaSettingOptions['items'] = [
], ],
}, },
]; ];
const useSecondConFirmVariables = () => {
const fieldSchema = useFieldSchema();
const form = useForm();
const record = useCollectionRecordData();
const scope = useVariableOptions({
collectionField: { uiSchema: fieldSchema },
form,
record,
uiSchema: fieldSchema,
noDisabled: true,
});
return scope;
};
export function SecondConFirm() { export function SecondConFirm() {
const { dn } = useDesignable(); const { dn } = useDesignable();
const fieldSchema = useFieldSchema(); const fieldSchema = useFieldSchema();
@ -738,9 +761,11 @@ export function SecondConFirm() {
}, },
title: { title: {
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
'x-component': 'Input.TextArea', 'x-component': 'Variable.RawTextArea',
title: t('Title'), title: t('Title'),
'x-component-props': {
scope: useSecondConFirmVariables,
},
'x-reactions': { 'x-reactions': {
dependencies: ['enable'], dependencies: ['enable'],
fulfill: { fulfill: {
@ -752,8 +777,11 @@ export function SecondConFirm() {
}, },
content: { content: {
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
'x-component': 'Input.TextArea', 'x-component': 'Variable.RawTextArea',
title: t('Content'), title: t('Content'),
'x-component-props': {
scope: useSecondConFirmVariables,
},
'x-reactions': { 'x-reactions': {
dependencies: ['enable'], dependencies: ['enable'],
fulfill: { fulfill: {

View File

@ -50,6 +50,7 @@ import { ActionContextProps, ActionProps, ComposedAction } from './types';
import { linkageAction, setInitialActionState } from './utils'; import { linkageAction, setInitialActionState } from './utils';
import { NAMESPACE_UI_SCHEMA } from '../../../i18n/constant'; import { NAMESPACE_UI_SCHEMA } from '../../../i18n/constant';
import { BlockContext } from '../../../block-provider/BlockProvider'; import { BlockContext } from '../../../block-provider/BlockProvider';
import { getVariableValue } from '../../../common/getVariableValue';
// 这个要放到最下面,否则会导致前端单测失败 // 这个要放到最下面,否则会导致前端单测失败
import { useApp } from '../../../application'; import { useApp } from '../../../application';
@ -450,18 +451,24 @@ const RenderButton = ({
const { t } = useTranslation(); const { t } = useTranslation();
const { isPopupVisibleControlledByURL } = usePopupSettings(); const { isPopupVisibleControlledByURL } = usePopupSettings();
const { openPopup } = usePopupUtils(); const { openPopup } = usePopupUtils();
const variables = useVariables();
const localVariables = useLocalVariables();
const openPopupRef = useRef(null); const openPopupRef = useRef(null);
openPopupRef.current = openPopup; openPopupRef.current = openPopup;
const scopes = {
variables,
localVariables,
};
const handleButtonClick = useCallback( const handleButtonClick = useCallback(
(e: React.MouseEvent, checkPortal = true) => { async (e: React.MouseEvent, checkPortal = true) => {
if (checkPortal && isPortalInBody(e.target as Element)) { if (checkPortal && isPortalInBody(e.target as Element)) {
return; return;
} }
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
const resultTitle = await getVariableValue(confirm?.title, scopes);
const resultContent = await getVariableValue(confirm?.content, scopes);
if (!disabled && aclCtx) { if (!disabled && aclCtx) {
const onOk = () => { const onOk = () => {
if (onClick) { if (onClick) {
@ -489,8 +496,8 @@ const RenderButton = ({
}; };
if (confirm?.enable !== false && confirm?.content) { if (confirm?.enable !== false && confirm?.content) {
modal.confirm({ modal.confirm({
title: t(confirm.title, { title: confirmTitle || title || field?.title }), title: t(resultTitle, { title: confirmTitle || title || field?.title }),
content: t(confirm.content, { title: confirmTitle || title || field?.title }), content: t(resultContent, { title: confirmTitle || title || field?.title }),
onOk, onOk,
}); });
} else { } else {

View File

@ -46,6 +46,7 @@ export function AccessControl() {
'x-decorator': 'ACLActionProvider', 'x-decorator': 'ACLActionProvider',
}, },
}); });
fieldSchema['x-decorator'] = 'ACLActionProvider';
dn.refresh(); dn.refresh();
} }
}, []); }, []);

View File

@ -20,8 +20,7 @@ import {
useContextVariable, useContextVariable,
useLocalVariables, useLocalVariables,
useVariables, useVariables,
replaceVariables, getVariableValue,
interpolateVariables,
} from '@nocobase/client'; } from '@nocobase/client';
import { isURL } from '@nocobase/utils/client'; import { isURL } from '@nocobase/utils/client';
import { App } from 'antd'; import { App } from 'antd';
@ -84,19 +83,13 @@ export const useCustomizeRequestActionProps = () => {
}, },
responseType: fieldSchema['x-response-type'] === 'stream' ? 'blob' : 'json', responseType: fieldSchema['x-response-type'] === 'stream' ? 'blob' : 'json',
}); });
try { successMessage = await getVariableValue(successMessage, {
const { exp, scope: expScope } = await replaceVariables(successMessage, { variables,
variables, localVariables: [
localVariables: [ ...localVariables,
...localVariables, { name: '$nResponse', ctx: new Proxy({ ...res?.data?.data, ...res?.data }, {}) },
{ name: '$nResponse', ctx: new Proxy({ ...res?.data?.data, ...res?.data }, {}) }, ],
], });
});
successMessage = interpolateVariables(exp, expScope);
} catch (error) {
console.log(error);
}
if (res.headers['content-disposition']) { if (res.headers['content-disposition']) {
const contentDisposition = res.headers['content-disposition']; const contentDisposition = res.headers['content-disposition'];
const utf8Match = contentDisposition.match(/filename\*=utf-8''([^;]+)/i); const utf8Match = contentDisposition.match(/filename\*=utf-8''([^;]+)/i);