@nocobase/client-v2 2.2.0-alpha.1 → 2.2.0-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (216) hide show
  1. package/es/PluginSettingsManager.d.ts +33 -0
  2. package/es/RouteRepository.d.ts +21 -6
  3. package/es/components/category-tabs/SortableCategoryTabs.d.ts +55 -0
  4. package/es/components/category-tabs/index.d.ts +9 -0
  5. package/es/components/form/JsonTextArea.d.ts +2 -1
  6. package/es/components/form/TypedVariableInput.d.ts +22 -4
  7. package/es/components/form/VariableJsonTextArea.d.ts +19 -0
  8. package/es/components/form/filter/CollectionFilterItem.d.ts +11 -1
  9. package/es/components/form/filter/index.d.ts +2 -0
  10. package/es/components/form/index.d.ts +1 -0
  11. package/es/components/index.d.ts +2 -0
  12. package/es/flow/actions/afterSuccess.d.ts +8 -1
  13. package/es/flow/actions/index.d.ts +1 -1
  14. package/es/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.d.ts +2 -0
  15. package/es/flow/admin-shell/admin-layout/AdminLayoutMenuFlowUtils.d.ts +2 -1
  16. package/es/flow/admin-shell/admin-layout/AdminLayoutMenuModels.d.ts +2 -0
  17. package/es/flow/admin-shell/admin-layout/AdminLayoutMenuUtils.d.ts +3 -2
  18. package/es/flow/admin-shell/admin-layout/AdminLayoutSlotModels.d.ts +5 -0
  19. package/es/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.d.ts +6 -0
  20. package/es/flow/components/FieldAssignRulesEditor.d.ts +3 -1
  21. package/es/flow/components/FieldAssignValueInput.d.ts +2 -0
  22. package/es/flow/index.d.ts +1 -0
  23. package/es/flow/models/base/GridModel.d.ts +1 -1
  24. package/es/flow/models/blocks/filter-form/FilterFormBlockModel.d.ts +5 -0
  25. package/es/flow/models/blocks/form/FormBlockModel.d.ts +4 -0
  26. package/es/flow/models/blocks/form/submitHandler.d.ts +1 -1
  27. package/es/flow/models/blocks/form/value-runtime/rules.d.ts +21 -0
  28. package/es/flow/models/blocks/form/value-runtime/runtime.d.ts +21 -0
  29. package/es/flow/models/blocks/form/value-runtime/types.d.ts +2 -2
  30. package/es/flow/models/blocks/table/TableActionsColumnModel.d.ts +1 -0
  31. package/es/flow/models/blocks/table/TableBlockModel.d.ts +1 -0
  32. package/es/flow/models/blocks/table/dragSort/dragSortHooks.d.ts +1 -1
  33. package/es/flow/models/blocks/table/dragSort/dragSortSettings.d.ts +2 -1
  34. package/es/flow/models/blocks/table/dragSort/dragSortUtils.d.ts +6 -4
  35. package/es/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.d.ts +2 -0
  36. package/es/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.d.ts +6 -0
  37. package/es/flow/resolveViewParamsToViewList.d.ts +1 -1
  38. package/es/flow/utils/dataScopeRowSnapshot.d.ts +59 -0
  39. package/es/flow/utils/dateTimeDisplayProps.d.ts +49 -0
  40. package/es/flow/utils/formValueDeps.d.ts +32 -0
  41. package/es/flow-compat/FieldValidation.d.ts +2 -1
  42. package/es/index.d.ts +2 -1
  43. package/es/index.mjs +233 -137
  44. package/lib/index.js +234 -138
  45. package/package.json +7 -7
  46. package/src/PluginSettingsManager.ts +53 -0
  47. package/src/RouteRepository.ts +126 -24
  48. package/src/__tests__/PluginSettingsManager.test.ts +13 -0
  49. package/src/__tests__/RouteRepository.test.ts +216 -0
  50. package/src/__tests__/browserChecker.test.ts +61 -0
  51. package/src/__tests__/exports.test.ts +16 -0
  52. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +199 -0
  53. package/src/__tests__/plugin-manager.test.tsx +44 -2
  54. package/src/__tests__/settings-center.test.tsx +40 -1
  55. package/src/collection-manager/field-configure.ts +1 -1
  56. package/src/collection-manager/interfaces/id.ts +1 -1
  57. package/src/collection-manager/interfaces/m2m.tsx +2 -2
  58. package/src/collection-manager/interfaces/m2o.tsx +2 -2
  59. package/src/collection-manager/interfaces/nanoid.ts +1 -1
  60. package/src/collection-manager/interfaces/o2m.tsx +2 -2
  61. package/src/collection-manager/interfaces/obo.tsx +2 -2
  62. package/src/collection-manager/interfaces/oho.tsx +2 -2
  63. package/src/collection-manager/interfaces/properties/index.ts +2 -2
  64. package/src/collection-manager/interfaces/uuid.ts +1 -1
  65. package/src/components/AppComponents.tsx +19 -3
  66. package/src/components/README.md +7 -1
  67. package/src/components/README.zh-CN.md +6 -1
  68. package/src/components/category-tabs/SortableCategoryTabs.tsx +210 -0
  69. package/src/components/category-tabs/index.ts +10 -0
  70. package/src/components/form/JsonTextArea.tsx +21 -11
  71. package/src/components/form/TypedVariableInput.tsx +416 -93
  72. package/src/components/form/VariableJsonTextArea.tsx +175 -0
  73. package/src/components/form/__tests__/JsonTextArea.test.tsx +43 -0
  74. package/src/components/form/__tests__/TypedVariableInput.test.tsx +266 -9
  75. package/src/components/form/__tests__/VariableJsonTextArea.test.tsx +86 -0
  76. package/src/components/form/filter/CollectionFilterItem.tsx +32 -7
  77. package/src/components/form/filter/__tests__/CollectionFilterItem.test.tsx +38 -0
  78. package/src/components/form/filter/index.ts +2 -0
  79. package/src/components/form/index.tsx +1 -0
  80. package/src/components/index.ts +2 -0
  81. package/src/flow/__tests__/FlowRoute.test.tsx +443 -5
  82. package/src/flow/__tests__/getKey.test.ts +7 -0
  83. package/src/flow/__tests__/resolveViewParamsToViewList.test.ts +34 -0
  84. package/src/flow/actions/__tests__/afterSuccess.test.ts +73 -0
  85. package/src/flow/actions/__tests__/dataScopeFilter.test.ts +58 -0
  86. package/src/flow/actions/__tests__/dataScopeFormValueClear.test.ts +1022 -0
  87. package/src/flow/actions/__tests__/fieldLinkageRules.scopeDepth.test.ts +150 -1
  88. package/src/flow/actions/__tests__/linkageAssignField.legacy.test.ts +135 -0
  89. package/src/flow/actions/__tests__/linkageRules.hiddenSync.restore.test.ts +19 -5
  90. package/src/flow/actions/__tests__/linkageRules.subFormSetFieldProps.test.ts +92 -0
  91. package/src/flow/actions/__tests__/openView.defineProps.route.test.tsx +104 -0
  92. package/src/flow/actions/__tests__/subFormFieldLinkageRules.inputArgs.test.ts +2 -2
  93. package/src/flow/actions/afterSuccess.tsx +142 -3
  94. package/src/flow/actions/customVariable.tsx +1 -2
  95. package/src/flow/actions/dataScopeFilter.ts +10 -0
  96. package/src/flow/actions/dateTimeFormat.tsx +42 -28
  97. package/src/flow/actions/index.ts +1 -1
  98. package/src/flow/actions/linkageRules.tsx +123 -35
  99. package/src/flow/actions/linkageRulesFormValueRefresh.ts +22 -130
  100. package/src/flow/actions/openView.tsx +38 -4
  101. package/src/flow/actions/runjs.tsx +2 -14
  102. package/src/flow/actions/validation.tsx +62 -30
  103. package/src/flow/admin-shell/BaseLayoutModel.tsx +25 -3
  104. package/src/flow/admin-shell/BaseLayoutRouteCoordinator.ts +5 -2
  105. package/src/flow/admin-shell/__tests__/AdminLayoutRouteCoordinator.test.ts +13 -0
  106. package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +34 -10
  107. package/src/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.test.tsx +310 -0
  108. package/src/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.tsx +44 -8
  109. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuFlowUtils.ts +5 -3
  110. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuModels.tsx +61 -9
  111. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuUtils.tsx +71 -8
  112. package/src/flow/admin-shell/admin-layout/AdminLayoutModel.tsx +1 -1
  113. package/src/flow/admin-shell/admin-layout/AdminLayoutSlotModels.tsx +23 -9
  114. package/src/flow/admin-shell/admin-layout/HelpLite.tsx +1 -3
  115. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutComponent.test.tsx +188 -0
  116. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +99 -0
  117. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutModel.test.tsx +82 -0
  118. package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +166 -2
  119. package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.test.ts +20 -0
  120. package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.ts +33 -5
  121. package/src/flow/components/DefaultValue.tsx +1 -2
  122. package/src/flow/components/DynamicFlowsIcon.tsx +447 -126
  123. package/src/flow/components/FieldAssignRulesEditor.tsx +128 -21
  124. package/src/flow/components/FieldAssignValueInput.tsx +10 -5
  125. package/src/flow/components/FlowRoute.tsx +56 -11
  126. package/src/flow/components/__tests__/DynamicFlowsIcon.test.tsx +148 -2
  127. package/src/flow/components/__tests__/FieldAssignRulesEditor.test.tsx +508 -4
  128. package/src/flow/components/__tests__/fieldAssignOptions.test.ts +151 -3
  129. package/src/flow/components/code-editor/__tests__/useCodeRunner.test.tsx +2 -17
  130. package/src/flow/components/code-editor/hooks/useCodeRunner.ts +2 -14
  131. package/src/flow/components/code-editor/runjsDiagnostics.ts +8 -45
  132. package/src/flow/components/fieldAssignOptions.ts +155 -27
  133. package/src/flow/flows/editMarkdownFlow.tsx +1 -1
  134. package/src/flow/getViewDiffAndUpdateHidden.tsx +1 -0
  135. package/src/flow/index.ts +2 -1
  136. package/src/flow/internal/utils/operatorSchemaHelper.ts +15 -1
  137. package/src/flow/models/actions/JSActionModel.tsx +2 -7
  138. package/src/flow/models/actions/JSCollectionActionModel.tsx +2 -7
  139. package/src/flow/models/actions/JSItemActionModel.tsx +2 -7
  140. package/src/flow/models/actions/JSRecordActionModel.tsx +2 -7
  141. package/src/flow/models/base/ActionModel.tsx +12 -1
  142. package/src/flow/models/base/GridModel.tsx +38 -7
  143. package/src/flow/models/base/PageModel/PageModel.tsx +4 -1
  144. package/src/flow/models/base/PageModel/__tests__/PageModel.test.ts +28 -4
  145. package/src/flow/models/base/__tests__/ActionModel.test.ts +83 -0
  146. package/src/flow/models/base/__tests__/GridModel.dragSnapshotContainer.test.ts +239 -1
  147. package/src/flow/models/base/__tests__/transformRowsToSingleColumn.test.ts +48 -0
  148. package/src/flow/models/blocks/filter-form/FilterFormBlockModel.tsx +31 -3
  149. package/src/flow/models/blocks/filter-form/FilterFormJSActionModel.tsx +2 -7
  150. package/src/flow/models/blocks/filter-form/__tests__/defaultValues.wiring.test.ts +77 -0
  151. package/src/flow/models/blocks/filter-form/fields/FilterFormCustomFieldModel.tsx +1 -1
  152. package/src/flow/models/blocks/filter-manager/flow-actions/__tests__/customizeFilterRender.test.tsx +56 -1
  153. package/src/flow/models/blocks/form/EditFormModel.tsx +1 -0
  154. package/src/flow/models/blocks/form/FormActionModel.tsx +1 -1
  155. package/src/flow/models/blocks/form/FormBlockModel.tsx +7 -0
  156. package/src/flow/models/blocks/form/JSFormActionModel.tsx +2 -7
  157. package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +17 -0
  158. package/src/flow/models/blocks/form/__tests__/submitHandler.test.ts +54 -1
  159. package/src/flow/models/blocks/form/submitHandler.ts +17 -3
  160. package/src/flow/models/blocks/form/value-runtime/__tests__/runtime.test.ts +880 -102
  161. package/src/flow/models/blocks/form/value-runtime/rules.ts +445 -13
  162. package/src/flow/models/blocks/form/value-runtime/runtime.ts +257 -13
  163. package/src/flow/models/blocks/form/value-runtime/types.ts +2 -2
  164. package/src/flow/models/blocks/js-block/JSBlock.tsx +2 -7
  165. package/src/flow/models/blocks/table/JSColumnModel.tsx +1 -9
  166. package/src/flow/models/blocks/table/TableActionsColumnModel.tsx +36 -13
  167. package/src/flow/models/blocks/table/TableBlockModel.tsx +38 -17
  168. package/src/flow/models/blocks/table/TableColumnModel.tsx +36 -3
  169. package/src/flow/models/blocks/table/__tests__/TableActionsColumnModel.test.tsx +111 -1
  170. package/src/flow/models/blocks/table/__tests__/TableBlockModel.dragSort.test.ts +127 -0
  171. package/src/flow/models/blocks/table/__tests__/TableBlockModel.mobileSettingsButtons.test.tsx +78 -0
  172. package/src/flow/models/blocks/table/__tests__/TableBlockModel.rowSelection.test.tsx +1 -0
  173. package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +159 -1
  174. package/src/flow/models/blocks/table/dragSort/dragSortHooks.tsx +28 -17
  175. package/src/flow/models/blocks/table/dragSort/dragSortSettings.ts +13 -6
  176. package/src/flow/models/blocks/table/dragSort/dragSortUtils.ts +15 -2
  177. package/src/flow/models/fields/AssociationFieldModel/AssociationFieldModel.tsx +1 -1
  178. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +29 -6
  179. package/src/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.tsx +16 -4
  180. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +141 -19
  181. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableField.tsx +13 -1
  182. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableColumnModel.rowRecord.test.ts +197 -0
  183. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableFieldModel.reset.test.ts +180 -0
  184. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/index.tsx +6 -4
  185. package/src/flow/models/fields/AssociationFieldModel/__tests__/AssociationFieldModel.updateAssociation.test.ts +72 -0
  186. package/src/flow/models/fields/AssociationFieldModel/__tests__/RecordPickerFieldModel.itemContext.test.ts +23 -0
  187. package/src/flow/models/fields/ClickableFieldModel.tsx +5 -0
  188. package/src/flow/models/fields/DisplayDateTimeFieldModel.tsx +29 -1
  189. package/src/flow/models/fields/InputFieldModel.tsx +40 -2
  190. package/src/flow/models/fields/JSEditableFieldModel.tsx +2 -11
  191. package/src/flow/models/fields/JSFieldModel.tsx +2 -7
  192. package/src/flow/models/fields/JSItemModel.tsx +2 -14
  193. package/src/flow/models/fields/SelectFieldModel.tsx +6 -4
  194. package/src/flow/models/fields/TextareaFieldModel.tsx +49 -3
  195. package/src/flow/models/fields/__tests__/DisplayDateTimeFieldModel.test.tsx +96 -0
  196. package/src/flow/models/fields/__tests__/InputFieldModel.test.tsx +117 -1
  197. package/src/flow/models/fields/__tests__/SelectFieldModel.test.tsx +43 -0
  198. package/src/flow/models/fields/__tests__/TextareaFieldModel.test.tsx +100 -0
  199. package/src/flow/models/topbar/TopbarActionModel.tsx +93 -10
  200. package/src/flow/resolveViewParamsToViewList.tsx +11 -3
  201. package/src/flow/utils/__tests__/dateTimeFormat.test.ts +258 -0
  202. package/src/flow/utils/dataScopeFormValueClear.ts +218 -81
  203. package/src/flow/utils/dataScopeRowSnapshot.ts +616 -0
  204. package/src/flow/utils/dateTimeDisplayProps.ts +135 -0
  205. package/src/flow/utils/formValueDeps.ts +170 -0
  206. package/src/flow-compat/FieldValidation.tsx +122 -60
  207. package/src/flow-compat/Popover.tsx +43 -4
  208. package/src/flow-compat/__tests__/Popover.test.tsx +34 -0
  209. package/src/index.ts +8 -1
  210. package/src/layout-manager/__tests__/LayoutRoute.test.tsx +41 -1
  211. package/src/nocobase-buildin-plugin/index.tsx +44 -14
  212. package/src/settings-center/AdminSettingsLayout.tsx +10 -2
  213. package/src/settings-center/SystemSettingsPage.tsx +1 -2
  214. package/src/settings-center/plugin-manager/PluginCard.tsx +2 -2
  215. package/src/settings-center/plugin-manager/index.tsx +3 -0
  216. package/src/settings-center/utils.tsx +0 -6
@@ -14,6 +14,7 @@ import dayjs from 'dayjs';
14
14
  import { observable } from '@formily/reactive';
15
15
  import { get as lodashGet, merge as lodashMerge, set as lodashSet } from 'lodash';
16
16
  import { FlowContext, JSRunner } from '@nocobase/flow-engine';
17
+ import { getValuesByPath } from '@nocobase/shared';
17
18
  import { FormValueRuntime } from '..';
18
19
  import type { FormInstance } from 'antd';
19
20
 
@@ -95,6 +96,10 @@ function createFieldContext(runtime: FormValueRuntime) {
95
96
  if (match) {
96
97
  const pathString = match[1];
97
98
  if (!pathString) return this;
99
+ if (pathString === 'formValues') return this.formValues;
100
+ if (pathString.startsWith('formValues.')) {
101
+ return getValuesByPath(runtime.getFormValuesSnapshot(), pathString.slice('formValues.'.length));
102
+ }
98
103
  const segs = pathString.split('.').filter(Boolean);
99
104
  return lodashGet(this, segs);
100
105
  }
@@ -747,13 +752,13 @@ describe('FormValueRuntime (form assign rules)', () => {
747
752
  await waitFor(() => expect(formStub.getFieldValue(['a'])).toBe('Y'));
748
753
  });
749
754
 
750
- it('mode=default follows explicit semantics and stops after user change', async () => {
755
+ it('mode=assign follows to-many association title changes without clearing on transient empty arrays', async () => {
751
756
  const engineEmitter = new EventEmitter();
752
757
  const blockEmitter = new EventEmitter();
753
- const formStub = createFormStub({ b: 'X' });
758
+ const formStub = createFormStub({ roles: [] });
754
759
 
755
760
  const blockModel: any = {
756
- uid: 'form-assign-2',
761
+ uid: 'form-assign-to-many-source-title',
757
762
  flowEngine: { emitter: engineEmitter },
758
763
  emitter: blockEmitter,
759
764
  dispatchEvent: vi.fn(),
@@ -764,46 +769,66 @@ describe('FormValueRuntime (form assign rules)', () => {
764
769
  runtime.mount({ sync: true });
765
770
 
766
771
  const blockCtx = createFieldContext(runtime);
767
- const fieldModel: any = {
768
- uid: 'field-a2',
769
- context: { fieldPathArray: ['a'] },
772
+ const usernameField: any = { isAssociationField: () => false, interface: 'input' };
773
+ const rolesCollection: any = { getField: () => null, filterTargetKey: 'name' };
774
+ const rolesField: any = {
775
+ isAssociationField: () => true,
776
+ type: 'belongsToMany',
777
+ targetCollection: rolesCollection,
770
778
  };
771
- blockCtx.defineProperty('engine', {
772
- value: {
773
- getModel: (id: string) => (id === 'field-a2' ? fieldModel : null),
779
+ const collection: any = {
780
+ getField: (name: string) => {
781
+ if (name === 'username') return usernameField;
782
+ if (name === 'roles') return rolesField;
783
+ return null;
774
784
  },
775
- });
785
+ };
786
+ blockCtx.defineProperty('collection', { value: collection });
776
787
  blockModel.context = blockCtx;
777
788
 
778
789
  runtime.syncAssignRules([
779
790
  {
780
- key: 'r1',
791
+ key: 'username-from-role-title',
781
792
  enable: true,
782
- targetPath: 'a',
783
- mode: 'default',
793
+ targetPath: 'username',
794
+ mode: 'assign',
784
795
  condition: { logic: '$and', items: [] },
785
- value: '__B__',
796
+ value: '{{ ctx.formValues.roles.title }}',
786
797
  },
787
798
  ]);
788
799
 
789
- await waitFor(() => expect(formStub.getFieldValue(['a'])).toBe('X'));
800
+ await new Promise((resolve) => setTimeout(resolve, 20));
801
+ expect(formStub.getFieldValue(['username'])).toBeUndefined();
790
802
 
791
- // user writes to target; default mode MUST stop permanently
792
- await runtime.setFormValues(blockCtx, [{ path: ['a'], value: 'user' }], { source: 'user' });
793
- expect(formStub.getFieldValue(['a'])).toBe('user');
803
+ await runtime.setFormValues(blockCtx, [{ path: ['roles'], value: [{ title: '经理' }] }], { source: 'user' });
804
+ await waitFor(() => expect(formStub.getFieldValue(['username'])).toBe('经理'));
794
805
 
795
- await runtime.setFormValues(blockCtx, [{ path: ['b'], value: 'Y' }], { source: 'user' });
796
- await new Promise((resolve) => setTimeout(resolve, 20));
797
- expect(formStub.getFieldValue(['a'])).toBe('user');
806
+ await runtime.setFormValues(blockCtx, [{ path: ['roles'], value: [{ title: '经理' }, { title: 'Admin' }] }], {
807
+ source: 'user',
808
+ });
809
+ await waitFor(() => expect(formStub.getFieldValue(['username'])).toBe('经理, Admin'));
810
+
811
+ await runtime.setFormValues(blockCtx, [{ path: ['roles'], value: [{ title: null }] }], { source: 'system' });
812
+ await new Promise((resolve) => setTimeout(resolve, 100));
813
+ expect(formStub.getFieldValue(['username'])).toBe('经理, Admin');
814
+
815
+ await runtime.setFormValues(blockCtx, [{ path: ['roles'], value: [{ __index__: 'row-1', __is_new__: true }] }], {
816
+ source: 'system',
817
+ });
818
+ await new Promise((resolve) => setTimeout(resolve, 100));
819
+ expect(formStub.getFieldValue(['username'])).toBe('经理, Admin');
820
+
821
+ await runtime.setFormValues(blockCtx, [{ path: ['roles'], value: [{ title: '销售' }] }], { source: 'user' });
822
+ await waitFor(() => expect(formStub.getFieldValue(['username'])).toBe('销售'));
798
823
  });
799
824
 
800
- it('reapplies mode=default value after form reset starts a new create session', async () => {
825
+ it('mode=assign clears scalar target when to-many association source is really cleared by user', async () => {
801
826
  const engineEmitter = new EventEmitter();
802
827
  const blockEmitter = new EventEmitter();
803
- const formStub = createFormStub({});
828
+ const formStub = createFormStub({ roles: [] });
804
829
 
805
830
  const blockModel: any = {
806
- uid: 'form-assign-reset-reapply-default',
831
+ uid: 'form-assign-to-many-source-user-clear',
807
832
  flowEngine: { emitter: engineEmitter },
808
833
  emitter: blockEmitter,
809
834
  dispatchEvent: vi.fn(),
@@ -814,47 +839,103 @@ describe('FormValueRuntime (form assign rules)', () => {
814
839
  runtime.mount({ sync: true });
815
840
 
816
841
  const blockCtx = createFieldContext(runtime);
817
- const fieldModel: any = {
818
- uid: 'field-a-reset-reapply-default',
819
- context: { fieldPathArray: ['a'] },
842
+ const usernameField: any = { isAssociationField: () => false, interface: 'input' };
843
+ const rolesCollection: any = { getField: () => null, filterTargetKey: 'name' };
844
+ const rolesField: any = {
845
+ isAssociationField: () => true,
846
+ type: 'belongsToMany',
847
+ targetCollection: rolesCollection,
820
848
  };
821
- blockCtx.defineProperty('engine', {
822
- value: {
823
- getModel: (id: string) => (id === 'field-a-reset-reapply-default' ? fieldModel : null),
849
+ const collection: any = {
850
+ getField: (name: string) => {
851
+ if (name === 'username') return usernameField;
852
+ if (name === 'roles') return rolesField;
853
+ return null;
824
854
  },
825
- });
855
+ };
856
+ blockCtx.defineProperty('collection', { value: collection });
826
857
  blockModel.context = blockCtx;
827
858
 
828
859
  runtime.syncAssignRules([
829
860
  {
830
- key: 'r1',
861
+ key: 'username-from-role-title-user-clear',
831
862
  enable: true,
832
- targetPath: 'a',
833
- mode: 'default',
863
+ targetPath: 'username',
864
+ mode: 'assign',
834
865
  condition: { logic: '$and', items: [] },
835
- value: 'AAA',
866
+ value: '{{ ctx.formValues.roles.title }}',
836
867
  },
837
868
  ]);
838
869
 
839
- await waitFor(() => expect(formStub.getFieldValue(['a'])).toBe('AAA'));
870
+ await runtime.setFormValues(blockCtx, [{ path: ['roles'], value: [{ title: '经理' }] }], { source: 'user' });
871
+ await waitFor(() => expect(formStub.getFieldValue(['username'])).toBe('经理'));
840
872
 
841
- await runtime.setFormValues(blockCtx, [{ path: ['a'], value: 'AAA1' }], { source: 'user' });
842
- expect(formStub.getFieldValue(['a'])).toBe('AAA1');
873
+ await runtime.setFormValues(blockCtx, [{ path: ['roles'], value: [] }], { source: 'user' });
874
+ await waitFor(() => expect(formStub.getFieldValue(['username'])).toBe(''));
875
+ });
843
876
 
844
- formStub.__store.a = undefined;
845
- delete formStub.__store.a;
846
- runtime.resetAfterFormReset();
877
+ it('mode=assign does not stringify multi-value aggregate for non-text scalar targets', async () => {
878
+ const engineEmitter = new EventEmitter();
879
+ const blockEmitter = new EventEmitter();
880
+ const formStub = createFormStub({ roles: [] });
847
881
 
848
- await waitFor(() => expect(formStub.getFieldValue(['a'])).toBe('AAA'));
882
+ const blockModel: any = {
883
+ uid: 'form-assign-to-many-number-target',
884
+ flowEngine: { emitter: engineEmitter },
885
+ emitter: blockEmitter,
886
+ dispatchEvent: vi.fn(),
887
+ getAclActionName: () => 'create',
888
+ };
889
+
890
+ const runtime = new FormValueRuntime({ model: blockModel, getForm: () => formStub as any });
891
+ runtime.mount({ sync: true });
892
+
893
+ const blockCtx = createFieldContext(runtime);
894
+ const scoreField: any = { isAssociationField: () => false, interface: 'number' };
895
+ const rolesCollection: any = { getField: () => null, filterTargetKey: 'name' };
896
+ const rolesField: any = {
897
+ isAssociationField: () => true,
898
+ type: 'belongsToMany',
899
+ targetCollection: rolesCollection,
900
+ };
901
+ const collection: any = {
902
+ getField: (name: string) => {
903
+ if (name === 'score') return scoreField;
904
+ if (name === 'roles') return rolesField;
905
+ return null;
906
+ },
907
+ };
908
+ blockCtx.defineProperty('collection', { value: collection });
909
+ blockModel.context = blockCtx;
910
+
911
+ runtime.syncAssignRules([
912
+ {
913
+ key: 'score-from-role-level',
914
+ enable: true,
915
+ targetPath: 'score',
916
+ mode: 'assign',
917
+ condition: { logic: '$and', items: [] },
918
+ value: '{{ ctx.formValues.roles.level }}',
919
+ },
920
+ ]);
921
+
922
+ await runtime.setFormValues(blockCtx, [{ path: ['roles'], value: [{ level: 3 }] }], { source: 'user' });
923
+ await waitFor(() => expect(formStub.getFieldValue(['score'])).toBe(3));
924
+
925
+ await runtime.setFormValues(blockCtx, [{ path: ['roles'], value: [{ level: 1 }, { level: 2 }] }], {
926
+ source: 'user',
927
+ });
928
+ await new Promise((resolve) => setTimeout(resolve, 50));
929
+ expect(formStub.getFieldValue(['score'])).toBe(3);
849
930
  });
850
931
 
851
- it('resets valuesMirror before rescheduling default rules after form reset', async () => {
932
+ it('tracks dynamic ctx.resolveJsonTemplate formValues dependencies from RunJS values', async () => {
852
933
  const engineEmitter = new EventEmitter();
853
934
  const blockEmitter = new EventEmitter();
854
935
  const formStub = createFormStub({ selector: 'x', x: 'AAA', y: 'BBB' });
855
936
 
856
937
  const blockModel: any = {
857
- uid: 'form-assign-reset-values-mirror',
938
+ uid: 'form-assign-runjs-dynamic-template-deps',
858
939
  flowEngine: { emitter: engineEmitter },
859
940
  emitter: blockEmitter,
860
941
  dispatchEvent: vi.fn(),
@@ -866,56 +947,104 @@ describe('FormValueRuntime (form assign rules)', () => {
866
947
 
867
948
  const blockCtx = createFieldContext(runtime);
868
949
  const fieldModel: any = {
869
- uid: 'field-a-reset-values-mirror',
950
+ uid: 'field-a-runjs-dynamic-template-deps',
870
951
  context: { fieldPathArray: ['a'] },
871
952
  };
872
953
  blockCtx.defineProperty('engine', {
873
954
  value: {
874
- getModel: (id: string) => (id === 'field-a-reset-values-mirror' ? fieldModel : null),
955
+ getModel: (id: string) => (id === 'field-a-runjs-dynamic-template-deps' ? fieldModel : null),
875
956
  },
876
957
  });
877
958
  blockModel.context = blockCtx;
878
959
 
879
960
  runtime.syncAssignRules([
880
961
  {
881
- key: 'r1',
962
+ key: 'runjs-dynamic-template-deps',
882
963
  enable: true,
883
964
  targetPath: 'a',
884
- mode: 'default',
965
+ mode: 'assign',
885
966
  condition: { logic: '$and', items: [] },
886
- value: '__DYNAMIC__',
967
+ value: {
968
+ code: "const key = ctx.formValues.selector; return await ctx.resolveJsonTemplate('{{ ctx.formValues.' + key + ' }}');",
969
+ version: 'v1',
970
+ },
887
971
  },
888
972
  ]);
889
973
 
890
974
  await waitFor(() => expect(formStub.getFieldValue(['a'])).toBe('AAA'));
891
975
 
892
- await runtime.setFormValues(blockCtx, [{ path: ['a'], value: 'AAA1' }], { source: 'user' });
893
- expect(formStub.getFieldValue(['a'])).toBe('AAA1');
976
+ await runtime.setFormValues(blockCtx, [{ path: ['x'], value: 'AAA2' }], { source: 'user' });
977
+ await waitFor(() => expect(formStub.getFieldValue(['a'])).toBe('AAA2'));
894
978
 
895
- for (const key of Object.keys(formStub.__store)) {
896
- delete formStub.__store[key];
897
- }
898
- runtime.resetAfterFormReset();
979
+ await runtime.setFormValues(blockCtx, [{ path: ['selector'], value: 'y' }], { source: 'user' });
980
+ await waitFor(() => expect(formStub.getFieldValue(['a'])).toBe('BBB'));
981
+ });
899
982
 
900
- await new Promise((resolve) => setTimeout(resolve, 20));
901
- expect(formStub.getFieldValue(['a'])).toBeUndefined();
983
+ it('mode=assign keeps local formValues replacement text literal', async () => {
984
+ const engineEmitter = new EventEmitter();
985
+ const blockEmitter = new EventEmitter();
986
+ const formStub = createFormStub({ roles: [] });
902
987
 
903
- await runtime.setFormValues(blockCtx, [{ path: ['selector'], value: 'y' }], { source: 'user' });
904
- await runtime.setFormValues(blockCtx, [{ path: ['y'], value: 'BBB2' }], { source: 'user' });
905
- await waitFor(() => expect(formStub.getFieldValue(['a'])).toBe('BBB2'));
988
+ const blockModel: any = {
989
+ uid: 'form-assign-local-template-literal',
990
+ flowEngine: { emitter: engineEmitter },
991
+ emitter: blockEmitter,
992
+ dispatchEvent: vi.fn(),
993
+ getAclActionName: () => 'create',
994
+ };
995
+
996
+ const runtime = new FormValueRuntime({ model: blockModel, getForm: () => formStub as any });
997
+ runtime.mount({ sync: true });
998
+
999
+ const blockCtx = createFieldContext(runtime);
1000
+ blockCtx.defineProperty('locale', { value: 'zh-CN' });
1001
+
1002
+ const usernameField: any = { isAssociationField: () => false, interface: 'input' };
1003
+ const rolesCollection: any = { getField: () => null, filterTargetKey: 'name' };
1004
+ const rolesField: any = {
1005
+ isAssociationField: () => true,
1006
+ type: 'belongsToMany',
1007
+ targetCollection: rolesCollection,
1008
+ };
1009
+ const collection: any = {
1010
+ getField: (name: string) => {
1011
+ if (name === 'username') return usernameField;
1012
+ if (name === 'roles') return rolesField;
1013
+ return null;
1014
+ },
1015
+ };
1016
+ blockCtx.defineProperty('collection', { value: collection });
1017
+ blockModel.context = blockCtx;
1018
+
1019
+ runtime.syncAssignRules([
1020
+ {
1021
+ key: 'username-from-role-template-literal',
1022
+ enable: true,
1023
+ targetPath: 'username',
1024
+ mode: 'assign',
1025
+ condition: { logic: '$and', items: [] },
1026
+ value: '{{ ctx.formValues.roles.title }}',
1027
+ },
1028
+ ]);
1029
+
1030
+ await runtime.setFormValues(blockCtx, [{ path: ['roles'], value: [{ title: '{{ ctx.locale }}' }] }], {
1031
+ source: 'user',
1032
+ });
1033
+
1034
+ await waitFor(() => expect(formStub.getFieldValue(['username'])).toBe('{{ ctx.locale }}'));
906
1035
  });
907
1036
 
908
- it('skips mode=default form assignment in update form', async () => {
1037
+ it('mode=default follows explicit semantics and stops after user change', async () => {
909
1038
  const engineEmitter = new EventEmitter();
910
1039
  const blockEmitter = new EventEmitter();
911
1040
  const formStub = createFormStub({ b: 'X' });
912
1041
 
913
1042
  const blockModel: any = {
914
- uid: 'form-assign-update-skip-default',
1043
+ uid: 'form-assign-2',
915
1044
  flowEngine: { emitter: engineEmitter },
916
1045
  emitter: blockEmitter,
917
1046
  dispatchEvent: vi.fn(),
918
- getAclActionName: () => 'update',
1047
+ getAclActionName: () => 'create',
919
1048
  };
920
1049
 
921
1050
  const runtime = new FormValueRuntime({ model: blockModel, getForm: () => formStub as any });
@@ -923,12 +1052,12 @@ describe('FormValueRuntime (form assign rules)', () => {
923
1052
 
924
1053
  const blockCtx = createFieldContext(runtime);
925
1054
  const fieldModel: any = {
926
- uid: 'field-a-update-skip-default',
1055
+ uid: 'field-a2',
927
1056
  context: { fieldPathArray: ['a'] },
928
1057
  };
929
1058
  blockCtx.defineProperty('engine', {
930
1059
  value: {
931
- getModel: (id: string) => (id === 'field-a-update-skip-default' ? fieldModel : null),
1060
+ getModel: (id: string) => (id === 'field-a2' ? fieldModel : null),
932
1061
  },
933
1062
  });
934
1063
  blockModel.context = blockCtx;
@@ -944,21 +1073,24 @@ describe('FormValueRuntime (form assign rules)', () => {
944
1073
  },
945
1074
  ]);
946
1075
 
947
- await new Promise((resolve) => setTimeout(resolve, 20));
948
- expect(formStub.getFieldValue(['a'])).toBeUndefined();
1076
+ await waitFor(() => expect(formStub.getFieldValue(['a'])).toBe('X'));
1077
+
1078
+ // user writes to target; default mode MUST stop permanently
1079
+ await runtime.setFormValues(blockCtx, [{ path: ['a'], value: 'user' }], { source: 'user' });
1080
+ expect(formStub.getFieldValue(['a'])).toBe('user');
949
1081
 
950
1082
  await runtime.setFormValues(blockCtx, [{ path: ['b'], value: 'Y' }], { source: 'user' });
951
1083
  await new Promise((resolve) => setTimeout(resolve, 20));
952
- expect(formStub.getFieldValue(['a'])).toBeUndefined();
1084
+ expect(formStub.getFieldValue(['a'])).toBe('user');
953
1085
  });
954
1086
 
955
- it('syncs UI props for mode=default form assignment on configured fields', async () => {
1087
+ it('mode=default follows to-many association subpath changes without clearing on transient empty arrays', async () => {
956
1088
  const engineEmitter = new EventEmitter();
957
1089
  const blockEmitter = new EventEmitter();
958
- const formStub = createFormStub();
1090
+ const formStub = createFormStub({ roles: [] });
959
1091
 
960
1092
  const blockModel: any = {
961
- uid: 'form-assign-default-ui-sync',
1093
+ uid: 'form-assign-default-to-many-source',
962
1094
  flowEngine: { emitter: engineEmitter },
963
1095
  emitter: blockEmitter,
964
1096
  dispatchEvent: vi.fn(),
@@ -969,49 +1101,691 @@ describe('FormValueRuntime (form assign rules)', () => {
969
1101
  runtime.mount({ sync: true });
970
1102
 
971
1103
  const blockCtx = createFieldContext(runtime);
972
- blockModel.context = blockCtx;
973
-
974
- const fieldCtx = createFieldContext(runtime);
975
- fieldCtx.defineProperty('blockModel', { value: blockModel });
976
- fieldCtx.defineProperty('fieldPathArray', { value: ['color'] });
977
-
978
- const colorFieldModel: any = {
979
- setProps: vi.fn(),
1104
+ const usernameField: any = { isAssociationField: () => false, interface: 'input' };
1105
+ const rolesCollection: any = { getField: () => null, filterTargetKey: 'name' };
1106
+ const rolesField: any = {
1107
+ isAssociationField: () => true,
1108
+ type: 'belongsToMany',
1109
+ targetCollection: rolesCollection,
980
1110
  };
981
-
982
- const formItemModel: any = {
983
- uid: 'form-item-color-default-ui-sync',
984
- context: fieldCtx,
985
- subModels: { field: colorFieldModel },
986
- getStepParams: (flowKey: string, stepKey: string) => {
987
- if (flowKey === 'fieldSettings' && stepKey === 'init') {
988
- return { fieldPath: 'color' };
989
- }
990
- return undefined;
1111
+ const collection: any = {
1112
+ getField: (name: string) => {
1113
+ if (name === 'username') return usernameField;
1114
+ if (name === 'roles') return rolesField;
1115
+ return null;
991
1116
  },
992
- setProps: vi.fn(),
993
1117
  };
994
- fieldCtx.defineProperty('model', { value: formItemModel });
995
-
996
- blockCtx.defineProperty('engine', {
997
- value: {
998
- forEachModel: (visit: (model: any) => void) => visit(formItemModel),
999
- },
1000
- });
1118
+ blockCtx.defineProperty('collection', { value: collection });
1119
+ blockModel.context = blockCtx;
1001
1120
 
1002
1121
  runtime.syncAssignRules([
1003
1122
  {
1004
- key: 'color-default',
1123
+ key: 'username-from-role-name',
1005
1124
  enable: true,
1006
- targetPath: 'color',
1125
+ targetPath: 'username',
1007
1126
  mode: 'default',
1008
1127
  condition: { logic: '$and', items: [] },
1009
- value: '#1677FF',
1128
+ value: '{{ ctx.formValues.roles.name }}',
1010
1129
  },
1011
1130
  ]);
1012
1131
 
1013
- await waitFor(() => expect(formStub.getFieldValue(['color'])).toBe('#1677FF'));
1014
- expect(colorFieldModel.setProps).toHaveBeenCalledWith({ value: '#1677FF' });
1132
+ await new Promise((resolve) => setTimeout(resolve, 20));
1133
+ expect(formStub.getFieldValue(['username'])).toBeUndefined();
1134
+
1135
+ await runtime.setFormValues(blockCtx, [{ path: ['roles'], value: [{ name: 'manager' }] }], { source: 'user' });
1136
+ await waitFor(() => expect(formStub.getFieldValue(['username'])).toBe('manager'));
1137
+
1138
+ // Association selects can briefly carry an incomplete display record while option data is refreshing.
1139
+ // For scalar targets, default mode should not treat that empty aggregate as a real default value.
1140
+ await runtime.setFormValues(blockCtx, [{ path: ['roles'], value: [{ name: null }] }], { source: 'system' });
1141
+ await new Promise((resolve) => setTimeout(resolve, 100));
1142
+ expect(formStub.getFieldValue(['username'])).toBe('manager');
1143
+
1144
+ await runtime.setFormValues(blockCtx, [{ path: ['roles'], value: [{ name: 'member' }] }], { source: 'user' });
1145
+ await waitFor(() => expect(formStub.getFieldValue(['username'])).toBe('member'));
1146
+
1147
+ await runtime.setFormValues(blockCtx, [{ path: ['username'], value: 'manual' }], { source: 'user' });
1148
+ await runtime.setFormValues(blockCtx, [{ path: ['roles'], value: [{ name: 'admin' }] }], { source: 'user' });
1149
+ await new Promise((resolve) => setTimeout(resolve, 20));
1150
+ expect(formStub.getFieldValue(['username'])).toBe('manual');
1151
+ });
1152
+
1153
+ it('mode=override overwrites existing update values until the user changes the target', async () => {
1154
+ const engineEmitter = new EventEmitter();
1155
+ const blockEmitter = new EventEmitter();
1156
+ const formStub = createFormStub({ a: 'Existing', b: 'X' });
1157
+
1158
+ const blockModel: any = {
1159
+ uid: 'form-assign-override-update',
1160
+ flowEngine: { emitter: engineEmitter },
1161
+ emitter: blockEmitter,
1162
+ dispatchEvent: vi.fn(),
1163
+ getAclActionName: () => 'update',
1164
+ };
1165
+
1166
+ const runtime = new FormValueRuntime({ model: blockModel, getForm: () => formStub as any });
1167
+ runtime.mount({ sync: true });
1168
+
1169
+ const blockCtx = createFieldContext(runtime);
1170
+ const fieldModel: any = {
1171
+ uid: 'field-a-override-update',
1172
+ context: { fieldPathArray: ['a'] },
1173
+ };
1174
+ blockCtx.defineProperty('engine', {
1175
+ value: {
1176
+ getModel: (id: string) => (id === 'field-a-override-update' ? fieldModel : null),
1177
+ },
1178
+ });
1179
+ blockModel.context = blockCtx;
1180
+
1181
+ runtime.syncAssignRules([
1182
+ {
1183
+ key: 'r1',
1184
+ enable: true,
1185
+ targetPath: 'a',
1186
+ mode: 'override',
1187
+ condition: { logic: '$and', items: [] },
1188
+ value: '__B__',
1189
+ },
1190
+ ]);
1191
+
1192
+ await waitFor(() => expect(formStub.getFieldValue(['a'])).toBe('X'));
1193
+ expect(runtime.canApplyOverrideValuePatch(['a'])).toBe(true);
1194
+
1195
+ await runtime.setFormValues(blockCtx, [{ path: ['b'], value: 'Y' }], { source: 'user' });
1196
+ await waitFor(() => expect(formStub.getFieldValue(['a'])).toBe('Y'));
1197
+
1198
+ await runtime.setFormValues(blockCtx, [{ path: ['a'], value: 'Manual' }], { source: 'user' });
1199
+ expect(formStub.getFieldValue(['a'])).toBe('Manual');
1200
+ expect(runtime.canApplyOverrideValuePatch(['a'])).toBe(false);
1201
+
1202
+ await runtime.setFormValues(blockCtx, [{ path: ['b'], value: 'Z' }], { source: 'user' });
1203
+ await new Promise((resolve) => setTimeout(resolve, 20));
1204
+ expect(formStub.getFieldValue(['a'])).toBe('Manual');
1205
+ });
1206
+
1207
+ it('stops mode=override for an association root when the user edits descendant fields', async () => {
1208
+ const engineEmitter = new EventEmitter();
1209
+ const blockEmitter = new EventEmitter();
1210
+ const formStub = createFormStub({
1211
+ b: { id: 2, name: 'Auto' },
1212
+ user: { id: 1, name: 'Existing' },
1213
+ });
1214
+
1215
+ const blockModel: any = {
1216
+ uid: 'form-assign-override-association-root',
1217
+ flowEngine: { emitter: engineEmitter },
1218
+ emitter: blockEmitter,
1219
+ dispatchEvent: vi.fn(),
1220
+ getAclActionName: () => 'update',
1221
+ };
1222
+
1223
+ const runtime = new FormValueRuntime({ model: blockModel, getForm: () => formStub as any });
1224
+ runtime.mount({ sync: true });
1225
+
1226
+ const blockCtx = createFieldContext(runtime);
1227
+ blockModel.context = blockCtx;
1228
+
1229
+ runtime.syncAssignRules([
1230
+ {
1231
+ key: 'r1',
1232
+ enable: true,
1233
+ targetPath: 'user',
1234
+ mode: 'override',
1235
+ condition: { logic: '$and', items: [] },
1236
+ value: '__B__',
1237
+ },
1238
+ ]);
1239
+
1240
+ await waitFor(() => expect(formStub.getFieldValue(['user'])).toEqual({ id: 2, name: 'Auto' }));
1241
+ expect(runtime.canApplyOverrideValuePatch(['user'])).toBe(true);
1242
+
1243
+ lodashSet((formStub as any).__store, ['user'], { id: 3, name: 'Manual' });
1244
+ runtime.handleFormValuesChange({ user: formStub.getFieldValue(['user']) }, formStub.getFieldsValue());
1245
+ expect(runtime.canApplyOverrideValuePatch(['user'])).toBe(false);
1246
+
1247
+ await runtime.setFormValues(blockCtx, [{ path: ['b'], value: { id: 4, name: 'Next' } }], { source: 'user' });
1248
+ await new Promise((resolve) => setTimeout(resolve, 20));
1249
+
1250
+ expect(formStub.getFieldValue(['user'])).toEqual({ id: 3, name: 'Manual' });
1251
+ });
1252
+
1253
+ it('stops mode=override for a subtable relation array after a user edits a row cell', async () => {
1254
+ const engineEmitter = new EventEmitter();
1255
+ const blockEmitter = new EventEmitter();
1256
+ const formStub = createFormStub({
1257
+ roles: [
1258
+ { id: 1, name: 'Existing A' },
1259
+ { id: 2, name: 'Existing B' },
1260
+ ],
1261
+ });
1262
+
1263
+ const blockModel: any = {
1264
+ uid: 'form-assign-override-subtable-array',
1265
+ flowEngine: { emitter: engineEmitter },
1266
+ emitter: blockEmitter,
1267
+ dispatchEvent: vi.fn(),
1268
+ getAclActionName: () => 'update',
1269
+ };
1270
+
1271
+ const runtime = new FormValueRuntime({ model: blockModel, getForm: () => formStub as any });
1272
+ runtime.mount({ sync: true });
1273
+
1274
+ const blockCtx = createFieldContext(runtime);
1275
+ blockModel.context = blockCtx;
1276
+
1277
+ runtime.syncAssignRules([
1278
+ {
1279
+ key: 'r1',
1280
+ enable: true,
1281
+ targetPath: 'roles',
1282
+ mode: 'override',
1283
+ condition: { logic: '$and', items: [] },
1284
+ value: [
1285
+ { id: 1, name: 'Auto A' },
1286
+ { id: 2, name: 'Auto B' },
1287
+ ],
1288
+ },
1289
+ ]);
1290
+
1291
+ await waitFor(() =>
1292
+ expect(formStub.getFieldValue(['roles'])).toEqual([
1293
+ { id: 1, name: 'Auto A' },
1294
+ { id: 2, name: 'Auto B' },
1295
+ ]),
1296
+ );
1297
+
1298
+ lodashSet((formStub as any).__store, ['roles', 0, 'name'], 'Manual A');
1299
+ runtime.handleFormFieldsChange([{ name: ['roles', 0, 'name'], touched: true } as any]);
1300
+ runtime.handleFormValuesChange({ roles: formStub.getFieldValue(['roles']) }, formStub.getFieldsValue());
1301
+ expect(runtime.canApplyOverrideValuePatch(['roles'])).toBe(false);
1302
+
1303
+ runtime.syncAssignRules([
1304
+ {
1305
+ key: 'r1',
1306
+ enable: true,
1307
+ targetPath: 'roles',
1308
+ mode: 'override',
1309
+ condition: { logic: '$and', items: [] },
1310
+ value: [
1311
+ { id: 1, name: 'Next A' },
1312
+ { id: 2, name: 'Next B' },
1313
+ { id: 3, name: 'Unexpected extra row' },
1314
+ ],
1315
+ },
1316
+ ]);
1317
+ await new Promise((resolve) => setTimeout(resolve, 20));
1318
+
1319
+ expect(formStub.getFieldValue(['roles'])).toEqual([
1320
+ { id: 1, name: 'Manual A' },
1321
+ { id: 2, name: 'Auto B' },
1322
+ ]);
1323
+ });
1324
+
1325
+ it('stops mode=override after a user clears a multiple value array', async () => {
1326
+ const engineEmitter = new EventEmitter();
1327
+ const blockEmitter = new EventEmitter();
1328
+ const formStub = createFormStub({ b: 'ready', tags: ['Existing'] });
1329
+
1330
+ const blockModel: any = {
1331
+ uid: 'form-assign-override-multiple-clear',
1332
+ flowEngine: { emitter: engineEmitter },
1333
+ emitter: blockEmitter,
1334
+ dispatchEvent: vi.fn(),
1335
+ getAclActionName: () => 'update',
1336
+ };
1337
+
1338
+ const runtime = new FormValueRuntime({ model: blockModel, getForm: () => formStub as any });
1339
+ runtime.mount({ sync: true });
1340
+
1341
+ const blockCtx = createFieldContext(runtime);
1342
+ blockModel.context = blockCtx;
1343
+
1344
+ runtime.syncAssignRules([
1345
+ {
1346
+ key: 'r1',
1347
+ enable: true,
1348
+ targetPath: 'tags',
1349
+ mode: 'override',
1350
+ condition: { logic: '$and', items: [{ path: '{{ ctx.formValues.b }}', operator: '$notEmpty', value: '' }] },
1351
+ value: ['A', 'B'],
1352
+ },
1353
+ ]);
1354
+
1355
+ await waitFor(() => expect(formStub.getFieldValue(['tags'])).toEqual(['A', 'B']));
1356
+ expect(runtime.canApplyOverrideValuePatch(['tags'])).toBe(true);
1357
+
1358
+ lodashSet((formStub as any).__store, ['tags'], []);
1359
+ runtime.handleFormValuesChange({ tags: [] }, formStub.getFieldsValue());
1360
+ expect(runtime.canApplyOverrideValuePatch(['tags'])).toBe(false);
1361
+
1362
+ await runtime.setFormValues(blockCtx, [{ path: ['b'], value: 'changed' }], { source: 'user' });
1363
+ await new Promise((resolve) => setTimeout(resolve, 20));
1364
+
1365
+ expect(formStub.getFieldValue(['tags'])).toEqual([]);
1366
+ });
1367
+
1368
+ it('stops mode=override after a user removes an item from a multiple value array', async () => {
1369
+ const engineEmitter = new EventEmitter();
1370
+ const blockEmitter = new EventEmitter();
1371
+ const formStub = createFormStub({ b: 'ready', tags: ['Existing'] });
1372
+
1373
+ const blockModel: any = {
1374
+ uid: 'form-assign-override-multiple-remove-item',
1375
+ flowEngine: { emitter: engineEmitter },
1376
+ emitter: blockEmitter,
1377
+ dispatchEvent: vi.fn(),
1378
+ getAclActionName: () => 'update',
1379
+ };
1380
+
1381
+ const runtime = new FormValueRuntime({ model: blockModel, getForm: () => formStub as any });
1382
+ runtime.mount({ sync: true });
1383
+
1384
+ const blockCtx = createFieldContext(runtime);
1385
+ blockModel.context = blockCtx;
1386
+
1387
+ runtime.syncAssignRules([
1388
+ {
1389
+ key: 'r1',
1390
+ enable: true,
1391
+ targetPath: 'tags',
1392
+ mode: 'override',
1393
+ condition: { logic: '$and', items: [{ path: '{{ ctx.formValues.b }}', operator: '$notEmpty', value: '' }] },
1394
+ value: ['A', 'B'],
1395
+ },
1396
+ ]);
1397
+
1398
+ await waitFor(() => expect(formStub.getFieldValue(['tags'])).toEqual(['A', 'B']));
1399
+ expect(runtime.canApplyOverrideValuePatch(['tags'])).toBe(true);
1400
+
1401
+ lodashSet((formStub as any).__store, ['tags'], ['A']);
1402
+ runtime.handleFormValuesChange({ tags: ['A'] }, formStub.getFieldsValue());
1403
+ expect(runtime.canApplyOverrideValuePatch(['tags'])).toBe(false);
1404
+
1405
+ await runtime.setFormValues(blockCtx, [{ path: ['b'], value: 'changed' }], { source: 'user' });
1406
+ await new Promise((resolve) => setTimeout(resolve, 20));
1407
+
1408
+ expect(formStub.getFieldValue(['tags'])).toEqual(['A']);
1409
+ });
1410
+
1411
+ it('reapplies mode=override after user edited state is reset', async () => {
1412
+ const engineEmitter = new EventEmitter();
1413
+ const blockEmitter = new EventEmitter();
1414
+ const formStub = createFormStub({ a: 'Existing', b: 'X' });
1415
+
1416
+ const blockModel: any = {
1417
+ uid: 'form-assign-override-reset-user-edited',
1418
+ flowEngine: { emitter: engineEmitter },
1419
+ emitter: blockEmitter,
1420
+ dispatchEvent: vi.fn(),
1421
+ getAclActionName: () => 'update',
1422
+ };
1423
+
1424
+ const runtime = new FormValueRuntime({ model: blockModel, getForm: () => formStub as any });
1425
+ runtime.mount({ sync: true });
1426
+
1427
+ const blockCtx = createFieldContext(runtime);
1428
+ const fieldModel: any = {
1429
+ uid: 'field-a-override-reset-user-edited',
1430
+ context: { fieldPathArray: ['a'] },
1431
+ };
1432
+ blockCtx.defineProperty('engine', {
1433
+ value: {
1434
+ getModel: (id: string) => (id === 'field-a-override-reset-user-edited' ? fieldModel : null),
1435
+ },
1436
+ });
1437
+ blockModel.context = blockCtx;
1438
+
1439
+ runtime.syncAssignRules([
1440
+ {
1441
+ key: 'r1',
1442
+ enable: true,
1443
+ targetPath: 'a',
1444
+ mode: 'override',
1445
+ condition: { logic: '$and', items: [] },
1446
+ value: '__B__',
1447
+ },
1448
+ ]);
1449
+
1450
+ await waitFor(() => expect(formStub.getFieldValue(['a'])).toBe('X'));
1451
+
1452
+ await runtime.setFormValues(blockCtx, [{ path: ['a'], value: 'Manual' }], { source: 'user' });
1453
+ expect(formStub.getFieldValue(['a'])).toBe('Manual');
1454
+
1455
+ formStub.__store.a = 'Next record';
1456
+ formStub.__store.b = 'Y';
1457
+ runtime.handleFormValuesChange({ a: 'Next record', b: 'Y' }, formStub.getFieldsValue());
1458
+ runtime.resetUserEditedState();
1459
+ expect(runtime.canApplyOverrideValuePatch(['a'])).toBe(true);
1460
+
1461
+ await waitFor(() => expect(formStub.getFieldValue(['a'])).toBe('Y'));
1462
+ });
1463
+
1464
+ it('reapplies mode=default value after form reset starts a new create session', async () => {
1465
+ const engineEmitter = new EventEmitter();
1466
+ const blockEmitter = new EventEmitter();
1467
+ const formStub = createFormStub({});
1468
+
1469
+ const blockModel: any = {
1470
+ uid: 'form-assign-reset-reapply-default',
1471
+ flowEngine: { emitter: engineEmitter },
1472
+ emitter: blockEmitter,
1473
+ dispatchEvent: vi.fn(),
1474
+ getAclActionName: () => 'create',
1475
+ };
1476
+
1477
+ const runtime = new FormValueRuntime({ model: blockModel, getForm: () => formStub as any });
1478
+ runtime.mount({ sync: true });
1479
+
1480
+ const blockCtx = createFieldContext(runtime);
1481
+ const fieldModel: any = {
1482
+ uid: 'field-a-reset-reapply-default',
1483
+ context: { fieldPathArray: ['a'] },
1484
+ };
1485
+ blockCtx.defineProperty('engine', {
1486
+ value: {
1487
+ getModel: (id: string) => (id === 'field-a-reset-reapply-default' ? fieldModel : null),
1488
+ },
1489
+ });
1490
+ blockModel.context = blockCtx;
1491
+
1492
+ runtime.syncAssignRules([
1493
+ {
1494
+ key: 'r1',
1495
+ enable: true,
1496
+ targetPath: 'a',
1497
+ mode: 'default',
1498
+ condition: { logic: '$and', items: [] },
1499
+ value: 'AAA',
1500
+ },
1501
+ ]);
1502
+
1503
+ await waitFor(() => expect(formStub.getFieldValue(['a'])).toBe('AAA'));
1504
+
1505
+ await runtime.setFormValues(blockCtx, [{ path: ['a'], value: 'AAA1' }], { source: 'user' });
1506
+ expect(formStub.getFieldValue(['a'])).toBe('AAA1');
1507
+
1508
+ formStub.__store.a = undefined;
1509
+ delete formStub.__store.a;
1510
+ runtime.resetAfterFormReset();
1511
+
1512
+ await waitFor(() => expect(formStub.getFieldValue(['a'])).toBe('AAA'));
1513
+ });
1514
+
1515
+ it('resets valuesMirror before rescheduling default rules after form reset', async () => {
1516
+ const engineEmitter = new EventEmitter();
1517
+ const blockEmitter = new EventEmitter();
1518
+ const formStub = createFormStub({ selector: 'x', x: 'AAA', y: 'BBB' });
1519
+
1520
+ const blockModel: any = {
1521
+ uid: 'form-assign-reset-values-mirror',
1522
+ flowEngine: { emitter: engineEmitter },
1523
+ emitter: blockEmitter,
1524
+ dispatchEvent: vi.fn(),
1525
+ getAclActionName: () => 'create',
1526
+ };
1527
+
1528
+ const runtime = new FormValueRuntime({ model: blockModel, getForm: () => formStub as any });
1529
+ runtime.mount({ sync: true });
1530
+
1531
+ const blockCtx = createFieldContext(runtime);
1532
+ const fieldModel: any = {
1533
+ uid: 'field-a-reset-values-mirror',
1534
+ context: { fieldPathArray: ['a'] },
1535
+ };
1536
+ blockCtx.defineProperty('engine', {
1537
+ value: {
1538
+ getModel: (id: string) => (id === 'field-a-reset-values-mirror' ? fieldModel : null),
1539
+ },
1540
+ });
1541
+ blockModel.context = blockCtx;
1542
+
1543
+ runtime.syncAssignRules([
1544
+ {
1545
+ key: 'r1',
1546
+ enable: true,
1547
+ targetPath: 'a',
1548
+ mode: 'default',
1549
+ condition: { logic: '$and', items: [] },
1550
+ value: '__DYNAMIC__',
1551
+ },
1552
+ ]);
1553
+
1554
+ await waitFor(() => expect(formStub.getFieldValue(['a'])).toBe('AAA'));
1555
+
1556
+ await runtime.setFormValues(blockCtx, [{ path: ['a'], value: 'AAA1' }], { source: 'user' });
1557
+ expect(formStub.getFieldValue(['a'])).toBe('AAA1');
1558
+
1559
+ for (const key of Object.keys(formStub.__store)) {
1560
+ delete formStub.__store[key];
1561
+ }
1562
+ runtime.resetAfterFormReset();
1563
+
1564
+ await new Promise((resolve) => setTimeout(resolve, 20));
1565
+ expect(formStub.getFieldValue(['a'])).toBeUndefined();
1566
+
1567
+ await runtime.setFormValues(blockCtx, [{ path: ['selector'], value: 'y' }], { source: 'user' });
1568
+ await runtime.setFormValues(blockCtx, [{ path: ['y'], value: 'BBB2' }], { source: 'user' });
1569
+ await waitFor(() => expect(formStub.getFieldValue(['a'])).toBe('BBB2'));
1570
+ });
1571
+
1572
+ it('skips mode=default form assignment in update form', async () => {
1573
+ const engineEmitter = new EventEmitter();
1574
+ const blockEmitter = new EventEmitter();
1575
+ const formStub = createFormStub({ b: 'X' });
1576
+
1577
+ const blockModel: any = {
1578
+ uid: 'form-assign-update-skip-default',
1579
+ flowEngine: { emitter: engineEmitter },
1580
+ emitter: blockEmitter,
1581
+ dispatchEvent: vi.fn(),
1582
+ getAclActionName: () => 'update',
1583
+ };
1584
+
1585
+ const runtime = new FormValueRuntime({ model: blockModel, getForm: () => formStub as any });
1586
+ runtime.mount({ sync: true });
1587
+
1588
+ const blockCtx = createFieldContext(runtime);
1589
+ const fieldModel: any = {
1590
+ uid: 'field-a-update-skip-default',
1591
+ context: { fieldPathArray: ['a'] },
1592
+ };
1593
+ blockCtx.defineProperty('engine', {
1594
+ value: {
1595
+ getModel: (id: string) => (id === 'field-a-update-skip-default' ? fieldModel : null),
1596
+ },
1597
+ });
1598
+ blockModel.context = blockCtx;
1599
+
1600
+ runtime.syncAssignRules([
1601
+ {
1602
+ key: 'r1',
1603
+ enable: true,
1604
+ targetPath: 'a',
1605
+ mode: 'default',
1606
+ condition: { logic: '$and', items: [] },
1607
+ value: '__B__',
1608
+ },
1609
+ ]);
1610
+
1611
+ await new Promise((resolve) => setTimeout(resolve, 20));
1612
+ expect(formStub.getFieldValue(['a'])).toBeUndefined();
1613
+
1614
+ await runtime.setFormValues(blockCtx, [{ path: ['b'], value: 'Y' }], { source: 'user' });
1615
+ await new Promise((resolve) => setTimeout(resolve, 20));
1616
+ expect(formStub.getFieldValue(['a'])).toBeUndefined();
1617
+ });
1618
+
1619
+ it('syncs UI props for mode=default form assignment on configured fields', async () => {
1620
+ const engineEmitter = new EventEmitter();
1621
+ const blockEmitter = new EventEmitter();
1622
+ const formStub = createFormStub();
1623
+
1624
+ const blockModel: any = {
1625
+ uid: 'form-assign-default-ui-sync',
1626
+ flowEngine: { emitter: engineEmitter },
1627
+ emitter: blockEmitter,
1628
+ dispatchEvent: vi.fn(),
1629
+ getAclActionName: () => 'create',
1630
+ };
1631
+
1632
+ const runtime = new FormValueRuntime({ model: blockModel, getForm: () => formStub as any });
1633
+ runtime.mount({ sync: true });
1634
+
1635
+ const blockCtx = createFieldContext(runtime);
1636
+ blockModel.context = blockCtx;
1637
+
1638
+ const fieldCtx = createFieldContext(runtime);
1639
+ fieldCtx.defineProperty('blockModel', { value: blockModel });
1640
+ fieldCtx.defineProperty('fieldPathArray', { value: ['color'] });
1641
+
1642
+ const colorFieldModel: any = {
1643
+ setProps: vi.fn(),
1644
+ };
1645
+
1646
+ const formItemModel: any = {
1647
+ uid: 'form-item-color-default-ui-sync',
1648
+ context: fieldCtx,
1649
+ subModels: { field: colorFieldModel },
1650
+ getStepParams: (flowKey: string, stepKey: string) => {
1651
+ if (flowKey === 'fieldSettings' && stepKey === 'init') {
1652
+ return { fieldPath: 'color' };
1653
+ }
1654
+ return undefined;
1655
+ },
1656
+ setProps: vi.fn(),
1657
+ };
1658
+ fieldCtx.defineProperty('model', { value: formItemModel });
1659
+
1660
+ blockCtx.defineProperty('engine', {
1661
+ value: {
1662
+ forEachModel: (visit: (model: any) => void) => visit(formItemModel),
1663
+ },
1664
+ });
1665
+
1666
+ runtime.syncAssignRules([
1667
+ {
1668
+ key: 'color-default',
1669
+ enable: true,
1670
+ targetPath: 'color',
1671
+ mode: 'default',
1672
+ condition: { logic: '$and', items: [] },
1673
+ value: '#1677FF',
1674
+ },
1675
+ ]);
1676
+
1677
+ await waitFor(() => expect(formStub.getFieldValue(['color'])).toBe('#1677FF'));
1678
+ expect(colorFieldModel.setProps).toHaveBeenCalledWith({ value: '#1677FF' });
1679
+ });
1680
+
1681
+ it('syncs mounted field UI props after programmatic form writes', async () => {
1682
+ const engineEmitter = new EventEmitter();
1683
+ const blockEmitter = new EventEmitter();
1684
+ const formStub = createFormStub();
1685
+
1686
+ const blockModel: any = {
1687
+ uid: 'form-runtime-mounted-field-ui-sync',
1688
+ flowEngine: { emitter: engineEmitter },
1689
+ emitter: blockEmitter,
1690
+ dispatchEvent: vi.fn(),
1691
+ getAclActionName: () => 'create',
1692
+ };
1693
+
1694
+ const runtime = new FormValueRuntime({ model: blockModel, getForm: () => formStub as any });
1695
+
1696
+ const blockCtx = createFieldContext(runtime);
1697
+ blockModel.context = blockCtx;
1698
+
1699
+ const fieldCtx = createFieldContext(runtime);
1700
+ fieldCtx.defineProperty('blockModel', { value: blockModel });
1701
+ fieldCtx.defineProperty('fieldPathArray', { value: ['username'] });
1702
+
1703
+ const inputFieldModel: any = {
1704
+ setProps: vi.fn(),
1705
+ };
1706
+ const formItemModel: any = {
1707
+ uid: 'form-item-username-runtime-ui-sync',
1708
+ context: fieldCtx,
1709
+ subModels: { field: inputFieldModel },
1710
+ getStepParams: (flowKey: string, stepKey: string) => {
1711
+ if (flowKey === 'fieldSettings' && stepKey === 'init') {
1712
+ return { fieldPath: 'username' };
1713
+ }
1714
+ return undefined;
1715
+ },
1716
+ setProps: vi.fn(),
1717
+ };
1718
+
1719
+ blockCtx.defineProperty('engine', {
1720
+ value: {
1721
+ forEachModel: (visit: (model: any) => void) => visit(formItemModel),
1722
+ },
1723
+ });
1724
+
1725
+ runtime.mount({ sync: true });
1726
+
1727
+ await runtime.setFormValues(blockCtx, [{ path: ['username'], value: 'Admin' }], { source: 'system' });
1728
+
1729
+ expect(formStub.getFieldValue(['username'])).toBe('Admin');
1730
+ expect(formItemModel.setProps).toHaveBeenCalledWith({ value: 'Admin' });
1731
+ expect(inputFieldModel.setProps).toHaveBeenCalledWith({ value: 'Admin' });
1732
+ });
1733
+
1734
+ it('syncs mounted field UI props from model index without scanning all models on every write', async () => {
1735
+ const engineEmitter = new EventEmitter();
1736
+ const blockEmitter = new EventEmitter();
1737
+ const formStub = createFormStub();
1738
+
1739
+ const blockModel: any = {
1740
+ uid: 'form-runtime-mounted-field-ui-index-sync',
1741
+ flowEngine: { emitter: engineEmitter },
1742
+ emitter: blockEmitter,
1743
+ dispatchEvent: vi.fn(),
1744
+ getAclActionName: () => 'create',
1745
+ };
1746
+
1747
+ const runtime = new FormValueRuntime({ model: blockModel, getForm: () => formStub as any });
1748
+
1749
+ const blockCtx = createFieldContext(runtime);
1750
+ blockModel.context = blockCtx;
1751
+
1752
+ const fieldCtx = createFieldContext(runtime);
1753
+ fieldCtx.defineProperty('blockModel', { value: blockModel });
1754
+ fieldCtx.defineProperty('fieldPathArray', { value: ['username'] });
1755
+
1756
+ const inputFieldModel: any = {
1757
+ setProps: vi.fn(),
1758
+ };
1759
+ const formItemModel: any = {
1760
+ uid: 'form-item-username-runtime-ui-index-sync',
1761
+ context: fieldCtx,
1762
+ subModels: { field: inputFieldModel },
1763
+ getStepParams: (flowKey: string, stepKey: string) => {
1764
+ if (flowKey === 'fieldSettings' && stepKey === 'init') {
1765
+ return { fieldPath: 'username' };
1766
+ }
1767
+ return undefined;
1768
+ },
1769
+ setProps: vi.fn(),
1770
+ };
1771
+
1772
+ const forEachModel = vi.fn((visit: (model: any) => void) => visit(formItemModel));
1773
+ blockCtx.defineProperty('engine', {
1774
+ value: {
1775
+ forEachModel,
1776
+ },
1777
+ });
1778
+
1779
+ runtime.mount({ sync: true });
1780
+ engineEmitter.emit('model:mounted', { model: formItemModel });
1781
+ forEachModel.mockClear();
1782
+
1783
+ await runtime.setFormValues(blockCtx, [{ path: ['username'], value: 'Admin' }], { source: 'system' });
1784
+ await runtime.setFormValues(blockCtx, [{ path: ['username'], value: 'Owner' }], { source: 'system' });
1785
+
1786
+ expect(forEachModel).not.toHaveBeenCalled();
1787
+ expect(formItemModel.setProps).toHaveBeenLastCalledWith({ value: 'Owner' });
1788
+ expect(inputFieldModel.setProps).toHaveBeenLastCalledWith({ value: 'Owner' });
1015
1789
  });
1016
1790
 
1017
1791
  it('linkage assignment takes precedence over mode=assign form assignment', async () => {
@@ -2396,6 +3170,10 @@ describe('FormValueRuntime (form assign rules)', () => {
2396
3170
 
2397
3171
  lodashSet((formStub as any).__store, ['users'], [{ __is_new__: true, name: '' }]);
2398
3172
  runtime.handleFormValuesChange({ users: formStub.getFieldValue(['users']) }, formStub.getFieldsValue());
3173
+ expect((runtime as any).findExplicitHit('users')).toBe('users');
3174
+ expect((runtime as any).findExplicitHit('users[0].name')).toBeNull();
3175
+ expect((runtime as any).findUserEditedHit('users')).toBe('users');
3176
+ expect((runtime as any).findUserEditedHit('users[0].name')).toBeNull();
2399
3177
 
2400
3178
  const row0: any = {
2401
3179
  uid: 'users.name',