@nocobase/client-v2 2.1.10 → 2.1.11

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.
@@ -14,6 +14,15 @@ import {
14
14
  collectFieldAssignCascaderOptions,
15
15
  } from '../fieldAssignOptions';
16
16
 
17
+ type TestCollectionField = {
18
+ name?: string;
19
+ title?: string;
20
+ interface?: string;
21
+ target?: string;
22
+ targetCollection?: unknown;
23
+ isAssociationField?: () => boolean;
24
+ };
25
+
17
26
  describe('fieldAssignOptions', () => {
18
27
  it('marks subform node as non-leaf so Cascader can lazy-load target collection fields', () => {
19
28
  const usersCollection = {
@@ -98,8 +107,58 @@ describe('fieldAssignOptions', () => {
98
107
  ).toBe(true);
99
108
  });
100
109
 
110
+ it('preserves configured display association field paths as nested cascader options', () => {
111
+ const orgNameField = {
112
+ name: 'orgname',
113
+ title: '公司名称(单行文本)',
114
+ interface: 'input',
115
+ type: 'string',
116
+ isAssociationField: () => false,
117
+ };
118
+ const orgCollection = {
119
+ getFields: () => [orgNameField],
120
+ getField: (name: string) => (name === 'orgname' ? orgNameField : null),
121
+ };
122
+ const orgAssociationField = {
123
+ name: 'org_m2o',
124
+ title: 'org_m2o',
125
+ interface: 'm2o',
126
+ type: 'belongsTo',
127
+ target: 'org',
128
+ isAssociationField: () => true,
129
+ targetCollection: orgCollection,
130
+ };
131
+ const rootCollection = {
132
+ getField: (name: string) => (name === 'org_m2o' ? orgAssociationField : null),
133
+ getFields: () => [orgAssociationField],
134
+ };
135
+ const displayAssociationItemModel = {
136
+ props: { label: '公司名称(单行文本)' },
137
+ getStepParams: (flowKey: string, stepKey: string) => {
138
+ if (flowKey === 'fieldSettings' && stepKey === 'init') return { fieldPath: 'org_m2o.orgname' };
139
+ return {};
140
+ },
141
+ subModels: {
142
+ field: {
143
+ context: { collectionField: orgNameField },
144
+ },
145
+ },
146
+ };
147
+ const formBlockModel = {
148
+ context: { collection: rootCollection },
149
+ subModels: { grid: { subModels: { items: [displayAssociationItemModel] } } },
150
+ };
151
+
152
+ const options = collectFieldAssignCascaderOptions({ formBlockModel, t: (s) => s });
153
+ const orgOption = options.find((option) => option.value === 'org_m2o');
154
+
155
+ expect(orgOption?.isLeaf).toBe(false);
156
+ expect(orgOption?.children?.some((child) => child.value === 'orgname' && child.isLeaf === true)).toBe(true);
157
+ expect(options.some((option) => option.value === 'orgname')).toBe(false);
158
+ });
159
+
101
160
  it('hides configured association fields deeper than relation / relation / field', () => {
102
- const makeItem = (fieldPath: string, field: any, label: string) => ({
161
+ const makeItem = (fieldPath: string, field: TestCollectionField, label: string) => ({
103
162
  props: { label },
104
163
  getStepParams: (flowKey: string, stepKey: string) => {
105
164
  if (flowKey === 'fieldSettings' && stepKey === 'init') return { fieldPath };
@@ -141,9 +200,98 @@ describe('fieldAssignOptions', () => {
141
200
  };
142
201
 
143
202
  const options = collectFieldAssignCascaderOptions({ formBlockModel, t: (s) => s });
203
+ const usersOption = options.find((item) => item.value === 'users');
204
+ const departmentOption = usersOption?.children?.find((item) => item.value === 'department');
205
+
206
+ expect(departmentOption?.children?.map((item) => item.value)).toContain('name');
207
+ expect(departmentOption?.children?.map((item) => item.value)).not.toContain('company');
208
+ });
209
+
210
+ it('hides configured non-association fields under a third association level', () => {
211
+ const codeField = {
212
+ name: 'code',
213
+ title: 'Code',
214
+ interface: 'input',
215
+ isAssociationField: () => false,
216
+ };
217
+ const companyCollection = {
218
+ getField: (name: string) => (name === 'code' ? codeField : null),
219
+ getFields: () => [codeField],
220
+ };
221
+ const companyField = {
222
+ name: 'company',
223
+ title: 'Company',
224
+ interface: 'm2o',
225
+ target: 'companies',
226
+ isAssociationField: () => true,
227
+ targetCollection: companyCollection,
228
+ };
229
+ const nameField = {
230
+ name: 'name',
231
+ title: 'Name',
232
+ interface: 'input',
233
+ isAssociationField: () => false,
234
+ };
235
+ const departmentCollection = {
236
+ getField: (name: string) => (name === 'name' ? nameField : name === 'company' ? companyField : null),
237
+ getFields: () => [nameField, companyField],
238
+ };
239
+ const departmentField = {
240
+ name: 'department',
241
+ title: 'Department',
242
+ interface: 'm2o',
243
+ target: 'departments',
244
+ isAssociationField: () => true,
245
+ targetCollection: departmentCollection,
246
+ };
247
+ const userCollection = {
248
+ getField: (name: string) => (name === 'department' ? departmentField : null),
249
+ getFields: () => [departmentField],
250
+ };
251
+ const usersField = {
252
+ name: 'users',
253
+ title: 'Users',
254
+ interface: 'm2m',
255
+ target: 'users',
256
+ isAssociationField: () => true,
257
+ targetCollection: userCollection,
258
+ };
259
+ const rootCollection = {
260
+ getField: (name: string) => (name === 'users' ? usersField : null),
261
+ getFields: () => [usersField],
262
+ };
263
+ const makeItem = (fieldPath: string, field: TestCollectionField, label: string) => ({
264
+ props: { label },
265
+ getStepParams: (flowKey: string, stepKey: string) => {
266
+ if (flowKey === 'fieldSettings' && stepKey === 'init') return { fieldPath };
267
+ return {};
268
+ },
269
+ subModels: {
270
+ field: {
271
+ context: { collectionField: field },
272
+ },
273
+ },
274
+ });
275
+ const formBlockModel = {
276
+ context: { collection: rootCollection },
277
+ subModels: {
278
+ grid: {
279
+ subModels: {
280
+ items: [
281
+ makeItem('users.department.name', nameField, 'Department name'),
282
+ makeItem('users.department.company.code', codeField, 'Company code'),
283
+ ],
284
+ },
285
+ },
286
+ },
287
+ };
288
+
289
+ const options = collectFieldAssignCascaderOptions({ formBlockModel, t: (s) => s });
290
+ const usersOption = options.find((item) => item.value === 'users');
291
+ const departmentOption = usersOption?.children?.find((item) => item.value === 'department');
144
292
 
145
- expect(options.map((item) => item.value)).toContain('name');
146
- expect(options.map((item) => item.value)).not.toContain('company');
293
+ expect(departmentOption?.children?.map((item) => item.value)).toContain('name');
294
+ expect(departmentOption?.children?.map((item) => item.value)).not.toContain('company');
147
295
  });
148
296
 
149
297
  it('hides lazy-loaded association selector options after two association levels', () => {
@@ -57,12 +57,11 @@ function getItemLabel(itemModel: any, t: (key: string) => string): string {
57
57
  return '';
58
58
  }
59
59
 
60
- function getLastPathSegment(path: string): string {
61
- const segs = String(path || '')
60
+ function splitFieldPath(path: string): string[] {
61
+ return String(path || '')
62
62
  .split('.')
63
63
  .map((s) => s.trim())
64
64
  .filter(Boolean);
65
- return segs.length ? segs[segs.length - 1] : '';
66
65
  }
67
66
 
68
67
  type CollectionLike = {
@@ -70,6 +69,15 @@ type CollectionLike = {
70
69
  getFields?: () => unknown[];
71
70
  };
72
71
 
72
+ type CollectionFieldLike = {
73
+ name?: unknown;
74
+ title?: unknown;
75
+ interface?: unknown;
76
+ target?: unknown;
77
+ isAssociationField?: () => boolean;
78
+ targetCollection?: CollectionLike | null;
79
+ };
80
+
73
81
  function getCollectionFromFormBlockModel(model: any): CollectionLike | null {
74
82
  if (!model || typeof model !== 'object') return null;
75
83
  const collection = (model as any)?.collection || (model as any)?.context?.collection;
@@ -89,14 +97,7 @@ export function buildFieldAssignCascaderOptionsFromCollection(
89
97
  const out: FieldAssignCascaderOption[] = [];
90
98
  for (const rawField of fields) {
91
99
  if (!rawField) continue;
92
- const f = rawField as {
93
- name?: unknown;
94
- title?: unknown;
95
- interface?: unknown;
96
- target?: unknown;
97
- isAssociationField?: () => boolean;
98
- targetCollection?: any;
99
- };
100
+ const f = rawField as CollectionFieldLike;
100
101
  const fieldInterface = typeof f.interface === 'string' ? f.interface : undefined;
101
102
  if (!fieldInterface) continue;
102
103
  if (fieldInterface === 'formula') continue;
@@ -123,23 +124,143 @@ export function buildFieldAssignCascaderOptionsFromCollection(
123
124
  function mergeRootOptions(
124
125
  configured: FieldAssignCascaderOption[],
125
126
  allFields: FieldAssignCascaderOption[],
127
+ ): FieldAssignCascaderOption[] {
128
+ return mergeCascaderOptions(configured, allFields);
129
+ }
130
+
131
+ function mergeCascaderOptions(
132
+ configured: FieldAssignCascaderOption[],
133
+ allFields: FieldAssignCascaderOption[],
126
134
  ): FieldAssignCascaderOption[] {
127
135
  const configuredList = Array.isArray(configured) ? configured : [];
128
136
  const allList = Array.isArray(allFields) ? allFields : [];
137
+ const result = configuredList.map((item) => cloneCascaderOption(item));
129
138
 
130
- const configuredValues = new Set<string>();
131
- for (const it of configuredList) {
132
- const v = it?.value ? String(it.value) : '';
133
- if (v) configuredValues.add(v);
139
+ for (const item of allList) {
140
+ mergeCascaderOptionInto(result, item);
134
141
  }
135
142
 
136
- const extra = allList.filter((it) => {
137
- const v = it?.value ? String(it.value) : '';
138
- if (!v) return false;
139
- return !configuredValues.has(v);
140
- });
143
+ return result;
144
+ }
145
+
146
+ function cloneCascaderOption(option: FieldAssignCascaderOption): FieldAssignCascaderOption {
147
+ return {
148
+ ...option,
149
+ children: option.children?.map((child) => cloneCascaderOption(child)),
150
+ };
151
+ }
152
+
153
+ function mergeCascaderOptionInto(options: FieldAssignCascaderOption[], source: FieldAssignCascaderOption) {
154
+ const value = source?.value ? String(source.value) : '';
155
+ if (!value) return;
156
+
157
+ const existing = options.find((item) => String(item?.value || '') === value);
158
+ if (!existing) {
159
+ options.push(cloneCascaderOption(source));
160
+ return;
161
+ }
162
+
163
+ if (!existing.label && source.label) {
164
+ existing.label = source.label;
165
+ }
166
+ if (source.isLeaf === false || existing.children?.length || source.children?.length) {
167
+ existing.isLeaf = false;
168
+ }
169
+ if (source.loading) {
170
+ existing.loading = source.loading;
171
+ }
172
+ if (source.children?.length) {
173
+ existing.children = mergeCascaderOptions(existing.children || [], source.children);
174
+ }
175
+ }
176
+
177
+ function getCollectionField(collection: CollectionLike | null, name: string): CollectionFieldLike | undefined {
178
+ const field = typeof collection?.getField === 'function' ? collection.getField(name) : undefined;
179
+ return field && typeof field === 'object' ? (field as CollectionFieldLike) : undefined;
180
+ }
181
+
182
+ function isAssociationFieldLike(field?: CollectionFieldLike | null) {
183
+ return !!(field?.isAssociationField?.() || field?.target || field?.targetCollection);
184
+ }
185
+
186
+ function getFieldLabelFromCollection(collection: CollectionLike | null, name: string, t: (key: string) => string) {
187
+ const field = getCollectionField(collection, name);
188
+ const title = typeof field?.title === 'string' && field.title ? field.title : name;
189
+ return t(title);
190
+ }
191
+
192
+ function getAssociationDepthFromFieldPath(
193
+ rootCollection: CollectionLike | null,
194
+ segments: string[],
195
+ ): number | undefined {
196
+ if (!rootCollection) return undefined;
197
+
198
+ let collection: CollectionLike | null = rootCollection;
199
+ let associationDepth = 0;
200
+ for (let index = 0; index < segments.length; index++) {
201
+ const field = getCollectionField(collection, segments[index]);
202
+ if (!field) return undefined;
203
+
204
+ if (isAssociationFieldLike(field)) {
205
+ associationDepth += 1;
206
+ collection = field.targetCollection || null;
207
+ continue;
208
+ }
209
+
210
+ if (index < segments.length - 1) return undefined;
211
+ }
212
+
213
+ return associationDepth;
214
+ }
215
+
216
+ function shouldIncludeConfiguredFieldPath(options: {
217
+ segments: string[];
218
+ rootCollection: CollectionLike | null;
219
+ leafField?: CollectionFieldLike | null;
220
+ maxAssociationFieldDepth: number;
221
+ }) {
222
+ const resolvedAssociationDepth = getAssociationDepthFromFieldPath(options.rootCollection, options.segments);
223
+ if (typeof resolvedAssociationDepth === 'number') {
224
+ return resolvedAssociationDepth <= options.maxAssociationFieldDepth;
225
+ }
226
+
227
+ const leafIsAssociation = isAssociationFieldLike(options.leafField);
228
+ const maxPathLength = leafIsAssociation ? options.maxAssociationFieldDepth : options.maxAssociationFieldDepth + 1;
229
+ return options.segments.length <= maxPathLength;
230
+ }
231
+
232
+ function buildNestedOptionFromFieldPath(options: {
233
+ targetPath: string;
234
+ leaf: FieldAssignCascaderOption;
235
+ rootCollection: CollectionLike | null;
236
+ t: (key: string) => string;
237
+ }): FieldAssignCascaderOption {
238
+ const segments = splitFieldPath(options.targetPath);
239
+ if (segments.length <= 1) {
240
+ return options.leaf;
241
+ }
242
+
243
+ const build = (index: number, collection: CollectionLike | null): FieldAssignCascaderOption => {
244
+ const value = segments[index];
245
+ const isLast = index === segments.length - 1;
246
+ if (isLast) {
247
+ return {
248
+ ...options.leaf,
249
+ value,
250
+ };
251
+ }
252
+
253
+ const field = getCollectionField(collection, value);
254
+ const nextCollection = field?.targetCollection || null;
255
+ return {
256
+ label: getFieldLabelFromCollection(collection, value, options.t),
257
+ value,
258
+ isLeaf: false,
259
+ children: [build(index + 1, nextCollection)],
260
+ };
261
+ };
141
262
 
142
- return [...configuredList, ...extra];
263
+ return build(0, options.rootCollection);
143
264
  }
144
265
 
145
266
  export function collectFieldAssignCascaderOptions(options: {
@@ -167,7 +288,8 @@ export function collectFieldAssignCascaderOptions(options: {
167
288
  const targetPath = getItemFieldPath(item);
168
289
  if (!targetPath) continue;
169
290
 
170
- const seg = getLastPathSegment(targetPath);
291
+ const segments = splitFieldPath(targetPath);
292
+ const seg = segments[segments.length - 1];
171
293
  if (!seg) continue;
172
294
 
173
295
  const node: FieldAssignCascaderOption = {
@@ -179,10 +301,16 @@ export function collectFieldAssignCascaderOptions(options: {
179
301
  const childItems = fieldModel?.subModels?.grid?.subModels?.items;
180
302
  const cf: (CollectionField & { target?: unknown; targetCollection?: unknown }) | undefined =
181
303
  fieldModel?.context?.collectionField;
182
- const isAssociation = !!(cf?.isAssociationField?.() || cf?.target || cf?.targetCollection);
304
+ const isAssociation = isAssociationFieldLike(cf);
183
305
  const hasTargetCollection = !!cf?.targetCollection;
184
- const associationDepth = isAssociation ? targetPath.split('.').filter(Boolean).length : 0;
185
- if (isAssociation && associationDepth > maxAssociationFieldDepth) {
306
+ if (
307
+ !shouldIncludeConfiguredFieldPath({
308
+ segments,
309
+ rootCollection,
310
+ leafField: cf,
311
+ maxAssociationFieldDepth,
312
+ })
313
+ ) {
186
314
  continue;
187
315
  }
188
316
 
@@ -203,7 +331,7 @@ export function collectFieldAssignCascaderOptions(options: {
203
331
  node.isLeaf = true;
204
332
  }
205
333
  }
206
- out.push(node);
334
+ mergeCascaderOptionInto(out, buildNestedOptionFromFieldPath({ targetPath, leaf: node, rootCollection, t }));
207
335
  continue;
208
336
  }
209
337
 
@@ -215,7 +343,7 @@ export function collectFieldAssignCascaderOptions(options: {
215
343
  node.isLeaf = true;
216
344
  }
217
345
 
218
- out.push(node);
346
+ mergeCascaderOptionInto(out, buildNestedOptionFromFieldPath({ targetPath, leaf: node, rootCollection, t }));
219
347
  }
220
348
 
221
349
  return out;