@nocobase/flow-engine 2.2.0-alpha.5 → 2.2.0-alpha.7

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.
@@ -243,8 +243,8 @@ const FlowContextSelectorComponent = /* @__PURE__ */ __name(({
243
243
  }, [options, pathToPreload, triggerUpdate]);
244
244
  const defaultChildren = (0, import_react.useMemo)(() => {
245
245
  const hasSelected = active ?? Boolean(currentPath && currentPath.length > 0);
246
- return /* @__PURE__ */ import_react.default.createElement(import_antd.Button, { type: hasSelected ? "primary" : "default", style: defaultButtonStyle }, "x");
247
- }, [active, currentPath]);
246
+ return /* @__PURE__ */ import_react.default.createElement(import_antd.Button, { type: hasSelected ? "primary" : "default", style: defaultButtonStyle, disabled: cascaderProps.disabled }, "x");
247
+ }, [active, cascaderProps.disabled, currentPath]);
248
248
  const handleChange = (0, import_react.useCallback)(
249
249
  (selectedValues, selectedOptions) => {
250
250
  const lastOption = selectedOptions == null ? void 0 : selectedOptions[selectedOptions.length - 1];
@@ -77,14 +77,18 @@ const formItemPropKeys = [
77
77
  "required",
78
78
  "showLabel"
79
79
  ];
80
+ const modelInternalPropKeys = ["globalSort"];
80
81
  const FormItem = /* @__PURE__ */ __name(({
81
82
  children,
82
83
  showLabel = true,
83
84
  labelWidth,
84
85
  ...rest
85
86
  }) => {
87
+ const forwardedRest = Object.fromEntries(
88
+ Object.entries(rest).filter(([key]) => !modelInternalPropKeys.includes(key))
89
+ );
86
90
  const childProps = Object.fromEntries(
87
- Object.entries(rest).filter(([key]) => !formItemPropKeys.includes(key))
91
+ Object.entries(forwardedRest).filter(([key]) => !formItemPropKeys.includes(key))
88
92
  );
89
93
  const processedChildren = typeof children === "function" ? children : import_react.default.Children.map(children, (child) => {
90
94
  if (import_react.default.isValidElement(child)) {
@@ -92,7 +96,7 @@ const FormItem = /* @__PURE__ */ __name(({
92
96
  }
93
97
  return child;
94
98
  });
95
- const { label, labelWrap, colon = true, layout } = rest;
99
+ const { label, labelWrap, colon = true, layout } = forwardedRest;
96
100
  const effectiveLabelWrap = !layout || layout === "vertical" ? true : labelWrap;
97
101
  const labelColStyle = layout === "vertical" ? { width: labelWidth, ...verticalFormItemLabelStyle } : { width: labelWidth };
98
102
  const renderLabel = /* @__PURE__ */ __name(() => {
@@ -136,15 +140,15 @@ const FormItem = /* @__PURE__ */ __name(({
136
140
  return /* @__PURE__ */ import_react.default.createElement(
137
141
  import_antd.Form.Item,
138
142
  {
139
- ...rest,
140
- style: { ...formItemStyle, ...rest.style },
143
+ ...forwardedRest,
144
+ style: { ...formItemStyle, ...forwardedRest.style },
141
145
  labelCol: { style: labelColStyle },
142
146
  layout,
143
147
  label: renderLabel(),
144
148
  colon: false,
145
- extra: rest.extra && /* @__PURE__ */ import_react.default.createElement("span", { style: { whiteSpace: "pre-wrap" } }, rest.extra),
146
- tooltip: rest.tooltip && {
147
- title: rest.tooltip,
149
+ extra: forwardedRest.extra && /* @__PURE__ */ import_react.default.createElement("span", { style: { whiteSpace: "pre-wrap" } }, forwardedRest.extra),
150
+ tooltip: forwardedRest.tooltip && {
151
+ title: forwardedRest.tooltip,
148
152
  overlayInnerStyle: { whiteSpace: "pre-line" }
149
153
  }
150
154
  },
@@ -48,14 +48,34 @@ var import_lazy_helper = require("../lazy-helper");
48
48
  const { Popup } = (0, import_lazy_helper.lazy)(() => import("antd-mobile"), "Popup");
49
49
  const { CloseOutline } = (0, import_lazy_helper.lazy)(() => import("antd-mobile-icons"), "CloseOutline");
50
50
  const MobilePopup = /* @__PURE__ */ __name((props) => {
51
+ var _a;
51
52
  const { title, visible, onClose: closePopup, children, minHeight, className, footer } = props;
52
53
  const { t } = (0, import_react_i18next.useTranslation)();
53
54
  const { componentCls, hashId } = (0, import_MobilePopup.useMobileActionDrawerStyle)();
54
- const style = (0, import_react.useMemo)(() => {
55
+ const bodyStyles = (_a = props.styles) == null ? void 0 : _a.body;
56
+ const popupStyle = (0, import_react.useMemo)(() => {
55
57
  return {
56
- minHeight
58
+ minHeight: (bodyStyles == null ? void 0 : bodyStyles.minHeight) ?? minHeight,
59
+ height: bodyStyles == null ? void 0 : bodyStyles.height,
60
+ maxHeight: bodyStyles == null ? void 0 : bodyStyles.maxHeight
57
61
  };
58
- }, [minHeight]);
62
+ }, [bodyStyles == null ? void 0 : bodyStyles.height, bodyStyles == null ? void 0 : bodyStyles.maxHeight, bodyStyles == null ? void 0 : bodyStyles.minHeight, minHeight]);
63
+ const bodyStyle = (0, import_react.useMemo)(() => {
64
+ return {
65
+ padding: 0,
66
+ ...bodyStyles
67
+ };
68
+ }, [bodyStyles]);
69
+ const handleCloseKeyDown = (0, import_react.useCallback)(
70
+ (event) => {
71
+ if (event.key !== "Enter" && event.key !== " ") {
72
+ return;
73
+ }
74
+ event.preventDefault();
75
+ closePopup();
76
+ },
77
+ [closePopup]
78
+ );
59
79
  const theme = (0, import_react.useMemo)(() => {
60
80
  return {
61
81
  token: {
@@ -75,21 +95,19 @@ const MobilePopup = /* @__PURE__ */ __name((props) => {
75
95
  onClose: closePopup,
76
96
  onMaskClick: closePopup,
77
97
  bodyClassName: "nb-mobile-action-drawer-body",
78
- bodyStyle: {
79
- padding: 0
80
- },
81
- maskStyle: style,
82
- style,
98
+ bodyStyle,
99
+ style: popupStyle,
83
100
  destroyOnClose: true
84
101
  },
85
- /* @__PURE__ */ import_react.default.createElement("div", { className: "nb-mobile-action-drawer-header" }, /* @__PURE__ */ import_react.default.createElement("span", { className: "nb-mobile-action-drawer-placeholder" }, /* @__PURE__ */ import_react.default.createElement(CloseOutline, null)), /* @__PURE__ */ import_react.default.createElement("span", null, title), /* @__PURE__ */ import_react.default.createElement(
102
+ /* @__PURE__ */ import_react.default.createElement("div", { className: "nb-mobile-action-drawer-header" }, /* @__PURE__ */ import_react.default.createElement("span", { className: "nb-mobile-action-drawer-placeholder" }, /* @__PURE__ */ import_react.default.createElement(CloseOutline, null)), /* @__PURE__ */ import_react.default.createElement("span", { className: "nb-mobile-action-drawer-title" }, title), /* @__PURE__ */ import_react.default.createElement(
86
103
  "span",
87
104
  {
88
105
  className: "nb-mobile-action-drawer-close-icon",
89
106
  onClick: closePopup,
90
107
  role: "button",
91
108
  tabIndex: 0,
92
- "aria-label": t("Close")
109
+ "aria-label": t("Close"),
110
+ onKeyDown: handleCloseKeyDown
93
111
  },
94
112
  /* @__PURE__ */ import_react.default.createElement(CloseOutline, null)
95
113
  )),
@@ -127,7 +127,7 @@ const useMobileActionDrawerStyle = genStyleHook("nb-mobile-action-drawer", (toke
127
127
  borderBottom: `1px solid ${token.colorSplit}`,
128
128
  position: "sticky",
129
129
  top: 0,
130
- backgroundColor: "white",
130
+ backgroundColor: token.colorBgContainer,
131
131
  zIndex: 1e3,
132
132
  // to match the button named 'Add block'
133
133
  "& + .nb-grid-container > .nb-grid > .nb-grid-warp > .ant-btn": {
@@ -137,11 +137,21 @@ const useMobileActionDrawerStyle = genStyleHook("nb-mobile-action-drawer", (toke
137
137
  ".nb-mobile-action-drawer-placeholder": {
138
138
  display: "inline-block",
139
139
  padding: 12,
140
+ flex: "0 0 auto",
140
141
  visibility: "hidden"
141
142
  },
143
+ ".nb-mobile-action-drawer-title": {
144
+ flex: "1 1 auto",
145
+ minWidth: 0,
146
+ overflow: "hidden",
147
+ textAlign: "center",
148
+ textOverflow: "ellipsis",
149
+ whiteSpace: "nowrap"
150
+ },
142
151
  ".nb-mobile-action-drawer-close-icon": {
143
152
  display: "inline-block",
144
153
  padding: 12,
154
+ flex: "0 0 auto",
145
155
  cursor: "pointer"
146
156
  },
147
157
  ".nb-mobile-action-drawer-body": {
@@ -326,6 +326,7 @@ const VariableInputComponent = /* @__PURE__ */ __name(({
326
326
  metaTree: resolvedMetaTree,
327
327
  value: innerValue,
328
328
  active: (0, import_utils.isVariableValue)(innerValue),
329
+ disabled,
329
330
  onChange: handleVariableSelect,
330
331
  parseValueToPath: resolvePathFromValue,
331
332
  formatPathToValue: resolveValueFromPath,
package/lib/flowEngine.js CHANGED
@@ -1141,6 +1141,9 @@ const _FlowEngine = class _FlowEngine {
1141
1141
  if (!this.ensureModelRepository()) return;
1142
1142
  const refresh = !!(options == null ? void 0 : options.refresh);
1143
1143
  const bypassLoadedPageCache = this._loadedPageCache.shouldBypass(options, () => this.context.flowSettingsEnabled);
1144
+ if (this.context.flowSettingsEnabled) {
1145
+ this._loadedPageCache.markDirtyForOptions(options);
1146
+ }
1144
1147
  if (!refresh && !bypassLoadedPageCache) {
1145
1148
  const model2 = this.findModelByParentId(options.parentId, options.subKey);
1146
1149
  if (model2) {
@@ -1201,6 +1204,9 @@ const _FlowEngine = class _FlowEngine {
1201
1204
  if (!this.ensureModelRepository()) return;
1202
1205
  const { uid, parentId, subKey } = options;
1203
1206
  const bypassLoadedPageCache = this._loadedPageCache.shouldBypass(options, () => this.context.flowSettingsEnabled);
1207
+ if (this.context.flowSettingsEnabled) {
1208
+ this._loadedPageCache.markDirtyForOptions(options);
1209
+ }
1204
1210
  if (uid && !bypassLoadedPageCache && this._modelInstances.has(uid)) {
1205
1211
  return this._modelInstances.get(uid);
1206
1212
  }
@@ -17,6 +17,7 @@ type DirtyKeyOptions = {
17
17
  export declare const createLoadedPageCache: () => {
18
18
  getDirtyKeyForModel(model?: FlowModel | null, options?: DirtyKeyOptions): string | undefined;
19
19
  markDirty(key?: string): void;
20
+ markDirtyForOptions(options?: LoadedPageOptions): void;
20
21
  shouldBypass(options?: LoadedPageOptions, isFlowSettingsEnabled?: () => boolean): boolean;
21
22
  clear(options?: LoadedPageOptions): void;
22
23
  mountModelToParent: <T extends FlowModel<import("..").DefaultStructure> = FlowModel<import("..").DefaultStructure>>(model: T, forceReplace?: boolean) => T;
@@ -113,6 +113,12 @@ const createLoadedPageCache = /* @__PURE__ */ __name(() => {
113
113
  dirtyKeys.add(key);
114
114
  }
115
115
  },
116
+ markDirtyForOptions(options) {
117
+ const key = getLoadedPageKey(options);
118
+ if (key) {
119
+ dirtyKeys.add(key);
120
+ }
121
+ },
116
122
  shouldBypass(options, isFlowSettingsEnabled) {
117
123
  const key = getLoadedPageKey(options);
118
124
  if (!key || !dirtyKeys.has(key)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nocobase/flow-engine",
3
- "version": "2.2.0-alpha.5",
3
+ "version": "2.2.0-alpha.7",
4
4
  "private": false,
5
5
  "description": "A standalone flow engine for NocoBase, managing workflows, models, and actions.",
6
6
  "main": "lib/index.js",
@@ -8,8 +8,8 @@
8
8
  "dependencies": {
9
9
  "@formily/antd-v5": "1.x",
10
10
  "@formily/reactive": "2.x",
11
- "@nocobase/sdk": "2.2.0-alpha.5",
12
- "@nocobase/shared": "2.2.0-alpha.5",
11
+ "@nocobase/sdk": "2.2.0-alpha.7",
12
+ "@nocobase/shared": "2.2.0-alpha.7",
13
13
  "ahooks": "^3.7.2",
14
14
  "axios": "^1.7.0",
15
15
  "dayjs": "^1.11.9",
@@ -37,5 +37,5 @@
37
37
  ],
38
38
  "author": "NocoBase Team",
39
39
  "license": "Apache-2.0",
40
- "gitHead": "814ff497dd1233ca7218c9f7f2a016c2ead6516d"
40
+ "gitHead": "34ba02960fca9ab0b6881d3db7040a8a64067bba"
41
41
  }
@@ -340,9 +340,9 @@ describe('ViewScopedFlowEngine', () => {
340
340
  const repository = new DirtyPageRepository();
341
341
  root.setModelRepository(repository);
342
342
 
343
- class ParentModel extends FlowModel {}
344
- class PageModel extends FlowModel {}
345
343
  class BlockModel extends FlowModel {}
344
+ class PageModel extends FlowModel<{ parent?: FlowModel; subModels: { items: BlockModel[] } }> {}
345
+ class ParentModel extends FlowModel<{ parent?: FlowModel; subModels: { page?: PageModel } }> {}
346
346
  root.registerModels({ ParentModel, PageModel, BlockModel });
347
347
 
348
348
  const parent = root.createModel<ParentModel>({ use: 'ParentModel', uid: 'popup-action' });
@@ -357,7 +357,10 @@ describe('ViewScopedFlowEngine', () => {
357
357
  items: [{ use: 'BlockModel', uid: 'stale-block' }],
358
358
  },
359
359
  });
360
- const staleBlock = stalePage.findSubModel('items' as any, (item) => item.uid === 'stale-block') as FlowModel;
360
+ const staleBlock = stalePage.findSubModel('items', (item) => item.uid === 'stale-block');
361
+ if (!staleBlock) {
362
+ throw new Error('Expected stale block to be loaded');
363
+ }
361
364
  parent.setSubModel('page', stalePage);
362
365
  oldScoped.unlinkFromStack();
363
366
 
@@ -372,7 +375,7 @@ describe('ViewScopedFlowEngine', () => {
372
375
  },
373
376
  };
374
377
 
375
- root.flowSettings.enable();
378
+ await root.flowSettings.enable();
376
379
  await staleBlock.saveStepParams();
377
380
  root.flowSettings.disable();
378
381
  repository.findOneCalls = 0;
@@ -388,8 +391,8 @@ describe('ViewScopedFlowEngine', () => {
388
391
 
389
392
  expect(repository.findOneCalls).toBe(1);
390
393
  expect(loaded).not.toBe(stalePage);
391
- expect((parent.subModels as any).page).toBe(loaded);
392
- expect(loaded?.mapSubModels('items' as any, (item) => item.uid)).toEqual(['fresh-block']);
394
+ expect(parent.subModels.page).toBe(loaded);
395
+ expect(loaded?.mapSubModels('items', (item) => item.uid)).toEqual(['fresh-block']);
393
396
 
394
397
  repository.findOneCalls = 0;
395
398
  const nextRuntimeScoped = createViewScopedEngine(root);
@@ -405,6 +408,69 @@ describe('ViewScopedFlowEngine', () => {
405
408
  expect(loadedAgain?.uid).toBe('popup-page');
406
409
  });
407
410
 
411
+ it('reloads a page after it was loaded in flow settings mode', async () => {
412
+ const root = new FlowEngine();
413
+ const repository = new DirtyPageRepository();
414
+ root.setModelRepository(repository);
415
+
416
+ class ParentModel extends FlowModel {}
417
+ class PageModel extends FlowModel {}
418
+ class BlockModel extends FlowModel {}
419
+ root.registerModels({ ParentModel, PageModel, BlockModel });
420
+
421
+ const parent = root.createModel<ParentModel>({ use: 'ParentModel', uid: 'settings-popup-action' });
422
+ repository.data = {
423
+ use: 'PageModel',
424
+ uid: 'settings-popup-page',
425
+ parentId: parent.uid,
426
+ subKey: 'page',
427
+ subType: 'object',
428
+ subModels: {
429
+ items: [{ use: 'BlockModel', uid: 'stale-settings-block' }],
430
+ },
431
+ };
432
+
433
+ await root.flowSettings.enable();
434
+ const designScoped = createViewScopedEngine(root);
435
+ const designLoaded = await designScoped.loadOrCreateModel<PageModel>({
436
+ async: true,
437
+ parentId: parent.uid,
438
+ subKey: 'page',
439
+ subType: 'object',
440
+ use: 'PageModel',
441
+ });
442
+ expect(repository.findOneCalls).toBe(1);
443
+ expect(designLoaded?.mapSubModels('items', (item) => item.uid)).toEqual(['stale-settings-block']);
444
+ designScoped.unlinkFromStack();
445
+
446
+ repository.data = {
447
+ use: 'PageModel',
448
+ uid: 'settings-popup-page',
449
+ parentId: parent.uid,
450
+ subKey: 'page',
451
+ subType: 'object',
452
+ subModels: {
453
+ items: [{ use: 'BlockModel', uid: 'fresh-settings-block' }],
454
+ },
455
+ };
456
+ root.flowSettings.disable();
457
+ repository.findOneCalls = 0;
458
+
459
+ const runtimeScoped = createViewScopedEngine(root);
460
+ const runtimeLoaded = await runtimeScoped.loadOrCreateModel<PageModel>({
461
+ async: true,
462
+ parentId: parent.uid,
463
+ subKey: 'page',
464
+ subType: 'object',
465
+ use: 'PageModel',
466
+ });
467
+
468
+ expect(repository.findOneCalls).toBe(1);
469
+ expect(runtimeLoaded).not.toBe(designLoaded);
470
+ expect(parent.subModels.page).toBe(runtimeLoaded);
471
+ expect(runtimeLoaded?.mapSubModels('items', (item) => item.uid)).toEqual(['fresh-settings-block']);
472
+ });
473
+
408
474
  it('does not bypass loaded page cache after a non-config save', async () => {
409
475
  const root = new FlowEngine();
410
476
  const repository = new DirtyPageRepository();
@@ -292,11 +292,11 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
292
292
  const defaultChildren = useMemo(() => {
293
293
  const hasSelected = active ?? Boolean(currentPath && currentPath.length > 0);
294
294
  return (
295
- <Button type={hasSelected ? 'primary' : 'default'} style={defaultButtonStyle}>
295
+ <Button type={hasSelected ? 'primary' : 'default'} style={defaultButtonStyle} disabled={cascaderProps.disabled}>
296
296
  x
297
297
  </Button>
298
298
  );
299
- }, [active, currentPath]);
299
+ }, [active, cascaderProps.disabled, currentPath]);
300
300
 
301
301
  // 处理选择变化事件
302
302
  const handleChange = useCallback(
@@ -54,15 +54,20 @@ const formItemPropKeys: (keyof ExtendedFormItemProps)[] = [
54
54
  'showLabel',
55
55
  ];
56
56
 
57
+ const modelInternalPropKeys = ['globalSort'];
58
+
57
59
  export const FormItem = ({
58
60
  children,
59
61
  showLabel = true,
60
62
  labelWidth,
61
63
  ...rest
62
64
  }: ExtendedFormItemProps & ChildExtraProps) => {
65
+ const forwardedRest = Object.fromEntries(
66
+ Object.entries(rest).filter(([key]) => !modelInternalPropKeys.includes(key)),
67
+ ) as ExtendedFormItemProps & ChildExtraProps;
63
68
  // 过滤掉 Form.Item 专用 props,只保留要传给子组件的
64
69
  const childProps = Object.fromEntries(
65
- Object.entries(rest).filter(([key]) => !formItemPropKeys.includes(key as keyof ExtendedFormItemProps)),
70
+ Object.entries(forwardedRest).filter(([key]) => !formItemPropKeys.includes(key as keyof ExtendedFormItemProps)),
66
71
  );
67
72
 
68
73
  const processedChildren =
@@ -74,7 +79,7 @@ export const FormItem = ({
74
79
  }
75
80
  return child;
76
81
  });
77
- const { label, labelWrap, colon = true, layout } = rest;
82
+ const { label, labelWrap, colon = true, layout } = forwardedRest;
78
83
  const effectiveLabelWrap = !layout || layout === 'vertical' ? true : labelWrap;
79
84
  const labelColStyle =
80
85
  layout === 'vertical' ? { width: labelWidth, ...verticalFormItemLabelStyle } : { width: labelWidth };
@@ -122,17 +127,17 @@ export const FormItem = ({
122
127
  };
123
128
  return (
124
129
  <Form.Item
125
- {...rest}
126
- style={{ ...formItemStyle, ...rest.style }}
130
+ {...forwardedRest}
131
+ style={{ ...formItemStyle, ...forwardedRest.style }}
127
132
  labelCol={{ style: labelColStyle }}
128
133
  layout={layout}
129
134
  label={renderLabel()}
130
135
  colon={false}
131
- extra={rest.extra && <span style={{ whiteSpace: 'pre-wrap' }}>{rest.extra}</span>}
136
+ extra={forwardedRest.extra && <span style={{ whiteSpace: 'pre-wrap' }}>{forwardedRest.extra}</span>}
132
137
  tooltip={
133
- rest.tooltip &&
138
+ forwardedRest.tooltip &&
134
139
  ({
135
- title: rest.tooltip,
140
+ title: forwardedRest.tooltip,
136
141
  overlayInnerStyle: { whiteSpace: 'pre-line' },
137
142
  } as TooltipProps)
138
143
  }
@@ -160,7 +160,7 @@ export const useMobileActionDrawerStyle = genStyleHook('nb-mobile-action-drawer'
160
160
  borderBottom: `1px solid ${token.colorSplit}`,
161
161
  position: 'sticky',
162
162
  top: 0,
163
- backgroundColor: 'white',
163
+ backgroundColor: token.colorBgContainer,
164
164
  zIndex: 1000,
165
165
 
166
166
  // to match the button named 'Add block'
@@ -172,12 +172,23 @@ export const useMobileActionDrawerStyle = genStyleHook('nb-mobile-action-drawer'
172
172
  '.nb-mobile-action-drawer-placeholder': {
173
173
  display: 'inline-block',
174
174
  padding: 12,
175
+ flex: '0 0 auto',
175
176
  visibility: 'hidden',
176
177
  },
177
178
 
179
+ '.nb-mobile-action-drawer-title': {
180
+ flex: '1 1 auto',
181
+ minWidth: 0,
182
+ overflow: 'hidden',
183
+ textAlign: 'center',
184
+ textOverflow: 'ellipsis',
185
+ whiteSpace: 'nowrap',
186
+ },
187
+
178
188
  '.nb-mobile-action-drawer-close-icon': {
179
189
  display: 'inline-block',
180
190
  padding: 12,
191
+ flex: '0 0 auto',
181
192
  cursor: 'pointer',
182
193
  },
183
194
 
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { ConfigProvider } from 'antd';
11
- import React, { FC, ReactNode, useMemo } from 'react';
11
+ import React, { FC, ReactNode, useCallback, useMemo } from 'react';
12
12
  import { useMobileActionDrawerStyle } from './MobilePopup.style';
13
13
  import { useTranslation } from 'react-i18next';
14
14
  import { lazy } from '../lazy-helper';
@@ -31,11 +31,33 @@ export const MobilePopup: FC<MobilePopupProps> = (props) => {
31
31
  const { t } = useTranslation();
32
32
  const { componentCls, hashId } = useMobileActionDrawerStyle();
33
33
 
34
- const style = useMemo(() => {
34
+ const bodyStyles = (props as MobilePopupProps & { styles?: { body?: React.CSSProperties } }).styles?.body;
35
+ const popupStyle = useMemo(() => {
35
36
  return {
36
- minHeight,
37
+ minHeight: bodyStyles?.minHeight ?? minHeight,
38
+ height: bodyStyles?.height,
39
+ maxHeight: bodyStyles?.maxHeight,
37
40
  };
38
- }, [minHeight]);
41
+ }, [bodyStyles?.height, bodyStyles?.maxHeight, bodyStyles?.minHeight, minHeight]);
42
+
43
+ const bodyStyle = useMemo(() => {
44
+ return {
45
+ padding: 0,
46
+ ...bodyStyles,
47
+ };
48
+ }, [bodyStyles]);
49
+
50
+ const handleCloseKeyDown = useCallback(
51
+ (event: React.KeyboardEvent<HTMLSpanElement>) => {
52
+ if (event.key !== 'Enter' && event.key !== ' ') {
53
+ return;
54
+ }
55
+
56
+ event.preventDefault();
57
+ closePopup();
58
+ },
59
+ [closePopup],
60
+ );
39
61
 
40
62
  const theme = useMemo(() => {
41
63
  return {
@@ -57,11 +79,8 @@ export const MobilePopup: FC<MobilePopupProps> = (props) => {
57
79
  onClose={closePopup}
58
80
  onMaskClick={closePopup}
59
81
  bodyClassName="nb-mobile-action-drawer-body"
60
- bodyStyle={{
61
- padding: 0,
62
- }}
63
- maskStyle={style}
64
- style={style}
82
+ bodyStyle={bodyStyle}
83
+ style={popupStyle}
65
84
  destroyOnClose
66
85
  >
67
86
  <div className="nb-mobile-action-drawer-header">
@@ -69,13 +88,14 @@ export const MobilePopup: FC<MobilePopupProps> = (props) => {
69
88
  <span className="nb-mobile-action-drawer-placeholder">
70
89
  <CloseOutline />
71
90
  </span>
72
- <span>{title}</span>
91
+ <span className="nb-mobile-action-drawer-title">{title}</span>
73
92
  <span
74
93
  className="nb-mobile-action-drawer-close-icon"
75
94
  onClick={closePopup}
76
95
  role="button"
77
96
  tabIndex={0}
78
97
  aria-label={t('Close')}
98
+ onKeyDown={handleCloseKeyDown}
79
99
  >
80
100
  <CloseOutline />
81
101
  </span>
@@ -7,8 +7,10 @@
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
9
 
10
- import { describe, expect, it } from 'vitest';
11
- import { formItemStyle, verticalFormItemLabelStyle } from '../FormItem';
10
+ import { render } from '@testing-library/react';
11
+ import React from 'react';
12
+ import { describe, expect, it, vi } from 'vitest';
13
+ import { FormItem, formItemStyle, verticalFormItemLabelStyle } from '../FormItem';
12
14
 
13
15
  describe('FormItem', () => {
14
16
  it('keeps vertical label-to-value spacing consistent with v1', () => {
@@ -22,4 +24,17 @@ describe('FormItem', () => {
22
24
  marginBottom: 12,
23
25
  });
24
26
  });
27
+
28
+ it('does not forward model-internal globalSort props to field children', () => {
29
+ const Field = vi.fn(() => <input aria-label="field" />);
30
+
31
+ render(
32
+ <FormItem globalSort={['title']} placeholder="Title">
33
+ <Field />
34
+ </FormItem>,
35
+ );
36
+
37
+ expect(Field).toHaveBeenCalledWith(expect.not.objectContaining({ globalSort: expect.anything() }), {});
38
+ expect(Field).toHaveBeenCalledWith(expect.objectContaining({ placeholder: 'Title' }), {});
39
+ });
25
40
  });
@@ -0,0 +1,109 @@
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 React from 'react';
11
+ import { fireEvent, render, screen } from '@testing-library/react';
12
+ import { describe, expect, it, vi } from 'vitest';
13
+ import { MobilePopup } from '../MobilePopup';
14
+
15
+ vi.mock('react-i18next', () => ({
16
+ useTranslation: () => ({
17
+ t: (key: string) => key,
18
+ }),
19
+ }));
20
+
21
+ vi.mock('../MobilePopup.style', () => ({
22
+ useMobileActionDrawerStyle: () => ({
23
+ componentCls: 'nb-mobile-action-drawer',
24
+ hashId: 'hash',
25
+ }),
26
+ }));
27
+
28
+ vi.mock('../../lazy-helper', () => ({
29
+ lazy: (_loader: unknown, name: string) => {
30
+ if (name === 'Popup') {
31
+ const Popup = ({
32
+ children,
33
+ bodyStyle,
34
+ maskStyle,
35
+ style,
36
+ }: {
37
+ children: React.ReactNode;
38
+ bodyStyle?: React.CSSProperties;
39
+ maskStyle?: React.CSSProperties;
40
+ style?: React.CSSProperties;
41
+ }) => (
42
+ <div data-testid="mobile-popup" style={style}>
43
+ <div data-testid="mobile-popup-mask" style={maskStyle} />
44
+ <div data-testid="mobile-popup-body" style={bodyStyle}>
45
+ {children}
46
+ </div>
47
+ </div>
48
+ );
49
+
50
+ return { Popup };
51
+ }
52
+
53
+ return {
54
+ CloseOutline: () => <span data-testid="close-outline" />,
55
+ };
56
+ },
57
+ }));
58
+
59
+ describe('MobilePopup', () => {
60
+ const MobilePopupWithDrawerStyles = MobilePopup as React.ComponentType<
61
+ React.ComponentProps<typeof MobilePopup> & { styles?: { body?: React.CSSProperties } }
62
+ >;
63
+
64
+ it('applies drawer body styles as max bounds without forcing fixed half-window height', () => {
65
+ render(
66
+ <MobilePopupWithDrawerStyles visible title="Title" styles={{ body: { maxHeight: '50vh' } }} onClose={vi.fn()}>
67
+ body
68
+ </MobilePopupWithDrawerStyles>,
69
+ );
70
+
71
+ expect(screen.getByTestId('mobile-popup')).toHaveStyle({
72
+ maxHeight: '50vh',
73
+ });
74
+ expect(screen.getByTestId('mobile-popup-body')).toHaveStyle({
75
+ maxHeight: '50vh',
76
+ });
77
+ expect(screen.getByTestId('mobile-popup-mask')).not.toHaveStyle({
78
+ maxHeight: '50vh',
79
+ });
80
+ });
81
+
82
+ it('closes from the header close icon with Enter or Space', () => {
83
+ const onClose = vi.fn();
84
+ render(
85
+ <MobilePopup visible title="Title" onClose={onClose}>
86
+ body
87
+ </MobilePopup>,
88
+ );
89
+
90
+ const closeButton = screen.getByRole('button', { name: 'Close' });
91
+ fireEvent.keyDown(closeButton, { key: 'Enter' });
92
+ fireEvent.keyDown(closeButton, { key: ' ' });
93
+
94
+ expect(onClose).toHaveBeenCalledTimes(2);
95
+ });
96
+
97
+ it('keeps long header titles in a constrained title element separate from the close button', () => {
98
+ const longTitle = 'A very long table column title that should not push the close button outside the drawer';
99
+
100
+ render(
101
+ <MobilePopup visible title={longTitle} onClose={vi.fn()}>
102
+ body
103
+ </MobilePopup>,
104
+ );
105
+
106
+ expect(screen.getByText(longTitle)).toHaveClass('nb-mobile-action-drawer-title');
107
+ expect(screen.getByRole('button', { name: 'Close' })).toHaveClass('nb-mobile-action-drawer-close-icon');
108
+ });
109
+ });
@@ -357,6 +357,7 @@ const VariableInputComponent: React.FC<VariableInputProps> = ({
357
357
  metaTree={resolvedMetaTree}
358
358
  value={innerValue}
359
359
  active={isVariableValue(innerValue)}
360
+ disabled={disabled}
360
361
  onChange={handleVariableSelect}
361
362
  parseValueToPath={resolvePathFromValue}
362
363
  formatPathToValue={resolveValueFromPath}
@@ -113,6 +113,18 @@ describe('VariableInput', () => {
113
113
  expect(selectorButton).toBeInTheDocument();
114
114
  });
115
115
 
116
+ it('disables the FlowContextSelector button when disabled', async () => {
117
+ const flowContext = createTestFlowContext();
118
+ render(
119
+ <TestFlowContextWrapper context={flowContext}>
120
+ <VariableInput value="test" metaTree={() => flowContext.getPropertyMetaTree()} disabled />
121
+ </TestFlowContextWrapper>,
122
+ );
123
+
124
+ const selectorButton = await screen.findByRole('button');
125
+ expect(selectorButton).toBeDisabled();
126
+ });
127
+
116
128
  it('should not highlight the selector button for synthetic constant/null paths', async () => {
117
129
  const flowContext = createTestFlowContext();
118
130
 
package/src/flowEngine.ts CHANGED
@@ -1343,6 +1343,9 @@ export class FlowEngine {
1343
1343
  if (!this.ensureModelRepository()) return;
1344
1344
  const refresh = !!options?.refresh;
1345
1345
  const bypassLoadedPageCache = this._loadedPageCache.shouldBypass(options, () => this.context.flowSettingsEnabled);
1346
+ if (this.context.flowSettingsEnabled) {
1347
+ this._loadedPageCache.markDirtyForOptions(options);
1348
+ }
1346
1349
  if (!refresh && !bypassLoadedPageCache) {
1347
1350
  const model = this.findModelByParentId(options.parentId, options.subKey);
1348
1351
  if (model) {
@@ -1412,6 +1415,9 @@ export class FlowEngine {
1412
1415
  if (!this.ensureModelRepository()) return;
1413
1416
  const { uid, parentId, subKey } = options;
1414
1417
  const bypassLoadedPageCache = this._loadedPageCache.shouldBypass(options, () => this.context.flowSettingsEnabled);
1418
+ if (this.context.flowSettingsEnabled) {
1419
+ this._loadedPageCache.markDirtyForOptions(options);
1420
+ }
1415
1421
  if (uid && !bypassLoadedPageCache && this._modelInstances.has(uid)) {
1416
1422
  return this._modelInstances.get(uid) as T;
1417
1423
  }
@@ -123,6 +123,13 @@ export const createLoadedPageCache = () => {
123
123
  }
124
124
  },
125
125
 
126
+ markDirtyForOptions(options?: LoadedPageOptions): void {
127
+ const key = getLoadedPageKey(options);
128
+ if (key) {
129
+ dirtyKeys.add(key);
130
+ }
131
+ },
132
+
126
133
  shouldBypass(options?: LoadedPageOptions, isFlowSettingsEnabled?: () => boolean): boolean {
127
134
  const key = getLoadedPageKey(options);
128
135
  if (!key || !dirtyKeys.has(key)) {