From e27cff15c98be2635194337064b19b254690fff7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=A2=AB=E9=9B=A8=E6=B0=B4=E8=BF=87=E6=BB=A4=E7=9A=84?= =?UTF-8?q?=E7=A9=BA=E6=B0=94-Rairn?= <958414905@qq.com> Date: Sun, 21 May 2023 17:18:35 +0800 Subject: [PATCH] feat: support to-multi field variables (#1680) * feat: support to parse association variable * chore: change comment * feat(operators): support array as value in string operator * refactor: add a special marker * feat: date support to-multi field variables * feat: support for notIn * refactor: rename * test: fix test error * Revert "test: fix test error" This reverts commit 3d139698f6295678a39b77b08c88427f9fafe247. * Revert "refactor: rename" This reverts commit 2e16225c038e18ee25f1136d510cb4746bd9932f. * Revert "feat: support for notIn" This reverts commit 2087e5c4da1429b260890d5136c714ad541955a9. * Revert "feat: date support to-multi field variables" This reverts commit 0d7b2db0512ba7b632a2ab61f37cf83c0d06b9a1. * Revert "refactor: add a special marker" This reverts commit 7ba2e4bc00d79f60a2e90340df65b2938985750a. * Revert "feat(operators): support array as value in string operator" This reverts commit 0897cd19e9863248ef9e2af6c91ee14e805778a4. * refactor: improve code * test: fix can not import style file * feat: only eq and ne oprators support mutil relation fields * test: add example.test.ts * refactor: remove jsonata * Revert "test: add example.test.ts" This reverts commit 0ad2ea458cd8a964891490d2021a5b000f391395. * test: fix error * fix: fix error * refactor: remove async * chore: rebase * test: fix error * test: fix errors --- __mocks__/styleMock.js | 1 + jest.config.js | 9 ++- .../core/client/src/filter-provider/utils.ts | 2 +- .../antd/table-v2/hooks/useUserVariable.ts | 74 +++++++++++++++++++ .../VariableInput/hooks/useUserVariable.ts | 2 +- packages/core/database/src/operators/notIn.ts | 1 + .../core/database/src/operators/string.ts | 2 +- .../src/__tests__/getValuesByPath.test.ts | 12 +-- .../utils/src/__tests__/parse-filter.test.ts | 18 +++++ packages/core/utils/src/getValuesByPath.ts | 15 ++-- packages/core/utils/src/parse-filter.ts | 13 +++- 11 files changed, 128 insertions(+), 21 deletions(-) create mode 100644 __mocks__/styleMock.js create mode 100644 packages/core/client/src/schema-component/antd/table-v2/hooks/useUserVariable.ts diff --git a/__mocks__/styleMock.js b/__mocks__/styleMock.js new file mode 100644 index 000000000..f053ebf79 --- /dev/null +++ b/__mocks__/styleMock.js @@ -0,0 +1 @@ +module.exports = {}; diff --git a/jest.config.js b/jest.config.js index eaedf6a98..3a13640d4 100644 --- a/jest.config.js +++ b/jest.config.js @@ -11,9 +11,12 @@ module.exports = { testMatch: ['**/__tests__/**/*.test.[jt]s?(x)'], setupFiles: ['./jest.setup.ts'], setupFilesAfterEnv: [require.resolve('jest-dom/extend-expect'), './jest.setupAfterEnv.ts'], - moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths, { - prefix: '/', - }), + moduleNameMapper: { + ...pathsToModuleNameMapper(compilerOptions.paths, { + prefix: '/', + }), + '\\.(css|less)$': '/__mocks__/styleMock.js', + }, globals: { 'ts-jest': { babelConfig: false, diff --git a/packages/core/client/src/filter-provider/utils.ts b/packages/core/client/src/filter-provider/utils.ts index bec974c81..e1b5c2a62 100644 --- a/packages/core/client/src/filter-provider/utils.ts +++ b/packages/core/client/src/filter-provider/utils.ts @@ -80,7 +80,7 @@ export const transformToFilter = ( key = `${key}.${collectionField.targetKey || 'id'}`; } - if (!value || value.length === 0) { + if (!value) { return null; } diff --git a/packages/core/client/src/schema-component/antd/table-v2/hooks/useUserVariable.ts b/packages/core/client/src/schema-component/antd/table-v2/hooks/useUserVariable.ts new file mode 100644 index 000000000..be7e17b3e --- /dev/null +++ b/packages/core/client/src/schema-component/antd/table-v2/hooks/useUserVariable.ts @@ -0,0 +1,74 @@ +import { CollectionFieldOptions } from '../../../../collection-manager'; +import { useFilterOptions } from '../../filter'; + +interface Operator { + label: string; + value: string; +} + +interface GetOptionsParams { + schema: any; + operator: Operator; + maxDepth: number; + count?: number; +} + +const isOperatorSupportMultiRelation = (operator: Operator) => { + if (!operator) return false; + return ['$eq', '$ne'].includes(operator.value); +}; + +const isSingleRelationField = (field: CollectionFieldOptions) => { + if (!field) return false; + return field.type === 'belongsTo' || field.type === 'hasOne'; +}; + +export const useOptions = (collectionName: string, { schema, operator, maxDepth, count = 1 }: GetOptionsParams) => { + if (count > maxDepth) { + return []; + } + + const result = useFilterOptions(collectionName).map((option) => { + if (!option.target) { + return { + key: option.name, + value: option.name, + label: option.title, + // TODO: 现在是通过组件的名称来过滤能够被选择的选项,这样的坏处是不够精确,后续可以优化 + disabled: schema?.['x-component'] !== option.schema?.['x-component'], + }; + } + + const children = + useOptions(option.target, { + schema, + operator, + maxDepth, + count: count + 1, + }) || []; + + return { + key: option.name, + value: option.name, + label: option.title, + children, + disabled: + (!isSingleRelationField(option) && !isOperatorSupportMultiRelation(operator)) || + children.every((child) => child.disabled), + }; + }); + + return result; +}; + +export const useUserVariable = ({ schema, operator }) => { + const options = useOptions('users', { schema, operator, maxDepth: 3 }) || []; + + return { + label: `{{t("Current user")}}`, + value: '$user', + key: '$user', + disabled: options.every((option) => option.disabled), + children: options, + }; +}; diff --git a/packages/core/client/src/schema-settings/VariableInput/hooks/useUserVariable.ts b/packages/core/client/src/schema-settings/VariableInput/hooks/useUserVariable.ts index 95de69d17..3a87af0a0 100644 --- a/packages/core/client/src/schema-settings/VariableInput/hooks/useUserVariable.ts +++ b/packages/core/client/src/schema-settings/VariableInput/hooks/useUserVariable.ts @@ -15,7 +15,7 @@ const getChildren = (options: any[], { schema, operator, maxDepth, count = 1, ge } const result = options.map((option) => { - if ((option.type !== 'belongsTo' && option.type !== 'hasOne') || !option.target) { + if (!option.target) { return { key: option.name, value: option.name, diff --git a/packages/core/database/src/operators/notIn.ts b/packages/core/database/src/operators/notIn.ts index fc1262b6c..e2be83122 100644 --- a/packages/core/database/src/operators/notIn.ts +++ b/packages/core/database/src/operators/notIn.ts @@ -10,3 +10,4 @@ export default { }; }, } as Record; + diff --git a/packages/core/database/src/operators/string.ts b/packages/core/database/src/operators/string.ts index a130170cd..155051e46 100644 --- a/packages/core/database/src/operators/string.ts +++ b/packages/core/database/src/operators/string.ts @@ -1,5 +1,5 @@ -import { isPg } from './utils'; import { Op } from 'sequelize'; +import { isPg } from './utils'; export default { $includes(value, ctx) { diff --git a/packages/core/utils/src/__tests__/getValuesByPath.test.ts b/packages/core/utils/src/__tests__/getValuesByPath.test.ts index 799028abe..4407477e9 100644 --- a/packages/core/utils/src/__tests__/getValuesByPath.test.ts +++ b/packages/core/utils/src/__tests__/getValuesByPath.test.ts @@ -32,7 +32,7 @@ describe('getValuesByPath', () => { a: { b: 1 }, }; const result = getValuesByPath(obj, ''); - expect(result).toEqual([]); + expect(result).toEqual(undefined); }); it('when path is not found', () => { @@ -40,7 +40,7 @@ describe('getValuesByPath', () => { a: { b: 1 }, }; const result = getValuesByPath(obj, 'a.c'); - expect(result).toEqual([]); + expect(result).toEqual(undefined); }); it('when path is not found in nested array', () => { @@ -48,7 +48,7 @@ describe('getValuesByPath', () => { a: [{ b: 1 }, { b: 2 }], }; const result = getValuesByPath(obj, 'a.c'); - expect(result).toEqual([]); + expect(result).toEqual(undefined); }); it('when path is not found in nested array with empty string', () => { @@ -56,7 +56,7 @@ describe('getValuesByPath', () => { a: [{ b: 1 }, { b: 2 }], }; const result = getValuesByPath(obj, 'a.'); - expect(result).toEqual([]); + expect(result).toEqual(undefined); }); it('when obj is null', () => { @@ -70,7 +70,7 @@ describe('getValuesByPath', () => { a: { b: 1 }, }; const result = getValuesByPath(obj, 'a.c', null); - expect(result).toEqual([]); + expect(result).toEqual(null); }); it('should return empty array when obj key value is undefined', () => { @@ -78,7 +78,7 @@ describe('getValuesByPath', () => { a: undefined, }; const result = getValuesByPath(obj, 'a.b'); - expect(result).toEqual([]); + expect(result).toEqual(undefined); }); it('the initial value is an array', () => { diff --git a/packages/core/utils/src/__tests__/parse-filter.test.ts b/packages/core/utils/src/__tests__/parse-filter.test.ts index 50e899663..59f45adf0 100644 --- a/packages/core/utils/src/__tests__/parse-filter.test.ts +++ b/packages/core/utils/src/__tests__/parse-filter.test.ts @@ -289,6 +289,24 @@ describe('parseFilter', () => { ).toEqual({ createdAt: { $eq: date } }); }); + test('$user & array', async () => { + const date = new Date(); + await expectParseFilter( + { + 'roles.name.$eq': '{{$user.roles.name}}', + }, + { + vars: { + $user: async (fields) => { + return { + roles: [{ name: 'admin' }, { name: 'user' }], + }; + }, + }, + }, + ).toEqual({ roles: { name: { $eq: ['admin', 'user'] } } }); + }); + test('$dateOn', async () => { const date = new Date(); await expectParseFilter( diff --git a/packages/core/utils/src/getValuesByPath.ts b/packages/core/utils/src/getValuesByPath.ts index 45a6c80e7..62db90f61 100644 --- a/packages/core/utils/src/getValuesByPath.ts +++ b/packages/core/utils/src/getValuesByPath.ts @@ -10,23 +10,28 @@ export const getValuesByPath = (obj: object, path: string, defaultValue?: any) = const key = keys[i]; if (Array.isArray(currentValue)) { - for (let j = 0; j < currentValue.length; j++) { - const value = getValuesByPath(currentValue[j], keys.slice(i).join('.'), defaultValue); + for (const element of currentValue) { + const value = getValuesByPath(element, keys.slice(i).join('.'), defaultValue); result = result.concat(value); } break; } - currentValue = currentValue[key] === undefined ? defaultValue : currentValue[key]; - - if (currentValue == null) { + if (currentValue?.[key] === undefined) { break; } + currentValue = currentValue[key]; if (i === keys.length - 1) { result.push(currentValue); } } + result = result.filter(Boolean); + + if (result.length === 0) { + return defaultValue; + } + return result.length === 1 ? result[0] : result; }; diff --git a/packages/core/utils/src/parse-filter.ts b/packages/core/utils/src/parse-filter.ts index a5b6b3c5b..ec1849806 100644 --- a/packages/core/utils/src/parse-filter.ts +++ b/packages/core/utils/src/parse-filter.ts @@ -1,6 +1,6 @@ -import get from 'lodash/get'; import set from 'lodash/set'; import moment from 'moment'; +import { getValuesByPath } from './getValuesByPath'; const re = /^\s*\{\{([\s\S]*)\}\}\s*$/; @@ -59,9 +59,9 @@ export function flatten(target, opts?: any) { function unflatten(obj, opts: any = {}) { const parsed = {}; const transformValue = opts.transformValue || keyIdentity; - Object.keys(obj).forEach((key) => { + for (const key of Object.keys(obj)) { set(parsed, key, transformValue(obj[key], key)); - }); + } return parsed; } @@ -161,7 +161,7 @@ export const parseFilter = async (filter: any, opts: ParseFilterOptions = {}) => const match = re.exec(value); if (match) { const key = match[1].trim(); - const val = get(vars, key, null); + const val = getValuesByPath(vars, key, null); const field = getField?.(path); value = typeof val === 'function' ? val?.({ field, operator, timezone, now }) : val; } @@ -293,3 +293,8 @@ export function getDateVars() { next90Days: toDays(90), }; } + +export function splitPathToTwoParts(path: string) { + const parts = path.split('.'); + return [parts.shift(), parts.join('.')]; +}