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} pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy