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) => { 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