@jupyterlab/apputils-extension 4.0.0-alpha.2 → 4.0.0-alpha.21
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/announcements.d.ts +2 -0
- package/lib/announcements.js +227 -0
- package/lib/announcements.js.map +1 -0
- package/lib/index.js +41 -8
- package/lib/index.js.map +1 -1
- package/lib/notificationplugin.d.ts +5 -0
- package/lib/notificationplugin.js +504 -0
- package/lib/notificationplugin.js.map +1 -0
- package/lib/palette.js +3 -3
- package/lib/palette.js.map +1 -1
- package/lib/settingconnector.js +4 -0
- package/lib/settingconnector.js.map +1 -1
- package/lib/settingsplugin.js +8 -2
- package/lib/settingsplugin.js.map +1 -1
- package/lib/statusbarplugin.d.ts +7 -0
- package/lib/statusbarplugin.js +96 -0
- package/lib/statusbarplugin.js.map +1 -0
- package/lib/themesplugins.js +3 -0
- package/lib/themesplugins.js.map +1 -1
- package/lib/toolbarregistryplugin.js +5 -1
- package/lib/toolbarregistryplugin.js.map +1 -1
- package/lib/workspacesplugin.js +6 -6
- package/lib/workspacesplugin.js.map +1 -1
- package/package.json +27 -22
- package/schema/notification.json +48 -0
- package/schema/palette.json +2 -0
- package/schema/sanitizer.json +25 -0
- package/src/announcements.ts +297 -0
- package/src/index.ts +684 -0
- package/src/notificationplugin.tsx +902 -0
- package/src/palette.ts +213 -0
- package/src/settingconnector.ts +63 -0
- package/src/settingsplugin.ts +65 -0
- package/src/statusbarplugin.ts +145 -0
- package/src/themesplugins.ts +266 -0
- package/src/toolbarregistryplugin.ts +29 -0
- package/src/workspacesplugin.ts +306 -0
- package/style/base.css +1 -1
- package/style/index.css +1 -0
- package/style/index.js +1 -0
- package/style/notification.css +227 -0
- package/style/splash.css +4 -0
- package/style/redirect.css +0 -15
|
@@ -0,0 +1,902 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) Jupyter Development Team.
|
|
3
|
+
* Distributed under the terms of the Modified BSD License.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
JupyterFrontEnd,
|
|
8
|
+
JupyterFrontEndPlugin
|
|
9
|
+
} from '@jupyterlab/application';
|
|
10
|
+
import {
|
|
11
|
+
Notification,
|
|
12
|
+
NotificationManager,
|
|
13
|
+
ReactWidget
|
|
14
|
+
} from '@jupyterlab/apputils';
|
|
15
|
+
import { ISettingRegistry } from '@jupyterlab/settingregistry';
|
|
16
|
+
import {
|
|
17
|
+
GroupItem,
|
|
18
|
+
IStatusBar,
|
|
19
|
+
Popup,
|
|
20
|
+
showPopup,
|
|
21
|
+
TextItem
|
|
22
|
+
} from '@jupyterlab/statusbar';
|
|
23
|
+
import {
|
|
24
|
+
ITranslator,
|
|
25
|
+
nullTranslator,
|
|
26
|
+
TranslationBundle
|
|
27
|
+
} from '@jupyterlab/translation';
|
|
28
|
+
import {
|
|
29
|
+
bellIcon,
|
|
30
|
+
Button,
|
|
31
|
+
closeIcon,
|
|
32
|
+
deleteIcon,
|
|
33
|
+
ToolbarButtonComponent,
|
|
34
|
+
UseSignal,
|
|
35
|
+
VDomModel
|
|
36
|
+
} from '@jupyterlab/ui-components';
|
|
37
|
+
import {
|
|
38
|
+
PromiseDelegate,
|
|
39
|
+
ReadonlyJSONObject,
|
|
40
|
+
ReadonlyJSONValue
|
|
41
|
+
} from '@lumino/coreutils';
|
|
42
|
+
import * as React from 'react';
|
|
43
|
+
import { createRoot } from 'react-dom/client';
|
|
44
|
+
import type {
|
|
45
|
+
ClearWaitingQueueParams,
|
|
46
|
+
CloseButtonProps,
|
|
47
|
+
Icons,
|
|
48
|
+
Id,
|
|
49
|
+
default as ReactToastify,
|
|
50
|
+
ToastContent,
|
|
51
|
+
ToastItem,
|
|
52
|
+
ToastOptions,
|
|
53
|
+
UpdateOptions
|
|
54
|
+
} from 'react-toastify';
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Toast close button class
|
|
58
|
+
*/
|
|
59
|
+
const TOAST_CLOSE_BUTTON_CLASS = 'jp-Notification-Toast-Close';
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Maximal number of characters displayed in a notification.
|
|
63
|
+
*/
|
|
64
|
+
const MAX_MESSAGE_LENGTH = 140;
|
|
65
|
+
|
|
66
|
+
namespace CommandIDs {
|
|
67
|
+
/**
|
|
68
|
+
* Dismiss a notification
|
|
69
|
+
*/
|
|
70
|
+
export const dismiss = 'apputils:dismiss-notification';
|
|
71
|
+
/**
|
|
72
|
+
* Display all notifications
|
|
73
|
+
*/
|
|
74
|
+
export const display = 'apputils:display-notifications';
|
|
75
|
+
/**
|
|
76
|
+
* Create a notification
|
|
77
|
+
*/
|
|
78
|
+
export const notify = 'apputils:notify';
|
|
79
|
+
/**
|
|
80
|
+
* Update a notification
|
|
81
|
+
*/
|
|
82
|
+
export const update = 'apputils:update-notification';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Half spacing between subitems in a status item.
|
|
87
|
+
*/
|
|
88
|
+
const HALF_SPACING = 4;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Notification center properties
|
|
92
|
+
*/
|
|
93
|
+
interface INotificationCenterProps {
|
|
94
|
+
/**
|
|
95
|
+
* Notification manager
|
|
96
|
+
*/
|
|
97
|
+
manager: NotificationManager;
|
|
98
|
+
/**
|
|
99
|
+
* Close notification handler
|
|
100
|
+
*/
|
|
101
|
+
onClose: () => void;
|
|
102
|
+
/**
|
|
103
|
+
* Translation object
|
|
104
|
+
*/
|
|
105
|
+
trans: TranslationBundle;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Notification center view
|
|
110
|
+
*/
|
|
111
|
+
function NotificationCenter(props: INotificationCenterProps): JSX.Element {
|
|
112
|
+
const { manager, onClose, trans } = props;
|
|
113
|
+
|
|
114
|
+
// Markdown parsed notifications
|
|
115
|
+
const [notifications, setNotifications] = React.useState<
|
|
116
|
+
Notification.INotification[]
|
|
117
|
+
>([]);
|
|
118
|
+
// Load asynchronously react-toastify icons
|
|
119
|
+
const [icons, setIcons] = React.useState<typeof Icons | null>(null);
|
|
120
|
+
|
|
121
|
+
React.useEffect(() => {
|
|
122
|
+
async function onChanged(): Promise<void> {
|
|
123
|
+
setNotifications(
|
|
124
|
+
await Promise.all(
|
|
125
|
+
manager.notifications.map(async n => {
|
|
126
|
+
return Object.freeze({
|
|
127
|
+
...n
|
|
128
|
+
});
|
|
129
|
+
})
|
|
130
|
+
)
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (notifications.length !== manager.count) {
|
|
135
|
+
void onChanged();
|
|
136
|
+
}
|
|
137
|
+
manager.changed.connect(onChanged);
|
|
138
|
+
|
|
139
|
+
return () => {
|
|
140
|
+
manager.changed.disconnect(onChanged);
|
|
141
|
+
};
|
|
142
|
+
}, [manager]);
|
|
143
|
+
React.useEffect(() => {
|
|
144
|
+
Private.getIcons()
|
|
145
|
+
.then(toastifyIcons => {
|
|
146
|
+
setIcons(toastifyIcons);
|
|
147
|
+
})
|
|
148
|
+
.catch(r => {
|
|
149
|
+
console.error(`Failed to get react-toastify icons:\n${r}`);
|
|
150
|
+
});
|
|
151
|
+
}, []);
|
|
152
|
+
|
|
153
|
+
return (
|
|
154
|
+
<UseSignal signal={manager.changed}>
|
|
155
|
+
{() => (
|
|
156
|
+
<>
|
|
157
|
+
<h2 className="jp-Notification-Header jp-Toolbar">
|
|
158
|
+
<span className="jp-Toolbar-item">
|
|
159
|
+
{manager.count > 0
|
|
160
|
+
? trans._n('%1 notification', '%1 notifications', manager.count)
|
|
161
|
+
: trans.__('No notifications')}
|
|
162
|
+
</span>
|
|
163
|
+
<span className="jp-Toolbar-item jp-Toolbar-spacer"></span>
|
|
164
|
+
<ToolbarButtonComponent
|
|
165
|
+
actualOnClick={true}
|
|
166
|
+
onClick={() => {
|
|
167
|
+
manager.dismiss();
|
|
168
|
+
}}
|
|
169
|
+
icon={deleteIcon}
|
|
170
|
+
tooltip={trans.__('Dismiss all notifications')}
|
|
171
|
+
enabled={manager.count > 0}
|
|
172
|
+
></ToolbarButtonComponent>
|
|
173
|
+
<ToolbarButtonComponent
|
|
174
|
+
actualOnClick={true}
|
|
175
|
+
onClick={onClose}
|
|
176
|
+
icon={closeIcon}
|
|
177
|
+
tooltip={trans.__('Hide notifications')}
|
|
178
|
+
></ToolbarButtonComponent>
|
|
179
|
+
</h2>
|
|
180
|
+
<ol className="jp-Notification-List">
|
|
181
|
+
{notifications.map(notification => {
|
|
182
|
+
const { id, message, type, options } = notification;
|
|
183
|
+
const toastType = type === 'in-progress' ? 'default' : type;
|
|
184
|
+
const closeNotification = () => {
|
|
185
|
+
manager.dismiss(id);
|
|
186
|
+
};
|
|
187
|
+
const icon =
|
|
188
|
+
type === 'default'
|
|
189
|
+
? null
|
|
190
|
+
: type === 'in-progress'
|
|
191
|
+
? icons?.spinner ?? null
|
|
192
|
+
: icons && icons[type];
|
|
193
|
+
return (
|
|
194
|
+
<li
|
|
195
|
+
className="jp-Notification-List-Item"
|
|
196
|
+
key={notification.id}
|
|
197
|
+
onClick={event => {
|
|
198
|
+
// Stop propagation to avoid closing the popup on click
|
|
199
|
+
event.stopPropagation();
|
|
200
|
+
}}
|
|
201
|
+
>
|
|
202
|
+
{/* This reuses the react-toastify elements to get a similar look and feel. */}
|
|
203
|
+
<div
|
|
204
|
+
className={`Toastify__toast Toastify__toast-theme--light Toastify__toast--${toastType} jp-Notification-Toast-${toastType}`}
|
|
205
|
+
>
|
|
206
|
+
<div className="Toastify__toast-body">
|
|
207
|
+
{icon && (
|
|
208
|
+
<div className="Toastify__toast-icon">
|
|
209
|
+
{icon({ theme: 'light', type: toastType })}
|
|
210
|
+
</div>
|
|
211
|
+
)}
|
|
212
|
+
<div>
|
|
213
|
+
{Private.createContent(
|
|
214
|
+
message,
|
|
215
|
+
closeNotification,
|
|
216
|
+
options.actions
|
|
217
|
+
)}
|
|
218
|
+
</div>
|
|
219
|
+
</div>
|
|
220
|
+
<button
|
|
221
|
+
className={`jp-Button jp-mod-minimal ${TOAST_CLOSE_BUTTON_CLASS}`}
|
|
222
|
+
title={trans.__('Dismiss notification')}
|
|
223
|
+
onClick={closeNotification}
|
|
224
|
+
>
|
|
225
|
+
<deleteIcon.react
|
|
226
|
+
className="jp-icon-hover"
|
|
227
|
+
tag="span"
|
|
228
|
+
></deleteIcon.react>
|
|
229
|
+
</button>
|
|
230
|
+
</div>
|
|
231
|
+
</li>
|
|
232
|
+
);
|
|
233
|
+
})}
|
|
234
|
+
</ol>
|
|
235
|
+
</>
|
|
236
|
+
)}
|
|
237
|
+
</UseSignal>
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Status widget model
|
|
243
|
+
*/
|
|
244
|
+
class NotificationStatusModel extends VDomModel {
|
|
245
|
+
constructor(protected manager: NotificationManager) {
|
|
246
|
+
super();
|
|
247
|
+
this._count = manager.count;
|
|
248
|
+
this.manager.changed.connect(this.onNotificationChanged, this);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Number of notifications.
|
|
253
|
+
*/
|
|
254
|
+
get count(): number {
|
|
255
|
+
return this._count;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Whether to silence all notifications or not.
|
|
260
|
+
*/
|
|
261
|
+
get doNotDisturbMode(): boolean {
|
|
262
|
+
return this._doNotDisturbMode;
|
|
263
|
+
}
|
|
264
|
+
set doNotDisturbMode(v: boolean) {
|
|
265
|
+
this._doNotDisturbMode = v;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Whether to highlight the status widget or not.
|
|
270
|
+
*/
|
|
271
|
+
get highlight(): boolean {
|
|
272
|
+
return this._highlight;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Whether the popup is opened or not.
|
|
277
|
+
*/
|
|
278
|
+
get listOpened(): boolean {
|
|
279
|
+
return this._listOpened;
|
|
280
|
+
}
|
|
281
|
+
set listOpened(v: boolean) {
|
|
282
|
+
this._listOpened = v;
|
|
283
|
+
if (this._listOpened || this._highlight) {
|
|
284
|
+
this._highlight = false;
|
|
285
|
+
}
|
|
286
|
+
this.stateChanged.emit();
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
protected onNotificationChanged(
|
|
290
|
+
manager: NotificationManager,
|
|
291
|
+
change: Notification.IChange
|
|
292
|
+
): void {
|
|
293
|
+
// Set private attribute to trigger only once the signal emission
|
|
294
|
+
this._count = this.manager.count;
|
|
295
|
+
|
|
296
|
+
const { autoClose } = change.notification.options;
|
|
297
|
+
const noToast =
|
|
298
|
+
this.doNotDisturbMode ||
|
|
299
|
+
(typeof autoClose === 'number' && autoClose <= 0);
|
|
300
|
+
|
|
301
|
+
// Highlight if
|
|
302
|
+
// the list is not opened (the style change if list is opened due to clickedItem style in statusbar.)
|
|
303
|
+
// the change type is not removed
|
|
304
|
+
// the notification will be hidden
|
|
305
|
+
if (!this._listOpened && change.type !== 'removed' && noToast) {
|
|
306
|
+
this._highlight = true;
|
|
307
|
+
}
|
|
308
|
+
this.stateChanged.emit();
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
private _count: number;
|
|
312
|
+
private _highlight = false;
|
|
313
|
+
private _listOpened = false;
|
|
314
|
+
private _doNotDisturbMode = false;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Status view properties
|
|
319
|
+
*/
|
|
320
|
+
interface INotificationStatusProps {
|
|
321
|
+
/**
|
|
322
|
+
* Number of notification
|
|
323
|
+
*/
|
|
324
|
+
count: number;
|
|
325
|
+
/**
|
|
326
|
+
* Whether to highlight the view or not.
|
|
327
|
+
*/
|
|
328
|
+
highlight: boolean;
|
|
329
|
+
/**
|
|
330
|
+
* Click event handler
|
|
331
|
+
*/
|
|
332
|
+
onClick: () => void;
|
|
333
|
+
/**
|
|
334
|
+
* Translation object
|
|
335
|
+
*/
|
|
336
|
+
trans: TranslationBundle;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Status view
|
|
341
|
+
*/
|
|
342
|
+
function NotificationStatus(props: INotificationStatusProps): JSX.Element {
|
|
343
|
+
return (
|
|
344
|
+
<GroupItem
|
|
345
|
+
spacing={HALF_SPACING}
|
|
346
|
+
onClick={() => {
|
|
347
|
+
props.onClick();
|
|
348
|
+
}}
|
|
349
|
+
title={
|
|
350
|
+
props.count > 0
|
|
351
|
+
? props.trans._n('%1 notification', '%1 notifications', props.count)
|
|
352
|
+
: props.trans.__('No notifications')
|
|
353
|
+
}
|
|
354
|
+
>
|
|
355
|
+
<TextItem
|
|
356
|
+
className="jp-Notification-Status-Text"
|
|
357
|
+
source={`${props.count}`}
|
|
358
|
+
></TextItem>
|
|
359
|
+
<bellIcon.react top={'2px'} stylesheet={'statusBar'}></bellIcon.react>
|
|
360
|
+
</GroupItem>
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Add notification center and toast
|
|
366
|
+
*/
|
|
367
|
+
export const notificationPlugin: JupyterFrontEndPlugin<void> = {
|
|
368
|
+
id: '@jupyterlab/apputils-extension:notification',
|
|
369
|
+
autoStart: true,
|
|
370
|
+
requires: [IStatusBar],
|
|
371
|
+
optional: [ISettingRegistry, ITranslator],
|
|
372
|
+
activate: (
|
|
373
|
+
app: JupyterFrontEnd,
|
|
374
|
+
statusBar: IStatusBar,
|
|
375
|
+
settingRegistry: ISettingRegistry | null,
|
|
376
|
+
translator: ITranslator | null
|
|
377
|
+
): void => {
|
|
378
|
+
Private.translator = translator ?? nullTranslator;
|
|
379
|
+
const trans = Private.translator.load('jupyterlab');
|
|
380
|
+
|
|
381
|
+
const model = new NotificationStatusModel(Notification.manager);
|
|
382
|
+
model.doNotDisturbMode = false;
|
|
383
|
+
|
|
384
|
+
if (settingRegistry) {
|
|
385
|
+
void Promise.all([
|
|
386
|
+
settingRegistry.load(notificationPlugin.id),
|
|
387
|
+
app.restored
|
|
388
|
+
]).then(([plugin]) => {
|
|
389
|
+
const updateSettings = () => {
|
|
390
|
+
model.doNotDisturbMode = plugin.get('doNotDisturbMode')
|
|
391
|
+
.composite as boolean;
|
|
392
|
+
};
|
|
393
|
+
updateSettings();
|
|
394
|
+
plugin.changed.connect(updateSettings);
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
app.commands.addCommand(CommandIDs.notify, {
|
|
399
|
+
label: trans.__('Emit a notification'),
|
|
400
|
+
caption: trans.__(
|
|
401
|
+
'Notification is described by {message: string, type?: string, options?: {autoClose?: number | false, actions: {label: string, commandId: string, args?: ReadOnlyJSONObject, caption?: string, className?: string}[], data?: ReadOnlyJSONValue}}.'
|
|
402
|
+
),
|
|
403
|
+
execute: args => {
|
|
404
|
+
const { message, type } = args as any;
|
|
405
|
+
const options = (args.options as any) ?? {};
|
|
406
|
+
|
|
407
|
+
return Notification.manager.notify(message, type ?? 'default', {
|
|
408
|
+
...options,
|
|
409
|
+
actions: options.actions
|
|
410
|
+
? options.actions.map(
|
|
411
|
+
(
|
|
412
|
+
action: Omit<Notification.IAction, 'callback'> & {
|
|
413
|
+
commandId: string;
|
|
414
|
+
args?: ReadonlyJSONObject;
|
|
415
|
+
}
|
|
416
|
+
) => {
|
|
417
|
+
return {
|
|
418
|
+
...action,
|
|
419
|
+
callback: () => {
|
|
420
|
+
app.commands
|
|
421
|
+
.execute(action.commandId, action.args)
|
|
422
|
+
.catch(r => {
|
|
423
|
+
console.error(
|
|
424
|
+
`Failed to executed '${action.commandId}':\n${r}`
|
|
425
|
+
);
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
} as Notification.IAction;
|
|
429
|
+
}
|
|
430
|
+
)
|
|
431
|
+
: null
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
app.commands.addCommand(CommandIDs.update, {
|
|
437
|
+
label: trans.__('Update a notification'),
|
|
438
|
+
caption: trans.__(
|
|
439
|
+
'Notification is described by {id: string, message: string, type?: string, options?: {autoClose?: number | false, actions: {label: string, commandId: string, args?: ReadOnlyJSONObject, caption?: string, className?: string}[], data?: ReadOnlyJSONValue}}.'
|
|
440
|
+
),
|
|
441
|
+
execute: args => {
|
|
442
|
+
const { id, message, type, ...options } = args as any;
|
|
443
|
+
|
|
444
|
+
return Notification.manager.update({
|
|
445
|
+
id,
|
|
446
|
+
message,
|
|
447
|
+
type: type ?? 'default',
|
|
448
|
+
...options,
|
|
449
|
+
actions: options.actions
|
|
450
|
+
? options.actions.map(
|
|
451
|
+
(
|
|
452
|
+
action: Omit<Notification.IAction, 'callback'> & {
|
|
453
|
+
commandId: string;
|
|
454
|
+
args?: ReadonlyJSONObject;
|
|
455
|
+
}
|
|
456
|
+
) => {
|
|
457
|
+
return {
|
|
458
|
+
...action,
|
|
459
|
+
callback: () => {
|
|
460
|
+
app.commands
|
|
461
|
+
.execute(action.commandId, action.args)
|
|
462
|
+
.catch(r => {
|
|
463
|
+
console.error(
|
|
464
|
+
`Failed to executed '${action.commandId}':\n${r}`
|
|
465
|
+
);
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
} as Notification.IAction;
|
|
469
|
+
}
|
|
470
|
+
)
|
|
471
|
+
: null
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
app.commands.addCommand(CommandIDs.dismiss, {
|
|
477
|
+
label: trans.__('Dismiss a notification'),
|
|
478
|
+
execute: args => {
|
|
479
|
+
const { id } = args as any;
|
|
480
|
+
|
|
481
|
+
Notification.manager.dismiss(id);
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
let popup: Popup | null = null;
|
|
486
|
+
model.listOpened = false;
|
|
487
|
+
|
|
488
|
+
const notificationList = ReactWidget.create(
|
|
489
|
+
<NotificationCenter
|
|
490
|
+
manager={Notification.manager}
|
|
491
|
+
onClose={() => {
|
|
492
|
+
popup?.dispose();
|
|
493
|
+
}}
|
|
494
|
+
trans={trans}
|
|
495
|
+
></NotificationCenter>
|
|
496
|
+
);
|
|
497
|
+
notificationList.addClass('jp-Notification-Center');
|
|
498
|
+
|
|
499
|
+
async function onNotification(
|
|
500
|
+
manager: NotificationManager,
|
|
501
|
+
change: Notification.IChange
|
|
502
|
+
): Promise<void> {
|
|
503
|
+
if (model.doNotDisturbMode || (popup !== null && !popup.isDisposed)) {
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const { message, type, options, id } = change.notification;
|
|
508
|
+
|
|
509
|
+
if (typeof options.autoClose === 'number' && options.autoClose <= 0) {
|
|
510
|
+
// If the notification is silent, bail early.
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
switch (change.type) {
|
|
515
|
+
case 'added':
|
|
516
|
+
await Private.createToast(id, message, type, options);
|
|
517
|
+
break;
|
|
518
|
+
case 'updated':
|
|
519
|
+
{
|
|
520
|
+
const toast = await Private.toast();
|
|
521
|
+
const actions = options.actions;
|
|
522
|
+
|
|
523
|
+
const autoClose =
|
|
524
|
+
options.autoClose ??
|
|
525
|
+
(actions && actions.length > 0 ? false : null);
|
|
526
|
+
|
|
527
|
+
if (toast.isActive(id)) {
|
|
528
|
+
// Update existing toast
|
|
529
|
+
const closeToast = (): void => {
|
|
530
|
+
// Dismiss the displayed toast
|
|
531
|
+
toast.dismiss(id);
|
|
532
|
+
// Dismiss the notification from the queue
|
|
533
|
+
manager.dismiss(id);
|
|
534
|
+
};
|
|
535
|
+
toast.update(id, {
|
|
536
|
+
type: type === 'in-progress' ? null : type,
|
|
537
|
+
isLoading: type === 'in-progress',
|
|
538
|
+
autoClose: autoClose,
|
|
539
|
+
render: Private.createContent(
|
|
540
|
+
message,
|
|
541
|
+
closeToast,
|
|
542
|
+
options.actions
|
|
543
|
+
)
|
|
544
|
+
});
|
|
545
|
+
} else {
|
|
546
|
+
// Needs to recreate a closed toast
|
|
547
|
+
await Private.createToast(id, message, type, options);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
break;
|
|
551
|
+
case 'removed':
|
|
552
|
+
await Private.toast().then(t => {
|
|
553
|
+
t.dismiss(id);
|
|
554
|
+
});
|
|
555
|
+
break;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
Notification.manager.changed.connect(onNotification);
|
|
559
|
+
|
|
560
|
+
const displayNotifications = (): void => {
|
|
561
|
+
if (popup) {
|
|
562
|
+
popup.dispose();
|
|
563
|
+
popup = null;
|
|
564
|
+
} else {
|
|
565
|
+
popup = showPopup({
|
|
566
|
+
body: notificationList,
|
|
567
|
+
anchor: notificationStatus,
|
|
568
|
+
align: 'right',
|
|
569
|
+
hasDynamicSize: true,
|
|
570
|
+
startHidden: true
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
// Dismiss all toasts when opening the notification center
|
|
574
|
+
Private.toast()
|
|
575
|
+
.then(t => {
|
|
576
|
+
t.dismiss();
|
|
577
|
+
})
|
|
578
|
+
.catch(r => {
|
|
579
|
+
console.error(`Failed to dismiss all toasts:\n${r}`);
|
|
580
|
+
})
|
|
581
|
+
.finally(() => {
|
|
582
|
+
popup?.launch();
|
|
583
|
+
|
|
584
|
+
// Focus on the pop-up
|
|
585
|
+
notificationList.node.focus();
|
|
586
|
+
|
|
587
|
+
popup?.disposed.connect(() => {
|
|
588
|
+
model.listOpened = false;
|
|
589
|
+
popup = null;
|
|
590
|
+
});
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
model.listOpened = popup !== null;
|
|
595
|
+
};
|
|
596
|
+
|
|
597
|
+
app.commands.addCommand(CommandIDs.display, {
|
|
598
|
+
label: trans.__('Show Notifications'),
|
|
599
|
+
execute: displayNotifications
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
const notificationStatus = ReactWidget.create(
|
|
603
|
+
<UseSignal signal={model.stateChanged}>
|
|
604
|
+
{() => {
|
|
605
|
+
if (model.highlight || (popup && !popup.isDisposed)) {
|
|
606
|
+
notificationStatus.addClass('jp-mod-selected');
|
|
607
|
+
} else {
|
|
608
|
+
notificationStatus.removeClass('jp-mod-selected');
|
|
609
|
+
}
|
|
610
|
+
return (
|
|
611
|
+
<NotificationStatus
|
|
612
|
+
count={model.count}
|
|
613
|
+
highlight={model.highlight}
|
|
614
|
+
trans={trans}
|
|
615
|
+
onClick={displayNotifications}
|
|
616
|
+
></NotificationStatus>
|
|
617
|
+
);
|
|
618
|
+
}}
|
|
619
|
+
</UseSignal>
|
|
620
|
+
);
|
|
621
|
+
|
|
622
|
+
notificationStatus.addClass('jp-Notification-Status');
|
|
623
|
+
|
|
624
|
+
statusBar.registerStatusItem(notificationPlugin.id, {
|
|
625
|
+
item: notificationStatus,
|
|
626
|
+
align: 'right',
|
|
627
|
+
rank: -1
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
};
|
|
631
|
+
|
|
632
|
+
namespace Private {
|
|
633
|
+
/**
|
|
634
|
+
* Translator object for private namespace
|
|
635
|
+
*/
|
|
636
|
+
export let translator: ITranslator = nullTranslator;
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* Pointer to asynchronously loaded react-toastify
|
|
640
|
+
*/
|
|
641
|
+
let toastify: typeof ReactToastify | null = null;
|
|
642
|
+
|
|
643
|
+
function CloseButton(props: CloseButtonProps): JSX.Element {
|
|
644
|
+
const trans = translator.load('jupyterlab');
|
|
645
|
+
return (
|
|
646
|
+
<button
|
|
647
|
+
className={`jp-Button jp-mod-minimal ${TOAST_CLOSE_BUTTON_CLASS}`}
|
|
648
|
+
title={trans.__('Hide notification')}
|
|
649
|
+
onClick={props.closeToast}
|
|
650
|
+
>
|
|
651
|
+
<closeIcon.react className="jp-icon-hover" tag="span"></closeIcon.react>
|
|
652
|
+
</button>
|
|
653
|
+
);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* Helper interface for 'react-toastify'.toast
|
|
658
|
+
*/
|
|
659
|
+
export interface IToast {
|
|
660
|
+
/**
|
|
661
|
+
* Helper generic function
|
|
662
|
+
*/
|
|
663
|
+
(content: ToastContent, options?: ToastOptions): Id;
|
|
664
|
+
/**
|
|
665
|
+
* Helper function for a toast with loading animation
|
|
666
|
+
*/
|
|
667
|
+
loading(content: ToastContent, options?: ToastOptions): Id;
|
|
668
|
+
/**
|
|
669
|
+
* Helper function for a toast with success style
|
|
670
|
+
*/
|
|
671
|
+
success(content: ToastContent, options?: ToastOptions): Id;
|
|
672
|
+
/**
|
|
673
|
+
* Helper function for a toast with info style
|
|
674
|
+
*/
|
|
675
|
+
info(content: ToastContent, options?: ToastOptions | undefined): Id;
|
|
676
|
+
/**
|
|
677
|
+
* Helper function for a toast with error style
|
|
678
|
+
*/
|
|
679
|
+
error(content: ToastContent, options?: ToastOptions | undefined): Id;
|
|
680
|
+
/**
|
|
681
|
+
* Helper function for a toast with warning style
|
|
682
|
+
*/
|
|
683
|
+
warning(content: ToastContent, options?: ToastOptions | undefined): Id;
|
|
684
|
+
/**
|
|
685
|
+
* Helper function for a toast with dark style
|
|
686
|
+
*/
|
|
687
|
+
dark(content: ToastContent, options?: ToastOptions | undefined): Id;
|
|
688
|
+
/**
|
|
689
|
+
* Helper function for a toast with warning style
|
|
690
|
+
*/
|
|
691
|
+
warn(content: ToastContent, options?: ToastOptions | undefined): Id;
|
|
692
|
+
/**
|
|
693
|
+
* Remove toast programmatically
|
|
694
|
+
*/
|
|
695
|
+
dismiss(id?: Id): void;
|
|
696
|
+
/**
|
|
697
|
+
* Clear waiting queue when limit is used
|
|
698
|
+
*/
|
|
699
|
+
clearWaitingQueue(params?: ClearWaitingQueueParams): void;
|
|
700
|
+
/**
|
|
701
|
+
* return true if one container is displaying the toast
|
|
702
|
+
*/
|
|
703
|
+
isActive(id: Id): boolean;
|
|
704
|
+
/**
|
|
705
|
+
* Update a toast
|
|
706
|
+
*/
|
|
707
|
+
update(toastId: Id, options?: UpdateOptions): void;
|
|
708
|
+
/**
|
|
709
|
+
* Used for controlled progress bar.
|
|
710
|
+
*/
|
|
711
|
+
done(id: Id): void;
|
|
712
|
+
/**
|
|
713
|
+
* Track changes. The callback get the number of toast displayed
|
|
714
|
+
*/
|
|
715
|
+
onChange(callback: (toast: ToastItem) => void): () => void;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
let waitForToastify: PromiseDelegate<void> | null = null;
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* Asynchronously load the toast container
|
|
722
|
+
*
|
|
723
|
+
* @returns The toast object
|
|
724
|
+
*/
|
|
725
|
+
export async function toast(): Promise<IToast> {
|
|
726
|
+
if (waitForToastify === null) {
|
|
727
|
+
waitForToastify = new PromiseDelegate();
|
|
728
|
+
} else {
|
|
729
|
+
await waitForToastify.promise;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
if (toastify === null) {
|
|
733
|
+
toastify = await import('react-toastify');
|
|
734
|
+
|
|
735
|
+
const container = document.body.appendChild(
|
|
736
|
+
document.createElement('div')
|
|
737
|
+
);
|
|
738
|
+
container.id = 'react-toastify-container';
|
|
739
|
+
const root = createRoot(container);
|
|
740
|
+
|
|
741
|
+
root.render(
|
|
742
|
+
<toastify.ToastContainer
|
|
743
|
+
draggable={false}
|
|
744
|
+
closeOnClick={false}
|
|
745
|
+
hideProgressBar={true}
|
|
746
|
+
newestOnTop={true}
|
|
747
|
+
pauseOnFocusLoss={true}
|
|
748
|
+
pauseOnHover={true}
|
|
749
|
+
position="bottom-right"
|
|
750
|
+
className="jp-toastContainer"
|
|
751
|
+
transition={toastify.Slide}
|
|
752
|
+
closeButton={CloseButton}
|
|
753
|
+
></toastify.ToastContainer>
|
|
754
|
+
);
|
|
755
|
+
|
|
756
|
+
waitForToastify.resolve();
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
return toastify.toast;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* react-toastify icons loader
|
|
764
|
+
*/
|
|
765
|
+
export async function getIcons(): Promise<typeof Icons> {
|
|
766
|
+
if (toastify === null) {
|
|
767
|
+
await toast();
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
return toastify!.Icons;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
interface IToastButtonProps {
|
|
774
|
+
/**
|
|
775
|
+
* User specification for the button
|
|
776
|
+
*/
|
|
777
|
+
action: Notification.IAction;
|
|
778
|
+
|
|
779
|
+
/**
|
|
780
|
+
* Function closing the notification
|
|
781
|
+
*/
|
|
782
|
+
closeToast: () => void;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
const displayType2Class: Record<Notification.ActionDisplayType, string> = {
|
|
786
|
+
accent: 'jp-mod-accept',
|
|
787
|
+
link: 'jp-mod-link',
|
|
788
|
+
warn: 'jp-mod-warn',
|
|
789
|
+
default: ''
|
|
790
|
+
};
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* Create a button with customized callback in a toast
|
|
794
|
+
*/
|
|
795
|
+
function ToastButton({ action, closeToast }: IToastButtonProps): JSX.Element {
|
|
796
|
+
const clickHandler = (event: React.MouseEvent): void => {
|
|
797
|
+
action.callback(event as any);
|
|
798
|
+
if (!event.defaultPrevented) {
|
|
799
|
+
closeToast();
|
|
800
|
+
}
|
|
801
|
+
};
|
|
802
|
+
const classes = [
|
|
803
|
+
'jp-toast-button',
|
|
804
|
+
displayType2Class[action.displayType ?? 'default']
|
|
805
|
+
].join(' ');
|
|
806
|
+
return (
|
|
807
|
+
<Button
|
|
808
|
+
title={action.caption ?? action.label}
|
|
809
|
+
className={classes}
|
|
810
|
+
onClick={clickHandler}
|
|
811
|
+
small={true}
|
|
812
|
+
>
|
|
813
|
+
{action.label}
|
|
814
|
+
</Button>
|
|
815
|
+
);
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/**
|
|
819
|
+
* Helper function to construct the notification content
|
|
820
|
+
*
|
|
821
|
+
* @param message Message to print in the notification
|
|
822
|
+
* @param closeHandler Function closing the notification
|
|
823
|
+
* @param actions Toast actions
|
|
824
|
+
*/
|
|
825
|
+
export function createContent(
|
|
826
|
+
message: string,
|
|
827
|
+
closeHandler: () => void,
|
|
828
|
+
actions?: Notification.IAction[]
|
|
829
|
+
): React.ReactNode {
|
|
830
|
+
const shortenMessage =
|
|
831
|
+
message.length > MAX_MESSAGE_LENGTH
|
|
832
|
+
? message.slice(0, MAX_MESSAGE_LENGTH) + '…'
|
|
833
|
+
: message;
|
|
834
|
+
return (
|
|
835
|
+
<>
|
|
836
|
+
<div>
|
|
837
|
+
{shortenMessage.split('\n').map((part, index) => (
|
|
838
|
+
<React.Fragment key={`part-${index}`}>
|
|
839
|
+
{index > 0 ? <br /> : null}
|
|
840
|
+
{part}
|
|
841
|
+
</React.Fragment>
|
|
842
|
+
))}
|
|
843
|
+
</div>
|
|
844
|
+
{(actions?.length ?? 0) > 0 && (
|
|
845
|
+
<div className="jp-toast-buttonBar">
|
|
846
|
+
<div className="jp-toast-spacer" />
|
|
847
|
+
{actions!.map((action, idx) => {
|
|
848
|
+
return (
|
|
849
|
+
<ToastButton
|
|
850
|
+
key={'button-' + idx}
|
|
851
|
+
action={action}
|
|
852
|
+
closeToast={closeHandler}
|
|
853
|
+
/>
|
|
854
|
+
);
|
|
855
|
+
})}
|
|
856
|
+
</div>
|
|
857
|
+
)}
|
|
858
|
+
</>
|
|
859
|
+
);
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/**
|
|
863
|
+
* Create a toast notification
|
|
864
|
+
*
|
|
865
|
+
* @param toastId Toast unique id
|
|
866
|
+
* @param message Toast message
|
|
867
|
+
* @param type Toast type
|
|
868
|
+
* @param options Toast options
|
|
869
|
+
* @returns Toast id
|
|
870
|
+
*/
|
|
871
|
+
export async function createToast<T extends ReadonlyJSONValue>(
|
|
872
|
+
toastId: string,
|
|
873
|
+
message: string,
|
|
874
|
+
type: Notification.TypeOptions,
|
|
875
|
+
options: Notification.IOptions<T> = {}
|
|
876
|
+
): Promise<Id> {
|
|
877
|
+
const { actions, autoClose, data } = options;
|
|
878
|
+
const t = await toast();
|
|
879
|
+
const toastOptions = {
|
|
880
|
+
autoClose:
|
|
881
|
+
autoClose ?? (actions && actions.length > 0 ? false : undefined),
|
|
882
|
+
data: data as any,
|
|
883
|
+
className: `jp-Notification-Toast-${type}`,
|
|
884
|
+
toastId,
|
|
885
|
+
type: type === 'in-progress' ? null : type,
|
|
886
|
+
isLoading: type === 'in-progress'
|
|
887
|
+
} as any;
|
|
888
|
+
|
|
889
|
+
return t(
|
|
890
|
+
({ closeToast }: { closeToast?: () => void }) =>
|
|
891
|
+
createContent(
|
|
892
|
+
message,
|
|
893
|
+
() => {
|
|
894
|
+
if (closeToast) closeToast();
|
|
895
|
+
Notification.manager.dismiss(toastId);
|
|
896
|
+
},
|
|
897
|
+
actions
|
|
898
|
+
),
|
|
899
|
+
toastOptions
|
|
900
|
+
);
|
|
901
|
+
}
|
|
902
|
+
}
|