From 78429bad175de06df88053e9d3ddf268daf958ef Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Sun, 27 Apr 2025 10:41:54 +0000 Subject: [PATCH 01/10] feat: update template embed page for dynamic params --- .../TemplateEmbedExperimentRouter.tsx | 22 + .../TemplateEmbedPageExperimental.tsx | 405 ++++++++++++++++++ site/src/router.tsx | 6 +- site/src/utils/richParameters.ts | 13 + 4 files changed, 443 insertions(+), 3 deletions(-) create mode 100644 site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedExperimentRouter.tsx create mode 100644 site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPageExperimental.tsx diff --git a/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedExperimentRouter.tsx b/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedExperimentRouter.tsx new file mode 100644 index 0000000000000..78f8bb3a4b7e0 --- /dev/null +++ b/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedExperimentRouter.tsx @@ -0,0 +1,22 @@ +import { useDashboard } from "modules/dashboard/useDashboard"; +import { type FC, createContext } from "react"; +import TemplateEmbedPage from "./TemplateEmbedPage"; +import TemplateEmbedPageExperimental from "./TemplateEmbedPageExperimental"; + +// Similar context as in CreateWorkspaceExperimentRouter for maintaining consistency +export const ExperimentalFormContext = createContext< + { toggleOptedOut: () => void } | undefined +>(undefined); + +const TemplateEmbedExperimentRouter: FC = () => { + const { experiments } = useDashboard(); + const dynamicParametersEnabled = experiments.includes("dynamic-parameters"); + + if (dynamicParametersEnabled) { + return ; + } + + return ; +}; + +export default TemplateEmbedExperimentRouter; \ No newline at end of file diff --git a/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPageExperimental.tsx b/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPageExperimental.tsx new file mode 100644 index 0000000000000..e3680dab886a8 --- /dev/null +++ b/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPageExperimental.tsx @@ -0,0 +1,405 @@ +import CheckOutlined from "@mui/icons-material/CheckOutlined"; +import FileCopyOutlined from "@mui/icons-material/FileCopyOutlined"; +import Button from "@mui/material/Button"; +import FormControlLabel from "@mui/material/FormControlLabel"; +import Radio from "@mui/material/Radio"; +import RadioGroup from "@mui/material/RadioGroup"; +import { API } from "api/api"; +import { DetailedError } from "api/errors"; +import type { + DynamicParametersRequest, + DynamicParametersResponse, + PreviewParameter, + Template +} from "api/typesGenerated"; +import { FormSection, VerticalForm } from "components/Form/Form"; +import { Loader } from "components/Loader/Loader"; +import { useClipboard } from "hooks/useClipboard"; +import { DynamicParameter } from "modules/workspaces/DynamicParameter/DynamicParameter"; +import { useTemplateLayoutContext } from "pages/TemplatePage/TemplateLayout"; +import { type FC, useCallback, useEffect, useRef, useState } from "react"; +import { Helmet } from "react-helmet-async"; +import { useQuery } from "react-query"; +import { useSearchParams } from "react-router-dom"; +import { pageTitle } from "utils/page"; +import { getAutofillParameters } from "utils/richParameters"; + +type ButtonValues = Record; + +const TemplateEmbedPageExperimental: FC = () => { + const { template } = useTemplateLayoutContext(); + const [searchParams] = useSearchParams(); + + return ( + <> + + {pageTitle(template.name)} + + + + ); +}; + +interface TemplateEmbedPageViewProps { + template: Template; + searchParams: URLSearchParams; +} + +const TemplateEmbedPageView: FC = ({ + template, + searchParams +}) => { + const [currentResponse, setCurrentResponse] = useState(null); + const [wsResponseId, setWSResponseId] = useState(-1); + const ws = useRef(null); + const [wsError, setWsError] = useState(null); + const [buttonValues, setButtonValues] = useState(); + + // Get the current user + const { data: me } = useQuery({ + queryKey: ["me"], + queryFn: () => API.getAuthenticatedUser(), + }); + + // Check if workspace should be auto-created + const isAutoMode = searchParams.get("mode") === "auto"; + + // Parse autofill parameters from URL + const autofillParameters = searchParams ? getAutofillParameters(searchParams) : []; + + const onMessage = useCallback((response: DynamicParametersResponse) => { + setCurrentResponse((prev) => { + if (prev?.id === response.id) { + return prev; + } + return response; + }); + }, []); + + // Initialize the WebSocket connection when component mounts + useEffect(() => { + if (!me?.id || !template.active_version_id) { + return; + } + + // If mode=auto and workspace will be auto-created, no need for WebSocket + if (isAutoMode) { + return; + } + + const socket = API.templateVersionDynamicParameters( + me.id, + template.active_version_id, + { + onMessage, + onError: (error) => { + setWsError(error); + }, + onClose: () => { + // There is no reason for the websocket to close while a user is on the page + setWsError( + new DetailedError( + "Websocket connection for dynamic parameters unexpectedly closed.", + "Refresh the page to reset the form.", + ), + ); + }, + }, + ); + + ws.current = socket; + + return () => { + socket.close(); + }; + }, [me?.id, template.active_version_id, onMessage, isAutoMode]); + + // Function to send messages to websocket + const sendMessage = useCallback((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; + }); + }, []); + + // Initialize button values when parameters are loaded + useEffect(() => { + if (currentResponse?.parameters && !buttonValues) { + const mode = searchParams.get("mode") || "manual"; + const initValues: ButtonValues = { + mode, + }; + + // Filter only parameters used for workspace creation + const workspaceParams = currentResponse.parameters.filter(param => !param.ephemeral); + + // Apply autofill parameters from URL if available + for (const parameter of workspaceParams) { + const autofillParam = autofillParameters.find(p => p.name === parameter.name); + + if (autofillParam) { + // Use the value from URL parameters + initValues[`param.${parameter.name}`] = autofillParam.value; + } else { + // Use the default or current value from the parameter + const paramValue = parameter.value.valid + ? parameter.value.value + : (parameter.default_value.valid ? parameter.default_value.value : ""); + + initValues[`param.${parameter.name}`] = paramValue; + } + } + + setButtonValues(initValues); + + // Send initial message to get updated parameters based on autofill values + if (workspaceParams.length > 0) { + const paramInputs: Record = {}; + + for (const param of workspaceParams) { + const autofillParam = autofillParameters.find(p => p.name === param.name); + + if (autofillParam) { + paramInputs[param.name] = autofillParam.value; + } else { + paramInputs[param.name] = param.value.valid + ? param.value.value + : (param.default_value.valid ? param.default_value.value : ""); + } + } + + sendMessage(paramInputs); + } + } + }, [currentResponse, buttonValues, searchParams, autofillParameters, sendMessage]); + + // When no WebSocket connection is needed (auto mode), initialize buttonValues directly + useEffect(() => { + if (isAutoMode && !buttonValues && me) { + const initValues: ButtonValues = { + mode: "auto", + }; + + // Add autofill parameters to button values + for (const param of autofillParameters) { + initValues[`param.${param.name}`] = param.value; + } + + setButtonValues(initValues); + + // No need to set currentResponse as we're not using the WebSocket in auto mode + } + }, [isAutoMode, buttonValues, me, autofillParameters]); + + const isLoading = (!buttonValues || (!currentResponse && !isAutoMode)); + + return ( + <> + {isLoading ? ( + + ) : ( +
+
+ {wsError && ( +
+ Error: {wsError.message} +
+ )} + + + { + setButtonValues((buttonValues) => ({ + ...buttonValues, + mode: v, + })); + }} + > + } + label="Manual" + /> + } + label="Automatic" + /> + + + + {currentResponse?.parameters && ( + + )} + +
+ + +
+ )} + + ); +}; + +interface ParametersListProps { + parameters: PreviewParameter[]; + buttonValues: ButtonValues; + setButtonValues: (values: ButtonValues | ((prev: ButtonValues) => ButtonValues)) => void; + sendMessage: (values: Record) => void; + autofillParameters: AutofillBuildParameter[]; +} + +const ParametersList: FC = ({ + parameters, + buttonValues, + setButtonValues, + sendMessage, + autofillParameters, +}) => { + // Filter parameters to only include those used for workspace creation + const workspaceParameters = parameters.filter(param => !param.ephemeral); + + if (workspaceParameters.length === 0) { + return null; + } + + // Handle parameter change + const handleParameterChange = (paramName: string, value: string) => { + // Update button values + setButtonValues((prev) => ({ + ...prev, + [`param.${paramName}`]: value, + })); + + // Send updated parameters to the server + const paramValues: Record = {}; + for (const param of workspaceParameters) { + if (param.name === paramName) { + paramValues[param.name] = value; + } else { + const paramKey = `param.${param.name}`; + paramValues[param.name] = buttonValues[paramKey] || ""; + } + } + sendMessage(paramValues); + }; + + return ( +
+ {workspaceParameters.map((parameter) => { + const autofillParam = autofillParameters.find(p => p.name === parameter.name); + const isAutofilled = !!autofillParam; + + return ( + handleParameterChange(parameter.name, value)} + disabled={isAutofilled} + /> + ); + })} +
+ ); +}; + +interface ButtonPreviewProps { + template: Template; + buttonValues: ButtonValues | undefined; +} + +const ButtonPreview: FC = ({ template, buttonValues }) => { + const clipboard = useClipboard({ + textToCopy: getClipboardCopyContent( + template.name, + template.organization_name, + buttonValues, + ), + }); + + return ( +
({ + // 80px for padding, 36px is for the status bar. We want to use `vh` + // so that it will be relative to the screen and not the parent layout. + height: "calc(100vh - (80px + 36px))", + top: 40, + position: "sticky", + display: "flex", + padding: 64, + flex: 1, + alignItems: "center", + justifyContent: "center", + borderRadius: 8, + backgroundColor: theme.palette.background.paper, + border: `1px solid ${theme.palette.divider}`, + })} + > + Open in Coder button +
+ +
+
+ ); +}; + +function getClipboardCopyContent( + templateName: string, + organization: string, + buttonValues: ButtonValues | undefined, +): string { + const deploymentUrl = `${window.location.protocol}//${window.location.host}`; + const createWorkspaceUrl = `${deploymentUrl}/templates/${organization}/${templateName}/workspace`; + const createWorkspaceParams = new URLSearchParams(buttonValues); + const buttonUrl = `${createWorkspaceUrl}?${createWorkspaceParams.toString()}`; + + return `[![Open in Coder](${deploymentUrl}/open-in-coder.svg)](${buttonUrl})`; +} + +// Function is now imported from utils/richParameters.ts + +export default TemplateEmbedPageExperimental; \ No newline at end of file diff --git a/site/src/router.tsx b/site/src/router.tsx index ad9c295f398e7..0a2c9e09e7558 100644 --- a/site/src/router.tsx +++ b/site/src/router.tsx @@ -273,8 +273,8 @@ const ProvisionersPage = lazy( "./pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPage" ), ); -const TemplateEmbedPage = lazy( - () => import("./pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage"), +const TemplateEmbedExperimentRouter = lazy( + () => import("./pages/TemplatePage/TemplateEmbedPage/TemplateEmbedExperimentRouter"), ); const TemplateInsightsPage = lazy( () => @@ -346,7 +346,7 @@ const templateRouter = () => { } /> } /> } /> - } /> + } /> } /> diff --git a/site/src/utils/richParameters.ts b/site/src/utils/richParameters.ts index 6bf62624067b2..0b184e8f63159 100644 --- a/site/src/utils/richParameters.ts +++ b/site/src/utils/richParameters.ts @@ -12,6 +12,19 @@ export type AutofillBuildParameter = { source: AutofillSource; } & WorkspaceBuildParameter; +// Gets autofill parameters from URL search params +export const getAutofillParameters = ( + urlSearchParams: URLSearchParams, +): AutofillBuildParameter[] => { + return Array.from(urlSearchParams.keys()) + .filter((key) => key.startsWith("param.")) + .map((key) => { + const name = key.replace("param.", ""); + const value = urlSearchParams.get(key) ?? ""; + return { name, value, source: "url" }; + }); +}; + export const getInitialRichParameterValues = ( templateParams: TemplateVersionParameter[], autofillParams?: AutofillBuildParameter[], From ae7cf11a2ff86725ff45a0aece6ee5e14703604f Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Sun, 27 Apr 2025 11:39:46 +0000 Subject: [PATCH 02/10] fix: fix types --- .../TemplateEmbedPageExperimental.tsx | 66 ++++++++++--------- 1 file changed, 35 insertions(+), 31 deletions(-) diff --git a/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPageExperimental.tsx b/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPageExperimental.tsx index e3680dab886a8..dff17419577ac 100644 --- a/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPageExperimental.tsx +++ b/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPageExperimental.tsx @@ -6,11 +6,11 @@ import Radio from "@mui/material/Radio"; import RadioGroup from "@mui/material/RadioGroup"; import { API } from "api/api"; import { DetailedError } from "api/errors"; -import type { - DynamicParametersRequest, - DynamicParametersResponse, - PreviewParameter, - Template +import type { + DynamicParametersRequest, + DynamicParametersResponse, + PreviewParameter, + Template } from "api/typesGenerated"; import { FormSection, VerticalForm } from "components/Form/Form"; import { Loader } from "components/Loader/Loader"; @@ -18,18 +18,22 @@ import { useClipboard } from "hooks/useClipboard"; import { DynamicParameter } from "modules/workspaces/DynamicParameter/DynamicParameter"; import { useTemplateLayoutContext } from "pages/TemplatePage/TemplateLayout"; import { type FC, useCallback, useEffect, useRef, useState } from "react"; +import React from "react"; import { Helmet } from "react-helmet-async"; import { useQuery } from "react-query"; import { useSearchParams } from "react-router-dom"; import { pageTitle } from "utils/page"; -import { getAutofillParameters } from "utils/richParameters"; +import { getAutofillParameters, type AutofillBuildParameter as ImportedAutofillBuildParameter } from "utils/richParameters"; type ButtonValues = Record; +// Use the imported type instead of redefining it +type AutofillBuildParameter = ImportedAutofillBuildParameter; + const TemplateEmbedPageExperimental: FC = () => { const { template } = useTemplateLayoutContext(); const [searchParams] = useSearchParams(); - + return ( <> @@ -45,7 +49,7 @@ interface TemplateEmbedPageViewProps { searchParams: URLSearchParams; } -const TemplateEmbedPageView: FC = ({ +const TemplateEmbedPageView: FC = ({ template, searchParams }) => { @@ -54,7 +58,7 @@ const TemplateEmbedPageView: FC = ({ const ws = useRef(null); const [wsError, setWsError] = useState(null); const [buttonValues, setButtonValues] = useState(); - + // Get the current user const { data: me } = useQuery({ queryKey: ["me"], @@ -136,45 +140,45 @@ const TemplateEmbedPageView: FC = ({ const initValues: ButtonValues = { mode, }; - + // Filter only parameters used for workspace creation const workspaceParams = currentResponse.parameters.filter(param => !param.ephemeral); - + // Apply autofill parameters from URL if available for (const parameter of workspaceParams) { const autofillParam = autofillParameters.find(p => p.name === parameter.name); - + if (autofillParam) { // Use the value from URL parameters initValues[`param.${parameter.name}`] = autofillParam.value; } else { // Use the default or current value from the parameter - const paramValue = parameter.value.valid - ? parameter.value.value + const paramValue = parameter.value.valid + ? parameter.value.value : (parameter.default_value.valid ? parameter.default_value.value : ""); - + initValues[`param.${parameter.name}`] = paramValue; } } - + setButtonValues(initValues); - + // Send initial message to get updated parameters based on autofill values if (workspaceParams.length > 0) { const paramInputs: Record = {}; - + for (const param of workspaceParams) { const autofillParam = autofillParameters.find(p => p.name === param.name); - + if (autofillParam) { paramInputs[param.name] = autofillParam.value; } else { - paramInputs[param.name] = param.value.valid - ? param.value.value + paramInputs[param.name] = param.value.valid + ? param.value.value : (param.default_value.valid ? param.default_value.value : ""); } } - + sendMessage(paramInputs); } } @@ -240,7 +244,7 @@ const TemplateEmbedPageView: FC = ({ {currentResponse?.parameters && ( - = ({ - @@ -262,9 +266,9 @@ const TemplateEmbedPageView: FC = ({ }; interface ParametersListProps { - parameters: PreviewParameter[]; + parameters: readonly PreviewParameter[]; buttonValues: ButtonValues; - setButtonValues: (values: ButtonValues | ((prev: ButtonValues) => ButtonValues)) => void; + setButtonValues: React.Dispatch>; sendMessage: (values: Record) => void; autofillParameters: AutofillBuildParameter[]; } @@ -278,7 +282,7 @@ const ParametersList: FC = ({ }) => { // Filter parameters to only include those used for workspace creation const workspaceParameters = parameters.filter(param => !param.ephemeral); - + if (workspaceParameters.length === 0) { return null; } @@ -287,10 +291,10 @@ const ParametersList: FC = ({ const handleParameterChange = (paramName: string, value: string) => { // Update button values setButtonValues((prev) => ({ - ...prev, + ...prev || {}, [`param.${paramName}`]: value, })); - + // Send updated parameters to the server const paramValues: Record = {}; for (const param of workspaceParameters) { @@ -309,7 +313,7 @@ const ParametersList: FC = ({ {workspaceParameters.map((parameter) => { const autofillParam = autofillParameters.find(p => p.name === parameter.name); const isAutofilled = !!autofillParam; - + return ( Date: Thu, 22 May 2025 20:18:26 +0000 Subject: [PATCH 03/10] fix: cleanup --- .../TemplateEmbedPage/TemplateEmbedPageExperimental.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPageExperimental.tsx b/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPageExperimental.tsx index dff17419577ac..f4a84b25741fa 100644 --- a/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPageExperimental.tsx +++ b/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPageExperimental.tsx @@ -18,7 +18,7 @@ import { useClipboard } from "hooks/useClipboard"; import { DynamicParameter } from "modules/workspaces/DynamicParameter/DynamicParameter"; import { useTemplateLayoutContext } from "pages/TemplatePage/TemplateLayout"; import { type FC, useCallback, useEffect, useRef, useState } from "react"; -import React from "react"; +import type React from "react"; import { Helmet } from "react-helmet-async"; import { useQuery } from "react-query"; import { useSearchParams } from "react-router-dom"; @@ -209,10 +209,10 @@ const TemplateEmbedPageView: FC = ({ {isLoading ? ( ) : ( -
-
+
+
{wsError && ( -
+
Error: {wsError.message}
)} From 793aaa2037cfe339ac961a3749bf0df1a813e8b5 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 22 May 2025 22:45:50 +0000 Subject: [PATCH 04/10] chore: cleanup --- .../DynamicParameter/DynamicParameter.tsx | 3 +- .../CreateWorkspacePageViewExperimental.tsx | 2 +- .../TemplateEmbedExperimentRouter.tsx | 16 +- .../TemplateEmbedPageExperimental.tsx | 637 ++++++++---------- site/src/router.tsx | 5 +- 5 files changed, 280 insertions(+), 383 deletions(-) diff --git a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx index 9f97d558c8f08..ec04bd7ea8e09 100644 --- a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx +++ b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx @@ -52,7 +52,7 @@ interface DynamicParameterProps { onChange: (value: string) => void; disabled?: boolean; isPreset?: boolean; - autofill: boolean; + autofill?: boolean; } export const DynamicParameter: FC = ({ @@ -873,7 +873,6 @@ interface DiagnosticsProps { diagnostics: PreviewParameter["diagnostics"]; } -// Displays a diagnostic with a border, icon and background color export const Diagnostics: FC = ({ diagnostics }) => { return (
diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx index 3522d24012445..ef657c3fa297c 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx @@ -29,8 +29,8 @@ import { type FormikContextType, useFormik } from "formik"; import type { ExternalAuthPollingState } from "hooks/useExternalAuth"; import { ArrowLeft, CircleHelp } from "lucide-react"; import { useSyncFormParameters } from "modules/hooks/useSyncFormParameters"; -import { Diagnostics } from "modules/workspaces/DynamicParameter/DynamicParameter"; import { + Diagnostics, DynamicParameter, getInitialParameterValues, useValidationSchemaForDynamicParameters, diff --git a/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedExperimentRouter.tsx b/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedExperimentRouter.tsx index 78f8bb3a4b7e0..13a2ce48affbd 100644 --- a/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedExperimentRouter.tsx +++ b/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedExperimentRouter.tsx @@ -5,18 +5,18 @@ import TemplateEmbedPageExperimental from "./TemplateEmbedPageExperimental"; // Similar context as in CreateWorkspaceExperimentRouter for maintaining consistency export const ExperimentalFormContext = createContext< - { toggleOptedOut: () => void } | undefined + { toggleOptedOut: () => void } | undefined >(undefined); const TemplateEmbedExperimentRouter: FC = () => { - const { experiments } = useDashboard(); - const dynamicParametersEnabled = experiments.includes("dynamic-parameters"); + const { experiments } = useDashboard(); + const dynamicParametersEnabled = experiments.includes("dynamic-parameters"); - if (dynamicParametersEnabled) { - return ; - } + if (dynamicParametersEnabled) { + return ; + } - return ; + return ; }; -export default TemplateEmbedExperimentRouter; \ No newline at end of file +export default TemplateEmbedExperimentRouter; diff --git a/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPageExperimental.tsx b/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPageExperimental.tsx index f4a84b25741fa..3901222535573 100644 --- a/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPageExperimental.tsx +++ b/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPageExperimental.tsx @@ -1,409 +1,304 @@ import CheckOutlined from "@mui/icons-material/CheckOutlined"; import FileCopyOutlined from "@mui/icons-material/FileCopyOutlined"; -import Button from "@mui/material/Button"; import FormControlLabel from "@mui/material/FormControlLabel"; import Radio from "@mui/material/Radio"; import RadioGroup from "@mui/material/RadioGroup"; import { API } from "api/api"; import { DetailedError } from "api/errors"; import type { - DynamicParametersRequest, - DynamicParametersResponse, - PreviewParameter, - Template + DynamicParametersRequest, + DynamicParametersResponse, + FriendlyDiagnostic, + PreviewParameter, + Template, + User, } from "api/typesGenerated"; +import { ErrorAlert } from "components/Alert/ErrorAlert"; +import { Button } from "components/Button/Button"; import { FormSection, VerticalForm } from "components/Form/Form"; import { Loader } from "components/Loader/Loader"; +import { useEffectEvent } from "hooks/hookPolyfills"; import { useClipboard } from "hooks/useClipboard"; -import { DynamicParameter } from "modules/workspaces/DynamicParameter/DynamicParameter"; +import { + Diagnostics, + DynamicParameter, +} from "modules/workspaces/DynamicParameter/DynamicParameter"; import { useTemplateLayoutContext } from "pages/TemplatePage/TemplateLayout"; -import { type FC, useCallback, useEffect, useRef, useState } from "react"; -import type React from "react"; +import { + type FC, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { Helmet } from "react-helmet-async"; import { useQuery } from "react-query"; -import { useSearchParams } from "react-router-dom"; import { pageTitle } from "utils/page"; -import { getAutofillParameters, type AutofillBuildParameter as ImportedAutofillBuildParameter } from "utils/richParameters"; type ButtonValues = Record; -// Use the imported type instead of redefining it -type AutofillBuildParameter = ImportedAutofillBuildParameter; - const TemplateEmbedPageExperimental: FC = () => { - const { template } = useTemplateLayoutContext(); - const [searchParams] = useSearchParams(); - - return ( - <> - - {pageTitle(template.name)} - - - - ); + const { template } = useTemplateLayoutContext(); + const [latestResponse, setLatestResponse] = + useState(null); + const wsResponseId = useRef(-1); + const ws = useRef(null); + const [wsError, setWsError] = useState(null); + + const { data: authenticatedUser } = useQuery({ + queryKey: ["authenticatedUser"], + queryFn: () => API.getAuthenticatedUser(), + }); + + const sendMessage = useCallback((formValues: Record) => { + const request: DynamicParametersRequest = { + id: wsResponseId.current + 1, + inputs: formValues, + }; + if (ws.current && ws.current.readyState === WebSocket.OPEN) { + ws.current.send(JSON.stringify(request)); + wsResponseId.current = wsResponseId.current + 1; + } + }, []); + + const onMessage = useEffectEvent((response: DynamicParametersResponse) => { + if (latestResponse && latestResponse?.id >= response.id) { + return; + } + + setLatestResponse(response); + }); + + useEffect(() => { + if (!template.active_version_id || !authenticatedUser) { + return; + } + + const socket = API.templateVersionDynamicParameters( + authenticatedUser.id, + template.active_version_id, + { + onMessage, + onError: (error) => { + setWsError(error); + }, + onClose: () => { + setWsError( + new DetailedError( + "Websocket connection for dynamic parameters unexpectedly closed.", + "Refresh the page to reset the form.", + ), + ); + }, + }, + ); + + ws.current = socket; + + return () => { + socket.close(); + }; + }, [authenticatedUser, template.active_version_id, onMessage]); + + const sortedParams = useMemo(() => { + if (!latestResponse?.parameters) { + return []; + } + return [...latestResponse.parameters].sort((a, b) => a.order - b.order); + }, [latestResponse?.parameters]); + + return ( + <> + + {pageTitle(template.name)} + + + + ); }; interface TemplateEmbedPageViewProps { - template: Template; - searchParams: URLSearchParams; + template: Template; + parameters: PreviewParameter[]; + diagnostics: readonly FriendlyDiagnostic[]; + error: unknown; + sendMessage: (message: Record) => void; } const TemplateEmbedPageView: FC = ({ - template, - searchParams + template, + parameters, + diagnostics, + error, + sendMessage, }) => { - const [currentResponse, setCurrentResponse] = useState(null); - const [wsResponseId, setWSResponseId] = useState(-1); - const ws = useRef(null); - const [wsError, setWsError] = useState(null); - const [buttonValues, setButtonValues] = useState(); - - // Get the current user - const { data: me } = useQuery({ - queryKey: ["me"], - queryFn: () => API.getAuthenticatedUser(), - }); - - // Check if workspace should be auto-created - const isAutoMode = searchParams.get("mode") === "auto"; - - // Parse autofill parameters from URL - const autofillParameters = searchParams ? getAutofillParameters(searchParams) : []; - - const onMessage = useCallback((response: DynamicParametersResponse) => { - setCurrentResponse((prev) => { - if (prev?.id === response.id) { - return prev; - } - return response; - }); - }, []); - - // Initialize the WebSocket connection when component mounts - useEffect(() => { - if (!me?.id || !template.active_version_id) { - return; - } - - // If mode=auto and workspace will be auto-created, no need for WebSocket - if (isAutoMode) { - return; - } - - const socket = API.templateVersionDynamicParameters( - me.id, - template.active_version_id, - { - onMessage, - onError: (error) => { - setWsError(error); - }, - onClose: () => { - // There is no reason for the websocket to close while a user is on the page - setWsError( - new DetailedError( - "Websocket connection for dynamic parameters unexpectedly closed.", - "Refresh the page to reset the form.", - ), - ); - }, - }, - ); - - ws.current = socket; - - return () => { - socket.close(); - }; - }, [me?.id, template.active_version_id, onMessage, isAutoMode]); - - // Function to send messages to websocket - const sendMessage = useCallback((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; - }); - }, []); - - // Initialize button values when parameters are loaded - useEffect(() => { - if (currentResponse?.parameters && !buttonValues) { - const mode = searchParams.get("mode") || "manual"; - const initValues: ButtonValues = { - mode, - }; - - // Filter only parameters used for workspace creation - const workspaceParams = currentResponse.parameters.filter(param => !param.ephemeral); - - // Apply autofill parameters from URL if available - for (const parameter of workspaceParams) { - const autofillParam = autofillParameters.find(p => p.name === parameter.name); - - if (autofillParam) { - // Use the value from URL parameters - initValues[`param.${parameter.name}`] = autofillParam.value; - } else { - // Use the default or current value from the parameter - const paramValue = parameter.value.valid - ? parameter.value.value - : (parameter.default_value.valid ? parameter.default_value.value : ""); - - initValues[`param.${parameter.name}`] = paramValue; - } - } - - setButtonValues(initValues); - - // Send initial message to get updated parameters based on autofill values - if (workspaceParams.length > 0) { - const paramInputs: Record = {}; - - for (const param of workspaceParams) { - const autofillParam = autofillParameters.find(p => p.name === param.name); - - if (autofillParam) { - paramInputs[param.name] = autofillParam.value; - } else { - paramInputs[param.name] = param.value.valid - ? param.value.value - : (param.default_value.valid ? param.default_value.value : ""); - } - } - - sendMessage(paramInputs); - } - } - }, [currentResponse, buttonValues, searchParams, autofillParameters, sendMessage]); - - // When no WebSocket connection is needed (auto mode), initialize buttonValues directly - useEffect(() => { - if (isAutoMode && !buttonValues && me) { - const initValues: ButtonValues = { - mode: "auto", - }; - - // Add autofill parameters to button values - for (const param of autofillParameters) { - initValues[`param.${param.name}`] = param.value; - } - - setButtonValues(initValues); - - // No need to set currentResponse as we're not using the WebSocket in auto mode - } - }, [isAutoMode, buttonValues, me, autofillParameters]); - - const isLoading = (!buttonValues || (!currentResponse && !isAutoMode)); - - return ( - <> - {isLoading ? ( - - ) : ( -
-
- {wsError && ( -
- Error: {wsError.message} -
- )} - - - { - setButtonValues((buttonValues) => ({ - ...buttonValues, - mode: v, - })); - }} - > - } - label="Manual" - /> - } - label="Automatic" - /> - - - - {currentResponse?.parameters && ( - - )} - -
- - -
- )} - - ); + const [buttonValues, setButtonValues] = useState(); + const [localParameters, setLocalParameters] = useState< + Record + >({}); + + useEffect(() => { + if (parameters) { + const initialInputs: Record = {}; + const currentMode = buttonValues?.mode || "manual"; + const initialButtonParamValues: ButtonValues = { mode: currentMode }; + + for (const p of parameters) { + const initialVal = p.value?.valid ? p.value.value : ""; + initialInputs[p.name] = initialVal; + initialButtonParamValues[`param.${p.name}`] = initialVal; + } + setLocalParameters(initialInputs); + + setButtonValues(initialButtonParamValues); + } + }, [parameters, buttonValues?.mode]); + + const handleChange = ( + changedParamInfo: PreviewParameter, + newValue: string, + ) => { + const newFormInputs = { + ...localParameters, + [changedParamInfo.name]: newValue, + }; + setLocalParameters(newFormInputs); + + setButtonValues((prevButtonValues) => ({ + ...(prevButtonValues || {}), + [`param.${changedParamInfo.name}`]: newValue, + })); + + const formInputsToSend: Record = { ...newFormInputs }; + for (const p of parameters) { + if (!(p.name in formInputsToSend)) { + formInputsToSend[p.name] = p.value?.valid ? p.value.value : ""; + } + } + + sendMessage(formInputsToSend); + }; + + useEffect(() => { + if (!buttonValues && parameters.length === 0) { + setButtonValues({ mode: "manual" }); + } else if (buttonValues && !buttonValues.mode && parameters.length > 0) { + setButtonValues((prev) => ({ ...prev, mode: "manual" })); + } + }, [buttonValues, parameters]); + + if (!buttonValues || (!parameters && !error)) { + return ; + } + + return ( + <> +
+
+ {Boolean(error) && } + {diagnostics.length > 0 && } + + + { + setButtonValues((prevButtonValues) => ({ + ...(prevButtonValues || {}), + mode: v, + })); + }} + > + } + label="Manual" + /> + } + label="Automatic" + /> + + + + {parameters.length > 0 && ( +
+ {parameters.map((parameter) => { + const isDisabled = parameter.styling?.disabled; + return ( + handleChange(parameter, value)} + disabled={isDisabled} + value={localParameters[parameter.name] || ""} + /> + ); + })} +
+ )} +
+
+ + +
+ + ); }; -interface ParametersListProps { - parameters: readonly PreviewParameter[]; - buttonValues: ButtonValues; - setButtonValues: React.Dispatch>; - sendMessage: (values: Record) => void; - autofillParameters: AutofillBuildParameter[]; -} +function getClipboardCopyContent( + templateName: string, + organization: string, + buttonValues: ButtonValues | undefined, +): string { + const deploymentUrl = `${window.location.protocol}//${window.location.host}`; + const createWorkspaceUrl = `${deploymentUrl}/templates/${organization}/${templateName}/workspace`; + const createWorkspaceParams = new URLSearchParams(buttonValues); + const buttonUrl = `${createWorkspaceUrl}?${createWorkspaceParams.toString()}`; -const ParametersList: FC = ({ - parameters, - buttonValues, - setButtonValues, - sendMessage, - autofillParameters, -}) => { - // Filter parameters to only include those used for workspace creation - const workspaceParameters = parameters.filter(param => !param.ephemeral); - - if (workspaceParameters.length === 0) { - return null; - } - - // Handle parameter change - const handleParameterChange = (paramName: string, value: string) => { - // Update button values - setButtonValues((prev) => ({ - ...prev || {}, - [`param.${paramName}`]: value, - })); - - // Send updated parameters to the server - const paramValues: Record = {}; - for (const param of workspaceParameters) { - if (param.name === paramName) { - paramValues[param.name] = value; - } else { - const paramKey = `param.${param.name}`; - paramValues[param.name] = buttonValues[paramKey] || ""; - } - } - sendMessage(paramValues); - }; - - return ( -
- {workspaceParameters.map((parameter) => { - const autofillParam = autofillParameters.find(p => p.name === parameter.name); - const isAutofilled = !!autofillParam; - - return ( - handleParameterChange(parameter.name, value)} - disabled={isAutofilled} - /> - ); - })} -
- ); -}; + return `[![Open in Coder](${deploymentUrl}/open-in-coder.svg)](${buttonUrl})`; +} interface ButtonPreviewProps { - template: Template; - buttonValues: ButtonValues | undefined; + template: Template; + buttonValues: ButtonValues | undefined; } const ButtonPreview: FC = ({ template, buttonValues }) => { - const clipboard = useClipboard({ - textToCopy: getClipboardCopyContent( - template.name, - template.organization_name, - buttonValues, - ), - }); - - return ( -
({ - // 80px for padding, 36px is for the status bar. We want to use `vh` - // so that it will be relative to the screen and not the parent layout. - height: "calc(100vh - (80px + 36px))", - top: 40, - position: "sticky", - display: "flex", - padding: 64, - flex: 1, - alignItems: "center", - justifyContent: "center", - borderRadius: 8, - backgroundColor: theme.palette.background.paper, - border: `1px solid ${theme.palette.divider}`, - })} - > - Open in Coder button -
- -
-
- ); + const clipboard = useClipboard({ + textToCopy: getClipboardCopyContent( + template.name, + template.organization_name, + buttonValues, + ), + }); + + return ( +
+ Open in Coder button + +
+ ); }; -function getClipboardCopyContent( - templateName: string, - organization: string, - buttonValues: ButtonValues | undefined, -): string { - const deploymentUrl = `${window.location.protocol}//${window.location.host}`; - const createWorkspaceUrl = `${deploymentUrl}/templates/${organization}/${templateName}/workspace`; - const createWorkspaceParams = new URLSearchParams(buttonValues); - const buttonUrl = `${createWorkspaceUrl}?${createWorkspaceParams.toString()}`; - - return `[![Open in Coder](${deploymentUrl}/open-in-coder.svg)](${buttonUrl})`; -} - -// Function is now imported from utils/richParameters.ts - export default TemplateEmbedPageExperimental; diff --git a/site/src/router.tsx b/site/src/router.tsx index 0a2c9e09e7558..27163b63eb426 100644 --- a/site/src/router.tsx +++ b/site/src/router.tsx @@ -274,7 +274,10 @@ const ProvisionersPage = lazy( ), ); const TemplateEmbedExperimentRouter = lazy( - () => import("./pages/TemplatePage/TemplateEmbedPage/TemplateEmbedExperimentRouter"), + () => + import( + "./pages/TemplatePage/TemplateEmbedPage/TemplateEmbedExperimentRouter" + ), ); const TemplateInsightsPage = lazy( () => From a02e6df921c8bd1faba5c9d9a2b07d8981fe2e9c Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 22 May 2025 22:51:41 +0000 Subject: [PATCH 05/10] fix: fix commits --- site/src/utils/richParameters.ts | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/site/src/utils/richParameters.ts b/site/src/utils/richParameters.ts index 0b184e8f63159..6bf62624067b2 100644 --- a/site/src/utils/richParameters.ts +++ b/site/src/utils/richParameters.ts @@ -12,19 +12,6 @@ export type AutofillBuildParameter = { source: AutofillSource; } & WorkspaceBuildParameter; -// Gets autofill parameters from URL search params -export const getAutofillParameters = ( - urlSearchParams: URLSearchParams, -): AutofillBuildParameter[] => { - return Array.from(urlSearchParams.keys()) - .filter((key) => key.startsWith("param.")) - .map((key) => { - const name = key.replace("param.", ""); - const value = urlSearchParams.get(key) ?? ""; - return { name, value, source: "url" }; - }); -}; - export const getInitialRichParameterValues = ( templateParams: TemplateVersionParameter[], autofillParams?: AutofillBuildParameter[], From 62b54d520be4fb85ef80c43d91f9680fd69ace27 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 22 May 2025 23:12:28 +0000 Subject: [PATCH 06/10] chore: cleanup --- .../TemplateEmbedExperimentRouter.tsx | 84 +++++++++++++++++-- .../TemplateEmbedPage/TemplateEmbedPage.tsx | 20 ++++- .../TemplateEmbedPageExperimental.tsx | 22 ++++- 3 files changed, 112 insertions(+), 14 deletions(-) diff --git a/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedExperimentRouter.tsx b/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedExperimentRouter.tsx index 13a2ce48affbd..9b2343d77ac71 100644 --- a/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedExperimentRouter.tsx +++ b/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedExperimentRouter.tsx @@ -1,22 +1,92 @@ +import { templateByName } from "api/queries/templates"; +import { ErrorAlert } from "components/Alert/ErrorAlert"; +import { Loader } from "components/Loader/Loader"; import { useDashboard } from "modules/dashboard/useDashboard"; -import { type FC, createContext } from "react"; +import { ExperimentalFormContext } from "pages/CreateWorkspacePage/ExperimentalFormContext"; +import type { FC } from "react"; +import { useQuery } from "react-query"; +import { useParams } from "react-router-dom"; import TemplateEmbedPage from "./TemplateEmbedPage"; import TemplateEmbedPageExperimental from "./TemplateEmbedPageExperimental"; -// Similar context as in CreateWorkspaceExperimentRouter for maintaining consistency -export const ExperimentalFormContext = createContext< - { toggleOptedOut: () => void } | undefined ->(undefined); - const TemplateEmbedExperimentRouter: FC = () => { const { experiments } = useDashboard(); const dynamicParametersEnabled = experiments.includes("dynamic-parameters"); + const { organization: organizationName = "default", template: templateName } = + useParams() as { organization?: string; template: string }; + const templateQuery = useQuery( + dynamicParametersEnabled + ? templateByName(organizationName, templateName) + : { enabled: false }, + ); + + const optOutQuery = useQuery( + templateQuery.data + ? { + queryKey: [ + organizationName, + "template", + templateQuery.data.id, + "optOut", + ], + queryFn: () => { + const templateId = templateQuery.data.id; + const localStorageKey = optOutKey(templateId); + const storedOptOutString = localStorage.getItem(localStorageKey); + + let optOutResult: boolean; + + if (storedOptOutString !== null) { + optOutResult = storedOptOutString === "true"; + } else { + optOutResult = Boolean( + templateQuery.data.use_classic_parameter_flow, + ); + } + + return { + templateId: templateId, + optedOut: optOutResult, + }; + }, + } + : { enabled: false }, + ); + if (dynamicParametersEnabled) { - return ; + if (optOutQuery.isLoading) { + return ; + } + if (!optOutQuery.data) { + return ; + } + + const toggleOptedOut = () => { + const key = optOutKey(optOutQuery.data.templateId); + const storedValue = localStorage.getItem(key); + + const current = storedValue + ? storedValue === "true" + : Boolean(templateQuery.data?.use_classic_parameter_flow); + + localStorage.setItem(key, (!current).toString()); + optOutQuery.refetch(); + }; + return ( + + {optOutQuery.data.optedOut ? ( + + ) : ( + + )} + + ); } return ; }; export default TemplateEmbedExperimentRouter; + +const optOutKey = (id: string) => `parameters.${id}.optOut`; diff --git a/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.tsx b/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.tsx index 74295ed63cf72..497af61144234 100644 --- a/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.tsx +++ b/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.tsx @@ -4,18 +4,20 @@ import Radio from "@mui/material/Radio"; import RadioGroup from "@mui/material/RadioGroup"; import { API } from "api/api"; import type { Template, TemplateVersionParameter } from "api/typesGenerated"; +import { Button as ShadcnButton } from "components/Button/Button"; import { FormSection, VerticalForm } from "components/Form/Form"; import { Loader } from "components/Loader/Loader"; import { RichParameterInput } from "components/RichParameterInput/RichParameterInput"; import { useClipboard } from "hooks/useClipboard"; import { CheckIcon, CopyIcon } from "lucide-react"; import { useTemplateLayoutContext } from "pages/TemplatePage/TemplateLayout"; -import { type FC, useEffect, useState } from "react"; +import { type FC, useContext, useEffect, useState } from "react"; import { Helmet } from "react-helmet-async"; import { useQuery } from "react-query"; import { pageTitle } from "utils/page"; import { getInitialRichParameterValues } from "utils/richParameters"; import { paramsUsedToCreateWorkspace } from "utils/workspace"; +import { ExperimentalFormContext } from "../../CreateWorkspacePage/ExperimentalFormContext"; type ButtonValues = Record; @@ -64,6 +66,7 @@ export const TemplateEmbedPageView: FC = ({ template, templateParameters, }) => { + const experimentalFormContext = useContext(ExperimentalFormContext); const [buttonValues, setButtonValues] = useState(); const clipboard = useClipboard({ textToCopy: getClipboardCopyContent( @@ -97,8 +100,19 @@ export const TemplateEmbedPageView: FC = ({ {!buttonValues || !templateParameters ? ( ) : ( -
-
+
+
+ {experimentalFormContext && ( +
+ + Try out the new workspace creation flow ✨ + +
+ )} = ({ error, sendMessage, }) => { + const experimentalFormContext = useContext(ExperimentalFormContext); const [buttonValues, setButtonValues] = useState(); const [localParameters, setLocalParameters] = useState< Record @@ -201,10 +204,21 @@ const TemplateEmbedPageView: FC = ({ return ( <>
-
+
+ {experimentalFormContext && ( +
+ +
+ )} {Boolean(error) && } {diagnostics.length > 0 && } - +
= ({ })}
)} -
+
From 1caf277e4dac5c3ce43d3d41fce729105ec9fc01 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Fri, 20 Jun 2025 12:56:02 +0000 Subject: [PATCH 07/10] fix: remove usages of experimentalFormContext --- .../TemplateEmbedExperimentRouter.tsx | 87 ++++--------------- .../TemplateEmbedPage/TemplateEmbedPage.tsx | 16 +--- .../TemplateEmbedPageExperimental.tsx | 14 --- 3 files changed, 16 insertions(+), 101 deletions(-) diff --git a/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedExperimentRouter.tsx b/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedExperimentRouter.tsx index 9b2343d77ac71..85dd2e39b5452 100644 --- a/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedExperimentRouter.tsx +++ b/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedExperimentRouter.tsx @@ -1,8 +1,6 @@ import { templateByName } from "api/queries/templates"; import { ErrorAlert } from "components/Alert/ErrorAlert"; import { Loader } from "components/Loader/Loader"; -import { useDashboard } from "modules/dashboard/useDashboard"; -import { ExperimentalFormContext } from "pages/CreateWorkspacePage/ExperimentalFormContext"; import type { FC } from "react"; import { useQuery } from "react-query"; import { useParams } from "react-router-dom"; @@ -10,83 +8,28 @@ import TemplateEmbedPage from "./TemplateEmbedPage"; import TemplateEmbedPageExperimental from "./TemplateEmbedPageExperimental"; const TemplateEmbedExperimentRouter: FC = () => { - const { experiments } = useDashboard(); - const dynamicParametersEnabled = experiments.includes("dynamic-parameters"); - const { organization: organizationName = "default", template: templateName } = useParams() as { organization?: string; template: string }; const templateQuery = useQuery( - dynamicParametersEnabled - ? templateByName(organizationName, templateName) - : { enabled: false }, - ); - - const optOutQuery = useQuery( - templateQuery.data - ? { - queryKey: [ - organizationName, - "template", - templateQuery.data.id, - "optOut", - ], - queryFn: () => { - const templateId = templateQuery.data.id; - const localStorageKey = optOutKey(templateId); - const storedOptOutString = localStorage.getItem(localStorageKey); - - let optOutResult: boolean; - - if (storedOptOutString !== null) { - optOutResult = storedOptOutString === "true"; - } else { - optOutResult = Boolean( - templateQuery.data.use_classic_parameter_flow, - ); - } - - return { - templateId: templateId, - optedOut: optOutResult, - }; - }, - } - : { enabled: false }, + templateByName(organizationName, templateName), ); - if (dynamicParametersEnabled) { - if (optOutQuery.isLoading) { - return ; - } - if (!optOutQuery.data) { - return ; - } - - const toggleOptedOut = () => { - const key = optOutKey(optOutQuery.data.templateId); - const storedValue = localStorage.getItem(key); - - const current = storedValue - ? storedValue === "true" - : Boolean(templateQuery.data?.use_classic_parameter_flow); - - localStorage.setItem(key, (!current).toString()); - optOutQuery.refetch(); - }; - return ( - - {optOutQuery.data.optedOut ? ( - - ) : ( - - )} - - ); + if (templateQuery.isError) { + return ; + } + if (!templateQuery.data) { + return ; } - return ; + return ( + <> + {templateQuery.data?.use_classic_parameter_flow ? ( + + ) : ( + + )} + + ); }; export default TemplateEmbedExperimentRouter; - -const optOutKey = (id: string) => `parameters.${id}.optOut`; diff --git a/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.tsx b/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.tsx index 497af61144234..a0f80f046c6ad 100644 --- a/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.tsx +++ b/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.tsx @@ -4,20 +4,18 @@ import Radio from "@mui/material/Radio"; import RadioGroup from "@mui/material/RadioGroup"; import { API } from "api/api"; import type { Template, TemplateVersionParameter } from "api/typesGenerated"; -import { Button as ShadcnButton } from "components/Button/Button"; import { FormSection, VerticalForm } from "components/Form/Form"; import { Loader } from "components/Loader/Loader"; import { RichParameterInput } from "components/RichParameterInput/RichParameterInput"; import { useClipboard } from "hooks/useClipboard"; import { CheckIcon, CopyIcon } from "lucide-react"; import { useTemplateLayoutContext } from "pages/TemplatePage/TemplateLayout"; -import { type FC, useContext, useEffect, useState } from "react"; +import { type FC, useEffect, useState } from "react"; import { Helmet } from "react-helmet-async"; import { useQuery } from "react-query"; import { pageTitle } from "utils/page"; import { getInitialRichParameterValues } from "utils/richParameters"; import { paramsUsedToCreateWorkspace } from "utils/workspace"; -import { ExperimentalFormContext } from "../../CreateWorkspacePage/ExperimentalFormContext"; type ButtonValues = Record; @@ -66,7 +64,6 @@ export const TemplateEmbedPageView: FC = ({ template, templateParameters, }) => { - const experimentalFormContext = useContext(ExperimentalFormContext); const [buttonValues, setButtonValues] = useState(); const clipboard = useClipboard({ textToCopy: getClipboardCopyContent( @@ -102,17 +99,6 @@ export const TemplateEmbedPageView: FC = ({ ) : (
- {experimentalFormContext && ( -
- - Try out the new workspace creation flow ✨ - -
- )} = ({ error, sendMessage, }) => { - const experimentalFormContext = useContext(ExperimentalFormContext); const [buttonValues, setButtonValues] = useState(); const [localParameters, setLocalParameters] = useState< Record @@ -205,17 +202,6 @@ const TemplateEmbedPageView: FC = ({ <>
- {experimentalFormContext && ( -
- -
- )} {Boolean(error) && } {diagnostics.length > 0 && }
From a0505bb529b4f787b6a1261bdce8f67573a5b7c0 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Fri, 20 Jun 2025 16:13:09 +0000 Subject: [PATCH 08/10] feat: add shadcn skeleton --- site/src/components/Skeleton/Skeleton.tsx | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 site/src/components/Skeleton/Skeleton.tsx diff --git a/site/src/components/Skeleton/Skeleton.tsx b/site/src/components/Skeleton/Skeleton.tsx new file mode 100644 index 0000000000000..da5d5a7f1ddd0 --- /dev/null +++ b/site/src/components/Skeleton/Skeleton.tsx @@ -0,0 +1,17 @@ +/** + * Copied from shadc/ui on 06/20/2025 + * @see {@link https://ui.shadcn.com/docs/components/skeleton} + */ +import { cn } from "utils/cn"; + +function Skeleton({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { Skeleton }; From d9af4b2069ec3d0f416fa0dd5ca4dfcbf77fb04a Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Fri, 20 Jun 2025 16:17:35 +0000 Subject: [PATCH 09/10] fix: get things working correctly --- .../TemplateEmbedPageExperimental.tsx | 255 +++++++++--------- 1 file changed, 135 insertions(+), 120 deletions(-) diff --git a/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPageExperimental.tsx b/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPageExperimental.tsx index e938aba763f80..b1d0d32862c49 100644 --- a/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPageExperimental.tsx +++ b/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPageExperimental.tsx @@ -1,8 +1,5 @@ import CheckOutlined from "@mui/icons-material/CheckOutlined"; import FileCopyOutlined from "@mui/icons-material/FileCopyOutlined"; -import FormControlLabel from "@mui/material/FormControlLabel"; -import Radio from "@mui/material/Radio"; -import RadioGroup from "@mui/material/RadioGroup"; import { API } from "api/api"; import { DetailedError } from "api/errors"; import type { @@ -11,12 +8,13 @@ import type { FriendlyDiagnostic, PreviewParameter, Template, - User, } from "api/typesGenerated"; import { ErrorAlert } from "components/Alert/ErrorAlert"; import { Button } from "components/Button/Button"; -import { FormSection } from "components/Form/Form"; -import { Loader } from "components/Loader/Loader"; +import { Label } from "components/Label/Label"; +import { RadioGroup, RadioGroupItem } from "components/RadioGroup/RadioGroup"; +import { Skeleton } from "components/Skeleton/Skeleton"; +import { useAuthenticated } from "hooks"; import { useEffectEvent } from "hooks/hookPolyfills"; import { useClipboard } from "hooks/useClipboard"; import { @@ -24,43 +22,34 @@ import { DynamicParameter, } from "modules/workspaces/DynamicParameter/DynamicParameter"; import { useTemplateLayoutContext } from "pages/TemplatePage/TemplateLayout"; -import { - type FC, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from "react"; +import { type FC, useEffect, useMemo, useRef, useState } from "react"; import { Helmet } from "react-helmet-async"; -import { useQuery } from "react-query"; import { pageTitle } from "utils/page"; type ButtonValues = Record; const TemplateEmbedPageExperimental: FC = () => { const { template } = useTemplateLayoutContext(); + const { user: me } = useAuthenticated(); const [latestResponse, setLatestResponse] = useState(null); const wsResponseId = useRef(-1); const ws = useRef(null); const [wsError, setWsError] = useState(null); - const { data: authenticatedUser } = useQuery({ - queryKey: ["authenticatedUser"], - queryFn: () => API.getAuthenticatedUser(), - }); - - const sendMessage = useCallback((formValues: Record) => { - const request: DynamicParametersRequest = { - id: wsResponseId.current + 1, - inputs: formValues, - }; - if (ws.current && ws.current.readyState === WebSocket.OPEN) { - ws.current.send(JSON.stringify(request)); - wsResponseId.current = wsResponseId.current + 1; - } - }, []); + const sendMessage = useEffectEvent( + (formValues: Record, ownerId?: string) => { + const request: DynamicParametersRequest = { + id: wsResponseId.current + 1, + owner_id: me.id, + inputs: formValues, + }; + if (ws.current && ws.current.readyState === WebSocket.OPEN) { + ws.current.send(JSON.stringify(request)); + wsResponseId.current = wsResponseId.current + 1; + } + }, + ); const onMessage = useEffectEvent((response: DynamicParametersResponse) => { if (latestResponse && latestResponse?.id >= response.id) { @@ -71,25 +60,29 @@ const TemplateEmbedPageExperimental: FC = () => { }); useEffect(() => { - if (!template.active_version_id || !authenticatedUser) { + if (!template.active_version_id || !me) { return; } const socket = API.templateVersionDynamicParameters( - authenticatedUser.id, template.active_version_id, + me.id, { onMessage, onError: (error) => { - setWsError(error); + if (ws.current === socket) { + setWsError(error); + } }, onClose: () => { - setWsError( - new DetailedError( - "Websocket connection for dynamic parameters unexpectedly closed.", - "Refresh the page to reset the form.", - ), - ); + if (ws.current === socket) { + setWsError( + new DetailedError( + "Websocket connection for dynamic parameters unexpectedly closed.", + "Refresh the page to reset the form.", + ), + ); + } }, }, ); @@ -99,7 +92,7 @@ const TemplateEmbedPageExperimental: FC = () => { return () => { socket.close(); }; - }, [authenticatedUser, template.active_version_id, onMessage]); + }, [template.active_version_id, onMessage, me]); const sortedParams = useMemo(() => { if (!latestResponse?.parameters) { @@ -108,6 +101,9 @@ const TemplateEmbedPageExperimental: FC = () => { return [...latestResponse.parameters].sort((a, b) => a.order - b.order); }, [latestResponse?.parameters]); + const isLoading = + ws.current?.readyState === WebSocket.CONNECTING || !latestResponse; + return ( <> @@ -119,6 +115,7 @@ const TemplateEmbedPageExperimental: FC = () => { diagnostics={latestResponse?.diagnostics ?? []} error={wsError} sendMessage={sendMessage} + isLoading={isLoading} /> ); @@ -130,6 +127,7 @@ interface TemplateEmbedPageViewProps { diagnostics: readonly FriendlyDiagnostic[]; error: unknown; sendMessage: (message: Record) => void; + isLoading: boolean; } const TemplateEmbedPageView: FC = ({ @@ -138,45 +136,46 @@ const TemplateEmbedPageView: FC = ({ diagnostics, error, sendMessage, + isLoading, }) => { - const [buttonValues, setButtonValues] = useState(); - const [localParameters, setLocalParameters] = useState< - Record - >({}); + const [formState, setFormState] = useState<{ + mode: "manual" | "auto"; + paramValues: Record; + }>({ + mode: "manual", + paramValues: {}, + }); useEffect(() => { if (parameters) { - const initialInputs: Record = {}; - const currentMode = buttonValues?.mode || "manual"; - const initialButtonParamValues: ButtonValues = { mode: currentMode }; - + const serverParamValues: Record = {}; for (const p of parameters) { const initialVal = p.value?.valid ? p.value.value : ""; - initialInputs[p.name] = initialVal; - initialButtonParamValues[`param.${p.name}`] = initialVal; + serverParamValues[p.name] = initialVal; } - setLocalParameters(initialInputs); + setFormState((prev) => ({ ...prev, paramValues: serverParamValues })); + } + }, [parameters]); - setButtonValues(initialButtonParamValues); + const buttonValues = useMemo(() => { + const values: ButtonValues = { mode: formState.mode }; + for (const [key, value] of Object.entries(formState.paramValues)) { + values[`param.${key}`] = value; } - }, [parameters, buttonValues?.mode]); + return values; + }, [formState]); const handleChange = ( changedParamInfo: PreviewParameter, newValue: string, ) => { - const newFormInputs = { - ...localParameters, + const newParamValues = { + ...formState.paramValues, [changedParamInfo.name]: newValue, }; - setLocalParameters(newFormInputs); - - setButtonValues((prevButtonValues) => ({ - ...(prevButtonValues || {}), - [`param.${changedParamInfo.name}`]: newValue, - })); + setFormState((prev) => ({ ...prev, paramValues: newParamValues })); - const formInputsToSend: Record = { ...newFormInputs }; + const formInputsToSend: Record = { ...newParamValues }; for (const p of parameters) { if (!(p.name in formInputsToSend)) { formInputsToSend[p.name] = p.value?.valid ? p.value.value : ""; @@ -186,68 +185,84 @@ const TemplateEmbedPageView: FC = ({ sendMessage(formInputsToSend); }; - useEffect(() => { - if (!buttonValues && parameters.length === 0) { - setButtonValues({ mode: "manual" }); - } else if (buttonValues && !buttonValues.mode && parameters.length > 0) { - setButtonValues((prev) => ({ ...prev, mode: "manual" })); - } - }, [buttonValues, parameters]); - - if (!buttonValues || (!parameters && !error)) { - return ; - } - return ( <>
-
- {Boolean(error) && } - {diagnostics.length > 0 && } -
- - { - setButtonValues((prevButtonValues) => ({ - ...(prevButtonValues || {}), - mode: v, - })); - }} - > - } - label="Manual" - /> - } - label="Automatic" - /> - - - - {parameters.length > 0 && ( +
+ {isLoading ? ( +
+
+ + +
+
+ + +
+
+ + +
+
+ ) : ( + <> + {Boolean(error) && } + {diagnostics.length > 0 && ( + + )}
- {parameters.map((parameter) => { - const isDisabled = parameter.styling?.disabled; - return ( - handleChange(parameter, value)} - disabled={isDisabled} - value={localParameters[parameter.name] || ""} - /> - ); - })} +
+
+

Creation mode

+

+ When set to automatic mode, clicking the button will + create the workspace automatically without displaying a + form to the user. +

+
+ { + setFormState((prev) => ({ + ...prev, + mode: v as "manual" | "auto", + })); + }} + > +
+ + +
+
+ + +
+
+
+ + {parameters.length > 0 && ( +
+ {parameters.map((parameter) => { + const isDisabled = parameter.styling?.disabled; + return ( + handleChange(parameter, value)} + disabled={isDisabled} + value={formState.paramValues[parameter.name] || ""} + /> + ); + })} +
+ )}
- )} -
+ + )}
@@ -285,7 +300,7 @@ const ButtonPreview: FC = ({ template, buttonValues }) => { return (
Open in Coder button From e9671b973a6ebfaa4394f4d5b196961c8d588a38 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Fri, 20 Jun 2025 16:27:36 +0000 Subject: [PATCH 10/10] feat: add a separator component --- site/package.json | 1 + site/pnpm-lock.yaml | 78 +++++++++++++++++++ site/src/components/Separator/Separator.tsx | 30 +++++++ .../TemplateEmbedPageExperimental.tsx | 3 + 4 files changed, 112 insertions(+) create mode 100644 site/src/components/Separator/Separator.tsx diff --git a/site/package.json b/site/package.json index b099706bd57a3..7f63035231d69 100644 --- a/site/package.json +++ b/site/package.json @@ -64,6 +64,7 @@ "@radix-ui/react-radio-group": "1.2.3", "@radix-ui/react-scroll-area": "1.2.3", "@radix-ui/react-select": "2.1.4", + "@radix-ui/react-separator": "1.1.7", "@radix-ui/react-slider": "1.2.2", "@radix-ui/react-slot": "1.1.1", "@radix-ui/react-switch": "1.1.1", diff --git a/site/pnpm-lock.yaml b/site/pnpm-lock.yaml index 7b332074b32fc..e626209d2c754 100644 --- a/site/pnpm-lock.yaml +++ b/site/pnpm-lock.yaml @@ -106,6 +106,9 @@ importers: '@radix-ui/react-select': specifier: 2.1.4 version: 2.1.4(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-separator': + specifier: 1.1.7 + version: 1.1.7(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-slider': specifier: 1.2.2 version: 1.2.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -1615,6 +1618,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-compose-refs@1.1.2': + resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==, tarball: https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-context@1.1.1': resolution: {integrity: sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==, tarball: https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz} peerDependencies: @@ -1833,6 +1845,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-primitive@2.1.3': + resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==, tarball: https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-radio-group@1.2.3': resolution: {integrity: sha512-xtCsqt8Rp09FK50ItqEqTJ7Sxanz8EM8dnkVIhJrc/wkMMomSmXHvYbhv3E7Zx4oXh98aaLt9W679SUYXg4IDA==, tarball: https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.2.3.tgz} peerDependencies: @@ -1898,6 +1923,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-separator@1.1.7': + resolution: {integrity: sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==, tarball: https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-slider@1.2.2': resolution: {integrity: sha512-sNlU06ii1/ZcbHf8I9En54ZPW0Vil/yPVg4vQMcFNjrIx51jsHbFl1HYHQvCIWJSr1q0ZmA+iIs/ZTv8h7HHSA==, tarball: https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.2.2.tgz} peerDependencies: @@ -1938,6 +1976,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-slot@1.2.3': + resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==, tarball: https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-switch@1.1.1': resolution: {integrity: sha512-diPqDDoBcZPSicYoMWdWx+bCPuTRH4QSp9J+65IvtdS0Kuzt67bI6n32vCj8q6NZmYW/ah+2orOtMwcX5eQwIg==, tarball: https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.1.1.tgz} peerDependencies: @@ -7792,6 +7839,12 @@ snapshots: optionalDependencies: '@types/react': 18.3.12 + '@radix-ui/react-compose-refs@1.1.2(@types/react@18.3.12)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.12 + '@radix-ui/react-context@1.1.1(@types/react@18.3.12)(react@18.3.1)': dependencies: react: 18.3.1 @@ -8014,6 +8067,15 @@ snapshots: '@types/react': 18.3.12 '@types/react-dom': 18.3.1 + '@radix-ui/react-primitive@2.1.3(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.12)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.12 + '@types/react-dom': 18.3.1 + '@radix-ui/react-radio-group@1.2.3(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.1 @@ -8112,6 +8174,15 @@ snapshots: '@types/react': 18.3.12 '@types/react-dom': 18.3.1 + '@radix-ui/react-separator@1.1.7(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.12 + '@types/react-dom': 18.3.1 + '@radix-ui/react-slider@1.2.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/number': 1.1.0 @@ -8152,6 +8223,13 @@ snapshots: optionalDependencies: '@types/react': 18.3.12 + '@radix-ui/react-slot@1.2.3(@types/react@18.3.12)(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.12)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.12 + '@radix-ui/react-switch@1.1.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.0 diff --git a/site/src/components/Separator/Separator.tsx b/site/src/components/Separator/Separator.tsx new file mode 100644 index 0000000000000..e18975eb2da58 --- /dev/null +++ b/site/src/components/Separator/Separator.tsx @@ -0,0 +1,30 @@ +import * as SeparatorPrimitive from "@radix-ui/react-separator"; +/** + * Copied from shadc/ui on 06/20/2025 + * @see {@link https://ui.shadcn.com/docs/components/separator} + */ +import type * as React from "react"; + +import { cn } from "utils/cn"; + +function Separator({ + className, + orientation = "horizontal", + decorative = true, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { Separator }; diff --git a/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPageExperimental.tsx b/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPageExperimental.tsx index b1d0d32862c49..010c765007aef 100644 --- a/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPageExperimental.tsx +++ b/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPageExperimental.tsx @@ -13,6 +13,7 @@ import { ErrorAlert } from "components/Alert/ErrorAlert"; import { Button } from "components/Button/Button"; import { Label } from "components/Label/Label"; import { RadioGroup, RadioGroupItem } from "components/RadioGroup/RadioGroup"; +import { Separator } from "components/Separator/Separator"; import { Skeleton } from "components/Skeleton/Skeleton"; import { useAuthenticated } from "hooks"; import { useEffectEvent } from "hooks/hookPolyfills"; @@ -244,6 +245,8 @@ const TemplateEmbedPageView: FC = ({ + + {parameters.length > 0 && (
{parameters.map((parameter) => {