@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.
- package/lib/StoredNotebooks.js +31 -39
- package/lib/auth/tokens.d.ts +0 -6
- package/lib/share/fromDatamesh.d.ts +82 -0
- package/lib/share/fromDatamesh.js +379 -0
- package/lib/share/notebook.d.ts +6 -12
- package/lib/share/notebook.js +17 -37
- package/lib/share/plugin.js +58 -2
- package/package.json +1 -1
- package/schema/datamesh-connect.json +1 -1
- package/src/StoredNotebooks.tsx +58 -77
- package/src/__tests__/OceanumPanel.spec.tsx +12 -17
- package/src/__tests__/StoredNotebooks.spec.tsx +80 -87
- package/src/__tests__/fromDatamesh.spec.ts +301 -0
- package/src/__tests__/shareNotebook.spec.ts +32 -89
- package/src/__tests__/sharePlugin.spec.ts +57 -2
- package/src/auth/tokens.ts +0 -6
- package/src/share/fromDatamesh.ts +477 -0
- package/src/share/notebook.ts +21 -52
- package/src/share/plugin.ts +69 -1
- package/style/index.css +0 -7
|
@@ -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('
|
|
223
|
-
const
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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('
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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('
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
'
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
+
});
|
package/src/auth/tokens.ts
CHANGED
|
@@ -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. */
|