@oceanum/oceanumlab 4.8.0 → 4.10.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.
@@ -2,12 +2,12 @@ import type { INotebookContent } from '@jupyterlab/nbformat';
2
2
 
3
3
  import {
4
4
  buildSpecBody,
5
+ demoItemsFromSummaries,
5
6
  isShareableEmail,
6
7
  isSpecId,
7
8
  nameFromPath,
8
9
  notebookFromRecord,
9
10
  notebookFromUpload,
10
- parseNotebookDemo,
11
11
  parseShareEmails,
12
12
  parseTimestamp,
13
13
  partitionSummaries,
@@ -219,98 +219,41 @@ describe('unlinkedNotebookFromRecord', () => {
219
219
  });
220
220
  });
221
221
 
222
- describe('parseNotebookDemo', () => {
223
- const OTHER = '0b7d9f2e-1111-4a2b-8c3d-9e8f7a6b5c4d';
224
- const demo = (sections: unknown, extra: object = {}): unknown => ({
225
- kind: 'notebook-demo',
226
- version: 1,
227
- sections,
228
- ...extra
229
- });
230
-
231
- let warn: jest.SpyInstance;
232
- beforeEach(() => {
233
- warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
234
- });
235
- afterEach(() => {
236
- warn.mockRestore();
237
- });
238
-
239
- it('keeps sections and items in record order', () => {
240
- const sections = parseNotebookDemo(
241
- demo(
242
- [
243
- {
244
- title: 'Getting started',
245
- items: [
246
- { id: ID, title: 'Query', summary: 'One line' },
247
- { id: OTHER, title: 'Plot', note: 'ignored' }
248
- ]
249
- },
250
- { title: 'Waves', items: [{ id: OTHER, title: 'Spectra' }] }
251
- ],
252
- { owner: 'ignored' }
253
- )
254
- );
255
- expect(sections).toEqual([
256
- {
257
- title: 'Getting started',
258
- items: [
259
- { id: ID, title: 'Query', summary: 'One line' },
260
- { id: OTHER, title: 'Plot' }
261
- ]
262
- },
263
- { title: 'Waves', items: [{ id: OTHER, title: 'Spectra' }] }
264
- ]);
265
- expect(warn).not.toHaveBeenCalled();
266
- });
267
-
268
- it('skips items without a record id or a title', () => {
269
- const sections = parseNotebookDemo(
270
- demo([
271
- {
272
- title: 'Mixed',
273
- items: [
274
- { id: '../eidos', title: 'Not a record id' },
275
- { id: ID, title: ' ' },
276
- { id: ID },
277
- 'not an item',
278
- { id: OTHER, title: 'Kept', summary: 42 }
279
- ]
280
- }
281
- ])
282
- );
283
- expect(sections).toEqual([
284
- { title: 'Mixed', items: [{ id: OTHER, title: 'Kept' }] }
285
- ]);
222
+ describe('demoItemsFromSummaries', () => {
223
+ const A = '0b7d9f2e-1111-4a2b-8c3d-9e8f7a6b5c4d';
224
+ const B = '1c2d3e4f-2222-4a2b-8c3d-9e8f7a6b5c4d';
225
+ const C = '2d3e4f5a-3333-4a2b-8c3d-9e8f7a6b5c4d';
226
+ const summary = (id: string, name: string, description: string | null) => ({
227
+ id,
228
+ name,
229
+ description,
230
+ modified: '2026-09-25T10:00:00',
231
+ creator: null as string | null
286
232
  });
287
233
 
288
- it('drops sections with no title or nothing left to list', () => {
289
- const sections = parseNotebookDemo(
290
- demo([
291
- { title: 'Empty', items: [] },
292
- { title: 'All invalid', items: [{ id: 'x', title: 'y' }] },
293
- { title: '', items: [{ id: ID, title: 'Untitled section' }] },
294
- { title: 'No items' }
295
- ])
296
- );
297
- expect(sections).toEqual([]);
298
- expect(warn).not.toHaveBeenCalled();
234
+ it('lists each spec by name, with its description as the summary', () => {
235
+ expect(
236
+ demoItemsFromSummaries([summary(A, '01_quickstart', 'A quick query')])
237
+ ).toEqual([{ id: A, title: '01_quickstart', summary: 'A quick query' }]);
299
238
  });
300
239
 
301
- it('reads nothing from a record of another kind or version, and says so once', () => {
302
- for (const spec of [
303
- demo([], { kind: 'workspace' }),
304
- demo([], { version: 2 }),
305
- { kind: 'notebook-demo', version: 1, sections: {} },
306
- null,
307
- [],
308
- 'notebook-demo'
309
- ]) {
310
- warn.mockClear();
311
- expect(parseNotebookDemo(spec)).toEqual([]);
312
- expect(warn).toHaveBeenCalledTimes(1);
313
- }
240
+ it('orders by name as people number them, ignoring case', () => {
241
+ const items = demoItemsFromSummaries([
242
+ summary(A, '10 Plot waves', null),
243
+ summary(B, 'spectra', null),
244
+ summary(C, '2 Query', null)
245
+ ]);
246
+ expect(items.map(item => item.title)).toEqual([
247
+ '2 Query',
248
+ '10 Plot waves',
249
+ 'spectra'
250
+ ]);
251
+ });
252
+
253
+ it('names an unnamed spec, and leaves out a summary it does not have', () => {
254
+ expect(demoItemsFromSummaries([summary(A, ' ', null)])).toEqual([
255
+ { id: A, title: 'Untitled' }
256
+ ]);
314
257
  });
315
258
  });
316
259
 
@@ -14,6 +14,7 @@ import { Signal } from '@lumino/signaling';
14
14
 
15
15
  import type { IOceanumAuth, IOceanumUser } from '../auth/tokens';
16
16
  import type { ISpecRecord } from '../share/notebook';
17
+ import { encodePayload, FRAGMENT_KEY } from '../share/fromDatamesh';
17
18
  import { CommandIDs, sharePlugin } from '../share/plugin';
18
19
 
19
20
  /** What every dialog answers. Accepting with no value is "chose nothing". */
@@ -100,6 +101,7 @@ const record = (id: string, name: string): ISpecRecord => ({
100
101
  */
101
102
  function store(stored: Record<string, INotebookContent> = {}) {
102
103
  const calls: string[] = [];
104
+ const urls: string[] = [];
103
105
  const bodies: Record<string, unknown>[] = [];
104
106
  const names: Record<string, string> = { [ID_A]: 'Alpha', [ID_B]: 'Beta' };
105
107
  const fetcher = async (
@@ -110,6 +112,7 @@ function store(stored: Record<string, INotebookContent> = {}) {
110
112
  const method = init?.method ?? 'GET';
111
113
  const id = url.split('/').pop() ?? '';
112
114
  calls.push(`${method} ${id}`);
115
+ urls.push(url);
113
116
  if (init?.body) {
114
117
  bodies.push(JSON.parse(String(init.body)));
115
118
  }
@@ -125,7 +128,7 @@ function store(stored: Record<string, INotebookContent> = {}) {
125
128
  }
126
129
  } as unknown as Response;
127
130
  };
128
- return { calls, bodies, fetcher };
131
+ return { calls, urls, bodies, fetcher };
129
132
  }
130
133
 
131
134
  /** A notebook panel with just the surface the plugin touches. */
@@ -159,6 +162,8 @@ function panel(id: string, path: string, link?: string): NotebookPanel {
159
162
  interface IHarness {
160
163
  commands: CommandRegistry;
161
164
  calls: string[];
165
+ /** Every URL the plugin fetched, in order. */
166
+ urls: string[];
162
167
  bodies: Record<string, unknown>[];
163
168
  saved: string[];
164
169
  /** What each save wrote, by path. */
@@ -183,7 +188,7 @@ function activate(
183
188
  } = {}
184
189
  ): IHarness {
185
190
  const commands = new CommandRegistry();
186
- const { calls, bodies, fetcher } = store(options.stored);
191
+ const { calls, urls, bodies, fetcher } = store(options.stored);
187
192
  const saved: string[] = [];
188
193
  const written: Record<string, unknown> = {};
189
194
  const opened: string[] = [];
@@ -303,6 +308,7 @@ function activate(
303
308
  return {
304
309
  commands,
305
310
  calls,
311
+ urls,
306
312
  bodies,
307
313
  saved,
308
314
  written,
@@ -493,6 +499,8 @@ describe('opening an example', () => {
493
499
 
494
500
  // Read, never written back: the example's record is Oceanum's.
495
501
  expect(harness.calls).toEqual([`GET ${ID_A}`]);
502
+ // An example is a Notebook Demo spec, not a notebook.
503
+ expect(harness.urls).toEqual([`${SPECS}/specs/notebook-demo/${ID_A}`]);
496
504
  const path = 'Oceanum/Query a datasource.ipynb';
497
505
  expect(harness.saved).toEqual([path]);
498
506
  expect(harness.opened).toEqual([path]);
@@ -662,3 +670,50 @@ describe('uploading a notebook', () => {
662
670
  expect(harness.commands.isEnabled(CommandIDs.upload)).toBe(false);
663
671
  });
664
672
  });
673
+
674
+ describe('opening a Datamesh UI workspace from the address', () => {
675
+ const settle = () => new Promise(resolve => setTimeout(resolve, 0));
676
+ const payload = encodePayload({
677
+ v: 1,
678
+ name: 'Coast',
679
+ items: [{ datasource: 'era5_wind10m', variables: ['u10'] }]
680
+ });
681
+
682
+ afterEach(() => window.history.replaceState(null, '', '/'));
683
+
684
+ it('writes a new untrusted notebook to the Oceanum folder, opens it and clears the hash', async () => {
685
+ window.history.replaceState(
686
+ null,
687
+ '',
688
+ `/lab/#other=1&${FRAGMENT_KEY}=${payload}`
689
+ );
690
+ const harness = activate({ user: null, folder: { 'Coast.ipynb': {} } });
691
+ // Read at activation but left in the address until the notebook is written,
692
+ // so a sign-in redirect before then still carries it.
693
+ expect(window.location.hash).toContain(FRAGMENT_KEY);
694
+ await settle();
695
+ expect(window.location.hash).toBe('#other=1');
696
+
697
+ const path = 'Oceanum/Coast (1).ipynb';
698
+ expect(harness.saved).toEqual([path]);
699
+ expect(harness.opened).toEqual([path]);
700
+ const content = harness.written[path] as INotebookContent;
701
+ expect(content.metadata).not.toHaveProperty('oceanum');
702
+ expect(content.cells[2].source).toContain('datamesh.query(');
703
+ expect(harness.calls).toEqual([]);
704
+ });
705
+
706
+ it('writes nothing for a payload that is not valid', async () => {
707
+ window.history.replaceState(null, '', `/lab/#${FRAGMENT_KEY}=bad`);
708
+ const harness = activate();
709
+ await settle();
710
+ expect(window.location.hash).toBe('');
711
+ expect(harness.saved).toEqual([]);
712
+ });
713
+
714
+ it('does nothing without a payload', async () => {
715
+ const harness = activate();
716
+ await settle();
717
+ expect(harness.saved).toEqual([]);
718
+ });
719
+ });
@@ -32,12 +32,6 @@ export interface IOceanumEnvironment {
32
32
  * absent) keeps JupyterLab's file management alongside the Oceanum commands.
33
33
  */
34
34
  readonly fileManagement?: 'local' | 'oceanum';
35
- /**
36
- * The spec store id of this deployment's Notebook Demo record, the curated list the
37
- * Notebooks tab shows as Examples. Absent where a deployment has none, and never set
38
- * by a JupyterLab server's sign-in.
39
- */
40
- readonly notebookDemo?: string;
41
35
  }
42
36
 
43
37
  /** Service base URLs for an environment, without trailing slashes. */