You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
bazarr/frontend/src/components/async.tsx

266 lines
6.0 KiB

import {
faCheck,
faCircleNotch,
faExclamationTriangle,
faTimes,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import React, {
FunctionComponent,
PropsWithChildren,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import { Alert, Button, ButtonProps, Container } from "react-bootstrap";
import { LoadingIndicator } from ".";
import { useNotification } from "../@redux/hooks/site";
import { Reload } from "../utilites";
import { Selector, SelectorProps } from "./inputs";
enum RequestState {
Success,
Error,
Invalid,
}
interface ChildProps<T> {
data: NonNullable<Readonly<T>>;
error?: Error;
}
interface AsyncStateOverlayProps<T> {
state: AsyncState<T>;
exist?: (item: T) => boolean;
children?: FunctionComponent<ChildProps<T>>;
}
function defaultExist(item: any) {
if (item instanceof Array) {
return item.length !== 0;
} else {
return item !== null && item !== undefined;
}
}
export function AsyncStateOverlay<T>(props: AsyncStateOverlayProps<T>) {
const { exist, state, children } = props;
const missing = exist ? !exist(state.data) : !defaultExist(state.data);
const onError = useNotification();
useEffect(() => {
if (!state.updating && state.error !== undefined && !missing) {
onError({
type: "error",
message: state.error.message,
});
}
}, [state, onError, missing]);
if (state.updating) {
if (missing) {
return <LoadingIndicator></LoadingIndicator>;
}
} else {
if (state.error && missing) {
return (
<Container>
<Alert variant="danger" className="my-4">
<Alert.Heading>
<FontAwesomeIcon
className="mr-2"
icon={faExclamationTriangle}
></FontAwesomeIcon>
<span>Ouch! You got an error</span>
</Alert.Heading>
<p>{state.error.message}</p>
<hr></hr>
<div className="d-flex justify-content-end">
<Button variant="outline-danger" onClick={Reload}>
Reload
</Button>
</div>
</Alert>
</Container>
);
}
}
return children ? children({ data: state.data!, error: state.error }) : null;
}
interface PromiseProps<T> {
promise: () => Promise<T>;
children: FunctionComponent<T>;
}
export function PromiseOverlay<T>({ promise, children }: PromiseProps<T>) {
const [item, setItem] = useState<T | null>(null);
useEffect(() => {
promise()
.then((result) => setItem(result))
.catch(() => {});
}, [promise]);
if (item === null) {
return <LoadingIndicator></LoadingIndicator>;
} else {
return children(item);
}
}
type ExtractAS<T extends AsyncState<any[]>> = Unpacked<AsyncPayload<T>>;
type AsyncSelectorProps<T extends AsyncState<any[]>> = {
state: T;
label: (item: ExtractAS<T>) => string;
};
type RemovedSelectorProps<T, M extends boolean> = Omit<
SelectorProps<T, M>,
"loading" | "options"
>;
export function AsyncSelector<
T extends AsyncState<any[]>,
M extends boolean = false
>(
props: Override<AsyncSelectorProps<T>, RemovedSelectorProps<ExtractAS<T>, M>>
) {
const { label, state, ...selector } = props;
const options = useMemo<SelectorOption<ExtractAS<T>>[]>(
() =>
state.data.map((v) => ({
label: label(v),
value: v,
})),
[state, label]
);
return (
<Selector
loading={state.updating}
options={options}
label={label}
{...selector}
></Selector>
);
}
interface AsyncButtonProps<T> {
as?: ButtonProps["as"];
variant?: ButtonProps["variant"];
size?: ButtonProps["size"];
className?: string;
disabled?: boolean;
onChange?: (v: boolean) => void;
noReset?: boolean;
animation?: boolean;
promise: () => Promise<T> | null;
onSuccess?: (result: T) => void;
error?: () => void;
}
export function AsyncButton<T>(
props: PropsWithChildren<AsyncButtonProps<T>>
): JSX.Element {
const {
children: propChildren,
className,
promise,
onSuccess,
noReset,
animation,
error,
onChange,
disabled,
...button
} = props;
const [loading, setLoading] = useState(false);
const [state, setState] = useState(RequestState.Invalid);
const [, setHandle] = useState<Nullable<NodeJS.Timeout>>(null);
useEffect(() => {
if (noReset) {
return;
}
if (state === RequestState.Error || state === RequestState.Success) {
const handle = setTimeout(() => setState(RequestState.Invalid), 2 * 1000);
setHandle(handle);
}
// Clear timeout handle so we wont leak memory
return () => {
setHandle((handle) => {
if (handle) {
clearTimeout(handle);
}
return null;
});
};
}, [state, noReset]);
const click = useCallback(() => {
if (state !== RequestState.Invalid) {
return;
}
const result = promise();
if (result) {
setLoading(true);
onChange && onChange(true);
result
.then((res) => {
setState(RequestState.Success);
onSuccess && onSuccess(res);
})
.catch(() => {
setState(RequestState.Error);
error && error();
})
.finally(() => {
setLoading(false);
onChange && onChange(false);
});
}
}, [error, onChange, promise, onSuccess, state]);
const showAnimation = animation ?? true;
let children = propChildren;
if (showAnimation) {
if (loading) {
children = <FontAwesomeIcon icon={faCircleNotch} spin></FontAwesomeIcon>;
}
if (state === RequestState.Success) {
children = <FontAwesomeIcon icon={faCheck}></FontAwesomeIcon>;
} else if (state === RequestState.Error) {
children = <FontAwesomeIcon icon={faTimes}></FontAwesomeIcon>;
}
}
return (
<Button
className={className}
disabled={loading || disabled || state !== RequestState.Invalid}
{...button}
onClick={click}
>
{children}
</Button>
);
}