diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e4d476..fcba219 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,29 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.2.0] - 2020-07-31 + +### 💻 Jobs on DB + +### Added + +### Changed + +- Details Side Link icons styling +- Moved away from bare `CSS` and implement `styled-components` instead - [#39](https://github.com/alexlee-dev/gh-jobs/issues/39) +- Jobs are now stored on the database - [#40](https://github.com/alexlee-dev/gh-jobs/issues/40) +- Notifications now use `react-toastify` - [#36](https://github.com/alexlee-dev/gh-jobs/issues/36) +- Searches where possible, will use the database instead of the GitHub Jobs API - [#48](https://github.com/alexlee-dev/gh-jobs/issues/48) +- The `Details` page now makes a request to the BE for job details - [#51](https://github.com/alexlee-dev/gh-jobs/issues/51) +- Going to the old domain should now route you to the new domain - [#46](https://github.com/alexlee-dev/gh-jobs/issues/46) +- `savedJobs` is now an array of `id`'s instead of an array of the entire `Job` object - [#55](https://github.com/alexlee-dev/gh-jobs/issues/5) + +### Removed + +### Fixed + +- Ability to return to Profile Display from viewing your saved jobs - [#56](https://github.com/alexlee-dev/gh-jobs/issues/56) + ## [1.1.1] - 2020-07-23 ### 🖼️ Assets Fix diff --git a/README.md b/README.md index 9e46cb0..111f810 100644 --- a/README.md +++ b/README.md @@ -58,8 +58,10 @@ View [my solution](https://devchallenges.io/solutions/Lwb0aAViU0LzUKL6kscT) on D - [react-dom](https://www.npmjs.com/package/react-dom) - A declarative, efficient, and flexible JavaScript library for building user interfaces. - [react-redux](https://react-redux.js.org/) - Official React bindings for Redux. - [react-router-dom](https://reactrouter.com/) - Declarative routing for React. +- [react-toastify](https://fkhadra.github.io/react-toastify/) - React notification made easy 🚀! - [redux](https://redux.js.org/) - Predictable state container for JavaScript apps. - [redux-thunk](https://github.com/reduxjs/redux-thunk) - Thunk middleware for Redux. +- [styled-components](https://styled-components.com/) - Visual primitives for the component age. Use the best bits of ES6 and CSS to style your apps without stress 💅. - [validator](https://github.com/validatorjs/validator.js) - String validation. ### DevDependencies diff --git a/cypress/fixtures/jobDetails.json b/cypress/fixtures/jobDetails.json new file mode 100644 index 0000000..8fc8440 --- /dev/null +++ b/cypress/fixtures/jobDetails.json @@ -0,0 +1,13 @@ +{ + "id": "f1884b46-ecb4-473c-81f5-08d9bf2ab3bb", + "type": "Full Time", + "url": "https://jobs.github.com/positions/f1884b46-ecb4-473c-81f5-08d9bf2ab3bb", + "created_at": "Thu Jul 16 12:03:19 UTC 2020", + "company": "Cool Company", + "company_url": "https://www.adswizz.com/", + "location": "Bucharest", + "title": "Cloud DevOps Engineer", + "description": "\u003cp\u003eFor our Global Operations team in Bucharest, located at the 35th floor in Sky Tower building (highest in Romania), we are hiring a Cloud DevOps Engineer.\u003c/p\u003e\n\u003cp\u003eAs a Cloud DevOps Engineer you will:\n– Be curious. You ask \"why\"?, you explore, you’re not afraid to blurt out your crazy idea\n– Code the infrastructure to act as designed\n– Improve the whole life-cycle of services from design, through deployment, operation and refinement\n– Maintain services once they are live by measuring and monitoring availability, latency and overall system health\n– Scale systems sustainably through mechanisms like automation, and evolve systems by pushing for changes that improve reliability and velocity\n– Practice sustainable incident response and blameless postmortems\n– Have an opinion on any/all of the following orchestration tools\n– Be an audiophile – Interested in all things audio including but not limited to: sound quality, streaming technologies and best bands of 21st century\u003c/p\u003e\n\u003cp\u003eWhat you bring to the team:\n– Bachelor Degree in Computer Science, Mathematics and Informatics or equivalent\n– At least 2 years of experience working on large-scale distributed systems\n– Experience running Linux-based production systems\n– Your thinking starts with the desire for high availability\n– Your love for the command line and scripting development\n– Experience with Amazon AWS services (EC2, S3, ELB, …)\n– Experience with or related technologies like Puppet, Ansible, Nagios, Grafana, Prometheus, HAProxy, NGinx, Apache, MySQL/MariaDB, Kubernetes, Kafka, Hadoop\n– CI/CD for both infrastructure and applications\u003c/p\u003e\n\u003cp\u003eOur offer (bonuses, benefits) – what’s in it for you:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003eCasual \u0026amp; friendly working environment with opportunities to impact the company with your ideas and involvement\u003c/li\u003e\n\u003cli\u003eWe have a start-up culture, product-centric, international/multi-cultural environment in each office and easy going communication style (even with Top Management)\u003c/li\u003e\n\u003cli\u003eTechnology diversity, interesting technical exposure in building the best ad-tech product on the market\u003c/li\u003e\n\u003cli\u003eFlexible working schedule and work-from-home option, within predefined rules\u003c/li\u003e\n\u003cli\u003eIndividual training budget to use as you like for career improvement\u003c/li\u003e\n\u003cli\u003eBonus system, on top of base salary, paid quarterly (for real, not just on paper)\u003c/li\u003e\n\u003cli\u003ePaid days off related to quarterly performance\u003c/li\u003e\n\u003cli\u003eDay off on your birthday (or within that month if it falls on the weekend)\u003c/li\u003e\n\u003cli\u003eSports bonus for activities such as gym, dances, yoga, etc.\u003c/li\u003e\n\u003cli\u003eWe encourage your healthy life-style with company paid enrollment for running or biking events\u003c/li\u003e\n\u003cli\u003eMeal tickets\u003c/li\u003e\n\u003cli\u003eGift cards for special events (e.g.: Easter, 1st of June, 8th of March, Christmas)\u003c/li\u003e\n\u003cli\u003eMedical coverage to keep you healthy\u003c/li\u003e\n\u003cli\u003eParking lots at Sky Tower\u003c/li\u003e\n\u003cli\u003eAdsWizz technical books library – you can propose new technical books to be bought by the company\u003c/li\u003e\n\u003cli\u003eBookster subscription\u003c/li\u003e\n\u003cli\u003eGood hardware devices (new laptops / Mac’s, displays etc.)\u003c/li\u003e\n\u003cli\u003eOffice relaxation areas (ping-pong, foosball etc.)\u003c/li\u003e\n\u003cli\u003eTeam buildings – each team goes on outings to keep that flame alive\u003c/li\u003e\n\u003cli\u003eAnnual Christmas party – the best company party you’ve ever seen\u003c/li\u003e\n\u003cli\u003eFamily events (e.g.: Halloween and Christmas party for employees’ kids)\u003c/li\u003e\n\u003cli\u003eThemed team events nights (casino night, boardgames night, scary movies night, etc.)\u003c/li\u003e\n\u003cli\u003eCatered lunch-time meetings\u003c/li\u003e\n\u003cli\u003eWe have weekly fresh fruit along with coffee and tea to keep that brain in top shape, orange juice \u0026amp; vending machine on premise as well\u003c/li\u003e\n\u003cli\u003eCSR activities (cake auctions, yard sales, blood donation campaigns at our office)\nWanna see how Belgian chocolate goes with technology? Come and join a community of the smartest folks you’ve ever met, that want to aim for the sky and want to use their skills to make a difference!\u003c/li\u003e\n\u003c/ul\u003e\n", + "how_to_apply": "\u003cp\u003e\u003ca href=\"https://www.adswizz.com/our-careers#!/job/4528257002?utm_source=github\u0026amp;utm_medium=smartdreamers\u0026amp;utm_campaign=July_2020_%7C_Cloud_DevOps_Engineer_%7C_Bucharest\"\u003ehttps://www.adswizz.com/our-careers#!/job/4528257002?utm_source=github\u0026amp;utm_medium=smartdreamers\u0026amp;utm_campaign=July_2020_%7C_Cloud_DevOps_Engineer_%7C_Bucharest\u003c/a\u003e\u003c/p\u003e\n", + "company_logo": "https://jobs.github.com/rails/active_storage/blobs/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBanFHIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--0903e54cc814013e7ce5b39a629717629e033a4e/Adswizz.png" +} diff --git a/cypress/fixtures/jobsSearch2.json b/cypress/fixtures/jobsSearch2.json new file mode 100644 index 0000000..50985c5 --- /dev/null +++ b/cypress/fixtures/jobsSearch2.json @@ -0,0 +1,54 @@ +[ + { + "id": 1, + "type": "Full Time", + "url": "http://nbcnews.com/id/justo.html?ante=tellus&vestibulum=in&ante=sagittis&ipsum=dui&primis=vel&in=nisl&faucibus=duis&orci=ac&luctus=nibh&et=fusce&ultrices=lacus&posuere=purus&cubilia=aliquet&curae=at&duis=feugiat&faucibus=non&accumsan=pretium&odio=quis&curabitur=lectus&convallis=suspendisse&duis=potenti&consequat=in&dui=eleifend&nec=quam&nisi=a&volutpat=odio&eleifend=in&donec=hac&ut=habitasse&dolor=platea&morbi=dictumst&vel=maecenas&lectus=ut&in=massa&quam=quis&fringilla=augue&rhoncus=luctus&mauris=tincidunt&enim=nulla&leo=mollis", + "created_at": "2009-12-26T17:13:11Z", + "company": "Anderson LLC", + "company_url": "https://deliciousdays.com/consequat/lectus/in.png?nisl=nibh&aenean=fusce&lectus=lacus&pellentesque=purus&eget=aliquet&nunc=at&donec=feugiat&quis=non&orci=pretium&eget=quis&orci=lectus&vehicula=suspendisse&condimentum=potenti&curabitur=in&in=eleifend&libero=quam&ut=a&massa=odio&volutpat=in&convallis=hac&morbi=habitasse&odio=platea&odio=dictumst&elementum=maecenas&eu=ut&interdum=massa&eu=quis&tincidunt=augue&in=luctus&leo=tincidunt&maecenas=nulla&pulvinar=mollis&lobortis=molestie&est=lorem&phasellus=quisque&sit=ut", + "location": "Pennsylvania", + "title": "Analog Circuit Design manager", + "description": "", + "how_to_apply": "", + "company_logo": "http://dummyimage.com/155x185.png/cc0000/ffffff" + }, + { + "id": 2, + "type": "Full Time", + "url": "https://posterous.com/et/ultrices/posuere/cubilia/curae/duis.json?nunc=non&purus=velit&phasellus=donec&in=diam&felis=neque&donec=vestibulum&semper=eget&sapien=vulputate&a=ut&libero=ultrices&nam=vel&dui=augue&proin=vestibulum&leo=ante&odio=ipsum&porttitor=primis&id=in&consequat=faucibus&in=orci&consequat=luctus&ut=et&nulla=ultrices&sed=posuere&accumsan=cubilia&felis=curae&ut=donec&at=pharetra&dolor=magna&quis=vestibulum&odio=aliquet&consequat=ultrices&varius=erat", + "created_at": "2019-10-01T13:09:54Z", + "company": "Herman-Kuhic", + "company_url": "http://weather.com/eleifend/donec/ut/dolor/morbi.json?tellus=augue&nisi=aliquam&eu=erat&orci=volutpat&mauris=in&lacinia=congue&sapien=etiam&quis=justo&libero=etiam&nullam=pretium&sit=iaculis&amet=justo&turpis=in&elementum=hac&ligula=habitasse&vehicula=platea&consequat=dictumst&morbi=etiam&a=faucibus&ipsum=cursus&integer=urna&a=ut&nibh=tellus&in=nulla&quis=ut&justo=erat&maecenas=id&rhoncus=mauris&aliquam=vulputate&lacus=elementum&morbi=nullam&quis=varius&tortor=nulla&id=facilisi&nulla=cras&ultrices=non&aliquet=velit&maecenas=nec&leo=nisi&odio=vulputate&condimentum=nonummy&id=maecenas&luctus=tincidunt&nec=lacus&molestie=at&sed=velit&justo=vivamus&pellentesque=vel&viverra=nulla&pede=eget&ac=eros&diam=elementum&cras=pellentesque&pellentesque=quisque&volutpat=porta&dui=volutpat&maecenas=erat&tristique=quisque&est=erat&et=eros&tempus=viverra&semper=eget&est=congue&quam=eget&pharetra=semper&magna=rutrum&ac=nulla&consequat=nunc&metus=purus&sapien=phasellus&ut=in&nunc=felis&vestibulum=donec&ante=semper&ipsum=sapien&primis=a&in=libero&faucibus=nam&orci=dui&luctus=proin&et=leo&ultrices=odio&posuere=porttitor&cubilia=id&curae=consequat&mauris=in&viverra=consequat&diam=ut", + "location": "California", + "title": "Operator", + "description": "", + "how_to_apply": "", + "company_logo": "http://dummyimage.com/185x224.jpg/cc0000/ffffff" + }, + { + "id": 3, + "type": "Full Time", + "url": "https://craigslist.org/luctus/rutrum.png?vestibulum=elit&rutrum=proin&rutrum=risus&neque=praesent&aenean=lectus&auctor=vestibulum&gravida=quam&sem=sapien&praesent=varius&id=ut&massa=blandit&id=non&nisl=interdum&venenatis=in&lacinia=ante&aenean=vestibulum&sit=ante&amet=ipsum&justo=primis&morbi=in&ut=faucibus&odio=orci&cras=luctus&mi=et&pede=ultrices&malesuada=posuere&in=cubilia&imperdiet=curae&et=duis&commodo=faucibus&vulputate=accumsan&justo=odio&in=curabitur&blandit=convallis&ultrices=duis&enim=consequat&lorem=dui&ipsum=nec&dolor=nisi&sit=volutpat&amet=eleifend&consectetuer=donec&adipiscing=ut&elit=dolor&proin=morbi&interdum=vel&mauris=lectus&non=in&ligula=quam&pellentesque=fringilla&ultrices=rhoncus&phasellus=mauris&id=enim&sapien=leo&in=rhoncus&sapien=sed&iaculis=vestibulum&congue=sit&vivamus=amet&metus=cursus&arcu=id&adipiscing=turpis&molestie=integer&hendrerit=aliquet&at=massa&vulputate=id&vitae=lobortis&nisl=convallis&aenean=tortor&lectus=risus&pellentesque=dapibus&eget=augue&nunc=vel&donec=accumsan&quis=tellus&orci=nisi&eget=eu&orci=orci&vehicula=mauris&condimentum=lacinia&curabitur=sapien&in=quis&libero=libero&ut=nullam&massa=sit&volutpat=amet&convallis=turpis&morbi=elementum&odio=ligula&odio=vehicula&elementum=consequat&eu=morbi&interdum=a", + "created_at": "2013-03-11T15:13:15Z", + "company": "Howe-Becker", + "company_url": "https://boston.com/orci/luctus/et/ultrices.jpg?condimentum=semper&neque=est&sapien=quam&placerat=pharetra&ante=magna&nulla=ac&justo=consequat&aliquam=metus&quis=sapien&turpis=ut&eget=nunc&elit=vestibulum&sodales=ante&scelerisque=ipsum&mauris=primis&sit=in&amet=faucibus&eros=orci&suspendisse=luctus&accumsan=et&tortor=ultrices&quis=posuere&turpis=cubilia&sed=curae&ante=mauris&vivamus=viverra&tortor=diam&duis=vitae&mattis=quam&egestas=suspendisse&metus=potenti&aenean=nullam&fermentum=porttitor&donec=lacus&ut=at&mauris=turpis&eget=donec&massa=posuere&tempor=metus&convallis=vitae&nulla=ipsum&neque=aliquam&libero=non&convallis=mauris&eget=morbi&eleifend=non&luctus=lectus&ultricies=aliquam&eu=sit&nibh=amet&quisque=diam&id=in&justo=magna&sit=bibendum&amet=imperdiet&sapien=nullam&dignissim=orci&vestibulum=pede&vestibulum=venenatis&ante=non&ipsum=sodales&primis=sed&in=tincidunt&faucibus=eu&orci=felis&luctus=fusce&et=posuere&ultrices=felis&posuere=sed&cubilia=lacus&curae=morbi&nulla=sem&dapibus=mauris&dolor=laoreet&vel=ut&est=rhoncus&donec=aliquet&odio=pulvinar&justo=sed&sollicitudin=nisl&ut=nunc&suscipit=rhoncus&a=dui&feugiat=vel&et=sem&eros=sed&vestibulum=sagittis&ac=nam&est=congue&lacinia=risus&nisi=semper&venenatis=porta&tristique=volutpat&fusce=quam", + "location": "Oregon", + "title": "Software Test Engineer I", + "description": "", + "how_to_apply": "", + "company_logo": "http://dummyimage.com/111x130.bmp/5fa2dd/ffffff" + }, + { + "id": 4, + "type": "Full Time", + "url": "http://usa.gov/at/diam/nam/tristique/tortor/eu/pede.jsp?in=turpis&blandit=adipiscing&ultrices=lorem&enim=vitae&lorem=mattis&ipsum=nibh&dolor=ligula&sit=nec&amet=sem&consectetuer=duis&adipiscing=aliquam&elit=convallis&proin=nunc", + "created_at": "2000-12-20T22:53:44Z", + "company": "Wolff LLC", + "company_url": "http://time.com/congue.jsp?pretium=turpis&iaculis=enim&justo=blandit&in=mi&hac=in&habitasse=porttitor&platea=pede&dictumst=justo&etiam=eu&faucibus=massa&cursus=donec&urna=dapibus&ut=duis&tellus=at&nulla=velit&ut=eu&erat=est&id=congue", + "location": "Texas", + "title": "Staff Scientist", + "description": "", + "how_to_apply": "", + "company_logo": "http://dummyimage.com/122x234.bmp/5fa2dd/ffffff" + } +] diff --git a/cypress/integration/details.spec.js b/cypress/integration/details.spec.js index 09771b9..a209950 100644 --- a/cypress/integration/details.spec.js +++ b/cypress/integration/details.spec.js @@ -3,46 +3,43 @@ context("Details", () => { beforeEach(() => { cy.fixture("jobs50").then((jobsJson) => { - cy.server(); - cy.route({ - method: "GET", - url: "/jobs", - status: 200, - response: jobsJson, - delay: 1000, + cy.fixture("jobDetails").then((jobDetails) => { + cy.server(); + cy.route({ + method: "GET", + url: "/jobs", + status: 200, + response: jobsJson, + delay: 1000, + }); + cy.route({ + method: "GET", + url: "/jobs/f1884b46-ecb4-473c-81f5-08d9bf2ab3bb", + status: 200, + response: jobDetails, + delay: 1000, + }); }); }); cy.visit("http://localhost:3000"); - cy.wait(1000); - cy.get( - "#app > div.search__container > div.jobs__container > div:nth-child(1) > div.jobcard__container__left > div.jobcard__container__middle > a > p" - ).click(); + cy.wait(500); + cy.get("#f1884b46-ecb4-473c-81f5-08d9bf2ab3bb").click({ force: true }); }); it("Should display '
' correctly", () => { - cy.get( - "#app > div.details__container > div.details__side__container > div > span" - ).should("have.text", "How to Apply"); - cy.get( - "#app > div.details__container > div.details__main__container > div.details__container__title > div.details__container__title__inner > h2" - ).should("have.text", "Cloud DevOps Engineer"); + cy.get("#how-to-label").should("have.text", "How to Apply"); + cy.get("#details-title").should("have.text", "Cloud DevOps Engineer"); cy.get("#full-time-indicator").should("have.text", "Full Time"); - cy.get( - "#app > div.details__container > div.details__main__container > div.details__container__company > div.details__company__right > a" - ).should("have.text", "Cool Company"); + cy.get("#details-company-name").should("have.text", "Cool Company"); }); it("Should be able to return to ''", () => { cy.get("#search").should("not.be.visible"); - cy.get( - "#app > div.details__container > div.details__side__container > a > span" - ).click(); + cy.get("#back-to-search").click(); cy.get("#search").should("be.visible"); - cy.get( - "#app > div.search__container > div.jobs__container > div:nth-child(1) > div.jobcard__container__left > div.jobcard__container__middle > a > p" - ).click(); + cy.get("#f1884b46-ecb4-473c-81f5-08d9bf2ab3bb").click({ force: true }); cy.get("#search").should("not.be.visible"); cy.get("header").click(); diff --git a/cypress/integration/login.spec.js b/cypress/integration/login.spec.js index dd5dddd..177d248 100644 --- a/cypress/integration/login.spec.js +++ b/cypress/integration/login.spec.js @@ -22,7 +22,7 @@ context("Login - Success", () => { }); }); cy.visit("http://localhost:3000"); - cy.wait(1000); + cy.wait(500); cy.get("#nav-login").click(); cy.get("h1").should("have.text", "Login"); }); @@ -31,7 +31,7 @@ context("Login - Success", () => { cy.get("#email").type("bobtest@email.com"); cy.get("#password").type("Red123456!!!"); cy.get("#log-in").click(); - cy.wait(1500); + cy.wait(500); cy.get("#nav-login").should("not.exist"); cy.get("#search").should("be.visible"); @@ -56,7 +56,7 @@ context("Login - Error", () => { }); }); cy.visit("http://localhost:3000"); - cy.wait(1000); + cy.wait(500); cy.get("#nav-login").click(); cy.get("h1").should("have.text", "Login"); }); @@ -66,6 +66,6 @@ context("Login - Error", () => { cy.get("#password").type("Red123456!!!"); cy.get("#log-in").click(); cy.wait(500); - cy.get("#notification-text").should("have.text", "Invalid credentials."); + cy.get("#notification").should("have.text", "Invalid credentials."); }); }); diff --git a/cypress/integration/notification.spec.js b/cypress/integration/notification.spec.js index b9d0aa2..97e143b 100644 --- a/cypress/integration/notification.spec.js +++ b/cypress/integration/notification.spec.js @@ -13,7 +13,7 @@ context("Notification", () => { }); }); cy.visit("http://localhost:3000"); - cy.wait(1000); + cy.wait(500); }); it("Should reset the notification on initial load", () => { @@ -22,7 +22,7 @@ context("Notification", () => { cy.get("#email").type("bobtest@email.com"); cy.get("#password").type("Red123456!!!"); cy.get("#log-in").click(); - cy.wait(1500); + cy.wait(500); cy.get("#nav-login").should("not.exist"); cy.get("#search").should("be.visible"); cy.get("#nav-profile").click(); @@ -32,25 +32,25 @@ context("Notification", () => { cy.get("#edit-name").type("Cool Bob"); cy.get("#edit-confirm").click(); - cy.wait(1500); + cy.wait(500); cy.get("h1").should("have.text", "Profile"); - cy.get("#notification-text").should( + cy.get("#notification").should( "have.text", "Profile information updated successfully." ); cy.reload(); cy.get("#nav-profile").click(); - cy.get("#notification-text").should("not.exist"); + cy.get("#notification").should("not.exist"); // * Reset to normal data (Cleanup) cy.get("#edit").click(); - cy.get("#notification-text").should("not.exist"); + cy.get("#notification").should("not.exist"); cy.get("#edit-name").clear(); cy.get("#edit-name").type("Bob Test"); cy.get("#edit-confirm").click(); - cy.wait(1500); + cy.wait(500); cy.get("h1").should("have.text", "Profile"); cy.get("#name").should("have.value", "Bob Test"); cy.get("#email").should("have.value", "bobtest@email.com"); @@ -62,7 +62,7 @@ context("Notification", () => { cy.get("#email").type("bobtest@email.com"); cy.get("#password").type("Red123456!!!"); cy.get("#log-in").click(); - cy.wait(1500); + cy.wait(500); cy.get("#nav-login").should("not.exist"); cy.get("#search").should("be.visible"); cy.get("#nav-profile").click(); @@ -72,22 +72,22 @@ context("Notification", () => { cy.get("#edit-name").type("Cool Bob"); cy.get("#edit-confirm").click(); - cy.wait(1500); + cy.wait(500); cy.get("h1").should("have.text", "Profile"); - cy.get("#notification-text").should( + cy.get("#notification").should( "have.text", "Profile information updated successfully." ); cy.wait(5000); - cy.get("#notification-text").should("not.exist"); + cy.get("#notification").should("not.exist"); // * Reset to normal data (Cleanup) cy.get("#edit").click(); cy.get("#edit-name").clear(); cy.get("#edit-name").type("Bob Test"); cy.get("#edit-confirm").click(); - cy.wait(1500); + cy.wait(500); cy.get("h1").should("have.text", "Profile"); cy.get("#name").should("have.value", "Bob Test"); cy.get("#email").should("have.value", "bobtest@email.com"); diff --git a/cypress/integration/optionsPanel.spec.js b/cypress/integration/optionsPanel.spec.js index 0fd7cfd..ebcce84 100644 --- a/cypress/integration/optionsPanel.spec.js +++ b/cypress/integration/optionsPanel.spec.js @@ -4,40 +4,43 @@ context("Options Panel", () => { beforeEach(() => { cy.fixture("jobs50").then((jobsJson) => { cy.fixture("jobsSearch1").then((jobsSearch1Json) => { - cy.server(); - cy.route({ - method: "GET", - url: "/jobs", - status: 200, - response: jobsJson, - }); - cy.route({ - method: "GET", - url: "/jobs/search?full_time=true&description=developer", - status: 200, - delay: 1000, - response: jobsSearch1Json, - }); - cy.route({ - method: "GET", - url: "/jobs/search?full_time=false&description=&location=Los Angeles", - status: 200, - delay: 1000, - response: jobsSearch1Json, - }); - cy.route({ - method: "GET", - url: "/jobs/search?full_time=false&description=&location=Chicago", - status: 200, - delay: 1000, - response: jobsSearch1Json, - }); - cy.route({ - method: "GET", - url: "/jobs/search?full_time=false&description=developer", - status: 200, - delay: 1000, - response: jobsSearch1Json, + cy.fixture("jobsSearch2").then((jobsSearch2Json) => { + cy.server(); + cy.route({ + method: "GET", + url: "/jobs", + status: 200, + response: jobsJson, + }); + cy.route({ + method: "GET", + url: "/jobs/search?full_time=true&description=developer", + status: 200, + delay: 1000, + response: jobsSearch2Json, + }); + cy.route({ + method: "GET", + url: + "/jobs/search?full_time=false&description=&location=Los Angeles", + status: 200, + delay: 1000, + response: jobsSearch1Json, + }); + cy.route({ + method: "GET", + url: "/jobs/search?full_time=false&description=&location=Chicago", + status: 200, + delay: 1000, + response: jobsSearch1Json, + }); + cy.route({ + method: "GET", + url: "/jobs/search?full_time=false&description=developer", + status: 200, + delay: 1000, + response: jobsSearch1Json, + }); }); }); }); @@ -45,19 +48,13 @@ context("Options Panel", () => { }); it("Should retain FullTime state", () => { - cy.get( - "#app > div.search__container > div.options-panel__container > label:nth-child(1) > input[type=checkbox]" - ).should("not.be.checked"); - cy.get( - "#app > div.search__container > div.options-panel__container > label:nth-child(1) > span" - ).click(); + cy.get('input[name="full-time-checkbox"]').should("not.be.checked"); + cy.get(":nth-child(1) > [data-cy=checkmark]").click(); cy.get("#search").type("developer"); cy.get("#search-submit").click(); cy.wait(1000); cy.reload(); - cy.get( - "#app > div.search__container > div.options-panel__container > label:nth-child(1) > input[type=checkbox]" - ).should("be.checked"); + cy.get('input[name="full-time-checkbox"]').should("be.checked"); }); it("Should retain location search value", () => { @@ -70,9 +67,7 @@ context("Options Panel", () => { it("Should retain options values", () => { cy.get("#location-1").should("not.be.checked"); - cy.get( - "#app > div.search__container > div.options-panel__container > label:nth-child(3) > span" - ).click(); + cy.get(":nth-child(3) > [data-cy=checkmark]").click(); cy.get("#location-1").should("be.checked"); cy.get("#search-submit").click(); cy.wait(1000); @@ -83,19 +78,17 @@ context("Options Panel", () => { cy.get("#search").type("developer"); cy.get("#search-submit").click(); - cy.wait(1500); - cy.get(".jobcard__container").then(($jobs) => { + cy.wait(1000); + cy.get('[data-cy="job-container"]').then(($jobs) => { assert.equal($jobs.length, 5); }); - cy.get( - "#app > div.search__container > div.options-panel__container > label:nth-child(1) > span" - ).click(); + cy.get(":nth-child(1) > [data-cy=checkmark]").click(); cy.get("#search-submit").click(); - cy.wait(1500); - cy.get(".jobcard__container").then(($jobs) => { - assert.equal($jobs.length, 2); + cy.wait(1000); + cy.get('[data-cy="job-container"]').then(($jobs) => { + assert.equal($jobs.length, 4); }); }); }); diff --git a/cypress/integration/pagination.spec.js b/cypress/integration/pagination.spec.js index 1b29001..2bb2cd1 100644 --- a/cypress/integration/pagination.spec.js +++ b/cypress/integration/pagination.spec.js @@ -17,7 +17,7 @@ context("Pagination", () => { }); it("Should render initial component correctly", () => { - cy.get(".pagination__list").then(($list) => { + cy.get('[data-cy="pagination-list"]').then(($list) => { const childList = $list[0].children; cy.paginationSelect1(childList); @@ -25,7 +25,7 @@ context("Pagination", () => { }); it("Should use right arrow to traverse to end of list", () => { - cy.get(".pagination__list").then(($list) => { + cy.get('[data-cy="pagination-list"]').then(($list) => { const childList = $list[0].children; // * 7th Button should be Right Arrow @@ -99,7 +99,7 @@ context("Pagination", () => { }); it("Should use left arrow to traverse back to the beginning of the list", () => { - cy.get(".pagination__list").then(($list) => { + cy.get('[data-cy="pagination-list"]').then(($list) => { const childList = $list[0].children; const rightArrowButton = childList[6].children[0]; const leftArrowButton = childList[0].children[0]; @@ -189,7 +189,7 @@ context("Pagination", () => { }); it("Should be able to hop to ends of pagination", () => { - cy.get(".pagination__list").then(($list) => { + cy.get('[data-cy="pagination-list"]').then(($list) => { const childList = $list[0].children; const page1Button = childList[1].children[0]; const page10Button = childList[5].children[0]; @@ -213,28 +213,24 @@ context("Pagination", () => { cy.server(); cy.route({ method: "GET", - url: "/jobs/search?full_time=false&description=&location=Chicago", + url: "/jobs/search?full_time=false&description=&location1=Chicago", status: 200, response: jobsJson, - onRequest: (xhr) => {}, - onResponse: (xhr) => {}, }); }); - cy.get(".pagination__list").then(($list) => { + cy.get('[data-cy="pagination-list"]').then(($list) => { const childList = $list[0].children; cy.paginationSelect1(childList); - cy.get( - "#app > div.search__container > div.options-panel__container > label:nth-child(3) > span" - ).click(); + cy.get(":nth-child(3) > [data-cy=checkmark]").click(); cy.get("#search-submit").click(); - cy.get(".jobcard__container").then(($jobs) => { + cy.get('[data-cy="job-container"]').then(($jobs) => { assert.equal($jobs.length, 3); - cy.get(".pagination__list").then(($list) => { + cy.get('[data-cy="pagination-list"]').then(($list) => { const childList = $list[0].children; // * Should contain 3 elements @@ -246,7 +242,7 @@ context("Pagination", () => { // * 2nd Button should be "1" assert.equal(childList[1].innerText, "1"); // * 2nd Button as "1" should be selected by default - assert.equal(childList[1].className, "pagination__item__selected"); + assert.equal(childList[1].dataset.cy, "pagination-item-selected"); // * 3rd Button should be Right Arrow assert.equal(childList[2].innerText, "chevron_right"); // * 3rd button should be disabled @@ -274,7 +270,7 @@ context("Pagination - 1 Page", () => { }); it("Should display pagination correctly, when 5 jobs exist", () => { - cy.get(".pagination__list").then(($list) => { + cy.get('[data-cy="pagination-list"]').then(($list) => { const childList = $list[0].children; // * Should contain 3 elements @@ -286,7 +282,7 @@ context("Pagination - 1 Page", () => { // * 2nd Button should be "1" assert.equal(childList[1].innerText, "1"); // * 2nd Button as "1" should be selected by default - assert.equal(childList[1].className, "pagination__item__selected"); + assert.equal(childList[1].dataset.cy, "pagination-item-selected"); // * 3rd Button should be Right Arrow assert.equal(childList[2].innerText, "chevron_right"); // * 3rd button should be disabled @@ -312,7 +308,7 @@ context("Pagination - 2 Pages", () => { }); it("Should display pagination correctly, when 10 jobs exist", () => { - cy.get(".pagination__list").then(($list) => { + cy.get('[data-cy="pagination-list"]').then(($list) => { const childList = $list[0].children; // * Should contain 4 elements @@ -324,7 +320,7 @@ context("Pagination - 2 Pages", () => { // * 2nd Button should be "1" assert.equal(childList[1].innerText, "1"); // * 2nd Button as "1" should be selected by default - assert.equal(childList[1].className, "pagination__item__selected"); + assert.equal(childList[1].dataset.cy, "pagination-item-selected"); // * 3rd Button should be "2" assert.equal(childList[2].innerText, "2"); // * 4th Button should be Right Arrow @@ -350,7 +346,7 @@ context("Pagination - 3 Pages", () => { }); it("Should display pagination correctly, when 15 jobs exist", () => { - cy.get(".pagination__list").then(($list) => { + cy.get('[data-cy="pagination-list"]').then(($list) => { const childList = $list[0].children; // * Should contain 5 elements @@ -362,7 +358,7 @@ context("Pagination - 3 Pages", () => { // * 2nd Button should be "1" assert.equal(childList[1].innerText, "1"); // * 2nd Button as "1" should be selected by default - assert.equal(childList[1].className, "pagination__item__selected"); + assert.equal(childList[1].dataset.cy, "pagination-item-selected"); // * 3rd Button should be "2" assert.equal(childList[2].innerText, "2"); // * 4th Button should be "3" @@ -390,7 +386,7 @@ context("Pagination - 4 Pages", () => { }); it("Should display pagination correctly, when 20 jobs exist", () => { - cy.get(".pagination__list").then(($list) => { + cy.get('[data-cy="pagination-list"]').then(($list) => { const childList = $list[0].children; // * Should contain 6 elements @@ -402,7 +398,7 @@ context("Pagination - 4 Pages", () => { // * 2nd Button should be "1" assert.equal(childList[1].innerText, "1"); // * 2nd Button as "1" should be selected by default - assert.equal(childList[1].className, "pagination__item__selected"); + assert.equal(childList[1].dataset.cy, "pagination-item-selected"); // * 3rd Button should be "2" assert.equal(childList[2].innerText, "2"); // * 4th Button should be "3" diff --git a/cypress/integration/profile.spec.js b/cypress/integration/profile.spec.js index c1203ea..ea65c8c 100644 --- a/cypress/integration/profile.spec.js +++ b/cypress/integration/profile.spec.js @@ -17,7 +17,7 @@ context("Profile", () => { cy.get("#email").type("bobtest@email.com"); cy.get("#password").type("Red123456!!!"); cy.get("#log-in").click(); - cy.wait(1500); + cy.wait(500); cy.get("#nav-login").should("not.exist"); cy.get("#nav-profile").should("be.visible"); @@ -43,20 +43,20 @@ context("Profile", () => { cy.wait(1500); cy.get("h1").should("have.text", "Profile"); - cy.get("#notification-text").should( + cy.get("#notification").should( "have.text", "Profile information updated successfully." ); // * Reset to normal data (Cleanup) cy.get("#edit").click(); - cy.get("#notification-text").should("not.exist"); + cy.get("#notification").should("not.be.visible"); cy.get("#edit-name").clear(); cy.get("#edit-name").type("Bob Test"); cy.get("#edit-email").clear(); cy.get("#edit-email").type("bobtest@email.com"); cy.get("#edit-confirm").click(); - cy.wait(1500); + cy.wait(500); cy.get("h1").should("have.text", "Profile"); cy.get("#name").should("have.value", "Bob Test"); cy.get("#email").should("have.value", "bobtest@email.com"); @@ -74,7 +74,7 @@ context("Profile", () => { cy.get("#cancel").click(); cy.get("h1").should("have.text", "Profile"); - cy.get("#notification-text").should("not.exist"); + cy.get("#notification").should("not.be.visible"); cy.get("#name").should("have.value", "Bob Test"); cy.get("#email").should("have.value", "bobtest@email.com"); }); @@ -101,10 +101,10 @@ context("Profile", () => { cy.get("#edit-email").type("bobtest2@email.com"); cy.get("#edit-confirm").click(); - cy.wait(1500); + cy.wait(500); cy.get("h1").should("have.text", "Edit Profile"); - cy.get("#notification-text").should("have.text", "Invalid email."); + cy.get("#notification").should("have.text", "Invalid email."); cy.get("#cancel").click(); }); @@ -117,13 +117,10 @@ context("Profile", () => { cy.get("#confirm-new-password").type("Blue123456!!!"); cy.get("#reset").click(); - cy.wait(1500); + cy.wait(500); cy.get("h1").should("have.text", "Profile"); - cy.get("#notification-text").should( - "have.text", - "Password reset successfully." - ); + cy.get("#notification").should("have.text", "Password reset successfully."); // * Reset to normal data (Cleanup) cy.get("#reset-password").click(); @@ -133,10 +130,7 @@ context("Profile", () => { cy.get("#confirm-new-password").type("Red123456!!!"); cy.get("#reset").click(); cy.get("h1").should("have.text", "Profile"); - cy.get("#notification-text").should( - "have.text", - "Password reset successfully." - ); + cy.get("#notification").should("have.text", "Password reset successfully."); }); it("Should not allow to submit reset password form if information is not changed", () => { @@ -162,10 +156,10 @@ context("Profile", () => { cy.get("#confirm-new-password").type("Red123456!!!"); cy.get("#reset").click(); - cy.wait(1500); + cy.wait(500); cy.get("h1").should("have.text", "Reset Password"); - cy.get("#notification-text").should("have.text", "Invalid credentials."); + cy.get("#notification").should("have.text", "Invalid credentials."); }); it("Should not be able to reset password if passwords do not match", () => { @@ -176,10 +170,10 @@ context("Profile", () => { cy.get("#confirm-new-password").type("Yellow123456!!!"); cy.get("#reset").click(); - cy.wait(1500); + cy.wait(500); cy.get("h1").should("have.text", "Reset Password"); - cy.get("#notification-text").should("have.text", "Passwords do not match."); + cy.get("#notification").should("have.text", "Passwords do not match."); }); it("Should be able to log out on this device", () => { @@ -196,7 +190,7 @@ context("Profile", () => { it("Should be able to delete a user profile", () => { cy.get("#log-out").click(); - cy.wait(1500); + cy.wait(500); cy.get("#nav-login").should("exist"); cy.get("#search").should("be.visible"); cy.get("#nav-login").click(); @@ -209,7 +203,7 @@ context("Profile", () => { cy.get("#password").type("Red123456!!!"); cy.get("#confirm-password").type("Red123456!!!"); cy.get("#signup").click(); - cy.wait(1500); + cy.wait(500); cy.get("#nav-login").should("not.exist"); cy.get("#search").should("be.visible"); cy.get("#nav-profile").click(); @@ -217,19 +211,19 @@ context("Profile", () => { cy.get("#delete-profile").click(); cy.get("h1").should("have.text", "Delete Profile"); - cy.get("#notification-text").should( + cy.get("#notification").should( "have.text", "Are you sure you would like to delete your profile? This can not be reversed." ); cy.get("#delete-profile-confirm").click(); - cy.wait(1500); + cy.wait(500); cy.get("#nav-login").should("exist"); cy.get("#search").should("be.visible"); }); it("Should be able to cancel deleting a user profile", () => { cy.get("#log-out").click(); - cy.wait(1500); + cy.wait(500); cy.get("#nav-login").should("exist"); cy.get("#search").should("be.visible"); cy.get("#nav-login").click(); @@ -242,7 +236,7 @@ context("Profile", () => { cy.get("#password").type("Red123456!!!"); cy.get("#confirm-password").type("Red123456!!!"); cy.get("#signup").click(); - cy.wait(1500); + cy.wait(500); cy.get("#nav-login").should("not.exist"); cy.get("#search").should("be.visible"); cy.get("#nav-profile").click(); @@ -250,7 +244,7 @@ context("Profile", () => { cy.get("#delete-profile").click(); cy.get("h1").should("have.text", "Delete Profile"); - cy.get("#notification-text").should( + cy.get("#notification").should( "have.text", "Are you sure you would like to delete your profile? This can not be reversed." ); @@ -262,12 +256,12 @@ context("Profile", () => { // * Cleanup cy.get("#delete-profile").click(); cy.get("h1").should("have.text", "Delete Profile"); - cy.get("#notification-text").should( + cy.get("#notification").should( "have.text", "Are you sure you would like to delete your profile? This can not be reversed." ); cy.get("#delete-profile-confirm").click(); - cy.wait(1500); + cy.wait(500); cy.get("#nav-login").should("exist"); cy.get("#search").should("be.visible"); }); diff --git a/cypress/integration/savedJobs.spec.js b/cypress/integration/savedJobs.spec.js index 8a26182..d45a59e 100644 --- a/cypress/integration/savedJobs.spec.js +++ b/cypress/integration/savedJobs.spec.js @@ -13,71 +13,64 @@ context("Saved Jobs", () => { }); }); cy.visit("http://localhost:3000"); - cy.wait(1000); + cy.wait(500); cy.get("#nav-login").click(); cy.get("h1").should("have.text", "Login"); cy.get("#email").type("bobtest@email.com"); cy.get("#password").type("Red123456!!!"); cy.get("#log-in").click(); - cy.wait(1500); + cy.wait(500); cy.get("#nav-login").should("not.exist"); cy.get("#search").should("be.visible"); }); it("Should be able to save a job from the 'Search' page", () => { - cy.get("#save-job-f1884b46-ecb4-473c-81f5-08d9bf2ab3bb").should( - "have.class", - "jobcard__save__deselected" - ); + cy.get("#save-job-f1884b46-ecb4-473c-81f5-08d9bf2ab3bb") + .its("data") + .should("be", '{ cy: "deselected" }'); cy.get("#save-job-f1884b46-ecb4-473c-81f5-08d9bf2ab3bb").click(); - cy.get("#remove-job-f1884b46-ecb4-473c-81f5-08d9bf2ab3bb").should( - "have.class", - "jobcard__save__selected" - ); - cy.get("#notification-text").should("have.text", "Job saved successfully."); + cy.get("#remove-job-f1884b46-ecb4-473c-81f5-08d9bf2ab3bb") + .its("data") + .should("be", "{ cy: 'selected' }"); + cy.get("#notification").should("have.text", "Job saved successfully."); + cy.get("#notification > button").click(); // * Cleanup cy.get("#remove-job-f1884b46-ecb4-473c-81f5-08d9bf2ab3bb").click(); - cy.get("#notification-text").should( - "have.text", - "Job removed successfully." - ); + cy.get("#notification").should("have.text", "Job removed successfully."); + cy.get("#notification > button").click(); }); it("Should be able to save a job from the 'Details' page", () => { cy.get("#f1884b46-ecb4-473c-81f5-08d9bf2ab3bb").click({ force: true }); - cy.get("#save-job-f1884b46-ecb4-473c-81f5-08d9bf2ab3bb").should( - "have.class", - "details__save__deselected" - ); + cy.get("#save-job-f1884b46-ecb4-473c-81f5-08d9bf2ab3bb") + .its("data") + .should("be", "{cy: 'deselected'}"); cy.get("#save-job-f1884b46-ecb4-473c-81f5-08d9bf2ab3bb").click(); - cy.get("#remove-job-f1884b46-ecb4-473c-81f5-08d9bf2ab3bb").should( - "have.class", - "details__save__selected" - ); - cy.get("#notification-text").should("have.text", "Job saved successfully."); + cy.get("#remove-job-f1884b46-ecb4-473c-81f5-08d9bf2ab3bb") + .its("data") + .should("be", "{ cy: 'selected' }"); + cy.get("#notification").should("have.text", "Job saved successfully."); + cy.get("#notification > button").click(); // * Cleanup cy.get("#remove-job-f1884b46-ecb4-473c-81f5-08d9bf2ab3bb").click(); - cy.get("#notification-text").should( - "have.text", - "Job removed successfully." - ); + cy.get("#notification").should("have.text", "Job removed successfully."); + cy.get("#notification > button").click(); }); it("Should be able to view list of saved jobs", () => { - cy.get("#save-job-f1884b46-ecb4-473c-81f5-08d9bf2ab3bb").should( - "have.class", - "jobcard__save__deselected" - ); + cy.get("#save-job-f1884b46-ecb4-473c-81f5-08d9bf2ab3bb") + .its("data") + .should("be", "{ cy: 'deselected' }"); cy.get("#save-job-f1884b46-ecb4-473c-81f5-08d9bf2ab3bb").click(); - cy.get("#remove-job-f1884b46-ecb4-473c-81f5-08d9bf2ab3bb").should( - "have.class", - "jobcard__save__selected" - ); - cy.get("#notification-text").should("have.text", "Job saved successfully."); + cy.get("#remove-job-f1884b46-ecb4-473c-81f5-08d9bf2ab3bb") + .its("data") + .should("be", '{ cy: "selected" }'); + cy.get("#notification").should("have.text", "Job saved successfully."); + cy.get("#notification > button").click(); cy.get("#nav-profile").click(); cy.get("#view-saved-jobs").click(); @@ -87,4 +80,19 @@ context("Saved Jobs", () => { // * Cleanup cy.get("#remove-job-f1884b46-ecb4-473c-81f5-08d9bf2ab3bb").click(); }); + + it("Should be able to return to the profile display page", () => { + cy.get("#save-job-f1884b46-ecb4-473c-81f5-08d9bf2ab3bb").click(); + + cy.get("#nav-profile").click(); + cy.get("#view-saved-jobs").click(); + cy.get("h1").should("have.text", "Saved Jobs"); + + cy.get("#back-to-profile").click(); + cy.get("h1").should("have.text", "Profile"); + + // * Cleanup + cy.get("#view-saved-jobs").click(); + cy.get("#remove-job-f1884b46-ecb4-473c-81f5-08d9bf2ab3bb").click(); + }); }); diff --git a/cypress/integration/search.spec.js b/cypress/integration/search.spec.js index cb00e7d..4b63222 100644 --- a/cypress/integration/search.spec.js +++ b/cypress/integration/search.spec.js @@ -25,13 +25,13 @@ context("Search", () => { }); it("Should search correctly", () => { - cy.get(".jobcard__container").then(($jobs) => { + cy.get('[data-cy="job-container"]').then(($jobs) => { assert.equal($jobs.length, 5); }); cy.get("#search").type("developer"); cy.get("#search-submit").click(); cy.wait(1000); - cy.get(".jobcard__container").then(($jobs) => { + cy.get('[data-cy="job-container"]').then(($jobs) => { assert.equal($jobs.length, 5); }); }); @@ -45,10 +45,10 @@ context("Search", () => { }); it("Should be able to submit form with enter key", () => { - cy.get(".orbit-spinner").should("not.be.visible"); + cy.get('[data-cy="orbit-container"]').should("not.be.visible"); cy.get("#search").type("developer"); cy.get("#search").type("{enter}"); - cy.get(".orbit-spinner").should("be.visible"); + cy.get('[data-cy="orbit-container"]').should("be.visible"); }); }); diff --git a/cypress/integration/signup.spec.js b/cypress/integration/signup.spec.js index 870dfc4..88de989 100644 --- a/cypress/integration/signup.spec.js +++ b/cypress/integration/signup.spec.js @@ -22,7 +22,7 @@ context("Signup - Success", () => { }); }); cy.visit("http://localhost:3000"); - cy.wait(1000); + cy.wait(500); cy.get("#nav-login").click(); cy.get("h1").should("have.text", "Login"); cy.get("#create-an-account").click(); @@ -36,7 +36,7 @@ context("Signup - Success", () => { cy.get("#password").type("Red123456!!!"); cy.get("#confirm-password").type("Red123456!!!"); cy.get("#signup").click(); - cy.wait(1500); + cy.wait(500); cy.get("#nav-login").should("not.exist"); cy.get("#search").should("be.visible"); @@ -61,7 +61,7 @@ context("Signup - Error", () => { }); }); cy.visit("http://localhost:3000"); - cy.wait(1000); + cy.wait(500); cy.get("#nav-login").click(); cy.get("h1").should("have.text", "Login"); cy.get("#create-an-account").click(); @@ -75,9 +75,9 @@ context("Signup - Error", () => { cy.get("#password").type("Red123456!!!"); cy.get("#confirm-password").type("Red123456!!!"); cy.get("#signup").click(); - cy.wait(1000); + cy.wait(500); - cy.get("#notification-text").should( + cy.get("#notification").should( "have.text", "A user with that email address already exists. Please try logging in instead." ); @@ -107,7 +107,7 @@ context("Signup - Error", () => { cy.get("#confirm-password").type("Red123456!"); cy.get("#signup").click(); - cy.get("#notification-text").should("have.text", "Email is invalid."); + cy.get("#notification").should("have.text", "Email is invalid."); }); it("Should not allow password to be less than 7 characters long", () => { @@ -117,7 +117,7 @@ context("Signup - Error", () => { cy.get("#confirm-password").type("Red1!"); cy.get("#signup").click(); - cy.get("#notification-text").should( + cy.get("#notification").should( "have.text", "Password must be a minimum of 7 characters." ); @@ -130,7 +130,7 @@ context("Signup - Error", () => { cy.get("#confirm-password").type("Redpassword123!"); cy.get("#signup").click(); - cy.get("#notification-text").should( + cy.get("#notification").should( "have.text", `Password can't contain the string "password".` ); @@ -143,7 +143,7 @@ context("Signup - Error", () => { cy.get("#confirm-password").type("red123456!"); cy.get("#signup").click(); - cy.get("#notification-text").should( + cy.get("#notification").should( "have.text", `Password should contain at least 1 uppercase letter.` ); @@ -156,7 +156,7 @@ context("Signup - Error", () => { cy.get("#confirm-password").type("RED123456!"); cy.get("#signup").click(); - cy.get("#notification-text").should( + cy.get("#notification").should( "have.text", `Password should contain at least 1 lowercase letter.` ); @@ -169,7 +169,7 @@ context("Signup - Error", () => { cy.get("#confirm-password").type("RedRedRed!"); cy.get("#signup").click(); - cy.get("#notification-text").should( + cy.get("#notification").should( "have.text", "Password should contain at least 1 number." ); @@ -182,7 +182,7 @@ context("Signup - Error", () => { cy.get("#confirm-password").type("Red123456"); cy.get("#signup").click(); - cy.get("#notification-text").should( + cy.get("#notification").should( "have.text", "Password should contain at least 1 special character." ); @@ -195,6 +195,6 @@ context("Signup - Error", () => { cy.get("#confirm-password").type("Blue123456!!!"); cy.get("#signup").click(); - cy.get("#notification-text").should("have.text", "Passwords do not match."); + cy.get("#notification").should("have.text", "Passwords do not match."); }); }); diff --git a/cypress/plugins/index.js b/cypress/plugins/index.js index aa9918d..191e5d5 100644 --- a/cypress/plugins/index.js +++ b/cypress/plugins/index.js @@ -18,4 +18,5 @@ module.exports = (on, config) => { // `on` is used to hook into various events Cypress emits // `config` is the resolved Cypress config -} + return config; +}; diff --git a/cypress/support/commands.js b/cypress/support/commands.js index 6d21de1..0afdfea 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -23,6 +23,7 @@ // // -- This will overwrite an existing command -- // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) + Cypress.Commands.add("paginationSelect1", (childList) => { // * Should contain 7 elements assert.equal(childList.length, 7); @@ -33,7 +34,7 @@ Cypress.Commands.add("paginationSelect1", (childList) => { // * 2nd Button should be "1" assert.equal(childList[1].innerText, "1"); // * 2nd Button as "1" should be selected by default - assert.equal(childList[1].className, "pagination__item__selected"); + assert.equal(childList[1].dataset.cy, "pagination-item-selected"); // * 3rd Button should be "2" assert.equal(childList[2].innerText, "2"); // * 4th Button should be "3" @@ -56,11 +57,11 @@ Cypress.Commands.add("paginationSelect2", (childList) => { // * 2nd Button should be "1" assert.equal(childList[1].innerText, "1"); // * 2nd Button as "1" should be deselected - assert.equal(childList[1].className, "pagination__item"); + assert.equal(childList[1].dataset.cy, "pagination-item-deselected"); // * 3rd Button should be "2" assert.equal(childList[2].innerText, "2"); // * 3rd Button should be selected - assert.equal(childList[2].className, "pagination__item__selected"); + assert.equal(childList[2].dataset.cy, "pagination-item-selected"); // * 4th Button should be "3" assert.equal(childList[3].innerText, "3"); // * 5th Button should be More Icon @@ -81,15 +82,15 @@ Cypress.Commands.add("paginationSelect3", (childList) => { // * 2nd Button should be "1" assert.equal(childList[1].innerText, "1"); // * 2nd Button as "1" should be deselected - assert.equal(childList[1].className, "pagination__item"); + assert.equal(childList[1].dataset.cy, "pagination-item-deselected"); // * 3rd Button should be "2" assert.equal(childList[2].innerText, "2"); // * 3rd Button should be deselected - assert.equal(childList[2].className, "pagination__item"); + assert.equal(childList[2].dataset.cy, "pagination-item-deselected"); // * 4th Button should be "3" assert.equal(childList[3].innerText, "3"); // * 4th button should be selected - assert.equal(childList[3].className, "pagination__item__selected"); + assert.equal(childList[3].dataset.cy, "pagination-item-selected"); // * 5th Button should be "4" assert.equal(childList[4].innerText, "4"); // * 6th Button should be More Icon @@ -110,7 +111,7 @@ Cypress.Commands.add("paginationSelect4", (childList) => { // * 2nd Button should be "1" assert.equal(childList[1].innerText, "1"); // * 2nd Button as "1" should be deselected - assert.equal(childList[1].className, "pagination__item"); + assert.equal(childList[1].dataset.cy, "pagination-item-deselected"); // * 3rd Button should be 'More' assert.equal(childList[2].innerText, "more_horiz"); // * 4th Button should be "3" @@ -118,7 +119,7 @@ Cypress.Commands.add("paginationSelect4", (childList) => { // * 5th Button should be "4" assert.equal(childList[4].innerText, "4"); // * 5th button should be selected - assert.equal(childList[4].className, "pagination__item__selected"); + assert.equal(childList[4].dataset.cy, "pagination-item-selected"); // * 6th Button should be "5" assert.equal(childList[5].innerText, "5"); // * 7th Button should be More Icon @@ -139,7 +140,7 @@ Cypress.Commands.add("paginationSelect5", (childList) => { // * 2nd Button should be "1" assert.equal(childList[1].innerText, "1"); // * 2nd Button as "1" should be deselected - assert.equal(childList[1].className, "pagination__item"); + assert.equal(childList[1].dataset.cy, "pagination-item-deselected"); // * 3rd Button should be 'More' assert.equal(childList[2].innerText, "more_horiz"); // * 4th Button should be "4" @@ -147,7 +148,7 @@ Cypress.Commands.add("paginationSelect5", (childList) => { // * 5th Button should be "5" assert.equal(childList[4].innerText, "5"); // * 5th button should be selected - assert.equal(childList[4].className, "pagination__item__selected"); + assert.equal(childList[4].dataset.cy, "pagination-item-selected"); // * 6th Button should be "6" assert.equal(childList[5].innerText, "6"); // * 7th Button should be More Icon @@ -168,7 +169,7 @@ Cypress.Commands.add("paginationSelect6", (childList) => { // * 2nd Button should be "1" assert.equal(childList[1].innerText, "1"); // * 2nd Button as "1" should be deselected - assert.equal(childList[1].className, "pagination__item"); + assert.equal(childList[1].dataset.cy, "pagination-item-deselected"); // * 3rd Button should be 'More' assert.equal(childList[2].innerText, "more_horiz"); // * 4th Button should be "5" @@ -176,7 +177,7 @@ Cypress.Commands.add("paginationSelect6", (childList) => { // * 5th Button should be "6" assert.equal(childList[4].innerText, "6"); // * 5th button should be selected - assert.equal(childList[4].className, "pagination__item__selected"); + assert.equal(childList[4].dataset.cy, "pagination-item-selected"); // * 6th Button should be "7" assert.equal(childList[5].innerText, "7"); // * 7th Button should be More Icon @@ -197,7 +198,7 @@ Cypress.Commands.add("paginationSelect7", (childList) => { // * 2nd Button should be "1" assert.equal(childList[1].innerText, "1"); // * 2nd Button as "1" should be deselected - assert.equal(childList[1].className, "pagination__item"); + assert.equal(childList[1].dataset.cy, "pagination-item-deselected"); // * 3rd Button should be 'More' assert.equal(childList[2].innerText, "more_horiz"); // * 4th Button should be "6" @@ -205,7 +206,7 @@ Cypress.Commands.add("paginationSelect7", (childList) => { // * 5th Button should be "7" assert.equal(childList[4].innerText, "7"); // * 5th button should be selected - assert.equal(childList[4].className, "pagination__item__selected"); + assert.equal(childList[4].dataset.cy, "pagination-item-selected"); // * 6th Button should be "8" assert.equal(childList[5].innerText, "8"); // * 7th Button should be More Icon @@ -226,7 +227,7 @@ Cypress.Commands.add("paginationSelect8", (childList) => { // * 2nd Button should be "1" assert.equal(childList[1].innerText, "1"); // * 2nd Button as "1" should be deselected - assert.equal(childList[1].className, "pagination__item"); + assert.equal(childList[1].dataset.cy, "pagination-item-deselected"); // * 3rd Button should be 'More' assert.equal(childList[2].innerText, "more_horiz"); // * 4th Button should be "7" @@ -234,7 +235,7 @@ Cypress.Commands.add("paginationSelect8", (childList) => { // * 5th Button should be "8" assert.equal(childList[4].innerText, "8"); // * 5th button should be selected - assert.equal(childList[4].className, "pagination__item__selected"); + assert.equal(childList[4].dataset.cy, "pagination-item-selected"); // * 6th Button should be "9" assert.equal(childList[5].innerText, "9"); // * 7th Button should be "10" @@ -253,7 +254,7 @@ Cypress.Commands.add("paginationSelect9", (childList) => { // * 2nd Button should be "1" assert.equal(childList[1].innerText, "1"); // * 2nd Button as "1" should be deselected - assert.equal(childList[1].className, "pagination__item"); + assert.equal(childList[1].dataset.cy, "pagination-item-deselected"); // * 3rd Button should be 'More' assert.equal(childList[2].innerText, "more_horiz"); // * 4th Button should be "8" @@ -261,7 +262,7 @@ Cypress.Commands.add("paginationSelect9", (childList) => { // * 5th Button should be "9" assert.equal(childList[4].innerText, "9"); // * 5th button should be selected - assert.equal(childList[4].className, "pagination__item__selected"); + assert.equal(childList[4].dataset.cy, "pagination-item-selected"); // * 6th Button should be "10" assert.equal(childList[5].innerText, "10"); // * 7th Button should be Right Arrow @@ -278,7 +279,7 @@ Cypress.Commands.add("paginationSelect10", (childList) => { // * 2nd Button should be "1" assert.equal(childList[1].innerText, "1"); // * 2nd Button as "1" should be deselected - assert.equal(childList[1].className, "pagination__item"); + assert.equal(childList[1].dataset.cy, "pagination-item-deselected"); // * 3rd Button should be 'More' assert.equal(childList[2].innerText, "more_horiz"); // * 4th Button should be "9" @@ -286,7 +287,7 @@ Cypress.Commands.add("paginationSelect10", (childList) => { // * 5th Button should be "10" assert.equal(childList[4].innerText, "10"); // * 5th button should be selected - assert.equal(childList[4].className, "pagination__item__selected"); + assert.equal(childList[4].dataset.cy, "pagination-item-selected"); // * 6th Button should be Right Arrow assert.equal(childList[5].innerText, "chevron_right"); // * 6th button should be disabled diff --git a/package-lock.json b/package-lock.json index 8e41006..f28dfb6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "gh-jobs", - "version": "1.1.1", + "version": "1.2.0", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -8,22 +8,71 @@ "version": "7.10.4", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz", "integrity": "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==", - "dev": true, "requires": { "@babel/highlight": "^7.10.4" } }, + "@babel/generator": { + "version": "7.10.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.10.5.tgz", + "integrity": "sha512-3vXxr3FEW7E7lJZiWQ3bM4+v/Vyr9C+hpolQ8BGFr9Y8Ri2tFLWTixmwKBafDujO1WVah4fhZBeU1bieKdghig==", + "requires": { + "@babel/types": "^7.10.5", + "jsesc": "^2.5.1", + "source-map": "^0.5.0" + } + }, + "@babel/helper-annotate-as-pure": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.10.4.tgz", + "integrity": "sha512-XQlqKQP4vXFB7BN8fEEerrmYvHp3fK/rBkRFz9jaJbzK0B1DSfej9Kc7ZzE8Z/OnId1jpJdNAZ3BFQjWG68rcA==", + "requires": { + "@babel/types": "^7.10.4" + } + }, + "@babel/helper-function-name": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.10.4.tgz", + "integrity": "sha512-YdaSyz1n8gY44EmN7x44zBn9zQ1Ry2Y+3GTA+3vH6Mizke1Vw0aWDM66FOYEPw8//qKkmqOckrGgTYa+6sceqQ==", + "requires": { + "@babel/helper-get-function-arity": "^7.10.4", + "@babel/template": "^7.10.4", + "@babel/types": "^7.10.4" + } + }, + "@babel/helper-get-function-arity": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.10.4.tgz", + "integrity": "sha512-EkN3YDB+SRDgiIUnNgcmiD361ti+AVbL3f3Henf6dqqUyr5dMsorno0lJWJuLhDhkI5sYEpgj6y9kB8AOU1I2A==", + "requires": { + "@babel/types": "^7.10.4" + } + }, + "@babel/helper-module-imports": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.10.4.tgz", + "integrity": "sha512-nEQJHqYavI217oD9+s5MUBzk6x1IlvoS9WTPfgG43CbMEeStE0v+r+TucWdx8KFGowPGvyOkDT9+7DHedIDnVw==", + "requires": { + "@babel/types": "^7.10.4" + } + }, + "@babel/helper-split-export-declaration": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.10.4.tgz", + "integrity": "sha512-pySBTeoUff56fL5CBU2hWm9TesA4r/rOkI9DyJLvvgz09MB9YtfIYe3iBriVaYNaPe+Alua0vBIOVOLs2buWhg==", + "requires": { + "@babel/types": "^7.10.4" + } + }, "@babel/helper-validator-identifier": { "version": "7.10.4", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.10.4.tgz", - "integrity": "sha512-3U9y+43hz7ZM+rzG24Qe2mufW5KhvFg/NhnNph+i9mgCtdTCtMJuI1TMkrIUiK7Ix4PYlRF9I5dhqaLYA/ADXw==", - "dev": true + "integrity": "sha512-3U9y+43hz7ZM+rzG24Qe2mufW5KhvFg/NhnNph+i9mgCtdTCtMJuI1TMkrIUiK7Ix4PYlRF9I5dhqaLYA/ADXw==" }, "@babel/highlight": { "version": "7.10.4", "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.10.4.tgz", "integrity": "sha512-i6rgnR/YgPEQzZZnbTHHuZdlE8qyoBNalD6F+q4vAFlcMEcqmkoG+mPqJYJCo63qPf74+Y1UZsl3l6f7/RIkmA==", - "dev": true, "requires": { "@babel/helper-validator-identifier": "^7.10.4", "chalk": "^2.0.0", @@ -34,7 +83,6 @@ "version": "3.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, "requires": { "color-convert": "^1.9.0" } @@ -43,7 +91,6 @@ "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dev": true, "requires": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", @@ -54,7 +101,6 @@ "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dev": true, "requires": { "color-name": "1.1.3" } @@ -62,26 +108,28 @@ "color-name": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", - "dev": true + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=" }, "has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", - "dev": true + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=" }, "supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, "requires": { "has-flag": "^3.0.0" } } } }, + "@babel/parser": { + "version": "7.10.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.10.5.tgz", + "integrity": "sha512-wfryxy4bE1UivvQKSQDU4/X6dr+i8bctjUjj8Zyt3DQy7NtPizJXT8M52nqpNKL+nq2PW8lxk4ZqLj0fD4B4hQ==" + }, "@babel/runtime": { "version": "7.10.4", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.10.4.tgz", @@ -90,6 +138,62 @@ "regenerator-runtime": "^0.13.4" } }, + "@babel/template": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.10.4.tgz", + "integrity": "sha512-ZCjD27cGJFUB6nmCB1Enki3r+L5kJveX9pq1SvAUKoICy6CZ9yD8xO086YXdYhvNjBdnekm4ZnaP5yC8Cs/1tA==", + "requires": { + "@babel/code-frame": "^7.10.4", + "@babel/parser": "^7.10.4", + "@babel/types": "^7.10.4" + } + }, + "@babel/traverse": { + "version": "7.10.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.10.5.tgz", + "integrity": "sha512-yc/fyv2gUjPqzTz0WHeRJH2pv7jA9kA7mBX2tXl/x5iOE81uaVPuGPtaYk7wmkx4b67mQ7NqI8rmT2pF47KYKQ==", + "requires": { + "@babel/code-frame": "^7.10.4", + "@babel/generator": "^7.10.5", + "@babel/helper-function-name": "^7.10.4", + "@babel/helper-split-export-declaration": "^7.10.4", + "@babel/parser": "^7.10.5", + "@babel/types": "^7.10.5", + "debug": "^4.1.0", + "globals": "^11.1.0", + "lodash": "^4.17.19" + }, + "dependencies": { + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "requires": { + "ms": "^2.1.1" + } + }, + "globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==" + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + } + } + }, + "@babel/types": { + "version": "7.10.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.10.5.tgz", + "integrity": "sha512-ixV66KWfCI6GKoA/2H9v6bQdbfXEwwpOdQ8cRvb4F+eyvhlaHxWFMQB4+3d9QFJXZsiiiqVrewNV0DFEQpyT4Q==", + "requires": { + "@babel/helper-validator-identifier": "^7.10.4", + "lodash": "^4.17.19", + "to-fast-properties": "^2.0.0" + } + }, "@cypress/listr-verbose-renderer": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@cypress/listr-verbose-renderer/-/listr-verbose-renderer-0.4.1.tgz", @@ -224,6 +328,29 @@ } } }, + "@emotion/is-prop-valid": { + "version": "0.8.8", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz", + "integrity": "sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA==", + "requires": { + "@emotion/memoize": "0.7.4" + } + }, + "@emotion/memoize": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.4.tgz", + "integrity": "sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==" + }, + "@emotion/stylis": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/@emotion/stylis/-/stylis-0.8.5.tgz", + "integrity": "sha512-h6KtPihKFn3T9fuIrwvXXUOwlx3rfUvfZIcP5a6rh8Y7zjE3O06hT5Ss4S/YI1AYhuZ1kjaE/5EaOOI2NqSylQ==" + }, + "@emotion/unitless": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz", + "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==" + }, "@hapi/address": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/@hapi/address/-/address-4.1.0.tgz", @@ -603,6 +730,15 @@ "@types/react": "*" } }, + "@types/react-native": { + "version": "0.63.2", + "resolved": "https://registry.npmjs.org/@types/react-native/-/react-native-0.63.2.tgz", + "integrity": "sha512-oxbp084lUsZvwfdWmWxKjJAuqEraQDRf+cE/JgwmrHQMguSrmgIHZ3xkeoQ5FYnW5NHIPpHudB3BbjL1Zn3vnA==", + "dev": true, + "requires": { + "@types/react": "*" + } + }, "@types/react-redux": { "version": "7.1.9", "resolved": "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.9.tgz", @@ -664,6 +800,18 @@ "integrity": "sha512-K5K+yml8LTo9bWJI/rECfIPrGgxdpeNbj+d53lwN4QjW1MCwlkhUms+gtdzigTeUyBr09+u8BwOIY3MXvHdcsA==", "dev": true }, + "@types/styled-components": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@types/styled-components/-/styled-components-5.1.1.tgz", + "integrity": "sha512-fIjKvDU1LJExBZWEQilHqzfpOK4KUwBsj5zC79lxa94ekz8oDQSBNcayMACBImxIuevF+NbBGL9O/2CQ67Zhig==", + "dev": true, + "requires": { + "@types/hoist-non-react-statics": "*", + "@types/react": "*", + "@types/react-native": "*", + "csstype": "^2.2.0" + } + }, "@types/tapable": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/tapable/-/tapable-1.0.6.tgz", @@ -1374,6 +1522,22 @@ "follow-redirects": "1.5.10" } }, + "babel-plugin-styled-components": { + "version": "1.10.7", + "resolved": "https://registry.npmjs.org/babel-plugin-styled-components/-/babel-plugin-styled-components-1.10.7.tgz", + "integrity": "sha512-MBMHGcIA22996n9hZRf/UJLVVgkEOITuR2SvjHLb5dSTUyR4ZRGn+ngITapes36FI3WLxZHfRhkA1ffHxihOrg==", + "requires": { + "@babel/helper-annotate-as-pure": "^7.0.0", + "@babel/helper-module-imports": "^7.0.0", + "babel-plugin-syntax-jsx": "^6.18.0", + "lodash": "^4.17.11" + } + }, + "babel-plugin-syntax-jsx": { + "version": "6.18.0", + "resolved": "https://registry.npmjs.org/babel-plugin-syntax-jsx/-/babel-plugin-syntax-jsx-6.18.0.tgz", + "integrity": "sha1-CvMqmm4Tyno/1QaeYtew9Y0NiUY=" + }, "balanced-match": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", @@ -1842,6 +2006,11 @@ "tslib": "^1.10.0" } }, + "camelize": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.0.tgz", + "integrity": "sha1-FkpUg+Yw+kMh5a8HAg5TGDGyYJs=" + }, "caseless": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", @@ -1937,6 +2106,11 @@ } } }, + "classnames": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.2.6.tgz", + "integrity": "sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q==" + }, "clean-css": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-4.2.3.tgz", @@ -2386,6 +2560,11 @@ "randomfill": "^1.0.3" } }, + "css-color-keywords": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz", + "integrity": "sha1-/qJhbcZ2spYmhrOvjb2+GAskTgU=" + }, "css-loader": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-3.6.0.tgz", @@ -2441,6 +2620,16 @@ } } }, + "css-to-react-native": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.0.0.tgz", + "integrity": "sha512-Ro1yETZA813eoyUp2GDBhG2j+YggidUmzO1/v9eYBKR2EHVEniE2MI/NqpTQ954BMpTPZFsGNPm46qFB9dpaPQ==", + "requires": { + "camelize": "^1.0.0", + "css-color-keywords": "^1.0.0", + "postcss-value-parser": "^4.0.2" + } + }, "cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -2450,8 +2639,7 @@ "csstype": { "version": "2.6.11", "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.11.tgz", - "integrity": "sha512-l8YyEC9NBkSm783PFTvh0FmJy7s5pFKrDp49ZL7zBGX3fWkO+N4EEyan1qqp8cwPLDcD0OSdyY6hAMoxp34JFw==", - "dev": true + "integrity": "sha512-l8YyEC9NBkSm783PFTvh0FmJy7s5pFKrDp49ZL7zBGX3fWkO+N4EEyan1qqp8cwPLDcD0OSdyY6hAMoxp34JFw==" }, "cyclist": { "version": "1.0.1", @@ -3018,6 +3206,15 @@ "utila": "~0.4" } }, + "dom-helpers": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.1.4.tgz", + "integrity": "sha512-TjMyeVUvNEnOnhzs6uAn9Ya47GmMo3qq7m+Lr/3ON0Rs5kHvb8I+SQYjLUSYn7qhEm0QjW0yrBkvz9yOrwwz1A==", + "requires": { + "@babel/runtime": "^7.8.7", + "csstype": "^2.6.7" + } + }, "dom-serializer": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.2.2.tgz", @@ -5071,6 +5268,11 @@ "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=", "dev": true }, + "jsesc": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", + "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==" + }, "json-parse-better-errors": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", @@ -5464,8 +5666,7 @@ "lodash": { "version": "4.17.19", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.19.tgz", - "integrity": "sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ==", - "dev": true + "integrity": "sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ==" }, "lodash.includes": { "version": "4.3.0", @@ -6869,8 +7070,7 @@ "postcss-value-parser": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.1.0.tgz", - "integrity": "sha512-97DXOFbQJhk71ne5/Mt6cOu6yxsSfM0QGQyl0L25Gca4yGWEGJaig7l7gbCX623VqTBNGLRLaVUCnNkcedlRSQ==", - "dev": true + "integrity": "sha512-97DXOFbQJhk71ne5/Mt6cOu6yxsSfM0QGQyl0L25Gca4yGWEGJaig7l7gbCX623VqTBNGLRLaVUCnNkcedlRSQ==" }, "prelude-ls": { "version": "1.2.1", @@ -7172,6 +7372,27 @@ "tiny-warning": "^1.0.0" } }, + "react-toastify": { + "version": "6.0.8", + "resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-6.0.8.tgz", + "integrity": "sha512-NSqCNwv+C4IfR+c92PFZiNyeBwOJvigrP2bcRi2f6Hg3WqcHhEHOknbSQOs9QDFuqUjmK3SOrdvScQ3z63ifXg==", + "requires": { + "classnames": "^2.2.6", + "prop-types": "^15.7.2", + "react-transition-group": "^4.4.1" + } + }, + "react-transition-group": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.1.tgz", + "integrity": "sha512-Djqr7OQ2aPUiYurhPalTrVy9ddmFCCzwhqQmtN+J3+3DzLO209Fdr70QrN8Z3DsglWql6iY1lDWAfpFiBtuKGw==", + "requires": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + } + }, "readable-stream": { "version": "2.3.7", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", @@ -7697,6 +7918,11 @@ "safe-buffer": "^5.0.1" } }, + "shallowequal": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", + "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==" + }, "shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -7911,8 +8137,7 @@ "source-map": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", - "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", - "dev": true + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=" }, "source-map-loader": { "version": "1.0.1", @@ -8314,6 +8539,38 @@ "schema-utils": "^2.6.6" } }, + "styled-components": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-5.1.1.tgz", + "integrity": "sha512-1ps8ZAYu2Husx+Vz8D+MvXwEwvMwFv+hqqUwhNlDN5ybg6A+3xyW1ECrAgywhvXapNfXiz79jJyU0x22z0FFTg==", + "requires": { + "@babel/helper-module-imports": "^7.0.0", + "@babel/traverse": "^7.4.5", + "@emotion/is-prop-valid": "^0.8.8", + "@emotion/stylis": "^0.8.4", + "@emotion/unitless": "^0.7.4", + "babel-plugin-styled-components": ">= 1", + "css-to-react-native": "^3.0.0", + "hoist-non-react-statics": "^3.0.0", + "shallowequal": "^1.1.0", + "supports-color": "^5.5.0" + }, + "dependencies": { + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=" + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "requires": { + "has-flag": "^3.0.0" + } + } + } + }, "supports-color": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.1.0.tgz", @@ -8560,6 +8817,11 @@ "integrity": "sha1-fSKbH8xjfkZsoIEYCDanqr/4P0M=", "dev": true }, + "to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=" + }, "to-object-path": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/to-object-path/-/to-object-path-0.3.0.tgz", diff --git a/package.json b/package.json index 39a1e4f..d0ea900 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gh-jobs", - "version": "1.1.1", + "version": "1.2.0", "description": "A MERN application bootstrapped with create-mern-application.", "main": "build/index.js", "scripts": { @@ -13,6 +13,7 @@ "dev": "env-cmd -e development npm run start", "format": "prettier --write src/**/*", "lint": "eslint src", + "preinstall": "npx npm-force-resolutions", "start": "node build/index.js", "start:dev": "start-server-and-test dev http://localhost:3000 'webpack-dev-server --info=false'", "test": "start-server-and-test test-server http://localhost:3000 cy:run", @@ -39,8 +40,10 @@ "react-dom": "^16.13.1", "react-redux": "^7.2.0", "react-router-dom": "^5.2.0", + "react-toastify": "^6.0.8", "redux": "^4.0.5", "redux-thunk": "^2.3.0", + "styled-components": "^5.1.1", "validator": "^13.1.1" }, "devDependencies": { @@ -58,6 +61,7 @@ "@types/react-dom": "^16.9.8", "@types/react-redux": "^7.1.9", "@types/react-router-dom": "^5.1.5", + "@types/styled-components": "^5.1.1", "@types/validator": "^13.1.0", "@typescript-eslint/eslint-plugin": "^3.6.1", "@typescript-eslint/parser": "^3.6.1", diff --git a/src/client/App.tsx b/src/client/App.tsx index 5eecda8..41fde7c 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -1,9 +1,11 @@ import * as React from "react"; import { connect } from "react-redux"; import { BrowserRouter as Router, Switch, Route } from "react-router-dom"; +import { ToastContainer } from "react-toastify"; import Details from "./pages/Details"; import Login from "./pages/Login"; +import Profile from "./pages/Profile"; import Search from "./pages/Search"; import Signup from "./pages/Signup"; @@ -12,8 +14,6 @@ import Navigation from "./components/Navigation"; import { initializeApplication } from "./redux/thunks"; -import Profile from "./pages/Profile"; - interface AppProps { handleInitializeApplication: () => void; } @@ -50,6 +50,7 @@ const App: React.SFC = (props: AppProps) => { + ); diff --git a/src/client/components/Button/Button-styled.tsx b/src/client/components/Button/Button-styled.tsx new file mode 100644 index 0000000..1509ddb --- /dev/null +++ b/src/client/components/Button/Button-styled.tsx @@ -0,0 +1,41 @@ +import styled from "styled-components"; + +import { ButtonStyle, ButtonType } from "../../types"; + +interface StyledButtonProps { + buttonStyle: ButtonStyle; + disabled?: boolean; + id?: string; + onClick?: () => void; + type: ButtonType; +} + +const StyledButton = styled.button` + background-color: ${(props) => { + if (props.buttonStyle === "primary") { + return `rgba(27, 108, 205, 1)`; + } else if (props.buttonStyle === "secondary") { + return `#b9bdcf`; + } else if (props.buttonStyle === "danger") { + return `rgba(205, 27, 27, 1)`; + } + }}; + border: 3px solid rgba(255, 255, 255, 1); + border-bottom-right-radius: 0.25rem; + border-top-right-radius: 0.25rem; + color: #fff; + cursor: pointer; + display: inline-block; + font-weight: 400; + line-height: 1.5; + padding: 0.375rem 3rem; + position: relative; + text-align: center; + transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, + border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; + user-select: none; + vertical-align: middle; + z-index: 2; +`; + +export { StyledButton }; diff --git a/src/client/components/Button.tsx b/src/client/components/Button/Button.tsx similarity index 61% rename from src/client/components/Button.tsx rename to src/client/components/Button/Button.tsx index 97dde1f..96ffbde 100644 --- a/src/client/components/Button.tsx +++ b/src/client/components/Button/Button.tsx @@ -1,28 +1,30 @@ import * as React from "react"; -import { ButtonStyle, ButtonType } from "../types"; +import { StyledButton } from "./Button-styled"; + +import { ButtonStyle, ButtonType } from "../../types"; export interface ButtonProps { + buttonStyle: ButtonStyle; disabled?: boolean; id?: string; label: string; onClick?: () => void; - style: ButtonStyle; type: ButtonType; } const Button: React.SFC = (props: ButtonProps) => { - const { disabled, id, label, onClick, style, type } = props; + const { buttonStyle, disabled, id, label, onClick, type } = props; return ( - + ); }; diff --git a/src/client/components/Button/index.ts b/src/client/components/Button/index.ts new file mode 100644 index 0000000..c4719be --- /dev/null +++ b/src/client/components/Button/index.ts @@ -0,0 +1 @@ +export { default } from "./Button"; diff --git a/src/client/components/Checkbox/Checkbox-styled.tsx b/src/client/components/Checkbox/Checkbox-styled.tsx new file mode 100644 index 0000000..68473a7 --- /dev/null +++ b/src/client/components/Checkbox/Checkbox-styled.tsx @@ -0,0 +1,69 @@ +import styled from "styled-components"; + +interface CheckboxProps { + checked: boolean; +} + +const CheckboxCheckmark = styled.span` + background-color: ${(props) => + props.checked ? "#1e86ff" : "rgba(243, 245, 250, 1)"}; + border: ${(props) => (props.checked ? "#1e86ff" : "1px solid #b9bdcf")}; + border-radius: 2px; + box-sizing: border-box; + height: 18px; + left: 0; + position: absolute; + top: 0; + width: 18px; + + :after { + content: ""; + display: ${(props) => (props.checked ? "block" : "none")}; + position: absolute; + + border: solid white; + border-radius: 1px; + border-width: 0 1px 1px 0; + height: 9px; + left: 6px; + top: 3px; + transform: rotate(45deg); + width: 4px; + -webkit-transform: rotate(45deg); + -ms-transform: rotate(45deg); + } +`; + +const CheckboxContainer = styled.label` + display: block; + color: #334680; + cursor: pointer; + font-family: "Poppins", sans-serif; + font-size: 14px; + font-weight: 500; + line-height: 21px; + margin-bottom: 12px; + position: relative; + padding-left: 30px; + user-select: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + + input { + position: absolute; + opacity: 0; + cursor: pointer; + height: 0; + width: 0; + } + + :hover { + span { + background-color: ${(props) => + props.checked ? "undefined" : "rgba(250, 250, 250, 1)"}; + } + } +`; + +export { CheckboxContainer, CheckboxCheckmark }; diff --git a/src/client/components/Checkbox.tsx b/src/client/components/Checkbox/Checkbox.tsx similarity index 73% rename from src/client/components/Checkbox.tsx rename to src/client/components/Checkbox/Checkbox.tsx index f068659..494066f 100644 --- a/src/client/components/Checkbox.tsx +++ b/src/client/components/Checkbox/Checkbox.tsx @@ -1,5 +1,7 @@ import * as React from "react"; +import { CheckboxCheckmark, CheckboxContainer } from "./Checkbox-styled"; + export interface CheckboxProps { checked?: boolean; id?: string; @@ -12,7 +14,7 @@ export interface CheckboxProps { const Checkbox: React.SFC = (props: CheckboxProps) => { const { checked, id, label, name, onChange, value } = props; return ( - + + ); }; diff --git a/src/client/components/Checkbox/index.ts b/src/client/components/Checkbox/index.ts new file mode 100644 index 0000000..a936c85 --- /dev/null +++ b/src/client/components/Checkbox/index.ts @@ -0,0 +1 @@ +export { default } from "./Checkbox"; diff --git a/src/client/components/Copyright/Copyright-styled.tsx b/src/client/components/Copyright/Copyright-styled.tsx new file mode 100644 index 0000000..37e0b14 --- /dev/null +++ b/src/client/components/Copyright/Copyright-styled.tsx @@ -0,0 +1,25 @@ +import styled from "styled-components"; + +const StyledCopyright = styled.p` + align-items: center; + color: #b9bdcf; + display: flex; + font-size: 14px; + font-weight: lighter; + justify-content: center; + line-height: 17px; + margin-top: 50px; + width: 100%; + + a { + color: #b9bdcf; + text-decoration: none; + + :hover { + color: #8f929b; + text-decoration: underline; + } + } +`; + +export { StyledCopyright }; diff --git a/src/client/components/Copyright.tsx b/src/client/components/Copyright/Copyright.tsx similarity index 76% rename from src/client/components/Copyright.tsx rename to src/client/components/Copyright/Copyright.tsx index ec1f960..ca8afdb 100644 --- a/src/client/components/Copyright.tsx +++ b/src/client/components/Copyright/Copyright.tsx @@ -1,15 +1,17 @@ import * as React from "react"; +import { StyledCopyright } from "./Copyright-styled"; + // eslint-disable-next-line const Copyright: React.SFC<{}> = () => { return ( -

+ Copyright ©  Alex Lee  {new Date().getFullYear()} -

+ ); }; diff --git a/src/client/components/Copyright/index.ts b/src/client/components/Copyright/index.ts new file mode 100644 index 0000000..4a05b55 --- /dev/null +++ b/src/client/components/Copyright/index.ts @@ -0,0 +1 @@ +export { default } from "./Copyright"; diff --git a/src/client/components/Header/Header-styled.tsx b/src/client/components/Header/Header-styled.tsx new file mode 100644 index 0000000..c3ebddd --- /dev/null +++ b/src/client/components/Header/Header-styled.tsx @@ -0,0 +1,30 @@ +import styled from "styled-components"; + +const StyledHeader = styled.header` + color: #282538; + font-family: Poppins; + font-size: 24px; + font-style: normal; + font-weight: 200; + line-height: 36px; + margin-bottom: 25px; + margin-top: 25px; + + span { + font-weight: bold; + } + + &:hover { + span { + text-decoration: none !important; + } + } + + a:hover { + span { + text-decoration: none; + } + } +`; + +export { StyledHeader }; diff --git a/src/client/components/Header.tsx b/src/client/components/Header/Header.tsx similarity index 64% rename from src/client/components/Header.tsx rename to src/client/components/Header/Header.tsx index 0cddb96..01110b5 100644 --- a/src/client/components/Header.tsx +++ b/src/client/components/Header/Header.tsx @@ -1,13 +1,15 @@ import * as React from "react"; import { Link } from "react-router-dom"; +import { StyledHeader } from "./Header-styled"; + // eslint-disable-next-line const Header: React.SFC<{}> = () => { return ( -
- GitHub Jobs -
+ + GitHub Jobs + ); }; diff --git a/src/client/components/Header/index.ts b/src/client/components/Header/index.ts new file mode 100644 index 0000000..2764567 --- /dev/null +++ b/src/client/components/Header/index.ts @@ -0,0 +1 @@ +export { default } from "./Header"; diff --git a/src/client/components/Input/Input-styled.tsx b/src/client/components/Input/Input-styled.tsx new file mode 100644 index 0000000..ee7cf84 --- /dev/null +++ b/src/client/components/Input/Input-styled.tsx @@ -0,0 +1,89 @@ +import styled from "styled-components"; + +interface StyledInputContainerProps { + full?: boolean; +} + +const StyledInputContainer = styled.div` + display: flex; + flex-direction: column; + margin: ${(props) => (props.full ? "0" : undefined)}; + margin-bottom: 25px; + margin-top: 32px; + max-width: ${(props) => (props.full ? `100%` : `90%`)}; + width: ${(props) => (props.full ? `100%` : ``)}; + + @media only screen and (max-width: 600px) { + max-width: 100%; + } +`; + +const StyledInputInnerContainer = styled.div` + align-items: stretch; + box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.05); + display: flex; + flex-wrap: wrap; + margin-top: 14px; + width: 100%; + + input { + background-clip: padding-box; + background-color: #fff; + border: 1px solid #b9bdcf; + border-bottom-left-radius: 0; + border-top-left-radius: 0; + border-bottom-right-radius: 4px; + border-top-right-radius: 4px; + border-right: 1px solid #b9bdcf; + border-left: none; + flex: 1 1 auto; + font-size: 12px; + font-weight: 400; + height: calc(1.5em + 0.75rem + 2px); + line-height: 14px; + margin-bottom: 0; + min-width: 0; + padding: 0.375rem 0.75rem; + transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; + width: 1%; + + :focus { + outline: none; + } + } +`; + +const StyledLeftContainer = styled.div` + align-items: center; + background-color: #fff; + border: 1px solid #b9bdcf; + border-bottom-left-radius: 0.25rem; + border-right: none; + border-top-left-radius: 0.25rem; + display: flex; + margin-right: -1px; + padding: 0.375rem 0.75rem; + padding-right: 0; + text-align: center; + + i { + color: #b9bdcf; + font-size: 16px; + } +`; + +const StyledInputLabel = styled.label` + color: #b9bdcf; + font-family: "Poppins", sans-serif; + font-size: 14px; + font-weight: bold; + line-height: 21px; + text-transform: uppercase; +`; + +export { + StyledInputContainer, + StyledInputLabel, + StyledInputInnerContainer, + StyledLeftContainer, +}; diff --git a/src/client/components/Input.tsx b/src/client/components/Input/Input.tsx similarity index 64% rename from src/client/components/Input.tsx rename to src/client/components/Input/Input.tsx index dccf075..9619852 100644 --- a/src/client/components/Input.tsx +++ b/src/client/components/Input/Input.tsx @@ -1,10 +1,18 @@ import * as React from "react"; -import { InputAutoComplete, InputType } from "../types"; +import { + StyledInputContainer, + StyledInputInnerContainer, + StyledInputLabel, + StyledLeftContainer, +} from "./Input-styled"; + +import { InputAutoComplete, InputType } from "../../types"; export interface InputProps { autoComplete?: InputAutoComplete; disabled?: boolean; + full?: boolean; icon?: string; id: string; label: string; @@ -19,6 +27,7 @@ const Input: React.SFC = (props: InputProps) => { const { autoComplete, disabled, + full, icon, id, label, @@ -29,13 +38,13 @@ const Input: React.SFC = (props: InputProps) => { value, } = props; return ( -
- -
+ + {label} + {icon && ( -
- {icon} -
+ + {icon} + )} = (props: InputProps) => { type={type ? type : "text"} value={value} /> -
-
+ + ); }; diff --git a/src/client/components/Input/index.ts b/src/client/components/Input/index.ts new file mode 100644 index 0000000..a50d7d1 --- /dev/null +++ b/src/client/components/Input/index.ts @@ -0,0 +1 @@ +export { default } from "./Input"; diff --git a/src/client/components/JobCard.tsx b/src/client/components/JobCard.tsx deleted file mode 100644 index 4cc1103..0000000 --- a/src/client/components/JobCard.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import * as React from "react"; -import { connect } from "react-redux"; -import formatDistanceToNow from "date-fns/formatDistanceToNow"; -import { Link } from "react-router-dom"; - -import { addSavedJob, removeSavedJob } from "../redux/thunks"; - -import { Job, RootState } from "../types"; - -export interface JobCardProps { - handleAddSavedJob: (job: Job) => void; - handleRemoveSavedJob: (job: Job) => void; - isLoggedIn: boolean; - job: Job; - savedJobs: Job[]; -} - -const JobCard: React.SFC = (props: JobCardProps) => { - const { - handleAddSavedJob, - handleRemoveSavedJob, - isLoggedIn, - job, - savedJobs, - } = props; - const handleImageError = () => { - // TODO - Should set the image to a fallback/just display the div with the not found text - // alert("IMAGE ERROR - CREATE FUNCTIONALITY"); - }; - - const jobIsSaved = savedJobs - ? savedJobs.findIndex((savedJob: Job) => savedJob.id === job.id) >= 0 - : false; - - return ( -
-
-
- {job.company_logo ? ( - Company Logo - ) : ( -
-

not found

-
- )} -
- -
-

{job.company}

- -

{job.title}

- - {job.type === "Full Time" && ( -

Full Time

- )} -
-
- -
-
- {isLoggedIn && ( - - )} -
-
-
- public -

{job.location}

-
-
- access_time -

- {formatDistanceToNow(new Date(job.created_at), { - addSuffix: true, - })} -

-
-
-
-
- ); -}; - -const mapStateToProps = (state: RootState) => ({ - isLoggedIn: state.user.isLoggedIn, - savedJobs: state.user.savedJobs, -}); - -const mapDispatchToProps = (dispatch) => ({ - handleAddSavedJob: (job: Job) => dispatch(addSavedJob(job)), - handleRemoveSavedJob: (job: Job) => dispatch(removeSavedJob(job)), -}); - -export default connect(mapStateToProps, mapDispatchToProps)(JobCard); diff --git a/src/client/components/JobCard/JobCard-styled.tsx b/src/client/components/JobCard/JobCard-styled.tsx new file mode 100644 index 0000000..b602e49 --- /dev/null +++ b/src/client/components/JobCard/JobCard-styled.tsx @@ -0,0 +1,213 @@ +import styled from "styled-components"; + +const StyledContainer = styled.div` + background-color: #fff; + box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.05); + border-radius: 4px; + display: flex; + flex-direction: row; + justify-content: space-between; + margin-bottom: 16px; + margin-top: 16px; + padding: 15px; + + a { + text-decoration: none; + } + + @media only screen and (max-width: 600px) { + margin-bottom: 13px; + margin-top: 13px; + } +`; + +const StyledLeftContainer = styled.div` + display: flex; + width: 100%; +`; + +const StyledLogoContainer = styled.div` + background-color: #f2f2f2; + border-radius: 4px; + height: 90px; + width: 90px; + + img { + height: 90px; + object-fit: contain; + width: 90px; + } +`; + +const StyledLogoNotFoundContainer = styled.div` + align-items: center; + color: #bdbdbd; + display: flex; + font-size: 12px; + font-weight: 500; + height: 100%; + justify-content: center; + line-height: 14px; + text-align: center; + width: 100%; +`; + +const StyledMiddleContainer = styled.div` + margin-left: 16px; +`; + +const StyledCompany = styled.p` + color: #334680; + font-size: 12px; + font-weight: bold; + line-height: 14px; + margin: 0; +`; + +const StyledTitle = styled.p` + color: #334680; + font-size: 18px; + font-weight: normal; + line-height: 21px; + margin-bottom: 12px; + margin-top: 8px; +`; + +const StyledFullTime = styled.p` + border: 1px solid #334680; + border-radius: 4px; + color: #334680; + font-size: 12px; + font-weight: bold; + line-height: 14px; + padding: 6px 8px; + text-align: center; + width: 53px; +`; + +const StyledRightContainer = styled.div` + display: flex; + flex-direction: column; + justify-content: space-between; +`; + +const StyledActions = styled.div` + display: flex; + flex-direction: row; + justify-content: flex-end; +`; + +interface SavedButtonProps { + jobIsSaved: boolean; +} + +const StyledSavedButton = styled.button` + background: transparent; + border: none; + color: ${(props) => (props.jobIsSaved ? "#1e86ff" : "#b9bdcf")}; + margin: 0; + padding: 0; + + &:hover { + color: #1e86ff; + cursor: pointer; + } +`; + +const StyledInfoContainer = styled.div` + align-self: flex-end; + display: flex; + flex-direction: column; +`; + +const StyledLocationContainer = styled.div` + align-items: center; + display: flex; + justify-content: flex-end; + + i { + color: #b9bdcf; + font-size: 15px; + margin-right: 5px; + } + + p { + color: #b9bdcf; + font-size: 12px; + font-weight: 500; + line-height: 14px; + margin: 0; + max-width: 160px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + @media only screen and (max-width: 600px) { + p { + margin-bottom: 0; + margin-top: 0; + } + } + + @media only screen and (max-width: 450px) { + p { + max-width: 65px; + } + } +`; + +const StyledCreatedContainer = styled.div` + align-items: center; + display: flex; + color: #b9bdcf; + font-size: 12px; + font-weight: 500; + justify-content: flex-end; + line-height: 14px; + margin-top: 15px; + + i { + color: #b9bdcf; + font-size: 15px; + margin-right: 5px; + } + + p { + margin: 0; + max-width: 160px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + @media only screen and (max-width: 600px) { + p { + margin-bottom: 0; + margin-top: 0; + } + } + + @media only screen and (max-width: 450px) { + p { + max-width: 65px; + } + } +`; + +export { + StyledContainer, + StyledLogoContainer, + StyledLeftContainer, + StyledLogoNotFoundContainer, + StyledMiddleContainer, + StyledCompany, + StyledTitle, + StyledFullTime, + StyledRightContainer, + StyledActions, + StyledSavedButton, + StyledInfoContainer, + StyledLocationContainer, + StyledCreatedContainer, +}; diff --git a/src/client/components/JobCard/JobCard.tsx b/src/client/components/JobCard/JobCard.tsx new file mode 100644 index 0000000..c02d77c --- /dev/null +++ b/src/client/components/JobCard/JobCard.tsx @@ -0,0 +1,135 @@ +import * as React from "react"; +import { connect } from "react-redux"; +import formatDistanceToNow from "date-fns/formatDistanceToNow"; +import { Link } from "react-router-dom"; + +import { + StyledContainer, + StyledLogoContainer, + StyledLeftContainer, + StyledLogoNotFoundContainer, + StyledMiddleContainer, + StyledCompany, + StyledTitle, + StyledFullTime, + StyledRightContainer, + StyledActions, + StyledSavedButton, + StyledInfoContainer, + StyledLocationContainer, + StyledCreatedContainer, +} from "./JobCard-styled"; + +import { addSavedJob, removeSavedJob } from "../../redux/thunks"; + +import { Job, RootState } from "../../types"; +import { setJobDetails } from "../../redux/actions/application"; + +export interface JobCardProps { + handleAddSavedJob: (id: string) => void; + handleClearJobDetails: () => void; + handleRemoveSavedJob: (id: string) => void; + isLoggedIn: boolean; + job: Job; + savedJobs: string[]; +} + +const JobCard: React.SFC = (props: JobCardProps) => { + const { + handleAddSavedJob, + handleClearJobDetails, + handleRemoveSavedJob, + isLoggedIn, + job, + savedJobs, + } = props; + const handleImageError = () => { + // TODO - Should set the image to a fallback/just display the div with the not found text + // alert("IMAGE ERROR - CREATE FUNCTIONALITY"); + }; + + const jobIsSaved = savedJobs + ? savedJobs.findIndex((savedJobID: string) => savedJobID === job.id) >= 0 + : false; + + return ( + + + + {job.company_logo ? ( + Company Logo + ) : ( + +

not found

+
+ )} +
+ + + {job.company} + handleClearJobDetails()} + to={`/jobs/${job.id}`} + > + {job.title} + + {job.type === "Full Time" && ( + Full Time + )} + +
+ + + + {isLoggedIn && ( + handleRemoveSavedJob(job.id) + : () => handleAddSavedJob(job.id) + } + > + bookmark + + )} + + + + public +

{job.location}

+
+ + access_time +

+ {formatDistanceToNow(new Date(job.created_at), { + addSuffix: true, + })} +

+
+
+
+
+ ); +}; + +const mapStateToProps = (state: RootState) => ({ + isLoggedIn: state.user.isLoggedIn, + savedJobs: state.user.savedJobs, +}); + +const mapDispatchToProps = (dispatch) => ({ + handleAddSavedJob: (id: string) => dispatch(addSavedJob(id)), + handleClearJobDetails: () => dispatch(setJobDetails(null)), + handleRemoveSavedJob: (id: string) => dispatch(removeSavedJob(id)), +}); + +export default connect(mapStateToProps, mapDispatchToProps)(JobCard); diff --git a/src/client/components/JobCard/index.ts b/src/client/components/JobCard/index.ts new file mode 100644 index 0000000..6a7da0a --- /dev/null +++ b/src/client/components/JobCard/index.ts @@ -0,0 +1 @@ +export { default } from "./JobCard"; diff --git a/src/client/components/LoadingIndicator/LoadingIndicator-styled.tsx b/src/client/components/LoadingIndicator/LoadingIndicator-styled.tsx new file mode 100644 index 0000000..20ec558 --- /dev/null +++ b/src/client/components/LoadingIndicator/LoadingIndicator-styled.tsx @@ -0,0 +1,67 @@ +import styled from "styled-components"; + +interface StyledLoadingIndicatorProps { + isLoading: boolean; +} + +const LoadingIndicatorContainer = styled.div` + display: ${(props) => (props.isLoading ? "block" : "none")}; +`; + +const Orbit = styled.div` + position: absolute; + box-sizing: border-box; + width: 100%; + height: 100%; + border-radius: 50%; +`; + +const OrbitContainer = styled.div` + border-radius: 50%; + box-sizing: border-box; + height: 100px; + left: calc(50% - 50px); + perspective: 800px; + position: absolute; + top: calc(50% - 50px); + width: 100px; + z-index: 10; + + * { + box-sizing: border-box; + } + + div:nth-child(1) { + left: 0%; + top: 0%; + animation: orbit-spinner-orbit-one-animation 1200ms linear infinite; + border-bottom: 3px solid #b9bdcf; + } + + div:nth-child(2) { + right: 0%; + top: 0%; + animation: orbit-spinner-orbit-two-animation 1200ms linear infinite; + border-right: 3px solid #b9bdcf; + } + + div:nth-child(3) { + right: 0%; + bottom: 0%; + animation: orbit-spinner-orbit-three-animation 1200ms linear infinite; + border-top: 3px solid #b9bdcf; + } +`; + +const Shade = styled.div` + background-color: rgba(0, 0, 0, 0.75); + bottom: 0; + display: ${(props) => (props.isLoading ? "block" : "none")}; + left: 0; + position: fixed; + right: 0; + top: 0; + z-index: 5; +`; + +export { LoadingIndicatorContainer, Orbit, OrbitContainer, Shade }; diff --git a/src/client/components/LoadingIndicator.tsx b/src/client/components/LoadingIndicator/LoadingIndicator.tsx similarity index 53% rename from src/client/components/LoadingIndicator.tsx rename to src/client/components/LoadingIndicator/LoadingIndicator.tsx index 9147e56..c51417a 100644 --- a/src/client/components/LoadingIndicator.tsx +++ b/src/client/components/LoadingIndicator/LoadingIndicator.tsx @@ -1,7 +1,14 @@ import * as React from "react"; import { connect } from "react-redux"; -import { RootState } from "../types"; +import { + LoadingIndicatorContainer, + Orbit, + OrbitContainer, + Shade, +} from "./LoadingIndicator-styled"; + +import { RootState } from "../../types"; export interface LoadingIndicatorProps { isLoading: boolean; @@ -16,14 +23,14 @@ const LoadingIndicator: React.SFC = ( const { isLoading } = props; return ( <> -
-
-
-
-
-
-
-
+ + + + + + + + ); }; diff --git a/src/client/components/LoadingIndicator/index.ts b/src/client/components/LoadingIndicator/index.ts new file mode 100644 index 0000000..92e1b28 --- /dev/null +++ b/src/client/components/LoadingIndicator/index.ts @@ -0,0 +1 @@ +export { default } from "./LoadingIndicator"; diff --git a/src/client/components/Navigation/Navigation-styled.tsx b/src/client/components/Navigation/Navigation-styled.tsx new file mode 100644 index 0000000..04d9d14 --- /dev/null +++ b/src/client/components/Navigation/Navigation-styled.tsx @@ -0,0 +1,36 @@ +import styled from "styled-components"; + +import { StyledHeader } from "../Header/Header-styled"; + +const NavigationContainer = styled.nav` + align-items: center; + display: flex; + flex-direction: row; + justify-content: space-between; + + a { + align-items: center; + color: #1e86ff; + display: flex; + font-family: Poppins, sans-serif; + font-size: 14px; + font-weight: 500; + line-height: 21px; + justify-content: flex-start; + text-decoration: none; + } + + a:hover { + span { + text-decoration: underline; + } + + ${StyledHeader} { + span { + text-decoration: none; + } + } + } +`; + +export { NavigationContainer }; diff --git a/src/client/components/Navigation.tsx b/src/client/components/Navigation/Navigation.tsx similarity index 71% rename from src/client/components/Navigation.tsx rename to src/client/components/Navigation/Navigation.tsx index c52ab34..400cb7d 100644 --- a/src/client/components/Navigation.tsx +++ b/src/client/components/Navigation/Navigation.tsx @@ -2,8 +2,11 @@ import * as React from "react"; import { connect } from "react-redux"; import { Link, useLocation } from "react-router-dom"; -import Header from "./Header"; -import { RootState } from "../types"; +import { NavigationContainer } from "./Navigation-styled"; + +import Header from "../Header"; + +import { RootState } from "../../types"; export interface NavigationProps { isLoggedIn: boolean; @@ -14,19 +17,19 @@ const Navigation: React.SFC = (props: NavigationProps) => { const { pathname } = useLocation(); return ( - + ); }; diff --git a/src/client/components/Navigation/index.ts b/src/client/components/Navigation/index.ts new file mode 100644 index 0000000..f8785c3 --- /dev/null +++ b/src/client/components/Navigation/index.ts @@ -0,0 +1 @@ +export { default } from "./Navigation"; diff --git a/src/client/components/Notification.tsx b/src/client/components/Notification.tsx deleted file mode 100644 index 80e34f2..0000000 --- a/src/client/components/Notification.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import * as React from "react"; -import { connect } from "react-redux"; - -import { - setNotificationMessage, - setNotificationType, -} from "../redux/actions/application"; - -import { NotificationType } from "../types"; - -export interface NotificationProps { - handleResetNotification: () => void; - message: string; - type: NotificationType; -} - -const Notification: React.SFC = ( - props: NotificationProps -) => { - const { handleResetNotification, message, type } = props; - - React.useEffect(() => { - if (message && type === "info") { - setTimeout(() => { - handleResetNotification(); - }, 5000); - } - }, [message]); - - return ( -
- {type} - {message} -
- ); -}; - -const mapDispatchToProps = (dispatch) => ({ - handleResetNotification: () => { - dispatch(setNotificationMessage("")); - dispatch(setNotificationType("info")); - }, -}); - -export default connect(null, mapDispatchToProps)(Notification); diff --git a/src/client/components/OptionsPanel/OptionsPanel-styled.tsx b/src/client/components/OptionsPanel/OptionsPanel-styled.tsx new file mode 100644 index 0000000..d224e2f --- /dev/null +++ b/src/client/components/OptionsPanel/OptionsPanel-styled.tsx @@ -0,0 +1,15 @@ +import styled from "styled-components"; + +const OptionsPanelContainer = styled.div` + width: 25%; + + @media only screen and (max-width: 800px) { + width: 100%; + } + + @media only screen and (max-width: 600px) { + width: 100%; + } +`; + +export { OptionsPanelContainer }; diff --git a/src/client/components/OptionsPanel.tsx b/src/client/components/OptionsPanel/OptionsPanel.tsx similarity index 71% rename from src/client/components/OptionsPanel.tsx rename to src/client/components/OptionsPanel/OptionsPanel.tsx index fe88dd2..94fb9a9 100644 --- a/src/client/components/OptionsPanel.tsx +++ b/src/client/components/OptionsPanel/OptionsPanel.tsx @@ -1,15 +1,24 @@ import * as React from "react"; import { connect } from "react-redux"; -import Checkbox from "./Checkbox"; -import Input from "./Input"; -import { setFullTime, setLocationSearch } from "../redux/actions/application"; -import { RootState } from "../types"; + +import Checkbox from "../Checkbox"; +import Input from "../Input"; + +import { OptionsPanelContainer } from "./OptionsPanel-styled"; + +import { + setFullTime, + setLocationSearch, +} from "../../redux/actions/application"; + +import { LocationOption, RootState } from "../../types"; export interface OptionsPanelProps { fullTime: boolean; handleCheckBox: (e: React.ChangeEvent) => void; handleSetFullTime: (fullTime: boolean) => void; handleSetLocationSearch: (locationSearch: string) => void; + locationOptions: LocationOption[]; locationSearch: string; } @@ -21,20 +30,16 @@ const OptionsPanel: React.SFC = ( handleCheckBox, handleSetFullTime, handleSetLocationSearch, + locationOptions, locationSearch, } = props; - const locations = [ - "Chicago", - "Los Angeles", - "New York City", - "San Francisco", - ]; return ( -
+ handleSetFullTime(e.target.checked)} value="full-time" /> @@ -48,17 +53,18 @@ const OptionsPanel: React.SFC = ( value={locationSearch} /> - {locations.map((location: string, i: number) => ( + {locationOptions.map((location: LocationOption, i: number) => ( handleCheckBox(e)} - value={location} + value={location.name} /> ))} -
+ ); }; diff --git a/src/client/components/OptionsPanel/index.ts b/src/client/components/OptionsPanel/index.ts new file mode 100644 index 0000000..e13e1e4 --- /dev/null +++ b/src/client/components/OptionsPanel/index.ts @@ -0,0 +1 @@ +export { default } from "./OptionsPanel"; diff --git a/src/client/components/Pagination/Pagination-styled.tsx b/src/client/components/Pagination/Pagination-styled.tsx new file mode 100644 index 0000000..221f810 --- /dev/null +++ b/src/client/components/Pagination/Pagination-styled.tsx @@ -0,0 +1,126 @@ +import styled from "styled-components"; + +import { PaginationNavigationType } from "../../types"; + +const PaginationNavContainer = styled.nav` + display: flex; + flex-direction: row; + justify-content: flex-end; +`; + +const PaginationList = styled.ul` + display: flex; + flex-direction: row; + list-style: none; +`; + +interface PaginationNavigationListItemProps { + currentPage: number; + totalPages: number; + type: PaginationNavigationType; +} + +const PaginationNavigationListItem = styled.li< + PaginationNavigationListItemProps +>` + margin-left: 6px; + margin-right: 6px; + + button { + background: transparent; + border: 1px solid #b7bcce; + border-radius: 4px; + color: #b9bdcf; + box-sizing: border-box; + font-size: 12px; + font-style: normal; + font-weight: normal; + height: 36px; + line-height: 14px; + text-align: center; + width: 36px; + + :hover { + border: 1px solid #1e86ff; + color: #1e86ff; + cursor: ${(props) => { + if ( + (props.type === "left" && props.currentPage === 1) || + (props.type === "right" && props.currentPage === props.totalPages) + ) { + return "not-allowed"; + } + }}; + } + + :focus { + outline: #1e86ff auto 1px; + } + } + + i { + font-size: 18px; + } +`; + +interface PaginationListItemProps { + currentPage: number; + page: number; +} + +const PaginationListItem = styled.li` + margin-left: 6px; + margin-right: 6px; + + button { + background: ${(props) => + props.page === props.currentPage ? "#1e86ff" : "transparent"}; + border: ${(props) => + props.page === props.currentPage + ? "1px solid #1e86ff" + : "1px solid #b7bcce"}; + border-radius: 4px; + color: ${(props) => + props.page === props.currentPage ? "#ffffff" : "#b9bdcf"}; + box-sizing: border-box; + font-size: 12px; + font-style: normal; + font-weight: normal; + height: 36px; + line-height: 14px; + text-align: center; + width: 36px; + + :hover { + border: 1px solid #1e86ff; + color: ${(props) => + props.page === props.currentPage ? "#ffffff" : "#1e86ff"}; + } + + :focus { + outline: #1e86ff auto 1px; + } + } +`; + +const PaginationItemMore = styled.li` + align-items: center; + display: flex; + flex-direction: column; + justify-content: center; + margin-left: 6px; + margin-right: 6px; + + i { + color: #b7bcce; + font-size: 18px; + } +`; + +export { + PaginationNavContainer, + PaginationList, + PaginationNavigationListItem, + PaginationListItem, + PaginationItemMore, +}; diff --git a/src/client/components/Pagination.tsx b/src/client/components/Pagination/Pagination.tsx similarity index 90% rename from src/client/components/Pagination.tsx rename to src/client/components/Pagination/Pagination.tsx index 63161f0..0e41849 100644 --- a/src/client/components/Pagination.tsx +++ b/src/client/components/Pagination/Pagination.tsx @@ -4,6 +4,8 @@ import PaginationItem from "./PaginationItem"; import PaginationMore from "./PaginationMore"; import PaginationNavigation from "./PaginationNavigation"; +import { PaginationNavContainer, PaginationList } from "./Pagination-styled"; + export interface PaginationProps { currentPage: number; totalPages: number; @@ -53,8 +55,8 @@ const Pagination: React.SFC = (props: PaginationProps) => { }, [currentPage, totalPages]); return ( - + + ); }; diff --git a/src/client/components/PaginationItem.tsx b/src/client/components/Pagination/PaginationItem.tsx similarity index 67% rename from src/client/components/PaginationItem.tsx rename to src/client/components/Pagination/PaginationItem.tsx index fdb1f5f..3007571 100644 --- a/src/client/components/PaginationItem.tsx +++ b/src/client/components/Pagination/PaginationItem.tsx @@ -1,9 +1,11 @@ import * as React from "react"; import { connect } from "react-redux"; -import { pagination } from "../redux/thunks"; +import { PaginationListItem } from "./Pagination-styled"; -import { RootState } from "../types"; +import { pagination } from "../../redux/thunks"; + +import { RootState } from "../../types"; export interface PaginationItemProps { currentPage: number; @@ -15,14 +17,17 @@ const PaginationItem: React.SFC = ( props: PaginationItemProps ) => { const { currentPage, handlePaginationClick, page } = props; + const selected = page === currentPage; return ( -
  • -
  • + ); }; diff --git a/src/client/components/PaginationMore.tsx b/src/client/components/Pagination/PaginationMore.tsx similarity index 64% rename from src/client/components/PaginationMore.tsx rename to src/client/components/Pagination/PaginationMore.tsx index 1d23f86..70c93bc 100644 --- a/src/client/components/PaginationMore.tsx +++ b/src/client/components/Pagination/PaginationMore.tsx @@ -1,10 +1,12 @@ import * as React from "react"; +import { PaginationItemMore } from "./Pagination-styled"; + // eslint-disable-next-line const PaginationMore: React.SFC<{}> = () => ( -
  • + more_horiz -
  • + ); export default PaginationMore; diff --git a/src/client/components/PaginationNavigation.tsx b/src/client/components/Pagination/PaginationNavigation.tsx similarity index 75% rename from src/client/components/PaginationNavigation.tsx rename to src/client/components/Pagination/PaginationNavigation.tsx index ea93908..2765dc5 100644 --- a/src/client/components/PaginationNavigation.tsx +++ b/src/client/components/Pagination/PaginationNavigation.tsx @@ -1,9 +1,11 @@ import * as React from "react"; import { connect } from "react-redux"; -import { setCurrentPage } from "../redux/actions/application"; +import { setCurrentPage } from "../../redux/actions/application"; -import { PaginationNavigationType } from "../types"; +import { PaginationNavigationListItem } from "./Pagination-styled"; + +import { PaginationNavigationType } from "../../types"; export interface PaginationNavigationProps { currentPage: number; @@ -24,16 +26,10 @@ const PaginationNavigation: React.SFC = ( type, } = props; return ( -
  • -
  • + ); }; diff --git a/src/client/components/Pagination/index.ts b/src/client/components/Pagination/index.ts new file mode 100644 index 0000000..34fcdf4 --- /dev/null +++ b/src/client/components/Pagination/index.ts @@ -0,0 +1 @@ +export { default } from "./Pagination"; diff --git a/src/client/components/Profile/Profile-styled.tsx b/src/client/components/Profile/Profile-styled.tsx new file mode 100644 index 0000000..d784bb5 --- /dev/null +++ b/src/client/components/Profile/Profile-styled.tsx @@ -0,0 +1,117 @@ +import styled from "styled-components"; + +const ProfileActionsContainer = styled.div` + align-items: center; + display: flex; + flex-direction: row; + justify-content: space-between; + margin-bottom: 25px; + margin-top: 25px; + + @media only screen and (max-width: 600px) { + align-items: normal; + flex-direction: column; + } +`; + +const ProfilePage = styled.div` + align-items: center; + display: flex; + flex-direction: column; + + @media only screen and (max-width: 600px) { + form { + width: 100%; + } + } +`; + +interface ProfileFormProps { + isViewingSavedJobs: boolean; +} + +const ProfileForm = styled.form` + max-width: ${(props) => (props.isViewingSavedJobs ? "800px" : "444px")}; + width: ${(props) => (props.isViewingSavedJobs ? "100%" : "50%")}; +`; + +const ProfileTitleContainer = styled.div` + align-items: center; + display: flex; + flex-direction: column; + + h1 { + color: #282538; + font-family: Poppins; + font-style: normal; + font-weight: 200; + font-size: 24px; + line-height: 36px; + margin: 0; + } + + span { + align-items: center; + background: #1e86ff; + border-radius: 50%; + display: flex; + height: 40px; + justify-content: center; + margin: 8px; + width: 40px; + + i { + color: #ffffff; + } + } +`; + +const ProfileSavedContainer = styled.div` + width: 100%; +`; + +const ProfileNoResults = styled.p` + text-align: center; +`; + +const ProfileBackButton = styled.button` + align-items: center; + background: none; + border: none; + color: #1e86ff; + display: flex; + font-family: Poppins, sans-serif; + font-size: 14px; + font-weight: 500; + line-height: 21px; + margin-right: 15px; + justify-content: flex-start; + text-decoration: none; + + i { + font-size: 16px; + margin-right: 5px; + } + + :hover { + cursor: pointer; + + i { + text-decoration: none; + } + + span { + text-decoration: underline; + } + } +`; + +export { + ProfileActionsContainer, + ProfilePage, + ProfileForm, + ProfileTitleContainer, + ProfileSavedContainer, + ProfileNoResults, + ProfileBackButton, +}; diff --git a/src/client/components/ProfileDelete.tsx b/src/client/components/Profile/ProfileDelete.tsx similarity index 76% rename from src/client/components/ProfileDelete.tsx rename to src/client/components/Profile/ProfileDelete.tsx index 55335d4..edc1d1a 100644 --- a/src/client/components/ProfileDelete.tsx +++ b/src/client/components/Profile/ProfileDelete.tsx @@ -1,9 +1,11 @@ import * as React from "react"; import { connect } from "react-redux"; -import Button from "./Button"; +import Button from "../Button"; -import { cancelDeleteProfile, deleteProfile } from "../redux/thunks"; +import { ProfileActionsContainer } from "./Profile-styled"; + +import { cancelDeleteProfile, deleteProfile } from "../../redux/thunks"; export interface ProfileDeleteProps { handleCancelDeleteProfile: () => void; @@ -16,22 +18,22 @@ const ProfileDelete: React.SFC = ( const { handleCancelDeleteProfile, handleDeleteProfile } = props; return ( <> -
    +
    + ); }; diff --git a/src/client/components/ProfileDisplay.tsx b/src/client/components/Profile/ProfileDisplay.tsx similarity index 78% rename from src/client/components/ProfileDisplay.tsx rename to src/client/components/Profile/ProfileDisplay.tsx index 0b4bd99..715de9a 100644 --- a/src/client/components/ProfileDisplay.tsx +++ b/src/client/components/Profile/ProfileDisplay.tsx @@ -1,20 +1,22 @@ import * as React from "react"; import { connect } from "react-redux"; -import Button from "./Button"; -import Input from "./Input"; +import Button from "../Button"; +import Input from "../Input"; -import { setNotificationMessage } from "../redux/actions/application"; -import { setIsResettingPassword } from "../redux/actions/user"; +import { ProfileActionsContainer } from "./Profile-styled"; + +import { displayNotification } from "../../redux/actions/application"; +import { setIsResettingPassword } from "../../redux/actions/user"; import { clickEditProfile, clickDeleteProfile, clickViewSavedJobs, logOut, logOutAll, -} from "../redux/thunks"; +} from "../../redux/thunks"; -import { Job, RootState } from "../types"; +import { RootState } from "../../types"; export interface ProfileDisplayProps { email: string; @@ -26,7 +28,7 @@ export interface ProfileDisplayProps { handleLogOutAll: () => void; handleSetIsResettingPassword: (isResettingPassword: boolean) => void; name: string; - savedJobs: Job[]; + savedJobs: string[]; } const ProfileDisplay: React.SFC = ( @@ -48,6 +50,7 @@ const ProfileDisplay: React.SFC = ( <> = ( = ( value={email} /> -
    +
    + -
    +
    + -
    +
    + ); }; @@ -129,7 +133,7 @@ const mapStateToProps = (state: RootState) => ({ }); const mapDispatchToProps = (dispatch) => ({ - handleClearFormError: () => dispatch(setNotificationMessage("")), + handleClearFormError: () => dispatch(displayNotification("", "default")), handleClickDeleteProfile: () => dispatch(clickDeleteProfile()), handleClickEditProfile: () => dispatch(clickEditProfile()), handleClickViewSavedJobs: () => dispatch(clickViewSavedJobs()), diff --git a/src/client/components/ProfileEdit.tsx b/src/client/components/Profile/ProfileEdit.tsx similarity index 81% rename from src/client/components/ProfileEdit.tsx rename to src/client/components/Profile/ProfileEdit.tsx index eeb2ae9..96aa1c4 100644 --- a/src/client/components/ProfileEdit.tsx +++ b/src/client/components/Profile/ProfileEdit.tsx @@ -1,13 +1,15 @@ import * as React from "react"; import { connect } from "react-redux"; -import Button from "./Button"; -import Input from "./Input"; +import Button from "../Button"; +import Input from "../Input"; -import { setEditEmail, setEditName } from "../redux/actions/user"; -import { cancelEditProfile, editProfile } from "../redux/thunks"; +import { ProfileActionsContainer } from "./Profile-styled"; -import { RootState } from "../types"; +import { setEditEmail, setEditName } from "../../redux/actions/user"; +import { cancelEditProfile, editProfile } from "../../redux/thunks"; + +import { RootState } from "../../types"; export interface ProfileEditProps { editEmail: string; @@ -34,6 +36,7 @@ const ProfileEdit: React.SFC = (props: ProfileEditProps) => { return ( <> = (props: ProfileEditProps) => { /> = (props: ProfileEditProps) => { value={editEmail} /> -
    +
    + ); }; diff --git a/src/client/components/ProfileReset.tsx b/src/client/components/Profile/ProfileReset.tsx similarity index 87% rename from src/client/components/ProfileReset.tsx rename to src/client/components/Profile/ProfileReset.tsx index 6bbe2fd..6ab447f 100644 --- a/src/client/components/ProfileReset.tsx +++ b/src/client/components/Profile/ProfileReset.tsx @@ -1,17 +1,19 @@ import * as React from "react"; import { connect } from "react-redux"; -import Button from "./Button"; -import Input from "./Input"; +import Button from "../Button"; +import Input from "../Input"; + +import { ProfileActionsContainer } from "./Profile-styled"; import { setResetConfirmNewPassword, setResetCurrentPassword, setResetNewPassword, -} from "../redux/actions/user"; -import { cancelResetPassword, resetPassword } from "../redux/thunks"; +} from "../../redux/actions/user"; +import { cancelResetPassword, resetPassword } from "../../redux/thunks"; -import { RootState } from "../types"; +import { RootState } from "../../types"; export interface ProfileResetProps { handleCancelResetPassword: () => void; @@ -41,6 +43,7 @@ const ProfileReset: React.SFC = ( <> = ( = ( = ( value={resetConfirmNewPassword} /> -
    +
    + ); }; diff --git a/src/client/components/Profile/ProfileSavedJobs.tsx b/src/client/components/Profile/ProfileSavedJobs.tsx new file mode 100644 index 0000000..b1ae8a4 --- /dev/null +++ b/src/client/components/Profile/ProfileSavedJobs.tsx @@ -0,0 +1,87 @@ +import * as React from "react"; +import { connect } from "react-redux"; + +import JobCard from "../JobCard"; +import Pagination from "../Pagination"; + +import { + ProfileSavedContainer, + ProfileNoResults, + ProfileBackButton, +} from "./Profile-styled"; + +import { setIsViewingSavedJobs } from "../../redux/actions/user"; +import { getSavedJobsDetails } from "../../redux/thunks"; + +import { RootState, Job } from "../../types"; + +export interface ProfileSavedJobsProps { + handleBackToProfile: () => void; + handleGetSavedJobsDetails: () => void; + savedJobsCurrentPage: number; + savedJobsDetails: Job[]; + savedJobsTotalPages: number; +} + +const ProfileSavedJobs: React.SFC = ( + props: ProfileSavedJobsProps +) => { + const { + handleBackToProfile, + handleGetSavedJobsDetails, + savedJobsCurrentPage, + savedJobsDetails, + savedJobsTotalPages, + } = props; + + const jobsOnPage = + savedJobsDetails && + savedJobsDetails.slice( + savedJobsCurrentPage * 5 - 5, + savedJobsCurrentPage * 5 + ); + + React.useEffect((): void => { + handleGetSavedJobsDetails(); + }, []); + + return ( + <> + handleBackToProfile()} + > + west + Back to profile + + + {jobsOnPage && + jobsOnPage.map((job: Job) => )} + {jobsOnPage.length > 0 && ( + + )} + {jobsOnPage.length === 0 && ( + + No results. Please modify your search and try again. + + )} + + + ); +}; + +const mapStateToProps = (state: RootState) => ({ + savedJobsCurrentPage: state.user.savedJobsCurrentPage, + savedJobsDetails: state.user.savedJobsDetails, + savedJobsTotalPages: state.user.savedJobsTotalPages, +}); + +const mapDispatchToProps = (dispatch) => ({ + handleBackToProfile: () => dispatch(setIsViewingSavedJobs(false)), + handleGetSavedJobsDetails: () => dispatch(getSavedJobsDetails()), +}); + +export default connect(mapStateToProps, mapDispatchToProps)(ProfileSavedJobs); diff --git a/src/client/components/Profile/index.ts b/src/client/components/Profile/index.ts new file mode 100644 index 0000000..4c6f684 --- /dev/null +++ b/src/client/components/Profile/index.ts @@ -0,0 +1,13 @@ +import ProfileDelete from "./ProfileDelete"; +import ProfileDisplay from "./ProfileDisplay"; +import ProfileEdit from "./ProfileEdit"; +import ProfileReset from "./ProfileReset"; +import ProfileSavedJobs from "./ProfileSavedJobs"; + +export { + ProfileDelete, + ProfileDisplay, + ProfileEdit, + ProfileReset, + ProfileSavedJobs, +}; diff --git a/src/client/components/ProfileSavedJobs.tsx b/src/client/components/ProfileSavedJobs.tsx deleted file mode 100644 index c0b51fe..0000000 --- a/src/client/components/ProfileSavedJobs.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import * as React from "react"; -import { connect } from "react-redux"; - -import JobCard from "./JobCard"; -import Notification from "./Notification"; -import Pagination from "./Pagination"; - -import { Job, NotificationType, RootState } from "../types"; - -export interface ProfileSavedJobsProps { - notificationMessage: string; - notificationType: NotificationType; - savedJobs: Job[]; - savedJobsCurrentPage: number; - savedJobsTotalPages: number; -} - -const ProfileSavedJobs: React.SFC = ( - props: ProfileSavedJobsProps -) => { - const { - notificationMessage, - notificationType, - savedJobs, - savedJobsCurrentPage, - savedJobsTotalPages, - } = props; - - const jobsOnPage = savedJobs.slice( - savedJobsCurrentPage * 5 - 5, - savedJobsCurrentPage * 5 - ); - return ( - <> -
    - {notificationMessage && ( - - )} - {jobsOnPage && - jobsOnPage.map((job: Job) => )} - {jobsOnPage.length > 0 && ( - - )} - {jobsOnPage.length === 0 && ( -

    - No results. Please modify your search and try again. -

    - )} -
    - - ); -}; - -const mapStateToProps = (state: RootState) => ({ - notificationMessage: state.application.notificationMessage, - notificationType: state.application.notificationType, - savedJobs: state.user.savedJobs, - savedJobsCurrentPage: state.user.savedJobsCurrentPage, - savedJobsTotalPages: state.user.savedJobsTotalPages, -}); - -export default connect(mapStateToProps)(ProfileSavedJobs); diff --git a/src/client/components/SearchInput/SearchInput-styled.tsx b/src/client/components/SearchInput/SearchInput-styled.tsx new file mode 100644 index 0000000..b80e588 --- /dev/null +++ b/src/client/components/SearchInput/SearchInput-styled.tsx @@ -0,0 +1,82 @@ +import styled from "styled-components"; + +const SearchInputOuterContainer = styled.div` + background-image: url("/assets/backgroundImg.png"); + background-position: center; + background-size: cover; + border-radius: 0.5rem; + padding: 35px 20%; + + @media only screen and (max-width: 600px) { + padding: 35px 5%; + } +`; + +const SearchInputInnerContainer = styled.div` + align-items: stretch; + display: flex; + flex-wrap: wrap; + width: 100%; +`; + +const SearchInputForm = styled.form` + display: flex; + width: 100%; +`; + +const SearchInputLeft = styled.div` + align-items: center; + background-color: #ffffff; + border: 1px solid #b9bdcf; + border-bottom-left-radius: 0.25rem; + border-right: none; + border-top-left-radius: 0.25rem; + display: flex; + margin-right: -1px; + padding: 0.375rem 0.75rem; + padding-right: 0; + text-align: center; + + i { + color: #b9bdcf; + font-size: 16px; + } +`; + +const SearchInputInput = styled.input` + background-clip: padding-box; + background-color: #fff; + border: 1px solid #b9bdcf; + border-bottom-right-radius: 0; + border-left: none; + border-right: none; + border-top-right-radius: 0; + flex: 1 1 auto; + font-size: 12px; + font-weight: 400; + height: calc(1.5em + 0.75rem + 2px); + line-height: 14px; + margin-bottom: 0; + min-width: 0; + padding: 0.375rem 0.75rem; + transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; + width: 1%; + + :focus { + outline: none; + } +`; + +const SearchInputButtonContainer = styled.div` + display: flex; + margin-left: -1px; +`; + +export { + SearchInputOuterContainer, + SearchInputInnerContainer, + SearchInputForm, + SearchInputLeft, + SearchInputInput, + SearchInputButtonContainer, +}; diff --git a/src/client/components/SearchInput.tsx b/src/client/components/SearchInput/SearchInput.tsx similarity index 64% rename from src/client/components/SearchInput.tsx rename to src/client/components/SearchInput/SearchInput.tsx index aeb646f..e0faae6 100644 --- a/src/client/components/SearchInput.tsx +++ b/src/client/components/SearchInput/SearchInput.tsx @@ -1,11 +1,20 @@ import * as React from "react"; import { connect } from "react-redux"; -import Button from "./Button"; +import Button from "../Button"; -import { searchJobs } from "../redux/thunks"; +import { + SearchInputOuterContainer, + SearchInputInnerContainer, + SearchInputForm, + SearchInputLeft, + SearchInputInput, + SearchInputButtonContainer, +} from "./SearchInput-styled"; -import { LocationOption, RootState } from "../types"; +import { searchJobs } from "../../redux/thunks"; + +import { LocationOption, RootState } from "../../types"; interface SearchInputProps { handleSearch: (search: string, locationOptions: LocationOption[]) => void; @@ -18,38 +27,37 @@ const SearchInput: React.SFC = (props: SearchInputProps) => { const [search, setSearch] = React.useState(searchValue); return ( -
    -
    -
    + + { e.preventDefault(); handleSearch(search, locationOptions); }} > -
    - work_outline -
    - + work_outline + + setSearch(e.target.value)} placeholder="Title, companies, expertise or benefits" type="text" value={search} /> -
    +
    - -
    -
    + + + + ); }; diff --git a/src/client/components/SearchInput/index.ts b/src/client/components/SearchInput/index.ts new file mode 100644 index 0000000..1a2fa40 --- /dev/null +++ b/src/client/components/SearchInput/index.ts @@ -0,0 +1 @@ +export { default } from "./SearchInput"; diff --git a/src/client/index.css b/src/client/index.css index 8e42ab4..44e92a0 100644 --- a/src/client/index.css +++ b/src/client/index.css @@ -23,93 +23,6 @@ body { text-decoration: none; } -header { - font-size: 1.5rem; - font-weight: lighter; - margin-bottom: 25px; - margin-top: 25px; - - font-family: Poppins; - font-style: normal; - font-weight: 200; - font-size: 24px; - line-height: 36px; - color: #282538; -} - -.bold { - font-weight: bold; -} - -.grey { - color: rgba(116, 116, 116, 1); -} - -.icon-sm { - font-size: 16px; -} - -.search__container__outer { - background-image: url("/assets/backgroundImg.png"); - background-position: center; - background-size: cover; - border-radius: 0.5rem; - padding: 35px 20%; -} - -.search__container__inner, -.input__container__inner { - align-items: stretch; - display: flex; - flex-wrap: wrap; - width: 100%; -} - -.input__addon__left { - align-items: center; - background-color: #fff; - border: 1px solid #b9bdcf; - /* border: 1px solid rgba(206, 212, 218, 1); */ - border-bottom-left-radius: 0.25rem; - border-right: none; - border-top-left-radius: 0.25rem; - display: flex; - margin-right: -1px; - padding: 0.375rem 0.75rem; - padding-right: 0; - text-align: center; -} - -.input__addon__left i { - color: #b9bdcf; -} - -.search__input, -.input__container__inner input { - background-clip: padding-box; - background-color: #fff; - border: 1px solid #b9bdcf; - border-bottom-right-radius: 0; - border-left: none; - border-right: none; - border-top-right-radius: 0; - flex: 1 1 auto; - font-size: 12px; - font-weight: 400; - height: calc(1.5em + 0.75rem + 2px); - line-height: 14px; - margin-bottom: 0; - min-width: 0; - padding: 0.375rem 0.75rem; - transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; - width: 1%; -} - -.input__container__inner input { - border-bottom-left-radius: 0; - border-top-left-radius: 0; -} - input::placeholder { /* Chrome, Firefox, Opera, Safari 10.1+ */ color: #b9bdcf; @@ -117,583 +30,6 @@ input::placeholder { text-overflow: ellipsis; } -.search__input:focus, -.input__container__inner input:focus { - outline: none; -} - -.input__container__inner { - box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.05); -} - -.input__container__inner input { - border-bottom-right-radius: 4px; - border-top-right-radius: 4px; - border-right: 1px solid #b9bdcf; -} - -.search__button__container { - display: flex; - margin-left: -1px; -} - -.flex { - display: flex; -} - -.jobcard__container { - background-color: #fff; - box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.05); - border-radius: 4px; - display: flex; - flex-direction: row; - justify-content: space-between; - margin-bottom: 16px; - margin-top: 16px; - padding: 15px; -} - -.jobcard__container a { - text-decoration: none; -} - -.jobcard__actions { - display: flex; - flex-direction: row; - justify-content: flex-end; -} - -.jobcard__save__selected, -.jobcard__save__deselected, -.details__save__selected, -.details__save__deselected { - background: transparent; - border: none; - color: #b9bdcf; - margin: 0; - padding: 0; -} - -.jobcard__save__selected, -.details__save__selected { - color: #1e86ff; -} - -.jobcard__save__selected:hover, -.jobcard__save__deselected:hover, -.details__save__selected:hover, -.details__save__deselected:hover { - color: #1e86ff; - cursor: pointer; -} - -.details__save__selected, -.details__save__deselected { - margin-left: 15px; -} - -.jobcard__info { - align-self: flex-end; - display: flex; - flex-direction: column; -} - -.jobcard__logo__not-found, -.details__logo__not-found { - align-items: center; - display: flex; - height: 100%; - justify-content: center; - text-align: center; - width: 100%; -} - -.jobcard__logo__not-found, -.details__logo__not-found p { - color: #bdbdbd; - font-size: 12px; - font-weight: 500; - line-height: 14px; -} - -.details__logo__not-found p { - font-size: 8px; -} - -.jobcard__logo__container, -.details__logo__container { - background-color: #f2f2f2; - border-radius: 4px; - height: 90px; - width: 90px; -} - -.jobcard__logo__container img, -.details__logo__container img { - height: 90px; - object-fit: contain; - width: 90px; -} - -.jobcard__container__middle { - margin-left: 16px; -} - -.jobcard__company, -.details__company { - color: #334680; - font-size: 12px; - font-weight: bold; - line-height: 14px; - margin: 0; -} - -.jobcard__title { - color: #334680; - font-size: 18px; - font-weight: normal; - line-height: 21px; - margin-bottom: 12px; - margin-top: 8px; -} - -.jobcard__fulltime, -.details__title__fulltime { - border: 1px solid #334680; - border-radius: 4px; - color: #334680; - font-size: 12px; - font-weight: bold; - line-height: 14px; - padding: 6px 8px; - text-align: center; - width: 53px; -} - -.jobcard__container__right { - display: flex; - flex-direction: column; - justify-content: space-between; -} - -.jobcard__location, -.details__location { - align-items: center; - display: flex; -} - -.jobcard__location i, -.details__location i, -.jobcard__created i { - color: #b9bdcf; - font-size: 15px; - margin-right: 5px; -} - -.jobcard__created { - margin-top: 15px; -} - -.jobcard__created p, -.jobcard__location p { - margin: 0; - max-width: 160px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.jobcard__location p, -.details__location p { - color: #b9bdcf; - font-size: 12px; - font-weight: 500; - line-height: 14px; -} - -.jobcard__created, -.details__created { - align-items: center; - display: flex; -} - -.jobcard__created, -.jobcard__location { - justify-content: flex-end; -} - -.details__created i { - color: #b9bdcf; - font-size: 15px; - margin-right: 7.5px; -} - -.jobcard__created, -.details__created p { - color: #b9bdcf; - font-size: 12px; - font-weight: 500; - line-height: 14px; -} - -.search__container { - display: flex; - margin-top: 42px; -} - -.options-panel__container { - width: 25%; -} - -.jobs__container { - width: 75%; -} - -.jobs__container.saved { - width: 100%; -} - -.checkbox__container { - display: block; - color: #334680; - cursor: pointer; - font-family: "Poppins", sans-serif; - font-size: 14px; - font-weight: 500; - line-height: 21px; - margin-bottom: 12px; - position: relative; - padding-left: 30px; - user-select: none; - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; -} - -.checkbox__container input { - position: absolute; - opacity: 0; - cursor: pointer; - height: 0; - width: 0; -} - -.checkmark { - background-color: rgba(243, 245, 250, 1); - border: 1px solid #b9bdcf; - border-radius: 2px; - box-sizing: border-box; - height: 18px; - left: 0; - position: absolute; - top: 0; - width: 18px; -} - -.checkbox__container:hover input ~ .checkmark { - background-color: rgba(250, 250, 250, 1); -} - -.checkbox__container input:checked ~ .checkmark { - background-color: #1e86ff; -} - -.checkbox__container input:checked ~ span { - border: 1px solid #1e86ff; -} - -.checkmark:after { - content: ""; - display: none; - position: absolute; -} - -.checkbox__container input:checked ~ .checkmark:after { - display: block; -} - -.checkbox__container .checkmark:after { - border: solid white; - border-radius: 1px; - border-width: 0 1px 1px 0; - height: 9px; - left: 5px; - top: 2px; - transform: rotate(45deg); - width: 4px; - -webkit-transform: rotate(45deg); - -ms-transform: rotate(45deg); -} - -.input__container { - display: flex; - flex-direction: column; - margin-bottom: 25px; - margin-top: 32px; - max-width: 90%; -} - -.input__container__inner { - margin-top: 14px; -} - -.input__container label { - color: #b9bdcf; - font-family: "Poppins", sans-serif; - font-size: 14px; - font-weight: bold; - line-height: 21px; - text-transform: uppercase; -} - -.copyright { - align-items: center; - color: #b9bdcf; - display: flex; - font-size: 14px; - font-weight: lighter; - justify-content: center; - line-height: 17px; - margin-top: 50px; - width: 100%; -} - -.copyright a { - color: #b9bdcf; - text-decoration: none; -} - -.copyright a:hover { - color: #8f929b; - text-decoration: underline; -} - -.details__container { - display: flex; - flex-direction: row; -} - -.details__side__container { - display: flex; - flex-direction: column; - padding-right: 50px; - width: 25%; -} - -.details__main__container { - display: flex; - flex-direction: column; - width: 75%; -} - -.details__side__link, -.login__action__create, -.navigation__link { - align-items: center; - color: #1e86ff; - display: flex; - font-family: Poppins, sans-serif; - font-size: 14px; - font-weight: 500; - line-height: 21px; - justify-content: flex-start; - text-decoration: none; -} - -.details__side__link { - font-size: 14px; - margin-right: 15px; -} - -.login__action__create i { - font-size: 18px; - margin-right: 10px; -} - -.details__side__link:hover span, -.login__action__create:hover span, -.navigation__link:hover span { - text-decoration: underline; -} - -.details__side__link:hover i, -.login__action__create:hover i { - text-decoration: none; -} - -.details__container__how-to { - display: flex; - flex-direction: column; - margin-top: 36px; - overflow-wrap: break-word; -} - -.details__container__label { - color: #b9bdcf; - font-family: Poppins, sans-serif; - font-size: 14px; - font-style: normal; - font-weight: bold; - line-height: 21px; - text-transform: uppercase; -} - -.details__container__apply-md a { - overflow-wrap: break-word; -} - -.details__title { - color: #334680; - font-family: Roboto; - font-style: normal; - font-weight: bold; - font-size: 24px; - line-height: 28px; - margin-bottom: 0; - margin-top: 0; -} - -.details__container__title { - display: flex; - flex-direction: column; -} - -.details__container__title__inner { - align-items: flex-start; - display: flex; - flex-direction: column; -} - -.details__title__fulltime { - margin-bottom: 0; - margin-top: 0; -} - -.details__container__actions { - display: flex; - flex-direction: row; - margin-top: 15px; -} - -.details__created { - align-items: start; - margin-left: 0; - margin-top: 10px; -} - -.details__created p { - margin-bottom: 0; - margin-top: 0; -} - -.details__logo__container { - margin-right: 12px; -} - -.details__logo__container, -.details__logo__container img { - height: 42px; - width: 42px; -} - -.details__container__company { - display: flex; - flex-direction: row; - margin-top: 32px; -} - -.details__company { - color: #334680; - font-family: Roboto; - font-size: 18px; - font-style: normal; - font-weight: bold; - line-height: 21px; - text-decoration: none; -} - -.details__company__right { - display: flex; - flex-direction: column; -} - -.details__location { - align-items: flex-start; - margin-top: 10px; -} - -.details__location p { - margin-top: 0; -} - -.details__container__description { - color: #334680; - font-family: Roboto; - font-style: normal; - font-weight: normal; - font-size: 16px; - line-height: 24px; -} - -.hidden { - display: none; -} - -.shade { - background-color: rgba(0, 0, 0, 0.75); - bottom: 0; - left: 0; - position: fixed; - right: 0; - top: 0; - z-index: 5; -} - -.orbit-spinner, -.orbit-spinner * { - box-sizing: border-box; -} - -.orbit-spinner { - border-radius: 50%; - height: 100px; - left: calc(50% - 50px); - perspective: 800px; - position: absolute; - top: calc(50% - 50px); - width: 100px; - z-index: 10; -} - -.orbit-spinner .orbit { - position: absolute; - box-sizing: border-box; - width: 100%; - height: 100%; - border-radius: 50%; -} - -.orbit-spinner .orbit:nth-child(1) { - left: 0%; - top: 0%; - animation: orbit-spinner-orbit-one-animation 1200ms linear infinite; - border-bottom: 3px solid #b9bdcf; -} - -.orbit-spinner .orbit:nth-child(2) { - right: 0%; - top: 0%; - animation: orbit-spinner-orbit-two-animation 1200ms linear infinite; - border-right: 3px solid #b9bdcf; -} - -.orbit-spinner .orbit:nth-child(3) { - right: 0%; - bottom: 0%; - animation: orbit-spinner-orbit-three-animation 1200ms linear infinite; - border-top: 3px solid #b9bdcf; -} - @keyframes orbit-spinner-orbit-one-animation { 0% { transform: rotateX(35deg) rotateY(-45deg) rotateZ(0deg); @@ -721,264 +57,6 @@ input::placeholder { } } -.pagination__list { - display: flex; - flex-direction: row; - list-style: none; -} - -.pagination__item, -.pagination__item__selected, -.pagination__item__disabled, -.pagination__item__more { - margin-left: 6px; - margin-right: 6px; -} - -.pagination__item button, -.pagination__item__selected button, -.pagination__item__disabled button { - background: transparent; - border: 1px solid #b7bcce; - border-radius: 4px; - color: #b9bdcf; - box-sizing: border-box; - font-size: 12px; - font-style: normal; - font-weight: normal; - height: 36px; - line-height: 14px; - text-align: center; - width: 36px; -} - -.pagination__item__selected button { - background: #1e86ff; - border: 1px solid #1e86ff; - color: #ffffff; -} - -.pagination__item button:hover { - border: 1px solid #1e86ff; - color: #1e86ff; -} - -.pagination__item__disabled button:hover { - cursor: not-allowed; -} - -.pagination__item button:focus, -.pagination__item__selected button:focus, -.pagination__item__disabled button:focus { - outline: #1e86ff auto 1px; -} - -.pagination__item i, -.pagination__item__disabled i { - font-size: 18px; -} - -.pagination__item__more { - align-items: center; - display: flex; - flex-direction: column; - justify-content: center; -} - -.pagination__item__more i { - color: #b7bcce; - font-size: 18px; -} - -#pagination { - display: flex; - flex-direction: row; - justify-content: flex-end; -} - -.text__center { - text-align: center; -} - -#search-form { - display: flex; - width: 100%; -} - -.jobcard__container__left { - display: flex; - width: 100%; -} - -#navigation { - align-items: center; - display: flex; - flex-direction: row; - justify-content: space-between; -} - -#login-page, -#signup-page, -#profile-page { - align-items: center; - display: flex; - flex-direction: column; -} - -#login-page > form, -#signup-page > form, -#profile-page > form { - max-width: 444px; - width: 50%; -} - -#profile-page > form.saved { - max-width: 800px; - width: 100%; -} - -#login-page > .input__container, -#signup-page > .input__container, -#profile-page > .input__container { - display: flex; - flex-direction: column; - margin: 0; - max-width: 100%; - width: 100%; -} - -.login__container__title, -.signup__container__title, -.profile__container__title { - align-items: center; - display: flex; - flex-direction: column; -} - -.login__container__title > h1, -.signup__container__title > h1, -.profile__container__title > h1 { - color: #282538; - font-family: Poppins; - font-style: normal; - font-weight: 200; - font-size: 24px; - line-height: 36px; - margin: 0; -} - -.avatar { - align-items: center; - background: #1e86ff; - border-radius: 50%; - display: flex; - height: 40px; - justify-content: center; - margin: 8px; - width: 40px; -} - -.avatar > i { - color: #fff; -} - -#login-page > form > .input__container, -#signup-page > form > .input__container, -#profile-page > form > .input__container { - max-width: 100%; -} - -.login__container__actions, -.signup__container__actions, -.profile__container__actions { - align-items: center; - display: flex; - flex-direction: row; - justify-content: space-between; -} - -.signup__container__actions { - justify-content: center; -} - -.profile__container__actions { - margin-bottom: 25px; - margin-top: 25px; -} - -.button__primary, -.button__secondary, -.button__danger { - border: 3px solid rgba(255, 255, 255, 1); - border-bottom-right-radius: 0.25rem; - border-top-right-radius: 0.25rem; - color: #fff; - cursor: pointer; - display: inline-block; - font-weight: 400; - line-height: 1.5; - padding: 0.375rem 3rem; - position: relative; - text-align: center; - transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, - border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; - user-select: none; - vertical-align: middle; - z-index: 2; -} - -.button__primary { - background-color: rgba(27, 108, 205, 1); -} - -.button__secondary { - background-color: #b9bdcf; -} - -.button__danger { - background-color: rgba(205, 27, 27, 1); -} - -.notification__container { - align-items: center; - border-radius: 4px; - display: flex; - flex-direction: row; - justify-content: flex-start; - padding: 15px; - margin-top: 25px; -} - -.notification__container > span { - margin-left: 10px; -} - -.notification__container.error { - background-color: #f8d7da; - color: #721c24; -} - -.notification__container.info { - background-color: #d1ecf1; - color: #0c5460; -} - -.notification__container.warning { - background-color: #fff3cd; - color: #856404; -} - -@media only screen and (max-width: 800px) { - .search__container { - flex-direction: column; - } - - .options-panel__container, - .jobs__container { - width: 100%; - } -} - @media only screen and (max-width: 600px) { #app { max-width: 100%; @@ -986,10 +64,6 @@ input::placeholder { padding-right: 10px; } - .search__container__outer { - padding: 35px 5%; - } - [placeholder] { text-overflow: ellipsis; } @@ -998,61 +72,8 @@ input::placeholder { padding: 0.375rem 2rem; } - .search__container, - .details__container, - .login__container__actions, - .signup__container__actions, - .profile__container__actions { - flex-direction: column; - } - - .profile__container__actions { - align-items: normal; - } - - .options-panel__container, - .jobs__container, - .details__side__container, - .details__main__container, - #login-page > form, - #signup-page > form, - #profile-page > form { - width: 100%; - } - - .input__container { - max-width: 100%; - } - - .jobcard__location p, - .jobcard__created p { - margin-bottom: 0; - margin-top: 0; - } - - .jobcard__container { - margin-bottom: 13px; - margin-top: 13px; - } - - .details__title { - margin-top: 36px; - } - #log-in, #sign-up { margin-top: 25px; } - - .details__title__fulltime { - margin-left: 0; - margin-top: 4px; - } -} - -@media only screen and (max-width: 450px) { - .jobcard__location p, - .jobcard__created p { - max-width: 65px; - } } diff --git a/src/client/index.tsx b/src/client/index.tsx index b4d8c97..26090fa 100644 --- a/src/client/index.tsx +++ b/src/client/index.tsx @@ -4,6 +4,7 @@ import { Provider } from "react-redux"; import App from "./App"; import store from "./redux/store"; import "./index.css"; +import "react-toastify/dist/ReactToastify.css"; ReactDOM.render( diff --git a/src/client/pages/Details.tsx b/src/client/pages/Details.tsx deleted file mode 100644 index 075c447..0000000 --- a/src/client/pages/Details.tsx +++ /dev/null @@ -1,206 +0,0 @@ -import * as React from "react"; -import { connect } from "react-redux"; -import formatDistanceToNow from "date-fns/formatDistanceToNow"; -import { useParams, Link } from "react-router-dom"; - -import Copyright from "../components/Copyright"; -import Notification from "../components/Notification"; - -import { addSavedJob, removeSavedJob } from "../redux/thunks"; - -import { Job, NotificationType, RootState } from "../types"; - -interface DetailsProps { - handleAddSavedJob: (job: Job) => void; - handleRemoveSavedJob: (job: Job) => void; - isLoggedIn: boolean; - jobs: Job[]; - notificationMessage: string; - notificationType: NotificationType; - savedJobs: Job[]; -} - -const Details: React.SFC = (props: DetailsProps) => { - const { - handleAddSavedJob, - handleRemoveSavedJob, - isLoggedIn, - jobs, - notificationMessage, - notificationType, - savedJobs, - } = props; - const { id } = useParams(); - const [data, setData] = React.useState(null); - const [applyLink, setApplyLink] = React.useState(""); - - React.useEffect(() => { - window.scrollTo(0, 0); - }, []); - - React.useEffect((): void => { - const job = jobs.find((job: Job) => job.id === id); - const isPlainLink = job.how_to_apply.slice(0, 5) === "

    /gm)[0]; - - setApplyLink(href); - } - - setData(job); - }, []); - - const jobIsSaved = - savedJobs && data - ? savedJobs.findIndex((savedJob: Job) => savedJob.id === data.id) >= 0 - : false; - - return ( - <> -

    -
    - - west - Back to search - - - -
    - {notificationMessage && ( - - )} - {data && ( - <> -
    -
    -

    {data.title}

    -
    - {data.type === "Full Time" && ( -

    - Full Time -

    - )} - {isLoggedIn && ( - - )} -
    -
    - -
    - access_time -

    - {formatDistanceToNow(new Date(data.created_at), { - addSuffix: true, - })} -

    -
    -
    - -
    -
    - {data.company_logo ? ( - Company Logo - ) : ( -
    -

    not found

    -
    - )} -
    - -
    - {data.company_url ? ( - - {data.company} - - ) : ( -

    {data.company}

    - )} - -
    - public -

    {data.location}

    -
    -
    -
    - -
    -
    -
    - - )} -
    -
    - - - ); -}; - -const mapStateToProps = (state: RootState) => ({ - isLoggedIn: state.user.isLoggedIn, - jobs: state.application.jobs, - notificationMessage: state.application.notificationMessage, - notificationType: state.application.notificationType, - savedJobs: state.user.savedJobs, -}); - -const mapDispatchToProps = (dispatch) => ({ - handleAddSavedJob: (job: Job) => dispatch(addSavedJob(job)), - handleRemoveSavedJob: (job: Job) => dispatch(removeSavedJob(job)), -}); - -export default connect(mapStateToProps, mapDispatchToProps)(Details); diff --git a/src/client/pages/Details/Details-styled.tsx b/src/client/pages/Details/Details-styled.tsx new file mode 100644 index 0000000..7f46df4 --- /dev/null +++ b/src/client/pages/Details/Details-styled.tsx @@ -0,0 +1,277 @@ +import styled from "styled-components"; + +const DetailsContainer = styled.div` + display: flex; + flex-direction: row; + + @media only screen and (max-width: 600px) { + flex-direction: column; + } +`; + +const DetailsSideContainer = styled.div` + display: flex; + flex-direction: column; + padding-right: 50px; + width: 25%; + + @media only screen and (max-width: 600px) { + width: 100%; + } + + a { + align-items: center; + color: #1e86ff; + display: flex; + font-family: Poppins, sans-serif; + font-size: 14px; + font-weight: 500; + line-height: 21px; + margin-right: 15px; + justify-content: flex-start; + text-decoration: none; + + i { + font-size: 16px; + margin-right: 5px; + } + + :hover { + i { + text-decoration: none; + } + + span { + text-decoration: underline; + } + } + } +`; + +const DetailsHowToContainer = styled.div` + display: flex; + flex-direction: column; + margin-top: 36px; + overflow-wrap: break-word; +`; + +const DetailsHowToLabel = styled.span` + color: #b9bdcf; + font-family: Poppins, sans-serif; + font-size: 14px; + font-style: normal; + font-weight: bold; + line-height: 21px; + text-transform: uppercase; +`; + +const DetailsMainContainer = styled.div` + display: flex; + flex-direction: column; + width: 75%; + + @media only screen and (max-width: 600px) { + width: 100%; + } +`; + +const DetailsMainTitleContainer = styled.div` + display: flex; + flex-direction: column; +`; + +interface DetailsMainInnerTitleContainerProps { + jobIsSaved: boolean; +} + +const DetailsMainInnerTitleContainer = styled.div< + DetailsMainInnerTitleContainerProps +>` + align-items: flex-start; + display: flex; + flex-direction: column; + + h2 { + color: #334680; + font-family: Roboto; + font-style: normal; + font-weight: bold; + font-size: 24px; + line-height: 28px; + margin-bottom: 0; + margin-top: 0; + + @media only screen and (max-width: 600px) { + margin-top: 36px; + } + } + + div { + display: flex; + flex-direction: row; + margin-top: 15px; + + p { + border: 1px solid #334680; + border-radius: 4px; + color: #334680; + font-size: 12px; + font-weight: bold; + line-height: 14px; + margin-bottom: 0; + margin-top: 0; + padding: 6px 8px; + text-align: center; + width: 53px; + + @media only screen and (max-width: 600px) { + margin-left: 0; + margin-top: 4px; + } + } + + button { + background: transparent; + border: none; + color: ${(props) => (props.jobIsSaved ? "#1e86ff" : "#b9bdcf")}; + margin: 0; + margin-left: 15px; + padding: 0; + + :hover { + color: #1e86ff; + cursor: pointer; + } + } + } +`; + +const DetailsCreatedContainer = styled.div` + align-items: center; + display: flex; + align-items: start; + margin-left: 0; + margin-top: 10px; + + i { + color: #b9bdcf; + font-size: 15px; + margin-right: 7.5px;s + } + + p { + color: #b9bdcf; + font-size: 12px; + font-weight: 500; + line-height: 14px; + margin-bottom: 0; + margin-top: 0; + } +`; + +const DetailsCompanyContainer = styled.div` + display: flex; + flex-direction: row; + margin-top: 32px; +`; + +const DetailsLogoContainer = styled.div` + background-color: #f2f2f2; + border-radius: 4px; + height: 42px; + margin-right: 12px; + width: 42px; + + div { + align-items: center; + display: flex; + height: 100%; + justify-content: center; + text-align: center; + width: 100%; + + p { + color: #bdbdbd; + font-size: 8px; + font-weight: 500; + line-height: 14px; + } + } + + img { + height: 42px; + object-fit: contain; + width: 42px; + } +`; + +const DetailsCompanyRightContainer = styled.div` + display: flex; + flex-direction: column; + + a { + color: #334680; + font-family: Roboto; + font-size: 18px; + font-style: normal; + font-weight: bold; + line-height: 21px; + margin: 0; + text-decoration: none; + } + + div { + align-items: flex-start; + display: flex; + margin-top: 10px; + + i { + color: #b9bdcf; + font-size: 15px; + margin-right: 5px; + } + + p { + color: #b9bdcf; + font-size: 12px; + font-weight: 500; + line-height: 14px; + margin-top: 0; + } + } + + p { + color: #334680; + font-family: Roboto; + font-size: 18px; + font-style: normal; + font-weight: bold; + line-height: 21px; + margin: 0; + text-decoration: none; + } +`; + +const DetailsContainerDescription = styled.div` + color: #334680; + font-family: Roboto; + font-style: normal; + font-weight: normal; + font-size: 16px; + line-height: 24px; +`; + +export { + DetailsContainer, + DetailsSideContainer, + DetailsHowToContainer, + DetailsHowToLabel, + DetailsMainContainer, + DetailsMainTitleContainer, + DetailsMainInnerTitleContainer, + DetailsCreatedContainer, + DetailsCompanyContainer, + DetailsLogoContainer, + DetailsCompanyRightContainer, + DetailsContainerDescription, +}; diff --git a/src/client/pages/Details/Details.tsx b/src/client/pages/Details/Details.tsx new file mode 100644 index 0000000..ed0fe01 --- /dev/null +++ b/src/client/pages/Details/Details.tsx @@ -0,0 +1,209 @@ +import * as React from "react"; +import { connect } from "react-redux"; +import formatDistanceToNow from "date-fns/formatDistanceToNow"; +import { useParams, Link } from "react-router-dom"; + +import Copyright from "../../components/Copyright"; + +import { + DetailsContainer, + DetailsSideContainer, + DetailsHowToContainer, + DetailsHowToLabel, + DetailsMainContainer, + DetailsMainTitleContainer, + DetailsMainInnerTitleContainer, + DetailsCreatedContainer, + DetailsCompanyContainer, + DetailsLogoContainer, + DetailsCompanyRightContainer, + DetailsContainerDescription, +} from "./Details-styled"; + +import { addSavedJob, getJobDetails, removeSavedJob } from "../../redux/thunks"; + +import { Job, RootState } from "../../types"; + +interface DetailsProps { + handleAddSavedJob: (id: string) => void; + handleGetJobDetails: (id: string) => void; + handleRemoveSavedJob: (id: string) => void; + jobDetails: Job; + isLoggedIn: boolean; + savedJobs: string[]; +} + +const Details: React.SFC = (props: DetailsProps) => { + const { id } = useParams(); + const { + handleAddSavedJob, + handleGetJobDetails, + handleRemoveSavedJob, + jobDetails, + isLoggedIn, + savedJobs, + } = props; + + const [applyLink, setApplyLink] = React.useState(""); + + React.useEffect((): void => { + window.scrollTo(0, 0); + handleGetJobDetails(id); + }, []); + + const jobIsSaved = + savedJobs && jobDetails + ? savedJobs.findIndex( + (savedJobID: string) => savedJobID === jobDetails.id + ) >= 0 + : false; + + React.useEffect((): void => { + if (jobDetails) { + const isPlainLink = jobDetails.how_to_apply.slice(0, 5) === "

    /gm)[0]; + + setApplyLink(href); + } + } + }, [jobDetails]); + + return ( + <> + + + + west + Back to search + + + + How to Apply + + {jobDetails && + (applyLink ? ( + + link + Apply + + ) : ( +

    + ))} + + + + + {jobDetails && ( + <> + + +

    {jobDetails.title}

    +
    + {jobDetails.type === "Full Time" && ( +

    Full Time

    + )} + {isLoggedIn && ( + + )} +
    +
    + + + access_time +

    + {formatDistanceToNow(new Date(jobDetails.created_at), { + addSuffix: true, + })} +

    +
    +
    + + + + {jobDetails.company_logo ? ( + Company Logo + ) : ( +
    +

    not found

    +
    + )} +
    + + + {jobDetails.company_url ? ( + + {jobDetails.company} + + ) : ( +

    {jobDetails.company}

    + )} + +
    + public +

    {jobDetails.location}

    +
    +
    +
    + + +
    + + + )} + + + + + ); +}; + +const mapStateToProps = (state: RootState) => ({ + isLoggedIn: state.user.isLoggedIn, + jobDetails: state.application.jobDetails, + savedJobs: state.user.savedJobs, +}); + +const mapDispatchToProps = (dispatch) => ({ + handleAddSavedJob: (id: string) => dispatch(addSavedJob(id)), + handleGetJobDetails: (id: string) => dispatch(getJobDetails(id)), + handleRemoveSavedJob: (id: string) => dispatch(removeSavedJob(id)), +}); + +export default connect(mapStateToProps, mapDispatchToProps)(Details); diff --git a/src/client/pages/Details/index.ts b/src/client/pages/Details/index.ts new file mode 100644 index 0000000..ee80bfc --- /dev/null +++ b/src/client/pages/Details/index.ts @@ -0,0 +1 @@ +export { default } from "./Details"; diff --git a/src/client/pages/Login/Login-styled.tsx b/src/client/pages/Login/Login-styled.tsx new file mode 100644 index 0000000..6672059 --- /dev/null +++ b/src/client/pages/Login/Login-styled.tsx @@ -0,0 +1,87 @@ +import styled from "styled-components"; + +const LoginContainer = styled.div` + align-items: center; + display: flex; + flex-direction: column; + + form { + max-width: 444px; + width: 50%; + + @media only screen and (max-width: 600px) { + width: 100%; + } + } +`; + +const LoginTitleContainer = styled.div` + align-items: center; + display: flex; + flex-direction: column; + + h1 { + color: #282538; + font-family: Poppins; + font-style: normal; + font-weight: 200; + font-size: 24px; + line-height: 36px; + margin: 0; + } + + span { + align-items: center; + background: #1e86ff; + border-radius: 50%; + display: flex; + height: 40px; + justify-content: center; + margin: 8px; + width: 40px; + + i { + color: #ffffff; + } + } +`; + +const LoginActionsContainer = styled.div` + align-items: center; + display: flex; + flex-direction: row; + justify-content: space-between; + + a { + align-items: center; + color: #1e86ff; + display: flex; + font-family: Poppins, sans-serif; + font-size: 14px; + font-weight: 500; + line-height: 21px; + justify-content: flex-start; + text-decoration: none; + + :hover { + span { + text-decoration: underline; + } + + i { + text-decoration: none; + } + } + + i { + font-size: 18px; + margin-right: 10px; + } + } + + @media only screen and (max-width: 600px) { + flex-direction: column; + } +`; + +export { LoginContainer, LoginTitleContainer, LoginActionsContainer }; diff --git a/src/client/pages/Login.tsx b/src/client/pages/Login/Login.tsx similarity index 67% rename from src/client/pages/Login.tsx rename to src/client/pages/Login/Login.tsx index cbbfa4a..2866030 100644 --- a/src/client/pages/Login.tsx +++ b/src/client/pages/Login/Login.tsx @@ -2,19 +2,23 @@ import * as React from "react"; import { connect } from "react-redux"; import { Link, Redirect } from "react-router-dom"; -import Button from "../components/Button"; -import Copyright from "../components/Copyright"; -import Notification from "../components/Notification"; -import Input from "../components/Input"; +import Button from "../../components/Button"; +import Copyright from "../../components/Copyright"; +import Input from "../../components/Input"; -import { setEmail, setPassword } from "../redux/actions/user"; -import { logIn } from "../redux/thunks"; +import { + LoginContainer, + LoginTitleContainer, + LoginActionsContainer, +} from "./Login-styled"; -import { RootState } from "../types"; +import { setEmail, setPassword } from "../../redux/actions/user"; +import { logIn } from "../../redux/thunks"; + +import { RootState } from "../../types"; export interface LoginProps { email: string; - notificationMessage: string; handleEmailChange: (email: string) => void; handleLogIn: () => void; handlePasswordChange: (password: string) => void; @@ -25,7 +29,6 @@ export interface LoginProps { const Login: React.SFC = (props: LoginProps) => { const { email, - notificationMessage, handleEmailChange, handleLogIn, handlePasswordChange, @@ -37,26 +40,23 @@ const Login: React.SFC = (props: LoginProps) => { return ; } else { return ( -
    +
    { e.preventDefault(); handleLogIn(); }} > -
    - + + lock

    Login

    -
    - - {notificationMessage && ( - - )} + = (props: LoginProps) => { /> = (props: LoginProps) => { value={password} /> -
    - + + account_circle Create an account -
    +
    + ); } }; const mapStateToProps = (state: RootState) => ({ email: state.user.email, - notificationMessage: state.application.notificationMessage, isLoggedIn: state.user.isLoggedIn, password: state.user.password, }); diff --git a/src/client/pages/Login/index.ts b/src/client/pages/Login/index.ts new file mode 100644 index 0000000..cd3a8ca --- /dev/null +++ b/src/client/pages/Login/index.ts @@ -0,0 +1 @@ +export { default } from "./Login"; diff --git a/src/client/pages/Profile.tsx b/src/client/pages/Profile/Profile.tsx similarity index 62% rename from src/client/pages/Profile.tsx rename to src/client/pages/Profile/Profile.tsx index 5488a8d..b5152e7 100644 --- a/src/client/pages/Profile.tsx +++ b/src/client/pages/Profile/Profile.tsx @@ -2,14 +2,21 @@ import * as React from "react"; import { connect } from "react-redux"; import { Redirect } from "react-router-dom"; -import Notification from "../components/Notification"; -import ProfileDelete from "../components/ProfileDelete"; -import ProfileDisplay from "../components/ProfileDisplay"; -import ProfileEdit from "../components/ProfileEdit"; -import ProfileReset from "../components/ProfileReset"; -import ProfileSavedJobs from "../components/ProfileSavedJobs"; +import { + ProfileDelete, + ProfileDisplay, + ProfileEdit, + ProfileReset, + ProfileSavedJobs, +} from "../../components/Profile"; -import { NotificationType, RootState } from "../types"; +import { + ProfilePage, + ProfileForm, + ProfileTitleContainer, +} from "../../components/Profile/Profile-styled"; + +import { RootState } from "../../types"; export interface ProfileProps { isDeletingProfile: boolean; @@ -17,8 +24,6 @@ export interface ProfileProps { isLoggedIn: boolean; isResettingPassword: boolean; isViewingSavedJobs: boolean; - notificationMessage: string; - notificationType: NotificationType; } const Profile: React.SFC = (props: ProfileProps) => { @@ -28,8 +33,6 @@ const Profile: React.SFC = (props: ProfileProps) => { isLoggedIn, isResettingPassword, isViewingSavedJobs, - notificationMessage, - notificationType, } = props; let heading = "Profile"; @@ -48,21 +51,17 @@ const Profile: React.SFC = (props: ProfileProps) => { return ; } else { return ( -
    -
    -
    - + + e.preventDefault()} + > + + account_circle

    {heading}

    -
    - - {notificationMessage && ( - - )} + {isResettingPassword && } @@ -76,8 +75,8 @@ const Profile: React.SFC = (props: ProfileProps) => { !isEditingProfile && !isDeletingProfile && !isViewingSavedJobs && } - -
    + + ); } }; @@ -88,8 +87,6 @@ const mapStateToProps = (state: RootState) => ({ isLoggedIn: state.user.isLoggedIn, isResettingPassword: state.user.isResettingPassword, isViewingSavedJobs: state.user.isViewingSavedJobs, - notificationMessage: state.application.notificationMessage, - notificationType: state.application.notificationType, }); export default connect(mapStateToProps)(Profile); diff --git a/src/client/pages/Profile/index.ts b/src/client/pages/Profile/index.ts new file mode 100644 index 0000000..2623c86 --- /dev/null +++ b/src/client/pages/Profile/index.ts @@ -0,0 +1 @@ +export { default } from "./Profile"; diff --git a/src/client/pages/Search/Search-styled.tsx b/src/client/pages/Search/Search-styled.tsx new file mode 100644 index 0000000..a6246f0 --- /dev/null +++ b/src/client/pages/Search/Search-styled.tsx @@ -0,0 +1,31 @@ +import styled from "styled-components"; + +const SearchContainer = styled.div` + display: flex; + margin-top: 42px; + @media only screen and (max-width: 800px) { + flex-direction: column; + } + + @media only screen and (max-width: 600px) { + flex-direction: column; + } +`; + +const SearchJobsContainer = styled.div` + width: 75%; + + @media only screen and (max-width: 800px) { + width: 100%; + } + + @media only screen and (max-width: 600px) { + width: 100%; + } +`; + +const SearchNoResults = styled.p` + text-align: center; +`; + +export { SearchContainer, SearchJobsContainer, SearchNoResults }; diff --git a/src/client/pages/Search.tsx b/src/client/pages/Search/Search.tsx similarity index 53% rename from src/client/pages/Search.tsx rename to src/client/pages/Search/Search.tsx index 57f03e8..05be43f 100644 --- a/src/client/pages/Search.tsx +++ b/src/client/pages/Search/Search.tsx @@ -1,31 +1,28 @@ import * as React from "react"; import { connect } from "react-redux"; -import Copyright from "../components/Copyright"; -import JobCard from "../components/JobCard"; -import Notification from "../components/Notification"; -import OptionsPanel from "../components/OptionsPanel"; -import Pagination from "../components/Pagination"; -import SearchInput from "../components/SearchInput"; +import Copyright from "../../components/Copyright"; +import JobCard from "../../components/JobCard"; +import OptionsPanel from "../../components/OptionsPanel"; +import Pagination from "../../components/Pagination"; +import SearchInput from "../../components/SearchInput"; -import { Job, LocationOption, NotificationType, RootState } from "../types"; +import { + SearchContainer, + SearchJobsContainer, + SearchNoResults, +} from "./Search-styled"; + +import { Job, LocationOption, RootState } from "../../types"; export interface SearchProps { currentJobs: Job[]; currentPage: number; - notificationMessage: string; - notificationType: NotificationType; totalPages: number; } const Search: React.SFC = (props: SearchProps) => { - const { - currentJobs, - currentPage, - notificationMessage, - notificationType, - totalPages, - } = props; + const { currentJobs, currentPage, totalPages } = props; const jobsOnPage = currentJobs.slice(currentPage * 5 - 5, currentPage * 5); @@ -35,10 +32,10 @@ const Search: React.SFC = (props: SearchProps) => { const [location4, setLocation4] = React.useState(""); const locationOptions: LocationOption[] = [ - { name: "location1", setter: setLocation1, value: location1 }, - { name: "location2", setter: setLocation2, value: location2 }, - { name: "location3", setter: setLocation3, value: location3 }, - { name: "location4", setter: setLocation4, value: location4 }, + { name: "Chicago", setter: setLocation1, value: location1 }, + { name: "Los Angeles", setter: setLocation2, value: location2 }, + { name: "New York City", setter: setLocation3, value: location3 }, + { name: "San Francisco", setter: setLocation4, value: location4 }, ]; const handleCheckBox = (e) => { @@ -56,27 +53,24 @@ const Search: React.SFC = (props: SearchProps) => { return ( <> -
    - -
    - {notificationMessage && ( - - )} + + + {jobsOnPage && jobsOnPage.map((job: Job) => )} {jobsOnPage.length > 0 && ( )} {jobsOnPage.length === 0 && ( -

    + No results. Please modify your search and try again. -

    + )} -
    -
    + + ); @@ -85,8 +79,6 @@ const Search: React.SFC = (props: SearchProps) => { const mapStateToProps = (state: RootState) => ({ currentJobs: state.application.currentJobs, currentPage: state.application.currentPage, - notificationMessage: state.application.notificationMessage, - notificationType: state.application.notificationType, totalPages: state.application.totalPages, }); diff --git a/src/client/pages/Search/index.ts b/src/client/pages/Search/index.ts new file mode 100644 index 0000000..4ff9149 --- /dev/null +++ b/src/client/pages/Search/index.ts @@ -0,0 +1 @@ +export { default } from "./Search"; diff --git a/src/client/pages/Signup/Signup-styled.tsx b/src/client/pages/Signup/Signup-styled.tsx new file mode 100644 index 0000000..a09def8 --- /dev/null +++ b/src/client/pages/Signup/Signup-styled.tsx @@ -0,0 +1,60 @@ +import styled from "styled-components"; + +const SignupContainer = styled.div` + align-items: center; + display: flex; + flex-direction: column; + + form { + max-width: 444px; + width: 50%; + + @media only screen and (max-width: 600px) { + width: 100%; + } + } +`; + +const SignupTitleContainer = styled.div` + align-items: center; + display: flex; + flex-direction: column; + + h1 { + color: #282538; + font-family: Poppins; + font-style: normal; + font-weight: 200; + font-size: 24px; + line-height: 36px; + margin: 0; + } + + span { + align-items: center; + background: #1e86ff; + border-radius: 50%; + display: flex; + height: 40px; + justify-content: center; + margin: 8px; + width: 40px; + + i { + color: #ffffff; + } + } +`; + +const SignupActionsContainer = styled.div` + align-items: center; + display: flex; + flex-direction: row; + justify-content: center; + + @media only screen and (max-width: 600px) { + flex-direction: column; + } +`; + +export { SignupContainer, SignupTitleContainer, SignupActionsContainer }; diff --git a/src/client/pages/Signup.tsx b/src/client/pages/Signup/Signup.tsx similarity index 80% rename from src/client/pages/Signup.tsx rename to src/client/pages/Signup/Signup.tsx index d989acd..1e6806d 100644 --- a/src/client/pages/Signup.tsx +++ b/src/client/pages/Signup/Signup.tsx @@ -2,25 +2,29 @@ import * as React from "react"; import { connect } from "react-redux"; import { Redirect } from "react-router-dom"; -import Button from "../components/Button"; -import Copyright from "../components/Copyright"; -import Notification from "../components/Notification"; -import Input from "../components/Input"; +import Button from "../../components/Button"; +import Copyright from "../../components/Copyright"; +import Input from "../../components/Input"; + +import { + SignupContainer, + SignupTitleContainer, + SignupActionsContainer, +} from "./Signup-styled"; import { setConfirmPassword, setEmail, setName, setPassword, -} from "../redux/actions/user"; -import { signup } from "../redux/thunks"; +} from "../../redux/actions/user"; +import { signup } from "../../redux/thunks"; -import { RootState } from "../types"; +import { RootState } from "../../types"; export interface SignupProps { confirmPassword: string; email: string; - notificationMessage: string; handleConfirmPasswordChange: (confirmPassword: string) => void; handleEmailChange: (email: string) => void; handleNameChange: (name: string) => void; @@ -35,7 +39,6 @@ const Signup: React.SFC = (props: SignupProps) => { const { confirmPassword, email, - notificationMessage, handleConfirmPasswordChange, handleEmailChange, handleNameChange, @@ -50,26 +53,23 @@ const Signup: React.SFC = (props: SignupProps) => { return ; } else { return ( -
    +
    { e.preventDefault(); handleSignup(); }} > -
    - + + lock

    Create Account

    -
    - - {notificationMessage && ( - - )} + = (props: SignupProps) => { /> = (props: SignupProps) => { /> = (props: SignupProps) => { /> = (props: SignupProps) => { value={confirmPassword} /> -
    +
    + -
    + ); } }; @@ -129,7 +132,6 @@ const Signup: React.SFC = (props: SignupProps) => { const mapStateToProps = (state: RootState) => ({ confirmPassword: state.user.confirmPassword, email: state.user.email, - notificationMessage: state.application.notificationMessage, isLoggedIn: state.user.isLoggedIn, name: state.user.name, password: state.user.password, diff --git a/src/client/pages/Signup/index.ts b/src/client/pages/Signup/index.ts new file mode 100644 index 0000000..6480e37 --- /dev/null +++ b/src/client/pages/Signup/index.ts @@ -0,0 +1 @@ +export { default } from "./Signup"; diff --git a/src/client/redux/actionTypes.ts b/src/client/redux/actionTypes.ts index b4e535c..231d640 100644 --- a/src/client/redux/actionTypes.ts +++ b/src/client/redux/actionTypes.ts @@ -1,10 +1,11 @@ // * Application +export const DISPLAY_NOTIFICATION = "DISPLAY_NOTIFICATION"; export const SET_CURRENT_JOBS = "SET_CURRENT_JOBS"; export const SET_CURRENT_PAGE = "SET_CURRENT_PAGE"; export const SET_FULL_TIME = "SET_FULL_TIME"; export const SET_IS_LOADING = "SET_IS_LOADING"; +export const SET_JOB_DETAILS = "SET_JOB_DETAILS"; export const SET_JOBS = "SET_JOBS"; -export const SET_JOBS_FETCHED_AT = "SET_JOBS_FETCHED_AT"; export const SET_LOCATION_SEARCH = "SET_LOCATION_SEARCH"; export const SET_SEARCH_VALUE = "SET_SEARCH_VALUE"; export const SET_TOTAL_PAGES = "SET_TOTAL_PAGES"; @@ -20,12 +21,11 @@ export const SET_IS_LOGGED_IN = "SET_IS_LOGGED_IN"; export const SET_IS_RESETTING_PASSWORD = "SET_IS_RESETTING_PASSWORD"; export const SET_IS_VIEWING_SAVED_JOBS = "SET_IS_VIEWING_SAVED_JOBS"; export const SET_NAME = "SET_NAME"; -export const SET_NOTIFICATION_MESSAGE = "SET_NOTIFICATION_MESSAGE"; -export const SET_NOTIFICATION_TYPE = "SET_NOTIFICATION_TYPE"; export const SET_PASSWORD = "SET_PASSWORD"; export const SET_RESET_CONFIRM_NEW_PASSWORD = "SET_RESET_CONFIRM_NEW_PASSWORD"; export const SET_RESET_CURRENT_PASSWORD = "SET_RESET_CURRENT_PASSWORD"; export const SET_RESET_NEW_PASSWORD = "SET_RESET_NEW_PASSWORD"; export const SET_SAVED_JOBS = "SET_SAVED_JOBS"; export const SET_SAVED_JOBS_CURRENT_PAGE = "SET_SAVED_JOBS_CURRENT_PAGE"; +export const SET_SAVED_JOBS_DETAILS = "SET_SAVED_JOBS_DETAILS"; export const SET_SAVED_JOBS_TOTAL_PAGES = "SET_SAVED_JOBS_TOTAL_PAGES"; diff --git a/src/client/redux/actions/application.ts b/src/client/redux/actions/application.ts index dd309dc..af48ad3 100644 --- a/src/client/redux/actions/application.ts +++ b/src/client/redux/actions/application.ts @@ -1,19 +1,26 @@ import { - SET_JOBS, - SET_JOBS_FETCHED_AT, - SET_FULL_TIME, - SET_IS_LOADING, + DISPLAY_NOTIFICATION, SET_CURRENT_JOBS, SET_CURRENT_PAGE, + SET_FULL_TIME, + SET_IS_LOADING, + SET_JOB_DETAILS, + SET_JOBS, SET_LOCATION_SEARCH, - SET_NOTIFICATION_MESSAGE, - SET_NOTIFICATION_TYPE, SET_SEARCH_VALUE, SET_TOTAL_PAGES, } from "../actionTypes"; import { ApplicationAction, Job, NotificationType } from "../../types"; +export const displayNotification = ( + notificationMessage: string, + notificationType: NotificationType +): ApplicationAction => ({ + type: DISPLAY_NOTIFICATION, + payload: { notificationMessage, notificationType }, +}); + export const setCurrentJobs = (currentJobs: Job[]): ApplicationAction => ({ type: SET_CURRENT_JOBS, payload: { currentJobs }, @@ -34,16 +41,16 @@ export const setIsLoading = (isLoading: boolean): ApplicationAction => ({ payload: { isLoading }, }); +export const setJobDetails = (jobDetails: Job): ApplicationAction => ({ + type: SET_JOB_DETAILS, + payload: { jobDetails }, +}); + export const setJobs = (jobs: Job[]): ApplicationAction => ({ type: SET_JOBS, payload: { jobs }, }); -export const setJobsFetchedAt = (jobsFetchedAt: string): ApplicationAction => ({ - type: SET_JOBS_FETCHED_AT, - payload: { jobsFetchedAt }, -}); - export const setLocationSearch = ( locationSearch: string ): ApplicationAction => ({ @@ -51,20 +58,6 @@ export const setLocationSearch = ( payload: { locationSearch }, }); -export const setNotificationMessage = ( - notificationMessage: string -): ApplicationAction => ({ - type: SET_NOTIFICATION_MESSAGE, - payload: { notificationMessage }, -}); - -export const setNotificationType = ( - notificationType: NotificationType -): ApplicationAction => ({ - type: SET_NOTIFICATION_TYPE, - payload: { notificationType }, -}); - export const setSearchValue = (searchValue: string): ApplicationAction => ({ type: SET_SEARCH_VALUE, payload: { searchValue }, diff --git a/src/client/redux/actions/user.ts b/src/client/redux/actions/user.ts index 705bc21..3a9cefe 100644 --- a/src/client/redux/actions/user.ts +++ b/src/client/redux/actions/user.ts @@ -15,10 +15,11 @@ import { SET_RESET_NEW_PASSWORD, SET_SAVED_JOBS, SET_SAVED_JOBS_CURRENT_PAGE, + SET_SAVED_JOBS_DETAILS, SET_SAVED_JOBS_TOTAL_PAGES, } from "../actionTypes"; -import { Job, UserAction } from "../../types"; +import { UserAction, Job } from "../../types"; export const setConfirmPassword = (confirmPassword: string): UserAction => ({ type: SET_CONFIRM_PASSWORD, @@ -100,7 +101,7 @@ export const setResetNewPassword = (resetNewPassword: string): UserAction => ({ payload: { resetNewPassword }, }); -export const setSavedJobs = (savedJobs: Job[]): UserAction => ({ +export const setSavedJobs = (savedJobs: string[]): UserAction => ({ type: SET_SAVED_JOBS, payload: { savedJobs }, }); @@ -112,6 +113,11 @@ export const setSavedJobsCurrentPage = ( payload: { savedJobsCurrentPage }, }); +export const setSavedJobsDetails = (savedJobsDetails: Job[]): UserAction => ({ + type: SET_SAVED_JOBS_DETAILS, + payload: { savedJobsDetails }, +}); + export const setSavedJobsTotalPages = ( savedJobsTotalPages: number ): UserAction => ({ diff --git a/src/client/redux/reducers/application.ts b/src/client/redux/reducers/application.ts index c9b050b..7c41594 100644 --- a/src/client/redux/reducers/application.ts +++ b/src/client/redux/reducers/application.ts @@ -1,13 +1,14 @@ +import { toast } from "react-toastify"; + import { + DISPLAY_NOTIFICATION, SET_CURRENT_JOBS, SET_CURRENT_PAGE, SET_FULL_TIME, SET_IS_LOADING, + SET_JOB_DETAILS, SET_JOBS, - SET_JOBS_FETCHED_AT, SET_LOCATION_SEARCH, - SET_NOTIFICATION_MESSAGE, - SET_NOTIFICATION_TYPE, SET_SEARCH_VALUE, SET_TOTAL_PAGES, } from "../actionTypes"; @@ -19,11 +20,11 @@ export const initialState: ApplicationState = { currentPage: 1, fullTime: false, isLoading: true, + jobDetails: null, jobs: [], - jobsFetchedAt: null, locationSearch: "", notificationMessage: "", - notificationType: "info", + notificationType: "default", searchValue: "", totalPages: 1, }; @@ -41,15 +42,37 @@ const reducer = ( } switch (action.type) { + case DISPLAY_NOTIFICATION: { + const { notificationMessage, notificationType } = action.payload; + if (notificationMessage) { + let autoClose: boolean | number = 5000; + if (notificationType === "error" || notificationType === "warning") { + autoClose = false; + } + toast(notificationMessage, { + autoClose, + toastId: "notification", + type: notificationType, + }); + } else { + // * If displayNotification() is called with `notificationMessage` === "", + // * Clear all notifications + toast.dismiss(); + } + + return { + ...state, + notificationMessage, + notificationType, + }; + } case SET_CURRENT_JOBS: case SET_CURRENT_PAGE: case SET_FULL_TIME: case SET_IS_LOADING: + case SET_JOB_DETAILS: case SET_JOBS: - case SET_JOBS_FETCHED_AT: case SET_LOCATION_SEARCH: - case SET_NOTIFICATION_MESSAGE: - case SET_NOTIFICATION_TYPE: case SET_SEARCH_VALUE: case SET_TOTAL_PAGES: { return { ...state, [key]: value }; diff --git a/src/client/redux/reducers/user.ts b/src/client/redux/reducers/user.ts index ff527b6..243b649 100644 --- a/src/client/redux/reducers/user.ts +++ b/src/client/redux/reducers/user.ts @@ -15,6 +15,7 @@ import { SET_RESET_NEW_PASSWORD, SET_SAVED_JOBS, SET_SAVED_JOBS_CURRENT_PAGE, + SET_SAVED_JOBS_DETAILS, SET_SAVED_JOBS_TOTAL_PAGES, } from "../actionTypes"; @@ -37,6 +38,7 @@ export const initialState: UserState = { resetNewPassword: "", savedJobs: [], savedJobsCurrentPage: 1, + savedJobsDetails: [], savedJobsTotalPages: 1, }; @@ -66,6 +68,7 @@ const reducer = (state = initialState, action: UserAction): UserState => { case SET_RESET_NEW_PASSWORD: case SET_SAVED_JOBS: case SET_SAVED_JOBS_CURRENT_PAGE: + case SET_SAVED_JOBS_DETAILS: case SET_SAVED_JOBS_TOTAL_PAGES: { return { ...state, [key]: value }; } diff --git a/src/client/redux/thunks.ts b/src/client/redux/thunks.ts index ce24d10..48927a1 100644 --- a/src/client/redux/thunks.ts +++ b/src/client/redux/thunks.ts @@ -1,17 +1,12 @@ -import endOfToday from "date-fns/endOfToday"; -import isWithinInterval from "date-fns/isWithinInterval"; -import startOfToday from "date-fns/startOfToday"; - import { + displayNotification, setCurrentJobs, setCurrentPage, setIsLoading, setJobs, - setJobsFetchedAt, setSearchValue, setTotalPages, - setNotificationMessage, - setNotificationType, + setJobDetails, } from "./actions/application"; import { setConfirmPassword, @@ -30,34 +25,49 @@ import { setResetNewPassword, setSavedJobs, setSavedJobsCurrentPage, + setSavedJobsDetails, setSavedJobsTotalPages, } from "./actions/user"; -import { fetchServerData, unique } from "../util"; +import { fetchServerData, isError } from "../util"; import { - AddSavedJobResponse, AppThunk, DeleteProfileResponse, EditProfileResponse, + GetJobsErrorResponse, + GetJobsSuccessResponse, + GetSavedJobsDetailsErrorResponse, + GetSavedJobsDetailsSuccessResponse, Job, LocationOption, LoginResponse, - RemoveSavedJobResponse, ResetPasswordResponse, RootState, ServerResponseUser, - SignupResponse, + AddSavedJobErrorResponse, + AddSavedJobSuccessResponse, + SignupErrorResponse, + SignupSuccessResponse, + RemoveSavedJobErrorResponse, + RemoveSavedJobSuccessResponse, } from "../types"; export const getJobs = (): AppThunk => async (dispatch) => { try { - const jobs: Job[] = await fetchServerData("/jobs", "GET"); + const result = (await fetchServerData("/jobs", "GET")) as + | GetJobsErrorResponse + | GetJobsSuccessResponse; + + if (isError(result)) { + dispatch(displayNotification(result.error, "error")); + dispatch(setIsLoading(false)); + return; + } - dispatch(setJobs(jobs)); - dispatch(setJobsFetchedAt(new Date().toString())); + dispatch(setJobs(result)); dispatch(setCurrentPage(1)); - dispatch(setTotalPages(Math.ceil(jobs.length / 5))); - dispatch(setCurrentJobs(jobs)); + dispatch(setTotalPages(Math.ceil(result.length / 5))); + dispatch(setCurrentJobs(result)); dispatch(setIsLoading(false)); } catch (error) { console.error(error); @@ -69,12 +79,12 @@ export const searchJobs = ( locationOptions: LocationOption[] ): AppThunk => async (dispatch, getState) => { dispatch(setIsLoading(true)); + dispatch(displayNotification("", "default")); dispatch(setSearchValue(search)); + const state: RootState = getState(); const { fullTime, locationSearch } = state.application; - const jobs = []; - const locationsSearches = locationOptions.filter( (location: LocationOption) => location.value !== "" ); @@ -87,38 +97,30 @@ export const searchJobs = ( }); } - // * Since location options have to be a thing for the challenge - // * Make as many requests as locations (since you can only have 1 location per request) - // * And push all the results into one array - await Promise.all( - locationsSearches.map(async (location: LocationOption) => { - const url = `/jobs/search?full_time=${encodeURI( - fullTime.toString() - )}&description=${encodeURI(search)}&location=${encodeURI( - location.value - )}`; - const data = await fetchServerData(url, "GET"); - jobs.push(...data); - }) - ); + let url = `/jobs/search?full_time=${encodeURI( + fullTime.toString() + )}&description=${encodeURI(search)}`; - if (locationsSearches.length === 0) { - const url = `/jobs/search?full_time=${encodeURI( - fullTime.toString() - )}&description=${encodeURI(search)}`; - const data = await fetchServerData(url, "GET"); - jobs.push(...data); - } + locationsSearches.forEach((locationSearch: LocationOption, i: number) => { + url = url + `&location${i + 1}=${encodeURI(locationSearch.value)}`; + }); - const uniqueJobs = unique(jobs); + const data = (await fetchServerData(url, "GET")) as + | GetJobsErrorResponse + | GetJobsSuccessResponse; - const finalJobs = uniqueJobs.filter((job: Job) => - fullTime ? job.type === "Full Time" : job - ); + if (isError(data)) { + dispatch(displayNotification(data.error, "error")); + dispatch(setIsLoading(false)); + return; + } - dispatch(setCurrentJobs(finalJobs)); + dispatch(setCurrentJobs(data)); dispatch(setCurrentPage(1)); - dispatch(setTotalPages(Math.ceil(finalJobs.length / 5))); + dispatch(setTotalPages(Math.ceil(data.length / 5))); + dispatch( + displayNotification(`Search returned ${data.length} results.`, "success") + ); dispatch(setIsLoading(false)); }; @@ -128,11 +130,12 @@ export const pagination = (pageNumber: number): AppThunk => (dispatch) => { export const logIn = (): AppThunk => async (dispatch, getState) => { dispatch(setIsLoading(true)); - dispatch(setNotificationMessage("")); + dispatch(displayNotification("", "default")); const { user } = getState(); const { email, password } = user; + // TODO - Modify const response: LoginResponse = await fetchServerData( "/user/login", "POST", @@ -140,8 +143,7 @@ export const logIn = (): AppThunk => async (dispatch, getState) => { ); if (response.error) { - dispatch(setNotificationType("error")); - dispatch(setNotificationMessage(response.error)); + dispatch(displayNotification(response.error, "error")); dispatch(setIsLoading(false)); return; } @@ -156,63 +158,48 @@ export const logIn = (): AppThunk => async (dispatch, getState) => { export const signup = (): AppThunk => async (dispatch, getState) => { dispatch(setIsLoading(true)); - dispatch(setNotificationMessage("")); + dispatch(displayNotification("", "default")); const { user } = getState(); const { confirmPassword, email, name, password } = user; if (confirmPassword !== password) { - dispatch(setNotificationType("error")); - dispatch(setNotificationMessage("Passwords do not match.")); + dispatch(displayNotification("Passwords do not match.", "error")); dispatch(setIsLoading(false)); return; } - const response: SignupResponse = await fetchServerData( + // TODO - Modify + const result: + | SignupErrorResponse + | SignupSuccessResponse = await fetchServerData( "/user", "POST", JSON.stringify({ confirmPassword, email, name, password }) ); - if (response.error) { - dispatch(setNotificationType("error")); - dispatch(setNotificationMessage(response.error)); + if (isError(result)) { + dispatch(displayNotification(result.error, "error")); dispatch(setIsLoading(false)); return; } dispatch(setIsLoggedIn(true)); - dispatch(setEmail(response.email)); - dispatch(setName(response.name)); + dispatch(setEmail(result.email)); + dispatch(setName(result.name)); dispatch(setPassword("")); dispatch(setConfirmPassword("")); - dispatch(setSavedJobs(response.savedJobs)); + dispatch(setSavedJobs(result.savedJobs)); dispatch(setIsLoading(false)); }; -export const initializeApplication = (): AppThunk => async ( - dispatch, - getState -) => { +export const initializeApplication = (): AppThunk => async (dispatch) => { dispatch(setIsLoading(true)); - dispatch(setNotificationType("info")); - dispatch(setNotificationMessage("")); - const state: RootState = getState(); - const { jobsFetchedAt } = state.application; - // * Establish Job Data - if (jobsFetchedAt) { - const isWithinToday = isWithinInterval(new Date(jobsFetchedAt), { - start: startOfToday(), - end: endOfToday(), - }); + dispatch(displayNotification("", "default")); - if (!isWithinToday) { - dispatch(getJobs()); - } - } else { - dispatch(getJobs()); - } + // * Establish Job Data + dispatch(getJobs()); // * Establish User Authentication dispatch(checkAuthentication()); @@ -237,14 +224,15 @@ export const checkAuthentication = (): AppThunk => async (dispatch) => { export const logOut = (): AppThunk => async (dispatch) => { dispatch(setIsLoading(true)); + // TODO - Modify const response = await fetchServerData("/user/logout", "POST"); if (response.error) { console.error(response.error); - dispatch(setNotificationType("error")); dispatch( - setNotificationMessage( - "Error when attempting to log out. Please try again or contact the developer." + displayNotification( + "Error when attempting to log out. Please try again or contact the developer.", + "error" ) ); return; @@ -252,7 +240,7 @@ export const logOut = (): AppThunk => async (dispatch) => { dispatch(setConfirmPassword("")); dispatch(setEmail("")); - dispatch(setNotificationMessage("")); + dispatch(displayNotification("", "default")); dispatch(setName("")); dispatch(setPassword("")); dispatch(setSavedJobs([])); @@ -263,14 +251,15 @@ export const logOut = (): AppThunk => async (dispatch) => { export const logOutAll = (): AppThunk => async (dispatch) => { dispatch(setIsLoading(true)); + // TODO - Modify const response = await fetchServerData("/user/logout/all", "POST"); if (response.error) { console.error(response.error); - dispatch(setNotificationType("error")); dispatch( - setNotificationMessage( - "Error when attempting to log out. Please try again or contact the developer." + displayNotification( + "Error when attempting to log out. Please try again or contact the developer.", + "error" ) ); return; @@ -278,7 +267,7 @@ export const logOutAll = (): AppThunk => async (dispatch) => { dispatch(setConfirmPassword("")); dispatch(setEmail("")); - dispatch(setNotificationMessage("")); + dispatch(displayNotification("", "default")); dispatch(setName("")); dispatch(setPassword("")); dispatch(setSavedJobs([])); @@ -289,6 +278,7 @@ export const logOutAll = (): AppThunk => async (dispatch) => { export const resetPassword = (): AppThunk => async (dispatch, getState) => { dispatch(setIsLoading(true)); + dispatch(displayNotification("", "default")); const state: RootState = getState(); const { @@ -298,13 +288,13 @@ export const resetPassword = (): AppThunk => async (dispatch, getState) => { } = state.user; if (resetConfirmNewPassword !== resetNewPassword) { - dispatch(setNotificationType("error")); - dispatch(setNotificationMessage("Passwords do not match.")); + dispatch(displayNotification("Passwords do not match.", "error")); dispatch(setIsLoading(false)); return; } try { + // TODO - Modify const response: ResetPasswordResponse = await fetchServerData( "/user/me", "PATCH", @@ -315,14 +305,12 @@ export const resetPassword = (): AppThunk => async (dispatch, getState) => { ); if (response.error) { - dispatch(setNotificationType("error")); - dispatch(setNotificationMessage(response.error)); + dispatch(displayNotification(response.error, "error")); dispatch(setIsLoading(false)); return; } - dispatch(setNotificationType("info")); - dispatch(setNotificationMessage("Password reset successfully.")); + dispatch(displayNotification("Password reset successfully.", "success")); dispatch(setResetConfirmNewPassword("")); dispatch(setResetCurrentPassword("")); dispatch(setResetNewPassword("")); @@ -330,8 +318,7 @@ export const resetPassword = (): AppThunk => async (dispatch, getState) => { dispatch(setIsLoading(false)); } catch (error) { console.error(error); - dispatch(setNotificationType("error")); - dispatch(setNotificationMessage(error)); + dispatch(displayNotification(error, "error")); dispatch(setIsLoading(false)); } }; @@ -340,7 +327,7 @@ export const cancelResetPassword = (): AppThunk => (dispatch) => { dispatch(setResetConfirmNewPassword("")); dispatch(setResetCurrentPassword("")); dispatch(setResetNewPassword("")); - dispatch(setNotificationMessage("")); + dispatch(displayNotification("", "default")); dispatch(setIsResettingPassword(false)); }; @@ -349,7 +336,7 @@ export const clickEditProfile = (): AppThunk => (dispatch, getState) => { const { email, name } = state.user; - dispatch(setNotificationMessage("")); + dispatch(displayNotification("", "default")); dispatch(setEditEmail(email)); dispatch(setEditName(name)); dispatch(setIsEditingProfile(true)); @@ -358,16 +345,18 @@ export const clickEditProfile = (): AppThunk => (dispatch, getState) => { export const cancelEditProfile = (): AppThunk => (dispatch) => { dispatch(setEditEmail("")); dispatch(setEditName("")); - dispatch(setNotificationMessage("")); + dispatch(displayNotification("", "default")); dispatch(setIsEditingProfile(false)); }; export const editProfile = (): AppThunk => async (dispatch, getState) => { dispatch(setIsLoading(true)); + dispatch(displayNotification("", "default")); const state: RootState = getState(); const { editEmail, editName } = state.user; try { + // TODO - Modify const response: EditProfileResponse = await fetchServerData( "/user/me", "PATCH", @@ -375,15 +364,16 @@ export const editProfile = (): AppThunk => async (dispatch, getState) => { ); if (response.error) { - dispatch(setNotificationType("error")); - dispatch(setNotificationMessage(response.error)); + dispatch(displayNotification(response.error, "error")); dispatch(setIsLoading(false)); return; } - dispatch(setNotificationType("info")); dispatch( - setNotificationMessage("Profile information updated successfully.") + displayNotification( + "Profile information updated successfully.", + "success" + ) ); dispatch(setEditEmail("")); dispatch(setEditName("")); @@ -393,22 +383,21 @@ export const editProfile = (): AppThunk => async (dispatch, getState) => { dispatch(setIsLoading(false)); } catch (error) { console.error(error); - dispatch(setNotificationType("error")); - dispatch(setNotificationMessage(error)); + dispatch(displayNotification(error, "error")); dispatch(setIsLoading(false)); } }; export const cancelDeleteProfile = (): AppThunk => (dispatch) => { - dispatch(setNotificationMessage("")); + dispatch(displayNotification("", "default")); dispatch(setIsDeletingProfile(false)); }; export const clickDeleteProfile = (): AppThunk => (dispatch) => { - dispatch(setNotificationType("warning")); dispatch( - setNotificationMessage( - "Are you sure you would like to delete your profile? This can not be reversed." + displayNotification( + "Are you sure you would like to delete your profile? This can not be reversed.", + "warning" ) ); dispatch(setIsDeletingProfile(true)); @@ -416,22 +405,22 @@ export const clickDeleteProfile = (): AppThunk => (dispatch) => { export const deleteProfile = (): AppThunk => async (dispatch) => { dispatch(setIsLoading(true)); + dispatch(displayNotification("", "default")); try { + // TODO - Modify const response: DeleteProfileResponse = await fetchServerData( "/user/me", "DELETE" ); if (response.error) { - dispatch(setNotificationType("error")); - dispatch(setNotificationMessage(response.error)); + dispatch(displayNotification(response.error, "error")); dispatch(setIsLoading(false)); return; } - dispatch(setNotificationType("info")); - dispatch(setNotificationMessage("Profile deleted successfully.")); + dispatch(displayNotification("Profile deleted successfully.", "success")); dispatch(setEmail("")); dispatch(setName("")); dispatch(setSavedJobs([])); @@ -440,76 +429,125 @@ export const deleteProfile = (): AppThunk => async (dispatch) => { dispatch(setIsLoading(false)); } catch (error) { console.error(error); - dispatch(setNotificationType("error")); - dispatch(setNotificationMessage(error)); + dispatch(displayNotification(error, "error")); dispatch(setIsLoading(false)); } }; -export const addSavedJob = (job: Job): AppThunk => async (dispatch) => { +export const addSavedJob = (id: string): AppThunk => async (dispatch) => { dispatch(setIsLoading(true)); try { - const response: AddSavedJobResponse = await fetchServerData( + // TODO - Modify + const result: + | AddSavedJobErrorResponse + | AddSavedJobSuccessResponse = await fetchServerData( "/user/savedJobs", "PATCH", - JSON.stringify({ method: "ADD", job }) + JSON.stringify({ method: "ADD", id }) ); - if (response.error) { - dispatch(setNotificationType("error")); - dispatch(setNotificationMessage(response.error)); + if (isError(result)) { + dispatch(displayNotification(result.error, "error")); dispatch(setIsLoading(false)); return; } - const { savedJobs } = response; + const { savedJobs } = result; dispatch(setSavedJobs(savedJobs)); dispatch(setSavedJobsCurrentPage(1)); dispatch(setSavedJobsTotalPages(Math.ceil(savedJobs.length / 5))); - dispatch(setNotificationType("info")); - dispatch(setNotificationMessage("Job saved successfully.")); + dispatch(displayNotification("Job saved successfully.", "success")); dispatch(setIsLoading(false)); } catch (error) { console.error(error); - dispatch(setNotificationType("error")); - dispatch(setNotificationMessage(error)); + dispatch(displayNotification(error, "error")); dispatch(setIsLoading(false)); } }; -export const removeSavedJob = (job: Job): AppThunk => async (dispatch) => { +export const removeSavedJob = (id: string): AppThunk => async (dispatch) => { dispatch(setIsLoading(true)); try { - const response: RemoveSavedJobResponse = await fetchServerData( + // TODO - Modify + const result: + | RemoveSavedJobErrorResponse + | RemoveSavedJobSuccessResponse = await fetchServerData( "/user/savedJobs", "PATCH", - JSON.stringify({ method: "REMOVE", job }) + JSON.stringify({ method: "REMOVE", id }) ); - if (response.error) { - dispatch(setNotificationType("error")); - dispatch(setNotificationMessage(response.error)); + if (isError(result)) { + dispatch(displayNotification(result.error, "error")); dispatch(setIsLoading(false)); return; } - const { savedJobs } = response; + const { savedJobs } = result; dispatch(setSavedJobs(savedJobs)); dispatch(setSavedJobsCurrentPage(1)); dispatch(setSavedJobsTotalPages(Math.ceil(savedJobs.length / 5))); - dispatch(setNotificationType("info")); - dispatch(setNotificationMessage("Job removed successfully.")); + dispatch(displayNotification("Job removed successfully.", "success")); dispatch(setIsLoading(false)); } catch (error) { console.error(error); - dispatch(setNotificationType("error")); - dispatch(setNotificationMessage(error)); + dispatch(displayNotification(error, "error")); dispatch(setIsLoading(false)); } }; export const clickViewSavedJobs = (): AppThunk => (dispatch) => { + dispatch(displayNotification("", "default")); dispatch(setIsViewingSavedJobs(true)); }; + +export const getJobDetails = (id: string): AppThunk => async (dispatch) => { + dispatch(setIsLoading(true)); + dispatch(displayNotification("", "default")); + + try { + const result: Job = await fetchServerData(`/jobs/${id}`, "GET"); + + if (isError(result)) { + dispatch(displayNotification(result.error, "error")); + dispatch(setIsLoading(false)); + return; + } + + dispatch(setJobDetails(result)); + dispatch(setIsLoading(false)); + } catch (error) { + console.error(error); + dispatch(displayNotification(error, "error")); + dispatch(setIsLoading(false)); + } +}; + +export const getSavedJobsDetails = (): AppThunk => async (dispatch) => { + dispatch(setIsLoading(true)); + dispatch(displayNotification("", "default")); + + try { + const result: + | GetSavedJobsDetailsErrorResponse + | GetSavedJobsDetailsSuccessResponse = await fetchServerData( + `/user/savedJobsDetails`, + "GET" + ); + + if (isError(result)) { + dispatch(displayNotification(result.error, "error")); + dispatch(setIsLoading(false)); + return; + } + + dispatch(setSavedJobsDetails(result)); + dispatch(setIsLoading(false)); + } catch (error) { + console.error(error); + dispatch(displayNotification(error, "error")); + dispatch(setIsLoading(false)); + } +}; diff --git a/src/client/types.ts b/src/client/types.ts index 10c3972..eebb3e8 100644 --- a/src/client/types.ts +++ b/src/client/types.ts @@ -1,7 +1,19 @@ import { Action } from "redux"; import { ThunkAction } from "redux-thunk"; -export type AddSavedJobResponse = ServerResponseError & ServerResponseUser; +export interface AddSavedJobErrorResponse { + error: string; +} + +export interface AddSavedJobSuccessResponse { + createdAt: string; + email: string; + name: string; + savedJobs: string[]; + updatedAt: string; + __v: number; + _id: string; +} export interface ApplicationAction { type: string; @@ -14,8 +26,8 @@ export interface ApplicationState { currentPage: number; fullTime: boolean; isLoading: boolean; + jobDetails: Job; jobs: Job[]; - jobsFetchedAt: string; locationSearch: string; notificationMessage: string; notificationType: NotificationType; @@ -38,6 +50,24 @@ export type DeleteProfileResponse = ServerResponseError & ServerResponseUser; export type EditProfileResponse = ServerResponseError & ServerResponseUser; +export interface GetJobDetailsErrorResponse { + error: string; +} + +export type GetJobDetailsSuccessResponse = Job; + +export interface GetJobsErrorResponse { + error: string; +} + +export type GetJobsSuccessResponse = Job[]; + +export interface GetSavedJobsDetailsErrorResponse { + error: string; +} + +export type GetSavedJobsDetailsSuccessResponse = Job[]; + export type InputAutoComplete = | "off" | "on" @@ -131,11 +161,29 @@ export interface LocationOption { export type LoginResponse = ServerResponseError & ServerResponseUser; -export type NotificationType = "error" | "info" | "warning"; +export type NotificationType = + | "error" + | "dark" + | "default" + | "info" + | "success" + | "warning"; export type PaginationNavigationType = "left" | "right"; -export type RemoveSavedJobResponse = ServerResponseError & ServerResponseUser; +export interface RemoveSavedJobErrorResponse { + error: string; +} + +export interface RemoveSavedJobSuccessResponse { + createdAt: string; + email: string; + name: string; + savedJobs: string[]; + updatedAt: string; + __v: number; + _id: string; +} export type RequestMethod = "DELETE" | "GET" | "PATCH" | "POST"; @@ -156,23 +204,21 @@ export interface ServerResponseUser { createdAt: string; email: string; name: string; - savedJobs: Job[]; + savedJobs: string[]; updatedAt: string; __v: number; _id: string; } -export type SignupResponse = SignupResponseError & SignupResponseSuccess; - -export interface SignupResponseError { +export interface SignupErrorResponse { error: string; } -export interface SignupResponseSuccess { +export interface SignupSuccessResponse { createdAt: string; email: string; name: string; - savedJobs: Job[]; + savedJobs: string[]; updatedAt: string; __v: number; _id: string; @@ -199,7 +245,8 @@ export interface UserState { resetConfirmNewPassword: string; resetCurrentPassword: string; resetNewPassword: string; - savedJobs: Job[]; + savedJobs: string[]; savedJobsCurrentPage: number; + savedJobsDetails: Job[]; savedJobsTotalPages: number; } diff --git a/src/client/util.ts b/src/client/util.ts index 6c89c27..136b12d 100644 --- a/src/client/util.ts +++ b/src/client/util.ts @@ -1,4 +1,14 @@ -import { RequestMethod } from "./types"; +import { + RequestMethod, + GetJobDetailsErrorResponse, + GetJobDetailsSuccessResponse, + GetJobsErrorResponse, + GetJobsSuccessResponse, + AddSavedJobErrorResponse, + AddSavedJobSuccessResponse, + SignupErrorResponse, + SignupSuccessResponse, +} from "./types"; export const fetchServerData = async ( url: string, @@ -11,6 +21,9 @@ export const fetchServerData = async ( headers: { "Content-Type": "application/json" }, method, }); + if (response.status === 500 || response.status === 404) { + return { error: `An error occured. Response Status = ${response.status}` }; + } const data = await response.json(); return data; }; @@ -68,3 +81,17 @@ export const saveState = (state: any): void => { console.error(error); } }; + +export const isError = ( + result: + | GetJobsErrorResponse + | GetJobsSuccessResponse + | GetJobDetailsErrorResponse + | GetJobDetailsSuccessResponse + | AddSavedJobErrorResponse + | AddSavedJobSuccessResponse + | SignupErrorResponse + | SignupSuccessResponse +): result is GetJobsErrorResponse => { + return (result as GetJobsErrorResponse).error !== undefined; +}; diff --git a/src/server/app.ts b/src/server/app.ts index 3293fa3..e37edf7 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -74,6 +74,10 @@ class App { this.app.use(express.static(path.join(__dirname, "../dist"))); this.app.get("*", (req: Request, res: Response) => { + console.log({ hostname: req.hostname }); + if (req.hostname === "herokuapp") { + return res.status(308).redirect("https://www.githubjobs.io/"); + } res.sendFile(path.join(__dirname, "../dist/index.html")); }); } diff --git a/src/server/controllers/job.ts b/src/server/controllers/job.ts index 794e072..5beb7ff 100644 --- a/src/server/controllers/job.ts +++ b/src/server/controllers/job.ts @@ -1,9 +1,20 @@ +import endOfToday from "date-fns/endOfToday"; import express, { Request, Response, Router } from "express"; +import isWithinInterval from "date-fns/isWithinInterval"; import nfetch from "node-fetch"; +import startOfToday from "date-fns/startOfToday"; -import { createSearchURL } from "../util"; +import JobModel from "../models/Job"; -import { Job } from "../types"; +import { getAllJobsFromAPI, isError, unique } from "../util"; + +import { + GetJobsErrorResponse, + GetJobsSuccessResponse, + Job, + GetJobDetailsErrorResponse, + GetJobDetailsSuccessResponse, +} from "../types"; /** * Job Controller. @@ -16,60 +27,199 @@ class JobController { } public initializeRoutes(): void { - this.router.get("/jobs", async (req: Request, res: Response) => { - try { - const jobs: Job[] = []; - let jobsInBatch = null; - let page = 1; - - // * Can only get 50 jobs at a time - // * keep going until there are no more jobs - while (jobsInBatch !== 0) { - const response = await nfetch( - `https://jobs.github.com/positions.json?page=${page}`, - { headers: { "Content-Type": "application/json" }, method: "GET" } - ); - const batchJobs: Job[] = await response.json(); - jobsInBatch = batchJobs.length; - page++; - if (jobsInBatch !== 0) { - jobs.push(...batchJobs); + this.router.get( + "/jobs", + async ( + req: Request, + res: Response + ): Promise> => { + try { + const currentJobs = await JobModel.find({}); + + // * No Jobs exist in DB + if (currentJobs.length === 0) { + const result = await getAllJobsFromAPI(); + + if (isError(result)) { + return res.status(500).send(result); + } + + await Promise.all( + result.map(async (job: Job) => { + const newJob = new JobModel(job); + await newJob.save(); + return; + }) + ); + + const dbJobs = await JobModel.find({}); + return res.send(dbJobs); + } else { + // * Jobs exist in DB + const { createdAt } = currentJobs[0]; + + const isWithinToday = isWithinInterval(new Date(createdAt), { + start: startOfToday(), + end: endOfToday(), + }); + + if (!isWithinToday) { + // * Jobs are stale. Get new jobs. + const result = await getAllJobsFromAPI(); + + if (isError(result)) { + return res.status(500).send(result); + } + + // * Drop the current database of Jobs + await JobModel.collection.drop(); + + // * Create new Job entries + await Promise.all( + result.map(async (job: Job) => { + const newJob = new JobModel(job); + await newJob.save(); + return; + }) + ); + + const dbJobs = await JobModel.find({}); + return res.send(dbJobs); + } else { + // * Jobs are fine, send that. + return res.send(currentJobs); + } } + } catch (error) { + if (process.env.NODE_ENV !== "test") { + console.error(error); + } + res.status(500).send({ error }); } - - res.send(jobs); - } catch (error) { - res.status(500).send({ error }); } - }); - - this.router.get("/jobs/search", async (req: Request, res: Response) => { - try { - const { description, full_time, location } = req.query; - const jobs: Job[] = []; - let jobsInBatch = null; - let page = 1; - - while (jobsInBatch !== 0) { - const url = createSearchURL(page, description, full_time, location); - - const response = await nfetch(url, { - headers: { "Content-Type": "application/json" }, - method: "GET", - }); - const batchJobs: Job[] = await response.json(); - jobsInBatch = batchJobs.length; - page++; - if (jobsInBatch !== 0) { - jobs.push(...batchJobs); + ); + + // TODO - Optimize (?) + this.router.get( + "/jobs/search", + async ( + req: Request, + res: Response + ): Promise> => { + try { + const { + description, + full_time, + location1, + location2, + location3, + location4, + location5, + } = req.query; + + const isLocationSearch = + location1 || location2 || location3 || location4; + + // * If there is a location in the search, use the API + // * If there is not a location, just query the DB + + if (isLocationSearch) { + const jobs: Job[] = []; + let jobsInBatch = null; + let page = 1; + const locations = [ + location1, + location2, + location3, + location4, + location5, + ]; + + await Promise.all( + locations.map(async (location: string | undefined) => { + if (location) { + while (jobsInBatch !== 0) { + const url = `https://jobs.github.com/positions.json?page=${page}&description=${encodeURI( + description.toString() + )}&location=${encodeURI(location)}`; + + const response = await nfetch(url, { + headers: { "Content-Type": "application/json" }, + method: "GET", + }); + const batchJobs: Job[] = await response.json(); + jobsInBatch = batchJobs.length; + page++; + if (jobsInBatch !== 0) { + jobs.push(...batchJobs); + } + } + } + }) + ); + + const uniqueResults: Job[] = unique(jobs); + + return res.send(uniqueResults); } + + // * Make Searches + const regexSearch = new RegExp(description.toString(), "i"); + + const companyQuery = JobModel.find({ company: regexSearch }); + const descriptionQuery = JobModel.find({ description: regexSearch }); + const titleQuery = JobModel.find({ title: regexSearch }); + + if (full_time === "true") { + companyQuery.find({ type: "Full Time" }); + descriptionQuery.find({ type: "Full Time" }); + titleQuery.find({ type: "Full Time" }); + } + + const companyResults = await companyQuery.exec(); + const descriptionResults = await descriptionQuery.exec(); + const titleResults = await titleQuery.exec(); + + // * Combine search results into 1 array + const searchResults: Job[] = [ + ...companyResults, + ...descriptionResults, + ...titleResults, + ]; + + const uniqueResults: Job[] = unique(searchResults); + + return res.send(uniqueResults); + } catch (error) { + if (process.env.NODE_ENV !== "test") { + console.error(error); + } + res.status(500).send({ error }); } + } + ); + + this.router.get( + "/jobs/:id", + async ( + req: Request, + res: Response + ): Promise< + Response + > => { + try { + const { id } = req.params; + const jobDetails = await JobModel.findOne({ id }); - res.send(jobs); - } catch (error) { - res.status(500).send({ error }); + return res.send(jobDetails); + } catch (error) { + if (process.env.NODE_ENV !== "test") { + console.error(error); + } + res.status(500).send({ error }); + } } - }); + ); } } diff --git a/src/server/controllers/user.ts b/src/server/controllers/user.ts index 7795a94..e2e86b9 100644 --- a/src/server/controllers/user.ts +++ b/src/server/controllers/user.ts @@ -5,14 +5,19 @@ import validator from "validator"; import auth from "../middleware/auth"; +import JobModel from "../models/Job"; import User from "../models/User"; import { AuthenticatedRequest, EditSavedJobsMethod, - Job, + GetSavedJobsDetailsErrorResponse, + GetSavedJobsDetailsSuccessResponse, + PatchSavedJobErrorResponse, + PatchSavedJobSuccessResponse, Token, UserDocument, + Job, } from "../types"; /** @@ -195,11 +200,16 @@ class UserController { this.router.patch( "/user/savedJobs", auth, - async (req: AuthenticatedRequest, res: Response) => { + async ( + req: AuthenticatedRequest, + res: Response + ): Promise< + Response + > => { try { const method: EditSavedJobsMethod = req.body.method; - const job: Job = req.body.job; - const currentSavedJobs = req.user.savedJobs; + const id: string = req.body.id; + const currentSavedJobs: string[] = req.user.savedJobs; let newJobs; if (method !== "ADD" && method !== "REMOVE") { @@ -210,11 +220,11 @@ class UserController { if (method === "ADD") { // * User is attempting to add a saved job - newJobs = [...currentSavedJobs, job]; + newJobs = [...currentSavedJobs, id]; } else if (method === "REMOVE") { // * User is attempting to remove a saved job newJobs = currentSavedJobs.filter( - (savedJob: Job) => savedJob.id !== job.id + (savedJobID: string) => savedJobID !== id ); } req.user.savedJobs = newJobs; @@ -230,6 +240,50 @@ class UserController { } ); + this.router.get( + "/user/savedJobsDetails", + auth, + async ( + req: AuthenticatedRequest, + res: Response + ): Promise< + Response< + GetSavedJobsDetailsErrorResponse | GetSavedJobsDetailsSuccessResponse + > + > => { + try { + const { savedJobs } = req.user; + + const savedJobsDetails: Job[] = []; + let dbError = false; + + await Promise.all( + savedJobs.map(async (id: string) => { + const job = await JobModel.findOne({ id }); + + if (!job) { + return (dbError = true); + } + return savedJobsDetails.push(job); + }) + ); + + if (dbError) { + return res + .status(500) + .send({ error: "Error finding corresponding jobs in database." }); + } + + return res.send(savedJobsDetails); + } catch (error) { + if (process.env.NODE_ENV !== "test") { + console.error(error); + } + return res.status(500).send({ error }); + } + } + ); + this.router.patch( "/user/me", auth, @@ -273,19 +327,6 @@ class UserController { // * Send User as respoonse return res.send(req.user); } catch (error) { - if (error.errors.password) { - // * Min Length Validation Error - if (error.errors.password.kind === "minlength") { - return res.status(400).send({ - error: "Password must be a minimum of 7 characters.", - }); - } - // * Password Validation Error - return res - .status(400) - .send({ error: error.errors.password.message }); - } - if (process.env.NODE_ENV !== "test") { console.error(error); } diff --git a/src/server/models/Job.ts b/src/server/models/Job.ts new file mode 100644 index 0000000..ad69ed4 --- /dev/null +++ b/src/server/models/Job.ts @@ -0,0 +1,65 @@ +import mongoose from "mongoose"; + +import { JobDocument } from "../types"; + +const jobSchema = new mongoose.Schema( + { + company: { + required: [true, "Field 'company' is required."], + type: String, + }, + company_logo: { + required: false, + type: String, + }, + company_url: { + required: false, + type: String, + }, + created_at: { + required: [true, "Field 'created_at' is required."], + type: String, + }, + description: { + required: [true, "Field 'description' is required."], + type: String, + }, + how_to_apply: { + required: [true, "Field 'how_to_apply' is required."], + type: String, + }, + id: { + required: [true, "Field 'id' is required."], + type: String, + }, + location: { + required: [true, "Field 'location' is required."], + type: String, + }, + title: { + required: [true, "Field 'title' is required."], + type: String, + }, + type: { + required: [true, "Field 'type' is required."], + type: String, + }, + url: { + required: [true, "Field 'url' is required."], + type: String, + }, + }, + { timestamps: true } +); + +function contentToJSON(): void { + const jobsObj = this.toObject(); + + return jobsObj; +} + +jobSchema.methods.toJSON = contentToJSON; + +const Job = mongoose.model("Job", jobSchema); + +export default Job; diff --git a/src/server/models/User.ts b/src/server/models/User.ts index ff42a23..3e503c8 100644 --- a/src/server/models/User.ts +++ b/src/server/models/User.ts @@ -79,17 +79,7 @@ const userSchema = new mongoose.Schema( }, savedJobs: [ { - company: String, - company_logo: String, - company_url: String, - created_at: String, - description: String, - how_to_apply: String, - id: String, - location: String, - title: String, - type: { type: String }, - url: String, + type: String, }, ], tokens: [ diff --git a/src/server/types.ts b/src/server/types.ts index 25aff27..b5f9437 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -1,5 +1,6 @@ import { Request, Router } from "express"; import { Document, Model } from "mongoose"; +import Job from "./models/Job"; export interface AuthenticatedRequest extends Request { token: string; @@ -12,6 +13,24 @@ export type Controller = { export type EditSavedJobsMethod = "ADD" | "REMOVE"; +export interface GetJobDetailsErrorResponse { + error: string; +} + +export type GetJobDetailsSuccessResponse = Job; + +export interface GetJobsErrorResponse { + error: string; +} + +export type GetJobsSuccessResponse = Job[]; + +export interface GetSavedJobsDetailsErrorResponse { + error: string; +} + +export type GetSavedJobsDetailsSuccessResponse = Job[]; + export interface Job { company: string; company_logo: string; @@ -26,8 +45,40 @@ export interface Job { url: string; } +export interface JobDocument extends Document { + _id: string; + company: string; + company_logo: string; + company_url: string; + created_at: string; + description: string; + how_to_apply: string; + id: string; + location: string; + title: string; + type: JobType; + url: string; + createdAt: string; + updatedAt: string; + __v: number; +} + export type JobType = "Contract" | "Full Time"; +export interface PatchSavedJobErrorResponse { + error: string; +} + +export interface PatchSavedJobSuccessResponse { + createdAt: string; + email: string; + name: string; + savedJobs: string[]; + updatedAt: string; + __v: number; + _id: string; +} + export interface Token { _id: string; token: string; @@ -39,7 +90,7 @@ export interface UserDocument extends Document { generateAuthToken(): Promise; password: string; name: string; - savedJobs: Job[]; + savedJobs: string[]; tokens: Token[]; } diff --git a/src/server/util.ts b/src/server/util.ts index b5a60a4..1dfac9b 100644 --- a/src/server/util.ts +++ b/src/server/util.ts @@ -1,4 +1,6 @@ -import fetch from "node-fetch"; +import nfetch from "node-fetch"; + +import { GetJobsErrorResponse, GetJobsSuccessResponse, Job } from "./types"; /** * Check if MongoDB is running locally. Stops application from continuing if false. @@ -41,3 +43,42 @@ export const createSearchURL = ( return url; }; + +export const getAllJobsFromAPI = async (): Promise< + GetJobsErrorResponse | GetJobsSuccessResponse +> => { + const jobs: Job[] = []; + let jobsInBatch = null; + let page = 1; + + // * Can only get 50 jobs at a time + // * keep going until there are no more jobs + try { + while (jobsInBatch !== 0) { + const response = await nfetch( + `https://jobs.github.com/positions.json?page=${page}`, + { headers: { "Content-Type": "application/json" }, method: "GET" } + ); + const batchJobs: Job[] = await response.json(); + jobsInBatch = batchJobs.length; + page++; + if (jobsInBatch !== 0) { + jobs.push(...batchJobs); + } + } + + return jobs; + } catch (error) { + console.error(error); + return { error }; + } +}; + +export const isError = ( + result: GetJobsErrorResponse | GetJobsSuccessResponse +): result is GetJobsErrorResponse => { + return (result as GetJobsErrorResponse).error !== undefined; +}; + +// eslint-disable-next-line +export const unique = (arr: any[]): any[] => [...new Set(arr)];