diff --git a/package.json b/package.json index 443b2c1..4dcf276 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,6 +49,7 @@ }, "dependencies": { "@types/jest": "^24.0.23", + "@types/node": "^12.12.11", "axios": "0.18.1", "husky": "^2.4.0", "lodash": "^4.17.13", @@ -56,7 +57,10 @@ "qs": "^6.7.0", "react": "^16.8.6", "react-dom": "^16.8.6", + "react-redux": "^7.1.3", "react-router-dom": "^5.0.0", + "redux": "^4.0.4", + "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/interfaces/asyncAction.ts b/src/core/interfaces/asyncAction.ts new file mode 100644 index 0000000..176204d --- /dev/null +++ b/src/core/interfaces/asyncAction.ts @@ -0,0 +1,11 @@ +export interface asyncAction { + type: string; + payload: any; + errorMessage: string; +} + +export interface asyncState { + loading: boolean; + errorMessage: string; + data: any; +} diff --git a/src/core/store/services/actions.ts b/src/core/store/services/actions.ts new file mode 100644 index 0000000..e6529fa --- /dev/null +++ b/src/core/store/services/actions.ts @@ -0,0 +1,131 @@ +import axios from "axios"; +import { getSheetData } from "~/core/utils"; +import { stringify } from "qs"; + +const SHEET_ID = + process.env.SHEET_ID || "1ZPRRR8T51Tk-Co8h_GBh3G_7P2F7ZrYxPQDSYycpCUg"; +const API_KEY = process.env.GOOGLE_API_KEY; + +const DEFAULT_ERROR_MESSAGE = "Something went wrong!"; + +// Create our API client and inject the API key into every request +const client = axios.create({ + baseURL: `https://sheets.googleapis.com/v4/spreadsheets/${SHEET_ID}/`, + params: { key: API_KEY }, +}); + +// Utility for fetching metadata about the sheets in the spreadsheet +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"); +}; + +// Utility for fetching a single sheet from a spreadsheet by its title +const getSheetByTitle = async title => + await client.get("values:batchGet", { + params: { + majorDimension: "ROWS", + ranges: title, + }, + }); + +/** + * Handlers for the various types of data we want from the Sheets API + * They should return parsed sheet data, rather than the raw response + * from the API. + */ +// Fetches all services and updates global state. "redux-thunk" action. +export const getAllServices = () => async (dispatch: Function) => { + let allServices = []; + + // Dispatch Loading action + 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 }); + }, + }); + /*allServices = allServicesRes.data.valueRanges.reduce((list, type) => { + return [...list, ...type.values]; + }, []);*/ + allServices = getSheetData(allServicesRes.data); + } catch (e) { + // Dispatch a 'failure' action if the request failed + return dispatch(getServicesError(DEFAULT_ERROR_MESSAGE)); + } + + // Dispatch services data + dispatch(getServicesSuccess(allServices)); +}; + +// Returns the spreadsheet's index sheet +export const getServicesIndex = () => async (dispatch: Function) => { + dispatch(getServicesIndexLoading()); + try { + const res = await getSheetByTitle("Index"); + return dispatch(getServicesIndexSuccess(getSheetData(res.data))); + } catch (e) { + return dispatch(getServicesIndexError(e)); + } +}; + +// Returns the spreadsheet's services by type +export const getServicesByType = () => async type => { + const res = await getSheetByTitle(type); + return getSheetData(res.data); +}; + +// Default error message +export { DEFAULT_ERROR_MESSAGE }; + +// getAllServices actions +export const getServicesSuccess = (payload: any) => ({ + 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, +}); + +// getServicesIndex actions +export const getServicesIndexSuccess = (payload: any) => ({ + type: "GET_SERVICES_INDEX_SUCCESS", + payload, + errorMessage: null, +}); + +export const getServicesIndexError = (errorMessage: string) => ({ + type: "GET_SERVICES_INDEX_ERROR", + payload: null, + errorMessage, +}); + +export const getServicesIndexLoading = () => ({ + type: "GET_SERVICES_INDEX_LOADING", + payload: null, + errorMessage: null, +}); diff --git a/src/core/store/services/reducers.ts b/src/core/store/services/reducers.ts new file mode 100644 index 0000000..a5d686f --- /dev/null +++ b/src/core/store/services/reducers.ts @@ -0,0 +1,56 @@ +import { asyncState, asyncAction } from "~/core/interfaces/asyncAction"; + +// Reducer that handles state for the useAPI hook +export const getServicesReducer = ( + state: asyncState = { loading: false, errorMessage: null, data: null }, + action: asyncAction +) => { + switch (action.type) { + case "GET_SERVICES_LOADING": + return { ...state, loading: false, data: null, errorMessage: null }; + case "GET_SERVICES_ERROR": + return { + ...state, + loading: false, + data: null, + errorMessage: action.errorMessage, + }; + case "GET_SERVICES_SUCCESS": + return { + ...state, + loading: false, + data: action.payload, + errorMessage: null, + }; + default: + return state; + } +}; + +export const getServicesIndex = ( + state: asyncState = { loading: false, errorMessage: null, data: null }, + action: asyncAction +) => { + switch (action.type) { + case "GET_SERVICES_INDEX_LOADING": + return { ...state, loading: false, data: null, errorMessage: null }; + case "GET_SERVICES_INDEX_ERROR": + return { + ...state, + loading: false, + data: null, + errorMessage: action.errorMessage, + }; + case "GET_SERVICES_INDEX_SUCCESS": + return { + ...state, + loading: false, + data: action.payload, + errorMessage: null, + }; + default: + return state; + } +}; + +export default getServicesReducer; diff --git a/src/core/store/services/useServices.ts b/src/core/store/services/useServices.ts new file mode 100644 index 0000000..4a2fbf9 --- /dev/null +++ b/src/core/store/services/useServices.ts @@ -0,0 +1,59 @@ +import { useEffect } from "react"; +import { useSelector, useDispatch } from "react-redux"; +import { getAllServices, getServicesIndex } from "./actions"; + +/** + * Hook to use all services data. Service data is parsed into an object. + * For example, { loading, errorMessage, data: {...} } + * See ~/core/interfaces/formattedService.ts for more info. + */ +export const useServices = () => { + const services = useSelector(state => state.services); + const dispatch = useDispatch(); + + // FETCH DATA 3 ATTEMPTS WHENEVER data = null && loading = false + useEffect(() => { + // Do not make additional requests for services. + if (services && services.data) return; + dispatch(getAllServices()); + }, []); + + return services; +}; + +/** + * Hook to fetch index sheet data. Used to define categories. + */ +export const useServicesIndex = () => { + const { loading = false, errorMessage = null, data = null } = useSelector( + state => state.servicesIndex + ); + const dispatch = useDispatch(); + + useEffect(() => { + // Don't make additional requests. + if (data) return; + dispatch(getServicesIndex()); + }, []); + + return { loading, errorMessage, data }; +}; + +/** + * Hooks for Categories, Types, and Services pages. + * Lifecycle: + * 1. GET all google sheets + * 2. Store globally in array + * 3. Hooks pull their data from the array + */ + +// Returns a list of categories +export const useCategories = () => {}; + +// Returns a list of subtypes for a category +//export const useSubTypes = category => {}; + +// Returns a list of services for a subtype +//export const useSubTypeServices = subtype => {}; + +export default useServices; diff --git a/src/core/store/store.ts b/src/core/store/store.ts new file mode 100644 index 0000000..4dc74d5 --- /dev/null +++ b/src/core/store/store.ts @@ -0,0 +1,20 @@ +import { createStore, combineReducers, applyMiddleware } from "redux"; +import thunk from "redux-thunk"; +import { + getServicesReducer as services, + getServicesIndex as servicesIndex, +} from "~/core/store/services/reducers"; + +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/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..b155582 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/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..d8f4237 100644 --- a/src/pages/categories/index.jsx +++ b/src/pages/categories/index.jsx @@ -1,10 +1,10 @@ import React, { Fragment } from "react"; +import { useServicesIndex } from "~/core/store/services/useServices"; import styled from "styled-components"; import Logo from "~/components/logo"; import Loader from "~/components/loader"; import Error from "~/components/error"; import { H1 } from "~/components/typography"; -import api, { useAPI } from "~/core/api"; import CategoryCard from "./category-card"; const StyledLogo = styled(Logo)({ @@ -39,7 +39,7 @@ const getCategories = data => ); const Categories = () => { - const { loading, errorMessage, data } = useAPI(api.getIndex); + const { loading, errorMessage, data } = useServicesIndex(); if (errorMessage) { return ; @@ -49,7 +49,7 @@ const Categories = () => { Pick a category to see services in your area. - {loading ? ( + {loading || !data ? ( ) : ( diff --git a/src/pages/search/index.jsx b/src/pages/search/index.jsx index 0be60a0..df64a18 100644 --- a/src/pages/search/index.jsx +++ b/src/pages/search/index.jsx @@ -4,7 +4,7 @@ import Logo from "~/components/logo"; import Loader from "~/components/loader"; import InputAndSubmit from "~/components/inputAndSubmit"; import { H1 } from "~/components/typography"; -import api, { useAPI } from "~/core/api"; +import { useSelector } from "react-redux"; import ServiceCard from "~/pages/services/service-card"; import { formatService } from "~/core/utils"; @@ -58,8 +58,8 @@ const queryServices = (data, query) => { }; const Search = () => { - const { loading, error, data } = useAPI(api.getAllServices); - const index = useAPI(api.getIndex); + const { loading, error, data } = useSelector(state => state.services); + const index = { error: null }; const urlQuery = new URLSearchParams(location.search); const [searchValue, setSearchValue] = useState(urlQuery.get("s") || ""); 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..2186396 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" @@ -6764,6 +6800,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.4: + version "4.0.4" + resolved "https://registry.yarnpkg.com/redux/-/redux-4.0.4.tgz#4ee1aeb164b63d6a1bcc57ae4aa0b6e6fa7a3796" + integrity sha512-vKv4WdiJxOWKxK0yRoaK3Y4pxxB0ilzVx6dszU2W8wLxlb2yikRph4iV/ymtdJ6ZxpBLFbyrxklnT5yBbQSl3Q== + 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 +7717,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==