@oceanum/oceanumlab 4.7.2 → 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.
@@ -16,6 +16,8 @@ interface IPanelOptions {
16
16
  token?: string;
17
17
  /** Register the host's sign-in commands, and say whether someone is signed in. */
18
18
  signedIn?: boolean;
19
+ /** Widget props to use instead of the defaults below. */
20
+ props?: Record<string, unknown>;
19
21
  }
20
22
 
21
23
  function panel(options: IPanelOptions = {}): DatameshConnectWidget {
@@ -48,7 +50,8 @@ function panel(options: IPanelOptions = {}): DatameshConnectWidget {
48
50
  settingsChanged: new Signal<unknown, void>({}),
49
51
  commands,
50
52
  getCurrentWidget: () => new Widget(),
51
- auth: null
53
+ auth: null,
54
+ ...options.props
52
55
  } as unknown as ConstructorParameters<typeof DatameshConnectWidget>[0]);
53
56
  }
54
57
 
@@ -155,3 +158,98 @@ describe('the Oceanum panel', () => {
155
158
  widget.dispose();
156
159
  });
157
160
  });
161
+
162
+ describe('the Examples switch in the Oceanum panel', () => {
163
+ const DEMO_ID = '3e4f5a6b-4444-4a2b-8c3d-9e8f7a6b5c4d';
164
+ const EXAMPLE = '0b7d9f2e-1111-4a2b-8c3d-9e8f7a6b5c4d';
165
+ const originalFetch = globalThis.fetch;
166
+ afterEach(() => {
167
+ globalThis.fetch = originalFetch;
168
+ });
169
+
170
+ const until = async (test: () => boolean): Promise<void> => {
171
+ const deadline = Date.now() + 4000;
172
+ while (!test()) {
173
+ if (Date.now() > deadline) {
174
+ throw new Error('timed out');
175
+ }
176
+ await new Promise(resolve => setTimeout(resolve, 25));
177
+ }
178
+ };
179
+
180
+ it('saves the choice, and follows the setting once it has changed', async () => {
181
+ globalThis.fetch = (async (input: RequestInfo | URL) => {
182
+ const demo = String(input).includes('/specs/notebook-demo/');
183
+ const data: unknown = demo
184
+ ? {
185
+ id: DEMO_ID,
186
+ name: 'Notebook Demo',
187
+ description: null as string | null,
188
+ modified: '2026-09-22T10:00:00',
189
+ creator: null as string | null,
190
+ spec: {
191
+ kind: 'notebook-demo',
192
+ version: 1,
193
+ sections: [
194
+ { title: 'Start', items: [{ id: EXAMPLE, title: 'Query' }] }
195
+ ]
196
+ }
197
+ }
198
+ : [];
199
+ return { ok: true, status: 200, json: async () => data } as Response;
200
+ }) as typeof fetch;
201
+ const auth = {
202
+ environment: { notebookDemo: DEMO_ID },
203
+ urls: { specs: 'https://specs.example.com' },
204
+ ready: Promise.resolve(),
205
+ user: {
206
+ sub: 'auth0|1',
207
+ email: 'me@example.com',
208
+ name: null as string | null,
209
+ activeOrg: null as string | null,
210
+ colorScheme: null as string | null
211
+ },
212
+ userChanged: new Signal<unknown, unknown>({}),
213
+ tokenChanged: new Signal<unknown, unknown>({}),
214
+ signIn: async (): Promise<void> => undefined,
215
+ signOut: async (): Promise<void> => undefined,
216
+ getAccessToken: async (): Promise<string> => 'tok'
217
+ };
218
+ // What the host keeps: the setting, and the signal it emits when it changes.
219
+ // Null until the host's settings load.
220
+ let shown: boolean | null = null;
221
+ const saved: boolean[] = [];
222
+ const settingsChanged = new Signal<unknown, void>({});
223
+ const widget = await mount({
224
+ props: {
225
+ auth,
226
+ settingsChanged,
227
+ showExamples: () => shown,
228
+ setShowExamples: (show: boolean) => saved.push(show)
229
+ }
230
+ });
231
+ const toggle = (): HTMLButtonElement | null =>
232
+ widget.node.querySelector<HTMLButtonElement>('[role="switch"]');
233
+ // The tab has loaded, but the examples wait for the setting.
234
+ await until(() => widget.node.querySelector('.oceanum-notebooks') !== null);
235
+ expect(toggle()).toBeNull();
236
+ expect(widget.node.querySelector('[data-example-id]')).toBeNull();
237
+
238
+ shown = true;
239
+ settingsChanged.emit();
240
+ await until(() => toggle() !== null);
241
+ expect(toggle()!.getAttribute('aria-checked')).toBe('true');
242
+ expect(widget.node.querySelector('[data-example-id]')).not.toBeNull();
243
+
244
+ toggle()!.click();
245
+ expect(saved).toEqual([false]);
246
+
247
+ // The host saves the setting and says so; the panel reads it again.
248
+ shown = false;
249
+ settingsChanged.emit();
250
+ await until(() => toggle()?.getAttribute('aria-checked') === 'false');
251
+ expect(widget.node.querySelector('[data-example-id]')).toBeNull();
252
+
253
+ widget.dispose();
254
+ });
255
+ });
@@ -5,7 +5,9 @@ import React from 'react';
5
5
 
6
6
  import { IOceanumAuth, IOceanumUser } from '../auth/tokens';
7
7
  import { notebooksChanged } from '../share/events';
8
- import { StoredNotebooks } from '../StoredNotebooks';
8
+ import type { INotebookDemoItem } from '../share/notebook';
9
+ import { NOTEBOOK_ITEM_SELECTOR } from '../share/plugin';
10
+ import { IStoredNotebooksProps, StoredNotebooks } from '../StoredNotebooks';
9
11
 
10
12
  /** A signed-out auth that records whether sign-in was asked for. */
11
13
  function signedOut(): IOceanumAuth & { signIns: number } {
@@ -31,13 +33,22 @@ function signedOut(): IOceanumAuth & { signIns: number } {
31
33
  class Harness extends ReactWidget {
32
34
  constructor(
33
35
  private readonly _auth: IOceanumAuth,
34
- private readonly _onSignIn?: () => void
36
+ private readonly _onSignIn?: () => void,
37
+ private readonly _onOpenExample?: (item: INotebookDemoItem) => void,
38
+ private readonly _more: Partial<IStoredNotebooksProps> = {}
35
39
  ) {
36
40
  super();
37
41
  }
38
42
 
39
43
  render(): React.ReactElement {
40
- return <StoredNotebooks auth={this._auth} onSignIn={this._onSignIn} />;
44
+ return (
45
+ <StoredNotebooks
46
+ auth={this._auth}
47
+ onSignIn={this._onSignIn}
48
+ onOpenExample={this._onOpenExample}
49
+ {...this._more}
50
+ />
51
+ );
41
52
  }
42
53
  }
43
54
 
@@ -117,6 +128,366 @@ describe('the Notebooks tab while signed in', () => {
117
128
  });
118
129
  });
119
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
+
120
491
  describe('the Notebooks tab while signed out', () => {
121
492
  it('links "Sign in" to starting a sign-in', async () => {
122
493
  const auth = signedOut();
@@ -7,13 +7,17 @@ import {
7
7
  FILE_BROWSER_ID,
8
8
  hideFileBrowser,
9
9
  LOCAL_FILE_COMMANDS,
10
+ moveToTop,
11
+ NON_NOTEBOOK_NEW_COMMANDS,
12
+ OCEANUM_PANEL_ID,
10
13
  pruneBeforeEachOpen,
11
14
  pruneFileMenu,
12
15
  routeSaves
13
16
  } from '../share/oceanumOnly';
14
17
 
15
18
  describe('hideFileBrowser', () => {
16
- it('closes the file browser without disposing it, and leaves the rest', () => {
19
+ /** A left area holding the file browser and the running-sessions panel. */
20
+ function leftArea(): { left: Panel; browser: Widget; running: Widget } {
17
21
  const left = new Panel();
18
22
  const browser = new Widget();
19
23
  browser.id = FILE_BROWSER_ID;
@@ -21,8 +25,24 @@ describe('hideFileBrowser', () => {
21
25
  running.id = 'jp-running-sessions';
22
26
  left.addWidget(browser);
23
27
  left.addWidget(running);
28
+ return { left, browser, running };
29
+ }
30
+
31
+ function shell(left: Panel) {
32
+ const activated: string[] = [];
33
+ return {
34
+ activated,
35
+ widgets: () => left.widgets,
36
+ activateById: (id: string): void => {
37
+ activated.push(id);
38
+ }
39
+ };
40
+ }
41
+
42
+ it('closes the file browser without disposing it, and leaves the rest', () => {
43
+ const { left, browser, running } = leftArea();
24
44
 
25
- expect(hideFileBrowser({ widgets: () => left.widgets })).toBe(true);
45
+ expect(hideFileBrowser(shell(left))).toBe(true);
26
46
 
27
47
  expect(left.widgets).toEqual([running]);
28
48
  // Other extensions still hold it through IDefaultFileBrowser.
@@ -31,7 +51,30 @@ describe('hideFileBrowser', () => {
31
51
 
32
52
  it('reports when there is none to hide', () => {
33
53
  const left = new Panel();
34
- expect(hideFileBrowser({ widgets: () => left.widgets })).toBe(false);
54
+ expect(hideFileBrowser(shell(left))).toBe(false);
55
+ });
56
+
57
+ it('opens the replacement when the file browser was the open panel', () => {
58
+ const { left } = leftArea();
59
+ Widget.attach(left, document.body);
60
+ const fake = shell(left);
61
+
62
+ hideFileBrowser(fake, OCEANUM_PANEL_ID);
63
+
64
+ expect(fake.activated).toEqual([OCEANUM_PANEL_ID]);
65
+ Widget.detach(left);
66
+ });
67
+
68
+ it('leaves a collapsed sidebar collapsed', () => {
69
+ const { left, browser } = leftArea();
70
+ Widget.attach(left, document.body);
71
+ browser.hide();
72
+ const fake = shell(left);
73
+
74
+ hideFileBrowser(fake, OCEANUM_PANEL_ID);
75
+
76
+ expect(fake.activated).toEqual([]);
77
+ Widget.detach(left);
35
78
  });
36
79
  });
37
80
 
@@ -191,3 +234,84 @@ describe('routeSaves', () => {
191
234
  expect(pushed).toEqual([first.panel, second.panel, first.panel]);
192
235
  });
193
236
  });
237
+
238
+ describe('the File > New menu', () => {
239
+ it('loses the entries for files that are not notebooks', () => {
240
+ const { menu } = fileMenu([
241
+ 'console:create',
242
+ 'notebook:create-new',
243
+ ...NON_NOTEBOOK_NEW_COMMANDS
244
+ ]);
245
+
246
+ expect(pruneFileMenu(menu, NON_NOTEBOOK_NEW_COMMANDS)).toBe(2);
247
+
248
+ expect(menuCommands(menu)).toEqual([
249
+ 'console:create',
250
+ 'notebook:create-new'
251
+ ]);
252
+ });
253
+ });
254
+
255
+ describe('moveToTop', () => {
256
+ /** A left area in a saved order, and a shell that re-adds by rank as JupyterLab does. */
257
+ function sidebar(ids: string[]) {
258
+ const left = new Panel();
259
+ for (const id of ids) {
260
+ const widget = new Widget();
261
+ widget.id = id;
262
+ left.addWidget(widget);
263
+ }
264
+ Widget.attach(left, document.body);
265
+ const activated: string[] = [];
266
+ const added: [string, number][] = [];
267
+ const shell = {
268
+ widgets: () => left.widgets,
269
+ add: (widget: Widget, _: 'left', options: { rank: number }): void => {
270
+ added.push([widget.id, options.rank]);
271
+ widget.hide();
272
+ left.insertWidget(0, widget);
273
+ },
274
+ activateById: (id: string): void => {
275
+ activated.push(id);
276
+ }
277
+ };
278
+ const order = (): string[] => left.widgets.map(widget => widget.id);
279
+ const get = (id: string): Widget => left.widgets.find(w => w.id === id)!;
280
+ return { left, shell, activated, added, order, get };
281
+ }
282
+
283
+ it('puts a panel a returning user had last back in front, and reopens it', () => {
284
+ const bar = sidebar(['jp-running-sessions', OCEANUM_PANEL_ID]);
285
+ bar.get('jp-running-sessions').hide();
286
+
287
+ expect(moveToTop(bar.shell, OCEANUM_PANEL_ID)).toBe(true);
288
+
289
+ expect(bar.added).toEqual([[OCEANUM_PANEL_ID, 0]]);
290
+ expect(bar.order()).toEqual([OCEANUM_PANEL_ID, 'jp-running-sessions']);
291
+ expect(bar.activated).toEqual([OCEANUM_PANEL_ID]);
292
+ Widget.detach(bar.left);
293
+ });
294
+
295
+ it('does not open a panel that was closed', () => {
296
+ const bar = sidebar(['jp-running-sessions', OCEANUM_PANEL_ID]);
297
+ bar.get(OCEANUM_PANEL_ID).hide();
298
+
299
+ moveToTop(bar.shell, OCEANUM_PANEL_ID);
300
+
301
+ expect(bar.order()[0]).toBe(OCEANUM_PANEL_ID);
302
+ expect(bar.activated).toEqual([]);
303
+ Widget.detach(bar.left);
304
+ });
305
+
306
+ it('leaves the sidebar alone when the panel is already first or absent', () => {
307
+ const first = sidebar([OCEANUM_PANEL_ID, 'jp-running-sessions']);
308
+ expect(moveToTop(first.shell, OCEANUM_PANEL_ID)).toBe(false);
309
+ expect(first.added).toEqual([]);
310
+ Widget.detach(first.left);
311
+
312
+ const absent = sidebar(['jp-running-sessions']);
313
+ expect(moveToTop(absent.shell, OCEANUM_PANEL_ID)).toBe(false);
314
+ expect(absent.added).toEqual([]);
315
+ Widget.detach(absent.left);
316
+ });
317
+ });
@@ -44,10 +44,20 @@ describe('the datamesh-connect settings schema', () => {
44
44
  'datameshToken',
45
45
  'injectToken',
46
46
  'autoRunCode',
47
- 'iterate'
47
+ 'iterate',
48
+ 'showExamples'
48
49
  ]);
49
50
  });
50
51
 
52
+ it('shows the examples unless the user has hidden them', async () => {
53
+ // Settings stored before the switch existed must still load, showing examples.
54
+ const before = await registryWith({ datameshToken: 'a-token' }).load(ID);
55
+ expect(before.get('showExamples').composite).toBe(true);
56
+
57
+ const hidden = await registryWith({ showExamples: false }).load(ID);
58
+ expect(hidden.get('showExamples').composite).toBe(false);
59
+ });
60
+
51
61
  it('still loads settings stored when they were', async () => {
52
62
  const registry = registryWith({
53
63
  datameshToken: 'a-token',