feat: create experimental template embed page for dynamic params (#17999)

resolves coder/preview#58
This commit is contained in:
Jaayden Halko
2025-06-23 15:47:30 -04:00
committed by GitHub
parent 7dd90f3076
commit 6877142a0e
10 changed files with 493 additions and 8 deletions
+1
View File
@@ -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",
+78
View File
@@ -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
@@ -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<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className,
)}
{...props}
/>
);
}
export { Separator };
+17
View File
@@ -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 (
<div
data-slot="skeleton"
className={cn("bg-surface-tertiary animate-pulse rounded-md", className)}
{...props}
/>
);
}
export { Skeleton };
@@ -52,7 +52,7 @@ interface DynamicParameterProps {
onChange: (value: string) => void;
disabled?: boolean;
isPreset?: boolean;
autofill: boolean;
autofill?: boolean;
}
export const DynamicParameter: FC<DynamicParameterProps> = ({
@@ -873,7 +873,6 @@ interface DiagnosticsProps {
diagnostics: PreviewParameter["diagnostics"];
}
// Displays a diagnostic with a border, icon and background color
export const Diagnostics: FC<DiagnosticsProps> = ({ diagnostics }) => {
return (
<div className="flex flex-col gap-4">
@@ -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,
@@ -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 <ErrorAlert error={templateQuery.error} />;
}
if (!templateQuery.data) {
return <Loader />;
}
return (
<>
{templateQuery.data?.use_classic_parameter_flow ? (
<TemplateEmbedPage />
) : (
<TemplateEmbedPageExperimental />
)}
</>
);
};
export default TemplateEmbedExperimentRouter;
@@ -97,8 +97,8 @@ export const TemplateEmbedPageView: FC<TemplateEmbedPageViewProps> = ({
{!buttonValues || !templateParameters ? (
<Loader />
) : (
<div css={{ display: "flex", alignItems: "flex-start", gap: 48 }}>
<div css={{ flex: 1, maxWidth: 400 }}>
<div className="flex items-start gap-12">
<div className="max-w-3xl">
<VerticalForm>
<FormSection
title="Creation mode"
@@ -0,0 +1,322 @@
import CheckOutlined from "@mui/icons-material/CheckOutlined";
import FileCopyOutlined from "@mui/icons-material/FileCopyOutlined";
import { API } from "api/api";
import { DetailedError } from "api/errors";
import type {
DynamicParametersRequest,
DynamicParametersResponse,
FriendlyDiagnostic,
PreviewParameter,
Template,
} from "api/typesGenerated";
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";
import { useClipboard } from "hooks/useClipboard";
import {
Diagnostics,
DynamicParameter,
} from "modules/workspaces/DynamicParameter/DynamicParameter";
import { useTemplateLayoutContext } from "pages/TemplatePage/TemplateLayout";
import { type FC, useEffect, useMemo, useRef, useState } from "react";
import { Helmet } from "react-helmet-async";
import { pageTitle } from "utils/page";
type ButtonValues = Record<string, string>;
const TemplateEmbedPageExperimental: FC = () => {
const { template } = useTemplateLayoutContext();
const { user: me } = useAuthenticated();
const [latestResponse, setLatestResponse] =
useState<DynamicParametersResponse | null>(null);
const wsResponseId = useRef<number>(-1);
const ws = useRef<WebSocket | null>(null);
const [wsError, setWsError] = useState<Error | null>(null);
const sendMessage = useEffectEvent(
(formValues: Record<string, string>, 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 (
<>
<Helmet>
<title>{pageTitle(template.name)}</title>
</Helmet>
<TemplateEmbedPageView
template={template}
parameters={sortedParams}
diagnostics={latestResponse?.diagnostics ?? []}
error={wsError}
sendMessage={sendMessage}
isLoading={isLoading}
/>
</>
);
};
interface TemplateEmbedPageViewProps {
template: Template;
parameters: PreviewParameter[];
diagnostics: readonly FriendlyDiagnostic[];
error: unknown;
sendMessage: (message: Record<string, string>) => void;
isLoading: boolean;
}
const TemplateEmbedPageView: FC<TemplateEmbedPageViewProps> = ({
template,
parameters,
diagnostics,
error,
sendMessage,
isLoading,
}) => {
const [formState, setFormState] = useState<{
mode: "manual" | "auto";
paramValues: Record<string, string>;
}>({
mode: "manual",
paramValues: {},
});
useEffect(() => {
if (parameters) {
const serverParamValues: Record<string, string> = {};
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<string, string> = { ...newParamValues };
for (const p of parameters) {
if (!(p.name in formInputsToSend)) {
formInputsToSend[p.name] = p.value?.valid ? p.value.value : "";
}
}
sendMessage(formInputsToSend);
};
return (
<>
<div className="flex items-start gap-12">
<div className="w-full flex flex-col gap-5 max-w-screen-md">
{isLoading ? (
<div className="flex flex-col gap-9">
<div className="flex flex-col gap-2">
<Skeleton className="h-5 w-1/3" />
<Skeleton className="h-9 w-full" />
</div>
<div className="flex flex-col gap-2">
<Skeleton className="h-5 w-1/3" />
<Skeleton className="h-9 w-full" />
</div>
<div className="flex flex-col gap-2">
<Skeleton className="h-5 w-1/3" />
<Skeleton className="h-9 w-full" />
</div>
</div>
) : (
<>
{Boolean(error) && <ErrorAlert error={error} />}
{diagnostics.length > 0 && (
<Diagnostics diagnostics={diagnostics} />
)}
<div className="flex flex-col gap-9">
<section className="flex flex-col gap-2">
<div>
<h2 className="text-lg font-bold m-0">Creation mode</h2>
<p className="text-sm text-content-secondary m-0">
When set to automatic mode, clicking the button will
create the workspace automatically without displaying a
form to the user.
</p>
</div>
<RadioGroup
value={formState.mode}
onValueChange={(v) => {
setFormState((prev) => ({
...prev,
mode: v as "manual" | "auto",
}));
}}
>
<div className="flex items-center gap-3">
<RadioGroupItem value="manual" id="manual" />
<Label htmlFor={"manual"} className="cursor-pointer">
Manual
</Label>
</div>
<div className="flex items-center gap-3">
<RadioGroupItem value="auto" id="automatic" />
<Label htmlFor={"automatic"} className="cursor-pointer">
Automatic
</Label>
</div>
</RadioGroup>
</section>
<Separator />
{parameters.length > 0 && (
<div className="flex flex-col gap-9">
{parameters.map((parameter) => {
const isDisabled = parameter.styling?.disabled;
return (
<DynamicParameter
key={parameter.name}
parameter={parameter}
onChange={(value) => handleChange(parameter, value)}
disabled={isDisabled}
value={formState.paramValues[parameter.name] || ""}
/>
);
})}
</div>
)}
</div>
</>
)}
</div>
<ButtonPreview template={template} buttonValues={buttonValues} />
</div>
</>
);
};
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<ButtonPreviewProps> = ({ template, buttonValues }) => {
const clipboard = useClipboard({
textToCopy: getClipboardCopyContent(
template.name,
template.organization_name,
buttonValues,
),
});
return (
<div
className="sticky top-10 flex gap-16 h-96 flex-1 flex-col items-center justify-center
rounded-lg border border-border border-solid bg-surface-secondary"
>
<img src="/open-in-coder.svg" alt="Open in Coder button" />
<Button
variant="default"
onClick={clipboard.copyToClipboard}
disabled={clipboard.showCopiedSuccess}
>
{clipboard.showCopiedSuccess ? <CheckOutlined /> : <FileCopyOutlined />}{" "}
Copy button code
</Button>
</div>
);
};
export default TemplateEmbedPageExperimental;
+6 -3
View File
@@ -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 = () => {
<Route path="files" element={<TemplateFilesPage />} />
<Route path="resources" element={<TemplateResourcesPage />} />
<Route path="versions" element={<TemplateVersionsPage />} />
<Route path="embed" element={<TemplateEmbedPage />} />
<Route path="embed" element={<TemplateEmbedExperimentRouter />} />
<Route path="insights" element={<TemplateInsightsPage />} />
</Route>