├── .eslintrc.js ├── .gitignore ├── .prettierrc ├── README.md ├── babel.config.js ├── index.html ├── jest.config.cjs ├── package.json ├── public ├── app-pic.png └── icon.png ├── src ├── App.tsx ├── components │ ├── TodoItem.tsx │ └── TodoList.tsx ├── hooks │ ├── useLocalStorage.ts │ └── useTodos.ts ├── main.tsx ├── mock │ └── mockTodos.ts ├── styled.d.ts ├── styles │ ├── global.ts │ └── theme.ts ├── tests │ └── useTodos.test.ts ├── types │ ├── styled.ts │ └── todo.ts └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.node.json ├── vite.config.ts └── yarn.lock /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { browser: true, es2020: true }, 3 | extends: [ 4 | "eslint:recommended", 5 | "plugin:@typescript-eslint/recommended", 6 | "plugin:react-hooks/recommended", 7 | ], 8 | parser: "@typescript-eslint/parser", 9 | parserOptions: { ecmaVersion: "latest", sourceType: "module" }, 10 | plugins: ["react-refresh"], 11 | rules: { 12 | "react-refresh/only-export-components": "warn", 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "proseWrap": "never", 3 | "quoteProps": "consistent" 4 | } 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Todo List 2 | 3 | Simple React Todo App 4 | 5 | 6 | 7 | ## Demo 8 | https://todo-vanila-react.vercel.app/ 9 | 10 | ## Installation 11 | 12 | ```bash 13 | npm install 14 | ``` 15 | 16 | or 17 | 18 | ```bash 19 | yarn install 20 | ``` 21 | 22 | ## Start 23 | 24 | ```bash 25 | npm run dev 26 | ``` 27 | or 28 | ```bash 29 | yarn dev 30 | ``` 31 | 32 | ## Functionality 33 | 1. Adding, editing, toggling, deleting todo's 34 | 2. Filtering and deleting completed todo's 35 | 3. Theme switching 36 | 37 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | targets: { 7 | node: "current", 8 | }, 9 | }, 10 | ], 11 | "@babel/preset-react", 12 | "@babel/preset-typescript", 13 | ], 14 | }; 15 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Todo List 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /jest.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: "ts-jest", 3 | testEnvironment: "jsdom", 4 | }; 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "todo-react-hooks", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 10 | "preview": "vite preview", 11 | "test": "jest --verbose false" 12 | }, 13 | "dependencies": { 14 | "react": "^18.2.0", 15 | "react-dom": "^18.2.0", 16 | "styled-components": "^6.0.0-rc.3" 17 | }, 18 | "devDependencies": { 19 | "@babel/preset-env": "^7.22.5", 20 | "@babel/preset-react": "^7.22.5", 21 | "@babel/preset-typescript": "^7.22.5", 22 | "@emotion/react": "^11.11.1", 23 | "@emotion/styled": "^11.11.0", 24 | "@mui/icons-material": "^5.11.16", 25 | "@mui/material": "^5.13.4", 26 | "@testing-library/jest-dom": "^5.16.5", 27 | "@testing-library/react": "^14.0.0", 28 | "@testing-library/react-hooks": "^8.0.1", 29 | "@testing-library/user-event": "^14.4.3", 30 | "@types/jest": "^29.5.2", 31 | "@types/react": "^18.0.37", 32 | "@types/react-dom": "^18.0.11", 33 | "@types/styled-components": "^5.1.26", 34 | "@typescript-eslint/eslint-plugin": "^5.59.0", 35 | "@typescript-eslint/parser": "^5.59.0", 36 | "@vitejs/plugin-react": "^4.0.0", 37 | "eslint": "^8.38.0", 38 | "eslint-plugin-eslint-plugin": "^5.1.0", 39 | "eslint-plugin-import": "^2.27.5", 40 | "eslint-plugin-react-hooks": "^4.6.0", 41 | "eslint-plugin-react-refresh": "^0.3.4", 42 | "identity-obj-proxy": "^3.0.0", 43 | "jest": "^29.5.0", 44 | "jest-environment-jsdom": "^29.5.0", 45 | "prettier": "^2.8.8", 46 | "ts-jest": "^29.1.0", 47 | "typescript": "^5.0.2", 48 | "vite": "^4.3.9" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /public/app-pic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/llamii/todo-react/40fb35fd991e89105c3415c202708c9d8e5595fb/public/app-pic.png -------------------------------------------------------------------------------- /public/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/llamii/todo-react/40fb35fd991e89105c3415c202708c9d8e5595fb/public/icon.png -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { DefaultTheme, ThemeProvider } from "styled-components"; 2 | import { darkTheme, lightTheme } from "./styles/theme"; 3 | 4 | import DarkModeIcon from "@mui/icons-material/DarkMode"; 5 | import { FC } from "react"; 6 | import GlobalStyles from "../src/styles/global"; 7 | import { TodoList } from "./components/TodoList"; 8 | import styled from "styled-components"; 9 | import useLocalStorage from "./hooks/useLocalStorage"; 10 | 11 | const App: FC = () => { 12 | const [theme, setTheme] = useLocalStorage("theme", lightTheme); 13 | 14 | const themeToggle = () => { 15 | const newTheme = theme === lightTheme ? darkTheme : lightTheme; 16 | setTheme(newTheme); 17 | }; 18 | 19 | return ( 20 | 21 |
📓 Todo List
22 | 23 | 29 | 30 | 31 | 32 | 33 |
34 | ); 35 | }; 36 | 37 | export const AppContainer = styled.div` 38 | display: flex; 39 | flex-direction: column; 40 | align-items: center; 41 | padding-top: 40px; 42 | `; 43 | 44 | export const Header = styled.h1` 45 | text-align: center; 46 | font-size: 48px; 47 | padding: 50px 0 50px 0; 48 | `; 49 | 50 | export const Footer = styled.h6` 51 | text-align: center; 52 | font-size: 14px; 53 | font-weight: 200; 54 | font-style: italic; 55 | opacity: 0.5; 56 | padding-top: 25px; 57 | padding-bottom: 25px; 58 | `; 59 | 60 | export const ThemeToggle = styled.button` 61 | background: none; 62 | border: none; 63 | font-size: 24px; 64 | color: ${(props) => props.theme.colors.text}; 65 | cursor: pointer; 66 | position: absolute; 67 | top: 20px; 68 | right: 20px; 69 | `; 70 | 71 | export default App; 72 | -------------------------------------------------------------------------------- /src/components/TodoItem.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Checkbox } from "@mui/material"; 2 | import { FC, useState } from "react"; 3 | 4 | import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline"; 5 | import ClearIcon from "@mui/icons-material/Clear"; 6 | import { ITodo } from "../types/todo"; 7 | import RadioButtonUncheckedOutlinedIcon from "@mui/icons-material/RadioButtonUncheckedOutlined"; 8 | import { styled } from "styled-components"; 9 | 10 | interface ITodoItemProps extends ITodo { 11 | toggleTodo: (id: number) => void; 12 | editTodo: (id: number, name: string) => void; 13 | removeTodo: (id: number) => void; 14 | } 15 | 16 | export const TodoItem: FC = (props) => { 17 | const { id, name, completed, toggleTodo, editTodo, removeTodo } = props; 18 | 19 | const [isEditing, setIsEditing] = useState(false); 20 | const [editedName, setEditedName] = useState(name); 21 | 22 | const handleNameChange = (event: React.ChangeEvent) => { 23 | setEditedName(event.target.value); 24 | }; 25 | 26 | const handleNameBlur = () => { 27 | if (editedName.trim() !== "") { 28 | editTodo(id, editedName); 29 | } 30 | setIsEditing(false); 31 | }; 32 | 33 | const handleKeyDown = (event: React.KeyboardEvent) => { 34 | if (event.key === "Enter" || event.key === "Escape") { 35 | handleNameBlur(); 36 | } 37 | }; 38 | 39 | return ( 40 | 41 | } 45 | checkedIcon={} 46 | onChange={() => toggleTodo(id)} 47 | /> 48 | {isEditing ? ( 49 | 57 | ) : ( 58 | setIsEditing(true)} 61 | > 62 | {name} 63 | 64 | )} 65 | 66 | removeTodo(id)} 70 | > 71 | 72 | 73 | 74 | ); 75 | }; 76 | 77 | const UncheckedIcon = styled(RadioButtonUncheckedOutlinedIcon)` 78 | color: ${(props) => props.theme.colors.primary}; 79 | `; 80 | 81 | const CheckedIcon = styled(CheckCircleOutlineIcon)` 82 | color: ${(props) => props.theme.colors.success}; 83 | `; 84 | 85 | const DeleteIcon = styled(ClearIcon)` 86 | color: ${(props) => props.theme.colors.danger}; 87 | `; 88 | 89 | const Input = styled.input` 90 | width: 100%; 91 | border: none; 92 | font-size: 16px; 93 | background-color: ${(props) => props.theme.colors.secondary}; 94 | `; 95 | 96 | const DeleteButton = styled(Button)` 97 | && { 98 | background-color: transparent; 99 | color: ${(props) => props.theme.colors.danger}; 100 | padding: 5px; 101 | margin: 0; 102 | display: none; 103 | &:hover { 104 | background-color: "transparent"; 105 | } 106 | } 107 | `; 108 | 109 | const TodoItemWrapper = styled.div` 110 | height: 48px; 111 | display: flex; 112 | align-items: center; 113 | justify-content: center; 114 | margin-bottom: 8px; 115 | padding-bottom: 8px; 116 | border-bottom: 1px solid ${(props) => props.theme.colors.primary}; 117 | 118 | &:hover { 119 | ${DeleteButton} { 120 | display: flex; 121 | } 122 | } 123 | `; 124 | 125 | const TodoText = styled.span<{ $isChecked: boolean }>` 126 | flex-grow: 1; 127 | margin-right: 8px; 128 | 129 | text-decoration: ${(props) => (props.$isChecked ? `line-through` : "none")}; 130 | `; 131 | -------------------------------------------------------------------------------- /src/components/TodoList.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { FC, useEffect, useMemo, useRef, useState } from "react"; 4 | import { Filter, ITodo, absurd } from "../types/todo"; 5 | 6 | import { Container } from "@mui/material"; 7 | import { TodoItem } from "./TodoItem"; 8 | import { mockTodos } from "../mock/mockTodos"; 9 | import { styled } from "styled-components"; 10 | import { useTodos } from "../hooks/useTodos"; 11 | 12 | export const TodoList: FC = () => { 13 | const inputRef = useRef(null); 14 | 15 | const [value, setValue] = useState(""); 16 | const [filter, setFilter] = useState(Filter.all); 17 | 18 | const { 19 | todos, 20 | addTodo, 21 | toggleTodo, 22 | editTodo, 23 | removeTodo, 24 | removeCompleted, 25 | getItemsLeft, 26 | } = useTodos(mockTodos); 27 | 28 | const itemsLeft = getItemsLeft(); 29 | 30 | const handleChange: React.ChangeEventHandler = (event) => { 31 | setValue(event.target.value); 32 | }; 33 | 34 | const handleKeyDown: React.KeyboardEventHandler = ( 35 | event 36 | ) => { 37 | if (event.key === "Enter" && value.trim() !== "") { 38 | addTodo(value); 39 | setValue(""); 40 | } 41 | }; 42 | 43 | const handleChangeFilter = (filter: Filter) => { 44 | setFilter(filter); 45 | }; 46 | 47 | const filteredTodos = useMemo( 48 | () => 49 | todos.filter((todo) => { 50 | switch (filter) { 51 | case Filter.active: 52 | return !todo.completed; 53 | case Filter.completed: 54 | return todo.completed; 55 | case Filter.all: 56 | return true; 57 | default: 58 | return absurd(filter); 59 | } 60 | }), 61 | [todos, filter] 62 | ); 63 | 64 | const filterButtons = Object.values(Filter).map((f) => ( 65 | handleChangeFilter(f)} 68 | $isActive={f === filter} 69 | > 70 | {f} 71 | 72 | )); 73 | 74 | useEffect(() => { 75 | inputRef.current?.focus(); 76 | }, []); 77 | 78 | return ( 79 | 80 | 87 | 88 | {filteredTodos && 89 | filteredTodos.map((todo: ITodo) => ( 90 | 99 | ))} 100 | 101 | 102 | {itemsLeft} item(s) left 103 | {filterButtons} 104 | 109 | Clear Completed 110 | 111 | 112 | 113 | ); 114 | }; 115 | 116 | const TodoListContainer = styled(Container)` 117 | margin: 0 auto; 118 | padding: 20px; 119 | border-radius: 4px; 120 | box-shadow: 0 0 10px rgba(0, 0, 0, 0.3); 121 | background-color: ${(props) => props.theme.colors.secondary}; 122 | min-height: 432px; 123 | display: flex; 124 | flex-direction: column; 125 | height: 100%; 126 | `; 127 | 128 | const Input = styled.input` 129 | width: 100%; 130 | padding: 10px; 131 | border: none; 132 | margin-bottom: 10px; 133 | height: 48px; 134 | border-bottom: 1px solid ${(props) => props.theme.colors.primary}; 135 | background-color: ${(props) => props.theme.colors.secondary}; 136 | `; 137 | 138 | const FilterButton = styled.button<{ $isActive: boolean }>` 139 | background-color: transparent; 140 | border: none; 141 | padding: 10px 15px; 142 | cursor: pointer; 143 | margin-right: 10px; 144 | font-size: 12px; 145 | 146 | border: ${(props) => 147 | props.$isActive ? `1px solid ${props.theme.colors.primary}` : "none"}; 148 | border-radius: 10px; 149 | 150 | &:last-child { 151 | margin-right: 0; 152 | } 153 | `; 154 | 155 | const FilterButtons = styled.div` 156 | display: flex; 157 | `; 158 | 159 | const TodoFooter = styled.div` 160 | display: flex; 161 | flex-direction: row; 162 | align-items: center; 163 | justify-content: space-between; 164 | 165 | justify-self: end; 166 | margin-top: auto; 167 | `; 168 | 169 | const ItemsLeft = styled.span` 170 | font-size: 12px; 171 | `; 172 | 173 | const ClearButton = styled(FilterButton)<{ $isVisible: boolean }>` 174 | font-size: 12px; 175 | background-color: transparent; 176 | visibility: ${(props) => (props.$isVisible ? `visible` : "hidden")}; 177 | `; 178 | -------------------------------------------------------------------------------- /src/hooks/useLocalStorage.ts: -------------------------------------------------------------------------------- 1 | import { Dispatch, SetStateAction, useState } from "react"; 2 | 3 | function useLocalStorage( 4 | key: string, 5 | initialValue: T 6 | ): [T, Dispatch>] { 7 | const [storedValue, setStoredValue] = useState(() => { 8 | try { 9 | const item = window.localStorage.getItem(key); 10 | return item ? JSON.parse(item) : initialValue; 11 | } catch (error) { 12 | console.log(error); 13 | return initialValue; 14 | } 15 | }); 16 | 17 | function setValue(value: SetStateAction): void { 18 | try { 19 | const valueToStore = 20 | value instanceof Function ? value(storedValue) : value; 21 | setStoredValue(valueToStore); 22 | window.localStorage.setItem(key, JSON.stringify(valueToStore)); 23 | } catch (error) { 24 | console.log(error); 25 | } 26 | } 27 | 28 | return [storedValue, setValue]; 29 | } 30 | 31 | export default useLocalStorage; 32 | -------------------------------------------------------------------------------- /src/hooks/useTodos.ts: -------------------------------------------------------------------------------- 1 | import { ITodo } from "../types/todo"; 2 | import useLocalStorage from "./useLocalStorage"; 3 | 4 | export function useTodos(initialTodos: ITodo[]) { 5 | const [todos, setTodos] = useLocalStorage("todos", initialTodos); 6 | 7 | const addTodo = (name: string): void => { 8 | const newTodo = { 9 | id: Date.now(), 10 | name, 11 | completed: false, 12 | }; 13 | setTodos((prevState) => [...prevState, newTodo]); 14 | }; 15 | 16 | const toggleTodo = (id: number): void => { 17 | setTodos((prevState) => 18 | prevState.map((todo) => { 19 | if (todo.id === id) { 20 | return { 21 | ...todo, 22 | completed: !todo.completed, 23 | }; 24 | } 25 | return todo; 26 | }) 27 | ); 28 | }; 29 | 30 | const editTodo = (id: number, name: string): void => { 31 | setTodos((prevState) => { 32 | return prevState.map((todo) => { 33 | if (todo.id === id) { 34 | return { 35 | ...todo, 36 | name, 37 | }; 38 | } 39 | return todo; 40 | }); 41 | }); 42 | }; 43 | 44 | const removeTodo = (id: number): void => { 45 | setTodos((prevState) => prevState.filter((todo) => todo.id !== id)); 46 | }; 47 | 48 | const getItemsLeft = () => { 49 | return todos.reduce((counter, todo) => { 50 | if (!todo.completed) { 51 | return counter + 1; 52 | } 53 | return counter; 54 | }, 0); 55 | }; 56 | 57 | const removeCompleted = (): void => { 58 | setTodos((prevState) => prevState.filter((todo) => !todo.completed)); 59 | }; 60 | 61 | return { 62 | todos, 63 | addTodo, 64 | toggleTodo, 65 | editTodo, 66 | removeTodo, 67 | getItemsLeft, 68 | removeCompleted, 69 | }; 70 | } 71 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import App from "./App.tsx"; 2 | import ReactDOM from "react-dom/client"; 3 | 4 | ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( 5 | 6 | ); 7 | -------------------------------------------------------------------------------- /src/mock/mockTodos.ts: -------------------------------------------------------------------------------- 1 | import { ITodo } from '../types/todo'; 2 | 3 | export const mockTodos: ITodo[] = [ 4 | { 5 | id: 1, 6 | name: '🏪 To buy products ', 7 | completed: false, 8 | }, 9 | { 10 | id: 2, 11 | name: '🧽 To clean the house', 12 | completed: false, 13 | }, 14 | { 15 | id: 3, 16 | name: '🌼 To water flowers', 17 | completed: true, 18 | }, 19 | { 20 | id: 4, 21 | name: '🐕 To feed the dog', 22 | completed: false, 23 | }, 24 | { 25 | id: 5, 26 | name: '⚛️ To code a react app', 27 | completed: true, 28 | }, 29 | ]; 30 | -------------------------------------------------------------------------------- /src/styled.d.ts: -------------------------------------------------------------------------------- 1 | import 'styled-components'; 2 | import { ITheme, ThemeEnum } from 'interfaces/styled'; 3 | 4 | declare module 'styled-components' { 5 | export interface DefaultTheme extends ITheme { 6 | type: ThemeEnum; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/styles/global.ts: -------------------------------------------------------------------------------- 1 | import { createGlobalStyle } from "styled-components"; 2 | 3 | export default createGlobalStyle` 4 | * { 5 | box-sizing: border-box; 6 | margin: 0; 7 | padding: 0; 8 | } 9 | 10 | *::before, 11 | *::after { 12 | box-sizing: border-box; 13 | } 14 | 15 | body { 16 | font-family: Arial, sans-serif; 17 | font-size: 16px; 18 | line-height: 1.4; 19 | 20 | background-color: ${(props) => props.theme.colors.bg}; 21 | color: ${(props) => props.theme.colors.font}; 22 | 23 | h1, h2, h3, h4, h5, h6, span, button, input { 24 | color: ${(props) => props.theme.colors.font} 25 | } 26 | 27 | a { 28 | cursor: pointer; 29 | color: ${(props) => props.theme.colors.font}; 30 | text-decoration: none; 31 | } 32 | 33 | } 34 | 35 | *:focus { 36 | outline: none; 37 | } 38 | `; 39 | -------------------------------------------------------------------------------- /src/styles/theme.ts: -------------------------------------------------------------------------------- 1 | import { ITheme, ThemeEnum } from "../types/styled"; 2 | 3 | import { DefaultTheme } from "styled-components"; 4 | 5 | export const baseTheme: ITheme = { 6 | colors: { 7 | primary: "#7986cb", 8 | secondary: "#2b2b2b", 9 | success: "#4caf50", 10 | danger: "#f44336", 11 | 12 | bg: "#E5E4E8", 13 | font: "#19191B", 14 | }, 15 | 16 | media: { 17 | extraLarge: "(max-width: 1140px)", 18 | large: "(max-width: 960px)", 19 | medium: "(max-width: 720px)", 20 | small: "(max-width: 540px)", 21 | }, 22 | }; 23 | 24 | export const lightTheme: DefaultTheme = { 25 | ...baseTheme, 26 | type: ThemeEnum.light, 27 | 28 | colors: { 29 | ...baseTheme.colors, 30 | bg: "#E5E4E8", 31 | font: "#19191B", 32 | primary: "lightgray", 33 | secondary: "#f8f8f8", 34 | }, 35 | }; 36 | 37 | export const darkTheme: DefaultTheme = { 38 | ...baseTheme, 39 | type: ThemeEnum.dark, 40 | 41 | colors: { 42 | ...baseTheme.colors, 43 | bg: "#19191B", 44 | font: "#E5E4E8", 45 | primary: "#3b3b3b", 46 | secondary: "#262629", 47 | }, 48 | }; 49 | -------------------------------------------------------------------------------- /src/tests/useTodos.test.ts: -------------------------------------------------------------------------------- 1 | import { act, cleanup, renderHook } from "@testing-library/react-hooks"; 2 | 3 | import { useTodos } from "../hooks/useTodos"; 4 | 5 | afterEach(() => { 6 | cleanup(); 7 | window.localStorage.clear(); 8 | }); 9 | 10 | describe("useTodos tests", () => { 11 | it("should initialize with an empty todo list", () => { 12 | const { result } = renderHook(() => useTodos([])); 13 | 14 | expect(result.current.todos).toEqual([]); 15 | }); 16 | 17 | it("should add a new todo", () => { 18 | const { result } = renderHook(() => useTodos([])); 19 | 20 | act(() => { 21 | result.current.addTodo("test"); 22 | }); 23 | console.log(result.current.todos); 24 | expect(result.current.todos).toEqual([ 25 | { 26 | id: expect.any(Number), 27 | name: "test", 28 | completed: false, 29 | }, 30 | ]); 31 | }); 32 | 33 | it("should toggle a todo", () => { 34 | const initialTodos = [ 35 | { id: 0, name: "todo 1", completed: false }, 36 | { id: 1, name: "todo 2", completed: false }, 37 | ]; 38 | const { result } = renderHook(() => useTodos(initialTodos)); 39 | act(() => { 40 | result.current.toggleTodo(0); 41 | }); 42 | 43 | expect(result.current.todos).toEqual([ 44 | { id: 0, name: "todo 1", completed: true }, 45 | { id: 1, name: "todo 2", completed: false }, 46 | ]); 47 | }); 48 | 49 | test("should edit a todo", () => { 50 | const initialTodos = [ 51 | { id: 0, name: "todo 1", completed: false }, 52 | { id: 1, name: "todo 2", completed: false }, 53 | ]; 54 | const { result } = renderHook(() => useTodos(initialTodos)); 55 | 56 | act(() => { 57 | result.current.editTodo(1, "updated todo 2"); 58 | }); 59 | 60 | expect(result.current.todos).toEqual([ 61 | { id: 0, name: "todo 1", completed: false }, 62 | { id: 1, name: "updated todo 2", completed: false }, 63 | ]); 64 | }); 65 | 66 | test("should remove a todo", () => { 67 | const initialTodos = [ 68 | { id: 0, name: "todo 1", completed: false }, 69 | { id: 1, name: "todo 2", completed: false }, 70 | ]; 71 | const { result } = renderHook(() => useTodos(initialTodos)); 72 | 73 | act(() => { 74 | result.current.removeTodo(1); 75 | }); 76 | 77 | expect(result.current.todos).toEqual([ 78 | { id: 0, name: "todo 1", completed: false }, 79 | ]); 80 | }); 81 | 82 | test("should return the correct number of items left", () => { 83 | const initialTodos = [ 84 | { id: 0, name: "todo 1", completed: false }, 85 | { id: 1, name: "todo 2", completed: false }, 86 | ]; 87 | const { result } = renderHook(() => useTodos(initialTodos)); 88 | 89 | expect(result.current.getItemsLeft()).toBe(2); 90 | }); 91 | 92 | test("should remove completed todos", () => { 93 | const initialTodos = [ 94 | { id: 0, name: "todo 1", completed: false }, 95 | { id: 1, name: "todo 2", completed: true }, 96 | ]; 97 | const { result } = renderHook(() => useTodos(initialTodos)); 98 | 99 | act(() => { 100 | result.current.removeCompleted(); 101 | }); 102 | 103 | expect(result.current.todos).toEqual([ 104 | { id: 0, name: "todo 1", completed: false }, 105 | ]); 106 | }); 107 | }); 108 | -------------------------------------------------------------------------------- /src/types/styled.ts: -------------------------------------------------------------------------------- 1 | export enum ThemeEnum { 2 | light = "light", 3 | dark = "dark", 4 | } 5 | 6 | export interface ITheme { 7 | colors: { 8 | primary: string; 9 | secondary: string; 10 | success: string; 11 | danger: string; 12 | 13 | bg: string; 14 | font: string; 15 | }; 16 | 17 | media: { 18 | extraLarge: string; 19 | large: string; 20 | medium: string; 21 | small: string; 22 | }; 23 | } 24 | -------------------------------------------------------------------------------- /src/types/todo.ts: -------------------------------------------------------------------------------- 1 | export interface ITodo { 2 | id: number; 3 | name: string; 4 | completed: boolean; 5 | } 6 | 7 | export enum Filter { 8 | all = "all", 9 | active = "active", 10 | completed = "completed", 11 | } 12 | 13 | export const absurd = (_value: never): any => {}; 14 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["src"], 24 | "references": [{ "path": "./tsconfig.node.json" }] 25 | } 26 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | }) 8 | --------------------------------------------------------------------------------