@dolphy-app/extension-sdk 0.4.0 → 0.6.0
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/README.md +28 -14
- package/dist/client.d.ts +40 -0
- package/dist/client.js +67 -0
- package/dist/define-entry-BTz2F3qv.js +22 -0
- package/dist/define-entry-lsuxzKdD.d.ts +77 -0
- package/dist/index-I_CXC6Lj.d.ts +1958 -0
- package/dist/index.d.ts +7 -111
- package/dist/index.js +5 -86
- package/dist/react.d.ts +51 -0
- package/dist/react.js +112 -0
- package/dist/rpc.d.ts +28 -0
- package/dist/rpc.js +37 -0
- package/dist/testing.d.ts +184 -241
- package/dist/testing.js +574 -464
- package/docs/debugging.md +82 -64
- package/docs/no-build.md +84 -48
- package/docs/quick-start.md +78 -56
- package/docs/recipe-command-panel.md +265 -118
- package/docs/recipe-event-storage.md +188 -128
- package/docs/recipe-exercise-type.md +271 -183
- package/docs/recipe-hooks.md +158 -0
- package/docs/recipe-import-export.md +48 -53
- package/docs/recipe-mountable.md +322 -0
- package/docs/recipe-react.md +352 -0
- package/docs/recipe-rpc-and-app.md +366 -0
- package/docs/recipe-settings.md +122 -100
- package/docs/recipe-theme.md +72 -40
- package/docs/recipe-when-dependencies.md +188 -82
- package/package.json +29 -7
- package/dist/answer-element-BOQcuxYh.js +0 -114
- package/dist/answer-view-D6wnyThb.d.ts +0 -28
- package/dist/runtime.d.ts +0 -17
- package/dist/runtime.js +0 -24
- package/docs/recipe-ui-kit.md +0 -172
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
# Recipe: a panel in React
|
|
2
|
+
|
|
3
|
+
A panel, an injection, an answer view or a markdown block can be drawn by
|
|
4
|
+
React instead of Vue: the extension turns its React component into a
|
|
5
|
+
`Mountable` with `reactComponent`, and the app gives it an element to draw
|
|
6
|
+
into. Several frameworks live in one window at once; an error in one
|
|
7
|
+
component replaces only that component with an error card. This recipe is the
|
|
8
|
+
`react-panel` template
|
|
9
|
+
(`npx @dolphy-app/create-extension <dir> --id acme.hello --template react-panel`).
|
|
10
|
+
The files below are exactly what the generator writes for the id `acme.hello`.
|
|
11
|
+
The commands and the panel are the ones of
|
|
12
|
+
[recipe-command-panel.md](recipe-command-panel.md); only the panel changes. For a
|
|
13
|
+
framework without a preset (Svelte, Lit, Solid) see
|
|
14
|
+
[recipe-mountable.md](recipe-mountable.md).
|
|
15
|
+
|
|
16
|
+
## The manifest and the build config
|
|
17
|
+
|
|
18
|
+
File `extension.json` (react-panel):
|
|
19
|
+
|
|
20
|
+
```json
|
|
21
|
+
{
|
|
22
|
+
"$schema": "./node_modules/@dolphy-app/extension-api/dist/extension.schema.json",
|
|
23
|
+
"id": "acme.hello",
|
|
24
|
+
"version": "0.1.0",
|
|
25
|
+
"apiVersion": 1,
|
|
26
|
+
"name": "Hello React panel",
|
|
27
|
+
"description": "Palette commands that greet the learner and open a panel drawn with React.",
|
|
28
|
+
"author": "your-github-login",
|
|
29
|
+
"tags": ["productivity"]
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
File `dolphy-ext.config.json` (react-panel):
|
|
34
|
+
|
|
35
|
+
```json
|
|
36
|
+
{
|
|
37
|
+
"frameworks": ["react"]
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`"frameworks"` lists the UI frameworks the client part is built for. The
|
|
42
|
+
default is `["vue"]`, which is always on; `"react"` adds the React build: `.tsx`
|
|
43
|
+
and `.jsx` files are compiled with the automatic JSX runtime (no `import React`),
|
|
44
|
+
and `react` and `react-dom` go into `client.mjs`. They are ordinary
|
|
45
|
+
dependencies of the project:
|
|
46
|
+
|
|
47
|
+
```sh
|
|
48
|
+
pnpm add -D react react-dom @types/react @types/react-dom
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The TypeScript side needs the JSX mode in `tsconfig.json`:
|
|
52
|
+
|
|
53
|
+
<!-- fragment -->
|
|
54
|
+
|
|
55
|
+
```json
|
|
56
|
+
{
|
|
57
|
+
"compilerOptions": {
|
|
58
|
+
"jsx": "react-jsx"
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
A `.tsx` file that has JSX but no `"react"` in `frameworks` fails the build
|
|
64
|
+
with a message that names the config.
|
|
65
|
+
|
|
66
|
+
## The server part
|
|
67
|
+
|
|
68
|
+
File `src/index.ts` (react-panel):
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
export { client } from './client.tsx';
|
|
72
|
+
export { server } from './server.ts';
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
File `src/server.ts` (react-panel):
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
import { defineServer, notify, openPanel } from '@dolphy-app/extension-sdk';
|
|
79
|
+
|
|
80
|
+
// runs in the extension host: every call registers a contribution
|
|
81
|
+
export const server = defineServer((s) => {
|
|
82
|
+
// palette command: shows a notification
|
|
83
|
+
s.registerCommand({
|
|
84
|
+
id: 'acme.hello.hello',
|
|
85
|
+
title: { en: 'Say hello', ru: 'Поздороваться' },
|
|
86
|
+
category: 'Hello',
|
|
87
|
+
run: (args) => {
|
|
88
|
+
const name = typeof args === 'string' ? args : 'world';
|
|
89
|
+
return notify(`Hello, ${name}!`);
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// palette command: opens the panel (registered by the client) with properties
|
|
94
|
+
s.registerCommand({
|
|
95
|
+
id: 'acme.hello.open',
|
|
96
|
+
title: { en: 'Open the hello panel', ru: 'Открыть панель' },
|
|
97
|
+
category: 'Hello',
|
|
98
|
+
run: () => openPanel('acme.hello.view', { name: 'Dolphy' }),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// hidden from the palette (palette: false): the panel asks for data
|
|
102
|
+
s.registerCommand({
|
|
103
|
+
id: 'acme.hello.data',
|
|
104
|
+
title: 'Hello panel data',
|
|
105
|
+
palette: false,
|
|
106
|
+
run: () => ({ message: 'Hello from acme.hello' }),
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
The server part is the same as in the command and panel recipe: it has no UI
|
|
112
|
+
and never sees React.
|
|
113
|
+
|
|
114
|
+
## The client part
|
|
115
|
+
|
|
116
|
+
File `src/client.tsx` (react-panel):
|
|
117
|
+
|
|
118
|
+
```tsx
|
|
119
|
+
import { defineClient } from '@dolphy-app/extension-sdk';
|
|
120
|
+
import type { PanelHandle, PanelProps } from '@dolphy-app/extension-sdk';
|
|
121
|
+
import { reactComponent, usePanel } from '@dolphy-app/extension-sdk/react';
|
|
122
|
+
import { useEffect, useState } from 'react';
|
|
123
|
+
|
|
124
|
+
// `reactComponent` draws this component with React and gives it the props of
|
|
125
|
+
// the panel; `usePanel()` is the handle with `call` for the commands of the
|
|
126
|
+
// extension
|
|
127
|
+
const HelloPanel = ({ props }: PanelProps) => {
|
|
128
|
+
const panel = usePanel();
|
|
129
|
+
const [message, setMessage] = useState('');
|
|
130
|
+
// the app opens the panel again with new properties: the component renders again
|
|
131
|
+
const name =
|
|
132
|
+
typeof props === 'object' && props !== null && 'name' in props
|
|
133
|
+
? String(props.name)
|
|
134
|
+
: 'world';
|
|
135
|
+
|
|
136
|
+
const load = async () => {
|
|
137
|
+
const data = await panel.call('acme.hello.data');
|
|
138
|
+
setMessage((data as { message: string }).message);
|
|
139
|
+
};
|
|
140
|
+
useEffect(() => {
|
|
141
|
+
void load();
|
|
142
|
+
}, []);
|
|
143
|
+
|
|
144
|
+
return (
|
|
145
|
+
<section>
|
|
146
|
+
<h2>Hello, {name}!</h2>
|
|
147
|
+
<p>{message}</p>
|
|
148
|
+
<button type="button" onClick={() => void load()}>
|
|
149
|
+
Reload
|
|
150
|
+
</button>
|
|
151
|
+
</section>
|
|
152
|
+
);
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
// runs in the app window: the panel is a `Mountable` the app draws into its own element
|
|
156
|
+
export const client = defineClient((c) => {
|
|
157
|
+
c.addPanel({
|
|
158
|
+
id: 'acme.hello.view',
|
|
159
|
+
title: { en: 'Hello', ru: 'Привет' },
|
|
160
|
+
component: reactComponent<PanelProps, PanelHandle>(HelloPanel),
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
- `reactComponent(Component)` from `@dolphy-app/extension-sdk/react` returns a
|
|
166
|
+
`Mountable`. It draws `<Component {...props} />` with `createRoot` into the
|
|
167
|
+
element the app gives it and draws again when the props, the theme or the
|
|
168
|
+
language change. `reactComponent<PanelProps, PanelHandle>(Panel)` names the
|
|
169
|
+
surface: the props type is `PanelProps` (`panelId`, `props`, `context`) in a
|
|
170
|
+
panel, `InjectionProps` in an injection, `AnswerViewProps` in an answer view
|
|
171
|
+
and `MarkdownBlockProps` in a markdown renderer. `{ strictMode: true }` as
|
|
172
|
+
the second argument wraps the tree in `React.StrictMode`.
|
|
173
|
+
- The props of the component are `ctx.props`: here `props` is what the panel
|
|
174
|
+
was opened with, and a repeated `openPanel` with new properties draws the
|
|
175
|
+
component again.
|
|
176
|
+
- The hooks `useApp()`, `useEngine()`, `useRpc(contract)`, `usePanel()` and
|
|
177
|
+
`useInjection()` are the ones of a Vue component, on React context.
|
|
178
|
+
`useTheme()` and `useLocale()` return the theme and the language and render
|
|
179
|
+
the component again when they change; `useMountContext()` is the whole
|
|
180
|
+
`MountContext` (`emit`, `signal`, `reportError`, …). Outside a component that
|
|
181
|
+
`reactComponent` draws they throw.
|
|
182
|
+
- An error of the render goes to `ctx.reportError` by itself, and the app shows
|
|
183
|
+
the card "Extension <name>: <error>" with a "Retry" button in place of the
|
|
184
|
+
panel; the rest of the window works. React does not catch the errors of an
|
|
185
|
+
event handler or of async code: catch them there and call
|
|
186
|
+
`useMountContext().reportError(error)`.
|
|
187
|
+
|
|
188
|
+
## The tests
|
|
189
|
+
|
|
190
|
+
File `test/index.test.ts` (react-panel):
|
|
191
|
+
|
|
192
|
+
```ts
|
|
193
|
+
// @vitest-environment happy-dom
|
|
194
|
+
import { isMountable } from '@dolphy-app/extension-sdk';
|
|
195
|
+
import type { PanelHandle, PanelProps } from '@dolphy-app/extension-sdk';
|
|
196
|
+
import {
|
|
197
|
+
createTestClient,
|
|
198
|
+
createTestServer,
|
|
199
|
+
mountForTest,
|
|
200
|
+
} from '@dolphy-app/extension-sdk/testing';
|
|
201
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
202
|
+
import { client, server } from '../src/index.ts';
|
|
203
|
+
|
|
204
|
+
const disposables: { dispose(): unknown }[] = [];
|
|
205
|
+
afterEach(async () => {
|
|
206
|
+
await Promise.all(disposables.splice(0).map((item) => item.dispose()));
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
const start = async () => {
|
|
210
|
+
const running = await createTestServer(server, { extensionId: 'acme.hello' });
|
|
211
|
+
disposables.push(running);
|
|
212
|
+
return running;
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
describe('acme.hello: server', () => {
|
|
216
|
+
it('hello greets the name from the arguments, "world" without them', async () => {
|
|
217
|
+
const running = await start();
|
|
218
|
+
expect(await running.commands.run('acme.hello.hello', 'Ada')).toEqual({
|
|
219
|
+
kind: 'notify',
|
|
220
|
+
text: 'Hello, Ada!',
|
|
221
|
+
});
|
|
222
|
+
expect(await running.commands.run('acme.hello.hello')).toEqual({
|
|
223
|
+
kind: 'notify',
|
|
224
|
+
text: 'Hello, world!',
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('open asks the app to open the panel with properties', async () => {
|
|
229
|
+
const running = await start();
|
|
230
|
+
expect(await running.commands.run('acme.hello.open')).toEqual({
|
|
231
|
+
kind: 'openPanel',
|
|
232
|
+
panelId: 'acme.hello.view',
|
|
233
|
+
props: { name: 'Dolphy' },
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('data returns what the panel shows and stays out of the palette', async () => {
|
|
238
|
+
const running = await start();
|
|
239
|
+
expect(await running.commands.run('acme.hello.data')).toEqual({
|
|
240
|
+
kind: 'data',
|
|
241
|
+
value: { message: 'Hello from acme.hello' },
|
|
242
|
+
});
|
|
243
|
+
const hidden = running.registration.commands.find(
|
|
244
|
+
(command) => command.id === 'acme.hello.data',
|
|
245
|
+
);
|
|
246
|
+
expect(hidden?.palette).toBe(false);
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
describe('acme.hello: client', () => {
|
|
251
|
+
it('adds the panel that the open command points to', async () => {
|
|
252
|
+
const running = await createTestClient(client, { extensionId: 'acme.hello' });
|
|
253
|
+
disposables.push(running);
|
|
254
|
+
expect(running.panels.map((panel) => panel.id)).toEqual(['acme.hello.view']);
|
|
255
|
+
expect(isMountable(running.panels[0]?.component)).toBe(true);
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
// draws the panel the way the app does: into an element, on a context the test controls
|
|
260
|
+
const mountPanel = async (
|
|
261
|
+
props: PanelProps['props'],
|
|
262
|
+
call: PanelHandle['call'],
|
|
263
|
+
) => {
|
|
264
|
+
const running = await createTestClient(client, { extensionId: 'acme.hello' });
|
|
265
|
+
disposables.push(running);
|
|
266
|
+
const component = running.panels[0]?.component;
|
|
267
|
+
if (!isMountable(component)) throw new Error('the panel is not a Mountable');
|
|
268
|
+
const panelProps: PanelProps = {
|
|
269
|
+
panelId: 'acme.hello.view',
|
|
270
|
+
props,
|
|
271
|
+
context: { courseId: null },
|
|
272
|
+
};
|
|
273
|
+
const mounted = await mountForTest(component, {
|
|
274
|
+
props: panelProps,
|
|
275
|
+
handle: { ...panelProps, call },
|
|
276
|
+
});
|
|
277
|
+
disposables.push({ dispose: () => mounted.unmount() });
|
|
278
|
+
return { mounted, panelProps };
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
describe('acme.hello: panel', () => {
|
|
282
|
+
it('shows the data command reply and follows new properties', async () => {
|
|
283
|
+
const calls: string[] = [];
|
|
284
|
+
const { mounted, panelProps } = await mountPanel(
|
|
285
|
+
{ name: 'Ada' },
|
|
286
|
+
async (commandId) => {
|
|
287
|
+
calls.push(commandId);
|
|
288
|
+
return { message: 'Hello from the test' };
|
|
289
|
+
},
|
|
290
|
+
);
|
|
291
|
+
expect(mounted.el.querySelector('h2')?.textContent).toBe('Hello, Ada!');
|
|
292
|
+
await vi.waitFor(() =>
|
|
293
|
+
expect(mounted.el.querySelector('p')?.textContent).toBe(
|
|
294
|
+
'Hello from the test',
|
|
295
|
+
),
|
|
296
|
+
);
|
|
297
|
+
expect(calls).toEqual(['acme.hello.data']);
|
|
298
|
+
|
|
299
|
+
mounted.setProps({ ...panelProps, props: { name: 'Grace' } });
|
|
300
|
+
expect(mounted.el.querySelector('h2')?.textContent).toBe('Hello, Grace!');
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('greets the world when it is opened without properties', async () => {
|
|
304
|
+
const { mounted } = await mountPanel(undefined, async () => ({
|
|
305
|
+
message: 'x',
|
|
306
|
+
}));
|
|
307
|
+
expect(mounted.el.querySelector('h2')?.textContent).toBe('Hello, world!');
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it('asks the data command again when the button is pressed', async () => {
|
|
311
|
+
const calls: string[] = [];
|
|
312
|
+
const { mounted } = await mountPanel(undefined, async (commandId) => {
|
|
313
|
+
calls.push(commandId);
|
|
314
|
+
return { message: 'x' };
|
|
315
|
+
});
|
|
316
|
+
mounted.el.querySelector('button')?.click();
|
|
317
|
+
await vi.waitFor(() => expect(calls).toEqual(['acme.hello.data', 'acme.hello.data']));
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
`mountForTest(mountable, { props, handle, … })` from
|
|
323
|
+
`@dolphy-app/extension-sdk/testing` mounts the `Mountable` into a new `<div>` on
|
|
324
|
+
a context the test controls, in `happy-dom`. It resolves to `{ el, ctx,
|
|
325
|
+
setProps, setTheme, setLocale, emitted, errors, unmount }`: `setProps` draws
|
|
326
|
+
the component with the next props, `emitted` and `errors` list the calls of
|
|
327
|
+
`ctx.emit` and `ctx.reportError`, `handle` is what `usePanel()` returns.
|
|
328
|
+
`createTestClient` records the `Mountable` as the `component` of the panel. The
|
|
329
|
+
component is drawn synchronously, but what it loads in an effect arrives
|
|
330
|
+
later, so the test waits for it with `vi.waitFor`.
|
|
331
|
+
|
|
332
|
+
## What to know
|
|
333
|
+
|
|
334
|
+
- No Vuetify components and no overlays of the app (`VDialog`, `VMenu`) in a
|
|
335
|
+
React component: Vuetify is a Vue library. Draw the dialogs yourself. The
|
|
336
|
+
theme reaches you through `useTheme()` (`{ id, dark }`) and through the
|
|
337
|
+
CSS variables of the window, such as `rgb(var(--v-theme-primary))`.
|
|
338
|
+
- Every extension carries its own copy of React. A "hello, world" panel built
|
|
339
|
+
the way the tool builds it, without minification, is about 565 KB in
|
|
340
|
+
`client.mjs` and about 106 KB gzipped. There is no shared React between
|
|
341
|
+
extensions.
|
|
342
|
+
- A plain `import './panel.css'` fails the build. Import the style sheet as
|
|
343
|
+
text (`import css from './panel.css?inline'`) and render it in a `<style>`
|
|
344
|
+
element of the component, or use the `style` attribute. The window document
|
|
345
|
+
is shared, so give the class names a prefix that cannot clash with the app.
|
|
346
|
+
- Svelte, Solid and Lit have no preset; draw them from a `Mountable` (see
|
|
347
|
+
[recipe-mountable.md](recipe-mountable.md)) if you bundle them yourself.
|
|
348
|
+
|
|
349
|
+
## Try and ship
|
|
350
|
+
|
|
351
|
+
Run `pnpm dev`, start the app with `DOLPHY_DEV_EXTENSIONS`, open the palette and
|
|
352
|
+
run "Open the hello panel".
|