@vuu-ui/vuu-notifications 4.2.4 → 4.2.6

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 CHANGED
@@ -1 +1,157 @@
1
1
  # Notifications
2
+
3
+ `@vuu-ui/vuu-notifications` shows toast and workspace notifications.
4
+
5
+ Wrap the app in a `NotificationsProvider` and raise notifications with `useNotifications`:
6
+
7
+ ```tsx
8
+ import {
9
+ NotificationsProvider,
10
+ useNotifications,
11
+ } from "@vuu-ui/vuu-notifications";
12
+
13
+ const SaveButton = () => {
14
+ const { showNotification } = useNotifications();
15
+ return (
16
+ <button
17
+ onClick={() =>
18
+ showNotification({
19
+ header: "Layout saved",
20
+ status: "success",
21
+ type: "toast",
22
+ })
23
+ }
24
+ >
25
+ Save
26
+ </button>
27
+ );
28
+ };
29
+
30
+ export const App = () => (
31
+ <NotificationsProvider>
32
+ <SaveButton />
33
+ </NotificationsProvider>
34
+ );
35
+ ```
36
+
37
+ `showNotification` returns an ID that can be passed to `hideNotification`. A toast is dismissed automatically after a delay unless `dismissal` is `"manual"`.
38
+
39
+ ## What's new in this version
40
+
41
+ These changes let an app host other apps (for example, modules loaded with module federation in a portal) and still show every notification once, in one place, knowing where each came from.
42
+
43
+ ### Notification origins
44
+
45
+ `NotificationOriginProvider` tags every notification raised beneath it with an `origin`:
46
+
47
+ ```tsx
48
+ <NotificationOriginProvider connectionId="simul" moduleId="basket-trading">
49
+ <BasketTrading />
50
+ </NotificationOriginProvider>
51
+ ```
52
+
53
+ | Field | Meaning |
54
+ | ---------------- | ------------------------------------------------------------------------------- |
55
+ | `moduleId` | The module that raised the notification. |
56
+ | `connectionId` | The Vuu server the module uses. |
57
+ | `parentModuleId` | For nested origins, the `moduleId` of the enclosing origin; set automatically. |
58
+
59
+ - **Explicit origins win:** a notification that already has an `origin` keeps it.
60
+ - **No provider, no origin:** outside any `NotificationOriginProvider`, notifications have no `origin`.
61
+ - **Reading it:** `useNotificationOrigin()` returns the current origin.
62
+
63
+ ### Interceptor
64
+
65
+ `NotificationsProvider` takes an optional `interceptor`. It sees every notification raised beneath the provider before it is shown, and returns `"present"` to show it or `"suppress"` to drop it. A host can use it to keep a history of notifications, or to drop those from a module the user has muted.
66
+
67
+ ```tsx
68
+ <NotificationsProvider
69
+ interceptor={(notification) => {
70
+ history.add(notification);
71
+ return notification.origin?.moduleId === mutedModule
72
+ ? "suppress"
73
+ : "present";
74
+ }}
75
+ >
76
+ ```
77
+
78
+ Only the outermost provider's interceptor is used.
79
+
80
+ ### One outermost provider
81
+
82
+ A `NotificationsProvider` inside another now renders only its children. Notifications raised anywhere beneath the outermost provider, including inside nested providers, are shown once, by that provider. A hosted app can therefore keep its own `NotificationsProvider`: it works standalone and defers to the host when embedded.
83
+
84
+ ### Shared across copies of the package
85
+
86
+ The provider contexts are kept on the global object under `Symbol.for` keys, not in module scope. A module federation remote that loads its own copy of `@vuu-ui/vuu-notifications` still finds the host's provider.
87
+
88
+ ### Other additions
89
+
90
+ - **`record`:** an optional flag on a notification, telling a host that keeps a notification history whether to record it. The host chooses the default.
91
+ - **New exports:**
92
+ - the types `ToastNotificationDescriptor`, `WorkspaceNotificationDescriptor`, `NotificationOrigin`, `NotificationInterceptor` and `NotificationsProviderProps`
93
+ - the type guards `isToastNotification` and `isWorkspaceNotification`
94
+ - **`children` is optional:** `NotificationsProvider` now accepts any `ReactNode`, not only elements.
95
+
96
+ ## Compatibility
97
+
98
+ The changes are backward compatible: existing code needs no changes.
99
+
100
+ - **API:** every change is an addition or a widening.
101
+ - Exports are only added.
102
+ - The new notification fields (`origin`, `record`) are optional.
103
+ - `NotificationsProvider`'s `children` accepts more than before.
104
+ - **Single provider:** an app with one `NotificationsProvider` behaves as before.
105
+ - **Callers outside a provider:** their notifications are still shown by the most recently mounted provider. vuu-shell's `WorkspaceProvider`, for example, sits above the shell's `NotificationsProvider`. If no provider is mounted anywhere, `showNotification` logs a warning and returns `undefined`, as before.
106
+ - **`useNotifications` identity:** without an origin, it returns the same stable functions as before.
107
+
108
+ Two cases now behave differently. Neither occurs in this repository outside showcase examples.
109
+
110
+ - **Nested providers:** before, all providers shared one notification centre and the last one to mount, usually the innermost, showed every notification. Now the outermost provider shows them.
111
+ - **Sibling providers:** two providers side by side now each show the notifications raised beneath them. Before, the last one to mount showed both sets.
112
+
113
+ ## Vuu server support
114
+
115
+ The new attributes are filled in on the client, so they work with today's Vuu server. A portal gets more out of them if the server can also say who a notification concerns and how it should be treated. This section describes what the server sends today and what it would need to add.
116
+
117
+ ### What the server sends today
118
+
119
+ The Vuu server notifies the client in two ways:
120
+
121
+ - **RPC responses:** a response can carry a `SHOW_NOTIFICATION_ACTION` with `notificationType` (`Error`, `Warning` or `Info`), `title` and `message`. The client shows it as a toast.
122
+ - **The `NOTIFICATIONS` module:** its `notifications` table has the columns `id`, `type`, `expiryTime`, `title`, `message`, `level` and `audience`, plus any columns the application adds. Rows are pushed to subscribers like any other table, so the server can notify users without a request.
123
+
124
+ ### What each attribute is for, and what the server would need
125
+
126
+ | Attribute | What it's for in a portal | What the server would need |
127
+ | --- | --- | --- |
128
+ | `origin.moduleId` | Says which application a notification belongs to. The portal can then badge that application in its navigation, filter the notification history by application, and let the user mute an application. | **RPC responses:** nothing. The response returns to the application that made the request, and that application's `NotificationOriginProvider` sets the origin. (Today this holds for context-menu RPCs only; see below.)<br>**`notifications` table:** a standard column naming the application, such as `module`, holding the application's client identifier. Without it, a notification can only be attributed to every application using that server. |
129
+ | `origin.connectionId` | Says which Vuu server a notification came from, when a portal uses several. | Nothing: the client knows which connection a message arrived on. |
130
+ | `origin.parentModuleId` | Attributes notifications from an application embedded in another to the outer application too. | Nothing: it comes from the nesting of origin providers on the client. |
131
+ | Topic, for routing | Lets applications declare the kinds of notification they care about (for example `orders`), so the server doesn't need to know application names. | A standard `topic` column on the `notifications` table. |
132
+ | `record` | Tells the portal whether to keep a notification in its history. A confirmation such as "Saved" needn't be kept; a rejected order should be. | An optional `record` field on `SHOW_NOTIFICATION_ACTION`. Rows in the `notifications` table are already a record, so they need nothing. |
133
+ | `status` (level) | Sets the toast's colour and icon. The worst unread level can also badge the portal's notification bell. | A `Success` value in `NotificationType`, which has only `Error`, `Warning` and `Info`. The `level` column should use the same four values. |
134
+ | `dismissal` | `"manual"` keeps a toast on screen until the user closes it. An error that needs acknowledging shouldn't disappear after a few seconds. | An optional `dismissal` field on `SHOW_NOTIFICATION_ACTION`, for example defaulting to manual for errors. |
135
+
136
+ The interceptor needs nothing from the server: it runs on the client and sees server and client notifications alike.
137
+
138
+ ### Client changes to go with them
139
+
140
+ The client shows the notifications on both kinds of RPC response at the server's level: `Error`, `Warning` and `Info` are shown with those statuses, and an action without a `notificationType` as a success. Without a `title`, the header names the status. The mapping is `getServerNotificationStatus` in `@vuu-ui/vuu-utils`.
141
+
142
+ - **Context-menu RPCs:** the menu action handler shows the notification.
143
+ - **Other RPCs:** the caller gets only the response's `result`, so `ConnectionManager` emits the notification as a `server-notification` event. `useServerNotifications(ConnectionManager)` shows these. The hook takes the event source as an argument, so this package doesn't depend on `@vuu-ui/vuu-data-remote`. vuu-shell calls it; an application that doesn't use the shell calls it once itself. A caller that also reports the `ERROR_RESULT`, such as the session editing form, shows the error twice: inline and as a toast.
144
+
145
+ Two client changes remain:
146
+
147
+ - **Origin of other RPC notifications:** the event isn't tied to the request that caused it, so these notifications get the origin of wherever `useServerNotifications` is called, not of the application that made the request. Passing the notification back to the caller would fix this.
148
+ - **New fields:** once the server sends `record` and `dismissal`, both handlers should pass them to `showNotification` as well.
149
+
150
+ ## Examples and tests
151
+
152
+ - **Examples:** the showcase has examples under `Notifications/Toast`, `Notifications/NotificationOrigins` and `Notifications/ServerNotifications`.
153
+ - **Component tests:** in `src/__tests__/__component__`, run with:
154
+
155
+ ```sh
156
+ npx playwright test --config=playwright.config.ts --project=components-chromium packages/vuu-notifications
157
+ ```
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "4.2.4",
2
+ "version": "4.2.6",
3
3
  "description": "VUU notifications - Toast, WorkspaceNotification etc",
4
4
  "main": "./src/index.js",
5
5
  "author": "heswell",
@@ -8,12 +8,15 @@
8
8
  "build": "node ../../scripts/run-build-rslib.ts",
9
9
  "type-defs": "node ../../scripts/build-type-defs.mjs"
10
10
  },
11
+ "devDependencies": {
12
+ "@vuu-ui/vuu-protocol-types": "4.2.6"
13
+ },
11
14
  "dependencies": {
12
15
  "@salt-ds/core": "1.74.0",
13
16
  "@salt-ds/styles": "0.4.2",
14
17
  "@salt-ds/window": "0.1.2",
15
- "@vuu-ui/vuu-ui-controls": "4.2.4",
16
- "@vuu-ui/vuu-utils": "4.2.4"
18
+ "@vuu-ui/vuu-ui-controls": "4.2.6",
19
+ "@vuu-ui/vuu-utils": "4.2.6"
17
20
  },
18
21
  "peerDependencies": {
19
22
  "clsx": "^2.0.0",
@@ -1,26 +1,64 @@
1
1
  import { jsx, jsxs } from "react/jsx-runtime";
2
- import react, { useContext, useMemo } from "react";
3
- import { NotificationsCenter } from "./NotificationsCenter.js";
4
2
  import { getLocalEntity } from "@vuu-ui/vuu-utils";
3
+ import { createContext, useContext, useEffect, useMemo, useState } from "react";
4
+ import { NotificationsCenter } from "./NotificationsCenter.js";
5
+ const globalContext = (name, defaultValue)=>{
6
+ const key = Symbol.for(name);
7
+ const contexts = globalThis;
8
+ let context = contexts[key];
9
+ if (void 0 === context) {
10
+ context = /*#__PURE__*/ createContext(defaultValue);
11
+ contexts[key] = context;
12
+ }
13
+ return context;
14
+ };
5
15
  class NotificationsContextObject {
6
16
  #showNotification = ()=>{
7
17
  console.log("have you forgotten to provide a NotificationsCenter?");
8
18
  };
9
19
  #hideNotification = ()=>console.log("have you forgotten to provide a NotificationsCenter?");
10
- showNotification = (notification)=>this.#showNotification(notification);
20
+ interceptor = void 0;
21
+ showNotification = (notification)=>this.interceptor?.(notification) === "suppress" ? void 0 : this.#showNotification(notification);
11
22
  hideNotification = (id)=>this.#hideNotification(id);
12
23
  setNotify = (showNotificationDispatcher, hideNotificationDispatcher)=>{
13
24
  this.#showNotification = showNotificationDispatcher;
14
25
  this.#hideNotification = hideNotificationDispatcher;
15
26
  };
16
27
  }
17
- const NotificationsContext = /*#__PURE__*/ react.createContext(new NotificationsContextObject());
18
- const NotificationsProvider = (props)=>{
19
- const context = useContext(NotificationsContext);
28
+ const NotificationsContext = globalContext("vuu.notifications.context", null);
29
+ const NotificationOriginContext = globalContext("vuu.notifications.origin", void 0);
30
+ const globalProviders = globalThis;
31
+ const providersKey = Symbol.for("vuu.notifications.providers");
32
+ globalProviders[providersKey] ??= [];
33
+ const mountedProviders = globalProviders[providersKey];
34
+ const noProvider = new NotificationsContextObject();
35
+ noProvider.setNotify((notification)=>{
36
+ const provider = mountedProviders.at(-1);
37
+ if (provider) return provider.showNotification(notification);
38
+ console.log("have you forgotten to provide a NotificationsProvider?");
39
+ }, (id)=>mountedProviders.at(-1)?.hideNotification(id));
40
+ const NotificationsProvider = ({ children, interceptor })=>{
41
+ const outerContext = useContext(NotificationsContext);
42
+ return outerContext ? children : /*#__PURE__*/ jsx(OutermostNotificationsProvider, {
43
+ interceptor: interceptor,
44
+ children: children
45
+ });
46
+ };
47
+ const OutermostNotificationsProvider = ({ children, interceptor })=>{
48
+ const [context] = useState(()=>new NotificationsContextObject());
49
+ context.interceptor = interceptor;
50
+ useEffect(()=>{
51
+ mountedProviders.push(context);
52
+ return ()=>{
53
+ mountedProviders.splice(mountedProviders.indexOf(context), 1);
54
+ };
55
+ }, [
56
+ context
57
+ ]);
20
58
  const startupToastNotification = useMemo(()=>{
21
59
  const toast = getLocalEntity("startup-notification", true);
22
60
  if (toast && toast.expires >= +Date.now()) {
23
- const { expires, ...toastDescriptor } = toast;
61
+ const { expires: _expires, ...toastDescriptor } = toast;
24
62
  return toastDescriptor;
25
63
  }
26
64
  }, []);
@@ -31,15 +69,41 @@ const NotificationsProvider = (props)=>{
31
69
  startupToastNotification: startupToastNotification,
32
70
  notificationsContext: context
33
71
  }),
34
- props.children
72
+ children
35
73
  ]
36
74
  });
37
75
  };
76
+ const NotificationOriginProvider = ({ children, connectionId, moduleId })=>{
77
+ const outerOrigin = useContext(NotificationOriginContext);
78
+ const outerModuleId = outerOrigin?.moduleId ?? outerOrigin?.parentModuleId;
79
+ const parentModuleId = outerModuleId !== moduleId ? outerModuleId : outerOrigin?.parentModuleId;
80
+ const origin = useMemo(()=>({
81
+ connectionId,
82
+ moduleId,
83
+ parentModuleId
84
+ }), [
85
+ connectionId,
86
+ moduleId,
87
+ parentModuleId
88
+ ]);
89
+ return /*#__PURE__*/ jsx(NotificationOriginContext.Provider, {
90
+ value: origin,
91
+ children: children
92
+ });
93
+ };
94
+ const useNotificationOrigin = ()=>useContext(NotificationOriginContext);
38
95
  const useNotifications = ()=>{
39
- const { hideNotification, showNotification } = useContext(NotificationsContext);
40
- return {
41
- hideNotification,
42
- showNotification
43
- };
96
+ const context = useContext(NotificationsContext) ?? noProvider;
97
+ const origin = useContext(NotificationOriginContext);
98
+ return useMemo(()=>({
99
+ hideNotification: context.hideNotification,
100
+ showNotification: origin ? (notification)=>context.showNotification(notification.origin ? notification : {
101
+ ...notification,
102
+ origin
103
+ }) : context.showNotification
104
+ }), [
105
+ context,
106
+ origin
107
+ ]);
44
108
  };
45
- export { NotificationsProvider, useNotifications };
109
+ export { NotificationOriginProvider, NotificationsProvider, useNotificationOrigin, useNotifications };
package/src/index.js CHANGED
@@ -1,3 +1,4 @@
1
- export { NotificationType } from "./NotificationsContext.js";
2
- export { NotificationsProvider, useNotifications } from "./NotificationsProvider.js";
1
+ export { NotificationType, isToastNotification, isWorkspaceNotification } from "./NotificationsContext.js";
2
+ export { NotificationOriginProvider, NotificationsProvider, useNotificationOrigin, useNotifications } from "./NotificationsProvider.js";
3
3
  export { ToastNotification } from "./ToastNotification.js";
4
+ export { useServerNotifications } from "./useServerNotifications.js";
@@ -0,0 +1,24 @@
1
+ import { getServerNotificationStatus } from "@vuu-ui/vuu-utils";
2
+ import { useEffect } from "react";
3
+ import { NotificationType } from "./NotificationsContext.js";
4
+ import { useNotifications } from "./NotificationsProvider.js";
5
+ const useServerNotifications = (source)=>{
6
+ const { showNotification } = useNotifications();
7
+ useEffect(()=>{
8
+ const handleServerNotification = (action)=>{
9
+ showNotification({
10
+ ...getServerNotificationStatus(action),
11
+ content: action.message,
12
+ type: NotificationType.Toast
13
+ });
14
+ };
15
+ source.on("server-notification", handleServerNotification);
16
+ return ()=>{
17
+ source.removeListener("server-notification", handleServerNotification);
18
+ };
19
+ }, [
20
+ showNotification,
21
+ source
22
+ ]);
23
+ };
24
+ export { useServerNotifications };
@@ -1,4 +1,4 @@
1
- import { NotificationsContextProps, ToastNotificationDescriptor } from "./NotificationsContext";
1
+ import { type NotificationsContextProps, type ToastNotificationDescriptor } from "./NotificationsContext";
2
2
  export interface NotificationsCenterProps {
3
3
  notificationsContext: NotificationsContextProps;
4
4
  startupToastNotification?: ToastNotificationDescriptor;
@@ -10,6 +10,18 @@ export declare const NotificationType: {
10
10
  export type NotificationType = ValueOf<typeof NotificationType>;
11
11
  export type DismissalStyle = "automatic" | "manual";
12
12
  export type NotificationAnimationType = "slide-in" | "slide-out" | "slide-in,slide-out";
13
+ /**
14
+ * Where a notification was raised. Filled in by `useNotifications` from the
15
+ * nearest `NotificationOriginProvider`, e.g. the portal module rendering the
16
+ * caller.
17
+ */
18
+ export interface NotificationOrigin {
19
+ moduleId?: string | number;
20
+ /** The Vuu server the module uses. */
21
+ connectionId?: string;
22
+ /** The module hosting the origin's module, for nested modules. */
23
+ parentModuleId?: string | number;
24
+ }
13
25
  interface NotificationDescriptorBase<T extends NotificationType> {
14
26
  animationType?: NotificationAnimationType;
15
27
  /**
@@ -23,6 +35,13 @@ interface NotificationDescriptorBase<T extends NotificationType> {
23
35
  * Default icons will be rendered for the different status values.
24
36
  */
25
37
  icon?: string | false;
38
+ /** Set automatically by `useNotifications`, when a provider gives one. */
39
+ origin?: NotificationOrigin;
40
+ /**
41
+ * Whether a host that keeps a notification history (e.g. a portal) should
42
+ * record this notification. Hosts choose the default.
43
+ */
44
+ record?: boolean;
26
45
  renderPostRefresh?: boolean;
27
46
  showCloseButton?: boolean;
28
47
  status: ValidationStatus;
@@ -39,6 +58,12 @@ export interface WorkspaceNotificationDescriptor extends NotificationDescriptorB
39
58
  export type Notification = ToastNotificationDescriptor | WorkspaceNotificationDescriptor;
40
59
  export declare const isToastNotification: (n: Notification) => n is ToastNotificationDescriptor;
41
60
  export declare const isWorkspaceNotification: (n: Notification) => n is WorkspaceNotificationDescriptor;
61
+ /**
62
+ * Decides whether a notification is shown. Given to the outermost
63
+ * `NotificationsProvider`, it sees every notification raised beneath it,
64
+ * including those raised inside nested providers.
65
+ */
66
+ export type NotificationInterceptor = (notification: Notification) => "present" | "suppress";
42
67
  export type NotificationsContextProps = {
43
68
  hideNotification: DispatchHideNotification;
44
69
  showNotification: DispatchShowNotification;
@@ -1,8 +1,29 @@
1
- import React, { type ReactElement } from "react";
2
- import type { DispatchHideNotification, DispatchShowNotification } from "./NotificationsContext";
3
- export declare const NotificationsProvider: (props: {
4
- children: ReactElement | ReactElement[];
5
- }) => React.JSX.Element;
1
+ import { type ReactNode } from "react";
2
+ import type { DispatchHideNotification, DispatchShowNotification, NotificationInterceptor, NotificationOrigin } from "./NotificationsContext";
3
+ export interface NotificationsProviderProps {
4
+ children?: ReactNode;
5
+ /**
6
+ * Sees every notification raised beneath the provider before it is shown.
7
+ * Only used by the outermost provider.
8
+ */
9
+ interceptor?: NotificationInterceptor;
10
+ }
11
+ /**
12
+ * Shows the notifications raised beneath it. Inside another
13
+ * `NotificationsProvider` it renders only its children, so notifications
14
+ * from nested applications are shown, once, by the outermost provider.
15
+ */
16
+ export declare const NotificationsProvider: ({ children, interceptor, }: NotificationsProviderProps) => string | number | bigint | boolean | import("react").JSX.Element | Iterable<ReactNode> | Promise<string | number | bigint | boolean | Iterable<ReactNode> | import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>> | import("react").ReactPortal | null | undefined> | null | undefined;
17
+ /**
18
+ * Tags notifications raised beneath it with their origin. A nested provider
19
+ * records the enclosing origin's module as its `parentModuleId`.
20
+ */
21
+ export declare const NotificationOriginProvider: ({ children, connectionId, moduleId, }: {
22
+ children?: ReactNode;
23
+ connectionId?: string;
24
+ moduleId?: string | number;
25
+ }) => import("react").JSX.Element;
26
+ export declare const useNotificationOrigin: () => NotificationOrigin | undefined;
6
27
  export declare const useNotifications: () => {
7
28
  hideNotification: DispatchHideNotification;
8
29
  showNotification: DispatchShowNotification;
package/types/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
- export { NotificationType, type Notification, type NotificationAnimationType, } from "./NotificationsContext";
2
- export { NotificationsProvider, useNotifications, } from "./NotificationsProvider";
1
+ export { NotificationType, type Notification, type NotificationAnimationType, type NotificationInterceptor, type NotificationOrigin, type ToastNotificationDescriptor, type WorkspaceNotificationDescriptor, isToastNotification, isWorkspaceNotification, } from "./NotificationsContext";
2
+ export { NotificationOriginProvider, NotificationsProvider, type NotificationsProviderProps, useNotificationOrigin, useNotifications, } from "./NotificationsProvider";
3
3
  export { ToastNotification, type ToastNotificationProps, } from "./ToastNotification";
4
+ export { type ServerNotificationSource, useServerNotifications, } from "./useServerNotifications";
@@ -0,0 +1,16 @@
1
+ import type { ShowNotificationAction } from "@vuu-ui/vuu-protocol-types";
2
+ type ServerNotificationListener = (action: ShowNotificationAction) => void;
3
+ /**
4
+ * Emits the notifications the server attaches to RPC responses. The Vuu
5
+ * ConnectionManager is one.
6
+ */
7
+ export interface ServerNotificationSource {
8
+ on(event: "server-notification", listener: ServerNotificationListener): void;
9
+ removeListener(event: "server-notification", listener: ServerNotificationListener): void;
10
+ }
11
+ /**
12
+ * Shows, as toasts, the notifications the server attaches to RPC responses,
13
+ * such as its report that a request failed. Use it once per application.
14
+ */
15
+ export declare const useServerNotifications: (source: ServerNotificationSource) => void;
16
+ export {};