@nocobase/client-v2 3.0.0-alpha.6 → 3.0.0-alpha.8

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 (47) hide show
  1. package/es/flow/actions/linkageRules.d.ts +24 -0
  2. package/es/flow/components/FieldAssignValueInput.d.ts +4 -30
  3. package/es/flow/components/field-value-variable/DateVariableEditor.d.ts +24 -0
  4. package/es/flow/components/field-value-variable/FieldValueVariableInput.d.ts +31 -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/flow/models/actions/UpdateRecordActionUtils.d.ts +8 -2
  8. package/es/flow/models/blocks/form/FormBlockModel.d.ts +6 -0
  9. package/es/flow/models/blocks/form/FormGridModel.d.ts +6 -0
  10. package/es/flow/models/blocks/form/value-runtime/rules.d.ts +1 -0
  11. package/es/flow/models/blocks/table/JSColumnModel.d.ts +2 -0
  12. package/es/index.mjs +106 -106
  13. package/lib/index.js +97 -97
  14. package/package.json +7 -7
  15. package/src/__tests__/app.test.tsx +122 -3
  16. package/src/__tests__/browserChecker.test.ts +37 -0
  17. package/src/__tests__/settings-shell.test.tsx +27 -1
  18. package/src/components/AppComponents.tsx +16 -7
  19. package/src/components/form/table/Table.tsx +81 -6
  20. package/src/components/form/table/__tests__/Table.columnWidth.test.tsx +433 -0
  21. package/src/flow/actions/__tests__/linkageRules.actionStates.test.ts +33 -0
  22. package/src/flow/actions/linkageRules.tsx +25 -7
  23. package/src/flow/components/FieldAssignValueInput.tsx +38 -621
  24. package/src/flow/components/field-value-variable/DateVariableEditor.tsx +181 -0
  25. package/src/flow/components/field-value-variable/FieldValueVariableInput.tsx +326 -0
  26. package/src/flow/components/field-value-variable/__tests__/FieldValueVariableInput.test.tsx +380 -0
  27. package/src/flow/components/field-value-variable/dateValue.ts +223 -0
  28. package/src/flow/components/field-value-variable/index.ts +12 -0
  29. package/src/flow/models/actions/UpdateRecordActionModel.tsx +18 -1
  30. package/src/flow/models/actions/UpdateRecordActionUtils.ts +18 -6
  31. package/src/flow/models/actions/__tests__/UpdateRecordActionModel.test.ts +76 -4
  32. package/src/flow/models/blocks/assign-form/AssignFormItemModel.tsx +28 -53
  33. package/src/flow/models/blocks/form/FormBlockModel.tsx +4 -0
  34. package/src/flow/models/blocks/form/FormGridModel.tsx +4 -0
  35. package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +8 -2
  36. package/src/flow/models/blocks/form/value-runtime/__tests__/runtime.test.ts +435 -0
  37. package/src/flow/models/blocks/form/value-runtime/rules.ts +67 -14
  38. package/src/flow/models/blocks/form/value-runtime/runtime.ts +43 -19
  39. package/src/flow/models/blocks/table/JSColumnModel.tsx +10 -1
  40. package/src/flow/models/blocks/table/__tests__/JSColumnModel.test.tsx +52 -5
  41. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +8 -0
  42. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/__tests__/popupContext.test.ts +120 -0
  43. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/actions/PopupSubTableEditActionModel.tsx +10 -2
  44. package/src/flow/models/fields/AssociationFieldModel/__tests__/RecordPickerFieldModel.itemContext.test.ts +46 -0
  45. package/src/flow/models/fields/mobile-components/MobileLazySelect.tsx +1 -0
  46. package/src/flow/models/fields/mobile-components/__tests__/MobileSelect.test.tsx +20 -2
  47. package/src/settings-app/SettingsShell.tsx +19 -4
@@ -86,6 +86,10 @@ export class FormValueRuntime {
86
86
 
87
87
  this.ruleEngine = new RuleEngine({
88
88
  getBlockModelUid: () => String(this.model?.uid),
89
+ getAssignRulesModelUid: () => {
90
+ const grid = this.model?.subModels?.grid;
91
+ return !Array.isArray(grid) && grid?.uid ? String(grid.uid) : undefined;
92
+ },
89
93
  getActionName: () => this.model?.getAclActionName?.() ?? this.model?.context?.actionName,
90
94
  getBlockContext: () => this.model?.context,
91
95
  getEngine: () => this.model?.context?.engine,
@@ -190,13 +194,32 @@ export class FormValueRuntime {
190
194
  return values;
191
195
  }
192
196
 
193
- private toMirrorSnapshot(value: any) {
194
- const raw = isObservable(value) ? toJS(value) : value;
195
- return _.cloneDeepWith(raw, (item) => {
196
- if (!item || typeof item !== 'object') return undefined;
197
- if (Array.isArray(item) || _.isPlainObject(item)) return undefined;
198
- return item;
199
- });
197
+ private toMirrorSnapshot<T>(value: T): T {
198
+ const cloneValue = (input: unknown): unknown =>
199
+ _.cloneDeepWith(input, (item: unknown) => {
200
+ if (isObservable(item)) {
201
+ const plainItem = toJS(item);
202
+ if (plainItem !== item) return cloneValue(plainItem);
203
+ }
204
+
205
+ // Tracking form-value array proxies hide `constructor`, while Lodash's array
206
+ // clone relies on it being callable.
207
+ if (Array.isArray(item) && typeof item.constructor !== 'function') {
208
+ const plainArray = new Array(item.length);
209
+ for (let index = 0; index < item.length; index++) {
210
+ if (Object.hasOwn(item, index)) {
211
+ plainArray[index] = cloneValue(item[index]);
212
+ }
213
+ }
214
+ return plainArray;
215
+ }
216
+
217
+ if (!item || typeof item !== 'object') return undefined;
218
+ if (Array.isArray(item) || _.isPlainObject(item)) return undefined;
219
+ return item;
220
+ });
221
+
222
+ return cloneValue(value) as T;
200
223
  }
201
224
 
202
225
  canApplyDefaultValuePatch(namePath: NamePath, resolved: any) {
@@ -940,11 +963,12 @@ export class FormValueRuntime {
940
963
  const changedPaths: NamePath[] = [];
941
964
 
942
965
  if (!Array.isArray(patch)) {
943
- const patchEntries = Object.entries(patch || {}).filter(([pathKey, rawValue]) => {
944
- if (shouldSkipByLinkageScope(pathKey)) return false;
945
- const value = isObservable(rawValue) ? toJS(rawValue) : rawValue;
946
- return !_.isEqual(this.getFormValueAtPath([pathKey]), value);
947
- });
966
+ const patchEntries = Object.entries(patch || {})
967
+ .map(([pathKey, rawValue]) => [pathKey, this.toMirrorSnapshot(rawValue)] as const)
968
+ .filter(([pathKey, value]) => {
969
+ if (shouldSkipByLinkageScope(pathKey)) return false;
970
+ return !_.isEqual(this.getFormValueAtPath([pathKey]), value);
971
+ });
948
972
  const patchToApply = Object.fromEntries(patchEntries);
949
973
  const patchKeys = patchEntries.map(([pathKey]) => pathKey);
950
974
  if (!patchKeys.length) {
@@ -955,8 +979,7 @@ export class FormValueRuntime {
955
979
  }
956
980
  this.suppressFormCallbackDepth++;
957
981
  try {
958
- for (const [pathKey, rawValue] of patchEntries) {
959
- const value = isObservable(rawValue) ? toJS(rawValue) : rawValue;
982
+ for (const [pathKey, value] of patchEntries) {
960
983
  if (typeof form.setFieldValue === 'function') {
961
984
  form.setFieldValue(pathKey, value);
962
985
  } else if (typeof (form as any).setFields === 'function') {
@@ -1017,7 +1040,7 @@ export class FormValueRuntime {
1017
1040
  const namePath = this.resolveNamePath(callerCtx, item.path);
1018
1041
  const pathKey = namePathToPathKey(namePath);
1019
1042
  const rawValue = item.value;
1020
- const value = isObservable(rawValue) ? toJS(rawValue) : rawValue;
1043
+ const value = this.toMirrorSnapshot(rawValue);
1021
1044
 
1022
1045
  if (shouldSkipByLinkageScope(pathKey)) continue;
1023
1046
 
@@ -1156,17 +1179,18 @@ export class FormValueRuntime {
1156
1179
  if (!form) return;
1157
1180
  if (source === 'override' && this.findUserEditedHit(pathKey)) return;
1158
1181
 
1182
+ const value = this.toMirrorSnapshot(nextValue);
1159
1183
  const prevValue = _.get(this.valuesMirror, namePath);
1160
- if (_.isEqual(prevValue, nextValue)) return;
1184
+ if (_.isEqual(prevValue, value)) return;
1161
1185
 
1162
1186
  this.writeSeq += 1;
1163
1187
  const writeSeq = this.writeSeq;
1164
1188
 
1165
1189
  this.suppressFormCallbackDepth++;
1166
1190
  try {
1167
- form.setFieldValue?.(namePath, nextValue);
1168
- _.set(this.valuesMirror, namePath, this.toMirrorSnapshot(nextValue));
1169
- this.syncMountedFieldModelValue(namePath, nextValue);
1191
+ form.setFieldValue?.(namePath, value);
1192
+ _.set(this.valuesMirror, namePath, this.toMirrorSnapshot(value));
1193
+ this.syncMountedFieldModelValue(namePath, value);
1170
1194
  this.bumpChangeTick();
1171
1195
  } finally {
1172
1196
  this.suppressFormCallbackDepth--;
@@ -9,13 +9,14 @@
9
9
 
10
10
  import { LockOutlined, QuestionCircleOutlined } from '@ant-design/icons';
11
11
  import { css } from '@emotion/css';
12
- import type { PropertyMetaFactory } from '@nocobase/flow-engine';
12
+ import type { FlowModelOptions, PropertyMetaFactory } from '@nocobase/flow-engine';
13
13
  import {
14
14
  Droppable,
15
15
  tExpr,
16
16
  FlowsFloatContextMenu,
17
17
  DragHandler,
18
18
  MemoFlowModelRenderer,
19
+ createCurrentRecordMetaFactory,
19
20
  createRecordMetaFactory,
20
21
  createRecordResolveOnServerWithLocal,
21
22
  ElementProxy,
@@ -51,6 +52,14 @@ function getRecordRenderSignature(record: any) {
51
52
  export class JSColumnModel extends TableCustomColumnModel {
52
53
  // Stable per‑instance render component to avoid remounts across re-renders
53
54
  private _RenderComponent?: React.ComponentType;
55
+
56
+ onInit(options: FlowModelOptions): void {
57
+ super.onInit(options);
58
+ this.context.defineProperty('record', {
59
+ meta: createCurrentRecordMetaFactory(this.context, () => this.context.collection),
60
+ });
61
+ }
62
+
54
63
  renderHiddenInConfig() {
55
64
  return (
56
65
  <Tooltip
@@ -11,17 +11,62 @@ import { FlowEngine } from '@nocobase/flow-engine';
11
11
  import { describe, expect, it, vi } from 'vitest';
12
12
  import { JSColumnModel } from '../JSColumnModel';
13
13
 
14
- describe('JSColumnModel row refresh', () => {
14
+ describe('JSColumnModel', () => {
15
+ it('declares current record metadata before rendering any row', async () => {
16
+ const engine = new FlowEngine();
17
+ engine.registerModels({ JSColumnModel });
18
+ const model = engine.createModel<JSColumnModel>({
19
+ use: 'JSColumnModel',
20
+ uid: 'js-column-record-meta',
21
+ props: {
22
+ width: 200,
23
+ title: 'JS column',
24
+ },
25
+ });
26
+
27
+ engine.context.dataSourceManager.getDataSource('main').addCollection({
28
+ name: 'users',
29
+ filterTargetKey: 'id',
30
+ fields: [
31
+ { name: 'id', type: 'integer', interface: 'number' },
32
+ { name: 'age', type: 'integer', interface: 'integer' },
33
+ ],
34
+ });
35
+
36
+ const collection = engine.context.dataSourceManager.getCollection('main', 'users');
37
+ model.context.defineProperty('collection', {
38
+ value: collection,
39
+ });
40
+
41
+ const recordNode = model.context.getPropertyMetaTree().find((node) => node.name === 'record');
42
+ const recordFields =
43
+ typeof recordNode?.children === 'function' ? await recordNode.children() : recordNode?.children || [];
44
+
45
+ expect(model.context.record).toBeUndefined();
46
+ expect(recordNode).toMatchObject({
47
+ name: 'record',
48
+ title: 'Current record',
49
+ paths: ['record'],
50
+ });
51
+ expect(recordFields).toEqual(
52
+ expect.arrayContaining([
53
+ expect.objectContaining({ name: 'id', paths: ['record', 'id'] }),
54
+ expect.objectContaining({ name: 'age', paths: ['record', 'age'] }),
55
+ ]),
56
+ );
57
+ });
58
+
15
59
  it('changes renderer key and invalidates beforeRender cache when row content changes', () => {
16
60
  const engine = new FlowEngine();
17
- const model = new JSColumnModel({
61
+ engine.registerModels({ JSColumnModel });
62
+ const model = engine.createModel<JSColumnModel>({
63
+ use: 'JSColumnModel',
18
64
  uid: 'js-column-row-refresh',
19
- flowEngine: engine,
20
65
  props: {
21
66
  width: 200,
22
67
  title: 'JS column',
23
68
  },
24
- } as any);
69
+ });
25
70
 
26
71
  engine.context.dataSourceManager.getDataSource('main').addCollection({
27
72
  name: 'users',
@@ -39,12 +84,14 @@ describe('JSColumnModel row refresh', () => {
39
84
  });
40
85
 
41
86
  const column = model.getColumnProps();
42
- const first = column.render(null, { id: 1, age: 3, workyears: 39.2 }, 0) as any;
87
+ const firstRecord = { id: 1, age: 3, workyears: 39.2 };
88
+ const first = column.render(null, firstRecord, 0) as any;
43
89
  const fork = model.getFork('1') as any;
44
90
  const invalidateFlowCache = vi.fn();
45
91
  fork.invalidateFlowCache = invalidateFlowCache;
46
92
  const second = column.render(null, { id: 1, age: 37, workyears: 39.2 }, 0) as any;
47
93
 
94
+ expect(fork.context.record).toEqual({ id: 1, age: 37, workyears: 39.2 });
48
95
  expect(first.key).not.toBe(second.key);
49
96
  expect(invalidateFlowCache).toHaveBeenCalledWith('beforeRender');
50
97
  });
@@ -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
 
@@ -18,6 +18,7 @@ import {
18
18
  } from '../flow/models/topbar/UserCenterTopbarActionModel';
19
19
  import { useApp } from '../hooks/useApp';
20
20
  import { useCurrentUserAuthStatus } from '../nocobase-buildin-plugin/currentUserAuthStatus';
21
+ import { addCustomAlgorithmToTheme } from '../theme/customAlgorithm';
21
22
  import { SettingsBrand } from './SettingsBrand';
22
23
  import { SettingsGroupNav } from './SettingsGroupNav';
23
24
  import { SettingsSearch } from './SettingsSearch';
@@ -79,17 +80,31 @@ export const SettingsShell: FC = ({ children }) => {
79
80
  const { token } = antdTheme.useToken();
80
81
  // 设置中心的外观由主题编辑器里那条「简约」主题记录约束;读不到才用代码里的中性配色兜底。
81
82
  const storedThemeConfig = useSettingsThemeConfig();
83
+ const normalizedStoredThemeConfig = useMemo(
84
+ () =>
85
+ storedThemeConfig
86
+ ? addCustomAlgorithmToTheme({
87
+ ...storedThemeConfig,
88
+ algorithm: Array.isArray(storedThemeConfig.algorithm)
89
+ ? [...storedThemeConfig.algorithm]
90
+ : storedThemeConfig.algorithm,
91
+ })
92
+ : storedThemeConfig,
93
+ [storedThemeConfig],
94
+ );
82
95
  // 顶栏和补丁样式要按**设置中心自己这套主题**算色,不能直接用上面那个 `token`——
83
96
  // 这个组件在自己的 ConfigProvider 外面,`token` 是业务端主题的。用它的话,业务端一切暗黑,
84
97
  // 顶栏就跟着变黑,而内容区还是简约的白,一半黑一半白。
85
98
  const settingsToken = useMemo(
86
- () => (storedThemeConfig ? antdTheme.getDesignToken(storedThemeConfig) : token),
87
- [storedThemeConfig, token],
99
+ () => (normalizedStoredThemeConfig ? antdTheme.getDesignToken(normalizedStoredThemeConfig) : token),
100
+ [normalizedStoredThemeConfig, token],
88
101
  );
89
102
  const settingsShellTheme = useMemo<ThemeConfig>(
90
103
  () =>
91
- storedThemeConfig ? withSettingsHeaderTheme(storedThemeConfig, settingsToken) : buildSettingsNeutralTheme(token),
92
- [settingsToken, storedThemeConfig, token],
104
+ normalizedStoredThemeConfig
105
+ ? withSettingsHeaderTheme(normalizedStoredThemeConfig, settingsToken)
106
+ : buildSettingsNeutralTheme(token),
107
+ [normalizedStoredThemeConfig, settingsToken, token],
93
108
  );
94
109
  const headerColors = useMemo(() => getSettingsHeaderColors(settingsToken), [settingsToken]);
95
110
  const settingsGlobalCss = useMemo(() => buildSettingsGlobalCss(settingsToken), [settingsToken]);