@nocobase/client-v2 2.1.0-beta.34 → 2.1.0-beta.36

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 (76) hide show
  1. package/es/BaseApplication.d.ts +7 -1
  2. package/es/PluginManager.d.ts +2 -0
  3. package/es/components/PoweredBy.d.ts +18 -0
  4. package/es/components/SwitchLanguage.d.ts +11 -0
  5. package/es/components/form/DialogFormLayout.d.ts +75 -0
  6. package/es/components/form/DrawerFormLayout.d.ts +11 -11
  7. package/es/components/form/PasswordInput.d.ts +40 -0
  8. package/es/components/form/RemoteSelect.d.ts +79 -0
  9. package/es/components/form/index.d.ts +3 -0
  10. package/es/components/form/table/styles.d.ts +10 -0
  11. package/es/components/index.d.ts +2 -0
  12. package/es/flow/models/base/ActionModelCore.d.ts +6 -0
  13. package/es/flow/models/base/GridModel.d.ts +2 -0
  14. package/es/flow/models/blocks/filter-form/FilterFormBlockModel.d.ts +9 -1
  15. package/es/flow/utils/dataScopeFormValueClear.d.ts +14 -0
  16. package/es/flow-compat/passwordUtils.d.ts +1 -1
  17. package/es/hooks/index.d.ts +2 -0
  18. package/es/hooks/useCurrentAppInfo.d.ts +9 -0
  19. package/es/index.mjs +117 -105
  20. package/es/json-logic/globalOperators.d.ts +11 -0
  21. package/es/nocobase-buildin-plugin/index.d.ts +25 -0
  22. package/es/utils/appVersionHTML.d.ts +10 -0
  23. package/es/utils/globalDeps.d.ts +7 -0
  24. package/es/utils/index.d.ts +1 -0
  25. package/es/utils/remotePlugins.d.ts +4 -1
  26. package/lib/index.js +120 -108
  27. package/package.json +7 -6
  28. package/src/BaseApplication.tsx +11 -3
  29. package/src/PluginManager.ts +2 -0
  30. package/src/PluginSettingsManager.ts +2 -1
  31. package/src/__tests__/PluginSettingsManager.test.ts +19 -0
  32. package/src/__tests__/PoweredBy.test.tsx +130 -0
  33. package/src/__tests__/app.test.tsx +39 -0
  34. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +39 -72
  35. package/src/__tests__/remotePlugins.test.ts +203 -0
  36. package/src/__tests__/useCurrentRoles.test.tsx +100 -0
  37. package/src/components/PoweredBy.tsx +71 -0
  38. package/src/components/README.md +314 -0
  39. package/src/components/README.zh-CN.md +312 -0
  40. package/src/components/SwitchLanguage.tsx +48 -0
  41. package/src/components/form/DialogFormLayout.tsx +111 -0
  42. package/src/components/form/DrawerFormLayout.tsx +13 -32
  43. package/src/components/form/PasswordInput.tsx +211 -0
  44. package/src/components/form/RemoteSelect.tsx +137 -0
  45. package/src/components/form/index.tsx +3 -0
  46. package/src/components/form/table/Table.tsx +2 -1
  47. package/src/components/form/table/styles.ts +19 -0
  48. package/src/components/index.ts +2 -0
  49. package/src/css-variable/CSSVariableProvider.tsx +10 -1
  50. package/src/flow/actions/__tests__/dataScopeFormValueClear.test.ts +96 -0
  51. package/src/flow/actions/dataScope.tsx +3 -0
  52. package/src/flow/actions/filterFormDefaultValues.tsx +1 -2
  53. package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +1 -4
  54. package/src/flow/admin-shell/admin-layout/HelpLite.tsx +7 -33
  55. package/src/flow/components/BlockItemCard.tsx +2 -2
  56. package/src/flow/models/base/ActionModel.tsx +8 -7
  57. package/src/flow/models/base/ActionModelCore.tsx +15 -7
  58. package/src/flow/models/base/GridModel.tsx +93 -36
  59. package/src/flow/models/base/__tests__/GridModel.visibleLayout.test.ts +83 -11
  60. package/src/flow/models/blocks/details/DetailsItemModel.tsx +2 -0
  61. package/src/flow/models/blocks/filter-form/FilterFormBlockModel.tsx +329 -5
  62. package/src/flow/models/blocks/filter-form/__tests__/defaultValues.wiring.test.ts +337 -0
  63. package/src/flow/models/blocks/form/FormItemModel.tsx +5 -3
  64. package/src/flow/models/blocks/form/__tests__/FormItemModel.defineChildren.test.ts +108 -0
  65. package/src/flow/models/blocks/table/TableActionsColumnModel.tsx +5 -0
  66. package/src/flow/models/blocks/table/TableColumnModel.tsx +2 -0
  67. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +2 -0
  68. package/src/flow/utils/dataScopeFormValueClear.ts +278 -0
  69. package/src/hooks/index.ts +2 -0
  70. package/src/hooks/useCurrentAppInfo.ts +36 -0
  71. package/src/json-logic/globalOperators.js +731 -0
  72. package/src/nocobase-buildin-plugin/index.tsx +70 -16
  73. package/src/utils/appVersionHTML.ts +28 -0
  74. package/src/utils/globalDeps.ts +47 -31
  75. package/src/utils/index.tsx +2 -0
  76. package/src/utils/remotePlugins.ts +119 -13
@@ -0,0 +1,96 @@
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 { EventEmitter } from 'events';
12
+ import { ensureFormValueDrivenDataScopeClear } from '../../utils/dataScopeFormValueClear';
13
+
14
+ describe('ensureFormValueDrivenDataScopeClear', () => {
15
+ it('clears field value when referenced formValues dependency changes', () => {
16
+ const emitter = new EventEmitter();
17
+ const formBlock = {
18
+ uid: 'form-1',
19
+ disposed: false,
20
+ emitter,
21
+ context: { form: {} },
22
+ };
23
+
24
+ const onChange = vi.fn();
25
+ const model: any = {
26
+ disposed: false,
27
+ props: {
28
+ value: { id: 1 },
29
+ onChange,
30
+ },
31
+ context: {
32
+ blockModel: formBlock,
33
+ },
34
+ };
35
+
36
+ const ctx: any = {
37
+ model,
38
+ flowKey: 'selectSettings',
39
+ };
40
+
41
+ const filter = {
42
+ logic: '$and',
43
+ items: [{ path: 'schoolId', operator: '$eq', value: '{{ ctx.formValues.school.id }}' }],
44
+ };
45
+
46
+ ensureFormValueDrivenDataScopeClear(ctx, filter);
47
+
48
+ emitter.emit('formValuesChange', {
49
+ changedValues: { school: { id: 2 } },
50
+ allValues: { school: { id: 2 }, class: { id: 1 } },
51
+ });
52
+
53
+ expect(onChange).toHaveBeenCalledWith(null);
54
+ });
55
+
56
+ it('does not clear when dependency did not change', () => {
57
+ const emitter = new EventEmitter();
58
+ const formBlock = {
59
+ uid: 'form-1',
60
+ disposed: false,
61
+ emitter,
62
+ context: { form: {} },
63
+ };
64
+
65
+ const onChange = vi.fn();
66
+ const model: any = {
67
+ disposed: false,
68
+ props: {
69
+ value: { id: 1 },
70
+ onChange,
71
+ },
72
+ context: {
73
+ blockModel: formBlock,
74
+ },
75
+ };
76
+
77
+ const ctx: any = {
78
+ model,
79
+ flowKey: 'selectSettings',
80
+ };
81
+
82
+ const filter = {
83
+ logic: '$and',
84
+ items: [{ path: 'schoolId', operator: '$eq', value: '{{ ctx.formValues.school.id }}' }],
85
+ };
86
+
87
+ ensureFormValueDrivenDataScopeClear(ctx, filter);
88
+
89
+ emitter.emit('formValuesChange', {
90
+ changedValues: { class: null },
91
+ allValues: { school: { id: 2 }, class: null },
92
+ });
93
+
94
+ expect(onChange).not.toHaveBeenCalled();
95
+ });
96
+ });
@@ -13,6 +13,7 @@ import React from 'react';
13
13
  import { FilterGroup, VariableFilterItem } from '../components/filter';
14
14
  import { FieldModel } from '../models/base/FieldModel';
15
15
  import { normalizeDataScopeFilter } from './dataScopeFilter';
16
+ import { ensureFormValueDrivenDataScopeClear } from '../utils/dataScopeFormValueClear';
16
17
 
17
18
  export const dataScope = defineAction({
18
19
  name: 'dataScope',
@@ -54,6 +55,8 @@ export const dataScope = defineAction({
54
55
  const resolvedFilter = await ctx.resolveJsonTemplate(params.filter);
55
56
  const filter = normalizeDataScopeFilter(params.filter, resolvedFilter);
56
57
 
58
+ ensureFormValueDrivenDataScopeClear(ctx as any, params.filter);
59
+
57
60
  if (isEmptyFilter(filter)) {
58
61
  resource.removeFilterGroup(ctx.model.uid);
59
62
  } else {
@@ -122,12 +122,11 @@ const FilterFormDefaultValuesUI = observer(
122
122
  rootCollection={getCollectionFromModel(ctx.model)}
123
123
  value={value}
124
124
  onChange={handleChange}
125
- fixedMode="default"
126
- showCondition={false}
127
125
  showValueEditorWhenNoField
128
126
  getValueInputProps={getValueInputProps}
129
127
  isTitleFieldCandidate={isTitleFieldCandidate}
130
128
  onSyncAssociationTitleField={onSyncAssociationTitleField}
129
+ enableDateVariableAsConstant
131
130
  />
132
131
  );
133
132
  },
@@ -371,15 +371,12 @@ export const AdminLayoutComponent = observer((props: any) => {
371
371
  const [allAccessRoutes, setAllAccessRoutes] = useState<NocoBaseDesktopRoute[]>(
372
372
  () => flowEngine.context.routeRepository?.listAccessible?.() || [],
373
373
  );
374
- const screens = Grid.useBreakpoint();
375
- const isMobileViewport =
376
- screens.md === false || (screens.md === undefined && typeof window !== 'undefined' && window.innerWidth < 768);
377
374
  const location = useLocation();
378
375
  const { token } = antdTheme.useToken();
379
376
  const customToken = token as CustomToken;
380
377
  const isMobileLayout = !!adminLayoutModel?.isMobileLayout;
381
378
  const menuRouteRefreshVersion = adminLayoutModel?.menuRouteRefreshVersion || 0;
382
- const isMobileSider = isMobileLayout || isMobileViewport;
379
+ const isMobileSider = isMobileLayout;
383
380
  const [collapsed, setCollapsed] = useState(isMobileSider);
384
381
  const [preferredFlowSettingsEnabled, setPreferredFlowSettingsEnabled] = useState(() => readFlowSettingsPreference());
385
382
  const [route, setRoute] = useState<{ path: string; children: AdminLayoutMenuNode[] }>({
@@ -9,45 +9,19 @@
9
9
 
10
10
  import { QuestionCircleOutlined } from '@ant-design/icons';
11
11
  import { css } from '@emotion/css';
12
- import { observer, useFlowEngine } from '@nocobase/flow-engine';
12
+ import { observer } from '@nocobase/flow-engine';
13
13
  import { parseHTML } from '@nocobase/utils/client';
14
14
  import { Dropdown, Menu, Popover, theme as antdTheme } from 'antd';
15
15
  import type { MenuItemType, MenuDividerType } from 'antd/es/menu/interface';
16
- import React, { useEffect, useMemo, useState } from 'react';
16
+ import React, { useMemo, useState } from 'react';
17
17
  import { useTranslation } from 'react-i18next';
18
18
  import { usePlugin } from '../../../flow-compat';
19
+ import { useCurrentAppInfo } from '../../../hooks';
19
20
  import type { CustomToken } from '../../../theme';
21
+ import { getAppVersionHTML } from '../../../utils';
20
22
 
21
23
  type SettingsMenuItemType = MenuItemType | MenuDividerType;
22
24
 
23
- /**
24
- * 读取当前应用信息,避免继续依赖旧的 CurrentAppInfoProvider。
25
- */
26
- function useCurrentAppInfoLite() {
27
- const flowEngine = useFlowEngine();
28
- const [data, setData] = useState<any>();
29
-
30
- useEffect(() => {
31
- let active = true;
32
-
33
- Promise.resolve(flowEngine.context.appInfo)
34
- .then((info) => {
35
- if (active) {
36
- setData(info);
37
- }
38
- })
39
- .catch((error) => {
40
- console.error(error);
41
- });
42
-
43
- return () => {
44
- active = false;
45
- };
46
- }, [flowEngine]);
47
-
48
- return data;
49
- }
50
-
51
25
  const helpClassName = css`
52
26
  display: inline-block;
53
27
  vertical-align: top;
@@ -60,7 +34,7 @@ const helpClassName = css`
60
34
 
61
35
  const SettingsMenu: React.FC = () => {
62
36
  const { t } = useTranslation();
63
- const appInfo = useCurrentAppInfoLite();
37
+ const appInfo = useCurrentAppInfo();
64
38
  const { token } = antdTheme.useToken();
65
39
  const isSimplifiedChinese = appInfo?.lang === 'zh-CN';
66
40
 
@@ -136,7 +110,7 @@ export const HelpLite = observer(
136
110
  const { token } = antdTheme.useToken();
137
111
  const customToken = token as CustomToken;
138
112
  const customBrandPlugin: any = usePlugin('@nocobase/plugin-custom-brand');
139
- const appInfo = useCurrentAppInfoLite();
113
+ const appInfo = useCurrentAppInfo();
140
114
 
141
115
  const icon = (
142
116
  <span
@@ -156,7 +130,7 @@ export const HelpLite = observer(
156
130
  );
157
131
 
158
132
  if (customBrandPlugin?.options?.options?.about) {
159
- const appVersion = `<span class="nb-app-version">v${appInfo?.version}</span>`;
133
+ const appVersion = getAppVersionHTML(appInfo?.version);
160
134
  const content = parseHTML(customBrandPlugin.options.options.about, { appVersion });
161
135
 
162
136
  return (
@@ -91,7 +91,7 @@ const useBlockHeight = ({
91
91
  const padding = getPadding(root);
92
92
  const addBlockContainer = getAddBlockContainer(root);
93
93
  const pageTop = rootRect.top + padding.top;
94
- const topOffset = Math.min(Math.max(0, cardRect.top - pageTop), 0);
94
+ const topOffset = Math.max(0, cardRect.top - pageTop);
95
95
  let bottomOffset = padding.bottom + ctx.themeToken.marginBlock;
96
96
  if (addBlockContainer) {
97
97
  const gapBetween = ctx.themeToken.marginBlock;
@@ -99,7 +99,7 @@ const useBlockHeight = ({
99
99
  }
100
100
  const nextHeight = Math.max(
101
101
  0,
102
- Math.floor(window.innerHeight - getValidPageTop(pageTop, 110) - topOffset - bottomOffset),
102
+ Math.floor(window.innerHeight - getValidPageTop(pageTop, 110) - topOffset - bottomOffset - 1),
103
103
  );
104
104
  setFullHeight((prev) => (prev === nextHeight ? prev : nextHeight));
105
105
  }, [heightMode, cardRef]);
@@ -48,18 +48,19 @@ ActionModel.registerFlow({
48
48
  title: tExpr('Button icon'),
49
49
  }
50
50
  : undefined,
51
+ iconOnly: ctx.model.enableEditIcon
52
+ ? {
53
+ 'x-decorator': 'FormItem',
54
+ 'x-component': 'Switch',
55
+ title: tExpr('Icon only'),
56
+ }
57
+ : undefined,
51
58
  type: ctx.model.enableEditType
52
59
  ? {
53
60
  'x-decorator': 'FormItem',
54
61
  'x-component': 'Radio.Group',
55
62
  title: tExpr('Button type'),
56
- enum: [
57
- { value: 'default', label: '{{t("Default")}}' },
58
- { value: 'primary', label: '{{t("Primary")}}' },
59
- { value: 'dashed', label: '{{t("Dashed")}}' },
60
- { value: 'link', label: '{{t("Link")}}' },
61
- { value: 'text', label: '{{t("Text")}}' },
62
- ],
63
+ enum: ctx.model.buttonTypeOptions,
63
64
  }
64
65
  : undefined,
65
66
  danger: ctx.model.enableEditDanger
@@ -50,12 +50,13 @@ export const ActionSceneEnum = {
50
50
  };
51
51
 
52
52
  export class ActionModel<T extends DefaultStructure = DefaultStructure> extends FlowModel<T> {
53
- declare props: ButtonProps & { tooltip?: string };
53
+ declare props: ButtonProps & { tooltip?: string; iconOnly?: boolean };
54
54
  declare scene: ActionSceneType;
55
55
 
56
- defaultProps: ButtonProps & { tooltip?: string } = {
56
+ defaultProps: ButtonProps & { tooltip?: string; iconOnly?: boolean } = {
57
57
  type: 'default',
58
58
  title: tExpr('Action'),
59
+ iconOnly: false,
59
60
  };
60
61
 
61
62
  enableEditTooltip = true;
@@ -64,6 +65,13 @@ export class ActionModel<T extends DefaultStructure = DefaultStructure> extends
64
65
  enableEditType = true;
65
66
  enableEditDanger = true;
66
67
  enableEditColor = false;
68
+ buttonTypeOptions = [
69
+ { value: 'default', label: '{{t("Default")}}' },
70
+ { value: 'primary', label: '{{t("Primary")}}' },
71
+ { value: 'dashed', label: '{{t("Dashed")}}' },
72
+ { value: 'link', label: '{{t("Link")}}' },
73
+ { value: 'text', label: '{{t("Text")}}' },
74
+ ];
67
75
 
68
76
  static _getScene() {
69
77
  return _.castArray(this['scene'] || []);
@@ -133,12 +141,12 @@ export class ActionModel<T extends DefaultStructure = DefaultStructure> extends
133
141
  }
134
142
 
135
143
  renderButton() {
136
- const props = this.props;
144
+ const { iconOnly, ...props } = this.props;
137
145
  const icon = this.getIcon() ? <Icon type={this.getIcon() as any} /> : undefined;
138
146
 
139
147
  return (
140
148
  <Button {...props} onClick={this.onClick.bind(this)} icon={icon}>
141
- {props.children || this.getTitle()}
149
+ {iconOnly ? null : props.children || this.getTitle()}
142
150
  </Button>
143
151
  );
144
152
  }
@@ -152,13 +160,13 @@ export class ActionModel<T extends DefaultStructure = DefaultStructure> extends
152
160
  }
153
161
 
154
162
  renderHiddenInConfig(): React.ReactNode | undefined {
155
- const props = this.props;
163
+ const { iconOnly, ...props } = this.props;
156
164
  const icon = this.getIcon() ? <Icon type={this.getIcon() as any} /> : undefined;
157
165
  if (this.forbidden) {
158
166
  return (
159
167
  <ActionWithoutPermission>
160
168
  <Button {...props} onClick={this.onClick.bind(this)} icon={icon} style={{ opacity: '0.3' }}>
161
- {props.children || this.getTitle()}
169
+ {iconOnly ? null : props.children || this.getTitle()}
162
170
  </Button>
163
171
  </ActionWithoutPermission>
164
172
  );
@@ -166,7 +174,7 @@ export class ActionModel<T extends DefaultStructure = DefaultStructure> extends
166
174
  return (
167
175
  <Tooltip title={this.context.t('The button is hidden and only visible when the UI Editor is active')}>
168
176
  <Button {...props} onClick={this.onClick.bind(this)} icon={icon} style={{ opacity: '0.3' }}>
169
- {props.children || this.getTitle()}
177
+ {iconOnly ? null : props.children || this.getTitle()}
170
178
  </Button>
171
179
  </Tooltip>
172
180
  );
@@ -359,6 +359,58 @@ export class GridModel<T extends { subModels: { items: FlowModel[] } } = Default
359
359
  return rowElement?.parentElement?.clientWidth || fallbackWidth;
360
360
  }
361
361
 
362
+ private prunePlaceholderOnlyRows(layout: GridLayoutV2): GridLayoutV2 {
363
+ type RowCellEntry = {
364
+ cell: GridCellV2;
365
+ size: number;
366
+ hasRealItem: boolean;
367
+ };
368
+
369
+ const pruneRows = (rows: GridRowV2[]): GridRowV2[] => {
370
+ return rows
371
+ .map((row) => {
372
+ const cellsWithSizes = row.cells
373
+ .map((cell, index) => {
374
+ const size = row.sizes?.[index] ?? 1;
375
+ if (cell.rows?.length) {
376
+ const childRows = pruneRows(cell.rows);
377
+ if (childRows.length) {
378
+ return { cell: { ...cell, rows: childRows }, size, hasRealItem: true };
379
+ }
380
+ return null;
381
+ }
382
+
383
+ const items = cell.items || [];
384
+ const hasRealItem = items.some((uid) => uid !== EMPTY_COLUMN_UID);
385
+ const hasEmptyPlaceholder = items.includes(EMPTY_COLUMN_UID);
386
+ if (hasRealItem || hasEmptyPlaceholder) {
387
+ return { cell, size, hasRealItem };
388
+ }
389
+ return null;
390
+ })
391
+ .filter(Boolean) as RowCellEntry[];
392
+
393
+ if (!cellsWithSizes.some((entry) => entry.hasRealItem)) {
394
+ return null;
395
+ }
396
+
397
+ return {
398
+ ...row,
399
+ cells: cellsWithSizes.map((entry) => entry.cell),
400
+ sizes: cellsWithSizes.map((entry) => entry.size),
401
+ };
402
+ })
403
+ .filter(Boolean) as GridRowV2[];
404
+ };
405
+
406
+ return normalizeGridLayout({
407
+ layout: { ...layout, rows: pruneRows(layout.rows || []) },
408
+ itemUids: this.getItemUids(),
409
+ gridUid: this.uid,
410
+ logger: console,
411
+ });
412
+ }
413
+
362
414
  private resizeGridLayout({
363
415
  direction,
364
416
  resizeDistance,
@@ -428,7 +480,9 @@ export class GridModel<T extends { subModels: { items: FlowModel[] } } = Default
428
480
  this.emitter.on('onSubModelDestroyed', (model: FlowModel) => {
429
481
  const modelUid = model.uid;
430
482
  this.resetRows(true);
431
- this.setGridStepLayout(this.props.layout);
483
+ const layout = this.prunePlaceholderOnlyRows(this.props.layout);
484
+ this.setGridStepLayout(layout);
485
+ this.syncLayoutProps(layout);
432
486
 
433
487
  // 删除筛选配置
434
488
  this.context.filterManager?.removeFilterConfig({ targetId: modelUid });
@@ -855,6 +909,7 @@ export class GridModel<T extends { subModels: { items: FlowModel[] } } = Default
855
909
  * 运行态按可见 block 过滤行/列,避免“整行都是 hidden block”但依然保留行间距占位。
856
910
  * - 配置态(flowSettingsEnabled)保持原始 rows/sizes 以便拖拽和布局编辑。
857
911
  * - 运行态仅在判断为“整列/整行都不可见”时做过滤,不写回 props/stepParams,布局元数据保持不变。
912
+ * - 空列是拖拽缩窄后保存的布局占位,运行态需要保留其宽度,但 Grid 渲染层不会渲染其内容。
858
913
  */
859
914
  private getVisibleLayout() {
860
915
  const rawLayout = this.normalizeLayoutFromSource();
@@ -877,51 +932,53 @@ export class GridModel<T extends { subModels: { items: FlowModel[] } } = Default
877
932
  }
878
933
 
879
934
  const items = this.subModels?.items || [];
880
- if (!items.length) {
881
- return { layout: baseLayout, rows: baseProjection.rows, sizes: baseProjection.sizes };
882
- }
883
-
884
935
  const modelByUid = new Map(items.map((m: FlowModel) => [m.uid, m]));
936
+ type VisibleCellEntry = {
937
+ cell: GridLayoutV2['rows'][number]['cells'][number];
938
+ size: number;
939
+ hasVisibleContent: boolean;
940
+ };
885
941
 
886
942
  const filterRows = (rows: GridLayoutV2['rows']): GridLayoutV2['rows'] => {
887
943
  return rows
888
944
  .map((row) => {
889
- const cells: GridLayoutV2['rows'][number]['cells'] = [];
890
- const keptSizes: number[] = [];
891
-
892
- row.cells.forEach((cell, index) => {
893
- const sourceSize = row.sizes?.[index];
894
- const keepSize = Number.isFinite(sourceSize) && sourceSize > 0 ? sourceSize : 1;
895
- if (cell.rows) {
896
- const childRows = filterRows(cell.rows);
897
- if (childRows.length) {
898
- cells.push({ ...cell, rows: childRows });
899
- keptSizes.push(keepSize);
945
+ const cellsWithSizes = row.cells
946
+ .map((cell, index) => {
947
+ const sourceSize = row.sizes?.[index];
948
+ const keepSize = Number.isFinite(sourceSize) && sourceSize > 0 ? sourceSize : 1;
949
+ if (cell.rows) {
950
+ const childRows = filterRows(cell.rows);
951
+ if (childRows.length) {
952
+ return { cell: { ...cell, rows: childRows }, size: keepSize, hasVisibleContent: true };
953
+ }
954
+ return null;
900
955
  }
901
- return;
902
- }
903
956
 
904
- const cellItems = (cell.items || []).filter((uid) => {
905
- if (uid === EMPTY_COLUMN_UID) {
906
- return false;
957
+ const cellItems = (cell.items || []).filter((uid) => {
958
+ if (uid === EMPTY_COLUMN_UID) {
959
+ return true;
960
+ }
961
+ return modelByUid.get(uid)?.hidden !== true;
962
+ });
963
+ const hasVisibleContent = cellItems.some((uid) => {
964
+ if (uid === EMPTY_COLUMN_UID) return false;
965
+ const model = modelByUid.get(uid);
966
+ return !model || !model.hidden;
967
+ });
968
+ const hasEmptyPlaceholder = cellItems.includes(EMPTY_COLUMN_UID);
969
+ if (hasVisibleContent || hasEmptyPlaceholder) {
970
+ return { cell: { ...cell, items: cellItems }, size: keepSize, hasVisibleContent };
907
971
  }
908
- return modelByUid.get(uid)?.hidden !== true;
909
- });
910
- const hasVisibleItem = cellItems.some((uid) => {
911
- const model = modelByUid.get(uid);
912
- return !model || !model.hidden;
913
- });
914
- if (hasVisibleItem) {
915
- cells.push({ ...cell, items: cellItems });
916
- keptSizes.push(keepSize);
917
- }
918
- });
919
-
920
- return cells.length
972
+ return null;
973
+ })
974
+ .filter(Boolean) as VisibleCellEntry[];
975
+ const hasVisibleContent = cellsWithSizes.some((entry) => entry.hasVisibleContent);
976
+
977
+ return hasVisibleContent
921
978
  ? {
922
979
  ...row,
923
- cells,
924
- sizes: keptSizes,
980
+ cells: cellsWithSizes.map((entry) => entry.cell),
981
+ sizes: cellsWithSizes.map((entry) => entry.size),
925
982
  }
926
983
  : null;
927
984
  })
@@ -9,7 +9,7 @@
9
9
 
10
10
  import { EMPTY_COLUMN_UID, FlowEngine } from '@nocobase/flow-engine';
11
11
  import { beforeEach, describe, expect, it } from 'vitest';
12
- import { GridModel } from '../GridModel';
12
+ import { GRID_FLOW_KEY, GRID_STEP, GridModel } from '../GridModel';
13
13
 
14
14
  describe('GridModel.getVisibleLayout (hidden items filtering)', () => {
15
15
  let engine: FlowEngine;
@@ -228,8 +228,8 @@ describe('GridModel.getVisibleLayout (hidden items filtering)', () => {
228
228
  expect(sizes.row1).toEqual([10, 14]);
229
229
  });
230
230
 
231
- it('ignores EMPTY_COLUMN uid in runtime mode without crashing', async () => {
232
- await engine.flowSettings.disable();
231
+ it('preserves EMPTY_COLUMN placeholder width in runtime mode', () => {
232
+ engine.flowSettings.disable();
233
233
 
234
234
  const visible = engine.createModel({ use: 'FlowModel', uid: 'v' });
235
235
 
@@ -237,11 +237,18 @@ describe('GridModel.getVisibleLayout (hidden items filtering)', () => {
237
237
  use: 'GridModel',
238
238
  uid: 'grid-8',
239
239
  props: {
240
- rows: {
241
- row1: [[EMPTY_COLUMN_UID, 'ghost', 'v'], ['ghost-2']],
242
- },
243
- sizes: {
244
- row1: [8, 16],
240
+ layout: {
241
+ version: 2,
242
+ rows: [
243
+ {
244
+ id: 'row1',
245
+ cells: [
246
+ { id: 'cell1', items: ['v'] },
247
+ { id: 'cell2', items: [EMPTY_COLUMN_UID] },
248
+ ],
249
+ sizes: [8, 16],
250
+ },
251
+ ],
245
252
  },
246
253
  },
247
254
  structure: {} as any,
@@ -250,8 +257,73 @@ describe('GridModel.getVisibleLayout (hidden items filtering)', () => {
250
257
  (model as any).subModels = { items: [visible] };
251
258
 
252
259
  const { rows, sizes } = (model as any).getVisibleLayout();
253
- // 新布局归一化会移除不在 subModels.items 中的 uid,EMPTY_COLUMN_UID 也不会在运行态显示
254
- expect(rows.row1).toEqual([['v']]);
255
- expect(sizes.row1).toEqual([24]);
260
+ // 空列是拖拽缩窄区块后的布局占位;运行态也要保留其宽度,避免剩余区块被拉满整行。
261
+ expect(rows.row1).toEqual([['v'], [EMPTY_COLUMN_UID]]);
262
+ expect(sizes.row1).toEqual([8, 16]);
263
+ });
264
+
265
+ it('removes rows that only contain EMPTY_COLUMN placeholders in runtime mode when there are no items', () => {
266
+ engine.flowSettings.disable();
267
+
268
+ const model = engine.createModel<GridModel>({
269
+ use: 'GridModel',
270
+ uid: 'grid-9',
271
+ props: {
272
+ layout: {
273
+ version: 2,
274
+ rows: [
275
+ {
276
+ id: 'row1',
277
+ cells: [{ id: 'cell1', items: [EMPTY_COLUMN_UID] }],
278
+ sizes: [24],
279
+ },
280
+ ],
281
+ },
282
+ },
283
+ structure: {} as any,
284
+ });
285
+
286
+ (model as any).subModels = { items: [] };
287
+
288
+ const { rows, sizes } = (model as any).getVisibleLayout();
289
+ expect(rows).toEqual({});
290
+ expect(sizes).toEqual({});
291
+ });
292
+
293
+ it('removes the placeholder-only row after the last real item in the row is deleted', () => {
294
+ engine.flowSettings.disable();
295
+
296
+ const visible = engine.createModel({ use: 'FlowModel', uid: 'v' });
297
+ const layout = {
298
+ version: 2 as const,
299
+ rows: [
300
+ {
301
+ id: 'row1',
302
+ cells: [
303
+ { id: 'cell1', items: ['v'] },
304
+ { id: 'cell2', items: [EMPTY_COLUMN_UID] },
305
+ ],
306
+ sizes: [8, 16],
307
+ },
308
+ ],
309
+ };
310
+ const model = engine.createModel<GridModel>({
311
+ use: 'GridModel',
312
+ uid: 'grid-10',
313
+ props: { layout },
314
+ structure: {} as any,
315
+ });
316
+ (model as any).subModels = { items: [visible] };
317
+ model.setStepParams(GRID_FLOW_KEY, GRID_STEP, { layout });
318
+ model.syncLayoutProps(model.getGridLayout());
319
+ model.onMount();
320
+
321
+ (model as any).subModels = { items: [] };
322
+ model.emitter.emit('onSubModelDestroyed', visible);
323
+
324
+ expect(model.props.rows).toEqual({});
325
+ expect(model.props.sizes).toEqual({});
326
+ expect(model.props.layout.rows).toEqual([]);
327
+ expect(model.getStepParams(GRID_FLOW_KEY, GRID_STEP).layout.rows).toEqual([]);
256
328
  });
257
329
  });
@@ -227,6 +227,8 @@ export class DetailsItemModel extends DisplayItemModel<{
227
227
 
228
228
  DetailsItemModel.define({
229
229
  label: tExpr('Display fields'),
230
+ searchable: true,
231
+ searchPlaceholder: tExpr('Search fields'),
230
232
  sort: 100,
231
233
  });
232
234