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
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>
|
|
);
|
|
}
|