diff --git a/src/App.tsx b/src/App.tsx index 6431508..589cf77 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,6 @@ import { useCallback, useMemo, useState, useRef } from 'react' import './App.scss' -import { styled, Alert } from "@mui/material"; +import { styled } from "@mui/material"; import { AddTodo } from "./components/addTodo/AddTodo.tsx"; import { TodoList } from "./components/todoList/TodoList.tsx"; import { useQuery } from "@tanstack/react-query"; @@ -9,6 +9,7 @@ import { useTodosMutation } from "./api/todos/useTodosMutation.ts"; import { ActionBar, FilterType } from "./components/actionBar/ActionBar.tsx"; import { colors } from "./styles/colors.ts"; import { Todo } from "./api/todos/Todo.ts"; +import { ApiErrorAlert } from "./components/ApiErrorAlert.tsx"; export const Root = styled('div')` background-color: ${({ theme }) => theme.palette.background.default}; @@ -39,13 +40,18 @@ const filterMap: Record boolean> = { function App() { - const { data: todos, isPending, error } = useQuery({ queryKey: ['todos'], queryFn: () => fetchTodos() }); + const { data: todos, isPending, error, refetch } = useQuery({ queryKey: ['todos'], queryFn: () => fetchTodos() }); - const { addTodo, deleteTodo, updateChecked, updateText, clearCompleted } = useTodosMutation(); + const { mutations, addTodo, deleteTodo, updateChecked, updateText, clearCompleted } = useTodosMutation(); const inputRef = useRef(null); + const [editId, setEditId] = useState(); const [editText, setEditText] = useState(""); + const resetEdit = () => { + setEditId(undefined); + setEditText(""); + } const [filter, setFilter] = useState('none'); @@ -82,18 +88,12 @@ function App() { const handleSave = (id: number, text: string) => { updateText({ id, text }); - setEditId(undefined); - setEditText(""); - }; - - const handleTextClear = () => { - setEditId(undefined); - setEditText(""); + resetEdit(); }; return ( - {error && {error.message}} + todos diff --git a/src/api/todos/fetchTodos.ts b/src/api/todos/fetchTodos.ts index 6336864..dd2afee 100644 --- a/src/api/todos/fetchTodos.ts +++ b/src/api/todos/fetchTodos.ts @@ -5,12 +5,13 @@ export const TODOS_URL = "api/todos"; interface FetchApiProps { url?: string method?: 'GET' | 'POST' | 'PUT' | 'DELETE', - body?: Body + body?: Body, + errorMessage?: string, } const fetchTodosApi = async >(props?: FetchApiProps): Promise => { - const { url = '', method = 'GET', body } = props || {}; + const { url = '', method = 'GET', body, errorMessage } = props || {}; const response = await fetch(TODOS_URL + url, { headers: { @@ -21,14 +22,18 @@ const fetchTodosApi = async >(props?: F body: body ? JSON.stringify(body) : undefined }); + const textResp = await response.text(); + const responseBody = textResp ? JSON.parse(textResp) : undefined; if (!response.ok) { - throw new Error(`Could not fetch todos`); + throw new Error(responseBody?.message || errorMessage || `ServerError`); } - - return await response.json(); + return responseBody; } -export const fetchTodos = () => fetchTodosApi(); +export const fetchTodos = () => + fetchTodosApi({ + errorMessage: 'Could not fetch todos' + }); export const addTodo = (newTodo: Partial) => fetchTodosApi({ @@ -40,24 +45,28 @@ export const deleteTodo = (id: number) => fetchTodosApi({ url: `/${id}`, method: 'DELETE', + errorMessage: 'Delete failed' }); export const updateTodoText = ({ id, text }: UpdateTodoTextRequest) => fetchTodosApi({ url: `/${id}/text`, method: 'PUT', - body: { text } + body: { text }, + errorMessage: `Todo '${text}' update failed` }); export const updateTodoCheck = ({ id, checked }: UpdateTodoCheckedRequest) => fetchTodosApi({ url: `/${id}/checked`, method: 'PUT', - body: { checked } + body: { checked }, + errorMessage: `Todo update failed` }); export const clearCompleted = () => fetchTodosApi({ url: '/clear-completed', - method: 'PUT' + method: 'PUT', + errorMessage: `Clearing all todos completion failed` }); diff --git a/src/api/todos/useTodosMutation.ts b/src/api/todos/useTodosMutation.ts index 1dc9ff9..1f4cd71 100644 --- a/src/api/todos/useTodosMutation.ts +++ b/src/api/todos/useTodosMutation.ts @@ -12,6 +12,7 @@ export const useTodosMutation = () => { const addTodoMutation = useMutation({ mutationFn: addTodo, onMutate: async (newTodo) => { + // Cancel any outgoing re-fetches (so they don't overwrite our optimistic update) await queryClient.cancelQueries({ queryKey: ['todos'] }) const previousTodos = queryClient.getQueryData(['todos']) as Todo[]; queryClient.setQueryData(['todos'], (old: Todo[]) => [...old, newTodo]); @@ -24,9 +25,8 @@ export const useTodosMutation = () => { onError: (_err, _newTodo, context) => { context && queryClient.setQueryData(['todos'], context.previousTodos) }, - onSettled: () => { // Always re-fetch after error or success: - queryClient.invalidateQueries({ queryKey: ['todos'] }) - }, + // Always re-fetch after error or success: + onSettled: () => queryClient.invalidateQueries({ queryKey: ['todos'] }), }) const deleteTodoMutation = useMutation({ @@ -35,9 +35,7 @@ export const useTodosMutation = () => { await queryClient.cancelQueries({ queryKey: ['todos', id] }); queryClient.setQueryData(['todos'], (old: Todo[]) => old.filter(todo => todo.id !== id)); }, - onSettled: () => { - queryClient.invalidateQueries({ queryKey: ['todos'] }); - }, + onSettled: () => queryClient.invalidateQueries({ queryKey: ['todos'] }), }); const updateTextMutation = useMutation({ @@ -46,21 +44,16 @@ export const useTodosMutation = () => { await queryClient.cancelQueries({ queryKey: ['todos', id] }); setTodo({ ...getTodo(id), text: text }); }, - onSettled: () => { - queryClient.invalidateQueries({ queryKey: ['todos'] }); - }, + onSettled: () => queryClient.invalidateQueries({ queryKey: ['todos'] }), }); const updateCheckedMutation = useMutation({ mutationFn: updateTodoCheck, onMutate: async ({ id, checked }) => { - // Cancel any outgoing re-fetches (so they don't overwrite our optimistic update) await queryClient.cancelQueries({ queryKey: ['todos', id] }); setTodo({ ...getTodo(id), checked: checked }); }, - onSettled: () => { - queryClient.invalidateQueries({ queryKey: ['todos'] }); - }, + onSettled: () => queryClient.invalidateQueries({ queryKey: ['todos'] }), }); const clearCompletedMutate = useMutation({ @@ -72,11 +65,11 @@ export const useTodosMutation = () => { (old) => old?.map((todo: Todo) => ({ ...todo, checked: false })) ); }, - onSettled: () => { - queryClient.invalidateQueries({ queryKey: ['todos'] }); - }, + onSettled: () => queryClient.invalidateQueries({ queryKey: ['todos'] }), + }); - }) + addTodoMutation.error + addTodoMutation.reset return { addTodo: addTodoMutation.mutate, @@ -84,5 +77,6 @@ export const useTodosMutation = () => { updateChecked: updateCheckedMutation.mutate, updateText: updateTextMutation.mutate, clearCompleted: clearCompletedMutate.mutate, + mutations: [addTodoMutation, deleteTodoMutation, updateCheckedMutation, updateTextMutation, clearCompletedMutate] }; } diff --git a/src/components/ApiErrorAlert.tsx b/src/components/ApiErrorAlert.tsx new file mode 100644 index 0000000..a53e490 --- /dev/null +++ b/src/components/ApiErrorAlert.tsx @@ -0,0 +1,21 @@ +import { Alert } from "@mui/material"; + +interface MutationError { + error: Error | null; + reset?: () => void +} + +export interface ApiErrorAlertProps { + mutations: MutationError[] +} + +export const ApiErrorAlert = ({ mutations }: ApiErrorAlertProps) => { + + return mutations + .filter(mutation => mutation.error) + .map((mutation) => ( + mutation.reset?.()}> + {mutation.error?.message} + + )); +} diff --git a/src/components/addTodo/AddTodo.tsx b/src/components/addTodo/AddTodo.tsx index cdd2389..d0ca50c 100644 --- a/src/components/addTodo/AddTodo.tsx +++ b/src/components/addTodo/AddTodo.tsx @@ -41,8 +41,8 @@ export const AddTodo = forwardRef((props: AddTod size='small' sx={{ flex: 1, mr: 0 }} InputProps={{ - sx: {pr: 0}, - endAdornment: onTextClear && + sx: { pr: 0 }, + endAdornment: onTextClear && text.length > 0 && @@ -53,6 +53,7 @@ export const AddTodo = forwardRef((props: AddTod type="submit" sx={{ marginLeft: '5px', width: '20%' }} variant='contained' + disabled={text.length === 0} > {id ? 'Save' : 'Add Todo'}