@nocobase/client-v2 2.1.5 → 2.1.7

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 (37) hide show
  1. package/es/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.d.ts +2 -0
  2. package/es/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.d.ts +1 -0
  3. package/es/flow/utils/dateTimeDisplayProps.d.ts +49 -0
  4. package/es/index.mjs +110 -90
  5. package/lib/index.js +89 -69
  6. package/package.json +7 -7
  7. package/src/flow/actions/__tests__/linkageRules.hiddenSync.restore.test.ts +19 -5
  8. package/src/flow/actions/__tests__/linkageRules.subFormSetFieldProps.test.ts +92 -0
  9. package/src/flow/actions/dateTimeFormat.tsx +42 -28
  10. package/src/flow/actions/linkageRules.tsx +45 -11
  11. package/src/flow/admin-shell/admin-layout/HelpLite.tsx +1 -3
  12. package/src/flow/components/DynamicFlowsIcon.tsx +447 -126
  13. package/src/flow/components/__tests__/DynamicFlowsIcon.test.tsx +148 -2
  14. package/src/flow/flows/editMarkdownFlow.tsx +1 -1
  15. package/src/flow/index.ts +1 -1
  16. package/src/flow/internal/utils/operatorSchemaHelper.ts +15 -1
  17. package/src/flow/models/blocks/filter-manager/flow-actions/__tests__/customizeFilterRender.test.tsx +56 -1
  18. package/src/flow/models/blocks/table/TableColumnModel.tsx +36 -3
  19. package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +159 -1
  20. package/src/flow/models/fields/AssociationFieldModel/AssociationFieldModel.tsx +1 -1
  21. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +29 -6
  22. package/src/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.tsx +16 -4
  23. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +24 -1
  24. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableField.tsx +13 -1
  25. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableColumnModel.rowRecord.test.ts +12 -0
  26. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableFieldModel.reset.test.ts +180 -0
  27. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/index.tsx +6 -4
  28. package/src/flow/models/fields/AssociationFieldModel/__tests__/AssociationFieldModel.updateAssociation.test.ts +72 -0
  29. package/src/flow/models/fields/AssociationFieldModel/__tests__/RecordPickerFieldModel.itemContext.test.ts +23 -0
  30. package/src/flow/models/fields/ClickableFieldModel.tsx +5 -0
  31. package/src/flow/models/fields/DisplayDateTimeFieldModel.tsx +29 -1
  32. package/src/flow/models/fields/SelectFieldModel.tsx +6 -4
  33. package/src/flow/models/fields/__tests__/DisplayDateTimeFieldModel.test.tsx +96 -0
  34. package/src/flow/models/fields/__tests__/SelectFieldModel.test.tsx +43 -0
  35. package/src/flow/utils/__tests__/dateTimeFormat.test.ts +258 -0
  36. package/src/flow/utils/dateTimeDisplayProps.ts +135 -0
  37. package/src/settings-center/plugin-manager/index.tsx +3 -0
@@ -203,6 +203,8 @@ const DisplayTable = (props) => {
203
203
  onSelectExitRecordClick,
204
204
  resetPage,
205
205
  allowCreate,
206
+ formValuesChangeEmitter,
207
+ onResetFieldValue,
206
208
  } = props;
207
209
  const [currentPage, setCurrentPage] = useState(1);
208
210
  const [currentPageSize, setCurrentPageSize] = useState(pageSize);
@@ -223,6 +225,18 @@ const DisplayTable = (props) => {
223
225
  resetPage && setCurrentPage(1);
224
226
  }, [resetPage]);
225
227
 
228
+ useEffect(() => {
229
+ if (!formValuesChangeEmitter?.on || !formValuesChangeEmitter?.off || !onResetFieldValue) return;
230
+ const listener = () => {
231
+ onResetFieldValue();
232
+ setTableData([]);
233
+ };
234
+ formValuesChangeEmitter.on('onFieldReset', listener);
235
+ return () => {
236
+ formValuesChangeEmitter.off('onFieldReset', listener);
237
+ };
238
+ }, [formValuesChangeEmitter, onResetFieldValue]);
239
+
226
240
  const pagination = useMemo(() => {
227
241
  return {
228
242
  current: currentPage, // 当前页码
@@ -237,7 +251,7 @@ const DisplayTable = (props) => {
237
251
  return t('Total {{count}} items', { count: total });
238
252
  },
239
253
  } as any;
240
- }, [currentPage, currentPageSize, tableData]);
254
+ }, [currentPage, currentPageSize, tableData, t]);
241
255
 
242
256
  const columns = useMemo(() => {
243
257
  const cols = adjustColumnOrder(
@@ -421,10 +435,6 @@ export class PopupSubTableFieldModel extends AssociationFieldModel {
421
435
  currentPageSize,
422
436
  });
423
437
  };
424
- // 监听表单reset
425
- this.context.blockModel.emitter.on('onFieldReset', () => {
426
- this.props?.onChange([]);
427
- });
428
438
  }
429
439
  }
430
440
 
@@ -457,8 +467,21 @@ export class PopupSubTableFieldModel extends AssociationFieldModel {
457
467
  }
458
468
 
459
469
  public render() {
470
+ const fieldPathArray = this.context.fieldPathArray ?? this.parent?.context?.fieldPathArray;
471
+ const onResetFieldValue = () => {
472
+ const value = [];
473
+ this.setProps({ value });
474
+ this.context.blockModel?.setFieldValue?.(fieldPathArray, value);
475
+ };
460
476
  return (
461
- <DisplayTable {...this.props} collection={this.collection} baseColumns={this.getBaseColumns(this)} model={this} />
477
+ <DisplayTable
478
+ {...this.props}
479
+ collection={this.collection}
480
+ baseColumns={this.getBaseColumns(this)}
481
+ model={this}
482
+ formValuesChangeEmitter={this.context.blockModel?.emitter}
483
+ onResetFieldValue={onResetFieldValue}
484
+ />
462
485
  );
463
486
  }
464
487
  }
@@ -99,6 +99,18 @@ export function collectAssociationHydrationCandidates(options: {
99
99
  return candidates;
100
100
  }
101
101
 
102
+ export function getAssociationHydrationNamePath(model: any) {
103
+ return model?.context?.fieldPathArray ?? model?.context?.fieldPath ?? model?.props?.name;
104
+ }
105
+
106
+ export function getAssociationHydrationSetterContext(model: any) {
107
+ if (typeof model?.context?.setFormValue === 'function') {
108
+ return model.context;
109
+ }
110
+
111
+ return model?.context?.blockModel?.context;
112
+ }
113
+
102
114
  function markAssociationHydrationDone(statusMap: Map<string, HydrateStatus>, tkKey: string | null | undefined) {
103
115
  if (!tkKey) return;
104
116
  statusMap.set(tkKey, 'done');
@@ -245,8 +257,8 @@ const LazySelect = (props: Readonly<LazySelectProps>) => {
245
257
  });
246
258
  if (!candidates.length) return;
247
259
 
248
- const namePath = model?.props?.name ?? model?.context?.fieldPathArray;
249
- const blockCtx: any = model?.context?.blockModel?.context;
260
+ const namePath = getAssociationHydrationNamePath(model);
261
+ const setterCtx: any = getAssociationHydrationSetterContext(model);
250
262
 
251
263
  candidates.forEach(({ item, tk, tkKey }) => {
252
264
  void (async () => {
@@ -268,8 +280,8 @@ const LazySelect = (props: Readonly<LazySelectProps>) => {
268
280
  })
269
281
  : merge;
270
282
 
271
- if (blockCtx && typeof blockCtx.setFormValue === 'function' && namePath != null) {
272
- await blockCtx.setFormValue(namePath, nextValue, {
283
+ if (setterCtx && typeof setterCtx.setFormValue === 'function' && namePath != null) {
284
+ await setterCtx.setFormValue(namePath, nextValue, {
273
285
  source: 'default',
274
286
  markExplicit: false,
275
287
  triggerEvent: false,
@@ -231,6 +231,20 @@ export function getLatestSubTableRowRecord(form: any, fieldIndex: unknown, fallb
231
231
  return typeof latestRecord === 'undefined' ? fallbackRecord : latestRecord;
232
232
  }
233
233
 
234
+ export function buildSubTableColumnNamePath(
235
+ fieldPath: Array<string | number>,
236
+ rowIdx: number,
237
+ namePath: string | number,
238
+ rowFieldIndex: unknown,
239
+ ): Array<string | number> {
240
+ const currentRowPath = buildRowPathFromFieldIndex(rowFieldIndex);
241
+ if (currentRowPath?.length) {
242
+ return [...currentRowPath, namePath];
243
+ }
244
+
245
+ return [...fieldPath, rowIdx, namePath];
246
+ }
247
+
234
248
  function shouldCommitImmediately(value: any) {
235
249
  if (Array.isArray(value)) {
236
250
  return true;
@@ -372,9 +386,18 @@ const MemoCell: React.FC<CellProps> = React.memo(
372
386
  {parent.mapSubModels('field', (action: FieldModel) => {
373
387
  const fieldPath = action.context.fieldPath.split('.');
374
388
  const namePath = fieldPath.pop();
389
+ if (typeof namePath === 'undefined') {
390
+ return null;
391
+ }
392
+ const fieldNamePath = buildSubTableColumnNamePath(fieldPath, rowIdx, namePath, rowFork?.context?.fieldIndex);
393
+ const formItemName = buildDynamicNamePath([...fieldPath, rowIdx, namePath], parentFieldIndex);
375
394
 
376
395
  const fork: any = action.createFork({}, `${id}`);
377
396
  fork.context.defineProperty('currentObject', { get: () => record });
397
+ fork.context.defineProperty('fieldPathArray', {
398
+ get: () => fieldNamePath,
399
+ cache: false,
400
+ });
378
401
  if (rowFork) {
379
402
  const itemOptions = rowFork.context.getPropertyOptions?.('item');
380
403
  const { value: _value, ...itemOptionsWithoutValue } = (itemOptions || {}) as any;
@@ -415,7 +438,7 @@ const MemoCell: React.FC<CellProps> = React.memo(
415
438
  <FormItem
416
439
  {...parent.props}
417
440
  key={id}
418
- name={buildDynamicNamePath([...fieldPath, rowIdx, namePath], parentFieldIndex)}
441
+ name={formItemName}
419
442
  style={{ marginBottom: 0 }}
420
443
  showLabel={false}
421
444
  disabled={
@@ -70,6 +70,7 @@ export function SubTableField(props) {
70
70
  getCurrentValue,
71
71
  fieldPathArray,
72
72
  formValuesChangeEmitter,
73
+ onResetFieldValue,
73
74
  } = props;
74
75
  const [currentPage, setCurrentPage] = useState(1);
75
76
  const [currentPageSize, setCurrentPageSize] = useState(pageSize);
@@ -97,6 +98,17 @@ export function SubTableField(props) {
97
98
  formValuesChangeEmitter.off('formValuesChange', listener);
98
99
  };
99
100
  }, [fieldPathArray, formValuesChangeEmitter]);
101
+ useEffect(() => {
102
+ if (!formValuesChangeEmitter?.on || !formValuesChangeEmitter?.off || !onResetFieldValue) return;
103
+ const listener = () => {
104
+ onResetFieldValue();
105
+ forceRefresh((v) => v + 1);
106
+ };
107
+ formValuesChangeEmitter.on('onFieldReset', listener);
108
+ return () => {
109
+ formValuesChangeEmitter.off('onFieldReset', listener);
110
+ };
111
+ }, [formValuesChangeEmitter, onResetFieldValue]);
100
112
  const applyValue = React.useCallback((nextValue: any) => onChange?.(normalizeSubTableRows(nextValue)), [onChange]);
101
113
  const getLatestValue = React.useCallback(() => normalizeSubTableRows(getCurrentValue()), [getCurrentValue]);
102
114
  useEffect(() => {
@@ -125,7 +137,7 @@ export function SubTableField(props) {
125
137
  return t('Total {{count}} items', { count: total });
126
138
  },
127
139
  } as any;
128
- }, [currentPage, currentPageSize, currentValue.length]);
140
+ }, [currentPage, currentPageSize, currentValue.length, t]);
129
141
 
130
142
  // 新增一行
131
143
  const handleAdd = () => {
@@ -15,6 +15,7 @@ import {
15
15
  SubTableColumnModel,
16
16
  getLatestSubTableRowRecord,
17
17
  buildRowPathFromFieldIndex,
18
+ buildSubTableColumnNamePath,
18
19
  isSubTableColumnConfiguredReadPretty,
19
20
  getSubTableColumnTitleField,
20
21
  getSubTableColumnReadPrettyFieldProps,
@@ -47,6 +48,17 @@ describe('SubTableColumnModel row record helpers', () => {
47
48
  expect(buildRowPathFromFieldIndex(['users:1', 'roles:2'])).toEqual(['users', 1, 'roles', 2]);
48
49
  });
49
50
 
51
+ it('builds absolute form item paths for nested sub-table columns', () => {
52
+ expect(buildSubTableColumnNamePath(['roles'], 0, 'name', ['roles:0'])).toEqual(['roles', 0, 'name']);
53
+ expect(buildSubTableColumnNamePath(['orders', 'lines'], 1, 'product', ['orders:0', 'lines:1'])).toEqual([
54
+ 'orders',
55
+ 0,
56
+ 'lines',
57
+ 1,
58
+ 'product',
59
+ ]);
60
+ });
61
+
50
62
  it('prefers the latest row value from form over the fallback record', () => {
51
63
  const form = {
52
64
  getFieldValue: vi.fn((path: any) => {
@@ -0,0 +1,180 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ import React from 'react';
11
+ import { act, render } from '@nocobase/test/client';
12
+ import { FlowEngine, type FlowModel } from '@nocobase/flow-engine';
13
+ import { describe, expect, it, vi } from 'vitest';
14
+ import { PopupSubTableFieldModel } from '../../PopupSubTableFieldModel';
15
+ import { SubTableFieldModel } from '..';
16
+
17
+ vi.mock('react-i18next', async (importOriginal) => ({
18
+ ...(await importOriginal<any>()),
19
+ useTranslation: () => ({
20
+ t: (value: string) => value,
21
+ }),
22
+ }));
23
+
24
+ vi.mock('antd', async () => {
25
+ const actual = await vi.importActual<any>('antd');
26
+ return {
27
+ ...actual,
28
+ Table: ({ dataSource = [], columns = [] }: any) =>
29
+ React.createElement(
30
+ 'div',
31
+ { 'data-testid': 'subtable' },
32
+ dataSource.map((record: any, rowIdx: number) =>
33
+ React.createElement(
34
+ 'div',
35
+ { 'data-testid': `row-${rowIdx}`, key: record.__index__ || rowIdx },
36
+ columns.map((column: any) =>
37
+ React.createElement(
38
+ 'div',
39
+ { 'data-testid': `cell-${rowIdx}-${String(column.dataIndex || column.key)}`, key: column.key },
40
+ column.render?.(record[column.dataIndex], record, rowIdx),
41
+ ),
42
+ ),
43
+ ),
44
+ ),
45
+ ),
46
+ };
47
+ });
48
+
49
+ function createSubTableFieldModel(props: Record<string, unknown> = {}) {
50
+ const engine = new FlowEngine();
51
+ engine.registerModels({ SubTableFieldModel });
52
+ const setFieldValue = vi.fn();
53
+
54
+ const blockModel = engine.createModel<FlowModel>({
55
+ use: 'FlowModel',
56
+ uid: 'form-block',
57
+ });
58
+ (blockModel as FlowModel & { setFieldValue: typeof setFieldValue }).setFieldValue = setFieldValue;
59
+ const parent = engine.createModel<FlowModel>({
60
+ use: 'FlowModel',
61
+ uid: 'form-item',
62
+ });
63
+
64
+ parent.context.defineProperty('blockModel', { value: blockModel });
65
+ parent.context.defineProperty('collectionField', {
66
+ value: {
67
+ target: 'roles',
68
+ targetCollection: {
69
+ filterTargetKey: 'id',
70
+ },
71
+ },
72
+ });
73
+ parent.context.defineProperty('fieldPathArray', { value: ['roles'] });
74
+
75
+ const model = engine.createModel<SubTableFieldModel>({
76
+ use: 'SubTableFieldModel',
77
+ uid: 'roles-subtable',
78
+ parentId: parent.uid,
79
+ props,
80
+ });
81
+
82
+ return { blockModel, model, setFieldValue };
83
+ }
84
+
85
+ async function createPopupSubTableFieldModel(props: Record<string, unknown> = {}) {
86
+ const engine = new FlowEngine();
87
+ engine.registerModels({ PopupSubTableFieldModel });
88
+ const setFieldValue = vi.fn();
89
+
90
+ const blockModel = engine.createModel<FlowModel>({
91
+ use: 'FlowModel',
92
+ uid: 'popup-form-block',
93
+ });
94
+ (blockModel as FlowModel & { setFieldValue: typeof setFieldValue }).setFieldValue = setFieldValue;
95
+ const parent = engine.createModel<FlowModel>({
96
+ use: 'FlowModel',
97
+ uid: 'popup-form-item',
98
+ });
99
+
100
+ parent.context.defineProperty('blockModel', { value: blockModel });
101
+ parent.context.defineProperty('collectionField', {
102
+ value: {
103
+ target: 'roles',
104
+ targetCollection: {
105
+ filterTargetKey: 'id',
106
+ },
107
+ },
108
+ });
109
+ parent.context.defineProperty('fieldPathArray', { value: ['roles'] });
110
+
111
+ const model = engine.createModel<PopupSubTableFieldModel>({
112
+ use: 'PopupSubTableFieldModel',
113
+ uid: 'roles-popup-subtable',
114
+ parentId: parent.uid,
115
+ props,
116
+ });
117
+ (model.subModels as Record<string, unknown>).subTableColumns = [];
118
+ await model.onDispatchEventStart('beforeRender');
119
+
120
+ return { blockModel, model, setFieldValue };
121
+ }
122
+
123
+ describe('SubTable field reset', () => {
124
+ it('resets the rendered fork through its own form field path', () => {
125
+ const onChange = vi.fn();
126
+ const { blockModel, model, setFieldValue } = createSubTableFieldModel({
127
+ onChange,
128
+ value: [{ id: 1 }],
129
+ });
130
+ const fork = model.createFork({ value: [{ id: 1 }] }, 'orders.0.lines');
131
+ fork.context.defineProperty('fieldPathArray', { value: ['orders', 0, 'lines'] });
132
+
133
+ render(React.createElement(React.Fragment, null, fork.render()));
134
+
135
+ act(() => {
136
+ blockModel.emitter.emit('onFieldReset');
137
+ });
138
+
139
+ expect(fork.props.value).toEqual([]);
140
+ expect(setFieldValue).toHaveBeenCalledWith(['orders', 0, 'lines'], []);
141
+ expect(onChange).not.toHaveBeenCalled();
142
+ });
143
+
144
+ it('resets the non-forked model through the parent form field path', () => {
145
+ const onChange = vi.fn();
146
+ const { blockModel, model, setFieldValue } = createSubTableFieldModel({
147
+ onChange,
148
+ value: [{ id: 1 }],
149
+ });
150
+
151
+ render(React.createElement(React.Fragment, null, model.render()));
152
+
153
+ act(() => {
154
+ blockModel.emitter.emit('onFieldReset');
155
+ });
156
+
157
+ expect(model.props.value).toEqual([]);
158
+ expect(setFieldValue).toHaveBeenCalledWith(['roles'], []);
159
+ expect(onChange).not.toHaveBeenCalled();
160
+ });
161
+
162
+ it('resets popup sub-table form values without relying on onChange', async () => {
163
+ const onChange = vi.fn();
164
+ const { blockModel, model, setFieldValue } = await createPopupSubTableFieldModel({
165
+ onChange,
166
+ pageSize: 10,
167
+ value: [{ id: 1 }],
168
+ });
169
+
170
+ render(React.createElement(React.Fragment, null, model.render()));
171
+
172
+ act(() => {
173
+ blockModel.emitter.emit('onFieldReset');
174
+ });
175
+
176
+ expect(model.props.value).toEqual([]);
177
+ expect(setFieldValue).toHaveBeenCalledWith(['roles'], []);
178
+ expect(onChange).not.toHaveBeenCalled();
179
+ });
180
+ });
@@ -112,6 +112,11 @@ export class SubTableFieldModel extends AssociationFieldModel {
112
112
  };
113
113
  const isConfigMode = !!this.context.flowSettingsEnabled;
114
114
  const fieldPathArray = this.context.fieldPathArray ?? this.parent?.context?.fieldPathArray;
115
+ const onResetFieldValue = () => {
116
+ const value = [];
117
+ this.setProps({ value });
118
+ this.context.blockModel?.setFieldValue?.(fieldPathArray, value);
119
+ };
115
120
  return (
116
121
  <SubTableField
117
122
  {...this.props}
@@ -124,6 +129,7 @@ export class SubTableFieldModel extends AssociationFieldModel {
124
129
  formValuesChangeEmitter={this.context.blockModel?.emitter}
125
130
  fieldPathArray={fieldPathArray}
126
131
  getCurrentValue={() => this.getCurrentValue()}
132
+ onResetFieldValue={onResetFieldValue}
127
133
  />
128
134
  );
129
135
  }
@@ -136,10 +142,6 @@ export class SubTableFieldModel extends AssociationFieldModel {
136
142
  this.context.defineProperty('collection', {
137
143
  get: () => this.context.collectionField.targetCollection,
138
144
  });
139
- // 监听表单reset
140
- this.context.blockModel.emitter.on('onFieldReset', () => {
141
- this.props.onChange([]);
142
- });
143
145
  this.onSelectExitRecordClick = (setCurrentPage, currentPageSize) => {
144
146
  this.setCurrentPage = setCurrentPage;
145
147
  this.currentPageSize = currentPageSize;
@@ -0,0 +1,72 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ import { FlowEngine, FlowModel } from '@nocobase/flow-engine';
11
+ import { describe, expect, it, vi } from 'vitest';
12
+ import { AssociationFieldModel } from '../AssociationFieldModel';
13
+
14
+ class DefaultAssociationFieldModel extends AssociationFieldModel {}
15
+
16
+ class UpdateAssociationFieldModel extends AssociationFieldModel {
17
+ updateAssociation = true;
18
+ }
19
+
20
+ async function runAssociationFieldInit(FieldModelClass: typeof AssociationFieldModel) {
21
+ const engine = new FlowEngine();
22
+ engine.registerModels({
23
+ DefaultAssociationFieldModel,
24
+ UpdateAssociationFieldModel,
25
+ });
26
+
27
+ const addUpdateAssociationValues = vi.fn();
28
+ const blockModel = engine.createModel<FlowModel>({
29
+ use: 'FlowModel',
30
+ uid: 'form-block',
31
+ });
32
+ blockModel.context.defineProperty('resource', {
33
+ value: { addUpdateAssociationValues },
34
+ });
35
+
36
+ const formItem = engine.createModel<FlowModel>({
37
+ use: 'FlowModel',
38
+ uid: 'form-item',
39
+ });
40
+ (formItem as FlowModel & { fieldPath: string }).fieldPath = 'author';
41
+ formItem.context.defineProperty('blockModel', { value: blockModel });
42
+
43
+ const field = engine.createModel<AssociationFieldModel>({
44
+ use: FieldModelClass,
45
+ uid: 'association-field',
46
+ parentId: formItem.uid,
47
+ });
48
+ field.context.defineProperty('collectionField', {
49
+ value: {
50
+ dataSourceKey: 'main',
51
+ target: 'users',
52
+ },
53
+ });
54
+
55
+ await field.applyFlow('AssociationFieldInit');
56
+
57
+ return addUpdateAssociationValues;
58
+ }
59
+
60
+ describe('AssociationFieldModel updateAssociation', () => {
61
+ it('does not add updateAssociationValues by default', async () => {
62
+ const addUpdateAssociationValues = await runAssociationFieldInit(DefaultAssociationFieldModel);
63
+
64
+ expect(addUpdateAssociationValues).not.toHaveBeenCalled();
65
+ });
66
+
67
+ it('adds updateAssociationValues for opt-in association fields', async () => {
68
+ const addUpdateAssociationValues = await runAssociationFieldInit(UpdateAssociationFieldModel);
69
+
70
+ expect(addUpdateAssociationValues).toHaveBeenCalledWith('author');
71
+ });
72
+ });
@@ -28,6 +28,7 @@ import {
28
28
  normalizeRecordPickerValue,
29
29
  shouldClearRecordPickerValueOnMultipleChange,
30
30
  } from '../RecordPickerFieldModel';
31
+ import { getAssociationHydrationNamePath, getAssociationHydrationSetterContext } from '../RecordSelectFieldModel';
31
32
 
32
33
  function createMockCollection() {
33
34
  return {
@@ -93,6 +94,28 @@ describe('RecordPickerFieldModel item context', () => {
93
94
  expect(shouldClearRecordPickerValueOnMultipleChange({ type: 'belongsTo' }, true, false)).toBe(false);
94
95
  });
95
96
 
97
+ it('uses the current field context for association hydration writes', () => {
98
+ const setFormValue = vi.fn();
99
+ const blockSetFormValue = vi.fn();
100
+ const model = {
101
+ props: {
102
+ name: 'orders',
103
+ },
104
+ context: {
105
+ fieldPathArray: ['orders', 0, 'lines', 1, 'product'],
106
+ setFormValue,
107
+ blockModel: {
108
+ context: {
109
+ setFormValue: blockSetFormValue,
110
+ },
111
+ },
112
+ },
113
+ };
114
+
115
+ expect(getAssociationHydrationNamePath(model)).toEqual(['orders', 0, 'lines', 1, 'product']);
116
+ expect(getAssociationHydrationSetterContext(model)).toBe(model.context);
117
+ });
118
+
96
119
  it('itemChain helpers: createParentItemAccessorsFromInputArgs works', () => {
97
120
  const inputArgs = {
98
121
  parentItem: { value: { id: 1 } },
@@ -197,6 +197,11 @@ export class ClickableFieldModel extends FieldModel {
197
197
  titleField,
198
198
  overflowMode,
199
199
  disabled,
200
+ dateOnly,
201
+ dateFormat,
202
+ format,
203
+ picker,
204
+ showTime,
200
205
  timeFormat,
201
206
  ...restProps
202
207
  } = this.props;
@@ -8,14 +8,42 @@
8
8
  */
9
9
 
10
10
  import { DisplayItemModel, tExpr } from '@nocobase/flow-engine';
11
+ import { getDateTimeFormat, getPickerFormat } from '@nocobase/utils/client';
11
12
  import dayjs from 'dayjs';
12
13
  import React from 'react';
13
14
  import { ClickableFieldModel } from './ClickableFieldModel';
14
15
 
16
+ const stripTimeFromFormat = (format?: string) =>
17
+ format ? format.replace(/\s*[Hh]{1,2}:mm(?::ss)?(?:\.SSS)?(?:\s*[aA])?/g, '').trim() : format;
18
+
19
+ interface DisplayDateTimeFormatProps {
20
+ dateOnly?: boolean;
21
+ picker?: string;
22
+ format?: string;
23
+ dateFormat?: string;
24
+ showTime?: boolean;
25
+ timeFormat?: string;
26
+ }
27
+
28
+ const resolveDisplayDateTimeFormat = (props: DisplayDateTimeFormatProps) => {
29
+ const { dateOnly, picker = 'date', format, dateFormat, showTime, timeFormat } = props;
30
+ const normalizedFormat = stripTimeFromFormat(format);
31
+ if (picker !== 'date') {
32
+ return dateFormat || normalizedFormat || getPickerFormat(picker);
33
+ }
34
+
35
+ if (!dateOnly && !dateFormat && typeof showTime === 'undefined' && !timeFormat && normalizedFormat) {
36
+ return format;
37
+ }
38
+
39
+ const normalizedDateFormat = dateFormat || normalizedFormat || getPickerFormat(picker);
40
+ return getDateTimeFormat(picker, normalizedDateFormat, showTime, timeFormat);
41
+ };
42
+
15
43
  export class DisplayDateTimeFieldModel extends ClickableFieldModel {
16
44
  public renderComponent(value) {
17
45
  const { className, style } = this.props;
18
- const finalFormat = this.props.format;
46
+ const finalFormat = resolveDisplayDateTimeFormat(this.props);
19
47
  let formattedValue = '';
20
48
  if (value) {
21
49
  const day = dayjs(value);
@@ -15,17 +15,20 @@ import { MobileSelect } from './mobile-components/MobileSelect';
15
15
  import { enumToOptions, getSelectedEnumLabels, translateOptionLabel } from '../../internal/utils/enumOptionsUtils';
16
16
 
17
17
  const getOriginalEnumOptions = (model: SelectFieldModel) => {
18
- const fromEnum = enumToOptions(model.context.collectionField?.uiSchema?.enum, (text) => text) || [];
18
+ const fromEnum = enumToOptions(model.context.collectionField?.uiSchema?.enum, model.translate) || [];
19
19
  if (fromEnum.length > 0) {
20
20
  return fromEnum.map((option) => ({ ...option }));
21
21
  }
22
22
  const current = Array.isArray(model.props.options) ? model.props.options : [];
23
- return current.map((option) => ({ ...option }));
23
+ return current.map((option) => ({
24
+ ...option,
25
+ label: translateOptionLabel(option.label, model.translate),
26
+ }));
24
27
  };
25
-
26
28
  export class SelectFieldModel extends FieldModel {
27
29
  render() {
28
30
  const fallbackOptions = getOriginalEnumOptions(this);
31
+
29
32
  const options = this.props.options?.map((v) => {
30
33
  return {
31
34
  ...v,
@@ -46,7 +49,6 @@ export class SelectFieldModel extends FieldModel {
46
49
  if (this.context.isMobileLayout) {
47
50
  return <MobileSelect {...this.props} options={options} displayValue={value} />;
48
51
  }
49
-
50
52
  return (
51
53
  <Select
52
54
  {...this.props}