Skip to content

Commit

Permalink
wip: added useBackendApi + changed toast types
Browse files Browse the repository at this point in the history
  • Loading branch information
tabarra committed Dec 8, 2023
1 parent 29d1df5 commit 8ab710c
Show file tree
Hide file tree
Showing 9 changed files with 317 additions and 21 deletions.
4 changes: 2 additions & 2 deletions core/components/WebServer/middlewares/authMws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,8 @@ export const apiAuthMw = async (ctx: InitializedCtx, next: Function) => {
//Doing ApiAuthErrorResp & GenericApiErrorResp to maintain compatibility with all routes
//"error" is used by diagnostic, masterActions, playerlist, whitelist and possibly more
return sendTypedResp({
type: 'danger',
message: msg,
type: 'error',
msg: msg,
error: msg
});
}
Expand Down
1 change: 1 addition & 0 deletions docs/dev_notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ Processo:
- [x] markdown toasts
- [x] error toasts with discord link
- [x][2h] prompts API
- [x][2d] useBackendApi hook - wrapper around fetch with optional toast management
- [ ][2h] server controls
- [ ][1h] server scheduled restarts (legacy style)
- [ ][3d] playerlist
Expand Down
35 changes: 21 additions & 14 deletions panel/src/components/TxToaster.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,29 +4,26 @@ import { cva } from "class-variance-authority";
import { AlertCircleIcon, AlertOctagonIcon, CheckCircleIcon, ChevronRightCircle, InfoIcon, Loader2Icon, XIcon } from "lucide-react";
import toast, { Toast, Toaster } from "react-hot-toast";
import { useEffect, useState } from "react";
import { ApiToastResp } from "@shared/genericApiTypes";


/**
* Types
*/
type TxToastType = 'default' | 'loading' | 'info' | 'success' | 'warning' | 'error';
export const validToastTypes = ['default', 'loading', 'info', 'success', 'warning', 'error'] as const;
type TxToastType = typeof validToastTypes[number];

type TxToastData = string | {
title?: string;
message: string;
md?: true
md?: boolean
msg: string;
}

type TxToastOptions = {
id?: string;
duration?: number;
}

type CustomToastProps = {
t: Toast,
type: TxToastType,
data: TxToastData,
}


/**
* Components
Expand Down Expand Up @@ -62,6 +59,12 @@ const toastIconMap = {
error: <AlertOctagonIcon className="stroke-destructive animate-toastbar-icon" />,
} as const;

type CustomToastProps = {
t: Toast,
type: TxToastType,
data: TxToastData,
}

export const CustomToast = ({ t, type, data }: CustomToastProps) => {
const [elapsedTime, setElapsedTime] = useState(0);

Expand Down Expand Up @@ -100,12 +103,12 @@ export const CustomToast = ({ t, type, data }: CustomToastProps) => {
) : data.md ? (
<>
<MarkdownProse md={`**${data.title}**`} isSmall isTitle />
<MarkdownProse md={data.message} isSmall />
<MarkdownProse md={data.msg} isSmall />
</>
) : (
<>
<span className="font-semibold mb-1">{data.title}</span>
<span className="block whitespace-pre-line">{data.message}</span>
<span className="block whitespace-pre-line">{data.msg}</span>
</>
)}
{type === 'error' && (
Expand Down Expand Up @@ -156,8 +159,12 @@ const callToast = (type: TxToastType, data: TxToastData, options: TxToastOptions
}, options);
}

//Exported functions
export const txToast = {
//Public interface
const genericToast = (data: ApiToastResp & { title?: string }, options?: TxToastOptions) => {
return callToast(data.type, data, options);
}

export const txToast = Object.assign(genericToast, {
default: (data: TxToastData, options?: TxToastOptions) => callToast('default', data, options),
loading: (data: TxToastData, options?: TxToastOptions) => callToast('loading', data, options),
info: (data: TxToastData, options?: TxToastOptions) => callToast('info', data, options),
Expand All @@ -166,4 +173,4 @@ export const txToast = {
error: (data: TxToastData, options?: TxToastOptions) => callToast('error', data, options),
dismiss: toast.dismiss,
remove: toast.remove,
}
});
9 changes: 9 additions & 0 deletions panel/src/hooks/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai';
*/
const authDataAtom = atom<ReactAuthDataType | false>(window.txConsts.preAuth);
const isAuthenticatedAtom = atom((get) => !!get(authDataAtom))
const csrfTokenAtom = atom((get) => {
const authData = get(authDataAtom);
return authData ? authData.csrfToken : undefined;
});


/**
Expand All @@ -18,6 +22,11 @@ export const useIsAuthenticated = () => {
return useAtomValue(isAuthenticatedAtom);
};

//CSRF hook, only re-renders on login/logout
export const useCsrfToken = () => {
return useAtomValue(csrfTokenAtom);
};

//Wipes auth data from the atom, this is triggered when an api pr page call returns a logout notice
//Since this is triggered by a logout notice, we don't need to bother doing a POST /auth/logout
export const useExpireAuthData = () => {
Expand Down
180 changes: 180 additions & 0 deletions panel/src/hooks/useBackendApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import { txToast, validToastTypes } from "@/components/TxToaster";
import { useCsrfToken, useExpireAuthData } from "@/hooks/auth";
import { useEffect, useRef } from "react";

export enum ApiTimeout {
DEFAULT = 7_500,
LONG = 15_000,
REALLY_LONG = 30_000,
REALLY_REALLY_LONG = 45_000,
}

export class BackendApiError extends Error {
title: string;
message: string;

constructor(title: string, message: string) {
super();
this.title = title;
this.message = message;
}
}

type HookOpts = {
//I'm pretty sure the webpipe supports only GET and POST
method: 'GET' | 'POST';
path: string;
abortOnUnmount?: boolean;
}

type ApiCallOpts<RespType, ReqType> = {
pathParams?: {
[key: string]: string;
};
queryParams?: {
[key: string]: string | number | boolean;
};
timeout?: ApiTimeout;
data?: ReqType;
toastId?: string;
toastLoadingMessage?: string;
success?: (data: RespType, toastId?: string) => void;
error?: (message: string, toastId?: string) => void;
}


/**
* Hook that provides a function to call the txAdmin API
*/
export const useBackendApi = <
RespType = any,
ReqType = NonNullable<Object>,
>(hookOpts: HookOpts) => {
const abortController = useRef<AbortController | undefined>(undefined);
const currentToastId = useRef<string | undefined>(undefined);
const expireSess = useExpireAuthData();
const csrfToken = useCsrfToken();
hookOpts.abortOnUnmount ??= false;
useEffect(() => {
return () => {
if (!hookOpts.abortOnUnmount) return
abortController.current?.abort();
if (currentToastId.current) {
txToast.dismiss(currentToastId.current);
}
}
}, []);

return async (opts: ApiCallOpts<RespType, ReqType>) => {
//Processing URL
let fetchUrl = hookOpts.path;
if (opts.pathParams) {
for (const [key, val] of Object.entries(opts.pathParams)) {
fetchUrl = fetchUrl.replace(`/:${key}/`, `/${val.toString()}/`);
}
}
if(opts.queryParams){
const params = new URLSearchParams();
for (const [key, val] of Object.entries(opts.queryParams)) {
params.append(key, val.toString());
}
fetchUrl += `?${params.toString()}`;
}
const apiCallDesc = `${hookOpts.method} ${hookOpts.path}`;

//Error handler
const handleError = (title: string, msg: string) => {
if (currentToastId.current) {
txToast.error({ title, msg, }, { id: currentToastId.current });
}
if (opts.error) {
try {
opts.error(msg, currentToastId.current);
} catch (error) {
console.log('[ERROR CB ERROR]', apiCallDesc, error);
}
} else {
throw new BackendApiError(title, msg);
}
}

//Setting up toast
if (opts.toastId && opts.toastLoadingMessage) {
throw new Error(`[useBackendApi] toastId and toastLoadingMessage are mutually exclusive.`);
} else if (opts.toastLoadingMessage) {
currentToastId.current = txToast.loading(opts.toastLoadingMessage);
} else if (opts.toastId) {
currentToastId.current = opts.toastId;
} else {
//cleaning last toast id
currentToastId.current = undefined;
}

//Starting timeout
abortController.current = new AbortController();
const timeoutId = setTimeout(() => {
if (abortController.current?.signal.aborted) return;
console.log('[TIMEOUT]', apiCallDesc);
abortController.current?.abort();
handleError('Request Timeout', 'If you closed txAdmin, please restart it and try again.');
}, opts.timeout ?? ApiTimeout.DEFAULT);

try {
//Make request
if (!csrfToken) throw new Error('CSRF token not set');
console.log('[>>]', apiCallDesc);
const resp = await fetch(fetchUrl, {
method: hookOpts.method,
headers: {
'Content-Type': 'application/json; charset=UTF-8',
'Accept': 'application/json',
'X-TxAdmin-CsrfToken': csrfToken,
},
body: opts.data ? JSON.stringify(opts.data) : undefined,
signal: abortController.current?.signal,
});
clearTimeout(timeoutId);
if (abortController.current?.signal.aborted) return;
const data = await resp.json();
if (data?.logout) {
expireSess('api');
throw new Error('Session expired');
}

//Success
if (
currentToastId.current
&& typeof data?.type === 'string'
&& typeof data?.msg === 'string'
&& validToastTypes.includes(data?.type)
&& typeof txToast[data.type as keyof typeof txToast] === 'function'
) {
txToast[data.type as keyof typeof txToast](data, { id: currentToastId.current });
}
if (opts.success) {
try {
opts.success(data, currentToastId.current);
} catch (error) {
console.log('[SUCCESS CB ERROR]', apiCallDesc, error);
}
}
return data as RespType;

} catch (e) {
if (abortController.current?.signal.aborted) return;
let errorMessage = 'unknown error';
const error = e as any;
if (typeof error.message !== 'string') {
errorMessage = JSON.stringify(error);
} else if (error.message.startsWith('NetworkError')) {
errorMessage = 'Network error.\nIf you closed txAdmin, please restart it and try again.';
} else if (error.message.startsWith('JSON.parse:')) {
errorMessage = 'Invalid JSON response from server.';
} else {
errorMessage = error.message;
}
console.error('[ERROR]', apiCallDesc, errorMessage);
handleError('Request Error', errorMessage);
}
}
}
2 changes: 2 additions & 0 deletions panel/src/pages/testing/TestingPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ import TmpWarningBarState from "./TmpWarningBarState";
import { useSetPageTitle } from "@/hooks/pages";
import TmpSocket from "./TmpSocket";
import TmpToasts from "./TmpToasts";
import TmpApi from "./TmpApi";


export default function TestingPage() {
const setPageTitle = useSetPageTitle();
setPageTitle();

return <div className="flex flex-col gap-4 w-full m-4">
{/* <TmpApi /> */}
{/* <TmpToasts /> */}
{/* <TmpSocket /> */}
{/* <TmpWarningBarState /> */}
Expand Down

0 comments on commit 8ab710c

Please sign in to comment.