@oceanum/oceanumlab 4.7.0 → 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/README.md +102 -11
- package/lib/DatameshUI.d.ts +1 -6
- package/lib/DatameshUI.js +1 -9
- package/lib/DatameshWidget.d.ts +18 -2
- package/lib/DatameshWidget.js +80 -12
- package/lib/StoredNotebooks.d.ts +20 -0
- package/lib/StoredNotebooks.js +112 -0
- package/lib/Tabs.d.ts +26 -0
- package/lib/Tabs.js +45 -0
- package/lib/aiBackend.d.ts +1 -1
- package/lib/aiBackend.js +1 -1
- package/lib/auth/accountWidget.d.ts +35 -0
- package/lib/auth/accountWidget.js +95 -0
- package/lib/auth/claims.d.ts +3 -0
- package/lib/auth/claims.js +25 -0
- package/lib/auth/plugin.d.ts +9 -0
- package/lib/auth/plugin.js +126 -0
- package/lib/auth/serverAuth.d.ts +79 -0
- package/lib/auth/serverAuth.js +254 -0
- package/lib/auth/startSignIn.d.ts +11 -0
- package/lib/auth/startSignIn.js +16 -0
- package/lib/auth/tokens.d.ts +102 -0
- package/lib/auth/tokens.js +9 -0
- package/lib/chatRouter.d.ts +13 -1
- package/lib/chatRouter.js +9 -2
- package/lib/constants.d.ts +1 -1
- package/lib/constants.js +1 -1
- package/lib/datameshUiUrl.d.ts +2 -2
- package/lib/datameshUiUrl.js +6 -6
- package/lib/index.d.ts +8 -1
- package/lib/index.js +58 -17
- 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 +13 -1
- package/schema/datamesh-connect.json +3 -12
- package/src/DatameshUI.tsx +1 -10
- package/src/DatameshWidget.tsx +143 -19
- package/src/StoredNotebooks.tsx +206 -0
- package/src/Tabs.tsx +102 -0
- package/src/__tests__/DatameshWidget.spec.tsx +33 -20
- package/src/__tests__/OceanumPanel.spec.tsx +157 -0
- package/src/__tests__/StoredNotebooks.spec.tsx +157 -0
- package/src/__tests__/Tabs.spec.tsx +168 -0
- package/src/__tests__/aiBackend.spec.ts +5 -6
- package/src/__tests__/chatRouter.spec.ts +9 -15
- package/src/__tests__/datameshUiUrl.spec.ts +4 -3
- package/src/__tests__/oceanumOnly.spec.ts +193 -0
- package/src/__tests__/pluginArity.spec.ts +24 -0
- package/src/__tests__/serverAuth.spec.ts +277 -0
- package/src/__tests__/settingsSchema.spec.ts +73 -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/aiBackend.ts +1 -1
- package/src/auth/accountWidget.ts +113 -0
- package/src/auth/claims.ts +30 -0
- package/src/auth/plugin.ts +155 -0
- package/src/auth/serverAuth.ts +342 -0
- package/src/auth/startSignIn.ts +23 -0
- package/src/auth/tokens.ts +111 -0
- package/src/chatRouter.ts +8 -4
- package/src/constants.ts +1 -1
- package/src/datameshUiUrl.ts +6 -6
- package/src/index.ts +87 -36
- 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 +266 -13
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
|
|
3
|
+
import { IOceanumAuth } from './auth/tokens';
|
|
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';
|
|
8
|
+
|
|
9
|
+
export interface IStoredNotebooksProps {
|
|
10
|
+
auth: IOceanumAuth;
|
|
11
|
+
/**
|
|
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.
|
|
14
|
+
*/
|
|
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;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type Load =
|
|
24
|
+
| { state: 'loading' }
|
|
25
|
+
| { state: 'error'; message: string }
|
|
26
|
+
| { state: 'ready'; items: ISpecSummary[] };
|
|
27
|
+
|
|
28
|
+
function Section({
|
|
29
|
+
title,
|
|
30
|
+
items,
|
|
31
|
+
empty,
|
|
32
|
+
onOpen
|
|
33
|
+
}: {
|
|
34
|
+
title: string;
|
|
35
|
+
items: readonly ISpecSummary[];
|
|
36
|
+
empty: string;
|
|
37
|
+
onOpen?: (item: ISpecSummary) => void;
|
|
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
|
+
});
|
|
45
|
+
return (
|
|
46
|
+
<div className="oceanum-notebooks-section">
|
|
47
|
+
<div className="oceanum-notebooks-heading">
|
|
48
|
+
{title}
|
|
49
|
+
<span className="oceanum-notebooks-count">{items.length}</span>
|
|
50
|
+
</div>
|
|
51
|
+
{items.length === 0 ? (
|
|
52
|
+
<div className="oceanum-text-empty">{empty}</div>
|
|
53
|
+
) : (
|
|
54
|
+
<ul className="oceanum-notebooks-list">
|
|
55
|
+
{items.map(item => (
|
|
56
|
+
<li key={item.id}>
|
|
57
|
+
{/* Names come from other users: React escapes these, never set as HTML. */}
|
|
58
|
+
{onOpen ? (
|
|
59
|
+
<button
|
|
60
|
+
type="button"
|
|
61
|
+
className="oceanum-notebooks-item"
|
|
62
|
+
onClick={() => onOpen(item)}
|
|
63
|
+
{...rowAttributes(item)}
|
|
64
|
+
>
|
|
65
|
+
<span className="oceanum-notebooks-name">
|
|
66
|
+
{item.name || 'Untitled'}
|
|
67
|
+
</span>
|
|
68
|
+
</button>
|
|
69
|
+
) : (
|
|
70
|
+
<div
|
|
71
|
+
className="oceanum-notebooks-item oceanum-notebooks-item-static"
|
|
72
|
+
{...rowAttributes(item)}
|
|
73
|
+
>
|
|
74
|
+
<span className="oceanum-notebooks-name">
|
|
75
|
+
{item.name || 'Untitled'}
|
|
76
|
+
</span>
|
|
77
|
+
</div>
|
|
78
|
+
)}
|
|
79
|
+
</li>
|
|
80
|
+
))}
|
|
81
|
+
</ul>
|
|
82
|
+
)}
|
|
83
|
+
</div>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The notebooks this user has stored on Oceanum.io, theirs and those shared with them.
|
|
89
|
+
*/
|
|
90
|
+
export function StoredNotebooks({
|
|
91
|
+
auth,
|
|
92
|
+
onOpen,
|
|
93
|
+
onSignIn
|
|
94
|
+
}: IStoredNotebooksProps): React.ReactElement {
|
|
95
|
+
const [load, setLoad] = React.useState<Load>({ state: 'loading' });
|
|
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);
|
|
99
|
+
|
|
100
|
+
// Reload whenever the signed-in user changes, so signing in or out does not leave a
|
|
101
|
+
// stale list from the previous session on screen; and whenever this extension has
|
|
102
|
+
// created, renamed or deleted a record.
|
|
103
|
+
React.useEffect(() => {
|
|
104
|
+
const onUser = (): void => setUser(auth.user);
|
|
105
|
+
const onChanged = (): void => setVersion(v => v + 1);
|
|
106
|
+
auth.userChanged.connect(onUser);
|
|
107
|
+
notebooksChanged.connect(onChanged);
|
|
108
|
+
return () => {
|
|
109
|
+
auth.userChanged.disconnect(onUser);
|
|
110
|
+
notebooksChanged.disconnect(onChanged);
|
|
111
|
+
};
|
|
112
|
+
}, [auth]);
|
|
113
|
+
|
|
114
|
+
React.useEffect(() => {
|
|
115
|
+
let cancelled = false;
|
|
116
|
+
const specs = auth.urls?.specs ?? null;
|
|
117
|
+
if (!user || !specs) {
|
|
118
|
+
setLoad({ state: 'ready', items: [] });
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
setLoad({ state: 'loading' });
|
|
122
|
+
void (async () => {
|
|
123
|
+
try {
|
|
124
|
+
const client = new SpecStoreClient({
|
|
125
|
+
specsUrl: specs,
|
|
126
|
+
getAccessToken: () => auth.getAccessToken()
|
|
127
|
+
});
|
|
128
|
+
const items = await client.list();
|
|
129
|
+
if (!cancelled) {
|
|
130
|
+
setLoad({ state: 'ready', items });
|
|
131
|
+
}
|
|
132
|
+
} catch (error) {
|
|
133
|
+
if (!cancelled) {
|
|
134
|
+
setLoad({
|
|
135
|
+
state: 'error',
|
|
136
|
+
message:
|
|
137
|
+
error instanceof Error
|
|
138
|
+
? error.message
|
|
139
|
+
: 'Could not reach Oceanum.io.'
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
})();
|
|
144
|
+
return () => {
|
|
145
|
+
cancelled = true;
|
|
146
|
+
};
|
|
147
|
+
}, [auth, user, version]);
|
|
148
|
+
|
|
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
|
+
};
|
|
159
|
+
return (
|
|
160
|
+
<div className="oceanum-text-empty">
|
|
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.
|
|
177
|
+
</div>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
if (load.state === 'loading') {
|
|
181
|
+
return <div className="oceanum-text-empty">Loading…</div>;
|
|
182
|
+
}
|
|
183
|
+
if (load.state === 'error') {
|
|
184
|
+
// Never render the sections alongside an error: their empty messages would claim
|
|
185
|
+
// the account has no notebooks when the load simply failed.
|
|
186
|
+
return <div className="oceanum-text-error">{load.message}</div>;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const { mine, shared } = partitionSummaries(load.items, user.email);
|
|
190
|
+
return (
|
|
191
|
+
<div className="oceanum-notebooks">
|
|
192
|
+
<Section
|
|
193
|
+
title="My notebooks"
|
|
194
|
+
items={mine}
|
|
195
|
+
empty="You have not saved any notebooks yet."
|
|
196
|
+
onOpen={onOpen}
|
|
197
|
+
/>
|
|
198
|
+
<Section
|
|
199
|
+
title="Shared with me"
|
|
200
|
+
items={shared}
|
|
201
|
+
empty="No notebooks have been shared with you."
|
|
202
|
+
onOpen={onOpen}
|
|
203
|
+
/>
|
|
204
|
+
</div>
|
|
205
|
+
);
|
|
206
|
+
}
|
package/src/Tabs.tsx
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
|
|
3
|
+
export interface ITab {
|
|
4
|
+
/** Stable key, also used as the restored selection value. */
|
|
5
|
+
id: string;
|
|
6
|
+
label: string;
|
|
7
|
+
render: () => React.ReactElement;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ITabsProps {
|
|
11
|
+
tabs: readonly ITab[];
|
|
12
|
+
/** Selected tab id. Falls back to the first tab when it names no tab. */
|
|
13
|
+
selected: string;
|
|
14
|
+
onSelect: (id: string) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A tab strip over a single visible pane.
|
|
19
|
+
*
|
|
20
|
+
* JupyterLab users work from the keyboard, so the strip follows the WAI-ARIA tabs
|
|
21
|
+
* pattern: one tab stop for the whole strip, arrows to move between tabs, Home and End
|
|
22
|
+
* to reach the ends.
|
|
23
|
+
*
|
|
24
|
+
* Every pane stays mounted and the inactive ones are hidden. Unmounting would be
|
|
25
|
+
* cheaper, but the AI pane holds the conversation in component state and the Datamesh
|
|
26
|
+
* pane holds a workspace subscription, so switching tabs would silently discard a chat
|
|
27
|
+
* the user is in the middle of.
|
|
28
|
+
*/
|
|
29
|
+
export function Tabs({
|
|
30
|
+
tabs,
|
|
31
|
+
selected,
|
|
32
|
+
onSelect
|
|
33
|
+
}: ITabsProps): React.ReactElement {
|
|
34
|
+
const index = Math.max(
|
|
35
|
+
0,
|
|
36
|
+
tabs.findIndex(tab => tab.id === selected)
|
|
37
|
+
);
|
|
38
|
+
const active = tabs[index];
|
|
39
|
+
|
|
40
|
+
const onKeyDown = (event: React.KeyboardEvent): void => {
|
|
41
|
+
const last = tabs.length - 1;
|
|
42
|
+
let next: number | null = null;
|
|
43
|
+
if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
|
|
44
|
+
next = index === last ? 0 : index + 1;
|
|
45
|
+
} else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
|
|
46
|
+
next = index === 0 ? last : index - 1;
|
|
47
|
+
} else if (event.key === 'Home') {
|
|
48
|
+
next = 0;
|
|
49
|
+
} else if (event.key === 'End') {
|
|
50
|
+
next = last;
|
|
51
|
+
}
|
|
52
|
+
if (next === null) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
// Otherwise the arrow keys also scroll the panel.
|
|
56
|
+
event.preventDefault();
|
|
57
|
+
onSelect(tabs[next].id);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<div className="oceanum-tabs">
|
|
62
|
+
<div
|
|
63
|
+
className="oceanum-tabs-strip"
|
|
64
|
+
role="tablist"
|
|
65
|
+
aria-label="Oceanum.io"
|
|
66
|
+
>
|
|
67
|
+
{tabs.map((tab, i) => (
|
|
68
|
+
<button
|
|
69
|
+
key={tab.id}
|
|
70
|
+
id={`oceanum-tab-${tab.id}`}
|
|
71
|
+
className={
|
|
72
|
+
i === index ? 'oceanum-tab oceanum-tab-selected' : 'oceanum-tab'
|
|
73
|
+
}
|
|
74
|
+
role="tab"
|
|
75
|
+
type="button"
|
|
76
|
+
aria-selected={i === index}
|
|
77
|
+
aria-controls={`oceanum-tabpanel-${tab.id}`}
|
|
78
|
+
// Only the selected tab is in the tab order, so Tab moves past the strip
|
|
79
|
+
// rather than through every tab in it.
|
|
80
|
+
tabIndex={i === index ? 0 : -1}
|
|
81
|
+
onClick={() => onSelect(tab.id)}
|
|
82
|
+
onKeyDown={onKeyDown}
|
|
83
|
+
>
|
|
84
|
+
{tab.label}
|
|
85
|
+
</button>
|
|
86
|
+
))}
|
|
87
|
+
</div>
|
|
88
|
+
{tabs.map(tab => (
|
|
89
|
+
<div
|
|
90
|
+
key={tab.id}
|
|
91
|
+
className="oceanum-tabpanel"
|
|
92
|
+
id={`oceanum-tabpanel-${tab.id}`}
|
|
93
|
+
role="tabpanel"
|
|
94
|
+
aria-labelledby={`oceanum-tab-${tab.id}`}
|
|
95
|
+
hidden={tab.id !== active.id}
|
|
96
|
+
>
|
|
97
|
+
{tab.render()}
|
|
98
|
+
</div>
|
|
99
|
+
))}
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
@@ -199,12 +199,16 @@ describe('DatameshConnectWidget: Oceanum AI with the host sign-in', () => {
|
|
|
199
199
|
oceanum.setSignedIn(true);
|
|
200
200
|
await until(() => expect(chat(node)).not.toBeNull());
|
|
201
201
|
expect(notice(node)).toBeNull();
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
202
|
+
// Also awaited: the chat renders before the capabilities call resolves, so asserting
|
|
203
|
+
// this straight after the chat appears is a race that a slow run loses.
|
|
204
|
+
await until(() =>
|
|
205
|
+
expect(requests).toEqual([
|
|
206
|
+
{
|
|
207
|
+
url: 'https://ai.example.test/api/capabilities',
|
|
208
|
+
headers: { Authorization: 'Bearer a-jwt' }
|
|
209
|
+
}
|
|
210
|
+
])
|
|
211
|
+
);
|
|
208
212
|
// One token request for the capabilities, however many components watch.
|
|
209
213
|
expect(oceanum.accessToken).toHaveBeenCalledTimes(1);
|
|
210
214
|
|
|
@@ -237,7 +241,9 @@ describe('DatameshConnectWidget: Oceanum AI with the host sign-in', () => {
|
|
|
237
241
|
// Signing in is a change, not a poll: it may run the token command once.
|
|
238
242
|
oceanum.setSignedIn(true);
|
|
239
243
|
await until(() => expect(chat(node)).not.toBeNull());
|
|
240
|
-
|
|
244
|
+
// Awaited: the chat renders before the token command resolves, so asserting straight
|
|
245
|
+
// after it appears is a race that a slow run loses.
|
|
246
|
+
await until(() => expect(oceanum.executed).toEqual([ACCESS_TOKEN_COMMAND]));
|
|
241
247
|
|
|
242
248
|
await settle(2000);
|
|
243
249
|
expect(oceanum.executed).toEqual([ACCESS_TOKEN_COMMAND]);
|
|
@@ -248,7 +254,8 @@ describe('DatameshConnectWidget: Oceanum AI with the host sign-in', () => {
|
|
|
248
254
|
const node = mount(oceanum.commands);
|
|
249
255
|
|
|
250
256
|
await until(() => expect(chat(node)).not.toBeNull());
|
|
251
|
-
|
|
257
|
+
// Awaited: the capabilities request resolves separately from the chat rendering.
|
|
258
|
+
await until(() => expect(requests).toHaveLength(1));
|
|
252
259
|
|
|
253
260
|
// The host refreshed the token and told the registry, as it does.
|
|
254
261
|
oceanum.setToken('refreshed-jwt');
|
|
@@ -264,12 +271,15 @@ describe('DatameshConnectWidget: Oceanum AI with the host sign-in', () => {
|
|
|
264
271
|
const node = mount(oceanum.commands, { pasted: 'pasted-token' });
|
|
265
272
|
|
|
266
273
|
await until(() => expect(chat(node)).not.toBeNull());
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
274
|
+
// Awaited: the capabilities request resolves separately from the chat rendering.
|
|
275
|
+
await until(() =>
|
|
276
|
+
expect(requests).toEqual([
|
|
277
|
+
{
|
|
278
|
+
url: 'https://ai.oceanum.io/api/capabilities',
|
|
279
|
+
headers: { 'X-Datamesh-Token': 'pasted-token' }
|
|
280
|
+
}
|
|
281
|
+
])
|
|
282
|
+
);
|
|
273
283
|
expect(oceanum.executed).toEqual([]);
|
|
274
284
|
});
|
|
275
285
|
|
|
@@ -283,12 +293,15 @@ describe('DatameshConnectWidget: Oceanum AI with the host sign-in', () => {
|
|
|
283
293
|
const node = mount(oceanum.commands, { pasted: '' });
|
|
284
294
|
|
|
285
295
|
await until(() => expect(chat(node)).not.toBeNull());
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
296
|
+
// Awaited: the capabilities request resolves separately from the chat rendering.
|
|
297
|
+
await until(() =>
|
|
298
|
+
expect(requests).toEqual([
|
|
299
|
+
{
|
|
300
|
+
url: 'https://ai.oceanum.io/api/capabilities',
|
|
301
|
+
headers: { Authorization: 'Bearer a-jwt' }
|
|
302
|
+
}
|
|
303
|
+
])
|
|
304
|
+
);
|
|
292
305
|
});
|
|
293
306
|
|
|
294
307
|
it('keeps the chat hidden when the capabilities say there is no code', async () => {
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { JupyterFrontEnd } from '@jupyterlab/application';
|
|
2
|
+
import { CommandRegistry } from '@lumino/commands';
|
|
3
|
+
import { Signal } from '@lumino/signaling';
|
|
4
|
+
import { Widget } from '@lumino/widgets';
|
|
5
|
+
import { LabIcon } from '@jupyterlab/ui-components';
|
|
6
|
+
|
|
7
|
+
import { DatameshConnectWidget } from '../DatameshWidget';
|
|
8
|
+
|
|
9
|
+
const icon = new LabIcon({
|
|
10
|
+
name: 'oceanum:test-icon',
|
|
11
|
+
svgstr: '<svg xmlns="http://www.w3.org/2000/svg"></svg>'
|
|
12
|
+
});
|
|
13
|
+
|
|
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 {
|
|
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
|
+
}
|
|
35
|
+
const app = {
|
|
36
|
+
shell: { currentWidget: null },
|
|
37
|
+
commands
|
|
38
|
+
} as unknown as JupyterFrontEnd;
|
|
39
|
+
return new DatameshConnectWidget({
|
|
40
|
+
app,
|
|
41
|
+
name: 'Datamesh Connect',
|
|
42
|
+
icon,
|
|
43
|
+
openDatameshUI: (): void => undefined,
|
|
44
|
+
datameshUiUrl: () => 'https://ui.datamesh.example.com',
|
|
45
|
+
datameshUiFrame: (): Window | null => null,
|
|
46
|
+
datameshToken: () => options.token ?? '',
|
|
47
|
+
aiBackendUrl: () => 'https://ai.example.com',
|
|
48
|
+
settingsChanged: new Signal<unknown, void>({}),
|
|
49
|
+
commands,
|
|
50
|
+
getCurrentWidget: () => new Widget(),
|
|
51
|
+
auth: null
|
|
52
|
+
} as unknown as ConstructorParameters<typeof DatameshConnectWidget>[0]);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function mount(
|
|
56
|
+
options: IPanelOptions = {}
|
|
57
|
+
): Promise<DatameshConnectWidget> {
|
|
58
|
+
const widget = panel(options);
|
|
59
|
+
Widget.attach(widget, document.body);
|
|
60
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
61
|
+
return widget;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The panel used to render the Datamesh workspace and the AI chat one above the other.
|
|
66
|
+
* They are now tabs. `DatameshWidget.spec.tsx` covers the AI chat's behaviour but never
|
|
67
|
+
* touches the Datamesh surface, so nothing else here would notice if rearranging the
|
|
68
|
+
* two lost one of them.
|
|
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
|
+
|
|
83
|
+
describe('the Oceanum panel', () => {
|
|
84
|
+
it('offers no Oceanum AI tab when the chat has no credential', async () => {
|
|
85
|
+
// AIChatPanel renders nothing without one, so a labelled tab would open on the
|
|
86
|
+
// same emptiness the panel showed before there were tabs, but now advertised.
|
|
87
|
+
const widget = await mount();
|
|
88
|
+
const labels = Array.from(widget.node.querySelectorAll('[role="tab"]')).map(
|
|
89
|
+
node => node.textContent
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
expect(labels).toEqual(['Notebooks', 'Datamesh']);
|
|
93
|
+
expect(widget.node.querySelector('#oceanum-tabpanel-ai')).toBeNull();
|
|
94
|
+
|
|
95
|
+
widget.dispose();
|
|
96
|
+
});
|
|
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
|
+
|
|
120
|
+
it('still renders the Datamesh workspace section', async () => {
|
|
121
|
+
const widget = await mount();
|
|
122
|
+
|
|
123
|
+
expect(
|
|
124
|
+
widget.node.querySelector('.datamesh-workspace-header')
|
|
125
|
+
).not.toBeNull();
|
|
126
|
+
expect(
|
|
127
|
+
widget.node.querySelector('.datamesh-connect-workspace')
|
|
128
|
+
).not.toBeNull();
|
|
129
|
+
expect(widget.node.textContent).toContain('Datamesh Workspace');
|
|
130
|
+
|
|
131
|
+
widget.dispose();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('opens on Notebooks, and announces a change so it can be persisted', async () => {
|
|
135
|
+
const widget = await mount();
|
|
136
|
+
const seen: string[] = [];
|
|
137
|
+
widget.tabChanged.connect((_, id) => seen.push(id));
|
|
138
|
+
|
|
139
|
+
expect(widget.selectedTab).toBe('notebooks');
|
|
140
|
+
widget.selectTab('datamesh');
|
|
141
|
+
expect(widget.selectedTab).toBe('datamesh');
|
|
142
|
+
expect(seen).toEqual(['datamesh']);
|
|
143
|
+
|
|
144
|
+
// Selecting the same tab again is not a change.
|
|
145
|
+
widget.selectTab('datamesh');
|
|
146
|
+
expect(seen).toEqual(['datamesh']);
|
|
147
|
+
|
|
148
|
+
widget.dispose();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('explains the Notebooks tab rather than failing when sign-in is unavailable', async () => {
|
|
152
|
+
// auth is null on a host with no Oceanum environment.
|
|
153
|
+
const widget = await mount();
|
|
154
|
+
expect(widget.node.textContent).toContain('sign-in is not configured');
|
|
155
|
+
widget.dispose();
|
|
156
|
+
});
|
|
157
|
+
});
|