Refactor task list example application
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
62
examples/task-list/src/actions.ts
Normal file
62
examples/task-list/src/actions.ts
Normal 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],
|
||||
);
|
||||
};
|
||||
50
examples/task-list/src/api.ts
Normal file
50
examples/task-list/src/api.ts
Normal 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');
|
||||
}
|
||||
};
|
||||
@@ -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 = () => {
|
||||
@@ -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"
|
||||
26
examples/task-list/src/components/date-time.tsx
Normal file
26
examples/task-list/src/components/date-time.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
45
examples/task-list/src/components/task.tsx
Normal file
45
examples/task-list/src/components/task.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
14
examples/task-list/src/components/tasks.tsx
Normal file
14
examples/task-list/src/components/tasks.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
125
examples/task-list/src/contexts/task-context.tsx
Normal file
125
examples/task-list/src/contexts/task-context.tsx
Normal 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 };
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
45
examples/task-list/src/reducer.ts
Normal file
45
examples/task-list/src/reducer.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
62
examples/task-list/src/types.ts
Normal file
62
examples/task-list/src/types.ts
Normal 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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
export interface Task {
|
||||
id: string;
|
||||
title: string;
|
||||
completed: boolean;
|
||||
createdAt: Date;
|
||||
lastModified: Date;
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
42
package-lock.json
generated
42
package-lock.json
generated
@@ -17,7 +17,8 @@
|
||||
"examples/basic-math",
|
||||
"examples/characters",
|
||||
"examples/accident-counter",
|
||||
"packages/css-configuration"
|
||||
"packages/css-configuration",
|
||||
"packages/utilities"
|
||||
],
|
||||
"devDependencies": {
|
||||
"prettier": "^3.3.3"
|
||||
@@ -111,7 +112,8 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"body-parser": "^1.20.3",
|
||||
"express": "^4.21.0"
|
||||
"express": "^4.21.0",
|
||||
"lucide-react": "^0.441.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/body-parser": "^1.19.5",
|
||||
@@ -122,6 +124,7 @@
|
||||
"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"
|
||||
@@ -4025,7 +4028,6 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jsdom": {
|
||||
@@ -4125,7 +4127,6 @@
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||
@@ -4151,6 +4152,15 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "0.441.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.441.0.tgz",
|
||||
"integrity": "sha512-0vfExYtvSDhkC2lqg0zYVW1Uu9GsI4knuV9GP9by5z0Xhc4Zi5RejTxfz9LsjRmCyWVzHCJvxGKZWcRyvQCWVg==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/lz-string": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||
@@ -4904,7 +4914,6 @@
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
@@ -5617,6 +5626,17 @@
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/tailwind-merge": {
|
||||
"version": "2.5.2",
|
||||
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.5.2.tgz",
|
||||
"integrity": "sha512-kjEBm+pvD+6eAwzJL2Bi+02/9LFLal1Gs61+QB7HvTfQQ0aXwC5LGT8PEt1gS0CWKktKe6ysPTAy3cBC5MeiIg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/dcastil"
|
||||
}
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "3.4.12",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.12.tgz",
|
||||
@@ -5979,6 +5999,10 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/utility-belt": {
|
||||
"resolved": "packages/utilities",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/utils-merge": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||
@@ -6514,6 +6538,14 @@
|
||||
"typescript": "^5.6.2",
|
||||
"vite": "^5.4.6"
|
||||
}
|
||||
},
|
||||
"packages/utilities": {
|
||||
"name": "utility-belt",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.6.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,8 @@
|
||||
"examples/basic-math",
|
||||
"examples/characters",
|
||||
"examples/accident-counter",
|
||||
"packages/css-configuration"
|
||||
"packages/css-configuration",
|
||||
"packages/utilities"
|
||||
],
|
||||
"devDependencies": {
|
||||
"prettier": "^3.3.3"
|
||||
|
||||
3
packages/utilities/index.d.ts
vendored
Normal file
3
packages/utilities/index.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
export type RequireOnly<T, Keys extends keyof T = keyof T> = Partial<T> & Required<Pick<T, Keys>>;
|
||||
export declare const isError: (error: unknown) => error is Error;
|
||||
export declare const getErrorMessage: (error: unknown, defaultMessage: string) => string;
|
||||
16
packages/utilities/index.js
Normal file
16
packages/utilities/index.js
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
*
|
||||
* @param {unknown} error The error object to check.
|
||||
* @returns {error is Error}
|
||||
*/
|
||||
export const isError = (error) => error instanceof Error;
|
||||
|
||||
/**
|
||||
* Get the error message from an error object or return a default message.
|
||||
* @param {unknown} error The error object to check.
|
||||
* @param {string} defaultMessage The default message to return if the error is not an instance of Error.
|
||||
* @returns {string} The error message or the default message.
|
||||
*/
|
||||
export const getErrorMessage = (error, defaultMessage) => {
|
||||
return isError(error) ? error.message : defaultMessage;
|
||||
};
|
||||
23
packages/utilities/package.json
Normal file
23
packages/utilities/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "utility-belt",
|
||||
"version": "1.0.0",
|
||||
"description": "Some little utilities",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"types": "tsc --emitDeclarationOnly --allowJs --declaration --skipLibCheck index.js"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/stevekinney/testing-javascript.git"
|
||||
},
|
||||
"author": "Steve Kinney <hello@stevekinney.net>",
|
||||
"license": "MIT",
|
||||
"bugs": {
|
||||
"url": "https://github.com/stevekinney/testing-javascript/issues"
|
||||
},
|
||||
"homepage": "https://github.com/stevekinney/testing-javascript#readme",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.6.2"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user