refactor(site): Suport template version variables on template creation (#6434)

This commit is contained in:
Bruno Quaresma 2023-03-06 15:36:19 -03:00 committed by GitHub
parent 84dd59ecc2
commit 136f23fb4c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 1166 additions and 421 deletions

View File

@ -5,6 +5,7 @@ import { server } from "./src/testHelpers/server"
import "jest-location-mock"
import { TextEncoder, TextDecoder } from "util"
import { Blob } from "buffer"
import { fetch, Request, Response, Headers } from "@remix-run/web-fetch"
global.TextEncoder = TextEncoder
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Polyfill for jsdom
@ -12,6 +13,22 @@ global.TextDecoder = TextDecoder as any
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Polyfill for jsdom
global.Blob = Blob as any
// From REMIX https://github.com/remix-run/react-router/blob/main/packages/react-router-dom/__tests__/setup.ts
if (!global.fetch) {
// Built-in lib.dom.d.ts expects `fetch(Request | string, ...)` but the web
// fetch API allows a URL so @remix-run/web-fetch defines
// `fetch(string | URL | Request, ...)`
// @ts-expect-error -- Polyfill for jsdom
global.fetch = fetch
// Same as above, lib.dom.d.ts doesn't allow a URL to the Request constructor
// @ts-expect-error -- Polyfill for jsdom
global.Request = Request
// web-std/fetch Response does not currently implement Response.error()
// @ts-expect-error -- Polyfill for jsdom
global.Response = Response
global.Headers = Headers
}
// Polyfill the getRandomValues that is used on utils/random.ts
Object.defineProperty(global.self, "crypto", {
value: {

View File

@ -36,6 +36,7 @@
"@material-ui/icons": "4.5.1",
"@material-ui/lab": "4.0.0-alpha.42",
"@monaco-editor/react": "4.4.6",
"@remix-run/web-fetch": "4.3.2",
"@tanstack/react-query": "4.22.4",
"@testing-library/react-hooks": "8.0.1",
"@types/color-convert": "2.0.0",

View File

@ -1,6 +1,5 @@
import { screen } from "@testing-library/react"
import { rest } from "msw"
import { Route } from "react-router-dom"
import { renderWithAuth } from "testHelpers/renderHelpers"
import { server } from "testHelpers/server"
@ -20,7 +19,12 @@ describe("RequireAuth", () => {
)
renderWithAuth(<h1>Test</h1>, {
routes: <Route path="setup" element={<h1>Setup</h1>} />,
nonAuthenticatedRoutes: [
{
path: "setup",
element: <h1>Setup</h1>,
},
],
})
await screen.findByText("Setup")

View File

@ -0,0 +1,364 @@
import { ComponentMeta, Story } from "@storybook/react"
import {
MockParameterSchemas,
MockTemplateExample,
MockTemplateVersionVariable1,
MockTemplateVersionVariable2,
MockTemplateVersionVariable3,
MockTemplateVersionVariable4,
MockTemplateVersionVariable5,
} from "testHelpers/entities"
import {
CreateTemplateForm,
CreateTemplateFormProps,
} from "./CreateTemplateForm"
export default {
title: "components/CreateTemplateForm",
component: CreateTemplateForm,
args: {
isSubmitting: false,
},
} as ComponentMeta<typeof CreateTemplateForm>
const Template: Story<CreateTemplateFormProps> = (args) => (
<CreateTemplateForm {...args} />
)
export const Initial = Template.bind({})
Initial.args = {}
export const WithStarterTemplate = Template.bind({})
WithStarterTemplate.args = {
starterTemplate: MockTemplateExample,
}
export const WithParameters = Template.bind({})
WithParameters.args = {
parameters: MockParameterSchemas,
}
export const WithVariables = Template.bind({})
WithVariables.args = {
variables: [
MockTemplateVersionVariable1,
MockTemplateVersionVariable2,
MockTemplateVersionVariable3,
MockTemplateVersionVariable4,
MockTemplateVersionVariable5,
],
}
export const WithJobError = Template.bind({})
WithJobError.args = {
jobError:
"template import provision for start: recv import provision: plan terraform: terraform plan: exit status 1",
logs: [
{
id: 461061,
created_at: "2023-03-06T14:47:32.501Z",
log_source: "provisioner_daemon",
log_level: "info",
stage: "Adding README.md...",
output: "",
},
{
id: 461062,
created_at: "2023-03-06T14:47:32.501Z",
log_source: "provisioner_daemon",
log_level: "info",
stage: "Setting up",
output: "",
},
{
id: 461063,
created_at: "2023-03-06T14:47:32.528Z",
log_source: "provisioner_daemon",
log_level: "info",
stage: "Parsing template parameters",
output: "",
},
{
id: 461064,
created_at: "2023-03-06T14:47:32.552Z",
log_source: "provisioner_daemon",
log_level: "info",
stage: "Detecting persistent resources",
output: "",
},
{
id: 461065,
created_at: "2023-03-06T14:47:32.633Z",
log_source: "provisioner",
log_level: "debug",
stage: "Detecting persistent resources",
output: "",
},
{
id: 461066,
created_at: "2023-03-06T14:47:32.633Z",
log_source: "provisioner",
log_level: "debug",
stage: "Detecting persistent resources",
output: "Initializing the backend...",
},
{
id: 461067,
created_at: "2023-03-06T14:47:32.71Z",
log_source: "provisioner",
log_level: "debug",
stage: "Detecting persistent resources",
output: "",
},
{
id: 461068,
created_at: "2023-03-06T14:47:32.711Z",
log_source: "provisioner",
log_level: "debug",
stage: "Detecting persistent resources",
output: "Initializing provider plugins...",
},
{
id: 461069,
created_at: "2023-03-06T14:47:32.712Z",
log_source: "provisioner",
log_level: "debug",
stage: "Detecting persistent resources",
output: '- Finding coder/coder versions matching "~\u003e 0.6.12"...',
},
{
id: 461070,
created_at: "2023-03-06T14:47:32.922Z",
log_source: "provisioner",
log_level: "debug",
stage: "Detecting persistent resources",
output: '- Finding hashicorp/aws versions matching "~\u003e 4.55"...',
},
{
id: 461071,
created_at: "2023-03-06T14:47:33.132Z",
log_source: "provisioner",
log_level: "debug",
stage: "Detecting persistent resources",
output: "- Installing hashicorp/aws v4.57.0...",
},
{
id: 461072,
created_at: "2023-03-06T14:47:37.364Z",
log_source: "provisioner",
log_level: "debug",
stage: "Detecting persistent resources",
output: "- Installed hashicorp/aws v4.57.0 (signed by HashiCorp)",
},
{
id: 461073,
created_at: "2023-03-06T14:47:38.142Z",
log_source: "provisioner",
log_level: "debug",
stage: "Detecting persistent resources",
output: "- Installing coder/coder v0.6.15...",
},
{
id: 461074,
created_at: "2023-03-06T14:47:39.083Z",
log_source: "provisioner",
log_level: "debug",
stage: "Detecting persistent resources",
output:
"- Installed coder/coder v0.6.15 (signed by a HashiCorp partner, key ID 93C75807601AA0EC)",
},
{
id: 461075,
created_at: "2023-03-06T14:47:39.394Z",
log_source: "provisioner",
log_level: "debug",
stage: "Detecting persistent resources",
output: "",
},
{
id: 461076,
created_at: "2023-03-06T14:47:39.394Z",
log_source: "provisioner",
log_level: "debug",
stage: "Detecting persistent resources",
output: "Partner and community providers are signed by their developers.",
},
{
id: 461077,
created_at: "2023-03-06T14:47:39.394Z",
log_source: "provisioner",
log_level: "debug",
stage: "Detecting persistent resources",
output:
"If you'd like to know more about provider signing, you can read about it here:",
},
{
id: 461078,
created_at: "2023-03-06T14:47:39.394Z",
log_source: "provisioner",
log_level: "debug",
stage: "Detecting persistent resources",
output: "https://www.terraform.io/docs/cli/plugins/signing.html",
},
{
id: 461079,
created_at: "2023-03-06T14:47:39.394Z",
log_source: "provisioner",
log_level: "debug",
stage: "Detecting persistent resources",
output: "",
},
{
id: 461080,
created_at: "2023-03-06T14:47:39.394Z",
log_source: "provisioner",
log_level: "debug",
stage: "Detecting persistent resources",
output:
"Terraform has created a lock file .terraform.lock.hcl to record the provider",
},
{
id: 461081,
created_at: "2023-03-06T14:47:39.394Z",
log_source: "provisioner",
log_level: "debug",
stage: "Detecting persistent resources",
output:
"selections it made above. Include this file in your version control repository",
},
{
id: 461082,
created_at: "2023-03-06T14:47:39.394Z",
log_source: "provisioner",
log_level: "debug",
stage: "Detecting persistent resources",
output:
"so that Terraform can guarantee to make the same selections by default when",
},
{
id: 461083,
created_at: "2023-03-06T14:47:39.395Z",
log_source: "provisioner",
log_level: "debug",
stage: "Detecting persistent resources",
output: 'you run "terraform init" in the future.',
},
{
id: 461084,
created_at: "2023-03-06T14:47:39.395Z",
log_source: "provisioner",
log_level: "debug",
stage: "Detecting persistent resources",
output: "",
},
{
id: 461085,
created_at: "2023-03-06T14:47:39.395Z",
log_source: "provisioner",
log_level: "debug",
stage: "Detecting persistent resources",
output: "Terraform has been successfully initialized!",
},
{
id: 461086,
created_at: "2023-03-06T14:47:39.395Z",
log_source: "provisioner",
log_level: "debug",
stage: "Detecting persistent resources",
output: "",
},
{
id: 461087,
created_at: "2023-03-06T14:47:39.395Z",
log_source: "provisioner",
log_level: "debug",
stage: "Detecting persistent resources",
output:
'You may now begin working with Terraform. Try running "terraform plan" to see',
},
{
id: 461088,
created_at: "2023-03-06T14:47:39.395Z",
log_source: "provisioner",
log_level: "debug",
stage: "Detecting persistent resources",
output:
"any changes that are required for your infrastructure. All Terraform commands",
},
{
id: 461089,
created_at: "2023-03-06T14:47:39.395Z",
log_source: "provisioner",
log_level: "debug",
stage: "Detecting persistent resources",
output: "should now work.",
},
{
id: 461090,
created_at: "2023-03-06T14:47:39.397Z",
log_source: "provisioner",
log_level: "debug",
stage: "Detecting persistent resources",
output: "",
},
{
id: 461091,
created_at: "2023-03-06T14:47:39.397Z",
log_source: "provisioner",
log_level: "debug",
stage: "Detecting persistent resources",
output:
"If you ever set or change modules or backend configuration for Terraform,",
},
{
id: 461092,
created_at: "2023-03-06T14:47:39.397Z",
log_source: "provisioner",
log_level: "debug",
stage: "Detecting persistent resources",
output:
"rerun this command to reinitialize your working directory. If you forget, other",
},
{
id: 461093,
created_at: "2023-03-06T14:47:39.397Z",
log_source: "provisioner",
log_level: "debug",
stage: "Detecting persistent resources",
output: "commands will detect it and remind you to do so if necessary.",
},
{
id: 461094,
created_at: "2023-03-06T14:47:39.431Z",
log_source: "provisioner",
log_level: "info",
stage: "Detecting persistent resources",
output: "Terraform 1.1.9",
},
{
id: 461095,
created_at: "2023-03-06T14:47:43.759Z",
log_source: "provisioner",
log_level: "error",
stage: "Detecting persistent resources",
output:
"Error: configuring Terraform AWS Provider: no valid credential sources for Terraform AWS Provider found.\n\nPlease see https://registry.terraform.io/providers/hashicorp/aws\nfor more information about providing credentials.\n\nError: failed to refresh cached credentials, no EC2 IMDS role found, operation error ec2imds: GetMetadata, http response error StatusCode: 404, request to EC2 IMDS failed\n",
},
{
id: 461096,
created_at: "2023-03-06T14:47:43.759Z",
log_source: "provisioner",
log_level: "error",
stage: "Detecting persistent resources",
output: "",
},
{
id: 461097,
created_at: "2023-03-06T14:47:43.777Z",
log_source: "provisioner_daemon",
log_level: "info",
stage: "Cleaning Up",
output: "",
},
],
}

View File

@ -5,8 +5,8 @@ import {
ParameterSchema,
ProvisionerJobLog,
TemplateExample,
TemplateVersionVariable,
} from "api/typesGenerated"
import { FormFooter } from "components/FormFooter/FormFooter"
import { ParameterInput } from "components/ParameterInput/ParameterInput"
import { Stack } from "components/Stack/Stack"
import {
@ -17,21 +17,30 @@ import { useFormik } from "formik"
import { SelectedTemplate } from "pages/CreateWorkspacePage/SelectedTemplate"
import { FC } from "react"
import { useTranslation } from "react-i18next"
import { nameValidator, getFormHelpers, onChangeTrimmed } from "util/formUtils"
import {
nameValidator,
getFormHelpers,
onChangeTrimmed,
templateDisplayNameValidator,
} from "util/formUtils"
import { CreateTemplateData } from "xServices/createTemplate/createTemplateXService"
import * as Yup from "yup"
import { WorkspaceBuildLogs } from "components/WorkspaceBuildLogs/WorkspaceBuildLogs"
import { HelpTooltip, HelpTooltipText } from "components/Tooltips/HelpTooltip"
import { LazyIconField } from "components/IconField/LazyIconField"
import { VariableInput } from "./VariableInput"
import {
FormFields,
FormFooter,
FormSection,
HorizontalForm,
} from "components/HorizontalForm/HorizontalForm"
import camelCase from "lodash/camelCase"
import capitalize from "lodash/capitalize"
const validationSchema = Yup.object({
name: nameValidator("Name"),
display_name: Yup.string().optional(),
description: Yup.string().optional(),
icon: Yup.string().optional(),
default_ttl_hours: Yup.number(),
allow_user_cancel_workspace_jobs: Yup.boolean(),
parameter_values_by_name: Yup.object().optional(),
display_name: templateDisplayNameValidator("Display name"),
})
const defaultInitialValues: CreateTemplateData = {
@ -41,7 +50,6 @@ const defaultInitialValues: CreateTemplateData = {
icon: "",
default_ttl_hours: 24,
allow_user_cancel_workspace_jobs: false,
parameter_values_by_name: undefined,
}
const getInitialValues = (starterTemplate?: TemplateExample) => {
@ -58,31 +66,32 @@ const getInitialValues = (starterTemplate?: TemplateExample) => {
}
}
interface CreateTemplateFormProps {
starterTemplate?: TemplateExample
error?: unknown
parameters?: ParameterSchema[]
isSubmitting: boolean
export interface CreateTemplateFormProps {
onCancel: () => void
onSubmit: (data: CreateTemplateData) => void
isSubmitting: boolean
upload: TemplateUploadProps
starterTemplate?: TemplateExample
parameters?: ParameterSchema[]
variables?: TemplateVersionVariable[]
error?: unknown
jobError?: string
logs?: ProvisionerJobLog[]
}
export const CreateTemplateForm: FC<CreateTemplateFormProps> = ({
starterTemplate,
error,
parameters,
isSubmitting,
onCancel,
onSubmit,
starterTemplate,
parameters,
variables,
isSubmitting,
upload,
error,
jobError,
logs,
}) => {
const styles = useStyles()
const formFooterStyles = useFormFooterStyles()
const form = useFormik<CreateTemplateData>({
initialValues: getInitialValues(starterTemplate),
validationSchema,
@ -92,258 +101,223 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = ({
const { t } = useTranslation("createTemplatePage")
return (
<form onSubmit={form.handleSubmit}>
<Stack direction="column" spacing={10} className={styles.formSections}>
{/* General info */}
<div className={styles.formSection}>
<div className={styles.formSectionInfo}>
<h2 className={styles.formSectionInfoTitle}>
{t("form.generalInfo.title")}
</h2>
<p className={styles.formSectionInfoDescription}>
{t("form.generalInfo.description")}
</p>
</div>
<Stack direction="column" className={styles.formSectionFields}>
{starterTemplate ? (
<SelectedTemplate template={starterTemplate} />
) : (
<TemplateUpload {...upload} />
)}
<TextField
{...getFieldHelpers("name")}
disabled={isSubmitting}
onChange={onChangeTrimmed(form)}
autoFocus
fullWidth
label={t("form.fields.name")}
variant="outlined"
<HorizontalForm onSubmit={form.handleSubmit}>
{/* General info */}
<FormSection
title={t("form.generalInfo.title")}
description={t("form.generalInfo.description")}
>
<FormFields>
{starterTemplate ? (
<SelectedTemplate template={starterTemplate} />
) : (
<TemplateUpload
{...upload}
onUpload={async (file) => {
await fillNameAndDisplayWithFilename(file.name, form)
upload.onUpload(file)
}}
/>
</Stack>
</div>
)}
{/* Display info */}
<div className={styles.formSection}>
<div className={styles.formSectionInfo}>
<h2 className={styles.formSectionInfoTitle}>
{t("form.displayInfo.title")}
</h2>
<p className={styles.formSectionInfoDescription}>
{t("form.displayInfo.description")}
</p>
</div>
<TextField
{...getFieldHelpers("name")}
disabled={isSubmitting}
onChange={onChangeTrimmed(form)}
autoFocus
fullWidth
required
label={t("form.fields.name")}
variant="outlined"
/>
</FormFields>
</FormSection>
<Stack direction="column" className={styles.formSectionFields}>
<TextField
{...getFieldHelpers("display_name")}
disabled={isSubmitting}
fullWidth
label={t("form.fields.displayName")}
variant="outlined"
/>
{/* Display info */}
<FormSection
title={t("form.displayInfo.title")}
description={t("form.displayInfo.description")}
>
<FormFields>
<TextField
{...getFieldHelpers("display_name")}
disabled={isSubmitting}
fullWidth
label={t("form.fields.displayName")}
variant="outlined"
/>
<TextField
{...getFieldHelpers("description")}
disabled={isSubmitting}
rows={5}
multiline
fullWidth
label={t("form.fields.description")}
variant="outlined"
/>
<TextField
{...getFieldHelpers("description")}
disabled={isSubmitting}
rows={5}
multiline
fullWidth
label={t("form.fields.description")}
variant="outlined"
/>
<LazyIconField
{...getFieldHelpers("icon")}
disabled={isSubmitting}
onChange={onChangeTrimmed(form)}
fullWidth
label={t("form.fields.icon")}
variant="outlined"
onPickEmoji={(value) => form.setFieldValue("icon", value)}
/>
</Stack>
</div>
<LazyIconField
{...getFieldHelpers("icon")}
disabled={isSubmitting}
onChange={onChangeTrimmed(form)}
fullWidth
label={t("form.fields.icon")}
variant="outlined"
onPickEmoji={(value) => form.setFieldValue("icon", value)}
/>
</FormFields>
</FormSection>
{/* Schedule */}
<div className={styles.formSection}>
<div className={styles.formSectionInfo}>
<h2 className={styles.formSectionInfoTitle}>
{t("form.schedule.title")}
</h2>
<p className={styles.formSectionInfoDescription}>
{t("form.schedule.description")}
</p>
</div>
{/* Schedule */}
<FormSection
title={t("form.schedule.title")}
description={t("form.schedule.description")}
>
<FormFields>
<TextField
{...getFieldHelpers("default_ttl_hours")}
disabled={isSubmitting}
onChange={onChangeTrimmed(form)}
fullWidth
label={t("form.fields.autoStop")}
variant="outlined"
type="number"
helperText={t("form.helperText.autoStop")}
/>
</FormFields>
</FormSection>
<Stack direction="column" className={styles.formSectionFields}>
<TextField
{...getFieldHelpers("default_ttl_hours")}
disabled={isSubmitting}
onChange={onChangeTrimmed(form)}
fullWidth
label={t("form.fields.autoStop")}
variant="outlined"
type="number"
helperText={t("form.helperText.autoStop")}
/>
</Stack>
</div>
{/* Operations */}
<FormSection
title={t("form.operations.title")}
description={t("form.operations.description")}
>
<FormFields>
<label htmlFor="allow_user_cancel_workspace_jobs">
<Stack direction="row" spacing={1}>
<Checkbox
color="primary"
id="allow_user_cancel_workspace_jobs"
name="allow_user_cancel_workspace_jobs"
disabled={isSubmitting}
checked={form.values.allow_user_cancel_workspace_jobs}
onChange={form.handleChange}
/>
{/* Operations */}
<div className={styles.formSection}>
<div className={styles.formSectionInfo}>
<h2 className={styles.formSectionInfoTitle}>
{t("form.operations.title")}
</h2>
<p className={styles.formSectionInfoDescription}>
{t("form.operations.description")}
</p>
</div>
<Stack direction="column" spacing={0.5}>
<Stack
direction="row"
alignItems="center"
spacing={0.5}
className={styles.optionText}
>
{t("form.fields.allowUsersToCancel")}
<Stack direction="column" className={styles.formSectionFields}>
<label htmlFor="allow_user_cancel_workspace_jobs">
<Stack direction="row" spacing={1}>
<Checkbox
color="primary"
id="allow_user_cancel_workspace_jobs"
name="allow_user_cancel_workspace_jobs"
disabled={isSubmitting}
checked={form.values.allow_user_cancel_workspace_jobs}
onChange={form.handleChange}
/>
<Stack direction="column" spacing={0.5}>
<Stack
direction="row"
alignItems="center"
spacing={0.5}
className={styles.optionText}
>
{t("form.fields.allowUsersToCancel")}
<HelpTooltip>
<HelpTooltipText>
{t("form.tooltip.allowUsersToCancel")}
</HelpTooltipText>
</HelpTooltip>
</Stack>
<span className={styles.optionHelperText}>
{t("form.helperText.allowUsersToCancel")}
</span>
<HelpTooltip>
<HelpTooltipText>
{t("form.tooltip.allowUsersToCancel")}
</HelpTooltipText>
</HelpTooltip>
</Stack>
<span className={styles.optionHelperText}>
{t("form.helperText.allowUsersToCancel")}
</span>
</Stack>
</label>
</Stack>
</div>
{/* Parameters */}
{parameters && (
<div className={styles.formSection}>
<div className={styles.formSectionInfo}>
<h2 className={styles.formSectionInfoTitle}>
{t("form.parameters.title")}
</h2>
<p className={styles.formSectionInfoDescription}>
{t("form.parameters.description")}
</p>
</div>
<Stack direction="column" className={styles.formSectionFields}>
{parameters.map((schema) => (
<ParameterInput
schema={schema}
disabled={isSubmitting}
key={schema.id}
onChange={async (value) => {
await form.setFieldValue(
`parameter_values_by_name.${schema.name}`,
value,
)
}}
/>
))}
</Stack>
</label>
</FormFields>
</FormSection>
{/* Parameters */}
{parameters && (
<FormSection
title={t("form.parameters.title")}
description={t("form.parameters.description")}
>
<FormFields>
{parameters.map((schema) => (
<ParameterInput
schema={schema}
disabled={isSubmitting}
key={schema.id}
onChange={async (value) => {
await form.setFieldValue(
`parameter_values_by_name.${schema.name}`,
value,
)
}}
/>
))}
</FormFields>
</FormSection>
)}
{/* Variables */}
{variables && (
<FormSection
title="Variables"
description="Input variables allow you to customize templates without altering their source code."
>
<FormFields>
{variables.map((variable, index) => (
<VariableInput
variable={variable}
disabled={isSubmitting}
key={variable.name}
onChange={async (value) => {
await form.setFieldValue("user_variable_values." + index, {
name: variable.name,
value: value,
})
}}
/>
))}
</FormFields>
</FormSection>
)}
{jobError && (
<Stack>
<div className={styles.error}>
<h5 className={styles.errorTitle}>Error during provisioning</h5>
<p className={styles.errorDescription}>
Looks like we found an error during the template provisioning. You
can see the logs bellow.
</p>
<code className={styles.errorDetails}>{jobError}</code>
</div>
)}
{jobError && (
<Stack>
<div className={styles.error}>
<h5 className={styles.errorTitle}>Error during provisioning</h5>
<p className={styles.errorDescription}>
Looks like we found an error during the template provisioning.
You can see the logs bellow.
</p>
<WorkspaceBuildLogs logs={logs ?? []} />
</Stack>
)}
<code className={styles.errorDetails}>{jobError}</code>
</div>
<WorkspaceBuildLogs logs={logs ?? []} />
</Stack>
)}
<FormFooter
styles={formFooterStyles}
onCancel={onCancel}
isLoading={isSubmitting}
submitLabel={jobError ? "Retry" : "Create template"}
/>
</Stack>
</form>
<FormFooter
onCancel={onCancel}
isLoading={isSubmitting}
submitLabel={jobError ? "Retry" : "Create template"}
/>
</HorizontalForm>
)
}
const fillNameAndDisplayWithFilename = async (
filename: string,
form: ReturnType<typeof useFormik<CreateTemplateData>>,
) => {
const [name, _extension] = filename.split(".")
await Promise.all([
form.setFieldValue(
"name",
// Camel case will remove special chars and spaces
camelCase(name).toLowerCase(),
),
form.setFieldValue("display_name", capitalize(name)),
])
}
const useStyles = makeStyles((theme) => ({
formSections: {
[theme.breakpoints.down("sm")]: {
gap: theme.spacing(8),
},
},
formSection: {
display: "flex",
alignItems: "flex-start",
gap: theme.spacing(15),
[theme.breakpoints.down("sm")]: {
flexDirection: "column",
gap: theme.spacing(2),
},
},
formSectionInfo: {
width: 312,
flexShrink: 0,
position: "sticky",
top: theme.spacing(3),
[theme.breakpoints.down("sm")]: {
width: "100%",
position: "initial",
},
},
formSectionInfoTitle: {
fontSize: 20,
color: theme.palette.text.primary,
fontWeight: 400,
margin: 0,
marginBottom: theme.spacing(1),
},
formSectionInfoDescription: {
fontSize: 14,
color: theme.palette.text.secondary,
lineHeight: "160%",
margin: 0,
},
formSectionFields: {
width: "100%",
},
optionText: {
fontSize: theme.spacing(2),
color: theme.palette.text.primary,
@ -379,25 +353,3 @@ const useStyles = makeStyles((theme) => ({
fontSize: theme.spacing(2),
},
}))
const useFormFooterStyles = makeStyles((theme) => ({
button: {
minWidth: theme.spacing(23),
[theme.breakpoints.down("sm")]: {
width: "100%",
},
},
footer: {
display: "flex",
alignItems: "center",
justifyContent: "flex-start",
flexDirection: "row-reverse",
gap: theme.spacing(2),
[theme.breakpoints.down("sm")]: {
flexDirection: "column",
gap: theme.spacing(1),
},
},
}))

View File

@ -0,0 +1,122 @@
import {
MockOrganization,
MockProvisionerJob,
MockTemplate,
MockTemplateExample,
MockTemplateVersion,
MockTemplateVersionVariable1,
MockTemplateVersionVariable2,
MockTemplateVersionVariable3,
MockTemplateVersionVariable4,
MockTemplateVersionVariable5,
renderWithAuth,
} from "testHelpers/renderHelpers"
import CreateTemplatePage from "./CreateTemplatePage"
import { screen, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import * as API from "api/api"
const renderPage = async () => {
// Render with the example ID so we don't need to upload a file
const result = renderWithAuth(<CreateTemplatePage />, {
route: `/templates/new?exampleId=${MockTemplateExample.id}`,
path: "/templates/new",
// We need this because after creation, the user will be redirected to here
extraRoutes: [{ path: "templates/:template", element: <></> }],
})
// It is lazy loaded, so we have to wait for it to be rendered to not get an
// act error
await screen.findByLabelText("Icon")
return result
}
test("Create template with variables", async () => {
// Return pending when creating the first template version
jest.spyOn(API, "createTemplateVersion").mockResolvedValueOnce({
...MockTemplateVersion,
job: {
...MockTemplateVersion.job,
status: "pending",
},
})
// Return an error requesting for template variables
jest.spyOn(API, "getTemplateVersion").mockResolvedValue({
...MockTemplateVersion,
job: {
...MockTemplateVersion.job,
status: "failed",
error: "required template variables",
},
})
// Return the template variables
jest
.spyOn(API, "getTemplateVersionVariables")
.mockResolvedValue([
MockTemplateVersionVariable1,
MockTemplateVersionVariable2,
MockTemplateVersionVariable3,
MockTemplateVersionVariable4,
MockTemplateVersionVariable5,
])
// Render page, fill the name and submit
const { router } = await renderPage()
await userEvent.type(screen.getByLabelText(/Name/), "my-template")
await userEvent.click(
screen.getByRole("button", { name: /create template/i }),
)
// Wait for the variables form to be rendered and fill it
await screen.findByText(/Variables/)
// Type first variable
await userEvent.clear(screen.getByLabelText(/var.first_variable/))
await userEvent.type(
screen.getByLabelText(/var.first_variable/),
"First value",
)
// Type second variable
await userEvent.clear(screen.getByLabelText(/var.second_variable/))
await userEvent.type(screen.getByLabelText(/var.second_variable/), "2")
// Select third variable on radio
await userEvent.click(screen.getByLabelText(/True/))
// Type fourth variable
await userEvent.clear(screen.getByLabelText(/var.fourth_variable/))
await userEvent.type(
screen.getByLabelText(/var.fourth_variable/),
"Fourth value",
)
// Type fifth variable
await userEvent.clear(screen.getByLabelText(/var.fifth_variable/))
await userEvent.type(
screen.getByLabelText(/var.fifth_variable/),
"Fifth value",
)
// Setup the mock for the second template version creation before submit the form
jest.clearAllMocks()
jest
.spyOn(API, "createTemplateVersion")
.mockResolvedValue(MockTemplateVersion)
jest.spyOn(API, "createTemplate").mockResolvedValue(MockTemplate)
await userEvent.click(
screen.getByRole("button", { name: /create template/i }),
)
await waitFor(() => expect(API.createTemplate).toBeCalledTimes(1))
expect(router.state.location.pathname).toEqual(
`/templates/${MockTemplate.name}`,
)
expect(API.createTemplateVersion).toHaveBeenCalledWith(MockOrganization.id, {
file_id: MockProvisionerJob.file_id,
parameter_values: [],
provisioner: "terraform",
storage_method: "file",
tags: {},
user_variable_values: [
{ name: "first_variable", value: "First value" },
{ name: "second_variable", value: "2" },
{ name: "third_variable", value: "true" },
{ name: "fourth_variable", value: "Fourth value" },
{ name: "fifth_variable", value: "Fifth value" },
],
})
})

View File

@ -30,8 +30,15 @@ const CreateTemplatePage: FC = () => {
},
},
})
const { starterTemplate, parameters, error, file, jobError, jobLogs } =
state.context
const {
starterTemplate,
parameters,
error,
file,
jobError,
jobLogs,
variables,
} = state.context
const shouldDisplayForm = !state.hasTag("loading")
const onCancel = () => {
@ -59,6 +66,7 @@ const CreateTemplatePage: FC = () => {
error={error}
starterTemplate={starterTemplate}
isSubmitting={state.hasTag("submitting")}
variables={variables}
parameters={parameters}
onCancel={onCancel}
onSubmit={(data) => {

View File

@ -0,0 +1,137 @@
import FormControlLabel from "@material-ui/core/FormControlLabel"
import Radio from "@material-ui/core/Radio"
import RadioGroup from "@material-ui/core/RadioGroup"
import { makeStyles } from "@material-ui/core/styles"
import TextField from "@material-ui/core/TextField"
import { Stack } from "components/Stack/Stack"
import { FC } from "react"
import { TemplateVersionVariable } from "../../api/typesGenerated"
const isBoolean = (variable: TemplateVersionVariable) => {
return variable.type === "bool"
}
const VariableLabel: React.FC<{ variable: TemplateVersionVariable }> = ({
variable,
}) => {
const styles = useStyles()
return (
<label htmlFor={variable.name}>
<span className={styles.labelName}>
var.{variable.name}
{!variable.required && " (optional)"}
</span>
<span className={styles.labelDescription}>{variable.description}</span>
</label>
)
}
export interface VariableInputProps {
disabled?: boolean
variable: TemplateVersionVariable
onChange: (value: string) => void
defaultValue?: string
}
export const VariableInput: FC<VariableInputProps> = ({
disabled,
onChange,
variable,
defaultValue,
}) => {
const styles = useStyles()
return (
<Stack direction="column" spacing={0.75}>
<VariableLabel variable={variable} />
<div className={styles.input}>
<VariableField
disabled={disabled}
onChange={onChange}
variable={variable}
defaultValue={defaultValue}
/>
</div>
</Stack>
)
}
const VariableField: React.FC<VariableInputProps> = ({
disabled,
onChange,
variable,
defaultValue,
}) => {
if (isBoolean(variable)) {
return (
<RadioGroup
id={variable.name}
defaultValue={variable.default_value}
onChange={(event) => {
onChange(event.target.value)
}}
>
<FormControlLabel
disabled={disabled}
value="true"
control={<Radio color="primary" size="small" disableRipple />}
label="True"
/>
<FormControlLabel
disabled={disabled}
value="false"
control={<Radio color="primary" size="small" disableRipple />}
label="False"
/>
</RadioGroup>
)
}
return (
<TextField
id={variable.name}
size="small"
disabled={disabled}
placeholder={variable.sensitive ? "" : variable.default_value}
required={variable.required}
defaultValue={
variable.sensitive ? "" : defaultValue ?? variable.default_value
}
onChange={(event) => {
onChange(event.target.value)
}}
type={
variable.type === "number"
? "number"
: variable.sensitive
? "password"
: "string"
}
/>
)
}
const useStyles = makeStyles((theme) => ({
labelName: {
fontSize: 14,
color: theme.palette.text.secondary,
display: "block",
marginBottom: theme.spacing(0.5),
},
labelDescription: {
fontSize: 16,
color: theme.palette.text.primary,
display: "block",
fontWeight: 600,
},
input: {
display: "flex",
flexDirection: "column",
},
checkbox: {
display: "flex",
alignItems: "center",
gap: theme.spacing(1),
},
}))

View File

@ -2,6 +2,7 @@ import { ComponentMeta, Story } from "@storybook/react"
import {
makeMockApiError,
mockParameterSchema,
MockParameterSchemas,
MockTemplate,
MockTemplateVersionParameter1,
MockTemplateVersionParameter2,
@ -34,41 +35,7 @@ export const Parameters = Template.bind({})
Parameters.args = {
templates: [MockTemplate],
selectedTemplate: MockTemplate,
templateSchema: [
mockParameterSchema({
name: "region",
default_source_value: "🏈 US Central",
description: "Where would you like your workspace to live?",
redisplay_value: true,
validation_contains: [
"🏈 US Central",
"⚽ Brazil East",
"💶 EU West",
"🦘 Australia South",
],
}),
mockParameterSchema({
name: "instance_size",
default_source_value: "Big",
description: "How large should you instance be?",
validation_contains: ["Small", "Medium", "Big"],
redisplay_value: true,
}),
mockParameterSchema({
name: "instance_size",
default_source_value: "Big",
description: "How large should your instance be?",
validation_contains: ["Small", "Medium", "Big"],
redisplay_value: true,
}),
mockParameterSchema({
name: "disable_docker",
description: "Disable Docker?",
validation_value_type: "bool",
default_source_value: "false",
redisplay_value: true,
}),
],
templateSchema: MockParameterSchemas,
createWorkspaceErrors: {},
}

View File

@ -11,17 +11,6 @@ import i18next from "i18next"
const { t } = i18next
const renderTemplateSettingsPage = async () => {
const renderResult = renderWithAuth(<TemplateSettingsPage />, {
route: `/templates/${MockTemplate.name}/settings`,
path: `/templates/:templateId/settings`,
})
// Wait the form to be rendered
const label = t("nameLabel", { ns: "templateSettingsPage" })
await screen.findAllByLabelText(label)
return renderResult
}
const validFormValues = {
name: "Name",
display_name: "A display name",
@ -31,6 +20,17 @@ const validFormValues = {
allow_user_cancel_workspace_jobs: false,
}
const renderTemplateSettingsPage = async () => {
renderWithAuth(<TemplateSettingsPage />, {
route: `/templates/${MockTemplate.name}/settings`,
path: `/templates/:template/settings`,
extraRoutes: [{ path: "templates/:template", element: <></> }],
})
// Wait the form to be rendered
const label = t("nameLabel", { ns: "templateSettingsPage" })
await screen.findAllByLabelText(label)
}
const fillAndSubmitForm = async ({
name,
display_name,
@ -109,17 +109,13 @@ describe("TemplateSettingsPage", () => {
})
await fillAndSubmitForm(validFormValues)
expect(screen.getByDisplayValue(1)).toBeInTheDocument() // the default_ttl_ms
await waitFor(() => expect(API.updateTemplateMeta).toBeCalledTimes(1))
await waitFor(() =>
expect(API.updateTemplateMeta).toBeCalledWith(
"test-template",
expect.objectContaining({
...validFormValues,
default_ttl_ms: 3600000, // the default_ttl_ms to ms
}),
),
expect(API.updateTemplateMeta).toBeCalledWith(
"test-template",
expect.objectContaining({
...validFormValues,
default_ttl_ms: 3600000, // the default_ttl_ms to ms
}),
)
})

View File

@ -13,7 +13,6 @@ import * as API from "api/api"
import i18next from "i18next"
import TemplateVariablesPage from "./TemplateVariablesPage"
import { Language as FooterFormLanguage } from "components/FormFooter/FormFooter"
import { Route } from "react-router-dom"
import * as router from "react-router"
const navigate = jest.fn()
@ -35,9 +34,7 @@ const renderTemplateVariablesPage = () => {
return renderWithAuth(<TemplateVariablesPage />, {
route: `/templates/${MockTemplate.name}/variables`,
path: `/templates/:template/variables`,
routes: (
<Route path={`/templates/${MockTemplate.name}`} element={<></>}></Route>
),
extraRoutes: [{ path: `/templates/${MockTemplate.name}`, element: <></> }],
})
}

View File

@ -8,6 +8,13 @@ import { Permissions } from "xServices/auth/authXService"
import { TemplateVersionFiles } from "util/templateVersion"
import { FileTree } from "util/filetree"
export const MockOrganization: TypesGen.Organization = {
id: "fc0774ce-cc9e-48d4-80ae-88f7a4d4a8b0",
name: "Test Organization",
created_at: "",
updated_at: "",
}
export const MockTemplateDAUResponse: TypesGen.TemplateDAUsResponse = {
entries: [
{ date: "2022-08-27T00:00:00Z", amount: 1 },
@ -140,7 +147,7 @@ export const MockUser: TypesGen.User = {
email: "test@coder.com",
created_at: "",
status: "active",
organization_ids: ["fc0774ce-cc9e-48d4-80ae-88f7a4d4a8b0"],
organization_ids: [MockOrganization.id],
roles: [MockOwnerRole],
avatar_url: "https://avatars.githubusercontent.com/u/95932066?s=200&v=4",
last_seen_at: "",
@ -152,7 +159,7 @@ export const MockUserAdmin: TypesGen.User = {
email: "test@coder.com",
created_at: "",
status: "active",
organization_ids: ["fc0774ce-cc9e-48d4-80ae-88f7a4d4a8b0"],
organization_ids: [MockOrganization.id],
roles: [MockUserAdminRole],
avatar_url: "",
last_seen_at: "",
@ -164,7 +171,7 @@ export const MockUser2: TypesGen.User = {
email: "test2@coder.com",
created_at: "",
status: "active",
organization_ids: ["fc0774ce-cc9e-48d4-80ae-88f7a4d4a8b0"],
organization_ids: [MockOrganization.id],
roles: [],
avatar_url: "",
last_seen_at: "2022-09-14T19:12:21Z",
@ -176,19 +183,12 @@ export const SuspendedMockUser: TypesGen.User = {
email: "iamsuspendedsad!@coder.com",
created_at: "",
status: "suspended",
organization_ids: ["fc0774ce-cc9e-48d4-80ae-88f7a4d4a8b0"],
organization_ids: [MockOrganization.id],
roles: [],
avatar_url: "",
last_seen_at: "",
}
export const MockOrganization: TypesGen.Organization = {
id: "test-org",
name: "Test Organization",
created_at: "",
updated_at: "",
}
export const MockProvisioner: TypesGen.ProvisionerDaemon = {
created_at: "",
id: "test-provisioner",
@ -201,7 +201,7 @@ export const MockProvisionerJob: TypesGen.ProvisionerJob = {
created_at: "",
id: "test-provisioner-job",
status: "succeeded",
file_id: "fc0774ce-cc9e-48d4-80ae-88f7a4d4a8b0",
file_id: MockOrganization.id,
completed_at: "2022-05-17T17:39:01.382927298Z",
tags: {},
}
@ -1240,7 +1240,7 @@ export const MockAuditLog: TypesGen.AuditLog = {
id: "fbd2116a-8961-4954-87ae-e4575bd29ce0",
request_id: "53bded77-7b9d-4e82-8771-991a34d759f9",
time: "2022-05-19T16:45:57.122Z",
organization_id: "fc0774ce-cc9e-48d4-80ae-88f7a4d4a8b0",
organization_id: MockOrganization.id,
ip: "127.0.0.1",
user_agent:
'"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.0.0 Safari/537.36"',
@ -1462,6 +1462,42 @@ export const mockParameterSchema = (
}
}
export const MockParameterSchemas: TypesGen.ParameterSchema[] = [
mockParameterSchema({
name: "region",
default_source_value: "🏈 US Central",
description: "Where would you like your workspace to live?",
redisplay_value: true,
validation_contains: [
"🏈 US Central",
"⚽ Brazil East",
"💶 EU West",
"🦘 Australia South",
],
}),
mockParameterSchema({
name: "instance_size",
default_source_value: "Big",
description: "How large should you instance be?",
validation_contains: ["Small", "Medium", "Big"],
redisplay_value: true,
}),
mockParameterSchema({
name: "instance_size",
default_source_value: "Big",
description: "How large should your instance be?",
validation_contains: ["Small", "Medium", "Big"],
redisplay_value: true,
}),
mockParameterSchema({
name: "disable_docker",
description: "Disable Docker?",
validation_value_type: "bool",
default_source_value: "false",
redisplay_value: true,
}),
]
export const MockTemplateVersionGitAuth: TypesGen.TemplateVersionGitAuth = {
id: "github",
type: "github",

View File

@ -11,10 +11,10 @@ import { i18n } from "i18n"
import { FC, ReactElement } from "react"
import { I18nextProvider } from "react-i18next"
import {
MemoryRouter,
Route,
Routes,
unstable_HistoryRouter as HistoryRouter,
RouterProvider,
createMemoryRouter,
RouteObject,
} from "react-router-dom"
import { RequireAuth } from "../components/RequireAuth/RequireAuth"
import { MockUser } from "./entities"
@ -35,41 +35,53 @@ export const render = (component: ReactElement): RenderResult => {
return wrappedRender(<WrapperComponent>{component}</WrapperComponent>)
}
type RenderWithAuthResult = RenderResult & { user: typeof MockUser }
type RenderWithAuthOptions = {
// The current URL, /workspaces/123
route?: string
// The route path, /workspaces/:workspaceId
path?: string
// Extra routes to add to the router. It is helpful when having redirecting
// routes or multiple routes during the test flow
extraRoutes?: RouteObject[]
// The same as extraRoutes but for routes that don't require authentication
nonAuthenticatedRoutes?: RouteObject[]
}
/**
*
* @param ui The component to render and test
* @param options Can contain `route`, the URL to use, such as /users/user1, and `path`,
* such as /users/:userid. When there are no parameters, they are the same and you can just supply `route`.
*/
export function renderWithAuth(
ui: JSX.Element,
element: JSX.Element,
{
path = "/",
route = "/",
path,
routes,
}: { route?: string; path?: string; routes?: JSX.Element } = {},
): RenderWithAuthResult {
extraRoutes = [],
nonAuthenticatedRoutes = [],
}: RenderWithAuthOptions = {},
) {
const routes: RouteObject[] = [
{
element: <RequireAuth />,
children: [
{
element: <DashboardLayout />,
children: [{ path, element }, ...extraRoutes],
},
],
},
...nonAuthenticatedRoutes,
]
const router = createMemoryRouter(routes, { initialEntries: [route] })
const renderResult = wrappedRender(
<I18nextProvider i18n={i18n}>
<AppProviders>
<MemoryRouter initialEntries={[route]}>
<Routes>
<Route element={<RequireAuth />}>
<Route element={<DashboardLayout />}>
<Route path={path ?? route} element={ui} />
</Route>
</Route>
{routes}
</Routes>
</MemoryRouter>
<RouterProvider router={router} />
</AppProviders>
</I18nextProvider>,
)
return {
user: MockUser,
router,
...renderResult,
}
}

View File

@ -100,3 +100,4 @@ export const templateDisplayNameValidator = (
templateDisplayNameMaxLength,
Language.nameTooLong(displayName, templateDisplayNameMaxLength),
)
.optional()

View File

@ -6,15 +6,19 @@ import {
getTemplateVersionSchema,
uploadTemplateFile,
getTemplateVersionLogs,
getTemplateVersionVariables,
} from "api/api"
import {
CreateTemplateVersionRequest,
ParameterSchema,
ProvisionerJob,
ProvisionerJobLog,
Template,
TemplateExample,
TemplateVersion,
TemplateVersionVariable,
UploadResponse,
VariableValue,
} from "api/typesGenerated"
import { displayError } from "components/GlobalSnackbar/utils"
import { delay } from "util/delay"
@ -24,7 +28,7 @@ import { assign, createMachine } from "xstate"
// 1. upload template tar or use the example ID
// 2. create template version
// 3. wait for it to complete
// 4. if the job failed with the missing parameter error then:
// 4. verify if template has missing parameters or variables
// a. prompt for params
// b. create template version again with the same file hash
// c. wait for it to complete
@ -39,6 +43,7 @@ export interface CreateTemplateData {
default_ttl_hours: number
allow_user_cancel_workspace_jobs: boolean
parameter_values_by_name?: Record<string, string>
user_variable_values?: VariableValue[]
}
interface CreateTemplateContext {
organizationId: string
@ -50,6 +55,7 @@ interface CreateTemplateContext {
version?: TemplateVersion
templateData?: CreateTemplateData
parameters?: ParameterSchema[]
variables?: TemplateVersionVariable[]
// file is used in the FE to show the filename and some other visual stuff
// uploadedFile is the response from the server to use in the API
file?: File
@ -78,7 +84,7 @@ export const createTemplateMachine =
createFirstVersion: {
data: TemplateVersion
}
createVersionWithParameters: {
createVersionWithParametersAndVariables: {
data: TemplateVersion
}
waitForJobToBeCompleted: {
@ -87,6 +93,12 @@ export const createTemplateMachine =
loadParameterSchema: {
data: ParameterSchema[]
}
checkParametersAndVariables: {
data: {
parameters?: ParameterSchema[]
variables?: TemplateVersionVariable[]
}
}
createTemplate: {
data: Template
}
@ -170,17 +182,15 @@ export const createTemplateMachine =
invoke: {
src: "waitForJobToBeCompleted",
onDone: [
{
target: "loadingMissingParameters",
cond: "hasMissingParameters",
actions: ["assignVersion"],
},
{
target: "loadingVersionLogs",
actions: ["assignJobError", "assignVersion"],
cond: "hasFailed",
},
{ target: "creatingTemplate", actions: ["assignVersion"] },
{
target: "checkingParametersAndVariables",
actions: ["assignVersion"],
},
],
onError: {
target: "#createTemplate.idle",
@ -189,26 +199,19 @@ export const createTemplateMachine =
},
tags: ["submitting"],
},
loadingVersionLogs: {
checkingParametersAndVariables: {
invoke: {
src: "loadVersionLogs",
onDone: {
target: "#createTemplate.idle",
actions: ["assignJobLogs"],
},
onError: {
target: "#createTemplate.idle",
actions: ["assignError"],
},
},
},
loadingMissingParameters: {
invoke: {
src: "loadParameterSchema",
onDone: {
target: "promptParameters",
actions: ["assignParameters"],
},
src: "checkParametersAndVariables",
onDone: [
{
target: "creatingTemplate",
cond: "hasNoParametersOrVariables",
},
{
target: "promptParametersAndVariables",
actions: ["assignParametersAndVariables"],
},
],
onError: {
target: "#createTemplate.idle",
actions: ["assignError"],
@ -216,24 +219,24 @@ export const createTemplateMachine =
},
tags: ["submitting"],
},
promptParameters: {
promptParametersAndVariables: {
on: {
CREATE: {
target: "creatingVersionWithParameters",
target: "creatingVersionWithParametersAndVariables",
actions: ["assignTemplateData"],
},
},
},
creatingVersionWithParameters: {
creatingVersionWithParametersAndVariables: {
invoke: {
src: "createVersionWithParameters",
src: "createVersionWithParametersAndVariables",
onDone: {
target: "waitingForJobToBeCompleted",
actions: ["assignVersion"],
},
onError: {
actions: ["assignError"],
target: "promptParameters",
target: "promptParametersAndVariables",
},
},
tags: ["submitting"],
@ -255,6 +258,19 @@ export const createTemplateMachine =
created: {
type: "final",
},
loadingVersionLogs: {
invoke: {
src: "loadVersionLogs",
onDone: {
target: "#createTemplate.idle",
actions: ["assignJobLogs"],
},
onError: {
target: "#createTemplate.idle",
actions: ["assignError"],
},
},
},
},
},
},
@ -300,7 +316,7 @@ export const createTemplateMachine =
throw new Error("No file or example provided")
},
createVersionWithParameters: async ({
createVersionWithParametersAndVariables: async ({
organizationId,
parameters,
templateData,
@ -313,11 +329,11 @@ export const createTemplateMachine =
throw new Error("No template data defined")
}
const { parameter_values_by_name } = templateData
// Get parameter values if they are needed/present
const parameterValues: CreateTemplateVersionRequest["parameter_values"] =
[]
if (parameters) {
const { parameter_values_by_name } = templateData
parameters.forEach((schema) => {
const value = parameter_values_by_name?.[schema.name]
parameterValues.push({
@ -334,6 +350,7 @@ export const createTemplateMachine =
file_id: version.job.file_id,
provisioner: "terraform",
parameter_values: parameterValues,
user_variable_values: templateData.user_variable_values,
tags: {},
})
},
@ -342,24 +359,48 @@ export const createTemplateMachine =
throw new Error("Version not defined")
}
let status = version.job.status
while (["pending", "running"].includes(status)) {
let job = version.job
while (isPendingOrRunning(job)) {
version = await getTemplateVersion(version.id)
status = version.job.status
job = version.job
// Delay the verification in two seconds to not overload the server
// with too many requests Maybe at some point we could have a
// websocket for template version Also, preferred doing this way to
// avoid a new state since we don't need to reflect it on the UI
await delay(2_000)
if (isPendingOrRunning(job)) {
await delay(2_000)
}
}
return version
},
loadParameterSchema: async ({ version }) => {
checkParametersAndVariables: async ({ version }) => {
if (!version) {
throw new Error("Version not defined")
}
return getTemplateVersionSchema(version.id)
let promiseParameter: Promise<ParameterSchema[]> | undefined =
undefined
let promiseVariables: Promise<TemplateVersionVariable[]> | undefined =
undefined
if (isMissingParameter(version)) {
promiseParameter = getTemplateVersionSchema(version.id)
}
if (isMissingVariables(version)) {
promiseVariables = getTemplateVersionVariables(version.id)
}
const [parameters, variables] = await Promise.all([
promiseParameter,
promiseVariables,
])
return {
parameters,
variables,
}
},
createTemplate: async ({ organizationId, version, templateData }) => {
if (!version) {
@ -401,7 +442,10 @@ export const createTemplateMachine =
}),
assignVersion: assign({ version: (_, { data }) => data }),
assignTemplateData: assign({ templateData: (_, { data }) => data }),
assignParameters: assign({ parameters: (_, { data }) => data }),
assignParametersAndVariables: assign({
parameters: (_, { data }) => data.parameters,
variables: (_, { data }) => data.variables,
}),
assignFile: assign({ file: (_, { file }) => file }),
assignUploadResponse: assign({ uploadResponse: (_, { data }) => data }),
removeFile: assign({
@ -414,11 +458,31 @@ export const createTemplateMachine =
isExampleProvided: ({ exampleId }) => Boolean(exampleId),
isNotUsingExample: ({ exampleId }) => !exampleId,
hasFile: ({ file }) => Boolean(file),
hasFailed: (_, { data }) => data.job.status === "failed",
hasMissingParameters: (_, { data }) =>
hasFailed: (_, { data }) =>
Boolean(
data.job.error && data.job.error.includes("missing parameter"),
data.job.status === "failed" &&
!isMissingParameter(data) &&
!isMissingVariables(data),
),
hasNoParametersOrVariables: (_, { data }) =>
data.parameters === undefined && data.variables === undefined,
},
},
)
const isMissingParameter = (version: TemplateVersion) => {
return Boolean(
version.job.error && version.job.error.includes("missing parameter"),
)
}
const isMissingVariables = (version: TemplateVersion) => {
return Boolean(
version.job.error &&
version.job.error.includes("required template variables"),
)
}
const isPendingOrRunning = (job: ProvisionerJob) => {
return job.status === "pending" || job.status === "running"
}

View File

@ -1844,6 +1844,41 @@
resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-1.0.1.tgz#88d7ac31811ab0cef14aaaeae2a0474923b278bc"
integrity sha512-eBV5rvW4dRFOU1eajN7FmYxjAIVz/mRHgUE9En9mBn6m3mulK3WTR5C3iQhL9MZ14rWAq+xOlEaCkDiW0/heOg==
"@remix-run/web-blob@^3.0.4":
version "3.0.4"
resolved "https://registry.yarnpkg.com/@remix-run/web-blob/-/web-blob-3.0.4.tgz#99c67b9d0fb641bd0c07d267fd218ae5aa4ae5ed"
integrity sha512-AfegzZvSSDc+LwnXV+SwROTrDtoLiPxeFW+jxgvtDAnkuCX1rrzmVJ6CzqZ1Ai0bVfmJadkG5GxtAfYclpPmgw==
dependencies:
"@remix-run/web-stream" "^1.0.0"
web-encoding "1.1.5"
"@remix-run/web-fetch@4.3.2":
version "4.3.2"
resolved "https://registry.yarnpkg.com/@remix-run/web-fetch/-/web-fetch-4.3.2.tgz#193758bb7a301535540f0e3a86c743283f81cf56"
integrity sha512-aRNaaa0Fhyegv/GkJ/qsxMhXvyWGjPNgCKrStCvAvV1XXphntZI0nQO/Fl02LIQg3cGL8lDiOXOS1gzqDOlG5w==
dependencies:
"@remix-run/web-blob" "^3.0.4"
"@remix-run/web-form-data" "^3.0.3"
"@remix-run/web-stream" "^1.0.3"
"@web3-storage/multipart-parser" "^1.0.0"
abort-controller "^3.0.0"
data-uri-to-buffer "^3.0.1"
mrmime "^1.0.0"
"@remix-run/web-form-data@^3.0.3":
version "3.0.4"
resolved "https://registry.yarnpkg.com/@remix-run/web-form-data/-/web-form-data-3.0.4.tgz#18c5795edaffbc88c320a311766dc04644125bab"
integrity sha512-UMF1jg9Vu9CLOf8iHBdY74Mm3PUvMW8G/XZRJE56SxKaOFWGSWlfxfG+/a3boAgHFLTkP7K4H1PxlRugy1iQtw==
dependencies:
web-encoding "1.1.5"
"@remix-run/web-stream@^1.0.0", "@remix-run/web-stream@^1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@remix-run/web-stream/-/web-stream-1.0.3.tgz#3284a6a45675d1455c4d9c8f31b89225c9006438"
integrity sha512-wlezlJaA5NF6SsNMiwQnnAW6tnPzQ5I8qk0Y0pSohm0eHKa2FQ1QhEKLVVcDDu02TmkfHgnux0igNfeYhDOXiA==
dependencies:
web-streams-polyfill "^3.1.1"
"@sinclair/typebox@^0.24.1":
version "0.24.51"
resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.24.51.tgz#645f33fe4e02defe26f2f5c0410e1c094eac7f5f"
@ -3623,6 +3658,11 @@
magic-string "^0.26.2"
react-refresh "^0.14.0"
"@web3-storage/multipart-parser@^1.0.0":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@web3-storage/multipart-parser/-/multipart-parser-1.0.0.tgz#6b69dc2a32a5b207ba43e556c25cc136a56659c4"
integrity sha512-BEO6al7BYqcnfX15W2cnGR+Q566ACXAT9UQykORCWW80lmkpWsnEob6zJS1ZVBKsSJC8+7vJkHwlp+lXG1UCdw==
"@webassemblyjs/ast@1.11.1":
version "1.11.1"
resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.11.1.tgz#2bfd767eae1a6996f432ff7e8d7fc75679c0b6a7"
@ -4055,6 +4095,13 @@ abbrev@1:
resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8"
integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==
abort-controller@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392"
integrity sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==
dependencies:
event-target-shim "^5.0.0"
accepts@~1.3.5, accepts@~1.3.8:
version "1.3.8"
resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e"
@ -5887,6 +5934,11 @@ damerau-levenshtein@^1.0.8:
resolved "https://registry.yarnpkg.com/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz#b43d286ccbd36bc5b2f7ed41caf2d0aba1f8a6e7"
integrity sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==
data-uri-to-buffer@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/data-uri-to-buffer/-/data-uri-to-buffer-3.0.1.tgz#594b8973938c5bc2c33046535785341abc4f3636"
integrity sha512-WboRycPNsVw3B3TL559F7kuBUM4d8CgMEvk6xEJlOp7OBPjt6G7z8WMWlD2rOFZLk6OYfFIUGsCOWzcQH9K2og==
data-urls@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/data-urls/-/data-urls-2.0.0.tgz#156485a72963a970f5d5821aaf642bef2bf2db9b"
@ -6978,6 +7030,11 @@ event-loop-spinner@^2.0.0, event-loop-spinner@^2.1.0:
dependencies:
tslib "^2.1.0"
event-target-shim@^5.0.0:
version "5.0.1"
resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789"
integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==
events@^3.0.0, events@^3.2.0, events@^3.3.0:
version "3.3.0"
resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400"
@ -10862,6 +10919,11 @@ mri@^1.1.0:
resolved "https://registry.yarnpkg.com/mri/-/mri-1.2.0.tgz#6721480fec2a11a4889861115a48b6cbe7cc8f0b"
integrity sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==
mrmime@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/mrmime/-/mrmime-1.0.1.tgz#5f90c825fad4bdd41dc914eff5d1a8cfdaf24f27"
integrity sha512-hzzEagAgDyoU1Q6yg5uI+AorQgdvMCur3FcKf7NhMKWsaYg+RnbTyHRa/9IlLF9rf455MOCtcqqrQQ83pPP7Uw==
ms@2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
@ -14653,7 +14715,7 @@ wcwidth@^1.0.1:
dependencies:
defaults "^1.0.3"
web-encoding@^1.1.5:
web-encoding@1.1.5, web-encoding@^1.1.5:
version "1.1.5"
resolved "https://registry.yarnpkg.com/web-encoding/-/web-encoding-1.1.5.tgz#fc810cf7667364a6335c939913f5051d3e0c4864"
integrity sha512-HYLeVCdJ0+lBYV2FvNZmv3HJ2Nt0QYXqZojk3d9FJOLkwnuhzM9tmamh8d7HPM8QqjKH8DeHkFTx+CFlWpZZDA==
@ -14667,6 +14729,11 @@ web-namespaces@^1.0.0:
resolved "https://registry.yarnpkg.com/web-namespaces/-/web-namespaces-1.1.4.tgz#bc98a3de60dadd7faefc403d1076d529f5e030ec"
integrity sha512-wYxSGajtmoP4WxfejAPIr4l0fVh+jeMXZb08wNc0tMg6xsfZXj3cECqIK0G7ZAqUq0PP8WlMDtaOGVBTAWztNw==
web-streams-polyfill@^3.1.1:
version "3.2.1"
resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-3.2.1.tgz#71c2718c52b45fd49dbeee88634b3a60ceab42a6"
integrity sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q==
webidl-conversions@^3.0.0:
version "3.0.1"
resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871"