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

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 (61) hide show
  1. package/es/APIClient.d.ts +16 -0
  2. package/es/Application.d.ts +2 -1
  3. package/es/authRedirect.d.ts +9 -16
  4. package/es/components/form/EnvVariableInput.d.ts +8 -6
  5. package/es/components/form/VariableInput.d.ts +73 -0
  6. package/es/components/form/index.d.ts +1 -0
  7. package/es/components/form/table/RowOverlayPreview.d.ts +27 -0
  8. package/es/components/form/table/SelectionCell.d.ts +36 -0
  9. package/es/components/form/table/Table.d.ts +82 -0
  10. package/es/components/form/table/constants.d.ts +15 -0
  11. package/es/components/form/table/dnd/SortableRow.d.ts +40 -0
  12. package/es/components/form/table/dnd/index.d.ts +9 -0
  13. package/es/components/form/table/index.d.ts +9 -0
  14. package/es/components/form/table/styles.d.ts +41 -0
  15. package/es/components/form/table/utils.d.ts +44 -0
  16. package/es/components/index.d.ts +2 -0
  17. package/es/flow/components/TextAreaWithContextSelector.d.ts +15 -0
  18. package/es/flow/models/blocks/table/dragSort/dragSortComponents.d.ts +1 -6
  19. package/es/flow/models/blocks/table/dragSort/dragSortHooks.d.ts +5 -1
  20. package/es/flow-compat/passwordUtils.d.ts +1 -1
  21. package/es/index.d.ts +1 -0
  22. package/es/index.mjs +145 -78
  23. package/es/theme/globalStyles.d.ts +9 -0
  24. package/es/theme/index.d.ts +1 -0
  25. package/lib/index.js +161 -94
  26. package/package.json +8 -6
  27. package/src/APIClient.ts +68 -0
  28. package/src/Application.tsx +6 -2
  29. package/src/__tests__/authRedirect.test.ts +170 -64
  30. package/src/__tests__/globalDeps.test.ts +2 -0
  31. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +6 -6
  32. package/src/authRedirect.ts +23 -84
  33. package/src/components/form/EnvVariableInput.tsx +11 -46
  34. package/src/components/form/VariableInput.tsx +177 -0
  35. package/src/components/form/__tests__/EnvVariableInput.test.tsx +175 -0
  36. package/src/components/form/index.tsx +1 -0
  37. package/src/components/form/table/RowOverlayPreview.tsx +51 -0
  38. package/src/components/form/table/SelectionCell.tsx +72 -0
  39. package/src/components/form/table/Table.tsx +279 -0
  40. package/src/components/form/table/__tests__/Table.pagination.test.tsx +80 -0
  41. package/src/components/form/table/constants.ts +16 -0
  42. package/src/components/form/table/dnd/SortableRow.tsx +106 -0
  43. package/src/components/form/table/dnd/index.ts +10 -0
  44. package/src/components/form/table/index.tsx +13 -0
  45. package/src/components/form/table/styles.ts +110 -0
  46. package/src/components/form/table/utils.ts +75 -0
  47. package/src/components/index.ts +2 -0
  48. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuModels.tsx +2 -0
  49. package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.test.ts +111 -0
  50. package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.ts +2 -1
  51. package/src/flow/components/TextAreaWithContextSelector.tsx +30 -6
  52. package/src/flow/components/code-editor/__tests__/useCodeRunner.test.tsx +81 -0
  53. package/src/flow/components/code-editor/hooks/useCodeRunner.ts +34 -2
  54. package/src/flow/models/blocks/table/dragSort/dragSortComponents.tsx +1 -81
  55. package/src/flow/models/fields/JSEditableFieldModel.tsx +107 -7
  56. package/src/flow/models/fields/__tests__/JSEditableFieldModel.test.tsx +97 -0
  57. package/src/index.ts +1 -0
  58. package/src/nocobase-buildin-plugin/index.tsx +4 -4
  59. package/src/theme/globalStyles.ts +21 -0
  60. package/src/theme/index.tsx +1 -0
  61. package/src/utils/globalDeps.ts +5 -1
@@ -0,0 +1,75 @@
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 type React from 'react';
11
+
12
+ /**
13
+ * Same shape as antd Table's `rowKey` prop — either a record key name or a
14
+ * function. Hoisted here so utilities and `Table.tsx` agree on the contract.
15
+ */
16
+ export type RowKey<RecordType extends object> =
17
+ | (keyof RecordType & (string | number))
18
+ | ((record: RecordType, index?: number) => React.Key);
19
+
20
+ /**
21
+ * Read a stable row id off a record. Mirrors antd Table's rowKey resolution
22
+ * but normalises non-primitive ids to strings so `data-row-key` attributes
23
+ * and `useSortable({ id })` agree on equality.
24
+ */
25
+ export function readRowKey<RecordType extends object>(
26
+ record: RecordType,
27
+ rowKey: RowKey<RecordType>,
28
+ index?: number,
29
+ ): React.Key | undefined {
30
+ if (typeof rowKey === 'function') {
31
+ return rowKey(record, index);
32
+ }
33
+ const value = record[rowKey] as unknown;
34
+ if (value == null) return undefined;
35
+ return typeof value === 'string' || typeof value === 'number' ? value : String(value);
36
+ }
37
+
38
+ /**
39
+ * Pixel-perfect snapshot of a rendered `<tr>` for the drag overlay clone.
40
+ * Contains everything needed to rebuild a visually identical floating row
41
+ * without re-running antd's column layout pass.
42
+ */
43
+ export type RowSnapshot = {
44
+ /** outerHTML of the source `<tr>`, captured at drag start. */
45
+ html: string;
46
+ /** Per-cell pixel widths (in DOM order) so the clone can fix them via `<col>`. */
47
+ cellWidths: number[];
48
+ /** Total row width — used as the wrapper width so the clone matches source horizontally. */
49
+ totalWidth: number;
50
+ /** Total row height — applied to the clone so cell padding matches the source row exactly. */
51
+ totalHeight: number;
52
+ };
53
+
54
+ /**
55
+ * Snapshot the source `<tr>` so the drag overlay can render a pixel-accurate
56
+ * floating clone. We can't reliably recompute the layout from `columns` alone
57
+ * — antd auto-sizes columns at runtime based on content + the surrounding
58
+ * container — so we read the rendered widths off the DOM at drag start. The
59
+ * row height is captured too because antd's cell padding rules are scoped to
60
+ * a selector chain we strip in the clone.
61
+ */
62
+ export function snapshotSourceRow(rowKey: string): RowSnapshot | null {
63
+ if (typeof document === 'undefined') return null;
64
+ // `CSS.escape` is in lib.dom and shipped in every browser we target; the
65
+ // guard is purely a belt-and-suspenders against exotic test environments
66
+ // where `window.CSS` may be absent.
67
+ const cssGlobal: { escape?: (value: string) => string } | undefined =
68
+ typeof window !== 'undefined' ? window.CSS : undefined;
69
+ const escaped = cssGlobal?.escape ? cssGlobal.escape(rowKey) : rowKey;
70
+ const sourceRow = document.querySelector(`tr[data-row-key="${escaped}"]`) as HTMLTableRowElement | null;
71
+ if (!sourceRow) return null;
72
+ const cellWidths = Array.from(sourceRow.cells).map((cell) => cell.getBoundingClientRect().width);
73
+ const rect = sourceRow.getBoundingClientRect();
74
+ return { html: sourceRow.outerHTML, cellWidths, totalWidth: rect.width, totalHeight: rect.height };
75
+ }
@@ -9,6 +9,8 @@
9
9
 
10
10
  export * from './AppComponents';
11
11
  export * from './BlankComponent';
12
+ export * from './form/table/dnd';
12
13
  export * from './form';
13
14
  export * from './Icon';
14
15
  export * from './RouterContextCleaner';
16
+ export * from './form/table';
@@ -681,6 +681,7 @@ export class AdminLayoutMenuItemModel extends FlowModel<AdminLayoutMenuItemStruc
681
681
  AdminLayoutMenuItemModel.registerFlow({
682
682
  key: 'menuCreation',
683
683
  title: 'Add menu item',
684
+ manual: true,
684
685
  steps: {
685
686
  basic: {
686
687
  title: 'Add menu item',
@@ -697,6 +698,7 @@ AdminLayoutMenuItemModel.registerFlow({
697
698
  AdminLayoutMenuItemModel.registerFlow({
698
699
  key: 'menuSettings',
699
700
  title: 'Menu settings',
701
+ manual: true,
700
702
  steps: {
701
703
  edit: {
702
704
  title: 'Edit',
@@ -249,4 +249,115 @@ describe('resolveAdminRouteRuntimeTarget', () => {
249
249
  expect(toRouterNavigationPath('/nocobase/v2/admin/page-1', '/nocobase/v2')).toBe('/admin/page-1');
250
250
  expect(toRouterNavigationPath('/admin/page-1', '/nocobase/v2')).toBe('/admin/page-1');
251
251
  });
252
+
253
+ describe('v2 sub-app context (router basename contains /apps/<id>/)', () => {
254
+ const subApp = {
255
+ getPublicPath: () => '/nocobase/v2/',
256
+ router: {
257
+ getBasename: () => '/nocobase/v2/apps/test-app/',
258
+ },
259
+ } as any;
260
+
261
+ it('should resolve flowPage runtime path under sub-app basename', () => {
262
+ expect(
263
+ resolveAdminRouteRuntimeTarget({
264
+ app: subApp,
265
+ route: {
266
+ type: NocoBaseDesktopRouteType.flowPage,
267
+ schemaUid: 'fp1',
268
+ },
269
+ }),
270
+ ).toEqual({
271
+ runtimePath: '/nocobase/v2/apps/test-app/admin/fp1',
272
+ navigationMode: 'spa',
273
+ isLegacy: false,
274
+ reason: 'ok',
275
+ });
276
+ });
277
+
278
+ it('should resolve group DFS to first flowPage under sub-app basename', () => {
279
+ const route: NocoBaseDesktopRoute = {
280
+ type: NocoBaseDesktopRouteType.group,
281
+ children: [
282
+ {
283
+ type: NocoBaseDesktopRouteType.tabs,
284
+ schemaUid: 'tabs-1',
285
+ },
286
+ {
287
+ type: NocoBaseDesktopRouteType.group,
288
+ children: [
289
+ {
290
+ type: NocoBaseDesktopRouteType.page,
291
+ schemaUid: 'legacy-2',
292
+ },
293
+ {
294
+ type: NocoBaseDesktopRouteType.flowPage,
295
+ schemaUid: 'nested-fp',
296
+ },
297
+ ],
298
+ },
299
+ ],
300
+ };
301
+
302
+ expect(resolveAdminRouteRuntimeTarget({ app: subApp, route })).toEqual({
303
+ runtimePath: '/nocobase/v2/apps/test-app/admin/nested-fp',
304
+ navigationMode: 'spa',
305
+ isLegacy: false,
306
+ reason: 'ok',
307
+ });
308
+ });
309
+
310
+ it('should strip sub-app basename when converting to router internal path', () => {
311
+ expect(toRouterNavigationPath('/nocobase/v2/apps/test-app/admin/page-1', '/nocobase/v2/apps/test-app')).toBe(
312
+ '/admin/page-1',
313
+ );
314
+ expect(toRouterNavigationPath('/nocobase/v2/apps/test-app/admin/page-1', '/nocobase/v2/apps/test-app/')).toBe(
315
+ '/admin/page-1',
316
+ );
317
+ });
318
+ });
319
+
320
+ describe('fallback when router basename is missing', () => {
321
+ it('should fall back to publicPath when router is undefined', () => {
322
+ const appNoRouter = {
323
+ getPublicPath: () => '/nocobase/v2/',
324
+ router: undefined,
325
+ } as any;
326
+
327
+ expect(
328
+ resolveAdminRouteRuntimeTarget({
329
+ app: appNoRouter,
330
+ route: {
331
+ type: NocoBaseDesktopRouteType.flowPage,
332
+ schemaUid: 'fp1',
333
+ },
334
+ }),
335
+ ).toMatchObject({
336
+ runtimePath: '/nocobase/v2/admin/fp1',
337
+ reason: 'ok',
338
+ });
339
+ });
340
+
341
+ it('should fall back to publicPath when getBasename returns undefined', () => {
342
+ const appNoBasename = {
343
+ getPublicPath: () => '/nocobase/v2/',
344
+ router: {
345
+ getBasename: () => undefined,
346
+ },
347
+ } as any;
348
+
349
+ expect(
350
+ resolveAdminRouteRuntimeTarget({
351
+ app: appNoBasename,
352
+ route: {
353
+ type: NocoBaseDesktopRouteType.flowPage,
354
+ schemaUid: 'fp1',
355
+ },
356
+ }),
357
+ ).toMatchObject({
358
+ runtimePath: '/nocobase/v2/admin/fp1',
359
+ reason: 'ok',
360
+ });
361
+ });
362
+ });
252
363
  });
@@ -7,6 +7,7 @@
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
9
 
10
+ import { getV2EffectiveBasePath } from '../../../authRedirect';
10
11
  import type { BaseApplication } from '../../../BaseApplication';
11
12
  import { NocoBaseDesktopRouteType, type NocoBaseDesktopRoute } from '../../../flow-compat';
12
13
 
@@ -106,7 +107,7 @@ function joinRootRelativePath(basePath: string, pathname: string) {
106
107
  }
107
108
 
108
109
  function getV2AdminPath(app: ResolveAdminRouteRuntimeTargetOptions['app'], schemaUid: string) {
109
- return joinRootRelativePath(app.getPublicPath(), `/admin/${schemaUid}`);
110
+ return joinRootRelativePath(getV2EffectiveBasePath(app), `/admin/${schemaUid}`);
110
111
  }
111
112
 
112
113
  function appendLocationState(pathname: string, location?: LocationLike) {
@@ -20,7 +20,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
20
20
  import { Button, Input } from 'antd';
21
21
  import { css } from '@emotion/css';
22
22
  import type { TextAreaRef } from 'antd/es/input/TextArea';
23
- import { FlowContextSelector, useFlowContext } from '@nocobase/flow-engine';
23
+ import { FlowContextSelector, useFlowContext, type MetaTreeNode } from '@nocobase/flow-engine';
24
24
 
25
25
  export interface TextAreaWithContextSelectorProps {
26
26
  value?: string;
@@ -29,6 +29,20 @@ export interface TextAreaWithContextSelectorProps {
29
29
  rows?: number;
30
30
  maxRows?: number;
31
31
  style?: React.CSSProperties;
32
+ disabled?: boolean;
33
+ /**
34
+ * Custom meta tree for the variable picker. Accepts an array, a sync getter,
35
+ * or an async getter — same shape as `FlowContextSelector`'s `metaTree`. If
36
+ * omitted, the full `ctx.getPropertyMetaTree()` is used (legacy default).
37
+ */
38
+ metaTree?: MetaTreeNode[] | (() => MetaTreeNode[] | Promise<MetaTreeNode[]>);
39
+ /**
40
+ * Format a picked meta node into the string inserted at the caret. When
41
+ * omitted, the FlowContextSelector default (`{{ ctx.X.Y }}`) is used.
42
+ * Override to match a different storage convention — e.g. NocoBase server
43
+ * templates use `{{$X.Y}}` without the `ctx.` prefix.
44
+ */
45
+ formatPathToValue?: (meta: MetaTreeNode) => string;
32
46
  }
33
47
 
34
48
  /**
@@ -41,6 +55,9 @@ export const TextAreaWithContextSelector: React.FC<TextAreaWithContextSelectorPr
41
55
  rows = 3,
42
56
  maxRows = 24,
43
57
  style,
58
+ disabled,
59
+ metaTree,
60
+ formatPathToValue,
44
61
  }) => {
45
62
  const flowCtx = useFlowContext();
46
63
  const [innerValue, setInnerValue] = useState<string>(value || '');
@@ -76,10 +93,11 @@ export const TextAreaWithContextSelector: React.FC<TextAreaWithContextSelectorPr
76
93
  const next = prev.slice(0, start) + toInsert + prev.slice(end);
77
94
  setInnerValue(next);
78
95
  onChange?.(next);
79
- // 恢复光标位置并聚焦
96
+ // 插入后选中刚插入的变量文本,与 v1 RawTextArea 行为一致:
97
+ // 用户可立即按删除键移除整段变量,或继续输入直接替换。
80
98
  requestAnimationFrame(() => {
81
99
  const pos = start + (toInsert?.length || 0);
82
- el.setSelectionRange(pos, pos);
100
+ el.setSelectionRange(start, pos);
83
101
  el.focus();
84
102
  });
85
103
  },
@@ -94,8 +112,8 @@ export const TextAreaWithContextSelector: React.FC<TextAreaWithContextSelectorPr
94
112
  [insertAtCaret],
95
113
  );
96
114
 
97
- // 使用函数形式提供变量树,保证与运行时上下文一致
98
- const metaTree = useMemo(() => () => flowCtx.getPropertyMetaTree?.(), [flowCtx]);
115
+ // 使用函数形式提供变量树,保证与运行时上下文一致;当外部传入则尊重外部值。
116
+ const resolvedMetaTree = useMemo(() => metaTree ?? (() => flowCtx.getPropertyMetaTree?.()), [flowCtx, metaTree]);
99
117
 
100
118
  return (
101
119
  <div style={{ position: 'relative', width: '100%', ...style }}>
@@ -105,6 +123,7 @@ export const TextAreaWithContextSelector: React.FC<TextAreaWithContextSelectorPr
105
123
  onChange={handleTextChange}
106
124
  autoSize={{ minRows: rows, maxRows }}
107
125
  placeholder={placeholder}
126
+ disabled={disabled}
108
127
  style={{ width: '100%' }}
109
128
  />
110
129
  <div
@@ -116,7 +135,12 @@ export const TextAreaWithContextSelector: React.FC<TextAreaWithContextSelectorPr
116
135
  lineHeight: 0,
117
136
  }}
118
137
  >
119
- <FlowContextSelector metaTree={metaTree} onChange={(val) => handleVariableSelected(val)}>
138
+ <FlowContextSelector
139
+ metaTree={resolvedMetaTree}
140
+ disabled={disabled}
141
+ formatPathToValue={formatPathToValue}
142
+ onChange={(val) => handleVariableSelected(val)}
143
+ >
120
144
  <Button
121
145
  type="default"
122
146
  style={{ fontStyle: 'italic', fontFamily: 'New York, Times New Roman, Times, serif' }}
@@ -14,6 +14,7 @@ import { render, waitFor } from '@testing-library/react';
14
14
  import { App, ConfigProvider } from 'antd';
15
15
  import { useCodeRunner } from '../hooks/useCodeRunner';
16
16
  import {
17
+ FlowContext,
17
18
  FlowEngine,
18
19
  FlowModel,
19
20
  FlowEngineProvider,
@@ -21,6 +22,7 @@ import {
21
22
  ElementProxy,
22
23
  createSafeWindow,
23
24
  createSafeDocument,
25
+ createViewScopedEngine,
24
26
  } from '@nocobase/flow-engine';
25
27
  import { JSEditableFieldModel } from '../../../models/fields/JSEditableFieldModel';
26
28
 
@@ -31,6 +33,20 @@ class DummyJsAutoModel extends FlowModel {
31
33
  }
32
34
  }
33
35
 
36
+ function registerRunJsPreviewFlow(model: FlowModel) {
37
+ model.registerFlow('jsSettings', {
38
+ steps: {
39
+ runJs: {
40
+ useRawParams: true,
41
+ async handler(ctx) {
42
+ const code = ctx?.inputArgs?.preview?.code || '';
43
+ return ctx.runjs(code, undefined, { preprocessTemplates: true });
44
+ },
45
+ },
46
+ },
47
+ });
48
+ }
49
+
34
50
  describe('useCodeRunner (beforeRender)', () => {
35
51
  it('logs success and captures console output', async () => {
36
52
  const engine = new FlowEngine();
@@ -192,6 +208,71 @@ describe('useCodeRunner (beforeRender)', () => {
192
208
  });
193
209
  });
194
210
 
211
+ it('keeps popup context when a top scoped engine has another model with the same uid', async () => {
212
+ const engine = new FlowEngine();
213
+ engine.registerModels({ DummyJsAutoModel });
214
+ const model = engine.createModel<DummyJsAutoModel>({ use: 'DummyJsAutoModel', uid: 'same-popup-uid' });
215
+ model.context.defineProperty('popup', {
216
+ value: {
217
+ uid: 'popup-view',
218
+ record: { username: 'alice' },
219
+ },
220
+ });
221
+ registerRunJsPreviewFlow(model);
222
+
223
+ const scopedEngine = createViewScopedEngine(engine);
224
+ const topModel = scopedEngine.createModel<DummyJsAutoModel>({ use: 'DummyJsAutoModel', uid: 'same-popup-uid' });
225
+ registerRunJsPreviewFlow(topModel);
226
+
227
+ const { result } = renderHook(() => useCodeRunner(model.context, 'v1'));
228
+ let runResult: any;
229
+ await act(async () => {
230
+ runResult = await result.current.run(`
231
+ const currentUsername = await ctx.getVar('ctx.popup.record.username');
232
+ console.log(currentUsername);
233
+ return currentUsername;
234
+ `);
235
+ });
236
+
237
+ expect(runResult?.success).toBe(true);
238
+ expect(runResult?.value).toBe('alice');
239
+ expect(result.current.logs.some((l) => l.level === 'log' && l.msg.includes('alice'))).toBe(true);
240
+ });
241
+
242
+ it('runs direct event-flow previews against the popup-bound model context when the settings view has no popup', async () => {
243
+ const engine = new FlowEngine();
244
+ engine.registerModels({ DummyJsAutoModel });
245
+ const model = engine.createModel<DummyJsAutoModel>({ use: 'DummyJsAutoModel', uid: 'direct-popup-uid' });
246
+ model.context.defineProperty('popup', {
247
+ value: {
248
+ uid: 'popup-view',
249
+ record: { username: 'alice' },
250
+ },
251
+ });
252
+
253
+ const scopedEngine = createViewScopedEngine(engine);
254
+ scopedEngine.createModel<DummyJsAutoModel>({ use: 'DummyJsAutoModel', uid: 'direct-popup-uid' });
255
+
256
+ const settingsCtx = new FlowContext();
257
+ settingsCtx.defineProperty('engine', { value: scopedEngine });
258
+ settingsCtx.defineProperty('popup', { value: undefined });
259
+ settingsCtx.addDelegate(model.context);
260
+
261
+ const { result } = renderHook(() => useCodeRunner(settingsCtx as any, 'v1'));
262
+ let runResult: any;
263
+ await act(async () => {
264
+ runResult = await result.current.run(`
265
+ const currentUsername = await ctx.getVar('ctx.popup.record.username');
266
+ console.log(currentUsername);
267
+ return currentUsername;
268
+ `);
269
+ });
270
+
271
+ expect(runResult?.success).toBe(true);
272
+ expect(runResult?.value).toBe('alice');
273
+ expect(result.current.logs.some((l) => l.level === 'log' && l.msg.includes('alice'))).toBe(true);
274
+ });
275
+
195
276
  it('compiles JSX in preview and renders antd Input without syntax error', async () => {
196
277
  const engine = new FlowEngine();
197
278
  engine.registerModels({ DummyJsAutoModel });
@@ -112,6 +112,31 @@ function createLoggerWrapperFactory(append: (level: RunLog['level'], args: any[]
112
112
  return wrap;
113
113
  }
114
114
 
115
+ function hasPopupViewMarkers(view: any): boolean {
116
+ const inputArgs = view?.inputArgs || {};
117
+ const openerUids = inputArgs?.openerUids;
118
+ const viewStack = view?.navigation?.viewStack;
119
+
120
+ return (Array.isArray(openerUids) && openerUids.length > 0) || (Array.isArray(viewStack) && viewStack.length >= 2);
121
+ }
122
+
123
+ async function hasPreviewPopupContext(ctx: any): Promise<boolean> {
124
+ if (!ctx) return false;
125
+
126
+ try {
127
+ const popup = await ctx.popup;
128
+ if (popup) return true;
129
+ } catch (_) {
130
+ // ignore unavailable popup getters
131
+ }
132
+
133
+ try {
134
+ return hasPopupViewMarkers(await ctx.view);
135
+ } catch (_) {
136
+ return false;
137
+ }
138
+ }
139
+
115
140
  export function useCodeRunner(hostCtx: FlowModelContext, version = 'v1') {
116
141
  const [logs, setLogs] = useState<RunLog[]>([]);
117
142
  const [running, setRunning] = useState(false);
@@ -131,7 +156,14 @@ export function useCodeRunner(hostCtx: FlowModelContext, version = 'v1') {
131
156
  const model = hostCtx?.model;
132
157
  if (!model) throw new Error('No model in FlowContext');
133
158
  const engine = hostCtx.engine;
134
- const runtimeModel = engine.getModel(model.uid, true) || model;
159
+ const globalRuntimeModel = engine.getModel(model.uid, true) || model;
160
+ const [hostHasPopupContext, modelHasPopupContext] = await Promise.all([
161
+ hasPreviewPopupContext(hostCtx),
162
+ hasPreviewPopupContext(model.context),
163
+ ]);
164
+ const shouldPreservePopupModel = hostHasPopupContext || modelHasPopupContext;
165
+ const runtimeModel = shouldPreservePopupModel ? model : globalRuntimeModel;
166
+ const directRunCtx = hostHasPopupContext ? hostCtx : modelHasPopupContext ? model.context : hostCtx;
135
167
 
136
168
  const nativeConsole: Record<RunLog['level'], (...args: any[]) => void> = {
137
169
  log: (...args) => console.log(...args),
@@ -255,7 +287,7 @@ export function useCodeRunner(hostCtx: FlowModelContext, version = 'v1') {
255
287
  if (!flow) {
256
288
  // 无可用流程(典型场景:联动规则里的 RunJS 预览),直接在当前上下文执行代码
257
289
  const navigator = createSafeNavigator();
258
- await hostCtx.runjs(
290
+ await directRunCtx.runjs(
259
291
  code,
260
292
  { window: createSafeWindow({ navigator }), document: createSafeDocument(), navigator },
261
293
  { version },
@@ -7,84 +7,4 @@
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
9
 
10
- import { MenuOutlined } from '@ant-design/icons';
11
- import { TinyColor } from '@ctrl/tinycolor';
12
- import { useSortable } from '@dnd-kit/sortable';
13
- import { css } from '@emotion/css';
14
- import { theme } from 'antd';
15
- import classNames from 'classnames';
16
- import React, { useMemo } from 'react';
17
-
18
- type DragSortRowContextValue = {
19
- attributes?: Record<string, unknown>;
20
- listeners?: Record<string, unknown>;
21
- setActivatorNodeRef?: (node: HTMLElement | null) => void;
22
- };
23
-
24
- const DragSortRowContext = React.createContext<DragSortRowContextValue | null>(null);
25
-
26
- const sortHandleClass = css`
27
- display: inline-flex;
28
- align-items: center;
29
- justify-content: center;
30
- cursor: grab;
31
- `;
32
-
33
- export const SortHandle: React.FC<{ id: string | number; style?: React.CSSProperties }> = (props) => {
34
- const { id: _id, ...otherProps } = props;
35
- const dragSortContext = React.useContext(DragSortRowContext);
36
- // return <MenuOutlined ref={setNodeRef} {...otherProps} {...listeners} style={{ cursor: 'grab' }} />;
37
- return (
38
- <span
39
- ref={dragSortContext?.setActivatorNodeRef}
40
- {...dragSortContext?.attributes}
41
- {...dragSortContext?.listeners}
42
- {...otherProps}
43
- className={classNames(sortHandleClass)}
44
- >
45
- <MenuOutlined />
46
- </span>
47
- );
48
- };
49
-
50
- export const SortableRow = (props) => {
51
- const { token }: any = theme.useToken();
52
- const id = props['data-row-key']?.toString();
53
- const { setNodeRef, setActivatorNodeRef, attributes, listeners, active, over } = useSortable({
54
- id,
55
- });
56
- const { rowIndex, ...others } = props;
57
- const isOver = over?.id === id;
58
- const classObj = useMemo(() => {
59
- const borderColor = new TinyColor(token.colorPrimary).setAlpha(0.6).toHex8String();
60
- return {
61
- topActiveClass: css`
62
- & > td {
63
- border-top: 2px solid ${borderColor} !important;
64
- }
65
- `,
66
- bottomActiveClass: css`
67
- & > td {
68
- border-bottom: 2px solid ${borderColor} !important;
69
- }
70
- `,
71
- };
72
- }, [token.colorPrimary]);
73
-
74
- const className =
75
- (active?.data.current?.sortable.index ?? -1) > rowIndex ? classObj.topActiveClass : classObj.bottomActiveClass;
76
-
77
- const row = (
78
- <DragSortRowContext.Provider value={{ listeners, setActivatorNodeRef }}>
79
- <tr
80
- ref={(node) => {
81
- setNodeRef(node);
82
- }}
83
- {...others}
84
- className={classNames(props.className, { [className]: active && isOver })}
85
- />
86
- </DragSortRowContext.Provider>
87
- );
88
-
89
- return row;
90
- };
10
+ export { DragSortRowContext, SortHandle, SortableRow } from '../../../../../components/form/table/dnd/SortableRow';