refactor(items): localize stock policy form resolver typing
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user