@nocobase/client-v2 2.1.36 → 2.1.38

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 (33) 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/flow/models/blocks/form/FormBlockModel.d.ts +6 -0
  8. package/es/flow/models/blocks/form/FormGridModel.d.ts +6 -0
  9. package/es/flow/models/blocks/form/value-runtime/rules.d.ts +1 -0
  10. package/es/index.mjs +65 -74
  11. package/lib/index.js +92 -101
  12. package/package.json +7 -7
  13. package/src/flow/actions/__tests__/linkageRules.actionStates.test.ts +33 -0
  14. package/src/flow/actions/linkageRules.tsx +25 -7
  15. package/src/flow/components/FieldAssignValueInput.tsx +34 -571
  16. package/src/flow/components/field-value-variable/DateVariableEditor.tsx +162 -0
  17. package/src/flow/components/field-value-variable/FieldValueVariableInput.tsx +306 -0
  18. package/src/flow/components/field-value-variable/__tests__/FieldValueVariableInput.test.tsx +380 -0
  19. package/src/flow/components/field-value-variable/dateValue.ts +223 -0
  20. package/src/flow/components/field-value-variable/index.ts +12 -0
  21. package/src/flow/models/blocks/assign-form/AssignFormItemModel.tsx +28 -53
  22. package/src/flow/models/blocks/form/FormBlockModel.tsx +4 -0
  23. package/src/flow/models/blocks/form/FormGridModel.tsx +4 -0
  24. package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +8 -2
  25. package/src/flow/models/blocks/form/value-runtime/__tests__/runtime.test.ts +435 -0
  26. package/src/flow/models/blocks/form/value-runtime/rules.ts +68 -14
  27. package/src/flow/models/blocks/form/value-runtime/runtime.ts +43 -19
  28. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +8 -0
  29. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/__tests__/popupContext.test.ts +120 -0
  30. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/actions/PopupSubTableEditActionModel.tsx +10 -2
  31. package/src/flow/models/fields/AssociationFieldModel/__tests__/RecordPickerFieldModel.itemContext.test.ts +46 -0
  32. package/src/flow/models/fields/mobile-components/MobileLazySelect.tsx +1 -0
  33. package/src/flow/models/fields/mobile-components/__tests__/MobileSelect.test.tsx +20 -2
@@ -8,7 +8,14 @@
8
8
  */
9
9
 
10
10
  import { isObservable, reaction, toJS } from '@formily/reactive';
11
- import { FlowContext, FlowModel, isRunJSValue, normalizeRunJSValue, runjsWithSafeGlobals } from '@nocobase/flow-engine';
11
+ import {
12
+ FlowContext,
13
+ FlowModel,
14
+ isRunJSValue,
15
+ normalizeRunJSValue,
16
+ runjsWithSafeGlobals,
17
+ type ResolveJsonTemplateOptions,
18
+ } from '@nocobase/flow-engine';
12
19
  import { getValuesByPath } from '@nocobase/shared';
13
20
  import _ from 'lodash';
14
21
  import { dayjs } from '@nocobase/utils/client';
@@ -49,6 +56,7 @@ type RuntimeRule = {
49
56
  getValue: () => any;
50
57
  getCondition?: () => any;
51
58
  getContext: () => any;
59
+ getContractModelUid?: () => string | undefined;
52
60
  };
53
61
 
54
62
  type ObservableBinding = {
@@ -83,8 +91,18 @@ type ToManyAggregateSourceInfo = {
83
91
  lastWrite?: FormValueWriteMeta;
84
92
  };
85
93
 
94
+ type RuntimeItemChain = {
95
+ index?: number;
96
+ length?: number;
97
+ __is_new__?: boolean;
98
+ __is_stored__?: boolean;
99
+ value: unknown;
100
+ parentItem?: RuntimeItemChain;
101
+ };
102
+
86
103
  export type RuleEngineOptions = {
87
104
  getBlockModelUid: () => string;
105
+ getAssignRulesModelUid: () => string | undefined;
88
106
  getActionName: () => string | undefined;
89
107
  getBlockContext: () => any;
90
108
  getEngine: () => any;
@@ -208,6 +226,7 @@ export class RuleEngine {
208
226
  getValue: () => template?.value,
209
227
  getCondition: () => template?.condition,
210
228
  getContext: () => this.options.getBlockContext(),
229
+ getContractModelUid: this.options.getAssignRulesModelUid,
211
230
  };
212
231
 
213
232
  this.rules.set(id, { rule, state: { deps: new Set(), depDisposers: [], runSeq: 0, scheduledAtWriteSeq: 0 } });
@@ -545,6 +564,7 @@ export class RuleEngine {
545
564
  getValue: () => template?.value,
546
565
  getCondition: () => template?.condition,
547
566
  getContext: () => this.options.getBlockContext(),
567
+ getContractModelUid: this.options.getAssignRulesModelUid,
548
568
  };
549
569
 
550
570
  this.rules.set(id, { rule, state: { deps: new Set(), depDisposers: [], runSeq: 0, scheduledAtWriteSeq: 0 } });
@@ -913,6 +933,7 @@ export class RuleEngine {
913
933
  getValue: () => template?.value,
914
934
  getCondition: () => template?.condition,
915
935
  getContext: () => model?.context,
936
+ getContractModelUid: this.options.getAssignRulesModelUid,
916
937
  };
917
938
 
918
939
  this.rules.set(id, { rule, state: { deps: new Set(), depDisposers: [], runSeq: 0, scheduledAtWriteSeq: 0 } });
@@ -1662,7 +1683,7 @@ export class RuleEngine {
1662
1683
  }
1663
1684
  }
1664
1685
 
1665
- const evalCtx = this.createRuleEvaluationContext(baseCtx, collector, targetNamePath);
1686
+ const evalCtx = this.createRuleEvaluationContext(baseCtx, collector, targetNamePath, rule.getContractModelUid?.());
1666
1687
  return { collector, evalCtx, rawValue, isRunJS };
1667
1688
  }
1668
1689
 
@@ -2001,7 +2022,12 @@ export class RuleEngine {
2001
2022
  return canOverwrite;
2002
2023
  }
2003
2024
 
2004
- private createRuleEvaluationContext(baseCtx: any, collector: DepCollector, targetNamePath: NamePath | null) {
2025
+ private createRuleEvaluationContext(
2026
+ baseCtx: any,
2027
+ collector: DepCollector,
2028
+ targetNamePath: NamePath | null,
2029
+ contractModelUid?: string,
2030
+ ) {
2005
2031
  const trackingFormValues = this.options.createTrackingFormValues(collector);
2006
2032
  const ctx: any = new FlowContext();
2007
2033
  try {
@@ -2021,12 +2047,19 @@ export class RuleEngine {
2021
2047
  }
2022
2048
 
2023
2049
  const delegatedResolveJsonTemplate =
2024
- typeof ctx.resolveJsonTemplate === 'function' ? ctx.resolveJsonTemplate.bind(ctx) : undefined;
2050
+ typeof ctx.resolveJsonTemplate === 'function'
2051
+ ? (ctx.resolveJsonTemplate.bind(ctx) as (
2052
+ template: unknown,
2053
+ options?: ResolveJsonTemplateOptions,
2054
+ ) => Promise<unknown>)
2055
+ : undefined;
2025
2056
  ctx.defineMethod('resolveJsonTemplate', async (template: unknown) => {
2026
2057
  const tokenStore = this.createLocalTemplateTokenStore();
2027
2058
  const localResolved = this.resolveLocalFormValuesTemplates(baseCtx, template, collector, tokenStore);
2028
2059
  const nextTemplate = localResolved.matched ? localResolved.value : template;
2029
- const resolved = delegatedResolveJsonTemplate ? await delegatedResolveJsonTemplate(nextTemplate) : nextTemplate;
2060
+ const resolved = delegatedResolveJsonTemplate
2061
+ ? await delegatedResolveJsonTemplate(nextTemplate, contractModelUid ? { contractModelUid } : undefined)
2062
+ : nextTemplate;
2030
2063
  return this.restoreLocalTemplateTokens(resolved, tokenStore);
2031
2064
  });
2032
2065
 
@@ -2038,7 +2071,7 @@ export class RuleEngine {
2038
2071
  // - parentItem:上级项(同结构,可链式 parentItem.parentItem...)
2039
2072
  // 计算顺序:
2040
2073
  // 1) 优先按 targetNamePath 从 formValues 构建“关联链 item”
2041
- // 2) 若无法构建(例如目标字段是顶层路径),回退到上游显式注入的 baseCtx.item
2074
+ // 2) 若无法构建(例如目标字段是顶层非关联字段),回退到上游显式注入的 baseCtx.item
2042
2075
  // (如 PopupSubTable 新增弹窗传入的 parentItem 链)
2043
2076
  let itemCached: any;
2044
2077
  let itemCachedReady = false;
@@ -2209,9 +2242,23 @@ export class RuleEngine {
2209
2242
  parentItem,
2210
2243
  };
2211
2244
  };
2212
- const defaultRoot = buildNode(trackingFormValues, undefined, undefined, undefined);
2213
- // item 仅用于“关系字段的子路径”场景;
2214
- // 顶层字段/非关联嵌套对象字段应使用 formValues。
2245
+ const blockCtx = this.options.getBlockContext();
2246
+ const formRootItem = (() => {
2247
+ try {
2248
+ const item = blockCtx?.item as RuntimeItemChain | undefined;
2249
+ if (!item || typeof item !== 'object') return undefined;
2250
+ return item.value === blockCtx?.formValues ? item : undefined;
2251
+ } catch {
2252
+ return undefined;
2253
+ }
2254
+ })();
2255
+ const defaultRoot = {
2256
+ ...buildNode(trackingFormValues, formRootItem?.index, formRootItem?.length, formRootItem?.parentItem),
2257
+ __is_new__: formRootItem?.__is_new__ ?? trackingFormValues?.__is_new__,
2258
+ __is_stored__: formRootItem?.__is_stored__ ?? trackingFormValues?.__is_stored__,
2259
+ };
2260
+ // item 用于“关系字段目标或其子路径”场景;
2261
+ // 顶层非关联字段/非关联嵌套对象字段应使用 formValues。
2215
2262
  if (!targetNamePath || !Array.isArray(targetNamePath) || !targetNamePath.length) return undefined;
2216
2263
  if (!rootCollection?.getField) return undefined;
2217
2264
 
@@ -2219,7 +2266,9 @@ export class RuleEngine {
2219
2266
  const prefix: NamePath = [];
2220
2267
  let collection = rootCollection;
2221
2268
 
2222
- for (let i = 0; i < targetNamePath.length - 1; i++) {
2269
+ // The target itself can be an association. Keep that final association in the item chain so
2270
+ // ctx.item.parentItem continues to point at the containing row instead of skipping directly to the form root.
2271
+ for (let i = 0; i < targetNamePath.length; i++) {
2223
2272
  const seg = targetNamePath[i];
2224
2273
  if (typeof seg !== 'string') break;
2225
2274
 
@@ -2231,9 +2280,12 @@ export class RuleEngine {
2231
2280
 
2232
2281
  if (toMany) {
2233
2282
  const next = targetNamePath[i + 1];
2234
- if (typeof next !== 'number') break;
2235
- prefix.push(next);
2236
- i += 1;
2283
+ if (typeof next === 'number') {
2284
+ prefix.push(next);
2285
+ i += 1;
2286
+ } else if (i !== targetNamePath.length - 1) {
2287
+ break;
2288
+ }
2237
2289
  }
2238
2290
 
2239
2291
  const targetCollection = field?.targetCollection;
@@ -2248,9 +2300,11 @@ export class RuleEngine {
2248
2300
  const assocEntry = assocEntries[idx];
2249
2301
  const value = _.get(trackingFormValues, assocEntry.path);
2250
2302
  const lastSeg = assocEntry.path[assocEntry.path.length - 1];
2251
- const index = assocEntry.toMany && typeof lastSeg === 'number' ? lastSeg : undefined;
2303
+ const isIndexedToManyItem = assocEntry.toMany && typeof lastSeg === 'number';
2304
+ const index = isIndexedToManyItem ? lastSeg : undefined;
2252
2305
  const length = (() => {
2253
2306
  if (!assocEntry.toMany) return undefined;
2307
+ if (!isIndexedToManyItem) return Array.isArray(value) ? value.length : undefined;
2254
2308
  // assocEntry.path: [..., associationKey, rowIndex]
2255
2309
  const listPath = assocEntry.path.slice(0, -1);
2256
2310
  const list = _.get(trackingFormValues, listPath);
@@ -81,6 +81,10 @@ export class FormValueRuntime {
81
81
 
82
82
  this.ruleEngine = new RuleEngine({
83
83
  getBlockModelUid: () => String(this.model?.uid),
84
+ getAssignRulesModelUid: () => {
85
+ const grid = this.model?.subModels?.grid;
86
+ return !Array.isArray(grid) && grid?.uid ? String(grid.uid) : undefined;
87
+ },
84
88
  getActionName: () => this.model?.getAclActionName?.() ?? this.model?.context?.actionName,
85
89
  getBlockContext: () => this.model?.context,
86
90
  getEngine: () => this.model?.context?.engine,
@@ -142,13 +146,32 @@ export class FormValueRuntime {
142
146
  return this.getForm().getFieldsValue(true);
143
147
  }
144
148
 
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
- });
149
+ private toMirrorSnapshot<T>(value: T): T {
150
+ const cloneValue = (input: unknown): unknown =>
151
+ _.cloneDeepWith(input, (item: unknown) => {
152
+ if (isObservable(item)) {
153
+ const plainItem = toJS(item);
154
+ if (plainItem !== item) return cloneValue(plainItem);
155
+ }
156
+
157
+ // Tracking form-value array proxies hide `constructor`, while Lodash's array
158
+ // clone relies on it being callable.
159
+ if (Array.isArray(item) && typeof item.constructor !== 'function') {
160
+ const plainArray = new Array(item.length);
161
+ for (let index = 0; index < item.length; index++) {
162
+ if (Object.hasOwn(item, index)) {
163
+ plainArray[index] = cloneValue(item[index]);
164
+ }
165
+ }
166
+ return plainArray;
167
+ }
168
+
169
+ if (!item || typeof item !== 'object') return undefined;
170
+ if (Array.isArray(item) || _.isPlainObject(item)) return undefined;
171
+ return item;
172
+ });
173
+
174
+ return cloneValue(value) as T;
152
175
  }
153
176
 
154
177
  canApplyDefaultValuePatch(namePath: NamePath, resolved: any) {
@@ -892,11 +915,12 @@ export class FormValueRuntime {
892
915
  const changedPaths: NamePath[] = [];
893
916
 
894
917
  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
- });
918
+ const patchEntries = Object.entries(patch || {})
919
+ .map(([pathKey, rawValue]) => [pathKey, this.toMirrorSnapshot(rawValue)] as const)
920
+ .filter(([pathKey, value]) => {
921
+ if (shouldSkipByLinkageScope(pathKey)) return false;
922
+ return !_.isEqual(this.getFormValueAtPath([pathKey]), value);
923
+ });
900
924
  const patchToApply = Object.fromEntries(patchEntries);
901
925
  const patchKeys = patchEntries.map(([pathKey]) => pathKey);
902
926
  if (!patchKeys.length) {
@@ -907,8 +931,7 @@ export class FormValueRuntime {
907
931
  }
908
932
  this.suppressFormCallbackDepth++;
909
933
  try {
910
- for (const [pathKey, rawValue] of patchEntries) {
911
- const value = isObservable(rawValue) ? toJS(rawValue) : rawValue;
934
+ for (const [pathKey, value] of patchEntries) {
912
935
  if (typeof form.setFieldValue === 'function') {
913
936
  form.setFieldValue(pathKey, value);
914
937
  } else if (typeof (form as any).setFields === 'function') {
@@ -969,7 +992,7 @@ export class FormValueRuntime {
969
992
  const namePath = this.resolveNamePath(callerCtx, item.path);
970
993
  const pathKey = namePathToPathKey(namePath);
971
994
  const rawValue = item.value;
972
- const value = isObservable(rawValue) ? toJS(rawValue) : rawValue;
995
+ const value = this.toMirrorSnapshot(rawValue);
973
996
 
974
997
  if (shouldSkipByLinkageScope(pathKey)) continue;
975
998
 
@@ -1108,17 +1131,18 @@ export class FormValueRuntime {
1108
1131
  if (!form) return;
1109
1132
  if (source === 'override' && this.findUserEditedHit(pathKey)) return;
1110
1133
 
1134
+ const value = this.toMirrorSnapshot(nextValue);
1111
1135
  const prevValue = _.get(this.valuesMirror, namePath);
1112
- if (_.isEqual(prevValue, nextValue)) return;
1136
+ if (_.isEqual(prevValue, value)) return;
1113
1137
 
1114
1138
  this.writeSeq += 1;
1115
1139
  const writeSeq = this.writeSeq;
1116
1140
 
1117
1141
  this.suppressFormCallbackDepth++;
1118
1142
  try {
1119
- form.setFieldValue?.(namePath, nextValue);
1120
- _.set(this.valuesMirror, namePath, this.toMirrorSnapshot(nextValue));
1121
- this.syncMountedFieldModelValue(namePath, nextValue);
1143
+ form.setFieldValue?.(namePath, value);
1144
+ _.set(this.valuesMirror, namePath, this.toMirrorSnapshot(value));
1145
+ this.syncMountedFieldModelValue(namePath, value);
1122
1146
  this.bumpChangeTick();
1123
1147
  } finally {
1124
1148
  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