@nocobase/client-v2 2.1.35 → 2.1.37

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 (28) hide show
  1. package/es/flow/actions/linkageRules.d.ts +24 -0
  2. package/es/flow/components/FieldAssignValueInput.d.ts +2 -23
  3. package/es/flow/components/field-value-variable/DateVariableEditor.d.ts +23 -0
  4. package/es/flow/components/field-value-variable/FieldValueVariableInput.d.ts +28 -0
  5. package/es/flow/components/field-value-variable/dateValue.d.ts +36 -0
  6. package/es/flow/components/field-value-variable/index.d.ts +11 -0
  7. package/es/index.mjs +64 -73
  8. package/lib/index.js +92 -101
  9. package/package.json +7 -7
  10. package/src/flow/actions/__tests__/linkageRules.actionStates.test.ts +33 -0
  11. package/src/flow/actions/linkageRules.tsx +25 -7
  12. package/src/flow/components/FieldAssignValueInput.tsx +34 -571
  13. package/src/flow/components/field-value-variable/DateVariableEditor.tsx +162 -0
  14. package/src/flow/components/field-value-variable/FieldValueVariableInput.tsx +306 -0
  15. package/src/flow/components/field-value-variable/__tests__/FieldValueVariableInput.test.tsx +380 -0
  16. package/src/flow/components/field-value-variable/dateValue.ts +223 -0
  17. package/src/flow/components/field-value-variable/index.ts +12 -0
  18. package/src/flow/models/blocks/assign-form/AssignFormItemModel.tsx +28 -53
  19. package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +8 -2
  20. package/src/flow/models/blocks/form/value-runtime/__tests__/runtime.test.ts +398 -0
  21. package/src/flow/models/blocks/form/value-runtime/rules.ts +39 -9
  22. package/src/flow/models/blocks/form/value-runtime/runtime.ts +39 -19
  23. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +8 -0
  24. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/__tests__/popupContext.test.ts +120 -0
  25. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/actions/PopupSubTableEditActionModel.tsx +10 -2
  26. package/src/flow/models/fields/AssociationFieldModel/__tests__/RecordPickerFieldModel.itemContext.test.ts +46 -0
  27. package/src/flow/models/fields/mobile-components/MobileLazySelect.tsx +1 -0
  28. package/src/flow/models/fields/mobile-components/__tests__/MobileSelect.test.tsx +20 -2
@@ -10,23 +10,20 @@
10
10
  import React from 'react';
11
11
  import { Input } from 'antd';
12
12
  import { define, observable } from '@formily/reactive';
13
- import {
14
- FlowModelRenderer,
15
- FormItem,
16
- VariableInput,
17
- tExpr,
18
- isVariableExpression,
19
- parseValueToPath,
20
- isRunJSValue,
21
- EditableItemModel,
22
- jioToJoiSchema,
23
- } from '@nocobase/flow-engine';
13
+ import { FlowModelRenderer, FormItem, tExpr, EditableItemModel, jioToJoiSchema } from '@nocobase/flow-engine';
24
14
  // 无需类型导入(避免未使用的类型)
25
15
  import { FormItemModel } from '../form/FormItemModel';
26
16
  import { EditFormModel } from '../form/EditFormModel';
27
17
  import { customAlphabet as Alphabet } from 'nanoid';
28
18
  import { ensureOptionsFromUiSchemaEnumIfAbsent } from '../../../internal/utils/enumOptionsUtils';
29
19
  import { RunJSValueEditor } from '../../../components/RunJSValueEditor';
20
+ import {
21
+ DEFAULT_DATE_VARIABLE_COMPONENT_PROPS,
22
+ FieldValueVariableInput,
23
+ getFieldInterface,
24
+ isDateLikeField,
25
+ resolveDateVariableComponentProps,
26
+ } from '../../../components/field-value-variable';
30
27
 
31
28
  type AssignFormTempOriginField = {
32
29
  uid?: string;
@@ -285,7 +282,9 @@ export class AssignFormItemModel extends FormItemModel {
285
282
  ) : null;
286
283
  };
287
284
 
288
- const NullComponent: React.FC = () => <Input placeholder={'<Null>'} readOnly style={{ width: '100%' }} />;
285
+ const NullComponent: React.FC = () => (
286
+ <Input placeholder={`<${this.context.t?.('Null') ?? 'Null'}>`} readOnly style={{ width: '100%' }} />
287
+ );
289
288
 
290
289
  const RunJSComponent: React.FC<any> = (inputProps: any) => {
291
290
  return (
@@ -298,46 +297,18 @@ export class AssignFormItemModel extends FormItemModel {
298
297
  );
299
298
  };
300
299
 
301
- const converters = {
302
- renderInputComponent: (meta: any) => {
303
- const firstPath = meta?.paths?.[0];
304
- if (firstPath === 'constant') return ConstantValueEditor as any;
305
- if (firstPath === 'null') return NullComponent as any;
306
- if (firstPath === 'runjs') return RunJSComponent as any;
307
- return undefined;
308
- },
309
- resolveValueFromPath: (item: any) => {
310
- const firstPath = item?.paths?.[0];
311
- if (firstPath === 'constant') return '';
312
- if (firstPath === 'null') return null;
313
- if (firstPath === 'runjs') return { code: '', version: 'v2' };
314
- return undefined;
315
- },
316
- resolvePathFromValue: (currentValue: any) => {
317
- if (currentValue === null) return ['null'];
318
- if (isRunJSValue(currentValue)) return ['runjs'];
319
- return isVariableExpression(currentValue) ? parseValueToPath(currentValue) : ['constant'];
320
- },
321
- } as any;
322
-
323
- // 合并变量树:在最前面追加“常量/空值”两个选项
324
- const mergedMetaTree = async () => {
300
+ const baseMetaTree = async () => {
325
301
  const getTree = (this.context as any)?.getPropertyMetaTree;
326
- const base: any[] = typeof getTree === 'function' ? await getTree() : [];
327
- return [
328
- {
329
- title: tExpr('Constant'),
330
- name: 'constant',
331
- type: 'string',
332
- paths: ['constant'],
333
- render: ConstantValueEditor,
334
- },
335
- { title: tExpr('Null'), name: 'null', type: 'object', paths: ['null'], render: NullComponent },
336
- { title: tExpr('RunJS'), name: 'runjs', type: 'object', paths: ['runjs'], render: RunJSComponent },
337
- ...base,
338
- ];
302
+ return typeof getTree === 'function' ? await getTree() : [];
339
303
  };
340
304
 
305
+ const targetCollectionField = collection?.getField?.(this.fieldPath);
306
+ const targetFieldInterface = getFieldInterface(targetCollectionField);
307
+ const dateLike = isDateLikeField(targetCollectionField, this.fieldPath?.split('.').slice(-1)[0]);
308
+ const dateComponentProps = dateLike
309
+ ? resolveDateVariableComponentProps(targetCollectionField, targetFieldInterface)
310
+ : DEFAULT_DATE_VARIABLE_COMPONENT_PROPS;
311
+
341
312
  // 计算 label:优先使用配置中的 label,其次集合字段标题,最后回退字段路径
342
313
  let labelText = this.props?.label ?? this.fieldPath ?? '';
343
314
  const cf = collection?.getField?.(this.fieldPath);
@@ -357,14 +328,18 @@ export class AssignFormItemModel extends FormItemModel {
357
328
  const formValue = formBindingProps?.__assign_value__;
358
329
  const mergedValue = typeof formValue === 'undefined' ? this.assignValue : formValue;
359
330
  return (
360
- <VariableInput
331
+ <FieldValueVariableInput
361
332
  value={mergedValue}
362
- onChange={(v: any) => {
333
+ onChange={(v) => {
363
334
  this.assignValue = v;
364
335
  formBindingProps?.__assign_trigger__?.(v);
365
336
  }}
366
- metaTree={mergedMetaTree}
367
- converters={converters}
337
+ baseMetaTree={baseMetaTree}
338
+ constantComponent={ConstantValueEditor}
339
+ nullComponent={NullComponent}
340
+ runJSComponent={RunJSComponent}
341
+ isDateLikeField={dateLike}
342
+ dateComponentProps={dateComponentProps}
368
343
  clearValue={''}
369
344
  />
370
345
  );
@@ -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
  // 聚合为单键,不再使用索引键
@@ -3999,6 +3999,404 @@ describe('FormValueRuntime (form assign rules)', () => {
3999
3999
  expect(formStub.getFieldValue(['users', 0, 'user', 'id'])).toBe(1);
4000
4000
  });
4001
4001
 
4002
+ it('assigns a to-one association from a sibling association in the same to-many row', async () => {
4003
+ const engineEmitter = new EventEmitter();
4004
+ const blockEmitter = new EventEmitter();
4005
+ const secondary = { id: 2, name: 'Secondary' };
4006
+ const formStub = createFormStub({ users: [{ user: null, secondaryUser: null }] });
4007
+
4008
+ const blockModel: any = {
4009
+ uid: 'form-assign-assoc-to-many-sibling',
4010
+ flowEngine: { emitter: engineEmitter },
4011
+ emitter: blockEmitter,
4012
+ dispatchEvent: vi.fn(),
4013
+ getAclActionName: () => 'create',
4014
+ };
4015
+
4016
+ const runtime = new FormValueRuntime({ model: blockModel, getForm: () => formStub as any });
4017
+ runtime.mount({ sync: true });
4018
+
4019
+ const blockCtx = createFieldContext(runtime);
4020
+ const userCollection: any = { getField: () => null };
4021
+ const userField: any = { isAssociationField: () => true, type: 'belongsTo', targetCollection: userCollection };
4022
+ const usersItemCollection: any = {
4023
+ getField: (name: string) => (name === 'user' || name === 'secondaryUser' ? userField : null),
4024
+ };
4025
+ const usersField: any = { isAssociationField: () => true, type: 'hasMany', targetCollection: usersItemCollection };
4026
+ const collection: any = { getField: (name: string) => (name === 'users' ? usersField : null) };
4027
+ blockCtx.defineProperty('collection', { value: collection });
4028
+
4029
+ const rowModel: any = {
4030
+ uid: 'users.user:users:0',
4031
+ isFork: true,
4032
+ forkId: 'users:0',
4033
+ subModels: { field: { context: { collectionField: userField } } },
4034
+ getStepParams(flowKey: string, stepKey: string) {
4035
+ if (flowKey === 'fieldSettings' && stepKey === 'init') return { fieldPath: 'users.user' };
4036
+ return undefined;
4037
+ },
4038
+ };
4039
+ const rowCtx = createFieldContext(runtime);
4040
+ rowCtx.defineProperty('blockModel', { value: blockModel });
4041
+ rowCtx.defineProperty('collection', { value: collection });
4042
+ rowCtx.defineProperty('fieldIndex', { value: ['users:0'] });
4043
+ rowCtx.defineProperty('model', { value: rowModel });
4044
+ rowModel.context = rowCtx;
4045
+
4046
+ blockCtx.defineProperty('engine', {
4047
+ value: { forEachModel: (callback: (model: unknown) => void) => callback(rowModel) },
4048
+ });
4049
+ blockModel.context = blockCtx;
4050
+
4051
+ runtime.syncAssignRules([
4052
+ {
4053
+ key: 'r1',
4054
+ enable: true,
4055
+ targetPath: 'users.user',
4056
+ mode: 'assign',
4057
+ condition: { logic: '$and', items: [] },
4058
+ value: '{{ ctx.item.parentItem.value.secondaryUser }}',
4059
+ },
4060
+ ]);
4061
+
4062
+ await runtime.setFormValues(blockCtx, [{ path: ['users', 0, 'secondaryUser'], value: secondary }], {
4063
+ source: 'user',
4064
+ });
4065
+ await waitFor(() => expect(formStub.getFieldValue(['users', 0, 'user'])).toEqual(secondary));
4066
+
4067
+ await runtime.setFormValues(blockCtx, [{ path: ['users', 0, 'user'], value: { id: 3, name: 'Manual' } }], {
4068
+ source: 'user',
4069
+ });
4070
+ await waitFor(() => expect(formStub.getFieldValue(['users', 0, 'user'])).toEqual(secondary));
4071
+
4072
+ const nextSecondary = { id: 4, name: 'Next secondary' };
4073
+ await runtime.setFormValues(blockCtx, [{ path: ['users', 0, 'secondaryUser'], value: nextSecondary }], {
4074
+ source: 'user',
4075
+ });
4076
+ await waitFor(() => expect(formStub.getFieldValue(['users', 0, 'user'])).toEqual(nextSecondary));
4077
+ });
4078
+
4079
+ it('materializes assigned proxies so later to-many rows continue updating', async () => {
4080
+ const engineEmitter = new EventEmitter();
4081
+ const blockEmitter = new EventEmitter();
4082
+ const initialSecondary = { id: 2, name: 'Initial secondary', roleCodes: ['admin'] };
4083
+ const nextSecondary = { id: 4, name: 'Next secondary', roleCodes: ['editor'] };
4084
+ type UserRow = {
4085
+ user: unknown;
4086
+ secondaryUser: typeof initialSecondary | null;
4087
+ };
4088
+ const store: { users: UserRow[] } = {
4089
+ users: [{ user: null, secondaryUser: initialSecondary }],
4090
+ };
4091
+ type FormNamePath = Parameters<FormInstance['getFieldValue']>[0];
4092
+ const formStub = {
4093
+ getFieldValue: (namePath: FormNamePath) => lodashGet(store, namePath),
4094
+ setFieldValue: (namePath: FormNamePath, value: unknown) => lodashSet(store, namePath, value),
4095
+ // Match Ant Form: the outer store is plain, but nested values may still be reactive proxies.
4096
+ getFieldsValue: () => store,
4097
+ setFieldsValue: (patch: Partial<typeof store>) => lodashMerge(store, patch),
4098
+ };
4099
+
4100
+ const blockModel = {
4101
+ uid: 'form-assign-assoc-to-many-later-row-reactive-proxy',
4102
+ flowEngine: { emitter: engineEmitter },
4103
+ emitter: blockEmitter,
4104
+ dispatchEvent: vi.fn(),
4105
+ getAclActionName: () => 'create',
4106
+ context: undefined as unknown,
4107
+ };
4108
+
4109
+ const runtime = new FormValueRuntime({
4110
+ model: blockModel as unknown as ConstructorParameters<typeof FormValueRuntime>[0]['model'],
4111
+ getForm: () => formStub as unknown as FormInstance,
4112
+ });
4113
+ runtime.mount({ sync: true });
4114
+
4115
+ const blockCtx = createFieldContext(runtime);
4116
+ const userCollection = { getField: () => null };
4117
+ const userField = { isAssociationField: () => true, type: 'belongsTo', targetCollection: userCollection };
4118
+ const usersItemCollection = {
4119
+ getField: (name: string) => (name === 'user' || name === 'secondaryUser' ? userField : null),
4120
+ };
4121
+ const usersField = { isAssociationField: () => true, type: 'hasMany', targetCollection: usersItemCollection };
4122
+ const collection = { getField: (name: string) => (name === 'users' ? usersField : null) };
4123
+ blockCtx.defineProperty('collection', { value: collection });
4124
+
4125
+ const createRowModel = (index: number) => {
4126
+ const forkId = `users:${index}`;
4127
+ const rowCtx = createFieldContext(runtime);
4128
+ const rowModel = {
4129
+ uid: `users.user:${forkId}`,
4130
+ isFork: true,
4131
+ forkId,
4132
+ subModels: { field: { context: { collectionField: userField } } },
4133
+ getStepParams(flowKey: string, stepKey: string) {
4134
+ if (flowKey === 'fieldSettings' && stepKey === 'init') return { fieldPath: 'users.user' };
4135
+ return undefined;
4136
+ },
4137
+ context: rowCtx,
4138
+ };
4139
+ rowCtx.defineProperty('blockModel', { value: blockModel });
4140
+ rowCtx.defineProperty('collection', { value: collection });
4141
+ rowCtx.defineProperty('fieldIndex', { value: [forkId] });
4142
+ rowCtx.defineProperty('model', { value: rowModel });
4143
+ return rowModel;
4144
+ };
4145
+ const rowModels = [createRowModel(0)];
4146
+
4147
+ blockCtx.defineProperty('engine', {
4148
+ value: { forEachModel: (callback: (model: unknown) => void) => rowModels.forEach(callback) },
4149
+ });
4150
+ blockModel.context = blockCtx;
4151
+
4152
+ runtime.syncAssignRules([
4153
+ {
4154
+ key: 'user-from-row-sibling',
4155
+ enable: true,
4156
+ targetPath: 'users.user',
4157
+ mode: 'assign',
4158
+ condition: { logic: '$and', items: [] },
4159
+ value: '{{ ctx.item.parentItem.value.secondaryUser }}',
4160
+ },
4161
+ ]);
4162
+
4163
+ await waitFor(() => expect(formStub.getFieldValue(['users', 0, 'user'])).toEqual(initialSecondary));
4164
+ const assignedUser = formStub.getFieldValue(['users', 0, 'user']);
4165
+ const assignedRoleCodes = formStub.getFieldValue(['users', 0, 'user', 'roleCodes']);
4166
+ expect(assignedUser.constructor).toBe(Object);
4167
+ expect(Array.isArray(assignedRoleCodes)).toBe(true);
4168
+ expect(assignedRoleCodes.constructor).toBe(Array);
4169
+
4170
+ const secondRow: UserRow = { user: null, secondaryUser: null };
4171
+ store.users.push(secondRow);
4172
+ const addedRows: Array<UserRow | undefined> = new Array(2);
4173
+ addedRows[1] = secondRow;
4174
+ expect(() => runtime.handleFormValuesChange({ users: addedRows }, store)).not.toThrow();
4175
+
4176
+ const row1 = createRowModel(1);
4177
+ rowModels.push(row1);
4178
+ engineEmitter.emit('model:mounted', { model: row1 });
4179
+
4180
+ secondRow.secondaryUser = nextSecondary;
4181
+ const changedRows: Array<Partial<UserRow> | undefined> = new Array(2);
4182
+ changedRows[1] = { secondaryUser: nextSecondary };
4183
+ expect(0 in changedRows).toBe(false);
4184
+
4185
+ expect(() => runtime.handleFormValuesChange({ users: changedRows }, store)).not.toThrow();
4186
+ await waitFor(() => expect(formStub.getFieldValue(['users', 1, 'user'])).toEqual(nextSecondary));
4187
+ expect(formStub.getFieldValue(['users', 0, 'user'])).toEqual(initialSecondary);
4188
+ });
4189
+
4190
+ it('assigns a to-many association from a sibling association in the same to-many row', async () => {
4191
+ const engineEmitter = new EventEmitter();
4192
+ const blockEmitter = new EventEmitter();
4193
+ const initialRoles = [{ id: 2, name: 'Initial role' }];
4194
+ const formStub = createFormStub({ users: [{ roles: [], secondaryRoles: [] }] });
4195
+
4196
+ const blockModel: any = {
4197
+ uid: 'form-assign-to-many-assoc-from-to-many-row-sibling',
4198
+ flowEngine: { emitter: engineEmitter },
4199
+ emitter: blockEmitter,
4200
+ dispatchEvent: vi.fn(),
4201
+ getAclActionName: () => 'create',
4202
+ };
4203
+
4204
+ const runtime = new FormValueRuntime({ model: blockModel, getForm: () => formStub as any });
4205
+ runtime.mount({ sync: true });
4206
+
4207
+ const blockCtx = createFieldContext(runtime);
4208
+ const roleCollection: any = { filterTargetKey: 'id', getField: () => null };
4209
+ const rolesField: any = {
4210
+ isAssociationField: () => true,
4211
+ type: 'belongsToMany',
4212
+ targetCollection: roleCollection,
4213
+ };
4214
+ const usersItemCollection: any = {
4215
+ getField: (name: string) => (name === 'roles' || name === 'secondaryRoles' ? rolesField : null),
4216
+ };
4217
+ const usersField: any = { isAssociationField: () => true, type: 'hasMany', targetCollection: usersItemCollection };
4218
+ const collection: any = { getField: (name: string) => (name === 'users' ? usersField : null) };
4219
+ blockCtx.defineProperty('collection', { value: collection });
4220
+
4221
+ const rowModel: any = {
4222
+ uid: 'users.roles:users:0',
4223
+ isFork: true,
4224
+ forkId: 'users:0',
4225
+ subModels: { field: { context: { collectionField: rolesField } } },
4226
+ getStepParams(flowKey: string, stepKey: string) {
4227
+ if (flowKey === 'fieldSettings' && stepKey === 'init') return { fieldPath: 'users.roles' };
4228
+ return undefined;
4229
+ },
4230
+ };
4231
+ const rowCtx = createFieldContext(runtime);
4232
+ rowCtx.defineProperty('blockModel', { value: blockModel });
4233
+ rowCtx.defineProperty('collection', { value: collection });
4234
+ rowCtx.defineProperty('fieldIndex', { value: ['users:0'] });
4235
+ rowCtx.defineProperty('model', { value: rowModel });
4236
+ rowModel.context = rowCtx;
4237
+
4238
+ blockCtx.defineProperty('engine', {
4239
+ value: { forEachModel: (callback: (model: unknown) => void) => callback(rowModel) },
4240
+ });
4241
+ blockModel.context = blockCtx;
4242
+
4243
+ runtime.syncAssignRules([
4244
+ {
4245
+ key: 'roles-from-row-sibling',
4246
+ enable: true,
4247
+ targetPath: 'users.roles',
4248
+ mode: 'assign',
4249
+ condition: { logic: '$and', items: [] },
4250
+ value: '{{ ctx.item.parentItem.value.secondaryRoles }}',
4251
+ },
4252
+ ]);
4253
+
4254
+ await runtime.setFormValues(blockCtx, [{ path: ['users', 0, 'secondaryRoles'], value: initialRoles }], {
4255
+ source: 'user',
4256
+ });
4257
+ await waitFor(() => expect(formStub.getFieldValue(['users', 0, 'roles'])).toEqual(initialRoles));
4258
+
4259
+ const nextRoles = [{ id: 3, name: 'Next role' }];
4260
+ await runtime.setFormValues(blockCtx, [{ path: ['users', 0, 'secondaryRoles'], value: nextRoles }], {
4261
+ source: 'user',
4262
+ });
4263
+ await waitFor(() => expect(formStub.getFieldValue(['users', 0, 'roles'])).toEqual(nextRoles));
4264
+ });
4265
+
4266
+ it('preserves the PopupSubTable outer item chain for a top-level to-one association target', async () => {
4267
+ const engineEmitter = new EventEmitter();
4268
+ const blockEmitter = new EventEmitter();
4269
+ const initialAssignee = { id: 2, name: 'Initial assignee' };
4270
+ const nextAssignee = { id: 3, name: 'Next assignee' };
4271
+ const outerValue = observable({ defaultAssignee: initialAssignee });
4272
+ const formStub = createFormStub({ assignee: null });
4273
+
4274
+ const blockModel: any = {
4275
+ uid: 'popup-sub-table-form-assign-parent-item',
4276
+ flowEngine: { emitter: engineEmitter },
4277
+ emitter: blockEmitter,
4278
+ dispatchEvent: vi.fn(),
4279
+ getAclActionName: () => 'create',
4280
+ };
4281
+
4282
+ const runtime = new FormValueRuntime({ model: blockModel, getForm: () => formStub as any });
4283
+ runtime.mount({ sync: true });
4284
+
4285
+ const blockCtx = createFieldContext(runtime);
4286
+ const userCollection: any = { filterTargetKey: 'id', getField: () => null };
4287
+ const assigneeField: any = {
4288
+ isAssociationField: () => true,
4289
+ type: 'belongsTo',
4290
+ targetCollection: userCollection,
4291
+ };
4292
+ const collection: any = { getField: (name: string) => (name === 'assignee' ? assigneeField : null) };
4293
+ const outerItem = { index: 0, length: 1, value: outerValue };
4294
+ blockCtx.defineProperty('collection', { value: collection });
4295
+ blockCtx.defineProperty('item', {
4296
+ get: () => ({
4297
+ index: 1,
4298
+ length: 3,
4299
+ __is_new__: true,
4300
+ __is_stored__: false,
4301
+ value: blockCtx.formValues,
4302
+ parentItem: outerItem,
4303
+ }),
4304
+ cache: false,
4305
+ });
4306
+ blockModel.context = blockCtx;
4307
+
4308
+ runtime.syncAssignRules([
4309
+ {
4310
+ key: 'assignee-from-popup-parent',
4311
+ enable: true,
4312
+ targetPath: 'assignee',
4313
+ mode: 'assign',
4314
+ condition: {
4315
+ logic: '$and',
4316
+ items: [
4317
+ { path: '{{ ctx.item.parentItem.index }}', operator: '$eq', value: 1 },
4318
+ { path: '{{ ctx.item.parentItem.length }}', operator: '$eq', value: 3 },
4319
+ { path: '{{ ctx.item.parentItem.__is_new__ }}', operator: '$eq', value: true },
4320
+ { path: '{{ ctx.item.parentItem.__is_stored__ }}', operator: '$eq', value: false },
4321
+ ],
4322
+ },
4323
+ value: '{{ ctx.item.parentItem.parentItem.value.defaultAssignee }}',
4324
+ },
4325
+ ]);
4326
+
4327
+ await waitFor(() => expect(formStub.getFieldValue(['assignee'])).toEqual(initialAssignee));
4328
+
4329
+ outerValue.defaultAssignee = nextAssignee;
4330
+ await waitFor(() => expect(formStub.getFieldValue(['assignee'])).toEqual(nextAssignee));
4331
+ });
4332
+
4333
+ it('preserves the PopupSubTable outer item chain for a top-level to-many association target', async () => {
4334
+ const engineEmitter = new EventEmitter();
4335
+ const blockEmitter = new EventEmitter();
4336
+ const initialReviewers = [{ id: 2, name: 'Initial reviewer' }];
4337
+ const nextReviewers = [{ id: 3, name: 'Next reviewer' }];
4338
+ const outerValue = observable({ defaultReviewers: initialReviewers });
4339
+ const formStub = createFormStub({ reviewers: [] });
4340
+
4341
+ const blockModel: any = {
4342
+ uid: 'popup-sub-table-form-assign-to-many-parent-item',
4343
+ flowEngine: { emitter: engineEmitter },
4344
+ emitter: blockEmitter,
4345
+ dispatchEvent: vi.fn(),
4346
+ getAclActionName: () => 'create',
4347
+ };
4348
+
4349
+ const runtime = new FormValueRuntime({ model: blockModel, getForm: () => formStub as any });
4350
+ runtime.mount({ sync: true });
4351
+
4352
+ const blockCtx = createFieldContext(runtime);
4353
+ const userCollection: any = { filterTargetKey: 'id', getField: () => null };
4354
+ const reviewersField: any = {
4355
+ isAssociationField: () => true,
4356
+ type: 'belongsToMany',
4357
+ targetCollection: userCollection,
4358
+ };
4359
+ const collection: any = { getField: (name: string) => (name === 'reviewers' ? reviewersField : null) };
4360
+ const outerItem = { index: 0, length: 1, value: outerValue };
4361
+ blockCtx.defineProperty('collection', { value: collection });
4362
+ blockCtx.defineProperty('item', {
4363
+ get: () => ({
4364
+ index: 1,
4365
+ length: 3,
4366
+ __is_new__: true,
4367
+ __is_stored__: false,
4368
+ value: blockCtx.formValues,
4369
+ parentItem: outerItem,
4370
+ }),
4371
+ cache: false,
4372
+ });
4373
+ blockModel.context = blockCtx;
4374
+
4375
+ runtime.syncAssignRules([
4376
+ {
4377
+ key: 'reviewers-from-popup-parent',
4378
+ enable: true,
4379
+ targetPath: 'reviewers',
4380
+ mode: 'assign',
4381
+ condition: {
4382
+ logic: '$and',
4383
+ items: [
4384
+ { path: '{{ ctx.item.parentItem.index }}', operator: '$eq', value: 1 },
4385
+ { path: '{{ ctx.item.parentItem.length }}', operator: '$eq', value: 3 },
4386
+ { path: '{{ ctx.item.parentItem.__is_new__ }}', operator: '$eq', value: true },
4387
+ { path: '{{ ctx.item.parentItem.__is_stored__ }}', operator: '$eq', value: false },
4388
+ ],
4389
+ },
4390
+ value: '{{ ctx.item.parentItem.parentItem.value.defaultReviewers }}',
4391
+ },
4392
+ ]);
4393
+
4394
+ await waitFor(() => expect(formStub.getFieldValue(['reviewers'])).toEqual(initialReviewers));
4395
+
4396
+ outerValue.defaultReviewers = nextReviewers;
4397
+ await waitFor(() => expect(formStub.getFieldValue(['reviewers'])).toEqual(nextReviewers));
4398
+ });
4399
+
4002
4400
  it('does not write to to-many nested path without row index', async () => {
4003
4401
  const engineEmitter = new EventEmitter();
4004
4402
  const blockEmitter = new EventEmitter();
@@ -83,6 +83,15 @@ type ToManyAggregateSourceInfo = {
83
83
  lastWrite?: FormValueWriteMeta;
84
84
  };
85
85
 
86
+ type RuntimeItemChain = {
87
+ index?: number;
88
+ length?: number;
89
+ __is_new__?: boolean;
90
+ __is_stored__?: boolean;
91
+ value: unknown;
92
+ parentItem?: RuntimeItemChain;
93
+ };
94
+
86
95
  export type RuleEngineOptions = {
87
96
  getBlockModelUid: () => string;
88
97
  getActionName: () => string | undefined;
@@ -2038,7 +2047,7 @@ export class RuleEngine {
2038
2047
  // - parentItem:上级项(同结构,可链式 parentItem.parentItem...)
2039
2048
  // 计算顺序:
2040
2049
  // 1) 优先按 targetNamePath 从 formValues 构建“关联链 item”
2041
- // 2) 若无法构建(例如目标字段是顶层路径),回退到上游显式注入的 baseCtx.item
2050
+ // 2) 若无法构建(例如目标字段是顶层非关联字段),回退到上游显式注入的 baseCtx.item
2042
2051
  // (如 PopupSubTable 新增弹窗传入的 parentItem 链)
2043
2052
  let itemCached: any;
2044
2053
  let itemCachedReady = false;
@@ -2209,9 +2218,23 @@ export class RuleEngine {
2209
2218
  parentItem,
2210
2219
  };
2211
2220
  };
2212
- const defaultRoot = buildNode(trackingFormValues, undefined, undefined, undefined);
2213
- // item 仅用于“关系字段的子路径”场景;
2214
- // 顶层字段/非关联嵌套对象字段应使用 formValues。
2221
+ const blockCtx = this.options.getBlockContext();
2222
+ const formRootItem = (() => {
2223
+ try {
2224
+ const item = blockCtx?.item as RuntimeItemChain | undefined;
2225
+ if (!item || typeof item !== 'object') return undefined;
2226
+ return item.value === blockCtx?.formValues ? item : undefined;
2227
+ } catch {
2228
+ return undefined;
2229
+ }
2230
+ })();
2231
+ const defaultRoot = {
2232
+ ...buildNode(trackingFormValues, formRootItem?.index, formRootItem?.length, formRootItem?.parentItem),
2233
+ __is_new__: formRootItem?.__is_new__ ?? trackingFormValues?.__is_new__,
2234
+ __is_stored__: formRootItem?.__is_stored__ ?? trackingFormValues?.__is_stored__,
2235
+ };
2236
+ // item 用于“关系字段目标或其子路径”场景;
2237
+ // 顶层非关联字段/非关联嵌套对象字段应使用 formValues。
2215
2238
  if (!targetNamePath || !Array.isArray(targetNamePath) || !targetNamePath.length) return undefined;
2216
2239
  if (!rootCollection?.getField) return undefined;
2217
2240
 
@@ -2219,7 +2242,9 @@ export class RuleEngine {
2219
2242
  const prefix: NamePath = [];
2220
2243
  let collection = rootCollection;
2221
2244
 
2222
- for (let i = 0; i < targetNamePath.length - 1; i++) {
2245
+ // The target itself can be an association. Keep that final association in the item chain so
2246
+ // ctx.item.parentItem continues to point at the containing row instead of skipping directly to the form root.
2247
+ for (let i = 0; i < targetNamePath.length; i++) {
2223
2248
  const seg = targetNamePath[i];
2224
2249
  if (typeof seg !== 'string') break;
2225
2250
 
@@ -2231,9 +2256,12 @@ export class RuleEngine {
2231
2256
 
2232
2257
  if (toMany) {
2233
2258
  const next = targetNamePath[i + 1];
2234
- if (typeof next !== 'number') break;
2235
- prefix.push(next);
2236
- i += 1;
2259
+ if (typeof next === 'number') {
2260
+ prefix.push(next);
2261
+ i += 1;
2262
+ } else if (i !== targetNamePath.length - 1) {
2263
+ break;
2264
+ }
2237
2265
  }
2238
2266
 
2239
2267
  const targetCollection = field?.targetCollection;
@@ -2248,9 +2276,11 @@ export class RuleEngine {
2248
2276
  const assocEntry = assocEntries[idx];
2249
2277
  const value = _.get(trackingFormValues, assocEntry.path);
2250
2278
  const lastSeg = assocEntry.path[assocEntry.path.length - 1];
2251
- const index = assocEntry.toMany && typeof lastSeg === 'number' ? lastSeg : undefined;
2279
+ const isIndexedToManyItem = assocEntry.toMany && typeof lastSeg === 'number';
2280
+ const index = isIndexedToManyItem ? lastSeg : undefined;
2252
2281
  const length = (() => {
2253
2282
  if (!assocEntry.toMany) return undefined;
2283
+ if (!isIndexedToManyItem) return Array.isArray(value) ? value.length : undefined;
2254
2284
  // assocEntry.path: [..., associationKey, rowIndex]
2255
2285
  const listPath = assocEntry.path.slice(0, -1);
2256
2286
  const list = _.get(trackingFormValues, listPath);