@oceanum/oceanumlab 4.8.0 → 4.9.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/notebook.d.ts +6 -12
- package/lib/share/notebook.js +17 -37
- package/lib/share/plugin.js +7 -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__/shareNotebook.spec.ts +32 -89
- package/src/__tests__/sharePlugin.spec.ts +9 -2
- package/src/auth/tokens.ts +0 -6
- package/src/share/notebook.ts +21 -52
- package/src/share/plugin.ts +9 -1
- package/style/index.css +0 -7
package/lib/StoredNotebooks.js
CHANGED
|
@@ -1,28 +1,32 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
|
-
import { SpecStoreClient } from './share/client';
|
|
2
|
+
import { SpecStoreClient, SpecStoreError } from './share/client';
|
|
3
3
|
import { notebooksChanged } from './share/events';
|
|
4
|
-
import {
|
|
4
|
+
import { demoItemsFromSummaries, NOTEBOOK_DEMO_TYPE, partitionSummaries } from './share/notebook';
|
|
5
5
|
import { SPEC_ID_ATTRIBUTE } from './share/plugin';
|
|
6
6
|
/** Example rows carry their record id here, not in SPEC_ID_ATTRIBUTE: see Examples. */
|
|
7
7
|
export const EXAMPLE_ID_ATTRIBUTE = 'data-example-id';
|
|
8
8
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* List the Notebook Demo specs, each one an example notebook. Only org oceanum may
|
|
10
|
+
* create them (spec store 0.6.2), so every one listed is Oceanum's. Never rejects: a
|
|
11
|
+
* failure here is shown in the Examples section and leaves the rest of the tab alone.
|
|
11
12
|
*/
|
|
12
|
-
async function loadExamples(auth, specsUrl
|
|
13
|
-
if (!id) {
|
|
14
|
-
return { state: 'none' };
|
|
15
|
-
}
|
|
13
|
+
async function loadExamples(auth, specsUrl) {
|
|
16
14
|
try {
|
|
17
15
|
const client = new SpecStoreClient({
|
|
18
16
|
specsUrl,
|
|
19
17
|
getAccessToken: () => auth.getAccessToken(),
|
|
20
|
-
specType:
|
|
18
|
+
specType: NOTEBOOK_DEMO_TYPE
|
|
21
19
|
});
|
|
22
|
-
|
|
23
|
-
|
|
20
|
+
return {
|
|
21
|
+
state: 'ready',
|
|
22
|
+
items: demoItemsFromSummaries(await client.list())
|
|
23
|
+
};
|
|
24
24
|
}
|
|
25
25
|
catch (error) {
|
|
26
|
+
// A store with no Notebook Demo type has no examples, which is not an error.
|
|
27
|
+
if (error instanceof SpecStoreError && error.kind === 'not-found') {
|
|
28
|
+
return { state: 'none' };
|
|
29
|
+
}
|
|
26
30
|
return {
|
|
27
31
|
state: 'error',
|
|
28
32
|
message: error instanceof Error ? error.message : 'Could not reach Oceanum.io.'
|
|
@@ -30,18 +34,17 @@ async function loadExamples(auth, specsUrl, id) {
|
|
|
30
34
|
}
|
|
31
35
|
}
|
|
32
36
|
/**
|
|
33
|
-
* The
|
|
34
|
-
* stored-notebook row class: the share plugin's row menu
|
|
35
|
-
* is bound to those, and none of it applies to
|
|
37
|
+
* The examples: Oceanum's Notebook Demo specs. Their rows deliberately leave out
|
|
38
|
+
* SPEC_ID_ATTRIBUTE and the stored-notebook row class: the share plugin's row menu
|
|
39
|
+
* (Open, Rename, Share, Delete) is bound to those, and none of it applies to
|
|
40
|
+
* Oceanum's own example records.
|
|
36
41
|
*/
|
|
37
42
|
function ExampleSections({ examples, onOpen, show, onShowChange }) {
|
|
38
43
|
// Not yet known, or hidden with no switch to bring them back: nothing to show.
|
|
39
44
|
if (examples.state === 'none' || show === null || (!show && !onShowChange)) {
|
|
40
45
|
return null;
|
|
41
46
|
}
|
|
42
|
-
const count = examples.state === 'ready'
|
|
43
|
-
? examples.sections.reduce((total, section) => total + section.items.length, 0)
|
|
44
|
-
: null;
|
|
47
|
+
const count = examples.state === 'ready' ? examples.items.length : null;
|
|
45
48
|
if (count === 0) {
|
|
46
49
|
return null;
|
|
47
50
|
}
|
|
@@ -64,18 +67,15 @@ function ExampleSections({ examples, onOpen, show, onShowChange }) {
|
|
|
64
67
|
}
|
|
65
68
|
return (React.createElement("div", { className: "oceanum-notebooks-section" },
|
|
66
69
|
heading,
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
// A record may list the same example twice; the id alone is no key.
|
|
77
|
-
React.createElement("li", { key: `${position}:${item.id}` }, onOpen ? (React.createElement("button", { type: "button", className: "oceanum-notebooks-example", onClick: () => onOpen(item), ...attributes }, name)) : (React.createElement("div", { className: "oceanum-notebooks-example oceanum-notebooks-item-static", ...attributes }, name))));
|
|
78
|
-
})))))));
|
|
70
|
+
React.createElement("ul", { className: "oceanum-notebooks-list" }, examples.items.map(item => {
|
|
71
|
+
const attributes = {
|
|
72
|
+
[EXAMPLE_ID_ATTRIBUTE]: item.id,
|
|
73
|
+
title: item.summary
|
|
74
|
+
};
|
|
75
|
+
// Names come from the spec store: React escapes them, never set as HTML.
|
|
76
|
+
const name = (React.createElement("span", { className: "oceanum-notebooks-name" }, item.title));
|
|
77
|
+
return (React.createElement("li", { key: item.id }, onOpen ? (React.createElement("button", { type: "button", className: "oceanum-notebooks-example", onClick: () => onOpen(item), ...attributes }, name)) : (React.createElement("div", { className: "oceanum-notebooks-example oceanum-notebooks-item-static", ...attributes }, name))));
|
|
78
|
+
}))));
|
|
79
79
|
}
|
|
80
80
|
function Section({ title, items, empty, onOpen }) {
|
|
81
81
|
// Each row carries its record id so the share plugin's context menu (Open, Rename,
|
|
@@ -127,17 +127,14 @@ export function StoredNotebooks({ auth, onOpen, onOpenExample, onUpload, showExa
|
|
|
127
127
|
}
|
|
128
128
|
setLoad({ state: 'loading' });
|
|
129
129
|
void (async () => {
|
|
130
|
-
var _a, _b;
|
|
131
130
|
try {
|
|
132
131
|
const client = new SpecStoreClient({
|
|
133
132
|
specsUrl: specs,
|
|
134
133
|
getAccessToken: () => auth.getAccessToken()
|
|
135
134
|
});
|
|
136
|
-
// Only the record the deployment names: every signed-in user can create
|
|
137
|
-
// notebook-demo records, so the examples never come from a listing.
|
|
138
135
|
const [items, examples] = await Promise.all([
|
|
139
136
|
client.list(),
|
|
140
|
-
loadExamples(auth, specs
|
|
137
|
+
loadExamples(auth, specs)
|
|
141
138
|
]);
|
|
142
139
|
if (!cancelled) {
|
|
143
140
|
setLoad({ state: 'ready', items, examples });
|
|
@@ -187,15 +184,10 @@ export function StoredNotebooks({ auth, onOpen, onOpenExample, onUpload, showExa
|
|
|
187
184
|
return React.createElement("div", { className: "oceanum-text-error" }, load.message);
|
|
188
185
|
}
|
|
189
186
|
const { mine, shared } = partitionSummaries(load.items, user.email);
|
|
190
|
-
// A public example is readable by everyone, so the listing returns it as shared with
|
|
191
|
-
// every signed-in user; it is shown under Examples instead.
|
|
192
|
-
const exampleIds = new Set(load.examples.state === 'ready'
|
|
193
|
-
? load.examples.sections.flatMap(section => section.items.map(item => item.id))
|
|
194
|
-
: []);
|
|
195
187
|
return (React.createElement("div", { className: "oceanum-notebooks" },
|
|
196
188
|
onUpload && ((_a = auth.urls) === null || _a === void 0 ? void 0 : _a.specs) ? (React.createElement("div", { className: "oceanum-notebooks-actions" },
|
|
197
189
|
React.createElement("button", { type: "button", className: "oceanum-notebooks-upload", title: "Upload a notebook from this computer, to save on Oceanum.io", onClick: onUpload }, "Upload notebook\u2026"))) : null,
|
|
198
190
|
React.createElement(Section, { title: "My notebooks", items: mine, empty: "You have not saved any notebooks yet.", onOpen: onOpen }),
|
|
199
|
-
React.createElement(Section, { title: "Shared with me", items: shared
|
|
191
|
+
React.createElement(Section, { title: "Shared with me", items: shared, empty: "No notebooks have been shared with you.", onOpen: onOpen }),
|
|
200
192
|
React.createElement(ExampleSections, { examples: load.examples, onOpen: onOpenExample, show: showExamples, onShowChange: onShowExamplesChange })));
|
|
201
193
|
}
|
package/lib/auth/tokens.d.ts
CHANGED
|
@@ -31,12 +31,6 @@ export interface IOceanumEnvironment {
|
|
|
31
31
|
* absent) keeps JupyterLab's file management alongside the Oceanum commands.
|
|
32
32
|
*/
|
|
33
33
|
readonly fileManagement?: 'local' | 'oceanum';
|
|
34
|
-
/**
|
|
35
|
-
* The spec store id of this deployment's Notebook Demo record, the curated list the
|
|
36
|
-
* Notebooks tab shows as Examples. Absent where a deployment has none, and never set
|
|
37
|
-
* by a JupyterLab server's sign-in.
|
|
38
|
-
*/
|
|
39
|
-
readonly notebookDemo?: string;
|
|
40
34
|
}
|
|
41
35
|
/** Service base URLs for an environment, without trailing slashes. */
|
|
42
36
|
export interface IOceanumServiceUrls {
|
package/lib/share/notebook.d.ts
CHANGED
|
@@ -141,25 +141,19 @@ export declare function partitionSummaries(items: readonly ISpecSummary[], email
|
|
|
141
141
|
mine: ISpecSummary[];
|
|
142
142
|
shared: ISpecSummary[];
|
|
143
143
|
};
|
|
144
|
-
/** The
|
|
145
|
-
export declare const
|
|
146
|
-
/** One example
|
|
144
|
+
/** The spec store type of the Notebook Demo specs, which are the Examples. */
|
|
145
|
+
export declare const NOTEBOOK_DEMO_TYPE = "notebook-demo";
|
|
146
|
+
/** One example: a Notebook Demo spec, whose body is the example notebook. */
|
|
147
147
|
export interface INotebookDemoItem {
|
|
148
148
|
id: string;
|
|
149
149
|
title: string;
|
|
150
150
|
summary?: string;
|
|
151
151
|
}
|
|
152
|
-
/** A titled group of examples. */
|
|
153
|
-
export interface INotebookDemoSection {
|
|
154
|
-
title: string;
|
|
155
|
-
items: INotebookDemoItem[];
|
|
156
|
-
}
|
|
157
152
|
/**
|
|
158
|
-
* The
|
|
159
|
-
*
|
|
160
|
-
* A spec of another kind or version gives no sections, with a warning.
|
|
153
|
+
* The examples in a listing of Notebook Demo specs, ordered by name as people number
|
|
154
|
+
* them ("2 …" before "10 …").
|
|
161
155
|
*/
|
|
162
|
-
export declare function
|
|
156
|
+
export declare function demoItemsFromSummaries(summaries: readonly ISpecSummary[]): INotebookDemoItem[];
|
|
163
157
|
/** Parse a spec store timestamp, which is UTC but usually has no zone designator. */
|
|
164
158
|
export declare function parseTimestamp(value: string): Date | null;
|
|
165
159
|
/**
|
package/lib/share/notebook.js
CHANGED
|
@@ -237,45 +237,25 @@ export function partitionSummaries(items, email) {
|
|
|
237
237
|
shared: sorted.filter(item => !isMine(item))
|
|
238
238
|
};
|
|
239
239
|
}
|
|
240
|
-
/** The
|
|
241
|
-
export const
|
|
242
|
-
function nonBlank(value) {
|
|
243
|
-
return typeof value === 'string' && value.trim() !== '';
|
|
244
|
-
}
|
|
240
|
+
/** The spec store type of the Notebook Demo specs, which are the Examples. */
|
|
241
|
+
export const NOTEBOOK_DEMO_TYPE = 'notebook-demo';
|
|
245
242
|
/**
|
|
246
|
-
* The
|
|
247
|
-
*
|
|
248
|
-
* A spec of another kind or version gives no sections, with a warning.
|
|
243
|
+
* The examples in a listing of Notebook Demo specs, ordered by name as people number
|
|
244
|
+
* them ("2 …" before "10 …").
|
|
249
245
|
*/
|
|
250
|
-
export function
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
continue;
|
|
264
|
-
}
|
|
265
|
-
const items = [];
|
|
266
|
-
for (const item of section.items) {
|
|
267
|
-
if (!isObject(item) || !isSpecId(item.id) || !nonBlank(item.title)) {
|
|
268
|
-
continue;
|
|
269
|
-
}
|
|
270
|
-
items.push(typeof item.summary === 'string'
|
|
271
|
-
? { id: item.id, title: item.title, summary: item.summary }
|
|
272
|
-
: { id: item.id, title: item.title });
|
|
273
|
-
}
|
|
274
|
-
if (items.length > 0) {
|
|
275
|
-
sections.push({ title: section.title, items });
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
return sections;
|
|
246
|
+
export function demoItemsFromSummaries(summaries) {
|
|
247
|
+
// No id check: the client's listing already rejects any summary without a spec id.
|
|
248
|
+
return summaries
|
|
249
|
+
.map(summary => {
|
|
250
|
+
const title = summary.name.trim() || 'Untitled';
|
|
251
|
+
return summary.description
|
|
252
|
+
? { id: summary.id, title, summary: summary.description }
|
|
253
|
+
: { id: summary.id, title };
|
|
254
|
+
})
|
|
255
|
+
.sort((a, b) => a.title.localeCompare(b.title, undefined, {
|
|
256
|
+
numeric: true,
|
|
257
|
+
sensitivity: 'base'
|
|
258
|
+
}));
|
|
279
259
|
}
|
|
280
260
|
/** Parse a spec store timestamp, which is UTC but usually has no zone designator. */
|
|
281
261
|
export function parseTimestamp(value) {
|
package/lib/share/plugin.js
CHANGED
|
@@ -9,7 +9,7 @@ import { startSignIn } from '../auth/startSignIn';
|
|
|
9
9
|
import { IOceanumAuth } from '../auth/tokens';
|
|
10
10
|
import { SpecStoreClient, SpecStoreError } from './client';
|
|
11
11
|
import { notebooksChanged } from './events';
|
|
12
|
-
import { buildSpecBody, isSpecId, METADATA_KEY, notebookFromRecord, notebookFromUpload, notebookNamesFor, OCEANUM_DIR, partitionSummaries, QUERY_PARAM, readLink, reportForShareFailures, sanitizeName, shareLink, uniqueNotebookPath, unlinkedNotebookFromRecord } from './notebook';
|
|
12
|
+
import { buildSpecBody, isSpecId, METADATA_KEY, notebookFromRecord, notebookFromUpload, notebookNamesFor, NOTEBOOK_DEMO_TYPE, OCEANUM_DIR, partitionSummaries, QUERY_PARAM, readLink, reportForShareFailures, sanitizeName, shareLink, uniqueNotebookPath, unlinkedNotebookFromRecord } from './notebook';
|
|
13
13
|
import { hideFileBrowser, moveToTop, NON_NOTEBOOK_NEW_COMMANDS, OCEANUM_PANEL_ID, pruneBeforeEachOpen, pruneFileMenu, routeSaves } from './oceanumOnly';
|
|
14
14
|
import { chooseNotebookFile } from './upload';
|
|
15
15
|
import { NotebookListBody, ShareBody } from './widgets';
|
|
@@ -51,6 +51,11 @@ export const sharePlugin = {
|
|
|
51
51
|
client: new SpecStoreClient({
|
|
52
52
|
specsUrl,
|
|
53
53
|
getAccessToken: () => auth.getAccessToken()
|
|
54
|
+
}),
|
|
55
|
+
demos: new SpecStoreClient({
|
|
56
|
+
specsUrl,
|
|
57
|
+
getAccessToken: () => auth.getAccessToken(),
|
|
58
|
+
specType: NOTEBOOK_DEMO_TYPE
|
|
54
59
|
})
|
|
55
60
|
};
|
|
56
61
|
const oceanumOnly = store !== null && ((_c = auth.environment) === null || _c === void 0 ? void 0 : _c.fileManagement) === 'oceanum';
|
|
@@ -293,7 +298,7 @@ export const sharePlugin = {
|
|
|
293
298
|
}
|
|
294
299
|
openingExamples.add(id);
|
|
295
300
|
try {
|
|
296
|
-
const record = await store.
|
|
301
|
+
const record = await store.demos.get(id);
|
|
297
302
|
const content = unlinkedNotebookFromRecord(record);
|
|
298
303
|
const path = uniqueNotebookPath(title || record.name, await oceanumFolderNames());
|
|
299
304
|
exampleCopies.set(id, await writeAndOpen(path, content));
|
package/package.json
CHANGED
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"showExamples": {
|
|
34
34
|
"type": "boolean",
|
|
35
35
|
"title": "Show examples",
|
|
36
|
-
"description": "List Oceanum's example notebooks
|
|
36
|
+
"description": "List Oceanum's example notebooks (its Notebook Demo specs) in the Notebooks tab. Also switched from the Examples heading.",
|
|
37
37
|
"default": true
|
|
38
38
|
}
|
|
39
39
|
},
|
package/src/StoredNotebooks.tsx
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
2
|
|
|
3
3
|
import { IOceanumAuth } from './auth/tokens';
|
|
4
|
-
import { SpecStoreClient } from './share/client';
|
|
4
|
+
import { SpecStoreClient, SpecStoreError } from './share/client';
|
|
5
5
|
import { notebooksChanged } from './share/events';
|
|
6
6
|
import {
|
|
7
|
+
demoItemsFromSummaries,
|
|
7
8
|
INotebookDemoItem,
|
|
8
|
-
INotebookDemoSection,
|
|
9
9
|
ISpecSummary,
|
|
10
|
-
|
|
10
|
+
NOTEBOOK_DEMO_TYPE,
|
|
11
11
|
partitionSummaries
|
|
12
12
|
} from './share/notebook';
|
|
13
13
|
import { SPEC_ID_ATTRIBUTE } from './share/plugin';
|
|
@@ -45,11 +45,11 @@ export interface IStoredNotebooksProps {
|
|
|
45
45
|
onSignIn?: () => void;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
/** The
|
|
48
|
+
/** The Notebook Demo specs the user can read, which are the Examples. */
|
|
49
49
|
type Examples =
|
|
50
50
|
| { state: 'none' }
|
|
51
51
|
| { state: 'error'; message: string }
|
|
52
|
-
| { state: 'ready';
|
|
52
|
+
| { state: 'ready'; items: INotebookDemoItem[] };
|
|
53
53
|
|
|
54
54
|
type Load =
|
|
55
55
|
| { state: 'loading' }
|
|
@@ -57,26 +57,29 @@ type Load =
|
|
|
57
57
|
| { state: 'ready'; items: ISpecSummary[]; examples: Examples };
|
|
58
58
|
|
|
59
59
|
/**
|
|
60
|
-
*
|
|
61
|
-
*
|
|
60
|
+
* List the Notebook Demo specs, each one an example notebook. Only org oceanum may
|
|
61
|
+
* create them (spec store 0.6.2), so every one listed is Oceanum's. Never rejects: a
|
|
62
|
+
* failure here is shown in the Examples section and leaves the rest of the tab alone.
|
|
62
63
|
*/
|
|
63
64
|
async function loadExamples(
|
|
64
65
|
auth: IOceanumAuth,
|
|
65
|
-
specsUrl: string
|
|
66
|
-
id: string | null
|
|
66
|
+
specsUrl: string
|
|
67
67
|
): Promise<Examples> {
|
|
68
|
-
if (!id) {
|
|
69
|
-
return { state: 'none' };
|
|
70
|
-
}
|
|
71
68
|
try {
|
|
72
69
|
const client = new SpecStoreClient({
|
|
73
70
|
specsUrl,
|
|
74
71
|
getAccessToken: () => auth.getAccessToken(),
|
|
75
|
-
specType:
|
|
72
|
+
specType: NOTEBOOK_DEMO_TYPE
|
|
76
73
|
});
|
|
77
|
-
|
|
78
|
-
|
|
74
|
+
return {
|
|
75
|
+
state: 'ready',
|
|
76
|
+
items: demoItemsFromSummaries(await client.list())
|
|
77
|
+
};
|
|
79
78
|
} catch (error) {
|
|
79
|
+
// A store with no Notebook Demo type has no examples, which is not an error.
|
|
80
|
+
if (error instanceof SpecStoreError && error.kind === 'not-found') {
|
|
81
|
+
return { state: 'none' };
|
|
82
|
+
}
|
|
80
83
|
return {
|
|
81
84
|
state: 'error',
|
|
82
85
|
message:
|
|
@@ -86,9 +89,10 @@ async function loadExamples(
|
|
|
86
89
|
}
|
|
87
90
|
|
|
88
91
|
/**
|
|
89
|
-
* The
|
|
90
|
-
* stored-notebook row class: the share plugin's row menu
|
|
91
|
-
* is bound to those, and none of it applies to
|
|
92
|
+
* The examples: Oceanum's Notebook Demo specs. Their rows deliberately leave out
|
|
93
|
+
* SPEC_ID_ATTRIBUTE and the stored-notebook row class: the share plugin's row menu
|
|
94
|
+
* (Open, Rename, Share, Delete) is bound to those, and none of it applies to
|
|
95
|
+
* Oceanum's own example records.
|
|
92
96
|
*/
|
|
93
97
|
function ExampleSections({
|
|
94
98
|
examples,
|
|
@@ -105,13 +109,7 @@ function ExampleSections({
|
|
|
105
109
|
if (examples.state === 'none' || show === null || (!show && !onShowChange)) {
|
|
106
110
|
return null;
|
|
107
111
|
}
|
|
108
|
-
const count =
|
|
109
|
-
examples.state === 'ready'
|
|
110
|
-
? examples.sections.reduce(
|
|
111
|
-
(total, section) => total + section.items.length,
|
|
112
|
-
0
|
|
113
|
-
)
|
|
114
|
-
: null;
|
|
112
|
+
const count = examples.state === 'ready' ? examples.items.length : null;
|
|
115
113
|
if (count === 0) {
|
|
116
114
|
return null;
|
|
117
115
|
}
|
|
@@ -155,45 +153,39 @@ function ExampleSections({
|
|
|
155
153
|
return (
|
|
156
154
|
<div className="oceanum-notebooks-section">
|
|
157
155
|
{heading}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
{
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
</li>
|
|
192
|
-
);
|
|
193
|
-
})}
|
|
194
|
-
</ul>
|
|
195
|
-
</div>
|
|
196
|
-
))}
|
|
156
|
+
<ul className="oceanum-notebooks-list">
|
|
157
|
+
{examples.items.map(item => {
|
|
158
|
+
const attributes = {
|
|
159
|
+
[EXAMPLE_ID_ATTRIBUTE]: item.id,
|
|
160
|
+
title: item.summary
|
|
161
|
+
};
|
|
162
|
+
// Names come from the spec store: React escapes them, never set as HTML.
|
|
163
|
+
const name = (
|
|
164
|
+
<span className="oceanum-notebooks-name">{item.title}</span>
|
|
165
|
+
);
|
|
166
|
+
return (
|
|
167
|
+
<li key={item.id}>
|
|
168
|
+
{onOpen ? (
|
|
169
|
+
<button
|
|
170
|
+
type="button"
|
|
171
|
+
className="oceanum-notebooks-example"
|
|
172
|
+
onClick={() => onOpen(item)}
|
|
173
|
+
{...attributes}
|
|
174
|
+
>
|
|
175
|
+
{name}
|
|
176
|
+
</button>
|
|
177
|
+
) : (
|
|
178
|
+
<div
|
|
179
|
+
className="oceanum-notebooks-example oceanum-notebooks-item-static"
|
|
180
|
+
{...attributes}
|
|
181
|
+
>
|
|
182
|
+
{name}
|
|
183
|
+
</div>
|
|
184
|
+
)}
|
|
185
|
+
</li>
|
|
186
|
+
);
|
|
187
|
+
})}
|
|
188
|
+
</ul>
|
|
197
189
|
</div>
|
|
198
190
|
);
|
|
199
191
|
}
|
|
@@ -302,11 +294,9 @@ export function StoredNotebooks({
|
|
|
302
294
|
specsUrl: specs,
|
|
303
295
|
getAccessToken: () => auth.getAccessToken()
|
|
304
296
|
});
|
|
305
|
-
// Only the record the deployment names: every signed-in user can create
|
|
306
|
-
// notebook-demo records, so the examples never come from a listing.
|
|
307
297
|
const [items, examples] = await Promise.all([
|
|
308
298
|
client.list(),
|
|
309
|
-
loadExamples(auth, specs
|
|
299
|
+
loadExamples(auth, specs)
|
|
310
300
|
]);
|
|
311
301
|
if (!cancelled) {
|
|
312
302
|
setLoad({ state: 'ready', items, examples });
|
|
@@ -369,15 +359,6 @@ export function StoredNotebooks({
|
|
|
369
359
|
}
|
|
370
360
|
|
|
371
361
|
const { mine, shared } = partitionSummaries(load.items, user.email);
|
|
372
|
-
// A public example is readable by everyone, so the listing returns it as shared with
|
|
373
|
-
// every signed-in user; it is shown under Examples instead.
|
|
374
|
-
const exampleIds = new Set(
|
|
375
|
-
load.examples.state === 'ready'
|
|
376
|
-
? load.examples.sections.flatMap(section =>
|
|
377
|
-
section.items.map(item => item.id)
|
|
378
|
-
)
|
|
379
|
-
: []
|
|
380
|
-
);
|
|
381
362
|
return (
|
|
382
363
|
<div className="oceanum-notebooks">
|
|
383
364
|
{/* Without a spec store the upload command does nothing. */}
|
|
@@ -401,7 +382,7 @@ export function StoredNotebooks({
|
|
|
401
382
|
/>
|
|
402
383
|
<Section
|
|
403
384
|
title="Shared with me"
|
|
404
|
-
items={shared
|
|
385
|
+
items={shared}
|
|
405
386
|
empty="No notebooks have been shared with you."
|
|
406
387
|
onOpen={onOpen}
|
|
407
388
|
/>
|
|
@@ -160,7 +160,6 @@ describe('the Oceanum panel', () => {
|
|
|
160
160
|
});
|
|
161
161
|
|
|
162
162
|
describe('the Examples switch in the Oceanum panel', () => {
|
|
163
|
-
const DEMO_ID = '3e4f5a6b-4444-4a2b-8c3d-9e8f7a6b5c4d';
|
|
164
163
|
const EXAMPLE = '0b7d9f2e-1111-4a2b-8c3d-9e8f7a6b5c4d';
|
|
165
164
|
const originalFetch = globalThis.fetch;
|
|
166
165
|
afterEach(() => {
|
|
@@ -179,27 +178,23 @@ describe('the Examples switch in the Oceanum panel', () => {
|
|
|
179
178
|
|
|
180
179
|
it('saves the choice, and follows the setting once it has changed', async () => {
|
|
181
180
|
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
|
182
|
-
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
version: 1,
|
|
193
|
-
sections: [
|
|
194
|
-
{ title: 'Start', items: [{ id: EXAMPLE, title: 'Query' }] }
|
|
195
|
-
]
|
|
181
|
+
// The Notebook Demo listing has one example; the user has no notebooks.
|
|
182
|
+
const demos = String(input).endsWith('/specs/notebook-demo');
|
|
183
|
+
const data: unknown = demos
|
|
184
|
+
? [
|
|
185
|
+
{
|
|
186
|
+
id: EXAMPLE,
|
|
187
|
+
name: 'Query',
|
|
188
|
+
description: null as string | null,
|
|
189
|
+
modified: '2026-09-22T10:00:00',
|
|
190
|
+
creator: null as string | null
|
|
196
191
|
}
|
|
197
|
-
|
|
192
|
+
]
|
|
198
193
|
: [];
|
|
199
194
|
return { ok: true, status: 200, json: async () => data } as Response;
|
|
200
195
|
}) as typeof fetch;
|
|
201
196
|
const auth = {
|
|
202
|
-
environment: {
|
|
197
|
+
environment: {},
|
|
203
198
|
urls: { specs: 'https://specs.example.com' },
|
|
204
199
|
ready: Promise.resolve(),
|
|
205
200
|
user: {
|
|
@@ -86,7 +86,10 @@ describe('the Notebooks tab while signed in', () => {
|
|
|
86
86
|
const fetches: string[] = [];
|
|
87
87
|
const originalFetch = globalThis.fetch;
|
|
88
88
|
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
|
89
|
-
|
|
89
|
+
// Counts the notebook listing only; each load also lists the demos.
|
|
90
|
+
if (String(input).endsWith('/specs/notebook')) {
|
|
91
|
+
fetches.push(String(input));
|
|
92
|
+
}
|
|
90
93
|
return {
|
|
91
94
|
ok: true,
|
|
92
95
|
status: 200,
|
|
@@ -130,53 +133,41 @@ describe('the Notebooks tab while signed in', () => {
|
|
|
130
133
|
|
|
131
134
|
describe('the Examples in the Notebooks tab', () => {
|
|
132
135
|
const MINE = '6f1c1a52-4b8e-4c0f-9a57-1d2e3f4a5b6c';
|
|
133
|
-
const
|
|
134
|
-
const
|
|
136
|
+
const QUERY = '0b7d9f2e-1111-4a2b-8c3d-9e8f7a6b5c4d';
|
|
137
|
+
const PLOT = '1c2d3e4f-2222-4a2b-8c3d-9e8f7a6b5c4d';
|
|
135
138
|
const SHARED = '2d3e4f5a-3333-4a2b-8c3d-9e8f7a6b5c4d';
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
const summary = (
|
|
139
|
+
const SPECTRA = '3e4f5a6b-4444-4a2b-8c3d-9e8f7a6b5c4d';
|
|
140
|
+
|
|
141
|
+
const summary = (
|
|
142
|
+
id: string,
|
|
143
|
+
name: string,
|
|
144
|
+
creator: string | null,
|
|
145
|
+
description: string | null = null
|
|
146
|
+
) => ({
|
|
139
147
|
id,
|
|
140
148
|
name,
|
|
141
|
-
description
|
|
149
|
+
description,
|
|
142
150
|
modified: '2026-09-12T10:00:00',
|
|
143
151
|
creator
|
|
144
152
|
});
|
|
145
153
|
|
|
146
|
-
// The
|
|
154
|
+
// The user's notebooks.
|
|
147
155
|
const LISTING = [
|
|
148
156
|
summary(MINE, 'Mine', 'me@example.com'),
|
|
149
|
-
summary(PUBLIC_EXAMPLE, 'Query (example)', null),
|
|
150
157
|
summary(SHARED, 'Shared', null)
|
|
151
158
|
];
|
|
152
159
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
};
|
|
160
|
+
// The Notebook Demo specs, each one an example notebook, in no particular order.
|
|
161
|
+
const DEMOS = [
|
|
162
|
+
summary(PLOT, '10 Plot waves', null),
|
|
163
|
+
summary(SPECTRA, 'Spectra', null),
|
|
164
|
+
summary(QUERY, '2 Query a datasource', null, 'One line')
|
|
165
|
+
];
|
|
175
166
|
|
|
176
167
|
const ok = (data: unknown): Response =>
|
|
177
168
|
({ ok: true, status: 200, json: async () => data }) as unknown as Response;
|
|
178
169
|
|
|
179
|
-
function signedIn(
|
|
170
|
+
function signedIn(): IOceanumAuth {
|
|
180
171
|
const auth = signedOut();
|
|
181
172
|
(auth as { user: IOceanumUser | null }).user = {
|
|
182
173
|
sub: 'auth0|1',
|
|
@@ -186,20 +177,19 @@ describe('the Examples in the Notebooks tab', () => {
|
|
|
186
177
|
colorScheme: null
|
|
187
178
|
};
|
|
188
179
|
auth.getAccessToken = async () => 'tok';
|
|
189
|
-
(auth as { environment: unknown }).environment =
|
|
190
|
-
? { notebookDemo }
|
|
191
|
-
: {};
|
|
180
|
+
(auth as { environment: unknown }).environment = {};
|
|
192
181
|
return auth;
|
|
193
182
|
}
|
|
194
183
|
|
|
195
184
|
const originalFetch = globalThis.fetch;
|
|
196
185
|
let fetches: string[];
|
|
197
|
-
|
|
186
|
+
const DEMO_LISTING = 'https://specs.example.com/specs/notebook-demo';
|
|
187
|
+
function stubFetch(demos: () => Response = () => ok(DEMOS)): void {
|
|
198
188
|
fetches = [];
|
|
199
189
|
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
|
200
190
|
const url = String(input);
|
|
201
191
|
fetches.push(url);
|
|
202
|
-
return url
|
|
192
|
+
return url === DEMO_LISTING ? demos() : ok(LISTING);
|
|
203
193
|
}) as typeof fetch;
|
|
204
194
|
}
|
|
205
195
|
afterEach(() => {
|
|
@@ -268,73 +258,83 @@ describe('the Examples in the Notebooks tab', () => {
|
|
|
268
258
|
Widget.detach(widget);
|
|
269
259
|
});
|
|
270
260
|
|
|
271
|
-
it('lists
|
|
261
|
+
it('lists every Notebook Demo spec as an example, ordered by name', async () => {
|
|
272
262
|
stubFetch();
|
|
273
|
-
const widget = await render(signedIn(
|
|
263
|
+
const widget = await render(signedIn());
|
|
274
264
|
|
|
275
265
|
const examples = section(widget, 'Examples')!;
|
|
276
266
|
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
267
|
const rows = Array.from(
|
|
284
268
|
examples.querySelectorAll<HTMLElement>('[data-example-id]')
|
|
285
269
|
);
|
|
270
|
+
// Numbered as people number them: 2 before 10.
|
|
286
271
|
expect(rows.map(row => row.textContent)).toEqual([
|
|
287
|
-
'Query a datasource',
|
|
288
|
-
'Plot waves',
|
|
272
|
+
'2 Query a datasource',
|
|
273
|
+
'10 Plot waves',
|
|
289
274
|
'Spectra'
|
|
290
275
|
]);
|
|
291
276
|
expect(rows.map(row => row.getAttribute('data-example-id'))).toEqual([
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
277
|
+
QUERY,
|
|
278
|
+
PLOT,
|
|
279
|
+
SPECTRA
|
|
295
280
|
]);
|
|
296
281
|
expect(rows[0].getAttribute('title')).toBe('One line');
|
|
297
282
|
expect(
|
|
298
283
|
examples.querySelector('.oceanum-notebooks-count')?.textContent
|
|
299
284
|
).toBe('3');
|
|
300
|
-
expect(fetches).toContain(
|
|
301
|
-
`https://specs.example.com/specs/notebook-demo/${DEMO_ID}`
|
|
302
|
-
);
|
|
285
|
+
expect(fetches).toContain(DEMO_LISTING);
|
|
303
286
|
widget.dispose();
|
|
304
287
|
});
|
|
305
288
|
|
|
306
|
-
it('
|
|
289
|
+
it('leaves My notebooks and Shared with me as listed', async () => {
|
|
307
290
|
stubFetch();
|
|
308
|
-
const widget = await render(signedIn(
|
|
291
|
+
const widget = await render(signedIn());
|
|
309
292
|
|
|
293
|
+
expect(idsIn(section(widget, 'My notebooks'), 'data-spec-id')).toEqual([
|
|
294
|
+
MINE
|
|
295
|
+
]);
|
|
310
296
|
expect(idsIn(section(widget, 'Shared with me'), 'data-spec-id')).toEqual([
|
|
311
297
|
SHARED
|
|
312
298
|
]);
|
|
299
|
+
widget.dispose();
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it('shows no Examples section when there are no demos', async () => {
|
|
303
|
+
stubFetch(() => ok([]));
|
|
304
|
+
const widget = await render(signedIn());
|
|
305
|
+
|
|
306
|
+
expect(section(widget, 'Examples')).toBeNull();
|
|
307
|
+
widget.dispose();
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it('shows no Examples, and no error, where the store has no Notebook Demo type', async () => {
|
|
311
|
+
stubFetch(
|
|
312
|
+
() => ({ ok: false, status: 404, json: async () => ({}) }) as Response
|
|
313
|
+
);
|
|
314
|
+
const widget = await render(signedIn());
|
|
315
|
+
|
|
316
|
+
expect(section(widget, 'Examples')).toBeNull();
|
|
313
317
|
expect(idsIn(section(widget, 'My notebooks'), 'data-spec-id')).toEqual([
|
|
314
318
|
MINE
|
|
315
319
|
]);
|
|
316
320
|
widget.dispose();
|
|
317
321
|
});
|
|
318
322
|
|
|
319
|
-
it('asks for
|
|
323
|
+
it('asks for no demos while signed out', async () => {
|
|
320
324
|
stubFetch();
|
|
321
|
-
const widget =
|
|
325
|
+
const widget = new Harness(signedOut());
|
|
326
|
+
Widget.attach(widget, document.body);
|
|
327
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
322
328
|
|
|
323
|
-
expect(
|
|
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
|
-
]);
|
|
329
|
+
expect(fetches).toEqual([]);
|
|
330
330
|
widget.dispose();
|
|
331
331
|
});
|
|
332
332
|
|
|
333
|
-
it('says so when the
|
|
333
|
+
it('says so when the demos cannot be listed, and still lists the rest', async () => {
|
|
334
334
|
stubFetch(
|
|
335
|
-
() => ({ ok: false, status:
|
|
335
|
+
() => ({ ok: false, status: 500, json: async () => ({}) }) as Response
|
|
336
336
|
);
|
|
337
|
-
const widget = await render(signedIn(
|
|
337
|
+
const widget = await render(signedIn());
|
|
338
338
|
|
|
339
339
|
const examples = section(widget, 'Examples')!;
|
|
340
340
|
expect(
|
|
@@ -345,7 +345,6 @@ describe('the Examples in the Notebooks tab', () => {
|
|
|
345
345
|
MINE
|
|
346
346
|
]);
|
|
347
347
|
expect(idsIn(section(widget, 'Shared with me'), 'data-spec-id')).toEqual([
|
|
348
|
-
PUBLIC_EXAMPLE,
|
|
349
348
|
SHARED
|
|
350
349
|
]);
|
|
351
350
|
widget.dispose();
|
|
@@ -354,7 +353,7 @@ describe('the Examples in the Notebooks tab', () => {
|
|
|
354
353
|
it('keeps example rows out of the stored-notebook row menu', async () => {
|
|
355
354
|
// Its Rename and Delete would act on Oceanum's own example records.
|
|
356
355
|
stubFetch();
|
|
357
|
-
const widget = await render(signedIn(
|
|
356
|
+
const widget = await render(signedIn(), () => undefined);
|
|
358
357
|
|
|
359
358
|
const rows = section(widget, 'Examples')!.querySelectorAll('button');
|
|
360
359
|
expect(rows).toHaveLength(3);
|
|
@@ -367,13 +366,13 @@ describe('the Examples in the Notebooks tab', () => {
|
|
|
367
366
|
it('opens an example through onOpenExample, with its title', async () => {
|
|
368
367
|
stubFetch();
|
|
369
368
|
const opened: INotebookDemoItem[] = [];
|
|
370
|
-
const widget = await render(signedIn(
|
|
369
|
+
const widget = await render(signedIn(), item => opened.push(item));
|
|
371
370
|
|
|
372
371
|
section(widget, 'Examples')!
|
|
373
|
-
.querySelector<HTMLButtonElement>(`[data-example-id="${
|
|
372
|
+
.querySelector<HTMLButtonElement>(`[data-example-id="${PLOT}"]`)!
|
|
374
373
|
.click();
|
|
375
374
|
|
|
376
|
-
expect(opened).toEqual([{ id:
|
|
375
|
+
expect(opened).toEqual([{ id: PLOT, title: '10 Plot waves' }]);
|
|
377
376
|
widget.dispose();
|
|
378
377
|
});
|
|
379
378
|
|
|
@@ -385,7 +384,7 @@ describe('the Examples in the Notebooks tab', () => {
|
|
|
385
384
|
it('puts a switch on the Examples heading that asks to hide them', async () => {
|
|
386
385
|
stubFetch();
|
|
387
386
|
const changes: boolean[] = [];
|
|
388
|
-
const widget = await render(signedIn(
|
|
387
|
+
const widget = await render(signedIn(), undefined, {
|
|
389
388
|
showExamples: true,
|
|
390
389
|
onShowExamplesChange: show => changes.push(show)
|
|
391
390
|
});
|
|
@@ -408,7 +407,7 @@ describe('the Examples in the Notebooks tab', () => {
|
|
|
408
407
|
it('keeps hidden examples folded under their heading, with the switch to bring them back', async () => {
|
|
409
408
|
stubFetch();
|
|
410
409
|
const changes: boolean[] = [];
|
|
411
|
-
const widget = await render(signedIn(
|
|
410
|
+
const widget = await render(signedIn(), undefined, {
|
|
412
411
|
showExamples: false,
|
|
413
412
|
onShowExamplesChange: show => changes.push(show)
|
|
414
413
|
});
|
|
@@ -418,14 +417,8 @@ describe('the Examples in the Notebooks tab', () => {
|
|
|
418
417
|
expect(
|
|
419
418
|
examples.querySelector('.oceanum-notebooks-count')?.textContent
|
|
420
419
|
).toBe('3');
|
|
421
|
-
// Hidden, not forgotten: the
|
|
422
|
-
|
|
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
|
-
]);
|
|
420
|
+
// Hidden, not forgotten: the demos are still listed, for the count.
|
|
421
|
+
expect(fetches).toContain(DEMO_LISTING);
|
|
429
422
|
|
|
430
423
|
const toggle = switchIn(widget)!;
|
|
431
424
|
expect(toggle.getAttribute('aria-checked')).toBe('false');
|
|
@@ -437,9 +430,9 @@ describe('the Examples in the Notebooks tab', () => {
|
|
|
437
430
|
|
|
438
431
|
it('shows no error for examples the user has hidden', async () => {
|
|
439
432
|
stubFetch(
|
|
440
|
-
() => ({ ok: false, status:
|
|
433
|
+
() => ({ ok: false, status: 500, json: async () => ({}) }) as Response
|
|
441
434
|
);
|
|
442
|
-
const widget = await render(signedIn(
|
|
435
|
+
const widget = await render(signedIn(), undefined, {
|
|
443
436
|
showExamples: false,
|
|
444
437
|
onShowExamplesChange: () => undefined
|
|
445
438
|
});
|
|
@@ -452,7 +445,7 @@ describe('the Examples in the Notebooks tab', () => {
|
|
|
452
445
|
|
|
453
446
|
it('offers no switch where the choice could not be kept', async () => {
|
|
454
447
|
stubFetch();
|
|
455
|
-
const widget = await render(signedIn(
|
|
448
|
+
const widget = await render(signedIn());
|
|
456
449
|
|
|
457
450
|
expect(switchIn(widget)).toBeNull();
|
|
458
451
|
expect(
|
|
@@ -464,7 +457,7 @@ describe('the Examples in the Notebooks tab', () => {
|
|
|
464
457
|
it('shows no examples while the setting is still loading', async () => {
|
|
465
458
|
// Otherwise examples a user has hidden would show for a moment on every reload.
|
|
466
459
|
stubFetch();
|
|
467
|
-
const widget = await render(signedIn(
|
|
460
|
+
const widget = await render(signedIn(), undefined, {
|
|
468
461
|
showExamples: null,
|
|
469
462
|
onShowExamplesChange: () => undefined
|
|
470
463
|
});
|
|
@@ -479,7 +472,7 @@ describe('the Examples in the Notebooks tab', () => {
|
|
|
479
472
|
|
|
480
473
|
it('shows nothing for hidden examples that no switch could bring back', async () => {
|
|
481
474
|
stubFetch();
|
|
482
|
-
const widget = await render(signedIn(
|
|
475
|
+
const widget = await render(signedIn(), undefined, {
|
|
483
476
|
showExamples: false
|
|
484
477
|
});
|
|
485
478
|
|
|
@@ -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
|
|
|
@@ -100,6 +100,7 @@ const record = (id: string, name: string): ISpecRecord => ({
|
|
|
100
100
|
*/
|
|
101
101
|
function store(stored: Record<string, INotebookContent> = {}) {
|
|
102
102
|
const calls: string[] = [];
|
|
103
|
+
const urls: string[] = [];
|
|
103
104
|
const bodies: Record<string, unknown>[] = [];
|
|
104
105
|
const names: Record<string, string> = { [ID_A]: 'Alpha', [ID_B]: 'Beta' };
|
|
105
106
|
const fetcher = async (
|
|
@@ -110,6 +111,7 @@ function store(stored: Record<string, INotebookContent> = {}) {
|
|
|
110
111
|
const method = init?.method ?? 'GET';
|
|
111
112
|
const id = url.split('/').pop() ?? '';
|
|
112
113
|
calls.push(`${method} ${id}`);
|
|
114
|
+
urls.push(url);
|
|
113
115
|
if (init?.body) {
|
|
114
116
|
bodies.push(JSON.parse(String(init.body)));
|
|
115
117
|
}
|
|
@@ -125,7 +127,7 @@ function store(stored: Record<string, INotebookContent> = {}) {
|
|
|
125
127
|
}
|
|
126
128
|
} as unknown as Response;
|
|
127
129
|
};
|
|
128
|
-
return { calls, bodies, fetcher };
|
|
130
|
+
return { calls, urls, bodies, fetcher };
|
|
129
131
|
}
|
|
130
132
|
|
|
131
133
|
/** A notebook panel with just the surface the plugin touches. */
|
|
@@ -159,6 +161,8 @@ function panel(id: string, path: string, link?: string): NotebookPanel {
|
|
|
159
161
|
interface IHarness {
|
|
160
162
|
commands: CommandRegistry;
|
|
161
163
|
calls: string[];
|
|
164
|
+
/** Every URL the plugin fetched, in order. */
|
|
165
|
+
urls: string[];
|
|
162
166
|
bodies: Record<string, unknown>[];
|
|
163
167
|
saved: string[];
|
|
164
168
|
/** What each save wrote, by path. */
|
|
@@ -183,7 +187,7 @@ function activate(
|
|
|
183
187
|
} = {}
|
|
184
188
|
): IHarness {
|
|
185
189
|
const commands = new CommandRegistry();
|
|
186
|
-
const { calls, bodies, fetcher } = store(options.stored);
|
|
190
|
+
const { calls, urls, bodies, fetcher } = store(options.stored);
|
|
187
191
|
const saved: string[] = [];
|
|
188
192
|
const written: Record<string, unknown> = {};
|
|
189
193
|
const opened: string[] = [];
|
|
@@ -303,6 +307,7 @@ function activate(
|
|
|
303
307
|
return {
|
|
304
308
|
commands,
|
|
305
309
|
calls,
|
|
310
|
+
urls,
|
|
306
311
|
bodies,
|
|
307
312
|
saved,
|
|
308
313
|
written,
|
|
@@ -493,6 +498,8 @@ describe('opening an example', () => {
|
|
|
493
498
|
|
|
494
499
|
// Read, never written back: the example's record is Oceanum's.
|
|
495
500
|
expect(harness.calls).toEqual([`GET ${ID_A}`]);
|
|
501
|
+
// An example is a Notebook Demo spec, not a notebook.
|
|
502
|
+
expect(harness.urls).toEqual([`${SPECS}/specs/notebook-demo/${ID_A}`]);
|
|
496
503
|
const path = 'Oceanum/Query a datasource.ipynb';
|
|
497
504
|
expect(harness.saved).toEqual([path]);
|
|
498
505
|
expect(harness.opened).toEqual([path]);
|
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. */
|
package/src/share/notebook.ts
CHANGED
|
@@ -352,68 +352,37 @@ export function partitionSummaries(
|
|
|
352
352
|
};
|
|
353
353
|
}
|
|
354
354
|
|
|
355
|
-
/** The
|
|
356
|
-
export const
|
|
355
|
+
/** The spec store type of the Notebook Demo specs, which are the Examples. */
|
|
356
|
+
export const NOTEBOOK_DEMO_TYPE = 'notebook-demo';
|
|
357
357
|
|
|
358
|
-
/** One example
|
|
358
|
+
/** One example: a Notebook Demo spec, whose body is the example notebook. */
|
|
359
359
|
export interface INotebookDemoItem {
|
|
360
360
|
id: string;
|
|
361
361
|
title: string;
|
|
362
362
|
summary?: string;
|
|
363
363
|
}
|
|
364
364
|
|
|
365
|
-
/** A titled group of examples. */
|
|
366
|
-
export interface INotebookDemoSection {
|
|
367
|
-
title: string;
|
|
368
|
-
items: INotebookDemoItem[];
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
function nonBlank(value: unknown): value is string {
|
|
372
|
-
return typeof value === 'string' && value.trim() !== '';
|
|
373
|
-
}
|
|
374
|
-
|
|
375
365
|
/**
|
|
376
|
-
* The
|
|
377
|
-
*
|
|
378
|
-
* A spec of another kind or version gives no sections, with a warning.
|
|
366
|
+
* The examples in a listing of Notebook Demo specs, ordered by name as people number
|
|
367
|
+
* them ("2 …" before "10 …").
|
|
379
368
|
*/
|
|
380
|
-
export function
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
369
|
+
export function demoItemsFromSummaries(
|
|
370
|
+
summaries: readonly ISpecSummary[]
|
|
371
|
+
): INotebookDemoItem[] {
|
|
372
|
+
// No id check: the client's listing already rejects any summary without a spec id.
|
|
373
|
+
return summaries
|
|
374
|
+
.map(summary => {
|
|
375
|
+
const title = summary.name.trim() || 'Untitled';
|
|
376
|
+
return summary.description
|
|
377
|
+
? { id: summary.id, title, summary: summary.description }
|
|
378
|
+
: { id: summary.id, title };
|
|
379
|
+
})
|
|
380
|
+
.sort((a, b) =>
|
|
381
|
+
a.title.localeCompare(b.title, undefined, {
|
|
382
|
+
numeric: true,
|
|
383
|
+
sensitivity: 'base'
|
|
384
|
+
})
|
|
389
385
|
);
|
|
390
|
-
return [];
|
|
391
|
-
}
|
|
392
|
-
const sections: INotebookDemoSection[] = [];
|
|
393
|
-
for (const section of spec.sections) {
|
|
394
|
-
if (
|
|
395
|
-
!isObject(section) ||
|
|
396
|
-
!nonBlank(section.title) ||
|
|
397
|
-
!Array.isArray(section.items)
|
|
398
|
-
) {
|
|
399
|
-
continue;
|
|
400
|
-
}
|
|
401
|
-
const items: INotebookDemoItem[] = [];
|
|
402
|
-
for (const item of section.items) {
|
|
403
|
-
if (!isObject(item) || !isSpecId(item.id) || !nonBlank(item.title)) {
|
|
404
|
-
continue;
|
|
405
|
-
}
|
|
406
|
-
items.push(
|
|
407
|
-
typeof item.summary === 'string'
|
|
408
|
-
? { id: item.id, title: item.title, summary: item.summary }
|
|
409
|
-
: { id: item.id, title: item.title }
|
|
410
|
-
);
|
|
411
|
-
}
|
|
412
|
-
if (items.length > 0) {
|
|
413
|
-
sections.push({ title: section.title, items });
|
|
414
|
-
}
|
|
415
|
-
}
|
|
416
|
-
return sections;
|
|
417
386
|
}
|
|
418
387
|
|
|
419
388
|
/** Parse a spec store timestamp, which is UTC but usually has no zone designator. */
|
package/src/share/plugin.ts
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
notebookFromRecord,
|
|
32
32
|
notebookFromUpload,
|
|
33
33
|
notebookNamesFor,
|
|
34
|
+
NOTEBOOK_DEMO_TYPE,
|
|
34
35
|
OCEANUM_DIR,
|
|
35
36
|
partitionSummaries,
|
|
36
37
|
QUERY_PARAM,
|
|
@@ -75,6 +76,8 @@ const UNAVAILABLE =
|
|
|
75
76
|
interface IStore {
|
|
76
77
|
specsUrl: string;
|
|
77
78
|
client: SpecStoreClient;
|
|
79
|
+
/** The Notebook Demo specs, which are the Examples. */
|
|
80
|
+
demos: SpecStoreClient;
|
|
78
81
|
}
|
|
79
82
|
|
|
80
83
|
/**
|
|
@@ -110,6 +113,11 @@ export const sharePlugin: JupyterFrontEndPlugin<void> = {
|
|
|
110
113
|
client: new SpecStoreClient({
|
|
111
114
|
specsUrl,
|
|
112
115
|
getAccessToken: () => auth.getAccessToken()
|
|
116
|
+
}),
|
|
117
|
+
demos: new SpecStoreClient({
|
|
118
|
+
specsUrl,
|
|
119
|
+
getAccessToken: () => auth.getAccessToken(),
|
|
120
|
+
specType: NOTEBOOK_DEMO_TYPE
|
|
113
121
|
})
|
|
114
122
|
};
|
|
115
123
|
const oceanumOnly =
|
|
@@ -409,7 +417,7 @@ export const sharePlugin: JupyterFrontEndPlugin<void> = {
|
|
|
409
417
|
}
|
|
410
418
|
openingExamples.add(id);
|
|
411
419
|
try {
|
|
412
|
-
const record = await store.
|
|
420
|
+
const record = await store.demos.get(id);
|
|
413
421
|
const content = unlinkedNotebookFromRecord(record);
|
|
414
422
|
const path = uniqueNotebookPath(
|
|
415
423
|
title || record.name,
|
package/style/index.css
CHANGED
|
@@ -717,13 +717,6 @@
|
|
|
717
717
|
}
|
|
718
718
|
}
|
|
719
719
|
|
|
720
|
-
/* A group within Examples: quieter than the tab's section headings. */
|
|
721
|
-
.oceanum-notebooks-subheading {
|
|
722
|
-
padding: 4px 8px 0;
|
|
723
|
-
color: var(--jp-ui-font-color2);
|
|
724
|
-
font-size: var(--jp-ui-font-size0);
|
|
725
|
-
}
|
|
726
|
-
|
|
727
720
|
.oceanum-notebooks-name {
|
|
728
721
|
overflow: hidden;
|
|
729
722
|
text-overflow: ellipsis;
|