원클릭으로
forms
// Use when building, editing, or adding forms in the Carbon ERP/MES codebase - covers ValidatedForm, zod validators, form components, and action handlers
// Use when building, editing, or adding forms in the Carbon ERP/MES codebase - covers ValidatedForm, zod validators, form components, and action handlers
Use early when debugging a medium or hard bug, especially when tests alone may not reveal the real runtime failure. Trigger this before extended TDD iteration when a bug involves runtime state, ordering, persistence, streaming, concurrency, UI/manual reproduction, external services, or when a red or newly passing test may not model the real issue. Skip only when the root cause is already directly proven by a stack trace or deterministic test that exercises the real runtime path.
Use when creating an approachable, self-contained HTML review aid for a pull request; explaining what changed, why it matters, how it works, and how it fits into the broader system; turning PR diffs, commits, tests, and architecture context into a local `.pr-review/` HTML page for reviewers; or helping reviewers understand complex code changes without dumping the full diff.
Use when breaking a large, complex, messy, or hard-to-review pull request into multiple smaller PRs; planning stacked PRs; extracting independent changes from a branch; splitting mixed refactor and behavior changes; managing drift after review feedback; rebasing follow-up PRs as earlier PRs change; or preserving original branch intent while shipping incrementally.
Interactive planning assistant that helps create focused, well-structured ralph-loop commands through collaborative conversation
Run an extremely strict maintainability review for abstraction quality, giant files, and spaghetti-condition growth. Use for a thermo-nuclear code quality review, thermonuclear review, deep code quality audit, or especially harsh maintainability review.
Use when writing service functions that perform multi-row database writes, bulk updates, reordering, or any operation that must succeed or fail atomically. Triggers on Kysely transactions, sortOrder updates, bulk inserts/updates, multi-table writes, and service functions with array/loop writes.
| name | forms |
| description | Use when building, editing, or adding forms in the Carbon ERP/MES codebase - covers ValidatedForm, zod validators, form components, and action handlers |
Forms in Carbon follow a three-part pattern: zod validator in the module's .models.ts, form component in the module's ui/ directory, and action handler in the route file.
| Piece | ERP Location | MES Location |
|---|---|---|
| Validator | app/modules/{module}/{module}.models.ts | app/services/models.ts |
| Form UI | app/modules/{module}/ui/{Feature}/{Feature}Form.tsx | Inline in route or app/components/ |
| Route action | app/routes/x+/{module}+/{resource}.new.tsx | app/routes/x+/{resource}.tsx |
| Form components | ~/components/Form (re-exports @carbon/form + domain selectors) | @carbon/form directly |
Define in the module's .models.ts. Use z from zod and zfd from zod-form-data.
import { z } from "zod";
import { zfd } from "zod-form-data";
export const thingValidator = z.object({
id: zfd.text(z.string().optional()), // optional ID for create/edit
name: z.string().min(1, { message: "Name is required" }),
type: z.enum(thingTypes, { // enum with custom error
errorMap: () => ({ message: "Type is required" })
}),
quantity: zfd.numeric(z.number().min(0)), // numeric from FormData
isActive: zfd.checkbox(), // checkbox boolean
notes: zfd.text(z.string().optional()), // optional text
items: z.array(z.string().min(1)).min(1, { // required array
message: "At least one item is required"
}),
});
Key rules:
zfd.text() for optional strings from FormDatazfd.numeric() for numbers from FormDatazfd.checkbox() for boolean checkboxesz.enum() with errorMap for enum fields.refine() for cross-field validationThe core of any form is ValidatedForm wrapping your fields. The surrounding container varies by context — Drawers, Cards, inline sections, modals, etc. Look at neighboring routes to match the existing pattern.
Import form fields from ~/components/Form (ERP) or @carbon/form (MES).
import { ValidatedForm } from "@carbon/form";
import { Button, HStack, VStack } from "@carbon/react";
import { useNavigate } from "react-router";
import type { z } from "zod";
import { Hidden, Input, Select, Submit } from "~/components/Form";
import { usePermissions } from "~/hooks";
import { thingValidator } from "~/modules/things";
import { path } from "~/utils/path";
type ThingFormProps = {
initialValues: z.infer<typeof thingValidator>;
};
const ThingForm = ({ initialValues }: ThingFormProps) => {
const permissions = usePermissions();
const navigate = useNavigate();
const onClose = () => navigate(-1);
const isEditing = !!initialValues.id;
const isDisabled = isEditing
? !permissions.can("update", "things")
: !permissions.can("create", "things");
return (
<ValidatedForm
validator={thingValidator}
method="post"
action={isEditing ? path.to.thing(initialValues.id!) : path.to.newThing}
defaultValues={initialValues}
>
<Hidden name="id" />
<VStack spacing={4}>
<Input name="name" label="Name" />
<Select name="type" label="Type" options={typeOptions} />
</VStack>
<HStack>
<Submit isDisabled={isDisabled}>Save</Submit>
<Button size="md" variant="solid" onClick={onClose}>Cancel</Button>
</HStack>
</ValidatedForm>
);
};
Key rules:
<Hidden name="id" /> for edit supportVStack spacing={4} for vertical field layoutgrid grid-cols-1 lg:grid-cols-3 gap-x-8 gap-y-4 for multi-column layoutsisDisabled on Submitz.infer<typeof validator>import { assertIsPost, error, success } from "@carbon/auth";
import { requirePermissions } from "@carbon/auth/auth.server";
import { flash } from "@carbon/auth/session.server";
import { validationError, validator } from "@carbon/form";
import type { ActionFunctionArgs } from "react-router";
import { data, redirect } from "react-router";
import { thingValidator, insertThing } from "~/modules/things";
import { path } from "~/utils/path";
export async function action({ request }: ActionFunctionArgs) {
assertIsPost(request);
const { client, companyId, userId } = await requirePermissions(request, {
create: "things"
});
const validation = await validator(thingValidator).validate(
await request.formData()
);
if (validation.error) {
return validationError(validation.error);
}
const result = await insertThing(client, {
...validation.data,
companyId,
createdBy: userId
});
if (result.error) {
return data({}, await flash(request, error(result.error, "Failed to create thing")));
}
throw redirect(path.to.things, await flash(request, success("Thing created")));
}
Key rules:
assertIsPost(request) firstrequirePermissions with appropriate module/actionvalidator(schema).validate(formData) - NOT schema.parse()validationError(validation.error) on failure (422 status)throw redirect() on success (not return redirect())Response.json()export default function NewThingRoute() {
const initialValues = {
id: "",
name: "",
type: "Default" as const,
};
return <ThingForm initialValues={initialValues} />;
}
For edit routes, load data in the loader and pass to the form:
export default function EditThingRoute() {
const { thing } = useLoaderData<typeof loader>();
return <ThingForm initialValues={thing} />;
}
From @carbon/form (base):
| Component | Props | Use for |
|---|---|---|
Input | name, label, prefix?, suffix?, helperText? | Text fields |
Number | name, label, formatOptions? | Numeric fields with steppers |
TextArea | name, label, characterLimit? | Multi-line text |
Select | name, label, options: {label, value}[] | Dropdown |
Combobox | name, label, options: {label, value}[] | Searchable dropdown |
CreatableCombobox | name, label, options, onCreateOption? | Searchable + create new |
MultiSelect | name, label, options | Multi-select |
Boolean | name, label, description? | Switch/toggle |
DatePicker | name, label, minValue?, maxValue? | Date selection |
DateTimePicker | name, label | Date + time |
TimePicker | name, label | Time only |
Hidden | name, value? | Hidden fields |
Password | name, label | Password with toggle |
Radios | name, label, options, orientation? | Radio buttons |
Submit | isDisabled?, withBlocker? | Submit with unsaved changes warning |
Array | name, label | Dynamic list fields |
From ~/components/Form (ERP domain selectors):
Customer, Supplier, Employee, Employees, Users, Item, Part, Location, Account, AccountCategory, AccountSubcategory, Currency, Department, WorkCenter, UnitOfMeasure, PaymentTerm, ShippingMethod, Shift, Sequence, Process, Procedure, Tool, Tags, CustomFormFields
These are Combobox/CreatableCombobox wrappers that auto-load options from stores. Use them instead of raw Combobox when the entity type matches.
Dependent fields (value of one field changes options of another):
const [categoryId, setCategoryId] = useState(initialValues.categoryId ?? "");
<AccountCategory name="categoryId" onChange={(cat) => setCategoryId(cat?.id ?? "")} />
<AccountSubcategory name="subcategoryId" accountCategoryId={categoryId} />
Enum options from const array:
const typeOptions = thingTypes.map((t) => ({ label: t, value: t }));
<Select name="type" label="Type" options={typeOptions} />
Client action for cache invalidation:
export async function clientAction({ serverAction }: ClientActionFunctionArgs) {
const companyId = getCompanyId();
window.clientCache?.invalidateQueries({
predicate: (query) => {
const queryKey = query.queryKey as string[];
return queryKey[0] === "things" && queryKey[1] === companyId;
}
});
return await serverAction();
}
When building a new form:
{module}.models.tsui/{Feature}/{Feature}Form.tsxclientAction if the entity is cached client-side~/utils/path if needed