@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.
Files changed (45) hide show
  1. package/lib/DatameshWidget.js +14 -5
  2. package/lib/StoredNotebooks.d.ts +9 -5
  3. package/lib/StoredNotebooks.js +49 -21
  4. package/lib/auth/serverAuth.js +1 -0
  5. package/lib/auth/startSignIn.d.ts +11 -0
  6. package/lib/auth/startSignIn.js +16 -0
  7. package/lib/auth/tokens.d.ts +7 -0
  8. package/lib/index.d.ts +1 -1
  9. package/lib/index.js +8 -6
  10. package/lib/share/client.d.ts +84 -0
  11. package/lib/share/client.js +205 -0
  12. package/lib/share/events.d.ts +7 -0
  13. package/lib/share/events.js +9 -0
  14. package/lib/share/notebook.d.ts +184 -0
  15. package/lib/share/notebook.js +277 -0
  16. package/lib/share/oceanumOnly.d.ts +49 -0
  17. package/lib/share/oceanumOnly.js +90 -0
  18. package/lib/share/plugin.d.ts +21 -0
  19. package/lib/share/plugin.js +883 -0
  20. package/lib/share/widgets.d.ts +25 -0
  21. package/lib/share/widgets.js +154 -0
  22. package/package.json +5 -1
  23. package/src/DatameshWidget.tsx +21 -2
  24. package/src/StoredNotebooks.tsx +60 -20
  25. package/src/__tests__/OceanumPanel.spec.tsx +60 -4
  26. package/src/__tests__/StoredNotebooks.spec.tsx +157 -0
  27. package/src/__tests__/oceanumOnly.spec.ts +193 -0
  28. package/src/__tests__/shareClient.spec.ts +314 -0
  29. package/src/__tests__/shareNotebook.spec.ts +451 -0
  30. package/src/__tests__/sharePlugin.spec.ts +426 -0
  31. package/src/auth/serverAuth.ts +1 -0
  32. package/src/auth/startSignIn.ts +23 -0
  33. package/src/auth/tokens.ts +7 -0
  34. package/src/index.ts +8 -6
  35. package/src/share/client.ts +288 -0
  36. package/src/share/events.ts +10 -0
  37. package/src/share/notebook.ts +416 -0
  38. package/src/share/oceanumOnly.ts +126 -0
  39. package/src/share/plugin.ts +1096 -0
  40. package/src/share/widgets.ts +217 -0
  41. package/style/index.css +111 -16
  42. package/lib/specStore.d.ts +0 -37
  43. package/lib/specStore.js +0 -57
  44. package/src/__tests__/specStore.spec.ts +0 -126
  45. 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.1",
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",
@@ -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 auth={auth} />
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
- if (access.source) {
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
  />
@@ -1,16 +1,23 @@
1
1
  import * as React from 'react';
2
2
 
3
3
  import { IOceanumAuth } from './auth/tokens';
4
- import { ISpecSummary, listNotebooks, partitionNotebooks } from './specStore';
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, 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
+ * 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
- title={item.description ?? undefined}
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
- title={item.description ?? undefined}
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 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);
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
- Sign in to Oceanum.io to see your notebooks.
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 } = partitionNotebooks(load.items, user.email);
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
- function panel(): DatameshConnectWidget {
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(): Promise<DatameshConnectWidget> {
37
- const widget = panel();
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