feat: switch to a new form hook

This commit is contained in:
Aarnav Tale
2026-03-19 12:35:17 -04:00
parent 246bb90ef0
commit 718cbf3c40
8 changed files with 340 additions and 132 deletions
+181
View File
@@ -0,0 +1,181 @@
import { type, type Type } from "arktype";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
type FormValue = string | number | boolean | null;
interface FormState {
values: Record<string, FormValue>;
errors: Record<string, string>;
modified: Record<string, boolean>;
}
export interface FormFieldProps {
name: string;
value: string;
onChange: (value: string) => void;
onBlur: () => void;
invalid: boolean;
errorMessage?: string;
}
interface UseFormOptions<T extends Record<string, unknown>> {
schema: Type<T>;
defaultValues?: Partial<Record<keyof T & string, FormValue>>;
actionData?: unknown;
validate?: (values: Record<string, FormValue>) => Record<string, string> | undefined;
}
// arktype's Type<T> doesn't expose `.props` in the type system,
// but it exists at runtime. This extracts schema keys safely.
function schemaKeys(schema: Type): string[] {
const props = (schema as unknown as { props?: { key: string }[] }).props;
return props?.map((p) => p.key) ?? [];
}
export function useForm<T extends Record<string, unknown>>(options: UseFormOptions<T>) {
const { schema, actionData, validate } = options;
const keys = schemaKeys(schema);
const validateRef = useRef(validate);
validateRef.current = validate;
const initialValues = useMemo(() => {
const values: Record<string, FormValue> = {};
for (const key of keys) {
values[key] =
options.defaultValues && key in options.defaultValues
? (options.defaultValues[key as keyof T & string] as FormValue)
: "";
}
return values;
}, []);
const [state, setState] = useState<FormState>({
values: initialValues,
errors: {},
modified: {},
});
useEffect(() => {
if (actionData && typeof actionData === "object" && "errors" in actionData) {
const errors = actionData.errors as Record<string, string>;
setState((prev) => ({
...prev,
errors: { ...prev.errors, ...errors },
}));
}
}, [actionData]);
const setValue = useCallback((name: keyof T & string, raw: FormValue) => {
setState((prev) => ({
...prev,
values: { ...prev.values, [name]: raw },
modified: { ...prev.modified, [name]: true },
}));
}, []);
function validateField(name: string) {
if (!state.modified[name]) {
return;
}
const errors: Record<string, string> = {};
// Schema validation
const result = schema({ ...state.values } as never);
if (result instanceof type.errors) {
const fieldProblems = result.flatProblemsByPath[name];
if (fieldProblems) {
errors[name] = fieldProblems[0];
}
}
// Cross-field validation
if (!errors[name]) {
const customErrors = validateRef.current?.(state.values);
if (customErrors?.[name]) {
errors[name] = customErrors[name];
}
}
setState((prev) => ({
...prev,
errors: {
...prev.errors,
[name]: errors[name] ?? "",
},
}));
}
function field(name: keyof T & string): FormFieldProps {
const error = state.errors[name];
return {
name,
value: String(state.values[name] ?? ""),
onChange: (value: string) => setValue(name, value),
onBlur: () => validateField(name),
invalid: !!error,
errorMessage: error || undefined,
};
}
function computeCanSubmit(): boolean {
const result = schema({ ...state.values } as never);
if (result instanceof type.errors) {
return false;
}
const customErrors = validateRef.current?.(state.values);
if (customErrors && Object.values(customErrors).some(Boolean)) {
return false;
}
return true;
}
const reset = useCallback(() => {
setState({
values: { ...initialValues },
errors: {},
modified: {},
});
}, [initialValues]);
return {
values: state.values,
errors: state.errors,
field,
canSubmit: computeCanSubmit(),
setValue,
reset,
};
}
type ServerValidationResult<T> =
| { success: true; data: T }
| { success: false; errors: Record<string, string> };
export async function validateFormData<T extends Record<string, unknown>>(
request: Request,
schema: Type<T>,
): Promise<ServerValidationResult<T>> {
const formData = await request.formData();
const parsed: Record<string, unknown> = {};
for (const key of schemaKeys(schema)) {
const raw = formData.get(key);
if (raw !== null) {
parsed[key] = raw.toString();
}
}
const result = schema(parsed as never);
if (result instanceof type.errors) {
const errors: Record<string, string> = {};
for (const [path, problems] of Object.entries(result.flatProblemsByPath)) {
errors[path] = problems[0];
}
return { success: false, errors };
}
return { success: true, data: result as T };
}
+34 -22
View File
@@ -1,5 +1,5 @@
import { type } from "arktype";
import { Split } from "lucide-react";
import { useMemo, useState } from "react";
import Button from "~/components/button";
import Chip from "~/components/chip";
@@ -9,30 +9,40 @@ import Switch from "~/components/switch";
import Text from "~/components/text";
import Title from "~/components/title";
import Tooltip from "~/components/tooltip";
import { useForm } from "~/hooks/use-form";
import cn from "~/utils/cn";
const nsSchema = type({
ns: "string.ip",
split_name: "string > 0",
});
interface Props {
nameservers: Record<string, string[]>;
}
export default function AddNameserver({ nameservers }: Props) {
const [split, setSplit] = useState(false);
const [ns, setNs] = useState("");
const [domain, setDomain] = useState("");
const form = useForm({
schema: nsSchema,
defaultValues: { split_name: "global" },
validate: (values) => {
const ns = values.ns as string;
const domain = values.split_name as string;
if (!ns) return undefined;
const isInvalid = useMemo(() => {
if (ns === "") return false;
// Test if it's a valid IPv4 or IPv6 address
const ipv4 = /^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$/;
const ipv6 = /^([0-9a-fA-F:]+:+)+[0-9a-fA-F]+$/;
if (!ipv4.test(ns) && !ipv6.test(ns)) return true;
const isSplit = domain !== "global";
const isDuplicate = isSplit
? nameservers[domain]?.includes(ns)
: Object.values(nameservers).some((nsList) => nsList.includes(ns));
if (split) {
return nameservers[domain]?.includes(ns);
}
if (isDuplicate) {
return { ns: "This nameserver already exists." };
}
return Object.values(nameservers).some((nsList) => nsList.includes(ns));
}, [nameservers, ns]);
return undefined;
},
});
const split = (form.values.split_name as string) !== "global";
return (
<Dialog>
@@ -41,12 +51,10 @@ export default function AddNameserver({ nameservers }: Props) {
<Title className="mb-4">Add nameserver</Title>
<input name="action_id" type="hidden" value="add_ns" />
<Input
{...form.field("ns")}
description="Use this IPv4 or IPv6 address to resolve names."
invalid={isInvalid}
required
label="Nameserver"
name="ns"
onChange={setNs}
placeholder="1.2.3.4"
/>
<div className="mt-8 flex items-center justify-between">
@@ -67,16 +75,20 @@ export default function AddNameserver({ nameservers }: Props) {
</div>
<Text className="text-sm">This nameserver will only be used for some domains.</Text>
</div>
<Switch label="Split DNS" onCheckedChange={setSplit} />
<Switch
label="Split DNS"
onCheckedChange={(checked) => {
form.setValue("split_name", checked ? "" : "global");
}}
/>
</div>
{split ? (
<>
<Text className="mt-8 font-semibold">Domain</Text>
<Input
required={split === true}
{...form.field("split_name")}
required
label="Domain"
name="split_name"
onChange={setDomain}
placeholder="example.com"
/>
<Text className="text-sm">
+37 -25
View File
@@ -1,4 +1,4 @@
import { useMemo, useState } from "react";
import { type } from "arktype";
import Button from "~/components/button";
import Code from "~/components/code";
@@ -7,33 +7,49 @@ import Input from "~/components/input";
import Select from "~/components/select";
import Text from "~/components/text";
import Title from "~/components/title";
import { useForm } from "~/hooks/use-form";
const recordSchema = type({
record_type: "'A' | 'AAAA'",
record_name: "string > 0",
record_value: "string > 0",
});
interface Props {
records: { name: string; type: "A" | "AAAA" | string; value: string }[];
}
export default function AddRecord({ records }: Props) {
const [type, setType] = useState<"A" | "AAAA" | string>("A");
const [name, setName] = useState("");
const [ip, setIp] = useState("");
const form = useForm({
schema: recordSchema,
defaultValues: { record_type: "A" },
validate: (values) => {
const name = values.record_name as string;
const ip = values.record_value as string;
if (name.length === 0 || ip.length === 0) return undefined;
const isDuplicate = useMemo(() => {
if (name.length === 0 || ip.length === 0) return false;
const lookup = records.find((record) => record.name === name);
if (!lookup) return false;
const lookup = records.find((r) => r.name === name);
if (lookup?.value === ip) {
return {
record_name: "This record already exists.",
record_value: "This record already exists.",
};
}
return lookup.value === ip;
}, [records, name, ip]);
return undefined;
},
});
const name = form.values.record_name as string;
const ip = form.values.record_value as string;
const recordType = form.values.record_type as string;
const isDuplicate =
!!form.errors.record_name?.includes("already exists") &&
!!form.errors.record_value?.includes("already exists");
return (
<Dialog>
<Button>Add DNS record</Button>
<DialogPanel
onSubmit={() => {
setName("");
setIp("");
}}
>
<DialogPanel onSubmit={() => form.reset()}>
<Title>Add DNS record</Title>
<Text>Enter the domain and IP address for the new DNS record.</Text>
<div className="mt-4 flex flex-col gap-2">
@@ -42,9 +58,9 @@ export default function AddRecord({ records }: Props) {
required
label="Record Type"
name="record_type"
defaultValue={type}
defaultValue={recordType}
onValueChange={(v) => {
if (v) setType(v as "A" | "AAAA");
if (v) form.setValue("record_type", v);
}}
items={[
{ value: "A", label: "A" },
@@ -52,20 +68,16 @@ export default function AddRecord({ records }: Props) {
]}
/>
<Input
{...form.field("record_name")}
required
label="Domain"
placeholder="test.example.com"
name="record_name"
onChange={setName}
invalid={isDuplicate}
/>
<Input
{...form.field("record_value")}
required
label="IP Address"
placeholder={type === "AAAA" ? "2001:db8::ff00:42:8329" : "101.101.101.101"}
name="record_value"
onChange={setIp}
invalid={isDuplicate}
placeholder={recordType === "AAAA" ? "2001:db8::ff00:42:8329" : "101.101.101.101"}
/>
{isDuplicate ? (
<p className="text-sm opacity-50">
+11 -8
View File
@@ -1,3 +1,4 @@
import { type } from "arktype";
import { Computer, FileKey2 } from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router";
@@ -9,9 +10,15 @@ import { Menu, MenuContent, MenuItem, MenuTrigger } from "~/components/menu";
import Select from "~/components/select";
import Text from "~/components/text";
import Title from "~/components/title";
import { useForm } from "~/hooks/use-form";
import type { User } from "~/types";
import { getUserDisplayName } from "~/utils/user";
const registerSchema = type({
register_key: "string == 24",
user: "string > 0",
});
export interface NewMachineProps {
server: string;
users: User[];
@@ -21,15 +28,13 @@ export interface NewMachineProps {
export default function NewMachine(data: NewMachineProps) {
const [pushDialog, setPushDialog] = useState(false);
const [mkey, setMkey] = useState("");
const form = useForm({ schema: registerSchema });
const navigate = useNavigate();
const isMkeyInvalid = mkey.length > 0 && mkey.length !== 24;
return (
<>
<Dialog isOpen={pushDialog} onOpenChange={setPushDialog}>
<DialogPanel isDisabled={mkey.length !== 24}>
<DialogPanel isDisabled={!form.canSubmit}>
<Title>Register Machine Key</Title>
<Text className="mb-4">
The machine key is given when you run{" "}
@@ -37,18 +42,16 @@ export default function NewMachine(data: NewMachineProps) {
</Text>
<input name="action_id" type="hidden" value="register" />
<Input
errorMessage="Machine key must be exactly 24 characters"
invalid={isMkeyInvalid}
{...form.field("register_key")}
required
label="Machine Key"
name="register_key"
onChange={setMkey}
placeholder="AbCd..."
/>
<Select
required
label="Owner"
name="user"
onValueChange={(v) => form.setValue("user", v)}
placeholder="Select a user"
items={data.users.map((user) => ({
value: user.id,
+12 -10
View File
@@ -1,12 +1,17 @@
import { useState } from "react";
import { type } from "arktype";
import Code from "~/components/code";
import Dialog, { DialogPanel } from "~/components/dialog";
import Input from "~/components/input";
import Text from "~/components/text";
import Title from "~/components/title";
import { useForm } from "~/hooks/use-form";
import type { Machine } from "~/types";
const renameSchema = type({
name: "string > 0",
});
interface RenameProps {
machine: Machine;
isOpen: boolean;
@@ -15,7 +20,11 @@ interface RenameProps {
}
export default function Rename({ machine, magic, isOpen, setIsOpen }: RenameProps) {
const [name, setName] = useState(machine.givenName);
const form = useForm({
schema: renameSchema,
defaultValues: { name: machine.givenName },
});
const name = form.values.name as string;
return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
@@ -27,14 +36,7 @@ export default function Rename({ machine, magic, isOpen, setIsOpen }: RenameProp
</Text>
<input name="action_id" type="hidden" value="rename" />
<input name="node_id" type="hidden" value={machine.id} />
<Input
defaultValue={machine.givenName}
required
label="Machine name"
name="name"
onChange={setName}
placeholder="Machine name"
/>
<Input {...form.field("name")} required label="Machine name" placeholder="Machine name" />
{magic ? (
name.length > 0 && name !== machine.givenName ? (
<p className="mt-2 text-sm leading-tight text-mist-600 dark:text-mist-300">
@@ -1,10 +1,15 @@
import { useMemo, useState } from "react";
import { type } from "arktype";
import Button from "~/components/button";
import Dialog, { DialogPanel } from "~/components/dialog";
import Input from "~/components/input";
import Text from "~/components/text";
import Title from "~/components/title";
import { useForm } from "~/hooks/use-form";
const domainSchema = type({
domain: "string > 0",
});
interface AddDomainProps {
domains: string[];
@@ -12,27 +17,29 @@ interface AddDomainProps {
}
export default function AddDomain({ domains, isDisabled }: AddDomainProps) {
const [domain, setDomain] = useState("");
const form = useForm({
schema: domainSchema,
validate: (values) => {
const domain = (values.domain as string).trim();
if (domain.length === 0) return undefined;
const isInvalid = useMemo(() => {
if (!domain || domain.trim().length === 0) {
// Empty domain is invalid, but no error shown
return false;
}
if (domains.includes(domain)) {
return { domain: "This domain already exists in the list." };
}
if (domains.includes(domain.trim())) {
return true;
}
try {
const url = new URL(`http://${domain}`);
if (url.hostname !== domain) {
return { domain: "This is not a valid domain." };
}
} catch {
return { domain: "This is not a valid domain." };
}
try {
// Check if domain is a valid FQDN
const url = new URL(`http://${domain.trim()}`);
return url.hostname !== domain.trim();
} catch (e) {
// If URL constructor fails, it's not a valid domain
return true;
}
}, [domain, domains]);
return undefined;
},
});
const domain = (form.values.domain as string).trim();
return (
<Dialog>
@@ -45,23 +52,16 @@ export default function AddDomain({ domains, isDisabled }: AddDomainProps) {
</Text>
<input name="action_id" type="hidden" value="add_domain" />
<Input
{...form.field("domain")}
description={
domain.trim().length > 0
? `Matches users with <user>@${domain.trim()}`
domain.length > 0
? `Matches users with <user>@${domain}`
: "Enter a domain to match users with their email addresses."
}
invalid={domain.trim().length === 0 || isInvalid}
required
label="Domain"
name="domain"
onChange={setDomain}
placeholder="example.com"
/>
{isInvalid && (
<p className="mt-2 text-sm text-red-500">
The domain you entered is invalid or already exists in the list.
</p>
)}
</DialogPanel>
</Dialog>
);
@@ -1,10 +1,15 @@
import { useMemo, useState } from "react";
import { type } from "arktype";
import Button from "~/components/button";
import Dialog, { DialogPanel } from "~/components/dialog";
import Input from "~/components/input";
import Text from "~/components/text";
import Title from "~/components/title";
import { useForm } from "~/hooks/use-form";
const groupSchema = type({
group: "string > 0",
});
interface AddGroupProps {
groups: string[];
@@ -12,18 +17,19 @@ interface AddGroupProps {
}
export default function AddGroup({ groups, isDisabled }: AddGroupProps) {
const [group, setGroup] = useState("");
const form = useForm({
schema: groupSchema,
validate: (values) => {
const group = (values.group as string).trim();
if (group.length === 0) return undefined;
const isInvalid = useMemo(() => {
if (!group || group.trim().length === 0) {
// Empty group is invalid, but no error shown
return false;
}
if (groups.includes(group)) {
return { group: "This group already exists in the list." };
}
if (groups.includes(group.trim())) {
return true;
}
}, [group, groups]);
return undefined;
},
});
return (
<Dialog>
@@ -35,19 +41,12 @@ export default function AddGroup({ groups, isDisabled }: AddGroupProps) {
</Text>
<input name="action_id" type="hidden" value="add_group" />
<Input
{...form.field("group")}
description="The group to allow for OIDC authentication."
invalid={group.trim().length === 0 || isInvalid}
required
label="Group"
name="group"
onChange={setGroup}
placeholder="admin"
/>
{isInvalid && (
<p className="mt-2 text-sm text-red-500">
The group you entered already exists in the list of allowed groups.
</p>
)}
</DialogPanel>
</Dialog>
);
@@ -1,10 +1,15 @@
import { useMemo, useState } from "react";
import { type } from "arktype";
import Button from "~/components/button";
import Dialog, { DialogPanel } from "~/components/dialog";
import Input from "~/components/input";
import Text from "~/components/text";
import Title from "~/components/title";
import { useForm } from "~/hooks/use-form";
const userSchema = type({
user: "string > 0",
});
interface AddUserProps {
users: string[];
@@ -12,18 +17,19 @@ interface AddUserProps {
}
export default function AddUser({ users, isDisabled }: AddUserProps) {
const [user, setUser] = useState("");
const form = useForm({
schema: userSchema,
validate: (values) => {
const user = (values.user as string).trim();
if (user.length === 0) return undefined;
const isInvalid = useMemo(() => {
if (!user || user.trim().length === 0) {
// Empty user is invalid, but no error shown
return false;
}
if (users.includes(user)) {
return { user: "This user already exists in the list." };
}
if (users.includes(user.trim())) {
return true;
}
}, [user, users]);
return undefined;
},
});
return (
<Dialog>
@@ -35,19 +41,12 @@ export default function AddUser({ users, isDisabled }: AddUserProps) {
</Text>
<input name="action_id" type="hidden" value="add_user" />
<Input
{...form.field("user")}
description="The user to allow for OIDC authentication."
invalid={user.trim().length === 0 || isInvalid}
required
label="User"
name="user"
onChange={setUser}
placeholder="john_doe"
/>
{isInvalid && (
<p className="mt-2 text-sm text-red-500">
The user you entered already exists in the list of allowed users.
</p>
)}
</DialogPanel>
</Dialog>
);