@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.
Files changed (43) hide show
  1. package/lib/announcements.d.ts +2 -0
  2. package/lib/announcements.js +227 -0
  3. package/lib/announcements.js.map +1 -0
  4. package/lib/index.js +41 -8
  5. package/lib/index.js.map +1 -1
  6. package/lib/notificationplugin.d.ts +5 -0
  7. package/lib/notificationplugin.js +504 -0
  8. package/lib/notificationplugin.js.map +1 -0
  9. package/lib/palette.js +3 -3
  10. package/lib/palette.js.map +1 -1
  11. package/lib/settingconnector.js +4 -0
  12. package/lib/settingconnector.js.map +1 -1
  13. package/lib/settingsplugin.js +8 -2
  14. package/lib/settingsplugin.js.map +1 -1
  15. package/lib/statusbarplugin.d.ts +7 -0
  16. package/lib/statusbarplugin.js +96 -0
  17. package/lib/statusbarplugin.js.map +1 -0
  18. package/lib/themesplugins.js +3 -0
  19. package/lib/themesplugins.js.map +1 -1
  20. package/lib/toolbarregistryplugin.js +5 -1
  21. package/lib/toolbarregistryplugin.js.map +1 -1
  22. package/lib/workspacesplugin.js +6 -6
  23. package/lib/workspacesplugin.js.map +1 -1
  24. package/package.json +27 -22
  25. package/schema/notification.json +48 -0
  26. package/schema/palette.json +2 -0
  27. package/schema/sanitizer.json +25 -0
  28. package/src/announcements.ts +297 -0
  29. package/src/index.ts +684 -0
  30. package/src/notificationplugin.tsx +902 -0
  31. package/src/palette.ts +213 -0
  32. package/src/settingconnector.ts +63 -0
  33. package/src/settingsplugin.ts +65 -0
  34. package/src/statusbarplugin.ts +145 -0
  35. package/src/themesplugins.ts +266 -0
  36. package/src/toolbarregistryplugin.ts +29 -0
  37. package/src/workspacesplugin.ts +306 -0
  38. package/style/base.css +1 -1
  39. package/style/index.css +1 -0
  40. package/style/index.js +1 -0
  41. package/style/notification.css +227 -0
  42. package/style/splash.css +4 -0
  43. 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
+ }