@nocobase/client-v2 2.1.0-alpha.45 → 2.1.0-alpha.46

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 (77) hide show
  1. package/es/authRedirect.d.ts +12 -0
  2. package/es/components/form/TypedVariableInput.d.ts +75 -0
  3. package/es/components/form/index.d.ts +1 -0
  4. package/es/flow/admin-shell/admin-layout/AdminLayoutMenuModels.d.ts +1 -0
  5. package/es/flow/components/ConditionBuilder.d.ts +1 -0
  6. package/es/flow/components/FieldAssignRulesEditor.d.ts +3 -1
  7. package/es/flow/components/FieldAssignValueInput.d.ts +1 -0
  8. package/es/flow/components/fieldAssignOptions.d.ts +10 -0
  9. package/es/flow/components/filter/LinkageFilterItem.d.ts +1 -0
  10. package/es/flow/components/filter/VariableFilterItem.d.ts +1 -0
  11. package/es/flow/components/filter/metaTreeAssociationDepth.d.ts +13 -0
  12. package/es/flow/models/base/AssociationFieldGroupModel.d.ts +2 -33
  13. package/es/flow/models/blocks/assign-form/assignFieldValuesFlow.d.ts +8 -0
  14. package/es/flow/models/blocks/table/TableColumnModel.d.ts +2 -0
  15. package/es/index.mjs +103 -103
  16. package/lib/index.js +105 -105
  17. package/package.json +7 -7
  18. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +5 -0
  19. package/src/authRedirect.ts +43 -0
  20. package/src/components/README.md +48 -0
  21. package/src/components/README.zh-CN.md +48 -0
  22. package/src/components/form/TypedVariableInput.tsx +441 -0
  23. package/src/components/form/__tests__/TypedVariableInput.test.tsx +152 -0
  24. package/src/components/form/index.tsx +1 -0
  25. package/src/flow/__tests__/FlowRoute.test.tsx +8 -1
  26. package/src/flow/actions/__tests__/linkageRules.hiddenSync.restore.test.ts +100 -0
  27. package/src/flow/actions/__tests__/linkageRulesRefresh.test.ts +23 -1
  28. package/src/flow/actions/dataScope.tsx +8 -1
  29. package/src/flow/actions/linkageRules.tsx +8 -5
  30. package/src/flow/actions/linkageRulesRefresh.tsx +2 -3
  31. package/src/flow/actions/setTargetDataScope.tsx +1 -1
  32. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuModels.tsx +15 -1
  33. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +75 -1
  34. package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.test.ts +11 -0
  35. package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.ts +2 -4
  36. package/src/flow/common/Markdown/Edit.tsx +32 -4
  37. package/src/flow/components/ConditionBuilder.tsx +12 -2
  38. package/src/flow/components/FieldAssignRulesEditor.tsx +37 -46
  39. package/src/flow/components/FieldAssignValueInput.tsx +8 -4
  40. package/src/flow/components/__tests__/FieldAssignRulesEditor.test.tsx +112 -1
  41. package/src/flow/components/__tests__/fieldAssignOptions.test.ts +71 -1
  42. package/src/flow/components/code-editor/__tests__/runjsCompletions.test.ts +272 -27
  43. package/src/flow/components/code-editor/runjsCompletions.ts +490 -9
  44. package/src/flow/components/fieldAssignOptions.ts +23 -6
  45. package/src/flow/components/filter/LinkageFilterItem.tsx +22 -9
  46. package/src/flow/components/filter/VariableFilterItem.tsx +27 -9
  47. package/src/flow/components/filter/__tests__/fieldsToOptions.test.ts +48 -0
  48. package/src/flow/components/filter/__tests__/metaTreeAssociationDepth.test.ts +60 -0
  49. package/src/flow/components/filter/fieldsToOptions.ts +9 -4
  50. package/src/flow/components/filter/metaTreeAssociationDepth.ts +69 -0
  51. package/src/flow/models/actions/FilterActionModel.tsx +15 -3
  52. package/src/flow/models/actions/__tests__/AssignFormRefill.test.ts +90 -0
  53. package/src/flow/models/base/AssociationFieldGroupModel.tsx +20 -3
  54. package/src/flow/models/base/GridModel.tsx +1 -1
  55. package/src/flow/models/base/PageModel/ChildPageModel.tsx +5 -1
  56. package/src/flow/models/base/PageModel/__tests__/ChildPageModel.test.ts +12 -0
  57. package/src/flow/models/base/__tests__/AssociationFieldGroupModel.test.ts +86 -0
  58. package/src/flow/models/base/__tests__/GridModel.render.test.tsx +53 -0
  59. package/src/flow/models/blocks/assign-form/AssignFormGridModel.tsx +12 -3
  60. package/src/flow/models/blocks/assign-form/AssignFormItemModel.tsx +14 -4
  61. package/src/flow/models/blocks/assign-form/__tests__/assignFieldValuesFlow.editor.test.tsx +216 -0
  62. package/src/flow/models/blocks/assign-form/assignFieldValuesFlow.tsx +142 -40
  63. package/src/flow/models/blocks/filter-form/FilterFormItemModel.tsx +1 -1
  64. package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.defineChildren.test.ts +6 -1
  65. package/src/flow/models/blocks/filter-manager/flow-actions/connectFields.tsx +48 -6
  66. package/src/flow/models/blocks/form/FormAssociationItemModel.tsx +9 -0
  67. package/src/flow/models/blocks/form/FormItemModel.tsx +9 -0
  68. package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +17 -0
  69. package/src/flow/models/blocks/form/__tests__/FormItemModel.defineChildren.test.ts +27 -2
  70. package/src/flow/models/blocks/form/value-runtime/__tests__/runtime.test.ts +29 -0
  71. package/src/flow/models/blocks/form/value-runtime/runtime.ts +5 -1
  72. package/src/flow/models/blocks/table/TableColumnModel.tsx +15 -5
  73. package/src/flow/models/blocks/table/TableCustomColumnModel.tsx +2 -2
  74. package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +18 -0
  75. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +6 -7
  76. package/src/flow/models/fields/mobile-components/MobileLazySelect.tsx +5 -3
  77. package/src/flow/models/fields/mobile-components/__tests__/MobileSelect.test.tsx +70 -0
@@ -22,6 +22,8 @@ import { FieldModel } from '../../base/FieldModel';
22
22
  import { rebuildFieldSubModel } from '../../../internal/utils/rebuildFieldSubModel';
23
23
  import { useJsonTemplateResolver } from '../../../utils/useJsonTemplateResolver';
24
24
 
25
+ const MAX_FORM_ASSOCIATION_FIELD_DEPTH = 2;
26
+
25
27
  function mergeArrays(a, b) {
26
28
  if (!b) {
27
29
  return a;
@@ -84,10 +86,17 @@ const AssociationItem = (props) => {
84
86
  export class FormAssociationItemModel extends DisplayItemModel {
85
87
  static defineChildren(ctx: FlowModelContext) {
86
88
  const collection = ctx.collection as Collection;
89
+ const associationDepth = ctx.prefixFieldPath ? ctx.prefixFieldPath.split('.').filter(Boolean).length : 0;
87
90
 
88
91
  return collection
89
92
  .getFields()
90
93
  .map((field) => {
94
+ if (
95
+ associationDepth >= MAX_FORM_ASSOCIATION_FIELD_DEPTH &&
96
+ (field.isAssociationField?.() || field.target || field.targetCollection)
97
+ ) {
98
+ return null;
99
+ }
91
100
  const binding = this.getDefaultBindingByField(ctx, field, { fallbackToTargetTitleField: true });
92
101
  if (!binding) return null;
93
102
  const fieldModel = binding.modelName;
@@ -42,12 +42,21 @@ const interfacesOfUnsupportedDefaultValue = [
42
42
  'formula',
43
43
  ];
44
44
 
45
+ const MAX_FORM_ASSOCIATION_FIELD_DEPTH = 2;
46
+
45
47
  export class FormItemModel<T extends DefaultStructure = DefaultStructure> extends EditableItemModel<T> {
46
48
  static defineChildren(ctx: FlowModelContext) {
47
49
  const collection = ctx.collection as Collection;
50
+ const associationDepth = ctx.prefixFieldPath ? ctx.prefixFieldPath.split('.').filter(Boolean).length : 0;
48
51
  return collection
49
52
  .getFields()
50
53
  .map((field) => {
54
+ if (
55
+ associationDepth >= MAX_FORM_ASSOCIATION_FIELD_DEPTH &&
56
+ (field.isAssociationField?.() || field.target || field.targetCollection)
57
+ ) {
58
+ return;
59
+ }
51
60
  const binding = this.getDefaultBindingByField(ctx, field);
52
61
  if (!binding) {
53
62
  return;
@@ -221,6 +221,23 @@ describe('FlowModel core behaviors (collected)', () => {
221
221
  expect(i1).toEqual({ ping: 1 });
222
222
  });
223
223
 
224
+ it('unchanged stepParams do not trigger a beforeRender rerun', async () => {
225
+ vi.useFakeTimers();
226
+ const spy = vi.fn().mockResolvedValue([]);
227
+ (engine as any).executor.dispatchEvent = spy;
228
+ const model = engine.createModel<FlowModel>({
229
+ use: 'FlowModel',
230
+ uid: 'm-flow-3-noop',
231
+ stepParams: { anyFlow: { anyStep: { x: 1 } } },
232
+ });
233
+
234
+ await model.dispatchEvent('beforeRender', { ping: 1 });
235
+ model.setStepParams('anyFlow', 'anyStep', { x: 1 });
236
+ await vi.advanceTimersByTimeAsync(150);
237
+
238
+ expect(spy).toHaveBeenCalledTimes(1);
239
+ });
240
+
224
241
  it('applyFlow delegates to executor.runFlow', async () => {
225
242
  const spyRun = vi.fn().mockResolvedValue('ok');
226
243
  (engine as any).executor.runFlow = spyRun;
@@ -12,7 +12,7 @@ import { FlowEngine, type FlowModelContext, type SubModelItem } from '@nocobase/
12
12
  // Import from the aggregate to preserve the model initialization order used by adjacent tests.
13
13
  import { FormItemModel, FormJSFieldItemModel, InputFieldModel, JSEditableFieldModel } from '../../../..';
14
14
 
15
- function createFormMenuContext(prefixFieldPath = 'roles') {
15
+ function createFormMenuContext(prefixFieldPath = 'roles', collectionName = 'roles') {
16
16
  const engine = new FlowEngine();
17
17
  engine.registerModels({
18
18
  FormItemModel,
@@ -36,15 +36,29 @@ function createFormMenuContext(prefixFieldPath = 'roles') {
36
36
  fields: [
37
37
  { name: 'id', type: 'integer', interface: 'number', title: 'ID' },
38
38
  { name: 'name', type: 'string', interface: 'input', title: 'Name' },
39
+ { name: 'department', type: 'belongsTo', interface: 'm2o', target: 'departments', title: 'Department' },
39
40
  ],
40
41
  });
42
+ dataSource.addCollection({
43
+ name: 'departments',
44
+ filterTargetKey: 'id',
45
+ fields: [
46
+ { name: 'name', type: 'string', interface: 'input', title: 'Name' },
47
+ { name: 'company', type: 'belongsTo', interface: 'm2o', target: 'companies', title: 'Company' },
48
+ ],
49
+ });
50
+ dataSource.addCollection({
51
+ name: 'companies',
52
+ filterTargetKey: 'id',
53
+ fields: [{ name: 'name', type: 'string', interface: 'input', title: 'Name' }],
54
+ });
41
55
 
42
56
  const blockModel = engine.createModel({ use: 'FlowModel', uid: 'users-form-block' });
43
57
  (blockModel as any).collection = dataSource.getCollection('users');
44
58
 
45
59
  const gridModel = engine.createModel({ use: 'FlowModel', uid: 'users-form-grid' });
46
60
  gridModel.context.defineProperty('blockModel', { value: blockModel });
47
- gridModel.context.defineProperty('collection', { value: dataSource.getCollection('roles') });
61
+ gridModel.context.defineProperty('collection', { value: dataSource.getCollection(collectionName) });
48
62
  gridModel.context.defineProperty('prefixFieldPath', { value: prefixFieldPath });
49
63
 
50
64
  return gridModel.context as FlowModelContext;
@@ -105,4 +119,15 @@ describe('FormItemModel defineChildren', () => {
105
119
 
106
120
  expect((jsNameItem?.toggleable as (model: any) => boolean)(createModelLike(normalCreateOptions))).toBe(true);
107
121
  });
122
+
123
+ it('hides association fields after two association levels in form add-field menus', async () => {
124
+ const ctx = createFormMenuContext('roles.department', 'departments');
125
+ const formItems = FormItemModel.defineChildren(ctx) as SubModelItem[];
126
+ const jsItems = (await FormJSFieldItemModel.defineChildren(ctx)) as SubModelItem[];
127
+
128
+ expect(formItems.map((item) => item.key)).toContain('roles.department.name');
129
+ expect(formItems.map((item) => item.key)).not.toContain('roles.department.company');
130
+ expect(jsItems.map((item) => item.key)).toContain('roles.department.name');
131
+ expect(jsItems.map((item) => item.key)).not.toContain('roles.department.company');
132
+ });
108
133
  });
@@ -15,6 +15,7 @@ import { observable } from '@formily/reactive';
15
15
  import { get as lodashGet, merge as lodashMerge, set as lodashSet } from 'lodash';
16
16
  import { FlowContext, JSRunner } from '@nocobase/flow-engine';
17
17
  import { FormValueRuntime } from '..';
18
+ import type { FormInstance } from 'antd';
18
19
 
19
20
  function createFormStub(initialValues: any = {}) {
20
21
  const store: any = JSON.parse(JSON.stringify(initialValues || {}));
@@ -121,6 +122,34 @@ function createFieldContext(runtime: FormValueRuntime) {
121
122
  }
122
123
 
123
124
  describe('FormValueRuntime (default rules)', () => {
125
+ it('skips object patches when values are unchanged', async () => {
126
+ const engineEmitter = new EventEmitter();
127
+ const blockEmitter = new EventEmitter();
128
+ const formStub = createFormStub({ name: 'old' });
129
+ const dispatchEvent = vi.fn();
130
+
131
+ const blockModel = {
132
+ uid: 'form-noop-patch',
133
+ flowEngine: { emitter: engineEmitter },
134
+ emitter: blockEmitter,
135
+ dispatchEvent,
136
+ getAclActionName: () => 'create',
137
+ } as ConstructorParameters<typeof FormValueRuntime>[0]['model'];
138
+
139
+ const runtime = new FormValueRuntime({ model: blockModel, getForm: () => formStub as unknown as FormInstance });
140
+ runtime.mount({ sync: true });
141
+
142
+ const blockCtx = createFieldContext(runtime);
143
+ await runtime.setFormValues(blockCtx, { name: 'old' }, { source: 'system' });
144
+
145
+ expect(dispatchEvent).not.toHaveBeenCalled();
146
+
147
+ await runtime.setFormValues(blockCtx, { name: 'new' }, { source: 'system' });
148
+
149
+ expect(formStub.getFieldValue(['name'])).toBe('new');
150
+ expect(dispatchEvent).toHaveBeenCalledTimes(1);
151
+ });
152
+
124
153
  it('recomputes default on dependency change when current equals last default; user change disables default permanently', async () => {
125
154
  const engineEmitter = new EventEmitter();
126
155
  const blockEmitter = new EventEmitter();
@@ -846,7 +846,11 @@ export class FormValueRuntime {
846
846
  const changedPaths: NamePath[] = [];
847
847
 
848
848
  if (!Array.isArray(patch)) {
849
- const patchEntries = Object.entries(patch || {}).filter(([pathKey]) => !shouldSkipByLinkageScope(pathKey));
849
+ const patchEntries = Object.entries(patch || {}).filter(([pathKey, rawValue]) => {
850
+ if (shouldSkipByLinkageScope(pathKey)) return false;
851
+ const value = isObservable(rawValue) ? toJS(rawValue) : rawValue;
852
+ return !_.isEqual(this.getFormValueAtPath([pathKey]), value);
853
+ });
850
854
  const patchToApply = Object.fromEntries(patchEntries);
851
855
  const patchKeys = patchEntries.map(([pathKey]) => pathKey);
852
856
  if (!patchKeys.length) {
@@ -47,7 +47,7 @@ export function FieldDeletePlaceholder(props: any) {
47
47
  const dataSourcePrefix = `${t(dataSource.displayName || dataSource.key)} > `;
48
48
  const collectionPrefix = collection ? `${t(collection.title) || collection.name || collection.tableName} > ` : '';
49
49
  return `${dataSourcePrefix}${collectionPrefix}${name}`;
50
- }, []);
50
+ }, [collection, dataSource.displayName, dataSource.key, name, t]);
51
51
  const content = t(`The {{type}} "{{name}}" may have been deleted. Please remove this {{blockType}}.`, {
52
52
  type: t('Field'),
53
53
  name: nameValue,
@@ -71,7 +71,7 @@ function FieldWithoutPermissionPlaceholder() {
71
71
  const dataSourcePrefix = `${t(dataSource.displayName || dataSource.key)} > `;
72
72
  const collectionPrefix = collection ? `${t(collection.title) || collection.name || collection.tableName} > ` : '';
73
73
  return `${dataSourcePrefix}${collectionPrefix}${name}`;
74
- }, []);
74
+ }, [collection, dataSource.displayName, dataSource.key, name, t]);
75
75
  const { actionName } = model.forbidden;
76
76
  const messageValue = useMemo(() => {
77
77
  return t(
@@ -81,7 +81,7 @@ function FieldWithoutPermissionPlaceholder() {
81
81
  actionName: t(capitalize(actionName)),
82
82
  },
83
83
  ).replaceAll('&gt;', '>');
84
- }, [nameValue, t]);
84
+ }, [actionName, nameValue, t]);
85
85
  return (
86
86
  <Tooltip title={messageValue}>
87
87
  <LockOutlined style={{ opacity: '0.3' }} />
@@ -89,6 +89,15 @@ function FieldWithoutPermissionPlaceholder() {
89
89
  );
90
90
  }
91
91
 
92
+ export const TABLE_COLUMN_MIN_WIDTH = 10;
93
+
94
+ export function normalizeTableColumnWidth(width: number | null | undefined) {
95
+ if (typeof width === 'number' && width < TABLE_COLUMN_MIN_WIDTH) {
96
+ return TABLE_COLUMN_MIN_WIDTH;
97
+ }
98
+ return width;
99
+ }
100
+
92
101
  export const CustomWidth = ({ setOpen, t, handleChange, defaultValue }) => {
93
102
  const [customWidth, setCustomWidth] = useState(defaultValue);
94
103
  return (
@@ -104,13 +113,14 @@ export const CustomWidth = ({ setOpen, t, handleChange, defaultValue }) => {
104
113
  <Space.Compact block>
105
114
  <InputNumber
106
115
  placeholder={t('Custom column width')}
116
+ min={TABLE_COLUMN_MIN_WIDTH}
107
117
  value={customWidth}
108
118
  onChange={(val) => {
109
119
  setCustomWidth(val);
110
120
  }}
111
121
  style={{ width: '100%', minWidth: 200 }}
112
122
  />
113
- <Button type="primary" onClick={() => handleChange(customWidth)}>
123
+ <Button type="primary" onClick={() => handleChange(normalizeTableColumnWidth(customWidth))}>
114
124
  OK
115
125
  </Button>
116
126
  </Space.Compact>
@@ -442,7 +452,7 @@ TableColumnModel.registerFlow({
442
452
  width: 150,
443
453
  },
444
454
  handler(ctx, params) {
445
- ctx.model.setProps('width', params.width);
455
+ ctx.model.setProps('width', normalizeTableColumnWidth(params.width));
446
456
  },
447
457
  },
448
458
  aclCheck: {
@@ -10,7 +10,7 @@
10
10
  import { tExpr, FlowModel, ModelRenderMode } from '@nocobase/flow-engine';
11
11
  import { Divider } from 'antd';
12
12
  import React from 'react';
13
- import { CustomWidth } from './TableColumnModel';
13
+ import { CustomWidth, normalizeTableColumnWidth } from './TableColumnModel';
14
14
  export class TableCustomColumnModel extends FlowModel {
15
15
  static renderMode: ModelRenderMode = ModelRenderMode.RenderFunction;
16
16
  }
@@ -100,7 +100,7 @@ TableCustomColumnModel.registerFlow({
100
100
  width: 150,
101
101
  },
102
102
  handler(ctx, params) {
103
- ctx.model.setProps('width', params.width);
103
+ ctx.model.setProps('width', normalizeTableColumnWidth(params.width));
104
104
  },
105
105
  },
106
106
  fixed: {
@@ -12,6 +12,24 @@ import { describe, expect, it, vi } from 'vitest';
12
12
  import { TableColumnModel } from '../TableColumnModel';
13
13
 
14
14
  describe('TableColumnModel sorter settings', () => {
15
+ it('clamps custom column width to the minimum value', () => {
16
+ const engine = new FlowEngine();
17
+ const model = new TableColumnModel({ uid: 'table-column-min-width', flowEngine: engine } as any);
18
+ const widthStep = model.getFlow('tableColumnSettings')?.steps?.width as any;
19
+ const setProps = vi.fn();
20
+
21
+ widthStep.handler(
22
+ {
23
+ model: {
24
+ setProps,
25
+ },
26
+ },
27
+ { width: 0 },
28
+ );
29
+
30
+ expect(setProps).toHaveBeenCalledWith('width', 10);
31
+ });
32
+
15
33
  it('hides quick edit setting for relation path columns added from association groups', async () => {
16
34
  const engine = new FlowEngine();
17
35
  const model = new TableColumnModel({ uid: 'table-column-relation-path-quick-edit', flowEngine: engine } as any);
@@ -33,7 +33,7 @@ import React, { useRef, useMemo, useEffect } from 'react';
33
33
  import { SubTableFieldModel } from '.';
34
34
  import { FieldModel } from '../../../base/FieldModel';
35
35
  import { DetailsItemModel } from '../../../blocks/details/DetailsItemModel';
36
- import { FieldDeletePlaceholder, CustomWidth } from '../../../blocks/table/TableColumnModel';
36
+ import { FieldDeletePlaceholder, CustomWidth, normalizeTableColumnWidth } from '../../../blocks/table/TableColumnModel';
37
37
  import { buildDynamicNamePath } from '../../../blocks/form/dynamicNamePath';
38
38
  import { getSubTableRowIdentity } from './rowIdentity';
39
39
  import { getFieldBindingUse, rebuildFieldSubModel } from '../../../../internal/utils/rebuildFieldSubModel';
@@ -67,7 +67,7 @@ export function FieldWithoutPermissionPlaceholder({ targetModel }) {
67
67
  const dataSourcePrefix = `${t(dataSource.displayName || dataSource.key)} > `;
68
68
  const collectionPrefix = collection ? `${t(collection.title) || collection.name || collection.tableName} > ` : '';
69
69
  return `${dataSourcePrefix}${collectionPrefix}${name}`;
70
- }, []);
70
+ }, [collection, dataSource.displayName, dataSource.key, name, t]);
71
71
  const { actionName } = fieldModel.forbidden || {};
72
72
  const messageValue = useMemo(() => {
73
73
  return t(
@@ -77,7 +77,7 @@ export function FieldWithoutPermissionPlaceholder({ targetModel }) {
77
77
  actionName: t(capitalize(actionName)),
78
78
  },
79
79
  ).replaceAll('&gt;', '>');
80
- }, [nameValue, t]);
80
+ }, [actionName, nameValue, t]);
81
81
  return (
82
82
  <Tooltip title={messageValue}>
83
83
  <LockOutlined style={{ opacity: '0.3' }} />
@@ -100,10 +100,9 @@ const LargeFieldEdit = observer(({ model, params: { fieldPath, index }, defaultV
100
100
  const handleChange = useMemo(
101
101
  () =>
102
102
  debounce((val) => {
103
- if (props.onChange) props.onChange(val);
104
103
  if (onChange) onChange(val);
105
104
  }, 200),
106
- [props.onChange, onChange],
105
+ [onChange],
107
106
  );
108
107
 
109
108
  return <FieldModelRenderer model={model} {...rest} onChange={handleChange} />;
@@ -149,7 +148,7 @@ const LargeFieldEdit = observer(({ model, params: { fieldPath, index }, defaultV
149
148
  </Space>
150
149
  );
151
150
  }
152
- }, [collectionField.interface, defaultValue, fieldModel]);
151
+ }, [collectionField.interface, defaultValue, disabled, fieldModel]);
153
152
  return (
154
153
  <div
155
154
  ref={ref}
@@ -830,7 +829,7 @@ SubTableColumnModel.registerFlow({
830
829
  width: 200,
831
830
  },
832
831
  handler(ctx, params) {
833
- ctx.model.setProps('width', params.width);
832
+ ctx.model.setProps('width', normalizeTableColumnWidth(params.width));
834
833
  },
835
834
  },
836
835
  aclCheck: {
@@ -10,7 +10,7 @@
10
10
  import { useFlowModelContext } from '@nocobase/flow-engine';
11
11
  import { Select } from 'antd';
12
12
  import type { CSSProperties } from 'react';
13
- import React, { useCallback, useEffect, useMemo, useState } from 'react';
13
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
14
14
  import { Button, CheckList, Popup, SearchBar, SpinLoading } from 'antd-mobile';
15
15
  import { css } from '@emotion/css';
16
16
  import {
@@ -51,7 +51,7 @@ function deriveRecordsFromValue(
51
51
  ) {
52
52
  if (isMultiple) {
53
53
  if (Array.isArray(value)) {
54
- return (value.filter(Boolean) as any[]).map((item) => {
54
+ return (value.filter((item) => item !== null && item !== undefined) as any[]).map((item) => {
55
55
  if (valueMode === 'value') {
56
56
  return optionMap.get(item) ?? { [valueKey]: item };
57
57
  }
@@ -113,11 +113,13 @@ export function MobileLazySelect(props: Readonly<LazySelectProps>) {
113
113
  );
114
114
 
115
115
  const [selectedRecords, setSelectedRecords] = useState<AssociationOption[]>(() => derivedRecords);
116
+ const wasVisibleRef = useRef(visible);
116
117
 
117
118
  useEffect(() => {
118
- if (visible) {
119
+ if (visible && !wasVisibleRef.current) {
119
120
  setSelectedRecords(derivedRecords);
120
121
  }
122
+ wasVisibleRef.current = visible;
121
123
  }, [derivedRecords, visible]);
122
124
 
123
125
  useEffect(() => {
@@ -10,6 +10,7 @@
10
10
  import React from 'react';
11
11
  import { beforeEach, describe, it, expect, vi } from 'vitest';
12
12
  import { act, fireEvent, render, screen } from '@nocobase/test/client';
13
+ import { MobileLazySelect } from '../MobileLazySelect';
13
14
  import { MobileSelect } from '../MobileSelect';
14
15
 
15
16
  const DEFAULT_OPTIONS = [
@@ -17,6 +18,11 @@ const DEFAULT_OPTIONS = [
17
18
  { label: 'Option B', value: 'b' },
18
19
  ];
19
20
 
21
+ const RELATION_OPTIONS = [
22
+ { uuid: '05f6a3b4-bfb7-7943-578a-3819e2687a7e' },
23
+ { uuid: 'c7d99828-a1de-9e70-4c2d-b0139abdf02e' },
24
+ ];
25
+
20
26
  const mockState = vi.hoisted(() => ({
21
27
  selectProps: undefined as any,
22
28
  popupProps: undefined as any,
@@ -44,6 +50,13 @@ function openPopup() {
44
50
  expect(screen.getByTestId('popup')).toBeInTheDocument();
45
51
  }
46
52
 
53
+ function openLazyPopup() {
54
+ act(() => {
55
+ mockState.selectProps?.onClick?.();
56
+ });
57
+ expect(screen.getByTestId('popup')).toBeInTheDocument();
58
+ }
59
+
47
60
  function selectValues(values: string[]) {
48
61
  act(() => {
49
62
  mockState.checklistProps?.onChange?.(values);
@@ -73,6 +86,30 @@ function renderMobileSelect(props: Record<string, any> = {}) {
73
86
  return { onChange, onChangeComplete };
74
87
  }
75
88
 
89
+ function renderMobileLazySelect(props: Record<string, any> = {}) {
90
+ const onChange = props.onChange ?? vi.fn();
91
+ const renderComponent = (nextProps: Record<string, any> = {}) => (
92
+ <MobileLazySelect
93
+ fieldNames={{ label: 'uuid', value: 'uuid' }}
94
+ value={[]}
95
+ multiple
96
+ allowMultiple
97
+ options={RELATION_OPTIONS}
98
+ {...props}
99
+ {...nextProps}
100
+ onChange={onChange}
101
+ />
102
+ );
103
+
104
+ const result = render(renderComponent());
105
+
106
+ return {
107
+ ...result,
108
+ onChange,
109
+ rerender: (nextProps: Record<string, any> = {}) => result.rerender(renderComponent(nextProps)),
110
+ };
111
+ }
112
+
76
113
  vi.mock('@nocobase/flow-engine', async () => {
77
114
  const actual = await vi.importActual<any>('@nocobase/flow-engine');
78
115
  return {
@@ -80,6 +117,12 @@ vi.mock('@nocobase/flow-engine', async () => {
80
117
  useFlowModelContext: () => ({
81
118
  t: (value: string) => value,
82
119
  }),
120
+ useFlowModel: () => ({
121
+ context: {
122
+ collectionField: {},
123
+ },
124
+ subModels: {},
125
+ }),
83
126
  };
84
127
  });
85
128
 
@@ -240,3 +283,30 @@ describe('MobileSelect in SubForm/SubTable containers', () => {
240
283
  expect(onCommit).toHaveBeenNthCalledWith(2, ['a', 'b']);
241
284
  });
242
285
  });
286
+
287
+ describe('MobileLazySelect', () => {
288
+ beforeEach(() => {
289
+ resetMockState();
290
+ });
291
+
292
+ it('keeps pending relation records selected until confirm', () => {
293
+ const { onChange, rerender } = renderMobileLazySelect();
294
+
295
+ openLazyPopup();
296
+ expect(mockState.checklistProps?.value).toEqual([]);
297
+
298
+ selectValues(['c7d99828-a1de-9e70-4c2d-b0139abdf02e']);
299
+ expect(mockState.checklistProps?.value).toEqual(['c7d99828-a1de-9e70-4c2d-b0139abdf02e']);
300
+
301
+ rerender({
302
+ options: RELATION_OPTIONS.map((item) => ({ ...item })),
303
+ });
304
+
305
+ expect(mockState.checklistProps?.value).toEqual(['c7d99828-a1de-9e70-4c2d-b0139abdf02e']);
306
+
307
+ confirmSelection();
308
+
309
+ expect(onChange).toHaveBeenCalledTimes(1);
310
+ expect(onChange).toHaveBeenCalledWith([RELATION_OPTIONS[1]]);
311
+ });
312
+ });