Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion components/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export function Button({ children, className, ...rest }: ButtonProps) {
<button
{...rest}
className={clsx(
"flex h-10 items-center rounded-lg bg-blue-500 px-4 text-sm font-medium text-white transition-colors hover:bg-blue-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 active:bg-blue-600 aria-disabled:cursor-not-allowed aria-disabled:opacity-50",
"flex h-10 items-center rounded-lg bg-blue-500 px-4 text-sm font-medium text-white transition-colors hover:bg-blue-400 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 active:bg-blue-600 aria-disabled:cursor-not-allowed aria-disabled:opacity-50",
className,
)}
>
Expand Down
45 changes: 42 additions & 3 deletions components/dashboard/invoices/create-form.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
"use client";

import Link from "next/link";

import { Button } from "@/components/button";
import { createInvoice } from "@/lib/actions";
import { useActionState } from "react";
import { CustomerField } from "@/types/global";
import { createInvoice, State } from "@/lib/actions";
import { Check, Clock, CircleDollarSign, UserCircle } from "lucide-react";

export default function Form({ customers }: { customers: CustomerField[] }) {
const initialState: State = { message: null, errors: {} };

const [state, formAction] = useActionState(createInvoice, initialState);

return (
<form action={createInvoice}>
<form action={formAction}>
<div className="rounded-md bg-gray-50 p-4 md:p-6">
{/* Customer Name */}
<div className="mb-4">
Expand All @@ -20,6 +27,7 @@ export default function Form({ customers }: { customers: CustomerField[] }) {
name="customerId"
className="peer block w-full cursor-pointer rounded-md border border-gray-200 py-2 pl-10 text-sm outline-2 placeholder:text-gray-500"
defaultValue=""
aria-describedby="customer-error"
>
<option value="" disabled>
Select a customer
Expand All @@ -32,6 +40,14 @@ export default function Form({ customers }: { customers: CustomerField[] }) {
</select>
<UserCircle className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500" />
</div>
<div id="customer-error" aria-live="polite" aria-atomic="true">
{state.errors?.customerId &&
state.errors.customerId.map((error: string) => (
<p className="mt-2 text-sm text-red-500" key={error}>
{error}
</p>
))}
</div>
</div>

{/* Invoice Amount */}
Expand All @@ -48,14 +64,23 @@ export default function Form({ customers }: { customers: CustomerField[] }) {
step="0.01"
placeholder="Enter USD amount"
className="peer block w-full rounded-md border border-gray-200 py-2 pl-10 text-sm outline-2 placeholder:text-gray-500"
aria-describedby="amount-error"
/>
<CircleDollarSign className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500 peer-focus:text-gray-900" />
</div>
</div>
<div id="amount-error" aria-live="polite" aria-atomic="true">
{state.errors?.amount &&
state.errors.amount.map((error) => (
<p key={error} className="mt-2 text-sm text-red-500">
{error}
</p>
))}
</div>
</div>

{/* Invoice Status */}
<fieldset>
<fieldset aria-describedby="status-error">
<legend className="mb-2 block text-sm font-medium">
Set the invoice status
</legend>
Expand Down Expand Up @@ -93,7 +118,21 @@ export default function Form({ customers }: { customers: CustomerField[] }) {
</div>
</div>
</div>
<div id="status-error" aria-live="polite" aria-atomic="true">
{state.errors?.status &&
state.errors.status.map((error) => (
<p key={error} className="mt-2 text-sm text-red-500">
{error}
</p>
))}
</div>
</fieldset>

<div aria-live="polite" aria-atomic="true">
{state.message && (
<p className="mt-2 text-sm text-red-500">{state.message}</p>
)}
</div>
</div>
<div className="mt-6 flex justify-end gap-4">
<Link
Expand Down
44 changes: 41 additions & 3 deletions components/dashboard/invoices/edit-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
import Link from "next/link";

import { Button } from "@/components/button";
import { updateInvoice } from "@/lib/actions";
import { State, updateInvoice } from "@/lib/actions";
import { CustomerField, InvoiceForm } from "@/types/global";
import { Check, Clock, CircleDollarSign, UserCircle } from "lucide-react";
import { useActionState } from "react";

export default function EditInvoiceForm({
invoice,
Expand All @@ -14,9 +15,14 @@ export default function EditInvoiceForm({
invoice: InvoiceForm;
customers: CustomerField[];
}) {
const initialState: State = { message: null, errors: {} };

const updateInvoiceWithId = updateInvoice.bind(null, invoice.id);

const [state, formAction] = useActionState(updateInvoiceWithId, initialState);

return (
<form action={updateInvoiceWithId}>
<form action={formAction}>
<div className="rounded-md bg-gray-50 p-4 md:p-6">
{/* Customer Name */}
<div className="mb-4">
Expand All @@ -29,6 +35,7 @@ export default function EditInvoiceForm({
name="customerId"
className="peer block w-full cursor-pointer rounded-md border border-gray-200 py-2 pl-10 text-sm outline-2 placeholder:text-gray-500"
defaultValue={invoice.customer_id}
aria-describedby="customer-error"
>
<option value="" disabled>
Select a customer
Expand All @@ -41,6 +48,14 @@ export default function EditInvoiceForm({
</select>
<UserCircle className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500" />
</div>
<div id="customer-error" aria-live="polite" aria-atomic="true">
{state.errors?.customerId &&
state.errors.customerId.map((error: string) => (
<p className="mt-2 text-sm text-red-500" key={error}>
{error}
</p>
))}
</div>
</div>

{/* Invoice Amount */}
Expand All @@ -58,14 +73,23 @@ export default function EditInvoiceForm({
defaultValue={invoice.amount}
placeholder="Enter USD amount"
className="peer block w-full rounded-md border border-gray-200 py-2 pl-10 text-sm outline-2 placeholder:text-gray-500"
aria-describedby="amount-error"
/>
<CircleDollarSign className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500 peer-focus:text-gray-900" />
</div>
</div>
<div id="amount-error" aria-live="polite" aria-atomic="true">
{state.errors?.amount &&
state.errors.amount.map((error: string) => (
<p className="mt-2 text-sm text-red-500" key={error}>
{error}
</p>
))}
</div>
</div>

{/* Invoice Status */}
<fieldset>
<fieldset aria-describedby="status-error">
<legend className="mb-2 block text-sm font-medium">
Set the invoice status
</legend>
Expand Down Expand Up @@ -105,7 +129,21 @@ export default function EditInvoiceForm({
</div>
</div>
</div>
<div id="status-error" aria-live="polite" aria-atomic="true">
{state.errors?.status &&
state.errors.status.map((error: string) => (
<p className="mt-2 text-sm text-red-500" key={error}>
{error}
</p>
))}
</div>
</fieldset>

<div aria-live="polite" aria-atomic="true">
{state.message && (
<p className="mt-2 text-sm text-red-500">{state.message}</p>
)}
</div>
</div>
<div className="mt-6 flex justify-end gap-4">
<Link
Expand Down
12 changes: 10 additions & 2 deletions data/invoices/create-invoice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,20 @@ import { CreateInvoiceRecord } from "@/types/invoice";
export const CreateInvoice = FormSchema.omit({ id: true, date: true });

export function createInvoiceDTO(formData: FormData) {
const validated = CreateInvoice.parse({
const validated = CreateInvoice.safeParse({
customerId: formData.get("customerId"),
amount: formData.get("amount"),
status: formData.get("status"),
});
return validated;

if (!validated.success) {
return {
errors: validated.error.flatten().fieldErrors,
message: "Missing fields. Failed to create invoice.",
};
}

return validated.data;
}

export async function createInvoiceDAL({
Expand Down
10 changes: 7 additions & 3 deletions data/invoices/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ import z from "zod";
export const FormSchema = z.object({
id: z.string(),
date: z.string(),
amount: z.coerce.number<number>(),
status: z.enum(["pending", "paid"]),
customerId: z.string(),
amount: z.coerce
.number<number>()
.gt(0, { error: "Please enter an amount greater than $0." }),
status: z.enum(["pending", "paid"], {
error: "Please select an invoice status",
}),
customerId: z.string({ error: "Please select a customer." }),
});
12 changes: 10 additions & 2 deletions data/invoices/update-invoice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,21 @@ import { UpdateInvoiceRecord } from "@/types/invoice";
export const UpdateInvoice = FormSchema.omit({ date: true });

export function updateInvoiceDTO(invoiceId: string, formData: FormData) {
const validated = UpdateInvoice.parse({
const validated = UpdateInvoice.safeParse({
id: invoiceId,
amount: formData.get("amount"),
status: formData.get("status"),
customerId: formData.get("customerId"),
});
return validated;

if (!validated.success) {
return {
errors: validated.error.flatten().fieldErrors,
message: "Missing fields. Failed to create invoice.",
};
}

return validated.data;
}

export async function updateInvoiceDAL({
Expand Down
26 changes: 21 additions & 5 deletions lib/__tests__/actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,22 @@ describe("createInvoice(formData)", () => {
mockFormData.set("status", "pending");
mockFormData.set("customerId", "1");

it("create invoice successfully and navigates back to the invoices page", async () => {
await createInvoice(mockFormData);
it("creates invoice successfully and navigates back to the invoices page", async () => {
await createInvoice({}, mockFormData);

expect(sql).toHaveBeenCalledTimes(1);
expect(revalidatePath).toHaveBeenCalledWith("/dashboard/invoices");
expect(redirect).toHaveBeenCalledWith("/dashboard/invoices");
});

it("returns validation errors if the value doesn't match the schema", async () => {
const { errors } = await createInvoice({}, new FormData());

expect(errors).toHaveProperty("status");
expect(errors).toHaveProperty("amount");
expect(errors).toHaveProperty("customerId");
});

it("handles error gracefully", async () => {
jest.isolateModules(async () => {
jest.mock("@/data/invoices/create-invoice", () => {
Expand All @@ -58,7 +66,7 @@ describe("createInvoice(formData)", () => {

const { createInvoice } = require("../actions");

await createInvoice(mockFormData);
await createInvoice({}, mockFormData);

expect(revalidatePath).toHaveBeenCalledWith("/dashboard/invoices");
expect(redirect).toHaveBeenCalledWith("/dashboard/invoices");
Expand All @@ -74,13 +82,21 @@ describe("updateInvoice(invoiceId, formData)", () => {
mockFormData.set("customerId", "1");

it("update invoice successfully and navigates back to the invoices page", async () => {
await updateInvoice("1", mockFormData);
await updateInvoice("1", {}, mockFormData);

expect(sql).toHaveBeenCalledTimes(1);
expect(revalidatePath).toHaveBeenCalledWith("/dashboard/invoices");
expect(redirect).toHaveBeenCalledWith("/dashboard/invoices");
});

it("returns validation errors if the value doesn't match the schema", async () => {
const { errors } = await updateInvoice("1", {}, new FormData());

expect(errors).toHaveProperty("status");
expect(errors).toHaveProperty("amount");
expect(errors).toHaveProperty("customerId");
});

it("handles error gracefully", async () => {
jest.isolateModules(async () => {
jest.mock("@/data/invoices/update-invoice", () => {
Expand All @@ -96,7 +112,7 @@ describe("updateInvoice(invoiceId, formData)", () => {

const { updateInvoice } = require("../actions");

await updateInvoice("1", mockFormData);
await updateInvoice("1", {}, mockFormData);

expect(revalidatePath).toHaveBeenCalledWith("/dashboard/invoices");
expect(redirect).toHaveBeenCalledWith("/dashboard/invoices");
Expand Down
36 changes: 29 additions & 7 deletions lib/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,14 @@ import {
deleteInvoiceDTO,
} from "@/data/invoices/delete-invoice";

export async function createInvoice(formData: FormData) {
const { amount, customerId, status } = createInvoiceDTO(formData);
export async function createInvoice(_: State, formData: FormData) {
const validated = createInvoiceDTO(formData);

if ("errors" in validated) {
return validated;
}

const { amount, customerId, status } = validated;

const date = new Date().toISOString().split("T")[0];
const amountInCents = amount * 100;
Expand All @@ -36,11 +42,18 @@ export async function createInvoice(formData: FormData) {
redirect("/dashboard/invoices");
}

export async function updateInvoice(invoiceId: string, formData: FormData) {
const { id, amount, customerId, status } = updateInvoiceDTO(
invoiceId,
formData,
);
export async function updateInvoice(
invoiceId: string,
_: State,
formData: FormData,
) {
const validated = updateInvoiceDTO(invoiceId, formData);

if ("errors" in validated) {
return validated;
}

const { id, amount, customerId, status } = validated;

const amountInCents = amount * 100;

Expand Down Expand Up @@ -70,3 +83,12 @@ export async function deleteInvoice(invoiceId: string) {

revalidatePath("/dashboard/invoices");
}

export type State = {
errors?: {
customerId?: string[];
amount?: string[];
status?: string[];
};
message?: string | null;
};