@unitedstatespowersquadrons/components 1.0.9 → 1.0.11

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.
@@ -0,0 +1,35 @@
1
+ import { createContext } from "react";
2
+ import { Draft } from "immer";
3
+ import * as ReducerHandlers from "./reducerHandlers";
4
+ import { AppState, ServerResponse } from "./types";
5
+ import { buildReducer, RailsAppAction } from "../";
6
+
7
+ // If you want to add additional data to the server response, use type `RailsAppAction<ExampleServerResponse>`.
8
+ // Otherwise, just use type `RailsAppAction`.
9
+ //
10
+ // If you need to access the available `type`s for your defined AppAction, use `AppActionType<AppAction>`
11
+ //
12
+ export type AppAction =
13
+ | RailsAppAction<ServerResponse>
14
+ | { type: "doSomething"; something: string };
15
+
16
+ export const reducer = (draft: Draft<AppState>, action: AppAction): void => {
17
+ const { type } = action;
18
+
19
+ switch (type) {
20
+ case "doSomething":
21
+ draft.something = "changed";
22
+
23
+ break;
24
+ case "saveComplete": break;
25
+ case "saveFailed": break;
26
+ default: return type satisfies never;
27
+ }
28
+ };
29
+
30
+ const StateContext = createContext<AppState | null>(null);
31
+ const DispatchContext = createContext<React.Dispatch<AppAction> | null>(null);
32
+ const handlers = Object.values(ReducerHandlers);
33
+
34
+ export const { AppProvider, useAppDispatch, useAppState } =
35
+ buildReducer(reducer, StateContext, DispatchContext, handlers);
@@ -0,0 +1,25 @@
1
+ import { AppAction } from "./reducer";
2
+ import { ServerResponse } from "./types";
3
+ import { DispatchFunc, DispatchHandler } from "../types";
4
+ import wrapDispatch from "../wrapDispatch";
5
+
6
+ export const doSomething = (
7
+ dispatch: DispatchFunc<AppAction>
8
+ ): DispatchFunc<AppAction> => {
9
+ const actionType = "doSomething";
10
+
11
+ const handler: DispatchHandler<AppAction> = (action: AppAction) => {
12
+ if (action.type !== actionType) return {};
13
+
14
+ const { something } = action;
15
+
16
+ const bodyJson = { something };
17
+ const href = "/do_something";
18
+
19
+ return { bodyJson, href };
20
+ };
21
+
22
+ // If you want to add additional data to the server response, pass in type `ServerResponse`.
23
+ // Otherwise, just leave the second type parameter unspecified.
24
+ return wrapDispatch<AppAction, ServerResponse>(dispatch, [actionType], handler);
25
+ };
@@ -0,0 +1,9 @@
1
+ import { RailsResponse } from "../types";
2
+
3
+ export interface AppState {
4
+ something: string;
5
+ }
6
+
7
+ export interface ServerResponse extends RailsResponse {
8
+ update: AppState;
9
+ }
@@ -0,0 +1,60 @@
1
+ import React, { useContext, useMemo } from "react";
2
+ import { Draft, produce } from "immer";
3
+ import { useImmerReducer } from "use-immer";
4
+ import { ReducerHandler } from "./types";
5
+
6
+ interface InitialAppProps<AppState> {
7
+ initialState: AppState;
8
+ children: React.ReactNode;
9
+ }
10
+
11
+ export default function buildReducer<AppState, AppAction>(
12
+ reducer: (draft: Draft<AppState>, action: AppAction) => void,
13
+ StateContext: React.Context<AppState | null>,
14
+ DispatchContext: React.Context<React.Dispatch<AppAction> | null>,
15
+ handlers: ReducerHandler<AppAction>[] = []
16
+ ) {
17
+ function AppProvider(props: InitialAppProps<AppState>) {
18
+ const [providerState, dispatch] = useImmerReducer(reducer, props.initialState, (state: AppState) => {
19
+ return produce(state, draft => draft);
20
+ });
21
+
22
+ const wrappedDispatch = useMemo(() => {
23
+ let d = dispatch;
24
+
25
+ handlers.forEach(handler => d = handler(d));
26
+
27
+ return d;
28
+ }, [dispatch]);
29
+
30
+ return (
31
+ <StateContext.Provider value={providerState}>
32
+ <DispatchContext.Provider value={wrappedDispatch}>
33
+ {props.children}
34
+ </DispatchContext.Provider>
35
+ </StateContext.Provider>
36
+ );
37
+ }
38
+
39
+ function useAppState() {
40
+ const context = useContext(StateContext);
41
+ if (!context) {
42
+ throw new Error("useAppState has to be used within AppProvider");
43
+ }
44
+ return context;
45
+ }
46
+
47
+ function useAppDispatch() {
48
+ const context = useContext(DispatchContext);
49
+ if (!context) {
50
+ throw new Error("useAppDispatch has to be used within AppProvider");
51
+ }
52
+ return context;
53
+ }
54
+
55
+ return {
56
+ AppProvider,
57
+ useAppDispatch,
58
+ useAppState,
59
+ };
60
+ }
@@ -0,0 +1,31 @@
1
+ import React, { useContext } from "react";
2
+
3
+ interface InitialAppProps<AppState> {
4
+ initialState: AppState;
5
+ children: React.ReactNode;
6
+ }
7
+
8
+ export default function buildUnreducableState<AppState>(
9
+ StateContext: React.Context<AppState | null>
10
+ ) {
11
+ const AppProvider = (props: InitialAppProps<AppState>) => {
12
+ return (
13
+ <StateContext.Provider value={props.initialState}>
14
+ {props.children}
15
+ </StateContext.Provider>
16
+ );
17
+ };
18
+
19
+ function useAppState() {
20
+ const context = useContext(StateContext);
21
+ if (!context) {
22
+ throw new Error("useAppState has to be used within AppProvider");
23
+ }
24
+ return context;
25
+ }
26
+
27
+ return {
28
+ AppProvider,
29
+ useAppState,
30
+ };
31
+ }
@@ -0,0 +1,3 @@
1
+ export { default as wrapDispatch } from "./wrapDispatch";
2
+ export { default as buildReducer } from "./buildReducer";
3
+ export type * from "./types";
@@ -0,0 +1,34 @@
1
+ import { Toast } from "../Toasts";
2
+
3
+ export interface RailsResponse {
4
+ reload?: boolean;
5
+ status: "OK" | "ERROR";
6
+ toasts: Toast[];
7
+ }
8
+
9
+ export type DispatchFunc<T> = (action: T) => void;
10
+ export type DispatchHandler<T> = (action: T) => {
11
+ body?: string;
12
+ bodyJson?: object;
13
+ headers?: { [key: string]: string };
14
+ href?: string;
15
+ method?: string;
16
+ };
17
+
18
+ export type ReducerHandler<AppAction> = (dispatch: DispatchFunc<AppAction>) => DispatchFunc<AppAction>;
19
+
20
+ export interface GenericAppAction {
21
+ type: string;
22
+ }
23
+
24
+ export type RailsAppAction<R extends RailsResponse = RailsResponse> =
25
+ | { type: "saveComplete"; res: R }
26
+ | { type: "saveFailed" };
27
+
28
+ type ExtractField<T, K extends string> = T extends { [key in K]: infer U } ? U : never;
29
+ export type AppActionType<
30
+ A extends GenericAppAction,
31
+ Res extends RailsResponse = RailsResponse
32
+ > =
33
+ | ExtractField<A, "type">
34
+ | ExtractField<RailsAppAction<Res>, "type">;
@@ -0,0 +1,74 @@
1
+ import {
2
+ AppActionType,
3
+ DispatchFunc,
4
+ DispatchHandler,
5
+ GenericAppAction,
6
+ RailsAppAction,
7
+ RailsResponse,
8
+ } from "./types";
9
+ import railsFetchJson from "../railsFetchJson";
10
+
11
+ type AppAction<Res extends RailsResponse = RailsResponse> =
12
+ | GenericAppAction
13
+ | RailsAppAction<Res>;
14
+
15
+ export default function wrapDispatch<
16
+ A extends AppAction<ServerResponse>,
17
+ ServerResponse extends RailsResponse = RailsResponse
18
+ >(
19
+ dispatch: DispatchFunc<A>,
20
+ appActionTypes: AppActionType<A>[],
21
+ handler: DispatchHandler<A>
22
+ ): DispatchFunc<A> {
23
+ const updateDebounceTime = (("testEnv" in window) && window.testEnv) ? 50 : 250;
24
+
25
+ const updateHandler = async (action: A, signal: AbortSignal) => {
26
+ const { bodyJson, headers, href, method } = handler(action);
27
+
28
+ if (!href || !bodyJson) return;
29
+
30
+ const requestHeaders = headers || {};
31
+
32
+ try {
33
+ const res = await railsFetchJson<ServerResponse>(
34
+ href,
35
+ { bodyJson, headers: requestHeaders, method: method || "POST", signal }
36
+ );
37
+
38
+ dispatch({ res, type: "saveComplete" } as A);
39
+ } catch(e) {
40
+ // Ignore request aborts
41
+ if (e instanceof DOMException) return;
42
+
43
+ dispatch({ type: "saveFailed" } as A);
44
+ }
45
+ };
46
+
47
+ const timers: { [id: string]: number } = {};
48
+ const abortControllers: { [id: string]: AbortController } = {};
49
+
50
+ return (action: A) => {
51
+ // Run it like normal
52
+ dispatch(action);
53
+
54
+ // Skip if it's not one of the appActionTypes we care about
55
+ if (!appActionTypes.includes(action.type as AppActionType<A>)) return;
56
+
57
+ const timeoutKey = "userTimeout";
58
+
59
+ // Stop previous request
60
+ const timer = timers[timeoutKey];
61
+ if (timer) clearTimeout(timer);
62
+ delete timers[timeoutKey];
63
+ const oldAbortController = abortControllers[timeoutKey];
64
+ if (oldAbortController) oldAbortController.abort();
65
+ delete abortControllers[timeoutKey];
66
+
67
+ // Make request
68
+ const abortController = abortControllers[timeoutKey] = new AbortController();
69
+ const signal = abortController.signal;
70
+ timers[timeoutKey] = setTimeout(() => {
71
+ void updateHandler(action, signal);
72
+ }, updateDebounceTime);
73
+ };
74
+ }
package/Toasts/Toast.tsx CHANGED
@@ -1,7 +1,7 @@
1
1
  import React from "react";
2
2
  import classNames from "classnames";
3
3
  import { createUseStyles } from "react-jss";
4
- import { DismissFunc, ToastProps, Toast_Status } from "./types";
4
+ import { DismissFunc, Toast_Status, ToastProps } from "./types";
5
5
  import FontAwesomeIcon from "../FontAwesomeIcon";
6
6
 
7
7
  interface Props {
package/Toasts/Toasts.tsx CHANGED
@@ -2,9 +2,9 @@ import React from "react";
2
2
  import classNames from "classnames";
3
3
  import { createUseStyles } from "react-jss";
4
4
  import Toast from "./Toast";
5
- import createDismissToast from "./createDismissToast";
5
+ import { createDismissToast } from "./toastHelpers";
6
6
  import { DismissFunc, ToastProps, ToastRemoveAppAction } from "./types";
7
- import { DispatchFunc } from "../types";
7
+ import { DispatchFunc } from "../";
8
8
 
9
9
  interface BaseProps {
10
10
  toasts?: ToastProps[];
package/Toasts/index.tsx CHANGED
@@ -1,5 +1,8 @@
1
1
  export { default as Toasts } from "./Toasts";
2
- export { default as createDismissToast } from "./createDismissToast";
2
+ export * from "./toastHelpers";
3
3
 
4
- export { Toast_Status } from "./types";
5
- export type { Toast, ToastAppAction, ToastProps } from "./types";
4
+ export * from "./types";
5
+ export type * from "./types";
6
+
7
+ // Legacy exports for compatibility
8
+ export { createDismissToast } from "./toastHelpers";
@@ -0,0 +1,47 @@
1
+ import { Draft } from "immer";
2
+ import { Toast, ToastProps, ToastRemoveAppAction } from "./types";
3
+ import { AppStateWithToasts, DispatchFunc } from "../";
4
+
5
+ export function createDismissToast(dispatch: DispatchFunc<ToastRemoveAppAction>) {
6
+ const dismissToast = (toastId: number) => {
7
+ dispatch({ toastId, type: "dismissToast" });
8
+
9
+ setTimeout(() => dispatch({ toastId, type: "removeToast" }), 2000);
10
+ };
11
+
12
+ return dismissToast;
13
+ }
14
+
15
+ export function handleToasts<T extends AppStateWithToasts>(draft: Draft<T>, toasts: Toast[]) {
16
+ const handleToastsFunc = () => {
17
+ if (toasts.length > 0) {
18
+ toasts.forEach(toast => addToast(draft, toast));
19
+ }
20
+ };
21
+
22
+ return handleToastsFunc;
23
+ }
24
+
25
+ export function addToast<T extends AppStateWithToasts>(draft: Draft<T>, toastOptions: Toast) {
26
+ const { body, status, timeout, title } = toastOptions;
27
+
28
+ // Increment current toastId to prevent collisions
29
+ const id = draft.toastId ? draft.toastId + 1 : 1;
30
+ draft.toastId = id;
31
+
32
+ const toast = { body, id, status, timeout, title } as ToastProps;
33
+
34
+ // Initialize toasts array if not provided by the server
35
+ if (!draft.toasts) draft.toasts = [];
36
+
37
+ // Do not store multiple identical toasts
38
+ if (draft.toasts.some(t => {
39
+ return (
40
+ toast.body === t.body &&
41
+ toast.status === t.status &&
42
+ toast.title === t.title
43
+ );
44
+ })) return;
45
+
46
+ draft.toasts.push(toast);
47
+ }
package/eslint.config.mjs CHANGED
@@ -30,6 +30,13 @@ export default tseslint.config(
30
30
  "quotes": ["error", "double"],
31
31
  "semi": ["error", "always"],
32
32
  "sort-keys": ["error", "asc", { caseSensitive: true, minKeys: 2, natural: false }],
33
+ "sort-imports": [
34
+ "error",
35
+ {
36
+ "ignoreCase": true,
37
+ "ignoreDeclarationSort": true,
38
+ }
39
+ ],
33
40
  "arrow-parens": ["error", "as-needed"],
34
41
  "comma-dangle": [
35
42
  "error",
package/index.tsx CHANGED
@@ -1,3 +1,5 @@
1
+ export * from "./Reducer";
2
+
1
3
  export { default as FontAwesomeIcon } from "./FontAwesomeIcon";
2
4
 
3
5
  export { default as railsFetchJson } from "./railsFetchJson";
@@ -17,6 +19,7 @@ export { default as Colors } from "./Colors";
17
19
  export { default as Styles } from "./Styles";
18
20
 
19
21
  export type * from "./types";
22
+ export type * from "./Reducer";
20
23
  export type * from "./Colors";
21
24
  export type * from "./FontAwesomeIcon";
22
25
  export type * from "./ActionButton";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unitedstatespowersquadrons/components",
3
- "version": "1.0.9",
3
+ "version": "1.0.11",
4
4
  "description": "USPS shared React components library",
5
5
  "main": "index.tsx",
6
6
  "scripts": {
@@ -18,9 +18,11 @@
18
18
  "homepage": "https://github.com/unitedstatespowersquadrons/components#readme",
19
19
  "dependencies": {
20
20
  "classnames": "^2.5.1",
21
+ "immer": "^10.1.1",
21
22
  "react": "^19.1.0",
22
23
  "react-jss": "^10.10.0",
23
- "typescript": "^5.8.3"
24
+ "typescript": "^5.8.3",
25
+ "use-immer": "^0.11.0"
24
26
  },
25
27
  "devDependencies": {
26
28
  "@stylistic/eslint-plugin": "^5.1.0",
package/types.ts CHANGED
@@ -1,20 +1,11 @@
1
1
  import React from "react";
2
- import { Toast, ToastProps } from "./Toasts";
2
+ import { ToastProps } from "./Toasts";
3
3
 
4
4
  export interface AppStateWithToasts {
5
5
  toasts: ToastProps[];
6
6
  toastId?: number;
7
7
  }
8
8
 
9
- export type DispatchFunc<T> = (action: T) => void;
10
- export type DispatchHandler<T> = (action: T) => {
11
- body?: string;
12
- bodyJson?: object;
13
- headers?: { [key: string]: string };
14
- href?: string;
15
- method?: string;
16
- };
17
-
18
9
  export type OnClickHandler = (event: React.MouseEvent<HTMLButtonElement>) => void;
19
10
  export type OnInputChangeHandler = (event: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => void;
20
11
  export type OnClickOrKeyboardHandler = (event: OnClickOrKeyboardEvent) => void;
@@ -44,9 +35,3 @@ export interface OnClickProps {
44
35
  confirm?: string | undefined;
45
36
  onClick: OnClickHandler;
46
37
  }
47
-
48
- export interface RailsResponse {
49
- reload?: boolean;
50
- status: "OK" | "ERROR";
51
- toasts: Toast[];
52
- }
@@ -1,16 +0,0 @@
1
- import { ToastRemoveAppAction } from "./types";
2
- import { DispatchFunc } from "../types";
3
-
4
- const createDismissToast = (dispatch: DispatchFunc<ToastRemoveAppAction>) => {
5
- const dismissToast = (toastId: number) => {
6
- dispatch({ toastId, type: "dismissToast" });
7
-
8
- setTimeout(() => {
9
- dispatch({ toastId, type: "removeToast" });
10
- }, 2000);
11
- };
12
-
13
- return dismissToast;
14
- };
15
-
16
- export default createDismissToast;