@oceanum/oceanumlab 4.7.0 → 4.7.1
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 +70 -11
- package/lib/StoredNotebooks.d.ts +16 -0
- package/lib/StoredNotebooks.js +84 -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 +253 -0
- package/lib/auth/tokens.d.ts +95 -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 +56 -17
- package/lib/specStore.d.ts +37 -0
- package/lib/specStore.js +57 -0
- package/package.json +9 -1
- package/schema/datamesh-connect.json +3 -12
- package/src/DatameshUI.tsx +1 -10
- package/src/DatameshWidget.tsx +124 -19
- package/src/StoredNotebooks.tsx +166 -0
- package/src/Tabs.tsx +102 -0
- package/src/__tests__/DatameshWidget.spec.tsx +33 -20
- package/src/__tests__/OceanumPanel.spec.tsx +101 -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__/pluginArity.spec.ts +24 -0
- package/src/__tests__/serverAuth.spec.ts +277 -0
- package/src/__tests__/settingsSchema.spec.ts +73 -0
- package/src/__tests__/specStore.spec.ts +126 -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 +341 -0
- package/src/auth/tokens.ts +104 -0
- package/src/chatRouter.ts +8 -4
- package/src/constants.ts +1 -1
- package/src/datameshUiUrl.ts +6 -6
- package/src/index.ts +85 -36
- package/src/specStore.ts +82 -0
- package/style/index.css +158 -0
package/src/DatameshWidget.tsx
CHANGED
|
@@ -21,6 +21,9 @@ import React from 'react';
|
|
|
21
21
|
import { marked } from 'marked';
|
|
22
22
|
|
|
23
23
|
import { DatasourceItem } from './DatasourceItem';
|
|
24
|
+
import { IOceanumAuth } from './auth/tokens';
|
|
25
|
+
import { StoredNotebooks } from './StoredNotebooks';
|
|
26
|
+
import { ITab, Tabs } from './Tabs';
|
|
24
27
|
import {
|
|
25
28
|
SIGN_IN_COMMAND,
|
|
26
29
|
aiBackendUrl,
|
|
@@ -476,7 +479,7 @@ interface IAiSettings {
|
|
|
476
479
|
* Not `window.datameshToken`, which keeps a token after it is cleared.
|
|
477
480
|
*/
|
|
478
481
|
datameshToken: () => string;
|
|
479
|
-
/** The
|
|
482
|
+
/** The deployment's Oceanum AI address. */
|
|
480
483
|
aiBackendUrl: () => string;
|
|
481
484
|
/** Emitted when the settings change. */
|
|
482
485
|
settingsChanged: ISignal<unknown, void>;
|
|
@@ -493,7 +496,7 @@ interface IAiAccess {
|
|
|
493
496
|
pasted: string;
|
|
494
497
|
/** Whether the host can sign the user in to Oceanum.io on this site. */
|
|
495
498
|
canSignIn: boolean;
|
|
496
|
-
/** The backend address
|
|
499
|
+
/** The backend address: the deployment's Oceanum AI. */
|
|
497
500
|
backend: string;
|
|
498
501
|
}
|
|
499
502
|
|
|
@@ -583,8 +586,15 @@ function TokenConfigMessage({
|
|
|
583
586
|
|
|
584
587
|
return (
|
|
585
588
|
<div className="oceanum-token-config">
|
|
586
|
-
Set your
|
|
587
|
-
|
|
589
|
+
Set your{' '}
|
|
590
|
+
<a
|
|
591
|
+
onClick={() =>
|
|
592
|
+
commands.execute('settingeditor:open', { query: 'Oceanum' })
|
|
593
|
+
}
|
|
594
|
+
>
|
|
595
|
+
Datamesh token
|
|
596
|
+
</a>{' '}
|
|
597
|
+
to enable Oceanum.io services{' '}
|
|
588
598
|
<a
|
|
589
599
|
href="https://home.oceanum.io/account"
|
|
590
600
|
target="_blank"
|
|
@@ -935,18 +945,74 @@ export interface IDatameshWidgetProps {
|
|
|
935
945
|
name: string;
|
|
936
946
|
icon: LabIcon;
|
|
937
947
|
openDatameshUI: any;
|
|
938
|
-
/** The
|
|
948
|
+
/** The deployment's Datamesh UI address. */
|
|
939
949
|
datameshUiUrl: () => string;
|
|
940
950
|
/** The Datamesh UI panel's iframe window; `null` when the panel is closed. */
|
|
941
951
|
datameshUiFrame: () => Window | null;
|
|
942
952
|
/** The `datameshToken` setting. */
|
|
943
953
|
datameshToken: () => string;
|
|
944
|
-
/** The
|
|
954
|
+
/** The deployment's Oceanum AI address. */
|
|
945
955
|
aiBackendUrl: () => string;
|
|
946
956
|
/** Emitted when the settings change. */
|
|
947
957
|
settingsChanged: ISignal<unknown, void>;
|
|
948
958
|
commands: CommandRegistry;
|
|
949
959
|
getCurrentWidget: () => Widget;
|
|
960
|
+
/** Oceanum.io sign-in, or null on a host with no Oceanum environment. */
|
|
961
|
+
auth?: IOceanumAuth | null;
|
|
962
|
+
/** Open a stored notebook by spec store id. */
|
|
963
|
+
openStoredNotebook?: (id: string) => void;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/**
|
|
967
|
+
* The panel's tabs.
|
|
968
|
+
*
|
|
969
|
+
* A component rather than a list built in `render`, because whether Oceanum AI is
|
|
970
|
+
* reachable is not static: `useAiAccess` re-reads it when the settings change and when
|
|
971
|
+
* the host signals a sign-in or sign-out. An AI tab is only offered when the chat has a
|
|
972
|
+
* credential to work with — otherwise it would open on the same nothing the panel
|
|
973
|
+
* rendered before there were tabs, except now with a label promising otherwise.
|
|
974
|
+
*/
|
|
975
|
+
function PanelTabs({
|
|
976
|
+
auth,
|
|
977
|
+
commands,
|
|
978
|
+
settings,
|
|
979
|
+
renderDatamesh,
|
|
980
|
+
selected,
|
|
981
|
+
onSelect
|
|
982
|
+
}: {
|
|
983
|
+
auth: IOceanumAuth | null;
|
|
984
|
+
commands: CommandRegistry;
|
|
985
|
+
settings: IAiSettings;
|
|
986
|
+
renderDatamesh: () => React.ReactElement;
|
|
987
|
+
selected: string;
|
|
988
|
+
onSelect: (id: string) => void;
|
|
989
|
+
}): React.ReactElement {
|
|
990
|
+
const access = useAiAccess(commands, settings);
|
|
991
|
+
const tabs: ITab[] = [
|
|
992
|
+
{
|
|
993
|
+
id: 'notebooks',
|
|
994
|
+
label: 'Notebooks',
|
|
995
|
+
render: () =>
|
|
996
|
+
// An auth with no environment is a provider that has nothing to sign in to (the
|
|
997
|
+
// server has sign-in turned off). Asking the user to sign in would be a dead end.
|
|
998
|
+
auth?.environment ? (
|
|
999
|
+
<StoredNotebooks auth={auth} />
|
|
1000
|
+
) : (
|
|
1001
|
+
<div className="oceanum-text-empty">
|
|
1002
|
+
Oceanum.io sign-in is not configured for this host.
|
|
1003
|
+
</div>
|
|
1004
|
+
)
|
|
1005
|
+
},
|
|
1006
|
+
{ id: 'datamesh', label: 'Datamesh', render: renderDatamesh }
|
|
1007
|
+
];
|
|
1008
|
+
if (access.source) {
|
|
1009
|
+
tabs.push({
|
|
1010
|
+
id: 'ai',
|
|
1011
|
+
label: 'Oceanum AI',
|
|
1012
|
+
render: () => <AIChatPanel commands={commands} settings={settings} />
|
|
1013
|
+
});
|
|
1014
|
+
}
|
|
1015
|
+
return <Tabs tabs={tabs} selected={selected} onSelect={onSelect} />;
|
|
950
1016
|
}
|
|
951
1017
|
|
|
952
1018
|
/**
|
|
@@ -1014,18 +1080,10 @@ export class DatameshConnectWidget extends ReactWidget {
|
|
|
1014
1080
|
);
|
|
1015
1081
|
}
|
|
1016
1082
|
|
|
1017
|
-
|
|
1083
|
+
/** The Datamesh workspace tab: what this panel showed above the divider. */
|
|
1084
|
+
renderDatamesh(): React.ReactElement {
|
|
1018
1085
|
return (
|
|
1019
|
-
|
|
1020
|
-
<header className="oceanum-sidebar-header">
|
|
1021
|
-
<this.props.icon.react
|
|
1022
|
-
tag="span"
|
|
1023
|
-
width="auto"
|
|
1024
|
-
height="20px"
|
|
1025
|
-
verticalAlign="middle"
|
|
1026
|
-
/>
|
|
1027
|
-
<span className="oceanum-sidebar-title">Oceanum.io</span>
|
|
1028
|
-
</header>
|
|
1086
|
+
<>
|
|
1029
1087
|
<div className="datamesh-workspace-header">
|
|
1030
1088
|
<span>Datamesh Workspace</span>
|
|
1031
1089
|
<div
|
|
@@ -1047,9 +1105,56 @@ export class DatameshConnectWidget extends ReactWidget {
|
|
|
1047
1105
|
settings={this.props}
|
|
1048
1106
|
/>
|
|
1049
1107
|
</div>
|
|
1050
|
-
|
|
1051
|
-
|
|
1108
|
+
</>
|
|
1109
|
+
);
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
render(): React.ReactElement {
|
|
1113
|
+
return (
|
|
1114
|
+
<div className="datamesh-connect">
|
|
1115
|
+
<header className="oceanum-sidebar-header">
|
|
1116
|
+
<this.props.icon.react
|
|
1117
|
+
tag="span"
|
|
1118
|
+
width="auto"
|
|
1119
|
+
height="20px"
|
|
1120
|
+
verticalAlign="middle"
|
|
1121
|
+
/>
|
|
1122
|
+
<span className="oceanum-sidebar-title">Oceanum.io</span>
|
|
1123
|
+
</header>
|
|
1124
|
+
<UseSignal signal={this.tabChanged} initialArgs={this.selectedTab}>
|
|
1125
|
+
{(): React.ReactElement => (
|
|
1126
|
+
<PanelTabs
|
|
1127
|
+
auth={this.props.auth ?? null}
|
|
1128
|
+
commands={this.props.commands}
|
|
1129
|
+
settings={this.props}
|
|
1130
|
+
renderDatamesh={() => this.renderDatamesh()}
|
|
1131
|
+
selected={this.selectedTab}
|
|
1132
|
+
onSelect={id => this.selectTab(id)}
|
|
1133
|
+
/>
|
|
1134
|
+
)}
|
|
1135
|
+
</UseSignal>
|
|
1052
1136
|
</div>
|
|
1053
1137
|
);
|
|
1054
1138
|
}
|
|
1139
|
+
|
|
1140
|
+
/**
|
|
1141
|
+
* The visible tab. The widget only holds it and announces changes through
|
|
1142
|
+
* `tabChanged`; the plugin persists it in IStateDB, because the layout restorer
|
|
1143
|
+
* restores a widget's place in the shell rather than fields on it.
|
|
1144
|
+
*/
|
|
1145
|
+
get selectedTab(): string {
|
|
1146
|
+
return this._selectedTab;
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
selectTab(id: string): void {
|
|
1150
|
+
if (id !== this._selectedTab) {
|
|
1151
|
+
this._selectedTab = id;
|
|
1152
|
+
this.tabChanged.emit(id);
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
readonly tabChanged = new Signal<this, string>(this);
|
|
1157
|
+
// Notebooks first: on notebook.oceanum.io this panel is how a user reaches their
|
|
1158
|
+
// work, so it opens there rather than on Datamesh.
|
|
1159
|
+
private _selectedTab = 'notebooks';
|
|
1055
1160
|
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
|
|
3
|
+
import { IOceanumAuth } from './auth/tokens';
|
|
4
|
+
import { ISpecSummary, listNotebooks, partitionNotebooks } from './specStore';
|
|
5
|
+
|
|
6
|
+
export interface IStoredNotebooksProps {
|
|
7
|
+
auth: IOceanumAuth;
|
|
8
|
+
/**
|
|
9
|
+
* Open a stored notebook, when this host can. Undefined where nothing has registered
|
|
10
|
+
* a way to open one — today that is native JupyterLab, which has no spec store drive
|
|
11
|
+
* until OCE-182. The list then reads rather than offering an action that does nothing.
|
|
12
|
+
*/
|
|
13
|
+
onOpen?: (item: ISpecSummary) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type Load =
|
|
17
|
+
| { state: 'loading' }
|
|
18
|
+
| { state: 'error'; message: string }
|
|
19
|
+
| { state: 'ready'; items: ISpecSummary[] };
|
|
20
|
+
|
|
21
|
+
function Section({
|
|
22
|
+
title,
|
|
23
|
+
items,
|
|
24
|
+
empty,
|
|
25
|
+
onOpen
|
|
26
|
+
}: {
|
|
27
|
+
title: string;
|
|
28
|
+
items: readonly ISpecSummary[];
|
|
29
|
+
empty: string;
|
|
30
|
+
onOpen?: (item: ISpecSummary) => void;
|
|
31
|
+
}): React.ReactElement {
|
|
32
|
+
return (
|
|
33
|
+
<div className="oceanum-notebooks-section">
|
|
34
|
+
<div className="oceanum-notebooks-heading">
|
|
35
|
+
{title}
|
|
36
|
+
<span className="oceanum-notebooks-count">{items.length}</span>
|
|
37
|
+
</div>
|
|
38
|
+
{items.length === 0 ? (
|
|
39
|
+
<div className="oceanum-text-empty">{empty}</div>
|
|
40
|
+
) : (
|
|
41
|
+
<ul className="oceanum-notebooks-list">
|
|
42
|
+
{items.map(item => (
|
|
43
|
+
<li key={item.id}>
|
|
44
|
+
{/* Names come from other users: React escapes these, never set as HTML. */}
|
|
45
|
+
{onOpen ? (
|
|
46
|
+
<button
|
|
47
|
+
type="button"
|
|
48
|
+
className="oceanum-notebooks-item"
|
|
49
|
+
onClick={() => onOpen(item)}
|
|
50
|
+
title={item.description ?? undefined}
|
|
51
|
+
>
|
|
52
|
+
<span className="oceanum-notebooks-name">
|
|
53
|
+
{item.name || 'Untitled'}
|
|
54
|
+
</span>
|
|
55
|
+
</button>
|
|
56
|
+
) : (
|
|
57
|
+
<div
|
|
58
|
+
className="oceanum-notebooks-item oceanum-notebooks-item-static"
|
|
59
|
+
title={item.description ?? undefined}
|
|
60
|
+
>
|
|
61
|
+
<span className="oceanum-notebooks-name">
|
|
62
|
+
{item.name || 'Untitled'}
|
|
63
|
+
</span>
|
|
64
|
+
</div>
|
|
65
|
+
)}
|
|
66
|
+
</li>
|
|
67
|
+
))}
|
|
68
|
+
</ul>
|
|
69
|
+
)}
|
|
70
|
+
</div>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The notebooks this user has stored on Oceanum.io, theirs and those shared with them.
|
|
76
|
+
*/
|
|
77
|
+
export function StoredNotebooks({
|
|
78
|
+
auth,
|
|
79
|
+
onOpen
|
|
80
|
+
}: IStoredNotebooksProps): React.ReactElement {
|
|
81
|
+
const [load, setLoad] = React.useState<Load>({ state: 'loading' });
|
|
82
|
+
const [user, setUser] = React.useState(auth.user);
|
|
83
|
+
|
|
84
|
+
// 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.
|
|
86
|
+
React.useEffect(() => {
|
|
87
|
+
const onUser = (): void => setUser(auth.user);
|
|
88
|
+
auth.userChanged.connect(onUser);
|
|
89
|
+
return () => {
|
|
90
|
+
auth.userChanged.disconnect(onUser);
|
|
91
|
+
};
|
|
92
|
+
}, [auth]);
|
|
93
|
+
|
|
94
|
+
React.useEffect(() => {
|
|
95
|
+
let cancelled = false;
|
|
96
|
+
const specs = auth.urls?.specs ?? null;
|
|
97
|
+
if (!user || !specs) {
|
|
98
|
+
setLoad({ state: 'ready', items: [] });
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
setLoad({ state: 'loading' });
|
|
102
|
+
void (async () => {
|
|
103
|
+
try {
|
|
104
|
+
const token = await auth.getAccessToken();
|
|
105
|
+
if (cancelled) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (!token) {
|
|
109
|
+
setLoad({ state: 'ready', items: [] });
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const items = await listNotebooks(specs, token);
|
|
113
|
+
if (!cancelled) {
|
|
114
|
+
setLoad({ state: 'ready', items });
|
|
115
|
+
}
|
|
116
|
+
} catch (error) {
|
|
117
|
+
if (!cancelled) {
|
|
118
|
+
setLoad({
|
|
119
|
+
state: 'error',
|
|
120
|
+
message:
|
|
121
|
+
error instanceof Error
|
|
122
|
+
? error.message
|
|
123
|
+
: 'Could not reach Oceanum.io.'
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
})();
|
|
128
|
+
return () => {
|
|
129
|
+
cancelled = true;
|
|
130
|
+
};
|
|
131
|
+
}, [auth, user]);
|
|
132
|
+
|
|
133
|
+
if (!user) {
|
|
134
|
+
return (
|
|
135
|
+
<div className="oceanum-text-empty">
|
|
136
|
+
Sign in to Oceanum.io to see your notebooks.
|
|
137
|
+
</div>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
if (load.state === 'loading') {
|
|
141
|
+
return <div className="oceanum-text-empty">Loading…</div>;
|
|
142
|
+
}
|
|
143
|
+
if (load.state === 'error') {
|
|
144
|
+
// Never render the sections alongside an error: their empty messages would claim
|
|
145
|
+
// the account has no notebooks when the load simply failed.
|
|
146
|
+
return <div className="oceanum-text-error">{load.message}</div>;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const { mine, shared } = partitionNotebooks(load.items, user.email);
|
|
150
|
+
return (
|
|
151
|
+
<div className="oceanum-notebooks">
|
|
152
|
+
<Section
|
|
153
|
+
title="My notebooks"
|
|
154
|
+
items={mine}
|
|
155
|
+
empty="You have not saved any notebooks yet."
|
|
156
|
+
onOpen={onOpen}
|
|
157
|
+
/>
|
|
158
|
+
<Section
|
|
159
|
+
title="Shared with me"
|
|
160
|
+
items={shared}
|
|
161
|
+
empty="No notebooks have been shared with you."
|
|
162
|
+
onOpen={onOpen}
|
|
163
|
+
/>
|
|
164
|
+
</div>
|
|
165
|
+
);
|
|
166
|
+
}
|
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,101 @@
|
|
|
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
|
+
function panel(): DatameshConnectWidget {
|
|
15
|
+
const commands = new CommandRegistry();
|
|
16
|
+
const app = {
|
|
17
|
+
shell: { currentWidget: null },
|
|
18
|
+
commands
|
|
19
|
+
} as unknown as JupyterFrontEnd;
|
|
20
|
+
return new DatameshConnectWidget({
|
|
21
|
+
app,
|
|
22
|
+
name: 'Datamesh Connect',
|
|
23
|
+
icon,
|
|
24
|
+
openDatameshUI: (): void => undefined,
|
|
25
|
+
datameshUiUrl: () => 'https://ui.datamesh.example.com',
|
|
26
|
+
datameshUiFrame: (): Window | null => null,
|
|
27
|
+
datameshToken: () => '',
|
|
28
|
+
aiBackendUrl: () => 'https://ai.example.com',
|
|
29
|
+
settingsChanged: new Signal<unknown, void>({}),
|
|
30
|
+
commands,
|
|
31
|
+
getCurrentWidget: () => new Widget(),
|
|
32
|
+
auth: null
|
|
33
|
+
} as unknown as ConstructorParameters<typeof DatameshConnectWidget>[0]);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function mount(): Promise<DatameshConnectWidget> {
|
|
37
|
+
const widget = panel();
|
|
38
|
+
Widget.attach(widget, document.body);
|
|
39
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
40
|
+
return widget;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The panel used to render the Datamesh workspace and the AI chat one above the other.
|
|
45
|
+
* They are now tabs. `DatameshWidget.spec.tsx` covers the AI chat's behaviour but never
|
|
46
|
+
* touches the Datamesh surface, so nothing else here would notice if rearranging the
|
|
47
|
+
* two lost one of them.
|
|
48
|
+
*/
|
|
49
|
+
describe('the Oceanum panel', () => {
|
|
50
|
+
it('offers no Oceanum AI tab when the chat has no credential', async () => {
|
|
51
|
+
// AIChatPanel renders nothing without one, so a labelled tab would open on the
|
|
52
|
+
// same emptiness the panel showed before there were tabs, but now advertised.
|
|
53
|
+
const widget = await mount();
|
|
54
|
+
const labels = Array.from(widget.node.querySelectorAll('[role="tab"]')).map(
|
|
55
|
+
node => node.textContent
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
expect(labels).toEqual(['Notebooks', 'Datamesh']);
|
|
59
|
+
expect(widget.node.querySelector('#oceanum-tabpanel-ai')).toBeNull();
|
|
60
|
+
|
|
61
|
+
widget.dispose();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('still renders the Datamesh workspace section', async () => {
|
|
65
|
+
const widget = await mount();
|
|
66
|
+
|
|
67
|
+
expect(
|
|
68
|
+
widget.node.querySelector('.datamesh-workspace-header')
|
|
69
|
+
).not.toBeNull();
|
|
70
|
+
expect(
|
|
71
|
+
widget.node.querySelector('.datamesh-connect-workspace')
|
|
72
|
+
).not.toBeNull();
|
|
73
|
+
expect(widget.node.textContent).toContain('Datamesh Workspace');
|
|
74
|
+
|
|
75
|
+
widget.dispose();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('opens on Notebooks, and announces a change so it can be persisted', async () => {
|
|
79
|
+
const widget = await mount();
|
|
80
|
+
const seen: string[] = [];
|
|
81
|
+
widget.tabChanged.connect((_, id) => seen.push(id));
|
|
82
|
+
|
|
83
|
+
expect(widget.selectedTab).toBe('notebooks');
|
|
84
|
+
widget.selectTab('datamesh');
|
|
85
|
+
expect(widget.selectedTab).toBe('datamesh');
|
|
86
|
+
expect(seen).toEqual(['datamesh']);
|
|
87
|
+
|
|
88
|
+
// Selecting the same tab again is not a change.
|
|
89
|
+
widget.selectTab('datamesh');
|
|
90
|
+
expect(seen).toEqual(['datamesh']);
|
|
91
|
+
|
|
92
|
+
widget.dispose();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('explains the Notebooks tab rather than failing when sign-in is unavailable', async () => {
|
|
96
|
+
// auth is null on a host with no Oceanum environment.
|
|
97
|
+
const widget = await mount();
|
|
98
|
+
expect(widget.node.textContent).toContain('sign-in is not configured');
|
|
99
|
+
widget.dispose();
|
|
100
|
+
});
|
|
101
|
+
});
|