diff --git a/site/.storybook/preview.jsx b/site/.storybook/preview.jsx
index 62c80dcb50..5297d730d9 100644
--- a/site/.storybook/preview.jsx
+++ b/site/.storybook/preview.jsx
@@ -2,6 +2,7 @@ import CssBaseline from "@mui/material/CssBaseline"
import { StyledEngineProvider, ThemeProvider } from "@mui/material/styles"
import { createMemoryHistory } from "history"
import { unstable_HistoryRouter as HistoryRouter } from "react-router-dom"
+import { HelmetProvider } from "react-helmet-async"
import { dark } from "../src/theme"
import "../src/theme/globalFonts"
import "../src/i18n"
@@ -24,6 +25,13 @@ export const decorators = [
)
},
+ (Story) => {
+ return (
+
+
+
+ )
+ },
]
export const parameters = {
diff --git a/site/src/AppRouter.tsx b/site/src/AppRouter.tsx
index 8bfd505949..b477e2ffc7 100644
--- a/site/src/AppRouter.tsx
+++ b/site/src/AppRouter.tsx
@@ -176,6 +176,9 @@ const AddNewLicensePage = lazy(
() =>
import("./pages/DeploySettingsPage/LicensesSettingsPage/AddNewLicensePage"),
)
+const TemplateEmbedPage = lazy(
+ () => import("./pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage"),
+)
export const AppRouter: FC = () => {
return (
@@ -208,6 +211,7 @@ export const AppRouter: FC = () => {
} />
} />
} />
+ } />
} />
diff --git a/site/src/components/Dashboard/DashboardLayout.tsx b/site/src/components/Dashboard/DashboardLayout.tsx
index 3835ef5935..a7ce6b500e 100644
--- a/site/src/components/Dashboard/DashboardLayout.tsx
+++ b/site/src/components/Dashboard/DashboardLayout.tsx
@@ -72,7 +72,7 @@ const useStyles = makeStyles((theme) => ({
// It also give a more pleasant distance to the site content when
// the banner is visible.
marginTop: theme.spacing(2),
- marginBottom: -theme.spacing(2),
+ marginBottom: theme.spacing(-2),
},
siteContent: {
flex: 1,
diff --git a/site/src/components/RichParameterInput/RichParameterInput.tsx b/site/src/components/RichParameterInput/RichParameterInput.tsx
index b791dc4dd2..b1d0943bd5 100644
--- a/site/src/components/RichParameterInput/RichParameterInput.tsx
+++ b/site/src/components/RichParameterInput/RichParameterInput.tsx
@@ -54,7 +54,7 @@ const ParameterLabel: FC = ({ id, parameter }) => {
)
}
-export type RichParameterInputProps = TextFieldProps & {
+export type RichParameterInputProps = Omit & {
index: number
parameter: TemplateVersionParameter
onChange: (value: string) => void
diff --git a/site/src/components/TabSidebar/TabSidebar.tsx b/site/src/components/TabSidebar/TabSidebar.tsx
index 77fda5bddd..af92db8a5d 100644
--- a/site/src/components/TabSidebar/TabSidebar.tsx
+++ b/site/src/components/TabSidebar/TabSidebar.tsx
@@ -73,7 +73,7 @@ const useStyles = makeStyles((theme) => ({
},
menuItem: {
- letterSpacing: -theme.spacing(0.0375),
+ letterSpacing: theme.spacing(-0.0375),
padding: 0,
fontSize: 18,
color: theme.palette.text.secondary,
diff --git a/site/src/components/TemplateLayout/TemplateLayout.tsx b/site/src/components/TemplateLayout/TemplateLayout.tsx
index e9cbea1dc8..b63035362e 100644
--- a/site/src/components/TemplateLayout/TemplateLayout.tsx
+++ b/site/src/components/TemplateLayout/TemplateLayout.tsx
@@ -146,6 +146,17 @@ export const TemplateLayout: FC<{ children?: JSX.Element }> = ({
>
Versions
+
+ combineClasses([
+ styles.tabItem,
+ isActive ? styles.tabItemActive : undefined,
+ ])
+ }
+ >
+ Embed
+
diff --git a/site/src/components/TemplateParameters/TemplateParameters.tsx b/site/src/components/TemplateParameters/TemplateParameters.tsx
new file mode 100644
index 0000000000..fbd4a6c1d1
--- /dev/null
+++ b/site/src/components/TemplateParameters/TemplateParameters.tsx
@@ -0,0 +1,87 @@
+import { TemplateVersionParameter } from "api/typesGenerated"
+import { FormSection, FormFields } from "components/Form/Form"
+import {
+ RichParameterInput,
+ RichParameterInputProps,
+} from "components/RichParameterInput/RichParameterInput"
+import { ComponentProps, FC } from "react"
+
+export type TemplateParametersSectionProps = {
+ templateParameters: TemplateVersionParameter[]
+ getInputProps: (
+ parameter: TemplateVersionParameter,
+ index: number,
+ ) => Omit
+} & Pick, "classes">
+
+export const MutableTemplateParametersSection: FC<
+ TemplateParametersSectionProps
+> = ({ templateParameters, getInputProps, ...formSectionProps }) => {
+ const hasMutableParameters =
+ templateParameters.filter((p) => p.mutable).length > 0
+
+ return (
+ <>
+ {hasMutableParameters && (
+
+
+ {templateParameters.map(
+ (parameter, index) =>
+ parameter.mutable && (
+
+ ),
+ )}
+
+
+ )}
+ >
+ )
+}
+
+export const ImmutableTemplateParametersSection: FC<
+ TemplateParametersSectionProps
+> = ({ templateParameters, getInputProps, ...formSectionProps }) => {
+ const hasImmutableParameters =
+ templateParameters.filter((p) => !p.mutable).length > 0
+
+ return (
+ <>
+ {hasImmutableParameters && (
+
+ These parameters are also provided by your Terraform configuration
+ but they{" "}
+ cannot be changed after creating the workspace.
+ >
+ }
+ >
+
+ {templateParameters.map(
+ (parameter, index) =>
+ !parameter.mutable && (
+
+ ),
+ )}
+
+
+ )}
+ >
+ )
+}
diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx
index 2be426b1ae..7534150a02 100644
--- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx
+++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx
@@ -1,7 +1,6 @@
import TextField from "@mui/material/TextField"
import * as TypesGen from "api/typesGenerated"
import { ParameterInput } from "components/ParameterInput/ParameterInput"
-import { RichParameterInput } from "components/RichParameterInput/RichParameterInput"
import { Stack } from "components/Stack/Stack"
import { UserAutocomplete } from "components/UserAutocomplete/UserAutocomplete"
import { FormikContextType, FormikTouched, useFormik } from "formik"
@@ -26,6 +25,10 @@ import {
useValidationSchemaForRichParameters,
workspaceBuildParameterValue,
} from "utils/richParameters"
+import {
+ ImmutableTemplateParametersSection,
+ MutableTemplateParametersSection,
+} from "components/TemplateParameters/TemplateParameters"
export enum CreateWorkspaceErrors {
GET_TEMPLATES_ERROR = "getTemplatesError",
@@ -308,86 +311,53 @@ export const CreateWorkspacePageView: FC<
)}
- {/* Mutable rich parameters */}
- {props.templateParameters &&
- props.templateParameters.filter((p) => p.mutable).length > 0 && (
-
-
- {props.templateParameters.map(
- (parameter, index) =>
- parameter.mutable && (
- {
- form.setFieldValue("rich_parameter_values." + index, {
- name: parameter.name,
- value: value,
- })
- }}
- parameter={parameter}
- initialValue={workspaceBuildParameterValue(
- initialRichParameterValues,
- parameter,
- )}
- />
- ),
- )}
-
-
- )}
-
- {/* Immutable rich parameters */}
- {props.templateParameters &&
- props.templateParameters.filter((p) => !p.mutable).length > 0 && (
-
+ {
+ return {
+ ...getFieldHelpers(
+ "rich_parameter_values[" + index + "].value",
+ ),
+ onChange: (value) => {
+ form.setFieldValue("rich_parameter_values." + index, {
+ name: parameter.name,
+ value: value,
+ })
+ },
+ initialValue: workspaceBuildParameterValue(
+ initialRichParameterValues,
+ parameter,
+ ),
+ disabled: form.isSubmitting,
+ }
+ }}
+ />
+
- These parameters are also provided by your Terraform
- configuration but they{" "}
-
- cannot be changed after creating the workspace.
-
- >
- }
- >
-
- {props.templateParameters.map(
- (parameter, index) =>
- !parameter.mutable && (
- {
- form.setFieldValue("rich_parameter_values." + index, {
- name: parameter.name,
- value: value,
- })
- }}
- parameter={parameter}
- initialValue={workspaceBuildParameterValue(
- initialRichParameterValues,
- parameter,
- )}
- />
- ),
- )}
-
-
- )}
+ getInputProps={(parameter, index) => {
+ return {
+ ...getFieldHelpers(
+ "rich_parameter_values[" + index + "].value",
+ ),
+ onChange: (value) => {
+ form.setFieldValue("rich_parameter_values." + index, {
+ name: parameter.name,
+ value: value,
+ })
+ },
+ initialValue: workspaceBuildParameterValue(
+ initialRichParameterValues,
+ parameter,
+ ),
+ disabled: form.isSubmitting,
+ }
+ }}
+ />
+ >
+ )}
({
borderRadius: 8,
backgroundColor: theme.palette.background.paper,
padding: theme.spacing(10),
- marginLeft: -theme.spacing(10),
- marginRight: -theme.spacing(10),
+ marginLeft: theme.spacing(-10),
+ marginRight: theme.spacing(-10),
},
}))
diff --git a/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.test.tsx b/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.test.tsx
new file mode 100644
index 0000000000..7da5ae0555
--- /dev/null
+++ b/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.test.tsx
@@ -0,0 +1,52 @@
+import {
+ renderWithAuth,
+ waitForLoaderToBeRemoved,
+} from "testHelpers/renderHelpers"
+import TemplateEmbedPage from "./TemplateEmbedPage"
+import { TemplateLayout } from "components/TemplateLayout/TemplateLayout"
+import {
+ MockTemplate,
+ MockTemplateVersionParameter1 as parameter1,
+ MockTemplateVersionParameter2 as parameter2,
+} from "testHelpers/entities"
+import * as API from "api/api"
+import userEvent from "@testing-library/user-event"
+import { screen } from "@testing-library/react"
+
+test("Users can fill the parameters and copy the open in coder url", async () => {
+ jest
+ .spyOn(API, "getTemplateVersionRichParameters")
+ .mockResolvedValue([parameter1, parameter2])
+
+ renderWithAuth(
+
+
+ ,
+ {
+ route: `/templates/${MockTemplate.name}/embed`,
+ path: "/templates/:template/embed",
+ },
+ )
+ await waitForLoaderToBeRemoved()
+
+ const user = userEvent.setup()
+ const firstParameterField = screen.getByLabelText(
+ parameter1.display_name ?? parameter1.name,
+ { exact: false },
+ )
+ await user.clear(firstParameterField)
+ await user.type(firstParameterField, "firstParameterValue")
+ const secondParameterField = screen.getByLabelText(
+ parameter2.display_name ?? parameter2.name,
+ { exact: false },
+ )
+ await user.clear(secondParameterField)
+ await user.type(secondParameterField, "123456")
+
+ jest.spyOn(window.navigator.clipboard, "writeText")
+ const copyButton = screen.getByRole("button", { name: /copy/i })
+ await userEvent.click(copyButton)
+ expect(window.navigator.clipboard.writeText).toBeCalledWith(
+ `[](http://localhost/templates/test-template/workspace?param.first_parameter=firstParameterValue¶m.second_parameter=123456)`,
+ )
+})
diff --git a/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.tsx b/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.tsx
new file mode 100644
index 0000000000..cc48cdf6e1
--- /dev/null
+++ b/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.tsx
@@ -0,0 +1,153 @@
+import CheckOutlined from "@mui/icons-material/CheckOutlined"
+import FileCopyOutlined from "@mui/icons-material/FileCopyOutlined"
+import Box from "@mui/material/Box"
+import Button from "@mui/material/Button"
+import { useQuery } from "@tanstack/react-query"
+import { getTemplateVersionRichParameters } from "api/api"
+import { Template, TemplateVersionParameter } from "api/typesGenerated"
+import { VerticalForm } from "components/Form/Form"
+import { Loader } from "components/Loader/Loader"
+import { useTemplateLayoutContext } from "components/TemplateLayout/TemplateLayout"
+import {
+ ImmutableTemplateParametersSection,
+ MutableTemplateParametersSection,
+ TemplateParametersSectionProps,
+} from "components/TemplateParameters/TemplateParameters"
+import { useClipboard } from "hooks/useClipboard"
+import { FC, useState } from "react"
+import { Helmet } from "react-helmet-async"
+import { pageTitle } from "utils/page"
+import {
+ selectInitialRichParametersValues,
+ workspaceBuildParameterValue,
+} from "utils/richParameters"
+
+type ButtonValues = Record
+
+const TemplateEmbedPage = () => {
+ const { template } = useTemplateLayoutContext()
+ const { data: templateParameters } = useQuery({
+ queryKey: ["template", template.id, "embed"],
+ queryFn: () => getTemplateVersionRichParameters(template.active_version_id),
+ })
+
+ return (
+ <>
+
+ {pageTitle(`${template.name} · Embed`)}
+
+
+ >
+ )
+}
+
+export const TemplateEmbedPageView: FC<{
+ template: Template
+ templateParameters?: TemplateVersionParameter[]
+}> = ({ template, templateParameters }) => {
+ const [buttonValues, setButtonValues] = useState({})
+ const initialRichParametersValues = templateParameters
+ ? selectInitialRichParametersValues(templateParameters)
+ : undefined
+ const deploymentUrl = `${window.location.protocol}//${window.location.host}`
+ const createWorkspaceUrl = `${deploymentUrl}/templates/${template.name}/workspace`
+ const createWorkspaceParams = new URLSearchParams(buttonValues)
+ const buttonUrl = `${createWorkspaceUrl}?${createWorkspaceParams.toString()}`
+ const buttonMkdCode = `[](${buttonUrl})`
+ const clipboard = useClipboard(buttonMkdCode)
+
+ const getInputProps: TemplateParametersSectionProps["getInputProps"] = (
+ parameter,
+ ) => {
+ if (!initialRichParametersValues) {
+ throw new Error("initialRichParametersValues is undefined")
+ }
+ return {
+ id: parameter.name,
+ initialValue: workspaceBuildParameterValue(
+ initialRichParametersValues,
+ parameter,
+ ),
+ onChange: (value) => {
+ setButtonValues((buttonValues) => ({
+ ...buttonValues,
+ [`param.${parameter.name}`]: value,
+ }))
+ },
+ }
+ }
+
+ return (
+ <>
+
+ {pageTitle(`${template.name} · Embed`)}
+
+ {!templateParameters ? (
+
+ ) : (
+
+ {templateParameters.length > 0 && (
+
+
+
+
+
+
+ )}
+ `1px solid ${theme.palette.divider}`}
+ >
+
+
+ :
+ }
+ variant="contained"
+ onClick={clipboard.copy}
+ disabled={clipboard.isCopied}
+ >
+ Copy button code
+
+
+
+
+ )}
+ >
+ )
+}
+
+export default TemplateEmbedPage
diff --git a/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPageView.stories.tsx b/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPageView.stories.tsx
new file mode 100644
index 0000000000..f1330ab0b0
--- /dev/null
+++ b/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPageView.stories.tsx
@@ -0,0 +1,38 @@
+import type { Meta, StoryObj } from "@storybook/react"
+
+import { TemplateEmbedPageView } from "./TemplateEmbedPage"
+import {
+ MockTemplate,
+ MockTemplateVersionParameter1,
+ MockTemplateVersionParameter2,
+ MockTemplateVersionParameter3,
+ MockTemplateVersionParameter4,
+} from "testHelpers/entities"
+
+const meta: Meta = {
+ title: "pages/TemplateEmbedPageView",
+ component: TemplateEmbedPageView,
+ args: {
+ template: MockTemplate,
+ },
+}
+
+export default meta
+type Story = StoryObj
+
+export const Empty: Story = {
+ args: {
+ templateParameters: [],
+ },
+}
+
+export const WithParameters: Story = {
+ args: {
+ templateParameters: [
+ MockTemplateVersionParameter1,
+ MockTemplateVersionParameter2,
+ MockTemplateVersionParameter3,
+ MockTemplateVersionParameter4,
+ ],
+ },
+}