├── .dockerignore ├── .eslintignore ├── .eslintrc.yml ├── .github └── workflows │ └── CI.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── docker-compose.yml ├── package-lock.json ├── package.json ├── playwright.config.js ├── public ├── favicon.ico └── index.html ├── src ├── assets │ ├── TodoPromo.gif │ ├── application.scss │ └── todoPreview.jpg ├── components │ ├── App.jsx │ ├── Footer.jsx │ ├── Header.jsx │ ├── Navbar.jsx │ ├── ToDoItem.jsx │ └── ToDoList.jsx ├── index.css ├── index.js ├── init.js ├── reportWebVitals.js ├── setupTests.js └── slices │ ├── filterInfoSlice.js │ ├── index.js │ └── tasksSlice.js └── tests ├── app.test.js └── example.spec.js /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | node_modules 3 | Dockerfile 4 | logs 5 | tmp -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | dist 3 | build 4 | -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | env: 2 | browser: true 3 | es2021: true 4 | 5 | extends: 6 | - airbnb 7 | - plugin:react/recommended 8 | - plugin:functional/recommended 9 | - plugin:react-hooks/recommended 10 | 11 | parserOptions: 12 | ecmaFeatures: 13 | jsx: true 14 | ecmaVersion: latest 15 | sourceType: module 16 | 17 | plugins: 18 | - react 19 | - functional 20 | 21 | rules: 22 | import/extensions: 0 23 | import/no-unresolved: 0 24 | react/prop-types: 0 25 | no-console: 0 26 | 27 | react/react-in-jsx-scope: 0 28 | linebreak-style: ["error", "windows"] 29 | functional/no-conditional-statement: 0 30 | functional/no-expression-statement: 0 31 | functional/immutable-data: 0 32 | functional/functional-parameters: 0 33 | functional/no-try-statement: 0 34 | functional/no-throw-statement: 0 35 | no-underscore-dangle: [2, { "allow": ["__filename", "__dirname"] }] 36 | react/function-component-definition: 37 | [2, { "namedComponents": "arrow-function" }] 38 | testing-library/no-debug: 0 39 | react/jsx-filename-extension: [1, { "extensions": [".js", ".jsx"] }] 40 | -------------------------------------------------------------------------------- /.github/workflows/CI.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: [ main, master ] 5 | pull_request: 6 | branches: [ main, master ] 7 | jobs: 8 | test: 9 | timeout-minutes: 60 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: actions/setup-node@v3 14 | with: 15 | node-version: 16 16 | - name: Install dependencies 17 | run: npm ci 18 | - name: App setup 19 | run: make app-setup 20 | - name: Run linter & tests 21 | run: make app-check 22 | - uses: actions/upload-artifact@v3 23 | if: always() 24 | with: 25 | name: playwright-report 26 | path: playwright-report/ 27 | retention-days: 30 28 | -------------------------------------------------------------------------------- /.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 | /playwright-report 11 | 12 | # production 13 | /build 14 | 15 | # misc 16 | .DS_Store 17 | .env.local 18 | .env.development.local 19 | .env.test.local 20 | .env.production.local 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | /test-results/ 26 | /playwright-report/ 27 | /playwright/.cache/ 28 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node 2 | 3 | WORKDIR /app 4 | 5 | COPY package.json . 6 | 7 | COPY package-lock.json . 8 | 9 | RUN npm ci 10 | 11 | COPY . . 12 | 13 | # RUN npm build 14 | 15 | EXPOSE 3000 16 | 17 | # CMD ["npm", "start"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Misa 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | install: 2 | npm ci 3 | 4 | start: 5 | npm start 6 | 7 | build: 8 | docker-compose build 9 | 10 | deploy: 11 | npm run deploy 12 | 13 | up: 14 | docker-compose up -d 15 | 16 | down: 17 | docker-compose down 18 | 19 | lint: 20 | npx eslint . 21 | 22 | linter-fix: 23 | npx eslint . --fix 24 | 25 | test: 26 | npx playwright test 27 | 28 | test-install: 29 | npx playwright install --with-deps 30 | 31 | app-install: 32 | docker-compose run --rm web make install 33 | 34 | app-lint: 35 | docker-compose run --rm --no-deps web make lint 36 | 37 | app-test: 38 | docker-compose run --rm web make test-install test 39 | 40 | app-check: app-lint app-test 41 | 42 | app-setup: build app-install 43 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React App 2 | 3 | ![Todo preview](./src/assets/todoPreview.jpg) 4 | 5 | ### CI, tests and linter status: 6 | [![CI](https://github.com/bogdan-ho/todo-list/actions/workflows/CI.yml/badge.svg)](https://github.com/bogdan-ho/todo-list/actions/workflows/CI.yml) 7 | [![Maintainability](https://api.codeclimate.com/v1/badges/da65ed83bf72a6cb066e/maintainability)](https://codeclimate.com/github/bogdan-ho/todo-list/maintainability) 8 | 9 | 10 | ## About 11 | ✅React Todo application built with React (with hooks), Redux (@redux/toolkit) with adaptive layout using Bootstrap + Formik + Playwright tests + Docker 12 | 13 | 14 | ## Getting Started 15 | - Clone a repository 16 | - Go to the working directory of the project `cd playwright-ts-redux-toolkit` 17 | - Run `make app-setup` 18 | - Run tests `make app-test` 19 | - Start the server `make up` 20 | 21 | ## Features 22 | - Adding, Editing, Deleting and marking Completed tasks 23 | - Filter tasks by All, Active, Completed 24 | - Clear all completed tasks by one click 25 | - Displaying the remaining tasks counter 26 | 27 | ## Demo 28 | Check out the demo project for a quick example of how React Todo application works. 29 | 30 | ![This is an image](./src/assets/TodoPromo.gif) 31 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | web: 5 | build: . 6 | 7 | image: bogdanho/todo-list 8 | 9 | container_name: todo-list 10 | 11 | restart: always 12 | 13 | stdin_open: true 14 | 15 | tty: true 16 | 17 | volumes: 18 | - ".:/app" 19 | 20 | ports: 21 | - 3000:3000 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "todo-list", 3 | "version": "0.1.0", 4 | "description": "React ToDo list", 5 | "main": "src/index.js", 6 | "homepage": "https://bogdan-ho.github.io/todo-list/", 7 | "dependencies": { 8 | "@playwright/test": "^1.29.2", 9 | "@reduxjs/toolkit": "^1.9.1", 10 | "@testing-library/jest-dom": "^5.16.5", 11 | "@testing-library/react": "^13.4.0", 12 | "@testing-library/user-event": "^13.5.0", 13 | "bootstrap": "^5.2.3", 14 | "formik": "^2.2.9", 15 | "lodash": "^4.17.21", 16 | "react": "^18.2.0", 17 | "react-bootstrap": "^2.7.0", 18 | "react-dom": "^18.2.0", 19 | "react-redux": "^8.0.5", 20 | "react-scripts": "5.0.1", 21 | "sass": "^1.57.1", 22 | "web-vitals": "^2.1.4", 23 | "yup": "^0.32.11" 24 | }, 25 | "scripts": { 26 | "predeploy" : "npm run build", 27 | "deploy" : "gh-pages -d build", 28 | "start": "react-scripts start", 29 | "build": "react-scripts build", 30 | "test": "react-scripts test", 31 | "eject": "react-scripts eject" 32 | }, 33 | "browserslist": { 34 | "production": [ 35 | ">0.2%", 36 | "not dead", 37 | "not op_mini all" 38 | ], 39 | "development": [ 40 | "last 1 chrome version", 41 | "last 1 firefox version", 42 | "last 1 safari version" 43 | ] 44 | }, 45 | "devDependencies": { 46 | "eslint": "^8.31.0", 47 | "eslint-config-airbnb": "^19.0.4", 48 | "eslint-plugin-functional": "^4.4.1", 49 | "eslint-plugin-import": "^2.26.0", 50 | "eslint-plugin-jsx-a11y": "^6.6.1", 51 | "eslint-plugin-react": "^7.31.11", 52 | "eslint-plugin-react-hooks": "^4.6.0", 53 | "gh-pages": "^4.0.0" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /playwright.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import { devices } from '@playwright/test'; 3 | 4 | /** 5 | * Read environment variables from file. 6 | * https://github.com/motdotla/dotenv 7 | */ 8 | // require('dotenv').config(); 9 | 10 | /** 11 | * @see https://playwright.dev/docs/test-configuration 12 | * @type {import('@playwright/test').PlaywrightTestConfig} 13 | */ 14 | const config = { 15 | testDir: './tests', 16 | /* Maximum time one test can run for. */ 17 | timeout: 30 * 1000, 18 | expect: { 19 | /** 20 | * Maximum time expect() should wait for the condition to be met. 21 | * For example in `await expect(locator).toHaveText();` 22 | */ 23 | timeout: 5000, 24 | }, 25 | /* Run tests in files in parallel */ 26 | fullyParallel: true, 27 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 28 | forbidOnly: !!process.env.CI, 29 | /* Retry on CI only */ 30 | retries: process.env.CI ? 2 : 0, 31 | /* Opt out of parallel tests on CI. */ 32 | workers: process.env.CI ? 1 : undefined, 33 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 34 | reporter: 'html', 35 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 36 | use: { 37 | /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ 38 | actionTimeout: 0, 39 | /* Base URL to use in actions like `await page.goto('/')`. */ 40 | baseURL: 'http://localhost:3000', 41 | 42 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 43 | trace: 'on-first-retry', 44 | }, 45 | 46 | /* Configure projects for major browsers */ 47 | projects: [ 48 | { 49 | name: 'chromium', 50 | use: { 51 | ...devices['Desktop Chrome'], 52 | }, 53 | }, 54 | 55 | // { 56 | // name: 'firefox', 57 | // use: { 58 | // ...devices['Desktop Firefox'], 59 | // }, 60 | // }, 61 | 62 | // { 63 | // name: 'webkit', 64 | // use: { 65 | // ...devices['Desktop Safari'], 66 | // }, 67 | // }, 68 | 69 | /* Test against mobile viewports. */ 70 | // { 71 | // name: 'Mobile Chrome', 72 | // use: { 73 | // ...devices['Pixel 5'], 74 | // }, 75 | // }, 76 | // { 77 | // name: 'Mobile Safari', 78 | // use: { 79 | // ...devices['iPhone 12'], 80 | // }, 81 | // }, 82 | 83 | /* Test against branded browsers. */ 84 | // { 85 | // name: 'Microsoft Edge', 86 | // use: { 87 | // channel: 'msedge', 88 | // }, 89 | // }, 90 | // { 91 | // name: 'Google Chrome', 92 | // use: { 93 | // channel: 'chrome', 94 | // }, 95 | // }, 96 | ], 97 | 98 | /* Folder for test artifacts such as screenshots, videos, traces, etc. */ 99 | // outputDir: 'test-results/', 100 | 101 | /* Run your local dev server before starting the tests */ 102 | webServer: { 103 | command: 'npm run start', 104 | port: 3000, 105 | timeout: 120000, 106 | }, 107 | }; 108 | 109 | export default config; 110 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mm0hammadi/playwright-ts-redux-toolkit/3a42087a9f0dcbd0e318d8b0fb7bb1081cbedc39/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | sample 11 | 12 | 13 | 14 | 15 |
16 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/assets/TodoPromo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mm0hammadi/playwright-ts-redux-toolkit/3a42087a9f0dcbd0e318d8b0fb7bb1081cbedc39/src/assets/TodoPromo.gif -------------------------------------------------------------------------------- /src/assets/application.scss: -------------------------------------------------------------------------------- 1 | @import '~bootstrap/scss/bootstrap'; 2 | -------------------------------------------------------------------------------- /src/assets/todoPreview.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mm0hammadi/playwright-ts-redux-toolkit/3a42087a9f0dcbd0e318d8b0fb7bb1081cbedc39/src/assets/todoPreview.jpg -------------------------------------------------------------------------------- /src/components/App.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable quotes */ 2 | import React from "react"; 3 | import "../assets/application.scss"; 4 | import Header from "./Header"; 5 | import ToDoList from "./ToDoList"; 6 | import Footer from "./Footer"; 7 | // import Navbar from "./Navbar"; 8 | 9 | const App = () => ( 10 |
11 | {/* */} 12 |
13 | 14 |
16 | ); 17 | 18 | export default App; 19 | -------------------------------------------------------------------------------- /src/components/Footer.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Button from 'react-bootstrap/Button'; 3 | import ButtonGroup from 'react-bootstrap/ButtonGroup'; 4 | import { useDispatch, useSelector } from 'react-redux'; 5 | 6 | import { actions as filterInfoActions, filterInfoSelector } from '../slices/filterInfoSlice'; 7 | import { actions as tasksActions, selectCompletedTasks, selectActiveTasks } from '../slices/tasksSlice'; 8 | 9 | const Footer = () => { 10 | const completedTasks = useSelector(selectCompletedTasks); 11 | const activeTasks = useSelector(selectActiveTasks); 12 | 13 | const { nowShowing } = useSelector(filterInfoSelector); 14 | const dispatch = useDispatch(); 15 | 16 | 17 | const handleShowAll = () => { 18 | dispatch(filterInfoActions.showAll()); 19 | }; 20 | const handleShowActive = () => { 21 | dispatch(filterInfoActions.showActive()); 22 | }; 23 | const handleShowCompleted = () => { 24 | dispatch(filterInfoActions.showCompleted()); 25 | }; 26 | const handleClearCompletedTasks = () => { 27 | const completedTasksIds = completedTasks.map((task) => task.id); 28 | dispatch(tasksActions.removeAllCompleted(completedTasksIds)); 29 | }; 30 | 31 | return ( 32 |
33 |
34 |
35 |
36 | 37 | {activeTasks.length} 38 | {' '} 39 | items left 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 |
48 |
49 |
50 |
51 | ); 52 | }; 53 | 54 | export default Footer; 55 | -------------------------------------------------------------------------------- /src/components/Header.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef } from 'react'; 2 | import { FloatingLabel, Form } from 'react-bootstrap/esm'; 3 | import { useDispatch } from 'react-redux'; 4 | import _uniqueId from 'lodash/uniqueId'; 5 | import { useFormik } from 'formik'; 6 | import * as yup from 'yup'; 7 | import { actions } from '../slices/tasksSlice'; 8 | 9 | const Header = () => { 10 | const dispatch = useDispatch(); 11 | 12 | const inputRef = useRef(); 13 | useEffect(() => { 14 | inputRef.current.focus(); 15 | }, []); 16 | 17 | const handleSubmit = (value, { resetForm }) => { 18 | const task = value.body; 19 | 20 | dispatch(actions.addTask({ id: _uniqueId(), task, completed: false })); 21 | resetForm(); 22 | }; 23 | 24 | const schema = yup.object().shape({ 25 | body: yup.string().min(3, 'Must be at least 3 characters'), 26 | }); 27 | 28 | const formik = useFormik({ onSubmit: handleSubmit, validationSchema: schema, initialValues: { body: '' } }); 29 | 30 | return ( 31 | 32 |
33 |
34 |
35 |
36 |
37 |

todos

38 |
39 |
40 | 45 | 57 | {formik.errors.body} 58 | 59 |
60 |
61 |
62 |
63 |
64 | ); 65 | }; 66 | 67 | export default Header; 68 | -------------------------------------------------------------------------------- /src/components/Navbar.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { NavItem, Nav } from 'react-bootstrap'; 3 | 4 | // eslint-disable-next-line react/function-component-definition 5 | export default function Navbar() { 6 | return ( 7 |
8 | 9 | 10 | 11 | React-Bootstrap 12 | 13 | 14 | 15 | 16 | 24 | 32 | 33 | 34 | ; 35 |
36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /src/components/ToDoItem.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import Form from 'react-bootstrap/Form'; 3 | import Button from 'react-bootstrap/Button'; 4 | import InputGroup from 'react-bootstrap/InputGroup'; 5 | import { useDispatch } from 'react-redux'; 6 | import { actions } from '../slices/tasksSlice'; 7 | 8 | const ToDoItem = ({ value }) => { 9 | const [removeButton, setRemoveButton] = useState(false); 10 | const { id, task, completed } = value; 11 | const [currentTask, setCurrentTask] = useState(task); 12 | 13 | const dispatch = useDispatch(); 14 | const handleCompleteTask = (taskId) => () => { 15 | dispatch(actions.updateTask({ id: taskId, changes: { completed: !completed } })); 16 | }; 17 | const handleChange = (event) => { 18 | setCurrentTask(event.target.value); 19 | }; 20 | const handleChangeTask = (taskId) => (event) => { 21 | dispatch(actions.updateTask({ id: taskId, changes: { task: event.target.value } })); 22 | }; 23 | const handleRemoveTask = (taskId) => () => { 24 | dispatch(actions.removeTask(taskId)); 25 | }; 26 | 27 | return ( 28 | setRemoveButton(true)} onMouseOut={() => setRemoveButton(false)}> 29 | 35 | 42 | {removeButton 43 | ? ( 44 | 47 | ) 48 | : null} 49 | 50 | ); 51 | }; 52 | 53 | export default ToDoItem; 54 | -------------------------------------------------------------------------------- /src/components/ToDoList.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { ListGroup } from 'react-bootstrap/esm'; 3 | import { useSelector } from 'react-redux'; 4 | import _uniqueId from 'lodash/uniqueId'; 5 | 6 | import { tasksSelector } from '../slices/tasksSlice'; 7 | import ToDoItem from './ToDoItem'; 8 | import { filterInfoSelector } from '../slices/filterInfoSlice'; 9 | 10 | const ToDoList = () => { 11 | const { nowShowing } = useSelector(filterInfoSelector); 12 | const tasksForRender = useSelector(tasksSelector.selectAll) 13 | .filter((task) => { 14 | switch (nowShowing) { 15 | case 'completed': 16 | return task.completed === true; 17 | case 'active': 18 | return task.completed === false; 19 | default: 20 | return true; 21 | } 22 | }); 23 | 24 | return ( 25 |
26 |
27 |
28 | 29 | {tasksForRender.map((task) => ( 30 | 31 | ))} 32 | 33 |
34 |
35 |
36 | 37 | ); 38 | }; 39 | 40 | export default ToDoList; 41 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import ReactDOM from 'react-dom/client'; 2 | 3 | import './index.css'; 4 | import init from './init'; 5 | 6 | const root = ReactDOM.createRoot(document.getElementById('root')); 7 | root.render(init()); 8 | -------------------------------------------------------------------------------- /src/init.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Provider } from 'react-redux'; 3 | import App from './components/App'; 4 | 5 | import store from './slices/index'; 6 | 7 | const init = () => ( 8 | 9 | 10 | 11 | 12 | 13 | ); 14 | 15 | export default init; 16 | -------------------------------------------------------------------------------- /src/reportWebVitals.js: -------------------------------------------------------------------------------- 1 | const reportWebVitals = (onPerfEntry) => { 2 | if (onPerfEntry && onPerfEntry instanceof Function) { 3 | import('web-vitals').then(({ 4 | getCLS, getFID, getFCP, getLCP, getTTFB, 5 | }) => { 6 | getCLS(onPerfEntry); 7 | getFID(onPerfEntry); 8 | getFCP(onPerfEntry); 9 | getLCP(onPerfEntry); 10 | getTTFB(onPerfEntry); 11 | }); 12 | } 13 | }; 14 | 15 | export default reportWebVitals; 16 | -------------------------------------------------------------------------------- /src/setupTests.js: -------------------------------------------------------------------------------- 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 | -------------------------------------------------------------------------------- /src/slices/filterInfoSlice.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-param-reassign */ 2 | import { createSlice } from '@reduxjs/toolkit'; 3 | 4 | const initialState = { nowShowing: 'all' }; 5 | 6 | const filterInfoSlice = createSlice({ 7 | name: 'filterInfo', 8 | initialState, 9 | reducers: { 10 | showAll: (state) => { 11 | state.nowShowing = 'all'; 12 | }, 13 | showCompleted: (state) => { 14 | state.nowShowing = 'completed'; 15 | }, 16 | showActive: (state) => { 17 | state.nowShowing = 'active'; 18 | }, 19 | }, 20 | }); 21 | 22 | export const filterInfoSelector = (state) => state.filterInfo; 23 | 24 | export const { actions } = filterInfoSlice; 25 | export default filterInfoSlice.reducer; 26 | -------------------------------------------------------------------------------- /src/slices/index.js: -------------------------------------------------------------------------------- 1 | import { configureStore } from '@reduxjs/toolkit'; 2 | import filterInfoSlice from './filterInfoSlice'; 3 | import tasksSlice from './tasksSlice'; 4 | 5 | const store = configureStore({ 6 | reducer: { 7 | tasks: tasksSlice, 8 | filterInfo: filterInfoSlice, 9 | }, 10 | }); 11 | 12 | export default store; 13 | -------------------------------------------------------------------------------- /src/slices/tasksSlice.js: -------------------------------------------------------------------------------- 1 | import { createEntityAdapter, createSlice } from '@reduxjs/toolkit'; 2 | 3 | const tasksAdapter = createEntityAdapter(); 4 | const initialState = tasksAdapter.getInitialState(); 5 | 6 | const tasksSlice = createSlice({ 7 | name: 'tasks', 8 | initialState, 9 | reducers: { 10 | addTask: tasksAdapter.addOne, 11 | removeTask: tasksAdapter.removeOne, 12 | updateTask: tasksAdapter.updateOne, 13 | removeAllCompleted: tasksAdapter.removeMany, 14 | }, 15 | }); 16 | 17 | export const tasksSelector = tasksAdapter.getSelectors((state) => state.tasks); 18 | export const selectCompletedTasks = (state) => tasksSelector 19 | .selectAll(state) 20 | .filter((task) => task.completed === true); 21 | export const selectActiveTasks = (state) => tasksSelector 22 | .selectAll(state) 23 | .filter((task) => task.completed === false); 24 | 25 | export const { actions } = tasksSlice; 26 | export default tasksSlice.reducer; 27 | -------------------------------------------------------------------------------- /tests/app.test.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { test, expect } from '@playwright/test'; 4 | 5 | test.describe('todo', () => { 6 | test.beforeEach(async ({ page }) => { 7 | await page.goto('http://localhost:3000'); 8 | await page.waitForTimeout(300); 9 | await page.locator('[aria-label="new task"]').first().type('hello'); 10 | await page.keyboard.press('Enter'); 11 | expect(await page.$('input[value="hello"]')).not.toBeNull(); 12 | }); 13 | 14 | test('add task', async ({ page }) => { 15 | await page.locator('[aria-label="new task"]').first().type('new task'); 16 | await page.keyboard.press('Enter'); 17 | expect(await page.$('input[value="new task"]')).not.toBeNull(); 18 | }); 19 | 20 | test('complete task', async ({ page }) => { 21 | await page.locator('.form-check-input').first().check(); 22 | expect(await page.$('input[disabled]')).not.toBeNull(); 23 | }); 24 | 25 | test('delete task', async ({ page }) => { 26 | await page.locator('input[value="hello"]').hover(); 27 | await page.locator('.btn-danger').first().click(); 28 | expect(await page.$('input[value="hello"]')).toBeNull(); 29 | }); 30 | 31 | test('update task', async ({ page }) => { 32 | await page.locator('input[value="hello"]').fill('new task'); 33 | expect(await page.$('input[value="new task"]')).not.toBeNull(); 34 | }); 35 | 36 | test('add task less 3 characters', async ({ page }) => { 37 | await page.locator('[aria-label="new task"]').first().type('ta'); 38 | await page.keyboard.press('Enter'); 39 | expect(await page.$('.invalid-feedback')).not.toBeNull(); 40 | }); 41 | 42 | test('tasks left', async ({ page }) => { 43 | await page.locator('[aria-label="new task"]').first().type('new task'); 44 | await page.keyboard.press('Enter'); 45 | await expect(page.locator('[aria-label="tasks left"]')).toContainText('2 items left'); 46 | }); 47 | 48 | test('clear completed', async ({ page }) => { 49 | await page.locator('[aria-label="new task"]').first().type('new task'); 50 | await page.keyboard.press('Enter'); 51 | 52 | await page.locator('.form-check-input').first().check(); 53 | await page.locator('.form-check-input').locator('nth=1').check(); 54 | 55 | expect(await page.$('input[value="hello"]')).not.toBeNull(); 56 | expect(await page.$('input[value="new task"]')).not.toBeNull(); 57 | 58 | await page.locator('text=Clear completed').first().click(); 59 | 60 | expect(await page.$('input[value="hello"]')).toBeNull(); 61 | expect(await page.$('input[value="new task"]')).toBeNull(); 62 | }); 63 | 64 | test('show only active tasks', async ({ page }) => { 65 | await page.locator('[aria-label="new task"]').first().type('new task'); 66 | await page.keyboard.press('Enter'); 67 | 68 | await page.locator('.form-check-input').first().check(); 69 | await page.locator('text=Active').first().click(); 70 | 71 | expect(await page.$('input[value="hello"]')).toBeNull(); 72 | }); 73 | 74 | test('show only completed tasks', async ({ page }) => { 75 | await page.locator('[aria-label="new task"]').first().type('new task'); 76 | await page.keyboard.press('Enter'); 77 | 78 | await page.locator('.form-check-input').first().check(); 79 | await page.locator('text=Completed').first().click(); 80 | 81 | expect(await page.$('input[value="hello"]')).not.toBeNull(); 82 | expect(await page.$('input[value="new task"]')).toBeNull(); 83 | }); 84 | 85 | test('show all tasks', async ({ page }) => { 86 | await page.locator('[aria-label="new task"]').first().type('new task'); 87 | await page.keyboard.press('Enter'); 88 | 89 | await page.locator('.form-check-input').first().check(); 90 | await page.locator('text=Completed').first().click(); 91 | 92 | expect(await page.$('input[value="hello"]')).not.toBeNull(); 93 | expect(await page.$('input[value="new task"]')).toBeNull(); 94 | 95 | await page.locator('text=All').first().click(); 96 | expect(await page.$('input[value="new task"]')).not.toBeNull(); 97 | }); 98 | }); 99 | -------------------------------------------------------------------------------- /tests/example.spec.js: -------------------------------------------------------------------------------- 1 | // // @ts-check 2 | // import { test, expect } from '@playwright/test'; 3 | 4 | 5 | 6 | // test('has title', async ({ page }) => { 7 | // await page.goto('https://playwright.dev/'); 8 | 9 | // // Expect a title "to contain" a substring. 10 | // await expect(page).toHaveTitle(/Playwright/); 11 | // }); 12 | 13 | // test('get started link', async ({ page }) => { 14 | // await page.goto('https://playwright.dev/'); 15 | 16 | // // Click the get started link. 17 | // await page.getByRole('link', { name: 'Get started' }).click(); 18 | 19 | // // Expects the URL to contain intro. 20 | // await expect(page).toHaveURL(/.*intro/); 21 | // }); 22 | --------------------------------------------------------------------------------