diff --git a/package-lock.json b/package-lock.json index f1d49e1..a977a88 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,16 @@ { "name": "engineer-interview", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "engineer-interview", - "version": "0.1.0", + "version": "0.2.0", "dependencies": { "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "react-feather": "^2.0.10" }, "devDependencies": { "@testing-library/jest-dom": "^6.4.2", @@ -4543,7 +4544,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -5034,6 +5034,23 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/psl": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", @@ -5104,6 +5121,18 @@ "react": "^18.3.1" } }, + "node_modules/react-feather": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/react-feather/-/react-feather-2.0.10.tgz", + "integrity": "sha512-BLhukwJ+Z92Nmdcs+EMw6dy1Z/VLiJTzEQACDUEnWMClhYnFykJCGWQx+NmwP/qQHGX/5CzQ+TGi8ofg2+HzVQ==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.7.2" + }, + "peerDependencies": { + "react": ">=16.8.6" + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", diff --git a/package.json b/package.json index 1a66568..2648015 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "engineer-interview", - "version": "0.1.0", + "version": "0.2.0", "private": true, "type": "module", "scripts": { @@ -12,27 +12,28 @@ }, "dependencies": { "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "react-feather": "^2.0.10" }, "devDependencies": { + "@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/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.test.tsx b/src/ChallengeComponent.test.tsx new file mode 100644 index 0000000..79fce61 --- /dev/null +++ b/src/ChallengeComponent.test.tsx @@ -0,0 +1,60 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, test } from 'vitest'; +import { ChallengeComponent } from './ChallengeComponent'; +import { act } from 'react'; +import userEvent from '@testing-library/user-event'; +describe('TaskColumn', () => { + test('renders with correct title and tasks', () => { + render( + + ); + + expect(screen.getByText('To Do')).toBeInTheDocument(); + expect(screen.getByText('In Progress')).toBeInTheDocument(); + expect(screen.getByText('Done')).toBeInTheDocument(); + + expect(screen.getByText('Mow The lawn')).toBeInTheDocument(); + expect(screen.getByText('Rake the leaves')).toBeInTheDocument(); + }); + + test('should move tasks between columns', () => { + render( + + ); + + expect(screen.getByTestId('move-left-task-1')).toBeDisabled() + fireEvent.click(screen.getByTestId('move-right-task-1')) + expect(screen.getByTestId('move-left-task-1')).toBeEnabled() + fireEvent.click(screen.getByTestId('move-right-task-1')) + expect(screen.getByTestId('move-left-task-1')).toBeEnabled() + expect(screen.getByTestId('move-right-task-1')).toBeDisabled() + + expect(screen.getByTestId('move-right-task-3')).toBeDisabled() + fireEvent.click(screen.getByTestId('move-left-task-3')) + expect(screen.getByTestId('move-right-task-3')).toBeEnabled() + fireEvent.click(screen.getByTestId('move-left-task-3')) + expect(screen.getByTestId('move-right-task-3')).toBeEnabled() + expect(screen.getByTestId('move-left-task-3')).toBeDisabled() + }); + + test('should add a new task', async () => { + const user = userEvent.setup(); + + render( + + ); + + const input = screen.getByRole('textbox', { + name: /new task text/i + }) as HTMLInputElement; + + await act(async () => { + await user.type(input, 'New task'); + await user.click(screen.getByRole('button', { + name: /new task button/i + })); + }) + + expect(screen.getByText(/new task/i)).toBeInTheDocument() + }); +}); \ No newline at end of file diff --git a/src/ChallengeComponent.tsx b/src/ChallengeComponent.tsx index 2344883..e6d7a7a 100644 --- a/src/ChallengeComponent.tsx +++ b/src/ChallengeComponent.tsx @@ -1,10 +1,34 @@ +import { TaskColumn } from "./components/TaskColumn"; +import { TaskForm } from "./components/TaskForm"; +import { useTasks } from "./hooks/useTask"; +import { mockedBoard } from "./mocks"; + export function ChallengeComponent() { - return ( - <> - {/* Delete this h2, and add your own code here. */} -

- Your code goes here -

- + const board = mockedBoard; + const { tasks, addTask, moveTask } = useTasks({ board }) + + const getTasksForColumn = (columnId: string) => { + return tasks.filter(task => task.columnId === columnId) + } + + return ( +
+
+ { + board.columns.map((column, index) => ( + + )) + } +
+ + +
); } diff --git a/src/components/TaskCard/TaskCard.test.tsx b/src/components/TaskCard/TaskCard.test.tsx new file mode 100644 index 0000000..ddd9fd0 --- /dev/null +++ b/src/components/TaskCard/TaskCard.test.tsx @@ -0,0 +1,101 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { TaskCard } from './index'; +import type { Task } from '../../types'; +import { fireEvent, render, screen } from '@testing-library/react'; + +describe('TaskCard', () => { + const mockTask: Task = { + id: '1', + text: 'Test task', + columnId: 'todo', + boardId: 'board-1', + updatedAt: '2024-01-01T00:00:00Z' + }; + + const mockOnMoveLeft = vi.fn(); + const mockOnMoveRight = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('renders task text correctly', () => { + render( + + ); + + expect(screen.getByText('Test task')).toBeInTheDocument(); + }); + + test('calls onMoveLeft when left arrow is clicked', () => { + render( + + ); + + const leftButton = screen.getByTestId('move-left-1'); + fireEvent.click(leftButton); + + expect(mockOnMoveLeft).toHaveBeenCalledWith('1'); + expect(mockOnMoveLeft).toHaveBeenCalledTimes(1); + }); + + test('calls onMoveRight when right arrow is clicked', () => { + render( + + ); + + const rightButton = screen.getByTestId('move-right-1'); + fireEvent.click(rightButton); + + expect(mockOnMoveRight).toHaveBeenCalledWith('1'); + expect(mockOnMoveRight).toHaveBeenCalledTimes(1); + }); + + test('disables left button when isFirstColumn is true', () => { + render( + + ); + + const leftButton = screen.getByTestId('move-left-1'); + expect(leftButton).toBeDisabled(); + }); + + test('disables right button when isLastColumn is true', () => { + render( + + ); + + const rightButton = screen.getByTestId('move-right-1'); + expect(rightButton).toBeDisabled(); + }); +}); \ No newline at end of file diff --git a/src/components/TaskCard/index.tsx b/src/components/TaskCard/index.tsx new file mode 100644 index 0000000..eb69c3f --- /dev/null +++ b/src/components/TaskCard/index.tsx @@ -0,0 +1,32 @@ +import { Task } from "../../types" + import { ArrowLeft, ArrowRight} from 'react-feather'; + +interface TaskCardProps { + task: Task; + onMoveLeft: (taskId: string) => void; + onMoveRight: (taskId: string) => void; + isFirstColumn: boolean; + isLastColumn: boolean; +} + +export function TaskCard({ task, onMoveLeft, onMoveRight, isFirstColumn, isLastColumn }: TaskCardProps) { + return
+ +

{task.text}

+ +
+} \ No newline at end of file diff --git a/src/components/TaskColumn/TaskColumn.test.tsx b/src/components/TaskColumn/TaskColumn.test.tsx new file mode 100644 index 0000000..83ffb35 --- /dev/null +++ b/src/components/TaskColumn/TaskColumn.test.tsx @@ -0,0 +1,39 @@ +import { render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { TaskColumn } from './index'; +import type { Task, Column } from '../../types'; + +describe('TaskColumn', () => { + const mockColumn: Column = { + id: 'todo', + title: 'Todo', + order: 0 + }; + + const mockTasks: Task[] = [ + { id: '1', text: 'Task 1', columnId: 'todo', boardId: 'board-1', updatedAt: '2024-01-01T00:00:00Z' }, + { id: '2', text: 'Task 2', columnId: 'todo', boardId: 'board-1', updatedAt: '2024-01-01T00:00:00Z' } + ]; + + const mockOnMoveTask = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('renders with correct title and tasks', () => { + render( + + ); + + expect(screen.getByText('Todo')).toBeInTheDocument(); + expect(screen.getByText('Task 1')).toBeInTheDocument(); + expect(screen.getByText('Task 2')).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/src/components/TaskColumn/index.tsx b/src/components/TaskColumn/index.tsx new file mode 100644 index 0000000..ff851b9 --- /dev/null +++ b/src/components/TaskColumn/index.tsx @@ -0,0 +1,35 @@ +import { Column, Direction, DIRECTION, Task } from "../../types"; +import { TaskCard } from "../TaskCard"; + +interface TaskColumnProps { + column: Column; + tasks: Task[]; + onMoveTask: (taskId: string, direction: Direction) => void; + isFirstColumn: boolean; + isLastColumn: boolean; +} + +export function TaskColumn({ + column, + tasks, + onMoveTask, + isFirstColumn, + isLastColumn +}: TaskColumnProps) { + return ( +
+

{column.title}

+ + {tasks.map((task) => + onMoveTask(taskId, DIRECTION.LEFT)} + onMoveRight={(taskId) => onMoveTask(taskId, DIRECTION.RIGHT)} + isFirstColumn={isFirstColumn} + isLastColumn={isLastColumn} + /> + )} +
+ ) +} \ No newline at end of file diff --git a/src/components/TaskForm/TaskForm.test.tsx b/src/components/TaskForm/TaskForm.test.tsx new file mode 100644 index 0000000..99a9e23 --- /dev/null +++ b/src/components/TaskForm/TaskForm.test.tsx @@ -0,0 +1,79 @@ +import { render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import userEvent from '@testing-library/user-event'; +import { TaskForm } from './index'; +import { act } from 'react'; + +describe('TaskForm', () => { + const mockOnAddTask = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('renders form elements correctly', () => { + render(); + + expect(screen.getByRole('textbox', { + name: /new task text/i + })).toBeInTheDocument(); + expect(screen.getByRole('button')).toBeInTheDocument(); + }); + + test('clears input after successful submission', async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByRole('textbox', { + name: /new task text/i + }) as HTMLInputElement; + + await act(async () => { + await user.type(input, 'New task'); + await user.click(screen.getByRole('button')); + }) + + expect(input.value).toBe(''); + }); + + test('does not submit empty or whitespace only tasks', async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByRole('textbox', { + name: /new task text/i + }) as HTMLInputElement; + + const button = screen.getByRole('button', { + name: /new task button/i + }) + + await act(async () => { + await user.click(button); + }) + + expect(mockOnAddTask).not.toHaveBeenCalled(); + + await act(async () => { + await user.type(input, ' '); + await user.click(button); + }) + + expect(mockOnAddTask).not.toHaveBeenCalled(); + }); + + test('submits form on Enter key press', async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByRole('textbox', { + name: /new task text/i + }) as HTMLInputElement; + + await act(async () => { + await user.type(input, 'Task from enter{enter}'); + }) + + expect(mockOnAddTask).toHaveBeenCalledWith('Task from enter'); + }); +}); \ No newline at end of file diff --git a/src/components/TaskForm/index.tsx b/src/components/TaskForm/index.tsx new file mode 100644 index 0000000..71666bd --- /dev/null +++ b/src/components/TaskForm/index.tsx @@ -0,0 +1,47 @@ + import { ChangeEvent, FormEvent, useState } from "react"; +import { Plus } from "react-feather"; + +interface TaskFormProps { + onAddTask: (text: string) => void; +} + +export function TaskForm({ onAddTask }: TaskFormProps) { + const [newTaskText, setNewTaskText] = useState('') + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + const trimText = newTaskText.trim(); + if(trimText) { + onAddTask(trimText); + setNewTaskText(''); + } + } + + const handleChange = (e: ChangeEvent) => { + setNewTaskText(e.target.value); + } + + return ( +
+
+ + +
+
+ ) +} \ No newline at end of file diff --git a/src/hooks/useTask.ts b/src/hooks/useTask.ts new file mode 100644 index 0000000..d4465b2 --- /dev/null +++ b/src/hooks/useTask.ts @@ -0,0 +1,74 @@ +import { useCallback, useMemo, useState } from "react"; +import { Task, Direction, Board, DIRECTION } from "../types"; +import { mockedTasks } from "../mocks"; + +interface UseTaskOptions { + board: Board | null +} + +interface UseTaskReturn { + tasks: Task[]; + addTask: (text:string) => void; + moveTask: (taskId:string, direction: Direction) => void; +} + + + +export const useTasks = ({ board }: UseTaskOptions): UseTaskReturn => { + const [tasks, setTasks] = useState(mockedTasks) + + const addTask = useCallback((text: string) => { + if(!board) return; + + const newTask: Task = { + id: crypto.randomUUID(), + text, + columnId: 'todo', + boardId: board.id, + updatedAt: new Date().toISOString() + } + + setTasks(prevTasks => [...prevTasks, newTask]) + },[]) + + const moveTask = useCallback((taskId: string, direction: Direction) => { + if(!board) return; + + setTasks(prevTasks => { + return prevTasks.map(task => { + if(task.id === taskId){ + const currentColumn = board.columns.find(col => col.id === task.columnId) + let targetColumn; + + if(!currentColumn) return task + + if(direction === DIRECTION.RIGHT) { + targetColumn = board.columns.find(col => col.order === currentColumn.order + 1) + } + if(direction === DIRECTION.LEFT) { + targetColumn = board.columns.find(col => col.order === currentColumn.order - 1) + } + + if(!targetColumn) return task; + + return { + ...task, + columnId: targetColumn.id, + updatedAt: new Date().toISOString() + } + } + return task; + }) + }) + },[board]) + + const sortedTasksByUpdatedDate = useMemo(() => { + return tasks.sort((a, b) => new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()); + },[ tasks]) + + return { + tasks: sortedTasksByUpdatedDate, + addTask, + moveTask, + } +} \ No newline at end of file diff --git a/src/mocks/index.tsx b/src/mocks/index.tsx new file mode 100644 index 0000000..3e5aab9 --- /dev/null +++ b/src/mocks/index.tsx @@ -0,0 +1,29 @@ +import { Board } from "../types" + +export const mockedTasks = [ + {id: 'task-1', text: 'Mow The lawn', columnId: 'todo', boardId: '1', updatedAt: '2025-01-01T10:00:00Z'}, + {id: 'task-2', text: 'Pull Weeds', columnId: 'inProgress', boardId: '1', updatedAt: '2025-01-01T10:00:00Z'}, + {id: 'task-3', text: 'Rake the leaves', columnId: 'done', boardId: '1', updatedAt: '2025-01-01T10:00:00Z'} +] + +export const mockedBoard: Board = { + id: '1', + name: 'interview', + columns: [ + { + id: 'todo', + title: 'To Do', + order: 0, + }, + { + id: 'inProgress', + title: 'In Progress', + order: 1, + }, + { + id: 'done', + title: 'Done', + order: 2, + }, + ] +} \ No newline at end of file diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..55b0109 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,26 @@ +export interface Board { + id: string; + name: string; + columns: Column[] +} + +export interface Column { + id: string; + title: string; + order: number; +} + +export interface Task { + id: string; + text: string; + columnId: string; + boardId: string; + updatedAt: string; +} + +export type Direction = 'left' | 'right' + +export const DIRECTION = { + LEFT: 'left' as const, + RIGHT: 'right' as const +} as const; \ No newline at end of file diff --git a/tailwind.config.js b/tailwind.config.js index 4e9b470..5f2a32a 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -6,6 +6,13 @@ export default { fontFamily: { roboto: ["Roboto", "sans-serif"], }, + colors: { + "green": "#218d1f", + "green-disabled": "#90b08f", + "red-disabled": "#ff9e9e", + "red": "#c20b0b", + "blue-button": "#0066ff" + } }, }, plugins: [],