@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
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
import { ReactWidget } from '@jupyterlab/ui-components';
|
|
2
|
+
import { Signal } from '@lumino/signaling';
|
|
3
|
+
import { Widget } from '@lumino/widgets';
|
|
4
|
+
import React from 'react';
|
|
5
|
+
|
|
6
|
+
import { IOceanumAuth, IOceanumUser } from '../auth/tokens';
|
|
7
|
+
import { notebooksChanged } from '../share/events';
|
|
8
|
+
import type { INotebookDemoItem } from '../share/notebook';
|
|
9
|
+
import { NOTEBOOK_ITEM_SELECTOR } from '../share/plugin';
|
|
10
|
+
import { IStoredNotebooksProps, StoredNotebooks } from '../StoredNotebooks';
|
|
11
|
+
|
|
12
|
+
/** A signed-out auth that records whether sign-in was asked for. */
|
|
13
|
+
function signedOut(): IOceanumAuth & { signIns: number } {
|
|
14
|
+
const auth = {
|
|
15
|
+
signIns: 0,
|
|
16
|
+
environment: null as IOceanumAuth['environment'],
|
|
17
|
+
urls: { specs: 'https://specs.example.com' },
|
|
18
|
+
ready: Promise.resolve(),
|
|
19
|
+
user: null as IOceanumUser | null,
|
|
20
|
+
userChanged: new Signal<IOceanumAuth, IOceanumUser | null>(
|
|
21
|
+
{} as IOceanumAuth
|
|
22
|
+
),
|
|
23
|
+
tokenChanged: new Signal<IOceanumAuth, string | null>({} as IOceanumAuth),
|
|
24
|
+
signIn: async (): Promise<void> => {
|
|
25
|
+
auth.signIns += 1;
|
|
26
|
+
},
|
|
27
|
+
signOut: async (): Promise<void> => undefined,
|
|
28
|
+
getAccessToken: async (): Promise<string | null> => null
|
|
29
|
+
};
|
|
30
|
+
return auth as unknown as IOceanumAuth & { signIns: number };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
class Harness extends ReactWidget {
|
|
34
|
+
constructor(
|
|
35
|
+
private readonly _auth: IOceanumAuth,
|
|
36
|
+
private readonly _onSignIn?: () => void,
|
|
37
|
+
private readonly _onOpenExample?: (item: INotebookDemoItem) => void,
|
|
38
|
+
private readonly _more: Partial<IStoredNotebooksProps> = {}
|
|
39
|
+
) {
|
|
40
|
+
super();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
render(): React.ReactElement {
|
|
44
|
+
return (
|
|
45
|
+
<StoredNotebooks
|
|
46
|
+
auth={this._auth}
|
|
47
|
+
onSignIn={this._onSignIn}
|
|
48
|
+
onOpenExample={this._onOpenExample}
|
|
49
|
+
{...this._more}
|
|
50
|
+
/>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function mount(
|
|
56
|
+
auth: IOceanumAuth,
|
|
57
|
+
onSignIn?: () => void
|
|
58
|
+
): Promise<Harness> {
|
|
59
|
+
const widget = new Harness(auth, onSignIn);
|
|
60
|
+
Widget.attach(widget, document.body);
|
|
61
|
+
const deadline = Date.now() + 4000;
|
|
62
|
+
for (;;) {
|
|
63
|
+
if (widget.node.querySelector('.oceanum-text-empty')) {
|
|
64
|
+
return widget;
|
|
65
|
+
}
|
|
66
|
+
if (Date.now() > deadline) {
|
|
67
|
+
throw new Error('StoredNotebooks rendered nothing within 4s');
|
|
68
|
+
}
|
|
69
|
+
await new Promise(resolve => setTimeout(resolve, 25));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
describe('the Notebooks tab while signed in', () => {
|
|
74
|
+
const ID = '6f1c1a52-4b8e-4c0f-9a57-1d2e3f4a5b6c';
|
|
75
|
+
|
|
76
|
+
it('lists again when a record is created, renamed or deleted', async () => {
|
|
77
|
+
const auth = signedOut();
|
|
78
|
+
(auth as { user: IOceanumUser | null }).user = {
|
|
79
|
+
sub: 'auth0|1',
|
|
80
|
+
email: 'me@example.com',
|
|
81
|
+
name: null,
|
|
82
|
+
activeOrg: null,
|
|
83
|
+
colorScheme: null
|
|
84
|
+
};
|
|
85
|
+
auth.getAccessToken = async () => 'tok';
|
|
86
|
+
const fetches: string[] = [];
|
|
87
|
+
const originalFetch = globalThis.fetch;
|
|
88
|
+
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
|
89
|
+
fetches.push(String(input));
|
|
90
|
+
return {
|
|
91
|
+
ok: true,
|
|
92
|
+
status: 200,
|
|
93
|
+
json: async () => [
|
|
94
|
+
{
|
|
95
|
+
id: ID,
|
|
96
|
+
name: 'Waves',
|
|
97
|
+
description: null as string | null,
|
|
98
|
+
modified: '2026-09-12T10:00:00',
|
|
99
|
+
creator: 'me@example.com'
|
|
100
|
+
}
|
|
101
|
+
]
|
|
102
|
+
} as unknown as Response;
|
|
103
|
+
}) as typeof fetch;
|
|
104
|
+
try {
|
|
105
|
+
const widget = new Harness(auth);
|
|
106
|
+
Widget.attach(widget, document.body);
|
|
107
|
+
const until = async (test: () => boolean): Promise<void> => {
|
|
108
|
+
const deadline = Date.now() + 4000;
|
|
109
|
+
while (!test()) {
|
|
110
|
+
if (Date.now() > deadline) {
|
|
111
|
+
throw new Error('timed out');
|
|
112
|
+
}
|
|
113
|
+
await new Promise(resolve => setTimeout(resolve, 25));
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
await until(() => !!widget.node.querySelector('.oceanum-notebooks-item'));
|
|
117
|
+
expect(fetches).toHaveLength(1);
|
|
118
|
+
const row = widget.node.querySelector('.oceanum-notebooks-item')!;
|
|
119
|
+
expect(row.getAttribute('data-spec-id')).toBe(ID);
|
|
120
|
+
|
|
121
|
+
notebooksChanged.emit();
|
|
122
|
+
|
|
123
|
+
await until(() => fetches.length === 2);
|
|
124
|
+
widget.dispose();
|
|
125
|
+
} finally {
|
|
126
|
+
globalThis.fetch = originalFetch;
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
});
|
|
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
|
+
|
|
491
|
+
describe('the Notebooks tab while signed out', () => {
|
|
492
|
+
it('links "Sign in" to starting a sign-in', async () => {
|
|
493
|
+
const auth = signedOut();
|
|
494
|
+
const widget = await mount(auth);
|
|
495
|
+
|
|
496
|
+
expect(widget.node.textContent).toContain(
|
|
497
|
+
'Sign in to Oceanum.io to see your notebooks.'
|
|
498
|
+
);
|
|
499
|
+
const link = widget.node.querySelector<HTMLAnchorElement>(
|
|
500
|
+
'.oceanum-text-empty a'
|
|
501
|
+
);
|
|
502
|
+
expect(link?.textContent).toBe('Sign in');
|
|
503
|
+
|
|
504
|
+
link!.click();
|
|
505
|
+
expect(auth.signIns).toBe(1);
|
|
506
|
+
|
|
507
|
+
widget.dispose();
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
it("prefers the host's own way of signing in when given one", async () => {
|
|
511
|
+
// On a JupyterLab server that is the device sign-in command, which shows the
|
|
512
|
+
// code; the auth's signIn() alone would start the flow with nothing to look at.
|
|
513
|
+
const auth = signedOut();
|
|
514
|
+
let hostSignIns = 0;
|
|
515
|
+
const widget = await mount(auth, () => {
|
|
516
|
+
hostSignIns += 1;
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
widget.node
|
|
520
|
+
.querySelector<HTMLAnchorElement>('.oceanum-text-empty a')!
|
|
521
|
+
.click();
|
|
522
|
+
|
|
523
|
+
expect(hostSignIns).toBe(1);
|
|
524
|
+
expect(auth.signIns).toBe(0);
|
|
525
|
+
|
|
526
|
+
widget.dispose();
|
|
527
|
+
});
|
|
528
|
+
});
|