@nocobase/client-v2 3.0.0-alpha.1 → 3.0.0-alpha.12

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 (133) hide show
  1. package/es/APIClient.d.ts +12 -0
  2. package/es/acl/aclCheckReadiness.d.ts +11 -0
  3. package/es/authRedirect.d.ts +6 -2
  4. package/es/flow/actions/linkageRules.d.ts +24 -0
  5. package/es/flow/components/FieldAssignValueInput.d.ts +4 -30
  6. package/es/flow/components/field-value-variable/DateVariableEditor.d.ts +24 -0
  7. package/es/flow/components/field-value-variable/FieldValueVariableInput.d.ts +31 -0
  8. package/es/flow/components/field-value-variable/dateValue.d.ts +36 -0
  9. package/es/flow/components/field-value-variable/index.d.ts +11 -0
  10. package/es/flow/components/placeholders/BlockPlaceholder.d.ts +1 -0
  11. package/es/flow/internal/utils/operatorSchemaHelper.d.ts +2 -2
  12. package/es/flow/models/actions/UpdateRecordActionUtils.d.ts +8 -2
  13. package/es/flow/models/base/CollectionBlockModel.d.ts +3 -0
  14. package/es/flow/models/blocks/details/DetailsBlockModel.d.ts +3 -0
  15. package/es/flow/models/blocks/filter-form/FilterFormGridModel.d.ts +1 -0
  16. package/es/flow/models/blocks/filter-form/FilterFormSubmitActionModel.d.ts +3 -1
  17. package/es/flow/models/blocks/form/FormBlockModel.d.ts +7 -0
  18. package/es/flow/models/blocks/form/FormGridModel.d.ts +6 -0
  19. package/es/flow/models/blocks/form/value-runtime/rules.d.ts +1 -0
  20. package/es/flow/models/blocks/table/JSColumnModel.d.ts +2 -0
  21. package/es/flow/models/blocks/table/TableBlockModel.d.ts +1 -0
  22. package/es/flow/models/blocks/table/TableColumnModel.d.ts +11 -0
  23. package/es/flow/models/blocks/table/utils.d.ts +1 -0
  24. package/es/flow/models/fields/AssociationFieldModel/RecordPickerFieldModel.d.ts +2 -1
  25. package/es/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.d.ts +0 -17
  26. package/es/flow/models/fields/AssociationFieldModel/recordSelectShared.d.ts +41 -0
  27. package/es/flow-compat/passwordUtils.d.ts +1 -1
  28. package/es/index.d.ts +2 -1
  29. package/es/index.mjs +140 -140
  30. package/es/nocobase-buildin-plugin/index.d.ts +1 -0
  31. package/es/settings-app/SettingsGroupNav.d.ts +3 -2
  32. package/es/settings-app/SettingsSearch.d.ts +1 -1
  33. package/es/ui-operation/index.d.ts +15 -0
  34. package/es/ui-operation/ui-operation-codec.d.ts +10 -0
  35. package/lib/index.js +145 -145
  36. package/package.json +7 -7
  37. package/src/APIClient.ts +92 -0
  38. package/src/Application.tsx +3 -1
  39. package/src/__tests__/APIClient.test.tsx +58 -0
  40. package/src/__tests__/app.test.tsx +122 -3
  41. package/src/__tests__/authRedirect.test.ts +17 -0
  42. package/src/__tests__/browserChecker.test.ts +81 -33
  43. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +37 -0
  44. package/src/__tests__/settings-center.test.tsx +312 -11
  45. package/src/__tests__/settings-layout-root.test.tsx +346 -6
  46. package/src/__tests__/settings-runtime-paths.test.ts +1 -1
  47. package/src/__tests__/settings-search-shortcut.test.tsx +321 -0
  48. package/src/__tests__/settings-shell.test.tsx +62 -1
  49. package/src/acl/ACLProvider.tsx +34 -3
  50. package/src/acl/__tests__/ACLProvider.test.tsx +92 -0
  51. package/src/acl/aclCheckReadiness.ts +44 -0
  52. package/src/authRedirect.ts +13 -5
  53. package/src/components/AppComponents.tsx +16 -7
  54. package/src/components/form/filter/CollectionFilterItem.tsx +4 -2
  55. package/src/components/form/filter/__tests__/CollectionFilterItem.test.tsx +17 -0
  56. package/src/components/form/table/Table.tsx +81 -6
  57. package/src/components/form/table/__tests__/Table.columnWidth.test.tsx +433 -0
  58. package/src/flow/actions/__tests__/linkageRules.actionStates.test.ts +33 -0
  59. package/src/flow/actions/__tests__/linkageRules.subFormSetFieldProps.test.ts +72 -0
  60. package/src/flow/actions/linkageRules.tsx +33 -10
  61. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuUtils.tsx +78 -59
  62. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +228 -0
  63. package/src/flow/components/FieldAssignValueInput.tsx +38 -621
  64. package/src/flow/components/field-value-variable/DateVariableEditor.tsx +181 -0
  65. package/src/flow/components/field-value-variable/FieldValueVariableInput.tsx +326 -0
  66. package/src/flow/components/field-value-variable/__tests__/FieldValueVariableInput.test.tsx +380 -0
  67. package/src/flow/components/field-value-variable/dateValue.ts +223 -0
  68. package/src/flow/components/field-value-variable/index.ts +12 -0
  69. package/src/flow/components/filter/VariableFilterItem.tsx +12 -3
  70. package/src/flow/components/filter/__tests__/VariableFilterItem.test.tsx +125 -0
  71. package/src/flow/components/placeholders/BlockPlaceholder.tsx +70 -23
  72. package/src/flow/components/placeholders/__tests__/BlockPlaceholder.test.tsx +57 -7
  73. package/src/flow/internal/utils/operatorSchemaHelper.ts +3 -2
  74. package/src/flow/models/actions/UpdateRecordActionModel.tsx +18 -1
  75. package/src/flow/models/actions/UpdateRecordActionUtils.ts +18 -6
  76. package/src/flow/models/actions/__tests__/UpdateRecordActionModel.test.ts +76 -4
  77. package/src/flow/models/base/CollectionBlockModel.tsx +32 -1
  78. package/src/flow/models/base/__tests__/CollectionBlockModel.initialBeforeRenderRefresh.test.ts +125 -9
  79. package/src/flow/models/blocks/assign-form/AssignFormItemModel.tsx +28 -53
  80. package/src/flow/models/blocks/details/DetailsBlockModel.tsx +8 -3
  81. package/src/flow/models/blocks/details/__tests__/DetailsBlockModel.initialPaginationChangeRefresh.test.ts +32 -1
  82. package/src/flow/models/blocks/filter-form/FilterFormGridModel.tsx +36 -5
  83. package/src/flow/models/blocks/filter-form/FilterFormItemModel.tsx +129 -14
  84. package/src/flow/models/blocks/filter-form/FilterFormSubmitActionModel.tsx +57 -4
  85. package/src/flow/models/blocks/filter-form/__tests__/FilterFormGridModel.onModelCreated.test.ts +174 -1
  86. package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.defineChildren.test.ts +359 -1
  87. package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.getFilterValue.test.ts +72 -0
  88. package/src/flow/models/blocks/filter-form/__tests__/FilterFormSubmitActionModel.lifecycle.test.ts +90 -0
  89. package/src/flow/models/blocks/filter-manager/FilterManager.ts +5 -0
  90. package/src/flow/models/blocks/filter-manager/__tests__/FilterManager.test.ts +40 -0
  91. package/src/flow/models/blocks/form/FormBlockModel.tsx +47 -0
  92. package/src/flow/models/blocks/form/FormGridModel.tsx +4 -0
  93. package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +8 -2
  94. package/src/flow/models/blocks/form/__tests__/runJsFormSubmit.test.ts +131 -0
  95. package/src/flow/models/blocks/form/value-runtime/__tests__/runtime.test.ts +435 -0
  96. package/src/flow/models/blocks/form/value-runtime/rules.ts +67 -14
  97. package/src/flow/models/blocks/form/value-runtime/runtime.ts +43 -19
  98. package/src/flow/models/blocks/table/JSColumnModel.tsx +10 -1
  99. package/src/flow/models/blocks/table/TableBlockModel.tsx +25 -1
  100. package/src/flow/models/blocks/table/TableColumnModel.tsx +18 -8
  101. package/src/flow/models/blocks/table/__tests__/JSColumnModel.test.tsx +52 -5
  102. package/src/flow/models/blocks/table/__tests__/TableBlockModel.filterReset.test.ts +78 -0
  103. package/src/flow/models/blocks/table/utils.ts +7 -0
  104. package/src/flow/models/fields/AssociationFieldModel/CascadeSelectFieldModel.tsx +33 -1
  105. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +8 -0
  106. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/__tests__/popupContext.test.ts +120 -0
  107. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/actions/PopupSubTableEditActionModel.tsx +10 -2
  108. package/src/flow/models/fields/AssociationFieldModel/RecordPickerFieldModel.tsx +24 -1
  109. package/src/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.tsx +9 -136
  110. package/src/flow/models/fields/AssociationFieldModel/__tests__/RecordPickerFieldModel.itemContext.test.ts +167 -3
  111. package/src/flow/models/fields/AssociationFieldModel/recordSelectShared.tsx +156 -0
  112. package/src/flow/models/fields/DisplayNumberFieldModel.tsx +1 -1
  113. package/src/flow/models/fields/NumberFieldModel.tsx +1 -1
  114. package/src/flow/models/fields/__tests__/DisplayNumberFieldModel.test.ts +33 -0
  115. package/src/flow/models/fields/__tests__/NumberFieldModel.test.tsx +47 -0
  116. package/src/flow/models/fields/mobile-components/MobileLazySelect.tsx +6 -0
  117. package/src/flow/models/fields/mobile-components/MobileSelect.tsx +27 -2
  118. package/src/flow/models/fields/mobile-components/__tests__/MobileSelect.test.tsx +139 -13
  119. package/src/index.ts +2 -0
  120. package/src/layout-manager/LayoutContentRoute.tsx +2 -1
  121. package/src/layout-manager/LayoutRoute.tsx +2 -1
  122. package/src/layout-manager/__tests__/LayoutRoute.test.tsx +87 -1
  123. package/src/nocobase-buildin-plugin/index.tsx +14 -1
  124. package/src/settings-app/SettingsGroupNav.tsx +11 -11
  125. package/src/settings-app/SettingsSearch.tsx +115 -10
  126. package/src/settings-app/SettingsShell.tsx +23 -6
  127. package/src/settings-app/runtimePaths.ts +0 -1
  128. package/src/settings-app/settingsTheme.ts +82 -14
  129. package/src/settings-center/AdminSettingsLayout.tsx +132 -107
  130. package/src/settings-center/__tests__/groups.test.ts +25 -0
  131. package/src/settings-center/groups.ts +1 -1
  132. package/src/ui-operation/index.ts +33 -0
  133. package/src/ui-operation/ui-operation-codec.ts +54 -0
@@ -21,6 +21,10 @@ export type DefaultFormGridStructure = {
21
21
  };
22
22
 
23
23
  export class FormGridModel<T extends DefaultFormGridStructure = DefaultFormGridStructure> extends GridModel<T> {
24
+ serialize() {
25
+ return { ...super.serialize(), variableContractType: { type: 'formGrid', use: this.use } };
26
+ }
27
+
24
28
  itemFallback = (<Skeleton.Input block size="small" style={{ marginBottom: '0.5rem' }} />);
25
29
  itemSettingsMenuLevel = 2;
26
30
  itemFlowSettings = {
@@ -465,11 +465,17 @@ describe('FormBlockModel (form/formValues injection & server resolve anchors)',
465
465
 
466
466
  it('builds non-empty contextParams for ctx.formValues.* deep association path', async () => {
467
467
  const model = await setupFormModel();
468
+ const sessionPayload = Buffer.from(JSON.stringify({ userId: 1, signInTime: 'form-record-slots' })).toString(
469
+ 'base64url',
470
+ );
468
471
  // 注入 api mock 到引擎上下文,拦截 variables:resolve 的请求
469
472
  const api = {
473
+ auth: { token: `test.${sessionPayload}.sig` },
470
474
  request: vi.fn(async (config: any) => {
471
- const payload = config?.data?.values || {};
472
- const batch = payload.batch || [];
475
+ const requestValues = config?.data?.values || {};
476
+ const batch = requestValues.batch || [];
477
+ expect(batch[0]?.rd).toEqual(expect.any(String));
478
+ expect(batch[0]?.template).toEqual({ who: '{{ ctx.formValues.assignees.org.name }}' });
473
479
  const cp = batch[0]?.contextParams || {};
474
480
  const keys = Object.keys(cp).sort();
475
481
  // 聚合为单键,不再使用索引键
@@ -0,0 +1,131 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ import { FlowEngine } from '@nocobase/flow-engine';
11
+ import { describe, expect, it, vi } from 'vitest';
12
+ import { CreateFormModel } from '../CreateFormModel';
13
+ import { EditFormModel } from '../EditFormModel';
14
+ import { FormActionModel } from '../FormActionModel';
15
+
16
+ function prepareFormModel<T extends CreateFormModel | EditFormModel>(model: T, publicFormRuntime = false) {
17
+ const engine = new FlowEngine();
18
+ engine.registerModels({ CreateFormModel, EditFormModel });
19
+ model.flowEngine = engine;
20
+ Object.defineProperty(model, 'context', {
21
+ value: {
22
+ publicFormRuntime,
23
+ message: { error: vi.fn() },
24
+ t: (value: string) => value,
25
+ },
26
+ });
27
+ return model;
28
+ }
29
+
30
+ function createSubmitAction() {
31
+ const onClick = vi.fn();
32
+ const action = {
33
+ getFlow: vi.fn((key: string) => (key === 'submitSettings' ? {} : undefined)),
34
+ onClick,
35
+ } as unknown as FormActionModel;
36
+ return { action, onClick };
37
+ }
38
+
39
+ describe('FormBlockModel.submitFromRunJs', () => {
40
+ it('dispatches the first action with a submitSettings flow', () => {
41
+ const { action, onClick } = createSubmitAction();
42
+ const { action: secondAction, onClick: secondOnClick } = createSubmitAction();
43
+ const blockModel = prepareFormModel(
44
+ Object.assign(Object.create(CreateFormModel.prototype), {
45
+ mapSubModels: (_key: string, callback: (item: FormActionModel) => FormActionModel) => [
46
+ callback(action),
47
+ callback(secondAction),
48
+ ],
49
+ }) as CreateFormModel,
50
+ );
51
+
52
+ blockModel.submitFromRunJs();
53
+
54
+ expect(onClick).toHaveBeenCalledWith(undefined);
55
+ expect(secondOnClick).not.toHaveBeenCalled();
56
+ });
57
+
58
+ it('does not dispatch an edit submit action without an available record', () => {
59
+ const { action, onClick } = createSubmitAction();
60
+ const blockModel = prepareFormModel(
61
+ Object.assign(Object.create(EditFormModel.prototype), {
62
+ hasAvailableData: () => false,
63
+ mapSubModels: (_key: string, callback: (item: FormActionModel) => FormActionModel) => [callback(action)],
64
+ }) as EditFormModel,
65
+ );
66
+
67
+ blockModel.submitFromRunJs();
68
+
69
+ expect(onClick).not.toHaveBeenCalled();
70
+ });
71
+
72
+ it('falls back to the core form submit handler when no submit action exists', () => {
73
+ const submit = vi.fn().mockResolvedValue(undefined);
74
+ const blockModel = prepareFormModel(
75
+ Object.assign(Object.create(CreateFormModel.prototype), {
76
+ mapSubModels: vi.fn(() => []),
77
+ submit,
78
+ }) as CreateFormModel,
79
+ );
80
+
81
+ blockModel.submitFromRunJs();
82
+
83
+ expect(submit).toHaveBeenCalledOnce();
84
+ });
85
+
86
+ it('falls back to the core edit submit handler when a record exists', () => {
87
+ const submit = vi.fn().mockResolvedValue(undefined);
88
+ const blockModel = prepareFormModel(
89
+ Object.assign(Object.create(EditFormModel.prototype), {
90
+ hasAvailableData: () => true,
91
+ mapSubModels: vi.fn(() => []),
92
+ submit,
93
+ }) as EditFormModel,
94
+ );
95
+
96
+ blockModel.submitFromRunJs();
97
+
98
+ expect(submit).toHaveBeenCalledOnce();
99
+ });
100
+
101
+ it('uses the core fallback for specialized form subclasses', () => {
102
+ class SpecializedCreateFormModel extends CreateFormModel {}
103
+
104
+ const submit = vi.fn().mockResolvedValue(undefined);
105
+ const blockModel = prepareFormModel(
106
+ Object.assign(Object.create(SpecializedCreateFormModel.prototype), {
107
+ mapSubModels: vi.fn(() => []),
108
+ submit,
109
+ }) as SpecializedCreateFormModel,
110
+ );
111
+
112
+ blockModel.submitFromRunJs();
113
+
114
+ expect(submit).toHaveBeenCalledOnce();
115
+ });
116
+
117
+ it('does not use the core fallback for public forms without a submit action', () => {
118
+ const submit = vi.fn().mockResolvedValue(undefined);
119
+ const blockModel = prepareFormModel(
120
+ Object.assign(Object.create(CreateFormModel.prototype), {
121
+ mapSubModels: vi.fn(() => []),
122
+ submit,
123
+ }) as CreateFormModel,
124
+ true,
125
+ );
126
+
127
+ blockModel.submitFromRunJs();
128
+
129
+ expect(submit).not.toHaveBeenCalled();
130
+ });
131
+ });
@@ -532,6 +532,43 @@ describe('FormValueRuntime (default rules)', () => {
532
532
  });
533
533
 
534
534
  describe('FormValueRuntime (form assign rules)', () => {
535
+ it('uses the form grid as the variable contract owner', async () => {
536
+ const engineEmitter = new EventEmitter();
537
+ const formStub = createFormStub({ b: 'licensed' });
538
+ const onResolveJsonTemplate = vi.fn();
539
+ const blockModel: any = {
540
+ uid: 'form-contract-owner',
541
+ subModels: { grid: { uid: 'form-contract-owner-grid' } },
542
+ flowEngine: { emitter: engineEmitter },
543
+ emitter: new EventEmitter(),
544
+ dispatchEvent: vi.fn(),
545
+ getAclActionName: () => 'create',
546
+ };
547
+ const runtime = new FormValueRuntime({ model: blockModel, getForm: () => formStub as any });
548
+ const blockCtx = createFieldContext(runtime);
549
+ const resolveJsonTemplate = blockCtx.resolveJsonTemplate.bind(blockCtx);
550
+ blockCtx.defineMethod('resolveJsonTemplate', async (template: unknown, options?: unknown) => {
551
+ onResolveJsonTemplate(options);
552
+ return resolveJsonTemplate(template, options);
553
+ });
554
+ blockModel.context = blockCtx;
555
+ runtime.mount({ sync: true });
556
+
557
+ runtime.syncAssignRules([
558
+ {
559
+ key: 'license-version',
560
+ enable: true,
561
+ targetPath: 'a',
562
+ mode: 'assign',
563
+ condition: { logic: '$and', items: [] },
564
+ value: '__B__',
565
+ },
566
+ ]);
567
+
568
+ await waitFor(() => expect(formStub.getFieldValue(['a'])).toBe('licensed'));
569
+ expect(onResolveJsonTemplate).toHaveBeenCalledWith({ contractModelUid: 'form-contract-owner-grid' });
570
+ });
571
+
535
572
  it('migrates block-level rule to field instance on mount and restores on unmount', async () => {
536
573
  const engineEmitter = new EventEmitter();
537
574
  const blockEmitter = new EventEmitter();
@@ -4086,6 +4123,404 @@ describe('FormValueRuntime (form assign rules)', () => {
4086
4123
  expect(formStub.getFieldValue(['users', 0, 'user', 'id'])).toBe(1);
4087
4124
  });
4088
4125
 
4126
+ it('assigns a to-one association from a sibling association in the same to-many row', async () => {
4127
+ const engineEmitter = new EventEmitter();
4128
+ const blockEmitter = new EventEmitter();
4129
+ const secondary = { id: 2, name: 'Secondary' };
4130
+ const formStub = createFormStub({ users: [{ user: null, secondaryUser: null }] });
4131
+
4132
+ const blockModel: any = {
4133
+ uid: 'form-assign-assoc-to-many-sibling',
4134
+ flowEngine: { emitter: engineEmitter },
4135
+ emitter: blockEmitter,
4136
+ dispatchEvent: vi.fn(),
4137
+ getAclActionName: () => 'create',
4138
+ };
4139
+
4140
+ const runtime = new FormValueRuntime({ model: blockModel, getForm: () => formStub as any });
4141
+ runtime.mount({ sync: true });
4142
+
4143
+ const blockCtx = createFieldContext(runtime);
4144
+ const userCollection: any = { getField: () => null };
4145
+ const userField: any = { isAssociationField: () => true, type: 'belongsTo', targetCollection: userCollection };
4146
+ const usersItemCollection: any = {
4147
+ getField: (name: string) => (name === 'user' || name === 'secondaryUser' ? userField : null),
4148
+ };
4149
+ const usersField: any = { isAssociationField: () => true, type: 'hasMany', targetCollection: usersItemCollection };
4150
+ const collection: any = { getField: (name: string) => (name === 'users' ? usersField : null) };
4151
+ blockCtx.defineProperty('collection', { value: collection });
4152
+
4153
+ const rowModel: any = {
4154
+ uid: 'users.user:users:0',
4155
+ isFork: true,
4156
+ forkId: 'users:0',
4157
+ subModels: { field: { context: { collectionField: userField } } },
4158
+ getStepParams(flowKey: string, stepKey: string) {
4159
+ if (flowKey === 'fieldSettings' && stepKey === 'init') return { fieldPath: 'users.user' };
4160
+ return undefined;
4161
+ },
4162
+ };
4163
+ const rowCtx = createFieldContext(runtime);
4164
+ rowCtx.defineProperty('blockModel', { value: blockModel });
4165
+ rowCtx.defineProperty('collection', { value: collection });
4166
+ rowCtx.defineProperty('fieldIndex', { value: ['users:0'] });
4167
+ rowCtx.defineProperty('model', { value: rowModel });
4168
+ rowModel.context = rowCtx;
4169
+
4170
+ blockCtx.defineProperty('engine', {
4171
+ value: { forEachModel: (callback: (model: unknown) => void) => callback(rowModel) },
4172
+ });
4173
+ blockModel.context = blockCtx;
4174
+
4175
+ runtime.syncAssignRules([
4176
+ {
4177
+ key: 'r1',
4178
+ enable: true,
4179
+ targetPath: 'users.user',
4180
+ mode: 'assign',
4181
+ condition: { logic: '$and', items: [] },
4182
+ value: '{{ ctx.item.parentItem.value.secondaryUser }}',
4183
+ },
4184
+ ]);
4185
+
4186
+ await runtime.setFormValues(blockCtx, [{ path: ['users', 0, 'secondaryUser'], value: secondary }], {
4187
+ source: 'user',
4188
+ });
4189
+ await waitFor(() => expect(formStub.getFieldValue(['users', 0, 'user'])).toEqual(secondary));
4190
+
4191
+ await runtime.setFormValues(blockCtx, [{ path: ['users', 0, 'user'], value: { id: 3, name: 'Manual' } }], {
4192
+ source: 'user',
4193
+ });
4194
+ await waitFor(() => expect(formStub.getFieldValue(['users', 0, 'user'])).toEqual(secondary));
4195
+
4196
+ const nextSecondary = { id: 4, name: 'Next secondary' };
4197
+ await runtime.setFormValues(blockCtx, [{ path: ['users', 0, 'secondaryUser'], value: nextSecondary }], {
4198
+ source: 'user',
4199
+ });
4200
+ await waitFor(() => expect(formStub.getFieldValue(['users', 0, 'user'])).toEqual(nextSecondary));
4201
+ });
4202
+
4203
+ it('materializes assigned proxies so later to-many rows continue updating', async () => {
4204
+ const engineEmitter = new EventEmitter();
4205
+ const blockEmitter = new EventEmitter();
4206
+ const initialSecondary = { id: 2, name: 'Initial secondary', roleCodes: ['admin'] };
4207
+ const nextSecondary = { id: 4, name: 'Next secondary', roleCodes: ['editor'] };
4208
+ type UserRow = {
4209
+ user: unknown;
4210
+ secondaryUser: typeof initialSecondary | null;
4211
+ };
4212
+ const store: { users: UserRow[] } = {
4213
+ users: [{ user: null, secondaryUser: initialSecondary }],
4214
+ };
4215
+ type FormNamePath = Parameters<FormInstance['getFieldValue']>[0];
4216
+ const formStub = {
4217
+ getFieldValue: (namePath: FormNamePath) => lodashGet(store, namePath),
4218
+ setFieldValue: (namePath: FormNamePath, value: unknown) => lodashSet(store, namePath, value),
4219
+ // Match Ant Form: the outer store is plain, but nested values may still be reactive proxies.
4220
+ getFieldsValue: () => store,
4221
+ setFieldsValue: (patch: Partial<typeof store>) => lodashMerge(store, patch),
4222
+ };
4223
+
4224
+ const blockModel = {
4225
+ uid: 'form-assign-assoc-to-many-later-row-reactive-proxy',
4226
+ flowEngine: { emitter: engineEmitter },
4227
+ emitter: blockEmitter,
4228
+ dispatchEvent: vi.fn(),
4229
+ getAclActionName: () => 'create',
4230
+ context: undefined as unknown,
4231
+ };
4232
+
4233
+ const runtime = new FormValueRuntime({
4234
+ model: blockModel as unknown as ConstructorParameters<typeof FormValueRuntime>[0]['model'],
4235
+ getForm: () => formStub as unknown as FormInstance,
4236
+ });
4237
+ runtime.mount({ sync: true });
4238
+
4239
+ const blockCtx = createFieldContext(runtime);
4240
+ const userCollection = { getField: () => null };
4241
+ const userField = { isAssociationField: () => true, type: 'belongsTo', targetCollection: userCollection };
4242
+ const usersItemCollection = {
4243
+ getField: (name: string) => (name === 'user' || name === 'secondaryUser' ? userField : null),
4244
+ };
4245
+ const usersField = { isAssociationField: () => true, type: 'hasMany', targetCollection: usersItemCollection };
4246
+ const collection = { getField: (name: string) => (name === 'users' ? usersField : null) };
4247
+ blockCtx.defineProperty('collection', { value: collection });
4248
+
4249
+ const createRowModel = (index: number) => {
4250
+ const forkId = `users:${index}`;
4251
+ const rowCtx = createFieldContext(runtime);
4252
+ const rowModel = {
4253
+ uid: `users.user:${forkId}`,
4254
+ isFork: true,
4255
+ forkId,
4256
+ subModels: { field: { context: { collectionField: userField } } },
4257
+ getStepParams(flowKey: string, stepKey: string) {
4258
+ if (flowKey === 'fieldSettings' && stepKey === 'init') return { fieldPath: 'users.user' };
4259
+ return undefined;
4260
+ },
4261
+ context: rowCtx,
4262
+ };
4263
+ rowCtx.defineProperty('blockModel', { value: blockModel });
4264
+ rowCtx.defineProperty('collection', { value: collection });
4265
+ rowCtx.defineProperty('fieldIndex', { value: [forkId] });
4266
+ rowCtx.defineProperty('model', { value: rowModel });
4267
+ return rowModel;
4268
+ };
4269
+ const rowModels = [createRowModel(0)];
4270
+
4271
+ blockCtx.defineProperty('engine', {
4272
+ value: { forEachModel: (callback: (model: unknown) => void) => rowModels.forEach(callback) },
4273
+ });
4274
+ blockModel.context = blockCtx;
4275
+
4276
+ runtime.syncAssignRules([
4277
+ {
4278
+ key: 'user-from-row-sibling',
4279
+ enable: true,
4280
+ targetPath: 'users.user',
4281
+ mode: 'assign',
4282
+ condition: { logic: '$and', items: [] },
4283
+ value: '{{ ctx.item.parentItem.value.secondaryUser }}',
4284
+ },
4285
+ ]);
4286
+
4287
+ await waitFor(() => expect(formStub.getFieldValue(['users', 0, 'user'])).toEqual(initialSecondary));
4288
+ const assignedUser = formStub.getFieldValue(['users', 0, 'user']);
4289
+ const assignedRoleCodes = formStub.getFieldValue(['users', 0, 'user', 'roleCodes']);
4290
+ expect(assignedUser.constructor).toBe(Object);
4291
+ expect(Array.isArray(assignedRoleCodes)).toBe(true);
4292
+ expect(assignedRoleCodes.constructor).toBe(Array);
4293
+
4294
+ const secondRow: UserRow = { user: null, secondaryUser: null };
4295
+ store.users.push(secondRow);
4296
+ const addedRows: Array<UserRow | undefined> = new Array(2);
4297
+ addedRows[1] = secondRow;
4298
+ expect(() => runtime.handleFormValuesChange({ users: addedRows }, store)).not.toThrow();
4299
+
4300
+ const row1 = createRowModel(1);
4301
+ rowModels.push(row1);
4302
+ engineEmitter.emit('model:mounted', { model: row1 });
4303
+
4304
+ secondRow.secondaryUser = nextSecondary;
4305
+ const changedRows: Array<Partial<UserRow> | undefined> = new Array(2);
4306
+ changedRows[1] = { secondaryUser: nextSecondary };
4307
+ expect(0 in changedRows).toBe(false);
4308
+
4309
+ expect(() => runtime.handleFormValuesChange({ users: changedRows }, store)).not.toThrow();
4310
+ await waitFor(() => expect(formStub.getFieldValue(['users', 1, 'user'])).toEqual(nextSecondary));
4311
+ expect(formStub.getFieldValue(['users', 0, 'user'])).toEqual(initialSecondary);
4312
+ });
4313
+
4314
+ it('assigns a to-many association from a sibling association in the same to-many row', async () => {
4315
+ const engineEmitter = new EventEmitter();
4316
+ const blockEmitter = new EventEmitter();
4317
+ const initialRoles = [{ id: 2, name: 'Initial role' }];
4318
+ const formStub = createFormStub({ users: [{ roles: [], secondaryRoles: [] }] });
4319
+
4320
+ const blockModel: any = {
4321
+ uid: 'form-assign-to-many-assoc-from-to-many-row-sibling',
4322
+ flowEngine: { emitter: engineEmitter },
4323
+ emitter: blockEmitter,
4324
+ dispatchEvent: vi.fn(),
4325
+ getAclActionName: () => 'create',
4326
+ };
4327
+
4328
+ const runtime = new FormValueRuntime({ model: blockModel, getForm: () => formStub as any });
4329
+ runtime.mount({ sync: true });
4330
+
4331
+ const blockCtx = createFieldContext(runtime);
4332
+ const roleCollection: any = { filterTargetKey: 'id', getField: () => null };
4333
+ const rolesField: any = {
4334
+ isAssociationField: () => true,
4335
+ type: 'belongsToMany',
4336
+ targetCollection: roleCollection,
4337
+ };
4338
+ const usersItemCollection: any = {
4339
+ getField: (name: string) => (name === 'roles' || name === 'secondaryRoles' ? rolesField : null),
4340
+ };
4341
+ const usersField: any = { isAssociationField: () => true, type: 'hasMany', targetCollection: usersItemCollection };
4342
+ const collection: any = { getField: (name: string) => (name === 'users' ? usersField : null) };
4343
+ blockCtx.defineProperty('collection', { value: collection });
4344
+
4345
+ const rowModel: any = {
4346
+ uid: 'users.roles:users:0',
4347
+ isFork: true,
4348
+ forkId: 'users:0',
4349
+ subModels: { field: { context: { collectionField: rolesField } } },
4350
+ getStepParams(flowKey: string, stepKey: string) {
4351
+ if (flowKey === 'fieldSettings' && stepKey === 'init') return { fieldPath: 'users.roles' };
4352
+ return undefined;
4353
+ },
4354
+ };
4355
+ const rowCtx = createFieldContext(runtime);
4356
+ rowCtx.defineProperty('blockModel', { value: blockModel });
4357
+ rowCtx.defineProperty('collection', { value: collection });
4358
+ rowCtx.defineProperty('fieldIndex', { value: ['users:0'] });
4359
+ rowCtx.defineProperty('model', { value: rowModel });
4360
+ rowModel.context = rowCtx;
4361
+
4362
+ blockCtx.defineProperty('engine', {
4363
+ value: { forEachModel: (callback: (model: unknown) => void) => callback(rowModel) },
4364
+ });
4365
+ blockModel.context = blockCtx;
4366
+
4367
+ runtime.syncAssignRules([
4368
+ {
4369
+ key: 'roles-from-row-sibling',
4370
+ enable: true,
4371
+ targetPath: 'users.roles',
4372
+ mode: 'assign',
4373
+ condition: { logic: '$and', items: [] },
4374
+ value: '{{ ctx.item.parentItem.value.secondaryRoles }}',
4375
+ },
4376
+ ]);
4377
+
4378
+ await runtime.setFormValues(blockCtx, [{ path: ['users', 0, 'secondaryRoles'], value: initialRoles }], {
4379
+ source: 'user',
4380
+ });
4381
+ await waitFor(() => expect(formStub.getFieldValue(['users', 0, 'roles'])).toEqual(initialRoles));
4382
+
4383
+ const nextRoles = [{ id: 3, name: 'Next role' }];
4384
+ await runtime.setFormValues(blockCtx, [{ path: ['users', 0, 'secondaryRoles'], value: nextRoles }], {
4385
+ source: 'user',
4386
+ });
4387
+ await waitFor(() => expect(formStub.getFieldValue(['users', 0, 'roles'])).toEqual(nextRoles));
4388
+ });
4389
+
4390
+ it('preserves the PopupSubTable outer item chain for a top-level to-one association target', async () => {
4391
+ const engineEmitter = new EventEmitter();
4392
+ const blockEmitter = new EventEmitter();
4393
+ const initialAssignee = { id: 2, name: 'Initial assignee' };
4394
+ const nextAssignee = { id: 3, name: 'Next assignee' };
4395
+ const outerValue = observable({ defaultAssignee: initialAssignee });
4396
+ const formStub = createFormStub({ assignee: null });
4397
+
4398
+ const blockModel: any = {
4399
+ uid: 'popup-sub-table-form-assign-parent-item',
4400
+ flowEngine: { emitter: engineEmitter },
4401
+ emitter: blockEmitter,
4402
+ dispatchEvent: vi.fn(),
4403
+ getAclActionName: () => 'create',
4404
+ };
4405
+
4406
+ const runtime = new FormValueRuntime({ model: blockModel, getForm: () => formStub as any });
4407
+ runtime.mount({ sync: true });
4408
+
4409
+ const blockCtx = createFieldContext(runtime);
4410
+ const userCollection: any = { filterTargetKey: 'id', getField: () => null };
4411
+ const assigneeField: any = {
4412
+ isAssociationField: () => true,
4413
+ type: 'belongsTo',
4414
+ targetCollection: userCollection,
4415
+ };
4416
+ const collection: any = { getField: (name: string) => (name === 'assignee' ? assigneeField : null) };
4417
+ const outerItem = { index: 0, length: 1, value: outerValue };
4418
+ blockCtx.defineProperty('collection', { value: collection });
4419
+ blockCtx.defineProperty('item', {
4420
+ get: () => ({
4421
+ index: 1,
4422
+ length: 3,
4423
+ __is_new__: true,
4424
+ __is_stored__: false,
4425
+ value: blockCtx.formValues,
4426
+ parentItem: outerItem,
4427
+ }),
4428
+ cache: false,
4429
+ });
4430
+ blockModel.context = blockCtx;
4431
+
4432
+ runtime.syncAssignRules([
4433
+ {
4434
+ key: 'assignee-from-popup-parent',
4435
+ enable: true,
4436
+ targetPath: 'assignee',
4437
+ mode: 'assign',
4438
+ condition: {
4439
+ logic: '$and',
4440
+ items: [
4441
+ { path: '{{ ctx.item.parentItem.index }}', operator: '$eq', value: 1 },
4442
+ { path: '{{ ctx.item.parentItem.length }}', operator: '$eq', value: 3 },
4443
+ { path: '{{ ctx.item.parentItem.__is_new__ }}', operator: '$eq', value: true },
4444
+ { path: '{{ ctx.item.parentItem.__is_stored__ }}', operator: '$eq', value: false },
4445
+ ],
4446
+ },
4447
+ value: '{{ ctx.item.parentItem.parentItem.value.defaultAssignee }}',
4448
+ },
4449
+ ]);
4450
+
4451
+ await waitFor(() => expect(formStub.getFieldValue(['assignee'])).toEqual(initialAssignee));
4452
+
4453
+ outerValue.defaultAssignee = nextAssignee;
4454
+ await waitFor(() => expect(formStub.getFieldValue(['assignee'])).toEqual(nextAssignee));
4455
+ });
4456
+
4457
+ it('preserves the PopupSubTable outer item chain for a top-level to-many association target', async () => {
4458
+ const engineEmitter = new EventEmitter();
4459
+ const blockEmitter = new EventEmitter();
4460
+ const initialReviewers = [{ id: 2, name: 'Initial reviewer' }];
4461
+ const nextReviewers = [{ id: 3, name: 'Next reviewer' }];
4462
+ const outerValue = observable({ defaultReviewers: initialReviewers });
4463
+ const formStub = createFormStub({ reviewers: [] });
4464
+
4465
+ const blockModel: any = {
4466
+ uid: 'popup-sub-table-form-assign-to-many-parent-item',
4467
+ flowEngine: { emitter: engineEmitter },
4468
+ emitter: blockEmitter,
4469
+ dispatchEvent: vi.fn(),
4470
+ getAclActionName: () => 'create',
4471
+ };
4472
+
4473
+ const runtime = new FormValueRuntime({ model: blockModel, getForm: () => formStub as any });
4474
+ runtime.mount({ sync: true });
4475
+
4476
+ const blockCtx = createFieldContext(runtime);
4477
+ const userCollection: any = { filterTargetKey: 'id', getField: () => null };
4478
+ const reviewersField: any = {
4479
+ isAssociationField: () => true,
4480
+ type: 'belongsToMany',
4481
+ targetCollection: userCollection,
4482
+ };
4483
+ const collection: any = { getField: (name: string) => (name === 'reviewers' ? reviewersField : null) };
4484
+ const outerItem = { index: 0, length: 1, value: outerValue };
4485
+ blockCtx.defineProperty('collection', { value: collection });
4486
+ blockCtx.defineProperty('item', {
4487
+ get: () => ({
4488
+ index: 1,
4489
+ length: 3,
4490
+ __is_new__: true,
4491
+ __is_stored__: false,
4492
+ value: blockCtx.formValues,
4493
+ parentItem: outerItem,
4494
+ }),
4495
+ cache: false,
4496
+ });
4497
+ blockModel.context = blockCtx;
4498
+
4499
+ runtime.syncAssignRules([
4500
+ {
4501
+ key: 'reviewers-from-popup-parent',
4502
+ enable: true,
4503
+ targetPath: 'reviewers',
4504
+ mode: 'assign',
4505
+ condition: {
4506
+ logic: '$and',
4507
+ items: [
4508
+ { path: '{{ ctx.item.parentItem.index }}', operator: '$eq', value: 1 },
4509
+ { path: '{{ ctx.item.parentItem.length }}', operator: '$eq', value: 3 },
4510
+ { path: '{{ ctx.item.parentItem.__is_new__ }}', operator: '$eq', value: true },
4511
+ { path: '{{ ctx.item.parentItem.__is_stored__ }}', operator: '$eq', value: false },
4512
+ ],
4513
+ },
4514
+ value: '{{ ctx.item.parentItem.parentItem.value.defaultReviewers }}',
4515
+ },
4516
+ ]);
4517
+
4518
+ await waitFor(() => expect(formStub.getFieldValue(['reviewers'])).toEqual(initialReviewers));
4519
+
4520
+ outerValue.defaultReviewers = nextReviewers;
4521
+ await waitFor(() => expect(formStub.getFieldValue(['reviewers'])).toEqual(nextReviewers));
4522
+ });
4523
+
4089
4524
  it('does not write to to-many nested path without row index', async () => {
4090
4525
  const engineEmitter = new EventEmitter();
4091
4526
  const blockEmitter = new EventEmitter();