feat(client): add new variable named 'URL search params' and support link action (#4506)

* feat: support link action

* feat(client): add new variable named 'URL search params'

* chore: add translation

* fix: avoid crashing

* chore: fix failed test

* feat: link action

* feat: link action

* fix: remove filter parameters with undefined values

* feat: link action

* feat: add support for default values in filter form fields

* refactor: code improve

* refactor: locale improve

* refactor: locale improve

* test: add e2e test

* refactor: locale improve

* refactor: locale improve

* fix: resolve operation issues with variables

* refactor: code improve

* chore: enable direct selection of variables as default value

* chore: use qs to parse query string

* fix: menu selectKeys (T-4373)

* refactor: use qs to stringify search params

* refactor: locale improve

* refactor: locale improve

* chore: fix failed tests

* fix: resolve issue where setting Data scope is not work

* chore: fix failed e2e tests

* chore: make e2e tests more stable

* chore: add translation

* chore: make e2e tests more stable

* fix: resolve the issue of error when saving data scope

* feat: trigger variable parsing after context change

* test: add unit tests

* test: add e2e test

* refactor: extract template

* chore: fix failed unit tests

* chore: fix failed e2e test

* fix(Link): hide linkage rules in top link (T-4410)

* fix(permission): remove URL search params variable from data scope

* chore: make more stable

* chore: make e2e test more stable

* fix(Link): reduce size for variable

* fix: clear previous context (T-4449)

* fix(calendar, map): resolve initial data scope setting error (T-4450)

* fix: correct concatenation of query string (T-4453)

---------

Co-authored-by: katherinehhh <katherine_15995@163.com>
Co-authored-by: jack zhang <1098626505@qq.com>
This commit is contained in:
Zeke Zhang 2024-06-04 20:57:03 +08:00 committed by GitHub
parent 0b8f762d8b
commit f66edb5d27
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
67 changed files with 1411 additions and 162 deletions

View File

@ -77,22 +77,26 @@ const InternalDetailsBlockProvider = (props) => {
const useCompatDetailsBlockParams = (props) => {
const fieldSchema = useFieldSchema();
let params;
let params,
parseVariableLoading = false;
// 1. 新版本的 schema 存在 x-use-decorator-props 属性
if (fieldSchema['x-use-decorator-props']) {
params = props?.params;
parseVariableLoading = props?.parseVariableLoading;
} else {
// 2. 旧版本的 schema 不存在 x-use-decorator-props 属性
// 因为 schema 中是否存在 x-use-decorator-props 是固定不变的,所以这里可以使用 hooks
// eslint-disable-next-line react-hooks/rules-of-hooks
params = useDetailsWithPaginationBlockParams(props);
const parsedParams = useDetailsWithPaginationBlockParams(props);
params = parsedParams.params;
parseVariableLoading = parsedParams.parseVariableLoading;
}
return params;
return { params, parseVariableLoading };
};
export const DetailsBlockProvider = withDynamicSchemaProps((props) => {
const params = useCompatDetailsBlockParams(props);
const { params, parseVariableLoading } = useCompatDetailsBlockParams(props);
const record = useCollectionRecordData();
const { association, dataSource } = props;
const { getCollection } = useCollectionManager_deprecated(dataSource);
@ -104,7 +108,7 @@ export const DetailsBlockProvider = withDynamicSchemaProps((props) => {
detailFlag = __collection === collection;
}
if (!detailFlag) {
if (!detailFlag || parseVariableLoading) {
return null;
}

View File

@ -67,8 +67,8 @@ const InternalTableBlockProvider = (props: Props) => {
}, [service?.loading, fieldSchema]);
const setExpandFlagValue = useCallback(
(falg) => {
setExpandFlag(falg || !expandFlag);
(flag) => {
setExpandFlag(flag || !expandFlag);
},
[expandFlag],
);
@ -106,18 +106,22 @@ const InternalTableBlockProvider = (props: Props) => {
const useTableBlockParamsCompat = (props) => {
const fieldSchema = useFieldSchema();
let params;
let params,
parseVariableLoading = false;
// 1. 新版本的 schema 存在 x-use-decorator-props 属性
if (fieldSchema['x-use-decorator-props']) {
params = props.params;
parseVariableLoading = props.parseVariableLoading;
} else {
// 2. 旧版本的 schema 不存在 x-use-decorator-props 属性
// 因为 schema 中是否存在 x-use-decorator-props 是固定不变的,所以这里可以使用 hooks
// eslint-disable-next-line react-hooks/rules-of-hooks
params = useTableBlockParams(props);
const tableBlockParams = useTableBlockParams(props);
params = tableBlockParams.params;
parseVariableLoading = tableBlockParams.parseVariableLoading;
}
return params;
return { params, parseVariableLoading };
};
export const TableBlockProvider = withDynamicSchemaProps((props) => {
@ -127,7 +131,7 @@ export const TableBlockProvider = withDynamicSchemaProps((props) => {
const { getCollection, getCollectionField } = useCollectionManager_deprecated(props.dataSource);
const collection = getCollection(props.collection, props.dataSource);
const { treeTable } = fieldSchema?.['x-decorator-props'] || {};
const params = useTableBlockParamsCompat(props);
const { params, parseVariableLoading } = useTableBlockParamsCompat(props);
let childrenColumnName = 'children';
@ -148,6 +152,11 @@ export const TableBlockProvider = withDynamicSchemaProps((props) => {
}
const form = useMemo(() => createForm(), [treeTable]);
// 在解析变量的时候不渲染,避免因为重复请求数据导致的资源浪费
if (parseVariableLoading) {
return null;
}
return (
<SchemaComponentOptions scope={{ treeTable }}>
<FormContext.Provider value={form}>

View File

@ -257,11 +257,11 @@ export const TableSelectorProvider = withDynamicSchemaProps((props: TableSelecto
console.error(err);
}
const { filter: parsedFilter } = useParsedFilter({
const { filter: parsedFilter, parseVariableLoading } = useParsedFilter({
filterOption: params?.filter,
});
if (!_.isEmpty(params?.filter) && _.isEmpty(parsedFilter)) {
if ((!_.isEmpty(params?.filter) && _.isEmpty(parsedFilter)) || parseVariableLoading) {
return null;
}

View File

@ -0,0 +1,152 @@
/**
* 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 { appendQueryStringToUrl, parseVariablesAndChangeParamsToQueryString, reduceValueSize } from '../../hooks/index';
describe('parseVariablesAndChangeParamsToQueryString', () => {
it('should parse variables and change params to query string', async () => {
const searchParams = [
{ name: 'param1', value: '{{ $var1.value }}' },
{ name: 'param2', value: 'value2' },
{ name: 'param3', value: 'value3' },
];
const variables: any = {
parseVariable: vi.fn().mockResolvedValue('parsedValue'),
};
const localVariables: any = [
{ name: '$var1', ctx: { value: 'localValue1' } },
{ name: '$var2', ctx: { value: 'localValue2' } },
];
const replaceVariableValue = vi.fn().mockResolvedValue('replacedValue');
const result = await parseVariablesAndChangeParamsToQueryString({
searchParams,
variables,
localVariables,
replaceVariableValue,
});
expect(variables.parseVariable).toHaveBeenCalledTimes(1);
expect(variables.parseVariable).toHaveBeenCalledWith('{{ $var1.value }}', localVariables);
expect(replaceVariableValue).toHaveBeenCalledTimes(2);
expect(replaceVariableValue).toHaveBeenCalledWith('value2', variables, localVariables);
expect(replaceVariableValue).toHaveBeenCalledWith('value3', variables, localVariables);
expect(result).toBe('param1=parsedValue&param2=replacedValue&param3=replacedValue');
});
});
describe('reduceValueSize', () => {
it('should reduce the size of the value', () => {
const value = {
key1: 'value1',
key2: 'value2',
key3: 'value3',
};
const result = reduceValueSize(value);
expect(result).toEqual({
key1: 'value1',
key2: 'value2',
key3: 'value3',
});
});
it('should remove keys with string values longer than 100 characters', () => {
const value = {
key1: 'value1',
key2: 'value2',
key3: 'value3'.repeat(20),
};
const result = reduceValueSize(value);
expect(result).toEqual({
key1: 'value1',
key2: 'value2',
});
});
it('should reduce the size of nested objects', () => {
const value = {
key1: 'value1',
key2: 'value2',
key3: {
nestedKey1: 'nestedValue1',
nestedKey2: 'nestedValue2',
nestedKey3: 'nestedValue3',
},
};
const result = reduceValueSize(value);
expect(result).toEqual({
key1: 'value1',
key2: 'value2',
});
});
it('should reduce the size of nested arrays', () => {
const value = {
key1: 'value1',
key2: 'value2',
key3: ['value1', 'value2', 'value3'],
};
const result = reduceValueSize(value);
expect(result).toEqual({
key1: 'value1',
key2: 'value2',
});
});
it('should reduce the size of arrays', () => {
const value = ['value1', 'value2', 'value3'.repeat(20)];
const result = reduceValueSize(value);
expect(result).toEqual(['value1', 'value2', 'value3'.repeat(20)]);
const value2 = [
'value1',
'value2',
{
key1: 'value1',
key2: 'value2',
key3: {
nestedKey1: 'nestedValue1',
nestedKey2: 'nestedValue2',
nestedKey3: 'nestedValue3',
},
},
];
const result2 = reduceValueSize(value2);
expect(result2).toEqual(['value1', 'value2', { key1: 'value1', key2: 'value2' }]);
});
});
describe('appendQueryStringToUrl', () => {
it('should append query string to the URL', () => {
const url = 'https://example.com';
const queryString = 'param1=value1&param2=value2';
const result = appendQueryStringToUrl(url, queryString);
expect(result).toBe('https://example.com?param1=value1&param2=value2');
});
it('should append query string to the URL with existing query parameters', () => {
const url = 'https://example.com?existingParam=value';
const queryString = 'param1=value1&param2=value2';
const result = appendQueryStringToUrl(url, queryString);
expect(result).toBe('https://example.com?existingParam=value&param1=value1&param2=value2');
});
});

View File

@ -10,11 +10,13 @@
import { Field, Form } from '@formily/core';
import { SchemaExpressionScopeContext, useField, useFieldSchema, useForm } from '@formily/react';
import { untracked } from '@formily/reactive';
import { evaluators } from '@nocobase/evaluators/client';
import { isURL, parse } from '@nocobase/utils/client';
import { App, message } from 'antd';
import _ from 'lodash';
import get from 'lodash/get';
import omit from 'lodash/omit';
import qs from 'qs';
import { ChangeEvent, useCallback, useContext, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
@ -35,8 +37,10 @@ import { useTreeParentRecord } from '../../modules/blocks/data-blocks/table/Tree
import { useRecord } from '../../record-provider';
import { removeNullCondition, useActionContext, useCompile } from '../../schema-component';
import { isSubMode } from '../../schema-component/antd/association-field/util';
import { replaceVariables } from '../../schema-component/antd/form-v2/utils';
import { useCurrentUserContext } from '../../user';
import { useLocalVariables, useVariables } from '../../variables';
import { VariableOption, VariablesContextType } from '../../variables/types';
import { isVariable } from '../../variables/utils/isVariable';
import { transformVariableValue } from '../../variables/utils/transformVariableValue';
import { useBlockRequestContext, useFilterByTk, useParamsFromRecord } from '../BlockProvider';
@ -1446,3 +1450,131 @@ async function resetFormCorrectly(form: Form) {
});
await form.reset();
}
export function appendQueryStringToUrl(url: string, queryString: string) {
return url + (url.includes('?') ? '&' : '?') + queryString;
}
export function useLinkActionProps() {
const navigate = useNavigate();
const fieldSchema = useFieldSchema();
const { t } = useTranslation();
const url = fieldSchema?.['x-component-props']?.['url'];
const searchParams = fieldSchema?.['x-component-props']?.['params'] || [];
const variables = useVariables();
const localVariables = useLocalVariables();
return {
type: 'default',
async onClick() {
if (!url) {
message.warning(t('Please configure the URL'));
return;
}
const queryString = await parseVariablesAndChangeParamsToQueryString({
searchParams,
variables,
localVariables,
replaceVariableValue,
});
const link = appendQueryStringToUrl(url, queryString);
if (link) {
if (isURL(link)) {
window.open(link, '_blank');
} else {
navigate(link);
}
}
},
};
}
async function replaceVariableValue(url: string, variables: VariablesContextType, localVariables: VariableOption[]) {
if (!url) {
return;
}
const { evaluate } = evaluators.get('string');
// 解析如 `{{$user.name}}` 之类的变量
const { exp, scope: expScope } = await replaceVariables(url, {
variables,
localVariables,
});
try {
const result = evaluate(exp, { now: () => new Date().toString(), ...expScope });
return result;
} catch (error) {
console.error(error);
}
}
export async function parseVariablesAndChangeParamsToQueryString({
searchParams,
variables,
localVariables,
replaceVariableValue,
}: {
searchParams: { name: string; value: any }[];
variables: VariablesContextType;
localVariables: VariableOption[];
replaceVariableValue: (
url: string,
variables: VariablesContextType,
localVariables: VariableOption[],
) => Promise<any>;
}) {
const parsed = await Promise.all(
searchParams.map(async ({ name, value }) => {
if (typeof value === 'string') {
if (isVariable(value)) {
const result = await variables.parseVariable(value, localVariables);
return { name, value: result };
}
const result = await replaceVariableValue(value, variables, localVariables);
return { name, value: result };
}
return { name, value };
}),
);
const params = {};
for (const { name, value } of parsed) {
if (name && value) {
params[name] = reduceValueSize(value);
}
}
return qs.stringify(params);
}
/**
* 1. value key
* 2. value 100 key
*/
export function reduceValueSize(value: any) {
if (_.isPlainObject(value)) {
const result = {};
Object.keys(value).forEach((key) => {
if (_.isPlainObject(value[key]) || _.isArray(value[key])) {
return;
}
if (_.isString(value[key]) && value[key].length > 100) {
return;
}
result[key] = value[key];
});
return result;
}
if (_.isArray(value)) {
return value.map((item) => {
if (_.isPlainObject(item) || _.isArray(item)) {
return reduceValueSize(item);
}
return item;
});
}
return value;
}

View File

@ -25,12 +25,15 @@ import { isVariable } from '../../variables/utils/isVariable';
export function useParsedFilter({ filterOption }: { filterOption: any }) {
const { parseFilter, findVariable } = useParseDataScopeFilter();
const [filter, setFilter] = useState({});
const [parseVariableLoading, setParseVariableLoading] = useState(!!filterOption);
useEffect(() => {
if (!filterOption) return;
const _run = async () => {
setParseVariableLoading(true);
const result = await parseFilter(filterOption);
setParseVariableLoading(false);
setFilter(result);
};
_run();
@ -64,7 +67,12 @@ export function useParsedFilter({ filterOption }: { filterOption: any }) {
});
return flat;
}, run);
}, [JSON.stringify(filterOption)]);
}, [JSON.stringify(filterOption), parseVariableLoading]);
return { filter };
return {
/** 数据范围的筛选参数 */
filter,
/** 表示是否正在解析筛选参数中的变量 */
parseVariableLoading,
};
}

View File

@ -826,5 +826,7 @@
"Plugin settings": "Plugin settings",
"Menu": "Menu",
"Drag and drop sorting field": "Drag and drop sorting field",
"This variable has been deprecated and can be replaced with \"Current form\"": "This variable has been deprecated and can be replaced with \"Current form\""
"This variable has been deprecated and can be replaced with \"Current form\"": "This variable has been deprecated and can be replaced with \"Current form\"",
"The value of this variable is derived from the query string of the page URL. This variable can only be used normally when the page has a query string.": "The value of this variable is derived from the query string of the page URL. This variable can only be used normally when the page has a query string.",
"URL search params": "URL search params"
}

View File

@ -758,5 +758,7 @@
"Home page": "Página de inicio",
"Handbook": "Manual de usuario",
"License": "Licencia",
"This variable has been deprecated and can be replaced with \"Current form\"": "La variable ha sido obsoleta; \"Formulario actual\" puede ser utilizada como sustituto"
"This variable has been deprecated and can be replaced with \"Current form\"": "La variable ha sido obsoleta; \"Formulario actual\" puede ser utilizada como sustituto",
"The value of this variable is derived from the query string of the page URL. This variable can only be used normally when the page has a query string.": "El valor de esta variable se deriva de la cadena de consulta de la URL de la página. Esta variable sólo puede utilizarse normalmente cuando la página tiene una cadena de consulta.",
"URL search params": "Parámetros de búsqueda de URL"
}

View File

@ -778,5 +778,7 @@
"Home page": "Page d'accueil",
"Handbook": "Manuel de l'utilisateur",
"License": "Licence",
"This variable has been deprecated and can be replaced with \"Current form\"": "La variable a été obsolète ; \"Formulaire actuel\" peut être utilisé comme substitut"
"This variable has been deprecated and can be replaced with \"Current form\"": "La variable a été obsolète ; \"Formulaire actuel\" peut être utilisé comme substitut",
"The value of this variable is derived from the query string of the page URL. This variable can only be used normally when the page has a query string.": "La valeur de cette variable est dérivée de la chaîne de requête de l'URL de la page. Cette variable ne peut être utilisée normalement que lorsque la page a une chaîne de requête.",
"URL search params": "Paramètres de recherche d'URL"
}

View File

@ -697,5 +697,7 @@
"Home page": "ホームページ",
"Handbook": "ユーザーマニュアル",
"License": "ライセンス",
"This variable has been deprecated and can be replaced with \"Current form\"": "この変数は非推奨です。代わりに「現在のフォーム」を使用してください"
"This variable has been deprecated and can be replaced with \"Current form\"": "この変数は非推奨です。代わりに「現在のフォーム」を使用してください",
"The value of this variable is derived from the query string of the page URL. This variable can only be used normally when the page has a query string.": "この変数の値はページURLのクエリ文字列から取得されます。この変数は、ページにクエリ文字列がある場合にのみ正常に使用できます。",
"URL search params": "URL検索パラメータ"
}

View File

@ -869,5 +869,7 @@
"Home page": "홈페이지",
"Handbook": "사용자 매뉴얼",
"License": "라이선스",
"This variable has been deprecated and can be replaced with \"Current form\"": "변수가 폐기되었습니다. \"현재 폼\"을 대체로 사용할 수 있습니다"
"This variable has been deprecated and can be replaced with \"Current form\"": "변수가 폐기되었습니다. \"현재 폼\"을 대체로 사용할 수 있습니다",
"The value of this variable is derived from the query string of the page URL. This variable can only be used normally when the page has a query string.": "이 변수의 값은 페이지 URL의 쿼리 문자열에서 파생됩니다. 이 변수는 페이지에 쿼리 문자열이 있는 경우에만 정상적으로 사용할 수 있습니다.",
"URL search params": "URL 검색 매개변수"
}

View File

@ -734,5 +734,7 @@
"Home page": "Página inicial",
"Handbook": "Manual do usuário",
"License": "Licença",
"This variable has been deprecated and can be replaced with \"Current form\"": "A variável foi descontinuada; \"Formulário atual\" pode ser usada como substituto"
"This variable has been deprecated and can be replaced with \"Current form\"": "A variável foi descontinuada; \"Formulário atual\" pode ser usada como substituto",
"The value of this variable is derived from the query string of the page URL. This variable can only be used normally when the page has a query string.": "O valor desta variável é derivado da string de consulta da URL da página. Esta variável só pode ser usada normalmente quando a página tem uma string de consulta.",
"URL search params": "Parâmetros de pesquisa de URL"
}

View File

@ -572,5 +572,7 @@
"Home page": "Домашняя страница",
"Handbook": "Руководство пользователя",
"License": "Лицензия",
"This variable has been deprecated and can be replaced with \"Current form\"": "Переменная устарела; \"Текущая форма\" может быть использована в качестве замены"
"This variable has been deprecated and can be replaced with \"Current form\"": "Переменная устарела; \"Текущая форма\" может быть использована в качестве замены",
"The value of this variable is derived from the query string of the page URL. This variable can only be used normally when the page has a query string.": "Значение этой переменной происходит из строки запроса URL страницы. Эта переменная может использоваться только в том случае, если у страницы есть строка запроса.",
"URL search params": "Параметры поиска URL"
}

View File

@ -570,5 +570,7 @@
"Home page": "Anasayfa",
"Handbook": "Kullanıcı kılavuzu",
"License": "Lisans",
"This variable has been deprecated and can be replaced with \"Current form\"": "Değişken kullanımdan kaldırıldı; \"Geçerli form\" yerine kullanılabilir"
"This variable has been deprecated and can be replaced with \"Current form\"": "Değişken kullanımdan kaldırıldı; \"Geçerli form\" yerine kullanılabilir",
"The value of this variable is derived from the query string of the page URL. This variable can only be used normally when the page has a query string.": "Bu değişkenin değeri sayfa URL'sinin sorgu dizgisinden türetilir. Bu değişken, sayfanın bir sorgu dizgisi olduğunda yalnızca normal olarak kullanılabilir.",
"URL search params": "URL arama parametreleri"
}

View File

@ -778,5 +778,7 @@
"Home page": "Домашня сторінка",
"Handbook": "Посібник користувача",
"License": "Ліцензія",
"This variable has been deprecated and can be replaced with \"Current form\"": "Змінна була застарілою; \"Поточна форма\" може бути використана як заміна"
"This variable has been deprecated and can be replaced with \"Current form\"": "Змінна була застарілою; \"Поточна форма\" може бути використана як заміна",
"The value of this variable is derived from the query string of the page URL. This variable can only be used normally when the page has a query string.": "Значення цієї змінної походить з рядка запиту URL-адреси сторінки. Цю змінну можна використовувати нормально лише тоді, коли у сторінки є рядок запиту.",
"URL search params": "Параметри пошуку URL"
}

View File

@ -949,5 +949,13 @@
"Fixed": "固定列",
"Set block height": "设置区块高度",
"Specify height": "指定高度",
"Full height": "全高"
"Full height": "全高",
"Please configure the URL": "请配置URL",
"URL": "URL",
"Search parameters": "URL 查询参数",
"Do not concatenate search params in the URL": "查询参数不要在 URL 里拼接",
"The value of this variable is derived from the query string of the page URL. This variable can only be used normally when the page has a query string.": "该变量的值是根据页面 URL 的 query string 得来的,只有当页面存在 query string 的时候,该变量才能正常使用。",
"Edit link":"编辑链接",
"Add parameter":"添加参数",
"URL search params": "URL 查询参数"
}

View File

@ -867,5 +867,7 @@
"Home page": "主頁",
"Handbook": "使用手冊",
"License": "許可證",
"This variable has been deprecated and can be replaced with \"Current form\"": "該變數已被棄用,可以使用“當前表單”作為替代"
"This variable has been deprecated and can be replaced with \"Current form\"": "該變數已被棄用,可以使用“當前表單”作為替代",
"The value of this variable is derived from the query string of the page URL. This variable can only be used normally when the page has a query string.": "該變數的值來自頁面 URL 的查詢字符串,只有當頁面有查詢字符串時,該變數才能正常使用。",
"URL search params": "URL 查詢參數"
}

View File

@ -0,0 +1,67 @@
/**
* 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 { oneEmptyTableWithUsers } from './templates';
test.describe('Link', () => {
test('basic', async ({ page, mockPage, mockRecords }) => {
const nocoPage = await mockPage(oneEmptyTableWithUsers).waitForInit();
const users = await mockRecords('users', 2, 0);
await nocoPage.goto();
// 1. create a new Link button
await page.getByRole('button', { name: 'Actions', exact: true }).hover();
await page.getByLabel('designer-schema-initializer-TableV2.Column-TableV2.ActionColumnDesigner-users').hover();
await page.getByRole('menuitem', { name: 'Link' }).click();
// 2. config the Link button
await page.getByLabel('action-Action.Link-Link-customize:link-users-table-0').hover();
await page.getByRole('button', { name: 'designer-schema-settings-Action.Link-actionSettings:link-users' }).hover();
await page.getByRole('menuitem', { name: 'Edit link' }).click();
await page
.getByLabel('block-item-Variable.TextArea-users-table-URL')
.getByLabel('textbox')
.fill(await nocoPage.getUrl());
await page.getByPlaceholder('Name').fill('id');
await page.getByLabel('block-item-ArrayItems-users-').getByLabel('variable-button').click();
await page.getByRole('menuitemcheckbox', { name: 'Current record right' }).click();
await page.getByRole('menuitemcheckbox', { name: 'ID', exact: true }).click();
await page.getByRole('button', { name: 'plus Add parameter' }).click();
await page.getByPlaceholder('Name').nth(1).fill('name');
await page.getByLabel('variable-button').nth(2).click();
await page.getByRole('menuitemcheckbox', { name: 'Current record right' }).click();
await page.getByRole('menuitemcheckbox', { name: 'Username' }).click();
await page.getByRole('button', { name: 'OK', exact: true }).click();
// 3. click the Link button then config data scope for the Table block using "URL search params" variable
await page.getByLabel('action-Action.Link-Link-customize:link-users-table-0').click();
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.getByText('Add condition', { exact: true }).click();
await page.getByTestId('select-filter-field').click();
await page.getByRole('menuitemcheckbox', { name: 'ID', exact: true }).click();
await page.getByTestId('select-filter-operator').click();
await page.getByRole('option', { name: 'is not' }).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: 'nocobase', exact: true })).not.toBeVisible();
await expect(page.getByRole('button', { name: users[0].username, exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: users[1].username, exact: true })).toBeVisible();
// 4. click the Link buttoncheck the data of the table block
await page.getByLabel('action-Action.Link-Link-customize:link-users-table-0').click();
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();
});
});

View File

@ -0,0 +1,224 @@
/**
* 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 oneEmptyTableWithUsers = {
pageSchema: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Page',
'x-app-version': '1.0.0-alpha.17',
properties: {
hoy3gninc4d: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Grid',
'x-initializer': 'page:addBlock',
'x-app-version': '1.0.0-alpha.17',
properties: {
'6q8k2r2zg8b': {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Grid.Row',
'x-app-version': '1.0.0-alpha.17',
properties: {
'5pelgrmw1uv': {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Grid.Col',
'x-app-version': '1.0.0-alpha.17',
properties: {
jinafp4khmd: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-decorator': 'TableBlockProvider',
'x-acl-action': 'users:list',
'x-use-decorator-props': 'useTableBlockDecoratorProps',
'x-decorator-props': {
collection: 'users',
dataSource: 'main',
action: 'list',
params: {
pageSize: 20,
},
rowKey: 'id',
showIndex: true,
dragSort: false,
},
'x-toolbar': 'BlockSchemaToolbar',
'x-settings': 'blockSettings:table',
'x-component': 'CardItem',
'x-filter-targets': [],
'x-app-version': '1.0.0-alpha.17',
properties: {
actions: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-initializer': 'table:configureActions',
'x-component': 'ActionBar',
'x-component-props': {
style: {
marginBottom: 'var(--nb-spacing)',
},
},
'x-app-version': '1.0.0-alpha.17',
'x-uid': '57i5fodavmy',
'x-async': false,
'x-index': 1,
},
'1c78hfblr62': {
_isJSONSchemaObject: true,
version: '2.0',
type: 'array',
'x-initializer': 'table:configureColumns',
'x-component': 'TableV2',
'x-use-component-props': 'useTableBlockProps',
'x-component-props': {
rowKey: 'id',
rowSelection: {
type: 'checkbox',
},
},
'x-app-version': '1.0.0-alpha.17',
properties: {
actions: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
title: '{{ t("Actions") }}',
'x-action-column': 'actions',
'x-decorator': 'TableV2.Column.ActionBar',
'x-component': 'TableV2.Column',
'x-designer': 'TableV2.ActionColumnDesigner',
'x-initializer': 'table:configureItemActions',
'x-app-version': '1.0.0-alpha.17',
properties: {
sq7wrsmu8k6: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-decorator': 'DndContext',
'x-component': 'Space',
'x-component-props': {
split: '|',
},
'x-app-version': '1.0.0-alpha.17',
'x-uid': '9wjvgdr7qlp',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'c2kd07xlalt',
'x-async': false,
'x-index': 1,
},
kce2z62jj3e: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-decorator': 'TableV2.Column.Decorator',
'x-toolbar': 'TableColumnSchemaToolbar',
'x-settings': 'fieldSettings:TableColumn',
'x-component': 'TableV2.Column',
'x-app-version': '1.0.0-alpha.17',
properties: {
id: {
_isJSONSchemaObject: true,
version: '2.0',
'x-collection-field': 'users.id',
'x-component': 'CollectionField',
'x-component-props': {},
'x-read-pretty': true,
'x-decorator': null,
'x-decorator-props': {
labelStyle: {
display: 'none',
},
},
'x-app-version': '1.0.0-alpha.17',
'x-uid': '5dwr9au92jy',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'd3o6bo2bntr',
'x-async': false,
'x-index': 2,
},
cxivkl2pz2e: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-decorator': 'TableV2.Column.Decorator',
'x-toolbar': 'TableColumnSchemaToolbar',
'x-settings': 'fieldSettings:TableColumn',
'x-component': 'TableV2.Column',
'x-app-version': '1.0.0-alpha.17',
properties: {
username: {
_isJSONSchemaObject: true,
version: '2.0',
'x-collection-field': 'users.username',
'x-component': 'CollectionField',
'x-component-props': {
ellipsis: true,
},
'x-read-pretty': true,
'x-decorator': null,
'x-decorator-props': {
labelStyle: {
display: 'none',
},
},
'x-app-version': '1.0.0-alpha.17',
'x-uid': '6zd6cdztudd',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'bstw44sol77',
'x-async': false,
'x-index': 3,
},
},
'x-uid': 'tesn3o6xlvo',
'x-async': false,
'x-index': 2,
},
},
'x-uid': 'am04rxkwyrn',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'l6m3f6u2opr',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'tgnms5xwgtj',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'cgq9lp3869e',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'py05yxxke4g',
'x-async': true,
'x-index': 1,
},
};

View File

@ -0,0 +1,27 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React from 'react';
import { useSchemaInitializerItem } from '../../../application';
import { BlockInitializer } from '../../../schema-initializer/items';
export const LinkActionInitializer = (props) => {
const schema = {
type: 'void',
title: '{{ t("Link") }}',
'x-action': 'customize:link',
'x-toolbar': 'ActionSchemaToolbar',
'x-settings': 'actionSettings:link',
'x-component': props?.['x-component'] || 'Action.Link',
'x-use-component-props': 'useLinkActionProps',
};
const itemConfig = useSchemaInitializerItem();
return <BlockInitializer {...itemConfig} schema={schema} item={itemConfig} />;
};

View File

@ -0,0 +1,181 @@
/**
* 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 { css } from '@emotion/css';
import { ArrayItems } from '@formily/antd-v5';
import { useField, useFieldSchema } from '@formily/react';
import _ from 'lodash';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useCollectionRecord, useDesignable, useFormBlockContext, useRecord } 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, SchemaSettingsModalItem } from '../../../schema-settings';
import { useVariableOptions } from '../../../schema-settings/VariableInput/hooks/useVariableOptions';
export function SchemaSettingsActionLinkItem() {
const field = useField();
const fieldSchema = useFieldSchema();
const { dn } = useDesignable();
const { t } = useTranslation();
const { form } = useFormBlockContext();
const record = useRecord();
const scope = useVariableOptions({
collectionField: { uiSchema: fieldSchema },
form,
record,
uiSchema: fieldSchema,
noDisabled: true,
});
return (
<SchemaSettingsModalItem
title={t('Edit link')}
components={{ ArrayItems }}
schema={{
type: 'object',
title: t('Edit link'),
properties: {
url: {
title: t('URL'),
type: 'string',
default: fieldSchema?.['x-component-props']?.['url'],
'x-decorator': 'FormItem',
'x-component': 'Variable.TextArea',
'x-component-props': {
scope,
changeOnSelect: true,
},
description: t('Do not concatenate search params in the URL'),
},
params: {
type: 'array',
'x-component': 'ArrayItems',
'x-decorator': 'FormItem',
title: `{{t("Search parameters")}}`,
default: fieldSchema?.['x-component-props']?.params || [{}],
items: {
type: 'object',
properties: {
space: {
type: 'void',
'x-component': 'Space',
'x-component-props': {
style: {
flexWrap: 'nowrap',
maxWidth: '100%',
},
className: css`
& > .ant-space-item:first-child,
& > .ant-space-item:last-child {
flex-shrink: 0;
}
`,
},
properties: {
name: {
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'Input',
'x-component-props': {
placeholder: `{{t("Name")}}`,
},
},
value: {
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'Variable.TextArea',
'x-component-props': {
scope,
placeholder: `{{t("Value")}}`,
useTypedConstant: true,
changeOnSelect: true,
},
},
remove: {
type: 'void',
'x-decorator': 'FormItem',
'x-component': 'ArrayItems.Remove',
},
},
},
},
},
properties: {
add: {
type: 'void',
title: `{{t("Add parameter")}}`,
'x-component': 'ArrayItems.Addition',
},
},
},
},
}}
onSubmit={({ url, params }) => {
const componentProps = fieldSchema['x-component-props'] || {};
componentProps.url = url;
fieldSchema['x-component-props'] = componentProps;
field.componentProps.url = url;
componentProps.params = params;
fieldSchema['x-component-props'] = componentProps;
field.componentProps.params = params;
dn.emit('patch', {
schema: {
['x-uid']: fieldSchema['x-uid'],
'x-component-props': componentProps,
},
});
dn.refresh();
}}
/>
);
}
export const customizeLinkActionSettings = new SchemaSettings({
name: 'actionSettings:link',
items: [
{
name: 'editButton',
Component: ButtonEditor,
useComponentProps() {
const { buttonEditorProps } = useSchemaToolbar();
return buttonEditorProps;
},
},
{
name: 'editLink',
Component: SchemaSettingsActionLinkItem,
},
{
name: 'linkageRules',
Component: SchemaSettingsLinkageRules,
useVisible() {
const record = useCollectionRecord();
return !_.isEmpty(record?.data);
},
useComponentProps() {
const { name } = useCollection_deprecated();
const { linkageRulesProps } = useSchemaToolbar();
return {
...linkageRulesProps,
collectionName: name,
};
},
},
{
name: 'remove',
sort: 100,
Component: RemoveButton as any,
useComponentProps() {
const { removeButtonProps } = useSchemaToolbar();
return removeButtonProps;
},
},
],
});

View File

@ -8,11 +8,11 @@
*/
import {
expect,
expectSettingsMenu,
oneDetailBlockWithM2oFieldToGeneral,
oneEmptyDetailsBlock,
test,
expect,
} from '@nocobase/test/e2e';
import { detailBlockWithLinkageRule, detailsBlockWithLinkageRule } from './templatesOfBug';
@ -54,9 +54,9 @@ test.describe('multi data details block schema settings', () => {
test('multi detail block support linkage rule', async ({ page, mockPage }) => {
const nocoPage = await mockPage(detailsBlockWithLinkageRule).waitForInit();
await nocoPage.goto();
await expect(await page.getByLabel('block-item-CollectionField-roles-details-roles.title')).not.toBeVisible();
await expect(page.getByLabel('block-item-CollectionField-roles-details-roles.title')).not.toBeVisible();
await page.getByRole('button', { name: 'right' }).click();
await expect(await page.getByLabel('block-item-CollectionField-roles-details-roles.title')).toBeVisible();
await expect(page.getByLabel('block-item-CollectionField-roles-details-roles.title')).toBeVisible();
});
});

View File

@ -13,11 +13,13 @@ import { useParsedFilter } from '../../../../../block-provider/hooks/useParsedFi
export const useDetailsWithPaginationBlockParams = (props) => {
const { params } = props;
const { filter } = useParsedFilter({
const { filter, parseVariableLoading } = useParsedFilter({
filterOption: params?.filter,
});
return useMemo(() => {
const result = useMemo(() => {
return { ...params, filter };
}, [JSON.stringify(filter)]);
return { params: result, parseVariableLoading };
};

View File

@ -13,7 +13,7 @@ import { useDetailsWithPaginationBlockParams } from './useDetailsWithPaginationB
export function useDetailsWithPaginationDecoratorProps(props) {
let parentRecord;
const params = useDetailsWithPaginationBlockParams(props);
const { params, parseVariableLoading } = useDetailsWithPaginationBlockParams(props);
// association 的值是固定不变的,所以可以在条件中使用 hooks
if (props.association) {
@ -24,5 +24,9 @@ export function useDetailsWithPaginationDecoratorProps(props) {
return {
parentRecord,
params,
/**
* loading
*/
parseVariableLoading,
};
}

View File

@ -72,6 +72,16 @@ const commonOptions = {
Component: 'CustomRequestInitializer',
useVisible: useVisibleCollection,
},
{
name: 'link',
title: '{{t("Link")}}',
Component: 'LinkActionInitializer',
useComponentProps() {
return {
'x-component': 'Action',
};
},
},
],
};

View File

@ -86,6 +86,16 @@ const commonOptions = {
return (collection.template !== 'view' || collection?.writableView) && collection.template !== 'sql';
},
},
{
name: 'link',
title: '{{t("Link")}}',
Component: 'LinkActionInitializer',
useComponentProps() {
return {
'x-component': 'Action.Link',
};
},
},
],
};

View File

@ -11,7 +11,7 @@ import { useParentRecordCommon } from '../../../useParentRecordCommon';
import { useGridCardBlockParams } from './useGridCardBlockParams';
export function useGridCardBlockDecoratorProps(props) {
const params = useGridCardBlockParams(props);
const { params, parseVariableLoading } = useGridCardBlockParams(props);
let parentRecord;
// 因为 association 是固定的,所以可以在条件中使用 hooks
@ -23,5 +23,7 @@ export function useGridCardBlockDecoratorProps(props) {
return {
params,
parentRecord,
/** 为 true 则表示正在解析 filter 参数中的变量 */
parseVariableLoading,
};
}

View File

@ -7,12 +7,12 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { useParsedFilter } from '../../../../../block-provider/hooks/useParsedFilter';
import { useMemo } from 'react';
import { useParsedFilter } from '../../../../../block-provider/hooks/useParsedFilter';
export function useGridCardBlockParams(props) {
const { params } = props;
const { filter: parsedFilter } = useParsedFilter({
const { filter: parsedFilter, parseVariableLoading } = useParsedFilter({
filterOption: params?.filter,
});
const paramsWithFilter = useMemo(() => {
@ -22,5 +22,5 @@ export function useGridCardBlockParams(props) {
};
}, [parsedFilter, params]);
return paramsWithFilter;
return { params: paramsWithFilter, parseVariableLoading };
}

View File

@ -86,6 +86,16 @@ const commonOptions = {
return (collection.template !== 'view' || collection?.writableView) && collection.template !== 'sql';
},
},
{
name: 'link',
title: '{{t("Link")}}',
Component: 'LinkActionInitializer',
useComponentProps() {
return {
'x-component': 'Action.Link',
};
},
},
],
};

View File

@ -261,6 +261,16 @@ const commonOptions = {
return (collection.template !== 'view' || collection?.writableView) && collection.template !== 'sql';
},
},
{
name: 'link',
title: '{{t("Link")}}',
Component: 'LinkActionInitializer',
useComponentProps() {
return {
'x-component': 'Action.Link',
};
},
},
],
};

View File

@ -67,6 +67,20 @@ const commonOptions = {
'x-align': 'right',
},
},
{
type: 'item',
title: "{{t('Link')}}",
name: 'link',
Component: 'LinkActionInitializer',
schema: {
'x-align': 'right',
},
useComponentProps() {
return {
'x-component': 'Action',
};
},
},
{
name: 'toggle',
title: "{{t('Expand/Collapse')}}",

View File

@ -406,6 +406,8 @@ test.describe('actions schema settings', () => {
.hover();
await page.getByRole('menuitem', { name: 'Assign field values' }).click();
await page.waitForTimeout(1000);
if (!(await page.getByLabel('block-item-AssignedField-').getByRole('textbox').isVisible())) {
await page.getByLabel('schema-initializer-Grid-assignFieldValuesForm:configureFields-users').hover();
await page.getByRole('menuitem', { name: 'Nickname' }).click();

View File

@ -13,22 +13,23 @@ import { useParsedFilter } from '../../../../../block-provider/hooks/useParsedFi
import { useParentRecordCommon } from '../../../useParentRecordCommon';
export const useTableBlockDecoratorProps = (props) => {
const params = useTableBlockParams(props);
const { params, parseVariableLoading } = useTableBlockParams(props);
const parentRecord = useParentRecordCommon(props.association);
return {
params,
parentRecord,
parseVariableLoading,
};
};
export function useTableBlockParams(props) {
const fieldSchema = useFieldSchema();
const { filter: parsedFilter } = useParsedFilter({
const { filter: parsedFilter, parseVariableLoading } = useParsedFilter({
filterOption: props.params?.filter,
});
return useMemo(() => {
const params = useMemo(() => {
const params = props.params || {};
// 1. sort
@ -45,4 +46,6 @@ export function useTableBlockParams(props) {
return paramsWithFilter;
}, [fieldSchema, parsedFilter, props.dragSort, props.params]);
return { params, parseVariableLoading };
}

View File

@ -17,6 +17,7 @@ import { SchemaSettings } from '../../../../application/schema-settings/SchemaSe
import { useCollectionManager_deprecated, useCollection_deprecated } from '../../../../collection-manager';
import { useFieldComponentName } from '../../../../common/useFieldComponentName';
import { EditOperator, useDesignable, useValidateSchema } from '../../../../schema-component';
import { SchemaSettingsDefaultValue } from '../../../../schema-settings/SchemaSettingsDefaultValue';
export const filterFormItemFieldSettings = new SchemaSettings({
name: 'fieldSettings:FilterFormItem',
@ -184,6 +185,10 @@ export const filterFormItemFieldSettings = new SchemaSettings({
};
},
},
{
name: 'setDefaultValue',
Component: SchemaSettingsDefaultValue,
} as any,
{
name: 'setValidationRules',
type: 'modal',

View File

@ -161,6 +161,15 @@ const MenuEditor = (props) => {
return s;
}, [data?.data]);
useEffect(() => {
if (isMatchAdminName) {
const s = findByUid(schema, defaultSelectedUid);
if (s) {
setTitle(s.title);
}
}
}, [defaultSelectedUid, isMatchAdmin, isMatchAdminName, schema, setTitle]);
useRequest(
{
url: 'applicationPlugins:list',

View File

@ -1,15 +1,14 @@
import {
DetailsBlockProvider,
ISchema,
Plugin,
SchemaComponent,
useDetailsPaginationProps,
useDetailsWithPaginationDecoratorProps,
useDetailsWithPaginationProps,
} from '@nocobase/client';
import React from 'react';
import { mockApp } from '@nocobase/client/demo-utils';
import { SchemaComponent, Plugin } from '@nocobase/client';
import React from 'react';
const schema: ISchema = {
type: 'void',
@ -52,28 +51,30 @@ const schema: ISchema = {
},
},
},
}
};
const Demo = () => {
return <SchemaComponent
schema={schema}
scope={{
useDetailsWithPaginationDecoratorProps,
useDetailsWithPaginationProps,
useDetailsPaginationProps,
}}
/>;
return (
<SchemaComponent
schema={schema}
scope={{
useDetailsWithPaginationDecoratorProps,
useDetailsWithPaginationProps,
useDetailsPaginationProps,
}}
/>
);
};
class DemoPlugin extends Plugin {
async load() {
this.app.router.add('root', { path: '/', Component: Demo })
this.app.router.add('root', { path: '/', Component: Demo });
}
}
const app = mockApp({
plugins: [DemoPlugin],
components: { DetailsBlockProvider }
components: { DetailsBlockProvider },
});
export default app.getRootComponent();

View File

@ -1,15 +1,14 @@
import {
DetailsBlockProvider,
ISchema,
Plugin,
SchemaComponent,
useDetailsPaginationProps,
useDetailsWithPaginationDecoratorProps,
useDetailsWithPaginationProps,
} from '@nocobase/client';
import React from 'react';
import { mockApp } from '@nocobase/client/demo-utils';
import { SchemaComponent, Plugin } from '@nocobase/client';
import React from 'react';
const schema: ISchema = {
type: 'void',
@ -47,25 +46,27 @@ const schema: ISchema = {
};
const Demo = () => {
return <SchemaComponent
schema={schema}
scope={{
useDetailsWithPaginationDecoratorProps,
useDetailsWithPaginationProps,
useDetailsPaginationProps,
}}
/>;
return (
<SchemaComponent
schema={schema}
scope={{
useDetailsWithPaginationDecoratorProps,
useDetailsWithPaginationProps,
useDetailsPaginationProps,
}}
/>
);
};
class DemoPlugin extends Plugin {
async load() {
this.app.router.add('root', { path: '/', Component: Demo })
this.app.router.add('root', { path: '/', Component: Demo });
}
}
const app = mockApp({
plugins: [DemoPlugin],
components: { DetailsBlockProvider }
components: { DetailsBlockProvider },
});
export default app.getRootComponent();

View File

@ -13,7 +13,6 @@ import { uid } from '@nocobase/utils/client';
import _ from 'lodash';
import { ActionType } from '../../../schema-settings/LinkageRules/type';
import { VariableOption, VariablesContextType } from '../../../variables/types';
import { REGEX_OF_VARIABLE } from '../../../variables/utils/isVariable';
import { conditionAnalyses } from '../../common/utils/uitls';
interface Props {
@ -138,7 +137,7 @@ export const collectFieldStateOfLinkageRules = ({
}
};
async function replaceVariables(
export async function replaceVariables(
value: string,
{
variables,
@ -155,6 +154,7 @@ async function replaceVariables(
return;
}
const REGEX_OF_VARIABLE = /{\{\s*([a-zA-Z0-9_$-.]+?)\s*\}\}/g;
const waitForParsing = value.match(REGEX_OF_VARIABLE)?.map(async (item) => {
const result = await variables.parseVariable(item, localVariables);
@ -169,7 +169,6 @@ async function replaceVariables(
if (waitForParsing) {
await Promise.all(waitForParsing);
}
return {
exp: value.replace(REGEX_OF_VARIABLE, (match) => {
return `{{${store[match] || match}}}`;

View File

@ -62,7 +62,7 @@ const useCompatGridCardBlockParams = (props) => {
// 因为 x-use-decorator-props 的值是固定的,所以可以在条件中使用 hooks
if (schema['x-use-decorator-props']) {
return props.params;
return { params: props.params, parseVariableLoading: props.parseVariableLoading };
} else {
// eslint-disable-next-line react-hooks/rules-of-hooks
return useGridCardBlockParams(props);
@ -70,7 +70,11 @@ const useCompatGridCardBlockParams = (props) => {
};
export const GridCardBlockProvider = withDynamicSchemaProps((props) => {
const params = useCompatGridCardBlockParams(props);
const { params, parseVariableLoading } = useCompatGridCardBlockParams(props);
if (parseVariableLoading) {
return null;
}
return (
<BlockProvider name="grid-card" {...props} params={params}>

View File

@ -65,7 +65,7 @@ const InternalListBlockProvider = (props) => {
export const ListBlockProvider = withDynamicSchemaProps((props) => {
const { params } = props;
const { filter: parsedFilter } = useParsedFilter({
const { filter: parsedFilter, parseVariableLoading } = useParsedFilter({
filterOption: params?.filter,
});
const paramsWithFilter = useMemo(() => {
@ -77,7 +77,7 @@ export const ListBlockProvider = withDynamicSchemaProps((props) => {
// parse filter 的过程是异步的,且一开始 parsedFilter 是一个空对象,所以当 parsedFilter 为空 params.filter 不为空时,
// 说明 filter 还未解析完成,此时不应该渲染,防止重复请求多次
if (_.isEmpty(parsedFilter) && !_.isEmpty(params?.filter)) {
if ((_.isEmpty(parsedFilter) && !_.isEmpty(params?.filter)) || parseVariableLoading) {
return null;
}

View File

@ -285,8 +285,9 @@ const SideMenu = ({
mode,
sideMenuSchema,
sideMenuRef,
defaultOpenKeys,
defaultSelectedKeys,
openKeys,
setOpenKeys,
selectedKeys,
onSelect,
render,
t,
@ -344,11 +345,14 @@ const SideMenu = ({
<Component />
<AntdMenu
mode={'inline'}
defaultOpenKeys={defaultOpenKeys}
defaultSelectedKeys={defaultSelectedKeys}
openKeys={openKeys}
selectedKeys={selectedKeys}
onSelect={(info) => {
onSelect?.(info);
}}
onOpenChange={(openKeys) => {
setOpenKeys(openKeys);
}}
className={sideMenuClass}
items={items as MenuProps['items']}
/>
@ -474,8 +478,9 @@ export const Menu: ComposedMenu = observer(
mode={mode}
sideMenuSchema={sideMenuSchema}
sideMenuRef={sideMenuRef}
defaultOpenKeys={defaultOpenKeys}
defaultSelectedKeys={defaultSelectedKeys}
openKeys={defaultOpenKeys}
setOpenKeys={setDefaultOpenKeys}
selectedKeys={selectedKeys}
onSelect={onSelect}
render={render}
t={t}

View File

@ -17,7 +17,7 @@ import { useAPIClient } from '../../../api-client';
import { useFormBlockContext, useTableBlockContext } from '../../../block-provider';
import { useCollectionManager_deprecated, useCollection_deprecated } from '../../../collection-manager';
import { useSortFields } from '../../../collection-manager/action-hooks';
import { FilterBlockType, mergeFilter } from '../../../filter-provider/utils';
import { FilterBlockType } from '../../../filter-provider/utils';
import { SetDataLoadingMode } from '../../../modules/blocks/data-blocks/details-multi/setDataLoadingModeSettingsItem';
import {
GeneralSchemaDesigner,
@ -113,17 +113,14 @@ export const TableBlockDesigner = () => {
params.filter = filter;
field.decoratorProps.params = params;
fieldSchema['x-decorator-props']['params'] = params;
const filters = service.params?.[1]?.filters || {};
service.run(
{ ...service.params?.[0], filter: mergeFilter([...Object.values(filters), filter]), page: 1 },
{ filters },
);
dn.emit('patch', {
schema: {
['x-uid']: fieldSchema['x-uid'],
'x-decorator-props': fieldSchema['x-decorator-props'],
},
});
service.params[0].page = 1;
},
[dn, field.decoratorProps, fieldSchema, service],
);

View File

@ -221,7 +221,16 @@ export function Input(props: VariableInputProps) {
const loadData = async (selectedOptions: DefaultOptionType[]) => {
const option = selectedOptions[selectedOptions.length - 1];
if (!option.children?.length && !option.isLeaf && option.loadChildren) {
await option.loadChildren(option);
let activeKey;
if (variable && variable.length >= 2) {
for (const key of variable) {
if (key === option[names.value]) {
activeKey = key;
break;
}
}
}
await option.loadChildren(option, activeKey, variable);
setOptions((prev) => [...prev]);
}
};
@ -260,7 +269,7 @@ export function Input(props: VariableInputProps) {
prevOption = options.find((item) => item[names.value] === key);
} else {
if (prevOption.loadChildren && !prevOption.children?.length) {
await prevOption.loadChildren(prevOption);
await prevOption.loadChildren(prevOption, key, variable);
}
prevOption = prevOption.children.find((item) => item[names.value] === key);
}

View File

@ -443,13 +443,7 @@ export function TextArea(props) {
dangerouslySetInnerHTML={{ __html: html }}
/>
{!disabled ? (
<VariableSelect
className=""
options={options}
setOptions={setOptions}
onInsert={onInsert}
changeOnSelect={changeOnSelect}
/>
<VariableSelect options={options} setOptions={setOptions} onInsert={onInsert} changeOnSelect={changeOnSelect} />
) : null}
</Space.Compact>,
);
@ -477,7 +471,7 @@ async function preloadOptions(scope, value: string) {
prevOption = options.find((item) => item.value === key);
} else {
if (prevOption.loadChildren && !prevOption.children?.length) {
await prevOption.loadChildren(prevOption);
await prevOption.loadChildren(prevOption, key, keys);
}
prevOption = prevOption.children.find((item) => item.value === key);
}

View File

@ -22,6 +22,13 @@ export function VariableSelect({
changeOnSelect = false,
fieldNames = {},
className,
}: {
options: any[];
setOptions: (options: any) => void;
onInsert: (keyPaths: string[]) => void;
changeOnSelect?: boolean;
fieldNames?: any;
className?: string;
}): JSX.Element {
const { t } = useTranslation();
const [selectedVar, setSelectedVar] = useState<string[]>([]);
@ -50,7 +57,7 @@ export function VariableSelect({
return;
}
const option = selectedOptions[selectedOptions.length - 1];
if (!option?.children?.length) {
if ((!option?.children?.length && !option?.loadChildren) || option?.isLeaf) {
onInsert(keyPaths);
}
}}

View File

@ -28,6 +28,8 @@ import { CreateSubmitActionInitializer } from '../modules/actions/submit/CreateS
import { UpdateSubmitActionInitializer } from '../modules/actions/submit/UpdateSubmitActionInitializer';
import { UpdateRecordActionInitializer } from '../modules/actions/update-record/UpdateRecordActionInitializer';
import { PopupActionInitializer } from '../modules/actions/view-edit-popup/PopupActionInitializer';
import { LinkActionInitializer } from '../modules/actions/link/LinkActionInitializer';
import { recordFormBlockInitializers } from '../modules/actions/view-edit-popup/RecordFormBlockInitializers';
import { UpdateActionInitializer } from '../modules/actions/view-edit-popup/UpdateActionInitializer';
import { ViewActionInitializer } from '../modules/actions/view-edit-popup/ViewActionInitializer';
@ -171,6 +173,7 @@ export class SchemaInitializerPlugin extends Plugin {
ViewActionInitializer,
UpdateActionInitializer,
PopupActionInitializer,
LinkActionInitializer,
UpdateRecordActionInitializer,
CreateSubmitActionInitializer,
UpdateSubmitActionInitializer,

View File

@ -22,6 +22,8 @@ import { createSubmitActionSettings } from '../modules/actions/submit/createSubm
import { submitActionSettings, updateSubmitActionSettings } from '../modules/actions/submit/updateSubmitActionSettings';
import { customizeUpdateRecordActionSettings } from '../modules/actions/update-record/customizeUpdateRecordActionSettings';
import { customizePopupActionSettings } from '../modules/actions/view-edit-popup/customizePopupActionSettings';
import { customizeLinkActionSettings } from '../modules/actions/link/customizeLinkActionSettings';
import { editActionSettings } from '../modules/actions/view-edit-popup/editActionSettings';
import { viewActionSettings } from '../modules/actions/view-edit-popup/viewActionSettings';
import {
@ -88,6 +90,7 @@ export class SchemaSettingsPlugin extends Plugin {
this.schemaSettingsManager.add(bulkDeleteActionSettings);
this.schemaSettingsManager.add(customizeAddRecordActionSettings);
this.schemaSettingsManager.add(customizePopupActionSettings);
this.schemaSettingsManager.add(customizeLinkActionSettings);
this.schemaSettingsManager.add(customizeUpdateRecordActionSettings);
this.schemaSettingsManager.add(createSubmitActionSettings);
this.schemaSettingsManager.add(updateSubmitActionSettings);

View File

@ -197,12 +197,12 @@ export const getShouldChange = ({
if (['o2o', 'o2m', 'oho'].includes(collectionFieldOfVariable?.interface)) {
return false;
}
if (!collectionField.target && collectionFieldOfVariable?.target) {
return false;
}
if (collectionField.target && !collectionFieldOfVariable?.target) {
return false;
}
// if (!collectionField.target && collectionFieldOfVariable?.target) {
// return false;
// }
// if (collectionField.target && !collectionFieldOfVariable?.target) {
// return false;
// }
if (
collectionField.target &&
collectionFieldOfVariable?.target &&

View File

@ -0,0 +1,98 @@
/**
* 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 { isObservable } from '@formily/reactive';
import { renderHook } from '@nocobase/test/client';
import {
getURLSearchParams,
getURLSearchParamsChildren,
useURLSearchParamsCtx,
} from '../../hooks/useURLSearchParamsVariable';
test('getURLSearchParamsChildren should return an array of options with expected properties', () => {
const queryParams = {
param1: 'value1',
param2: 'value2',
param3: 'value3',
};
const result = getURLSearchParamsChildren(queryParams);
expect(result).toEqual([
{
label: 'param1',
value: 'param1',
key: 'param1',
isLeaf: true,
},
{
label: 'param2',
value: 'param2',
key: 'param2',
isLeaf: true,
},
{
label: 'param3',
value: 'param3',
key: 'param3',
isLeaf: true,
},
]);
});
test('getURLSearchParams should parse search string and return params object', () => {
const search = '?param1=value1&param2=value2&param3=value3';
const expectedParams = {
param1: 'value1',
param2: 'value2',
param3: 'value3',
};
const result = getURLSearchParams(search);
expect(result).toEqual(expectedParams);
});
test('useURLSearchParamsCtx should return the parsed search params object', () => {
const search = '?param1=value1&param2=value2&param3=value3';
const { result } = renderHook(() => useURLSearchParamsCtx(search));
expect(result.current).toEqual({
param1: 'value1',
param2: 'value2',
param3: 'value3',
});
expect(isObservable(result.current)).toBe(true);
});
test('useURLSearchParamsCtx should update the parsed search params object when search value changes', () => {
const { result, rerender } = renderHook(({ search }) => useURLSearchParamsCtx(search), {
initialProps: {
search: '?param1=value1&param2=value2&param3=value3',
},
});
expect(result.current).toEqual({
param1: 'value1',
param2: 'value2',
param3: 'value3',
});
rerender({
search: '?param1=newValue1&param2=newValue2',
});
expect(result.current).toEqual({
param1: 'newValue1',
param2: 'newValue2',
});
rerender({
search: '',
});
expect(result.current).toEqual({});
});

View File

@ -36,7 +36,14 @@ interface GetOptionsParams {
* 使
*/
noDisabled?: boolean;
loadChildren?: (option: Option) => Promise<void>;
/**
* children
* @param option children
* @param activeKey key
* @param variablePath ['$user', 'nickname']
* @returns
*/
loadChildren?: (option: Option, activeKey?: string, variablePath?: string[]) => Promise<void>;
compile: (value: string) => any;
isDisabled?: (params: IsDisabledParams) => boolean;
getCollectionField?: (name: string) => CollectionFieldOptions_deprecated;
@ -153,6 +160,28 @@ const getChildren = (
return result;
};
export const getLabelWithTooltip = (title: string, tooltip?: string) => {
return tooltip ? (
<Tooltip placement="left" title={tooltip} zIndex={9999}>
<span
style={{
position: 'relative',
display: 'inline-block',
marginLeft: -14,
paddingLeft: 14,
marginRight: -80,
paddingRight: 80,
zIndex: 1,
}}
>
{title}
</span>
</Tooltip>
) : (
title
);
};
export const useBaseVariable = ({
collectionField,
uiSchema,
@ -234,25 +263,7 @@ export const useBaseVariable = ({
const result = useMemo(() => {
return {
label: tooltip ? (
<Tooltip placement="left" title={tooltip} zIndex={9999}>
<span
style={{
position: 'relative',
display: 'inline-block',
marginLeft: -14,
paddingLeft: 14,
marginRight: -80,
paddingRight: 80,
zIndex: 1,
}}
>
{title}
</span>
</Tooltip>
) : (
title
),
label: getLabelWithTooltip(title, tooltip),
value: name,
key: name,
isLeaf: noChildren,

View File

@ -0,0 +1,104 @@
/**
* 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 { observable, untracked } from '@formily/reactive';
import _ from 'lodash';
import qs from 'qs';
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useLocation } from 'react-router-dom';
import { useFlag } from '../../../flag-provider/hooks/useFlag';
import { Option } from '../type';
import { getLabelWithTooltip } from './useBaseVariable';
export const getURLSearchParams = (search: string) => {
if (search.startsWith('?')) {
search = search.slice(1);
}
const params = qs.parse(search);
return params || {};
};
export const getURLSearchParamsChildren = (queryParams: Record<string, any>): Option[] => {
return Object.keys(queryParams).map((key) => {
return {
label: key,
value: key,
key,
isLeaf: true,
};
});
};
export const useURLSearchParamsCtx = (search: string) => {
// 使用响应式对象,目的是为了在变量值变化时,能够触发重新解析变量值
const [_urlSearchParamsCtx] = useState(() => observable({}));
return useMemo(() => {
const newValue = getURLSearchParams(search);
untracked(() => {
Object.assign(_urlSearchParamsCtx, newValue);
Object.keys(_urlSearchParamsCtx).forEach((key) => {
if (newValue[key] === undefined) {
delete _urlSearchParamsCtx[key];
}
});
});
return _urlSearchParamsCtx;
}, [_urlSearchParamsCtx, search]);
};
/**
* `URL search params`
* @param props
* @returns
*/
export const useURLSearchParamsVariable = (props: any = {}) => {
const variableName = '$nURLSearchParams';
const { t } = useTranslation();
const location = useLocation();
const { isVariableParsedInOtherContext } = useFlag();
const urlSearchParamsCtx = useURLSearchParamsCtx(location.search);
const disabled = useMemo(() => _.isEmpty(urlSearchParamsCtx), [urlSearchParamsCtx]);
const urlSearchParamsSettings: Option = useMemo(() => {
return {
label: getLabelWithTooltip(
t('URL search params'),
disabled
? t(
'The value of this variable is derived from the query string of the page URL. This variable can only be used normally when the page has a query string.',
)
: '',
),
value: variableName,
key: variableName,
isLeaf: false,
disabled,
loadChildren: async (option, activeKey) => {
const activeSettings = activeKey
? {
[activeKey]: undefined,
}
: {};
option.children = getURLSearchParamsChildren({ ...activeSettings, ...urlSearchParamsCtx });
},
};
}, [disabled, t, urlSearchParamsCtx]);
return {
name: variableName,
/** 变量配置 */
urlSearchParamsSettings,
/** 变量值 */
urlSearchParamsCtx,
shouldDisplay: !isVariableParsedInOtherContext,
};
};

View File

@ -18,6 +18,7 @@ import { useCurrentParentRecordVariable } from './useParentRecordVariable';
import { usePopupVariable } from './usePopupVariable';
import { useCurrentRecordVariable } from './useRecordVariable';
import { useCurrentRoleVariable } from './useRoleVariable';
import { useURLSearchParamsVariable } from './useURLSearchParamsVariable';
import { useCurrentUserVariable } from './useUserVariable';
interface Props {
@ -102,6 +103,7 @@ export const useVariableOptions = ({
noDisabled,
targetFieldSchema,
});
const { urlSearchParamsSettings, shouldDisplay: shouldDisplayURLSearchParams } = useURLSearchParamsVariable();
return useMemo(() => {
return [
@ -113,6 +115,7 @@ export const useVariableOptions = ({
shouldDisplayCurrentRecord && currentRecordSettings,
shouldDisplayCurrentParentRecord && currentParentRecordSettings,
shouldDisplayPopupRecord && popupRecordSettings,
shouldDisplayURLSearchParams && urlSearchParamsSettings,
].filter(Boolean);
}, [
currentUserSettings,
@ -128,5 +131,7 @@ export const useVariableOptions = ({
currentParentRecordSettings,
shouldDisplayPopupRecord,
popupRecordSettings,
shouldDisplayURLSearchParams,
urlSearchParamsSettings,
]);
};

View File

@ -19,8 +19,14 @@ export interface Option extends DefaultOptionType {
// 标记是否为叶子节点,设置了 `loadData` 时有效
// 设为 `false` 时会强制标记为父节点,即使当前节点没有 children也会显示展开图标
isLeaf?: boolean;
/** 当开启异步加载时有效,用于加载当前 node 的 children */
loadChildren?(option: Option): Promise<void>;
/**
* node children
* @param option children
* @param activeKey key
* @param variablePath ['$user', 'nickname']
* @returns
*/
loadChildren?: (option: Option, activeKey?: string, variablePath?: string[]) => Promise<void>;
/** 变量中的字段 */
field?: FieldOption;
depth?: number;

View File

@ -63,7 +63,7 @@ const useParseDataScopeFilter = ({ exclude = defaultExclude }: Props = {}) => {
Object.keys(flat).map(async (key) => {
flat[key] = await flat[key];
if (flat[key] === undefined) {
flat[key] = null;
delete flat[key];
}
return flat[key];
}),

View File

@ -10,6 +10,7 @@
import { SchemaExpressionScopeContext, SchemaOptionsContext } from '@formily/react';
import { act, renderHook, waitFor } from '@nocobase/test/client';
import React from 'react';
import { Router } from 'react-router';
import { APIClientProvider } from '../../api-client';
import { mockAPIClient } from '../../testUtils';
import { CurrentUserProvider } from '../../user';
@ -142,15 +143,17 @@ mockRequest.onGet('/someBelongsToField/0/belongsToField:get').reply(() => {
const Providers = ({ children }) => {
return (
<APIClientProvider apiClient={apiClient}>
<CurrentUserProvider>
<SchemaOptionsContext.Provider value={{}}>
<SchemaExpressionScopeContext.Provider value={{}}>
<VariablesProvider>{children}</VariablesProvider>
</SchemaExpressionScopeContext.Provider>
</SchemaOptionsContext.Provider>
</CurrentUserProvider>
</APIClientProvider>
<Router location={window.location} navigator={null}>
<APIClientProvider apiClient={apiClient}>
<CurrentUserProvider>
<SchemaOptionsContext.Provider value={{}}>
<SchemaExpressionScopeContext.Provider value={{}}>
<VariablesProvider>{children}</VariablesProvider>
</SchemaExpressionScopeContext.Provider>
</SchemaOptionsContext.Provider>
</CurrentUserProvider>
</APIClientProvider>
</Router>
);
};
@ -218,6 +221,7 @@ describe('useVariables', () => {
"yesterday": [Function],
},
"$nRole": "root",
"$nURLSearchParams": {},
"$system": {
"now": [Function],
},
@ -429,6 +433,7 @@ describe('useVariables', () => {
"yesterday": [Function],
},
"$nRole": "root",
"$nURLSearchParams": {},
"$system": {
"now": [Function],
},
@ -513,6 +518,7 @@ describe('useVariables', () => {
"yesterday": [Function],
},
"$nRole": "root",
"$nURLSearchParams": {},
"$new": {
"name": "new variable",
},

View File

@ -36,3 +36,7 @@ it('should return true for a string with a valid variable "{{ $nRecord._name }}"
it('should return true for a string with a valid variable " {{ $nRecord.name }} "', () => {
expect(isVariable(' {{ $nRecord.name }} ')).toBe(true);
});
it('should return false for a string with a valid variable "{{ $nRecord.name }}+1"', () => {
expect(isVariable('{{ $nRecord.name }}+1')).toBe(false);
});

View File

@ -7,4 +7,4 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
export const DEBOUNCE_WAIT = 300;
export const DEBOUNCE_WAIT = 100;

View File

@ -12,12 +12,18 @@ import { useMemo } from 'react';
import { DEFAULT_DATA_SOURCE_KEY } from '../../data-source/data-source/DataSourceManager';
import { useCurrentUserVariable, useDatetimeVariable } from '../../schema-settings';
import { useCurrentRoleVariable } from '../../schema-settings/VariableInput/hooks/useRoleVariable';
import { useURLSearchParamsVariable } from '../../schema-settings/VariableInput/hooks/useURLSearchParamsVariable';
import { VariableOption } from '../types';
/**
*
* @returns
*/
const useBuiltInVariables = () => {
const { currentUserCtx } = useCurrentUserVariable();
const { currentRoleCtx } = useCurrentRoleVariable();
const { datetimeCtx } = useDatetimeVariable();
const { urlSearchParamsCtx, name: urlSearchParamsName } = useURLSearchParamsVariable();
const builtinVariables: VariableOption[] = useMemo(() => {
return [
{
@ -71,8 +77,12 @@ const useBuiltInVariables = () => {
name: 'currentTime',
ctx: () => dayjs().toISOString(),
},
{
name: urlSearchParamsName,
ctx: urlSearchParamsCtx,
},
];
}, [currentRoleCtx, currentUserCtx, datetimeCtx]);
}, [currentRoleCtx, currentUserCtx, datetimeCtx, urlSearchParamsCtx, urlSearchParamsName]);
return { builtinVariables };
};

View File

@ -7,7 +7,7 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
export const REGEX_OF_VARIABLE = /\{\{\s*([a-zA-Z0-9_$-.]+?)\s*\}\}/g;
export const REGEX_OF_VARIABLE = /^\s*\{\{\s*([a-zA-Z0-9_$-.]+?)\s*\}\}\s*$/g;
export const isVariable = (str: unknown) => {
if (typeof str !== 'string') {

View File

@ -7,8 +7,8 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { evaluate } from '../../utils';
import evaluators from '..';
import { evaluate } from '../../utils';
describe('evaluate', () => {
describe('pre-process', () => {

View File

@ -8,22 +8,22 @@
*/
import { useField, useFieldSchema } from '@formily/react';
import React from 'react';
import {
SchemaSettings,
SchemaSettingsBlockTitleItem,
SchemaSettingsSelectItem,
useCollection,
SchemaSettingsSwitchItem,
SchemaSettingsDataScope,
useDesignable,
SchemaSettingsCascaderItem,
useFormBlockContext,
removeNullCondition,
SchemaSettingsTemplate,
useCollectionManager_deprecated,
SchemaSettingsBlockHeightItem,
SchemaSettingsBlockTitleItem,
SchemaSettingsCascaderItem,
SchemaSettingsDataScope,
SchemaSettingsSelectItem,
SchemaSettingsSwitchItem,
SchemaSettingsTemplate,
removeNullCondition,
useCollection,
useCollectionManager_deprecated,
useDesignable,
useFormBlockContext,
} from '@nocobase/client';
import React from 'react';
import { useTranslation } from '../../locale';
import { useCalendarBlockContext } from '../schema-initializer/CalendarBlockProvider';
@ -181,7 +181,6 @@ export const calendarBlockSettings = new SchemaSettings({
const fieldSchema = useFieldSchema();
const { form } = useFormBlockContext();
const field = useField();
const { service } = useCalendarBlockContext();
const { dn } = useDesignable();
return {
collectionName: name,
@ -193,7 +192,6 @@ export const calendarBlockSettings = new SchemaSettings({
params.filter = filter;
field.decoratorProps.params = params;
fieldSchema['x-decorator-props']['params'] = params;
service.run({ ...service?.params?.[0], filter });
dn.emit('patch', {
schema: {
['x-uid']: fieldSchema['x-uid'],

View File

@ -11,7 +11,7 @@ import { useParentRecordCommon } from '@nocobase/client';
import { useCalendarBlockParams } from './useCalendarBlockParams';
export function useCalendarBlockDecoratorProps(props) {
const params = useCalendarBlockParams(props);
const { params, parseVariableLoading } = useCalendarBlockParams(props);
let parentRecord;
// 因为 association 是一个固定的值,所以可以在 hooks 中直接使用
@ -23,5 +23,9 @@ export function useCalendarBlockDecoratorProps(props) {
return {
params,
parentRecord,
/**
* true filter
*/
parseVariableLoading,
};
}

View File

@ -11,7 +11,7 @@ import { useParsedFilter } from '@nocobase/client';
import { useMemo } from 'react';
export function useCalendarBlockParams(props) {
const { filter } = useParsedFilter({
const { filter, parseVariableLoading } = useParsedFilter({
filterOption: props.params?.filter,
});
const appends = useMemo(() => {
@ -29,7 +29,9 @@ export function useCalendarBlockParams(props) {
return arr;
}, [props.fieldNames]);
return useMemo(() => {
const params = useMemo(() => {
return { ...props.params, appends: [...appends, ...(props.params.appends || [])], paginate: false, filter };
}, [appends, JSON.stringify(filter), props.params]);
return { params, parseVariableLoading };
}

View File

@ -44,7 +44,7 @@ const useCompatCalendarBlockParams = (props) => {
// 因为 x-use-decorator-props 的值是固定不变的,所以可以在条件中使用 hooks
if (fieldSchema['x-use-decorator-props']) {
return props.params;
return { params: props.params, parseVariableLoading: props.parseVariableLoading };
} else {
// eslint-disable-next-line react-hooks/rules-of-hooks
return useCalendarBlockParams(props);
@ -53,7 +53,12 @@ const useCompatCalendarBlockParams = (props) => {
export const CalendarBlockProvider = withDynamicSchemaProps(
(props) => {
const params = useCompatCalendarBlockParams(props);
const { params, parseVariableLoading } = useCompatCalendarBlockParams(props);
if (parseVariableLoading) {
return null;
}
return (
<BlockProvider name="calendar" {...props} params={params}>
<InternalCalendarBlockProvider {...props} />

View File

@ -7,3 +7,35 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { expectSettingsMenu, test } from '@nocobase/test/e2e';
test.describe('form item & filter form', () => {
test('supported options', async ({ page, mockPage }) => {
await mockPage().goto();
// 在页面上创建一个筛选表单,并在表单中添加一个字段
await page.getByLabel('schema-initializer-Grid-page:').hover();
await page.getByRole('menuitem', { name: 'form Form right' }).nth(1).click();
await page.getByRole('menuitem', { name: 'Users' }).click();
await page.getByLabel('schema-initializer-Grid-filterForm:configureFields-users').hover();
await page.getByRole('menuitem', { name: 'Nickname' }).click();
await expectSettingsMenu({
page,
showMenu: async () => {
await page.getByLabel('block-item-CollectionField-').hover();
await page.getByLabel('designer-schema-settings-CollectionField-fieldSettings:FilterFormItem-users-').hover();
},
supportedOptions: [
'Edit field title',
'Display title',
'Edit description',
'Edit tooltip',
'Operator',
'Set validation rules',
'Set default value',
'Delete',
],
});
});
});

View File

@ -8,7 +8,13 @@
*/
import { useField, useFieldSchema } from '@formily/react';
import { BlockProvider, FixedBlockWrapper, SchemaComponentOptions, useBlockRequestContext } from '@nocobase/client';
import {
BlockProvider,
FixedBlockWrapper,
SchemaComponentOptions,
useBlockRequestContext,
useParsedFilter,
} from '@nocobase/client';
import React, { createContext, useContext, useState } from 'react';
import { css } from '@emotion/css';
import { theme } from 'antd';
@ -54,11 +60,33 @@ const InternalMapBlockProvider = (props) => {
);
};
const useMapBlockParams = (params: Record<string, any>) => {
const { filter: parsedFilter, parseVariableLoading } = useParsedFilter({
filterOption: params?.filter,
});
return {
params: {
...params,
filter: parsedFilter,
} as Record<string, any>,
parseVariableLoading,
};
};
export const MapBlockProvider = (props) => {
const uField = useField();
const { params, fieldNames } = props;
const { fieldNames } = props;
const { params, parseVariableLoading } = useMapBlockParams(props.params);
// 在解析变量的时候不渲染,避免因为重复请求数据导致的资源浪费
if (parseVariableLoading) {
return null;
}
const appends = params.appends || [];
const { field } = fieldNames || {};
if (Array.isArray(field) && field.length > 1) {
appends.push(field[0]);
}

View File

@ -202,7 +202,7 @@ test('Collection event Add Data Trigger, determines that the trigger node single
await page.getByRole('menuitemcheckbox', { name: triggerNodeFieldDisplayName }).click();
const conditionalRightConstant = faker.lorem.words();
await page.waitForTimeout(500);
await page.keyboard.type(`=='${conditionalRightConstant}'`, { delay: 50 });
await page.keyboard.type(`=='${conditionalRightConstant}'`, { delay: 100 });
await expect(conditionNode.conditionExpressionEditBox).toHaveText(
`Trigger variables / Trigger data / ${triggerNodeFieldDisplayName}=='${conditionalRightConstant}'`,
);