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
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 "jest-location-mock"
import { TextEncoder, TextDecoder } from "util" import { TextEncoder, TextDecoder } from "util"
import { Blob } from "buffer" import { Blob } from "buffer"
import { fetch, Request, Response, Headers } from "@remix-run/web-fetch"
global.TextEncoder = TextEncoder global.TextEncoder = TextEncoder
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Polyfill for jsdom // 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 // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Polyfill for jsdom
global.Blob = Blob as any 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 // Polyfill the getRandomValues that is used on utils/random.ts
Object.defineProperty(global.self, "crypto", { Object.defineProperty(global.self, "crypto", {
value: { value: {

View File

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

View File

@ -1,6 +1,5 @@
import { screen } from "@testing-library/react" import { screen } from "@testing-library/react"
import { rest } from "msw" import { rest } from "msw"
import { Route } from "react-router-dom"
import { renderWithAuth } from "testHelpers/renderHelpers" import { renderWithAuth } from "testHelpers/renderHelpers"
import { server } from "testHelpers/server" import { server } from "testHelpers/server"
@ -20,7 +19,12 @@ describe("RequireAuth", () => {
) )
renderWithAuth(<h1>Test</h1>, { renderWithAuth(<h1>Test</h1>, {
routes: <Route path="setup" element={<h1>Setup</h1>} />, nonAuthenticatedRoutes: [
{
path: "setup",
element: <h1>Setup</h1>,
},
],
}) })
await screen.findByText("Setup") 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, ParameterSchema,
ProvisionerJobLog, ProvisionerJobLog,
TemplateExample, TemplateExample,
TemplateVersionVariable,
} from "api/typesGenerated" } from "api/typesGenerated"
import { FormFooter } from "components/FormFooter/FormFooter"
import { ParameterInput } from "components/ParameterInput/ParameterInput" import { ParameterInput } from "components/ParameterInput/ParameterInput"
import { Stack } from "components/Stack/Stack" import { Stack } from "components/Stack/Stack"
import { import {
@ -17,21 +17,30 @@ import { useFormik } from "formik"
import { SelectedTemplate } from "pages/CreateWorkspacePage/SelectedTemplate" import { SelectedTemplate } from "pages/CreateWorkspacePage/SelectedTemplate"
import { FC } from "react" import { FC } from "react"
import { useTranslation } from "react-i18next" 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 { CreateTemplateData } from "xServices/createTemplate/createTemplateXService"
import * as Yup from "yup" import * as Yup from "yup"
import { WorkspaceBuildLogs } from "components/WorkspaceBuildLogs/WorkspaceBuildLogs" import { WorkspaceBuildLogs } from "components/WorkspaceBuildLogs/WorkspaceBuildLogs"
import { HelpTooltip, HelpTooltipText } from "components/Tooltips/HelpTooltip" import { HelpTooltip, HelpTooltipText } from "components/Tooltips/HelpTooltip"
import { LazyIconField } from "components/IconField/LazyIconField" 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({ const validationSchema = Yup.object({
name: nameValidator("Name"), name: nameValidator("Name"),
display_name: Yup.string().optional(), display_name: templateDisplayNameValidator("Display name"),
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(),
}) })
const defaultInitialValues: CreateTemplateData = { const defaultInitialValues: CreateTemplateData = {
@ -41,7 +50,6 @@ const defaultInitialValues: CreateTemplateData = {
icon: "", icon: "",
default_ttl_hours: 24, default_ttl_hours: 24,
allow_user_cancel_workspace_jobs: false, allow_user_cancel_workspace_jobs: false,
parameter_values_by_name: undefined,
} }
const getInitialValues = (starterTemplate?: TemplateExample) => { const getInitialValues = (starterTemplate?: TemplateExample) => {
@ -58,31 +66,32 @@ const getInitialValues = (starterTemplate?: TemplateExample) => {
} }
} }
interface CreateTemplateFormProps { export interface CreateTemplateFormProps {
starterTemplate?: TemplateExample
error?: unknown
parameters?: ParameterSchema[]
isSubmitting: boolean
onCancel: () => void onCancel: () => void
onSubmit: (data: CreateTemplateData) => void onSubmit: (data: CreateTemplateData) => void
isSubmitting: boolean
upload: TemplateUploadProps upload: TemplateUploadProps
starterTemplate?: TemplateExample
parameters?: ParameterSchema[]
variables?: TemplateVersionVariable[]
error?: unknown
jobError?: string jobError?: string
logs?: ProvisionerJobLog[] logs?: ProvisionerJobLog[]
} }
export const CreateTemplateForm: FC<CreateTemplateFormProps> = ({ export const CreateTemplateForm: FC<CreateTemplateFormProps> = ({
starterTemplate,
error,
parameters,
isSubmitting,
onCancel, onCancel,
onSubmit, onSubmit,
starterTemplate,
parameters,
variables,
isSubmitting,
upload, upload,
error,
jobError, jobError,
logs, logs,
}) => { }) => {
const styles = useStyles() const styles = useStyles()
const formFooterStyles = useFormFooterStyles()
const form = useFormik<CreateTemplateData>({ const form = useFormik<CreateTemplateData>({
initialValues: getInitialValues(starterTemplate), initialValues: getInitialValues(starterTemplate),
validationSchema, validationSchema,
@ -92,24 +101,23 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = ({
const { t } = useTranslation("createTemplatePage") const { t } = useTranslation("createTemplatePage")
return ( return (
<form onSubmit={form.handleSubmit}> <HorizontalForm onSubmit={form.handleSubmit}>
<Stack direction="column" spacing={10} className={styles.formSections}>
{/* General info */} {/* General info */}
<div className={styles.formSection}> <FormSection
<div className={styles.formSectionInfo}> title={t("form.generalInfo.title")}
<h2 className={styles.formSectionInfoTitle}> description={t("form.generalInfo.description")}
{t("form.generalInfo.title")} >
</h2> <FormFields>
<p className={styles.formSectionInfoDescription}>
{t("form.generalInfo.description")}
</p>
</div>
<Stack direction="column" className={styles.formSectionFields}>
{starterTemplate ? ( {starterTemplate ? (
<SelectedTemplate template={starterTemplate} /> <SelectedTemplate template={starterTemplate} />
) : ( ) : (
<TemplateUpload {...upload} /> <TemplateUpload
{...upload}
onUpload={async (file) => {
await fillNameAndDisplayWithFilename(file.name, form)
upload.onUpload(file)
}}
/>
)} )}
<TextField <TextField
@ -118,24 +126,19 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = ({
onChange={onChangeTrimmed(form)} onChange={onChangeTrimmed(form)}
autoFocus autoFocus
fullWidth fullWidth
required
label={t("form.fields.name")} label={t("form.fields.name")}
variant="outlined" variant="outlined"
/> />
</Stack> </FormFields>
</div> </FormSection>
{/* Display info */} {/* Display info */}
<div className={styles.formSection}> <FormSection
<div className={styles.formSectionInfo}> title={t("form.displayInfo.title")}
<h2 className={styles.formSectionInfoTitle}> description={t("form.displayInfo.description")}
{t("form.displayInfo.title")} >
</h2> <FormFields>
<p className={styles.formSectionInfoDescription}>
{t("form.displayInfo.description")}
</p>
</div>
<Stack direction="column" className={styles.formSectionFields}>
<TextField <TextField
{...getFieldHelpers("display_name")} {...getFieldHelpers("display_name")}
disabled={isSubmitting} disabled={isSubmitting}
@ -163,21 +166,15 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = ({
variant="outlined" variant="outlined"
onPickEmoji={(value) => form.setFieldValue("icon", value)} onPickEmoji={(value) => form.setFieldValue("icon", value)}
/> />
</Stack> </FormFields>
</div> </FormSection>
{/* Schedule */} {/* Schedule */}
<div className={styles.formSection}> <FormSection
<div className={styles.formSectionInfo}> title={t("form.schedule.title")}
<h2 className={styles.formSectionInfoTitle}> description={t("form.schedule.description")}
{t("form.schedule.title")} >
</h2> <FormFields>
<p className={styles.formSectionInfoDescription}>
{t("form.schedule.description")}
</p>
</div>
<Stack direction="column" className={styles.formSectionFields}>
<TextField <TextField
{...getFieldHelpers("default_ttl_hours")} {...getFieldHelpers("default_ttl_hours")}
disabled={isSubmitting} disabled={isSubmitting}
@ -188,21 +185,15 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = ({
type="number" type="number"
helperText={t("form.helperText.autoStop")} helperText={t("form.helperText.autoStop")}
/> />
</Stack> </FormFields>
</div> </FormSection>
{/* Operations */} {/* Operations */}
<div className={styles.formSection}> <FormSection
<div className={styles.formSectionInfo}> title={t("form.operations.title")}
<h2 className={styles.formSectionInfoTitle}> description={t("form.operations.description")}
{t("form.operations.title")} >
</h2> <FormFields>
<p className={styles.formSectionInfoDescription}>
{t("form.operations.description")}
</p>
</div>
<Stack direction="column" className={styles.formSectionFields}>
<label htmlFor="allow_user_cancel_workspace_jobs"> <label htmlFor="allow_user_cancel_workspace_jobs">
<Stack direction="row" spacing={1}> <Stack direction="row" spacing={1}>
<Checkbox <Checkbox
@ -235,22 +226,16 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = ({
</Stack> </Stack>
</Stack> </Stack>
</label> </label>
</Stack> </FormFields>
</div> </FormSection>
{/* Parameters */} {/* Parameters */}
{parameters && ( {parameters && (
<div className={styles.formSection}> <FormSection
<div className={styles.formSectionInfo}> title={t("form.parameters.title")}
<h2 className={styles.formSectionInfoTitle}> description={t("form.parameters.description")}
{t("form.parameters.title")} >
</h2> <FormFields>
<p className={styles.formSectionInfoDescription}>
{t("form.parameters.description")}
</p>
</div>
<Stack direction="column" className={styles.formSectionFields}>
{parameters.map((schema) => ( {parameters.map((schema) => (
<ParameterInput <ParameterInput
schema={schema} schema={schema}
@ -264,8 +249,32 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = ({
}} }}
/> />
))} ))}
</Stack> </FormFields>
</div> </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 && ( {jobError && (
@ -273,8 +282,8 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = ({
<div className={styles.error}> <div className={styles.error}>
<h5 className={styles.errorTitle}>Error during provisioning</h5> <h5 className={styles.errorTitle}>Error during provisioning</h5>
<p className={styles.errorDescription}> <p className={styles.errorDescription}>
Looks like we found an error during the template provisioning. Looks like we found an error during the template provisioning. You
You can see the logs bellow. can see the logs bellow.
</p> </p>
<code className={styles.errorDetails}>{jobError}</code> <code className={styles.errorDetails}>{jobError}</code>
@ -285,65 +294,30 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = ({
)} )}
<FormFooter <FormFooter
styles={formFooterStyles}
onCancel={onCancel} onCancel={onCancel}
isLoading={isSubmitting} isLoading={isSubmitting}
submitLabel={jobError ? "Retry" : "Create template"} submitLabel={jobError ? "Retry" : "Create template"}
/> />
</Stack> </HorizontalForm>
</form>
) )
} }
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) => ({ 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: { optionText: {
fontSize: theme.spacing(2), fontSize: theme.spacing(2),
color: theme.palette.text.primary, color: theme.palette.text.primary,
@ -379,25 +353,3 @@ const useStyles = makeStyles((theme) => ({
fontSize: theme.spacing(2), 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 } = const {
state.context starterTemplate,
parameters,
error,
file,
jobError,
jobLogs,
variables,
} = state.context
const shouldDisplayForm = !state.hasTag("loading") const shouldDisplayForm = !state.hasTag("loading")
const onCancel = () => { const onCancel = () => {
@ -59,6 +66,7 @@ const CreateTemplatePage: FC = () => {
error={error} error={error}
starterTemplate={starterTemplate} starterTemplate={starterTemplate}
isSubmitting={state.hasTag("submitting")} isSubmitting={state.hasTag("submitting")}
variables={variables}
parameters={parameters} parameters={parameters}
onCancel={onCancel} onCancel={onCancel}
onSubmit={(data) => { 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 { import {
makeMockApiError, makeMockApiError,
mockParameterSchema, mockParameterSchema,
MockParameterSchemas,
MockTemplate, MockTemplate,
MockTemplateVersionParameter1, MockTemplateVersionParameter1,
MockTemplateVersionParameter2, MockTemplateVersionParameter2,
@ -34,41 +35,7 @@ export const Parameters = Template.bind({})
Parameters.args = { Parameters.args = {
templates: [MockTemplate], templates: [MockTemplate],
selectedTemplate: MockTemplate, selectedTemplate: MockTemplate,
templateSchema: [ templateSchema: MockParameterSchemas,
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,
}),
],
createWorkspaceErrors: {}, createWorkspaceErrors: {},
} }

View File

@ -11,17 +11,6 @@ import i18next from "i18next"
const { t } = 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 = { const validFormValues = {
name: "Name", name: "Name",
display_name: "A display name", display_name: "A display name",
@ -31,6 +20,17 @@ const validFormValues = {
allow_user_cancel_workspace_jobs: false, 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 ({ const fillAndSubmitForm = async ({
name, name,
display_name, display_name,
@ -109,17 +109,13 @@ describe("TemplateSettingsPage", () => {
}) })
await fillAndSubmitForm(validFormValues) await fillAndSubmitForm(validFormValues)
expect(screen.getByDisplayValue(1)).toBeInTheDocument() // the default_ttl_ms
await waitFor(() => expect(API.updateTemplateMeta).toBeCalledTimes(1)) await waitFor(() => expect(API.updateTemplateMeta).toBeCalledTimes(1))
await waitFor(() =>
expect(API.updateTemplateMeta).toBeCalledWith( expect(API.updateTemplateMeta).toBeCalledWith(
"test-template", "test-template",
expect.objectContaining({ expect.objectContaining({
...validFormValues, ...validFormValues,
default_ttl_ms: 3600000, // the default_ttl_ms to ms 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 i18next from "i18next"
import TemplateVariablesPage from "./TemplateVariablesPage" import TemplateVariablesPage from "./TemplateVariablesPage"
import { Language as FooterFormLanguage } from "components/FormFooter/FormFooter" import { Language as FooterFormLanguage } from "components/FormFooter/FormFooter"
import { Route } from "react-router-dom"
import * as router from "react-router" import * as router from "react-router"
const navigate = jest.fn() const navigate = jest.fn()
@ -35,9 +34,7 @@ const renderTemplateVariablesPage = () => {
return renderWithAuth(<TemplateVariablesPage />, { return renderWithAuth(<TemplateVariablesPage />, {
route: `/templates/${MockTemplate.name}/variables`, route: `/templates/${MockTemplate.name}/variables`,
path: `/templates/:template/variables`, path: `/templates/:template/variables`,
routes: ( extraRoutes: [{ path: `/templates/${MockTemplate.name}`, element: <></> }],
<Route path={`/templates/${MockTemplate.name}`} element={<></>}></Route>
),
}) })
} }

View File

@ -8,6 +8,13 @@ import { Permissions } from "xServices/auth/authXService"
import { TemplateVersionFiles } from "util/templateVersion" import { TemplateVersionFiles } from "util/templateVersion"
import { FileTree } from "util/filetree" 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 = { export const MockTemplateDAUResponse: TypesGen.TemplateDAUsResponse = {
entries: [ entries: [
{ date: "2022-08-27T00:00:00Z", amount: 1 }, { date: "2022-08-27T00:00:00Z", amount: 1 },
@ -140,7 +147,7 @@ export const MockUser: TypesGen.User = {
email: "test@coder.com", email: "test@coder.com",
created_at: "", created_at: "",
status: "active", status: "active",
organization_ids: ["fc0774ce-cc9e-48d4-80ae-88f7a4d4a8b0"], organization_ids: [MockOrganization.id],
roles: [MockOwnerRole], roles: [MockOwnerRole],
avatar_url: "https://avatars.githubusercontent.com/u/95932066?s=200&v=4", avatar_url: "https://avatars.githubusercontent.com/u/95932066?s=200&v=4",
last_seen_at: "", last_seen_at: "",
@ -152,7 +159,7 @@ export const MockUserAdmin: TypesGen.User = {
email: "test@coder.com", email: "test@coder.com",
created_at: "", created_at: "",
status: "active", status: "active",
organization_ids: ["fc0774ce-cc9e-48d4-80ae-88f7a4d4a8b0"], organization_ids: [MockOrganization.id],
roles: [MockUserAdminRole], roles: [MockUserAdminRole],
avatar_url: "", avatar_url: "",
last_seen_at: "", last_seen_at: "",
@ -164,7 +171,7 @@ export const MockUser2: TypesGen.User = {
email: "test2@coder.com", email: "test2@coder.com",
created_at: "", created_at: "",
status: "active", status: "active",
organization_ids: ["fc0774ce-cc9e-48d4-80ae-88f7a4d4a8b0"], organization_ids: [MockOrganization.id],
roles: [], roles: [],
avatar_url: "", avatar_url: "",
last_seen_at: "2022-09-14T19:12:21Z", last_seen_at: "2022-09-14T19:12:21Z",
@ -176,19 +183,12 @@ export const SuspendedMockUser: TypesGen.User = {
email: "iamsuspendedsad!@coder.com", email: "iamsuspendedsad!@coder.com",
created_at: "", created_at: "",
status: "suspended", status: "suspended",
organization_ids: ["fc0774ce-cc9e-48d4-80ae-88f7a4d4a8b0"], organization_ids: [MockOrganization.id],
roles: [], roles: [],
avatar_url: "", avatar_url: "",
last_seen_at: "", last_seen_at: "",
} }
export const MockOrganization: TypesGen.Organization = {
id: "test-org",
name: "Test Organization",
created_at: "",
updated_at: "",
}
export const MockProvisioner: TypesGen.ProvisionerDaemon = { export const MockProvisioner: TypesGen.ProvisionerDaemon = {
created_at: "", created_at: "",
id: "test-provisioner", id: "test-provisioner",
@ -201,7 +201,7 @@ export const MockProvisionerJob: TypesGen.ProvisionerJob = {
created_at: "", created_at: "",
id: "test-provisioner-job", id: "test-provisioner-job",
status: "succeeded", status: "succeeded",
file_id: "fc0774ce-cc9e-48d4-80ae-88f7a4d4a8b0", file_id: MockOrganization.id,
completed_at: "2022-05-17T17:39:01.382927298Z", completed_at: "2022-05-17T17:39:01.382927298Z",
tags: {}, tags: {},
} }
@ -1240,7 +1240,7 @@ export const MockAuditLog: TypesGen.AuditLog = {
id: "fbd2116a-8961-4954-87ae-e4575bd29ce0", id: "fbd2116a-8961-4954-87ae-e4575bd29ce0",
request_id: "53bded77-7b9d-4e82-8771-991a34d759f9", request_id: "53bded77-7b9d-4e82-8771-991a34d759f9",
time: "2022-05-19T16:45:57.122Z", time: "2022-05-19T16:45:57.122Z",
organization_id: "fc0774ce-cc9e-48d4-80ae-88f7a4d4a8b0", organization_id: MockOrganization.id,
ip: "127.0.0.1", ip: "127.0.0.1",
user_agent: 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"', '"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 = { export const MockTemplateVersionGitAuth: TypesGen.TemplateVersionGitAuth = {
id: "github", id: "github",
type: "github", type: "github",

View File

@ -11,10 +11,10 @@ import { i18n } from "i18n"
import { FC, ReactElement } from "react" import { FC, ReactElement } from "react"
import { I18nextProvider } from "react-i18next" import { I18nextProvider } from "react-i18next"
import { import {
MemoryRouter,
Route,
Routes,
unstable_HistoryRouter as HistoryRouter, unstable_HistoryRouter as HistoryRouter,
RouterProvider,
createMemoryRouter,
RouteObject,
} from "react-router-dom" } from "react-router-dom"
import { RequireAuth } from "../components/RequireAuth/RequireAuth" import { RequireAuth } from "../components/RequireAuth/RequireAuth"
import { MockUser } from "./entities" import { MockUser } from "./entities"
@ -35,41 +35,53 @@ export const render = (component: ReactElement): RenderResult => {
return wrappedRender(<WrapperComponent>{component}</WrapperComponent>) 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( export function renderWithAuth(
ui: JSX.Element, element: JSX.Element,
{ {
path = "/",
route = "/", route = "/",
path, extraRoutes = [],
routes, nonAuthenticatedRoutes = [],
}: { route?: string; path?: string; routes?: JSX.Element } = {}, }: RenderWithAuthOptions = {},
): RenderWithAuthResult { ) {
const routes: RouteObject[] = [
{
element: <RequireAuth />,
children: [
{
element: <DashboardLayout />,
children: [{ path, element }, ...extraRoutes],
},
],
},
...nonAuthenticatedRoutes,
]
const router = createMemoryRouter(routes, { initialEntries: [route] })
const renderResult = wrappedRender( const renderResult = wrappedRender(
<I18nextProvider i18n={i18n}> <I18nextProvider i18n={i18n}>
<AppProviders> <AppProviders>
<MemoryRouter initialEntries={[route]}> <RouterProvider router={router} />
<Routes>
<Route element={<RequireAuth />}>
<Route element={<DashboardLayout />}>
<Route path={path ?? route} element={ui} />
</Route>
</Route>
{routes}
</Routes>
</MemoryRouter>
</AppProviders> </AppProviders>
</I18nextProvider>, </I18nextProvider>,
) )
return { return {
user: MockUser, user: MockUser,
router,
...renderResult, ...renderResult,
} }
} }

View File

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

View File

@ -6,15 +6,19 @@ import {
getTemplateVersionSchema, getTemplateVersionSchema,
uploadTemplateFile, uploadTemplateFile,
getTemplateVersionLogs, getTemplateVersionLogs,
getTemplateVersionVariables,
} from "api/api" } from "api/api"
import { import {
CreateTemplateVersionRequest, CreateTemplateVersionRequest,
ParameterSchema, ParameterSchema,
ProvisionerJob,
ProvisionerJobLog, ProvisionerJobLog,
Template, Template,
TemplateExample, TemplateExample,
TemplateVersion, TemplateVersion,
TemplateVersionVariable,
UploadResponse, UploadResponse,
VariableValue,
} from "api/typesGenerated" } from "api/typesGenerated"
import { displayError } from "components/GlobalSnackbar/utils" import { displayError } from "components/GlobalSnackbar/utils"
import { delay } from "util/delay" import { delay } from "util/delay"
@ -24,7 +28,7 @@ import { assign, createMachine } from "xstate"
// 1. upload template tar or use the example ID // 1. upload template tar or use the example ID
// 2. create template version // 2. create template version
// 3. wait for it to complete // 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 // a. prompt for params
// b. create template version again with the same file hash // b. create template version again with the same file hash
// c. wait for it to complete // c. wait for it to complete
@ -39,6 +43,7 @@ export interface CreateTemplateData {
default_ttl_hours: number default_ttl_hours: number
allow_user_cancel_workspace_jobs: boolean allow_user_cancel_workspace_jobs: boolean
parameter_values_by_name?: Record<string, string> parameter_values_by_name?: Record<string, string>
user_variable_values?: VariableValue[]
} }
interface CreateTemplateContext { interface CreateTemplateContext {
organizationId: string organizationId: string
@ -50,6 +55,7 @@ interface CreateTemplateContext {
version?: TemplateVersion version?: TemplateVersion
templateData?: CreateTemplateData templateData?: CreateTemplateData
parameters?: ParameterSchema[] parameters?: ParameterSchema[]
variables?: TemplateVersionVariable[]
// file is used in the FE to show the filename and some other visual stuff // 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 // uploadedFile is the response from the server to use in the API
file?: File file?: File
@ -78,7 +84,7 @@ export const createTemplateMachine =
createFirstVersion: { createFirstVersion: {
data: TemplateVersion data: TemplateVersion
} }
createVersionWithParameters: { createVersionWithParametersAndVariables: {
data: TemplateVersion data: TemplateVersion
} }
waitForJobToBeCompleted: { waitForJobToBeCompleted: {
@ -87,6 +93,12 @@ export const createTemplateMachine =
loadParameterSchema: { loadParameterSchema: {
data: ParameterSchema[] data: ParameterSchema[]
} }
checkParametersAndVariables: {
data: {
parameters?: ParameterSchema[]
variables?: TemplateVersionVariable[]
}
}
createTemplate: { createTemplate: {
data: Template data: Template
} }
@ -170,17 +182,15 @@ export const createTemplateMachine =
invoke: { invoke: {
src: "waitForJobToBeCompleted", src: "waitForJobToBeCompleted",
onDone: [ onDone: [
{
target: "loadingMissingParameters",
cond: "hasMissingParameters",
actions: ["assignVersion"],
},
{ {
target: "loadingVersionLogs", target: "loadingVersionLogs",
actions: ["assignJobError", "assignVersion"], actions: ["assignJobError", "assignVersion"],
cond: "hasFailed", cond: "hasFailed",
}, },
{ target: "creatingTemplate", actions: ["assignVersion"] }, {
target: "checkingParametersAndVariables",
actions: ["assignVersion"],
},
], ],
onError: { onError: {
target: "#createTemplate.idle", target: "#createTemplate.idle",
@ -189,26 +199,19 @@ export const createTemplateMachine =
}, },
tags: ["submitting"], tags: ["submitting"],
}, },
loadingVersionLogs: { checkingParametersAndVariables: {
invoke: { invoke: {
src: "loadVersionLogs", src: "checkParametersAndVariables",
onDone: { onDone: [
target: "#createTemplate.idle", {
actions: ["assignJobLogs"], target: "creatingTemplate",
cond: "hasNoParametersOrVariables",
}, },
onError: { {
target: "#createTemplate.idle", target: "promptParametersAndVariables",
actions: ["assignError"], actions: ["assignParametersAndVariables"],
},
},
},
loadingMissingParameters: {
invoke: {
src: "loadParameterSchema",
onDone: {
target: "promptParameters",
actions: ["assignParameters"],
}, },
],
onError: { onError: {
target: "#createTemplate.idle", target: "#createTemplate.idle",
actions: ["assignError"], actions: ["assignError"],
@ -216,24 +219,24 @@ export const createTemplateMachine =
}, },
tags: ["submitting"], tags: ["submitting"],
}, },
promptParameters: { promptParametersAndVariables: {
on: { on: {
CREATE: { CREATE: {
target: "creatingVersionWithParameters", target: "creatingVersionWithParametersAndVariables",
actions: ["assignTemplateData"], actions: ["assignTemplateData"],
}, },
}, },
}, },
creatingVersionWithParameters: { creatingVersionWithParametersAndVariables: {
invoke: { invoke: {
src: "createVersionWithParameters", src: "createVersionWithParametersAndVariables",
onDone: { onDone: {
target: "waitingForJobToBeCompleted", target: "waitingForJobToBeCompleted",
actions: ["assignVersion"], actions: ["assignVersion"],
}, },
onError: { onError: {
actions: ["assignError"], actions: ["assignError"],
target: "promptParameters", target: "promptParametersAndVariables",
}, },
}, },
tags: ["submitting"], tags: ["submitting"],
@ -255,6 +258,19 @@ export const createTemplateMachine =
created: { created: {
type: "final", 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") throw new Error("No file or example provided")
}, },
createVersionWithParameters: async ({ createVersionWithParametersAndVariables: async ({
organizationId, organizationId,
parameters, parameters,
templateData, templateData,
@ -313,11 +329,11 @@ export const createTemplateMachine =
throw new Error("No template data defined") throw new Error("No template data defined")
} }
const { parameter_values_by_name } = templateData
// Get parameter values if they are needed/present // Get parameter values if they are needed/present
const parameterValues: CreateTemplateVersionRequest["parameter_values"] = const parameterValues: CreateTemplateVersionRequest["parameter_values"] =
[] []
if (parameters) { if (parameters) {
const { parameter_values_by_name } = templateData
parameters.forEach((schema) => { parameters.forEach((schema) => {
const value = parameter_values_by_name?.[schema.name] const value = parameter_values_by_name?.[schema.name]
parameterValues.push({ parameterValues.push({
@ -334,6 +350,7 @@ export const createTemplateMachine =
file_id: version.job.file_id, file_id: version.job.file_id,
provisioner: "terraform", provisioner: "terraform",
parameter_values: parameterValues, parameter_values: parameterValues,
user_variable_values: templateData.user_variable_values,
tags: {}, tags: {},
}) })
}, },
@ -342,24 +359,48 @@ export const createTemplateMachine =
throw new Error("Version not defined") throw new Error("Version not defined")
} }
let status = version.job.status let job = version.job
while (["pending", "running"].includes(status)) { while (isPendingOrRunning(job)) {
version = await getTemplateVersion(version.id) version = await getTemplateVersion(version.id)
status = version.job.status job = version.job
// Delay the verification in two seconds to not overload the server // Delay the verification in two seconds to not overload the server
// with too many requests Maybe at some point we could have a // with too many requests Maybe at some point we could have a
// websocket for template version Also, preferred doing this way to // 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 // avoid a new state since we don't need to reflect it on the UI
if (isPendingOrRunning(job)) {
await delay(2_000) await delay(2_000)
} }
}
return version return version
}, },
loadParameterSchema: async ({ version }) => { checkParametersAndVariables: async ({ version }) => {
if (!version) { if (!version) {
throw new Error("Version not defined") 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 }) => { createTemplate: async ({ organizationId, version, templateData }) => {
if (!version) { if (!version) {
@ -401,7 +442,10 @@ export const createTemplateMachine =
}), }),
assignVersion: assign({ version: (_, { data }) => data }), assignVersion: assign({ version: (_, { data }) => data }),
assignTemplateData: assign({ templateData: (_, { 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 }), assignFile: assign({ file: (_, { file }) => file }),
assignUploadResponse: assign({ uploadResponse: (_, { data }) => data }), assignUploadResponse: assign({ uploadResponse: (_, { data }) => data }),
removeFile: assign({ removeFile: assign({
@ -414,11 +458,31 @@ export const createTemplateMachine =
isExampleProvided: ({ exampleId }) => Boolean(exampleId), isExampleProvided: ({ exampleId }) => Boolean(exampleId),
isNotUsingExample: ({ exampleId }) => !exampleId, isNotUsingExample: ({ exampleId }) => !exampleId,
hasFile: ({ file }) => Boolean(file), hasFile: ({ file }) => Boolean(file),
hasFailed: (_, { data }) => data.job.status === "failed", hasFailed: (_, { data }) =>
hasMissingParameters: (_, { data }) =>
Boolean( 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" resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-1.0.1.tgz#88d7ac31811ab0cef14aaaeae2a0474923b278bc"
integrity sha512-eBV5rvW4dRFOU1eajN7FmYxjAIVz/mRHgUE9En9mBn6m3mulK3WTR5C3iQhL9MZ14rWAq+xOlEaCkDiW0/heOg== 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": "@sinclair/typebox@^0.24.1":
version "0.24.51" version "0.24.51"
resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.24.51.tgz#645f33fe4e02defe26f2f5c0410e1c094eac7f5f" resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.24.51.tgz#645f33fe4e02defe26f2f5c0410e1c094eac7f5f"
@ -3623,6 +3658,11 @@
magic-string "^0.26.2" magic-string "^0.26.2"
react-refresh "^0.14.0" 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": "@webassemblyjs/ast@1.11.1":
version "1.11.1" version "1.11.1"
resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.11.1.tgz#2bfd767eae1a6996f432ff7e8d7fc75679c0b6a7" 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" resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8"
integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q== 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: accepts@~1.3.5, accepts@~1.3.8:
version "1.3.8" version "1.3.8"
resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e" 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" resolved "https://registry.yarnpkg.com/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz#b43d286ccbd36bc5b2f7ed41caf2d0aba1f8a6e7"
integrity sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA== 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: data-urls@^2.0.0:
version "2.0.0" version "2.0.0"
resolved "https://registry.yarnpkg.com/data-urls/-/data-urls-2.0.0.tgz#156485a72963a970f5d5821aaf642bef2bf2db9b" 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: dependencies:
tslib "^2.1.0" 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: events@^3.0.0, events@^3.2.0, events@^3.3.0:
version "3.3.0" version "3.3.0"
resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" 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" resolved "https://registry.yarnpkg.com/mri/-/mri-1.2.0.tgz#6721480fec2a11a4889861115a48b6cbe7cc8f0b"
integrity sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA== 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: ms@2.0.0:
version "2.0.0" version "2.0.0"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
@ -14653,7 +14715,7 @@ wcwidth@^1.0.1:
dependencies: dependencies:
defaults "^1.0.3" defaults "^1.0.3"
web-encoding@^1.1.5: web-encoding@1.1.5, web-encoding@^1.1.5:
version "1.1.5" version "1.1.5"
resolved "https://registry.yarnpkg.com/web-encoding/-/web-encoding-1.1.5.tgz#fc810cf7667364a6335c939913f5051d3e0c4864" resolved "https://registry.yarnpkg.com/web-encoding/-/web-encoding-1.1.5.tgz#fc810cf7667364a6335c939913f5051d3e0c4864"
integrity sha512-HYLeVCdJ0+lBYV2FvNZmv3HJ2Nt0QYXqZojk3d9FJOLkwnuhzM9tmamh8d7HPM8QqjKH8DeHkFTx+CFlWpZZDA== 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" resolved "https://registry.yarnpkg.com/web-namespaces/-/web-namespaces-1.1.4.tgz#bc98a3de60dadd7faefc403d1076d529f5e030ec"
integrity sha512-wYxSGajtmoP4WxfejAPIr4l0fVh+jeMXZb08wNc0tMg6xsfZXj3cECqIK0G7ZAqUq0PP8WlMDtaOGVBTAWztNw== 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: webidl-conversions@^3.0.0:
version "3.0.1" version "3.0.1"
resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871"