From 8781439b68529960992902d70175235b91761a30 Mon Sep 17 00:00:00 2001 From: Oliver Roick Date: Wed, 3 Apr 2024 11:31:52 +1100 Subject: [PATCH 01/12] Break out application setup frm form component --- src/{Form.jsx => ProfileForm.jsx} | 11 ++--------- src/index.jsx | 10 ++++++++++ webpack.config.js | 2 +- 3 files changed, 13 insertions(+), 10 deletions(-) rename src/{Form.jsx => ProfileForm.jsx} (80%) create mode 100644 src/index.jsx diff --git a/src/Form.jsx b/src/ProfileForm.jsx similarity index 80% rename from src/Form.jsx rename to src/ProfileForm.jsx index 4535f79..de69d7b 100644 --- a/src/Form.jsx +++ b/src/ProfileForm.jsx @@ -1,10 +1,8 @@ -import { createRoot } from "react-dom/client"; - import "../node_modules/xterm/css/xterm.css"; import "./form.css"; import { ResourceSelector } from "./ResourceSelector"; -import { SpawnerFormContext, SpawnerFormProvider } from "./state"; +import { SpawnerFormContext } from "./state"; import { useContext } from "react"; /** @@ -42,9 +40,4 @@ function Form() { ); } -const root = createRoot(document.getElementById("form")); -root.render( - -
- , -); +export default Form; diff --git a/src/index.jsx b/src/index.jsx new file mode 100644 index 0000000..9b32f47 --- /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/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: { From d9f851ba11e556e34cad38b6d929811f41fdc722 Mon Sep 17 00:00:00 2001 From: Oliver Roick Date: Wed, 3 Apr 2024 15:35:01 +1100 Subject: [PATCH 02/12] Refactor UI components --- src/{ => ImageSelect}/ImageBuilder.jsx | 4 +- src/ImageSelect/index.jsx | 67 ++++++++++++++++ src/ProfileForm.jsx | 8 +- src/ProfileOption.jsx | 107 ------------------------- src/ResourceSelect.jsx | 27 +++++++ src/ResourceSelector.jsx | 27 ------- src/hooks/useSelectOptions.js | 34 ++++++++ 7 files changed, 136 insertions(+), 138 deletions(-) rename src/{ => ImageSelect}/ImageBuilder.jsx (96%) create mode 100644 src/ImageSelect/index.jsx delete mode 100644 src/ProfileOption.jsx create mode 100644 src/ResourceSelect.jsx delete mode 100644 src/ResourceSelector.jsx create mode 100644 src/hooks/useSelectOptions.js diff --git a/src/ImageBuilder.jsx b/src/ImageSelect/ImageBuilder.jsx similarity index 96% rename from src/ImageBuilder.jsx rename to src/ImageSelect/ImageBuilder.jsx index ebd0d7a..b0afcd0 100644 --- a/src/ImageBuilder.jsx +++ b/src/ImageSelect/ImageBuilder.jsx @@ -89,7 +89,7 @@ function ImageLogs({ visible, setTerm, setFitAddon }) { ); } -export function ImageBuilder({ visible, unlistedInputName }) { +export function ImageBuilder({ visible, name }) { const [repo, setRepo] = useState(""); const [builtImage, setBuiltImage] = useState(null); @@ -140,7 +140,7 @@ export function ImageBuilder({ visible, unlistedInputName }) { }} /> {visible && builtImage && ( - + )} diff --git a/src/ImageSelect/index.jsx b/src/ImageSelect/index.jsx new file mode 100644 index 0000000..e9731bd --- /dev/null +++ b/src/ImageSelect/index.jsx @@ -0,0 +1,67 @@ +import { useCallback, useState } from "react"; +import { CustomizedSelect } from "../CustomSelect"; +import { ImageBuilder } from "./ImageBuilder"; +import useSelectOptions from "../hooks/useSelectOptions"; + +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 FIELD_ID = "image"; + const { display_name, choices } = config; + + const { options, defaultOption } = useSelectOptions(choices, extraChoices); + + const [value, setValue] = useState(defaultOption.value); + const [imageName, setImageName] = useState(''); + const onChange = useCallback(e => setValue(e.value), []); + + return ( + <> +
+ +
+
+ +
+ {value === "dockerImage" && ( + <> +
+ +
+
+ {/* Save and restore the typed in value, so we don't lose it if the user selects another choice */} + setImageName(e.target.value)} + /> +
+ + )} + {value === "buildImage" && ( + + )} + + ) +} + +export default ImageSelect; diff --git a/src/ProfileForm.jsx b/src/ProfileForm.jsx index de69d7b..92af01c 100644 --- a/src/ProfileForm.jsx +++ b/src/ProfileForm.jsx @@ -1,7 +1,8 @@ import "../node_modules/xterm/css/xterm.css"; import "./form.css"; -import { ResourceSelector } from "./ResourceSelector"; +import ImageSelect from "./ImageSelect"; +import ResourceSelect from "./ResourceSelect"; import { SpawnerFormContext } from "./state"; import { useContext } from "react"; @@ -16,6 +17,8 @@ function Form() { // Currently, we only support a single profile, with many options. const profile = profileList[0]; + const { image, resources } = profile.profile_options; + const { canSubmit } = useContext(SpawnerFormContext); return ( @@ -28,7 +31,8 @@ function Form() { checked readOnly /> - + + 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..34b3fbc --- /dev/null +++ b/src/ResourceSelect.jsx @@ -0,0 +1,27 @@ +import { CustomizedSelect } from "./CustomSelect"; +import useSelectOptions from "./hooks/useSelectOptions"; + +function ResourceSelect({ config }) { + const FIELD_ID = "resource"; + const { display_name, choices } = config; + + const { options, defaultOption } = useSelectOptions(choices); + + return ( + <> +
+ +
+
+ +
+ + ) +} + +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/hooks/useSelectOptions.js b/src/hooks/useSelectOptions.js new file mode 100644 index 0000000..ae792b6 --- /dev/null +++ b/src/hooks/useSelectOptions.js @@ -0,0 +1,34 @@ +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; From 561d410a284585d578061040bb6e59eec252087d Mon Sep 17 00:00:00 2001 From: Oliver Roick Date: Thu, 4 Apr 2024 15:33:23 +1100 Subject: [PATCH 03/12] Refactor state management --- src/ImageSelect/ImageBuilder.jsx | 11 +++-- src/ImageSelect/index.jsx | 29 ++++++------ src/ProfileForm.jsx | 7 +-- src/ResourceSelect.jsx | 4 ++ src/state.js | 80 +++++++++----------------------- 5 files changed, 46 insertions(+), 85 deletions(-) diff --git a/src/ImageSelect/ImageBuilder.jsx b/src/ImageSelect/ImageBuilder.jsx index b0afcd0..e746f95 100644 --- a/src/ImageSelect/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; @@ -91,7 +92,7 @@ function ImageLogs({ visible, setTerm, setFitAddon }) { } export function ImageBuilder({ visible, name }) { const [repo, setRepo] = useState(""); - const [builtImage, setBuiltImage] = useState(null); + const { costumImage, 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, name }) { 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 && costumImage && ( + )} diff --git a/src/ImageSelect/index.jsx b/src/ImageSelect/index.jsx index e9731bd..e864887 100644 --- a/src/ImageSelect/index.jsx +++ b/src/ImageSelect/index.jsx @@ -1,7 +1,8 @@ -import { useCallback, useState } from "react"; +import { useContext } from "react"; import { CustomizedSelect } from "../CustomSelect"; import { ImageBuilder } from "./ImageBuilder"; import useSelectOptions from "../hooks/useSelectOptions"; +import { SpawnerFormContext } from "../state"; const extraChoices = [ { @@ -17,14 +18,14 @@ const extraChoices = [ function ImageSelect({ config }) { - const FIELD_ID = "image"; + const { profile } = useContext(SpawnerFormContext); + const FIELD_ID = `profile-option-${ profile }--image`; + const FIELD_ID_UNLISTED = `${FIELD_ID}--unlisted-choice`; const { display_name, choices } = config; const { options, defaultOption } = useSelectOptions(choices, extraChoices); - const [value, setValue] = useState(defaultOption.value); - const [imageName, setImageName] = useState(''); - const onChange = useCallback(e => setValue(e.value), []); + const { image, setImage, customImage, setCustomImage } = useContext(SpawnerFormContext); return ( <> @@ -35,30 +36,28 @@ function ImageSelect({ config }) { setImage(e.value)} /> - {value === "dockerImage" && ( + {image === "dockerImage" && ( <>
- +
{/* Save and restore the typed in value, so we don't lose it if the user selects another choice */} setImageName(e.target.value)} + id={FIELD_ID_UNLISTED} + value={customImage} + onChange={(e) => setCustomImage(e.target.value)} />
)} - {value === "buildImage" && ( - + {image === "buildImage" && ( + )} ) diff --git a/src/ProfileForm.jsx b/src/ProfileForm.jsx index 92af01c..52fc43f 100644 --- a/src/ProfileForm.jsx +++ b/src/ProfileForm.jsx @@ -13,14 +13,10 @@ 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 { profile } = useContext(SpawnerFormContext); const { image, resources } = profile.profile_options; - const { canSubmit } = useContext(SpawnerFormContext); - return (
diff --git a/src/ResourceSelect.jsx b/src/ResourceSelect.jsx index 34b3fbc..1213ab6 100644 --- a/src/ResourceSelect.jsx +++ b/src/ResourceSelect.jsx @@ -1,11 +1,14 @@ +import { useContext } from "react"; import { CustomizedSelect } from "./CustomSelect"; import useSelectOptions from "./hooks/useSelectOptions"; +import { SpawnerFormContext } from "./state"; function ResourceSelect({ config }) { const FIELD_ID = "resource"; const { display_name, choices } = config; const { options, defaultOption } = useSelectOptions(choices); + const { setResource } = useContext(SpawnerFormContext); return ( <> @@ -18,6 +21,7 @@ function ResourceSelect({ config }) { id={FIELD_ID} name={FIELD_ID} defaultValue={defaultOption} + onChange={e => setResource(e.value)} /> diff --git a/src/state.js b/src/state.js index 8cf1ac0..994cecb 100644 --- a/src/state.js +++ b/src/state.js @@ -1,67 +1,31 @@ -import { createContext, useReducer } from "react"; +import { createContext, 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, -}; +export const SpawnerFormContext = createContext(); -const ACTION_TYPES = { - SET_UNLISTED_IMAGE: Symbol("set-unlisted-image"), - SET_IMAGE_OPTION_VIEW: Symbol("set-image-option-view"), -}; - -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, - }); - }, + profile, + image, + setImage, + customImage, + setCustomImage, + resource, + setResource }; return ( @@ -70,5 +34,3 @@ const SpawnerFormProvider = ({ children }) => { ); }; - -export { SpawnerFormContext, SpawnerFormProvider, IMAGE_OPTION_VIEWS }; From 1c39046d41f51720a00666ac7f8ad9bf7cf1c9f2 Mon Sep 17 00:00:00 2001 From: Oliver Roick Date: Mon, 8 Apr 2024 11:45:11 +1000 Subject: [PATCH 04/12] Enable form submission --- src/ProfileForm.jsx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/ProfileForm.jsx b/src/ProfileForm.jsx index 52fc43f..e60cac1 100644 --- a/src/ProfileForm.jsx +++ b/src/ProfileForm.jsx @@ -14,7 +14,7 @@ import { useContext } from "react"; */ function Form() { // Currently, we only support a single profile, with many options. - const { profile } = useContext(SpawnerFormContext); + const { profile, submit } = useContext(SpawnerFormContext); const { image, resources } = profile.profile_options; return ( @@ -29,12 +29,13 @@ function Form() { /> - + ); } From af68475c4c80076e700b0b374b654a86c908a23b Mon Sep 17 00:00:00 2001 From: Oliver Roick Date: Tue, 9 Apr 2024 10:53:48 +1000 Subject: [PATCH 05/12] Fix typo --- src/ImageSelect/ImageBuilder.jsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ImageSelect/ImageBuilder.jsx b/src/ImageSelect/ImageBuilder.jsx index e746f95..d0ee067 100644 --- a/src/ImageSelect/ImageBuilder.jsx +++ b/src/ImageSelect/ImageBuilder.jsx @@ -92,7 +92,7 @@ function ImageLogs({ visible, setTerm, setFitAddon }) { } export function ImageBuilder({ visible, name }) { const [repo, setRepo] = useState(""); - const { costumImage, setCustomImage } = useContext(SpawnerFormContext); + const { customImage, setCustomImage } = useContext(SpawnerFormContext); // FIXME: Allow users to actually configure this const [ref, _] = useState("HEAD"); // eslint-disable-line no-unused-vars @@ -140,8 +140,8 @@ export function ImageBuilder({ visible, name }) { }); }} /> - {visible && costumImage && ( - + {visible && customImage && ( + )} From 337ae7ada8f97dff3ff165d749c7ed4eb99f3c33 Mon Sep 17 00:00:00 2001 From: Oliver Roick Date: Tue, 9 Apr 2024 10:57:59 +1000 Subject: [PATCH 06/12] Remove unused var --- src/ProfileForm.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ProfileForm.jsx b/src/ProfileForm.jsx index e60cac1..bfb04d2 100644 --- a/src/ProfileForm.jsx +++ b/src/ProfileForm.jsx @@ -14,7 +14,7 @@ import { useContext } from "react"; */ function Form() { // Currently, we only support a single profile, with many options. - const { profile, submit } = useContext(SpawnerFormContext); + const { profile } = useContext(SpawnerFormContext); const { image, resources } = profile.profile_options; return ( From 7663f4cb95b7a2ccee8cee6731556e87a20a1d73 Mon Sep 17 00:00:00 2001 From: Oliver Roick Date: Tue, 9 Apr 2024 11:08:20 +1000 Subject: [PATCH 07/12] Prettier --- src/ImageSelect/index.jsx | 47 +++++++++++++++++++---------------- src/ProfileForm.jsx | 5 +--- src/ResourceSelect.jsx | 4 +-- src/hooks/useSelectOptions.js | 10 +++----- src/index.jsx | 2 +- src/state.js | 18 +++++++++----- 6 files changed, 44 insertions(+), 42 deletions(-) diff --git a/src/ImageSelect/index.jsx b/src/ImageSelect/index.jsx index e864887..1dd3f06 100644 --- a/src/ImageSelect/index.jsx +++ b/src/ImageSelect/index.jsx @@ -8,24 +8,27 @@ const extraChoices = [ { value: "dockerImage", label: "Specify an existing docker image", - description: "Use a pre-existing docker image from a public docker registry", - }, { + 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", - } + 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 }--image`; + const FIELD_ID = `profile-option-${profile}--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 } = useContext(SpawnerFormContext); + const { image, setImage, customImage, setCustomImage } = + useContext(SpawnerFormContext); return ( <> @@ -37,30 +40,30 @@ function ImageSelect({ config }) { options={options} id={FIELD_ID} defaultValue={defaultOption} - onChange={e => setImage(e.value)} + onChange={(e) => setImage(e.value)} /> {image === "dockerImage" && ( <> -
- -
-
- {/* Save and restore the typed in value, so we don't lose it if the user selects another choice */} - setCustomImage(e.target.value)} - /> -
- +
+ +
+
+ {/* Save and restore the typed in value, so we don't lose it if the user selects another choice */} + setCustomImage(e.target.value)} + /> +
+ )} {image === "buildImage" && ( )} - ) + ); } export default ImageSelect; diff --git a/src/ProfileForm.jsx b/src/ProfileForm.jsx index bfb04d2..e949c08 100644 --- a/src/ProfileForm.jsx +++ b/src/ProfileForm.jsx @@ -30,10 +30,7 @@ function Form() {
-
diff --git a/src/ResourceSelect.jsx b/src/ResourceSelect.jsx index 1213ab6..cc7cda0 100644 --- a/src/ResourceSelect.jsx +++ b/src/ResourceSelect.jsx @@ -21,11 +21,11 @@ function ResourceSelect({ config }) { id={FIELD_ID} name={FIELD_ID} defaultValue={defaultOption} - onChange={e => setResource(e.value)} + onChange={(e) => setResource(e.value)} /> - ) + ); } export default ResourceSelect; diff --git a/src/hooks/useSelectOptions.js b/src/hooks/useSelectOptions.js index ae792b6..7b4b232 100644 --- a/src/hooks/useSelectOptions.js +++ b/src/hooks/useSelectOptions.js @@ -1,7 +1,6 @@ import { useMemo } from "react"; function useSelectOptions(choices, extraChoices = []) { - const options = useMemo(() => { const defaultChoices = Object.keys(choices).map((choiceName) => { return { @@ -11,16 +10,13 @@ function useSelectOptions(choices, extraChoices = []) { }; }); - return [ - ...defaultChoices, - ...extraChoices - ]; + 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, ); @@ -28,7 +24,7 @@ function useSelectOptions(choices, extraChoices = []) { return { options, defaultOption, - } + }; } export default useSelectOptions; diff --git a/src/index.jsx b/src/index.jsx index 9b32f47..2b5ea92 100644 --- a/src/index.jsx +++ b/src/index.jsx @@ -1,6 +1,6 @@ import { createRoot } from "react-dom/client"; import { SpawnerFormProvider } from "./state"; -import Form from './ProfileForm'; +import Form from "./ProfileForm"; const root = createRoot(document.getElementById("form")); root.render( diff --git a/src/state.js b/src/state.js index 994cecb..efd0b9d 100644 --- a/src/state.js +++ b/src/state.js @@ -3,19 +3,25 @@ import { createContext, useState } from "react"; export const SpawnerFormContext = createContext(); function getDefaultOption(choices) { - return Object.keys(choices).find((choiceName) => choices[choiceName].default) || - Object.keys(choices)[0]; + return ( + Object.keys(choices).find((choiceName) => choices[choiceName].default) || + Object.keys(choices)[0] + ); } 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 defaultImageKey = getDefaultOption( + profile.profile_options.image.choices, + ); + const defaultResourceKey = getDefaultOption( + profile.profile_options.resources.choices, + ); const [image, setImage] = useState(defaultImageKey); - const [customImage, setCustomImage] = useState(''); + const [customImage, setCustomImage] = useState(""); const [resource, setResource] = useState(defaultResourceKey); const value = { @@ -25,7 +31,7 @@ export const SpawnerFormProvider = ({ children }) => { customImage, setCustomImage, resource, - setResource + setResource, }; return ( From d6768ef8c0d824c5c81b3e449d3450cc524ba32d Mon Sep 17 00:00:00 2001 From: Oliver Roick Date: Tue, 9 Apr 2024 17:11:51 +1000 Subject: [PATCH 08/12] Add form validation --- src/CustomSelect.jsx | 8 +++++++- src/ImageSelect/index.jsx | 42 ++++++++++++++++++++++++++------------- src/ProfileForm.jsx | 9 ++++++--- src/ResourceSelect.jsx | 14 +++++++++---- src/form.css | 23 ++++++++++++++++++--- src/state.js | 27 ++++++++++++++++++++++++- 6 files changed, 97 insertions(+), 26 deletions(-) diff --git a/src/CustomSelect.jsx b/src/CustomSelect.jsx index 812536b..09e13ec 100644 --- a/src/CustomSelect.jsx +++ b/src/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/ResourceSelect.jsx b/src/ResourceSelect.jsx index cc7cda0..60cbbb5 100644 --- a/src/ResourceSelect.jsx +++ b/src/ResourceSelect.jsx @@ -4,14 +4,15 @@ import useSelectOptions from "./hooks/useSelectOptions"; import { SpawnerFormContext } from "./state"; function ResourceSelect({ config }) { - const FIELD_ID = "resource"; const { display_name, choices } = config; const { options, defaultOption } = useSelectOptions(choices); - const { setResource } = useContext(SpawnerFormContext); + const { setResource, profile, touched, setTouched, errors } = useContext(SpawnerFormContext); + const FIELD_ID = `profile-option-${profile.slug}--resource`; + const hasError = errors[FIELD_ID] && touched[FIELD_ID]; return ( - <> +
@@ -22,9 +23,14 @@ function ResourceSelect({ config }) { name={FIELD_ID} defaultValue={defaultOption} onChange={(e) => setResource(e.value)} + onBlur={() => setTouched(FIELD_ID, true)} + hasError={hasError} /> + {hasError && ( +
{errors[FIELD_ID]}
+ )}
- + ); } 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/state.js b/src/state.js index efd0b9d..08a7bce 100644 --- a/src/state.js +++ b/src/state.js @@ -1,4 +1,4 @@ -import { createContext, useState } from "react"; +import { createContext, useCallback, useMemo, useState } from "react"; export const SpawnerFormContext = createContext(); @@ -24,6 +24,28 @@ export const SpawnerFormProvider = ({ children }) => { const [customImage, setCustomImage] = useState(""); const [resource, setResource] = useState(defaultResourceKey); + 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) && !customImage) { + e[`profile-option-${profile.slug}--image--unlisted-choice`] = "Provide a custom image." + } + return e; + }, [image, resource, customImage]); + const value = { profile, image, @@ -32,6 +54,9 @@ export const SpawnerFormProvider = ({ children }) => { setCustomImage, resource, setResource, + touched, + setTouched: setFieldTouched, + errors }; return ( From 83c5a430f8f06b50beb51cfdb97ea815fe15c34c Mon Sep 17 00:00:00 2001 From: Oliver Roick Date: Wed, 10 Apr 2024 10:59:19 +1000 Subject: [PATCH 09/12] Prettier --- src/CustomSelect.jsx | 2 +- src/ImageSelect/index.jsx | 32 ++++++++++++++++++++++++-------- src/ProfileForm.jsx | 6 +++++- src/ResourceSelect.jsx | 3 ++- src/state.js | 30 +++++++++++++++++++----------- 5 files changed, 51 insertions(+), 22 deletions(-) diff --git a/src/CustomSelect.jsx b/src/CustomSelect.jsx index 09e13ec..f7b9912 100644 --- a/src/CustomSelect.jsx +++ b/src/CustomSelect.jsx @@ -70,7 +70,7 @@ export function CustomizedSelect({ options, hasError, ...props }) { styles={{ control: (baseStyles) => ({ ...baseStyles, - borderColor: hasError ? 'red' : 'grey', + borderColor: hasError ? "red" : "grey", }), }} {...props} diff --git a/src/ImageSelect/index.jsx b/src/ImageSelect/index.jsx index 25ab65c..61b5127 100644 --- a/src/ImageSelect/index.jsx +++ b/src/ImageSelect/index.jsx @@ -27,15 +27,25 @@ function ImageSelect({ config }) { const { options, defaultOption } = useSelectOptions(choices, extraChoices); - const { image, setImage, customImage, setCustomImage, errors, touched, setTouched } = - useContext(SpawnerFormContext); - + const { + image, + setImage, + customImage, + setCustomImage, + errors, + touched, + setTouched, + } = useContext(SpawnerFormContext); + const imageError = errors[FIELD_ID] && touched[FIELD_ID]; - const customImageError = errors[FIELD_ID_UNLISTED] && touched[FIELD_ID_UNLISTED]; + const customImageError = + errors[FIELD_ID_UNLISTED] && touched[FIELD_ID_UNLISTED]; return ( <> -
+
@@ -48,12 +58,16 @@ function ImageSelect({ config }) { onBlur={() => setTouched(FIELD_ID, true)} /> {imageError && ( -
{errors[FIELD_ID]}
+
+ {errors[FIELD_ID]} +
)}
{image === "dockerImage" && ( -
+
@@ -68,7 +82,9 @@ function ImageSelect({ config }) { required /> {customImageError && ( -
{errors[FIELD_ID_UNLISTED]}
+
+ {errors[FIELD_ID_UNLISTED]} +
)}
diff --git a/src/ProfileForm.jsx b/src/ProfileForm.jsx index 23a400b..4045b35 100644 --- a/src/ProfileForm.jsx +++ b/src/ProfileForm.jsx @@ -33,7 +33,11 @@ function Form() {
-
diff --git a/src/ResourceSelect.jsx b/src/ResourceSelect.jsx index 60cbbb5..85800d4 100644 --- a/src/ResourceSelect.jsx +++ b/src/ResourceSelect.jsx @@ -7,7 +7,8 @@ function ResourceSelect({ config }) { const { display_name, choices } = config; const { options, defaultOption } = useSelectOptions(choices); - const { setResource, profile, touched, setTouched, errors } = useContext(SpawnerFormContext); + const { setResource, profile, touched, setTouched, errors } = + useContext(SpawnerFormContext); const FIELD_ID = `profile-option-${profile.slug}--resource`; const hasError = errors[FIELD_ID] && touched[FIELD_ID]; diff --git a/src/state.js b/src/state.js index 08a7bce..dd54faa 100644 --- a/src/state.js +++ b/src/state.js @@ -25,23 +25,31 @@ export const SpawnerFormProvider = ({ children }) => { const [resource, setResource] = useState(defaultResourceKey); const [touched, setTouched] = useState({}); - const setFieldTouched = useCallback((fieldName, isTouched) => { - setTouched({ - ...touched, - [fieldName]: isTouched - }) - }, [touched]); + 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." + e[`profile-option-${profile.slug}--resouces`] = + "Select the resouces allocation for your container."; } if (!image) { - e[`profile-option-${profile.slug}--image`] = "Select an image" + e[`profile-option-${profile.slug}--image`] = "Select an image"; } - if (!Object.keys(profile.profile_options.image.choices).includes(image) && !customImage) { - e[`profile-option-${profile.slug}--image--unlisted-choice`] = "Provide a custom image." + if ( + !Object.keys(profile.profile_options.image.choices).includes(image) && + !customImage + ) { + e[`profile-option-${profile.slug}--image--unlisted-choice`] = + "Provide a custom image."; } return e; }, [image, resource, customImage]); @@ -56,7 +64,7 @@ export const SpawnerFormProvider = ({ children }) => { setResource, touched, setTouched: setFieldTouched, - errors + errors, }; return ( From 0fa8006a01bdec1d7b3233f78cd9cf0b7223cced Mon Sep 17 00:00:00 2001 From: Oliver Roick Date: Wed, 10 Apr 2024 11:17:46 +1000 Subject: [PATCH 10/12] Refactor: Abstract form field composition --- src/ImageSelect/index.jsx | 68 ++++++---------------- src/ProfileForm.jsx | 1 - src/ResourceSelect.jsx | 31 ++++------ src/{ => components/form}/CustomSelect.jsx | 0 src/components/form/fields.jsx | 62 ++++++++++++++++++++ src/components/form/index.js | 0 6 files changed, 91 insertions(+), 71 deletions(-) rename src/{ => components/form}/CustomSelect.jsx (100%) create mode 100644 src/components/form/fields.jsx create mode 100644 src/components/form/index.js diff --git a/src/ImageSelect/index.jsx b/src/ImageSelect/index.jsx index 61b5127..4f8ee30 100644 --- a/src/ImageSelect/index.jsx +++ b/src/ImageSelect/index.jsx @@ -1,5 +1,5 @@ import { useContext } from "react"; -import { CustomizedSelect } from "../CustomSelect"; +import { SelectField, TextField } from "../components/form/fields"; import { ImageBuilder } from "./ImageBuilder"; import useSelectOptions from "../hooks/useSelectOptions"; import { SpawnerFormContext } from "../state"; @@ -37,57 +37,27 @@ function ImageSelect({ config }) { setTouched, } = useContext(SpawnerFormContext); - const imageError = errors[FIELD_ID] && touched[FIELD_ID]; - const customImageError = - errors[FIELD_ID_UNLISTED] && touched[FIELD_ID_UNLISTED]; - return ( <> -
-
- -
-
- setImage(e.value)} - onBlur={() => setTouched(FIELD_ID, true)} - /> - {imageError && ( -
- {errors[FIELD_ID]} -
- )} -
-
+ setImage(e.value)} + onBlur={() => setTouched(FIELD_ID, true)} + /> {image === "dockerImage" && ( -
-
- -
-
- {/* Save and restore the typed in value, so we don't lose it if the user selects another choice */} - setCustomImage(e.target.value)} - onBlur={() => setTouched(FIELD_ID_UNLISTED, true)} - required - /> - {customImageError && ( -
- {errors[FIELD_ID_UNLISTED]} -
- )} -
-
+ setCustomImage(e.target.value)} + onBlur={() => setTouched(FIELD_ID_UNLISTED, true)} + /> )} {image === "buildImage" && ( diff --git a/src/ProfileForm.jsx b/src/ProfileForm.jsx index 4045b35..7cfccf7 100644 --- a/src/ProfileForm.jsx +++ b/src/ProfileForm.jsx @@ -18,7 +18,6 @@ function Form() { const { image, resources } = profile.profile_options; const canSubmit = Object.keys(errors).length === 0; - console.log(canSubmit); return (
diff --git a/src/ResourceSelect.jsx b/src/ResourceSelect.jsx index 85800d4..b5813d2 100644 --- a/src/ResourceSelect.jsx +++ b/src/ResourceSelect.jsx @@ -1,7 +1,7 @@ import { useContext } from "react"; -import { CustomizedSelect } from "./CustomSelect"; import useSelectOptions from "./hooks/useSelectOptions"; import { SpawnerFormContext } from "./state"; +import { SelectField } from "./components/form/fields"; function ResourceSelect({ config }) { const { display_name, choices } = config; @@ -10,28 +10,17 @@ function ResourceSelect({ config }) { const { setResource, profile, touched, setTouched, errors } = useContext(SpawnerFormContext); const FIELD_ID = `profile-option-${profile.slug}--resource`; - const hasError = errors[FIELD_ID] && touched[FIELD_ID]; return ( -
-
- -
-
- setResource(e.value)} - onBlur={() => setTouched(FIELD_ID, true)} - hasError={hasError} - /> - {hasError && ( -
{errors[FIELD_ID]}
- )} -
-
+ setResource(e.value)} + onBlur={() => setTouched(FIELD_ID, true)} + /> ); } diff --git a/src/CustomSelect.jsx b/src/components/form/CustomSelect.jsx similarity index 100% rename from src/CustomSelect.jsx rename to src/components/form/CustomSelect.jsx diff --git a/src/components/form/fields.jsx b/src/components/form/fields.jsx new file mode 100644 index 0000000..486297d --- /dev/null +++ b/src/components/form/fields.jsx @@ -0,0 +1,62 @@ +import { CustomizedSelect } from "./CustomSelect"; + +function Field({ id, label, children, error }) { + return ( +
+
+ +
+
+ {children} + + {error &&
{error}
} +
+
+ ); +} + +export function SelectField({ + id, + label, + options, + defaultOption, + error, + onChange, + onBlur, +}) { + return ( + + + + ); +} + +export function TextField({ + id, + label, + value, + required, + error, + onChange, + onBlur, +}) { + return ( + + + + ); +} diff --git a/src/components/form/index.js b/src/components/form/index.js new file mode 100644 index 0000000..e69de29 From c7d99145853999e8629bf5ae5f1ed4c2f08f7469 Mon Sep 17 00:00:00 2001 From: Oliver Roick Date: Thu, 11 Apr 2024 15:54:17 +1000 Subject: [PATCH 11/12] Add regex validation --- src/ImageSelect/index.jsx | 1 + src/components/form/fields.jsx | 2 ++ src/state.js | 14 ++++++++------ 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/ImageSelect/index.jsx b/src/ImageSelect/index.jsx index 4f8ee30..6f91689 100644 --- a/src/ImageSelect/index.jsx +++ b/src/ImageSelect/index.jsx @@ -54,6 +54,7 @@ function ImageSelect({ config }) { label="Custom image" value={customImage} required + pattern="^.+:.+$" error={touched[FIELD_ID_UNLISTED] && errors[FIELD_ID_UNLISTED]} onChange={(e) => setCustomImage(e.target.value)} onBlur={() => setTouched(FIELD_ID_UNLISTED, true)} diff --git a/src/components/form/fields.jsx b/src/components/form/fields.jsx index 486297d..aa30129 100644 --- a/src/components/form/fields.jsx +++ b/src/components/form/fields.jsx @@ -43,6 +43,7 @@ export function TextField({ label, value, required, + pattern, error, onChange, onBlur, @@ -53,6 +54,7 @@ export function TextField({ type="text" id={id} value={value} + pattern={pattern} onChange={onChange} onBlur={onBlur} required={required} diff --git a/src/state.js b/src/state.js index dd54faa..da0e17b 100644 --- a/src/state.js +++ b/src/state.js @@ -44,12 +44,14 @@ export const SpawnerFormProvider = ({ children }) => { if (!image) { e[`profile-option-${profile.slug}--image`] = "Select an image"; } - if ( - !Object.keys(profile.profile_options.image.choices).includes(image) && - !customImage - ) { - e[`profile-option-${profile.slug}--image--unlisted-choice`] = - "Provide a custom 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]); From eb4e719ba12a3de3b98db90a5aa594f6afc2f1c8 Mon Sep 17 00:00:00 2001 From: Oliver Roick Date: Mon, 15 Apr 2024 11:10:06 +1000 Subject: [PATCH 12/12] Prettier --- src/state.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/state.js b/src/state.js index da0e17b..c25eb5e 100644 --- a/src/state.js +++ b/src/state.js @@ -48,7 +48,7 @@ export const SpawnerFormProvider = ({ children }) => { if (!customImage) { e[`profile-option-${profile.slug}--image--unlisted-choice`] = "Provide a custom image."; - } else if (!(/^.+:.+$/.test(customImage))) { + } else if (!/^.+:.+$/.test(customImage)) { e[`profile-option-${profile.slug}--image--unlisted-choice`] = "Must be a publicly available docker image, of form :."; }