diff --git a/site/package.json b/site/package.json
index 6a8e6ae153..383d3e0f64 100644
--- a/site/package.json
+++ b/site/package.json
@@ -95,6 +95,7 @@
"ts-prune": "0.10.3",
"tzdata": "1.0.30",
"ua-parser-js": "1.0.33",
+ "unique-names-generator": "4.7.1",
"uuid": "9.0.0",
"vite": "4.4.2",
"xstate": "4.38.1",
diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx
index 6aa5fe32b8..b34ff6fd97 100644
--- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx
+++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx
@@ -12,6 +12,7 @@ import {
MockTemplateVersionParameter2,
MockTemplateVersionParameter3,
MockTemplateVersionGitAuth,
+ MockOrganization,
} from "testHelpers/entities"
import {
renderWithAuth,
@@ -217,4 +218,29 @@ describe("CreateWorkspacePage", () => {
await screen.findByText("You must authenticate to create a workspace!")
})
+
+ it("auto create a workspace if uses mode=auto", async () => {
+ const param = "first_parameter"
+ const paramValue = "It works!"
+ const createWorkspaceSpy = jest.spyOn(API, "createWorkspace")
+
+ renderWithAuth(, {
+ route:
+ "/templates/" +
+ MockTemplate.name +
+ `/workspace?param.${param}=${paramValue}&mode=auto`,
+ path: "/templates/:template/workspace",
+ })
+
+ await waitFor(() => {
+ expect(createWorkspaceSpy).toBeCalledWith(
+ MockOrganization.id,
+ "me",
+ expect.objectContaining({
+ template_id: MockTemplate.id,
+ rich_parameter_values: [{ name: param, value: paramValue }],
+ }),
+ )
+ })
+ })
})
diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx
index 6136559615..d49ea38894 100644
--- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx
+++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx
@@ -1,27 +1,47 @@
import { useMachine } from "@xstate/react"
-import { TemplateVersionParameter } from "api/typesGenerated"
+import {
+ Template,
+ TemplateVersionGitAuth,
+ TemplateVersionParameter,
+ WorkspaceBuildParameter,
+} from "api/typesGenerated"
import { useMe } from "hooks/useMe"
import { useOrganizationId } from "hooks/useOrganizationId"
import { FC } from "react"
import { Helmet } from "react-helmet-async"
import { useNavigate, useParams, useSearchParams } from "react-router-dom"
import { pageTitle } from "utils/page"
-import { createWorkspaceMachine } from "xServices/createWorkspace/createWorkspaceXService"
import {
- CreateWorkspaceErrors,
- CreateWorkspacePageView,
-} from "./CreateWorkspacePageView"
+ CreateWSPermissions,
+ CreateWorkspaceMode,
+ createWorkspaceMachine,
+} from "xServices/createWorkspace/createWorkspaceXService"
+import { CreateWorkspacePageView } from "./CreateWorkspacePageView"
+import { Loader } from "components/Loader/Loader"
+import { ErrorAlert } from "components/Alert/ErrorAlert"
+import {
+ uniqueNamesGenerator,
+ animals,
+ colors,
+ NumberDictionary,
+} from "unique-names-generator"
const CreateWorkspacePage: FC = () => {
const organizationId = useOrganizationId()
const { template: templateName } = useParams() as { template: string }
- const navigate = useNavigate()
const me = useMe()
+ const navigate = useNavigate()
+ const [searchParams] = useSearchParams()
+ const defaultBuildParameters = getDefaultBuildParameters(searchParams)
+ const mode = (searchParams.get("mode") ?? "form") as CreateWorkspaceMode
const [createWorkspaceState, send] = useMachine(createWorkspaceMachine, {
context: {
organizationId,
templateName,
- owner: me,
+ mode,
+ defaultBuildParameters,
+ defaultName:
+ mode === "auto" ? generateUniqueName() : searchParams.get("name") ?? "",
},
actions: {
onCreateWorkspace: (_, event) => {
@@ -29,83 +49,65 @@ const CreateWorkspacePage: FC = () => {
},
},
})
- const {
- templates,
- templateParameters,
- templateGitAuth,
- selectedTemplate,
- getTemplateGitAuthError,
- getTemplatesError,
- createWorkspaceError,
- permissions,
- owner,
- } = createWorkspaceState.context
- const [searchParams] = useSearchParams()
- const defaultParameterValues = getDefaultParameterValues(searchParams)
- const name = getName(searchParams)
+ const { template, error, parameters, permissions, gitAuth, defaultName } =
+ createWorkspaceState.context
+ const title = createWorkspaceState.matches("autoCreating")
+ ? "Creating workspace..."
+ : "Create Workspace"
return (
<>
- {pageTitle("Create Workspace")}
+ {pageTitle(title)}
- {
- send({
- type: "SELECT_OWNER",
- owner: user,
- })
- }}
- onCancel={() => {
- // Go back
- navigate(-1)
- }}
- onSubmit={(request) => {
- send({
- type: "CREATE_WORKSPACE",
- request,
- owner,
- })
- }}
- />
+ {Boolean(
+ createWorkspaceState.matches("loadingFormData") ||
+ createWorkspaceState.matches("autoCreating"),
+ ) && }
+ {createWorkspaceState.matches("loadError") && (
+
+ )}
+ {createWorkspaceState.matches("idle") && (
+ {
+ navigate(-1)
+ }}
+ onSubmit={(request, owner) => {
+ send({
+ type: "CREATE_WORKSPACE",
+ request,
+ owner,
+ })
+ }}
+ />
+ )}
>
)
}
-const getName = (urlSearchParams: URLSearchParams): string => {
- return urlSearchParams.get("name") ?? ""
-}
+export default CreateWorkspacePage
-const getDefaultParameterValues = (
+const getDefaultBuildParameters = (
urlSearchParams: URLSearchParams,
-): Record => {
- const paramValues: Record = {}
+): WorkspaceBuildParameter[] => {
+ const buildValues: WorkspaceBuildParameter[] = []
Array.from(urlSearchParams.keys())
.filter((key) => key.startsWith("param."))
.forEach((key) => {
- const paramName = key.replace("param.", "")
- const paramValue = urlSearchParams.get(key)
- paramValues[paramName] = paramValue ?? ""
+ const name = key.replace("param.", "")
+ const value = urlSearchParams.get(key) ?? ""
+ buildValues.push({ name, value })
})
- return paramValues
+ return buildValues
}
export const orderedTemplateParameters = (
@@ -122,4 +124,12 @@ export const orderedTemplateParameters = (
return [...immutables, ...mutables]
}
-export default CreateWorkspacePage
+const generateUniqueName = () => {
+ const numberDictionary = NumberDictionary.generate({ min: 0, max: 99 })
+ return uniqueNamesGenerator({
+ dictionaries: [colors, animals, numberDictionary],
+ separator: "-",
+ length: 3,
+ style: "lowerCase",
+ })
+}
diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx
index 94ac17a40d..51cfc8c69b 100644
--- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx
+++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx
@@ -1,64 +1,38 @@
-import { ComponentMeta, Story } from "@storybook/react"
+import { Meta, StoryObj } from "@storybook/react"
import {
mockApiError,
MockTemplate,
MockTemplateVersionParameter1,
MockTemplateVersionParameter2,
MockTemplateVersionParameter3,
+ MockUser,
} from "../../testHelpers/entities"
-import {
- CreateWorkspaceErrors,
- CreateWorkspacePageView,
- CreateWorkspacePageViewProps,
-} from "./CreateWorkspacePageView"
+import { CreateWorkspacePageView } from "./CreateWorkspacePageView"
-export default {
- title: "pages/CreateWorkspacePageView",
+const meta: Meta = {
+ title: "components/Alert",
component: CreateWorkspacePageView,
-} as ComponentMeta
-
-const Template: Story = (args) => (
-
-)
-
-export const NoParameters = Template.bind({})
-NoParameters.args = {
- templates: [MockTemplate],
- selectedTemplate: MockTemplate,
- createWorkspaceErrors: {},
-}
-
-export const Parameters = Template.bind({})
-Parameters.args = {
- templates: [MockTemplate],
- selectedTemplate: MockTemplate,
- createWorkspaceErrors: {},
-}
-
-export const RedisplayParameters = Template.bind({})
-RedisplayParameters.args = {
- templates: [MockTemplate],
- selectedTemplate: MockTemplate,
- createWorkspaceErrors: {},
-}
-
-export const GetTemplatesError = Template.bind({})
-GetTemplatesError.args = {
- ...Parameters.args,
- createWorkspaceErrors: {
- [CreateWorkspaceErrors.GET_TEMPLATES_ERROR]: mockApiError({
- message: "Failed to fetch templates.",
- detail: "You do not have permission to access this resource.",
- }),
+ args: {
+ defaultName: "",
+ defaultOwner: MockUser,
+ defaultBuildParameters: [],
+ template: MockTemplate,
+ parameters: [],
+ gitAuth: [],
+ permissions: {
+ createWorkspaceForUser: true,
+ },
},
- hasTemplateErrors: true,
}
-export const CreateWorkspaceError = Template.bind({})
-CreateWorkspaceError.args = {
- ...Parameters.args,
- createWorkspaceErrors: {
- [CreateWorkspaceErrors.CREATE_WORKSPACE_ERROR]: mockApiError({
+export default meta
+type Story = StoryObj
+
+export const NoParameters: Story = {}
+
+export const CreateWorkspaceError: Story = {
+ args: {
+ error: mockApiError({
message:
'Workspace "test" already exists in the "docker-amd64" template.',
validations: [
@@ -69,72 +43,64 @@ CreateWorkspaceError.args = {
],
}),
},
- initialTouched: {
- name: true,
+}
+
+export const Parameters: Story = {
+ args: {
+ parameters: [
+ MockTemplateVersionParameter1,
+ MockTemplateVersionParameter2,
+ MockTemplateVersionParameter3,
+ {
+ name: "Region",
+ required: false,
+ description: "",
+ description_plaintext: "",
+ type: "string",
+ mutable: false,
+ default_value: "",
+ icon: "/emojis/1f30e.png",
+ options: [
+ {
+ name: "Pittsburgh",
+ description: "",
+ value: "us-pittsburgh",
+ icon: "/emojis/1f1fa-1f1f8.png",
+ },
+ {
+ name: "Helsinki",
+ description: "",
+ value: "eu-helsinki",
+ icon: "/emojis/1f1eb-1f1ee.png",
+ },
+ {
+ name: "Sydney",
+ description: "",
+ value: "ap-sydney",
+ icon: "/emojis/1f1e6-1f1fa.png",
+ },
+ ],
+ ephemeral: false,
+ },
+ ],
},
}
-export const RichParameters = Template.bind({})
-RichParameters.args = {
- templates: [MockTemplate],
- selectedTemplate: MockTemplate,
- templateParameters: [
- MockTemplateVersionParameter1,
- MockTemplateVersionParameter2,
- MockTemplateVersionParameter3,
- {
- name: "Region",
- required: false,
- description: "",
- description_plaintext: "",
- type: "string",
- mutable: false,
- default_value: "",
- icon: "/emojis/1f30e.png",
- options: [
- {
- name: "Pittsburgh",
- description: "",
- value: "us-pittsburgh",
- icon: "/emojis/1f1fa-1f1f8.png",
- },
- {
- name: "Helsinki",
- description: "",
- value: "eu-helsinki",
- icon: "/emojis/1f1eb-1f1ee.png",
- },
- {
- name: "Sydney",
- description: "",
- value: "ap-sydney",
- icon: "/emojis/1f1e6-1f1fa.png",
- },
- ],
- ephemeral: false,
- },
- ],
- createWorkspaceErrors: {},
-}
-
-export const GitAuth = Template.bind({})
-GitAuth.args = {
- templates: [MockTemplate],
- selectedTemplate: MockTemplate,
- createWorkspaceErrors: {},
- templateParameters: [],
- templateGitAuth: [
- {
- id: "github",
- type: "github",
- authenticated: false,
- authenticate_url: "",
- },
- {
- id: "gitlab",
- type: "gitlab",
- authenticated: true,
- authenticate_url: "",
- },
- ],
+export const GitAuth: Story = {
+ args: {
+ gitAuth: [
+ {
+ id: "github",
+ type: "github",
+ authenticated: false,
+ authenticate_url: "",
+ },
+ {
+ id: "gitlab",
+ type: "gitlab",
+ authenticated: true,
+ authenticate_url: "",
+ },
+ ],
+ },
}
diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx
index eee8f48759..ecc16b9b5c 100644
--- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx
+++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx
@@ -1,16 +1,13 @@
import TextField from "@mui/material/TextField"
import * as TypesGen from "api/typesGenerated"
-import { Stack } from "components/Stack/Stack"
import { UserAutocomplete } from "components/UserAutocomplete/UserAutocomplete"
-import { FormikContextType, FormikTouched, useFormik } from "formik"
+import { FormikContextType, useFormik } from "formik"
import { FC, useEffect, useState } from "react"
import { useTranslation } from "react-i18next"
import { getFormHelpers, nameValidator, onChangeTrimmed } from "utils/formUtils"
import * as Yup from "yup"
import { FullPageHorizontalForm } from "components/FullPageForm/FullPageHorizontalForm"
import { SelectedTemplate } from "./SelectedTemplate"
-import { Loader } from "components/Loader/Loader"
-import { GitAuth } from "components/GitAuth/GitAuth"
import {
FormFields,
FormSection,
@@ -27,171 +24,92 @@ import {
ImmutableTemplateParametersSection,
MutableTemplateParametersSection,
} from "components/TemplateParameters/TemplateParameters"
+import { CreateWSPermissions } from "xServices/createWorkspace/createWorkspaceXService"
+import { GitAuth } from "components/GitAuth/GitAuth"
import { ErrorAlert } from "components/Alert/ErrorAlert"
-import { paramUsedToCreateWorkspace } from "utils/workspace"
-
-export enum CreateWorkspaceErrors {
- GET_TEMPLATES_ERROR = "getTemplatesError",
- GET_TEMPLATE_GITAUTH_ERROR = "getTemplateGitAuthError",
- CREATE_WORKSPACE_ERROR = "createWorkspaceError",
-}
export interface CreateWorkspacePageViewProps {
- name: string
- loadingTemplates: boolean
+ error: unknown
+ defaultName: string
+ defaultOwner: TypesGen.User
+ template: TypesGen.Template
+ gitAuth: TypesGen.TemplateVersionGitAuth[]
+ parameters: TypesGen.TemplateVersionParameter[]
+ defaultBuildParameters: TypesGen.WorkspaceBuildParameter[]
+ permissions: CreateWSPermissions
creatingWorkspace: boolean
- hasTemplateErrors: boolean
- templateName: string
- templates?: TypesGen.Template[]
- selectedTemplate?: TypesGen.Template
- templateParameters?: TypesGen.TemplateVersionParameter[]
- templateGitAuth?: TypesGen.TemplateVersionGitAuth[]
- createWorkspaceErrors: Partial>
- canCreateForUser?: boolean
- owner: TypesGen.User | null
- setOwner: (arg0: TypesGen.User | null) => void
onCancel: () => void
- onSubmit: (req: TypesGen.CreateWorkspaceRequest) => void
- // initialTouched is only used for testing the error state of the form.
- initialTouched?: FormikTouched
- defaultParameterValues?: Record
+ onSubmit: (req: TypesGen.CreateWorkspaceRequest, owner: TypesGen.User) => void
}
-export const CreateWorkspacePageView: FC<
- React.PropsWithChildren
-> = (props) => {
- const templateParameters = props.templateParameters?.filter(
- paramUsedToCreateWorkspace,
- )
+export const CreateWorkspacePageView: FC = ({
+ error,
+ defaultName,
+ defaultOwner,
+ template,
+ gitAuth,
+ parameters,
+ defaultBuildParameters,
+ permissions,
+ creatingWorkspace,
+ onSubmit,
+ onCancel,
+}) => {
const initialRichParameterValues = selectInitialRichParametersValues(
- templateParameters,
- props.defaultParameterValues,
+ parameters,
+ defaultBuildParameters,
)
- const [gitAuthErrors, setGitAuthErrors] = useState>({})
- useEffect(() => {
- // templateGitAuth is refreshed automatically using a BroadcastChannel
- // which may change the `authenticated` property.
- //
- // If the provider becomes authenticated, we want the error message
- // to disappear.
- setGitAuthErrors({})
- }, [props.templateGitAuth])
- const workspaceErrors =
- props.createWorkspaceErrors[CreateWorkspaceErrors.CREATE_WORKSPACE_ERROR]
- // Scroll to top of page if errors are present
- useEffect(() => {
- if (props.hasTemplateErrors || Boolean(workspaceErrors)) {
- window.scrollTo(0, 0)
- }
- }, [props.hasTemplateErrors, workspaceErrors])
const { t } = useTranslation("createWorkspacePage")
const styles = useStyles()
+ const [owner, setOwner] = useState(defaultOwner)
+ const { verifyGitAuth, gitAuthErrors } = useGitAuthVerification(gitAuth)
const form: FormikContextType =
useFormik({
initialValues: {
- name: props.name,
- template_id: props.selectedTemplate ? props.selectedTemplate.id : "",
+ name: defaultName,
+ template_id: template.id,
rich_parameter_values: initialRichParameterValues,
},
validationSchema: Yup.object({
name: nameValidator(t("nameLabel", { ns: "createWorkspacePage" })),
rich_parameter_values: useValidationSchemaForRichParameters(
"createWorkspacePage",
- templateParameters,
+ parameters,
),
}),
enableReinitialize: true,
- initialTouched: props.initialTouched,
onSubmit: (request) => {
- for (let i = 0; i < (props.templateGitAuth?.length || 0); i++) {
- const auth = props.templateGitAuth?.[i]
- if (!auth) {
- continue
- }
- if (!auth.authenticated) {
- setGitAuthErrors({
- [auth.id]: "You must authenticate to create a workspace!",
- })
- form.setSubmitting(false)
- return
- }
+ if (!verifyGitAuth()) {
+ form.setSubmitting(false)
+ return
}
- props.onSubmit({
- ...request,
- })
- form.setSubmitting(false)
+
+ onSubmit(request, owner)
},
})
- const isLoading = props.loadingTemplates
+ useEffect(() => {
+ if (error) {
+ window.scrollTo(0, 0)
+ }
+ }, [error])
const getFieldHelpers = getFormHelpers(
form,
- props.createWorkspaceErrors[CreateWorkspaceErrors.CREATE_WORKSPACE_ERROR],
+ error,
)
- if (isLoading) {
- return
- }
-
return (
-
+
- {Boolean(props.hasTemplateErrors) && (
-
- {Boolean(
- props.createWorkspaceErrors[
- CreateWorkspaceErrors.GET_TEMPLATES_ERROR
- ],
- ) && (
-
- )}
- {Boolean(
- props.createWorkspaceErrors[
- CreateWorkspaceErrors.GET_TEMPLATE_GITAUTH_ERROR
- ],
- ) && (
-
- )}
-
- )}
-
- {Boolean(
- props.createWorkspaceErrors[
- CreateWorkspaceErrors.CREATE_WORKSPACE_ERROR
- ],
- ) && (
-
- )}
-
+ {Boolean(error) && }
{/* General info */}
- {props.selectedTemplate && (
-
- )}
-
+
- {/* Workspace owner */}
- {props.canCreateForUser && (
+ {permissions.createWorkspaceForUser && (
{
+ setOwner(user ?? defaultOwner)
+ }}
label={t("ownerLabel").toString()}
size="medium"
/>
@@ -220,14 +139,13 @@ export const CreateWorkspacePageView: FC<
)}
- {/* Template git auth */}
- {props.templateGitAuth && props.templateGitAuth.length > 0 && (
+ {gitAuth && gitAuth.length > 0 && (
- {props.templateGitAuth.map((auth, index) => (
+ {gitAuth.map((auth, index) => (
)}
- {templateParameters && (
+ {parameters && (
<>
{
return {
...getFieldHelpers(
@@ -264,7 +182,7 @@ export const CreateWorkspacePageView: FC<
}}
/>
{
return {
@@ -289,8 +207,8 @@ export const CreateWorkspacePageView: FC<
)}
@@ -298,6 +216,44 @@ export const CreateWorkspacePageView: FC<
)
}
+type GitAuthErrors = Record
+
+const useGitAuthVerification = (gitAuth: TypesGen.TemplateVersionGitAuth[]) => {
+ const [gitAuthErrors, setGitAuthErrors] = useState({})
+
+ useEffect(() => {
+ // templateGitAuth is refreshed automatically using a BroadcastChannel
+ // which may change the `authenticated` property.
+ //
+ // If the provider becomes authenticated, we want the error message
+ // to disappear.
+ setGitAuthErrors({})
+ }, [gitAuth])
+
+ const verifyGitAuth = () => {
+ const errors: GitAuthErrors = {}
+
+ for (let i = 0; i < gitAuth.length; i++) {
+ const auth = gitAuth.at(i)
+ if (!auth) {
+ continue
+ }
+ if (!auth.authenticated) {
+ errors[auth.id] = "You must authenticate to create a workspace!"
+ }
+ }
+
+ setGitAuthErrors(errors)
+ const isValid = Object.keys(errors).length === 0
+ return isValid
+ }
+
+ return {
+ gitAuthErrors,
+ verifyGitAuth,
+ }
+}
+
const useStyles = makeStyles((theme) => ({
warningText: {
color: theme.palette.warning.light,
diff --git a/site/src/pages/GitAuthPage/GitAuthPage.tsx b/site/src/pages/GitAuthPage/GitAuthPage.tsx
index 97013b7b13..efe62ad6a9 100644
--- a/site/src/pages/GitAuthPage/GitAuthPage.tsx
+++ b/site/src/pages/GitAuthPage/GitAuthPage.tsx
@@ -7,10 +7,10 @@ import {
import { usePermissions } from "hooks"
import { FC, useEffect } from "react"
import { useParams } from "react-router-dom"
-import { REFRESH_GITAUTH_BROADCAST_CHANNEL } from "xServices/createWorkspace/createWorkspaceXService"
import GitAuthPageView from "./GitAuthPageView"
import { ApiErrorResponse } from "api/errors"
import { isAxiosError } from "axios"
+import { REFRESH_GITAUTH_BROADCAST_CHANNEL } from "utils/gitAuth"
const GitAuthPage: FC = () => {
const { provider } = useParams()
@@ -58,7 +58,6 @@ const GitAuthPage: FC = () => {
}
// This is used to notify the parent window that the Git auth token has been refreshed.
// It's critical in the create workspace flow!
- // eslint-disable-next-line compat/compat -- It actually is supported... not sure why it's complaining.
const bc = new BroadcastChannel(REFRESH_GITAUTH_BROADCAST_CHANNEL)
// The message doesn't matter, any message refreshes the page!
bc.postMessage("noop")
diff --git a/site/src/pages/GitAuthPage/GitAuthPageView.tsx b/site/src/pages/GitAuthPage/GitAuthPageView.tsx
index 4bf1acded5..ce46539ac3 100644
--- a/site/src/pages/GitAuthPage/GitAuthPageView.tsx
+++ b/site/src/pages/GitAuthPage/GitAuthPageView.tsx
@@ -12,7 +12,7 @@ import { CopyButton } from "components/CopyButton/CopyButton"
import { SignInLayout } from "components/SignInLayout/SignInLayout"
import { Welcome } from "components/Welcome/Welcome"
import { FC, useEffect } from "react"
-import { REFRESH_GITAUTH_BROADCAST_CHANNEL } from "xServices/createWorkspace/createWorkspaceXService"
+import { REFRESH_GITAUTH_BROADCAST_CHANNEL } from "utils/gitAuth"
export interface GitAuthPageViewProps {
gitAuth: GitAuth
diff --git a/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.test.tsx b/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.test.tsx
index 7da5ae0555..898e89efeb 100644
--- a/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.test.tsx
+++ b/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.test.tsx
@@ -47,6 +47,6 @@ test("Users can fill the parameters and copy the open in coder url", async () =>
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)`,
+ `[](http://localhost/templates/test-template/workspace?mode=manual¶m.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
index 8874bc8d2b..32b9ffcd39 100644
--- a/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.tsx
+++ b/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.tsx
@@ -2,10 +2,13 @@ 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 FormControlLabel from "@mui/material/FormControlLabel"
+import Radio from "@mui/material/Radio"
+import RadioGroup from "@mui/material/RadioGroup"
import { useQuery } from "@tanstack/react-query"
import { getTemplateVersionRichParameters } from "api/api"
import { Template, TemplateVersionParameter } from "api/typesGenerated"
-import { VerticalForm } from "components/Form/Form"
+import { FormSection, VerticalForm } from "components/Form/Form"
import { Loader } from "components/Loader/Loader"
import { useTemplateLayoutContext } from "components/TemplateLayout/TemplateLayout"
import {
@@ -21,7 +24,7 @@ import {
selectInitialRichParametersValues,
workspaceBuildParameterValue,
} from "utils/richParameters"
-import { paramUsedToCreateWorkspace } from "utils/workspace"
+import { paramsUsedToCreateWorkspace } from "utils/workspace"
type ButtonValues = Record
@@ -40,7 +43,7 @@ const TemplateEmbedPage = () => {
>
@@ -51,7 +54,9 @@ export const TemplateEmbedPageView: FC<{
template: Template
templateParameters?: TemplateVersionParameter[]
}> = ({ template, templateParameters }) => {
- const [buttonValues, setButtonValues] = useState({})
+ const [buttonValues, setButtonValues] = useState({
+ mode: "manual",
+ })
const initialRichParametersValues = templateParameters
? selectInitialRichParametersValues(templateParameters)
: undefined
@@ -92,20 +97,48 @@ export const TemplateEmbedPageView: FC<{
) : (
- {templateParameters.length > 0 && (
-
-
-
-
-
-
- )}
+
+
+
+ {
+ setButtonValues((buttonValues) => ({
+ ...buttonValues,
+ mode: v,
+ }))
+ }}
+ >
+ }
+ label="Manual"
+ />
+ }
+ label="Automatic"
+ />
+
+
+
+ {templateParameters.length > 0 && (
+ <>
+
+
+ >
+ )}
+
+
= {
export default meta
type Story = StoryObj
-export const Empty: Story = {
+export const NoParameters: Story = {
args: {
templateParameters: [],
},
diff --git a/site/src/utils/gitAuth.ts b/site/src/utils/gitAuth.ts
new file mode 100644
index 0000000000..11a15668f1
--- /dev/null
+++ b/site/src/utils/gitAuth.ts
@@ -0,0 +1 @@
+export const REFRESH_GITAUTH_BROADCAST_CHANNEL = "gitauth_refresh"
diff --git a/site/src/utils/richParameters.test.ts b/site/src/utils/richParameters.test.ts
new file mode 100644
index 0000000000..66ccb3ac6b
--- /dev/null
+++ b/site/src/utils/richParameters.test.ts
@@ -0,0 +1,53 @@
+import { TemplateVersionParameter } from "api/typesGenerated"
+import { selectInitialRichParametersValues } from "./richParameters"
+
+test("selectInitialRichParametersValues return default value when default build parameter is not valid", () => {
+ const templateParameters: TemplateVersionParameter[] = [
+ {
+ name: "cpu",
+ display_name: "CPU",
+ description: "The number of CPU cores",
+ description_plaintext: "The number of CPU cores",
+ type: "string",
+ mutable: true,
+ default_value: "2",
+ icon: "/icon/memory.svg",
+ options: [
+ {
+ name: "2 Cores",
+ description: "",
+ value: "2",
+ icon: "",
+ },
+ {
+ name: "4 Cores",
+ description: "",
+ value: "4",
+ icon: "",
+ },
+ {
+ name: "6 Cores",
+ description: "",
+ value: "6",
+ icon: "",
+ },
+ {
+ name: "8 Cores",
+ description: "",
+ value: "8",
+ icon: "",
+ },
+ ],
+ required: false,
+ ephemeral: false,
+ },
+ ]
+
+ const cpuParameter = templateParameters[0]
+ const [cpuParameterInitialValue] = selectInitialRichParametersValues(
+ templateParameters,
+ [{ name: cpuParameter.name, value: "100" }],
+ )
+
+ expect(cpuParameterInitialValue.value).toBe(cpuParameter.default_value)
+})
diff --git a/site/src/utils/richParameters.ts b/site/src/utils/richParameters.ts
index f4c87eff48..6fac6c33f2 100644
--- a/site/src/utils/richParameters.ts
+++ b/site/src/utils/richParameters.ts
@@ -7,7 +7,7 @@ import * as Yup from "yup"
export const selectInitialRichParametersValues = (
templateParameters?: TemplateVersionParameter[],
- defaultValuesFromQuery?: Record,
+ defaultBuildParameters?: WorkspaceBuildParameter[],
): WorkspaceBuildParameter[] => {
const defaults: WorkspaceBuildParameter[] = []
if (!templateParameters) {
@@ -19,9 +19,20 @@ export const selectInitialRichParametersValues = (
if (parameter.options.length > 0) {
parameterValue = parameterValue ?? parameter.options[0].value
+ const validValues = parameter.options.map((option) => option.value)
- if (defaultValuesFromQuery && defaultValuesFromQuery[parameter.name]) {
- parameterValue = defaultValuesFromQuery[parameter.name]
+ if (defaultBuildParameters) {
+ const defaultBuildParameter = defaultBuildParameters.find(
+ (p) => p.name === parameter.name,
+ )
+
+ // We don't want invalid values from default parameters to be set
+ if (
+ defaultBuildParameter &&
+ validValues.includes(defaultBuildParameter.value)
+ ) {
+ parameterValue = defaultBuildParameter?.value
+ }
}
const buildParameter: WorkspaceBuildParameter = {
@@ -36,8 +47,14 @@ export const selectInitialRichParametersValues = (
parameterValue = parameter.default_value
}
- if (defaultValuesFromQuery && defaultValuesFromQuery[parameter.name]) {
- parameterValue = defaultValuesFromQuery[parameter.name]
+ if (defaultBuildParameters) {
+ const buildParameter = defaultBuildParameters.find(
+ (p) => p.name === parameter.name,
+ )
+
+ if (buildParameter) {
+ parameterValue = buildParameter?.value
+ }
}
const buildParameter: WorkspaceBuildParameter = {
diff --git a/site/src/utils/workspace.tsx b/site/src/utils/workspace.tsx
index 61213bf21b..26b436aafe 100644
--- a/site/src/utils/workspace.tsx
+++ b/site/src/utils/workspace.tsx
@@ -286,6 +286,6 @@ export const hasJobError = (workspace: TypesGen.Workspace) => {
return workspace.latest_build.job.error !== undefined
}
-export const paramUsedToCreateWorkspace = (
+export const paramsUsedToCreateWorkspace = (
param: TypesGen.TemplateVersionParameter,
) => !param.ephemeral
diff --git a/site/src/xServices/createWorkspace/createWorkspaceXService.ts b/site/src/xServices/createWorkspace/createWorkspaceXService.ts
index a052e92d48..be7b0b0779 100644
--- a/site/src/xServices/createWorkspace/createWorkspaceXService.ts
+++ b/site/src/xServices/createWorkspace/createWorkspaceXService.ts
@@ -1,7 +1,7 @@
import {
checkAuthorization,
createWorkspace,
- getTemplates,
+ getTemplateByName,
getTemplateVersionGitAuth,
getTemplateVersionRichParameters,
} from "api/api"
@@ -12,38 +12,33 @@ import {
TemplateVersionParameter,
User,
Workspace,
+ WorkspaceBuildParameter,
} from "api/typesGenerated"
import { assign, createMachine } from "xstate"
+import { paramsUsedToCreateWorkspace } from "utils/workspace"
+import { REFRESH_GITAUTH_BROADCAST_CHANNEL } from "utils/gitAuth"
-export const REFRESH_GITAUTH_BROADCAST_CHANNEL = "gitauth_refresh"
+export type CreateWorkspaceMode = "form" | "auto"
type CreateWorkspaceContext = {
organizationId: string
- owner: User | null
templateName: string
- templates?: Template[]
- selectedTemplate?: Template
- templateParameters?: TemplateVersionParameter[]
- templateGitAuth?: TemplateVersionGitAuth[]
- createWorkspaceRequest?: CreateWorkspaceRequest
- createdWorkspace?: Workspace
- createWorkspaceError?: Error | unknown
- getTemplatesError?: Error | unknown
- getTemplateParametersError?: Error | unknown
- getTemplateGitAuthError?: Error | unknown
+ mode: CreateWorkspaceMode
+ defaultName: string
+ error?: Error | unknown
+ // Form
+ template?: Template
+ parameters?: TemplateVersionParameter[]
permissions?: Record
- checkPermissionsError?: Error | unknown
+ gitAuth?: TemplateVersionGitAuth[]
+ // Used on auto-create
+ defaultBuildParameters?: WorkspaceBuildParameter[]
}
type CreateWorkspaceEvent = {
type: "CREATE_WORKSPACE"
request: CreateWorkspaceRequest
- owner: User | null
-}
-
-type SelectOwnerEvent = {
- type: "SELECT_OWNER"
- owner: User | null
+ owner: User
}
type RefreshGitAuthEvent = {
@@ -59,117 +54,79 @@ export const createWorkspaceMachine =
tsTypes: {} as import("./createWorkspaceXService.typegen").Typegen0,
schema: {
context: {} as CreateWorkspaceContext,
- events: {} as
- | CreateWorkspaceEvent
- | SelectOwnerEvent
- | RefreshGitAuthEvent,
+ events: {} as CreateWorkspaceEvent | RefreshGitAuthEvent,
services: {} as {
- getTemplates: {
- data: Template[]
- }
- getTemplateGitAuth: {
- data: TemplateVersionGitAuth[]
- }
- getTemplateParameters: {
- data: TemplateVersionParameter[]
+ loadFormData: {
+ data: {
+ template: Template
+ permissions: CreateWSPermissions
+ parameters: TemplateVersionParameter[]
+ gitAuth: TemplateVersionGitAuth[]
+ }
}
createWorkspace: {
data: Workspace
}
+ autoCreateWorkspace: {
+ data: Workspace
+ }
},
},
- initial: "gettingTemplates",
+ initial: "checkingMode",
states: {
- gettingTemplates: {
- entry: "clearGetTemplatesError",
+ checkingMode: {
+ always: [
+ {
+ target: "autoCreating",
+ cond: ({ mode }) => mode === "auto",
+ },
+ { target: "loadingFormData" },
+ ],
+ },
+ autoCreating: {
invoke: {
- src: "getTemplates",
- onDone: [
- {
- actions: ["assignTemplates"],
- cond: "areTemplatesEmpty",
+ src: "autoCreateWorkspace",
+ onDone: {
+ actions: ["onCreateWorkspace"],
+ },
+ onError: {
+ actions: ["assignError"],
+ target: "loadingFormData",
+ },
+ },
+ },
+ loadingFormData: {
+ invoke: {
+ src: "loadFormData",
+ onDone: {
+ target: "idle",
+ actions: ["assignFormData"],
+ },
+ onError: {
+ target: "loadError",
+ actions: ["assignError"],
+ },
+ },
+ },
+ idle: {
+ invoke: [
+ {
+ src: () => (callback) => {
+ const channel = watchGitAuthRefresh(() => {
+ callback("REFRESH_GITAUTH")
+ })
+ return () => channel.close()
},
- {
- actions: ["assignTemplates", "assignSelectedTemplate"],
- target: "gettingTemplateParameters",
- },
- ],
- onError: {
- actions: ["assignGetTemplatesError"],
- target: "error",
},
- },
- },
- gettingTemplateParameters: {
- entry: "clearGetTemplateParametersError",
- invoke: {
- src: "getTemplateParameters",
- onDone: {
- actions: ["assignTemplateParameters"],
- target: "checkingPermissions",
- },
- onError: {
- actions: ["assignGetTemplateParametersError"],
- target: "error",
- },
- },
- },
- checkingPermissions: {
- entry: "clearCheckPermissionsError",
- invoke: {
- src: "checkPermissions",
- id: "checkPermissions",
- onDone: {
- actions: "assignPermissions",
- target: "gettingTemplateGitAuth",
- },
- onError: {
- actions: ["assignCheckPermissionsError"],
- },
- },
- },
- gettingTemplateGitAuth: {
- entry: "clearTemplateGitAuthError",
- invoke: {
- src: "getTemplateGitAuth",
- onDone: {
- actions: ["assignTemplateGitAuth"],
- target: "fillingParams",
- },
- onError: {
- actions: ["assignTemplateGitAuthError"],
- target: "error",
- },
- },
- },
- fillingParams: {
- invoke: {
- id: "listenForRefreshGitAuth",
- src: () => (callback) => {
- // eslint-disable-next-line compat/compat -- It actually is supported... not sure why eslint is complaining.
- const bc = new BroadcastChannel(REFRESH_GITAUTH_BROADCAST_CHANNEL)
- bc.addEventListener("message", () => {
- callback("REFRESH_GITAUTH")
- })
- return () => bc.close()
- },
- },
+ ],
on: {
CREATE_WORKSPACE: {
- actions: ["assignCreateWorkspaceRequest", "assignOwner"],
target: "creatingWorkspace",
},
- SELECT_OWNER: {
- actions: ["assignOwner"],
- target: ["fillingParams"],
- },
- REFRESH_GITAUTH: {
- target: "gettingTemplateGitAuth",
- },
},
},
creatingWorkspace: {
- entry: "clearCreateWorkspaceError",
+ entry: "clearError",
invoke: {
src: "createWorkspace",
onDone: {
@@ -177,137 +134,100 @@ export const createWorkspaceMachine =
target: "created",
},
onError: {
- actions: ["assignCreateWorkspaceError"],
- target: "fillingParams",
+ actions: ["assignError"],
+ target: "idle",
},
},
},
created: {
type: "final",
},
- error: {},
+ loadError: {
+ type: "final",
+ },
},
},
{
services: {
- getTemplates: (context) => getTemplates(context.organizationId),
- getTemplateGitAuth: (context) => {
- const { selectedTemplate } = context
-
- if (!selectedTemplate) {
- throw new Error("No selected template")
- }
-
- return getTemplateVersionGitAuth(selectedTemplate.active_version_id)
+ createWorkspace: ({ organizationId }, { request, owner }) => {
+ return createWorkspace(organizationId, owner.id, request)
},
- getTemplateParameters: (context) => {
- const { selectedTemplate } = context
-
- if (!selectedTemplate) {
- throw new Error("No selected template")
- }
-
- return getTemplateVersionRichParameters(
- selectedTemplate.active_version_id,
- )
- },
- checkPermissions: async (context) => {
- if (!context.organizationId) {
- throw new Error("No organization ID")
- }
-
- // HACK: below, we pass in * for the owner_id, which is a hacky way of checking if the
- // current user can create a workspace on behalf of anyone within the org (only org owners should be able to do this).
- // This pattern should not be replicated outside of this narrow use case.
- const permissionsToCheck = {
- createWorkspaceForUser: {
- object: {
- resource_type: "workspace",
- organization_id: `${context.organizationId}`,
- owner_id: "*",
- },
- action: "create",
- },
- } as const
-
- return checkAuthorization({
- checks: permissionsToCheck,
+ autoCreateWorkspace: async ({
+ templateName,
+ organizationId,
+ defaultBuildParameters,
+ defaultName,
+ }) => {
+ const template = await getTemplateByName(organizationId, templateName)
+ return createWorkspace(organizationId, "me", {
+ template_id: template.id,
+ name: defaultName,
+ rich_parameter_values: defaultBuildParameters,
})
},
- createWorkspace: (context) => {
- const { createWorkspaceRequest, organizationId, owner } = context
+ loadFormData: async ({ templateName, organizationId }) => {
+ const [template, permissions] = await Promise.all([
+ getTemplateByName(organizationId, templateName),
+ checkCreateWSPermissions(organizationId),
+ ])
+ const [parameters, gitAuth] = await Promise.all([
+ getTemplateVersionRichParameters(template.active_version_id).then(
+ (p) => p.filter(paramsUsedToCreateWorkspace),
+ ),
+ getTemplateVersionGitAuth(template.active_version_id),
+ ])
- if (!createWorkspaceRequest) {
- throw new Error("No create workspace request")
+ return {
+ template,
+ permissions,
+ parameters,
+ gitAuth,
}
-
- return createWorkspace(
- organizationId,
- owner?.id ?? "me",
- createWorkspaceRequest,
- )
},
},
- guards: {
- areTemplatesEmpty: (_, event) => event.data.length === 0,
- },
actions: {
- assignTemplates: assign({
- templates: (_, event) => event.data,
+ assignFormData: assign((ctx, event) => {
+ return {
+ ...ctx,
+ ...event.data,
+ }
}),
- assignSelectedTemplate: assign({
- selectedTemplate: (ctx, event) => {
- const templates = event.data.filter(
- (template) => template.name === ctx.templateName,
- )
- return templates.length > 0 ? templates[0] : undefined
- },
+ assignError: assign({
+ error: (_, event) => event.data,
}),
- assignTemplateParameters: assign({
- templateParameters: (_, event) => event.data,
- }),
- assignPermissions: assign({
- permissions: (_, event) => event.data as Record,
- }),
- assignCheckPermissionsError: assign({
- checkPermissionsError: (_, event) => event.data,
- }),
- clearCheckPermissionsError: assign({
- checkPermissionsError: (_) => undefined,
- }),
- assignCreateWorkspaceRequest: assign({
- createWorkspaceRequest: (_, event) => event.request,
- }),
- assignOwner: assign({
- owner: (_, event) => event.owner,
- }),
- assignCreateWorkspaceError: assign({
- createWorkspaceError: (_, event) => event.data,
- }),
- clearCreateWorkspaceError: assign({
- createWorkspaceError: (_) => undefined,
- }),
- assignGetTemplatesError: assign({
- getTemplatesError: (_, event) => event.data,
- }),
- clearGetTemplatesError: assign({
- getTemplatesError: (_) => undefined,
- }),
- assignGetTemplateParametersError: assign({
- getTemplateParametersError: (_, event) => event.data,
- }),
- clearGetTemplateParametersError: assign({
- getTemplateParametersError: (_) => undefined,
- }),
- clearTemplateGitAuthError: assign({
- getTemplateGitAuthError: (_) => undefined,
- }),
- assignTemplateGitAuthError: assign({
- getTemplateGitAuthError: (_, event) => event.data,
- }),
- assignTemplateGitAuth: assign({
- templateGitAuth: (_, event) => event.data,
+ clearError: assign({
+ error: (_) => undefined,
}),
},
},
)
+
+const checkCreateWSPermissions = async (organizationId: string) => {
+ // HACK: below, we pass in * for the owner_id, which is a hacky way of checking if the
+ // current user can create a workspace on behalf of anyone within the org (only org owners should be able to do this).
+ // This pattern should not be replicated outside of this narrow use case.
+ const permissionsToCheck = {
+ createWorkspaceForUser: {
+ object: {
+ resource_type: "workspace",
+ organization_id: organizationId,
+ owner_id: "*",
+ },
+ action: "create",
+ },
+ } as const
+
+ return checkAuthorization({
+ checks: permissionsToCheck,
+ }) as Promise>
+}
+
+export const watchGitAuthRefresh = (callback: () => void) => {
+ const bc = new BroadcastChannel(REFRESH_GITAUTH_BROADCAST_CHANNEL)
+ bc.addEventListener("message", callback)
+ return bc
+}
+
+export type CreateWSPermissions = Awaited<
+ ReturnType
+>
diff --git a/site/yarn.lock b/site/yarn.lock
index bbbe6d3984..125af4faba 100644
--- a/site/yarn.lock
+++ b/site/yarn.lock
@@ -11497,6 +11497,11 @@ unified@^10.0.0:
trough "^2.0.0"
vfile "^5.0.0"
+unique-names-generator@4.7.1:
+ version "4.7.1"
+ resolved "https://registry.yarnpkg.com/unique-names-generator/-/unique-names-generator-4.7.1.tgz#966407b12ba97f618928f77322cfac8c80df5597"
+ integrity sha512-lMx9dX+KRmG8sq6gulYYpKWZc9RlGsgBR6aoO8Qsm3qvkSJ+3rAymr+TnV8EDMrIrwuFJ4kruzMWM/OpYzPoow==
+
unique-string@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/unique-string/-/unique-string-2.0.0.tgz#39c6451f81afb2749de2b233e3f7c5e8843bd89d"