@nocobase/flow-engine 2.2.0-beta.7 → 2.2.0-beta.8

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 (44) hide show
  1. package/lib/components/MobilePopup.style.js +16 -5
  2. package/lib/flowContext.d.ts +1 -1
  3. package/lib/flowContext.js +22 -6
  4. package/lib/locale/en-US.json +1 -0
  5. package/lib/locale/index.d.ts +2 -0
  6. package/lib/locale/zh-CN.json +1 -0
  7. package/lib/resources/apiResource.js +2 -1
  8. package/lib/resources/baseRecordResource.js +6 -17
  9. package/lib/resources/multiRecordResource.js +13 -3
  10. package/lib/resources/singleRecordResource.js +7 -2
  11. package/lib/utils/dataSourceDirty.d.ts +20 -0
  12. package/lib/utils/dataSourceDirty.js +139 -0
  13. package/lib/utils/dirtyAwareApiClient.d.ts +11 -0
  14. package/lib/utils/dirtyAwareApiClient.js +378 -0
  15. package/lib/utils/index.d.ts +1 -0
  16. package/lib/utils/index.js +11 -0
  17. package/lib/utils/openViewRouteState.d.ts +28 -0
  18. package/lib/utils/openViewRouteState.js +125 -0
  19. package/lib/utils/parsePathnameToViewParams.d.ts +3 -0
  20. package/lib/utils/parsePathnameToViewParams.js +18 -1
  21. package/lib/views/ViewNavigation.js +5 -0
  22. package/package.json +4 -4
  23. package/src/__tests__/flowContext.test.ts +88 -0
  24. package/src/__tests__/flowEngine.dataSourceDirty.test.ts +51 -0
  25. package/src/__tests__/runjsRuntimeFeatures.test.ts +15 -2
  26. package/src/components/MobilePopup.style.ts +22 -6
  27. package/src/components/__tests__/MobilePopup.style.test.tsx +103 -0
  28. package/src/flowContext.ts +32 -7
  29. package/src/locale/en-US.json +1 -0
  30. package/src/locale/zh-CN.json +1 -0
  31. package/src/resources/apiResource.ts +2 -1
  32. package/src/resources/baseRecordResource.ts +6 -23
  33. package/src/resources/multiRecordResource.ts +13 -3
  34. package/src/resources/singleRecordResource.ts +6 -1
  35. package/src/utils/__tests__/dirtyAwareApiClient.test.ts +392 -0
  36. package/src/utils/__tests__/openViewRouteState.test.ts +40 -0
  37. package/src/utils/__tests__/parsePathnameToViewParams.test.ts +36 -0
  38. package/src/utils/dataSourceDirty.ts +126 -0
  39. package/src/utils/dirtyAwareApiClient.ts +430 -0
  40. package/src/utils/index.ts +10 -0
  41. package/src/utils/openViewRouteState.ts +107 -0
  42. package/src/utils/parsePathnameToViewParams.ts +23 -1
  43. package/src/views/ViewNavigation.ts +6 -1
  44. package/src/views/__tests__/ViewNavigation.test.ts +15 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nocobase/flow-engine",
3
- "version": "2.2.0-beta.7",
3
+ "version": "2.2.0-beta.8",
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-beta.7",
12
- "@nocobase/shared": "2.2.0-beta.7",
11
+ "@nocobase/sdk": "2.2.0-beta.8",
12
+ "@nocobase/shared": "2.2.0-beta.8",
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": "e6a3fa8963a73cd9ddfc1273d71b0012483e1ad8"
40
+ "gitHead": "fa2502c1e9faf6d74b3f51b42dbc6546638d46af"
41
41
  }
@@ -14,6 +14,8 @@ import { FlowEngine } from '../flowEngine';
14
14
  import { FlowModel } from '../models/flowModel';
15
15
  import { RunJSContextRegistry } from '../runjs-context/registry';
16
16
  import { setupRunJSContexts } from '../runjs-context/setup';
17
+ import { createViewScopedEngine } from '../ViewScopedFlowEngine';
18
+ import { DATA_SOURCE_DIRTY_EVENT } from '../views/viewEvents';
17
19
 
18
20
  describe('FlowContext properties and methods', () => {
19
21
  it('should return static property value', () => {
@@ -1429,6 +1431,92 @@ describe('FlowEngine context', () => {
1429
1431
  expect(engine.context.appName).toBe('NocoBase');
1430
1432
  });
1431
1433
 
1434
+ it('ctx.api should return a dirty-aware wrapper for static api properties', async () => {
1435
+ const engine = new FlowEngine();
1436
+ const update = vi.fn(async () => ({ data: { data: { id: 1 } } }));
1437
+ const api = {
1438
+ auth: { locale: 'zh-CN' },
1439
+ request: vi.fn(async () => ({ data: { ok: true } })),
1440
+ resource: vi.fn(() => ({ update })),
1441
+ };
1442
+ engine.context.defineProperty('api', { value: api });
1443
+
1444
+ await engine.context.api.resource('posts').update({ filterByTk: 1, values: { title: 't' } });
1445
+
1446
+ expect(update).toHaveBeenCalledTimes(1);
1447
+ expect(engine.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
1448
+ expect(engine.context.api).toBe(engine.context.api);
1449
+ });
1450
+
1451
+ it('ctx.api should stay dirty-aware when resolved from a scoped context delegate', async () => {
1452
+ const root = new FlowEngine();
1453
+ const scoped = createViewScopedEngine(root);
1454
+ const update = vi.fn(async () => ({ data: { data: { id: 1 } } }));
1455
+ root.context.defineProperty('api', {
1456
+ value: {
1457
+ auth: { locale: 'zh-CN' },
1458
+ request: vi.fn(async () => ({ data: { ok: true } })),
1459
+ resource: vi.fn(() => ({ update })),
1460
+ },
1461
+ });
1462
+
1463
+ await scoped.context.api.resource('posts').update({ filterByTk: 1, values: { title: 't' } });
1464
+
1465
+ expect(update).toHaveBeenCalledTimes(1);
1466
+ expect(root.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
1467
+ expect(scoped.context.engine.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
1468
+ });
1469
+
1470
+ it('ctx.request should use the dirty-aware api wrapper', async () => {
1471
+ const engine = new FlowEngine();
1472
+ const request = vi.fn(async () => ({ data: { ok: true } }));
1473
+ engine.context.defineProperty('api', {
1474
+ value: {
1475
+ auth: { locale: 'zh-CN' },
1476
+ request,
1477
+ resource: vi.fn(),
1478
+ },
1479
+ });
1480
+
1481
+ await engine.context.request({
1482
+ resource: 'posts',
1483
+ action: 'update',
1484
+ headers: { 'X-Data-Source': 'analytics' },
1485
+ params: { filterByTk: 1 },
1486
+ } as any);
1487
+
1488
+ expect(request).toHaveBeenCalledTimes(1);
1489
+ expect(engine.getDataSourceDirtyVersion('analytics', 'posts')).toBe(1);
1490
+ });
1491
+
1492
+ it('ctx.request should use the caller context when resolved through a scoped delegate', async () => {
1493
+ const root = new FlowEngine();
1494
+ const scoped = createViewScopedEngine(root);
1495
+ const callerCtx = new FlowContext();
1496
+ const dirtyEvents: Array<{ dataSourceKey: string; resourceNames: string[] }> = [];
1497
+ const request = vi.fn(async () => ({ data: { ok: true } }));
1498
+ root.context.defineProperty('api', {
1499
+ value: {
1500
+ auth: { locale: 'zh-CN' },
1501
+ request,
1502
+ resource: vi.fn(),
1503
+ },
1504
+ });
1505
+ callerCtx.addDelegate(scoped.context);
1506
+ scoped.context.engine.emitter.on(DATA_SOURCE_DIRTY_EVENT, (event) => dirtyEvents.push(event));
1507
+
1508
+ await callerCtx.request({
1509
+ resource: 'posts',
1510
+ action: 'update',
1511
+ params: { filterByTk: 1 },
1512
+ } as any);
1513
+
1514
+ expect(request).toHaveBeenCalledTimes(1);
1515
+ expect(root.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
1516
+ expect(scoped.context.engine.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
1517
+ expect(dirtyEvents).toEqual([{ dataSourceKey: 'main', resourceNames: ['posts'] }]);
1518
+ });
1519
+
1432
1520
  it('ctx.sql should resolve template variables from caller context in delegate chain', async () => {
1433
1521
  const engine = new FlowEngine();
1434
1522
  const request = vi.fn(async () => ({ data: { data: [] } }));
@@ -11,6 +11,7 @@ import { describe, expect, it, vi } from 'vitest';
11
11
  import { FlowEngine } from '../flowEngine';
12
12
  import { MultiRecordResource } from '../resources/multiRecordResource';
13
13
  import { SingleRecordResource } from '../resources/singleRecordResource';
14
+ import { DATA_SOURCE_DIRTY_EVENT } from '../views/viewEvents';
14
15
 
15
16
  describe('FlowEngine dataSource dirty registry', () => {
16
17
  it('tracks versions per dataSourceKey + resourceName', () => {
@@ -60,4 +61,54 @@ describe('FlowEngine dataSource dirty registry', () => {
60
61
  // plus root collection (safety)
61
62
  expect(markSpy).toHaveBeenCalledWith('main', 'users');
62
63
  });
64
+
65
+ it('marks dirty once for record write helpers when using the dirty-aware context api', async () => {
66
+ const engine = new FlowEngine();
67
+ const request = vi.fn(async () => ({ data: { data: { id: 1 }, meta: {} } }));
68
+ const dirtyEvents: Array<{ dataSourceKey: string; resourceNames: string[] }> = [];
69
+ engine.context.defineProperty('api', {
70
+ value: {
71
+ auth: { locale: 'zh-CN' },
72
+ request,
73
+ resource: vi.fn(),
74
+ },
75
+ });
76
+ engine.emitter.on(DATA_SOURCE_DIRTY_EVENT, (event) => dirtyEvents.push(event));
77
+
78
+ const multi = engine.createResource(MultiRecordResource);
79
+ multi.setDataSourceKey('main').setResourceName('posts');
80
+ await multi.create({ title: 't' } as any, { refresh: false });
81
+
82
+ expect(request).toHaveBeenCalledTimes(1);
83
+ expect(engine.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
84
+ expect(dirtyEvents).toEqual([{ dataSourceKey: 'main', resourceNames: ['posts'] }]);
85
+
86
+ const single = engine.createResource(SingleRecordResource);
87
+ single.setDataSourceKey('main').setResourceName('posts').setFilterByTk(1);
88
+ await single.save({ title: 'u' } as any, { refresh: false });
89
+
90
+ expect(request).toHaveBeenCalledTimes(2);
91
+ expect(engine.getDataSourceDirtyVersion('main', 'posts')).toBe(2);
92
+ expect(dirtyEvents).toEqual([
93
+ { dataSourceKey: 'main', resourceNames: ['posts'] },
94
+ { dataSourceKey: 'main', resourceNames: ['posts'] },
95
+ ]);
96
+ });
97
+
98
+ it('still marks dirty for direct runAction writes', async () => {
99
+ const engine = new FlowEngine();
100
+ engine.context.defineProperty('api', {
101
+ value: {
102
+ auth: { locale: 'zh-CN' },
103
+ request: vi.fn(async () => ({ data: { data: { id: 1 }, meta: {} } })),
104
+ resource: vi.fn(),
105
+ },
106
+ });
107
+
108
+ const multi = engine.createResource(MultiRecordResource);
109
+ multi.setDataSourceKey('main').setResourceName('posts');
110
+ await multi.runAction('create', { data: { title: 't' } });
111
+
112
+ expect(engine.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
113
+ });
63
114
  });
@@ -8,9 +8,9 @@
8
8
  */
9
9
 
10
10
  import { describe, it, expect, beforeAll, vi } from 'vitest';
11
- import { FlowEngineContext, FlowRunJSContext } from '../flowContext';
11
+ import { FlowContext, FlowEngineContext, FlowRunJSContext } from '../flowContext';
12
+ import { RUNJS_OPEN_VIEW_ROUTE_STATE } from '../utils/openViewRouteState';
12
13
  import { FlowEngine } from '../flowEngine';
13
- import { FlowContext } from '../flowContext';
14
14
  import { setupRunJSContexts } from '../runjs-context/setup';
15
15
  import { createJSRunnerWithVersion } from '..';
16
16
  import { RunJSContextRegistry } from '../runjs-context/registry';
@@ -267,6 +267,19 @@ describe('RunJS Runtime Features', () => {
267
267
  expect(runCtx.libs.dayjs).toBeDefined();
268
268
  expect(runCtx.libs.antdIcons).toBeDefined();
269
269
  });
270
+
271
+ it('should mark ctx.openView calls with route state only when RunJS passes display overrides', async () => {
272
+ const parentCtx = new FlowContext();
273
+ const openView = vi.fn(async () => undefined);
274
+ parentCtx.defineMethod('openView', openView);
275
+
276
+ const runCtx = new FlowRunJSContext(parentCtx);
277
+ await (runCtx as any).openView('popup', { mode: 'dialog', size: 'large' });
278
+ await (runCtx as any).openView('popup', { filterByTk: 1 });
279
+
280
+ expect(openView.mock.calls[0][1][RUNJS_OPEN_VIEW_ROUTE_STATE]).toEqual({ mode: 'dialog', size: 'large' });
281
+ expect(Object.prototype.hasOwnProperty.call(openView.mock.calls[1][1], RUNJS_OPEN_VIEW_ROUTE_STATE)).toBe(false);
282
+ });
270
283
  });
271
284
 
272
285
  describe('Actual code execution', () => {
@@ -11,7 +11,7 @@ import type { CSSInterpolation, CSSObject } from '@ant-design/cssinjs';
11
11
  import { useStyleRegister } from '@ant-design/cssinjs';
12
12
  import { merge } from '@formily/shared';
13
13
  import type { ComponentTokenMap } from 'antd/es/theme/interface';
14
- import { useMemo, useContext } from 'react';
14
+ import { useContext, useRef } from 'react';
15
15
  import { ConfigProvider, theme } from 'antd';
16
16
 
17
17
  const usePrefixCls = (
@@ -83,6 +83,10 @@ type UseComponentStyleResult = {
83
83
  componentCls: string;
84
84
  rootPrefixCls: string;
85
85
  };
86
+ type WrapSSRCache = {
87
+ deps: unknown[];
88
+ wrapSSR: ReturnType<typeof useStyleRegister>;
89
+ };
86
90
 
87
91
  const genStyleHook = <ComponentName extends OverrideComponent>(
88
92
  component: ComponentName,
@@ -122,9 +126,18 @@ const genStyleHook = <ComponentName extends OverrideComponent>(
122
126
 
123
127
  // useStyleRegister 有 BUG,会导致重复渲染,所以这里做了一层缓存
124
128
  // 等 https://github.com/ant-design/cssinjs/pull/176 合并后,可以去掉这层缓存
125
- const memoizedWrapSSR = useMemo(() => {
126
- return wrapSSR;
127
- }, [theme, token, hashId, prefixCls, iconPrefixCls, rootPrefixCls, props]);
129
+ const wrapSSRDeps = [theme, token, hashId, prefixCls, iconPrefixCls, rootPrefixCls, props];
130
+ const wrapSSRCacheRef = useRef<WrapSSRCache>();
131
+ const currentCache = wrapSSRCacheRef.current;
132
+ let memoizedWrapSSR: ReturnType<typeof useStyleRegister> = currentCache?.wrapSSR || wrapSSR;
133
+
134
+ if (!currentCache || wrapSSRDeps.some((dep, index) => dep !== currentCache.deps[index])) {
135
+ wrapSSRCacheRef.current = {
136
+ deps: wrapSSRDeps,
137
+ wrapSSR,
138
+ };
139
+ memoizedWrapSSR = wrapSSR;
140
+ }
128
141
 
129
142
  return {
130
143
  wrapSSR: memoizedWrapSSR,
@@ -140,7 +153,7 @@ export const useMobileActionDrawerStyle = genStyleHook('nb-mobile-action-drawer'
140
153
  return {
141
154
  [componentCls]: {
142
155
  '.nb-mobile-action-drawer-header': {
143
- height: 'var(--nb-mobile-page-header-height)',
156
+ height: 'var(--nb-mobile-page-header-height, 46px)',
144
157
  display: 'flex',
145
158
  alignItems: 'center',
146
159
  justifyContent: 'space-between',
@@ -171,7 +184,10 @@ export const useMobileActionDrawerStyle = genStyleHook('nb-mobile-action-drawer'
171
184
  '.nb-mobile-action-drawer-body': {
172
185
  borderTopLeftRadius: 8,
173
186
  borderTopRightRadius: 8,
174
- maxHeight: 'calc(100% - var(--nb-mobile-page-header-height))',
187
+ maxHeight: 'calc(100vh - var(--nb-mobile-page-header-height, 46px))',
188
+ '@supports (height: 100dvh)': {
189
+ maxHeight: 'calc(100dvh - var(--nb-mobile-page-header-height, 46px))',
190
+ },
175
191
  overflowY: 'auto',
176
192
  overflowX: 'hidden',
177
193
  backgroundColor: token.colorBgLayout,
@@ -0,0 +1,103 @@
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, { CSSProperties, ReactNode } from 'react';
11
+ import { render, screen, waitFor } from '@testing-library/react';
12
+ import { ConfigProvider } from 'antd';
13
+ import { vi } from 'vitest';
14
+ import { MobilePopup } from '../MobilePopup';
15
+ import { useMobileActionDrawerStyle } from '../MobilePopup.style';
16
+
17
+ vi.mock('antd-mobile', () => ({
18
+ Popup: ({
19
+ bodyClassName,
20
+ bodyStyle,
21
+ children,
22
+ className,
23
+ visible,
24
+ }: {
25
+ bodyClassName?: string;
26
+ bodyStyle?: CSSProperties;
27
+ children?: ReactNode;
28
+ className?: string;
29
+ visible?: boolean;
30
+ }) =>
31
+ visible ? (
32
+ <div className={className} data-testid="popup-root">
33
+ <div className={`adm-popup-body ${bodyClassName || ''}`} data-testid="popup-body" style={bodyStyle}>
34
+ {children}
35
+ </div>
36
+ </div>
37
+ ) : null,
38
+ }));
39
+
40
+ vi.mock('antd-mobile-icons', () => ({
41
+ CloseOutline: () => <span data-testid="close-outline" />,
42
+ }));
43
+
44
+ vi.mock('react-i18next', () => ({
45
+ useTranslation: () => ({
46
+ t: (key: string) => key,
47
+ }),
48
+ }));
49
+
50
+ function StyleProbe() {
51
+ const { componentCls, hashId } = useMobileActionDrawerStyle();
52
+
53
+ return (
54
+ <div className={`${componentCls} ${hashId}`}>
55
+ <div className="nb-mobile-action-drawer-header" data-testid="drawer-header" />
56
+ <div className="nb-mobile-action-drawer-body" data-testid="drawer-body" />
57
+ </div>
58
+ );
59
+ }
60
+
61
+ function getInjectedStyles() {
62
+ return Array.from(document.querySelectorAll('style'))
63
+ .map((style) => style.textContent || '')
64
+ .join('\n')
65
+ .replace(/\s+/g, '');
66
+ }
67
+
68
+ describe('MobilePopup styles', () => {
69
+ it('mounts the action drawer class on the real popup body', async () => {
70
+ render(
71
+ <MobilePopup visible title="Edit" onClose={vi.fn()}>
72
+ <div style={{ height: 1200 }}>Long content</div>
73
+ </MobilePopup>,
74
+ );
75
+
76
+ const popupBody = await screen.findByTestId('popup-body');
77
+
78
+ expect(screen.getByTestId('popup-root')).toHaveClass('ant-nb-mobile-action-drawer');
79
+ expect(popupBody).toHaveClass('adm-popup-body');
80
+ expect(popupBody).toHaveClass('nb-mobile-action-drawer-body');
81
+ expect(popupBody).toHaveStyle({ padding: '0px' });
82
+ });
83
+
84
+ it('limits the mobile action drawer body by viewport height', async () => {
85
+ render(
86
+ <ConfigProvider>
87
+ <StyleProbe />
88
+ </ConfigProvider>,
89
+ );
90
+
91
+ await waitFor(() => {
92
+ expect(getInjectedStyles()).toContain('.nb-mobile-action-drawer-body');
93
+ });
94
+
95
+ const styles = getInjectedStyles();
96
+
97
+ expect(styles).toContain('height:var(--nb-mobile-page-header-height,46px)');
98
+ expect(styles).toContain('max-height:calc(100vh-var(--nb-mobile-page-header-height,46px))');
99
+ expect(styles).toContain('max-height:calc(100dvh-var(--nb-mobile-page-header-height,46px))');
100
+ expect(styles).toContain('overflow-y:auto');
101
+ expect(styles).not.toContain('max-height:calc(100%-var(--nb-mobile-page-header-height))');
102
+ });
103
+ });
@@ -9,7 +9,7 @@
9
9
 
10
10
  import { ISchema } from '@formily/json-schema';
11
11
  import { observable } from '@formily/reactive';
12
- import { APIClient, RequestOptions } from '@nocobase/sdk';
12
+ import type { APIClient, RequestOptions } from '@nocobase/sdk';
13
13
  import type { Router } from '@remix-run/router';
14
14
  import axios from 'axios';
15
15
  import { MessageInstance } from 'antd/es/message/interface';
@@ -39,9 +39,11 @@ import {
39
39
  extractUsedVariablePaths,
40
40
  FlowExitException,
41
41
  FLOW_ENGINE_NAMESPACE,
42
+ createOpenViewRouteState,
42
43
  isCtxDatePathPrefix,
43
44
  isCssFile,
44
45
  prepareRunJsCode,
46
+ RUNJS_OPEN_VIEW_ROUTE_STATE,
45
47
  resolveCtxDatePath,
46
48
  resolveDefaultParams,
47
49
  resolveExpressions,
@@ -51,6 +53,7 @@ import { FlowExitAllException } from './utils/exceptions';
51
53
  import { enqueueVariablesResolve, JSONValue } from './utils/params-resolvers';
52
54
  import type { RecordRef } from './utils/serverContextParams';
53
55
  import { buildServerContextParams as _buildServerContextParams } from './utils/serverContextParams';
56
+ import { getDirtyAwareApiClient } from './utils/dirtyAwareApiClient';
54
57
  import { inferRecordRef } from './utils/variablesParams';
55
58
  import { FlowView, FlowViewer } from './views/FlowView';
56
59
  import { RunJSContextRegistry, getModelClassName, type RunJSVersion } from './runjs-context/registry';
@@ -2909,19 +2912,20 @@ export class FlowContext {
2909
2912
 
2910
2913
  // 静态值
2911
2914
  if ('value' in options) {
2912
- return options.value;
2915
+ return key === 'api' ? getDirtyAwareApiClient(options.value, currentContext) : options.value;
2913
2916
  }
2914
2917
 
2915
2918
  // get 方法
2916
2919
  if (options.get) {
2917
2920
  if (options.cache === false) {
2918
- return options.get(currentContext);
2921
+ const value = options.get(currentContext);
2922
+ return key === 'api' ? getDirtyAwareApiClient(value, currentContext) : value;
2919
2923
  }
2920
2924
 
2921
2925
  const cacheKey = options.observable ? '_observableCache' : '_cache';
2922
2926
 
2923
2927
  if (key in this[cacheKey]) {
2924
- return this[cacheKey][key];
2928
+ return key === 'api' ? getDirtyAwareApiClient(this[cacheKey][key], currentContext) : this[cacheKey][key];
2925
2929
  }
2926
2930
 
2927
2931
  if (this._pending[key]) return this._pending[key];
@@ -2939,7 +2943,7 @@ export class FlowContext {
2939
2943
  (v) => {
2940
2944
  this[cacheKey][key] = v;
2941
2945
  delete this._pending[key];
2942
- return v;
2946
+ return key === 'api' ? getDirtyAwareApiClient(v, currentContext) : v;
2943
2947
  },
2944
2948
  (err) => {
2945
2949
  delete this._pending[key];
@@ -2951,7 +2955,7 @@ export class FlowContext {
2951
2955
 
2952
2956
  // sync 直接缓存
2953
2957
  this[cacheKey][key] = result;
2954
- return result;
2958
+ return key === 'api' ? getDirtyAwareApiClient(result, currentContext) : result;
2955
2959
  }
2956
2960
 
2957
2961
  return undefined;
@@ -3074,7 +3078,7 @@ class BaseFlowEngineContext extends FlowContext {
3074
3078
  this.defineMethod('getModel', (modelName: string, searchInPreviousEngines?: boolean) => {
3075
3079
  return this.engine.getModel(modelName, searchInPreviousEngines);
3076
3080
  });
3077
- this.defineMethod('request', (options: RequestOptions) => {
3081
+ this.defineMethod('request', function (this: FlowContext, options: RequestOptions) {
3078
3082
  const app = this.app as { getApiUrl?: (pathname?: string) => string } | undefined;
3079
3083
  if (typeof options?.url === 'string' && shouldBypassApiClient(options.url, app)) {
3080
3084
  return axios.request(options);
@@ -4600,6 +4604,27 @@ export class FlowRunJSContext extends FlowContext {
4600
4604
  this.defineProperty('ReactDOM', { value: ReactDOMShim });
4601
4605
 
4602
4606
  setupRunJSLibs(this);
4607
+ this.defineMethod('openView', async function (uid: string, options?: Record<PropertyKey, unknown>) {
4608
+ const delegateOpenView = (
4609
+ delegate as FlowContext & {
4610
+ openView?: (uid: string, options?: Record<PropertyKey, unknown>) => Promise<unknown>;
4611
+ }
4612
+ ).openView;
4613
+
4614
+ if (typeof delegateOpenView !== 'function') {
4615
+ throw new Error('ctx.openView is not available in current context.');
4616
+ }
4617
+
4618
+ const routeState = createOpenViewRouteState(options);
4619
+ if (!routeState) {
4620
+ return delegateOpenView(uid, options);
4621
+ }
4622
+
4623
+ return delegateOpenView(uid, {
4624
+ ...(options || {}),
4625
+ [RUNJS_OPEN_VIEW_ROUTE_STATE]: routeState,
4626
+ });
4627
+ });
4603
4628
 
4604
4629
  // Convenience: ctx.render(<App />[, container])
4605
4630
  // - container defaults to ctx.element if available
@@ -56,6 +56,7 @@
56
56
  "Replace current block with template?": "Replace current block with template?",
57
57
  "Replaced with template block": "Replaced with template block",
58
58
  "Render failed": "Render failed",
59
+ "Response record": "Response record",
59
60
  "Step configuration": "Step configuration",
60
61
  "Step parameter configuration": "Step parameter configuration",
61
62
  "Step with key {{stepKey}} not found": "Step with key {{stepKey}} not found",
@@ -60,6 +60,7 @@
60
60
  "Other blocks": "其他区块",
61
61
  "Previous step": "上一步",
62
62
  "Render failed": "渲染失败",
63
+ "Response record": "响应结果记录",
63
64
  "Step configuration": "步骤配置",
64
65
  "Step parameter configuration": "步骤参数配置",
65
66
  "Step with key {{stepKey}} not found": "未找到key为 {{stepKey}} 的步骤",
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { APIClient } from '@nocobase/sdk';
11
11
  import { FlowContext } from '../flowContext';
12
+ import { getDirtyAwareApiClient } from '../utils/dirtyAwareApiClient';
12
13
  import { FlowResource, ResourceError } from './flowResource';
13
14
 
14
15
  export class APIResource<TData = any> extends FlowResource<TData> {
@@ -33,7 +34,7 @@ export class APIResource<TData = any> extends FlowResource<TData> {
33
34
  }
34
35
 
35
36
  setAPIClient(api: APIClient) {
36
- this.api = api;
37
+ this.api = getDirtyAwareApiClient(api, this.context) as APIClient;
37
38
  return this;
38
39
  }
39
40
 
@@ -12,7 +12,7 @@ import _ from 'lodash';
12
12
  import { APIResource } from './apiResource';
13
13
  import { FilterItem } from './filterItem';
14
14
  import { ResourceError } from './flowResource';
15
- import { DATA_SOURCE_DIRTY_EVENT } from '../views/viewEvents';
15
+ import { markDataSourceDirty } from '../utils/dataSourceDirty';
16
16
 
17
17
  export abstract class BaseRecordResource<TData = any> extends APIResource<TData> {
18
18
  protected resourceName: string;
@@ -142,28 +142,11 @@ export abstract class BaseRecordResource<TData = any> extends APIResource<TData>
142
142
  * Used to coordinate "refresh on active" across view stacks.
143
143
  */
144
144
  protected markDataSourceDirty(resourceName?: string) {
145
- const engine = this.context.engine;
146
- if (!engine) return;
147
-
148
- const dataSourceKey = this.getDataSourceKey() || 'main';
149
- const resName = resourceName || this.getResourceName();
150
- if (!resName) return;
151
-
152
- const affectedResourceNames = new Set<string>([String(resName)]);
153
- // Optional safety: association resources like "users.profile" may impact parent collection views.
154
- if (typeof resName === 'string' && resName.includes('.')) {
155
- affectedResourceNames.add(resName.split('.')[0]);
156
- }
157
-
158
- for (const name of affectedResourceNames) {
159
- engine.markDataSourceDirty(dataSourceKey, name);
160
- }
161
-
162
- // Signal current view to re-evaluate dirty blocks (e.g., same-view sibling refresh).
163
- // This is emitted on the *current* engine emitter (view-scoped) so it won't affect other views.
164
- engine.emitter?.emit?.(DATA_SOURCE_DIRTY_EVENT, {
165
- dataSourceKey,
166
- resourceNames: Array.from(affectedResourceNames),
145
+ markDataSourceDirty({
146
+ engine: this.context.engine,
147
+ dataSourceKey: this.getDataSourceKey(),
148
+ resourceName: resourceName || this.getResourceName(),
149
+ includePreviousEngines: true,
167
150
  });
168
151
  }
169
152
 
@@ -10,6 +10,7 @@
10
10
  import { observable } from '@formily/reactive';
11
11
  import { AxiosRequestConfig } from 'axios';
12
12
  import _ from 'lodash';
13
+ import { SKIP_DATA_SOURCE_DIRTY } from '../utils/dirtyAwareApiClient';
13
14
  import { BaseRecordResource } from './baseRecordResource';
14
15
 
15
16
  export class MultiRecordResource<TDataItem = any> extends BaseRecordResource<TDataItem[]> {
@@ -113,7 +114,10 @@ export class MultiRecordResource<TDataItem = any> extends BaseRecordResource<TDa
113
114
 
114
115
  async create(data: TDataItem, options?: AxiosRequestConfig & { refresh?: boolean }): Promise<void> {
115
116
  const config = this.mergeRequestConfig({ data }, this.createActionOptions, options);
116
- const res = await this.runAction('create', config);
117
+ const res = await this.runAction('create', {
118
+ ...config,
119
+ [SKIP_DATA_SOURCE_DIRTY]: true,
120
+ });
117
121
  this.markDataSourceDirty();
118
122
  this.emit('saved', data);
119
123
  if (options?.refresh !== false) {
@@ -146,7 +150,10 @@ export class MultiRecordResource<TDataItem = any> extends BaseRecordResource<TDa
146
150
  this.updateActionOptions,
147
151
  options,
148
152
  );
149
- await this.runAction('update', config);
153
+ await this.runAction('update', {
154
+ ...config,
155
+ [SKIP_DATA_SOURCE_DIRTY]: true,
156
+ });
150
157
  this.markDataSourceDirty();
151
158
  this.emit('saved', data);
152
159
  await this.refresh();
@@ -172,7 +179,10 @@ export class MultiRecordResource<TDataItem = any> extends BaseRecordResource<TDa
172
179
  },
173
180
  options,
174
181
  );
175
- await this.runAction('destroy', config);
182
+ await this.runAction('destroy', {
183
+ ...config,
184
+ [SKIP_DATA_SOURCE_DIRTY]: true,
185
+ });
176
186
  this.markDataSourceDirty();
177
187
  const currentPage = this.getPage();
178
188
  const lastPage = Math.ceil((this.getCount() - _.castArray(filterByTk).length) / this.getPageSize());
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { AxiosRequestConfig } from 'axios';
11
11
  import _ from 'lodash';
12
+ import { SKIP_DATA_SOURCE_DIRTY } from '../utils/dirtyAwareApiClient';
12
13
  import { BaseRecordResource } from './baseRecordResource';
13
14
 
14
15
  export class SingleRecordResource<TData = any> extends BaseRecordResource<TData> {
@@ -43,6 +44,7 @@ export class SingleRecordResource<TData = any> extends BaseRecordResource<TData>
43
44
  const res = await this.runAction(actionName, {
44
45
  ...config,
45
46
  data: result,
47
+ [SKIP_DATA_SOURCE_DIRTY]: true,
46
48
  });
47
49
  // Mark as dirty before emitting/refreshing so other views can refresh when activated.
48
50
  this.markDataSourceDirty();
@@ -62,7 +64,10 @@ export class SingleRecordResource<TData = any> extends BaseRecordResource<TData>
62
64
  },
63
65
  options,
64
66
  );
65
- await this.runAction('destroy', config);
67
+ await this.runAction('destroy', {
68
+ ...config,
69
+ [SKIP_DATA_SOURCE_DIRTY]: true,
70
+ });
66
71
  this.markDataSourceDirty();
67
72
  this.setData(null);
68
73
  }