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 { 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,24 +101,23 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = ({
const { t } = useTranslation("createTemplatePage")
return (
<form onSubmit={form.handleSubmit}>
<Stack direction="column" spacing={10} className={styles.formSections}>
<HorizontalForm onSubmit={form.handleSubmit}>
{/* 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}>
<FormSection
title={t("form.generalInfo.title")}
description={t("form.generalInfo.description")}
>
<FormFields>
{starterTemplate ? (
<SelectedTemplate template={starterTemplate} />
) : (
<TemplateUpload {...upload} />
<TemplateUpload
{...upload}
onUpload={async (file) => {
await fillNameAndDisplayWithFilename(file.name, form)
upload.onUpload(file)
}}
/>
)}
<TextField
@ -118,24 +126,19 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = ({
onChange={onChangeTrimmed(form)}
autoFocus
fullWidth
required
label={t("form.fields.name")}
variant="outlined"
/>
</Stack>
</div>
</FormFields>
</FormSection>
{/* 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>
<Stack direction="column" className={styles.formSectionFields}>
<FormSection
title={t("form.displayInfo.title")}
description={t("form.displayInfo.description")}
>
<FormFields>
<TextField
{...getFieldHelpers("display_name")}
disabled={isSubmitting}
@ -163,21 +166,15 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = ({
variant="outlined"
onPickEmoji={(value) => form.setFieldValue("icon", value)}
/>
</Stack>
</div>
</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>
<Stack direction="column" className={styles.formSectionFields}>
<FormSection
title={t("form.schedule.title")}
description={t("form.schedule.description")}
>
<FormFields>
<TextField
{...getFieldHelpers("default_ttl_hours")}
disabled={isSubmitting}
@ -188,21 +185,15 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = ({
type="number"
helperText={t("form.helperText.autoStop")}
/>
</Stack>
</div>
</FormFields>
</FormSection>
{/* 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" className={styles.formSectionFields}>
<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
@ -235,22 +226,16 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = ({
</Stack>
</Stack>
</label>
</Stack>
</div>
</FormFields>
</FormSection>
{/* 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}>
<FormSection
title={t("form.parameters.title")}
description={t("form.parameters.description")}
>
<FormFields>
{parameters.map((schema) => (
<ParameterInput
schema={schema}
@ -264,8 +249,32 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = ({
}}
/>
))}
</Stack>
</div>
</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 && (
@ -273,8 +282,8 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = ({
<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.
Looks like we found an error during the template provisioning. You
can see the logs bellow.
</p>
<code className={styles.errorDetails}>{jobError}</code>
@ -285,65 +294,30 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = ({
)}
<FormFooter
styles={formFooterStyles}
onCancel={onCancel}
isLoading={isSubmitting}
submitLabel={jobError ? "Retry" : "Create template"}
/>
</Stack>
</form>
</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
}),
),
)
})

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"],
src: "checkParametersAndVariables",
onDone: [
{
target: "creatingTemplate",
cond: "hasNoParametersOrVariables",
},
onError: {
target: "#createTemplate.idle",
actions: ["assignError"],
},
},
},
loadingMissingParameters: {
invoke: {
src: "loadParameterSchema",
onDone: {
target: "promptParameters",
actions: ["assignParameters"],
{
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
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"