From 8aa9f013186c09e8a1a15bd9594de18580cd30db Mon Sep 17 00:00:00 2001 From: YuviPanda Date: Wed, 15 Nov 2023 13:13:33 +0530 Subject: [PATCH] [WIP] Use appropriate state management --- src/Form.jsx | 8 +++- src/ImageBuilder.jsx | 27 +++++++++--- src/ProfileOption.jsx | 34 +++++++++----- src/state.js | 100 ++++++++++++++++++++++++++++-------------- 4 files changed, 114 insertions(+), 55 deletions(-) diff --git a/src/Form.jsx b/src/Form.jsx index 4535f79..c7947e9 100644 --- a/src/Form.jsx +++ b/src/Form.jsx @@ -5,7 +5,7 @@ import "../node_modules/xterm/css/xterm.css"; import "./form.css"; import { ResourceSelector } from "./ResourceSelector"; import { SpawnerFormContext, SpawnerFormProvider } from "./state"; -import { useContext } from "react"; +import { useContext, useEffect } from "react"; /** * Generates the *contents* of the form shown in the profile selection page @@ -18,7 +18,11 @@ function Form() { // Currently, we only support a single profile, with many options. const profile = profileList[0]; - const { canSubmit } = useContext(SpawnerFormContext); + const { canSubmit, setProfileSlug } = useContext(SpawnerFormContext); + + useEffect(() => { + setProfileSlug(profile.slug); + }, []); return (
diff --git a/src/ImageBuilder.jsx b/src/ImageBuilder.jsx index ebd0d7a..d663d1f 100644 --- a/src/ImageBuilder.jsx +++ b/src/ImageBuilder.jsx @@ -2,6 +2,8 @@ import { Terminal } from "xterm"; import { FitAddon } from "xterm-addon-fit"; import { useEffect, useState } from "react"; import { BinderRepository } from "@jupyterhub/binderhub-client"; +import { SpawnerFormContext, CHOICE_TYPE } from "./state"; +import { useContext } from "react"; async function buildImage(repo, ref, term, fitAddon, onImageBuilt) { const providerSpec = "gh/" + repo + "/" + ref; @@ -43,7 +45,6 @@ async function buildImage(repo, ref, term, fitAddon, onImageBuilt) { break; } default: { - console.log("Unknown phase in response from server"); console.log(data); break; } @@ -89,14 +90,29 @@ function ImageLogs({ visible, setTerm, setFitAddon }) { ); } -export function ImageBuilder({ visible, unlistedInputName }) { +export function ImageBuilder({ visible, optionName }) { const [repo, setRepo] = useState(""); - const [builtImage, setBuiltImage] = useState(null); + const { setOptionValue } = useContext(SpawnerFormContext); // FIXME: Allow users to actually configure this const [ref, _] = useState("HEAD"); // eslint-disable-line no-unused-vars const [term, setTerm] = useState(null); const [fitAddon, setFitAddon] = useState(null); + const [imageName, setImageName] = useState(""); + + useEffect(() => { + setOptionValue(optionName, CHOICE_TYPE.UNLISTED, imageName); + }, [imageName]); + + useEffect(() => { + if (visible) { + // When the state of visibility changes, set option to be current imageName + // This is empty string when we first start, and last built image otherwise + // When we migrate *away* from this component, whatever we migrate to will + // set this instead. + setOptionValue(optionName, CHOICE_TYPE.UNLISTED, imageName); + } + }, [visible]); // We render everything, but only toggle visibility based on wether we are being // shown or hidden. This provides for more DOM stability, and also allows the image @@ -132,16 +148,13 @@ export function ImageBuilder({ visible, unlistedInputName }) { value="Build image" onClick={async () => { await buildImage(repo, ref, term, fitAddon, (imageName) => { - setBuiltImage(imageName); + setImageName(imageName); term.write( "\nImage has been built! Click the start button to launch your server", ); }); }} /> - {visible && builtImage && ( - - )}
choices[choiceName].default) || Object.keys(choices)[0]; + useEffect(() => { + // Mark the default option as 'selected' + setOptionValue(optionName, CHOICE_TYPE.LISTED, defaultChoiceName); + }, []); + let options = Object.keys(choices).map((choiceName) => { return { value: choiceName, label: choices[choiceName].display_name, description: choices[choiceName].description, + onSelected: () => { + setOptionValue(optionName, CHOICE_TYPE.LISTED, choiceName); + }, }; }); @@ -35,6 +43,7 @@ export function ProfileOption({ label: unlistedChoice.display_name_in_choices, description: unlistedChoice.description_in_choices, onSelected: () => { + setOptionValue(optionName, CHOICE_TYPE.UNLISTED, unlistedChoiceValue); setUnlistedChoiceVisible(true); }, onDeselected: () => { @@ -63,17 +72,14 @@ export function ProfileOption({ return ( <>
- +
@@ -86,10 +92,14 @@ export function ProfileOption({ {/* Save and restore the typed in value, so we don't lose it if the user selects another choice */} { setUnlistedChoiceValue(ev.target.value); + setOptionValue( + optionName, + CHOICE_TYPE.UNLISTED, + ev.target.value, + ); }} /> @@ -98,8 +108,8 @@ export function ProfileOption({ {extraSelectableItem && ( )} diff --git a/src/state.js b/src/state.js index 8cf1ac0..30c155e 100644 --- a/src/state.js +++ b/src/state.js @@ -1,67 +1,101 @@ import { createContext, useReducer } from "react"; -const IMAGE_OPTION_VIEWS = { - choices: Symbol("choices"), - specifier: Symbol("specifier"), - builder: Symbol("builder"), -}; - const INITIAL_STATE = { - canSubmit: true, - unlistedImage: "", - imageOptionView: IMAGE_OPTION_VIEWS.choices, + canSubmit: false, + optionValues: {}, + profileSlug: "", }; const ACTION_TYPES = { - SET_UNLISTED_IMAGE: Symbol("set-unlisted-image"), - SET_IMAGE_OPTION_VIEW: Symbol("set-image-option-view"), + SET_OPTION_VALUE: Symbol("set-choice"), + SET_PROFILE_SLUG: Symbol("set-profile-slug"), }; +export const CHOICE_TYPE = { + LISTED: Symbol("listed"), + UNLISTED: Symbol("unlisted"), +}; + +/** + * Return true if optionValues can be posted + * + * Currently only says 'invalid' when user has an unlisted choice chosen but empty. + * + * @param {object} optionValues + */ +function validateOptionValues(optionValues) { + for (const key of Object.keys(optionValues)) { + const value = optionValues[key]; + if (value.type === CHOICE_TYPE.UNLISTED) { + if (value.value.trim() === "") { + return false; + } + } + } + return true; +} + function reducer(oldState, action) { switch (action.type) { - case ACTION_TYPES.SET_IMAGE_OPTION_VIEW: - return { + case ACTION_TYPES.SET_OPTION_VALUE: { + const name = action.name; + const value = action.value; + const optionValues = { + ...oldState.optionValues, + [name]: value, + }; + const newState = { ...oldState, - imageOptionView: action.payload, - canSubmit: - action.payload == IMAGE_OPTION_VIEWS.choices || - oldState.unlistedImage !== "", + canSubmit: validateOptionValues(optionValues), + optionValues: optionValues, }; - case ACTION_TYPES.SET_UNLISTED_IMAGE: + console.log(newState); + return newState; + } + case ACTION_TYPES.SET_PROFILE_SLUG: { return { ...oldState, - unlistedImage: action.payload, - canSubmit: - oldState.imageOptionView == IMAGE_OPTION_VIEWS.choices || - action.payload !== "", + profileSlug: action.profileSlug, }; + } default: throw new Error(); } } -const SpawnerFormContext = createContext(); +export const SpawnerFormContext = createContext(); -const SpawnerFormProvider = ({ children }) => { +export const SpawnerFormProvider = ({ children }) => { const [state, dispatch] = useReducer(reducer, INITIAL_STATE); // These can be destructured out by any child element const value = { canSubmit: state.canSubmit, - imageOptionView: state.imageOptionView, - unlistedImage: state.unlistedImage, - setUnlistedImage: (unlistedImage) => { + setOptionValue: (name, type, value) => { dispatch({ - type: ACTION_TYPES.SET_UNLISTED_IMAGE, - payload: unlistedImage, + type: ACTION_TYPES.SET_OPTION_VALUE, + name: name, + value: { + type: type, + value: value, + }, }); }, - setImageOptionView: (imageOptionView) => { + /** + * @param {string} profileSlug Slug identifying current profile + */ + setProfileSlug: (profileSlug) => { dispatch({ - type: ACTION_TYPES.SET_IMAGE_OPTION_VIEW, - payload: imageOptionView, + type: ACTION_TYPES.SET_PROFILE_SLUG, + profileSlug: profileSlug, }); }, + /** + * Get formatted form values for support + * + * @returns {Map} + */ + getFormValues: () => {}, }; return ( @@ -70,5 +104,3 @@ const SpawnerFormProvider = ({ children }) => { ); }; - -export { SpawnerFormContext, SpawnerFormProvider, IMAGE_OPTION_VIEWS };