├── .gitignore ├── README.md ├── part0 ├── 0.4_new_note.png ├── 0.5_get_single_page_app.png ├── 0.6_single_page_app_new_note.png └── README.md ├── part1 ├── README.md ├── anecdotes │ ├── README.md │ ├── package.json │ ├── pnpm-lock.yaml │ ├── public │ │ ├── favicon.ico │ │ ├── index.html │ │ ├── logo192.png │ │ ├── logo512.png │ │ ├── manifest.json │ │ └── robots.txt │ └── src │ │ ├── index.css │ │ ├── index.js │ │ └── setupTests.js ├── courseinfo │ ├── README.md │ ├── package.json │ ├── pnpm-lock.yaml │ ├── public │ │ ├── favicon.ico │ │ ├── index.html │ │ ├── logo192.png │ │ ├── logo512.png │ │ ├── manifest.json │ │ └── robots.txt │ └── src │ │ ├── index.css │ │ ├── index.js │ │ └── setupTests.js └── unicafe │ ├── README.md │ ├── package.json │ ├── pnpm-lock.yaml │ ├── public │ ├── favicon.ico │ ├── index.html │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ └── robots.txt │ └── src │ ├── index.css │ ├── index.js │ └── setupTests.js ├── part2 ├── README.md ├── countries │ ├── .env.example │ ├── README.md │ ├── package.json │ ├── pnpm-lock.yaml │ ├── public │ │ ├── favicon.ico │ │ ├── index.html │ │ ├── logo192.png │ │ ├── logo512.png │ │ ├── manifest.json │ │ └── robots.txt │ └── src │ │ ├── App.js │ │ ├── components │ │ ├── Countries.js │ │ ├── Country.js │ │ ├── Filter.js │ │ ├── Weather.js │ │ └── WeatherInfo.js │ │ ├── index.js │ │ └── setupTests.js ├── course-contents │ ├── README.md │ ├── package.json │ ├── pnpm-lock.yaml │ ├── public │ │ ├── favicon.ico │ │ ├── index.html │ │ ├── logo192.png │ │ ├── logo512.png │ │ ├── manifest.json │ │ └── robots.txt │ └── src │ │ ├── components │ │ └── Course.js │ │ ├── index.css │ │ ├── index.js │ │ └── setupTests.js └── phonebook │ ├── README.md │ ├── db.json │ ├── package.json │ ├── pnpm-lock.yaml │ ├── public │ ├── favicon.ico │ ├── index.html │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ └── robots.txt │ ├── routes.json │ └── src │ ├── App.js │ ├── components │ ├── Alert.js │ ├── Filter.js │ ├── PersonForm.js │ └── Persons.js │ ├── index.css │ ├── index.js │ ├── services │ └── persons.js │ └── setupTests.js ├── part4 └── bloglist-backend │ ├── .env.example │ ├── .eslintignore │ ├── .eslintrc.js │ ├── README.md │ ├── app.js │ ├── controllers │ ├── blogs.js │ ├── login.js │ ├── testing.js │ └── users.js │ ├── index.js │ ├── jest.config.js │ ├── models │ ├── blog.js │ └── user.js │ ├── package.json │ ├── pnpm-lock.yaml │ ├── tests │ ├── blogs_api.int.test.js │ ├── list_helper.test.js │ ├── mockDb_helper.js │ └── test_helper.js │ └── utils │ ├── config.js │ ├── db_helper.js │ ├── error_helper.js │ ├── list_helper.js │ ├── logger.js │ └── middleware.js ├── part5 ├── README.md └── bloglist-frontend │ ├── .eslintignore │ ├── .eslintrc.js │ ├── README.md │ ├── cypress.json │ ├── cypress │ ├── fixtures │ │ ├── blogs.json │ │ └── users.json │ ├── integration │ │ └── App.spec.js │ ├── plugins │ │ └── index.js │ └── support │ │ ├── commands.js │ │ ├── index.js │ │ └── utils.js │ ├── package.json │ ├── pnpm-lock.yaml │ ├── public │ ├── favicon.ico │ ├── index.html │ └── manifest.json │ └── src │ ├── App.js │ ├── App.test.js │ ├── UserContext.js │ ├── components │ ├── Alert.js │ ├── AlertList.js │ ├── Blog.js │ ├── Blog.test.js │ ├── BlogForm.js │ ├── BlogForm.test.js │ ├── BlogList.js │ ├── Login.js │ ├── Modal.js │ ├── ModalSpinner.js │ ├── NavBar.js │ ├── ToTopScroller.js │ └── Toggleable.js │ ├── fonts │ ├── Dosis-Light.woff2 │ ├── Dosis-Medium.woff2 │ ├── Roboto-Bold-webfont.woff │ └── Roboto-Regular-webfont.woff │ ├── helpers │ └── testHelper.js │ ├── hooks │ └── index.js │ ├── index.css │ ├── index.js │ ├── services │ ├── __mocks__ │ │ ├── blogs.js │ │ └── login.js │ ├── blogs.js │ └── login.js │ └── setupTests.js ├── part6 ├── README.md ├── redux-anecdotes │ ├── .eslintignore │ ├── .eslintrc.js │ ├── README.md │ ├── db.json │ ├── package.json │ ├── pnpm-lock.yaml │ ├── public │ │ ├── favicon.ico │ │ ├── index.html │ │ └── manifest.json │ └── src │ │ ├── App.js │ │ ├── components │ │ ├── AnecdoteForm.js │ │ ├── AnecdoteList.js │ │ ├── Filter.js │ │ ├── Modal.js │ │ ├── ModalSpinner.js │ │ ├── Notification.js │ │ ├── ProgressBar.js │ │ └── StyledComponents.js │ │ ├── fonts │ │ ├── FlexoW01Regular.woff │ │ └── FlexoW01Regular.woff2 │ │ ├── helpers │ │ └── helper.js │ │ ├── index.js │ │ ├── reducers │ │ ├── anecdoteReducer.js │ │ ├── anecdoteReducer.test.js │ │ ├── filterReducer.js │ │ ├── filterReducer.test.js │ │ ├── notificationReducer.js │ │ ├── notificationReducer.test.js │ │ ├── requestReducer.js │ │ └── requestReducer.test.js │ │ ├── services │ │ └── anecdotes.js │ │ └── store.js └── unicafe-redux │ ├── .eslintignore │ ├── .eslintrc.js │ ├── README.md │ ├── package.json │ ├── pnpm-lock.yaml │ ├── public │ ├── favicon.ico │ ├── index.html │ └── manifest.json │ └── src │ ├── components.js │ ├── index.js │ ├── reducer.js │ └── reducer.test.js ├── part7 ├── README.md ├── bloglist-backend │ ├── .env.example │ ├── .eslintignore │ ├── .eslintrc.js │ ├── README.md │ ├── app.js │ ├── controllers │ │ ├── blogs.js │ │ ├── login.js │ │ ├── testing.js │ │ └── users.js │ ├── index.js │ ├── jest.config.js │ ├── models │ │ ├── blog.js │ │ └── user.js │ ├── package.json │ ├── pnpm-lock.yaml │ ├── requests │ │ └── blogs_api.rest │ ├── tests │ │ ├── blogs_api.int.test.js │ │ ├── list_helper.test.js │ │ ├── mockDb_helper.js │ │ └── test_helper.js │ └── utils │ │ ├── config.js │ │ ├── db_helper.js │ │ ├── error_helper.js │ │ ├── list_helper.js │ │ ├── logger.js │ │ └── middleware.js ├── bloglist-frontend │ ├── .eslintignore │ ├── .eslintrc.js │ ├── README.md │ ├── __mocks__ │ │ ├── fileMock.js │ │ └── styleMock.js │ ├── cypress.json │ ├── cypress │ │ ├── fixtures │ │ │ ├── blogs.json │ │ │ └── users.json │ │ ├── integration │ │ │ └── App.spec.js │ │ ├── plugins │ │ │ └── index.js │ │ └── support │ │ │ ├── commands.js │ │ │ ├── index.js │ │ │ └── utils.js │ ├── package.json │ ├── pnpm-lock.yaml │ ├── public │ │ ├── favicon.ico │ │ └── index.html │ ├── src │ │ ├── App.js │ │ ├── App.test.js │ │ ├── components │ │ │ ├── Blog.js │ │ │ ├── BlogForm.js │ │ │ ├── BlogList.js │ │ │ ├── Comments.js │ │ │ ├── Home.js │ │ │ ├── Login.js │ │ │ ├── Modal.js │ │ │ ├── ModalSpinner.js │ │ │ ├── NavBar.js │ │ │ ├── NotFound.js │ │ │ ├── NotFoundPage.js │ │ │ ├── Notification.js │ │ │ ├── NotificationList.js │ │ │ ├── PrivateRoute.js │ │ │ ├── PublicRoute.js │ │ │ ├── ToTopScroller.js │ │ │ ├── Toggleable.js │ │ │ ├── User.js │ │ │ └── Users.js │ │ ├── configureStore.js │ │ ├── fonts │ │ │ ├── Dosis-Light.woff2 │ │ │ ├── Dosis-Medium.woff2 │ │ │ ├── Roboto-Bold-webfont.woff │ │ │ └── Roboto-Regular-webfont.woff │ │ ├── helpers │ │ │ └── testHelper.js │ │ ├── hooks │ │ │ └── index.js │ │ ├── index.css │ │ ├── index.js │ │ ├── reducers │ │ │ ├── authReducer.js │ │ │ ├── blogReducer.js │ │ │ ├── blogReducer.test.js │ │ │ ├── index.js │ │ │ ├── notificationReducer.js │ │ │ ├── notificationReducer.test.js │ │ │ ├── requestReducer.js │ │ │ ├── requestReducer.test.js │ │ │ └── userReducer.js │ │ ├── services │ │ │ ├── blogs.js │ │ │ ├── login.js │ │ │ └── users.js │ │ ├── setupTests.js │ │ └── utils │ │ │ └── index.js │ └── webpack.config.js ├── country-hook │ ├── README.md │ ├── package.json │ ├── pnpm-lock.yaml │ ├── public │ │ ├── favicon.ico │ │ ├── index.html │ │ ├── logo192.png │ │ ├── logo512.png │ │ ├── manifest.json │ │ └── robots.txt │ └── src │ │ ├── App.js │ │ └── index.js ├── routed-anecdotes │ ├── .eslintignore │ ├── .eslintrc.js │ ├── README.md │ ├── package.json │ ├── pnpm-lock.yaml │ ├── public │ │ ├── favicon.ico │ │ ├── index.html │ │ └── manifest.json │ └── src │ │ ├── App.js │ │ ├── components │ │ ├── About.js │ │ ├── Anecdote.js │ │ ├── AnecdoteList.js │ │ ├── CreateNew.js │ │ ├── Footer.js │ │ └── Menu.js │ │ ├── hooks │ │ └── index.js │ │ └── index.js └── ultimate-hooks │ ├── README.md │ ├── db.json │ ├── package.json │ ├── pnpm-lock.yaml │ ├── public │ ├── favicon.ico │ ├── index.html │ └── manifest.json │ └── src │ ├── App.js │ └── index.js ├── part8 ├── README.md ├── library-backend │ ├── .env.example │ ├── .eslintrc.js │ ├── README.md │ ├── graphql │ │ ├── loaders.js │ │ ├── resolvers.js │ │ ├── schema │ │ │ ├── author.js │ │ │ ├── book.js │ │ │ ├── root.js │ │ │ └── user.js │ │ └── typeDefs.js │ ├── helpers │ │ └── errorHelper.js │ ├── index.js │ ├── models │ │ ├── Author.js │ │ ├── Book.js │ │ └── User.js │ ├── package.json │ ├── pnpm-lock.yaml │ └── utils │ │ ├── config.js │ │ └── logger.js └── library-frontend │ ├── README.md │ ├── package.json │ ├── pnpm-lock.yaml │ ├── public │ ├── favicon.ico │ ├── index.html │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ └── robots.txt │ └── src │ ├── App.js │ ├── components │ ├── Authors.js │ ├── AuthorsForm.js │ ├── AuthorsTable.js │ ├── Books.js │ ├── BooksTable.js │ ├── LinkedNavBar.js │ ├── Login.js │ ├── LoginForm.js │ ├── Modal.js │ ├── ModalSpinner.js │ ├── NewBook.js │ ├── NoResource.js │ ├── Notification.js │ ├── Notifications.js │ ├── RecommendedBooks.js │ └── StyledComponents.js │ ├── graphql │ ├── queries.js │ └── resolvers.js │ ├── helpers │ └── errorHelper.js │ ├── hooks │ ├── useAuthUser.js │ ├── useBookGenres.js │ ├── useNotification.js │ ├── useUpdateCache.js │ └── useYupValidationResolver.js │ ├── index.js │ └── utils │ ├── limitArrayOfObjectsByDate.js │ └── logger.js └── part9 ├── README.md ├── patientor-backend ├── .eslintignore ├── .eslintrc ├── README.md ├── build │ ├── data │ │ ├── diagnoses.js │ │ └── patients.js │ └── src │ │ ├── index.js │ │ ├── routes │ │ ├── diagnoses.js │ │ └── patients.js │ │ ├── services │ │ ├── diagnosisService.js │ │ └── patientService.js │ │ ├── types.js │ │ └── utils.js ├── data │ ├── diagnoses.ts │ └── patients.ts ├── package.json ├── pnpm-lock.yaml ├── src │ ├── index.ts │ ├── routes │ │ ├── diagnoses.ts │ │ └── patients.ts │ ├── services │ │ ├── diagnosisService.ts │ │ └── patientService.ts │ ├── types.ts │ └── utils.ts └── tsconfig.json ├── patientor-frontend ├── .eslintrc ├── README.md ├── package.json ├── pnpm-lock.yaml ├── public │ ├── favicon.ico │ ├── index.html │ └── manifest.json ├── src │ ├── AddEntryModal │ │ ├── AddEntryForm.tsx │ │ ├── AddEntryFormWrapper.tsx │ │ ├── EntryTypeFields.tsx │ │ └── index.tsx │ ├── AddPatientModal │ │ ├── AddPatientForm.tsx │ │ └── index.tsx │ ├── App.tsx │ ├── PatientListPage │ │ └── index.tsx │ ├── PatientPage │ │ ├── DiagnosisList.tsx │ │ ├── EntryDetails.tsx │ │ ├── HealthCheckEntry.tsx │ │ ├── HospitalEntry.tsx │ │ ├── OccupationalHealthCareEntry.tsx │ │ └── index.tsx │ ├── components │ │ ├── FormField.tsx │ │ └── HealthRatingBar.tsx │ ├── constants.ts │ ├── helpers │ │ └── errorHelper.ts │ ├── index.tsx │ ├── react-app-env.d.ts │ ├── state │ │ ├── index.ts │ │ ├── reducer.ts │ │ └── state.tsx │ ├── types.ts │ └── utils.ts └── tsconfig.json ├── ts-first-steps ├── .eslintrc ├── README.md ├── calculateBmi.ts ├── exerciseCalculator.ts ├── index.ts ├── package.json ├── pnpm-lock.yaml └── tsconfig.json └── typed-courseinfo ├── .eslintrc ├── README.md ├── package.json ├── pnpm-lock.yaml ├── public ├── favicon.ico ├── index.html ├── logo192.png ├── logo512.png ├── manifest.json └── robots.txt ├── src ├── App.tsx ├── components │ ├── Content.tsx │ ├── Header.tsx │ ├── Part.tsx │ └── Total.tsx ├── index.css ├── index.tsx ├── react-app-env.d.ts ├── setupTests.ts ├── types.ts └── utils.ts └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | **/node_modules 3 | **/.pnp 4 | **/.pnp.js 5 | 6 | # testing 7 | **/coverage 8 | **/cypress/screenshots/** 9 | **/cypress/videos/** 10 | 11 | # production 12 | **/build 13 | # !/part7/bloglist-backend/build 14 | !/part9/patientor-backend/build 15 | 16 | # linters 17 | # **/.eslintrc.js* 18 | # **/.eslintignore 19 | 20 | # misc 21 | **/.DS_Store 22 | **/.env.local 23 | **/.env.development.local 24 | **/.env.test.local 25 | **/.env.production.local 26 | **/.env 27 | 28 | # vscode REST Client 29 | part4/bloglist-backend/requests/ 30 | 31 | # vscode 32 | **/.vscode 33 | **/jsConfig.json 34 | 35 | # logs 36 | *.log 37 | **/*.log 38 | **/npm-debug.log* 39 | **/yarn-debug.log* 40 | **/yarn-error.log* 41 | **/pnpm-debug.log* -------------------------------------------------------------------------------- /part0/0.4_new_note.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeremy-ebinum/full-stack-open-2020/ce8ebbefb5f60c71362b12849129167348891680/part0/0.4_new_note.png -------------------------------------------------------------------------------- /part0/0.5_get_single_page_app.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeremy-ebinum/full-stack-open-2020/ce8ebbefb5f60c71362b12849129167348891680/part0/0.5_get_single_page_app.png -------------------------------------------------------------------------------- /part0/0.6_single_page_app_new_note.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeremy-ebinum/full-stack-open-2020/ce8ebbefb5f60c71362b12849129167348891680/part0/0.6_single_page_app_new_note.png -------------------------------------------------------------------------------- /part0/README.md: -------------------------------------------------------------------------------- 1 | # Full Stack Open 2020 - Exercise Solutions 2 | 3 | ## Part 0 - [Fundamentals of Web Apps](https://fullstackopen.com/en/part0) 4 | 5 | ## [Exercise Descriptions](https://fullstackopen.com/en/part0/fundamentals_of_web_apps#exercises-0-1-0-6) 6 | 7 | ### 0.4: new note: 8 | 9 | ![new_note](./0.4_new_note.png) 10 | 11 | ### 0.5: Single page application 12 | 13 | ![get_single_page_app](./0.5_get_single_page_app.png) 14 | 15 | ### 0.6: New note: 16 | 17 | ![single_page_app_new_note](./0.6_single_page_app_new_note.png) 18 | -------------------------------------------------------------------------------- /part1/README.md: -------------------------------------------------------------------------------- 1 | # Full Stack Open 2020 - Exercise Solutions 2 | 3 | ## Part 1 - [Introduction to React](https://fullstackopen.com/en/part1) 4 | 5 | Each of the following folders contain the final solutions to their respective exercises 6 | 7 | ### [courseinfo](https://github.com/jeremy-ebinum/full-stack-open-2020/tree/master/part1/courseinfo) 8 | 9 | - Exercises 1.1 - 1.2 — [Description](https://fullstackopen.com/en/part1/introduction_to_react#exercises-1-1-1-2) 10 | - Exercises 1.3 - 1.5 — [Description](https://fullstackopen.com/en/part1/javascript#exercises-1-3-1-5) 11 | 12 | ### [unicafe](https://github.com/jeremy-ebinum/full-stack-open-2020/tree/master/part1/unicafe) 13 | 14 | - Exercises 1.6 - 1.11 — [Description](https://fullstackopen.com/en/part1/a_more_complex_state_debugging_react_apps#exercises-1-6-1-14) 15 | 16 | ### [anecdotes](https://github.com/jeremy-ebinum/full-stack-open-2020/tree/master/part1/anecdotes) 17 | 18 | - Exercises 1.12 - 1.14 — [Description](https://fullstackopen.com/en/part1/a_more_complex_state_debugging_react_apps#exercises-1-6-1-14) 19 | -------------------------------------------------------------------------------- /part1/anecdotes/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "anecdotes", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^4.2.4", 7 | "@testing-library/react": "^9.4.0", 8 | "@testing-library/user-event": "^7.2.1", 9 | "react": "^16.12.0", 10 | "react-dom": "^16.12.0", 11 | "react-scripts": "3.4.0" 12 | }, 13 | "scripts": { 14 | "start": "react-scripts start", 15 | "build": "react-scripts build", 16 | "test": "react-scripts test", 17 | "eject": "react-scripts eject" 18 | }, 19 | "eslintConfig": { 20 | "extends": "react-app" 21 | }, 22 | "browserslist": { 23 | "production": [ 24 | ">0.2%", 25 | "not dead", 26 | "not op_mini all" 27 | ], 28 | "development": [ 29 | "last 1 chrome version", 30 | "last 1 firefox version", 31 | "last 1 safari version" 32 | ] 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /part1/anecdotes/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeremy-ebinum/full-stack-open-2020/ce8ebbefb5f60c71362b12849129167348891680/part1/anecdotes/public/favicon.ico -------------------------------------------------------------------------------- /part1/anecdotes/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeremy-ebinum/full-stack-open-2020/ce8ebbefb5f60c71362b12849129167348891680/part1/anecdotes/public/logo192.png -------------------------------------------------------------------------------- /part1/anecdotes/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeremy-ebinum/full-stack-open-2020/ce8ebbefb5f60c71362b12849129167348891680/part1/anecdotes/public/logo512.png -------------------------------------------------------------------------------- /part1/anecdotes/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 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 | -------------------------------------------------------------------------------- /part1/anecdotes/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /part1/anecdotes/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 | -------------------------------------------------------------------------------- /part1/anecdotes/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/extend-expect'; 6 | -------------------------------------------------------------------------------- /part1/courseinfo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "courseinfo", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^4.2.4", 7 | "@testing-library/react": "^9.4.0", 8 | "@testing-library/user-event": "^7.2.1", 9 | "react": "^16.12.0", 10 | "react-dom": "^16.12.0", 11 | "react-scripts": "3.4.0" 12 | }, 13 | "scripts": { 14 | "start": "react-scripts start", 15 | "build": "react-scripts build", 16 | "test": "react-scripts test", 17 | "eject": "react-scripts eject" 18 | }, 19 | "eslintConfig": { 20 | "extends": "react-app" 21 | }, 22 | "browserslist": { 23 | "production": [ 24 | ">0.2%", 25 | "not dead", 26 | "not op_mini all" 27 | ], 28 | "development": [ 29 | "last 1 chrome version", 30 | "last 1 firefox version", 31 | "last 1 safari version" 32 | ] 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /part1/courseinfo/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeremy-ebinum/full-stack-open-2020/ce8ebbefb5f60c71362b12849129167348891680/part1/courseinfo/public/favicon.ico -------------------------------------------------------------------------------- /part1/courseinfo/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeremy-ebinum/full-stack-open-2020/ce8ebbefb5f60c71362b12849129167348891680/part1/courseinfo/public/logo192.png -------------------------------------------------------------------------------- /part1/courseinfo/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeremy-ebinum/full-stack-open-2020/ce8ebbefb5f60c71362b12849129167348891680/part1/courseinfo/public/logo512.png -------------------------------------------------------------------------------- /part1/courseinfo/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 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 | -------------------------------------------------------------------------------- /part1/courseinfo/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /part1/courseinfo/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 | -------------------------------------------------------------------------------- /part1/courseinfo/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/extend-expect'; 6 | -------------------------------------------------------------------------------- /part1/unicafe/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "unicafe", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^4.2.4", 7 | "@testing-library/react": "^9.4.0", 8 | "@testing-library/user-event": "^7.2.1", 9 | "react": "^16.12.0", 10 | "react-dom": "^16.12.0", 11 | "react-scripts": "3.4.0" 12 | }, 13 | "scripts": { 14 | "start": "react-scripts start", 15 | "build": "react-scripts build", 16 | "test": "react-scripts test", 17 | "eject": "react-scripts eject" 18 | }, 19 | "eslintConfig": { 20 | "extends": "react-app" 21 | }, 22 | "browserslist": { 23 | "production": [ 24 | ">0.2%", 25 | "not dead", 26 | "not op_mini all" 27 | ], 28 | "development": [ 29 | "last 1 chrome version", 30 | "last 1 firefox version", 31 | "last 1 safari version" 32 | ] 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /part1/unicafe/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeremy-ebinum/full-stack-open-2020/ce8ebbefb5f60c71362b12849129167348891680/part1/unicafe/public/favicon.ico -------------------------------------------------------------------------------- /part1/unicafe/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeremy-ebinum/full-stack-open-2020/ce8ebbefb5f60c71362b12849129167348891680/part1/unicafe/public/logo192.png -------------------------------------------------------------------------------- /part1/unicafe/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeremy-ebinum/full-stack-open-2020/ce8ebbefb5f60c71362b12849129167348891680/part1/unicafe/public/logo512.png -------------------------------------------------------------------------------- /part1/unicafe/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 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 | -------------------------------------------------------------------------------- /part1/unicafe/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /part1/unicafe/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 | -------------------------------------------------------------------------------- /part1/unicafe/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/extend-expect'; 6 | -------------------------------------------------------------------------------- /part2/README.md: -------------------------------------------------------------------------------- 1 | # Full Stack Open 2020 - Exercise Solutions 2 | 3 | ## Part 2 - [Communicating with server](https://fullstackopen.com/en/part2) 4 | 5 | Each of the following folders contain the final solutions to their respective exercises 6 | 7 | ### [course-contents](https://github.com/jeremy-ebinum/full-stack-open-2020/tree/master/part2/course-contents) 8 | 9 | - Exercises 2.1 - 2.5 — [Description](https://fullstackopen.com/en/part2/rendering_a_collection_modules#exercises-2-1-2-5) 10 | 11 | ### [countries](https://github.com/jeremy-ebinum/full-stack-open-2020/tree/master/part2/countries) 12 | 13 | - Exercises 2.12 - 2.14 — [Description](https://fullstackopen.com/en/part2/getting_data_from_server#exercises-2-11-2-14) 14 | 15 | ### [phonebook](https://github.com/jeremy-ebinum/full-stack-open-2020/tree/master/part2/phonebook) 16 | 17 | - Exercises 2.6 - 2.10 — [Description](https://fullstackopen.com/en/part2/forms#exercises-2-6-2-10) 18 | - Exercise 2.11 — [Description](https://fullstackopen.com/en/part2/getting_data_from_server#exercises-2-11-2-14) 19 | - Exercise 2.15 - 2.18 — [Description](https://fullstackopen.com/en/part2/altering_data_in_server#exercises-2-15-2-18) 20 | - Exercise 2.19 - 2.20 — [Description](https://fullstackopen.com/en/part2/adding_styles_to_react_app#exercises-2-19-2-20) 21 | -------------------------------------------------------------------------------- /part2/countries/.env.example: -------------------------------------------------------------------------------- 1 | REACT_APP_OPENWEATHERMAP_KEY= -------------------------------------------------------------------------------- /part2/countries/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "countries", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^4.2.4", 7 | "@testing-library/react": "^9.4.0", 8 | "@testing-library/user-event": "^7.2.1", 9 | "axios": "^0.19.2", 10 | "react": "^16.12.0", 11 | "react-dom": "^16.12.0", 12 | "react-scripts": "3.4.0" 13 | }, 14 | "scripts": { 15 | "start": "react-scripts start", 16 | "build": "react-scripts build", 17 | "test": "react-scripts test", 18 | "eject": "react-scripts eject" 19 | }, 20 | "eslintConfig": { 21 | "extends": "react-app" 22 | }, 23 | "browserslist": { 24 | "production": [ 25 | ">0.2%", 26 | "not dead", 27 | "not op_mini all" 28 | ], 29 | "development": [ 30 | "last 1 chrome version", 31 | "last 1 firefox version", 32 | "last 1 safari version" 33 | ] 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /part2/countries/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeremy-ebinum/full-stack-open-2020/ce8ebbefb5f60c71362b12849129167348891680/part2/countries/public/favicon.ico -------------------------------------------------------------------------------- /part2/countries/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeremy-ebinum/full-stack-open-2020/ce8ebbefb5f60c71362b12849129167348891680/part2/countries/public/logo192.png -------------------------------------------------------------------------------- /part2/countries/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeremy-ebinum/full-stack-open-2020/ce8ebbefb5f60c71362b12849129167348891680/part2/countries/public/logo512.png -------------------------------------------------------------------------------- /part2/countries/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 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 | -------------------------------------------------------------------------------- /part2/countries/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /part2/countries/src/components/Countries.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Country from "./Country"; 3 | 4 | const Countries = ({ countries, handleClick }) => { 5 | const tooManyCountries = countries.length > 10; 6 | const multipleCountries = countries.length > 1 && countries.length <= 10; 7 | const singleCountry = countries.length === 1; 8 | 9 | const countriesList = countries.map((country) => { 10 | return ( 11 |
12 | {country.name}{" "} 13 | 16 |
17 | ); 18 | }); 19 | 20 | return ( 21 |
22 | {tooManyCountries && "Too many matches, specify another filter"} 23 | {multipleCountries &&
{countriesList}
} 24 | {singleCountry && } 25 |
26 | ); 27 | }; 28 | 29 | export default Countries; 30 | -------------------------------------------------------------------------------- /part2/countries/src/components/Filter.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const Filter = (props) => { 4 | return ( 5 |
6 | 7 | 8 |
9 | ); 10 | }; 11 | 12 | export default Filter; 13 | -------------------------------------------------------------------------------- /part2/countries/src/components/Weather.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from "react"; 2 | import axios from "axios"; 3 | import WeatherInfo from "./WeatherInfo"; 4 | 5 | const Weather = ({ query }) => { 6 | const [condition, setCondition] = useState({}); 7 | const [hasCondition, setHasCondition] = useState(false); 8 | 9 | const key = process.env.REACT_APP_OPENWEATHERMAP_KEY || null; 10 | 11 | const params = { 12 | q: query, 13 | APPID: key, 14 | }; 15 | 16 | const updateCondition = () => { 17 | if (!key) return; 18 | 19 | let source = axios.CancelToken.source(); 20 | 21 | axios 22 | .get("http://api.openweathermap.org/data/2.5/weather", { 23 | params: params, 24 | cancelToken: source.token, 25 | }) 26 | .catch((error) => { 27 | if (axios.isCancel(error)) { 28 | console.log("Request canceled", error.message); 29 | } else { 30 | throw error; 31 | } 32 | }) 33 | .then((response) => { 34 | if (response.statusText === "OK") { 35 | setCondition(response.data); 36 | setHasCondition(true); 37 | } 38 | }) 39 | .catch((error) => { 40 | console.log(error.config); 41 | }); 42 | 43 | return () => { 44 | source.cancel("Weather component is unmounting"); 45 | }; 46 | }; 47 | useEffect(updateCondition, []); 48 | 49 | return
{hasCondition && }
; 50 | }; 51 | 52 | export default Weather; 53 | -------------------------------------------------------------------------------- /part2/countries/src/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import App from "./App"; 4 | 5 | ReactDOM.render(, document.getElementById("root")); 6 | -------------------------------------------------------------------------------- /part2/countries/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/extend-expect'; 6 | -------------------------------------------------------------------------------- /part2/course-contents/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "course-contents", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^4.2.4", 7 | "@testing-library/react": "^9.4.0", 8 | "@testing-library/user-event": "^7.2.1", 9 | "react": "^16.12.0", 10 | "react-dom": "^16.12.0", 11 | "react-scripts": "3.4.0" 12 | }, 13 | "scripts": { 14 | "start": "react-scripts start", 15 | "build": "react-scripts build", 16 | "test": "react-scripts test", 17 | "eject": "react-scripts eject" 18 | }, 19 | "eslintConfig": { 20 | "extends": "react-app" 21 | }, 22 | "browserslist": { 23 | "production": [ 24 | ">0.2%", 25 | "not dead", 26 | "not op_mini all" 27 | ], 28 | "development": [ 29 | "last 1 chrome version", 30 | "last 1 firefox version", 31 | "last 1 safari version" 32 | ] 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /part2/course-contents/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeremy-ebinum/full-stack-open-2020/ce8ebbefb5f60c71362b12849129167348891680/part2/course-contents/public/favicon.ico -------------------------------------------------------------------------------- /part2/course-contents/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeremy-ebinum/full-stack-open-2020/ce8ebbefb5f60c71362b12849129167348891680/part2/course-contents/public/logo192.png -------------------------------------------------------------------------------- /part2/course-contents/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeremy-ebinum/full-stack-open-2020/ce8ebbefb5f60c71362b12849129167348891680/part2/course-contents/public/logo512.png -------------------------------------------------------------------------------- /part2/course-contents/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 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 | -------------------------------------------------------------------------------- /part2/course-contents/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /part2/course-contents/src/components/Course.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const Header = (props) => { 4 | return

{props.name}

; 5 | }; 6 | 7 | const Part = (props) => { 8 | return ( 9 |

10 | {props.name} {props.exercises} 11 |

12 | ); 13 | }; 14 | 15 | const Content = ({ parts }) => { 16 | const partsList = parts.map((item) => { 17 | return ; 18 | }); 19 | 20 | const noParts = !Array.isArray(partsList) || !partsList.length; 21 | 22 | return ( 23 |
24 | {noParts &&

This course doesn't have any parts yet.

} 25 | {!noParts && partsList} 26 |
27 | ); 28 | }; 29 | 30 | const Total = ({ parts }) => { 31 | const total = parts.reduce((acc, item) => acc + item.exercises, 0); 32 | 33 | return ( 34 |

35 | Total of {total} exercises 36 |

37 | ); 38 | }; 39 | 40 | const Course = ({ course }) => { 41 | return ( 42 |
43 |
44 | 45 | 46 |
47 | ); 48 | }; 49 | 50 | export default Course; 51 | -------------------------------------------------------------------------------- /part2/course-contents/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 | -------------------------------------------------------------------------------- /part2/course-contents/src/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import "./index.css"; 4 | import Course from "./components/Course"; 5 | 6 | const App = () => { 7 | const courses = [ 8 | { 9 | name: "Half Stack application development", 10 | id: 1, 11 | parts: [ 12 | { 13 | name: "Fundamentals of React", 14 | exercises: 10, 15 | id: 1, 16 | }, 17 | { 18 | name: "Using props to pass data", 19 | exercises: 7, 20 | id: 2, 21 | }, 22 | { 23 | name: "State of a component", 24 | exercises: 14, 25 | id: 3, 26 | }, 27 | { 28 | name: "Redux", 29 | exercises: 11, 30 | id: 4, 31 | }, 32 | ], 33 | }, 34 | { 35 | name: "Node.js", 36 | id: 2, 37 | parts: [ 38 | { 39 | name: "Routing", 40 | exercises: 3, 41 | id: 1, 42 | }, 43 | { 44 | name: "Middlewares", 45 | exercises: 7, 46 | id: 2, 47 | }, 48 | ], 49 | }, 50 | ]; 51 | 52 | const courseList = courses.map((course) => { 53 | return ; 54 | }); 55 | 56 | return
{courseList}
; 57 | }; 58 | 59 | ReactDOM.render(, document.getElementById("root")); 60 | -------------------------------------------------------------------------------- /part2/course-contents/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/extend-expect'; 6 | -------------------------------------------------------------------------------- /part2/phonebook/db.json: -------------------------------------------------------------------------------- 1 | { 2 | "persons": [ 3 | { 4 | "name": "Arto Hellas", 5 | "number": "040-123456", 6 | "id": 1 7 | }, 8 | { 9 | "name": "Ada Lovelace", 10 | "number": "39-44-5323523", 11 | "id": 2 12 | }, 13 | { 14 | "name": "Dan Abramov", 15 | "number": "12-43-234345", 16 | "id": 3 17 | }, 18 | { 19 | "name": "Mary Poppendieck", 20 | "number": "39-23-6423122", 21 | "id": 4 22 | } 23 | ] 24 | } -------------------------------------------------------------------------------- /part2/phonebook/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "phonebook", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^4.2.4", 7 | "@testing-library/react": "^9.4.0", 8 | "@testing-library/user-event": "^7.2.1", 9 | "axios": "^0.19.2", 10 | "react": "^16.12.0", 11 | "react-dom": "^16.12.0", 12 | "react-scripts": "3.4.0", 13 | "unique-random": "^2.1.0" 14 | }, 15 | "scripts": { 16 | "start": "react-scripts start", 17 | "build": "react-scripts build", 18 | "test": "react-scripts test", 19 | "eject": "react-scripts eject", 20 | "server": "json-server -p3001 db.json --routes routes.json" 21 | }, 22 | "proxy": "http://localhost:3001", 23 | "eslintConfig": { 24 | "extends": "react-app" 25 | }, 26 | "browserslist": { 27 | "production": [ 28 | ">0.2%", 29 | "not dead", 30 | "not op_mini all" 31 | ], 32 | "development": [ 33 | "last 1 chrome version", 34 | "last 1 firefox version", 35 | "last 1 safari version" 36 | ] 37 | }, 38 | "devDependencies": { 39 | "json-server": "^0.15.1" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /part2/phonebook/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeremy-ebinum/full-stack-open-2020/ce8ebbefb5f60c71362b12849129167348891680/part2/phonebook/public/favicon.ico -------------------------------------------------------------------------------- /part2/phonebook/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeremy-ebinum/full-stack-open-2020/ce8ebbefb5f60c71362b12849129167348891680/part2/phonebook/public/logo192.png -------------------------------------------------------------------------------- /part2/phonebook/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeremy-ebinum/full-stack-open-2020/ce8ebbefb5f60c71362b12849129167348891680/part2/phonebook/public/logo512.png -------------------------------------------------------------------------------- /part2/phonebook/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 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 | -------------------------------------------------------------------------------- /part2/phonebook/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /part2/phonebook/routes.json: -------------------------------------------------------------------------------- 1 | { 2 | "/api/*": "/$1", 3 | "/:resource/": "/:resource/", 4 | "/:resource/:id/": "/:resource/:id" 5 | } 6 | -------------------------------------------------------------------------------- /part2/phonebook/src/components/Alert.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | 3 | // run the timeoutFunc after a given period with useEffect hook 4 | const Alert = ({ timeoutFunc, id, type, message }) => { 5 | const alertTypes = { 6 | error: "c-alert--error", 7 | success: "c-alert--success", 8 | info: "c-alert--info", 9 | }; 10 | 11 | if (!alertTypes[type]) throw new Error("Invalid Alert Type"); 12 | 13 | const removeAlert = () => timeoutFunc(id); 14 | 15 | useEffect(() => { 16 | setTimeout(removeAlert, 3000); 17 | }); 18 | 19 | return ( 20 |
21 | {message} 22 |
23 | ); 24 | }; 25 | 26 | export default Alert; 27 | -------------------------------------------------------------------------------- /part2/phonebook/src/components/Filter.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const Filter = (props) => { 4 | return ( 5 |
6 |

Search Contacts

7 |
8 | 15 |
16 |
17 | ); 18 | }; 19 | 20 | export default Filter; 21 | -------------------------------------------------------------------------------- /part2/phonebook/src/components/PersonForm.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const PersonForm = (props) => { 4 | return ( 5 |
6 |
7 |

Add a New Contact

8 |
9 | 12 | 18 |
19 |
20 | 23 | 29 |
30 |
31 | 34 |
35 |
36 |
37 | ); 38 | }; 39 | 40 | export default PersonForm; 41 | -------------------------------------------------------------------------------- /part2/phonebook/src/components/Persons.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const Person = (props) => { 4 | return ( 5 |
6 | 7 | {props.name}: 8 | 9 | 10 | {props.number}, 11 | 12 |
13 |
14 | 21 |
22 |
23 |
24 | ); 25 | }; 26 | 27 | const Persons = ({ persons, handleClick }) => { 28 | const personsList = persons.map((person) => { 29 | return ( 30 | 37 | ); 38 | }); 39 | 40 | return ( 41 |
42 |

Contact List

43 | {personsList} 44 |
45 | ); 46 | }; 47 | 48 | export default Persons; 49 | -------------------------------------------------------------------------------- /part2/phonebook/src/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import App from "./App"; 4 | import "./index.css"; 5 | 6 | ReactDOM.render(, document.getElementById("root")); 7 | -------------------------------------------------------------------------------- /part2/phonebook/src/services/persons.js: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | 3 | const baseUrl = "/api/persons"; 4 | 5 | const create = (newPerson) => { 6 | const request = axios.post(baseUrl, newPerson); 7 | 8 | return request.then((response) => response.data); 9 | }; 10 | 11 | const getAll = () => { 12 | const request = axios.get(baseUrl); 13 | 14 | return request.then((response) => response.data); 15 | }; 16 | 17 | const update = (id, newPerson) => { 18 | const request = axios.put(`${baseUrl}/${id}`, newPerson); 19 | 20 | return request.then((response) => response.data); 21 | }; 22 | 23 | const remove = (id) => { 24 | const request = axios.delete(`${baseUrl}/${id}`); 25 | 26 | return request; 27 | }; 28 | 29 | export default { create, getAll, update, remove }; 30 | -------------------------------------------------------------------------------- /part2/phonebook/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/extend-expect'; 6 | -------------------------------------------------------------------------------- /part4/bloglist-backend/.env.example: -------------------------------------------------------------------------------- 1 | PORT=3001 2 | MONGODB_URI=mongodb://[username:password@]host1[:port1][,...hostN[:portN]][/[database][?options]] 3 | SECRET_KEY=n-e(hc^3d4i!5h$#78_2!zjaw42bqq5n+uze_7peyv6dcbly4a -------------------------------------------------------------------------------- /part4/bloglist-backend/.eslintignore: -------------------------------------------------------------------------------- 1 | build -------------------------------------------------------------------------------- /part4/bloglist-backend/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | commonjs: true, 4 | es6: true, 5 | node: true, 6 | jest: true, 7 | }, 8 | extends: ["airbnb-base", "plugin:prettier/recommended"], 9 | globals: { 10 | Atomics: "readonly", 11 | SharedArrayBuffer: "readonly", 12 | }, 13 | parserOptions: { 14 | ecmaVersion: 2018, 15 | }, 16 | plugins: ["prettier"], 17 | rules: { 18 | "prettier/prettier": [ 19 | "error", 20 | { 21 | trailingComma: "es5", 22 | arrowParens: "always", 23 | printWidth: 80, 24 | tabWidth: 2, 25 | semi: true, 26 | singleQuote: false, 27 | bracketSpacing: true, 28 | }, 29 | ], 30 | "no-console": 0, 31 | "no-param-reassign": ["error", { props: false }], 32 | "no-underscore-dangle": ["error", { allow: ["_id", "__v"] }], 33 | }, 34 | }; 35 | -------------------------------------------------------------------------------- /part4/bloglist-backend/app.js: -------------------------------------------------------------------------------- 1 | const express = require("express"); 2 | const bodyParser = require("body-parser"); 3 | const cors = require("cors"); 4 | const logger = require("./utils/logger"); 5 | const db = require("./utils/db_helper"); 6 | const mockDb = require("./tests/mockDb_helper"); 7 | const middleware = require("./utils/middleware"); 8 | const usersRouter = require("./controllers/users"); 9 | const loginRouter = require("./controllers/login"); 10 | const blogsRouter = require("./controllers/blogs"); 11 | const testingRouter = require("./controllers/testing"); 12 | 13 | const app = express(); 14 | 15 | if (process.env.NODE_ENV === "test") { 16 | mockDb.connect().catch((err) => { 17 | logger.error(err); 18 | }); 19 | app.use("/api/testing", testingRouter); 20 | } else { 21 | db.connect().catch((err) => { 22 | logger.error(err); 23 | }); 24 | } 25 | 26 | app.use(cors()); 27 | app.use(bodyParser.json()); 28 | app.use(middleware.morganLogger()); 29 | app.use(middleware.tokenExtractor); 30 | 31 | app.use("/api/users", usersRouter); 32 | app.use("/api/login", loginRouter); 33 | app.use("/api/blogs", blogsRouter); 34 | app.use(middleware.unknownRouteHandler); 35 | 36 | app.use(middleware.errorHandler); 37 | 38 | module.exports = app; 39 | -------------------------------------------------------------------------------- /part4/bloglist-backend/controllers/login.js: -------------------------------------------------------------------------------- 1 | const bcrypt = require("bcrypt"); 2 | const jwt = require("jsonwebtoken"); 3 | const loginRouter = require("express").Router(); 4 | const { ErrorHelper } = require("../utils/error_helper"); 5 | const User = require("../models/user"); 6 | 7 | loginRouter.post("/", async (req, res, next) => { 8 | const { body } = req; 9 | 10 | try { 11 | const user = await User.findOne({ username: body.username }); 12 | const isCorrectPassword = 13 | user === null 14 | ? false 15 | : await bcrypt.compare(body.password, user.passwordHash); 16 | 17 | if (!(user && isCorrectPassword)) { 18 | throw new ErrorHelper(401, "Authentication Error", [ 19 | "Invalid username or password", 20 | ]); 21 | } 22 | 23 | const userForToken = { 24 | username: user.username, 25 | id: user._id, 26 | }; 27 | 28 | const token = jwt.sign(userForToken, process.env.SECRET_KEY); 29 | 30 | res.status(200).send({ token, username: user.username, name: user.name }); 31 | } catch (err) { 32 | next(err); 33 | } 34 | }); 35 | 36 | module.exports = loginRouter; 37 | -------------------------------------------------------------------------------- /part4/bloglist-backend/controllers/testing.js: -------------------------------------------------------------------------------- 1 | const router = require("express").Router(); 2 | const mockDb = require("../tests/mockDb_helper"); 3 | const testHelper = require("../tests/test_helper"); 4 | 5 | router.post("/reset", async (request, response) => { 6 | mockDb.clearDatabase(); 7 | response.status(204).end(); 8 | }); 9 | 10 | router.get("/blogs", async (req, res) => { 11 | const blogsInDb = await testHelper.getBlogsInDb(); 12 | 13 | res.status(200).json(blogsInDb); 14 | }); 15 | 16 | router.get("/users", async (req, res) => { 17 | const usersInDb = await testHelper.getUsersInDb(); 18 | 19 | res.status(200).json(usersInDb); 20 | }); 21 | 22 | module.exports = router; 23 | -------------------------------------------------------------------------------- /part4/bloglist-backend/index.js: -------------------------------------------------------------------------------- 1 | const http = require("http"); 2 | const config = require("./utils/config"); 3 | const app = require("./app"); 4 | const logger = require("./utils/logger"); 5 | 6 | const server = http.createServer(app); 7 | 8 | server.listen(config.PORT, () => { 9 | logger.info(`Server running on port ${config.PORT}`); 10 | }); 11 | -------------------------------------------------------------------------------- /part4/bloglist-backend/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: "node", 3 | }; 4 | -------------------------------------------------------------------------------- /part4/bloglist-backend/models/blog.js: -------------------------------------------------------------------------------- 1 | const mongoose = require("mongoose"); 2 | 3 | const blogSchema = mongoose.Schema({ 4 | title: { type: String, required: true }, 5 | author: String, 6 | url: { type: String, required: true }, 7 | likes: Number, 8 | user: { type: mongoose.SchemaTypes.ObjectId, ref: "User" }, 9 | }); 10 | 11 | blogSchema.set("toJSON", { 12 | transform: (document, returnedObject) => { 13 | returnedObject.id = returnedObject._id.toString(); 14 | delete returnedObject._id; 15 | delete returnedObject.__v; 16 | }, 17 | }); 18 | 19 | module.exports = mongoose.model("Blog", blogSchema); 20 | -------------------------------------------------------------------------------- /part4/bloglist-backend/models/user.js: -------------------------------------------------------------------------------- 1 | const mongoose = require("mongoose"); 2 | const uniqueValidator = require("mongoose-unique-validator"); 3 | 4 | const userSchema = mongoose.Schema({ 5 | username: { type: String, unique: true }, 6 | passwordHash: { type: String }, 7 | name: { type: String }, 8 | blogs: [{ type: mongoose.SchemaTypes.ObjectId, ref: "Blog" }], 9 | }); 10 | 11 | userSchema.plugin(uniqueValidator); 12 | 13 | userSchema.set("toJSON", { 14 | transform: (document, returnedObject) => { 15 | returnedObject.id = returnedObject._id.toString(); 16 | delete returnedObject._id; 17 | delete returnedObject.passwordHash; 18 | delete returnedObject.__v; 19 | }, 20 | }); 21 | 22 | module.exports = mongoose.model("User", userSchema); 23 | -------------------------------------------------------------------------------- /part4/bloglist-backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bloglist-backend", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "scripts": { 6 | "start": "cross-env NODE_ENV=production node index.js", 7 | "start:test": "cross-env NODE_ENV=test node index.js", 8 | "watch": "cross-env NODE_ENV=development nodemon index.js", 9 | "lint": "eslint .", 10 | "test": "cross-env NODE_ENV=test jest --verbose --runInBand" 11 | }, 12 | "keywords": [], 13 | "author": "Jeremy Ebinum", 14 | "license": "MIT", 15 | "description": "", 16 | "dependencies": { 17 | "bcrypt": "^4.0.1", 18 | "body-parser": "^1.19.0", 19 | "cors": "^2.8.5", 20 | "dotenv": "^8.2.0", 21 | "express": "^4.17.1", 22 | "jsonwebtoken": "^8.5.1", 23 | "lodash": "^4.17.15", 24 | "mongoose": "^5.8.11", 25 | "mongoose-unique-validator": "^2.0.3", 26 | "morgan": "^1.9.1" 27 | }, 28 | "devDependencies": { 29 | "cross-env": "^7.0.0", 30 | "eslint": "^6.1.0", 31 | "eslint-config-airbnb-base": "^14.0.0", 32 | "eslint-config-prettier": "^6.10.0", 33 | "eslint-plugin-import": "^2.18.2", 34 | "eslint-plugin-prettier": "^3.1.2", 35 | "jest": "^25.1.0", 36 | "mongodb-memory-server-global": "^6.2.4", 37 | "nodemon": "^2.0.2", 38 | "prettier": "^1.19.1", 39 | "supertest": "^4.0.2" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /part4/bloglist-backend/tests/mockDb_helper.js: -------------------------------------------------------------------------------- 1 | const mongoose = require("mongoose"); 2 | const { MongoMemoryServer } = require("mongodb-memory-server-global"); 3 | 4 | const mongod = new MongoMemoryServer(); 5 | 6 | /** 7 | * Connect to the in-memory database. 8 | */ 9 | module.exports.connect = async () => { 10 | const uri = await mongod.getConnectionString(); 11 | 12 | const mongooseOpts = { 13 | useNewUrlParser: true, 14 | useUnifiedTopology: true, 15 | }; 16 | 17 | mongoose.set("useCreateIndex", true); 18 | mongoose.set("useFindAndModify", false); 19 | 20 | await mongoose.connect(uri, mongooseOpts); 21 | }; 22 | 23 | /** 24 | * Drop database, close the connection and stop mongod. 25 | */ 26 | module.exports.closeDatabase = async () => { 27 | await mongoose.connection.dropDatabase(); 28 | await mongoose.connection.close(); 29 | await mongod.stop(); 30 | }; 31 | 32 | /** 33 | * Remove all the data for all db collections. 34 | */ 35 | module.exports.clearDatabase = async () => { 36 | const { collections } = mongoose.connection; 37 | 38 | Object.keys(collections).forEach(async (key) => { 39 | const collection = collections[key]; 40 | await collection.deleteMany(); 41 | }); 42 | }; 43 | -------------------------------------------------------------------------------- /part4/bloglist-backend/utils/config.js: -------------------------------------------------------------------------------- 1 | require("dotenv").config(); 2 | 3 | const { PORT, MONGODB_URI } = process.env; 4 | 5 | module.exports = { PORT, MONGODB_URI }; 6 | -------------------------------------------------------------------------------- /part4/bloglist-backend/utils/db_helper.js: -------------------------------------------------------------------------------- 1 | const mongoose = require("mongoose"); 2 | const config = require("./config"); 3 | const logger = require("./logger"); 4 | 5 | module.exports.connect = async () => { 6 | mongoose.connection.on("connected", () => { 7 | logger.info("Connection Established"); 8 | }); 9 | 10 | mongoose.connection.on("reconnected", () => { 11 | logger.info("Connection Reestablished"); 12 | }); 13 | 14 | mongoose.connection.on("disconnected", () => { 15 | logger.info("Connection Disconnected"); 16 | }); 17 | 18 | mongoose.connection.on("close", () => { 19 | logger.info("Connection Closed"); 20 | }); 21 | 22 | mongoose.connection.on("error", (error) => { 23 | logger.error(`ERROR: ${error}`); 24 | }); 25 | 26 | if (process.env.NODE_ENV === "test") { 27 | return Promise.resolve(""); 28 | } 29 | 30 | mongoose.set("useCreateIndex", true); 31 | mongoose.set("useFindAndModify", false); 32 | 33 | return mongoose.connect(config.MONGODB_URI, { 34 | useNewUrlParser: true, 35 | useUnifiedTopology: true, 36 | }); 37 | }; 38 | -------------------------------------------------------------------------------- /part4/bloglist-backend/utils/error_helper.js: -------------------------------------------------------------------------------- 1 | const logger = require("../utils/logger"); 2 | 3 | class ErrorHelper extends Error { 4 | constructor(statusCode, kind, messages = []) { 5 | super(); 6 | this.statusCode = statusCode; 7 | this.kind = kind; 8 | this.messages = messages; 9 | this.message = this.messages.join("\n"); 10 | } 11 | } 12 | const handleError = (err, res) => { 13 | try { 14 | const { statusCode, kind, message, messages } = err; 15 | 16 | res.status(statusCode).json({ 17 | statusCode, 18 | kind, 19 | message, 20 | messages, 21 | }); 22 | } catch (exception) { 23 | logger.error("IN handleError helper\n", exception.message); 24 | } 25 | }; 26 | 27 | module.exports = { 28 | ErrorHelper, 29 | handleError, 30 | }; 31 | -------------------------------------------------------------------------------- /part4/bloglist-backend/utils/logger.js: -------------------------------------------------------------------------------- 1 | const info = (...params) => { 2 | if (process.env.NODE_ENV !== "test") { 3 | console.log(...params); 4 | } 5 | }; 6 | 7 | const error = (...params) => { 8 | console.error(...params); 9 | }; 10 | 11 | module.exports = { 12 | info, 13 | error, 14 | }; 15 | -------------------------------------------------------------------------------- /part5/README.md: -------------------------------------------------------------------------------- 1 | # Full Stack Open 2020 - Exercise Solutions 2 | 3 | ## Part 5 - [Testing React apps, custom hooks](https://fullstackopen.com/en/part5) 4 | 5 | Each of the following folders contain the final solutions to their respective exercises 6 | 7 | ### [bloglist-frontend](https://github.com/jeremy-ebinum/full-stack-open-2020/tree/master/part5/bloglist-frontend) 8 | 9 | - Exercises 5.1 - 5.4 — [Description](https://fullstackopen.com/en/part5/login_in_frontend#exercises-5-1-5-4) 10 | - Exercises 5.5 - 5.10 — [Description](https://fullstackopen.com/en/part5/props_children_and_proptypes#exercises-5-5-5-10) 11 | - Exercises 5.11 - 5.12 — [Description](https://fullstackopen.com/en/part5/props_children_and_proptypes#exercises-5-11-5-12) 12 | - Exercises 5.17 - 5.22 — [Description](https://fullstackopen.com/en/part5/end_to_end_testing#exercises-5-17-5-22) 13 | -------------------------------------------------------------------------------- /part5/bloglist-frontend/.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build -------------------------------------------------------------------------------- /part5/bloglist-frontend/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | es6: true, 4 | node: true, 5 | "jest/globals": true, 6 | "cypress/globals": true, 7 | }, 8 | extends: ["react-app", "plugin:prettier/recommended"], 9 | globals: { 10 | Atomics: "readonly", 11 | SharedArrayBuffer: "readonly", 12 | }, 13 | parserOptions: { 14 | ecmaVersion: 2018, 15 | }, 16 | plugins: ["jest", "cypress", "prettier"], 17 | rules: { 18 | "prettier/prettier": [ 19 | "error", 20 | { 21 | trailingComma: "es5", 22 | arrowParens: "always", 23 | printWidth: 80, 24 | tabWidth: 2, 25 | semi: true, 26 | singleQuote: false, 27 | bracketSpacing: true, 28 | }, 29 | ], 30 | "react/jsx-filename-extension": [1, { extensions: [".js", ".jsx"] }], 31 | "no-param-reassign": ["error", { props: false }], 32 | "no-console": 0, 33 | // "react/prop-types": 0, 34 | "jsx-a11y/label-has-associated-control": [ 35 | 2, 36 | { 37 | labelComponents: [], 38 | labelAttributes: [], 39 | controlComponents: ["Input"], 40 | assert: "either", 41 | depth: 3, 42 | }, 43 | ], 44 | }, 45 | }; 46 | -------------------------------------------------------------------------------- /part5/bloglist-frontend/cypress.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /part5/bloglist-frontend/cypress/fixtures/blogs.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "title": "First Test Blog Title", 4 | "author": "First Test Blog Author", 5 | "url": "http://site.com" 6 | }, 7 | { 8 | "title": "Second Test Blog Title", 9 | "author": "Second Test Blog Author", 10 | "url": "http://site.com" 11 | }, 12 | { 13 | "title": "Third Test Blog Title", 14 | "author": "Third Test Blog Author", 15 | "url": "http://site.com" 16 | } 17 | ] 18 | -------------------------------------------------------------------------------- /part5/bloglist-frontend/cypress/fixtures/users.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "Test User", 4 | "username": "test_username", 5 | "password": "test_password" 6 | }, 7 | { 8 | "name": "Other Test User", 9 | "username": "other_test_username", 10 | "password": "ohter_test_password" 11 | } 12 | ] 13 | -------------------------------------------------------------------------------- /part5/bloglist-frontend/cypress/plugins/index.js: -------------------------------------------------------------------------------- 1 | /// 2 | // *********************************************************** 3 | // This example plugins/index.js can be used to load plugins 4 | // 5 | // You can change the location of this file or turn off loading 6 | // the plugins file with the 'pluginsFile' configuration option. 7 | // 8 | // You can read more here: 9 | // https://on.cypress.io/plugins-guide 10 | // *********************************************************** 11 | 12 | // This function is called when a project is opened or re-opened (e.g. due to 13 | // the project's config changing) 14 | 15 | /** 16 | * @type {Cypress.PluginConfig} 17 | */ 18 | module.exports = (on, config) => { 19 | // `on` is used to hook into various events Cypress emits 20 | // `config` is the resolved Cypress config 21 | }; 22 | -------------------------------------------------------------------------------- /part5/bloglist-frontend/cypress/support/commands.js: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example commands.js shows you how to 3 | // create various custom commands and overwrite 4 | // existing commands. 5 | // 6 | // For more comprehensive examples of custom 7 | // commands please read more here: 8 | // https://on.cypress.io/custom-commands 9 | // *********************************************** 10 | // 11 | // 12 | // -- This is a parent command -- 13 | // Cypress.Commands.add("login", (email, password) => { ... }) 14 | // 15 | // 16 | // -- This is a child command -- 17 | // Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) 18 | // 19 | // 20 | // -- This is a dual command -- 21 | // Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) 22 | // 23 | // 24 | // -- This will overwrite an existing command -- 25 | // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) 26 | -------------------------------------------------------------------------------- /part5/bloglist-frontend/cypress/support/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import "./commands"; 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | -------------------------------------------------------------------------------- /part5/bloglist-frontend/cypress/support/utils.js: -------------------------------------------------------------------------------- 1 | export const login = (username, password) => { 2 | cy.get("[data-testid='Login_username']").type(username); 3 | cy.get("[data-testid='Login_password']").type(password); 4 | cy.get("[data-testid='Login_submitButton']").click(); 5 | }; 6 | 7 | export const createBlog = ({ title, author, url }) => { 8 | cy.get("[data-testid='BlogForm_title']").type(title); 9 | cy.get("[data-testid='BlogForm_author']").type(author); 10 | cy.get("[data-testid='BlogForm_url']").type(url); 11 | cy.get("[data-testid='BlogForm_submitButton']").click(); 12 | }; 13 | -------------------------------------------------------------------------------- /part5/bloglist-frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeremy-ebinum/full-stack-open-2020/ce8ebbefb5f60c71362b12849129167348891680/part5/bloglist-frontend/public/favicon.ico -------------------------------------------------------------------------------- /part5/bloglist-frontend/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /part5/bloglist-frontend/src/UserContext.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const UserContext = React.createContext(null); 4 | 5 | export default UserContext; 6 | -------------------------------------------------------------------------------- /part5/bloglist-frontend/src/components/Alert.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import PropTypes from "prop-types"; 3 | import { getTestIDs } from "../helpers/testHelper"; 4 | 5 | export const testIDs = getTestIDs(); 6 | 7 | // run the timeoutFunc after a given period with useEffect hook 8 | const Alert = ({ timeoutFunc, id, type, contextClass, message }) => { 9 | const alertTypes = { 10 | error: "c-alert--error", 11 | success: "c-alert--success", 12 | info: "c-alert--info", 13 | }; 14 | 15 | if (!alertTypes[type]) throw new Error("Invalid Alert Type"); 16 | 17 | useEffect(() => { 18 | const removeAlert = () => timeoutFunc(id); 19 | setTimeout(removeAlert, 3000); 20 | }, [id, timeoutFunc]); 21 | 22 | return ( 23 |
27 | {message} 28 |
29 | ); 30 | }; 31 | 32 | Alert.propTypes = { 33 | timeoutFunc: PropTypes.func.isRequired, 34 | id: PropTypes.string.isRequired, 35 | type: PropTypes.string.isRequired, 36 | contextClass: PropTypes.string.isRequired, 37 | message: PropTypes.string.isRequired, 38 | }; 39 | 40 | export default React.memo(Alert); 41 | -------------------------------------------------------------------------------- /part5/bloglist-frontend/src/components/AlertList.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import Alert from "./Alert"; 4 | 5 | const AlertList = ({ alerts, contextClass }) => { 6 | const alertsToDisplay = alerts.map((alert) => { 7 | return ( 8 | 16 | ); 17 | }); 18 | 19 | return alertsToDisplay; 20 | }; 21 | 22 | AlertList.propTypes = { 23 | alerts: PropTypes.arrayOf(PropTypes.object).isRequired, 24 | contextClass: PropTypes.string.isRequired, 25 | }; 26 | 27 | const shouldNotUpdate = (prevProps, nextProps) => { 28 | const sameAlerts = prevProps.alerts.length === nextProps.alerts.length; 29 | const sameContextClass = prevProps.contextClass === nextProps.contextClass; 30 | 31 | if (sameAlerts && sameContextClass) { 32 | return true; 33 | } 34 | 35 | return false; 36 | }; 37 | 38 | export default React.memo(AlertList, shouldNotUpdate); 39 | -------------------------------------------------------------------------------- /part5/bloglist-frontend/src/components/BlogForm.test.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render, fireEvent, cleanup } from "@testing-library/react"; 3 | import testHelper from "../helpers/testHelper"; 4 | import BlogForm from "./BlogForm"; 5 | 6 | describe("", () => { 7 | let createBlog; 8 | 9 | beforeEach(() => { 10 | createBlog = jest.fn().mockName("handleDelete"); 11 | }); 12 | 13 | afterEach(() => { 14 | cleanup(); 15 | }); 16 | 17 | test("correctly calls the handler prop on submit", () => { 18 | const { getByLabelText, getByRole } = render( 19 | 20 | ); 21 | 22 | const form = getByRole("form"); 23 | const titleInput = getByLabelText(/title/i); 24 | const authorInput = getByLabelText(/author/i); 25 | const urlInput = getByLabelText(/url/i); 26 | 27 | fireEvent.change(titleInput, { 28 | target: { value: testHelper.validNewBlog.title }, 29 | }); 30 | fireEvent.change(authorInput, { 31 | target: { value: testHelper.validNewBlog.author }, 32 | }); 33 | fireEvent.change(urlInput, { 34 | target: { value: testHelper.validNewBlog.url }, 35 | }); 36 | 37 | fireEvent.submit(form); 38 | 39 | expect(createBlog).toHaveBeenCalledWith(testHelper.validNewBlog); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /part5/bloglist-frontend/src/components/BlogList.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import Blog from "./Blog"; 4 | import { getTestIDs } from "../helpers/testHelper"; 5 | 6 | export const testIDs = getTestIDs(); 7 | 8 | const BlogList = ({ fetchHasRun, blogs, handleLike, handleDelete }) => { 9 | return ( 10 |
11 |

Blogs

12 | 13 | {fetchHasRun && !blogs.length && ( 14 |

No Blogs have been added yet

15 | )} 16 | 17 | {blogs.map((blog) => { 18 | return ( 19 | 25 | ); 26 | })} 27 |
28 | ); 29 | }; 30 | 31 | BlogList.propTypes = { 32 | blogs: PropTypes.arrayOf(PropTypes.object).isRequired, 33 | fetchHasRun: PropTypes.bool.isRequired, 34 | handleLike: PropTypes.func.isRequired, 35 | handleDelete: PropTypes.func.isRequired, 36 | }; 37 | 38 | export default React.memo(BlogList); 39 | -------------------------------------------------------------------------------- /part5/bloglist-frontend/src/components/Modal.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import PropTypes from "prop-types"; 3 | 4 | const Modal = ({ testid, children }) => { 5 | useEffect(() => { 6 | const rootStyle = document.documentElement.style; 7 | const wrapper = document.querySelector(".js-wrapper"); 8 | rootStyle.setProperty("--body-overflow", "hidden"); 9 | wrapper.classList.add("o-wrapper--hasModal"); 10 | return () => { 11 | rootStyle.setProperty("--body-overflow", "auto"); 12 | wrapper.classList.remove("o-wrapper--hasModal"); 13 | }; 14 | }, []); 15 | 16 | return ( 17 |
18 |
{children}
19 |
20 | ); 21 | }; 22 | 23 | Modal.propTypes = { 24 | children: PropTypes.node.isRequired, 25 | testid: PropTypes.string, 26 | }; 27 | 28 | Modal.defaultProps = { 29 | testid: null, 30 | }; 31 | 32 | export default Modal; 33 | -------------------------------------------------------------------------------- /part5/bloglist-frontend/src/components/ModalSpinner.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 3 | import { faSpinner } from "@fortawesome/free-solid-svg-icons"; 4 | import { getTestIDs } from "../helpers/testHelper"; 5 | import Modal from "./Modal"; 6 | 7 | export const testIDs = getTestIDs(); 8 | 9 | const ModalSpinner = () => { 10 | return ( 11 | 12 | 17 | 18 | ); 19 | }; 20 | 21 | export default ModalSpinner; 22 | -------------------------------------------------------------------------------- /part5/bloglist-frontend/src/components/ToTopScroller.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useImperativeHandle } from "react"; 2 | 3 | const ToTopScroller = React.forwardRef((props, ref) => { 4 | const [isHidden, setIsHidden] = useState(true); 5 | 6 | const style = { display: isHidden ? "none" : "" }; 7 | 8 | const scrollToTop = () => { 9 | document.documentElement.scrollTop = 0; 10 | }; 11 | 12 | const show = () => { 13 | setIsHidden(false); 14 | }; 15 | 16 | const hide = () => { 17 | setIsHidden(true); 18 | }; 19 | 20 | useImperativeHandle(ref, () => { 21 | return { 22 | show, 23 | hide, 24 | }; 25 | }); 26 | 27 | return ( 28 |
29 | 36 |
37 | ); 38 | }); 39 | 40 | export default React.memo(ToTopScroller); 41 | -------------------------------------------------------------------------------- /part5/bloglist-frontend/src/fonts/Dosis-Light.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeremy-ebinum/full-stack-open-2020/ce8ebbefb5f60c71362b12849129167348891680/part5/bloglist-frontend/src/fonts/Dosis-Light.woff2 -------------------------------------------------------------------------------- /part5/bloglist-frontend/src/fonts/Dosis-Medium.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeremy-ebinum/full-stack-open-2020/ce8ebbefb5f60c71362b12849129167348891680/part5/bloglist-frontend/src/fonts/Dosis-Medium.woff2 -------------------------------------------------------------------------------- /part5/bloglist-frontend/src/fonts/Roboto-Bold-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeremy-ebinum/full-stack-open-2020/ce8ebbefb5f60c71362b12849129167348891680/part5/bloglist-frontend/src/fonts/Roboto-Bold-webfont.woff -------------------------------------------------------------------------------- /part5/bloglist-frontend/src/fonts/Roboto-Regular-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeremy-ebinum/full-stack-open-2020/ce8ebbefb5f60c71362b12849129167348891680/part5/bloglist-frontend/src/fonts/Roboto-Regular-webfont.woff -------------------------------------------------------------------------------- /part5/bloglist-frontend/src/hooks/index.js: -------------------------------------------------------------------------------- 1 | import { useState, useCallback } from "react"; 2 | 3 | export const useField = ({ 4 | id = null, 5 | className = "c-row__input", 6 | type = "text", 7 | name = null, 8 | placeholder = null, 9 | }) => { 10 | const [value, setValue] = useState(""); 11 | 12 | const onChange = useCallback((event) => { 13 | setValue(event.target.value); 14 | }, []); 15 | 16 | const reset = useCallback(() => setValue(""), []); 17 | 18 | return [{ id, className, type, name, placeholder, value, onChange }, reset]; 19 | }; 20 | -------------------------------------------------------------------------------- /part5/bloglist-frontend/src/index.js: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | import React from "react"; 3 | import ReactDOM from "react-dom"; 4 | import App from "./App"; 5 | import "./index.css"; 6 | 7 | if (process.env.NODE_ENV === "development") { 8 | const whyDidYouRender = require("@welldone-software/why-did-you-render"); 9 | whyDidYouRender(React); 10 | } 11 | 12 | ReactDOM.render(, document.getElementById("root")); 13 | -------------------------------------------------------------------------------- /part5/bloglist-frontend/src/services/__mocks__/blogs.js: -------------------------------------------------------------------------------- 1 | import testHelper from "../../helpers/testHelper"; 2 | 3 | const blogs = testHelper.blogs; 4 | // eslint-disable-next-line no-unused-vars 5 | let token = null; 6 | 7 | const setToken = (newToken) => { 8 | if (newToken) { 9 | token = `Bearer ${newToken}`; 10 | } else { 11 | token = null; 12 | } 13 | }; 14 | 15 | const getAll = () => { 16 | return Promise.resolve(blogs); 17 | }; 18 | 19 | export default { setToken, getAll }; 20 | -------------------------------------------------------------------------------- /part5/bloglist-frontend/src/services/__mocks__/login.js: -------------------------------------------------------------------------------- 1 | import testHelper from "../../helpers/testHelper"; 2 | 3 | const returnedUser = testHelper.validLoggedInUser; 4 | 5 | const login = async () => { 6 | return Promise.resolve(returnedUser); 7 | }; 8 | 9 | export default { login }; 10 | -------------------------------------------------------------------------------- /part5/bloglist-frontend/src/services/blogs.js: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import testHelper from "../helpers/testHelper"; 3 | 4 | if (process.env.NODE_ENV === "test") { 5 | axios.defaults.adapter = require("axios/lib/adapters/http"); 6 | axios.defaults.baseURL = testHelper.host; 7 | } 8 | 9 | const baseUrl = "/api/blogs"; 10 | 11 | let token = null; 12 | 13 | const setToken = (newToken) => { 14 | if (newToken) { 15 | token = `Bearer ${newToken}`; 16 | } else { 17 | token = null; 18 | } 19 | }; 20 | 21 | const create = async (newBlog) => { 22 | const config = { headers: { Authorization: token } }; 23 | const response = await axios.post(baseUrl, newBlog, config); 24 | 25 | return response.data; 26 | }; 27 | 28 | const getAll = async () => { 29 | const response = await axios.get(baseUrl); 30 | 31 | return response.data; 32 | }; 33 | 34 | const update = async (id, updatedBlog) => { 35 | const config = { headers: { Authorization: token } }; 36 | const response = await axios.put(`${baseUrl}/${id}`, updatedBlog, config); 37 | 38 | return response.data; 39 | }; 40 | 41 | const remove = async (id) => { 42 | const config = { headers: { Authorization: token } }; 43 | const response = await axios.delete(`${baseUrl}/${id}`, config); 44 | 45 | return response.data; 46 | }; 47 | 48 | export default { setToken, create, getAll, update, remove }; 49 | -------------------------------------------------------------------------------- /part5/bloglist-frontend/src/services/login.js: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import testHelper from "../helpers/testHelper"; 3 | 4 | if (process.env.NODE_ENV === "test") { 5 | axios.defaults.adapter = require("axios/lib/adapters/http"); 6 | axios.defaults.baseURL = testHelper.host; 7 | } 8 | 9 | const baseUrl = "/api/login"; 10 | 11 | const login = async (credentials) => { 12 | const response = await axios.post(baseUrl, credentials); 13 | 14 | return response.data; 15 | }; 16 | 17 | export default { login }; 18 | -------------------------------------------------------------------------------- /part5/bloglist-frontend/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/extend-expect"; 6 | 7 | // Mocks 8 | // jest.mock("./services/blogs"); 9 | // jest.mock("./services/login"); 10 | 11 | // localStorage 12 | let savedItems = {}; 13 | 14 | const localStorageMock = { 15 | setItem: (key, item) => { 16 | savedItems[key] = item; 17 | }, 18 | getItem: (key) => savedItems[key], 19 | removeItem: (key) => delete savedItems[key], 20 | clear: () => { 21 | savedItems = {}; 22 | }, 23 | }; 24 | 25 | global.localStorage = localStorageMock; 26 | 27 | // window.confirm 28 | window.confirm = () => true; 29 | -------------------------------------------------------------------------------- /part6/README.md: -------------------------------------------------------------------------------- 1 | # Full Stack Open 2020 - Exercise Solutions 2 | 3 | ## Part 6 - [State management with Redux](https://fullstackopen.com/en/part6) 4 | 5 | Each of the following folders contain the final solutions to their respective exercises 6 | 7 | ### [unicafe-redux](https://github.com/jeremy-ebinum/full-stack-open-2020/tree/master/part6/unicafe-redux) 8 | 9 | - Exercises 6.1 - 6.2 — [Description](https://fullstackopen.com/en/part6/flux_architecture_and_redux#exercises-6-1-6-2) 10 | 11 | ### [redux-anecdotes](https://github.com/jeremy-ebinum/full-stack-open-2020/tree/master/part6/redux-anecdotes) 12 | 13 | - Exercises 6.3 - 6.8 — [Description](https://fullstackopen.com/en/part6/flux_architecture_and_redux#exercises-6-3-6-8) 14 | - Exercises 6.9 - 6.12 — [Description](https://fullstackopen.com/en/part6/many_reducers#exercises-6-9-6-12) 15 | - Exercises 6.13 - 6.14 — [Description](https://fullstackopen.com/en/part6/communicating_with_server_in_a_redux_application#exercises-6-13-6-14) 16 | - Exercises 6.15 - 6.18 — [Description](https://fullstackopen.com/en/part6/communicating_with_server_in_a_redux_application#exercises-6-15-6-18) 17 | - Exercises 6.19 - 6.21 — [Description](https://fullstackopen.com/en/part6/connect#exercises-6-19-6-21) 18 | -------------------------------------------------------------------------------- /part6/redux-anecdotes/.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build -------------------------------------------------------------------------------- /part6/redux-anecdotes/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | es6: true, 4 | node: true, 5 | "jest/globals": true, 6 | }, 7 | extends: ["react-app", "plugin:prettier/recommended"], 8 | globals: { 9 | Atomics: "readonly", 10 | SharedArrayBuffer: "readonly", 11 | }, 12 | parserOptions: { 13 | ecmaVersion: 2018, 14 | }, 15 | plugins: ["jest", "prettier"], 16 | rules: { 17 | "prettier/prettier": [ 18 | "error", 19 | { 20 | trailingComma: "es5", 21 | arrowParens: "always", 22 | printWidth: 80, 23 | tabWidth: 2, 24 | semi: true, 25 | singleQuote: false, 26 | bracketSpacing: true, 27 | }, 28 | ], 29 | "react/jsx-filename-extension": [1, { extensions: [".js", ".jsx"] }], 30 | "no-param-reassign": ["error", { props: false }], 31 | "no-console": 0, 32 | // "react/prop-types": 0, 33 | "jsx-a11y/label-has-associated-control": [ 34 | 2, 35 | { 36 | labelComponents: [], 37 | labelAttributes: [], 38 | controlComponents: ["Input"], 39 | assert: "either", 40 | depth: 3, 41 | }, 42 | ], 43 | }, 44 | }; 45 | -------------------------------------------------------------------------------- /part6/redux-anecdotes/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-anecdotes", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "axios": "^0.19.2", 7 | "json-server": "^0.16.1", 8 | "prop-types": "^15.7.2", 9 | "react": "^16.12.0", 10 | "react-dom": "^16.12.0", 11 | "react-redux": "^7.2.0", 12 | "react-scripts": "3.4.0", 13 | "react-uid": "^2.2.0", 14 | "redux": "^4.0.5", 15 | "redux-devtools-extension": "^2.13.8", 16 | "redux-thunk": "^2.3.0", 17 | "styled-components": "^5.0.1", 18 | "styled-icons": "^9.5.0" 19 | }, 20 | "devDependencies": { 21 | "@types/jest": "^25.1.3", 22 | "deep-freeze": "^0.0.1", 23 | "eslint-config-prettier": "^6.10.0", 24 | "eslint-plugin-jest": "^23.7.0", 25 | "eslint-plugin-prettier": "^3.1.2", 26 | "prettier": "^1.19.1" 27 | }, 28 | "scripts": { 29 | "start": "react-scripts start", 30 | "build": "react-scripts build", 31 | "server": "json-server -p3001 db.json", 32 | "test": "set CI=true && react-scripts test", 33 | "test:watch": "react-scripts test", 34 | "test:coverage": "set CI=true && react-scripts test --coverage", 35 | "eject": "react-scripts eject" 36 | }, 37 | "browserslist": { 38 | "production": [ 39 | ">0.2%", 40 | "not dead", 41 | "not op_mini all" 42 | ], 43 | "development": [ 44 | "last 1 chrome version", 45 | "last 1 firefox version", 46 | "last 1 safari version" 47 | ] 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /part6/redux-anecdotes/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeremy-ebinum/full-stack-open-2020/ce8ebbefb5f60c71362b12849129167348891680/part6/redux-anecdotes/public/favicon.ico -------------------------------------------------------------------------------- /part6/redux-anecdotes/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /part6/redux-anecdotes/src/components/Filter.js: -------------------------------------------------------------------------------- 1 | import React, { useRef } from "react"; 2 | import PropTypes from "prop-types"; 3 | import { connect } from "react-redux"; 4 | import { useUIDSeed } from "react-uid"; 5 | import { setFilter, clearFilter } from "../reducers/filterReducer"; 6 | import { 7 | FilterInput, 8 | FilterLabel, 9 | FilterContainer, 10 | ClearFliterButton, 11 | } from "./StyledComponents"; 12 | 13 | const Filter = ({ setFilter, clearFilter }) => { 14 | const seed = useUIDSeed(); 15 | const filterInputRef = useRef(); 16 | 17 | const handleChange = (event) => { 18 | setFilter(event.target.value); 19 | }; 20 | 21 | const clear = () => { 22 | clearFilter(); 23 | filterInputRef.current.value = ""; 24 | }; 25 | 26 | return ( 27 | 28 | Search 29 | 35 | Clear Filter 36 | 37 | ); 38 | }; 39 | 40 | Filter.propTypes = { 41 | setFilter: PropTypes.func.isRequired, 42 | clearFilter: PropTypes.func.isRequired, 43 | }; 44 | 45 | export default connect(null, { setFilter, clearFilter })(Filter); 46 | -------------------------------------------------------------------------------- /part6/redux-anecdotes/src/components/Modal.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import { Modal as StyledModal, ModalContent } from "./StyledComponents"; 3 | 4 | const Modal = ({ children }) => { 5 | useEffect(() => { 6 | document.body.style.overflow = "hidden"; 7 | return () => { 8 | document.body.style.overflow = ""; 9 | }; 10 | }, []); 11 | 12 | return ( 13 | 14 | {children} 15 | 16 | ); 17 | }; 18 | 19 | export default Modal; 20 | -------------------------------------------------------------------------------- /part6/redux-anecdotes/src/components/ModalSpinner.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Modal from "./Modal"; 3 | import { FetchSpinner } from "./StyledComponents"; 4 | 5 | const ModalSpinner = () => { 6 | return ( 7 | 8 | 9 | 10 | ); 11 | }; 12 | 13 | export default ModalSpinner; 14 | -------------------------------------------------------------------------------- /part6/redux-anecdotes/src/components/Notification.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import { connect } from "react-redux"; 4 | import { NotificationContainer, Alert } from "./StyledComponents"; 5 | 6 | const Notification = ({ notificationsToShow }) => { 7 | return ( 8 | 9 | {notificationsToShow.map((notification) => ( 10 | 11 | {notification.message} 12 | 13 | ))} 14 | 15 | ); 16 | }; 17 | 18 | const notificationsToShow = (notifications) => { 19 | if (notifications.length <= 3) { 20 | return notifications; 21 | } else { 22 | const notificationsByDateDesc = [...notifications].sort( 23 | (a, b) => b.date - a.date 24 | ); 25 | 26 | const lastThreeNotifications = notificationsByDateDesc 27 | .slice(0, 3) 28 | .reverse(); 29 | 30 | return lastThreeNotifications; 31 | } 32 | }; 33 | 34 | const mapStateToProps = (state) => { 35 | return { notificationsToShow: notificationsToShow(state.notifications) }; 36 | }; 37 | 38 | Notification.propTypes = { 39 | notificationsToShow: PropTypes.arrayOf(PropTypes.object).isRequired, 40 | }; 41 | 42 | export default connect(mapStateToProps)(Notification); 43 | -------------------------------------------------------------------------------- /part6/redux-anecdotes/src/components/ProgressBar.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import { connect } from "react-redux"; 4 | import { Meter, MeterBar } from "./StyledComponents"; 5 | 6 | const ProgressBar = ({ isLoading }) => { 7 | return ( 8 | <> 9 | {isLoading && ( 10 | 11 | 12 | 13 | )} 14 | 15 | ); 16 | }; 17 | 18 | const mapStateToProps = (state) => { 19 | const loadingStates = Object.entries(state.requests).map((entry) => { 20 | return { ...entry[1], requestName: entry[0] }; 21 | }); 22 | 23 | const isLoading = loadingStates.some((l) => { 24 | return l.requestName !== "initAnecdotes" && l.isLoading; 25 | }); 26 | 27 | return { isLoading }; 28 | }; 29 | 30 | ProgressBar.propTypes = { isLoading: PropTypes.bool.isRequired }; 31 | 32 | export default connect(mapStateToProps)(ProgressBar); 33 | -------------------------------------------------------------------------------- /part6/redux-anecdotes/src/fonts/FlexoW01Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeremy-ebinum/full-stack-open-2020/ce8ebbefb5f60c71362b12849129167348891680/part6/redux-anecdotes/src/fonts/FlexoW01Regular.woff -------------------------------------------------------------------------------- /part6/redux-anecdotes/src/fonts/FlexoW01Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeremy-ebinum/full-stack-open-2020/ce8ebbefb5f60c71362b12849129167348891680/part6/redux-anecdotes/src/fonts/FlexoW01Regular.woff2 -------------------------------------------------------------------------------- /part6/redux-anecdotes/src/helpers/helper.js: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | 3 | export const getTrimmedStr = (str) => { 4 | return str.length > 50 ? str.slice(0, 49) + "..." : str; 5 | }; 6 | 7 | export const getCancelTokenSource = () => { 8 | const CancelToken = axios.CancelToken; 9 | const source = CancelToken.source(); 10 | 11 | return source; 12 | }; 13 | -------------------------------------------------------------------------------- /part6/redux-anecdotes/src/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import { Provider } from "react-redux"; 4 | import App from "./App"; 5 | import store from "./store"; 6 | 7 | ReactDOM.render( 8 | 9 | 10 | , 11 | document.getElementById("root") 12 | ); 13 | -------------------------------------------------------------------------------- /part6/redux-anecdotes/src/reducers/filterReducer.js: -------------------------------------------------------------------------------- 1 | export const initialState = ""; 2 | 3 | const filterReducer = (state = initialState, action) => { 4 | switch (action.type) { 5 | case "SET_FILTER": 6 | return action.filter; 7 | case "CLEAR_FILTER": 8 | return initialState; 9 | default: 10 | return state; 11 | } 12 | }; 13 | 14 | export const setFilter = (filter) => { 15 | return (dispatch) => { 16 | dispatch({ type: "SET_FILTER", filter }); 17 | }; 18 | }; 19 | 20 | export const clearFilter = () => { 21 | return (dispatch) => { 22 | dispatch({ type: "CLEAR_FILTER" }); 23 | }; 24 | }; 25 | 26 | export default filterReducer; 27 | -------------------------------------------------------------------------------- /part6/redux-anecdotes/src/reducers/filterReducer.test.js: -------------------------------------------------------------------------------- 1 | import deepFreeze from "deep-freeze"; 2 | import filterReducer, { initialState } from "./filterReducer"; 3 | 4 | describe("filterReducer", () => { 5 | test("returns it's initial state when initialized with an undefined state", () => { 6 | const action = { 7 | type: "DO_NOTHING", 8 | }; 9 | 10 | const newState = filterReducer(undefined, action); 11 | expect(newState).toEqual(initialState); 12 | }); 13 | 14 | test("SET_FILTER updates the state of filter", () => { 15 | const newFilter = "test filter"; 16 | 17 | const action = { 18 | type: "SET_FILTER", 19 | filter: newFilter, 20 | }; 21 | 22 | const state = initialState; 23 | deepFreeze(state); 24 | 25 | const newState = filterReducer(state, action); 26 | 27 | expect(newState).toBe(newFilter); 28 | }); 29 | 30 | test("CLEAR_FILTER resets filter to initial state", () => { 31 | const state = "existing filter"; 32 | 33 | const action = { 34 | type: "CLEAR_FILTER", 35 | }; 36 | 37 | deepFreeze(state); 38 | 39 | const newState = filterReducer(state, action); 40 | 41 | expect(newState).toBe(initialState); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /part6/redux-anecdotes/src/reducers/notificationReducer.js: -------------------------------------------------------------------------------- 1 | import { uid } from "react-uid"; 2 | 3 | export const initialState = []; 4 | 5 | const notificationReducer = (state = initialState, action) => { 6 | switch (action.type) { 7 | case "NEW_NOTIFICATION": 8 | return state.concat(action.data); 9 | case "REMOVE_NOTIFICATION": 10 | const id = action.id; 11 | return state.filter((notification) => notification.id !== id); 12 | default: 13 | return state; 14 | } 15 | }; 16 | 17 | export const newNotification = (message, level) => { 18 | return { 19 | type: "NEW_NOTIFICATION", 20 | data: { id: uid({}), message, level, date: Date.now() }, 21 | }; 22 | }; 23 | 24 | export const removeNotification = (id) => { 25 | return { 26 | type: "REMOVE_NOTIFICATION", 27 | id, 28 | }; 29 | }; 30 | 31 | /** 32 | * Queues a new notification to be removed after the specified timeout 33 | * @param {string} message 34 | * @param {number} timeout 35 | * @param {string=} level - success | info | warning 36 | * 37 | * @return {function} thunk 38 | */ 39 | export const displayNotification = (message, timeout, level = "") => { 40 | return (dispatch) => { 41 | const action = newNotification(message, level); 42 | const id = action.data.id; 43 | 44 | dispatch(action); 45 | 46 | setTimeout(() => { 47 | dispatch(removeNotification(id)); 48 | }, timeout); 49 | }; 50 | }; 51 | 52 | export default notificationReducer; 53 | -------------------------------------------------------------------------------- /part6/redux-anecdotes/src/reducers/requestReducer.test.js: -------------------------------------------------------------------------------- 1 | import deepFreeze from "deep-freeze"; 2 | import requestReducer, { initialState, newStates } from "./requestReducer"; 3 | 4 | describe("requestReducer()", () => { 5 | test("returns it's initial state when initialized with an undefined state", () => { 6 | const action = { type: null }; 7 | 8 | const newState = requestReducer(undefined, action); 9 | expect(newState).toEqual(initialState); 10 | }); 11 | 12 | test("LOADING sets the state for pending requests", () => { 13 | const action = { 14 | type: "LOADING", 15 | request: "initAnecdotes", 16 | }; 17 | 18 | const state = initialState; 19 | deepFreeze(state); 20 | 21 | const newState = requestReducer(state, action); 22 | expect(newState.initAnecdotes).toEqual({ 23 | ...initialState.initAnecdotes, 24 | ...newStates.loading, 25 | }); 26 | }); 27 | 28 | test("SUCCESS sets the state for successful requests", () => { 29 | const action = { 30 | type: "LOADING", 31 | request: "initAnecdotes", 32 | }; 33 | 34 | const state = initialState; 35 | deepFreeze(state); 36 | 37 | const newState = requestReducer(state, action); 38 | expect(newState.initAnecdotes).toEqual({ 39 | ...initialState.initAnecdotes, 40 | ...newStates.loading, 41 | }); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /part6/redux-anecdotes/src/services/anecdotes.js: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | 3 | const baseUrl = "http://localhost:3001/anecdotes"; 4 | 5 | const create = async (content) => { 6 | const newAnecdote = { content, votes: 0 }; 7 | const response = await axios.post(baseUrl, newAnecdote); 8 | return response.data; 9 | }; 10 | 11 | const getAll = async () => { 12 | const response = await axios.get(baseUrl); 13 | return response.data; 14 | }; 15 | 16 | const update = async (id, updatedAnecdote) => { 17 | const response = await axios.put(`${baseUrl}/${id}`, updatedAnecdote); 18 | return response.data; 19 | }; 20 | 21 | export default { create, getAll, update }; 22 | -------------------------------------------------------------------------------- /part6/redux-anecdotes/src/store.js: -------------------------------------------------------------------------------- 1 | import { createStore, combineReducers, applyMiddleware } from "redux"; 2 | import { composeWithDevTools } from "redux-devtools-extension"; 3 | import thunk from "redux-thunk"; 4 | 5 | import anecdoteReducer from "./reducers/anecdoteReducer"; 6 | import notificationReducer from "./reducers/notificationReducer"; 7 | import filterReducer from "./reducers/filterReducer"; 8 | import requestReducer from "./reducers/requestReducer"; 9 | 10 | const reducer = combineReducers({ 11 | anecdotes: anecdoteReducer, 12 | notifications: notificationReducer, 13 | filter: filterReducer, 14 | requests: requestReducer, 15 | }); 16 | 17 | const store = createStore(reducer, composeWithDevTools(applyMiddleware(thunk))); 18 | 19 | export default store; 20 | -------------------------------------------------------------------------------- /part6/unicafe-redux/.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build -------------------------------------------------------------------------------- /part6/unicafe-redux/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | es6: true, 4 | node: true, 5 | "jest/globals": true, 6 | }, 7 | extends: ["react-app", "plugin:prettier/recommended"], 8 | globals: { 9 | Atomics: "readonly", 10 | SharedArrayBuffer: "readonly", 11 | }, 12 | parserOptions: { 13 | ecmaVersion: 2018, 14 | }, 15 | plugins: ["jest", "prettier"], 16 | rules: { 17 | "prettier/prettier": [ 18 | "error", 19 | { 20 | trailingComma: "es5", 21 | arrowParens: "always", 22 | printWidth: 80, 23 | tabWidth: 2, 24 | semi: true, 25 | singleQuote: false, 26 | bracketSpacing: true, 27 | }, 28 | ], 29 | "react/jsx-filename-extension": [1, { extensions: [".js", ".jsx"] }], 30 | "no-param-reassign": ["error", { props: false }], 31 | "no-console": 0, 32 | // "react/prop-types": 0, 33 | "jsx-a11y/label-has-associated-control": [ 34 | 2, 35 | { 36 | labelComponents: [], 37 | labelAttributes: [], 38 | controlComponents: ["Input"], 39 | assert: "either", 40 | depth: 3, 41 | }, 42 | ], 43 | }, 44 | }; 45 | -------------------------------------------------------------------------------- /part6/unicafe-redux/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "unicafe-redux", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^4.2.4", 7 | "@testing-library/react": "^9.4.0", 8 | "@testing-library/user-event": "^7.2.1", 9 | "react": "^16.12.0", 10 | "react-dom": "^16.12.0", 11 | "react-scripts": "3.4.0", 12 | "redux": "^4.0.5", 13 | "styled-components": "^5.0.1" 14 | }, 15 | "scripts": { 16 | "start": "react-scripts start", 17 | "build": "react-scripts build", 18 | "test": "set CI=true && react-scripts test", 19 | "test:watch": "react-scripts test", 20 | "test:coverage": "set CI=true && react-scripts test --coverage", 21 | "eject": "react-scripts eject" 22 | }, 23 | "eslintConfig": { 24 | "extends": "react-app" 25 | }, 26 | "browserslist": { 27 | "production": [ 28 | ">0.2%", 29 | "not dead", 30 | "not op_mini all" 31 | ], 32 | "development": [ 33 | "last 1 chrome version", 34 | "last 1 firefox version", 35 | "last 1 safari version" 36 | ] 37 | }, 38 | "devDependencies": { 39 | "@types/jest": "^25.1.3", 40 | "deep-freeze": "^0.0.1", 41 | "eslint-config-prettier": "^6.10.0", 42 | "eslint-plugin-jest": "^23.7.0", 43 | "eslint-plugin-prettier": "^3.1.2", 44 | "prettier": "^1.19.1" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /part6/unicafe-redux/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeremy-ebinum/full-stack-open-2020/ce8ebbefb5f60c71362b12849129167348891680/part6/unicafe-redux/public/favicon.ico -------------------------------------------------------------------------------- /part6/unicafe-redux/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /part6/unicafe-redux/src/reducer.js: -------------------------------------------------------------------------------- 1 | const initialState = { 2 | good: 0, 3 | ok: 0, 4 | bad: 0, 5 | }; 6 | 7 | const counterReducer = (state = initialState, action) => { 8 | switch (action.type) { 9 | case "GOOD": 10 | return { ...state, good: state.good + 1 }; 11 | case "OK": 12 | return { ...state, ok: state.ok + 1 }; 13 | case "BAD": 14 | return { ...state, bad: state.bad + 1 }; 15 | case "ZERO": 16 | return initialState; 17 | default: 18 | return state; 19 | } 20 | }; 21 | 22 | export default counterReducer; 23 | -------------------------------------------------------------------------------- /part7/bloglist-backend/.env.example: -------------------------------------------------------------------------------- 1 | PORT=3001 2 | MONGODB_URI=mongodb://[username:password@]host1[:port1][,...hostN[:portN]][/[database][?options]] 3 | SECRET_KEY=n-e(hc^3d4i!5h$#78_2!zjaw42bqq5n+uze_7peyv6dcbly4a -------------------------------------------------------------------------------- /part7/bloglist-backend/.eslintignore: -------------------------------------------------------------------------------- 1 | build -------------------------------------------------------------------------------- /part7/bloglist-backend/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | commonjs: true, 4 | es6: true, 5 | node: true, 6 | jest: true, 7 | }, 8 | extends: ["airbnb-base", "plugin:prettier/recommended"], 9 | globals: { 10 | Atomics: "readonly", 11 | SharedArrayBuffer: "readonly", 12 | }, 13 | parserOptions: { 14 | ecmaVersion: 2018, 15 | }, 16 | plugins: ["prettier"], 17 | rules: { 18 | "prettier/prettier": [ 19 | "error", 20 | { 21 | trailingComma: "es5", 22 | arrowParens: "always", 23 | printWidth: 80, 24 | tabWidth: 2, 25 | semi: true, 26 | singleQuote: false, 27 | bracketSpacing: true, 28 | }, 29 | ], 30 | "no-console": 0, 31 | "no-param-reassign": ["error", { props: false }], 32 | "no-underscore-dangle": ["error", { allow: ["_id", "__v"] }], 33 | }, 34 | }; 35 | -------------------------------------------------------------------------------- /part7/bloglist-backend/controllers/login.js: -------------------------------------------------------------------------------- 1 | const bcrypt = require("bcrypt"); 2 | const jwt = require("jsonwebtoken"); 3 | const loginRouter = require("express").Router(); 4 | const { ErrorHelper } = require("../utils/error_helper"); 5 | const User = require("../models/user"); 6 | 7 | loginRouter.post("/", async (req, res, next) => { 8 | const { body } = req; 9 | 10 | try { 11 | const user = await User.findOne({ username: body.username }); 12 | const isCorrectPassword = 13 | user === null 14 | ? false 15 | : await bcrypt.compare(body.password, user.passwordHash); 16 | 17 | if (!(user && isCorrectPassword)) { 18 | throw new ErrorHelper(401, "Authentication Error", [ 19 | "Invalid username or password", 20 | ]); 21 | } 22 | 23 | const userForToken = { 24 | username: user.username, 25 | id: user._id, 26 | }; 27 | 28 | const token = jwt.sign(userForToken, process.env.SECRET_KEY); 29 | 30 | res.status(200).send({ token, username: user.username, name: user.name }); 31 | } catch (err) { 32 | next(err); 33 | } 34 | }); 35 | 36 | module.exports = loginRouter; 37 | -------------------------------------------------------------------------------- /part7/bloglist-backend/controllers/testing.js: -------------------------------------------------------------------------------- 1 | const router = require("express").Router(); 2 | const mockDb = require("../tests/mockDb_helper"); 3 | const testHelper = require("../tests/test_helper"); 4 | 5 | router.post("/reset", async (request, response) => { 6 | mockDb.clearDatabase(); 7 | response.status(204).end(); 8 | }); 9 | 10 | router.get("/blogs", async (req, res) => { 11 | const blogsInDb = await testHelper.getBlogsInDb(); 12 | 13 | res.status(200).json(blogsInDb); 14 | }); 15 | 16 | router.get("/users", async (req, res) => { 17 | const usersInDb = await testHelper.getUsersInDb(); 18 | 19 | res.status(200).json(usersInDb); 20 | }); 21 | 22 | module.exports = router; 23 | -------------------------------------------------------------------------------- /part7/bloglist-backend/index.js: -------------------------------------------------------------------------------- 1 | const http = require("http"); 2 | const config = require("./utils/config"); 3 | const app = require("./app"); 4 | const logger = require("./utils/logger"); 5 | 6 | const server = http.createServer(app); 7 | 8 | server.listen(config.PORT, () => { 9 | logger.info(`Server running on port ${config.PORT}`); 10 | }); 11 | -------------------------------------------------------------------------------- /part7/bloglist-backend/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: "node", 3 | }; 4 | -------------------------------------------------------------------------------- /part7/bloglist-backend/models/blog.js: -------------------------------------------------------------------------------- 1 | const mongoose = require("mongoose"); 2 | 3 | const blogSchema = mongoose.Schema({ 4 | title: { type: String, required: true }, 5 | author: String, 6 | url: { type: String, required: true }, 7 | likes: Number, 8 | user: { type: mongoose.SchemaTypes.ObjectId, ref: "User" }, 9 | comments: [String], 10 | }); 11 | 12 | blogSchema.set("toJSON", { 13 | transform: (document, returnedObject) => { 14 | returnedObject.id = returnedObject._id.toString(); 15 | delete returnedObject._id; 16 | delete returnedObject.__v; 17 | }, 18 | }); 19 | 20 | module.exports = mongoose.model("Blog", blogSchema); 21 | -------------------------------------------------------------------------------- /part7/bloglist-backend/models/user.js: -------------------------------------------------------------------------------- 1 | const mongoose = require("mongoose"); 2 | const uniqueValidator = require("mongoose-unique-validator"); 3 | 4 | const userSchema = mongoose.Schema({ 5 | username: { type: String, unique: true }, 6 | passwordHash: { type: String }, 7 | name: { type: String }, 8 | blogs: [{ type: mongoose.SchemaTypes.ObjectId, ref: "Blog" }], 9 | }); 10 | 11 | userSchema.plugin(uniqueValidator); 12 | 13 | userSchema.set("toJSON", { 14 | transform: (document, returnedObject) => { 15 | returnedObject.id = returnedObject._id.toString(); 16 | delete returnedObject._id; 17 | delete returnedObject.passwordHash; 18 | delete returnedObject.__v; 19 | }, 20 | }); 21 | 22 | module.exports = mongoose.model("User", userSchema); 23 | -------------------------------------------------------------------------------- /part7/bloglist-backend/requests/blogs_api.rest: -------------------------------------------------------------------------------- 1 | # VARIABLES 2 | @baseUrl = http://localhost:3003 3 | @contentType = application/json;charset=utf-8 4 | @BlogId = {{getBlogs.response.body.$[-1:].id}} 5 | @token = {{loginUser.response.body.token}} 6 | 7 | 8 | ### Get Users Collection 9 | # @name getUsers 10 | GET {{baseUrl}}/api/users HTTP/1.1 11 | 12 | 13 | ### Create Valid User 14 | # @name createUser 15 | POST {{baseUrl}}/api/users HTTP/1.1 16 | Content-Type: {{contentType}} 17 | 18 | { 19 | "username": "username", 20 | "name": "Uchiha Madara", 21 | "password": "password" 22 | } 23 | 24 | ### Login Valid User 25 | # @name loginUser 26 | POST {{baseUrl}}/api/login HTTP/1.1 27 | Content-Type: {{contentType}} 28 | 29 | { 30 | "username": "username", 31 | "password": "password" 32 | } 33 | 34 | ### Get Blogs Collection: 35 | # @name getBlogs 36 | GET {{baseUrl}}/api/blogs HTTP/1.1 37 | 38 | ### Get Specific Blog 39 | GET {{baseUrl}}/api/blogs/{{BlogId}} HTTP/1.1 40 | 41 | ### Delete Specific Blog 42 | DELETE {{baseUrl}}/api/blogs/{{BlogId}} HTTP/1.1 43 | Authorization: Bearer {{token}} 44 | 45 | ### Create Valid Blog 46 | # @name createBlog 47 | POST {{baseUrl}}/api/blogs HTTP/1.1 48 | Content-Type: {{contentType}} 49 | Authorization: Bearer {{token}} 50 | 51 | { 52 | "title": "Type wars", 53 | "author": "Robert C. Martin", 54 | "url": "http://blog.cleancoder.com/uncle-bob/2016/05/01/TypeWars.html", 55 | "likes": 2 56 | } 57 | -------------------------------------------------------------------------------- /part7/bloglist-backend/tests/mockDb_helper.js: -------------------------------------------------------------------------------- 1 | const mongoose = require("mongoose"); 2 | const { MongoMemoryServer } = require("mongodb-memory-server-core"); 3 | 4 | const mongod = new MongoMemoryServer(); 5 | 6 | /** 7 | * Connect to the in-memory database. 8 | */ 9 | module.exports.connect = async () => { 10 | const uri = await mongod.getConnectionString(); 11 | 12 | const mongooseOpts = { 13 | useNewUrlParser: true, 14 | useUnifiedTopology: true, 15 | }; 16 | 17 | mongoose.set("useCreateIndex", true); 18 | mongoose.set("useFindAndModify", false); 19 | 20 | await mongoose.connect(uri, mongooseOpts); 21 | }; 22 | 23 | /** 24 | * Drop database, close the connection and stop mongod. 25 | */ 26 | module.exports.closeDatabase = async () => { 27 | await mongoose.connection.dropDatabase(); 28 | await mongoose.connection.close(); 29 | await mongod.stop(); 30 | }; 31 | 32 | /** 33 | * Remove all the data for all db collections. 34 | */ 35 | module.exports.clearDatabase = async () => { 36 | const { collections } = mongoose.connection; 37 | 38 | Object.keys(collections).forEach(async (key) => { 39 | const collection = collections[key]; 40 | await collection.deleteMany(); 41 | }); 42 | }; 43 | -------------------------------------------------------------------------------- /part7/bloglist-backend/utils/config.js: -------------------------------------------------------------------------------- 1 | require("dotenv").config(); 2 | 3 | const { PORT, MONGODB_URI } = process.env; 4 | 5 | module.exports = { PORT, MONGODB_URI }; 6 | -------------------------------------------------------------------------------- /part7/bloglist-backend/utils/db_helper.js: -------------------------------------------------------------------------------- 1 | const mongoose = require("mongoose"); 2 | const config = require("./config"); 3 | const logger = require("./logger"); 4 | 5 | module.exports.connect = async () => { 6 | mongoose.connection.on("connected", () => { 7 | logger.info("Connection Established"); 8 | }); 9 | 10 | mongoose.connection.on("reconnected", () => { 11 | logger.info("Connection Reestablished"); 12 | }); 13 | 14 | mongoose.connection.on("disconnected", () => { 15 | logger.info("Connection Disconnected"); 16 | }); 17 | 18 | mongoose.connection.on("close", () => { 19 | logger.info("Connection Closed"); 20 | }); 21 | 22 | mongoose.connection.on("error", (error) => { 23 | logger.error(`ERROR: ${error}`); 24 | }); 25 | 26 | if (process.env.NODE_ENV === "test") { 27 | return Promise.resolve(""); 28 | } 29 | 30 | mongoose.set("useCreateIndex", true); 31 | mongoose.set("useFindAndModify", false); 32 | 33 | return mongoose.connect(config.MONGODB_URI, { 34 | useNewUrlParser: true, 35 | useUnifiedTopology: true, 36 | }); 37 | }; 38 | -------------------------------------------------------------------------------- /part7/bloglist-backend/utils/error_helper.js: -------------------------------------------------------------------------------- 1 | const logger = require("../utils/logger"); 2 | 3 | class ErrorHelper extends Error { 4 | constructor(statusCode, kind, messages = []) { 5 | super(); 6 | this.statusCode = statusCode; 7 | this.kind = kind; 8 | this.messages = messages; 9 | this.message = this.messages.join("\n"); 10 | } 11 | } 12 | const handleError = (err, res) => { 13 | try { 14 | const { statusCode, kind, message, messages } = err; 15 | 16 | res.status(statusCode).json({ 17 | statusCode, 18 | kind, 19 | message, 20 | messages, 21 | }); 22 | } catch (exception) { 23 | logger.error("IN handleError helper\n", exception.message); 24 | } 25 | }; 26 | 27 | module.exports = { 28 | ErrorHelper, 29 | handleError, 30 | }; 31 | -------------------------------------------------------------------------------- /part7/bloglist-backend/utils/logger.js: -------------------------------------------------------------------------------- 1 | const info = (...params) => { 2 | if (process.env.NODE_ENV !== "test") { 3 | console.log(...params); 4 | } 5 | }; 6 | 7 | const error = (...params) => { 8 | console.error(...params); 9 | }; 10 | 11 | module.exports = { 12 | info, 13 | error, 14 | }; 15 | -------------------------------------------------------------------------------- /part7/bloglist-frontend/.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build -------------------------------------------------------------------------------- /part7/bloglist-frontend/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | es6: true, 4 | node: true, 5 | "jest/globals": true, 6 | "cypress/globals": true, 7 | }, 8 | extends: [ 9 | "react-app", 10 | "plugin:jsx-a11y/recommended", 11 | "plugin:prettier/recommended", 12 | ], 13 | globals: { 14 | Atomics: "readonly", 15 | SharedArrayBuffer: "readonly", 16 | }, 17 | parserOptions: { 18 | ecmaVersion: 2018, 19 | }, 20 | plugins: ["jest", "cypress", "jsx-a11y", "prettier"], 21 | rules: { 22 | "prettier/prettier": [ 23 | "error", 24 | { 25 | trailingComma: "es5", 26 | arrowParens: "always", 27 | printWidth: 80, 28 | tabWidth: 2, 29 | semi: true, 30 | singleQuote: false, 31 | bracketSpacing: true, 32 | }, 33 | ], 34 | "react/jsx-filename-extension": [1, { extensions: [".js", ".jsx"] }], 35 | "no-param-reassign": ["error", { props: false }], 36 | "no-console": 0, 37 | // "react/prop-types": 0, 38 | "jsx-a11y/label-has-associated-control": [ 39 | 2, 40 | { 41 | labelComponents: [], 42 | labelAttributes: [], 43 | controlComponents: ["Input"], 44 | assert: "either", 45 | depth: 3, 46 | }, 47 | ], 48 | }, 49 | }; 50 | -------------------------------------------------------------------------------- /part7/bloglist-frontend/README.md: -------------------------------------------------------------------------------- 1 | # Full Stack Open 2020 - Exercise Solutions - Bloglist Frontend 2 | 3 | ## Part 7 - [React router, custom hooks, styling app with CSS and webpack](https://fullstackopen.com/en/part7) 4 | 5 | Requires [bloglist-backend](https://github.com/jeremy-ebinum/full-stack-open-2020/tree/master/part7/bloglist-backend) to be running. However, tests except the cypress E2E tests can be run without it. 6 | 7 | ## Available Scripts 8 | 9 | In the project directory, you can run: 10 | 11 | ### `npm start` 12 | 13 | Runs the app in the development mode.
14 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 15 | 16 | The page will reload if you make edits.
17 | 18 | ### `npm start:test` 19 | 20 | Runs the app in test mode for the cypress E2E tests 21 | 22 | ### `npm run build` 23 | 24 | Builds the app for production to the `build` folder.
25 | 26 | ### `npm run build:analyze` 27 | 28 | Builds the app for production to the `build` folder.
29 | Also enables stats logging and webpack-bundle-analyzer 30 | 31 | ### `npm test` 32 | 33 | Launches the test runner 34 | 35 | ### `npm run test:watch` 36 | 37 | Launches the test runner in the interactive watch mode.
38 | 39 | ### `npm run cypress:open` 40 | 41 | Launches cypress for End-to-End tests 42 | 43 | ### `npm run test:coverage` 44 | 45 | Launches the test runner and generates coverage report 46 | 47 | ### `npm run lint` 48 | 49 | Lints project with eslint 50 | -------------------------------------------------------------------------------- /part7/bloglist-frontend/__mocks__/fileMock.js: -------------------------------------------------------------------------------- 1 | module.exports = "test-file-stub"; 2 | -------------------------------------------------------------------------------- /part7/bloglist-frontend/__mocks__/styleMock.js: -------------------------------------------------------------------------------- 1 | module.exports = {}; 2 | -------------------------------------------------------------------------------- /part7/bloglist-frontend/cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "baseUrl": "http://localhost:3000" 3 | } 4 | -------------------------------------------------------------------------------- /part7/bloglist-frontend/cypress/fixtures/blogs.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "title": "First Test Blog Title", 4 | "author": "First Test Blog Author", 5 | "url": "http://site.com" 6 | }, 7 | { 8 | "title": "Second Test Blog Title", 9 | "author": "Second Test Blog Author", 10 | "url": "http://site.com" 11 | }, 12 | { 13 | "title": "Third Test Blog Title", 14 | "author": "Third Test Blog Author", 15 | "url": "http://site.com" 16 | } 17 | ] 18 | -------------------------------------------------------------------------------- /part7/bloglist-frontend/cypress/fixtures/users.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "Test User", 4 | "username": "test_username", 5 | "password": "test_password" 6 | }, 7 | { 8 | "name": "Other Test User", 9 | "username": "other_test_username", 10 | "password": "ohter_test_password" 11 | } 12 | ] 13 | -------------------------------------------------------------------------------- /part7/bloglist-frontend/cypress/plugins/index.js: -------------------------------------------------------------------------------- 1 | /// 2 | // *********************************************************** 3 | // This example plugins/index.js can be used to load plugins 4 | // 5 | // You can change the location of this file or turn off loading 6 | // the plugins file with the 'pluginsFile' configuration option. 7 | // 8 | // You can read more here: 9 | // https://on.cypress.io/plugins-guide 10 | // *********************************************************** 11 | 12 | // This function is called when a project is opened or re-opened (e.g. due to 13 | // the project's config changing) 14 | 15 | /** 16 | * @type {Cypress.PluginConfig} 17 | */ 18 | module.exports = (on, config) => { 19 | // `on` is used to hook into various events Cypress emits 20 | // `config` is the resolved Cypress config 21 | } 22 | -------------------------------------------------------------------------------- /part7/bloglist-frontend/cypress/support/commands.js: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example commands.js shows you how to 3 | // create various custom commands and overwrite 4 | // existing commands. 5 | // 6 | // For more comprehensive examples of custom 7 | // commands please read more here: 8 | // https://on.cypress.io/custom-commands 9 | // *********************************************** 10 | // 11 | // 12 | // -- This is a parent command -- 13 | // Cypress.Commands.add("login", (email, password) => { ... }) 14 | // 15 | // 16 | // -- This is a child command -- 17 | // Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) 18 | // 19 | // 20 | // -- This is a dual command -- 21 | // Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) 22 | // 23 | // 24 | // -- This will overwrite an existing command -- 25 | // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) 26 | -------------------------------------------------------------------------------- /part7/bloglist-frontend/cypress/support/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands' 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | -------------------------------------------------------------------------------- /part7/bloglist-frontend/cypress/support/utils.js: -------------------------------------------------------------------------------- 1 | export const uiLogin = (username, password) => { 2 | cy.get("[data-testid='Login_username']").type(username); 3 | cy.get("[data-testid='Login_password']").type(password); 4 | cy.get("[data-testid='Login_submitButton']").click(); 5 | }; 6 | 7 | export const uiCreateBlog = ({ title, author, url }) => { 8 | cy.get("[data-testid='BlogForm_title']").type(title); 9 | cy.get("[data-testid='BlogForm_author']").type(author); 10 | cy.get("[data-testid='BlogForm_url']").type(url); 11 | cy.get("[data-testid='BlogForm_submitButton']").click(); 12 | }; 13 | -------------------------------------------------------------------------------- /part7/bloglist-frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeremy-ebinum/full-stack-open-2020/ce8ebbefb5f60c71362b12849129167348891680/part7/bloglist-frontend/public/favicon.ico -------------------------------------------------------------------------------- /part7/bloglist-frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Blog List | Full Stack Open 2019 Project 7 | 8 | 9 | 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /part7/bloglist-frontend/src/components/Home.js: -------------------------------------------------------------------------------- 1 | import React, { useLayoutEffect } from "react"; 2 | import NavBar from "./NavBar"; 3 | import NotificationList from "./NotificationList"; 4 | import BlogForm from "./BlogForm"; 5 | import BlogList from "./BlogList"; 6 | import ToTopScroller from "./ToTopScroller"; 7 | import { getTestIDs } from "../helpers/testHelper"; 8 | 9 | export const testIDs = getTestIDs(); 10 | 11 | const Home = () => { 12 | useLayoutEffect(() => { 13 | const rootStyle = document.documentElement.style; 14 | document.title = "Blog List | Home"; 15 | 16 | rootStyle.setProperty("--body-bg-color", "var(--light-color)"); 17 | }, []); 18 | 19 | return ( 20 |
21 | 22 |
23 |
24 | 25 | 26 | 27 |
28 | 29 | 30 |
31 |
32 | ); 33 | }; 34 | 35 | export default Home; 36 | -------------------------------------------------------------------------------- /part7/bloglist-frontend/src/components/Modal.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import PropTypes from "prop-types"; 3 | 4 | const Modal = ({ testid, children }) => { 5 | useEffect(() => { 6 | const rootStyle = document.documentElement.style; 7 | const wrapper = document.querySelector(".js-wrapper"); 8 | rootStyle.setProperty("--body-overflow", "hidden"); 9 | wrapper.classList.add("hasModal"); 10 | return () => { 11 | rootStyle.setProperty("--body-overflow", "auto"); 12 | wrapper.classList.remove("hasModal"); 13 | }; 14 | }, []); 15 | 16 | return ( 17 |
18 |
{children}
19 |
20 | ); 21 | }; 22 | 23 | Modal.propTypes = { 24 | children: PropTypes.node.isRequired, 25 | testid: PropTypes.string, 26 | }; 27 | 28 | Modal.defaultProps = { 29 | testid: null, 30 | }; 31 | 32 | export default Modal; 33 | -------------------------------------------------------------------------------- /part7/bloglist-frontend/src/components/ModalSpinner.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 3 | import { faSpinner } from "@fortawesome/free-solid-svg-icons/faSpinner"; 4 | import { getTestIDs } from "../helpers/testHelper"; 5 | import Modal from "./Modal"; 6 | 7 | export const testIDs = getTestIDs(); 8 | 9 | const ModalSpinner = () => { 10 | return ( 11 | 12 | 17 | 18 | ); 19 | }; 20 | 21 | export default ModalSpinner; 22 | -------------------------------------------------------------------------------- /part7/bloglist-frontend/src/components/NotFound.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const NotFound = () => { 4 | return ( 5 |
6 |

Not Found

7 |

8 | Sorry, The Page You Are Looking For Does Not Exist 9 |

10 |
11 | ); 12 | }; 13 | 14 | export default NotFound; 15 | -------------------------------------------------------------------------------- /part7/bloglist-frontend/src/components/NotFoundPage.js: -------------------------------------------------------------------------------- 1 | import React, { useLayoutEffect } from "react"; 2 | import NavBar from "./NavBar"; 3 | import NotificationList from "./NotificationList"; 4 | 5 | const NotFound = () => { 6 | useLayoutEffect(() => { 7 | const rootStyle = document.documentElement.style; 8 | document.title = "Blog List | Not Found"; 9 | 10 | rootStyle.setProperty("--body-bg-color", "var(--light-color)"); 11 | }, []); 12 | 13 | return ( 14 |
15 | 16 |
17 | 18 |
19 |

Not Found

20 |

21 | Sorry, The Page You Are Looking For Does Not Exist 22 |

23 |
24 |
25 |
26 | ); 27 | }; 28 | 29 | export default NotFound; 30 | -------------------------------------------------------------------------------- /part7/bloglist-frontend/src/components/Notification.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import { getTestIDs } from "../helpers/testHelper"; 4 | 5 | export const testIDs = getTestIDs(); 6 | 7 | const alertTypes = { 8 | error: "c-alert c-alert--error", 9 | success: "c-alert c-alert--success", 10 | info: "c-alert c-alert--info", 11 | default: "c-alert", 12 | }; 13 | 14 | const Notification = ({ id, type, message }) => { 15 | return ( 16 |
20 | {message} 21 |
22 | ); 23 | }; 24 | 25 | Notification.propTypes = { 26 | id: PropTypes.string.isRequired, 27 | type: PropTypes.string.isRequired, 28 | message: PropTypes.string.isRequired, 29 | }; 30 | 31 | export default Notification; 32 | -------------------------------------------------------------------------------- /part7/bloglist-frontend/src/components/PrivateRoute.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { connect } from "react-redux"; 3 | import { Route, Redirect } from "react-router-dom"; 4 | 5 | const PrivateRoute = ({ isAuth, ...routeProps }) => { 6 | return ( 7 | <> 8 | {!isAuth && ( 9 | 15 | )} 16 | 17 | {isAuth && } 18 | 19 | ); 20 | }; 21 | 22 | const mapStateToProps = (state, ownProps) => { 23 | const { isAuth } = state.auth; 24 | 25 | return { isAuth, ...ownProps }; 26 | }; 27 | 28 | export default connect(mapStateToProps)(PrivateRoute); 29 | -------------------------------------------------------------------------------- /part7/bloglist-frontend/src/components/PublicRoute.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { connect } from "react-redux"; 3 | import { Route, Redirect } from "react-router-dom"; 4 | 5 | const PublicRoute = ({ isAuth, restricted, ...routeProps }) => { 6 | return ( 7 | <> 8 | {isAuth && restricted ? : } 9 | 10 | ); 11 | }; 12 | 13 | const mapStateToProps = (state, ownProps) => { 14 | const { isAuth } = state.auth; 15 | 16 | return { isAuth, ...ownProps }; 17 | }; 18 | 19 | export default connect(mapStateToProps)(PublicRoute); 20 | -------------------------------------------------------------------------------- /part7/bloglist-frontend/src/configureStore.js: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware, compose } from "redux"; 2 | import { composeWithDevTools } from "redux-devtools-extension"; 3 | import createRootReducer from "./reducers"; 4 | import thunk from "redux-thunk"; 5 | import { createBrowserHistory } from "history"; 6 | import { routerMiddleware } from "connected-react-router"; 7 | 8 | export const history = createBrowserHistory(); 9 | 10 | const configureStore = (preloadedState) => { 11 | let store; 12 | 13 | if (process.env.NODE_ENV === "development") { 14 | store = createStore( 15 | createRootReducer(history), 16 | preloadedState, 17 | composeWithDevTools(applyMiddleware(routerMiddleware(history), thunk)) 18 | ); 19 | } else { 20 | store = createStore( 21 | createRootReducer(history), 22 | preloadedState, 23 | compose(applyMiddleware(routerMiddleware(history), thunk)) 24 | ); 25 | } 26 | 27 | return store; 28 | }; 29 | 30 | export default configureStore; 31 | -------------------------------------------------------------------------------- /part7/bloglist-frontend/src/fonts/Dosis-Light.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeremy-ebinum/full-stack-open-2020/ce8ebbefb5f60c71362b12849129167348891680/part7/bloglist-frontend/src/fonts/Dosis-Light.woff2 -------------------------------------------------------------------------------- /part7/bloglist-frontend/src/fonts/Dosis-Medium.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeremy-ebinum/full-stack-open-2020/ce8ebbefb5f60c71362b12849129167348891680/part7/bloglist-frontend/src/fonts/Dosis-Medium.woff2 -------------------------------------------------------------------------------- /part7/bloglist-frontend/src/fonts/Roboto-Bold-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeremy-ebinum/full-stack-open-2020/ce8ebbefb5f60c71362b12849129167348891680/part7/bloglist-frontend/src/fonts/Roboto-Bold-webfont.woff -------------------------------------------------------------------------------- /part7/bloglist-frontend/src/fonts/Roboto-Regular-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeremy-ebinum/full-stack-open-2020/ce8ebbefb5f60c71362b12849129167348891680/part7/bloglist-frontend/src/fonts/Roboto-Regular-webfont.woff -------------------------------------------------------------------------------- /part7/bloglist-frontend/src/hooks/index.js: -------------------------------------------------------------------------------- 1 | import { useState, useCallback } from "react"; 2 | 3 | export const useField = ({ 4 | id = null, 5 | className = "c-row__input", 6 | type = "text", 7 | name = null, 8 | placeholder = null, 9 | }) => { 10 | const [value, setValue] = useState(""); 11 | 12 | const onChange = useCallback((event) => { 13 | setValue(event.target.value); 14 | }, []); 15 | 16 | const reset = useCallback(() => setValue(""), []); 17 | 18 | return [{ id, className, type, name, placeholder, value, onChange }, reset]; 19 | }; 20 | -------------------------------------------------------------------------------- /part7/bloglist-frontend/src/index.js: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | import React from "react"; 3 | import ReactDOM from "react-dom"; 4 | import { AppContainer } from "react-hot-loader"; 5 | import configureStore, { history } from "./configureStore"; 6 | import { Provider } from "react-redux"; 7 | import App from "./App"; 8 | import "./index.css"; 9 | 10 | if (process.env.NODE_ENV === "development") { 11 | const whyDidYouRender = require("@welldone-software/why-did-you-render"); 12 | whyDidYouRender(React); 13 | } 14 | 15 | const store = configureStore(); 16 | 17 | const render = () => { 18 | ReactDOM.render( 19 | 20 | 21 | 22 | 23 | , 24 | document.getElementById("root") 25 | ); 26 | }; 27 | 28 | render(); 29 | 30 | if (module.hot) { 31 | module.hot.accept("./App", () => { 32 | render(); 33 | }); 34 | } 35 | -------------------------------------------------------------------------------- /part7/bloglist-frontend/src/reducers/index.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from "redux"; 2 | import { connectRouter } from "connected-react-router"; 3 | 4 | import notificationReducer from "./notificationReducer"; 5 | import requestReducer from "./requestReducer"; 6 | import blogReducer from "./blogReducer"; 7 | import authReducer from "./authReducer"; 8 | import userReducer from "./userReducer"; 9 | 10 | const createRootReducer = (history) => 11 | combineReducers({ 12 | router: connectRouter(history), 13 | notifications: notificationReducer, 14 | requests: requestReducer, 15 | blogs: blogReducer, 16 | users: userReducer, 17 | auth: authReducer, 18 | }); 19 | 20 | export default createRootReducer; 21 | -------------------------------------------------------------------------------- /part7/bloglist-frontend/src/reducers/userReducer.js: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | 3 | import usersService from "../services/users"; 4 | import { setRequestState } from "./requestReducer"; 5 | import { displayNotification } from "./notificationReducer"; 6 | 7 | import { logger } from "../utils"; 8 | 9 | export const initialState = []; 10 | 11 | const userReducer = (state = initialState, action) => { 12 | switch (action.type) { 13 | case "INIT_USERS": 14 | if (action.data && !Array.isArray(action.data)) { 15 | throw new Error("Users should be passed as an array"); 16 | } 17 | return action.data; 18 | default: 19 | return state; 20 | } 21 | }; 22 | 23 | export const initUsers = () => { 24 | return async (dispatch, getState) => { 25 | try { 26 | const source = await getState().requests.initUsers.source; 27 | dispatch(setRequestState("initUsers", "LOADING")); 28 | const users = await usersService.getAll(source.token); 29 | 30 | dispatch(setRequestState("initUsers", "SUCCESS")); 31 | dispatch({ type: "INIT_USERS", data: users }); 32 | } catch (e) { 33 | if (!axios.isCancel(e)) { 34 | console.log(e.message); 35 | logger.error(e); 36 | dispatch(setRequestState("initUsers", "FAILURE")); 37 | dispatch(displayNotification("Oops! Something went wrong", "error")); 38 | } 39 | } 40 | }; 41 | }; 42 | 43 | export default userReducer; 44 | -------------------------------------------------------------------------------- /part7/bloglist-frontend/src/services/blogs.js: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | 3 | const baseUrl = "/api/blogs"; 4 | 5 | let token = null; 6 | 7 | const setToken = (newToken) => { 8 | if (newToken) { 9 | token = `Bearer ${newToken}`; 10 | } else { 11 | token = null; 12 | } 13 | }; 14 | 15 | const create = async (newBlog) => { 16 | const config = { headers: { Authorization: token } }; 17 | const response = await axios.post(baseUrl, newBlog, config); 18 | 19 | return response.data; 20 | }; 21 | 22 | const getAll = async (cancelToken) => { 23 | const response = await axios.get(baseUrl, { cancelToken }); 24 | 25 | return response.data; 26 | }; 27 | 28 | const update = async (id, updatedBlog) => { 29 | const config = { headers: { Authorization: token } }; 30 | const response = await axios.put(`${baseUrl}/${id}`, updatedBlog, config); 31 | 32 | return response.data; 33 | }; 34 | 35 | const remove = async (id) => { 36 | const config = { headers: { Authorization: token } }; 37 | const response = await axios.delete(`${baseUrl}/${id}`, config); 38 | 39 | return response.data; 40 | }; 41 | 42 | const comment = async (id, comment) => { 43 | const config = { 44 | headers: { Authorization: token, "Content-Type": "text/plain" }, 45 | }; 46 | const response = await axios.post( 47 | `${baseUrl}/${id}/comments`, 48 | comment, 49 | config 50 | ); 51 | 52 | return response.data; 53 | }; 54 | 55 | export default { setToken, create, getAll, update, remove, comment }; 56 | -------------------------------------------------------------------------------- /part7/bloglist-frontend/src/services/login.js: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | 3 | const baseUrl = "/api/login"; 4 | 5 | const login = async (credentials) => { 6 | const response = await axios.post(baseUrl, credentials); 7 | 8 | return response.data; 9 | }; 10 | 11 | export default { login }; 12 | -------------------------------------------------------------------------------- /part7/bloglist-frontend/src/services/users.js: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | 3 | const baseUrl = "/api/users"; 4 | 5 | const getAll = async (cancelToken) => { 6 | const response = await axios.get(baseUrl, { cancelToken }); 7 | 8 | return response.data; 9 | }; 10 | 11 | export default { getAll }; 12 | -------------------------------------------------------------------------------- /part7/bloglist-frontend/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/extend-expect"; 6 | 7 | /* Mocks 8 | *******************************************************************************/ 9 | // localStorage 10 | let savedItems = {}; 11 | 12 | const localStorageMock = { 13 | setItem: (key, item) => { 14 | savedItems[key] = item; 15 | }, 16 | getItem: (key) => savedItems[key], 17 | removeItem: (key) => delete savedItems[key], 18 | clear: () => { 19 | savedItems = {}; 20 | }, 21 | }; 22 | 23 | global.localStorage = localStorageMock; 24 | 25 | // window.confirm 26 | window.confirm = () => true; 27 | -------------------------------------------------------------------------------- /part7/bloglist-frontend/src/utils/index.js: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | import axios from "axios"; 3 | 4 | export const getTrimmedStr = (str) => { 5 | return str.length > 50 ? str.slice(0, 49) + "..." : str; 6 | }; 7 | 8 | export const getCancelTokenSource = () => { 9 | const CancelToken = axios.CancelToken; 10 | const source = CancelToken.source(); 11 | 12 | return source; 13 | }; 14 | 15 | export const logger = { 16 | info: function(...params) { 17 | if (process.env.NODE_ENV !== "test") { 18 | console.log(...params); 19 | } 20 | }, 21 | 22 | error: function(...params) { 23 | console.error(...params); 24 | }, 25 | 26 | test: function(...params) { 27 | if (process.env.NODE_ENV === "test") { 28 | console.log(...params); 29 | } 30 | }, 31 | }; 32 | -------------------------------------------------------------------------------- /part7/country-hook/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "countryhook", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^4.2.4", 7 | "@testing-library/react": "^9.4.0", 8 | "@testing-library/user-event": "^7.2.1", 9 | "axios": "^0.19.2", 10 | "react": "^16.12.0", 11 | "react-dom": "^16.12.0", 12 | "react-scripts": "3.4.0" 13 | }, 14 | "scripts": { 15 | "start": "react-scripts start", 16 | "build": "react-scripts build", 17 | "test": "react-scripts test", 18 | "eject": "react-scripts eject" 19 | }, 20 | "eslintConfig": { 21 | "extends": "react-app" 22 | }, 23 | "browserslist": { 24 | "production": [ 25 | ">0.2%", 26 | "not dead", 27 | "not op_mini all" 28 | ], 29 | "development": [ 30 | "last 1 chrome version", 31 | "last 1 firefox version", 32 | "last 1 safari version" 33 | ] 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /part7/country-hook/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeremy-ebinum/full-stack-open-2020/ce8ebbefb5f60c71362b12849129167348891680/part7/country-hook/public/favicon.ico -------------------------------------------------------------------------------- /part7/country-hook/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeremy-ebinum/full-stack-open-2020/ce8ebbefb5f60c71362b12849129167348891680/part7/country-hook/public/logo192.png -------------------------------------------------------------------------------- /part7/country-hook/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeremy-ebinum/full-stack-open-2020/ce8ebbefb5f60c71362b12849129167348891680/part7/country-hook/public/logo512.png -------------------------------------------------------------------------------- /part7/country-hook/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 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 | -------------------------------------------------------------------------------- /part7/country-hook/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /part7/country-hook/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import App from './App' 4 | 5 | ReactDOM.render(, document.getElementById('root')) -------------------------------------------------------------------------------- /part7/routed-anecdotes/.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build -------------------------------------------------------------------------------- /part7/routed-anecdotes/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | es6: true, 4 | node: true, 5 | }, 6 | extends: ["react-app", "plugin:prettier/recommended"], 7 | globals: { 8 | Atomics: "readonly", 9 | SharedArrayBuffer: "readonly", 10 | }, 11 | parserOptions: { 12 | ecmaVersion: 2018, 13 | }, 14 | plugins: ["prettier"], 15 | rules: { 16 | "prettier/prettier": [ 17 | "error", 18 | { 19 | trailingComma: "es5", 20 | arrowParens: "always", 21 | printWidth: 80, 22 | tabWidth: 2, 23 | semi: true, 24 | singleQuote: false, 25 | bracketSpacing: true, 26 | }, 27 | ], 28 | "react/jsx-filename-extension": [1, { extensions: [".js", ".jsx"] }], 29 | "no-param-reassign": ["error", { props: false }], 30 | "no-console": 0, 31 | }, 32 | }; 33 | -------------------------------------------------------------------------------- /part7/routed-anecdotes/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "routed-anecdotes", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "react": "^16.12.0", 7 | "react-dom": "^16.12.0", 8 | "react-router-dom": "^5.1.2", 9 | "react-scripts": "3.4.0" 10 | }, 11 | "devDependencies": { 12 | "eslint-config-prettier": "^6.10.0", 13 | "eslint-plugin-prettier": "^3.1.2", 14 | "prettier": "^1.19.1" 15 | }, 16 | "scripts": { 17 | "start": "react-scripts start", 18 | "build": "react-scripts build", 19 | "test": "react-scripts test", 20 | "eject": "react-scripts eject" 21 | }, 22 | "eslintConfig": { 23 | "extends": "react-app" 24 | }, 25 | "browserslist": { 26 | "production": [ 27 | ">0.2%", 28 | "not dead", 29 | "not op_mini all" 30 | ], 31 | "development": [ 32 | "last 1 chrome version", 33 | "last 1 firefox version", 34 | "last 1 safari version" 35 | ] 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /part7/routed-anecdotes/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeremy-ebinum/full-stack-open-2020/ce8ebbefb5f60c71362b12849129167348891680/part7/routed-anecdotes/public/favicon.ico -------------------------------------------------------------------------------- /part7/routed-anecdotes/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /part7/routed-anecdotes/src/components/About.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const About = () => ( 4 |
5 |

About anecdote app

6 |

According to Wikipedia:

7 | 8 | 9 | An anecdote is a brief, revealing account of an individual person or an 10 | incident. Occasionally humorous, anecdotes differ from jokes because their 11 | primary purpose is not simply to provoke laughter but to reveal a truth 12 | more general than the brief tale itself, such as to characterize a person 13 | by delineating a specific quirk or trait, to communicate an abstract idea 14 | about a person, place, or thing through the concrete details of a short 15 | narrative. An anecdote is "a story with a point." 16 | 17 | 18 |

19 | Software engineering is full of excellent anecdotes, at this app you can 20 | find the best and add more. 21 |

22 |
23 | ); 24 | 25 | export default About; 26 | -------------------------------------------------------------------------------- /part7/routed-anecdotes/src/components/Anecdote.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const Anecdote = ({ anecdote, vote }) => ( 4 | <> 5 | {!anecdote &&

Anecdote does not exist

} 6 | 7 | {anecdote && ( 8 |
9 |

10 | {anecdote.content} by {anecdote.author} 11 |

12 |

13 | has {anecdote.votes} {anecdote.votes === 1 ? "vote" : "votes"} 14 | 17 |

18 |

19 | for more info see{" "} 20 | 21 | {anecdote.info} 22 | 23 |

24 |
25 | )} 26 | 27 | ); 28 | 29 | export default Anecdote; 30 | -------------------------------------------------------------------------------- /part7/routed-anecdotes/src/components/AnecdoteList.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Link } from "react-router-dom"; 3 | 4 | const AnecdoteList = ({ anecdotes }) => ( 5 |
6 |

Anecdotes

7 |
    8 | {anecdotes.map((anecdote) => ( 9 |
  • 10 | {anecdote.content} 11 |
  • 12 | ))} 13 |
14 |
15 | ); 16 | 17 | export default AnecdoteList; 18 | -------------------------------------------------------------------------------- /part7/routed-anecdotes/src/components/CreateNew.js: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from "react"; 2 | import { useHistory } from "react-router-dom"; 3 | import { useField } from "../hooks"; 4 | 5 | const CreateNew = ({ addNew }) => { 6 | const [content, resetContent] = useField(); 7 | const [author, resetAuthor] = useField(); 8 | const [info, resetInfo] = useField({ type: "url" }); 9 | 10 | const history = useHistory(); 11 | 12 | const resetForm = useCallback(() => { 13 | resetContent(); 14 | resetAuthor(); 15 | resetInfo(); 16 | }, [resetContent, resetAuthor, resetInfo]); 17 | 18 | const handleSubmit = (e) => { 19 | e.preventDefault(); 20 | addNew({ 21 | content: content.value, 22 | author: author.value, 23 | info: info.value, 24 | votes: 0, 25 | }); 26 | resetForm(); 27 | history.push("/"); 28 | }; 29 | 30 | return ( 31 |
32 |

create a new anecdote

33 |
34 |
35 | content 36 | 37 |
38 |
39 | author 40 | 41 |
42 |
43 | url for more info 44 | 45 |
46 | 47 | 50 |
51 |
52 | ); 53 | }; 54 | 55 | export default CreateNew; 56 | -------------------------------------------------------------------------------- /part7/routed-anecdotes/src/components/Footer.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const Footer = () => ( 4 |
5 | Anecdote app for{" "} 6 | 7 | Full Stack Open 2019 8 | 9 |
10 | See{" "} 11 | 12 | this link 13 | {" "} 14 | for the source code. 15 |
16 | ); 17 | 18 | export default Footer; 19 | -------------------------------------------------------------------------------- /part7/routed-anecdotes/src/components/Menu.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Link } from "react-router-dom"; 3 | 4 | const Menu = () => { 5 | const padding = { 6 | paddingRight: 5, 7 | }; 8 | return ( 9 |
10 | 11 | Anecdotes 12 | 13 | 14 | Create New 15 | 16 | 17 | About 18 | 19 |
20 | ); 21 | }; 22 | 23 | export default Menu; 24 | -------------------------------------------------------------------------------- /part7/routed-anecdotes/src/hooks/index.js: -------------------------------------------------------------------------------- 1 | import { useState, useCallback } from "react"; 2 | 3 | export const useField = ({ type = "text" } = {}) => { 4 | const [value, setValue] = useState(""); 5 | 6 | const onChange = useCallback((event) => { 7 | setValue(event.target.value); 8 | }, []); 9 | 10 | const reset = useCallback(() => setValue(""), []); 11 | 12 | return [{ type, value, onChange }, reset]; 13 | }; 14 | -------------------------------------------------------------------------------- /part7/routed-anecdotes/src/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import { BrowserRouter as Router } from "react-router-dom"; 4 | import App from "./App"; 5 | 6 | ReactDOM.render( 7 | 8 | 9 | , 10 | document.getElementById("root") 11 | ); 12 | -------------------------------------------------------------------------------- /part7/ultimate-hooks/README.md: -------------------------------------------------------------------------------- 1 | # Full Stack Open 2020 - Exercise Solutions - Ultimate Hooks 2 | 3 | ## Part 7 - [React router, custom hooks, styling app with CSS and webpack](https://fullstackopen.com/en/part7) 4 | 5 | ## usage 6 | 7 | Start server to port 3005 with _npm run server_ 8 | 9 | Run frontend in development mode with _npm start_ 10 | -------------------------------------------------------------------------------- /part7/ultimate-hooks/db.json: -------------------------------------------------------------------------------- 1 | { 2 | "notes": [ 3 | { 4 | "content": "custom-hooks are awesome!", 5 | "id": 1 6 | }, 7 | { 8 | "content": "best feature ever <3", 9 | "id": 2 10 | }, 11 | { 12 | "content": "ultimate-hooks is now online", 13 | "id": 3 14 | }, 15 | { 16 | "content": "SPAAAAAAMMMMM", 17 | "id": 4 18 | }, 19 | { 20 | "content": "EGGSSSSSSSSSS", 21 | "id": 5 22 | } 23 | ], 24 | "persons": [ 25 | { 26 | "name": "mluukkai", 27 | "number": "040-5483923", 28 | "id": 1 29 | }, 30 | { 31 | "name": "jeremy ebinum", 32 | "number": "2348000000000", 33 | "id": 2 34 | } 35 | ] 36 | } -------------------------------------------------------------------------------- /part7/ultimate-hooks/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ultimate-hooks", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^4.2.4", 7 | "@testing-library/react": "^9.4.0", 8 | "@testing-library/user-event": "^7.2.1", 9 | "axios": "^0.19.2", 10 | "react": "^16.12.0", 11 | "react-dom": "^16.12.0", 12 | "react-scripts": "3.4.0" 13 | }, 14 | "scripts": { 15 | "start": "react-scripts start", 16 | "build": "react-scripts build", 17 | "test": "react-scripts test", 18 | "eject": "react-scripts eject", 19 | "server": "json-server --port=3005 --watch db.json" 20 | }, 21 | "eslintConfig": { 22 | "extends": "react-app" 23 | }, 24 | "browserslist": { 25 | "production": [ 26 | ">0.2%", 27 | "not dead", 28 | "not op_mini all" 29 | ], 30 | "development": [ 31 | "last 1 chrome version", 32 | "last 1 firefox version", 33 | "last 1 safari version" 34 | ] 35 | }, 36 | "devDependencies": { 37 | "json-server": "^0.15.1" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /part7/ultimate-hooks/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeremy-ebinum/full-stack-open-2020/ce8ebbefb5f60c71362b12849129167348891680/part7/ultimate-hooks/public/favicon.ico -------------------------------------------------------------------------------- /part7/ultimate-hooks/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /part7/ultimate-hooks/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import App from './App' 4 | 5 | ReactDOM.render(, document.getElementById('root')) 6 | -------------------------------------------------------------------------------- /part8/README.md: -------------------------------------------------------------------------------- 1 | # Full Stack Open 2020 - Exercise Solutions 2 | 3 | ## Part 8 - [GraphQL](https://fullstackopen.com/en/part8) 4 | 5 | Each of the following folders contain the final solutions to their respective exercises 6 | 7 | ### [library-backend](https://github.com/jeremy-ebinum/full-stack-open-2020/tree/master/part8/library-backend) 8 | 9 | - Exercises 8.1 - 8.7 — [Description](https://fullstackopen.com/en/part8/graph_ql_server#exercises-8-1-8-7) 10 | - Exercises 8.13 - 8.16 — [Description](https://fullstackopen.com/en/part8/database_and_user_administration#exercises-8-13-8-16) 11 | - Exercises 8.23 - 8.26 — [Description](https://fullstackopen.com/en/part8/fragments_and_subscriptions#exercises-8-23-8-26) 12 | 13 | ### [library-frontend](https://github.com/jeremy-ebinum/full-stack-open-2020/tree/master/part8/library-frontend) 14 | 15 | - Exercises 8.8 - 8.12 — [Description](https://fullstackopen.com/en/part8/react_and_graph_ql#exercises-8-8-8-12) 16 | - Exercises 8.13 - 8.16 — [Description](https://fullstackopen.com/en/part8/database_and_user_administration#exercises-8-13-8-16) 17 | - Exercises 8.17 - 8.22 — [Description](https://fullstackopen.com/en/part8/login_and_updating_the_cache#exercises-8-17-8-22) 18 | - Exercises 8.23 - 8.26 — [Description](https://fullstackopen.com/en/part8/fragments_and_subscriptions#exercises-8-23-8-26) 19 | -------------------------------------------------------------------------------- /part8/library-backend/.env.example: -------------------------------------------------------------------------------- 1 | MONGODB_URI=mongodb://[username:password@]host1[:port1][,...hostN[:portN]][/[database][?options]] 2 | JWT_SECRET=uh2e3q1!h5vq_re7ea_b4x4$-rbwy$iad!adf$g7ep5(wpg+^3 -------------------------------------------------------------------------------- /part8/library-backend/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | commonjs: true, 4 | es6: true, 5 | node: true, 6 | jest: true, 7 | }, 8 | extends: ["airbnb-base", "plugin:prettier/recommended"], 9 | globals: { 10 | Atomics: "readonly", 11 | SharedArrayBuffer: "readonly", 12 | }, 13 | parserOptions: { 14 | ecmaVersion: 2018, 15 | }, 16 | plugins: ["prettier"], 17 | rules: { 18 | "prettier/prettier": [ 19 | "error", 20 | { 21 | trailingComma: "es5", 22 | arrowParens: "always", 23 | printWidth: 80, 24 | tabWidth: 2, 25 | semi: true, 26 | singleQuote: false, 27 | bracketSpacing: true, 28 | }, 29 | ], 30 | "no-console": 0, 31 | "no-param-reassign": ["error", { props: false }], 32 | "no-underscore-dangle": [ 33 | "error", 34 | { allow: ["_id", "__v", "_countBy", "_merge"] }, 35 | ], 36 | }, 37 | }; 38 | -------------------------------------------------------------------------------- /part8/library-backend/README.md: -------------------------------------------------------------------------------- 1 | # Full Stack Open 2020 - Exercise Solutions - Library Backend 2 | 3 | ## Part 8 - [GraphQL](https://fullstackopen.com/en/part8) 4 | 5 | graphql powered server for the [library-frontend](https://github.com/jeremy-ebinum/full-stack-open-2020/tree/master/part8/library-frontend) project 6 | 7 | ## usage 8 | 9 | See .env.example for required env variables 10 | 11 | Start the server with _npm start_ 12 | 13 | Start the server in watch mode with _npm run watch_ 14 | -------------------------------------------------------------------------------- /part8/library-backend/graphql/loaders.js: -------------------------------------------------------------------------------- 1 | const DataLoader = require("dataloader"); 2 | const _countBy = require("lodash.countby"); 3 | const Book = require("../models/Book"); 4 | 5 | const createBookCountLoader = () => { 6 | return new DataLoader(async (authorIds) => { 7 | const books = await Book.find({}); 8 | const booksByAuthorId = books.map((b) => b.author); 9 | const authorIdCounts = _countBy(booksByAuthorId, (id) => id); 10 | 11 | return authorIds.map((id) => authorIdCounts[id] || 0); 12 | }); 13 | }; 14 | 15 | module.exports = { createBookCountLoader }; 16 | -------------------------------------------------------------------------------- /part8/library-backend/graphql/resolvers.js: -------------------------------------------------------------------------------- 1 | const _merge = require("lodash.merge"); 2 | 3 | const { resolvers: authorResolvers } = require("./schema/author"); 4 | const { resolvers: bookResolvers } = require("./schema/book"); 5 | const { resolvers: userResolvers } = require("./schema/user"); 6 | 7 | const resolvers = _merge({}, authorResolvers, bookResolvers, userResolvers); 8 | 9 | module.exports = resolvers; 10 | -------------------------------------------------------------------------------- /part8/library-backend/graphql/schema/root.js: -------------------------------------------------------------------------------- 1 | const { gql } = require("apollo-server"); 2 | 3 | const typeDef = gql` 4 | type Query { 5 | _root: String 6 | } 7 | 8 | type Mutation { 9 | _root: String 10 | } 11 | 12 | type Subscription { 13 | _root: String 14 | } 15 | `; 16 | 17 | module.exports = { typeDef }; 18 | -------------------------------------------------------------------------------- /part8/library-backend/graphql/typeDefs.js: -------------------------------------------------------------------------------- 1 | const { typeDef: base } = require("./schema/root"); 2 | const { typeDef: Author } = require("./schema/author"); 3 | const { typeDef: Book } = require("./schema/book"); 4 | const { typeDef: User } = require("./schema/user"); 5 | 6 | const typeDefs = [base, Author, Book, User]; 7 | 8 | module.exports = typeDefs; 9 | -------------------------------------------------------------------------------- /part8/library-backend/helpers/errorHelper.js: -------------------------------------------------------------------------------- 1 | const { ApolloError } = require("apollo-server"); 2 | 3 | module.exports.getModelValidationErrors = ({ errors }, model) => { 4 | const validationErrors = Object.values(errors).map((error) => { 5 | const path = error.path === "born" ? "birth year" : error.path; 6 | if (error.name === "CastError") { 7 | return `Invalid ${model} ${path}`; 8 | } 9 | return error.message; 10 | }); 11 | 12 | return validationErrors; 13 | }; 14 | 15 | module.exports.useFallbackErrorHandler = (error) => { 16 | throw new ApolloError(error.message, "INTERNAL_SERVER_ERROR"); 17 | }; 18 | -------------------------------------------------------------------------------- /part8/library-backend/models/Author.js: -------------------------------------------------------------------------------- 1 | const mongoose = require("mongoose"); 2 | const uniqueValidator = require("mongoose-unique-validator"); 3 | 4 | const schema = new mongoose.Schema({ 5 | name: { 6 | type: String, 7 | required: [true, "Authors must have a name"], 8 | unique: true, 9 | uniqueCaseInsensitive: true, 10 | minlength: [4, "Author name must be at least 4 characters long"], 11 | }, 12 | born: { 13 | type: Number, 14 | }, 15 | }); 16 | 17 | schema.plugin(uniqueValidator, { 18 | message: "An Author with that {PATH} already exists", 19 | }); 20 | 21 | module.exports = mongoose.model("Author", schema); 22 | -------------------------------------------------------------------------------- /part8/library-backend/models/Book.js: -------------------------------------------------------------------------------- 1 | const mongoose = require("mongoose"); 2 | const uniqueValidator = require("mongoose-unique-validator"); 3 | 4 | const schema = new mongoose.Schema({ 5 | title: { 6 | type: String, 7 | required: [true, "Books must have a title"], 8 | unique: true, 9 | uniqueCaseInsensitive: true, 10 | minlength: [2, "Book title must be at least 2 characters long"], 11 | }, 12 | published: { 13 | type: Number, 14 | }, 15 | author: { 16 | type: mongoose.Schema.Types.ObjectId, 17 | ref: "Author", 18 | }, 19 | genres: [{ type: String }], 20 | }); 21 | 22 | schema.plugin(uniqueValidator, { 23 | message: "A Book with that {PATH} already exists", 24 | }); 25 | 26 | module.exports = mongoose.model("Book", schema); 27 | -------------------------------------------------------------------------------- /part8/library-backend/models/User.js: -------------------------------------------------------------------------------- 1 | const mongoose = require("mongoose"); 2 | const uniqueValidator = require("mongoose-unique-validator"); 3 | 4 | const schema = new mongoose.Schema({ 5 | username: { 6 | type: String, 7 | required: [true, "User must have a username"], 8 | unique: true, 9 | minlength: [3, "Username must be at least 3 characters long"], 10 | }, 11 | favoriteGenre: { 12 | type: String, 13 | required: [true, "User must have a favorite genre"], 14 | minlength: [3, "User favorite genre must be at least 3 characters long"], 15 | }, 16 | }); 17 | 18 | schema.plugin(uniqueValidator, { 19 | message: "A User with that {PATH} already exists", 20 | }); 21 | 22 | module.exports = mongoose.model("User", schema); 23 | -------------------------------------------------------------------------------- /part8/library-backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "library-backend", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "node index.js", 8 | "watch": "nodemon index.js", 9 | "test": "echo \"Error: no test specified\" && exit 1" 10 | }, 11 | "keywords": [], 12 | "author": "Jeremy Ebinum", 13 | "license": "MIT", 14 | "dependencies": { 15 | "apollo-server": "^2.11.0", 16 | "dataloader": "^2.0.0", 17 | "dotenv": "^8.2.0", 18 | "graphql": "^14.6.0", 19 | "jsonwebtoken": "^8.5.1", 20 | "lodash.countby": "^4.6.0", 21 | "lodash.merge": "^4.6.2", 22 | "mongoose": "^5.8.11", 23 | "mongoose-unique-validator": "^2.0.3" 24 | }, 25 | "devDependencies": { 26 | "eslint": "^6.8.0", 27 | "eslint-config-airbnb-base": "^14.0.0", 28 | "eslint-config-prettier": "^6.10.0", 29 | "eslint-plugin-import": "^2.20.1", 30 | "eslint-plugin-prettier": "^3.1.2", 31 | "nodemon": "^2.0.2", 32 | "prettier": "^1.19.1" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /part8/library-backend/utils/config.js: -------------------------------------------------------------------------------- 1 | require("dotenv").config(); 2 | 3 | const { MONGODB_URI, JWT_SECRET } = process.env; 4 | 5 | module.exports = { MONGODB_URI, JWT_SECRET }; 6 | -------------------------------------------------------------------------------- /part8/library-backend/utils/logger.js: -------------------------------------------------------------------------------- 1 | const info = (...params) => { 2 | if (process.env.NODE_ENV !== "test") { 3 | console.log(...params); 4 | } 5 | }; 6 | 7 | const error = (...params) => { 8 | console.error(...params); 9 | }; 10 | 11 | module.exports = { 12 | info, 13 | error, 14 | }; 15 | -------------------------------------------------------------------------------- /part8/library-frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeremy-ebinum/full-stack-open-2020/ce8ebbefb5f60c71362b12849129167348891680/part8/library-frontend/public/favicon.ico -------------------------------------------------------------------------------- /part8/library-frontend/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeremy-ebinum/full-stack-open-2020/ce8ebbefb5f60c71362b12849129167348891680/part8/library-frontend/public/logo192.png -------------------------------------------------------------------------------- /part8/library-frontend/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeremy-ebinum/full-stack-open-2020/ce8ebbefb5f60c71362b12849129167348891680/part8/library-frontend/public/logo512.png -------------------------------------------------------------------------------- /part8/library-frontend/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 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 | -------------------------------------------------------------------------------- /part8/library-frontend/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /part8/library-frontend/src/components/AuthorsTable.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | 4 | import Table from "react-bootstrap/Table"; 5 | 6 | const AuthorsTable = ({ authors }) => { 7 | return ( 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | {authors.map((a) => ( 19 | 20 | 21 | 22 | 23 | 24 | ))} 25 | 26 |
List of Authors
NameBirth YearBooks
{a.name}{a.born}{a.bookCount}
27 | ); 28 | }; 29 | 30 | AuthorsTable.propTypes = { 31 | authors: PropTypes.arrayOf(PropTypes.object), 32 | }; 33 | 34 | export default AuthorsTable; 35 | -------------------------------------------------------------------------------- /part8/library-frontend/src/components/BooksTable.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | 4 | import Table from "react-bootstrap/Table"; 5 | 6 | const BooksTable = ({ books }) => { 7 | return ( 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | {books.map((b) => ( 19 | 20 | 21 | 22 | 23 | 24 | ))} 25 | 26 |
List of Books
TitleAuthorPublished
{b.title}{b.author.name}{b.published}
27 | ); 28 | }; 29 | 30 | BooksTable.propTypes = { 31 | books: PropTypes.arrayOf(PropTypes.object), 32 | }; 33 | 34 | export default BooksTable; 35 | -------------------------------------------------------------------------------- /part8/library-frontend/src/components/Login.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Helmet } from "react-helmet-async"; 3 | import Container from "react-bootstrap/Container"; 4 | import Row from "react-bootstrap/Row"; 5 | import Col from "react-bootstrap/Col"; 6 | import Card from "react-bootstrap/Card"; 7 | 8 | import Notifications from "./Notifications"; 9 | import LoginForm from "./LoginForm"; 10 | 11 | const Login = () => { 12 | return ( 13 | <> 14 | 15 | GraphQL Library | Login 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 |

Login

26 |
27 | 28 | 29 | 30 |
31 | 32 |
33 |
34 | 35 | ); 36 | }; 37 | 38 | export default Login; 39 | -------------------------------------------------------------------------------- /part8/library-frontend/src/components/Modal.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import { Modal as StyledModal, ModalContent } from "./StyledComponents"; 3 | 4 | const Modal = ({ children }) => { 5 | useEffect(() => { 6 | document.body.style.overflow = "hidden"; 7 | return () => { 8 | document.body.style.overflow = ""; 9 | }; 10 | }, []); 11 | 12 | return ( 13 | 14 | {children} 15 | 16 | ); 17 | }; 18 | 19 | export default Modal; 20 | -------------------------------------------------------------------------------- /part8/library-frontend/src/components/ModalSpinner.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import Modal from "./Modal"; 4 | import { LoadingSpinner } from "./StyledComponents"; 5 | 6 | const ModalSpinner = () => { 7 | return ( 8 | 9 | 15 | Loading... 16 | 17 | 18 | ); 19 | }; 20 | 21 | export default ModalSpinner; 22 | -------------------------------------------------------------------------------- /part8/library-frontend/src/components/NoResource.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import { Link } from "react-router-dom"; 4 | import Jumbotron from "react-bootstrap/Jumbotron"; 5 | import Button from "react-bootstrap/Button"; 6 | 7 | import useAuthUser from "../hooks/useAuthUser"; 8 | 9 | const NoResource = ({ resource }) => { 10 | const { user, hasSyncAuth } = useAuthUser(); 11 | 12 | if (!hasSyncAuth) return null; 13 | return ( 14 | 15 |

There are currently no {resource} to display.

16 |

17 | {user && ( 18 | 21 | )} 22 | {!user && ( 23 | 33 | )} 34 |

35 |
36 | ); 37 | }; 38 | 39 | NoResource.propTypes = { 40 | resource: PropTypes.string.isRequired, 41 | }; 42 | 43 | export default NoResource; 44 | -------------------------------------------------------------------------------- /part8/library-frontend/src/components/Notifications.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from "react"; 2 | import { useQuery } from "@apollo/client"; 3 | 4 | import { ALL_NOTIFICATIONS } from "../graphql/queries"; 5 | import limitNotifications from "../utils/limitArrayOfObjectsByDate"; 6 | import Notification from "./Notification"; 7 | 8 | const Notifications = () => { 9 | const [notifications, setNotifications] = useState([]); 10 | 11 | const getAllNotifications = useQuery(ALL_NOTIFICATIONS); 12 | 13 | useEffect(() => { 14 | if (getAllNotifications.data) { 15 | const notifications = getAllNotifications.data.allNotifications; 16 | setNotifications(notifications); 17 | } 18 | }, [getAllNotifications.data]); 19 | 20 | const notificationsToShow = limitNotifications(notifications, 3); 21 | 22 | return ( 23 |
34 | {notificationsToShow.map((n) => ( 35 | 42 | ))} 43 |
44 | ); 45 | }; 46 | 47 | export default Notifications; 48 | -------------------------------------------------------------------------------- /part8/library-frontend/src/components/StyledComponents.js: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import Spinner from "react-bootstrap/Spinner"; 3 | 4 | export const Modal = styled.div` 5 | display: flex; 6 | justify-content: center; 7 | align-items: center; 8 | position: absolute; 9 | top: 0; 10 | left: 0; 11 | width: 100%; 12 | height: 100%; 13 | background-color: rgba(50, 50, 50, 0.95); 14 | z-index: 9999; 15 | `; 16 | 17 | export const ModalContent = styled.div` 18 | display: flex; 19 | justify-content: center; 20 | align-items: center; 21 | `; 22 | 23 | export const LoadingSpinner = styled(Spinner)` 24 | width: 4rem; 25 | height: 4rem; 26 | font-size: 2rem; 27 | 28 | @media (min-width: 768px) { 29 | width: 6rem; 30 | height: 6rem; 31 | } 32 | `; 33 | -------------------------------------------------------------------------------- /part8/library-frontend/src/helpers/errorHelper.js: -------------------------------------------------------------------------------- 1 | import logger from "../utils/logger"; 2 | 3 | export const resolveApolloErrors = ({ graphQLErrors, networkError }) => { 4 | let errors; 5 | 6 | if (networkError) { 7 | logger.error(`[Network error]: ${networkError}`); 8 | errors = ["Something went wrong..."]; 9 | } else if (graphQLErrors) { 10 | errors = graphQLErrors.reduce((result, error) => { 11 | const { message, locations, path, extensions } = error; 12 | logger.error( 13 | `[GraphQL error]: Message: ${message}, Location: ${JSON.stringify( 14 | locations 15 | )}, Path: ${path}` 16 | ); 17 | 18 | const { code, errorMessages } = extensions; 19 | 20 | switch (code) { 21 | case "BAD_USER_INPUT": 22 | return result.concat(errorMessages); 23 | case "NOT_FOUND": 24 | result.push(message); 25 | return result; 26 | default: 27 | result.push(message); 28 | return result; 29 | } 30 | }, []); 31 | } 32 | 33 | return errors; 34 | }; 35 | -------------------------------------------------------------------------------- /part8/library-frontend/src/hooks/useAuthUser.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { useQuery } from "@apollo/client"; 3 | 4 | import { GET_AUTH_USER } from "../graphql/queries"; 5 | 6 | const useAuthUser = () => { 7 | const [authStatus, setAuthStatus] = useState({ 8 | user: null, 9 | hasSyncAuth: false, 10 | }); 11 | const getAuthUser = useQuery(GET_AUTH_USER); 12 | 13 | useEffect(() => { 14 | const { called, networkStatus, data } = getAuthUser; 15 | if (called && networkStatus > 6) { 16 | const authUser = data ? data.me : null; 17 | setAuthStatus({ user: authUser, hasSyncAuth: true }); 18 | } 19 | }, [getAuthUser]); 20 | 21 | return authStatus; 22 | }; 23 | 24 | export default useAuthUser; 25 | -------------------------------------------------------------------------------- /part8/library-frontend/src/hooks/useBookGenres.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { useQuery } from "@apollo/client"; 3 | 4 | import { GET_ALL_BOOKS } from "../graphql/queries"; 5 | 6 | const toGenres = (genres, book) => { 7 | const newGenres = book.genres.filter((g) => g && !genres.includes(g)); 8 | return genres.concat(newGenres); 9 | }; 10 | 11 | const useBookGenres = () => { 12 | const [books, setBooks] = useState([]); 13 | const [genresState, setGenresState] = useState({ 14 | genres: [], 15 | hasGenres: false, 16 | }); 17 | const getAllBooks = useQuery(GET_ALL_BOOKS); 18 | 19 | useEffect(() => { 20 | const { called, networkStatus, data } = getAllBooks; 21 | if (called && networkStatus > 6) { 22 | const newBooks = data ? data.allBooks : books; 23 | const genres = newBooks.reduce(toGenres, []); 24 | setGenresState({ genres, hasGenres: true }); 25 | setBooks(newBooks); 26 | } 27 | }, [books, getAllBooks]); 28 | 29 | return genresState; 30 | }; 31 | 32 | export default useBookGenres; 33 | -------------------------------------------------------------------------------- /part8/library-frontend/src/hooks/useYupValidationResolver.js: -------------------------------------------------------------------------------- 1 | import { useCallback } from "react"; 2 | 3 | const useYupValidationResolver = (validationSchema) => 4 | useCallback( 5 | async (data) => { 6 | try { 7 | const values = await validationSchema.validate(data, { 8 | abortEarly: false, 9 | }); 10 | return { 11 | values, 12 | errors: {}, 13 | }; 14 | } catch (errors) { 15 | return { 16 | values: {}, 17 | errors: errors.inner.reduce( 18 | (allErrors, currentError) => ({ 19 | ...allErrors, 20 | [currentError.path]: { 21 | type: currentError.type ?? "validation", 22 | message: currentError.message, 23 | }, 24 | }), 25 | {} 26 | ), 27 | }; 28 | } 29 | }, 30 | [validationSchema] 31 | ); 32 | 33 | export default useYupValidationResolver; 34 | -------------------------------------------------------------------------------- /part8/library-frontend/src/utils/limitArrayOfObjectsByDate.js: -------------------------------------------------------------------------------- 1 | const limitArrayOfObjectsByDate = (array, limit = 3, ascending = false) => { 2 | if (limit < 1) throw new Error("Invalid Limit"); 3 | if (array.some((i) => !i.date)) throw new Error("No Date Property On Items"); 4 | 5 | if (array.length <= limit) { 6 | return array; 7 | } else { 8 | const arrayByDateDesc = [...array].sort((a, b) => b.date - a.date); 9 | 10 | if (ascending) { 11 | const lastThreeItemsAsc = arrayByDateDesc.slice(0, limit); 12 | return lastThreeItemsAsc; 13 | } else { 14 | const lastThreeItemsDesc = arrayByDateDesc.slice(0, limit).reverse(); 15 | return lastThreeItemsDesc; 16 | } 17 | } 18 | }; 19 | 20 | export default limitArrayOfObjectsByDate; 21 | -------------------------------------------------------------------------------- /part8/library-frontend/src/utils/logger.js: -------------------------------------------------------------------------------- 1 | const logger = { 2 | info: (...args) => { 3 | if (process.env.NODE_ENV === "development") { 4 | console.log(...args); 5 | } 6 | }, 7 | error: (...args) => { 8 | console.error(...args); 9 | }, 10 | }; 11 | 12 | export default logger; 13 | -------------------------------------------------------------------------------- /part9/patientor-backend/.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build -------------------------------------------------------------------------------- /part9/patientor-backend/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "eslint:recommended", 4 | "plugin:@typescript-eslint/recommended", 5 | "plugin:@typescript-eslint/recommended-requiring-type-checking" 6 | ], 7 | "plugins": ["@typescript-eslint"], 8 | "env": { 9 | "browser": true, 10 | "es6": true 11 | }, 12 | "rules": { 13 | "@typescript-eslint/semi": ["error"], 14 | "@typescript-eslint/explicit-function-return-type": 0, 15 | "@typescript-eslint/no-unused-vars": [ 16 | "error", 17 | { "argsIgnorePattern": "^_" } 18 | ], 19 | "@typescript-eslint/no-explicit-any": 1, 20 | "no-case-declarations": 0 21 | }, 22 | "parser": "@typescript-eslint/parser", 23 | "parserOptions": { 24 | "project": "./tsconfig.json" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /part9/patientor-backend/README.md: -------------------------------------------------------------------------------- 1 | # Full Stack Open 2020 - Exercise Solutions - Patientor Backend 2 | 3 | ## Part 9 - [Typescript](https://fullstackopen.com/en/part9) 4 | 5 | ## Routes 6 | 7 | The server supports the following routes 8 | 9 | - /api/ping - test route (GET) 10 | - /api/diagnoses - for diagnosis resources (GET) 11 | - /api/patients/ - for patient resources (GET, POST) 12 | - /api/patients/:id - for patient members (GET) 13 | - /api/patients/:id/entries - for entries of a patient (POST) 14 | 15 | ## usage 16 | 17 | Run application in dev watch mode (ts-node-dev) with _npm run dev_ 18 | 19 | Build js files with _npm run tsc_ 20 | 21 | Rebuild (deleting all old files) with _npm run tsc:rebuild_ 22 | 23 | Built JavsScript src is emmited to /build folder 24 | 25 | Start application (after build) with _npm start_ 26 | 27 | Run eslint with _npm run lint_ 28 | -------------------------------------------------------------------------------- /part9/patientor-backend/build/src/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var __importDefault = (this && this.__importDefault) || function (mod) { 3 | return (mod && mod.__esModule) ? mod : { "default": mod }; 4 | }; 5 | Object.defineProperty(exports, "__esModule", { value: true }); 6 | const express_1 = __importDefault(require("express")); 7 | const cors_1 = __importDefault(require("cors")); 8 | const diagnoses_1 = __importDefault(require("./routes/diagnoses")); 9 | const patients_1 = __importDefault(require("./routes/patients")); 10 | const app = express_1.default(); 11 | app.use(express_1.default.json()); 12 | app.use(cors_1.default()); 13 | app.get("/api/ping", (_req, res) => { 14 | res.send("pong"); 15 | }); 16 | app.use("/api/diagnoses", diagnoses_1.default); 17 | app.use("/api/patients", patients_1.default); 18 | const PORT = 3001; 19 | app.listen(PORT, () => { 20 | console.log(`Server running on port ${PORT}`); 21 | }); 22 | -------------------------------------------------------------------------------- /part9/patientor-backend/build/src/routes/diagnoses.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var __importDefault = (this && this.__importDefault) || function (mod) { 3 | return (mod && mod.__esModule) ? mod : { "default": mod }; 4 | }; 5 | Object.defineProperty(exports, "__esModule", { value: true }); 6 | const express_1 = require("express"); 7 | const diagnosisService_1 = __importDefault(require("../services/diagnosisService")); 8 | const router = express_1.Router(); 9 | router.get("/", (_req, res) => { 10 | res.json(diagnosisService_1.default.getDiagnoses()); 11 | }); 12 | exports.default = router; 13 | -------------------------------------------------------------------------------- /part9/patientor-backend/build/src/services/diagnosisService.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var __importDefault = (this && this.__importDefault) || function (mod) { 3 | return (mod && mod.__esModule) ? mod : { "default": mod }; 4 | }; 5 | Object.defineProperty(exports, "__esModule", { value: true }); 6 | const diagnoses_1 = __importDefault(require("../../data/diagnoses")); 7 | const getDiagnoses = () => { 8 | return diagnoses_1.default; 9 | }; 10 | exports.default = { 11 | getDiagnoses, 12 | }; 13 | -------------------------------------------------------------------------------- /part9/patientor-backend/build/src/types.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | var Gender; 4 | (function (Gender) { 5 | Gender["Male"] = "male"; 6 | Gender["Female"] = "female"; 7 | Gender["Other"] = "other"; 8 | })(Gender = exports.Gender || (exports.Gender = {})); 9 | var EntryType; 10 | (function (EntryType) { 11 | EntryType["HealthCheck"] = "HealthCheck"; 12 | EntryType["OccupationalHealthCare"] = "OccupationalHealthcare"; 13 | EntryType["Hospital"] = "Hospital"; 14 | })(EntryType = exports.EntryType || (exports.EntryType = {})); 15 | var HealthCheckRating; 16 | (function (HealthCheckRating) { 17 | HealthCheckRating[HealthCheckRating["Healthy"] = 0] = "Healthy"; 18 | HealthCheckRating[HealthCheckRating["LowRisk"] = 1] = "LowRisk"; 19 | HealthCheckRating[HealthCheckRating["HighRisk"] = 2] = "HighRisk"; 20 | HealthCheckRating[HealthCheckRating["CriticalRisk"] = 3] = "CriticalRisk"; 21 | })(HealthCheckRating = exports.HealthCheckRating || (exports.HealthCheckRating = {})); 22 | -------------------------------------------------------------------------------- /part9/patientor-backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "patientor-backend", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "src/index.ts", 6 | "scripts": { 7 | "tsc": "tsc", 8 | "tsc:rebuild": "del-cli ./build && npm run tsc", 9 | "start": "node build/src/index.js", 10 | "dev": "ts-node-dev src/index.ts", 11 | "lint": "eslint --ext .ts .", 12 | "test": "echo \"Error: no test specified\" && exit 1" 13 | }, 14 | "devDependencies": { 15 | "@types/cors": "^2.8.6", 16 | "@types/express": "^4.17.3", 17 | "@types/uuid": "^7.0.2", 18 | "@typescript-eslint/eslint-plugin": "^2.26.0", 19 | "@typescript-eslint/parser": "^2.26.0", 20 | "del-cli": "^3.0.0", 21 | "eslint": "^6.8.0", 22 | "ts-node-dev": "^1.0.0-pre.44", 23 | "typescript": "^3.8.3" 24 | }, 25 | "dependencies": { 26 | "cors": "^2.8.5", 27 | "express": "^4.17.1", 28 | "uuid": "^7.0.3" 29 | }, 30 | "keywords": [], 31 | "author": "Jeremy Ebinum", 32 | "license": "MIT" 33 | } -------------------------------------------------------------------------------- /part9/patientor-backend/src/index.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import cors from "cors"; 3 | import diagnosisRouter from "./routes/diagnoses"; 4 | import patientRouter from "./routes/patients"; 5 | 6 | const app = express(); 7 | 8 | app.use(express.json()); 9 | app.use(cors()); 10 | 11 | app.get("/api/ping", (_req, res) => { 12 | res.send("pong"); 13 | }); 14 | 15 | app.use("/api/diagnoses", diagnosisRouter); 16 | 17 | app.use("/api/patients", patientRouter); 18 | 19 | const PORT = 3001; 20 | 21 | app.listen(PORT, () => { 22 | console.log(`Server running on port ${PORT}`); 23 | }); 24 | -------------------------------------------------------------------------------- /part9/patientor-backend/src/routes/diagnoses.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import diagnosisService from "../services/diagnosisService"; 3 | 4 | const router = Router(); 5 | 6 | router.get("/", (_req, res) => { 7 | res.json(diagnosisService.getDiagnoses()); 8 | }); 9 | 10 | export default router; 11 | -------------------------------------------------------------------------------- /part9/patientor-backend/src/routes/patients.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import patientService from "../services/patientService"; 3 | import { toNewPatient, toNewEntry } from "../utils"; 4 | 5 | const router = Router(); 6 | 7 | router.get("/", (_req, res) => { 8 | res.json(patientService.getPatients()); 9 | }); 10 | 11 | router.get("/:id", (req, res) => { 12 | const patient = patientService.findById(req.params.id); 13 | 14 | if (patient) { 15 | res.json(patient); 16 | } else { 17 | res.sendStatus(404); 18 | } 19 | }); 20 | 21 | router.post("/", (req, res) => { 22 | try { 23 | const newPatient = toNewPatient(req.body); 24 | const addedPatient = patientService.addPatient(newPatient); 25 | res.json(addedPatient); 26 | } catch (e) { 27 | res.status(400).send({ error: e.message }); 28 | } 29 | }); 30 | 31 | router.post("/:id/entries", (req, res) => { 32 | const patient = patientService.findById(req.params.id); 33 | 34 | if (patient) { 35 | try { 36 | const newEntry = toNewEntry(req.body); 37 | const updatedPatient = patientService.addEntry(patient, newEntry); 38 | res.json(updatedPatient); 39 | } catch (e) { 40 | res.status(400).send({ error: e.message }); 41 | } 42 | } else { 43 | res.status(404).send({ error: "Sorry, this patient does not exist" }); 44 | } 45 | }); 46 | 47 | export default router; 48 | -------------------------------------------------------------------------------- /part9/patientor-backend/src/services/diagnosisService.ts: -------------------------------------------------------------------------------- 1 | import diagnoses from "../../data/diagnoses"; 2 | import { Diagnosis } from "../types"; 3 | 4 | const getDiagnoses = (): Diagnosis[] => { 5 | return diagnoses; 6 | }; 7 | 8 | export default { 9 | getDiagnoses, 10 | }; 11 | -------------------------------------------------------------------------------- /part9/patientor-backend/src/services/patientService.ts: -------------------------------------------------------------------------------- 1 | import patients from "../../data/patients"; 2 | import { 3 | Patient, 4 | NonSensitivePatient, 5 | NewPatient, 6 | Entry, 7 | NewEntry, 8 | } from "../types"; 9 | import { v4 as uuid } from "uuid"; 10 | 11 | let savedPatients = [...patients]; 12 | 13 | /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ 14 | const findById = (id: any): Patient | undefined => { 15 | const patient = savedPatients.find((p) => p.id === id); 16 | return patient; 17 | }; 18 | 19 | const getPatients = (): NonSensitivePatient[] => { 20 | return savedPatients.map( 21 | ({ id, name, dateOfBirth, gender, occupation, entries }) => { 22 | return { id, name, dateOfBirth, gender, occupation, entries }; 23 | } 24 | ); 25 | }; 26 | 27 | const addPatient = (patient: NewPatient): Patient => { 28 | const newPatient = { ...patient, id: uuid(), entries: [] as Entry[] }; 29 | savedPatients = savedPatients.concat(newPatient); 30 | return newPatient; 31 | }; 32 | 33 | const addEntry = (patient: Patient, newEntry: NewEntry): Patient => { 34 | const entry: Entry = { ...newEntry, id: uuid() }; 35 | const savedPatient = { ...patient, entries: patient.entries.concat(entry) }; 36 | savedPatients = savedPatients.map((p) => 37 | p.id === savedPatient.id ? savedPatient : p 38 | ); 39 | 40 | return savedPatient; 41 | }; 42 | 43 | export default { getPatients, addPatient, findById, addEntry }; 44 | -------------------------------------------------------------------------------- /part9/patientor-backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES6", 4 | "outDir": "./build/", 5 | "module": "commonjs", 6 | "strict": true, 7 | "noUnusedLocals": true, 8 | "noUnusedParameters": true, 9 | "noImplicitReturns": true, 10 | "noFallthroughCasesInSwitch": true, 11 | "esModuleInterop": true, 12 | "resolveJsonModule": true 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /part9/patientor-frontend/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "eslint:recommended", 4 | "plugin:@typescript-eslint/recommended", 5 | "plugin:react/recommended", 6 | "plugin:@typescript-eslint/recommended-requiring-type-checking" 7 | ], 8 | "plugins": ["@typescript-eslint", "react"], 9 | "env": { 10 | "browser": true, 11 | "es6": true 12 | }, 13 | "rules": { 14 | "@typescript-eslint/semi": ["error"], 15 | "@typescript-eslint/explicit-function-return-type": 0, 16 | "@typescript-eslint/no-unused-vars": [ 17 | "error", { "argsIgnorePattern": "^_" } 18 | ], 19 | "@typescript-eslint/no-explicit-any": 1, 20 | "no-case-declarations": 0, 21 | "react/prop-types": 0 22 | }, 23 | "settings": { 24 | "react": { 25 | "pragma": "React", 26 | "version": "detect" 27 | } 28 | }, 29 | "parser": "@typescript-eslint/parser", 30 | "parserOptions": { 31 | "project": "./tsconfig.json" 32 | } 33 | } -------------------------------------------------------------------------------- /part9/patientor-frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "patientor", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "axios": "^0.19.0", 7 | "formik": "^2.0.6", 8 | "react": "^16.12.0", 9 | "react-dom": "^16.12.0", 10 | "react-router-dom": "^5.1.2", 11 | "react-scripts": "3.4.0", 12 | "react-uid": "^2.2.0", 13 | "semantic-ui-css": "^2.4.1", 14 | "semantic-ui-react": "^0.88.1", 15 | "yup": "^0.28.3" 16 | }, 17 | "devDependencies": { 18 | "@types/jest": "24.0.19", 19 | "@types/node": "12.11.7", 20 | "@types/react": "^16.9.11", 21 | "@types/react-dom": "16.9.3", 22 | "@types/react-router-dom": "^5.1.2", 23 | "@types/yup": "^0.26.34", 24 | "@typescript-eslint/eslint-plugin": "^2.12.0", 25 | "@typescript-eslint/parser": "^2.12.0", 26 | "eslint-config-react": "^1.1.7", 27 | "typescript": "^3.7.0" 28 | }, 29 | "scripts": { 30 | "start": "react-scripts start", 31 | "build": "react-scripts build", 32 | "test": "react-scripts test", 33 | "eject": "react-scripts eject", 34 | "lint": "eslint './src/**/*.{ts,tsx}'", 35 | "lint:fix": "eslint './src/**/*.{ts,tsx}' --fix" 36 | }, 37 | "browserslist": { 38 | "production": [ 39 | ">0.2%", 40 | "not dead", 41 | "not op_mini all" 42 | ], 43 | "development": [ 44 | "last 1 chrome version", 45 | "last 1 firefox version", 46 | "last 1 safari version" 47 | ] 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /part9/patientor-frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeremy-ebinum/full-stack-open-2020/ce8ebbefb5f60c71362b12849129167348891680/part9/patientor-frontend/public/favicon.ico -------------------------------------------------------------------------------- /part9/patientor-frontend/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "start_url": ".", 5 | "display": "standalone", 6 | "theme_color": "#000000", 7 | "background_color": "#ffffff" 8 | } 9 | -------------------------------------------------------------------------------- /part9/patientor-frontend/src/AddEntryModal/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Modal, Segment } from "semantic-ui-react"; 3 | import { NewEntry } from "../types"; 4 | 5 | import AddEntryFormWrapper from "./AddEntryFormWrapper"; 6 | 7 | interface Props { 8 | modalOpen: boolean; 9 | onClose: () => void; 10 | onSubmit: (values: NewEntry) => void; 11 | error?: string; 12 | } 13 | 14 | const AddEntryModal = ({ modalOpen, onClose, onSubmit, error }: Props) => ( 15 | 16 | Add a new entry 17 | 18 | {error && {`${error}`}} 19 | 20 | 21 | 22 | ); 23 | 24 | export default AddEntryModal; 25 | -------------------------------------------------------------------------------- /part9/patientor-frontend/src/AddPatientModal/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Modal, Segment } from "semantic-ui-react"; 3 | import AddPatientForm, { PatientFormValues } from "./AddPatientForm"; 4 | 5 | interface Props { 6 | modalOpen: boolean; 7 | onClose: () => void; 8 | onSubmit: (values: PatientFormValues) => void; 9 | error?: string; 10 | } 11 | 12 | const AddPatientModal = ({ modalOpen, onClose, onSubmit, error }: Props) => ( 13 | 14 | Add a new patient 15 | 16 | {error && {`${error}`}} 17 | 18 | 19 | 20 | ); 21 | 22 | export default AddPatientModal; 23 | -------------------------------------------------------------------------------- /part9/patientor-frontend/src/PatientPage/DiagnosisList.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { uid } from "react-uid"; 3 | 4 | import { useStateValue } from "../state"; 5 | import { Diagnosis } from "../types"; 6 | import { List } from "semantic-ui-react"; 7 | 8 | interface DiagnosesDetailsProps { 9 | diagnosesCodes: Array; 10 | } 11 | const DiagnosisList: React.FC = ({ diagnosesCodes }) => { 12 | const [{ diagnoses }] = useStateValue(); 13 | 14 | return ( 15 | 16 | 17 | 18 | {diagnosesCodes.length > 1 ? "Diagnoses" : "Diagnosis"} 19 | 20 | 21 | {diagnosesCodes.map((code) => ( 22 | 23 | 24 | 25 | {code} - 26 | {diagnoses[code] && diagnoses[code].name} 27 | 28 | 29 | 30 | ))} 31 | 32 | ); 33 | }; 34 | 35 | export default DiagnosisList; 36 | -------------------------------------------------------------------------------- /part9/patientor-frontend/src/PatientPage/EntryDetails.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { Entry, EntryType } from "../types"; 4 | import { assertNever } from "../utils"; 5 | 6 | import HealthCheckEntry from "./HealthCheckEntry"; 7 | import OccupationalHealthCareEntry from "./OccupationalHealthCareEntry"; 8 | import HospitalEntry from "./HospitalEntry"; 9 | 10 | const EntryDetails: React.FC<{ entry: Entry }> = ({ entry }) => { 11 | switch (entry.type) { 12 | case EntryType.HealthCheck: 13 | return ; 14 | case EntryType.OccupationalHealthCare: 15 | return ; 16 | case EntryType.Hospital: 17 | return ; 18 | default: 19 | return assertNever(entry); 20 | } 21 | }; 22 | 23 | export default EntryDetails; 24 | -------------------------------------------------------------------------------- /part9/patientor-frontend/src/PatientPage/HealthCheckEntry.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Card, Icon } from "semantic-ui-react"; 3 | 4 | import { HealthCheckEntry as HealthCheck } from "../types"; 5 | 6 | import HealthRatingBar from "../components/HealthRatingBar"; 7 | import DiagnosisList from "./DiagnosisList"; 8 | 9 | const HealthCheckEntry: React.FC<{ entry: HealthCheck }> = ({ entry }) => { 10 | return ( 11 | 12 | 13 | 14 | {entry.date} 15 | 16 | by {entry.specialist} 17 | {entry.description} 18 | {entry.diagnosisCodes && ( 19 | 20 | )} 21 | 22 | 23 | 24 | 25 | 26 | ); 27 | }; 28 | 29 | export default HealthCheckEntry; 30 | -------------------------------------------------------------------------------- /part9/patientor-frontend/src/PatientPage/HospitalEntry.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Card, Icon, List } from "semantic-ui-react"; 3 | 4 | import { HospitalEntry as Hospital } from "../types"; 5 | 6 | import DiagnosisList from "./DiagnosisList"; 7 | 8 | const HospitalEntry: React.FC<{ entry: Hospital }> = ({ entry }) => { 9 | return ( 10 | 11 | 12 | 13 | {entry.date} 14 | 15 | by {entry.specialist} 16 | {entry.description} 17 | {entry.diagnosisCodes && ( 18 | 19 | )} 20 | 21 | 22 | 23 | 24 | 25 | Discharged on {entry.discharge.date} 26 | {entry.discharge.criteria} 27 | 28 | 29 | 30 | 31 | ); 32 | }; 33 | 34 | export default HospitalEntry; 35 | -------------------------------------------------------------------------------- /part9/patientor-frontend/src/PatientPage/OccupationalHealthCareEntry.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Card, Icon, List } from "semantic-ui-react"; 3 | 4 | import { OccupationalHealthCareEntry as OccupationalHealthCare } from "../types"; 5 | 6 | import DiagnosisList from "./DiagnosisList"; 7 | 8 | const OccupationalHealthCareEntry: React.FC<{ 9 | entry: OccupationalHealthCare; 10 | }> = ({ entry }) => { 11 | return ( 12 | 13 | 14 | 15 | {entry.date} 16 | 17 | by {entry.specialist} 18 | {entry.description} 19 | {entry.diagnosisCodes && ( 20 | 21 | )} 22 | 23 | 24 | 25 | Employer: {entry.employerName} 26 | 27 | {entry.sickLeave && ( 28 | 29 | Sick Leave: {entry.sickLeave.startDate} to{" "} 30 | {entry.sickLeave.endDate} 31 | 32 | )} 33 | 34 | 35 | 36 | ); 37 | }; 38 | 39 | export default OccupationalHealthCareEntry; 40 | -------------------------------------------------------------------------------- /part9/patientor-frontend/src/components/HealthRatingBar.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Rating } from 'semantic-ui-react'; 3 | 4 | type BarProps = { 5 | rating: number; 6 | showText: boolean; 7 | }; 8 | 9 | const HEALTHBAR_TEXTS = [ 10 | 'The patient is in great shape', 11 | 'The patient has a low risk of getting sick', 12 | 'The patient has a high risk of getting sick', 13 | 'The patient has a diagnosed condition', 14 | ]; 15 | 16 | const HealthRatingBar = ({ rating, showText }: BarProps) => { 17 | return ( 18 |
19 | {} 20 | {showText ?

{HEALTHBAR_TEXTS[rating]}

: null} 21 |
22 | ); 23 | }; 24 | 25 | export default HealthRatingBar; 26 | -------------------------------------------------------------------------------- /part9/patientor-frontend/src/constants.ts: -------------------------------------------------------------------------------- 1 | export const apiBaseUrl = 'http://localhost:3001/api'; 2 | -------------------------------------------------------------------------------- /part9/patientor-frontend/src/helpers/errorHelper.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | export class InvalidPatientError extends Error { 3 | /* eslint-disable-next-line */ 4 | constructor(...params: any) { 5 | // Pass remaining arguments (including vendor specific ones) to parent constructor 6 | super(...params); 7 | 8 | // Maintains proper stack trace for where our error was thrown (only available on V8) 9 | if (Error.captureStackTrace) { 10 | Error.captureStackTrace(this, InvalidPatientError); 11 | } 12 | 13 | this.name = "InvalidPatientError"; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /part9/patientor-frontend/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import 'semantic-ui-css/semantic.min.css'; 4 | import App from './App'; 5 | import { reducer, StateProvider } from "./state"; 6 | 7 | ReactDOM.render( 8 | 9 | 10 | , 11 | document.getElementById('root') 12 | ); 13 | -------------------------------------------------------------------------------- /part9/patientor-frontend/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /part9/patientor-frontend/src/state/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./reducer"; 2 | export * from "./state"; 3 | -------------------------------------------------------------------------------- /part9/patientor-frontend/src/state/state.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useContext, useReducer } from "react"; 2 | import { Patient, Diagnosis } from "../types"; 3 | 4 | import { Action } from "./reducer"; 5 | 6 | export type State = { 7 | patients: { [id: string]: Patient }; 8 | diagnoses: { [code: string]: Diagnosis }; 9 | }; 10 | 11 | const initialState: State = { 12 | patients: {}, 13 | diagnoses: {}, 14 | }; 15 | 16 | export const StateContext = createContext<[State, React.Dispatch]>([ 17 | initialState, 18 | () => initialState, 19 | ]); 20 | 21 | type StateProviderProps = { 22 | reducer: React.Reducer; 23 | children: React.ReactElement; 24 | }; 25 | 26 | export const StateProvider: React.FC = ({ 27 | reducer, 28 | children, 29 | }: StateProviderProps) => { 30 | const [state, dispatch] = useReducer(reducer, initialState); 31 | return ( 32 | 33 | {children} 34 | 35 | ); 36 | }; 37 | export const useStateValue = () => useContext(StateContext); 38 | -------------------------------------------------------------------------------- /part9/patientor-frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "skipLibCheck": true, 10 | "esModuleInterop": true, 11 | "allowSyntheticDefaultImports": true, 12 | "strict": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "module": "esnext", 15 | "moduleResolution": "node", 16 | "resolveJsonModule": true, 17 | "isolatedModules": true, 18 | "noEmit": true, 19 | "jsx": "react", 20 | "downlevelIteration": true, 21 | "allowJs": true 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /part9/ts-first-steps/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "eslint:recommended", 4 | "plugin:@typescript-eslint/recommended", 5 | "plugin:@typescript-eslint/recommended-requiring-type-checking" 6 | ], 7 | "plugins": ["@typescript-eslint"], 8 | "env": { 9 | "node": true, 10 | "es6": true 11 | }, 12 | "rules": { 13 | "@typescript-eslint/semi": ["error"], 14 | "@typescript-eslint/no-explicit-any": 2, 15 | "@typescript-eslint/explicit-function-return-type": 0, 16 | "@typescript-eslint/no-unused-vars": [ 17 | "error", 18 | { "argsIgnorePattern": "^_" } 19 | ], 20 | "no-case-declarations": 0 21 | }, 22 | "parser": "@typescript-eslint/parser", 23 | "parserOptions": { 24 | "project": "./tsconfig.json" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /part9/ts-first-steps/README.md: -------------------------------------------------------------------------------- 1 | # Full Stack Open 2020 - Exercise Solutions - First Steps With Typescript 2 | 3 | ## Part 9 - [Typescript](https://fullstackopen.com/en/part9) 4 | 5 | ## usage 6 | 7 | Start the express server with _npm start_ 8 | 9 | Start the server in watch mode with _npm run dev_ 10 | 11 | Go to /hello for routes information 12 | 13 | Run bmi calculator from cmd line with _npm run calculateBmi height(cm) weight(kg)_ 14 | 15 | Run exercise calculator from cmd line with _npm run calculateExercises target(hrs) dailyHours(hrs)[]_ 16 | -------------------------------------------------------------------------------- /part9/ts-first-steps/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ts-first-steps", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "dependencies": { 6 | "body-parser": "^1.19.0", 7 | "cors": "^2.8.5", 8 | "express": "^4.17.1" 9 | }, 10 | "devDependencies": { 11 | "@types/body-parser": "^1.19.0", 12 | "@types/cors": "^2.8.6", 13 | "@types/express": "^4.17.3", 14 | "@types/node": "^13.9.5", 15 | "@typescript-eslint/eslint-plugin": "^2.26.0", 16 | "@typescript-eslint/parser": "^2.26.0", 17 | "eslint": "^6.8.0", 18 | "ts-node": "^8.8.1", 19 | "ts-node-dev": "^1.0.0-pre.44", 20 | "typescript": "^3.8.3" 21 | }, 22 | "scripts": { 23 | "start": "ts-node index.ts", 24 | "dev": "ts-node-dev index.ts", 25 | "calculateBmi": "ts-node calculateBmi.ts", 26 | "calculateExercises": "ts-node exerciseCalculator.ts", 27 | "lint": "eslint --ext .ts .", 28 | "test": "echo \"Error: no test specified\" && exit 1" 29 | }, 30 | "keywords": [], 31 | "author": "Jeremy Ebinum", 32 | "license": "MIT", 33 | "description": "" 34 | } 35 | -------------------------------------------------------------------------------- /part9/ts-first-steps/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noImplicitAny": true, 4 | "noImplicitReturns": true, 5 | "strictNullChecks": true, 6 | "strictPropertyInitialization": true, 7 | "strictBindCallApply": true, 8 | "noUnusedLocals": true, 9 | "noUnusedParameters": true, 10 | "noImplicitThis": true, 11 | "alwaysStrict": true, 12 | "esModuleInterop": true, 13 | "declaration": true 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /part9/typed-courseinfo/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true 5 | }, 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:react/recommended", 9 | "plugin:@typescript-eslint/recommended" 10 | ], 11 | "plugins": ["react", "@typescript-eslint"], 12 | "settings": { 13 | "react": { 14 | "pragma": "React", 15 | "version": "detect" 16 | } 17 | }, 18 | "rules": { 19 | "react/prop-types": 0, 20 | "@typescript-eslint/explicit-function-return-type": 0 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /part9/typed-courseinfo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typed-courseinfo", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^4.2.4", 7 | "@testing-library/react": "^9.5.0", 8 | "@testing-library/user-event": "^7.2.1", 9 | "@types/jest": "^24.9.1", 10 | "@types/node": "^12.12.34", 11 | "@types/react": "^16.9.32", 12 | "@types/react-dom": "^16.9.6", 13 | "react": "^16.13.1", 14 | "react-dom": "^16.13.1", 15 | "react-scripts": "3.4.1", 16 | "react-uid": "^2.2.0", 17 | "typescript": "^3.7.5" 18 | }, 19 | "scripts": { 20 | "start": "react-scripts start", 21 | "build": "react-scripts build", 22 | "test": "react-scripts test", 23 | "eject": "react-scripts eject", 24 | "lint": "eslint ./src/**/*.{ts,tsx}" 25 | }, 26 | "eslintConfig": { 27 | "extends": "react-app" 28 | }, 29 | "browserslist": { 30 | "production": [ 31 | ">0.2%", 32 | "not dead", 33 | "not op_mini all" 34 | ], 35 | "development": [ 36 | "last 1 chrome version", 37 | "last 1 firefox version", 38 | "last 1 safari version" 39 | ] 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /part9/typed-courseinfo/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeremy-ebinum/full-stack-open-2020/ce8ebbefb5f60c71362b12849129167348891680/part9/typed-courseinfo/public/favicon.ico -------------------------------------------------------------------------------- /part9/typed-courseinfo/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeremy-ebinum/full-stack-open-2020/ce8ebbefb5f60c71362b12849129167348891680/part9/typed-courseinfo/public/logo192.png -------------------------------------------------------------------------------- /part9/typed-courseinfo/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeremy-ebinum/full-stack-open-2020/ce8ebbefb5f60c71362b12849129167348891680/part9/typed-courseinfo/public/logo512.png -------------------------------------------------------------------------------- /part9/typed-courseinfo/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 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 | -------------------------------------------------------------------------------- /part9/typed-courseinfo/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /part9/typed-courseinfo/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { uid } from "react-uid"; 3 | 4 | import { CoursePart } from "./types"; 5 | 6 | import Header from "./components/Header"; 7 | import Content from "./components/Content"; 8 | import Total from "./components/Total"; 9 | 10 | const App: React.FC = () => { 11 | const courseName = "Half Stack application development"; 12 | const courseParts: CoursePart[] = [ 13 | { 14 | name: "Fundamentals", 15 | exerciseCount: 10, 16 | description: "This is an awesome course part", 17 | id: uid({}), 18 | }, 19 | { 20 | name: "Using props to pass data", 21 | exerciseCount: 7, 22 | groupProjectCount: 3, 23 | id: uid({}), 24 | }, 25 | { 26 | name: "Deeper type usage", 27 | exerciseCount: 14, 28 | description: "Confusing description", 29 | exerciseSubmissionLink: "https://fake-exercise-submit.made-up-url.dev", 30 | id: uid({}), 31 | }, 32 | { 33 | name: "A History of Spam and Eggs", 34 | exerciseCount: 1, 35 | studentCount: 7e9, 36 | description: "Quite possibly the most important course in the world", 37 | id: uid({}), 38 | }, 39 | ]; 40 | 41 | return ( 42 |
43 |
44 | 45 | 46 |
47 | ); 48 | }; 49 | 50 | export default App; 51 | -------------------------------------------------------------------------------- /part9/typed-courseinfo/src/components/Content.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { CoursePart } from "../types"; 4 | import Part from "./Part"; 5 | 6 | const Content: React.FC<{ courseParts: CoursePart[] }> = ({ courseParts }) => { 7 | return ( 8 | <> 9 | {courseParts.map((part) => ( 10 | 11 | ))} 12 | 13 | ); 14 | }; 15 | 16 | export default Content; 17 | -------------------------------------------------------------------------------- /part9/typed-courseinfo/src/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const Header: React.FC<{ courseName: string }> = ({ courseName }) => { 4 | return

{courseName}

; 5 | }; 6 | 7 | export default Header; 8 | -------------------------------------------------------------------------------- /part9/typed-courseinfo/src/components/Total.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { CoursePart } from "../types"; 4 | 5 | const Total: React.FC<{ courseParts: CoursePart[] }> = ({ courseParts }) => { 6 | return ( 7 |

8 | Total number of exercises:{" "} 9 | 10 | {courseParts.reduce((carry, part) => carry + part.exerciseCount, 0)} 11 | 12 |

13 | ); 14 | }; 15 | 16 | export default Total; 17 | -------------------------------------------------------------------------------- /part9/typed-courseinfo/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 | -------------------------------------------------------------------------------- /part9/typed-courseinfo/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import "./index.css"; 4 | import App from "./App"; 5 | 6 | ReactDOM.render( 7 | 8 | 9 | , 10 | document.getElementById("root") 11 | ); 12 | -------------------------------------------------------------------------------- /part9/typed-courseinfo/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /part9/typed-courseinfo/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/extend-expect'; 6 | -------------------------------------------------------------------------------- /part9/typed-courseinfo/src/types.ts: -------------------------------------------------------------------------------- 1 | export interface CoursePartBase { 2 | name: string; 3 | exerciseCount: number; 4 | id: string; 5 | } 6 | 7 | interface CoursePartBaseWithOptionalDescription extends CoursePartBase { 8 | description?: string; 9 | } 10 | 11 | interface CoursePartOne extends CoursePartBaseWithOptionalDescription { 12 | name: "Fundamentals"; 13 | } 14 | 15 | interface CoursePartTwo extends CoursePartBase { 16 | name: "Using props to pass data"; 17 | groupProjectCount: number; 18 | } 19 | 20 | interface CoursePartThree extends CoursePartBaseWithOptionalDescription { 21 | name: "Deeper type usage"; 22 | exerciseSubmissionLink: string; 23 | } 24 | 25 | interface CoursePartFour extends CoursePartBase { 26 | name: "A History of Spam and Eggs"; 27 | description: string; 28 | studentCount: number; 29 | } 30 | 31 | export type CoursePart = 32 | | CoursePartOne 33 | | CoursePartTwo 34 | | CoursePartThree 35 | | CoursePartFour; 36 | -------------------------------------------------------------------------------- /part9/typed-courseinfo/src/utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Helper function for exhaustive type checking 3 | */ 4 | export const assertNever = (value: never): never => { 5 | throw new Error( 6 | `Unhandled discriminated union member: ${JSON.stringify(value)}` 7 | ); 8 | }; 9 | -------------------------------------------------------------------------------- /part9/typed-courseinfo/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 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "noEmit": true, 20 | "jsx": "react" 21 | }, 22 | "include": [ 23 | "src" 24 | ] 25 | } 26 | --------------------------------------------------------------------------------