Files
buzz/components/form.tsx
T
dmgnr f7bd43087d
Build and Push Image / build-backend (push) Successful in 1m4s
Build and Push Image / build-frontend (push) Successful in 2m52s
Build and Push Image / push (push) Successful in 1m16s
feat: add mobile upload
2026-08-29 15:32:47 +07:00

588 lines
16 KiB
TypeScript
Executable File

// oxlint-disable react-hooks/exhaustive-deps
"use client";
import { Divide } from "lucide-react";
import * as React from "react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import type { FormSubmitResult } from "./form-submit";
import { DialogClose } from "./ui/dialog";
import { Label } from "./ui/label";
import { Tabs, TabsList, TabsTrigger } from "./ui/tabs";
const AUTOSAVE_TTL_MS = 10 * 60 * 1000;
const STORAGE_PREFIX = "form:autosave:";
type FormValues = Record<string, unknown>;
type FormContextValue = {
id: string;
values: FormValues;
errors: Record<string, string>;
loading: boolean;
setValue: (name: string, value: unknown) => void;
updateValues: (updater: (prev: FormValues) => FormValues) => void;
clear: () => void;
submit: () => Promise<void>;
closeDialog: () => void;
setErrors: React.Dispatch<React.SetStateAction<Record<string, string>>>;
};
const FormContext = React.createContext<FormContextValue | null>(null);
export function useFormContext() {
const ctx = React.useContext(FormContext);
if (!ctx) throw new Error("FormInput/FormAction must be inside FormProvider");
return ctx;
}
function storageKey(id: string) {
return `${STORAGE_PREFIX}${id}`;
}
function safeParseAutosave(value: string | null) {
if (!value) return null;
try {
return JSON.parse(value) as { savedAt: number; values: FormValues };
} catch {
return null;
}
}
function pruneAutosaveValues(values: FormValues) {
const pruned: FormValues = {};
for (const [key, value] of Object.entries(values)) {
if (value == null) continue;
if (value instanceof Blob) continue;
if (Array.isArray(value)) {
const items = value.filter(
(item) =>
typeof item === "string" ||
typeof item === "number" ||
typeof item === "boolean",
);
if (items.length) pruned[key] = items;
continue;
}
if (
typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean"
) {
pruned[key] = value;
}
}
return pruned;
}
function appendFormValue(form: FormData, key: string, value: unknown) {
if (value == null) return;
if (Array.isArray(value)) {
for (const item of value) appendFormValue(form, key, item);
return;
}
if (value instanceof Blob) {
form.append(key, value);
return;
}
form.append(key, typeof value === "string" ? value : String(value));
}
function getInputValue(eventOrValue: unknown) {
const event = eventOrValue as React.ChangeEvent<HTMLInputElement>;
const target = event?.target as
| (HTMLInputElement & { files?: FileList | null })
| undefined;
if (target) {
if (target.type === "checkbox") return target.checked;
if (target.files)
return target.multiple
? Array.from(target.files)
: target.files[0] || null;
if (target instanceof HTMLSelectElement && target.multiple)
return Array.from(target.selectedOptions).map((opt) => opt.value);
return target.value;
}
return eventOrValue;
}
type FormProviderProps = Omit<
React.ComponentProps<"form">,
"id" | "onSubmit"
> & {
// Unique Autosave Key
id: string;
onSubmit?: (form: FormData) => FormSubmitResult | Promise<FormSubmitResult>;
values?: FormValues;
inDialog?: boolean;
};
export function FormProvider({
id,
onSubmit,
className,
children,
inDialog = true,
values: defaultValues = {},
...props
}: FormProviderProps) {
const [values, setValues] = React.useState<FormValues>(defaultValues);
const [errors, setErrors] = React.useState<Record<string, string>>({});
const [loading, setLoading] = React.useState(false);
const formRef = React.useRef<HTMLFormElement | null>(null);
const [storageLock, setStorageLock] = React.useState<"r" | "w" | null>("r");
const closerRef = React.useRef<HTMLButtonElement | null>(null);
React.useEffect(() => {
const stored = safeParseAutosave(localStorage.getItem(storageKey(id)));
if (!stored) {
setStorageLock(null);
return;
}
if (Date.now() - stored.savedAt > AUTOSAVE_TTL_MS) {
localStorage.removeItem(storageKey(id));
setStorageLock(null);
return;
}
setValues(stored.values || {});
setStorageLock(null);
}, [id]);
React.useEffect(() => {
if (storageLock === "r") return;
setStorageLock("w");
const pruned = pruneAutosaveValues(values);
localStorage.setItem(
storageKey(id),
JSON.stringify({ savedAt: Date.now(), values: pruned }),
);
setStorageLock(null);
}, [id, values, storageLock]);
const setValue = React.useCallback((name: string, value: unknown) => {
setValues((prev) => ({ ...prev, [name]: value }));
setErrors((prev) => {
if (!prev[name]) return prev;
const next = { ...prev };
delete next[name];
return next;
});
}, []);
const updateValues = React.useCallback(
(updater: (prev: FormValues) => FormValues) => {
setValues((prev) => updater(prev));
},
[],
);
const clear = React.useCallback(() => {
setValues({});
setErrors({});
localStorage.removeItem(storageKey(id));
formRef.current?.reset();
}, [id]);
const closeDialog = React.useCallback(() => {
closerRef.current?.click();
}, []);
const submit = React.useCallback(async () => {
const formData = new FormData();
for (const [key, value] of Object.entries(values)) {
appendFormValue(formData, key, value);
}
setLoading(true);
setErrors({});
try {
const res = await onSubmit?.(formData);
if (res && typeof res === "object") {
if ("error" in res) {
const { error } = res;
if (typeof error === "string") {
toast.error(error);
} else if (Array.isArray(error)) {
const fieldErrors: Record<string, string> = {};
for (const e of error) {
fieldErrors[e.where] = e.what;
}
setErrors(fieldErrors);
} else if (error) {
setErrors({ [error.where]: error.what });
}
} else {
setErrors({});
}
if ("toast" in res) {
const { toast: toastMsg } = res;
if (typeof toastMsg === "string") toast(toastMsg);
}
if ("close" in res) {
const { close } = res;
if (typeof close === "boolean") {
if (close) closerRef.current?.click();
}
}
if ("reset" in res) {
const { reset } = res;
if (reset) clear();
}
}
} catch (error) {
const message =
error instanceof Error
? error.message
: typeof error === "string"
? error
: "Something went wrong";
if (message.includes("NEXT_REDIRECT")) return;
toast.error(message);
} finally {
setLoading(false);
}
}, [onSubmit, values, clear]);
const contextValue = React.useMemo<FormContextValue>(
() => ({
id,
values,
errors,
loading,
setValue,
updateValues,
clear,
submit,
closeDialog,
setErrors,
}),
[
id,
values,
errors,
loading,
setValue,
updateValues,
clear,
submit,
closeDialog,
setErrors,
],
);
return (
<FormContext.Provider value={contextValue}>
<form
ref={formRef}
id={id}
{...props}
className={cn("grid gap-4 [&>label]:-mb-2", className)}
onSubmit={(event) => {
event.preventDefault();
submit();
}}
>
{children}
</form>
{inDialog && <DialogClose ref={closerRef} />}
</FormContext.Provider>
);
}
type FormInputProps = {
name: string;
// biome-ignore lint/suspicious/noExplicitAny: typescript too strict :<
override?: (original: any) => Partial<FormValues> | void;
label?: string;
subLabel?: string;
children: React.ReactElement;
className?: string;
};
export function FormInput({
name,
override,
children,
label,
subLabel,
className,
}: FormInputProps) {
const { values, setValue, updateValues, errors } = useFormContext();
const childProps = (children.props ?? {}) as Record<string, unknown>;
const value = values[name] ?? childProps?.defaultValue ?? "";
const nextProps: Record<string, unknown> = { name, defaultValue: undefined };
const applyValue = React.useCallback(
(nextValue: unknown) => {
if (!override) {
setValue(name, nextValue);
return;
}
updateValues((prev) => {
const nextValues = { ...prev, [name]: nextValue };
const overrides = override(nextValues);
if (!overrides || typeof overrides !== "object") return nextValues;
return { ...nextValues, ...overrides };
});
},
[name, override, setValue, updateValues],
);
// biome-ignore lint/correctness/useExhaustiveDependencies: must run only on initial render
React.useEffect(() => {
if (childProps.defaultValue !== undefined)
applyValue(childProps.defaultValue);
}, []);
nextProps.onCheckedChange = (checked: boolean) => {
applyValue(checked);
(childProps.onCheckedChange as ((value: boolean) => void) | undefined)?.(
checked,
);
};
nextProps.onValueChange = (nextValue: unknown) => {
applyValue(nextValue);
(childProps.onValueChange as ((value: unknown) => void) | undefined)?.(
nextValue,
);
};
nextProps.onChange = (event: unknown) => {
const nextValue = getInputValue(event);
applyValue(nextValue);
(childProps.onChange as ((value: unknown) => void) | undefined)?.(event);
};
if (value !== undefined) {
if (typeof value === "boolean") nextProps.checked = value;
else {
nextProps.value = value;
nextProps.checked = Boolean(value);
}
}
if (label) {
nextProps.id = `forminput-${name}`;
}
if (children.$$typeof === Symbol.for("react.lazy")) {
children = React.use((children as any)._payload);
}
return (
<>
{label ? (
<div className={cn("flex grow flex-col gap-2", className)}>
<Label htmlFor={`forminput-${name}`}>
{label}{" "}
{subLabel && (
<span className="font-normal opacity-70">{subLabel}</span>
)}
</Label>
{React.cloneElement(children || Divide, nextProps)}
{errors[name] && (
<span className="text-sm text-destructive">{errors[name]}</span>
)}
</div>
) : (
<div className={cn(className)}>
{React.cloneElement(children || Divide, nextProps)}
{errors[name] && (
<span className="text-sm text-destructive">{errors[name]}</span>
)}
</div>
)}
</>
);
}
type FormActionProps = Omit<
React.ComponentProps<"button">,
"type" | "children"
> & {
type: "clear" | "submit" | "action";
action?: () => FormSubmitResult | Promise<FormSubmitResult>;
children?: React.ReactNode;
loading?: React.ReactNode;
asChild?: boolean;
};
export function FormAction({
type,
children,
asChild,
action,
loading: loadingComponent,
...props
}: FormActionProps) {
const {
loading: formLoading,
clear,
closeDialog,
setErrors,
} = useFormContext();
const [actionLoading, setActionLoading] = React.useState(false);
const loading = formLoading || actionLoading;
const buttonProps = {
...props,
type: (type === "submit" ? "submit" : "button") as "submit" | "button",
disabled: type !== "clear" && loading,
onClick: async (event: React.MouseEvent<HTMLButtonElement>) => {
props.onClick?.(event);
if (!event.defaultPrevented && type === "action") {
setActionLoading(true);
try {
const res = await action?.();
if (res && typeof res === "object") {
if ("error" in res) {
const { error } = res;
if (typeof error === "string") {
toast.error(error);
} else if (Array.isArray(error)) {
const fieldErrors: Record<string, string> = {};
for (const e of error) {
fieldErrors[e.where] = e.what;
}
setErrors(fieldErrors);
} else if (error) {
setErrors({ [error.where]: error.what });
}
} else {
setErrors({});
}
if ("toast" in res) {
const { toast: toastMsg } = res;
if (typeof toastMsg === "string") toast(toastMsg);
}
if ("close" in res) {
const { close } = res;
if (typeof close === "boolean") {
if (close) closeDialog();
}
}
if ("reset" in res) {
const { reset } = res;
if (reset) clear();
}
}
} catch (error) {
const message =
error instanceof Error
? error.message
: typeof error === "string"
? error
: "Something went wrong";
toast.error(message);
} finally {
setActionLoading(false);
}
}
if (!event.defaultPrevented && type === "clear") clear();
},
};
if (asChild && React.isValidElement(children)) {
return React.cloneElement(
children as React.ReactElement<unknown>,
buttonProps,
);
}
return (
<button {...buttonProps}>
{loadingComponent && loading ? loadingComponent : children}
</button>
);
}
export function FormRow({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn("flex flex-wrap items-end gap-2", className)}
{...props}
/>
);
}
export function FormTab({
label,
name,
tabs,
children,
}: {
label?: React.ReactNode;
name: string;
tabs: { label: React.ReactNode; value: string }[];
children: React.ReactNode;
}) {
const { values, setValue } = useFormContext();
const active = (values[name] as string | undefined) ?? tabs[0]?.value;
React.useEffect(() => {
if (values[name] === undefined && tabs[0]) {
setValue(name, tabs[0].value);
}
// biome-ignore lint/correctness/useExhaustiveDependencies: only run on mount to seed default
}, []);
return (
<div className="flex flex-col gap-2">
<div className="flex w-full items-end justify-between">
{label}
<Tabs value={active} onValueChange={(v) => setValue(name, v)}>
<TabsList>
{tabs.map((tab) => (
<TabsTrigger key={tab.value} value={tab.value}>
{tab.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
</div>
<div className="rounded-md bg-muted-foreground/10 p-3">
{React.Children.map(children, (child) => {
if (!React.isValidElement(child)) return null;
const childValue = (child.props as Record<string, unknown>).value;
return childValue === active ? child : null;
})}
</div>
</div>
);
}
export function FormChoice({
children,
}: {
// biome-ignore lint/style/useExportType: not a type
value: string;
children: React.ReactNode;
}) {
return <>{children}</>;
}
export function FormIf({
children,
...conditions
}: {
children: React.ReactNode;
} & Record<string, unknown>) {
const { values } = useFormContext();
const match = Object.entries(conditions).every(
([name, equals]) => values[name] === equals,
);
return <div className={cn(!match && "hidden")}>{children}</div>;
}
export function FormWrapper({
children,
...props
}: React.ComponentProps<"div">) {
return (
<div {...props}>
{React.Children.map(children, (child) =>
React.isValidElement(child)
? React.cloneElement(child, props as Record<string, unknown>)
: child,
)}
</div>
);
}