diff --git a/package.json b/package.json index 443b2c1..f4b1376 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "fd-servicedirectory", "version": "1.0.0", "description": "PFA Service Directory", - "main": "src/index.jsx", + "main": "src/index.tsx", "repository": "git@github.com:CodeForFoco/fd-servicedirectory.git", "author": "Code for Fort Collins", "license": "MIT", @@ -49,14 +49,18 @@ }, "dependencies": { "@types/jest": "^24.0.23", + "@types/node": "^12.12.11", "axios": "0.18.1", "husky": "^2.4.0", "lodash": "^4.17.13", "polished": "^3.3.0", "qs": "^6.7.0", - "react": "^16.8.6", + "react": "^16.8.3", "react-dom": "^16.8.6", + "react-redux": "^7.1.3", "react-router-dom": "^5.0.0", + "redux": "^4.0.5", + "redux-thunk": "^2.3.0", "styled-components": "^4.2.0", "styled-normalize": "^8.0.6", "ts-jest": "^24.1.0" diff --git a/src/core/api/services/useServices.ts b/src/core/api/services/useServices.ts new file mode 100644 index 0000000..9f1eb32 --- /dev/null +++ b/src/core/api/services/useServices.ts @@ -0,0 +1,107 @@ +import { useEffect } from "react"; +import { useSelector, useDispatch } from "react-redux"; +import { stringify } from "qs"; +import { formatService } from "~core/utils"; + +import { ActionRequest, Request, FormattedService } from "~/types/services"; +import { axiosClient as client, DEFAULT_ERROR_MESSAGE } from "~/core/constants"; + +export const useServices = (): Request => { + const services = useSelector(state => state.services); + const dispatch = useDispatch(); + + useEffect(() => { + if (!services || !services.data) { + dispatch(getAllServices); + } + }, []); + + return services; +}; + +const getAllServices = async (dispatch: Function) => { + dispatch(getServicesLoading()); + + try { + const types = await getSheetTitles(); + const allServicesRes = await client.get("values:batchGet", { + params: { + majorDimension: "ROWS", + ranges: types, + }, + paramsSerializer: params => { + return stringify(params, { indices: false }); + }, + }); + + dispatch( + getServicesSuccess( + allServicesRes.data.valueRanges.reduce((list, type) => { + // Remove "Types, Id, etc..." row from service + type.values.shift(); + + return [...list, type.values.map(service => formatService(service))]; + }, []) + ) + ); + } catch (e) { + return dispatch(getServicesError(DEFAULT_ERROR_MESSAGE)); + } +}; + +const getSheetTitles = async () => { + // sheetMetadata will be a list of sheets: { "sheets": [ { "properties": { "title": "Index" } }, ... ] } + const sheetMetadata = await client.get("", { + params: { + fields: "sheets.properties.title", + }, + }); + return sheetMetadata.data.sheets + .map(sheet => sheet.properties.title) + .filter(title => title !== "Index"); +}; + +// Reducer that handles state for the useAPI hook +export const getServicesReducer = ( + state: Request = { loading: true, errorMessage: null, data: null }, + action: ActionRequest +) => { + switch (action.type) { + case "GET_SERVICES_LOADING": + return { ...state, loading: true, data: null, errorMessage: null }; + case "GET_SERVICES_ERROR": + return { + loading: false, + data: null, + errorMessage: action.errorMessage, + }; + case "GET_SERVICES_SUCCESS": + return { + loading: false, + data: action.payload, + errorMessage: null, + }; + default: + return state; + } +}; + +export const getServicesSuccess = (payload: FormattedService[][]) => ({ + type: "GET_SERVICES_SUCCESS", + payload, + errorMessage: null, +}); + +export const getServicesError = (errorMessage: string) => ({ + type: "GET_SERVICES_ERROR", + payload: null, + errorMessage, +}); + +export const getServicesLoading = () => ({ + type: "GET_SERVICES_LOADING", + payload: null, + errorMessage: null, +}); + +export default useServices; diff --git a/src/core/api/services/useServicesIndex.ts b/src/core/api/services/useServicesIndex.ts new file mode 100644 index 0000000..6a31830 --- /dev/null +++ b/src/core/api/services/useServicesIndex.ts @@ -0,0 +1,73 @@ +import { useEffect } from "react"; +import { useSelector, useDispatch } from "react-redux"; +import { getSheetData } from "~/core/utils"; + +import { ActionRequest, Request } from "~/types/services"; +import { + axiosClient as client, + DEFAULT_ERROR_MESSAGE, + INDEX_SHEET_TITLE, +} from "~/core/constants"; + +const useServicesIndex = (): Request => { + const servicesIndex = useSelector(state => state.servicesIndex); + const dispatch = useDispatch(); + + useEffect(() => { + if (servicesIndex && servicesIndex.data) return; + dispatch(getServicesIndex); + }, []); + + return servicesIndex; +}; + +const getServicesIndex = async (dispatch: Function) => { + dispatch(getServicesIndexLoading()); + + try { + const res = await getSheetByTitle(INDEX_SHEET_TITLE); + dispatch(getServicesIndexSuccess(getSheetData(res.data))); + } catch { + dispatch(getServicesIndexError(DEFAULT_ERROR_MESSAGE)); + } +}; + +const getSheetByTitle = async title => + await client.get("values:batchGet", { + params: { + majorDimension: "ROWS", + ranges: title, + }, + }); + +export const getServicesIndexReducer = ( + state: Request = { loading: true, errorMessage: null, data: null }, + action: ActionRequest +) => { + switch (action.type) { + case "GET_SHEET_INDEX_SUCCESS": + return { loading: false, data: action.payload, errorMessage: null }; + case "GET_SHEET_INDEX_ERROR": + return { loading: false, data: null, errorMessage: action.errorMessage }; + case "GET_SHEET_INDEX_LOADING": + return { loading: true, data: null, errorMessage: null }; + default: + return state; + } +}; + +export const getServicesIndexSuccess = (payload): ActionRequest => ({ + type: "GET_SHEET_INDEX_SUCCESS", + payload, +}); + +export const getServicesIndexError = (errorMessage): ActionRequest => ({ + type: "GET_SHEET_INDEX_ERROR", + errorMessage, +}); + +export const getServicesIndexLoading = (): ActionRequest => ({ + type: "GET_SHEET_INDEX_LOADING", +}); + +export default useServicesIndex; diff --git a/src/core/constants.ts b/src/core/constants.ts new file mode 100644 index 0000000..0ddbdc1 --- /dev/null +++ b/src/core/constants.ts @@ -0,0 +1,13 @@ +import axios from "axios"; + +export const API_KEY = process.env.GOOGLE_API_KEY; +export const DEFAULT_ERROR_MESSAGE = "Something went wrong!"; +export const INDEX_SHEET_TITLE = "Index"; +export const SHEET_ID = + process.env.SHEET_ID || "1ZPRRR8T51Tk-Co8h_GBh3G_7P2F7ZrYxPQDSYycpCUg"; + +// Create our API client and inject the API key into every request +export const axiosClient = axios.create({ + baseURL: `https://sheets.googleapis.com/v4/spreadsheets/${SHEET_ID}/`, + params: { key: API_KEY }, +}); diff --git a/src/core/store.ts b/src/core/store.ts new file mode 100644 index 0000000..0e97aae --- /dev/null +++ b/src/core/store.ts @@ -0,0 +1,18 @@ +import { createStore, combineReducers, applyMiddleware } from "redux"; +import thunk from "redux-thunk"; +import { getServicesReducer as services } from "~core/api/services/useServices"; +import { getServicesIndexReducer as servicesIndex } from "~core/api/services/useServicesIndex"; + +const reducers = combineReducers({ + services, + servicesIndex, +}); + +export const configureStore = initialState => { + return createStore(reducers, initialState, applyMiddleware(thunk)); +}; + +export const initializeStore = () => { + // State is intialized per reducer + return configureStore({}); +}; diff --git a/src/core/utils.ts b/src/core/utils.ts index f41f705..9bb5c73 100644 --- a/src/core/utils.ts +++ b/src/core/utils.ts @@ -1,11 +1,18 @@ -import formattedService from "./interfaces/formattedService"; +import { FormattedService } from "~/types/services"; -interface iSheet { - valueRanges: Array; +export interface iSheet { + spreadsheetid: string; + valueRanges: valueRangeItem[]; +} + +export interface valueRangeItem { + range: string; + majorDimension: string; + values: string[]; } // Returns the rows of data from a sheet (excluding the header row) -export const getSheetData = (sheet: iSheet): Array => { +export const getSheetData = (sheet: iSheet): string[] => { const items = sheet.valueRanges[0].values; // Remove the header row items.shift(); @@ -18,7 +25,7 @@ export const getSheetData = (sheet: iSheet): Array => { * Note: A fallback is required for the Phone # field because * the Sheets API omits the last column if there's no value. */ -export const formatService = (service: any): formattedService => { +export const formatService = (service: any): FormattedService => { // Split the comma separated populations into an array const populations = service[3] === "" ? [] : service[3].split(", "); return { diff --git a/src/index.html b/src/index.html index 2bad8af..7c9b227 100644 --- a/src/index.html +++ b/src/index.html @@ -14,6 +14,6 @@
- + diff --git a/src/index.jsx b/src/index.tsx similarity index 87% rename from src/index.jsx rename to src/index.tsx index 14b243e..52e7345 100644 --- a/src/index.jsx +++ b/src/index.tsx @@ -1,8 +1,10 @@ import React from "react"; import { render } from "react-dom"; import { BrowserRouter, Redirect, Route, Switch } from "react-router-dom"; +import { Provider } from "react-redux"; import styled, { createGlobalStyle, ThemeProvider } from "styled-components"; import { Normalize } from "styled-normalize"; +import { initializeStore } from "~/core/store"; import Nav from "~/components/nav"; import theme from "~/core/theme"; import Categories from "~/pages/categories"; @@ -23,6 +25,9 @@ const PageContainer = styled.div({ marginBottom: "96px", }); +// Initialize Redux store +const store = initializeStore(); + const App = () => ( @@ -52,4 +57,9 @@ const App = () => ( ); -render(, document.getElementById("app")); +render( + + + , + document.getElementById("app") +); diff --git a/src/pages/categories/index.jsx b/src/pages/categories/index.jsx index 7f954a0..1b45eba 100644 --- a/src/pages/categories/index.jsx +++ b/src/pages/categories/index.jsx @@ -1,11 +1,11 @@ -import React, { Fragment } from "react"; -import styled from "styled-components"; -import Logo from "~/components/logo"; -import Loader from "~/components/loader"; +import CategoryCard from "./category-card"; import Error from "~/components/error"; import { H1 } from "~/components/typography"; -import api, { useAPI } from "~/core/api"; -import CategoryCard from "./category-card"; +import Logo from "~/components/logo"; +import Loader from "~/components/loader"; +import React, { Fragment } from "react"; +import styled from "styled-components"; +import useServicesIndex from "~/core/api/services/useServicesIndex"; const StyledLogo = styled(Logo)({ margin: "48px auto", @@ -39,7 +39,7 @@ const getCategories = data => ); const Categories = () => { - const { loading, errorMessage, data } = useAPI(api.getIndex); + const { loading, errorMessage, data } = useServicesIndex(); if (errorMessage) { return ; @@ -53,8 +53,8 @@ const Categories = () => { ) : ( - {getCategories(data).map(c => ( - + {getCategories(data).map(category => ( + ))} )} diff --git a/src/pages/service-detail/index.jsx b/src/pages/service-detail/index.jsx index 45ae376..bc96631 100644 --- a/src/pages/service-detail/index.jsx +++ b/src/pages/service-detail/index.jsx @@ -1,16 +1,16 @@ -import React, { Fragment } from "react"; -import styled from "styled-components"; import Box from "~/components/box"; import Button from "~/components/button"; import Divider from "~/components/divider"; -import Loader from "~/components/loader"; import Error from "~/components/error"; +import { formatPhoneNumber } from "~/core/utils"; +import Loader from "~/components/loader"; import PhysicalInfo from "~/components/physical-info"; +import { P1, P2 } from "~/components/typography"; +import React, { Fragment } from "react"; import Requirements from "~/components/requirements"; +import styled from "styled-components"; import TitleBar from "~/components/title-bar"; -import { P1, P2 } from "~/components/typography"; -import api, { useAPI } from "~/core/api"; -import { formatPhoneNumber, formatService } from "~/core/utils"; +import useServices from "~/core/api/services/useServices"; const ServiceCard = styled(Box)({ margin: "72px 16px 0 16px", @@ -32,8 +32,7 @@ const PhoneLink = styled.a({ const ServiceDetail = ({ match }) => { const { categoryId, serviceId, typeId } = match.params; - - const { loading, errorMessage, data } = useAPI(api.getServicesByType, typeId); + const { loading, errorMessage, data } = useServices(); if (loading) { return ; @@ -43,16 +42,14 @@ const ServiceDetail = ({ match }) => { return ; } - const serviceRow = data.find(d => d[1] === serviceId); + const typeList = data.find(typeList => typeList[1].type === typeId); + const service = typeList.find(service => service.id === serviceId); // If no service is found, show an empty state - if (!serviceRow) { + if (!service) { return

No service was found that matches this id!

; } - // Format the service into a useful object - const service = formatService(serviceRow); - return ( { const { categoryId, typeId } = match.params; - - const { loading, errorMessage, data } = useAPI(api.getServicesByType, typeId); + const { loading, errorMessage, data } = useServices(); if (loading) { return ; @@ -26,25 +24,21 @@ const Services = ({ match }) => { return ; } - // Format services into useful objects - const services = data.map(formatService); + // Get my services + const myServices = getMyServices(data, typeId); - // If there are no services, show an empty state - if (services.length === 0) { - return

No types were found for this category!

; + if (!myServices || myServices.length === 0) { + return

No services were found for this type!

; } - // Grab the current type from the first service - const currentType = services[0].type; - return ( - {services.map(s => ( + {myServices.map(s => ( { ); }; +const getMyServices = (allTypes, myType) => { + let myServices = []; + + allTypes.forEach(services => { + if (services[1].type === myType) { + myServices = services; + } + }); + + return myServices; +}; + export default Services; diff --git a/src/pages/types/index.jsx b/src/pages/types/index.jsx index 22e3149..1ea008b 100644 --- a/src/pages/types/index.jsx +++ b/src/pages/types/index.jsx @@ -1,10 +1,10 @@ +import Error from "~/components/error"; +import Loader from "~/components/loader"; import React, { Fragment } from "react"; import styled from "styled-components"; -import Loader from "~/components/loader"; -import Error from "~/components/error"; import TitleBar from "~/components/title-bar"; -import api, { useAPI } from "~/core/api"; import TypeCard from "./type-card"; +import useServicesIndex from "~/core/api/services/useServicesIndex"; const TypesList = styled.ul({ listStyle: "none", @@ -13,8 +13,8 @@ const TypesList = styled.ul({ }); const Types = ({ match }) => { + const { loading, errorMessage, data } = useServicesIndex(); const { categoryId } = match.params; - const { loading, errorMessage, data } = useAPI(api.getIndex); if (loading) { return ; diff --git a/src/core/interfaces/formattedService.ts b/src/types/services/index.ts similarity index 60% rename from src/core/interfaces/formattedService.ts rename to src/types/services/index.ts index f1b7023..546c74d 100644 --- a/src/core/interfaces/formattedService.ts +++ b/src/types/services/index.ts @@ -1,4 +1,16 @@ -export interface formattedService { +export interface ActionRequest { + type: string; + errorMessage?: any; + payload?: any; +} + +export interface Request { + loading: boolean; + errorMessage: any; + data: any; +} + +export interface FormattedService { address: string; description: string; hours: number; @@ -17,5 +29,3 @@ export interface formattedService { lowIncome: boolean; }; } - -export default formattedService; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..487708a --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,13 @@ +// References: +// https://parceljs.org/typeScript.html +// https://www.typescriptlang.org/docs/handbook/tsconfig-json.html +{ + "compilerOptions": { + "baseUrl": "./src", + "paths": { + "~*": ["./*"] + }, + "jsx": "react", + }, + "include": ["src/**/*"] +} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 0bf78fd..39d96f7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -885,6 +885,13 @@ dependencies: regenerator-runtime "^0.13.2" +"@babel/runtime@^7.5.5": + version "7.7.2" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.7.2.tgz#111a78002a5c25fc8e3361bedc9529c696b85a6a" + integrity sha512-JONRbXbTXc9WQE2mAZd1p0Z3DZ/6vaQIkgYMSTP3KjRCyd7rCZCcfhCyX+YjwcKxcZ82UrxbRD358bpExNgrjw== + dependencies: + regenerator-runtime "^0.13.2" + "@babel/template@^7.0.0", "@babel/template@^7.1.0", "@babel/template@^7.2.2", "@babel/template@^7.4.0": version "7.4.0" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.4.0.tgz#12474e9c077bae585c5d835a95c0b0b790c25c8b" @@ -1353,6 +1360,11 @@ dependencies: jest-diff "^24.3.0" +"@types/node@^12.12.11": + version "12.12.11" + resolved "https://registry.yarnpkg.com/@types/node/-/node-12.12.11.tgz#bec2961975888d964196bf0016a2f984d793d3ce" + integrity sha512-O+x6uIpa6oMNTkPuHDa9MhMMehlxLAd5QcOvKRjAFsBVpeFWTOPnXbDvILvFgFFZfQ1xh1EZi1FbXxUix+zpsQ== + "@types/normalize-package-data@^2.4.0": version "2.4.0" resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz#e486d0d97396d79beedd0a6e33f4534ff6b4973e" @@ -3810,6 +3822,13 @@ hoist-non-react-statics@^3.1.0: dependencies: react-is "^16.7.0" +hoist-non-react-statics@^3.3.0: + version "3.3.1" + resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz#101685d3aff3b23ea213163f6e8e12f4f111e19f" + integrity sha512-wbg3bpgA/ZqWrZuMOeJi8+SKMhr7X9TesL/rXMjTzh0p0JUBo3II8DHboYbuIXWRlttrUFxwcu/5kygrCw8fJw== + dependencies: + react-is "^16.7.0" + hosted-git-info@^2.1.4: version "2.7.1" resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.7.1.tgz#97f236977bd6e125408930ff6de3eec6281ec047" @@ -6660,6 +6679,23 @@ react-is@^16.6.0, react-is@^16.7.0, react-is@^16.8.1, react-is@^16.8.4: resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.8.6.tgz#5bbc1e2d29141c9fbdfed456343fe2bc430a6a16" integrity sha512-aUk3bHfZ2bRSVFFbbeVS4i+lNPZr3/WM5jT2J5omUVV1zzcs1nAaf3l51ctA5FFvCRbhrH0bdAsRRQddFJZPtA== +react-is@^16.9.0: + version "16.12.0" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.12.0.tgz#2cc0fe0fba742d97fd527c42a13bec4eeb06241c" + integrity sha512-rPCkf/mWBtKc97aLL9/txD8DZdemK0vkA3JMLShjlJB3Pj3s+lpf1KaBzMfQrAmhMQB0n1cU/SUGgKKBCe837Q== + +react-redux@^7.1.3: + version "7.1.3" + resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-7.1.3.tgz#717a3d7bbe3a1b2d535c94885ce04cdc5a33fc79" + integrity sha512-uI1wca+ECG9RoVkWQFF4jDMqmaw0/qnvaSvOoL/GA4dNxf6LoV8sUAcNDvE5NWKs4hFpn0t6wswNQnY3f7HT3w== + dependencies: + "@babel/runtime" "^7.5.5" + hoist-non-react-statics "^3.3.0" + invariant "^2.2.4" + loose-envify "^1.4.0" + prop-types "^15.7.2" + react-is "^16.9.0" + react-router-dom@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-5.0.0.tgz#542a9b86af269a37f0b87218c4c25ea8dcf0c073" @@ -6689,15 +6725,14 @@ react-router@5.0.0: tiny-invariant "^1.0.2" tiny-warning "^1.0.0" -react@^16.8.6: - version "16.8.6" - resolved "https://registry.yarnpkg.com/react/-/react-16.8.6.tgz#ad6c3a9614fd3a4e9ef51117f54d888da01f2bbe" - integrity sha512-pC0uMkhLaHm11ZSJULfOBqV4tIZkx87ZLvbbQYunNixAAvjnC+snJCg0XQXn9VIsttVsbZP/H/ewzgsd5fxKXw== +react@^16.8.3: + version "16.12.0" + resolved "https://registry.yarnpkg.com/react/-/react-16.12.0.tgz#0c0a9c6a142429e3614834d5a778e18aa78a0b83" + integrity sha512-fglqy3k5E+81pA8s+7K0/T3DBCF0ZDOher1elBFzF7O6arXJgzyu/FW+COxFvAWXJoJN9KIZbT2LXlukwphYTA== dependencies: loose-envify "^1.1.0" object-assign "^4.1.1" prop-types "^15.6.2" - scheduler "^0.13.6" read-pkg-up@^4.0.0: version "4.0.0" @@ -6764,6 +6799,19 @@ realpath-native@^1.1.0: dependencies: util.promisify "^1.0.0" +redux-thunk@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-2.3.0.tgz#51c2c19a185ed5187aaa9a2d08b666d0d6467622" + integrity sha512-km6dclyFnmcvxhAcrQV2AkZmPQjzPDjgVlQtR0EQjxZPyJ0BnMf3in1ryuR8A2qU0HldVRfxYXbFSKlI3N7Slw== + +redux@^4.0.5: + version "4.0.5" + resolved "https://registry.yarnpkg.com/redux/-/redux-4.0.5.tgz#4db5de5816e17891de8a80c424232d06f051d93f" + integrity sha512-VSz1uMAH24DM6MF72vcojpYPtrTUu3ByVWfPL1nPfVRb5mZVTve5GnNCUV53QM/BZ66xfWrm0CTWoM+Xlz8V1w== + dependencies: + loose-envify "^1.4.0" + symbol-observable "^1.2.0" + regenerate-unicode-properties@^8.0.2: version "8.0.2" resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-8.0.2.tgz#7b38faa296252376d363558cfbda90c9ce709662" @@ -7668,7 +7716,7 @@ svgo@^1.0.0, svgo@^1.0.5: unquote "~1.1.1" util.promisify "~1.0.0" -symbol-observable@^1.1.0: +symbol-observable@^1.1.0, symbol-observable@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804" integrity sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==