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
This commit is contained in:
gchust
2025-10-13 16:10:52 +08:00
committed by GitHub
parent 0c2e3aae56
commit 3e6a7d83a5
111 changed files with 5730 additions and 1834 deletions
@@ -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(
@@ -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);
@@ -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);
});
});
@@ -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);
});
});
@@ -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<any>',
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 = `<h1>Hello, NocoBase!</h1>`;' },
});
},
},
{
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 = \`
<div style="padding: 20px;">
<h2>Hello World</h2>
<p>This is a basic HTML template.</p>
</div>
\`;`,
},
{
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 = \`
<pre>\${JSON.stringify(response.data, null, 2)}<pre/>
\``,
},
{
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 = \`
<pre>\${JSON.stringify(ctx.resource.getData(), null, 2)}</pre>
\`;`,
},
] as Completion[];
@@ -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;
@@ -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',
@@ -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<Completion[] | null>(null);
const [entries, setEntries] = useState<SnippetEntry[]>([]);
const [error, setError] = useState<Error | null>(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 };
}
@@ -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<CodeEditorProps & InjectableRendingEventTrigge
const triggerProps = { mode, name, language, scene };
return (
<InjectableRendingEventTrigger {...triggerProps}>
<InnerCodeEditor {...rest} />
<InnerCodeEditor {...rest} scene={scene} />
</InjectableRendingEventTrigger>
);
};
@@ -62,13 +63,28 @@ const InnerCodeEditor: React.FC<CodeEditorProps> = ({
wrapperStyle,
extraCompletions,
version = 'v1',
scene,
}) => {
const wrapperRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
const runtimeCtx = useFlowContext<any>();
// 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<CodeEditorProps> = ({
// 合并外部注入与动态构建的 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]);
@@ -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<string, any> | 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<string, any> | 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<string, any> | 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,
};
};
@@ -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<string>([
// 常见全局(简表)
'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<string>();
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;
@@ -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 (
<pre
key={i}
@@ -53,7 +56,7 @@ export const LogsPanel: React.FC<{
}}
>
[{l.level}] {l.msg}
{clickable ? ` (${tr('at')} ${l.line}:${l.column})` : ''}
{clickable ? ` (${tr('at')} ${displayLine}:${l.column})` : ''}
</pre>
);
})
@@ -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<string, string> = {
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<{
/>
<List
dataSource={filtered}
renderItem={(item) => (
<List.Item
actions={[
<Button
key="insert"
size="small"
type="primary"
onClick={() => {
const text = item.body.endsWith('\n') ? item.body : item.body + '\n';
onInsert(text);
}}
>
{tr('Insert')}
</Button>,
]}
>
<List.Item.Meta
title={
<>
<span>{item.name}</span>
{item.prefix ? <Tag style={{ marginLeft: 8 }}>{item.prefix}</Tag> : null}
{item.group ? (
<Tag color="blue" style={{ marginLeft: 8 }}>
{groupDisplay(item.group)}
</Tag>
) : null}
</>
}
description={item.description || groupDisplay(item.group) || item.ref || ''}
/>
</List.Item>
)}
renderItem={(item) => {
const groups = item.groups?.length ? item.groups : item.group ? [item.group] : [];
return (
<List.Item
actions={[
<Button
key="insert"
size="small"
type="primary"
onClick={() => {
const text = item.body.endsWith('\n') ? item.body : item.body + '\n';
onInsert(text);
}}
>
{tr('Insert')}
</Button>,
]}
>
<List.Item.Meta
title={
<>
<span>{item.name}</span>
{item.prefix ? <Tag style={{ marginLeft: 8 }}>{item.prefix}</Tag> : null}
{groups.map((grp) => (
<Tag color="blue" style={{ marginLeft: 8 }} key={grp}>
{groupDisplay(grp)}
</Tag>
))}
</>
}
description={item.description || groupDisplay(groups[0]) || item.ref || ''}
/>
</List.Item>
);
}}
/>
</Drawer>
);
@@ -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<string, any> | 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<string, any> | 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<string, any> | 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<string>();
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<any>(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 };
}
@@ -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<string, () => Promise<any>>;
// 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<string> {
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<string, any>): Promise<Record<string, any>> {
const out: Record<string, any> = {};
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<LoadedSnippetEntry[]> {
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;
}
@@ -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);
});
});
});
@@ -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');
});
});
});
@@ -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();
});
});
});
@@ -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 字段项/);
});
});
});
@@ -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');
});
});
});
@@ -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);
});
});
});
@@ -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(/国际化函数/);
});
});
@@ -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();
});
});
});
@@ -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();
});
});
});
+190 -91
View File
@@ -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<string, any>) => {
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<any>;
declare importAsync: (url: string) => Promise<any>;
declare createJSRunner: (options?: JSRunnerOptions) => JSRunner;
/**
* @deprecated use `resolveJsonTemplate` instead
@@ -931,7 +896,6 @@ class BaseFlowEngineContext extends FlowContext {
declare renderJson: (template: JSONValue) => Promise<any>;
declare resolveJsonTemplate: (template: JSONValue) => Promise<any>;
declare runjs: (code: string, variables?: Record<string, any>, options?: JSRunnerOptions) => Promise<any>;
declare copyToClipboard: (text: string) => Promise<void>;
declare getAction: <TModel extends FlowModel = FlowModel, TCtx extends FlowContext = FlowContext>(
name: string,
) => ActionDefinition<TModel, TCtx> | 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<void>((resolve, reject) => {
const u = url.trim();
const g = globalThis as any;
g.__nocobaseImportAsyncCache = g.__nocobaseImportAsyncCache || new Map<string, Promise<any>>();
const cache: Map<string, Promise<any>> = 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<string, any> = { 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<TModel extends FlowModel = FlowModel> = FlowRuntimeContext<TModel, 'settings'>;
export type RunJSDocCompletionDoc = {
insertText?: string;
};
export type RunJSDocPropertyDoc =
| string
| {
description?: string;
detail?: string;
type?: string;
examples?: string[];
completion?: RunJSDocCompletionDoc;
properties?: Record<string, RunJSDocPropertyDoc>;
};
export type RunJSDocMethodDoc =
| string
| {
description?: string;
detail?: string;
examples?: string[];
completion?: RunJSDocCompletionDoc;
};
export type RunJSDocMeta = {
label?: string;
properties?: Record<string, RunJSDocPropertyDoc>;
methods?: Record<string, RunJSDocMethodDoc>;
snippets?: Record<string, any>;
};
const __runjsClassDefaultMeta = new WeakMap<Function, RunJSDocMeta>();
const __runjsClassLocaleMeta = new WeakMap<Function, Map<string, RunJSDocMeta>>();
const __runjsDocCache = new WeakMap<Function, Map<string, RunJSDocMeta>>();
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<string, RunJSDocMeta>();
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<string, RunJSDocMeta>();
__runjsDocCache.set(self, cacheForClass);
}
cacheForClass.set(cacheKey, merged);
return merged;
}
}
+9 -1
View File
@@ -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';
@@ -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<string, any>;
methods?: Record<string, any>;
snipastes?: Record<string, any>;
};
const classMeta = new WeakMap<Function, RunJSDocMeta>();
const classDocCache = new WeakMap<Function, RunJSDocMeta>();
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<string>; facades?: Record<string, ReadonlyArray<string>> };
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<string, any>) => {
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', { ... })`",
},
});
@@ -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' },
);
@@ -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' },
);
@@ -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' },
);
@@ -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' },
);
@@ -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' },
);
@@ -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' },
);
@@ -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' },
);
@@ -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' },
},
});
@@ -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<any>.',
detail: 'Promise<any>',
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<any>. Example: `const lodash = await ctx.requireAsync("https://cdn.jsdelivr.net/npm/lodash")`',
importAsync:
'Dynamically import ESM module by URL. Parameters: (url: string) => Promise<Module>. 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<any>. Example: `await ctx.runAction("create", { values: { name: "test" } })`',
detail: 'Promise<any>',
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<void>
Options:
- params: Record<string, any> - 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<void>',
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<any>',
completion: {
insertText: `await ctx.api.request({ url: '', method: 'get', params: {} })`,
},
},
},
},
i18n: {
description: 'i18next 实例,可用于管理国际化',
detail: 'i18next',
properties: {
language: '当前激活的语言代码',
},
},
React: 'React 命名空间,提供 React 函数与 hooksRunJS 环境中可用)',
ReactDOM: 'ReactDOM 客户端 API,含 createRoot 等渲染方法',
antd: 'Ant Design 组件库(RunJS 环境中可用)',
},
methods: {
t: '国际化函数,用于翻译文案。参数:(key: string, variables?: object) => string。示例:`ctx.t("你好 {name}", { name: "世界" })`',
requireAsync:
'按 URL 异步加载外部库。参数:(url: string) => Promise<any>。示例:`const lodash = await ctx.requireAsync("https://cdn.jsdelivr.net/npm/lodash")`',
importAsync:
'按 URL 动态导入 ESM 模块(开发/生产均可用)。参数:(url: string) => Promise<Module>。示例:`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<any>。示例:`await ctx.runAction("create", { values: { name: "test" } })`',
detail: 'Promise<any>',
completion: {
insertText: `await ctx.runAction('create', { values: {} })`,
},
},
openView: {
description:
'根据唯一标识打开视图(页面/弹窗/抽屉)。参数:(viewId: string, options?: OpenViewOptions) => Promise<void>`,常用选项:params、mode、title、width、navigation、preventClose、viewUid、isMobileLayout。',
detail: 'Promise<void>',
completion: {
insertText: `await ctx.openView('view-id', { mode: 'drawer', params: {} })`,
},
},
},
},
{ locale: 'zh-CN' },
);
}
@@ -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<string, any> = { 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);
}
@@ -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';
@@ -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<string, RunJSContextCtor>();
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<string, { ctor: RunJSContextCtor; meta?: RunJSContextMeta }>();
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 || '*';
}
@@ -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;
}
@@ -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);
`,
};
@@ -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);
`,
};
@@ -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;
@@ -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'));
`,
};
@@ -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;
@@ -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;
@@ -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\`<span style="color:#52c41a;">lit-html loaded and rendered</span>\`, container);
`,
};
export default snippet;
@@ -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;
@@ -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'));
`,
@@ -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'));
`,
@@ -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;
@@ -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',
});
`,
};
@@ -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',
});
`,
};
@@ -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;
@@ -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;
@@ -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) }));
}
`,
};
@@ -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 }));
`,
};
@@ -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) }));
}
`,
};
@@ -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;
@@ -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;
@@ -7,53 +7,189 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
export type EngineSnippetMap = Record<string, () => Promise<any>>;
import { RunJSContextRegistry } from '../registry';
export const engineSnippets: EngineSnippetMap = {
// Simple manual exports - no build-time magic needed
const snippets: Record<string, () => Promise<any>> = {
// 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<string> {
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<EngineSnippetEntry[]> {
const entries: EngineSnippetEntry[] = [];
const allowedContextNames = new Set<string>();
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;
}
@@ -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') }] }] });
}
`,
};
@@ -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 }));
}
`,
};
@@ -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'));
`,
};
@@ -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;
@@ -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 = [
'<div style="padding:12px">',
'<h4 style="margin:0 0 8px">' + ctx.t('Users') + '</h4>',
'<ul style="margin:0; padding-left:20px">',
...rows.map((r, i) => '<li>#' + (i + 1) + ': ' + String((r && (r.nickname ?? r.username ?? r.id)) ?? '') + '</li>'),
'</ul>',
'</div>'
].join('');
`,
};
export default snippet;
@@ -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 = '<div style="color:#c00;">' + (error?.message || ctx.t('Chart initialization failed')) + '</div>';
});
`,
};
export default snippet;
@@ -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;
@@ -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;
@@ -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;
@@ -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 = '<div style="padding:16px;color:#999;">' + ctx.t('No record data') + '</div>';
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;
@@ -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;
@@ -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;
@@ -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 = '<div style="padding:16px;color:#999;">' + ctx.t('No data') + '</div>';
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;
@@ -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 = ` +
'`' +
`
<pre style="padding: 12px; background: #f5f5f5; border-radius: 6px;">
\${JSON.stringify(resource.getData(), null, 2)}
</pre>
` +
'`' +
`;
`,
};
export default snippet;
@@ -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 = '<div style="color:#cbd5e1; padding: 12px; text-align:center;">' + msg + '</div>';
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 = '<div style="color:#cbd5e1; padding: 12px; text-align:center;">users:list 接口无数据</div>';
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;
@@ -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 = '<div style="color:#c00;">' + (error?.message || ctx.t('Vue initialization failed')) + '</div>';
});
`,
};
export default snippet;
@@ -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 = '<span style=' + JSON.stringify('color:' + color) + '>' + String(ctx.value ?? '') + '</span>';
`,
};
export default snippet;
@@ -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 = '<a class="nb-copy" style="cursor:pointer;color:#1677ff">' +
ctx.t('Copy') + '</a>';
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;
@@ -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;
@@ -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 = \`<span class="nb-js-field-value" style="color:#1890ff;font-weight:600">\${v}</span>\`;
`,
};
export default snippet;
@@ -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 = \`
<div style="display: flex; align-items: center; gap: 8px;">
<div style="flex: 1; height: 8px; background: #f0f0f0; border-radius: 4px; overflow: hidden;">
<div style="
width: \${percent}%;
height: 100%;
background: \${color};
transition: width 0.3s ease;
"></div>
</div>
<span style="color: \${color}; font-weight: 500; min-width: 45px; text-align: right;">
\${percent.toFixed(1)}%
</span>
</div>
\`;
`,
};
export default snippet;
@@ -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 = \`
<span title="\${fullDate}" style="cursor: help; color: #666;">
\${relativeTime}
</span>
\`;
`,
};
export default snippet;
@@ -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 = \`
<span style="
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
background-color: var(--\${color}-1, #f0f0f0);
color: var(--\${color}-6, #333);
border: 1px solid var(--\${color}-3, #d9d9d9);
">
\${ctx.t(status)}
</span>
\`;
`,
};
export default snippet;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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';
`,
};
@@ -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(<Button type="primary" onClick={() => ctx.message.success(ctx.t('Clicked!'))}>{ctx.t('Button')}</Button>);
ctx.__reactRoot = root;
`,
};
@@ -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 = '';
`,
};
@@ -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 = ` +
'`' +
`
<div style="padding:12px">\${ctx.t('Hello JSBlock')}</div>
` +
'`' +
`;
`,
};
@@ -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 = ` +
'`' +
`
<div style="border:1px solid #ddd;border-radius:8px;padding:16px;">
<h3 style="margin:0 0 8px;">\${ctx.t('Title')}</h3>
<div>\${ctx.t('Card content')}</div>
</div>
` +
'`' +
`;
`,
};
export default snippet;
@@ -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 = '<span style=' + JSON.stringify('color:' + color) + '>' + String(ctx.value ?? '') + '</span>';
`,
};

Some files were not shown because too many files have changed in this diff Show More