@unitedstatespowersquadrons/components 1.3.4 → 1.3.6-1
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/.claude/settings.local.json +10 -0
- package/.github/workflows/main.yml +9 -0
- package/Badges/helpers.test.ts +31 -0
- package/Pagination.test.tsx +78 -0
- package/RailsForm.tsx +2 -2
- package/Reducer/buildReducer.tsx +6 -6
- package/Reducer/buildUnreducibleState.tsx +1 -1
- package/Reducer/wrapDispatch.test.ts +172 -0
- package/Reducer/wrapDispatch.tsx +1 -1
- package/Toasts/Toast.tsx +20 -4
- package/Toasts/Toasts.tsx +5 -2
- package/Toasts/index.tsx +0 -3
- package/Toasts/toastHelpers.test.ts +175 -0
- package/eslint.config.mjs +7 -1
- package/mount.test.tsx +110 -0
- package/mount.tsx +18 -18
- package/package.json +11 -5
- package/railsFetchJson.test.ts +137 -0
- package/railsFetchJson.tsx +6 -4
- package/tsconfig.json +5 -1
- package/types.ts +1 -1
- package/vitest.config.ts +9 -0
- package/vitest.setup.ts +1 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { badgeify, versionColor } from "./helpers";
|
|
3
|
+
|
|
4
|
+
describe("badgeify", () => {
|
|
5
|
+
it("escapes hyphens", () => {
|
|
6
|
+
expect(badgeify("my-text")).toBe("my--text");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("escapes underscores", () => {
|
|
10
|
+
expect(badgeify("my_text")).toBe("my__text");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("replaces spaces with underscores", () => {
|
|
14
|
+
expect(badgeify("my text")).toBe("my_text");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("returns undefined for undefined input", () => {
|
|
18
|
+
expect(badgeify(undefined)).toBeUndefined();
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("versionColor", () => {
|
|
23
|
+
it("returns orange for v0.x versions", () => {
|
|
24
|
+
expect(versionColor("v0.1.0")).toBe("orange");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("returns blue for v1+ versions", () => {
|
|
28
|
+
expect(versionColor("v1.0.0")).toBe("blue");
|
|
29
|
+
expect(versionColor("v2.3.1")).toBe("blue");
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { render, screen } from "@testing-library/react";
|
|
4
|
+
import Pagination from "./Pagination";
|
|
5
|
+
|
|
6
|
+
describe("Pagination", () => {
|
|
7
|
+
it("renders the current page number", () => {
|
|
8
|
+
render(<Pagination maxPage={5} pageNumber={3} path="/items" />);
|
|
9
|
+
|
|
10
|
+
expect(screen.getByText("3")).toBeInTheDocument();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("renders a label when provided", () => {
|
|
14
|
+
render(<Pagination label="Page" maxPage={5} pageNumber={2} path="/items" />);
|
|
15
|
+
|
|
16
|
+
expect(screen.getByText("Page")).toBeInTheDocument();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("shows all navigation buttons on a middle page", () => {
|
|
20
|
+
render(<Pagination maxPage={5} pageNumber={3} path="/items" />);
|
|
21
|
+
|
|
22
|
+
expect(screen.getByTitle("First Page")).toBeInTheDocument();
|
|
23
|
+
expect(screen.getByTitle("Previous Page")).toBeInTheDocument();
|
|
24
|
+
expect(screen.getByTitle("Next Page")).toBeInTheDocument();
|
|
25
|
+
expect(screen.getByTitle("Last Page")).toBeInTheDocument();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("hides First and Previous on page 1", () => {
|
|
29
|
+
render(<Pagination maxPage={5} pageNumber={1} path="/items" />);
|
|
30
|
+
|
|
31
|
+
expect(screen.queryByTitle("First Page")).not.toBeInTheDocument();
|
|
32
|
+
expect(screen.queryByTitle("Previous Page")).not.toBeInTheDocument();
|
|
33
|
+
expect(screen.getByTitle("Next Page")).toBeInTheDocument();
|
|
34
|
+
expect(screen.getByTitle("Last Page")).toBeInTheDocument();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("hides Next and Last on the last page", () => {
|
|
38
|
+
render(<Pagination maxPage={5} pageNumber={5} path="/items" />);
|
|
39
|
+
|
|
40
|
+
expect(screen.getByTitle("First Page")).toBeInTheDocument();
|
|
41
|
+
expect(screen.getByTitle("Previous Page")).toBeInTheDocument();
|
|
42
|
+
expect(screen.queryByTitle("Next Page")).not.toBeInTheDocument();
|
|
43
|
+
expect(screen.queryByTitle("Last Page")).not.toBeInTheDocument();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("hides First when on page 2 (only one page back)", () => {
|
|
47
|
+
render(<Pagination maxPage={5} pageNumber={2} path="/items" />);
|
|
48
|
+
|
|
49
|
+
expect(screen.queryByTitle("First Page")).not.toBeInTheDocument();
|
|
50
|
+
expect(screen.getByTitle("Previous Page")).toBeInTheDocument();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("hides Last when on penultimate page", () => {
|
|
54
|
+
render(<Pagination maxPage={5} pageNumber={4} path="/items" />);
|
|
55
|
+
|
|
56
|
+
expect(screen.getByTitle("Next Page")).toBeInTheDocument();
|
|
57
|
+
expect(screen.queryByTitle("Last Page")).not.toBeInTheDocument();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("constructs correct hrefs with params", () => {
|
|
61
|
+
render(<Pagination maxPage={3} pageNumber={2} params={{ filter: "active" }} path="/items" />);
|
|
62
|
+
|
|
63
|
+
const nextLink = screen.getByTitle("Next Page").closest("a");
|
|
64
|
+
expect(nextLink?.getAttribute("href")).toContain("page=3");
|
|
65
|
+
expect(nextLink?.getAttribute("href")).toContain("filter=active");
|
|
66
|
+
expect(nextLink?.getAttribute("href")).toMatch(/^\/items\?/);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("renders nothing extra for a single page", () => {
|
|
70
|
+
render(<Pagination maxPage={1} pageNumber={1} path="/items" />);
|
|
71
|
+
|
|
72
|
+
expect(screen.queryByTitle("First Page")).not.toBeInTheDocument();
|
|
73
|
+
expect(screen.queryByTitle("Previous Page")).not.toBeInTheDocument();
|
|
74
|
+
expect(screen.queryByTitle("Next Page")).not.toBeInTheDocument();
|
|
75
|
+
expect(screen.queryByTitle("Last Page")).not.toBeInTheDocument();
|
|
76
|
+
expect(screen.getByText("1")).toBeInTheDocument();
|
|
77
|
+
});
|
|
78
|
+
});
|
package/RailsForm.tsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
+
import { getCsrfTokenHeader } from "./railsFetchJson";
|
|
2
3
|
|
|
3
4
|
interface Props {
|
|
4
5
|
action: string;
|
|
@@ -11,8 +12,7 @@ interface Props {
|
|
|
11
12
|
const RailsForm = (props: Props) => {
|
|
12
13
|
const { action, children, fileUpload, id, method } = props;
|
|
13
14
|
|
|
14
|
-
const
|
|
15
|
-
const csrfToken = token ? token.getAttribute("content")! : "";
|
|
15
|
+
const csrfToken = getCsrfTokenHeader();
|
|
16
16
|
|
|
17
17
|
const formMethod = method === "get" ? "get" : "post";
|
|
18
18
|
|
package/Reducer/buildReducer.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import React, { createContext, useContext, useEffect, useMemo, useRef } from "react";
|
|
2
2
|
import { Draft, produce } from "immer";
|
|
3
3
|
import { useImmerReducer } from "use-immer";
|
|
4
|
-
import {
|
|
4
|
+
import { Consumer, createConsumer, Subscription } from "@rails/actioncable";
|
|
5
5
|
import { CableAction, DispatchFunc, ReducerHandler } from "./types";
|
|
6
6
|
|
|
7
7
|
interface InitialAppProps<AppState> {
|
|
@@ -9,17 +9,17 @@ interface InitialAppProps<AppState> {
|
|
|
9
9
|
children: React.ReactNode;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
export default function buildReducer<AppState, AppAction>(
|
|
12
|
+
export default function buildReducer<AppState extends object, AppAction>(
|
|
13
13
|
reducer: (draft: Draft<AppState>, action: AppAction) => void,
|
|
14
14
|
handlers: ReducerHandler<AppAction>[] = []
|
|
15
15
|
) {
|
|
16
16
|
const StateContext = createContext<AppState | null>(null);
|
|
17
17
|
const DispatchContext = createContext<React.Dispatch<AppAction> | null>(null);
|
|
18
|
-
const CableContext = createContext<
|
|
19
|
-
|
|
20
|
-
const cable = createConsumer();
|
|
18
|
+
const CableContext = createContext<Consumer | null>(null);
|
|
21
19
|
|
|
22
20
|
function AppProvider(props: InitialAppProps<AppState>) {
|
|
21
|
+
const cable = useMemo(() => createConsumer(), []);
|
|
22
|
+
|
|
23
23
|
const [providerState, dispatch] = useImmerReducer(reducer, props.initialState, (state: AppState) => {
|
|
24
24
|
return produce(state, draft => draft);
|
|
25
25
|
});
|
|
@@ -61,7 +61,7 @@ export default function buildReducer<AppState, AppAction>(
|
|
|
61
61
|
|
|
62
62
|
function useAppCableSubscription(dispatch: DispatchFunc<CableAction>, channel: string) {
|
|
63
63
|
const appCable = useAppCable();
|
|
64
|
-
const channelRef = useRef<
|
|
64
|
+
const channelRef = useRef<Subscription>(null);
|
|
65
65
|
|
|
66
66
|
useEffect(() => {
|
|
67
67
|
channelRef.current = appCable.subscriptions.create(
|
|
@@ -5,7 +5,7 @@ interface InitialAppProps<AppState> {
|
|
|
5
5
|
children: React.ReactNode;
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
-
export default function buildUnreducibleState<AppState>() {
|
|
8
|
+
export default function buildUnreducibleState<AppState extends object>() {
|
|
9
9
|
const StateContext = createContext<AppState | null>(null);
|
|
10
10
|
|
|
11
11
|
const AppProvider = (props: InitialAppProps<AppState>) => {
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import wrapDispatch from "./wrapDispatch";
|
|
3
|
+
import { DispatchHandler, RailsResponse } from "./types";
|
|
4
|
+
|
|
5
|
+
vi.mock("../railsFetchJson", () => ({
|
|
6
|
+
default: vi.fn(),
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
import railsFetchJson from "../railsFetchJson";
|
|
10
|
+
|
|
11
|
+
const mockFetch = vi.mocked(railsFetchJson);
|
|
12
|
+
|
|
13
|
+
interface TestAction {
|
|
14
|
+
type: "update" | "other" | "saveComplete" | "saveFailed";
|
|
15
|
+
res?: RailsResponse;
|
|
16
|
+
value?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const makeHandler = (href = "/api/save"): DispatchHandler<TestAction> => {
|
|
20
|
+
return (action) => {
|
|
21
|
+
if (action.type === "update") {
|
|
22
|
+
return { href };
|
|
23
|
+
}
|
|
24
|
+
return undefined;
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
describe("wrapDispatch", () => {
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
vi.useFakeTimers();
|
|
31
|
+
mockFetch.mockReset();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
vi.useRealTimers();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("always dispatches the action immediately", () => {
|
|
39
|
+
const dispatch = vi.fn();
|
|
40
|
+
const wrapped = wrapDispatch<TestAction>(dispatch, ["update"], makeHandler());
|
|
41
|
+
|
|
42
|
+
wrapped({ type: "update", value: "test" });
|
|
43
|
+
|
|
44
|
+
expect(dispatch).toHaveBeenCalledWith({ type: "update", value: "test" });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("does not trigger handler for non-matching action types", () => {
|
|
48
|
+
const dispatch = vi.fn();
|
|
49
|
+
const wrapped = wrapDispatch<TestAction>(dispatch, ["update"], makeHandler());
|
|
50
|
+
|
|
51
|
+
wrapped({ type: "other" });
|
|
52
|
+
|
|
53
|
+
vi.advanceTimersByTime(300);
|
|
54
|
+
expect(mockFetch).not.toHaveBeenCalled();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("debounces the fetch call", () => {
|
|
58
|
+
const dispatch = vi.fn();
|
|
59
|
+
const wrapped = wrapDispatch<TestAction>(dispatch, ["update"], makeHandler());
|
|
60
|
+
|
|
61
|
+
wrapped({ type: "update" });
|
|
62
|
+
expect(mockFetch).not.toHaveBeenCalled();
|
|
63
|
+
|
|
64
|
+
vi.advanceTimersByTime(250);
|
|
65
|
+
expect(mockFetch).toHaveBeenCalledOnce();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("cancels previous debounced call when a new action arrives", () => {
|
|
69
|
+
const dispatch = vi.fn();
|
|
70
|
+
const wrapped = wrapDispatch<TestAction>(dispatch, ["update"], makeHandler());
|
|
71
|
+
|
|
72
|
+
wrapped({ type: "update", value: "first" });
|
|
73
|
+
vi.advanceTimersByTime(100);
|
|
74
|
+
|
|
75
|
+
wrapped({ type: "update", value: "second" });
|
|
76
|
+
vi.advanceTimersByTime(250);
|
|
77
|
+
|
|
78
|
+
// Only one fetch should have fired (the second one)
|
|
79
|
+
expect(mockFetch).toHaveBeenCalledOnce();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("skips debounce when skipDebounce is true", () => {
|
|
83
|
+
const dispatch = vi.fn();
|
|
84
|
+
const wrapped = wrapDispatch<TestAction>(
|
|
85
|
+
dispatch, ["update"], makeHandler(), true
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
wrapped({ type: "update" });
|
|
89
|
+
|
|
90
|
+
// Should fire immediately without waiting
|
|
91
|
+
expect(mockFetch).toHaveBeenCalledOnce();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("dispatches saveComplete on successful fetch", async () => {
|
|
95
|
+
const dispatch = vi.fn();
|
|
96
|
+
const response: RailsResponse = { status: "OK", toasts: [] };
|
|
97
|
+
mockFetch.mockResolvedValue(response);
|
|
98
|
+
|
|
99
|
+
const wrapped = wrapDispatch<TestAction>(
|
|
100
|
+
dispatch, ["update"], makeHandler(), true
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
wrapped({ type: "update" });
|
|
104
|
+
|
|
105
|
+
// Let the promise resolve
|
|
106
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
107
|
+
|
|
108
|
+
expect(dispatch).toHaveBeenCalledWith({ res: response, type: "saveComplete" });
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("dispatches saveFailed on fetch error", async () => {
|
|
112
|
+
const dispatch = vi.fn();
|
|
113
|
+
mockFetch.mockRejectedValue(new Error("Network error"));
|
|
114
|
+
|
|
115
|
+
const wrapped = wrapDispatch<TestAction>(
|
|
116
|
+
dispatch, ["update"], makeHandler(), true
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
wrapped({ type: "update" });
|
|
120
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
121
|
+
|
|
122
|
+
expect(dispatch).toHaveBeenCalledWith({ type: "saveFailed" });
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("ignores DOMException (aborted requests)", async () => {
|
|
126
|
+
const dispatch = vi.fn();
|
|
127
|
+
mockFetch.mockRejectedValue(new DOMException("Aborted", "AbortError"));
|
|
128
|
+
|
|
129
|
+
const wrapped = wrapDispatch<TestAction>(
|
|
130
|
+
dispatch, ["update"], makeHandler(), true
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
wrapped({ type: "update" });
|
|
134
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
135
|
+
|
|
136
|
+
// Should only have the initial dispatch, no saveFailed
|
|
137
|
+
expect(dispatch).toHaveBeenCalledOnce();
|
|
138
|
+
expect(dispatch).toHaveBeenCalledWith({ type: "update" });
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("does not call fetch when handler returns undefined", () => {
|
|
142
|
+
const dispatch = vi.fn();
|
|
143
|
+
const handler: DispatchHandler<TestAction> = () => undefined;
|
|
144
|
+
const wrapped = wrapDispatch<TestAction>(
|
|
145
|
+
dispatch, ["update"], handler, true
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
wrapped({ type: "update" });
|
|
149
|
+
|
|
150
|
+
expect(mockFetch).not.toHaveBeenCalled();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("passes href and method from handler to fetch", () => {
|
|
154
|
+
const dispatch = vi.fn();
|
|
155
|
+
const handler: DispatchHandler<TestAction> = (action) => {
|
|
156
|
+
if (action.type === "update") {
|
|
157
|
+
return { href: "/api/custom", method: "PUT" };
|
|
158
|
+
}
|
|
159
|
+
return undefined;
|
|
160
|
+
};
|
|
161
|
+
const wrapped = wrapDispatch<TestAction>(
|
|
162
|
+
dispatch, ["update"], handler, true
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
wrapped({ type: "update" });
|
|
166
|
+
|
|
167
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
168
|
+
"/api/custom",
|
|
169
|
+
expect.objectContaining({ method: "PUT" })
|
|
170
|
+
);
|
|
171
|
+
});
|
|
172
|
+
});
|
package/Reducer/wrapDispatch.tsx
CHANGED
|
@@ -48,7 +48,7 @@ export default function wrapDispatch<
|
|
|
48
48
|
}
|
|
49
49
|
};
|
|
50
50
|
|
|
51
|
-
const timers: { [id: string]:
|
|
51
|
+
const timers: { [id: string]: ReturnType<typeof setTimeout> } = {};
|
|
52
52
|
const abortControllers: { [id: string]: AbortController } = {};
|
|
53
53
|
|
|
54
54
|
return (action: A) => {
|
package/Toasts/Toast.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React from "react";
|
|
1
|
+
import React, { useEffect } from "react";
|
|
2
2
|
import classNames from "classnames";
|
|
3
3
|
import { createUseStyles } from "react-jss";
|
|
4
4
|
import { DismissFunc, Toast_Status, ToastProps } from "./types";
|
|
@@ -6,10 +6,21 @@ import FontAwesomeIcon from "../FontAwesomeIcon";
|
|
|
6
6
|
import Styles from "../Styles";
|
|
7
7
|
|
|
8
8
|
interface Props {
|
|
9
|
+
allowedDomains?: string[] | undefined;
|
|
9
10
|
dismiss: DismissFunc;
|
|
10
11
|
toast: ToastProps;
|
|
11
12
|
}
|
|
12
13
|
|
|
14
|
+
const isAllowedUrl = (url: string, allowedDomains?: string[]): boolean => {
|
|
15
|
+
if (!allowedDomains) return false;
|
|
16
|
+
try {
|
|
17
|
+
const hostname = new URL(url).hostname;
|
|
18
|
+
return allowedDomains.some(domain => hostname === domain || hostname.endsWith(`.${domain}`));
|
|
19
|
+
} catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
|
|
13
24
|
const useStyles = createUseStyles({
|
|
14
25
|
dismissed: { opacity: "0" },
|
|
15
26
|
error: {
|
|
@@ -72,16 +83,21 @@ const useStyles = createUseStyles({
|
|
|
72
83
|
const Toast = (props: Props) => {
|
|
73
84
|
const classes = useStyles();
|
|
74
85
|
|
|
75
|
-
const { dismiss, toast } = props;
|
|
86
|
+
const { allowedDomains, dismiss, toast } = props;
|
|
76
87
|
const { body, dismissed, id, status, timeout, title } = toast;
|
|
77
88
|
|
|
78
89
|
const handleDismiss = () => dismiss(id);
|
|
79
90
|
|
|
80
|
-
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
if (timeout <= 0) return;
|
|
93
|
+
|
|
94
|
+
const timer = window.setTimeout(handleDismiss, 2000 + timeout);
|
|
95
|
+
return () => window.clearTimeout(timer);
|
|
96
|
+
}, [id, timeout]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
81
97
|
|
|
82
98
|
const renderBodyLines = (bodyLines: string[]) => {
|
|
83
99
|
return(bodyLines.map((line, index) => {
|
|
84
|
-
if (/^https?:\/\//.exec(line)) {
|
|
100
|
+
if (/^https?:\/\//.exec(line) && isAllowedUrl(line, allowedDomains)) {
|
|
85
101
|
return(
|
|
86
102
|
<div key={`line-${index}`}>
|
|
87
103
|
<a href={line}>{line}</a>
|
package/Toasts/Toasts.tsx
CHANGED
|
@@ -7,6 +7,7 @@ import { DismissFunc, ToastProps, ToastRemoveAppAction } from "./types";
|
|
|
7
7
|
import { DispatchFunc } from "../";
|
|
8
8
|
|
|
9
9
|
interface BaseProps {
|
|
10
|
+
allowedDomains?: string[];
|
|
10
11
|
toasts?: ToastProps[];
|
|
11
12
|
}
|
|
12
13
|
|
|
@@ -39,7 +40,7 @@ const useStyles = createUseStyles({
|
|
|
39
40
|
});
|
|
40
41
|
|
|
41
42
|
const Toasts = (props: Props) => {
|
|
42
|
-
const { toasts } = props;
|
|
43
|
+
const { allowedDomains, toasts } = props;
|
|
43
44
|
const classes = useStyles();
|
|
44
45
|
|
|
45
46
|
const dismissFunc = (): DismissFunc => {
|
|
@@ -55,7 +56,9 @@ const Toasts = (props: Props) => {
|
|
|
55
56
|
|
|
56
57
|
return (
|
|
57
58
|
<div className={classNames("toastsContainer", classes.toastsContainer)}>
|
|
58
|
-
{toasts?.map(toast =>
|
|
59
|
+
{toasts?.map(toast => (
|
|
60
|
+
<Toast allowedDomains={allowedDomains} dismiss={dismissFunc()} key={toast.id} toast={toast} />
|
|
61
|
+
))}
|
|
59
62
|
</div>
|
|
60
63
|
);
|
|
61
64
|
};
|
package/Toasts/index.tsx
CHANGED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { produce } from "immer";
|
|
3
|
+
import { addToast, handleToasts, removeToast, removeToastById, createDismissToast } from "./toastHelpers";
|
|
4
|
+
import { Toast_Status, ToastProps } from "./types";
|
|
5
|
+
|
|
6
|
+
const makeToast = (overrides: Partial<ToastProps> = {}): ToastProps => ({
|
|
7
|
+
body: "Test body",
|
|
8
|
+
id: 1,
|
|
9
|
+
status: Toast_Status.SUCCESS,
|
|
10
|
+
timeout: 5000,
|
|
11
|
+
title: "Test title",
|
|
12
|
+
...overrides,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const makeState = (overrides = {}) => ({
|
|
16
|
+
toastId: 0,
|
|
17
|
+
toasts: [] as ToastProps[],
|
|
18
|
+
...overrides,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe("addToast", () => {
|
|
22
|
+
it("adds a toast to the state", () => {
|
|
23
|
+
const state = makeState();
|
|
24
|
+
const toast = { body: "Hello", status: Toast_Status.SUCCESS, timeout: 3000, title: "Hi" };
|
|
25
|
+
|
|
26
|
+
const result = produce(state, draft => {
|
|
27
|
+
addToast(draft, toast);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
expect(result.toasts).toHaveLength(1);
|
|
31
|
+
expect(result.toasts[0]?.title).toBe("Hi");
|
|
32
|
+
expect(result.toasts[0]?.body).toBe("Hello");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("increments toastId", () => {
|
|
36
|
+
const state = makeState({ toastId: 5 });
|
|
37
|
+
const toast = { body: "Body", status: Toast_Status.NOTICE, timeout: 0, title: "Title" };
|
|
38
|
+
|
|
39
|
+
const result = produce(state, draft => {
|
|
40
|
+
addToast(draft, toast);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
expect(result.toastId).toBe(6);
|
|
44
|
+
expect(result.toasts[0]?.id).toBe(6);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("uses provided id when available", () => {
|
|
48
|
+
const state = makeState();
|
|
49
|
+
const toast = { body: "Body", id: 42, status: Toast_Status.SUCCESS, timeout: 0, title: "Title" };
|
|
50
|
+
|
|
51
|
+
const result = produce(state, draft => {
|
|
52
|
+
addToast(draft, toast);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
expect(result.toasts[0]?.id).toBe(42);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("prevents duplicate toasts with same body, status, and title", () => {
|
|
59
|
+
const existing = makeToast({ body: "Dup", id: 1, status: Toast_Status.ERROR, title: "Dup Title" });
|
|
60
|
+
const state = makeState({ toasts: [existing] });
|
|
61
|
+
const toast = { body: "Dup", status: Toast_Status.ERROR, timeout: 0, title: "Dup Title" };
|
|
62
|
+
|
|
63
|
+
const result = produce(state, draft => {
|
|
64
|
+
addToast(draft, toast);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
expect(result.toasts).toHaveLength(1);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("allows toasts with different body", () => {
|
|
71
|
+
const existing = makeToast({ body: "First", id: 1, title: "Title" });
|
|
72
|
+
const state = makeState({ toasts: [existing] });
|
|
73
|
+
const toast = { body: "Second", status: Toast_Status.SUCCESS, timeout: 0, title: "Title" };
|
|
74
|
+
|
|
75
|
+
const result = produce(state, draft => {
|
|
76
|
+
addToast(draft, toast);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
expect(result.toasts).toHaveLength(2);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("initializes toasts array if missing", () => {
|
|
83
|
+
const state = { toastId: 0 } as ReturnType<typeof makeState>;
|
|
84
|
+
const toast = { body: "Body", status: Toast_Status.SUCCESS, timeout: 0, title: "Title" };
|
|
85
|
+
|
|
86
|
+
const result = produce(state, draft => {
|
|
87
|
+
addToast(draft, toast);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
expect(result.toasts).toHaveLength(1);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("removeToast", () => {
|
|
95
|
+
it("removes the specified toast", () => {
|
|
96
|
+
const toast = makeToast({ id: 1 });
|
|
97
|
+
const state = makeState({ toasts: [toast, makeToast({ id: 2 })] });
|
|
98
|
+
|
|
99
|
+
const result = produce(state, draft => {
|
|
100
|
+
removeToast(draft, draft.toasts[0]!);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
expect(result.toasts).toHaveLength(1);
|
|
104
|
+
expect(result.toasts[0]?.id).toBe(2);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe("removeToastById", () => {
|
|
109
|
+
it("removes a toast by its id", () => {
|
|
110
|
+
const state = makeState({ toasts: [makeToast({ id: 5 }), makeToast({ id: 6, title: "Keep" })] });
|
|
111
|
+
|
|
112
|
+
const result = produce(state, draft => {
|
|
113
|
+
removeToastById(draft, 5);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
expect(result.toasts).toHaveLength(1);
|
|
117
|
+
expect(result.toasts[0]?.title).toBe("Keep");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("does nothing when id is not found", () => {
|
|
121
|
+
const state = makeState({ toasts: [makeToast({ id: 1 })] });
|
|
122
|
+
|
|
123
|
+
const result = produce(state, draft => {
|
|
124
|
+
removeToastById(draft, 999);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
expect(result.toasts).toHaveLength(1);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe("handleToasts", () => {
|
|
132
|
+
it("adds multiple toasts", () => {
|
|
133
|
+
const state = makeState();
|
|
134
|
+
const toasts = [
|
|
135
|
+
{ body: "A", status: Toast_Status.SUCCESS, timeout: 0, title: "First" },
|
|
136
|
+
{ body: "B", status: Toast_Status.NOTICE, timeout: 0, title: "Second" },
|
|
137
|
+
];
|
|
138
|
+
|
|
139
|
+
const result = produce(state, draft => {
|
|
140
|
+
handleToasts(draft, toasts);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
expect(result.toasts).toHaveLength(2);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("does nothing with empty array", () => {
|
|
147
|
+
const state = makeState();
|
|
148
|
+
|
|
149
|
+
const result = produce(state, draft => {
|
|
150
|
+
handleToasts(draft, []);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
expect(result.toasts).toHaveLength(0);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe("createDismissToast", () => {
|
|
158
|
+
it("dispatches dismissToast then removeToast after delay", () => {
|
|
159
|
+
vi.useFakeTimers();
|
|
160
|
+
const dispatch = vi.fn();
|
|
161
|
+
const dismiss = createDismissToast(dispatch);
|
|
162
|
+
|
|
163
|
+
dismiss(7);
|
|
164
|
+
|
|
165
|
+
expect(dispatch).toHaveBeenCalledWith({ toastId: 7, type: "dismissToast" });
|
|
166
|
+
expect(dispatch).toHaveBeenCalledOnce();
|
|
167
|
+
|
|
168
|
+
vi.advanceTimersByTime(2000);
|
|
169
|
+
|
|
170
|
+
expect(dispatch).toHaveBeenCalledWith({ toastId: 7, type: "removeToast" });
|
|
171
|
+
expect(dispatch).toHaveBeenCalledTimes(2);
|
|
172
|
+
|
|
173
|
+
vi.useRealTimers();
|
|
174
|
+
});
|
|
175
|
+
});
|
package/eslint.config.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import globals from "globals";
|
|
2
2
|
import eslint from "@eslint/js";
|
|
3
3
|
import stylisticJs from "@stylistic/eslint-plugin";
|
|
4
|
+
import perfectionist from "eslint-plugin-perfectionist";
|
|
4
5
|
import react from "eslint-plugin-react";
|
|
5
6
|
import reactHooks from "eslint-plugin-react-hooks";
|
|
6
7
|
import importPlugin from "eslint-plugin-import";
|
|
@@ -152,7 +153,7 @@ export default tseslint.config(
|
|
|
152
153
|
"stylistic/no-extra-semi": "error",
|
|
153
154
|
"stylistic/semi": ["error", "always"],
|
|
154
155
|
"stylistic/no-multiple-empty-lines": "error",
|
|
155
|
-
"
|
|
156
|
+
"perfectionist/sort-jsx-props": "error",
|
|
156
157
|
"stylistic/eol-last": "error",
|
|
157
158
|
"stylistic/array-element-newline": [
|
|
158
159
|
"error",
|
|
@@ -250,11 +251,16 @@ export default tseslint.config(
|
|
|
250
251
|
"app/javascript/application.js",
|
|
251
252
|
"app/javascript/mount.tsx",
|
|
252
253
|
"eslint.config.mjs",
|
|
254
|
+
"vitest.config.ts",
|
|
255
|
+
"vitest.setup.ts",
|
|
256
|
+
"**/*.test.ts",
|
|
257
|
+
"**/*.test.tsx",
|
|
253
258
|
],
|
|
254
259
|
},
|
|
255
260
|
{
|
|
256
261
|
plugins: {
|
|
257
262
|
"stylistic": stylisticJs,
|
|
263
|
+
"perfectionist": perfectionist,
|
|
258
264
|
"react": react,
|
|
259
265
|
"react-hooks": reactHooks,
|
|
260
266
|
"import": importPlugin,
|
package/mount.test.tsx
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
const mockRender = vi.fn();
|
|
5
|
+
const mockCreateRoot = vi.fn(() => ({ render: mockRender }));
|
|
6
|
+
|
|
7
|
+
vi.mock("react-dom/client", () => ({
|
|
8
|
+
default: { createRoot: (...args: unknown[]) => mockCreateRoot(...args) },
|
|
9
|
+
createRoot: (...args: unknown[]) => mockCreateRoot(...args),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
import mount from "./mount";
|
|
13
|
+
|
|
14
|
+
const Greeting = (props: { name?: string }) => <div>Hello {props.name}</div>;
|
|
15
|
+
const Inner = (props: { name?: string }) => <div>Inner {props.name}</div>;
|
|
16
|
+
|
|
17
|
+
const setupAndMount = (
|
|
18
|
+
components: { [key: string]: React.ElementType | Record<string, unknown> },
|
|
19
|
+
mountPoints: { name: string; props?: object }[]
|
|
20
|
+
) => {
|
|
21
|
+
for (const mp of mountPoints) {
|
|
22
|
+
const el = document.createElement("div");
|
|
23
|
+
el.setAttribute("data-react-component", mp.name);
|
|
24
|
+
if (mp.props) el.setAttribute("data-props", JSON.stringify(mp.props));
|
|
25
|
+
document.body.appendChild(el);
|
|
26
|
+
}
|
|
27
|
+
mount(components);
|
|
28
|
+
document.dispatchEvent(new Event("DOMContentLoaded"));
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
describe("mount", () => {
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
const newDoc = document.implementation.createHTMLDocument();
|
|
34
|
+
Object.defineProperty(window, "document", {
|
|
35
|
+
configurable: true,
|
|
36
|
+
value: newDoc,
|
|
37
|
+
writable: true,
|
|
38
|
+
});
|
|
39
|
+
mockCreateRoot.mockClear();
|
|
40
|
+
mockRender.mockClear();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("mounts a component found by name", () => {
|
|
44
|
+
setupAndMount({ Greeting }, [{ name: "Greeting", props: { name: "World" } }]);
|
|
45
|
+
|
|
46
|
+
expect(mockCreateRoot).toHaveBeenCalledOnce();
|
|
47
|
+
expect(mockRender).toHaveBeenCalledOnce();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("resolves dotted component names", () => {
|
|
51
|
+
setupAndMount(
|
|
52
|
+
{ Nested: { Inner } },
|
|
53
|
+
[{ name: "Nested.Inner", props: { name: "Dot" } }]
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
expect(mockCreateRoot).toHaveBeenCalledOnce();
|
|
57
|
+
expect(mockRender).toHaveBeenCalledOnce();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("resolves deeply nested component names", () => {
|
|
61
|
+
setupAndMount(
|
|
62
|
+
{ A: { B: { C: { Inner } } } },
|
|
63
|
+
[{ name: "A.B.C.Inner", props: { name: "Deep" } }]
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
expect(mockCreateRoot).toHaveBeenCalledOnce();
|
|
67
|
+
expect(mockRender).toHaveBeenCalledOnce();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("warns when component is not found", () => {
|
|
71
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
72
|
+
setupAndMount({ Greeting }, [{ name: "Missing" }]);
|
|
73
|
+
|
|
74
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
75
|
+
"No component found for: Missing",
|
|
76
|
+
expect.any(Object)
|
|
77
|
+
);
|
|
78
|
+
expect(mockCreateRoot).not.toHaveBeenCalled();
|
|
79
|
+
warnSpy.mockRestore();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("defaults to empty props when data-props is missing", () => {
|
|
83
|
+
setupAndMount({ Greeting }, [{ name: "Greeting" }]);
|
|
84
|
+
|
|
85
|
+
expect(mockCreateRoot).toHaveBeenCalledOnce();
|
|
86
|
+
expect(mockRender).toHaveBeenCalledOnce();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("handles broken dotted name chain", () => {
|
|
90
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
91
|
+
setupAndMount(
|
|
92
|
+
{ Nested: { Inner } },
|
|
93
|
+
[{ name: "Nested.Wrong.Deep" }]
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
expect(warnSpy).toHaveBeenCalled();
|
|
97
|
+
expect(mockCreateRoot).not.toHaveBeenCalled();
|
|
98
|
+
warnSpy.mockRestore();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("mounts multiple components", () => {
|
|
102
|
+
setupAndMount({ Greeting }, [
|
|
103
|
+
{ name: "Greeting", props: { name: "A" } },
|
|
104
|
+
{ name: "Greeting", props: { name: "B" } },
|
|
105
|
+
]);
|
|
106
|
+
|
|
107
|
+
expect(mockCreateRoot).toHaveBeenCalledTimes(2);
|
|
108
|
+
expect(mockRender).toHaveBeenCalledTimes(2);
|
|
109
|
+
});
|
|
110
|
+
});
|
package/mount.tsx
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import ReactDOM from "react-dom/client";
|
|
3
3
|
|
|
4
|
-
type
|
|
5
|
-
type NestedComponents = Record<string, React.ElementType | Components>;
|
|
4
|
+
type NestedComponents = { [key: string]: React.ElementType | NestedComponents };
|
|
6
5
|
|
|
7
6
|
const findComponent = (
|
|
8
7
|
components: NestedComponents,
|
|
@@ -25,7 +24,7 @@ const findComponent = (
|
|
|
25
24
|
}
|
|
26
25
|
|
|
27
26
|
// Return the specified component
|
|
28
|
-
return (current as
|
|
27
|
+
return (current as NestedComponents)[componentName] as React.ElementType | undefined;
|
|
29
28
|
};
|
|
30
29
|
|
|
31
30
|
export default function mount(components: NestedComponents): void {
|
|
@@ -33,22 +32,23 @@ export default function mount(components: NestedComponents): void {
|
|
|
33
32
|
const mountPoints = document.querySelectorAll("[data-react-component]");
|
|
34
33
|
mountPoints.forEach(mountPoint => {
|
|
35
34
|
const { dataset } = mountPoint as HTMLElement;
|
|
36
|
-
const componentName = dataset["reactComponent"]
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
dataset["reactComponent"],
|
|
48
|
-
components
|
|
49
|
-
);
|
|
50
|
-
}
|
|
35
|
+
const componentName = dataset["reactComponent"]!;
|
|
36
|
+
|
|
37
|
+
const Component = findComponent(components, componentName);
|
|
38
|
+
|
|
39
|
+
if (!Component) {
|
|
40
|
+
// eslint-disable-next-line no-console
|
|
41
|
+
console.warn(
|
|
42
|
+
`No component found for: ${dataset["reactComponent"]}`,
|
|
43
|
+
components
|
|
44
|
+
);
|
|
45
|
+
return;
|
|
51
46
|
}
|
|
47
|
+
|
|
48
|
+
const props = JSON.parse(dataset["props"] || "{}") as object;
|
|
49
|
+
const root = ReactDOM.createRoot(mountPoint);
|
|
50
|
+
|
|
51
|
+
root.render(<Component {...props} />);
|
|
52
52
|
});
|
|
53
53
|
});
|
|
54
54
|
}
|
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@unitedstatespowersquadrons/components",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.6-1",
|
|
4
4
|
"description": "USPS shared React components library",
|
|
5
5
|
"main": "index.tsx",
|
|
6
6
|
"scripts": {
|
|
7
|
-
"test": "
|
|
7
|
+
"test": "vitest run",
|
|
8
|
+
"test:watch": "vitest"
|
|
8
9
|
},
|
|
9
10
|
"repository": {
|
|
10
11
|
"type": "git",
|
|
@@ -18,19 +19,21 @@
|
|
|
18
19
|
"homepage": "https://github.com/unitedstatespowersquadrons/components#readme",
|
|
19
20
|
"dependencies": {
|
|
20
21
|
"@rails/actioncable": "^8.0.200",
|
|
21
|
-
"@types/actioncable": "^5.2.11",
|
|
22
22
|
"@types/rails__actioncable": "^6.1.11",
|
|
23
23
|
"classnames": "^2.5.1",
|
|
24
|
+
"eslint-plugin-perfectionist": "^5.9.0",
|
|
24
25
|
"immer": "^10.1.1",
|
|
25
26
|
"javascript-time-ago": "^2.6.4",
|
|
26
27
|
"react": "^19.1.0",
|
|
27
28
|
"react-dom": "^19.1.0",
|
|
28
29
|
"react-jss": "^10.10.0",
|
|
29
|
-
"typescript": "^5.8.3",
|
|
30
30
|
"use-immer": "^0.11.0"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
33
|
"@stylistic/eslint-plugin": "^5.1.0",
|
|
34
|
+
"@testing-library/dom": "^10.4.1",
|
|
35
|
+
"@testing-library/jest-dom": "^6.6.0",
|
|
36
|
+
"@testing-library/react": "^16.3.0",
|
|
34
37
|
"@types/react": "^19.1.8",
|
|
35
38
|
"@types/react-dom": "^19.1.6",
|
|
36
39
|
"eslint": "^9.30.1",
|
|
@@ -38,6 +41,9 @@
|
|
|
38
41
|
"eslint-plugin-import-newlines": "^1.4.0",
|
|
39
42
|
"eslint-plugin-react": "^7.37.5",
|
|
40
43
|
"eslint-plugin-react-hooks": "^5.2.0",
|
|
41
|
-
"
|
|
44
|
+
"jsdom": "^26.1.0",
|
|
45
|
+
"typescript": "^5.8.3",
|
|
46
|
+
"typescript-eslint": "^8.36.0",
|
|
47
|
+
"vitest": "^3.1.0"
|
|
42
48
|
}
|
|
43
49
|
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { getCsrfTokenHeader } from "./railsFetchJson";
|
|
3
|
+
|
|
4
|
+
describe("getCsrfTokenHeader", () => {
|
|
5
|
+
afterEach(() => {
|
|
6
|
+
document.head.innerHTML = "";
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("returns the csrf token from the meta tag", () => {
|
|
10
|
+
const meta = document.createElement("meta");
|
|
11
|
+
meta.setAttribute("name", "csrf-token");
|
|
12
|
+
meta.setAttribute("content", "abc123");
|
|
13
|
+
document.head.appendChild(meta);
|
|
14
|
+
|
|
15
|
+
expect(getCsrfTokenHeader()).toBe("abc123");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("returns empty string when meta tag is missing", () => {
|
|
19
|
+
expect(getCsrfTokenHeader()).toBe("");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("returns empty string when meta tag has no content attribute", () => {
|
|
23
|
+
const meta = document.createElement("meta");
|
|
24
|
+
meta.setAttribute("name", "csrf-token");
|
|
25
|
+
document.head.appendChild(meta);
|
|
26
|
+
|
|
27
|
+
expect(getCsrfTokenHeader()).toBe("");
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("railsFetchJson", () => {
|
|
32
|
+
let mockFetch: ReturnType<typeof vi.fn>;
|
|
33
|
+
let railsFetchJson: typeof import("./railsFetchJson").default;
|
|
34
|
+
|
|
35
|
+
const mockResponse = (body: object, ok = true, status = 200) => {
|
|
36
|
+
return Promise.resolve({
|
|
37
|
+
json: () => Promise.resolve(body),
|
|
38
|
+
ok,
|
|
39
|
+
status,
|
|
40
|
+
statusText: ok ? "OK" : "Internal Server Error",
|
|
41
|
+
});
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
beforeEach(async () => {
|
|
45
|
+
document.head.innerHTML = "";
|
|
46
|
+
mockFetch = vi.fn();
|
|
47
|
+
// Set up the mock before the module captures window.fetch
|
|
48
|
+
window.fetch = mockFetch as unknown as typeof fetch;
|
|
49
|
+
// Re-import to capture the mocked fetch
|
|
50
|
+
vi.resetModules();
|
|
51
|
+
const mod = await import("./railsFetchJson");
|
|
52
|
+
railsFetchJson = mod.default;
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
afterEach(() => {
|
|
56
|
+
vi.restoreAllMocks();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("sends default headers", async () => {
|
|
60
|
+
mockFetch.mockReturnValue(mockResponse({ result: "ok" }));
|
|
61
|
+
|
|
62
|
+
await railsFetchJson("http://localhost/api/test");
|
|
63
|
+
|
|
64
|
+
expect(mockFetch).toHaveBeenCalledOnce();
|
|
65
|
+
const [url, options] = mockFetch.mock.calls[0]!;
|
|
66
|
+
expect(url).toBe("http://localhost/api/test");
|
|
67
|
+
expect(options.credentials).toBe("same-origin");
|
|
68
|
+
|
|
69
|
+
const headers = options.headers as Headers;
|
|
70
|
+
expect(headers.get("X-Requested-With")).toBe("XMLHttpRequest");
|
|
71
|
+
expect(headers.get("X-Usps-Client")).toBe("React-2025");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("includes CSRF token from meta tag", async () => {
|
|
75
|
+
const meta = document.createElement("meta");
|
|
76
|
+
meta.setAttribute("name", "csrf-token");
|
|
77
|
+
meta.setAttribute("content", "token-xyz");
|
|
78
|
+
document.head.appendChild(meta);
|
|
79
|
+
|
|
80
|
+
mockFetch.mockReturnValue(mockResponse({}));
|
|
81
|
+
|
|
82
|
+
await railsFetchJson("http://localhost/api/test");
|
|
83
|
+
|
|
84
|
+
const headers = mockFetch.mock.calls[0]![1].headers as Headers;
|
|
85
|
+
expect(headers.get("X-CSRF-Token")).toBe("token-xyz");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("sends JSON headers and body for non-GET with bodyJson", async () => {
|
|
89
|
+
mockFetch.mockReturnValue(mockResponse({ saved: true }));
|
|
90
|
+
|
|
91
|
+
await railsFetchJson("http://localhost/api/save", {
|
|
92
|
+
bodyJson: { name: "test" },
|
|
93
|
+
method: "POST",
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const [, options] = mockFetch.mock.calls[0]!;
|
|
97
|
+
const headers = options.headers as Headers;
|
|
98
|
+
expect(headers.get("Content-Type")).toBe("application/json");
|
|
99
|
+
expect(headers.get("Accept")).toBe("application/json");
|
|
100
|
+
expect(options.body).toBe(JSON.stringify({ name: "test" }));
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("does not send JSON headers for GET requests", async () => {
|
|
104
|
+
mockFetch.mockReturnValue(mockResponse({ data: [] }));
|
|
105
|
+
|
|
106
|
+
await railsFetchJson("http://localhost/api/list", { method: "GET" });
|
|
107
|
+
|
|
108
|
+
const headers = mockFetch.mock.calls[0]![1].headers as Headers;
|
|
109
|
+
expect(headers.has("Content-Type")).toBe(false);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("returns parsed JSON response", async () => {
|
|
113
|
+
mockFetch.mockReturnValue(mockResponse({ items: [1, 2, 3] }));
|
|
114
|
+
|
|
115
|
+
const result = await railsFetchJson<{ items: number[] }>("http://localhost/api/items");
|
|
116
|
+
|
|
117
|
+
expect(result).toEqual({ items: [1, 2, 3] });
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("throws on non-2xx response", async () => {
|
|
121
|
+
mockFetch.mockReturnValue(mockResponse({}, false, 500));
|
|
122
|
+
|
|
123
|
+
await expect(railsFetchJson("http://localhost/api/fail")).rejects.toThrow("HTTP 500");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("merges custom headers", async () => {
|
|
127
|
+
mockFetch.mockReturnValue(mockResponse({}));
|
|
128
|
+
|
|
129
|
+
await railsFetchJson("http://localhost/api/test", {
|
|
130
|
+
headers: { "X-Custom": "value" },
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const headers = mockFetch.mock.calls[0]![1].headers as Headers;
|
|
134
|
+
expect(headers.get("X-Custom")).toBe("value");
|
|
135
|
+
expect(headers.get("X-Requested-With")).toBe("XMLHttpRequest");
|
|
136
|
+
});
|
|
137
|
+
});
|
package/railsFetchJson.tsx
CHANGED
|
@@ -6,7 +6,7 @@ interface FetchJsonOptions extends RequestInit {
|
|
|
6
6
|
|
|
7
7
|
export const getCsrfTokenHeader = () => {
|
|
8
8
|
const token = document.querySelector("meta[name='csrf-token']");
|
|
9
|
-
return token
|
|
9
|
+
return token?.getAttribute("content") ?? "";
|
|
10
10
|
};
|
|
11
11
|
|
|
12
12
|
const railsFetchJson = <T = object>(url: RequestInfo, options?: FetchJsonOptions) => {
|
|
@@ -15,10 +15,9 @@ const railsFetchJson = <T = object>(url: RequestInfo, options?: FetchJsonOptions
|
|
|
15
15
|
delete(options.headers);
|
|
16
16
|
const baseheaders = {
|
|
17
17
|
...optionHeaders,
|
|
18
|
-
Credentials: "same-origin",
|
|
19
18
|
"X-CSRF-Token": getCsrfTokenHeader(),
|
|
20
|
-
"X-Exams-Client": "React-2025",
|
|
21
19
|
"X-Requested-With": "XMLHttpRequest",
|
|
20
|
+
"X-Usps-Client": "React-2025",
|
|
22
21
|
};
|
|
23
22
|
|
|
24
23
|
const jsonPost = options.method !== "GET" && options?.bodyJson;
|
|
@@ -31,7 +30,10 @@ const railsFetchJson = <T = object>(url: RequestInfo, options?: FetchJsonOptions
|
|
|
31
30
|
|
|
32
31
|
if (jsonPost) options.body = JSON.stringify(options.bodyJson);
|
|
33
32
|
|
|
34
|
-
return oldFetch(url, { headers, ...options }).then(res =>
|
|
33
|
+
return oldFetch(url, { credentials: "same-origin", headers, ...options }).then(res => {
|
|
34
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
|
35
|
+
return res.json() as Promise<T>;
|
|
36
|
+
});
|
|
35
37
|
};
|
|
36
38
|
|
|
37
39
|
export default railsFetchJson;
|
package/tsconfig.json
CHANGED
package/types.ts
CHANGED
package/vitest.config.ts
ADDED
package/vitest.setup.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import "@testing-library/jest-dom/vitest";
|