@dolphy-app/extension-sdk 0.3.0 → 0.5.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.
@@ -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".