Add task list demo application

This commit is contained in:
Steve Kinney
2024-09-17 15:38:26 -06:00
parent b13029bffc
commit 4971bda81b
40 changed files with 2545 additions and 773 deletions

View File

@@ -0,0 +1,14 @@
import { CreateTask } from './create-task';
import { TaskProvider } from './task-context';
import { Tasks } from './tasks';
export const Application = () => {
return (
<TaskProvider>
<main className="container max-w-xl my-10 space-y-8">
<CreateTask onSubmit={(title) => console.log(title)} />
<Tasks />
</main>
</TaskProvider>
);
};

View File

@@ -0,0 +1,43 @@
import { useState } from 'react';
import { useTaskContext } from './task-context';
type CreateTaskProps = {
onSubmit: (title: string) => void;
};
export const CreateTask = ({ onSubmit }: CreateTaskProps) => {
const { addTask } = useTaskContext();
const [title, setTitle] = useState('');
return (
<form
method="POST"
action="/api/tasks"
onSubmit={(event) => {
event.preventDefault();
addTask(title);
setTitle('');
}}
>
<div>
<label htmlFor="new-task-title" className="sr-only">
Title
</label>
<div className="flex">
<input
type="text"
name="title"
id="new-task-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="What do you need to get done?"
required
/>
<button type="submit" disabled={!title}>
Create Task
</button>
</div>
</div>
</form>
);
};

View File

@@ -0,0 +1,15 @@
export const DateTime = ({ date, title }: { date: Date; title: string }) => {
return (
<div className="flex gap-2 text-xs sm:flex-row">
<h3 className="font-semibold sm:after:content-[':'] after:text-gray-900 text-primary-800">
{title}
</h3>
<p>
{date.toLocaleString(undefined, {
dateStyle: 'short',
timeStyle: 'short',
})}
</p>
</div>
);
};

View File

@@ -0,0 +1,9 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { Application } from './application';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<Application />
</StrictMode>,
);

View File

@@ -0,0 +1,131 @@
import {
createContext,
useReducer,
useEffect,
type ReactNode,
useContext,
} from 'react';
import { TasksActions, taskReducer, initialState } from './task-reducer';
import type { Task } from '../types';
interface TaskContextProps {
tasks: Task[];
loading: boolean;
error: string | null;
addTask: (title: string) => void;
updateTask: (id: string, updatedTask: Partial<Task>) => void;
deleteTask: (id: string) => void;
}
const TaskContext = createContext<TaskContextProps | undefined>(undefined);
interface TaskProviderProps {
children: ReactNode;
}
const TaskProvider = ({ children }: TaskProviderProps) => {
const [state, dispatch] = useReducer(taskReducer, initialState);
// Fetch all tasks
const fetchTasks = async () => {
dispatch({ type: TasksActions.SET_LOADING });
try {
const response = await fetch('/api/tasks');
const data = await response.json();
dispatch({ type: TasksActions.FETCH_TASKS, payload: data });
} catch (error) {
dispatch({
type: TasksActions.SET_ERROR,
payload: 'Failed to fetch tasks',
});
}
};
// Add a new task
const addTask = async (title: string) => {
dispatch({ type: TasksActions.SET_LOADING });
try {
const response = await fetch('/api/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
});
const data = await response.json();
dispatch({ type: TasksActions.ADD_TASK, payload: data });
} catch (error) {
dispatch({ type: TasksActions.SET_ERROR, payload: 'Failed to add task' });
}
};
// Update a task
const updateTask = async (id: string, updatedTask: Partial<Task>) => {
dispatch({ type: TasksActions.SET_LOADING });
try {
const response = await fetch(`/api/tasks/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updatedTask),
});
if (!response.ok) {
throw new Error('Failed to update task');
}
dispatch({
type: TasksActions.UPDATE_TASK,
payload: { id, ...updatedTask },
});
} catch (error) {
dispatch({
type: TasksActions.SET_ERROR,
payload: 'Failed to update task',
});
}
};
// Delete a task
const deleteTask = async (id: string) => {
dispatch({ type: TasksActions.SET_LOADING });
try {
await fetch(`/api/tasks/${id}`, {
method: 'DELETE',
});
dispatch({ type: TasksActions.DELETE_TASK, payload: id });
} catch (error) {
dispatch({
type: TasksActions.SET_ERROR,
payload: 'Failed to delete task',
});
}
};
useEffect(() => {
fetchTasks();
}, []);
return (
<TaskContext.Provider
value={{
tasks: state.tasks,
loading: state.loading,
error: state.error,
addTask,
updateTask,
deleteTask,
}}
>
{children}
</TaskContext.Provider>
);
};
const useTaskContext = () => {
const context = useContext(TaskContext);
if (!context) {
throw new Error('useTaskContext must be used within a TaskProvider');
}
return context;
};
export { TaskContext, TaskProvider, useTaskContext };

View File

@@ -0,0 +1,60 @@
import type { Task } from '../types';
export enum TasksActions {
FETCH_TASKS = 'fetch-tasks',
ADD_TASK = 'add-task',
UPDATE_TASK = 'update-task',
DELETE_TASK = 'delete-task',
SET_LOADING = 'set-loading',
SET_ERROR = 'set-error',
}
export interface TaskState {
tasks: Task[];
loading: boolean;
error: string | null;
}
export interface Action {
type: TasksActions;
payload?: any;
}
export const initialState: TaskState = {
tasks: [],
loading: false,
error: null,
};
export const taskReducer = (state: TaskState, action: Action): TaskState => {
switch (action.type) {
case TasksActions.FETCH_TASKS:
return { ...state, tasks: action.payload, loading: false };
case TasksActions.ADD_TASK:
return {
...state,
tasks: [...state.tasks, action.payload],
loading: false,
};
case TasksActions.UPDATE_TASK:
return {
...state,
tasks: state.tasks.map((task) =>
task.id === action.payload.id ? { ...task, ...action.payload } : task,
),
loading: false,
};
case TasksActions.DELETE_TASK:
return {
...state,
tasks: state.tasks.filter((task) => task.id !== action.payload),
loading: false,
};
case TasksActions.SET_LOADING:
return { ...state, loading: true };
case TasksActions.SET_ERROR:
return { ...state, error: action.payload, loading: false };
default:
return state;
}
};

View File

@@ -0,0 +1,40 @@
import { DateTime } from './date-time';
import { useTaskContext } from './task-context';
type TaskProps = {
task: import('../types').Task;
};
export const Task = ({ task }: TaskProps) => {
const { updateTask, deleteTask } = useTaskContext();
return (
<li className="block p-4 space-y-2 border-t-2 border-x-2 last:border-b-2">
<header className="flex flex-row items-center gap-4">
<label htmlFor={`toggle-${task.id}`} className="sr-only">
Mark Task as {task.completed ? 'Incomplete' : 'Complete'}
</label>
<input
id={`toggle-${task.id}`}
type="checkbox"
className="block w-6 h-6"
checked={task.completed}
onChange={() => updateTask(task.id, { completed: !task.completed })}
/>
<h2 className="w-full font-semibold">{task.title}</h2>
<button
className="py-1 px-1.5 text-xs button-destructive button-ghost"
onClick={() => {
deleteTask(task.id);
}}
>
Delete
</button>
</header>
<div className="flex flex-col md:gap-2 md:flex-row">
<DateTime date={task.createdAt} title="Created" />
<DateTime date={task.lastModified} title="Modified" />
</div>
</li>
);
};

View File

@@ -0,0 +1,16 @@
import { Task } from './task';
import { useTaskContext } from './task-context';
export const Tasks = () => {
const { tasks } = useTaskContext();
return (
<section>
<ul>
{tasks.map((task) => (
<Task key={task.id} task={task} />
))}
</ul>
</section>
);
};