@oceanum/oceanumlab 4.7.1 → 4.7.2
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.js +14 -5
- package/lib/StoredNotebooks.d.ts +9 -5
- package/lib/StoredNotebooks.js +49 -21
- 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 +7 -0
- package/lib/index.d.ts +1 -1
- package/lib/index.js +8 -6
- package/lib/share/client.d.ts +84 -0
- package/lib/share/client.js +205 -0
- package/lib/share/events.d.ts +7 -0
- package/lib/share/events.js +9 -0
- package/lib/share/notebook.d.ts +184 -0
- package/lib/share/notebook.js +277 -0
- package/lib/share/oceanumOnly.d.ts +49 -0
- package/lib/share/oceanumOnly.js +90 -0
- package/lib/share/plugin.d.ts +21 -0
- package/lib/share/plugin.js +883 -0
- package/lib/share/widgets.d.ts +25 -0
- package/lib/share/widgets.js +154 -0
- package/package.json +5 -1
- package/src/DatameshWidget.tsx +21 -2
- package/src/StoredNotebooks.tsx +60 -20
- package/src/__tests__/OceanumPanel.spec.tsx +60 -4
- package/src/__tests__/StoredNotebooks.spec.tsx +157 -0
- package/src/__tests__/oceanumOnly.spec.ts +193 -0
- package/src/__tests__/shareClient.spec.ts +314 -0
- package/src/__tests__/shareNotebook.spec.ts +451 -0
- package/src/__tests__/sharePlugin.spec.ts +426 -0
- package/src/auth/serverAuth.ts +1 -0
- package/src/auth/startSignIn.ts +23 -0
- package/src/auth/tokens.ts +7 -0
- package/src/index.ts +8 -6
- package/src/share/client.ts +288 -0
- package/src/share/events.ts +10 -0
- package/src/share/notebook.ts +416 -0
- package/src/share/oceanumOnly.ts +126 -0
- package/src/share/plugin.ts +1096 -0
- package/src/share/widgets.ts +217 -0
- package/style/index.css +111 -16
- 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,25 @@
|
|
|
1
|
+
import { Dialog } from '@jupyterlab/apputils';
|
|
2
|
+
import { Widget } from '@lumino/widgets';
|
|
3
|
+
import type { ShareChoice } from './client';
|
|
4
|
+
import { ISpecSummary } from './notebook';
|
|
5
|
+
/** Dialog body listing notebooks to open; its value is the selected record id. */
|
|
6
|
+
export declare class NotebookListBody extends Widget implements Dialog.IBodyWidget<string | null> {
|
|
7
|
+
constructor(mine: readonly ISpecSummary[], shared: readonly ISpecSummary[]);
|
|
8
|
+
getValue(): string | null;
|
|
9
|
+
private _addSection;
|
|
10
|
+
private _select;
|
|
11
|
+
private _selected;
|
|
12
|
+
}
|
|
13
|
+
export declare const EVERYONE_WARNING: string;
|
|
14
|
+
/** Dialog body for sharing; its value is the change to make, or `null` if incomplete. */
|
|
15
|
+
export declare class ShareBody extends Widget implements Dialog.IBodyWidget<ShareChoice | null> {
|
|
16
|
+
constructor(link: string);
|
|
17
|
+
getValue(): ShareChoice | null;
|
|
18
|
+
private _radio;
|
|
19
|
+
private static _count;
|
|
20
|
+
private readonly _person;
|
|
21
|
+
private readonly _everyone;
|
|
22
|
+
private readonly _stopEveryone;
|
|
23
|
+
private readonly _email;
|
|
24
|
+
private readonly _level;
|
|
25
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { Clipboard } from '@jupyterlab/apputils';
|
|
2
|
+
import { Widget } from '@lumino/widgets';
|
|
3
|
+
import { parseShareEmails, parseTimestamp } from './notebook';
|
|
4
|
+
function element(tag, className, text) {
|
|
5
|
+
const node = document.createElement(tag);
|
|
6
|
+
node.className = className;
|
|
7
|
+
if (text !== undefined) {
|
|
8
|
+
// Names and emails come from other users: always set text, never HTML.
|
|
9
|
+
node.textContent = text;
|
|
10
|
+
}
|
|
11
|
+
return node;
|
|
12
|
+
}
|
|
13
|
+
/** Dialog body listing notebooks to open; its value is the selected record id. */
|
|
14
|
+
export class NotebookListBody extends Widget {
|
|
15
|
+
constructor(mine, shared) {
|
|
16
|
+
super();
|
|
17
|
+
this._selected = null;
|
|
18
|
+
this.addClass('jp-OceanumShare-body');
|
|
19
|
+
this._addSection('My notebooks', mine, false);
|
|
20
|
+
this._addSection('Shared with me', shared, true);
|
|
21
|
+
}
|
|
22
|
+
getValue() {
|
|
23
|
+
return this._selected;
|
|
24
|
+
}
|
|
25
|
+
_addSection(title, items, showCreator) {
|
|
26
|
+
this.node.appendChild(element('h3', 'jp-OceanumShare-heading', title));
|
|
27
|
+
if (items.length === 0) {
|
|
28
|
+
this.node.appendChild(element('p', 'jp-OceanumShare-empty', 'None'));
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const list = element('ul', 'jp-OceanumShare-list');
|
|
32
|
+
list.setAttribute('role', 'listbox');
|
|
33
|
+
list.setAttribute('aria-label', title);
|
|
34
|
+
for (const item of items) {
|
|
35
|
+
const option = element('li', 'jp-OceanumShare-item');
|
|
36
|
+
option.setAttribute('role', 'option');
|
|
37
|
+
option.setAttribute('aria-selected', 'false');
|
|
38
|
+
option.tabIndex = 0;
|
|
39
|
+
option.appendChild(element('span', 'jp-OceanumShare-name', item.name));
|
|
40
|
+
const modified = parseTimestamp(item.modified);
|
|
41
|
+
const details = [
|
|
42
|
+
modified ? `Modified ${modified.toLocaleDateString()}` : '',
|
|
43
|
+
showCreator && item.creator ? item.creator : ''
|
|
44
|
+
].filter(Boolean);
|
|
45
|
+
option.appendChild(element('span', 'jp-OceanumShare-meta', details.join(' · ')));
|
|
46
|
+
const select = () => this._select(item.id, option);
|
|
47
|
+
option.addEventListener('click', select);
|
|
48
|
+
option.addEventListener('focus', select);
|
|
49
|
+
list.appendChild(option);
|
|
50
|
+
}
|
|
51
|
+
this.node.appendChild(list);
|
|
52
|
+
}
|
|
53
|
+
_select(id, option) {
|
|
54
|
+
this._selected = id;
|
|
55
|
+
for (const node of this.node.querySelectorAll('.jp-OceanumShare-item')) {
|
|
56
|
+
const selected = node === option;
|
|
57
|
+
node.classList.toggle('jp-mod-selected', selected);
|
|
58
|
+
node.setAttribute('aria-selected', String(selected));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
export const EVERYONE_WARNING = 'Anyone with the link can read this notebook, including people without an Oceanum ' +
|
|
63
|
+
'account. This cannot be limited to your organisation. Its name and your email ' +
|
|
64
|
+
'address also become visible to anyone who lists Oceanum notebooks.';
|
|
65
|
+
const PUBLIC_GRANT = {
|
|
66
|
+
type: 'public',
|
|
67
|
+
entity: '',
|
|
68
|
+
permission: 'read'
|
|
69
|
+
};
|
|
70
|
+
/** Dialog body for sharing; its value is the change to make, or `null` if incomplete. */
|
|
71
|
+
export class ShareBody extends Widget {
|
|
72
|
+
constructor(link) {
|
|
73
|
+
super();
|
|
74
|
+
this.addClass('jp-OceanumShare-body');
|
|
75
|
+
this.node.appendChild(element('h3', 'jp-OceanumShare-heading', 'Link'));
|
|
76
|
+
const row = element('div', 'jp-OceanumShare-linkRow');
|
|
77
|
+
const linkInput = element('input', 'jp-OceanumShare-link');
|
|
78
|
+
linkInput.readOnly = true;
|
|
79
|
+
linkInput.value = link;
|
|
80
|
+
linkInput.setAttribute('aria-label', 'Notebook link');
|
|
81
|
+
const copy = element('button', 'jp-mod-styled jp-OceanumShare-copy', 'Copy link');
|
|
82
|
+
copy.type = 'button';
|
|
83
|
+
copy.addEventListener('click', () => {
|
|
84
|
+
Clipboard.copyToSystem(link);
|
|
85
|
+
copy.textContent = 'Copied';
|
|
86
|
+
});
|
|
87
|
+
row.append(linkInput, copy);
|
|
88
|
+
this.node.appendChild(row);
|
|
89
|
+
this.node.appendChild(element('p', 'jp-OceanumShare-hint', 'The link opens the notebook for anyone it is shared with.'));
|
|
90
|
+
this.node.appendChild(element('h3', 'jp-OceanumShare-heading', 'Share with'));
|
|
91
|
+
const name = `jp-OceanumShare-who-${++ShareBody._count}`;
|
|
92
|
+
this._person = this._radio(name, 'A specific person', true);
|
|
93
|
+
const personRow = element('div', 'jp-OceanumShare-personRow');
|
|
94
|
+
this._email = element('textarea', 'jp-OceanumShare-email');
|
|
95
|
+
this._email.rows = 2;
|
|
96
|
+
this._email.placeholder = 'name@example.com, another@example.com';
|
|
97
|
+
this._email.setAttribute('aria-label', 'Email addresses');
|
|
98
|
+
this._level = element('select', 'jp-OceanumShare-level');
|
|
99
|
+
this._level.setAttribute('aria-label', 'Access');
|
|
100
|
+
for (const [value, label] of [
|
|
101
|
+
['read', 'can view'],
|
|
102
|
+
['write', 'can edit']
|
|
103
|
+
]) {
|
|
104
|
+
const option = element('option', '', label);
|
|
105
|
+
option.value = value;
|
|
106
|
+
this._level.appendChild(option);
|
|
107
|
+
}
|
|
108
|
+
personRow.append(this._email, this._level);
|
|
109
|
+
this.node.appendChild(personRow);
|
|
110
|
+
this.node.appendChild(element('p', 'jp-OceanumShare-hint',
|
|
111
|
+
// Not "new lines": JupyterLab's dialog swallows Enter before it reaches the
|
|
112
|
+
// textarea, so one cannot be typed here. Pasted ones still separate addresses.
|
|
113
|
+
'Separate several addresses with commas or spaces. Everyone you add gets the access chosen here.'));
|
|
114
|
+
this._everyone = this._radio(name, 'Everyone with the link', false);
|
|
115
|
+
this.node.appendChild(element('p', 'jp-OceanumShare-warning', EVERYONE_WARNING));
|
|
116
|
+
this._stopEveryone = this._radio(name, 'Stop sharing with everyone', false);
|
|
117
|
+
const update = () => {
|
|
118
|
+
this._email.disabled = !this._person.checked;
|
|
119
|
+
this._level.disabled = !this._person.checked;
|
|
120
|
+
};
|
|
121
|
+
for (const radio of [this._person, this._everyone, this._stopEveryone]) {
|
|
122
|
+
radio.addEventListener('change', update);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
getValue() {
|
|
126
|
+
if (this._everyone.checked) {
|
|
127
|
+
return { action: 'grant', grants: [PUBLIC_GRANT], invalid: [] };
|
|
128
|
+
}
|
|
129
|
+
if (this._stopEveryone.checked) {
|
|
130
|
+
return { action: 'revoke', grant: PUBLIC_GRANT };
|
|
131
|
+
}
|
|
132
|
+
const { emails, invalid } = parseShareEmails(this._email.value);
|
|
133
|
+
if (emails.length === 0 && invalid.length === 0) {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
const permission = this._level.value === 'write' ? 'write' : 'read';
|
|
137
|
+
return {
|
|
138
|
+
action: 'grant',
|
|
139
|
+
grants: emails.map(entity => ({ type: 'user', entity, permission })),
|
|
140
|
+
invalid
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
_radio(name, text, checked) {
|
|
144
|
+
const label = element('label', 'jp-OceanumShare-choice');
|
|
145
|
+
const input = element('input', 'jp-OceanumShare-radio');
|
|
146
|
+
input.type = 'radio';
|
|
147
|
+
input.name = name;
|
|
148
|
+
input.checked = checked;
|
|
149
|
+
label.append(input, document.createTextNode(` ${text}`));
|
|
150
|
+
this.node.appendChild(label);
|
|
151
|
+
return input;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
ShareBody._count = 0;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oceanum/oceanumlab",
|
|
3
|
-
"version": "4.7.
|
|
3
|
+
"version": "4.7.2",
|
|
4
4
|
"description": "A Jupyterlab extension to interact with the Oceanum.io platform",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"jupyter",
|
|
@@ -62,12 +62,16 @@
|
|
|
62
62
|
"@jupyterlab/cells": "^4.6.3",
|
|
63
63
|
"@jupyterlab/console": "^4.6.3",
|
|
64
64
|
"@jupyterlab/coreutils": "^6.6.3",
|
|
65
|
+
"@jupyterlab/docmanager": "^4.6.3",
|
|
66
|
+
"@jupyterlab/mainmenu": "^4.6.3",
|
|
67
|
+
"@jupyterlab/nbformat": "^4.6.3",
|
|
65
68
|
"@jupyterlab/notebook": "^4.6.3",
|
|
66
69
|
"@jupyterlab/services": "^7.6.3",
|
|
67
70
|
"@jupyterlab/settingregistry": "^4.6.3",
|
|
68
71
|
"@lumino/commands": "^2.3.3",
|
|
69
72
|
"@lumino/coreutils": "^2.2.2",
|
|
70
73
|
"@lumino/disposable": "^2.1.5",
|
|
74
|
+
"@lumino/messaging": "^2.0.3",
|
|
71
75
|
"@lumino/signaling": "^2.1.5",
|
|
72
76
|
"@lumino/widgets": "^2.8.0",
|
|
73
77
|
"@phosphor-icons/react": "^2.1.10",
|
package/src/DatameshWidget.tsx
CHANGED
|
@@ -22,6 +22,7 @@ import { marked } from 'marked';
|
|
|
22
22
|
|
|
23
23
|
import { DatasourceItem } from './DatasourceItem';
|
|
24
24
|
import { IOceanumAuth } from './auth/tokens';
|
|
25
|
+
import { startSignIn } from './auth/startSignIn';
|
|
25
26
|
import { StoredNotebooks } from './StoredNotebooks';
|
|
26
27
|
import { ITab, Tabs } from './Tabs';
|
|
27
28
|
import {
|
|
@@ -29,6 +30,7 @@ import {
|
|
|
29
30
|
aiBackendUrl,
|
|
30
31
|
aiCredentialSource,
|
|
31
32
|
canSignIn,
|
|
33
|
+
isSignedIn,
|
|
32
34
|
pastedToken,
|
|
33
35
|
resolveAiCredential,
|
|
34
36
|
signInToken,
|
|
@@ -496,6 +498,8 @@ interface IAiAccess {
|
|
|
496
498
|
pasted: string;
|
|
497
499
|
/** Whether the host can sign the user in to Oceanum.io on this site. */
|
|
498
500
|
canSignIn: boolean;
|
|
501
|
+
/** Whether the host says the user is signed in to Oceanum.io. */
|
|
502
|
+
signedIn: boolean;
|
|
499
503
|
/** The backend address: the deployment's Oceanum AI. */
|
|
500
504
|
backend: string;
|
|
501
505
|
}
|
|
@@ -509,6 +513,7 @@ function readAiAccess(
|
|
|
509
513
|
source: aiCredentialSource(datameshToken, commands),
|
|
510
514
|
pasted: pastedToken(datameshToken),
|
|
511
515
|
canSignIn: canSignIn(commands),
|
|
516
|
+
signedIn: isSignedIn(commands),
|
|
512
517
|
backend: aiBackendUrl(settings.aiBackendUrl())
|
|
513
518
|
};
|
|
514
519
|
}
|
|
@@ -977,6 +982,7 @@ function PanelTabs({
|
|
|
977
982
|
commands,
|
|
978
983
|
settings,
|
|
979
984
|
renderDatamesh,
|
|
985
|
+
openStoredNotebook,
|
|
980
986
|
selected,
|
|
981
987
|
onSelect
|
|
982
988
|
}: {
|
|
@@ -984,6 +990,7 @@ function PanelTabs({
|
|
|
984
990
|
commands: CommandRegistry;
|
|
985
991
|
settings: IAiSettings;
|
|
986
992
|
renderDatamesh: () => React.ReactElement;
|
|
993
|
+
openStoredNotebook?: (id: string) => void;
|
|
987
994
|
selected: string;
|
|
988
995
|
onSelect: (id: string) => void;
|
|
989
996
|
}): React.ReactElement {
|
|
@@ -996,7 +1003,15 @@ function PanelTabs({
|
|
|
996
1003
|
// An auth with no environment is a provider that has nothing to sign in to (the
|
|
997
1004
|
// server has sign-in turned off). Asking the user to sign in would be a dead end.
|
|
998
1005
|
auth?.environment ? (
|
|
999
|
-
<StoredNotebooks
|
|
1006
|
+
<StoredNotebooks
|
|
1007
|
+
auth={auth}
|
|
1008
|
+
onOpen={openStoredNotebook && (item => openStoredNotebook(item.id))}
|
|
1009
|
+
onSignIn={() => {
|
|
1010
|
+
startSignIn(commands, auth).catch(error => {
|
|
1011
|
+
console.warn('Oceanum.io sign-in could not start.', error);
|
|
1012
|
+
});
|
|
1013
|
+
}}
|
|
1014
|
+
/>
|
|
1000
1015
|
) : (
|
|
1001
1016
|
<div className="oceanum-text-empty">
|
|
1002
1017
|
Oceanum.io sign-in is not configured for this host.
|
|
@@ -1005,7 +1020,10 @@ function PanelTabs({
|
|
|
1005
1020
|
},
|
|
1006
1021
|
{ id: 'datamesh', label: 'Datamesh', render: renderDatamesh }
|
|
1007
1022
|
];
|
|
1008
|
-
|
|
1023
|
+
// Signed in, not merely holding a credential: a Datamesh token pasted into the
|
|
1024
|
+
// settings would reach the backend, but the tab stays out of the way until someone
|
|
1025
|
+
// has signed in to Oceanum.io.
|
|
1026
|
+
if (access.signedIn) {
|
|
1009
1027
|
tabs.push({
|
|
1010
1028
|
id: 'ai',
|
|
1011
1029
|
label: 'Oceanum AI',
|
|
@@ -1128,6 +1146,7 @@ export class DatameshConnectWidget extends ReactWidget {
|
|
|
1128
1146
|
commands={this.props.commands}
|
|
1129
1147
|
settings={this.props}
|
|
1130
1148
|
renderDatamesh={() => this.renderDatamesh()}
|
|
1149
|
+
openStoredNotebook={this.props.openStoredNotebook}
|
|
1131
1150
|
selected={this.selectedTab}
|
|
1132
1151
|
onSelect={id => this.selectTab(id)}
|
|
1133
1152
|
/>
|
package/src/StoredNotebooks.tsx
CHANGED
|
@@ -1,16 +1,23 @@
|
|
|
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 { ISpecSummary, partitionSummaries } from './share/notebook';
|
|
7
|
+
import { SPEC_ID_ATTRIBUTE } from './share/plugin';
|
|
5
8
|
|
|
6
9
|
export interface IStoredNotebooksProps {
|
|
7
10
|
auth: IOceanumAuth;
|
|
8
11
|
/**
|
|
9
|
-
* Open a stored notebook
|
|
10
|
-
*
|
|
11
|
-
* until OCE-182. The list then reads rather than offering an action that does nothing.
|
|
12
|
+
* Open a stored notebook. Undefined where nothing has registered a way to open one;
|
|
13
|
+
* the list then reads rather than offering a click that does nothing.
|
|
12
14
|
*/
|
|
13
15
|
onOpen?: (item: ISpecSummary) => void;
|
|
16
|
+
/**
|
|
17
|
+
* Start signing in the way this host shows it; without it the auth's own `signIn()`
|
|
18
|
+
* is used, which on a JupyterLab server shows no code dialog.
|
|
19
|
+
*/
|
|
20
|
+
onSignIn?: () => void;
|
|
14
21
|
}
|
|
15
22
|
|
|
16
23
|
type Load =
|
|
@@ -29,6 +36,12 @@ function Section({
|
|
|
29
36
|
empty: string;
|
|
30
37
|
onOpen?: (item: ISpecSummary) => void;
|
|
31
38
|
}): React.ReactElement {
|
|
39
|
+
// Each row carries its record id so the share plugin's context menu (Open, Rename,
|
|
40
|
+
// Share, Delete) can tell which one was clicked.
|
|
41
|
+
const rowAttributes = (item: ISpecSummary) => ({
|
|
42
|
+
[SPEC_ID_ATTRIBUTE]: item.id,
|
|
43
|
+
title: item.description ?? undefined
|
|
44
|
+
});
|
|
32
45
|
return (
|
|
33
46
|
<div className="oceanum-notebooks-section">
|
|
34
47
|
<div className="oceanum-notebooks-heading">
|
|
@@ -47,7 +60,7 @@ function Section({
|
|
|
47
60
|
type="button"
|
|
48
61
|
className="oceanum-notebooks-item"
|
|
49
62
|
onClick={() => onOpen(item)}
|
|
50
|
-
|
|
63
|
+
{...rowAttributes(item)}
|
|
51
64
|
>
|
|
52
65
|
<span className="oceanum-notebooks-name">
|
|
53
66
|
{item.name || 'Untitled'}
|
|
@@ -56,7 +69,7 @@ function Section({
|
|
|
56
69
|
) : (
|
|
57
70
|
<div
|
|
58
71
|
className="oceanum-notebooks-item oceanum-notebooks-item-static"
|
|
59
|
-
|
|
72
|
+
{...rowAttributes(item)}
|
|
60
73
|
>
|
|
61
74
|
<span className="oceanum-notebooks-name">
|
|
62
75
|
{item.name || 'Untitled'}
|
|
@@ -76,18 +89,25 @@ function Section({
|
|
|
76
89
|
*/
|
|
77
90
|
export function StoredNotebooks({
|
|
78
91
|
auth,
|
|
79
|
-
onOpen
|
|
92
|
+
onOpen,
|
|
93
|
+
onSignIn
|
|
80
94
|
}: IStoredNotebooksProps): React.ReactElement {
|
|
81
95
|
const [load, setLoad] = React.useState<Load>({ state: 'loading' });
|
|
82
96
|
const [user, setUser] = React.useState(auth.user);
|
|
97
|
+
// Bumped whenever the store changes, so the list is fetched again.
|
|
98
|
+
const [version, setVersion] = React.useState(0);
|
|
83
99
|
|
|
84
100
|
// 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
|
|
101
|
+
// stale list from the previous session on screen; and whenever this extension has
|
|
102
|
+
// created, renamed or deleted a record.
|
|
86
103
|
React.useEffect(() => {
|
|
87
104
|
const onUser = (): void => setUser(auth.user);
|
|
105
|
+
const onChanged = (): void => setVersion(v => v + 1);
|
|
88
106
|
auth.userChanged.connect(onUser);
|
|
107
|
+
notebooksChanged.connect(onChanged);
|
|
89
108
|
return () => {
|
|
90
109
|
auth.userChanged.disconnect(onUser);
|
|
110
|
+
notebooksChanged.disconnect(onChanged);
|
|
91
111
|
};
|
|
92
112
|
}, [auth]);
|
|
93
113
|
|
|
@@ -101,15 +121,11 @@ export function StoredNotebooks({
|
|
|
101
121
|
setLoad({ state: 'loading' });
|
|
102
122
|
void (async () => {
|
|
103
123
|
try {
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
setLoad({ state: 'ready', items: [] });
|
|
110
|
-
return;
|
|
111
|
-
}
|
|
112
|
-
const items = await listNotebooks(specs, token);
|
|
124
|
+
const client = new SpecStoreClient({
|
|
125
|
+
specsUrl: specs,
|
|
126
|
+
getAccessToken: () => auth.getAccessToken()
|
|
127
|
+
});
|
|
128
|
+
const items = await client.list();
|
|
113
129
|
if (!cancelled) {
|
|
114
130
|
setLoad({ state: 'ready', items });
|
|
115
131
|
}
|
|
@@ -128,12 +144,36 @@ export function StoredNotebooks({
|
|
|
128
144
|
return () => {
|
|
129
145
|
cancelled = true;
|
|
130
146
|
};
|
|
131
|
-
}, [auth, user]);
|
|
147
|
+
}, [auth, user, version]);
|
|
132
148
|
|
|
133
149
|
if (!user) {
|
|
150
|
+
const signIn = (): void => {
|
|
151
|
+
if (onSignIn) {
|
|
152
|
+
onSignIn();
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
auth.signIn().catch(error => {
|
|
156
|
+
console.warn('Oceanum.io sign-in could not start.', error);
|
|
157
|
+
});
|
|
158
|
+
};
|
|
134
159
|
return (
|
|
135
160
|
<div className="oceanum-text-empty">
|
|
136
|
-
|
|
161
|
+
{/* A link rather than a button to match the panel's other actions, so it
|
|
162
|
+
carries the role and the keys a button would have. */}
|
|
163
|
+
<a
|
|
164
|
+
role="button"
|
|
165
|
+
tabIndex={0}
|
|
166
|
+
onClick={signIn}
|
|
167
|
+
onKeyDown={event => {
|
|
168
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
169
|
+
event.preventDefault();
|
|
170
|
+
signIn();
|
|
171
|
+
}
|
|
172
|
+
}}
|
|
173
|
+
>
|
|
174
|
+
Sign in
|
|
175
|
+
</a>{' '}
|
|
176
|
+
to Oceanum.io to see your notebooks.
|
|
137
177
|
</div>
|
|
138
178
|
);
|
|
139
179
|
}
|
|
@@ -146,7 +186,7 @@ export function StoredNotebooks({
|
|
|
146
186
|
return <div className="oceanum-text-error">{load.message}</div>;
|
|
147
187
|
}
|
|
148
188
|
|
|
149
|
-
const { mine, shared } =
|
|
189
|
+
const { mine, shared } = partitionSummaries(load.items, user.email);
|
|
150
190
|
return (
|
|
151
191
|
<div className="oceanum-notebooks">
|
|
152
192
|
<Section
|
|
@@ -11,8 +11,27 @@ 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
|
+
}
|
|
20
|
+
|
|
21
|
+
function panel(options: IPanelOptions = {}): DatameshConnectWidget {
|
|
15
22
|
const commands = new CommandRegistry();
|
|
23
|
+
if (options.signedIn !== undefined) {
|
|
24
|
+
// What the host provides: sign-out is visible only while signed in, which is how
|
|
25
|
+
// the sidebar reads the state without running anything.
|
|
26
|
+
commands.addCommand('oceanum-auth:sign-out', {
|
|
27
|
+
execute: () => undefined,
|
|
28
|
+
isVisible: () => options.signedIn === true
|
|
29
|
+
});
|
|
30
|
+
commands.addCommand('oceanum-auth:access-token', {
|
|
31
|
+
execute: () => null,
|
|
32
|
+
isVisible: () => false
|
|
33
|
+
});
|
|
34
|
+
}
|
|
16
35
|
const app = {
|
|
17
36
|
shell: { currentWidget: null },
|
|
18
37
|
commands
|
|
@@ -24,7 +43,7 @@ function panel(): DatameshConnectWidget {
|
|
|
24
43
|
openDatameshUI: (): void => undefined,
|
|
25
44
|
datameshUiUrl: () => 'https://ui.datamesh.example.com',
|
|
26
45
|
datameshUiFrame: (): Window | null => null,
|
|
27
|
-
datameshToken: () => '',
|
|
46
|
+
datameshToken: () => options.token ?? '',
|
|
28
47
|
aiBackendUrl: () => 'https://ai.example.com',
|
|
29
48
|
settingsChanged: new Signal<unknown, void>({}),
|
|
30
49
|
commands,
|
|
@@ -33,8 +52,10 @@ function panel(): DatameshConnectWidget {
|
|
|
33
52
|
} as unknown as ConstructorParameters<typeof DatameshConnectWidget>[0]);
|
|
34
53
|
}
|
|
35
54
|
|
|
36
|
-
async function mount(
|
|
37
|
-
|
|
55
|
+
async function mount(
|
|
56
|
+
options: IPanelOptions = {}
|
|
57
|
+
): Promise<DatameshConnectWidget> {
|
|
58
|
+
const widget = panel(options);
|
|
38
59
|
Widget.attach(widget, document.body);
|
|
39
60
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
40
61
|
return widget;
|
|
@@ -46,6 +67,19 @@ async function mount(): Promise<DatameshConnectWidget> {
|
|
|
46
67
|
* touches the Datamesh surface, so nothing else here would notice if rearranging the
|
|
47
68
|
* two lost one of them.
|
|
48
69
|
*/
|
|
70
|
+
beforeAll(() => {
|
|
71
|
+
// The AI chat scrolls to its last message, and every pane renders whether or not it
|
|
72
|
+
// is the selected one. jsdom has no scrollIntoView, and the error would come back as
|
|
73
|
+
// React unmounting the whole sidebar.
|
|
74
|
+
(Element.prototype as unknown as Record<string, unknown>).scrollIntoView =
|
|
75
|
+
(): void => undefined;
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const tabLabels = (widget: DatameshConnectWidget): (string | null)[] =>
|
|
79
|
+
Array.from(widget.node.querySelectorAll('[role="tab"]')).map(
|
|
80
|
+
node => node.textContent
|
|
81
|
+
);
|
|
82
|
+
|
|
49
83
|
describe('the Oceanum panel', () => {
|
|
50
84
|
it('offers no Oceanum AI tab when the chat has no credential', async () => {
|
|
51
85
|
// AIChatPanel renders nothing without one, so a labelled tab would open on the
|
|
@@ -61,6 +95,28 @@ describe('the Oceanum panel', () => {
|
|
|
61
95
|
widget.dispose();
|
|
62
96
|
});
|
|
63
97
|
|
|
98
|
+
it('offers no Oceanum AI tab for a pasted token alone, until someone signs in', async () => {
|
|
99
|
+
// The token would reach the backend, but the tab waits for a sign-in.
|
|
100
|
+
const withToken = await mount({ token: 'a-datamesh-token' });
|
|
101
|
+
expect(tabLabels(withToken)).toEqual(['Notebooks', 'Datamesh']);
|
|
102
|
+
withToken.dispose();
|
|
103
|
+
|
|
104
|
+
const signedOut = await mount({
|
|
105
|
+
token: 'a-datamesh-token',
|
|
106
|
+
signedIn: false
|
|
107
|
+
});
|
|
108
|
+
expect(tabLabels(signedOut)).toEqual(['Notebooks', 'Datamesh']);
|
|
109
|
+
signedOut.dispose();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('offers the Oceanum AI tab once the host says the user is signed in', async () => {
|
|
113
|
+
const widget = await mount({ signedIn: true });
|
|
114
|
+
|
|
115
|
+
expect(tabLabels(widget)).toEqual(['Notebooks', 'Datamesh', 'Oceanum AI']);
|
|
116
|
+
|
|
117
|
+
widget.dispose();
|
|
118
|
+
});
|
|
119
|
+
|
|
64
120
|
it('still renders the Datamesh workspace section', async () => {
|
|
65
121
|
const widget = await mount();
|
|
66
122
|
|