Skip to content

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 (ItemCreateFormItemEditForm 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): CreateItemRequest from src/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):
  • Identitycode, name.
  • Classificationitem_type, storage_unit, group_id. The group <Combobox> sits below the two enums on its own row (per the mockup).
  • Inventory parametersshelf_life_days, ean.
  • Description & tagsdescription, tags.
  • Conditional logic:
  • On item_type change: if leaving product/intermediate, clear shelf_life_days and disable the input; on entering product/intermediate, re-enable.
  • On item_type change: clear group_id (mismatched type guard) and refetch useListItemGroupOptions({ 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 via handleApiError(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: EditItemRequest from generated. Group changes use AssignItemToGroupRequest (PUT /items/{id}/group) and RemoveItemFromGroupRequest (DELETE /items/{id}/group — no body).
  • Form schema: distinct from itemCreateFormSchema because edit must carry version and group_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 in EditItemRequest.
  • Composite-save flow: see docs/standards.md § Composite Edits and data-fetching.md § ItemEditPage.
  • The form's onSubmit is not a single mutation. It calls saveItem(itemId, initial, next, mutators).
  • saveItem diffs next against initial (the snapshot from the prefill query) per sub-resource:
    • Field PATCH (useEditItem): dirty editable fields (any of name, 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 if group_id changed. Routing rule: new value non-null → PUT; new value null → DELETE.
  • Both calls fire in parallel via Promise.allSettled. The returned ItemSaveOutcome is mapped to UX per pages.md § ItemEditPage and § Composite Edits.
  • Permission-gated disabling (mirrors pages.md permission table):
  • All editable fieldsets (Identity / Description / Inventory) disabled if user lacks Permission.ITEM_UPDATE.
  • Group <Combobox> disabled if user lacks Permission.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 on ean; GROUP_ITEM_TYPE_MISMATCH → field error on group_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 prefill updated_by / updated_at fields. 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 from item-group-list-a.tsx. Two stacked rows: name (TextField), item_type (SelectField). Cancel + Save in the dialog footer.
  • Source schemas: CreateItemGroupRequest + EditItemGroupRequest from generated. Note: EditItemGroupRequest only allows name (the item_type is immutable on edit per BE invariant); the form reflects that.
  • Form schema (create mode):
export const itemGroupCreateFormSchema = CreateItemGroupRequest    // name + item_type
  • 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 accepts mode: 'create' | 'edit' and optionally an initialValues prop. In edit mode it renders item_type as a disabled <TextField> (showing the translated value) since the underlying control is locked. Submit dispatches useCreateItemGroupMutation or useEditItemGroupMutation per mode (see data-fetching.md).
  • Validation:
  • Default onBlur.
  • 409 ITEM_GROUP_NAME_ALREADY_IN_USE → field error on name.
  • 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 separate interface.
  • 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_CHANGED 400) should never fire — if it does, it's a bug.
  • Optional fields with the string \| null shape obey the PATCH tri-state convention: absent in submit body = unchanged, null = clear, value = set. RHF's dirtyFields plus the diff helper inside saveItem enforce this for ItemEditForm.
  • Field errors from BE flow into the form via handleApiError(e, { setError }) (the shared parser). Toast errors are for non-field problems only.