import Link from "@mui/material/Link"; import TextField from "@mui/material/TextField"; import { provisionerDaemons } from "api/queries/organizations"; import type { CreateTemplateVersionRequest, Organization, ProvisionerJobLog, ProvisionerType, Template, TemplateExample, TemplateVersionVariable, VariableValue, } from "api/typesGenerated"; import { Alert } from "components/Alert/Alert"; import { Button } from "components/Button/Button"; import { FormFields, FormFooter, FormSection, HorizontalForm, } from "components/Form/Form"; import { IconField } from "components/IconField/IconField"; import { OrganizationAutocomplete } from "components/OrganizationAutocomplete/OrganizationAutocomplete"; import { Spinner } from "components/Spinner/Spinner"; import { useFormik } from "formik"; import camelCase from "lodash/camelCase"; import capitalize from "lodash/capitalize"; import { ProvisionerTagsField } from "modules/provisioners/ProvisionerTagsField"; import { SelectedTemplate } from "pages/CreateWorkspacePage/SelectedTemplate"; import { type FC, useState } from "react"; import { useQuery } from "react-query"; import { useSearchParams } from "react-router-dom"; import { docs } from "utils/docs"; import { displayNameValidator, getFormHelpers, nameValidator, onChangeTrimmed, } from "utils/formUtils"; import { type TemplateAutostartRequirementDaysValue, type TemplateAutostopRequirementDaysValue, sortedDays, } from "utils/schedule"; import * as Yup from "yup"; import { TemplateUpload, type TemplateUploadProps } from "./TemplateUpload"; import { VariableInput } from "./VariableInput"; const MAX_DESCRIPTION_CHAR_LIMIT = 128; export interface CreateTemplateFormData { name: string; display_name: string; description: string; icon: string; default_ttl_hours: number; autostart_requirement_days_of_week: TemplateAutostartRequirementDaysValue[]; autostop_requirement_days_of_week: TemplateAutostopRequirementDaysValue; autostop_requirement_weeks: number; allow_user_autostart: boolean; allow_user_autostop: boolean; allow_user_cancel_workspace_jobs: boolean; parameter_values_by_name?: Record; user_variable_values?: VariableValue[]; allow_everyone_group_access: boolean; provisioner_type: ProvisionerType; organization: string; tags: CreateTemplateVersionRequest["tags"]; } const validationSchema = Yup.object({ name: nameValidator("Name"), display_name: displayNameValidator("Display name"), description: Yup.string().max( MAX_DESCRIPTION_CHAR_LIMIT, "Please enter a description that is less than or equal to 128 characters.", ), icon: Yup.string().optional(), }); const defaultInitialValues: CreateTemplateFormData = { name: "", display_name: "", description: "", icon: "", default_ttl_hours: 24, // autostop_requirement is an enterprise-only feature, and the server ignores // the value if you are not licensed. We hide the form value based on // entitlements. // // Default to requiring restart every Sunday in the user's quiet hours in the // user's timezone. autostop_requirement_days_of_week: "sunday", autostop_requirement_weeks: 1, autostart_requirement_days_of_week: sortedDays, allow_user_cancel_workspace_jobs: false, allow_user_autostart: false, allow_user_autostop: false, allow_everyone_group_access: true, provisioner_type: "terraform", organization: "default", tags: {}, }; type GetInitialValuesParams = { fromExample?: TemplateExample; fromCopy?: Template; variables?: TemplateVersionVariable[]; allowAdvancedScheduling: boolean; searchParams: URLSearchParams; }; const getInitialValues = ({ fromExample, fromCopy, allowAdvancedScheduling, variables, searchParams, }: GetInitialValuesParams) => { let initialValues = defaultInitialValues; // Will assume the query param has a valid ProvisionerType, as this query param is only used // in testing. defaultInitialValues.provisioner_type = (searchParams.get("provisioner_type") as ProvisionerType) || "terraform"; if (!allowAdvancedScheduling) { initialValues = { ...initialValues, autostop_requirement_days_of_week: "off", autostop_requirement_weeks: 1, }; } if (fromExample) { initialValues = { ...initialValues, name: fromExample.id, display_name: fromExample.name, icon: fromExample.icon, description: fromExample.description, }; } if (fromCopy) { initialValues = { ...initialValues, ...fromCopy, name: `${fromCopy.name}-copy`, display_name: fromCopy.display_name ? `Copy of ${fromCopy.display_name}` : "", }; } if (variables) { for (const variable of variables) { if (!initialValues.user_variable_values) { initialValues.user_variable_values = []; } initialValues.user_variable_values.push({ name: variable.name, value: variable.sensitive ? "" : variable.value, }); } } return initialValues; }; type CopiedTemplateForm = { copiedTemplate: Template }; type StarterTemplateForm = { starterTemplate: TemplateExample }; type UploadTemplateForm = { upload: TemplateUploadProps }; export type CreateTemplateFormProps = ( | CopiedTemplateForm | StarterTemplateForm | UploadTemplateForm ) & { onCancel: () => void; onSubmit: (data: CreateTemplateFormData) => void; onOpenBuildLogsDrawer: () => void; isSubmitting: boolean; variables?: TemplateVersionVariable[]; error?: unknown; jobError?: string; logs?: ProvisionerJobLog[]; allowAdvancedScheduling: boolean; variablesSectionRef: React.RefObject; showOrganizationPicker?: boolean; }; export const CreateTemplateForm: FC = (props) => { const [searchParams] = useSearchParams(); const [selectedOrg, setSelectedOrg] = useState(null); const { onCancel, onSubmit, onOpenBuildLogsDrawer, variables, isSubmitting, error, jobError, logs, allowAdvancedScheduling, variablesSectionRef, showOrganizationPicker, } = props; const form = useFormik({ initialValues: getInitialValues({ allowAdvancedScheduling, fromExample: "starterTemplate" in props ? props.starterTemplate : undefined, fromCopy: "copiedTemplate" in props ? props.copiedTemplate : undefined, variables, searchParams, }), validationSchema, onSubmit, }); const getFieldHelpers = getFormHelpers(form, error); const { data: provisioners } = useQuery( selectedOrg ? { ...provisionerDaemons(selectedOrg.id), enabled: showOrganizationPicker, } : { enabled: false }, ); // TODO: Ideally, we would have a backend endpoint that could notify the // frontend that a provisioner has been connected, so that we could hide // this warning. In the meantime, **do not use this variable to disable // form submission**!! A user could easily see this warning, connect a // provisioner, and then not refresh the page. Even if they submit without // a provisioner, it'll just sit in the job queue until they connect one. const showProvisionerWarning = provisioners ? provisioners.length < 1 : false; return ( {/* General info */} {"starterTemplate" in props && ( )} {"upload" in props && ( { await fillNameAndDisplayWithFilename(file.name, form); props.upload.onUpload(file); }} /> )} {showOrganizationPicker && ( <> {showProvisionerWarning && } { setSelectedOrg(newValue); void form.setFieldValue("organization", newValue?.name || ""); }} size="medium" check={{ object: { resource_type: "template" }, action: "create", }} /> )} {"copiedTemplate" in props && ( )} {/* Display info */} form.setFieldValue("icon", value)} /> {provisioners && provisioners.length > 0 && ( Tags are a way to control which provisioner daemons complete which build jobs.  Learn more... } > form.setFieldValue("tags", tags)} /> )} {/* Variables */} {variables && variables.length > 0 && ( {variables.map((variable, index) => ( { await form.setFieldValue(`user_variable_values.${index}`, { name: variable.name, value, }); }} /> ))} )} {logs && ( )} ); }; const fillNameAndDisplayWithFilename = async ( filename: string, form: ReturnType>, ) => { 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 ProvisionerWarning: FC = () => { return ( This organization does not have any provisioners. Before you create a template, you'll need to configure a provisioner.{" "} See our documentation. ); };