From 3e6a7d83a5fc3d3e6e247320becd1a0828e303b3 Mon Sep 17 00:00:00 2001 From: gchust Date: Mon, 13 Oct 2025 16:10:52 +0800 Subject: [PATCH] feat: improve js code editor (#7559) * feat: improve runjs context * fix: circle dependencies * chore: improve code * feat: improve runjs context * fix: tests failure * chore: improve code * chore: clean code * chore: improve doc * feat: improve snippets locale * feat: improve code editor completions * feat: improve completion * chore: add tests * chore: add tests * chore: improve snippets * chore: improve snippets * chore: improve snippets * fix: bug * chore: update snippets * chore: update snippets * fix: can't use sn to filter snippets * chore: update snippets * feat: support import esm in runjs * chore: remove some snippets * chore: improve snippets * fix: ant design theme * fix: incorrect error line * fix: preview run not working in some case * chore: improve linters * fix: linter * fix: tests error * fix: build error --- .../__tests__/SnippetsDrawer.test.tsx | 9 +- .../__tests__/javascriptHtmlTemplate.test.ts | 12 +- .../__tests__/runjsCompletions.test.ts | 69 ++- .../__tests__/runjsLocales.test.ts | 24 + .../code-editor/completions/index.ts | 202 ------- .../code-editor/core/EditorCore.tsx | 10 +- .../code-editor/hooks/useCodeRunner.ts | 25 +- .../hooks/useRunJSDocCompletions.ts | 6 +- .../src/flow/components/code-editor/index.tsx | 24 +- .../code-editor/javascriptCompletion.ts | 98 +++- .../src/flow/components/code-editor/linter.ts | 165 +++++- .../code-editor/panels/LogsPanel.tsx | 5 +- .../code-editor/panels/SnippetsDrawer.tsx | 78 +-- .../code-editor/runjsCompletions.ts | 201 ++++--- .../components/code-editor/snippets/loader.ts | 175 ------ .../flowContextCreateJSRunner.test.ts | 163 ++++++ .../__tests__/flowRunJSContextDefine.test.ts | 508 ++++++++++++++++++ .../src/__tests__/runjsContext.test.ts | 251 +++++++-- .../runjsContextImplementations.test.ts | 217 ++++++++ .../src/__tests__/runjsContextRuntime.test.ts | 269 ++++++++++ .../src/__tests__/runjsEdgeCases.test.ts | 281 ++++++++++ .../src/__tests__/runjsLocales.test.ts | 36 ++ .../__tests__/runjsRuntimeFeatures.test.ts | 449 ++++++++++++++++ .../src/__tests__/runjsSnippets.test.ts | 140 +++++ packages/core/flow-engine/src/flowContext.ts | 281 ++++++---- packages/core/flow-engine/src/index.ts | 10 +- .../contexts/FlowRunJSContext.ts | 190 ------- .../contexts/FormJSFieldItemRunJSContext.ts | 49 +- .../contexts/JSBlockRunJSContext.ts | 80 +-- .../JSCollectionActionRunJSContext.ts | 31 +- .../contexts/JSColumnRunJSContext.ts | 58 ++ .../contexts/JSFieldRunJSContext.ts | 55 +- .../contexts/JSItemRunJSContext.ts | 46 +- .../contexts/JSRecordActionRunJSContext.ts | 35 +- .../contexts/LinkageRunJSContext.ts | 35 -- .../src/runjs-context/contexts/base.ts | 155 ++++++ .../flow-engine/src/runjs-context/helpers.ts | 62 +-- .../flow-engine/src/runjs-context/index.ts | 20 - .../flow-engine/src/runjs-context/registry.ts | 63 +-- .../flow-engine/src/runjs-context/setup.ts | 51 ++ .../global/api-request-get.snippet.ts | 20 - .../global/api-request-post.snippet.ts | 20 - .../snippets/global/api-request.snippet.ts | 38 ++ .../global/console-log-ctx.snippet.ts | 19 - .../global/copy-record-json.snippet.ts | 21 - .../global/copy-to-clipboard.snippet.ts | 21 - .../snippets/global/import-esm.snippet.ts | 39 ++ .../global/log-json-record.snippet.ts | 21 - .../snippets/global/message-error.snippet.ts | 6 + .../global/message-success.snippet.ts | 6 + .../global/notification-open.snippet.ts | 12 +- .../global/open-view-dialog.snippet.ts | 13 +- .../global/open-view-drawer.snippet.ts | 13 +- .../snippets/global/query-selector.snippet.ts | 34 ++ .../snippets/global/require-amd.snippet.ts | 30 ++ .../snippets/global/requireAsync.snippet.ts | 24 - .../snippets/global/sleep.snippet.ts | 21 - .../global/try-catch-async.snippet.ts | 22 - .../global/view-navigation-push.snippet.ts | 23 - .../snippets/global/window-open.snippet.ts | 12 +- .../src/runjs-context/snippets/index.ts | 214 ++++++-- .../snippets/libs/echarts-init.snippet.ts | 24 - .../actions/record-id-message.snippet.ts | 21 - .../scene/actions/run-action-basic.snippet.ts | 18 - .../add-event-listener.snippet.ts | 16 +- .../block/api-fetch-render-list.snippet.ts | 45 ++ .../scene/block/chartjs-bar.snippet.ts | 80 +++ .../scene/block/echarts-init.snippet.ts | 44 ++ .../render-button-handler.snippet.ts | 21 +- .../scene/block/render-iframe.snippet.ts | 38 ++ .../scene/block/render-info-card.snippet.ts | 53 ++ .../render-react.snippet.ts | 26 +- .../scene/block/render-statistics.snippet.ts | 81 +++ .../scene/block/render-timeline.snippet.ts | 66 +++ .../scene/block/resource-example.snippet.ts | 46 ++ .../scene/block/three-users-orbit.snippet.ts | 264 +++++++++ .../scene/block/vue-component.snippet.ts | 105 ++++ .../scene/detail/color-by-value.snippet.ts | 33 ++ .../scene/detail/copy-to-clipboard.snippet.ts | 45 ++ .../scene/detail/format-number.snippet.ts | 32 ++ .../scene/detail/innerHTML-value.snippet.ts | 31 ++ .../scene/detail/percentage-bar.snippet.ts | 63 +++ .../scene/detail/relative-time.snippet.ts | 61 +++ .../scene/detail/status-tag.snippet.ts | 55 ++ .../scene/form/calculate-total.snippet.ts | 44 ++ .../scene/form/cascade-select.snippet.ts | 62 +++ .../form/conditional-required.snippet.ts | 45 ++ .../scene/form/copy-field-values.snippet.ts | 55 ++ .../{jsitem => form}/render-basic.snippet.ts | 16 +- .../{linkage => form}/set-disabled.snippet.ts | 18 +- .../set-field-value.snippet.ts | 18 +- .../{linkage => form}/set-required.snippet.ts | 18 +- .../form/toggle-multiple-fields.snippet.ts | 48 ++ .../toggle-visible.snippet.ts | 18 +- .../scene/jsblock/append-style.snippet.ts | 20 - .../scene/jsblock/jsx-mount.snippet.ts | 24 - .../scene/jsblock/jsx-unmount.snippet.ts | 19 - .../scene/jsblock/render-basic.snippet.ts | 24 - .../scene/jsblock/render-card.snippet.ts | 30 -- .../scene/jsfield/color-by-value.snippet.ts | 20 - .../scene/jsfield/format-number.snippet.ts | 19 - .../scene/jsfield/innerHTML-value.snippet.ts | 18 - .../scene/table/cell-open-dialog.snippet.ts | 45 ++ .../collection-selected-count.snippet.ts | 16 +- .../scene/table/concat-fields.snippet.ts | 60 +++ .../scene/table/destroy-selected.snippet.ts | 36 ++ .../table/export-selected-json.snippet.ts | 45 ++ .../iterate-selected-rows.snippet.ts | 16 +- .../src/runjs-context/snippets/types.ts | 6 +- .../src/client/CodeEditor.tsx | 17 +- .../src/client/completions/index.ts | 272 +++------- 111 files changed, 5730 insertions(+), 1834 deletions(-) create mode 100644 packages/core/client/src/flow/components/code-editor/__tests__/runjsLocales.test.ts delete mode 100644 packages/core/client/src/flow/components/code-editor/completions/index.ts delete mode 100644 packages/core/client/src/flow/components/code-editor/snippets/loader.ts create mode 100644 packages/core/flow-engine/src/__tests__/flowContextCreateJSRunner.test.ts create mode 100644 packages/core/flow-engine/src/__tests__/flowRunJSContextDefine.test.ts create mode 100644 packages/core/flow-engine/src/__tests__/runjsContextImplementations.test.ts create mode 100644 packages/core/flow-engine/src/__tests__/runjsContextRuntime.test.ts create mode 100644 packages/core/flow-engine/src/__tests__/runjsEdgeCases.test.ts create mode 100644 packages/core/flow-engine/src/__tests__/runjsLocales.test.ts create mode 100644 packages/core/flow-engine/src/__tests__/runjsRuntimeFeatures.test.ts create mode 100644 packages/core/flow-engine/src/__tests__/runjsSnippets.test.ts delete mode 100644 packages/core/flow-engine/src/runjs-context/contexts/FlowRunJSContext.ts create mode 100644 packages/core/flow-engine/src/runjs-context/contexts/JSColumnRunJSContext.ts delete mode 100644 packages/core/flow-engine/src/runjs-context/contexts/LinkageRunJSContext.ts create mode 100644 packages/core/flow-engine/src/runjs-context/contexts/base.ts delete mode 100644 packages/core/flow-engine/src/runjs-context/index.ts create mode 100644 packages/core/flow-engine/src/runjs-context/setup.ts delete mode 100644 packages/core/flow-engine/src/runjs-context/snippets/global/api-request-get.snippet.ts delete mode 100644 packages/core/flow-engine/src/runjs-context/snippets/global/api-request-post.snippet.ts create mode 100644 packages/core/flow-engine/src/runjs-context/snippets/global/api-request.snippet.ts delete mode 100644 packages/core/flow-engine/src/runjs-context/snippets/global/console-log-ctx.snippet.ts delete mode 100644 packages/core/flow-engine/src/runjs-context/snippets/global/copy-record-json.snippet.ts delete mode 100644 packages/core/flow-engine/src/runjs-context/snippets/global/copy-to-clipboard.snippet.ts create mode 100644 packages/core/flow-engine/src/runjs-context/snippets/global/import-esm.snippet.ts delete mode 100644 packages/core/flow-engine/src/runjs-context/snippets/global/log-json-record.snippet.ts create mode 100644 packages/core/flow-engine/src/runjs-context/snippets/global/query-selector.snippet.ts create mode 100644 packages/core/flow-engine/src/runjs-context/snippets/global/require-amd.snippet.ts delete mode 100644 packages/core/flow-engine/src/runjs-context/snippets/global/requireAsync.snippet.ts delete mode 100644 packages/core/flow-engine/src/runjs-context/snippets/global/sleep.snippet.ts delete mode 100644 packages/core/flow-engine/src/runjs-context/snippets/global/try-catch-async.snippet.ts delete mode 100644 packages/core/flow-engine/src/runjs-context/snippets/global/view-navigation-push.snippet.ts delete mode 100644 packages/core/flow-engine/src/runjs-context/snippets/libs/echarts-init.snippet.ts delete mode 100644 packages/core/flow-engine/src/runjs-context/snippets/scene/actions/record-id-message.snippet.ts delete mode 100644 packages/core/flow-engine/src/runjs-context/snippets/scene/actions/run-action-basic.snippet.ts rename packages/core/flow-engine/src/runjs-context/snippets/scene/{jsblock => block}/add-event-listener.snippet.ts (62%) create mode 100644 packages/core/flow-engine/src/runjs-context/snippets/scene/block/api-fetch-render-list.snippet.ts create mode 100644 packages/core/flow-engine/src/runjs-context/snippets/scene/block/chartjs-bar.snippet.ts create mode 100644 packages/core/flow-engine/src/runjs-context/snippets/scene/block/echarts-init.snippet.ts rename packages/core/flow-engine/src/runjs-context/snippets/scene/{jsblock => block}/render-button-handler.snippet.ts (53%) create mode 100644 packages/core/flow-engine/src/runjs-context/snippets/scene/block/render-iframe.snippet.ts create mode 100644 packages/core/flow-engine/src/runjs-context/snippets/scene/block/render-info-card.snippet.ts rename packages/core/flow-engine/src/runjs-context/snippets/scene/{jsblock => block}/render-react.snippet.ts (59%) create mode 100644 packages/core/flow-engine/src/runjs-context/snippets/scene/block/render-statistics.snippet.ts create mode 100644 packages/core/flow-engine/src/runjs-context/snippets/scene/block/render-timeline.snippet.ts create mode 100644 packages/core/flow-engine/src/runjs-context/snippets/scene/block/resource-example.snippet.ts create mode 100644 packages/core/flow-engine/src/runjs-context/snippets/scene/block/three-users-orbit.snippet.ts create mode 100644 packages/core/flow-engine/src/runjs-context/snippets/scene/block/vue-component.snippet.ts create mode 100644 packages/core/flow-engine/src/runjs-context/snippets/scene/detail/color-by-value.snippet.ts create mode 100644 packages/core/flow-engine/src/runjs-context/snippets/scene/detail/copy-to-clipboard.snippet.ts create mode 100644 packages/core/flow-engine/src/runjs-context/snippets/scene/detail/format-number.snippet.ts create mode 100644 packages/core/flow-engine/src/runjs-context/snippets/scene/detail/innerHTML-value.snippet.ts create mode 100644 packages/core/flow-engine/src/runjs-context/snippets/scene/detail/percentage-bar.snippet.ts create mode 100644 packages/core/flow-engine/src/runjs-context/snippets/scene/detail/relative-time.snippet.ts create mode 100644 packages/core/flow-engine/src/runjs-context/snippets/scene/detail/status-tag.snippet.ts create mode 100644 packages/core/flow-engine/src/runjs-context/snippets/scene/form/calculate-total.snippet.ts create mode 100644 packages/core/flow-engine/src/runjs-context/snippets/scene/form/cascade-select.snippet.ts create mode 100644 packages/core/flow-engine/src/runjs-context/snippets/scene/form/conditional-required.snippet.ts create mode 100644 packages/core/flow-engine/src/runjs-context/snippets/scene/form/copy-field-values.snippet.ts rename packages/core/flow-engine/src/runjs-context/snippets/scene/{jsitem => form}/render-basic.snippet.ts (64%) rename packages/core/flow-engine/src/runjs-context/snippets/scene/{linkage => form}/set-disabled.snippet.ts (68%) rename packages/core/flow-engine/src/runjs-context/snippets/scene/{linkage => form}/set-field-value.snippet.ts (69%) rename packages/core/flow-engine/src/runjs-context/snippets/scene/{linkage => form}/set-required.snippet.ts (68%) create mode 100644 packages/core/flow-engine/src/runjs-context/snippets/scene/form/toggle-multiple-fields.snippet.ts rename packages/core/flow-engine/src/runjs-context/snippets/scene/{linkage => form}/toggle-visible.snippet.ts (69%) delete mode 100644 packages/core/flow-engine/src/runjs-context/snippets/scene/jsblock/append-style.snippet.ts delete mode 100644 packages/core/flow-engine/src/runjs-context/snippets/scene/jsblock/jsx-mount.snippet.ts delete mode 100644 packages/core/flow-engine/src/runjs-context/snippets/scene/jsblock/jsx-unmount.snippet.ts delete mode 100644 packages/core/flow-engine/src/runjs-context/snippets/scene/jsblock/render-basic.snippet.ts delete mode 100644 packages/core/flow-engine/src/runjs-context/snippets/scene/jsblock/render-card.snippet.ts delete mode 100644 packages/core/flow-engine/src/runjs-context/snippets/scene/jsfield/color-by-value.snippet.ts delete mode 100644 packages/core/flow-engine/src/runjs-context/snippets/scene/jsfield/format-number.snippet.ts delete mode 100644 packages/core/flow-engine/src/runjs-context/snippets/scene/jsfield/innerHTML-value.snippet.ts create mode 100644 packages/core/flow-engine/src/runjs-context/snippets/scene/table/cell-open-dialog.snippet.ts rename packages/core/flow-engine/src/runjs-context/snippets/scene/{actions => table}/collection-selected-count.snippet.ts (57%) create mode 100644 packages/core/flow-engine/src/runjs-context/snippets/scene/table/concat-fields.snippet.ts create mode 100644 packages/core/flow-engine/src/runjs-context/snippets/scene/table/destroy-selected.snippet.ts create mode 100644 packages/core/flow-engine/src/runjs-context/snippets/scene/table/export-selected-json.snippet.ts rename packages/core/flow-engine/src/runjs-context/snippets/scene/{actions => table}/iterate-selected-rows.snippet.ts (56%) diff --git a/packages/core/client/src/flow/components/code-editor/__tests__/SnippetsDrawer.test.tsx b/packages/core/client/src/flow/components/code-editor/__tests__/SnippetsDrawer.test.tsx index 245ee9a272d..05d80ed27d9 100644 --- a/packages/core/client/src/flow/components/code-editor/__tests__/SnippetsDrawer.test.tsx +++ b/packages/core/client/src/flow/components/code-editor/__tests__/SnippetsDrawer.test.tsx @@ -16,7 +16,14 @@ describe('SnippetsDrawer', () => { it('renders entries and calls onInsert', async () => { const entries = [ { name: 'A', prefix: 'a', body: 'console.log(1)', ref: 'global/test', group: 'global' }, - { name: 'B', prefix: 'b', body: 'console.log(2)', ref: 'scene/jsblock/test', group: 'scene/jsblock' }, + { + name: 'B', + prefix: 'b', + body: 'console.log(2)', + ref: 'scene/block/test', + group: 'scene/block', + groups: ['scene/block', 'scene/form'], + }, ]; const onInsert = vi.fn(); render( diff --git a/packages/core/client/src/flow/components/code-editor/__tests__/javascriptHtmlTemplate.test.ts b/packages/core/client/src/flow/components/code-editor/__tests__/javascriptHtmlTemplate.test.ts index 81e811c735a..697e3bb22b1 100644 --- a/packages/core/client/src/flow/components/code-editor/__tests__/javascriptHtmlTemplate.test.ts +++ b/packages/core/client/src/flow/components/code-editor/__tests__/javascriptHtmlTemplate.test.ts @@ -10,12 +10,15 @@ import { syntaxTree } from '@codemirror/language'; import { CompletionContext } from '@codemirror/autocomplete'; import { EditorState } from '@codemirror/state'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, beforeAll } from 'vitest'; +import { setupRunJSContexts } from '@nocobase/flow-engine'; import { javascriptWithHtmlTemplates } from '../javascriptHtmlTemplate'; -import { createJavascriptCompletion } from '../javascriptCompletion'; import { createHtmlCompletion } from '../htmlCompletion'; describe('javascriptWithHtmlTemplates', () => { + beforeAll(async () => { + await setupRunJSContexts(); + }); it('mounts html parser for template literal segments', () => { const support = javascriptWithHtmlTemplates(); const state = EditorState.create({ @@ -52,6 +55,7 @@ describe('javascriptWithHtmlTemplates', () => { const pos = doc.indexOf('<') + 1; const context = new CompletionContext(state, pos, true); + const { createJavascriptCompletion } = await import('../javascriptCompletion'); const jsCompletion = createJavascriptCompletion()(context); expect(jsCompletion).toBeNull(); @@ -71,6 +75,7 @@ describe('javascriptWithHtmlTemplates', () => { const pos = doc.lastIndexOf('<'); const context = new CompletionContext(state, pos + 1, true); + const { createJavascriptCompletion } = await import('../javascriptCompletion'); const jsCompletion = createJavascriptCompletion()(context); expect(jsCompletion).toBeNull(); @@ -79,7 +84,7 @@ describe('javascriptWithHtmlTemplates', () => { expect(htmlResult?.options?.some((option) => option.label.includes('div'))).toBe(true); }); - it('keeps javascript completions available outside template literals', () => { + it('keeps javascript completions available outside template literals', async () => { const state = EditorState.create({ doc: 'const value = windo', extensions: [javascriptWithHtmlTemplates()], @@ -88,6 +93,7 @@ describe('javascriptWithHtmlTemplates', () => { const pos = state.doc.length; const context = new CompletionContext(state, pos, true); + const { createJavascriptCompletion } = await import('../javascriptCompletion'); const result = createJavascriptCompletion()(context); expect(result).not.toBeNull(); expect(result?.options.length ?? 0).toBeGreaterThan(0); diff --git a/packages/core/client/src/flow/components/code-editor/__tests__/runjsCompletions.test.ts b/packages/core/client/src/flow/components/code-editor/__tests__/runjsCompletions.test.ts index 1030cf29d5a..c5d383cdb3f 100644 --- a/packages/core/client/src/flow/components/code-editor/__tests__/runjsCompletions.test.ts +++ b/packages/core/client/src/flow/components/code-editor/__tests__/runjsCompletions.test.ts @@ -9,35 +9,46 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -// Mock engine doc provider +// Mock engine api provider vi.mock('@nocobase/flow-engine', () => { const doc = { - properties: { foo: 'foo prop' }, - methods: { bar: 'bar method' }, - snipastes: { - 'Test Snippet': { body: 'console.log(1)', prefix: 'sn-one', description: 'desc' }, + properties: { + foo: 'foo prop', + api: { + description: 'api client', + completion: { insertText: 'ctx.api' }, + properties: { + request: { + description: 'send request', + completion: { insertText: "await ctx.api.request({ url: '', method: 'get' })" }, + }, + }, + }, + }, + methods: { + bar: { + description: 'bar method', + completion: { insertText: "ctx.bar('value')" }, + }, }, }; return { getRunJSDocFor: () => doc, FlowRunJSContext: { getDoc: () => doc }, - }; -}); - -// Mock loader to avoid dynamic imports -vi.mock('../snippets/loader', () => { - return { - loadSnippets: async (snipastes: any) => snipastes, - loadSnippetsForContext: async () => [ + // New cohesive APIs + listSnippetsForContext: async () => [ { name: 'Class Snippet', prefix: 'sn-class', description: 'cls', body: 'alert(1)', - ref: 'scene/jsblock/x', - group: 'scene/jsblock', + ref: 'scene/block/x', + group: 'scene/block', + groups: ['scene/block'], + scenes: ['block'], }, ], + setupRunJSContexts: () => void 0, }; }); @@ -46,21 +57,39 @@ import { buildRunJSCompletions } from '../runjsCompletions'; describe('buildRunJSCompletions', () => { it('builds ctx property/method completions and snippets', async () => { const hostCtx = {}; // not used since engine doc is mocked - const { completions, entries } = await buildRunJSCompletions(hostCtx, 'v1'); + const { completions, entries } = await buildRunJSCompletions(hostCtx, 'v1', 'block'); expect(Array.isArray(completions)).toBe(true); // property expect(completions.some((c: any) => c.label === 'ctx.foo')).toBe(true); + const apiProp = completions.find((c: any) => c.label === 'ctx.api'); + expect(apiProp).toBeTruthy(); + const apiReq = completions.find((c: any) => c.label === 'ctx.api.request'); + expect(apiReq).toBeTruthy(); + const mockView = { dispatch: vi.fn() } as any; + (apiReq as any).apply?.(mockView, apiReq, 0, 0); + expect(mockView.dispatch).toHaveBeenCalled(); + const inserted = mockView.dispatch.mock.calls[0][0]?.changes?.insert; + expect(inserted).toContain('ctx.api.request'); // method (with parentheses) const method = completions.find((c: any) => c.label === 'ctx.bar()'); expect(method).toBeTruthy(); // method completion should provide an apply function to insert parentheses expect(typeof (method as any).apply).toBe('function'); - // snippet from doc - expect(completions.some((c: any) => c.label === 'sn-one')).toBe(true); + const mockViewMethod = { dispatch: vi.fn() } as any; + (method as any).apply?.(mockViewMethod, method, 0, 0); + expect(mockViewMethod.dispatch).toHaveBeenCalled(); + const methodInserted = mockViewMethod.dispatch.mock.calls[0][0]?.changes?.insert; + expect(methodInserted).toContain('ctx.bar'); // snippet from class loader - expect(completions.some((c: any) => c.label === 'sn-class')).toBe(true); + expect(completions.some((c: any) => c.label === 'Class Snippet')).toBe(true); // entries produced for drawer - expect(entries.some((e) => e.name === 'Test Snippet')).toBe(true); expect(entries.some((e) => e.name === 'Class Snippet')).toBe(true); }); + + it('filters snippets by scene when provided', async () => { + const hostCtx = {}; + const { completions, entries } = await buildRunJSCompletions(hostCtx, 'v1', 'form'); + expect(entries.length).toBe(0); + expect(completions.some((c: any) => c.label === 'Class Snippet')).toBe(false); + }); }); diff --git a/packages/core/client/src/flow/components/code-editor/__tests__/runjsLocales.test.ts b/packages/core/client/src/flow/components/code-editor/__tests__/runjsLocales.test.ts new file mode 100644 index 00000000000..18f121609a3 --- /dev/null +++ b/packages/core/client/src/flow/components/code-editor/__tests__/runjsLocales.test.ts @@ -0,0 +1,24 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { describe, it, expect } from 'vitest'; +import { buildRunJSCompletions } from '../runjsCompletions'; + +describe('RunJS snippets locales (client completions)', () => { + it('should load snippets with zh-CN labels when locale is zh-CN', async () => { + const hostCtx = { + model: { constructor: { name: 'JSBlockModel' } }, + api: { auth: { locale: 'zh-CN' } }, + } as any; + const { entries } = await buildRunJSCompletions(hostCtx, 'v1', 'block'); + // expect at least one well-known snippet description (from doc) to be Chinese + const hasDialog = entries.some((e) => /对话框/.test(e.description || '')); + expect(hasDialog).toBe(true); + }); +}); diff --git a/packages/core/client/src/flow/components/code-editor/completions/index.ts b/packages/core/client/src/flow/components/code-editor/completions/index.ts deleted file mode 100644 index 6f36caa879e..00000000000 --- a/packages/core/client/src/flow/components/code-editor/completions/index.ts +++ /dev/null @@ -1,202 +0,0 @@ -/** - * This file is part of the NocoBase (R) project. - * Copyright (c) 2020-2024 NocoBase Co., Ltd. - * Authors: NocoBase Team. - * - * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. - * For more information, please refer to: https://www.nocobase.com/agreement. - */ - -import { Completion } from '@codemirror/autocomplete'; - -export default [ - { - label: 'ctx', - type: 'class', - info: 'Running context with all available APIs and utilities', - detail: 'FlowRuntimeContext', - boost: 110, // 核心入口,优先级最高 - }, - { - label: 'ctx.api', - type: 'class', - info: 'APIClient instance for making HTTP requests.', - detail: 'APIClient', - boost: 100, // 次高优先级,常用功能 - // 使用自动补全提供的 from/to,避免仅在光标处插入导致重复 - apply: (view, completion, from, to) => { - view.dispatch({ - changes: { from, to, insert: completion.label }, - }); - }, - }, - { - label: 'ctx.api.request(options)', - type: 'method', - info: 'Make an HTTP request using the APIClient instance.', - detail: 'Promise', - boost: 95, // 中等优先级,具体方法 - apply: (view, _completion, from, to) => { - view.dispatch({ - changes: { - from, - to, - insert: 'await ctx.api.request({\n url: "",\n method: "get",\n params: {}\n})', - }, - }); - }, - }, - { - label: 'ctx.element', - type: 'property', - info: 'Represents the current HTML element in the runtime context', - detail: 'HTMLElement', - boost: 110, // 核心属性,优先级最高 - apply: (view, completion, from, to) => { - view.dispatch({ - changes: { from, to, insert: completion.label }, - }); - }, - }, - { - label: 'ctx.element.innerHTML', - type: 'property', - info: 'Set the inner HTML content of the current HTML element in the runtime context.', - detail: 'string', - boost: 90, // 中等优先级,具体属性 - apply: (view, _completion, from, to) => { - view.dispatch({ - changes: { from, to, insert: 'ctx.element.innerHTML = `

Hello, NocoBase!

`;' }, - }); - }, - }, - { - label: 'Basic HTML Template', - type: 'snippet', - info: 'Insert a basic HTML structure with padding and sample content.', - detail: 'HTML Snippet', - boost: 85, // 较低优先级,代码片段 - apply: `ctx.element.innerHTML = \` -
-

Hello World

-

This is a basic HTML template.

-
-\`;`, - }, - { - label: 'API Response Snippet', - type: 'snippet', - info: 'Fetch data from an API and display it as formatted JSON in the current HTML element.', - detail: 'API Request and Display', - boost: 85, // 较低优先级,代码片段 - apply: `const response = await ctx.api.request({ - url: '/users', - method: 'get', - params: { page: 1, pageSize: 10 } -}); - -ctx.element.innerHTML = \` -
\${JSON.stringify(response.data, null, 2)}
-\``,
-  },
-  {
-    label: 'ECharts Example Snippet',
-    type: 'snippet',
-    info: 'Insert an ECharts example with random data and responsive resizing.',
-    detail: 'ECharts Snippet',
-    boost: 85, // 较低优先级,代码片段
-    apply: `ctx.element.style.height = '400px';
-const echarts = await ctx.requireAsync('https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js');
-if (!echarts) {
-  return;
-}
-const chart = echarts.init(ctx.element);
-// Generate random data
-const categories = ['A', 'B', 'C', 'D', 'E', 'F'];
-const randomData = categories.map(() => Math.floor(Math.random() * 50) + 1);
-const option = {
-  title: { text: 'ECharts Example (Random Data)' },
-  tooltip: {},
-  xAxis: { data: categories },
-  yAxis: {},
-  series: [{ name: 'Sales', type: 'bar', data: randomData }],
-};
-chart.setOption(option);
-chart.resize();
-window.addEventListener('resize', () => chart.resize());`,
-  },
-  {
-    label: 'I18n Example Snippet',
-    type: 'snippet',
-    info: 'Insert an example for internationalization using ctx.i18n.',
-    detail: 'I18n Snippet',
-    boost: 85, // 较低优先级,代码片段
-    apply: `const zhCN = {
-  hello: "你好",
-  welcome_user: "欢迎,{{user}}!"
-};
-const enUS = {
-  hello: "Hello",
-  welcome_user: "Welcome, {{user}}!"
-};
-
-// Add Chinese resource bundle
-ctx.i18n.addResourceBundle('zh-CN', 'ns1', zhCN, true, true);
-// Add English resource bundle
-ctx.i18n.addResourceBundle('en-US', 'ns1', enUS, true, true);
-
-// Render localized content
-ctx.element.innerHTML = ctx.t('welcome_user', { user: ctx.auth.user.nickname, ns: 'ns1' });`,
-  },
-  {
-    label: 'ctx.i18n',
-    type: 'class',
-    info: 'An instance of i18next for managing internationalization.',
-    detail: 'i18next',
-    boost: 100, // 次高优先级,常用功能
-    apply: (view, completion, from, to) => {
-      view.dispatch({
-        changes: { from, to, insert: completion.label },
-      });
-    },
-  },
-  {
-    label: 'ctx.t(text, options)',
-    type: 'method',
-    info: 'Translate a given text key using the provided options.',
-    detail: 'string',
-    boost: 95, // 中等优先级,具体方法
-    apply: (view, _completion, from, to) => {
-      view.dispatch({
-        changes: { from, to, insert: 'ctx.t("key", { ns: "namespace" })' },
-      });
-    },
-  },
-  {
-    label: 'ctx.element.querySelector(selector)',
-    type: 'method',
-    info: 'Find the first descendant element that matches the specified CSS selector.',
-    detail: 'HTMLElement | null',
-    boost: 85, // 较低优先级,具体方法
-    apply: (view, _completion, from, to) => {
-      view.dispatch({
-        changes: { from, to, insert: 'const child = ctx.element.querySelector(".child-class");' },
-      });
-    },
-  },
-  {
-    label: 'Resource Example Snippet',
-    type: 'snippet',
-    info: 'Use a resource to fetch data and display it in the current HTML element.',
-    detail: 'Resource Snippet',
-    boost: 85, // 较低优先级,代码片段
-    apply: `ctx.useResource('SingleRecordResource');
-const resource = ctx.resource;
-resource.setDataSourceKey('main');
-resource.setResourceName('users');
-await resource.refresh();
-ctx.element.innerHTML = \`
-  
\${JSON.stringify(ctx.resource.getData(), null, 2)}
-\`;`, - }, -] as Completion[]; diff --git a/packages/core/client/src/flow/components/code-editor/core/EditorCore.tsx b/packages/core/client/src/flow/components/code-editor/core/EditorCore.tsx index 2b9f3c9f46c..d25b4644619 100644 --- a/packages/core/client/src/flow/components/code-editor/core/EditorCore.tsx +++ b/packages/core/client/src/flow/components/code-editor/core/EditorCore.tsx @@ -57,8 +57,14 @@ export const EditorCore: React.FC<{ if (!editorRef.current) return; const staticCompletionSource = (options: Completion[]) => { const source = (context: CompletionContext): CompletionResult | null => { - const word = context.matchBefore(/[a-zA-Z_][\w.]*/); - if (!word || (word.from === word.to && !context.explicit)) return null; + const word = context.matchBefore(/[$_\p{Letter}][$_\p{Letter}\p{Number}.-]*/u); + if (!word) { + if (context.explicit) { + return { from: context.pos, to: context.pos, options }; + } + return null; + } + if (word.from === word.to && !context.explicit) return null; return { from: word.from, to: word.to, options }; }; return source; diff --git a/packages/core/client/src/flow/components/code-editor/hooks/useCodeRunner.ts b/packages/core/client/src/flow/components/code-editor/hooks/useCodeRunner.ts index e1eaf8e9d07..cc0b6df6839 100644 --- a/packages/core/client/src/flow/components/code-editor/hooks/useCodeRunner.ts +++ b/packages/core/client/src/flow/components/code-editor/hooks/useCodeRunner.ts @@ -9,7 +9,7 @@ import { useCallback, useState } from 'react'; import { parseErrorLineColumn } from '../errorHelpers'; -import { FlowModelContext, JSRunner } from '@nocobase/flow-engine'; +import { FlowModelContext, JSRunner, createSafeWindow, createSafeDocument } from '@nocobase/flow-engine'; export type RunLog = { level: 'log' | 'info' | 'warn' | 'error'; msg: string; line?: number; column?: number }; @@ -62,7 +62,13 @@ export function useCodeRunner(hostCtx: FlowModelContext, version = 'v1') { }; const captureConsole = createConsoleCapture(push); - runtimeModel.setStepParams?.('jsSettings', 'runJs', { code, version }); + // 选择一个可用的设置流:优先 jsSettings,不存在则尝试 clickSettings + const preferFlowKeys = ['jsSettings', 'clickSettings'] as const; + const availableKey = preferFlowKeys.find((k) => (runtimeModel as any)?.getFlow?.(k)); + const flowKey = availableKey || 'jsSettings'; + + // 将预览中的代码写入对应 flow 的 stepParams,确保 handler 能拿到最新代码 + runtimeModel.setStepParams?.(flowKey, 'runJs', { code, version }); // Monkey-patch JSRunner.run to inject captureConsole into globals for all runjs calls during preview type JSRunnerPrototype = { run: JSRunner['run'] }; @@ -101,10 +107,19 @@ export function useCodeRunner(hostCtx: FlowModelContext, version = 'v1') { } as JSRunner['run']; const runOnModel = async (m) => { - const flow = m?.getFlow?.('jsSettings'); + const flow = m?.getFlow?.(flowKey); const isManual = flow?.manual === true; - if (isManual) { - await m.applyFlow('jsSettings', { preview: { code, version } }); + // 如果 flow 显式绑定了某个事件(如 on: 'click'),则按事件名分发; + // 否则:manual=true 走 applyFlow;没有 on 且非 manual 走 beforeRender。 + const onDef = flow?.on; + const eventName = typeof onDef === 'string' ? onDef : onDef?.eventName; + if (!flow) { + // 无可用流程(典型场景:联动规则里的 RunJS 预览),直接在当前上下文执行代码 + await hostCtx.runjs(code, { window: createSafeWindow(), document: createSafeDocument() }, { version }); + } else if (typeof eventName === 'string') { + await m.dispatchEvent(eventName, { preview: { code, version } }, { sequential: true, useCache: false }); + } else if (isManual) { + await m.applyFlow(flowKey, { preview: { code, version } }); } else { await m.dispatchEvent( 'beforeRender', diff --git a/packages/core/client/src/flow/components/code-editor/hooks/useRunJSDocCompletions.ts b/packages/core/client/src/flow/components/code-editor/hooks/useRunJSDocCompletions.ts index 3b30509963a..0888096b817 100644 --- a/packages/core/client/src/flow/components/code-editor/hooks/useRunJSDocCompletions.ts +++ b/packages/core/client/src/flow/components/code-editor/hooks/useRunJSDocCompletions.ts @@ -11,7 +11,7 @@ import { useEffect, useState } from 'react'; import type { Completion } from '@codemirror/autocomplete'; import { buildRunJSCompletions, type SnippetEntry } from '../runjsCompletions'; -export function useRunJSDocCompletions(hostCtx: any, version = 'v1') { +export function useRunJSDocCompletions(hostCtx: any, version = 'v1', scene?: string | string[]) { const [completions, setCompletions] = useState(null); const [entries, setEntries] = useState([]); const [error, setError] = useState(null); @@ -19,7 +19,7 @@ export function useRunJSDocCompletions(hostCtx: any, version = 'v1') { let cancelled = false; (async () => { try { - const { completions, entries } = await buildRunJSCompletions(hostCtx, version); + const { completions, entries } = await buildRunJSCompletions(hostCtx, version, scene); if (!cancelled) { setCompletions(completions); setEntries(entries); @@ -36,6 +36,6 @@ export function useRunJSDocCompletions(hostCtx: any, version = 'v1') { return () => { cancelled = true; }; - }, [hostCtx, version]); + }, [hostCtx, version, scene]); return { completions, entries, error }; } diff --git a/packages/core/client/src/flow/components/code-editor/index.tsx b/packages/core/client/src/flow/components/code-editor/index.tsx index 4c29838f508..d0a36c7d072 100644 --- a/packages/core/client/src/flow/components/code-editor/index.tsx +++ b/packages/core/client/src/flow/components/code-editor/index.tsx @@ -12,7 +12,7 @@ import React, { useCallback, useMemo, useRef, useState } from 'react'; import type { Completion } from '@codemirror/autocomplete'; import { EditorView } from '@codemirror/view'; import { InjectableRendingEventTrigger, InjectableRendingEventTriggerProps } from '../decorator'; -import { useFlowContext } from '@nocobase/flow-engine'; +import { useFlowContext, getRunJSScenesForContext } from '@nocobase/flow-engine'; import { useRunJSDocCompletions } from './hooks/useRunJSDocCompletions'; import { clearDiagnostics, parseErrorLineColumn, markErrorAt, jumpTo } from './errorHelpers'; import { Button } from 'antd'; @@ -35,6 +35,7 @@ interface CodeEditorProps { wrapperStyle?: React.CSSProperties; extraCompletions?: Completion[]; // 供外部注入的静态补全 version?: string; // runjs 版本(默认 v1) + scene?: string | string[]; } export * from './types'; @@ -44,7 +45,7 @@ export const CodeEditor: React.FC - + ); }; @@ -62,13 +63,28 @@ const InnerCodeEditor: React.FC = ({ wrapperStyle, extraCompletions, version = 'v1', + scene, }) => { const wrapperRef = useRef(null); const viewRef = useRef(null); const runtimeCtx = useFlowContext(); // const settingsCtx = useFlowSettingsContext?.() as any; const hostCtx = runtimeCtx; // || settingsCtx; - const { completions: dynamicCompletions, entries: snippetEntries } = useRunJSDocCompletions(hostCtx, version); + const resolvedScene = useMemo(() => { + if (scene && (Array.isArray(scene) ? scene.length : true)) return scene; + if (!hostCtx) return undefined; + try { + const autoScenes = getRunJSScenesForContext(hostCtx, { version: version as any }); + return autoScenes.length ? autoScenes : undefined; + } catch (_) { + return undefined; + } + }, [scene, hostCtx, version]); + const { completions: dynamicCompletions, entries: snippetEntries } = useRunJSDocCompletions( + hostCtx, + version, + resolvedScene, + ); const { run, logs, running } = useCodeRunner(hostCtx, version); const [snippetOpen, setSnippetOpen] = useState(false); const getSnippetsContainer = useCallback(() => { @@ -100,8 +116,8 @@ const InnerCodeEditor: React.FC = ({ // 合并外部注入与动态构建的 completions const finalExtra = useMemo(() => { const arr: Completion[] = []; - if (Array.isArray(extraCompletions)) arr.push(...extraCompletions); if (Array.isArray(dynamicCompletions)) arr.push(...dynamicCompletions); + if (Array.isArray(extraCompletions)) arr.push(...extraCompletions); return arr; }, [extraCompletions, dynamicCompletions]); diff --git a/packages/core/client/src/flow/components/code-editor/javascriptCompletion.ts b/packages/core/client/src/flow/components/code-editor/javascriptCompletion.ts index afdf2c6f2b6..c69a1ac2d9b 100644 --- a/packages/core/client/src/flow/components/code-editor/javascriptCompletion.ts +++ b/packages/core/client/src/flow/components/code-editor/javascriptCompletion.ts @@ -7,16 +7,106 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -import { CompletionContext, CompletionResult } from '@codemirror/autocomplete'; -import completions from './completions'; +import { CompletionContext, CompletionResult, Completion } from '@codemirror/autocomplete'; +import { FlowRunJSContext } from '@nocobase/flow-engine'; import { isHtmlTemplateContext } from './htmlCompletion'; +function buildBaseCompletions(): Completion[] { + try { + const doc = typeof (FlowRunJSContext as any)?.getDoc === 'function' ? (FlowRunJSContext as any).getDoc() : {}; + const options: Completion[] = []; + const toInfo = (value: any) => (typeof value === 'string' ? value : JSON.stringify(value)); + if (doc?.label || doc?.properties || doc?.methods) { + options.push({ + label: 'ctx', + type: 'class', + detail: 'FlowRunJSContext', + info: doc?.label || 'RunJS context', + boost: 115, + } as Completion); + } + const collectProperties = (props: Record | undefined, parentPath: string[] = []) => { + if (!props) return; + for (const [key, value] of Object.entries(props)) { + const path = [...parentPath, key]; + const ctxLabel = `ctx.${path.join('.')}`; + const depth = path.length; + let description: any = value; + let detail: string | undefined; + let completionSpec: any; + let children: Record | undefined; + if (value && typeof value === 'object' && !Array.isArray(value)) { + description = value.description ?? value.detail ?? value.type ?? value; + detail = value.detail ?? value.type ?? 'ctx property'; + completionSpec = value.completion; + children = value.properties as Record | undefined; + } + const apply = completionSpec?.insertText + ? (view: any, _completion: any, from: number, to: number) => { + view.dispatch({ + changes: { from, to, insert: completionSpec.insertText }, + selection: { anchor: from + completionSpec.insertText.length }, + scrollIntoView: true, + }); + } + : undefined; + options.push({ + label: ctxLabel, + type: 'property', + detail: detail || 'ctx property', + info: toInfo(description), + boost: Math.max(90 - depth * 5, 10), + apply, + } as Completion); + if (children) collectProperties(children, path); + } + }; + + collectProperties(doc?.properties || {}); + const methods = doc?.methods || {}; + for (const key of Object.keys(methods)) { + const methodDoc = methods[key]; + let description: any = methodDoc; + let detail = 'ctx method'; + let completionSpec: any; + if (methodDoc && typeof methodDoc === 'object' && !Array.isArray(methodDoc)) { + description = methodDoc.description ?? methodDoc.detail ?? methodDoc; + detail = methodDoc.detail ?? detail; + completionSpec = methodDoc.completion; + } + const insertText = completionSpec?.insertText ?? `ctx.${key}()`; + options.push({ + label: `ctx.${key}()` as any, + type: 'function', + detail, + info: toInfo(description), + boost: 95, + apply: (view: any, _completion: any, from: number, to: number) => { + view.dispatch({ + changes: { from, to, insert: insertText }, + selection: { anchor: from + insertText.length }, + scrollIntoView: true, + }); + }, + } as Completion); + } + return options; + } catch (_) { + return []; + } +} + +const baseCompletions = buildBaseCompletions(); + export const javascriptCompletionSource = (context: CompletionContext): CompletionResult | null => { if (isHtmlTemplateContext(context)) { return null; } - const word = context.matchBefore(/[a-zA-Z_][\w.]*/); + const word = context.matchBefore(/[$_\p{Letter}][$_\p{Letter}\p{Number}.-]*/u); + if (!word && context.explicit) { + return { from: context.pos, to: context.pos, options: baseCompletions }; + } if (!word || (word.from === word.to && !context.explicit)) return null; const from = word.from; @@ -25,7 +115,7 @@ export const javascriptCompletionSource = (context: CompletionContext): Completi return { from, to, - options: completions, + options: baseCompletions, }; }; diff --git a/packages/core/client/src/flow/components/code-editor/linter.ts b/packages/core/client/src/flow/components/code-editor/linter.ts index bc205ee02b1..3e5c940916c 100644 --- a/packages/core/client/src/flow/components/code-editor/linter.ts +++ b/packages/core/client/src/flow/components/code-editor/linter.ts @@ -9,6 +9,11 @@ import { linter, Diagnostic } from '@codemirror/lint'; import * as acorn from 'acorn'; +// acorn-walk 仅用于轻量遍历做一些静态启发式检查(非类型检查) +// 类型定义可缺省,因此用 any 兼容 +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +import * as acornWalk from 'acorn-walk'; /** * 创建 JavaScript 语法检查器 - 只检查语法错误 @@ -31,12 +36,14 @@ export const createJavaScriptLinter = () => { return diagnostics; } + let ast: any = null; try { // 使用 acorn 解析代码,只检查语法错误 - acorn.parse(text, { + ast = acorn.parse(text, { ecmaVersion: 2022, sourceType: 'script', allowAwaitOutsideFunction: true, + allowReturnOutsideFunction: true, locations: true, }); } catch (error) { @@ -65,6 +72,162 @@ export const createJavaScriptLinter = () => { message: `Syntax error: ${acornError.message}`, actions: [], }); + return diagnostics; + } + + // 非语法层的小启发式检查(可开可关,尽量减少误报) + try { + const push = (from: number, to: number, msg: string, severity: Diagnostic['severity'] = 'warning') => { + diagnostics.push({ from, to, message: msg, severity, actions: [] }); + }; + + const declared = new Set([ + // 常见全局(简表) + 'ctx', + 'console', + 'window', + 'document', + 'Math', + 'Date', + 'Array', + 'Object', + 'Number', + 'String', + 'Boolean', + 'Promise', + 'RegExp', + 'Set', + 'Map', + 'WeakSet', + 'WeakMap', + 'JSON', + 'Intl', + 'URL', + 'Error', + 'TypeError', + 'encodeURIComponent', + 'decodeURIComponent', + 'parseInt', + 'parseFloat', + 'isNaN', + 'isFinite', + 'undefined', + 'NaN', + 'Infinity', + ]); + + const addId = (id: any) => { + if (id && typeof id.name === 'string') declared.add(id.name); + }; + const addPatternIds = (pattern: any) => { + // 支持简单模式:Identifier / ArrayPattern / ObjectPattern + if (!pattern) return; + const stack: any[] = [pattern]; + while (stack.length) { + const node = stack.pop(); + if (!node) continue; + if (node.type === 'Identifier') addId(node); + else if (node.type === 'AssignmentPattern') stack.push(node.left); + else if (node.type === 'ArrayPattern') (node.elements || []).forEach((n: any) => n && stack.push(n)); + else if (node.type === 'ObjectPattern') + (node.properties || []).forEach((p: any) => p && stack.push(p.value || p)); + } + }; + + // 收集顶层声明(以及函数/参数名,粗粒度,尽量避免误报) + // 使用 full 方式更兼容,避免对特定 walker 键的依赖(如 VariableDeclarator 在某些打包环境下不可用) + acornWalk.full(ast, (node: any) => { + switch (node?.type) { + case 'VariableDeclarator': + addPatternIds(node.id); + break; + case 'FunctionDeclaration': + addId(node.id); + (node.params || []).forEach(addPatternIds); + break; + case 'FunctionExpression': + // 具名函数表达式也记录 id + addId(node.id); + (node.params || []).forEach(addPatternIds); + break; + case 'ArrowFunctionExpression': + (node.params || []).forEach(addPatternIds); + break; + case 'CatchClause': + addPatternIds((node as any).param); + break; + case 'ClassDeclaration': + addId(node.id); + break; + default: + break; + } + }); + + // 1) 明显不可调用的调用表达式:如 123()、'x'()、(1+2)() + acornWalk.full(ast, (node: any) => { + if (!node || typeof node.type !== 'string') return; + if (node.type === 'CallExpression') { + const callee = node.callee; + const isCallableLike = + callee && + (callee.type === 'Identifier' || + callee.type === 'MemberExpression' || + callee.type === 'FunctionExpression' || + callee.type === 'ArrowFunctionExpression' || + callee.type === 'CallExpression' || + callee.type === 'ChainExpression'); + if (!isCallableLike) { + const from = (callee?.loc && (callee as any).start) ?? node.start; + const to = (callee?.loc && (callee as any).end) ?? node.end; + push(from, to, 'This expression is not callable.'); + } + } else if (node.type === 'NewExpression') { + const callee = node.callee; + const isConstructorLike = + callee && + (callee.type === 'Identifier' || callee.type === 'MemberExpression' || callee.type === 'CallExpression'); + if (!isConstructorLike) { + const from = (callee?.loc && (callee as any).start) ?? node.start; + const to = (callee?.loc && (callee as any).end) ?? node.end; + push(from, to, 'This constructor is not a function.'); + } + } + }); + + // 2) 疑似未定义变量(尽量减少误报:排除属性名与解构/声明) + const reported = new Set(); + acornWalk.ancestor(ast, { + Identifier(node: any, ancestors: any[]) { + const name = node.name; + if (!name || declared.has(name) || reported.has(name)) return; + const parent = ancestors[ancestors.length - 2]; + if (!parent) return; + // 跳过声明位置 / 属性键 / 非计算属性 + if ( + (parent.type === 'VariableDeclarator' && parent.id === node) || + (parent.type === 'FunctionDeclaration' && parent.id === node) || + (parent.type === 'FunctionExpression' && parent.id === node) || + (parent.type === 'ClassDeclaration' && parent.id === node) || + (parent.type === 'ClassExpression' && parent.id === node) || + (parent.type === 'Property' && parent.key === node && parent.computed !== true) || + (parent.type === 'MemberExpression' && parent.property === node && parent.computed !== true) || + (parent.type === 'LabeledStatement' && parent.label === node) || + (parent.type === 'BreakStatement' && parent.label === node) || + (parent.type === 'ContinueStatement' && parent.label === node) + ) { + return; + } + // 可能未定义的自由变量 + const from = (node as any).start ?? 0; + const to = (node as any).end ?? from + 1; + push(from, to, `Possible undefined variable: ${name}`, 'warning'); + reported.add(name); + }, + }); + } catch (e) { + // 静态检查失败不影响编辑体验 + // console.debug('[linter] static checks failed', e); } return diagnostics; diff --git a/packages/core/client/src/flow/components/code-editor/panels/LogsPanel.tsx b/packages/core/client/src/flow/components/code-editor/panels/LogsPanel.tsx index 1e66ed14c61..ad453e5b2ad 100644 --- a/packages/core/client/src/flow/components/code-editor/panels/LogsPanel.tsx +++ b/packages/core/client/src/flow/components/code-editor/panels/LogsPanel.tsx @@ -9,6 +9,7 @@ import React from 'react'; import type { RunLog } from '../hooks/useCodeRunner'; +import { WRAPPER_PRELUDE_LINES } from '../errorHelpers'; export const LogsPanel: React.FC<{ logs: RunLog[]; @@ -32,6 +33,8 @@ export const LogsPanel: React.FC<{ logs.map((l, i) => { const color = l.level === 'error' ? '#ff4d4f' : l.level === 'warn' ? '#faad14' : '#333'; const clickable = l.level === 'error' && typeof l.line === 'number' && typeof l.column === 'number'; + const displayLine = + typeof l.line === 'number' ? Math.max(1, l.line - WRAPPER_PRELUDE_LINES) : (l.line as any); return (
               [{l.level}] {l.msg}
-              {clickable ? ` (${tr('at')} ${l.line}:${l.column})` : ''}
+              {clickable ? ` (${tr('at')} ${displayLine}:${l.column})` : ''}
             
); }) diff --git a/packages/core/client/src/flow/components/code-editor/panels/SnippetsDrawer.tsx b/packages/core/client/src/flow/components/code-editor/panels/SnippetsDrawer.tsx index 848e8be3c31..0a9719b88fc 100644 --- a/packages/core/client/src/flow/components/code-editor/panels/SnippetsDrawer.tsx +++ b/packages/core/client/src/flow/components/code-editor/panels/SnippetsDrawer.tsx @@ -28,7 +28,7 @@ export const SnippetsDrawer: React.FC<{ s.name.toLowerCase().includes(q) || (s.prefix || '').toLowerCase().includes(q) || (s.description || '').toLowerCase().includes(q) || - (s.group || '').toLowerCase().includes(q) || + ([...(s.groups || []), s.group].filter(Boolean).join(' ') || '').toLowerCase().includes(q) || s.body.toLowerCase().includes(q), ); }, [entries, query]); @@ -37,11 +37,10 @@ export const SnippetsDrawer: React.FC<{ const map: Record = { global: tr('Common'), libs: tr('Libs'), - 'scene/jsblock': tr('Block'), - 'scene/jsfield': tr('Field'), - 'scene/jsitem': tr('Form Item'), - 'scene/actions': tr('Actions'), - 'scene/linkage': tr('Linkage'), + 'scene/block': tr('Block'), + 'scene/detail': tr('Detail'), + 'scene/form': tr('Form'), + 'scene/table': tr('Table'), }; return (group?: string) => (group ? map[group] || group : ''); }, [tr]); @@ -64,38 +63,41 @@ export const SnippetsDrawer: React.FC<{ /> ( - { - const text = item.body.endsWith('\n') ? item.body : item.body + '\n'; - onInsert(text); - }} - > - {tr('Insert')} - , - ]} - > - - {item.name} - {item.prefix ? {item.prefix} : null} - {item.group ? ( - - {groupDisplay(item.group)} - - ) : null} - - } - description={item.description || groupDisplay(item.group) || item.ref || ''} - /> - - )} + renderItem={(item) => { + const groups = item.groups?.length ? item.groups : item.group ? [item.group] : []; + return ( + { + const text = item.body.endsWith('\n') ? item.body : item.body + '\n'; + onInsert(text); + }} + > + {tr('Insert')} + , + ]} + > + + {item.name} + {item.prefix ? {item.prefix} : null} + {groups.map((grp) => ( + + {groupDisplay(grp)} + + ))} + + } + description={item.description || groupDisplay(groups[0]) || item.ref || ''} + /> + + ); + }} /> ); diff --git a/packages/core/client/src/flow/components/code-editor/runjsCompletions.ts b/packages/core/client/src/flow/components/code-editor/runjsCompletions.ts index 0008d773358..416e5f50a84 100644 --- a/packages/core/client/src/flow/components/code-editor/runjsCompletions.ts +++ b/packages/core/client/src/flow/components/code-editor/runjsCompletions.ts @@ -7,10 +7,9 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -import { Completion, snippetCompletion } from '@codemirror/autocomplete'; +import { Completion } from '@codemirror/autocomplete'; import { EditorView } from '@codemirror/view'; -import { getRunJSDocFor, FlowRunJSContext } from '@nocobase/flow-engine'; -import { loadSnippets, loadSnippetsForContext } from './snippets/loader'; +import { getRunJSDocFor, setupRunJSContexts, listSnippetsForContext } from '@nocobase/flow-engine'; export type SnippetEntry = { name: string; @@ -19,85 +18,165 @@ export type SnippetEntry = { body: string; ref?: string; group?: string; + groups?: string[]; + scenes?: string[]; }; export async function buildRunJSCompletions( hostCtx: any, version = 'v1', + scene?: string | string[], ): Promise<{ completions: Completion[]; entries: SnippetEntry[]; }> { - const doc = hostCtx ? getRunJSDocFor(hostCtx as any, { version }) : FlowRunJSContext.getDoc(); - const sn = await loadSnippets(doc?.snipastes || {}); + // Ensure RunJS contexts are registered (lazy, avoids static cycles) + try { + await setupRunJSContexts(); + } catch (_) { + // ignore if setup fails + } + // 当 hostCtx 不存在时,传入空对象以获取通用(*)上下文的文档 + const doc = getRunJSDocFor((hostCtx as any) || ({} as any), { version }); const completions: Completion[] = []; const toMd = (v: any) => (typeof v === 'string' ? v : JSON.stringify(v)); - const props = doc?.properties || {}; - for (const k of Object.keys(props)) { - completions.push({ label: `ctx.${k}`, type: 'property', info: toMd(props[k]), detail: 'ctx property' }); + if (doc?.label || doc?.properties || doc?.methods) { + completions.push({ + label: 'ctx', + type: 'class', + detail: 'FlowRunJSContext', + info: doc?.label || 'RunJS context', + boost: 115, + } as Completion); } + + const collectProperties = (props: Record | undefined, parentPath: string[] = []) => { + if (!props) return; + for (const [key, value] of Object.entries(props)) { + const path = [...parentPath, key]; + const ctxLabel = `ctx.${path.join('.')}`; + const depth = path.length; + let description: any = value; + let detail: string | undefined; + let children: Record | undefined; + let completionSpec: any; + if (value && typeof value === 'object' && !Array.isArray(value)) { + description = value.description ?? value.detail ?? value.type ?? value; + detail = value.detail ?? value.type ?? 'ctx property'; + completionSpec = value.completion; + children = value.properties as Record | undefined; + } + const apply = completionSpec?.insertText + ? (view: EditorView, _completion: Completion, from: number, to: number) => { + view.dispatch({ + changes: { from, to, insert: completionSpec.insertText }, + selection: { anchor: from + completionSpec.insertText.length }, + scrollIntoView: true, + }); + } + : undefined; + completions.push({ + label: ctxLabel, + type: 'property', + info: toMd(description), + detail: detail || 'ctx property', + boost: Math.max(90 - depth * 5, 10), + apply, + } as Completion); + if (children) { + collectProperties(children, path); + } + } + }; + + collectProperties(doc?.properties || {}); const methods = doc?.methods || {}; for (const k of Object.keys(methods)) { + const methodDoc = methods[k]; + let description: any = methodDoc; + let detail = 'ctx method'; + let completionSpec: any; + if (methodDoc && typeof methodDoc === 'object' && !Array.isArray(methodDoc)) { + description = methodDoc.description ?? methodDoc.detail ?? methodDoc; + detail = methodDoc.detail ?? detail; + completionSpec = methodDoc.completion; + } + const insertText = completionSpec?.insertText ?? `ctx.${k}()`; completions.push({ label: `ctx.${k}()` as any, type: 'function', - info: toMd(methods[k]), - detail: 'ctx method', + info: toMd(description), + detail, + boost: 95, apply: (view: EditorView, _c: Completion, from: number, to: number) => { - view.dispatch({ changes: { from, to, insert: `ctx.${k}()` } }); + view.dispatch({ + changes: { from, to, insert: insertText }, + selection: { anchor: from + insertText.length }, + scrollIntoView: true, + }); + }, + }); + } + let entries: SnippetEntry[] = []; + try { + const ctxClassName = (hostCtx as any)?.model?.constructor?.name || '*'; + const locale = (hostCtx as any)?.api?.auth?.locale || (hostCtx as any)?.i18n?.language; + entries = await listSnippetsForContext(ctxClassName, version, locale); + } catch (_) { + entries = []; + } + + const requestedScenes = Array.isArray(scene) + ? scene.filter((s): s is string => typeof s === 'string' && s.trim().length > 0) + : scene + ? [scene] + : []; + + const filteredEntries = requestedScenes.length + ? entries.filter((entry) => { + if (entry.scenes?.length) { + return entry.scenes.some((sceneName) => requestedScenes.includes(sceneName)); + } + const group = entry.group || ''; + if (!group.startsWith('scene/')) return true; // global/libs snippets + const [_, inferredScene] = group.split('/'); + if (inferredScene) { + return requestedScenes.includes(inferredScene); + } + return false; + }) + : entries; + + const snippetLabelSet = new Set(); + + for (const s of filteredEntries) { + const text = s.body; + const baseLabel = String(s.name ?? '').trim(); + const prefixLabel = typeof s.prefix === 'string' ? s.prefix.trim() : ''; + // 为了与测试及常见补全行为一致,label 仅使用展示名(不拼接 prefix) + const label = baseLabel || prefixLabel; + const displayLabel = label; + const detail = baseLabel && prefixLabel && prefixLabel !== displayLabel ? prefixLabel : undefined; + const dedupeKey = s.ref || `${label}|${detail ?? ''}`; + if (!displayLabel || snippetLabelSet.has(dedupeKey)) continue; + snippetLabelSet.add(dedupeKey); + completions.push({ + label, + displayLabel, + detail, + type: 'snippet', + info: s.description || s.ref, + boost: 80, + apply: (view: EditorView, _completion: Completion, from: number, to: number) => { + view.dispatch({ + changes: { from, to, insert: text }, + selection: { anchor: from + text.length }, + scrollIntoView: true, + }); }, }); } - const entries: SnippetEntry[] = []; - for (const [name, def] of Object.entries(sn || {})) { - const body = typeof def === 'string' ? def : def.body; - const label = (def && def.prefix) || name; - if (!body) continue; - const text = Array.isArray(body) ? body.join('\n') : String(body); - completions.push( - snippetCompletion(text, { - label, - detail: name, - info: (def && def.description) || name, - }) as any, - ); - const ref = (def && def.$ref) || ''; - const group = - typeof ref === 'string' - ? ref - .replace(/^\.\/?/, '') - .split('/') - .slice(0, 2) - .join('/') - : undefined; - entries.push({ name, prefix: def?.prefix, description: def?.description, body: text, ref, group }); - } - - try { - const ctxClassName = (hostCtx as any)?.model?.constructor?.name || '*'; - const classSnippets = await loadSnippetsForContext(ctxClassName, version); - for (const s of classSnippets) { - completions.push( - snippetCompletion(s.body, { - label: s.prefix || s.name, - detail: s.name, - info: s.description || s.ref, - }) as any, - ); - entries.push({ - name: s.name, - prefix: s.prefix, - description: s.description, - body: s.body, - ref: s.ref, - group: s.group, - }); - } - } catch (_) { - // ignore single failure - } - - return { completions, entries }; + return { completions, entries: filteredEntries }; } diff --git a/packages/core/client/src/flow/components/code-editor/snippets/loader.ts b/packages/core/client/src/flow/components/code-editor/snippets/loader.ts deleted file mode 100644 index ea052e83c7a..00000000000 --- a/packages/core/client/src/flow/components/code-editor/snippets/loader.ts +++ /dev/null @@ -1,175 +0,0 @@ -/** - * This file is part of the NocoBase (R) project. - * Copyright (c) 2020-2024 NocoBase Co., Ltd. - * Authors: NocoBase Team. - * - * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. - * For more information, please refer to: https://www.nocobase.com/agreement. - */ - -// TS/JS module-based snippets loader -// Each snippet module exports either default string (body) or { body: string } - -type ModuleLoader = Record Promise>; -// declare for webpack/rspack fallback -// eslint-disable-next-line @typescript-eslint/no-explicit-any -declare const require: any; - -// Engine-provided snippets map (normalized keys like 'global/message-success') -import { engineSnippets as engineModules } from '@nocobase/flow-engine'; - -function buildLocalModuleMap(): ModuleLoader { - try { - const anyMeta: any = import.meta as any; - if (anyMeta && typeof anyMeta.glob === 'function') { - return anyMeta.glob('./**/*.snippet.{ts,js}', { eager: false }); - } - } catch (err) { - try { - console.debug?.('[snippets/loader] import.meta.glob not available', err); - } catch (_) { - void 0; - } - } - // Fallback: webpack/rspack require.context - try { - const req = require?.context('./', true, /\.snippet\.(ts|js)$/); - if (req) { - const map: ModuleLoader = {}; - req.keys().forEach((k: string) => { - map[k] = async () => req(k); - }); - return map; - } - } catch (err) { - try { - console.debug?.('[snippets/loader] require.context not available', err); - } catch (_) { - void 0; - } - } - return {} as ModuleLoader; -} - -function normalizeKey(k: string): string { - // ./scene/jsblock/render-basic.snippet.ts -> scene/jsblock/render-basic - return k.replace(/^\.\//, '').replace(/\.(snippet\.)?(t|j)s$/, ''); -} - -function buildUnifiedMap(): ModuleLoader { - const out: ModuleLoader = {}; - // Local snippets - const locals = buildLocalModuleMap(); - Object.keys(locals).forEach((k) => { - out[normalizeKey(k)] = (locals as any)[k]; - }); - // Engine snippets (already normalized keys) - Object.assign(out, engineModules); - return out; -} - -const modules: ModuleLoader = buildUnifiedMap(); - -export async function loadOne(ref: string): Promise { - const loader = (modules as any)[ref]; - if (!loader) throw new Error(`Snippet not found: ${ref}`); - const mod = await loader(); - const def = mod?.default; - if (def && typeof def === 'object' && typeof def.content === 'string') return def.content; - const val = def ?? mod?.body ?? mod?.content ?? ''; - return typeof val === 'string' ? val : String(val ?? ''); -} - -export async function loadSnippets(snipastes: Record): Promise> { - const out: Record = {}; - for (const [name, def] of Object.entries(snipastes || {})) { - if (def && typeof def === 'object' && typeof (def as any).$ref === 'string') { - try { - out[name] = { ...def, body: await loadOne((def as any).$ref) }; - } catch (err) { - try { - console.debug?.('[snippets/loader] missing snippet ref', (def as any).$ref, err); - } catch (_) { - void 0; - } - out[name] = def; // keep as is if missing - } - } else { - out[name] = def; - } - } - return out; -} - -export type LoadedSnippetEntry = { - name: string; - prefix?: string; - description?: string; - body: string; - ref: string; - group?: string; -}; - -function deriveNameFromKey(key: string): string { - const parts = key.split('/'); - return parts[parts.length - 1] || key; -} - -function groupFromKey(key: string): string | undefined { - const parts = key.split('/'); - if (!parts.length) return undefined; - // 归类规则: - // - global/* -> global - // - libs/* -> libs - // - scene/x/* -> scene/x(如 scene/jsblock、scene/actions) - const first = parts[0]; - if (first === 'global' || first === 'libs') return first; - if (first === 'scene' && parts.length >= 2) return `${first}/${parts[1]}`; - if (parts.length >= 2) return `${parts[0]}/${parts[1]}`; - return parts[0]; -} - -export async function loadSnippetsForContext(ctxClassName: string, version = 'v1'): Promise { - const entries: LoadedSnippetEntry[] = []; - await Promise.all( - Object.entries(modules).map(async ([key, loader]) => { - try { - const mod = await (loader as any)(); - const def = mod?.default; - let meta: any = undefined; - let body: any = undefined; - if (def && typeof def === 'object' && typeof def.content === 'string') { - meta = def; - body = def.content; - } else if (typeof def === 'string') { - body = def; - meta = mod?.meta || mod?.info || {}; - } else if (typeof mod?.body === 'string') { - body = mod.body; - meta = mod?.meta || mod?.info || {}; - } - if (typeof body !== 'string') return; - // If snippet declares contexts, filter; support '*' for all - let ok = true; - if (meta && Array.isArray(meta.contexts) && meta.contexts.length) { - ok = meta.contexts.includes('*') || meta.contexts.includes(ctxClassName); - } - // If versions declared, filter - if (ok && meta && Array.isArray(meta.versions) && meta.versions.length) { - ok = meta.versions.includes('*') || meta.versions.includes(version); - } - if (!ok) return; - const name = meta?.label || deriveNameFromKey(key); - const prefix = meta?.prefix || name; - entries.push({ name, prefix, description: meta?.description, body, ref: key, group: groupFromKey(key) }); - } catch (err) { - try { - console.debug?.('[snippets/loader] load module failed', key, err); - } catch (_) { - void 0; - } - } - }), - ); - return entries; -} diff --git a/packages/core/flow-engine/src/__tests__/flowContextCreateJSRunner.test.ts b/packages/core/flow-engine/src/__tests__/flowContextCreateJSRunner.test.ts new file mode 100644 index 00000000000..a8b1f5e54fa --- /dev/null +++ b/packages/core/flow-engine/src/__tests__/flowContextCreateJSRunner.test.ts @@ -0,0 +1,163 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { describe, it, expect, beforeAll } from 'vitest'; +import { FlowEngineContext } from '../flowContext'; +import { FlowEngine } from '../flowEngine'; +import { JSRunner } from '../JSRunner'; + +describe('FlowContext async createJSRunner', () => { + let engine: FlowEngine; + let engineCtx: FlowEngineContext; + + beforeAll(() => { + // Create minimal engine and context for testing + engine = new FlowEngine(); + engineCtx = new FlowEngineContext(engine); + }); + + describe('createJSRunner method', () => { + it('should be async and return JSRunner instance', async () => { + const runner = await engineCtx.createJSRunner(); + expect(runner).toBeInstanceOf(JSRunner); + }); + + it('should pass globals to JSRunner', async () => { + const customGlobals = { customVar: 'test' }; + const runner = await engineCtx.createJSRunner({ globals: customGlobals }); + const result = await runner.run('return customVar'); + expect(result?.success).toBe(true); + expect(result?.value).toBe('test'); + }); + + it('should always provide ctx in globals', async () => { + const runner = await engineCtx.createJSRunner(); + const result = await runner.run('return typeof ctx !== "undefined"'); + expect(result?.success).toBe(true); + expect(result?.value).toBe(true); + }); + + it('should support timeout option', async () => { + const runner = await engineCtx.createJSRunner({ timeoutMs: 100 }); + expect(runner).toBeInstanceOf(JSRunner); + }); + + it('should select appropriate context based on model class', async () => { + // Test with JSBlockModel + const ctx = new FlowEngineContext(engine); + (ctx as any).defineProperty('model', { + value: { constructor: { name: 'JSBlockModel' } }, + }); + + const runner = await ctx.createJSRunner(); + expect(runner).toBeInstanceOf(JSRunner); + + // Check if ctx.element is accessible (specific to JSBlockModel) + const result = await runner.run('return typeof ctx !== "undefined"'); + expect(result?.success).toBe(true); + }); + + it('should select JSFieldModel context correctly', async () => { + const ctx = new FlowEngineContext(engine); + (ctx as any).defineProperty('model', { + value: { constructor: { name: 'JSFieldModel' } }, + }); + + const runner = await ctx.createJSRunner(); + expect(runner).toBeInstanceOf(JSRunner); + }); + + it('should select JSColumnModel context correctly', async () => { + const ctx = new FlowEngineContext(engine); + (ctx as any).defineProperty('model', { + value: { constructor: { name: 'JSColumnModel' } }, + }); + + const runner = await ctx.createJSRunner(); + expect(runner).toBeInstanceOf(JSRunner); + }); + + it('should fallback to base context for unknown model', async () => { + const ctx = new FlowEngineContext(engine); + (ctx as any).defineProperty('model', { + value: { constructor: { name: 'UnknownModel' } }, + }); + + const runner = await ctx.createJSRunner(); + expect(runner).toBeInstanceOf(JSRunner); + + // Should still have ctx + const result = await runner.run('return typeof ctx !== "undefined"'); + expect(result?.success).toBe(true); + expect(result?.value).toBe(true); + }); + + it('should support version option', async () => { + const runner = await engineCtx.createJSRunner({ version: 'v1' } as any); + expect(runner).toBeInstanceOf(JSRunner); + }); + + it('should merge custom globals with ctx', async () => { + const runner = await engineCtx.createJSRunner({ + globals: { foo: 'bar', baz: 123 }, + }); + + const result = await runner.run('return { hasCtx: typeof ctx !== "undefined", foo, baz }'); + expect(result?.success).toBe(true); + expect(result?.value).toEqual({ hasCtx: true, foo: 'bar', baz: 123 }); + }); + + it('should execute async code successfully', async () => { + const runner = await engineCtx.createJSRunner(); + const result = await runner.run(` + return new Promise(resolve => { + setTimeout(() => resolve('async result'), 10); + }); + `); + expect(result?.success).toBe(true); + expect(result?.value).toBe('async result'); + }); + + it('should handle errors gracefully', async () => { + const runner = await engineCtx.createJSRunner(); + const result = await runner.run('throw new Error("test error")'); + expect(result?.success).toBe(false); + expect(result?.error).toBeTruthy(); + }); + + it('should respect timeout setting', async () => { + const runner = await engineCtx.createJSRunner({ timeoutMs: 50 }); + const result = await runner.run(` + return new Promise(resolve => { + setTimeout(() => resolve('done'), 1000); + }); + `); + // Should timeout before completing + expect(result?.success).toBe(false); + }); + }); + + describe('setupRunJSContexts integration', () => { + it('should auto-setup contexts when createJSRunner is called', async () => { + // Even if setup wasn't called manually, createJSRunner should trigger it + const ctx = new FlowEngineContext(engine); + (ctx as any).defineProperty('model', { + value: { constructor: { name: 'JSBlockModel' } }, + }); + + const runner = await ctx.createJSRunner(); + expect(runner).toBeInstanceOf(JSRunner); + + // Verify context is properly initialized + const result = await runner.run('return typeof ctx !== "undefined"'); + expect(result?.success).toBe(true); + expect(result?.value).toBe(true); + }); + }); +}); diff --git a/packages/core/flow-engine/src/__tests__/flowRunJSContextDefine.test.ts b/packages/core/flow-engine/src/__tests__/flowRunJSContextDefine.test.ts new file mode 100644 index 00000000000..7774057b66f --- /dev/null +++ b/packages/core/flow-engine/src/__tests__/flowRunJSContextDefine.test.ts @@ -0,0 +1,508 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { FlowRunJSContext } from '../flowContext'; + +describe('FlowRunJSContext.define() and getDoc() deep tests', () => { + // Create test classes for inheritance testing + class TestBaseContext extends FlowRunJSContext {} + class TestChildContext extends TestBaseContext {} + class TestGrandchildContext extends TestChildContext {} + + beforeEach(() => { + // Clear all caches to ensure clean state for each test + // @ts-ignore - accessing private WeakMap for testing + const cache = (FlowRunJSContext as any).__runjsDocCache; + if (cache) { + // WeakMaps can't be cleared directly, but we can create new test classes + } + }); + + describe('Deep merging', () => { + it('should deep merge nested object properties', () => { + class MergeTestContext extends FlowRunJSContext {} + + MergeTestContext.define({ + properties: { + api: { + description: 'API client', + properties: { + request: { description: 'Request method' }, + auth: { description: 'Auth info' }, + }, + }, + }, + }); + + MergeTestContext.define({ + properties: { + api: { + properties: { + request: { description: 'Updated request method' }, + query: { description: 'Query method' }, + }, + }, + }, + }); + + const doc = MergeTestContext.getDoc(); + expect(doc.properties?.api).toBeTruthy(); + const apiProp: any = doc.properties?.api; + expect(apiProp.description).toBe('API client'); + expect(apiProp.properties?.request?.description).toBe('Updated request method'); + expect(apiProp.properties?.query?.description).toBe('Query method'); + expect(apiProp.properties?.auth?.description).toBe('Auth info'); + }); + + it('should deep merge method documentation', () => { + class MethodMergeContext extends FlowRunJSContext {} + + MethodMergeContext.define({ + methods: { + runAction: { + description: 'Run an action', + completion: { insertText: 'ctx.runAction()' }, + }, + }, + }); + + MethodMergeContext.define({ + methods: { + runAction: { + description: 'Execute a data action', + examples: ['ctx.runAction("create", {})'], + }, + }, + }); + + const doc = MethodMergeContext.getDoc(); + const method: any = doc.methods?.runAction; + expect(method?.description).toBe('Execute a data action'); + expect(method?.completion?.insertText).toBe('ctx.runAction()'); + expect(method?.examples).toEqual(['ctx.runAction("create", {})']); + }); + }); + + describe('Null value property deletion', () => { + it('should remove property when defined with null', () => { + class NullTestContext extends FlowRunJSContext {} + + NullTestContext.define({ + properties: { + foo: 'Foo property', + bar: 'Bar property', + }, + }); + + NullTestContext.define({ + properties: { + foo: null as any, + }, + }); + + const doc = NullTestContext.getDoc(); + expect(doc.properties?.foo).toBeUndefined(); + expect(doc.properties?.bar).toBe('Bar property'); + }); + + it('should remove nested property when defined with null', () => { + class NestedNullContext extends FlowRunJSContext {} + + NestedNullContext.define({ + properties: { + api: { + description: 'API client', + properties: { + request: { description: 'Request' }, + query: { description: 'Query' }, + }, + }, + }, + }); + + NestedNullContext.define({ + properties: { + api: { + properties: { + query: null as any, + }, + }, + }, + }); + + const doc = NestedNullContext.getDoc(); + const apiProp: any = doc.properties?.api; + expect(apiProp.properties?.request?.description).toBe('Request'); + expect(apiProp.properties?.query).toBeUndefined(); + }); + + it('should remove method when defined with null', () => { + class MethodNullContext extends FlowRunJSContext {} + + MethodNullContext.define({ + methods: { + foo: 'Foo method', + bar: 'Bar method', + }, + }); + + MethodNullContext.define({ + methods: { + foo: null as any, + }, + }); + + const doc = MethodNullContext.getDoc(); + expect(doc.methods?.foo).toBeUndefined(); + expect(doc.methods?.bar).toBe('Bar method'); + }); + }); + + describe('Inheritance chain merging', () => { + it('should merge metadata from parent class', () => { + class InheritBaseContext extends FlowRunJSContext {} + class InheritChildContext extends InheritBaseContext {} + + InheritBaseContext.define({ + label: 'Base Context', + properties: { + baseProp: 'Base property', + }, + }); + + InheritChildContext.define({ + label: 'Child Context', + properties: { + childProp: 'Child property', + }, + }); + + const doc = InheritChildContext.getDoc(); + expect(doc.label).toBe('Child Context'); + expect(doc.properties?.baseProp).toBe('Base property'); + expect(doc.properties?.childProp).toBe('Child property'); + }); + + it('should merge metadata from entire inheritance chain', () => { + class ChainBase extends FlowRunJSContext {} + class ChainMiddle extends ChainBase {} + class ChainLeaf extends ChainMiddle {} + + ChainBase.define({ + properties: { base: 'base' }, + methods: { baseMethod: 'base method' }, + }); + + ChainMiddle.define({ + properties: { middle: 'middle' }, + methods: { middleMethod: 'middle method' }, + }); + + ChainLeaf.define({ + properties: { leaf: 'leaf' }, + methods: { leafMethod: 'leaf method' }, + }); + + const doc = ChainLeaf.getDoc(); + expect(doc.properties?.base).toBe('base'); + expect(doc.properties?.middle).toBe('middle'); + expect(doc.properties?.leaf).toBe('leaf'); + expect(doc.methods?.baseMethod).toBe('base method'); + expect(doc.methods?.middleMethod).toBe('middle method'); + expect(doc.methods?.leafMethod).toBe('leaf method'); + }); + + it('should allow child to override parent properties', () => { + class OverrideBase extends FlowRunJSContext {} + class OverrideChild extends OverrideBase {} + + OverrideBase.define({ + properties: { + shared: 'Base version', + }, + }); + + OverrideChild.define({ + properties: { + shared: 'Child version', + }, + }); + + const doc = OverrideChild.getDoc(); + expect(doc.properties?.shared).toBe('Child version'); + }); + + it('should allow child to remove parent properties with null', () => { + class RemoveBase extends FlowRunJSContext {} + class RemoveChild extends RemoveBase {} + + RemoveBase.define({ + properties: { + toRemove: 'To be removed', + toKeep: 'To keep', + }, + }); + + RemoveChild.define({ + properties: { + toRemove: null as any, + }, + }); + + const doc = RemoveChild.getDoc(); + expect(doc.properties?.toRemove).toBeUndefined(); + expect(doc.properties?.toKeep).toBe('To keep'); + }); + }); + + describe('Locale-specific metadata', () => { + it('should support multiple locales', () => { + class LocaleContext extends FlowRunJSContext {} + + LocaleContext.define({ + label: 'Default Label', + properties: { message: 'Default message' }, + }); + + LocaleContext.define( + { + label: 'Chinese Label', + properties: { message: '中文消息' }, + }, + { locale: 'zh-CN' }, + ); + + LocaleContext.define( + { + label: 'Japanese Label', + properties: { message: '日本語メッセージ' }, + }, + { locale: 'ja-JP' }, + ); + + const defaultDoc = LocaleContext.getDoc(); + expect(defaultDoc.label).toBe('Default Label'); + expect(defaultDoc.properties?.message).toBe('Default message'); + + const zhDoc = LocaleContext.getDoc('zh-CN'); + expect(zhDoc.label).toBe('Chinese Label'); + expect(zhDoc.properties?.message).toBe('中文消息'); + + const jaDoc = LocaleContext.getDoc('ja-JP'); + expect(jaDoc.label).toBe('Japanese Label'); + expect(jaDoc.properties?.message).toBe('日本語メッセージ'); + }); + + it('should merge locale-specific metadata with default', () => { + class LocaleMergeContext extends FlowRunJSContext {} + + LocaleMergeContext.define({ + properties: { + api: 'API client', + message: 'Message', + }, + }); + + LocaleMergeContext.define( + { + properties: { + message: '消息', + }, + }, + { locale: 'zh-CN' }, + ); + + const zhDoc = LocaleMergeContext.getDoc('zh-CN'); + expect(zhDoc.properties?.api).toBe('API client'); + expect(zhDoc.properties?.message).toBe('消息'); + }); + + it('should support locale inheritance', () => { + class LocaleInheritBase extends FlowRunJSContext {} + class LocaleInheritChild extends LocaleInheritBase {} + + LocaleInheritBase.define({ properties: { base: 'base' } }); + LocaleInheritBase.define({ properties: { base: '基础' } }, { locale: 'zh-CN' }); + + LocaleInheritChild.define({ properties: { child: 'child' } }); + LocaleInheritChild.define({ properties: { child: '子级' } }, { locale: 'zh-CN' }); + + const zhDoc = LocaleInheritChild.getDoc('zh-CN'); + expect(zhDoc.properties?.base).toBe('基础'); + expect(zhDoc.properties?.child).toBe('子级'); + }); + }); + + describe('Cache behavior', () => { + it('should cache getDoc results', () => { + class CacheContext extends FlowRunJSContext {} + + CacheContext.define({ + properties: { foo: 'bar' }, + }); + + const doc1 = CacheContext.getDoc(); + const doc2 = CacheContext.getDoc(); + + // Should return the same cached object + expect(doc1).toBe(doc2); + }); + + it('should invalidate cache when define is called', () => { + class InvalidateContext extends FlowRunJSContext {} + + InvalidateContext.define({ + properties: { foo: 'initial' }, + }); + + const doc1 = InvalidateContext.getDoc(); + expect(doc1.properties?.foo).toBe('initial'); + + InvalidateContext.define({ + properties: { foo: 'updated' }, + }); + + const doc2 = InvalidateContext.getDoc(); + expect(doc2.properties?.foo).toBe('updated'); + + // Should be different objects after invalidation + expect(doc1).not.toBe(doc2); + }); + + it('should cache different locales separately', () => { + class LocaleCacheContext extends FlowRunJSContext {} + + LocaleCacheContext.define({ properties: { msg: 'English' } }); + LocaleCacheContext.define({ properties: { msg: '中文' } }, { locale: 'zh-CN' }); + + const enDoc1 = LocaleCacheContext.getDoc(); + const zhDoc1 = LocaleCacheContext.getDoc('zh-CN'); + const enDoc2 = LocaleCacheContext.getDoc(); + const zhDoc2 = LocaleCacheContext.getDoc('zh-CN'); + + expect(enDoc1).toBe(enDoc2); + expect(zhDoc1).toBe(zhDoc2); + expect(enDoc1).not.toBe(zhDoc1); + }); + }); + + describe('Edge cases', () => { + it('should handle empty define calls', () => { + class EmptyContext extends FlowRunJSContext {} + + EmptyContext.define({}); + const doc = EmptyContext.getDoc(); + + expect(doc).toBeTruthy(); + expect(typeof doc).toBe('object'); + }); + + it('should handle undefined values gracefully', () => { + class UndefinedContext extends FlowRunJSContext {} + + UndefinedContext.define({ + properties: { + foo: undefined as any, + }, + }); + + const doc = UndefinedContext.getDoc(); + expect(doc.properties?.foo).toBeUndefined(); + }); + + it('should handle arrays in properties', () => { + class ArrayContext extends FlowRunJSContext {} + + ArrayContext.define({ + properties: { + examples: ['example1', 'example2'] as any, + }, + }); + + const doc = ArrayContext.getDoc(); + expect(doc.properties?.examples).toEqual(['example1', 'example2']); + }); + + it('should not merge arrays, replace them instead', () => { + class ArrayReplaceContext extends FlowRunJSContext {} + + ArrayReplaceContext.define({ + properties: { + items: ['a', 'b'] as any, + }, + }); + + ArrayReplaceContext.define({ + properties: { + items: ['c', 'd'] as any, + }, + }); + + const doc = ArrayReplaceContext.getDoc(); + expect(doc.properties?.items).toEqual(['c', 'd']); + }); + }); + + describe('Multiple define calls', () => { + it('should accumulate metadata from multiple define calls', () => { + class MultiDefineContext extends FlowRunJSContext {} + + MultiDefineContext.define({ + properties: { prop1: 'value1' }, + }); + + MultiDefineContext.define({ + properties: { prop2: 'value2' }, + }); + + MultiDefineContext.define({ + methods: { method1: 'desc1' }, + }); + + const doc = MultiDefineContext.getDoc(); + expect(doc.properties?.prop1).toBe('value1'); + expect(doc.properties?.prop2).toBe('value2'); + expect(doc.methods?.method1).toBe('desc1'); + }); + + it('should allow incremental updates to nested structures', () => { + class IncrementalContext extends FlowRunJSContext {} + + IncrementalContext.define({ + properties: { + api: { + description: 'API client', + properties: { + request: { description: 'Request method' }, + }, + }, + }, + }); + + IncrementalContext.define({ + properties: { + api: { + detail: 'APIClient instance', + properties: { + query: { description: 'Query method' }, + }, + }, + }, + }); + + const doc = IncrementalContext.getDoc(); + const apiProp: any = doc.properties?.api; + expect(apiProp.description).toBe('API client'); + expect(apiProp.detail).toBe('APIClient instance'); + expect(apiProp.properties?.request?.description).toBe('Request method'); + expect(apiProp.properties?.query?.description).toBe('Query method'); + }); + }); +}); diff --git a/packages/core/flow-engine/src/__tests__/runjsContext.test.ts b/packages/core/flow-engine/src/__tests__/runjsContext.test.ts index fb85d29b422..cf65498741a 100644 --- a/packages/core/flow-engine/src/__tests__/runjsContext.test.ts +++ b/packages/core/flow-engine/src/__tests__/runjsContext.test.ts @@ -7,54 +7,235 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeAll } from 'vitest'; import { RunJSContextRegistry, - registerDefaultMappings, getRunJSDocFor, createJSRunnerWithVersion, - FlowRunJSContext, -} from '../runjs-context'; + getRunJSScenesForModel, + getRunJSScenesForContext, +} from '..'; +import { setupRunJSContexts } from '../runjs-context/setup'; +import { FlowContext } from '../flowContext'; import { JSRunner } from '../JSRunner'; -import type { FlowContext } from '../flowContext'; describe('flowRunJSContext registry and doc', () => { - it('registerDefaultMappings should register v1 mapping', () => { - registerDefaultMappings(); - expect(RunJSContextRegistry['resolve']('v1' as any, '*')).toBeTruthy(); + beforeAll(async () => { + await setupRunJSContexts(); }); - it('getRunJSDocFor should pick subclass by model class name', () => { - const ctx: any = { model: { constructor: { name: 'JSBlockModel' } } }; - const doc = getRunJSDocFor(ctx as any, { version: 'v1' }); - expect(doc?.label).toMatch(/JSBlock RunJS/); + describe('setupRunJSContexts', () => { + it('should register v1 mapping', () => { + expect(RunJSContextRegistry['resolve']('v1' as any, '*')).toBeTruthy(); + }); + + it('should register all context types', () => { + const contextTypes = [ + 'JSBlockModel', + 'JSFieldModel', + 'JSItemModel', + 'JSColumnModel', + 'FormJSFieldItemModel', + 'JSRecordActionModel', + 'JSCollectionActionModel', + ]; + + contextTypes.forEach((modelClass) => { + const ctor = RunJSContextRegistry['resolve']('v1' as any, modelClass); + expect(ctor).toBeTruthy(); + }); + }); + + it('should expose scene metadata for contexts', () => { + expect(getRunJSScenesForModel('JSBlockModel', 'v1')).toEqual(['block']); + expect(getRunJSScenesForModel('JSFieldModel', 'v1')).toEqual(['detail']); + expect(getRunJSScenesForModel('UnknownModel', 'v1')).toEqual([]); + }); + + it('should only execute once (idempotent)', async () => { + const ctor1 = RunJSContextRegistry['resolve']('v1' as any, '*'); + await setupRunJSContexts(); + await setupRunJSContexts(); + const ctor2 = RunJSContextRegistry['resolve']('v1' as any, '*'); + expect(ctor1).toBe(ctor2); + }); }); - it('createJSRunnerWithVersion returns a JSRunner', async () => { - const stubCtx: any = { - model: { constructor: { name: 'JSFieldModel' } }, - createProxy() { - return this; - }, - }; - const runner = createJSRunnerWithVersion.call(stubCtx, { version: 'v1' }); - expect(runner).toBeInstanceOf(JSRunner); - const result = await runner.run('return 1 + 1'); - expect(result?.success).toBe(true); - expect(result?.value).toBe(2); + describe('getRunJSDocFor', () => { + it('should pick subclass by model class name', () => { + const ctx: any = { model: { constructor: { name: 'JSBlockModel' } } }; + const doc = getRunJSDocFor(ctx as any, { version: 'v1' }); + expect(doc?.label).toMatch(/RunJS/); + }); + + it('should return base doc for unknown model', () => { + const ctx: any = { model: { constructor: { name: 'UnknownModel' } } }; + const doc = getRunJSDocFor(ctx as any, { version: 'v1' }); + expect(doc).toBeTruthy(); + expect(doc?.label).toMatch(/RunJS base/); + }); + + it('should support locale-specific doc', () => { + const ctx = new FlowContext(); + (ctx as any).defineProperty('model', { value: { constructor: { name: 'JSFieldModel' } } }); + (ctx as any).defineProperty('api', { value: { auth: { locale: 'zh-CN' } } }); + const doc = getRunJSDocFor(ctx as any, { version: 'v1' }); + expect(doc?.properties?.message).toMatch(/Ant Design 全局消息/); + }); + + it('should fallback to English when locale is not found', () => { + const ctx: any = { model: { constructor: { name: 'JSBlockModel' } } }; + const doc = getRunJSDocFor(ctx as any, { version: 'v1' }); + expect(doc?.label).toBeTruthy(); + }); }); - it('default globals (window/document) should be injected for field/block contexts', async () => { - const stubCtx: any = { - model: { constructor: { name: 'JSFieldModel' } }, - createProxy() { - return this; - }, - }; - const runner = createJSRunnerWithVersion.call(stubCtx, { version: 'v1' }); - const r = await runner.run('return typeof window !== "undefined" && typeof document !== "undefined"'); - expect(r.success && r.value).toBe(true); + describe('createJSRunnerWithVersion', () => { + it('should return a JSRunner instance', () => { + const ctx = new FlowContext(); + ctx.defineProperty('model', { + value: { constructor: { name: 'JSFieldModel' } }, + }); + const runner = createJSRunnerWithVersion.call(ctx, { version: 'v1' }); + expect(runner).toBeInstanceOf(JSRunner); + }); + + it('should execute JavaScript code successfully', async () => { + const ctx = new FlowContext(); + ctx.defineProperty('model', { + value: { constructor: { name: 'JSFieldModel' } }, + }); + const runner = createJSRunnerWithVersion.call(ctx, { version: 'v1' }); + const result = await runner.run('return 1 + 1'); + expect(result?.success).toBe(true); + expect(result?.value).toBe(2); + }); + + it('should inject window/document for field contexts', async () => { + const ctx = new FlowContext(); + ctx.defineProperty('model', { + value: { constructor: { name: 'JSFieldModel' } }, + }); + const runner = createJSRunnerWithVersion.call(ctx, { version: 'v1' }); + const r = await runner.run('return typeof window !== "undefined" && typeof document !== "undefined"'); + expect(r.success && r.value).toBe(true); + }); + + it('should inject window/document for block contexts', async () => { + const ctx = new FlowContext(); + ctx.defineProperty('model', { + value: { constructor: { name: 'JSBlockModel' } }, + }); + const runner = createJSRunnerWithVersion.call(ctx, { version: 'v1' }); + const r = await runner.run('return typeof window !== "undefined" && typeof document !== "undefined"'); + expect(r.success && r.value).toBe(true); + }); + + it('should provide ctx variable in globals', async () => { + const ctx = new FlowContext(); + ctx.defineProperty('model', { + value: { constructor: { name: 'JSFieldModel' } }, + }); + const runner = createJSRunnerWithVersion.call(ctx, { version: 'v1' }); + const r = await runner.run('return typeof ctx !== "undefined"'); + expect(r.success && r.value).toBe(true); + }); + + it('should throw error when no context is registered', () => { + const ctx = new FlowContext(); + ctx.defineProperty('model', { + value: { constructor: { name: 'UnknownModel' } }, + }); + // Clear registry temporarily - this test checks the error case + const originalResolve = RunJSContextRegistry['resolve']; + RunJSContextRegistry['resolve'] = () => undefined; + + expect(() => { + createJSRunnerWithVersion.call(ctx, { version: 'v999' as any }); + }).toThrow(/No RunJSContext registered/); + + // Restore + RunJSContextRegistry['resolve'] = originalResolve; + }); }); - // Linkage kind via __runjsKind removed; linkage scripts now run in the host model context. + describe('Context-specific features', () => { + it('JSColumnModel context should be available', () => { + const ctor = RunJSContextRegistry['resolve']('v1' as any, 'JSColumnModel'); + expect(ctor).toBeTruthy(); + const ctx = new FlowContext(); + const instance = new (ctor as any)(ctx); + expect(instance).toBeTruthy(); + }); + + it('should resolve scenes from context instance', () => { + const ctx = new FlowContext(); + ctx.defineProperty('model', { value: { constructor: { name: 'JSColumnModel' } } }); + expect(getRunJSScenesForContext(ctx as any, { version: 'v1' })).toEqual(['table']); + }); + + it('JSBlockModel context should have element property in doc', () => { + const ctx: any = { model: { constructor: { name: 'JSBlockModel' } } }; + const doc = getRunJSDocFor(ctx as any, { version: 'v1' }); + expect(doc?.properties?.element).toBeTruthy(); + }); + + it('JSFieldModel context should have record property in doc', () => { + const ctx: any = { model: { constructor: { name: 'JSFieldModel' } } }; + const doc = getRunJSDocFor(ctx as any, { version: 'v1' }); + expect(doc?.properties?.record).toBeTruthy(); + }); + + it('JSItemModel context should have element and record properties in doc', () => { + const ctx: any = { model: { constructor: { name: 'JSItemModel' } } }; + const doc = getRunJSDocFor(ctx as any, { version: 'v1' }); + expect(doc?.properties?.element).toBeTruthy(); + expect(doc?.properties?.record).toBeTruthy(); + expect(doc?.properties?.resource).toBeTruthy(); + }); + }); + + describe('Base context metadata', () => { + it('should have logger property in base context', () => { + const ctx: any = { model: { constructor: { name: '*' } } }; + const doc = getRunJSDocFor(ctx as any, { version: 'v1' }); + expect(doc?.properties?.logger).toBeTruthy(); + }); + + it('should have message property in base context', () => { + const ctx: any = { model: { constructor: { name: '*' } } }; + const doc = getRunJSDocFor(ctx as any, { version: 'v1' }); + expect(doc?.properties?.message).toBeTruthy(); + }); + + it('should have api property in base context', () => { + const ctx: any = { model: { constructor: { name: '*' } } }; + const doc = getRunJSDocFor(ctx as any, { version: 'v1' }); + expect(doc?.properties?.api).toBeTruthy(); + }); + + it('should have t method in base context', () => { + const ctx: any = { model: { constructor: { name: '*' } } }; + const doc = getRunJSDocFor(ctx as any, { version: 'v1' }); + expect(doc?.methods?.t).toBeTruthy(); + }); + + it('should have requireAsync method in base context', () => { + const ctx: any = { model: { constructor: { name: '*' } } }; + const doc = getRunJSDocFor(ctx as any, { version: 'v1' }); + expect(doc?.methods?.requireAsync).toBeTruthy(); + }); + + it('should have runAction method in base context', () => { + const ctx: any = { model: { constructor: { name: '*' } } }; + const doc = getRunJSDocFor(ctx as any, { version: 'v1' }); + expect(doc?.methods?.runAction).toBeTruthy(); + }); + + it('should have openView method in base context', () => { + const ctx: any = { model: { constructor: { name: '*' } } }; + const doc = getRunJSDocFor(ctx as any, { version: 'v1' }); + expect(doc?.methods?.openView).toBeTruthy(); + }); + }); }); diff --git a/packages/core/flow-engine/src/__tests__/runjsContextImplementations.test.ts b/packages/core/flow-engine/src/__tests__/runjsContextImplementations.test.ts new file mode 100644 index 00000000000..08bf4bb7797 --- /dev/null +++ b/packages/core/flow-engine/src/__tests__/runjsContextImplementations.test.ts @@ -0,0 +1,217 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { describe, it, expect, beforeAll } from 'vitest'; +import { RunJSContextRegistry, getRunJSDocFor } from '..'; +import { setupRunJSContexts } from '../runjs-context/setup'; +import { FlowContext } from '../flowContext'; + +describe('Specific RunJSContext implementations', () => { + beforeAll(async () => { + await setupRunJSContexts(); + }); + + describe('JSColumnRunJSContext', () => { + it('should be registered for JSColumnModel', () => { + const ctor = RunJSContextRegistry['resolve']('v1' as any, 'JSColumnModel'); + expect(ctor).toBeTruthy(); + }); + + it('should have element property in doc', () => { + const ctx: any = { model: { constructor: { name: 'JSColumnModel' } } }; + const doc = getRunJSDocFor(ctx as any, { version: 'v1' }); + expect(doc?.properties?.element).toBeTruthy(); + expect(doc?.properties?.element).toContain('ElementProxy'); + }); + + it('should have record property in doc', () => { + const ctx: any = { model: { constructor: { name: 'JSColumnModel' } } }; + const doc = getRunJSDocFor(ctx as any, { version: 'v1' }); + expect(doc?.properties?.record).toBeTruthy(); + expect(doc?.properties?.record).toContain('row record'); + }); + + it('should have recordIndex property in doc', () => { + const ctx: any = { model: { constructor: { name: 'JSColumnModel' } } }; + const doc = getRunJSDocFor(ctx as any, { version: 'v1' }); + expect(doc?.properties?.recordIndex).toBeTruthy(); + }); + + it('should have collection property in doc', () => { + const ctx: any = { model: { constructor: { name: 'JSColumnModel' } } }; + const doc = getRunJSDocFor(ctx as any, { version: 'v1' }); + expect(doc?.properties?.collection).toBeTruthy(); + }); + + it('should have viewer property in doc', () => { + const ctx: any = { model: { constructor: { name: 'JSColumnModel' } } }; + const doc = getRunJSDocFor(ctx as any, { version: 'v1' }); + expect(doc?.properties?.viewer).toBeTruthy(); + }); + + it('should have onRefReady method in doc', () => { + const ctx: any = { model: { constructor: { name: 'JSColumnModel' } } }; + const doc = getRunJSDocFor(ctx as any, { version: 'v1' }); + expect(doc?.methods?.onRefReady).toBeTruthy(); + }); + + it('should support zh-CN locale', () => { + const ctx = new FlowContext(); + (ctx as any).defineProperty('model', { value: { constructor: { name: 'JSColumnModel' } } }); + (ctx as any).defineProperty('api', { value: { auth: { locale: 'zh-CN' } } }); + const doc = getRunJSDocFor(ctx as any, { version: 'v1' }); + expect(doc?.label).toMatch(/JS 列/); + expect(doc?.properties?.element).toContain('表格单元格'); + }); + + it('should create instance successfully', () => { + const ctor = RunJSContextRegistry['resolve']('v1' as any, 'JSColumnModel'); + const baseCtx = new FlowContext(); + const instance = new (ctor as any)(baseCtx); + expect(instance).toBeTruthy(); + }); + }); + + describe('JSBlockRunJSContext', () => { + it('should have React and antd in doc', () => { + const ctx: any = { model: { constructor: { name: 'JSBlockModel' } } }; + const doc = getRunJSDocFor(ctx as any, { version: 'v1' }); + expect(doc?.properties?.React).toBeTruthy(); + expect(doc?.properties?.antd).toBeTruthy(); + }); + + it('should have element property', () => { + const ctx: any = { model: { constructor: { name: 'JSBlockModel' } } }; + const doc = getRunJSDocFor(ctx as any, { version: 'v1' }); + expect(doc?.properties?.element).toBeTruthy(); + }); + + it('should support zh-CN locale', () => { + const ctx = new FlowContext(); + (ctx as any).defineProperty('model', { value: { constructor: { name: 'JSBlockModel' } } }); + (ctx as any).defineProperty('api', { value: { auth: { locale: 'zh-CN' } } }); + const doc = getRunJSDocFor(ctx as any, { version: 'v1' }); + expect(doc?.label).toContain('RunJS'); + }); + }); + + describe('JSFieldRunJSContext', () => { + it('should have record, value, and collection properties', () => { + const ctx: any = { model: { constructor: { name: 'JSFieldModel' } } }; + const doc = getRunJSDocFor(ctx as any, { version: 'v1' }); + expect(doc?.properties?.record).toBeTruthy(); + expect(doc?.properties?.value).toBeTruthy(); + expect(doc?.properties?.collection).toBeTruthy(); + }); + + it('should have element property', () => { + const ctx: any = { model: { constructor: { name: 'JSFieldModel' } } }; + const doc = getRunJSDocFor(ctx as any, { version: 'v1' }); + expect(doc?.properties?.element).toBeTruthy(); + }); + + it('should support zh-CN locale', () => { + const ctx = new FlowContext(); + (ctx as any).defineProperty('model', { value: { constructor: { name: 'JSFieldModel' } } }); + (ctx as any).defineProperty('api', { value: { auth: { locale: 'zh-CN' } } }); + const doc = getRunJSDocFor(ctx as any, { version: 'v1' }); + expect(doc?.label).toMatch(/JS 字段/); + }); + }); + + describe('JSItemRunJSContext', () => { + it('should have element and record properties', () => { + const ctx: any = { model: { constructor: { name: 'JSItemModel' } } }; + const doc = getRunJSDocFor(ctx as any, { version: 'v1' }); + expect(doc?.properties?.element).toBeTruthy(); + expect(doc?.properties?.record).toBeTruthy(); + }); + + it('should have resource property', () => { + const ctx: any = { model: { constructor: { name: 'JSItemModel' } } }; + const doc = getRunJSDocFor(ctx as any, { version: 'v1' }); + expect(doc?.properties?.resource).toBeTruthy(); + }); + + it('should support zh-CN locale', () => { + const ctx = new FlowContext(); + (ctx as any).defineProperty('model', { value: { constructor: { name: 'JSItemModel' } } }); + (ctx as any).defineProperty('api', { value: { auth: { locale: 'zh-CN' } } }); + const doc = getRunJSDocFor(ctx as any, { version: 'v1' }); + expect(doc?.label).toMatch(/JS 表单项/); + }); + }); + + describe('JSRecordActionRunJSContext', () => { + it('should have record property', () => { + const ctx: any = { model: { constructor: { name: 'JSRecordActionModel' } } }; + const doc = getRunJSDocFor(ctx as any, { version: 'v1' }); + expect(doc?.properties?.record).toBeTruthy(); + }); + + it('should have filterByTk property', () => { + const ctx: any = { model: { constructor: { name: 'JSRecordActionModel' } } }; + const doc = getRunJSDocFor(ctx as any, { version: 'v1' }); + expect(doc?.properties?.filterByTk).toBeTruthy(); + }); + + it('should support zh-CN locale', () => { + const ctx = new FlowContext(); + (ctx as any).defineProperty('model', { value: { constructor: { name: 'JSRecordActionModel' } } }); + (ctx as any).defineProperty('api', { value: { auth: { locale: 'zh-CN' } } }); + const doc = getRunJSDocFor(ctx as any, { version: 'v1' }); + expect(doc?.label).toMatch(/JS 记录动作/); + }); + }); + + describe('JSCollectionActionRunJSContext', () => { + it('should have resource property', () => { + const ctx: any = { model: { constructor: { name: 'JSCollectionActionModel' } } }; + const doc = getRunJSDocFor(ctx as any, { version: 'v1' }); + expect(doc?.properties?.resource).toBeTruthy(); + }); + + it('should support zh-CN locale', () => { + const ctx = new FlowContext(); + (ctx as any).defineProperty('model', { value: { constructor: { name: 'JSCollectionActionModel' } } }); + (ctx as any).defineProperty('api', { value: { auth: { locale: 'zh-CN' } } }); + const doc = getRunJSDocFor(ctx as any, { version: 'v1' }); + expect(doc?.label).toMatch(/JS 集合动作/); + }); + }); + + describe('FormJSFieldItemRunJSContext', () => { + it('should have element property', () => { + const ctx: any = { model: { constructor: { name: 'FormJSFieldItemModel' } } }; + const doc = getRunJSDocFor(ctx as any, { version: 'v1' }); + expect(doc?.properties?.element).toBeTruthy(); + }); + + it('should have value and record properties', () => { + const ctx: any = { model: { constructor: { name: 'FormJSFieldItemModel' } } }; + const doc = getRunJSDocFor(ctx as any, { version: 'v1' }); + expect(doc?.properties?.value).toBeTruthy(); + expect(doc?.properties?.record).toBeTruthy(); + }); + + it('should have setProps method', () => { + const ctx: any = { model: { constructor: { name: 'FormJSFieldItemModel' } } }; + const doc = getRunJSDocFor(ctx as any, { version: 'v1' }); + expect(doc?.methods?.setProps).toBeTruthy(); + }); + + it('should support zh-CN locale', () => { + const ctx = new FlowContext(); + (ctx as any).defineProperty('model', { value: { constructor: { name: 'FormJSFieldItemModel' } } }); + (ctx as any).defineProperty('api', { value: { auth: { locale: 'zh-CN' } } }); + const doc = getRunJSDocFor(ctx as any, { version: 'v1' }); + expect(doc?.label).toMatch(/表单 JS 字段项/); + }); + }); +}); diff --git a/packages/core/flow-engine/src/__tests__/runjsContextRuntime.test.ts b/packages/core/flow-engine/src/__tests__/runjsContextRuntime.test.ts new file mode 100644 index 00000000000..1b6851f7ec5 --- /dev/null +++ b/packages/core/flow-engine/src/__tests__/runjsContextRuntime.test.ts @@ -0,0 +1,269 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { describe, it, expect, beforeAll } from 'vitest'; +import { FlowContext } from '../flowContext'; +import { createJSRunnerWithVersion, getRunJSDocFor } from '..'; +import { setupRunJSContexts } from '../runjs-context/setup'; + +describe('RunJS Context Runtime Behavior', () => { + beforeAll(async () => { + await setupRunJSContexts(); + }); + + describe('JSBlockRunJSContext', () => { + it('should create JSBlock context successfully', async () => { + const ctx = new FlowContext(); + ctx.defineProperty('model', { value: { constructor: { name: 'JSBlockModel' } } }); + + const runner = createJSRunnerWithVersion.call(ctx, { version: 'v1' }); + const result = await runner.run('return typeof ctx !== "undefined"'); + + expect(result?.success).toBe(true); + expect(result?.value).toBe(true); + }); + + it('should have element property in documentation', () => { + const ctx: any = { model: { constructor: { name: 'JSBlockModel' } } }; + const doc = getRunJSDocFor(ctx as any, { version: 'v1' }); + + expect(doc?.properties?.element).toBeTruthy(); + expect(doc?.properties?.React).toBeTruthy(); + expect(doc?.properties?.antd).toBeTruthy(); + }); + + it('should have onRefReady method in documentation', () => { + const ctx: any = { model: { constructor: { name: 'JSBlockModel' } } }; + const doc = getRunJSDocFor(ctx as any, { version: 'v1' }); + + expect(doc?.methods?.onRefReady).toBeTruthy(); + expect(doc?.methods?.requireAsync).toBeTruthy(); + }); + }); + + describe('JSFieldRunJSContext', () => { + it('should create JSField context successfully', async () => { + const ctx = new FlowContext(); + ctx.defineProperty('model', { value: { constructor: { name: 'JSFieldModel' } } }); + + const runner = createJSRunnerWithVersion.call(ctx, { version: 'v1' }); + const result = await runner.run('return typeof ctx !== "undefined"'); + + expect(result?.success).toBe(true); + expect(result?.value).toBe(true); + }); + + it('should have field-specific properties in documentation', () => { + const ctx: any = { model: { constructor: { name: 'JSFieldModel' } } }; + const doc = getRunJSDocFor(ctx as any, { version: 'v1' }); + + expect(doc?.properties?.element).toBeTruthy(); + expect(doc?.properties?.value).toBeTruthy(); + expect(doc?.properties?.record).toBeTruthy(); + expect(doc?.properties?.collection).toBeTruthy(); + }); + }); + + describe('JSItemRunJSContext', () => { + it('should create JSItem context successfully', async () => { + const ctx = new FlowContext(); + ctx.defineProperty('model', { value: { constructor: { name: 'JSItemModel' } } }); + + const runner = createJSRunnerWithVersion.call(ctx, { version: 'v1' }); + const result = await runner.run('return typeof ctx !== "undefined"'); + + expect(result?.success).toBe(true); + expect(result?.value).toBe(true); + }); + + it('should have item-specific properties in documentation', () => { + const ctx: any = { model: { constructor: { name: 'JSItemModel' } } }; + const doc = getRunJSDocFor(ctx as any, { version: 'v1' }); + + expect(doc?.properties?.element).toBeTruthy(); + expect(doc?.properties?.record).toBeTruthy(); + expect(doc?.properties?.resource).toBeTruthy(); + }); + }); + + describe('JSColumnRunJSContext', () => { + it('should create JSColumn context successfully', async () => { + const ctx = new FlowContext(); + ctx.defineProperty('model', { value: { constructor: { name: 'JSColumnModel' } } }); + + const runner = createJSRunnerWithVersion.call(ctx, { version: 'v1' }); + const result = await runner.run('return typeof ctx !== "undefined"'); + + expect(result?.success).toBe(true); + expect(result?.value).toBe(true); + }); + + it('should have column-specific properties in documentation', () => { + const ctx: any = { model: { constructor: { name: 'JSColumnModel' } } }; + const doc = getRunJSDocFor(ctx as any, { version: 'v1' }); + + expect(doc?.properties?.element).toBeTruthy(); + expect(doc?.properties?.record).toBeTruthy(); + expect(doc?.properties?.recordIndex).toBeTruthy(); + expect(doc?.properties?.collection).toBeTruthy(); + expect(doc?.properties?.viewer).toBeTruthy(); + }); + }); + + describe('FormJSFieldItemRunJSContext', () => { + it('should create FormJSFieldItem context successfully', async () => { + const ctx = new FlowContext(); + ctx.defineProperty('model', { value: { constructor: { name: 'FormJSFieldItemModel' } } }); + + const runner = createJSRunnerWithVersion.call(ctx, { version: 'v1' }); + const result = await runner.run('return typeof ctx !== "undefined"'); + + expect(result?.success).toBe(true); + expect(result?.value).toBe(true); + }); + + it('should have form field-specific properties and methods in documentation', () => { + const ctx: any = { model: { constructor: { name: 'FormJSFieldItemModel' } } }; + const doc = getRunJSDocFor(ctx as any, { version: 'v1' }); + + expect(doc?.properties?.element).toBeTruthy(); + expect(doc?.properties?.value).toBeTruthy(); + expect(doc?.properties?.record).toBeTruthy(); + expect(doc?.methods?.setProps).toBeTruthy(); + }); + }); + + describe('JSRecordActionRunJSContext', () => { + it('should create JSRecordAction context successfully', async () => { + const ctx = new FlowContext(); + ctx.defineProperty('model', { value: { constructor: { name: 'JSRecordActionModel' } } }); + + const runner = createJSRunnerWithVersion.call(ctx, { version: 'v1' }); + const result = await runner.run('return typeof ctx !== "undefined"'); + + expect(result?.success).toBe(true); + expect(result?.value).toBe(true); + }); + + it('should have record action-specific properties in documentation', () => { + const ctx: any = { model: { constructor: { name: 'JSRecordActionModel' } } }; + const doc = getRunJSDocFor(ctx as any, { version: 'v1' }); + + expect(doc?.properties?.record).toBeTruthy(); + expect(doc?.properties?.filterByTk).toBeTruthy(); + }); + }); + + describe('JSCollectionActionRunJSContext', () => { + it('should create JSCollectionAction context successfully', async () => { + const ctx = new FlowContext(); + ctx.defineProperty('model', { value: { constructor: { name: 'JSCollectionActionModel' } } }); + + const runner = createJSRunnerWithVersion.call(ctx, { version: 'v1' }); + const result = await runner.run('return typeof ctx !== "undefined"'); + + expect(result?.success).toBe(true); + expect(result?.value).toBe(true); + }); + + it('should have collection action-specific properties in documentation', () => { + const ctx: any = { model: { constructor: { name: 'JSCollectionActionModel' } } }; + const doc = getRunJSDocFor(ctx as any, { version: 'v1' }); + + expect(doc?.properties?.resource).toBeTruthy(); + }); + }); + + describe('Cross-context features', () => { + it('should provide React and antd in all contexts', async () => { + const contextTypes = [ + 'JSBlockModel', + 'JSFieldModel', + 'JSItemModel', + 'JSColumnModel', + 'FormJSFieldItemModel', + 'JSRecordActionModel', + 'JSCollectionActionModel', + ]; + + for (const modelName of contextTypes) { + const ctx = new FlowContext(); + ctx.defineProperty('model', { value: { constructor: { name: modelName } } }); + + const runner = createJSRunnerWithVersion.call(ctx, { version: 'v1' }); + const result = await runner.run(` + return { + hasReact: typeof ctx.React !== "undefined", + hasAntd: typeof ctx.antd !== "undefined", + hasReactDOM: typeof ctx.ReactDOM !== "undefined" + } + `); + + expect(result?.success).toBe(true); + expect(result?.value).toEqual({ + hasReact: true, + hasAntd: true, + hasReactDOM: true, + }); + } + }); + + it('should access base context properties in documentation', () => { + const contextTypes = ['JSBlockModel', 'JSFieldModel', 'JSColumnModel']; + + for (const modelName of contextTypes) { + const ctx: any = { model: { constructor: { name: modelName } } }; + const doc = getRunJSDocFor(ctx as any, { version: 'v1' }); + + // Base properties from FlowRunJSContext + expect(doc?.properties?.logger).toBeTruthy(); + expect(doc?.properties?.message).toBeTruthy(); + expect(doc?.properties?.api).toBeTruthy(); + expect(doc?.methods?.t).toBeTruthy(); + expect(doc?.methods?.requireAsync).toBeTruthy(); + } + }); + }); + + describe('Documentation completeness', () => { + it('should provide complete documentation for all context types', () => { + const contextTypes = [ + 'JSBlockModel', + 'JSFieldModel', + 'JSItemModel', + 'JSColumnModel', + 'FormJSFieldItemModel', + 'JSRecordActionModel', + 'JSCollectionActionModel', + ]; + + for (const modelName of contextTypes) { + const ctx: any = { model: { constructor: { name: modelName } } }; + const doc = getRunJSDocFor(ctx as any, { version: 'v1' }); + + expect(doc).toBeTruthy(); + expect(doc?.label).toBeTruthy(); + expect(doc?.properties).toBeTruthy(); + expect(typeof doc?.properties).toBe('object'); + } + }); + + it('should provide locale-specific documentation', () => { + const ctx = new FlowContext(); + ctx.defineProperty('model', { value: { constructor: { name: 'JSFieldModel' } } }); + ctx.defineProperty('api', { value: { auth: { locale: 'zh-CN' } } }); + + const doc = getRunJSDocFor(ctx as any, { version: 'v1' }); + + expect(doc).toBeTruthy(); + // Should have Chinese documentation + expect(doc?.label).toContain('JS'); + }); + }); +}); diff --git a/packages/core/flow-engine/src/__tests__/runjsEdgeCases.test.ts b/packages/core/flow-engine/src/__tests__/runjsEdgeCases.test.ts new file mode 100644 index 00000000000..0c062a28ed0 --- /dev/null +++ b/packages/core/flow-engine/src/__tests__/runjsEdgeCases.test.ts @@ -0,0 +1,281 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { describe, it, expect, beforeAll } from 'vitest'; +import { FlowContext, FlowEngineContext, FlowRunJSContext } from '../flowContext'; +import { FlowEngine } from '../flowEngine'; +import { createJSRunnerWithVersion, RunJSContextRegistry } from '..'; +import { setupRunJSContexts } from '../runjs-context/setup'; + +describe('RunJS Edge Cases and Error Handling', () => { + let engine: FlowEngine; + let engineCtx: FlowEngineContext; + + beforeAll(async () => { + await setupRunJSContexts(); + engine = new FlowEngine(); + engineCtx = new FlowEngineContext(engine); + }); + + describe('Missing or invalid model scenarios', () => { + it('should handle undefined model', async () => { + const ctx = new FlowEngineContext(engine); + const runner = await ctx.createJSRunner(); + + expect(runner).toBeDefined(); + const result = await runner.run('return typeof ctx !== "undefined"'); + expect(result?.success).toBe(true); + expect(result?.value).toBe(true); + }); + + it('should handle null model', async () => { + const ctx = new FlowEngineContext(engine); + (ctx as any).defineProperty('model', { value: null }); + + const runner = await ctx.createJSRunner(); + expect(runner).toBeDefined(); + }); + + it('should handle model without constructor', async () => { + const ctx = new FlowEngineContext(engine); + (ctx as any).defineProperty('model', { value: {} }); + + const runner = await ctx.createJSRunner(); + expect(runner).toBeDefined(); + }); + + it('should handle unrecognized model type', async () => { + const ctx = new FlowEngineContext(engine); + (ctx as any).defineProperty('model', { + value: { constructor: { name: 'UnknownModelType' } }, + }); + + const runner = await ctx.createJSRunner(); + expect(runner).toBeDefined(); + + const result = await runner.run('return typeof ctx !== "undefined"'); + expect(result?.success).toBe(true); + expect(result?.value).toBe(true); + }); + }); + + describe('Invalid version handling', () => { + it('should handle missing version gracefully', async () => { + const ctx = new FlowContext(); + ctx.defineProperty('model', { + value: { constructor: { name: 'JSBlockModel' } }, + }); + + const runner = createJSRunnerWithVersion.call(ctx, {} as any); + expect(runner).toBeDefined(); + }); + + it('should throw error for invalid version string', async () => { + const ctx = new FlowContext(); + ctx.defineProperty('model', { + value: { constructor: { name: 'JSBlockModel' } }, + }); + + expect(() => { + createJSRunnerWithVersion.call(ctx, { + version: 'invalid-version', + } as any); + }).toThrow(); + }); + }); + + describe('Code execution edge cases', () => { + it('should handle empty code', async () => { + const runner = await engineCtx.createJSRunner(); + const result = await runner.run(''); + + expect(result).toBeDefined(); + }); + + it('should handle whitespace-only code', async () => { + const runner = await engineCtx.createJSRunner(); + const result = await runner.run(' \n\n\t\t '); + + expect(result).toBeDefined(); + }); + + it('should handle syntax errors', async () => { + const runner = await engineCtx.createJSRunner(); + const result = await runner.run('return {invalid: syntax'); + + expect(result?.success).toBe(false); + expect(result?.error).toBeDefined(); + }); + + it('should handle rejected promises', async () => { + const runner = await engineCtx.createJSRunner(); + const result = await runner.run(` + return Promise.reject(new Error('Rejected')); + `); + + expect(result?.success).toBe(false); + expect(result?.error).toBeDefined(); + }); + + it('should handle code that returns undefined', async () => { + const runner = await engineCtx.createJSRunner(); + const result = await runner.run('return undefined'); + + expect(result?.success).toBe(true); + expect(result?.value).toBeUndefined(); + }); + + it('should handle code that returns null', async () => { + const runner = await engineCtx.createJSRunner(); + const result = await runner.run('return null'); + + expect(result?.success).toBe(true); + expect(result?.value).toBeNull(); + }); + }); + + describe('Context property edge cases', () => { + it('should handle value of 0', async () => { + const ctx = new FlowContext(); + ctx.defineProperty('model', { + value: { + constructor: { name: 'JSFieldModel' }, + }, + }); + ctx.defineProperty('value', { value: 0 }); + + const runner = createJSRunnerWithVersion.call(ctx, { version: 'v1' }); + const result = await runner.run('return typeof ctx !== "undefined"'); + + expect(result?.success).toBe(true); + expect(result?.value).toBe(true); + }); + + it('should handle empty string value', async () => { + const ctx = new FlowContext(); + ctx.defineProperty('model', { + value: { + constructor: { name: 'JSFieldModel' }, + }, + }); + ctx.defineProperty('value', { value: '' }); + + const runner = createJSRunnerWithVersion.call(ctx, { version: 'v1' }); + const result = await runner.run('return typeof ctx !== "undefined"'); + + expect(result?.success).toBe(true); + expect(result?.value).toBe(true); + }); + + it('should handle false boolean value', async () => { + const ctx = new FlowContext(); + ctx.defineProperty('model', { + value: { + constructor: { name: 'JSFieldModel' }, + }, + }); + ctx.defineProperty('value', { value: false }); + + const runner = createJSRunnerWithVersion.call(ctx, { version: 'v1' }); + const result = await runner.run('return typeof ctx !== "undefined"'); + + expect(result?.success).toBe(true); + expect(result?.value).toBe(true); + }); + }); + + describe('Registry edge cases', () => { + it('should handle repeated setup calls idempotently', async () => { + await setupRunJSContexts(); + await setupRunJSContexts(); + await setupRunJSContexts(); + + const ctor = RunJSContextRegistry['resolve']('v1' as any, 'JSBlockModel'); + expect(ctor).toBeDefined(); + }); + + it('should handle context creation with minimal delegate', () => { + const minimalCtx = new FlowContext(); + const runCtx = new FlowRunJSContext(minimalCtx); + + expect(runCtx).toBeDefined(); + expect((runCtx as any).React).toBeDefined(); + expect((runCtx as any).antd).toBeDefined(); + expect((runCtx as any).ReactDOM).toBeDefined(); + }); + + it('should handle context creation with null delegate', () => { + expect(() => { + new FlowRunJSContext(null as any); + }).toThrow(); + }); + + it('should handle context creation with undefined delegate', () => { + expect(() => { + new FlowRunJSContext(undefined as any); + }).toThrow(); + }); + }); + + describe('Timeout edge cases', () => { + it('should handle very large timeout', async () => { + const runner = await engineCtx.createJSRunner({ timeoutMs: 999999999 }); + const result = await runner.run('return 1 + 1'); + + expect(result?.success).toBe(true); + expect(result?.value).toBe(2); + }); + + it('should handle code that completes before timeout', async () => { + const runner = await engineCtx.createJSRunner({ timeoutMs: 200 }); + const result = await runner.run(` + return new Promise(resolve => { + setTimeout(() => resolve('done'), 50); + }); + `); + + expect(result?.success).toBe(true); + expect(result?.value).toBe('done'); + }); + }); + + describe('Complex scenarios', () => { + it('should handle multiple concurrent runners', async () => { + const runners = await Promise.all([ + engineCtx.createJSRunner(), + engineCtx.createJSRunner(), + engineCtx.createJSRunner(), + ]); + + const results = await Promise.all([ + runners[0].run('return 1'), + runners[1].run('return 2'), + runners[2].run('return 3'), + ]); + + expect(results[0]?.value).toBe(1); + expect(results[1]?.value).toBe(2); + expect(results[2]?.value).toBe(3); + }); + + it('should handle recursive code execution', async () => { + const runner = await engineCtx.createJSRunner({ timeoutMs: 1000 }); + const result = await runner.run(` + function factorial(n) { + if (n <= 1) return 1; + return n * factorial(n - 1); + } + return factorial(10); + `); + + expect(result?.success).toBe(true); + expect(result?.value).toBe(3628800); + }); + }); +}); diff --git a/packages/core/flow-engine/src/__tests__/runjsLocales.test.ts b/packages/core/flow-engine/src/__tests__/runjsLocales.test.ts new file mode 100644 index 00000000000..692559781cd --- /dev/null +++ b/packages/core/flow-engine/src/__tests__/runjsLocales.test.ts @@ -0,0 +1,36 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { describe, it, expect, beforeAll } from 'vitest'; +import { getRunJSDocFor } from '..'; +import { setupRunJSContexts } from '../runjs-context/setup'; +import { FlowContext } from '../flowContext'; + +describe('RunJS locales patch (engine doc)', () => { + beforeAll(async () => { + await setupRunJSContexts(); + }); + + it('should merge zh-CN locales for subclass doc label', () => { + const ctx = new FlowContext(); + (ctx as any).defineProperty('model', { value: { constructor: { name: 'JSFieldModel' } } }); + (ctx as any).defineProperty('api', { value: { auth: { locale: 'zh-CN' } } }); + const doc = getRunJSDocFor(ctx as any, { version: 'v1' }); + expect(doc?.label || '').toMatch(/JS 字段|JS 字段 RunJS 上下文/); + }); + + it('should localize base properties/methods via locales', () => { + const ctx = new FlowContext(); + (ctx as any).defineProperty('model', { value: { constructor: { name: 'JSBlockModel' } } }); + (ctx as any).defineProperty('api', { value: { auth: { locale: 'zh-CN' } } }); + const doc = getRunJSDocFor(ctx as any, { version: 'v1' }); + expect(String(doc?.properties?.message || '')).toMatch(/Ant Design 全局消息 API/); + expect(String(doc?.methods?.t || '')).toMatch(/国际化函数/); + }); +}); diff --git a/packages/core/flow-engine/src/__tests__/runjsRuntimeFeatures.test.ts b/packages/core/flow-engine/src/__tests__/runjsRuntimeFeatures.test.ts new file mode 100644 index 00000000000..d63c12393a8 --- /dev/null +++ b/packages/core/flow-engine/src/__tests__/runjsRuntimeFeatures.test.ts @@ -0,0 +1,449 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { describe, it, expect, beforeAll, vi } from 'vitest'; +import { FlowEngineContext, FlowRunJSContext } from '../flowContext'; +import { FlowEngine } from '../flowEngine'; +import { FlowContext } from '../flowContext'; +import { setupRunJSContexts } from '../runjs-context/setup'; +import { createJSRunnerWithVersion } from '..'; + +describe('RunJS Runtime Features', () => { + let engine: FlowEngine; + let engineCtx: FlowEngineContext; + + beforeAll(async () => { + await setupRunJSContexts(); + engine = new FlowEngine(); + engineCtx = new FlowEngineContext(engine); + }); + + describe('ReactDOM availability', () => { + it('should provide ReactDOM in runjs context', async () => { + const ctx = new FlowContext(); + ctx.defineProperty('model', { value: { constructor: { name: 'JSBlockModel' } } }); + + const runner = createJSRunnerWithVersion.call(ctx, { version: 'v1' }); + const result = await runner.run('return typeof ctx.ReactDOM !== "undefined"'); + + expect(result?.success).toBe(true); + expect(result?.value).toBe(true); + }); + + it('should provide ReactDOM.createRoot', async () => { + const ctx = new FlowContext(); + ctx.defineProperty('model', { value: { constructor: { name: 'JSBlockModel' } } }); + + const runner = createJSRunnerWithVersion.call(ctx, { version: 'v1' }); + const result = await runner.run('return typeof ctx.ReactDOM?.createRoot === "function"'); + + expect(result?.success).toBe(true); + expect(result?.value).toBe(true); + }); + + it('should make ReactDOM available in all context types', async () => { + const contextTypes = ['JSBlockModel', 'JSFieldModel', 'JSItemModel', 'JSColumnModel', 'FormJSFieldItemModel']; + + for (const modelName of contextTypes) { + const ctx = new FlowContext(); + ctx.defineProperty('model', { value: { constructor: { name: modelName } } }); + + const runner = createJSRunnerWithVersion.call(ctx, { version: 'v1' }); + const result = await runner.run('return typeof ctx.ReactDOM !== "undefined"'); + + expect(result?.success).toBe(true); + expect(result?.value).toBe(true); + } + }); + + it('should provide React alongside ReactDOM', async () => { + const ctx = new FlowContext(); + ctx.defineProperty('model', { value: { constructor: { name: 'JSBlockModel' } } }); + + const runner = createJSRunnerWithVersion.call(ctx, { version: 'v1' }); + const result = await runner.run(` + return { + hasReact: typeof ctx.React !== "undefined", + hasReactDOM: typeof ctx.ReactDOM !== "undefined", + hasCreateElement: typeof ctx.React?.createElement === "function", + hasCreateRoot: typeof ctx.ReactDOM?.createRoot === "function" + } + `); + + expect(result?.success).toBe(true); + expect(result?.value).toEqual({ + hasReact: true, + hasReactDOM: true, + hasCreateElement: true, + hasCreateRoot: true, + }); + }); + }); + + describe('React and antd availability', () => { + it('should provide React in runjs context', async () => { + const ctx = new FlowContext(); + ctx.defineProperty('model', { value: { constructor: { name: 'JSBlockModel' } } }); + + const runner = createJSRunnerWithVersion.call(ctx, { version: 'v1' }); + const result = await runner.run('return typeof ctx.React !== "undefined"'); + + expect(result?.success).toBe(true); + expect(result?.value).toBe(true); + }); + + it('should provide antd in runjs context', async () => { + const ctx = new FlowContext(); + ctx.defineProperty('model', { value: { constructor: { name: 'JSBlockModel' } } }); + + const runner = createJSRunnerWithVersion.call(ctx, { version: 'v1' }); + const result = await runner.run('return typeof ctx.antd !== "undefined"'); + + expect(result?.success).toBe(true); + expect(result?.value).toBe(true); + }); + + it('should allow access to React.createElement', async () => { + const ctx = new FlowContext(); + ctx.defineProperty('model', { value: { constructor: { name: 'JSBlockModel' } } }); + + const runner = createJSRunnerWithVersion.call(ctx, { version: 'v1' }); + const result = await runner.run('return typeof ctx.React.createElement === "function"'); + + expect(result?.success).toBe(true); + expect(result?.value).toBe(true); + }); + + it('should allow access to antd.Button', async () => { + const ctx = new FlowContext(); + ctx.defineProperty('model', { value: { constructor: { name: 'JSBlockModel' } } }); + + const runner = createJSRunnerWithVersion.call(ctx, { version: 'v1' }); + const result = await runner.run('return typeof ctx.antd.Button !== "undefined"'); + + expect(result?.success).toBe(true); + expect(result?.value).toBe(true); + }); + }); + + describe('Window and document injection', () => { + it('should inject window in JSBlock context', async () => { + const ctx = new FlowContext(); + ctx.defineProperty('model', { value: { constructor: { name: 'JSBlockModel' } } }); + + const runner = createJSRunnerWithVersion.call(ctx, { version: 'v1' }); + const result = await runner.run('return typeof window !== "undefined"'); + + expect(result?.success).toBe(true); + expect(result?.value).toBe(true); + }); + + it('should inject document in JSBlock context', async () => { + const ctx = new FlowContext(); + ctx.defineProperty('model', { value: { constructor: { name: 'JSBlockModel' } } }); + + const runner = createJSRunnerWithVersion.call(ctx, { version: 'v1' }); + const result = await runner.run('return typeof document !== "undefined"'); + + expect(result?.success).toBe(true); + expect(result?.value).toBe(true); + }); + + it('should inject window in JSField context', async () => { + const ctx = new FlowContext(); + ctx.defineProperty('model', { value: { constructor: { name: 'JSFieldModel' } } }); + + const runner = createJSRunnerWithVersion.call(ctx, { version: 'v1' }); + const result = await runner.run('return typeof window !== "undefined"'); + + expect(result?.success).toBe(true); + expect(result?.value).toBe(true); + }); + + it('should inject window in JSItem context', async () => { + const ctx = new FlowContext(); + ctx.defineProperty('model', { value: { constructor: { name: 'JSItemModel' } } }); + + const runner = createJSRunnerWithVersion.call(ctx, { version: 'v1' }); + const result = await runner.run('return typeof window !== "undefined"'); + + // JSItemModel may not inject window/document depending on implementation + expect(result?.success).toBe(true); + // Allow both true and false as valid results + }); + + it('should inject window in JSColumn context', async () => { + const ctx = new FlowContext(); + ctx.defineProperty('model', { value: { constructor: { name: 'JSColumnModel' } } }); + + const runner = createJSRunnerWithVersion.call(ctx, { version: 'v1' }); + const result = await runner.run('return typeof window !== "undefined"'); + + // JSColumnModel may not inject window/document depending on implementation + expect(result?.success).toBe(true); + // Allow both true and false as valid results + }); + + it('should provide safe window with basic properties', async () => { + const ctx = new FlowContext(); + ctx.defineProperty('model', { value: { constructor: { name: 'JSBlockModel' } } }); + + const runner = createJSRunnerWithVersion.call(ctx, { version: 'v1' }); + const result = await runner.run(` + return { + hasLocation: typeof window.location !== "undefined", + hasNavigator: typeof window.navigator !== "undefined", + hasDocument: typeof window.document !== "undefined" + } + `); + + expect(result?.success).toBe(true); + expect(result?.value?.hasLocation).toBe(true); + expect(result?.value?.hasNavigator).toBe(true); + expect(result?.value?.hasDocument).toBe(true); + }); + + it('should provide safe document with basic methods', async () => { + const ctx = new FlowContext(); + ctx.defineProperty('model', { value: { constructor: { name: 'JSBlockModel' } } }); + + const runner = createJSRunnerWithVersion.call(ctx, { version: 'v1' }); + const result = await runner.run(` + return { + hasCreateElement: typeof document.createElement === "function", + hasQuerySelector: typeof document.querySelector === "function", + hasGetElementById: typeof document.getElementById === "function" + } + `); + + expect(result?.success).toBe(true); + expect(result?.value?.hasCreateElement).toBe(true); + expect(result?.value?.hasQuerySelector).toBe(true); + expect(result?.value?.hasGetElementById).toBe(true); + }); + }); + + describe('Context delegation', () => { + it('should delegate to parent context properties', async () => { + const parentCtx = new FlowContext(); + parentCtx.defineProperty('customProp', { value: 'custom value' }); + + const runCtx = new FlowRunJSContext(parentCtx); + expect((runCtx as any).customProp).toBe('custom value'); + }); + + it('should allow accessing parent context methods', async () => { + const parentCtx = new FlowContext(); + parentCtx.defineMethod('customMethod', () => 'result'); + + const runCtx = new FlowRunJSContext(parentCtx); + expect((runCtx as any).customMethod()).toBe('result'); + }); + + it('should preserve FlowRunJSContext own properties', () => { + const parentCtx = new FlowContext(); + const runCtx = new FlowRunJSContext(parentCtx); + + expect((runCtx as any).React).toBeDefined(); + expect((runCtx as any).antd).toBeDefined(); + expect((runCtx as any).ReactDOM).toBeDefined(); + }); + }); + + describe('Actual code execution', () => { + it('should execute simple arithmetic', async () => { + const runner = await engineCtx.createJSRunner(); + const result = await runner.run('return 1 + 2 * 3'); + + expect(result?.success).toBe(true); + expect(result?.value).toBe(7); + }); + + it('should execute async code with Promise', async () => { + const runner = await engineCtx.createJSRunner(); + const result = await runner.run(` + return new Promise(resolve => { + setTimeout(() => resolve('async done'), 10); + }); + `); + + expect(result?.success).toBe(true); + expect(result?.value).toBe('async done'); + }); + + it('should access ctx in code', async () => { + const runner = await engineCtx.createJSRunner(); + const result = await runner.run(` + return typeof ctx !== "undefined" + `); + + expect(result?.success).toBe(true); + expect(result?.value).toBe(true); + }); + + it('should execute code with custom globals', async () => { + const runner = await engineCtx.createJSRunner({ + globals: { + customVar: 'test value', + customFunc: () => 'func result', + }, + }); + + const result = await runner.run(` + return { + varValue: customVar, + funcResult: customFunc() + } + `); + + expect(result?.success).toBe(true); + expect(result?.value).toEqual({ + varValue: 'test value', + funcResult: 'func result', + }); + }); + + it('should handle errors gracefully', async () => { + const runner = await engineCtx.createJSRunner(); + const result = await runner.run('throw new Error("Test error")'); + + expect(result?.success).toBe(false); + expect(result?.error).toBeTruthy(); + }); + + it('should respect timeout settings', async () => { + const runner = await engineCtx.createJSRunner({ timeoutMs: 50 }); + const result = await runner.run(` + return new Promise(resolve => { + setTimeout(() => resolve('done'), 500); + }); + `); + + expect(result?.success).toBe(false); + }); + }); + + describe('Context-specific runtime behavior', () => { + it('should create context for JSBlock model', async () => { + const ctx = new FlowContext(); + ctx.defineProperty('model', { + value: { + constructor: { name: 'JSBlockModel' }, + }, + }); + ctx.defineProperty('element', { value: { innerHTML: '', append: vi.fn() } }); + + const runner = createJSRunnerWithVersion.call(ctx, { version: 'v1' }); + const result = await runner.run('return typeof ctx !== "undefined"'); + + expect(result?.success).toBe(true); + expect(result?.value).toBe(true); + }); + + it('should create context for JSField model', async () => { + const ctx = new FlowContext(); + ctx.defineProperty('model', { + value: { + constructor: { name: 'JSFieldModel' }, + }, + }); + ctx.defineProperty('record', { value: { id: 1, name: 'test' } }); + + const runner = createJSRunnerWithVersion.call(ctx, { version: 'v1' }); + const result = await runner.run('return typeof ctx !== "undefined"'); + + expect(result?.success).toBe(true); + expect(result?.value).toBe(true); + }); + + it('should create context for JSColumn model', async () => { + const ctx = new FlowContext(); + ctx.defineProperty('model', { + value: { + constructor: { name: 'JSColumnModel' }, + }, + }); + ctx.defineProperty('element', { value: { innerHTML: '' } }); + ctx.defineProperty('record', { value: { id: 1 } }); + ctx.defineProperty('recordIndex', { value: 0 }); + + const runner = createJSRunnerWithVersion.call(ctx, { version: 'v1' }); + const result = await runner.run('return typeof ctx !== "undefined"'); + + expect(result?.success).toBe(true); + expect(result?.value).toBe(true); + }); + }); + + describe('Edge cases and error handling', () => { + it('should handle missing model gracefully', async () => { + const ctx = new FlowEngineContext(engine); + const runner = await ctx.createJSRunner(); + + expect(runner).toBeDefined(); + const result = await runner.run('return typeof ctx !== "undefined"'); + expect(result?.success).toBe(true); + }); + + it('should handle null model gracefully', async () => { + const ctx = new FlowEngineContext(engine); + (ctx as any).defineProperty('model', { value: null }); + + const runner = await ctx.createJSRunner(); + expect(runner).toBeDefined(); + }); + + it('should handle model without constructor name', async () => { + const ctx = new FlowEngineContext(engine); + (ctx as any).defineProperty('model', { value: {} }); + + const runner = await ctx.createJSRunner(); + expect(runner).toBeDefined(); + + const result = await runner.run('return typeof ctx !== "undefined"'); + expect(result?.success).toBe(true); + }); + + it('should handle invalid version gracefully', async () => { + const ctx = new FlowEngineContext(engine); + const runner = await ctx.createJSRunner({ version: 'invalid-version' } as any); + + expect(runner).toBeDefined(); + }); + + it('should handle syntax errors in user code', async () => { + const runner = await engineCtx.createJSRunner(); + const result = await runner.run('return {invalid syntax'); + + expect(result?.success).toBe(false); + expect(result?.error).toBeTruthy(); + }); + + it('should handle reference errors', async () => { + const runner = await engineCtx.createJSRunner(); + const result = await runner.run('return nonExistentVariable'); + + // JSRunner behavior may vary - some implementations return undefined + // for undefined variables instead of throwing ReferenceError + expect(result).toBeDefined(); + }); + + it('should handle async errors', async () => { + const runner = await engineCtx.createJSRunner(); + const result = await runner.run(` + return new Promise((resolve, reject) => { + setTimeout(() => reject(new Error('Async error')), 10); + }); + `); + + expect(result?.success).toBe(false); + expect(result?.error).toBeTruthy(); + }); + }); +}); diff --git a/packages/core/flow-engine/src/__tests__/runjsSnippets.test.ts b/packages/core/flow-engine/src/__tests__/runjsSnippets.test.ts new file mode 100644 index 00000000000..700aaa29455 --- /dev/null +++ b/packages/core/flow-engine/src/__tests__/runjsSnippets.test.ts @@ -0,0 +1,140 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { describe, it, expect } from 'vitest'; +import { getSnippetBody, listSnippetsForContext } from '../runjs-context/snippets'; + +describe('RunJS Snippets', () => { + describe('getSnippetBody', () => { + it('should return snippet body for global/message-success', async () => { + const body = await getSnippetBody('global/message-success'); + expect(body).toBeTruthy(); + expect(typeof body).toBe('string'); + expect(body).toContain('ctx.message.success'); + }); + + it('should return snippet body for global/api-request', async () => { + const body = await getSnippetBody('global/api-request'); + expect(body).toBeTruthy(); + expect(body).toContain('ctx.api.request'); + }); + + it('should throw error for non-existent snippet', async () => { + await expect(getSnippetBody('non/existent/snippet')).rejects.toThrow(/snippet not found/); + }); + }); + + describe('listSnippetsForContext', () => { + it('should return snippet list for JSBlockModel', async () => { + const snippets = await listSnippetsForContext('JSBlockRunJSContext', 'v1', 'en-US'); + expect(Array.isArray(snippets)).toBe(true); + expect(snippets.length).toBeGreaterThan(0); + + const snippet = snippets[0]; + expect(snippet).toHaveProperty('name'); + expect(snippet).toHaveProperty('body'); + expect(snippet).toHaveProperty('ref'); + }); + + it('should return snippet list for JSFieldModel', async () => { + const snippets = await listSnippetsForContext('JSFieldRunJSContext', 'v1', 'en-US'); + expect(Array.isArray(snippets)).toBe(true); + expect(snippets.length).toBeGreaterThan(0); + }); + + it('should filter snippets by context', async () => { + const allSnippets = await listSnippetsForContext('*', 'v1', 'en-US'); + const blockSnippets = await listSnippetsForContext('JSBlockRunJSContext', 'v1', 'en-US'); + + // Both should return snippets + expect(allSnippets.length).toBeGreaterThan(0); + expect(blockSnippets.length).toBeGreaterThan(0); + + // The filtering works based on snippet definitions: + // - Snippets without contexts filter are available to all + // - Snippets with contexts: ['*'] are available when querying with '*' + // - Snippets with contexts: ['JSBlockRunJSContext'] are available when querying with 'JSBlockRunJSContext' + // So the counts may vary depending on how snippets are configured + }); + + it('should support locale-specific labels', async () => { + const enSnippets = await listSnippetsForContext('JSBlockRunJSContext', 'v1', 'en-US'); + const zhSnippets = await listSnippetsForContext('JSBlockRunJSContext', 'v1', 'zh-CN'); + + expect(enSnippets.length).toBeGreaterThan(0); + expect(zhSnippets.length).toBeGreaterThan(0); + + // Both should have snippets, but labels might differ + const enSnippet = enSnippets.find((s) => s.ref.includes('message-success')); + const zhSnippet = zhSnippets.find((s) => s.ref.includes('message-success')); + + if (enSnippet && zhSnippet) { + // If locale support is implemented, labels should differ + // Otherwise they might be the same + expect(enSnippet.ref).toBe(zhSnippet.ref); + } + }); + + it('should include group information', async () => { + const snippets = await listSnippetsForContext('*', 'v1', 'en-US'); + + const globalSnippet = snippets.find((s) => s.ref.startsWith('global/')); + if (globalSnippet) { + expect(globalSnippet.group).toBe('global'); + } + + const sceneSnippet = snippets.find((s) => s.ref.startsWith('scene/block/')); + if (sceneSnippet) { + expect(sceneSnippet.group).toBe('scene/block'); + expect(sceneSnippet.groups?.[0]).toBe('scene/block'); + } + + // At least one should have a group + expect(snippets.some((s) => s.group)).toBe(true); + expect(snippets.some((s) => Array.isArray(s.groups) && s.groups.length)).toBe(true); + }); + + it('should handle empty context gracefully', async () => { + const snippets = await listSnippetsForContext('', 'v1', 'en-US'); + expect(Array.isArray(snippets)).toBe(true); + }); + + it('should respect scenes metadata when provided', async () => { + const snippets = await listSnippetsForContext('*', 'v1', 'en-US'); + const multiScene = snippets.find((s) => s.ref === 'scene/detail/status-tag'); + expect(multiScene).toBeTruthy(); + expect(multiScene?.scenes).toEqual(expect.arrayContaining(['detail', 'table'])); + expect(multiScene?.groups).toEqual(expect.arrayContaining(['scene/detail', 'scene/table'])); + }); + }); + + describe('New snippets', () => { + it('should include query-selector snippet', async () => { + const body = await getSnippetBody('global/query-selector'); + expect(body).toBeTruthy(); + expect(body).toContain('querySelector'); + }); + + it('should include resource-example snippet', async () => { + const body = await getSnippetBody('scene/block/resource-example'); + expect(body).toBeTruthy(); + expect(body).toContain('resource'); + }); + }); + + describe('Removed snippets', () => { + it('should not include copy-to-clipboard snippet', async () => { + await expect(getSnippetBody('global/copy-to-clipboard')).rejects.toThrow(); + }); + + it('should not include copy-record-json snippet', async () => { + await expect(getSnippetBody('global/copy-record-json')).rejects.toThrow(); + }); + }); +}); diff --git a/packages/core/flow-engine/src/flowContext.ts b/packages/core/flow-engine/src/flowContext.ts index 3e58f3fd43e..4a9d6261c51 100644 --- a/packages/core/flow-engine/src/flowContext.ts +++ b/packages/core/flow-engine/src/flowContext.ts @@ -11,14 +11,15 @@ import { ISchema } from '@formily/json-schema'; import { observable } from '@formily/reactive'; import { APIClient } from '@nocobase/sdk'; import type { Router } from '@remix-run/router'; -import * as antd from 'antd'; import { MessageInstance } from 'antd/es/message/interface'; +import * as antd from 'antd'; import type { HookAPI } from 'antd/es/modal/useModal'; import { NotificationInstance } from 'antd/es/notification/interface'; import _ from 'lodash'; import pino from 'pino'; import qs from 'qs'; import React, { createRef } from 'react'; +import * as ReactDOMClient from 'react-dom/client'; import type { Location } from 'react-router-dom'; import { ACL } from './acl/Acl'; import { ContextPathProxy } from './ContextPathProxy'; @@ -26,21 +27,11 @@ import { DataSource, DataSourceManager } from './data-source'; import { FlowEngine } from './flowEngine'; import { FlowI18n } from './flowI18n'; import { JSRunner, JSRunnerOptions } from './JSRunner'; -import { createJSRunnerWithVersion } from './runjs-context'; -import { FlowModel, ForkFlowModel } from './models'; -import { - APIResource, - BaseRecordResource, - FlowResource, - FlowSQLRepository, - MultiRecordResource, - SingleRecordResource, - SQLResource, -} from './resources'; +import type { FlowModel } from './models/flowModel'; +import type { ForkFlowModel } from './models/forkFlowModel'; +import { FlowResource, FlowSQLRepository } from './resources'; import type { ActionDefinition, EventDefinition, ResourceType } from './types'; import { - createSafeDocument, - createSafeWindow, escapeT, extractPropertyPath, extractUsedVariablePaths, @@ -53,6 +44,7 @@ import { enqueueVariablesResolve, JSONValue } from './utils/params-resolvers'; import type { RecordRef } from './utils/serverContextParams'; import { buildServerContextParams as _buildServerContextParams } from './utils/serverContextParams'; import { FlowView, FlowViewer } from './views/FlowView'; +import { RunJSContextRegistry, getModelClassName } from './runjs-context/registry'; // Helper: detect a RecordRef-like object function isRecordRefLike(val: any): boolean { @@ -892,38 +884,11 @@ export class FlowContext { } } -export class FlowRunjsContext extends FlowContext { - constructor(delegate: FlowContext) { - super(); - this.addDelegate(delegate); - // Expose React and antd only within runjs context - // This keeps the scope minimal while enabling React/AntD rendering in scripts - this.defineProperty('React', { value: React }); - this.defineProperty('antd', { value: antd }); - this.defineMethod( - 'dispatchModelEvent', - async (modelOrUid: FlowModel | string, eventName: string, inputArgs?: Record) => { - let model: FlowModel | null = null; - if (typeof modelOrUid === 'string') { - model = await this.engine.loadModel({ uid: modelOrUid }); - } else if (modelOrUid instanceof FlowModel) { - model = modelOrUid; - } - if (model) { - model.context.addDelegate(this); - model.dispatchEvent(eventName, { navigation: false, ...this.model?.['getInputArgs']?.(), ...inputArgs }); - } else { - this.message.error(this.t('Model with ID {{uid}} not found', { uid: modelOrUid })); - } - }, - ); - } -} - class BaseFlowEngineContext extends FlowContext { declare router: Router; declare dataSourceManager: DataSourceManager; declare requireAsync: (url: string) => Promise; + declare importAsync: (url: string) => Promise; declare createJSRunner: (options?: JSRunnerOptions) => JSRunner; /** * @deprecated use `resolveJsonTemplate` instead @@ -931,7 +896,6 @@ class BaseFlowEngineContext extends FlowContext { declare renderJson: (template: JSONValue) => Promise; declare resolveJsonTemplate: (template: JSONValue) => Promise; declare runjs: (code: string, variables?: Record, options?: JSRunnerOptions) => Promise; - declare copyToClipboard: (text: string) => Promise; declare getAction: ( name: string, ) => ActionDefinition | undefined; @@ -1005,10 +969,10 @@ export class FlowEngineContext extends BaseFlowEngineContext { }); this.defineMethod('runjs', async (code, variables, options?: JSRunnerOptions) => { const mergedGlobals = { ...(options?.globals || {}), ...(variables || {}) }; - const runner = this.createJSRunner({ + const runner = (await (this as any).createJSRunner({ ...(options || {}), globals: mergedGlobals, - }); + })) as JSRunner; return runner.run(code); }); this.defineMethod('renderJson', function (template: any) { @@ -1218,50 +1182,60 @@ export class FlowEngineContext extends BaseFlowEngineContext { ); }); }); - this.defineMethod('createJSRunner', function (options?: JSRunnerOptions) { - // return createJSRunnerWithVersion.call(this, options as any); - const runCtx = new FlowRunjsContext(this.createProxy()); - return new JSRunner({ - ...options, - globals: { - ctx: runCtx, - window: createSafeWindow(), - document: createSafeDocument(), - ...options?.globals, - }, - }); - }); - // 复制文本到剪贴板(优先使用 Clipboard API,降级到 execCommand) - this.defineMethod('copyToClipboard', async (text: string) => { - const content = String(text ?? ''); - try { - if (typeof navigator !== 'undefined' && navigator.clipboard && navigator.clipboard.writeText) { - await navigator.clipboard.writeText(content); - return; - } - } catch (e) { - // 忽略,尝试降级方案 + // 动态按 URL 加载 ESM 模块 + // - 使用 Vite / Webpack ignore 注释,避免被预打包或重写 + // - 返回模块命名空间对象(包含 default 与命名导出) + this.defineMethod('importAsync', async (url: string) => { + if (!url || typeof url !== 'string') { + throw new Error('invalid url'); } - - // 降级方案:创建临时 textarea + execCommand('copy') - return new Promise((resolve, reject) => { + const u = url.trim(); + const g = globalThis as any; + g.__nocobaseImportAsyncCache = g.__nocobaseImportAsyncCache || new Map>(); + const cache: Map> = g.__nocobaseImportAsyncCache; + if (cache.has(u)) return cache.get(u)!; + // 尝试使用原生 dynamic import(加上 vite/webpack 的 ignore 注释) + const nativeImport = () => import(/* @vite-ignore */ /* webpackIgnore: true */ u); + // 兜底方案:通过 eval 在运行时构造 import,避免被打包器接管 + const evalImport = () => { + const importer = (0, eval)('u => import(u)'); + return importer(u); + }; + const p = (async () => { try { - const ta = document.createElement('textarea'); - ta.value = content; - ta.setAttribute('readonly', ''); - ta.style.position = 'fixed'; - ta.style.top = '-9999px'; - document.body.appendChild(ta); - ta.focus(); - ta.select(); - const ok = document.execCommand('copy'); - document.body.removeChild(ta); - if (ok) resolve(); - else reject(new Error('execCommand copy failed')); - } catch (err) { - reject(err); + return await nativeImport(); + } catch (err: any) { + // 常见于打包产物仍然拦截了 dynamic import 或开发态插件未识别 ignore 注释 + try { + return await evalImport(); + } catch (err2) { + throw err2 || err; + } } - }); + })(); + cache.set(u, p); + return p; + }); + this.defineMethod('createJSRunner', async function (options?: JSRunnerOptions) { + try { + const mod: any = await import('./runjs-context/setup'); + if (typeof mod?.setupRunJSContexts === 'function') await mod.setupRunJSContexts(); + } catch (_) { + // ignore if setup is not available + } + const version = (options?.version as any) || 'v1'; + const modelClass = getModelClassName(this); + const Ctor = + (RunJSContextRegistry.resolve(version, modelClass) as any) || + (RunJSContextRegistry.resolve(version, '*') as any) || + FlowRunJSContext; + let runCtx: any; + if (Ctor) { + runCtx = new Ctor(this); + } + const globals: Record = { ctx: runCtx, ...(options?.globals || {}) }; + const { timeoutMs } = options || {}; + return new JSRunner({ globals, timeoutMs }); }); // Helper: build server contextParams for variables:resolve this.defineMethod('buildServerContextParams', function (this: BaseFlowEngineContext, input?: any) { @@ -1369,12 +1343,25 @@ export class FlowEngineContext extends BaseFlowEngineContext { context: this.createProxy(), }); }); + // Provide useResource in base engine context so RunJS can call it directly + this.defineMethod( + 'useResource', + function ( + this: BaseFlowEngineContext, + className: 'APIResource' | 'SingleRecordResource' | 'MultiRecordResource' | 'SQLResource', + ) { + if (this.has('resource')) return; + this.defineProperty('resource', { + get: () => this.createResource(className), + }); + }, + ); } } export class FlowModelContext extends BaseFlowModelContext { constructor(model: FlowModel) { - if (!(model instanceof FlowModel)) { + if (!model || typeof model !== 'object') { throw new Error('Invalid FlowModel instance'); } super(); @@ -1383,7 +1370,7 @@ export class FlowModelContext extends BaseFlowModelContext { this.engine.reactView.onRefReady(ref, cb, timeout); }); this.defineMethod('runjs', async (code, variables, options?: { version?: string }) => { - const runner = this.createJSRunner({ + const runner = await this.createJSRunner({ globals: variables, version: options?.version, }); @@ -1507,7 +1494,7 @@ export class FlowForkModelContext extends BaseFlowModelContext { public master: FlowModel, public fork: ForkFlowModel, ) { - if (!(master instanceof FlowModel)) { + if (!master || typeof master !== 'object') { throw new Error('Invalid FlowModel instance'); } super(); @@ -1527,7 +1514,7 @@ export class FlowForkModelContext extends BaseFlowModelContext { }, }); this.defineMethod('runjs', async (code, variables, options?: { version?: string }) => { - const runner = this.createJSRunner({ + const runner = await this.createJSRunner({ globals: variables, version: options?.version, }); @@ -1588,7 +1575,7 @@ export class FlowRuntimeContext< this.engine.reactView.onRefReady(ref, cb, timeout); }); this.defineMethod('runjs', async (code, variables, options?: { version?: string }) => { - const runner = this.createJSRunner({ + const runner = await this.createJSRunner({ globals: variables, version: options?.version, }); @@ -1641,3 +1628,115 @@ export class FlowRuntimeContext< // 类型别名,方便使用 export type FlowSettingsContext = FlowRuntimeContext; + +export type RunJSDocCompletionDoc = { + insertText?: string; +}; + +export type RunJSDocPropertyDoc = + | string + | { + description?: string; + detail?: string; + type?: string; + examples?: string[]; + completion?: RunJSDocCompletionDoc; + properties?: Record; + }; + +export type RunJSDocMethodDoc = + | string + | { + description?: string; + detail?: string; + examples?: string[]; + completion?: RunJSDocCompletionDoc; + }; + +export type RunJSDocMeta = { + label?: string; + properties?: Record; + methods?: Record; + snippets?: Record; +}; + +const __runjsClassDefaultMeta = new WeakMap(); +const __runjsClassLocaleMeta = new WeakMap>(); +const __runjsDocCache = new WeakMap>(); + +function __runjsDeepMerge(base: any, patch: any) { + if (patch === null) return undefined; + if (Array.isArray(base) || Array.isArray(patch) || typeof base !== 'object' || typeof patch !== 'object') { + return patch ?? base; + } + const out: any = { ...base }; + for (const k of Object.keys(patch)) { + const v = __runjsDeepMerge(base?.[k], patch[k]); + if (typeof v === 'undefined') delete out[k]; + else out[k] = v; + } + return out; +} +export class FlowRunJSContext extends FlowContext { + constructor(delegate: FlowContext) { + super(); + this.addDelegate(delegate); + this.defineProperty('React', { value: React }); + this.defineProperty('antd', { value: antd }); + // 为 JS 运行时代码提供带有 antd/App/ConfigProvider 包裹的 React 根 + // 保持与 ReactDOMClient 接口一致,优先覆盖 createRoot,其余方法透传 + const ReactDOMShim: any = { + ...ReactDOMClient, + createRoot: (container: Element | DocumentFragment, options?: any) => { + // 兼容 ElementProxy:若传入的是代理对象,取其底层原生元素 + const realContainer: any = (container as any)?.__el || container; + // 使用引擎自带的 reactView.createRoot,以继承应用内的 ConfigProvider/App 上下文与主题 + return this.engine.reactView.createRoot(realContainer as HTMLElement, options); + }, + }; + this.defineProperty('ReactDOM', { value: ReactDOMShim }); + } + static define(meta: RunJSDocMeta, options?: { locale?: string }) { + const locale = options?.locale; + if (locale) { + const map = __runjsClassLocaleMeta.get(this) || new Map(); + const prev = map.get(locale) || {}; + map.set(locale, __runjsDeepMerge(prev, meta)); + __runjsClassLocaleMeta.set(this, map); + } else { + const prev = __runjsClassDefaultMeta.get(this) || {}; + __runjsClassDefaultMeta.set(this, __runjsDeepMerge(prev, meta)); + } + __runjsDocCache.delete(this); + } + static getDoc(locale?: string): RunJSDocMeta { + const self = this as any as Function; + let cacheForClass = __runjsDocCache.get(self); + const cacheKey = String(locale || 'default'); + if (cacheForClass && cacheForClass.has(cacheKey)) return cacheForClass.get(cacheKey)!; + const chain: Function[] = []; + let cur: any = self; + while (cur && cur.prototype) { + chain.unshift(cur); + cur = Object.getPrototypeOf(cur); + } + let merged: RunJSDocMeta = {}; + for (const cls of chain) { + merged = __runjsDeepMerge(merged, __runjsClassDefaultMeta.get(cls) || {}); + } + if (locale) { + for (const cls of chain) { + const lmap = __runjsClassLocaleMeta.get(cls); + if (lmap && lmap.has(locale)) { + merged = __runjsDeepMerge(merged, lmap.get(locale)); + } + } + } + if (!cacheForClass) { + cacheForClass = new Map(); + __runjsDocCache.set(self, cacheForClass); + } + cacheForClass.set(cacheKey, merged); + return merged; + } +} diff --git a/packages/core/flow-engine/src/index.ts b/packages/core/flow-engine/src/index.ts index 645bd8b67b7..c0f2bf21736 100644 --- a/packages/core/flow-engine/src/index.ts +++ b/packages/core/flow-engine/src/index.ts @@ -31,7 +31,15 @@ export * from './ElementProxy'; export * from './flowContext'; export * from './FlowContextProvider'; export * from './JSRunner'; -export * from './runjs-context'; +export { + getRunJSDocFor, + createJSRunnerWithVersion, + getRunJSScenesForModel, + getRunJSScenesForContext, +} from './runjs-context/helpers'; +export { RunJSContextRegistry, getModelClassName } from './runjs-context/registry'; +export { setupRunJSContexts } from './runjs-context/setup'; +export { getSnippetBody, listSnippetsForContext } from './runjs-context/snippets'; export * from './views'; diff --git a/packages/core/flow-engine/src/runjs-context/contexts/FlowRunJSContext.ts b/packages/core/flow-engine/src/runjs-context/contexts/FlowRunJSContext.ts deleted file mode 100644 index d390d36e8d3..00000000000 --- a/packages/core/flow-engine/src/runjs-context/contexts/FlowRunJSContext.ts +++ /dev/null @@ -1,190 +0,0 @@ -/** - * This file is part of the NocoBase (R) project. - * Copyright (c) 2020-2024 NocoBase Co., Ltd. - * Authors: NocoBase Team. - * - * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. - * For more information, please refer to: https://www.nocobase.com/agreement. - */ - -import type { FlowContext } from '../../flowContext'; -import * as antd from 'antd'; -import React from 'react'; -import * as ReactDOMClient from 'react-dom/client'; - -export type RunJSVersion = 'v1' | (string & {}); -export type RunJSContextCtor = new (delegate: FlowContext) => FlowRunJSContext; - -export type RunJSDocMeta = { - label?: string; - properties?: Record; - methods?: Record; - snipastes?: Record; -}; - -const classMeta = new WeakMap(); -const classDocCache = new WeakMap(); - -function deepMerge(base: any, patch: any) { - if (patch === null) return undefined; - if (Array.isArray(base) || Array.isArray(patch) || typeof base !== 'object' || typeof patch !== 'object') { - return patch ?? base; - } - const out: any = { ...base }; - for (const k of Object.keys(patch)) { - const v = deepMerge(base?.[k], patch[k]); - if (typeof v === 'undefined') delete out[k]; - else out[k] = v; - } - return out; -} - -export class FlowRunJSContext { - protected _delegate: FlowContext; - [key: string]: any; - static allow?: { keys?: ReadonlyArray; facades?: Record> }; - - constructor(delegate: FlowContext) { - this._delegate = delegate; - const self = this as any; - // 常用前端依赖直接注入 - self.React = React; - self.react = React; - self.antd = antd; - self.ReactDOM = ReactDOMClient; - // 公共方法:分发模型事件 - self.dispatchModelEvent = async (modelOrUid: any, eventName: string, inputArgs?: Record) => { - let model: any = null; - const engine = (this._delegate as any).engine; - if (typeof modelOrUid === 'string') { - model = await engine?.loadModel?.({ uid: modelOrUid }); - } else if (modelOrUid && typeof modelOrUid === 'object' && typeof modelOrUid.dispatchEvent === 'function') { - model = modelOrUid; - } - if (model) { - model.context?.addDelegate?.(self); - model.dispatchEvent(eventName, { - navigation: false, - ...(self.model?.['getInputArgs']?.() || {}), - ...(inputArgs || {}), - }); - } else { - self.message?.error?.( - self.t?.('Model with ID {{uid}} not found', { uid: String(modelOrUid) }) || 'Model not found', - ); - } - }; - - // 显式暴露:基础属性(只读 getter 映射到委托) - this.#exposeProps([ - 'message', - 'notification', - 'logger', - 'resource', - 'urlSearchParams', - 'token', - 'role', - 'auth', - 'api', - 'ref', - 'model', - ]); - // 显式暴露:基础方法(绑定到委托) - this.#exposeMethods(['t', 'requireAsync', 'copyToClipboard', 'resolveJsonTemplate', 'runAction', 'onRefReady']); - } - - // 供子类/外部一致使用的定义 API(与 FlowContext 的接口保持一致风格) - defineProperty(key: string, options: { get?: (ctx: any) => any; value?: any }) { - if (options && typeof options.get === 'function') { - Object.defineProperty(this, key, { - configurable: true, - enumerable: true, - get: () => options.get?.(this), - }); - return; - } - Object.defineProperty(this, key, { - configurable: true, - enumerable: true, - writable: false, - value: options?.value, - }); - } - defineMethod(name: string, fn: (...args: any[]) => any) { - Object.defineProperty(this, name, { - configurable: true, - enumerable: false, - writable: false, - value: fn.bind(this), - }); - } - - // 工具:将委托上的属性以 getter 暴露 - #exposeProps(names: string[]) { - for (const k of names) { - if (Object.prototype.hasOwnProperty.call(this, k)) continue; - this.defineProperty(k, { get: () => (this._delegate as any)[k] }); - } - } - // 工具:将委托上的同名方法绑定暴露 - #exposeMethods(names: string[]) { - for (const k of names) { - if (Object.prototype.hasOwnProperty.call(this, k)) continue; - const src = (this._delegate as any)[k]; - if (typeof src === 'function') { - this.defineMethod(k, src.bind(this._delegate)); - } - } - } - - static injectDefaultGlobals?(): { window?: any; document?: any } | void; - - static define(meta: RunJSDocMeta) { - const prev = classMeta.get(this) || {}; - classMeta.set(this, deepMerge(prev, meta)); - classDocCache.delete(this); - } - - static getDoc(): RunJSDocMeta { - const self = this as any; - if (classDocCache.has(self)) return classDocCache.get(self)!; - const chain: Function[] = []; - let cur: any = self; - while (cur && cur.prototype) { - chain.unshift(cur); - cur = Object.getPrototypeOf(cur); - } - let merged: RunJSDocMeta = {}; - for (const cls of chain) merged = deepMerge(merged, classMeta.get(cls) || {}); - classDocCache.set(self, merged); - return merged; - } -} - -// Define base doc on FlowRunJSContext itself -FlowRunJSContext.define({ - label: 'RunJS base', - properties: { - t: "国际化函数。示例:`ctx.t('Hello {name}', { name: 'World' })`", - logger: "Pino logger 子实例。`ctx.logger.info({ foo: 1 }, 'msg')`", - message: "AntD 全局消息。`ctx.message.success('done')`", - notification: "AntD 通知。`ctx.notification.open({ message: 'Hi' })`", - requireAsync: '异步加载外部库。`const x = await ctx.requireAsync(url)`', - copyToClipboard: '复制文本到剪贴板。`await ctx.copyToClipboard(text)`', - resolveJsonTemplate: '解析含 {{ }} 的模板/表达式', - runAction: '运行当前模型动作。`await ctx.runAction(name, params)`', - resource: '数据资源(按委托可见)', - urlSearchParams: 'URL 查询参数对象', - token: 'API Token', - role: '当前角色', - auth: '认证信息(locale/role/user/token)', - api: 'APIClient 实例', - React: 'React 命名空间(RunJS 环境可用)', - react: 'React 别名(小写)', - ReactDOM: 'ReactDOM 客户端(含 createRoot)', - antd: 'AntD 组件库(RunJS 环境可用)', - }, - methods: { - dispatchModelEvent: "触发模型事件:`await ctx.dispatchModelEvent(modelUid, 'click', { ... })`", - }, -}); diff --git a/packages/core/flow-engine/src/runjs-context/contexts/FormJSFieldItemRunJSContext.ts b/packages/core/flow-engine/src/runjs-context/contexts/FormJSFieldItemRunJSContext.ts index 4e8795b5f70..d424ba49253 100644 --- a/packages/core/flow-engine/src/runjs-context/contexts/FormJSFieldItemRunJSContext.ts +++ b/packages/core/flow-engine/src/runjs-context/contexts/FormJSFieldItemRunJSContext.ts @@ -7,33 +7,40 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -import { FlowRunJSContext } from './FlowRunJSContext'; -import { createSafeDocument, createSafeWindow } from '../../utils'; +import { FlowRunJSContext } from '../../flowContext'; -export class FormJSFieldItemRunJSContext extends FlowRunJSContext { - static injectDefaultGlobals() { - return { window: createSafeWindow(), document: createSafeDocument() }; - } - constructor(delegate: any) { - super(delegate); - this.defineProperty('element', { get: () => (this as any)._delegate['element'] }); - this.defineProperty('record', { get: () => (this as any)._delegate['record'] }); - this.defineProperty('value', { get: () => (this as any)._delegate['value'] }); - } -} +export class FormJSFieldItemRunJSContext extends FlowRunJSContext {} FormJSFieldItemRunJSContext.define({ label: 'FormJSFieldItem RunJS context', properties: { - element: 'ElementProxy,表单字段容器', - value: '字段值(读/受控场景需通过 setProps 修改)', - record: '当前记录(只读)', + element: `ElementProxy instance providing a safe DOM container for form field rendering. + Supports innerHTML, append, and other DOM manipulation methods.`, + value: `Current field value (read-only in display mode; in controlled scenarios, use setProps to modify).`, + record: `Current record data object (read-only). + Contains all field values of the parent record.`, }, methods: { - onRefReady: '容器就绪回调', - setProps: '设置表单项属性:`setProps(fieldModel, { value })`(由联动/表单上下文提供)', - }, - snipastes: { - 显示字段值: { $ref: 'scene/jsfield/innerHTML-value', prefix: 'sn-form-value' }, + onRefReady: `Wait for form field container DOM element to be ready before executing callback. + Parameters: (ref: React.RefObject, callback: (element: HTMLElement) => void, timeout?: number) => void`, + setProps: `Set form field properties programmatically. + Parameters: (fieldModel: any, props: { value?: any, disabled?: boolean, visible?: boolean }) => void + Example: ctx.setProps(fieldModel, { value: "new value" })`, }, }); + +FormJSFieldItemRunJSContext.define( + { + label: '表单 JS 字段项 RunJS 上下文', + properties: { + element: 'ElementProxy,表单字段容器', + value: '字段值(展示模式为只读;受控场景用 setProps 修改)', + record: '当前记录(只读)', + }, + methods: { + onRefReady: '容器就绪回调', + setProps: '设置表单项属性:`setProps(fieldModel, { value })`(由联动/表单上下文提供)', + }, + }, + { locale: 'zh-CN' }, +); diff --git a/packages/core/flow-engine/src/runjs-context/contexts/JSBlockRunJSContext.ts b/packages/core/flow-engine/src/runjs-context/contexts/JSBlockRunJSContext.ts index ad7f0838f35..27da3e13572 100644 --- a/packages/core/flow-engine/src/runjs-context/contexts/JSBlockRunJSContext.ts +++ b/packages/core/flow-engine/src/runjs-context/contexts/JSBlockRunJSContext.ts @@ -7,46 +7,58 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -import { FlowRunJSContext } from './FlowRunJSContext'; -import { createSafeDocument, createSafeWindow } from '../../utils'; +import { FlowRunJSContext } from '../../flowContext'; -export class JSBlockRunJSContext extends FlowRunJSContext { - static injectDefaultGlobals() { - return { window: createSafeWindow(), document: createSafeDocument() }; - } - constructor(delegate: any) { - super(delegate); - // 显式暴露本场景所需属性 - this.defineProperty('element', { get: () => (this as any)._delegate['element'] }); - this.defineProperty('record', { get: () => (this as any)._delegate['record'] }); - this.defineProperty('value', { get: () => (this as any)._delegate['value'] }); - } -} +export class JSBlockRunJSContext extends FlowRunJSContext {} JSBlockRunJSContext.define({ - label: 'JSBlock RunJS context', + label: 'RunJS context', properties: { - element: 'ElementProxy,安全 DOM 容器。支持 innerHTML/append 等', - record: '当前记录(只读,存在于数据块/详情等场景)', - value: '当前值(若存在)', - React: 'React(已注入)', - antd: 'AntD(已注入)', + element: { + description: `ElementProxy instance providing a safe DOM container. + Supports innerHTML, append, and other DOM manipulation methods. + Use this to render content in the JS block.`, + detail: 'ElementProxy', + properties: { + innerHTML: 'Set or read the HTML content of the container element.', + }, + }, + record: `Current record data object (read-only). + Available when the JS block is within a data block or detail view context.`, + value: 'Current value of the field or component, if available in the current context.', + React: 'React library', + antd: 'Ant Design library', }, methods: { - onRefReady: 'Container ref ready callback:\n```js\nctx.onRefReady(ctx.ref, el => { /* ... */ })\n```', + onRefReady: `Wait for container DOM element to be ready before executing callback. + Parameters: (ref: React.RefObject, callback: (element: HTMLElement) => void, timeout?: number) => void + Example: ctx.onRefReady(ctx.ref, (el) => { el.innerHTML = "Ready!" })`, requireAsync: 'Load external library: `const lib = await ctx.requireAsync(url)`', - }, - snipastes: { - 'Render HTML': { $ref: 'scene/jsblock/render-basic', prefix: 'sn-jsb-html' }, - 'Render React': { $ref: 'scene/jsblock/render-react', prefix: 'sn-jsb-react' }, - 'Init ECharts': { $ref: 'libs/echarts-init', prefix: 'sn-echarts' }, - 'Render card': { $ref: 'scene/jsblock/render-card', prefix: 'sn-jsb-card' }, - 'Button handler': { $ref: 'scene/jsblock/render-button-handler', prefix: 'sn-jsb-button' }, - 'JSX mount': { $ref: 'scene/jsblock/jsx-mount', prefix: 'sn-jsx-mount' }, - 'JSX unmount': { $ref: 'scene/jsblock/jsx-unmount', prefix: 'sn-jsx-unmount' }, - Notification: { $ref: 'global/notification-open', prefix: 'sn-notify' }, - 'Window open': { $ref: 'global/window-open', prefix: 'sn-window-open' }, - 'Add click listener': { $ref: 'scene/jsblock/add-event-listener', prefix: 'sn-jsb-click' }, - 'Append style': { $ref: 'scene/jsblock/append-style', prefix: 'sn-jsb-style' }, + importAsync: 'Dynamically import ESM module: `const mod = await ctx.importAsync(url)`', }, }); + +JSBlockRunJSContext.define( + { + label: 'RunJS 上下文', + properties: { + element: { + description: 'ElementProxy,安全的 DOM 容器,支持 innerHTML/append 等', + detail: 'ElementProxy', + properties: { + innerHTML: '读取或设置容器的 HTML 内容', + }, + }, + record: '当前记录(只读,用于数据区块/详情等场景)', + value: '当前值(若存在)', + React: 'React 库', + antd: 'Ant Design 库', + }, + methods: { + onRefReady: '容器 ref 就绪回调:\n```js\nctx.onRefReady(ctx.ref, el => { /* ... */ })\n```', + requireAsync: '加载外部库:`const lib = await ctx.requireAsync(url)`', + importAsync: '按 URL 动态导入 ESM 模块:`const mod = await ctx.importAsync(url)`', + }, + }, + { locale: 'zh-CN' }, +); diff --git a/packages/core/flow-engine/src/runjs-context/contexts/JSCollectionActionRunJSContext.ts b/packages/core/flow-engine/src/runjs-context/contexts/JSCollectionActionRunJSContext.ts index 228e61c55d1..918a5a7ef46 100644 --- a/packages/core/flow-engine/src/runjs-context/contexts/JSCollectionActionRunJSContext.ts +++ b/packages/core/flow-engine/src/runjs-context/contexts/JSCollectionActionRunJSContext.ts @@ -7,26 +7,25 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -import { FlowRunJSContext } from './FlowRunJSContext'; +import { FlowRunJSContext } from '../../flowContext'; -export class JSCollectionActionRunJSContext extends FlowRunJSContext { - constructor(delegate: any) { - super(delegate); - this.defineProperty('resource', { get: () => (this as any)._delegate['resource'] }); - } -} +export class JSCollectionActionRunJSContext extends FlowRunJSContext {} JSCollectionActionRunJSContext.define({ label: 'JSCollectionAction RunJS context', properties: { - resource: '列表资源(选中行/分页等)', - }, - methods: { - runAction: 'Run action', - message: 'Message API', - }, - snipastes: { - 'Selected count': { $ref: 'scene/actions/collection-selected-count', prefix: 'sn-act-selected-count' }, - 'Iterate selected rows': { $ref: 'scene/actions/iterate-selected-rows', prefix: 'sn-act-iterate' }, + resource: `Collection resource instance providing access to selected rows, pagination, and data operations. + Use ctx.resource.selectedRows to get selected records. + Use ctx.resource.pagination for page info.`, }, }); + +JSCollectionActionRunJSContext.define( + { + label: 'JS 集合动作 RunJS 上下文', + properties: { + resource: '列表资源(包含选中行/分页信息等)', + }, + }, + { locale: 'zh-CN' }, +); diff --git a/packages/core/flow-engine/src/runjs-context/contexts/JSColumnRunJSContext.ts b/packages/core/flow-engine/src/runjs-context/contexts/JSColumnRunJSContext.ts new file mode 100644 index 00000000000..0efd2eb04dd --- /dev/null +++ b/packages/core/flow-engine/src/runjs-context/contexts/JSColumnRunJSContext.ts @@ -0,0 +1,58 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { FlowRunJSContext } from '../../flowContext'; + +/** + * RunJS context for JSColumnModel (table custom column). + * Focused on per-row rendering with access to current record and cell element. + */ +export class JSColumnRunJSContext extends FlowRunJSContext {} + +JSColumnRunJSContext.define({ + label: 'JSColumn RunJS context', + properties: { + element: + 'ElementProxy instance providing a safe DOM container for the current table cell. Supports innerHTML/append and basic DOM APIs.', + record: 'Current row record object (read-only).', + recordIndex: 'Index of the current row in the page (0-based).', + collection: 'Collection definition metadata (read-only).', + viewer: + 'View controller providing dialog/drawer/embed helpers for interactions initiated from the cell (e.g., open details).', + React: 'React library', + antd: 'Ant Design library', + }, + methods: { + onRefReady: + 'Wait for cell DOM element to be ready before executing callback. Parameters: (ref, callback, timeout?) => void', + requireAsync: 'Load external library by URL: `const lib = await ctx.requireAsync(url)`', + importAsync: 'Dynamically import ESM module by URL: `const mod = await ctx.importAsync(url)`', + }, +}); + +JSColumnRunJSContext.define( + { + label: 'JS 列 RunJS 上下文', + properties: { + element: 'ElementProxy,表格单元格的安全 DOM 容器,支持 innerHTML/append 等', + record: '当前行记录对象(只读)', + recordIndex: '当前行索引(从 0 开始)', + collection: '集合定义元数据(只读)', + viewer: '视图控制器,可用于在单元格中触发抽屉/对话框/内嵌等交互', + React: 'React 库', + antd: 'Ant Design 库', + }, + methods: { + onRefReady: '等待单元格 DOM 就绪后执行回调。参数:(ref, callback, timeout?)', + requireAsync: '按 URL 异步加载外部库:`const lib = await ctx.requireAsync(url)`', + importAsync: '按 URL 动态导入 ESM 模块:`const mod = await ctx.importAsync(url)`', + }, + }, + { locale: 'zh-CN' }, +); diff --git a/packages/core/flow-engine/src/runjs-context/contexts/JSFieldRunJSContext.ts b/packages/core/flow-engine/src/runjs-context/contexts/JSFieldRunJSContext.ts index ffb95eb5674..c7a90e3a736 100644 --- a/packages/core/flow-engine/src/runjs-context/contexts/JSFieldRunJSContext.ts +++ b/packages/core/flow-engine/src/runjs-context/contexts/JSFieldRunJSContext.ts @@ -7,37 +7,42 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -import { FlowRunJSContext } from './FlowRunJSContext'; -import { createSafeDocument, createSafeWindow } from '../../utils'; +import { FlowRunJSContext } from '../../flowContext'; -export class JSFieldRunJSContext extends FlowRunJSContext { - static injectDefaultGlobals() { - return { window: createSafeWindow(), document: createSafeDocument() }; - } - constructor(delegate: any) { - super(delegate); - this.defineProperty('element', { get: () => (this as any)._delegate['element'] }); - this.defineProperty('value', { get: () => (this as any)._delegate['value'] }); - this.defineProperty('record', { get: () => (this as any)._delegate['record'] }); - this.defineProperty('collection', { get: () => (this as any)._delegate['collection'] }); - } -} +export class JSFieldRunJSContext extends FlowRunJSContext {} JSFieldRunJSContext.define({ label: 'JSField RunJS context', properties: { - element: 'ElementProxy,字段渲染容器', - value: '字段当前值(只读)', - record: '当前记录(只读)', - collection: '集合定义(只读)', + element: `ElementProxy instance providing a safe DOM container for field rendering. + Supports innerHTML, append, and other DOM manipulation methods.`, + value: `Current value of the field (read-only). + Contains the data value stored in this field.`, + record: `Current record data object (read-only). + Contains all field values of the parent record.`, + collection: `Collection definition metadata (read-only). + Provides schema information about the collection this field belongs to.`, }, methods: { - onRefReady: 'Container ready callback', - }, - snipastes: { - 'Render value': { $ref: 'scene/jsfield/innerHTML-value', prefix: 'sn-jsf-value' }, - 'Message success': { $ref: 'global/message-success', prefix: 'sn-msg-ok' }, - 'Format number': { $ref: 'scene/jsfield/format-number', prefix: 'sn-jsf-num' }, - 'Color by value': { $ref: 'scene/jsfield/color-by-value', prefix: 'sn-jsf-color' }, + onRefReady: `Wait for field container DOM element to be ready before executing callback. + Parameters: (ref: React.RefObject, callback: (element: HTMLElement) => void, timeout?: number) => void + Example: ctx.onRefReady(ctx.ref, (el) => { el.innerHTML = ctx.value })`, }, }); + +JSFieldRunJSContext.define( + { + label: 'JS 字段 RunJS 上下文', + properties: { + element: 'ElementProxy,字段渲染容器,支持 innerHTML/append 等 DOM 操作', + value: '字段当前值(只读)', + record: '当前记录对象(只读,包含父记录全部字段值)', + collection: '集合定义元数据(只读,描述字段所属集合的 Schema)', + }, + methods: { + onRefReady: + '在字段容器 DOM 就绪后执行回调。参数:(ref, callback, timeout?);示例:ctx.onRefReady(ctx.ref, el => { el.innerHTML = ctx.value })', + }, + }, + { locale: 'zh-CN' }, +); diff --git a/packages/core/flow-engine/src/runjs-context/contexts/JSItemRunJSContext.ts b/packages/core/flow-engine/src/runjs-context/contexts/JSItemRunJSContext.ts index c4d85d08dbf..11a0cd6bce3 100644 --- a/packages/core/flow-engine/src/runjs-context/contexts/JSItemRunJSContext.ts +++ b/packages/core/flow-engine/src/runjs-context/contexts/JSItemRunJSContext.ts @@ -7,33 +7,37 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -import { FlowRunJSContext } from './FlowRunJSContext'; -import { createSafeDocument, createSafeWindow } from '../../utils'; +import { FlowRunJSContext } from '../../flowContext'; -export class JSItemRunJSContext extends FlowRunJSContext { - static injectDefaultGlobals() { - return { window: createSafeWindow(), document: createSafeDocument() }; - } - constructor(delegate: any) { - super(delegate); - this.defineProperty('element', { get: () => (this as any)._delegate['element'] }); - this.defineProperty('record', { get: () => (this as any)._delegate['record'] }); - this.defineProperty('resource', { get: () => (this as any)._delegate['resource'] }); - } -} +export class JSItemRunJSContext extends FlowRunJSContext {} JSItemRunJSContext.define({ label: 'JSItem RunJS context', properties: { - element: 'ElementProxy,表单项渲染容器', - resource: '当前资源(只读)', - record: '当前记录(只读)', + element: `ElementProxy instance providing a safe DOM container for form item rendering. + Supports innerHTML, append, and other DOM manipulation methods.`, + resource: `Current resource instance (read-only). + Provides access to the data resource associated with the current form context.`, + record: `Current record data object (read-only). + Contains all field values of the parent record.`, }, methods: { - onRefReady: 'Container ready callback', - requireAsync: 'Load external library', - }, - snipastes: { - 'Render form item': { $ref: 'scene/jsitem/render-basic', prefix: 'sn-jsitem-basic' }, + onRefReady: `Wait for form item container DOM element to be ready before executing callback. + Parameters: (ref: React.RefObject, callback: (element: HTMLElement) => void, timeout?: number) => void`, }, }); + +JSItemRunJSContext.define( + { + label: 'JS 表单项 RunJS 上下文', + properties: { + element: 'ElementProxy,表单项渲染容器,支持 innerHTML/append 等 DOM 操作', + resource: '当前资源(只读)', + record: '当前记录(只读)', + }, + methods: { + onRefReady: '容器就绪后执行回调。参数:(ref, callback, timeout?)', + }, + }, + { locale: 'zh-CN' }, +); diff --git a/packages/core/flow-engine/src/runjs-context/contexts/JSRecordActionRunJSContext.ts b/packages/core/flow-engine/src/runjs-context/contexts/JSRecordActionRunJSContext.ts index 237007aad63..b8f14c08380 100644 --- a/packages/core/flow-engine/src/runjs-context/contexts/JSRecordActionRunJSContext.ts +++ b/packages/core/flow-engine/src/runjs-context/contexts/JSRecordActionRunJSContext.ts @@ -7,28 +7,27 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -import { FlowRunJSContext } from './FlowRunJSContext'; +import { FlowRunJSContext } from '../../flowContext'; -export class JSRecordActionRunJSContext extends FlowRunJSContext { - constructor(delegate: any) { - super(delegate); - this.defineProperty('record', { get: () => (this as any)._delegate['record'] }); - this.defineProperty('filterByTk', { get: () => (this as any)._delegate['filterByTk'] }); - } -} +export class JSRecordActionRunJSContext extends FlowRunJSContext {} JSRecordActionRunJSContext.define({ label: 'JSRecordAction RunJS context', properties: { - record: '当前记录(只读)', - filterByTk: '主键/过滤键(只读)', - }, - methods: { - runAction: 'Run action: `await ctx.runAction(name, params)`', - message: 'Message API', - }, - snipastes: { - 'Show record id': { $ref: 'scene/actions/record-id-message', prefix: 'sn-act-record-id' }, - 'Run action': { $ref: 'scene/actions/run-action-basic', prefix: 'sn-act-run' }, + record: `Current record data object (read-only). + Contains all field values of the record associated with this action.`, + filterByTk: `Primary key or filter key of the current record (read-only). + Used to identify the specific record in database operations.`, }, }); + +JSRecordActionRunJSContext.define( + { + label: 'JS 记录动作 RunJS 上下文', + properties: { + record: '当前记录(只读)', + filterByTk: '主键/过滤键(只读)', + }, + }, + { locale: 'zh-CN' }, +); diff --git a/packages/core/flow-engine/src/runjs-context/contexts/LinkageRunJSContext.ts b/packages/core/flow-engine/src/runjs-context/contexts/LinkageRunJSContext.ts deleted file mode 100644 index e88b7439e31..00000000000 --- a/packages/core/flow-engine/src/runjs-context/contexts/LinkageRunJSContext.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * This file is part of the NocoBase (R) project. - * Copyright (c) 2020-2024 NocoBase Co., Ltd. - * Authors: NocoBase Team. - * - * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. - * For more information, please refer to: https://www.nocobase.com/agreement. - */ - -import { FlowRunJSContext } from './FlowRunJSContext'; - -export class LinkageRunJSContext extends FlowRunJSContext { - constructor(delegate: any) { - super(delegate); - this.defineProperty('model', { get: () => (this as any)._delegate['model'] }); - this.defineProperty('fields', { get: () => (this as any)._delegate['fields'] }); - } -} - -LinkageRunJSContext.define({ - label: 'Linkage RunJS context', - properties: { - model: '当前块/字段模型(只读访问)', - fields: '可访问的字段集合(只读)', - }, - methods: { - message: 'Message API', - }, - snipastes: { - 'Set field value': { $ref: 'scene/linkage/set-field-value', prefix: 'sn-link-set' }, - 'Toggle visible': { $ref: 'scene/linkage/toggle-visible', prefix: 'sn-link-visibility' }, - 'Set disabled': { $ref: 'scene/linkage/set-disabled', prefix: 'sn-link-disable' }, - 'Set required': { $ref: 'scene/linkage/set-required', prefix: 'sn-link-required' }, - }, -}); diff --git a/packages/core/flow-engine/src/runjs-context/contexts/base.ts b/packages/core/flow-engine/src/runjs-context/contexts/base.ts new file mode 100644 index 00000000000..a3b707e30ae --- /dev/null +++ b/packages/core/flow-engine/src/runjs-context/contexts/base.ts @@ -0,0 +1,155 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { FlowRunJSContext } from '../../flowContext'; + +export function defineBaseContextMeta() { + FlowRunJSContext.define({ + label: 'RunJS base', + properties: { + logger: 'Pino logger instance for structured logging. Example: `ctx.logger.info({ foo: 1 }, "message")`', + message: + 'Ant Design global message API for displaying temporary messages. Example: `ctx.message.success("Operation completed")`', + notification: + 'Ant Design notification API for displaying notification boxes. Example: `ctx.notification.open({ message: "Title", description: "Content" })`', + resource: 'Data resource accessible based on current user permissions', + urlSearchParams: 'URLSearchParams object containing query parameters from the current URL', + token: 'API authentication token for the current session', + role: 'Current user role information', + auth: 'Authentication context containing locale, role, user, and token information', + api: { + description: 'APIClient instance for making HTTP requests.', + detail: 'APIClient', + properties: { + request: { + description: + 'Make an HTTP request using the APIClient instance. Parameters: (options: RequestOptions) => Promise.', + detail: 'Promise', + completion: { + insertText: `await ctx.api.request({ url: '', method: 'get', params: {} })`, + }, + }, + }, + }, + i18n: { + description: 'An instance of i18next for managing internationalization.', + detail: 'i18next', + properties: { + language: 'Current active language code.', + }, + }, + React: 'React namespace providing React library functions and hooks (available in RunJS environment)', + ReactDOM: 'ReactDOM client API including createRoot for rendering React components', + antd: 'Ant Design component library', + }, + methods: { + t: 'Internationalization function for translating text. Parameters: (key: string, variables?: object) => string. Example: `ctx.t("Hello {name}", { name: "World" })`', + requireAsync: + 'Asynchronously load external libraries from URL. Parameters: (url: string) => Promise. Example: `const lodash = await ctx.requireAsync("https://cdn.jsdelivr.net/npm/lodash")`', + importAsync: + 'Dynamically import ESM module by URL. Parameters: (url: string) => Promise. Example: `const mod = await ctx.importAsync("https://cdn.jsdelivr.net/npm/lit-html@2/+esm")`', + resolveJsonTemplate: + 'Resolve JSON templates containing variable expressions with {{ }} syntax. Parameters: (template: any, context?: object) => any', + runAction: { + description: + 'Execute a data action on the current resource. Parameters: (actionName: string, params: object) => Promise. Example: `await ctx.runAction("create", { values: { name: "test" } })`', + detail: 'Promise', + completion: { + insertText: `await ctx.runAction('create', { values: {} })`, + }, + }, + openView: { + description: `Open a view component (page, modal, or drawer) by its unique identifier. + Parameters: (viewId: string, options?: OpenViewOptions) => Promise + Options: + - params: Record - Parameters passed to the view component + - mode: "page" | "modal" | "drawer" - Display mode (default: "drawer") + - title: string - Modal/drawer title + - width: number | string - Modal/drawer width + - navigation: boolean - Whether to use route navigation + - preventClose: boolean - Prevent closing the view + - viewUid: string - Custom view UID for routing + - isMobileLayout: boolean - Use mobile layout (displays as embed) + Examples: + - Modal with params: await ctx.openView("user-detail", { params: { id: 123 }, mode: "modal", title: "User Details", width: 800 }) + - Drawer (default): await ctx.openView("settings-page") + - Page navigation: await ctx.openView("dashboard", { mode: "page" })`, + detail: 'Promise', + completion: { + insertText: `await ctx.openView('view-id', { mode: 'drawer', params: {} })`, + }, + }, + }, + }); + + FlowRunJSContext.define( + { + label: 'RunJS 基础', + properties: { + logger: 'Pino 日志实例(结构化日志)。示例:`ctx.logger.info({ foo: 1 }, "message")`', + message: 'Ant Design 全局消息 API,用于显示临时提示。示例:`ctx.message.success("操作成功")`', + notification: + 'Ant Design 通知 API,用于显示通知框。示例:`ctx.notification.open({ message: "标题", description: "内容" })`', + resource: '基于当前用户权限可访问的数据资源', + urlSearchParams: '当前 URL 的查询参数(URLSearchParams 对象)', + token: '当前会话的 API 认证 token', + role: '当前用户角色信息', + auth: '认证上下文,包含 locale、role、user、token 等信息', + api: { + description: '用于发起 HTTP 请求的 APIClient 实例', + detail: 'APIClient', + properties: { + request: { + description: '通过 ctx.api.request 发起 HTTP 请求,入参为 RequestOptions,返回 Promise。', + detail: 'Promise', + completion: { + insertText: `await ctx.api.request({ url: '', method: 'get', params: {} })`, + }, + }, + }, + }, + i18n: { + description: 'i18next 实例,可用于管理国际化', + detail: 'i18next', + properties: { + language: '当前激活的语言代码', + }, + }, + React: 'React 命名空间,提供 React 函数与 hooks(RunJS 环境中可用)', + ReactDOM: 'ReactDOM 客户端 API,含 createRoot 等渲染方法', + antd: 'Ant Design 组件库(RunJS 环境中可用)', + }, + methods: { + t: '国际化函数,用于翻译文案。参数:(key: string, variables?: object) => string。示例:`ctx.t("你好 {name}", { name: "世界" })`', + requireAsync: + '按 URL 异步加载外部库。参数:(url: string) => Promise。示例:`const lodash = await ctx.requireAsync("https://cdn.jsdelivr.net/npm/lodash")`', + importAsync: + '按 URL 动态导入 ESM 模块(开发/生产均可用)。参数:(url: string) => Promise。示例:`const mod = await ctx.importAsync("https://cdn.jsdelivr.net/npm/lit-html@2/+esm")`', + resolveJsonTemplate: '解析含 {{ }} 变量表达式的 JSON 模板。参数:(template: any, context?: object) => any', + runAction: { + description: + '对当前资源执行数据动作。参数:(actionName: string, params: object) => Promise。示例:`await ctx.runAction("create", { values: { name: "test" } })`', + detail: 'Promise', + completion: { + insertText: `await ctx.runAction('create', { values: {} })`, + }, + }, + openView: { + description: + '根据唯一标识打开视图(页面/弹窗/抽屉)。参数:(viewId: string, options?: OpenViewOptions) => Promise`,常用选项:params、mode、title、width、navigation、preventClose、viewUid、isMobileLayout。', + detail: 'Promise', + completion: { + insertText: `await ctx.openView('view-id', { mode: 'drawer', params: {} })`, + }, + }, + }, + }, + { locale: 'zh-CN' }, + ); +} diff --git a/packages/core/flow-engine/src/runjs-context/helpers.ts b/packages/core/flow-engine/src/runjs-context/helpers.ts index 8095b6d6b40..06be4d4c7a6 100644 --- a/packages/core/flow-engine/src/runjs-context/helpers.ts +++ b/packages/core/flow-engine/src/runjs-context/helpers.ts @@ -10,47 +10,49 @@ import type { FlowContext } from '../flowContext'; import { JSRunner } from '../JSRunner'; import type { JSRunnerOptions } from '../JSRunner'; -import { FlowRunJSContext, RunJSVersion } from './contexts/FlowRunJSContext'; -import { RunJSContextRegistry, getModelClassName } from './registry'; -import { JSBlockRunJSContext } from './contexts/JSBlockRunJSContext'; -import { JSFieldRunJSContext } from './contexts/JSFieldRunJSContext'; -import { JSItemRunJSContext } from './contexts/JSItemRunJSContext'; -import { FormJSFieldItemRunJSContext } from './contexts/FormJSFieldItemRunJSContext'; -import { JSRecordActionRunJSContext } from './contexts/JSRecordActionRunJSContext'; -import { JSCollectionActionRunJSContext } from './contexts/JSCollectionActionRunJSContext'; -import { LinkageRunJSContext } from './contexts/LinkageRunJSContext'; +import { RunJSContextRegistry, getModelClassName, type RunJSVersion } from './registry'; + +function getLocale(ctx: any): string | undefined { + return ctx?.api?.auth?.locale || ctx?.i18n?.language || ctx?.locale; +} export function getRunJSDocFor(ctx: FlowContext, { version = 'v1' as RunJSVersion } = {}) { const modelClass = getModelClassName(ctx); - const ctor = - RunJSContextRegistry.resolve(version, modelClass) || - RunJSContextRegistry.resolve('latest' as RunJSVersion, modelClass) || - FlowRunJSContext; - return (ctor as any).getDoc?.() || {}; + const ctor = RunJSContextRegistry.resolve(version, modelClass) || RunJSContextRegistry.resolve(version, '*'); + const locale = getLocale(ctx); + if ((ctor as any)?.getDoc?.length) { + // prefer getDoc(locale) + return (ctor as any).getDoc(locale) || {}; + } + return (ctor as any)?.getDoc?.() || {}; } export function createJSRunnerWithVersion(this: FlowContext, options?: JSRunnerOptions) { const version = (options?.version as RunJSVersion) || ('v1' as RunJSVersion); const modelClass = getModelClassName(this); - const Ctor = - RunJSContextRegistry.resolve(version, modelClass) || - RunJSContextRegistry.resolve('latest' as RunJSVersion, modelClass) || - FlowRunJSContext; - const runCtx = new Ctor((this as any).createProxy ? (this as any).createProxy() : (this as any)); - const def = (Ctor as any).injectDefaultGlobals?.() || {}; - const globals = { ctx: runCtx, ...def, ...(options?.globals || {}) }; + const ensureFlowContext = (obj: any): FlowContext => obj as FlowContext; + const Ctor = RunJSContextRegistry.resolve(version, modelClass) || RunJSContextRegistry.resolve(version, '*'); + if (!Ctor) { + throw new Error('[RunJS] No RunJSContext registered for version/model.'); + } + const runCtx = new (Ctor as any)(ensureFlowContext(this)); + const globals: Record = { ctx: runCtx, ...(options?.globals || {}) }; + // 对字段/区块类上下文,默认注入 window/document 以支持在沙箱中访问 DOM API + if (modelClass === 'JSFieldModel' || modelClass === 'JSBlockModel') { + if (typeof window !== 'undefined') globals.window = window as any; + if (typeof document !== 'undefined') globals.document = document as any; + } // 透传 JSRunnerOptions 其余配置(如 timeoutMs) const { timeoutMs } = options || {}; return new JSRunner({ globals, timeoutMs }); } -export function registerDefaultMappings() { - const v: RunJSVersion = 'v1'; - RunJSContextRegistry.register(v, 'JSBlockModel', JSBlockRunJSContext); - RunJSContextRegistry.register(v, 'JSFieldModel', JSFieldRunJSContext); - RunJSContextRegistry.register(v, 'JSItemModel', JSItemRunJSContext); - RunJSContextRegistry.register(v, 'FormJSFieldItemModel', FormJSFieldItemRunJSContext); - RunJSContextRegistry.register(v, 'JSRecordActionModel', JSRecordActionRunJSContext); - RunJSContextRegistry.register(v, 'JSCollectionActionModel', JSCollectionActionRunJSContext); - RunJSContextRegistry.register(v, '*', FlowRunJSContext); +export function getRunJSScenesForModel(modelClass: string, version: RunJSVersion = 'v1'): string[] { + const meta = RunJSContextRegistry.getMeta(version, modelClass); + return Array.isArray(meta?.scenes) ? [...meta!.scenes!] : []; +} + +export function getRunJSScenesForContext(ctx: FlowContext, { version = 'v1' as RunJSVersion } = {}): string[] { + const modelClass = getModelClassName(ctx); + return getRunJSScenesForModel(modelClass, version); } diff --git a/packages/core/flow-engine/src/runjs-context/index.ts b/packages/core/flow-engine/src/runjs-context/index.ts deleted file mode 100644 index 17be0a871b5..00000000000 --- a/packages/core/flow-engine/src/runjs-context/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * This file is part of the NocoBase (R) project. - * Copyright (c) 2020-2024 NocoBase Co., Ltd. - * Authors: NocoBase Team. - * - * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. - * For more information, please refer to: https://www.nocobase.com/agreement. - */ - -export * from './registry'; -export * from './helpers'; -export * from './contexts/FlowRunJSContext'; -export * from './contexts/JSBlockRunJSContext'; -export * from './contexts/JSFieldRunJSContext'; -export * from './contexts/JSItemRunJSContext'; -export * from './contexts/FormJSFieldItemRunJSContext'; -export * from './contexts/JSRecordActionRunJSContext'; -export * from './contexts/JSCollectionActionRunJSContext'; -export * from './contexts/LinkageRunJSContext'; -export { engineSnippets } from './snippets'; diff --git a/packages/core/flow-engine/src/runjs-context/registry.ts b/packages/core/flow-engine/src/runjs-context/registry.ts index c904bd0d51a..1ab567396ef 100644 --- a/packages/core/flow-engine/src/runjs-context/registry.ts +++ b/packages/core/flow-engine/src/runjs-context/registry.ts @@ -7,59 +7,28 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -import type { RunJSVersion, RunJSContextCtor } from './contexts/FlowRunJSContext'; -import { FlowRunJSContext } from './contexts/FlowRunJSContext'; -import { JSBlockRunJSContext } from './contexts/JSBlockRunJSContext'; -import { JSFieldRunJSContext } from './contexts/JSFieldRunJSContext'; -import { JSItemRunJSContext } from './contexts/JSItemRunJSContext'; -import { FormJSFieldItemRunJSContext } from './contexts/FormJSFieldItemRunJSContext'; -import { JSRecordActionRunJSContext } from './contexts/JSRecordActionRunJSContext'; -import { JSCollectionActionRunJSContext } from './contexts/JSCollectionActionRunJSContext'; +// 为避免在模块初始化阶段引入 FlowContext(从而触发循环依赖),不要在顶层导入各类 RunJSContext。 +// 在需要默认映射时(首次 resolve)再使用 createRequire 同步加载对应模块。 + +export type RunJSVersion = 'v1' | (string & {}); +export type RunJSContextCtor = new (delegate: any) => any; +export type RunJSContextMeta = { + scenes?: string[]; +}; export class RunJSContextRegistry { - private static map = new Map(); - private static defaultsRegistered = false; - private static ensureDefaults() { - if (this.defaultsRegistered) return; - // v1 默认映射(延迟注册:首次访问时再注册) - const v = 'v1' as RunJSVersion; - try { - const ensure = (model: string, ctor: RunJSContextCtor) => { - const key = `${v}:${model}`; - if (!this.map.has(key)) this.map.set(key, ctor); - }; - ensure('JSBlockModel', JSBlockRunJSContext as any); - ensure('JSFieldModel', JSFieldRunJSContext as any); - ensure('JSItemModel', JSItemRunJSContext as any); - ensure('FormJSFieldItemModel', FormJSFieldItemRunJSContext as any); - ensure('JSRecordActionModel', JSRecordActionRunJSContext as any); - ensure('JSCollectionActionModel', JSCollectionActionRunJSContext as any); - ensure('*', FlowRunJSContext as any); - } finally { - this.defaultsRegistered = true; - } - } - static register(version: RunJSVersion, modelClass: string, ctor: RunJSContextCtor) { - this.map.set(`${version}:${modelClass}`, ctor); + private static map = new Map(); + static register(version: RunJSVersion, modelClass: string, ctor: RunJSContextCtor, meta?: RunJSContextMeta) { + this.map.set(`${version}:${modelClass}`, { ctor, meta }); } static resolve(version: RunJSVersion, modelClass: string) { - this.ensureDefaults(); - return this.map.get(`${version}:${modelClass}`) || this.map.get(`${version}:*`); + return this.map.get(`${version}:${modelClass}`)?.ctor || this.map.get(`${version}:*`)?.ctor; + } + static getMeta(version: RunJSVersion, modelClass: string): RunJSContextMeta | undefined { + return this.map.get(`${version}:${modelClass}`)?.meta || this.map.get(`${version}:*`)?.meta; } } export function getModelClassName(ctx: any): string { - const model = ctx?.model; - if (!model) return '*'; - // 1) 优先使用类 meta 中声明的 createModelOptions.use(构建后稳定,不受构造函数名压缩影响) - try { - const Ctor = model.constructor as any; - const use = Ctor?.meta?.createModelOptions?.use; - if (typeof use === 'string' && use) return use; - } catch (_) { - // ignore - } - // 2) 回退到构造函数名(开发模式可靠,生产模式可能被压缩) - const byName = model?.constructor?.name; - return byName || '*'; + return ctx?.model?.constructor?.name || '*'; } diff --git a/packages/core/flow-engine/src/runjs-context/setup.ts b/packages/core/flow-engine/src/runjs-context/setup.ts new file mode 100644 index 00000000000..04f292d524a --- /dev/null +++ b/packages/core/flow-engine/src/runjs-context/setup.ts @@ -0,0 +1,51 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +/** + * RunJS context registration entry. No side-effects by default. + */ +import { RunJSContextRegistry } from './registry'; +import { FlowRunJSContext } from '../flowContext'; +import { defineBaseContextMeta } from './contexts/base'; + +let done = false; +export async function setupRunJSContexts() { + if (done) return; + defineBaseContextMeta(); + + // Lazy import to avoid circular dependencies during module initialization + const [ + { JSBlockRunJSContext }, + { JSFieldRunJSContext }, + { JSItemRunJSContext }, + { JSColumnRunJSContext }, + { FormJSFieldItemRunJSContext }, + { JSRecordActionRunJSContext }, + { JSCollectionActionRunJSContext }, + ] = await Promise.all([ + import('./contexts/JSBlockRunJSContext'), + import('./contexts/JSFieldRunJSContext'), + import('./contexts/JSItemRunJSContext'), + import('./contexts/JSColumnRunJSContext'), + import('./contexts/FormJSFieldItemRunJSContext'), + import('./contexts/JSRecordActionRunJSContext'), + import('./contexts/JSCollectionActionRunJSContext'), + ]); + + const v1 = 'v1'; + RunJSContextRegistry.register(v1, '*', FlowRunJSContext); + RunJSContextRegistry.register(v1, 'JSBlockModel', JSBlockRunJSContext, { scenes: ['block'] }); + RunJSContextRegistry.register(v1, 'JSFieldModel', JSFieldRunJSContext, { scenes: ['detail'] }); + RunJSContextRegistry.register(v1, 'JSItemModel', JSItemRunJSContext, { scenes: ['form'] }); + RunJSContextRegistry.register(v1, 'JSColumnModel', JSColumnRunJSContext, { scenes: ['table'] }); + RunJSContextRegistry.register(v1, 'FormJSFieldItemModel', FormJSFieldItemRunJSContext, { scenes: ['form'] }); + RunJSContextRegistry.register(v1, 'JSRecordActionModel', JSRecordActionRunJSContext, { scenes: ['table'] }); + RunJSContextRegistry.register(v1, 'JSCollectionActionModel', JSCollectionActionRunJSContext, { scenes: ['table'] }); + done = true; +} diff --git a/packages/core/flow-engine/src/runjs-context/snippets/global/api-request-get.snippet.ts b/packages/core/flow-engine/src/runjs-context/snippets/global/api-request-get.snippet.ts deleted file mode 100644 index d12c714de2c..00000000000 --- a/packages/core/flow-engine/src/runjs-context/snippets/global/api-request-get.snippet.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * This file is part of the NocoBase (R) project. - * Copyright (c) 2020-2024 NocoBase Co., Ltd. - * Authors: NocoBase Team. - * - * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. - * For more information, please refer to: https://www.nocobase.com/agreement. - */ - -export default { - contexts: ['*'], - prefix: 'sn-api-get', - label: 'GET request template', - description: 'Basic template to send a GET request via ctx.api', - content: ` -const res = await ctx.api.request({ url: '/your/api', method: 'get', params: { page: 1 } }); -ctx.message.success(ctx.t('GET request completed')); -console.log(ctx.t('GET result:'), res); -`, -}; diff --git a/packages/core/flow-engine/src/runjs-context/snippets/global/api-request-post.snippet.ts b/packages/core/flow-engine/src/runjs-context/snippets/global/api-request-post.snippet.ts deleted file mode 100644 index b6eb9c0dfa5..00000000000 --- a/packages/core/flow-engine/src/runjs-context/snippets/global/api-request-post.snippet.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * This file is part of the NocoBase (R) project. - * Copyright (c) 2020-2024 NocoBase Co., Ltd. - * Authors: NocoBase Team. - * - * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. - * For more information, please refer to: https://www.nocobase.com/agreement. - */ - -export default { - contexts: ['*'], - prefix: 'sn-api-post', - label: 'POST request template', - description: 'Basic template to send a POST request via ctx.api', - content: ` -const res = await ctx.api.request({ url: '/your/api', method: 'post', data: { name: 'NocoBase' } }); -ctx.message.success(ctx.t('POST request completed')); -console.log(ctx.t('POST result:'), res); -`, -}; diff --git a/packages/core/flow-engine/src/runjs-context/snippets/global/api-request.snippet.ts b/packages/core/flow-engine/src/runjs-context/snippets/global/api-request.snippet.ts new file mode 100644 index 00000000000..b77e34b2449 --- /dev/null +++ b/packages/core/flow-engine/src/runjs-context/snippets/global/api-request.snippet.ts @@ -0,0 +1,38 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import type { SnippetModule } from '../types'; + +const snippet: SnippetModule = { + contexts: ['*'], + prefix: 'sn-api-request', + label: 'API request template', + description: 'Basic template to send HTTP requests via ctx.api.request', + locales: { + 'zh-CN': { + label: 'API 请求模板', + description: '使用 ctx.api.request 发送 HTTP 请求的基础模板', + }, + }, + content: ` +// Replace url/method/params/data as needed +const response = await ctx.api.request({ + url: 'users:list', + method: 'get', + params: { + pageSize: 10, + }, +}); + +ctx.message.success(ctx.t('Request finished')); +console.log(ctx.t('Response data:'), response?.data); +`, +}; + +export default snippet; diff --git a/packages/core/flow-engine/src/runjs-context/snippets/global/console-log-ctx.snippet.ts b/packages/core/flow-engine/src/runjs-context/snippets/global/console-log-ctx.snippet.ts deleted file mode 100644 index 982f100a810..00000000000 --- a/packages/core/flow-engine/src/runjs-context/snippets/global/console-log-ctx.snippet.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * This file is part of the NocoBase (R) project. - * Copyright (c) 2020-2024 NocoBase Co., Ltd. - * Authors: NocoBase Team. - * - * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. - * For more information, please refer to: https://www.nocobase.com/agreement. - */ - -export default { - contexts: ['*'], - prefix: 'sn-log-ctx', - label: 'Log ctx', - description: 'Log the whole ctx object to console', - content: ` -console.log('ctx =>', ctx); -ctx.message?.success?.(ctx.t('ctx printed')); -`, -}; diff --git a/packages/core/flow-engine/src/runjs-context/snippets/global/copy-record-json.snippet.ts b/packages/core/flow-engine/src/runjs-context/snippets/global/copy-record-json.snippet.ts deleted file mode 100644 index c8697b1a8a8..00000000000 --- a/packages/core/flow-engine/src/runjs-context/snippets/global/copy-record-json.snippet.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * This file is part of the NocoBase (R) project. - * Copyright (c) 2020-2024 NocoBase Co., Ltd. - * Authors: NocoBase Team. - * - * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. - * For more information, please refer to: https://www.nocobase.com/agreement. - */ - -import type { SnippetModule } from '../types'; -const snippet: SnippetModule = { - contexts: ['*'], - prefix: 'sn-copy-record', - label: 'Copy record JSON', - description: 'Copy current ctx.record JSON to clipboard', - content: ` -await ctx.copyToClipboard(JSON.stringify(ctx.record ?? {}, null, 2)); -ctx.message.success(ctx.t('Record JSON copied to clipboard')); -`, -}; -export default snippet; diff --git a/packages/core/flow-engine/src/runjs-context/snippets/global/copy-to-clipboard.snippet.ts b/packages/core/flow-engine/src/runjs-context/snippets/global/copy-to-clipboard.snippet.ts deleted file mode 100644 index 5e77067f3a4..00000000000 --- a/packages/core/flow-engine/src/runjs-context/snippets/global/copy-to-clipboard.snippet.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * This file is part of the NocoBase (R) project. - * Copyright (c) 2020-2024 NocoBase Co., Ltd. - * Authors: NocoBase Team. - * - * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. - * For more information, please refer to: https://www.nocobase.com/agreement. - */ - -import type { SnippetModule } from '../types'; -const snippet: SnippetModule = { - contexts: ['*'], - prefix: 'sn-copy', - label: 'Copy to clipboard', - description: 'Copy a string to clipboard and show a success message', - content: ` -await ctx.copyToClipboard(ctx.t('Text to copy')); -ctx.message.success(ctx.t('Copied to clipboard')); -`, -}; -export default snippet; diff --git a/packages/core/flow-engine/src/runjs-context/snippets/global/import-esm.snippet.ts b/packages/core/flow-engine/src/runjs-context/snippets/global/import-esm.snippet.ts new file mode 100644 index 00000000000..65abda56d1c --- /dev/null +++ b/packages/core/flow-engine/src/runjs-context/snippets/global/import-esm.snippet.ts @@ -0,0 +1,39 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import type { SnippetModule } from '../types'; + +const snippet: SnippetModule = { + contexts: ['*'], + prefix: 'sn-import', + label: 'Import ESM module', + description: 'Dynamically import an ESM module by URL', + locales: { + 'zh-CN': { + label: '导入 ESM 模块', + description: '按 URL 动态导入 ESM 模块', + }, + }, + content: ` +// Import an ESM module by URL +// Works in yarn dev and yarn start +const mod = await ctx.importAsync('https://cdn.jsdelivr.net/npm/lit-html@2/+esm'); +const { html, render } = mod; + +ctx.element.innerHTML = ''; +const container = document.createElement('div'); +container.style.padding = '8px'; +container.style.border = '1px dashed #999'; +ctx.element.append(container); + +render(html\`lit-html loaded and rendered\`, container); +`, +}; + +export default snippet; diff --git a/packages/core/flow-engine/src/runjs-context/snippets/global/log-json-record.snippet.ts b/packages/core/flow-engine/src/runjs-context/snippets/global/log-json-record.snippet.ts deleted file mode 100644 index 80960754a9e..00000000000 --- a/packages/core/flow-engine/src/runjs-context/snippets/global/log-json-record.snippet.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * This file is part of the NocoBase (R) project. - * Copyright (c) 2020-2024 NocoBase Co., Ltd. - * Authors: NocoBase Team. - * - * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. - * For more information, please refer to: https://www.nocobase.com/agreement. - */ - -import type { SnippetModule } from '../types'; -const snippet: SnippetModule = { - contexts: ['*'], - prefix: 'sn-log-record', - label: 'Log record JSON', - description: 'Log current ctx.record as formatted JSON', - content: ` -console.log(ctx.t('Current record JSON:'), JSON.stringify(ctx.record ?? {}, null, 2)); -ctx.message.success(ctx.t('Printed to console')); -`, -}; -export default snippet; diff --git a/packages/core/flow-engine/src/runjs-context/snippets/global/message-error.snippet.ts b/packages/core/flow-engine/src/runjs-context/snippets/global/message-error.snippet.ts index 0e325965bb2..c94f4825c6a 100644 --- a/packages/core/flow-engine/src/runjs-context/snippets/global/message-error.snippet.ts +++ b/packages/core/flow-engine/src/runjs-context/snippets/global/message-error.snippet.ts @@ -13,6 +13,12 @@ const snippet: SnippetModule = { prefix: 'sn-msg-error', label: 'Message error', description: 'Show an error toast message', + locales: { + 'zh-CN': { + label: '错误消息提示', + description: '显示一条错误提示消息', + }, + }, content: ` ctx.message.error(ctx.t('Operation failed')); `, diff --git a/packages/core/flow-engine/src/runjs-context/snippets/global/message-success.snippet.ts b/packages/core/flow-engine/src/runjs-context/snippets/global/message-success.snippet.ts index a57fe37293d..dd15d251c9b 100644 --- a/packages/core/flow-engine/src/runjs-context/snippets/global/message-success.snippet.ts +++ b/packages/core/flow-engine/src/runjs-context/snippets/global/message-success.snippet.ts @@ -13,6 +13,12 @@ const snippet: SnippetModule = { prefix: 'sn-msg', label: 'Message success', description: 'Show a success toast message', + locales: { + 'zh-CN': { + label: '成功消息提示', + description: '显示一条成功提示消息', + }, + }, content: ` ctx.message.success(ctx.t('Operation succeeded')); `, diff --git a/packages/core/flow-engine/src/runjs-context/snippets/global/notification-open.snippet.ts b/packages/core/flow-engine/src/runjs-context/snippets/global/notification-open.snippet.ts index 7b5fb6bff87..43ae8303962 100644 --- a/packages/core/flow-engine/src/runjs-context/snippets/global/notification-open.snippet.ts +++ b/packages/core/flow-engine/src/runjs-context/snippets/global/notification-open.snippet.ts @@ -7,11 +7,19 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -export default { +import type { SnippetModule } from '../types'; + +const snippet: SnippetModule = { contexts: ['*'], prefix: 'sn-notify', label: 'Open notification', description: 'Open an AntD notification with custom content', + locales: { + 'zh-CN': { + label: '打开通知', + description: '展示一条自定义内容的通知', + }, + }, content: ` ctx.notification.open({ message: ctx.t('Notification title'), @@ -19,3 +27,5 @@ ctx.notification.open({ }); `, }; + +export default snippet; diff --git a/packages/core/flow-engine/src/runjs-context/snippets/global/open-view-dialog.snippet.ts b/packages/core/flow-engine/src/runjs-context/snippets/global/open-view-dialog.snippet.ts index fb539ade6da..00ebc921241 100644 --- a/packages/core/flow-engine/src/runjs-context/snippets/global/open-view-dialog.snippet.ts +++ b/packages/core/flow-engine/src/runjs-context/snippets/global/open-view-dialog.snippet.ts @@ -14,12 +14,19 @@ const snippet: SnippetModule = { prefix: 'sn-open-dialog', label: 'Open view (dialog)', description: 'Open a view in dialog via ctx.openView', + locales: { + 'zh-CN': { + label: '打开视图(对话框)', + description: '通过 ctx.openView 以对话框方式打开视图', + }, + }, content: ` -const popupUid = 'your-popup-uid'; +// Open a view as dialog and pass arguments at top-level +const popupUid = ctx.model.uid + '-1'; // popupUid should be stable and better bound to ctx.model.uid await ctx.openView(popupUid, { mode: 'dialog', - viewUid: 'detail', - inputArgs: { foo: 'bar' }, + title: ctx.t('Sample dialog'), + size: 'medium', }); `, }; diff --git a/packages/core/flow-engine/src/runjs-context/snippets/global/open-view-drawer.snippet.ts b/packages/core/flow-engine/src/runjs-context/snippets/global/open-view-drawer.snippet.ts index 0a2d21e7a02..45140c0af62 100644 --- a/packages/core/flow-engine/src/runjs-context/snippets/global/open-view-drawer.snippet.ts +++ b/packages/core/flow-engine/src/runjs-context/snippets/global/open-view-drawer.snippet.ts @@ -14,12 +14,19 @@ const snippet: SnippetModule = { prefix: 'sn-open-drawer', label: 'Open view (drawer)', description: 'Open a view in drawer via ctx.openView', + locales: { + 'zh-CN': { + label: '打开视图(抽屉)', + description: '通过 ctx.openView 以抽屉方式打开视图', + }, + }, content: ` -const popupUid = 'your-popup-uid'; +// Open a view as drawer and pass arguments at top-level +const popupUid = ctx.model.uid + '-1'; // popupUid should be stable and better bound to ctx.model.uid await ctx.openView(popupUid, { mode: 'drawer', - viewUid: 'detail', - inputArgs: { foo: 'bar' }, + title: ctx.t('Sample drawer'), + size: 'large', }); `, }; diff --git a/packages/core/flow-engine/src/runjs-context/snippets/global/query-selector.snippet.ts b/packages/core/flow-engine/src/runjs-context/snippets/global/query-selector.snippet.ts new file mode 100644 index 00000000000..e4592112134 --- /dev/null +++ b/packages/core/flow-engine/src/runjs-context/snippets/global/query-selector.snippet.ts @@ -0,0 +1,34 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import type { SnippetModule } from '../types'; +import { JSBlockRunJSContext } from '../../contexts/JSBlockRunJSContext'; +import { JSFieldRunJSContext } from '../../contexts/JSFieldRunJSContext'; +import { FormJSFieldItemRunJSContext } from '../../contexts/FormJSFieldItemRunJSContext'; + +const snippet: SnippetModule = { + contexts: [JSBlockRunJSContext, JSFieldRunJSContext, FormJSFieldItemRunJSContext], + prefix: 'sn-query-selector', + label: 'Query selector', + description: 'Find a child element inside ctx.element using querySelector', + locales: { + 'zh-CN': { + label: '查询子元素', + description: '使用 querySelector 在 ctx.element 内查找子元素', + }, + }, + content: ` +const child = ctx.element.querySelector('.child-class'); +if (child) { + child.textContent = ctx.t('Hello from querySelector'); +} +`, +}; + +export default snippet; diff --git a/packages/core/flow-engine/src/runjs-context/snippets/global/require-amd.snippet.ts b/packages/core/flow-engine/src/runjs-context/snippets/global/require-amd.snippet.ts new file mode 100644 index 00000000000..0e4c227e987 --- /dev/null +++ b/packages/core/flow-engine/src/runjs-context/snippets/global/require-amd.snippet.ts @@ -0,0 +1,30 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import type { SnippetModule } from '../types'; + +const snippet: SnippetModule = { + contexts: ['*'], + prefix: 'sn-require', + label: 'Load AMD module', + description: 'Dynamically load an AMD/RequireJS module by URL', + locales: { + 'zh-CN': { + label: '加载 AMD 模块', + description: '通过 RequireJS 按 URL 动态加载 AMD 模块', + }, + }, + content: ` +// Load an external library (AMD/RequireJS) +const dayjs = await ctx.requireAsync('https://cdn.jsdelivr.net/npm/dayjs@1/dayjs.min.js'); +console.log('dayjs loaded:', dayjs?.default || dayjs); +`, +}; + +export default snippet; diff --git a/packages/core/flow-engine/src/runjs-context/snippets/global/requireAsync.snippet.ts b/packages/core/flow-engine/src/runjs-context/snippets/global/requireAsync.snippet.ts deleted file mode 100644 index 1438d13c05c..00000000000 --- a/packages/core/flow-engine/src/runjs-context/snippets/global/requireAsync.snippet.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * This file is part of the NocoBase (R) project. - * Copyright (c) 2020-2024 NocoBase Co., Ltd. - * Authors: NocoBase Team. - * - * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. - * For more information, please refer to: https://www.nocobase.com/agreement. - */ - -export default { - contexts: ['*'], - prefix: 'sn-require', - label: 'Load external library', - description: 'Dynamically load an external JS via RequireJS', - content: ` -// Load an external library (AMD/RequireJS) -try { - const lib = await ctx.requireAsync('https://cdn.example.com/lib@1.0.0/index.js'); - console.log('lib loaded:', lib); -} catch (e) { - ctx.message.error(ctx.t('Failed to load external library: {{msg}}', { msg: String(e?.message || e) })); -} -`, -}; diff --git a/packages/core/flow-engine/src/runjs-context/snippets/global/sleep.snippet.ts b/packages/core/flow-engine/src/runjs-context/snippets/global/sleep.snippet.ts deleted file mode 100644 index 62e00406edc..00000000000 --- a/packages/core/flow-engine/src/runjs-context/snippets/global/sleep.snippet.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * This file is part of the NocoBase (R) project. - * Copyright (c) 2020-2024 NocoBase Co., Ltd. - * Authors: NocoBase Team. - * - * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. - * For more information, please refer to: https://www.nocobase.com/agreement. - */ - -export default { - contexts: ['*'], - prefix: 'sn-sleep', - label: 'Sleep', - description: 'Pause execution for a given milliseconds', - content: ` -const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); -const ms = 500; -await sleep(ms); -ctx.message.success(ctx.t('Waited {{ms}} ms', { ms })); -`, -}; diff --git a/packages/core/flow-engine/src/runjs-context/snippets/global/try-catch-async.snippet.ts b/packages/core/flow-engine/src/runjs-context/snippets/global/try-catch-async.snippet.ts deleted file mode 100644 index 4e220b02c63..00000000000 --- a/packages/core/flow-engine/src/runjs-context/snippets/global/try-catch-async.snippet.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * This file is part of the NocoBase (R) project. - * Copyright (c) 2020-2024 NocoBase Co., Ltd. - * Authors: NocoBase Team. - * - * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. - * For more information, please refer to: https://www.nocobase.com/agreement. - */ - -export default { - contexts: ['*'], - prefix: 'sn-try', - label: 'Try/catch template', - description: 'Async try/catch template with toast message', - content: ` -try { - // await some async work -} catch (e) { - ctx.message.error(ctx.t('Operation failed: {{msg}}', { msg: String(e?.message || e) })); -} -`, -}; diff --git a/packages/core/flow-engine/src/runjs-context/snippets/global/view-navigation-push.snippet.ts b/packages/core/flow-engine/src/runjs-context/snippets/global/view-navigation-push.snippet.ts deleted file mode 100644 index 4fcac480ded..00000000000 --- a/packages/core/flow-engine/src/runjs-context/snippets/global/view-navigation-push.snippet.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * This file is part of the NocoBase (R) project. - * Copyright (c) 2020-2024 NocoBase Co., Ltd. - * Authors: NocoBase Team. - * - * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. - * For more information, please refer to: https://www.nocobase.com/agreement. - */ - -import type { SnippetModule } from '../types'; -const snippet: SnippetModule = { - contexts: ['*'], - versions: ['*'], - prefix: 'sn-nav-push', - label: 'View navigation: push', - description: 'Navigate within current view (if supported)', - content: ` -// Push a new sub-view (if navigation is available) -ctx.view?.navigation?.push({ viewUid: 'detail', filterByTk: 1 }); -// To go back: ctx.view?.navigation?.back(); -`, -}; -export default snippet; diff --git a/packages/core/flow-engine/src/runjs-context/snippets/global/window-open.snippet.ts b/packages/core/flow-engine/src/runjs-context/snippets/global/window-open.snippet.ts index c548a69bdcf..641385eeada 100644 --- a/packages/core/flow-engine/src/runjs-context/snippets/global/window-open.snippet.ts +++ b/packages/core/flow-engine/src/runjs-context/snippets/global/window-open.snippet.ts @@ -7,13 +7,23 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -export default { +import type { SnippetModule } from '../types'; + +const snippet: SnippetModule = { contexts: ['*'], prefix: 'sn-window-open', label: 'Open new window', description: 'Safely open a new browser window/tab', + locales: { + 'zh-CN': { + label: '打开新窗口', + description: '安全地打开一个新的浏览器窗口或标签页', + }, + }, content: ` // Open a new window/tab window.open('https://example.com', '_blank'); `, }; + +export default snippet; diff --git a/packages/core/flow-engine/src/runjs-context/snippets/index.ts b/packages/core/flow-engine/src/runjs-context/snippets/index.ts index 2e75d6c7ac9..52b0a6acce2 100644 --- a/packages/core/flow-engine/src/runjs-context/snippets/index.ts +++ b/packages/core/flow-engine/src/runjs-context/snippets/index.ts @@ -7,53 +7,189 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -export type EngineSnippetMap = Record Promise>; +import { RunJSContextRegistry } from '../registry'; -export const engineSnippets: EngineSnippetMap = { +// Simple manual exports - no build-time magic needed +const snippets: Record Promise> = { // global 'global/message-success': () => import('./global/message-success.snippet'), 'global/message-error': () => import('./global/message-error.snippet'), - 'global/copy-to-clipboard': () => import('./global/copy-to-clipboard.snippet'), - 'global/copy-record-json': () => import('./global/copy-record-json.snippet'), - 'global/log-json-record': () => import('./global/log-json-record.snippet'), - 'global/api-request-get': () => import('./global/api-request-get.snippet'), - 'global/api-request-post': () => import('./global/api-request-post.snippet'), - 'global/requireAsync': () => import('./global/requireAsync.snippet'), - 'global/try-catch-async': () => import('./global/try-catch-async.snippet'), - 'global/sleep': () => import('./global/sleep.snippet'), + 'global/api-request': () => import('./global/api-request.snippet'), + 'global/require-amd': () => import('./global/require-amd.snippet'), + 'global/import-esm': () => import('./global/import-esm.snippet'), 'global/notification-open': () => import('./global/notification-open.snippet'), 'global/window-open': () => import('./global/window-open.snippet'), - 'global/console-log-ctx': () => import('./global/console-log-ctx.snippet'), 'global/open-view-drawer': () => import('./global/open-view-drawer.snippet'), 'global/open-view-dialog': () => import('./global/open-view-dialog.snippet'), - 'global/view-navigation-push': () => import('./global/view-navigation-push.snippet'), + 'global/query-selector': () => import('./global/query-selector.snippet'), // libs - 'libs/echarts-init': () => import('./libs/echarts-init.snippet'), - // scene/jsblock - 'scene/jsblock/render-basic': () => import('./scene/jsblock/render-basic.snippet'), - 'scene/jsblock/render-react': () => import('./scene/jsblock/render-react.snippet'), - 'scene/jsblock/render-card': () => import('./scene/jsblock/render-card.snippet'), - 'scene/jsblock/render-button-handler': () => import('./scene/jsblock/render-button-handler.snippet'), - 'scene/jsblock/jsx-mount': () => import('./scene/jsblock/jsx-mount.snippet'), - 'scene/jsblock/jsx-unmount': () => import('./scene/jsblock/jsx-unmount.snippet'), - 'scene/jsblock/add-event-listener': () => import('./scene/jsblock/add-event-listener.snippet'), - 'scene/jsblock/append-style': () => import('./scene/jsblock/append-style.snippet'), - // scene/jsfield - 'scene/jsfield/innerHTML-value': () => import('./scene/jsfield/innerHTML-value.snippet'), - 'scene/jsfield/format-number': () => import('./scene/jsfield/format-number.snippet'), - 'scene/jsfield/color-by-value': () => import('./scene/jsfield/color-by-value.snippet'), - // scene/jsitem - 'scene/jsitem/render-basic': () => import('./scene/jsitem/render-basic.snippet'), - // scene/actions - 'scene/actions/record-id-message': () => import('./scene/actions/record-id-message.snippet'), - 'scene/actions/run-action-basic': () => import('./scene/actions/run-action-basic.snippet'), - 'scene/actions/collection-selected-count': () => import('./scene/actions/collection-selected-count.snippet'), - 'scene/actions/iterate-selected-rows': () => import('./scene/actions/iterate-selected-rows.snippet'), - // scene/linkage - 'scene/linkage/set-field-value': () => import('./scene/linkage/set-field-value.snippet'), - 'scene/linkage/toggle-visible': () => import('./scene/linkage/toggle-visible.snippet'), - 'scene/linkage/set-disabled': () => import('./scene/linkage/set-disabled.snippet'), - 'scene/linkage/set-required': () => import('./scene/linkage/set-required.snippet'), + 'scene/block/echarts-init': () => import('./scene/block/echarts-init.snippet'), + // scene/block + 'scene/block/render-react': () => import('./scene/block/render-react.snippet'), + 'scene/block/render-button-handler': () => import('./scene/block/render-button-handler.snippet'), + 'scene/block/add-event-listener': () => import('./scene/block/add-event-listener.snippet'), + 'scene/block/chartjs-bar': () => import('./scene/block/chartjs-bar.snippet'), + 'scene/block/vue-component': () => import('./scene/block/vue-component.snippet'), + 'scene/block/resource-example': () => import('./scene/block/resource-example.snippet'), + 'scene/block/api-fetch-render-list': () => import('./scene/block/api-fetch-render-list.snippet'), + 'scene/block/render-info-card': () => import('./scene/block/render-info-card.snippet'), + 'scene/block/render-statistics': () => import('./scene/block/render-statistics.snippet'), + 'scene/block/render-timeline': () => import('./scene/block/render-timeline.snippet'), + 'scene/block/render-iframe': () => import('./scene/block/render-iframe.snippet'), + // scene/detail + 'scene/detail/innerHTML-value': () => import('./scene/detail/innerHTML-value.snippet'), + 'scene/detail/format-number': () => import('./scene/detail/format-number.snippet'), + 'scene/detail/color-by-value': () => import('./scene/detail/color-by-value.snippet'), + 'scene/detail/copy-to-clipboard': () => import('./scene/detail/copy-to-clipboard.snippet'), + 'scene/detail/status-tag': () => import('./scene/detail/status-tag.snippet'), + 'scene/detail/relative-time': () => import('./scene/detail/relative-time.snippet'), + 'scene/detail/percentage-bar': () => import('./scene/detail/percentage-bar.snippet'), + // scene/form + 'scene/form/render-basic': () => import('./scene/form/render-basic.snippet'), + 'scene/form/set-field-value': () => import('./scene/form/set-field-value.snippet'), + 'scene/form/toggle-visible': () => import('./scene/form/toggle-visible.snippet'), + 'scene/form/set-disabled': () => import('./scene/form/set-disabled.snippet'), + 'scene/form/set-required': () => import('./scene/form/set-required.snippet'), + 'scene/form/calculate-total': () => import('./scene/form/calculate-total.snippet'), + 'scene/form/conditional-required': () => import('./scene/form/conditional-required.snippet'), + 'scene/form/cascade-select': () => import('./scene/form/cascade-select.snippet'), + 'scene/form/toggle-multiple-fields': () => import('./scene/form/toggle-multiple-fields.snippet'), + 'scene/form/copy-field-values': () => import('./scene/form/copy-field-values.snippet'), + // scene/table + 'scene/table/cell-open-dialog': () => import('./scene/table/cell-open-dialog.snippet'), + 'scene/table/concat-fields': () => import('./scene/table/concat-fields.snippet'), + 'scene/table/collection-selected-count': () => import('./scene/table/collection-selected-count.snippet'), + 'scene/table/iterate-selected-rows': () => import('./scene/table/iterate-selected-rows.snippet'), + 'scene/table/destroy-selected': () => import('./scene/table/destroy-selected.snippet'), + 'scene/table/export-selected-json': () => import('./scene/table/export-selected-json.snippet'), }; -export default engineSnippets; +export default snippets; + +// Cohesive snippet helpers for clients (editor, etc.) +type EngineSnippetEntry = { + name: string; + prefix?: string; + description?: string; + body: string; + ref: string; + group?: string; + groups?: string[]; + scenes?: string[]; +}; + +function deriveNameFromKey(key: string): string { + const parts = key.split('/'); + return parts[parts.length - 1] || key; +} + +function normalizeScenes(def: any, key: string): string[] { + if (Array.isArray(def?.scenes) && def.scenes.length) { + return def.scenes.map((scene: any) => String(scene).trim()).filter((scene: string) => scene.length > 0); + } + const parts = key.split('/'); + if (parts[0] === 'scene' && parts[1]) { + return [parts[1]]; + } + return []; +} + +function computeGroups(def: any, key: string): string[] { + const scenes = normalizeScenes(def, key); + if (scenes.length) { + return scenes.map((scene) => `scene/${scene}`); + } + const parts = key.split('/'); + if (!parts.length) return []; + const first = parts[0]; + if (first === 'global' || first === 'libs') return [first]; + if (first === 'scene' && parts.length >= 2) return [`${first}/${parts[1]}`]; + if (parts.length >= 2) return [`${parts[0]}/${parts[1]}`]; + return [parts[0]]; +} + +function resolveLocaleMeta(def: any, locale?: string) { + if (!locale || !def?.locales) return {}; + const exact = def.locales[locale]; + if (exact) return exact; + const normalized = locale.toLowerCase(); + if (normalized !== locale && def.locales[normalized]) return def.locales[normalized]; + const base = locale.split('-')[0]; + if (base && def.locales[base]) return def.locales[base]; + if (base) { + const lowerBase = base.toLowerCase(); + if (def.locales[lowerBase]) return def.locales[lowerBase]; + } + return {}; +} + +export async function getSnippetBody(ref: string): Promise { + const loader = (snippets as any)[ref]; + if (!loader) throw new Error(`[flow-engine] snippet not found: ${ref}`); + const mod = await loader(); + const def = mod?.default; + // engine snippet modules export a SnippetModule as default + const content = def?.content ?? mod?.content ?? mod?.body ?? ''; + return typeof content === 'string' ? content : String(content ?? ''); +} + +export async function listSnippetsForContext( + ctxClassName: string, + version = 'v1', + locale?: string, +): Promise { + const entries: EngineSnippetEntry[] = []; + const allowedContextNames = new Set(); + if (typeof ctxClassName === 'string' && ctxClassName) allowedContextNames.add(ctxClassName); + try { + const resolvedCtor = RunJSContextRegistry['resolve'](version as any, ctxClassName); + if (resolvedCtor?.name) allowedContextNames.add(resolvedCtor.name); + } catch (_) { + // ignore resolution failure + } + await Promise.all( + Object.entries(snippets).map(async ([key, loader]) => { + const mod = await (loader as any)(); + const def = mod?.default || {}; + const body: any = def?.content ?? mod?.content; + if (typeof body !== 'string') return; + let ok = true; + if (Array.isArray(def?.contexts) && def.contexts.length) { + const ctxNames = def.contexts.map((item: any) => { + if (item === '*') return '*'; + if (typeof item === 'string') return item; + if (typeof item === 'function') return item.name || '*'; + if (item && typeof item === 'object' && typeof item.name === 'string') return item.name; + return String(item ?? ''); + }); + if (ctxClassName === '*') { + // '*' means return all snippets without filtering by context + ok = true; + } else { + ok = ctxNames.includes('*') || ctxNames.some((name: string) => allowedContextNames.has(name)); + } + } + if (ok && Array.isArray(def?.versions) && def.versions.length) { + ok = def.versions.includes('*') || def.versions.includes(version); + } + if (!ok) return; + const localeMeta = resolveLocaleMeta(def, locale); + const name = localeMeta.label || def?.label || deriveNameFromKey(key); + const description = localeMeta.description ?? def?.description; + const prefix = def?.prefix || name; + const groups = computeGroups(def, key); + const scenes = normalizeScenes(def, key); + entries.push({ + name, + prefix, + description, + body, + ref: key, + group: groups[0], + groups, + scenes, + }); + }), + ); + return entries; +} diff --git a/packages/core/flow-engine/src/runjs-context/snippets/libs/echarts-init.snippet.ts b/packages/core/flow-engine/src/runjs-context/snippets/libs/echarts-init.snippet.ts deleted file mode 100644 index a814fc3de0a..00000000000 --- a/packages/core/flow-engine/src/runjs-context/snippets/libs/echarts-init.snippet.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * This file is part of the NocoBase (R) project. - * Copyright (c) 2020-2024 NocoBase Co., Ltd. - * Authors: NocoBase Team. - * - * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. - * For more information, please refer to: https://www.nocobase.com/agreement. - */ - -export default { - contexts: ['JSBlockRunJSContext'], - prefix: 'sn-echarts', - label: 'Init ECharts', - content: ` -ctx.element.style.height = '400px'; -const echarts = await ctx.requireAsync('https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js'); -if (!echarts) { - ctx.message.error(ctx.t('Failed to load ECharts')); -} else { - const chart = echarts.init(ctx.element); - chart.setOption({ title: { text: ctx.t('ECharts') }, series: [{ type: 'pie', data: [{ value: 1, name: ctx.t('A') }] }] }); -} -`, -}; diff --git a/packages/core/flow-engine/src/runjs-context/snippets/scene/actions/record-id-message.snippet.ts b/packages/core/flow-engine/src/runjs-context/snippets/scene/actions/record-id-message.snippet.ts deleted file mode 100644 index ba0f384bc2d..00000000000 --- a/packages/core/flow-engine/src/runjs-context/snippets/scene/actions/record-id-message.snippet.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * This file is part of the NocoBase (R) project. - * Copyright (c) 2020-2024 NocoBase Co., Ltd. - * Authors: NocoBase Team. - * - * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. - * For more information, please refer to: https://www.nocobase.com/agreement. - */ - -export default { - contexts: ['JSRecordActionRunJSContext'], - prefix: 'sn-act-record-id', - label: 'Show record id', - content: ` -if (!ctx.record) { - ctx.message.error(ctx.t('Record not found')); -} else { - ctx.message.success(ctx.t('Record ID: {{id}}', { id: ctx.filterByTk ?? ctx.record?.id })); -} -`, -}; diff --git a/packages/core/flow-engine/src/runjs-context/snippets/scene/actions/run-action-basic.snippet.ts b/packages/core/flow-engine/src/runjs-context/snippets/scene/actions/run-action-basic.snippet.ts deleted file mode 100644 index 5cf173d9d7d..00000000000 --- a/packages/core/flow-engine/src/runjs-context/snippets/scene/actions/run-action-basic.snippet.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * This file is part of the NocoBase (R) project. - * Copyright (c) 2020-2024 NocoBase Co., Ltd. - * Authors: NocoBase Team. - * - * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. - * For more information, please refer to: https://www.nocobase.com/agreement. - */ - -export default { - contexts: ['JSRecordActionRunJSContext', 'JSCollectionActionRunJSContext'], - prefix: 'sn-act-run', - label: 'Run action', - content: ` -await ctx.runAction('someAction', { foo: 'bar' }); -ctx.message.success(ctx.t('Action executed')); -`, -}; diff --git a/packages/core/flow-engine/src/runjs-context/snippets/scene/jsblock/add-event-listener.snippet.ts b/packages/core/flow-engine/src/runjs-context/snippets/scene/block/add-event-listener.snippet.ts similarity index 62% rename from packages/core/flow-engine/src/runjs-context/snippets/scene/jsblock/add-event-listener.snippet.ts rename to packages/core/flow-engine/src/runjs-context/snippets/scene/block/add-event-listener.snippet.ts index 185f676b1c0..479d9f23b89 100644 --- a/packages/core/flow-engine/src/runjs-context/snippets/scene/jsblock/add-event-listener.snippet.ts +++ b/packages/core/flow-engine/src/runjs-context/snippets/scene/block/add-event-listener.snippet.ts @@ -7,10 +7,20 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -export default { - contexts: ['JSBlockRunJSContext'], +import type { SnippetModule } from '../../types'; +import { JSBlockRunJSContext } from '../../../contexts/JSBlockRunJSContext'; + +const snippet: SnippetModule = { + contexts: [JSBlockRunJSContext], prefix: 'sn-jsb-click', label: 'Add click listener', + description: 'Render a button and bind a click event handler', + locales: { + 'zh-CN': { + label: '添加点击监听', + description: '渲染按钮并绑定点击事件处理', + }, + }, content: ` // Render a button and bind a click handler @@ -27,3 +37,5 @@ if (btn) { } `, }; + +export default snippet; diff --git a/packages/core/flow-engine/src/runjs-context/snippets/scene/block/api-fetch-render-list.snippet.ts b/packages/core/flow-engine/src/runjs-context/snippets/scene/block/api-fetch-render-list.snippet.ts new file mode 100644 index 00000000000..35088e322c3 --- /dev/null +++ b/packages/core/flow-engine/src/runjs-context/snippets/scene/block/api-fetch-render-list.snippet.ts @@ -0,0 +1,45 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import type { SnippetModule } from '../../types'; +import { JSBlockRunJSContext } from '../../../contexts/JSBlockRunJSContext'; + +const snippet: SnippetModule = { + contexts: [JSBlockRunJSContext], + prefix: 'sn-jsb-fetch-list', + label: 'Fetch & render list', + description: 'Fetch a small list via ctx.api and render basic HTML', + locales: { + 'zh-CN': { + label: '拉取并渲染列表', + description: '使用 ctx.api 拉取少量数据,并渲染基础 HTML 列表', + }, + }, + content: ` +// Fetch users +const { data } = await ctx.api.request({ + url: 'users:list', + method: 'get', + params: { pageSize: 5 }, +}); +const rows = Array.isArray(data?.data) ? data.data : (Array.isArray(data) ? data : []); + +// Render as a simple HTML list +ctx.element.innerHTML = [ + '
', + '

' + ctx.t('Users') + '

', + '
    ', + ...rows.map((r, i) => '
  • #' + (i + 1) + ': ' + String((r && (r.nickname ?? r.username ?? r.id)) ?? '') + '
  • '), + '
', + '
' +].join(''); +`, +}; + +export default snippet; diff --git a/packages/core/flow-engine/src/runjs-context/snippets/scene/block/chartjs-bar.snippet.ts b/packages/core/flow-engine/src/runjs-context/snippets/scene/block/chartjs-bar.snippet.ts new file mode 100644 index 00000000000..fa37e3165ea --- /dev/null +++ b/packages/core/flow-engine/src/runjs-context/snippets/scene/block/chartjs-bar.snippet.ts @@ -0,0 +1,80 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import type { SnippetModule } from '../../types'; +import { JSBlockRunJSContext } from '../../../contexts/JSBlockRunJSContext'; + +const snippet: SnippetModule = { + contexts: [JSBlockRunJSContext], + prefix: 'sn-jsb-chartjs', + label: 'Render Chart.js bar chart', + description: 'Load Chart.js from CDN and render a basic bar chart inside the block', + locales: { + 'zh-CN': { + label: '渲染 Chart.js 柱状图', + description: '通过 CDN 引入 Chart.js 并在区块中渲染基础柱状图', + }, + }, + content: ` +const wrapper = document.createElement('div'); +wrapper.style.padding = '16px'; +wrapper.style.background = '#fff'; +wrapper.style.borderRadius = '8px'; +wrapper.style.boxShadow = '0 2px 8px rgba(0,0,0,0.05)'; + +const canvas = document.createElement('canvas'); +canvas.width = 480; +canvas.height = 320; +wrapper.appendChild(canvas); +ctx.element.replaceChildren(wrapper); + +async function renderChart() { + const loaded = await ctx.requireAsync('https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js'); + const Chart = loaded?.Chart || loaded?.default?.Chart || loaded?.default; + if (!Chart) { + throw new Error('Chart.js is not available'); + } + + const labels = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri']; + const data = [12, 18, 9, 15, 22]; + + new Chart(canvas.getContext('2d'), { + type: 'bar', + data: { + labels, + datasets: [ + { + label: ctx.t('Daily visits'), + data, + backgroundColor: 'rgba(24, 144, 255, 0.6)', + borderColor: '#1890ff', + borderWidth: 1, + }, + ], + }, + options: { + plugins: { + legend: { display: true }, + title: { + display: true, + text: ctx.t('Weekly overview'), + }, + }, + }, + }); +} + +renderChart().catch((error) => { + console.error('[RunJS] failed to render chart', error); + wrapper.innerHTML = '
' + (error?.message || ctx.t('Chart initialization failed')) + '
'; +}); +`, +}; + +export default snippet; diff --git a/packages/core/flow-engine/src/runjs-context/snippets/scene/block/echarts-init.snippet.ts b/packages/core/flow-engine/src/runjs-context/snippets/scene/block/echarts-init.snippet.ts new file mode 100644 index 00000000000..37d0dc02236 --- /dev/null +++ b/packages/core/flow-engine/src/runjs-context/snippets/scene/block/echarts-init.snippet.ts @@ -0,0 +1,44 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { JSBlockRunJSContext } from '../../../contexts/JSBlockRunJSContext'; +import { SnippetModule } from '../../types'; + +const snippet: SnippetModule = { + contexts: [JSBlockRunJSContext], + prefix: 'sn-echarts', + label: 'Init ECharts', + description: 'Load ECharts and render a simple chart inside the block', + locales: { + 'zh-CN': { + label: '初始化 ECharts', + description: '加载 ECharts 并在区块内渲染示例图表', + }, + }, + content: ` +const container = document.createElement('div'); +container.style.height = '400px'; +container.style.width = '100%'; +ctx.element.replaceChildren(container); +const echarts = await ctx.requireAsync('https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js'); +if (!echarts) { + throw new Error('ECharts library not loaded'); +} + +const chart = echarts.init(container); +chart.setOption({ + title: { text: ctx.t('ECharts') }, + series: [{ type: 'pie', data: [{ value: 1, name: ctx.t('A') }] }], +}); + +chart.resize(); +`, +}; + +export default snippet; diff --git a/packages/core/flow-engine/src/runjs-context/snippets/scene/jsblock/render-button-handler.snippet.ts b/packages/core/flow-engine/src/runjs-context/snippets/scene/block/render-button-handler.snippet.ts similarity index 53% rename from packages/core/flow-engine/src/runjs-context/snippets/scene/jsblock/render-button-handler.snippet.ts rename to packages/core/flow-engine/src/runjs-context/snippets/scene/block/render-button-handler.snippet.ts index ac28902c3eb..d6c359157bf 100644 --- a/packages/core/flow-engine/src/runjs-context/snippets/scene/jsblock/render-button-handler.snippet.ts +++ b/packages/core/flow-engine/src/runjs-context/snippets/scene/block/render-button-handler.snippet.ts @@ -7,18 +7,27 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -export default { - contexts: ['JSBlockRunJSContext'], +import type { SnippetModule } from '../../types'; +import { JSBlockRunJSContext } from '../../../contexts/JSBlockRunJSContext'; + +const snippet: SnippetModule = { + contexts: [JSBlockRunJSContext], prefix: 'sn-jsb-button', label: 'Render button handler', + description: 'Render a button and handle click events inside the block', + locales: { + 'zh-CN': { + label: '按钮事件处理', + description: '在区块中渲染按钮并绑定点击处理逻辑', + }, + }, content: ` const { React, ReactDOM, antd } = ctx; const { Button } = antd; -if (ctx.__reactRoot?.unmount) { try { ctx.__reactRoot.unmount(); } catch(_) {} ctx.__reactRoot = undefined; } const node = React.createElement(Button, { type: 'primary', onClick: () => ctx.message.success(ctx.t('Clicked!')) }, ctx.t('Button')); -const root = ReactDOM.createRoot(ctx.element); -root.render(node); -ctx.__reactRoot = root; +ReactDOM.createRoot(ctx.element).render(node); `, }; + +export default snippet; diff --git a/packages/core/flow-engine/src/runjs-context/snippets/scene/block/render-iframe.snippet.ts b/packages/core/flow-engine/src/runjs-context/snippets/scene/block/render-iframe.snippet.ts new file mode 100644 index 00000000000..82834e32275 --- /dev/null +++ b/packages/core/flow-engine/src/runjs-context/snippets/scene/block/render-iframe.snippet.ts @@ -0,0 +1,38 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import type { SnippetModule } from '../../types'; +import { JSBlockRunJSContext } from '../../../contexts/JSBlockRunJSContext'; + +const snippet: SnippetModule = { + contexts: [JSBlockRunJSContext], + prefix: 'sn-jsb-iframe', + label: 'Render iframe', + description: 'Embed example.com as a sandboxed iframe inside the block element', + locales: { + 'zh-CN': { + label: '渲染 iframe', + description: '在区块中以 sandbox 限制嵌入 example.com 页面', + }, + }, + content: ` +// Create an iframe that fills the current block container +const iframe = document.createElement('iframe'); +iframe.src = 'https://example.com'; +iframe.setAttribute('sandbox', 'allow-scripts'); +iframe.style.width = '100%'; +iframe.style.height = '100%'; +iframe.style.border = 'none'; + +// Replace existing children so the iframe is the only content +ctx.element.replaceChildren(iframe); +`, +}; + +export default snippet; diff --git a/packages/core/flow-engine/src/runjs-context/snippets/scene/block/render-info-card.snippet.ts b/packages/core/flow-engine/src/runjs-context/snippets/scene/block/render-info-card.snippet.ts new file mode 100644 index 00000000000..bbedd6d8b39 --- /dev/null +++ b/packages/core/flow-engine/src/runjs-context/snippets/scene/block/render-info-card.snippet.ts @@ -0,0 +1,53 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import type { SnippetModule } from '../../types'; +import { JSBlockRunJSContext } from '../../../contexts/JSBlockRunJSContext'; + +const snippet: SnippetModule = { + contexts: [JSBlockRunJSContext], + prefix: 'sn-jsb-info-card', + label: 'Render record info card', + description: 'Display current record information in an Ant Design card', + locales: { + 'zh-CN': { + label: '渲染记录信息卡片', + description: '使用 Ant Design 卡片显示当前记录的关键信息', + }, + }, + content: ` +const { Card, Descriptions, Tag, Typography } = ctx.antd; +const { createElement: h } = ctx.React; + +if (!ctx.record) { + ctx.element.innerHTML = '
' + ctx.t('No record data') + '
'; + return; +} + +const record = ctx.record; + +ctx.ReactDOM.createRoot(ctx.element).render( + h(Card, { title: ctx.t('Record Details'), bordered: true, style: { margin: 0 } }, + h(Descriptions, { column: 2, size: 'small' }, + h(Descriptions.Item, { label: ctx.t('ID') }, record.id || '-'), + h(Descriptions.Item, { label: ctx.t('Status') }, + h(Tag, { color: record.status === 'active' ? 'green' : 'default' }, record.status || '-') + ), + h(Descriptions.Item, { label: ctx.t('Title') }, record.title || '-'), + h(Descriptions.Item, { label: ctx.t('Created At') }, + record.createdAt ? new Date(record.createdAt).toLocaleString() : '-' + ) + ) + ) +); + +`, +}; + +export default snippet; diff --git a/packages/core/flow-engine/src/runjs-context/snippets/scene/jsblock/render-react.snippet.ts b/packages/core/flow-engine/src/runjs-context/snippets/scene/block/render-react.snippet.ts similarity index 59% rename from packages/core/flow-engine/src/runjs-context/snippets/scene/jsblock/render-react.snippet.ts rename to packages/core/flow-engine/src/runjs-context/snippets/scene/block/render-react.snippet.ts index 8638c55011c..be52be0c5e1 100644 --- a/packages/core/flow-engine/src/runjs-context/snippets/scene/jsblock/render-react.snippet.ts +++ b/packages/core/flow-engine/src/runjs-context/snippets/scene/block/render-react.snippet.ts @@ -7,28 +7,32 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -export default { - contexts: ['JSBlockRunJSContext'], +import type { SnippetModule } from '../../types'; +import { JSBlockRunJSContext } from '../../../contexts/JSBlockRunJSContext'; + +const snippet: SnippetModule = { + contexts: [JSBlockRunJSContext], prefix: 'sn-jsb-react', label: 'Render React', + description: 'Render a React element inside the block container', + locales: { + 'zh-CN': { + label: '渲染 React', + description: '在区块容器中渲染 React 组件', + }, + }, content: ` // Render a React element into ctx.element via ReactDOM const { React, ReactDOM, antd } = ctx; const { Button } = antd; -// Unmount previous render to allow repeated runs -if (ctx.__reactRoot?.unmount) { - try { ctx.__reactRoot.unmount(); } catch(_) {} - ctx.__reactRoot = undefined; -} - const node = React.createElement( 'div', { style: { padding: 12 } }, React.createElement(Button, { type: 'primary', onClick: () => ctx.message.success(ctx.t('Clicked!')) }, ctx.t('Click')), ); -const root = ReactDOM.createRoot(ctx.element); -root.render(node); -ctx.__reactRoot = root; +ReactDOM.createRoot(ctx.element).render(node); `, }; + +export default snippet; diff --git a/packages/core/flow-engine/src/runjs-context/snippets/scene/block/render-statistics.snippet.ts b/packages/core/flow-engine/src/runjs-context/snippets/scene/block/render-statistics.snippet.ts new file mode 100644 index 00000000000..aa9cfb941c2 --- /dev/null +++ b/packages/core/flow-engine/src/runjs-context/snippets/scene/block/render-statistics.snippet.ts @@ -0,0 +1,81 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import type { SnippetModule } from '../../types'; +import { JSBlockRunJSContext } from '../../../contexts/JSBlockRunJSContext'; + +const snippet: SnippetModule = { + contexts: [JSBlockRunJSContext], + prefix: 'sn-jsb-stats', + label: 'Render statistics cards', + description: 'Display multiple statistic cards with numbers from API', + locales: { + 'zh-CN': { + label: '渲染统计卡片', + description: '显示多个统计数字卡片(从 API 获取数据)', + }, + }, + content: ` +const { Card, Statistic, Row, Col } = ctx.antd; +const { createElement: h } = ctx.React; + +const res = await ctx.api.request({ + url: 'users:list', + method: 'get', + params: { + pageSize: 100, + appends: ['roles'], + }, +}); + +const users = res?.data?.data || []; + +const total = users.length; +const adminCount = users.filter((user) => + Array.isArray(user?.roles) && user.roles.some((role) => role?.name === 'admin') +).length; +const withEmail = users.filter((user) => !!user?.email).length; +const distinctRoles = new Set( + users + .flatMap((user) => (Array.isArray(user?.roles) ? user.roles.map((role) => role?.name) : [])) + .filter(Boolean), +).size; + +ctx.ReactDOM.createRoot(ctx.element).render( + h(Row, { gutter: 16 }, + h(Col, { span: 6 }, + h(Card, {}, + h(Statistic, { title: ctx.t('Total users'), value: total, valueStyle: { color: '#3f8600' } }) + ) + ), + h(Col, { span: 6 }, + h(Card, {}, + h(Statistic, { title: ctx.t('Administrators'), value: adminCount, valueStyle: { color: '#1890ff' } }) + ) + ), + h(Col, { span: 6 }, + h(Card, {}, + h(Statistic, { title: ctx.t('Users with email'), value: withEmail, valueStyle: { color: '#faad14' } }) + ) + ), + h(Col, { span: 6 }, + h(Card, {}, + h(Statistic, { + title: ctx.t('Distinct roles'), + value: distinctRoles, + valueStyle: { color: '#cf1322' }, + }) + ) + ) + ) +); +`, +}; + +export default snippet; diff --git a/packages/core/flow-engine/src/runjs-context/snippets/scene/block/render-timeline.snippet.ts b/packages/core/flow-engine/src/runjs-context/snippets/scene/block/render-timeline.snippet.ts new file mode 100644 index 00000000000..3a4cfcf56fd --- /dev/null +++ b/packages/core/flow-engine/src/runjs-context/snippets/scene/block/render-timeline.snippet.ts @@ -0,0 +1,66 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import type { SnippetModule } from '../../types'; +import { JSBlockRunJSContext } from '../../../contexts/JSBlockRunJSContext'; + +const snippet: SnippetModule = { + contexts: [JSBlockRunJSContext], + prefix: 'sn-jsb-timeline', + label: 'Render timeline from records', + description: 'Display records as a timeline using Ant Design Timeline', + locales: { + 'zh-CN': { + label: '渲染时间轴', + description: '使用 Ant Design 时间轴组件显示记录历史', + }, + }, + content: ` +const { Timeline, Card } = ctx.antd; +const { createElement: h } = ctx.React; + +const res = await ctx.api.request({ + url: 'users:list', + method: 'get', + params: { + pageSize: 20, + sort: ['-createdAt'], + }, +}); + +const records = res?.data?.data || []; + +if (!records.length) { + ctx.element.innerHTML = '
' + ctx.t('No data') + '
'; + return; +} + +ctx.ReactDOM.createRoot(ctx.element).render( + h(Card, { title: ctx.t('Activity Timeline'), bordered: true }, + h(Timeline, { mode: 'left' }, + ...records.map(record => + h(Timeline.Item, { + key: record.id, + label: record.createdAt ? new Date(record.createdAt).toLocaleString() : '', + }, + h('div', {}, + h('strong', {}, record.nickname || record.username || ctx.t('Unnamed user')), + record.email + ? h('div', { style: { color: '#999', fontSize: '12px', marginTop: '4px' } }, record.email) + : null, + ) + ) + ) + ) + ) +); +`, +}; + +export default snippet; diff --git a/packages/core/flow-engine/src/runjs-context/snippets/scene/block/resource-example.snippet.ts b/packages/core/flow-engine/src/runjs-context/snippets/scene/block/resource-example.snippet.ts new file mode 100644 index 00000000000..e1847c80c14 --- /dev/null +++ b/packages/core/flow-engine/src/runjs-context/snippets/scene/block/resource-example.snippet.ts @@ -0,0 +1,46 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { JSBlockRunJSContext } from '../../../contexts/JSBlockRunJSContext'; +import type { SnippetModule } from '../../types'; + +const snippet: SnippetModule = { + contexts: [JSBlockRunJSContext], + prefix: 'sn-resource-example', + label: 'Resource example', + description: 'Create a resource via ctx.createResource and render JSON output', + locales: { + 'zh-CN': { + label: '资源示例', + description: '使用 ctx.useResource 加载数据并渲染 JSON 输出', + }, + }, + content: + ` +// Create a resource and load a single record +const resource = ctx.createResource('SingleRecordResource'); +resource.setDataSourceKey('main'); +resource.setResourceName('users'); +// Optionally set filterByTk to target a specific record: +// resource.setRequestOptions('params', { filterByTk: 1 }); +await resource.refresh(); + +ctx.element.innerHTML = ` + + '`' + + ` +
+    \${JSON.stringify(resource.getData(), null, 2)}
+  
+` + + '`' + + `; +`, +}; + +export default snippet; diff --git a/packages/core/flow-engine/src/runjs-context/snippets/scene/block/three-users-orbit.snippet.ts b/packages/core/flow-engine/src/runjs-context/snippets/scene/block/three-users-orbit.snippet.ts new file mode 100644 index 00000000000..f4d0d1e5cdc --- /dev/null +++ b/packages/core/flow-engine/src/runjs-context/snippets/scene/block/three-users-orbit.snippet.ts @@ -0,0 +1,264 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import type { SnippetModule } from '../../types'; +import { JSBlockRunJSContext } from '../../../contexts/JSBlockRunJSContext'; + +const snippet: SnippetModule = { + contexts: [JSBlockRunJSContext], + prefix: 'sn-three-users-orbit', + label: 'Users orbit (three.js)', + description: 'Fetch users:list and render a rotating 3D orbit of users with hover/click', + locales: { + 'zh-CN': { + label: 'Three.js 用户轨道', + description: '从 users:list 加载用户,并以 3D 轨道方式展示(支持悬停高亮与点击提示)', + }, + }, + content: ` +// Container +const container = document.createElement('div'); +container.style.width = '100%'; +container.style.height = '360px'; +container.style.position = 'relative'; +container.style.borderRadius = '10px'; +container.style.overflow = 'hidden'; +container.style.background = 'radial-gradient(700px 300px at 20% 25%, #172036, #0b0f19 60%), radial-gradient(600px 240px at 80% 70%, rgba(56,189,248,0.12), transparent 60%)'; +ctx.element.replaceChildren(container); + +// 不做显式清理逻辑;如需存储信息,统一挂在 ctx.model 上 + +// 使用 ctx.useResource 加载 users:list(真实数据) +ctx.useResource('MultiRecordResource'); +const resource = ctx.resource; +resource.setDataSourceKey && resource.setDataSourceKey('main'); +resource.setResourceName && resource.setResourceName('users'); +resource.setPageSize && resource.setPageSize(50); +try { + await resource.refresh(); +} catch (err) { + var msg = (err && err.message) ? err.message : 'users:list 请求失败'; + ctx.element.innerHTML = '
' + msg + '
'; + throw err; +} + +// Helpers: generate avatar textures (emoji or initials) +function makeAvatarTexture(user, idx) { + const canvas = document.createElement('canvas'); + const size = 128; + canvas.width = canvas.height = size; + const g = canvas.getContext('2d'); + // background circle with subtle gradient + const palettes = ['#60a5fa','#34d399','#fbbf24','#f472b6','#a78bfa','#f87171','#22d3ee']; + const c1 = palettes[idx % palettes.length]; + const grad = g.createRadialGradient(size*0.35, size*0.35, 10, size*0.5, size*0.5, size*0.6); + grad.addColorStop(0, c1); + grad.addColorStop(1, '#111827'); + g.fillStyle = grad; + g.beginPath(); + g.arc(size/2, size/2, size*0.48, 0, Math.PI*2); + g.fill(); + // border + g.lineWidth = 4; g.strokeStyle = 'rgba(255,255,255,0.25)'; + g.stroke(); + // content: emoji if possible, else initials + const emojis = ['😀','😎','🚀','🌟','🎉','🧠','🐼','🦊','🐯','🦄','🍀','🍕','⚡️','🔥','❤️']; + const text = emojis[idx % emojis.length]; + g.textAlign = 'center'; + g.textBaseline = 'middle'; + g.font = 'bold 68px system-ui, -apple-system, Segoe UI Emoji, Noto Color Emoji, Apple Color Emoji'; + g.fillStyle = '#fff'; + g.fillText(text, size/2, size/2 + 4); + const tex = new THREE.CanvasTexture(canvas); + tex.needsUpdate = true; + return tex; +} + +const THREE = await ctx.importAsync('https://esm.sh/three@0.160.0'); +const { Scene, PerspectiveCamera, WebGLRenderer, Color, AmbientLight, DirectionalLight, Group, Mesh, MeshStandardMaterial, SphereGeometry, Raycaster, Vector2 } = THREE; + +const scene = new Scene(); +scene.background = new Color(0x0b0f19); + +const camera = new PerspectiveCamera(55, container.clientWidth / container.clientHeight, 0.1, 100); +camera.position.set(0, 1.2, 6.5); + +const renderer = new WebGLRenderer({ antialias: true, alpha: false }); +renderer.setSize(container.clientWidth, container.clientHeight); +renderer.setPixelRatio(1); // sandbox-friendly +container.appendChild(renderer.domElement); + +scene.add(new AmbientLight(0xffffff, 0.8)); +const dir = new DirectionalLight(0xffffff, 0.6); +dir.position.set(2.5, 3.0, 4.0); +scene.add(dir); + +const orbit = new Group(); +scene.add(orbit); + +const users = (resource && resource.getData && Array.isArray(resource.getData())) ? resource.getData() : []; +if (!users.length) { + container.innerHTML = '
users:list 接口无数据
'; + return; +} +const N = users.length; +const R = 2.8; +// background starfield +const starCount = 500; +const starPos = new Float32Array(starCount * 3); +for (let i = 0; i < starCount; i++) { + const r = 10 * Math.pow(Math.random(), 0.7) + 4; + const th = Math.random() * Math.PI * 2; + const ph = Math.acos(2 * Math.random() - 1); + starPos[i*3+0] = r * Math.sin(ph) * Math.cos(th); + starPos[i*3+1] = r * Math.cos(ph) * 0.5; + starPos[i*3+2] = r * Math.sin(ph) * Math.sin(th); +} +const starGeo = new THREE.BufferGeometry(); +starGeo.setAttribute('position', new THREE.Float32BufferAttribute(starPos, 3)); +const starMat = new THREE.PointsMaterial({ color: 0xffffff, size: 0.02, transparent: true, opacity: 0.8 }); +const stars = new THREE.Points(starGeo, starMat); +scene.add(stars); + +const items = []; +for (let i = 0; i < N; i++) { + const u = users[i]; + const a = (i / N) * Math.PI * 2; + const y = (Math.sin(a * 2) * 0.6); + const tex = makeAvatarTexture(u, i); + const mat = new THREE.SpriteMaterial({ map: tex, transparent: true, depthWrite: false }); + const sprite = new THREE.Sprite(mat); + sprite.scale.set(0.9, 0.9, 1); + sprite.position.set(Math.cos(a) * R, y, Math.sin(a) * R); + sprite.userData.user = u; + orbit.add(sprite); + items.push(sprite); +} + +// Simple ring wireframe for context +const ring = new Group(); +scene.add(ring); +for (let k = 0; k < 64; k++) { + const a = (k / 64) * Math.PI * 2; + const seg = new Mesh(new SphereGeometry(0.01, 8, 8), new MeshStandardMaterial({ color: 0xffffff })); + seg.position.set(Math.cos(a) * R, 0, Math.sin(a) * R); + ring.add(seg); +} + +// Hover + click via Raycaster +const raycaster = new Raycaster(); +const mouse = new Vector2(); +let over = null; + +function updateMouse(e) { + const rect = container.getBoundingClientRect(); + const x = (e.clientX - rect.left) / rect.width; + const y = (e.clientY - rect.top) / rect.height; + mouse.set(x * 2 - 1, -(y * 2 - 1)); +} + +function handleHover() { + raycaster.setFromCamera(mouse, camera); + const intersects = raycaster.intersectObjects(items, false); + const hit = (intersects && intersects[0] && intersects[0].object) ? intersects[0].object : null; + if (over && over !== hit) { + over.material.opacity = 1.0; + over.scale.set(0.9, 0.9, 1); + over = null; + } + if (hit && hit !== over) { + over = hit; + over.material.opacity = 1.0; + over.scale.set(1.15, 1.15, 1); + } +} + +const onMouseMove = (e) => { updateMouse(e); handleHover(); }; +// resolve primary key field name from collection meta +let __pkField = null; +async function getPrimaryKeyField() { + if (__pkField) return __pkField; + const name = (resource && resource.getResourceName) ? resource.getResourceName() : 'users'; + try { + const meta = await ctx.api.request({ url: 'collections:get', method: 'get', params: { filterByTk: name } }); + const data = (meta && meta.data) ? meta.data : {}; + // prefer filterTargetKey, fallback to fields.primaryKey + const ft = (data && data.filterTargetKey) ? data.filterTargetKey : (data && data.options && data.options.filterTargetKey); + if (ft && typeof ft === 'string') { + __pkField = ft; + return __pkField; + } + const fields = (data && data.fields) ? data.fields : (data && data.options && data.options.fields) ? data.options.fields : []; + const pk = Array.isArray(fields) && fields.find(function(f){ return f && (f.primaryKey || (f.uiSchema && f.uiSchema['x-component'] === 'ID')); }); + if (pk && typeof pk.name === 'string') { + __pkField = pk.name; + return __pkField; + } + } catch (_) {} + __pkField = 'id'; + return __pkField; +} + +const onClick = async (e) => { + updateMouse(e); + handleHover(); + if (over && over.userData && over.userData.user) { + const u = over.userData.user; + const baseId = (ctx.model && ctx.model.uid) ? ctx.model.uid : String(ctx.model || 'runjs'); + const popupUid = baseId + '-details'; + const pkField = await getPrimaryKeyField(); + var tk = (u && u[pkField]) ? u[pkField] : (u ? (u.id || u.userId || u.pk) : undefined); + await ctx.openView(popupUid, { + mode: 'dialog', + // 让弹窗自动定位当前记录:顶层 filterByTk + 数据源/集合信息 + dataSourceKey: (resource && resource.getDataSourceKey) ? resource.getDataSourceKey() : 'main', + collectionName: (resource && resource.getResourceName) ? resource.getResourceName() : 'users', + filterByTk: tk, + params: { user: u, userId: tk }, + title: (u.nickname || u.username || u.name || 'User') + ' details', + width: 720, + }); + } +}; +container.addEventListener('mousemove', onMouseMove); +container.addEventListener('click', onClick); + +// Animation (no rAF/use setInterval) +let t = 0; +const tick = () => { + t += 0.016; + orbit.rotation.y += 0.005; // slow spin + stars.rotation.y = t * 0.02; + // subtle bobbing per item + for (let i = 0; i < items.length; i++) { + const m = items[i]; + const a = (i / items.length) * Math.PI * 2; + m.position.y = Math.sin(a * 2 + t * 1.5) * 0.6; + } + renderer.render(scene, camera); +}; +// 若上一次运行的动画仍在,清理之(仅使用 ctx.model 存储) +try { if (ctx.model && ctx.model.__usersOrbitTimer) clearInterval(ctx.model.__usersOrbitTimer); } catch (_) {} +var __timer = setInterval(tick, 16); +if (ctx.model) ctx.model.__usersOrbitTimer = __timer; + +// Initial sizing +(() => { + const w = container.clientWidth; + const h = container.clientHeight; + camera.aspect = w / h; + camera.updateProjectionMatrix(); + renderer.setSize(w, h); +})(); + +// 不额外挂载 cleanup;容器被替换后旧 DOM 将被 GC;定时器在下次运行前通过 ctx.model 清理 +`, +}; + +export default snippet; diff --git a/packages/core/flow-engine/src/runjs-context/snippets/scene/block/vue-component.snippet.ts b/packages/core/flow-engine/src/runjs-context/snippets/scene/block/vue-component.snippet.ts new file mode 100644 index 00000000000..93904a0523f --- /dev/null +++ b/packages/core/flow-engine/src/runjs-context/snippets/scene/block/vue-component.snippet.ts @@ -0,0 +1,105 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import type { SnippetModule } from '../../types'; +import { JSBlockRunJSContext } from '../../../contexts/JSBlockRunJSContext'; + +const snippet: SnippetModule = { + contexts: [JSBlockRunJSContext], + prefix: 'sn-jsb-vue', + label: 'Embed Vue component', + description: 'Use ctx.importAsync to load Vue 3 ESM build and render a reactive widget', + locales: { + 'zh-CN': { + label: '嵌入 Vue 组件', + description: '通过 ctx.importAsync 加载 Vue 3 ESM 构建并渲染交互组件', + }, + }, + content: ` +const mountNode = document.createElement('div'); +mountNode.style.padding = '16px'; +mountNode.style.background = '#fff'; +mountNode.style.borderRadius = '8px'; +const target = document.createElement('div'); +target.className = 'nb-vue-counter'; +mountNode.appendChild(target); +ctx.element.replaceChildren(mountNode); + +async function bootstrap() { + const mod = await ctx.importAsync('https://esm.sh/vue@3.4.27/dist/vue.runtime.esm-browser.js'); + const createApp = mod?.createApp; + const ref = mod?.ref; + const h = mod?.h; + if (typeof createApp !== 'function' || typeof ref !== 'function' || typeof h !== 'function') { + throw new Error('Vue ESM module not available'); + } + + const Counter = { + setup() { + const count = ref(0); + const increase = () => { + count.value += 1; + }; + const openPopup = async () => { + const popupUid = ctx.model?.uid ? ctx.model.uid + '-popup' : 'vue-popup'; + await ctx.openView(popupUid, { + mode: 'drawer', + title: ctx.t('Hello from Vue'), + params: { + fromVue: true, + triggerCount: count.value, + }, + }); + }; + return () => + h( + 'div', + { style: 'display:flex;align-items:center;gap:12px;' }, + [ + h( + 'button', + { + style: + 'padding:6px 12px;border:1px solid #fa8c16;background:#fa8c16;color:#fff;border-radius:4px;cursor:pointer;', + onClick: increase, + }, + ctx.t('Increase'), + ), + h( + 'button', + { + style: + 'padding:6px 12px;border:1px solid #1677ff;background:#1677ff;color:#fff;border-radius:4px;cursor:pointer;', + onClick: openPopup, + }, + ctx.t('Open popup'), + ), + h( + 'span', + null, + ctx.t('Current count') + ': ' + count.value, + ), + ], + ); + }, + }; + + const app = createApp(Counter); + const mountTarget = ctx.element.querySelector('.nb-vue-counter'); + app.mount(mountTarget || ctx.element); +} + +bootstrap().catch((error) => { + console.error('[RunJS] failed to mount Vue counter', error); + mountNode.innerHTML = '
' + (error?.message || ctx.t('Vue initialization failed')) + '
'; +}); +`, +}; + +export default snippet; diff --git a/packages/core/flow-engine/src/runjs-context/snippets/scene/detail/color-by-value.snippet.ts b/packages/core/flow-engine/src/runjs-context/snippets/scene/detail/color-by-value.snippet.ts new file mode 100644 index 00000000000..9aed6ab1a4c --- /dev/null +++ b/packages/core/flow-engine/src/runjs-context/snippets/scene/detail/color-by-value.snippet.ts @@ -0,0 +1,33 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import type { SnippetModule } from '../../types'; +import { JSFieldRunJSContext } from '../../../contexts/JSFieldRunJSContext'; +import { FormJSFieldItemRunJSContext } from '../../../contexts/FormJSFieldItemRunJSContext'; + +const snippet: SnippetModule = { + contexts: [JSFieldRunJSContext, FormJSFieldItemRunJSContext], + prefix: 'sn-jsf-color', + label: 'Display number field as colored text', + description: 'Display numeric values using colors based on their sign', + locales: { + 'zh-CN': { + label: '将数字字段显示为彩色文本', + description: '根据数值正负设置显示颜色', + }, + }, + content: ` +// Colorize based on numeric sign +const n = Number(ctx.value ?? 0); +const color = Number.isFinite(n) ? (n > 0 ? 'green' : n < 0 ? 'red' : '#999') : '#555'; +ctx.element.innerHTML = '' + String(ctx.value ?? '') + ''; +`, +}; + +export default snippet; diff --git a/packages/core/flow-engine/src/runjs-context/snippets/scene/detail/copy-to-clipboard.snippet.ts b/packages/core/flow-engine/src/runjs-context/snippets/scene/detail/copy-to-clipboard.snippet.ts new file mode 100644 index 00000000000..3a122f19dfd --- /dev/null +++ b/packages/core/flow-engine/src/runjs-context/snippets/scene/detail/copy-to-clipboard.snippet.ts @@ -0,0 +1,45 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import type { SnippetModule } from '../../types'; +import { JSFieldRunJSContext } from '../../../contexts/JSFieldRunJSContext'; + +const snippet: SnippetModule = { + contexts: [JSFieldRunJSContext], + prefix: 'sn-jsf-copy', + label: 'Display text field with copy button', + description: 'Render the text field value with a copy-to-clipboard button', + locales: { + 'zh-CN': { + label: '将文本字段显示为复制按钮', + description: '展示字段值并提供快捷复制到剪贴板的按钮', + }, + }, + content: ` +const text = String(ctx.value ?? ''); +ctx.element.innerHTML = '' + + ctx.t('Copy') + ''; + +ctx.element.querySelector('.nb-copy')?.addEventListener('click', async () => { + if (navigator?.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + } else { + const ta = document.createElement('textarea'); + ta.value = text; + document.body.appendChild(ta); + ta.select(); + document.execCommand('copy'); + document.body.removeChild(ta); + } + ctx.message.success(ctx.t('Copied')); +}); +`, +}; + +export default snippet; diff --git a/packages/core/flow-engine/src/runjs-context/snippets/scene/detail/format-number.snippet.ts b/packages/core/flow-engine/src/runjs-context/snippets/scene/detail/format-number.snippet.ts new file mode 100644 index 00000000000..63dfb682782 --- /dev/null +++ b/packages/core/flow-engine/src/runjs-context/snippets/scene/detail/format-number.snippet.ts @@ -0,0 +1,32 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import type { SnippetModule } from '../../types'; +import { JSFieldRunJSContext } from '../../../contexts/JSFieldRunJSContext'; +import { FormJSFieldItemRunJSContext } from '../../../contexts/FormJSFieldItemRunJSContext'; + +const snippet: SnippetModule = { + contexts: [JSFieldRunJSContext, FormJSFieldItemRunJSContext], + prefix: 'sn-jsf-num', + label: 'Display number field as localized number', + description: 'Format numeric values with locale-aware separators before rendering', + locales: { + 'zh-CN': { + label: '将数字字段显示为本地化格式', + description: '按本地化格式输出数值', + }, + }, + content: ` +// Format number using locale +const n = Number(ctx.value ?? 0); +ctx.element.innerHTML = String(Number.isFinite(n) ? n.toLocaleString() : ctx.value ?? ''); +`, +}; + +export default snippet; diff --git a/packages/core/flow-engine/src/runjs-context/snippets/scene/detail/innerHTML-value.snippet.ts b/packages/core/flow-engine/src/runjs-context/snippets/scene/detail/innerHTML-value.snippet.ts new file mode 100644 index 00000000000..078d8097a4b --- /dev/null +++ b/packages/core/flow-engine/src/runjs-context/snippets/scene/detail/innerHTML-value.snippet.ts @@ -0,0 +1,31 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import type { SnippetModule } from '../../types'; +import { JSFieldRunJSContext } from '../../../contexts/JSFieldRunJSContext'; +import { FormJSFieldItemRunJSContext } from '../../../contexts/FormJSFieldItemRunJSContext'; + +const snippet: SnippetModule = { + contexts: [JSFieldRunJSContext, FormJSFieldItemRunJSContext], + prefix: 'sn-jsf-value', + label: 'Display text field as highlighted text', + description: 'Render the current text field value with simple highlight styling', + locales: { + 'zh-CN': { + label: '将文本字段显示为高亮文本', + description: '将字段值写入容器并添加高亮样式', + }, + }, + content: ` +const v = String(ctx.value ?? ''); +ctx.element.innerHTML = \`\${v}\`; +`, +}; + +export default snippet; diff --git a/packages/core/flow-engine/src/runjs-context/snippets/scene/detail/percentage-bar.snippet.ts b/packages/core/flow-engine/src/runjs-context/snippets/scene/detail/percentage-bar.snippet.ts new file mode 100644 index 00000000000..1889f4e4b70 --- /dev/null +++ b/packages/core/flow-engine/src/runjs-context/snippets/scene/detail/percentage-bar.snippet.ts @@ -0,0 +1,63 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import type { SnippetModule } from '../../types'; +import { JSFieldRunJSContext } from '../../../contexts/JSFieldRunJSContext'; +import { FormJSFieldItemRunJSContext } from '../../../contexts/FormJSFieldItemRunJSContext'; + +const snippet: SnippetModule = { + contexts: [JSFieldRunJSContext, FormJSFieldItemRunJSContext], + prefix: 'sn-jsf-percent', + label: 'Display number field as percentage bar', + description: 'Render numeric values as a percentage progress bar', + locales: { + 'zh-CN': { + label: '将数字字段显示为百分比进度条', + description: '将数字格式化为百分比并显示进度条', + }, + }, + content: ` +const value = Number(ctx.value ?? 0); + +if (!Number.isFinite(value)) { + ctx.element.innerHTML = '-'; + return; +} + +// Ensure value is between 0 and 100 +const percent = Math.max(0, Math.min(100, value)); + +// Color based on value +const getColor = (val) => { + if (val >= 80) return '#52c41a'; + if (val >= 50) return '#faad14'; + return '#f5222d'; +}; + +const color = getColor(percent); + +ctx.element.innerHTML = \` +
+
+
+
+ + \${percent.toFixed(1)}% + +
+\`; +`, +}; + +export default snippet; diff --git a/packages/core/flow-engine/src/runjs-context/snippets/scene/detail/relative-time.snippet.ts b/packages/core/flow-engine/src/runjs-context/snippets/scene/detail/relative-time.snippet.ts new file mode 100644 index 00000000000..008e7a87fa3 --- /dev/null +++ b/packages/core/flow-engine/src/runjs-context/snippets/scene/detail/relative-time.snippet.ts @@ -0,0 +1,61 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import type { SnippetModule } from '../../types'; +import { JSFieldRunJSContext } from '../../../contexts/JSFieldRunJSContext'; +import { FormJSFieldItemRunJSContext } from '../../../contexts/FormJSFieldItemRunJSContext'; + +const snippet: SnippetModule = { + contexts: [JSFieldRunJSContext, FormJSFieldItemRunJSContext], + prefix: 'sn-jsf-relative-time', + label: 'Display date field as relative time', + description: 'Render date values as “3 days ago”, “just now”, etc.', + locales: { + 'zh-CN': { + label: '将日期字段显示为相对时间', + description: '将日期显示为“3天前”、“刚刚”等相对时间', + }, + }, + content: ` +const formatRelativeTime = (date) => { + const now = new Date(); + const diff = now - new Date(date); + const seconds = Math.floor(diff / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + const months = Math.floor(days / 30); + const years = Math.floor(days / 365); + + if (seconds < 60) return ctx.t('just now'); + if (minutes < 60) return ctx.t('{{count}} minutes ago', { count: minutes }); + if (hours < 24) return ctx.t('{{count}} hours ago', { count: hours }); + if (days < 30) return ctx.t('{{count}} days ago', { count: days }); + if (months < 12) return ctx.t('{{count}} months ago', { count: months }); + return ctx.t('{{count}} years ago', { count: years }); +}; + +const dateStr = ctx.value; +if (!dateStr) { + ctx.element.innerHTML = '-'; + return; +} + +const relativeTime = formatRelativeTime(dateStr); +const fullDate = new Date(dateStr).toLocaleString(); + +ctx.element.innerHTML = \` + + \${relativeTime} + +\`; +`, +}; + +export default snippet; diff --git a/packages/core/flow-engine/src/runjs-context/snippets/scene/detail/status-tag.snippet.ts b/packages/core/flow-engine/src/runjs-context/snippets/scene/detail/status-tag.snippet.ts new file mode 100644 index 00000000000..cfd5c923cac --- /dev/null +++ b/packages/core/flow-engine/src/runjs-context/snippets/scene/detail/status-tag.snippet.ts @@ -0,0 +1,55 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import type { SnippetModule } from '../../types'; +import { JSFieldRunJSContext } from '../../../contexts/JSFieldRunJSContext'; +import { FormJSFieldItemRunJSContext } from '../../../contexts/FormJSFieldItemRunJSContext'; +import { JSColumnRunJSContext } from '../../../contexts/JSColumnRunJSContext'; + +const snippet: SnippetModule = { + contexts: [JSFieldRunJSContext, FormJSFieldItemRunJSContext, JSColumnRunJSContext], + scenes: ['detail', 'table'], + prefix: 'sn-jsf-status-tag', + label: 'Display status field as colored tag', + description: 'Display status values using colored tags', + locales: { + 'zh-CN': { + label: '将状态字段显示为彩色标签', + description: '根据状态值显示不同颜色的标签', + }, + }, + content: ` +const statusColors = { + active: 'green', + pending: 'orange', + inactive: 'gray', + error: 'red', + success: 'blue', +}; + +const status = String(ctx.value || 'unknown'); +const color = statusColors[status] || 'default'; + +ctx.element.innerHTML = \` + + \${ctx.t(status)} + +\`; +`, +}; + +export default snippet; diff --git a/packages/core/flow-engine/src/runjs-context/snippets/scene/form/calculate-total.snippet.ts b/packages/core/flow-engine/src/runjs-context/snippets/scene/form/calculate-total.snippet.ts new file mode 100644 index 00000000000..2aeae59ce3d --- /dev/null +++ b/packages/core/flow-engine/src/runjs-context/snippets/scene/form/calculate-total.snippet.ts @@ -0,0 +1,44 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import type { SnippetModule } from '../../types'; +import { JSItemRunJSContext } from '../../../contexts/JSItemRunJSContext'; + +const snippet: SnippetModule = { + contexts: [JSItemRunJSContext], + prefix: 'sn-link-calc', + label: 'Calculate total price (quantity × price)', + description: 'Automatically calculate total when quantity or unit price changes', + locales: { + 'zh-CN': { + label: '计算总价(数量 × 单价)', + description: '当数量或单价变化时自动计算总价', + }, + }, + content: ` +// Get quantity and unit price from current record +const quantity = Number(ctx.record?.quantity) || 0; +const unitPrice = Number(ctx.record?.unitPrice) || 0; +const total = quantity * unitPrice; + +// Find and update the 'totalPrice' field +const items = ctx.model?.subModels?.grid?.subModels?.items; +const candidates = Array.isArray(items) ? items : Array.from(items?.values?.() || items || []); + +const totalField = candidates.find((item) => item?.props?.name === 'totalPrice'); + +if (totalField) { + totalField.setProps({ value: total.toFixed(2) }); +} else { + console.warn('[Form snippet] totalPrice field not found'); +} +`, +}; + +export default snippet; diff --git a/packages/core/flow-engine/src/runjs-context/snippets/scene/form/cascade-select.snippet.ts b/packages/core/flow-engine/src/runjs-context/snippets/scene/form/cascade-select.snippet.ts new file mode 100644 index 00000000000..acd0c9a3ee1 --- /dev/null +++ b/packages/core/flow-engine/src/runjs-context/snippets/scene/form/cascade-select.snippet.ts @@ -0,0 +1,62 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import type { SnippetModule } from '../../types'; +import { JSItemRunJSContext } from '../../../contexts/JSItemRunJSContext'; + +const snippet: SnippetModule = { + contexts: [JSItemRunJSContext], + prefix: 'sn-link-cascade', + label: 'Cascade select (load child roles)', + description: 'Load child roles based on the selected parent role', + locales: { + 'zh-CN': { + label: '级联选择(加载子角色)', + description: '根据选择的父角色加载对应子角色', + }, + }, + content: ` +// Get selected parent role (adjust field name to match your form) +const parentRoleId = ctx.record?.parentRole?.id; + +if (!parentRoleId) { + return; +} + +const res = await ctx.api.request({ + url: 'roles:list', + method: 'get', + params: { + pageSize: 100, + filter: { + parentId: parentRoleId, + }, + }, +}); + +const childRoles = res?.data?.data || []; + +const items = ctx.model?.subModels?.grid?.subModels?.items; +const candidates = Array.isArray(items) ? items : Array.from(items?.values?.() || items || []); + +const roleField = candidates.find((item) => item?.props?.name === 'role'); + +if (roleField) { + roleField.setProps({ + dataSource: childRoles.map((role) => ({ + value: role.id, + label: role.name, + })), + value: undefined, + }); +} +`, +}; + +export default snippet; diff --git a/packages/core/flow-engine/src/runjs-context/snippets/scene/form/conditional-required.snippet.ts b/packages/core/flow-engine/src/runjs-context/snippets/scene/form/conditional-required.snippet.ts new file mode 100644 index 00000000000..43c0d71bf45 --- /dev/null +++ b/packages/core/flow-engine/src/runjs-context/snippets/scene/form/conditional-required.snippet.ts @@ -0,0 +1,45 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import type { SnippetModule } from '../../types'; +import { JSItemRunJSContext } from '../../../contexts/JSItemRunJSContext'; + +const snippet: SnippetModule = { + contexts: [JSItemRunJSContext], + prefix: 'sn-link-require', + label: 'Conditional required field', + description: "Make a field required based on another field's value", + locales: { + 'zh-CN': { + label: '条件必填', + description: '根据另一个字段的值动态设置必填状态', + }, + }, + content: ` +// When 'needsApproval' is true, make 'approver' field required +const needsApproval = ctx.record?.needsApproval; + +const items = ctx.model?.subModels?.grid?.subModels?.items; +const candidates = Array.isArray(items) ? items : Array.from(items?.values?.() || items || []); + +const approverField = candidates.find((item) => item?.props?.name === 'approver'); + +if (approverField) { + approverField.setProps({ + required: !!needsApproval, + // Also toggle visibility if needed + // display: needsApproval ? 'visible' : 'hidden', + }); +} else { + console.warn('[Form snippet] approver field not found'); +} +`, +}; + +export default snippet; diff --git a/packages/core/flow-engine/src/runjs-context/snippets/scene/form/copy-field-values.snippet.ts b/packages/core/flow-engine/src/runjs-context/snippets/scene/form/copy-field-values.snippet.ts new file mode 100644 index 00000000000..517a12ef317 --- /dev/null +++ b/packages/core/flow-engine/src/runjs-context/snippets/scene/form/copy-field-values.snippet.ts @@ -0,0 +1,55 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import type { SnippetModule } from '../../types'; +import { JSItemRunJSContext } from '../../../contexts/JSItemRunJSContext'; + +const snippet: SnippetModule = { + contexts: [JSItemRunJSContext], + prefix: 'sn-link-copy', + label: 'Copy value from another field', + description: 'Copy value from one field to another when checkbox is checked', + locales: { + 'zh-CN': { + label: '复制字段值', + description: '勾选复选框时,将一个字段的值复制到另一个字段', + }, + }, + content: ` +// When 'sameAsAbove' is checked, copy billing address to shipping address +const sameAsAbove = ctx.record?.sameAsAbove; + +if (!sameAsAbove) { + return; +} + +const items = ctx.model?.subModels?.grid?.subModels?.items; +const candidates = Array.isArray(items) ? items : Array.from(items?.values?.() || items || []); + +// Source and target field mappings +const fieldMappings = [ + { from: 'billingAddress', to: 'shippingAddress' }, + { from: 'billingCity', to: 'shippingCity' }, + { from: 'billingZipCode', to: 'shippingZipCode' }, +]; + +fieldMappings.forEach(({ from, to }) => { + const sourceValue = ctx.record?.[from]; + const targetField = candidates.find((item) => item?.props?.name === to); + + if (targetField) { + targetField.setProps({ value: sourceValue }); + } +}); + +ctx.message?.success?.(ctx.t('Address copied successfully')); +`, +}; + +export default snippet; diff --git a/packages/core/flow-engine/src/runjs-context/snippets/scene/jsitem/render-basic.snippet.ts b/packages/core/flow-engine/src/runjs-context/snippets/scene/form/render-basic.snippet.ts similarity index 64% rename from packages/core/flow-engine/src/runjs-context/snippets/scene/jsitem/render-basic.snippet.ts rename to packages/core/flow-engine/src/runjs-context/snippets/scene/form/render-basic.snippet.ts index 27d260f22de..42e7f9a0095 100644 --- a/packages/core/flow-engine/src/runjs-context/snippets/scene/jsitem/render-basic.snippet.ts +++ b/packages/core/flow-engine/src/runjs-context/snippets/scene/form/render-basic.snippet.ts @@ -7,10 +7,20 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -export default { - contexts: ['JSItemRunJSContext'], +import type { SnippetModule } from '../../types'; +import { JSItemRunJSContext } from '../../../contexts/JSItemRunJSContext'; + +const snippet: SnippetModule = { + contexts: [JSItemRunJSContext], prefix: 'sn-jsitem-basic', label: 'Render form item', + description: 'Render custom content inside a form item container', + locales: { + 'zh-CN': { + label: '渲染表单项', + description: '在表单项容器中渲染自定义内容', + }, + }, content: ` ctx.element.innerHTML = ` + @@ -25,3 +35,5 @@ ctx.element.innerHTML = ` + `; `, }; + +export default snippet; diff --git a/packages/core/flow-engine/src/runjs-context/snippets/scene/linkage/set-disabled.snippet.ts b/packages/core/flow-engine/src/runjs-context/snippets/scene/form/set-disabled.snippet.ts similarity index 68% rename from packages/core/flow-engine/src/runjs-context/snippets/scene/linkage/set-disabled.snippet.ts rename to packages/core/flow-engine/src/runjs-context/snippets/scene/form/set-disabled.snippet.ts index 94783cb1a52..6877c8c81c4 100644 --- a/packages/core/flow-engine/src/runjs-context/snippets/scene/linkage/set-disabled.snippet.ts +++ b/packages/core/flow-engine/src/runjs-context/snippets/scene/form/set-disabled.snippet.ts @@ -7,16 +7,26 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -export default { - contexts: ['*'], +import type { SnippetModule } from '../../types'; +import { JSItemRunJSContext } from '../../../contexts/JSItemRunJSContext'; + +const snippet: SnippetModule = { + contexts: [JSItemRunJSContext], prefix: 'sn-link-disable', label: 'Set disabled', + description: 'Enable or disable another field in linkage scripts', + locales: { + 'zh-CN': { + label: '设置禁用', + description: '在联动脚本中启用或禁用字段', + }, + }, content: ` const targetFieldUid = 'FIELD_UID_OR_NAME'; const disabled = true; const items = ctx.model?.subModels?.grid?.subModels?.items; -const candidates: any[] = Array.isArray(items) +const candidates = Array.isArray(items) ? items : Array.from(items?.values?.() || items || []); const fieldModel = @@ -36,3 +46,5 @@ ctx.message?.success?.( ); `, }; + +export default snippet; diff --git a/packages/core/flow-engine/src/runjs-context/snippets/scene/linkage/set-field-value.snippet.ts b/packages/core/flow-engine/src/runjs-context/snippets/scene/form/set-field-value.snippet.ts similarity index 69% rename from packages/core/flow-engine/src/runjs-context/snippets/scene/linkage/set-field-value.snippet.ts rename to packages/core/flow-engine/src/runjs-context/snippets/scene/form/set-field-value.snippet.ts index 0476f91217a..602448ffcad 100644 --- a/packages/core/flow-engine/src/runjs-context/snippets/scene/linkage/set-field-value.snippet.ts +++ b/packages/core/flow-engine/src/runjs-context/snippets/scene/form/set-field-value.snippet.ts @@ -7,17 +7,27 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -export default { - contexts: ['*'], +import type { SnippetModule } from '../../types'; +import { JSItemRunJSContext } from '../../../contexts/JSItemRunJSContext'; + +const snippet: SnippetModule = { + contexts: [JSItemRunJSContext], prefix: 'sn-link-set', label: 'Set field value', + description: 'Programmatically update another field in linkage scripts', + locales: { + 'zh-CN': { + label: '设置字段值', + description: '在联动脚本中为其他字段设置值', + }, + }, content: ` // Update another field in the same form/block const targetFieldUid = 'FIELD_UID_OR_NAME'; const nextValue = ctx.record?.status ?? ctx.t('Updated value'); const items = ctx.model?.subModels?.grid?.subModels?.items; -const candidates: any[] = Array.isArray(items) +const candidates = Array.isArray(items) ? items : Array.from(items?.values?.() || items || []); const fieldModel = @@ -35,3 +45,5 @@ ctx.message?.success?.( ); `, }; + +export default snippet; diff --git a/packages/core/flow-engine/src/runjs-context/snippets/scene/linkage/set-required.snippet.ts b/packages/core/flow-engine/src/runjs-context/snippets/scene/form/set-required.snippet.ts similarity index 68% rename from packages/core/flow-engine/src/runjs-context/snippets/scene/linkage/set-required.snippet.ts rename to packages/core/flow-engine/src/runjs-context/snippets/scene/form/set-required.snippet.ts index 75be74e4623..802089d1baf 100644 --- a/packages/core/flow-engine/src/runjs-context/snippets/scene/linkage/set-required.snippet.ts +++ b/packages/core/flow-engine/src/runjs-context/snippets/scene/form/set-required.snippet.ts @@ -7,16 +7,26 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -export default { - contexts: ['*'], +import type { SnippetModule } from '../../types'; +import { JSItemRunJSContext } from '../../../contexts/JSItemRunJSContext'; + +const snippet: SnippetModule = { + contexts: [JSItemRunJSContext], prefix: 'sn-link-required', label: 'Set required', + description: 'Toggle required rule for another field within linkage', + locales: { + 'zh-CN': { + label: '设置必填', + description: '在联动脚本中控制字段是否必填', + }, + }, content: ` const targetFieldUid = 'FIELD_UID_OR_NAME'; const required = true; const items = ctx.model?.subModels?.grid?.subModels?.items; -const candidates: any[] = Array.isArray(items) +const candidates = Array.isArray(items) ? items : Array.from(items?.values?.() || items || []); const fieldModel = @@ -36,3 +46,5 @@ ctx.message?.success?.( ); `, }; + +export default snippet; diff --git a/packages/core/flow-engine/src/runjs-context/snippets/scene/form/toggle-multiple-fields.snippet.ts b/packages/core/flow-engine/src/runjs-context/snippets/scene/form/toggle-multiple-fields.snippet.ts new file mode 100644 index 00000000000..f08b9b1989e --- /dev/null +++ b/packages/core/flow-engine/src/runjs-context/snippets/scene/form/toggle-multiple-fields.snippet.ts @@ -0,0 +1,48 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import type { SnippetModule } from '../../types'; +import { JSItemRunJSContext } from '../../../contexts/JSItemRunJSContext'; + +const snippet: SnippetModule = { + contexts: [JSItemRunJSContext], + prefix: 'sn-link-show-hide', + label: 'Show/hide fields based on condition', + description: 'Toggle multiple fields visibility based on a condition', + locales: { + 'zh-CN': { + label: '条件显示/隐藏字段', + description: '根据条件批量显示或隐藏多个字段', + }, + }, + content: ` +// Show payment fields only when paymentMethod is 'online' +const paymentMethod = ctx.record?.paymentMethod; +const showPaymentFields = paymentMethod === 'online'; + +const items = ctx.model?.subModels?.grid?.subModels?.items; +const candidates = Array.isArray(items) ? items : Array.from(items?.values?.() || items || []); + +// Fields to toggle +const fieldNames = ['creditCard', 'expiryDate', 'cvv']; + +fieldNames.forEach((fieldName) => { + const field = candidates.find((item) => item?.props?.name === fieldName); + if (field) { + field.setProps({ + display: showPaymentFields ? 'visible' : 'hidden', + // Also clear values when hiding + value: showPaymentFields ? field.props.value : undefined, + }); + } +}); +`, +}; + +export default snippet; diff --git a/packages/core/flow-engine/src/runjs-context/snippets/scene/linkage/toggle-visible.snippet.ts b/packages/core/flow-engine/src/runjs-context/snippets/scene/form/toggle-visible.snippet.ts similarity index 69% rename from packages/core/flow-engine/src/runjs-context/snippets/scene/linkage/toggle-visible.snippet.ts rename to packages/core/flow-engine/src/runjs-context/snippets/scene/form/toggle-visible.snippet.ts index 5121ff44257..66c985774fe 100644 --- a/packages/core/flow-engine/src/runjs-context/snippets/scene/linkage/toggle-visible.snippet.ts +++ b/packages/core/flow-engine/src/runjs-context/snippets/scene/form/toggle-visible.snippet.ts @@ -7,16 +7,26 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -export default { - contexts: ['*'], +import type { SnippetModule } from '../../types'; +import { JSItemRunJSContext } from '../../../contexts/JSItemRunJSContext'; + +const snippet: SnippetModule = { + contexts: [JSItemRunJSContext], prefix: 'sn-link-visibility', label: 'Toggle visible', + description: 'Show or hide another field within linkage scripts', + locales: { + 'zh-CN': { + label: '切换可见性', + description: '在联动脚本中设置字段显示或隐藏', + }, + }, content: ` const targetFieldUid = 'FIELD_UID_OR_NAME'; const shouldHide = true; const items = ctx.model?.subModels?.grid?.subModels?.items; -const candidates: any[] = Array.isArray(items) +const candidates = Array.isArray(items) ? items : Array.from(items?.values?.() || items || []); const fieldModel = @@ -36,3 +46,5 @@ ctx.message?.success?.( ); `, }; + +export default snippet; diff --git a/packages/core/flow-engine/src/runjs-context/snippets/scene/jsblock/append-style.snippet.ts b/packages/core/flow-engine/src/runjs-context/snippets/scene/jsblock/append-style.snippet.ts deleted file mode 100644 index 9a18a1a881c..00000000000 --- a/packages/core/flow-engine/src/runjs-context/snippets/scene/jsblock/append-style.snippet.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * This file is part of the NocoBase (R) project. - * Copyright (c) 2020-2024 NocoBase Co., Ltd. - * Authors: NocoBase Team. - * - * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. - * For more information, please refer to: https://www.nocobase.com/agreement. - */ - -export default { - contexts: ['JSBlockRunJSContext'], - prefix: 'sn-jsb-style', - label: 'Append style', - content: ` -// Append styles to container -ctx.element.style.border = '1px dashed #999'; -ctx.element.style.padding = '12px'; -ctx.element.style.borderRadius = '8px'; -`, -}; diff --git a/packages/core/flow-engine/src/runjs-context/snippets/scene/jsblock/jsx-mount.snippet.ts b/packages/core/flow-engine/src/runjs-context/snippets/scene/jsblock/jsx-mount.snippet.ts deleted file mode 100644 index 1d58390593f..00000000000 --- a/packages/core/flow-engine/src/runjs-context/snippets/scene/jsblock/jsx-mount.snippet.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * This file is part of the NocoBase (R) project. - * Copyright (c) 2020-2024 NocoBase Co., Ltd. - * Authors: NocoBase Team. - * - * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. - * For more information, please refer to: https://www.nocobase.com/agreement. - */ - -export default { - contexts: ['JSBlockRunJSContext'], - prefix: 'sn-jsx-mount', - label: 'JSX mount', - content: ` -// Render JSX (editor does not auto-transform) -const { ReactDOM, antd } = ctx; -const { Button } = antd; - -if (ctx.__reactRoot?.unmount) { try { ctx.__reactRoot.unmount(); } catch(_) {} ctx.__reactRoot = undefined; } -const root = ReactDOM.createRoot(ctx.element); -root.render(); -ctx.__reactRoot = root; -`, -}; diff --git a/packages/core/flow-engine/src/runjs-context/snippets/scene/jsblock/jsx-unmount.snippet.ts b/packages/core/flow-engine/src/runjs-context/snippets/scene/jsblock/jsx-unmount.snippet.ts deleted file mode 100644 index fbf1bf1eb22..00000000000 --- a/packages/core/flow-engine/src/runjs-context/snippets/scene/jsblock/jsx-unmount.snippet.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * This file is part of the NocoBase (R) project. - * Copyright (c) 2020-2024 NocoBase Co., Ltd. - * Authors: NocoBase Team. - * - * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. - * For more information, please refer to: https://www.nocobase.com/agreement. - */ - -export default { - contexts: ['JSBlockRunJSContext'], - prefix: 'sn-jsx-unmount', - label: 'JSX unmount', - content: ` -if (ctx.__reactRoot?.unmount) { try { ctx.__reactRoot.unmount(); } catch(_) {} } -ctx.__reactRoot = undefined; -ctx.element.innerHTML = ''; -`, -}; diff --git a/packages/core/flow-engine/src/runjs-context/snippets/scene/jsblock/render-basic.snippet.ts b/packages/core/flow-engine/src/runjs-context/snippets/scene/jsblock/render-basic.snippet.ts deleted file mode 100644 index 32e6afcfcbc..00000000000 --- a/packages/core/flow-engine/src/runjs-context/snippets/scene/jsblock/render-basic.snippet.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * This file is part of the NocoBase (R) project. - * Copyright (c) 2020-2024 NocoBase Co., Ltd. - * Authors: NocoBase Team. - * - * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. - * For more information, please refer to: https://www.nocobase.com/agreement. - */ - -export default { - contexts: ['JSBlockRunJSContext'], - prefix: 'sn-jsb-html', - label: 'Render HTML', - content: - ` -ctx.element.innerHTML = ` + - '`' + - ` -
\${ctx.t('Hello JSBlock')}
-` + - '`' + - `; -`, -}; diff --git a/packages/core/flow-engine/src/runjs-context/snippets/scene/jsblock/render-card.snippet.ts b/packages/core/flow-engine/src/runjs-context/snippets/scene/jsblock/render-card.snippet.ts deleted file mode 100644 index 2b4dc944b78..00000000000 --- a/packages/core/flow-engine/src/runjs-context/snippets/scene/jsblock/render-card.snippet.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * This file is part of the NocoBase (R) project. - * Copyright (c) 2020-2024 NocoBase Co., Ltd. - * Authors: NocoBase Team. - * - * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. - * For more information, please refer to: https://www.nocobase.com/agreement. - */ - -import { SnippetModule } from '../../types'; - -const snippet: SnippetModule = { - contexts: ['JSBlockRunJSContext'], - prefix: 'sn-jsb-card', - label: 'Render card', - content: - ` -ctx.element.innerHTML = ` + - '`' + - ` -
-

\${ctx.t('Title')}

-
\${ctx.t('Card content')}
-
-` + - '`' + - `; -`, -}; -export default snippet; diff --git a/packages/core/flow-engine/src/runjs-context/snippets/scene/jsfield/color-by-value.snippet.ts b/packages/core/flow-engine/src/runjs-context/snippets/scene/jsfield/color-by-value.snippet.ts deleted file mode 100644 index 8bd814645d7..00000000000 --- a/packages/core/flow-engine/src/runjs-context/snippets/scene/jsfield/color-by-value.snippet.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * This file is part of the NocoBase (R) project. - * Copyright (c) 2020-2024 NocoBase Co., Ltd. - * Authors: NocoBase Team. - * - * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. - * For more information, please refer to: https://www.nocobase.com/agreement. - */ - -export default { - contexts: ['JSFieldRunJSContext', 'FormJSFieldItemRunJSContext'], - prefix: 'sn-jsf-color', - label: 'Color by value', - content: ` -// Colorize based on numeric sign -const n = Number(ctx.value ?? 0); -const color = Number.isFinite(n) ? (n > 0 ? 'green' : n < 0 ? 'red' : '#999') : '#555'; -ctx.element.innerHTML = '' + String(ctx.value ?? '') + ''; -`, -}; diff --git a/packages/core/flow-engine/src/runjs-context/snippets/scene/jsfield/format-number.snippet.ts b/packages/core/flow-engine/src/runjs-context/snippets/scene/jsfield/format-number.snippet.ts deleted file mode 100644 index ff23112e04c..00000000000 --- a/packages/core/flow-engine/src/runjs-context/snippets/scene/jsfield/format-number.snippet.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * This file is part of the NocoBase (R) project. - * Copyright (c) 2020-2024 NocoBase Co., Ltd. - * Authors: NocoBase Team. - * - * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. - * For more information, please refer to: https://www.nocobase.com/agreement. - */ - -export default { - contexts: ['JSFieldRunJSContext', 'FormJSFieldItemRunJSContext'], - prefix: 'sn-jsf-num', - label: 'Format number', - content: ` -// Format number using locale -const n = Number(ctx.value ?? 0); -ctx.element.innerHTML = String(Number.isFinite(n) ? n.toLocaleString() : ctx.value ?? ''); -`, -}; diff --git a/packages/core/flow-engine/src/runjs-context/snippets/scene/jsfield/innerHTML-value.snippet.ts b/packages/core/flow-engine/src/runjs-context/snippets/scene/jsfield/innerHTML-value.snippet.ts deleted file mode 100644 index e2862eca44f..00000000000 --- a/packages/core/flow-engine/src/runjs-context/snippets/scene/jsfield/innerHTML-value.snippet.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * This file is part of the NocoBase (R) project. - * Copyright (c) 2020-2024 NocoBase Co., Ltd. - * Authors: NocoBase Team. - * - * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. - * For more information, please refer to: https://www.nocobase.com/agreement. - */ - -export default { - contexts: ['JSFieldRunJSContext', 'FormJSFieldItemRunJSContext'], - prefix: 'sn-jsf-value', - label: 'Render field value', - content: ` -const v = String(ctx.value ?? ''); -ctx.element.innerHTML = \`\${v}\`; -`, -}; diff --git a/packages/core/flow-engine/src/runjs-context/snippets/scene/table/cell-open-dialog.snippet.ts b/packages/core/flow-engine/src/runjs-context/snippets/scene/table/cell-open-dialog.snippet.ts new file mode 100644 index 00000000000..b3b897cc249 --- /dev/null +++ b/packages/core/flow-engine/src/runjs-context/snippets/scene/table/cell-open-dialog.snippet.ts @@ -0,0 +1,45 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import type { SnippetModule } from '../../types'; +import { JSColumnRunJSContext } from '../../../contexts/JSColumnRunJSContext'; + +const snippet: SnippetModule = { + contexts: [JSColumnRunJSContext], + prefix: 'sn-col-open-dialog', + label: 'Cell dialog with row data', + description: 'Render a button in cell to open dialog via ctx.openView with current row context', + locales: { + 'zh-CN': { + label: '单元格对话框(显示行数据)', + description: '在单元格渲染按钮,点击后通过 ctx.openView 打开弹窗并传入当前行上下文', + }, + }, + content: ` +// Render a button inside the cell +ctx.element.innerHTML = ''; + +const button = ctx.element.querySelector('.nb-cell-btn'); +const popupUid = ctx.model.uid + '-1'; // popupUid should be stable and better bound to ctx.model.uid +const primaryKey = ctx.collection?.primaryKey || 'id'; + +button?.addEventListener('click', async () => { + await ctx.openView(popupUid, { + mode: 'dialog', + title: ctx.t('Row detail'), + params: { + filterByTk: ctx.record?.[primaryKey], + record: ctx.record, + }, + }); +}); +`, +}; + +export default snippet; diff --git a/packages/core/flow-engine/src/runjs-context/snippets/scene/actions/collection-selected-count.snippet.ts b/packages/core/flow-engine/src/runjs-context/snippets/scene/table/collection-selected-count.snippet.ts similarity index 57% rename from packages/core/flow-engine/src/runjs-context/snippets/scene/actions/collection-selected-count.snippet.ts rename to packages/core/flow-engine/src/runjs-context/snippets/scene/table/collection-selected-count.snippet.ts index 9548ac3b6c3..f6738332b80 100644 --- a/packages/core/flow-engine/src/runjs-context/snippets/scene/actions/collection-selected-count.snippet.ts +++ b/packages/core/flow-engine/src/runjs-context/snippets/scene/table/collection-selected-count.snippet.ts @@ -7,10 +7,20 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -export default { - contexts: ['JSCollectionActionRunJSContext'], +import type { SnippetModule } from '../../types'; +import { JSCollectionActionRunJSContext } from '../../../contexts/JSCollectionActionRunJSContext'; + +const snippet: SnippetModule = { + contexts: [JSCollectionActionRunJSContext], prefix: 'sn-act-selected-count', label: 'Selected count', + description: 'Show number of selected rows in list action', + locales: { + 'zh-CN': { + label: '选中数量', + description: '提示当前选中行的数量', + }, + }, content: ` const rows = ctx.resource?.getSelectedRows?.() || []; if (!rows.length) { @@ -20,3 +30,5 @@ if (!rows.length) { } `, }; + +export default snippet; diff --git a/packages/core/flow-engine/src/runjs-context/snippets/scene/table/concat-fields.snippet.ts b/packages/core/flow-engine/src/runjs-context/snippets/scene/table/concat-fields.snippet.ts new file mode 100644 index 00000000000..12873b20f19 --- /dev/null +++ b/packages/core/flow-engine/src/runjs-context/snippets/scene/table/concat-fields.snippet.ts @@ -0,0 +1,60 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import type { SnippetModule } from '../../types'; +import { JSColumnRunJSContext } from '../../../contexts/JSColumnRunJSContext'; + +const snippet: SnippetModule = { + contexts: [JSColumnRunJSContext], + prefix: 'sn-col-concat', + label: 'Concat two fields', + description: 'Combine two field values into the current column cell', + locales: { + 'zh-CN': { + label: '拼接两个字段值', + description: '在自定义列中拼接两个字段的值作为显示内容', + }, + }, + content: ` +// Demo helper: infer previous columns' dataIndex values based on the current column position +// In production, hardcode field names to avoid surprises when columns are reordered +function resolvePreviousDataIndexes() { + const parent = ctx.model?.parent; + const list = parent?.subModels?.columns; + if (!Array.isArray(list)) return []; + const currentUid = ctx.model?.uid; + const currentIndex = list.findIndex((item) => item?.uid === currentUid); + if (currentIndex <= 0) return []; + return list + .slice(Math.max(0, currentIndex - 2), currentIndex) + .map((item) => (item && item.props ? item.props.dataIndex : undefined)) + .filter((key) => typeof key === 'string' && key.length > 0); +} + +const [autoFieldA, autoFieldB] = resolvePreviousDataIndexes(); + +// Fallback: manually specify field keys when auto detection is not enough +const fieldA = autoFieldA || 'firstName'; +const fieldB = autoFieldB || 'lastName'; + +const normalize = (value) => { + if (value === undefined || value === null) return ''; + return String(value).trim(); +}; + +const valueA = normalize(ctx.record?.[fieldA]); +const valueB = normalize(ctx.record?.[fieldB]); + +const parts = [valueA, valueB].filter((item) => item.length > 0); + +ctx.element.textContent = parts.length ? parts.join(' / ') : ctx.t('N/A'); +`, +}; + +export default snippet; diff --git a/packages/core/flow-engine/src/runjs-context/snippets/scene/table/destroy-selected.snippet.ts b/packages/core/flow-engine/src/runjs-context/snippets/scene/table/destroy-selected.snippet.ts new file mode 100644 index 00000000000..b60aa8109c2 --- /dev/null +++ b/packages/core/flow-engine/src/runjs-context/snippets/scene/table/destroy-selected.snippet.ts @@ -0,0 +1,36 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import type { SnippetModule } from '../../types'; +import { JSCollectionActionRunJSContext } from '../../../contexts/JSCollectionActionRunJSContext'; + +const snippet: SnippetModule = { + contexts: [JSCollectionActionRunJSContext], + prefix: 'sn-act-destroy-selected', + label: 'Destroy selected rows', + description: 'Delete selected rows via resource.destroySelectedRows()', + locales: { + 'zh-CN': { + label: '删除选中行', + description: '通过 resource.destroySelectedRows() 删除选中行', + }, + }, + content: ` +const rows = ctx.resource?.getSelectedRows?.() || []; +if (!rows.length) { + ctx.message.warning(ctx.t('Please select data')); + return; +} + +await ctx.resource.destroySelectedRows(); +ctx.message.success(ctx.t('Deleted {{count}} rows', { count: rows.length })); +`, +}; + +export default snippet; diff --git a/packages/core/flow-engine/src/runjs-context/snippets/scene/table/export-selected-json.snippet.ts b/packages/core/flow-engine/src/runjs-context/snippets/scene/table/export-selected-json.snippet.ts new file mode 100644 index 00000000000..b8945e7f8ab --- /dev/null +++ b/packages/core/flow-engine/src/runjs-context/snippets/scene/table/export-selected-json.snippet.ts @@ -0,0 +1,45 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import type { SnippetModule } from '../../types'; +import { JSCollectionActionRunJSContext } from '../../../contexts/JSCollectionActionRunJSContext'; + +const snippet: SnippetModule = { + contexts: [JSCollectionActionRunJSContext], + prefix: 'sn-act-export', + label: 'Export selected rows as JSON', + description: 'Download selected rows as a JSON file', + locales: { + 'zh-CN': { + label: '导出选中行为 JSON', + description: '将选中的行导出为 JSON 文件下载', + }, + }, + content: ` +const rows = ctx.resource?.getSelectedRows?.() || []; +if (!rows.length) { + ctx.message.warning(ctx.t('Please select at least one row')); + return; +} + +// Create JSON file and download +const jsonStr = JSON.stringify(rows, null, 2); +const blob = new Blob([jsonStr], { type: 'application/json' }); +const url = URL.createObjectURL(blob); +const link = document.createElement('a'); +link.href = url; +link.download = \`export-\${new Date().toISOString().split('T')[0]}.json\`; +link.click(); +URL.revokeObjectURL(url); + +ctx.message.success(ctx.t('Exported {{count}} records', { count: rows.length })); +`, +}; + +export default snippet; diff --git a/packages/core/flow-engine/src/runjs-context/snippets/scene/actions/iterate-selected-rows.snippet.ts b/packages/core/flow-engine/src/runjs-context/snippets/scene/table/iterate-selected-rows.snippet.ts similarity index 56% rename from packages/core/flow-engine/src/runjs-context/snippets/scene/actions/iterate-selected-rows.snippet.ts rename to packages/core/flow-engine/src/runjs-context/snippets/scene/table/iterate-selected-rows.snippet.ts index 7c8fcf6b42b..b0356cf5f8f 100644 --- a/packages/core/flow-engine/src/runjs-context/snippets/scene/actions/iterate-selected-rows.snippet.ts +++ b/packages/core/flow-engine/src/runjs-context/snippets/scene/table/iterate-selected-rows.snippet.ts @@ -7,10 +7,20 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -export default { - contexts: ['JSCollectionActionRunJSContext'], +import type { SnippetModule } from '../../types'; +import { JSCollectionActionRunJSContext } from '../../../contexts/JSCollectionActionRunJSContext'; + +const snippet: SnippetModule = { + contexts: [JSCollectionActionRunJSContext], prefix: 'sn-act-iterate', label: 'Iterate selected rows', + description: 'Loop through selected rows and process each record', + locales: { + 'zh-CN': { + label: '遍历选中行', + description: '遍历选中行并处理每条记录', + }, + }, content: ` const rows = ctx.resource?.getSelectedRows?.() || []; for (const row of rows) { @@ -19,3 +29,5 @@ for (const row of rows) { ctx.message.success(ctx.t('Processed {{count}} rows', { count: rows.length })); `, }; + +export default snippet; diff --git a/packages/core/flow-engine/src/runjs-context/snippets/types.ts b/packages/core/flow-engine/src/runjs-context/snippets/types.ts index c8bd79e92f3..4b22faabc51 100644 --- a/packages/core/flow-engine/src/runjs-context/snippets/types.ts +++ b/packages/core/flow-engine/src/runjs-context/snippets/types.ts @@ -7,11 +7,15 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ +export type SnippetContextSpecifier = string | { name: string } | (new (...args: any[]) => any); + export type SnippetModule = { content: string; - contexts?: string[]; // e.g., ['JSBlockRunJSContext'] or ['*'] + contexts?: SnippetContextSpecifier[]; // e.g., [JSBlockRunJSContext] or ['*'] versions?: string[]; // e.g., ['v1'] or ['*'] + scenes?: string[]; // logical placement hints like ['form', 'table'] prefix?: string; // completion trigger text label?: string; // display name in snippets list description?: string; // optional longer description + locales?: Record; }; diff --git a/packages/plugins/@nocobase/plugin-block-lowcode/src/client/CodeEditor.tsx b/packages/plugins/@nocobase/plugin-block-lowcode/src/client/CodeEditor.tsx index 7d4e40ef8a4..9112f1ee728 100644 --- a/packages/plugins/@nocobase/plugin-block-lowcode/src/client/CodeEditor.tsx +++ b/packages/plugins/@nocobase/plugin-block-lowcode/src/client/CodeEditor.tsx @@ -23,15 +23,18 @@ import { createJavaScriptLinter } from './linter'; // 自定义自动补全函数 const createCustomCompletion = () => { return (context: CompletionContext): CompletionResult | null => { - const word = context.matchBefore(/[a-zA-Z_][\w.]*/); - if (!word || (word.from === word.to && !context.explicit)) return null; - - const from = word.from; - const to = word.to; + const word = context.matchBefore(/[$_\p{Letter}][$_\p{Letter}\p{Number}.-]*/u); + if (!word) { + if (context.explicit) { + return { from: context.pos, to: context.pos, options: completions }; + } + return null; + } + if (word.from === word.to && !context.explicit) return null; return { - from, - to, + from: word.from, + to: word.to, options: completions, }; }; diff --git a/packages/plugins/@nocobase/plugin-block-lowcode/src/client/completions/index.ts b/packages/plugins/@nocobase/plugin-block-lowcode/src/client/completions/index.ts index f4da44bb0d1..b120eb23365 100644 --- a/packages/plugins/@nocobase/plugin-block-lowcode/src/client/completions/index.ts +++ b/packages/plugins/@nocobase/plugin-block-lowcode/src/client/completions/index.ts @@ -7,196 +7,90 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -import { Completion } from '@codemirror/autocomplete'; +import type { Completion } from '@codemirror/autocomplete'; +import { FlowRunJSContext } from '@nocobase/flow-engine'; -export default [ - { - label: 'ctx', - type: 'class', - info: 'Running context with all available APIs and utilities', - detail: 'FlowRuntimeContext', - boost: 110, // 核心入口,优先级最高 - }, - { - label: 'ctx.api', - type: 'class', - info: 'APIClient instance for making HTTP requests.', - detail: 'APIClient', - boost: 100, // 次高优先级,常用功能 - // 使用补全提供的 from/to 进行替换,避免仅在光标处插入 - apply: (view, completion, from, to) => { - view.dispatch({ - changes: { from, to, insert: completion.label }, - }); - }, - }, - { - label: 'ctx.api.request(options)', - type: 'method', - info: 'Make an HTTP request using the APIClient instance.', - detail: 'Promise', - boost: 95, // 中等优先级,具体方法 - apply: (view, _completion, from, to) => { - view.dispatch({ - changes: { - from, - to, - insert: 'await ctx.api.request({\n url: "",\n method: "get",\n params: {}\n})', +const buildLowcodeCompletions = (): Completion[] => { + try { + const doc = typeof (FlowRunJSContext as any)?.getDoc === 'function' ? (FlowRunJSContext as any).getDoc() : {}; + const completions: Completion[] = []; + const toInfo = (value: any) => (typeof value === 'string' ? value : JSON.stringify(value)); + completions.push({ + label: 'ctx', + type: 'class', + detail: 'FlowRunJSContext', + info: doc?.label || 'RunJS context', + boost: 110, + } as Completion); + const collectProperties = (props: Record | undefined, parentPath: string[] = []) => { + if (!props) return; + for (const [key, value] of Object.entries(props)) { + const path = [...parentPath, key]; + const ctxLabel = `ctx.${path.join('.')}`; + const depth = path.length; + let description: any = value; + let detail: string | undefined; + let completionSpec: any; + let children: Record | undefined; + if (value && typeof value === 'object' && !Array.isArray(value)) { + description = value.description ?? value.detail ?? value.type ?? value; + detail = value.detail ?? value.type ?? 'ctx property'; + completionSpec = value.completion; + children = value.properties as Record | undefined; + } + const apply = completionSpec?.insertText + ? (view: any, _completion: any, from: number, to: number) => { + view.dispatch({ + changes: { from, to, insert: completionSpec.insertText }, + selection: { anchor: from + completionSpec.insertText.length }, + scrollIntoView: true, + }); + } + : undefined; + completions.push({ + label: ctxLabel, + type: 'property', + detail: detail || 'ctx property', + info: toInfo(description), + boost: Math.max(90 - depth * 5, 10), + apply, + } as Completion); + if (children) collectProperties(children, path); + } + }; + + collectProperties(doc?.properties || {}); + const methods = doc?.methods || {}; + for (const key of Object.keys(methods)) { + const methodDoc = methods[key]; + let description: any = methodDoc; + let detail = 'ctx method'; + let completionSpec: any; + if (methodDoc && typeof methodDoc === 'object' && !Array.isArray(methodDoc)) { + description = methodDoc.description ?? methodDoc.detail ?? methodDoc; + detail = methodDoc.detail ?? detail; + completionSpec = methodDoc.completion; + } + const insertText = completionSpec?.insertText ?? `ctx.${key}()`; + completions.push({ + label: `ctx.${key}()` as any, + type: 'function', + detail, + info: toInfo(description), + boost: 95, + apply: (view: any, _completion: any, from: number, to: number) => { + view.dispatch({ + changes: { from, to, insert: insertText }, + selection: { anchor: from + insertText.length }, + scrollIntoView: true, + }); }, - }); - }, - }, - { - label: 'ctx.element', - type: 'property', - info: 'Represents the current HTML element in the runtime context', - detail: 'HTMLElement', - boost: 110, // 核心属性,优先级最高 - apply: (view, completion, from, to) => { - view.dispatch({ - changes: { from, to, insert: completion.label }, - }); - }, - }, - { - label: 'ctx.element.innerHTML', - type: 'property', - info: 'Set the inner HTML content of the current HTML element in the runtime context.', - detail: 'string', - boost: 90, // 中等优先级,具体属性 - apply: (view, _completion, from, to) => { - view.dispatch({ - changes: { from, to, insert: 'ctx.element.innerHTML = `

Hello, NocoBase!

`;' }, - }); - }, - }, - { - label: 'Basic HTML Template', - type: 'snippet', - info: 'Insert a basic HTML structure with padding and sample content.', - detail: 'HTML Snippet', - boost: 85, // 较低优先级,代码片段 - apply: `ctx.element.innerHTML = \` -
-

Hello World

-

This is a basic HTML template.

-
-\`;`, - }, - { - label: 'API Response Snippet', - type: 'snippet', - info: 'Fetch data from an API and display it as formatted JSON in the current HTML element.', - detail: 'API Request and Display', - boost: 85, // 较低优先级,代码片段 - apply: `const response = await ctx.api.request({ - url: '/users', - method: 'get', - params: { page: 1, pageSize: 10 } -}); - -ctx.element.innerHTML = \` -
\${JSON.stringify(response.data, null, 2)}
-\``,
-  },
-  {
-    label: 'ECharts Example Snippet',
-    type: 'snippet',
-    info: 'Insert an ECharts example with random data and responsive resizing.',
-    detail: 'ECharts Snippet',
-    boost: 85, // 较低优先级,代码片段
-    apply: `ctx.element.style.height = '400px';
-const echarts = await ctx.requireAsync('https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js');
-if (!echarts) {
-  return;
-}
-const chart = echarts.init(ctx.element);
-// Generate random data
-const categories = ['A', 'B', 'C', 'D', 'E', 'F'];
-const randomData = categories.map(() => Math.floor(Math.random() * 50) + 1);
-const option = {
-  title: { text: 'ECharts Example (Random Data)' },
-  tooltip: {},
-  xAxis: { data: categories },
-  yAxis: {},
-  series: [{ name: 'Sales', type: 'bar', data: randomData }],
-};
-chart.setOption(option);
-chart.resize();
-window.addEventListener('resize', () => chart.resize());`,
-  },
-  {
-    label: 'I18n Example Snippet',
-    type: 'snippet',
-    info: 'Insert an example for internationalization using ctx.i18n.',
-    detail: 'I18n Snippet',
-    boost: 85, // 较低优先级,代码片段
-    apply: `const zhCN = {
-  hello: "你好",
-  welcome_user: "欢迎,{{user}}!"
-};
-const enUS = {
-  hello: "Hello",
-  welcome_user: "Welcome, {{user}}!"
+      } as Completion);
+    }
+    return completions;
+  } catch (_) {
+    return [];
+  }
 };
 
-// Add Chinese resource bundle
-ctx.i18n.addResourceBundle('zh-CN', 'ns1', zhCN, true, true);
-// Add English resource bundle
-ctx.i18n.addResourceBundle('en-US', 'ns1', enUS, true, true);
-
-// Render localized content
-ctx.element.innerHTML = ctx.t('welcome_user', { user: ctx.auth.user.nickname, ns: 'ns1' });`,
-  },
-  {
-    label: 'ctx.i18n',
-    type: 'class',
-    info: 'An instance of i18next for managing internationalization.',
-    detail: 'i18next',
-    boost: 100, // 次高优先级,常用功能
-    apply: (view, completion, from, to) => {
-      view.dispatch({
-        changes: { from, to, insert: completion.label },
-      });
-    },
-  },
-  {
-    label: 'ctx.t(text, options)',
-    type: 'method',
-    info: 'Translate a given text key using the provided options.',
-    detail: 'string',
-    boost: 95, // 中等优先级,具体方法
-    apply: (view, _completion, from, to) => {
-      view.dispatch({
-        changes: { from, to, insert: 'ctx.t("key", { ns: "namespace" })' },
-      });
-    },
-  },
-  {
-    label: 'ctx.element.querySelector(selector)',
-    type: 'method',
-    info: 'Find the first descendant element that matches the specified CSS selector.',
-    detail: 'HTMLElement | null',
-    boost: 85, // 较低优先级,具体方法
-    apply: (view, _completion, from, to) => {
-      view.dispatch({
-        changes: { from, to, insert: 'const child = ctx.element.querySelector(".child-class");' },
-      });
-    },
-  },
-  {
-    label: 'Resource Example Snippet',
-    type: 'snippet',
-    info: 'Use a resource to fetch data and display it in the current HTML element.',
-    detail: 'Resource Snippet',
-    boost: 85, // 较低优先级,代码片段
-    apply: `ctx.useResource('SingleRecordResource');
-const resource = ctx.resource;
-resource.setDataSourceKey('main');
-resource.setResourceName('users');
-await resource.refresh();
-ctx.element.innerHTML = \`
-  
\${JSON.stringify(ctx.resource.getData(), null, 2)}
-\`;`, - }, -] as Completion[]; +export default buildLowcodeCompletions();