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/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 }; 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 new file mode 100644 index 0000000000000..85dd2e39b5452 --- /dev/null +++ b/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedExperimentRouter.tsx @@ -0,0 +1,35 @@ +import { templateByName } from "api/queries/templates"; +import { ErrorAlert } from "components/Alert/ErrorAlert"; +import { Loader } from "components/Loader/Loader"; +import type { FC } from "react"; +import { useQuery } from "react-query"; +import { useParams } from "react-router-dom"; +import TemplateEmbedPage from "./TemplateEmbedPage"; +import TemplateEmbedPageExperimental from "./TemplateEmbedPageExperimental"; + +const TemplateEmbedExperimentRouter: FC = () => { + const { organization: organizationName = "default", template: templateName } = + useParams() as { organization?: string; template: string }; + const templateQuery = useQuery( + templateByName(organizationName, templateName), + ); + + if (templateQuery.isError) { + return ; + } + if (!templateQuery.data) { + return ; + } + + return ( + <> + {templateQuery.data?.use_classic_parameter_flow ? ( + + ) : ( + + )} + + ); +}; + +export default TemplateEmbedExperimentRouter; diff --git a/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.tsx b/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.tsx index 74295ed63cf72..a0f80f046c6ad 100644 --- a/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.tsx +++ b/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.tsx @@ -97,8 +97,8 @@ export const TemplateEmbedPageView: FC = ({ {!buttonValues || !templateParameters ? ( ) : ( -
-
+
+
; + +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 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) { + return; + } + + setLatestResponse(response); + }); + + useEffect(() => { + if (!template.active_version_id || !me) { + return; + } + + const socket = API.templateVersionDynamicParameters( + template.active_version_id, + me.id, + { + onMessage, + onError: (error) => { + if (ws.current === socket) { + setWsError(error); + } + }, + onClose: () => { + if (ws.current === socket) { + setWsError( + new DetailedError( + "Websocket connection for dynamic parameters unexpectedly closed.", + "Refresh the page to reset the form.", + ), + ); + } + }, + }, + ); + + ws.current = socket; + + return () => { + socket.close(); + }; + }, [template.active_version_id, onMessage, me]); + + const sortedParams = useMemo(() => { + if (!latestResponse?.parameters) { + return []; + } + return [...latestResponse.parameters].sort((a, b) => a.order - b.order); + }, [latestResponse?.parameters]); + + const isLoading = + ws.current?.readyState === WebSocket.CONNECTING || !latestResponse; + + return ( + <> + + {pageTitle(template.name)} + + + + ); +}; + +interface TemplateEmbedPageViewProps { + template: Template; + parameters: PreviewParameter[]; + diagnostics: readonly FriendlyDiagnostic[]; + error: unknown; + sendMessage: (message: Record) => void; + isLoading: boolean; +} + +const TemplateEmbedPageView: FC = ({ + template, + parameters, + diagnostics, + error, + sendMessage, + isLoading, +}) => { + const [formState, setFormState] = useState<{ + mode: "manual" | "auto"; + paramValues: Record; + }>({ + mode: "manual", + paramValues: {}, + }); + + useEffect(() => { + if (parameters) { + const serverParamValues: Record = {}; + for (const p of parameters) { + const initialVal = p.value?.valid ? p.value.value : ""; + serverParamValues[p.name] = initialVal; + } + setFormState((prev) => ({ ...prev, paramValues: serverParamValues })); + } + }, [parameters]); + + const buttonValues = useMemo(() => { + const values: ButtonValues = { mode: formState.mode }; + for (const [key, value] of Object.entries(formState.paramValues)) { + values[`param.${key}`] = value; + } + return values; + }, [formState]); + + const handleChange = ( + changedParamInfo: PreviewParameter, + newValue: string, + ) => { + const newParamValues = { + ...formState.paramValues, + [changedParamInfo.name]: newValue, + }; + setFormState((prev) => ({ ...prev, paramValues: newParamValues })); + + const formInputsToSend: Record = { ...newParamValues }; + for (const p of parameters) { + if (!(p.name in formInputsToSend)) { + formInputsToSend[p.name] = p.value?.valid ? p.value.value : ""; + } + } + + sendMessage(formInputsToSend); + }; + + return ( + <> +
+
+ {isLoading ? ( +
+
+ + +
+
+ + +
+
+ + +
+
+ ) : ( + <> + {Boolean(error) && } + {diagnostics.length > 0 && ( + + )} +
+
+
+

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] || ""} + /> + ); + })} +
+ )} +
+ + )} +
+ + +
+ + ); +}; + +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})`; +} + +interface ButtonPreviewProps { + template: Template; + buttonValues: ButtonValues | undefined; +} + +const ButtonPreview: FC = ({ template, buttonValues }) => { + const clipboard = useClipboard({ + textToCopy: getClipboardCopyContent( + template.name, + template.organization_name, + buttonValues, + ), + }); + + return ( +
+ Open in Coder button + +
+ ); +}; + +export default TemplateEmbedPageExperimental; diff --git a/site/src/router.tsx b/site/src/router.tsx index ad9c295f398e7..27163b63eb426 100644 --- a/site/src/router.tsx +++ b/site/src/router.tsx @@ -273,8 +273,11 @@ 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 +349,7 @@ const templateRouter = () => { } /> } /> } /> - } /> + } /> } />