refactor(variable): support default value setting (#4583)

* refactor(variable): support default value setting

* chore: add e2e test for URL search params variable

* fix: resolve field linkage errors

* chore: fix unit test

* test: association select data scope linkage

---------

Co-authored-by: katherinehhh <katherine_15995@163.com>
This commit is contained in:
Zeke Zhang 2024-06-07 10:23:28 +08:00 committed by GitHub
parent 17edad6014
commit 837f4f4158
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 642 additions and 57 deletions

View File

@ -63,5 +63,25 @@ test.describe('Link', () => {
await expect(page.getByRole('button', { name: users[0].username, exact: true })).not.toBeVisible();
await expect(page.getByRole('button', { name: 'nocobase', exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: users[1].username, exact: true })).toBeVisible();
// 5. Change the operator of the data scope from "is not" to "is"
await page.getByLabel('block-item-CardItem-users-').hover();
await page.getByLabel('designer-schema-settings-CardItem-blockSettings:table-users').hover();
await page.getByRole('menuitem', { name: 'Set the data scope' }).click();
await page.getByTestId('select-filter-operator').click();
await page.getByRole('option', { name: 'is', exact: true }).click();
await page.getByLabel('variable-button').click();
await page.getByRole('menuitemcheckbox', { name: 'URL search params right' }).click();
await page.getByRole('menuitemcheckbox', { name: 'id', exact: true }).click();
await page.getByRole('button', { name: 'OK', exact: true }).click();
await expect(page.getByRole('button', { name: users[0].username, exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: 'nocobase', exact: true })).not.toBeVisible();
await expect(page.getByRole('button', { name: users[1].username, exact: true })).not.toBeVisible();
// 6. Re-enter the page (to eliminate the query string in the URL), at this time the value of the variable is undefined, and all data should be displayed
await nocoPage.goto();
await expect(page.getByRole('button', { name: users[0].username, exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: 'nocobase', exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: users[1].username, exact: true })).toBeVisible();
});
});

View File

@ -0,0 +1,45 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { expect, test } from '@nocobase/test/e2e';
import { associationSelectDataScope } from './templatesOfBug';
test.describe('AssociationSelect ', () => {
test('data scope linkage with other association select field', async ({ page, mockPage, mockRecord }) => {
await mockPage(associationSelectDataScope).goto();
await mockRecord('school', { id: 1 });
await mockRecord('class', {
school: { id: 1 },
});
const [request] = await Promise.all([
page.waitForRequest((request) => request.url().includes('api/class:list')),
page.getByLabel('block-item-CollectionField-student-form-student.class-class').click(),
]);
const requestUrl = request.url();
const queryParams = new URLSearchParams(new URL(requestUrl).search);
const filter = queryParams.get('filter');
//请求参数符合预期
expect(JSON.parse(filter)).toEqual({ $and: [{ school: { id: { $eq: null } } }] });
// 选择数据后联动
await page.getByLabel('block-item-CollectionField-student-form-student.school-school').click();
await page.getByLabel('block-item-CollectionField-student-form-student.school-school').click();
await page.getByRole('option', { name: '1' }).click();
const [request1] = await Promise.all([
page.waitForRequest((request) => request.url().includes('api/class:list')),
page.getByLabel('block-item-CollectionField-student-form-student.class-class').click(),
]);
const requestUrl1 = request1.url();
const queryParams1 = new URLSearchParams(new URL(requestUrl1).search);
const filter1 = queryParams1.get('filter');
//请求参数符合预期
await expect(JSON.parse(filter1)).toEqual({ $and: [{ school: { id: { $eq: 1 } } }] });
});
});

View File

@ -0,0 +1,458 @@
/**
* 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 const associationSelectDataScope = {
pageSchema: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Page',
'x-app-version': '1.0.0-alpha.17',
properties: {
i9pflzjyioi: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Grid',
'x-initializer': 'page:addBlock',
'x-app-version': '1.0.0-alpha.17',
properties: {
'4ai4zgcz8ee': {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Grid.Row',
'x-app-version': '1.0.0-alpha.17',
properties: {
vpldrh6hgmf: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Grid.Col',
'x-app-version': '1.0.0-alpha.17',
properties: {
'5ieufmio6lw': {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-acl-action-props': {
skipScopeCheck: true,
},
'x-acl-action': 'student:create',
'x-decorator': 'FormBlockProvider',
'x-use-decorator-props': 'useCreateFormBlockDecoratorProps',
'x-decorator-props': {
dataSource: 'main',
collection: 'student',
},
'x-toolbar': 'BlockSchemaToolbar',
'x-settings': 'blockSettings:createForm',
'x-component': 'CardItem',
'x-app-version': '1.0.0-alpha.17',
properties: {
'5husg9t3vv9': {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'FormV2',
'x-use-component-props': 'useCreateFormBlockProps',
'x-app-version': '1.0.0-alpha.17',
properties: {
grid: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Grid',
'x-initializer': 'form:configureFields',
'x-app-version': '1.0.0-alpha.17',
properties: {
tl52qwh5qb2: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Grid.Row',
'x-app-version': '1.0.0-alpha.17',
properties: {
k0bea3ifdvt: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Grid.Col',
'x-app-version': '1.0.0-alpha.17',
properties: {
school: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'string',
'x-toolbar': 'FormItemSchemaToolbar',
'x-settings': 'fieldSettings:FormItem',
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
'x-collection-field': 'student.school',
'x-component-props': {
fieldNames: {
value: 'id',
label: 'id',
},
},
'x-app-version': '1.0.0-alpha.17',
'x-uid': '4aqvcj92dmg',
'x-async': false,
'x-index': 1,
},
},
'x-uid': '3menjy0e1ej',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'oowsi97eacq',
'x-async': false,
'x-index': 1,
},
whxgd4trw89: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Grid.Row',
'x-app-version': '1.0.0-alpha.17',
properties: {
'2lbxox3r15o': {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Grid.Col',
'x-app-version': '1.0.0-alpha.17',
properties: {
class: {
'x-uid': '1gwunmk7b3c',
_isJSONSchemaObject: true,
version: '2.0',
type: 'string',
'x-toolbar': 'FormItemSchemaToolbar',
'x-settings': 'fieldSettings:FormItem',
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
'x-collection-field': 'student.class',
'x-component-props': {
fieldNames: {
value: 'id',
label: 'id',
},
service: {
params: {
filter: {
$and: [
{
school: {
id: {
$eq: '{{$nForm.school.id}}',
},
},
},
],
},
},
},
},
'x-app-version': '1.0.0-alpha.17',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'ntz289r21df',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'cavrlbod03e',
'x-async': false,
'x-index': 2,
},
},
'x-uid': 'k7qwqqhdx60',
'x-async': false,
'x-index': 1,
},
'1kqjiuyj895': {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-initializer': 'createForm:configureActions',
'x-component': 'ActionBar',
'x-component-props': {
layout: 'one-column',
style: {
marginTop: 'var(--nb-spacing)',
},
},
'x-app-version': '1.0.0-alpha.17',
'x-uid': 'cwurx7plt95',
'x-async': false,
'x-index': 2,
},
},
'x-uid': '306cgan6w47',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'wy8gkqcnr6b',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'd30aarrzirt',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'pqdwwjn7y28',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'u3r6l5ed5c8',
'x-async': false,
'x-index': 1,
},
},
'x-uid': '42t4voitjmc',
'x-async': true,
'x-index': 1,
},
collections: [
{
name: 'school',
fields: [
{
key: 'ze26tawdlux',
name: 'id',
type: 'bigInt',
interface: 'integer',
description: null,
collectionName: 'school',
parentKey: null,
reverseKey: null,
autoIncrement: true,
primaryKey: true,
allowNull: false,
uiSchema: {
type: 'number',
title: '{{t("ID")}}',
'x-component': 'InputNumber',
'x-read-pretty': true,
},
},
],
filterTargetKey: 'id',
},
{
name: 'class',
fields: [
{
key: 'rkb2b156str',
name: 'id',
type: 'bigInt',
interface: 'integer',
description: null,
collectionName: 'class',
parentKey: null,
reverseKey: null,
autoIncrement: true,
primaryKey: true,
allowNull: false,
uiSchema: {
type: 'number',
title: '{{t("ID")}}',
'x-component': 'InputNumber',
'x-read-pretty': true,
},
},
{
key: 's804a0a6hb4',
name: 'f_nwk9mip9y1k',
type: 'bigInt',
interface: 'integer',
description: null,
collectionName: 'class',
parentKey: null,
reverseKey: null,
isForeignKey: true,
uiSchema: {
type: 'number',
title: 'f_nwk9mip9y1k',
'x-component': 'InputNumber',
'x-read-pretty': true,
},
},
{
key: 'btl687vmwyo',
name: 'f_ih087vrmag4',
type: 'bigInt',
interface: 'integer',
description: null,
collectionName: 'class',
parentKey: null,
reverseKey: null,
isForeignKey: true,
uiSchema: {
type: 'number',
title: 'f_ih087vrmag4',
'x-component': 'InputNumber',
'x-read-pretty': true,
},
},
{
key: 'a35wf3880bb',
name: 'f_uwcl0rf78mn',
type: 'bigInt',
interface: 'integer',
description: null,
collectionName: 'class',
parentKey: null,
reverseKey: null,
isForeignKey: true,
uiSchema: {
type: 'number',
title: 'f_uwcl0rf78mn',
'x-component': 'InputNumber',
'x-read-pretty': true,
},
},
{
key: 'nt7i4vmih24',
name: 'school',
type: 'belongsTo',
interface: 'm2o',
description: null,
collectionName: 'class',
parentKey: null,
reverseKey: null,
foreignKey: 'f_uwcl0rf78mn',
onDelete: 'SET NULL',
uiSchema: {
'x-component': 'AssociationField',
'x-component-props': {
multiple: false,
},
title: 'school',
},
target: 'school',
targetKey: 'id',
},
],
filterTargetKey: 'id',
},
{
name: 'student',
fields: [
{
key: 'pjz61nq67ym',
name: 'id',
type: 'bigInt',
interface: 'integer',
description: null,
collectionName: 'student',
parentKey: null,
reverseKey: null,
autoIncrement: true,
primaryKey: true,
allowNull: false,
uiSchema: {
type: 'number',
title: '{{t("ID")}}',
'x-component': 'InputNumber',
'x-read-pretty': true,
},
},
{
key: 'jrt23ehbhwn',
name: 'f_firb6d8f8jq',
type: 'bigInt',
interface: 'integer',
description: null,
collectionName: 'student',
parentKey: null,
reverseKey: null,
isForeignKey: true,
uiSchema: {
type: 'number',
title: 'f_firb6d8f8jq',
'x-component': 'InputNumber',
'x-read-pretty': true,
},
},
{
key: '9hydd4b1out',
name: 'f_7kxab0celw3',
type: 'bigInt',
interface: 'integer',
description: null,
collectionName: 'student',
parentKey: null,
reverseKey: null,
isForeignKey: true,
uiSchema: {
type: 'number',
title: 'f_7kxab0celw3',
'x-component': 'InputNumber',
'x-read-pretty': true,
},
},
{
key: 'xwpw9a9f7y6',
name: 'class',
type: 'belongsTo',
interface: 'm2o',
description: null,
collectionName: 'student',
parentKey: null,
reverseKey: null,
foreignKey: 'f_firb6d8f8jq',
onDelete: 'SET NULL',
uiSchema: {
'x-component': 'AssociationField',
'x-component-props': {
multiple: false,
},
title: 'class',
},
target: 'class',
targetKey: 'id',
},
{
key: 'by6lqjkl50g',
name: 'school',
type: 'belongsTo',
interface: 'm2o',
description: null,
collectionName: 'student',
parentKey: null,
reverseKey: null,
foreignKey: 'f_7kxab0celw3',
onDelete: 'SET NULL',
uiSchema: {
'x-component': 'AssociationField',
'x-component-props': {
multiple: false,
},
title: 'school',
},
target: 'school',
targetKey: 'id',
},
],
filterTargetKey: 'id',
},
],
};

View File

@ -14,7 +14,8 @@ import { flatten, getValuesByPath } from '@nocobase/utils/client';
import _, { isString } from 'lodash';
import cloneDeep from 'lodash/cloneDeep';
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { useCollection_deprecated, useCollectionManager_deprecated } from '../../../collection-manager';
import { useCollectionManager_deprecated, useCollection_deprecated } from '../../../collection-manager';
import { Collection } from '../../../data-source';
import { isInFilterFormBlock } from '../../../filter-provider';
import { mergeFilter } from '../../../filter-provider/utils';
import { useRecord } from '../../../record-provider';
@ -25,7 +26,6 @@ import { getVariableName } from '../../../variables/utils/getVariableName';
import { isVariable } from '../../../variables/utils/isVariable';
import { useDesignable } from '../../hooks';
import { AssociationFieldContext } from './context';
import { Collection } from '../../../data-source';
export const useInsertSchema = (component) => {
const fieldSchema = useFieldSchema();

View File

@ -99,6 +99,12 @@ export const useURLSearchParamsVariable = (props: any = {}) => {
urlSearchParamsSettings,
/** 变量值 */
urlSearchParamsCtx,
/**
* undefined
* null filter URL search params filter
* defaultValue undefined undefined undefined filter
*/
defaultValue: undefined,
shouldDisplay: !isVariableParsedInOtherContext,
};
};

View File

@ -28,27 +28,24 @@ import { uniq } from './utils/uniq';
export const VariablesContext = createContext<VariablesContextType>(null);
VariablesContext.displayName = 'VariablesContext';
const variableToCollectionName: Record<
string,
{
collectionName?: string;
dataSource?: string;
}
> = {};
const variablesStore: Record<string, VariableOption> = {};
const getFieldPath = (variablePath: string, variableToCollectionName: Record<string, any>) => {
const getFieldPath = (variablePath: string, variablesStore: Record<string, VariableOption>) => {
let dataSource;
let variableOption: VariableOption;
const list = variablePath.split('.');
const result = list.map((item) => {
if (variableToCollectionName[item]) {
dataSource = variableToCollectionName[item].dataSource;
return variableToCollectionName[item].collectionName;
if (variablesStore[item]) {
dataSource = variablesStore[item].dataSource;
variableOption = variablesStore[item];
return variablesStore[item].collectionName;
}
return item;
});
return {
fieldPath: result.join('.'),
dataSource,
variableOption,
};
};
@ -83,12 +80,9 @@ const VariablesProvider = ({ children }) => {
) => {
const list = variablePath.split('.');
const variableName = list[0];
const _variableToCollectionName = mergeVariableToCollectionNameWithLocalVariables(
variableToCollectionName,
localVariables,
);
const _variableToCollectionName = mergeVariableToCollectionNameWithLocalVariables(variablesStore, localVariables);
let current = mergeCtxWithLocalVariables(ctxRef.current, localVariables);
const { fieldPath, dataSource } = getFieldPath(variableName, _variableToCollectionName);
const { fieldPath, dataSource, variableOption } = getFieldPath(variableName, _variableToCollectionName);
let collectionName = fieldPath;
if (!(variableName in current)) {
@ -97,7 +91,7 @@ const VariablesProvider = ({ children }) => {
for (let index = 0; index < list.length; index++) {
if (current == null) {
return current;
return current === undefined ? variableOption.defaultValue : current;
}
const key = list[index];
@ -165,7 +159,8 @@ const VariablesProvider = ({ children }) => {
}
}
return compile(_.isFunction(current) ? current() : current);
const result = compile(_.isFunction(current) ? current() : current);
return result === undefined ? variableOption.defaultValue : result;
},
[getCollectionJoinField],
);
@ -185,12 +180,10 @@ const VariablesProvider = ({ children }) => {
[variableOption.name]: variableOption.ctx,
};
});
if (variableOption.collectionName) {
variableToCollectionName[variableOption.name] = {
collectionName: variableOption.collectionName,
dataSource: variableOption.dataSource,
};
}
variablesStore[variableOption.name] = {
...variableOption,
defaultValue: _.has(variableOption, 'defaultValue') ? variableOption.defaultValue : null,
};
},
[setCtx],
);
@ -200,13 +193,8 @@ const VariablesProvider = ({ children }) => {
return null;
}
const { collectionName, dataSource } = variableToCollectionName[variableName] || {};
return {
name: variableName,
ctx: ctxRef.current[variableName],
collectionName,
dataSource,
...variablesStore[variableName],
};
}, []);
@ -217,7 +205,7 @@ const VariablesProvider = ({ children }) => {
delete next[variableName];
return next;
});
delete variableToCollectionName[variableName];
delete variablesStore[variableName];
},
[setCtx],
);
@ -264,7 +252,7 @@ const VariablesProvider = ({ children }) => {
}
const _variableToCollectionName = mergeVariableToCollectionNameWithLocalVariables(
variableToCollectionName,
variablesStore,
localVariables as VariableOption[],
);
const path = getPath(variableString);
@ -285,7 +273,10 @@ const VariablesProvider = ({ children }) => {
useEffect(() => {
builtinVariables.forEach((variableOption) => {
registerVariable(variableOption);
registerVariable({
...variableOption,
defaultValue: _.has(variableOption, 'defaultValue') ? variableOption.defaultValue : null,
});
});
}, [builtinVariables, registerVariable]);
@ -339,25 +330,17 @@ function mergeCtxWithLocalVariables(ctx: Record<string, any>, localVariables?: V
}
function mergeVariableToCollectionNameWithLocalVariables(
variableToCollectionName: Record<
string,
{
collectionName?: string;
dataSource?: string;
}
>,
variablesStore: Record<string, VariableOption>,
localVariables?: VariableOption[],
) {
variableToCollectionName = { ...variableToCollectionName };
variablesStore = { ...variablesStore };
localVariables?.forEach((item) => {
if (item.collectionName) {
variableToCollectionName[item.name] = {
collectionName: item.collectionName,
dataSource: item.dataSource,
};
}
variablesStore[item.name] = {
...item,
defaultValue: _.has(item, 'defaultValue') ? item.defaultValue : null,
};
});
return variableToCollectionName;
return variablesStore;
}

View File

@ -545,6 +545,7 @@ describe('useVariables', () => {
ctx: {
name: 'new variable',
},
defaultValue: null,
});
});
@ -567,6 +568,46 @@ describe('useVariables', () => {
});
});
await waitFor(async () => {
expect(await result.current.parseVariable('{{ $new.noExist }}')).toBe(null);
});
});
it('$new.noExist with default value', async () => {
const { result } = renderHook(() => useVariables(), {
wrapper: Providers,
});
await waitFor(async () => {
result.current.registerVariable({
name: '$new',
ctx: {
name: 'new variable',
},
defaultValue: 'default value',
});
});
await waitFor(async () => {
expect(await result.current.parseVariable('{{ $new.noExist }}')).toBe('default value');
});
});
it('$new.noExist with undefined default value', async () => {
const { result } = renderHook(() => useVariables(), {
wrapper: Providers,
});
await waitFor(async () => {
result.current.registerVariable({
name: '$new',
ctx: {
name: 'new variable',
},
defaultValue: undefined,
});
});
await waitFor(async () => {
expect(await result.current.parseVariable('{{ $new.noExist }}')).toBe(undefined);
});
@ -679,11 +720,27 @@ describe('useVariables', () => {
belongsToField: null,
},
collectionName: 'some',
defaultValue: 'default value',
});
await waitFor(async () => {
// 因为 $some 的 ctx 没有 id 所以无法获取关系字段的数据
expect(await result.current.parseVariable('{{ $some.belongsToField.belongsToField }}')).toBe(undefined);
// 只有解析后的值是 undefined 才会使用默认值
expect(await result.current.parseVariable('{{ $some.belongsToField.belongsToField }}')).toBe(null);
});
// 会覆盖之前的 $some
result.current.registerVariable({
name: '$some',
ctx: {
name: 'new variable',
},
collectionName: 'some',
defaultValue: 'default value',
});
await waitFor(async () => {
// 解析后的值是 undefined 所以会返回上面设置的默认值
expect(await result.current.parseVariable('{{ $some.belongsToField.belongsToField }}')).toBe('default value');
});
});

View File

@ -23,7 +23,7 @@ const useBuiltInVariables = () => {
const { currentUserCtx } = useCurrentUserVariable();
const { currentRoleCtx } = useCurrentRoleVariable();
const { datetimeCtx } = useDatetimeVariable();
const { urlSearchParamsCtx, name: urlSearchParamsName } = useURLSearchParamsVariable();
const { urlSearchParamsCtx, name: urlSearchParamsName, defaultValue } = useURLSearchParamsVariable();
const builtinVariables: VariableOption[] = useMemo(() => {
return [
{
@ -80,9 +80,10 @@ const useBuiltInVariables = () => {
{
name: urlSearchParamsName,
ctx: urlSearchParamsCtx,
defaultValue,
},
];
}, [currentRoleCtx, currentUserCtx, datetimeCtx, urlSearchParamsCtx, urlSearchParamsName]);
}, [currentRoleCtx, currentUserCtx, datetimeCtx, defaultValue, urlSearchParamsCtx, urlSearchParamsName]);
return { builtinVariables };
};

View File

@ -88,4 +88,11 @@ export interface VariableOption {
collectionName?: string;
/** 数据表所对应的数据源 */
dataSource?: string;
/**
* @default null
* undefined
* null filter
* filter defaultValue undefined
*/
defaultValue?: any;
}

View File

@ -90,6 +90,14 @@ describe('getValuesByPath', () => {
expect(result).toEqual(null);
});
it('when return is null', () => {
const obj = {
a: { b: null },
};
const result = getValuesByPath(obj, 'a.b');
expect(result).toEqual(null);
});
it('should return empty array when obj key value is undefined', () => {
const obj = {
a: undefined,

View File

@ -38,15 +38,15 @@ export const getValuesByPath = (obj: object, path: string, defaultValue?: any) =
}
}
result = result.filter((item) => item != null);
result = result.filter((item) => item !== undefined);
if (result.length === 0) {
return defaultValue;
}
if (shouldReturnArray) {
return result;
return result.filter((item) => item !== null);
}
return result.length === 1 ? result[0] : result;
return result[0];
};