From 573cde0a216d20bac6e85889799476683629970e 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-Rain?= <958414905@qq.com> Date: Sat, 7 Oct 2023 15:49:09 +0800 Subject: [PATCH] feat(variable): lazy load association fields (#2382) * refactor: remove useless code * feat: should always show user variable * style: optimize style of variable input * feat: change maxDepth of user varialbe to 3 * refactor: migrate getFieldDefaultValue to SchemaSettings.tsx * feat: support to parse variables * feat: support to parse array fields * refactor: make testing more stable * chore: fix CI * feat: support to parse current user * fix: fix tests * feat: support to register variable * feat: support to parse "$context" * refactor: remove useless code * fix: fix build * fix: fix build * test: make stable * docs: add doc * fix: fix T-1267 * fix: fix T-1268 * fix: fix T-1269 * fix: fix T-1271 * fix: fix T-1274 * refactor: improve code * feat(variable): support local variable * fix: fix T-1280 * refactor: close T-881 * refactor: remove useLazyLoadAssociation * fix: fix T-1289,T-1290 * fix: fix T-1296 * refactor(AssignedField): replace user variable * refactor: extract useBaseVariable * refactor(AssignedField): replace variables * feat(useVariables): support to use array as local variables * refactor: improve variables (T-1295) * feat: limit user choice (T-1297) * fix: fix T-1305 * refactor: extract as SchemaSettings.DataScope * refactor: improve linkage rules * refactor: improve linkage rules of Action * chore: fix build * refactor: improve data scope of association fields * chore: fix build * feat: select limit * refactor: use VariableInput to replace other * fix: fix can not input * fix(AssignedField): fix cannot close variables list * test: fix errors * refactor: migrate to new file * fix: uniq * fix: filter empty values * fix: transform variables value * fix: should sum numbers (T-1330) * fix: fix Select (T-1337) * refactor: use useMemo * refactor: use hooks * refactor: use hooks * refactor: use hooks * refactor: remove useless code * refactor: rename `$form` to `$nForm` and `$record` to `$nRecord` * fix: fix T-1334 * refactor(LinkageRuleActionGroup): use hooks * refactor(FormButtonLinkageRuleAction): use hooks * refactor: add type * fix: avoid crashing * refactor: rename `$date` to `$nDate` * fix: fix T-1348 * perf: avoid duplicate requests * test: fix error * fix: fix T-1355 * fix: fix T-1355 * fix: fix T-1345 * fix: fix T-1346 * refactor(useVariableOptions): replace query with parameter * fix: fix T-1335 * chore: fix build * fix: fix T-1453 * refactor: remove useless code * fix: fix T-1447 * fix: should update when seting default value * fix: fix T-1454 * chore(eslint): ignore docker * fix: fix T-1440 * refactor: use hooks * fix: fix T-1332 * fix(defaultValue): should use Select mode * fix(defaultValue): fix Checkbox * refactor: rename and comment * refactor: rename file name and hook name * feat(subForm): support lazy load association fields * fix: filter empty values * fix(defaultValue): schema type of Checkbox should be void * fix: fix T-1479 * fix: fix T-1491 * fix(ToOneNester): should use RecordProvider * fix: fix T-1493 * chore: add comment * fix: fix T-1495 * chore: fix build * test: make pass * fix: fix T-1503 * fix: fix T-1504 * fix: fix transformVariableValue * fix: disable unsupport options * refactor(useVariableOptions): add noDisabled params * fix: data scope support to select to-many fields * chore: add comment * refactor: improve default value * refactor: extract as useIsAllowToSetDefaultValue * fix: should not support to set default value in edit mode * fix: only the default values for sub-forms and sub-tables are valid * fix: limit default value of association fields * fix: fix '$nForm' variable * fix: allow all inherit chain * fix: optimize default value * fix: optimize json field * fix(currentObject): avoid error * fix: should not show variable string * fix: active fields * fix: avoid errors * feat: support special case * fix: fix CI * chore: fix build * fix: json * refactor: rename flag name * fix: fix T-1559 * fix: fix T-1515 * fix: avoid crash * fix: fix T-1520 * fix: fix T-1521 * fix: fix T-1523 * fix: fix T-1563 * fix: fix T-1565 * fix: fix T-1525 * fix: fix T-1524 * fix: fix T-1603 * fix: fix T-1332 * fix: fix T-1559 * fix: fix T-1602 * fix: fix T-1608 * fix: fix T-1612 T-1609 * fix: fix T-1681 * fix: fix T-1711 * fix: fix T-1713 * fix: fix T-1680 * chore: close T-1550 * fix: fix T-1507 * chore: avoid crashing * fix: fix T-1557 * perf: close T-1726 * fix: fix T-1727 * fix: fix T-1741 * fix: fix T-1740 * chore(workflow): compact * chore: should not to support to set default value in assign field values * perf: compat and perf (T-1736) * fix: fix T-1872 * fix: fix T-1877 * fix: fix quarter variables * chore: add README * chore: front-end should not depend on back-end code * chore: should disable all variable options if no field is selected * chore: should be a leaf if it has no children * chore: fix build error * fix: compat $form * fix: compat $form * chore: fix build * chore: fix build * fix: typeError: Cannot read properties of undefined (reading 'template') * chore: fix test * chore: add test * fix: fix T-2072 * chore: optimize eslint * perf: reduce number of requests * fix: fix JSON * test: fix error * fix(e2e): avoid error --------- Co-authored-by: chenos --- .vscode/launch.json | 2 +- packages/core/build/src/buildClient.ts | 134 +-- packages/core/build/src/buildPlugin.ts | 1 + .../src/__tests__/e2e/variables.test.ts | 803 ++++++++++++++++++ .../src/block-provider/BlockProvider.tsx | 19 +- .../src/block-provider/FormBlockProvider.tsx | 8 +- .../src/block-provider/GanttBlockProvider.tsx | 6 +- .../src/block-provider/TableBlockProvider.tsx | 7 +- .../client/src/block-provider/hooks/index.ts | 275 +++++- .../hooks/useFormActiveFields.tsx | 61 ++ .../client/src/collection-manager/context.ts | 2 +- .../hooks/useCollectionManager.ts | 11 +- .../collection-manager/interfaces/json.tsx | 6 +- .../collection-manager/interfaces/linkTo.ts | 1 - .../src/collection-manager/interfaces/m2m.tsx | 7 +- .../src/collection-manager/interfaces/m2o.tsx | 7 +- .../collection-manager/interfaces/markdown.ts | 5 +- .../src/collection-manager/interfaces/o2m.tsx | 1 - .../src/collection-manager/interfaces/o2o.tsx | 3 - .../interfaces/properties/operators.ts | 7 +- .../collection-manager/interfaces/richText.ts | 7 +- .../collection-manager/interfaces/textarea.ts | 7 +- .../collection-manager/interfaces/types.ts | 18 +- .../src/collection-manager/interfaces/url.ts | 5 +- .../templates/components/PreviewFields.tsx | 4 +- .../client/src/collection-manager/types.ts | 72 +- .../src/filter-provider/FilterProvider.tsx | 6 +- .../client/src/flag-provider/FlagProvider.tsx | 31 + .../__tests__/flag-provider.test.tsx | 16 + .../client/src/flag-provider/hooks/useFlag.ts | 6 + .../core/client/src/flag-provider/index.tsx | 3 + .../route-switch/antd/admin-layout/index.tsx | 5 +- .../antd/action/Action.Designer.tsx | 47 +- .../schema-component/antd/action/Action.tsx | 86 +- .../src/schema-component/antd/action/utils.ts | 79 +- .../association-field/AssociationSelect.tsx | 25 +- .../antd/association-field/FileManager.tsx | 2 +- .../InternalCascadeSelect.tsx | 16 +- .../association-field/InternalSubTable.tsx | 7 +- .../antd/association-field/InternalViewer.tsx | 2 +- .../antd/association-field/Nester.tsx | 104 ++- .../antd/association-field/SubTable.tsx | 79 +- .../antd/association-field/hooks.ts | 137 ++- .../antd/association-field/util.ts | 47 +- .../AssociationFilter.Item.Designer.tsx | 36 +- .../association-select/AssociationSelect.tsx | 147 ++-- .../antd/calendar/Calendar.Designer.tsx | 37 +- .../antd/cascader/Cascader.tsx | 7 +- .../collection-select/CollectionSelect.tsx | 2 +- .../antd/date-picker/DatePicker.tsx | 3 +- .../__tests__/getDateRanges.test.ts | 12 + .../schema-component/antd/date-picker/util.ts | 18 +- .../antd/filter/DynamicComponent.tsx | 34 +- .../schema-component/antd/filter/Filter.tsx | 31 +- .../antd/filter/FilterItem.tsx | 61 +- .../antd/filter/FilterItems.tsx | 2 +- .../schema-component/antd/filter/context.ts | 2 +- .../antd/filter/useFilterActionProps.ts | 1 + .../schema-component/antd/filter/useValues.ts | 77 +- .../antd/form-item/FormItem.tsx | 288 +++---- .../antd/form-item/SchemaSettingOptions.tsx | 2 +- .../__tests__/hooks/useSpecialCase.test.ts | 33 + .../useLazyLoadAssociationFieldOfForm.ts | 50 ++ .../useLazyLoadAssociationFieldOfSubForm.ts | 62 ++ .../form-item/hooks/useParseDefaultValue.ts | 112 +++ .../antd/form-item/hooks/useSpecialCase.ts | 174 ++++ .../antd/form-v2/Form.Designer.tsx | 29 +- .../schema-component/antd/form-v2/Form.tsx | 104 ++- .../schema-component/antd/form-v2/utils.tsx | 157 +++- .../antd/form/__tests__/form.test.tsx | 22 +- .../antd/gantt/Gantt.Designer.tsx | 36 +- .../antd/grid-card/GridCard.Designer.tsx | 30 +- .../antd/kanban/Kanban.Designer.tsx | 33 +- .../antd/list/List.Designer.tsx | 32 +- .../antd/remote-select/RemoteSelect.tsx | 7 +- .../antd/rich-text/RichText.tsx | 17 +- .../antd/table-v2/FilterDynamicComponent.tsx | 14 - .../antd/table-v2/Table.Column.Designer.tsx | 44 +- .../schema-component/antd/table-v2/Table.tsx | 76 +- .../antd/table-v2/TableBlockDesigner.tsx | 39 +- .../antd/table-v2/TableSelectorDesigner.tsx | 33 +- .../schema-component/antd/table-v2/index.ts | 1 - .../schema-component/antd/table-v2/utils.ts | 14 - .../schema-component/antd/variable/Input.tsx | 36 +- .../antd/variable/Variable.tsx | 13 +- .../antd/variable/VariableSelect.tsx | 2 +- .../schema-component/antd/variable/style.ts | 12 + .../schema-component/common/utils/logic.js | 59 +- .../schema-component/common/utils/uitls.tsx | 151 ++-- .../src/schema-component/hooks/useCompile.ts | 14 +- .../buttons/TableColumnInitializers.tsx | 2 +- .../components/CreateRecordAction.tsx | 43 +- .../assigned-field/AssignedField.tsx | 120 +-- .../client/src/schema-initializer/utils.ts | 19 +- .../DataTemplates/components/Designer.tsx | 30 +- .../DynamicComponent.tsx | 19 +- .../LinkageRules/FilterDynamicComponent.tsx | 20 - .../LinkageRules/LinkageRuleAction.tsx | 55 +- .../LinkageRules/LinkageRuleActionGroup.tsx | 51 +- .../LinkageRules/ValueDynamicComponent.tsx | 137 ++- .../schema-settings/LinkageRules/index.tsx | 233 +++-- .../src/schema-settings/SchemaSettings.tsx | 480 ++++++++--- .../VariableInput/VariableInput.tsx | 327 ++++++- .../VariableInput/hooks/useBaseVariable.tsx | 272 ++++++ .../VariableInput/hooks/useBlockCollection.ts | 8 + .../hooks/useContextAssociationFields.tsx | 6 +- .../VariableInput/hooks/useDateVariable.ts | 30 +- .../VariableInput/hooks/useFormVariable.ts | 142 +--- .../hooks/useIterationVariable.ts | 140 +-- .../VariableInput/hooks/useRecordVariable.ts | 29 + .../VariableInput/hooks/useUserVariable.ts | 129 +-- .../VariableInput/hooks/useVariableOptions.ts | 120 ++- .../src/schema-settings/VariableInput/type.ts | 1 + .../utils/formatVariableScop.tsx | 14 + .../hooks/useIsAllowToSetDefaultValue.tsx | 123 +++ .../hooks/useParseDataScopeFilter.ts | 59 ++ .../core/client/src/schema-settings/index.ts | 3 + .../core/client/src/schema-settings/types.ts | 32 + .../src/schema-templates/BlockTemplate.tsx | 2 - .../client/src/user/CurrentUserProvider.tsx | 2 + packages/core/client/src/variables/README.md | 50 ++ .../core/client/src/variables/README.zh-CN.md | 50 ++ .../src/variables/VariablesProvider.tsx | 291 +++++++ .../variables/__tests__/useVariables.test.tsx | 597 +++++++++++++ .../__tests__/utils/filterEmptyValues.test.ts | 21 + .../utils/transformVariableValue.test.ts | 106 +++ .../variables/__tests__/utils/uniq.test.ts | 41 + .../core/client/src/variables/constants.ts | 1 + .../variables/hooks/useBuiltinVariables.ts | 58 ++ .../src/variables/hooks/useContextVariable.ts | 21 + .../src/variables/hooks/useLocalVariables.tsx | 97 +++ .../src/variables/hooks/useVariables.ts | 8 + packages/core/client/src/variables/index.ts | 7 + packages/core/client/src/variables/types.ts | 73 ++ .../src/variables/utils/filterEmptyValues.ts | 9 + .../client/src/variables/utils/getAction.tsx | 13 + .../client/src/variables/utils/getPath.tsx | 16 + .../src/variables/utils/getVariableName.tsx | 20 + .../src/variables/utils/hasRequested.tsx | 25 + .../client/src/variables/utils/isVariable.tsx | 14 + .../variables/utils/transformVariableValue.ts | 60 ++ .../core/client/src/variables/utils/uniq.ts | 23 + packages/core/database/src/collection.ts | 5 +- .../core/database/src/operators/string.ts | 62 +- packages/core/server/src/gateway/index.ts | 2 +- .../server/src/middlewares/parse-variables.ts | 4 + .../src/__tests__/getValuesByPath.test.ts | 8 + packages/core/utils/src/client.ts | 3 +- packages/core/utils/src/getValuesByPath.ts | 6 + packages/core/utils/src/isPortalInBody.ts | 27 + packages/core/utils/src/parse-filter.ts | 53 +- .../client/deplicated/AuditLogsDesigner.tsx | 28 +- .../src/client/useExportAction.ts | 1 + .../src/client/components/Formula/Result.tsx | 7 +- .../src/client/block/MapBlockDesigner.tsx | 26 +- .../src/client/interfaces/expression.tsx | 1 - .../src/client/nodes/condition.tsx | 1 - .../client/nodes/manual/AssigneesSelect.tsx | 1 - .../client/nodes/manual/FormBlockProvider.tsx | 71 +- .../src/client/nodes/manual/SchemaConfig.tsx | 56 +- .../src/client/triggers/form.tsx | 34 +- .../src/server/instructions/manual/actions.ts | 10 +- .../src/server/instructions/query.ts | 2 +- vitest.config.ts | 3 + 164 files changed, 6669 insertions(+), 2334 deletions(-) create mode 100644 packages/core/client/src/__tests__/e2e/variables.test.ts create mode 100644 packages/core/client/src/block-provider/hooks/useFormActiveFields.tsx create mode 100644 packages/core/client/src/flag-provider/FlagProvider.tsx create mode 100644 packages/core/client/src/flag-provider/__tests__/flag-provider.test.tsx create mode 100644 packages/core/client/src/flag-provider/hooks/useFlag.ts create mode 100644 packages/core/client/src/flag-provider/index.tsx create mode 100644 packages/core/client/src/schema-component/antd/form-item/__tests__/hooks/useSpecialCase.test.ts create mode 100644 packages/core/client/src/schema-component/antd/form-item/hooks/useLazyLoadAssociationFieldOfForm.ts create mode 100644 packages/core/client/src/schema-component/antd/form-item/hooks/useLazyLoadAssociationFieldOfSubForm.ts create mode 100644 packages/core/client/src/schema-component/antd/form-item/hooks/useParseDefaultValue.ts create mode 100644 packages/core/client/src/schema-component/antd/form-item/hooks/useSpecialCase.ts delete mode 100644 packages/core/client/src/schema-component/antd/table-v2/FilterDynamicComponent.tsx delete mode 100644 packages/core/client/src/schema-settings/LinkageRules/FilterDynamicComponent.tsx create mode 100644 packages/core/client/src/schema-settings/VariableInput/hooks/useBaseVariable.tsx create mode 100644 packages/core/client/src/schema-settings/VariableInput/hooks/useBlockCollection.ts create mode 100644 packages/core/client/src/schema-settings/VariableInput/hooks/useRecordVariable.ts create mode 100644 packages/core/client/src/schema-settings/VariableInput/utils/formatVariableScop.tsx create mode 100644 packages/core/client/src/schema-settings/hooks/useIsAllowToSetDefaultValue.tsx create mode 100644 packages/core/client/src/schema-settings/hooks/useParseDataScopeFilter.ts create mode 100644 packages/core/client/src/schema-settings/types.ts create mode 100644 packages/core/client/src/variables/README.md create mode 100644 packages/core/client/src/variables/README.zh-CN.md create mode 100644 packages/core/client/src/variables/VariablesProvider.tsx create mode 100644 packages/core/client/src/variables/__tests__/useVariables.test.tsx create mode 100644 packages/core/client/src/variables/__tests__/utils/filterEmptyValues.test.ts create mode 100644 packages/core/client/src/variables/__tests__/utils/transformVariableValue.test.ts create mode 100644 packages/core/client/src/variables/__tests__/utils/uniq.test.ts create mode 100644 packages/core/client/src/variables/constants.ts create mode 100644 packages/core/client/src/variables/hooks/useBuiltinVariables.ts create mode 100644 packages/core/client/src/variables/hooks/useContextVariable.ts create mode 100644 packages/core/client/src/variables/hooks/useLocalVariables.tsx create mode 100644 packages/core/client/src/variables/hooks/useVariables.ts create mode 100644 packages/core/client/src/variables/index.ts create mode 100644 packages/core/client/src/variables/types.ts create mode 100644 packages/core/client/src/variables/utils/filterEmptyValues.ts create mode 100644 packages/core/client/src/variables/utils/getAction.tsx create mode 100644 packages/core/client/src/variables/utils/getPath.tsx create mode 100644 packages/core/client/src/variables/utils/getVariableName.tsx create mode 100644 packages/core/client/src/variables/utils/hasRequested.tsx create mode 100644 packages/core/client/src/variables/utils/isVariable.tsx create mode 100644 packages/core/client/src/variables/utils/transformVariableValue.ts create mode 100644 packages/core/client/src/variables/utils/uniq.ts create mode 100644 packages/core/utils/src/isPortalInBody.ts diff --git a/.vscode/launch.json b/.vscode/launch.json index 466e07842..d39f5a171 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -30,7 +30,7 @@ "name": "Debug Client Tests", "runtimeExecutable": "yarn", "runtimeArgs": ["run", "test:client", "${file}"], - "skipFiles": ["/**"], + "skipFiles": ["/**", "**/node_modules/**", "**/dist/**", "**/lib/**", "**/es/**"], "console": "integratedTerminal", "internalConsoleOptions": "neverOpen", "windows": { diff --git a/packages/core/build/src/buildClient.ts b/packages/core/build/src/buildClient.ts index 11d5ccd96..83e17fdb1 100644 --- a/packages/core/build/src/buildClient.ts +++ b/packages/core/build/src/buildClient.ts @@ -1,14 +1,13 @@ -import path from 'path'; +import react from '@vitejs/plugin-react'; import fg from 'fast-glob'; import fs from 'fs-extra'; -import { build as viteBuild } from 'vite'; +import path from 'path'; import { build as tsupBuild } from 'tsup'; +import { build as viteBuild } from 'vite'; import { libInjectCss } from 'vite-plugin-lib-inject-css'; -import react from '@vitejs/plugin-react'; -import { PkgLog, UserConfig } from './utils'; import { globExcludeFiles } from './constant'; - +import { PkgLog, UserConfig } from './utils'; export async function buildClient(cwd: string, userConfig: UserConfig, sourcemap: boolean = false, log: PkgLog) { log('build client'); @@ -19,7 +18,7 @@ export async function buildClient(cwd: string, userConfig: UserConfig, sourcemap return false; } return true; - } + }; await buildEsm(cwd, userConfig, sourcemap, external, log); await buildLib(cwd, userConfig, sourcemap, external, log); await buildLocale(cwd, userConfig, log); @@ -31,61 +30,72 @@ export function buildEsm(cwd: string, userConfig: UserConfig, sourcemap: boolean log('build client esm'); const entry = path.join(cwd, 'src/index.ts').replaceAll(/\\/g, '/'); const outDir = path.resolve(cwd, 'es'); - return viteBuild(userConfig.modifyViteConfig({ - mode: 'production', - define: { - 'process.env.NODE_ENV': JSON.stringify('production'), - }, - build: { - minify: false, - outDir, - cssCodeSplit: true, - emptyOutDir: true, - sourcemap, - lib: { - entry, - formats: ['es'], - fileName: 'index', + return viteBuild( + userConfig.modifyViteConfig({ + mode: 'production', + define: { + 'process.env.NODE_ENV': JSON.stringify('production'), + 'process.env.__TEST__': false, }, - target: ['es2015', 'edge88', 'firefox78', 'chrome87', 'safari14'], - rollupOptions: { - cache: true, - treeshake: true, - external, + build: { + minify: false, + outDir, + cssCodeSplit: true, + emptyOutDir: true, + sourcemap, + lib: { + entry, + formats: ['es'], + fileName: 'index', + }, + target: ['es2015', 'edge88', 'firefox78', 'chrome87', 'safari14'], + rollupOptions: { + cache: true, + treeshake: true, + external, + }, }, - }, - plugins: [react(), libInjectCss()], - })); + plugins: [react(), libInjectCss()], + }), + ); } -export async function buildLib(cwd: string, userConfig: UserConfig, sourcemap: boolean, external: External, log: PkgLog) { +export async function buildLib( + cwd: string, + userConfig: UserConfig, + sourcemap: boolean, + external: External, + log: PkgLog, +) { log('build client lib'); const outDir = path.resolve(cwd, 'lib'); const esDir = path.resolve(cwd, 'es'); - const entry = path.join(esDir, 'index.ts') + const entry = path.join(esDir, 'index.ts'); fs.removeSync(entry); fs.linkSync(path.join(cwd, 'es/index.mjs'), entry); - await viteBuild(userConfig.modifyViteConfig({ - mode: 'production', - esbuild: { - format: 'cjs' - }, - build: { - outDir, - minify: false, - sourcemap, - lib: { - entry: path.join(cwd, 'es/index.ts'), - formats: ['cjs'], - fileName: 'index', + await viteBuild( + userConfig.modifyViteConfig({ + mode: 'production', + esbuild: { + format: 'cjs', }, - rollupOptions: { - external, + build: { + outDir, + minify: false, + sourcemap, + lib: { + entry: path.join(cwd, 'es/index.ts'), + formats: ['cjs'], + fileName: 'index', + }, + rollupOptions: { + external, + }, }, - }, - })); + }), + ); fs.removeSync(entry); @@ -100,17 +110,19 @@ export function buildLocale(cwd: string, userConfig: UserConfig, log: PkgLog) { const entry = fg.globSync(['src/locale/**', ...globExcludeFiles], { cwd, absolute: true }); const outDir = path.resolve(cwd, 'lib', 'locale'); - return tsupBuild(userConfig.modifyTsupConfig({ - entry, - splitting: false, - clean: false, - bundle: false, - silent: true, - treeshake: false, - target: 'node16', - keepNames: true, - outDir, - format: 'cjs', - skipNodeModulesBundle: true, - })); + return tsupBuild( + userConfig.modifyTsupConfig({ + entry, + splitting: false, + clean: false, + bundle: false, + silent: true, + treeshake: false, + target: 'node16', + keepNames: true, + outDir, + format: 'cjs', + skipNodeModulesBundle: true, + }), + ); } diff --git a/packages/core/build/src/buildPlugin.ts b/packages/core/build/src/buildPlugin.ts index 583355476..d22f8b67b 100644 --- a/packages/core/build/src/buildPlugin.ts +++ b/packages/core/build/src/buildPlugin.ts @@ -303,6 +303,7 @@ export async function buildPluginClient(cwd: string, userConfig: UserConfig, sou mode: 'production', define: { 'process.env.NODE_ENV': JSON.stringify('production'), + 'process.env.__TEST__': false, }, logLevel: 'warn', build: { diff --git a/packages/core/client/src/__tests__/e2e/variables.test.ts b/packages/core/client/src/__tests__/e2e/variables.test.ts new file mode 100644 index 000000000..18acb799c --- /dev/null +++ b/packages/core/client/src/__tests__/e2e/variables.test.ts @@ -0,0 +1,803 @@ +import { test } from '@nocobase/test/client'; + +const t2072Config = { + collections: [ + { + key: 'etlrv6xojl4', + name: 't_cyjvg3xxak7', + title: '人工>组织[普通表]', + inherit: false, + hidden: false, + description: null, + fields: [ + { + key: 'e4jfv35cotm', + name: 'id', + type: 'bigInt', + interface: 'id', + description: null, + collectionName: 't_cyjvg3xxak7', + parentKey: null, + reverseKey: null, + autoIncrement: true, + primaryKey: true, + allowNull: false, + uiSchema: { + type: 'number', + title: '{{t("ID")}}', + 'x-component': 'InputNumber', + 'x-read-pretty': true, + }, + }, + { + key: '778zti7timg', + name: 'f_jnf1x0qxsnz', + type: 'bigInt', + interface: 'integer', + description: null, + collectionName: 't_cyjvg3xxak7', + parentKey: null, + reverseKey: null, + isForeignKey: true, + uiSchema: { + type: 'number', + title: 'f_jnf1x0qxsnz', + 'x-component': 'InputNumber', + 'x-read-pretty': true, + }, + }, + { + key: '2nuvtsxlzvh', + name: 'f_zx8j9897cv5', + type: 'bigInt', + interface: 'integer', + description: null, + collectionName: 't_cyjvg3xxak7', + parentKey: null, + reverseKey: null, + isForeignKey: true, + uiSchema: { + type: 'number', + title: 'f_zx8j9897cv5', + 'x-component': 'InputNumber', + 'x-read-pretty': true, + }, + }, + { + key: 'q491jgqygpg', + name: 'createdAt', + type: 'date', + interface: 'createdAt', + description: null, + collectionName: 't_cyjvg3xxak7', + parentKey: null, + reverseKey: null, + field: 'createdAt', + uiSchema: { + type: 'datetime', + title: '{{t("Created at")}}', + 'x-component': 'DatePicker', + 'x-component-props': {}, + 'x-read-pretty': true, + }, + }, + { + key: 'q2uungrabre', + name: 'createdBy', + type: 'belongsTo', + interface: 'createdBy', + description: null, + collectionName: 't_cyjvg3xxak7', + parentKey: null, + reverseKey: null, + target: 'users', + foreignKey: 'createdById', + uiSchema: { + type: 'object', + title: '{{t("Created by")}}', + 'x-component': 'AssociationField', + 'x-component-props': { + fieldNames: { + value: 'id', + label: 'nickname', + }, + }, + 'x-read-pretty': true, + }, + targetKey: 'id', + }, + { + key: 'cizz53lon1q', + name: 'updatedAt', + type: 'date', + interface: 'updatedAt', + description: null, + collectionName: 't_cyjvg3xxak7', + parentKey: null, + reverseKey: null, + field: 'updatedAt', + uiSchema: { + type: 'string', + title: '{{t("Last updated at")}}', + 'x-component': 'DatePicker', + 'x-component-props': {}, + 'x-read-pretty': true, + }, + }, + { + key: '9dd8bohloh2', + name: 'updatedBy', + type: 'belongsTo', + interface: 'updatedBy', + description: null, + collectionName: 't_cyjvg3xxak7', + parentKey: null, + reverseKey: null, + target: 'users', + foreignKey: 'updatedById', + uiSchema: { + type: 'object', + title: '{{t("Last updated by")}}', + 'x-component': 'AssociationField', + 'x-component-props': { + fieldNames: { + value: 'id', + label: 'nickname', + }, + }, + 'x-read-pretty': true, + }, + targetKey: 'id', + }, + { + key: 'p7649gl5tic', + name: 'f_wrlootg9xu9', + type: 'hasMany', + interface: 'o2m', + description: null, + collectionName: 't_cyjvg3xxak7', + parentKey: null, + reverseKey: null, + foreignKey: 'f_jnf1x0qxsnz', + onDelete: 'SET NULL', + uiSchema: { + 'x-component': 'AssociationField', + 'x-component-props': { + multiple: true, + fieldNames: { + label: 'id', + value: 'id', + }, + }, + title: '部门(one to many)', + }, + target: 't_cyjvg3xxak7', + targetKey: 'id', + sourceKey: 'id', + }, + { + key: '6etvnvsplrv', + name: 'f_4xulfnosvlx', + type: 'belongsTo', + interface: 'm2o', + description: null, + collectionName: 't_cyjvg3xxak7', + parentKey: null, + reverseKey: null, + foreignKey: 'f_zx8j9897cv5', + onDelete: 'SET NULL', + uiSchema: { + 'x-component': 'AssociationField', + 'x-component-props': { + multiple: false, + fieldNames: { + label: 'id', + value: 'id', + }, + }, + title: '负责人(many to one)', + }, + target: 't_ymx99ljfetl', + targetKey: 'id', + }, + ], + category: [], + logging: true, + autoGenId: true, + createdBy: true, + updatedBy: true, + createdAt: true, + updatedAt: true, + sortable: true, + template: 'general', + view: false, + }, + { + key: 'gmsav2kkbk3', + name: 't_ymx99ljfetl', + title: '人员', + inherit: false, + hidden: false, + description: null, + fields: [ + { + key: 'dhonulr1xxc', + name: 'id', + type: 'bigInt', + interface: 'id', + description: null, + collectionName: 't_ymx99ljfetl', + parentKey: null, + reverseKey: null, + autoIncrement: true, + primaryKey: true, + allowNull: false, + uiSchema: { + type: 'number', + title: '{{t("ID")}}', + 'x-component': 'InputNumber', + 'x-read-pretty': true, + }, + }, + { + key: 'fh09uvcibsq', + name: 'f_qojsgjsrjie', + type: 'bigInt', + interface: 'integer', + description: null, + collectionName: 't_ymx99ljfetl', + parentKey: null, + reverseKey: null, + isForeignKey: true, + uiSchema: { + type: 'number', + title: 'f_qojsgjsrjie', + 'x-component': 'InputNumber', + 'x-read-pretty': true, + }, + }, + { + key: 'xzvwq5g75gi', + name: 'createdAt', + type: 'date', + interface: 'createdAt', + description: null, + collectionName: 't_ymx99ljfetl', + parentKey: null, + reverseKey: null, + field: 'createdAt', + uiSchema: { + type: 'datetime', + title: '{{t("Created at")}}', + 'x-component': 'DatePicker', + 'x-component-props': {}, + 'x-read-pretty': true, + }, + }, + { + key: 'oc580r95evm', + name: 'createdBy', + type: 'belongsTo', + interface: 'createdBy', + description: null, + collectionName: 't_ymx99ljfetl', + parentKey: null, + reverseKey: null, + target: 'users', + foreignKey: 'createdById', + uiSchema: { + type: 'object', + title: '{{t("Created by")}}', + 'x-component': 'AssociationField', + 'x-component-props': { + fieldNames: { + value: 'id', + label: 'nickname', + }, + }, + 'x-read-pretty': true, + }, + targetKey: 'id', + }, + { + key: '9zi17bh97yl', + name: 'updatedAt', + type: 'date', + interface: 'updatedAt', + description: null, + collectionName: 't_ymx99ljfetl', + parentKey: null, + reverseKey: null, + field: 'updatedAt', + uiSchema: { + type: 'string', + title: '{{t("Last updated at")}}', + 'x-component': 'DatePicker', + 'x-component-props': {}, + 'x-read-pretty': true, + }, + }, + { + key: '2xbcmman6gz', + name: 'updatedBy', + type: 'belongsTo', + interface: 'updatedBy', + description: null, + collectionName: 't_ymx99ljfetl', + parentKey: null, + reverseKey: null, + target: 'users', + foreignKey: 'updatedById', + uiSchema: { + type: 'object', + title: '{{t("Last updated by")}}', + 'x-component': 'AssociationField', + 'x-component-props': { + fieldNames: { + value: 'id', + label: 'nickname', + }, + }, + 'x-read-pretty': true, + }, + targetKey: 'id', + }, + ], + category: [], + logging: true, + autoGenId: true, + createdBy: true, + updatedBy: true, + createdAt: true, + updatedAt: true, + sortable: true, + template: 'general', + view: false, + }, + ], + pageSchema: { + _isJSONSchemaObject: true, + version: '2.0', + type: 'void', + 'x-component': 'Page', + properties: { + '3fsvxxy8klh': { + _isJSONSchemaObject: true, + version: '2.0', + type: 'void', + 'x-component': 'Grid', + 'x-initializer': 'BlockInitializers', + properties: { + kwm0euv1w2z: { + _isJSONSchemaObject: true, + version: '2.0', + type: 'void', + 'x-component': 'Grid.Row', + properties: { + '4wk59ijgdzx': { + _isJSONSchemaObject: true, + version: '2.0', + type: 'void', + 'x-component': 'Grid.Col', + properties: { + vsgx66mbgpm: { + _isJSONSchemaObject: true, + version: '2.0', + type: 'void', + 'x-decorator': 'TableBlockProvider', + 'x-acl-action': 't_cyjvg3xxak7:list', + 'x-decorator-props': { + collection: 't_cyjvg3xxak7', + resource: 't_cyjvg3xxak7', + action: 'list', + params: { + pageSize: 20, + }, + rowKey: 'id', + showIndex: true, + dragSort: false, + disableTemplate: false, + }, + 'x-designer': 'TableBlockDesigner', + 'x-component': 'CardItem', + 'x-filter-targets': [], + properties: { + actions: { + _isJSONSchemaObject: true, + version: '2.0', + type: 'void', + 'x-initializer': 'TableActionInitializers', + 'x-component': 'ActionBar', + 'x-component-props': { + style: { + marginBottom: 'var(--nb-spacing)', + }, + }, + properties: { + mb64tcykcw3: { + _isJSONSchemaObject: true, + version: '2.0', + type: 'void', + title: '{{t("Add record")}}', + 'x-designer': 'Action.Designer', + 'x-component': 'Action', + 'x-action': 'customize:create', + 'x-component-props': { + openMode: 'drawer', + icon: 'PlusOutlined', + }, + 'x-align': 'right', + 'x-decorator': 'ACLActionProvider', + 'x-acl-action': 'create', + 'x-acl-action-props': { + skipScopeCheck: true, + }, + properties: { + drawer: { + _isJSONSchemaObject: true, + version: '2.0', + type: 'void', + title: '{{t("Add record")}}', + 'x-component': 'Action.Container', + 'x-component-props': { + className: 'nb-action-popup', + }, + properties: { + tabs: { + _isJSONSchemaObject: true, + version: '2.0', + type: 'void', + 'x-component': 'Tabs', + 'x-component-props': {}, + 'x-initializer': 'TabPaneInitializersForCreateFormBlock', + properties: { + tab1: { + _isJSONSchemaObject: true, + version: '2.0', + type: 'void', + title: '{{t("Add record")}}', + 'x-component': 'Tabs.TabPane', + 'x-designer': 'Tabs.Designer', + 'x-component-props': {}, + properties: { + grid: { + _isJSONSchemaObject: true, + version: '2.0', + type: 'void', + 'x-component': 'Grid', + 'x-initializer': 'CusomeizeCreateFormBlockInitializers', + properties: { + '4sdqrhx1egh': { + _isJSONSchemaObject: true, + version: '2.0', + type: 'void', + 'x-component': 'Grid.Row', + properties: { + m1gl7ido4xg: { + _isJSONSchemaObject: true, + version: '2.0', + type: 'void', + 'x-component': 'Grid.Col', + properties: { + f0srs8s7mq6: { + _isJSONSchemaObject: true, + version: '2.0', + type: 'void', + 'x-acl-action-props': { + skipScopeCheck: true, + }, + 'x-acl-action': 't_cyjvg3xxak7:create', + 'x-decorator': 'FormBlockProvider', + 'x-decorator-props': { + isCusomeizeCreate: true, + resource: 't_cyjvg3xxak7', + collection: 't_cyjvg3xxak7', + }, + 'x-designer': 'FormV2.Designer', + 'x-component': 'CardItem', + 'x-component-props': {}, + properties: { + l3kfu43aq17: { + _isJSONSchemaObject: true, + version: '2.0', + type: 'void', + 'x-component': 'FormV2', + 'x-component-props': { + useProps: '{{ useFormBlockProps }}', + }, + properties: { + grid: { + _isJSONSchemaObject: true, + version: '2.0', + type: 'void', + 'x-component': 'Grid', + 'x-initializer': 'FormItemInitializers', + properties: { + j57m9wdhidh: { + _isJSONSchemaObject: true, + version: '2.0', + type: 'void', + 'x-component': 'Grid.Row', + properties: { + '8l5ut3li3my': { + _isJSONSchemaObject: true, + version: '2.0', + type: 'void', + 'x-component': 'Grid.Col', + properties: { + f_wrlootg9xu9: { + 'x-uid': 'enypaahwvzs', + _isJSONSchemaObject: true, + version: '2.0', + type: 'string', + 'x-designer': 'FormItem.Designer', + 'x-component': 'CollectionField', + 'x-decorator': 'FormItem', + 'x-collection-field': + 't_cyjvg3xxak7.f_wrlootg9xu9', + 'x-component-props': { + mode: 'SubTable', + }, + default: null, + properties: { + svaxqhd7v29: { + _isJSONSchemaObject: true, + version: '2.0', + type: 'void', + 'x-component': + 'AssociationField.SubTable', + 'x-initializer': + 'TableColumnInitializers', + 'x-initializer-props': { + action: false, + }, + 'x-index': 1, + properties: { + eevxwy50bcp: { + _isJSONSchemaObject: true, + version: '2.0', + type: 'void', + 'x-decorator': + 'TableV2.Column.Decorator', + 'x-designer': + 'TableV2.Column.Designer', + 'x-component': 'TableV2.Column', + properties: { + f_4xulfnosvlx: { + 'x-uid': 'ho3gwsra9qk', + _isJSONSchemaObject: true, + version: '2.0', + 'x-collection-field': + 't_cyjvg3xxak7.f_4xulfnosvlx', + 'x-component': + 'CollectionField', + 'x-component-props': { + ellipsis: true, + size: 'small', + }, + 'x-decorator': 'FormItem', + 'x-decorator-props': { + labelStyle: { + display: 'none', + }, + }, + default: + '{{$context.f_4xulfnosvlx}}', + 'x-async': false, + 'x-index': 1, + }, + }, + 'x-uid': '6ym1xpsxmyf', + 'x-async': false, + 'x-index': 1, + }, + }, + 'x-uid': 'nmbnils3epp', + 'x-async': false, + }, + }, + 'x-async': false, + 'x-index': 1, + }, + }, + 'x-uid': 'e69smhckndt', + 'x-async': false, + 'x-index': 1, + }, + }, + 'x-uid': 'puxwfyhomzf', + 'x-async': false, + 'x-index': 1, + }, + }, + 'x-uid': 'rziak1zqrgj', + 'x-async': false, + 'x-index': 1, + }, + actions: { + _isJSONSchemaObject: true, + version: '2.0', + type: 'void', + 'x-initializer': 'FormActionInitializers', + 'x-component': 'ActionBar', + 'x-component-props': { + layout: 'one-column', + style: { + marginTop: 24, + }, + }, + 'x-uid': 'ohkberml3rb', + 'x-async': false, + 'x-index': 2, + }, + }, + 'x-uid': 't2zwt2anafy', + 'x-async': false, + 'x-index': 1, + }, + }, + 'x-uid': '9nr7zawsp77', + 'x-async': false, + 'x-index': 1, + }, + }, + 'x-uid': 'sy2eoiiguig', + 'x-async': false, + 'x-index': 1, + }, + }, + 'x-uid': '3dpkedn8cdb', + 'x-async': false, + 'x-index': 1, + }, + }, + 'x-uid': 'vkzbih8qgqz', + 'x-async': false, + 'x-index': 1, + }, + }, + 'x-uid': '88pixfaj3wm', + 'x-async': false, + 'x-index': 1, + }, + }, + 'x-uid': 'khgiclrgqhp', + 'x-async': false, + 'x-index': 1, + }, + }, + 'x-uid': 'p5j5303aqdv', + 'x-async': false, + 'x-index': 1, + }, + }, + 'x-uid': 'jcdnkjqfof4', + 'x-async': false, + 'x-index': 1, + }, + }, + 'x-uid': 'wmcdk2bf0rk', + 'x-async': false, + 'x-index': 1, + }, + ooh4p68w3wy: { + _isJSONSchemaObject: true, + version: '2.0', + type: 'array', + 'x-initializer': 'TableColumnInitializers', + 'x-component': 'TableV2', + 'x-component-props': { + rowKey: 'id', + rowSelection: { + type: 'checkbox', + }, + useProps: '{{ useTableBlockProps }}', + }, + 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': 'TableActionColumnInitializers', + properties: { + actions: { + _isJSONSchemaObject: true, + version: '2.0', + type: 'void', + 'x-decorator': 'DndContext', + 'x-component': 'Space', + 'x-component-props': { + split: '|', + }, + 'x-uid': 'zf80uv8uhol', + 'x-async': false, + 'x-index': 1, + }, + }, + 'x-uid': 'lrb33ra71kx', + 'x-async': false, + 'x-index': 1, + }, + bi9qnla6pey: { + _isJSONSchemaObject: true, + version: '2.0', + type: 'void', + 'x-decorator': 'TableV2.Column.Decorator', + 'x-designer': 'TableV2.Column.Designer', + 'x-component': 'TableV2.Column', + properties: { + f_4xulfnosvlx: { + _isJSONSchemaObject: true, + version: '2.0', + 'x-collection-field': 't_cyjvg3xxak7.f_4xulfnosvlx', + 'x-component': 'CollectionField', + 'x-component-props': { + ellipsis: true, + size: 'small', + }, + 'x-read-pretty': true, + 'x-decorator': null, + 'x-decorator-props': { + labelStyle: { + display: 'none', + }, + }, + 'x-uid': '9a1xgqcn8g8', + 'x-async': false, + 'x-index': 1, + }, + }, + 'x-uid': 'ipt9o4tqqkw', + 'x-async': false, + 'x-index': 2, + }, + }, + 'x-uid': 'a89hczfm5nt', + 'x-async': false, + 'x-index': 2, + }, + }, + 'x-uid': 'sghu92e6os9', + 'x-async': false, + 'x-index': 1, + }, + }, + 'x-uid': '8bhcc7f8dfb', + 'x-async': false, + 'x-index': 1, + }, + }, + 'x-uid': 'l4nrtmguqpv', + 'x-async': false, + 'x-index': 1, + }, + }, + 'x-uid': 'jfcdfwu92mu', + 'x-async': false, + 'x-index': 1, + }, + }, + 'x-uid': 'cfjrdgvlm68', + 'x-async': true, + 'x-index': 1, + }, +}; + +test.describe.skip('variables', () => { + test('T-2072', async ({ page, mockPage }) => { + await mockPage(t2072Config).goto(); + + await page.getByTestId('table-index-1').hover(); + await page.getByRole('cell', { name: '1' }).getByLabel('').check(); + await page.getByTestId('table-index-2').hover(); + await page.getByRole('cell', { name: '2' }).getByLabel('').check(); + await page.getByTestId('table-index-3').hover(); + await page.getByRole('cell', { name: '3' }).getByLabel('').check(); + await page.getByTestId('customize:create-action').click(); + }); +}); diff --git a/packages/core/client/src/block-provider/BlockProvider.tsx b/packages/core/client/src/block-provider/BlockProvider.tsx index a75b67642..d5d75c871 100644 --- a/packages/core/client/src/block-provider/BlockProvider.tsx +++ b/packages/core/client/src/block-provider/BlockProvider.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import { Field } from '@formily/core'; +import { Field, GeneralField } from '@formily/core'; import { RecursionField, useField, useFieldSchema } from '@formily/react'; import { useRequest } from 'ahooks'; import { Col, Row } from 'antd'; @@ -24,7 +24,16 @@ import { useAssociationNames } from './hooks'; export const BlockResourceContext = createContext(null); export const BlockAssociationContext = createContext(null); -export const BlockRequestContext = createContext({}); +export const BlockRequestContext = createContext<{ + block?: string; + props?: any; + field?: GeneralField; + service?: any; + resource?: any; + allowedActions?: any; + __parent?: any; + updateAssociationValues?: any[]; +}>({}); export const useBlockResource = () => { return useContext(BlockResourceContext); @@ -56,7 +65,7 @@ const useResource = (props: UseResourceProps) => { const isCreateAction = fieldSchema?.['x-action'] === 'create'; const association = useAssociation(props); const sourceId = useSourceId?.(); - const field = useField(); + const field = useField(); const withoutTableFieldResource = useContext(WithoutTableFieldResource); const __parent = useContext(BlockRequestContext); if (block === 'TableField') { @@ -96,7 +105,7 @@ const useActionParams = (props) => { return { ...props.params, ...params }; }; -export const useResourceAction = (props, opts = {}) => { +const useResourceAction = (props, opts = {}) => { /** * fieldName: 来自 TableFieldProvider */ @@ -168,7 +177,7 @@ export const MaybeCollectionProvider = (props) => { }; export const BlockRequestProvider = (props) => { - const field = useField(); + const field = useField(); const resource = useBlockResource(); const [allowedActions, setAllowedActions] = useState({}); diff --git a/packages/core/client/src/block-provider/FormBlockProvider.tsx b/packages/core/client/src/block-provider/FormBlockProvider.tsx index 745b9dd21..f1ef601fd 100644 --- a/packages/core/client/src/block-provider/FormBlockProvider.tsx +++ b/packages/core/client/src/block-provider/FormBlockProvider.tsx @@ -8,6 +8,7 @@ import { RecordProvider, useRecord } from '../record-provider'; import { useActionContext, useDesignable } from '../schema-component'; import { Templates as DataTemplateSelect } from '../schema-component/antd/form-v2/Templates'; import { BlockProvider, useBlockRequestContext } from './BlockProvider'; +import { FormActiveFieldsProvider } from './hooks'; export const FormBlockContext = createContext({}); @@ -19,7 +20,7 @@ const InternalFormBlockProvider = (props) => { createForm({ readPretty, }), - [], + [readPretty], ); const { resource, service, updateAssociationValues } = useBlockRequestContext(); const formBlockRef = useRef(); @@ -27,6 +28,7 @@ const InternalFormBlockProvider = (props) => { if (service.loading && Object.keys(form?.initialValues)?.length === 0 && action) { return ; } + return ( { return ( (detailFlag || createFlag || isCusomeizeCreate) && ( - + + + ) ); diff --git a/packages/core/client/src/block-provider/GanttBlockProvider.tsx b/packages/core/client/src/block-provider/GanttBlockProvider.tsx index 4f4cb1e13..2989fb66d 100644 --- a/packages/core/client/src/block-provider/GanttBlockProvider.tsx +++ b/packages/core/client/src/block-provider/GanttBlockProvider.tsx @@ -91,7 +91,7 @@ export const useGanttBlockProps = () => { const { getPrimaryKey, name, template, writableView } = useCollection(); const { parseAction } = useACLRoleContext(); const primaryKey = getPrimaryKey(); - const checkPermassion = (record) => { + const checkPermission = (record) => { const actionPath = `${name}:update`; const schema = {}; const recordPkValue = record?.[primaryKey]; @@ -106,13 +106,13 @@ export const useGanttBlockProps = () => { ctx.field.data = tasksData; }; const expandAndCollapseAll = (flag) => { - const data = formatData(ctx.service.data?.data, ctx.fieldNames, [], undefined, flag, checkPermassion); + const data = formatData(ctx.service.data?.data, ctx.fieldNames, [], undefined, flag, checkPermission); setTasks(data); ctx.field.data = data; }; useEffect(() => { if (!ctx?.service?.loading) { - const data = formatData(ctx.service.data?.data, ctx.fieldNames, [], undefined, false, checkPermassion); + const data = formatData(ctx.service.data?.data, ctx.fieldNames, [], undefined, false, checkPermission); setTasks(data); ctx.field.data = data; } diff --git a/packages/core/client/src/block-provider/TableBlockProvider.tsx b/packages/core/client/src/block-provider/TableBlockProvider.tsx index c01f03ade..0125f0a3d 100644 --- a/packages/core/client/src/block-provider/TableBlockProvider.tsx +++ b/packages/core/client/src/block-provider/TableBlockProvider.tsx @@ -28,6 +28,10 @@ interface Props { rowKey?: string; childrenColumnName: any; fieldNames?: any; + /** + * Table 区块的 collection name + */ + collection?: string; } const InternalTableBlockProvider = (props: Props) => { @@ -43,6 +47,7 @@ const InternalTableBlockProvider = (props: Props) => { return keys || []; } }, [service?.loading]); + return ( { } } const form = useMemo(() => createForm(), [treeTable]); + return ( @@ -142,7 +148,6 @@ export const useTableBlockProps = () => { } : false, onRowSelectionChange(selectedRowKeys) { - console.log(selectedRowKeys); ctx.field.data = ctx?.field?.data || {}; ctx.field.data.selectedRowKeys = selectedRowKeys; ctx?.field?.onRowSelect?.(selectedRowKeys); diff --git a/packages/core/client/src/block-provider/hooks/index.ts b/packages/core/client/src/block-provider/hooks/index.ts index 01c837a48..e4793f26d 100644 --- a/packages/core/client/src/block-provider/hooks/index.ts +++ b/packages/core/client/src/block-provider/hooks/index.ts @@ -1,14 +1,14 @@ import { SchemaExpressionScopeContext, useField, useFieldSchema, useForm } from '@formily/react'; import { isURL, parse } from '@nocobase/utils/client'; import { App, message } from 'antd'; -import { cloneDeep } from 'lodash'; +import _, { cloneDeep } from 'lodash'; import get from 'lodash/get'; import omit from 'lodash/omit'; import { ChangeEvent, useContext, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { useReactToPrint } from 'react-to-print'; -import { AssociationFilter, useFormBlockContext, useTableBlockContext } from '../..'; +import { AssociationFilter, useFormActiveFields, useFormBlockContext, useTableBlockContext } from '../..'; import { useAPIClient, useRequest } from '../../api-client'; import { useCollection, useCollectionManager } from '../../collection-manager'; import { useFilterBlock } from '../../filter-provider/FilterProvider'; @@ -17,11 +17,16 @@ import { useRecord } from '../../record-provider'; import { removeNullCondition, useActionContext, useCompile } from '../../schema-component'; import { BulkEditFormItemValueType } from '../../schema-initializer/components'; import { useCurrentUserContext } from '../../user'; +import { useLocalVariables, useVariables } from '../../variables'; +import { isVariable } from '../../variables/utils/isVariable'; +import { transformVariableValue } from '../../variables/utils/transformVariableValue'; import { useBlockRequestContext, useFilterByTk, useParamsFromRecord } from '../BlockProvider'; import { useDetailsBlockContext } from '../DetailsBlockProvider'; import { mergeFilter } from '../SharedFilterProvider'; import { TableFieldResource } from '../TableFieldProvider'; +export * from './useFormActiveFields'; + export const usePickActionProps = () => { const form = useForm(); return { @@ -61,13 +66,28 @@ const filterValue = (value) => { return obj; }; -function getFormValues(filterByTk, field, form, fieldNames, getField, resource) { +function getFormValues({ + filterByTk, + field, + form, + fieldNames, + getField, + resource, + actionFields, +}: { + filterByTk; + field; + form; + fieldNames; + getField; + resource; + actionFields: any[]; +}) { if (filterByTk) { - const actionFields = field?.data?.activeFields as Set; if (actionFields) { const keys = Object.keys(form.values).filter((key) => { const f = getField(key); - return !actionFields.has(key) && ['hasOne', 'hasMany', 'belongsTo', 'belongsToMany'].includes(f?.type); + return !actionFields.includes(key) && ['hasOne', 'hasMany', 'belongsTo', 'belongsToMany'].includes(f?.type); }); return omit({ ...form.values }, keys); } @@ -122,17 +142,17 @@ export const useCreateActionProps = () => { const { field, resource, __parent } = useBlockRequestContext(); const { setVisible, fieldSchema } = useActionContext(); const navigate = useNavigate(); - const { t } = useTranslation(); const actionSchema = useFieldSchema(); const actionField = useField(); - const { fields, getField, getTreeParentField } = useCollection(); + const { fields, getField, getTreeParentField, name } = useCollection(); const compile = useCompile(); const filterByTk = useFilterByTk(); const currentRecord = useRecord(); - const currentUserContext = useCurrentUserContext(); const { modal } = App.useApp(); + const variables = useVariables(); + const localVariables = useLocalVariables({ currentForm: form }); + const { getActiveFieldsName } = useFormActiveFields(); - const currentUser = currentUserContext?.data?.data; const action = actionField.componentProps.saveMode || 'create'; const filterKeys = actionField.componentProps.filterKeys?.checked || []; return { @@ -146,11 +166,41 @@ export const useCreateActionProps = () => { triggerWorkflows, } = actionSchema?.['x-action-settings'] ?? {}; const addChild = fieldSchema?.['x-component-props']?.addChild; - const assignedValues = parse(originalAssignedValues)({ currentTime: new Date(), currentRecord, currentUser }); + + const assignedValues = {}; + const waitList = Object.keys(originalAssignedValues).map(async (key) => { + const value = originalAssignedValues[key]; + const collectionField = getField(key); + + if (process.env.NODE_ENV !== 'production') { + if (!collectionField) { + throw new Error(`useCreateActionProps: field "${key}" not found in collection "${name}"`); + } + } + + if (isVariable(value)) { + const result = await variables?.parseVariable(value, localVariables); + if (result) { + assignedValues[key] = transformVariableValue(result, { targetCollectionField: collectionField }); + } + } else if (value != null && value !== '') { + assignedValues[key] = value; + } + }); + await Promise.all(waitList); + if (!skipValidator) { await form.submit(); } - const values = getFormValues(filterByTk, field, form, fieldNames, getField, resource); + const values = getFormValues({ + filterByTk, + field, + form, + fieldNames, + getField, + resource, + actionFields: getActiveFieldsName('form'), + }); // const values = omitBy(formValues, (value) => isEqual(JSON.stringify(value), '[{}]')); if (addChild) { const treeParentField = getTreeParentField(); @@ -209,12 +259,14 @@ export const useAssociationCreateActionProps = () => { const { setVisible, fieldSchema } = useActionContext(); const actionSchema = useFieldSchema(); const actionField = useField(); - const { fields, getField, getTreeParentField } = useCollection(); + const { fields, getField, getTreeParentField, name } = useCollection(); const compile = useCompile(); const filterByTk = useFilterByTk(); const currentRecord = useRecord(); - const currentUserContext = useCurrentUserContext(); - const currentUser = currentUserContext?.data?.data; + const variables = useVariables(); + const localVariables = useLocalVariables({ currentForm: form }); + const { getActiveFieldsName } = useFormActiveFields(); + const action = actionField.componentProps.saveMode || 'create'; const filterKeys = actionField.componentProps.filterKeys?.checked || []; return { @@ -228,11 +280,41 @@ export const useAssociationCreateActionProps = () => { triggerWorkflows, } = actionSchema?.['x-action-settings'] ?? {}; const addChild = fieldSchema?.['x-component-props']?.addChild; - const assignedValues = parse(originalAssignedValues)({ currentTime: new Date(), currentRecord, currentUser }); + + const assignedValues = {}; + const waitList = Object.keys(originalAssignedValues).map(async (key) => { + const value = originalAssignedValues[key]; + const collectionField = getField(key); + + if (process.env.NODE_ENV !== 'production') { + if (!collectionField) { + throw new Error(`useAssociationCreateActionProps: field "${key}" not found in collection "${name}"`); + } + } + + if (isVariable(value)) { + const result = await variables?.parseVariable(value, localVariables); + if (result) { + assignedValues[key] = transformVariableValue(result, { targetCollectionField: collectionField }); + } + } else if (value != null && value !== '') { + assignedValues[key] = value; + } + }); + await Promise.all(waitList); + if (!skipValidator) { await form.submit(); } - const values = getFormValues(filterByTk, field, form, fieldNames, getField, resource); + const values = getFormValues({ + filterByTk, + field, + form, + fieldNames, + getField, + resource, + actionFields: getActiveFieldsName('form'), + }); if (addChild) { const treeParentField = getTreeParentField(); values[treeParentField?.name ?? 'parent'] = currentRecord; @@ -405,13 +487,13 @@ export const useCustomizeUpdateActionProps = () => { const { resource, __parent, service } = useBlockRequestContext(); const filterByTk = useFilterByTk(); const actionSchema = useFieldSchema(); - const currentRecord = useRecord(); - const currentUserContext = useCurrentUserContext(); - const currentUser = currentUserContext?.data?.data; const navigate = useNavigate(); const compile = useCompile(); const form = useForm(); const { modal } = App.useApp(); + const variables = useVariables(); + const localVariables = useLocalVariables({ currentForm: form }); + const { name, getField } = useCollection(); return { async onClick() { @@ -420,7 +502,29 @@ export const useCustomizeUpdateActionProps = () => { onSuccess, skipValidator, } = actionSchema?.['x-action-settings'] ?? {}; - const assignedValues = parse(originalAssignedValues)({ currentTime: new Date(), currentRecord, currentUser }); + + const assignedValues = {}; + const waitList = Object.keys(originalAssignedValues).map(async (key) => { + const value = originalAssignedValues[key]; + const collectionField = getField(key); + + if (process.env.NODE_ENV !== 'production') { + if (!collectionField) { + throw new Error(`useCustomizeUpdateActionProps: field "${key}" not found in collection "${name}"`); + } + } + + if (isVariable(value)) { + const result = await variables?.parseVariable(value, localVariables); + if (result) { + assignedValues[key] = transformVariableValue(result, { targetCollectionField: collectionField }); + } + } else if (value != null && value !== '') { + assignedValues[key] = value; + } + }); + await Promise.all(waitList); + if (skipValidator === false) { await form.submit(); } @@ -463,13 +567,14 @@ export const useCustomizeBulkUpdateActionProps = () => { const { rowKey } = tableBlockContext; const selectedRecordKeys = tableBlockContext.field?.data?.selectedRowKeys ?? expressionScope?.selectedRecordKeys ?? {}; - const currentUserContext = useCurrentUserContext(); - const currentUser = currentUserContext?.data?.data; const navigate = useNavigate(); const compile = useCompile(); const { t } = useTranslation(); const actionField = useField(); const { modal } = App.useApp(); + const variables = useVariables(); + const localVariables = useLocalVariables(); + const { name, getField } = useCollection(); return { async onClick() { @@ -480,7 +585,29 @@ export const useCustomizeBulkUpdateActionProps = () => { } = actionSchema?.['x-action-settings'] ?? {}; actionField.data = field.data || {}; actionField.data.loading = true; - const assignedValues = parse(originalAssignedValues)({ currentTime: new Date(), currentUser }); + + const assignedValues = {}; + const waitList = Object.keys(originalAssignedValues).map(async (key) => { + const value = originalAssignedValues[key]; + const collectionField = getField(key); + + if (process.env.NODE_ENV !== 'production') { + if (!collectionField) { + throw new Error(`useCustomizeBulkUpdateActionProps: field "${key}" not found in collection "${name}"`); + } + } + + if (isVariable(value)) { + const result = await variables?.parseVariable(value, localVariables); + if (result) { + assignedValues[key] = transformVariableValue(result, { targetCollectionField: collectionField }); + } + } else if (value != null && value !== '') { + assignedValues[key] = value; + } + }); + await Promise.all(waitList); + modal.confirm({ title: t('Bulk update'), content: updateMode === 'selected' ? t('Update selected data?') : t('Update all data?'), @@ -644,6 +771,7 @@ export const useCustomizeRequestActionProps = () => { const actionField = useField(); const { setVisible } = useActionContext(); const { modal } = App.useApp(); + const { getActiveFieldsName } = useFormActiveFields(); return { async onClick() { @@ -662,7 +790,15 @@ export const useCustomizeRequestActionProps = () => { const methods = ['POST', 'PUT', 'PATCH']; if (xAction === 'customize:form:request' && methods.includes(requestSettings['method'])) { const fieldNames = fields.map((field) => field.name); - const values = getFormValues(filterByTk, field, form, fieldNames, getField, resource); + const values = getFormValues({ + filterByTk, + field, + form, + fieldNames, + getField, + resource, + actionFields: getActiveFieldsName('form'), + }); Object.assign(data, values); } const requestBody = { @@ -715,20 +851,19 @@ export const useCustomizeRequestActionProps = () => { export const useUpdateActionProps = () => { const form = useForm(); const filterByTk = useFilterByTk(); - const { field, resource, __parent, service } = useBlockRequestContext(); + const { field, resource, __parent } = useBlockRequestContext(); const { setVisible } = useActionContext(); const actionSchema = useFieldSchema(); const navigate = useNavigate(); - const { fields, getField } = useCollection(); + const { fields, getField, name } = useCollection(); const compile = useCompile(); const actionField = useField(); const { updateAssociationValues } = useFormBlockContext(); - const currentRecord = useRecord(); - const currentUserContext = useCurrentUserContext(); const { modal } = App.useApp(); - - const currentUser = currentUserContext?.data?.data; const data = useParamsFromRecord(); + const variables = useVariables(); + const localVariables = useLocalVariables({ currentForm: form }); + const { getActiveFieldsName } = useFormActiveFields(); return { async onClick() { @@ -739,12 +874,42 @@ export const useUpdateActionProps = () => { skipValidator, triggerWorkflows, } = actionSchema?.['x-action-settings'] ?? {}; - const assignedValues = parse(originalAssignedValues)({ currentTime: new Date(), currentRecord, currentUser }); + + const assignedValues = {}; + const waitList = Object.keys(originalAssignedValues).map(async (key) => { + const value = originalAssignedValues[key]; + const collectionField = getField(key); + + if (process.env.NODE_ENV !== 'production') { + if (!collectionField) { + throw new Error(`useUpdateActionProps: field "${key}" not found in collection "${name}"`); + } + } + + if (isVariable(value)) { + const result = await variables?.parseVariable(value, localVariables); + if (result) { + assignedValues[key] = transformVariableValue(result, { targetCollectionField: collectionField }); + } + } else if (value != null && value !== '') { + assignedValues[key] = value; + } + }); + await Promise.all(waitList); + if (!skipValidator) { await form.submit(); } const fieldNames = fields.map((field) => field.name); - const values = getFormValues(filterByTk, field, form, fieldNames, getField, resource); + const values = getFormValues({ + filterByTk, + field, + form, + fieldNames, + getField, + resource, + actionFields: getActiveFieldsName('form'), + }); actionField.data = field.data || {}; actionField.data.loading = true; try { @@ -1125,6 +1290,25 @@ export const useAssociationNames = () => { const isAssociationSubfield = s.name.includes('.'); const isAssociationField = collectionfield && ['hasOne', 'hasMany', 'belongsTo', 'belongsToMany'].includes(collectionfield.type); + + // 根据联动规则中条件的字段获取一些 appends + if (s['x-linkage-rules']) { + const rules = s['x-linkage-rules']; + rules.forEach(({ condition }) => { + const type = Object.keys(condition)[0] || '$and'; + const list = condition[type]; + + list.forEach((item) => { + const fieldNames = getTargetField(item); + + // 只应该收集关系字段,只有大于 1 的时候才是关系字段 + if (fieldNames.length > 1) { + appends.add(fieldNames.join('.')); + } + }); + }); + } + const isTreeCollection = isAssociationField && getCollection(collectionfield.target)?.template === 'tree'; if (collectionfield && (isAssociationField || isAssociationSubfield) && s['x-component'] !== 'TableField') { const fieldPath = !isAssociationField && isAssociationSubfield ? getAssociationPath(s.name) : s.name; @@ -1161,3 +1345,30 @@ export const useAssociationNames = () => { getAssociationAppends(fieldSchema, ''); return { appends: [...appends], updateAssociationValues: [...updateAssociationValues] }; }; + +function getTargetField(obj) { + function getAllKeys(obj) { + const keys = []; + function traverse(o) { + Object.keys(o) + .sort() + .forEach(function (key) { + keys.push(key); + if (o[key] && typeof o[key] === 'object') { + traverse(o[key]); + } + }); + } + traverse(obj); + return keys; + } + + const keys = getAllKeys(obj); + const index = _.findIndex(keys, (key: string, index: number) => { + if (key.includes('$') && index > 0) { + return true; + } + }); + const result = keys.slice(0, index); + return result; +} diff --git a/packages/core/client/src/block-provider/hooks/useFormActiveFields.tsx b/packages/core/client/src/block-provider/hooks/useFormActiveFields.tsx new file mode 100644 index 000000000..ae651c589 --- /dev/null +++ b/packages/core/client/src/block-provider/hooks/useFormActiveFields.tsx @@ -0,0 +1,61 @@ +import React, { createContext, useContext, useMemo, useRef } from 'react'; + +interface FormActiveFieldsProviderValue { + getActiveFieldsName: (name: string) => string[]; + addActiveFieldName: (fieldName: string) => void; + removeActiveFieldName: (fieldName: string) => void; + name: string; + parent: FormActiveFieldsProviderValue; +} + +const FormActiveFieldsContext = createContext(null); + +interface ProviderProps extends Partial { + children: React.ReactNode; +} + +export const FormActiveFieldsProvider = ({ + children, + name, + getActiveFieldsName, + addActiveFieldName, + removeActiveFieldName, +}: ProviderProps) => { + const activeFieldsNameRef = useRef>(new Set()); + const upLevelContext = useContext(FormActiveFieldsContext); + + const value = useMemo(() => { + return { + getActiveFieldsName: + getActiveFieldsName || + ((_name: string) => { + if (!_name || _name === name) { + return Array.from(activeFieldsNameRef.current); + } + return upLevelContext?.getActiveFieldsName(_name); + }), + addActiveFieldName: + addActiveFieldName || + ((field: string) => { + activeFieldsNameRef.current.add(field); + }), + removeActiveFieldName: + removeActiveFieldName || + ((field: string) => { + activeFieldsNameRef.current.delete(field); + }), + name, + parent: upLevelContext, + }; + }, [addActiveFieldName, getActiveFieldsName, name, removeActiveFieldName, upLevelContext]); + + return {children}; +}; + +/** + * 用于获取和更新当前表单区块中已显示出来的字段 + * @returns + */ +export const useFormActiveFields = () => { + return useContext(FormActiveFieldsContext); +}; diff --git a/packages/core/client/src/collection-manager/context.ts b/packages/core/client/src/collection-manager/context.ts index 476ca34e5..475666caa 100644 --- a/packages/core/client/src/collection-manager/context.ts +++ b/packages/core/client/src/collection-manager/context.ts @@ -6,7 +6,7 @@ export const CollectionManagerContext = createContext( interfaces: {}, }); -export const CollectionContext = createContext({}); +export const CollectionContext = createContext({} as CollectionOptions); export const CollectionFieldContext = createContext({}); diff --git a/packages/core/client/src/collection-manager/hooks/useCollectionManager.ts b/packages/core/client/src/collection-manager/hooks/useCollectionManager.ts index 7015663ba..f7d0f2f17 100644 --- a/packages/core/client/src/collection-manager/hooks/useCollectionManager.ts +++ b/packages/core/client/src/collection-manager/hooks/useCollectionManager.ts @@ -4,11 +4,12 @@ import _, { reduce, unionBy, uniq, uniqBy } from 'lodash'; import { useContext } from 'react'; import { useCompile } from '../../schema-component'; import { CollectionManagerContext } from '../context'; -import { CollectionFieldOptions } from '../types'; +import { CollectionFieldOptions, CollectionOptions } from '../types'; export const useCollectionManager = () => { - const { refreshCM, updateCollection, service, interfaces, collections, templates } = - useContext(CollectionManagerContext); + const { refreshCM, updateCollection, service, interfaces, collections, templates } = useContext( + CollectionManagerContext, + ); const compile = useCompile(); const getInheritedFields = (name) => { const inheritKeys = getInheritCollections(name); @@ -192,7 +193,7 @@ export const useCollectionManager = () => { return options; }; - const getCollection = (name: any) => { + const getCollection = (name: any): CollectionOptions => { if (typeof name !== 'string') { return name; } @@ -290,7 +291,7 @@ export const useCollectionManager = () => { getCollectionFieldsOptions, getCurrentCollectionFields, getCollection, - getCollectionJoinField(name: string) { + getCollectionJoinField(name: string): CollectionFieldOptions { if (!name) { return; } diff --git a/packages/core/client/src/collection-manager/interfaces/json.tsx b/packages/core/client/src/collection-manager/interfaces/json.tsx index 5963b5b0e..d63f4503d 100644 --- a/packages/core/client/src/collection-manager/interfaces/json.tsx +++ b/packages/core/client/src/collection-manager/interfaces/json.tsx @@ -1,7 +1,7 @@ import { FormItem, FormLayout } from '@formily/antd-v5'; import { registerValidateRules } from '@formily/core'; import React from 'react'; -import { defaultProps } from './properties'; +import { defaultProps, operators } from './properties'; import { IField } from './types'; registerValidateRules({ @@ -59,5 +59,7 @@ export const json: IField = { 'x-disabled': `{{ disabledJSONB }}`, }, }, - filterable: {}, + filterable: { + operators: operators.string, + }, }; diff --git a/packages/core/client/src/collection-manager/interfaces/linkTo.ts b/packages/core/client/src/collection-manager/interfaces/linkTo.ts index e347c3eba..29af362b2 100644 --- a/packages/core/client/src/collection-manager/interfaces/linkTo.ts +++ b/packages/core/client/src/collection-manager/interfaces/linkTo.ts @@ -138,5 +138,4 @@ export const linkTo: IField = { // }, ], }, - invariable: true, }; diff --git a/packages/core/client/src/collection-manager/interfaces/m2m.tsx b/packages/core/client/src/collection-manager/interfaces/m2m.tsx index 9b51f0ba1..2184c4f4e 100644 --- a/packages/core/client/src/collection-manager/interfaces/m2m.tsx +++ b/packages/core/client/src/collection-manager/interfaces/m2m.tsx @@ -1,10 +1,6 @@ import { ISchema } from '@formily/react'; import { uid } from '@formily/shared'; -import { - defaultProps, - relationshipType, - reverseFieldProperties -} from './properties'; +import { defaultProps, relationshipType, reverseFieldProperties } from './properties'; import { IField } from './types'; export const m2m: IField = { @@ -246,5 +242,4 @@ export const m2m: IField = { // }, ], }, - invariable: true, }; diff --git a/packages/core/client/src/collection-manager/interfaces/m2o.tsx b/packages/core/client/src/collection-manager/interfaces/m2o.tsx index 2ac8dc1e6..bf16abee4 100644 --- a/packages/core/client/src/collection-manager/interfaces/m2o.tsx +++ b/packages/core/client/src/collection-manager/interfaces/m2o.tsx @@ -1,9 +1,5 @@ import { ISchema } from '@formily/react'; -import { - constraintsProps, - relationshipType, - reverseFieldProperties -} from './properties'; +import { constraintsProps, relationshipType, reverseFieldProperties } from './properties'; import { IField } from './types'; export const m2o: IField = { @@ -178,5 +174,4 @@ export const m2o: IField = { // }, ], }, - invariable: true, }; diff --git a/packages/core/client/src/collection-manager/interfaces/markdown.ts b/packages/core/client/src/collection-manager/interfaces/markdown.ts index 918c4fbd6..ff1deed26 100644 --- a/packages/core/client/src/collection-manager/interfaces/markdown.ts +++ b/packages/core/client/src/collection-manager/interfaces/markdown.ts @@ -1,6 +1,6 @@ import { ISchema } from '@formily/react'; import { i18n } from '../../i18n'; -import { defaultProps } from './properties'; +import { defaultProps, operators } from './properties'; import { IField } from './types'; export const markdown: IField = { @@ -69,4 +69,7 @@ export const markdown: IField = { }, }; }, + filterable: { + operators: operators.string, + }, }; diff --git a/packages/core/client/src/collection-manager/interfaces/o2m.tsx b/packages/core/client/src/collection-manager/interfaces/o2m.tsx index 800e07502..57ad67386 100644 --- a/packages/core/client/src/collection-manager/interfaces/o2m.tsx +++ b/packages/core/client/src/collection-manager/interfaces/o2m.tsx @@ -198,5 +198,4 @@ export const o2m: IField = { // }, ], }, - invariable: true, }; diff --git a/packages/core/client/src/collection-manager/interfaces/o2o.tsx b/packages/core/client/src/collection-manager/interfaces/o2o.tsx index 82610e2da..b80307741 100644 --- a/packages/core/client/src/collection-manager/interfaces/o2o.tsx +++ b/packages/core/client/src/collection-manager/interfaces/o2o.tsx @@ -178,7 +178,6 @@ export const o2o: IField = { // }, ], }, - invariable: true, }; export const oho: IField = { @@ -349,7 +348,6 @@ export const oho: IField = { // }, ], }, - invariable: true, }; export const obo: IField = { @@ -524,5 +522,4 @@ export const obo: IField = { // }, ], }, - invariable: true, }; diff --git a/packages/core/client/src/collection-manager/interfaces/properties/operators.ts b/packages/core/client/src/collection-manager/interfaces/properties/operators.ts index 0afd4042b..d91ca9ab0 100644 --- a/packages/core/client/src/collection-manager/interfaces/properties/operators.ts +++ b/packages/core/client/src/collection-manager/interfaces/properties/operators.ts @@ -45,6 +45,11 @@ export const array = [ { label: '{{t("is not empty")}}', value: '$notEmpty', noValue: true }, ]; +export const object = [ + { label: '{{t("is")}}', value: '$eq', selected: true }, + { label: '{{t("is not")}}', value: '$ne' }, +]; + export const datetime = [ { label: "{{ t('is') }}", value: '$dateOn', selected: true }, { label: "{{ t('is not') }}", value: '$dateNotOn' }, @@ -168,4 +173,4 @@ export const collection = [ }, { label: '{{t("is empty")}}', value: '$empty', noValue: true }, { label: '{{t("is not empty")}}', value: '$notEmpty', noValue: true }, -]; \ No newline at end of file +]; diff --git a/packages/core/client/src/collection-manager/interfaces/richText.ts b/packages/core/client/src/collection-manager/interfaces/richText.ts index 9d7d622ef..05f02ebd1 100644 --- a/packages/core/client/src/collection-manager/interfaces/richText.ts +++ b/packages/core/client/src/collection-manager/interfaces/richText.ts @@ -1,7 +1,7 @@ import type { ISchema } from '@formily/react'; -import { defaultProps } from './properties'; -import { IField } from './types'; import { i18n } from '../../i18n'; +import { defaultProps, operators } from './properties'; +import { IField } from './types'; export const richText: IField = { name: 'richText', @@ -70,4 +70,7 @@ export const richText: IField = { }, }; }, + filterable: { + operators: operators.string, + }, }; diff --git a/packages/core/client/src/collection-manager/interfaces/textarea.ts b/packages/core/client/src/collection-manager/interfaces/textarea.ts index 60bcfd756..42da34fc7 100644 --- a/packages/core/client/src/collection-manager/interfaces/textarea.ts +++ b/packages/core/client/src/collection-manager/interfaces/textarea.ts @@ -1,7 +1,7 @@ import { ISchema } from '@formily/react'; -import { defaultProps } from './properties'; -import { IField } from './types'; import { i18n } from '../../i18n'; +import { defaultProps, operators } from './properties'; +import { IField } from './types'; export const textarea: IField = { name: 'textarea', @@ -70,4 +70,7 @@ export const textarea: IField = { }, }; }, + filterable: { + operators: operators.string, + }, }; diff --git a/packages/core/client/src/collection-manager/interfaces/types.ts b/packages/core/client/src/collection-manager/interfaces/types.ts index 744f3c720..3d8b3ad89 100644 --- a/packages/core/client/src/collection-manager/interfaces/types.ts +++ b/packages/core/client/src/collection-manager/interfaces/types.ts @@ -9,9 +9,21 @@ interface IDefault { export interface IField extends ISchema { default?: IDefault; operators?: any[]; - filterable?: any; - /** 不支持使用变量的值进行设置 */ - invariable?: boolean; + /** + * - 如果该值为空,则在 Filter 组件中该字段会被过滤掉 + * - 如果该值为空,则不会在变量列表中看到该字段 + */ + filterable?: { + /** + * 字段所支持的操作符,会在 Filter 组件中显示,比如设置 `数据范围` 的时候可以看见 + */ + operators?: any[]; + /** + * 为当前字段添加子选项,这个子选项会在 Filter 组件中显示,比如设置 `数据范围` 的时候可以看见 + */ + children?: any[]; + [key: string]: any; + }; // NOTE: set to `true` means field could be used as a title field titleUsable?: boolean; [key: string]: any; diff --git a/packages/core/client/src/collection-manager/interfaces/url.ts b/packages/core/client/src/collection-manager/interfaces/url.ts index 81cd3e025..a314a1dab 100644 --- a/packages/core/client/src/collection-manager/interfaces/url.ts +++ b/packages/core/client/src/collection-manager/interfaces/url.ts @@ -1,5 +1,5 @@ import { ISchema } from '@formily/react'; -import { defaultProps } from './properties'; +import { defaultProps, operators } from './properties'; import { IField } from './types'; export const url: IField = { @@ -20,4 +20,7 @@ export const url: IField = { ...defaultProps, }, titleUsable: true, + filterable: { + operators: operators.string, + }, }; diff --git a/packages/core/client/src/collection-manager/templates/components/PreviewFields.tsx b/packages/core/client/src/collection-manager/templates/components/PreviewFields.tsx index b07162109..70da4711c 100644 --- a/packages/core/client/src/collection-manager/templates/components/PreviewFields.tsx +++ b/packages/core/client/src/collection-manager/templates/components/PreviewFields.tsx @@ -40,7 +40,7 @@ const PreviewCom = (props) => { sourceCollections.forEach((item) => { const collection = getCollection(item); const inherits = getInheritCollections(item); - const result = inherits.map((v) => { + const result: any[] = inherits.map((v) => { const fields = getParentCollectionFields(v, item); return { type: 'group', @@ -59,7 +59,7 @@ const PreviewCom = (props) => { const children = collection.fields .filter((v) => !['hasOne', 'hasMany', 'belongsToMany'].includes(v?.type)) ?.map((v) => { - return { value: v.name, label: t(v.uiSchema?.title) }; + return { value: v.name, key: v.name, label: t(v.uiSchema?.title) }; }) .concat(result); data.push({ diff --git a/packages/core/client/src/collection-manager/types.ts b/packages/core/client/src/collection-manager/types.ts index 10a8d2caa..d1bf64f22 100644 --- a/packages/core/client/src/collection-manager/types.ts +++ b/packages/core/client/src/collection-manager/types.ts @@ -1,36 +1,66 @@ import { ISchema } from '@formily/react'; import { ReactNode } from 'react'; +type dumpable = 'required' | 'optional' | 'skip'; +type CollectionSortable = string | boolean | { name?: string; scopeKey?: string }; + +export interface CollectionOptions { + name: string; + title?: string; + namespace?: string; + /** + * Used for @nocobase/plugin-duplicator + * @see packages/core/database/src/collection-group-manager.tss + * + * @prop {'required' | 'optional' | 'skip'} dumpable - Determine whether the collection is dumped + * @prop {string[] | string} [with] - Collections dumped with this collection + * @prop {any} [delayRestore] - A function to execute after all collections are restored + */ + duplicator?: + | dumpable + | { + dumpable: dumpable; + with?: string[] | string; + delayRestore?: any; + }; + + tableName?: string; + inherits?: string[] | string; + viewName?: string; + writableView?: boolean; + + filterTargetKey?: string; + fields?: FieldOptions[]; + model?: any; + repository?: any; + sortable?: CollectionSortable; + /** + * @default true + */ + autoGenId?: boolean; + /** + * @default 'options' + */ + magicAttribute?: string; + + tree?: string; + + template?: string; + + [key: string]: any; +} + export interface CollectionManagerOptions { service?: any; interfaces?: any; - collections?: any[]; + collections?: CollectionOptions[]; templates?: any; refreshCM?: () => Promise; children?: ReactNode; updateCollection?: (collection: any) => void; } -export interface FieldOptions { - type: string; - interface?: string; - uiSchema?: ISchema; - - [key: string]: any; -} - -export interface CollectionOptions { - name?: string; - title?: string; - filterTargetKey?: string; - targetKey?: string; - sortable?: any; - fields?: FieldOptions[]; - inherits?: string[]; - tree?: string; - template?: string; - writableView?: boolean; -} +export type FieldOptions = any; export interface ICollectionProviderProps { name?: string; diff --git a/packages/core/client/src/filter-provider/FilterProvider.tsx b/packages/core/client/src/filter-provider/FilterProvider.tsx index 831a3172c..0265ca57b 100644 --- a/packages/core/client/src/filter-provider/FilterProvider.tsx +++ b/packages/core/client/src/filter-provider/FilterProvider.tsx @@ -3,11 +3,11 @@ import { uniqBy } from 'lodash'; import React, { createContext, useEffect, useRef } from 'react'; import { useBlockRequestContext } from '../block-provider/BlockProvider'; import { SharedFilter, mergeFilter } from '../block-provider/SharedFilterProvider'; -import { CollectionFieldOptions, FieldOptions, useCollection } from '../collection-manager'; +import { CollectionFieldOptions, useCollection } from '../collection-manager'; import { removeNullCondition } from '../schema-component'; import { useAssociatedFields } from './utils'; -export interface ForeignKeyField extends FieldOptions { +export interface ForeignKeyField { /** 外键字段所在的数据表的名称 */ collectionName: string; isForeignKey: boolean; @@ -15,6 +15,8 @@ export interface ForeignKeyField extends FieldOptions { name: string; parentKey: null | string; reverseKey: null | string; + + [key: string]: any; } type Collection = ReturnType; diff --git a/packages/core/client/src/flag-provider/FlagProvider.tsx b/packages/core/client/src/flag-provider/FlagProvider.tsx new file mode 100644 index 000000000..229bfb5dd --- /dev/null +++ b/packages/core/client/src/flag-provider/FlagProvider.tsx @@ -0,0 +1,31 @@ +import React, { useMemo } from 'react'; + +export interface FlagProviderProps { + /** + * 字段是否存在于 `字段赋值` 弹窗中 + */ + isInAssignFieldValues?: boolean; + /** + * 是否存在于 `设置默认值` 弹窗中 + */ + isInSetDefaultValueDialog?: boolean; + /** + * 是否存在于 `表单数据模板` 中 + */ + isInFormDataTemplate?: boolean; + children: any; +} + +export const FlagContext = React.createContext>(null); + +export const FlagProvider = (props: FlagProviderProps) => { + const value = useMemo(() => { + return { + isInAssignFieldValues: props.isInAssignFieldValues, + isInSetDefaultValueDialog: props.isInSetDefaultValueDialog, + isInFormDataTemplate: props.isInFormDataTemplate, + }; + }, [props.isInAssignFieldValues, props.isInFormDataTemplate, props.isInSetDefaultValueDialog]); + + return {props.children}; +}; diff --git a/packages/core/client/src/flag-provider/__tests__/flag-provider.test.tsx b/packages/core/client/src/flag-provider/__tests__/flag-provider.test.tsx new file mode 100644 index 000000000..fdeae3745 --- /dev/null +++ b/packages/core/client/src/flag-provider/__tests__/flag-provider.test.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { renderHook } from 'testUtils'; +import { FlagProvider } from '../FlagProvider'; +import { useFlag } from '../hooks/useFlag'; + +describe('FlagProvider', () => { + it('should render', () => { + const { result } = renderHook(() => useFlag(), { + wrapper: ({ children }) => { + return {children}; + }, + }); + + expect(result.current.isInAssignFieldValues).toBe(true); + }); +}); diff --git a/packages/core/client/src/flag-provider/hooks/useFlag.ts b/packages/core/client/src/flag-provider/hooks/useFlag.ts new file mode 100644 index 000000000..7b7abf5cb --- /dev/null +++ b/packages/core/client/src/flag-provider/hooks/useFlag.ts @@ -0,0 +1,6 @@ +import { useContext } from 'react'; +import { FlagContext } from '../FlagProvider'; + +export const useFlag = () => { + return useContext(FlagContext); +}; diff --git a/packages/core/client/src/flag-provider/index.tsx b/packages/core/client/src/flag-provider/index.tsx new file mode 100644 index 000000000..79a849254 --- /dev/null +++ b/packages/core/client/src/flag-provider/index.tsx @@ -0,0 +1,3 @@ +export * from './FlagProvider'; + +export * from './hooks/useFlag'; diff --git a/packages/core/client/src/route-switch/antd/admin-layout/index.tsx b/packages/core/client/src/route-switch/antd/admin-layout/index.tsx index f31d54b0e..1338ef66a 100644 --- a/packages/core/client/src/route-switch/antd/admin-layout/index.tsx +++ b/packages/core/client/src/route-switch/antd/admin-layout/index.tsx @@ -25,6 +25,7 @@ import { import { Plugin } from '../../../application/Plugin'; import { useAppSpin } from '../../../application/hooks/useAppSpin'; import { useCollectionManager } from '../../../collection-manager'; +import { VariablesProvider } from '../../../variables'; const filterByACL = (schema, options) => { const { allowAll, allowMenuItemIds = [] } = options; @@ -355,7 +356,9 @@ export const AdminProvider = (props) => { - {props.children} + + {props.children} + diff --git a/packages/core/client/src/schema-component/antd/action/Action.Designer.tsx b/packages/core/client/src/schema-component/antd/action/Action.Designer.tsx index d4b70d0c0..8ea1bdb16 100644 --- a/packages/core/client/src/schema-component/antd/action/Action.Designer.tsx +++ b/packages/core/client/src/schema-component/antd/action/Action.Designer.tsx @@ -7,12 +7,14 @@ import { cloneDeep } from 'lodash'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { RemoteSelect, useCompile, useDesignable } from '../..'; -import { useCollection, useCollectionManager } from '../../../collection-manager'; +import { CollectionOptions, useCollection, useCollectionManager } from '../../../collection-manager'; +import { FlagProvider } from '../../../flag-provider'; import { useRecord } from '../../../record-provider'; import { OpenModeSchemaItems } from '../../../schema-items'; import { GeneralSchemaDesigner, SchemaSettings } from '../../../schema-settings'; import { useCollectionState } from '../../../schema-settings/DataTemplates/hooks/useCollectionState'; import { useSyncFromForm } from '../../../schema-settings/DataTemplates/utils'; +import { DefaultValueProvider } from '../../../schema-settings/hooks/useIsAllowToSetDefaultValue'; import { useLinkageAction } from './hooks'; import { requestSettingsSchema } from './utils'; @@ -481,25 +483,32 @@ function AssignedFieldValues() { ), }; const actionType = fieldSchema['x-action'] ?? ''; + const onSubmit = useCallback( + (assignedValues) => { + fieldSchema['x-action-settings']['assignedValues'] = assignedValues; + dn.emit('patch', { + schema: { + ['x-uid']: fieldSchema['x-uid'], + 'x-action-settings': fieldSchema['x-action-settings'], + }, + }); + }, + [dn, fieldSchema], + ); return ( - { - fieldSchema['x-action-settings']['assignedValues'] = assignedValues; - dn.emit('patch', { - schema: { - ['x-uid']: fieldSchema['x-uid'], - 'x-action-settings': fieldSchema['x-action-settings'], - }, - }); - dn.refresh(); - }} - /> + + false}> + + + ); } @@ -653,7 +662,7 @@ function FormWorkflowSelect(props) { const [workflowCollection, setWorkflowCollection] = useState(baseCollection.name); useFormEffects(() => { onFieldValueChange(`group[${index}].context`, (field) => { - let collection = baseCollection; + let collection: CollectionOptions = baseCollection; if (field.value) { const paths = field.value.split('.'); for (let i = 0; i < paths.length && collection; i++) { diff --git a/packages/core/client/src/schema-component/antd/action/Action.tsx b/packages/core/client/src/schema-component/antd/action/Action.tsx index f5c9e3f9d..841a9f52b 100644 --- a/packages/core/client/src/schema-component/antd/action/Action.tsx +++ b/packages/core/client/src/schema-component/antd/action/Action.tsx @@ -1,13 +1,15 @@ import { observer, RecursionField, useField, useFieldSchema, useForm } from '@formily/react'; +import { isPortalInBody } from '@nocobase/utils/client'; import { App, Button, Popover } from 'antd'; import classnames from 'classnames'; import lodash from 'lodash'; -import React, { useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useActionContext } from '../..'; import { useDesignable } from '../../'; import { Icon } from '../../../icon'; import { useRecord } from '../../../record-provider'; +import { useLocalVariables, useVariables } from '../../../variables'; import { SortableItem } from '../../common'; import { useCompile, useComponent, useDesigner } from '../../hooks'; import { useProps } from '../../hooks/useProps'; @@ -51,29 +53,74 @@ export const Action: ComposedAction = observer( const values = useRecord(); const designerProps = fieldSchema['x-designer-props']; const openMode = fieldSchema?.['x-component-props']?.['openMode']; - const disabled = form.disabled || field.disabled || props.disabled; + const disabled = form.disabled || field.disabled || field.data?.disabled || props.disabled; const openSize = fieldSchema?.['x-component-props']?.['openSize']; const linkageRules = fieldSchema?.['x-linkage-rules'] || []; const { designable } = useDesignable(); const tarComponent = useComponent(component) || component; const { modal } = App.useApp(); + const variables = useVariables(); + const localVariables = useLocalVariables({ currentForm: { values } as any }); + let actionTitle = title || compile(fieldSchema.title); actionTitle = lodash.isString(actionTitle) ? t(actionTitle) : actionTitle; useEffect(() => { - field.linkageProperty = {}; linkageRules .filter((k) => !k.disabled) .forEach((v) => { - return v.actions?.map((h) => { - linkageAction(h.operator, field, v.condition, form.initialValues); + v.actions?.forEach((h) => { + linkageAction({ + operator: h.operator, + field, + condition: v.condition, + values, + variables, + localVariables, + }); }); }); - }, [linkageRules, values, designable]); + }, [JSON.stringify(linkageRules), values, designable, field]); + + const handleButtonClick = useCallback( + (e: React.MouseEvent) => { + if (isPortalInBody(e.target as Element)) { + return; + } + + e.preventDefault(); + e.stopPropagation(); + + if (!disabled) { + const onOk = () => { + onClick?.(e); + setVisible(true); + run(); + }; + if (confirm) { + modal.confirm({ + ...confirm, + onOk, + }); + } else { + onOk(); + } + } + }, + [confirm, disabled, modal, onClick, run], + ); + + const buttonStyle = useMemo(() => { + return { + ...others.style, + opacity: designable && field?.data?.hidden && 0.1, + }; + }, [designable, field?.data?.hidden, others.style]); const renderButton = () => { if (!designable && field?.data?.hidden) { return null; } + return ( : null} disabled={disabled} - style={{ - ...others.style, - opacity: designable && field?.data?.hidden && 0.1, - }} - onClick={(e: React.MouseEvent) => { - if (!disabled) { - e.preventDefault(); - e.stopPropagation(); - const onOk = () => { - onClick?.(e); - setVisible(true); - run(); - }; - if (confirm) { - modal.confirm({ - ...confirm, - onOk, - }); - } else { - onOk(); - } - } - }} + style={buttonStyle} + onClick={handleButtonClick} component={tarComponent || Button} - className={classnames(componentCls, hashId, className)} + className={classnames(componentCls, hashId, className, 'nb-action')} type={props.type === 'danger' ? undefined : props.type} > {actionTitle} diff --git a/packages/core/client/src/schema-component/antd/action/utils.ts b/packages/core/client/src/schema-component/antd/action/utils.ts index acd91e971..c43e27eea 100644 --- a/packages/core/client/src/schema-component/antd/action/utils.ts +++ b/packages/core/client/src/schema-component/antd/action/utils.ts @@ -1,7 +1,7 @@ import type { ISchema } from '@formily/react'; -import { last } from 'lodash'; -import { conditionAnalyse } from '../../common/utils/uitls'; import { ActionType } from '../../../schema-settings/LinkageRules/type'; +import { VariableOption, VariablesContextType } from '../../../variables/types'; +import { conditionAnalyses } from '../../common/utils/uitls'; const validateJSON = { validator: `{{(value, rule)=> { if (!value) { @@ -69,52 +69,69 @@ export const requestSettingsSchema: ISchema = { }, }; -export const linkageAction = (operator, field, condition, values) => { - const disableResult = field?.linkageProperty?.disabled || [false]; - const displayResult = field?.linkageProperty?.display || ['visible']; +export const linkageAction = async ({ + operator, + field, + condition, + values, + variables, + localVariables, +}: { + operator; + field; + condition; + values; + variables: VariablesContextType; + localVariables: VariableOption[]; +}) => { + field.data = field.data || {}; + switch (operator) { case ActionType.Visible: - if (conditionAnalyse(condition, values)) { - displayResult.push(operator); - field.data = field.data || {}; - field.data.hidden = false; + reset(field); + if (await conditionAnalyses({ rules: condition, formValues: values, variables, localVariables })) { + field._display = '_display' in field ? field._display : field.display; + field.display = operator; + } else { + field.display = field._display; } - field.linkageProperty = { - ...field.linkageProperty, - display: displayResult, - }; - field.display = last(displayResult); break; case ActionType.Hidden: - if (conditionAnalyse(condition, values)) { - field.data = field.data || {}; + reset(field); + if (await conditionAnalyses({ rules: condition, formValues: values, variables, localVariables })) { field.data.hidden = true; } else { - field.data = field.data || {}; field.data.hidden = false; } break; case ActionType.Disabled: - if (conditionAnalyse(condition, values)) { - disableResult.push(true); + reset(field); + if (await conditionAnalyses({ rules: condition, formValues: values, variables, localVariables })) { + field.data.disabled = true; + field.disabled = true; + } else { + field.data.disabled = false; + field.disabled = false; } - field.linkageProperty = { - ...field.linkageProperty, - disabled: disableResult, - }; - field.disabled = last(disableResult); break; case ActionType.Active: - if (conditionAnalyse(condition, values)) { - disableResult.push(false); + reset(field); + if (await conditionAnalyses({ rules: condition, formValues: values, variables, localVariables })) { + field.data.disabled = false; + field.disabled = false; + } else { + field.data.disabled = true; + field.disabled = true; } - field.linkageProperty = { - ...field.linkageProperty, - disabled: disableResult, - }; - field.disabled = last(disableResult); break; default: return null; } }; + +function reset(field: any) { + field.display = field._display; + field.data.hidden = false; + field.data.disabled = false; + field.disabled = false; +} diff --git a/packages/core/client/src/schema-component/antd/association-field/AssociationSelect.tsx b/packages/core/client/src/schema-component/antd/association-field/AssociationSelect.tsx index deefe452b..6c8f64137 100644 --- a/packages/core/client/src/schema-component/antd/association-field/AssociationSelect.tsx +++ b/packages/core/client/src/schema-component/antd/association-field/AssociationSelect.tsx @@ -5,7 +5,7 @@ import { isFunction } from 'mathjs'; import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { RecordProvider, useAPIClient } from '../../../'; -import { isVariable } from '../../common/utils/uitls'; +import { isVariable } from '../../../variables/utils/isVariable'; import { RemoteSelect, RemoteSelectProps } from '../remote-select'; import useServiceOptions, { useAssociationFieldContext } from './hooks'; @@ -110,3 +110,26 @@ interface AssociationSelectInterface { } export const AssociationSelect = InternalAssociationSelect as unknown as AssociationSelectInterface; + +export const AssociationSelectReadPretty = connect( + (props: any) => { + const service = useServiceOptions(props); + if (props.fieldNames) { + return ; + } + return null; + }, + mapProps( + { + dataSource: 'options', + loading: true, + }, + (props, field) => { + return { + ...props, + fieldNames: props.fieldNames && { ...props.fieldNames, ...field.componentProps.fieldNames }, + suffixIcon: field?.['loading'] || field?.['validating'] ? : props.suffixIcon, + }; + }, + ), +); diff --git a/packages/core/client/src/schema-component/antd/association-field/FileManager.tsx b/packages/core/client/src/schema-component/antd/association-field/FileManager.tsx index 135692518..0a35fd9fd 100644 --- a/packages/core/client/src/schema-component/antd/association-field/FileManager.tsx +++ b/packages/core/client/src/schema-component/antd/association-field/FileManager.tsx @@ -167,7 +167,7 @@ const InternalFileManager = (props) => { }} > - + diff --git a/packages/core/client/src/schema-component/antd/association-field/InternalCascadeSelect.tsx b/packages/core/client/src/schema-component/antd/association-field/InternalCascadeSelect.tsx index a6b8200f1..54da0e865 100644 --- a/packages/core/client/src/schema-component/antd/association-field/InternalCascadeSelect.tsx +++ b/packages/core/client/src/schema-component/antd/association-field/InternalCascadeSelect.tsx @@ -1,14 +1,14 @@ -import { observer, useField, connect, createSchemaField, FormProvider, useFieldSchema, Field } from '@formily/react'; -import { createForm, onFormValuesChange } from '@formily/core'; -import { uid } from '@formily/shared'; -import { Space, Tag, Spin, Select as AntdSelect, Input } from 'antd'; import { ArrayItems, FormItem } from '@formily/antd-v5'; -import React, { useState, useCallback, useEffect, useMemo } from 'react'; +import { createForm, onFormValuesChange } from '@formily/core'; +import { FormProvider, connect, createSchemaField, observer, useField, useFieldSchema } from '@formily/react'; +import { uid } from '@formily/shared'; +import { Select as AntdSelect, Input, Space, Spin, Tag } from 'antd'; import dayjs from 'dayjs'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { useCompile, SchemaComponent } from '../../../schema-component'; import { useAPIClient, useCollectionManager } from '../../../'; import { mergeFilter } from '../../../block-provider/SharedFilterProvider'; +import { SchemaComponent, useCompile } from '../../../schema-component'; import useServiceOptions, { useAssociationFieldContext } from './hooks'; const EMPTY = 'N/A'; @@ -73,7 +73,7 @@ const CascadeSelect = connect((props) => { if (Array.isArray(label)) { label = label .map((item, index) => { - const option = targetField.uiSchema.enum.find((i) => i.value === item); + const option = (targetField.uiSchema.enum as any).find?.((i) => i.value === item); if (option) { return ( @@ -86,7 +86,7 @@ const CascadeSelect = connect((props) => { }) .reverse(); } else { - const item = targetField.uiSchema.enum.find((i) => i.value === label); + const item = (targetField.uiSchema.enum as any).find?.((i) => i.value === label); if (item) { label = {item.label}; } diff --git a/packages/core/client/src/schema-component/antd/association-field/InternalSubTable.tsx b/packages/core/client/src/schema-component/antd/association-field/InternalSubTable.tsx index 0d7c93119..6807aa979 100644 --- a/packages/core/client/src/schema-component/antd/association-field/InternalSubTable.tsx +++ b/packages/core/client/src/schema-component/antd/association-field/InternalSubTable.tsx @@ -4,7 +4,7 @@ import { RecursionField, SchemaOptionsContext, observer, useField, useFieldSchem import React, { useEffect } from 'react'; import { ACLCollectionProvider, useACLActionParamsContext } from '../../../acl'; import { CollectionProvider } from '../../../collection-manager'; -import { useSchemaOptionsContext } from '../../../schema-component'; +import { FormItem, useSchemaOptionsContext } from '../../../schema-component'; import Select from '../select/Select'; import { useAssociationFieldContext, useInsertSchema } from './hooks'; import schema from './schema'; @@ -24,8 +24,9 @@ export const InternalSubTable = observer( const option = useSchemaOptionsContext(); const components = { ...option.components, - 'Radio.Group': (props) => , + FormItem, + 'Radio.Group': Select, + 'Checkbox.Group': (props) => { - setOperator(value); - }} + onChange={onOperatorsChange} placeholder={t('Comparision')} /> - {!operator?.noValue ? : null} + {!operator?.noValue ? ( + + ) : null} {!props.disabled && ( - remove()} style={{ color: '#bfbfbf' }} /> + )} diff --git a/packages/core/client/src/schema-component/antd/filter/FilterItems.tsx b/packages/core/client/src/schema-component/antd/filter/FilterItems.tsx index b9b489f8e..c197d564b 100644 --- a/packages/core/client/src/schema-component/antd/filter/FilterItems.tsx +++ b/packages/core/client/src/schema-component/antd/filter/FilterItems.tsx @@ -1,9 +1,9 @@ import { ArrayField as ArrayFieldModel } from '@formily/core'; import { ObjectField, observer, useField } from '@formily/react'; import React from 'react'; -import { RemoveConditionContext } from './context'; import { FilterGroup } from './FilterGroup'; import { FilterItem } from './FilterItem'; +import { RemoveConditionContext } from './context'; export const FilterItems = observer( (props) => { diff --git a/packages/core/client/src/schema-component/antd/filter/context.ts b/packages/core/client/src/schema-component/antd/filter/context.ts index 34776b24b..5e80dc964 100644 --- a/packages/core/client/src/schema-component/antd/filter/context.ts +++ b/packages/core/client/src/schema-component/antd/filter/context.ts @@ -3,7 +3,7 @@ import { Schema } from '@formily/react'; import { createContext } from 'react'; export interface FilterContextProps { - field?: ObjectField; + field?: ObjectField & { collectionName?: string }; fieldSchema?: Schema; dynamicComponent?: any; options?: any[]; diff --git a/packages/core/client/src/schema-component/antd/filter/useFilterActionProps.ts b/packages/core/client/src/schema-component/antd/filter/useFilterActionProps.ts index 585134778..4eae02b73 100644 --- a/packages/core/client/src/schema-component/antd/filter/useFilterActionProps.ts +++ b/packages/core/client/src/schema-component/antd/filter/useFilterActionProps.ts @@ -46,6 +46,7 @@ export const useGetFilterFieldOptions = () => { target: field.target, title: field?.uiSchema?.title || field.name, schema: field?.uiSchema, + interface: field.interface, operators: operators?.filter?.((operator) => { return !operator?.visible || operator.visible(field); diff --git a/packages/core/client/src/schema-component/antd/filter/useValues.ts b/packages/core/client/src/schema-component/antd/filter/useValues.ts index a8343fe10..e998bfb35 100644 --- a/packages/core/client/src/schema-component/antd/filter/useValues.ts +++ b/packages/core/client/src/schema-component/antd/filter/useValues.ts @@ -3,9 +3,22 @@ import { merge } from '@formily/shared'; import flat, { unflatten } from 'flat'; import cloneDeep from 'lodash/cloneDeep'; import get from 'lodash/get'; -import { useContext, useEffect } from 'react'; -import { FilterContext } from './context'; +import { useCallback, useContext, useEffect, useMemo } from 'react'; import { useCollection, useCollectionManager } from '../../../collection-manager'; +import { FilterContext } from './context'; + +interface UseValuesReturn { + fields: any[]; + collectionField: any; + dataIndex: string[]; + operators: any[]; + operator: any; + schema: any; + value: any; + setDataIndex: (dataIndex: string[]) => void; + setOperator: (operatorValue: string) => void; + setValue: (value: any) => void; +} // import { useValues } from './useValues'; const findOption = (dataIndex = [], options) => { @@ -21,21 +34,27 @@ const findOption = (dataIndex = [], options) => { return option; }; -export const useValues = () => { - const field = useField(); +export const useValues = (): UseValuesReturn => { const { name } = useCollection(); const { getCollectionJoinField } = useCollectionManager(); - const ctx: any = useContext(FilterContext) || {}; - const { options } = ctx; - const data2value = () => { + const field = useField(); + const { options, collectionName, field: ctxField } = useContext(FilterContext) || {}; + const values: object = flat(field.value || {}); + const path = Object.keys(values).shift() || ''; + + const collectionField = useMemo(() => { + const [fieldPath = ''] = path.split('.$'); + return getCollectionJoinField(`${collectionName || name}.${fieldPath}`); + }, [name, path]); + + const data2value = useCallback(() => { field.value = flat.unflatten({ [`${field.data.dataIndex?.join('.')}.${field.data?.operator?.value}`]: field.data?.value, }); - }; + }, [field]); + const value2data = () => { field.data = field.data || {}; - const values: object = flat(field.value || {}); - const path = Object.keys(values).shift() || ''; if (!path || !options) { return; } @@ -50,20 +69,20 @@ export const useValues = () => { const fieldNames = dataIndex.concat(); fieldNames.pop(); const targetField = getCollectionJoinField(`${name}.${fieldNames.join('.')}`); - ctx.field.collectionName = targetField?.target; + ctxField.collectionName = targetField?.target; } else { - ctx.field.collectionName = null; + ctxField.collectionName = null; } field.data.operators = operators; field.data.operator = operator; field.data.schema = merge(option?.schema, operator?.schema); field.data.value = get(unflatten(field.value), `${fieldPath}.$${operatorValue}`); }; + useEffect(value2data, [field.path]); - return { - fields: options, - ...(field?.data || {}), - setDataIndex(dataIndex) { + + const setDataIndex = useCallback( + (dataIndex) => { const option = findOption(dataIndex, options); const operator = option?.operators?.[0]; field.data = field.data || {}; @@ -77,14 +96,18 @@ export const useValues = () => { const fieldNames = dataIndex.concat(); fieldNames.pop(); const targetField = getCollectionJoinField(`${name}.${fieldNames.join('.')}`); - ctx.field.collectionName = targetField?.target; + ctxField.collectionName = targetField?.target; } else { - ctx.field.collectionName = null; + ctxField.collectionName = null; } field.data.value = operator?.noValue ? operator.default || true : undefined; data2value(); }, - setOperator(operatorValue) { + [data2value, field, options], + ); + + const setOperator = useCallback( + (operatorValue) => { const operator = field.data?.operators?.find?.((item) => item.value === operatorValue); field.data.operator = operator; const option = findOption(field.data.dataIndex, options); @@ -94,9 +117,23 @@ export const useValues = () => { field.data.value = operator.noValue ? operator.default || true : undefined; data2value(); }, - setValue(value) { + [data2value, field.data, options], + ); + + const setValue = useCallback( + (value) => { field.data.value = value; data2value(); }, + [data2value, field.data], + ); + + return { + fields: options, + ...(field?.data || {}), + collectionField, + setDataIndex, + setOperator, + setValue, }; }; diff --git a/packages/core/client/src/schema-component/antd/form-item/FormItem.tsx b/packages/core/client/src/schema-component/antd/form-item/FormItem.tsx index 07bea8085..bbf082594 100644 --- a/packages/core/client/src/schema-component/antd/form-item/FormItem.tsx +++ b/packages/core/client/src/schema-component/antd/form-item/FormItem.tsx @@ -3,156 +3,89 @@ import { ArrayCollapse, FormLayout, FormItem as Item } from '@formily/antd-v5'; import { Field } from '@formily/core'; import { ISchema, observer, useField, useFieldSchema } from '@formily/react'; import { Select } from 'antd'; -import dayjs from 'dayjs'; import _ from 'lodash'; -import React, { useEffect } from 'react'; +import React, { useEffect, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { ACLCollectionFieldProvider } from '../../../acl/ACLProvider'; -import { useBlockRequestContext } from '../../../block-provider/BlockProvider'; +import { useFormActiveFields } from '../../../block-provider'; import { useFormBlockContext } from '../../../block-provider/FormBlockProvider'; -import { - Collection, - CollectionFieldOptions, - useCollection, - useCollectionFilterOptions, - useCollectionManager, -} from '../../../collection-manager'; +import { Collection, useCollection, useCollectionManager } from '../../../collection-manager'; +import { useRecord } from '../../../record-provider'; import { GeneralSchemaItems } from '../../../schema-items/GeneralSchemaItems'; -import { GeneralSchemaDesigner, SchemaSettings, isPatternDisabled, isShowDefaultValue } from '../../../schema-settings'; +import { GeneralSchemaDesigner, SchemaSettings, isPatternDisabled } from '../../../schema-settings'; +import { VariableInput, getShouldChange } from '../../../schema-settings/VariableInput/VariableInput'; +import useIsAllowToSetDefaultValue from '../../../schema-settings/hooks/useIsAllowToSetDefaultValue'; import { useIsShowMultipleSwitch } from '../../../schema-settings/hooks/useIsShowMultipleSwitch'; -import { isVariable, parseVariables, useVariablesCtx } from '../../common/utils/uitls'; +import { useLocalVariables, useVariables } from '../../../variables'; +import useContextVariable from '../../../variables/hooks/useContextVariable'; import { useCompile, useDesignable, useFieldModeOptions } from '../../hooks'; +import { isSubMode } from '../association-field/util'; import { BlockItem } from '../block-item'; import { removeNullCondition } from '../filter'; +import { DynamicComponentProps } from '../filter/DynamicComponent'; import { HTMLEncode } from '../input/shared'; -import { FilterDynamicComponent } from '../table-v2/FilterDynamicComponent'; import { useColorFields } from '../table-v2/Table.Column.Designer'; import { FilterFormDesigner } from './FormItem.FilterFormDesigner'; import { useEnsureOperatorsValid } from './SchemaSettingOptions'; +import useLazyLoadAssociationFieldOfForm from './hooks/useLazyLoadAssociationFieldOfForm'; +import useLazyLoadAssociationFieldOfSubForm from './hooks/useLazyLoadAssociationFieldOfSubForm'; +import useParseDefaultValue from './hooks/useParseDefaultValue'; -export const findColumnFieldSchema = (fieldSchema, getCollectionJoinField) => { - const childsSchema = new Set(); - const getAssociationAppends = (schema) => { - schema.reduceProperties((_, s) => { - const collectionfield = s['x-collection-field'] && getCollectionJoinField(s['x-collection-field']); - const isAssociationField = collectionfield && ['belongsTo'].includes(collectionfield.type); - if (collectionfield && isAssociationField && s.default?.includes?.('$context')) { - childsSchema.add(JSON.stringify({ name: s.name, default: s.default })); - } else { - getAssociationAppends(s); - } - }, []); - }; - - getAssociationAppends(fieldSchema); - return [...childsSchema]; -}; -function transformData(inputData) { - const transformedData = []; - const keys = Object.keys(inputData) || []; - const values: any[] = Object.values(inputData) || []; - for (let i = 0; i < values[0]?.length; i++) { - const newObj = {}; - keys.forEach((key, index) => { - newObj[key] = values[index][i]; - }); - transformedData.push(newObj); - } - - return transformedData; -} export const FormItem: any = observer( (props: any) => { useEnsureOperatorsValid(); const field = useField(); - const ctx = useBlockRequestContext(); const schema = useFieldSchema(); - const variablesCtx = useVariablesCtx(); - const { getCollectionJoinField } = useCollectionManager(); - const collectionField = getCollectionJoinField(schema['x-collection-field']); + const contextVariable = useContextVariable(); + const variables = useVariables(); + const { addActiveFieldName } = useFormActiveFields() || {}; useEffect(() => { - if (ctx?.block === 'form') { - ctx.field.data = ctx.field.data || {}; - ctx.field.data.activeFields = ctx.field.data.activeFields || new Set(); - ctx.field.data.activeFields.add(schema.name); - // 如果默认值是一个变量,则需要解析之后再显示出来 - if (isVariable(schema?.default) && !schema?.default.includes('$context')) { - field.setInitialValue?.(parseVariables(schema.default, variablesCtx)); - } else if ( - isVariable(schema?.default) && - schema?.default?.includes('$context') && - collectionField?.interface === 'm2m' - ) { - // 直接对多 - const contextData = parseVariables('{{$context}}', variablesCtx); - let iniValues = []; - contextData?.map((v) => { - const data = parseVariables(schema.default, { $context: v }); - iniValues = iniValues.concat(data).map((v) => { - delete v[collectionField.through]; - return v; - }); - }); - const data = _.uniqBy(iniValues, 'id'); - field.setInitialValue?.(data.length > 0 ? data : [{}]); - } else if ( - collectionField?.interface === 'o2m' && - ['SubTable', 'Nester'].includes(schema?.['x-component-props']?.['mode']) // 间接对多 - ) { - const childrenFieldWithDefault = findColumnFieldSchema(schema, getCollectionJoinField); - // 子表格/子表单中找出所有belongsTo字段的上下文默认值 - if (childrenFieldWithDefault.length > 0) { - const tableData = parseVariables('{{$context}}', variablesCtx); - const contextData = {}; - // 将数据拍平 - childrenFieldWithDefault?.forEach((s: any) => { - const child = JSON.parse(s); - tableData?.map((v) => { - contextData[child.name] = _.uniqBy( - (contextData[child.name] || []).concat(parseVariables(child.default, { $context: v })), - 'id', - ); - }); - }); - const initValues = transformData(contextData); - field.setInitialValue?.(initValues); - } - } - } - }, []); + variables?.registerVariable(contextVariable); + }, [contextVariable]); + + // 需要放在注冊完变量之后 + useParseDefaultValue(); + useLazyLoadAssociationFieldOfForm(); + useLazyLoadAssociationFieldOfSubForm(); + + useEffect(() => { + addActiveFieldName?.(schema.name as string); + }, [addActiveFieldName, schema.name]); + const showTitle = schema['x-decorator-props']?.showTitle ?? true; + const extra = useMemo(() => { + return typeof field.description === 'string' ? ( +
'), + }} + /> + ) : ( + field.description + ); + }, [field.description]); + const className = useMemo(() => { + return cx( + css` + & .ant-space { + flex-wrap: wrap; + } + `, + { + [css` + > .ant-formily-item-label { + display: none; + } + `]: showTitle === false, + }, + ); + }, [showTitle]); + return ( - .ant-formily-item-label { - display: none; - } - `]: showTitle === false, - }, - )} - {...props} - extra={ - typeof field.description === 'string' ? ( -
'), - }} - /> - ) : ( - field.description - ) - } - /> + ); @@ -161,12 +94,17 @@ export const FormItem: any = observer( ); FormItem.Designer = function Designer() { - let targetField; - const { getCollectionFields, getInterface, getCollectionJoinField, getCollection, isTitleField } = - useCollectionManager(); + const { + getCollectionFields, + getInterface, + getCollectionJoinField, + getCollection, + isTitleField, + getAllCollectionsInheritChain, + } = useCollectionManager(); const { getField } = useCollection(); const { form } = useFormBlockContext(); - const ctx = useBlockRequestContext(); + const record = useRecord(); const field = useField(); const fieldSchema = useFieldSchema(); const { t } = useTranslation(); @@ -174,11 +112,10 @@ FormItem.Designer = function Designer() { const compile = useCompile(); const IsShowMultipleSwitch = useIsShowMultipleSwitch(); const collectionField = getField(fieldSchema['name']) || getCollectionJoinField(fieldSchema['x-collection-field']); - if (collectionField?.target) { - targetField = getCollectionJoinField( - `${collectionField.target}.${fieldSchema['x-component-props']?.fieldNames?.label || 'id'}`, - ); - } + const variables = useVariables(); + const localVariables = useLocalVariables(); + const { isAllowToSetDefaultValue } = useIsAllowToSetDefaultValue(); + const isAddNewForm = useIsAddNewForm(); const targetCollection = getCollection(collectionField?.target); const interfaceConfig = getInterface(collectionField?.interface); @@ -190,7 +127,7 @@ FormItem.Designer = function Designer() { const fieldModeOptions = useFieldModeOptions(); const isAssociationField = ['obo', 'oho', 'o2o', 'o2m', 'm2m', 'm2o'].includes(collectionField?.interface); const isTableField = fieldSchema['x-component'] === 'TableField'; - const isFileField = isFileCollection(targetCollection); + const isFileField = isFileCollection(targetCollection as any); const initialValue = { title: field.title === originalTitle ? undefined : field.title, }; @@ -214,8 +151,6 @@ FormItem.Designer = function Designer() { if (fieldSchema['x-read-pretty'] === true) { readOnlyMode = 'read-pretty'; } - const dataSource = useCollectionFilterOptions(collectionField?.target); - const defaultFilter = fieldSchema?.['x-component-props']?.service?.params?.filter || {}; const fieldMode = field?.componentProps?.['mode'] || (isFileField ? 'FileManager' : 'Select'); const isSelectFieldMode = isAssociationField && fieldMode === 'Select'; const isSubFormMode = fieldSchema['x-component-props']?.mode === 'Nester'; @@ -365,10 +300,6 @@ FormItem.Designer = function Designer() { const schema = { ['x-uid']: fieldSchema['x-uid'], }; - // return; - // if (['number'].includes(collectionField?.interface) && collectionField?.uiSchema?.['x-component-props']?.['stringMode'] === true) { - // rules['numberStringMode'] = true; - // } if (['percent'].includes(collectionField?.interface)) { for (const rule of rules) { if (!!rule.maxValue || !!rule.minValue) { @@ -391,41 +322,33 @@ FormItem.Designer = function Designer() { }} /> )} - {form && - !form?.readPretty && - isShowDefaultValue(collectionField, getInterface) && - !isPatternDisabled(fieldSchema) && } + {isAllowToSetDefaultValue() && } {isSelectFieldMode && !field.readPretty && ( - - FilterDynamicComponent({ - ...props, - form, - collectionField, - rootCollection: ctx.props.collection || ctx.props.resource, - }), - }, - }, - }, - } as ISchema - } + { + return ( + + ); + }} onSubmit={({ filter }) => { filter = removeNullCondition(filter); _.set(field.componentProps, 'service.params.filter', filter); fieldSchema['x-component-props'] = field.componentProps; - dn.emit('patch', { + void dn.emit('patch', { schema: { ['x-uid']: fieldSchema['x-uid'], 'x-component-props': field.componentProps, @@ -450,7 +373,17 @@ FormItem.Designer = function Designer() { schema['x-component-props'] = fieldSchema['x-component-props']; field.componentProps = field.componentProps || {}; field.componentProps.mode = mode; - dn.emit('patch', { + + // 子表单状态不允许设置默认值 + if (isSubMode(fieldSchema) && isAddNewForm) { + // @ts-ignore + schema.default = null; + fieldSchema.default = null; + field.setInitialValue(null); + field.setValue(null); + } + + void dn.emit('patch', { schema, }); dn.refresh(); @@ -800,10 +733,9 @@ export function isFileCollection(collection: Collection) { FormItem.FilterFormDesigner = FilterFormDesigner; -export function getFieldDefaultValue(fieldSchema: ISchema, collectionField: CollectionFieldOptions) { - const result = fieldSchema?.default ?? collectionField?.defaultValue; - if (collectionField?.uiSchema?.['x-component'] === 'DatePicker' && result) { - return dayjs(result); - } - return result; +function useIsAddNewForm() { + const record = useRecord(); + const isAddNewForm = _.isEmpty(_.omit(record, '__parent')); + + return isAddNewForm; } diff --git a/packages/core/client/src/schema-component/antd/form-item/SchemaSettingOptions.tsx b/packages/core/client/src/schema-component/antd/form-item/SchemaSettingOptions.tsx index 3f24d67dc..a86aaca61 100644 --- a/packages/core/client/src/schema-component/antd/form-item/SchemaSettingOptions.tsx +++ b/packages/core/client/src/schema-component/antd/form-item/SchemaSettingOptions.tsx @@ -382,7 +382,7 @@ export const EditComponent = () => { const fieldModeOptions = useFieldModeOptions(); const isAssociationField = ['belongsTo', 'hasOne', 'hasMany', 'belongsToMany'].includes(collectionField?.type); const targetCollection = getCollection(collectionField?.target); - const isFileField = isFileCollection(targetCollection); + const isFileField = isFileCollection(targetCollection as any); return isAssociationField && fieldModeOptions ? ( { + it('value is an array', () => { + const value = [{ a: 3 }, { a: 4 }]; + const deps = { + field: { + value: [{ a: 1, b: 11 }], + }, + subFieldSchema: { + name: 'a', + }, + }; + + expect(transformValue(value, deps as any)).toMatchInlineSnapshot(` + [ + { + "a": { + "a": 3, + }, + "b": 11, + }, + { + "__notFromDatabase": true, + "a": { + "a": 4, + }, + "b": 11, + }, + ] + `); + }); +}); diff --git a/packages/core/client/src/schema-component/antd/form-item/hooks/useLazyLoadAssociationFieldOfForm.ts b/packages/core/client/src/schema-component/antd/form-item/hooks/useLazyLoadAssociationFieldOfForm.ts new file mode 100644 index 000000000..59ecc4c99 --- /dev/null +++ b/packages/core/client/src/schema-component/antd/form-item/hooks/useLazyLoadAssociationFieldOfForm.ts @@ -0,0 +1,50 @@ +import { Field } from '@formily/core'; +import { useField, useFieldSchema, useForm } from '@formily/react'; +import { useEffect } from 'react'; +import { useCollection, useCollectionManager } from '../../../../collection-manager'; +import { useVariables } from '../../../../variables'; +import { transformVariableValue } from '../../../../variables/utils/transformVariableValue'; + +/** + * 用于懒加载 Form 区块中只用于展示的关联字段的值 + */ +const useLazyLoadAssociationFieldOfForm = () => { + const { name } = useCollection(); + const { getCollectionJoinField } = useCollectionManager(); + const form = useForm(); + const fieldSchema = useFieldSchema(); + const variables = useVariables(); + const field = useField(); + + const schemaName = fieldSchema.name.toString(); + + useEffect(() => { + // 在数据表管理页面,也存在 `a.b` 之类的 schema name,其 collectionName 为 fields,所以在这里排除一下 `name === 'fields'` 的情况 + if (!schemaName.includes('.') || !variables || name === 'fields') { + return; + } + + const formVariable = { + name: '$nForm', + ctx: form.values, + collectionName: name, + }; + const variableString = `{{ $nForm.${schemaName} }}`; + const collectionField = getCollectionJoinField(`${name}.${schemaName}`); + + if (!collectionField) { + return console.error(new Error(`useLazyLoadAssociationField: ${schemaName} not found in collection ${name}`)); + } + + variables + .parseVariable(variableString, formVariable) + .then((value) => { + field.value = transformVariableValue(value, { targetCollectionField: collectionField }); + }) + .catch((err) => { + console.error(err); + }); + }, [schemaName.includes('.') ? form.values[schemaName.split('.')[0]] : null]); +}; + +export default useLazyLoadAssociationFieldOfForm; diff --git a/packages/core/client/src/schema-component/antd/form-item/hooks/useLazyLoadAssociationFieldOfSubForm.ts b/packages/core/client/src/schema-component/antd/form-item/hooks/useLazyLoadAssociationFieldOfSubForm.ts new file mode 100644 index 000000000..6a5e8925b --- /dev/null +++ b/packages/core/client/src/schema-component/antd/form-item/hooks/useLazyLoadAssociationFieldOfSubForm.ts @@ -0,0 +1,62 @@ +import { Field } from '@formily/core'; +import { useField, useFieldSchema } from '@formily/react'; +import _ from 'lodash'; +import { useEffect } from 'react'; +import { useRecord } from '../../../..'; +import { useCollection, useCollectionManager } from '../../../../collection-manager'; +import { useFlag } from '../../../../flag-provider'; +import { useVariables } from '../../../../variables'; +import { transformVariableValue } from '../../../../variables/utils/transformVariableValue'; + +/** + * 用于懒加载 subForm 区块中的关系字段 + */ +const useLazyLoadAssociationFieldOfSubForm = () => { + const { name } = useCollection(); + const { getCollectionJoinField } = useCollectionManager(); + const record = useRecord(); + const fieldSchema = useFieldSchema(); + const variables = useVariables(); + const field = useField(); + const { isInAssignFieldValues } = useFlag() || {}; + + const schemaName = fieldSchema.name.toString(); + + useEffect(() => { + if (isInAssignFieldValues) { + return; + } + + const collectionField = getCollectionJoinField(`${name}.${schemaName}`); + + if (!collectionField) { + return; + } + + const cloneRecord = { ...record }; + delete cloneRecord['__parent']; + + if (_.isEmpty(cloneRecord) || !variables || record[schemaName] != null) { + return; + } + + // 通过模拟一个 record 变量,用解析变量的方法去获取关系字段的值 + const recordVariable = { + name: '$nRecord', + ctx: cloneRecord, + collectionName: name, + }; + const variableString = `{{ $nRecord.${schemaName} }}`; + + variables + .parseVariable(variableString, recordVariable) + .then((value) => { + field.value = transformVariableValue(value, { targetCollectionField: collectionField }); + }) + .catch((err) => { + console.error(err); + }); + }, [field, name, record, schemaName, isInAssignFieldValues]); +}; + +export default useLazyLoadAssociationFieldOfSubForm; diff --git a/packages/core/client/src/schema-component/antd/form-item/hooks/useParseDefaultValue.ts b/packages/core/client/src/schema-component/antd/form-item/hooks/useParseDefaultValue.ts new file mode 100644 index 000000000..07206a546 --- /dev/null +++ b/packages/core/client/src/schema-component/antd/form-item/hooks/useParseDefaultValue.ts @@ -0,0 +1,112 @@ +import { Field } from '@formily/core'; +import { useField, useFieldSchema } from '@formily/react'; +import { reaction } from '@formily/reactive'; +import _ from 'lodash'; +import { useEffect, useRef } from 'react'; +import { useRecord, useRecordIndex } from '../../../../../src/record-provider'; +import { useCollection } from '../../../../collection-manager'; +import { useFlag } from '../../../../flag-provider'; +import { DEBOUNCE_WAIT, useLocalVariables, useVariables } from '../../../../variables'; +import { getPath } from '../../../../variables/utils/getPath'; +import { getVariableName } from '../../../../variables/utils/getVariableName'; +import { isVariable } from '../../../../variables/utils/isVariable'; +import { transformVariableValue } from '../../../../variables/utils/transformVariableValue'; +import { isSubMode } from '../../association-field/util'; +import { isFromDatabase, useSpecialCase } from './useSpecialCase'; + +/** + * 用于解析并设置 FormItem 的默认值 + */ +const useParseDefaultValue = () => { + const field = useField(); + const fieldSchema = useFieldSchema(); + const variables = useVariables(); + const localVariables = useLocalVariables(); + const record = useRecord(); + const { isInAssignFieldValues, isInSetDefaultValueDialog, isInFormDataTemplate } = useFlag() || {}; + const { getField } = useCollection(); + const { isSpecialCase, setDefaultValue } = useSpecialCase(); + const index = useRecordIndex(); + + // 需要保存 record 的初始值,因为设置默认值的时候 record 会被修改,导致初始值丢失 + const recordRef = useRef(_.omit(record, '__parent')); + + useEffect(() => { + if ( + fieldSchema.default == null || + isInSetDefaultValueDialog || + isInFormDataTemplate || + isSubMode(fieldSchema) || + // 根据 record 是否为空,判断当前是否是新建状态,编辑状态下不需要设置默认值,否则会覆盖用户输入的值,只有新建状态下才需要设置默认值 + (!_.isEmpty(recordRef.current) && isFromDatabase(record) && !isInAssignFieldValues) + ) { + return; + } + + const _run = async () => { + // 如果默认值是一个变量,则需要解析之后再显示出来 + if (isVariable(fieldSchema.default) && variables && field) { + // 一个变量字符串如果显示出来会比较奇怪 + if (isVariable(field.value)) { + field.setValue(null); + } + + field.loading = true; + const collectionField = !fieldSchema.name.toString().includes('.') && getField(fieldSchema.name); + + if (process.env.NODE_ENV !== 'production') { + if (!collectionField) { + throw new Error(`useParseDefaultValue: can not find field ${fieldSchema.name}`); + } + } + + const value = transformVariableValue(await variables.parseVariable(fieldSchema.default, localVariables), { + targetCollectionField: collectionField, + }); + + if (value == null || value === '') { + field.setValue(null); + } else if (isSpecialCase()) { + // 只需要设置一次就可以了 + if (index === 0) { + setDefaultValue(value); + } + } else { + // TODO: 可能会因为异步时序问题导致设置默认值无效 + // eslint-disable-next-line promise/catch-or-return + void Promise.resolve().then(() => { + field.setInitialValue(value); + }); + } + + field.loading = false; + + // 如果不是一个有效的变量字符串(如:`{{ $user.name }}`)却依然包含 `{{` 和 `}}`, + // 则可以断定是一个需要 compile(`useCompile` 返回的函数) 的字符串,这样的字符串会由 formily 自动解析,无需在这里赋值 + } else if (!/\{\{.+\}\}/g.test(fieldSchema.default) && field.setInitialValue) { + field.setInitialValue(fieldSchema.default); + } + }; + + // 使用防抖,提高性能和用户体验 + const run = _.debounce(_run, DEBOUNCE_WAIT); + + void _run(); + + if (isVariable(fieldSchema.default)) { + const variableName = getVariableName(fieldSchema.default); + const variableValue = localVariables.find((item) => item.name === variableName); + + if (variableValue) { + // 实现联动的效果,当依赖的变量变化时(如 `$nForm` 变量),重新解析默认值 + const dispose = reaction(() => { + return _.get({ [variableName]: variableValue?.ctx }, getPath(fieldSchema.default)); + }, run); + + return dispose; + } + } + }, [fieldSchema.default]); +}; + +export default useParseDefaultValue; diff --git a/packages/core/client/src/schema-component/antd/form-item/hooks/useSpecialCase.ts b/packages/core/client/src/schema-component/antd/form-item/hooks/useSpecialCase.ts new file mode 100644 index 000000000..8998d1497 --- /dev/null +++ b/packages/core/client/src/schema-component/antd/form-item/hooks/useSpecialCase.ts @@ -0,0 +1,174 @@ +import { Field } from '@formily/core'; +import { Schema, useFieldSchema, useForm } from '@formily/react'; +import _ from 'lodash'; +import { useCallback, useEffect, useMemo } from 'react'; +import { CollectionFieldOptions, useCollection, useCollectionManager } from '../../../../collection-manager'; +import { isSubMode } from '../../association-field/util'; + +/** + * #### 处理 `子表单` 和 `子表格` 中的特殊情况 + * + * - 提供一个判断当前是否是特殊情况的方法 + * - 提供一个设置默认值的方法 + */ +export const useSpecialCase = () => { + const form = useForm(); + const fieldSchema = useFieldSchema(); + const { getField } = useCollection(); + const { getCollectionField } = useCollectionManager(); + + const collectionField = useMemo(() => { + return getField(fieldSchema.name); + }, [fieldSchema.name, getField]); + + const isSpecialCase = useCallback(() => { + return isSpecialCaseField({ collectionField, fieldSchema, getCollectionField }); + }, [collectionField, fieldSchema, getCollectionField]); + + const setDefaultValue = useCallback( + (value: any) => { + const parentFieldSchema = getParentFieldSchema(fieldSchema); + if (parentFieldSchema) { + const parentField: any = form.query(parentFieldSchema.name).take(); + if (parentField) { + parentField.setInitialValue(transformValue(value, { field: parentField, subFieldSchema: fieldSchema })); + } + } + }, + [fieldSchema, form], + ); + + return { + /** + * 特殊情况指的是:当前字段是 `对一` 字段且存在于 `对多` 字段的 `子表格` 或 `子表单` 中 + * 详细说明见:https://nocobase.feishu.cn/docx/EmNEdEBOnoQohUx2UmBcqIQ5nyh#CUdLdy6OpoPKjyx9DLPc3lqknVc + */ + isSpecialCase, + setDefaultValue, + }; +}; + +/** + * 通过存在于 `子表格` 和 `子表单` 中的字段,获取父级字段的 fieldSchema + * @param fieldSchema + */ +export function getParentFieldSchema(fieldSchema: Schema) { + while (fieldSchema?.parent) { + fieldSchema = fieldSchema.parent; + + if (isSubMode(fieldSchema)) { + return fieldSchema; + } + } + + return fieldSchema; +} + +/** + * 特殊情况指的是:当前字段是 `对一` 字段且存在于 `对多` 字段的 `子表格` 或 `子表单` 中 + * 详细说明见:https://nocobase.feishu.cn/docx/EmNEdEBOnoQohUx2UmBcqIQ5nyh#CUdLdy6OpoPKjyx9DLPc3lqknVc + * @returns + */ +export function isSpecialCaseField({ + collectionField, + fieldSchema, + getCollectionField, +}: { + collectionField: CollectionFieldOptions; + fieldSchema: Schema; + getCollectionField: (name: string) => CollectionFieldOptions; +}) { + if (collectionField && ['hasOne', 'belongsTo'].includes(collectionField.type) && fieldSchema) { + const parentFieldSchema = getParentFieldSchema(fieldSchema); + if (parentFieldSchema && parentFieldSchema['x-collection-field']) { + const parentCollectionField = getCollectionField(parentFieldSchema['x-collection-field']); + if (['hasMany', 'belongsToMany'].includes(parentCollectionField?.type)) { + return true; + } + } + } + + return false; +} + +/** + * 将 `对多` 字段的子表单或子表格中的 `对一` 字段的值转换成 `对多` 字段的值 + * @param value `子表单` 和 `子表格` 中的 `对一` 字段的经过解析之后的变量的值 + * @param deps + * @returns + */ +export function transformValue( + value: any, + deps: { + field: Field; + subFieldSchema: Schema; + }, +) { + const { field, subFieldSchema } = deps; + const oldValues = field.value; + let newValues = null; + + if (Array.isArray(value)) { + newValues = value.map((item) => { + return { + [subFieldSchema.name]: item, + }; + }); + } else { + newValues = [ + { + [subFieldSchema.name]: value, + }, + ]; + } + + const result = _.assignWith([...oldValues], newValues, (oldValue, newValue) => { + if (!oldValue && newValue) { + // 用于标记这个值不是从数据库中获取的 + newValue.__notFromDatabase = true; + } + + // 之所以取第一个值,是因为 `对一` 字段的默认值可能是异步的,导致会赋默认值值会晚于普通字段,导致普通字段的值会被覆盖 + return _.assign({ ...oldValues[0] }, newValue); + }); + + return result; +} + +/** + * 判断一个 record 是否是从数据库中获取的,如果是则返回 true,否则返回 false + * @param value useRecord 返回的值 + * @returns boolean + */ +export function isFromDatabase(value: Record) { + if (!value) { + return false; + } + + return !value.__notFromDatabase; +} + +/** + * 解决 `子表格` 中的 “一对多/多对一” 字段的默认值问题。 + * + * 问题:如果子表格是空的,会导致默认值不会被设置,这是因为子表格中没有 FormItem 渲染,而设置默认值的逻辑就在 FormItem 中。 + * + * 解决方法: + * 1. 如果子表格是空的,就设置一个值,这样会渲染出 FormItem。 + * 2. 在 FormItem 中会设置默认值。 + * 3. 如果子表格中没有设置默认值,就会再把子表格重置为空。 + * @param param0 + */ +export const useSubTableSpecialCase = ({ field }) => { + useEffect(() => { + if (_.isEmpty(field.value)) { + field.value = [{}]; + // 因为默认值的解析是异步的,所以下面的代码会优先于默认值的设置,这样就防止了设置完默认值后又被清空的问题 + Promise.resolve() + .then(() => { + field.value = []; + }) + .catch(console.error); + } + }, []); +}; diff --git a/packages/core/client/src/schema-component/antd/form-v2/Form.Designer.tsx b/packages/core/client/src/schema-component/antd/form-v2/Form.Designer.tsx index 56b0f69e7..3e5a61021 100644 --- a/packages/core/client/src/schema-component/antd/form-v2/Form.Designer.tsx +++ b/packages/core/client/src/schema-component/antd/form-v2/Form.Designer.tsx @@ -5,12 +5,11 @@ import { useTranslation } from 'react-i18next'; import { useFormBlockContext } from '../../../block-provider'; import { useDetailsBlockContext } from '../../../block-provider/DetailsBlockProvider'; import { useCollection } from '../../../collection-manager'; -import { useCollectionFilterOptions, useSortFields } from '../../../collection-manager/action-hooks'; +import { useSortFields } from '../../../collection-manager/action-hooks'; import { GeneralSchemaDesigner, SchemaSettings } from '../../../schema-settings'; import { useSchemaTemplate } from '../../../schema-templates'; import { useDesignable } from '../../hooks'; import { removeNullCondition } from '../filter'; -import { FilterDynamicComponent } from '../table-v2/FilterDynamicComponent'; export const FormDesigner = () => { const { name, title } = useCollection(); @@ -74,12 +73,11 @@ export const DetailsDesigner = () => { const template = useSchemaTemplate(); const { t } = useTranslation(); const fieldSchema = useFieldSchema(); + const { form } = useFormBlockContext(); const field = useField(); - const dataSource = useCollectionFilterOptions(name); const { service } = useDetailsBlockContext(); const { dn } = useDesignable(); const sortFields = useSortFields(name); - const defaultFilter = fieldSchema?.['x-decorator-props']?.params?.filter || {}; const defaultSort = fieldSchema?.['x-decorator-props']?.params?.sort || []; const defaultResource = fieldSchema?.['x-decorator-props']?.resource; const sort = defaultSort?.map((item: string) => { @@ -96,25 +94,10 @@ export const DetailsDesigner = () => { return ( - FilterDynamicComponent({ ...props }), - }, - }, - }, - } as ISchema - } + { filter = removeNullCondition(filter); const params = field.decoratorProps.params || {}; diff --git a/packages/core/client/src/schema-component/antd/form-v2/Form.tsx b/packages/core/client/src/schema-component/antd/form-v2/Form.tsx index 1ab3a7325..2558c8370 100644 --- a/packages/core/client/src/schema-component/antd/form-v2/Form.tsx +++ b/packages/core/client/src/schema-component/antd/form-v2/Form.tsx @@ -1,12 +1,14 @@ import { css } from '@emotion/css'; import { FormLayout } from '@formily/antd-v5'; -import { createForm, Field, onFieldChange, onFieldInit, onFieldReact, onFormInputChange } from '@formily/core'; +import { createForm, Field, Form as FormilyForm, onFieldInit, onFormInputChange } from '@formily/core'; import { FieldContext, FormContext, observer, RecursionField, useField, useFieldSchema } from '@formily/react'; +import { autorun } from '@formily/reactive'; import { uid } from '@formily/shared'; import { ConfigProvider, Spin } from 'antd'; import React, { useEffect, useMemo } from 'react'; import { useActionContext } from '..'; import { useAttach, useComponent } from '../..'; +import { useLocalVariables, useVariables } from '../../../variables'; import { useProps } from '../../hooks/useProps'; import { linkageMergeAction } from './utils'; @@ -66,81 +68,91 @@ const getLinkageRules = (fieldSchema) => { return linkageRules; }; -const WithForm = (props) => { +interface WithFormProps { + form: FormilyForm; + disabled?: boolean; +} + +const WithForm = (props: WithFormProps) => { const { form } = props; const fieldSchema = useFieldSchema(); const { setFormValueChanged } = useActionContext(); - const linkageRules = + const variables = useVariables(); + const localVariables = useLocalVariables({ currentForm: form }); + const linkageRules: any[] = (getLinkageRules(fieldSchema) || fieldSchema.parent?.['x-linkage-rules'])?.filter((k) => !k.disabled) || []; + useEffect(() => { const id = uid(); + form.addEffects(id, () => { - onFormInputChange((form) => { + onFormInputChange(() => { setFormValueChanged?.(true); }); }); + if (props.disabled) { form.disabled = props.disabled; } + return () => { form.removeEffects(id); }; - }, []); + }, [props.disabled]); + useEffect(() => { const id = uid(); + const disposes = []; + form.addEffects(id, () => { - return linkageRules.map((v) => { - return v.actions?.map((h) => { - if (h.targetFields) { + linkageRules.forEach((v) => { + v.actions?.forEach((h) => { + if (h.targetFields?.length) { const fields = h.targetFields.join(','); + + // 当 `linkageRules` 变更时,需要把 `field` 上之前已设置的值还原成初始值,以防止下面所述的情况: + // + // 1. 更改 `linkageRules`,使 a 字段满足条件时被隐藏 + // 2. 设置表单,使其满足条件,进而隐藏 a 字段 + // 3. 再次更改 `linkageRules`,使 a 字段满足条件时被禁用 + // 4. 设置表单,使其满足条件,会发现 a 字段还是隐藏状态,这里期望的是只显示禁用状态 + onFieldInit(`*(${fields})`, (field: any) => { + Object.keys(field).forEach((key) => { + if (key.startsWith('_') && field[key] !== undefined) { + field[key.slice(1)] = field[key]; + } + }); + }); + + // `onFieldReact` 有问题,没有办法被取消监听,所以这里用 `onFieldInit` 代替 onFieldInit(`*(${fields})`, (field: any, form) => { - field['initProperty'] = { - display: field.display, - required: field.required, - pattern: field.pattern, - value: field.value || field.initialValue, - }; - }); - onFieldChange(`*(${fields})`, ['value', 'required', 'pattern', 'display'], (field: any) => { - field.linkageProperty = { - display: field.linkageProperty?.display, - }; + disposes.push( + autorun(() => { + linkageMergeAction({ + operator: h.operator, + value: h.value, + field, + condition: v.condition, + values: form?.values, + variables, + localVariables, + }); + }), + ); }); } }); }); }); + return () => { form.removeEffects(id); - }; - }, []); - useEffect(() => { - const id = uid(); - const linkagefields = []; - form.addEffects(id, () => { - return linkageRules.map((v, index) => { - return v.actions?.map((h) => { - if (h.targetFields) { - const fields = h.targetFields.join(','); - return onFieldReact(`*(${fields})`, (field: any, form) => { - linkagefields.push(field); - linkageMergeAction(h, field, v.condition, form?.values); - if (index === linkageRules.length - 1) { - setTimeout(() => - linkagefields.map((v) => { - v.linkageProperty = {}; - }), - ); - } - }); - } - }); + disposes.forEach((dispose) => { + dispose(); }); - }); - return () => { - form.removeEffects(id); }; - }, [linkageRules]); + }, [JSON.stringify(linkageRules)]); + return fieldSchema['x-decorator'] === 'FormV2' ? : ; }; diff --git a/packages/core/client/src/schema-component/antd/form-v2/utils.tsx b/packages/core/client/src/schema-component/antd/form-v2/utils.tsx index 734fe432a..67b4d74ec 100644 --- a/packages/core/client/src/schema-component/antd/form-v2/utils.tsx +++ b/packages/core/client/src/schema-component/antd/form-v2/utils.tsx @@ -1,83 +1,148 @@ +import { Field } from '@formily/core'; import { evaluators } from '@nocobase/evaluators/client'; -import { cloneDeep, last } from 'lodash'; +import { uid } from '@nocobase/utils'; +import _, { cloneDeep } from 'lodash'; import { ActionType } from '../../../schema-settings/LinkageRules/type'; -import { conditionAnalyse } from '../../common/utils/uitls'; +import { VariableOption, VariablesContextType } from '../../../variables/types'; +import { REGEX_OF_VARIABLE } from '../../../variables/utils/isVariable'; +import { conditionAnalyses } from '../../common/utils/uitls'; -export const linkageMergeAction = ({ operator, value }, field, condition, values) => { - const requiredResult = field?.linkageProperty?.required || [field?.initProperty?.required || false]; - const displayResult = field?.linkageProperty?.display || [field?.initProperty?.display]; - const patternResult = field?.linkageProperty?.pattern || [field?.initProperty?.pattern]; - const valueResult = field?.linkageProperty?.value || [field.value || field?.initProperty?.value]; +interface Props { + operator; + value; + field: Field & { + [key: string]: any; + }; + condition; + values; + variables: VariablesContextType; + localVariables: VariableOption[]; +} + +export const linkageMergeAction = async ({ + operator, + value, + field, + condition, + values, + variables, + localVariables, +}: Props) => { const { evaluate } = evaluators.get('formula.js'); switch (operator) { case ActionType.Required: - if (conditionAnalyse(condition, values)) { - requiredResult.push(true); + if (await conditionAnalyses({ rules: condition, formValues: values, variables, localVariables })) { + field.required = true; + } else { + field.required = false; } - field.linkageProperty = { - ...field.linkageProperty, - required: requiredResult, - }; - field.required = last(field.linkageProperty?.required); break; case ActionType.InRequired: - if (conditionAnalyse(condition, values)) { - requiredResult.push(false); + if (await conditionAnalyses({ rules: condition, formValues: values, variables, localVariables })) { + field.required = false; + } else { + field.required = true; } - field.linkageProperty = { - ...field.linkageProperty, - required: requiredResult, - }; - field.required = last(field.linkageProperty?.required); break; case ActionType.Visible: case ActionType.None: case ActionType.Hidden: - if (conditionAnalyse(condition, values)) { - displayResult.push(operator); + if (await conditionAnalyses({ rules: condition, formValues: values, variables, localVariables })) { + field._display = '_display' in field ? field._display : field.display; + field.display = operator; + } else { + field.display = field._display; } - field.linkageProperty = { - ...field.linkageProperty, - display: displayResult, - }; - field.display = last(field.linkageProperty?.display); break; case ActionType.Editable: case ActionType.ReadOnly: case ActionType.ReadPretty: - if (conditionAnalyse(condition, values)) { - patternResult.push(operator); + if (await conditionAnalyses({ rules: condition, formValues: values, variables, localVariables })) { + field._pattern = '_pattern' in field ? field._pattern : field.pattern; + field.pattern = operator; + } else { + field.pattern = field._pattern; } - field.linkageProperty = { - ...field.linkageProperty, - pattern: patternResult, - }; - field.pattern = last(field.linkageProperty.pattern); break; case ActionType.Value: - if (conditionAnalyse(condition, values)) { + if ( + isConditionEmpty(condition) || + (await conditionAnalyses({ rules: condition, formValues: values, variables, localVariables })) + ) { + let result = null; if (value?.mode === 'express') { const scope = cloneDeep(values); + + // 1. 解析如 `{{$user.name}}` 之类的变量 + const { exp, scope: expScope } = await replaceVariables(value.value || value.result, { + variables, + localVariables, + }); + try { - const result = evaluate(value.result || value.value, { ...scope, now: () => new Date().toString() }); - valueResult.push(result); + // 2. TODO: 需要把里面解析变量的逻辑删除,因为在上一步已经解析过了 + result = evaluate(exp, { ...scope, now: () => new Date().toString(), ...expScope }); } catch (error) { - console.log(error); + if (process.env.NODE_ENV !== 'production') { + throw error; + } } } else if (value?.mode === 'constant') { - valueResult.push(value?.value || value); + result = value?.value || value; } else { - valueResult.push(null); + result = null; } + field._value = '_value' in field ? field._value : field.value; + field.value = result === undefined ? field.value : result; + } else { + field.value = field._value; } - field.linkageProperty = { - ...field.linkageProperty, - value: valueResult, - }; - field.value = last(valueResult) === undefined ? field.value : last(valueResult); break; default: return null; } }; + +async function replaceVariables( + value: string, + { + variables, + localVariables, + }: { + variables: VariablesContextType; + localVariables: VariableOption[]; + }, +) { + const store = {}; + const scope = {}; + + const waitForParsing = value.match(REGEX_OF_VARIABLE)?.map(async (item) => { + const result = await variables.parseVariable(item, localVariables); + + // 在开头加 `_` 是为了保证 id 不能以数字开头,否则在解析表达式的时候(不是解析变量)会报错 + const id = `_${uid()}`; + + scope[id] = result; + store[item] = id; + return result; + }); + + if (waitForParsing) { + await Promise.all(waitForParsing); + } + + return { + exp: value.replace(REGEX_OF_VARIABLE, (match) => { + return store[match] || match; + }), + scope, + }; +} + +function isConditionEmpty(rules: { $and?: any; $or?: any }) { + const type = Object.keys(rules)[0] || '$and'; + const conditions = rules[type]; + + return _.isEmpty(conditions); +} diff --git a/packages/core/client/src/schema-component/antd/form/__tests__/form.test.tsx b/packages/core/client/src/schema-component/antd/form/__tests__/form.test.tsx index 173e2762c..55db9c288 100644 --- a/packages/core/client/src/schema-component/antd/form/__tests__/form.test.tsx +++ b/packages/core/client/src/schema-component/antd/form/__tests__/form.test.tsx @@ -13,18 +13,24 @@ describe('Form', () => { it('basic', async () => { render(); + await sleep(); + const submit = screen.getByText('Submit'); const input = document.querySelector('.ant-input') as HTMLInputElement; - expect(submit).toBeInTheDocument(); - expect(input).toBeInTheDocument(); - expect(screen.getByText(/form title/i)).toBeInTheDocument(); - expect(screen.getByText('T1')).toBeInTheDocument(); - expect(screen.getByText(/\{\}/i)).toBeInTheDocument(); + await waitFor(() => { + expect(submit).toBeInTheDocument(); + expect(input).toBeInTheDocument(); + expect(screen.queryByText(/form title/i)).toBeInTheDocument(); + expect(screen.queryByText('T1')).toBeInTheDocument(); + expect(screen.queryByText(/\{\}/i)).toBeInTheDocument(); + }); // 使用 waitFor 防止报错,参见:https://github.com/testing-library/user-event/issues/662#issuecomment-904365493 await waitFor(() => userEvent.type(input, '123')); - expect(screen.getByText(/\{ "field1": "123" \}/i)).toBeInTheDocument(); + await waitFor(() => { + expect(screen.queryByText(/\{ "field1": "123" \}/i)).toBeInTheDocument(); + }); }); it('decorator', async () => { @@ -46,7 +52,9 @@ describe('Form', () => { }); await userEvent.type(input, '123'); - expect(screen.getByText(/\{ "field1": "aaa123" \}/i)).toBeInTheDocument(); + await waitFor(() => { + expect(screen.queryByText(/\{ "field1": "aaa123" \}/i)).toBeInTheDocument(); + }); }); it('Form & Drawer', async () => { diff --git a/packages/core/client/src/schema-component/antd/gantt/Gantt.Designer.tsx b/packages/core/client/src/schema-component/antd/gantt/Gantt.Designer.tsx index df69c8148..a52c14503 100644 --- a/packages/core/client/src/schema-component/antd/gantt/Gantt.Designer.tsx +++ b/packages/core/client/src/schema-component/antd/gantt/Gantt.Designer.tsx @@ -1,10 +1,9 @@ -import { ISchema, useField, useFieldSchema } from '@formily/react'; +import { useField, useFieldSchema } from '@formily/react'; import React from 'react'; import { useTranslation } from 'react-i18next'; import { useCompile, useDesignable } from '../..'; -import { useGanttBlockContext } from '../../../block-provider'; +import { useFormBlockContext, useGanttBlockContext } from '../../../block-provider'; import { useCollection } from '../../../collection-manager'; -import { useCollectionFilterOptions } from '../../../collection-manager/action-hooks'; import { GeneralSchemaDesigner, SchemaSettings } from '../../../schema-settings'; import { useSchemaTemplate } from '../../../schema-templates'; @@ -25,14 +24,13 @@ const useOptions = (type = 'string') => { export const GanttDesigner = () => { const field = useField(); const fieldSchema = useFieldSchema(); - const { name, title, fields } = useCollection(); - const dataSource = useCollectionFilterOptions(name); + const { form } = useFormBlockContext(); + const { name, title } = useCollection(); const { service } = useGanttBlockContext(); const { dn } = useDesignable(); const compile = useCompile(); const { t } = useTranslation(); const template = useSchemaTemplate(); - const defaultFilter = fieldSchema?.['x-decorator-props']?.params?.filter || {}; const fieldNames = fieldSchema?.['x-decorator-props']?.['fieldNames'] || {}; const defaultResource = fieldSchema?.['x-decorator-props']?.resource; return ( @@ -146,28 +144,10 @@ export const GanttDesigner = () => { dn.refresh(); }} /> - { const params = field.decoratorProps.params || {}; params.filter = filter; diff --git a/packages/core/client/src/schema-component/antd/grid-card/GridCard.Designer.tsx b/packages/core/client/src/schema-component/antd/grid-card/GridCard.Designer.tsx index f3d7cf055..6965a4777 100644 --- a/packages/core/client/src/schema-component/antd/grid-card/GridCard.Designer.tsx +++ b/packages/core/client/src/schema-component/antd/grid-card/GridCard.Designer.tsx @@ -4,13 +4,13 @@ import { Slider } from 'antd'; import _ from 'lodash'; import React, { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { useCollection, useCollectionFilterOptions, useSortFields } from '../../../collection-manager'; +import { useFormBlockContext } from '../../../block-provider'; +import { useCollection, useSortFields } from '../../../collection-manager'; import { GeneralSchemaDesigner, SchemaSettings } from '../../../schema-settings'; import { useSchemaTemplate } from '../../../schema-templates'; import { SchemaComponentOptions } from '../../core'; import { useDesignable } from '../../hooks'; import { removeNullCondition } from '../filter'; -import { FilterDynamicComponent } from '../table-v2/FilterDynamicComponent'; import { defaultColumnCount, gridSizes, pageSizeOptions, screenSizeMaps, screenSizeTitleMaps } from './options'; const columnCountMarks = [1, 2, 3, 4, 6, 8, 12, 24].reduce((obj, cur) => { @@ -23,11 +23,10 @@ export const GridCardDesigner = () => { const template = useSchemaTemplate(); const { t } = useTranslation(); const fieldSchema = useFieldSchema(); + const { form } = useFormBlockContext(); const field = useField(); - const dataSource = useCollectionFilterOptions(name); const { dn } = useDesignable(); const sortFields = useSortFields(name); - const defaultFilter = fieldSchema?.['x-decorator-props']?.params?.filter || {}; const defaultSort = fieldSchema?.['x-decorator-props']?.params?.sort || []; const defaultResource = fieldSchema?.['x-decorator-props']?.resource; const columnCount = field.decoratorProps.columnCount || defaultColumnCount; @@ -94,25 +93,10 @@ export const GridCardDesigner = () => { }); }} /> - FilterDynamicComponent({ ...props }), - }, - }, - }, - } as ISchema - } + { filter = removeNullCondition(filter); _.set(fieldSchema, 'x-decorator-props.params.filter', filter); diff --git a/packages/core/client/src/schema-component/antd/kanban/Kanban.Designer.tsx b/packages/core/client/src/schema-component/antd/kanban/Kanban.Designer.tsx index f8b852418..ed3d45d50 100644 --- a/packages/core/client/src/schema-component/antd/kanban/Kanban.Designer.tsx +++ b/packages/core/client/src/schema-component/antd/kanban/Kanban.Designer.tsx @@ -1,49 +1,30 @@ -import { ISchema, useField, useFieldSchema } from '@formily/react'; +import { useField, useFieldSchema } from '@formily/react'; import React from 'react'; -import { useTranslation } from 'react-i18next'; -import { useKanbanBlockContext } from '../../../block-provider'; +import { useFormBlockContext, useKanbanBlockContext } from '../../../block-provider'; import { useCollection } from '../../../collection-manager'; -import { useCollectionFilterOptions } from '../../../collection-manager/action-hooks'; import { GeneralSchemaDesigner, SchemaSettings } from '../../../schema-settings'; import { useSchemaTemplate } from '../../../schema-templates'; import { useDesignable } from '../../hooks'; import { removeNullCondition } from '../filter'; import { FixedBlockDesignerItem } from '../page'; -import { FilterDynamicComponent } from '../table-v2/FilterDynamicComponent'; export const KanbanDesigner = () => { const { name, title } = useCollection(); const field = useField(); const fieldSchema = useFieldSchema(); - const dataSource = useCollectionFilterOptions(name); + const { form } = useFormBlockContext(); const { service } = useKanbanBlockContext(); - const { t } = useTranslation(); const { dn } = useDesignable(); - const defaultFilter = fieldSchema?.['x-decorator-props']?.params?.filter || {}; const defaultResource = fieldSchema?.['x-decorator-props']?.resource; const template = useSchemaTemplate(); return ( - FilterDynamicComponent({ ...props }), - }, - }, - }, - } as ISchema - } + { filter = removeNullCondition(filter); const params = field.decoratorProps.params || {}; diff --git a/packages/core/client/src/schema-component/antd/list/List.Designer.tsx b/packages/core/client/src/schema-component/antd/list/List.Designer.tsx index 6965cc97b..0696edd50 100644 --- a/packages/core/client/src/schema-component/antd/list/List.Designer.tsx +++ b/packages/core/client/src/schema-component/antd/list/List.Designer.tsx @@ -3,25 +3,22 @@ import { ISchema, useField, useFieldSchema } from '@formily/react'; import _ from 'lodash'; import React from 'react'; import { useTranslation } from 'react-i18next'; -import { useCollection, useCollectionFilterOptions, useSortFields } from '../../../collection-manager'; +import { useFormBlockContext } from '../../../block-provider'; +import { useCollection, useSortFields } from '../../../collection-manager'; import { GeneralSchemaDesigner, SchemaSettings } from '../../../schema-settings'; import { useSchemaTemplate } from '../../../schema-templates'; import { useDesignable } from '../../hooks'; import { removeNullCondition } from '../filter'; -import { FilterDynamicComponent } from '../table-v2/FilterDynamicComponent'; -import { useListBlockContext } from './List.Decorator'; export const ListDesigner = () => { const { name, title } = useCollection(); const template = useSchemaTemplate(); const { t } = useTranslation(); const fieldSchema = useFieldSchema(); + const { form } = useFormBlockContext(); const field = useField(); - const dataSource = useCollectionFilterOptions(name); - const { service } = useListBlockContext(); const { dn } = useDesignable(); const sortFields = useSortFields(name); - const defaultFilter = fieldSchema?.['x-decorator-props']?.params?.filter || {}; const defaultSort = fieldSchema?.['x-decorator-props']?.params?.sort || []; const defaultResource = fieldSchema?.['x-decorator-props']?.resource; const sort = defaultSort?.map((item: string) => { @@ -38,25 +35,10 @@ export const ListDesigner = () => { return ( - FilterDynamicComponent({ ...props }), - }, - }, - }, - } as ISchema - } + { filter = removeNullCondition(filter); _.set(fieldSchema, 'x-decorator-props.params.filter', filter); diff --git a/packages/core/client/src/schema-component/antd/remote-select/RemoteSelect.tsx b/packages/core/client/src/schema-component/antd/remote-select/RemoteSelect.tsx index 870b2b224..9f1968f26 100644 --- a/packages/core/client/src/schema-component/antd/remote-select/RemoteSelect.tsx +++ b/packages/core/client/src/schema-component/antd/remote-select/RemoteSelect.tsx @@ -1,8 +1,8 @@ import { LoadingOutlined } from '@ant-design/icons'; import { connect, mapProps, mapReadPretty, useFieldSchema } from '@formily/react'; -import dayjs from 'dayjs'; import { Divider, SelectProps, Tag } from 'antd'; -import _, { uniqBy } from 'lodash'; +import dayjs from 'dayjs'; +import { uniqBy } from 'lodash'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ResourceActionOptions, useRequest } from '../../../api-client'; import { mergeFilter } from '../../../block-provider/SharedFilterProvider'; @@ -127,7 +127,7 @@ const InternalRemoteSelect = connect( }, }, { - manual: manual && Object.prototype.toString.call(value) === '[object Object]', + manual, debounceWait: wait, }, ); @@ -194,6 +194,7 @@ const InternalRemoteSelect = connect( } firstRun.current = true; }; + return ( { - const flag = [ActionType.Value].includes(value); - setEditFlag(flag); - setOperator(value); - }} + options={operators} + onChange={onChange} placeholder={t('action')} /> - {editFalg && + {editFlag && React.createElement(DynamicComponent, { value, schema, - onChange(value) { - setValue(value); - }, + onChange: onChangeValue, })} {!props.disabled && ( - remove()} style={{ color: '#bfbfbf' }} /> + )} diff --git a/packages/core/client/src/schema-settings/LinkageRules/LinkageRuleActionGroup.tsx b/packages/core/client/src/schema-settings/LinkageRules/LinkageRuleActionGroup.tsx index a4e2c2b0e..8ad50a5d3 100644 --- a/packages/core/client/src/schema-settings/LinkageRules/LinkageRuleActionGroup.tsx +++ b/packages/core/client/src/schema-settings/LinkageRules/LinkageRuleActionGroup.tsx @@ -1,10 +1,10 @@ import { ArrayField as ArrayFieldModel, VoidField } from '@formily/core'; import { ArrayField, ObjectField, observer, useField } from '@formily/react'; import { Space } from 'antd'; -import React from 'react'; +import React, { useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { RemoveActionContext } from './context'; import { FormButtonLinkageRuleAction, FormFieldLinkageRuleAction } from './LinkageRuleAction'; +import { RemoveActionContext } from './context'; export const LinkageRuleActions = observer( (props: any): any => { const { type, linkageOptions } = props; @@ -26,29 +26,38 @@ export const LinkageRuleActions = observer( { displayName: 'LinkageRuleActions' }, ); -export const LinkageRuleActionGroup = (props) => { +interface LinkageRuleActionGroupProps { + useProps: () => { + type: 'button' | 'field'; + linkageOptions: any; + collectionName: string; + }; +} + +export const LinkageRuleActionGroup = (props: LinkageRuleActionGroupProps) => { const { t } = useTranslation(); const field = useField(); const logic = 'actions'; - const { type, linkageOptions, collectionName } = props?.useProps(); + const { type, linkageOptions, collectionName } = props.useProps(); + + const style = useMemo(() => ({ marginLeft: 10 }), []); + const components = useMemo( + () => [LinkageRuleActions, { type, linkageOptions, collectionName }], + [collectionName, linkageOptions, type], + ); + const spaceStyle = useMemo(() => ({ marginTop: 8, marginBottom: 8 }), []); + const onClick = useCallback(() => { + const f = field.query('.actions').take() as ArrayFieldModel; + const items = f.value || []; + items.push({}); + f.value = items; + }, [field]); + return ( -
- - - { - const f = field.query('.actions').take() as ArrayFieldModel; - const items = f.value || []; - items.push({}); - f.value = items; - }} - > - {t('Add property')} - + ); diff --git a/packages/core/client/src/schema-settings/LinkageRules/ValueDynamicComponent.tsx b/packages/core/client/src/schema-settings/LinkageRules/ValueDynamicComponent.tsx index 6c8ec22f8..34f694b26 100644 --- a/packages/core/client/src/schema-settings/LinkageRules/ValueDynamicComponent.tsx +++ b/packages/core/client/src/schema-settings/LinkageRules/ValueDynamicComponent.tsx @@ -1,17 +1,107 @@ import { Input, Select } from 'antd'; -import React, { useState } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { useFormBlockContext } from '../../block-provider'; +import { useRecord } from '../../record-provider'; import { Variable } from '.././../schema-component'; +import { useCompatOldVariables } from '../VariableInput/VariableInput'; +import { useVariableOptions } from '../VariableInput/hooks/useVariableOptions'; import { DynamicComponent } from './DynamicComponent'; -import { useVariableOptions } from './Variables'; const { Option } = Select; -export const ValueDynamicComponent = (props) => { +interface ValueDynamicComponentProps { + fieldValue: any; + schema: any; + setValue: (value: any) => void; + collectionName: string; +} + +export const ValueDynamicComponent = (props: ValueDynamicComponentProps) => { const { fieldValue, schema, setValue, collectionName } = props; const [mode, setMode] = useState(fieldValue?.mode || 'constant'); const { t } = useTranslation(); - const scope = useVariableOptions(collectionName); + const { form } = useFormBlockContext(); + const record = useRecord(); + const scope = useVariableOptions({ + collectionField: { uiSchema: schema }, + form, + record, + uiSchema: schema, + }); + const { compatOldVariables } = useCompatOldVariables({ + collectionField: { uiSchema: schema, collectionName }, + uiSchema: schema, + blockCollectionName: collectionName, + }); + const constantStyle = useMemo(() => { + return { minWidth: 150, maxWidth: 430, marginLeft: 5 }; + }, []); + const handleChangeOfConstant = useCallback( + (value) => { + setValue({ + mode, + value, + }); + }, + [mode, setValue], + ); + const expressStyle = useMemo(() => { + return { minWidth: 150, maxWidth: 430, fontSize: 13 }; + }, []); + const handleChangeOfExpress = useCallback( + (value) => { + const result = value.replaceAll(`${collectionName}.`, '').replaceAll('$system.', '').trim(); + setValue({ + mode, + value, + result, + }); + }, + [collectionName, mode, setValue], + ); + const textAreaStyle = useMemo(() => { + return { minWidth: 460, marginRight: 15 }; + }, []); + const compatScope = useMemo(() => { + return compatOldVariables(scope, { + value: fieldValue?.value, + }); + }, [compatOldVariables, fieldValue?.value, scope]); + + const modeMap = { + // 常量 + constant: ( +
+ {React.createElement(DynamicComponent, { + value: fieldValue?.value || fieldValue, + schema, + onChange: handleChangeOfConstant, + })} +
+ ), + + // 表达式 + express: ( +
+ + <> + + {t('Syntax references')}: + + + Formula.js + + +
+ ), + }; + return ( - {mode === 'constant' ? ( -
- {React.createElement(DynamicComponent, { - value: fieldValue?.value || fieldValue, - schema, - onChange(value) { - setValue({ - mode, - value, - }); - }, - })} -
- ) : mode === 'express' ? ( -
- { - const result = value.replaceAll(`${collectionName}.`, '').replaceAll('$system.', '').trim(); - setValue({ - mode, - value, - result, - }); - }} - scope={scope} - style={{ minWidth: 460, marginRight: 15 }} - /> - <> - - {t('Syntax references')}: - - - Formula.js - - -
- ) : null} + {modeMap[mode]}
); }; diff --git a/packages/core/client/src/schema-settings/LinkageRules/index.tsx b/packages/core/client/src/schema-settings/LinkageRules/index.tsx index b1666888f..7efe8bc50 100644 --- a/packages/core/client/src/schema-settings/LinkageRules/index.tsx +++ b/packages/core/client/src/schema-settings/LinkageRules/index.tsx @@ -1,123 +1,172 @@ import { css } from '@emotion/css'; +import { Form } from '@formily/core'; import { observer, useFieldSchema } from '@formily/react'; -import React from 'react'; +import React, { useMemo } from 'react'; +import { FormBlockContext } from '../../block-provider'; +import { useCollectionManager } from '../../collection-manager'; +import { RecordProvider } from '../../record-provider'; import { SchemaComponent } from '../../schema-component'; +import { DynamicComponentProps } from '../../schema-component/antd/filter/DynamicComponent'; import { FilterContext } from '../../schema-component/antd/filter/context'; +import { VariableOption, VariablesContextType } from '../../variables/types'; +import { VariableInput, getShouldChange } from '../VariableInput/VariableInput'; +import { LinkageRuleActionGroup } from './LinkageRuleActionGroup'; import { EnableLinkage } from './components/EnableLinkage'; import { ArrayCollapse } from './components/LinkageHeader'; -import { FilterDynamicComponent } from './FilterDynamicComponent'; -import { LinkageRuleActionGroup } from './LinkageRuleActionGroup'; + +interface usePropsReturn { + options: any; + defaultValues: any[]; + collectionName: string; + form: Form; + variables: VariablesContextType; + localVariables: VariableOption | VariableOption[]; + record: Record; +} + +interface Props { + useProps: () => usePropsReturn; + dynamicComponent: any; +} export const FormLinkageRules = observer( - (props: any) => { + (props: Props) => { const fieldSchema = useFieldSchema(); const { useProps, dynamicComponent } = props; - const { options, defaultValues, collectionName } = useProps(); - return ( - - ({ ArrayCollapse }), []); + const schema = useMemo( + () => ({ + type: 'object', + properties: { + rules: { + type: 'array', + default: defaultValues, + 'x-component': 'ArrayCollapse', + 'x-decorator': 'FormItem', + 'x-component-props': { + accordion: true, + }, + items: { + type: 'object', + 'x-component': 'ArrayCollapse.CollapsePanel', + 'x-component-props': { + extra: , + }, + properties: { + layout: { + type: 'void', + 'x-component': 'FormLayout', 'x-component-props': { - extra: , + labelStyle: { + marginTop: '6px', + }, + labelCol: 8, + wrapperCol: 16, }, properties: { - layout: { - type: 'void', - 'x-component': 'FormLayout', + conditions: { + 'x-component': 'h4', + 'x-content': '{{ t("Condition") }}', + }, + condition: { + 'x-component': 'Filter', 'x-component-props': { - labelStyle: { - marginTop: '6px', + collectionName, + useProps() { + return { + options, + className: css` + position: relative; + width: 100%; + margin-left: 10px; + `, + }; }, - labelCol: 8, - wrapperCol: 16, - }, - properties: { - conditions: { - 'x-component': 'h4', - 'x-content': '{{ t("Condition") }}', - }, - condition: { - 'x-component': 'Filter', - 'x-component-props': { - collectionName, - useProps() { - return { - options, - className: css` - position: relative; - width: 100%; - margin-left: 10px; - `, - }; - }, - dynamicComponent: (props) => FilterDynamicComponent({ ...props, collectionName }), - }, - }, - actions: { - 'x-component': 'h4', - 'x-content': '{{ t("Properties") }}', - }, - action: { - type: 'void', - 'x-component': LinkageRuleActionGroup, - 'x-component-props': { - ...props, - }, + dynamicComponent: (props: DynamicComponentProps) => { + const { collectionField } = props; + return ( + + ); }, }, }, - remove: { - type: 'void', - 'x-component': 'ArrayCollapse.Remove', + actions: { + 'x-component': 'h4', + 'x-content': '{{ t("Properties") }}', }, - moveUp: { + action: { type: 'void', - 'x-component': 'ArrayCollapse.MoveUp', - }, - moveDown: { - type: 'void', - 'x-component': 'ArrayCollapse.MoveDown', - }, - copy: { - type: 'void', - 'x-component': 'ArrayCollapse.Copy', + 'x-component': LinkageRuleActionGroup, + 'x-component-props': { + ...props, + }, }, }, }, - properties: { - add: { - type: 'void', - title: '{{ t("Add linkage rule") }}', - 'x-component': 'ArrayCollapse.Addition', - 'x-reactions': { - dependencies: ['rules'], - fulfill: { - state: { - // disabled: '{{$deps[0].length >= 3}}', - }, - }, + remove: { + type: 'void', + 'x-component': 'ArrayCollapse.Remove', + }, + moveUp: { + type: 'void', + 'x-component': 'ArrayCollapse.MoveUp', + }, + moveDown: { + type: 'void', + 'x-component': 'ArrayCollapse.MoveDown', + }, + copy: { + type: 'void', + 'x-component': 'ArrayCollapse.Copy', + }, + }, + }, + properties: { + add: { + type: 'void', + title: '{{ t("Add linkage rule") }}', + 'x-component': 'ArrayCollapse.Addition', + 'x-reactions': { + dependencies: ['rules'], + fulfill: { + state: { + // disabled: '{{$deps[0].length >= 3}}', }, }, }, }, }, - }} - /> - + }, + }, + }), + [collectionName, defaultValues, form, localVariables, options, props, record, variables], + ); + const value = useMemo( + () => ({ field: options, fieldSchema, dynamicComponent, options: options || [] }), + [dynamicComponent, fieldSchema, options], + ); + + return ( + + + + + + + ); }, { displayName: 'FormLinkageRules' }, diff --git a/packages/core/client/src/schema-settings/SchemaSettings.tsx b/packages/core/client/src/schema-settings/SchemaSettings.tsx index 67165a29d..a7a59566e 100644 --- a/packages/core/client/src/schema-settings/SchemaSettings.tsx +++ b/packages/core/client/src/schema-settings/SchemaSettings.tsx @@ -32,12 +32,14 @@ import React, { } from 'react'; import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; +import { Router } from 'react-router-dom'; import { APIClientProvider, ActionContextProvider, CollectionFieldOptions, CollectionManagerContext, CollectionProvider, + DatePickerProvider, Designable, FormDialog, FormProvider, @@ -51,16 +53,23 @@ import { useActionContext, useBlockRequestContext, useCollection, + useCollectionFilterOptions, useCollectionManager, useCompile, useDesignable, useFilterBlock, useGlobalTheme, useLinkageCollectionFilterOptions, + useRecord, useSortFields, } from '..'; -import { useTableBlockContext } from '../block-provider'; -import { findFilterTargets, updateFilterTargets } from '../block-provider/hooks'; +import { BlockRequestContext, useFormBlockContext, useTableBlockContext } from '../block-provider'; +import { + FormActiveFieldsProvider, + findFilterTargets, + updateFilterTargets, + useFormActiveFields, +} from '../block-provider/hooks'; import { FilterBlockType, getSupportFieldsByAssociation, @@ -68,19 +77,25 @@ import { isSameCollection, useSupportedBlocks, } from '../filter-provider/utils'; +import { FlagProvider } from '../flag-provider'; import { useCollectMenuItem, useCollectMenuItems, useMenuItem } from '../hooks/useMenuItem'; import { getTargetKey } from '../schema-component/antd/association-filter/utilts'; -import { getFieldDefaultValue } from '../schema-component/antd/form-item'; -import { parseVariables, useVariablesCtx } from '../schema-component/common/utils/uitls'; +import { DynamicComponentProps } from '../schema-component/antd/filter/DynamicComponent'; import { useSchemaTemplateManager } from '../schema-templates'; import { useBlockTemplateContext } from '../schema-templates/BlockTemplate'; +import { useLocalVariables, useVariables } from '../variables'; +import { isVariable } from '../variables/utils/isVariable'; import { FormDataTemplates } from './DataTemplates'; import { DateFormatCom, ExpiresRadio } from './DateFormat/ExpiresRadio'; import { EnableChildCollections } from './EnableChildCollections'; import { ChildDynamicComponent } from './EnableChildCollections/DynamicComponent'; import { FormLinkageRules } from './LinkageRules'; import { useLinkageCollectionFieldOptions } from './LinkageRules/action-hooks'; -import { VariableInput } from './VariableInput/VariableInput'; +import { VariableInput, getShouldChange } from './VariableInput/VariableInput'; +import { BaseVariableProvider, IsDisabledParams } from './VariableInput/hooks/useBaseVariable'; +import { Option } from './VariableInput/type'; +import { formatVariableScop } from './VariableInput/utils/formatVariableScop'; +import { DataScopeProps } from './types'; interface SchemaSettingsProps { title?: any; @@ -112,6 +127,23 @@ interface RemoveProps { breakRemoveOn?: ISchema | ((s: ISchema) => boolean); } +interface ModalItemProps { + title: string; + onSubmit: (values: any) => void; + initialValues?: any; + schema?: ISchema; + modalTip?: string; + components?: any; + hidden?: boolean; + scope?: any; + effects?: any; + width?: string | number; + children?: ReactNode; + asyncGetInitialValues?: () => Promise; + eventKey?: string; + hide?: boolean; +} + type SchemaSettingsNested = { Remove?: React.FC; Item?: React.FC; @@ -119,6 +151,8 @@ type SchemaSettingsNested = { Popup?: React.FC; SwitchItem?: React.FC; CascaderItem?: React.FC & Omit & { title: any }>; + DataScope?: React.FC; + ModalItem: React.FC; [key: string]: any; }; @@ -515,6 +549,7 @@ SchemaSettings.Remove = function Remove(props: any) { const ctx = useBlockTemplateContext(); const form = useForm(); const { modal } = App.useApp(); + const { removeActiveFieldName } = useFormActiveFields() || {}; return ( { const { dn } = useSchemaSettings(); const compile = useCompile(); const api = useAPIClient(); + const upLevelActiveFields = useFormActiveFields(); const form = useMemo( () => @@ -810,20 +851,20 @@ SchemaSettings.ActionModalItem = React.memo((props: any) => { initialValues: cloneDeep(initialValues), values: cloneDeep(initialValues), }), - [], + [initialValues], ); - const cancelHandler = () => { + const cancelHandler = useCallback(() => { setVisible(false); - }; + }, []); - const submitHandler = async () => { + const submitHandler = useCallback(async () => { await form.submit(); onSubmit?.(cloneDeep(form.values)); setVisible(false); - }; + }, [form, onSubmit]); - const openAssignedFieldValueHandler = async () => { + const openAssignedFieldValueHandler = useCallback(async () => { if (!schemaUid && initialSchema?.['x-uid']) { fieldSchema['x-action-settings'].schemaUid = initialSchema['x-uid']; dn.emit('patch', { schema: fieldSchema }); @@ -833,38 +874,33 @@ SchemaSettings.ActionModalItem = React.memo((props: any) => { ctx.setVisible(false); setVisible(true); - }; + }, [api, ctx, dn, fieldSchema, initialSchema, schemaUid]); + + const onKeyDown = useCallback((e: React.KeyboardEvent): void => e.stopPropagation(), []); return ( <> - e.stopPropagation()}> + {props.children || props.title} {createPortal( -
{ - e.stopPropagation(); - }} - onKeyDown={(e) => { - e.stopPropagation(); - }} + + + + + } > - - - - - } - > + {modalTip && } @@ -873,8 +909,8 @@ SchemaSettings.ActionModalItem = React.memo((props: any) => { {visible && schema && } - -
, + + , document.body, )} @@ -882,7 +918,7 @@ SchemaSettings.ActionModalItem = React.memo((props: any) => { }); SchemaSettings.ActionModalItem.displayName = 'SchemaSettings.ActionModalItem'; -SchemaSettings.ModalItem = function ModalItem(props) { +SchemaSettings.ModalItem = function ModalItem(props: ModalItemProps) { const { hidden, title, @@ -901,6 +937,8 @@ SchemaSettings.ModalItem = function ModalItem(props) { const collection = useCollection(); const apiClient = useAPIClient(); const { theme } = useGlobalTheme(); + const ctx = useContext(BlockRequestContext); + const upLevelActiveFields = useFormActiveFields(); if (hidden) { return null; @@ -914,30 +952,36 @@ SchemaSettings.ModalItem = function ModalItem(props) { { title: schema.title || title, width }, () => { return ( - - - - 576px - @media (min-width: 576px) { - min-width: 520px; - } + + + + + + + 576px + @media (min-width: 576px) { + min-width: 520px; + } - // screen <= 576px - @media (max-width: 576px) { - min-width: 320px; - } - `} - > - - - - - - - + // screen <= 576px + @media (max-width: 576px) { + min-width: 320px; + } + `} + > + + + + + + + + + + ); }, theme, @@ -1089,57 +1133,74 @@ SchemaSettings.DefaultSortingRules = function DefaultSortingRules(props) { SchemaSettings.LinkageRules = function LinkageRules(props) { const { collectionName } = props; const fieldSchema = useFieldSchema(); + const { form } = useFormBlockContext(); const { dn } = useDesignable(); const { t } = useTranslation(); const { getTemplateById } = useSchemaTemplateManager(); + const variables = useVariables(); + const localVariables = useLocalVariables(); + const record = useRecord(); + const type = ['Action', 'Action.Link'].includes(fieldSchema['x-component']) ? 'button' : 'field'; const gridSchema = findGridSchema(fieldSchema) || fieldSchema; + const schema = useMemo( + () => ({ + type: 'object', + title: t('Linkage rules'), + properties: { + fieldReaction: { + 'x-component': FormLinkageRules, + 'x-component-props': { + useProps: () => { + const options = useLinkageCollectionFilterOptions(collectionName); + return { + options, + defaultValues: gridSchema?.['x-linkage-rules'] || fieldSchema?.['x-linkage-rules'], + type, + linkageOptions: useLinkageCollectionFieldOptions(collectionName), + collectionName, + form, + variables, + localVariables, + record, + }; + }, + }, + }, + }, + }), + [collectionName, fieldSchema, form, gridSchema, localVariables, record, t, type, variables], + ); + const components = useMemo(() => ({ ArrayCollapse, FormLayout }), []); + const onSubmit = useCallback( + (v) => { + const rules = []; + for (const rule of v.fieldReaction.rules) { + rules.push(_.pickBy(rule, _.identity)); + } + const templateId = gridSchema['x-component'] === 'BlockTemplate' && gridSchema['x-component-props'].templateId; + const uid = (templateId && getTemplateById(templateId).uid) || gridSchema['x-uid']; + const schema = { + ['x-uid']: uid, + }; + + gridSchema['x-linkage-rules'] = rules; + schema['x-linkage-rules'] = rules; + dn.emit('patch', { + schema, + }); + dn.refresh(); + }, + [dn, getTemplateById, gridSchema], + ); + return ( { - const options = useLinkageCollectionFilterOptions(collectionName); - return { - options, - defaultValues: gridSchema?.['x-linkage-rules'] || fieldSchema?.['x-linkage-rules'], - type, - linkageOptions: useLinkageCollectionFieldOptions(collectionName), - collectionName, - }; - }, - }, - }, - }, - } as ISchema - } - onSubmit={(v) => { - const rules = []; - for (const rule of v.fieldReaction.rules) { - rules.push(_.pickBy(rule, _.identity)); - } - const templateId = gridSchema['x-component'] === 'BlockTemplate' && gridSchema['x-component-props'].templateId; - const uid = (templateId && getTemplateById(templateId).uid) || gridSchema['x-uid']; - const schema = { - ['x-uid']: uid, - }; - - gridSchema['x-linkage-rules'] = rules; - schema['x-linkage-rules'] = rules; - dn.emit('patch', { - schema, - }); - dn.refresh(); - }} + schema={schema} + onSubmit={onSubmit} /> ); }; @@ -1173,6 +1234,7 @@ SchemaSettings.DataTemplates = function DataTemplates(props) { title: t('Form data templates'), properties: { fieldReaction: { + 'x-decorator': (props) => , 'x-component': FormDataTemplates, 'x-component-props': { designerCtx, @@ -1221,7 +1283,6 @@ SchemaSettings.EnableChildCollections = function EnableChildCollectionsItem(prop const { dn } = useDesignable(); const { t } = useTranslation(); const allowAddToCurrent = fieldSchema?.['x-allow-add-to-current']; - const form = useForm(); const { getCollectionJoinField } = useCollectionManager(); const ctx = useBlockRequestContext(); const collectionField = getCollectionJoinField(fieldSchema?.parent?.['x-collection-field']) || {}; @@ -1262,7 +1323,6 @@ SchemaSettings.EnableChildCollections = function EnableChildCollectionsItem(prop 'x-component': ChildDynamicComponent, 'x-component-props': { rootCollection: ctx.props.collection || ctx.props.resource, - form, collectionField, }, default: fieldSchema?.['x-component-props']?.['linkageFromForm'], @@ -1464,17 +1524,24 @@ export const findParentFieldSchema = (fieldSchema: Schema) => { } }; -SchemaSettings.DefaultValue = function DefaultValueConfigure(props) { - const variablesCtx = useVariablesCtx(); +SchemaSettings.DefaultValue = function DefaultValueConfigure(props: { fieldSchema?: Schema }) { const currentSchema = useFieldSchema(); const fieldSchema = props?.fieldSchema ?? currentSchema; - const field = useField(); + const field: Field = useField(); const { dn } = useDesignable(); const { t } = useTranslation(); const actionCtx = useActionContext(); let targetField; const { getField } = useCollection(); - const { getCollectionJoinField } = useCollectionManager(); + const { getCollectionJoinField, getCollectionFields, getAllCollectionsInheritChain } = useCollectionManager(); + const variables = useVariables(); + const localVariables = useLocalVariables(); + const collection = useCollection(); + const record = useRecord(); + const { form } = useFormBlockContext(); + const currentFormFields = useCollectionFilterOptions(collection); + + const { name } = collection; const collectionField = getField(fieldSchema['name']) || getCollectionJoinField(fieldSchema['x-collection-field']); const fieldSchemaWithoutRequired = _.omit(fieldSchema, 'required'); if (collectionField?.target) { @@ -1485,14 +1552,41 @@ SchemaSettings.DefaultValue = function DefaultValueConfigure(props) { const parentFieldSchema = collectionField?.interface === 'm2o' && findParentFieldSchema(fieldSchema); const parentCollectionField = parentFieldSchema && getCollectionJoinField(parentFieldSchema?.['x-collection-field']); const tableCtx = useTableBlockContext(); - const isAllowContexVariable = + const isAllowContextVariable = actionCtx?.fieldSchema?.['x-action'] === 'customize:create' && (collectionField?.interface === 'm2m' || (parentCollectionField?.type === 'hasMany' && collectionField?.interface === 'm2o')); + + const returnScope = useCallback( + (scope: Option[]) => { + const currentForm = scope.find((item) => item.value === '$nForm'); + const fields = getCollectionFields(name); + + // fix https://nocobase.height.app/T-1355 + // 工作流人工节点的 `自定义表单` 区块,与其它表单区块不同,根据它的数据表名称,获取到的字段列表为空,所以需要在这里特殊处理一下 + if (!fields?.length && currentForm) { + currentForm.children = formatVariableScop(currentFormFields); + } + + return scope; + }, + [currentFormFields, name], + ); + return ( { + return ( + + + + ); + }, + }} width={800} schema={ { @@ -1505,18 +1599,44 @@ SchemaSettings.DefaultValue = function DefaultValueConfigure(props) { 'x-component-props': { ...(fieldSchema?.['x-component-props'] || {}), collectionField, - contextCollectionName: isAllowContexVariable && tableCtx.collection, + contextCollectionName: isAllowContextVariable && tableCtx.collection, schema: collectionField?.uiSchema, + targetFieldSchema: fieldSchema, className: defaultInputStyle, + form, + record, + returnScope, + shouldChange: getShouldChange({ + collectionField, + variables, + localVariables, + getAllCollectionsInheritChain, + }), renderSchemaComponent: function Com(props) { const s = _.cloneDeep(fieldSchemaWithoutRequired) || ({} as Schema); s.title = ''; - - // 任何一个非空字符串都可以 s.name = 'default'; - s['x-read-pretty'] = false; s['x-disabled'] = false; + + const defaultValue = getFieldDefaultValue(s, collectionField); + + if (collectionField.target && s['x-component-props']) { + s['x-component-props'].mode = 'Select'; + } + + if (collectionField?.uiSchema.type) { + s.type = collectionField.uiSchema.type; + } + + if (collectionField?.uiSchema['x-component'] === 'Checkbox') { + s['x-component-props'].defaultChecked = defaultValue; + + // 在这里如果不设置 type 为 void,会导致设置的默认值不生效 + // 但是我不知道为什么必须要设置为 void ? + s.type = 'void'; + } + const schema = { ...(s || {}), 'x-decorator': 'FormItem', @@ -1524,16 +1644,22 @@ SchemaSettings.DefaultValue = function DefaultValueConfigure(props) { ...s['x-component-props'], collectionName: collectionField?.collectionName, targetField, - onChange: collectionField?.interface !== 'richText' ? props.onChange : null, - defaultValue: getFieldDefaultValue(s, collectionField), + onChange: props.onChange, + defaultValue: isVariable(defaultValue) ? '' : defaultValue, style: { width: '100%', verticalAlign: 'top', minWidth: '200px', }, }, - }; - return ; + default: isVariable(defaultValue) ? '' : defaultValue, + } as ISchema; + + return ( + + + + ); }, }, title: t('Default value'), @@ -1546,16 +1672,15 @@ SchemaSettings.DefaultValue = function DefaultValueConfigure(props) { const schema: ISchema = { ['x-uid']: fieldSchema['x-uid'], }; - if (field.value !== v.default) { - field.value = parseVariables(v.default, variablesCtx); - } fieldSchema.default = v.default; + if (!v.default && v.default !== 0) { + field.value = null; + } schema.default = v.default; dn.emit('patch', { schema, currentSchema, }); - dn.refresh(); }} /> ); @@ -1676,22 +1801,60 @@ SchemaSettings.SortingRule = function SortRuleConfigure(props) { /> ); }; -// 是否显示默认值配置项 -export const isShowDefaultValue = (collectionField: CollectionFieldOptions, getInterface) => { + +SchemaSettings.DataScope = function DataScopeConfigure(props: DataScopeProps) { + const { t } = useTranslation(); + const options = useCollectionFilterOptions(props.collectionName); + const record = useRecord(); + const { form } = useFormBlockContext(); + const variables = useVariables(); + const localVariables = useLocalVariables(); + const { getAllCollectionsInheritChain } = useCollectionManager(); + + const dynamicComponent = (props: DynamicComponentProps) => { + return ( + + + + ); + }; + return ( - ![ - 'o2o', - 'oho', - 'obo', - 'o2m', - 'attachment', - 'expression', - 'point', - 'lineString', - 'circle', - 'polygon', - 'sequence', - ].includes(collectionField?.interface) && !isSystemField(collectionField, getInterface) + ); }; @@ -1704,3 +1867,40 @@ export const isSystemField = (collectionField: CollectionFieldOptions, getInterf export const isPatternDisabled = (fieldSchema: Schema) => { return fieldSchema?.['x-component-props']?.['pattern-disable'] == true; }; + +function getFieldDefaultValue(fieldSchema: ISchema, collectionField: CollectionFieldOptions) { + const result = fieldSchema?.default ?? collectionField?.defaultValue; + return result; +} + +function isDisabled(params: IsDisabledParams) { + const { option, collectionField, uiSchema } = params; + + if (!uiSchema || !collectionField) { + return true; + } + + // json 类型的字段,允许设置任意类型的值 + if (collectionField.interface === 'json') { + return false; + } + + // 数据范围支持选择 `对多` 、`对一` 的关系字段 + if (option.target) { + return false; + } + + if (['input', 'markdown', 'richText', 'textarea', 'username'].includes(collectionField.interface)) { + return !['string', 'number'].includes(option.schema?.type); + } + + if (collectionField.interface && option.interface) { + return collectionField.interface !== option.interface; + } + + if (uiSchema?.['x-component'] !== option.schema?.['x-component']) { + return true; + } + + return false; +} diff --git a/packages/core/client/src/schema-settings/VariableInput/VariableInput.tsx b/packages/core/client/src/schema-settings/VariableInput/VariableInput.tsx index 238101552..061a1cf9b 100644 --- a/packages/core/client/src/schema-settings/VariableInput/VariableInput.tsx +++ b/packages/core/client/src/schema-settings/VariableInput/VariableInput.tsx @@ -1,23 +1,72 @@ -import React, { useMemo } from 'react'; +import { Form } from '@formily/core'; +// @ts-ignore +import { Schema } from '@formily/json-schema'; +import _ from 'lodash'; +import React, { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; import { CollectionFieldOptions } from '../../collection-manager'; -import { useCompile, Variable } from '../../schema-component'; -import { useContextAssociationFields, useIsSameOrChildCollection } from './hooks/useContextAssociationFields'; +import { Variable, useVariableScope } from '../../schema-component'; +import { useValues } from '../../schema-component/antd/filter/useValues'; +import { VariableOption, VariablesContextType } from '../../variables/types'; +import { isVariable } from '../../variables/utils/isVariable'; +import { useBlockCollection } from './hooks/useBlockCollection'; +import { useContextAssociationFields } from './hooks/useContextAssociationFields'; +import { useRecordVariable } from './hooks/useRecordVariable'; import { useUserVariable } from './hooks/useUserVariable'; +import { useVariableOptions } from './hooks/useVariableOptions'; +import { Option } from './type'; + +interface GetShouldChangeProps { + collectionField: CollectionFieldOptions; + variables: VariablesContextType; + localVariables: VariableOption | VariableOption[]; + /** `useCollectionManager` 返回的 */ + getAllCollectionsInheritChain: (collectionName: string) => string[]; +} + +interface RenderSchemaComponentProps { + value: any; + onChange: (value: any) => void; +} type Props = { value: any; - onChange: (value: any) => void; - collectionName: string; - renderSchemaComponent?: (props: any) => any; - schema: any; - operator: any; + onChange: (value: any, optionPath?: any[]) => void; + renderSchemaComponent: (props: RenderSchemaComponentProps) => any; + schema?: any; + /** 消费变量值的字段 */ + targetFieldSchema?: Schema; children?: any; className?: string; style?: React.CSSProperties; - collectionField?: CollectionFieldOptions; + collectionField: CollectionFieldOptions; contextCollectionName?: string; + /** + * 根据 `onChange` 的第一个参数,判断是否需要触发 `onChange` + * @param value `onChange` 的第一个参数 + * @returns 返回为 `true` 时,才会触发 `onChange` + */ + shouldChange?: (value: any, optionPath?: any[]) => Promise; + form?: Form; + /** + * 当前表单的记录,数据来自数据库 + */ + record?: Record; + /** + * 可以用该方法对内部的 scope 进行筛选和修改 + * @param scope + * @returns + */ + returnScope?: (scope: Option[]) => any[]; }; +/** + * 注意:该组件存在以下问题: + * - 在选中选项的时候该组件不能触发重渲染 + * - 如果触发重渲染可能会导致无法展开子选项列表 + * @param props + * @returns + */ export const VariableInput = (props: Props) => { const { value, @@ -26,49 +75,245 @@ export const VariableInput = (props: Props) => { style, schema, className, - collectionField, contextCollectionName, + collectionField, + shouldChange, + form, + record, + returnScope = _.identity, + targetFieldSchema, } = props; - const compile = useCompile(); - const userVariable = useUserVariable({ schema, maxDepth: 1 }); + const { name: blockCollectionName } = useBlockCollection(); + const scope = useVariableScope(); + const { operator, schema: uiSchema = collectionField?.uiSchema } = useValues(); + + const variableOptions = useVariableOptions({ collectionField, form, record, operator, uiSchema, targetFieldSchema }); const contextVariable = useContextAssociationFields({ schema, maxDepth: 2, contextCollectionName, collectionField }); - const getIsSameOrChildCollection = useIsSameOrChildCollection(); - const isAllowTableContext = getIsSameOrChildCollection(contextCollectionName, collectionField?.target); - const scope = useMemo(() => { - const data = [ - compile({ - label: `{{t("Date variables")}}`, - value: '$date', - key: '$date', - disabled: schema['x-component'] !== 'DatePicker', - children: [ - { - key: 'now', - value: 'now', - label: `{{t("Now")}}`, - }, - ], - }), - ]; - if (collectionField?.target === 'users') { - data.unshift(userVariable); - } - if (contextCollectionName) { - data.unshift(contextVariable); - } - return data; - }, []); + const { compatOldVariables } = useCompatOldVariables({ + collectionField, + uiSchema, + targetFieldSchema, + blockCollectionName, + }); + + if (contextCollectionName && variableOptions.every((item) => item.value !== contextVariable.value)) { + variableOptions.push(contextVariable); + } + + const handleChange = useCallback( + (value: any, optionPath: any[]) => { + if (!shouldChange) { + return onChange(value); + } + + // `shouldChange` 这个函数的运算量比较大,会导致展开变量列表时有明显的卡顿感,在这里加个延迟能有效解决这个问题 + setTimeout(async () => { + if (await shouldChange(value, optionPath)) { + onChange(value); + } + }); + }, + [onChange, shouldChange], + ); return ( ); }; + +/** + * 通过限制用户的选择,来防止用户选择错误的变量 + */ +export const getShouldChange = ({ + collectionField, + variables, + localVariables, + getAllCollectionsInheritChain, +}: GetShouldChangeProps) => { + const collectionsInheritChain = collectionField ? getAllCollectionsInheritChain(collectionField.target) : []; + + return async (value: any, optionPath: any[]) => { + if (!isVariable(value) || !variables || !collectionField) { + return true; + } + + // `json` 可以选择任意类型的变量,详见:https://nocobase.feishu.cn/docx/EmNEdEBOnoQohUx2UmBcqIQ5nyh#FPLfdSRDEoXR65xW0mBcdfL5n0c + if (collectionField.interface === 'json') { + return true; + } + + const lastOption = optionPath[optionPath.length - 1]; + + // 点击叶子节点时,必须更新 value + if (lastOption && _.isEmpty(lastOption.children) && !lastOption.loadChildren) { + return true; + } + + const collectionFieldOfVariable = await variables.getCollectionField(value, localVariables); + + if (!collectionField) { + return false; + } + + // `一对一` 和 `一对多` 的不能用于设置默认值,因为其具有唯一性 + 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 && + !collectionsInheritChain.includes(collectionFieldOfVariable?.target) + ) { + return false; + } + + return true; + }; +}; + +export interface FormatVariableScopeParam { + children: any[]; + disabled: boolean; + name: string; + title: string; +} + +export interface FormatVariableScopeReturn { + value: string; + key: string; + label: string; + disabled: boolean; + children?: any[]; +} + +/** + * 兼容老版本的变量 + * @param variables + */ +export function useCompatOldVariables(props: { + uiSchema: any; + collectionField: CollectionFieldOptions; + blockCollectionName: string; + noDisabled?: boolean; + targetFieldSchema?: Schema; +}) { + const { uiSchema, collectionField, noDisabled, targetFieldSchema, blockCollectionName } = props; + const { t } = useTranslation(); + const lowLevelUserVariable = useUserVariable({ + maxDepth: 1, + uiSchema: uiSchema, + collectionField, + noDisabled, + targetFieldSchema, + }); + const currentRecordVariable = useRecordVariable({ + schema: uiSchema, + collectionName: blockCollectionName, + collectionField, + noDisabled, + targetFieldSchema, + }); + + const compatOldVariables = useCallback( + (variables: Option[], { value }) => { + if (!isVariable(value)) { + return variables; + } + + variables = _.cloneDeep(variables); + + const systemVariable: Option = { + value: '$system', + key: '$system', + label: t('System variables'), + isLeaf: false, + children: [ + { + value: 'now', + key: 'now', + label: t('Current time'), + isLeaf: true, + depth: 1, + }, + ], + depth: 0, + }; + const currentTime = { + value: 'currentTime', + label: t('Current time'), + children: null, + }; + + if (value.includes('$system')) { + variables.push(systemVariable); + } + + if (value.includes(`${blockCollectionName}.`)) { + const variable = variables.find((item) => item.value === '$nForm' || item.value === '$nRecord'); + if (variable) { + variable.value = blockCollectionName; + } + } + + if (value.includes('$form')) { + const variable = variables.find((item) => item.value === '$nForm'); + if (variable) { + variable.value = '$form'; + } + } + + if (value.includes('currentUser')) { + const userVariable = variables.find((item) => item.value === '$user'); + if (userVariable) { + userVariable.value = 'currentUser'; + } else { + variables.unshift({ ...lowLevelUserVariable, value: 'currentUser' }); + } + } + + if (value.includes('currentRecord')) { + const formVariable = variables.find((item) => item.value === '$nRecord'); + if (formVariable) { + formVariable.value = 'currentRecord'; + } else { + variables.unshift({ ...currentRecordVariable, value: 'currentRecord' }); + } + } + + if (value.includes('currentTime')) { + variables.push(currentTime); + } + + if (value.includes('$date')) { + const formVariable = variables.find((item) => item.value === '$nDate'); + if (formVariable) { + formVariable.value = '$date'; + } + } + + return variables; + }, + [blockCollectionName], + ); + + return { compatOldVariables }; +} diff --git a/packages/core/client/src/schema-settings/VariableInput/hooks/useBaseVariable.tsx b/packages/core/client/src/schema-settings/VariableInput/hooks/useBaseVariable.tsx new file mode 100644 index 000000000..1a321f643 --- /dev/null +++ b/packages/core/client/src/schema-settings/VariableInput/hooks/useBaseVariable.tsx @@ -0,0 +1,272 @@ +import { ISchema, Schema } from '@formily/json-schema'; +import React, { useContext, useMemo } from 'react'; +import { CollectionFieldOptions, useCollectionManager } from '../../../collection-manager'; +import { useCompile, useGetFilterOptions } from '../../../schema-component'; +import { isSpecialCaseField } from '../../../schema-component/antd/form-item/hooks/useSpecialCase'; +import { FieldOption, Option } from '../type'; + +export interface IsDisabledParams { + option: FieldOption; + collectionField: CollectionFieldOptions; + uiSchema: ISchema; + /** 消费变量值的字段 */ + targetFieldSchema: Schema; + getCollectionField: (name: string) => CollectionFieldOptions; +} + +interface GetOptionsParams { + collectionField: CollectionFieldOptions; + uiSchema: any; + depth: number; + /** 消费变量值的字段 */ + targetFieldSchema?: Schema; + maxDepth?: number; + /** + * 不需要禁用选项,一般会在表达式中使用 + */ + noDisabled?: boolean; + loadChildren?: (option: Option) => Promise; + compile: (value: string) => any; + isDisabled?: (params: IsDisabledParams) => boolean; + getCollectionField?: (name: string) => CollectionFieldOptions; +} + +interface BaseProps { + // 当前字段 + collectionField: CollectionFieldOptions; + /** 当前字段的 `uiSchema`,和 `collectionField.uiSchema` 不同,该值也包含操作符中 schema(参见 useValues) */ + uiSchema: any; + /** 消费变量值的字段 */ + targetFieldSchema?: Schema; + maxDepth?: number; + name: string; + title: string; + /** + * 变量所对应的 collectionName,例如:$user 对应的 collectionName 是 users + */ + collectionName: string; + /** + * 不需要禁用选项,一般会在表达式中使用 + */ + noDisabled?: boolean; + /** + * 对每一个关系字段的 fields 进行过滤 + * @param fields + * @param option + */ + returnFields?(fields: FieldOption[], option: Option): FieldOption[]; +} + +interface BaseVariableProviderProps { + /** + * 每一个选项都会调用该方法,返回 true 表示禁用该选项,当 `noDisabled` 为 true 时,该方法不会被调用 + */ + isDisabled?: (params: IsDisabledParams) => boolean; + children?: React.ReactNode; +} + +const BaseVariableContext = React.createContext>(null); + +export const BaseVariableProvider = (props: BaseVariableProviderProps) => { + return {props.children}; +}; + +const getChildren = ( + options: FieldOption[], + { + collectionField, + uiSchema, + depth, + maxDepth, + noDisabled, + loadChildren, + compile, + isDisabled, + targetFieldSchema, + getCollectionField, + }: GetOptionsParams, +): Option[] => { + const result = options + .map((option): Option => { + if (!option.target) { + return { + key: option.name, + value: option.name, + label: compile(option.title), + disabled: noDisabled + ? false + : isDisabled({ option, collectionField, uiSchema, targetFieldSchema, getCollectionField }), + isLeaf: true, + depth, + }; + } + + if (depth >= maxDepth) { + return null; + } + + return { + key: option.name, + value: option.name, + label: compile(option.title), + isLeaf: false, + field: option, + depth, + disabled: noDisabled + ? false + : isDisabled({ option, collectionField, uiSchema, targetFieldSchema, getCollectionField }), + loadChildren, + }; + }) + .filter(Boolean); + + return result; +}; + +export const useBaseVariable = ({ + collectionField, + uiSchema, + targetFieldSchema, + maxDepth = 3, + name, + title, + collectionName, + noDisabled, + returnFields = (fields) => fields, +}: BaseProps) => { + const compile = useCompile(); + const getFilterOptions = useGetFilterOptions(); + const { isDisabled } = useContext(BaseVariableContext) || {}; + const { getCollectionField } = useCollectionManager(); + + const loadChildren = (option: Option): Promise => { + if (!option.field?.target) { + return Promise.resolve(void 0); + } + + const target = option.field.target; + return new Promise((resolve) => { + setTimeout(() => { + const children = ( + getChildren(returnFields(getFilterOptions(target), option), { + collectionField, + uiSchema, + targetFieldSchema, + depth: option.depth + 1, + maxDepth, + noDisabled, + loadChildren, + compile, + isDisabled: isDisabled || isDisabledDefault, + getCollectionField, + }) || [] + ) + // 将叶子节点排列在上面,方便用户选择 + .sort((a, b) => { + if (a.isLeaf && !b.isLeaf) { + return -1; + } + if (!a.isLeaf && b.isLeaf) { + return 1; + } + return 0; + }) + // 将禁用项排列在下面,方便用户选择 + .sort((a, b) => { + if (a.disabled && !b.disabled) { + return 1; + } + if (!a.disabled && b.disabled) { + return -1; + } + return 0; + }); + + if (children.length === 0) { + option.disabled = true; + option.isLeaf = true; + resolve(); + return; + } + option.children = children; + resolve(); + + // 延迟 5 毫秒,防止阻塞主线程,导致 UI 卡顿 + }, 5); + }); + }; + + const result = useMemo(() => { + return { + label: title, + value: name, + key: name, + isLeaf: false, + field: { + target: collectionName, + }, + depth: 0, + loadChildren, + children: [], + } as Option; + }, [uiSchema?.['x-component']]); + + return result; +}; + +/** + * 默认的禁用选项方法,可以通过 `BaseVariableProvider` 覆盖 + * @param params + * @returns + */ +function isDisabledDefault(params: IsDisabledParams) { + const { option, collectionField, uiSchema, targetFieldSchema, getCollectionField } = params; + + if (!uiSchema || !collectionField) { + return true; + } + + // json 类型的字段,允许设置任意类型的值 + if (collectionField.interface === 'json') { + return false; + } + + // 普通字段不允许设置多个值 + if (!collectionField.target && ['hasMany', 'belongsToMany'].includes(option.type)) { + return true; + } + if (!collectionField.target && ['hasOne', 'belongsTo'].includes(option.type)) { + return false; + } + + if (option.target && isSpecialCaseField({ collectionField, fieldSchema: targetFieldSchema, getCollectionField })) { + return false; + } + + // 对一字段不允许设置多个值 + if (['hasOne', 'belongsTo'].includes(collectionField.type) && ['hasMany', 'belongsToMany'].includes(option.type)) { + return true; + } + if (['hasOne', 'belongsTo'].includes(collectionField.type) && ['hasOne', 'belongsTo'].includes(option.type)) { + return false; + } + + // 对多字段可以选择对一和对多字段作为默认值 + if (['hasMany', 'belongsToMany'].includes(collectionField.type) && option.target) { + return false; + } + + if (['input', 'markdown', 'richText', 'textarea', 'username'].includes(collectionField.interface)) { + return !['string', 'number'].includes(option.schema?.type); + } + + if (collectionField.interface && option.interface) { + return collectionField.interface !== option.interface; + } + + if (uiSchema?.['x-component'] !== option.schema?.['x-component']) { + return true; + } + + return false; +} diff --git a/packages/core/client/src/schema-settings/VariableInput/hooks/useBlockCollection.ts b/packages/core/client/src/schema-settings/VariableInput/hooks/useBlockCollection.ts new file mode 100644 index 000000000..64021c2a1 --- /dev/null +++ b/packages/core/client/src/schema-settings/VariableInput/hooks/useBlockCollection.ts @@ -0,0 +1,8 @@ +import { useBlockRequestContext } from '../../../block-provider/BlockProvider'; + +export const useBlockCollection = () => { + const ctx = useBlockRequestContext(); + const name: string = ctx.props?.collection || ctx.props?.resource; + + return { name }; +}; diff --git a/packages/core/client/src/schema-settings/VariableInput/hooks/useContextAssociationFields.tsx b/packages/core/client/src/schema-settings/VariableInput/hooks/useContextAssociationFields.tsx index 7c7ad9acd..0ceaaab4b 100644 --- a/packages/core/client/src/schema-settings/VariableInput/hooks/useContextAssociationFields.tsx +++ b/packages/core/client/src/schema-settings/VariableInput/hooks/useContextAssociationFields.tsx @@ -1,7 +1,7 @@ import { error } from '@nocobase/utils/client'; import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { useCollectionManager, CollectionFieldOptions } from '../../../collection-manager'; +import { CollectionFieldOptions, useCollectionManager } from '../../../collection-manager'; import { useCompile, useGetFilterOptions } from '../../../schema-component'; import { FieldOption, Option } from '../type'; @@ -11,8 +11,8 @@ export const useIsSameOrChildCollection = () => { if (contextCollection === targetCollection) { return true; } - const childrens = getChildrenCollections(targetCollection); - return childrens?.some((v) => v.name === contextCollection); + const children = getChildrenCollections(targetCollection); + return children?.some((v) => v.name === contextCollection); }; }; diff --git a/packages/core/client/src/schema-settings/VariableInput/hooks/useDateVariable.ts b/packages/core/client/src/schema-settings/VariableInput/hooks/useDateVariable.ts index 2168e28b5..d1566b2fa 100644 --- a/packages/core/client/src/schema-settings/VariableInput/hooks/useDateVariable.ts +++ b/packages/core/client/src/schema-settings/VariableInput/hooks/useDateVariable.ts @@ -1,16 +1,32 @@ import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -export const useDateVariable = ({ operator, schema }) => { +interface Props { + operator?: { + value: string; + }; + schema: any; + /** + * 不需要禁用选项,一般会在表达式中使用 + */ + noDisabled?: boolean; +} + +/** + * 变量:`日期变量` + * @param param0 + * @returns + */ +export const useDateVariable = ({ operator, schema, noDisabled }: Props) => { const { t } = useTranslation(); const operatorValue = operator?.value || ''; - const disabled = !['DatePicker', 'DatePicker.RangePicker'].includes(schema?.['x-component']); + const disabled = noDisabled ? false : !['DatePicker', 'DatePicker.RangePicker'].includes(schema?.['x-component']); const dateOptions = [ { key: 'now', value: 'now', - label: t('Now'), - disabled: schema?.['x-component'] !== 'DatePicker' || operatorValue === '$dateBetween', + label: t('Current time'), + disabled: noDisabled ? false : schema?.['x-component'] !== 'DatePicker' || operatorValue === '$dateBetween', }, { key: 'yesterday', @@ -143,14 +159,14 @@ export const useDateVariable = ({ operator, schema }) => { const result = useMemo(() => { return { label: t('Date variables'), - value: '$date', - key: '$date', + value: '$nDate', + key: '$nDate', disabled: dateOptions.every((option) => option.disabled), children: dateOptions, }; }, [schema?.['x-component']]); - if (!operator || !schema) return null; + if (!schema) return null; return result; }; diff --git a/packages/core/client/src/schema-settings/VariableInput/hooks/useFormVariable.ts b/packages/core/client/src/schema-settings/VariableInput/hooks/useFormVariable.ts index 95b7573f3..e547165ca 100644 --- a/packages/core/client/src/schema-settings/VariableInput/hooks/useFormVariable.ts +++ b/packages/core/client/src/schema-settings/VariableInput/hooks/useFormVariable.ts @@ -1,123 +1,45 @@ -import { useMemo } from 'react'; +import { Schema } from '@formily/json-schema'; import { useTranslation } from 'react-i18next'; -import { useCompile, useGetFilterOptions } from '../../../schema-component'; -import { FieldOption, Option } from '../type'; +import { useFormActiveFields } from '../../../block-provider'; +import { CollectionFieldOptions } from '../../../collection-manager'; +import { useBaseVariable } from './useBaseVariable'; -interface GetOptionsParams { - depth: number; - operator?: string; - maxDepth: number; - count?: number; - loadChildren?: (option: Option) => Promise; - getFilterOptions?: (collectionName: string) => any[]; - compile: (value: string) => any; +interface Props { + collectionName: string; + collectionField: CollectionFieldOptions; schema?: any; + noDisabled?: boolean; + /** 消费变量值的字段 */ + targetFieldSchema?: Schema; } -const getChildren = ( - options: FieldOption[], - { depth, maxDepth, loadChildren, compile, schema }: GetOptionsParams, -): Option[] => { - const result = options - .map((option): Option => { - if (!option.target) { - return { - key: option.name, - value: option.name, - label: compile(option.title), - depth, - // TODO: 现在是通过组件的名称来过滤能够被选择的选项,这样的坏处是不够精确,后续可以优化 - disabled: schema && schema?.['x-component'] !== option.schema?.['x-component'], - }; - } - - if (depth >= maxDepth) { - return null; - } - - return { - key: option.name, - value: option.name, - label: compile(option.title), - isLeaf: false, - field: option, - depth, - loadChildren, - }; - }) - .filter(Boolean); - return result; -}; -export const useFormVariable = ({ - blockForm, - rootCollection, - operator, - level, - schema, -}: { - blockForm?: any; - rootCollection: string; - operator?: any; - level?: number; - schema?: any; -}) => { - const compile = useCompile(); +/** + * 变量:`当前表单` + * @param param0 + * @returns + */ +export const useFormVariable = ({ collectionName, collectionField, schema, noDisabled, targetFieldSchema }: Props) => { + const { getActiveFieldsName } = useFormActiveFields(); const { t } = useTranslation(); - const getFilterOptions = useGetFilterOptions(); - const loadChildren = (option: any): Promise => { - if (!option.field?.target) { - return new Promise((resolve) => { - resolve(void 0); - }); - } + const result = useBaseVariable({ + collectionField, + uiSchema: schema, + targetFieldSchema, + maxDepth: 4, + name: '$nForm', + title: t('Current form'), + collectionName: collectionName, + noDisabled, + returnFields: (fields, option) => { + const activeFieldsName = getActiveFieldsName('form'); - const collectionName = option.field.target; - const fields = getFilterOptions(collectionName); - const allowFields = - option.depth === 0 + return option.depth === 0 ? fields.filter((field) => { - return Object.keys(blockForm.fields).some((name) => name.includes(`.${field.name}`)); + return activeFieldsName.includes(field.name); }) : fields; - return new Promise((resolve) => { - setTimeout(() => { - const children = - getChildren(allowFields, { - depth: option.depth + 1, - maxDepth: 4, - loadChildren, - compile, - schema, - }) || []; - if (children.length === 0) { - option.disabled = true; - resolve(); - return; - } - option.children = children; - resolve(); + }, + }); - // 延迟 5 毫秒,防止阻塞主线程,导致 UI 卡顿 - }, 5); - }); - }; - - const label = t('Current form'); - - const result = useMemo(() => { - return ( - blockForm && { - label, - value: '$form', - key: '$form', - isLeaf: false, - field: { - target: rootCollection, - }, - depth: 0, - loadChildren, - } - ); - }, [rootCollection]); return result; }; diff --git a/packages/core/client/src/schema-settings/VariableInput/hooks/useIterationVariable.ts b/packages/core/client/src/schema-settings/VariableInput/hooks/useIterationVariable.ts index 3c9a563d9..f56c7ed4f 100644 --- a/packages/core/client/src/schema-settings/VariableInput/hooks/useIterationVariable.ts +++ b/packages/core/client/src/schema-settings/VariableInput/hooks/useIterationVariable.ts @@ -1,125 +1,49 @@ -import { useMemo } from 'react'; +import { Schema } from '@formily/json-schema'; import { useTranslation } from 'react-i18next'; -import { useCompile, useGetFilterOptions } from '../../../schema-component'; -import { FieldOption, Option } from '../type'; +import { useFormActiveFields } from '../../../block-provider'; +import { CollectionFieldOptions } from '../../../collection-manager'; +import { useBaseVariable } from './useBaseVariable'; -interface GetOptionsParams { - depth: number; - operator?: string; - maxDepth: number; - count?: number; - loadChildren?: (option: Option) => Promise; - getFilterOptions?: (collectionName: string) => any[]; - compile: (value: string) => any; - schema: any; -} - -const getChildren = ( - options: FieldOption[], - { depth, maxDepth, loadChildren, compile, schema }: GetOptionsParams, -): Option[] => { - const result = options - .map((option): Option => { - if (!option.target) { - return { - key: option.name, - value: option.name, - label: compile(option.title), - depth, - // TODO: 现在是通过组件的名称来过滤能够被选择的选项,这样的坏处是不够精确,后续可以优化 - disabled: schema && schema?.['x-component'] !== option.schema?.['x-component'], - }; - } - - if (depth >= maxDepth) { - return null; - } - - return { - key: option.name, - value: option.name, - label: compile(option.title), - children: [], - isLeaf: false, - field: option, - depth, - loadChildren, - }; - }) - .filter(Boolean); - return result; -}; +/** + * 变量:`当前对象` + * @param param0 + * @returns + */ export const useIterationVariable = ({ - blockForm, currentCollection, - operator, + collectionField, schema, - level, - rootCollection, + noDisabled, + targetFieldSchema, }: { - blockForm?: any; currentCollection: string; - rootCollection: string; - operator?: any; + collectionField: CollectionFieldOptions; schema?: any; - level?: number; + noDisabled?: boolean; + /** 消费变量值的字段 */ + targetFieldSchema?: Schema; }) => { + const { getActiveFieldsName } = useFormActiveFields(); const { t } = useTranslation(); - const compile = useCompile(); - const getFilterOptions = useGetFilterOptions(); - const loadChildren = (option: any): Promise => { - if (!option.field?.target) { - return new Promise((resolve) => { - resolve(void 0); - }); - } + const result = useBaseVariable({ + collectionField, + uiSchema: schema, + targetFieldSchema, + maxDepth: 4, + name: '$iteration', + title: t('Current object'), + collectionName: currentCollection, + noDisabled, + returnFields: (fields, option) => { + const activeFieldsName = getActiveFieldsName('nester'); - const collectionName = option.field.target; - const fields = getFilterOptions(collectionName); - const allowFields = - option.depth === 0 + return option.depth === 0 ? fields.filter((field) => { - return Object.keys(blockForm.fields).some((name) => name.includes(`.${field.name}`)); + return activeFieldsName?.includes(field.name); }) : fields; - return new Promise((resolve) => { - setTimeout(() => { - const children = - getChildren(allowFields, { - depth: option.depth + 1, - maxDepth: 4, - loadChildren, - compile, - schema, - }) || []; - if (children.length === 0) { - option.disabled = true; - resolve(); - return; - } - option.children = children; - resolve(); + }, + }); - // 延迟 5 毫秒,防止阻塞主线程,导致 UI 卡顿 - }, 5); - }); - }; - const label = t('Current object'); - const result = useMemo(() => { - return ( - blockForm && - currentCollection !== rootCollection && { - label: label, - value: '$iteration', - key: '$iteration', - isLeaf: false, - field: { - target: currentCollection, - }, - depth: 0, - loadChildren, - } - ); - }, [currentCollection]); return result; }; diff --git a/packages/core/client/src/schema-settings/VariableInput/hooks/useRecordVariable.ts b/packages/core/client/src/schema-settings/VariableInput/hooks/useRecordVariable.ts new file mode 100644 index 000000000..c9e303caf --- /dev/null +++ b/packages/core/client/src/schema-settings/VariableInput/hooks/useRecordVariable.ts @@ -0,0 +1,29 @@ +import { Schema } from '@formily/json-schema'; +import { useTranslation } from 'react-i18next'; +import { CollectionFieldOptions } from '../../../collection-manager'; +import { useBaseVariable } from './useBaseVariable'; + +interface Props { + collectionField: CollectionFieldOptions; + schema: any; + collectionName: string; + noDisabled?: boolean; + /** 消费变量值的字段 */ + targetFieldSchema?: Schema; +} + +export const useRecordVariable = (props: Props) => { + const { t } = useTranslation(); + + const currentRecordVariable = useBaseVariable({ + collectionField: props.collectionField, + uiSchema: props.schema, + name: '$nRecord', + title: t('Current record'), + collectionName: props.collectionName, + noDisabled: props.noDisabled, + targetFieldSchema: props.targetFieldSchema, + }); + + return currentRecordVariable; +}; 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 1010ec40e..93ca47cb1 100644 --- a/packages/core/client/src/schema-settings/VariableInput/hooks/useUserVariable.ts +++ b/packages/core/client/src/schema-settings/VariableInput/hooks/useUserVariable.ts @@ -1,105 +1,38 @@ -import { error } from '@nocobase/utils/client'; -import { useMemo } from 'react'; +import { Schema } from '@formily/json-schema'; import { useTranslation } from 'react-i18next'; -import { useCompile, useGetFilterOptions } from '../../../schema-component'; -import { FieldOption, Option } from '../type'; +import { CollectionFieldOptions } from '../../../collection-manager'; +import { useBaseVariable } from './useBaseVariable'; -interface GetOptionsParams { - schema: any; - depth: number; +/** + * 变量:`当前用户` + * @param param0 + * @returns + */ +export const useUserVariable = ({ + collectionField, + uiSchema, + noDisabled, + targetFieldSchema, + maxDepth = 3, +}: { + collectionField: CollectionFieldOptions; + uiSchema: any; maxDepth?: number; - loadChildren?: (option: Option) => Promise; - compile: (value: string) => any; -} - -const getChildren = ( - options: FieldOption[], - { schema, depth, maxDepth, loadChildren, compile }: GetOptionsParams, -): Option[] => { - const result = options - .map((option): Option => { - if (!option.target) { - return { - key: option.name, - value: option.name, - label: compile(option.title), - // TODO: 现在是通过组件的名称来过滤能够被选择的选项,这样的坏处是不够精确,后续可以优化 - disabled: schema?.['x-component'] !== option.schema?.['x-component'], - isLeaf: true, - depth, - }; - } - - if (depth >= maxDepth) { - return null; - } - - return { - key: option.name, - value: option.name, - label: compile(option.title), - isLeaf: false, - field: option, - depth, - loadChildren, - }; - }) - .filter(Boolean); - - return result; -}; - -export const useUserVariable = ({ schema, maxDepth = 3 }: { schema: any; maxDepth?: number }) => { + noDisabled?: boolean; + /** 消费变量值的字段 */ + targetFieldSchema?: Schema; +}) => { const { t } = useTranslation(); - const compile = useCompile(); - const getFilterOptions = useGetFilterOptions(); - - const loadChildren = (option: Option): Promise => { - if (!option.field?.target) { - return new Promise((resolve) => { - error('Must be set field target'); - resolve(void 0); - }); - } - - const collectionName = option.field.target; - return new Promise((resolve) => { - setTimeout(() => { - const children = - getChildren(getFilterOptions(collectionName), { - schema, - depth: option.depth + 1, - maxDepth, - loadChildren, - compile, - }) || []; - - if (children.length === 0) { - option.disabled = true; - resolve(); - return; - } - option.children = children; - resolve(); - - // 延迟 5 毫秒,防止阻塞主线程,导致 UI 卡顿 - }, 5); - }); - }; - - const result = useMemo(() => { - return { - label: t('Current user'), - value: '$user', - key: '$user', - isLeaf: false, - field: { - target: 'users', - }, - depth: 0, - loadChildren, - } as Option; - }, [schema?.['x-component']]); + const result = useBaseVariable({ + collectionField, + uiSchema, + maxDepth, + name: '$user', + title: t('Current user'), + collectionName: 'users', + noDisabled, + targetFieldSchema, + }); return result; }; diff --git a/packages/core/client/src/schema-settings/VariableInput/hooks/useVariableOptions.ts b/packages/core/client/src/schema-settings/VariableInput/hooks/useVariableOptions.ts index 436bfb760..3f5d957ae 100644 --- a/packages/core/client/src/schema-settings/VariableInput/hooks/useVariableOptions.ts +++ b/packages/core/client/src/schema-settings/VariableInput/hooks/useVariableOptions.ts @@ -1,28 +1,112 @@ +import { Form } from '@formily/core'; +import { ISchema, Schema } from '@formily/react'; +import _ from 'lodash'; import { useMemo } from 'react'; -import { useValues } from '../../../schema-component/antd/filter/useValues'; +import { CollectionFieldOptions } from '../../../collection-manager'; +import { useFlag } from '../../../flag-provider'; +import { useBlockCollection } from './useBlockCollection'; import { useDateVariable } from './useDateVariable'; import { useFormVariable } from './useFormVariable'; import { useIterationVariable } from './useIterationVariable'; +import { useRecordVariable } from './useRecordVariable'; import { useUserVariable } from './useUserVariable'; -export const useVariableOptions = ({ form, collectionField, rootCollection }: any) => { - const { operator, schema } = useValues(); - const userVariable = useUserVariable({ maxDepth: 3, schema }); - const dateVariable = useDateVariable({ operator, schema }); - const formVariabele = useFormVariable({ blockForm: form, schema, rootCollection }); - const iterationVariabele = useIterationVariable({ - blockForm: form, - currentCollection: collectionField?.collectionName, - schema, - rootCollection, +interface Props { + /** + * 消费该变量的字段 + */ + collectionField: CollectionFieldOptions; + form: Form; + /** + * `useRecord` 返回的值 + */ + record: Record; + /** + * `Filter` 组件中选中的字段的 `uiSchema`,比如设置 `数据范围` 的时候在左侧选择的字段 + */ + uiSchema?: ISchema; + /** + * `Filter` 组件中的操作符,比如设置 `数据范围` 的时候在中间选择的操作符 + */ + operator?: { value: string }; + /** + * 不需要禁用选项,一般会在表达式中使用 + */ + noDisabled?: boolean; + /** 消费变量值的字段 */ + targetFieldSchema?: Schema; +} + +export const useVariableOptions = ({ + collectionField, + form, + record, + uiSchema, + operator, + noDisabled, + targetFieldSchema, +}: Props) => { + const { name: blockCollectionName } = useBlockCollection(); + const { isInSetDefaultValueDialog } = useFlag() || {}; + + const fieldCollectionName = collectionField?.collectionName; + const userVariable = useUserVariable({ + maxDepth: 3, + uiSchema: uiSchema, + collectionField, + noDisabled, + targetFieldSchema, + }); + const dateVariable = useDateVariable({ operator, schema: uiSchema, noDisabled }); + const formVariable = useFormVariable({ + schema: uiSchema, + collectionName: blockCollectionName, + collectionField, + noDisabled, + targetFieldSchema, + }); + const iterationVariable = useIterationVariable({ + currentCollection: fieldCollectionName, + collectionField, + schema: uiSchema, + noDisabled, + targetFieldSchema, + }); + const currentRecordVariable = useRecordVariable({ + schema: uiSchema, + collectionName: blockCollectionName, + collectionField, + noDisabled, + targetFieldSchema, }); - const result = useMemo( - () => [userVariable, dateVariable, formVariabele, iterationVariabele].filter(Boolean), - [dateVariable, userVariable, formVariabele, iterationVariabele], - ); + // 保证下面的 `_.isEmpty(record)` 结果符合预期,如果存在 `__parent` 字段,需要删除 + record = { ...record }; + delete record.__parent; - if (!operator || !schema) return []; - - return result; + return useMemo(() => { + return [ + userVariable, + dateVariable, + form && !form.readPretty && formVariable, + form && + fieldCollectionName && + blockCollectionName && + fieldCollectionName !== blockCollectionName && + isInSetDefaultValueDialog && + iterationVariable, + !_.isEmpty(record) && currentRecordVariable, + ].filter(Boolean); + }, [ + userVariable, + dateVariable, + form, + formVariable, + fieldCollectionName, + blockCollectionName, + isInSetDefaultValueDialog, + iterationVariable, + record, + currentRecordVariable, + ]); }; diff --git a/packages/core/client/src/schema-settings/VariableInput/type.ts b/packages/core/client/src/schema-settings/VariableInput/type.ts index edba1ebab..77e9ad145 100644 --- a/packages/core/client/src/schema-settings/VariableInput/type.ts +++ b/packages/core/client/src/schema-settings/VariableInput/type.ts @@ -22,6 +22,7 @@ export interface FieldOption { target?: string; title?: string; schema?: Schema; + interface?: string; operators?: Operator[]; children?: FieldOption[]; } diff --git a/packages/core/client/src/schema-settings/VariableInput/utils/formatVariableScop.tsx b/packages/core/client/src/schema-settings/VariableInput/utils/formatVariableScop.tsx new file mode 100644 index 000000000..0bd5655c2 --- /dev/null +++ b/packages/core/client/src/schema-settings/VariableInput/utils/formatVariableScop.tsx @@ -0,0 +1,14 @@ +import { FormatVariableScopeParam, FormatVariableScopeReturn } from '../VariableInput'; + +export function formatVariableScop(variableScop: FormatVariableScopeParam[]): FormatVariableScopeReturn[] { + return variableScop.map((item) => { + return { + ...item, + value: item.name, + key: item.name, + label: item.title, + disabled: item.disabled, + children: item.children ? formatVariableScop(item.children) : undefined, + }; + }); +} diff --git a/packages/core/client/src/schema-settings/hooks/useIsAllowToSetDefaultValue.tsx b/packages/core/client/src/schema-settings/hooks/useIsAllowToSetDefaultValue.tsx new file mode 100644 index 000000000..ad0ca5589 --- /dev/null +++ b/packages/core/client/src/schema-settings/hooks/useIsAllowToSetDefaultValue.tsx @@ -0,0 +1,123 @@ +import { Form } from '@formily/core'; +import { Schema, useFieldSchema } from '@formily/react'; +import _ from 'lodash'; +import React, { useContext, useMemo } from 'react'; +import { CollectionFieldOptions, useCollection, useCollectionManager, useFormBlockContext, useRecord } from '../..'; +import { isPatternDisabled, isSystemField } from '../SchemaSettings'; + +interface DefaultValueProviderProps { + isAllowToSetDefaultValue: (params: IsAllowToSetDefaultValueParams) => boolean; + children: React.ReactNode; +} + +interface IsAllowToSetDefaultValueParams { + collectionField: CollectionFieldOptions; + getInterface: (name: string) => any; + form: Form; + fieldSchema: Schema; + /** + * `useRecord` 返回的值 + */ + record: Record; + isSubTableColumn?: boolean; +} + +interface Props { + form?: Form; + fieldSchema?: Schema; + collectionField?: CollectionFieldOptions; + record?: Record; +} + +const DefaultValueContext = React.createContext>(null); + +export const DefaultValueProvider = (props: DefaultValueProviderProps) => { + const value = useMemo(() => { + return { + isAllowToSetDefaultValue: props.isAllowToSetDefaultValue, + }; + }, [props.isAllowToSetDefaultValue]); + + return {props.children}; +}; + +const useIsAllowToSetDefaultValue = ({ form, fieldSchema, collectionField, record }: Props = {}) => { + const { getInterface, getCollectionJoinField } = useCollectionManager(); + const { getField } = useCollection(); + const { form: innerForm } = useFormBlockContext(); + const innerFieldSchema = useFieldSchema(); + const innerRecord = useRecord(); + const { isAllowToSetDefaultValue = _isAllowToSetDefaultValue } = useContext(DefaultValueContext) || {}; + + const innerCollectionField = + getField(innerFieldSchema['name']) || getCollectionJoinField(innerFieldSchema['x-collection-field']); + + return { + isAllowToSetDefaultValue: (isSubTableColumn?: boolean) => { + return isAllowToSetDefaultValue({ + collectionField: collectionField || innerCollectionField, + getInterface, + form: form || innerForm, + fieldSchema: fieldSchema || innerFieldSchema, + record: record || innerRecord, + isSubTableColumn, + }); + }, + }; +}; + +export default useIsAllowToSetDefaultValue; + +export const interfacesOfUnsupportedDefaultValue = [ + 'o2o', + 'oho', + 'obo', + 'o2m', + 'attachment', + 'expression', + 'point', + 'lineString', + 'circle', + 'polygon', + 'sequence', + 'formula', +]; + +function _isAllowToSetDefaultValue({ + collectionField, + getInterface, + form, + fieldSchema, + record, + isSubTableColumn, +}: IsAllowToSetDefaultValueParams) { + if (isSubTableColumn) { + return ( + !interfacesOfUnsupportedDefaultValue.includes(collectionField?.interface) && + !isSystemField(collectionField, getInterface) + ); + } + + // 当 Field component 不是下列组件时,不允许设置默认值 + if ( + collectionField.target && + fieldSchema['x-component-props']?.mode && + !['Picker', 'Select'].includes(fieldSchema['x-component-props'].mode) + ) { + return false; + } + + record = _.omit(record, '__parent'); + + // 表单编辑状态下,不允许设置默认值 + if (!_.isEmpty(record)) { + return false; + } + + return ( + !form?.readPretty && + !isPatternDisabled(fieldSchema) && + !interfacesOfUnsupportedDefaultValue.includes(collectionField?.interface) && + !isSystemField(collectionField, getInterface) + ); +} diff --git a/packages/core/client/src/schema-settings/hooks/useParseDataScopeFilter.ts b/packages/core/client/src/schema-settings/hooks/useParseDataScopeFilter.ts new file mode 100644 index 000000000..6b0ee75b2 --- /dev/null +++ b/packages/core/client/src/schema-settings/hooks/useParseDataScopeFilter.ts @@ -0,0 +1,59 @@ +import { flatten, unflatten } from '@nocobase/utils/client'; +import { useCallback } from 'react'; +import { useLocalVariables, useVariables } from '../../variables'; +import { getVariableName } from '../../variables/utils/getVariableName'; +import { isVariable } from '../../variables/utils/isVariable'; + +interface Props { + /** + * 需要排除的变量名称,例如:['$user', '$date'] + * 被排除的变量不会被解析,会按原值返回 + */ + exclude: string[]; +} + +// TODO: 建议变量名统一命名为 `$n` 开头,以防止与 formily 内置变量冲突 +const defaultExclude = ['$user', '$date', '$nDate']; + +const useParseDataScopeFilter = ({ exclude }: Props = { exclude: defaultExclude }) => { + const localVariables = useLocalVariables(); + const variables = useVariables(); + + const parseFilter = useCallback( + async (filterValue: any) => { + const flat = flatten(filterValue, { + breakOn({ key }) { + return key.startsWith('$') && key !== '$and' && key !== '$or'; + }, + transformValue(value) { + if (!isVariable(value)) { + return value; + } + if (exclude.includes(getVariableName(value))) { + return value; + } + const result = variables?.parseVariable(value, localVariables); + return result; + }, + }); + + await Promise.all( + Object.keys(flat).map(async (key) => { + flat[key] = await flat[key]; + if (flat[key] === undefined) { + delete flat[key]; + } + return flat[key]; + }), + ); + + const result = unflatten(flat); + return result; + }, + [exclude, localVariables, variables?.parseVariable], + ); + + return { parseFilter }; +}; + +export default useParseDataScopeFilter; diff --git a/packages/core/client/src/schema-settings/index.ts b/packages/core/client/src/schema-settings/index.ts index 453740fc4..83823e8b6 100644 --- a/packages/core/client/src/schema-settings/index.ts +++ b/packages/core/client/src/schema-settings/index.ts @@ -1,2 +1,5 @@ export * from './GeneralSchemaDesigner'; export * from './SchemaSettings'; +export * from './hooks/useIsAllowToSetDefaultValue'; + +export { default as useParseDataScopeFilter } from './hooks/useParseDataScopeFilter'; diff --git a/packages/core/client/src/schema-settings/types.ts b/packages/core/client/src/schema-settings/types.ts new file mode 100644 index 000000000..6a4857fe1 --- /dev/null +++ b/packages/core/client/src/schema-settings/types.ts @@ -0,0 +1,32 @@ +import { Form } from '@formily/core'; + +export interface DataScopeProps { + /** + * 点击提交时触发 + * @param data 配置的 filter 数据 + * @returns void + */ + onSubmit: (data: { filter: any }) => void; + /** + * 数据表名称,用于获取所有的字段选项 + */ + collectionName: string; + /** + * 默认的 filter 数据 + */ + defaultFilter: any; + /** + * 一个数据表中所有的字段选项 + */ + collectionFilterOption?: any[]; + /** + * 根据选中的字段类型渲染对应的组件 + * @param props + * @returns + */ + dynamicComponent?: (props: any) => React.ReactNode; + /** + * 当前表单区块中的表单实例 + */ + form: Form; +} diff --git a/packages/core/client/src/schema-templates/BlockTemplate.tsx b/packages/core/client/src/schema-templates/BlockTemplate.tsx index 6e5c320d8..d708205aa 100644 --- a/packages/core/client/src/schema-templates/BlockTemplate.tsx +++ b/packages/core/client/src/schema-templates/BlockTemplate.tsx @@ -1,6 +1,5 @@ import { observer, useField, useFieldSchema } from '@formily/react'; import React, { createContext, useContext, useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; import { RemoteSchemaComponent, useDesignable } from '..'; import { useSchemaTemplateManager } from './SchemaTemplateManagerProvider'; @@ -17,7 +16,6 @@ export const BlockTemplate = observer( const field = useField(); const fieldSchema = useFieldSchema(); const { dn } = useDesignable(); - const { t } = useTranslation(); const template = useMemo(() => getTemplateById(templateId), [templateId]); const onSuccess = (data) => { fieldSchema['x-linkage-rules'] = data?.data?.['x-linkage-rules'] || []; diff --git a/packages/core/client/src/user/CurrentUserProvider.tsx b/packages/core/client/src/user/CurrentUserProvider.tsx index b0baa6d96..de522f541 100644 --- a/packages/core/client/src/user/CurrentUserProvider.tsx +++ b/packages/core/client/src/user/CurrentUserProvider.tsx @@ -33,9 +33,11 @@ export const CurrentUserProvider = (props) => { const result = useRequest({ url: 'auth:check', }); + if (result.loading) { return render(); } + return {props.children}; }; diff --git a/packages/core/client/src/variables/README.md b/packages/core/client/src/variables/README.md new file mode 100644 index 000000000..0762c1abe --- /dev/null +++ b/packages/core/client/src/variables/README.md @@ -0,0 +1,50 @@ +# useVariables + +## Usage + +```ts +const { registerVariable, parseVariable } = useVariables(); + +// Register variable +registerVariable({ + name: '$user', + // If you are certain that the `association field` data does not need to be loaded on-demand in the variable, you can omit this field + collectionName: 'users', + ctx: { + name: 'Zhang San', + nickname: 'Xiao Zhang', + }, +}); + +// Parse variable +const userName = await parseVariable('{{ $user.name }}'); +console.log(userName); // 'Zhang San' +``` + +## Built-in Global Variables +Some variables that are used globally are registered within the `VariablesProvider`. These variables are defined in `useBuiltinVariables` and can be modified by changing the return value of `useBuiltinVariables`. + +## Local Variables +When using the `parseVariable` method in `useVariables`, in addition to parsing based on the built-in global variables, you can also use some temporary local variables. + +```ts +const { parseVariable } = useVariables(); +const localVariable = { + name: '$user', + // If you are certain that the `association field` data does not need to be loaded on-demand in the variable, you can omit this field + collectionName: 'users', + ctx: { + name: 'Zhang San', + nickname: 'Xiao Zhang', + }, +} + +// Use local variable for parsing +const userName = await parseVariable('{{ $user.name }}', localVariable); +console.log(userName); // 'Zhang San' +``` + +The registered local variables will be automatically destroyed after parsing and will not affect the value of global variables. + +### useLocalVariables +This hook encapsulates some variables that are quite common but cannot be treated as global variables. diff --git a/packages/core/client/src/variables/README.zh-CN.md b/packages/core/client/src/variables/README.zh-CN.md new file mode 100644 index 000000000..6139c45d6 --- /dev/null +++ b/packages/core/client/src/variables/README.zh-CN.md @@ -0,0 +1,50 @@ +# useVariables + +## 使用方法 + +```ts +const { registerVariable, parseVariable } = useVariables(); + +// 注册变量 +registerVariable({ + name: '$user', + // 如果确定变量中不需要按需加载 `关系字段` 数据,则可以省略该字段 + collectionName: 'users', + ctx: { + name: '张三', + nickname: '小张', + }, +}); + +// 解析变量 +const userName = await parseVariable('{{ $user.name }}'); +console.log(userName); // '张三' +``` + +## 内置全局变量 +在 `VariablesProvider` 内部注册了一些全局都会用到的变量,这些变量在 `useBuiltinVariables` 中被定义,可以通过修改 `useBuiltinVariables` 的返回值来修改内置的变量。 + +## 本地变量 +在使用 `useVariables` 中的 `parseVariable` 方法时,除了可以根据内置的全局变量进行解析之外,还可以使用一些临时的本地变量。 + +```ts +const { parseVariable } = useVariables(); +const localVariable = { + name: '$user', + // 如果确定变量中不需要按需加载 `关系字段` 数据,则可以省略该字段 + collectionName: 'users', + ctx: { + name: '张三', + nickname: '小张', + }, +} + +// 使用本地变量进行解析 +const userName = await parseVariable('{{ $user.name }}', localVariable); +console.log(userName); // '张三' +``` + +注册的本地变量在解析完之后会自动被销毁,且不会影响到全局变量的值。 + +### useLocalVariables +该 hook 封装了一些比较通用但又不能作为全局变量的变量。 diff --git a/packages/core/client/src/variables/VariablesProvider.tsx b/packages/core/client/src/variables/VariablesProvider.tsx new file mode 100644 index 000000000..c72877384 --- /dev/null +++ b/packages/core/client/src/variables/VariablesProvider.tsx @@ -0,0 +1,291 @@ +import { getValuesByPath } from '@nocobase/utils/client'; +import _ from 'lodash'; +import React, { createContext, useCallback, useEffect, useMemo, useRef } from 'react'; +import { useAPIClient } from '../api-client'; +import type { CollectionFieldOptions } from '../collection-manager'; +import { useCollectionManager } from '../collection-manager'; +import { useCompile } from '../schema-component'; +import useBuiltInVariables from './hooks/useBuiltinVariables'; +import { VariableOption, VariablesContextType } from './types'; +import { filterEmptyValues } from './utils/filterEmptyValues'; +import { getAction } from './utils/getAction'; +import { getPath } from './utils/getPath'; +import { clearRequested, getRequested, hasRequested, stashRequested } from './utils/hasRequested'; +import { REGEX_OF_VARIABLE, isVariable } from './utils/isVariable'; +import { uniq } from './utils/uniq'; + +export const VariablesContext = createContext(null); + +const variableToCollectionName = {}; + +const getFieldPath = (variablePath: string) => { + const list = variablePath.split('.'); + const result = list.map((item) => { + if (variableToCollectionName[item]) { + return variableToCollectionName[item]; + } + return item; + }); + return result.join('.'); +}; + +const VariablesProvider = ({ children }) => { + const ctxRef = useRef>({}); + const api = useAPIClient(); + const { getCollectionJoinField } = useCollectionManager(); + const compile = useCompile(); + const { builtinVariables } = useBuiltInVariables(); + + const setCtx = useCallback((ctx: Record | ((prev: Record) => Record)) => { + if (_.isFunction(ctx)) { + ctxRef.current = ctx(ctxRef.current); + } else { + ctxRef.current = ctx; + } + }, []); + + /** + * 1. 从 `ctx` 中根据 `path` 取值 + * 2. 如果某个 `key` 不存在,且 `key` 是一个关联字段,则从 api 中获取数据,并缓存到 `ctx` 中 + * 3. 如果某个 `key` 不存在,且 `key` 不是一个关联字段,则返回当前值 + */ + const getValue = useCallback( + async (variablePath: string) => { + const list = variablePath.split('.'); + const variableName = list[0]; + let current = ctxRef.current; + let collectionName = getFieldPath(variableName); + + if (process.env.NODE_ENV !== 'production' && !ctxRef.current[variableName]) { + throw new Error(`VariablesProvider: ${variableName} is not found`); + } + + for (let index = 0; index < list.length; index++) { + if (current == null) { + return current; + } + + const key = list[index]; + const associationField: CollectionFieldOptions = getCollectionJoinField( + getFieldPath(list.slice(0, index + 1).join('.')), + ); + if (Array.isArray(current)) { + const result = current.map((item) => { + if (shouldToRequest(item[key]) && item.id != null) { + if (associationField?.target) { + const url = `/${collectionName}/${item.id}/${key}:${getAction(associationField.type)}`; + if (hasRequested(url)) { + return getRequested(url); + } + const result = api + .request({ + url, + }) + .then((data) => { + clearRequested(url); + item[key] = data.data.data; + return item[key]; + }); + stashRequested(url, result); + return result; + } + } + return item[key]; + }); + current = _.flatten(await Promise.all(result)); + } else if (shouldToRequest(current[key]) && current.id != null && associationField?.target) { + const url = `/${collectionName}/${current.id}/${key}:${getAction(associationField.type)}`; + let data = null; + if (hasRequested(url)) { + data = await getRequested(url); + } else { + const waitForData = api.request({ + url, + }); + stashRequested(url, waitForData); + data = await waitForData; + clearRequested(url); + } + current[key] = data.data.data; + current = getValuesByPath(current, key); + } else { + current = getValuesByPath(current, key); + } + + if (associationField?.target) { + collectionName = associationField.target; + } + } + + return compile(_.isFunction(current) ? current() : current); + }, + [getCollectionJoinField], + ); + + /** + * 注册一个全局变量 + */ + const registerVariable = useCallback( + (variableOption: VariableOption) => { + if (process.env.NODE_ENV !== 'production' && !isVariable(`{{${variableOption.name}}}`)) { + throw new Error(`VariablesProvider: ${variableOption.name} is not a valid name`); + } + + setCtx((prev) => { + return { + ...prev, + [variableOption.name]: variableOption.ctx, + }; + }); + if (variableOption.collectionName) { + variableToCollectionName[variableOption.name] = variableOption.collectionName; + } + }, + [setCtx], + ); + + const getVariable = useCallback((variableName: string): VariableOption => { + if (!ctxRef.current[variableName]) { + return null; + } + + return { + name: variableName, + ctx: ctxRef.current[variableName], + collectionName: variableToCollectionName[variableName], + }; + }, []); + + const removeVariable = useCallback((variableName: string) => { + setCtx((prev) => { + const next = { ...prev }; + delete next[variableName]; + return next; + }); + delete variableToCollectionName[variableName]; + }, []); + + const onLocalVariablesReady = useCallback( + async (localVariables: VariableOption | VariableOption[], handler: () => Promise) => { + let old = null; + if (localVariables) { + if (Array.isArray(localVariables)) { + old = localVariables.map((item) => getVariable(item.name)); + localVariables.forEach((item) => registerVariable(item)); + } else { + // 1. 如果有局部变量,先把全局中同名的变量取出来 + old = getVariable(localVariables.name); + // 2. 把局部变量注册到全局,这样就可以使用了 + registerVariable(localVariables); + } + } + + await handler(); + + // 3. 局部变量使用完成后,需要在全局中清除 + if (localVariables) { + if (Array.isArray(localVariables)) { + localVariables.forEach((item) => removeVariable(item.name)); + } else { + removeVariable(localVariables.name); + } + } + // 4. 如果有同名的全局变量,把它重新注册回去 + if (old) { + if (Array.isArray(old)) { + old.filter(Boolean).forEach((item) => registerVariable(item)); + } else { + registerVariable(old); + } + } + }, + [getVariable, registerVariable, removeVariable], + ); + + const parseVariable = useCallback( + /** + * 将变量字符串解析为真正的值 + * @param str 变量字符串 + * @param localVariables 局部变量,解析完成后会被清除 + * @returns + */ + async (str: string, localVariables?: VariableOption | VariableOption[]) => { + if (!isVariable(str)) { + return str; + } + + let value = null; + await onLocalVariablesReady(localVariables, async () => { + const path = getPath(str); + value = await getValue(path); + }); + + return uniq(filterEmptyValues(value)); + }, + [getValue, onLocalVariablesReady], + ); + + const getCollectionField = useCallback( + async (variableString: string, localVariables?: VariableOption | VariableOption[]) => { + if (process.env.NODE_ENV !== 'production' && !isVariable(variableString)) { + throw new Error(`VariablesProvider: ${variableString} is not a variable string`); + } + + let result = null; + + await onLocalVariablesReady(localVariables, async () => { + const matches = variableString.match(REGEX_OF_VARIABLE); + const path = matches[0].replace(REGEX_OF_VARIABLE, '$1'); + + result = getCollectionJoinField(getFieldPath(path)); + + // 当仅有一个例如 `$user` 这样的字符串时,需要拼一个假的 `collectionField` 返回 + if (!result && !path.includes('.')) { + result = { + target: variableToCollectionName[path], + }; + } + }); + + return result; + }, + [getCollectionJoinField, onLocalVariablesReady], + ); + + useEffect(() => { + builtinVariables.forEach((variableOption) => { + registerVariable(variableOption); + }); + }, [builtinVariables, registerVariable]); + + const value = useMemo( + () => + ({ + ctxRef, + setCtx, + parseVariable, + registerVariable, + getVariable, + getCollectionField, + removeVariable, + }) as VariablesContextType, + [getCollectionField, getVariable, parseVariable, registerVariable, removeVariable, setCtx], + ); + + return {children}; +}; + +VariablesProvider.displayName = 'VariablesProvider'; + +export default VariablesProvider; + +/** + * 判断是否应该请求关系字段的数据。如果是 null 则可以确定后端的数据为空,此时不需要请求;如果是 undefined 则需要请求。 + * + * 注意:如果后端接口的这一 “规则” 发生了变更,那么可能会出现问题。 + * @param value + * @returns + */ +function shouldToRequest(value) { + return value === undefined; +} diff --git a/packages/core/client/src/variables/__tests__/useVariables.test.tsx b/packages/core/client/src/variables/__tests__/useVariables.test.tsx new file mode 100644 index 000000000..477b38ea8 --- /dev/null +++ b/packages/core/client/src/variables/__tests__/useVariables.test.tsx @@ -0,0 +1,597 @@ +import { SchemaExpressionScopeContext, SchemaOptionsContext } from '@formily/react'; +import React from 'react'; +import { act, renderHook, waitFor } from 'testUtils'; +import { APIClientProvider } from '../../api-client'; +import { mockAPIClient } from '../../testUtils'; +import { CurrentUserProvider } from '../../user'; +import VariablesProvider from '../VariablesProvider'; +import useVariables from '../hooks/useVariables'; + +vi.mock('../../collection-manager', async () => { + return { + useCollectionManager: () => { + return { + getCollectionJoinField: (path: string) => { + if (path === 'users.belongsToField') { + return { + type: 'belongsTo', + target: 'test', + }; + } + if (path === 'some.belongsToField') { + return { + type: 'belongsTo', + target: 'someBelongsToField', + }; + } + if (path === 'some.belongsToField.belongsToField') { + return { + type: 'belongsTo', + target: 'someBelongsToField', + }; + } + if (path === 'users.hasManyField') { + return { + type: 'hasMany', + target: 'test', + }; + } + if (path === 'users.hasManyField.hasManyField') { + return { + type: 'hasMany', + target: 'test', + }; + } + if (path === 'local.belongsToField') { + return { + type: 'belongsTo', + target: 'test', + }; + } + }, + }; + }, + CollectionManagerPane: null, + }; +}); + +const { apiClient, mockRequest } = mockAPIClient(); +mockRequest.onGet('/auth:check').reply(() => { + return [ + 200, + { + data: { + id: 0, + nickname: 'from request', + }, + }, + ]; +}); +mockRequest.onGet('/users/0/belongsToField:get').reply(() => { + return [ + 200, + { + data: { + id: 0, + name: '$user.belongsToField', + }, + }, + ]; +}); +mockRequest.onGet('/users/0/hasManyField:list?pageSize=9999').reply(() => { + return [ + 200, + { + data: [ + { + id: 0, + name: '$user.hasManyField', + }, + ], + }, + ]; +}); +mockRequest.onGet('/test/0/hasManyField:list?pageSize=9999').reply(() => { + return [ + 200, + { + data: [ + { + id: 0, + name: '$user.hasManyField.hasManyField', + }, + ], + }, + ]; +}); + +mockRequest.onGet('/someBelongsToField/0/belongsToField:get').reply(() => { + return [ + 200, + { + data: { + id: 0, + name: '$some.belongsToField.belongsToField', + }, + }, + ]; +}); + +const Providers = ({ children }) => { + return ( + + + + + {children} + + + + + ); +}; + +describe('useVariables', () => { + it('parsing variables with custom ctx', async () => { + const { result } = renderHook(() => useVariables(), { + wrapper: Providers, + }); + + await waitFor(async () => { + expect(result.current.ctxRef.current).toMatchInlineSnapshot(` + { + "$nDate": { + "last30Days": [Function], + "last7Days": [Function], + "last90Days": [Function], + "lastIsoWeek": [Function], + "lastMonth": [Function], + "lastQuarter": [Function], + "lastWeek": [Function], + "lastYear": [Function], + "next30Days": [Function], + "next7Days": [Function], + "next90Days": [Function], + "nextIsoWeek": [Function], + "nextMonth": [Function], + "nextQuarter": [Function], + "nextWeek": [Function], + "nextYear": [Function], + "now": [Function], + "thisIsoWeek": [Function], + "thisMonth": [Function], + "thisQuarter": [Function], + "thisWeek": [Function], + "thisYear": [Function], + "today": [Function], + "tomorrow": [Function], + "yesterday": [Function], + }, + "$system": { + "now": [Function], + }, + "$user": { + "id": 0, + "nickname": "from request", + }, + "currentTime": [Function], + "currentUser": { + "id": 0, + "nickname": "from request", + }, + } + `); + }); + + act(() => { + result.current.setCtx({ + $user: { + nickname: 'test', + }, + }); + }); + + await waitFor(async () => { + expect(await result.current.parseVariable('{{ $user.nickname }}')).toBe('test'); + }); + }); + + it('parsing variables with current user data', async () => { + const { result } = renderHook(() => useVariables(), { + wrapper: Providers, + }); + + await waitFor(async () => { + expect(await result.current.parseVariable('{{ $user.nickname }}')).toBe('from request'); + }); + }); + + it('parsing variables with lazy loading of data', async () => { + const { result } = renderHook(() => useVariables(), { + wrapper: Providers, + }); + + await waitFor(async () => { + expect(await result.current.parseVariable('{{ $user.belongsToField }}')).toEqual({ + id: 0, + name: '$user.belongsToField', + }); + }); + }); + + it('long variable path', async () => { + const { result } = renderHook(() => useVariables(), { + wrapper: Providers, + }); + + await waitFor(async () => { + expect(await result.current.parseVariable('{{ $user.belongsToField.name }}')).toBe('$user.belongsToField'); + }); + }); + + it('should return array when variable path is a hasMany field', async () => { + const { result } = renderHook(() => useVariables(), { + wrapper: Providers, + }); + + await waitFor(async () => { + expect(await result.current.parseVariable('{{ $user.hasManyField }}')).toEqual([ + { + id: 0, + name: '$user.hasManyField', + }, + ]); + }); + + await waitFor(async () => { + expect(await result.current.parseVariable('{{ $user.hasManyField.name }}')).toEqual(['$user.hasManyField']); + }); + }); + + it('$user.hasManyField.hasManyField', async () => { + const { result } = renderHook(() => useVariables(), { + wrapper: Providers, + }); + + await waitFor(async () => { + expect(await result.current.parseVariable('{{ $user.hasManyField.hasManyField }}')).toEqual([ + { + id: 0, + name: '$user.hasManyField.hasManyField', + }, + ]); + }); + }); + + it('$user.hasManyField.hasManyField.name', async () => { + const { result } = renderHook(() => useVariables(), { + wrapper: Providers, + }); + + await waitFor(async () => { + expect(await result.current.parseVariable('{{ $user.hasManyField.hasManyField.name }}')).toEqual([ + '$user.hasManyField.hasManyField', + ]); + }); + }); + + it('should throw error when variable is not found', async () => { + const { result } = renderHook(() => useVariables(), { + wrapper: Providers, + }); + + await waitFor(async () => { + try { + await result.current.parseVariable('{{ $some.name }}'); + // 如果走到这里则说明没有抛出错误,但是这里期望抛出错误,所以这里故意写一个错误的断言 + expect(true).toBe(false); + } catch (error) { + expect(error.message).toBe('VariablesProvider: $some is not found'); + } + }); + }); + + it('should not error when changing the variable path', async () => { + const { result } = renderHook(() => useVariables(), { + wrapper: Providers, + }); + + await waitFor(async () => { + expect(await result.current.parseVariable('{{ $user.hasManyField }}')).toEqual([ + { + id: 0, + name: '$user.hasManyField', + }, + ]); + }); + + await waitFor(async () => { + expect(await result.current.parseVariable('{{ $user.hasManyField.hasManyField }}')).toEqual([ + { + id: 0, + name: '$user.hasManyField.hasManyField', + }, + ]); + }); + }); + + it('register variable', async () => { + const { result } = renderHook(() => useVariables(), { + wrapper: Providers, + }); + + await waitFor(async () => { + expect(result.current.ctxRef.current).toMatchInlineSnapshot(` + { + "$nDate": { + "last30Days": [Function], + "last7Days": [Function], + "last90Days": [Function], + "lastIsoWeek": [Function], + "lastMonth": [Function], + "lastQuarter": [Function], + "lastWeek": [Function], + "lastYear": [Function], + "next30Days": [Function], + "next7Days": [Function], + "next90Days": [Function], + "nextIsoWeek": [Function], + "nextMonth": [Function], + "nextQuarter": [Function], + "nextWeek": [Function], + "nextYear": [Function], + "now": [Function], + "thisIsoWeek": [Function], + "thisMonth": [Function], + "thisQuarter": [Function], + "thisWeek": [Function], + "thisYear": [Function], + "today": [Function], + "tomorrow": [Function], + "yesterday": [Function], + }, + "$system": { + "now": [Function], + }, + "$user": { + "id": 0, + "nickname": "from request", + }, + "currentTime": [Function], + "currentUser": { + "id": 0, + "nickname": "from request", + }, + } + `); + }); + + act(() => { + result.current.registerVariable({ + name: '$new', + collectionName: 'new', + ctx: { + name: 'new variable', + }, + }); + }); + + await waitFor(async () => { + expect(result.current.ctxRef.current).toMatchInlineSnapshot(` + { + "$nDate": { + "last30Days": [Function], + "last7Days": [Function], + "last90Days": [Function], + "lastIsoWeek": [Function], + "lastMonth": [Function], + "lastQuarter": [Function], + "lastWeek": [Function], + "lastYear": [Function], + "next30Days": [Function], + "next7Days": [Function], + "next90Days": [Function], + "nextIsoWeek": [Function], + "nextMonth": [Function], + "nextQuarter": [Function], + "nextWeek": [Function], + "nextYear": [Function], + "now": [Function], + "thisIsoWeek": [Function], + "thisMonth": [Function], + "thisQuarter": [Function], + "thisWeek": [Function], + "thisYear": [Function], + "today": [Function], + "tomorrow": [Function], + "yesterday": [Function], + }, + "$new": { + "name": "new variable", + }, + "$system": { + "now": [Function], + }, + "$user": { + "id": 0, + "nickname": "from request", + }, + "currentTime": [Function], + "currentUser": { + "id": 0, + "nickname": "from request", + }, + } + `); + }); + + await waitFor(async () => { + expect(result.current.getVariable('$new')).toEqual({ + name: '$new', + collectionName: 'new', + ctx: { + name: 'new variable', + }, + }); + }); + + await waitFor(async () => { + expect(await result.current.parseVariable('{{ $new.name }}')).toBe('new variable'); + }); + }); + + it('$new.noExist', async () => { + const { result } = renderHook(() => useVariables(), { + wrapper: Providers, + }); + + await waitFor(async () => { + result.current.registerVariable({ + name: '$new', + ctx: { + name: 'new variable', + }, + }); + }); + + await waitFor(async () => { + expect(await result.current.parseVariable('{{ $new.noExist }}')).toBe(undefined); + }); + }); + + it('use object as local variable', async () => { + const { result } = renderHook(() => useVariables(), { + wrapper: Providers, + }); + + await waitFor(async () => { + expect(result.current.getVariable('$local')).toBe(null); + }); + + expect( + await result.current.parseVariable('{{ $local.name }}', { + name: '$local', + ctx: { + name: 'local variable', + }, + }), + ).toBe('local variable'); + + // 由于 $local 是一个局部变量,所以不会被缓存到 ctx 中 + expect(result.current.getVariable('$local')).toBe(null); + }); + + it('use array as local variables', async () => { + const { result } = renderHook(() => useVariables(), { + wrapper: Providers, + }); + + await waitFor(async () => { + expect(result.current.getVariable('$local')).toBe(null); + }); + + expect( + await result.current.parseVariable('{{ $local.name }}', [ + { + name: '$local', + ctx: { + name: 'local variable', + }, + }, + ]), + ).toBe('local variable'); + + // 由于 $local 是一个局部变量,所以不会被缓存到 ctx 中 + expect(result.current.getVariable('$local')).toBe(null); + }); + + it('no id', async () => { + const { result } = renderHook(() => useVariables(), { + wrapper: Providers, + }); + + await waitFor(async () => { + result.current.registerVariable({ + name: '$some', + ctx: { + name: 'new variable', + belongsToField: { + id: 0, + }, + }, + collectionName: 'some', + }); + }); + + await waitFor(async () => { + expect(await result.current.parseVariable('{{ $some.belongsToField.belongsToField }}')).toEqual({ + id: 0, + name: '$some.belongsToField.belongsToField', + }); + }); + + // 会覆盖之前的 $some + result.current.registerVariable({ + name: '$some', + ctx: { + name: 'new variable', + belongsToField: null, + }, + collectionName: 'some', + }); + + await waitFor(async () => { + // 因为 $some 的 ctx 没有 id 所以无法获取关系字段的数据 + expect(await result.current.parseVariable('{{ $some.belongsToField.belongsToField }}')).toBe(undefined); + }); + }); + + it('getCollectionField', async () => { + const { result } = renderHook(() => useVariables(), { + wrapper: Providers, + }); + + await waitFor(async () => { + expect(await result.current.getCollectionField('{{ $user.belongsToField }}')).toEqual({ + type: 'belongsTo', + target: 'test', + }); + }); + }); + + it('getCollectionField with no exist variable', async () => { + const { result } = renderHook(() => useVariables(), { + wrapper: Providers, + }); + + await waitFor(async () => { + expect(await result.current.getCollectionField('{{ $noExist.belongsToField }}')).toEqual(undefined); + }); + }); + + it('getCollectionField with local variables', async () => { + const { result } = renderHook(() => useVariables(), { + wrapper: Providers, + }); + + const localVariables = [ + { + name: '$local', + ctx: { + name: 'local variable', + }, + collectionName: 'local', + }, + ]; + + await waitFor(async () => { + expect(await result.current.getCollectionField('{{ $local.belongsToField }}', localVariables)) + .toMatchInlineSnapshot(` + { + "target": "test", + "type": "belongsTo", + } + `); + }); + }); +}); diff --git a/packages/core/client/src/variables/__tests__/utils/filterEmptyValues.test.ts b/packages/core/client/src/variables/__tests__/utils/filterEmptyValues.test.ts new file mode 100644 index 000000000..5bec7b007 --- /dev/null +++ b/packages/core/client/src/variables/__tests__/utils/filterEmptyValues.test.ts @@ -0,0 +1,21 @@ +import { filterEmptyValues } from '../../utils/filterEmptyValues'; + +describe('filterEmptyValues', () => { + test('should remove empty values from an array', () => { + const value = [null, 0, '', false, undefined, 42]; + const result = filterEmptyValues(value); + expect(result).toEqual([0, false, 42]); + }); + + test('should return the value unchanged if it is not an array', () => { + const value = 'hello world'; + const result = filterEmptyValues(value); + expect(result).toEqual('hello world'); + }); + + test('should remove empty values from an array containing various types', () => { + const value = [1, 'apple', false, 0, 3.14, '', null, 'orange', undefined]; + const result = filterEmptyValues(value); + expect(result).toEqual([1, 'apple', false, 0, 3.14, 'orange']); + }); +}); diff --git a/packages/core/client/src/variables/__tests__/utils/transformVariableValue.test.ts b/packages/core/client/src/variables/__tests__/utils/transformVariableValue.test.ts new file mode 100644 index 000000000..5823702b1 --- /dev/null +++ b/packages/core/client/src/variables/__tests__/utils/transformVariableValue.test.ts @@ -0,0 +1,106 @@ +import { transformVariableValue } from '../../utils/transformVariableValue'; + +describe('transformVariableValue', () => { + test('should return value as it is when targetCollectionFiled.type is "belongsTo" and value is not an array', () => { + const value = { + name: 'test', + }; + const deps = { + targetCollectionField: { type: 'belongsTo', uiSchema: { 'x-component': 'Text' } }, + }; + + const result = transformVariableValue(value, deps as any); + + expect(result).toBe(value); + }); + + test('should return value as it is when targetCollectionFiled.type is "hasOne" and value is not an array', () => { + const value = { + name: 'test', + }; + const deps = { + targetCollectionField: { type: 'hasOne', uiSchema: { 'x-component': 'Text' } }, + }; + + const result = transformVariableValue(value, deps as any); + + expect(result).toBe(value); + }); + + test('should return an array containing the value when targetCollectionFiled.type is "hasMany" and value is not an array', () => { + const value = { + name: 'test', + }; + const deps = { + targetCollectionField: { type: 'hasMany', uiSchema: { 'x-component': 'Text' } }, + }; + + const result = transformVariableValue(value, deps as any); + + expect(result).toEqual([value]); + }); + + test('should return value as it is when targetCollectionFiled.type is "hasMany" and value is an array', () => { + const value = [ + { + name: 'test1', + }, + { + name: 'test2', + }, + ]; + const deps = { + targetCollectionField: { type: 'hasMany', uiSchema: { 'x-component': 'Text' } }, + }; + + const result = transformVariableValue(value, deps as any); + + expect(result).toBe(value); + }); + + test('should return value as it is when targetCollectionFiled.type is "belongsToMany" and value is not an array', () => { + const value = { + name: 'test', + }; + const deps = { + targetCollectionField: { type: 'belongsToMany', uiSchema: { 'x-component': 'Text' } }, + }; + + const result = transformVariableValue(value, deps as any); + + expect(result).toEqual([value]); + }); + + test('should return an array containing the value when targetCollectionFiled.type is "belongsToMany" and value is an array', () => { + const value = [ + { + name: 'test1', + }, + { + name: 'test2', + }, + ]; + const deps = { + targetCollectionField: { type: 'belongsToMany', uiSchema: { 'x-component': 'Text' } }, + }; + + const result = transformVariableValue(value, deps as any); + + expect(result).toEqual(value); + }); + + test('json', () => { + const value = { a: 1 }; + const deps = { + targetCollectionField: { type: 'string', interface: 'json', uiSchema: { 'x-component': 'Text' } }, + }; + + const result = transformVariableValue(value, deps as any); + + expect(result).toMatchInlineSnapshot(` + { + "a": 1, + } + `); + }); +}); diff --git a/packages/core/client/src/variables/__tests__/utils/uniq.test.ts b/packages/core/client/src/variables/__tests__/utils/uniq.test.ts new file mode 100644 index 000000000..19473da2b --- /dev/null +++ b/packages/core/client/src/variables/__tests__/utils/uniq.test.ts @@ -0,0 +1,41 @@ +import { uniq } from '../../utils/uniq'; + +describe('uniq', () => { + test('uniq method with non-array value', () => { + const value = 'Hello'; + const result = uniq(value); + expect(result).toBe(value); + }); + + test('uniq method with array elements as strings', () => { + const value = ['apple', 'banana', 'apple', 'orange']; + const result = uniq(value); + expect(result).toEqual(['apple', 'banana', 'apple', 'orange']); + }); + + test('uniq method with array elements as objects with an id property', () => { + const value = [ + { id: 1, name: 'John' }, + { id: 2, name: 'Jane' }, + { id: 1, name: 'John Doe' }, + ]; + const result = uniq(value); + expect(result).toEqual([ + { id: 1, name: 'John' }, + { id: 2, name: 'Jane' }, + ]); + }); + + test('uniq method with array elements as objects without an id property', () => { + const value = [ + { id: 1, name: 'John' }, + { id: 1, name: 'John' }, + { id: 2, name: 'John Doe' }, + ]; + const result = uniq(value); + expect(result).toEqual([ + { id: 1, name: 'John' }, + { id: 2, name: 'John Doe' }, + ]); + }); +}); diff --git a/packages/core/client/src/variables/constants.ts b/packages/core/client/src/variables/constants.ts new file mode 100644 index 000000000..efca9325c --- /dev/null +++ b/packages/core/client/src/variables/constants.ts @@ -0,0 +1 @@ +export const DEBOUNCE_WAIT = 500; diff --git a/packages/core/client/src/variables/hooks/useBuiltinVariables.ts b/packages/core/client/src/variables/hooks/useBuiltinVariables.ts new file mode 100644 index 000000000..18a4b3ebb --- /dev/null +++ b/packages/core/client/src/variables/hooks/useBuiltinVariables.ts @@ -0,0 +1,58 @@ +import { dayjs } from '@nocobase/utils/client'; +import { useMemo } from 'react'; +import { getDateRanges } from '../../schema-component/antd/date-picker/util'; +import { useCurrentUserContext } from '../../user'; +import { VariableOption } from '../types'; + +const useBuiltInVariables = () => { + const data = useCurrentUserContext(); + + const currentUser = data?.data?.data; + const dateVars = getDateRanges(); + // 使用函数方便测试断言 + dateVars.now = (() => dayjs().toISOString()) as any; + const builtinVariables: VariableOption[] = useMemo(() => { + return [ + { + name: '$user', + ctx: currentUser, + collectionName: 'users', + }, + /** + * @deprecated + * 兼容老版本 + */ + { + name: 'currentUser', + ctx: currentUser, + collectionName: 'users', + }, + { + name: '$nDate', + ctx: dateVars, + }, + /** + * @deprecated + * 兼容旧版本的 `$system` 变量,新版本已弃用 + */ + { + name: '$system', + ctx: { + now: () => dayjs().toISOString(), + }, + }, + /** + * @deprecated + * 旧版本的一个变量,新版用 `$nDate` 代替 + */ + { + name: 'currentTime', + ctx: () => dayjs().toISOString(), + }, + ]; + }, [currentUser]); + + return { builtinVariables }; +}; + +export default useBuiltInVariables; diff --git a/packages/core/client/src/variables/hooks/useContextVariable.ts b/packages/core/client/src/variables/hooks/useContextVariable.ts new file mode 100644 index 000000000..a268c0b99 --- /dev/null +++ b/packages/core/client/src/variables/hooks/useContextVariable.ts @@ -0,0 +1,21 @@ +import { useMemo } from 'react'; +import { useTableBlockContext } from '../../block-provider'; +import { VariableOption } from '../types'; + +const useContextVariable = (): VariableOption => { + const { field, service, rowKey, collection: collectionName } = useTableBlockContext(); + const contextData = useMemo( + () => service?.data?.data?.filter((v) => (field?.data?.selectedRowKeys || [])?.includes(v[rowKey])), + [field?.data?.selectedRowKeys, rowKey, service?.data?.data], + ); + + return useMemo(() => { + return { + name: '$context', + ctx: contextData, + collectionName, + }; + }, [collectionName, contextData]); +}; + +export default useContextVariable; diff --git a/packages/core/client/src/variables/hooks/useLocalVariables.tsx b/packages/core/client/src/variables/hooks/useLocalVariables.tsx new file mode 100644 index 000000000..b3b6c7b88 --- /dev/null +++ b/packages/core/client/src/variables/hooks/useLocalVariables.tsx @@ -0,0 +1,97 @@ +import { Form } from '@formily/core'; +import _ from 'lodash'; +import React, { useContext, useMemo } from 'react'; +import { useFormBlockContext } from '../../block-provider'; +import { useCollection } from '../../collection-manager'; +import { useRecord } from '../../record-provider'; +import { useBlockCollection } from '../../schema-settings/VariableInput/hooks/useBlockCollection'; +import { VariableOption } from '../types'; + +interface Props { + collectionName?: string; + currentRecord?: Record; + currentForm?: Form; +} + +const LocalVariablesContext = React.createContext<{ iterationCtx: Record }>(null); + +export const LocalVariablesProvider = ({ + children, + iterationCtx, +}: { + iterationCtx: Record; + children: React.ReactNode; +}) => { + return {children}; +}; + +const useLocalVariables = (props?: Props) => { + const { name: currentCollectionName } = useCollection(); + const { iterationCtx } = useContext(LocalVariablesContext) || {}; + let { name } = useBlockCollection(); + let currentRecord = useRecord(); + let { form } = useFormBlockContext(); + + if (props?.currentForm) { + form = props.currentForm; + } + if (props?.currentRecord) { + currentRecord = props.currentRecord; + } + if (props?.collectionName) { + name = props.collectionName; + } + + let blockRecord = currentRecord; + // 获取到最顶层的 record,即当前区块所在的 record + while (!_.isEmpty(blockRecord.__parent)) { + blockRecord = blockRecord.__parent; + } + + return useMemo(() => { + return ( + [ + /** + * @deprecated + * 兼容老版本 + */ + { + name: 'currentRecord', + ctx: blockRecord, + collectionName: name, + }, + /** + * @deprecated + * 兼容旧版本的以数据表名称命名的变量,新版本已更名为 `$nForm` + */ + { + name, + ctx: form?.values || blockRecord, + collectionName: name, + }, + /** + * @deprecated + * 新版本已更名为 `$nForm` + */ + { + name: '$form', + ctx: form?.values, + collectionName: name, + }, + { + name: '$nRecord', + ctx: blockRecord, + collectionName: name, + }, + { + name: '$nForm', + ctx: form?.values, + collectionName: name, + }, + iterationCtx && { name: '$iteration', ctx: iterationCtx, collectionName: currentCollectionName }, + ] as VariableOption[] + ).filter(Boolean); + }, [blockRecord, name, form?.values, iterationCtx, currentCollectionName]); // 尽量保持返回的值不变,这样可以减少接口的请求次数,因为关系字段会缓存到变量的 ctx 中 +}; + +export default useLocalVariables; diff --git a/packages/core/client/src/variables/hooks/useVariables.ts b/packages/core/client/src/variables/hooks/useVariables.ts new file mode 100644 index 000000000..a9b30c2fd --- /dev/null +++ b/packages/core/client/src/variables/hooks/useVariables.ts @@ -0,0 +1,8 @@ +import { useContext } from 'react'; +import { VariablesContext } from '../VariablesProvider'; + +const useVariables = () => { + return useContext(VariablesContext); +}; + +export default useVariables; diff --git a/packages/core/client/src/variables/index.ts b/packages/core/client/src/variables/index.ts new file mode 100644 index 000000000..dc02b4613 --- /dev/null +++ b/packages/core/client/src/variables/index.ts @@ -0,0 +1,7 @@ +export { default as VariablesProvider } from './VariablesProvider'; +export { default as useBuiltinVariables } from './hooks/useBuiltinVariables'; +export { default as useContextVariable } from './hooks/useContextVariable'; +export { default as useLocalVariables } from './hooks/useLocalVariables'; +export { default as useVariables } from './hooks/useVariables'; + +export * from './constants'; diff --git a/packages/core/client/src/variables/types.ts b/packages/core/client/src/variables/types.ts new file mode 100644 index 000000000..30f390603 --- /dev/null +++ b/packages/core/client/src/variables/types.ts @@ -0,0 +1,73 @@ +import type { Dispatch, SetStateAction } from 'react'; +import { CollectionFieldOptions } from '../collection-manager'; + +export interface VariablesContextType { + /** + * 解析变量所需的上下文数据 + * + * ```ts + * const ctx = { + * $user: { + * id: 1, + * name: 'test', + * } + * } + * ``` + */ + ctxRef: React.MutableRefObject>; + /** + * 更新上下文 + */ + setCtx: Dispatch>>; + /** + * 解析变量 + * @param str 一个变量字符串,例如:`{{ $user.name }}` + * @returns 变量解析后的值 + * + * ```ts + * const value = await parseVariable('{{ $user.name }}'); + * console.log(value); // test + * ``` + */ + parseVariable: (str: string, localVariable?: VariableOption | VariableOption[]) => Promise; + /** + * 注册变量 + * @param variableOption 新变量的配置 + * @returns void + * + * ```ts + * registerVariable({ + * name: '$user', + * collectionName: 'users', + * ctx: { + * id: 1, + * name: 'test', + * }, + * }); + * ``` + */ + registerVariable: (variableOption: VariableOption) => void; + /** + * 获取变量的配置 + * @param variableName 变量的名称,例如:`$user` + * @returns 变量的配置 + */ + getVariable: (variableName: string) => VariableOption; + getCollectionField: ( + variableString: string, + localVariables?: VariableOption | VariableOption[], + ) => Promise; + removeVariable: (variableName: string) => void; +} + +export interface VariableOption { + /** 变量的表示,例如:`$user` */ + name: string; + /** 变量的值 */ + ctx: { + id?: number | string; + [key: string]: any; + }; + /** 变量所对应的数据表的名称 */ + collectionName?: string; +} diff --git a/packages/core/client/src/variables/utils/filterEmptyValues.ts b/packages/core/client/src/variables/utils/filterEmptyValues.ts new file mode 100644 index 000000000..54455cc25 --- /dev/null +++ b/packages/core/client/src/variables/utils/filterEmptyValues.ts @@ -0,0 +1,9 @@ +export const filterEmptyValues = (value: any) => { + if (!Array.isArray(value)) { + return value; + } + + return value.filter((item) => { + return item === 0 || item === false || item; + }); +}; diff --git a/packages/core/client/src/variables/utils/getAction.tsx b/packages/core/client/src/variables/utils/getAction.tsx new file mode 100644 index 000000000..dfbdb6556 --- /dev/null +++ b/packages/core/client/src/variables/utils/getAction.tsx @@ -0,0 +1,13 @@ +const TYPE_TO_ACTION = { + hasMany: 'list?pageSize=9999', + belongsTo: 'get', + hasOne: 'get', + belongsToMany: 'list?pageSize=9999', +}; +export const getAction = (type: string) => { + if (process.env.NODE_ENV !== 'production' && !(type in TYPE_TO_ACTION)) { + throw new Error(`VariablesProvider: unknown type: ${type}`); + } + + return TYPE_TO_ACTION[type]; +}; diff --git a/packages/core/client/src/variables/utils/getPath.tsx b/packages/core/client/src/variables/utils/getPath.tsx new file mode 100644 index 000000000..adba144cc --- /dev/null +++ b/packages/core/client/src/variables/utils/getPath.tsx @@ -0,0 +1,16 @@ +import { REGEX_OF_VARIABLE } from './isVariable'; + +/** + * `{{ $user.name }}` => `$user.name` + * @param variableString + * @returns + */ + +export const getPath = (variableString: string) => { + if (!variableString) { + return variableString; + } + + const matches = variableString.match(REGEX_OF_VARIABLE); + return matches[0].replace(REGEX_OF_VARIABLE, '$1'); +}; diff --git a/packages/core/client/src/variables/utils/getVariableName.tsx b/packages/core/client/src/variables/utils/getVariableName.tsx new file mode 100644 index 000000000..6c9562f2e --- /dev/null +++ b/packages/core/client/src/variables/utils/getVariableName.tsx @@ -0,0 +1,20 @@ +import _ from 'lodash'; +import { getPath } from './getPath'; + +/** + * `{{ $user.name }}` => `$user` + * @param variableString + * @returns + */ + +export const getVariableName = (variableString: string) => { + if (!_.isString(variableString)) { + return variableString; + } + + const variablePath = getPath(variableString); + const list = variablePath.split('.'); + const variableName = list[0]; + + return variableName; +}; diff --git a/packages/core/client/src/variables/utils/hasRequested.tsx b/packages/core/client/src/variables/utils/hasRequested.tsx new file mode 100644 index 000000000..4b2ff3694 --- /dev/null +++ b/packages/core/client/src/variables/utils/hasRequested.tsx @@ -0,0 +1,25 @@ +const requested = {}; + +/** + * Check if a request has already been made for a given url to avoid duplicate requests + * @param url + * @returns + */ +export const hasRequested = (url: string) => { + if (requested[url]) { + return true; + } + return false; +}; + +export const getRequested = (url: string) => { + return requested[url]; +}; + +export const stashRequested = (url: string, value: Promise) => { + requested[url] = value; +}; + +export const clearRequested = (url: string) => { + delete requested[url]; +}; diff --git a/packages/core/client/src/variables/utils/isVariable.tsx b/packages/core/client/src/variables/utils/isVariable.tsx new file mode 100644 index 000000000..2dca20632 --- /dev/null +++ b/packages/core/client/src/variables/utils/isVariable.tsx @@ -0,0 +1,14 @@ +export const REGEX_OF_VARIABLE = /\{\{\s*([a-zA-Z0-9_$.]+?)\s*\}\}/g; + +export const isVariable = (str: unknown) => { + if (typeof str !== 'string') { + return false; + } + const matches = str.match(REGEX_OF_VARIABLE); + + if (!matches) { + return false; + } + + return true; +}; diff --git a/packages/core/client/src/variables/utils/transformVariableValue.ts b/packages/core/client/src/variables/utils/transformVariableValue.ts new file mode 100644 index 000000000..b94cdccb3 --- /dev/null +++ b/packages/core/client/src/variables/utils/transformVariableValue.ts @@ -0,0 +1,60 @@ +import _ from 'lodash'; +import { CollectionFieldOptions } from '../../collection-manager'; + +interface Deps { + /** + * 消费当前变量值的 collection field,根据其值去判断应该怎么转换变量值 + */ + targetCollectionField: CollectionFieldOptions; +} + +/** + * - `对一` 赋给 `对多` 时应该转化为一个数组 + */ +export const transformVariableValue = (value: any, deps: Deps) => { + const { targetCollectionField } = deps; + + if (!targetCollectionField) { + return value; + } + + // 行政区划 + if (targetCollectionField.interface === 'chinaRegion') { + if (Array.isArray(value)) { + return value.map((item) => { + if (!_.isObject(item)) { + if (process.env.NODE_ENV !== 'production') { + throw new Error(`transformVariableValue: chinaRegion field value should be an array of object`); + } + } + + item = { ...item }; + Object.keys(item).forEach((key) => { + // 在这里删除掉一些字段,不然保存的时候会报 `字段唯一性` 错误 + if (_.isObjectLike(item[key])) { + delete item[key]; + } + }); + + return item; + }); + } + + if (process.env.NODE_ENV !== 'production') { + throw new Error(`transformVariableValue: chinaRegion field value should be an array`); + } + } + + if (['hasMany', 'belongsToMany'].includes(targetCollectionField.type)) { + if (!Array.isArray(value)) { + return [value]; + } + return value; + } + + if (targetCollectionField.interface === 'json') { + return value; + } + + return value; +}; diff --git a/packages/core/client/src/variables/utils/uniq.ts b/packages/core/client/src/variables/utils/uniq.ts new file mode 100644 index 000000000..2232209d3 --- /dev/null +++ b/packages/core/client/src/variables/utils/uniq.ts @@ -0,0 +1,23 @@ +import _ from 'lodash'; + +/** + * 如果 `value` 是一个数组,返回一个去重后的数组 + * @param value + * @returns + */ +export const uniq = (value: any) => { + if (!Array.isArray(value)) { + return value; + } + + if (!_.isObject(value[0])) { + return value; + } + + return _.uniqBy(value, (item) => { + if ('id' in item) { + return item.id; + } + return JSON.stringify(item); + }); +}; diff --git a/packages/core/database/src/collection.ts b/packages/core/database/src/collection.ts index 5c4a2a0a6..0da717e76 100644 --- a/packages/core/database/src/collection.ts +++ b/packages/core/database/src/collection.ts @@ -58,6 +58,7 @@ function EnsureAtomicity(target: any, propertyKey: string, descriptor: PropertyD export interface CollectionOptions extends Omit { name: string; + title?: string; namespace?: string; /** * Used for @nocobase/plugin-duplicator @@ -96,6 +97,8 @@ export interface CollectionOptions extends Omit tree?: string; + template?: string; + [key: string]: any; } @@ -105,7 +108,7 @@ export interface CollectionContext { export class Collection< TModelAttributes extends {} = any, - TCreationAttributes extends {} = TModelAttributes, + TCreationAttributes extends {} = TModelAttributes > extends EventEmitter { options: CollectionOptions; context: CollectionContext; diff --git a/packages/core/database/src/operators/string.ts b/packages/core/database/src/operators/string.ts index 155051e46..c87a34b73 100644 --- a/packages/core/database/src/operators/string.ts +++ b/packages/core/database/src/operators/string.ts @@ -3,38 +3,98 @@ import { isPg } from './utils'; export default { $includes(value, ctx) { + if (Array.isArray(value)) { + const conditions = value.map((item) => ({ + [isPg(ctx) ? Op.iLike : Op.like]: `%${item}%`, + })); + + return { + [Op.or]: conditions, + }; + } + return { [isPg(ctx) ? Op.iLike : Op.like]: `%${value}%`, }; }, $notIncludes(value, ctx) { + if (Array.isArray(value)) { + const conditions = value.map((item) => ({ + [isPg(ctx) ? Op.notILike : Op.notLike]: `%${item}%`, + })); + + return { + [Op.and]: conditions, + }; + } + return { [isPg(ctx) ? Op.notILike : Op.notLike]: `%${value}%`, }; }, $startsWith(value, ctx) { + if (Array.isArray(value)) { + const conditions = value.map((item) => ({ + [isPg(ctx) ? Op.iLike : Op.like]: `${item}%`, + })); + + return { + [Op.or]: conditions, + }; + } + return { [isPg(ctx) ? Op.iLike : Op.like]: `${value}%`, }; }, $notStartsWith(value, ctx) { + if (Array.isArray(value)) { + const conditions = value.map((item) => ({ + [isPg(ctx) ? Op.notILike : Op.notLike]: `${item}%`, + })); + + return { + [Op.and]: conditions, + }; + } + return { [isPg(ctx) ? Op.notILike : Op.notLike]: `${value}%`, }; }, $endWith(value, ctx) { + if (Array.isArray(value)) { + const conditions = value.map((item) => ({ + [isPg(ctx) ? Op.iLike : Op.like]: `%${item}`, + })); + + return { + [Op.or]: conditions, + }; + } + return { [isPg(ctx) ? Op.iLike : Op.like]: `%${value}`, }; }, $notEndWith(value, ctx) { + if (Array.isArray(value)) { + const conditions = value.map((item) => ({ + [isPg(ctx) ? Op.notILike : Op.notLike]: `%${item}`, + })); + + return { + [Op.and]: conditions, + }; + } + return { [isPg(ctx) ? Op.notILike : Op.notLike]: `%${value}`, }; }, -}; +} as Record; diff --git a/packages/core/server/src/gateway/index.ts b/packages/core/server/src/gateway/index.ts index 75be962b8..823aa53f6 100644 --- a/packages/core/server/src/gateway/index.ts +++ b/packages/core/server/src/gateway/index.ts @@ -163,7 +163,7 @@ export class Gateway extends EventEmitter { const hasApp = AppSupervisor.getInstance().hasApp(handleApp); if (!hasApp) { - AppSupervisor.getInstance().bootStrapApp(handleApp); + void AppSupervisor.getInstance().bootStrapApp(handleApp); } const appStatus = AppSupervisor.getInstance().getAppStatus(handleApp, 'initializing'); diff --git a/packages/core/server/src/middlewares/parse-variables.ts b/packages/core/server/src/middlewares/parse-variables.ts index 91abd4d34..cdc940615 100644 --- a/packages/core/server/src/middlewares/parse-variables.ts +++ b/packages/core/server/src/middlewares/parse-variables.ts @@ -45,10 +45,14 @@ export const parseVariables = async (ctx, next) => { return ctx.db.getFieldByPath(`${resourceName}.${fieldPath}`); }, vars: { + // @deprecated $system: { now: new Date().toISOString(), }, + // @deprecated $date: getDateVars(), + // 新的命名方式,防止和 formily 内置变量冲突 + $nDate: getDateVars(), $user: getUser(ctx), }, }); diff --git a/packages/core/utils/src/__tests__/getValuesByPath.test.ts b/packages/core/utils/src/__tests__/getValuesByPath.test.ts index 4407477e9..482215909 100644 --- a/packages/core/utils/src/__tests__/getValuesByPath.test.ts +++ b/packages/core/utils/src/__tests__/getValuesByPath.test.ts @@ -19,6 +19,14 @@ describe('getValuesByPath', () => { expect(result).toEqual([1, 2]); }); + it('should return an array of values', () => { + const obj = { + a: [{ b: 1 }], + }; + const result = getValuesByPath(obj, 'a.b'); + expect(result).toEqual([1]); + }); + it('nested array', () => { const obj = { a: [{ b: [{ c: 1 }, { c: 2 }] }, { b: [{ c: 3 }, { c: 4 }] }], diff --git a/packages/core/utils/src/client.ts b/packages/core/utils/src/client.ts index 3c6b63d13..08502cb20 100644 --- a/packages/core/utils/src/client.ts +++ b/packages/core/utils/src/client.ts @@ -14,6 +14,7 @@ export * from './number'; export * from './parse-filter'; export * from './registry'; // export * from './toposort'; +export * from './isPortalInBody'; export * from './uid'; -export { dayjs, lodash }; export * from './url'; +export { dayjs, lodash }; diff --git a/packages/core/utils/src/getValuesByPath.ts b/packages/core/utils/src/getValuesByPath.ts index 62db90f61..af0e60c58 100644 --- a/packages/core/utils/src/getValuesByPath.ts +++ b/packages/core/utils/src/getValuesByPath.ts @@ -5,11 +5,13 @@ export const getValuesByPath = (obj: object, path: string, defaultValue?: any) = const keys = path.split('.'); let result: any[] = []; let currentValue = obj; + let shouldReturnArray = false; for (let i = 0; i < keys.length; i++) { const key = keys[i]; if (Array.isArray(currentValue)) { + shouldReturnArray = true; for (const element of currentValue) { const value = getValuesByPath(element, keys.slice(i).join('.'), defaultValue); result = result.concat(value); @@ -33,5 +35,9 @@ export const getValuesByPath = (obj: object, path: string, defaultValue?: any) = return defaultValue; } + if (shouldReturnArray) { + return result; + } + return result.length === 1 ? result[0] : result; }; diff --git a/packages/core/utils/src/isPortalInBody.ts b/packages/core/utils/src/isPortalInBody.ts new file mode 100644 index 000000000..2e3e7c03d --- /dev/null +++ b/packages/core/utils/src/isPortalInBody.ts @@ -0,0 +1,27 @@ +/** + * 判断一个 DOM 对象是否是由 createPortal 挂在到了 body 上 + * @param domNode DOM 对象 + */ + +export const isPortalInBody = (dom: Element) => { + while (dom) { + // 如果有 `nb-action` 类名,说明是一个 Action 按钮,其本身已经阻止了冒泡,不需要再次阻止,如果阻止会导致点击无效 + if (dom.id === 'root' || dom.classList?.contains('nb-action')) { + return false; + } + dom = dom.parentNode as Element; + } + + // 测试环境下大部分都是直接 render 的组件,是没有以 root 为 ID 的根元素的 + if (process.env.__TEST__) { + return false; + } + + if (process.env.NODE_ENV !== 'production') { + if (!document.querySelector('#root')) { + throw new Error(`isPortalInBody: can not find element with id 'root'`); + } + } + + return true; +}; diff --git a/packages/core/utils/src/parse-filter.ts b/packages/core/utils/src/parse-filter.ts index 352326e51..72435dd14 100644 --- a/packages/core/utils/src/parse-filter.ts +++ b/packages/core/utils/src/parse-filter.ts @@ -1,3 +1,4 @@ +import _ from 'lodash'; import set from 'lodash/set'; import { offsetFromString } from './date'; import { dayjs } from './dayjs'; @@ -24,32 +25,34 @@ export function flatten(target, opts?: any) { function step(object, prev?: any, currentDepth?: any) { currentDepth = currentDepth || 1; - Object.keys(object).forEach(function (key) { - const value = object[key]; - const isarray = opts.safe && Array.isArray(value); - const type = Object.prototype.toString.call(value); - const isbuffer = isBuffer(value); - const isobject = type === '[object Object]' || type === '[object Array]'; + if (_.isObjectLike(object)) { + Object.keys(object).forEach(function (key) { + const value = object[key]; + const isarray = opts.safe && Array.isArray(value); + const type = Object.prototype.toString.call(value); + const isbuffer = isBuffer(value); + const isobject = type === '[object Object]' || type === '[object Array]'; - const newKey = prev ? prev + delimiter + transformKey(key) : transformKey(key); + const newKey = prev ? prev + delimiter + transformKey(key) : transformKey(key); + + if (opts.breakOn?.({ key, value, path: newKey })) { + output[newKey] = transformValue(value, newKey); + return; + } + + if ( + !isarray && + !isbuffer && + isobject && + Object.keys(value).length && + (!opts.maxDepth || currentDepth < maxDepth) + ) { + return step(value, newKey, currentDepth + 1); + } - if (opts.breakOn?.({ key, value, path: newKey })) { output[newKey] = transformValue(value, newKey); - return; - } - - if ( - !isarray && - !isbuffer && - isobject && - Object.keys(value).length && - (!opts.maxDepth || currentDepth < maxDepth) - ) { - return step(value, newKey, currentDepth + 1); - } - - output[newKey] = transformValue(value, newKey); - }); + }); + } } step(target); @@ -57,7 +60,7 @@ export function flatten(target, opts?: any) { return output; } -function unflatten(obj, opts: any = {}) { +export function unflatten(obj, opts: any = {}) { const parsed = {}; const transformValue = opts.transformValue || keyIdentity; for (const key of Object.keys(obj)) { @@ -104,7 +107,7 @@ const dateValueWrapper = (value: any, timezone?: string) => { } if (typeof value === 'string') { - if (!timezone || /(\+|\-)\d\d\:\d\d$/.test(value)) { + if (!timezone || /(\+|-)\d\d:\d\d$/.test(value)) { return value; } return value + timezone; diff --git a/packages/plugins/@nocobase/plugin-audit-logs/src/client/deplicated/AuditLogsDesigner.tsx b/packages/plugins/@nocobase/plugin-audit-logs/src/client/deplicated/AuditLogsDesigner.tsx index abaef30b4..7644f552c 100644 --- a/packages/plugins/@nocobase/plugin-audit-logs/src/client/deplicated/AuditLogsDesigner.tsx +++ b/packages/plugins/@nocobase/plugin-audit-logs/src/client/deplicated/AuditLogsDesigner.tsx @@ -1,10 +1,10 @@ -import { ISchema, useField, useFieldSchema } from '@formily/react'; +import { useField, useFieldSchema } from '@formily/react'; import { GeneralSchemaDesigner, SchemaSettings, useCollection, - useCollectionFilterOptions, useDesignable, + useFormBlockContext, useTableBlockContext, } from '@nocobase/client'; import React from 'react'; @@ -14,30 +14,16 @@ export const AuditLogsDesigner = () => { const { name, title } = useCollection(); const field = useField(); const fieldSchema = useFieldSchema(); - const dataSource = useCollectionFilterOptions(name); + const { form } = useFormBlockContext(); const { service } = useTableBlockContext(); const { t } = useTranslation(); const { dn } = useDesignable(); - const defaultFilter = fieldSchema?.['x-decorator-props']?.params?.filter || {}; return ( - { const params = field.decoratorProps.params || {}; params.filter = filter; diff --git a/packages/plugins/@nocobase/plugin-export/src/client/useExportAction.ts b/packages/plugins/@nocobase/plugin-export/src/client/useExportAction.ts index 99bd36430..f249b88b5 100644 --- a/packages/plugins/@nocobase/plugin-export/src/client/useExportAction.ts +++ b/packages/plugins/@nocobase/plugin-export/src/client/useExportAction.ts @@ -24,6 +24,7 @@ export const useExportAction = () => { exportSettings.forEach((es) => { const { uiSchema, interface: fieldInterface } = getCollectionJoinField(`${name}.${es.dataIndex.join('.')}`) ?? {}; + // @ts-ignore es.enum = uiSchema?.enum?.map((e) => ({ value: e.value, label: e.label })); if (!es.enum && uiSchema?.type === 'boolean') { es.enum = [ diff --git a/packages/plugins/@nocobase/plugin-formula-field/src/client/components/Formula/Result.tsx b/packages/plugins/@nocobase/plugin-formula-field/src/client/components/Formula/Result.tsx index 4d2700a84..7a09d1e3f 100644 --- a/packages/plugins/@nocobase/plugin-formula-field/src/client/components/Formula/Result.tsx +++ b/packages/plugins/@nocobase/plugin-formula-field/src/client/components/Formula/Result.tsx @@ -1,7 +1,7 @@ -import React, { useState, useEffect } from 'react'; import { onFormInputChange } from '@formily/core'; +import { useField, useFieldSchema, useFormEffects } from '@formily/react'; import { toJS } from '@formily/reactive'; -import { useFieldSchema, useFormEffects, useField } from '@formily/react'; +import type { CollectionOptions } from '@nocobase/client'; import { Checkbox, DatePicker, @@ -13,6 +13,7 @@ import { } from '@nocobase/client'; import { Evaluator, evaluators } from '@nocobase/evaluators/client'; import { Registry, toFixedByStep } from '@nocobase/utils/client'; +import React, { useEffect, useState } from 'react'; import { toDbType } from '../../../utils'; @@ -31,7 +32,7 @@ function useTargetCollectionField() { const providedCollection = useCollection(); const { getCollection, getCollectionField } = useCollectionManager(); const paths = (fieldSchema.name as string).split('.'); - let collection = providedCollection; + let collection: CollectionOptions = providedCollection; for (let i = 0; i < paths.length - 1; i++) { const field = collection.getField(paths[i]); collection = getCollection(field.target); diff --git a/packages/plugins/@nocobase/plugin-map/src/client/block/MapBlockDesigner.tsx b/packages/plugins/@nocobase/plugin-map/src/client/block/MapBlockDesigner.tsx index a7910455f..eadce80ec 100644 --- a/packages/plugins/@nocobase/plugin-map/src/client/block/MapBlockDesigner.tsx +++ b/packages/plugins/@nocobase/plugin-map/src/client/block/MapBlockDesigner.tsx @@ -6,9 +6,9 @@ import { SchemaSettings, mergeFilter, useCollection, - useCollectionFilterOptions, useCollectionManager, useDesignable, + useFormBlockContext, useSchemaTemplate, } from '@nocobase/client'; import lodash from 'lodash'; @@ -21,13 +21,12 @@ export const MapBlockDesigner = () => { const { name, title } = useCollection(); const field = useField(); const fieldSchema = useFieldSchema(); - const dataSource = useCollectionFilterOptions(name); + const { form } = useFormBlockContext(); const { service } = useMapBlockContext(); const { t } = useMapTranslation(); const { dn } = useDesignable(); const { getCollectionFieldsOptions } = useCollectionManager(); const collection = useCollection(); - const defaultFilter = fieldSchema?.['x-decorator-props']?.params?.filter || {}; const defaultResource = fieldSchema?.['x-decorator-props']?.resource; const fieldNames = fieldSchema?.['x-decorator-props']?.['fieldNames'] || {}; const defaultZoom = fieldSchema?.['x-component-props']?.['zoom'] || 13; @@ -116,23 +115,10 @@ export const MapBlockDesigner = () => { dn.refresh(); }} > - { const params = field.decoratorProps.params || {}; params.filter = filter; diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client/interfaces/expression.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client/interfaces/expression.tsx index b73373453..b134f7788 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/client/interfaces/expression.tsx +++ b/packages/plugins/@nocobase/plugin-workflow/src/client/interfaces/expression.tsx @@ -21,5 +21,4 @@ export default { properties: { ...defaultProps, }, - invariable: true, } as IField; diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client/nodes/condition.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client/nodes/condition.tsx index 783555f48..58b82ba4a 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/client/nodes/condition.tsx +++ b/packages/plugins/@nocobase/plugin-workflow/src/client/nodes/condition.tsx @@ -162,7 +162,6 @@ function Calculation({ calculator, operands = [], onChange }) { onChange={(v) => onChange({ operands, calculator: v })} placeholder={lang('Calculator')} popupMatchSelectWidth={false} - className="auto-width" > {calculatorGroups .filter((group) => Boolean(getGroupCalculators(group.value).length)) diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client/nodes/manual/AssigneesSelect.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client/nodes/manual/AssigneesSelect.tsx index 59c9b2437..e10b4f170 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/client/nodes/manual/AssigneesSelect.tsx +++ b/packages/plugins/@nocobase/plugin-workflow/src/client/nodes/manual/AssigneesSelect.tsx @@ -33,7 +33,6 @@ export function AssigneesSelect({ multiple = false, value = [], onChange }) { onChange={(v) => { onChange(v != null ? [v] : []); }} - className="auto-width" /> ); diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client/nodes/manual/FormBlockProvider.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client/nodes/manual/FormBlockProvider.tsx index c5c7a60b4..6144baca8 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/client/nodes/manual/FormBlockProvider.tsx +++ b/packages/plugins/@nocobase/plugin-workflow/src/client/nodes/manual/FormBlockProvider.tsx @@ -1,9 +1,9 @@ -import React, { useRef, useMemo, useContext } from 'react'; import { createForm } from '@formily/core'; -import { useFieldSchema, useField, RecursionField } from '@formily/react'; +import { RecursionField, useField, useFieldSchema } from '@formily/react'; import { BlockRequestContext, CollectionProvider, + FormActiveFieldsProvider, FormBlockContext, FormV2, RecordProvider, @@ -12,6 +12,7 @@ import { useDesignable, useRecord, } from '@nocobase/client'; +import React, { useContext, useMemo, useRef } from 'react'; export function FormBlockProvider(props) { const userJob = useRecord(); @@ -33,42 +34,50 @@ export function FormBlockProvider(props) { [values], ); - const params = { - appends, - ...props.params, - }; - const service = { - loading: false, - data: { - data: values, - }, - }; + const params = useMemo(() => { + return { + appends, + ...props.params, + }; + }, [appends, props.params]); + const service = useMemo(() => { + return { + loading: false, + data: { + data: values, + }, + }; + }, [values]); const api = useAPIClient(); const resource = api.resource(props.collection); const __parent = useContext(BlockRequestContext); + const formBlockValue = useMemo(() => { + return { + params, + form, + field, + service, + updateAssociationValues, + formBlockRef, + }; + }, [field, form, params, service, updateAssociationValues]); + return !userJob.status || values ? ( - - - - -
- -
-
-
-
+ + + + + +
+ +
+
+
+
+
) : null; diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client/nodes/manual/SchemaConfig.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client/nodes/manual/SchemaConfig.tsx index 69846c735..cd67bf1d0 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/client/nodes/manual/SchemaConfig.tsx +++ b/packages/plugins/@nocobase/plugin-workflow/src/client/nodes/manual/SchemaConfig.tsx @@ -8,6 +8,8 @@ import { useTranslation } from 'react-i18next'; import { Action, ActionContextProvider, + DefaultValueProvider, + FormActiveFieldsProvider, GeneralSchemaDesigner, InitializerWithSwitch, SchemaComponent, @@ -20,6 +22,7 @@ import { gridRowColWrap, useCollectionManager, useCompile, + useFormActiveFields, useFormBlockContext, useSchemaOptionsContext, } from '@nocobase/client'; @@ -179,7 +182,7 @@ function AssignedFieldValues() { const ctx = useContext(SchemaComponentContext); const { t } = useTranslation(); const fieldSchema = useFieldSchema(); - const scope = useWorkflowVariableOptions({ fieldNames: { label: 'title', value: 'name' } }); + const scope = useWorkflowVariableOptions(); const [open, setOpen] = useState(false); const [initialSchema, setInitialSchema] = useState( fieldSchema?.['x-action-settings']?.assignedValues?.schema ?? { @@ -207,6 +210,7 @@ function AssignedFieldValues() { values: lodash.cloneDeep(initialValues), }); }, []); + const upLevelActiveFields = useFormActiveFields(); const title = t('Assign field values'); @@ -246,28 +250,34 @@ function AssignedFieldValues() {
} > - - - - -
- {open && schema && ( - - - - )} -
-
-
+ false}> + + + + + +
+ {open && schema && ( + + + + )} +
+
+
+
+
); diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client/triggers/form.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client/triggers/form.tsx index 66839c348..b96cec608 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/client/triggers/form.tsx +++ b/packages/plugins/@nocobase/plugin-workflow/src/client/triggers/form.tsx @@ -10,6 +10,7 @@ import { useCompile, useCurrentUserContext, useFilterByTk, + useFormActiveFields, useRecord, } from '@nocobase/client'; import { isURL, parse } from '@nocobase/utils/client'; @@ -102,13 +103,22 @@ export default { initializers: {}, }; -function getFormValues(filterByTk, field, form, fieldNames, getField, resource) { +function getFormValues({ + filterByTk, + form, + getField, + actionFields, +}: { + filterByTk; + form; + getField; + actionFields: any[]; +}) { if (filterByTk) { - const actionFields = field?.data?.activeFields as Set; if (actionFields) { const keys = Object.keys(form.values).filter((key) => { const f = getField(key); - return !actionFields.has(key) && ['hasOne', 'hasMany', 'belongsTo', 'belongsToMany'].includes(f?.type); + return !actionFields.includes(key) && ['hasOne', 'hasMany', 'belongsTo', 'belongsToMany'].includes(f?.type); }); return omit({ ...form.values }, keys); } @@ -131,6 +141,7 @@ export function useTriggerWorkflowsActionProps() { const currentRecord = useRecord(); const currentUserContext = useCurrentUserContext(); const { modal } = App.useApp(); + const { getActiveFieldsName } = useFormActiveFields(); const currentUser = currentUserContext?.data?.data; const filterKeys = actionField.componentProps.filterKeys || []; @@ -146,11 +157,24 @@ export function useTriggerWorkflowsActionProps() { triggerWorkflows, } = actionSchema?.['x-action-settings'] ?? {}; const addChild = fieldSchema?.['x-component-props']?.addChild; - const assignedValues = parse(originalAssignedValues)({ currentTime: new Date(), currentRecord, currentUser }); + const assignedValues = parse(originalAssignedValues)({ + // @deprecated + currentTime: new Date(), + // @deprecated + currentRecord, + // @deprecated + currentUser, + $user: currentUser, + $nRecord: currentRecord, + $nForm: form.values, + $nDate: { + now: new Date(), + }, + }); if (!skipValidator) { await form.submit(); } - const values = getFormValues(filterByTk, field, form, fieldNames, getField, resource); + const values = getFormValues({ filterByTk, form, getField, actionFields: getActiveFieldsName('form') }); // const values = omitBy(formValues, (value) => isEqual(JSON.stringify(value), '[{}]')); if (addChild) { const treeParentField = getTreeParentField(); diff --git a/packages/plugins/@nocobase/plugin-workflow/src/server/instructions/manual/actions.ts b/packages/plugins/@nocobase/plugin-workflow/src/server/instructions/manual/actions.ts index 788c7cdab..c3711bd6b 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/server/instructions/manual/actions.ts +++ b/packages/plugins/@nocobase/plugin-workflow/src/server/instructions/manual/actions.ts @@ -1,8 +1,8 @@ import { Context, utils } from '@nocobase/actions'; +import ManualInstruction from '.'; import Plugin from '../..'; import { EXECUTION_STATUS, JOB_STATUS } from '../../constants'; -import ManualInstruction from '.'; export async function submit(context: Context, next) { const repository = utils.getRepositoryFromParams(context); @@ -56,9 +56,17 @@ export async function submit(context: Context, next) { return context.throw(403); } const presetValues = processor.getParsedValue(actionItem.values ?? {}, userJob.nodeId, { + // @deprecated currentUser: currentUser.toJSON(), + // @deprecated currentRecord: values.result[formKey], + // @deprecated currentTime: new Date(), + $user: currentUser.toJSON(), + $nForm: values.result[formKey], + $nDate: { + now: new Date(), + }, }); userJob.set({ diff --git a/packages/plugins/@nocobase/plugin-workflow/src/server/instructions/query.ts b/packages/plugins/@nocobase/plugin-workflow/src/server/instructions/query.ts index 12ac357d9..52be2c48e 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/server/instructions/query.ts +++ b/packages/plugins/@nocobase/plugin-workflow/src/server/instructions/query.ts @@ -2,8 +2,8 @@ import { DEFAULT_PAGE, DEFAULT_PER_PAGE, utils } from '@nocobase/actions'; import Processor from '../Processor'; import { JOB_STATUS } from '../constants'; -import { toJSON } from '../utils'; import type { FlowNodeModel } from '../types'; +import { toJSON } from '../utils'; export default { async run(node: FlowNodeModel, input, processor: Processor) { diff --git a/vitest.config.ts b/vitest.config.ts index 17d70f433..f36387ae6 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -20,6 +20,9 @@ export default defineConfig({ resolve: { mainFields: ['module'], }, + define: { + 'process.env.__TEST__': true, + }, test: { globals: true, setupFiles: 'scripts/vitest.setup.ts',