@nocobase/client-v2 2.1.4 → 2.1.6

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.
@@ -13,7 +13,7 @@ import { subFormFieldLinkageRules } from '../linkageRules';
13
13
  import { SubFormFieldModel, SubFormListFieldModel } from '../../models/fields/AssociationFieldModel/SubFormFieldModel';
14
14
 
15
15
  describe('subFormFieldLinkageRules action', () => {
16
- it('passes inputArgs to fork runtime context', async () => {
16
+ it('passes inputArgs to fork runtime context when grid forks are stored in a Set', async () => {
17
17
  const linkageAssignHandler = vi.fn();
18
18
  const engine = new FlowEngine();
19
19
  const forkModel = new FlowModel({ uid: 'fork-model', flowEngine: engine }) as any;
@@ -38,7 +38,7 @@ describe('subFormFieldLinkageRules action', () => {
38
38
  hidden: false,
39
39
  subModels: {
40
40
  grid: {
41
- forks: [forkModel],
41
+ forks: new Set([forkModel]),
42
42
  },
43
43
  },
44
44
  },
@@ -2842,7 +2842,7 @@ export const subFormFieldLinkageRules = defineAction({
2842
2842
  }
2843
2843
  } else {
2844
2844
  await Promise.all(
2845
- (grid.forks || []).map(async (forkModel: FlowModel) => {
2845
+ Array.from(grid.forks || []).map(async (forkModel: FlowModel) => {
2846
2846
  if (forkModel.hidden) {
2847
2847
  return;
2848
2848
  }
@@ -15,17 +15,29 @@ import {
15
15
  isRunJSValue,
16
16
  } from '@nocobase/flow-engine';
17
17
  import _ from 'lodash';
18
- import { namePathToPathKey, parsePathString, pathKeyToNamePath } from '../models/blocks/form/value-runtime/path';
18
+ import { namePathToPathKey } from '../models/blocks/form/value-runtime/path';
19
19
  import {
20
20
  collectStaticDepsFromRunJSValue,
21
21
  collectStaticDepsFromTemplateValue,
22
22
  recordDep,
23
23
  type DepCollector,
24
24
  } from '../models/blocks/form/value-runtime/deps';
25
+ import {
26
+ buildItemListRootPath,
27
+ buildItemRowPath,
28
+ dedupeNamePaths,
29
+ findFormValueChangeSource,
30
+ getChangedPathsFromPayload,
31
+ getFieldIndexEntriesFromContext,
32
+ isNamePathPrefix,
33
+ isSameNamePath,
34
+ minimizeNamePaths,
35
+ parseDependencyPath,
36
+ parsePathKey,
37
+ type NamePath,
38
+ } from '../utils/formValueDeps';
25
39
  import { linkageRulesRefresh } from './linkageRulesRefresh';
26
40
 
27
- type NamePath = Array<string | number>;
28
-
29
41
  type LinkageRefreshDeps = {
30
42
  wildcard: boolean;
31
43
  valuePaths: NamePath[];
@@ -40,75 +52,11 @@ type LinkageRefreshBinding = {
40
52
  dispose: () => void;
41
53
  };
42
54
 
43
- type FieldIndexEntry = {
44
- name: string;
45
- index: number;
46
- };
47
-
48
55
  const FORM_VALUES_CHANGE_EVENT = 'formValuesChange';
49
56
  const LINKAGE_REFRESH_BINDINGS_KEY = '__formValueDrivenLinkageRefreshBindings';
50
57
 
51
- function isSameNamePath(a: NamePath, b: NamePath) {
52
- return a.length === b.length && a.every((seg, index) => seg === b[index]);
53
- }
54
-
55
- function isNamePathPrefix(prefix: NamePath, path: NamePath) {
56
- if (prefix.length > path.length) return false;
57
- return prefix.every((seg, index) => seg === path[index]);
58
- }
59
-
60
- function dedupeNamePaths(paths: NamePath[]) {
61
- const byKey = new Map<string, NamePath>();
62
- for (const path of paths) {
63
- if (!path?.length) continue;
64
- byKey.set(namePathToPathKey(path), path);
65
- }
66
- return Array.from(byKey.values());
67
- }
68
-
69
- function minimizeValueNamePaths(paths: NamePath[]) {
70
- const deduped = dedupeNamePaths(paths);
71
- return deduped.filter((path, index) => {
72
- return !deduped.some((other, otherIndex) => otherIndex !== index && isNamePathPrefix(path, other));
73
- });
74
- }
75
-
76
- function parseFieldIndexEntries(fieldIndex: unknown): FieldIndexEntry[] {
77
- const arr = Array.isArray(fieldIndex) ? fieldIndex : [];
78
- const entries: FieldIndexEntry[] = [];
79
- for (const it of arr) {
80
- if (typeof it !== 'string') continue;
81
- const [name, indexStr] = it.split(':');
82
- const index = Number(indexStr);
83
- if (!name || Number.isNaN(index)) continue;
84
- entries.push({ name, index });
85
- }
86
- return entries;
87
- }
88
-
89
- function getFieldIndexEntriesFromContext(ctx: any): FieldIndexEntry[] {
90
- return parseFieldIndexEntries(ctx?.model?.context?.fieldIndex ?? ctx?.fieldIndex);
91
- }
92
-
93
- function buildItemRowPath(entries: FieldIndexEntry[], parentDepth: number): NamePath | null {
94
- const targetIndex = entries.length - 1 - parentDepth;
95
- if (targetIndex < 0) return null;
96
-
97
- const out: NamePath = [];
98
- for (let i = 0; i <= targetIndex; i++) {
99
- out.push(entries[i].name, entries[i].index);
100
- }
101
- return out;
102
- }
103
-
104
- function buildItemListRootPath(entries: FieldIndexEntry[], parentDepth: number): NamePath | null {
105
- const rowPath = buildItemRowPath(entries, parentDepth);
106
- if (!rowPath?.length) return null;
107
- return rowPath.slice(0, -1);
108
- }
109
-
110
58
  function resolveItemDependencyPath(ctx: FlowContext, depPath: NamePath): LinkageRefreshDeps {
111
- const entries = getFieldIndexEntriesFromContext(ctx as any);
59
+ const entries = getFieldIndexEntriesFromContext(ctx);
112
60
  if (!entries.length) {
113
61
  return { wildcard: true, valuePaths: [], structuralPaths: [] };
114
62
  }
@@ -171,8 +119,7 @@ function addRunjsUsageToCollector(script: string, collector: DepCollector) {
171
119
  collector.wildcard = true;
172
120
  continue;
173
121
  }
174
- const segs = parsePathString(String(subPath)).filter((seg) => typeof seg !== 'object') as NamePath;
175
- recordDep(segs, collector);
122
+ recordDep(parseDependencyPath(String(subPath)), collector);
176
123
  continue;
177
124
  }
178
125
  if (varName === 'item') {
@@ -233,13 +180,13 @@ function collectLinkageRefreshDeps(ctx: FlowContext, params: any): LinkageRefres
233
180
  wildcard = true;
234
181
  continue;
235
182
  }
236
- valuePaths.push(pathKeyToNamePath(inner));
183
+ valuePaths.push(parsePathKey(inner));
237
184
  continue;
238
185
  }
239
186
 
240
187
  if (depKey === 'ctx:item' || depKey.startsWith('ctx:item:')) {
241
188
  const subPath = depKey === 'ctx:item' ? '' : depKey.slice('ctx:item:'.length);
242
- const depPath = subPath ? (parsePathString(subPath).filter((seg) => typeof seg !== 'object') as NamePath) : [];
189
+ const depPath = subPath ? parseDependencyPath(subPath) : [];
243
190
  const resolved = resolveItemDependencyPath(ctx, depPath);
244
191
  wildcard ||= resolved.wildcard;
245
192
  valuePaths.push(...resolved.valuePaths);
@@ -249,7 +196,7 @@ function collectLinkageRefreshDeps(ctx: FlowContext, params: any): LinkageRefres
249
196
 
250
197
  return {
251
198
  wildcard,
252
- valuePaths: minimizeValueNamePaths(valuePaths),
199
+ valuePaths: minimizeNamePaths(valuePaths),
253
200
  structuralPaths: dedupeNamePaths(structuralPaths),
254
201
  };
255
202
  }
@@ -258,44 +205,9 @@ function hasLinkageRefreshDeps(deps: LinkageRefreshDeps) {
258
205
  return deps.wildcard || deps.valuePaths.length > 0 || deps.structuralPaths.length > 0;
259
206
  }
260
207
 
261
- function getChangedPathsFromPayload(payload: any): NamePath[] {
262
- const rawChangedPaths = Array.isArray(payload?.changedPaths) ? payload.changedPaths : [];
263
- const out: NamePath[] = [];
264
-
265
- for (const path of rawChangedPaths) {
266
- if (Array.isArray(path)) {
267
- if (path.length === 1 && typeof path[0] === 'string') {
268
- const namePath = pathKeyToNamePath(path[0]);
269
- if (namePath.length) out.push(namePath);
270
- continue;
271
- }
272
- const segs = path.filter((seg) => typeof seg === 'string' || typeof seg === 'number') as NamePath;
273
- if (segs.length) out.push(segs);
274
- continue;
275
- }
276
- if (typeof path === 'string' && path) {
277
- out.push(pathKeyToNamePath(path));
278
- }
279
- }
280
-
281
- if (out.length) {
282
- return out;
283
- }
284
-
285
- const changedValues = payload?.changedValues;
286
- if (changedValues && typeof changedValues === 'object') {
287
- for (const key of Object.keys(changedValues)) {
288
- const namePath = pathKeyToNamePath(key);
289
- if (namePath.length) out.push(namePath);
290
- }
291
- }
292
-
293
- return out;
294
- }
295
-
296
208
  function linkageRefreshDepsMatchPayload(deps: LinkageRefreshDeps, payload: any) {
297
209
  if (!hasLinkageRefreshDeps(deps)) return false;
298
- const changedPaths = getChangedPathsFromPayload(payload);
210
+ const changedPaths = getChangedPathsFromPayload(payload, { includeArrayChangedValues: true });
299
211
  if (deps.wildcard) return true;
300
212
  if (!changedPaths.length) return true;
301
213
 
@@ -330,28 +242,8 @@ function getLinkageRefreshBindings(model: any): Map<string, LinkageRefreshBindin
330
242
  return (model[LINKAGE_REFRESH_BINDINGS_KEY] ||= new Map<string, LinkageRefreshBinding>());
331
243
  }
332
244
 
333
- function isFormBlockForLinkageRefresh(model: any) {
334
- if (!model || typeof model !== 'object') return false;
335
- if (!model.emitter || typeof model.emitter.on !== 'function' || typeof model.emitter.off !== 'function') return false;
336
- return !!model.formValueRuntime || !!model.context?.form || typeof model.context?.setFormValues === 'function';
337
- }
338
-
339
245
  function findFormBlockForLinkageRefresh(ctx: FlowContext): any | null {
340
- const candidates: any[] = [];
341
- const push = (model: any) => {
342
- if (model && !candidates.includes(model)) candidates.push(model);
343
- };
344
-
345
- push((ctx.model as any)?.context?.blockModel);
346
- push(ctx.model);
347
-
348
- let cursor: any = (ctx.model as any)?.parent;
349
- while (cursor) {
350
- push(cursor);
351
- cursor = cursor?.parent;
352
- }
353
-
354
- return candidates.find(isFormBlockForLinkageRefresh) || null;
246
+ return findFormValueChangeSource(ctx) as any;
355
247
  }
356
248
 
357
249
  function disposeLinkageRefreshBinding(model: any, key: string) {
@@ -26,6 +26,7 @@ import {
26
26
  observer,
27
27
  FlowModelProvider,
28
28
  FlowErrorFallback,
29
+ extractUsedVariablePaths,
29
30
  } from '@nocobase/flow-engine';
30
31
  import { TableColumnProps, Tooltip, Input, Space, Divider } from 'antd';
31
32
  import { ErrorBoundary } from 'react-error-boundary';
@@ -37,6 +38,14 @@ import { FieldDeletePlaceholder, CustomWidth, normalizeTableColumnWidth } from '
37
38
  import { buildDynamicNamePath } from '../../../blocks/form/dynamicNamePath';
38
39
  import { getSubTableRowIdentity } from './rowIdentity';
39
40
  import { getFieldBindingUse, rebuildFieldSubModel } from '../../../../internal/utils/rebuildFieldSubModel';
41
+ import {
42
+ buildCurrentItemTitle,
43
+ createAssociationItemChainContextPropertyOptions,
44
+ createItemChainGetter,
45
+ createParentItemAccessorsFromContext,
46
+ createRootItemChain,
47
+ type ItemChain,
48
+ } from '../itemChain';
40
49
 
41
50
  export const SUB_TABLE_COLUMN_FIELD_COMPONENT_CONTEXT = 'subTableColumn';
42
51
 
@@ -180,6 +189,25 @@ const handleModelName = (modelName) => {
180
189
  return modelName;
181
190
  };
182
191
 
192
+ const DATA_SCOPE_FLOW_KEYS = ['selectSettings', 'cascadeSelectSettings'];
193
+
194
+ function hasFormValueDrivenDataScope(filter: any) {
195
+ if (!filter) return false;
196
+ try {
197
+ const usage = extractUsedVariablePaths(filter) || {};
198
+ return ['item', 'formValues'].some((name) => Object.prototype.hasOwnProperty.call(usage, name));
199
+ } catch {
200
+ return false;
201
+ }
202
+ }
203
+
204
+ function hasFormValueDrivenDataScopeField(fieldModel: any) {
205
+ return DATA_SCOPE_FLOW_KEYS.some((flowKey) => {
206
+ const params = fieldModel?.getStepParams?.(flowKey, 'dataScope');
207
+ return hasFormValueDrivenDataScope(params?.filter);
208
+ });
209
+ }
210
+
183
211
  const MemoFieldRenderer = React.memo(FieldModelRenderer, (prev, next) => {
184
212
  return prev.value === next.value && prev.model === next.model;
185
213
  });
@@ -348,7 +376,10 @@ const MemoCell: React.FC<CellProps> = React.memo(
348
376
  const fork: any = action.createFork({}, `${id}`);
349
377
  fork.context.defineProperty('currentObject', { get: () => record });
350
378
  if (rowFork) {
379
+ const itemOptions = rowFork.context.getPropertyOptions?.('item');
380
+ const { value: _value, ...itemOptionsWithoutValue } = (itemOptions || {}) as any;
351
381
  fork.context.defineProperty('item', {
382
+ ...itemOptionsWithoutValue,
352
383
  get: () => rowFork.context.item,
353
384
  cache: false,
354
385
  });
@@ -445,6 +476,57 @@ export class SubTableColumnModel<
445
476
  static renderMode = ModelRenderMode.RenderFunction;
446
477
  static fieldComponentContext = SUB_TABLE_COLUMN_FIELD_COMPONENT_CONTEXT;
447
478
 
479
+ private getAssociationField() {
480
+ return (this.parent as any)?.context?.collectionField;
481
+ }
482
+
483
+ private getParentFormValues() {
484
+ return (this.context?.blockModel as any)?.context?.formValues;
485
+ }
486
+
487
+ private createParentItemAccessors(parentItemAccessor?: () => ItemChain | undefined) {
488
+ const baseAccessors = createParentItemAccessorsFromContext({
489
+ parentContextAccessor: () => (this.parent as any)?.context,
490
+ fallbackParentPropertiesAccessor: () => this.getParentFormValues(),
491
+ });
492
+
493
+ if (!parentItemAccessor) {
494
+ return baseAccessors;
495
+ }
496
+
497
+ return {
498
+ parentPropertiesAccessor: (ctx?: any) =>
499
+ parentItemAccessor()?.value ?? baseAccessors.parentPropertiesAccessor(ctx),
500
+ parentItemMetaAccessor: baseAccessors.parentItemMetaAccessor,
501
+ parentItemResolverAccessor: baseAccessors.parentItemResolverAccessor,
502
+ };
503
+ }
504
+
505
+ private createRowItemContextPropertyOptions(
506
+ options: {
507
+ resolverPropertiesAccessor?: () => unknown;
508
+ parentItemAccessor?: () => ItemChain | undefined;
509
+ showParentIndex?: boolean;
510
+ } = {},
511
+ ) {
512
+ const associationField = this.getAssociationField();
513
+
514
+ return createAssociationItemChainContextPropertyOptions({
515
+ t: this.context.t,
516
+ title: buildCurrentItemTitle(this.context.t, associationField, this.props?.name),
517
+ showIndex: true,
518
+ showParentIndex:
519
+ options.showParentIndex ??
520
+ (Array.isArray((this.parent as any)?.context?.fieldIndex) &&
521
+ (this.parent as any).context.fieldIndex.length > 0),
522
+ collectionAccessor: () => (this.parent as any)?.collection ?? associationField?.targetCollection ?? null,
523
+ propertiesAccessor: (ctx) => ctx?.item?.value,
524
+ resolverPropertiesAccessor: options.resolverPropertiesAccessor ?? (() => undefined),
525
+ parentCollectionAccessor: () => associationField?.collection,
526
+ parentAccessors: this.createParentItemAccessors(options.parentItemAccessor),
527
+ });
528
+ }
529
+
448
530
  renderHiddenInConfig() {
449
531
  return <FieldWithoutPermissionPlaceholder targetModel={this} />;
450
532
  }
@@ -505,6 +587,14 @@ export class SubTableColumnModel<
505
587
  ).some(Boolean);
506
588
  }
507
589
 
590
+ get hasFormValueDrivenDataScopeColumn() {
591
+ return (
592
+ this.parent?.mapSubModels('columns', (column: SubTableColumnModel) => {
593
+ return hasFormValueDrivenDataScopeField(column?.subModels?.field);
594
+ }) || []
595
+ ).some(Boolean);
596
+ }
597
+
508
598
  onInit(options: any): void {
509
599
  super.onInit(options);
510
600
  this.context.defineProperty('resourceName', {
@@ -516,6 +606,10 @@ export class SubTableColumnModel<
516
606
  this.context.defineProperty('actionName', {
517
607
  get: () => 'view',
518
608
  });
609
+ this.context.defineProperty('item', {
610
+ get: () => undefined,
611
+ ...this.createRowItemContextPropertyOptions(),
612
+ });
519
613
  this.emitter.on('onSubModelAdded', (subModel: FieldModel) => {
520
614
  if (this.collectionField) {
521
615
  subModel.setProps(this.collectionField.getComponentProps());
@@ -673,25 +767,30 @@ export class SubTableColumnModel<
673
767
  value: [...baseArr, `${associationKey}:${rowIndex}`],
674
768
  });
675
769
  }
676
- fork.context.defineProperty('item', {
677
- get: () => {
678
- const form = (fork.context as any)?.form || (this.context?.blockModel as any)?.context?.form;
679
- const rowRecord = getLatestSubTableRowRecord(form, fork.context.fieldIndex, record);
680
- const parentItemCtx = (parentItem ?? this.context?.item) as any;
681
- const isNew = rowRecord?.__is_new__;
682
- const isStored = rowRecord?.__is_stored__;
770
+ const getRowRecord = () => {
771
+ const form = (fork.context as any)?.form || (this.context?.blockModel as any)?.context?.form;
772
+ return getLatestSubTableRowRecord(form, fork.context.fieldIndex, record);
773
+ };
774
+ const getParentItem = () => {
775
+ const parentItemCtx = (parentItem ?? (this.parent as any)?.context?.item) as ItemChain | undefined;
776
+ return parentItemCtx ?? createRootItemChain(this.getParentFormValues());
777
+ };
778
+ const getRowItem = createItemChainGetter({
779
+ valueAccessor: getRowRecord,
780
+ parentItemAccessor: getParentItem,
781
+ indexAccessor: () => (Number.isFinite(rowIndex) ? rowIndex : undefined),
782
+ lengthAccessor: () => {
683
783
  const list = (this.parent as any)?.props?.value;
684
- const length = Array.isArray(list) ? list.length : undefined;
685
- return {
686
- index: Number.isFinite(rowIndex) ? rowIndex : undefined,
687
- length,
688
- __is_new__: isNew,
689
- __is_stored__: isStored,
690
- value: rowRecord,
691
- parentItem: parentItemCtx,
692
- };
784
+ return Array.isArray(list) ? list.length : undefined;
693
785
  },
694
- cache: false,
786
+ });
787
+ fork.context.defineProperty('item', {
788
+ get: getRowItem,
789
+ ...this.createRowItemContextPropertyOptions({
790
+ resolverPropertiesAccessor: getRowRecord,
791
+ parentItemAccessor: getParentItem,
792
+ showParentIndex: baseArr.length > 0,
793
+ }),
695
794
  });
696
795
  return fork;
697
796
  })();
@@ -707,7 +806,7 @@ export class SubTableColumnModel<
707
806
  rowFork={rowFork}
708
807
  memoKey={cellModeKey}
709
808
  width={this.props.width}
710
- commitOnChange={this.hasFormulaColumn}
809
+ commitOnChange={this.hasFormulaColumn || this.hasFormValueDrivenDataScopeColumn}
711
810
  />
712
811
  );
713
812
  };
@@ -21,6 +21,26 @@ import {
21
21
  isSubTableColumnReadPretty,
22
22
  } from '../SubTableColumnModel';
23
23
 
24
+ function createMockCollection(name: string, fields: any[] = []) {
25
+ const collection: any = {
26
+ name,
27
+ title: name,
28
+ dataSourceKey: 'main',
29
+ filterTargetKey: 'id',
30
+ fields,
31
+ getFields: () => collection.fields,
32
+ getField: (fieldName: string) => collection.fields.find((field) => field.name === fieldName),
33
+ };
34
+
35
+ collection.fields.forEach((field) => {
36
+ field.collection = field.collection ?? collection;
37
+ field.filterable = field.filterable ?? true;
38
+ field.isAssociationField = field.isAssociationField ?? (() => false);
39
+ });
40
+
41
+ return collection;
42
+ }
43
+
24
44
  describe('SubTableColumnModel row record helpers', () => {
25
45
  it('builds the row path from fieldIndex entries', () => {
26
46
  expect(buildRowPathFromFieldIndex(['roles:0'])).toEqual(['roles', 0]);
@@ -51,6 +71,171 @@ describe('SubTableColumnModel row record helpers', () => {
51
71
  expect(getLatestSubTableRowRecord(form, ['roles:0'], fallback)).toBe(fallback);
52
72
  });
53
73
 
74
+ it('provides current item meta for sub-table column data scope variables', async () => {
75
+ const engine = new FlowEngine();
76
+ engine.registerModels({ SubTableColumnModel });
77
+
78
+ const tagsCollection = createMockCollection('tags', [
79
+ { name: 'id', title: 'ID', type: 'integer', interface: 'integer' },
80
+ { name: 'name', title: 'Name', type: 'string', interface: 'input' },
81
+ ]);
82
+ const rolesCollection = createMockCollection('roles', [
83
+ { name: 'id', title: 'ID', type: 'integer', interface: 'integer' },
84
+ { name: 'name', title: 'Name', type: 'string', interface: 'input' },
85
+ {
86
+ name: 'tags',
87
+ title: 'Tags',
88
+ type: 'hasMany',
89
+ interface: 'o2m',
90
+ target: 'tags',
91
+ targetCollection: tagsCollection,
92
+ isAssociationField: () => true,
93
+ },
94
+ ]);
95
+ const usersCollection = createMockCollection('users', [
96
+ { name: 'id', title: 'ID', type: 'integer', interface: 'integer' },
97
+ { name: 'nickname', title: 'Nickname', type: 'string', interface: 'input' },
98
+ ]);
99
+ const rolesField = {
100
+ name: 'roles',
101
+ title: 'Roles',
102
+ type: 'hasMany',
103
+ interface: 'o2m',
104
+ collection: usersCollection,
105
+ target: 'roles',
106
+ targetCollection: rolesCollection,
107
+ isAssociationField: () => true,
108
+ };
109
+
110
+ const form = {
111
+ getFieldValue: vi.fn((path: any) => {
112
+ if (JSON.stringify(path) === JSON.stringify(['roles', 0])) {
113
+ return { id: 11, name: 'Admin', tags: [{ id: 7 }] };
114
+ }
115
+ }),
116
+ };
117
+ const blockModel: any = engine.createModel({ use: 'FlowModel', uid: 'form-block', structure: {} as any });
118
+ blockModel.context.defineProperty('form', { value: form });
119
+ blockModel.context.defineProperty('formValues', {
120
+ value: {
121
+ id: 1,
122
+ nickname: 'Alice',
123
+ roles: [{ id: 11, name: 'Admin' }],
124
+ },
125
+ });
126
+
127
+ const subTableFieldModel: any = engine.createModel({
128
+ use: 'FlowModel',
129
+ uid: 'users.roles',
130
+ structure: {} as any,
131
+ });
132
+ subTableFieldModel.collection = rolesCollection;
133
+ subTableFieldModel.setProps({ value: [{ id: 11, name: 'Admin' }] });
134
+ subTableFieldModel.context.defineProperty('collectionField', { value: rolesField });
135
+ subTableFieldModel.context.defineProperty('fieldPath', { value: 'roles' });
136
+ subTableFieldModel.context.defineProperty('blockModel', { value: blockModel });
137
+
138
+ const column: any = engine.createModel({
139
+ use: 'SubTableColumnModel',
140
+ uid: 'users.roles.tags',
141
+ stepParams: {
142
+ fieldSettings: {
143
+ init: {
144
+ fieldPath: 'roles.tags',
145
+ },
146
+ },
147
+ },
148
+ structure: {} as any,
149
+ });
150
+ column.setParent(subTableFieldModel);
151
+ column.context.defineProperty('blockModel', { value: blockModel });
152
+ column.subModels = { field: [] };
153
+
154
+ const designTimeItemOptions = column.context.getPropertyOptions('item');
155
+ expect(typeof designTimeItemOptions.meta).toBe('function');
156
+ const designTimeMeta = await designTimeItemOptions.meta();
157
+ expect(designTimeMeta.properties.value.title).toBe('Attributes');
158
+ expect(designTimeMeta.properties.parentItem.properties.value.title).toBe('Attributes');
159
+
160
+ const renderCell = column.renderItem();
161
+ renderCell({
162
+ id: 'cell-roles-tags-0',
163
+ rowIdx: 0,
164
+ record: { id: 11, name: 'Stale admin', tags: [{ id: 7 }] },
165
+ parentItem: {
166
+ value: { id: 1, nickname: 'Alice' },
167
+ },
168
+ });
169
+
170
+ const [rowFork] = Array.from(column.forks ?? []) as any[];
171
+ const rowItemOptions = rowFork.context.getPropertyOptions('item');
172
+ expect(typeof rowItemOptions.meta).toBe('function');
173
+ expect(rowItemOptions.resolveOnServer('value.tags.name')).toBe(true);
174
+
175
+ const rowMeta = await rowItemOptions.meta();
176
+ expect(rowMeta.properties.value.title).toBe('Attributes');
177
+ expect(rowMeta.properties.parentItem.properties.value.title).toBe('Attributes');
178
+ expect(rowFork.context.item).toMatchObject({
179
+ index: 0,
180
+ length: 1,
181
+ value: { id: 11, name: 'Admin', tags: [{ id: 7 }] },
182
+ parentItem: {
183
+ value: { id: 1, nickname: 'Alice' },
184
+ },
185
+ });
186
+ });
187
+
188
+ it('detects variable data scope columns for immediate row commits', () => {
189
+ const engine = new FlowEngine();
190
+ engine.registerModels({ SubTableColumnModel });
191
+
192
+ const inputColumn: any = engine.createModel({
193
+ use: 'SubTableColumnModel',
194
+ uid: 'users.roles.name',
195
+ structure: {} as any,
196
+ });
197
+ const scopedColumn: any = engine.createModel({
198
+ use: 'SubTableColumnModel',
199
+ uid: 'users.roles.tags',
200
+ structure: {} as any,
201
+ });
202
+
203
+ inputColumn.subModels = {
204
+ field: {
205
+ getStepParams: vi.fn(() => undefined),
206
+ },
207
+ };
208
+ scopedColumn.subModels = {
209
+ field: {
210
+ getStepParams: vi.fn((flowKey: string, stepKey: string) => {
211
+ if (flowKey !== 'selectSettings' || stepKey !== 'dataScope') {
212
+ return undefined;
213
+ }
214
+ return {
215
+ filter: {
216
+ logic: '$and',
217
+ items: [{ path: 'name', operator: '$eq', value: '{{ ctx.item.value.name }}' }],
218
+ },
219
+ };
220
+ }),
221
+ },
222
+ };
223
+
224
+ const parent: any = engine.createModel({
225
+ use: 'FlowModel',
226
+ uid: 'users.roles-table',
227
+ structure: {} as any,
228
+ });
229
+ parent.mapSubModels = vi.fn((key: string, iterator: (column: SubTableColumnModel) => unknown) => {
230
+ if (key !== 'columns') return [];
231
+ return [inputColumn, scopedColumn].map(iterator);
232
+ });
233
+ inputColumn.setParent(parent);
234
+ scopedColumn.setParent(parent);
235
+
236
+ expect(inputColumn.hasFormValueDrivenDataScopeColumn).toBe(true);
237
+ });
238
+
54
239
  it('treats a display-only column pattern as read-pretty mode', () => {
55
240
  expect(isSubTableColumnReadPretty({ props: { pattern: 'readPretty' } })).toBe(true);
56
241
  expect(isSubTableColumnReadPretty({ props: { readPretty: true } })).toBe(true);