diff --git a/.gitignore b/.gitignore index b0d57997..3a1b5fa4 100644 --- a/.gitignore +++ b/.gitignore @@ -121,4 +121,5 @@ dist **/*.backup.* **/*.back.* -node_modules \ No newline at end of file +node_modules +package-lock.json \ No newline at end of file diff --git a/.prettierrc b/.prettierrc index 8c4b2fa1..f85defc5 100644 --- a/.prettierrc +++ b/.prettierrc @@ -15,5 +15,5 @@ "tabWidth": 2, "trailingComma": "all", "useTabs": false, - "endOfLine": "lf" + "endOfLine": "auto" } diff --git a/package.json b/package.json index 326aeb06..b62ae771 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@testing-library/react": "^11.2.6", "@testing-library/user-event": "^13.1.5", "bootstrap": "^4.6.0", + "formik": "^2.2.7", "prop-types": "^15.7.2", "react": "^17.0.2", "react-dom": "^17.0.2", @@ -25,7 +26,9 @@ "redux": "^4.0.5", "redux-thunk": "^2.3.0", "sass": "^1.32.11", - "web-vitals": "^1.1.1" + "uuid": "^8.3.2", + "web-vitals": "^1.1.1", + "yup": "^0.32.9" }, "devDependencies": { "@babel/core": "^7.13.16", diff --git a/public/img/enter.png b/public/img/enter.png new file mode 100644 index 00000000..55512d6f Binary files /dev/null and b/public/img/enter.png differ diff --git a/public/img/fail.png b/public/img/fail.png new file mode 100644 index 00000000..34ef5ee9 Binary files /dev/null and b/public/img/fail.png differ diff --git a/public/img/moon.png b/public/img/moon.png new file mode 100644 index 00000000..a677e5fa Binary files /dev/null and b/public/img/moon.png differ diff --git a/public/img/sun.png b/public/img/sun.png new file mode 100644 index 00000000..684e2f0e Binary files /dev/null and b/public/img/sun.png differ diff --git a/src/App.js b/src/App.js index de524524..fb403445 100644 --- a/src/App.js +++ b/src/App.js @@ -1,15 +1,220 @@ -import React from "react"; - -function App() { - return ( -
-
-
-

Hola mundo

+import React, { Component } from "react"; +import { BrowserRouter, Route } from "react-router-dom"; + +import Home from "./pages/Home"; +import Completed from "./pages/Completed"; +import Active from "./pages/Active"; + +import Main from "./components/Main"; +import Header from "./components/Header"; +import NewTask from "./components/NewTask"; +import ListFooter from "./components/ListFooter"; + +import * as api from "./api"; + +const LOCAL_STORAGE_KEY = "react-tasks-state"; + +function loadLocalStorageData() { + const prevItems = localStorage.getItem(LOCAL_STORAGE_KEY); + + if (!prevItems) { + return null; + } + + try { + return JSON.parse(prevItems); + } catch (error) { + return null; + } +} + +class App extends Component { + constructor(props) { + super(props); + + this.state = { + tasks: [], + isDark: false, + }; + + this.saveNewTask = this.saveNewTask.bind(this); + this.toggleDarkLightMode = this.toggleDarkLightMode.bind(this); + this.handleDeleteTask = this.handleDeleteTask.bind(this); + this.handleClearCompleted = this.handleClearCompleted.bind(this); + this.handleUpdateTask = this.handleUpdateTask.bind(this); + this.handleToggleEditing = this.handleToggleEditing.bind(this); + this.handleToggleCheck = this.handleToggleCheck.bind(this); + } + + componentDidMount() { + const prevItems = loadLocalStorageData(); + + if (!prevItems) { + api.getTasks().then((data) => { + this.setState({ + tasks: data, + }); + }); + return; + } + + this.setState({ + tasks: prevItems.tasks, + }); + } + + componentDidUpdate() { + const { tasks } = this.state; + + localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify({ tasks })); + } + + handleDeleteTask(event, taskId) { + const { tasks } = this.state; + + const updatedTasks = tasks.filter((task) => task.id !== taskId); + + this.setState({ tasks: updatedTasks }); + } + + handleClearCompleted() { + const { tasks } = this.state; + + const updatedTasks = tasks.filter((task) => !task.isCompleted); + + this.setState({ tasks: updatedTasks }); + } + + handleToggleCheck(taskId) { + const { tasks } = this.state; + + const updatedTasks = tasks.map((task) => { + if (task.id === taskId) { + return { + ...task, + isCompleted: !task.isCompleted, + }; + } + return task; + }); + + this.setState({ tasks: updatedTasks }); + } + + handleToggleEditing(taskId) { + const { tasks } = this.state; + + const updatedTasks = tasks.map((task) => { + if (task.id === taskId) { + return { + ...task, + isEditing: true, + }; + } + + return task; + }); + + this.setState({ tasks: updatedTasks }); + } + + handleUpdateTask(data, taskId) { + const { tasks } = this.state; + + const updatedTasks = tasks.map((task) => { + if (task.id === taskId && data.title !== task.title) { + return { + ...task, + title: data.title, + isEditing: false, + }; + } + return { + ...task, + isEditing: false, + }; + }); + + this.setState({ tasks: updatedTasks }); + } + + saveNewTask(newTask) { + this.setState((prevState) => ({ + tasks: [newTask, ...prevState.tasks], + })); + } + + toggleDarkLightMode() { + const { isDark } = this.state; + if (isDark === true) this.setState({ isDark: false }); + else this.setState({ isDark: true }); + } + + render() { + const { tasks, isDark } = this.state; + + return ( +
+
+
+ +
+
+ + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + !task.isCompleted).length} + /> +
-
-
- ); + + ); + } } export default App; diff --git a/src/api/getTasks.js b/src/api/getTasks.js new file mode 100644 index 00000000..8114f268 --- /dev/null +++ b/src/api/getTasks.js @@ -0,0 +1,15 @@ +import tasks from "../utils/demo-data"; + +function getTasks(fail = false) { + return new Promise((res, rej) => { + setTimeout(() => { + if (fail) { + rej(new Error("Failed to fetch")); + } + + res(tasks); + }, 1000); + }); +} + +export { getTasks }; diff --git a/src/api/index.js b/src/api/index.js new file mode 100644 index 00000000..cf547600 --- /dev/null +++ b/src/api/index.js @@ -0,0 +1 @@ +export * from "./getTasks"; diff --git a/src/components/Checkbox/Checkbox.js b/src/components/Checkbox/Checkbox.js new file mode 100644 index 00000000..a26fb91a --- /dev/null +++ b/src/components/Checkbox/Checkbox.js @@ -0,0 +1,41 @@ +import React, { Component } from "react"; + +import "./Checkbox.scss"; + +class Checkbox extends Component { + constructor(props) { + super(props); + this.state = { + isChecked: props.defaultChecked, + taskId: props.taskId, + }; + this.toggleChange = this.toggleChange.bind(this); + this.handleToggleCheck = props.handleToggleCheck; + } + + toggleChange() { + this.setState((prevState) => ({ + isChecked: !prevState.isChecked, + })); + const { taskId } = this.state; + this.handleToggleCheck(taskId); + } + + render() { + const { taskId, isChecked } = this.state; + return ( +
+ + {/* eslint-disable-next-line */} +
+ ); + } +} + +export default Checkbox; diff --git a/src/components/Checkbox/Checkbox.scss b/src/components/Checkbox/Checkbox.scss new file mode 100644 index 00000000..e0886492 --- /dev/null +++ b/src/components/Checkbox/Checkbox.scss @@ -0,0 +1,49 @@ +.round-checkbox { + position: relative; + & input[type="checkbox"]:not(:checked) + label:hover { + border: 4px solid; + border-color: rgb(66, 126, 179); + } + & input[type="checkbox"]:checked + label:hover { + border: 1px solid; + border-color: rgb(20, 71, 116); + } + & label { + background-color: #fff; + border: 1px solid #ccc; + border-radius: 50%; + cursor: pointer; + height: 28px; + left: 0; + position: absolute; + top: 0; + width: 28px; + &:after { + border: 2px solid #fff; + border-top: none; + border-right: none; + content: ""; + height: 6px; + left: 7px; + opacity: 0; + position: absolute; + top: 8px; + transform: rotate(-45deg); + width: 12px; + } + } + & input[type="checkbox"] { + visibility: hidden; + } + & input[type="checkbox"]:checked + label { + background: linear-gradient( + -45deg, + rgb(149, 184, 214), + rgb(66, 126, 179), + white + ); + } + & input[type="checkbox"]:checked + label:after { + opacity: 1; + } +} diff --git a/src/components/Checkbox/index.js b/src/components/Checkbox/index.js new file mode 100644 index 00000000..a936c852 --- /dev/null +++ b/src/components/Checkbox/index.js @@ -0,0 +1 @@ +export { default } from "./Checkbox"; diff --git a/src/components/Header/Header.js b/src/components/Header/Header.js new file mode 100644 index 00000000..86885b28 --- /dev/null +++ b/src/components/Header/Header.js @@ -0,0 +1,26 @@ +import React from "react"; + +import "./Header.scss"; + +const MOON_PATH = "./img/moon.png"; +const SUN_PATH = "./img/sun.png"; + +function Header({ toggleDarkLightMode, isDark }) { + let mode; + if (isDark === true) mode = Sun; + else mode = Moon; + return ( +
+
T O D O
+ +
+ ); +} + +export default Header; diff --git a/src/components/Header/Header.scss b/src/components/Header/Header.scss new file mode 100644 index 00000000..eed4309f --- /dev/null +++ b/src/components/Header/Header.scss @@ -0,0 +1,22 @@ +.header-logo { + height: 70px; + margin-bottom: 30px; +} + +.without-border { + border: 0; +} + +.without-background { + background-color: transparent; +} + +.icon { + width: 30px; + height: 30px; +} + +.logo-todo { + font-size: 45px; + font-weight: 700; +} diff --git a/src/components/Header/index.js b/src/components/Header/index.js new file mode 100644 index 00000000..2764567d --- /dev/null +++ b/src/components/Header/index.js @@ -0,0 +1 @@ +export { default } from "./Header"; diff --git a/src/components/ListFooter/ListFooter.js b/src/components/ListFooter/ListFooter.js new file mode 100644 index 00000000..fe1b2ff6 --- /dev/null +++ b/src/components/ListFooter/ListFooter.js @@ -0,0 +1,36 @@ +import React from "react"; +import { NavLink } from "react-router-dom"; + +import "./ListFooter.scss"; + +function ListFooter({ handleClearCompleted, tasksLeft }) { + return ( + + ); +} + +export default ListFooter; diff --git a/src/components/ListFooter/ListFooter.scss b/src/components/ListFooter/ListFooter.scss new file mode 100644 index 00000000..0a339836 --- /dev/null +++ b/src/components/ListFooter/ListFooter.scss @@ -0,0 +1,37 @@ +.list-footer { + display: inline-flex; + align-items: center; + color: grey; + font-size: small; + padding: 10px; + width: 100%; + margin-right: auto; + margin-left: auto; + & .filter-panel { + display: flex; + flex-flow: row; + align-items: center; + justify-content: space-between; + & .is-active { + color: #007bff; + font-weight: bold; + } + & > a { + display: flex; + justify-content: center; + margin: auto 10px auto 10px; + color: grey; + &:hover { + color: rgb(16, 16, 16); + font-weight: bold; + text-decoration: none; + } + } + } + & > button { + color: grey; + &:hover { + font-weight: bold; + } + } +} diff --git a/src/components/ListFooter/index.js b/src/components/ListFooter/index.js new file mode 100644 index 00000000..f3a48d9f --- /dev/null +++ b/src/components/ListFooter/index.js @@ -0,0 +1 @@ +export { default } from "./ListFooter"; diff --git a/src/components/Main/Main.js b/src/components/Main/Main.js new file mode 100644 index 00000000..e39c8f63 --- /dev/null +++ b/src/components/Main/Main.js @@ -0,0 +1,12 @@ +import React from "react"; +import "./Main.scss"; + +function Main({ children, ...props }) { + return ( +
+ {children} +
+ ); +} + +export default Main; diff --git a/src/components/Main/Main.scss b/src/components/Main/Main.scss new file mode 100644 index 00000000..9874809f --- /dev/null +++ b/src/components/Main/Main.scss @@ -0,0 +1,53 @@ +#root { + background-image: url("../../img/mainBg.png"); + background-repeat: no-repeat; + background-size: contain; +} +main { + height: 85vh; +} +.list-header { + height: 20vh; + margin-bottom: 2vh; +} +.main-list { + padding-top: 7vh; + padding-bottom: 5vh; + margin: 0 auto; + width: 90vw; + max-width: 800px; +} + +.camouflaged-button { + background-color: transparent; + border: 0; +} + +.list-container { + box-shadow: rgba(16, 16, 16, 0.6) 0 8px 24px; + border-radius: 5px; + background-color: white; + & > ul { + overflow-y: auto; + max-height: 60vh; + &::-webkit-scrollbar { + border-radius: 5px; + } + &::-webkit-scrollbar-track { + -webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3); + border-radius: 5px; + background-color: #f5f5f5; + } + + &::-webkit-scrollbar { + width: 12px; + background-color: #f5f5f5; + } + + &::-webkit-scrollbar-thumb { + border-radius: 5px; + -webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3); + background-color: rgb(149, 184, 214); + } + } +} diff --git a/src/components/Main/index.js b/src/components/Main/index.js new file mode 100644 index 00000000..0fdf55f5 --- /dev/null +++ b/src/components/Main/index.js @@ -0,0 +1 @@ +export { default } from "./Main"; diff --git a/src/components/NewTask/NewTask.js b/src/components/NewTask/NewTask.js new file mode 100644 index 00000000..a99f02d2 --- /dev/null +++ b/src/components/NewTask/NewTask.js @@ -0,0 +1,100 @@ +import React from "react"; +import { v4 as uuid } from "uuid"; +import { Formik } from "formik"; + +import taskSchema from "./task-schema"; + +import "./NewTask.scss"; +import "../Checkbox/Checkbox.scss"; + +function addDetailsNewTask(data) { + return { + id: uuid(), + ...data, + isEditing: false, + }; +} + +function NewTask({ saveNewTask }) { + return ( + <> + { + onSubmitProps.resetForm({}); + const newTask = addDetailsNewTask(values); + saveNewTask(newTask); + }} + > + {({ + handleChange, + handleBlur, + handleSubmit, + errors, + values, + touched, + }) => ( +
+
+
+ + {/* eslint-disable-next-line */} +
+
+
+ + {touched.title && errors.title && ( +

{errors.title}

+ )} + +
+
+ )} +
+ + ); +} + +export default NewTask; diff --git a/src/components/NewTask/NewTask.scss b/src/components/NewTask/NewTask.scss new file mode 100644 index 00000000..47193496 --- /dev/null +++ b/src/components/NewTask/NewTask.scss @@ -0,0 +1,50 @@ +.form-wrapper { + box-shadow: rgb(16 16 16 / 60%) 0 8px 30px; + border-radius: 8px; + background-color: white; +} + +.isCompleted-wrapper { + height: 75px; + width: 75px; +} + +.check-new-task { + position: relative; + left: -14px; + top: -14px; +} + +.text-input-wrapper { + height: 40px; + width: 100%; + padding-right: 15px; +} + +.text-input { + width: 100%; + border: none; +} + +.text-input:focus { + outline: none; +} + +.text-input-failed { + font-size: 13px; + color: red; + text-align: center; + margin: 0; +} + +.submit-new-task { + height: fit-content; + width: fit-content; + border: none; + background-color: transparent; +} + +.icon-submit { + height: 20px; + width: 20px; +} diff --git a/src/components/NewTask/index.js b/src/components/NewTask/index.js new file mode 100644 index 00000000..68b03a31 --- /dev/null +++ b/src/components/NewTask/index.js @@ -0,0 +1 @@ +export { default } from "./NewTask"; diff --git a/src/components/NewTask/task-schema.js b/src/components/NewTask/task-schema.js new file mode 100644 index 00000000..8e05c964 --- /dev/null +++ b/src/components/NewTask/task-schema.js @@ -0,0 +1,10 @@ +import * as Yup from "yup"; + +const taskSchema = Yup.object().shape({ + title: Yup.string() + .min(2, "The task description is too short!") + .max(50, "The task description is too long!") + .required("A task description is required"), +}); + +export default taskSchema; diff --git a/src/components/TaskUpdater/TaskUpdater.js b/src/components/TaskUpdater/TaskUpdater.js new file mode 100644 index 00000000..33e5d091 --- /dev/null +++ b/src/components/TaskUpdater/TaskUpdater.js @@ -0,0 +1,56 @@ +import React from "react"; + +import "./TaskUpdater.scss"; +import { Formik } from "formik"; +import taskSchema from "./task-schema"; + +function TaskUpdater({ id, title, handleUpdateTask }) { + function onHandleUpdateTask(values) { + handleUpdateTask(values, id); + } + return ( + <> + { + onHandleUpdateTask(values); + }} + > + {({ + handleChange, + handleBlur, + handleSubmit, + errors, + values, + touched, + }) => ( +
+
+ + {touched.title && errors.title && ( +

{errors.title}

+ )} +
+
+ )} +
+ + ); +} + +export default TaskUpdater; diff --git a/src/components/TaskUpdater/TaskUpdater.scss b/src/components/TaskUpdater/TaskUpdater.scss new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/src/components/TaskUpdater/TaskUpdater.scss @@ -0,0 +1 @@ + diff --git a/src/components/TaskUpdater/index.js b/src/components/TaskUpdater/index.js new file mode 100644 index 00000000..0d4c864b --- /dev/null +++ b/src/components/TaskUpdater/index.js @@ -0,0 +1 @@ +export { default } from "./TaskUpdater"; diff --git a/src/components/TaskUpdater/task-schema.js b/src/components/TaskUpdater/task-schema.js new file mode 100644 index 00000000..8e05c964 --- /dev/null +++ b/src/components/TaskUpdater/task-schema.js @@ -0,0 +1,10 @@ +import * as Yup from "yup"; + +const taskSchema = Yup.object().shape({ + title: Yup.string() + .min(2, "The task description is too short!") + .max(50, "The task description is too long!") + .required("A task description is required"), +}); + +export default taskSchema; diff --git a/src/components/TasksList/TasksList.js b/src/components/TasksList/TasksList.js new file mode 100644 index 00000000..dbfb0f18 --- /dev/null +++ b/src/components/TasksList/TasksList.js @@ -0,0 +1,41 @@ +import React from "react"; + +import TasksListEntry from "../TasksListEntry"; + +function TasksList({ + tasks, + noTasksMessage, + handleDeleteTask, + handleUpdateTask, + handleToggleEditing, + handleToggleCheck, + ...props +}) { + return ( + + ); +} + +export default TasksList; diff --git a/src/components/TasksList/index.js b/src/components/TasksList/index.js new file mode 100644 index 00000000..7be9e6ea --- /dev/null +++ b/src/components/TasksList/index.js @@ -0,0 +1 @@ +export { default } from "./TasksList"; diff --git a/src/components/TasksListEntry/TasksListEntry.js b/src/components/TasksListEntry/TasksListEntry.js new file mode 100644 index 00000000..4929e1f9 --- /dev/null +++ b/src/components/TasksListEntry/TasksListEntry.js @@ -0,0 +1,68 @@ +import React from "react"; + +import "./TasksListEntry.scss"; +import Checkbox from "../Checkbox"; +import TaskUpdater from "../TaskUpdater"; + +function TasksListEntry({ + id, + title, + isCompleted, + editing, + handleDeleteTask, + handleUpdateTask, + handleToggleEditing, + handleToggleCheck, + ...props +}) { + function onHandleDeleteTask(event) { + handleDeleteTask(event, id); + } + function onHandleUpdateTask(event) { + handleUpdateTask(event, id); + } + function onHandleToggleEditing() { + handleToggleEditing(id); + } + + return ( +
  • + +
    + {editing ? ( + + ) : ( + + )} +
    + +
  • + ); +} + +export default TasksListEntry; diff --git a/src/components/TasksListEntry/TasksListEntry.scss b/src/components/TasksListEntry/TasksListEntry.scss new file mode 100644 index 00000000..0f987c73 --- /dev/null +++ b/src/components/TasksListEntry/TasksListEntry.scss @@ -0,0 +1,18 @@ +.entry { + display: inline-flex; + align-items: center; + justify-content: space-between; + padding: 10px 20px; + border-bottom: 1px solid slategray; + flex-basis: 100%; + & > button > span { + visibility: hidden; + vertical-align: text-top; + } + + &:hover { + & > button > span { + visibility: visible; + } + } +} diff --git a/src/components/TasksListEntry/index.js b/src/components/TasksListEntry/index.js new file mode 100644 index 00000000..40974353 --- /dev/null +++ b/src/components/TasksListEntry/index.js @@ -0,0 +1 @@ +export { default } from "./TasksListEntry"; diff --git a/src/img/mainBg.png b/src/img/mainBg.png new file mode 100644 index 00000000..b0531b33 Binary files /dev/null and b/src/img/mainBg.png differ diff --git a/src/pages/Active.js b/src/pages/Active.js new file mode 100644 index 00000000..540370d0 --- /dev/null +++ b/src/pages/Active.js @@ -0,0 +1,25 @@ +import React from "react"; +import TasksList from "../components/TasksList"; + +function Active({ + tasks, + handleDeleteTask, + handleUpdateTask, + handleToggleEditing, + handleToggleCheck, +}) { + return ( + + ); +} + +export default Active; diff --git a/src/pages/Completed.js b/src/pages/Completed.js new file mode 100644 index 00000000..9e005b1d --- /dev/null +++ b/src/pages/Completed.js @@ -0,0 +1,25 @@ +import React from "react"; +import TasksList from "../components/TasksList"; + +function Completed({ + tasks, + handleDeleteTask, + handleUpdateTask, + handleToggleEditing, + handleToggleCheck, +}) { + return ( + + ); +} + +export default Completed; diff --git a/src/pages/Home.js b/src/pages/Home.js new file mode 100644 index 00000000..a7871907 --- /dev/null +++ b/src/pages/Home.js @@ -0,0 +1,25 @@ +import React from "react"; +import TasksList from "../components/TasksList"; + +function Home({ + tasks, + handleDeleteTask, + handleUpdateTask, + handleToggleEditing, + handleToggleCheck, +}) { + return ( + + ); +} + +export default Home; diff --git a/src/pages/index.js b/src/pages/index.js new file mode 100644 index 00000000..ffa79319 --- /dev/null +++ b/src/pages/index.js @@ -0,0 +1 @@ +export { default } from "./Home"; diff --git a/src/utils/demo-data.js b/src/utils/demo-data.js new file mode 100644 index 00000000..0f973a42 --- /dev/null +++ b/src/utils/demo-data.js @@ -0,0 +1,40 @@ +const tasks = [ + { + id: "46644f66-e173-46be-9253-b4072aeba5d0", + title: "Task 1", + isCompleted: true, + isEditing: false, + }, + { + id: "a889f6da-a16a-4e1f-88f5-2b64d7f33907", + title: "Task 2", + isCompleted: false, + isEditing: false, + }, + { + id: "77378e2c-9ae8-40be-beb3-b3635eb9decb", + title: "Task 3", + isCompleted: false, + isEditing: false, + }, + { + id: "56ffac71-460c-4c2c-8be2-a0c69d273d69", + title: "Task 4", + isCompleted: true, + isEditing: false, + }, + { + id: "026e8de4-67ce-4122-90e3-20666a8891b5", + title: "Task 5", + isCompleted: false, + isEditing: false, + }, + { + id: "a4d1342b-bcb4-48cf-a340-1a00a2355ad6", + title: "Task 6", + isCompleted: true, + isEditing: false, + }, +]; + +export default tasks;