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

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

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


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy