feat: support variables on the left side of linkage rule conditions (#6609)

* feat: support linkage rules setting for association block action

* chore: linkage rule

* fix: bug

* fix: bug

* refactor: linkage rule

* refactor: code imporve

* fix: bug

* fix: bug

* fix: bug

* fix: bug

* refactor: code imporve

* fix: test

* fix: test

* fix: test

* test: e2e test

* fix: bug

* fix: bug

* fix: test

* fix: e2e test

* fix: bug

* chore: support legacy leftVar in linkage rules

* fix: bug

* fix: bug

* fix: bug

* fix: bug

* fix: bug

* fix: bug

* fix: bug

* fix: create submit linkage rule

* fix: action panel

* refactor: code improve

* fix: bug

* fix: test

* fix: test

* fix: code improve

* fix: e2e test

* fix: bug

* refactor: refresh & expend action support linkage rule

* refactor: code improve

* fix: test

* fix: bug

* fix: bug

* fix: e2e test

* fix: e2e test

* fix: e2e test

* fix: test

* refactor: code improve

* fix: e2e test

* fix: bug

* fix: e2e test

* fix: e2e test

* fix: test

* fix: e2e test

* fix: e2e test

* fix: bug
This commit is contained in:
Katherine 2025-04-16 15:52:51 +08:00 committed by GitHub
parent cb518dd162
commit 6fef3d7bed
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
71 changed files with 1581 additions and 219 deletions

View File

@ -234,6 +234,10 @@ export default defineConfig({
"title": "Filter",
"link": "/components/filter"
},
{
"title": "LinkageFilter",
"link": "/components/linkage-filter"
},
]
},
{

View File

@ -1582,7 +1582,7 @@ export const getAppends = ({
const fieldNames = getTargetField(item);
// 只应该收集关系字段,只有大于 1 的时候才是关系字段
if (fieldNames.length > 1) {
if (fieldNames.length > 1 && !item.op) {
appends.add(fieldNames.join('.'));
}
});

View File

@ -15,6 +15,8 @@ import { ButtonEditor, RemoveButton } from '../../../schema-component/antd/actio
import { SchemaSettingOpenModeSchemaItems } from '../../../schema-items';
import { SchemaSettingsEnableChildCollections } from '../../../schema-settings/SchemaSettings';
import { useOpenModeContext } from '../../popup/OpenModeProvider';
import { SchemaSettingsLinkageRules } from '../../../schema-settings';
import { useDataBlockProps } from '../../../data-source';
export const addNewActionSettings = new SchemaSettings({
name: 'actionSettings:addNew',
@ -27,6 +29,16 @@ export const addNewActionSettings = new SchemaSettings({
return buttonEditorProps;
},
},
{
name: 'linkageRules',
Component: SchemaSettingsLinkageRules,
useComponentProps() {
const { linkageRulesProps } = useSchemaToolbar();
return {
...linkageRulesProps,
};
},
},
{
name: 'openMode',
Component: SchemaSettingOpenModeSchemaItems,

View File

@ -15,6 +15,7 @@ import {
SecondConFirm,
RefreshDataBlockRequest,
} from '../../../schema-component/antd/action/Action.Designer';
import { SchemaSettingsLinkageRules } from '../../../schema-settings';
export const bulkDeleteActionSettings = new SchemaSettings({
name: 'actionSettings:bulkDelete',
@ -27,6 +28,16 @@ export const bulkDeleteActionSettings = new SchemaSettings({
return buttonEditorProps;
},
},
{
name: 'linkageRules',
Component: SchemaSettingsLinkageRules,
useComponentProps() {
const { linkageRulesProps } = useSchemaToolbar();
return {
...linkageRulesProps,
};
},
},
{
name: 'secondConFirm',
Component: SecondConFirm,

View File

@ -32,11 +32,9 @@ export const disassociateActionSettings = new SchemaSettings({
name: 'linkageRules',
Component: SchemaSettingsLinkageRules,
useComponentProps() {
const { name } = useCollection_deprecated();
const { linkageRulesProps } = useSchemaToolbar();
return {
...linkageRulesProps,
collectionName: name,
};
},
},

View File

@ -14,7 +14,7 @@ import { useDesignable } from '../../..';
import { useSchemaToolbar } from '../../../application';
import { SchemaSettings } from '../../../application/schema-settings/SchemaSettings';
import { RemoveButton } from '../../../schema-component/antd/action/Action.Designer';
import { SchemaSettingsModalItem } from '../../../schema-settings';
import { SchemaSettingsModalItem, SchemaSettingsLinkageRules } from '../../../schema-settings';
function ButtonEditor() {
const field = useField();
@ -110,6 +110,17 @@ export const expendableActionSettings = new SchemaSettings({
return buttonEditorProps;
},
},
{
name: 'linkageRules',
Component: SchemaSettingsLinkageRules,
useComponentProps() {
const { linkageRulesProps } = useSchemaToolbar();
return {
...linkageRulesProps,
};
},
},
{
name: 'remove',
sort: 100,

View File

@ -11,10 +11,9 @@ import { useField, useFieldSchema } from '@formily/react';
import _ from 'lodash';
import React, { FC } from 'react';
import { useTranslation } from 'react-i18next';
import { useCollectionRecord, useDesignable } from '../../../';
import { useDesignable } from '../../../';
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,
@ -22,6 +21,7 @@ import {
SchemaSettingAccessControl,
} from '../../../schema-settings';
import { useURLAndHTMLSchema } from './useURLAndHTMLSchema';
import { useDataBlockProps } from '../../../data-source';
export const SchemaSettingsActionLinkItem: FC = () => {
const field = useField();
@ -94,16 +94,10 @@ export const customizeLinkActionSettings = new SchemaSettings({
{
name: 'linkageRules',
Component: SchemaSettingsLinkageRules,
useVisible() {
const record = useCollectionRecord();
return !_.isEmpty(record?.data);
},
useComponentProps() {
const { name } = useCollection_deprecated();
const { linkageRulesProps } = useSchemaToolbar();
return {
...linkageRulesProps,
collectionName: name,
};
},
},

View File

@ -10,7 +10,7 @@
import { useSchemaToolbar } from '../../../application';
import { SchemaSettings } from '../../../application/schema-settings/SchemaSettings';
import { ButtonEditor, RemoveButton, SecondConFirm } from '../../../schema-component/antd/action/Action.Designer';
import { SchemaSettingsLinkageRules } from '../../../schema-settings';
export const refreshActionSettings = new SchemaSettings({
name: 'actionSettings:refresh',
items: [
@ -22,6 +22,17 @@ export const refreshActionSettings = new SchemaSettings({
return buttonEditorProps;
},
},
{
name: 'linkageRules',
Component: SchemaSettingsLinkageRules,
useComponentProps() {
const { linkageRulesProps } = useSchemaToolbar();
return {
...linkageRulesProps,
};
},
},
{
name: 'secondConFirm',
Component: SecondConFirm,

View File

@ -29,6 +29,7 @@ import { useCollectionState } from '../../../schema-settings/DataTemplates/hooks
import { SchemaSettingsModalItem } from '../../../schema-settings/SchemaSettings';
import { useParentPopupRecord } from '../../variable/variablesProvider/VariablePopupRecordProvider';
import { useDataBlockProps } from '../../../data-source';
import { SchemaSettingsLinkageRules } from '../../../schema-settings';
const Tree = connect(
AntdTree,
@ -149,6 +150,16 @@ export const createSubmitActionSettings = new SchemaSettings({
return buttonEditorProps;
},
},
{
name: 'linkageRules',
Component: SchemaSettingsLinkageRules,
useComponentProps() {
const { linkageRulesProps } = useSchemaToolbar();
return {
...linkageRulesProps,
};
},
},
{
name: 'secondConfirmation',
Component: SecondConFirm,

View File

@ -46,10 +46,6 @@ export const updateSubmitActionSettings = new SchemaSettings({
collectionName: name,
};
},
useVisible() {
const fieldSchema = useFieldSchema();
return !fieldSchema.parent['x-initializer'].includes('bulkEditForm');
},
},
{
name: 'secondConfirmation',

View File

@ -6,17 +6,12 @@
* 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 { useSchemaToolbar } from '../../../application';
import { SchemaSettings } from '../../../application/schema-settings/SchemaSettings';
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, SchemaSettingAccessControl } from '../../../schema-settings';
import { useOpenModeContext } from '../../popup/OpenModeProvider';
import { useCurrentPopupRecord } from '../../variable/variablesProvider/VariablePopupRecordProvider';
export const customizePopupActionSettings = new SchemaSettings({
name: 'actionSettings:popup',
@ -33,18 +28,11 @@ export const customizePopupActionSettings = new SchemaSettings({
name: 'linkageRules',
Component: SchemaSettingsLinkageRules,
useComponentProps() {
const { name } = useCollection_deprecated();
const { linkageRulesProps } = useSchemaToolbar();
return {
...linkageRulesProps,
collectionName: name,
};
},
useVisible() {
const { collection } = useCurrentPopupRecord() || {};
const currentCollection = useCollection();
return !collection || collection?.name === currentCollection?.name;
},
},
{
name: 'openMode',

View File

@ -35,11 +35,16 @@ test.describe('linkage rules', () => {
// 条件singleLineText 字段的值包含 123 时
await page.getByRole('button', { name: 'plus Add linkage rule' }).click();
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').getByRole('tabpanel').getByRole('textbox').click();
await page.getByLabel('Linkage rules').getByRole('tabpanel').getByRole('textbox').fill('123');
await page.getByLabel('variable-button').first().click();
await page.getByText('Current form').last().click();
await page.getByText('Current form').last().click();
await page.getByRole('menuitemcheckbox', { name: 'singleLineText' }).locator('div').click();
// await page.getByRole('menuitemcheckbox', { name: 'singleLineText' }).click();
await page.getByTestId('right-filter-field').getByRole('textbox').click();
await page.getByTestId('right-filter-field').getByRole('textbox').fill('123');
await page.getByRole('tabpanel').getByRole('textbox').last().fill('123');
// action禁用 longText 字段
await page.getByText('Add property').click();
await page.getByTestId('select-linkage-property-field').click();
@ -81,7 +86,7 @@ test.describe('linkage rules', () => {
// 修改第一组规则,使其条件中包含一个变量 --------------------------------------------------------------------------
// 当 singleLineText 字段的值包含 longText 字段的值时,禁用 longText 字段
await openLinkageRules();
await page.getByLabel('variable-button').click();
await page.getByLabel('variable-button').last().click();
await expectSupportedVariables(page, [
'Constant',
'Current user',
@ -136,8 +141,13 @@ test.describe('linkage rules', () => {
.getByText('Add condition', { exact: true })
.last()
.click();
await page.getByRole('button', { name: 'Select field' }).click();
await page.getByRole('menuitemcheckbox', { name: 'number' }).click();
// await page.getByRole('button', { name: 'Select field' }).click();
await page.getByTestId('left-filter-field').getByLabel('variable-button').last().click();
await page.getByText('Current form').last().click();
await page.getByText('Current form').last().click();
await page.getByRole('menuitemcheckbox', { name: 'number' }).locator('div').click();
await page.getByLabel('Linkage rules').getByRole('spinbutton').click();
await page.getByLabel('Linkage rules').getByRole('spinbutton').fill('123');

View File

@ -22,7 +22,7 @@ test.describe('deprecated variables', () => {
await expect(page.getByLabel('variable-tag').getByText('Current record / Nickname')).toBeVisible();
// 2. 但是变量列表中是禁用状态
await page.locator('button').filter({ hasText: /^x$/ }).click();
await page.locator('button').filter({ hasText: /^x$/ }).last().click();
await page.getByRole('menuitemcheckbox', { name: 'Current record right' }).hover({ position: { x: 40, y: 12 } });
await expect(page.getByRole('tooltip', { name: 'This variable has been deprecated' })).toBeVisible();
await expect(page.getByRole('menuitemcheckbox', { name: 'Current record right' })).toHaveClass(
@ -45,11 +45,11 @@ test.describe('deprecated variables', () => {
await page.getByLabel('Linkage rules').getByText('Linkage rules').click();
// 3. 当设置为其它变量后,再次打开,变量列表中的弃用变量不再显示
await page.locator('button').filter({ hasText: /^x$/ }).click();
await page.locator('button').filter({ hasText: /^x$/ }).last().click();
await expect(page.getByRole('menuitemcheckbox', { name: 'Current form right' })).toHaveCount(1);
await page.getByRole('menuitemcheckbox', { name: 'Current form right' }).click();
await page.getByRole('menuitemcheckbox', { name: 'Nickname' }).click();
await expect(page.getByLabel('variable-tag').getByText('Current form / Nickname')).toBeVisible();
await expect(page.getByLabel('variable-tag').getByText('Current form / Nickname').last()).toBeVisible();
// 清空表达式
await page.getByLabel('textbox').clear();
await page.getByRole('button', { name: 'OK', exact: true }).click();
@ -58,7 +58,7 @@ test.describe('deprecated variables', () => {
await page.getByLabel('block-item-CardItem-users-form').hover();
await page.getByLabel('designer-schema-settings-CardItem-blockSettings:editForm-users').hover();
await page.getByRole('menuitem', { name: 'Linkage rules' }).click();
await page.locator('button').filter({ hasText: /^x$/ }).click();
await page.locator('button').filter({ hasText: /^x$/ }).last().click();
await expect(page.getByRole('menuitemcheckbox', { name: 'Current record right' })).toBeHidden();
// 使下拉菜单消失
await page.getByLabel('Linkage rules').getByText('Linkage rules').click();

View File

@ -86,7 +86,6 @@ test.describe('configure fields', () => {
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

@ -42,6 +42,7 @@ test.describe('where grid card 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.getByText('Root')).toBeVisible();
await expect(page.getByText('Admin')).toBeVisible();
await expect(page.getByText('Member')).toBeVisible();

View File

@ -30,7 +30,6 @@ test('action linkage by row data', async ({ page, mockPage }) => {
// 添加其他你需要的样式属性
};
});
expect(adminEditActionStyle.opacity).not.toBe('0.1');
expect(rootEditActionStyle.opacity).not.toBe('1');
});

View File

@ -316,7 +316,8 @@ test.describe('actions schema settings', () => {
// 添加一个条件ID 等于 1
await page.getByText('Add condition', { exact: true }).click();
await page.getByTestId('select-filter-field').click();
await page.getByTestId('left-filter-field').getByLabel('variable-button').click();
await page.getByText('Current record').last().click();
await page.getByRole('menuitemcheckbox', { name: 'ID', exact: true }).click();
await page.getByRole('spinbutton').click();
await page.getByRole('spinbutton').fill('1');
@ -340,7 +341,8 @@ test.describe('actions schema settings', () => {
// 添加一个条件ID 等于 1
await page.getByRole('tabpanel').getByText('Add condition', { exact: true }).last().click();
await page.getByRole('button', { name: 'Select field' }).click();
await page.getByTestId('left-filter-field').getByLabel('variable-button').last().click();
await page.getByText('Current record').last().click();
await page.getByRole('menuitemcheckbox', { name: 'ID', exact: true }).click();
await page.getByRole('spinbutton').click();
await page.getByRole('spinbutton').fill('1');
@ -902,7 +904,6 @@ test.describe('actions schema settings', () => {
await page.getByRole('menuitem', { name: 'Submit' }).click();
await page.mouse.move(300, 0);
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByLabel('designer-schema-settings-CardItem-TableBlockDesigner-treeCollection').hover();
await page.getByRole('menuitem', { name: 'Tree table' }).click();
@ -928,6 +929,7 @@ test.describe('actions schema settings', () => {
await page.getByLabel('schema-initializer-Grid-form:').hover();
await page.getByRole('menuitem', { name: 'Parent', exact: true }).click();
await page.mouse.move(300, 0);
await page.reload();
await expect(
page
.getByLabel('block-item-CollectionField-')

View File

@ -27,7 +27,8 @@ test.describe('options of Select field in linkage rule', () => {
await page.getByRole('switch', { name: 'On Off' }).click();
await page.getByRole('button', { name: 'OK' }).click();
await page.reload();
await expect(page.getByRole('option', { name: 'option2' })).toBeVisible();
await page.getByLabel('block-item-CollectionField-').click();
await expect(page.getByRole('option', { name: 'option2' }).last()).toBeVisible();
await expect(page.getByRole('option', { name: 'option3' })).toBeVisible();
});
});

View File

@ -215,7 +215,7 @@ test.describe('where to open a popup and what can be added to it', () => {
await expect(page.getByLabel('block-item-CardItem-users-')).toBeVisible();
await page.getByLabel('schema-initializer-Grid-popup:common:addBlock-general').hover();
await page.getByRole('menuitem', { name: 'Table right' }).hover();
await page.getByRole('menuitem', { name: 'Table right' }).click();
await expect(page.getByRole('menuitem', { name: 'Associated records' })).toHaveCount(1);
await page.getByRole('menuitem', { name: 'Associated records' }).hover();
await page.getByRole('menuitem', { name: 'One to many' }).click();
@ -282,7 +282,7 @@ test.describe('where to open a popup and what can be added to it', () => {
await expect(page.getByLabel('block-item-CardItem-users-')).toBeVisible();
await page.getByLabel('schema-initializer-Grid-popup:common:addBlock-general').hover();
await page.getByRole('menuitem', { name: 'Table right' }).hover();
await page.getByRole('menuitem', { name: 'Table right' }).click();
await expect(page.getByRole('menuitem', { name: 'Associated records' })).toHaveCount(1);
await page.getByRole('menuitem', { name: 'Associated records' }).hover();
await page.getByRole('menuitem', { name: 'One to many' }).click();

View File

@ -18,7 +18,7 @@ test.describe('variables', () => {
await page.getByLabel('action-Action.Link-View-view-').hover();
await page.getByLabel('designer-schema-settings-Action.Link-actionSettings:view-users').hover();
await page.getByRole('menuitem', { name: 'Linkage rules' }).click();
await page.getByLabel('variable-button').click();
await page.getByTestId('left-filter-field').getByLabel('variable-button').click();
// 2. 断言应该显示的变量
['Constant', 'Current user', 'Current role', 'API token', 'Date variables', 'Current record'].forEach(

View File

@ -22,14 +22,14 @@ test.describe('variable: Current Record', () => {
await page.getByRole('menuitem', { name: 'Linkage rules' }).click();
await page.getByRole('button', { name: 'plus Add linkage rule' }).click();
await page.getByText('Add condition', { exact: true }).click();
await page.getByLabel('variable-button').click();
await page.getByLabel('variable-button').first().click();
// 当前表单中应该包含 “Nickname” 字段
await page.getByRole('menuitemcheckbox', { name: 'Current form right' }).click();
await page.getByRole('menuitemcheckbox', { name: 'Nickname' }).click();
// 当前对象中应该包含 “Role UID” 字段
await page.getByLabel('variable-button').click();
await page.getByLabel('variable-button').first().click();
await page.getByText('Current object').click();
await page.getByRole('menuitemcheckbox', { name: 'Current object right' }).click();
await page.getByRole('menuitemcheckbox', { name: 'Role UID' }).click();
@ -43,12 +43,12 @@ test.describe('variable: Current Record', () => {
await page.getByRole('menuitem', { name: 'Linkage rules' }).click();
await page.getByRole('button', { name: 'plus Add linkage rule' }).click();
await page.getByText('Add condition', { exact: true }).click();
await page.getByLabel('variable-button').click();
await page.getByLabel('variable-button').first().click();
// 当前记录中应该包含 “Nickname” 字段
await page.getByRole('menuitemcheckbox', { name: 'Current record right' }).click();
await page.getByRole('menuitemcheckbox', { name: 'Nickname' }).click();
await page.getByLabel('variable-button').click();
await page.getByLabel('variable-button').first().click();
// 当前对象中应该包含 “Role UID” 字段
await page.getByRole('menuitemcheckbox', { name: 'Current object right' }).click();

View File

@ -49,6 +49,7 @@ import { useGetAriaLabelOfAction } from './hooks/useGetAriaLabelOfAction';
import { ActionContextProps, ActionProps, ComposedAction } from './types';
import { linkageAction, setInitialActionState } from './utils';
import { NAMESPACE_UI_SCHEMA } from '../../../i18n/constant';
import { BlockContext } from '../../../block-provider/BlockProvider';
// 这个要放到最下面,否则会导致前端单测失败
import { useApp } from '../../../application';
@ -96,7 +97,9 @@ export const Action: ComposedAction = withDynamicSchemaProps(
const { designable } = useDesignable();
const tarComponent = useComponent(component) || component;
const variables = useVariables();
const localVariables = useLocalVariables({ currentForm: { values: recordData, readPretty: false } as any });
const localVariables = useLocalVariables({
currentForm: { values: recordData, readPretty: false } as any,
});
const { visibleWithURL, setVisibleWithURL } = usePopupUtils();
const { setSubmitted } = useActionContext();
const { getAriaLabel } = useGetAriaLabelOfAction(title);
@ -120,6 +123,7 @@ export const Action: ComposedAction = withDynamicSchemaProps(
condition: v.condition,
variables,
localVariables,
conditionType: v.conditionType,
},
app.jsonLogic,
);
@ -155,36 +159,38 @@ export const Action: ComposedAction = withDynamicSchemaProps(
}, [onClick, fieldSchema, getAllDataBlocks]);
return (
<InternalAction
containerRefKey={containerRefKey}
fieldSchema={fieldSchema}
designable={designable}
field={field}
icon={icon}
loading={loading}
handleMouseEnter={handleMouseEnter}
tarComponent={tarComponent}
className={className}
type={props.type}
Designer={Designer}
onClick={handleClick}
confirm={confirm}
confirmTitle={confirmTitle}
popover={popover}
addChild={addChild}
recordData={recordData}
title={title}
style={style}
propsDisabled={propsDisabled}
useAction={useAction}
visibleWithURL={visibleWithURL}
setVisibleWithURL={setVisibleWithURL}
setSubmitted={setSubmitted}
getAriaLabel={getAriaLabel}
parentRecordData={parentRecordData}
actionCallback={actionCallback}
{...others}
/>
<BlockContext.Provider value={{ name: 'action' }}>
<InternalAction
containerRefKey={containerRefKey}
fieldSchema={fieldSchema}
designable={designable}
field={field}
icon={icon}
loading={loading}
handleMouseEnter={handleMouseEnter}
tarComponent={tarComponent}
className={className}
type={props.type}
Designer={Designer}
onClick={onClick}
confirm={confirm}
confirmTitle={confirmTitle}
popover={popover}
addChild={addChild}
recordData={recordData}
title={title}
style={style}
propsDisabled={propsDisabled}
useAction={useAction}
visibleWithURL={visibleWithURL}
setVisibleWithURL={setVisibleWithURL}
setSubmitted={setSubmitted}
getAriaLabel={getAriaLabel}
parentRecordData={parentRecordData}
actionCallback={actionCallback}
{...others}
/>
</BlockContext.Provider>
);
}),
{ displayName: 'Action' },

View File

@ -32,7 +32,7 @@ export const useGetAriaLabelOfAction = (title: string) => {
let { name: blockName } = useBlockContext() || {};
const actionTitle = title || compile(fieldSchema.title);
collectionName = collectionName ? `-${collectionName}` : '';
blockName = blockName ? `-${blockName}` : '';
blockName = blockName && blockName !== 'action' ? `-${blockName}` : '';
action = action ? `-${action}` : '';
recordName = recordName ? `-${recordName}` : '';

View File

@ -87,12 +87,14 @@ export const linkageAction = async (
condition,
variables,
localVariables,
conditionType,
}: {
operator;
field;
condition;
variables: VariablesContextType;
localVariables: VariableOption[];
conditionType: 'advanced' | 'basic';
},
jsonLogic: any,
) => {
@ -101,7 +103,7 @@ export const linkageAction = async (
switch (operator) {
case ActionType.Visible:
if (await conditionAnalyses({ ruleGroup: condition, variables, localVariables }, jsonLogic)) {
if (await conditionAnalyses({ ruleGroup: condition, variables, localVariables, conditionType }, jsonLogic)) {
displayResult.push(operator);
field.data = field.data || {};
field.data.hidden = false;
@ -113,7 +115,7 @@ export const linkageAction = async (
field.display = last(displayResult);
break;
case ActionType.Hidden:
if (await conditionAnalyses({ ruleGroup: condition, variables, localVariables }, jsonLogic)) {
if (await conditionAnalyses({ ruleGroup: condition, variables, localVariables, conditionType }, jsonLogic)) {
field.data = field.data || {};
field.data.hidden = true;
} else {
@ -122,7 +124,7 @@ export const linkageAction = async (
}
break;
case ActionType.Disabled:
if (await conditionAnalyses({ ruleGroup: condition, variables, localVariables }, jsonLogic)) {
if (await conditionAnalyses({ ruleGroup: condition, variables, localVariables, conditionType }, jsonLogic)) {
disableResult.push(true);
}
field.stateOfLinkageRules = {
@ -133,7 +135,7 @@ export const linkageAction = async (
field.componentProps['disabled'] = last(disableResult);
break;
case ActionType.Active:
if (await conditionAnalyses({ ruleGroup: condition, variables, localVariables }, jsonLogic)) {
if (await conditionAnalyses({ ruleGroup: condition, variables, localVariables, conditionType }, jsonLogic)) {
disableResult.push(false);
} else {
disableResult.push(!!field.componentProps?.['disabled']);

View File

@ -27,7 +27,7 @@ export const useGetAriaLabelOfBlockItem = (name?: string) => {
let { name: blockName } = useBlockContext() || {};
// eslint-disable-next-line prefer-const
let { name: collectionName, getField } = useCollection_deprecated();
blockName = name || blockName;
blockName = name || (blockName !== 'action' ? blockName : '');
const title = compile(fieldSchema['title']) || compile(getField(fieldSchema.name)?.uiSchema?.title);

View File

@ -54,7 +54,7 @@ describe('CollectionSelect', () => {
role="button"
>
<div
class="css-a7w9kk ant-formily-item ant-formily-item-layout-horizontal ant-formily-item-feedback-layout-loose ant-formily-item-label-align-right ant-formily-item-control-align-left css-dev-only-do-not-override-1rquknz"
class="css-9mlexe ant-formily-item ant-formily-item-layout-horizontal ant-formily-item-feedback-layout-loose ant-formily-item-label-align-right ant-formily-item-control-align-left css-dev-only-do-not-override-1rquknz"
>
<div
class="ant-formily-item-label"
@ -195,7 +195,7 @@ describe('CollectionSelect', () => {
role="button"
>
<div
class="css-a7w9kk ant-formily-item ant-formily-item-layout-horizontal ant-formily-item-feedback-layout-loose ant-formily-item-label-align-right ant-formily-item-control-align-left css-dev-only-do-not-override-1rquknz"
class="css-9mlexe ant-formily-item ant-formily-item-layout-horizontal ant-formily-item-feedback-layout-loose ant-formily-item-label-align-right ant-formily-item-control-align-left css-dev-only-do-not-override-1rquknz"
>
<div
class="ant-formily-item-label"

View File

@ -39,15 +39,13 @@ const formItemWrapCss = css`
.ant-description-textarea img {
max-width: 100%;
}
&.ant-formily-item-layout-horizontal.ant-formily-item-label-wrap {
.ant-formily-item-label {
&.ant-formily-item-layout-vertical .ant-formily-item-label {
display: inline;
.ant-formily-item-label-tooltip-icon {
display: inline;
}
.ant-formily-item-label-content {
display: inline;
padding-right: 5px;
.ant-formily-item-label-tooltip-icon,
.ant-formily-item-label-content {
display: inline;
}
}
}
`;

View File

@ -66,4 +66,5 @@ export * from './unix-timestamp';
export * from './upload';
export * from './variable';
export * from './form-drawer';
export * from './linkageFilter';
import './index.less';

View File

@ -0,0 +1,127 @@
/**
* 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 { createForm, onFieldValueChange } from '@formily/core';
import { FieldContext, FormContext } from '@formily/react';
import { merge } from '@formily/shared';
import React, { useCallback, useContext, useMemo } from 'react';
import { CollectionFieldOptions_deprecated } from '../../../collection-manager';
import { SchemaComponent } from '../../core';
import { FilterContext } from './context';
import { VariableInput, getShouldChange } from '../../../schema-settings/VariableInput/VariableInput';
import { useCollectionRecordData } from '../../../data-source';
import { useLocalVariables, useVariables } from '../../../variables';
import { useCollectionManager_deprecated } from '../../../collection-manager';
export interface DynamicComponentProps {
value: any;
/**
* `Filter`
*/
collectionField: CollectionFieldOptions_deprecated;
onChange: (value: any) => void;
renderSchemaComponent: () => React.JSX.Element;
}
interface Props {
value: any;
collectionField?: CollectionFieldOptions_deprecated;
onChange: (value: any) => void;
style?: React.CSSProperties;
componentProps?: any;
schema?: any;
setScopes?: any;
testid?: string;
nullable?: boolean;
constantAbel?: boolean;
changeOnSelect?: boolean;
readOnly?: boolean;
}
export const DynamicComponent = (props: Props) => {
const { setScopes, nullable, constantAbel, changeOnSelect, readOnly = false } = props;
const { disabled } = useContext(FilterContext) || {};
const record = useCollectionRecordData();
const variables = useVariables();
const localVariables = useLocalVariables();
const { getAllCollectionsInheritChain } = useCollectionManager_deprecated();
const { collectionField } = props;
const component = useCallback((props: DynamicComponentProps) => {
return (
<VariableInput
{...props}
form={form}
record={record}
setScopes={setScopes}
nullable={nullable}
constantAbel={constantAbel}
changeOnSelect={changeOnSelect}
shouldChange={getShouldChange({
collectionField,
variables,
localVariables,
getAllCollectionsInheritChain,
})}
/>
);
}, []);
const form = useMemo(() => {
return createForm({
values: {
value: props.value,
},
effects() {
onFieldValueChange('value', (field) => {
props?.onChange?.(field.value);
});
},
disabled,
});
}, [JSON.stringify(props.value), props.schema]);
const renderSchemaComponent: any = useCallback(() => {
const componentProps = merge(props?.schema?.['x-component-props'] || {}, props.componentProps || {});
return (
<FieldContext.Provider value={null}>
<SchemaComponent
schema={{
'x-component': 'Input',
...props.schema,
'x-component-props': merge(componentProps, {
style: {
minWidth: 150,
...props.style,
},
utc: false,
readOnly: readOnly,
}),
name: 'value',
'x-read-pretty': false,
'x-validator': undefined,
'x-decorator': undefined,
}}
/>
</FieldContext.Provider>
);
}, [props.schema]);
return (
<FormContext.Provider value={form}>
<div data-testid={props.testid}>
{React.createElement<DynamicComponentProps>(component, {
value: props.value,
collectionField: props.collectionField,
onChange: props?.onChange,
renderSchemaComponent,
})}
</div>
</FormContext.Provider>
);
};
export const FilterDynamicComponent = DynamicComponent;

View File

@ -0,0 +1,124 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { CloseCircleOutlined } from '@ant-design/icons';
import { ObjectField as ObjectFieldModel } from '@formily/core';
import { ArrayField, connect, useField } from '@formily/react';
import { Select, Space } from 'antd';
import React, { useContext } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { useToken } from '../__builtins__';
import { FilterItems } from './FilterItems';
import { FilterLogicContext, RemoveConditionContext } from './context';
export const FilterGroup = connect((props) => {
const { bordered = true, disabled } = props;
const field = useField<ObjectFieldModel>();
const remove = useContext(RemoveConditionContext);
const { t } = useTranslation();
const { token } = useToken();
const keys = Object.keys(field.value || {});
const logic = keys.includes('$or') ? '$or' : '$and';
const setLogic = (value) => {
const obj = field.value || {};
field.value = {
[value]: [...(obj[logic] || [])],
};
};
const mergedDisabled = disabled || field.disabled;
return (
<FilterLogicContext.Provider value={logic}>
<div
style={
bordered
? {
position: 'relative',
border: `1px dashed ${token.colorBorder}`,
padding: token.paddingSM,
marginBottom: token.marginXS,
}
: {
position: 'relative',
marginBottom: token.marginXS,
}
}
>
{remove && !mergedDisabled && (
<a role="button" aria-label="icon-close">
<CloseCircleOutlined
style={{
position: 'absolute',
right: 10,
top: 10,
color: '#bfbfbf',
}}
onClick={() => remove()}
/>
</a>
)}
<div style={{ marginBottom: 8, color: token.colorText }}>
<Trans>
{'Meet '}
<Select
// @ts-ignore
role="button"
data-testid="filter-select-all-or-any"
style={{ width: 'auto' }}
value={logic}
onChange={(value) => {
setLogic(value);
}}
>
<Select.Option value={'$and'}>All</Select.Option>
<Select.Option value={'$or'}>Any</Select.Option>
</Select>
{' conditions in the group'}
</Trans>
</div>
<div>
<ArrayField name={`${logic}`} component={[FilterItems]} disabled={mergedDisabled} />
</div>
{!mergedDisabled && (
<Space size={16} style={{ marginTop: 8, marginBottom: 8 }}>
<a
onClick={() => {
const value = field.value || {};
const items = value[logic] || [];
items.push({});
field.value = {
[logic]: items,
};
field.initialValue = {
[logic]: items,
};
}}
>
{t('Add condition')}
</a>
<a
onClick={() => {
const value = field.value || {};
const items = value[logic] || [];
items.push({
$and: [{}],
});
field.value = {
[logic]: items,
};
}}
>
{t('Add condition group')}
</a>
</Space>
)}
</div>
</FilterLogicContext.Provider>
);
});

View File

@ -0,0 +1,33 @@
/**
* 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 { ArrayField as ArrayFieldModel } from '@formily/core';
import { ObjectField, observer, useField } from '@formily/react';
import React from 'react';
import { FilterGroup } from './FilterGroup';
import { LinkageFilterItem } from './LinkageFilterItem';
import { RemoveConditionContext } from './context';
export const FilterItems = observer(
(props) => {
const field = useField<ArrayFieldModel>();
return (
<div>
{field?.value?.filter(Boolean).map((item, index) => {
return (
<RemoveConditionContext.Provider key={index} value={() => field.remove(index)}>
<ObjectField name={index} component={[item.$and || item.$or ? FilterGroup : LinkageFilterItem]} />
</RemoveConditionContext.Provider>
);
})}
</div>
);
},
{ displayName: 'FilterItems' },
);

View File

@ -0,0 +1,65 @@
/**
* 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 { ObjectField as ObjectFieldModel } from '@formily/core';
import { observer, useField, useFieldSchema } from '@formily/react';
import React, { useEffect, useState } from 'react';
import { UseRequestOptions, useRequest } from '../../../api-client';
import { withDynamicSchemaProps } from '../../../hoc/withDynamicSchemaProps';
import { useProps } from '../../hooks/useProps';
import { FilterGroup } from './FilterGroup';
import { FilterContext } from './context';
const useDef = (options: UseRequestOptions) => {
const field = useField<ObjectFieldModel>();
return useRequest(() => Promise.resolve({ data: field.dataSource }), options);
};
export const LinkageFilter: any = withDynamicSchemaProps(
observer((props: any) => {
const { useDataSource = useDef } = props;
// 新版 UISchema1.0 之后)中已经废弃了 useProps这里之所以继续保留是为了兼容旧版的 UISchema
const { dynamicComponent, className, collectionName } = useProps(props);
const [scopes, setScopes] = useState([]);
const field = useField<ObjectFieldModel>();
const fieldSchema: any = useFieldSchema();
useDataSource({
onSuccess(data) {
field.dataSource = data?.data || [];
},
});
useEffect(() => {
if (fieldSchema.defaultValue) {
field.initialValue = fieldSchema.defaultValue;
}
}, [fieldSchema.defaultValue]);
return (
<div className={className}>
<FilterContext.Provider
value={{
field,
fieldSchema,
dynamicComponent,
disabled: props.disabled,
collectionName,
scopes,
setScopes,
}}
>
<FilterGroup {...props} bordered={false} />
</FilterContext.Provider>
</div>
);
}),
{ displayName: 'LinkageFilter' },
);

View File

@ -0,0 +1,78 @@
/**
* 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 { CloseCircleOutlined } from '@ant-design/icons';
import { css } from '@emotion/css';
import { observer } from '@formily/react';
import { Select, Space } from 'antd';
import React, { useCallback, useContext, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useCompile } from '../../hooks';
import { DynamicComponent } from './DynamicComponent';
import { RemoveConditionContext } from './context';
import { useValues } from './useValues';
import { FilterContext } from './context';
export const LinkageFilterItem = observer(
(props: any) => {
const { t } = useTranslation();
const compile = useCompile();
const remove = useContext(RemoveConditionContext);
const { setScopes } = useContext(FilterContext) || {};
const { schema, operators, operator, setOperator, rightVar, leftVar, setLeftValue, setRightValue } = useValues();
const style = useMemo(() => ({ marginBottom: 8 }), []);
const onOperatorsChange = useCallback(
(value) => {
setOperator(value);
},
[setOperator],
);
const removeStyle = useMemo(() => ({ color: '#bfbfbf' }), []);
return (
// 添加 nc-filter-item 类名是为了帮助编写测试时更容易选中该元素
<div style={style} className="nc-filter-item">
<Space wrap>
<DynamicComponent
value={leftVar}
onChange={setLeftValue}
setScopes={setScopes}
testid="left-filter-field"
nullable={false}
constantAbel={false}
changeOnSelect={false}
readOnly={true}
/>
<Select
// @ts-ignore
role="button"
data-testid="select-filter-operator"
className={css`
min-width: 110px;
`}
popupMatchSelectWidth={false}
value={operator?.value}
options={compile(operators)}
onChange={onOperatorsChange}
placeholder={t('Comparision')}
/>
{!operator?.noValue ? (
<DynamicComponent value={rightVar} schema={schema} onChange={setRightValue} testid="right-filter-field" />
) : null}
{!props.disabled && (
<a role="button" aria-label="icon-close">
<CloseCircleOutlined onClick={remove} style={removeStyle} />
</a>
)}
</Space>
</div>
);
},
{ displayName: 'FilterItem' },
);

View File

@ -0,0 +1,30 @@
/**
* 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 { ObjectField } from '@formily/core';
import { Schema } from '@formily/react';
import { ComponentType, createContext } from 'react';
import { DynamicComponentProps } from './DynamicComponent';
export interface FilterContextProps {
field?: ObjectField & { collectionName?: string };
fieldSchema?: Schema;
dynamicComponent?: ComponentType<DynamicComponentProps>;
disabled?: boolean;
collectionName?: string;
scopes?: any[];
setScopes?: any;
}
export const RemoveConditionContext = createContext(null);
RemoveConditionContext.displayName = 'RemoveConditionContext';
export const FilterContext = createContext<FilterContextProps>(null);
FilterContext.displayName = 'FilterContext';
export const FilterLogicContext = createContext(null);
FilterLogicContext.displayName = 'FilterLogicContext';

View File

@ -0,0 +1,148 @@
import React, { useMemo } from 'react';
import { useField, observer, ISchema } from '@formily/react';
import { FilterActionProps, useRequest, SchemaComponent, Plugin } from '@nocobase/client';
import { ArrayCollapse, FormLayout } from '@formily/antd-v5';
import { css } from '@emotion/css';
import { mockApp } from '@nocobase/client/demo-utils';
const ShowFilterData = observer(({ children }) => {
const field = useField<any>();
return (
<>
<pre>{JSON.stringify(field.value, null, 2)}</pre>
{children}
</>
);
});
const useFilterActionProps = (): FilterActionProps => {
const field = useField<any>();
const { run } = useRequest({ url: 'test' }, { manual: true });
return {
onSubmit: async (values) => {
console.log('onSubmit', values);
// request api
run(values);
field.setValue(values);
},
onReset: (values) => {
console.log('onReset', values);
},
};
};
const schema = {
type: 'object',
'x-decorator': 'FormV2',
'x-component': 'ShowFormData',
properties: {
rules: {
type: 'array',
default: [{}],
'x-component': 'ArrayCollapse',
'x-decorator': 'FormItem',
'x-component-props': {
accordion: true,
},
items: {
type: 'object',
'x-component': 'ArrayCollapse.CollapsePanel',
'x-component-props': {
// extra: 'linkage rule',
},
properties: {
layout: {
type: 'void',
'x-component': 'FormLayout',
'x-component-props': {
labelStyle: {
marginTop: '6px',
},
labelCol: 8,
wrapperCol: 16,
},
properties: {
conditions: {
'x-component': 'h4',
'x-content': '{{ t("Condition") }}',
},
condition: {
'x-component': 'LinkageFilter',
'x-use-component-props': () => {
return {
// options,
className: css`
position: relative;
width: 100%;
margin-left: 10px;
`,
};
},
},
},
},
remove: {
type: 'void',
'x-component': 'ArrayCollapse.Remove',
},
moveUp: {
type: 'void',
'x-component': 'ArrayCollapse.MoveUp',
},
moveDown: {
type: 'void',
'x-component': 'ArrayCollapse.MoveDown',
},
copy: {
type: 'void',
'x-component': 'ArrayCollapse.Copy',
},
},
},
properties: {
add: {
type: 'void',
title: '{{ t("Add linkage rule") }}',
'x-component': 'ArrayCollapse.Addition',
'x-reactions': {
dependencies: ['rules'],
fulfill: {
state: {
// disabled: '{{$deps[0].length >= 3}}',
},
},
},
},
},
},
},
};
const Demo = () => {
return (
<SchemaComponent
schema={schema}
components={{ ShowFilterData, ArrayCollapse, FormLayout }}
scope={{ useFilterActionProps }}
/>
);
};
class DemoPlugin extends Plugin {
async load() {
this.app.router.add('root', { path: '/', Component: Demo });
}
}
const app = mockApp({
plugins: [DemoPlugin],
apis: {
test: { data: { data: 'ok' } },
},
});
export default app.getRootComponent();

View File

@ -0,0 +1,81 @@
import React from 'react';
import { useField, observer } from '@formily/react';
import { FilterActionProps, ISchema, useDataBlockRequest } from '@nocobase/client';
import { mockApp } from '@nocobase/client/demo-utils';
import { SchemaComponent, Plugin } from '@nocobase/client';
const ShowFilterData = observer(({ children }) => {
const field = useField<any>();
return (
<>
<pre>{JSON.stringify(field.value, null, 2)}</pre>
{children}
</>
);
});
const useFilterActionProps = (): FilterActionProps => {
const field = useField<any>();
const { run } = useDataBlockRequest(); // replace `useRequest`
return {
onSubmit: async (values) => {
console.log('onSubmit', values);
// request api
run(values);
field.setValue(values);
},
onReset: (values) => {
console.log('onReset', values);
},
};
};
const schema: ISchema = {
type: 'void',
name: 'root',
'x-decorator': 'DataBlockProvider',
'x-decorator-props': {
collection: 'users',
action: 'list',
},
properties: {
test: {
name: 'filter',
type: 'object',
title: 'Filter',
'x-decorator': 'ShowFilterData',
'x-component': 'Filter.Action',
'x-use-component-props': 'useFilterActionProps',
},
},
};
const Demo = () => {
return (
<SchemaComponent
schema={schema}
components={{ ShowFilterData }}
scope={{
useFilterActionProps,
}}
/>
);
};
class DemoPlugin extends Plugin {
async load() {
this.app.router.add('root', { path: '/', Component: Demo });
}
}
const app = mockApp({
plugins: [DemoPlugin],
apis: {
test: { data: { data: 'ok' } },
},
});
export default app.getRootComponent();

View File

@ -0,0 +1,17 @@
# LinkageFilter
A component used for filtering data, commonly used to filter data in blocks.
```ts
type FilterActionProps<T = {}> = ActionProps & {
options?: any[];
form?: Form;
onSubmit?: (values: T) => void;
onReset?: (values: T) => void;
}
```
### Basic Usage
<code src="./demos/new-demos/basic.tsx"></code>

View File

@ -0,0 +1,18 @@
# LinkageFilter
用于前端联动规则中,用作条件配置
```ts
type FilterActionProps<T = {}> = ActionProps & {
options?: any[];
form?: Form;
onSubmit?: (values: T) => void;
onReset?: (values: T) => void;
}
```
### Basic Usage
左侧支持变量,操作符、和右侧变量组件跟随左侧变量联动
<code src="./demos/new-demos/basic.tsx"></code>

View File

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

View File

@ -0,0 +1,34 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { useFieldSchema } from '@formily/react';
import { useCollection_deprecated, useCollectionManager_deprecated } from '../../../collection-manager';
import { useMemo } from 'react';
/**
*
* @returns
*/
export const useOperatorList = (): any[] => {
const schema = useFieldSchema();
const { name } = useCollection_deprecated();
const { getCollectionFields, getInterface } = useCollectionManager_deprecated();
const res = useMemo(() => {
const fieldInterface = schema['x-designer-props']?.interface;
const collectionFields = getCollectionFields(name);
if (fieldInterface) {
return getInterface(fieldInterface)?.filterable?.operators || [];
}
const field = collectionFields.find((item) => item.name === schema.name);
const ops = getInterface(field?.interface)?.filterable?.operators || [];
return ops.filter((o) => typeof o.visible !== 'function' || o.visible(field));
}, [schema.name]);
return res;
};

View File

@ -0,0 +1,141 @@
/**
* 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 { useField } from '@formily/react';
import { merge } from '@formily/shared';
import { cloneDeep, last, uniqBy } from 'lodash';
import { useCallback, useContext, useEffect } from 'react';
import { FilterContext } from './context';
interface UseValuesReturn {
fields: any[];
collectionField: any;
dataIndex: string[];
operators: any[];
operator: any;
schema: any;
value: any;
setDataIndex: (dataIndex: string[]) => void;
setOperator: (operatorValue: string) => void;
setRightValue: (value: any) => void;
setLeftValue: (value: any) => void;
leftVar: any;
rightVar: any;
}
const findOption = (str, options) => {
if (!str) return null;
const match = str.match(/\{\{\$(.*?)\}\}/);
if (!match) return null;
const [firstKey, ...subKeys] = match[1].split('.'); // 拆分层级
const keys = [`$${firstKey}`, ...subKeys]; // 第一层保留 `$`,后续不带 `$`
let currentOptions = options;
let option = null;
for (const key of keys) {
option = currentOptions.find((opt) => opt.value === key);
if (!option) return null;
// 进入下一层 children 查找
if (Array.isArray(option.children) || option.isLeaf === false) {
currentOptions = option.children;
} else {
return option; // 没有 children 直接返回
}
}
return option;
};
const operators = [
{ label: '{{t("is empty")}}', value: '$empty', noValue: true },
{ label: '{{t("is not empty")}}', value: '$notEmpty', noValue: true },
];
export const useValues = (): UseValuesReturn => {
const field = useField<any>();
const { scopes } = useContext(FilterContext) || {};
const { op, leftVar, rightVar } = field.value || {};
const data2value = useCallback(() => {
field.value = field.data.leftVar
? {
op: field.data.operator?.value,
leftVar: field.data.leftVar,
rightVar: field.data?.rightVar,
}
: {};
}, [field]);
const value2data = () => {
/**
* scopes
*/
setTimeout(() => {
const option = findOption(leftVar, scopes);
field.data = field.data || {};
if (!field.value) {
return;
}
const combOperators = uniqBy([...(field.data.operators || []), ...(option?.operators || [])], 'value');
field.data.operators = combOperators.length ? combOperators : operators;
field.data.leftVar = leftVar;
field.data.rightVar = rightVar;
const operator = combOperators?.find((v) => v.value === op);
field.data.operator = field.data.operator || operator;
const s1 = cloneDeep(option?.schema);
const s2 = cloneDeep(operator?.schema);
field.data.schema = field.data?.schema || merge(s1, s2);
}, 100);
};
useEffect(value2data, [field.value, scopes]);
const setLeftValue = useCallback(
(leftVar, paths) => {
const option: any = last(paths);
field.data = field.data || {};
field.data.operators = option?.operators || operators;
const operator = field.data.operators?.[0];
field.data.operator = operator;
const s1 = cloneDeep(option?.schema);
const s2 = cloneDeep(operator?.schema);
field.data.schema = merge(s1, s2);
field.data.leftVar = leftVar;
field.data.rightVar = operator?.noValue ? operator.default || true : undefined;
data2value();
},
[data2value, field],
);
const setOperator = useCallback(
(operatorValue) => {
const operator = field.data?.operators?.find?.((item) => item.value === operatorValue);
field.data.operator = operator;
const s1 = cloneDeep(field.data.schema);
const s2 = cloneDeep(operator?.schema);
field.data.schema = merge(s1, s2);
field.data.value = operator.noValue ? operator.default || true : undefined;
data2value();
},
[data2value, field.data],
);
const setRightValue = useCallback(
(rightVar) => {
field.data.rightVar = rightVar;
data2value();
},
[data2value, field.data],
);
return {
...(field?.data || {}),
setLeftValue,
setOperator,
setRightValue,
};
};

View File

@ -18,6 +18,8 @@ import {
useDesigner,
useFlag,
useSchemaComponentContext,
BlockContext,
useBlockContext,
} from '../../../';
import { useToken } from '../__builtins__';
import { designerCss } from './Table.Column.ActionBar';
@ -77,6 +79,7 @@ export const TableColumnDecorator = (props) => {
const compile = useCompile();
const { isInSubTable } = useFlag() || {};
const { token } = useToken();
const { name } = useBlockContext?.() || {};
useEffect(() => {
if (field.title) {
@ -110,11 +113,13 @@ export const TableColumnDecorator = (props) => {
})}
>
<CollectionFieldContext.Provider value={collectionField}>
<Designer fieldSchema={fieldSchema} uiSchema={uiSchema} collectionField={collectionField} />
<span role="button">
{fieldSchema?.required && <span className="ant-formily-item-asterisk">*</span>}
<span>{field?.title || compile(uiSchema?.title)}</span>
</span>
<BlockContext.Provider value={{ name: isInSubTable ? name : 'taleColumn' }}>
<Designer fieldSchema={fieldSchema} uiSchema={uiSchema} collectionField={collectionField} />
<span role="button">
{fieldSchema?.required && <span className="ant-formily-item-asterisk">*</span>}
<span>{field?.title || compile(uiSchema?.title)}</span>
</span>
</BlockContext.Provider>
</CollectionFieldContext.Provider>
</SortableItem>
);

View File

@ -186,6 +186,7 @@ export type VariableInputProps = {
className?: string;
parseOptions?: ParseOptions;
hideVariableButton?: boolean;
constantAbel?: boolean;
};
export function Input(props: VariableInputProps) {
@ -202,6 +203,7 @@ export function Input(props: VariableInputProps) {
fieldNames,
parseOptions,
hideVariableButton,
constantAbel = true,
} = props;
const scope = typeof props.scope === 'function' ? props.scope() : props.scope;
const { wrapSSR, hashId, componentCls, rootPrefixCls } = useStyles({ hideVariableButton });
@ -233,6 +235,7 @@ export function Input(props: VariableInputProps) {
);
const constantOption: DefaultOptionType & { component?: React.FC<any> } = useMemo(() => {
if (!constantAbel) return null;
if (children) {
return {
value: '$',

View File

@ -57,6 +57,7 @@ export const getTargetField = (obj) => {
}
});
const result = keys.slice(0, index);
return result;
};
@ -76,72 +77,44 @@ function getAllKeys(obj) {
return keys;
}
const parseVariableValue = async (targetVariable, variables, localVariables) => {
const parsingResult = isVariable(targetVariable)
? [variables.parseVariable(targetVariable, localVariables).then(({ value }) => value)]
: [targetVariable];
try {
const [value] = await Promise.all(parsingResult);
return value;
} catch (error) {
console.error('Error in parseVariableValue:', error);
throw error;
}
};
export const conditionAnalyses = async (
{
ruleGroup,
variables,
localVariables,
variableNameOfLeftCondition,
conditionType,
}: {
ruleGroup;
variables: VariablesContextType;
localVariables: VariableOption[];
/**
* used to parse the variable name of the left condition value
* @default '$nForm'
*/
variableNameOfLeftCondition?: string;
conditionType?: 'advanced' | 'basic';
},
jsonLogic: any,
) => {
const type = Object.keys(ruleGroup)[0] || '$and';
const conditions = ruleGroup[type];
let results = conditions.map(async (condition) => {
if ('$and' in condition || '$or' in condition) {
return await conditionAnalyses({ ruleGroup: condition, variables, localVariables }, jsonLogic);
}
const logicCalculation = getInnermostKeyAndValue(condition);
const operator = logicCalculation?.key;
if (!operator) {
return true;
}
const targetVariableName = targetFieldToVariableString(getTargetField(condition), variableNameOfLeftCondition);
const targetValue = variables
.parseVariable(targetVariableName, localVariables, {
doNotRequest: true,
})
.then(({ value }) => value);
const parsingResult = isVariable(logicCalculation?.value)
? [variables.parseVariable(logicCalculation?.value, localVariables).then(({ value }) => value), targetValue]
: [logicCalculation?.value, targetValue];
try {
const [value, targetValue] = await Promise.all(parsingResult);
const targetCollectionField = await variables.getCollectionField(targetVariableName, localVariables);
let currentInputValue = transformVariableValue(targetValue, { targetCollectionField });
const comparisonValue = transformVariableValue(value, { targetCollectionField });
if (
targetCollectionField?.type &&
['datetime', 'date', 'datetimeNoTz', 'dateOnly', 'unixTimestamp'].includes(targetCollectionField.type) &&
currentInputValue
) {
const picker = inferPickerType(comparisonValue);
const format = getPickerFormat(picker);
currentInputValue = dayjs(currentInputValue).format(format);
}
return jsonLogic.apply({
[operator]: [currentInputValue, comparisonValue],
});
} catch (error) {
throw error;
}
});
results = await Promise.all(results);
const results = await Promise.all(
conditions.map((condition) =>
processCondition(condition, variables, localVariables, variableNameOfLeftCondition, conditionType, jsonLogic),
),
);
if (type === '$and') {
return every(results, (v) => v);
@ -153,6 +126,67 @@ export const conditionAnalyses = async (
}
};
const processCondition = async (
condition,
variables,
localVariables,
variableNameOfLeftCondition,
conditionType,
jsonLogic,
) => {
if ('$and' in condition || '$or' in condition) {
return await conditionAnalyses({ ruleGroup: condition, variables, localVariables, conditionType }, jsonLogic);
}
return conditionType === 'advanced'
? processAdvancedCondition(condition, variables, localVariables, jsonLogic)
: processBasicCondition(condition, variables, localVariables, variableNameOfLeftCondition, jsonLogic);
};
const processAdvancedCondition = async (condition, variables, localVariables, jsonLogic) => {
const operator = condition.op;
const rightValue = await parseVariableValue(condition.rightVar, variables, localVariables);
const leftValue = await parseVariableValue(condition.leftVar, variables, localVariables);
if (operator) {
return jsonLogic.apply({ [operator]: [leftValue, rightValue] });
}
return true;
};
const processBasicCondition = async (condition, variables, localVariables, variableNameOfLeftCondition, jsonLogic) => {
const logicCalculation = getInnermostKeyAndValue(condition);
const operator = logicCalculation?.key;
if (!operator) return true;
const targetVariableName = targetFieldToVariableString(getTargetField(condition), variableNameOfLeftCondition);
const targetValue = variables
.parseVariable(targetVariableName, localVariables, { doNotRequest: true })
.then(({ value }) => value);
const parsingResult = isVariable(logicCalculation?.value)
? [variables.parseVariable(logicCalculation?.value, localVariables).then(({ value }) => value), targetValue]
: [logicCalculation?.value, targetValue];
try {
const [value, resolvedTargetValue] = await Promise.all(parsingResult);
const targetCollectionField = await variables.getCollectionField(targetVariableName, localVariables);
let currentInputValue = transformVariableValue(resolvedTargetValue, { targetCollectionField });
const comparisonValue = transformVariableValue(value, { targetCollectionField });
if (
targetCollectionField?.type &&
['datetime', 'date', 'datetimeNoTz', 'dateOnly', 'unixTimestamp'].includes(targetCollectionField.type) &&
currentInputValue
) {
const picker = inferPickerType(comparisonValue);
const format = getPickerFormat(picker);
currentInputValue = dayjs(currentInputValue).format(format);
}
return jsonLogic.apply({ [operator]: [currentInputValue, comparisonValue] });
} catch (error) {
throw error;
}
};
/**
* 便
* @param targetField

View File

@ -89,6 +89,7 @@ const InternalCreateRecordAction = (props: any, ref) => {
condition: v.condition,
variables,
localVariables,
conditionType: v.conditionType,
},
app.jsonLogic,
);
@ -208,6 +209,7 @@ export const CreateAction = observer(
condition: v.condition,
variables,
localVariables,
conditionType: v.conditionType,
},
app.jsonLogic,
);

View File

@ -26,7 +26,7 @@ import { VariableInput, getShouldChange } from '../../../schema-settings/Variabl
import { Option } from '../../../schema-settings/VariableInput/type';
import { formatVariableScop } from '../../../schema-settings/VariableInput/utils/formatVariableScop';
import { useLocalVariables, useVariables } from '../../../variables';
import { BlockContext, useBlockContext } from '../../../block-provider';
interface AssignedFieldProps {
value: any;
onChange: (value: any) => void;
@ -93,7 +93,7 @@ export enum AssignedFieldValueType {
DynamicValue = 'dynamicValue',
}
export const AssignedField = (props: AssignedFieldProps) => {
export const AssignedFieldInner = (props: AssignedFieldProps) => {
const { value, onChange } = props;
const { getCollectionFields, getAllCollectionsInheritChain } = useCollectionManager_deprecated();
const collection = useCollection_deprecated();
@ -148,3 +148,13 @@ export const AssignedField = (props: AssignedFieldProps) => {
/>
);
};
export const AssignedField = (props) => {
const { form } = useFormBlockContext();
const { name } = useBlockContext();
return (
<BlockContext.Provider value={{ name: form ? 'form' : name }}>
<AssignedFieldInner {...props} />
</BlockContext.Provider>
);
};

View File

@ -38,6 +38,7 @@ interface Props {
*/
variableNameOfLeftCondition?: string;
action?: any;
conditionType?: 'advanced' | 'basic';
}
export function bindLinkageRulesToFiled(
@ -83,7 +84,6 @@ export function bindLinkageRulesToFiled(
() => {
// 获取条件中的字段值
const fieldValuesInCondition = getFieldValuesInCondition({ linkageRules, formValues });
// 获取条件中的变量值
const variableValuesInCondition = getVariableValuesInCondition({ linkageRules, localVariables });
@ -132,20 +132,37 @@ function getVariableValuesInCondition({
return linkageRules.map((rule) => {
const type = Object.keys(rule.condition)[0] || '$and';
const conditions = rule.condition[type];
if (rule.conditionType === 'advanced') {
return conditions
.map((condition) => {
if (!condition) {
return null;
}
return conditions
.map((condition) => {
const jsonlogic = getInnermostKeyAndValue(condition);
if (!jsonlogic) {
return null;
}
if (isVariable(jsonlogic.value)) {
return getVariableValue(jsonlogic.value, localVariables);
}
const resolveVariable = (varName) =>
isVariable(varName) ? getVariableValue(varName, localVariables) : varName;
return jsonlogic.value;
})
.filter(Boolean);
return {
leftVar: resolveVariable(condition.leftVar),
rightVar: resolveVariable(condition.rightVar),
};
})
.filter(Boolean);
} else {
return conditions
.map((condition) => {
const jsonlogic = getInnermostKeyAndValue(condition);
if (!jsonlogic) {
return null;
}
if (isVariable(jsonlogic.value)) {
return getVariableValue(jsonlogic.value, localVariables);
}
return jsonlogic.value;
})
.filter(Boolean);
}
});
}
@ -216,6 +233,7 @@ function getSubscriber(
localVariables,
variableNameOfLeftCondition,
action,
conditionType: rule.conditionType,
},
jsonLogic,
);
@ -327,7 +345,17 @@ function getFieldNameByOperator(operator: ActionType) {
}
export const collectFieldStateOfLinkageRules = (
{ operator, value, field, condition, variables, localVariables, variableNameOfLeftCondition, action }: Props,
{
operator,
value,
field,
condition,
variables,
localVariables,
variableNameOfLeftCondition,
action,
conditionType,
}: Props,
jsonLogic: any,
) => {
const requiredResult = field?.stateOfLinkageRules?.required || [field?.initStateOfLinkageRules?.required];
@ -336,7 +364,13 @@ export const collectFieldStateOfLinkageRules = (
const valueResult = field?.stateOfLinkageRules?.value || [field?.initStateOfLinkageRules?.value];
const optionsResult = field?.stateOfLinkageRules?.dataSource || [field?.initStateOfLinkageRules?.dataSource];
const { evaluate } = evaluators.get('formula.js');
const paramsToGetConditionResult = { ruleGroup: condition, variables, localVariables, variableNameOfLeftCondition };
const paramsToGetConditionResult = {
ruleGroup: condition,
variables,
localVariables,
variableNameOfLeftCondition,
conditionType,
};
const dateScopeResult = field?.stateOfLinkageRules?.dateScope || [field?.initStateOfLinkageRules?.dateScope];
switch (operator) {

View File

@ -38,7 +38,12 @@ const getSatisfiedActions = async ({ rules, variables, localVariables }, jsonLog
rules
.filter((k) => !k.disabled)
.map(async (rule) => {
if (await conditionAnalyses({ ruleGroup: rule.condition, variables, localVariables }, jsonLogic)) {
if (
await conditionAnalyses(
{ ruleGroup: rule.condition, variables, localVariables, conditionType: rule.conditionType },
jsonLogic,
)
) {
return rule;
} else return null;
}),

View File

@ -10,7 +10,7 @@
import { css } from '@emotion/css';
import { observer, useFieldSchema } from '@formily/react';
import React, { useMemo } from 'react';
import { useCollectionManager_deprecated } from '../../collection-manager';
import { useCollectionManager_deprecated, useCollection_deprecated } from '../../collection-manager';
import { useCollectionParentRecordData } from '../../data-source/collection-record/CollectionRecordProvider';
import { CollectionProvider } from '../../data-source/collection/CollectionProvider';
import { withDynamicSchemaProps } from '../../hoc/withDynamicSchemaProps';
@ -27,15 +27,75 @@ import { ArrayCollapse } from './components/LinkageHeader';
export interface Props {
dynamicComponent: any;
}
function extractFieldPath(obj, path = []) {
if (typeof obj !== 'object' || obj === null) return null;
const [key, value] = Object.entries(obj)[0] || [];
if (typeof value === 'object' && value !== null && !key.startsWith('$')) {
return extractFieldPath(value, [...path, key]);
}
return [path.join('.'), obj];
}
type Condition = { [field: string]: { [op: string]: any } } | { $and: Condition[] } | { $or: Condition[] };
type TransformedCondition =
| { leftVar: string; op: string; rightVar: any }
| { $and: TransformedCondition[] }
| { $or: TransformedCondition[] };
function transformConditionData(condition: Condition, variableKey: '$nForm' | '$nRecord'): TransformedCondition {
if ('$and' in condition) {
return {
$and: condition.$and.map((c) => transformConditionData(c, variableKey)),
};
}
if ('$or' in condition) {
return {
$or: condition.$or.map((c) => transformConditionData(c, variableKey)),
};
}
const [field, expression] = extractFieldPath(condition || {}) || [];
const [op, value] = Object.entries(expression || {})[0] || [];
return {
leftVar: field ? `{{${variableKey}.${field}}}` : null,
op,
rightVar: value,
};
}
function getActiveContextName(contextList: { name: string; ctx: any }[]): string | null {
const priority = ['$nForm', '$nRecord'];
for (const name of priority) {
const item = contextList.find((ctx) => ctx.name === name && ctx.ctx);
if (item) return name;
}
return '$nRecord';
}
const transformDefaultValue = (values, variableKey) => {
return values.map((v) => {
if (v.conditionType !== 'advanced') {
const condition = transformConditionData(v.condition, variableKey);
return {
...v,
condition: variableKey ? condition : v.condition,
conditionType: variableKey ? 'advanced' : 'basic',
};
}
return v;
});
};
export const FormLinkageRules = withDynamicSchemaProps(
observer((props: Props) => {
const fieldSchema = useFieldSchema();
const { options, defaultValues, collectionName, form, variables, localVariables, record, dynamicComponent } =
useProps(props); // 新版 UISchema1.0 之后)中已经废弃了 useProps这里之所以继续保留是为了兼容旧版的 UISchema
const { name } = useCollection_deprecated();
const { getAllCollectionsInheritChain } = useCollectionManager_deprecated();
const parentRecordData = useCollectionParentRecordData();
const variableKey = getActiveContextName(localVariables);
const components = useMemo(() => ({ ArrayCollapse }), []);
const schema = useMemo(
() => ({
@ -43,7 +103,7 @@ export const FormLinkageRules = withDynamicSchemaProps(
properties: {
rules: {
type: 'array',
default: defaultValues,
default: transformDefaultValue(defaultValues, variableKey),
'x-component': 'ArrayCollapse',
'x-decorator': 'FormItem',
'x-component-props': {
@ -72,6 +132,20 @@ export const FormLinkageRules = withDynamicSchemaProps(
'x-content': '{{ t("Condition") }}',
},
condition: {
'x-component': 'Input', // 仅作为数据存储
'x-hidden': true, // 不显示
'x-reactions': [
{
dependencies: ['.conditionType', '.conditionBasic', '.conditionAdvanced'],
fulfill: {
state: {
value: '{{$deps[0] === "basic" ? $deps[1] : $deps[2]}}',
},
},
},
],
},
conditionBasic: {
'x-component': 'Filter',
'x-use-component-props': () => {
return {
@ -83,6 +157,7 @@ export const FormLinkageRules = withDynamicSchemaProps(
`,
};
},
'x-visible': '{{$deps[0] === "basic"}}',
'x-component-props': {
collectionName,
dynamicComponent: (props: DynamicComponentProps) => {
@ -102,6 +177,38 @@ export const FormLinkageRules = withDynamicSchemaProps(
);
},
},
'x-reactions': [
{
dependencies: ['.conditionType', '.condition'],
fulfill: {
state: {
visible: '{{$deps[0] === "basic"}}',
value: '{{$deps[0] === "basic" ? $deps[1] : undefined}}',
},
},
},
],
},
conditionAdvanced: {
'x-component': 'LinkageFilter',
'x-visible': '{{$deps[0] === "advanced"}}',
'x-reactions': [
{
dependencies: ['.conditionType', '.condition'],
fulfill: {
state: {
visible: '{{$deps[0] === "advanced"}}',
value: '{{$deps[0] === "advanced" ? $deps[1] : undefined}}',
},
},
},
],
},
conditionType: {
type: 'string',
'x-component': 'Input',
default: 'advanced',
'x-hidden': true,
},
actions: {
'x-component': 'h4',
@ -168,10 +275,10 @@ export const FormLinkageRules = withDynamicSchemaProps(
return (
// 这里使用 SubFormProvider 包裹,是为了让子表格的联动规则中 “当前对象” 的配置显示正确
<SubFormProvider value={{ value: null, collection: { name: collectionName } as any }}>
<SubFormProvider value={{ value: null, collection: { name: collectionName || name } as any }}>
<RecordProvider record={record} parent={parentRecordData}>
<FilterContext.Provider value={value}>
<CollectionProvider name={collectionName}>
<CollectionProvider name={collectionName || name} allowNull>
<SchemaComponent components={components} schema={schema} />
</CollectionProvider>
</FilterContext.Provider>

View File

@ -32,9 +32,11 @@ export enum ActionType {
export enum LinkageRuleCategory {
default = 'default',
style = 'style',
button = 'button',
}
export const LinkageRuleDataKeyMap: Record<`${LinkageRuleCategory}`, string> = {
[LinkageRuleCategory.style]: 'x-linkage-style-rules',
[LinkageRuleCategory.default]: 'x-linkage-rules',
[LinkageRuleCategory.button]: 'x-linkage-rules',
};

View File

@ -1122,7 +1122,7 @@ export const SchemaSettingsLinkageRules = function LinkageRules(props) {
const getRules = useCallback(() => {
return gridSchema?.[dataKey] || fieldSchema?.[dataKey] || [];
}, [gridSchema, fieldSchema, dataKey]);
const title = titleMap[category];
const title = titleMap[category] || t('Linkage rules');
const schema = useMemo<ISchema>(
() => ({
type: 'object',
@ -1155,7 +1155,7 @@ export const SchemaSettingsLinkageRules = function LinkageRules(props) {
(v) => {
const rules = [];
for (const rule of v.fieldReaction.rules) {
rules.push(_.pickBy(rule, _.identity));
rules.push(_.omit(_.pickBy(rule, _.identity), ['conditionBasic', 'conditionAdvanced']));
}
const templateId = gridSchema['x-component'] === 'BlockTemplate' && gridSchema['x-component-props']?.templateId;
const uid = (templateId && getTemplateById(templateId).uid) || gridSchema['x-uid'];

View File

@ -11,7 +11,7 @@ import { Form } from '@formily/core';
// @ts-ignore
import { Schema } from '@formily/json-schema';
import _ from 'lodash';
import React, { useCallback } from 'react';
import React, { useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { CollectionFieldOptions_deprecated } from '../../collection-manager';
import { Variable, useVariableScope } from '../../schema-component';
@ -72,6 +72,10 @@ type Props = {
*/
noDisabled?: boolean;
hideVariableButton?: boolean;
setScopes?: any; //更新scopes
nullable?: boolean;
constantAbel?: boolean;
changeOnSelect?: boolean;
};
/**
@ -98,6 +102,10 @@ export const VariableInput = (props: Props) => {
targetFieldSchema,
noDisabled,
hideVariableButton,
setScopes,
nullable = true,
constantAbel = true,
changeOnSelect = true,
} = props;
const { name: blockCollectionName } = useBlockCollection();
const scope = useVariableScope();
@ -127,31 +135,37 @@ export const VariableInput = (props: Props) => {
const handleChange = useCallback(
(value: any, optionPath: any[]) => {
if (!shouldChange) {
return onChange(value);
return onChange(value, optionPath);
}
// `shouldChange` 这个函数的运算量比较大,会导致展开变量列表时有明显的卡顿感,在这里加个延迟能有效解决这个问题
setTimeout(async () => {
if (await shouldChange(value, optionPath)) {
onChange(value);
onChange(value, optionPath);
}
});
},
[onChange, shouldChange],
);
const scopes = returnScope(
compatOldVariables(_.isEmpty(scope) ? variableOptions : scope, {
value,
}),
);
useEffect(() => {
setScopes?.(scopes);
}, [value, scope]);
return (
<Variable.Input
className={className}
value={value}
onChange={handleChange}
scope={returnScope(
compatOldVariables(_.isEmpty(scope) ? variableOptions : scope, {
value,
}),
)}
scope={scopes}
style={style}
changeOnSelect
changeOnSelect={changeOnSelect}
hideVariableButton={hideVariableButton}
nullable={nullable}
constantAbel={constantAbel}
>
<RenderSchemaComponent value={value} onChange={onChange} />
</Variable.Input>

View File

@ -9,6 +9,7 @@
import { useAPIClient } from '../../../api-client/hooks/useAPIClient';
import { useBaseVariable } from './useBaseVariable';
import { string } from '../../../collection-manager/interfaces/properties/operators';
/**
* `当前 Token`
@ -26,6 +27,7 @@ export const useAPITokenVariable = ({
title: 'API token',
noDisabled,
noChildren: true,
operators: string,
});
return {

View File

@ -87,6 +87,8 @@ interface BaseProps {
*/
deprecated?: boolean;
tooltip?: string;
/**支持的操作符 */
operators?: any[];
}
interface BaseVariableProviderProps {
@ -133,6 +135,8 @@ const getChildren = (
: isDisabled({ option, collectionField, uiSchema, targetFieldSchema, getCollectionField })),
isLeaf: true,
depth,
operators: option?.operators,
schema: option?.schema,
};
}
@ -197,6 +201,7 @@ export const useBaseVariable = ({
returnFields = (fields) => fields,
deprecated,
tooltip,
operators = [],
}: BaseProps) => {
const compile = useCompile();
const getFilterOptions = useGetFilterOptions();
@ -276,6 +281,7 @@ export const useBaseVariable = ({
children: [],
disabled: !!deprecated,
deprecated,
operators,
} as Option;
}, [uiSchema?.['x-component']]);

View File

@ -13,7 +13,7 @@ import { useTranslation } from 'react-i18next';
import { useOperators } from '../../../block-provider/CollectOperators';
import { useDatePickerContext } from '../../../schema-component/antd/date-picker/DatePicker';
import { getDateRanges } from '../../../schema-component/antd/date-picker/util';
import { datetime } from '../../../collection-manager/interfaces/properties/operators';
interface Props {
operator?: {
value: string;
@ -45,132 +45,155 @@ export const useDateVariable = ({ operator, schema, noDisabled }: Props) => {
value: 'now',
label: t('Current time'),
disabled: noDisabled ? false : schema?.['x-component'] !== 'DatePicker' || operatorValue === '$dateBetween',
operators: datetime,
schema: {},
},
{
key: 'yesterday',
value: 'yesterday',
label: t('Yesterday'),
disabled,
operators: datetime,
},
{
key: 'today',
value: 'today',
label: t('Today'),
disabled,
operators: datetime,
},
{
key: 'tomorrow',
value: 'tomorrow',
label: t('Tomorrow'),
disabled,
operators: datetime,
},
{
key: 'lastIsoWeek',
value: 'lastIsoWeek',
label: t('Last week'),
disabled,
operators: datetime,
},
{
key: 'thisIsoWeek',
value: 'thisIsoWeek',
label: t('This week'),
disabled,
operators: datetime,
},
{
key: 'nextIsoWeek',
value: 'nextIsoWeek',
label: t('Next week'),
disabled,
operators: datetime,
},
{
key: 'lastMonth',
value: 'lastMonth',
label: t('Last month'),
disabled,
operators: datetime,
},
{
key: 'thisMonth',
value: 'thisMonth',
label: t('This month'),
disabled,
operators: datetime,
},
{
key: 'nextMonth',
value: 'nextMonth',
label: t('Next month'),
disabled,
operators: datetime,
},
{
key: 'lastQuarter',
value: 'lastQuarter',
label: t('Last quarter'),
disabled,
operators: datetime,
},
{
key: 'thisQuarter',
value: 'thisQuarter',
label: t('This quarter'),
disabled,
operators: datetime,
},
{
key: 'nextQuarter',
value: 'nextQuarter',
label: t('Next quarter'),
disabled,
operators: datetime,
},
{
key: 'lastYear',
value: 'lastYear',
label: t('Last year'),
disabled,
operators: datetime,
},
{
key: 'thisYear',
value: 'thisYear',
label: t('This year'),
disabled,
operators: datetime,
},
{
key: 'nextYear',
value: 'nextYear',
label: t('Next year'),
disabled,
operators: datetime,
},
{
key: 'last7Days',
value: 'last7Days',
label: t('Last 7 days'),
disabled,
operators: datetime,
},
{
key: 'next7Days',
value: 'next7Days',
label: t('Next 7 days'),
disabled,
operators: datetime,
},
{
key: 'last30Days',
value: 'last30Days',
label: t('Last 30 days'),
disabled,
operators: datetime,
},
{
key: 'next30Days',
value: 'next30Days',
label: t('Next 30 days'),
disabled,
operators: datetime,
},
{
key: 'last90Days',
value: 'last90Days',
label: t('Last 90 days'),
disabled,
operators: datetime,
},
{
key: 'next90Days',
value: 'next90Days',
label: t('Next 90 days'),
disabled,
operators: datetime,
},
];
@ -222,132 +245,154 @@ export const useDatetimeVariable = ({ operator, schema, noDisabled, targetFieldS
value: 'now',
label: t('Current time'),
disabled: noDisabled ? false : schema?.['x-component'] !== 'DatePicker' || operatorValue === '$dateBetween',
operators: datetime,
},
{
key: 'yesterday',
value: 'yesterday',
label: t('Yesterday'),
disabled,
operators: datetime,
},
{
key: 'today',
value: 'today',
label: t('Today'),
disabled,
operators: datetime,
},
{
key: 'tomorrow',
value: 'tomorrow',
label: t('Tomorrow'),
disabled,
operators: datetime,
},
{
key: 'lastIsoWeek',
value: 'lastIsoWeek',
label: t('Last week'),
disabled,
operators: datetime,
},
{
key: 'thisIsoWeek',
value: 'thisIsoWeek',
label: t('This week'),
disabled,
operators: datetime,
},
{
key: 'nextIsoWeek',
value: 'nextIsoWeek',
label: t('Next week'),
disabled,
operators: datetime,
},
{
key: 'lastMonth',
value: 'lastMonth',
label: t('Last month'),
disabled,
operators: datetime,
},
{
key: 'thisMonth',
value: 'thisMonth',
label: t('This month'),
disabled,
operators: datetime,
},
{
key: 'nextMonth',
value: 'nextMonth',
label: t('Next month'),
disabled,
operators: datetime,
},
{
key: 'lastQuarter',
value: 'lastQuarter',
label: t('Last quarter'),
disabled,
operators: datetime,
},
{
key: 'thisQuarter',
value: 'thisQuarter',
label: t('This quarter'),
disabled,
operators: datetime,
},
{
key: 'nextQuarter',
value: 'nextQuarter',
label: t('Next quarter'),
disabled,
operators: datetime,
},
{
key: 'lastYear',
value: 'lastYear',
label: t('Last year'),
disabled,
operators: datetime,
},
{
key: 'thisYear',
value: 'thisYear',
label: t('This year'),
disabled,
operators: datetime,
},
{
key: 'nextYear',
value: 'nextYear',
label: t('Next year'),
disabled,
operators: datetime,
},
{
key: 'last7Days',
value: 'last7Days',
label: t('Last 7 days'),
disabled,
operators: datetime,
},
{
key: 'next7Days',
value: 'next7Days',
label: t('Next 7 days'),
disabled,
operators: datetime,
},
{
key: 'last30Days',
value: 'last30Days',
label: t('Last 30 days'),
disabled,
operators: datetime,
},
{
key: 'next30Days',
value: 'next30Days',
label: t('Next 30 days'),
disabled,
operators: datetime,
},
{
key: 'last90Days',
value: 'last90Days',
label: t('Last 90 days'),
disabled,
operators: datetime,
},
{
key: 'next90Days',
value: 'next90Days',
label: t('Next 90 days'),
disabled,
operators: datetime,
},
];

View File

@ -10,6 +10,7 @@
import { Form } from '@formily/core';
import { Schema } from '@formily/json-schema';
import { useTranslation } from 'react-i18next';
import { useBlockContext } from '../../../block-provider';
import { useFormBlockContext } from '../../../block-provider/FormBlockProvider';
import { CollectionFieldOptions_deprecated } from '../../../collection-manager';
import { useDataBlockRequestData, useDataSource } from '../../../data-source';
@ -62,14 +63,6 @@ export const useFormVariable = ({ collectionName, collectionField, schema, noDis
return result;
};
const useCurrentFormData = () => {
const data = useDataBlockRequestData();
if (data?.data?.length > 1) {
return;
}
return data?.data?.[0] || data?.data;
};
/**
* `当前表单` hook
* @param param0
@ -78,14 +71,14 @@ const useCurrentFormData = () => {
export const useCurrentFormContext = ({ form: _form }: Pick<Props, 'form'> = {}) => {
const { form } = useFormBlockContext();
const { isVariableParsedInOtherContext } = useFlag();
const { name } = useBlockContext?.() || {};
const formInstance = _form || form;
return {
/** 变量值 */
currentFormCtx: formInstance?.values,
/** 用来判断是否可以显示`当前表单`变量 */
shouldDisplayCurrentForm: formInstance && !formInstance.readPretty && !isVariableParsedInOtherContext,
shouldDisplayCurrentForm:
name === 'form' && formInstance && !formInstance.readPretty && !isVariableParsedInOtherContext,
};
};

View File

@ -90,7 +90,10 @@ export const useCurrentRecordContext = () => {
/** 变量值 */
currentRecordCtx: ctx?.recordData || formRecord?.data || recordData,
/** 用于判断是否需要显示配置项 */
shouldDisplayCurrentRecord: !_.isEmpty(_.omit(recordData, ['__collectionName', '__parent'])) || !!formRecord?.data,
shouldDisplayCurrentRecord:
!_.isEmpty(_.omit(recordData, ['__collectionName', '__parent'])) ||
!!formRecord?.data ||
blockType === 'taleColumn',
/** 当前记录对应的 collection name */
collectionName: realCollectionName,
/** 块类型 */

View File

@ -13,6 +13,9 @@ import { useAPIClient } from '../../../api-client';
import { CollectionFieldOptions_deprecated } from '../../../collection-manager';
import { CollectionFieldOptions } from '../../../data-source/collection/Collection';
import { useBaseVariable } from './useBaseVariable';
import { string } from '../../../collection-manager/interfaces/properties/operators';
import { useCurrentUserContext } from '../../../user/CurrentUserProvider';
import { useCompile } from '../../../schema-component';
/**
* @deprecated
@ -47,6 +50,7 @@ export const useRoleVariable = ({
noDisabled,
targetFieldSchema,
noChildren: true,
operators: string,
});
return result;
@ -73,6 +77,9 @@ export const useCurrentRoleVariable = ({
} = {}) => {
const { t } = useTranslation();
const apiClient = useAPIClient();
const compile = useCompile();
const { data } = useCurrentUserContext() || {};
const roles = (data?.data?.roles || []).map(({ name, title }) => ({ name, title: compile(title) }));
const currentRoleSettings = useBaseVariable({
collectionField,
uiSchema,
@ -83,12 +90,13 @@ export const useCurrentRoleVariable = ({
noDisabled,
targetFieldSchema,
noChildren: true,
operators: string,
});
return {
/** 变量配置项 */
currentRoleSettings,
/** 变量的值 */
currentRoleCtx: apiClient.auth?.role,
currentRoleCtx: apiClient.auth?.role === '__union__' ? roles.map((v) => v.name) : apiClient.auth?.role,
};
};

View File

@ -27,7 +27,7 @@ export const useIsLoggedIn = () => {
export const useCurrentRoles = () => {
const { allowAnonymous } = useACLRoleContext();
const { data } = useCurrentUserContext();
const { data } = useCurrentUserContext() || {};
const compile = useCompile();
const options = useMemo(() => {
const roles = (data?.data?.roles || []).map(({ name, title }) => ({ name, title: compile(title) }));

View File

@ -21,9 +21,10 @@ import {
SecondConFirm,
AfterSuccess,
RefreshDataBlockRequest,
SchemaSettingsLinkageRules,
useDataBlockProps,
} from '@nocobase/client';
import { ModalProps } from 'antd';
import { isValid } from '@formily/shared';
import React from 'react';
import { useTranslation } from 'react-i18next';
@ -96,6 +97,16 @@ export const deprecatedBulkEditActionSettings = new SchemaSettings({
return buttonEditorProps;
},
},
{
name: 'linkageRules',
Component: SchemaSettingsLinkageRules,
useComponentProps() {
const { linkageRulesProps } = useSchemaToolbar();
return {
...linkageRulesProps,
};
},
},
{
name: 'openMode',
Component: SchemaInitializerOpenModeSchemaItems,
@ -138,6 +149,16 @@ export const bulkEditActionSettings = new SchemaSettings({
return buttonEditorProps;
},
},
{
name: 'linkageRules',
Component: SchemaSettingsLinkageRules,
useComponentProps() {
const { linkageRulesProps } = useSchemaToolbar();
return {
...linkageRulesProps,
};
},
},
{
name: 'openMode',
Component: SchemaInitializerOpenModeSchemaItems,
@ -158,6 +179,7 @@ export const bulkEditActionSettings = new SchemaSettings({
name: 'updateMode',
Component: UpdateMode,
},
{
name: 'remove',
sort: 100,
@ -191,6 +213,17 @@ export const bulkEditFormSubmitActionSettings = new SchemaSettings({
name: 'afterSuccessfulSubmission',
Component: AfterSuccess,
},
{
name: 'linkageRules',
Component: SchemaSettingsLinkageRules,
useComponentProps() {
const { linkageRulesProps } = useSchemaToolbar();
return {
...linkageRulesProps,
};
},
},
{
name: 'refreshDataBlockRequest',
Component: RefreshDataBlockRequest,

View File

@ -22,6 +22,10 @@ import {
useGlobalVariable,
BlocksSelector,
usePlugin,
SchemaSettingsLinkageRules,
useCollectionManager_deprecated,
useDataBlockProps,
useCollection_deprecated,
} from '@nocobase/client';
import { useTranslation } from 'react-i18next';
import React from 'react';
@ -161,6 +165,21 @@ const schemaSettingsItems: SchemaSettingsItemType[] = [
return buttonEditorProps;
},
},
{
name: 'linkageRules',
Component: SchemaSettingsLinkageRules,
useComponentProps() {
const { name } = useCollection_deprecated();
const { association } = useDataBlockProps() || {};
const { getCollectionField } = useCollectionManager_deprecated();
const associationField = getCollectionField(association);
const { linkageRulesProps } = useSchemaToolbar();
return {
...linkageRulesProps,
collectionName: associationField?.collectionName || name,
};
},
},
{
name: 'updateMode',
Component: UpdateMode,

View File

@ -15,6 +15,7 @@ import {
useCollectionRecordData,
useCompile,
useGlobalVariable,
useFormBlockContext,
} from '@nocobase/client';
import { useMemo } from 'react';
import { useTranslation } from '../locale';
@ -27,6 +28,7 @@ export const useCustomRequestVariableOptions = () => {
const compile = useCompile();
const recordData = useCollectionRecordData();
const { name: blockType } = useBlockContext() || {};
const { form } = useFormBlockContext();
const [fields, userFields] = useMemo(() => {
return [compile(fieldsOptions), compile(userFieldOptions)];
}, [fieldsOptions, userFieldOptions]);
@ -39,7 +41,7 @@ export const useCustomRequestVariableOptions = () => {
title: t('Current record', { ns: 'client' }),
children: [...fields],
},
blockType === 'form' && {
(blockType === 'form' || form) && {
name: '$nForm',
title: t('Current form', { ns: 'client' }),
children: [...fields],

View File

@ -20,6 +20,8 @@ import {
useCollectionRecord,
useSchemaToolbar,
SchemaSettingAccessControl,
useDataBlockProps,
useCollectionManager_deprecated,
} from '@nocobase/client';
import { CustomRequestSettingsItem } from './components/CustomRequestActionDesigner';
@ -40,17 +42,11 @@ export const customizeCustomRequestActionSettings = new SchemaSettings({
name: 'linkageRules',
Component: SchemaSettingsLinkageRules,
useComponentProps() {
const { name } = useCollection() || {};
const { linkageRulesProps } = useSchemaToolbar();
return {
...linkageRulesProps,
collectionName: name,
};
},
useVisible() {
const record = useCollectionRecord();
return record && record.data && !record?.isNew;
},
},
{
name: 'secondConFirm',

View File

@ -9,7 +9,14 @@
import { ArrayItems } from '@formily/antd-v5';
import { ISchema, useField, useFieldSchema } from '@formily/react';
import { ButtonEditor, SchemaSettings, useDesignable, useSchemaToolbar } from '@nocobase/client';
import {
ButtonEditor,
SchemaSettings,
useDesignable,
useSchemaToolbar,
SchemaSettingsLinkageRules,
useDataBlockProps,
} from '@nocobase/client';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useShared } from './useShared';
@ -25,6 +32,16 @@ export const exportActionSchemaSettings = new SchemaSettings({
return buttonEditorProps;
},
},
{
name: 'linkageRules',
Component: SchemaSettingsLinkageRules,
useComponentProps() {
const { linkageRulesProps } = useSchemaToolbar();
return {
...linkageRulesProps,
};
},
},
{
name: 'exportableFields',
type: 'actionModal',
@ -65,6 +82,7 @@ export const exportActionSchemaSettings = new SchemaSettings({
};
},
},
{
name: 'divider',
type: 'divider',

View File

@ -9,14 +9,19 @@
import { ArrayItems } from '@formily/antd-v5';
import { ISchema, useField, useFieldSchema } from '@formily/react';
import { ButtonEditor, SchemaSettings, type, useDesignable, useSchemaToolbar } from '@nocobase/client';
import {
ButtonEditor,
SchemaSettings,
useDesignable,
useSchemaToolbar,
SchemaSettingsLinkageRules,
} from '@nocobase/client';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useShared } from './useShared';
import { Button, Space } from 'antd';
import { Action } from '@nocobase/client';
import React from 'react';
import { useDownloadXlsxTemplateAction } from './useImportAction';
export const importActionSchemaSettings = new SchemaSettings({
name: 'actionSettings:import',
@ -29,6 +34,16 @@ export const importActionSchemaSettings = new SchemaSettings({
return buttonEditorProps;
},
},
{
name: 'linkageRules',
Component: SchemaSettingsLinkageRules,
useComponentProps() {
const { linkageRulesProps } = useSchemaToolbar();
return {
...linkageRulesProps,
};
},
},
{
name: 'importableFields',
type: 'actionModal',
@ -72,6 +87,7 @@ export const importActionSchemaSettings = new SchemaSettings({
};
},
},
{
name: 'divider',
type: 'divider',

View File

@ -12,7 +12,6 @@ import {
SchemaSettings,
SchemaSettingsItemType,
SchemaSettingsLinkageRules,
useCollection_deprecated,
useSchemaToolbar,
} from '@nocobase/client';
@ -35,11 +34,9 @@ const schemaSettingsItems: SchemaSettingsItemType[] = [
name: 'linkageRules',
Component: SchemaSettingsLinkageRules,
useComponentProps() {
const { name } = useCollection_deprecated();
const { linkageRulesProps } = useSchemaToolbar();
return {
...linkageRulesProps,
collectionName: name,
};
},
},

View File

@ -14,6 +14,8 @@ import {
useSchemaInitializer,
ModalActionSchemaInitializerItem,
SchemaSettingAccessControl,
SchemaSettingsLinkageRules,
useSchemaToolbar,
} from '@nocobase/client';
import React from 'react';
import { useTranslation } from 'react-i18next';
@ -27,6 +29,16 @@ export const workbenchActionSettingsCustomRequest = new SchemaSettings({
return { hasIconColor: true };
},
},
{
name: 'linkageRules',
Component: SchemaSettingsLinkageRules,
useComponentProps() {
const { linkageRulesProps } = useSchemaToolbar();
return {
...linkageRulesProps,
};
},
},
{
name: 'editLink',
Component: SchemaSettingsActionLinkItem,

View File

@ -15,6 +15,8 @@ import {
useSchemaInitializerItem,
ModalActionSchemaInitializerItem,
SchemaSettingAccessControl,
SchemaSettingsLinkageRules,
useSchemaToolbar,
} from '@nocobase/client';
import React from 'react';
import { useTranslation } from 'react-i18next';
@ -29,6 +31,16 @@ export const workbenchActionSettingsLink = new SchemaSettings({
return { hasIconColor: true };
},
},
{
name: 'linkageRules',
Component: SchemaSettingsLinkageRules,
useComponentProps() {
const { linkageRulesProps } = useSchemaToolbar();
return {
...linkageRulesProps,
};
},
},
{
name: 'editLink',
Component: SchemaSettingsActionLinkItem,

View File

@ -15,6 +15,8 @@ import {
useOpenModeContext,
ModalActionSchemaInitializerItem,
SchemaSettingAccessControl,
SchemaSettingsLinkageRules,
useSchemaToolbar,
} from '@nocobase/client';
import React from 'react';
import { useTranslation } from 'react-i18next';
@ -29,7 +31,16 @@ export const workbenchActionSettingsPopup = new SchemaSettings({
return { hasIconColor: true };
},
},
{
name: 'linkageRules',
Component: SchemaSettingsLinkageRules,
useComponentProps() {
const { linkageRulesProps } = useSchemaToolbar();
return {
...linkageRulesProps,
};
},
},
{
name: 'openMode',
Component: SchemaSettingOpenModeSchemaItems,

View File

@ -15,6 +15,8 @@ import {
useSchemaInitializerItem,
ModalActionSchemaInitializerItem,
SchemaSettingAccessControl,
SchemaSettingsLinkageRules,
useSchemaToolbar,
} from '@nocobase/client';
import React from 'react';
import { useTranslation } from 'react-i18next';
@ -29,6 +31,16 @@ export const workbenchActionSettingsScanQrCode = new SchemaSettings({
return { hasIconColor: true };
},
},
{
name: 'linkageRules',
Component: SchemaSettingsLinkageRules,
useComponentProps() {
const { linkageRulesProps } = useSchemaToolbar();
return {
...linkageRulesProps,
};
},
},
{
...SchemaSettingAccessControl,
useVisible() {

View File

@ -20,7 +20,7 @@ import { useStorageUploadProps } from './useStorageUploadProps';
export const useUploadFiles = () => {
const { getDataBlockRequest } = useDataBlockRequestGetter();
const { association } = useDataBlockProps();
const { association } = useDataBlockProps() || {};
const { setVisible } = useActionContext();
const collection = useCollection();
const sourceId = useSourceId();