@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.
- package/Reducer/Example/reducer.tsx +35 -0
- package/Reducer/Example/reducerHandlers.tsx +25 -0
- package/Reducer/Example/types.ts +9 -0
- package/Reducer/buildReducer.tsx +60 -0
- package/Reducer/buildUnreducableState.tsx +31 -0
- package/Reducer/index.tsx +3 -0
- package/Reducer/types.ts +34 -0
- package/Reducer/wrapDispatch.tsx +74 -0
- package/Toasts/Toast.tsx +1 -1
- package/Toasts/Toasts.tsx +2 -2
- package/Toasts/index.tsx +6 -3
- package/Toasts/toastHelpers.ts +47 -0
- package/eslint.config.mjs +7 -0
- package/index.tsx +3 -0
- package/package.json +4 -2
- package/types.ts +1 -16
- package/Toasts/createDismissToast.ts +0 -16
|
@@ -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,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
|
+
}
|
package/Reducer/types.ts
ADDED
|
@@ -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,
|
|
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 "./
|
|
5
|
+
import { createDismissToast } from "./toastHelpers";
|
|
6
6
|
import { DismissFunc, ToastProps, ToastRemoveAppAction } from "./types";
|
|
7
|
-
import { DispatchFunc } from "../
|
|
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
|
|
2
|
+
export * from "./toastHelpers";
|
|
3
3
|
|
|
4
|
-
export
|
|
5
|
-
export type
|
|
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.
|
|
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 {
|
|
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;
|