diff --git a/src/ImageBuilder.jsx b/src/ImageSelect/ImageBuilder.jsx similarity index 91% rename from src/ImageBuilder.jsx rename to src/ImageSelect/ImageBuilder.jsx index ebd0d7a..d0ee067 100644 --- a/src/ImageBuilder.jsx +++ b/src/ImageSelect/ImageBuilder.jsx @@ -1,7 +1,8 @@ +import { useEffect, useState, useContext } from "react"; import { Terminal } from "xterm"; import { FitAddon } from "xterm-addon-fit"; -import { useEffect, useState } from "react"; import { BinderRepository } from "@jupyterhub/binderhub-client"; +import { SpawnerFormContext } from "../state"; async function buildImage(repo, ref, term, fitAddon, onImageBuilt) { const providerSpec = "gh/" + repo + "/" + ref; @@ -89,9 +90,9 @@ function ImageLogs({ visible, setTerm, setFitAddon }) { ); } -export function ImageBuilder({ visible, unlistedInputName }) { +export function ImageBuilder({ visible, name }) { const [repo, setRepo] = useState(""); - const [builtImage, setBuiltImage] = useState(null); + const { customImage, setCustomImage } = useContext(SpawnerFormContext); // FIXME: Allow users to actually configure this const [ref, _] = useState("HEAD"); // eslint-disable-line no-unused-vars @@ -132,15 +133,15 @@ export function ImageBuilder({ visible, unlistedInputName }) { value="Build image" onClick={async () => { await buildImage(repo, ref, term, fitAddon, (imageName) => { - setBuiltImage(imageName); + setCustomImage(imageName); term.write( "\nImage has been built! Click the start button to launch your server", ); }); }} /> - {visible && builtImage && ( - + {visible && customImage && ( + )} diff --git a/src/ImageSelect/index.jsx b/src/ImageSelect/index.jsx new file mode 100644 index 0000000..6f91689 --- /dev/null +++ b/src/ImageSelect/index.jsx @@ -0,0 +1,70 @@ +import { useContext } from "react"; +import { SelectField, TextField } from "../components/form/fields"; +import { ImageBuilder } from "./ImageBuilder"; +import useSelectOptions from "../hooks/useSelectOptions"; +import { SpawnerFormContext } from "../state"; + +const extraChoices = [ + { + value: "dockerImage", + label: "Specify an existing docker image", + description: + "Use a pre-existing docker image from a public docker registry", + }, + { + value: "buildImage", + label: "Build your own image", + description: + "Use a mybinder.org compatible GitHub repo to build your own image", + }, +]; + +function ImageSelect({ config }) { + const { profile } = useContext(SpawnerFormContext); + const FIELD_ID = `profile-option-${profile.slug}--image`; + const FIELD_ID_UNLISTED = `${FIELD_ID}--unlisted-choice`; + const { display_name, choices } = config; + + const { options, defaultOption } = useSelectOptions(choices, extraChoices); + + const { + image, + setImage, + customImage, + setCustomImage, + errors, + touched, + setTouched, + } = useContext(SpawnerFormContext); + + return ( + <> + setImage(e.value)} + onBlur={() => setTouched(FIELD_ID, true)} + /> + {image === "dockerImage" && ( + setCustomImage(e.target.value)} + onBlur={() => setTouched(FIELD_ID_UNLISTED, true)} + /> + )} + {image === "buildImage" && ( + + )} + + ); +} + +export default ImageSelect; diff --git a/src/Form.jsx b/src/ProfileForm.jsx similarity index 56% rename from src/Form.jsx rename to src/ProfileForm.jsx index 4535f79..7cfccf7 100644 --- a/src/Form.jsx +++ b/src/ProfileForm.jsx @@ -1,11 +1,10 @@ -import { createRoot } from "react-dom/client"; - +import { useContext } from "react"; import "../node_modules/xterm/css/xterm.css"; import "./form.css"; -import { ResourceSelector } from "./ResourceSelector"; -import { SpawnerFormContext, SpawnerFormProvider } from "./state"; -import { useContext } from "react"; +import ImageSelect from "./ImageSelect"; +import ResourceSelect from "./ResourceSelect"; +import { SpawnerFormContext } from "./state"; /** * Generates the *contents* of the form shown in the profile selection page @@ -14,11 +13,11 @@ import { useContext } from "react"; * be generated here. */ function Form() { - const profileList = window.profileList; - // Currently, we only support a single profile, with many options. - const profile = profileList[0]; - const { canSubmit } = useContext(SpawnerFormContext); + const { profile, errors } = useContext(SpawnerFormContext); + const { image, resources } = profile.profile_options; + + const canSubmit = Object.keys(errors).length === 0; return (
@@ -30,21 +29,18 @@ function Form() { checked readOnly /> - - + +
+
); } -const root = createRoot(document.getElementById("form")); -root.render( - -
- , -); +export default Form; diff --git a/src/ProfileOption.jsx b/src/ProfileOption.jsx deleted file mode 100644 index 2d9f1dc..0000000 --- a/src/ProfileOption.jsx +++ /dev/null @@ -1,107 +0,0 @@ -import { useState } from "react"; -import { CustomizedSelect } from "./CustomSelect"; - -export function ProfileOption({ - profileSlug, - optionName, - displayName, - choices, - unlistedChoice, - extraSelectableItem, -}) { - const [unlistedChoiceVisible, setUnlistedChoiceVisible] = useState(false); - const [unlistedChoiceValue, setUnlistedChoiceValue] = useState(""); - const [extraSelectableItemVisible, setExtraSelectableItemVisible] = - useState(false); - - const listedInputName = `profile-option-${profileSlug}--${optionName}`; - const unlistedInputName = `${listedInputName}--unlisted-choice`; - - const defaultChoiceName = - Object.keys(choices).find((choiceName) => choices[choiceName].default) || - Object.keys(choices)[0]; - - let options = Object.keys(choices).map((choiceName) => { - return { - value: choiceName, - label: choices[choiceName].display_name, - description: choices[choiceName].description, - }; - }); - - if (unlistedChoice && unlistedChoice.enabled) { - options.push({ - value: "--unlisted-choice", - label: unlistedChoice.display_name_in_choices, - description: unlistedChoice.description_in_choices, - onSelected: () => { - setUnlistedChoiceVisible(true); - }, - onDeselected: () => { - setUnlistedChoiceVisible(false); - }, - }); - } - const defaultOption = options.find( - (option) => option.value === defaultChoiceName, - ); - - if (extraSelectableItem) { - options.push({ - value: "--extra-selectable-item", - label: extraSelectableItem.display_name_in_choices, - description: extraSelectableItem.description_in_choices, - onSelected: () => { - setExtraSelectableItemVisible(true); - }, - onDeselected: () => { - setExtraSelectableItemVisible(false); - }, - }); - } - - return ( - <> -
- -
-
- -
- - {unlistedChoiceVisible && ( - <> -
- -
-
- {/* Save and restore the typed in value, so we don't lose it if the user selects another choice */} - { - setUnlistedChoiceValue(ev.target.value); - }} - /> -
- - )} - - {extraSelectableItem && ( - - )} - - ); -} diff --git a/src/ResourceSelect.jsx b/src/ResourceSelect.jsx new file mode 100644 index 0000000..b5813d2 --- /dev/null +++ b/src/ResourceSelect.jsx @@ -0,0 +1,27 @@ +import { useContext } from "react"; +import useSelectOptions from "./hooks/useSelectOptions"; +import { SpawnerFormContext } from "./state"; +import { SelectField } from "./components/form/fields"; + +function ResourceSelect({ config }) { + const { display_name, choices } = config; + + const { options, defaultOption } = useSelectOptions(choices); + const { setResource, profile, touched, setTouched, errors } = + useContext(SpawnerFormContext); + const FIELD_ID = `profile-option-${profile.slug}--resource`; + + return ( + setResource(e.value)} + onBlur={() => setTouched(FIELD_ID, true)} + /> + ); +} + +export default ResourceSelect; diff --git a/src/ResourceSelector.jsx b/src/ResourceSelector.jsx deleted file mode 100644 index 58b42fd..0000000 --- a/src/ResourceSelector.jsx +++ /dev/null @@ -1,27 +0,0 @@ -import { ProfileOption } from "./ProfileOption"; -import { ImageBuilder } from "./ImageBuilder"; - -export function ResourceSelector({ profile }) { - const options = profile.profile_options; - return Object.keys(options).map((optionName) => { - const optionBody = options[optionName]; - return ( - - ); - }); -} diff --git a/src/CustomSelect.jsx b/src/components/form/CustomSelect.jsx similarity index 92% rename from src/CustomSelect.jsx rename to src/components/form/CustomSelect.jsx index 812536b..f7b9912 100644 --- a/src/CustomSelect.jsx +++ b/src/components/form/CustomSelect.jsx @@ -23,7 +23,7 @@ import Select from "react-select"; * @param {Props} props * @returns */ -export function CustomizedSelect({ options, ...props }) { +export function CustomizedSelect({ options, hasError, ...props }) { const [lastSelectedChoice, setLastSelectedChoice] = useState(null); return ( + + ); +} diff --git a/src/components/form/index.js b/src/components/form/index.js new file mode 100644 index 0000000..e69de29 diff --git a/src/form.css b/src/form.css index 71c45c7..0cd14b8 100644 --- a/src/form.css +++ b/src/form.css @@ -4,15 +4,17 @@ } /* Layout */ -.form-grid { + +.profile-option-container { display: grid; grid-template-columns: 1fr 6fr; - gap: 16px; + gap: 1rem; + margin-bottom: 2rem; } .profile-option-label-container { grid-column-start: 1; - align-self: center; + align-self: start; justify-self: right; text-align: right; } @@ -38,6 +40,21 @@ Achieves similar visual effect. */ padding: 8px; + border: 1px solid gray; + border-radius: 4px; +} + +.profile-option-container.has-error label { + color: red; +} + +.profile-option-container.has-error input { + border-color: red; +} + +.profile-option-control-error { + color: red; + margin-top: 0.5rem; } /* react-select styling */ diff --git a/src/hooks/useSelectOptions.js b/src/hooks/useSelectOptions.js new file mode 100644 index 0000000..7b4b232 --- /dev/null +++ b/src/hooks/useSelectOptions.js @@ -0,0 +1,30 @@ +import { useMemo } from "react"; + +function useSelectOptions(choices, extraChoices = []) { + const options = useMemo(() => { + const defaultChoices = Object.keys(choices).map((choiceName) => { + return { + value: choiceName, + label: choices[choiceName].display_name, + description: choices[choiceName].description, + }; + }); + + return [...defaultChoices, ...extraChoices]; + }, [choices]); + + const defaultChoiceName = + Object.keys(choices).find((choiceName) => choices[choiceName].default) || + Object.keys(choices)[0]; + + const defaultOption = options.find( + (option) => option.value === defaultChoiceName, + ); + + return { + options, + defaultOption, + }; +} + +export default useSelectOptions; diff --git a/src/index.jsx b/src/index.jsx new file mode 100644 index 0000000..2b5ea92 --- /dev/null +++ b/src/index.jsx @@ -0,0 +1,10 @@ +import { createRoot } from "react-dom/client"; +import { SpawnerFormProvider } from "./state"; +import Form from "./ProfileForm"; + +const root = createRoot(document.getElementById("form")); +root.render( + + + , +); diff --git a/src/state.js b/src/state.js index 8cf1ac0..c25eb5e 100644 --- a/src/state.js +++ b/src/state.js @@ -1,67 +1,72 @@ -import { createContext, useReducer } from "react"; +import { createContext, useCallback, useMemo, useState } 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, -}; - -const ACTION_TYPES = { - SET_UNLISTED_IMAGE: Symbol("set-unlisted-image"), - SET_IMAGE_OPTION_VIEW: Symbol("set-image-option-view"), -}; +export const SpawnerFormContext = createContext(); -function reducer(oldState, action) { - switch (action.type) { - case ACTION_TYPES.SET_IMAGE_OPTION_VIEW: - return { - ...oldState, - imageOptionView: action.payload, - canSubmit: - action.payload == IMAGE_OPTION_VIEWS.choices || - oldState.unlistedImage !== "", - }; - case ACTION_TYPES.SET_UNLISTED_IMAGE: - return { - ...oldState, - unlistedImage: action.payload, - canSubmit: - oldState.imageOptionView == IMAGE_OPTION_VIEWS.choices || - action.payload !== "", - }; - default: - throw new Error(); - } +function getDefaultOption(choices) { + return ( + Object.keys(choices).find((choiceName) => choices[choiceName].default) || + Object.keys(choices)[0] + ); } -const SpawnerFormContext = createContext(); +export const SpawnerFormProvider = ({ children }) => { + const profileList = window.profileList; + const profile = profileList[0]; + + const defaultImageKey = getDefaultOption( + profile.profile_options.image.choices, + ); + const defaultResourceKey = getDefaultOption( + profile.profile_options.resources.choices, + ); -const SpawnerFormProvider = ({ children }) => { - const [state, dispatch] = useReducer(reducer, INITIAL_STATE); + const [image, setImage] = useState(defaultImageKey); + const [customImage, setCustomImage] = useState(""); + const [resource, setResource] = useState(defaultResourceKey); - // These can be destructured out by any child element - const value = { - canSubmit: state.canSubmit, - imageOptionView: state.imageOptionView, - unlistedImage: state.unlistedImage, - setUnlistedImage: (unlistedImage) => { - dispatch({ - type: ACTION_TYPES.SET_UNLISTED_IMAGE, - payload: unlistedImage, - }); - }, - setImageOptionView: (imageOptionView) => { - dispatch({ - type: ACTION_TYPES.SET_IMAGE_OPTION_VIEW, - payload: imageOptionView, + const [touched, setTouched] = useState({}); + const setFieldTouched = useCallback( + (fieldName, isTouched) => { + setTouched({ + ...touched, + [fieldName]: isTouched, }); }, + [touched], + ); + + const errors = useMemo(() => { + const e = {}; + if (!resource) { + e[`profile-option-${profile.slug}--resouces`] = + "Select the resouces allocation for your container."; + } + if (!image) { + e[`profile-option-${profile.slug}--image`] = "Select an image"; + } + if (!Object.keys(profile.profile_options.image.choices).includes(image)) { + if (!customImage) { + e[`profile-option-${profile.slug}--image--unlisted-choice`] = + "Provide a custom image."; + } else if (!/^.+:.+$/.test(customImage)) { + e[`profile-option-${profile.slug}--image--unlisted-choice`] = + "Must be a publicly available docker image, of form :."; + } + } + return e; + }, [image, resource, customImage]); + + const value = { + profile, + image, + setImage, + customImage, + setCustomImage, + resource, + setResource, + touched, + setTouched: setFieldTouched, + errors, }; return ( @@ -70,5 +75,3 @@ const SpawnerFormProvider = ({ children }) => { ); }; - -export { SpawnerFormContext, SpawnerFormProvider, IMAGE_OPTION_VIEWS }; diff --git a/webpack.config.js b/webpack.config.js index 8059653..ccf45ae 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -2,7 +2,7 @@ const webpack = require("webpack"); const path = require("path"); module.exports = { - entry: path.resolve(__dirname, "src", "Form.jsx"), + entry: path.resolve(__dirname, "src", "index.jsx"), devtool: "source-map", mode: "development", module: {