From 1d32dbe8595579be32144e7543e55eaafdab9be2 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 10 Apr 2025 22:19:03 +0000 Subject: [PATCH 01/12] feat: create dynamic parameter component --- site/src/api/typesParameter.ts | 124 ++++++ site/src/components/Checkbox/Checkbox.tsx | 3 + .../MultiSelectCombobox.tsx | 13 +- site/src/components/RadioGroup/RadioGroup.tsx | 2 +- site/src/hooks/useWebsocket.ts | 94 +++++ .../DynamicParameter/DynamicParameter.tsx | 359 ++++++++++++++++++ .../CreateWorkspacePageExperimental.tsx | 101 +++-- .../CreateWorkspacePageViewExperimental.tsx | 227 ++++++++--- 8 files changed, 832 insertions(+), 91 deletions(-) create mode 100644 site/src/api/typesParameter.ts create mode 100644 site/src/hooks/useWebsocket.ts create mode 100644 site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx diff --git a/site/src/api/typesParameter.ts b/site/src/api/typesParameter.ts new file mode 100644 index 0000000000000..c2397611d37ea --- /dev/null +++ b/site/src/api/typesParameter.ts @@ -0,0 +1,124 @@ +// Code generated by 'guts'. DO NOT EDIT. + +// From types/diagnostics.go +export type DiagnosticSeverityString = "error" | "warning"; + +export const DiagnosticSeverityStrings: DiagnosticSeverityString[] = [ + "error", + "warning", +]; + +// From types/diagnostics.go +export type Diagnostics = readonly FriendlyDiagnostic[]; + +// From types/diagnostics.go +export interface FriendlyDiagnostic { + readonly severity: DiagnosticSeverityString; + readonly summary: string; + readonly detail: string; +} + +// From types/value.go +export interface NullHCLString { + readonly value: string; + readonly valid: boolean; +} + +// From types/parameter.go +export interface Parameter extends ParameterData { + readonly value: NullHCLString; + readonly diagnostics: Diagnostics; +} + +// From types/parameter.go +export interface ParameterData { + readonly name: string; + readonly display_name: string; + readonly description: string; + readonly type: ParameterType; + // this is likely an enum in an external package "github.com/coder/terraform-provider-coder/v2/provider.ParameterFormType" + readonly form_type: string; + // empty interface{} type, falling back to unknown + readonly styling: unknown; + readonly mutable: boolean; + readonly default_value: NullHCLString; + readonly icon: string; + readonly options: readonly ParameterOption[]; + readonly validations: readonly ParameterValidation[]; + readonly required: boolean; + readonly order: number; + readonly ephemeral: boolean; +} + +// From types/parameter.go +export interface ParameterOption { + readonly name: string; + readonly description: string; + readonly value: NullHCLString; + readonly icon: string; +} + +// From types/enum.go +export type ParameterType = "bool" | "list(string)" | "number" | "string"; + +export const ParameterTypes: ParameterType[] = [ + "bool", + "list(string)", + "number", + "string", +]; + +// From types/parameter.go +export interface ParameterValidation { + readonly validation_error: string; + readonly validation_regex: string | null; + readonly validation_min: number | null; + readonly validation_max: number | null; + readonly validation_monotonic: string | null; + readonly validation_invalid: boolean | null; +} + +// From web/session.go +export interface Request { + readonly id: number; + readonly inputs: Record; +} + +// From web/session.go +export interface Response { + readonly id: number; + readonly diagnostics: Diagnostics; + readonly parameters: readonly Parameter[]; +} + +// From web/session.go +export interface SessionInputs { + readonly PlanPath: string; + readonly User: WorkspaceOwner; +} + +// From types/parameter.go +export const ValidationMonotonicDecreasing = "decreasing"; + +// From types/parameter.go +export const ValidationMonotonicIncreasing = "increasing"; + +// From types/owner.go +export interface WorkspaceOwner { + readonly id: string; + readonly name: string; + readonly full_name: string; + readonly email: string; + readonly ssh_public_key: string; + readonly groups: readonly string[]; + readonly session_token: string; + readonly oidc_access_token: string; + readonly login_type: string; + readonly rbac_roles: readonly WorkspaceOwnerRBACRole[]; +} + +// From types/owner.go +export interface WorkspaceOwnerRBACRole { + readonly name: string; + readonly org_id: string; +} diff --git a/site/src/components/Checkbox/Checkbox.tsx b/site/src/components/Checkbox/Checkbox.tsx index 304a04ad5b4ca..6bc1338955122 100644 --- a/site/src/components/Checkbox/Checkbox.tsx +++ b/site/src/components/Checkbox/Checkbox.tsx @@ -8,6 +8,9 @@ import * as React from "react"; import { cn } from "utils/cn"; +/** + * To allow for an indeterminate state the checkbox must be controlled, otherwise the checked prop would remain undefined + */ export const Checkbox = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef diff --git a/site/src/components/MultiSelectCombobox/MultiSelectCombobox.tsx b/site/src/components/MultiSelectCombobox/MultiSelectCombobox.tsx index 83f2aeed41cd4..7d21ea453b211 100644 --- a/site/src/components/MultiSelectCombobox/MultiSelectCombobox.tsx +++ b/site/src/components/MultiSelectCombobox/MultiSelectCombobox.tsx @@ -203,9 +203,18 @@ export const MultiSelectCombobox = forwardRef< const [open, setOpen] = useState(false); const [onScrollbar, setOnScrollbar] = useState(false); const [isLoading, setIsLoading] = useState(false); - const dropdownRef = useRef(null); // Added this + const dropdownRef = useRef(null); - const [selected, setSelected] = useState(value || []); + const getInitialSelectedOptions = () => { + if (arrayDefaultOptions && arrayDefaultOptions.length > 0) { + return arrayDefaultOptions; + } + return []; + }; + + const [selected, setSelected] = useState( + getInitialSelectedOptions, + ); const [options, setOptions] = useState( transitionToGroupOption(arrayDefaultOptions, groupBy), ); diff --git a/site/src/components/RadioGroup/RadioGroup.tsx b/site/src/components/RadioGroup/RadioGroup.tsx index 9be24d6e26f33..3b63a91f40087 100644 --- a/site/src/components/RadioGroup/RadioGroup.tsx +++ b/site/src/components/RadioGroup/RadioGroup.tsx @@ -34,7 +34,7 @@ export const RadioGroupItem = React.forwardRef< focus:outline-none focus-visible:ring-2 focus-visible:ring-content-link focus-visible:ring-offset-4 focus-visible:ring-offset-surface-primary disabled:cursor-not-allowed disabled:opacity-25 disabled:border-surface-invert-primary - hover:border-border-hover`, + hover:border-border-hover data-[state=checked]:border-border-hover`, className, )} {...props} diff --git a/site/src/hooks/useWebsocket.ts b/site/src/hooks/useWebsocket.ts new file mode 100644 index 0000000000000..d9aa3ba8f4fa1 --- /dev/null +++ b/site/src/hooks/useWebsocket.ts @@ -0,0 +1,94 @@ +// This file is temporary until we have a proper websocket implementation for dynamic parameters +import { useCallback, useEffect, useRef, useState } from "react"; + +export function useWebSocket( + url: string, + testdata: string, + user: string, + plan: string, +) { + const [message, setMessage] = useState(null); + const [connectionStatus, setConnectionStatus] = useState< + "connecting" | "connected" | "disconnected" + >("connecting"); + const wsRef = useRef(null); + const urlRef = useRef(url); + + const connectWebSocket = useCallback(() => { + try { + const ws = new WebSocket(urlRef.current); + wsRef.current = ws; + setConnectionStatus("connecting"); + + ws.onopen = () => { + // console.log("Connected to WebSocket"); + setConnectionStatus("connected"); + ws.send(JSON.stringify({})); + }; + + ws.onmessage = (event) => { + try { + const data: T = JSON.parse(event.data); + // console.log("Received message:", data); + setMessage(data); + } catch (err) { + console.error("Invalid JSON from server: ", event.data); + console.error("Error: ", err); + } + }; + + ws.onerror = (event) => { + console.error("WebSocket error:", event); + }; + + ws.onclose = (event) => { + // console.log( + // `WebSocket closed with code ${event.code}. Reason: ${event.reason}`, + // ); + setConnectionStatus("disconnected"); + }; + } catch (error) { + console.error("Failed to create WebSocket connection:", error); + setConnectionStatus("disconnected"); + } + }, []); + + useEffect(() => { + if (!testdata) { + return; + } + + setMessage(null); + setConnectionStatus("connecting"); + + const createConnection = () => { + urlRef.current = url; + connectWebSocket(); + }; + + if (wsRef.current) { + wsRef.current.close(); + wsRef.current = null; + } + + const timeoutId = setTimeout(createConnection, 100); + + return () => { + clearTimeout(timeoutId); + if (wsRef.current) { + wsRef.current.close(); + wsRef.current = null; + } + }; + }, [testdata, connectWebSocket, url]); + + const sendMessage = (data: unknown) => { + if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify(data)); + } else { + console.warn("Cannot send message: WebSocket is not connected"); + } + }; + + return { message, sendMessage, connectionStatus }; +} diff --git a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx new file mode 100644 index 0000000000000..44a844a002bc0 --- /dev/null +++ b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx @@ -0,0 +1,359 @@ +import type { Parameter, ParameterOption } from "api/typesParameter"; +import { Badge } from "components/Badge/Badge"; +import { Checkbox } from "components/Checkbox/Checkbox"; +import { ExternalImage } from "components/ExternalImage/ExternalImage"; +import { Input } from "components/Input/Input"; +import { Label } from "components/Label/Label"; +import { MemoizedMarkdown } from "components/Markdown/Markdown"; +import { + MultiSelectCombobox, + type Option, +} from "components/MultiSelectCombobox/MultiSelectCombobox"; +import { RadioGroup, RadioGroupItem } from "components/RadioGroup/RadioGroup"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "components/Select/Select"; +import { Switch } from "components/Switch/Switch"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "components/Tooltip/Tooltip"; +import { Info, Settings, TriangleAlert } from "lucide-react"; +import { type FC, useId } from "react"; + +export interface DynamicParameterProps { + parameter: Parameter; + onChange: (value: string) => void; + disabled?: boolean; + isPreset?: boolean; +} + +export const DynamicParameter: FC = ({ + parameter, + onChange, + disabled, + isPreset, +}) => { + const id = useId(); + + return ( +
+ + + {parameter.diagnostics.length > 0 && ( + + )} +
+ ); +}; + +interface ParameterLabelProps { + parameter: Parameter; + isPreset?: boolean; +} + +const ParameterLabel: FC = ({ parameter, isPreset }) => { + const hasDescription = parameter.description && parameter.description !== ""; + const displayName = parameter.display_name + ? parameter.display_name + : parameter.name; + + return ( +
+ {parameter.icon && ( + + + + )} + +
+ + + {hasDescription && ( +
+ + {parameter.description} + +
+ )} +
+
+ ); +}; + +interface ParameterFieldProps { + parameter: Parameter; + onChange: (value: string) => void; + disabled?: boolean; + id: string; +} + +const ParameterField: FC = ({ + parameter, + onChange, + disabled, + id, +}) => { + const value = parameter.value.valid ? parameter.value.value : ""; + const defaultValue = parameter.default_value.valid + ? parameter.default_value.value + : ""; + + switch (parameter.form_type) { + case "dropdown": + return ( + + ); + + case "multi-select": { + // Map parameter options to MultiSelectCombobox options format + const comboboxOptions: Option[] = parameter.options.map((opt) => ({ + value: opt.value.value, + label: opt.name, + disable: false, + })); + + const defaultOptions: Option[] = JSON.parse(defaultValue).map( + (val: string) => { + const option = parameter.options.find((o) => o.value.value === val); + return { + value: val, + label: option?.name || val, + disable: false, + }; + }, + ); + + return ( + { + const values = newValues.map((option) => option.value); + onChange(JSON.stringify(values)); + }} + hidePlaceholderWhenSelected + placeholder="Select option" + emptyIndicator={ +

+ No results found +

+ } + disabled={disabled} + /> + ); + } + + case "switch": + return ( + { + onChange(checked ? "true" : "false"); + }} + disabled={disabled} + /> + ); + + case "radio": + return ( + + {parameter.options.map((option) => ( +
+ + +
+ ))} +
+ ); + + case "checkbox": + return ( +
+ { + onChange(checked ? "true" : "false"); + }} + disabled={disabled} + /> + +
+ ); + case "input": { + const inputType = parameter.type === "number" ? "number" : "text"; + const inputProps: Record = {}; + + if (parameter.type === "number") { + const validations = parameter.validations[0] || {}; + const { validation_min, validation_max } = validations; + + if (validation_min !== null) { + inputProps.min = validation_min; + } + + if (validation_max !== null) { + inputProps.max = validation_max; + } + } + + return ( + onChange(e.target.value)} + disabled={disabled} + placeholder={ + (parameter.styling as { placehholder?: string })?.placehholder + } + {...inputProps} + /> + ); + } + } +}; + +interface OptionDisplayProps { + option: ParameterOption; +} + +const OptionDisplay: FC = ({ option }) => { + return ( +
+ {option.icon && ( + + )} + {option.name} + {option.description && ( + + + + + + {option.description} + + + )} +
+ ); +}; + +interface ParameterDiagnosticsProps { + diagnostics: Parameter["diagnostics"]; +} + +const ParameterDiagnostics: FC = ({ + diagnostics, +}) => { + return ( +
+ {diagnostics.map((diagnostic, index) => ( +
+
{diagnostic.summary}
+ {diagnostic.detail &&
{diagnostic.detail}
} +
+ ))} +
+ ); +}; diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx index 8598085c948e5..c91aa2d8d768d 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx @@ -1,4 +1,3 @@ -import { API } from "api/api"; import type { ApiErrorResponse } from "api/errors"; import { checkAuthorization } from "api/queries/authCheck"; import { @@ -9,16 +8,22 @@ import { } from "api/queries/templates"; import { autoCreateWorkspace, createWorkspace } from "api/queries/workspaces"; import type { + Template, TemplateVersionParameter, - UserParameter, Workspace, } from "api/typesGenerated"; import { Loader } from "components/Loader/Loader"; import { useAuthenticated } from "contexts/auth/RequireAuth"; import { useEffectEvent } from "hooks/hookPolyfills"; -import { useDashboard } from "modules/dashboard/useDashboard"; import { generateWorkspaceName } from "modules/workspaces/generateWorkspaceName"; -import { type FC, useCallback, useEffect, useRef, useState } from "react"; +import { + type FC, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { Helmet } from "react-helmet-async"; import { useMutation, useQuery, useQueryClient } from "react-query"; import { useNavigate, useParams, useSearchParams } from "react-router-dom"; @@ -28,20 +33,44 @@ import { paramsUsedToCreateWorkspace } from "utils/workspace"; import { CreateWorkspacePageViewExperimental } from "./CreateWorkspacePageViewExperimental"; export const createWorkspaceModes = ["form", "auto", "duplicate"] as const; export type CreateWorkspaceMode = (typeof createWorkspaceModes)[number]; +import type { + Response, +} from "api/typesParameter"; +import { useWebSocket } from "hooks/useWebsocket"; import { type CreateWorkspacePermissions, createWorkspaceChecks, } from "./permissions"; - export type ExternalAuthPollingState = "idle" | "polling" | "abandoned"; +const serverAddress = "localhost:8100"; +const urlTestdata = "demo"; +const wsUrl = `ws://${serverAddress}/ws/${encodeURIComponent(urlTestdata)}`; + const CreateWorkspacePageExperimental: FC = () => { const { organization: organizationName = "default", template: templateName } = useParams() as { organization?: string; template: string }; const { user: me } = useAuthenticated(); const navigate = useNavigate(); const [searchParams] = useSearchParams(); - const { experiments } = useDashboard(); + + const [currentResponse, setCurrentResponse] = useState(null); + const [wsResponseId, setWSResponseId] = useState(0); + const { + message: webSocketResponse, + sendMessage, + } = useWebSocket(wsUrl, urlTestdata, "", ""); + + useEffect(() => { + if (webSocketResponse && webSocketResponse.id >= wsResponseId) { + setCurrentResponse((prev) => { + if (prev?.id === webSocketResponse.id) { + return prev; + } + return webSocketResponse; + }); + } + }, [webSocketResponse, wsResponseId]); const customVersionId = searchParams.get("version") ?? undefined; const defaultName = searchParams.get("name"); @@ -107,16 +136,7 @@ const CreateWorkspacePageExperimental: FC = () => { ); // Auto fill parameters - const autofillEnabled = experiments.includes("auto-fill-parameters"); - const userParametersQuery = useQuery({ - queryKey: ["userParameters"], - queryFn: () => API.getUserParameters(templateQuery.data!.id), - enabled: autofillEnabled && templateQuery.isSuccess, - }); - const autofillParameters = getAutofillParameters( - searchParams, - userParametersQuery.data ? userParametersQuery.data : [], - ); + const autofillParameters = getAutofillParameters(searchParams); const autoCreationStartedRef = useRef(false); const automateWorkspaceCreation = useEffectEvent(async () => { @@ -146,10 +166,7 @@ const CreateWorkspacePageExperimental: FC = () => { externalAuth?.every((auth) => auth.optional || auth.authenticated), ); - let autoCreateReady = - mode === "auto" && - (!autofillEnabled || userParametersQuery.isSuccess) && - hasAllRequiredExternalAuth; + let autoCreateReady = mode === "auto" && hasAllRequiredExternalAuth; // `mode=auto` was set, but a prerequisite has failed, and so auto-mode should be abandoned. if ( @@ -181,17 +198,29 @@ const CreateWorkspacePageExperimental: FC = () => { } }, [automateWorkspaceCreation, autoCreateReady]); + const sortedParams = useMemo(() => { + if (!currentResponse?.parameters) { + return []; + } + return [...currentResponse.parameters].sort((a, b) => a.order - b.order); + }, [currentResponse?.parameters]); + + // console.log("sortedParams", sortedParams); return ( <> {pageTitle(title)} - {isLoadingFormData || isLoadingExternalAuth || autoCreateReady ? ( + {!currentResponse || + isLoadingFormData || + isLoadingExternalAuth || + autoCreateReady ? ( ) : ( { autoCreateWorkspaceMutation.error } resetMutation={createWorkspaceMutation.reset} - template={templateQuery.data!} + template={templateQuery.data ?? ({} as Template)} versionId={realizedVersionId} externalAuth={externalAuth ?? []} externalAuthPollingState={externalAuthPollingState} startPollingExternalAuth={startPollingExternalAuth} hasAllRequiredExternalAuth={hasAllRequiredExternalAuth} permissions={permissionsQuery.data as CreateWorkspacePermissions} - parameters={realizedParameters as TemplateVersionParameter[]} + templateVersionParameters={ + realizedParameters as TemplateVersionParameter[] + } + parameters={sortedParams} presets={templateVersionPresetsQuery.data ?? []} creatingWorkspace={createWorkspaceMutation.isLoading} + setWSResponseId={setWSResponseId} + sendMessage={sendMessage} onCancel={() => { navigate(-1); }} onSubmit={async (request, owner) => { + let workspaceRequest = request; if (realizedVersionId) { - request = { + workspaceRequest = { ...request, template_id: undefined, template_version_id: realizedVersionId, @@ -225,7 +260,7 @@ const CreateWorkspacePageExperimental: FC = () => { } const workspace = await createWorkspaceMutation.mutateAsync({ - ...request, + ...workspaceRequest, userId: owner.id, }); onCreateWorkspace(workspace); @@ -286,13 +321,7 @@ const useExternalAuth = (versionId: string | undefined) => { const getAutofillParameters = ( urlSearchParams: URLSearchParams, - userParameters: UserParameter[], ): AutofillBuildParameter[] => { - const userParamMap = userParameters.reduce((acc, param) => { - acc.set(param.name, param); - return acc; - }, new Map()); - const buildValues: AutofillBuildParameter[] = Array.from( urlSearchParams.keys(), ) @@ -300,18 +329,8 @@ const getAutofillParameters = ( .map((key) => { const name = key.replace("param.", ""); const value = urlSearchParams.get(key) ?? ""; - // URL should take precedence over user parameters - userParamMap.delete(name); return { name, value, source: "url" }; }); - - for (const param of userParamMap.values()) { - buildValues.push({ - name: param.name, - value: param.value, - source: "user_history", - }); - } return buildValues; }; diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx index ff8c2836be311..8667b13909675 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx @@ -1,5 +1,9 @@ -import type { Interpolation, Theme } from "@emotion/react"; import type * as TypesGen from "api/typesGenerated"; +import type { + Diagnostics, + Parameter, + Request, +} from "api/typesParameter"; import { Alert } from "components/Alert/Alert"; import { ErrorAlert } from "components/Alert/ErrorAlert"; import { Avatar } from "components/Avatar/Avatar"; @@ -9,12 +13,13 @@ import { SelectFilter } from "components/Filter/SelectFilter"; import { Input } from "components/Input/Input"; import { Label } from "components/Label/Label"; import { Pill } from "components/Pill/Pill"; -import { RichParameterInput } from "components/RichParameterInput/RichParameterInput"; import { Spinner } from "components/Spinner/Spinner"; import { Stack } from "components/Stack/Stack"; +import { Switch } from "components/Switch/Switch"; import { UserAutocomplete } from "components/UserAutocomplete/UserAutocomplete"; import { type FormikContextType, useFormik } from "formik"; import { ArrowLeft } from "lucide-react"; +import { DynamicParameter } from "modules/workspaces/DynamicParameter/DynamicParameter"; import { generateWorkspaceName } from "modules/workspaces/generateWorkspaceName"; import { type FC, @@ -37,65 +42,112 @@ import type { } from "./CreateWorkspacePage"; import { ExternalAuthButton } from "./ExternalAuthButton"; import type { CreateWorkspacePermissions } from "./permissions"; + export const Language = { duplicationWarning: "Duplicating a workspace only copies its parameters. No state from the old workspace is copied over.", } as const; export interface CreateWorkspacePageViewExperimentalProps { - mode: CreateWorkspaceMode; + autofillParameters: AutofillBuildParameter[]; + creatingWorkspace: boolean; defaultName?: string | null; + defaultOwner: TypesGen.User; + diagnostics: Diagnostics; disabledParams?: string[]; error: unknown; - resetMutation: () => void; - defaultOwner: TypesGen.User; - template: TypesGen.Template; - versionId?: string; externalAuth: TypesGen.TemplateVersionExternalAuth[]; externalAuthPollingState: ExternalAuthPollingState; - startPollingExternalAuth: () => void; hasAllRequiredExternalAuth: boolean; - parameters: TypesGen.TemplateVersionParameter[]; - autofillParameters: AutofillBuildParameter[]; - presets: TypesGen.Preset[]; + mode: CreateWorkspaceMode; + parameters: Parameter[]; permissions: CreateWorkspacePermissions; - creatingWorkspace: boolean; + presets: TypesGen.Preset[]; + template: TypesGen.Template; + templateVersionParameters: TypesGen.TemplateVersionParameter[]; + versionId?: string; onCancel: () => void; onSubmit: ( req: TypesGen.CreateWorkspaceRequest, owner: TypesGen.User, ) => void; + resetMutation: () => void; + sendMessage: (message: Request) => void; + setWSResponseId: (value: React.SetStateAction) => void; + startPollingExternalAuth: () => void; } +// const getInitialParameterValues = ( +// params: Parameter[], +// autofillParams?: AutofillBuildParameter[], +// ): WorkspaceBuildParameter[] => { +// return params.map((parameter) => { +// // Short-circuit for ephemeral parameters, which are always reset to +// // the template-defined default. +// if (parameter.ephemeral) { +// return { +// name: parameter.name, +// value: parameter.default_value, +// }; +// } + +// const autofillParam = autofillParams?.find( +// ({ name }) => name === parameter.name, +// ); + +// return { +// name: parameter.name, +// value: +// autofillParam && +// // isValidValue(parameter, autofillParam) && +// autofillParam.source !== "user_history" +// ? autofillParam.value +// : parameter.default_value, +// }; +// }); +// }; + +const getInitialParameterValues = (parameters: Parameter[]) => { + return parameters.map((parameter) => { + return { + name: parameter.name, + value: parameter.default_value.valid ? parameter.default_value.value : "", + }; + }); +}; export const CreateWorkspacePageViewExperimental: FC< CreateWorkspacePageViewExperimentalProps > = ({ - mode, + autofillParameters, + creatingWorkspace, defaultName, + defaultOwner, + diagnostics, disabledParams, error, - resetMutation, - defaultOwner, - template, - versionId, externalAuth, externalAuthPollingState, - startPollingExternalAuth, hasAllRequiredExternalAuth, + mode, parameters, - autofillParameters, - presets = [], permissions, - creatingWorkspace, + presets = [], + template, + templateVersionParameters, + versionId, onSubmit, onCancel, + resetMutation, + sendMessage, + setWSResponseId, + startPollingExternalAuth, }) => { const [owner, setOwner] = useState(defaultOwner); const [suggestedName, setSuggestedName] = useState(() => generateWorkspaceName(), ); + const [showPresetParameters, setShowPresetParameters] = useState(false); const id = useId(); - const rerollSuggestedName = useCallback(() => { setSuggestedName(() => generateWorkspaceName()); }, []); @@ -105,16 +157,17 @@ export const CreateWorkspacePageViewExperimental: FC< initialValues: { name: defaultName ?? "", template_id: template.id, - rich_parameter_values: getInitialRichParameterValues( - parameters, - autofillParameters, - ), + rich_parameter_values: getInitialParameterValues(parameters), }, validationSchema: Yup.object({ name: nameValidator("Workspace Name"), - rich_parameter_values: useValidationSchemaForRichParameters(parameters), + rich_parameter_values: useValidationSchemaForRichParameters( + templateVersionParameters, + ), }), enableReinitialize: true, + validateOnChange: false, + validateOnBlur: true, onSubmit: (request) => { if (!hasAllRequiredExternalAuth) { return; @@ -195,10 +248,75 @@ export const CreateWorkspacePageViewExperimental: FC< presetOptions, selectedPresetIndex, presets, - parameters, form.setFieldValue, + parameters, ]); + const [debouncedTimer, setDebouncedTimer] = useState( + null, + ); + + const handleChange = async ( + value: string, + parameterField: string, + parameter: Parameter, + ) => { + // Update form value immediately for all types + await form.setFieldValue(parameterField, { + name: parameter.form_type, + value, + }); + + // Create the request object + const createRequest = () => { + // Convert the rich_parameter_values array to a key-value object + const newInputs = (form.values.rich_parameter_values ?? []).reduce( + (acc, param) => { + acc[param.name] = param.value; + return acc; + }, + {} as Record, + ); + + // Update the input for the changed parameter + newInputs[parameter.name] = value; + + setWSResponseId((prevId) => { + const newId = prevId + 1; + const request: Request = { + id: newId, + inputs: newInputs, + }; + sendMessage(request); + return newId; + }); + }; + + // Clear any existing timer + if (debouncedTimer) { + clearTimeout(debouncedTimer); + } + + // For input type, debounce the sendMessage + if (parameter.form_type === "input") { + const timer = setTimeout(() => { + createRequest(); + }, 1050); + setDebouncedTimer(timer); + } else { + // For all other form control types (checkbox, select, etc.), send immediately + createRequest(); + } + }; + + useEffect(() => { + return () => { + if (debouncedTimer) { + clearTimeout(debouncedTimer); + } + }; + }, [debouncedTimer]); + return ( <>
@@ -353,9 +471,8 @@ export const CreateWorkspacePageViewExperimental: FC<

Parameters

- These are the settings used by your template. Please note that - immutable parameters cannot be modified once the workspace is - created. + These are the settings used by your template. Immutable + parameters cannot be modified once the workspace is created.

{presets.length > 0 && ( @@ -382,6 +499,22 @@ export const CreateWorkspacePageViewExperimental: FC< selectedOption={presetOptions[selectedPresetIndex]} />
+
+ + +
)} @@ -390,26 +523,33 @@ export const CreateWorkspacePageViewExperimental: FC< {parameters.map((parameter, index) => { const parameterField = `rich_parameter_values.${index}`; const parameterInputName = `${parameterField}.value`; + const isPresetParameter = presetParameterNames.includes( + parameter.name, + ); const isDisabled = disabledParams?.includes( parameter.name.toLowerCase().replace(/ /g, "_"), ) || + (parameter.styling as { disabled?: boolean })?.disabled || creatingWorkspace || - presetParameterNames.includes(parameter.name); + isPresetParameter; + + // Hide preset parameters if showPresetParameters is false + if (!showPresetParameters && isPresetParameter) { + return null; + } return ( - { - await form.setFieldValue(parameterField, { - name: parameter.name, - value, - }); - }} key={parameter.name} parameter={parameter} - parameterAutofill={autofillByName[parameter.name]} + onChange={(value) => + handleChange(value, parameterField, parameter) + } disabled={isDisabled} + isPreset={isPresetParameter} + // parameterAutofill={autofillByName[parameter.name]} /> ); })} @@ -431,10 +571,3 @@ export const CreateWorkspacePageViewExperimental: FC< ); }; - -const styles = { - description: (theme) => ({ - fontSize: 13, - color: theme.palette.text.secondary, - }), -} satisfies Record>; From 5b1d5b4fe3ebc2cf25f47a82c072cc645e9e212e Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Fri, 11 Apr 2025 14:53:05 +0000 Subject: [PATCH 02/12] fix: format --- .../CreateWorkspacePageExperimental.tsx | 14 +++++++------- .../CreateWorkspacePageViewExperimental.tsx | 6 +----- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx index c91aa2d8d768d..d1b2e348e8d3f 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx @@ -33,9 +33,7 @@ import { paramsUsedToCreateWorkspace } from "utils/workspace"; import { CreateWorkspacePageViewExperimental } from "./CreateWorkspacePageViewExperimental"; export const createWorkspaceModes = ["form", "auto", "duplicate"] as const; export type CreateWorkspaceMode = (typeof createWorkspaceModes)[number]; -import type { - Response, -} from "api/typesParameter"; +import type { Response } from "api/typesParameter"; import { useWebSocket } from "hooks/useWebsocket"; import { type CreateWorkspacePermissions, @@ -56,10 +54,12 @@ const CreateWorkspacePageExperimental: FC = () => { const [currentResponse, setCurrentResponse] = useState(null); const [wsResponseId, setWSResponseId] = useState(0); - const { - message: webSocketResponse, - sendMessage, - } = useWebSocket(wsUrl, urlTestdata, "", ""); + const { message: webSocketResponse, sendMessage } = useWebSocket( + wsUrl, + urlTestdata, + "", + "", + ); useEffect(() => { if (webSocketResponse && webSocketResponse.id >= wsResponseId) { diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx index 8667b13909675..06291c303c1b9 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx @@ -1,9 +1,5 @@ import type * as TypesGen from "api/typesGenerated"; -import type { - Diagnostics, - Parameter, - Request, -} from "api/typesParameter"; +import type { Diagnostics, Parameter, Request } from "api/typesParameter"; import { Alert } from "components/Alert/Alert"; import { ErrorAlert } from "components/Alert/ErrorAlert"; import { Avatar } from "components/Avatar/Avatar"; From 4516af20817878e8da230125eac93d294961130f Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Fri, 11 Apr 2025 17:48:17 +0000 Subject: [PATCH 03/12] chore: cleanup, update validation --- .../DynamicParameter/DynamicParameter.tsx | 176 +++++++++++++++++- .../CreateWorkspacePageExperimental.tsx | 27 +-- .../CreateWorkspacePageViewExperimental.tsx | 22 +-- 3 files changed, 187 insertions(+), 38 deletions(-) diff --git a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx index 44a844a002bc0..47515dca31e1b 100644 --- a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx +++ b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx @@ -1,4 +1,9 @@ -import type { Parameter, ParameterOption } from "api/typesParameter"; +import type { WorkspaceBuildParameter } from "api/typesGenerated"; +import type { + Parameter, + ParameterOption, + ParameterValidation, +} from "api/typesParameter"; import { Badge } from "components/Badge/Badge"; import { Checkbox } from "components/Checkbox/Checkbox"; import { ExternalImage } from "components/ExternalImage/ExternalImage"; @@ -26,6 +31,7 @@ import { } from "components/Tooltip/Tooltip"; import { Info, Settings, TriangleAlert } from "lucide-react"; import { type FC, useId } from "react"; +import * as Yup from "yup"; export interface DynamicParameterProps { parameter: Parameter; @@ -324,7 +330,9 @@ const OptionDisplay: FC = ({ option }) => { - {option.description} + + {option.description} + )} @@ -357,3 +365,167 @@ const ParameterDiagnostics: FC = ({ ); }; + +export const useValidationSchemaForDynamicParameters = ( + parameters?: Parameter[], + lastBuildParameters?: WorkspaceBuildParameter[], +): Yup.AnySchema => { + if (!parameters) { + return Yup.object(); + } + + return Yup.array() + .of( + Yup.object().shape({ + name: Yup.string().required(), + value: Yup.string() + .test("verify with template", (val, ctx) => { + const name = ctx.parent.name; + const parameter = parameters.find( + (parameter) => parameter.name === name, + ); + if (parameter) { + switch (parameter.type) { + case "number": { + const minValidation = parameter.validations.find( + (v) => v.validation_min !== null, + ); + const maxValidation = parameter.validations.find( + (v) => v.validation_max !== null, + ); + + if ( + minValidation?.validation_min && + !maxValidation && + Number(val) < minValidation.validation_min + ) { + return ctx.createError({ + path: ctx.path, + message: + parameterError(parameter, val) ?? + `Value must be greater than ${minValidation.validation_min}.`, + }); + } + + if ( + !minValidation && + maxValidation?.validation_max && + Number(val) > maxValidation.validation_max + ) { + return ctx.createError({ + path: ctx.path, + message: + parameterError(parameter, val) ?? + `Value must be less than ${maxValidation.validation_max}.`, + }); + } + + if ( + minValidation?.validation_min && + maxValidation?.validation_max && + (Number(val) < minValidation.validation_min || + Number(val) > maxValidation.validation_max) + ) { + return ctx.createError({ + path: ctx.path, + message: + parameterError(parameter, val) ?? + `Value must be between ${minValidation.validation_min} and ${maxValidation.validation_max}.`, + }); + } + + const monotonicValidation = parameter.validations.find( + (v) => v.validation_monotonic !== null, + ); + if ( + monotonicValidation?.validation_monotonic && + lastBuildParameters + ) { + const lastBuildParameter = lastBuildParameters.find( + (last: { name: string }) => last.name === name, + ); + if (lastBuildParameter) { + switch (monotonicValidation.validation_monotonic) { + case "increasing": + if (Number(lastBuildParameter.value) > Number(val)) { + return ctx.createError({ + path: ctx.path, + message: `Value must only ever increase (last value was ${lastBuildParameter.value})`, + }); + } + break; + case "decreasing": + if (Number(lastBuildParameter.value) < Number(val)) { + return ctx.createError({ + path: ctx.path, + message: `Value must only ever decrease (last value was ${lastBuildParameter.value})`, + }); + } + break; + } + } + } + break; + } + case "string": { + const regexValidation = parameter.validations.find( + (v) => v.validation_regex !== null, + ); + if (!regexValidation?.validation_regex) { + return true; + } + + if ( + val && + !new RegExp(regexValidation.validation_regex).test(val) + ) { + return ctx.createError({ + path: ctx.path, + message: parameterError(parameter, val), + }); + } + break; + } + } + } + return true; + }), + }), + ) + .required(); +}; + +const parameterError = ( + parameter: Parameter, + value?: string, +): string | undefined => { + const validation_error = parameter.validations.find( + (v) => v.validation_error !== null, + ); + const minValidation = parameter.validations.find( + (v) => v.validation_min !== null, + ); + const maxValidation = parameter.validations.find( + (v) => v.validation_max !== null, + ); + + if (!validation_error || !value) { + return; + } + + const r = new Map([ + [ + "{min}", + minValidation ? (minValidation.validation_min?.toString() ?? "") : "", + ], + [ + "{max}", + maxValidation ? (maxValidation.validation_max?.toString() ?? "") : "", + ], + ["{value}", value], + ]); + return validation_error.validation_error.replace( + /{min}|{max}|{value}/g, + (match) => r.get(match) || "", + ); +}; diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx index d1b2e348e8d3f..08545391b9033 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx @@ -1,17 +1,12 @@ import type { ApiErrorResponse } from "api/errors"; import { checkAuthorization } from "api/queries/authCheck"; import { - richParameters, templateByName, templateVersionExternalAuth, templateVersionPresets, } from "api/queries/templates"; import { autoCreateWorkspace, createWorkspace } from "api/queries/workspaces"; -import type { - Template, - TemplateVersionParameter, - Workspace, -} from "api/typesGenerated"; +import type { Template, Workspace } from "api/typesGenerated"; import { Loader } from "components/Loader/Loader"; import { useAuthenticated } from "contexts/auth/RequireAuth"; import { useEffectEvent } from "hooks/hookPolyfills"; @@ -29,7 +24,6 @@ import { useMutation, useQuery, useQueryClient } from "react-query"; import { useNavigate, useParams, useSearchParams } from "react-router-dom"; import { pageTitle } from "utils/page"; import type { AutofillBuildParameter } from "utils/richParameters"; -import { paramsUsedToCreateWorkspace } from "utils/workspace"; import { CreateWorkspacePageViewExperimental } from "./CreateWorkspacePageViewExperimental"; export const createWorkspaceModes = ["form", "auto", "duplicate"] as const; export type CreateWorkspaceMode = (typeof createWorkspaceModes)[number]; @@ -101,14 +95,8 @@ const CreateWorkspacePageExperimental: FC = () => { ); const realizedVersionId = customVersionId ?? templateQuery.data?.active_version_id; + const organizationId = templateQuery.data?.organization_id; - const richParametersQuery = useQuery({ - ...richParameters(realizedVersionId ?? ""), - enabled: realizedVersionId !== undefined, - }); - const realizedParameters = richParametersQuery.data - ? richParametersQuery.data.filter(paramsUsedToCreateWorkspace) - : undefined; const { externalAuth, @@ -118,11 +106,8 @@ const CreateWorkspacePageExperimental: FC = () => { } = useExternalAuth(realizedVersionId); const isLoadingFormData = - templateQuery.isLoading || - permissionsQuery.isLoading || - richParametersQuery.isLoading; - const loadFormDataError = - templateQuery.error ?? permissionsQuery.error ?? richParametersQuery.error; + templateQuery.isLoading || permissionsQuery.isLoading; + const loadFormDataError = templateQuery.error ?? permissionsQuery.error; const title = autoCreateWorkspaceMutation.isLoading ? "Creating workspace..." @@ -205,7 +190,6 @@ const CreateWorkspacePageExperimental: FC = () => { return [...currentResponse.parameters].sort((a, b) => a.order - b.order); }, [currentResponse?.parameters]); - // console.log("sortedParams", sortedParams); return ( <> @@ -238,9 +222,6 @@ const CreateWorkspacePageExperimental: FC = () => { startPollingExternalAuth={startPollingExternalAuth} hasAllRequiredExternalAuth={hasAllRequiredExternalAuth} permissions={permissionsQuery.data as CreateWorkspacePermissions} - templateVersionParameters={ - realizedParameters as TemplateVersionParameter[] - } parameters={sortedParams} presets={templateVersionPresetsQuery.data ?? []} creatingWorkspace={createWorkspaceMutation.isLoading} diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx index 06291c303c1b9..2c548796a4e0b 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx @@ -15,7 +15,10 @@ import { Switch } from "components/Switch/Switch"; import { UserAutocomplete } from "components/UserAutocomplete/UserAutocomplete"; import { type FormikContextType, useFormik } from "formik"; import { ArrowLeft } from "lucide-react"; -import { DynamicParameter } from "modules/workspaces/DynamicParameter/DynamicParameter"; +import { + DynamicParameter, + useValidationSchemaForDynamicParameters, +} from "modules/workspaces/DynamicParameter/DynamicParameter"; import { generateWorkspaceName } from "modules/workspaces/generateWorkspaceName"; import { type FC, @@ -26,11 +29,7 @@ import { useState, } from "react"; import { getFormHelpers, nameValidator } from "utils/formUtils"; -import { - type AutofillBuildParameter, - getInitialRichParameterValues, - useValidationSchemaForRichParameters, -} from "utils/richParameters"; +import type { AutofillBuildParameter } from "utils/richParameters"; import * as Yup from "yup"; import type { CreateWorkspaceMode, @@ -60,7 +59,6 @@ export interface CreateWorkspacePageViewExperimentalProps { permissions: CreateWorkspacePermissions; presets: TypesGen.Preset[]; template: TypesGen.Template; - templateVersionParameters: TypesGen.TemplateVersionParameter[]; versionId?: string; onCancel: () => void; onSubmit: ( @@ -73,7 +71,7 @@ export interface CreateWorkspacePageViewExperimentalProps { startPollingExternalAuth: () => void; } -// const getInitialParameterValues = ( +// const getInitialParameterValues1 = ( // params: Parameter[], // autofillParams?: AutofillBuildParameter[], // ): WorkspaceBuildParameter[] => { @@ -95,7 +93,7 @@ export interface CreateWorkspacePageViewExperimentalProps { // name: parameter.name, // value: // autofillParam && -// // isValidValue(parameter, autofillParam) && +// isValidValue(parameter, autofillParam) && // autofillParam.source !== "user_history" // ? autofillParam.value // : parameter.default_value, @@ -129,7 +127,6 @@ export const CreateWorkspacePageViewExperimental: FC< permissions, presets = [], template, - templateVersionParameters, versionId, onSubmit, onCancel, @@ -157,9 +154,8 @@ export const CreateWorkspacePageViewExperimental: FC< }, validationSchema: Yup.object({ name: nameValidator("Workspace Name"), - rich_parameter_values: useValidationSchemaForRichParameters( - templateVersionParameters, - ), + rich_parameter_values: + useValidationSchemaForDynamicParameters(parameters), }), enableReinitialize: true, validateOnChange: false, From 5784127f45d51a0574d91384a3ec0516fcef6ef7 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Fri, 11 Apr 2025 20:19:48 +0000 Subject: [PATCH 04/12] chore: update for types from typesGenerated --- .../DynamicParameter/DynamicParameter.tsx | 246 ++++++++++++------ .../CreateWorkspacePageViewExperimental.tsx | 70 ++--- 2 files changed, 178 insertions(+), 138 deletions(-) diff --git a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx index 47515dca31e1b..11d2396fa8a96 100644 --- a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx +++ b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx @@ -1,9 +1,8 @@ -import type { WorkspaceBuildParameter } from "api/typesGenerated"; import type { - Parameter, - ParameterOption, - ParameterValidation, -} from "api/typesParameter"; + PreviewParameter, + PreviewParameterOption, + WorkspaceBuildParameter, +} from "api/typesGenerated"; import { Badge } from "components/Badge/Badge"; import { Checkbox } from "components/Checkbox/Checkbox"; import { ExternalImage } from "components/ExternalImage/ExternalImage"; @@ -31,10 +30,11 @@ import { } from "components/Tooltip/Tooltip"; import { Info, Settings, TriangleAlert } from "lucide-react"; import { type FC, useId } from "react"; +import type { AutofillBuildParameter } from "utils/richParameters"; import * as Yup from "yup"; export interface DynamicParameterProps { - parameter: Parameter; + parameter: PreviewParameter; onChange: (value: string) => void; disabled?: boolean; isPreset?: boolean; @@ -68,7 +68,7 @@ export const DynamicParameter: FC = ({ }; interface ParameterLabelProps { - parameter: Parameter; + parameter: PreviewParameter; isPreset?: boolean; } @@ -144,7 +144,7 @@ const ParameterLabel: FC = ({ parameter, isPreset }) => { }; interface ParameterFieldProps { - parameter: Parameter; + parameter: PreviewParameter; onChange: (value: string) => void; disabled?: boolean; id: string; @@ -173,26 +173,35 @@ const ParameterField: FC = ({ - {parameter.options.map((option) => ( - - - - ))} + {parameter.options + .filter( + (option): option is NonNullable => + option !== null, + ) + .map((option) => ( + + + + ))} ); case "multi-select": { // Map parameter options to MultiSelectCombobox options format - const comboboxOptions: Option[] = parameter.options.map((opt) => ({ - value: opt.value.value, - label: opt.name, - disable: false, - })); + const comboboxOptions: Option[] = parameter.options + .filter((opt): opt is NonNullable => opt !== null) + .map((opt) => ({ + value: opt.value.value, + label: opt.name, + disable: false, + })); const defaultOptions: Option[] = JSON.parse(defaultValue).map( (val: string) => { - const option = parameter.options.find((o) => o.value.value === val); + const option = parameter.options + .filter((o): o is NonNullable => o !== null) + .find((o) => o.value.value === val); return { value: val, label: option?.name || val, @@ -242,20 +251,24 @@ const ParameterField: FC = ({ disabled={disabled} defaultValue={defaultValue} > - {parameter.options.map((option) => ( -
- - -
- ))} + {parameter.options + .filter( + (option): option is NonNullable => option !== null, + ) + .map((option) => ( +
+ + +
+ ))} ); @@ -281,7 +294,10 @@ const ParameterField: FC = ({ const inputProps: Record = {}; if (parameter.type === "number") { - const validations = parameter.validations[0] || {}; + const validations = + parameter.validations.filter( + (v): v is NonNullable => v !== null, + )[0] || {}; const { validation_min, validation_max } = validations; if (validation_min !== null) { @@ -310,7 +326,7 @@ const ParameterField: FC = ({ }; interface OptionDisplayProps { - option: ParameterOption; + option: PreviewParameterOption; } const OptionDisplay: FC = ({ option }) => { @@ -341,7 +357,7 @@ const OptionDisplay: FC = ({ option }) => { }; interface ParameterDiagnosticsProps { - diagnostics: Parameter["diagnostics"]; + diagnostics: PreviewParameter["diagnostics"]; } const ParameterDiagnostics: FC = ({ @@ -349,25 +365,76 @@ const ParameterDiagnostics: FC = ({ }) => { return (
- {diagnostics.map((diagnostic, index) => ( -
-
{diagnostic.summary}
- {diagnostic.detail &&
{diagnostic.detail}
} -
- ))} + {diagnostics + .filter( + (diagnostic): diagnostic is NonNullable => + diagnostic !== null, + ) + .map((diagnostic, index) => ( +
+
{diagnostic.summary}
+ {diagnostic.detail &&
{diagnostic.detail}
} +
+ ))}
); }; +export const getInitialParameterValues = ( + params: PreviewParameter[], + autofillParams?: AutofillBuildParameter[], +): WorkspaceBuildParameter[] => { + return params.map((parameter) => { + // Short-circuit for ephemeral parameters, which are always reset to + // the template-defined default. + if (parameter.ephemeral) { + return { + name: parameter.name, + value: parameter.default_value.valid + ? parameter.default_value.value + : "", + }; + } + + const autofillParam = autofillParams?.find( + ({ name }) => name === parameter.name, + ); + + return { + name: parameter.name, + value: + autofillParam && + isValidValue(parameter, autofillParam) && + autofillParam.value + ? autofillParam.value + : "", + }; + }); +}; + +const isValidValue = ( + previewParam: PreviewParameter, + buildParam: WorkspaceBuildParameter, +) => { + if (previewParam.options.length > 0) { + const validValues = previewParam.options + .filter((option): option is NonNullable => option !== null) + .map((option) => option.value.value); + return validValues.includes(buildParam.value); + } + + return true; +}; + export const useValidationSchemaForDynamicParameters = ( - parameters?: Parameter[], + parameters?: PreviewParameter[], lastBuildParameters?: WorkspaceBuildParameter[], ): Yup.AnySchema => { if (!parameters) { @@ -387,15 +454,16 @@ export const useValidationSchemaForDynamicParameters = ( if (parameter) { switch (parameter.type) { case "number": { - const minValidation = parameter.validations.find( - (v) => v.validation_min !== null, - ); - const maxValidation = parameter.validations.find( - (v) => v.validation_max !== null, - ); + const minValidation = parameter.validations + .filter((v): v is NonNullable => v !== null) + .find((v) => v.validation_min !== null); + const maxValidation = parameter.validations + .filter((v): v is NonNullable => v !== null) + .find((v) => v.validation_max !== null); if ( - minValidation?.validation_min && + minValidation && + minValidation.validation_min !== null && !maxValidation && Number(val) < minValidation.validation_min ) { @@ -409,7 +477,8 @@ export const useValidationSchemaForDynamicParameters = ( if ( !minValidation && - maxValidation?.validation_max && + maxValidation && + maxValidation.validation_max !== null && Number(val) > maxValidation.validation_max ) { return ctx.createError({ @@ -421,8 +490,10 @@ export const useValidationSchemaForDynamicParameters = ( } if ( - minValidation?.validation_min && - maxValidation?.validation_max && + minValidation && + minValidation.validation_min !== null && + maxValidation && + maxValidation.validation_max !== null && (Number(val) < minValidation.validation_min || Number(val) > maxValidation.validation_max) ) { @@ -434,18 +505,20 @@ export const useValidationSchemaForDynamicParameters = ( }); } - const monotonicValidation = parameter.validations.find( - (v) => v.validation_monotonic !== null, - ); - if ( - monotonicValidation?.validation_monotonic && - lastBuildParameters - ) { + const monotonic = parameter.validations + .filter((v): v is NonNullable => v !== null) + .find( + (v) => + v.validation_monotonic !== null && + v.validation_monotonic !== "", + ); + + if (monotonic && lastBuildParameters) { const lastBuildParameter = lastBuildParameters.find( (last: { name: string }) => last.name === name, ); if (lastBuildParameter) { - switch (monotonicValidation.validation_monotonic) { + switch (monotonic.validation_monotonic) { case "increasing": if (Number(lastBuildParameter.value) > Number(val)) { return ctx.createError({ @@ -468,17 +541,18 @@ export const useValidationSchemaForDynamicParameters = ( break; } case "string": { - const regexValidation = parameter.validations.find( - (v) => v.validation_regex !== null, - ); - if (!regexValidation?.validation_regex) { + const regex = parameter.validations + .filter((v): v is NonNullable => v !== null) + .find( + (v) => + v.validation_regex !== null && + v.validation_regex !== "", + ); + if (!regex || !regex.validation_regex) { return true; } - if ( - val && - !new RegExp(regexValidation.validation_regex).test(val) - ) { + if (val && !new RegExp(regex.validation_regex).test(val)) { return ctx.createError({ path: ctx.path, message: parameterError(parameter, val), @@ -496,18 +570,18 @@ export const useValidationSchemaForDynamicParameters = ( }; const parameterError = ( - parameter: Parameter, + parameter: PreviewParameter, value?: string, ): string | undefined => { - const validation_error = parameter.validations.find( - (v) => v.validation_error !== null, - ); - const minValidation = parameter.validations.find( - (v) => v.validation_min !== null, - ); - const maxValidation = parameter.validations.find( - (v) => v.validation_max !== null, - ); + const validation_error = parameter.validations + .filter((v): v is NonNullable => v !== null) + .find((v) => v.validation_error !== null); + const minValidation = parameter.validations + .filter((v): v is NonNullable => v !== null) + .find((v) => v.validation_min !== null); + const maxValidation = parameter.validations + .filter((v): v is NonNullable => v !== null) + .find((v) => v.validation_max !== null); if (!validation_error || !value) { return; diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx index 2c548796a4e0b..721c9f672cf61 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx @@ -1,5 +1,9 @@ import type * as TypesGen from "api/typesGenerated"; -import type { Diagnostics, Parameter, Request } from "api/typesParameter"; +import type { + DynamicParametersRequest, + PreviewDiagnostics, + PreviewParameter, +} from "api/typesGenerated"; import { Alert } from "components/Alert/Alert"; import { ErrorAlert } from "components/Alert/ErrorAlert"; import { Avatar } from "components/Avatar/Avatar"; @@ -17,6 +21,7 @@ import { type FormikContextType, useFormik } from "formik"; import { ArrowLeft } from "lucide-react"; import { DynamicParameter, + getInitialParameterValues, useValidationSchemaForDynamicParameters, } from "modules/workspaces/DynamicParameter/DynamicParameter"; import { generateWorkspaceName } from "modules/workspaces/generateWorkspaceName"; @@ -38,24 +43,19 @@ import type { import { ExternalAuthButton } from "./ExternalAuthButton"; import type { CreateWorkspacePermissions } from "./permissions"; -export const Language = { - duplicationWarning: - "Duplicating a workspace only copies its parameters. No state from the old workspace is copied over.", -} as const; - export interface CreateWorkspacePageViewExperimentalProps { autofillParameters: AutofillBuildParameter[]; creatingWorkspace: boolean; defaultName?: string | null; defaultOwner: TypesGen.User; - diagnostics: Diagnostics; + diagnostics: PreviewDiagnostics; disabledParams?: string[]; error: unknown; externalAuth: TypesGen.TemplateVersionExternalAuth[]; externalAuthPollingState: ExternalAuthPollingState; hasAllRequiredExternalAuth: boolean; mode: CreateWorkspaceMode; - parameters: Parameter[]; + parameters: PreviewParameter[]; permissions: CreateWorkspacePermissions; presets: TypesGen.Preset[]; template: TypesGen.Template; @@ -66,49 +66,11 @@ export interface CreateWorkspacePageViewExperimentalProps { owner: TypesGen.User, ) => void; resetMutation: () => void; - sendMessage: (message: Request) => void; + sendMessage: (message: DynamicParametersRequest) => void; setWSResponseId: (value: React.SetStateAction) => void; startPollingExternalAuth: () => void; } -// const getInitialParameterValues1 = ( -// params: Parameter[], -// autofillParams?: AutofillBuildParameter[], -// ): WorkspaceBuildParameter[] => { -// return params.map((parameter) => { -// // Short-circuit for ephemeral parameters, which are always reset to -// // the template-defined default. -// if (parameter.ephemeral) { -// return { -// name: parameter.name, -// value: parameter.default_value, -// }; -// } - -// const autofillParam = autofillParams?.find( -// ({ name }) => name === parameter.name, -// ); - -// return { -// name: parameter.name, -// value: -// autofillParam && -// isValidValue(parameter, autofillParam) && -// autofillParam.source !== "user_history" -// ? autofillParam.value -// : parameter.default_value, -// }; -// }); -// }; - -const getInitialParameterValues = (parameters: Parameter[]) => { - return parameters.map((parameter) => { - return { - name: parameter.name, - value: parameter.default_value.valid ? parameter.default_value.value : "", - }; - }); -}; export const CreateWorkspacePageViewExperimental: FC< CreateWorkspacePageViewExperimentalProps > = ({ @@ -150,7 +112,10 @@ export const CreateWorkspacePageViewExperimental: FC< initialValues: { name: defaultName ?? "", template_id: template.id, - rich_parameter_values: getInitialParameterValues(parameters), + rich_parameter_values: getInitialParameterValues( + parameters, + autofillParameters, + ), }, validationSchema: Yup.object({ name: nameValidator("Workspace Name"), @@ -251,7 +216,7 @@ export const CreateWorkspacePageViewExperimental: FC< const handleChange = async ( value: string, parameterField: string, - parameter: Parameter, + parameter: PreviewParameter, ) => { // Update form value immediately for all types await form.setFieldValue(parameterField, { @@ -275,7 +240,7 @@ export const CreateWorkspacePageViewExperimental: FC< setWSResponseId((prevId) => { const newId = prevId + 1; - const request: Request = { + const request: DynamicParametersRequest = { id: newId, inputs: newInputs, }; @@ -309,6 +274,7 @@ export const CreateWorkspacePageViewExperimental: FC< }; }, [debouncedTimer]); + // TODO: display top level diagnostics return ( <>
@@ -354,7 +320,7 @@ export const CreateWorkspacePageViewExperimental: FC< dismissible data-testid="duplication-warning" > - {Language.duplicationWarning} + Duplicating a workspace only copies its parameters. No state from the old workspace is copied over. )} @@ -541,7 +507,7 @@ export const CreateWorkspacePageViewExperimental: FC< } disabled={isDisabled} isPreset={isPresetParameter} - // parameterAutofill={autofillByName[parameter.name]} + // parameterAutofill={autofillByName[parameter.name]} TODO: handle autofill /> ); })} From dd5147d1478fc8d7654a7afda5bd65c27ce94500 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Fri, 11 Apr 2025 20:53:44 +0000 Subject: [PATCH 05/12] fix: remove filters --- .../DynamicParameter/DynamicParameter.tsx | 26 +------------------ 1 file changed, 1 insertion(+), 25 deletions(-) diff --git a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx index 11d2396fa8a96..cd3107db03b81 100644 --- a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx +++ b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx @@ -174,10 +174,6 @@ const ParameterField: FC = ({ {parameter.options - .filter( - (option): option is NonNullable => - option !== null, - ) .map((option) => ( @@ -190,7 +186,6 @@ const ParameterField: FC = ({ case "multi-select": { // Map parameter options to MultiSelectCombobox options format const comboboxOptions: Option[] = parameter.options - .filter((opt): opt is NonNullable => opt !== null) .map((opt) => ({ value: opt.value.value, label: opt.name, @@ -200,7 +195,6 @@ const ParameterField: FC = ({ const defaultOptions: Option[] = JSON.parse(defaultValue).map( (val: string) => { const option = parameter.options - .filter((o): o is NonNullable => o !== null) .find((o) => o.value.value === val); return { value: val, @@ -252,9 +246,6 @@ const ParameterField: FC = ({ defaultValue={defaultValue} > {parameter.options - .filter( - (option): option is NonNullable => option !== null, - ) .map((option) => (
= ({ const inputProps: Record = {}; if (parameter.type === "number") { - const validations = - parameter.validations.filter( - (v): v is NonNullable => v !== null, - )[0] || {}; + const validations = parameter.validations[0] || {}; const { validation_min, validation_max } = validations; if (validation_min !== null) { @@ -366,10 +354,6 @@ const ParameterDiagnostics: FC = ({ return (
{diagnostics - .filter( - (diagnostic): diagnostic is NonNullable => - diagnostic !== null, - ) .map((diagnostic, index) => (
{ if (previewParam.options.length > 0) { const validValues = previewParam.options - .filter((option): option is NonNullable => option !== null) .map((option) => option.value.value); return validValues.includes(buildParam.value); } @@ -455,10 +438,8 @@ export const useValidationSchemaForDynamicParameters = ( switch (parameter.type) { case "number": { const minValidation = parameter.validations - .filter((v): v is NonNullable => v !== null) .find((v) => v.validation_min !== null); const maxValidation = parameter.validations - .filter((v): v is NonNullable => v !== null) .find((v) => v.validation_max !== null); if ( @@ -506,7 +487,6 @@ export const useValidationSchemaForDynamicParameters = ( } const monotonic = parameter.validations - .filter((v): v is NonNullable => v !== null) .find( (v) => v.validation_monotonic !== null && @@ -542,7 +522,6 @@ export const useValidationSchemaForDynamicParameters = ( } case "string": { const regex = parameter.validations - .filter((v): v is NonNullable => v !== null) .find( (v) => v.validation_regex !== null && @@ -574,13 +553,10 @@ const parameterError = ( value?: string, ): string | undefined => { const validation_error = parameter.validations - .filter((v): v is NonNullable => v !== null) .find((v) => v.validation_error !== null); const minValidation = parameter.validations - .filter((v): v is NonNullable => v !== null) .find((v) => v.validation_min !== null); const maxValidation = parameter.validations - .filter((v): v is NonNullable => v !== null) .find((v) => v.validation_max !== null); if (!validation_error || !value) { From 3b8d5a50d0448da7ed84985867b80b3e46b2d20c Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Fri, 11 Apr 2025 20:59:28 +0000 Subject: [PATCH 06/12] chore: remove unused typesParameter.ts --- site/src/api/typesParameter.ts | 124 ---------------- .../DynamicParameter/DynamicParameter.tsx | 132 +++++++++--------- .../CreateWorkspacePageExperimental.tsx | 18 +-- .../CreateWorkspacePageViewExperimental.tsx | 3 +- 4 files changed, 76 insertions(+), 201 deletions(-) delete mode 100644 site/src/api/typesParameter.ts diff --git a/site/src/api/typesParameter.ts b/site/src/api/typesParameter.ts deleted file mode 100644 index c2397611d37ea..0000000000000 --- a/site/src/api/typesParameter.ts +++ /dev/null @@ -1,124 +0,0 @@ -// Code generated by 'guts'. DO NOT EDIT. - -// From types/diagnostics.go -export type DiagnosticSeverityString = "error" | "warning"; - -export const DiagnosticSeverityStrings: DiagnosticSeverityString[] = [ - "error", - "warning", -]; - -// From types/diagnostics.go -export type Diagnostics = readonly FriendlyDiagnostic[]; - -// From types/diagnostics.go -export interface FriendlyDiagnostic { - readonly severity: DiagnosticSeverityString; - readonly summary: string; - readonly detail: string; -} - -// From types/value.go -export interface NullHCLString { - readonly value: string; - readonly valid: boolean; -} - -// From types/parameter.go -export interface Parameter extends ParameterData { - readonly value: NullHCLString; - readonly diagnostics: Diagnostics; -} - -// From types/parameter.go -export interface ParameterData { - readonly name: string; - readonly display_name: string; - readonly description: string; - readonly type: ParameterType; - // this is likely an enum in an external package "github.com/coder/terraform-provider-coder/v2/provider.ParameterFormType" - readonly form_type: string; - // empty interface{} type, falling back to unknown - readonly styling: unknown; - readonly mutable: boolean; - readonly default_value: NullHCLString; - readonly icon: string; - readonly options: readonly ParameterOption[]; - readonly validations: readonly ParameterValidation[]; - readonly required: boolean; - readonly order: number; - readonly ephemeral: boolean; -} - -// From types/parameter.go -export interface ParameterOption { - readonly name: string; - readonly description: string; - readonly value: NullHCLString; - readonly icon: string; -} - -// From types/enum.go -export type ParameterType = "bool" | "list(string)" | "number" | "string"; - -export const ParameterTypes: ParameterType[] = [ - "bool", - "list(string)", - "number", - "string", -]; - -// From types/parameter.go -export interface ParameterValidation { - readonly validation_error: string; - readonly validation_regex: string | null; - readonly validation_min: number | null; - readonly validation_max: number | null; - readonly validation_monotonic: string | null; - readonly validation_invalid: boolean | null; -} - -// From web/session.go -export interface Request { - readonly id: number; - readonly inputs: Record; -} - -// From web/session.go -export interface Response { - readonly id: number; - readonly diagnostics: Diagnostics; - readonly parameters: readonly Parameter[]; -} - -// From web/session.go -export interface SessionInputs { - readonly PlanPath: string; - readonly User: WorkspaceOwner; -} - -// From types/parameter.go -export const ValidationMonotonicDecreasing = "decreasing"; - -// From types/parameter.go -export const ValidationMonotonicIncreasing = "increasing"; - -// From types/owner.go -export interface WorkspaceOwner { - readonly id: string; - readonly name: string; - readonly full_name: string; - readonly email: string; - readonly ssh_public_key: string; - readonly groups: readonly string[]; - readonly session_token: string; - readonly oidc_access_token: string; - readonly login_type: string; - readonly rbac_roles: readonly WorkspaceOwnerRBACRole[]; -} - -// From types/owner.go -export interface WorkspaceOwnerRBACRole { - readonly name: string; - readonly org_id: string; -} diff --git a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx index cd3107db03b81..d3f2cbbd69fa6 100644 --- a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx +++ b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx @@ -173,29 +173,26 @@ const ParameterField: FC = ({ - {parameter.options - .map((option) => ( - - - - ))} + {parameter.options.map((option) => ( + + + + ))} ); case "multi-select": { // Map parameter options to MultiSelectCombobox options format - const comboboxOptions: Option[] = parameter.options - .map((opt) => ({ - value: opt.value.value, - label: opt.name, - disable: false, - })); + const comboboxOptions: Option[] = parameter.options.map((opt) => ({ + value: opt.value.value, + label: opt.name, + disable: false, + })); const defaultOptions: Option[] = JSON.parse(defaultValue).map( (val: string) => { - const option = parameter.options - .find((o) => o.value.value === val); + const option = parameter.options.find((o) => o.value.value === val); return { value: val, label: option?.name || val, @@ -245,21 +242,20 @@ const ParameterField: FC = ({ disabled={disabled} defaultValue={defaultValue} > - {parameter.options - .map((option) => ( -
- - -
- ))} + {parameter.options.map((option) => ( +
+ + +
+ ))} ); @@ -353,20 +349,19 @@ const ParameterDiagnostics: FC = ({ }) => { return (
- {diagnostics - .map((diagnostic, index) => ( -
-
{diagnostic.summary}
- {diagnostic.detail &&
{diagnostic.detail}
} -
- ))} + {diagnostics.map((diagnostic, index) => ( +
+
{diagnostic.summary}
+ {diagnostic.detail &&
{diagnostic.detail}
} +
+ ))}
); }; @@ -408,8 +403,9 @@ const isValidValue = ( buildParam: WorkspaceBuildParameter, ) => { if (previewParam.options.length > 0) { - const validValues = previewParam.options - .map((option) => option.value.value); + const validValues = previewParam.options.map( + (option) => option.value.value, + ); return validValues.includes(buildParam.value); } @@ -437,10 +433,12 @@ export const useValidationSchemaForDynamicParameters = ( if (parameter) { switch (parameter.type) { case "number": { - const minValidation = parameter.validations - .find((v) => v.validation_min !== null); - const maxValidation = parameter.validations - .find((v) => v.validation_max !== null); + const minValidation = parameter.validations.find( + (v) => v.validation_min !== null, + ); + const maxValidation = parameter.validations.find( + (v) => v.validation_max !== null, + ); if ( minValidation && @@ -486,12 +484,11 @@ export const useValidationSchemaForDynamicParameters = ( }); } - const monotonic = parameter.validations - .find( - (v) => - v.validation_monotonic !== null && - v.validation_monotonic !== "", - ); + const monotonic = parameter.validations.find( + (v) => + v.validation_monotonic !== null && + v.validation_monotonic !== "", + ); if (monotonic && lastBuildParameters) { const lastBuildParameter = lastBuildParameters.find( @@ -521,12 +518,10 @@ export const useValidationSchemaForDynamicParameters = ( break; } case "string": { - const regex = parameter.validations - .find( - (v) => - v.validation_regex !== null && - v.validation_regex !== "", - ); + const regex = parameter.validations.find( + (v) => + v.validation_regex !== null && v.validation_regex !== "", + ); if (!regex || !regex.validation_regex) { return true; } @@ -552,12 +547,15 @@ const parameterError = ( parameter: PreviewParameter, value?: string, ): string | undefined => { - const validation_error = parameter.validations - .find((v) => v.validation_error !== null); - const minValidation = parameter.validations - .find((v) => v.validation_min !== null); - const maxValidation = parameter.validations - .find((v) => v.validation_max !== null); + const validation_error = parameter.validations.find( + (v) => v.validation_error !== null, + ); + const minValidation = parameter.validations.find( + (v) => v.validation_min !== null, + ); + const maxValidation = parameter.validations.find( + (v) => v.validation_max !== null, + ); if (!validation_error || !value) { return; diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx index 08545391b9033..b82ac340b784c 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx @@ -6,7 +6,11 @@ import { templateVersionPresets, } from "api/queries/templates"; import { autoCreateWorkspace, createWorkspace } from "api/queries/workspaces"; -import type { Template, Workspace } from "api/typesGenerated"; +import type { + DynamicParametersResponse, + Template, + Workspace, +} from "api/typesGenerated"; import { Loader } from "components/Loader/Loader"; import { useAuthenticated } from "contexts/auth/RequireAuth"; import { useEffectEvent } from "hooks/hookPolyfills"; @@ -27,7 +31,6 @@ import type { AutofillBuildParameter } from "utils/richParameters"; import { CreateWorkspacePageViewExperimental } from "./CreateWorkspacePageViewExperimental"; export const createWorkspaceModes = ["form", "auto", "duplicate"] as const; export type CreateWorkspaceMode = (typeof createWorkspaceModes)[number]; -import type { Response } from "api/typesParameter"; import { useWebSocket } from "hooks/useWebsocket"; import { type CreateWorkspacePermissions, @@ -46,14 +49,11 @@ const CreateWorkspacePageExperimental: FC = () => { const navigate = useNavigate(); const [searchParams] = useSearchParams(); - const [currentResponse, setCurrentResponse] = useState(null); + const [currentResponse, setCurrentResponse] = + useState(null); const [wsResponseId, setWSResponseId] = useState(0); - const { message: webSocketResponse, sendMessage } = useWebSocket( - wsUrl, - urlTestdata, - "", - "", - ); + const { message: webSocketResponse, sendMessage } = + useWebSocket(wsUrl, urlTestdata, "", ""); useEffect(() => { if (webSocketResponse && webSocketResponse.id >= wsResponseId) { diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx index 721c9f672cf61..c65cbbc171294 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx @@ -320,7 +320,8 @@ export const CreateWorkspacePageViewExperimental: FC< dismissible data-testid="duplication-warning" > - Duplicating a workspace only copies its parameters. No state from the old workspace is copied over. + Duplicating a workspace only copies its parameters. No state from + the old workspace is copied over. )} From 25c7d3cb64be6f1b1a3110b95afaa07e585312c0 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Sat, 12 Apr 2025 08:57:14 +0000 Subject: [PATCH 07/12] fix: use options instead of defaultOptions to set option values --- .../MultiSelectCombobox/MultiSelectCombobox.stories.tsx | 2 +- .../IdpOrgSyncPage/IdpOrgSyncPageView.tsx | 2 +- .../OrganizationSettingsPage/IdpSyncPage/IdpGroupSyncForm.tsx | 2 +- .../OrganizationSettingsPage/IdpSyncPage/IdpRoleSyncForm.tsx | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/site/src/components/MultiSelectCombobox/MultiSelectCombobox.stories.tsx b/site/src/components/MultiSelectCombobox/MultiSelectCombobox.stories.tsx index fd35842e0fddc..109a60e60448d 100644 --- a/site/src/components/MultiSelectCombobox/MultiSelectCombobox.stories.tsx +++ b/site/src/components/MultiSelectCombobox/MultiSelectCombobox.stories.tsx @@ -16,7 +16,7 @@ const meta: Meta = { All organizations selected

), - defaultOptions: organizations.map((org) => ({ + options: organizations.map((org) => ({ label: org.display_name, value: org.id, })), diff --git a/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPageView.tsx b/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPageView.tsx index aa39906f09370..f99c1d04fee14 100644 --- a/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPageView.tsx +++ b/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPageView.tsx @@ -257,7 +257,7 @@ export const IdpOrgSyncPageView: FC = ({ className="min-w-60 max-w-3xl" value={coderOrgs} onChange={setCoderOrgs} - defaultOptions={organizations.map((org) => ({ + options={organizations.map((org) => ({ label: org.display_name, value: org.id, }))} diff --git a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpGroupSyncForm.tsx b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpGroupSyncForm.tsx index 5340ec99dda79..284267f4487e1 100644 --- a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpGroupSyncForm.tsx +++ b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpGroupSyncForm.tsx @@ -259,7 +259,7 @@ export const IdpGroupSyncForm: FC = ({ className="min-w-60 max-w-3xl" value={coderGroups} onChange={setCoderGroups} - defaultOptions={groups.map((group) => ({ + options={groups.map((group) => ({ label: group.display_name || group.name, value: group.id, }))} diff --git a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpRoleSyncForm.tsx b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpRoleSyncForm.tsx index faeaf0773dffd..0825ab4217395 100644 --- a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpRoleSyncForm.tsx +++ b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpRoleSyncForm.tsx @@ -200,7 +200,7 @@ export const IdpRoleSyncForm: FC = ({ className="min-w-60 max-w-3xl" value={coderRoles} onChange={setCoderRoles} - defaultOptions={roles.map((role) => ({ + options={roles.map((role) => ({ label: role.display_name || role.name, value: role.name, }))} From 19767201dc943f49e684f928c9d7498535b2c3ec Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Tue, 15 Apr 2025 15:26:37 +0000 Subject: [PATCH 08/12] fix: updates for PR review --- .../MultiSelectCombobox.tsx | 14 ++++----- site/src/hooks/useWebsocket.ts | 5 ---- .../CreateWorkspacePageViewExperimental.tsx | 30 +++++-------------- 3 files changed, 14 insertions(+), 35 deletions(-) diff --git a/site/src/components/MultiSelectCombobox/MultiSelectCombobox.tsx b/site/src/components/MultiSelectCombobox/MultiSelectCombobox.tsx index 7d21ea453b211..f9cc1f9f804f6 100644 --- a/site/src/components/MultiSelectCombobox/MultiSelectCombobox.tsx +++ b/site/src/components/MultiSelectCombobox/MultiSelectCombobox.tsx @@ -205,15 +205,13 @@ export const MultiSelectCombobox = forwardRef< const [isLoading, setIsLoading] = useState(false); const dropdownRef = useRef(null); - const getInitialSelectedOptions = () => { - if (arrayDefaultOptions && arrayDefaultOptions.length > 0) { - return arrayDefaultOptions; - } - return []; - }; - const [selected, setSelected] = useState( - getInitialSelectedOptions, + () => { + if (arrayDefaultOptions && arrayDefaultOptions.length > 0) { + return arrayDefaultOptions; + } + return []; + } ); const [options, setOptions] = useState( transitionToGroupOption(arrayDefaultOptions, groupBy), diff --git a/site/src/hooks/useWebsocket.ts b/site/src/hooks/useWebsocket.ts index d9aa3ba8f4fa1..1031aa05b5ddc 100644 --- a/site/src/hooks/useWebsocket.ts +++ b/site/src/hooks/useWebsocket.ts @@ -21,7 +21,6 @@ export function useWebSocket( setConnectionStatus("connecting"); ws.onopen = () => { - // console.log("Connected to WebSocket"); setConnectionStatus("connected"); ws.send(JSON.stringify({})); }; @@ -29,7 +28,6 @@ export function useWebSocket( ws.onmessage = (event) => { try { const data: T = JSON.parse(event.data); - // console.log("Received message:", data); setMessage(data); } catch (err) { console.error("Invalid JSON from server: ", event.data); @@ -42,9 +40,6 @@ export function useWebSocket( }; ws.onclose = (event) => { - // console.log( - // `WebSocket closed with code ${event.code}. Reason: ${event.reason}`, - // ); setConnectionStatus("disconnected"); }; } catch (error) { diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx index c65cbbc171294..c6a5eac7531d0 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx @@ -209,7 +209,7 @@ export const CreateWorkspacePageViewExperimental: FC< parameters, ]); - const [debouncedTimer, setDebouncedTimer] = useState( + const [debouncedTimer, setDebouncedTimer] = useState | null>( null, ); @@ -226,15 +226,9 @@ export const CreateWorkspacePageViewExperimental: FC< // Create the request object const createRequest = () => { - // Convert the rich_parameter_values array to a key-value object - const newInputs = (form.values.rich_parameter_values ?? []).reduce( - (acc, param) => { - acc[param.name] = param.value; - return acc; - }, - {} as Record, - ); - + const newInputs = Object.fromEntries(form.values.rich_parameter_values?.map(value => { + return [value.name, value.value] + }) ?? []); // Update the input for the changed parameter newInputs[parameter.name] = value; @@ -274,7 +268,6 @@ export const CreateWorkspacePageViewExperimental: FC< }; }, [debouncedTimer]); - // TODO: display top level diagnostics return ( <>
@@ -458,22 +451,16 @@ export const CreateWorkspacePageViewExperimental: FC< selectedOption={presetOptions[selectedPresetIndex]} />
-
+ -
+ +
)} @@ -508,7 +495,6 @@ export const CreateWorkspacePageViewExperimental: FC< } disabled={isDisabled} isPreset={isPresetParameter} - // parameterAutofill={autofillByName[parameter.name]} TODO: handle autofill /> ); })} From 2931256bc331c13fd66225af408f3eac48e1d318 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Tue, 15 Apr 2025 16:18:03 +0000 Subject: [PATCH 09/12] fix: format --- .../MultiSelectCombobox/MultiSelectCombobox.tsx | 12 +++++------- .../CreateWorkspacePageViewExperimental.tsx | 14 ++++++++------ 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/site/src/components/MultiSelectCombobox/MultiSelectCombobox.tsx b/site/src/components/MultiSelectCombobox/MultiSelectCombobox.tsx index f9cc1f9f804f6..548440022de0a 100644 --- a/site/src/components/MultiSelectCombobox/MultiSelectCombobox.tsx +++ b/site/src/components/MultiSelectCombobox/MultiSelectCombobox.tsx @@ -205,14 +205,12 @@ export const MultiSelectCombobox = forwardRef< const [isLoading, setIsLoading] = useState(false); const dropdownRef = useRef(null); - const [selected, setSelected] = useState( - () => { - if (arrayDefaultOptions && arrayDefaultOptions.length > 0) { - return arrayDefaultOptions; - } - return []; + const [selected, setSelected] = useState(() => { + if (arrayDefaultOptions && arrayDefaultOptions.length > 0) { + return arrayDefaultOptions; } - ); + return []; + }); const [options, setOptions] = useState( transitionToGroupOption(arrayDefaultOptions, groupBy), ); diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx index c6a5eac7531d0..cf419fe1a9c6f 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx @@ -209,9 +209,9 @@ export const CreateWorkspacePageViewExperimental: FC< parameters, ]); - const [debouncedTimer, setDebouncedTimer] = useState | null>( - null, - ); + const [debouncedTimer, setDebouncedTimer] = useState | null>(null); const handleChange = async ( value: string, @@ -226,9 +226,11 @@ export const CreateWorkspacePageViewExperimental: FC< // Create the request object const createRequest = () => { - const newInputs = Object.fromEntries(form.values.rich_parameter_values?.map(value => { - return [value.name, value.value] - }) ?? []); + const newInputs = Object.fromEntries( + form.values.rich_parameter_values?.map((value) => { + return [value.name, value.value]; + }) ?? [], + ); // Update the input for the changed parameter newInputs[parameter.name] = value; From 07632b87c21e48773c2cbd5fda68e9047408e0ed Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Tue, 15 Apr 2025 17:00:47 +0000 Subject: [PATCH 10/12] fix: update to use useDebouncedFunction --- .../CreateWorkspacePageViewExperimental.tsx | 98 +++++++++---------- 1 file changed, 46 insertions(+), 52 deletions(-) diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx index cf419fe1a9c6f..49fd6e9188960 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx @@ -18,6 +18,7 @@ import { Stack } from "components/Stack/Stack"; import { Switch } from "components/Switch/Switch"; import { UserAutocomplete } from "components/UserAutocomplete/UserAutocomplete"; import { type FormikContextType, useFormik } from "formik"; +import { useDebouncedFunction } from "hooks/debounce"; import { ArrowLeft } from "lucide-react"; import { DynamicParameter, @@ -209,67 +210,60 @@ export const CreateWorkspacePageViewExperimental: FC< parameters, ]); - const [debouncedTimer, setDebouncedTimer] = useState | null>(null); - - const handleChange = async ( - value: string, - parameterField: string, + const sendDynamicParamsRequest = ( parameter: PreviewParameter, + value: string, ) => { - // Update form value immediately for all types - await form.setFieldValue(parameterField, { - name: parameter.form_type, - value, + const formInputs = Object.fromEntries( + form.values.rich_parameter_values?.map((value) => { + return [value.name, value.value]; + }) ?? [], + ); + // Update the input for the changed parameter + formInputs[parameter.name] = value; + + setWSResponseId((prevId) => { + const newId = prevId + 1; + const request: DynamicParametersRequest = { + id: newId, + inputs: formInputs, + }; + sendMessage(request); + return newId; }); + }; - // Create the request object - const createRequest = () => { - const newInputs = Object.fromEntries( - form.values.rich_parameter_values?.map((value) => { - return [value.name, value.value]; - }) ?? [], - ); - // Update the input for the changed parameter - newInputs[parameter.name] = value; - - setWSResponseId((prevId) => { - const newId = prevId + 1; - const request: DynamicParametersRequest = { - id: newId, - inputs: newInputs, - }; - sendMessage(request); - return newId; + const { debounced: handleChangeDebounced } = useDebouncedFunction( + async ( + parameter: PreviewParameter, + parameterField: string, + value: string, + ) => { + await form.setFieldValue(parameterField, { + name: parameter.form_type, + value, }); - }; - - // Clear any existing timer - if (debouncedTimer) { - clearTimeout(debouncedTimer); - } + sendDynamicParamsRequest(parameter, value); + }, + 500, + ); - // For input type, debounce the sendMessage - if (parameter.form_type === "input") { - const timer = setTimeout(() => { - createRequest(); - }, 1050); - setDebouncedTimer(timer); + const handleChange = async ( + parameter: PreviewParameter, + parameterField: string, + value: string, + ) => { + if (parameter.form_type === "input" || parameter.form_type === "textarea") { + handleChangeDebounced(parameter, parameterField, value); } else { - // For all other form control types (checkbox, select, etc.), send immediately - createRequest(); + await form.setFieldValue(parameterField, { + name: parameter.form_type, + value, + }); + sendDynamicParamsRequest(parameter, value); } }; - useEffect(() => { - return () => { - if (debouncedTimer) { - clearTimeout(debouncedTimer); - } - }; - }, [debouncedTimer]); - return ( <>
@@ -493,7 +487,7 @@ export const CreateWorkspacePageViewExperimental: FC< key={parameter.name} parameter={parameter} onChange={(value) => - handleChange(value, parameterField, parameter) + handleChange(parameter, parameterField, value) } disabled={isDisabled} isPreset={isPresetParameter} From b7d0d3271b23a9395d0085d944c8da69c63782c1 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Tue, 15 Apr 2025 17:37:15 +0000 Subject: [PATCH 11/12] fix: remove websocket code --- site/src/hooks/useWebsocket.ts | 89 ------------------- .../CreateWorkspacePageExperimental.tsx | 20 +---- 2 files changed, 2 insertions(+), 107 deletions(-) delete mode 100644 site/src/hooks/useWebsocket.ts diff --git a/site/src/hooks/useWebsocket.ts b/site/src/hooks/useWebsocket.ts deleted file mode 100644 index 1031aa05b5ddc..0000000000000 --- a/site/src/hooks/useWebsocket.ts +++ /dev/null @@ -1,89 +0,0 @@ -// This file is temporary until we have a proper websocket implementation for dynamic parameters -import { useCallback, useEffect, useRef, useState } from "react"; - -export function useWebSocket( - url: string, - testdata: string, - user: string, - plan: string, -) { - const [message, setMessage] = useState(null); - const [connectionStatus, setConnectionStatus] = useState< - "connecting" | "connected" | "disconnected" - >("connecting"); - const wsRef = useRef(null); - const urlRef = useRef(url); - - const connectWebSocket = useCallback(() => { - try { - const ws = new WebSocket(urlRef.current); - wsRef.current = ws; - setConnectionStatus("connecting"); - - ws.onopen = () => { - setConnectionStatus("connected"); - ws.send(JSON.stringify({})); - }; - - ws.onmessage = (event) => { - try { - const data: T = JSON.parse(event.data); - setMessage(data); - } catch (err) { - console.error("Invalid JSON from server: ", event.data); - console.error("Error: ", err); - } - }; - - ws.onerror = (event) => { - console.error("WebSocket error:", event); - }; - - ws.onclose = (event) => { - setConnectionStatus("disconnected"); - }; - } catch (error) { - console.error("Failed to create WebSocket connection:", error); - setConnectionStatus("disconnected"); - } - }, []); - - useEffect(() => { - if (!testdata) { - return; - } - - setMessage(null); - setConnectionStatus("connecting"); - - const createConnection = () => { - urlRef.current = url; - connectWebSocket(); - }; - - if (wsRef.current) { - wsRef.current.close(); - wsRef.current = null; - } - - const timeoutId = setTimeout(createConnection, 100); - - return () => { - clearTimeout(timeoutId); - if (wsRef.current) { - wsRef.current.close(); - wsRef.current = null; - } - }; - }, [testdata, connectWebSocket, url]); - - const sendMessage = (data: unknown) => { - if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { - wsRef.current.send(JSON.stringify(data)); - } else { - console.warn("Cannot send message: WebSocket is not connected"); - } - }; - - return { message, sendMessage, connectionStatus }; -} diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx index b82ac340b784c..640522d9881a8 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx @@ -7,6 +7,7 @@ import { } from "api/queries/templates"; import { autoCreateWorkspace, createWorkspace } from "api/queries/workspaces"; import type { + DynamicParametersRequest, DynamicParametersResponse, Template, Workspace, @@ -31,17 +32,12 @@ import type { AutofillBuildParameter } from "utils/richParameters"; import { CreateWorkspacePageViewExperimental } from "./CreateWorkspacePageViewExperimental"; export const createWorkspaceModes = ["form", "auto", "duplicate"] as const; export type CreateWorkspaceMode = (typeof createWorkspaceModes)[number]; -import { useWebSocket } from "hooks/useWebsocket"; import { type CreateWorkspacePermissions, createWorkspaceChecks, } from "./permissions"; export type ExternalAuthPollingState = "idle" | "polling" | "abandoned"; -const serverAddress = "localhost:8100"; -const urlTestdata = "demo"; -const wsUrl = `ws://${serverAddress}/ws/${encodeURIComponent(urlTestdata)}`; - const CreateWorkspacePageExperimental: FC = () => { const { organization: organizationName = "default", template: templateName } = useParams() as { organization?: string; template: string }; @@ -52,19 +48,7 @@ const CreateWorkspacePageExperimental: FC = () => { const [currentResponse, setCurrentResponse] = useState(null); const [wsResponseId, setWSResponseId] = useState(0); - const { message: webSocketResponse, sendMessage } = - useWebSocket(wsUrl, urlTestdata, "", ""); - - useEffect(() => { - if (webSocketResponse && webSocketResponse.id >= wsResponseId) { - setCurrentResponse((prev) => { - if (prev?.id === webSocketResponse.id) { - return prev; - } - return webSocketResponse; - }); - } - }, [webSocketResponse, wsResponseId]); + const sendMessage = (message: DynamicParametersRequest) => {}; const customVersionId = searchParams.get("version") ?? undefined; const defaultName = searchParams.get("name"); From 2a35fe284baa8f1fc0cbfabdd10a5a3176017cd8 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Tue, 15 Apr 2025 19:14:44 +0000 Subject: [PATCH 12/12] chore: updates for PR review --- .../MultiSelectCombobox/MultiSelectCombobox.tsx | 9 +++------ .../CreateWorkspacePageExperimental.tsx | 3 ++- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/site/src/components/MultiSelectCombobox/MultiSelectCombobox.tsx b/site/src/components/MultiSelectCombobox/MultiSelectCombobox.tsx index 548440022de0a..249af7918df28 100644 --- a/site/src/components/MultiSelectCombobox/MultiSelectCombobox.tsx +++ b/site/src/components/MultiSelectCombobox/MultiSelectCombobox.tsx @@ -205,12 +205,9 @@ export const MultiSelectCombobox = forwardRef< const [isLoading, setIsLoading] = useState(false); const dropdownRef = useRef(null); - const [selected, setSelected] = useState(() => { - if (arrayDefaultOptions && arrayDefaultOptions.length > 0) { - return arrayDefaultOptions; - } - return []; - }); + const [selected, setSelected] = useState( + arrayDefaultOptions ?? [], + ); const [options, setOptions] = useState( transitionToGroupOption(arrayDefaultOptions, groupBy), ); diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx index 640522d9881a8..14f34a2e29f0b 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx @@ -180,6 +180,7 @@ const CreateWorkspacePageExperimental: FC = () => { {pageTitle(title)} {!currentResponse || + !templateQuery.data || isLoadingFormData || isLoadingExternalAuth || autoCreateReady ? ( @@ -199,7 +200,7 @@ const CreateWorkspacePageExperimental: FC = () => { autoCreateWorkspaceMutation.error } resetMutation={createWorkspaceMutation.reset} - template={templateQuery.data ?? ({} as Template)} + template={templateQuery.data} versionId={realizedVersionId} externalAuth={externalAuth ?? []} externalAuthPollingState={externalAuthPollingState}