feat(site): add auto mode on create workspace form (#8651)

This commit is contained in:
Bruno Quaresma
2023-07-27 08:47:58 -03:00
committed by GitHub
parent bc55ffdf0d
commit f54d385266
16 changed files with 563 additions and 576 deletions
+1
View File
@@ -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",
@@ -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(<CreateWorkspacePage />, {
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 }],
}),
)
})
})
})
@@ -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 (
<>
<Helmet>
<title>{pageTitle("Create Workspace")}</title>
<title>{pageTitle(title)}</title>
</Helmet>
<CreateWorkspacePageView
name={name}
defaultParameterValues={defaultParameterValues}
loadingTemplates={createWorkspaceState.matches("gettingTemplates")}
creatingWorkspace={createWorkspaceState.matches("creatingWorkspace")}
hasTemplateErrors={createWorkspaceState.matches("error")}
templateName={templateName}
templates={templates}
selectedTemplate={selectedTemplate}
templateParameters={orderedTemplateParameters(templateParameters)}
templateGitAuth={templateGitAuth}
createWorkspaceErrors={{
[CreateWorkspaceErrors.GET_TEMPLATES_ERROR]: getTemplatesError,
[CreateWorkspaceErrors.CREATE_WORKSPACE_ERROR]: createWorkspaceError,
[CreateWorkspaceErrors.GET_TEMPLATE_GITAUTH_ERROR]:
getTemplateGitAuthError,
}}
canCreateForUser={permissions?.createWorkspaceForUser}
owner={owner}
setOwner={(user) => {
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"),
) && <Loader />}
{createWorkspaceState.matches("loadError") && (
<ErrorAlert error={error} />
)}
{createWorkspaceState.matches("idle") && (
<CreateWorkspacePageView
defaultName={defaultName}
defaultOwner={me}
defaultBuildParameters={defaultBuildParameters}
error={error}
template={template as Template}
gitAuth={gitAuth as TemplateVersionGitAuth[]}
permissions={permissions as CreateWSPermissions}
parameters={parameters as TemplateVersionParameter[]}
creatingWorkspace={createWorkspaceState.matches("creatingWorkspace")}
onCancel={() => {
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<string, string> => {
const paramValues: Record<string, string> = {}
): 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",
})
}
@@ -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<typeof CreateWorkspacePageView> = {
title: "components/Alert",
component: CreateWorkspacePageView,
} as ComponentMeta<typeof CreateWorkspacePageView>
const Template: Story<CreateWorkspacePageViewProps> = (args) => (
<CreateWorkspacePageView {...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<typeof CreateWorkspacePageView>
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: "",
},
],
},
}
@@ -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<Record<CreateWorkspaceErrors, Error | unknown>>
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<TypesGen.CreateWorkspaceRequest>
defaultParameterValues?: Record<string, string>
onSubmit: (req: TypesGen.CreateWorkspaceRequest, owner: TypesGen.User) => void
}
export const CreateWorkspacePageView: FC<
React.PropsWithChildren<CreateWorkspacePageViewProps>
> = (props) => {
const templateParameters = props.templateParameters?.filter(
paramUsedToCreateWorkspace,
)
export const CreateWorkspacePageView: FC<CreateWorkspacePageViewProps> = ({
error,
defaultName,
defaultOwner,
template,
gitAuth,
parameters,
defaultBuildParameters,
permissions,
creatingWorkspace,
onSubmit,
onCancel,
}) => {
const initialRichParameterValues = selectInitialRichParametersValues(
templateParameters,
props.defaultParameterValues,
parameters,
defaultBuildParameters,
)
const [gitAuthErrors, setGitAuthErrors] = useState<Record<string, string>>({})
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<TypesGen.CreateWorkspaceRequest> =
useFormik<TypesGen.CreateWorkspaceRequest>({
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<TypesGen.CreateWorkspaceRequest>(
form,
props.createWorkspaceErrors[CreateWorkspaceErrors.CREATE_WORKSPACE_ERROR],
error,
)
if (isLoading) {
return <Loader />
}
return (
<FullPageHorizontalForm title="New workspace" onCancel={props.onCancel}>
<FullPageHorizontalForm title="New workspace" onCancel={onCancel}>
<HorizontalForm onSubmit={form.handleSubmit}>
{Boolean(props.hasTemplateErrors) && (
<Stack>
{Boolean(
props.createWorkspaceErrors[
CreateWorkspaceErrors.GET_TEMPLATES_ERROR
],
) && (
<ErrorAlert
error={
props.createWorkspaceErrors[
CreateWorkspaceErrors.GET_TEMPLATES_ERROR
]
}
/>
)}
{Boolean(
props.createWorkspaceErrors[
CreateWorkspaceErrors.GET_TEMPLATE_GITAUTH_ERROR
],
) && (
<ErrorAlert
error={
props.createWorkspaceErrors[
CreateWorkspaceErrors.GET_TEMPLATE_GITAUTH_ERROR
]
}
/>
)}
</Stack>
)}
{Boolean(
props.createWorkspaceErrors[
CreateWorkspaceErrors.CREATE_WORKSPACE_ERROR
],
) && (
<ErrorAlert
error={
props.createWorkspaceErrors[
CreateWorkspaceErrors.CREATE_WORKSPACE_ERROR
]
}
/>
)}
{Boolean(error) && <ErrorAlert error={error} />}
{/* General info */}
<FormSection
title="General"
description="The template and name of your new workspace."
>
<FormFields>
{props.selectedTemplate && (
<SelectedTemplate template={props.selectedTemplate} />
)}
<SelectedTemplate template={template} />
<TextField
{...getFieldHelpers("name")}
disabled={form.isSubmitting}
@@ -203,16 +121,17 @@ export const CreateWorkspacePageView: FC<
</FormFields>
</FormSection>
{/* Workspace owner */}
{props.canCreateForUser && (
{permissions.createWorkspaceForUser && (
<FormSection
title="Workspace Owner"
description="Only admins can create workspace for other users."
>
<FormFields>
<UserAutocomplete
value={props.owner}
onChange={props.setOwner}
value={owner}
onChange={(user) => {
setOwner(user ?? defaultOwner)
}}
label={t("ownerLabel").toString()}
size="medium"
/>
@@ -220,14 +139,13 @@ export const CreateWorkspacePageView: FC<
</FormSection>
)}
{/* Template git auth */}
{props.templateGitAuth && props.templateGitAuth.length > 0 && (
{gitAuth && gitAuth.length > 0 && (
<FormSection
title="Git Authentication"
description="This template requires authentication to automatically perform Git operations on create."
>
<FormFields>
{props.templateGitAuth.map((auth, index) => (
{gitAuth.map((auth, index) => (
<GitAuth
key={index}
authenticateURL={auth.authenticate_url}
@@ -240,10 +158,10 @@ export const CreateWorkspacePageView: FC<
</FormSection>
)}
{templateParameters && (
{parameters && (
<>
<MutableTemplateParametersSection
templateParameters={templateParameters}
templateParameters={parameters}
getInputProps={(parameter, index) => {
return {
...getFieldHelpers(
@@ -264,7 +182,7 @@ export const CreateWorkspacePageView: FC<
}}
/>
<ImmutableTemplateParametersSection
templateParameters={templateParameters}
templateParameters={parameters}
classes={{ root: styles.warningSection }}
getInputProps={(parameter, index) => {
return {
@@ -289,8 +207,8 @@ export const CreateWorkspacePageView: FC<
)}
<FormFooter
onCancel={props.onCancel}
isLoading={props.creatingWorkspace}
onCancel={onCancel}
isLoading={creatingWorkspace}
submitLabel={t("createWorkspace").toString()}
/>
</HorizontalForm>
@@ -298,6 +216,44 @@ export const CreateWorkspacePageView: FC<
)
}
type GitAuthErrors = Record<string, string>
const useGitAuthVerification = (gitAuth: TypesGen.TemplateVersionGitAuth[]) => {
const [gitAuthErrors, setGitAuthErrors] = useState<GitAuthErrors>({})
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,
+1 -2
View File
@@ -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")
@@ -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
@@ -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(
`[![Open in Coder](http://localhost/open-in-coder.svg)](http://localhost/templates/test-template/workspace?param.first_parameter=firstParameterValue&param.second_parameter=123456)`,
`[![Open in Coder](http://localhost/open-in-coder.svg)](http://localhost/templates/test-template/workspace?mode=manual&param.first_parameter=firstParameterValue&param.second_parameter=123456)`,
)
})
@@ -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<string, string>
@@ -40,7 +43,7 @@ const TemplateEmbedPage = () => {
<TemplateEmbedPageView
template={template}
templateParameters={templateParameters?.filter(
paramUsedToCreateWorkspace,
paramsUsedToCreateWorkspace,
)}
/>
</>
@@ -51,7 +54,9 @@ export const TemplateEmbedPageView: FC<{
template: Template
templateParameters?: TemplateVersionParameter[]
}> = ({ template, templateParameters }) => {
const [buttonValues, setButtonValues] = useState<ButtonValues>({})
const [buttonValues, setButtonValues] = useState<ButtonValues>({
mode: "manual",
})
const initialRichParametersValues = templateParameters
? selectInitialRichParametersValues(templateParameters)
: undefined
@@ -92,20 +97,48 @@ export const TemplateEmbedPageView: FC<{
<Loader />
) : (
<Box display="flex" alignItems="flex-start" gap={6}>
{templateParameters.length > 0 && (
<Box flex={1} maxWidth={400}>
<VerticalForm>
<MutableTemplateParametersSection
templateParameters={templateParameters}
getInputProps={getInputProps}
/>
<ImmutableTemplateParametersSection
templateParameters={templateParameters}
getInputProps={getInputProps}
/>
</VerticalForm>
</Box>
)}
<Box flex={1} maxWidth={400}>
<VerticalForm>
<FormSection
title="Creation mode"
description="By changing the mode to automatic, when the user clicks the button, the workspace will be created automatically instead of showing a form to the user."
>
<RadioGroup
defaultValue={buttonValues.mode}
onChange={(_, v) => {
setButtonValues((buttonValues) => ({
...buttonValues,
mode: v,
}))
}}
>
<FormControlLabel
value="manual"
control={<Radio size="small" />}
label="Manual"
/>
<FormControlLabel
value="auto"
control={<Radio size="small" />}
label="Automatic"
/>
</RadioGroup>
</FormSection>
{templateParameters.length > 0 && (
<>
<MutableTemplateParametersSection
templateParameters={templateParameters}
getInputProps={getInputProps}
/>
<ImmutableTemplateParametersSection
templateParameters={templateParameters}
getInputProps={getInputProps}
/>
</>
)}
</VerticalForm>
</Box>
<Box
display="flex"
height={{
@@ -20,7 +20,7 @@ const meta: Meta<typeof TemplateEmbedPageView> = {
export default meta
type Story = StoryObj<typeof TemplateEmbedPageView>
export const Empty: Story = {
export const NoParameters: Story = {
args: {
templateParameters: [],
},
+1
View File
@@ -0,0 +1 @@
export const REFRESH_GITAUTH_BROADCAST_CHANNEL = "gitauth_refresh"
+53
View File
@@ -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)
})
+22 -5
View File
@@ -7,7 +7,7 @@ import * as Yup from "yup"
export const selectInitialRichParametersValues = (
templateParameters?: TemplateVersionParameter[],
defaultValuesFromQuery?: Record<string, string>,
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 = {
+1 -1
View File
@@ -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
@@ -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<string, boolean>
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<string, boolean>,
}),
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<Record<keyof typeof permissionsToCheck, boolean>>
}
export const watchGitAuthRefresh = (callback: () => void) => {
const bc = new BroadcastChannel(REFRESH_GITAUTH_BROADCAST_CHANNEL)
bc.addEventListener("message", callback)
return bc
}
export type CreateWSPermissions = Awaited<
ReturnType<typeof checkCreateWSPermissions>
>
+5
View File
@@ -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"