@nocobase/client-v2 2.1.0-beta.42 → 2.1.0-beta.43
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/es/components/form/TypedVariableInput.d.ts +75 -0
- package/es/components/form/index.d.ts +1 -0
- package/es/flow/models/blocks/assign-form/assignFieldValuesFlow.d.ts +8 -0
- package/es/index.mjs +70 -70
- package/lib/index.js +91 -91
- package/package.json +7 -7
- package/src/components/README.md +48 -0
- package/src/components/README.zh-CN.md +48 -0
- package/src/components/form/TypedVariableInput.tsx +441 -0
- package/src/components/form/__tests__/TypedVariableInput.test.tsx +152 -0
- package/src/components/form/index.tsx +1 -0
- package/src/flow/actions/__tests__/linkageRules.hiddenSync.restore.test.ts +100 -0
- package/src/flow/actions/__tests__/linkageRulesRefresh.test.ts +23 -1
- package/src/flow/actions/linkageRules.tsx +5 -4
- package/src/flow/actions/linkageRulesRefresh.tsx +2 -3
- package/src/flow/components/code-editor/__tests__/runjsCompletions.test.ts +272 -27
- package/src/flow/components/code-editor/runjsCompletions.ts +490 -9
- package/src/flow/models/actions/__tests__/AssignFormRefill.test.ts +90 -0
- package/src/flow/models/blocks/assign-form/AssignFormItemModel.tsx +14 -3
- package/src/flow/models/blocks/assign-form/__tests__/assignFieldValuesFlow.editor.test.tsx +94 -2
- package/src/flow/models/blocks/assign-form/assignFieldValuesFlow.tsx +67 -13
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This file is part of the NocoBase (R) project.
|
|
3
|
+
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
|
4
|
+
* Authors: NocoBase Team.
|
|
5
|
+
*
|
|
6
|
+
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
|
7
|
+
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { FlowContext, FlowContextProvider } from '@nocobase/flow-engine';
|
|
11
|
+
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
12
|
+
import React from 'react';
|
|
13
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
14
|
+
import { TypedVariableInput } from '../TypedVariableInput';
|
|
15
|
+
|
|
16
|
+
function createContextWithEnv() {
|
|
17
|
+
const ctx = new FlowContext();
|
|
18
|
+
(ctx as any).t = (key: string) => key;
|
|
19
|
+
|
|
20
|
+
ctx.defineProperty('$env', {
|
|
21
|
+
value: { SMTP_PORT: 465, SECURE_FLAG: true },
|
|
22
|
+
meta: {
|
|
23
|
+
title: 'Env',
|
|
24
|
+
type: 'object',
|
|
25
|
+
properties: {
|
|
26
|
+
SMTP_PORT: { title: 'SMTP Port', type: 'number' },
|
|
27
|
+
SECURE_FLAG: { title: 'Secure flag', type: 'boolean' },
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
return ctx;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function renderWithCtx(ctx: FlowContext, node: React.ReactNode) {
|
|
36
|
+
return render(<FlowContextProvider context={ctx}>{node}</FlowContextProvider>);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('TypedVariableInput - constant rendering', () => {
|
|
40
|
+
it('renders an InputNumber for a numeric value when types=[number]', async () => {
|
|
41
|
+
const ctx = createContextWithEnv();
|
|
42
|
+
renderWithCtx(
|
|
43
|
+
ctx,
|
|
44
|
+
<TypedVariableInput
|
|
45
|
+
value={465}
|
|
46
|
+
types={[['number', { min: 1, max: 65535 }]]}
|
|
47
|
+
namespaces={['$env']}
|
|
48
|
+
onChange={() => undefined}
|
|
49
|
+
/>,
|
|
50
|
+
);
|
|
51
|
+
const numberInput = await screen.findByDisplayValue('465');
|
|
52
|
+
expect(numberInput).toBeInTheDocument();
|
|
53
|
+
expect(numberInput.getAttribute('role')).toBe('spinbutton');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('renders a boolean Select when value is true and types=[boolean]', async () => {
|
|
57
|
+
const ctx = createContextWithEnv();
|
|
58
|
+
renderWithCtx(
|
|
59
|
+
ctx,
|
|
60
|
+
<TypedVariableInput value={true} types={['boolean']} namespaces={['$env']} onChange={() => undefined} />,
|
|
61
|
+
);
|
|
62
|
+
await waitFor(() => {
|
|
63
|
+
// antd Select renders the chosen item label via a selection-item span
|
|
64
|
+
const item = screen.getByText('True');
|
|
65
|
+
expect(item).toBeInTheDocument();
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('renders the Null placeholder when value=null and nullable=true', async () => {
|
|
70
|
+
const ctx = createContextWithEnv();
|
|
71
|
+
renderWithCtx(
|
|
72
|
+
ctx,
|
|
73
|
+
<TypedVariableInput value={null} types={['number']} namespaces={['$env']} nullable onChange={() => undefined} />,
|
|
74
|
+
);
|
|
75
|
+
// Uses placeholder slot (not value) so antd's grey placeholder colour
|
|
76
|
+
// applies — see comment in TypedVariableInput.tsx.
|
|
77
|
+
const nullInput = await screen.findByPlaceholderText('<Null>');
|
|
78
|
+
expect(nullInput).toBeInTheDocument();
|
|
79
|
+
expect(nullInput.getAttribute('readonly')).not.toBeNull();
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('TypedVariableInput - variable rendering', () => {
|
|
84
|
+
it('renders a labelled pill when value is a {{ $env.X }} expression', async () => {
|
|
85
|
+
const ctx = createContextWithEnv();
|
|
86
|
+
renderWithCtx(
|
|
87
|
+
ctx,
|
|
88
|
+
<TypedVariableInput
|
|
89
|
+
value="{{$env.SMTP_PORT}}"
|
|
90
|
+
types={['number']}
|
|
91
|
+
namespaces={['$env']}
|
|
92
|
+
onChange={() => undefined}
|
|
93
|
+
/>,
|
|
94
|
+
);
|
|
95
|
+
await waitFor(() => {
|
|
96
|
+
const tag = screen.getByRole('button', { name: 'variable-tag' });
|
|
97
|
+
expect(tag.textContent).toContain('Env');
|
|
98
|
+
expect(tag.textContent).toContain('SMTP Port');
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('clears back to null when the close button is clicked (nullable=true)', async () => {
|
|
103
|
+
const ctx = createContextWithEnv();
|
|
104
|
+
const handleChange = vi.fn();
|
|
105
|
+
renderWithCtx(
|
|
106
|
+
ctx,
|
|
107
|
+
<TypedVariableInput
|
|
108
|
+
value="{{$env.SMTP_PORT}}"
|
|
109
|
+
types={['number']}
|
|
110
|
+
namespaces={['$env']}
|
|
111
|
+
nullable
|
|
112
|
+
onChange={handleChange}
|
|
113
|
+
/>,
|
|
114
|
+
);
|
|
115
|
+
const clear = await screen.findByRole('button', { name: 'icon-close' });
|
|
116
|
+
fireEvent.click(clear);
|
|
117
|
+
expect(handleChange).toHaveBeenCalledWith(null);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('clears back to default-of-first-type when nullable=false', async () => {
|
|
121
|
+
const ctx = createContextWithEnv();
|
|
122
|
+
const handleChange = vi.fn();
|
|
123
|
+
renderWithCtx(
|
|
124
|
+
ctx,
|
|
125
|
+
<TypedVariableInput
|
|
126
|
+
value="{{$env.SMTP_PORT}}"
|
|
127
|
+
types={['number']}
|
|
128
|
+
namespaces={['$env']}
|
|
129
|
+
nullable={false}
|
|
130
|
+
onChange={handleChange}
|
|
131
|
+
/>,
|
|
132
|
+
);
|
|
133
|
+
const clear = await screen.findByRole('button', { name: 'icon-close' });
|
|
134
|
+
fireEvent.click(clear);
|
|
135
|
+
expect(handleChange).toHaveBeenCalledWith(0);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe('TypedVariableInput - editor onChange propagation', () => {
|
|
140
|
+
it('forwards numeric edits via onChange', async () => {
|
|
141
|
+
const ctx = createContextWithEnv();
|
|
142
|
+
const handleChange = vi.fn();
|
|
143
|
+
renderWithCtx(ctx, <TypedVariableInput value={465} types={['number']} onChange={handleChange} />);
|
|
144
|
+
const numberInput = await screen.findByDisplayValue('465');
|
|
145
|
+
fireEvent.change(numberInput, { target: { value: '587' } });
|
|
146
|
+
// antd InputNumber may emit on blur; force blur to flush
|
|
147
|
+
fireEvent.blur(numberInput);
|
|
148
|
+
expect(handleChange).toHaveBeenCalled();
|
|
149
|
+
const lastCall = handleChange.mock.calls[handleChange.mock.calls.length - 1][0];
|
|
150
|
+
expect(lastCall).toBe(587);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This file is part of the NocoBase (R) project.
|
|
3
|
+
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
|
4
|
+
* Authors: NocoBase Team.
|
|
5
|
+
*
|
|
6
|
+
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
|
7
|
+
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
11
|
+
import { actionLinkageRules } from '../linkageRules';
|
|
12
|
+
|
|
13
|
+
function createRule(actions: any[]) {
|
|
14
|
+
return {
|
|
15
|
+
key: 'r1',
|
|
16
|
+
title: 'r1',
|
|
17
|
+
enable: true,
|
|
18
|
+
condition: { logic: '$and', items: [] },
|
|
19
|
+
actions,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('linkageRules hidden state propagation', () => {
|
|
24
|
+
it('restores hidden state to same-target models when reverting to original props', async () => {
|
|
25
|
+
const blockModel = { uid: 'block-1' };
|
|
26
|
+
|
|
27
|
+
const hostModel: any = {
|
|
28
|
+
uid: 'host',
|
|
29
|
+
hidden: false,
|
|
30
|
+
context: { blockModel },
|
|
31
|
+
__allModels: [],
|
|
32
|
+
setProps: vi.fn(),
|
|
33
|
+
getFlow: vi.fn(() => ({})),
|
|
34
|
+
getStepParams: vi.fn((_flowKey: string, stepKey: string) => {
|
|
35
|
+
if (stepKey === 'init') return { fieldPath: 'org_m2o' };
|
|
36
|
+
return undefined;
|
|
37
|
+
}),
|
|
38
|
+
translate: (s: string) => s,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const sameTargetModel: any = {
|
|
42
|
+
uid: 'same-target',
|
|
43
|
+
hidden: false,
|
|
44
|
+
context: { blockModel },
|
|
45
|
+
setProps: vi.fn(),
|
|
46
|
+
getStepParams: vi.fn((_flowKey: string, stepKey: string) => {
|
|
47
|
+
if (stepKey === 'init') return { fieldPath: 'org_m2o' };
|
|
48
|
+
return undefined;
|
|
49
|
+
}),
|
|
50
|
+
translate: (s: string) => s,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const engine = {
|
|
54
|
+
forEachModel: (visitor: (m: any) => void) => {
|
|
55
|
+
[hostModel, sameTargetModel].forEach(visitor);
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const ctx: any = {
|
|
60
|
+
flowKey: 'buttonSettings',
|
|
61
|
+
model: hostModel,
|
|
62
|
+
engine,
|
|
63
|
+
app: {
|
|
64
|
+
jsonLogic: {
|
|
65
|
+
apply: vi.fn(() => true),
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
resolveJsonTemplate: vi.fn(async (value: any) => value),
|
|
69
|
+
getAction: (name: string) => {
|
|
70
|
+
if (name !== 'linkageSetActionProps') return undefined;
|
|
71
|
+
return {
|
|
72
|
+
handler: async (_ctx: any, params: any) => {
|
|
73
|
+
params.setProps(hostModel, {
|
|
74
|
+
hiddenModel: params.value === 'hidden',
|
|
75
|
+
disabled: false,
|
|
76
|
+
hiddenText: false,
|
|
77
|
+
});
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
await actionLinkageRules.handler(ctx, {
|
|
84
|
+
value: [
|
|
85
|
+
createRule([
|
|
86
|
+
{
|
|
87
|
+
name: 'linkageSetActionProps',
|
|
88
|
+
params: { value: 'hidden' },
|
|
89
|
+
},
|
|
90
|
+
]),
|
|
91
|
+
],
|
|
92
|
+
});
|
|
93
|
+
expect(hostModel.hidden).toBe(true);
|
|
94
|
+
expect(sameTargetModel.hidden).toBe(true);
|
|
95
|
+
|
|
96
|
+
await actionLinkageRules.handler(ctx, { value: [] });
|
|
97
|
+
expect(hostModel.hidden).toBe(false);
|
|
98
|
+
expect(sameTargetModel.hidden).toBe(false);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -46,7 +46,7 @@ describe('linkageRulesRefresh action', () => {
|
|
|
46
46
|
expect(handler).not.toHaveBeenCalled();
|
|
47
47
|
});
|
|
48
48
|
|
|
49
|
-
it('
|
|
49
|
+
it('skips master model when forks can handle flow even if regular action buttons do not attach refs', async () => {
|
|
50
50
|
const handler = vi.fn(async () => {});
|
|
51
51
|
const model: any = {
|
|
52
52
|
isFork: false,
|
|
@@ -74,6 +74,28 @@ describe('linkageRulesRefresh action', () => {
|
|
|
74
74
|
flowKey: 'buttonSettings',
|
|
75
75
|
});
|
|
76
76
|
|
|
77
|
+
expect(handler).not.toHaveBeenCalled();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('runs linkage action on master model when no forks exist in runtime mode', async () => {
|
|
81
|
+
const handler = vi.fn(async () => {});
|
|
82
|
+
const model: any = {
|
|
83
|
+
isFork: false,
|
|
84
|
+
forks: new Set(),
|
|
85
|
+
getFlow: vi.fn(() => ({})),
|
|
86
|
+
getStepParams: vi.fn(() => ({ value: ['master-runtime'] })),
|
|
87
|
+
};
|
|
88
|
+
const ctx: any = {
|
|
89
|
+
model,
|
|
90
|
+
resolveJsonTemplate: vi.fn(async (p: any) => p),
|
|
91
|
+
getAction: vi.fn(() => ({ handler })),
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
await linkageRulesRefresh.handler(ctx, {
|
|
95
|
+
actionName: 'actionLinkageRules',
|
|
96
|
+
flowKey: 'buttonSettings',
|
|
97
|
+
});
|
|
98
|
+
|
|
77
99
|
expect(handler).toHaveBeenCalledWith(ctx, { value: ['master-runtime'] });
|
|
78
100
|
});
|
|
79
101
|
|
|
@@ -2099,16 +2099,17 @@ const commonLinkageRulesHandler = async (ctx: FlowContext, params: any) => {
|
|
|
2099
2099
|
mergedByUid.forEach((model: any, uid) => {
|
|
2100
2100
|
const patchProps = mergedPropsByUid.get(uid) || {};
|
|
2101
2101
|
const newProps = { ...model.__originalProps, ...patchProps };
|
|
2102
|
-
const
|
|
2102
|
+
const prevHidden = !!model.hidden;
|
|
2103
|
+
const nextHidden = !!newProps.hiddenModel;
|
|
2103
2104
|
|
|
2104
2105
|
model.setProps(_.omit(newProps, ['hiddenModel', 'value', 'hiddenText']));
|
|
2105
2106
|
syncFieldOptionsToForks(model, patchProps);
|
|
2106
2107
|
if (typeof model.setHidden === 'function') {
|
|
2107
2108
|
model.setHidden(!!newProps.hiddenModel);
|
|
2108
2109
|
} else {
|
|
2109
|
-
model.hidden =
|
|
2110
|
-
if (
|
|
2111
|
-
hiddenStatePatches.push({ model, hidden:
|
|
2110
|
+
model.hidden = nextHidden;
|
|
2111
|
+
if (prevHidden !== nextHidden) {
|
|
2112
|
+
hiddenStatePatches.push({ model, hidden: nextHidden });
|
|
2112
2113
|
}
|
|
2113
2114
|
}
|
|
2114
2115
|
|
|
@@ -41,16 +41,15 @@ export const linkageRulesRefresh = defineAction({
|
|
|
41
41
|
const flowSettingsEnabled = Boolean(
|
|
42
42
|
(ctx as any)?.flowSettingsEnabled || (model as any)?.context?.flowSettingsEnabled,
|
|
43
43
|
);
|
|
44
|
-
const
|
|
44
|
+
const hasForkWithFlow =
|
|
45
45
|
!model?.isFork &&
|
|
46
46
|
!!model?.forks?.size &&
|
|
47
47
|
Array.from(model?.forks || []).some((fork: any) => {
|
|
48
48
|
if (!fork || fork.disposed) return false;
|
|
49
|
-
if (!fork?.context?.ref?.current) return false;
|
|
50
49
|
return !!fork?.getFlow?.(flowKey);
|
|
51
50
|
});
|
|
52
51
|
const isMasterMounted = Boolean((model as any)?.context?.ref?.current);
|
|
53
|
-
if (
|
|
52
|
+
if (hasForkWithFlow && !isMasterMounted && !flowSettingsEnabled) {
|
|
54
53
|
return;
|
|
55
54
|
}
|
|
56
55
|
|
|
@@ -11,44 +11,97 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
|
11
11
|
|
|
12
12
|
// Mock engine api provider
|
|
13
13
|
vi.mock('@nocobase/flow-engine', () => {
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
14
|
+
const baseDocProperties = {
|
|
15
|
+
foo: 'foo prop',
|
|
16
|
+
api: {
|
|
17
|
+
description: 'api client',
|
|
18
|
+
completion: { insertText: 'ctx.api' },
|
|
19
|
+
properties: {
|
|
20
|
+
request: {
|
|
21
|
+
description: 'send request',
|
|
22
|
+
completion: { insertText: "await ctx.api.request({ url: '', method: 'get' })" },
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
viewer: {
|
|
27
|
+
description: 'viewer helpers',
|
|
28
|
+
properties: {
|
|
29
|
+
popover: {
|
|
30
|
+
type: 'function',
|
|
31
|
+
description: 'open popover',
|
|
32
|
+
completion: {
|
|
33
|
+
insertText: 'await ctx.viewer.popover({ target: ctx.element?.__el, content: <div /> })',
|
|
34
|
+
requires: ['element'],
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
embed: {
|
|
38
|
+
type: 'function',
|
|
39
|
+
description: 'open embed',
|
|
40
|
+
completion: {
|
|
41
|
+
insertText: 'await ctx.viewer.embed({ target: ctx.element?.__el, content: <div /> })',
|
|
42
|
+
requires: ['element'],
|
|
24
43
|
},
|
|
25
44
|
},
|
|
26
45
|
},
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
46
|
+
},
|
|
47
|
+
popup: {
|
|
48
|
+
description: 'popup context (async)',
|
|
49
|
+
detail: 'Promise<any>',
|
|
50
|
+
hidden: async (ctx: any) => !(await ctx?.popup)?.uid,
|
|
51
|
+
properties: {
|
|
52
|
+
uid: 'popup uid',
|
|
53
|
+
record: 'popup record',
|
|
54
|
+
parent: {
|
|
55
|
+
properties: {
|
|
56
|
+
record: 'parent record',
|
|
38
57
|
},
|
|
39
58
|
},
|
|
40
59
|
},
|
|
41
60
|
},
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
61
|
+
};
|
|
62
|
+
const methods = {
|
|
63
|
+
bar: {
|
|
64
|
+
description: 'bar method',
|
|
65
|
+
completion: { insertText: "ctx.bar('value')" },
|
|
66
|
+
},
|
|
67
|
+
render: {
|
|
68
|
+
description: 'render react content',
|
|
69
|
+
completion: { insertText: 'ctx.render(<div />)', requires: ['element'] },
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
const baseDoc = {
|
|
73
|
+
properties: baseDocProperties,
|
|
74
|
+
methods,
|
|
75
|
+
};
|
|
76
|
+
const domDoc = {
|
|
77
|
+
properties: {
|
|
78
|
+
...baseDocProperties,
|
|
79
|
+
element: {
|
|
80
|
+
description: 'DOM container',
|
|
81
|
+
detail: 'ElementProxy',
|
|
82
|
+
properties: {
|
|
83
|
+
innerHTML: 'inner html',
|
|
84
|
+
setAttribute: {
|
|
85
|
+
type: 'function',
|
|
86
|
+
detail: '(name: string, value: string) => void',
|
|
87
|
+
completion: { insertText: "ctx.element.setAttribute('data-key', 'value')" },
|
|
88
|
+
},
|
|
89
|
+
},
|
|
46
90
|
},
|
|
47
91
|
},
|
|
92
|
+
methods,
|
|
48
93
|
};
|
|
94
|
+
const domModels = new Set([
|
|
95
|
+
'JSBlockModel',
|
|
96
|
+
'JSFieldModel',
|
|
97
|
+
'JSEditableFieldModel',
|
|
98
|
+
'JSItemModel',
|
|
99
|
+
'JSColumnModel',
|
|
100
|
+
'FormJSFieldItemModel',
|
|
101
|
+
]);
|
|
49
102
|
return {
|
|
50
|
-
getRunJSDocFor: () =>
|
|
51
|
-
FlowRunJSContext: { getDoc: () =>
|
|
103
|
+
getRunJSDocFor: (ctx: any) => (domModels.has(ctx?.model?.constructor?.name) ? domDoc : baseDoc),
|
|
104
|
+
FlowRunJSContext: { getDoc: () => baseDoc },
|
|
52
105
|
// New cohesive APIs
|
|
53
106
|
listSnippetsForContext: async () => [
|
|
54
107
|
{
|
|
@@ -69,6 +122,17 @@ vi.mock('@nocobase/flow-engine', () => {
|
|
|
69
122
|
import { buildRunJSCompletions } from '../runjsCompletions';
|
|
70
123
|
|
|
71
124
|
describe('buildRunJSCompletions', () => {
|
|
125
|
+
const labelsOf = (completions: any[]) => new Set(completions.map((c: any) => c.label));
|
|
126
|
+
|
|
127
|
+
const appliedTextsOf = (completions: any[]) =>
|
|
128
|
+
completions.flatMap((completion: any) => {
|
|
129
|
+
const mockView = { dispatch: vi.fn() } as any;
|
|
130
|
+
if (typeof completion.apply === 'function') {
|
|
131
|
+
completion.apply(mockView, completion, 0, 0);
|
|
132
|
+
}
|
|
133
|
+
return mockView.dispatch.mock.calls.map((call: any[]) => String(call[0]?.changes?.insert || ''));
|
|
134
|
+
});
|
|
135
|
+
|
|
72
136
|
it('builds ctx property/method completions and snippets', async () => {
|
|
73
137
|
const hostCtx = { popup: Promise.resolve({ uid: 'p1' }) }; // not used since engine doc is mocked
|
|
74
138
|
const { completions, entries } = await buildRunJSCompletions(hostCtx, 'v1', 'block');
|
|
@@ -242,4 +306,185 @@ describe('buildRunJSCompletions', () => {
|
|
|
242
306
|
// restore
|
|
243
307
|
engineDoc.properties.popup.hidden = prev;
|
|
244
308
|
});
|
|
309
|
+
|
|
310
|
+
it('adds safe browser/runtime global completions and excludes blocked globals', async () => {
|
|
311
|
+
const { completions } = await buildRunJSCompletions({}, 'v1', 'block');
|
|
312
|
+
const labels = new Set(completions.map((c: any) => c.label));
|
|
313
|
+
|
|
314
|
+
expect(labels.has('window.location.reload()')).toBe(true);
|
|
315
|
+
expect(labels.has('document.createElement()')).toBe(true);
|
|
316
|
+
expect(labels.has('navigator.clipboard.writeText()')).toBe(true);
|
|
317
|
+
expect(labels.has('console.log()')).toBe(true);
|
|
318
|
+
expect(labels.has('setTimeout()')).toBe(true);
|
|
319
|
+
expect(labels.has('Blob')).toBe(true);
|
|
320
|
+
expect(labels.has('URL')).toBe(true);
|
|
321
|
+
|
|
322
|
+
expect(labels.has('navigator.userAgent')).toBe(false);
|
|
323
|
+
expect(labels.has('navigator.geolocation')).toBe(false);
|
|
324
|
+
expect(labels.has('document.cookie')).toBe(false);
|
|
325
|
+
expect(labels.has('document.body')).toBe(false);
|
|
326
|
+
expect(labels.has('document.getElementById()')).toBe(false);
|
|
327
|
+
expect(labels.has('window.location.href')).toBe(false);
|
|
328
|
+
expect(labels.has('window.location.href =')).toBe(true);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it('adds stable ctx runtime namespace completions', async () => {
|
|
332
|
+
const hostCtx = {
|
|
333
|
+
model: {
|
|
334
|
+
constructor: {
|
|
335
|
+
name: 'JSBlockModel',
|
|
336
|
+
},
|
|
337
|
+
},
|
|
338
|
+
};
|
|
339
|
+
const { completions } = await buildRunJSCompletions(hostCtx, 'v1', 'block');
|
|
340
|
+
const labels = labelsOf(completions);
|
|
341
|
+
|
|
342
|
+
expect(labels.has('ctx.runjs()')).toBe(true);
|
|
343
|
+
expect(labels.has('ctx.sql.run()')).toBe(true);
|
|
344
|
+
expect(labels.has('ctx.logger.info()')).toBe(true);
|
|
345
|
+
expect(labels.has('ctx.element.setAttribute()')).toBe(true);
|
|
346
|
+
expect(labels.has('ctx.libs.React.createElement()')).toBe(true);
|
|
347
|
+
expect(labels.has('ctx.libs.lodash.get()')).toBe(true);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it('does not expose ctx.element for generic base completions', async () => {
|
|
351
|
+
const { completions } = await buildRunJSCompletions({}, 'v1', 'block');
|
|
352
|
+
const labels = labelsOf(completions);
|
|
353
|
+
const appliedTexts = appliedTextsOf(completions);
|
|
354
|
+
|
|
355
|
+
expect([...labels].some((label) => String(label).startsWith('ctx.element'))).toBe(false);
|
|
356
|
+
expect(labels.has('ctx.render()')).toBe(false);
|
|
357
|
+
expect(appliedTexts.some((text) => /\bctx\.element\b/.test(text))).toBe(false);
|
|
358
|
+
expect(appliedTexts.some((text) => text === 'ctx.render(<div />)')).toBe(false);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it('keeps ctx.element completions for DOM container models', async () => {
|
|
362
|
+
const hostCtx = {
|
|
363
|
+
model: {
|
|
364
|
+
constructor: {
|
|
365
|
+
name: 'JSBlockModel',
|
|
366
|
+
},
|
|
367
|
+
},
|
|
368
|
+
};
|
|
369
|
+
const { completions } = await buildRunJSCompletions(hostCtx, 'v1', 'block');
|
|
370
|
+
const labels = labelsOf(completions);
|
|
371
|
+
|
|
372
|
+
expect(labels.has('ctx.element.innerHTML')).toBe(true);
|
|
373
|
+
expect(labels.has('ctx.element.setAttribute()')).toBe(true);
|
|
374
|
+
expect(labels.has('ctx.libs.ReactDOM.createRoot()')).toBe(true);
|
|
375
|
+
expect(labels.has('ctx.render()')).toBe(true);
|
|
376
|
+
|
|
377
|
+
const renderCompletion = completions.find((c: any) => c.label === 'ctx.render()');
|
|
378
|
+
const mockView = { dispatch: vi.fn() } as any;
|
|
379
|
+
(renderCompletion as any).apply?.(mockView, renderCompletion, 0, 0);
|
|
380
|
+
expect(mockView.dispatch.mock.calls[0][0]?.changes?.insert).toBe('ctx.render(<div />)');
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it('hides ctx.element-dependent completions for eventFlow', async () => {
|
|
384
|
+
const hostCtx = {
|
|
385
|
+
model: {
|
|
386
|
+
constructor: {
|
|
387
|
+
name: 'JSBlockModel',
|
|
388
|
+
},
|
|
389
|
+
},
|
|
390
|
+
};
|
|
391
|
+
const { completions } = await buildRunJSCompletions(hostCtx, 'v1', 'eventFlow');
|
|
392
|
+
const labels = labelsOf(completions);
|
|
393
|
+
const appliedTexts = appliedTextsOf(completions);
|
|
394
|
+
|
|
395
|
+
expect([...labels].some((label) => String(label).startsWith('ctx.element'))).toBe(false);
|
|
396
|
+
expect(labels.has('ctx.libs.ReactDOM.createRoot()')).toBe(false);
|
|
397
|
+
expect(labels.has('ctx.ReactDOM.createRoot()')).toBe(false);
|
|
398
|
+
expect(labels.has('ctx.viewer.popover()')).toBe(false);
|
|
399
|
+
expect(labels.has('ctx.viewer.embed()')).toBe(false);
|
|
400
|
+
expect(labels.has('ctx.render()')).toBe(false);
|
|
401
|
+
expect(appliedTexts.some((text) => /\bctx\.element\b/.test(text))).toBe(false);
|
|
402
|
+
expect(appliedTexts.some((text) => text === 'ctx.render(<div />)')).toBe(false);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it('hides ctx.element-dependent completions for non-DOM action models', async () => {
|
|
406
|
+
for (const [modelName, scene] of [
|
|
407
|
+
['JSRecordActionModel', 'table'],
|
|
408
|
+
['JSFormActionModel', 'form'],
|
|
409
|
+
['FilterFormJSActionModel', 'form'],
|
|
410
|
+
]) {
|
|
411
|
+
const hostCtx = {
|
|
412
|
+
model: {
|
|
413
|
+
constructor: {
|
|
414
|
+
name: modelName,
|
|
415
|
+
},
|
|
416
|
+
},
|
|
417
|
+
};
|
|
418
|
+
const { completions } = await buildRunJSCompletions(hostCtx, 'v1', scene);
|
|
419
|
+
const labels = labelsOf(completions);
|
|
420
|
+
const appliedTexts = appliedTextsOf(completions);
|
|
421
|
+
|
|
422
|
+
expect([...labels].some((label) => String(label).startsWith('ctx.element'))).toBe(false);
|
|
423
|
+
expect(labels.has('ctx.libs.ReactDOM.createRoot()')).toBe(false);
|
|
424
|
+
expect(labels.has('ctx.ReactDOM.createRoot()')).toBe(false);
|
|
425
|
+
expect(labels.has('ctx.render()')).toBe(false);
|
|
426
|
+
expect(appliedTexts.some((text) => /\bctx\.element\b/.test(text))).toBe(false);
|
|
427
|
+
expect(appliedTexts.some((text) => text === 'ctx.render(<div />)')).toBe(false);
|
|
428
|
+
}
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it('uses friendly insertText for ctx.loadCSS and ctx.previewRunJS', async () => {
|
|
432
|
+
const { completions } = await buildRunJSCompletions({}, 'v1', 'block');
|
|
433
|
+
|
|
434
|
+
for (const [label, expected] of [
|
|
435
|
+
['ctx.loadCSS()', "await ctx.loadCSS('https://example.com/style.css')"],
|
|
436
|
+
['ctx.previewRunJS()', "await ctx.previewRunJS('console.log(1)', 'v2')"],
|
|
437
|
+
['ctx.resolveJsonTemplate()', "await ctx.resolveJsonTemplate({ value: '{{ctx.record.id}}' })"],
|
|
438
|
+
]) {
|
|
439
|
+
const completion = completions.find((c: any) => c.label === label);
|
|
440
|
+
expect(completion).toBeTruthy();
|
|
441
|
+
expect(typeof (completion as any).apply).toBe('function');
|
|
442
|
+
|
|
443
|
+
const mockView = { dispatch: vi.fn() } as any;
|
|
444
|
+
(completion as any).apply?.(mockView, completion, 0, 0);
|
|
445
|
+
expect(mockView.dispatch.mock.calls[0][0]?.changes?.insert).toBe(expected);
|
|
446
|
+
expect(mockView.dispatch.mock.calls[0][0]?.changes?.insert).not.toBe(label);
|
|
447
|
+
}
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
it('requests completion metadata from ctx.getApiInfos() for editor completions', async () => {
|
|
451
|
+
const getApiInfos = vi.fn(async (options?: any) => ({
|
|
452
|
+
customRuntime: {
|
|
453
|
+
type: 'function',
|
|
454
|
+
description: 'custom runtime helper',
|
|
455
|
+
completion: {
|
|
456
|
+
insertText: options?.includeCompletion ? "await ctx.customRuntime('value')" : undefined,
|
|
457
|
+
},
|
|
458
|
+
},
|
|
459
|
+
}));
|
|
460
|
+
|
|
461
|
+
const { completions } = await buildRunJSCompletions({ getApiInfos }, 'v1', 'block');
|
|
462
|
+
|
|
463
|
+
expect(getApiInfos).toHaveBeenCalledWith({ version: 'v1', includeCompletion: true });
|
|
464
|
+
const completion = completions.find((c: any) => c.label === 'ctx.customRuntime()');
|
|
465
|
+
expect(completion).toBeTruthy();
|
|
466
|
+
|
|
467
|
+
const mockView = { dispatch: vi.fn() } as any;
|
|
468
|
+
(completion as any).apply?.(mockView, completion, 0, 0);
|
|
469
|
+
expect(mockView.dispatch.mock.calls[0][0]?.changes?.insert).toBe("await ctx.customRuntime('value')");
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
it('keeps friendly insertText for ctx.resolveJsonTemplate with Chinese docs', async () => {
|
|
473
|
+
const hostCtx = {
|
|
474
|
+
api: {
|
|
475
|
+
auth: {
|
|
476
|
+
locale: 'zh-CN',
|
|
477
|
+
},
|
|
478
|
+
},
|
|
479
|
+
};
|
|
480
|
+
const { completions } = await buildRunJSCompletions(hostCtx, 'v1', 'block');
|
|
481
|
+
const completion = completions.find((c: any) => c.label === 'ctx.resolveJsonTemplate()');
|
|
482
|
+
expect(completion).toBeTruthy();
|
|
483
|
+
|
|
484
|
+
const mockView = { dispatch: vi.fn() } as any;
|
|
485
|
+
(completion as any).apply?.(mockView, completion, 0, 0);
|
|
486
|
+
expect(mockView.dispatch.mock.calls[0][0]?.changes?.insert).toBe(
|
|
487
|
+
"await ctx.resolveJsonTemplate({ value: '{{ctx.record.id}}' })",
|
|
488
|
+
);
|
|
489
|
+
});
|
|
245
490
|
});
|