@oceanum/oceanumlab 4.7.1 → 4.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/DatameshWidget.d.ts +11 -0
- package/lib/DatameshWidget.js +35 -5
- package/lib/StoredNotebooks.d.ts +27 -5
- package/lib/StoredNotebooks.js +141 -24
- package/lib/auth/serverAuth.js +1 -0
- package/lib/auth/startSignIn.d.ts +11 -0
- package/lib/auth/startSignIn.js +16 -0
- package/lib/auth/tokens.d.ts +13 -0
- package/lib/index.d.ts +1 -1
- package/lib/index.js +43 -7
- package/lib/share/client.d.ts +86 -0
- package/lib/share/client.js +206 -0
- package/lib/share/events.d.ts +7 -0
- package/lib/share/events.js +9 -0
- package/lib/share/notebook.d.ts +216 -0
- package/lib/share/notebook.js +355 -0
- package/lib/share/oceanumOnly.d.ts +72 -0
- package/lib/share/oceanumOnly.js +127 -0
- package/lib/share/plugin.d.ts +23 -0
- package/lib/share/plugin.js +981 -0
- package/lib/share/upload.d.ts +6 -0
- package/lib/share/upload.js +27 -0
- package/lib/share/widgets.d.ts +25 -0
- package/lib/share/widgets.js +154 -0
- package/package.json +5 -1
- package/schema/datamesh-connect.json +6 -0
- package/src/DatameshWidget.tsx +79 -2
- package/src/StoredNotebooks.tsx +274 -24
- package/src/__tests__/OceanumPanel.spec.tsx +159 -5
- package/src/__tests__/StoredNotebooks.spec.tsx +528 -0
- package/src/__tests__/oceanumOnly.spec.ts +317 -0
- package/src/__tests__/settingsSchema.spec.ts +11 -1
- package/src/__tests__/shareClient.spec.ts +328 -0
- package/src/__tests__/shareNotebook.spec.ts +624 -0
- package/src/__tests__/sharePlugin.spec.ts +664 -0
- package/src/auth/serverAuth.ts +1 -0
- package/src/auth/startSignIn.ts +23 -0
- package/src/auth/tokens.ts +13 -0
- package/src/index.ts +43 -7
- package/src/share/client.ts +291 -0
- package/src/share/events.ts +10 -0
- package/src/share/notebook.ts +525 -0
- package/src/share/oceanumOnly.ts +177 -0
- package/src/share/plugin.ts +1220 -0
- package/src/share/upload.ts +27 -0
- package/src/share/widgets.ts +217 -0
- package/style/index.css +212 -18
- package/lib/specStore.d.ts +0 -37
- package/lib/specStore.js +0 -57
- package/src/__tests__/specStore.spec.ts +0 -126
- package/src/specStore.ts +0 -82
package/src/StoredNotebooks.tsx
CHANGED
|
@@ -1,22 +1,202 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
2
|
|
|
3
3
|
import { IOceanumAuth } from './auth/tokens';
|
|
4
|
-
import {
|
|
4
|
+
import { SpecStoreClient } from './share/client';
|
|
5
|
+
import { notebooksChanged } from './share/events';
|
|
6
|
+
import {
|
|
7
|
+
INotebookDemoItem,
|
|
8
|
+
INotebookDemoSection,
|
|
9
|
+
ISpecSummary,
|
|
10
|
+
parseNotebookDemo,
|
|
11
|
+
partitionSummaries
|
|
12
|
+
} from './share/notebook';
|
|
13
|
+
import { SPEC_ID_ATTRIBUTE } from './share/plugin';
|
|
14
|
+
|
|
15
|
+
/** Example rows carry their record id here, not in SPEC_ID_ATTRIBUTE: see Examples. */
|
|
16
|
+
export const EXAMPLE_ID_ATTRIBUTE = 'data-example-id';
|
|
5
17
|
|
|
6
18
|
export interface IStoredNotebooksProps {
|
|
7
19
|
auth: IOceanumAuth;
|
|
8
20
|
/**
|
|
9
|
-
* Open a stored notebook
|
|
10
|
-
*
|
|
11
|
-
* until OCE-182. The list then reads rather than offering an action that does nothing.
|
|
21
|
+
* Open a stored notebook. Undefined where nothing has registered a way to open one;
|
|
22
|
+
* the list then reads rather than offering a click that does nothing.
|
|
12
23
|
*/
|
|
13
24
|
onOpen?: (item: ISpecSummary) => void;
|
|
25
|
+
/** Open a copy of an example, as `onOpen` does for a stored notebook. */
|
|
26
|
+
onOpenExample?: (item: INotebookDemoItem) => void;
|
|
27
|
+
/** Upload a notebook from the user's computer; no Upload button without it. */
|
|
28
|
+
onUpload?: () => void;
|
|
29
|
+
/**
|
|
30
|
+
* Whether the examples are listed, or folded under their heading. Default true. Null
|
|
31
|
+
* while the setting is still loading: the Examples wait for it, so examples a user
|
|
32
|
+
* has hidden never show for a moment first.
|
|
33
|
+
*/
|
|
34
|
+
showExamples?: boolean | null;
|
|
35
|
+
/**
|
|
36
|
+
* Record the user's choice to show or hide the examples. The Examples heading offers
|
|
37
|
+
* its switch only where this is given: a switch that could not keep its setting
|
|
38
|
+
* would flip back on the next load.
|
|
39
|
+
*/
|
|
40
|
+
onShowExamplesChange?: (show: boolean) => void;
|
|
41
|
+
/**
|
|
42
|
+
* Start signing in the way this host shows it; without it the auth's own `signIn()`
|
|
43
|
+
* is used, which on a JupyterLab server shows no code dialog.
|
|
44
|
+
*/
|
|
45
|
+
onSignIn?: () => void;
|
|
14
46
|
}
|
|
15
47
|
|
|
48
|
+
/** The deployment's Notebook Demo record, which lists the Examples. */
|
|
49
|
+
type Examples =
|
|
50
|
+
| { state: 'none' }
|
|
51
|
+
| { state: 'error'; message: string }
|
|
52
|
+
| { state: 'ready'; sections: INotebookDemoSection[] };
|
|
53
|
+
|
|
16
54
|
type Load =
|
|
17
55
|
| { state: 'loading' }
|
|
18
56
|
| { state: 'error'; message: string }
|
|
19
|
-
| { state: 'ready'; items: ISpecSummary[] };
|
|
57
|
+
| { state: 'ready'; items: ISpecSummary[]; examples: Examples };
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Read the Notebook Demo record, if the deployment names one. Never rejects: a failure
|
|
61
|
+
* here is shown in the Examples section and leaves the rest of the tab alone.
|
|
62
|
+
*/
|
|
63
|
+
async function loadExamples(
|
|
64
|
+
auth: IOceanumAuth,
|
|
65
|
+
specsUrl: string,
|
|
66
|
+
id: string | null
|
|
67
|
+
): Promise<Examples> {
|
|
68
|
+
if (!id) {
|
|
69
|
+
return { state: 'none' };
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
const client = new SpecStoreClient({
|
|
73
|
+
specsUrl,
|
|
74
|
+
getAccessToken: () => auth.getAccessToken(),
|
|
75
|
+
specType: 'notebook-demo'
|
|
76
|
+
});
|
|
77
|
+
const record = await client.get(id);
|
|
78
|
+
return { state: 'ready', sections: parseNotebookDemo(record.spec) };
|
|
79
|
+
} catch (error) {
|
|
80
|
+
return {
|
|
81
|
+
state: 'error',
|
|
82
|
+
message:
|
|
83
|
+
error instanceof Error ? error.message : 'Could not reach Oceanum.io.'
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The curated examples. Their rows deliberately leave out SPEC_ID_ATTRIBUTE and the
|
|
90
|
+
* stored-notebook row class: the share plugin's row menu (Open, Rename, Share, Delete)
|
|
91
|
+
* is bound to those, and none of it applies to Oceanum's own example records.
|
|
92
|
+
*/
|
|
93
|
+
function ExampleSections({
|
|
94
|
+
examples,
|
|
95
|
+
onOpen,
|
|
96
|
+
show,
|
|
97
|
+
onShowChange
|
|
98
|
+
}: {
|
|
99
|
+
examples: Examples;
|
|
100
|
+
onOpen?: (item: INotebookDemoItem) => void;
|
|
101
|
+
show: boolean | null;
|
|
102
|
+
onShowChange?: (show: boolean) => void;
|
|
103
|
+
}): React.ReactElement | null {
|
|
104
|
+
// Not yet known, or hidden with no switch to bring them back: nothing to show.
|
|
105
|
+
if (examples.state === 'none' || show === null || (!show && !onShowChange)) {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
const count =
|
|
109
|
+
examples.state === 'ready'
|
|
110
|
+
? examples.sections.reduce(
|
|
111
|
+
(total, section) => total + section.items.length,
|
|
112
|
+
0
|
|
113
|
+
)
|
|
114
|
+
: null;
|
|
115
|
+
if (count === 0) {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
const heading = (
|
|
119
|
+
<div className="oceanum-notebooks-heading">
|
|
120
|
+
Examples
|
|
121
|
+
<span className="oceanum-notebooks-heading-end">
|
|
122
|
+
{count !== null && (
|
|
123
|
+
<span className="oceanum-notebooks-count">{count}</span>
|
|
124
|
+
)}
|
|
125
|
+
{onShowChange && (
|
|
126
|
+
<button
|
|
127
|
+
type="button"
|
|
128
|
+
role="switch"
|
|
129
|
+
aria-checked={show}
|
|
130
|
+
aria-label="Show examples"
|
|
131
|
+
title="Show examples"
|
|
132
|
+
className="oceanum-notebooks-switch"
|
|
133
|
+
onClick={() => onShowChange(!show)}
|
|
134
|
+
>
|
|
135
|
+
<span className="oceanum-notebooks-switch-thumb" />
|
|
136
|
+
</button>
|
|
137
|
+
)}
|
|
138
|
+
</span>
|
|
139
|
+
</div>
|
|
140
|
+
);
|
|
141
|
+
// Hidden: the heading stays, so the switch is there to bring them back.
|
|
142
|
+
if (!show) {
|
|
143
|
+
return <div className="oceanum-notebooks-section">{heading}</div>;
|
|
144
|
+
}
|
|
145
|
+
if (examples.state === 'error') {
|
|
146
|
+
return (
|
|
147
|
+
<div className="oceanum-notebooks-section">
|
|
148
|
+
{heading}
|
|
149
|
+
<div className="oceanum-text-error">
|
|
150
|
+
The examples could not be loaded. {examples.message}
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
return (
|
|
156
|
+
<div className="oceanum-notebooks-section">
|
|
157
|
+
{heading}
|
|
158
|
+
{examples.sections.map((section, index) => (
|
|
159
|
+
<div key={index}>
|
|
160
|
+
{/* Titles come from the record: React escapes them, never set as HTML. */}
|
|
161
|
+
<div className="oceanum-notebooks-subheading">{section.title}</div>
|
|
162
|
+
<ul className="oceanum-notebooks-list">
|
|
163
|
+
{section.items.map((item, position) => {
|
|
164
|
+
const attributes = {
|
|
165
|
+
[EXAMPLE_ID_ATTRIBUTE]: item.id,
|
|
166
|
+
title: item.summary
|
|
167
|
+
};
|
|
168
|
+
const name = (
|
|
169
|
+
<span className="oceanum-notebooks-name">{item.title}</span>
|
|
170
|
+
);
|
|
171
|
+
return (
|
|
172
|
+
// A record may list the same example twice; the id alone is no key.
|
|
173
|
+
<li key={`${position}:${item.id}`}>
|
|
174
|
+
{onOpen ? (
|
|
175
|
+
<button
|
|
176
|
+
type="button"
|
|
177
|
+
className="oceanum-notebooks-example"
|
|
178
|
+
onClick={() => onOpen(item)}
|
|
179
|
+
{...attributes}
|
|
180
|
+
>
|
|
181
|
+
{name}
|
|
182
|
+
</button>
|
|
183
|
+
) : (
|
|
184
|
+
<div
|
|
185
|
+
className="oceanum-notebooks-example oceanum-notebooks-item-static"
|
|
186
|
+
{...attributes}
|
|
187
|
+
>
|
|
188
|
+
{name}
|
|
189
|
+
</div>
|
|
190
|
+
)}
|
|
191
|
+
</li>
|
|
192
|
+
);
|
|
193
|
+
})}
|
|
194
|
+
</ul>
|
|
195
|
+
</div>
|
|
196
|
+
))}
|
|
197
|
+
</div>
|
|
198
|
+
);
|
|
199
|
+
}
|
|
20
200
|
|
|
21
201
|
function Section({
|
|
22
202
|
title,
|
|
@@ -29,6 +209,12 @@ function Section({
|
|
|
29
209
|
empty: string;
|
|
30
210
|
onOpen?: (item: ISpecSummary) => void;
|
|
31
211
|
}): React.ReactElement {
|
|
212
|
+
// Each row carries its record id so the share plugin's context menu (Open, Rename,
|
|
213
|
+
// Share, Delete) can tell which one was clicked.
|
|
214
|
+
const rowAttributes = (item: ISpecSummary) => ({
|
|
215
|
+
[SPEC_ID_ATTRIBUTE]: item.id,
|
|
216
|
+
title: item.description ?? undefined
|
|
217
|
+
});
|
|
32
218
|
return (
|
|
33
219
|
<div className="oceanum-notebooks-section">
|
|
34
220
|
<div className="oceanum-notebooks-heading">
|
|
@@ -47,7 +233,7 @@ function Section({
|
|
|
47
233
|
type="button"
|
|
48
234
|
className="oceanum-notebooks-item"
|
|
49
235
|
onClick={() => onOpen(item)}
|
|
50
|
-
|
|
236
|
+
{...rowAttributes(item)}
|
|
51
237
|
>
|
|
52
238
|
<span className="oceanum-notebooks-name">
|
|
53
239
|
{item.name || 'Untitled'}
|
|
@@ -56,7 +242,7 @@ function Section({
|
|
|
56
242
|
) : (
|
|
57
243
|
<div
|
|
58
244
|
className="oceanum-notebooks-item oceanum-notebooks-item-static"
|
|
59
|
-
|
|
245
|
+
{...rowAttributes(item)}
|
|
60
246
|
>
|
|
61
247
|
<span className="oceanum-notebooks-name">
|
|
62
248
|
{item.name || 'Untitled'}
|
|
@@ -76,18 +262,29 @@ function Section({
|
|
|
76
262
|
*/
|
|
77
263
|
export function StoredNotebooks({
|
|
78
264
|
auth,
|
|
79
|
-
onOpen
|
|
265
|
+
onOpen,
|
|
266
|
+
onOpenExample,
|
|
267
|
+
onUpload,
|
|
268
|
+
showExamples = true,
|
|
269
|
+
onShowExamplesChange,
|
|
270
|
+
onSignIn
|
|
80
271
|
}: IStoredNotebooksProps): React.ReactElement {
|
|
81
272
|
const [load, setLoad] = React.useState<Load>({ state: 'loading' });
|
|
82
273
|
const [user, setUser] = React.useState(auth.user);
|
|
274
|
+
// Bumped whenever the store changes, so the list is fetched again.
|
|
275
|
+
const [version, setVersion] = React.useState(0);
|
|
83
276
|
|
|
84
277
|
// Reload whenever the signed-in user changes, so signing in or out does not leave a
|
|
85
|
-
// stale list from the previous session on screen
|
|
278
|
+
// stale list from the previous session on screen; and whenever this extension has
|
|
279
|
+
// created, renamed or deleted a record.
|
|
86
280
|
React.useEffect(() => {
|
|
87
281
|
const onUser = (): void => setUser(auth.user);
|
|
282
|
+
const onChanged = (): void => setVersion(v => v + 1);
|
|
88
283
|
auth.userChanged.connect(onUser);
|
|
284
|
+
notebooksChanged.connect(onChanged);
|
|
89
285
|
return () => {
|
|
90
286
|
auth.userChanged.disconnect(onUser);
|
|
287
|
+
notebooksChanged.disconnect(onChanged);
|
|
91
288
|
};
|
|
92
289
|
}, [auth]);
|
|
93
290
|
|
|
@@ -95,23 +292,24 @@ export function StoredNotebooks({
|
|
|
95
292
|
let cancelled = false;
|
|
96
293
|
const specs = auth.urls?.specs ?? null;
|
|
97
294
|
if (!user || !specs) {
|
|
98
|
-
setLoad({ state: 'ready', items: [] });
|
|
295
|
+
setLoad({ state: 'ready', items: [], examples: { state: 'none' } });
|
|
99
296
|
return;
|
|
100
297
|
}
|
|
101
298
|
setLoad({ state: 'loading' });
|
|
102
299
|
void (async () => {
|
|
103
300
|
try {
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
301
|
+
const client = new SpecStoreClient({
|
|
302
|
+
specsUrl: specs,
|
|
303
|
+
getAccessToken: () => auth.getAccessToken()
|
|
304
|
+
});
|
|
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
|
+
const [items, examples] = await Promise.all([
|
|
308
|
+
client.list(),
|
|
309
|
+
loadExamples(auth, specs, auth.environment?.notebookDemo ?? null)
|
|
310
|
+
]);
|
|
113
311
|
if (!cancelled) {
|
|
114
|
-
setLoad({ state: 'ready', items });
|
|
312
|
+
setLoad({ state: 'ready', items, examples });
|
|
115
313
|
}
|
|
116
314
|
} catch (error) {
|
|
117
315
|
if (!cancelled) {
|
|
@@ -128,12 +326,36 @@ export function StoredNotebooks({
|
|
|
128
326
|
return () => {
|
|
129
327
|
cancelled = true;
|
|
130
328
|
};
|
|
131
|
-
}, [auth, user]);
|
|
329
|
+
}, [auth, user, version]);
|
|
132
330
|
|
|
133
331
|
if (!user) {
|
|
332
|
+
const signIn = (): void => {
|
|
333
|
+
if (onSignIn) {
|
|
334
|
+
onSignIn();
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
auth.signIn().catch(error => {
|
|
338
|
+
console.warn('Oceanum.io sign-in could not start.', error);
|
|
339
|
+
});
|
|
340
|
+
};
|
|
134
341
|
return (
|
|
135
342
|
<div className="oceanum-text-empty">
|
|
136
|
-
|
|
343
|
+
{/* A link rather than a button to match the panel's other actions, so it
|
|
344
|
+
carries the role and the keys a button would have. */}
|
|
345
|
+
<a
|
|
346
|
+
role="button"
|
|
347
|
+
tabIndex={0}
|
|
348
|
+
onClick={signIn}
|
|
349
|
+
onKeyDown={event => {
|
|
350
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
351
|
+
event.preventDefault();
|
|
352
|
+
signIn();
|
|
353
|
+
}
|
|
354
|
+
}}
|
|
355
|
+
>
|
|
356
|
+
Sign in
|
|
357
|
+
</a>{' '}
|
|
358
|
+
to Oceanum.io to see your notebooks.
|
|
137
359
|
</div>
|
|
138
360
|
);
|
|
139
361
|
}
|
|
@@ -146,9 +368,31 @@ export function StoredNotebooks({
|
|
|
146
368
|
return <div className="oceanum-text-error">{load.message}</div>;
|
|
147
369
|
}
|
|
148
370
|
|
|
149
|
-
const { mine, shared } =
|
|
371
|
+
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
|
+
);
|
|
150
381
|
return (
|
|
151
382
|
<div className="oceanum-notebooks">
|
|
383
|
+
{/* Without a spec store the upload command does nothing. */}
|
|
384
|
+
{onUpload && auth.urls?.specs ? (
|
|
385
|
+
<div className="oceanum-notebooks-actions">
|
|
386
|
+
<button
|
|
387
|
+
type="button"
|
|
388
|
+
className="oceanum-notebooks-upload"
|
|
389
|
+
title="Upload a notebook from this computer, to save on Oceanum.io"
|
|
390
|
+
onClick={onUpload}
|
|
391
|
+
>
|
|
392
|
+
Upload notebook…
|
|
393
|
+
</button>
|
|
394
|
+
</div>
|
|
395
|
+
) : null}
|
|
152
396
|
<Section
|
|
153
397
|
title="My notebooks"
|
|
154
398
|
items={mine}
|
|
@@ -157,10 +401,16 @@ export function StoredNotebooks({
|
|
|
157
401
|
/>
|
|
158
402
|
<Section
|
|
159
403
|
title="Shared with me"
|
|
160
|
-
items={shared}
|
|
404
|
+
items={shared.filter(item => !exampleIds.has(item.id))}
|
|
161
405
|
empty="No notebooks have been shared with you."
|
|
162
406
|
onOpen={onOpen}
|
|
163
407
|
/>
|
|
408
|
+
<ExampleSections
|
|
409
|
+
examples={load.examples}
|
|
410
|
+
onOpen={onOpenExample}
|
|
411
|
+
show={showExamples}
|
|
412
|
+
onShowChange={onShowExamplesChange}
|
|
413
|
+
/>
|
|
164
414
|
</div>
|
|
165
415
|
);
|
|
166
416
|
}
|
|
@@ -11,8 +11,29 @@ const icon = new LabIcon({
|
|
|
11
11
|
svgstr: '<svg xmlns="http://www.w3.org/2000/svg"></svg>'
|
|
12
12
|
});
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
interface IPanelOptions {
|
|
15
|
+
/** The `datameshToken` setting value. */
|
|
16
|
+
token?: string;
|
|
17
|
+
/** Register the host's sign-in commands, and say whether someone is signed in. */
|
|
18
|
+
signedIn?: boolean;
|
|
19
|
+
/** Widget props to use instead of the defaults below. */
|
|
20
|
+
props?: Record<string, unknown>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function panel(options: IPanelOptions = {}): DatameshConnectWidget {
|
|
15
24
|
const commands = new CommandRegistry();
|
|
25
|
+
if (options.signedIn !== undefined) {
|
|
26
|
+
// What the host provides: sign-out is visible only while signed in, which is how
|
|
27
|
+
// the sidebar reads the state without running anything.
|
|
28
|
+
commands.addCommand('oceanum-auth:sign-out', {
|
|
29
|
+
execute: () => undefined,
|
|
30
|
+
isVisible: () => options.signedIn === true
|
|
31
|
+
});
|
|
32
|
+
commands.addCommand('oceanum-auth:access-token', {
|
|
33
|
+
execute: () => null,
|
|
34
|
+
isVisible: () => false
|
|
35
|
+
});
|
|
36
|
+
}
|
|
16
37
|
const app = {
|
|
17
38
|
shell: { currentWidget: null },
|
|
18
39
|
commands
|
|
@@ -24,17 +45,20 @@ function panel(): DatameshConnectWidget {
|
|
|
24
45
|
openDatameshUI: (): void => undefined,
|
|
25
46
|
datameshUiUrl: () => 'https://ui.datamesh.example.com',
|
|
26
47
|
datameshUiFrame: (): Window | null => null,
|
|
27
|
-
datameshToken: () => '',
|
|
48
|
+
datameshToken: () => options.token ?? '',
|
|
28
49
|
aiBackendUrl: () => 'https://ai.example.com',
|
|
29
50
|
settingsChanged: new Signal<unknown, void>({}),
|
|
30
51
|
commands,
|
|
31
52
|
getCurrentWidget: () => new Widget(),
|
|
32
|
-
auth: null
|
|
53
|
+
auth: null,
|
|
54
|
+
...options.props
|
|
33
55
|
} as unknown as ConstructorParameters<typeof DatameshConnectWidget>[0]);
|
|
34
56
|
}
|
|
35
57
|
|
|
36
|
-
async function mount(
|
|
37
|
-
|
|
58
|
+
async function mount(
|
|
59
|
+
options: IPanelOptions = {}
|
|
60
|
+
): Promise<DatameshConnectWidget> {
|
|
61
|
+
const widget = panel(options);
|
|
38
62
|
Widget.attach(widget, document.body);
|
|
39
63
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
40
64
|
return widget;
|
|
@@ -46,6 +70,19 @@ async function mount(): Promise<DatameshConnectWidget> {
|
|
|
46
70
|
* touches the Datamesh surface, so nothing else here would notice if rearranging the
|
|
47
71
|
* two lost one of them.
|
|
48
72
|
*/
|
|
73
|
+
beforeAll(() => {
|
|
74
|
+
// The AI chat scrolls to its last message, and every pane renders whether or not it
|
|
75
|
+
// is the selected one. jsdom has no scrollIntoView, and the error would come back as
|
|
76
|
+
// React unmounting the whole sidebar.
|
|
77
|
+
(Element.prototype as unknown as Record<string, unknown>).scrollIntoView =
|
|
78
|
+
(): void => undefined;
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const tabLabels = (widget: DatameshConnectWidget): (string | null)[] =>
|
|
82
|
+
Array.from(widget.node.querySelectorAll('[role="tab"]')).map(
|
|
83
|
+
node => node.textContent
|
|
84
|
+
);
|
|
85
|
+
|
|
49
86
|
describe('the Oceanum panel', () => {
|
|
50
87
|
it('offers no Oceanum AI tab when the chat has no credential', async () => {
|
|
51
88
|
// AIChatPanel renders nothing without one, so a labelled tab would open on the
|
|
@@ -61,6 +98,28 @@ describe('the Oceanum panel', () => {
|
|
|
61
98
|
widget.dispose();
|
|
62
99
|
});
|
|
63
100
|
|
|
101
|
+
it('offers no Oceanum AI tab for a pasted token alone, until someone signs in', async () => {
|
|
102
|
+
// The token would reach the backend, but the tab waits for a sign-in.
|
|
103
|
+
const withToken = await mount({ token: 'a-datamesh-token' });
|
|
104
|
+
expect(tabLabels(withToken)).toEqual(['Notebooks', 'Datamesh']);
|
|
105
|
+
withToken.dispose();
|
|
106
|
+
|
|
107
|
+
const signedOut = await mount({
|
|
108
|
+
token: 'a-datamesh-token',
|
|
109
|
+
signedIn: false
|
|
110
|
+
});
|
|
111
|
+
expect(tabLabels(signedOut)).toEqual(['Notebooks', 'Datamesh']);
|
|
112
|
+
signedOut.dispose();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('offers the Oceanum AI tab once the host says the user is signed in', async () => {
|
|
116
|
+
const widget = await mount({ signedIn: true });
|
|
117
|
+
|
|
118
|
+
expect(tabLabels(widget)).toEqual(['Notebooks', 'Datamesh', 'Oceanum AI']);
|
|
119
|
+
|
|
120
|
+
widget.dispose();
|
|
121
|
+
});
|
|
122
|
+
|
|
64
123
|
it('still renders the Datamesh workspace section', async () => {
|
|
65
124
|
const widget = await mount();
|
|
66
125
|
|
|
@@ -99,3 +158,98 @@ describe('the Oceanum panel', () => {
|
|
|
99
158
|
widget.dispose();
|
|
100
159
|
});
|
|
101
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
|
+
});
|