Skip to content

Commit

Permalink
[WIP] Use appropriate state management
Browse files Browse the repository at this point in the history
  • Loading branch information
yuvipanda committed Mar 26, 2024
1 parent 5874628 commit 8aa9f01
Show file tree
Hide file tree
Showing 4 changed files with 114 additions and 55 deletions.
8 changes: 6 additions & 2 deletions src/Form.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 (
<div className="form-grid">
Expand Down
27 changes: 20 additions & 7 deletions src/ImageBuilder.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 && (
<input name={unlistedInputName} type="hidden" value={builtImage} />
)}
</div>

<ImageLogs
Expand Down
34 changes: 22 additions & 12 deletions src/ProfileOption.jsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { CustomizedSelect } from "./CustomSelect";
import { SpawnerFormContext, CHOICE_TYPE } from "./state";
import { useContext } from "react";

export function ProfileOption({
profileSlug,
optionName,
displayName,
choices,
Expand All @@ -14,18 +15,25 @@ export function ProfileOption({
const [extraSelectableItemVisible, setExtraSelectableItemVisible] =
useState(false);

const listedInputName = `profile-option-${profileSlug}--${optionName}`;
const unlistedInputName = `${listedInputName}--unlisted-choice`;
const { setOptionValue } = useContext(SpawnerFormContext);

const defaultChoiceName =
Object.keys(choices).find((choiceName) => 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);
},
};
});

Expand All @@ -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: () => {
Expand Down Expand Up @@ -63,17 +72,14 @@ export function ProfileOption({
return (
<>
<div className="profile-option-label-container">
<label htmlFor={listedInputName}>{displayName}</label>
<label>{displayName}</label>
</div>
<div className="profile-option-control-container">
<CustomizedSelect
options={options}
name={
extraSelectableItemVisible || unlistedChoiceVisible
? null
: listedInputName
}
name={null}
defaultValue={defaultOption}
on
/>
</div>

Expand All @@ -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 */}
<input
type="text"
name={unlistedInputName}
defaultValue={unlistedChoiceValue}
onChange={(ev) => {
setUnlistedChoiceValue(ev.target.value);
setOptionValue(
optionName,
CHOICE_TYPE.UNLISTED,
ev.target.value,
);
}}
/>
</div>
Expand All @@ -98,8 +108,8 @@ export function ProfileOption({

{extraSelectableItem && (
<extraSelectableItem.component
optionName={optionName}
visible={extraSelectableItemVisible}
unlistedInputName={unlistedInputName}
/>
)}
</>
Expand Down
100 changes: 66 additions & 34 deletions src/state.js
Original file line number Diff line number Diff line change
@@ -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<string, string>}
*/
getFormValues: () => {},
};

return (
Expand All @@ -70,5 +104,3 @@ const SpawnerFormProvider = ({ children }) => {
</SpawnerFormContext.Provider>
);
};

export { SpawnerFormContext, SpawnerFormProvider, IMAGE_OPTION_VIEWS };

0 comments on commit 8aa9f01

Please sign in to comment.