From 7b6148570c6c6f9d2410e80a3e9f275731111b00 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 10 Apr 2025 22:19:03 +0000 Subject: [PATCH 01/48] feat: create dynamic parameter component --- site/src/api/typesParameter.ts | 124 ++++++++++++++++++ site/src/hooks/useWebsocket.ts | 94 +++++++++++++ .../CreateWorkspacePageExperimental.tsx | 8 ++ .../CreateWorkspacePageViewExperimental.tsx | 38 ++++++ 4 files changed, 264 insertions(+) create mode 100644 site/src/api/typesParameter.ts create mode 100644 site/src/hooks/useWebsocket.ts 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/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/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx index 14f34a2e29f0b..bd1edcb539c5d 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx @@ -32,12 +32,20 @@ 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, 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 }; diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx index 49fd6e9188960..151ce83f11672 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx @@ -72,6 +72,44 @@ export interface CreateWorkspacePageViewExperimentalProps { 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 > = ({ From f5d4a1c03d48b9c81b91abd64620c544755cb31c Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Fri, 11 Apr 2025 14:53:05 +0000 Subject: [PATCH 02/48] fix: format --- .../CreateWorkspacePage/CreateWorkspacePageExperimental.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx index bd1edcb539c5d..8ca6bb81c96d3 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx @@ -32,9 +32,7 @@ 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 type { Response } from "api/typesParameter"; import { useWebSocket } from "hooks/useWebsocket"; import { type CreateWorkspacePermissions, From 0fc42895e6d82c6d64a7a4da1843337d9db5fccd Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Fri, 11 Apr 2025 17:48:17 +0000 Subject: [PATCH 03/48] chore: cleanup, update validation --- .../CreateWorkspacePageViewExperimental.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx index 151ce83f11672..3a376af9c12c9 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx @@ -72,7 +72,7 @@ export interface CreateWorkspacePageViewExperimentalProps { startPollingExternalAuth: () => void; } -// const getInitialParameterValues = ( +// const getInitialParameterValues1 = ( // params: Parameter[], // autofillParams?: AutofillBuildParameter[], // ): WorkspaceBuildParameter[] => { @@ -94,7 +94,7 @@ export interface CreateWorkspacePageViewExperimentalProps { // name: parameter.name, // value: // autofillParam && -// // isValidValue(parameter, autofillParam) && +// isValidValue(parameter, autofillParam) && // autofillParam.source !== "user_history" // ? autofillParam.value // : parameter.default_value, From d109874195c9d44396e02dbde27911e6beef6675 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Fri, 11 Apr 2025 20:19:48 +0000 Subject: [PATCH 04/48] chore: update for types from typesGenerated --- .../DynamicParameter/DynamicParameter.tsx | 129 ++++++++++-------- .../CreateWorkspacePageViewExperimental.tsx | 38 ------ 2 files changed, 75 insertions(+), 92 deletions(-) diff --git a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx index d3f2cbbd69fa6..589c482d05ad0 100644 --- a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx +++ b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx @@ -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) { @@ -349,19 +365,24 @@ 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}
} +
+ ))}
); }; @@ -433,12 +454,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 + .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 && @@ -547,15 +568,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 + .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 3a376af9c12c9..49fd6e9188960 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx @@ -72,44 +72,6 @@ export interface CreateWorkspacePageViewExperimentalProps { 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 > = ({ From 7c13eb7079affb51d6bbed95269feb14082d035f Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Fri, 11 Apr 2025 20:53:44 +0000 Subject: [PATCH 05/48] fix: remove filters --- .../DynamicParameter/DynamicParameter.tsx | 23 +------------------ 1 file changed, 1 insertion(+), 22 deletions(-) diff --git a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx index 589c482d05ad0..ba00291ee6b85 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) => (
=> 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 ( @@ -569,13 +551,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 429028435f97931cc8f52b03df8e07543d805db7 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Fri, 11 Apr 2025 20:59:28 +0000 Subject: [PATCH 06/48] chore: remove unused typesParameter.ts --- site/src/api/typesParameter.ts | 124 ------------------ .../DynamicParameter/DynamicParameter.tsx | 106 +++++++-------- .../CreateWorkspacePageExperimental.tsx | 1 - 3 files changed, 53 insertions(+), 178 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 ba00291ee6b85..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}
} +
+ ))}
); }; @@ -438,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 && @@ -550,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 8ca6bb81c96d3..970c51f2a77da 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx @@ -32,7 +32,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, From 74084fb9e2698ef8a95553f714d2cfcb19942da0 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Tue, 15 Apr 2025 15:26:37 +0000 Subject: [PATCH 07/48] fix: updates for PR review --- site/src/hooks/useWebsocket.ts | 5 ----- 1 file changed, 5 deletions(-) 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) { From 05adc15e506e7a9e2de047f1994ab784e450ca88 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Tue, 15 Apr 2025 16:18:03 +0000 Subject: [PATCH 08/48] fix: format --- .cursor/rules/frontend-dev.mdc | 46 +++++++++++++++++++++++++++ site/src/components/Slider/Slider.tsx | 28 ++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 .cursor/rules/frontend-dev.mdc create mode 100644 site/src/components/Slider/Slider.tsx diff --git a/.cursor/rules/frontend-dev.mdc b/.cursor/rules/frontend-dev.mdc new file mode 100644 index 0000000000000..2d718e828464e --- /dev/null +++ b/.cursor/rules/frontend-dev.mdc @@ -0,0 +1,46 @@ +--- +description: Frontend dev with React, Typescript, shadcn and Tailwind +globs: +alwaysApply: false +--- + +// TypeScript React .cursorrules + +// Prefer functional components + +const preferFunctionalComponents = true; + +// TypeScript React best practices + +const typescriptReactBestPractices = [ + "Use React.FC for functional components with props", + "Utilize useState and useEffect hooks for state and side effects", + "Implement proper TypeScript interfaces for props and state", + "Use React.memo for performance optimization when needed", + "Implement custom hooks for reusable logic", + "Utilize TypeScript's strict mode", +]; + +// Folder structure + +const folderStructure = ` +src/ + components/ + hooks/ + pages/ + utils/ + App.tsx + index.tsx +`; + +// Additional instructions + +const additionalInstructions = ` +1. Use .tsx extension for files with JSX +2. Implement strict TypeScript checks +3. Utilize React.lazy and Suspense for code-splitting +4. Use type inference where possible +5. Implement error boundaries for robust error handling +6. Follow React and TypeScript best practices and naming conventions +7. Use ESLint with TypeScript and React plugins for code quality +`; diff --git a/site/src/components/Slider/Slider.tsx b/site/src/components/Slider/Slider.tsx new file mode 100644 index 0000000000000..847743bbf5ebb --- /dev/null +++ b/site/src/components/Slider/Slider.tsx @@ -0,0 +1,28 @@ +"use client"; + +import * as SliderPrimitive from "@radix-ui/react-slider"; +import * as React from "react"; + +import { cn } from "utils/cn"; + +const Slider = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + + +)); +Slider.displayName = SliderPrimitive.Root.displayName; + +export { Slider }; From b2c662a8d36fe463ff7da16912d8fc87c263e84b Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Tue, 15 Apr 2025 17:37:15 +0000 Subject: [PATCH 09/48] fix: remove websocket code --- site/src/hooks/useWebsocket.ts | 89 ------------------- .../CreateWorkspacePageExperimental.tsx | 5 -- 2 files changed, 94 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 970c51f2a77da..14f34a2e29f0b 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx @@ -32,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 }; From 2da7d9948cda9a62653c4b934f8ce8affe0d3e9e Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Mon, 14 Apr 2025 19:27:48 +0000 Subject: [PATCH 10/48] feat: connect to dynamic parameters websocket --- site/src/api/api.ts | 7 +++ .../CreateWorkspacePageExperimental.tsx | 59 ++++++++++++++++++- .../CreateWorkspacePageViewExperimental.tsx | 4 +- 3 files changed, 64 insertions(+), 6 deletions(-) diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 70d54e5ea0fee..355c5402f1a9a 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -1009,6 +1009,13 @@ class ApiMethods { return response.data; }; + templateVersionDynamicParameters = (versionId: string): WebSocket => { + const socket = createWebSocket( + `/api/v2/templateversions/${versionId}/dynamic-parameters`, + ); + return socket; + }; + /** * @param organization Can be the organization's ID or name */ diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx index 14f34a2e29f0b..cd229be6e7b6b 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx @@ -32,6 +32,7 @@ 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 { API } from "api/api"; import { type CreateWorkspacePermissions, createWorkspaceChecks, @@ -47,8 +48,8 @@ const CreateWorkspacePageExperimental: FC = () => { const [currentResponse, setCurrentResponse] = useState(null); - const [wsResponseId, setWSResponseId] = useState(0); - const sendMessage = (message: DynamicParametersRequest) => {}; + const [wsResponseId, setWSResponseId] = useState(-1); + const webSocket = useRef(null); const customVersionId = searchParams.get("version") ?? undefined; const defaultName = searchParams.get("name"); @@ -80,6 +81,59 @@ const CreateWorkspacePageExperimental: FC = () => { const realizedVersionId = customVersionId ?? templateQuery.data?.active_version_id; + // Initialize the WebSocket connection when there is a valid template version ID + useEffect(() => { + if (!realizedVersionId) { + return; + } + + if (webSocket.current) { + webSocket.current.close(); + } + + const socket = API.templateVersionDynamicParameters(realizedVersionId); + + socket.addEventListener("message", (event) => { + try { + const response = JSON.parse(event.data) as DynamicParametersResponse; + + if (response && response.id >= wsResponseId) { + setCurrentResponse((prev) => { + if (prev?.id === response.id) { + return prev; + } + return response; + }); + } + } catch (error) { + console.error("Failed to parse WebSocket message:", error); + } + }); + + webSocket.current = socket; + + return () => { + if (webSocket.current) { + webSocket.current.close(); + } + }; + }, [realizedVersionId]); + + const sendMessage = + (formValues: Record) => { + setWSResponseId(prevId => { + const request: DynamicParametersRequest = { + id: prevId + 1, + inputs: formValues, + }; + if (webSocket.current && webSocket.current.readyState === WebSocket.OPEN) { + webSocket.current.send(JSON.stringify(request)); + return prevId + 1; + } + return prevId; + }) + }; + const organizationId = templateQuery.data?.organization_id; const { @@ -210,7 +264,6 @@ const CreateWorkspacePageExperimental: FC = () => { parameters={sortedParams} presets={templateVersionPresetsQuery.data ?? []} creatingWorkspace={createWorkspaceMutation.isLoading} - setWSResponseId={setWSResponseId} sendMessage={sendMessage} onCancel={() => { navigate(-1); diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx index 49fd6e9188960..381e614c900ba 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx @@ -67,8 +67,7 @@ export interface CreateWorkspacePageViewExperimentalProps { owner: TypesGen.User, ) => void; resetMutation: () => void; - sendMessage: (message: DynamicParametersRequest) => void; - setWSResponseId: (value: React.SetStateAction) => void; + sendMessage: (message: Record) => void; startPollingExternalAuth: () => void; } @@ -95,7 +94,6 @@ export const CreateWorkspacePageViewExperimental: FC< onCancel, resetMutation, sendMessage, - setWSResponseId, startPollingExternalAuth, }) => { const [owner, setOwner] = useState(defaultOwner); From 98dfee25fcef47c69513c68e40c8de2309b77e5d Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Mon, 14 Apr 2025 20:21:04 +0000 Subject: [PATCH 11/48] chore: cleanup --- site/src/api/api.ts | 21 ++++- .../CreateWorkspacePageExperimental.tsx | 76 +++++++++---------- 2 files changed, 56 insertions(+), 41 deletions(-) diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 355c5402f1a9a..02f72c3b4fbba 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -1009,10 +1009,29 @@ class ApiMethods { return response.data; }; - templateVersionDynamicParameters = (versionId: string): WebSocket => { + templateVersionDynamicParameters = ( + versionId: string, + { + onMessage, + onError, + }: { + onMessage: (response: TypesGen.DynamicParametersResponse) => void; + onError: (error: Error) => void; + }, + ): WebSocket => { const socket = createWebSocket( `/api/v2/templateversions/${versionId}/dynamic-parameters`, ); + + socket.addEventListener("message", (event) => + onMessage(JSON.parse(event.data) as TypesGen.DynamicParametersResponse), + ); + + socket.addEventListener("error", () => { + onError?.(new Error("Connection for dynamic parameters failed.")); + socket.close(); + }); + return socket; }; diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx index cd229be6e7b6b..bb15c40b971f8 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx @@ -49,7 +49,7 @@ const CreateWorkspacePageExperimental: FC = () => { const [currentResponse, setCurrentResponse] = useState(null); const [wsResponseId, setWSResponseId] = useState(-1); - const webSocket = useRef(null); + const ws = useRef(null); const customVersionId = searchParams.get("version") ?? undefined; const defaultName = searchParams.get("name"); @@ -81,58 +81,54 @@ const CreateWorkspacePageExperimental: FC = () => { const realizedVersionId = customVersionId ?? templateQuery.data?.active_version_id; + const onMessage = useCallback((response: DynamicParametersResponse) => { + setCurrentResponse((prev) => { + if (prev?.id === response.id) { + return prev; + } + return response; + }); + }, []); + // Initialize the WebSocket connection when there is a valid template version ID useEffect(() => { if (!realizedVersionId) { return; } - if (webSocket.current) { - webSocket.current.close(); + if (ws.current) { + ws.current.close(); } - const socket = API.templateVersionDynamicParameters(realizedVersionId); - - socket.addEventListener("message", (event) => { - try { - const response = JSON.parse(event.data) as DynamicParametersResponse; - - if (response && response.id >= wsResponseId) { - setCurrentResponse((prev) => { - if (prev?.id === response.id) { - return prev; - } - return response; - }); - } - } catch (error) { - console.error("Failed to parse WebSocket message:", error); - } + const socket = API.templateVersionDynamicParameters(realizedVersionId, { + onMessage, + onError: (error) => { + console.error("Failed to parse dynamic parameters webSocket message:", error); + }, }); - webSocket.current = socket; + ws.current = socket; return () => { - if (webSocket.current) { - webSocket.current.close(); + if (ws.current) { + ws.current.close(); } }; - }, [realizedVersionId]); - - const sendMessage = - (formValues: Record) => { - setWSResponseId(prevId => { - const request: DynamicParametersRequest = { - id: prevId + 1, - inputs: formValues, - }; - if (webSocket.current && webSocket.current.readyState === WebSocket.OPEN) { - webSocket.current.send(JSON.stringify(request)); - return prevId + 1; - } - return prevId; - }) - }; + }, [realizedVersionId, onMessage]); + + const sendMessage = (formValues: Record) => { + setWSResponseId((prevId) => { + const request: DynamicParametersRequest = { + id: prevId + 1, + inputs: formValues, + }; + if (ws.current && ws.current.readyState === WebSocket.OPEN) { + ws.current.send(JSON.stringify(request)); + return prevId + 1; + } + return prevId; + }); + }; const organizationId = templateQuery.data?.organization_id; @@ -143,7 +139,7 @@ const CreateWorkspacePageExperimental: FC = () => { isLoadingExternalAuth, } = useExternalAuth(realizedVersionId); - const isLoadingFormData = + const isLoadingFormData = ws.current?.readyState !== WebSocket.OPEN || templateQuery.isLoading || permissionsQuery.isLoading; const loadFormDataError = templateQuery.error ?? permissionsQuery.error; From d1ada89b4c9e01db46b77a6d4d5e37ffe011c90c Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Mon, 14 Apr 2025 20:21:41 +0000 Subject: [PATCH 12/48] fix: set initial values --- .../modules/workspaces/DynamicParameter/DynamicParameter.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx index d3f2cbbd69fa6..95de6babe984d 100644 --- a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx +++ b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx @@ -393,7 +393,9 @@ export const getInitialParameterValues = ( isValidValue(parameter, autofillParam) && autofillParam.value ? autofillParam.value - : "", + : parameter.default_value.valid + ? parameter.default_value.value + : "", }; }); }; From e04ce2f6f2e2d0b18e4ed95a573d699c93928e3c Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Wed, 16 Apr 2025 09:16:36 +0000 Subject: [PATCH 13/48] fix: fix commit --- .cursor/rules/frontend-dev.mdc | 46 ------------------- site/src/components/Slider/Slider.tsx | 28 ----------- .../CreateWorkspacePageExperimental.tsx | 11 +++-- 3 files changed, 8 insertions(+), 77 deletions(-) delete mode 100644 .cursor/rules/frontend-dev.mdc delete mode 100644 site/src/components/Slider/Slider.tsx diff --git a/.cursor/rules/frontend-dev.mdc b/.cursor/rules/frontend-dev.mdc deleted file mode 100644 index 2d718e828464e..0000000000000 --- a/.cursor/rules/frontend-dev.mdc +++ /dev/null @@ -1,46 +0,0 @@ ---- -description: Frontend dev with React, Typescript, shadcn and Tailwind -globs: -alwaysApply: false ---- - -// TypeScript React .cursorrules - -// Prefer functional components - -const preferFunctionalComponents = true; - -// TypeScript React best practices - -const typescriptReactBestPractices = [ - "Use React.FC for functional components with props", - "Utilize useState and useEffect hooks for state and side effects", - "Implement proper TypeScript interfaces for props and state", - "Use React.memo for performance optimization when needed", - "Implement custom hooks for reusable logic", - "Utilize TypeScript's strict mode", -]; - -// Folder structure - -const folderStructure = ` -src/ - components/ - hooks/ - pages/ - utils/ - App.tsx - index.tsx -`; - -// Additional instructions - -const additionalInstructions = ` -1. Use .tsx extension for files with JSX -2. Implement strict TypeScript checks -3. Utilize React.lazy and Suspense for code-splitting -4. Use type inference where possible -5. Implement error boundaries for robust error handling -6. Follow React and TypeScript best practices and naming conventions -7. Use ESLint with TypeScript and React plugins for code quality -`; diff --git a/site/src/components/Slider/Slider.tsx b/site/src/components/Slider/Slider.tsx deleted file mode 100644 index 847743bbf5ebb..0000000000000 --- a/site/src/components/Slider/Slider.tsx +++ /dev/null @@ -1,28 +0,0 @@ -"use client"; - -import * as SliderPrimitive from "@radix-ui/react-slider"; -import * as React from "react"; - -import { cn } from "utils/cn"; - -const Slider = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - - - - - - -)); -Slider.displayName = SliderPrimitive.Root.displayName; - -export { Slider }; diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx index bb15c40b971f8..1b91caac95c50 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx @@ -103,7 +103,10 @@ const CreateWorkspacePageExperimental: FC = () => { const socket = API.templateVersionDynamicParameters(realizedVersionId, { onMessage, onError: (error) => { - console.error("Failed to parse dynamic parameters webSocket message:", error); + console.error( + "Failed to parse dynamic parameters webSocket message:", + error, + ); }, }); @@ -139,8 +142,10 @@ const CreateWorkspacePageExperimental: FC = () => { isLoadingExternalAuth, } = useExternalAuth(realizedVersionId); - const isLoadingFormData = ws.current?.readyState !== WebSocket.OPEN || - templateQuery.isLoading || permissionsQuery.isLoading; + const isLoadingFormData = + ws.current?.readyState !== WebSocket.OPEN || + templateQuery.isLoading || + permissionsQuery.isLoading; const loadFormDataError = templateQuery.error ?? permissionsQuery.error; const title = autoCreateWorkspaceMutation.isLoading From a6f480da7c4c0c1e6a9cb445708163c32fb1678a Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Wed, 16 Apr 2025 10:01:24 +0000 Subject: [PATCH 14/48] fix: fix rebase issues --- .../CreateWorkspacePageViewExperimental.tsx | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx index 381e614c900ba..86f06b84bfe44 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx @@ -220,15 +220,7 @@ export const CreateWorkspacePageViewExperimental: FC< // 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; - }); + sendMessage(formInputs); }; const { debounced: handleChangeDebounced } = useDebouncedFunction( @@ -238,7 +230,7 @@ export const CreateWorkspacePageViewExperimental: FC< value: string, ) => { await form.setFieldValue(parameterField, { - name: parameter.form_type, + name: parameter.name, value, }); sendDynamicParamsRequest(parameter, value); @@ -255,7 +247,7 @@ export const CreateWorkspacePageViewExperimental: FC< handleChangeDebounced(parameter, parameterField, value); } else { await form.setFieldValue(parameterField, { - name: parameter.form_type, + name: parameter.name, value, }); sendDynamicParamsRequest(parameter, value); From 2613100d5e00997263643fc24826fe384cd3769f Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Wed, 16 Apr 2025 14:06:27 +0000 Subject: [PATCH 15/48] chore: update valid value methods --- .../DynamicParameter/DynamicParameter.tsx | 27 ++++++++++--------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx index 95de6babe984d..be31081b1e01a 100644 --- a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx +++ b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx @@ -1,4 +1,5 @@ import type { + NullHCLString, PreviewParameter, PreviewParameterOption, WorkspaceBuildParameter, @@ -156,10 +157,8 @@ const ParameterField: FC = ({ disabled, id, }) => { - const value = parameter.value.valid ? parameter.value.value : ""; - const defaultValue = parameter.default_value.valid - ? parameter.default_value.value - : ""; + const value = validValue(parameter.value) + const defaultValue = validValue(parameter.default_value); switch (parameter.form_type) { case "dropdown": @@ -376,9 +375,7 @@ export const getInitialParameterValues = ( if (parameter.ephemeral) { return { name: parameter.name, - value: parameter.default_value.valid - ? parameter.default_value.value - : "", + value: validValue(parameter.default_value) }; } @@ -390,17 +387,21 @@ export const getInitialParameterValues = ( name: parameter.name, value: autofillParam && - isValidValue(parameter, autofillParam) && + isValidParameterOption(parameter, autofillParam) && autofillParam.value ? autofillParam.value - : parameter.default_value.valid - ? parameter.default_value.value - : "", + : validValue(parameter.default_value) }; }); }; -const isValidValue = ( +const validValue = ( + value: NullHCLString +) => { + return value.valid ? value.value : ""; +} + +const isValidParameterOption = ( previewParam: PreviewParameter, buildParam: WorkspaceBuildParameter, ) => { @@ -411,7 +412,7 @@ const isValidValue = ( return validValues.includes(buildParam.value); } - return true; + return false; }; export const useValidationSchemaForDynamicParameters = ( From 1e66a71cad0b25ddd3c1218d0016aaf1a7626ea4 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Wed, 16 Apr 2025 14:06:40 +0000 Subject: [PATCH 16/48] chore: onError is required --- site/src/api/api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 02f72c3b4fbba..f7e0cd0889f70 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -1028,7 +1028,7 @@ class ApiMethods { ); socket.addEventListener("error", () => { - onError?.(new Error("Connection for dynamic parameters failed.")); + onError(new Error("Connection for dynamic parameters failed.")); socket.close(); }); From 9a9201e919c4e6f41a055cebd5c04a7cecaaf54e Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Wed, 16 Apr 2025 14:22:26 +0000 Subject: [PATCH 17/48] chore: display websocket error in UI --- .../CreateWorkspacePageExperimental.tsx | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx index 1b91caac95c50..60d64b378d5ae 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx @@ -9,7 +9,6 @@ import { autoCreateWorkspace, createWorkspace } from "api/queries/workspaces"; import type { DynamicParametersRequest, DynamicParametersResponse, - Template, Workspace, } from "api/typesGenerated"; import { Loader } from "components/Loader/Loader"; @@ -50,6 +49,7 @@ const CreateWorkspacePageExperimental: FC = () => { useState(null); const [wsResponseId, setWSResponseId] = useState(-1); const ws = useRef(null); + const [wsError, setWsError] = useState(null); const customVersionId = searchParams.get("version") ?? undefined; const defaultName = searchParams.get("name"); @@ -103,10 +103,7 @@ const CreateWorkspacePageExperimental: FC = () => { const socket = API.templateVersionDynamicParameters(realizedVersionId, { onMessage, onError: (error) => { - console.error( - "Failed to parse dynamic parameters webSocket message:", - error, - ); + setWsError(error); }, }); @@ -244,11 +241,12 @@ const CreateWorkspacePageExperimental: FC = () => { Date: Wed, 16 Apr 2025 14:27:24 +0000 Subject: [PATCH 18/48] fix: format --- .../workspaces/DynamicParameter/DynamicParameter.tsx | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx index be31081b1e01a..939316625f3db 100644 --- a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx +++ b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx @@ -157,7 +157,7 @@ const ParameterField: FC = ({ disabled, id, }) => { - const value = validValue(parameter.value) + const value = validValue(parameter.value); const defaultValue = validValue(parameter.default_value); switch (parameter.form_type) { @@ -375,7 +375,7 @@ export const getInitialParameterValues = ( if (parameter.ephemeral) { return { name: parameter.name, - value: validValue(parameter.default_value) + value: validValue(parameter.default_value), }; } @@ -390,16 +390,14 @@ export const getInitialParameterValues = ( isValidParameterOption(parameter, autofillParam) && autofillParam.value ? autofillParam.value - : validValue(parameter.default_value) + : validValue(parameter.default_value), }; }); }; -const validValue = ( - value: NullHCLString -) => { +const validValue = (value: NullHCLString) => { return value.valid ? value.value : ""; -} +}; const isValidParameterOption = ( previewParam: PreviewParameter, From d7e46ffd078a7c4ab5b7a73c5a9256c039f554cf Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 10 Apr 2025 22:19:03 +0000 Subject: [PATCH 19/48] feat: create dynamic parameter component --- site/src/api/typesParameter.ts | 124 ++++++++++++++++++ site/src/hooks/useWebsocket.ts | 94 +++++++++++++ .../CreateWorkspacePageExperimental.tsx | 4 + .../CreateWorkspacePageViewExperimental.tsx | 38 ++++++ 4 files changed, 260 insertions(+) create mode 100644 site/src/api/typesParameter.ts create mode 100644 site/src/hooks/useWebsocket.ts 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/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/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx index 60d64b378d5ae..b30ebffa366a2 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx @@ -38,6 +38,10 @@ import { } 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 }; diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx index 86f06b84bfe44..f1459e19ece4c 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx @@ -71,6 +71,44 @@ export interface CreateWorkspacePageViewExperimentalProps { 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 > = ({ From 946d27a885e9db6bce2f9006890f4b7e9707f6a9 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Fri, 11 Apr 2025 17:48:17 +0000 Subject: [PATCH 20/48] chore: cleanup, update validation --- .../CreateWorkspacePageViewExperimental.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx index f1459e19ece4c..a4319d846a06d 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx @@ -71,7 +71,7 @@ export interface CreateWorkspacePageViewExperimentalProps { startPollingExternalAuth: () => void; } -// const getInitialParameterValues = ( +// const getInitialParameterValues1 = ( // params: Parameter[], // autofillParams?: AutofillBuildParameter[], // ): WorkspaceBuildParameter[] => { @@ -93,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, From c59a5460ef38c4a8571c4634a10676cd1ae63200 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Fri, 11 Apr 2025 20:19:48 +0000 Subject: [PATCH 21/48] chore: update for types from typesGenerated --- .../DynamicParameter/DynamicParameter.tsx | 129 ++++++++++-------- .../CreateWorkspacePageViewExperimental.tsx | 38 ------ 2 files changed, 75 insertions(+), 92 deletions(-) diff --git a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx index 939316625f3db..83ffded78f7fa 100644 --- a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx +++ b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx @@ -172,26 +172,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, @@ -241,20 +250,24 @@ const ParameterField: FC = ({ disabled={disabled} defaultValue={defaultValue} > - {parameter.options.map((option) => ( -
- - -
- ))} + {parameter.options + .filter( + (option): option is NonNullable => option !== null, + ) + .map((option) => ( +
+ + +
+ ))} ); @@ -280,7 +293,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) { @@ -348,19 +364,24 @@ 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}
} +
+ ))}
); }; @@ -434,12 +455,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 + .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 && @@ -548,15 +569,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 + .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 a4319d846a06d..86f06b84bfe44 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx @@ -71,44 +71,6 @@ export interface CreateWorkspacePageViewExperimentalProps { 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 > = ({ From 82a473324544a1f520523fcd801e38a55c6ba846 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Fri, 11 Apr 2025 20:53:44 +0000 Subject: [PATCH 22/48] fix: remove filters --- .../DynamicParameter/DynamicParameter.tsx | 23 +------------------ 1 file changed, 1 insertion(+), 22 deletions(-) diff --git a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx index 83ffded78f7fa..c256d84616db5 100644 --- a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx +++ b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx @@ -173,10 +173,6 @@ const ParameterField: FC = ({ {parameter.options - .filter( - (option): option is NonNullable => - option !== null, - ) .map((option) => ( @@ -189,7 +185,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, @@ -199,7 +194,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, @@ -251,9 +245,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) { @@ -365,10 +353,6 @@ const ParameterDiagnostics: FC = ({ return (
{diagnostics - .filter( - (diagnostic): diagnostic is NonNullable => - diagnostic !== null, - ) .map((diagnostic, index) => (
=> 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 ( @@ -570,13 +552,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 c1cc222e629218d5b5804906c77ecad46429a150 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Fri, 11 Apr 2025 20:59:28 +0000 Subject: [PATCH 23/48] chore: remove unused typesParameter.ts --- site/src/api/typesParameter.ts | 124 ------------------ .../DynamicParameter/DynamicParameter.tsx | 106 +++++++-------- 2 files changed, 53 insertions(+), 177 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 c256d84616db5..939316625f3db 100644 --- a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx +++ b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx @@ -172,29 +172,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, @@ -244,21 +241,20 @@ const ParameterField: FC = ({ disabled={disabled} defaultValue={defaultValue} > - {parameter.options - .map((option) => ( -
- - -
- ))} + {parameter.options.map((option) => ( +
+ + +
+ ))} ); @@ -352,20 +348,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}
} +
+ ))}
); }; @@ -439,10 +434,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 && @@ -551,12 +548,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; From ef389a9becd3d90fd1eaa5fff5021afc99c04f5c Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Mon, 14 Apr 2025 19:27:48 +0000 Subject: [PATCH 24/48] feat: connect to dynamic parameters websocket --- .../CreateWorkspacePage/CreateWorkspacePageExperimental.tsx | 4 ---- .../CreateWorkspacePageViewExperimental.tsx | 1 - 2 files changed, 5 deletions(-) diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx index b30ebffa366a2..60d64b378d5ae 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx @@ -38,10 +38,6 @@ import { } 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 }; diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx index 86f06b84bfe44..0b999f5a85d9f 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx @@ -1,6 +1,5 @@ import type * as TypesGen from "api/typesGenerated"; import type { - DynamicParametersRequest, PreviewDiagnostics, PreviewParameter, } from "api/typesGenerated"; From d21b83f0500c66242dcfd8a3753a10364698d4f5 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Mon, 14 Apr 2025 20:21:04 +0000 Subject: [PATCH 25/48] chore: cleanup --- .../CreateWorkspacePageViewExperimental.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx index 0b999f5a85d9f..dc8ca5b8bcd70 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx @@ -1,8 +1,5 @@ import type * as TypesGen from "api/typesGenerated"; -import type { - PreviewDiagnostics, - PreviewParameter, -} from "api/typesGenerated"; +import type { PreviewDiagnostics, PreviewParameter } from "api/typesGenerated"; import { Alert } from "components/Alert/Alert"; import { ErrorAlert } from "components/Alert/ErrorAlert"; import { Avatar } from "components/Avatar/Avatar"; From 8c5be29721f2eabc8dc7060433f68bda7db079cf Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Mon, 14 Apr 2025 20:33:19 +0000 Subject: [PATCH 26/48] feat: enable top level diagnostics display --- site/src/index.css | 2 + .../DynamicParameter/DynamicParameter.tsx | 13 +++--- .../CreateWorkspacePageViewExperimental.tsx | 42 +++++++++++++++++++ site/tailwind.config.js | 1 + 4 files changed, 53 insertions(+), 5 deletions(-) diff --git a/site/src/index.css b/site/src/index.css index 6037a0d2fbfc4..fe8699bc62b07 100644 --- a/site/src/index.css +++ b/site/src/index.css @@ -30,6 +30,7 @@ --surface-sky: 201 94% 86%; --border-default: 240 6% 90%; --border-success: 142 76% 36%; + --border-warning: 30.66, 97.16%, 72.35%; --border-destructive: 0 84% 60%; --border-hover: 240, 5%, 34%; --overlay-default: 240 5% 84% / 80%; @@ -67,6 +68,7 @@ --surface-sky: 204 80% 16%; --border-default: 240 4% 16%; --border-success: 142 76% 36%; + --border-warning: 30.66, 97.16%, 72.35%; --border-destructive: 0 91% 71%; --border-hover: 240, 5%, 34%; --overlay-default: 240 10% 4% / 80%; diff --git a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx index 939316625f3db..e1e79bdcd7a06 100644 --- a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx +++ b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx @@ -247,10 +247,13 @@ const ParameterField: FC = ({ className="flex items-center space-x-2" > -
@@ -350,15 +353,15 @@ const ParameterDiagnostics: FC = ({
{diagnostics.map((diagnostic, index) => (
-
{diagnostic.summary}
- {diagnostic.detail &&
{diagnostic.detail}
} +

{diagnostic.summary}

+ {diagnostic.detail &&

{diagnostic.detail}

}
))}
diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx index dc8ca5b8bcd70..e221aedf7bf3a 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx @@ -409,6 +409,7 @@ export const CreateWorkspacePageViewExperimental: FC< parameters cannot be modified once the workspace is created.

+ {presets.length > 0 && (
@@ -498,3 +499,44 @@ export const CreateWorkspacePageViewExperimental: FC< ); }; + +interface DiagnosticsProps { + diagnostics: PreviewParameter["diagnostics"]; +} + +export const Diagnostics: FC = ({ diagnostics }) => { + return ( +
+ {diagnostics.map((diagnostic, index) => ( +
+
+ {diagnostic.severity === "error" && ( +
+ {diagnostic.detail &&

{diagnostic.detail}

} +
+ ))} +
+ ); +}; diff --git a/site/tailwind.config.js b/site/tailwind.config.js index 971a729332aff..3e612408596f5 100644 --- a/site/tailwind.config.js +++ b/site/tailwind.config.js @@ -52,6 +52,7 @@ module.exports = { }, border: { DEFAULT: "hsl(var(--border-default))", + warning: "hsl(var(--border-warning))", destructive: "hsl(var(--border-destructive))", success: "hsl(var(--border-success))", hover: "hsl(var(--border-hover))", From 0658c35a5830025f5c92e688e48ad060f376f351 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Wed, 16 Apr 2025 14:44:18 +0000 Subject: [PATCH 27/48] fix: remove useWebsocket.ts --- site/src/hooks/useWebsocket.ts | 94 ---------------------------------- 1 file changed, 94 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 d9aa3ba8f4fa1..0000000000000 --- a/site/src/hooks/useWebsocket.ts +++ /dev/null @@ -1,94 +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 = () => { - // 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 }; -} From 001944c1d77eeb0f1f276c92ad7c57f0758b6133 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Wed, 16 Apr 2025 14:54:20 +0000 Subject: [PATCH 28/48] fix: add missing icons --- .../CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx index e221aedf7bf3a..3674884c1fb37 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx @@ -15,7 +15,7 @@ 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 { ArrowLeft, CircleAlert, TriangleAlert } from "lucide-react"; import { DynamicParameter, getInitialParameterValues, From 00d6c32bdd0e675aab53dc57c53d770a82dea450 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Wed, 16 Apr 2025 16:25:40 +0000 Subject: [PATCH 29/48] fix: updates for PR review --- .../CreateWorkspacePageExperimental.tsx | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx index 60d64b378d5ae..052cce01c59b3 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx @@ -96,10 +96,6 @@ const CreateWorkspacePageExperimental: FC = () => { return; } - if (ws.current) { - ws.current.close(); - } - const socket = API.templateVersionDynamicParameters(realizedVersionId, { onMessage, onError: (error) => { @@ -110,9 +106,7 @@ const CreateWorkspacePageExperimental: FC = () => { ws.current = socket; return () => { - if (ws.current) { - ws.current.close(); - } + socket.close(); }; }, [realizedVersionId, onMessage]); From 57377b5d90a8101a124875c0d00ff962fd7b6394 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 10 Apr 2025 22:19:03 +0000 Subject: [PATCH 30/48] feat: create dynamic parameter component --- site/src/api/typesParameter.ts | 124 ++++++++++++++++++ site/src/hooks/useWebsocket.ts | 94 +++++++++++++ .../CreateWorkspacePageExperimental.tsx | 4 + .../CreateWorkspacePageViewExperimental.tsx | 38 ++++++ 4 files changed, 260 insertions(+) create mode 100644 site/src/api/typesParameter.ts create mode 100644 site/src/hooks/useWebsocket.ts 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/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/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx index 27d76a23a83cd..44d419e0de43e 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx @@ -38,6 +38,10 @@ import { } 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 }; diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx index 86f06b84bfe44..f1459e19ece4c 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx @@ -71,6 +71,44 @@ export interface CreateWorkspacePageViewExperimentalProps { 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 > = ({ From ee37ae5321d7509e0caa80b73758424153dfab16 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Fri, 11 Apr 2025 17:48:17 +0000 Subject: [PATCH 31/48] chore: cleanup, update validation --- .../CreateWorkspacePageViewExperimental.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx index f1459e19ece4c..a4319d846a06d 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx @@ -71,7 +71,7 @@ export interface CreateWorkspacePageViewExperimentalProps { startPollingExternalAuth: () => void; } -// const getInitialParameterValues = ( +// const getInitialParameterValues1 = ( // params: Parameter[], // autofillParams?: AutofillBuildParameter[], // ): WorkspaceBuildParameter[] => { @@ -93,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, From 951bf1273def590b983aac4357e53d74b41cb37e Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Fri, 11 Apr 2025 20:19:48 +0000 Subject: [PATCH 32/48] chore: update for types from typesGenerated --- .../DynamicParameter/DynamicParameter.tsx | 129 ++++++++++-------- .../CreateWorkspacePageViewExperimental.tsx | 38 ------ 2 files changed, 75 insertions(+), 92 deletions(-) diff --git a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx index 939316625f3db..83ffded78f7fa 100644 --- a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx +++ b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx @@ -172,26 +172,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, @@ -241,20 +250,24 @@ const ParameterField: FC = ({ disabled={disabled} defaultValue={defaultValue} > - {parameter.options.map((option) => ( -
- - -
- ))} + {parameter.options + .filter( + (option): option is NonNullable => option !== null, + ) + .map((option) => ( +
+ + +
+ ))} ); @@ -280,7 +293,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) { @@ -348,19 +364,24 @@ 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}
} +
+ ))}
); }; @@ -434,12 +455,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 + .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 && @@ -548,15 +569,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 + .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 a4319d846a06d..86f06b84bfe44 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx @@ -71,44 +71,6 @@ export interface CreateWorkspacePageViewExperimentalProps { 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 > = ({ From de71716b973f113d2a87a2d03756b4b1f8ffdcb2 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Fri, 11 Apr 2025 20:53:44 +0000 Subject: [PATCH 33/48] fix: remove filters --- .../DynamicParameter/DynamicParameter.tsx | 23 +------------------ 1 file changed, 1 insertion(+), 22 deletions(-) diff --git a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx index 83ffded78f7fa..c256d84616db5 100644 --- a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx +++ b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx @@ -173,10 +173,6 @@ const ParameterField: FC = ({ {parameter.options - .filter( - (option): option is NonNullable => - option !== null, - ) .map((option) => ( @@ -189,7 +185,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, @@ -199,7 +194,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, @@ -251,9 +245,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) { @@ -365,10 +353,6 @@ const ParameterDiagnostics: FC = ({ return (
{diagnostics - .filter( - (diagnostic): diagnostic is NonNullable => - diagnostic !== null, - ) .map((diagnostic, index) => (
=> 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 ( @@ -570,13 +552,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 193702a4d9fb6b134b9936f15501137129a09008 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Fri, 11 Apr 2025 20:59:28 +0000 Subject: [PATCH 34/48] chore: remove unused typesParameter.ts --- site/src/api/typesParameter.ts | 124 ------------------ .../DynamicParameter/DynamicParameter.tsx | 106 +++++++-------- 2 files changed, 53 insertions(+), 177 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 c256d84616db5..939316625f3db 100644 --- a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx +++ b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx @@ -172,29 +172,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, @@ -244,21 +241,20 @@ const ParameterField: FC = ({ disabled={disabled} defaultValue={defaultValue} > - {parameter.options - .map((option) => ( -
- - -
- ))} + {parameter.options.map((option) => ( +
+ + +
+ ))} ); @@ -352,20 +348,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}
} +
+ ))}
); }; @@ -439,10 +434,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 && @@ -551,12 +548,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; From c9608b4fd7cb53099187261eead7f4955909695f Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Tue, 15 Apr 2025 15:26:37 +0000 Subject: [PATCH 35/48] fix: updates for PR review --- site/src/hooks/useWebsocket.ts | 5 ----- 1 file changed, 5 deletions(-) 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) { From cd09e4291aa923f1395cbcf7dac942db35ccd940 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Tue, 15 Apr 2025 16:18:03 +0000 Subject: [PATCH 36/48] fix: format --- .cursor/rules/frontend-dev.mdc | 46 +++++++++++++++++++++++++++ site/src/components/Slider/Slider.tsx | 28 ++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 .cursor/rules/frontend-dev.mdc create mode 100644 site/src/components/Slider/Slider.tsx diff --git a/.cursor/rules/frontend-dev.mdc b/.cursor/rules/frontend-dev.mdc new file mode 100644 index 0000000000000..2d718e828464e --- /dev/null +++ b/.cursor/rules/frontend-dev.mdc @@ -0,0 +1,46 @@ +--- +description: Frontend dev with React, Typescript, shadcn and Tailwind +globs: +alwaysApply: false +--- + +// TypeScript React .cursorrules + +// Prefer functional components + +const preferFunctionalComponents = true; + +// TypeScript React best practices + +const typescriptReactBestPractices = [ + "Use React.FC for functional components with props", + "Utilize useState and useEffect hooks for state and side effects", + "Implement proper TypeScript interfaces for props and state", + "Use React.memo for performance optimization when needed", + "Implement custom hooks for reusable logic", + "Utilize TypeScript's strict mode", +]; + +// Folder structure + +const folderStructure = ` +src/ + components/ + hooks/ + pages/ + utils/ + App.tsx + index.tsx +`; + +// Additional instructions + +const additionalInstructions = ` +1. Use .tsx extension for files with JSX +2. Implement strict TypeScript checks +3. Utilize React.lazy and Suspense for code-splitting +4. Use type inference where possible +5. Implement error boundaries for robust error handling +6. Follow React and TypeScript best practices and naming conventions +7. Use ESLint with TypeScript and React plugins for code quality +`; diff --git a/site/src/components/Slider/Slider.tsx b/site/src/components/Slider/Slider.tsx new file mode 100644 index 0000000000000..847743bbf5ebb --- /dev/null +++ b/site/src/components/Slider/Slider.tsx @@ -0,0 +1,28 @@ +"use client"; + +import * as SliderPrimitive from "@radix-ui/react-slider"; +import * as React from "react"; + +import { cn } from "utils/cn"; + +const Slider = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + + +)); +Slider.displayName = SliderPrimitive.Root.displayName; + +export { Slider }; From 1a825b206ce568ced6ec6823ca121d7e094a5904 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Tue, 15 Apr 2025 17:37:15 +0000 Subject: [PATCH 37/48] fix: remove websocket code --- site/src/hooks/useWebsocket.ts | 89 ------------------- .../CreateWorkspacePageExperimental.tsx | 4 - 2 files changed, 93 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 44d419e0de43e..27d76a23a83cd 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx @@ -38,10 +38,6 @@ import { } 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 }; From 81d4a2b9543d6f9c00afce0baf47d41dd51e0243 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Wed, 16 Apr 2025 09:16:36 +0000 Subject: [PATCH 38/48] fix: fix commit --- .cursor/rules/frontend-dev.mdc | 46 --------------------------- site/src/components/Slider/Slider.tsx | 28 ---------------- 2 files changed, 74 deletions(-) delete mode 100644 .cursor/rules/frontend-dev.mdc delete mode 100644 site/src/components/Slider/Slider.tsx diff --git a/.cursor/rules/frontend-dev.mdc b/.cursor/rules/frontend-dev.mdc deleted file mode 100644 index 2d718e828464e..0000000000000 --- a/.cursor/rules/frontend-dev.mdc +++ /dev/null @@ -1,46 +0,0 @@ ---- -description: Frontend dev with React, Typescript, shadcn and Tailwind -globs: -alwaysApply: false ---- - -// TypeScript React .cursorrules - -// Prefer functional components - -const preferFunctionalComponents = true; - -// TypeScript React best practices - -const typescriptReactBestPractices = [ - "Use React.FC for functional components with props", - "Utilize useState and useEffect hooks for state and side effects", - "Implement proper TypeScript interfaces for props and state", - "Use React.memo for performance optimization when needed", - "Implement custom hooks for reusable logic", - "Utilize TypeScript's strict mode", -]; - -// Folder structure - -const folderStructure = ` -src/ - components/ - hooks/ - pages/ - utils/ - App.tsx - index.tsx -`; - -// Additional instructions - -const additionalInstructions = ` -1. Use .tsx extension for files with JSX -2. Implement strict TypeScript checks -3. Utilize React.lazy and Suspense for code-splitting -4. Use type inference where possible -5. Implement error boundaries for robust error handling -6. Follow React and TypeScript best practices and naming conventions -7. Use ESLint with TypeScript and React plugins for code quality -`; diff --git a/site/src/components/Slider/Slider.tsx b/site/src/components/Slider/Slider.tsx deleted file mode 100644 index 847743bbf5ebb..0000000000000 --- a/site/src/components/Slider/Slider.tsx +++ /dev/null @@ -1,28 +0,0 @@ -"use client"; - -import * as SliderPrimitive from "@radix-ui/react-slider"; -import * as React from "react"; - -import { cn } from "utils/cn"; - -const Slider = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - - - - - - -)); -Slider.displayName = SliderPrimitive.Root.displayName; - -export { Slider }; From c486af13b970e77f964792d83fb413b9d0aab5a1 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 10 Apr 2025 22:19:03 +0000 Subject: [PATCH 39/48] feat: create dynamic parameter component --- site/src/api/typesParameter.ts | 124 ++++++++++++++++++ site/src/hooks/useWebsocket.ts | 94 +++++++++++++ .../CreateWorkspacePageExperimental.tsx | 4 + .../CreateWorkspacePageViewExperimental.tsx | 38 ++++++ 4 files changed, 260 insertions(+) create mode 100644 site/src/api/typesParameter.ts create mode 100644 site/src/hooks/useWebsocket.ts 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/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/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx index 27d76a23a83cd..44d419e0de43e 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx @@ -38,6 +38,10 @@ import { } 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 }; diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx index 86f06b84bfe44..f1459e19ece4c 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx @@ -71,6 +71,44 @@ export interface CreateWorkspacePageViewExperimentalProps { 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 > = ({ From 53f1c22fd6ee61b7d7fbf68303c03da8db4f4552 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Fri, 11 Apr 2025 17:48:17 +0000 Subject: [PATCH 40/48] chore: cleanup, update validation --- .../CreateWorkspacePageViewExperimental.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx index f1459e19ece4c..a4319d846a06d 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx @@ -71,7 +71,7 @@ export interface CreateWorkspacePageViewExperimentalProps { startPollingExternalAuth: () => void; } -// const getInitialParameterValues = ( +// const getInitialParameterValues1 = ( // params: Parameter[], // autofillParams?: AutofillBuildParameter[], // ): WorkspaceBuildParameter[] => { @@ -93,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, From 7bcd18d5a0e8b0772e813872dc47b65090e8a5c3 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Fri, 11 Apr 2025 20:19:48 +0000 Subject: [PATCH 41/48] chore: update for types from typesGenerated --- .../DynamicParameter/DynamicParameter.tsx | 129 ++++++++++-------- .../CreateWorkspacePageViewExperimental.tsx | 38 ------ 2 files changed, 75 insertions(+), 92 deletions(-) diff --git a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx index 939316625f3db..83ffded78f7fa 100644 --- a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx +++ b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx @@ -172,26 +172,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, @@ -241,20 +250,24 @@ const ParameterField: FC = ({ disabled={disabled} defaultValue={defaultValue} > - {parameter.options.map((option) => ( -
- - -
- ))} + {parameter.options + .filter( + (option): option is NonNullable => option !== null, + ) + .map((option) => ( +
+ + +
+ ))} ); @@ -280,7 +293,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) { @@ -348,19 +364,24 @@ 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}
} +
+ ))}
); }; @@ -434,12 +455,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 + .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 && @@ -548,15 +569,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 + .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 a4319d846a06d..86f06b84bfe44 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx @@ -71,44 +71,6 @@ export interface CreateWorkspacePageViewExperimentalProps { 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 > = ({ From d6a4fde1dbfb67909f98fc7adee119af691d7f15 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Fri, 11 Apr 2025 20:53:44 +0000 Subject: [PATCH 42/48] fix: remove filters --- .../DynamicParameter/DynamicParameter.tsx | 23 +------------------ 1 file changed, 1 insertion(+), 22 deletions(-) diff --git a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx index 83ffded78f7fa..c256d84616db5 100644 --- a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx +++ b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx @@ -173,10 +173,6 @@ const ParameterField: FC = ({ {parameter.options - .filter( - (option): option is NonNullable => - option !== null, - ) .map((option) => ( @@ -189,7 +185,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, @@ -199,7 +194,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, @@ -251,9 +245,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) { @@ -365,10 +353,6 @@ const ParameterDiagnostics: FC = ({ return (
{diagnostics - .filter( - (diagnostic): diagnostic is NonNullable => - diagnostic !== null, - ) .map((diagnostic, index) => (
=> 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 ( @@ -570,13 +552,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 ddd58da2313d51b172b60bb52ab5ffb97d7d773d Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Fri, 11 Apr 2025 20:59:28 +0000 Subject: [PATCH 43/48] chore: remove unused typesParameter.ts --- site/src/api/typesParameter.ts | 124 ------------------ .../DynamicParameter/DynamicParameter.tsx | 106 +++++++-------- 2 files changed, 53 insertions(+), 177 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 c256d84616db5..939316625f3db 100644 --- a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx +++ b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx @@ -172,29 +172,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, @@ -244,21 +241,20 @@ const ParameterField: FC = ({ disabled={disabled} defaultValue={defaultValue} > - {parameter.options - .map((option) => ( -
- - -
- ))} + {parameter.options.map((option) => ( +
+ + +
+ ))} ); @@ -352,20 +348,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}
} +
+ ))}
); }; @@ -439,10 +434,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 && @@ -551,12 +548,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; From ed981c6d8da61b66666e3fd70c53a3f181135598 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Mon, 14 Apr 2025 19:27:48 +0000 Subject: [PATCH 44/48] feat: connect to dynamic parameters websocket --- .../CreateWorkspacePage/CreateWorkspacePageExperimental.tsx | 4 ---- .../CreateWorkspacePageViewExperimental.tsx | 1 - 2 files changed, 5 deletions(-) diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx index 44d419e0de43e..27d76a23a83cd 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx @@ -38,10 +38,6 @@ import { } 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 }; diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx index 86f06b84bfe44..0b999f5a85d9f 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx @@ -1,6 +1,5 @@ import type * as TypesGen from "api/typesGenerated"; import type { - DynamicParametersRequest, PreviewDiagnostics, PreviewParameter, } from "api/typesGenerated"; From 8d6ba965da3addd0c123a1656504b7e22b97c7c6 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Mon, 14 Apr 2025 20:21:04 +0000 Subject: [PATCH 45/48] chore: cleanup --- .../CreateWorkspacePageViewExperimental.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx index 0b999f5a85d9f..dc8ca5b8bcd70 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx @@ -1,8 +1,5 @@ import type * as TypesGen from "api/typesGenerated"; -import type { - PreviewDiagnostics, - PreviewParameter, -} from "api/typesGenerated"; +import type { PreviewDiagnostics, PreviewParameter } from "api/typesGenerated"; import { Alert } from "components/Alert/Alert"; import { ErrorAlert } from "components/Alert/ErrorAlert"; import { Avatar } from "components/Avatar/Avatar"; From 99d4c3a7ab50d3ef053b2df8b1f674a43ebd37c1 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Mon, 14 Apr 2025 20:33:19 +0000 Subject: [PATCH 46/48] feat: enable top level diagnostics display --- site/src/index.css | 2 + .../DynamicParameter/DynamicParameter.tsx | 13 +++--- .../CreateWorkspacePageViewExperimental.tsx | 42 +++++++++++++++++++ site/tailwind.config.js | 1 + 4 files changed, 53 insertions(+), 5 deletions(-) diff --git a/site/src/index.css b/site/src/index.css index 6037a0d2fbfc4..fe8699bc62b07 100644 --- a/site/src/index.css +++ b/site/src/index.css @@ -30,6 +30,7 @@ --surface-sky: 201 94% 86%; --border-default: 240 6% 90%; --border-success: 142 76% 36%; + --border-warning: 30.66, 97.16%, 72.35%; --border-destructive: 0 84% 60%; --border-hover: 240, 5%, 34%; --overlay-default: 240 5% 84% / 80%; @@ -67,6 +68,7 @@ --surface-sky: 204 80% 16%; --border-default: 240 4% 16%; --border-success: 142 76% 36%; + --border-warning: 30.66, 97.16%, 72.35%; --border-destructive: 0 91% 71%; --border-hover: 240, 5%, 34%; --overlay-default: 240 10% 4% / 80%; diff --git a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx index 939316625f3db..e1e79bdcd7a06 100644 --- a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx +++ b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx @@ -247,10 +247,13 @@ const ParameterField: FC = ({ className="flex items-center space-x-2" > -
@@ -350,15 +353,15 @@ const ParameterDiagnostics: FC = ({
{diagnostics.map((diagnostic, index) => (
-
{diagnostic.summary}
- {diagnostic.detail &&
{diagnostic.detail}
} +

{diagnostic.summary}

+ {diagnostic.detail &&

{diagnostic.detail}

}
))}
diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx index dc8ca5b8bcd70..e221aedf7bf3a 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx @@ -409,6 +409,7 @@ export const CreateWorkspacePageViewExperimental: FC< parameters cannot be modified once the workspace is created.

+ {presets.length > 0 && (
@@ -498,3 +499,44 @@ export const CreateWorkspacePageViewExperimental: FC< ); }; + +interface DiagnosticsProps { + diagnostics: PreviewParameter["diagnostics"]; +} + +export const Diagnostics: FC = ({ diagnostics }) => { + return ( +
+ {diagnostics.map((diagnostic, index) => ( +
+
+ {diagnostic.severity === "error" && ( +
+ {diagnostic.detail &&

{diagnostic.detail}

} +
+ ))} +
+ ); +}; diff --git a/site/tailwind.config.js b/site/tailwind.config.js index 971a729332aff..3e612408596f5 100644 --- a/site/tailwind.config.js +++ b/site/tailwind.config.js @@ -52,6 +52,7 @@ module.exports = { }, border: { DEFAULT: "hsl(var(--border-default))", + warning: "hsl(var(--border-warning))", destructive: "hsl(var(--border-destructive))", success: "hsl(var(--border-success))", hover: "hsl(var(--border-hover))", From 3ba83c97142c018cfd54339379aa5c88e3ee6989 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Wed, 16 Apr 2025 14:44:18 +0000 Subject: [PATCH 47/48] fix: remove useWebsocket.ts --- site/src/hooks/useWebsocket.ts | 94 ---------------------------------- 1 file changed, 94 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 d9aa3ba8f4fa1..0000000000000 --- a/site/src/hooks/useWebsocket.ts +++ /dev/null @@ -1,94 +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 = () => { - // 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 }; -} From 1555961f8486a4369a96f2094d9be742e98a6430 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Wed, 16 Apr 2025 14:54:20 +0000 Subject: [PATCH 48/48] fix: add missing icons --- .../CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx index e221aedf7bf3a..3674884c1fb37 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx @@ -15,7 +15,7 @@ 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 { ArrowLeft, CircleAlert, TriangleAlert } from "lucide-react"; import { DynamicParameter, getInitialParameterValues,