Item Catalog — Forms¶
Form schemas are derived from the generated Orval Zod schemas via .pick() / .omit() / .extend() — never hand-written. BE-owned enums (item_type, storage_unit) are imported from src/generated/schemas/item-catalog.ts, not redeclared. Validation mode is onBlur by default; onChange only where instant feedback is meaningful (none in this module).
The four schemas in this module are owned at the module level because each is reused (ItemCreateForm ↔ ItemEditForm share the same ItemForm component; ItemGroupForm is used by both GroupListPage's FormDialog and GroupDetailPage's edit dialog). They live in features/item-catalog/schemas.ts per docs/standards.md § Forms.
ItemCreateForm¶
- UX baseline: Variant A —
src/ux-exploration/item-catalog/item-create-a.tsx. Single-column grouped fieldsets. - Source schema (generated):
CreateItemRequestfromsrc/generated/schemas/item-catalog.ts. - Form schema:
itemCreateFormSchema = CreateItemRequest(no omissions — every field is settable on create). Form value type:type ItemCreateFormValues = z.infer<typeof itemCreateFormSchema>. - Fields:
| Field | Type (Zod) | Required | Constraint (from BE data-model.md / business/modules/item_catalog.md) |
UI control |
|---|---|---|---|---|
code |
string |
yes | max 50 chars; unique per tenant (case-insensitive); immutable after save (BR-IC-01, BR-IC-07) | <TextField> |
name |
string |
yes | max 200 chars | <TextField> |
item_type |
enum: ingredient \| product \| intermediate \| packaging \| consumable \| service \| resale |
yes | immutable after save (BR-IC-07) | <SelectField> |
storage_unit |
enum: kg \| g \| l \| ml \| piece |
yes | immutable always (BR-IC-02) | <SelectField> |
description |
string \| null |
no | max 500 chars | <TextareaField> |
tags |
string[] |
no | max 20 entries × max 50 chars each, deduped case-insensitively (Tags VO) | <TagInput> (M0 prereq, RHF-aware, enforces the 20-tag / 50-char caps client-side) |
shelf_life_days |
number \| null |
no | positive integer; applicable only when item_type ∈ {product, intermediate} (BR / ShelfLifeDaysNotApplicable 400) |
<NumberField> — disabled when item_type is not product/intermediate; cleared automatically on type change |
ean |
string \| null |
no | max 20 chars; unique per tenant (partial unique index where ean IS NOT NULL) |
<TextField> |
group_id |
string \| null (uuid) |
no | must exist; group's item_type must equal form's item_type (BR-IC-05, GROUP_ITEM_TYPE_MISMATCH 409) |
<Combobox> (M0 prereq, searchable single-select) populated by useListItemGroupOptions({ item_type }) — the BE options endpoint per ../../standards.md § Options endpoints (NOT the paginated useListItemGroups); wired via <Controller> since it's not a native input; disabled until item_type is chosen; clearing the selection (re-clicking the chosen row) sets the value back to null ("Ungrouped") |
description tags shelf_life_days ean group_id |
(optional fields) | no | — | — |
- Fieldset grouping (matches variant A's Fieldset cards):
- Identity —
code,name. - Classification —
item_type,storage_unit,group_id. The group<Combobox>sits below the two enums on its own row (per the mockup). - Inventory parameters —
shelf_life_days,ean. - Description & tags —
description,tags. - Conditional logic:
- On
item_typechange: if leaving product/intermediate, clearshelf_life_daysand disable the input; on entering product/intermediate, re-enable. - On
item_typechange: cleargroup_id(mismatched type guard) and refetchuseListItemGroupOptions({ item_type }). - Validation:
- Default
onBlur. - Uniqueness checks (
code,ean) are server-side only — the form does not pre-check. 409 responses map to field errors viahandleApiError(e, { setError }). - Submit: see
data-fetching.md§ ItemCreatePage.
ItemEditForm¶
- UX baseline: Variant A —
src/ux-exploration/item-catalog/item-edit-a.tsx. Same shell as create with locked Identity fieldset. - Source schemas:
EditItemRequestfrom generated. Group changes useAssignItemToGroupRequest(PUT /items/{id}/group) andRemoveItemFromGroupRequest(DELETE /items/{id}/group— no body). - Form schema: distinct from
itemCreateFormSchemabecause edit must carryversionandgroup_id(the group select still lives in the form, even though it submits to a different endpoint).
export const itemEditFormSchema = z.object({
name: z.string().min(1).max(200),
description: z.string().max(500).nullable(),
tags: z.array(z.string().max(50)).max(20),
shelf_life_days: z.number().int().positive().nullable(),
ean: z.string().max(20).nullable(),
group_id: z.string().uuid().nullable(),
version: z.number().int().nonnegative(), // hidden, carried from prefill
})
export type ItemEditFormValues = z.infer<typeof itemEditFormSchema>
- Locked context inputs (rendered but not in the schema):
code,storage_unit,item_type— disabled<TextField>s in the Identity fieldset with the "Locked" badge per the variant. They exist as visual context only; they cannot be sent because they're not in the schema and they're not inEditItemRequest. - Composite-save flow: see
docs/standards.md§ Composite Edits anddata-fetching.md§ ItemEditPage. - The form's
onSubmitis not a single mutation. It callssaveItem(itemId, initial, next, mutators). saveItemdiffsnextagainstinitial(the snapshot from the prefill query) per sub-resource:- Field PATCH (
useEditItem): dirty editable fields (any ofname,description,tags,shelf_life_days,ean) +version. Sent only if at least one of those fields changed. Unchanged optional fields are absent from the body (BE PATCH convention: absent = unchanged,null= clear). - Group call (
useAssignItemToGroup/useRemoveItemFromGroup): sent only ifgroup_idchanged. Routing rule: new value non-null → PUT; new value null → DELETE.
- Field PATCH (
- Both calls fire in parallel via
Promise.allSettled. The returnedItemSaveOutcomeis mapped to UX perpages.md§ ItemEditPage and § Composite Edits. - Permission-gated disabling (mirrors
pages.mdpermission table): - All editable fieldsets (Identity / Description / Inventory) disabled if user lacks
Permission.ITEM_UPDATE. - Group
<Combobox>disabled if user lacksPermission.ITEM_GROUP_ASSIGN. - Save button hidden if user lacks both.
- Validation:
- Default
onBlur. - 409 errors map per
data-fetching.md:EAN_ALREADY_IN_USE→ field error onean;GROUP_ITEM_TYPE_MISMATCH→ field error ongroup_id;CONCURRENT_MODIFICATION→ form-level conflict alert with "Reload latest" button. - System-item check: if the prefill query returns
is_system === true, the edit-page route handler redirects back to detail before mounting the form (BR-IC-04). - FormActions: sticky bar at form bottom. Left side renders
t('item-catalog:page.itemEdit.lastUpdated', { user, datetime })derived from the prefillupdated_by/updated_atfields. Right side:<CancelButton>+<SaveButton>.
ItemGroupForm¶
Used inside <FormDialog> for both create and edit flows. Mounted on GroupListPage (header "New group", row "Edit" action) and on GroupDetailPage (header "Edit" action).
- UX baseline: Variant A inline
<FormDialog>content fromitem-group-list-a.tsx. Two stacked rows:name(TextField),item_type(SelectField). Cancel + Save in the dialog footer. - Source schemas:
CreateItemGroupRequest+EditItemGroupRequestfrom generated. Note:EditItemGroupRequestonly allowsname(theitem_typeis immutable on edit per BE invariant); the form reflects that. - Form schema (create mode):
- Form schema (edit mode):
export const itemGroupEditFormSchema = z.object({
name: z.string().min(1).max(100),
version: z.number().int().nonnegative(), // carried from prefill, OCC works on groups today
})
- Fields:
| Field | Type | Required | Constraint | UI | Mode |
|---|---|---|---|---|---|
name |
string | yes | max 100 chars; unique within (tenant_id, item_type) case-insensitively |
<TextField> |
both |
item_type |
enum (7 values) | yes | immutable after create | <SelectField> (disabled in edit mode) |
both |
version |
number | yes (hidden) | echoed back to PATCH for OCC | hidden controller field | edit only |
- Mode handling: the
<ItemGroupForm>component acceptsmode: 'create' | 'edit'and optionally aninitialValuesprop. In edit mode it rendersitem_typeas a disabled<TextField>(showing the translated value) since the underlying control is locked. Submit dispatchesuseCreateItemGroupMutationoruseEditItemGroupMutationper mode (seedata-fetching.md). - Validation:
- Default
onBlur. - 409
ITEM_GROUP_NAME_ALREADY_IN_USE→ field error onname. - 409
CONCURRENT_MODIFICATION(edit only) → form-level alert + "Reload latest" button.
Cross-cutting form rules (apply to every form above)¶
- Zod is the single source of truth for field types, required-ness, and FE-side validation. Never hand-redeclare BE enums.
- Form value type is always
z.infer<typeof formSchema>— never written as a separateinterface. - Immutable fields are not in the form's submission schema. They are rendered as disabled inputs for visual context only. The server-side immutability guard (
IMMUTABLE_FIELD_CANNOT_BE_CHANGED400) should never fire — if it does, it's a bug. - Optional fields with the
string \| nullshape obey the PATCH tri-state convention: absent in submit body = unchanged,null= clear, value = set. RHF'sdirtyFieldsplus the diff helper insidesaveItemenforce this forItemEditForm. - Field errors from BE flow into the form via
handleApiError(e, { setError })(the shared parser). Toast errors are for non-field problems only.