@nocobase/client-v2 2.3.0-beta.9 → 2.4.0-alpha.10

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 (117) hide show
  1. package/es/RouteRepository.d.ts +6 -0
  2. package/es/acl/ACLProvider.d.ts +10 -0
  3. package/es/acl/aclCheckReadiness.d.ts +11 -0
  4. package/es/authRedirect.d.ts +11 -2
  5. package/es/components/AttachmentUpload.d.ts +51 -0
  6. package/es/components/index.d.ts +1 -0
  7. package/es/flow/admin-shell/admin-layout/NocoBaseLogo.d.ts +10 -0
  8. package/es/flow/components/filter/VariableFilterItem.d.ts +10 -1
  9. package/es/flow/internal/utils/quickEditDataScope.d.ts +48 -0
  10. package/es/flow/internal/utils/quickEditViewContainer.d.ts +27 -0
  11. package/es/flow/models/blocks/form/QuickEditFormModel.d.ts +1 -0
  12. package/es/index.d.ts +4 -1
  13. package/es/index.mjs +129 -118
  14. package/es/nocobase-buildin-plugin/currentUserAuthStatus.d.ts +11 -0
  15. package/es/nocobase-buildin-plugin/index.d.ts +10 -0
  16. package/es/settings-app/SettingsApplication.d.ts +18 -0
  17. package/es/settings-app/SettingsBrand.d.ts +16 -0
  18. package/es/settings-app/SettingsBuildInPlugin.d.ts +9 -0
  19. package/es/settings-app/SettingsDocumentRedirect.d.ts +10 -0
  20. package/es/settings-app/SettingsGroupNav.d.ts +18 -0
  21. package/es/settings-app/SettingsPluginSettingsManager.d.ts +14 -0
  22. package/es/settings-app/SettingsRouterManager.d.ts +15 -0
  23. package/es/settings-app/SettingsSearch.d.ts +17 -0
  24. package/es/settings-app/SettingsShell.d.ts +10 -0
  25. package/es/settings-app/isSettingsApp.d.ts +18 -0
  26. package/es/settings-app/runtimePaths.d.ts +18 -0
  27. package/es/settings-app/settingsDocumentPath.d.ts +14 -0
  28. package/es/settings-app/settingsTheme.d.ts +87 -0
  29. package/es/settings-app/useSettingsThemeConfig.d.ts +22 -0
  30. package/es/settings-center/AdminSettingsLayout.d.ts +2 -2
  31. package/es/settings-center/groups.d.ts +66 -0
  32. package/es/settings-center/useSettingsGroups.d.ts +36 -0
  33. package/es/settings-center/useSettingsSearch.d.ts +35 -0
  34. package/es/settings-center/utils.d.ts +24 -0
  35. package/es/theme/index.d.ts +1 -0
  36. package/es/theme/itemActive.d.ts +24 -0
  37. package/es/theme/type.d.ts +9 -0
  38. package/lib/index.js +133 -122
  39. package/package.json +7 -7
  40. package/src/BaseApplication.tsx +2 -0
  41. package/src/RouteRepository.ts +14 -0
  42. package/src/__tests__/RouteRepository.test.ts +51 -0
  43. package/src/__tests__/app.test.tsx +135 -3
  44. package/src/__tests__/authRedirect.test.ts +154 -0
  45. package/src/__tests__/browserChecker.test.ts +172 -6
  46. package/src/__tests__/mockSettingsApplication.ts +29 -0
  47. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +81 -8
  48. package/src/__tests__/plugin-manager.test.tsx +7 -6
  49. package/src/__tests__/settings-application.test.ts +164 -0
  50. package/src/__tests__/settings-auth-routes.test.ts +71 -0
  51. package/src/__tests__/settings-center.test.tsx +168 -130
  52. package/src/__tests__/settings-layout-root.test.tsx +600 -0
  53. package/src/__tests__/settings-runtime-paths.test.ts +99 -0
  54. package/src/__tests__/settings-search-shortcut.test.tsx +321 -0
  55. package/src/__tests__/settings-shell.test.tsx +261 -0
  56. package/src/acl/ACLProvider.tsx +53 -3
  57. package/src/acl/aclCheckReadiness.ts +44 -0
  58. package/src/authRedirect.ts +85 -8
  59. package/src/components/AppComponents.tsx +16 -7
  60. package/src/components/AttachmentUpload.tsx +275 -0
  61. package/src/components/index.ts +1 -0
  62. package/src/flow/actions/__tests__/validation.test.ts +48 -0
  63. package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +1 -56
  64. package/src/flow/admin-shell/admin-layout/NocoBaseLogo.tsx +77 -0
  65. package/src/flow/admin-shell/admin-layout/TopbarActionsBar.tsx +37 -14
  66. package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +104 -20
  67. package/src/flow/components/filter/VariableFilterItem.tsx +14 -4
  68. package/src/flow/components/filter/__tests__/VariableFilterItem.rightMetaTree.test.tsx +158 -0
  69. package/src/flow/internal/utils/__tests__/quickEditDataScope.test.ts +156 -0
  70. package/src/flow/internal/utils/__tests__/quickEditViewContainer.test.ts +52 -0
  71. package/src/flow/internal/utils/quickEditDataScope.tsx +177 -0
  72. package/src/flow/internal/utils/quickEditViewContainer.ts +46 -0
  73. package/src/flow/models/actions/JSItemActionModel.tsx +19 -3
  74. package/src/flow/models/actions/__tests__/JSItemActionModel.test.tsx +47 -1
  75. package/src/flow/models/blocks/form/QuickEditFormModel.tsx +45 -11
  76. package/src/flow/models/blocks/form/__tests__/QuickEditFormModel.quickEdit.test.ts +120 -0
  77. package/src/flow/models/blocks/js-block/JSBlock.tsx +5 -1
  78. package/src/flow/models/blocks/js-block/__tests__/JSBlockModel.test.tsx +23 -1
  79. package/src/flow/models/blocks/table/TableColumnModel.tsx +31 -0
  80. package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +64 -0
  81. package/src/flow/models/fields/AssociationFieldModel/CascadeSelectFieldModel.tsx +7 -1
  82. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +4 -3
  83. package/src/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.tsx +9 -0
  84. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +17 -10
  85. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableField.tsx +6 -1
  86. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableField.disabled.test.tsx +90 -0
  87. package/src/flow/models/fields/AssociationFieldModel/__tests__/RecordSelectFieldModel.quickCreatePopup.test.tsx +183 -0
  88. package/src/flow/models/fields/SelectFieldModel.tsx +5 -0
  89. package/src/flow/models/fields/__tests__/SelectFieldModel.test.tsx +29 -1
  90. package/src/flow/models/topbar/TopbarActionModel.tsx +52 -180
  91. package/src/index.ts +11 -1
  92. package/src/nocobase-buildin-plugin/currentUserAuthStatus.ts +58 -0
  93. package/src/nocobase-buildin-plugin/index.tsx +112 -71
  94. package/src/settings-app/SettingsApplication.ts +31 -0
  95. package/src/settings-app/SettingsBrand.tsx +108 -0
  96. package/src/settings-app/SettingsBuildInPlugin.ts +10 -0
  97. package/src/settings-app/SettingsDocumentRedirect.tsx +26 -0
  98. package/src/settings-app/SettingsGroupNav.tsx +124 -0
  99. package/src/settings-app/SettingsPluginSettingsManager.ts +38 -0
  100. package/src/settings-app/SettingsRouterManager.ts +92 -0
  101. package/src/settings-app/SettingsSearch.tsx +343 -0
  102. package/src/settings-app/SettingsShell.tsx +184 -0
  103. package/src/settings-app/isSettingsApp.ts +21 -0
  104. package/src/settings-app/runtimePaths.ts +103 -0
  105. package/src/settings-app/settingsDocumentPath.ts +66 -0
  106. package/src/settings-app/settingsTheme.ts +369 -0
  107. package/src/settings-app/useSettingsThemeConfig.ts +100 -0
  108. package/src/settings-center/AdminSettingsLayout.tsx +154 -169
  109. package/src/settings-center/SystemSettingsPage.tsx +4 -170
  110. package/src/settings-center/__tests__/groups.test.ts +25 -0
  111. package/src/settings-center/groups.ts +108 -0
  112. package/src/settings-center/useSettingsGroups.ts +105 -0
  113. package/src/settings-center/useSettingsSearch.ts +173 -0
  114. package/src/settings-center/utils.tsx +50 -1
  115. package/src/theme/index.tsx +1 -0
  116. package/src/theme/itemActive.ts +31 -0
  117. package/src/theme/type.ts +10 -0
@@ -8,13 +8,59 @@
8
8
  */
9
9
 
10
10
  import React from 'react';
11
- import { render, screen, waitFor } from '@nocobase/test/client';
11
+ import { act, render, screen, waitFor } from '@nocobase/test/client';
12
12
  import { App, ConfigProvider } from 'antd';
13
13
  import { describe, expect, it } from 'vitest';
14
14
  import { FlowEngine, FlowEngineProvider, FlowModelRenderer } from '@nocobase/flow-engine';
15
15
  import { JSItemActionModel } from '../JSItemActionModel';
16
16
 
17
17
  describe('JSItemActionModel', () => {
18
+ it('keeps empty items reachable in configuration mode without replacing rendered content', async () => {
19
+ const engine = new FlowEngine();
20
+ engine.registerModels({ JSItemActionModel });
21
+ const model = engine.createModel<JSItemActionModel>({ use: 'JSItemActionModel' });
22
+ await engine.flowSettings.disable();
23
+
24
+ render(model.render());
25
+ const host = model.context.ref.current;
26
+ expect(host).toBeInstanceOf(HTMLDivElement);
27
+ expect(host.style.minWidth).toBe('');
28
+ expect(host.textContent).toBe('');
29
+
30
+ await act(async () => {
31
+ await engine.flowSettings.enable();
32
+ });
33
+ await waitFor(() => expect(host.style.minWidth).toBe('40px'));
34
+
35
+ const content = document.createElement('button');
36
+ content.textContent = 'Custom action';
37
+ host.appendChild(content);
38
+
39
+ await act(async () => {
40
+ await engine.flowSettings.disable();
41
+ });
42
+ await waitFor(() => expect(host.style.minWidth).toBe(''));
43
+ expect(model.context.ref.current).toBe(host);
44
+ expect(host.firstElementChild).toBe(content);
45
+ });
46
+
47
+ it('keeps hidden empty items configurable and removes them outside configuration mode', async () => {
48
+ const engine = new FlowEngine();
49
+ engine.registerModels({ JSItemActionModel });
50
+ const model = engine.createModel<JSItemActionModel>({ use: 'JSItemActionModel' });
51
+ model.setHidden(true);
52
+ await engine.flowSettings.enable();
53
+
54
+ const { container } = render(model.render());
55
+ expect(model.context.ref.current.style.minWidth).toBe('40px');
56
+ expect(model.context.ref.current.style.opacity).toBe('0.3');
57
+
58
+ await act(async () => {
59
+ await engine.flowSettings.disable();
60
+ });
61
+ await waitFor(() => expect(container).toBeEmptyDOMElement());
62
+ });
63
+
18
64
  it('renders collection-level custom content', async () => {
19
65
  const engine = new FlowEngine();
20
66
  engine.registerModels({ JSItemActionModel });
@@ -23,6 +23,7 @@ import {
23
23
  } from '@nocobase/flow-engine';
24
24
  import { Button, Form, Skeleton, Space } from 'antd';
25
25
  import React from 'react';
26
+ import { findDataScopeStep, getQuickEditDataScopeFilter } from '../../../internal/utils/quickEditDataScope';
26
27
  import { FieldModel } from '../../base/FieldModel';
27
28
  import { FormComponent } from './FormBlockModel';
28
29
  import { FormItemModel } from './FormItemModel';
@@ -36,6 +37,14 @@ const QUICK_EDIT_MOBILE_CONTENT_PADDING = '8px var(--nb-mobile-page-tabs-content
36
37
  const QUICK_EDIT_MOBILE_ACTIONS_PADDING =
37
38
  '8px var(--nb-mobile-page-tabs-content-padding, 12px) calc(80px + env(safe-area-inset-bottom, 0px))';
38
39
  const QUICK_EDIT_MOBILE_MEDIA_QUERY = '(max-width: 768px)';
40
+ // Dropdown-style editors open upwards inside the desktop popover so the option list does not cover the Submit and
41
+ // Cancel buttons rendered right below the field. antd still flips the list downwards when there is no room above.
42
+ const QUICK_EDIT_DROPDOWN_UP_MODELS = [
43
+ 'SelectFieldModel',
44
+ 'RecordSelectFieldModel',
45
+ 'CascadeSelectFieldModel',
46
+ 'CascadeSelectListFieldModel',
47
+ ];
39
48
 
40
49
  type QuickEditViewBeforeClosePayload = {
41
50
  result?: unknown;
@@ -159,6 +168,7 @@ export class QuickEditFormModel extends FlowModel {
159
168
  __onSubmitSuccess;
160
169
  _fieldProps: any;
161
170
  _onOk: any;
171
+ _dataScopeFilter: Record<string, any> | undefined;
162
172
 
163
173
  get form() {
164
174
  return this.context.form;
@@ -212,6 +222,7 @@ export class QuickEditFormModel extends FlowModel {
212
222
  },
213
223
  sourceFieldModel ? { delegate: sourceFieldModel.context } : undefined,
214
224
  ) as QuickEditFormModel;
225
+ model._dataScopeFilter = getQuickEditDataScopeFilter(sourceFieldModel);
215
226
 
216
227
  const bodyStyles = {
217
228
  maxHeight: QUICK_EDIT_POPOVER_MAX_HEIGHT,
@@ -410,6 +421,13 @@ QuickEditFormModel.registerFlow({
410
421
  resource.setDataSourceKey(dataSourceKey);
411
422
  resource.setResourceName(collectionName);
412
423
  ctx.model.resource = resource;
424
+ // Seed the edited row before dispatching the field model's flows. An association data scope resolves
425
+ // `ctx.record` while the field renders, and so does the foreign key filter that one-to-one and one-to-many
426
+ // editors build, so the record has to be in place first.
427
+ if (ctx.inputArgs.filterByTk || ctx.inputArgs.record) {
428
+ resource.setFilterByTk(ctx.inputArgs.filterByTk);
429
+ resource.setData(ctx.inputArgs.record);
430
+ }
413
431
  const collectionField = ctx.model.collection.getField(fieldPath) as CollectionField;
414
432
  if (collectionField) {
415
433
  const binding = FormItemModel.getDefaultBindingByField(ctx, collectionField);
@@ -417,24 +435,42 @@ QuickEditFormModel.registerFlow({
417
435
  return;
418
436
  }
419
437
  const use = binding.modelName;
438
+ const stepParams: Record<string, Record<string, Record<string, unknown>>> = {
439
+ fieldSettings: {
440
+ init: {
441
+ dataSourceKey,
442
+ collectionName,
443
+ fieldPath,
444
+ },
445
+ },
446
+ };
447
+ const dataScopeFilter = ctx.model._dataScopeFilter;
448
+ if (dataScopeFilter) {
449
+ const dataScopeStep = findDataScopeStep(ctx.engine.getModelClass(use));
450
+ if (dataScopeStep) {
451
+ stepParams[dataScopeStep.flowKey] = {
452
+ ...stepParams[dataScopeStep.flowKey],
453
+ [dataScopeStep.stepKey]: { filter: dataScopeFilter },
454
+ };
455
+ }
456
+ }
420
457
  const fieldModel = ctx.model.addSubModel<FieldModel>('fields', {
421
458
  use,
422
459
  props:
423
460
  typeof binding.defaultProps === 'function'
424
461
  ? binding.defaultProps(ctx, collectionField)
425
462
  : binding.defaultProps,
426
- stepParams: {
427
- fieldSettings: {
428
- init: {
429
- dataSourceKey,
430
- collectionName,
431
- fieldPath,
432
- },
433
- },
434
- },
463
+ stepParams,
435
464
  });
436
465
  fieldModel.setProps(getQuickEditFieldProps(collectionField, ctx.model._fieldProps));
437
466
  fieldModel.setProps({ sourceFieldModelUid: ctx.inputArgs.sourceFieldModelUid });
467
+ if (
468
+ !ctx.model.context.isMobileLayout &&
469
+ QUICK_EDIT_DROPDOWN_UP_MODELS.includes(use) &&
470
+ fieldModel.props.placement == null
471
+ ) {
472
+ fieldModel.setProps({ placement: 'topLeft' });
473
+ }
438
474
  ctx.model.context.defineProperty('collectionField', {
439
475
  get: () => collectionField,
440
476
  });
@@ -442,8 +478,6 @@ QuickEditFormModel.registerFlow({
442
478
  await fieldModel.dispatchEvent('beforeRender');
443
479
  }
444
480
  if (ctx.inputArgs.filterByTk || ctx.inputArgs.record) {
445
- resource.setFilterByTk(ctx.inputArgs.filterByTk);
446
- resource.setData(ctx.inputArgs.record);
447
481
  ctx.model.form?.setFieldsValue(resource.getData());
448
482
  }
449
483
  },
@@ -9,7 +9,11 @@
9
9
 
10
10
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
11
11
  import { FlowEngine, FlowModel, SingleRecordResource } from '@nocobase/flow-engine';
12
+ import '../../../index';
12
13
  import { QuickEditFormModel } from '../QuickEditFormModel';
14
+ import { dataScope } from '../../../../actions/dataScope';
15
+ import { RecordSelectFieldModel } from '../../../fields/AssociationFieldModel/RecordSelectFieldModel';
16
+ import { SelectFieldModel } from '../../../fields/SelectFieldModel';
13
17
 
14
18
  describe('QuickEditFormModel - quick edit save triggers API (regression)', () => {
15
19
  let engine: FlowEngine;
@@ -55,6 +59,54 @@ describe('QuickEditFormModel - quick edit save triggers API (regression)', () =>
55
59
  });
56
60
  };
57
61
 
62
+ const addMemberCollections = () => {
63
+ const ds = engine.context.dataSourceManager.getDataSource('main');
64
+ ds.addCollection({
65
+ name: 'departments',
66
+ filterTargetKey: 'id',
67
+ titleField: 'name',
68
+ fields: [
69
+ { name: 'id', type: 'integer', interface: 'number' },
70
+ { name: 'name', type: 'string', interface: 'input', uiSchema: { title: 'Name' } },
71
+ { name: 'level', type: 'string', interface: 'input', uiSchema: { title: 'Level' } },
72
+ ],
73
+ });
74
+ ds.addCollection({
75
+ name: 'members',
76
+ filterTargetKey: 'id',
77
+ fields: [
78
+ { name: 'id', type: 'integer', interface: 'number' },
79
+ { name: 'level', type: 'string', interface: 'input', uiSchema: { title: 'Level' } },
80
+ {
81
+ name: 'department',
82
+ type: 'belongsTo',
83
+ interface: 'm2o',
84
+ target: 'departments',
85
+ targetKey: 'id',
86
+ uiSchema: { title: 'Department' },
87
+ },
88
+ ],
89
+ });
90
+ };
91
+
92
+ const openQuickEditForAssociation = async (dataScopeFilter?: Record<string, unknown>) => {
93
+ engine.registerModels({ QuickEditFormModel, RecordSelectFieldModel });
94
+ engine.registerActions({ dataScope });
95
+ addMemberCollections();
96
+ const model = engine.createModel<QuickEditFormModel>({
97
+ use: QuickEditFormModel,
98
+ uid: `qe-association-${dataScopeFilter ? 'scoped' : 'plain'}`,
99
+ stepParams: {
100
+ quickEditFormSettings: {
101
+ init: { dataSourceKey: 'main', collectionName: 'members', fieldPath: 'department' },
102
+ },
103
+ },
104
+ });
105
+ model._dataScopeFilter = dataScopeFilter;
106
+ await model.applyFlow('quickEditFormSettings', { filterByTk: 7, record: { id: 7, level: 'senior' } });
107
+ return model;
108
+ };
109
+
58
110
  it('uses source field context when opening quick edit', async () => {
59
111
  engine.registerModels({ QuickEditFormModel });
60
112
  engine.context.defineProperty('pageActive', { value: { value: false } });
@@ -414,4 +466,72 @@ describe('QuickEditFormModel - quick edit save triggers API (regression)', () =>
414
466
 
415
467
  expect(api.request).toHaveBeenCalledTimes(1);
416
468
  });
469
+
470
+ it('opens dropdown editors upwards in the desktop popover so they do not cover the actions', async () => {
471
+ engine.registerModels({ QuickEditFormModel, SelectFieldModel });
472
+ const ds = engine.context.dataSourceManager.getDataSource('main');
473
+ ds.addCollection({
474
+ name: 'users',
475
+ fields: [
476
+ {
477
+ name: 'tags',
478
+ type: 'array',
479
+ interface: 'multipleSelect',
480
+ uiSchema: { title: 'Tags', enum: [{ label: 'A', value: 'a' }] },
481
+ },
482
+ ],
483
+ });
484
+
485
+ const desktopModel = engine.createModel<QuickEditFormModel>({
486
+ use: QuickEditFormModel,
487
+ uid: 'qe-desktop',
488
+ stepParams: {
489
+ quickEditFormSettings: {
490
+ init: { dataSourceKey: 'main', collectionName: 'users', fieldPath: 'tags' },
491
+ },
492
+ },
493
+ });
494
+ await desktopModel.applyFlow('quickEditFormSettings', { record: {} });
495
+ const desktopField = desktopModel.subModels.fields[0];
496
+ expect(desktopField.use).toBe('SelectFieldModel');
497
+ expect(desktopField.props.placement).toBe('topLeft');
498
+
499
+ const mobileModel = engine.createModel<QuickEditFormModel>({
500
+ use: QuickEditFormModel,
501
+ uid: 'qe-mobile',
502
+ stepParams: {
503
+ quickEditFormSettings: {
504
+ init: { dataSourceKey: 'main', collectionName: 'users', fieldPath: 'tags' },
505
+ },
506
+ },
507
+ });
508
+ mobileModel.context.defineProperty('isMobileLayout', { value: true });
509
+ await mobileModel.applyFlow('quickEditFormSettings', { record: {} });
510
+ expect(mobileModel.subModels.fields[0].props.placement).toBeUndefined();
511
+ });
512
+
513
+ it('hands the column data scope to the association editor built for the popover', async () => {
514
+ const filter = { logic: '$and', items: [{ path: 'level', operator: 'eq', value: '{{ctx.record.level}}' }] };
515
+
516
+ const model = await openQuickEditForAssociation(filter);
517
+
518
+ const fieldModel = model.subModels.fields[0];
519
+ expect(fieldModel.use).toBe('RecordSelectFieldModel');
520
+ expect(fieldModel.getStepParams('selectSettings', 'dataScope')).toEqual({ filter });
521
+ });
522
+
523
+ it('leaves the association editor unscoped when the column has no data scope', async () => {
524
+ const model = await openQuickEditForAssociation();
525
+
526
+ expect(model.subModels.fields[0].getStepParams('selectSettings', 'dataScope')).toBeUndefined();
527
+ });
528
+
529
+ it('seeds the edited row before the editor flows run so record variables resolve', async () => {
530
+ const filter = { logic: '$and', items: [{ path: 'level', operator: 'eq', value: '{{ctx.record.level}}' }] };
531
+
532
+ const model = await openQuickEditForAssociation(filter);
533
+
534
+ expect(model.resource.getData()).toMatchObject({ id: 7, level: 'senior' });
535
+ expect(JSON.stringify(model.subModels.fields[0].resource.getFilter() ?? null)).toContain('senior');
536
+ });
417
537
  });
@@ -152,6 +152,7 @@ const JSBlockPlainHost = ({
152
152
  afterContent,
153
153
  contentRef,
154
154
  marginBlock,
155
+ minHeight,
155
156
  ...rest
156
157
  }: React.HTMLAttributes<HTMLDivElement> & {
157
158
  uid: string;
@@ -161,6 +162,7 @@ const JSBlockPlainHost = ({
161
162
  afterContent?: React.ReactNode;
162
163
  contentRef: React.RefObject<HTMLDivElement>;
163
164
  marginBlock: number;
165
+ minHeight: number;
164
166
  }) => {
165
167
  const hostRef = React.useRef<HTMLDivElement | null>(null);
166
168
  const resolvedHeight = usePlainHostHeight({ height, heightMode, hostRef, marginBlock });
@@ -175,7 +177,7 @@ const JSBlockPlainHost = ({
175
177
  display: 'flex',
176
178
  flexDirection: 'column',
177
179
  height: resolvedHeight ?? undefined,
178
- minHeight: 0,
180
+ minHeight,
179
181
  overflow: 'auto',
180
182
  ...(style || {}),
181
183
  }}
@@ -228,6 +230,8 @@ export class JSBlockModel extends BlockModel {
228
230
  afterContent={afterContent}
229
231
  contentRef={this.context.ref}
230
232
  marginBlock={this.context.themeToken?.marginBlock ?? 0}
233
+ // Keep empty blocks reachable by the settings overlay while the UI editor is active.
234
+ minHeight={this.context.flowSettingsEnabled ? this.context.themeToken?.controlHeightLG ?? 40 : 0}
231
235
  />
232
236
  );
233
237
  }
@@ -9,7 +9,7 @@
9
9
 
10
10
  import React from 'react';
11
11
  import { App, ConfigProvider } from 'antd';
12
- import { render } from '@nocobase/test/client';
12
+ import { act, render, waitFor } from '@nocobase/test/client';
13
13
  import { describe, expect, it, vi } from 'vitest';
14
14
  import { FlowEngine, FlowEngineProvider } from '@nocobase/flow-engine';
15
15
  import { JSBlockModel } from '../JSBlock';
@@ -78,6 +78,28 @@ describe('JSBlockModel', () => {
78
78
  expect(model.context.ref.current).toBe(host?.firstElementChild);
79
79
  });
80
80
 
81
+ it('keeps an empty cardless block reachable only while the UI editor is active', async () => {
82
+ const { engine, model } = createJSBlock('js-block-empty-without-card', false, { style: undefined });
83
+ await engine.flowSettings.disable();
84
+ const { container } = renderBlock(engine, model);
85
+ const host = container.querySelector('#model-js-block-empty-without-card') as HTMLElement;
86
+ const content = model.context.ref.current;
87
+
88
+ expect(host.style.minHeight).toBe('0');
89
+ await act(async () => {
90
+ await engine.flowSettings.enable();
91
+ });
92
+ await waitFor(() => expect(host.style.minHeight).toBe('40px'));
93
+ expect(model.context.ref.current).toBe(content);
94
+ expect(container.querySelector('.ant-card')).toBeNull();
95
+
96
+ await act(async () => {
97
+ await engine.flowSettings.disable();
98
+ });
99
+ await waitFor(() => expect(host.style.minHeight).toBe('0'));
100
+ expect(model.context.ref.current).toBe(content);
101
+ });
102
+
81
103
  it('keeps cardless specified-height content scrollable inside the plain host', () => {
82
104
  const { engine, model } = createJSBlock('js-block-without-card-height', false, {
83
105
  heightMode: 'specifyValue',
@@ -37,6 +37,7 @@ import { getRowKey } from './utils';
37
37
  import { getSavedAssociationTitleField, getTableColumnSortField } from './sortUtils';
38
38
  import { getFieldBindingUse, rebuildFieldSubModel } from '../../../internal/utils/rebuildFieldSubModel';
39
39
  import { getSavedDateTimeFormatParams, resolveDateTimeDisplayProps } from '../../../utils/dateTimeDisplayProps';
40
+ import { QuickEditDataScopeInput, resolveQuickEditDataScopeStep } from '../../../internal/utils/quickEditDataScope';
40
41
 
41
42
  export function FieldDeletePlaceholder(props: any) {
42
43
  const { t } = useTranslation();
@@ -542,6 +543,36 @@ TableColumnModel.registerFlow({
542
543
  ctx.model.setProps('editable', ctx.model.associationPathName ? false : params.editable);
543
544
  },
544
545
  },
546
+ quickEditDataScope: {
547
+ title: tExpr('Data scope'),
548
+ uiMode: {
549
+ type: 'dialog',
550
+ props: {
551
+ width: 800,
552
+ },
553
+ },
554
+ uiSchema: {
555
+ filter: {
556
+ type: 'object',
557
+ 'x-decorator': 'FormItem',
558
+ 'x-component': QuickEditDataScopeInput,
559
+ },
560
+ },
561
+ useRawParams: true,
562
+ defaultParams: {
563
+ filter: { logic: '$and', items: [] },
564
+ },
565
+ hideInSettings(ctx) {
566
+ if (ctx.model.associationPathName || !ctx.model.props.editable) {
567
+ return true;
568
+ }
569
+ return !resolveQuickEditDataScopeStep(ctx, ctx.collectionField);
570
+ },
571
+ handler() {
572
+ // Settings-only step. The filter is kept raw on the column and handed to the editor model that
573
+ // QuickEditFormModel builds when the popover opens, so there is nothing to apply to the column itself.
574
+ },
575
+ },
545
576
  model: {
546
577
  title: tExpr('Field component'),
547
578
  use: 'displayFieldComponent',
@@ -9,8 +9,36 @@
9
9
 
10
10
  import { FlowEngine } from '@nocobase/flow-engine';
11
11
  import { describe, expect, it, vi } from 'vitest';
12
+ // Prime the model module graph so the editable field bindings used by the quick edit data scope setting are registered.
13
+ import '../../../index';
14
+ import { RecordSelectFieldModel } from '../../../fields/AssociationFieldModel/RecordSelectFieldModel';
12
15
  import { TableColumnModel } from '../TableColumnModel';
13
16
 
17
+ const createQuickEditDataScopeCtx = (
18
+ overrides: { associationPathName?: string; editable?: boolean; isAssociation?: boolean } = {},
19
+ ) => {
20
+ const engine = new FlowEngine();
21
+ engine.registerModels({ RecordSelectFieldModel });
22
+ const { associationPathName, editable = true, isAssociation = true } = overrides;
23
+ return {
24
+ engine,
25
+ model: {
26
+ associationPathName,
27
+ props: { editable },
28
+ },
29
+ collectionField: {
30
+ interface: isAssociation ? 'm2o' : 'input',
31
+ isAssociationField: () => isAssociation,
32
+ targetCollection: isAssociation ? { template: 'general' } : undefined,
33
+ },
34
+ };
35
+ };
36
+
37
+ const getQuickEditDataScopeStep = (engine: FlowEngine) => {
38
+ const model = new TableColumnModel({ uid: 'table-column-quick-edit-data-scope', flowEngine: engine } as any);
39
+ return model.getFlow('tableColumnSettings')?.steps?.quickEditDataScope as any;
40
+ };
41
+
14
42
  describe('TableColumnModel sorter settings', () => {
15
43
  it('clamps custom column width to the minimum value', () => {
16
44
  const engine = new FlowEngine();
@@ -30,6 +58,42 @@ describe('TableColumnModel sorter settings', () => {
30
58
  expect(setProps).toHaveBeenCalledWith('width', 10);
31
59
  });
32
60
 
61
+ it('offers the quick edit data scope on an editable association column', async () => {
62
+ const engine = new FlowEngine();
63
+ const step = getQuickEditDataScopeStep(engine);
64
+
65
+ const hidden = await step.hideInSettings(createQuickEditDataScopeCtx());
66
+
67
+ expect(hidden).toBe(false);
68
+ });
69
+
70
+ it('hides the quick edit data scope when quick edit is off', async () => {
71
+ const engine = new FlowEngine();
72
+ const step = getQuickEditDataScopeStep(engine);
73
+
74
+ const hidden = await step.hideInSettings(createQuickEditDataScopeCtx({ editable: false }));
75
+
76
+ expect(hidden).toBe(true);
77
+ });
78
+
79
+ it('hides the quick edit data scope for non-association columns', async () => {
80
+ const engine = new FlowEngine();
81
+ const step = getQuickEditDataScopeStep(engine);
82
+
83
+ const hidden = await step.hideInSettings(createQuickEditDataScopeCtx({ isAssociation: false }));
84
+
85
+ expect(hidden).toBe(true);
86
+ });
87
+
88
+ it('hides the quick edit data scope for relation path columns', async () => {
89
+ const engine = new FlowEngine();
90
+ const step = getQuickEditDataScopeStep(engine);
91
+
92
+ const hidden = await step.hideInSettings(createQuickEditDataScopeCtx({ associationPathName: 'department' }));
93
+
94
+ expect(hidden).toBe(true);
95
+ });
96
+
33
97
  it('hides quick edit setting for relation path columns added from association groups', async () => {
34
98
  const engine = new FlowEngine();
35
99
  const model = new TableColumnModel({ uid: 'table-column-relation-path-quick-edit', flowEngine: engine } as any);
@@ -25,6 +25,7 @@ import { arrayMove, SortableContext, verticalListSortingStrategy, useSortable }
25
25
  import { CSS } from '@dnd-kit/utilities';
26
26
  import { AssociationFieldModel } from './AssociationFieldModel';
27
27
  import { transformNestedData } from '../ClickableFieldModel';
28
+ import { syncQuickEditPreventClose } from '../../../internal/utils/quickEditViewContainer';
28
29
 
29
30
  type CascadeHydrateStatus = 'pending' | 'done';
30
31
 
@@ -373,7 +374,12 @@ export class CascadeSelectInnerFieldModel extends AssociationFieldModel {
373
374
  this.setProps({ onPopupScroll: fn });
374
375
  }
375
376
  set onDropdownVisibleChange(fn) {
376
- this.setProps({ onDropdownVisibleChange: fn });
377
+ this.setProps({
378
+ onDropdownVisibleChange: (visible: boolean) => {
379
+ syncQuickEditPreventClose(this, visible);
380
+ fn(visible);
381
+ },
382
+ });
377
383
  }
378
384
  set onSearch(fn) {
379
385
  this.setProps({ onSearch: fn });
@@ -19,7 +19,7 @@ import {
19
19
  createCurrentRecordMetaFactory,
20
20
  useFlowModel,
21
21
  } from '@nocobase/flow-engine';
22
- import { Table, Button, Space } from 'antd';
22
+ import { Table, Button, Space, theme } from 'antd';
23
23
  import { uid } from '@formily/shared';
24
24
  import classNames from 'classnames';
25
25
  import { DragEndEvent } from '@dnd-kit/core';
@@ -190,6 +190,7 @@ const AddFieldColumn = ({ model }) => {
190
190
  };
191
191
 
192
192
  const DisplayTable = (props) => {
193
+ const { token } = theme.useToken();
193
194
  const {
194
195
  pageSize,
195
196
  value,
@@ -269,7 +270,7 @@ const DisplayTable = (props) => {
269
270
  },
270
271
  },
271
272
  ...baseColumns,
272
- { key: 'empty' },
273
+ { key: 'empty', render: () => <div style={{ minHeight: token.controlHeight }} /> },
273
274
  ].filter(Boolean),
274
275
  ) as any[];
275
276
 
@@ -283,7 +284,7 @@ const DisplayTable = (props) => {
283
284
  }
284
285
 
285
286
  return cols;
286
- }, [baseColumns, enableIndexColumn, currentPage, currentPageSize, isConfigMode, model]);
287
+ }, [baseColumns, enableIndexColumn, currentPage, currentPageSize, isConfigMode, model, token.controlHeight]);
287
288
  return (
288
289
  <Table
289
290
  tableLayout="fixed"
@@ -41,6 +41,7 @@ import { BlockSceneEnum } from '../../base/BlockModel';
41
41
  import { ActionWithoutPermission } from '../../base/ActionModel';
42
42
  import { EditFormModel } from '../../blocks';
43
43
  import { hasAncestorModel } from './recordSelectSettingsUtils';
44
+ import { syncQuickEditPreventClose } from '../../../internal/utils/quickEditViewContainer';
44
45
 
45
46
  function RemoteModelRenderer({ options }) {
46
47
  const ctx = useFlowViewContext();
@@ -65,6 +66,11 @@ function RemoteModelRenderer({ options }) {
65
66
 
66
67
  export function CreateContent({ model, toOne = false }) {
67
68
  const ctx = useFlowContext();
69
+ // Quick-create popups isolate business context but must retain the field's configuration mode.
70
+ ctx.defineProperty('flowSettingsEnabled', {
71
+ get: () => model.context.flowSettingsEnabled,
72
+ cache: false,
73
+ });
68
74
  const { Header, type } = ctx.view;
69
75
  model._closeView = ctx.view.close;
70
76
  return (
@@ -308,6 +314,7 @@ const LazySelect = (props: Readonly<LazySelectProps>) => {
308
314
  }
309
315
  setKeepOpenRuntime({ preventCloseOnSelect: false, open });
310
316
  }
317
+ syncQuickEditPreventClose(model, open);
311
318
  others.onDropdownVisibleChange?.(open);
312
319
  }}
313
320
  optionRender={({ data }) => {
@@ -874,6 +881,8 @@ RecordSelectFieldModel.registerFlow({
874
881
  dataSourceKey: ctx.collection.dataSourceKey,
875
882
  collectionName: ctx.collectionField?.target,
876
883
  ...(associationName && sourceId != null ? { associationName, sourceId } : {}),
884
+ // 上级记录暂无主键(如审批配置态表单)时不能传 associationName,否则弹窗内新建的区块会绑定到关联资源;改传 sourceAssociationName,仅用于展示“当前弹窗上级记录”变量
885
+ ...(associationName && sourceId == null ? { sourceAssociationName: associationName } : {}),
877
886
  collectionField: ctx.collectionField,
878
887
  openerUids,
879
888
  onChange: (e) => {