You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

125 lines
3.6 KiB
TypeScript

"use client";
import { useActionState, useState } from "react";
import { Loader2Icon } from "lucide-react";
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Field, FieldLabel, FieldError } from "@/components/ui/field";
import type { FormState } from "@/lib/server-action";
// Hanya untuk input teks biasa. Form yang butuh select, textarea, atau lookup ditulis sendiri
// di _components fitur itu, jangan menambah opsi ke config ini.
export type MasterField<F extends string = string> = {
name: F;
label: string;
type?: React.HTMLInputTypeAttribute;
required?: boolean;
};
type MasterSheetFormProps<F extends string> = {
action: (
prevState: FormState<F>,
formData: FormData,
) => Promise<FormState<F>>;
fields: readonly MasterField<F>[];
defaultValues?: Partial<Record<F, string | number | null>>;
hiddenFields?: Record<string, string | number>;
title: string;
description: string;
trigger: React.ReactElement;
};
export function MasterSheetForm<F extends string>({
action,
fields,
defaultValues,
hiddenFields,
title,
description,
trigger,
}: MasterSheetFormProps<F>) {
const [open, setOpen] = useState(false);
const [state, formAction, pending] = useActionState(
async (prevState: FormState<F>, formData: FormData) => {
const result = await action(prevState, formData);
if (result.ok) setOpen(false);
return result;
},
{ ok: false },
);
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger render={trigger} />
<SheetContent>
<SheetHeader>
<SheetTitle>{title}</SheetTitle>
<SheetDescription>{description}</SheetDescription>
</SheetHeader>
<form
action={formAction}
className="flex flex-col gap-4 overflow-y-auto p-4"
>
{hiddenFields &&
Object.entries(hiddenFields).map(([name, value]) => (
<input key={name} type="hidden" name={name} value={value} />
))}
{fields.map((field) => {
const errors = state.fieldErrors?.[field.name];
return (
<Field key={field.name} data-invalid={!!errors}>
<FieldLabel htmlFor={field.name}>
{field.label}
{field.required && " *"}
</FieldLabel>
<Input
id={field.name}
name={field.name}
type={field.type}
onWheel={
field.type === "number"
? (e) => e.currentTarget.blur()
: undefined
}
defaultValue={
state.values?.[field.name] ??
defaultValues?.[field.name] ??
""
}
aria-invalid={!!errors}
required={field.required}
/>
<FieldError errors={errors?.map((message) => ({ message }))} />
</Field>
);
})}
{state.error && (
<p className="text-sm text-destructive">{state.error}</p>
)}
<SheetFooter className="px-0">
<Button type="submit" disabled={pending}>
{pending && <Loader2Icon className="size-4 animate-spin" />}
Simpan
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}