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: [],