@nocobase/client-v2 2.1.36 → 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
@@ -142,13 +142,32 @@ export class FormValueRuntime {
142
142
  return this.getForm().getFieldsValue(true);
143
143
  }
144
144
 
145
- private toMirrorSnapshot(value: any) {
146
- const raw = isObservable(value) ? toJS(value) : value;
147
- return _.cloneDeepWith(raw, (item) => {
148
- if (!item || typeof item !== 'object') return undefined;
149
- if (Array.isArray(item) || _.isPlainObject(item)) return undefined;
150
- return item;
151
- });
145
+ private toMirrorSnapshot<T>(value: T): T {
146
+ const cloneValue = (input: unknown): unknown =>
147
+ _.cloneDeepWith(input, (item: unknown) => {
148
+ if (isObservable(item)) {
149
+ const plainItem = toJS(item);
150
+ if (plainItem !== item) return cloneValue(plainItem);
151
+ }
152
+
153
+ // Tracking form-value array proxies hide `constructor`, while Lodash's array
154
+ // clone relies on it being callable.
155
+ if (Array.isArray(item) && typeof item.constructor !== 'function') {
156
+ const plainArray = new Array(item.length);
157
+ for (let index = 0; index < item.length; index++) {
158
+ if (Object.hasOwn(item, index)) {
159
+ plainArray[index] = cloneValue(item[index]);
160
+ }
161
+ }
162
+ return plainArray;
163
+ }
164
+
165
+ if (!item || typeof item !== 'object') return undefined;
166
+ if (Array.isArray(item) || _.isPlainObject(item)) return undefined;
167
+ return item;
168
+ });
169
+
170
+ return cloneValue(value) as T;
152
171
  }
153
172
 
154
173
  canApplyDefaultValuePatch(namePath: NamePath, resolved: any) {
@@ -892,11 +911,12 @@ export class FormValueRuntime {
892
911
  const changedPaths: NamePath[] = [];
893
912
 
894
913
  if (!Array.isArray(patch)) {
895
- const patchEntries = Object.entries(patch || {}).filter(([pathKey, rawValue]) => {
896
- if (shouldSkipByLinkageScope(pathKey)) return false;
897
- const value = isObservable(rawValue) ? toJS(rawValue) : rawValue;
898
- return !_.isEqual(this.getFormValueAtPath([pathKey]), value);
899
- });
914
+ const patchEntries = Object.entries(patch || {})
915
+ .map(([pathKey, rawValue]) => [pathKey, this.toMirrorSnapshot(rawValue)] as const)
916
+ .filter(([pathKey, value]) => {
917
+ if (shouldSkipByLinkageScope(pathKey)) return false;
918
+ return !_.isEqual(this.getFormValueAtPath([pathKey]), value);
919
+ });
900
920
  const patchToApply = Object.fromEntries(patchEntries);
901
921
  const patchKeys = patchEntries.map(([pathKey]) => pathKey);
902
922
  if (!patchKeys.length) {
@@ -907,8 +927,7 @@ export class FormValueRuntime {
907
927
  }
908
928
  this.suppressFormCallbackDepth++;
909
929
  try {
910
- for (const [pathKey, rawValue] of patchEntries) {
911
- const value = isObservable(rawValue) ? toJS(rawValue) : rawValue;
930
+ for (const [pathKey, value] of patchEntries) {
912
931
  if (typeof form.setFieldValue === 'function') {
913
932
  form.setFieldValue(pathKey, value);
914
933
  } else if (typeof (form as any).setFields === 'function') {
@@ -969,7 +988,7 @@ export class FormValueRuntime {
969
988
  const namePath = this.resolveNamePath(callerCtx, item.path);
970
989
  const pathKey = namePathToPathKey(namePath);
971
990
  const rawValue = item.value;
972
- const value = isObservable(rawValue) ? toJS(rawValue) : rawValue;
991
+ const value = this.toMirrorSnapshot(rawValue);
973
992
 
974
993
  if (shouldSkipByLinkageScope(pathKey)) continue;
975
994
 
@@ -1108,17 +1127,18 @@ export class FormValueRuntime {
1108
1127
  if (!form) return;
1109
1128
  if (source === 'override' && this.findUserEditedHit(pathKey)) return;
1110
1129
 
1130
+ const value = this.toMirrorSnapshot(nextValue);
1111
1131
  const prevValue = _.get(this.valuesMirror, namePath);
1112
- if (_.isEqual(prevValue, nextValue)) return;
1132
+ if (_.isEqual(prevValue, value)) return;
1113
1133
 
1114
1134
  this.writeSeq += 1;
1115
1135
  const writeSeq = this.writeSeq;
1116
1136
 
1117
1137
  this.suppressFormCallbackDepth++;
1118
1138
  try {
1119
- form.setFieldValue?.(namePath, nextValue);
1120
- _.set(this.valuesMirror, namePath, this.toMirrorSnapshot(nextValue));
1121
- this.syncMountedFieldModelValue(namePath, nextValue);
1139
+ form.setFieldValue?.(namePath, value);
1140
+ _.set(this.valuesMirror, namePath, this.toMirrorSnapshot(value));
1141
+ this.syncMountedFieldModelValue(namePath, value);
1122
1142
  this.bumpChangeTick();
1123
1143
  } finally {
1124
1144
  this.suppressFormCallbackDepth--;
@@ -28,6 +28,7 @@ import { observer } from '@formily/reactive-react';
28
28
  import React, { useEffect, useMemo, useState, useRef } from 'react';
29
29
  import { useTranslation } from 'react-i18next';
30
30
  import { buildRecordPickerPopupContextInputArgs, RecordPickerContent } from '../RecordPickerFieldModel';
31
+ import { buildOpenerUids } from '../recordSelectShared';
31
32
  import { AssociationFieldModel } from '../AssociationFieldModel';
32
33
  import { adjustColumnOrder } from '../../../blocks/table/utils';
33
34
  import { isSubTableColumnFieldComponentContext } from '../SubTableFieldModel/SubTableColumnModel';
@@ -653,6 +654,11 @@ PopupSubTableFieldModel.registerFlow({
653
654
  const parentItemOptions = ctx?.getPropertyOptions?.('item');
654
655
  const itemIndex = Array.isArray(ctx.model?.props?.value) ? ctx.model.props.value.length : 0;
655
656
  const itemLength = itemIndex + 1;
657
+ const associationName = ctx.collectionField?.resourceName;
658
+ const sourceId = parentItem?.value
659
+ ? ctx.collectionField?.collection?.getFilterByTK?.(parentItem.value)
660
+ : undefined;
661
+ const openerUids = buildOpenerUids(ctx, ctx.inputArgs);
656
662
  ctx.viewer.open({
657
663
  type: openMode,
658
664
  width: sizeToWidthMap[openMode][size],
@@ -663,12 +669,14 @@ PopupSubTableFieldModel.registerFlow({
663
669
  scene: 'subForm',
664
670
  dataSourceKey: ctx.collection.dataSourceKey,
665
671
  collectionName: ctx.collectionField?.target,
672
+ ...(associationName && sourceId != null ? { associationName, sourceId } : {}),
666
673
  collectionField: ctx.collectionField,
667
674
  parentItem,
668
675
  parentItemMeta: parentItemOptions?.meta,
669
676
  parentItemResolver: parentItemOptions?.resolveOnServer,
670
677
  itemIndex,
671
678
  itemLength,
679
+ openerUids,
672
680
  },
673
681
  content: () => <EditFormContent model={ctx.model} scene="create" />,
674
682
  styles: {
@@ -0,0 +1,120 @@
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 { describe, expect, it, vi } from 'vitest';
11
+ import { PopupSubTableFieldModel } from '../PopupSubTableFieldModel';
12
+ import { PopupSubTableEditActionModel } from '../actions/PopupSubTableEditActionModel';
13
+
14
+ function getOpenViewHandler(modelClass: typeof PopupSubTableFieldModel | typeof PopupSubTableEditActionModel) {
15
+ const flow = modelClass.globalFlowRegistry.getFlow('popupSettings');
16
+ const step = flow?.getStep('openView');
17
+ const handler = step?.serialize().handler;
18
+
19
+ if (!handler) {
20
+ throw new Error('popupSettings.openView handler is not registered');
21
+ }
22
+
23
+ return handler;
24
+ }
25
+
26
+ function createContext(record?: Record<string, unknown>, parentRecord: Record<string, unknown> = { id: 1 }) {
27
+ const open = vi.fn();
28
+ const sourceCollection = {
29
+ getFilterByTK: vi.fn((sourceRecord: Record<string, unknown>) => sourceRecord.id),
30
+ };
31
+ const model = {
32
+ uid: 'popup-subtable-uid',
33
+ props: {
34
+ value: record ? [record] : [],
35
+ },
36
+ context: {
37
+ inputArgs: {},
38
+ },
39
+ flowEngine: {
40
+ context: {
41
+ themeToken: {
42
+ colorBgLayout: '#fff',
43
+ },
44
+ },
45
+ },
46
+ };
47
+
48
+ return {
49
+ open,
50
+ context: {
51
+ inputArgs: {},
52
+ item: {
53
+ value: parentRecord,
54
+ },
55
+ view: {
56
+ inputArgs: {
57
+ viewUid: 'parent-popup-uid',
58
+ openerUids: ['root-page-uid'],
59
+ },
60
+ },
61
+ viewer: { open },
62
+ layoutContentElement: {},
63
+ model,
64
+ associationModel: model,
65
+ collection: {
66
+ dataSourceKey: 'main',
67
+ filterTargetKey: 'id',
68
+ },
69
+ collectionField: {
70
+ target: 'roles',
71
+ resourceName: 'users.roles',
72
+ collection: sourceCollection,
73
+ },
74
+ record,
75
+ getFormValues: () => ({ id: 1 }),
76
+ getPropertyOptions: () => undefined,
77
+ },
78
+ };
79
+ }
80
+
81
+ describe('PopupSubTable popup context', () => {
82
+ it('passes popup and parent record context to the add-new popup', () => {
83
+ const { context, open } = createContext();
84
+ const handler = getOpenViewHandler(PopupSubTableFieldModel);
85
+
86
+ handler(context, { mode: 'drawer', size: 'medium' });
87
+
88
+ expect(open).toHaveBeenCalledOnce();
89
+ expect(open.mock.calls[0][0].inputArgs).toMatchObject({
90
+ openerUids: ['root-page-uid', 'parent-popup-uid'],
91
+ associationName: 'users.roles',
92
+ sourceId: 1,
93
+ });
94
+ });
95
+
96
+ it('passes popup and parent record context to the edit popup', () => {
97
+ const { context, open } = createContext({ id: 2, name: 'Member' });
98
+ const handler = getOpenViewHandler(PopupSubTableEditActionModel);
99
+
100
+ handler(context, { mode: 'dialog', size: 'medium' });
101
+
102
+ expect(open).toHaveBeenCalledOnce();
103
+ expect(open.mock.calls[0][0].inputArgs).toMatchObject({
104
+ openerUids: ['root-page-uid', 'parent-popup-uid'],
105
+ associationName: 'users.roles',
106
+ sourceId: 1,
107
+ });
108
+ });
109
+
110
+ it('does not expose a parent record reference before the parent record is persisted', () => {
111
+ const { context, open } = createContext(undefined, { nickname: 'Draft user' });
112
+ const handler = getOpenViewHandler(PopupSubTableFieldModel);
113
+
114
+ handler(context, { mode: 'drawer', size: 'medium' });
115
+
116
+ expect(open).toHaveBeenCalledOnce();
117
+ expect(open.mock.calls[0][0].inputArgs).not.toHaveProperty('associationName');
118
+ expect(open.mock.calls[0][0].inputArgs).not.toHaveProperty('sourceId');
119
+ });
120
+ });
@@ -17,6 +17,7 @@ import { capitalize } from 'lodash';
17
17
  import '../../../../base/ActionModel';
18
18
  import { ActionModel, ActionWithoutPermission } from '../../../../base/ActionModelCore';
19
19
  import { SkeletonFallback } from '../../../../../components/SkeletonFallback';
20
+ import { buildOpenerUids } from '../../recordSelectShared';
20
21
  import { bindPopupSubTableBeforeClose } from './popupSubTableBeforeClose';
21
22
 
22
23
  function FieldWithoutPermissionPlaceholder({ targetModel, children }) {
@@ -29,7 +30,7 @@ function FieldWithoutPermissionPlaceholder({ targetModel, children }) {
29
30
  const dataSourcePrefix = `${t(dataSource.displayName || dataSource.key)} > `;
30
31
  const collectionPrefix = collection ? `${t(collection.title) || collection.name || collection.tableName} > ` : '';
31
32
  return `${dataSourcePrefix}${collectionPrefix}${name}`;
32
- }, []);
33
+ }, [collection, dataSource.displayName, dataSource.key, name, t]);
33
34
  const { actionName } = fieldModel.forbidden || {};
34
35
  const messageValue = useMemo(() => {
35
36
  return t(
@@ -39,7 +40,7 @@ function FieldWithoutPermissionPlaceholder({ targetModel, children }) {
39
40
  actionName: t(capitalize(actionName)),
40
41
  },
41
42
  ).replaceAll('&gt;', '>');
42
- }, [nameValue, t]);
43
+ }, [actionName, nameValue, t]);
43
44
  return <Tooltip title={messageValue}>{children}</Tooltip>;
44
45
  }
45
46
 
@@ -347,6 +348,11 @@ PopupSubTableEditActionModel.registerFlow({
347
348
  return undefined;
348
349
  }
349
350
  })();
351
+ const associationName = ctx.collectionField?.resourceName;
352
+ const sourceId = parentItem?.value
353
+ ? ctx.collectionField?.collection?.getFilterByTK?.(parentItem.value)
354
+ : undefined;
355
+ const openerUids = buildOpenerUids(ctx, ctx.inputArgs);
350
356
  ctx.viewer.open({
351
357
  type: openMode,
352
358
  width: sizeToWidthMap[openMode][size],
@@ -357,6 +363,7 @@ PopupSubTableEditActionModel.registerFlow({
357
363
  scene: 'subForm',
358
364
  dataSourceKey: ctx.collection.dataSourceKey,
359
365
  collectionName: ctx.collectionField?.target,
366
+ ...(associationName && sourceId != null ? { associationName, sourceId } : {}),
360
367
  collectionField: ctx.collectionField,
361
368
  record: ctx.record,
362
369
  parentItem,
@@ -364,6 +371,7 @@ PopupSubTableEditActionModel.registerFlow({
364
371
  parentItemResolver: parentItemOptions?.resolveOnServer,
365
372
  itemIndex,
366
373
  itemLength,
374
+ openerUids,
367
375
  },
368
376
  content: () => <EditFormContent model={ctx.model} />,
369
377
  styles: {
@@ -243,6 +243,52 @@ describe('RecordPickerFieldModel item context', () => {
243
243
  expect(associationOpts.serverOnlyWhenContextParams).toBe(true);
244
244
  });
245
245
 
246
+ it('builds whole-association descriptors for current and recursive parent item values', async () => {
247
+ const people = {
248
+ name: 'people',
249
+ dataSourceKey: 'main',
250
+ filterTargetKey: 'id',
251
+ getFields: () => [],
252
+ getField: () => undefined,
253
+ } as any;
254
+ const owner = {
255
+ name: 'owner',
256
+ target: 'people',
257
+ targetCollection: people,
258
+ isAssociationField: () => true,
259
+ } as any;
260
+ const collection = {
261
+ name: 'tasks',
262
+ dataSourceKey: 'main',
263
+ getFields: () => [owner],
264
+ getField: (name: string) => (name === 'owner' ? owner : undefined),
265
+ } as any;
266
+ const options = createAssociationItemChainContextPropertyOptions({
267
+ t: (value) => value,
268
+ title: 'Current item',
269
+ collectionAccessor: () => collection,
270
+ propertiesAccessor: (ctx) => ctx.item.value,
271
+ resolverPropertiesAccessor: () => ({ owner: { id: 7 } }),
272
+ parentCollectionAccessor: () => collection,
273
+ parentAccessors: {
274
+ parentPropertiesAccessor: () => ({ owner: { id: 8 } }),
275
+ parentItemMetaAccessor: () => undefined,
276
+ parentItemResolverAccessor: () => undefined,
277
+ },
278
+ });
279
+ const meta = await options.meta();
280
+ const params = await meta.buildVariablesParams({
281
+ item: { value: { owner: { id: 7 } }, parentItem: { value: { owner: { id: 8 } } } },
282
+ });
283
+
284
+ expect(params).toEqual({
285
+ parentItem: { value: { owner: { collection: 'people', dataSourceKey: 'main', filterByTk: 8 } } },
286
+ value: { owner: { collection: 'people', dataSourceKey: 'main', filterByTk: 7 } },
287
+ });
288
+ expect(options.resolveOnServer('value.owner.name')).toBe(true);
289
+ expect(options.resolveOnServer('parentItem.value.owner.name')).toBe(true);
290
+ });
291
+
246
292
  it('disables current item attributes for select-record popup item context', async () => {
247
293
  const parentCtx = new FlowContext();
248
294
  const departmentsCollection = {
@@ -291,6 +291,7 @@ export function MobileLazySelect(props: Readonly<LazySelectProps>) {
291
291
  value={searchText}
292
292
  onChange={handleSearchChange}
293
293
  onCancel={handleClose}
294
+ cancelText={t('Cancel')}
294
295
  showCancelButton
295
296
  />
296
297
  </div>
@@ -158,8 +158,15 @@ vi.mock('antd-mobile', () => {
158
158
  mockState.popupProps = props;
159
159
  return props.visible ? <div data-testid="popup">{props.children}</div> : null;
160
160
  },
161
- SearchBar: ({ value, onChange }: any) => (
162
- <input data-testid="search" value={value ?? ''} onChange={(e) => onChange?.(e.target.value)} />
161
+ SearchBar: ({ value, onChange, onCancel, cancelText, showCancelButton }: any) => (
162
+ <div>
163
+ <input data-testid="search" value={value ?? ''} onChange={(e) => onChange?.(e.target.value)} />
164
+ {showCancelButton && value ? (
165
+ <button type="button" onClick={onCancel}>
166
+ {cancelText ?? '取消'}
167
+ </button>
168
+ ) : null}
169
+ </div>
163
170
  ),
164
171
  CheckList: MockCheckList,
165
172
  };
@@ -289,6 +296,17 @@ describe('MobileLazySelect', () => {
289
296
  resetMockState();
290
297
  });
291
298
 
299
+ it('renders a translated cancel action after searching', () => {
300
+ renderMobileLazySelect();
301
+
302
+ openLazyPopup();
303
+ act(() => {
304
+ fireEvent.change(screen.getByTestId('search'), { target: { value: '11' } });
305
+ });
306
+
307
+ expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
308
+ });
309
+
292
310
  it('keeps pending relation records selected until confirm', () => {
293
311
  const { onChange, rerender } = renderMobileLazySelect();
294
312