@oceanum/oceanumlab 4.7.1 → 4.8.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.
Files changed (51) hide show
  1. package/lib/DatameshWidget.d.ts +11 -0
  2. package/lib/DatameshWidget.js +35 -5
  3. package/lib/StoredNotebooks.d.ts +27 -5
  4. package/lib/StoredNotebooks.js +141 -24
  5. package/lib/auth/serverAuth.js +1 -0
  6. package/lib/auth/startSignIn.d.ts +11 -0
  7. package/lib/auth/startSignIn.js +16 -0
  8. package/lib/auth/tokens.d.ts +13 -0
  9. package/lib/index.d.ts +1 -1
  10. package/lib/index.js +43 -7
  11. package/lib/share/client.d.ts +86 -0
  12. package/lib/share/client.js +206 -0
  13. package/lib/share/events.d.ts +7 -0
  14. package/lib/share/events.js +9 -0
  15. package/lib/share/notebook.d.ts +216 -0
  16. package/lib/share/notebook.js +355 -0
  17. package/lib/share/oceanumOnly.d.ts +72 -0
  18. package/lib/share/oceanumOnly.js +127 -0
  19. package/lib/share/plugin.d.ts +23 -0
  20. package/lib/share/plugin.js +981 -0
  21. package/lib/share/upload.d.ts +6 -0
  22. package/lib/share/upload.js +27 -0
  23. package/lib/share/widgets.d.ts +25 -0
  24. package/lib/share/widgets.js +154 -0
  25. package/package.json +5 -1
  26. package/schema/datamesh-connect.json +6 -0
  27. package/src/DatameshWidget.tsx +79 -2
  28. package/src/StoredNotebooks.tsx +274 -24
  29. package/src/__tests__/OceanumPanel.spec.tsx +159 -5
  30. package/src/__tests__/StoredNotebooks.spec.tsx +528 -0
  31. package/src/__tests__/oceanumOnly.spec.ts +317 -0
  32. package/src/__tests__/settingsSchema.spec.ts +11 -1
  33. package/src/__tests__/shareClient.spec.ts +328 -0
  34. package/src/__tests__/shareNotebook.spec.ts +624 -0
  35. package/src/__tests__/sharePlugin.spec.ts +664 -0
  36. package/src/auth/serverAuth.ts +1 -0
  37. package/src/auth/startSignIn.ts +23 -0
  38. package/src/auth/tokens.ts +13 -0
  39. package/src/index.ts +43 -7
  40. package/src/share/client.ts +291 -0
  41. package/src/share/events.ts +10 -0
  42. package/src/share/notebook.ts +525 -0
  43. package/src/share/oceanumOnly.ts +177 -0
  44. package/src/share/plugin.ts +1220 -0
  45. package/src/share/upload.ts +27 -0
  46. package/src/share/widgets.ts +217 -0
  47. package/style/index.css +212 -18
  48. package/lib/specStore.d.ts +0 -37
  49. package/lib/specStore.js +0 -57
  50. package/src/__tests__/specStore.spec.ts +0 -126
  51. package/src/specStore.ts +0 -82
@@ -0,0 +1,528 @@
1
+ import { ReactWidget } from '@jupyterlab/ui-components';
2
+ import { Signal } from '@lumino/signaling';
3
+ import { Widget } from '@lumino/widgets';
4
+ import React from 'react';
5
+
6
+ import { IOceanumAuth, IOceanumUser } from '../auth/tokens';
7
+ import { notebooksChanged } from '../share/events';
8
+ import type { INotebookDemoItem } from '../share/notebook';
9
+ import { NOTEBOOK_ITEM_SELECTOR } from '../share/plugin';
10
+ import { IStoredNotebooksProps, StoredNotebooks } from '../StoredNotebooks';
11
+
12
+ /** A signed-out auth that records whether sign-in was asked for. */
13
+ function signedOut(): IOceanumAuth & { signIns: number } {
14
+ const auth = {
15
+ signIns: 0,
16
+ environment: null as IOceanumAuth['environment'],
17
+ urls: { specs: 'https://specs.example.com' },
18
+ ready: Promise.resolve(),
19
+ user: null as IOceanumUser | null,
20
+ userChanged: new Signal<IOceanumAuth, IOceanumUser | null>(
21
+ {} as IOceanumAuth
22
+ ),
23
+ tokenChanged: new Signal<IOceanumAuth, string | null>({} as IOceanumAuth),
24
+ signIn: async (): Promise<void> => {
25
+ auth.signIns += 1;
26
+ },
27
+ signOut: async (): Promise<void> => undefined,
28
+ getAccessToken: async (): Promise<string | null> => null
29
+ };
30
+ return auth as unknown as IOceanumAuth & { signIns: number };
31
+ }
32
+
33
+ class Harness extends ReactWidget {
34
+ constructor(
35
+ private readonly _auth: IOceanumAuth,
36
+ private readonly _onSignIn?: () => void,
37
+ private readonly _onOpenExample?: (item: INotebookDemoItem) => void,
38
+ private readonly _more: Partial<IStoredNotebooksProps> = {}
39
+ ) {
40
+ super();
41
+ }
42
+
43
+ render(): React.ReactElement {
44
+ return (
45
+ <StoredNotebooks
46
+ auth={this._auth}
47
+ onSignIn={this._onSignIn}
48
+ onOpenExample={this._onOpenExample}
49
+ {...this._more}
50
+ />
51
+ );
52
+ }
53
+ }
54
+
55
+ async function mount(
56
+ auth: IOceanumAuth,
57
+ onSignIn?: () => void
58
+ ): Promise<Harness> {
59
+ const widget = new Harness(auth, onSignIn);
60
+ Widget.attach(widget, document.body);
61
+ const deadline = Date.now() + 4000;
62
+ for (;;) {
63
+ if (widget.node.querySelector('.oceanum-text-empty')) {
64
+ return widget;
65
+ }
66
+ if (Date.now() > deadline) {
67
+ throw new Error('StoredNotebooks rendered nothing within 4s');
68
+ }
69
+ await new Promise(resolve => setTimeout(resolve, 25));
70
+ }
71
+ }
72
+
73
+ describe('the Notebooks tab while signed in', () => {
74
+ const ID = '6f1c1a52-4b8e-4c0f-9a57-1d2e3f4a5b6c';
75
+
76
+ it('lists again when a record is created, renamed or deleted', async () => {
77
+ const auth = signedOut();
78
+ (auth as { user: IOceanumUser | null }).user = {
79
+ sub: 'auth0|1',
80
+ email: 'me@example.com',
81
+ name: null,
82
+ activeOrg: null,
83
+ colorScheme: null
84
+ };
85
+ auth.getAccessToken = async () => 'tok';
86
+ const fetches: string[] = [];
87
+ const originalFetch = globalThis.fetch;
88
+ globalThis.fetch = (async (input: RequestInfo | URL) => {
89
+ fetches.push(String(input));
90
+ return {
91
+ ok: true,
92
+ status: 200,
93
+ json: async () => [
94
+ {
95
+ id: ID,
96
+ name: 'Waves',
97
+ description: null as string | null,
98
+ modified: '2026-09-12T10:00:00',
99
+ creator: 'me@example.com'
100
+ }
101
+ ]
102
+ } as unknown as Response;
103
+ }) as typeof fetch;
104
+ try {
105
+ const widget = new Harness(auth);
106
+ Widget.attach(widget, document.body);
107
+ const until = async (test: () => boolean): Promise<void> => {
108
+ const deadline = Date.now() + 4000;
109
+ while (!test()) {
110
+ if (Date.now() > deadline) {
111
+ throw new Error('timed out');
112
+ }
113
+ await new Promise(resolve => setTimeout(resolve, 25));
114
+ }
115
+ };
116
+ await until(() => !!widget.node.querySelector('.oceanum-notebooks-item'));
117
+ expect(fetches).toHaveLength(1);
118
+ const row = widget.node.querySelector('.oceanum-notebooks-item')!;
119
+ expect(row.getAttribute('data-spec-id')).toBe(ID);
120
+
121
+ notebooksChanged.emit();
122
+
123
+ await until(() => fetches.length === 2);
124
+ widget.dispose();
125
+ } finally {
126
+ globalThis.fetch = originalFetch;
127
+ }
128
+ });
129
+ });
130
+
131
+ describe('the Examples in the Notebooks tab', () => {
132
+ const MINE = '6f1c1a52-4b8e-4c0f-9a57-1d2e3f4a5b6c';
133
+ const PUBLIC_EXAMPLE = '0b7d9f2e-1111-4a2b-8c3d-9e8f7a6b5c4d';
134
+ const OTHER_EXAMPLE = '1c2d3e4f-2222-4a2b-8c3d-9e8f7a6b5c4d';
135
+ const SHARED = '2d3e4f5a-3333-4a2b-8c3d-9e8f7a6b5c4d';
136
+ const DEMO_ID = '3e4f5a6b-4444-4a2b-8c3d-9e8f7a6b5c4d';
137
+
138
+ const summary = (id: string, name: string, creator: string | null) => ({
139
+ id,
140
+ name,
141
+ description: null as string | null,
142
+ modified: '2026-09-12T10:00:00',
143
+ creator
144
+ });
145
+
146
+ // The listing returns a public example as shared with every signed-in user.
147
+ const LISTING = [
148
+ summary(MINE, 'Mine', 'me@example.com'),
149
+ summary(PUBLIC_EXAMPLE, 'Query (example)', null),
150
+ summary(SHARED, 'Shared', null)
151
+ ];
152
+
153
+ const DEMO = {
154
+ ...summary(DEMO_ID, 'Notebook Demo', null),
155
+ spec: {
156
+ kind: 'notebook-demo',
157
+ version: 1,
158
+ sections: [
159
+ {
160
+ title: 'Getting started',
161
+ items: [
162
+ {
163
+ id: PUBLIC_EXAMPLE,
164
+ title: 'Query a datasource',
165
+ summary: 'One line'
166
+ },
167
+ { id: OTHER_EXAMPLE, title: 'Plot waves' }
168
+ ]
169
+ },
170
+ // A curator's own example is also theirs: it stays in My notebooks.
171
+ { title: 'Advanced', items: [{ id: MINE, title: 'Spectra' }] }
172
+ ]
173
+ }
174
+ };
175
+
176
+ const ok = (data: unknown): Response =>
177
+ ({ ok: true, status: 200, json: async () => data }) as unknown as Response;
178
+
179
+ function signedIn(notebookDemo?: string): IOceanumAuth {
180
+ const auth = signedOut();
181
+ (auth as { user: IOceanumUser | null }).user = {
182
+ sub: 'auth0|1',
183
+ email: 'me@example.com',
184
+ name: null,
185
+ activeOrg: null,
186
+ colorScheme: null
187
+ };
188
+ auth.getAccessToken = async () => 'tok';
189
+ (auth as { environment: unknown }).environment = notebookDemo
190
+ ? { notebookDemo }
191
+ : {};
192
+ return auth;
193
+ }
194
+
195
+ const originalFetch = globalThis.fetch;
196
+ let fetches: string[];
197
+ function stubFetch(demo: () => Response = () => ok(DEMO)): void {
198
+ fetches = [];
199
+ globalThis.fetch = (async (input: RequestInfo | URL) => {
200
+ const url = String(input);
201
+ fetches.push(url);
202
+ return url.includes('/specs/notebook-demo/') ? demo() : ok(LISTING);
203
+ }) as typeof fetch;
204
+ }
205
+ afterEach(() => {
206
+ globalThis.fetch = originalFetch;
207
+ });
208
+
209
+ async function render(
210
+ auth: IOceanumAuth,
211
+ onOpenExample?: (item: INotebookDemoItem) => void,
212
+ more: Partial<IStoredNotebooksProps> = {}
213
+ ): Promise<Harness> {
214
+ const widget = new Harness(auth, undefined, onOpenExample, more);
215
+ Widget.attach(widget, document.body);
216
+ const deadline = Date.now() + 4000;
217
+ while (!widget.node.querySelector('.oceanum-notebooks')) {
218
+ if (Date.now() > deadline) {
219
+ throw new Error('the Notebooks tab did not load within 4s');
220
+ }
221
+ await new Promise(resolve => setTimeout(resolve, 25));
222
+ }
223
+ return widget;
224
+ }
225
+
226
+ /** The section whose heading starts with `title`. */
227
+ function section(widget: Harness, title: string): HTMLElement | null {
228
+ const sections = widget.node.querySelectorAll<HTMLElement>(
229
+ '.oceanum-notebooks-section'
230
+ );
231
+ return (
232
+ Array.from(sections).find(node =>
233
+ node
234
+ .querySelector('.oceanum-notebooks-heading')
235
+ ?.textContent?.startsWith(title)
236
+ ) ?? null
237
+ );
238
+ }
239
+
240
+ const idsIn = (node: HTMLElement | null, attribute: string): string[] =>
241
+ Array.from(node?.querySelectorAll(`[${attribute}]`) ?? []).map(
242
+ row => row.getAttribute(attribute) ?? ''
243
+ );
244
+
245
+ it('offers Upload where the host can upload, and runs it on click', async () => {
246
+ stubFetch();
247
+ let uploads = 0;
248
+ const widget = await render(signedIn(), undefined, {
249
+ onUpload: () => {
250
+ uploads++;
251
+ }
252
+ });
253
+
254
+ const button = widget.node.querySelector<HTMLButtonElement>(
255
+ '.oceanum-notebooks-upload'
256
+ );
257
+ button!.click();
258
+
259
+ expect(uploads).toBe(1);
260
+ Widget.detach(widget);
261
+ });
262
+
263
+ it('has no Upload button where the host cannot upload', async () => {
264
+ stubFetch();
265
+ const widget = await render(signedIn());
266
+
267
+ expect(widget.node.querySelector('.oceanum-notebooks-upload')).toBeNull();
268
+ Widget.detach(widget);
269
+ });
270
+
271
+ it('lists the record’s examples in order, under their section titles', async () => {
272
+ stubFetch();
273
+ const widget = await render(signedIn(DEMO_ID));
274
+
275
+ const examples = section(widget, 'Examples')!;
276
+ expect(examples).not.toBeNull();
277
+ expect(
278
+ Array.from(
279
+ examples.querySelectorAll('.oceanum-notebooks-subheading'),
280
+ node => node.textContent
281
+ )
282
+ ).toEqual(['Getting started', 'Advanced']);
283
+ const rows = Array.from(
284
+ examples.querySelectorAll<HTMLElement>('[data-example-id]')
285
+ );
286
+ expect(rows.map(row => row.textContent)).toEqual([
287
+ 'Query a datasource',
288
+ 'Plot waves',
289
+ 'Spectra'
290
+ ]);
291
+ expect(rows.map(row => row.getAttribute('data-example-id'))).toEqual([
292
+ PUBLIC_EXAMPLE,
293
+ OTHER_EXAMPLE,
294
+ MINE
295
+ ]);
296
+ expect(rows[0].getAttribute('title')).toBe('One line');
297
+ expect(
298
+ examples.querySelector('.oceanum-notebooks-count')?.textContent
299
+ ).toBe('3');
300
+ expect(fetches).toContain(
301
+ `https://specs.example.com/specs/notebook-demo/${DEMO_ID}`
302
+ );
303
+ widget.dispose();
304
+ });
305
+
306
+ it('moves examples out of Shared with me, but leaves My notebooks alone', async () => {
307
+ stubFetch();
308
+ const widget = await render(signedIn(DEMO_ID));
309
+
310
+ expect(idsIn(section(widget, 'Shared with me'), 'data-spec-id')).toEqual([
311
+ SHARED
312
+ ]);
313
+ expect(idsIn(section(widget, 'My notebooks'), 'data-spec-id')).toEqual([
314
+ MINE
315
+ ]);
316
+ widget.dispose();
317
+ });
318
+
319
+ it('asks for nothing more and shows no Examples where none is configured', async () => {
320
+ stubFetch();
321
+ const widget = await render(signedIn());
322
+
323
+ expect(section(widget, 'Examples')).toBeNull();
324
+ expect(fetches).toEqual(['https://specs.example.com/specs/notebook']);
325
+ // Without a record, a public example is just another shared notebook.
326
+ expect(idsIn(section(widget, 'Shared with me'), 'data-spec-id')).toEqual([
327
+ PUBLIC_EXAMPLE,
328
+ SHARED
329
+ ]);
330
+ widget.dispose();
331
+ });
332
+
333
+ it('says so when the record cannot be read, and still lists the rest', async () => {
334
+ stubFetch(
335
+ () => ({ ok: false, status: 404, json: async () => ({}) }) as Response
336
+ );
337
+ const widget = await render(signedIn(DEMO_ID));
338
+
339
+ const examples = section(widget, 'Examples')!;
340
+ expect(
341
+ examples.querySelector('.oceanum-text-error')?.textContent
342
+ ).toContain('The examples could not be loaded.');
343
+ expect(examples.querySelector('[data-example-id]')).toBeNull();
344
+ expect(idsIn(section(widget, 'My notebooks'), 'data-spec-id')).toEqual([
345
+ MINE
346
+ ]);
347
+ expect(idsIn(section(widget, 'Shared with me'), 'data-spec-id')).toEqual([
348
+ PUBLIC_EXAMPLE,
349
+ SHARED
350
+ ]);
351
+ widget.dispose();
352
+ });
353
+
354
+ it('keeps example rows out of the stored-notebook row menu', async () => {
355
+ // Its Rename and Delete would act on Oceanum's own example records.
356
+ stubFetch();
357
+ const widget = await render(signedIn(DEMO_ID), () => undefined);
358
+
359
+ const rows = section(widget, 'Examples')!.querySelectorAll('button');
360
+ expect(rows).toHaveLength(3);
361
+ for (const row of Array.from(rows)) {
362
+ expect(row.matches(NOTEBOOK_ITEM_SELECTOR)).toBe(false);
363
+ }
364
+ widget.dispose();
365
+ });
366
+
367
+ it('opens an example through onOpenExample, with its title', async () => {
368
+ stubFetch();
369
+ const opened: INotebookDemoItem[] = [];
370
+ const widget = await render(signedIn(DEMO_ID), item => opened.push(item));
371
+
372
+ section(widget, 'Examples')!
373
+ .querySelector<HTMLButtonElement>(`[data-example-id="${OTHER_EXAMPLE}"]`)!
374
+ .click();
375
+
376
+ expect(opened).toEqual([{ id: OTHER_EXAMPLE, title: 'Plot waves' }]);
377
+ widget.dispose();
378
+ });
379
+
380
+ const switchIn = (widget: Harness): HTMLButtonElement | null =>
381
+ section(widget, 'Examples')!.querySelector<HTMLButtonElement>(
382
+ '[role="switch"]'
383
+ );
384
+
385
+ it('puts a switch on the Examples heading that asks to hide them', async () => {
386
+ stubFetch();
387
+ const changes: boolean[] = [];
388
+ const widget = await render(signedIn(DEMO_ID), undefined, {
389
+ showExamples: true,
390
+ onShowExamplesChange: show => changes.push(show)
391
+ });
392
+
393
+ const toggle = switchIn(widget)!;
394
+ expect(toggle.getAttribute('aria-checked')).toBe('true');
395
+ expect(toggle.getAttribute('aria-label')).toBe('Show examples');
396
+ expect(
397
+ section(widget, 'Examples')!
398
+ .querySelector('.oceanum-notebooks-heading')!
399
+ .contains(toggle)
400
+ ).toBe(true);
401
+
402
+ toggle.click();
403
+
404
+ expect(changes).toEqual([false]);
405
+ widget.dispose();
406
+ });
407
+
408
+ it('keeps hidden examples folded under their heading, with the switch to bring them back', async () => {
409
+ stubFetch();
410
+ const changes: boolean[] = [];
411
+ const widget = await render(signedIn(DEMO_ID), undefined, {
412
+ showExamples: false,
413
+ onShowExamplesChange: show => changes.push(show)
414
+ });
415
+
416
+ const examples = section(widget, 'Examples')!;
417
+ expect(examples.querySelector('[data-example-id]')).toBeNull();
418
+ expect(
419
+ examples.querySelector('.oceanum-notebooks-count')?.textContent
420
+ ).toBe('3');
421
+ // Hidden, not forgotten: the record is still read, and its examples stay out of
422
+ // Shared with me.
423
+ expect(fetches).toContain(
424
+ `https://specs.example.com/specs/notebook-demo/${DEMO_ID}`
425
+ );
426
+ expect(idsIn(section(widget, 'Shared with me'), 'data-spec-id')).toEqual([
427
+ SHARED
428
+ ]);
429
+
430
+ const toggle = switchIn(widget)!;
431
+ expect(toggle.getAttribute('aria-checked')).toBe('false');
432
+ toggle.click();
433
+
434
+ expect(changes).toEqual([true]);
435
+ widget.dispose();
436
+ });
437
+
438
+ it('shows no error for examples the user has hidden', async () => {
439
+ stubFetch(
440
+ () => ({ ok: false, status: 404, json: async () => ({}) }) as Response
441
+ );
442
+ const widget = await render(signedIn(DEMO_ID), undefined, {
443
+ showExamples: false,
444
+ onShowExamplesChange: () => undefined
445
+ });
446
+
447
+ const examples = section(widget, 'Examples')!;
448
+ expect(examples.querySelector('.oceanum-text-error')).toBeNull();
449
+ expect(switchIn(widget)).not.toBeNull();
450
+ widget.dispose();
451
+ });
452
+
453
+ it('offers no switch where the choice could not be kept', async () => {
454
+ stubFetch();
455
+ const widget = await render(signedIn(DEMO_ID));
456
+
457
+ expect(switchIn(widget)).toBeNull();
458
+ expect(
459
+ section(widget, 'Examples')!.querySelectorAll('[data-example-id]')
460
+ ).toHaveLength(3);
461
+ widget.dispose();
462
+ });
463
+
464
+ it('shows no examples while the setting is still loading', async () => {
465
+ // Otherwise examples a user has hidden would show for a moment on every reload.
466
+ stubFetch();
467
+ const widget = await render(signedIn(DEMO_ID), undefined, {
468
+ showExamples: null,
469
+ onShowExamplesChange: () => undefined
470
+ });
471
+
472
+ expect(section(widget, 'Examples')).toBeNull();
473
+ // They are still kept out of Shared with me in the meantime.
474
+ expect(idsIn(section(widget, 'Shared with me'), 'data-spec-id')).toEqual([
475
+ SHARED
476
+ ]);
477
+ widget.dispose();
478
+ });
479
+
480
+ it('shows nothing for hidden examples that no switch could bring back', async () => {
481
+ stubFetch();
482
+ const widget = await render(signedIn(DEMO_ID), undefined, {
483
+ showExamples: false
484
+ });
485
+
486
+ expect(section(widget, 'Examples')).toBeNull();
487
+ widget.dispose();
488
+ });
489
+ });
490
+
491
+ describe('the Notebooks tab while signed out', () => {
492
+ it('links "Sign in" to starting a sign-in', async () => {
493
+ const auth = signedOut();
494
+ const widget = await mount(auth);
495
+
496
+ expect(widget.node.textContent).toContain(
497
+ 'Sign in to Oceanum.io to see your notebooks.'
498
+ );
499
+ const link = widget.node.querySelector<HTMLAnchorElement>(
500
+ '.oceanum-text-empty a'
501
+ );
502
+ expect(link?.textContent).toBe('Sign in');
503
+
504
+ link!.click();
505
+ expect(auth.signIns).toBe(1);
506
+
507
+ widget.dispose();
508
+ });
509
+
510
+ it("prefers the host's own way of signing in when given one", async () => {
511
+ // On a JupyterLab server that is the device sign-in command, which shows the
512
+ // code; the auth's signIn() alone would start the flow with nothing to look at.
513
+ const auth = signedOut();
514
+ let hostSignIns = 0;
515
+ const widget = await mount(auth, () => {
516
+ hostSignIns += 1;
517
+ });
518
+
519
+ widget.node
520
+ .querySelector<HTMLAnchorElement>('.oceanum-text-empty a')!
521
+ .click();
522
+
523
+ expect(hostSignIns).toBe(1);
524
+ expect(auth.signIns).toBe(0);
525
+
526
+ widget.dispose();
527
+ });
528
+ });