├── .github
└── workflows
│ └── deploy.yml
├── .gitignore
├── .idea
├── .gitignore
├── aws.xml
├── codeStyles
│ └── codeStyleConfig.xml
├── jsLibraryMappings.xml
├── misc.xml
├── modules.xml
└── todos.iml
├── .storybook
├── main.js
└── preview.js
├── README.md
├── package.json
├── public
├── favicon.ico
├── index.html
├── logo192.png
├── logo512.png
├── manifest.json
└── robots.txt
├── src
├── App
│ ├── App.stories.tsx
│ ├── App.test.tsx
│ ├── App.tsx
│ └── useTodos.ts
├── components
│ ├── AddTodo
│ │ ├── AddTodo.stories.tsx
│ │ ├── AddTodo.tsx
│ │ └── index.ts
│ ├── TodoItem
│ │ ├── CompleteTodoButton.stories.tsx
│ │ ├── CompleteTodoButton.tsx
│ │ ├── RemoveTodoButton.stories.tsx
│ │ ├── RemoveTodoButton.tsx
│ │ ├── TodoItem.stories.tsx
│ │ ├── TodoItem.tsx
│ │ └── index.ts
│ ├── TodoList
│ │ ├── NoTodos.stories.tsx
│ │ ├── NoTodos.tsx
│ │ ├── TodoList.stories.tsx
│ │ ├── TodoList.tsx
│ │ └── index.ts
│ └── shared
│ │ ├── Button.stories.tsx
│ │ ├── Button.tsx
│ │ ├── index.ts
│ │ └── screenReaderOnlyStyle.ts
├── index.tsx
├── locale
│ └── index.ts
├── model
│ ├── Todo.test.ts
│ ├── Todo.ts
│ ├── TodoMock.ts
│ ├── actions.test.ts
│ ├── actions.ts
│ ├── reducer.test.ts
│ └── reducer.ts
├── react-app-env.d.ts
└── setupTests.ts
├── tsconfig.json
└── yarn.lock
/.github/workflows/deploy.yml:
--------------------------------------------------------------------------------
1 | name: Deploy
2 | on:
3 | push:
4 | branches:
5 | - main
6 | jobs:
7 | deploy:
8 | runs-on: ubuntu-latest
9 | steps:
10 | - uses: actions/checkout@v2
11 | - name: Use Node.js
12 | uses: actions/setup-node@v1
13 | with:
14 | node-version: '14'
15 | - name: Install Packages
16 | run: npm install
17 | - name: Test
18 | run: npm test
19 | - name: Build page
20 | run: npm run build
21 | - name: Deploy to gh-pages
22 | uses: peaceiris/actions-gh-pages@v3
23 | with:
24 | github_token: ${{ secrets.GITHUB_TOKEN }}
25 | publish_dir: ./build
26 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 |
25 | # ides
26 | .idea
27 |
28 | # cache
29 | .eslintcache
30 |
--------------------------------------------------------------------------------
/.idea/.gitignore:
--------------------------------------------------------------------------------
1 | # Default ignored files
2 | /shelf/
3 | /workspace.xml
4 | # Editor-based HTTP Client requests
5 | /httpRequests/
6 |
--------------------------------------------------------------------------------
/.idea/aws.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/.idea/codeStyles/codeStyleConfig.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/.idea/jsLibraryMappings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/misc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/modules.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/.idea/todos.iml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/.storybook/main.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | stories: ["../src/**/*.stories.mdx", "../src/**/*.stories.@(js|jsx|ts|tsx)"],
3 | typescript: {
4 | reactDocgen: "react-docgen-typescript",
5 | },
6 | addons: [
7 | "@storybook/addon-links",
8 | "@storybook/addon-essentials",
9 | "@storybook/preset-create-react-app",
10 | ],
11 | };
12 |
--------------------------------------------------------------------------------
/.storybook/preview.js:
--------------------------------------------------------------------------------
1 |
2 | export const parameters = {
3 | actions: { argTypesRegex: "^on[A-Z].*" },
4 | }
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Todo app
2 |
3 | This is a simple todo app, written to demonstrate how to write tests in the frontend. It is the base for [a blog article](https://startup-cto.net/tdd-in-a-react-frontend/).
4 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "todos",
3 | "version": "0.1.0",
4 | "private": true,
5 | "homepage": "https://startup-cto.github.io/todos/",
6 | "dependencies": {
7 | "@fortawesome/fontawesome-free": "^5.15.2",
8 | "@fortawesome/fontawesome-svg-core": "^1.2.34",
9 | "@fortawesome/free-solid-svg-icons": "^5.15.2",
10 | "@fortawesome/react-fontawesome": "^0.1.14",
11 | "@testing-library/jest-dom": "^5.11.4",
12 | "@testing-library/react": "^11.1.0",
13 | "@testing-library/user-event": "^12.1.10",
14 | "@types/jest": "^26.0.15",
15 | "@types/node": "^12.0.0",
16 | "@types/react": "^16.9.53",
17 | "@types/react-dom": "^16.9.8",
18 | "@types/uuid": "^8.3.0",
19 | "react": "^17.0.1",
20 | "react-dom": "^17.0.1",
21 | "react-scripts": "4.0.1",
22 | "uuid": "^8.3.2",
23 | "typescript": "^4.0.3"
24 | },
25 | "scripts": {
26 | "start": "react-scripts start",
27 | "build": "react-scripts build",
28 | "test": "react-scripts test",
29 | "eject": "react-scripts eject",
30 | "storybook": "start-storybook -p 6006 -s public",
31 | "build-storybook": "build-storybook -s public"
32 | },
33 | "eslintConfig": {
34 | "extends": [
35 | "react-app",
36 | "react-app/jest"
37 | ]
38 | },
39 | "browserslist": {
40 | "production": [
41 | ">0.2%",
42 | "not dead",
43 | "not op_mini all"
44 | ],
45 | "development": [
46 | "last 1 chrome version",
47 | "last 1 firefox version",
48 | "last 1 safari version"
49 | ]
50 | },
51 | "devDependencies": {
52 | "@storybook/addon-actions": "^6.1.14",
53 | "@storybook/addon-essentials": "^6.1.14",
54 | "@storybook/addon-links": "^6.1.14",
55 | "@storybook/node-logger": "^6.1.14",
56 | "@storybook/preset-create-react-app": "^3.1.5",
57 | "@storybook/react": "^6.1.14",
58 | "prettier": "^2.2.1",
59 | "react-docgen-typescript": "^1.20.5"
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/startup-cto/todos/f20bc189d0daad7bb33d8f4584f3516f0a6ea536/public/favicon.ico
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
17 |
18 |
27 | Todos
28 |
29 |
30 |
31 |
32 |
42 |
43 |
44 |
--------------------------------------------------------------------------------
/public/logo192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/startup-cto/todos/f20bc189d0daad7bb33d8f4584f3516f0a6ea536/public/logo192.png
--------------------------------------------------------------------------------
/public/logo512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/startup-cto/todos/f20bc189d0daad7bb33d8f4584f3516f0a6ea536/public/logo512.png
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "Todos",
3 | "name": "Manage todos with test-driven code",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | },
10 | {
11 | "src": "logo192.png",
12 | "type": "image/png",
13 | "sizes": "192x192"
14 | },
15 | {
16 | "src": "logo512.png",
17 | "type": "image/png",
18 | "sizes": "512x512"
19 | }
20 | ],
21 | "start_url": ".",
22 | "display": "standalone",
23 | "theme_color": "#000000",
24 | "background_color": "#ffffff"
25 | }
26 |
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/src/App/App.stories.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Story, Meta } from "@storybook/react";
3 | import App from "./App";
4 |
5 | const meta: Meta = {
6 | title: "App",
7 | component: App,
8 | };
9 |
10 | export default meta;
11 |
12 | const Template: Story = (args) => ;
13 |
14 | export const Default = Template.bind({});
15 |
--------------------------------------------------------------------------------
/src/App/App.test.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { render, screen } from "@testing-library/react";
3 | import userEvent from "@testing-library/user-event";
4 | import App from "./App";
5 | import { en } from "../locale";
6 |
7 | function addTodo(todoDescription: string) {
8 | const todoInput = screen.getByLabelText(en.newTodo);
9 | userEvent.type(todoInput, todoDescription);
10 | const addTodoButton = screen.getByText(en.addTodo);
11 | userEvent.click(addTodoButton);
12 | }
13 |
14 | function wait(waitTimeInMS: number) {
15 | return new Promise((resolve) => setTimeout(resolve, waitTimeInMS));
16 | }
17 |
18 | describe("TodoApp", () => {
19 | it('renders "No todos" by default', () => {
20 | render();
21 | expect(screen.getByText(en.noTodos)).toBeInTheDocument();
22 | });
23 |
24 | it("shows an added todo", async () => {
25 | render();
26 | const todoDescription = "My new todo";
27 | addTodo(todoDescription);
28 | expect(await screen.findByText(todoDescription)).toBeInTheDocument();
29 | });
30 |
31 | it("does not show a removed todo", async () => {
32 | render();
33 | const todoDescription = "My new todo";
34 | addTodo(todoDescription);
35 | const removeButton = await screen.findByText(en.removeTodo);
36 | userEvent.click(removeButton);
37 | await wait(50);
38 | expect(screen.queryByText(todoDescription)).not.toBeInTheDocument();
39 | });
40 |
41 | it("marks a completed todo as complete", async () => {
42 | render();
43 | const todoDescription = "My new todo";
44 | addTodo(todoDescription);
45 | const completeButton = await screen.findByText(en.markTodoAsCompleted);
46 | userEvent.click(completeButton);
47 | expect(await screen.findByText(en.todoCompleted)).toBeInTheDocument();
48 | });
49 | });
50 |
--------------------------------------------------------------------------------
/src/App/App.tsx:
--------------------------------------------------------------------------------
1 | import React, { FunctionComponent } from "react";
2 | import { AddTodo } from "../components/AddTodo";
3 | import { TodoList } from "../components/TodoList";
4 | import { useTodos } from "./useTodos";
5 |
6 | const TodoApp: FunctionComponent = () => {
7 | const { todos, addTodo, completeTodo, deleteTodo } = useTodos([]);
8 |
9 | return (
10 | <>
11 |
16 |
17 | >
18 | );
19 | };
20 |
21 | export default TodoApp;
22 |
--------------------------------------------------------------------------------
/src/App/useTodos.ts:
--------------------------------------------------------------------------------
1 | import { Todo } from "../model/Todo";
2 | import { useReducer } from "react";
3 | import { todosReducer } from "../model/reducer";
4 | import {
5 | createAddTodoAction,
6 | createCompleteTodoAction,
7 | createDeleteTodoAction,
8 | } from "../model/actions";
9 |
10 | export function useTodos(initialTodos: Todo[]) {
11 | const [todos, dispatch] = useReducer(todosReducer, initialTodos);
12 | return {
13 | todos,
14 | addTodo: (description: string) =>
15 | dispatch(createAddTodoAction(description)),
16 | completeTodo: (id: Todo["id"]) => dispatch(createCompleteTodoAction(id)),
17 | deleteTodo: (id: Todo["id"]) => dispatch(createDeleteTodoAction(id)),
18 | };
19 | }
20 |
--------------------------------------------------------------------------------
/src/components/AddTodo/AddTodo.stories.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Story, Meta } from "@storybook/react";
3 | import { action } from "@storybook/addon-actions";
4 | import { AddTodo, Props } from "./AddTodo";
5 |
6 | const meta: Meta = {
7 | title: "AddTodo",
8 | component: AddTodo,
9 | };
10 |
11 | export default meta;
12 |
13 | const Template: Story = (args) => ;
14 |
15 | const actionArgs = {
16 | onAdd: action("onAdd"),
17 | };
18 |
19 | export const Default = Template.bind({});
20 |
21 | Default.args = {
22 | ...actionArgs,
23 | };
24 |
--------------------------------------------------------------------------------
/src/components/AddTodo/AddTodo.tsx:
--------------------------------------------------------------------------------
1 | import React, { FormEvent, FunctionComponent, useState } from "react";
2 | import { en } from "../../locale";
3 | import { Button, ButtonColor, ButtonType } from "../shared";
4 |
5 | export interface Props {
6 | onAdd: (description: string) => void;
7 | }
8 |
9 | export const AddTodo: FunctionComponent = ({ onAdd }) => {
10 | const [todoText, setTodoText] = useState("");
11 |
12 | function onSubmit(event: FormEvent) {
13 | event.preventDefault();
14 | onAdd(todoText);
15 | }
16 |
17 | const todoInputId = "todoInput";
18 | return (
19 |
41 | );
42 | };
43 |
--------------------------------------------------------------------------------
/src/components/AddTodo/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./AddTodo";
2 |
--------------------------------------------------------------------------------
/src/components/TodoItem/CompleteTodoButton.stories.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Meta, Story } from "@storybook/react";
3 | import { action } from "@storybook/addon-actions";
4 | import { CompleteTodoButton, Props } from "./CompleteTodoButton";
5 |
6 | const meta: Meta = {
7 | title: "CompleteTodoButton",
8 | component: CompleteTodoButton,
9 | };
10 |
11 | export default meta;
12 |
13 | const Template: Story = (args) => ;
14 |
15 | const actionArgs = {
16 | onClick: action("onClick"),
17 | };
18 |
19 | export const Default = Template.bind({});
20 |
21 | Default.args = {
22 | ...actionArgs,
23 | };
24 |
--------------------------------------------------------------------------------
/src/components/TodoItem/CompleteTodoButton.tsx:
--------------------------------------------------------------------------------
1 | import { Button, ButtonColor } from "../shared/Button";
2 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
3 | import { faCheck } from "@fortawesome/free-solid-svg-icons";
4 | import { en } from "../../locale";
5 | import React from "react";
6 |
7 | export interface Props {
8 | onClick: () => void;
9 | }
10 |
11 | export function CompleteTodoButton(props: Props) {
12 | return (
13 |
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/src/components/TodoItem/RemoveTodoButton.stories.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Meta, Story } from "@storybook/react";
3 | import { action } from "@storybook/addon-actions";
4 | import { Props, RemoveTodoButton } from "./RemoveTodoButton";
5 |
6 | const meta: Meta = {
7 | title: "RemoveTodoButton",
8 | component: RemoveTodoButton,
9 | };
10 |
11 | export default meta;
12 |
13 | const Template: Story = (args) => ;
14 |
15 | const actionArgs = {
16 | onClick: action("onClick"),
17 | };
18 |
19 | export const Default = Template.bind({});
20 |
21 | Default.args = {
22 | ...actionArgs,
23 | };
24 |
--------------------------------------------------------------------------------
/src/components/TodoItem/RemoveTodoButton.tsx:
--------------------------------------------------------------------------------
1 | import { Button, ButtonColor } from "../shared/Button";
2 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
3 | import { faTrash } from "@fortawesome/free-solid-svg-icons";
4 | import { en } from "../../locale";
5 | import React from "react";
6 |
7 | export interface Props {
8 | onClick: () => void;
9 | }
10 |
11 | export function RemoveTodoButton(props: Props) {
12 | return (
13 |
16 | );
17 | }
18 |
--------------------------------------------------------------------------------
/src/components/TodoItem/TodoItem.stories.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Story, Meta } from "@storybook/react";
3 | import { TodoMock } from "../../model/TodoMock";
4 | import { action } from "@storybook/addon-actions";
5 | import { Props, TodoItem } from "./TodoItem";
6 |
7 | const meta: Meta = {
8 | title: "TodoItem",
9 | component: TodoItem,
10 | };
11 |
12 | export default meta;
13 |
14 | const Template: Story = (args) => ;
15 |
16 | const actionArgs = {
17 | onComplete: action("onComplete"),
18 | onRemove: action("onRemove"),
19 | };
20 |
21 | export const ShortText = Template.bind({});
22 | ShortText.args = {
23 | ...actionArgs,
24 | todo: new TodoMock({
25 | completed: false,
26 | description: "Write a short todo description",
27 | }),
28 | };
29 |
30 | export const LongText = Template.bind({});
31 | LongText.args = {
32 | ...actionArgs,
33 | todo: new TodoMock({
34 | completed: false,
35 | description:
36 | "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam ullamcorper eleifend risus, a varius sapien ornare sit amet. Aliquam a accumsan nisl.",
37 | }),
38 | };
39 |
40 | export const Completed = Template.bind({});
41 | Completed.args = {
42 | ...actionArgs,
43 | todo: new TodoMock({
44 | completed: true,
45 | description: "Write a short todo description",
46 | }),
47 | };
48 |
--------------------------------------------------------------------------------
/src/components/TodoItem/TodoItem.tsx:
--------------------------------------------------------------------------------
1 | import { RemoveTodoButton } from "./RemoveTodoButton";
2 | import { CompleteTodoButton } from "./CompleteTodoButton";
3 | import React, { FunctionComponent } from "react";
4 | import { en } from "../../locale";
5 | import { screenReaderOnlyStyle } from "../shared/screenReaderOnlyStyle";
6 | import { Todo } from "../../model/Todo";
7 |
8 | export interface Props {
9 | todo: Todo;
10 | onComplete: (id: Todo["id"]) => void;
11 | onRemove: (id: Todo["id"]) => void;
12 | }
13 |
14 | export const TodoItem: FunctionComponent = ({
15 | todo: { id, completed, description },
16 | onComplete,
17 | onRemove,
18 | }) => (
19 |
30 |
38 | {completed && (
39 | {en.todoCompleted}
40 | )}
41 | {description}
42 |
43 |
51 | onRemove(id)} />
52 | {!completed && onComplete(id)} />}
53 |
54 |
55 | );
56 |
--------------------------------------------------------------------------------
/src/components/TodoItem/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./TodoItem";
2 |
--------------------------------------------------------------------------------
/src/components/TodoList/NoTodos.stories.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Meta, Story } from "@storybook/react";
3 | import { NoTodos } from "./NoTodos";
4 |
5 | const meta: Meta = {
6 | title: "NoTodos",
7 | component: NoTodos,
8 | };
9 |
10 | export default meta;
11 |
12 | const Template: Story = (args) => ;
13 |
14 | export const Default = Template.bind({});
15 |
--------------------------------------------------------------------------------
/src/components/TodoList/NoTodos.tsx:
--------------------------------------------------------------------------------
1 | import { en } from "../../locale";
2 | import React from "react";
3 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
4 | import { faSmileBeam } from "@fortawesome/free-solid-svg-icons";
5 |
6 | export function NoTodos() {
7 | return (
8 |
15 |
16 |
{en.noTodos}
17 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/src/components/TodoList/TodoList.stories.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Story, Meta } from "@storybook/react";
3 | import { Props, TodoList } from "./TodoList";
4 | import { TodoMock } from "../../model/TodoMock";
5 | import { action } from "@storybook/addon-actions";
6 |
7 | const meta: Meta = {
8 | title: "TodoList",
9 | component: TodoList,
10 | };
11 |
12 | export default meta;
13 |
14 | const Template: Story = (args) => ;
15 |
16 | const actionArgs = {
17 | completeTodo: action("completeTodo"),
18 | deleteTodo: action("deleteTodo"),
19 | };
20 |
21 | export const WithoutTodos = Template.bind({});
22 | WithoutTodos.args = {
23 | ...actionArgs,
24 | todos: [],
25 | };
26 |
27 | export const WithTodos = Template.bind({});
28 | WithTodos.args = {
29 | ...actionArgs,
30 | todos: [new TodoMock(), new TodoMock(), new TodoMock()],
31 | };
32 |
33 | export const WithALongTodo = Template.bind({});
34 | WithALongTodo.args = {
35 | ...actionArgs,
36 | todos: [
37 | new TodoMock({
38 | description: `Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam ullamcorper eleifend risus, a varius sapien ornare sit amet.
39 | Aliquam a accumsan nisl, vel rhoncus libero. Ut laoreet massa et velit finibus, vel pretium nulla iaculis. Etiam a eros eget eros euismod rhoncus at vitae felis. Proin molestie vel dui sit amet venenatis. Nunc quis fringilla dolor. Cras ullamcorper justo massa, id vehicula sem aliquam vel. Nunc nec ex vitae turpis convallis hendrerit et et nulla. Vestibulum laoreet nisi id sagittis iaculis. Donec et efficitur nunc. Cras sagittis ipsum sit amet libero tincidunt, eu consequat arcu dapibus. Duis lacinia magna vitae tincidunt blandit. Nam tempus nibh nisl, non aliquet neque hendrerit vel.`,
40 | }),
41 | ],
42 | };
43 |
--------------------------------------------------------------------------------
/src/components/TodoList/TodoList.tsx:
--------------------------------------------------------------------------------
1 | import React, { FunctionComponent } from "react";
2 | import { Todo } from "../../model/Todo";
3 | import { TodoItem } from "../TodoItem";
4 | import { NoTodos } from "./NoTodos";
5 |
6 | export interface Props {
7 | todos: Todo[];
8 | onCompleteTodo: (id: Todo["id"]) => void;
9 | onDeleteTodo: (id: Todo["id"]) => void;
10 | }
11 |
12 | export const TodoList: FunctionComponent = ({
13 | todos,
14 | onCompleteTodo,
15 | onDeleteTodo,
16 | }) => {
17 | if (todos.length === 0) {
18 | return ;
19 | }
20 | return (
21 |
22 | {todos.map((todo) => (
23 | onDeleteTodo(todo.id)}
27 | onComplete={() => onCompleteTodo(todo.id)}
28 | />
29 | ))}
30 |
31 | );
32 | };
33 |
--------------------------------------------------------------------------------
/src/components/TodoList/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./TodoList";
2 |
--------------------------------------------------------------------------------
/src/components/shared/Button.stories.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Meta, Story } from "@storybook/react";
3 | import { action } from "@storybook/addon-actions";
4 | import { Button, ButtonColor, Props } from "./Button";
5 |
6 | const meta: Meta = {
7 | title: "Button",
8 | component: Button,
9 | };
10 |
11 | export default meta;
12 |
13 | const Template: Story = (args) => ;
14 |
15 | const actionArgs = {
16 | onClick: action("onClick"),
17 | };
18 |
19 | export const Default = Template.bind({});
20 |
21 | Default.args = {
22 | ...actionArgs,
23 | children: "Click me!",
24 | color: ButtonColor.Success,
25 | };
26 |
--------------------------------------------------------------------------------
/src/components/shared/Button.tsx:
--------------------------------------------------------------------------------
1 | import React, { FunctionComponent, ReactNode } from "react";
2 |
3 | export enum ButtonColor {
4 | Alert = "Alert",
5 | Success = "Success",
6 | }
7 |
8 | export enum ButtonType {
9 | Submit = "submit",
10 | Reset = "reset",
11 | Button = "button",
12 | }
13 |
14 | export interface Props {
15 | children: ReactNode;
16 | color: ButtonColor;
17 | onClick?: () => void;
18 | type?: ButtonType;
19 | }
20 |
21 | export const Button: FunctionComponent = ({
22 | children,
23 | color,
24 | onClick,
25 | type,
26 | }) => {
27 | const colorStyles = {
28 | [ButtonColor.Alert]: {
29 | border: "#b33 solid 1px",
30 | borderRadius: "4px",
31 | boxShadow: "2px 2px 2px rgba(100,0,0,0.8)",
32 | color: "white",
33 | backgroundColor: "#a00",
34 | },
35 | [ButtonColor.Success]: {
36 | border: "#3b3 solid 1px",
37 | borderRadius: "4px",
38 | boxShadow: "2px 2px 2px rgba(0,100,0,0.8)",
39 | color: "white",
40 | backgroundColor: "#0a0",
41 | },
42 | };
43 | return (
44 |
54 | );
55 | };
56 |
--------------------------------------------------------------------------------
/src/components/shared/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./Button";
2 | export * from "./screenReaderOnlyStyle";
3 |
--------------------------------------------------------------------------------
/src/components/shared/screenReaderOnlyStyle.ts:
--------------------------------------------------------------------------------
1 | // Based on https://webaim.org/techniques/css/invisiblecontent/
2 | export const screenReaderOnlyStyle = {
3 | position: "absolute",
4 | left: "-10000px",
5 | top: "auto",
6 | width: "1px",
7 | height: "1px",
8 | overflow: "hidden",
9 | } as const;
10 |
--------------------------------------------------------------------------------
/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ReactDOM from "react-dom";
3 | import App from "./App/App";
4 |
5 | ReactDOM.render(
6 |
7 |
8 | ,
9 | document.getElementById("root")
10 | );
11 |
--------------------------------------------------------------------------------
/src/locale/index.ts:
--------------------------------------------------------------------------------
1 | export const en = {
2 | markTodoAsCompleted: "Mark as completed",
3 | removeTodo: "Remove",
4 | noTodos: "No todos left",
5 | newTodo: "New todo",
6 | addTodo: "Add todo",
7 | todoCompleted: "Todo completed:",
8 | };
9 |
--------------------------------------------------------------------------------
/src/model/Todo.test.ts:
--------------------------------------------------------------------------------
1 | import { Todo } from "./Todo";
2 |
3 | describe("Todo", () => {
4 | it("has an id", () => {
5 | expect(new Todo("A description").id).toBeDefined();
6 | });
7 |
8 | it("is not complete by default", () => {
9 | expect(new Todo("A description").completed).toBe(false);
10 | });
11 | });
12 |
--------------------------------------------------------------------------------
/src/model/Todo.ts:
--------------------------------------------------------------------------------
1 | import { v4 as uuid } from "uuid";
2 |
3 | export class Todo {
4 | id = uuid();
5 | completed = false;
6 |
7 | constructor(public description: string) {}
8 | }
9 |
--------------------------------------------------------------------------------
/src/model/TodoMock.ts:
--------------------------------------------------------------------------------
1 | import { Todo } from "./Todo";
2 | import { v4 as uuid } from "uuid";
3 |
4 | export class TodoMock implements Todo {
5 | completed = false;
6 | description = "This is a todo";
7 | id = uuid();
8 |
9 | constructor(overrides: Partial = {}) {
10 | Object.assign(this, overrides);
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/src/model/actions.test.ts:
--------------------------------------------------------------------------------
1 | import {
2 | createAddTodoAction,
3 | createCompleteTodoAction,
4 | createDeleteTodoAction,
5 | } from "./actions";
6 | import { Todo } from "./Todo";
7 |
8 | describe("actions", () => {
9 | describe("createAddTodoAction", () => {
10 | it("creates an action with a todo as payload", () => {
11 | expect(createAddTodoAction("A description").payload).toBeInstanceOf(Todo);
12 | });
13 | });
14 |
15 | describe("createCompleteTodoAction", () => {
16 | it("creates an action with the given id in the payload", () => {
17 | const id = "id";
18 | expect(createCompleteTodoAction(id).payload.id).toEqual(id);
19 | });
20 | });
21 |
22 | describe("createDeleteTodoAction", () => {
23 | it("creates an action with the given id in the payload", () => {
24 | const id = "id";
25 | expect(createDeleteTodoAction(id).payload.id).toEqual(id);
26 | });
27 | });
28 | });
29 |
--------------------------------------------------------------------------------
/src/model/actions.ts:
--------------------------------------------------------------------------------
1 | import { Todo } from "./Todo";
2 |
3 | export enum TodoActionType {
4 | AddTodo = "AddTodo",
5 | CompleteTodo = "CompleteTodo",
6 | DeleteTodo = "DeleteTodo",
7 | }
8 |
9 | export function createAddTodoAction(description: string) {
10 | const newTodo = new Todo(description);
11 | return {
12 | type: TodoActionType.AddTodo as const,
13 | payload: newTodo,
14 | };
15 | }
16 |
17 | type AddTodoAction = ReturnType;
18 |
19 | export function createCompleteTodoAction(id: Todo["id"]) {
20 | return {
21 | type: TodoActionType.CompleteTodo as const,
22 | payload: {
23 | id,
24 | },
25 | };
26 | }
27 |
28 | type CompleteTodoAction = ReturnType;
29 |
30 | export function createDeleteTodoAction(id: Todo["id"]) {
31 | return {
32 | type: TodoActionType.DeleteTodo as const,
33 | payload: {
34 | id,
35 | },
36 | };
37 | }
38 |
39 | type DeleteTodoAction = ReturnType;
40 | export type TodoAction = AddTodoAction | CompleteTodoAction | DeleteTodoAction;
41 |
--------------------------------------------------------------------------------
/src/model/reducer.test.ts:
--------------------------------------------------------------------------------
1 | import { todosReducer } from "./reducer";
2 | import { TodoMock } from "./TodoMock";
3 | import {
4 | createAddTodoAction,
5 | createCompleteTodoAction,
6 | createDeleteTodoAction,
7 | } from "./actions";
8 |
9 | describe("todo reducer", () => {
10 | describe("addTodoAction", () => {
11 | it("adds a new todo to the list", () => {
12 | const description = "This is a todo";
13 | expect(todosReducer([], createAddTodoAction(description))).toContainEqual(
14 | expect.objectContaining({ description })
15 | );
16 | });
17 |
18 | it("does not remove an existing todo", () => {
19 | const existingTodo = new TodoMock();
20 | expect(
21 | todosReducer([existingTodo], createAddTodoAction("This is a todo"))
22 | ).toContainEqual(existingTodo);
23 | });
24 | });
25 |
26 | describe("completeTodoAction", () => {
27 | it("marks a todo as completed", () => {
28 | const incompleteTodo = new TodoMock({ completed: false });
29 | expect(
30 | todosReducer(
31 | [incompleteTodo],
32 | createCompleteTodoAction(incompleteTodo.id)
33 | )
34 | ).toContainEqual(
35 | expect.objectContaining({ id: incompleteTodo.id, completed: true })
36 | );
37 | });
38 | });
39 |
40 | describe("deleteTodoAction", () => {
41 | it("removes the given todo", () => {
42 | const existingTodo = new TodoMock();
43 | expect(
44 | todosReducer([existingTodo], createDeleteTodoAction(existingTodo.id))
45 | ).toEqual([]);
46 | });
47 |
48 | it("does not remove a todo with a different id", () => {
49 | const existingTodo = new TodoMock({ id: "existingId" });
50 | expect(
51 | todosReducer([existingTodo], createDeleteTodoAction("differentId"))
52 | ).toEqual([existingTodo]);
53 | });
54 | });
55 | });
56 |
--------------------------------------------------------------------------------
/src/model/reducer.ts:
--------------------------------------------------------------------------------
1 | import { Todo } from "./Todo";
2 | import { TodoAction, TodoActionType } from "./actions";
3 |
4 | export function todosReducer(todos: Todo[], action: TodoAction) {
5 | switch (action.type) {
6 | case TodoActionType.AddTodo:
7 | return [...todos, action.payload];
8 | case TodoActionType.CompleteTodo:
9 | return todos.map((todo) =>
10 | todo.id === action.payload.id ? { ...todo, completed: true } : todo
11 | );
12 | case TodoActionType.DeleteTodo:
13 | return todos.filter((todo) => todo.id !== action.payload.id);
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/src/react-app-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/src/setupTests.ts:
--------------------------------------------------------------------------------
1 | // jest-dom adds custom jest matchers for asserting on DOM nodes.
2 | // allows you to do things like:
3 | // expect(element).toHaveTextContent(/react/i)
4 | // learn more: https://github.com/testing-library/jest-dom
5 | import '@testing-library/jest-dom';
6 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": [
5 | "dom",
6 | "dom.iterable",
7 | "esnext"
8 | ],
9 | "allowJs": true,
10 | "skipLibCheck": true,
11 | "esModuleInterop": true,
12 | "allowSyntheticDefaultImports": true,
13 | "strict": true,
14 | "forceConsistentCasingInFileNames": true,
15 | "noFallthroughCasesInSwitch": true,
16 | "module": "esnext",
17 | "moduleResolution": "node",
18 | "resolveJsonModule": true,
19 | "isolatedModules": true,
20 | "noEmit": true,
21 | "jsx": "react-jsx"
22 | },
23 | "include": [
24 | "src"
25 | ]
26 | }
27 |
--------------------------------------------------------------------------------