mirror of https://github.com/coder/coder.git
refactor(site): Suport template version variables on template creation (#6434)
This commit is contained in:
parent
84dd59ecc2
commit
136f23fb4c
|
@ -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: {
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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: "",
|
||||
},
|
||||
],
|
||||
}
|
|
@ -5,8 +5,8 @@ import {
|
|||
ParameterSchema,
|
||||
ProvisionerJobLog,
|
||||
TemplateExample,
|
||||
TemplateVersionVariable,
|
||||
} from "api/typesGenerated"
|
||||
import { FormFooter } from "components/FormFooter/FormFooter"
|
||||
import { ParameterInput } from "components/ParameterInput/ParameterInput"
|
||||
import { Stack } from "components/Stack/Stack"
|
||||
import {
|
||||
|
@ -17,21 +17,30 @@ import { useFormik } from "formik"
|
|||
import { SelectedTemplate } from "pages/CreateWorkspacePage/SelectedTemplate"
|
||||
import { FC } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { nameValidator, getFormHelpers, onChangeTrimmed } from "util/formUtils"
|
||||
import {
|
||||
nameValidator,
|
||||
getFormHelpers,
|
||||
onChangeTrimmed,
|
||||
templateDisplayNameValidator,
|
||||
} from "util/formUtils"
|
||||
import { CreateTemplateData } from "xServices/createTemplate/createTemplateXService"
|
||||
import * as Yup from "yup"
|
||||
import { WorkspaceBuildLogs } from "components/WorkspaceBuildLogs/WorkspaceBuildLogs"
|
||||
import { HelpTooltip, HelpTooltipText } from "components/Tooltips/HelpTooltip"
|
||||
import { LazyIconField } from "components/IconField/LazyIconField"
|
||||
import { VariableInput } from "./VariableInput"
|
||||
import {
|
||||
FormFields,
|
||||
FormFooter,
|
||||
FormSection,
|
||||
HorizontalForm,
|
||||
} from "components/HorizontalForm/HorizontalForm"
|
||||
import camelCase from "lodash/camelCase"
|
||||
import capitalize from "lodash/capitalize"
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
name: nameValidator("Name"),
|
||||
display_name: Yup.string().optional(),
|
||||
description: Yup.string().optional(),
|
||||
icon: Yup.string().optional(),
|
||||
default_ttl_hours: Yup.number(),
|
||||
allow_user_cancel_workspace_jobs: Yup.boolean(),
|
||||
parameter_values_by_name: Yup.object().optional(),
|
||||
display_name: templateDisplayNameValidator("Display name"),
|
||||
})
|
||||
|
||||
const defaultInitialValues: CreateTemplateData = {
|
||||
|
@ -41,7 +50,6 @@ const defaultInitialValues: CreateTemplateData = {
|
|||
icon: "",
|
||||
default_ttl_hours: 24,
|
||||
allow_user_cancel_workspace_jobs: false,
|
||||
parameter_values_by_name: undefined,
|
||||
}
|
||||
|
||||
const getInitialValues = (starterTemplate?: TemplateExample) => {
|
||||
|
@ -58,31 +66,32 @@ const getInitialValues = (starterTemplate?: TemplateExample) => {
|
|||
}
|
||||
}
|
||||
|
||||
interface CreateTemplateFormProps {
|
||||
starterTemplate?: TemplateExample
|
||||
error?: unknown
|
||||
parameters?: ParameterSchema[]
|
||||
isSubmitting: boolean
|
||||
export interface CreateTemplateFormProps {
|
||||
onCancel: () => void
|
||||
onSubmit: (data: CreateTemplateData) => void
|
||||
isSubmitting: boolean
|
||||
upload: TemplateUploadProps
|
||||
starterTemplate?: TemplateExample
|
||||
parameters?: ParameterSchema[]
|
||||
variables?: TemplateVersionVariable[]
|
||||
error?: unknown
|
||||
jobError?: string
|
||||
logs?: ProvisionerJobLog[]
|
||||
}
|
||||
|
||||
export const CreateTemplateForm: FC<CreateTemplateFormProps> = ({
|
||||
starterTemplate,
|
||||
error,
|
||||
parameters,
|
||||
isSubmitting,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
starterTemplate,
|
||||
parameters,
|
||||
variables,
|
||||
isSubmitting,
|
||||
upload,
|
||||
error,
|
||||
jobError,
|
||||
logs,
|
||||
}) => {
|
||||
const styles = useStyles()
|
||||
const formFooterStyles = useFormFooterStyles()
|
||||
const form = useFormik<CreateTemplateData>({
|
||||
initialValues: getInitialValues(starterTemplate),
|
||||
validationSchema,
|
||||
|
@ -92,258 +101,223 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = ({
|
|||
const { t } = useTranslation("createTemplatePage")
|
||||
|
||||
return (
|
||||
<form onSubmit={form.handleSubmit}>
|
||||
<Stack direction="column" spacing={10} className={styles.formSections}>
|
||||
{/* General info */}
|
||||
<div className={styles.formSection}>
|
||||
<div className={styles.formSectionInfo}>
|
||||
<h2 className={styles.formSectionInfoTitle}>
|
||||
{t("form.generalInfo.title")}
|
||||
</h2>
|
||||
<p className={styles.formSectionInfoDescription}>
|
||||
{t("form.generalInfo.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Stack direction="column" className={styles.formSectionFields}>
|
||||
{starterTemplate ? (
|
||||
<SelectedTemplate template={starterTemplate} />
|
||||
) : (
|
||||
<TemplateUpload {...upload} />
|
||||
)}
|
||||
|
||||
<TextField
|
||||
{...getFieldHelpers("name")}
|
||||
disabled={isSubmitting}
|
||||
onChange={onChangeTrimmed(form)}
|
||||
autoFocus
|
||||
fullWidth
|
||||
label={t("form.fields.name")}
|
||||
variant="outlined"
|
||||
<HorizontalForm onSubmit={form.handleSubmit}>
|
||||
{/* General info */}
|
||||
<FormSection
|
||||
title={t("form.generalInfo.title")}
|
||||
description={t("form.generalInfo.description")}
|
||||
>
|
||||
<FormFields>
|
||||
{starterTemplate ? (
|
||||
<SelectedTemplate template={starterTemplate} />
|
||||
) : (
|
||||
<TemplateUpload
|
||||
{...upload}
|
||||
onUpload={async (file) => {
|
||||
await fillNameAndDisplayWithFilename(file.name, form)
|
||||
upload.onUpload(file)
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Display info */}
|
||||
<div className={styles.formSection}>
|
||||
<div className={styles.formSectionInfo}>
|
||||
<h2 className={styles.formSectionInfoTitle}>
|
||||
{t("form.displayInfo.title")}
|
||||
</h2>
|
||||
<p className={styles.formSectionInfoDescription}>
|
||||
{t("form.displayInfo.description")}
|
||||
</p>
|
||||
</div>
|
||||
<TextField
|
||||
{...getFieldHelpers("name")}
|
||||
disabled={isSubmitting}
|
||||
onChange={onChangeTrimmed(form)}
|
||||
autoFocus
|
||||
fullWidth
|
||||
required
|
||||
label={t("form.fields.name")}
|
||||
variant="outlined"
|
||||
/>
|
||||
</FormFields>
|
||||
</FormSection>
|
||||
|
||||
<Stack direction="column" className={styles.formSectionFields}>
|
||||
<TextField
|
||||
{...getFieldHelpers("display_name")}
|
||||
disabled={isSubmitting}
|
||||
fullWidth
|
||||
label={t("form.fields.displayName")}
|
||||
variant="outlined"
|
||||
/>
|
||||
{/* Display info */}
|
||||
<FormSection
|
||||
title={t("form.displayInfo.title")}
|
||||
description={t("form.displayInfo.description")}
|
||||
>
|
||||
<FormFields>
|
||||
<TextField
|
||||
{...getFieldHelpers("display_name")}
|
||||
disabled={isSubmitting}
|
||||
fullWidth
|
||||
label={t("form.fields.displayName")}
|
||||
variant="outlined"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
{...getFieldHelpers("description")}
|
||||
disabled={isSubmitting}
|
||||
rows={5}
|
||||
multiline
|
||||
fullWidth
|
||||
label={t("form.fields.description")}
|
||||
variant="outlined"
|
||||
/>
|
||||
<TextField
|
||||
{...getFieldHelpers("description")}
|
||||
disabled={isSubmitting}
|
||||
rows={5}
|
||||
multiline
|
||||
fullWidth
|
||||
label={t("form.fields.description")}
|
||||
variant="outlined"
|
||||
/>
|
||||
|
||||
<LazyIconField
|
||||
{...getFieldHelpers("icon")}
|
||||
disabled={isSubmitting}
|
||||
onChange={onChangeTrimmed(form)}
|
||||
fullWidth
|
||||
label={t("form.fields.icon")}
|
||||
variant="outlined"
|
||||
onPickEmoji={(value) => form.setFieldValue("icon", value)}
|
||||
/>
|
||||
</Stack>
|
||||
</div>
|
||||
<LazyIconField
|
||||
{...getFieldHelpers("icon")}
|
||||
disabled={isSubmitting}
|
||||
onChange={onChangeTrimmed(form)}
|
||||
fullWidth
|
||||
label={t("form.fields.icon")}
|
||||
variant="outlined"
|
||||
onPickEmoji={(value) => form.setFieldValue("icon", value)}
|
||||
/>
|
||||
</FormFields>
|
||||
</FormSection>
|
||||
|
||||
{/* Schedule */}
|
||||
<div className={styles.formSection}>
|
||||
<div className={styles.formSectionInfo}>
|
||||
<h2 className={styles.formSectionInfoTitle}>
|
||||
{t("form.schedule.title")}
|
||||
</h2>
|
||||
<p className={styles.formSectionInfoDescription}>
|
||||
{t("form.schedule.description")}
|
||||
</p>
|
||||
</div>
|
||||
{/* Schedule */}
|
||||
<FormSection
|
||||
title={t("form.schedule.title")}
|
||||
description={t("form.schedule.description")}
|
||||
>
|
||||
<FormFields>
|
||||
<TextField
|
||||
{...getFieldHelpers("default_ttl_hours")}
|
||||
disabled={isSubmitting}
|
||||
onChange={onChangeTrimmed(form)}
|
||||
fullWidth
|
||||
label={t("form.fields.autoStop")}
|
||||
variant="outlined"
|
||||
type="number"
|
||||
helperText={t("form.helperText.autoStop")}
|
||||
/>
|
||||
</FormFields>
|
||||
</FormSection>
|
||||
|
||||
<Stack direction="column" className={styles.formSectionFields}>
|
||||
<TextField
|
||||
{...getFieldHelpers("default_ttl_hours")}
|
||||
disabled={isSubmitting}
|
||||
onChange={onChangeTrimmed(form)}
|
||||
fullWidth
|
||||
label={t("form.fields.autoStop")}
|
||||
variant="outlined"
|
||||
type="number"
|
||||
helperText={t("form.helperText.autoStop")}
|
||||
/>
|
||||
</Stack>
|
||||
</div>
|
||||
{/* Operations */}
|
||||
<FormSection
|
||||
title={t("form.operations.title")}
|
||||
description={t("form.operations.description")}
|
||||
>
|
||||
<FormFields>
|
||||
<label htmlFor="allow_user_cancel_workspace_jobs">
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Checkbox
|
||||
color="primary"
|
||||
id="allow_user_cancel_workspace_jobs"
|
||||
name="allow_user_cancel_workspace_jobs"
|
||||
disabled={isSubmitting}
|
||||
checked={form.values.allow_user_cancel_workspace_jobs}
|
||||
onChange={form.handleChange}
|
||||
/>
|
||||
|
||||
{/* Operations */}
|
||||
<div className={styles.formSection}>
|
||||
<div className={styles.formSectionInfo}>
|
||||
<h2 className={styles.formSectionInfoTitle}>
|
||||
{t("form.operations.title")}
|
||||
</h2>
|
||||
<p className={styles.formSectionInfoDescription}>
|
||||
{t("form.operations.description")}
|
||||
</p>
|
||||
</div>
|
||||
<Stack direction="column" spacing={0.5}>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
spacing={0.5}
|
||||
className={styles.optionText}
|
||||
>
|
||||
{t("form.fields.allowUsersToCancel")}
|
||||
|
||||
<Stack direction="column" className={styles.formSectionFields}>
|
||||
<label htmlFor="allow_user_cancel_workspace_jobs">
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Checkbox
|
||||
color="primary"
|
||||
id="allow_user_cancel_workspace_jobs"
|
||||
name="allow_user_cancel_workspace_jobs"
|
||||
disabled={isSubmitting}
|
||||
checked={form.values.allow_user_cancel_workspace_jobs}
|
||||
onChange={form.handleChange}
|
||||
/>
|
||||
|
||||
<Stack direction="column" spacing={0.5}>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
spacing={0.5}
|
||||
className={styles.optionText}
|
||||
>
|
||||
{t("form.fields.allowUsersToCancel")}
|
||||
|
||||
<HelpTooltip>
|
||||
<HelpTooltipText>
|
||||
{t("form.tooltip.allowUsersToCancel")}
|
||||
</HelpTooltipText>
|
||||
</HelpTooltip>
|
||||
</Stack>
|
||||
<span className={styles.optionHelperText}>
|
||||
{t("form.helperText.allowUsersToCancel")}
|
||||
</span>
|
||||
<HelpTooltip>
|
||||
<HelpTooltipText>
|
||||
{t("form.tooltip.allowUsersToCancel")}
|
||||
</HelpTooltipText>
|
||||
</HelpTooltip>
|
||||
</Stack>
|
||||
<span className={styles.optionHelperText}>
|
||||
{t("form.helperText.allowUsersToCancel")}
|
||||
</span>
|
||||
</Stack>
|
||||
</label>
|
||||
</Stack>
|
||||
</div>
|
||||
|
||||
{/* Parameters */}
|
||||
{parameters && (
|
||||
<div className={styles.formSection}>
|
||||
<div className={styles.formSectionInfo}>
|
||||
<h2 className={styles.formSectionInfoTitle}>
|
||||
{t("form.parameters.title")}
|
||||
</h2>
|
||||
<p className={styles.formSectionInfoDescription}>
|
||||
{t("form.parameters.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Stack direction="column" className={styles.formSectionFields}>
|
||||
{parameters.map((schema) => (
|
||||
<ParameterInput
|
||||
schema={schema}
|
||||
disabled={isSubmitting}
|
||||
key={schema.id}
|
||||
onChange={async (value) => {
|
||||
await form.setFieldValue(
|
||||
`parameter_values_by_name.${schema.name}`,
|
||||
value,
|
||||
)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</label>
|
||||
</FormFields>
|
||||
</FormSection>
|
||||
|
||||
{/* Parameters */}
|
||||
{parameters && (
|
||||
<FormSection
|
||||
title={t("form.parameters.title")}
|
||||
description={t("form.parameters.description")}
|
||||
>
|
||||
<FormFields>
|
||||
{parameters.map((schema) => (
|
||||
<ParameterInput
|
||||
schema={schema}
|
||||
disabled={isSubmitting}
|
||||
key={schema.id}
|
||||
onChange={async (value) => {
|
||||
await form.setFieldValue(
|
||||
`parameter_values_by_name.${schema.name}`,
|
||||
value,
|
||||
)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</FormFields>
|
||||
</FormSection>
|
||||
)}
|
||||
|
||||
{/* Variables */}
|
||||
{variables && (
|
||||
<FormSection
|
||||
title="Variables"
|
||||
description="Input variables allow you to customize templates without altering their source code."
|
||||
>
|
||||
<FormFields>
|
||||
{variables.map((variable, index) => (
|
||||
<VariableInput
|
||||
variable={variable}
|
||||
disabled={isSubmitting}
|
||||
key={variable.name}
|
||||
onChange={async (value) => {
|
||||
await form.setFieldValue("user_variable_values." + index, {
|
||||
name: variable.name,
|
||||
value: value,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</FormFields>
|
||||
</FormSection>
|
||||
)}
|
||||
|
||||
{jobError && (
|
||||
<Stack>
|
||||
<div className={styles.error}>
|
||||
<h5 className={styles.errorTitle}>Error during provisioning</h5>
|
||||
<p className={styles.errorDescription}>
|
||||
Looks like we found an error during the template provisioning. You
|
||||
can see the logs bellow.
|
||||
</p>
|
||||
|
||||
<code className={styles.errorDetails}>{jobError}</code>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{jobError && (
|
||||
<Stack>
|
||||
<div className={styles.error}>
|
||||
<h5 className={styles.errorTitle}>Error during provisioning</h5>
|
||||
<p className={styles.errorDescription}>
|
||||
Looks like we found an error during the template provisioning.
|
||||
You can see the logs bellow.
|
||||
</p>
|
||||
<WorkspaceBuildLogs logs={logs ?? []} />
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<code className={styles.errorDetails}>{jobError}</code>
|
||||
</div>
|
||||
|
||||
<WorkspaceBuildLogs logs={logs ?? []} />
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<FormFooter
|
||||
styles={formFooterStyles}
|
||||
onCancel={onCancel}
|
||||
isLoading={isSubmitting}
|
||||
submitLabel={jobError ? "Retry" : "Create template"}
|
||||
/>
|
||||
</Stack>
|
||||
</form>
|
||||
<FormFooter
|
||||
onCancel={onCancel}
|
||||
isLoading={isSubmitting}
|
||||
submitLabel={jobError ? "Retry" : "Create template"}
|
||||
/>
|
||||
</HorizontalForm>
|
||||
)
|
||||
}
|
||||
|
||||
const fillNameAndDisplayWithFilename = async (
|
||||
filename: string,
|
||||
form: ReturnType<typeof useFormik<CreateTemplateData>>,
|
||||
) => {
|
||||
const [name, _extension] = filename.split(".")
|
||||
await Promise.all([
|
||||
form.setFieldValue(
|
||||
"name",
|
||||
// Camel case will remove special chars and spaces
|
||||
camelCase(name).toLowerCase(),
|
||||
),
|
||||
form.setFieldValue("display_name", capitalize(name)),
|
||||
])
|
||||
}
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
formSections: {
|
||||
[theme.breakpoints.down("sm")]: {
|
||||
gap: theme.spacing(8),
|
||||
},
|
||||
},
|
||||
|
||||
formSection: {
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
gap: theme.spacing(15),
|
||||
|
||||
[theme.breakpoints.down("sm")]: {
|
||||
flexDirection: "column",
|
||||
gap: theme.spacing(2),
|
||||
},
|
||||
},
|
||||
|
||||
formSectionInfo: {
|
||||
width: 312,
|
||||
flexShrink: 0,
|
||||
position: "sticky",
|
||||
top: theme.spacing(3),
|
||||
|
||||
[theme.breakpoints.down("sm")]: {
|
||||
width: "100%",
|
||||
position: "initial",
|
||||
},
|
||||
},
|
||||
|
||||
formSectionInfoTitle: {
|
||||
fontSize: 20,
|
||||
color: theme.palette.text.primary,
|
||||
fontWeight: 400,
|
||||
margin: 0,
|
||||
marginBottom: theme.spacing(1),
|
||||
},
|
||||
|
||||
formSectionInfoDescription: {
|
||||
fontSize: 14,
|
||||
color: theme.palette.text.secondary,
|
||||
lineHeight: "160%",
|
||||
margin: 0,
|
||||
},
|
||||
|
||||
formSectionFields: {
|
||||
width: "100%",
|
||||
},
|
||||
|
||||
optionText: {
|
||||
fontSize: theme.spacing(2),
|
||||
color: theme.palette.text.primary,
|
||||
|
@ -379,25 +353,3 @@ const useStyles = makeStyles((theme) => ({
|
|||
fontSize: theme.spacing(2),
|
||||
},
|
||||
}))
|
||||
|
||||
const useFormFooterStyles = makeStyles((theme) => ({
|
||||
button: {
|
||||
minWidth: theme.spacing(23),
|
||||
|
||||
[theme.breakpoints.down("sm")]: {
|
||||
width: "100%",
|
||||
},
|
||||
},
|
||||
footer: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-start",
|
||||
flexDirection: "row-reverse",
|
||||
gap: theme.spacing(2),
|
||||
|
||||
[theme.breakpoints.down("sm")]: {
|
||||
flexDirection: "column",
|
||||
gap: theme.spacing(1),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
|
|
@ -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" },
|
||||
],
|
||||
})
|
||||
})
|
|
@ -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) => {
|
||||
|
|
|
@ -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),
|
||||
},
|
||||
}))
|
|
@ -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: {},
|
||||
}
|
||||
|
||||
|
|
|
@ -11,17 +11,6 @@ import i18next from "i18next"
|
|||
|
||||
const { t } = i18next
|
||||
|
||||
const renderTemplateSettingsPage = async () => {
|
||||
const renderResult = renderWithAuth(<TemplateSettingsPage />, {
|
||||
route: `/templates/${MockTemplate.name}/settings`,
|
||||
path: `/templates/:templateId/settings`,
|
||||
})
|
||||
// Wait the form to be rendered
|
||||
const label = t("nameLabel", { ns: "templateSettingsPage" })
|
||||
await screen.findAllByLabelText(label)
|
||||
return renderResult
|
||||
}
|
||||
|
||||
const validFormValues = {
|
||||
name: "Name",
|
||||
display_name: "A display name",
|
||||
|
@ -31,6 +20,17 @@ const validFormValues = {
|
|||
allow_user_cancel_workspace_jobs: false,
|
||||
}
|
||||
|
||||
const renderTemplateSettingsPage = async () => {
|
||||
renderWithAuth(<TemplateSettingsPage />, {
|
||||
route: `/templates/${MockTemplate.name}/settings`,
|
||||
path: `/templates/:template/settings`,
|
||||
extraRoutes: [{ path: "templates/:template", element: <></> }],
|
||||
})
|
||||
// Wait the form to be rendered
|
||||
const label = t("nameLabel", { ns: "templateSettingsPage" })
|
||||
await screen.findAllByLabelText(label)
|
||||
}
|
||||
|
||||
const fillAndSubmitForm = async ({
|
||||
name,
|
||||
display_name,
|
||||
|
@ -109,17 +109,13 @@ describe("TemplateSettingsPage", () => {
|
|||
})
|
||||
|
||||
await fillAndSubmitForm(validFormValues)
|
||||
expect(screen.getByDisplayValue(1)).toBeInTheDocument() // the default_ttl_ms
|
||||
await waitFor(() => expect(API.updateTemplateMeta).toBeCalledTimes(1))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(API.updateTemplateMeta).toBeCalledWith(
|
||||
"test-template",
|
||||
expect.objectContaining({
|
||||
...validFormValues,
|
||||
default_ttl_ms: 3600000, // the default_ttl_ms to ms
|
||||
}),
|
||||
),
|
||||
expect(API.updateTemplateMeta).toBeCalledWith(
|
||||
"test-template",
|
||||
expect.objectContaining({
|
||||
...validFormValues,
|
||||
default_ttl_ms: 3600000, // the default_ttl_ms to ms
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
|
|
|
@ -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: <></> }],
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -100,3 +100,4 @@ export const templateDisplayNameValidator = (
|
|||
templateDisplayNameMaxLength,
|
||||
Language.nameTooLong(displayName, templateDisplayNameMaxLength),
|
||||
)
|
||||
.optional()
|
||||
|
|
|
@ -6,15 +6,19 @@ import {
|
|||
getTemplateVersionSchema,
|
||||
uploadTemplateFile,
|
||||
getTemplateVersionLogs,
|
||||
getTemplateVersionVariables,
|
||||
} from "api/api"
|
||||
import {
|
||||
CreateTemplateVersionRequest,
|
||||
ParameterSchema,
|
||||
ProvisionerJob,
|
||||
ProvisionerJobLog,
|
||||
Template,
|
||||
TemplateExample,
|
||||
TemplateVersion,
|
||||
TemplateVersionVariable,
|
||||
UploadResponse,
|
||||
VariableValue,
|
||||
} from "api/typesGenerated"
|
||||
import { displayError } from "components/GlobalSnackbar/utils"
|
||||
import { delay } from "util/delay"
|
||||
|
@ -24,7 +28,7 @@ import { assign, createMachine } from "xstate"
|
|||
// 1. upload template tar or use the example ID
|
||||
// 2. create template version
|
||||
// 3. wait for it to complete
|
||||
// 4. if the job failed with the missing parameter error then:
|
||||
// 4. verify if template has missing parameters or variables
|
||||
// a. prompt for params
|
||||
// b. create template version again with the same file hash
|
||||
// c. wait for it to complete
|
||||
|
@ -39,6 +43,7 @@ export interface CreateTemplateData {
|
|||
default_ttl_hours: number
|
||||
allow_user_cancel_workspace_jobs: boolean
|
||||
parameter_values_by_name?: Record<string, string>
|
||||
user_variable_values?: VariableValue[]
|
||||
}
|
||||
interface CreateTemplateContext {
|
||||
organizationId: string
|
||||
|
@ -50,6 +55,7 @@ interface CreateTemplateContext {
|
|||
version?: TemplateVersion
|
||||
templateData?: CreateTemplateData
|
||||
parameters?: ParameterSchema[]
|
||||
variables?: TemplateVersionVariable[]
|
||||
// file is used in the FE to show the filename and some other visual stuff
|
||||
// uploadedFile is the response from the server to use in the API
|
||||
file?: File
|
||||
|
@ -78,7 +84,7 @@ export const createTemplateMachine =
|
|||
createFirstVersion: {
|
||||
data: TemplateVersion
|
||||
}
|
||||
createVersionWithParameters: {
|
||||
createVersionWithParametersAndVariables: {
|
||||
data: TemplateVersion
|
||||
}
|
||||
waitForJobToBeCompleted: {
|
||||
|
@ -87,6 +93,12 @@ export const createTemplateMachine =
|
|||
loadParameterSchema: {
|
||||
data: ParameterSchema[]
|
||||
}
|
||||
checkParametersAndVariables: {
|
||||
data: {
|
||||
parameters?: ParameterSchema[]
|
||||
variables?: TemplateVersionVariable[]
|
||||
}
|
||||
}
|
||||
createTemplate: {
|
||||
data: Template
|
||||
}
|
||||
|
@ -170,17 +182,15 @@ export const createTemplateMachine =
|
|||
invoke: {
|
||||
src: "waitForJobToBeCompleted",
|
||||
onDone: [
|
||||
{
|
||||
target: "loadingMissingParameters",
|
||||
cond: "hasMissingParameters",
|
||||
actions: ["assignVersion"],
|
||||
},
|
||||
{
|
||||
target: "loadingVersionLogs",
|
||||
actions: ["assignJobError", "assignVersion"],
|
||||
cond: "hasFailed",
|
||||
},
|
||||
{ target: "creatingTemplate", actions: ["assignVersion"] },
|
||||
{
|
||||
target: "checkingParametersAndVariables",
|
||||
actions: ["assignVersion"],
|
||||
},
|
||||
],
|
||||
onError: {
|
||||
target: "#createTemplate.idle",
|
||||
|
@ -189,26 +199,19 @@ export const createTemplateMachine =
|
|||
},
|
||||
tags: ["submitting"],
|
||||
},
|
||||
loadingVersionLogs: {
|
||||
checkingParametersAndVariables: {
|
||||
invoke: {
|
||||
src: "loadVersionLogs",
|
||||
onDone: {
|
||||
target: "#createTemplate.idle",
|
||||
actions: ["assignJobLogs"],
|
||||
},
|
||||
onError: {
|
||||
target: "#createTemplate.idle",
|
||||
actions: ["assignError"],
|
||||
},
|
||||
},
|
||||
},
|
||||
loadingMissingParameters: {
|
||||
invoke: {
|
||||
src: "loadParameterSchema",
|
||||
onDone: {
|
||||
target: "promptParameters",
|
||||
actions: ["assignParameters"],
|
||||
},
|
||||
src: "checkParametersAndVariables",
|
||||
onDone: [
|
||||
{
|
||||
target: "creatingTemplate",
|
||||
cond: "hasNoParametersOrVariables",
|
||||
},
|
||||
{
|
||||
target: "promptParametersAndVariables",
|
||||
actions: ["assignParametersAndVariables"],
|
||||
},
|
||||
],
|
||||
onError: {
|
||||
target: "#createTemplate.idle",
|
||||
actions: ["assignError"],
|
||||
|
@ -216,24 +219,24 @@ export const createTemplateMachine =
|
|||
},
|
||||
tags: ["submitting"],
|
||||
},
|
||||
promptParameters: {
|
||||
promptParametersAndVariables: {
|
||||
on: {
|
||||
CREATE: {
|
||||
target: "creatingVersionWithParameters",
|
||||
target: "creatingVersionWithParametersAndVariables",
|
||||
actions: ["assignTemplateData"],
|
||||
},
|
||||
},
|
||||
},
|
||||
creatingVersionWithParameters: {
|
||||
creatingVersionWithParametersAndVariables: {
|
||||
invoke: {
|
||||
src: "createVersionWithParameters",
|
||||
src: "createVersionWithParametersAndVariables",
|
||||
onDone: {
|
||||
target: "waitingForJobToBeCompleted",
|
||||
actions: ["assignVersion"],
|
||||
},
|
||||
onError: {
|
||||
actions: ["assignError"],
|
||||
target: "promptParameters",
|
||||
target: "promptParametersAndVariables",
|
||||
},
|
||||
},
|
||||
tags: ["submitting"],
|
||||
|
@ -255,6 +258,19 @@ export const createTemplateMachine =
|
|||
created: {
|
||||
type: "final",
|
||||
},
|
||||
loadingVersionLogs: {
|
||||
invoke: {
|
||||
src: "loadVersionLogs",
|
||||
onDone: {
|
||||
target: "#createTemplate.idle",
|
||||
actions: ["assignJobLogs"],
|
||||
},
|
||||
onError: {
|
||||
target: "#createTemplate.idle",
|
||||
actions: ["assignError"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -300,7 +316,7 @@ export const createTemplateMachine =
|
|||
|
||||
throw new Error("No file or example provided")
|
||||
},
|
||||
createVersionWithParameters: async ({
|
||||
createVersionWithParametersAndVariables: async ({
|
||||
organizationId,
|
||||
parameters,
|
||||
templateData,
|
||||
|
@ -313,11 +329,11 @@ export const createTemplateMachine =
|
|||
throw new Error("No template data defined")
|
||||
}
|
||||
|
||||
const { parameter_values_by_name } = templateData
|
||||
// Get parameter values if they are needed/present
|
||||
const parameterValues: CreateTemplateVersionRequest["parameter_values"] =
|
||||
[]
|
||||
if (parameters) {
|
||||
const { parameter_values_by_name } = templateData
|
||||
parameters.forEach((schema) => {
|
||||
const value = parameter_values_by_name?.[schema.name]
|
||||
parameterValues.push({
|
||||
|
@ -334,6 +350,7 @@ export const createTemplateMachine =
|
|||
file_id: version.job.file_id,
|
||||
provisioner: "terraform",
|
||||
parameter_values: parameterValues,
|
||||
user_variable_values: templateData.user_variable_values,
|
||||
tags: {},
|
||||
})
|
||||
},
|
||||
|
@ -342,24 +359,48 @@ export const createTemplateMachine =
|
|||
throw new Error("Version not defined")
|
||||
}
|
||||
|
||||
let status = version.job.status
|
||||
while (["pending", "running"].includes(status)) {
|
||||
let job = version.job
|
||||
while (isPendingOrRunning(job)) {
|
||||
version = await getTemplateVersion(version.id)
|
||||
status = version.job.status
|
||||
job = version.job
|
||||
|
||||
// Delay the verification in two seconds to not overload the server
|
||||
// with too many requests Maybe at some point we could have a
|
||||
// websocket for template version Also, preferred doing this way to
|
||||
// avoid a new state since we don't need to reflect it on the UI
|
||||
await delay(2_000)
|
||||
if (isPendingOrRunning(job)) {
|
||||
await delay(2_000)
|
||||
}
|
||||
}
|
||||
return version
|
||||
},
|
||||
loadParameterSchema: async ({ version }) => {
|
||||
checkParametersAndVariables: async ({ version }) => {
|
||||
if (!version) {
|
||||
throw new Error("Version not defined")
|
||||
}
|
||||
|
||||
return getTemplateVersionSchema(version.id)
|
||||
let promiseParameter: Promise<ParameterSchema[]> | undefined =
|
||||
undefined
|
||||
let promiseVariables: Promise<TemplateVersionVariable[]> | undefined =
|
||||
undefined
|
||||
|
||||
if (isMissingParameter(version)) {
|
||||
promiseParameter = getTemplateVersionSchema(version.id)
|
||||
}
|
||||
|
||||
if (isMissingVariables(version)) {
|
||||
promiseVariables = getTemplateVersionVariables(version.id)
|
||||
}
|
||||
|
||||
const [parameters, variables] = await Promise.all([
|
||||
promiseParameter,
|
||||
promiseVariables,
|
||||
])
|
||||
|
||||
return {
|
||||
parameters,
|
||||
variables,
|
||||
}
|
||||
},
|
||||
createTemplate: async ({ organizationId, version, templateData }) => {
|
||||
if (!version) {
|
||||
|
@ -401,7 +442,10 @@ export const createTemplateMachine =
|
|||
}),
|
||||
assignVersion: assign({ version: (_, { data }) => data }),
|
||||
assignTemplateData: assign({ templateData: (_, { data }) => data }),
|
||||
assignParameters: assign({ parameters: (_, { data }) => data }),
|
||||
assignParametersAndVariables: assign({
|
||||
parameters: (_, { data }) => data.parameters,
|
||||
variables: (_, { data }) => data.variables,
|
||||
}),
|
||||
assignFile: assign({ file: (_, { file }) => file }),
|
||||
assignUploadResponse: assign({ uploadResponse: (_, { data }) => data }),
|
||||
removeFile: assign({
|
||||
|
@ -414,11 +458,31 @@ export const createTemplateMachine =
|
|||
isExampleProvided: ({ exampleId }) => Boolean(exampleId),
|
||||
isNotUsingExample: ({ exampleId }) => !exampleId,
|
||||
hasFile: ({ file }) => Boolean(file),
|
||||
hasFailed: (_, { data }) => data.job.status === "failed",
|
||||
hasMissingParameters: (_, { data }) =>
|
||||
hasFailed: (_, { data }) =>
|
||||
Boolean(
|
||||
data.job.error && data.job.error.includes("missing parameter"),
|
||||
data.job.status === "failed" &&
|
||||
!isMissingParameter(data) &&
|
||||
!isMissingVariables(data),
|
||||
),
|
||||
hasNoParametersOrVariables: (_, { data }) =>
|
||||
data.parameters === undefined && data.variables === undefined,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const isMissingParameter = (version: TemplateVersion) => {
|
||||
return Boolean(
|
||||
version.job.error && version.job.error.includes("missing parameter"),
|
||||
)
|
||||
}
|
||||
|
||||
const isMissingVariables = (version: TemplateVersion) => {
|
||||
return Boolean(
|
||||
version.job.error &&
|
||||
version.job.error.includes("required template variables"),
|
||||
)
|
||||
}
|
||||
|
||||
const isPendingOrRunning = (job: ProvisionerJob) => {
|
||||
return job.status === "pending" || job.status === "running"
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue