@nocobase/client-v2 2.1.0-alpha.35 → 2.1.0-alpha.36

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.
@@ -8,7 +8,18 @@
8
8
  */
9
9
 
10
10
  import { describe, expect, it, vi } from 'vitest';
11
- import { getLatestSubTableRowRecord, buildRowPathFromFieldIndex } from '../SubTableColumnModel';
11
+ import { FlowEngine } from '@nocobase/flow-engine';
12
+ import { DisplayTitleFieldModel } from '../../../DisplayTitleFieldModel';
13
+ import { titleField } from '../../../../../actions/titleField';
14
+ import {
15
+ SubTableColumnModel,
16
+ getLatestSubTableRowRecord,
17
+ buildRowPathFromFieldIndex,
18
+ isSubTableColumnConfiguredReadPretty,
19
+ getSubTableColumnTitleField,
20
+ getSubTableColumnReadPrettyFieldProps,
21
+ isSubTableColumnReadPretty,
22
+ } from '../SubTableColumnModel';
12
23
 
13
24
  describe('SubTableColumnModel row record helpers', () => {
14
25
  it('builds the row path from fieldIndex entries', () => {
@@ -39,4 +50,162 @@ describe('SubTableColumnModel row record helpers', () => {
39
50
 
40
51
  expect(getLatestSubTableRowRecord(form, ['roles:0'], fallback)).toBe(fallback);
41
52
  });
53
+
54
+ it('treats a display-only column pattern as read-pretty mode', () => {
55
+ expect(isSubTableColumnReadPretty({ props: { pattern: 'readPretty' } })).toBe(true);
56
+ expect(isSubTableColumnReadPretty({ props: { readPretty: true } })).toBe(true);
57
+ expect(isSubTableColumnReadPretty({ props: { pattern: 'editable' } })).toBe(false);
58
+ });
59
+
60
+ it('treats a saved display-only column pattern as read-pretty during beforeRender restore', () => {
61
+ expect(
62
+ isSubTableColumnConfiguredReadPretty({
63
+ props: {},
64
+ getStepParams: vi.fn(() => ({ pattern: 'readPretty' })),
65
+ }),
66
+ ).toBe(true);
67
+ });
68
+
69
+ it('passes the association title field to read-pretty cell field models', () => {
70
+ const relationValue = { id: 1, name: 'Alice' };
71
+ expect(
72
+ getSubTableColumnReadPrettyFieldProps(
73
+ {
74
+ props: {},
75
+ collectionField: {
76
+ targetCollectionTitleFieldName: 'name',
77
+ },
78
+ },
79
+ relationValue,
80
+ ),
81
+ ).toEqual({
82
+ value: relationValue,
83
+ titleField: 'name',
84
+ });
85
+ });
86
+
87
+ it('resolves the saved title field before the target collection default', () => {
88
+ expect(
89
+ getSubTableColumnTitleField({
90
+ props: { titleField: 'nickname' },
91
+ subModels: {
92
+ field: {
93
+ props: {
94
+ titleField: 'name',
95
+ },
96
+ },
97
+ },
98
+ collectionField: {
99
+ targetCollectionTitleFieldName: 'title',
100
+ },
101
+ }),
102
+ ).toBe('nickname');
103
+ });
104
+
105
+ it('applies the configured title field to a display-only association column', async () => {
106
+ const engine = new FlowEngine();
107
+ engine.registerModels({ SubTableColumnModel, DisplayTitleFieldModel });
108
+ engine.registerActions({ titleField });
109
+
110
+ const rolesField = {
111
+ name: 'roles',
112
+ title: 'Roles',
113
+ collection: { name: 'users' },
114
+ targetCollectionTitleFieldName: 'name',
115
+ targetCollection: {
116
+ name: 'roles',
117
+ getField: vi.fn((name: string) => ({
118
+ name,
119
+ getComponentProps: () => ({ componentField: name }),
120
+ })),
121
+ },
122
+ isAssociationField: () => true,
123
+ getComponentProps: () => ({}),
124
+ };
125
+
126
+ const column = engine.createModel<SubTableColumnModel>({
127
+ use: SubTableColumnModel,
128
+ uid: 'roles-display-column-title-field',
129
+ stepParams: {
130
+ fieldSettings: {
131
+ init: {
132
+ dataSourceKey: 'main',
133
+ collectionName: 'users',
134
+ fieldPath: 'roles',
135
+ },
136
+ },
137
+ subTableColumnSettings: {
138
+ pattern: {
139
+ pattern: 'readPretty',
140
+ },
141
+ fieldNames: {
142
+ label: 'nickname',
143
+ },
144
+ },
145
+ },
146
+ });
147
+ column.context.defineProperty('collectionField', { value: rolesField });
148
+ column.context.defineProperty('blockModel', { value: { addAppends: vi.fn() } });
149
+ column.setSubModel('field', {
150
+ use: DisplayTitleFieldModel,
151
+ uid: 'roles-display-field-title-field',
152
+ });
153
+
154
+ await column.dispatchEvent('beforeRender');
155
+
156
+ expect(column.props.titleField).toBe('nickname');
157
+ expect(column.props.componentField).toBe('nickname');
158
+ });
159
+
160
+ it('applies saved display field settings to the inner field during column beforeRender', async () => {
161
+ const engine = new FlowEngine();
162
+ engine.registerModels({ SubTableColumnModel, DisplayTitleFieldModel });
163
+
164
+ const rolesCollection = {
165
+ name: 'roles',
166
+ filterTargetKey: 'id',
167
+ };
168
+ const rolesField = {
169
+ name: 'roles',
170
+ title: 'Roles',
171
+ collection: { name: 'users' },
172
+ targetCollection: rolesCollection,
173
+ isAssociationField: () => true,
174
+ getComponentProps: () => ({ titleField: 'name' }),
175
+ };
176
+
177
+ const column = engine.createModel<SubTableColumnModel>({
178
+ use: SubTableColumnModel,
179
+ uid: 'roles-title-column',
180
+ stepParams: {
181
+ fieldSettings: {
182
+ init: {
183
+ dataSourceKey: 'main',
184
+ collectionName: 'users',
185
+ fieldPath: 'roles',
186
+ },
187
+ },
188
+ },
189
+ });
190
+ column.context.defineProperty('collectionField', { value: rolesField });
191
+ column.context.defineProperty('blockModel', { value: { addAppends: vi.fn() } });
192
+
193
+ const field = column.setSubModel('field', {
194
+ use: DisplayTitleFieldModel,
195
+ uid: 'roles-title-display',
196
+ stepParams: {
197
+ displayFieldSettings: {
198
+ clickToOpen: {
199
+ clickToOpen: true,
200
+ },
201
+ },
202
+ },
203
+ }) as DisplayTitleFieldModel;
204
+
205
+ expect(field.props.clickToOpen).toBeUndefined();
206
+
207
+ await column.dispatchEvent('beforeRender');
208
+
209
+ expect(field.props.clickToOpen).toBe(true);
210
+ });
42
211
  });
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { CollectionField, tExpr } from '@nocobase/flow-engine';
11
+ import { uid } from '@formily/shared';
11
12
  import { Tag } from 'antd';
12
13
  import { castArray, get } from 'lodash';
13
14
  import React from 'react';
@@ -38,9 +39,49 @@ const hasAssociationPathName = (parent: unknown): parent is { associationPathNam
38
39
 
39
40
  const hasUsableSourceId = (sourceId: unknown) => sourceId !== undefined && sourceId !== null && String(sourceId) !== '';
40
41
 
42
+ function getParentAssociationField(model: FieldModel): CollectionField | null {
43
+ const parentCollectionField =
44
+ (model.parent as any)?.context?.collectionField || (model.parent as any)?.collectionField;
45
+ return parentCollectionField?.isAssociationField?.() ? parentCollectionField : null;
46
+ }
47
+
48
+ export function applyClickToOpenProps(ctx: any, params: any) {
49
+ const collectionField = ctx.collectionField?.isAssociationField?.()
50
+ ? ctx.collectionField
51
+ : ctx.model?.parent?.context?.collectionField || ctx.collectionField;
52
+ ctx.model.setProps({
53
+ clickToOpen: params.clickToOpen,
54
+ ...(collectionField?.getComponentProps?.() || {}),
55
+ });
56
+ }
57
+
58
+ export async function refreshClickToOpenRuntime(ctx: any) {
59
+ ctx.model.invalidateFlowCache?.('beforeRender', true);
60
+ await ctx.model.rerender?.();
61
+
62
+ const parent = ctx.model.parent;
63
+ if (!parent) {
64
+ return;
65
+ }
66
+ parent.invalidateFlowCache?.('beforeRender', true);
67
+ parent.setProps?.({
68
+ __displayFieldRefreshKey: uid(),
69
+ });
70
+ await parent.rerender?.();
71
+ }
72
+
73
+ export async function applyClickToOpenSetting(ctx: any, params: any) {
74
+ applyClickToOpenProps(ctx, params);
75
+ await refreshClickToOpenRuntime(ctx);
76
+ }
77
+
41
78
  export class ClickableFieldModel extends FieldModel {
42
79
  get collectionField(): CollectionField {
43
- return this.context.collectionField;
80
+ const collectionField = this.context.collectionField;
81
+ if (collectionField?.isAssociationField?.()) {
82
+ return collectionField;
83
+ }
84
+ return getParentAssociationField(this) || collectionField;
44
85
  }
45
86
 
46
87
  /**
@@ -294,8 +335,11 @@ ClickableFieldModel.registerFlow({
294
335
  hideInSettings(ctx) {
295
336
  return ctx.disableFieldClickToOpen;
296
337
  },
338
+ async afterParamsSave(ctx, params) {
339
+ await applyClickToOpenSetting(ctx, params);
340
+ },
297
341
  handler(ctx, params) {
298
- ctx.model.setProps({ clickToOpen: params.clickToOpen, ...ctx.collectionField.getComponentProps() });
342
+ applyClickToOpenProps(ctx, params);
299
343
  },
300
344
  },
301
345
  },
@@ -12,12 +12,20 @@ import { Typography } from 'antd';
12
12
  import { castArray } from 'lodash';
13
13
  import { css } from '@emotion/css';
14
14
  import React from 'react';
15
- import { FieldModel } from '../base';
16
- import { hasDisplayValue, normalizeDisplayValue } from '../utils/displayValueUtils';
15
+ import { openViewFlow } from '../../flows/openViewFlow';
16
+ import { applyClickToOpenProps, applyClickToOpenSetting, ClickableFieldModel } from './ClickableFieldModel';
17
17
 
18
- export class DisplayTitleFieldModel extends FieldModel {
18
+ function isParentAssociationField(ctx: any) {
19
+ return !!ctx.model?.parent?.context?.collectionField?.isAssociationField?.();
20
+ }
21
+
22
+ export class DisplayTitleFieldModel extends ClickableFieldModel {
19
23
  get collectionField(): CollectionField {
20
- return this.context.collectionField;
24
+ const collectionField = this.context.collectionField;
25
+ if (collectionField?.isAssociationField?.()) {
26
+ return collectionField;
27
+ }
28
+ return (this.parent as any)?.context?.collectionField || collectionField;
21
29
  }
22
30
 
23
31
  renderComponent(value) {
@@ -56,20 +64,13 @@ export class DisplayTitleFieldModel extends FieldModel {
56
64
  };
57
65
  if (titleField) {
58
66
  const result = castArray(value).flatMap((v, idx) => {
59
- const titleCollectionField =
60
- this.context.collectionField?.targetCollection?.getField?.(titleField) || this.context.collectionField;
61
- const displayValue = normalizeDisplayValue(v?.[titleField], { collectionField: titleCollectionField });
62
- const result = this.renderComponent(displayValue);
63
- const node = hasDisplayValue(displayValue) ? result : 'N/A';
64
- return idx === 0 ? [node] : [<span key={`sep-${idx}`}>, </span>, node];
67
+ const node = this.renderInDisplayStyle(v?.[titleField], v, Array.isArray(value));
68
+ const keyedNode = React.isValidElement(node) ? React.cloneElement(node, { key: `item-${idx}` }) : node;
69
+ return idx === 0 ? [keyedNode] : [<span key={`sep-${idx}`}>, </span>, keyedNode];
65
70
  });
66
71
  return <Typography.Text {...typographyProps}>{result}</Typography.Text>;
67
72
  } else {
68
- const textContent = (
69
- <Typography.Text {...typographyProps}>
70
- {this.renderComponent(normalizeDisplayValue(value, { collectionField: this.context.collectionField }))}
71
- </Typography.Text>
72
- );
73
+ const textContent = <Typography.Text {...typographyProps}>{this.renderInDisplayStyle(value)}</Typography.Text>;
73
74
  return textContent;
74
75
  }
75
76
  }
@@ -83,5 +84,29 @@ DisplayTitleFieldModel.registerFlow({
83
84
  overflowMode: {
84
85
  use: 'overflowMode',
85
86
  },
87
+ clickToOpen: {
88
+ title: tExpr('Enable click-to-open'),
89
+ uiMode: { type: 'switch', key: 'clickToOpen' },
90
+ defaultParams: (ctx) => {
91
+ if (ctx.disableFieldClickToOpen) {
92
+ return {
93
+ clickToOpen: false,
94
+ };
95
+ }
96
+ return {
97
+ clickToOpen: ctx.collectionField?.isAssociationField?.() || isParentAssociationField(ctx),
98
+ };
99
+ },
100
+ hideInSettings(ctx) {
101
+ return ctx.disableFieldClickToOpen;
102
+ },
103
+ async afterParamsSave(ctx, params) {
104
+ await applyClickToOpenSetting(ctx, params);
105
+ },
106
+ handler(ctx, params) {
107
+ applyClickToOpenProps(ctx, params);
108
+ },
109
+ },
86
110
  },
87
111
  });
112
+ DisplayTitleFieldModel.registerFlow(openViewFlow);
@@ -8,9 +8,11 @@
8
8
  */
9
9
 
10
10
  import { describe, expect, it, vi } from 'vitest';
11
- import { FlowEngine } from '@nocobase/flow-engine';
12
- import { render, screen } from '@testing-library/react';
11
+ import { FlowEngine, FlowModel } from '@nocobase/flow-engine';
12
+ import React from 'react';
13
+ import { fireEvent, render, screen } from '@testing-library/react';
13
14
  import { ClickableFieldModel } from '../ClickableFieldModel';
15
+ import { DisplayTitleFieldModel } from '../DisplayTitleFieldModel';
14
16
  import { DisplayTextFieldModel } from '../DisplayTextFieldModel';
15
17
 
16
18
  function createRolesFieldModel(sourceRecord: Record<string, any>) {
@@ -87,6 +89,133 @@ describe('ClickableFieldModel', () => {
87
89
  );
88
90
  });
89
91
 
92
+ it('uses the parent association field when the display model is bound to the title field', () => {
93
+ const engine = new FlowEngine();
94
+ engine.registerModels({ ClickableFieldModel });
95
+
96
+ const usersCollection = {
97
+ name: 'users',
98
+ filterTargetKey: 'id',
99
+ };
100
+ const rolesCollection = {
101
+ name: 'roles',
102
+ filterTargetKey: 'name',
103
+ };
104
+ const rolesField = {
105
+ name: 'roles',
106
+ target: 'roles',
107
+ targetKey: 'name',
108
+ type: 'belongsToMany',
109
+ interface: 'm2m',
110
+ collection: usersCollection,
111
+ targetCollection: rolesCollection,
112
+ isAssociationField: () => true,
113
+ };
114
+ const titleField = {
115
+ name: 'title',
116
+ collection: rolesCollection,
117
+ isAssociationField: () => false,
118
+ };
119
+
120
+ const parent = engine.createModel<FlowModel>({
121
+ use: FlowModel,
122
+ uid: 'roles-column',
123
+ });
124
+ parent.context.defineProperty('collectionField', { value: rolesField });
125
+
126
+ const model = engine.createModel<ClickableFieldModel>({
127
+ use: ClickableFieldModel,
128
+ uid: 'roles-title-display',
129
+ });
130
+ model.setParent(parent);
131
+ model.context.defineProperty('collectionField', { value: titleField });
132
+ model.context.defineProperty('blockModel', { value: { collection: usersCollection } });
133
+ model.context.defineProperty('record', { value: { id: 1 } });
134
+ const dispatchEvent = vi.spyOn(model, 'dispatchEvent').mockResolvedValue([]);
135
+ const event = { type: 'click' };
136
+
137
+ model.onClick(event, { name: 'admin', title: 'Admin' });
138
+
139
+ expect(dispatchEvent).toHaveBeenCalledWith(
140
+ 'click',
141
+ {
142
+ event,
143
+ filterByTk: 'admin',
144
+ collectionName: 'users',
145
+ associationName: 'users.roles',
146
+ sourceId: 1,
147
+ },
148
+ { debounce: true },
149
+ );
150
+ });
151
+
152
+ it('renders title display values as links when click-to-open is enabled', () => {
153
+ const engine = new FlowEngine();
154
+ engine.registerModels({ DisplayTitleFieldModel });
155
+
156
+ const usersCollection = {
157
+ name: 'users',
158
+ filterTargetKey: 'id',
159
+ };
160
+ const rolesCollection = {
161
+ name: 'roles',
162
+ filterTargetKey: 'name',
163
+ };
164
+ const rolesField = {
165
+ name: 'roles',
166
+ target: 'roles',
167
+ targetKey: 'name',
168
+ type: 'belongsToMany',
169
+ interface: 'm2m',
170
+ collection: usersCollection,
171
+ targetCollection: rolesCollection,
172
+ isAssociationField: () => true,
173
+ };
174
+ const titleField = {
175
+ name: 'title',
176
+ collection: rolesCollection,
177
+ isAssociationField: () => false,
178
+ };
179
+
180
+ const parent = engine.createModel<FlowModel>({
181
+ use: FlowModel,
182
+ uid: 'roles-title-column',
183
+ });
184
+ parent.context.defineProperty('collectionField', { value: rolesField });
185
+
186
+ const model = engine.createModel<DisplayTitleFieldModel>({
187
+ use: DisplayTitleFieldModel,
188
+ uid: 'roles-title-display-link',
189
+ props: {
190
+ clickToOpen: true,
191
+ titleField: 'name',
192
+ value: { name: 'admin', title: 'Admin' },
193
+ },
194
+ });
195
+ model.setParent(parent);
196
+ model.context.defineProperty('collectionField', { value: titleField });
197
+ model.context.defineProperty('blockModel', { value: { collection: usersCollection } });
198
+ model.context.defineProperty('record', { value: { id: 1 } });
199
+ const dispatchEvent = vi.spyOn(model, 'dispatchEvent').mockResolvedValue([]);
200
+
201
+ render(React.createElement(React.Fragment, null, model.render()));
202
+ const link = screen.getByText('admin').closest('a');
203
+ expect(link).toBeTruthy();
204
+
205
+ fireEvent.click(link);
206
+
207
+ expect(dispatchEvent).toHaveBeenCalledWith(
208
+ 'click',
209
+ expect.objectContaining({
210
+ filterByTk: 'admin',
211
+ collectionName: 'users',
212
+ associationName: 'users.roles',
213
+ sourceId: 1,
214
+ }),
215
+ { debounce: true },
216
+ );
217
+ });
218
+
90
219
  it('renders object title field values by configured target title field', () => {
91
220
  const engine = new FlowEngine();
92
221
  engine.registerModels({ DisplayTextFieldModel });
@@ -107,4 +236,53 @@ describe('ClickableFieldModel', () => {
107
236
  expect(screen.getByText('S-001')).toBeInTheDocument();
108
237
  expect(screen.queryByText('Sales')).not.toBeInTheDocument();
109
238
  });
239
+
240
+ it('refreshes the parent column when title display click-to-open changes', async () => {
241
+ const engine = new FlowEngine();
242
+ engine.registerModels({ DisplayTitleFieldModel });
243
+
244
+ const rolesField = {
245
+ name: 'roles',
246
+ target: 'roles',
247
+ collection: { name: 'users' },
248
+ targetCollection: { name: 'roles' },
249
+ isAssociationField: () => true,
250
+ getComponentProps: () => ({ titleField: 'name' }),
251
+ };
252
+ const titleField = {
253
+ name: 'title',
254
+ collection: { name: 'roles' },
255
+ isAssociationField: () => false,
256
+ };
257
+
258
+ const parent = engine.createModel<FlowModel>({
259
+ use: FlowModel,
260
+ uid: 'roles-title-column-refresh',
261
+ });
262
+ parent.context.defineProperty('collectionField', { value: rolesField });
263
+ const parentSetProps = vi.spyOn(parent, 'setProps');
264
+ const parentRerender = vi.spyOn(parent, 'rerender').mockResolvedValue(undefined);
265
+
266
+ const model = engine.createModel<DisplayTitleFieldModel>({
267
+ use: DisplayTitleFieldModel,
268
+ uid: 'roles-title-display-refresh',
269
+ });
270
+ model.setParent(parent);
271
+ model.context.defineProperty('collectionField', { value: titleField });
272
+ const modelRerender = vi.spyOn(model, 'rerender').mockResolvedValue(undefined);
273
+
274
+ const clickToOpenStep = model.getFlow('displayFieldSettings').steps.clickToOpen;
275
+
276
+ await clickToOpenStep.afterParamsSave(model.context as any, { clickToOpen: true }, { clickToOpen: false });
277
+
278
+ expect(model.props).toMatchObject({
279
+ clickToOpen: true,
280
+ titleField: 'name',
281
+ });
282
+ expect(modelRerender).toHaveBeenCalled();
283
+ expect(parentSetProps).toHaveBeenCalledWith({
284
+ __displayFieldRefreshKey: expect.any(String),
285
+ });
286
+ expect(parentRerender).toHaveBeenCalled();
287
+ });
110
288
  });
@@ -16,6 +16,7 @@ import * as formilyCore from '@formily/core';
16
16
  import * as formilyReact from '@formily/react';
17
17
  import * as formilyReactive from '@formily/reactive';
18
18
  import * as formilyShared from '@formily/shared';
19
+ import * as nocobaseEvaluators from '@nocobase/evaluators/client';
19
20
  import * as nocobaseClientUtils from '@nocobase/utils/client';
20
21
  import { dayjs } from '@nocobase/utils/client';
21
22
  import * as nocobaseFlowEngine from '@nocobase/flow-engine';
@@ -71,6 +72,8 @@ export function defineGlobalDeps(requirejs: RequireJS) {
71
72
  requirejs.define('@nocobase/client-v2', () => nocobaseClientV2);
72
73
  requirejs.define('@nocobase/client-v2/client-v2', () => nocobaseClientV2);
73
74
  requirejs.define('@nocobase/flow-engine', () => nocobaseFlowEngine);
75
+ requirejs.define('@nocobase/evaluators', () => nocobaseEvaluators);
76
+ requirejs.define('@nocobase/evaluators/client', () => nocobaseEvaluators);
74
77
 
75
78
  // utils
76
79
  requirejs.define('ahooks', () => ahooks);