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 @@
- +