@oceanum/oceanumlab 4.7.2 → 4.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/DatameshWidget.d.ts +11 -0
- package/lib/DatameshWidget.js +24 -3
- package/lib/StoredNotebooks.d.ts +20 -2
- package/lib/StoredNotebooks.js +95 -6
- package/lib/auth/tokens.d.ts +6 -0
- package/lib/index.js +35 -1
- package/lib/share/client.d.ts +3 -1
- package/lib/share/client.js +5 -4
- package/lib/share/notebook.d.ts +32 -0
- package/lib/share/notebook.js +86 -8
- package/lib/share/oceanumOnly.d.ts +25 -2
- package/lib/share/oceanumOnly.js +39 -2
- package/lib/share/plugin.d.ts +2 -0
- package/lib/share/plugin.js +109 -11
- package/lib/share/upload.d.ts +6 -0
- package/lib/share/upload.js +27 -0
- package/package.json +1 -1
- package/schema/datamesh-connect.json +6 -0
- package/src/DatameshWidget.tsx +58 -0
- package/src/StoredNotebooks.tsx +216 -6
- package/src/__tests__/OceanumPanel.spec.tsx +99 -1
- package/src/__tests__/StoredNotebooks.spec.tsx +374 -3
- package/src/__tests__/oceanumOnly.spec.ts +127 -3
- package/src/__tests__/settingsSchema.spec.ts +11 -1
- package/src/__tests__/shareClient.spec.ts +14 -0
- package/src/__tests__/shareNotebook.spec.ts +173 -0
- package/src/__tests__/sharePlugin.spec.ts +245 -7
- package/src/auth/tokens.ts +6 -0
- package/src/index.ts +35 -1
- package/src/share/client.ts +5 -2
- package/src/share/notebook.ts +119 -10
- package/src/share/oceanumOnly.ts +55 -4
- package/src/share/plugin.ts +135 -11
- package/src/share/upload.ts +27 -0
- package/style/index.css +101 -2
|
@@ -16,6 +16,8 @@ interface IPanelOptions {
|
|
|
16
16
|
token?: string;
|
|
17
17
|
/** Register the host's sign-in commands, and say whether someone is signed in. */
|
|
18
18
|
signedIn?: boolean;
|
|
19
|
+
/** Widget props to use instead of the defaults below. */
|
|
20
|
+
props?: Record<string, unknown>;
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
function panel(options: IPanelOptions = {}): DatameshConnectWidget {
|
|
@@ -48,7 +50,8 @@ function panel(options: IPanelOptions = {}): DatameshConnectWidget {
|
|
|
48
50
|
settingsChanged: new Signal<unknown, void>({}),
|
|
49
51
|
commands,
|
|
50
52
|
getCurrentWidget: () => new Widget(),
|
|
51
|
-
auth: null
|
|
53
|
+
auth: null,
|
|
54
|
+
...options.props
|
|
52
55
|
} as unknown as ConstructorParameters<typeof DatameshConnectWidget>[0]);
|
|
53
56
|
}
|
|
54
57
|
|
|
@@ -155,3 +158,98 @@ describe('the Oceanum panel', () => {
|
|
|
155
158
|
widget.dispose();
|
|
156
159
|
});
|
|
157
160
|
});
|
|
161
|
+
|
|
162
|
+
describe('the Examples switch in the Oceanum panel', () => {
|
|
163
|
+
const DEMO_ID = '3e4f5a6b-4444-4a2b-8c3d-9e8f7a6b5c4d';
|
|
164
|
+
const EXAMPLE = '0b7d9f2e-1111-4a2b-8c3d-9e8f7a6b5c4d';
|
|
165
|
+
const originalFetch = globalThis.fetch;
|
|
166
|
+
afterEach(() => {
|
|
167
|
+
globalThis.fetch = originalFetch;
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const until = async (test: () => boolean): Promise<void> => {
|
|
171
|
+
const deadline = Date.now() + 4000;
|
|
172
|
+
while (!test()) {
|
|
173
|
+
if (Date.now() > deadline) {
|
|
174
|
+
throw new Error('timed out');
|
|
175
|
+
}
|
|
176
|
+
await new Promise(resolve => setTimeout(resolve, 25));
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
it('saves the choice, and follows the setting once it has changed', async () => {
|
|
181
|
+
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
|
182
|
+
const demo = String(input).includes('/specs/notebook-demo/');
|
|
183
|
+
const data: unknown = demo
|
|
184
|
+
? {
|
|
185
|
+
id: DEMO_ID,
|
|
186
|
+
name: 'Notebook Demo',
|
|
187
|
+
description: null as string | null,
|
|
188
|
+
modified: '2026-09-22T10:00:00',
|
|
189
|
+
creator: null as string | null,
|
|
190
|
+
spec: {
|
|
191
|
+
kind: 'notebook-demo',
|
|
192
|
+
version: 1,
|
|
193
|
+
sections: [
|
|
194
|
+
{ title: 'Start', items: [{ id: EXAMPLE, title: 'Query' }] }
|
|
195
|
+
]
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
: [];
|
|
199
|
+
return { ok: true, status: 200, json: async () => data } as Response;
|
|
200
|
+
}) as typeof fetch;
|
|
201
|
+
const auth = {
|
|
202
|
+
environment: { notebookDemo: DEMO_ID },
|
|
203
|
+
urls: { specs: 'https://specs.example.com' },
|
|
204
|
+
ready: Promise.resolve(),
|
|
205
|
+
user: {
|
|
206
|
+
sub: 'auth0|1',
|
|
207
|
+
email: 'me@example.com',
|
|
208
|
+
name: null as string | null,
|
|
209
|
+
activeOrg: null as string | null,
|
|
210
|
+
colorScheme: null as string | null
|
|
211
|
+
},
|
|
212
|
+
userChanged: new Signal<unknown, unknown>({}),
|
|
213
|
+
tokenChanged: new Signal<unknown, unknown>({}),
|
|
214
|
+
signIn: async (): Promise<void> => undefined,
|
|
215
|
+
signOut: async (): Promise<void> => undefined,
|
|
216
|
+
getAccessToken: async (): Promise<string> => 'tok'
|
|
217
|
+
};
|
|
218
|
+
// What the host keeps: the setting, and the signal it emits when it changes.
|
|
219
|
+
// Null until the host's settings load.
|
|
220
|
+
let shown: boolean | null = null;
|
|
221
|
+
const saved: boolean[] = [];
|
|
222
|
+
const settingsChanged = new Signal<unknown, void>({});
|
|
223
|
+
const widget = await mount({
|
|
224
|
+
props: {
|
|
225
|
+
auth,
|
|
226
|
+
settingsChanged,
|
|
227
|
+
showExamples: () => shown,
|
|
228
|
+
setShowExamples: (show: boolean) => saved.push(show)
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
const toggle = (): HTMLButtonElement | null =>
|
|
232
|
+
widget.node.querySelector<HTMLButtonElement>('[role="switch"]');
|
|
233
|
+
// The tab has loaded, but the examples wait for the setting.
|
|
234
|
+
await until(() => widget.node.querySelector('.oceanum-notebooks') !== null);
|
|
235
|
+
expect(toggle()).toBeNull();
|
|
236
|
+
expect(widget.node.querySelector('[data-example-id]')).toBeNull();
|
|
237
|
+
|
|
238
|
+
shown = true;
|
|
239
|
+
settingsChanged.emit();
|
|
240
|
+
await until(() => toggle() !== null);
|
|
241
|
+
expect(toggle()!.getAttribute('aria-checked')).toBe('true');
|
|
242
|
+
expect(widget.node.querySelector('[data-example-id]')).not.toBeNull();
|
|
243
|
+
|
|
244
|
+
toggle()!.click();
|
|
245
|
+
expect(saved).toEqual([false]);
|
|
246
|
+
|
|
247
|
+
// The host saves the setting and says so; the panel reads it again.
|
|
248
|
+
shown = false;
|
|
249
|
+
settingsChanged.emit();
|
|
250
|
+
await until(() => toggle()?.getAttribute('aria-checked') === 'false');
|
|
251
|
+
expect(widget.node.querySelector('[data-example-id]')).toBeNull();
|
|
252
|
+
|
|
253
|
+
widget.dispose();
|
|
254
|
+
});
|
|
255
|
+
});
|
|
@@ -5,7 +5,9 @@ import React from 'react';
|
|
|
5
5
|
|
|
6
6
|
import { IOceanumAuth, IOceanumUser } from '../auth/tokens';
|
|
7
7
|
import { notebooksChanged } from '../share/events';
|
|
8
|
-
import {
|
|
8
|
+
import type { INotebookDemoItem } from '../share/notebook';
|
|
9
|
+
import { NOTEBOOK_ITEM_SELECTOR } from '../share/plugin';
|
|
10
|
+
import { IStoredNotebooksProps, StoredNotebooks } from '../StoredNotebooks';
|
|
9
11
|
|
|
10
12
|
/** A signed-out auth that records whether sign-in was asked for. */
|
|
11
13
|
function signedOut(): IOceanumAuth & { signIns: number } {
|
|
@@ -31,13 +33,22 @@ function signedOut(): IOceanumAuth & { signIns: number } {
|
|
|
31
33
|
class Harness extends ReactWidget {
|
|
32
34
|
constructor(
|
|
33
35
|
private readonly _auth: IOceanumAuth,
|
|
34
|
-
private readonly _onSignIn?: () => void
|
|
36
|
+
private readonly _onSignIn?: () => void,
|
|
37
|
+
private readonly _onOpenExample?: (item: INotebookDemoItem) => void,
|
|
38
|
+
private readonly _more: Partial<IStoredNotebooksProps> = {}
|
|
35
39
|
) {
|
|
36
40
|
super();
|
|
37
41
|
}
|
|
38
42
|
|
|
39
43
|
render(): React.ReactElement {
|
|
40
|
-
return
|
|
44
|
+
return (
|
|
45
|
+
<StoredNotebooks
|
|
46
|
+
auth={this._auth}
|
|
47
|
+
onSignIn={this._onSignIn}
|
|
48
|
+
onOpenExample={this._onOpenExample}
|
|
49
|
+
{...this._more}
|
|
50
|
+
/>
|
|
51
|
+
);
|
|
41
52
|
}
|
|
42
53
|
}
|
|
43
54
|
|
|
@@ -117,6 +128,366 @@ describe('the Notebooks tab while signed in', () => {
|
|
|
117
128
|
});
|
|
118
129
|
});
|
|
119
130
|
|
|
131
|
+
describe('the Examples in the Notebooks tab', () => {
|
|
132
|
+
const MINE = '6f1c1a52-4b8e-4c0f-9a57-1d2e3f4a5b6c';
|
|
133
|
+
const PUBLIC_EXAMPLE = '0b7d9f2e-1111-4a2b-8c3d-9e8f7a6b5c4d';
|
|
134
|
+
const OTHER_EXAMPLE = '1c2d3e4f-2222-4a2b-8c3d-9e8f7a6b5c4d';
|
|
135
|
+
const SHARED = '2d3e4f5a-3333-4a2b-8c3d-9e8f7a6b5c4d';
|
|
136
|
+
const DEMO_ID = '3e4f5a6b-4444-4a2b-8c3d-9e8f7a6b5c4d';
|
|
137
|
+
|
|
138
|
+
const summary = (id: string, name: string, creator: string | null) => ({
|
|
139
|
+
id,
|
|
140
|
+
name,
|
|
141
|
+
description: null as string | null,
|
|
142
|
+
modified: '2026-09-12T10:00:00',
|
|
143
|
+
creator
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// The listing returns a public example as shared with every signed-in user.
|
|
147
|
+
const LISTING = [
|
|
148
|
+
summary(MINE, 'Mine', 'me@example.com'),
|
|
149
|
+
summary(PUBLIC_EXAMPLE, 'Query (example)', null),
|
|
150
|
+
summary(SHARED, 'Shared', null)
|
|
151
|
+
];
|
|
152
|
+
|
|
153
|
+
const DEMO = {
|
|
154
|
+
...summary(DEMO_ID, 'Notebook Demo', null),
|
|
155
|
+
spec: {
|
|
156
|
+
kind: 'notebook-demo',
|
|
157
|
+
version: 1,
|
|
158
|
+
sections: [
|
|
159
|
+
{
|
|
160
|
+
title: 'Getting started',
|
|
161
|
+
items: [
|
|
162
|
+
{
|
|
163
|
+
id: PUBLIC_EXAMPLE,
|
|
164
|
+
title: 'Query a datasource',
|
|
165
|
+
summary: 'One line'
|
|
166
|
+
},
|
|
167
|
+
{ id: OTHER_EXAMPLE, title: 'Plot waves' }
|
|
168
|
+
]
|
|
169
|
+
},
|
|
170
|
+
// A curator's own example is also theirs: it stays in My notebooks.
|
|
171
|
+
{ title: 'Advanced', items: [{ id: MINE, title: 'Spectra' }] }
|
|
172
|
+
]
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const ok = (data: unknown): Response =>
|
|
177
|
+
({ ok: true, status: 200, json: async () => data }) as unknown as Response;
|
|
178
|
+
|
|
179
|
+
function signedIn(notebookDemo?: string): IOceanumAuth {
|
|
180
|
+
const auth = signedOut();
|
|
181
|
+
(auth as { user: IOceanumUser | null }).user = {
|
|
182
|
+
sub: 'auth0|1',
|
|
183
|
+
email: 'me@example.com',
|
|
184
|
+
name: null,
|
|
185
|
+
activeOrg: null,
|
|
186
|
+
colorScheme: null
|
|
187
|
+
};
|
|
188
|
+
auth.getAccessToken = async () => 'tok';
|
|
189
|
+
(auth as { environment: unknown }).environment = notebookDemo
|
|
190
|
+
? { notebookDemo }
|
|
191
|
+
: {};
|
|
192
|
+
return auth;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const originalFetch = globalThis.fetch;
|
|
196
|
+
let fetches: string[];
|
|
197
|
+
function stubFetch(demo: () => Response = () => ok(DEMO)): void {
|
|
198
|
+
fetches = [];
|
|
199
|
+
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
|
200
|
+
const url = String(input);
|
|
201
|
+
fetches.push(url);
|
|
202
|
+
return url.includes('/specs/notebook-demo/') ? demo() : ok(LISTING);
|
|
203
|
+
}) as typeof fetch;
|
|
204
|
+
}
|
|
205
|
+
afterEach(() => {
|
|
206
|
+
globalThis.fetch = originalFetch;
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
async function render(
|
|
210
|
+
auth: IOceanumAuth,
|
|
211
|
+
onOpenExample?: (item: INotebookDemoItem) => void,
|
|
212
|
+
more: Partial<IStoredNotebooksProps> = {}
|
|
213
|
+
): Promise<Harness> {
|
|
214
|
+
const widget = new Harness(auth, undefined, onOpenExample, more);
|
|
215
|
+
Widget.attach(widget, document.body);
|
|
216
|
+
const deadline = Date.now() + 4000;
|
|
217
|
+
while (!widget.node.querySelector('.oceanum-notebooks')) {
|
|
218
|
+
if (Date.now() > deadline) {
|
|
219
|
+
throw new Error('the Notebooks tab did not load within 4s');
|
|
220
|
+
}
|
|
221
|
+
await new Promise(resolve => setTimeout(resolve, 25));
|
|
222
|
+
}
|
|
223
|
+
return widget;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** The section whose heading starts with `title`. */
|
|
227
|
+
function section(widget: Harness, title: string): HTMLElement | null {
|
|
228
|
+
const sections = widget.node.querySelectorAll<HTMLElement>(
|
|
229
|
+
'.oceanum-notebooks-section'
|
|
230
|
+
);
|
|
231
|
+
return (
|
|
232
|
+
Array.from(sections).find(node =>
|
|
233
|
+
node
|
|
234
|
+
.querySelector('.oceanum-notebooks-heading')
|
|
235
|
+
?.textContent?.startsWith(title)
|
|
236
|
+
) ?? null
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const idsIn = (node: HTMLElement | null, attribute: string): string[] =>
|
|
241
|
+
Array.from(node?.querySelectorAll(`[${attribute}]`) ?? []).map(
|
|
242
|
+
row => row.getAttribute(attribute) ?? ''
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
it('offers Upload where the host can upload, and runs it on click', async () => {
|
|
246
|
+
stubFetch();
|
|
247
|
+
let uploads = 0;
|
|
248
|
+
const widget = await render(signedIn(), undefined, {
|
|
249
|
+
onUpload: () => {
|
|
250
|
+
uploads++;
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
const button = widget.node.querySelector<HTMLButtonElement>(
|
|
255
|
+
'.oceanum-notebooks-upload'
|
|
256
|
+
);
|
|
257
|
+
button!.click();
|
|
258
|
+
|
|
259
|
+
expect(uploads).toBe(1);
|
|
260
|
+
Widget.detach(widget);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('has no Upload button where the host cannot upload', async () => {
|
|
264
|
+
stubFetch();
|
|
265
|
+
const widget = await render(signedIn());
|
|
266
|
+
|
|
267
|
+
expect(widget.node.querySelector('.oceanum-notebooks-upload')).toBeNull();
|
|
268
|
+
Widget.detach(widget);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('lists the record’s examples in order, under their section titles', async () => {
|
|
272
|
+
stubFetch();
|
|
273
|
+
const widget = await render(signedIn(DEMO_ID));
|
|
274
|
+
|
|
275
|
+
const examples = section(widget, 'Examples')!;
|
|
276
|
+
expect(examples).not.toBeNull();
|
|
277
|
+
expect(
|
|
278
|
+
Array.from(
|
|
279
|
+
examples.querySelectorAll('.oceanum-notebooks-subheading'),
|
|
280
|
+
node => node.textContent
|
|
281
|
+
)
|
|
282
|
+
).toEqual(['Getting started', 'Advanced']);
|
|
283
|
+
const rows = Array.from(
|
|
284
|
+
examples.querySelectorAll<HTMLElement>('[data-example-id]')
|
|
285
|
+
);
|
|
286
|
+
expect(rows.map(row => row.textContent)).toEqual([
|
|
287
|
+
'Query a datasource',
|
|
288
|
+
'Plot waves',
|
|
289
|
+
'Spectra'
|
|
290
|
+
]);
|
|
291
|
+
expect(rows.map(row => row.getAttribute('data-example-id'))).toEqual([
|
|
292
|
+
PUBLIC_EXAMPLE,
|
|
293
|
+
OTHER_EXAMPLE,
|
|
294
|
+
MINE
|
|
295
|
+
]);
|
|
296
|
+
expect(rows[0].getAttribute('title')).toBe('One line');
|
|
297
|
+
expect(
|
|
298
|
+
examples.querySelector('.oceanum-notebooks-count')?.textContent
|
|
299
|
+
).toBe('3');
|
|
300
|
+
expect(fetches).toContain(
|
|
301
|
+
`https://specs.example.com/specs/notebook-demo/${DEMO_ID}`
|
|
302
|
+
);
|
|
303
|
+
widget.dispose();
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('moves examples out of Shared with me, but leaves My notebooks alone', async () => {
|
|
307
|
+
stubFetch();
|
|
308
|
+
const widget = await render(signedIn(DEMO_ID));
|
|
309
|
+
|
|
310
|
+
expect(idsIn(section(widget, 'Shared with me'), 'data-spec-id')).toEqual([
|
|
311
|
+
SHARED
|
|
312
|
+
]);
|
|
313
|
+
expect(idsIn(section(widget, 'My notebooks'), 'data-spec-id')).toEqual([
|
|
314
|
+
MINE
|
|
315
|
+
]);
|
|
316
|
+
widget.dispose();
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('asks for nothing more and shows no Examples where none is configured', async () => {
|
|
320
|
+
stubFetch();
|
|
321
|
+
const widget = await render(signedIn());
|
|
322
|
+
|
|
323
|
+
expect(section(widget, 'Examples')).toBeNull();
|
|
324
|
+
expect(fetches).toEqual(['https://specs.example.com/specs/notebook']);
|
|
325
|
+
// Without a record, a public example is just another shared notebook.
|
|
326
|
+
expect(idsIn(section(widget, 'Shared with me'), 'data-spec-id')).toEqual([
|
|
327
|
+
PUBLIC_EXAMPLE,
|
|
328
|
+
SHARED
|
|
329
|
+
]);
|
|
330
|
+
widget.dispose();
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it('says so when the record cannot be read, and still lists the rest', async () => {
|
|
334
|
+
stubFetch(
|
|
335
|
+
() => ({ ok: false, status: 404, json: async () => ({}) }) as Response
|
|
336
|
+
);
|
|
337
|
+
const widget = await render(signedIn(DEMO_ID));
|
|
338
|
+
|
|
339
|
+
const examples = section(widget, 'Examples')!;
|
|
340
|
+
expect(
|
|
341
|
+
examples.querySelector('.oceanum-text-error')?.textContent
|
|
342
|
+
).toContain('The examples could not be loaded.');
|
|
343
|
+
expect(examples.querySelector('[data-example-id]')).toBeNull();
|
|
344
|
+
expect(idsIn(section(widget, 'My notebooks'), 'data-spec-id')).toEqual([
|
|
345
|
+
MINE
|
|
346
|
+
]);
|
|
347
|
+
expect(idsIn(section(widget, 'Shared with me'), 'data-spec-id')).toEqual([
|
|
348
|
+
PUBLIC_EXAMPLE,
|
|
349
|
+
SHARED
|
|
350
|
+
]);
|
|
351
|
+
widget.dispose();
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it('keeps example rows out of the stored-notebook row menu', async () => {
|
|
355
|
+
// Its Rename and Delete would act on Oceanum's own example records.
|
|
356
|
+
stubFetch();
|
|
357
|
+
const widget = await render(signedIn(DEMO_ID), () => undefined);
|
|
358
|
+
|
|
359
|
+
const rows = section(widget, 'Examples')!.querySelectorAll('button');
|
|
360
|
+
expect(rows).toHaveLength(3);
|
|
361
|
+
for (const row of Array.from(rows)) {
|
|
362
|
+
expect(row.matches(NOTEBOOK_ITEM_SELECTOR)).toBe(false);
|
|
363
|
+
}
|
|
364
|
+
widget.dispose();
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it('opens an example through onOpenExample, with its title', async () => {
|
|
368
|
+
stubFetch();
|
|
369
|
+
const opened: INotebookDemoItem[] = [];
|
|
370
|
+
const widget = await render(signedIn(DEMO_ID), item => opened.push(item));
|
|
371
|
+
|
|
372
|
+
section(widget, 'Examples')!
|
|
373
|
+
.querySelector<HTMLButtonElement>(`[data-example-id="${OTHER_EXAMPLE}"]`)!
|
|
374
|
+
.click();
|
|
375
|
+
|
|
376
|
+
expect(opened).toEqual([{ id: OTHER_EXAMPLE, title: 'Plot waves' }]);
|
|
377
|
+
widget.dispose();
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
const switchIn = (widget: Harness): HTMLButtonElement | null =>
|
|
381
|
+
section(widget, 'Examples')!.querySelector<HTMLButtonElement>(
|
|
382
|
+
'[role="switch"]'
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
it('puts a switch on the Examples heading that asks to hide them', async () => {
|
|
386
|
+
stubFetch();
|
|
387
|
+
const changes: boolean[] = [];
|
|
388
|
+
const widget = await render(signedIn(DEMO_ID), undefined, {
|
|
389
|
+
showExamples: true,
|
|
390
|
+
onShowExamplesChange: show => changes.push(show)
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
const toggle = switchIn(widget)!;
|
|
394
|
+
expect(toggle.getAttribute('aria-checked')).toBe('true');
|
|
395
|
+
expect(toggle.getAttribute('aria-label')).toBe('Show examples');
|
|
396
|
+
expect(
|
|
397
|
+
section(widget, 'Examples')!
|
|
398
|
+
.querySelector('.oceanum-notebooks-heading')!
|
|
399
|
+
.contains(toggle)
|
|
400
|
+
).toBe(true);
|
|
401
|
+
|
|
402
|
+
toggle.click();
|
|
403
|
+
|
|
404
|
+
expect(changes).toEqual([false]);
|
|
405
|
+
widget.dispose();
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it('keeps hidden examples folded under their heading, with the switch to bring them back', async () => {
|
|
409
|
+
stubFetch();
|
|
410
|
+
const changes: boolean[] = [];
|
|
411
|
+
const widget = await render(signedIn(DEMO_ID), undefined, {
|
|
412
|
+
showExamples: false,
|
|
413
|
+
onShowExamplesChange: show => changes.push(show)
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
const examples = section(widget, 'Examples')!;
|
|
417
|
+
expect(examples.querySelector('[data-example-id]')).toBeNull();
|
|
418
|
+
expect(
|
|
419
|
+
examples.querySelector('.oceanum-notebooks-count')?.textContent
|
|
420
|
+
).toBe('3');
|
|
421
|
+
// Hidden, not forgotten: the record is still read, and its examples stay out of
|
|
422
|
+
// Shared with me.
|
|
423
|
+
expect(fetches).toContain(
|
|
424
|
+
`https://specs.example.com/specs/notebook-demo/${DEMO_ID}`
|
|
425
|
+
);
|
|
426
|
+
expect(idsIn(section(widget, 'Shared with me'), 'data-spec-id')).toEqual([
|
|
427
|
+
SHARED
|
|
428
|
+
]);
|
|
429
|
+
|
|
430
|
+
const toggle = switchIn(widget)!;
|
|
431
|
+
expect(toggle.getAttribute('aria-checked')).toBe('false');
|
|
432
|
+
toggle.click();
|
|
433
|
+
|
|
434
|
+
expect(changes).toEqual([true]);
|
|
435
|
+
widget.dispose();
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it('shows no error for examples the user has hidden', async () => {
|
|
439
|
+
stubFetch(
|
|
440
|
+
() => ({ ok: false, status: 404, json: async () => ({}) }) as Response
|
|
441
|
+
);
|
|
442
|
+
const widget = await render(signedIn(DEMO_ID), undefined, {
|
|
443
|
+
showExamples: false,
|
|
444
|
+
onShowExamplesChange: () => undefined
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
const examples = section(widget, 'Examples')!;
|
|
448
|
+
expect(examples.querySelector('.oceanum-text-error')).toBeNull();
|
|
449
|
+
expect(switchIn(widget)).not.toBeNull();
|
|
450
|
+
widget.dispose();
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it('offers no switch where the choice could not be kept', async () => {
|
|
454
|
+
stubFetch();
|
|
455
|
+
const widget = await render(signedIn(DEMO_ID));
|
|
456
|
+
|
|
457
|
+
expect(switchIn(widget)).toBeNull();
|
|
458
|
+
expect(
|
|
459
|
+
section(widget, 'Examples')!.querySelectorAll('[data-example-id]')
|
|
460
|
+
).toHaveLength(3);
|
|
461
|
+
widget.dispose();
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it('shows no examples while the setting is still loading', async () => {
|
|
465
|
+
// Otherwise examples a user has hidden would show for a moment on every reload.
|
|
466
|
+
stubFetch();
|
|
467
|
+
const widget = await render(signedIn(DEMO_ID), undefined, {
|
|
468
|
+
showExamples: null,
|
|
469
|
+
onShowExamplesChange: () => undefined
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
expect(section(widget, 'Examples')).toBeNull();
|
|
473
|
+
// They are still kept out of Shared with me in the meantime.
|
|
474
|
+
expect(idsIn(section(widget, 'Shared with me'), 'data-spec-id')).toEqual([
|
|
475
|
+
SHARED
|
|
476
|
+
]);
|
|
477
|
+
widget.dispose();
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
it('shows nothing for hidden examples that no switch could bring back', async () => {
|
|
481
|
+
stubFetch();
|
|
482
|
+
const widget = await render(signedIn(DEMO_ID), undefined, {
|
|
483
|
+
showExamples: false
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
expect(section(widget, 'Examples')).toBeNull();
|
|
487
|
+
widget.dispose();
|
|
488
|
+
});
|
|
489
|
+
});
|
|
490
|
+
|
|
120
491
|
describe('the Notebooks tab while signed out', () => {
|
|
121
492
|
it('links "Sign in" to starting a sign-in', async () => {
|
|
122
493
|
const auth = signedOut();
|
|
@@ -7,13 +7,17 @@ import {
|
|
|
7
7
|
FILE_BROWSER_ID,
|
|
8
8
|
hideFileBrowser,
|
|
9
9
|
LOCAL_FILE_COMMANDS,
|
|
10
|
+
moveToTop,
|
|
11
|
+
NON_NOTEBOOK_NEW_COMMANDS,
|
|
12
|
+
OCEANUM_PANEL_ID,
|
|
10
13
|
pruneBeforeEachOpen,
|
|
11
14
|
pruneFileMenu,
|
|
12
15
|
routeSaves
|
|
13
16
|
} from '../share/oceanumOnly';
|
|
14
17
|
|
|
15
18
|
describe('hideFileBrowser', () => {
|
|
16
|
-
|
|
19
|
+
/** A left area holding the file browser and the running-sessions panel. */
|
|
20
|
+
function leftArea(): { left: Panel; browser: Widget; running: Widget } {
|
|
17
21
|
const left = new Panel();
|
|
18
22
|
const browser = new Widget();
|
|
19
23
|
browser.id = FILE_BROWSER_ID;
|
|
@@ -21,8 +25,24 @@ describe('hideFileBrowser', () => {
|
|
|
21
25
|
running.id = 'jp-running-sessions';
|
|
22
26
|
left.addWidget(browser);
|
|
23
27
|
left.addWidget(running);
|
|
28
|
+
return { left, browser, running };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function shell(left: Panel) {
|
|
32
|
+
const activated: string[] = [];
|
|
33
|
+
return {
|
|
34
|
+
activated,
|
|
35
|
+
widgets: () => left.widgets,
|
|
36
|
+
activateById: (id: string): void => {
|
|
37
|
+
activated.push(id);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
it('closes the file browser without disposing it, and leaves the rest', () => {
|
|
43
|
+
const { left, browser, running } = leftArea();
|
|
24
44
|
|
|
25
|
-
expect(hideFileBrowser(
|
|
45
|
+
expect(hideFileBrowser(shell(left))).toBe(true);
|
|
26
46
|
|
|
27
47
|
expect(left.widgets).toEqual([running]);
|
|
28
48
|
// Other extensions still hold it through IDefaultFileBrowser.
|
|
@@ -31,7 +51,30 @@ describe('hideFileBrowser', () => {
|
|
|
31
51
|
|
|
32
52
|
it('reports when there is none to hide', () => {
|
|
33
53
|
const left = new Panel();
|
|
34
|
-
expect(hideFileBrowser(
|
|
54
|
+
expect(hideFileBrowser(shell(left))).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('opens the replacement when the file browser was the open panel', () => {
|
|
58
|
+
const { left } = leftArea();
|
|
59
|
+
Widget.attach(left, document.body);
|
|
60
|
+
const fake = shell(left);
|
|
61
|
+
|
|
62
|
+
hideFileBrowser(fake, OCEANUM_PANEL_ID);
|
|
63
|
+
|
|
64
|
+
expect(fake.activated).toEqual([OCEANUM_PANEL_ID]);
|
|
65
|
+
Widget.detach(left);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('leaves a collapsed sidebar collapsed', () => {
|
|
69
|
+
const { left, browser } = leftArea();
|
|
70
|
+
Widget.attach(left, document.body);
|
|
71
|
+
browser.hide();
|
|
72
|
+
const fake = shell(left);
|
|
73
|
+
|
|
74
|
+
hideFileBrowser(fake, OCEANUM_PANEL_ID);
|
|
75
|
+
|
|
76
|
+
expect(fake.activated).toEqual([]);
|
|
77
|
+
Widget.detach(left);
|
|
35
78
|
});
|
|
36
79
|
});
|
|
37
80
|
|
|
@@ -191,3 +234,84 @@ describe('routeSaves', () => {
|
|
|
191
234
|
expect(pushed).toEqual([first.panel, second.panel, first.panel]);
|
|
192
235
|
});
|
|
193
236
|
});
|
|
237
|
+
|
|
238
|
+
describe('the File > New menu', () => {
|
|
239
|
+
it('loses the entries for files that are not notebooks', () => {
|
|
240
|
+
const { menu } = fileMenu([
|
|
241
|
+
'console:create',
|
|
242
|
+
'notebook:create-new',
|
|
243
|
+
...NON_NOTEBOOK_NEW_COMMANDS
|
|
244
|
+
]);
|
|
245
|
+
|
|
246
|
+
expect(pruneFileMenu(menu, NON_NOTEBOOK_NEW_COMMANDS)).toBe(2);
|
|
247
|
+
|
|
248
|
+
expect(menuCommands(menu)).toEqual([
|
|
249
|
+
'console:create',
|
|
250
|
+
'notebook:create-new'
|
|
251
|
+
]);
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
describe('moveToTop', () => {
|
|
256
|
+
/** A left area in a saved order, and a shell that re-adds by rank as JupyterLab does. */
|
|
257
|
+
function sidebar(ids: string[]) {
|
|
258
|
+
const left = new Panel();
|
|
259
|
+
for (const id of ids) {
|
|
260
|
+
const widget = new Widget();
|
|
261
|
+
widget.id = id;
|
|
262
|
+
left.addWidget(widget);
|
|
263
|
+
}
|
|
264
|
+
Widget.attach(left, document.body);
|
|
265
|
+
const activated: string[] = [];
|
|
266
|
+
const added: [string, number][] = [];
|
|
267
|
+
const shell = {
|
|
268
|
+
widgets: () => left.widgets,
|
|
269
|
+
add: (widget: Widget, _: 'left', options: { rank: number }): void => {
|
|
270
|
+
added.push([widget.id, options.rank]);
|
|
271
|
+
widget.hide();
|
|
272
|
+
left.insertWidget(0, widget);
|
|
273
|
+
},
|
|
274
|
+
activateById: (id: string): void => {
|
|
275
|
+
activated.push(id);
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
const order = (): string[] => left.widgets.map(widget => widget.id);
|
|
279
|
+
const get = (id: string): Widget => left.widgets.find(w => w.id === id)!;
|
|
280
|
+
return { left, shell, activated, added, order, get };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
it('puts a panel a returning user had last back in front, and reopens it', () => {
|
|
284
|
+
const bar = sidebar(['jp-running-sessions', OCEANUM_PANEL_ID]);
|
|
285
|
+
bar.get('jp-running-sessions').hide();
|
|
286
|
+
|
|
287
|
+
expect(moveToTop(bar.shell, OCEANUM_PANEL_ID)).toBe(true);
|
|
288
|
+
|
|
289
|
+
expect(bar.added).toEqual([[OCEANUM_PANEL_ID, 0]]);
|
|
290
|
+
expect(bar.order()).toEqual([OCEANUM_PANEL_ID, 'jp-running-sessions']);
|
|
291
|
+
expect(bar.activated).toEqual([OCEANUM_PANEL_ID]);
|
|
292
|
+
Widget.detach(bar.left);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('does not open a panel that was closed', () => {
|
|
296
|
+
const bar = sidebar(['jp-running-sessions', OCEANUM_PANEL_ID]);
|
|
297
|
+
bar.get(OCEANUM_PANEL_ID).hide();
|
|
298
|
+
|
|
299
|
+
moveToTop(bar.shell, OCEANUM_PANEL_ID);
|
|
300
|
+
|
|
301
|
+
expect(bar.order()[0]).toBe(OCEANUM_PANEL_ID);
|
|
302
|
+
expect(bar.activated).toEqual([]);
|
|
303
|
+
Widget.detach(bar.left);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('leaves the sidebar alone when the panel is already first or absent', () => {
|
|
307
|
+
const first = sidebar([OCEANUM_PANEL_ID, 'jp-running-sessions']);
|
|
308
|
+
expect(moveToTop(first.shell, OCEANUM_PANEL_ID)).toBe(false);
|
|
309
|
+
expect(first.added).toEqual([]);
|
|
310
|
+
Widget.detach(first.left);
|
|
311
|
+
|
|
312
|
+
const absent = sidebar(['jp-running-sessions']);
|
|
313
|
+
expect(moveToTop(absent.shell, OCEANUM_PANEL_ID)).toBe(false);
|
|
314
|
+
expect(absent.added).toEqual([]);
|
|
315
|
+
Widget.detach(absent.left);
|
|
316
|
+
});
|
|
317
|
+
});
|
|
@@ -44,10 +44,20 @@ describe('the datamesh-connect settings schema', () => {
|
|
|
44
44
|
'datameshToken',
|
|
45
45
|
'injectToken',
|
|
46
46
|
'autoRunCode',
|
|
47
|
-
'iterate'
|
|
47
|
+
'iterate',
|
|
48
|
+
'showExamples'
|
|
48
49
|
]);
|
|
49
50
|
});
|
|
50
51
|
|
|
52
|
+
it('shows the examples unless the user has hidden them', async () => {
|
|
53
|
+
// Settings stored before the switch existed must still load, showing examples.
|
|
54
|
+
const before = await registryWith({ datameshToken: 'a-token' }).load(ID);
|
|
55
|
+
expect(before.get('showExamples').composite).toBe(true);
|
|
56
|
+
|
|
57
|
+
const hidden = await registryWith({ showExamples: false }).load(ID);
|
|
58
|
+
expect(hidden.get('showExamples').composite).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
|
|
51
61
|
it('still loads settings stored when they were', async () => {
|
|
52
62
|
const registry = registryWith({
|
|
53
63
|
datameshToken: 'a-token',
|