mirror of
https://github.com/nocobase/nocobase.git
synced 2026-09-21 05:44:51 +08:00
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:
+8
-1
@@ -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(
|
||||
|
||||
+9
-3
@@ -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);
|
||||
|
||||
+49
-20
@@ -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',
|
||||
|
||||
+3
-3
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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', { ... })`",
|
||||
},
|
||||
});
|
||||
+28
-21
@@ -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' },
|
||||
);
|
||||
|
||||
+15
-16
@@ -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' },
|
||||
);
|
||||
|
||||
+17
-18
@@ -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 函数与 hooks(RunJS 环境中可用)',
|
||||
ReactDOM: 'ReactDOM 客户端 API,含 createRoot 等渲染方法',
|
||||
antd: 'Ant Design 组件库(RunJS 环境中可用)',
|
||||
},
|
||||
methods: {
|
||||
t: '国际化函数,用于翻译文案。参数:(key: string, variables?: object) => string。示例:`ctx.t("你好 {name}", { name: "世界" })`',
|
||||
requireAsync:
|
||||
'按 URL 异步加载外部库。参数:(url: string) => Promise<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;
|
||||
}
|
||||
-20
@@ -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);
|
||||
`,
|
||||
};
|
||||
-20
@@ -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;
|
||||
-19
@@ -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'));
|
||||
`,
|
||||
};
|
||||
-21
@@ -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;
|
||||
-21
@@ -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;
|
||||
-21
@@ -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'));
|
||||
`,
|
||||
|
||||
+11
-1
@@ -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;
|
||||
|
||||
+10
-3
@@ -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',
|
||||
});
|
||||
`,
|
||||
};
|
||||
|
||||
+10
-3
@@ -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 }));
|
||||
`,
|
||||
};
|
||||
-22
@@ -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) }));
|
||||
}
|
||||
`,
|
||||
};
|
||||
-23
@@ -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') }] }] });
|
||||
}
|
||||
`,
|
||||
};
|
||||
-21
@@ -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 }));
|
||||
}
|
||||
`,
|
||||
};
|
||||
-18
@@ -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'));
|
||||
`,
|
||||
};
|
||||
+14
-2
@@ -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;
|
||||
+45
@@ -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;
|
||||
+80
@@ -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;
|
||||
+44
@@ -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;
|
||||
+15
-6
@@ -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;
|
||||
+38
@@ -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;
|
||||
+53
@@ -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;
|
||||
+15
-11
@@ -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;
|
||||
+81
@@ -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;
|
||||
+66
@@ -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;
|
||||
+46
@@ -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;
|
||||
+264
@@ -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;
|
||||
+105
@@ -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;
|
||||
+33
@@ -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;
|
||||
+45
@@ -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;
|
||||
+32
@@ -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;
|
||||
+31
@@ -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;
|
||||
+63
@@ -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;
|
||||
+61
@@ -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;
|
||||
+55
@@ -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;
|
||||
+44
@@ -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;
|
||||
+62
@@ -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;
|
||||
+45
@@ -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;
|
||||
+55
@@ -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;
|
||||
+14
-2
@@ -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;
|
||||
+15
-3
@@ -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;
|
||||
+15
-3
@@ -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;
|
||||
+15
-3
@@ -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;
|
||||
+48
@@ -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;
|
||||
+15
-3
@@ -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;
|
||||
-20
@@ -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';
|
||||
`,
|
||||
};
|
||||
-24
@@ -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;
|
||||
`,
|
||||
};
|
||||
-19
@@ -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 = '';
|
||||
`,
|
||||
};
|
||||
-24
@@ -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>
|
||||
` +
|
||||
'`' +
|
||||
`;
|
||||
`,
|
||||
};
|
||||
-30
@@ -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;
|
||||
-20
@@ -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
Reference in New Issue
Block a user