@unitedstatespowersquadrons/components 1.3.14 → 1.4.0-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.
@@ -4,7 +4,8 @@
4
4
  "Bash(yarn run test*)",
5
5
  "Bash(yarn run vitest*)",
6
6
  "Bash(yarn run tsc*)",
7
- "Bash(yarn run eslint*)"
7
+ "Bash(yarn run eslint*)",
8
+ "Bash(git commit -m ' *)"
8
9
  ]
9
10
  }
10
11
  }
@@ -0,0 +1,101 @@
1
+ import React from "react";
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { act, cleanup, render, screen } from "@testing-library/react";
4
+ import GlobalToasts from "./GlobalToasts";
5
+ import { toastStore } from "./globalStore";
6
+ import { Toast_Status } from "./types";
7
+
8
+ interface ReceivedHandler {
9
+ (data: { toasts?: Array<Record<string, unknown>>; dismissToast?: number }): void;
10
+ }
11
+
12
+ const subscriptionStub = {
13
+ send: vi.fn(),
14
+ unsubscribe: vi.fn(),
15
+ };
16
+ let lastReceived: ReceivedHandler | null = null;
17
+ const createSpy = vi.fn((_channel: { channel: string }, handlers: { received: ReceivedHandler }) => {
18
+ lastReceived = handlers.received;
19
+ return subscriptionStub;
20
+ });
21
+
22
+ vi.mock("@rails/actioncable", () => ({
23
+ createConsumer: () => ({
24
+ subscriptions: { create: createSpy },
25
+ }),
26
+ }));
27
+
28
+ beforeEach(() => {
29
+ toastStore.clear();
30
+ createSpy.mockClear();
31
+ subscriptionStub.send.mockClear();
32
+ subscriptionStub.unsubscribe.mockClear();
33
+ lastReceived = null;
34
+ });
35
+
36
+ afterEach(() => {
37
+ cleanup();
38
+ });
39
+
40
+ describe("<GlobalToasts />", () => {
41
+ it("subscribes exactly once to BroadcastToastChannel on mount", () => {
42
+ render(<GlobalToasts />);
43
+ expect(createSpy).toHaveBeenCalledTimes(1);
44
+ expect(createSpy.mock.calls[0]?.[0]).toEqual({ channel: "BroadcastToastChannel" });
45
+ });
46
+
47
+ it("unsubscribes on unmount", () => {
48
+ const { unmount } = render(<GlobalToasts />);
49
+ unmount();
50
+ expect(subscriptionStub.unsubscribe).toHaveBeenCalledOnce();
51
+ });
52
+
53
+ it("renders broadcasts received from the channel with broadcast styling above local toasts", () => {
54
+ toastStore.add({
55
+ body: "local body",
56
+ status: Toast_Status.SUCCESS,
57
+ timeout: 0,
58
+ title: "Local title",
59
+ });
60
+
61
+ render(<GlobalToasts />);
62
+
63
+ act(() => {
64
+ lastReceived!({
65
+ toasts: [{
66
+ body: "broadcast body",
67
+ status: Toast_Status.NOTICE,
68
+ timeout: 0,
69
+ title: "Broadcast title",
70
+ }],
71
+ });
72
+ });
73
+
74
+ const titles = screen.getAllByText(/title$/i).map(el => el.textContent);
75
+ expect(titles[0]).toBe("Broadcast title");
76
+ expect(titles[1]).toBe("Local title");
77
+ expect(screen.getByText("Broadcast")).toBeInTheDocument();
78
+ });
79
+
80
+ it("removes a toast when the channel sends dismissToast", () => {
81
+ render(<GlobalToasts />);
82
+
83
+ act(() => {
84
+ lastReceived!({
85
+ toasts: [{
86
+ body: "B",
87
+ id: 42,
88
+ status: Toast_Status.NOTICE,
89
+ timeout: 0,
90
+ title: "Going",
91
+ }],
92
+ });
93
+ });
94
+ expect(screen.getByText("Going")).toBeInTheDocument();
95
+
96
+ act(() => {
97
+ lastReceived!({ dismissToast: 42 });
98
+ });
99
+ expect(screen.queryByText("Going")).not.toBeInTheDocument();
100
+ });
101
+ });
@@ -0,0 +1,58 @@
1
+ import React, { useEffect, useSyncExternalStore } from "react";
2
+ import Toasts from "./Toasts";
3
+ import { toastStore } from "./globalStore";
4
+ import { dismissToastGlobal } from "./globalHelpers";
5
+ import { broadcastSubscriptionRef, getConsumer } from "./cableConsumer";
6
+ import { Toast, ToastProps } from "./types";
7
+
8
+ interface Props {
9
+ allowedDomains?: string[];
10
+ }
11
+
12
+ interface BroadcastPayload {
13
+ toasts?: Toast[];
14
+ dismissToast?: number;
15
+ }
16
+
17
+ const sortBroadcastsFirst = (toasts: readonly ToastProps[]): ToastProps[] => (
18
+ [...toasts].sort((a, b) => (
19
+ Number(b.kind === "broadcast") - Number(a.kind === "broadcast")
20
+ ))
21
+ );
22
+
23
+ const GlobalToasts = ({ allowedDomains }: Props) => {
24
+ const toasts = useSyncExternalStore(toastStore.subscribe, toastStore.getSnapshot, toastStore.getSnapshot);
25
+
26
+ useEffect(() => {
27
+ const consumer = getConsumer();
28
+ const sub = consumer.subscriptions.create(
29
+ { channel: "BroadcastToastChannel" },
30
+ {
31
+ received: (data: BroadcastPayload) => {
32
+ if (data.toasts) {
33
+ data.toasts.forEach(t => toastStore.add({ ...t, kind: "broadcast" }));
34
+ }
35
+ if (typeof data.dismissToast === "number") {
36
+ toastStore.remove(data.dismissToast);
37
+ }
38
+ },
39
+ }
40
+ );
41
+ broadcastSubscriptionRef.current = sub;
42
+
43
+ return () => {
44
+ sub.unsubscribe();
45
+ if (broadcastSubscriptionRef.current === sub) {
46
+ broadcastSubscriptionRef.current = null;
47
+ }
48
+ };
49
+ }, []);
50
+
51
+ const sorted = sortBroadcastsFirst(toasts);
52
+
53
+ return allowedDomains
54
+ ? <Toasts allowedDomains={allowedDomains} dismiss={dismissToastGlobal} toasts={sorted} />
55
+ : <Toasts dismiss={dismissToastGlobal} toasts={sorted} />;
56
+ };
57
+
58
+ export default GlobalToasts;
package/Toasts/Toast.tsx CHANGED
@@ -22,6 +22,20 @@ const isAllowedUrl = (url: string, allowedDomains?: string[]): boolean => {
22
22
  };
23
23
 
24
24
  const useStyles = createUseStyles({
25
+ broadcast: {
26
+ borderLeft: "4px solid #7A1FA2",
27
+ },
28
+ broadcastTag: {
29
+ backgroundColor: "#7A1FA2",
30
+ borderRadius: "0.25em",
31
+ color: "#FFFFFF",
32
+ fontSize: "0.75em",
33
+ fontWeight: "bold",
34
+ letterSpacing: "0.05em",
35
+ marginRight: "0.5em",
36
+ padding: "0.1em 0.4em",
37
+ textTransform: "uppercase",
38
+ },
25
39
  dismissed: { opacity: "0" },
26
40
  error: {
27
41
  borderColor: "#CC0000",
@@ -84,7 +98,8 @@ const Toast = (props: Props) => {
84
98
  const classes = useStyles();
85
99
 
86
100
  const { allowedDomains, dismiss, toast } = props;
87
- const { body, dismissed, id, status, timeout, title } = toast;
101
+ const { body, dismissed, id, kind, status, timeout, title } = toast;
102
+ const isBroadcast = kind === "broadcast";
88
103
 
89
104
  const handleDismiss = () => dismiss(id);
90
105
 
@@ -140,9 +155,13 @@ const Toast = (props: Props) => {
140
155
  const opacityClass = dismissed ? classes.dismissed : classes.visible;
141
156
 
142
157
  return (
143
- <div className={classNames(classes.toast, statusClassName, opacityClass)} key={`toast-${id}`}>
158
+ <div
159
+ className={classNames(classes.toast, statusClassName, opacityClass, isBroadcast && classes.broadcast)}
160
+ key={`toast-${id}`}
161
+ >
144
162
  <div className={classes.toastTitle}>
145
163
  <FontAwesomeIcon fa="fw" icon={icon} mode="duotone" />
164
+ {isBroadcast && <span className={classes.broadcastTag}>Broadcast</span>}
146
165
  <span>{title}</span>
147
166
  </div>
148
167
  <div className={classes.toastBody}>{renderBody()}</div>
package/Toasts/Toasts.tsx CHANGED
@@ -54,9 +54,13 @@ const Toasts = (props: Props) => {
54
54
  }
55
55
  };
56
56
 
57
+ const sorted = toasts && [...toasts].sort((a, b) => (
58
+ Number(b.kind === "broadcast") - Number(a.kind === "broadcast")
59
+ ));
60
+
57
61
  return (
58
62
  <div className={classNames("toastsContainer", classes.toastsContainer)}>
59
- {toasts?.map(toast => (
63
+ {sorted?.map(toast => (
60
64
  <Toast allowedDomains={allowedDomains} dismiss={dismissFunc()} key={toast.id} toast={toast} />
61
65
  ))}
62
66
  </div>
@@ -0,0 +1,10 @@
1
+ import { Consumer, createConsumer, Subscription } from "@rails/actioncable";
2
+
3
+ let consumer: Consumer | null = null;
4
+
5
+ export const getConsumer = (): Consumer => {
6
+ if (!consumer) consumer = createConsumer();
7
+ return consumer;
8
+ };
9
+
10
+ export const broadcastSubscriptionRef: { current: Subscription | null } = { current: null };
@@ -0,0 +1,51 @@
1
+ import { beforeEach, describe, expect, it } from "vitest";
2
+ import { dismissToastGlobal, handleToastsGlobal, pushToast, removeToastGlobal } from "./globalHelpers";
3
+ import { toastStore } from "./globalStore";
4
+ import { Toast_Status } from "./types";
5
+
6
+ const baseToast = {
7
+ body: "Body",
8
+ status: Toast_Status.SUCCESS,
9
+ timeout: 0,
10
+ title: "Title",
11
+ };
12
+
13
+ beforeEach(() => { toastStore.clear(); });
14
+
15
+ describe("pushToast", () => {
16
+ it("delegates to the singleton and returns the id", () => {
17
+ const id = pushToast(baseToast);
18
+ expect(toastStore.getSnapshot()[0]?.id).toBe(id);
19
+ });
20
+ });
21
+
22
+ describe("handleToastsGlobal", () => {
23
+ it("adds each toast to the singleton", () => {
24
+ handleToastsGlobal([
25
+ baseToast,
26
+ { ...baseToast, body: "Two" },
27
+ ]);
28
+ expect(toastStore.getSnapshot()).toHaveLength(2);
29
+ });
30
+
31
+ it("does nothing for an empty array", () => {
32
+ handleToastsGlobal([]);
33
+ expect(toastStore.getSnapshot()).toHaveLength(0);
34
+ });
35
+ });
36
+
37
+ describe("removeToastGlobal", () => {
38
+ it("removes a toast by id", () => {
39
+ const id = pushToast(baseToast);
40
+ removeToastGlobal(id);
41
+ expect(toastStore.getSnapshot()).toHaveLength(0);
42
+ });
43
+ });
44
+
45
+ describe("dismissToastGlobal", () => {
46
+ it("marks the toast dismissed", () => {
47
+ const id = pushToast(baseToast);
48
+ dismissToastGlobal(id);
49
+ expect(toastStore.getSnapshot()[0]?.dismissed).toBe(true);
50
+ });
51
+ });
@@ -0,0 +1,12 @@
1
+ import { toastStore } from "./globalStore";
2
+ import { Toast } from "./types";
3
+
4
+ export const pushToast = (toast: Toast): number => toastStore.add(toast);
5
+
6
+ export const handleToastsGlobal = (toasts: Toast[]): void => {
7
+ toasts.forEach(t => toastStore.add(t));
8
+ };
9
+
10
+ export const dismissToastGlobal = (id: number): void => toastStore.dismiss(id);
11
+
12
+ export const removeToastGlobal = (id: number): void => toastStore.remove(id);
@@ -0,0 +1,124 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { toastStore } from "./globalStore";
3
+ import { Toast_Status } from "./types";
4
+
5
+ const baseToast = {
6
+ body: "Body",
7
+ status: Toast_Status.SUCCESS,
8
+ timeout: 0,
9
+ title: "Title",
10
+ };
11
+
12
+ beforeEach(() => {
13
+ toastStore.clear();
14
+ });
15
+
16
+ describe("toastStore.add", () => {
17
+ it("adds a toast and emits", () => {
18
+ const listener = vi.fn();
19
+ toastStore.subscribe(listener);
20
+
21
+ const id = toastStore.add(baseToast);
22
+
23
+ expect(toastStore.getSnapshot()).toHaveLength(1);
24
+ expect(toastStore.getSnapshot()[0]?.id).toBe(id);
25
+ expect(toastStore.getSnapshot()[0]?.kind).toBe("local");
26
+ expect(listener).toHaveBeenCalledOnce();
27
+ });
28
+
29
+ it("assigns auto-incrementing ids", () => {
30
+ const a = toastStore.add(baseToast);
31
+ const b = toastStore.add({ ...baseToast, body: "Other" });
32
+
33
+ expect(b).toBe(a + 1);
34
+ });
35
+
36
+ it("respects an explicit id and advances nextId past it", () => {
37
+ toastStore.add({ ...baseToast, id: 50 });
38
+ const next = toastStore.add({ ...baseToast, body: "Next" });
39
+
40
+ expect(next).toBe(51);
41
+ });
42
+
43
+ it("stores broadcast kind when supplied", () => {
44
+ toastStore.add({ ...baseToast, kind: "broadcast" });
45
+ expect(toastStore.getSnapshot()[0]?.kind).toBe("broadcast");
46
+ });
47
+
48
+ it("dedupes identical toasts (body+status+title)", () => {
49
+ const a = toastStore.add(baseToast);
50
+ const b = toastStore.add(baseToast);
51
+
52
+ expect(toastStore.getSnapshot()).toHaveLength(1);
53
+ expect(b).toBe(a);
54
+ });
55
+
56
+ it("returns a new array reference on mutation", () => {
57
+ const before = toastStore.getSnapshot();
58
+ toastStore.add(baseToast);
59
+ const after = toastStore.getSnapshot();
60
+
61
+ expect(after).not.toBe(before);
62
+ });
63
+ });
64
+
65
+ describe("toastStore.dismiss", () => {
66
+ beforeEach(() => { vi.useFakeTimers(); });
67
+ afterEach(() => { vi.useRealTimers(); });
68
+
69
+ it("flags the toast dismissed and removes it after 2000ms", () => {
70
+ const id = toastStore.add(baseToast);
71
+
72
+ toastStore.dismiss(id);
73
+ expect(toastStore.getSnapshot()[0]?.dismissed).toBe(true);
74
+ expect(toastStore.getSnapshot()).toHaveLength(1);
75
+
76
+ vi.advanceTimersByTime(2000);
77
+ expect(toastStore.getSnapshot()).toHaveLength(0);
78
+ });
79
+
80
+ it("is a no-op for unknown ids", () => {
81
+ toastStore.dismiss(999);
82
+ expect(toastStore.getSnapshot()).toHaveLength(0);
83
+ });
84
+ });
85
+
86
+ describe("toastStore.remove", () => {
87
+ it("removes a toast by id and cancels any pending dismiss timer", () => {
88
+ vi.useFakeTimers();
89
+ const listener = vi.fn();
90
+ const id = toastStore.add(baseToast);
91
+ toastStore.dismiss(id);
92
+
93
+ toastStore.subscribe(listener);
94
+ toastStore.remove(id);
95
+ expect(toastStore.getSnapshot()).toHaveLength(0);
96
+
97
+ listener.mockClear();
98
+ vi.advanceTimersByTime(2000);
99
+ expect(listener).not.toHaveBeenCalled();
100
+ vi.useRealTimers();
101
+ });
102
+
103
+ it("is a no-op for unknown ids and does not emit", () => {
104
+ const listener = vi.fn();
105
+ toastStore.subscribe(listener);
106
+
107
+ toastStore.remove(999);
108
+ expect(listener).not.toHaveBeenCalled();
109
+ });
110
+ });
111
+
112
+ describe("toastStore.subscribe", () => {
113
+ it("returns an unsubscribe function", () => {
114
+ const listener = vi.fn();
115
+ const unsubscribe = toastStore.subscribe(listener);
116
+
117
+ toastStore.add(baseToast);
118
+ expect(listener).toHaveBeenCalledOnce();
119
+
120
+ unsubscribe();
121
+ toastStore.add({ ...baseToast, body: "Two" });
122
+ expect(listener).toHaveBeenCalledOnce();
123
+ });
124
+ });
@@ -0,0 +1,86 @@
1
+ import { Toast, ToastKind, ToastProps } from "./types";
2
+
3
+ type Listener = () => void;
4
+
5
+ let toasts: ToastProps[] = [];
6
+ let nextId = 1;
7
+ const listeners = new Set<Listener>();
8
+ const pendingRemovals = new Map<number, ReturnType<typeof setTimeout>>();
9
+
10
+ const emit = () => listeners.forEach(l => l());
11
+
12
+ const isDuplicate = (toast: Toast) => toasts.some(t => (
13
+ t.body === toast.body && t.status === toast.status && t.title === toast.title
14
+ ));
15
+
16
+ const add = (toast: Toast & { kind?: ToastKind }): number => {
17
+ if (isDuplicate(toast)) {
18
+ const existing = toasts.find(t => (
19
+ t.body === toast.body && t.status === toast.status && t.title === toast.title
20
+ ));
21
+ return existing!.id;
22
+ }
23
+
24
+ const id = toast.id ?? nextId++;
25
+ if (toast.id && toast.id >= nextId) nextId = toast.id + 1;
26
+
27
+ const next: ToastProps = {
28
+ body: toast.body,
29
+ id,
30
+ kind: toast.kind ?? "local",
31
+ status: toast.status,
32
+ timeout: toast.timeout,
33
+ title: toast.title,
34
+ };
35
+
36
+ toasts = [...toasts, next];
37
+ emit();
38
+ return id;
39
+ };
40
+
41
+ const dismiss = (id: number): void => {
42
+ const idx = toasts.findIndex(t => t.id === id);
43
+ if (idx < 0) return;
44
+
45
+ toasts = toasts.map((t, i) => (i === idx ? { ...t, dismissed: true } : t));
46
+ emit();
47
+
48
+ if (pendingRemovals.has(id)) return;
49
+
50
+ const timer = setTimeout(() => {
51
+ pendingRemovals.delete(id);
52
+ remove(id);
53
+ }, 2000);
54
+ pendingRemovals.set(id, timer);
55
+ };
56
+
57
+ const remove = (id: number): void => {
58
+ const timer = pendingRemovals.get(id);
59
+ if (timer) {
60
+ clearTimeout(timer);
61
+ pendingRemovals.delete(id);
62
+ }
63
+
64
+ const next = toasts.filter(t => t.id !== id);
65
+ if (next.length === toasts.length) return;
66
+
67
+ toasts = next;
68
+ emit();
69
+ };
70
+
71
+ const clear = (): void => {
72
+ pendingRemovals.forEach(t => clearTimeout(t));
73
+ pendingRemovals.clear();
74
+ toasts = [];
75
+ nextId = 1;
76
+ emit();
77
+ };
78
+
79
+ const subscribe = (listener: Listener): (() => void) => {
80
+ listeners.add(listener);
81
+ return () => { listeners.delete(listener); };
82
+ };
83
+
84
+ const getSnapshot = (): readonly ToastProps[] => toasts;
85
+
86
+ export const toastStore = { add, clear, dismiss, getSnapshot, remove, subscribe };
package/Toasts/index.tsx CHANGED
@@ -1,5 +1,9 @@
1
1
  export { default as Toasts } from "./Toasts";
2
+ export { default as GlobalToasts } from "./GlobalToasts";
2
3
  export * from "./toastHelpers";
4
+ export * from "./globalHelpers";
5
+ export { useBroadcastChannel } from "./useBroadcastChannel";
6
+ export type { BroadcastChannelHandle } from "./useBroadcastChannel";
3
7
 
4
8
  export * from "./types";
5
9
  export type * from "./types";
package/Toasts/types.ts CHANGED
@@ -11,6 +11,8 @@ export type ToastAppAction =
11
11
  | { type: "addToast" } & Toast
12
12
  | ToastRemoveAppAction;
13
13
 
14
+ export type ToastKind = "local" | "broadcast";
15
+
14
16
  export interface Toast {
15
17
  id?: number;
16
18
  status: Toast_Status;
@@ -18,6 +20,7 @@ export interface Toast {
18
20
  body: string;
19
21
  timeout: number;
20
22
  toastId?: number | undefined;
23
+ kind?: ToastKind;
21
24
  }
22
25
 
23
26
  export enum Toast_Status {
@@ -0,0 +1,35 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { useBroadcastChannel } from "./useBroadcastChannel";
3
+ import { broadcastSubscriptionRef } from "./cableConsumer";
4
+
5
+ describe("useBroadcastChannel", () => {
6
+ let warnSpy: ReturnType<typeof vi.spyOn>;
7
+
8
+ beforeEach(() => {
9
+ broadcastSubscriptionRef.current = null;
10
+ warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
11
+ });
12
+
13
+ afterEach(() => {
14
+ broadcastSubscriptionRef.current = null;
15
+ warnSpy.mockRestore();
16
+ });
17
+
18
+ it("delegates send to the active subscription", () => {
19
+ const send = vi.fn();
20
+ broadcastSubscriptionRef.current = { send } as unknown as typeof broadcastSubscriptionRef.current;
21
+
22
+ const { send: handleSend } = useBroadcastChannel();
23
+ handleSend({ hello: "world" });
24
+
25
+ expect(send).toHaveBeenCalledWith({ hello: "world" });
26
+ expect(warnSpy).not.toHaveBeenCalled();
27
+ });
28
+
29
+ it("warns and is a no-op when no subscription is mounted", () => {
30
+ const { send } = useBroadcastChannel();
31
+ send({ hello: "world" });
32
+
33
+ expect(warnSpy).toHaveBeenCalledOnce();
34
+ });
35
+ });
@@ -0,0 +1,17 @@
1
+ import { broadcastSubscriptionRef } from "./cableConsumer";
2
+
3
+ export interface BroadcastChannelHandle {
4
+ send: (data: object) => void;
5
+ }
6
+
7
+ export const useBroadcastChannel = (): BroadcastChannelHandle => ({
8
+ send: (data: object) => {
9
+ const sub = broadcastSubscriptionRef.current;
10
+ if (!sub) {
11
+ // eslint-disable-next-line no-console
12
+ console.warn("useBroadcastChannel: <GlobalToasts /> is not mounted; broadcast send ignored");
13
+ return;
14
+ }
15
+ sub.send(data);
16
+ },
17
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unitedstatespowersquadrons/components",
3
- "version": "1.3.14",
3
+ "version": "1.4.0-2",
4
4
  "description": "USPS shared React components library",
5
5
  "main": "index.tsx",
6
6
  "scripts": {