refactor(items): localize stock policy form resolver typing

This commit is contained in:
2026-07-01 01:28:49 +02:00
parent 79bd1b5d5e
commit 7abf298da1
5 changed files with 247 additions and 12 deletions
@@ -1,9 +1,8 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useRouter } from "next/navigation"
import { useMemo } from "react"
import { type Resolver, useForm } from "react-hook-form"
import { useForm } from "react-hook-form"
import { toast } from "sonner"
import { createItemAction } from "@/actions/item.actions"
import {
@@ -11,7 +10,9 @@ import {
type SubmitButtonCopy,
} from "@/components/forms/submitButton"
import {
buildCreateItemResolver,
buildCreateItemSchema,
type CreateItemData,
type CreateItemFormType,
} from "@/schemas/item.schema"
import type { CategorySummary } from "@/types"
@@ -32,20 +33,19 @@ export default function NewItemForm({
}) {
const router = useRouter()
const schema = useMemo(() => buildCreateItemSchema(schemaCopy), [schemaCopy])
const resolver = zodResolver(schema as never) as Resolver<CreateItemFormType>
const {
register,
handleSubmit,
setError,
formState: { errors, isSubmitting, isSubmitSuccessful },
} = useForm<CreateItemFormType>({
resolver,
} = useForm<CreateItemFormType, unknown, CreateItemData>({
resolver: buildCreateItemResolver(schema),
shouldFocusError: true,
mode: "onSubmit",
})
const onSubmit = async (formData: CreateItemFormType) => {
const onSubmit = async (formData: CreateItemData) => {
const response = await createItemAction(formData)
if (response?.errors) {
@@ -1,9 +1,8 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useRouter } from "next/navigation"
import { useMemo } from "react"
import { type Resolver, useForm } from "react-hook-form"
import { useForm } from "react-hook-form"
import { toast } from "sonner"
import { updateItemAction } from "@/actions/item.actions"
import {
@@ -11,7 +10,9 @@ import {
type SubmitButtonCopy,
} from "@/components/forms/submitButton"
import {
buildUpdateItemResolver,
buildUpdateItemSchema,
type UpdateItemData,
type UpdateItemFormType,
} from "@/schemas/item.schema"
import type { CategorySummary, ItemWithAssetCount } from "@/types"
@@ -34,7 +35,6 @@ export default function UpdateItemForm({
}) {
const router = useRouter()
const schema = useMemo(() => buildUpdateItemSchema(schemaCopy), [schemaCopy])
const resolver = zodResolver(schema as never) as Resolver<UpdateItemFormType>
const isDisabled = !!item?._count.assets && item?._count.assets > 0
@@ -43,8 +43,8 @@ export default function UpdateItemForm({
handleSubmit,
setError,
formState: { errors, isSubmitting, isSubmitSuccessful },
} = useForm<UpdateItemFormType>({
resolver,
} = useForm<UpdateItemFormType, unknown, UpdateItemData>({
resolver: buildUpdateItemResolver(schema),
defaultValues: {
id: item?.id,
name: item?.name,
@@ -57,7 +57,7 @@ export default function UpdateItemForm({
mode: "onSubmit",
})
const onSubmit = async (formData: UpdateItemFormType) => {
const onSubmit = async (formData: UpdateItemData) => {
const response = await updateItemAction(formData)
if (response?.errors) {
+22
View File
@@ -1,3 +1,5 @@
import { zodResolver } from "@hookform/resolvers/zod"
import type { Resolver } from "react-hook-form"
import { z } from "zod"
import type { InventoryMovementReason } from "@/generated/prisma/client"
@@ -192,6 +194,16 @@ export type CreateItemFormType = {
export type CreateItemData = z.output<typeof createItemSchema>
export function buildCreateItemResolver(
schema: z.ZodTypeAny,
): Resolver<CreateItemFormType, unknown, CreateItemData> {
return zodResolver(schema as never) as Resolver<
CreateItemFormType,
unknown,
CreateItemData
>
}
export function buildUpdateItemSchema(copy: ItemSchemaCopy) {
return addStockPolicyValidation(
buildItemBaseSchema(copy).extend({
@@ -215,6 +227,16 @@ export type UpdateItemFormType = CreateItemFormType & {
export type UpdateItemData = z.output<typeof updateItemSchema>
export function buildUpdateItemResolver(
schema: z.ZodTypeAny,
): Resolver<UpdateItemFormType, unknown, UpdateItemData> {
return zodResolver(schema as never) as Resolver<
UpdateItemFormType,
unknown,
UpdateItemData
>
}
export function buildGetItemByIdSchema(copy: ItemSchemaCopy) {
return z.object({
id: z.string().min(1, {
@@ -0,0 +1,212 @@
// @vitest-environment jsdom
import "@testing-library/jest-dom/vitest"
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import type {
ItemFormCopy,
ItemSchemaCopy,
} from "@/app/(dashboard)/inventory/items/_components/item.copy"
import NewItemForm from "@/app/(dashboard)/inventory/items/_components/new.item.form"
import UpdateItemForm from "@/app/(dashboard)/inventory/items/_components/update.item.form"
import type { SubmitButtonCopy } from "@/components/forms/submitButton"
import type { CategorySummary, ItemWithAssetCount } from "@/types"
const mocks = vi.hoisted(() => ({
createItemAction: vi.fn(),
updateItemAction: vi.fn(),
push: vi.fn(),
toastError: vi.fn(),
toastSuccess: vi.fn(),
}))
vi.mock("@/actions/item.actions", () => ({
createItemAction: mocks.createItemAction,
updateItemAction: mocks.updateItemAction,
}))
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: mocks.push }),
}))
vi.mock("sonner", () => ({
toast: { error: mocks.toastError, success: mocks.toastSuccess },
}))
const formCopy = {
nameLabel: "Name",
namePlaceholder: "Item name",
categoryLabel: "Category",
categoryPlaceholder: "Select a category",
stockLabel: "Stock",
stockPlaceholder: "0",
stockPolicyTitle: "Stock Policy",
stockPolicyDescription:
"Define the minimum and target stock levels for this item.",
minStockLabel: "Minimum stock",
minStockPlaceholder: "Optional",
targetStockLabel: "Target stock",
targetStockPlaceholder: "Optional",
createSubmit: "Create Item",
updateSubmit: "Update Item",
} satisfies ItemFormCopy
const schemaCopy = {
nameRequired: "Name is required",
categoryRequired: "Category is required",
stockRequired: "Stock is required",
trackingTypeRequired: "Tracking type is required",
invalidTrackingType: "Invalid tracking type",
statusRequired: "Status is required",
invalidStatus: "Invalid status",
itemRequired: "Item is required",
stockPolicyPairRequired: "Stock policy values must be provided together",
stockPolicyValueInvalid:
"Stock policy values must be whole numbers greater than or equal to 0",
stockPolicyOrderInvalid:
"Target stock must be greater than or equal to minimum stock",
} satisfies ItemSchemaCopy
const submitButtonCopy = {
defaultLabel: "Submit",
processing: "Processing",
success: "Success",
} satisfies SubmitButtonCopy
const categories: CategorySummary[] = [{ id: "cat-1", name: "Hardware" }]
const item: ItemWithAssetCount = {
id: "item-1",
name: "Laptop",
category: { id: "cat-1", name: "Hardware" },
stock: 7,
trackingType: "QUANTITY",
status: "ACTIVE",
minStock: 2,
targetStock: 5,
_count: { assets: 0 },
}
describe("item forms", () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
cleanup()
})
it("submits stock policy values through the create item form", async () => {
mocks.createItemAction.mockResolvedValue({
success: true,
message: "Item created successfully!",
})
const user = userEvent.setup()
render(
<NewItemForm
categories={categories}
formCopy={formCopy}
schemaCopy={schemaCopy}
submitButtonCopy={submitButtonCopy}
/>,
)
await user.type(screen.getByLabelText(formCopy.nameLabel), "Laptop")
await user.selectOptions(
screen.getByLabelText(formCopy.categoryLabel),
"cat-1",
)
fireEvent.change(screen.getByLabelText(formCopy.stockLabel), {
target: { value: "4" },
})
fireEvent.change(screen.getByLabelText(formCopy.minStockLabel), {
target: { value: "2" },
})
fireEvent.change(screen.getByLabelText(formCopy.targetStockLabel), {
target: { value: "6" },
})
await user.click(
screen.getByRole("button", { name: formCopy.createSubmit }),
)
await waitFor(() => {
expect(mocks.createItemAction).toHaveBeenCalledWith(
expect.objectContaining({
name: "Laptop",
categoryId: "cat-1",
stock: 4,
minStock: 2,
targetStock: 6,
trackingType: "QUANTITY",
status: "ACTIVE",
}),
)
})
expect(mocks.toastSuccess).toHaveBeenCalledWith(
"Item created successfully!",
)
expect(mocks.push).toHaveBeenCalledWith("/inventory/items")
})
it("prefills and submits stock policy values through the update item form", async () => {
mocks.updateItemAction.mockResolvedValue({
success: true,
message: "Item updated successfully!",
})
const user = userEvent.setup()
render(
<UpdateItemForm
categories={categories}
item={item}
formCopy={formCopy}
schemaCopy={schemaCopy}
submitButtonCopy={submitButtonCopy}
/>,
)
expect(screen.getByLabelText(formCopy.minStockLabel)).toHaveValue(2)
expect(screen.getByLabelText(formCopy.targetStockLabel)).toHaveValue(5)
fireEvent.change(screen.getByLabelText(formCopy.minStockLabel), {
target: { value: "3" },
})
fireEvent.change(screen.getByLabelText(formCopy.targetStockLabel), {
target: { value: "8" },
})
await user.click(
screen.getByRole("button", { name: formCopy.updateSubmit }),
)
await waitFor(() => {
expect(mocks.updateItemAction).toHaveBeenCalledWith(
expect.objectContaining({
id: "item-1",
name: "Laptop",
categoryId: "cat-1",
stock: 7,
minStock: 3,
targetStock: 8,
}),
)
})
expect(mocks.toastSuccess).toHaveBeenCalledWith(
"Item updated successfully!",
)
expect(mocks.push).toHaveBeenCalledWith("/inventory/items")
})
})
+1
View File
@@ -5,6 +5,7 @@ export default defineConfig({
environment: "node",
include: ["tests/**/*.test.ts", "tests/**/*.test.tsx"],
watch: false,
allowOnly: !process.env.CI,
globals: false,
clearMocks: true,
restoreMocks: true,