diff --git a/package-lock.json b/package-lock.json index f1d49e1..257fef1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,8 @@ "@testing-library/jest-dom": "^6.4.2", "@testing-library/react": "^14.2.1", "@testing-library/user-event": "^14.5.2", - "@types/react": "^18.2.66", + "@types/node": "^24.5.2", + "@types/react": "^18.3.24", "@types/react-dom": "^18.2.22", "@typescript-eslint/eslint-plugin": "^7.2.0", "@typescript-eslint/parser": "^7.2.0", @@ -1561,6 +1562,16 @@ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true }, + "node_modules/@types/node": { + "version": "24.5.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.5.2.tgz", + "integrity": "sha512-FYxk1I7wPv3K2XBaoyH2cTnocQEu8AOZ60hPbsyukMPLv5/5qr7V1i8PLHdl6Zf87I+xZXFvPCXYjiTFq+YSDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.12.0" + } + }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", @@ -1568,10 +1579,11 @@ "dev": true }, "node_modules/@types/react": { - "version": "18.3.23", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.23.tgz", - "integrity": "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==", + "version": "18.3.24", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.24.tgz", + "integrity": "sha512-0dLEBsA1kI3OezMBF8nSsb7Nk19ZnsyE1LLhB8r27KbgU5H4pvuqZLdtE+aUkJVoXgTVuA+iLIwmZ0TuK4tx6A==", "dev": true, + "license": "MIT", "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -5979,6 +5991,13 @@ "integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==", "dev": true }, + "node_modules/undici-types": { + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.12.0.tgz", + "integrity": "sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ==", + "dev": true, + "license": "MIT" + }, "node_modules/universalify": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", diff --git a/package.json b/package.json index 1a66568..ce19b9c 100644 --- a/package.json +++ b/package.json @@ -15,24 +15,25 @@ "react-dom": "^18.2.0" }, "devDependencies": { - "@types/react": "^18.2.66", + "@testing-library/jest-dom": "^6.4.2", + "@testing-library/react": "^14.2.1", + "@testing-library/user-event": "^14.5.2", + "@types/node": "^24.5.2", + "@types/react": "^18.3.24", "@types/react-dom": "^18.2.22", "@typescript-eslint/eslint-plugin": "^7.2.0", "@typescript-eslint/parser": "^7.2.0", "@vitejs/plugin-react": "^4.2.1", + "@vitest/ui": "^1.4.0", "autoprefixer": "^10.4.19", "eslint": "^8.57.0", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.6", + "jsdom": "^24.0.0", "postcss": "^8.4.38", "tailwindcss": "^3.4.3", "typescript": "^5.2.2", "vite": "^5.2.0", - "vitest": "^1.4.0", - "@vitest/ui": "^1.4.0", - "@testing-library/react": "^14.2.1", - "@testing-library/jest-dom": "^6.4.2", - "@testing-library/user-event": "^14.5.2", - "jsdom": "^24.0.0" + "vitest": "^1.4.0" } } diff --git a/src/ChallengeComponent.tsx b/src/ChallengeComponent.tsx index 2344883..19fddaf 100644 --- a/src/ChallengeComponent.tsx +++ b/src/ChallengeComponent.tsx @@ -1,10 +1,96 @@ +import { useEffect, useState } from "react"; +import { TaskColumn } from "./components/TaskColumn"; +import { Task, TaskStatus } from "./types"; +import { taskService } from "./services/taskService"; +import { filterByStatus } from "./helpers/tasks"; +import { CreateTaskForm } from "./components/CreateTaskForm"; +import { COLUMN_LABELS, TASK_STATUS_IDS } from "./utils/constants"; + export function ChallengeComponent() { + const [tasks, setTasks] = useState([]); + + useEffect(() => { + (async () => { + // Seed a list of tasks if the task list is empty for demo purposes. + let allTasks = await taskService.fetchAll(); + if (allTasks.length === 0) { + allTasks = await taskService.seed(); + } + setTasks(allTasks); + })(); + }, []); + + const moveTask = async (id: string, direction: "previous" | "next") => { + const order: TaskStatus[] = ["todo", "inProgress", "done"]; + const task = tasks.find((task) => task.id === id); + + if (!task) return; + + let taskStatusIndex = order.indexOf(task.status); + if (direction === "previous") + taskStatusIndex = Math.max(0, taskStatusIndex - 1); + if (direction === "next") + taskStatusIndex = Math.min(order.length - 1, taskStatusIndex + 1); + + const updated = await taskService.update(id, { + status: order[taskStatusIndex], + }); + + if (updated) { + setTasks((prev) => prev.map((task) => (task.id === id ? updated : task))); + } + }; + + const handleCreate = async (task: Omit) => { + const newTask = await taskService.create(task); + setTasks((prev) => [...prev, newTask]); + }; + + const handleDelete = async (id: string) => { + await taskService.delete(id); + setTasks((prevTasks) => prevTasks.filter((task) => task.id !== id)); + }; + + const handleClear = async () => { + await taskService.clear(); + setTasks([]); + }; + + const handleSeed = async () => { + const seeded = await taskService.seed(); + setTasks(seeded); + }; + return ( - <> - {/* Delete this h2, and add your own code here. */} -

- Your code goes here -

- +
+
+ {TASK_STATUS_IDS.map((taskStatusId) => ( + + ))} +
+
+ +
+ + +
+
+
); } diff --git a/src/components/CreateTaskForm.tsx b/src/components/CreateTaskForm.tsx new file mode 100644 index 0000000..dacc483 --- /dev/null +++ b/src/components/CreateTaskForm.tsx @@ -0,0 +1,52 @@ +import { useState, FormEvent } from "react"; +import { Task } from "../types"; + +interface CreateTaskFormProps { + onCreate: (task: Omit) => void; +} + +export function CreateTaskForm({ onCreate }: CreateTaskFormProps) { + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + onCreate({ + title: title.trim(), + description: description.trim(), + status: "todo", + }); + + // Note: if this were to be handled by an HTTP request, we would only reset the + // inputs after onSuccess. + setTitle(""); + setDescription(""); + }; + + return ( +
+ setTitle(e.target.value)} + placeholder="Task title" + aria-label="Task title" + className="border rounded px-2 py-1" + /> + setDescription(e.target.value)} + placeholder="Task description" + aria-label="Task description" + className="border rounded px-2 py-1" + /> + +
+ ); +} diff --git a/src/components/TaskCard.tsx b/src/components/TaskCard.tsx new file mode 100644 index 0000000..ec73aa8 --- /dev/null +++ b/src/components/TaskCard.tsx @@ -0,0 +1,64 @@ +import { COLUMN_LABELS } from "@/utils/constants"; +import { Task, TaskStatus } from "../types"; + +interface TaskCardProps { + task: Task; + onMovePrevious?: (taskId: string) => void; + onMoveNext?: (taskId: string) => void; + onDelete?: (taskId: string) => void; +} + +export function TaskCard({ + task, + onMovePrevious, + onMoveNext, + onDelete, +}: TaskCardProps) { + const { id, title, description, status } = task; + + const statusOrder: TaskStatus[] = ["todo", "inProgress", "done"]; + const currentIndex = statusOrder.indexOf(status); + + const statusColors: Record = { + todo: "bg-blue-100", + inProgress: "bg-yellow-100", + done: "bg-green-100", + }; + + return ( +
+
+

{title}

+

{COLUMN_LABELS[status]}

+
+

{description}

+ +
+ +
+ + +
+
+
+ ); +} diff --git a/src/components/TaskColumn.tsx b/src/components/TaskColumn.tsx new file mode 100644 index 0000000..fdcffc3 --- /dev/null +++ b/src/components/TaskColumn.tsx @@ -0,0 +1,40 @@ +import { Task } from "../types"; +import { TaskCard } from "./TaskCard"; + +interface TaskColumnProps { + title: string; + tasks: Task[]; + moveTask: (id: string, direction: "previous" | "next") => void; + onDelete: (id: string) => void; +} + +export function TaskColumn({ + title, + tasks, + moveTask, + onDelete, +}: TaskColumnProps) { + return ( +
+

+ {title} +

+
    + {tasks.length === 0 ? ( +
  • No tasks
  • + ) : ( + tasks.map((task) => ( +
  • + moveTask(id, "previous")} + onMoveNext={(id) => moveTask(id, "next")} + onDelete={onDelete} + /> +
  • + )) + )} +
+
+ ); +} diff --git a/src/helpers/tasks.ts b/src/helpers/tasks.ts new file mode 100644 index 0000000..04ab5e7 --- /dev/null +++ b/src/helpers/tasks.ts @@ -0,0 +1,8 @@ +import { Task, TaskStatus } from "../types"; + +export const filterByStatus = ( + allTasks: Task[], + status: TaskStatus +): Task[] => { + return allTasks.filter((task) => task.status === status); +}; diff --git a/src/services/taskService.ts b/src/services/taskService.ts new file mode 100644 index 0000000..acac248 --- /dev/null +++ b/src/services/taskService.ts @@ -0,0 +1,81 @@ +import { Task } from "@/types"; + +const STORAGE_KEY = "tasks"; + +const load = (): Task[] => { + const data = localStorage.getItem(STORAGE_KEY); + return data ? (JSON.parse(data) as Task[]) : []; +}; + +const save = (tasks: Task[]) => { + localStorage.setItem(STORAGE_KEY, JSON.stringify(tasks)); +}; + +export const taskService = { + seed: () => { + const sampleTasks: Task[] = [ + { + id: "1", + title: "Do laundry", + description: "Don't forget to run the dryer this time", + status: "todo", + }, + { + id: "2", + title: "Do dishes", + description: "Handwash the skillet", + status: "inProgress", + }, + { + id: "3", + title: "Nap", + description: "Try to keep it under 3 hours", + status: "done", + }, + ]; + save(sampleTasks); + return sampleTasks; + }, + + fetchAll: (): Task[] => { + return load(); + }, + + create: (task: Omit): Task => { + const tasks = load(); + const newTask: Task = { id: crypto.randomUUID(), ...task }; + + tasks.push(newTask); + + save(tasks); + return newTask; + }, + + update: (id: string, updates: Partial>): Task | null => { + const tasks = load(); + const index = tasks.findIndex((task) => task.id === id); + + if (index === -1) return null; + + const updatedTask = { ...tasks[index], ...updates }; + + tasks[index] = updatedTask; + save(tasks); + + return updatedTask; + }, + + delete: (id: string): boolean => { + const tasks = load(); + const newTasks = tasks.filter((task) => task.id !== id); + + if (newTasks.length === tasks.length) return false; + save(newTasks); + + return true; + }, + + clear: (): void => { + localStorage.removeItem(STORAGE_KEY); + }, +}; diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..9dbd4f3 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,9 @@ +export type TaskStatus = "todo" | "inProgress" | "done"; +export type TaskStatusArray = TaskStatus[]; + +export interface Task { + id: string; + title: string; + description: string; + status: TaskStatus; +} diff --git a/src/utils/constants.ts b/src/utils/constants.ts new file mode 100644 index 0000000..cf9e025 --- /dev/null +++ b/src/utils/constants.ts @@ -0,0 +1,8 @@ +import { TaskStatus, TaskStatusArray } from "@/types"; + +export const TASK_STATUS_IDS: TaskStatusArray = ["todo", "inProgress", "done"]; +export const COLUMN_LABELS: Record = { + todo: "To Do", + inProgress: "In Progress", + done: "Done", +}; diff --git a/src/App.test.tsx b/test/App.test.tsx similarity index 91% rename from src/App.test.tsx rename to test/App.test.tsx index 3f4e706..fce4f8b 100644 --- a/src/App.test.tsx +++ b/test/App.test.tsx @@ -1,6 +1,6 @@ import { render, screen } from "@testing-library/react"; import { describe, it, expect } from "vitest"; -import App from "./App"; +import App from "../src/App"; describe("App", () => { it("renders welcome message", () => { diff --git a/test/components/CreateTaskForm.test.tsx b/test/components/CreateTaskForm.test.tsx new file mode 100644 index 0000000..7565d24 --- /dev/null +++ b/test/components/CreateTaskForm.test.tsx @@ -0,0 +1,54 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import { CreateTaskForm } from "@/components/CreateTaskForm"; +import { describe, it, expect, vi } from "vitest"; + +describe("CreateTaskForm", () => { + it("calls onCreate with the correct data and resets inputs", () => { + const mockOnCreate = vi.fn(); + render(); + + const titleInput = screen.getByLabelText(/task title/i) as HTMLInputElement; + const descriptionInput = screen.getByLabelText( + /task description/i + ) as HTMLInputElement; + const submitButton = screen.getByRole("button", { name: /add task/i }); + + fireEvent.change(titleInput, { target: { value: "My Task" } }); + fireEvent.change(descriptionInput, { + target: { value: "Task description" }, + }); + fireEvent.click(submitButton); + + expect(mockOnCreate).toHaveBeenCalledTimes(1); + expect(mockOnCreate).toHaveBeenCalledWith({ + title: "My Task", + description: "Task description", + status: "todo", + }); + + expect(titleInput.value).toBe(""); + expect(descriptionInput.value).toBe(""); + }); + + it("trims input values before calling onCreate", () => { + const mockOnCreate = vi.fn(); + + render(); + + const titleInput = screen.getByLabelText(/task title/i); + const descriptionInput = screen.getByLabelText(/task description/i); + const submitButton = screen.getByRole("button", { name: /add task/i }); + + fireEvent.change(titleInput, { target: { value: " Task with spaces " } }); + fireEvent.change(descriptionInput, { + target: { value: " Description " }, + }); + fireEvent.click(submitButton); + + expect(mockOnCreate).toHaveBeenCalledWith({ + title: "Task with spaces", + description: "Description", + status: "todo", + }); + }); +}); diff --git a/test/components/TaskCard.test.tsx b/test/components/TaskCard.test.tsx new file mode 100644 index 0000000..d8e5ba9 --- /dev/null +++ b/test/components/TaskCard.test.tsx @@ -0,0 +1,62 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import { describe, it, expect, vi } from "vitest"; +import { TaskCard } from "@/components/TaskCard"; +import { Task } from "@/types"; +import { COLUMN_LABELS } from "@/utils/constants"; + +describe("TaskCard", () => { + const baseTask: Task = { + id: "1", + title: "Do Things!", + description: "Do all of the things!", + status: "inProgress", + }; + + it("renders task title, description, and status label", () => { + render(); + expect(screen.getByText(baseTask.title)).toBeInTheDocument(); + expect(screen.getByText(baseTask.description)).toBeInTheDocument(); + expect( + screen.getByText(COLUMN_LABELS[baseTask.status]) + ).toBeInTheDocument(); + }); + + it("calls onMovePrevious and onMoveNext when buttons are clicked", () => { + const mockPrev = vi.fn(); + const mockNext = vi.fn(); + + render( + + ); + + const prevButton = screen.getByRole("button", { name: /previous/i }); + const nextButton = screen.getByRole("button", { name: /next/i }); + + fireEvent.click(prevButton); + fireEvent.click(nextButton); + + expect(mockPrev).toHaveBeenCalledTimes(1); + expect(mockPrev).toHaveBeenCalledWith(baseTask.id); + + expect(mockNext).toHaveBeenCalledTimes(1); + expect(mockNext).toHaveBeenCalledWith(baseTask.id); + }); + + it("disables Previous button when task is first in status order", () => { + const firstTask: Task = { ...baseTask, status: "todo" }; + render(); + const prevButton = screen.getByRole("button", { name: /previous/i }); + expect(prevButton).toBeDisabled(); + }); + + it("disables Next button when task is last in status order", () => { + const lastTask: Task = { ...baseTask, status: "done" }; + render(); + const nextButton = screen.getByRole("button", { name: /next/i }); + expect(nextButton).toBeDisabled(); + }); +}); diff --git a/test/components/TaskColumn.test.tsx b/test/components/TaskColumn.test.tsx new file mode 100644 index 0000000..97c269a --- /dev/null +++ b/test/components/TaskColumn.test.tsx @@ -0,0 +1,103 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import { describe, it, expect, vi } from "vitest"; +import { TaskColumn } from "@/components/TaskColumn"; +import { Task } from "@/types"; + +describe("TaskColumn", () => { + const tasks: Task[] = [ + { + id: "1", + title: "Task 1", + description: "Do the first thing.", + status: "todo", + }, + { + id: "2", + title: "Task 2", + description: "Now do the second thing.", + status: "inProgress", + }, + ]; + + it("renders the column title", () => { + render( + {}} + onDelete={() => {}} + /> + ); + + expect(screen.getByText("Todo")).toBeDefined(); + }); + + it("renders tasks when present", () => { + render( + {}} + onDelete={() => {}} + /> + ); + + tasks.forEach((task) => { + expect(screen.getByText(task.title)).toBeDefined(); + expect(screen.getByText(task.description)).toBeDefined(); + }); + }); + + it("shows empty state when there are no tasks", () => { + render( + {}} + onDelete={() => {}} + /> + ); + + expect(screen.getByText("No tasks")).toBeDefined(); + }); + + it("calls moveTask on Previous / Next button clicks", () => { + const moveTaskMock = vi.fn(); + + render( + {}} + /> + ); + + const prevButtons = screen.getAllByText("Previous"); + const nextButtons = screen.getAllByText("Next"); + + fireEvent.click(nextButtons[0]); + expect(moveTaskMock).toHaveBeenCalledWith("1", "next"); + + fireEvent.click(prevButtons[1]); + expect(moveTaskMock).toHaveBeenCalledWith("2", "previous"); + }); + + it("calls onDelete when Delete button is clicked", () => { + const onDeleteMock = vi.fn(); + + render( + {}} + onDelete={onDeleteMock} + /> + ); + + const deleteButtons = screen.getAllByText("Delete"); + + fireEvent.click(deleteButtons[0]); + expect(onDeleteMock).toHaveBeenCalledWith("1"); + }); +}); diff --git a/test/helpers/tasks.test.ts b/test/helpers/tasks.test.ts new file mode 100644 index 0000000..8246939 --- /dev/null +++ b/test/helpers/tasks.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from "vitest"; +import { filterByStatus } from "@/helpers/tasks"; +import { Task, TaskStatus } from "@/types"; + +describe("filterByStatus", () => { + const sampleTasks: Task[] = [ + { id: "1", title: "Task 1", description: "Do the thing", status: "todo" }, + { + id: "2", + title: "Task 2", + description: "Do the thing", + status: "inProgress", + }, + { id: "3", title: "Task 3", description: "Do the thing", status: "done" }, + { id: "4", title: "Task 4", description: "Do the thing", status: "todo" }, + ]; + + it.each([ + ["todo", ["1", "4"]], + ["inProgress", ["2"]], + ["done", ["3"]], + ] as [TaskStatus, string[]][])( + "returns only tasks with corredsponding status", + (status, expectedIds) => { + const result = filterByStatus(sampleTasks, status); + expect(result.map((t) => t.id)).toEqual(expectedIds); + } + ); + + it("returns an empty array when given an empty task list", () => { + expect(filterByStatus([], "todo")).toEqual([]); + }); +}); diff --git a/test/services/taskService.test.ts b/test/services/taskService.test.ts new file mode 100644 index 0000000..3e562cc --- /dev/null +++ b/test/services/taskService.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { taskService } from "@/services/taskService"; + +describe("taskService", () => { + const localStorageMock = (() => { + let store: Record = {}; + return { + getItem: (key: string) => store[key] || null, + setItem: (key: string, value: string) => { + store[key] = value.toString(); + }, + removeItem: (key: string) => { + delete store[key]; + }, + clear: () => { + store = {}; + }, + }; + })(); + + beforeEach(() => { + // @ts-ignore + global.localStorage = localStorageMock; + localStorage.clear(); + }); + + it("seeds sample tasks", () => { + const seededTasks = taskService.seed(); + + expect(seededTasks.length).toBe(3); + + const tasks = JSON.parse(localStorage.getItem("tasks")!); + + expect(tasks.length).toBe(3); + }); + + it("fetchs all tasks", () => { + taskService.seed(); + const tasks = taskService.fetchAll(); + + expect(tasks.length).toBe(3); + }); + + it("creates a new task", () => { + taskService.seed(); + const newTask = taskService.create({ + title: "Test task", + description: "Test description", + status: "todo", + }); + + expect(newTask.id).toBeDefined(); + expect(newTask.title).toBe("Test task"); + + const tasks = taskService.fetchAll(); + + expect(tasks.length).toBe(4); + }); + + it("updates an existing task", () => { + const tasks = taskService.seed(); + const taskToUpdate = tasks[0]; + + const updated = taskService.update(taskToUpdate.id, { + title: "Updated title", + status: "done", + }); + + expect(updated).not.toBeNull(); + expect(updated?.title).toBe("Updated title"); + expect(updated?.status).toBe("done"); + + const stored = taskService.fetchAll().find((t) => t.id === taskToUpdate.id); + + expect(stored?.title).toBe("Updated title"); + }); + + it("returns null when updating non-existent task", () => { + const result = taskService.update("non-existent-id", { + title: "This does not exist", + }); + + expect(result).toBeNull(); + }); + + it("deletes a task", () => { + const tasks = taskService.seed(); + const taskToDelete = tasks[0]; + const result = taskService.delete(taskToDelete.id); + + expect(result).toBe(true); + + const remainingTasks = taskService.fetchAll(); + + expect(remainingTasks.length).toBe(2); + expect( + remainingTasks.find((t) => t.id === taskToDelete.id) + ).toBeUndefined(); + }); + + it("returns false when deleting a non-existent task", () => { + taskService.seed(); + const result = taskService.delete("non-existent-id"); + + expect(result).toBe(false); + }); + + it("clears all tasks", () => { + taskService.seed(); + taskService.clear(); + + const tasks = taskService.fetchAll(); + + expect(tasks.length).toBe(0); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index a7fc6fb..6b44549 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,8 +18,12 @@ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true + "noFallthroughCasesInSwitch": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } }, - "include": ["src"], + "include": ["src", "test"], "references": [{ "path": "./tsconfig.node.json" }] } diff --git a/vite.config.ts b/vite.config.ts index 279c5d2..4b31cad 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,5 +1,6 @@ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; +import path from "path"; // https://vitejs.dev/config/ export default defineConfig({ @@ -8,6 +9,11 @@ export default defineConfig({ port: 3000, open: true, }, + resolve: { + alias: { + "@": path.resolve(__dirname, "src"), + }, + }, test: { globals: true, environment: "jsdom",