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 = () => { } /> } /> } /> - } /> + } /> } /> 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