diff --git a/README.md b/README.md index 15dab0d..986ca45 100644 --- a/README.md +++ b/README.md @@ -7,3 +7,4 @@ It will be developed in phases, in several weeks, to illustrate the different fe * _Phase 1_: just static component rendering, and props-propagation. Uses 'fake' data and does not allow any user interaction (except one). * _Phase 2_: add a form and its management. * _Phase 3_: edit form and client-side routing. The application now has 3 different pages. +* _Phase 4_: add interaction with the server for all the operations. Basic error handling. diff --git a/package.json b/package.json index a73a591..caf7db0 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "test": "react-scripts test", "eject": "react-scripts eject" }, + "proxy": "http://localhost:3001", "eslintConfig": { "extends": [ "react-app", diff --git a/src/API.js b/src/API.js new file mode 100644 index 0000000..45cf5b6 --- /dev/null +++ b/src/API.js @@ -0,0 +1,94 @@ +/** + * All the API calls + */ +import Course from './models/Course'; +import Exam from './models/Exam'; + +const BASEURL = '/api'; + +async function getAllCourses() { + // call: GET /api/courses + const response = await fetch(BASEURL + '/courses'); + const coursesJson = await response.json(); + if (response.ok) { + return coursesJson.map((co) => Course.from(co)); + } else { + throw coursesJson; // an object with the error coming from the server + } +} + +async function getAllExams() { + // call: GET /api/exams + const response = await fetch(BASEURL + '/exams'); + const examsJson = await response.json(); + if (response.ok) { + return examsJson.map((ex) => Exam.from(ex)); + } else { + throw examsJson; // an object with the error coming from the server + } +} + +function deleteExam(coursecode) { + // call: DELETE /api/exams/:coursecode + return new Promise((resolve, reject) => { + fetch(BASEURL + '/exams/' + coursecode, { + method: 'DELETE', + }).then((response) => { + if (response.ok) { + resolve(null); + } else { + // analyze the cause of error + response.json() + .then((message) => { reject(message); }) // error message in the response body + .catch(() => { reject({ error: "Cannot parse server response." }) }); // something else + } + }).catch(() => { reject({ error: "Cannot communicate with the server." }) }); // connection errors + }); +} + +function addExam(exam) { + // call: POST /api/exams + return new Promise((resolve, reject) => { + fetch(BASEURL + '/exams', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({code: exam.coursecode, score: exam.score, date: exam.date}), + }).then((response) => { + if (response.ok) { + resolve(null); + } else { + // analyze the cause of error + response.json() + .then((message) => { reject(message); }) // error message in the response body + .catch(() => { reject({ error: "Cannot parse server response." }) }); // something else + } + }).catch(() => { reject({ error: "Cannot communicate with the server." }) }); // connection errors + }); +} + +function updateExam(exam) { + // call: PUT /api/exams/:coursecode + return new Promise((resolve, reject) => { + fetch(BASEURL + '/exams/' + exam.coursecode, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({code: exam.coursecode, score: exam.score, date: exam.date}), + }).then((response) => { + if (response.ok) { + resolve(null); + } else { + // analyze the cause of error + response.json() + .then((obj) => { reject(obj); }) // error message in the response body + .catch(() => { reject({ error: "Cannot parse server response." }) }); // something else + } + }).catch(() => { reject({ error: "Cannot communicate with the server." }) }); // connection errors + }); +} + +const API = {getAllCourses, getAllExams, deleteExam, addExam, updateExam}; +export default API; \ No newline at end of file diff --git a/src/App.js b/src/App.js index 7537a72..af8451a 100644 --- a/src/App.js +++ b/src/App.js @@ -1,55 +1,131 @@ import 'bootstrap/dist/css/bootstrap.min.css'; import './App.css'; -import { Container, Row } from 'react-bootstrap'; -import {ExamScores, ExamForm} from './ExamComponents.js'; +import { Container, Row, Alert } from 'react-bootstrap'; +import { ExamScores, ExamForm } from './ExamComponents.js'; import AppTitle from './AppTitle.js'; -import dayjs from 'dayjs'; -import { useState } from 'react'; -import {BrowserRouter as Router, Route, Switch} from 'react-router-dom'; - -const fakeExams = [ - {coursecode: '01TYMOV', score: 28, date: dayjs('2021-03-01')}, - {coursecode: '01SQJOV', score: 29, date: dayjs('2021-06-03')}, - {coursecode: '04GSPOV', score: 18, date: dayjs('2021-05-24')}, - {coursecode: '01TXYOV', score: 24, date: dayjs('2021-06-21')}, -]; - -const fakeCourses = [ - {coursecode: '01TYMOV', name: 'Information systems security'}, - {coursecode: '02LSEOV', name: 'Computer architectures'}, - {coursecode: '01SQJOV', name: 'Data Science and Database Technology'}, - {coursecode: '01OTWOV', name: 'Computer network technologies and services'}, - {coursecode: '04GSPOV', name: 'Software Engineering'}, - {coursecode: '01TXYOV', name: 'Web Applications I'}, - {coursecode: '01NYHOV', name: 'System and device programming'}, - {coursecode: '01TYDOV', name: 'Cloud Computing'}, - {coursecode: '01SQPOV', name: 'Software Networking'}, -]; +import { useEffect, useState } from 'react'; +import { BrowserRouter as Router, Route, Switch } from 'react-router-dom'; +import API from './API'; function App() { - const [exams, setExams] = useState([...fakeExams]); + const [exams, setExams] = useState([]); + const [courses, setCourses] = useState([]); + const [loading, setLoading] = useState(true); + const [dirty, setDirty] = useState(true); + const [errorMsg, setErrorMsg] = useState(''); - const examCodes = exams.map(exam => exam.coursecode) ; + useEffect(()=> { + const getCourses = async () => { + const courses = await API.getAllCourses(); + setCourses(courses); + }; + getCourses() + .catch(err => { + setErrorMsg("Impossible to load your exams! Please, try again later..."); + console.error(err); + });; + }, []); + + useEffect(()=> { + const getExams = async () => { + const exams = await API.getAllExams(); + setExams(exams); + }; + if(courses.length && dirty) { + getExams().then(() => { + setLoading(false); + setDirty(false); + }).catch(err => { + setErrorMsg("Impossible to load your exams! Please, try again later..."); + console.error(err); + });; + } + }, [courses.length, dirty]); + + /* With requests in parallel... + useEffect(() => { + const promises = [API.getAllCourses(), API.getAllExams()]; + Promise.all(promises).then( + ([cs, ex]) => { + setCourses(cs); + setExams(ex); + setLoading(false); + } + ).catch(err => { + setErrorMsg("Impossible to load your exams! Please, try again later..."); + console.error(err); + }); + }, []); + + useEffect(()=> { + const getExams = async () => { + const exams = await API.getAllExams(); + setExams(exams); + }; + if(courses.length && dirty) { + getExams().then(() => { + setDirty(false); + }).catch(err => { + setErrorMsg("Impossible to load your exams! Please, try again later..."); + console.error(err); + }); + } + }, [dirty]); */ + + const handleErrors = (err) => { + if(err.errors) + setErrorMsg(err.errors[0].msg + ': ' + err.errors[0].param); + else + setErrorMsg(err.error); + + setDirty(true); + } const deleteExam = (coursecode) => { - setExams((exs) => exs.filter(ex => ex.coursecode !== coursecode)) + // temporary set the deleted item as "in progress" + setExams(oldExams => { + return oldExams.map(ex => { + if (ex.coursecode === coursecode) + return {...ex, status: 'deleted'}; + else + return ex; + }); + }); + + API.deleteExam(coursecode) + .then(() => { + setDirty(true); + }).catch(err => handleErrors(err) ); } const addExam = (exam) => { + exam.status = 'added'; setExams(oldExams => [...oldExams, exam]); + + API.addExam(exam) + .then(() => { + setDirty(true); + }).catch(err => handleErrors(err) ); } const updateExam = (exam) => { setExams(oldExams => { return oldExams.map(ex => { if (ex.coursecode === exam.coursecode) - return {coursecode: exam.coursecode, score: exam.score, date: exam.date}; + return {coursecode: exam.coursecode, score: exam.score, date: exam.date, status: 'updated'}; else return ex; }); }); + + API.updateExam(exam) + .then(() => { + setDirty(true); + }).catch(err => handleErrors(err) );; } + const examCodes = exams.map(exam => exam.coursecode); + return ( @@ -58,23 +134,29 @@ function App() { - !examCodes.includes(course.coursecode))} addOrUpdateExam={addExam}> + !examCodes.includes(course.coursecode))} addOrUpdateExam={addExam}> }/> {/* without useLocation(): - + }/> */} {/* with useLocation() in ExamForm */} - + }/> - + + <> + + {errorMsg && setErrorMsg('')} dismissible>{errorMsg}} + - + {loading ? 🕗 Please wait, loading your exams... 🕗 : + } + } /> diff --git a/src/ExamComponents.js b/src/ExamComponents.js index 88baed8..3ca67c2 100644 --- a/src/ExamComponents.js +++ b/src/ExamComponents.js @@ -41,14 +41,32 @@ function ExamTable(props) { } function ExamRow(props) { - return + let statusClass = null; + + switch(props.exam.status) { + case 'added': + statusClass = 'table-success'; + break; + case 'deleted': + statusClass = 'table-danger'; + break; + case 'updated': + statusClass = 'table-warning'; + break; + default: + break; + } + + return { !props.exam.status ? : } } function ExamRowData(props) { + + return <> {props.examName} {props.exam.score} - {props.exam.date.format('DD MMM YYYY')} + {dayjs(props.exam.date).format('DD MMM YYYY')} ; } @@ -56,7 +74,7 @@ function RowControls(props) { return {iconEdit} { props.deleteExam(props.exam.coursecode) }}>{iconDelete} @@ -66,13 +84,13 @@ function ExamForm(props) { const location = useLocation(); const [course, setCourse] = useState(location.state ? location.state.exam.coursecode : ''); const [score, setScore] = useState(location.state ? location.state.exam.score : 30); - const [date, setDate] = useState(location.state ? location.state.examDate : dayjs().format('YYYY-MM-DD')); + const [date, setDate] = useState(location.state ? location.state.exam.date : dayjs().format('YYYY-MM-DD')); const [errorMessage, setErrorMessage] = useState('') ; const [submitted, setSubmitted] = useState(false); const handleSubmit = (event) => { event.preventDefault(); - const exam = { coursecode: course, score: score, date: dayjs(date) }; + const exam = { coursecode: course, score: score, date: dayjs(date).format('YYYY-MM-DD') }; // SOME VALIDATION, ADD MORE!!! let valid = true; diff --git a/src/models/Course.js b/src/models/Course.js new file mode 100644 index 0000000..3c06c24 --- /dev/null +++ b/src/models/Course.js @@ -0,0 +1,31 @@ +/** + * Object describing a course + */ + class Course { + /** + * Create a new Course + * @param {*} coursecode unique code for the course + * @param {*} name full name of the course + * @param {*} CFU number of CFU credits + */ + constructor(coursecode, name, CFU) { + this.coursecode = coursecode; + this.name = name; + this.CFU = CFU; + } + + /** + * Creates a new Course from plain (JSON) objects + * @param {*} json a plain object (coming form JSON deserialization) + * with the right properties + * @return {Course} the newly created object + */ + static from(json) { + const course = new Course(); + delete Object.assign(course, json, {coursecode: json.code}).code; + return course; + } + +} + +export default Course; \ No newline at end of file diff --git a/src/models/Exam.js b/src/models/Exam.js new file mode 100644 index 0000000..b68cd4e --- /dev/null +++ b/src/models/Exam.js @@ -0,0 +1,27 @@ +/** + * Information about an exam being passed + */ + class Exam { + /** + * Constructs a new Exam object + * @param {String} coursecode unique code for the course being passed + * @param {Number} score achieved score (18..31) + * @param {Date} date date of the exam + */ + constructor(coursecode, score, date) { + this.coursecode = coursecode; + this.score = score; + this.date = date; + } + + /** + * Construct an Exam from a plain object + * @param {{}} json + * @return {Exam} the newly created Exam object + */ + static from(json) { + return new Exam(json.code, json.score, json.date); + } +} + +export default Exam; \ No newline at end of file