From 52c0ef101b9665464fa7fd9ac896000feadbfc58 Mon Sep 17 00:00:00 2001 From: ozaretc Date: Wed, 21 Aug 2019 18:56:24 +0300 Subject: [PATCH] Add Cases component with SSR data fetching --- components/Cases/Cases.jsx | 91 +++++++ components/Cases/cases.module.scss | 24 ++ components/Cases/components/Case/Case.jsx | 94 +++++++ .../Cases/components/Case/case.module.scss | 15 ++ .../Case/components/CaseModal/CaseModal.jsx | 229 ++++++++++++++++++ .../CaseModal/case-modal.module.scss | 3 + .../components/Placeholder/Placeholder.jsx | 31 +++ .../Placeholder/placeholder.module.scss | 56 +++++ components/Cases/services/cases.js | 21 ++ routes.js | 6 + server.js | 42 ++-- services/ssrDataLayer.js | 30 +++ 12 files changed, 626 insertions(+), 16 deletions(-) create mode 100644 components/Cases/Cases.jsx create mode 100644 components/Cases/cases.module.scss create mode 100644 components/Cases/components/Case/Case.jsx create mode 100644 components/Cases/components/Case/case.module.scss create mode 100644 components/Cases/components/Case/components/CaseModal/CaseModal.jsx create mode 100644 components/Cases/components/Case/components/CaseModal/case-modal.module.scss create mode 100644 components/Cases/components/Placeholder/Placeholder.jsx create mode 100644 components/Cases/components/Placeholder/placeholder.module.scss create mode 100644 components/Cases/services/cases.js create mode 100644 services/ssrDataLayer.js diff --git a/components/Cases/Cases.jsx b/components/Cases/Cases.jsx new file mode 100644 index 0000000..fa372c5 --- /dev/null +++ b/components/Cases/Cases.jsx @@ -0,0 +1,91 @@ +import React, { useEffect, useLayoutEffect, useState } from 'react'; +import to from 'await-to-js'; +import Placeholder from './components/Placeholder/Placeholder'; +import Case from './components/Case/Case'; +import useRouter from 'use-react-router'; +import { hasSsrData, getSsrData } from '../../services/ssrDataLayer'; +import { getCases } from './services/cases'; +import { Trans } from 'react-i18next'; + +import styles from './cases.module.scss'; + +export default function Cases(props) { + const { urlPrefix } = props; + const { staticContext } = useRouter(); + const [isLoading, setLoading] = useState(false); + const [cases, setCases] = useState(filterCases(getSsrData('cases', [], staticContext))); + + /** + * Set loading state and proceed with retrieving cases + */ + useLayoutEffect(() => { + setLoading(true); + proceedCases(); + }, []); + + /** + * Get all cases + * @returns {Promise} + */ + async function proceedCases() { + const [err, response] = await to(getCases('us')); + + setLoading(false); + + if (err) + return; + + setCases(filterCases(response.cases)); + } + + /** + * Filter cases, which arrived from BE or SSR + * @param casesToBeFiltered + */ + function filterCases(casesToBeFiltered) { + return casesToBeFiltered.filter(item => item.isVisible); + } + + /** + * Render case + * @param currentCase + * @param index + */ + function renderCase(currentCase, index) { + return ( + + ); + } + + return ( +
+ {isLoading ? + + + + + : null} + + {!isLoading ? +
+ {!cases.length ? +
+ No cases found +
+ : + '' + } +
+ {cases.length ? cases.map(renderCase) : null} +
+
+ : null} +
+ ); +} diff --git a/components/Cases/cases.module.scss b/components/Cases/cases.module.scss new file mode 100644 index 0000000..9ca3672 --- /dev/null +++ b/components/Cases/cases.module.scss @@ -0,0 +1,24 @@ +.notFound { + padding: 20px 0 20px 0; +} + +.blocks { + display: flex; + flex-wrap: wrap; +} + +.expand { + width: 50px; + max-height: 40px; + margin-top: 33px; + transition: .3s transform ease-in, opacity .2s ease-in; + + &.hideCases { + transform: rotate(180deg); + } + + &:hover { + cursor: pointer; + opacity: 0.9; + } +} diff --git a/components/Cases/components/Case/Case.jsx b/components/Cases/components/Case/Case.jsx new file mode 100644 index 0000000..7adb8d8 --- /dev/null +++ b/components/Cases/components/Case/Case.jsx @@ -0,0 +1,94 @@ +import React, { useEffect, useState } from 'react'; +import useReactRouter from 'use-react-router'; +import CaseModal from './components/CaseModal/CaseModal'; +import { Trans } from 'react-i18next'; +import { useTranslation } from 'react-i18next'; + +import styles from './case.module.scss'; + +/** + * Return a timestamp with the format "dd/mm/yy hh:MM:ss" + */ +function timeStamp() { + let i; + const now = new Date(); + const date = [now.getDate(), now.getMonth() + 1, now.getFullYear()]; + let time = [now.getHours(), now.getMinutes(), now.getSeconds()]; + + // If seconds and minutes are less than 10, add a zero + for (i = 1; i < 3; i++) { + if (time[i] < 10) { + time[i] = '0' + time[i]; + } + } + + return '(' + date.join('/') + ' ' + time.join(':') + ')'; +} + +export default function Case(props) { + const [t] = useTranslation(); + const { history } = useReactRouter(); + const [isModalOpen, setModalOpen] = useState(false); + const { + id, + name, + imageUrl, + workflowId, + isSuggest, + shortDescription, + urlPrefix + } = props; + + /** + * Open case modal if current location ends with :caseId + */ + useEffect(() => { + if (location.pathname.indexOf(`${urlPrefix}/${id}`) >> 0) + setModalOpen(true); + }, []); + + /** + * Opens a modal and changes url + */ + function onCaseClick() { + history.push(`${urlPrefix}/${id}`); + setModalOpen(true); + } + + /** + * Handler for modal update + * Proceed with document preparation + * @param documentId + */ + function onModalUpdate(documentId) { + history.push(`/w/${workflowId}/d/${documentId}`); + } + + return ( + +
+
+
+ {t(name)} +
+
+

+ +

+

+ +

+
+
+
+ + {isModalOpen && !isSuggest ? + setModalOpen(false)} + onUpdate={onModalUpdate} + /> : null} + +
+ ); +} diff --git a/components/Cases/components/Case/case.module.scss b/components/Cases/components/Case/case.module.scss new file mode 100644 index 0000000..1f77475 --- /dev/null +++ b/components/Cases/components/Case/case.module.scss @@ -0,0 +1,15 @@ +.block { + flex-basis: 33%; +} + +@media screen and (max-width: $device-large-width) { + .block { + flex-basis: 50%; + } +} + +@media screen and (max-width: $device-small-width) { + .block { + flex-basis: 100%; + } +} diff --git a/components/Cases/components/Case/components/CaseModal/CaseModal.jsx b/components/Cases/components/Case/components/CaseModal/CaseModal.jsx new file mode 100644 index 0000000..c1594f4 --- /dev/null +++ b/components/Cases/components/Case/components/CaseModal/CaseModal.jsx @@ -0,0 +1,229 @@ +import React, { useState, useEffect } from 'react'; +import to from 'await-to-js'; +import ReactHtmlParser from 'react-html-parser'; +import useReactRouter from 'use-react-router'; +import { Helmet } from 'react-helmet'; +import { Modal } from 'react-bootstrap'; +import { Trans } from 'react-i18next'; +import { useTranslation } from 'react-i18next'; + +import styles from './case-modal.module.scss'; + +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faTimes, faEye, faInfoCircle } from '@fortawesome/free-solid-svg-icons'; + +/** + * Remove elements from DOM and prevent click everywhere + * @param event + * @private + */ +const removeElementFromDOM = event => { + const iframe = document.querySelector(`.${styles.documentPreview}`); + const hideIcon = document.querySelector(`.${styles.documentHide}`); + const modal = document.querySelector('[role="dialog"]'); + + event.preventDefault(); + event.stopPropagation(); + if (iframe) + document.body.removeChild(iframe); + + if (hideIcon) + document.body.removeChild(hideIcon); + + if (modal) { + modal.style.pointerEvents = 'auto'; + modal.style.touchAction = 'auto'; + } + + document.body.removeListener('click', removeElementFromDOM); +}; + +export default function CaseModal(props) { + const [t] = useTranslation(); + const { history } = useReactRouter(); + const [isLoading, setLoading] = useState({}); + const { + id, + name, + imageUrl, + description, + shortDescription, + documents, + closeModal, + onUpdate + } = props; + + /** + * Proceed with the selected document + * @param currentDocument + */ + function onDocumentClick(currentDocument) { + onUpdate(currentDocument.documentId); + closeModal(); + } + + /** + * Handler for document preview click + * @param documentId + */ + function onDocumentPreviewClick(documentId) { + setLoading({ [documentId]: true }); + + proceedPreview(documentId); + } + + /** + * Get file for preview + * @param documentId + */ + function proceedPreview(documentId) { + const [err, response] = await to(getPreview(documentId)); + + if (err) { + setLoading({}); + + const errorMessage = err.error.message; + + if (errorMessage === 'File not found') + console.error('business.fileNotFound'); + + if (errorMessage === 'Preview file is unavailable') + alerts.err(t('business.previewFileNotAvailable')); + + console.error('Something went wrong while fetching preview file. Error: ', err); + + return; + } + + const iframe = document.createElement('iframe'); + const hideBlock = document.createElement('i'); + const modal = document.querySelector('[role="dialog"]'); + const url = 'https://mozilla.github.io/pdf.js/web/viewer.html?file=' + encodeURIComponent(response.file); + + // Click outside listener + document.addEventListener('click', removeElementsFromDOM); + + modal.style.pointerEvents = 'none'; + modal.style.touchAction = 'none'; + + hideBlock.className = 'fa fa-times ' + styles.documentHide; + iframe.className = styles.documentPreview; + iframe.src = url; + document.body.appendChild(iframe); + document.body.appendChild(hideBlock); + document.body.classList.add('modal-open'); + + setLoading({}); + } + + /** + * Close modal window + */ + function onModalClose() { + history.goBack(); + closeModal(); + } + + return ( + + + + + {t(name)} + + + + + + + + + + + + + +
+ + +
+

+ +

+ {t(name)}/ +

+ {ReactHtmlParser(t(description))} +

+
+ + {documents.length > 1 ? +
+

+ +   + + + +

+ + {documents.map(document => { + return ( +
+ onDocumentClick(document)} + > + + + +
+ {isLoading[document.documentId] ? + + : + onDocumentPreviewClick(document.documentId)} + />} +
+
+ ); + })} +
+ : null} +
+ + {documents.length === 1 ? +
+
+ +
+
+ +
+
: null} +
+
+ ); +} diff --git a/components/Cases/components/Case/components/CaseModal/case-modal.module.scss b/components/Cases/components/Case/components/CaseModal/case-modal.module.scss new file mode 100644 index 0000000..793f9e2 --- /dev/null +++ b/components/Cases/components/Case/components/CaseModal/case-modal.module.scss @@ -0,0 +1,3 @@ +.body { + position: relative; +} diff --git a/components/Cases/components/Placeholder/Placeholder.jsx b/components/Cases/components/Placeholder/Placeholder.jsx new file mode 100644 index 0000000..d2b12b8 --- /dev/null +++ b/components/Cases/components/Placeholder/Placeholder.jsx @@ -0,0 +1,31 @@ +import React from 'react'; + +import styles from './placeholder.module.scss'; + +export default function Placeholder() { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/components/Cases/components/Placeholder/placeholder.module.scss b/components/Cases/components/Placeholder/placeholder.module.scss new file mode 100644 index 0000000..50d51be --- /dev/null +++ b/components/Cases/components/Placeholder/placeholder.module.scss @@ -0,0 +1,56 @@ +.block { + flex-basis: 33.3%; + + .loading { + cursor: wait; + } +} + +.blocks { + display: flex; + flex-wrap: wrap; +} + +.placeholder { + height: 220px; + + .animatedBackground { + animation-duration: 1s; + animation-fill-mode: forwards; + animation-iteration-count: infinite; + animation-name: placeHolderShimmer; + animation-timing-function: linear; + background: white; + background-size: 800px 104px; + position: relative; + cursor: wait; + } + + .imgPlaceholder { + margin: 20px 50px; + height: 120px; + border-radius: 15px; + } + + .textPlaceholder { + margin: 0 20px; + height: 14px; + } + + .textPlaceholderTwo { + margin: 10px 40px; + height: 14px; + } +} + +@media screen and (max-width: $device-large-width) { + .block { + flex-basis: 50%; + } +} + +@media screen and (max-width: $device-small-width) { + .block { + flex-basis: 100%; + } +} diff --git a/components/Cases/services/cases.js b/components/Cases/services/cases.js new file mode 100644 index 0000000..6e3e13d --- /dev/null +++ b/components/Cases/services/cases.js @@ -0,0 +1,21 @@ +import fetch from 'isomorphic-fetch'; +import { apiUrl, checkResponse } from '../services/apiUrl'; + +/** + * Function to fetch cases based on region + * @param country + * @returns {*} + */ +export function getCases(country) { + let url = `${apiUrl()}/cases`; + + if (country) + url += `?country=${country}`; + + return fetch(url, { + method : 'POST', + headers : { + 'Content-Type': 'application/json; charset=utf-8' + } + }).then(checkResponse); +} diff --git a/routes.js b/routes.js index 21d7864..283d5f1 100644 --- a/routes.js +++ b/routes.js @@ -1,4 +1,6 @@ import Home from './components/Home/Home'; +import Cases from './components/Cases/Cases'; +import { getCases } from './components/Cases/services/cases'; /** * List of all the routes in the application @@ -8,6 +10,10 @@ const routes = [ { path : '/home', component: Home + }, { + path : `/cases`, + components: Cases, + fetchData : getCases } ]; diff --git a/server.js b/server.js index 6fae244..586adc0 100644 --- a/server.js +++ b/server.js @@ -6,7 +6,7 @@ import fs from 'fs'; import express from 'express'; import App from './App'; import { Helmet } from 'react-helmet'; -import { StaticRouter } from 'react-router-dom'; +import { StaticRouter, matchPath } from 'react-router-dom'; const app = express(); @@ -18,23 +18,33 @@ app.get('*', axdssr); * @param res */ function ssr(req, res) { - const context = { fetchedData: data, requestCountry: country }; - const indexFile = path.join(__dirname, 'index.html'); - const app = ReactDOMServer.renderToString( - - - - ); - const helmet = Helmet.renderStatic(); + let promise; + const currentRoute = routes.find(route => matchPath(req.url, route)) || {}; - fs.readFile(indexFile, 'utf8', (err, data) => { - const oldBody = '
'; - const newBody = `
${app}
`; + if (currentRoute.fetchData) + promise = currentRoute.fetchData('us'); + else + promise = Promise.resolve(null); - return res.send( - data - .replace(oldBody, newBody) - .replace(/@@title@@/, helmet.title.toString()) + promise.then(data => { + const context = { fetchedData: data, requestCountry: country, isFirstRun: true }; + const indexFile = path.join(__dirname, 'index.html'); + const app = ReactDOMServer.renderToString( + + + ); + const helmet = Helmet.renderStatic(); + + fs.readFile(indexFile, 'utf8', (err, data) => { + const oldBody = '
'; + const newBody = `
${app}
`; + + return res.send( + data + .replace(oldBody, newBody) + .replace(/@@title@@/, helmet.title.toString()) + ); + }); }); } diff --git a/services/ssrDataLayer.js b/services/ssrDataLayer.js new file mode 100644 index 0000000..8ac1a8a --- /dev/null +++ b/services/ssrDataLayer.js @@ -0,0 +1,30 @@ +/** + * Check if we have SSR cache and if we do - remove it + * @returns {null|boolean} + */ +export function hasSsrData() { + if (window.ssrContext && window.ssrContext.fetchedData) { + delete window.ssrContext.fetchedData; + return true; + } else { + return false; + } +} + +/** + * Get data from the SSR cache using the key. Return default value if cache is not present. + * @param key + * @param defaultValue + * @param context + * @returns {*} + */ +export function getSsrData(key, defaultValue, context) { + if (context.fetchedData) + return context.fetchedData[key]; + + if (window.ssrContext && window.ssrContext.fetchedData) + return window.ssrContext.fetchedData[key]; + + return defaultValue; +} +