diff --git a/packages/browser-tests/cypress/integration/auth/auth.spec.js b/packages/browser-tests/cypress/integration/auth/auth.spec.js index fb738a32c..4707e0257 100644 --- a/packages/browser-tests/cypress/integration/auth/auth.spec.js +++ b/packages/browser-tests/cypress/integration/auth/auth.spec.js @@ -12,8 +12,10 @@ const interceptSettings = (payload) => { describe("OSS", () => { before(() => { interceptSettings({ - "release.type": "OSS", - "release.version": "1.2.3", + "config": { + "release.type": "OSS", + "release.version": "1.2.3", + } }); cy.visit(baseUrl); }); @@ -27,16 +29,18 @@ describe("OSS", () => { describe("Auth - UI", () => { before(() => { interceptSettings({ - "release.type": "EE", - "release.version": "1.2.3", - "acl.enabled": true, - "acl.basic.auth.realm.enabled": false, - "acl.oidc.enabled": false, - "acl.oidc.client.id": null, - "acl.oidc.authorization.endpoint": null, - "acl.oidc.token.endpoint": null, - "acl.oidc.pkce.required": null, - "acl.oidc.groups.encoded.in.token": false, + "config": { + "release.type": "EE", + "release.version": "1.2.3", + "acl.enabled": true, + "acl.basic.auth.realm.enabled": false, + "acl.oidc.enabled": false, + "acl.oidc.client.id": null, + "acl.oidc.authorization.endpoint": null, + "acl.oidc.token.endpoint": null, + "acl.oidc.pkce.required": null, + "acl.oidc.groups.encoded.in.token": false, + } }); cy.visit(baseUrl); }); @@ -52,16 +56,18 @@ describe("Auth - UI", () => { describe("Auth - OIDC", () => { before(() => { interceptSettings({ - "release.type": "EE", - "release.version": "1.2.3", - "acl.enabled": true, - "acl.basic.auth.realm.enabled": false, - "acl.oidc.enabled": true, - "acl.oidc.client.id": "test", - "acl.oidc.authorization.endpoint": "https://host:9999/auth", - "acl.oidc.token.endpoint": "https://host:9999/token", - "acl.oidc.pkce.required": true, - "acl.oidc.groups.encoded.in.token": false, + "config": { + "release.type": "EE", + "release.version": "1.2.3", + "acl.enabled": true, + "acl.basic.auth.realm.enabled": false, + "acl.oidc.enabled": true, + "acl.oidc.client.id": "test", + "acl.oidc.authorization.endpoint": "https://host:9999/auth", + "acl.oidc.token.endpoint": "https://host:9999/token", + "acl.oidc.pkce.required": true, + "acl.oidc.groups.encoded.in.token": false, + } }); cy.visit(baseUrl); }); @@ -77,16 +83,18 @@ describe("Auth - OIDC", () => { describe("Auth - Basic", () => { before(() => { interceptSettings({ - "release.type": "EE", - "release.version": "1.2.3", - "acl.enabled": true, - "acl.basic.auth.realm.enabled": true, - "acl.oidc.enabled": false, - "acl.oidc.client.id": null, - "acl.oidc.authorization.endpoint": null, - "acl.oidc.token.endpoint": null, - "acl.oidc.pkce.required": null, - "acl.oidc.groups.encoded.in.token": false, + "config": { + "release.type": "EE", + "release.version": "1.2.3", + "acl.enabled": true, + "acl.basic.auth.realm.enabled": true, + "acl.oidc.enabled": false, + "acl.oidc.client.id": null, + "acl.oidc.authorization.endpoint": null, + "acl.oidc.token.endpoint": null, + "acl.oidc.pkce.required": null, + "acl.oidc.groups.encoded.in.token": false, + } }); cy.visit(baseUrl); }); @@ -100,16 +108,18 @@ describe("Auth - Basic", () => { describe("Auth - Disabled", () => { before(() => { interceptSettings({ - "release.type": "EE", - "release.version": "1.2.3", - "acl.enabled": false, - "acl.basic.auth.realm.enabled": true, - "acl.oidc.enabled": false, - "acl.oidc.client.id": null, - "acl.oidc.authorization.endpoint": null, - "acl.oidc.token.endpoint": null, - "acl.oidc.pkce.required": null, - "acl.oidc.groups.encoded.in.token": false, + "config": { + "release.type": "EE", + "release.version": "1.2.3", + "acl.enabled": false, + "acl.basic.auth.realm.enabled": true, + "acl.oidc.enabled": false, + "acl.oidc.client.id": null, + "acl.oidc.authorization.endpoint": null, + "acl.oidc.token.endpoint": null, + "acl.oidc.pkce.required": null, + "acl.oidc.groups.encoded.in.token": false, + } }); cy.visit(baseUrl); }); diff --git a/packages/browser-tests/cypress/integration/console/telemetry.spec.js b/packages/browser-tests/cypress/integration/console/telemetry.spec.js index 924bbbb96..dec180847 100644 --- a/packages/browser-tests/cypress/integration/console/telemetry.spec.js +++ b/packages/browser-tests/cypress/integration/console/telemetry.spec.js @@ -22,7 +22,7 @@ describe("telemetry config", () => { cy.wait("@telemetryConfig").then(({ response }) => { const columnNames = response.body.columns.map((c) => c.name); expect(response.statusCode).to.equal(200); - ["id", "enabled", "version", "os", "package"].forEach((name) => { + ["id", "enabled", "version", "os", "package", "instance_name", "instance_type", "instance_desc"].forEach((name) => { expect(columnNames).to.include(name); }); expect(response.body.dataset[0][0]).to.be.string; @@ -34,6 +34,9 @@ describe("telemetry config", () => { expect(typeof response.body.dataset[0][4]).to.satisfy( (v) => v === null || typeof v === "string" ); + expect(response.body.dataset[0][5]).to.be.string; + expect(response.body.dataset[0][6]).to.be.string; + expect(response.body.dataset[0][7]).to.be.string; }); }); }); @@ -62,7 +65,7 @@ describe("telemetry enabled", () => { it("should start telemetry when enabled", () => { cy.wait("@telemetryConfig").then(({ response }) => { cy.wait("@addTelemetry").then(({ request }) => { - const payload = JSON.parse(request.body); + const payload = request.body; expect(payload.id).to.equal(response.body.dataset[0][0]); expect(payload.version).to.equal(response.body.dataset[0][2]); expect(payload.os).to.equal(response.body.dataset[0][3]); diff --git a/packages/browser-tests/cypress/integration/console/topbar.spec.js b/packages/browser-tests/cypress/integration/console/topbar.spec.js new file mode 100644 index 000000000..5aac8a446 --- /dev/null +++ b/packages/browser-tests/cypress/integration/console/topbar.spec.js @@ -0,0 +1,64 @@ +/// + +describe("TopBar", () => { + beforeEach(() => { + cy.loadConsoleWithAuth(); + }); + + it("should show the instance warning and no description", () => { + cy.getByDataHook("topbar-instance-name").should( + "have.text", + "Instance name is not set" + ); + cy.getByDataHook("topbar-instance-badge").should( + "have.css", + "background-color", + "rgb(40, 42, 54)" + ); + }); + + it("should preview the color, show error when instance name is empty, and don't save changes on cancel", () => { + cy.getByDataHook("topbar-instance-name").realHover(); + cy.getByDataHook("topbar-instance-edit-icon").should("be.visible"); + cy.getByDataHook("topbar-instance-edit-icon").click(); + cy.getByDataHook("topbar-instance-color-option-r").click(); + cy.getByDataHook("topbar-instance-badge").should( + "have.css", + "background-color", + "rgb(199, 7, 45)" + ); + cy.getByDataHook("topbar-instance-save-button").click(); + cy.contains("Instance name is required").should("be.visible"); + cy.getByDataHook("topbar-instance-cancel-button").click(); + cy.getByDataHook("topbar-instance-name").should( + "have.text", + "Instance name is not set" + ); + cy.getByDataHook("topbar-instance-badge").should( + "have.css", + "background-color", + "rgb(40, 42, 54)" + ); + }); + + it("should change the instance name, description, and type", () => { + cy.getByDataHook("topbar-instance-badge").realHover(); + cy.getByDataHook("topbar-instance-edit-icon").should("be.visible"); + cy.getByDataHook("topbar-instance-edit-icon").click(); + cy.getByDataHook("topbar-instance-name-input").type("test-instance"); + cy.getByDataHook("topbar-instance-type-select").select("production"); + cy.getByDataHook("topbar-instance-description-input").type( + "test description of the test instance" + ); + cy.getByDataHook("topbar-instance-color-option-g").click(); + cy.getByDataHook("topbar-instance-save-button").click(); + cy.getByDataHook("topbar-instance-save-button").should("not.exist"); + cy.getByDataHook("topbar-instance-name").should("contain", "Production"); + cy.getByDataHook("topbar-instance-name").should("contain", "test-instance"); + cy.getByDataHook("topbar-instance-icon").realHover(); + cy.contains("test description of the test instance").should("be.visible"); + cy.contains( + "You are connected to a QuestDB instance for production" + ).should("be.visible"); + }); +}); diff --git a/packages/browser-tests/cypress/integration/enterprise/oidc.spec.js b/packages/browser-tests/cypress/integration/enterprise/oidc.spec.js index 5eeeea786..3d05a2201 100644 --- a/packages/browser-tests/cypress/integration/enterprise/oidc.spec.js +++ b/packages/browser-tests/cypress/integration/enterprise/oidc.spec.js @@ -40,17 +40,19 @@ describe("OIDC authentication", () => { // load login page interceptSettings({ - "release.type": "EE", - "release.version": "1.2.3", - "acl.enabled": true, - "acl.basic.auth.realm.enabled": false, - "acl.oidc.enabled": true, - "acl.oidc.client.id": "client1", - "acl.oidc.authorization.endpoint": oidcAuthorizationCodeUrl, - "acl.oidc.token.endpoint": oidcTokenUrl, - "acl.oidc.pkce.required": true, - "acl.oidc.state.required": false, - "acl.oidc.groups.encoded.in.token": false, + "config": { + "release.type": "EE", + "release.version": "1.2.3", + "acl.enabled": true, + "acl.basic.auth.realm.enabled": false, + "acl.oidc.enabled": true, + "acl.oidc.client.id": "client1", + "acl.oidc.authorization.endpoint": oidcAuthorizationCodeUrl, + "acl.oidc.token.endpoint": oidcTokenUrl, + "acl.oidc.pkce.required": true, + "acl.oidc.state.required": false, + "acl.oidc.groups.encoded.in.token": false, + } }); cy.visit(baseUrl); diff --git a/packages/browser-tests/questdb b/packages/browser-tests/questdb index 66e288007..9374c7857 160000 --- a/packages/browser-tests/questdb +++ b/packages/browser-tests/questdb @@ -1 +1 @@ -Subproject commit 66e288007684bdc035884a485979da7396de5dae +Subproject commit 9374c78570f2dc089aff8665316eea294b4ac8a2 diff --git a/packages/web-console/src/components/PopperToggle/index.tsx b/packages/web-console/src/components/PopperToggle/index.tsx index bbbc53595..1e48d550f 100644 --- a/packages/web-console/src/components/PopperToggle/index.tsx +++ b/packages/web-console/src/components/PopperToggle/index.tsx @@ -86,13 +86,27 @@ export const PopperToggle = ({ return } - setActive(false) + if (_active) { + setActive(false) + if (onToggle) { + onToggle(false) + } + } + }, + [container, onToggle, triggerElement, _active], + ) - if (onToggle) { - onToggle(false) + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + if (event.key === "Escape" && _active) { + setActive(false) + + if (onToggle) { + onToggle(false) + } } }, - [container, onToggle, triggerElement], + [_active, onToggle], ) usePopperStyles(container, styles.popper) @@ -100,21 +114,33 @@ export const PopperToggle = ({ useTransition(container, _active, transitionTimeoutId) useEffect(() => { - setActive(typeof active === "undefined" ? _active || false : active) - }, [active, _active]) + if (typeof active !== "undefined") { + setActive(active) + } + }, [active]) + + useEffect(() => { + document.body.appendChild(container) + + return () => { + clearTimeout(transitionTimeoutId.current) + if (document.body.contains(container)) { + document.body.removeChild(container) + } + } + }, [container]) useEffect(() => { document.addEventListener("mousedown", handleMouseDown) document.addEventListener("touchstart", handleMouseDown) + document.addEventListener("keydown", handleKeyDown) return () => { - // eslint-disable-next-line react-hooks/exhaustive-deps - clearTimeout(transitionTimeoutId.current) document.removeEventListener("mousedown", handleMouseDown) document.removeEventListener("touchstart", handleMouseDown) - document.body.contains(container) && document.body.removeChild(container) + document.removeEventListener("keydown", handleKeyDown) } - }, [container, handleMouseDown]) + }, [handleMouseDown, handleKeyDown]) return ( <> diff --git a/packages/web-console/src/components/Toast/index.tsx b/packages/web-console/src/components/Toast/index.tsx index eea81c39c..4468e7ad0 100644 --- a/packages/web-console/src/components/Toast/index.tsx +++ b/packages/web-console/src/components/Toast/index.tsx @@ -2,22 +2,24 @@ import React from "react" import { ToastContainer as RTToastContainer, ToastContainerProps, - toast as _toast, + toast as rtToast, Slide, ToastOptions as RTToastOptions, + ToastContent, } from "react-toastify" import { useNotificationCenter as RTNotificationCenter } from "react-toastify/addons/use-notification-center" import { NotificationCenterItem as RNotificationCenterItem } from "react-toastify/addons/use-notification-center/useNotificationCenter" import { BadgeType } from "../../scenes/Import/ImportCSVFiles/types" import { - Check, CloseCircle, ErrorWarning, Information, } from "@styled-icons/remix-line" +import { CheckmarkOutline } from "@styled-icons/evaicons-outline" import { theme } from "../../theme" import "react-toastify/dist/ReactToastify.css" +import "../../styles/_toast.scss" interface StyledIconProps extends React.PropsWithRef> { @@ -25,33 +27,95 @@ interface StyledIconProps title?: string | null } -export const toast = _toast +export type ToastOptions = RTToastOptions export const useNotificationCenter = RTNotificationCenter export type NotificationCenterItem = RNotificationCenterItem -export type ToastOptions = RTToastOptions - export const ToastIcon = ({ type, + size = 18, ...props }: StyledIconProps & { type: BadgeType }) => { switch (type) { case BadgeType.SUCCESS: - return + return case BadgeType.WARNING: - return + return case BadgeType.ERROR: - return + return case BadgeType.INFO: default: - return + return } } +const toast = { + info: (content: ToastContent, options?: ToastOptions) => { + return rtToast.info(content, { + icon: , + className: "toast-info-container", + progressStyle: { + background: theme.color.cyan, + }, + style: { + borderColor: theme.color.cyan, + background: theme.color.backgroundLighter, + }, + ...options, + }) + }, + success: (content: ToastContent, options?: ToastOptions) => { + return rtToast.success(content, { + icon: , + className: "toast-success-container", + progressStyle: { + background: theme.color.green, + }, + style: { + borderColor: theme.color.green, + background: theme.color.backgroundLighter, + }, + ...options, + }) + }, + warning: (content: ToastContent, options?: ToastOptions) => { + return rtToast.warning(content, { + icon: , + className: "toast-warning-container", + progressStyle: { + background: theme.color.orange, + }, + style: { + borderColor: theme.color.orange, + background: theme.color.backgroundLighter, + }, + ...options, + }) + }, + error: (content: ToastContent, options?: ToastOptions) => { + return rtToast.error(content, { + icon: , + progressStyle: { + background: theme.color.red, + }, + className: "toast-error-container", + style: { + borderColor: theme.color.red, + background: theme.color.backgroundLighter, + }, + ...options, + }) + }, + dismiss: rtToast.dismiss, + isActive: rtToast.isActive, +} + +export { toast } + export const ToastContainer = (props?: ToastContainerProps) => { const mergedProps: ToastContainerProps = { autoClose: 3000, @@ -59,6 +123,11 @@ export const ToastContainer = (props?: ToastContainerProps) => { position: "top-right", theme: "dark", transition: Slide, + hideProgressBar: false, + closeButton: true, + closeOnClick: true, + pauseOnHover: true, + pauseOnFocusLoss: false, ...props, } diff --git a/packages/web-console/src/components/TopBar/InstanceSettingsPopper.tsx b/packages/web-console/src/components/TopBar/InstanceSettingsPopper.tsx new file mode 100644 index 000000000..20177165b --- /dev/null +++ b/packages/web-console/src/components/TopBar/InstanceSettingsPopper.tsx @@ -0,0 +1,473 @@ +import React, { useState, FormEvent, ReactNode, useRef, useEffect } from "react" +import styled from "styled-components" +import { Box, Button, Input, Loader, Select } from "@questdb/react-components" +import { Text } from "../Text" +import { PopperToggle } from "../PopperToggle" +import { Preferences, InstanceType } from "../../utils/questdb/types" + +const Wrapper = styled.div` + position: absolute; + margin-top: 0.5rem; + background: ${({ theme }) => theme.color.backgroundDarker}; + border: 1px solid ${({ theme }) => theme.color.gray1}; + border-radius: 0.4rem; + padding: 1.5rem; + width: 32rem; + z-index: 1000; +` + +const ColorSelector = styled.div` + display: flex; + gap: 1rem; + flex-wrap: wrap; + align-self: center; +` + +const ColorOption = styled.button<{ colorValue: string; selected: boolean; customColor?: string }>` + width: 3rem; + height: 3rem; + border-radius: 0.4rem; + cursor: pointer; + border: 2px solid ${({ theme, selected }) => (selected ? theme.color.foreground : "transparent")}; + padding: 0; + background: ${({ colorValue, theme, customColor }) => { + if (customColor) return customColor; + + switch (colorValue) { + case "r": + return "#c7072d" + case "g": + return "#00aa3b" + case "b": + return "#007aff" + case "default": + return theme.color.backgroundLighter + default: + return "transparent" + } + }}; + + &:focus-visible { + outline: 1px solid ${({ theme }) => theme.color.cyan}; + } +` + +const ColorWheelOption = styled.button<{ selected: boolean }>` + position: relative; + width: 3rem; + height: 3rem; + border-radius: 0.4rem; + cursor: pointer; + border: ${({ selected, theme }) => selected ? `2px solid ${theme.color.foreground}` : "0"}; + padding: 0; + overflow: hidden; + + &:focus-visible { + outline: 1px solid ${({ theme }) => theme.color.cyan}; + } + + &::before { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: conic-gradient( + #ff0000, + #ffff00, + #00ff00, + #00ffff, + #0000ff, + #ff00ff, + #ff0000 + ); + } +` + +const ColorPickerContainer = styled.div` + margin-top: 1rem; + display: flex; + flex-direction: column; + gap: 1rem; + align-self: center; +` + +const ColorInputRow = styled.div` + display: flex; + gap: 1rem; + align-items: center; +` + +const ColorSlider = styled.input.attrs({ type: 'range', min: 0, max: 255 })` + flex: 1; + height: 1rem; + appearance: none; + background: linear-gradient(to right, rgb(0,0,0), rgb(255,0,0)); + border-radius: 0.5rem; + + &::-webkit-slider-thumb { + appearance: none; + width: 1.8rem; + height: 1.8rem; + border-radius: 50%; + background: white; + cursor: pointer; + border: 1px solid ${({ theme }) => theme.color.gray1}; + } + + &.red { + background: linear-gradient(to right, rgb(0,0,0), rgb(255,0,0)); + } + + &.green { + background: linear-gradient(to right, rgb(0,0,0), rgb(0,255,0)); + } + + &.blue { + background: linear-gradient(to right, rgb(0,0,0), rgb(0,0,255)); + } +` + +const ColorValueInput = styled.input.attrs({ type: 'number', min: 0, max: 255 })` + width: 6rem; + padding: 0.5rem; + background: ${({ theme }) => theme.color.selection}; + border: 1px solid ${({ theme }) => theme.color.gray1}; + border-radius: 0.4rem; + color: ${({ theme }) => theme.color.foreground}; + + &:focus { + outline: none; + border-color: ${({ theme }) => theme.color.cyan}; + } +` + +const StyledForm = styled.form` + display: flex; + flex-direction: column; + gap: 1.5rem; +` + +const StyledButton = styled(Button)` + font-size: 1.6rem; + &:focus-visible { + outline: 2px solid ${({ theme }) => theme.color.cyan}; + } +` + +const StyledSelect = styled(Select)` + &:focus-visible { + border: 1px solid ${({ theme }) => theme.color.cyan}; + } +` + +const Buttons = styled(Box)` + margin-top: 1.5rem; + gap: 1rem; + justify-content: flex-end; + flex-direction: row-reverse; +` + +const FormGroup = styled(Box).attrs({ flexDirection: "column", gap: "0.5rem" })` + width: 100%; + align-items: flex-start; +` + +const StyledInput = styled(Input)` + width: 100%; + background: ${({ theme }) => theme.color.selection}; + border: 1px solid ${({ theme }) => theme.color.gray1}; + border-radius: 0.4rem; + color: ${({ theme }) => theme.color.foreground}; + + &:focus { + outline: none; + border-color: ${({ theme }) => theme.color.cyan}; + } + + &::placeholder { + color: ${({ theme }) => theme.color.gray2}; + } +` + +const TextArea = styled.textarea` + width: 100%; + min-height: 8rem; + padding: 0.8rem; + background: ${({ theme }) => theme.color.selection}; + border: 1px solid ${({ theme }) => theme.color.gray1}; + border-radius: 0.4rem; + color: ${({ theme }) => theme.color.foreground}; + font-family: inherit; + font-size: 1.4rem; + line-height: 1.5; + resize: vertical; + + &:focus { + outline: none; + border-color: ${({ theme }) => theme.color.cyan}; + } + + &::placeholder { + color: ${({ theme }) => theme.color.gray2}; + } +` + +const FormLabel = styled.label<{ align?: 'left' | 'center' }>` + text-align: ${props => props.align || 'left'}; + width: 100%; + font-size: 1.6rem; +` + +const ErrorText = styled(Text)` + color: ${({ theme }) => theme.color.red}; + font-size: 1.2rem; + margin-top: 0.2rem; +` + +type Props = { + active: boolean + onToggle: (active: boolean) => void + values: Preferences + onSave: (values: Preferences) => Promise + onValuesChange: (values: Preferences) => void + trigger: ReactNode +} + +export const InstanceSettingsPopper = ({ + active, + onToggle, + values, + onSave, + onValuesChange, + trigger, +}: Props) => { + const [isSaving, setIsSaving] = useState(false) + const [instanceNameError, setInstanceNameError] = useState(null) + const [showCustomColor, setShowCustomColor] = useState(false) + const [rgbValues, setRgbValues] = useState({ r: 0, g: 0, b: 0 }) + const inputRef = useRef(null) + + useEffect(() => { + if (values.instance_rgb && values.instance_rgb.startsWith('rgb')) { + setShowCustomColor(true) + const matches = values.instance_rgb.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/) + if (matches) { + setRgbValues({ + r: parseInt(matches[1], 10), + g: parseInt(matches[2], 10), + b: parseInt(matches[3], 10) + }) + } + } else { + setShowCustomColor(false) + } + }, [values.instance_rgb]) + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault() + + if (!values?.instance_name?.trim()) { + setInstanceNameError("Instance name is required") + return + } + setInstanceNameError(null) + + setIsSaving(true) + await onSave(values) // Errors are handled in the parent component + setIsSaving(false) + } + + const handleNameChange = (e: React.ChangeEvent) => { + const newValues = { ...values, instance_name: e.target.value } + onValuesChange(newValues) + } + + const handleColorSelect = (color: string | undefined) => { + onValuesChange({ ...values, instance_rgb: color }) + if (color !== 'custom') { + setShowCustomColor(false) + } + } + + const handleCustomColorSelect = () => { + setShowCustomColor(true) + onValuesChange({ ...values, instance_rgb: rgbColorString }) + } + + const handleRgbChange = (component: 'r' | 'g' | 'b', value: number) => { + const newValues = { ...rgbValues, [component]: value } + setRgbValues(newValues) + const newRgbColor = `rgb(${newValues.r}, ${newValues.g}, ${newValues.b})` + onValuesChange({ ...values, instance_rgb: newRgbColor }) + } + + useEffect(() => { + if (!values.instance_type) { + onValuesChange({ ...values, instance_type: "development" }) + } + if (active) { + setTimeout(() => { + inputRef.current?.focus() + }, 100) + } + }, [active]) + + const rgbColorString = `rgb(${rgbValues.r}, ${rgbValues.g}, ${rgbValues.b})` + + return ( + + + + + Instance Name + + {instanceNameError && {instanceNameError}} + + + Instance Type + onValuesChange({ ...values, instance_type: e.target.value as InstanceType })} + /> + + + Description +