Refactor task list example application

This commit is contained in:
Steve Kinney
2024-09-18 07:52:42 -06:00
parent e29c63f5cb
commit 2a8720330e
26 changed files with 535 additions and 326 deletions

View File

@@ -29,12 +29,14 @@
"chalk": "^5.3.0",
"concurrently": "^9.0.1",
"cors": "^2.8.5",
"tailwind-merge": "^2.5.2",
"uuid": "^10.0.0",
"vite": "^5.4.6",
"vitest": "^2.1.1"
},
"dependencies": {
"body-parser": "^1.20.3",
"express": "^4.21.0"
"express": "^4.21.0",
"lucide-react": "^0.441.0"
}
}

View File

@@ -1,6 +1,6 @@
import { v4 as id } from 'uuid';
/** @typedef {import('../types').Task} Task */
/** @typedef {import('../src/types').Task} Task */
/** @type {Task[]} tasks - An array to store tasks. */
let tasks = [];

View File

@@ -0,0 +1,62 @@
import { useMemo } from 'react';
import { getErrorMessage } from 'utility-belt';
import {
Task,
SetTasksAction,
AddTaskAction,
UpdateTaskAction,
RemoveTaskAction,
SetLoadingAction,
SetErrorAction,
TaskAction,
TaskData,
} from './types';
export const setTasks = (tasks: Task[]): SetTasksAction => ({
type: 'set-tasks',
payload: tasks,
});
export const addTask = (task: Task): AddTaskAction => ({
type: 'add-task',
payload: task,
});
export const updateTask = (id: string, task: TaskData): UpdateTaskAction => ({
type: 'update-task',
payload: { ...task, id },
});
export const removeTask = (id: string): RemoveTaskAction => ({
type: 'remove-task',
payload: id,
});
export const setLoading = (): SetLoadingAction => ({
type: 'set-loading',
});
export const setError = (
error: unknown,
fallback: string = 'Unknown error',
): SetErrorAction => {
const message = getErrorMessage(error, fallback);
return { type: 'set-error', payload: message };
};
export const bindActionCreators = (dispatch: React.Dispatch<TaskAction>) => {
return useMemo(
() => ({
setTasks: (tasks: Task[]) => dispatch(setTasks(tasks)),
addTask: (task: Task) => dispatch(addTask(task)),
updateTask: (id: string, task: TaskData) =>
dispatch(updateTask(id, task)),
removeTask: (id: string) => dispatch(removeTask(id)),
setLoading: () => dispatch(setLoading()),
setError: (error: unknown, fallback: string) =>
dispatch(setError(error, fallback)),
}),
[dispatch],
);
};

View File

@@ -0,0 +1,50 @@
import { Task } from './types';
export const all = async (): Promise<Task[]> => {
const response = await fetch('/api/tasks');
if (!response.ok) {
throw new Error('Failed to fetch tasks');
}
return response.json();
};
export const add = async (title: string): Promise<Task> => {
const response = await fetch('/api/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
});
if (!response.ok) {
throw new Error('Failed to add task');
}
return response.json();
};
export const update = async (
id: string,
updatedTask: Partial<Task>,
): Promise<void> => {
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');
}
};
export const remove = async (id: string): Promise<void> => {
const response = await fetch(`/api/tasks/${id}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error('Failed to delete task');
}
};

View File

@@ -1,5 +1,5 @@
import { CreateTask } from './create-task';
import { TaskProvider } from './task-context';
import { TaskProvider } from '../contexts/task-context';
import { Tasks } from './tasks';
export const Application = () => {

View File

@@ -1,5 +1,5 @@
import { useState } from 'react';
import { useTaskActions } from './task-context';
import { useTaskActions } from '../contexts/task-context';
type CreateTaskProps = {
onSubmit: (title: string) => void;
@@ -23,7 +23,7 @@ export const CreateTask = ({ onSubmit }: CreateTaskProps) => {
<label htmlFor="new-task-title" className="sr-only">
Title
</label>
<div className="flex">
<div className="flex flex-col sm:flex-row">
<input
type="text"
name="title"

View File

@@ -0,0 +1,26 @@
import type { ComponentPropsWithoutRef } from 'react';
import { twMerge as merge } from 'tailwind-merge';
const formatDate = new Intl.DateTimeFormat(navigator.language, {
dateStyle: 'short',
timeStyle: 'short',
});
export const DateTime = ({
date,
title,
className,
}: ComponentPropsWithoutRef<'div'> & {
date: Date | string;
title: string;
}) => {
if (typeof date === 'string') date = new Date(date);
return (
<div className={merge('overflow-x-hidden space-x-2 text-xs', className)}>
<span className="font-semibold after:content-[':'] after:text-slate-700 text-primary-800 dark:text-primary-200 dark:after:text-slate-300">
{title}
</span>
<span className="whitespace-pre">{formatDate.format(date)}</span>
</div>
);
};

View File

@@ -0,0 +1,45 @@
import { memo } from 'react';
import { ChevronRightCircle } from 'lucide-react';
import { DateTime } from './date-time';
import { useTaskActions } from '../contexts/task-context';
type TaskProps = {
task: import('../types').Task;
};
export const Task = memo(({ task }: TaskProps) => {
const { updateTask, removeTask } = useTaskActions();
return (
<li className="block p-4 space-y-2 border-t first:rounded-t-md border-x last:border-b last:rounded-b-md dark:border-slate-700">
<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="button-small button-destructive button-ghost"
onClick={() => removeTask(task.id)}
>
</button>
</header>
<label className="text-xs grid grid-cols-[1.25rem_1fr] gap-1 cursor-pointer rounded-md bg-primary-50 dark:bg-primary-950 border-2 border-primary-100 dark:border-primary-900 p-2">
<input type="checkbox" className="sr-only peer" />
<ChevronRightCircle className="block w-4 h-4 transition-transform peer-checked:rotate-90" />
<h3 className="select-none">Metadata</h3>
<div className="hidden col-span-2 gap-2 peer-checked:flex">
<DateTime date={task.createdAt} title="Created" />
<DateTime date={task.lastModified} title="Modified" />
</div>
</label>
</li>
);
});

View File

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

View File

@@ -0,0 +1,125 @@
import {
createContext,
useReducer,
useMemo,
useEffect,
type PropsWithChildren,
useContext,
useCallback,
} from 'react';
import { taskReducer, initialState } from '../reducer';
import { bindActionCreators } from '../actions';
import * as api from '../api';
import type { Task, TaskContextProps } from '../types';
const TaskContext = createContext<TaskContextProps | undefined>(undefined);
const TaskProvider = ({ children }: PropsWithChildren) => {
const [state, dispatch] = useReducer(taskReducer, initialState);
const { setLoading, setError, ...actions } = bindActionCreators(dispatch);
// Fetch all tasks
const getAllTasks = useCallback(async () => {
setLoading();
try {
const tasks = await api.all();
actions.setTasks(tasks);
} catch (error) {
setError(error, 'Failed to fetch tasks');
}
}, [dispatch]);
// Add a new task
const addTask = useCallback(
async (title: string) => {
setLoading();
try {
const task = await api.add(title);
actions.addTask(task);
} catch (error) {
setError(error, 'Failed to add task');
}
},
[dispatch],
);
// Update a task
const updateTask = useCallback(
async (id: string, updatedTask: Partial<Task>) => {
setLoading();
try {
await api.update(id, updatedTask);
actions.updateTask(id, updatedTask);
} catch (error) {
setError(error, 'Failed to update task');
}
},
[dispatch],
);
// Delete a task
const removeTask = useCallback(
async (id: string) => {
setLoading();
try {
await api.remove(id);
actions.removeTask(id);
} catch (error) {
setError(error, 'Failed to delete task');
}
},
[dispatch],
);
useEffect(() => {
getAllTasks();
}, []);
return (
<TaskContext.Provider
value={{
tasks: state.tasks,
loading: state.loading,
error: state.error,
addTask,
updateTask,
removeTask,
}}
>
{children}
</TaskContext.Provider>
);
};
const useTaskState = () => {
const context = useContext(TaskContext);
if (!context) {
throw new Error('useTaskContext must be used within a TaskProvider');
}
return context.tasks;
};
export const useTaskActions = () => {
const context = useContext(TaskContext);
if (!context) {
throw new Error('useTaskContext must be used within a TaskProvider');
}
const actions = useMemo(
() => ({
addTask: context.addTask,
updateTask: context.updateTask,
removeTask: context.removeTask,
}),
[context.addTask, context.updateTask, context.removeTask],
);
return actions;
};
export { TaskContext, TaskProvider, useTaskState };

View File

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

View File

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

View File

@@ -0,0 +1,45 @@
import type { Task, TaskAction, TaskState } from './types';
export const initialState: TaskState = {
tasks: [],
loading: false,
error: null,
};
export const taskReducer = (
state: TaskState,
action: TaskAction,
): TaskState => {
switch (action.type) {
case 'set-tasks':
return { ...state, tasks: action.payload, loading: false };
case 'add-task':
return {
...state,
tasks: [...state.tasks, action.payload],
loading: false,
};
case 'update-task':
const { id, ...payload } = action.payload;
return {
...state,
tasks: state.tasks.map((task) =>
task.id === id ? { ...task, ...payload } : task,
),
loading: false,
};
case 'remove-task':
return {
...state,
tasks: state.tasks.filter((task) => task.id !== action.payload),
loading: false,
};
case 'set-loading':
return { ...state, loading: action.payload };
case 'set-error':
return { ...state, error: action.payload, loading: false };
default:
return state;
}
};

View File

@@ -1,166 +0,0 @@
import {
createContext,
useReducer,
useMemo,
useEffect,
type ReactNode,
useContext,
useCallback,
} 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 = useCallback(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',
});
}
}, [dispatch]);
// Add a new task
const addTask = useCallback(
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',
});
}
},
[dispatch],
);
// Update a task
const updateTask = useCallback(
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',
});
}
},
[dispatch],
);
// Delete a task
const deleteTask = useCallback(
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',
});
}
},
[dispatch],
);
useEffect(() => {
fetchTasks();
}, []);
return (
<TaskContext.Provider
value={{
tasks: state.tasks,
loading: state.loading,
error: state.error,
addTask,
updateTask,
deleteTask,
}}
>
{children}
</TaskContext.Provider>
);
};
const useTaskState = () => {
const context = useContext(TaskContext);
if (!context) {
throw new Error('useTaskContext must be used within a TaskProvider');
}
return context.tasks;
};
export const useTaskActions = () => {
const context = useContext(TaskContext);
if (!context) {
throw new Error('useTaskContext must be used within a TaskProvider');
}
const actions = useMemo(
() => ({
addTask: context.addTask,
updateTask: context.updateTask,
deleteTask: context.deleteTask,
}),
[context.addTask, context.updateTask, context.deleteTask],
);
return actions;
};
export { TaskContext, TaskProvider, useTaskState };

View File

@@ -1,60 +0,0 @@
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

@@ -1,40 +0,0 @@
import { DateTime } from './date-time';
import { useTaskActions } from './task-context';
type TaskProps = {
task: import('../types').Task;
};
export const Task = ({ task }: TaskProps) => {
const { updateTask, deleteTask } = useTaskActions();
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

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

View File

@@ -0,0 +1,62 @@
export type Task = {
id: string;
title: string;
completed: boolean;
createdAt: Date;
lastModified: Date;
};
export type TaskData = Partial<Omit<Task, 'id'>>;
export interface TaskContextProps {
tasks: Task[];
loading: boolean;
error: string | null;
addTask: (title: string) => void;
updateTask: (id: string, updatedTask: Partial<Omit<Task, 'id'>>) => void;
removeTask: (id: string) => void;
}
export type TaskState = {
tasks: Task[];
loading: boolean;
error: string | null;
};
export type TaskAction =
| SetTasksAction
| AddTaskAction
| UpdateTaskAction
| RemoveTaskAction
| SetLoadingAction
| SetErrorAction;
export type SetTasksAction = {
type: 'set-tasks';
payload: Task[];
};
export type AddTaskAction = {
type: 'add-task';
payload: Task;
};
export type UpdateTaskAction = {
type: 'update-task';
payload: TaskData & { id: string };
};
export type RemoveTaskAction = {
type: 'remove-task';
payload: string;
};
export type SetLoadingAction = {
type: 'set-loading';
payload?: never;
};
export type SetErrorAction = {
type: 'set-error';
payload: string;
};

View File

@@ -3,25 +3,26 @@
@tailwind utilities;
@layer base {
body {
@apply bg-slate-50 text-slate-900 dark:bg-slate-900 dark:text-slate-50;
}
label {
@apply block text-sm font-medium leading-6;
&[for]:has(~ input[required]) {
@apply after:content-['*'] after:text-red-600;
}
}
input[type='text'],
input[type='email'],
input[type='password'] {
@apply text-sm sm:text-base block w-full rounded-md border-0 py-1.5 px-2.5 text-slate-900 shadow-sm ring-1 ring-inset ring-primary-700/20 placeholder:text-slate-400 sm:text-sm sm:leading-6 placeholder:text-primary-600;
@apply focus:ring-2 focus:ring-primary-600 focus:outline-none;
@apply text-sm sm:text-base block w-full rounded-md border-0 py-1.5 px-2.5 shadow-sm ring-1 ring-inset ring-primary-600 placeholder:text-primary-600 sm:text-sm sm:leading-6 dark:bg-slate-800 dark:text-slate-50 dark:placeholder:text-slate-400;
@apply focus:ring-2 focus:outline-none;
&:has(+ button[type='submit']) {
@apply block w-full rounded-r-none;
}
}
input[type='checkbox'] {
@apply h-4 w-4 rounded border-primary-500 accent-primary-600;
@apply h-4 w-4 rounded border-primary-500 accent-primary-600 dark:bg-slate-800 dark:border-slate-700 dark:accent-primary-600;
}
button {
@@ -41,13 +42,17 @@
}
.button-primary {
@apply bg-primary-600 text-white cursor-pointer ring-primary-700 transition duration-100 ease-in-out hover:bg-primary-700 active:bg-primary-800 disabled:bg-primary-600/50 disabled:hover:bg-primary-600/50 disabled:active:bg-primary-600/50 disabled:cursor-not-allowed disabled:ring-primary-700/20;
@apply bg-primary-600 text-white cursor-pointer ring-primary-600 transition duration-100 ease-in-out hover:bg-primary-700 active:bg-primary-800 disabled:bg-primary-600/50 disabled:hover:bg-primary-600/50 disabled:active:bg-primary-600/50 disabled:cursor-not-allowed;
}
.button-small {
@apply py-1 px-1.5 text-xs;
}
.button-destructive {
@apply bg-red-600 text-white cursor-pointer ring-red-700 transition duration-100 ease-in-out hover:bg-red-700 active:bg-red-800 disabled:bg-red-600/50 disabled:hover:bg-red-600/50 disabled:active:bg-red-600/50 disabled:cursor-not-allowed disabled:ring-red-700/20;
@apply bg-red-600 text-white cursor-pointer ring-0 transition duration-100 ease-in-out hover:bg-red-700 active:bg-red-800 disabled:bg-red-600/50 disabled:hover:bg-red-600/50 disabled:active:bg-red-600/50 disabled:cursor-not-allowed disabled:ring-red-700/20;
&.button-ghost {
@apply bg-transparent text-red-600 ring-red-600 hover:bg-red-600/10;
@apply bg-transparent text-red-600 ring-red-600 hover:bg-red-600/10 active:bg-red-600/20 dark:hover:bg-red-400/30 dark:active:bg-red-400/40;
}
}
}

View File

@@ -1,7 +0,0 @@
export interface Task {
id: string;
title: string;
completed: boolean;
createdAt: Date;
lastModified: Date;
}

View File

@@ -2,13 +2,15 @@ import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { css } from 'css-configuration';
const PORT = process.env.PORT || 3000;
export default defineConfig({
plugins: [react()],
css,
server: {
proxy: {
'/api': {
target: 'http://localhost:3000',
target: `http://localhost:${PORT}`,
changeOrigin: true,
secure: false,
},