trezy/firebase-system-update

View on GitHub
src/contexts/AppContext.js

Summary

Maintainability
D
3 days
Test Coverage
// Module imports
import {
    createContext,
    useCallback,
    useContext,
    useEffect,
    useReducer,
} from 'react'
// import {
//     collection as firestoreCollection,
//     doc as firestoreDoc,
//     onSnapshot as onFirestoreSnapshot,
// } from 'firebase/firestore'
import PropTypes from 'prop-types'





// Local imports
// import { firestore } from 'helpers/firebase'
import * as API from 'helpers/api'
import * as Cookies from 'helpers/Cookies'





// Constants
const ALL_STEPS = {
    selectServices: 'Choose Firebase Services',
    'setup::realtimeDatabase': 'Setup Realtime Database',
    'setup::firestore': 'Setup Firestore',
    'setup::remoteConfig': 'Setup Remote Config',
    'setup::storage': 'Setup Firebase Storage',
    setupServiceAccount: 'Setup Your Service Account',
}
const INITIAL_STATE = {
    data: {
        code: null,
        configurationID: null,
        projects: null,
        nextURL: null,
        teamID: null,
    },

    states: {
        areProjectsLoaded: false,
        isConfigured: false,
        isConfiguring: false,
        isInstalled: false,
        isInstalling: false,
        isLoaded: false,
        isLoading: true,
        isSaving: false,
        isUpdatingProjects: false,
    },
}





function reducer(state, action) {
    const {
        payload,
        type,
    } = action
    const newState = {
        data: {
            ...INITIAL_STATE.data,
            ...state.data,
        },

        states: {
            ...INITIAL_STATE.states,
            ...state.states,
        },
    }

    switch (type) {
        case 'app installed':
            Cookies.set('vercelAccessToken', payload.accessToken, {
                maxAge: 60 * 60 * 24 * 30,
                path: '/',
            })
            newState.data.projects = Object
                .entries(payload.projects)
                .reduce((accumulator, [id, project]) => {
                    accumulator[id] = {
                        ...project,
                        currentStep: 'selectServices',
                        isSaving: false,
                        steps: [
                            {
                                id: 'selectServices',
                                isComplete: false,
                                label: ALL_STEPS.selectServices,
                            },
                        ],
                    }
                    return accumulator
                }, {})
            newState.states.isInstalled = true
            newState.states.isInstalling = false
            break

        case 'change step completion':
            {
                const project = { ...newState.data.projects[payload.projectID] }

                project.steps = [...project.steps]

                const stepIndex = project.steps.findIndex(({ id }) => id === payload.stepID)
                const step = { ...project.steps[stepIndex] }

                step.isComplete = payload.isComplete

                project.steps[stepIndex] = step
                newState.data.projects[payload.projectID] = project
            }
            break

        case 'finish project configuration':
            {
                newState.data.projects = { ...newState.data.projects }
                const project = { ...newState.data.projects[payload.projectID] }
                const finalStepIndex = project.steps.length - 1
                const finalStep = { ...project.steps[finalStepIndex] }

                finalStep.isComplete = true
                project.serviceAccountJSON = payload.serviceAccountJSON
                project.steps[finalStepIndex] = finalStep
                newState.data.projects[payload.projectID] = project
            }
            break

        case 'go back':
            {
                const project = { ...newState.data.projects[payload.projectID] }

                const currentStepIndex = project.steps.findIndex(({ id }) => id === project.currentStep)

                project.currentStep = project.steps[currentStepIndex - 1].id

                newState.data.projects[payload.projectID] = project
            }
            break

        case 'installing app':
            newState.states.isInstalling = true
            break

        case 'load configuration data':
            newState.data.code = payload.code
            newState.data.configurationID = payload.configurationID
            newState.data.nextURL = decodeURIComponent(payload.next)
            newState.data.teamID = payload.teamID

            newState.states.isLoaded = true
            newState.states.isLoading = false
            break

        case 'save project':
            newState.states.isSaving = true
            newState.data.projects[payload.projectID] = {
                ...newState.data.projects[payload.projectID],
                isSaving: true,
            }
            break

        case 'saved project':
            newState.states.isSaving = false
            newState.data.projects[payload.projectID] = {
                ...newState.data.projects[payload.projectID],
                isSaving: false,
            }
            break

        case 'update project service config':
            {
                const project = { ...newState.data.projects[payload.projectID] }

                project.serviceConfigs[payload.service] = payload.serviceConfig

                const currentStepIndex = project.steps.findIndex(({ id }) => id === project.currentStep)

                if (currentStepIndex < (project.steps.length - 1)) {
                    project.currentStep = project.steps[currentStepIndex + 1].id
                }

                newState.data.projects[payload.projectID] = project
            }
            break

        case 'update project services':
            {
                const project = { ...newState.data.projects[payload.projectID] }

                project.steps[0].isComplete = true

                Object.entries(payload.serviceStates).forEach(([service, isEnabled]) => {
                    const stepID = `setup::${service}`
                    project.serviceConfigs[service].isEnabled = isEnabled

                    if (isEnabled) {
                        project.steps.push({
                            id: stepID,
                            isComplete: false,
                            label: ALL_STEPS[stepID],
                        })

                        if (!project.currentStep.startsWith('setup::')) {
                            project.currentStep = stepID
                        }
                    }
                })

                project.steps.push({
                    id: 'setupServiceAccount',
                    isComplete: false,
                    label: ALL_STEPS.setupServiceAccount,
                })

                newState.data.projects[payload.projectID] = project
            }
            break

        case 'update projects':
            newState.data.projects = payload
            newState.states.areProjectsLoaded = true
            newState.states.isUpdatingProjects = false
            break

        case 'updating projects':
            newState.states.isUpdatingProjects = true
            break

        default:
            console.warn(`Unrecognized action dispatched: ${type}`, payload)
            return state
    }

    console.log(type, newState)

    return newState
}





const AppContext = createContext({
    ...INITIAL_STATE,
    cancelConfiguration: () => {},
    changeStepCompletion: () => {},
    finishConfiguration: () => {},
    finishProjectConfiguration: () => {},
    finishSaving: () => {},
    goBack: () => {},
    loadConfigurationData: () => {},
    startSaving: () => {},
    updateProjectServiceConfig: () => {},
    updateProjectServices: () => {},
})

const AppContextProvider = props => {
    const {
        children,
        code,
        configurationID,
        next,
        teamID,
    } = props
    const [state, dispatch] = useReducer(reducer, { ...INITIAL_STATE })

    const cancelConfiguration = useCallback(() => window.close(), [])

    const changeStepCompletion = useCallback((projectID, stepID, isComplete) => {
        dispatch({
            payload: {
                isComplete,
                projectID,
                stepID,
            },
            type: 'change step completion',
        })
    }, [dispatch])

    const finishConfiguration = useCallback(() => {
        window.location = state.data.nextURL
    }, [state.data.nextURL])

    const finishProjectConfiguration = useCallback((projectID, serviceAccountJSON) => {
        dispatch({
            payload: {
                projectID,
                serviceAccountJSON,
            },
            type: 'finish project configuration',
        })
    }, [dispatch])

    const saveProject = useCallback(async (projectID, serviceAccountJSON) => {
        dispatch({
            payload: { projectID },
            type: 'save project',
        })

        const serializedProject = {
            ...state.data.projects[projectID],
            serviceAccountJSON,
        }

        delete serializedProject.currentStep
        delete serializedProject.isSaving
        delete serializedProject.steps

        await API.saveProject({
            projectID,
            project: serializedProject,
        })

        dispatch({
            payload: { projectID },
            type: 'saved project',
        })
    }, [
        dispatch,
        state,
    ])

    const goBack = useCallback(projectID => {
        dispatch({
            payload: { projectID },
            type: 'go back',
        })
    }, [dispatch])

    const handleProjectsSnapshot = useCallback(snapshot => {
        dispatch({ type: 'updating projects' })

        const projects = {}

        snapshot.docChanges().forEach(change => {
            const {
                doc,
                type,
            } = change

            if (['added', 'modified'].includes(type)) {
                projects[doc.id] = doc.data()
            }
        })

        dispatch({
            payload: projects,
            type: 'update projects',
        })
    }, [dispatch])

    const loadConfigurationData = useCallback(configurationData => {
        dispatch({
            payload: configurationData,
            type: 'load configuration data',
        })
    }, [dispatch])

    const install = useCallback(async () => {
        dispatch({ type: 'installing app' })

        try {
            const response = await API.install({
                code: state.data.code,
                configurationID: state.data.configurationID,
                teamID: state.data.teamID,
            })

            if (response.ok) {
                const responseJSON = await response.json()

                dispatch({
                    payload: responseJSON,
                    type: 'app installed',
                })
            }
        } catch (error) {
            console.error(error)
        }
    }, [
        dispatch,
        state.data.code,
        state.data.configurationID,
        state.data.teamID,
    ])

    const updateProjectServiceConfig = useCallback((projectID, service, serviceConfig) => {
        dispatch({
            payload: {
                projectID,
                service,
                serviceConfig,
            },
            type: 'update project service config',
        })
    }, [dispatch])

    const updateProjectServices = useCallback((projectID, serviceStates) => {
        dispatch({
            payload: {
                projectID,
                serviceStates,
            },
            type: 'update project services',
        })
        // dispatch({
        //     payload: {
        //         projectID,
        //         nextStep: ''
        //     },
        //     type: 'next step',
        // })
    }, [dispatch])

    // Initialize configuration data
    useEffect(() => {
        if (!state.states.isLoaded) {
            loadConfigurationData({
                code,
                configurationID,
                next,
                teamID,
            })
        }
    }, [
        code,
        configurationID,
        state.states.isLoaded,
        loadConfigurationData,
        next,
        teamID,
    ])

    // Start install if it's not already in progress
    useEffect(() => {
        if (!state.states.isLoading && !state.states.isInstalling && !state.states.isInstalled) {
            install()
        }
    }, [
        install,
        state.states.isInstalled,
        state.states.isInstalling,
        state.states.isLoading,
    ])

    // TODO: Figure out why Firstore can't connect
    // Load and watch for changes to projects
    // useEffect(() => {
    //     const {
    //         data: {
    //             configurationID,
    //         },
    //         states: {
    //             isInstalled,
    //         },
    //     } = state

    //     if (isInstalled) {
    //         const projectsCollection = firestoreCollection(firestore, 'configurations', configurationID, 'projects')
    //         return onFirestoreSnapshot(projectsCollection, handleProjectsSnapshot)
    //     }
    // }, [
    //     handleProjectsSnapshot,
    //     state.data.configurationID,
    //     state.states.isInstalled,
    // ])

    return (
        <AppContext.Provider
            value={{
                ...state,
                cancelConfiguration,
                changeStepCompletion,
                finishConfiguration,
                finishProjectConfiguration,
                goBack,
                loadConfigurationData,
                saveProject,
                updateProjectServiceConfig,
                updateProjectServices,
            }}>
            {children}
        </AppContext.Provider>
    )
}

AppContextProvider.propTypes = {
    children: PropTypes.node.isRequired,
}

const useAppContext = () => useContext(AppContext)





export {
    AppContext,
    AppContextProvider,
    useAppContext,
}