mirror of https://github.com/coder/coder.git
337 lines
9.4 KiB
TypeScript
337 lines
9.4 KiB
TypeScript
import TextField from "@mui/material/TextField";
|
|
import { useFormik } from "formik";
|
|
import camelCase from "lodash/camelCase";
|
|
import capitalize from "lodash/capitalize";
|
|
import type { FC } from "react";
|
|
import * as Yup from "yup";
|
|
import type {
|
|
ProvisionerJobLog,
|
|
Template,
|
|
TemplateExample,
|
|
TemplateVersionVariable,
|
|
VariableValue,
|
|
} from "api/typesGenerated";
|
|
import {
|
|
HorizontalForm,
|
|
FormSection,
|
|
FormFields,
|
|
FormFooter,
|
|
} from "components/Form/Form";
|
|
import { IconField } from "components/IconField/IconField";
|
|
import { SelectedTemplate } from "pages/CreateWorkspacePage/SelectedTemplate";
|
|
import {
|
|
nameValidator,
|
|
getFormHelpers,
|
|
onChangeTrimmed,
|
|
templateDisplayNameValidator,
|
|
} from "utils/formUtils";
|
|
import {
|
|
sortedDays,
|
|
type TemplateAutostartRequirementDaysValue,
|
|
type TemplateAutostopRequirementDaysValue,
|
|
} from "utils/schedule";
|
|
import { TemplateUpload, type TemplateUploadProps } from "./TemplateUpload";
|
|
import { VariableInput } from "./VariableInput";
|
|
|
|
const MAX_DESCRIPTION_CHAR_LIMIT = 128;
|
|
|
|
export interface CreateTemplateData {
|
|
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<string, string>;
|
|
user_variable_values?: VariableValue[];
|
|
allow_everyone_group_access: boolean;
|
|
}
|
|
|
|
const validationSchema = Yup.object({
|
|
name: nameValidator("Name"),
|
|
display_name: templateDisplayNameValidator("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: CreateTemplateData = {
|
|
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,
|
|
};
|
|
|
|
type GetInitialValuesParams = {
|
|
fromExample?: TemplateExample;
|
|
fromCopy?: Template;
|
|
variables?: TemplateVersionVariable[];
|
|
allowAdvancedScheduling: boolean;
|
|
};
|
|
|
|
const getInitialValues = ({
|
|
fromExample,
|
|
fromCopy,
|
|
allowAdvancedScheduling,
|
|
variables,
|
|
}: GetInitialValuesParams) => {
|
|
let initialValues = defaultInitialValues;
|
|
|
|
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) {
|
|
variables.forEach((variable) => {
|
|
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: CreateTemplateData) => void;
|
|
onOpenBuildLogsDrawer: () => void;
|
|
isSubmitting: boolean;
|
|
variables?: TemplateVersionVariable[];
|
|
error?: unknown;
|
|
jobError?: string;
|
|
logs?: ProvisionerJobLog[];
|
|
allowAdvancedScheduling: boolean;
|
|
variablesSectionRef: React.RefObject<HTMLDivElement>;
|
|
};
|
|
|
|
export const CreateTemplateForm: FC<CreateTemplateFormProps> = (props) => {
|
|
const {
|
|
onCancel,
|
|
onSubmit,
|
|
onOpenBuildLogsDrawer,
|
|
variables,
|
|
isSubmitting,
|
|
error,
|
|
jobError,
|
|
logs,
|
|
allowAdvancedScheduling,
|
|
variablesSectionRef,
|
|
} = props;
|
|
const form = useFormik<CreateTemplateData>({
|
|
initialValues: getInitialValues({
|
|
allowAdvancedScheduling,
|
|
fromExample:
|
|
"starterTemplate" in props ? props.starterTemplate : undefined,
|
|
fromCopy: "copiedTemplate" in props ? props.copiedTemplate : undefined,
|
|
variables,
|
|
}),
|
|
validationSchema,
|
|
onSubmit,
|
|
});
|
|
const getFieldHelpers = getFormHelpers<CreateTemplateData>(form, error);
|
|
|
|
return (
|
|
<HorizontalForm onSubmit={form.handleSubmit}>
|
|
{/* General info */}
|
|
<FormSection
|
|
title="General"
|
|
description="The name is used to identify the template in URLs and the API."
|
|
>
|
|
<FormFields>
|
|
{"starterTemplate" in props && (
|
|
<SelectedTemplate template={props.starterTemplate} />
|
|
)}
|
|
{"copiedTemplate" in props && (
|
|
<SelectedTemplate template={props.copiedTemplate} />
|
|
)}
|
|
{"upload" in props && (
|
|
<TemplateUpload
|
|
{...props.upload}
|
|
onUpload={async (file) => {
|
|
await fillNameAndDisplayWithFilename(file.name, form);
|
|
props.upload.onUpload(file);
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
<TextField
|
|
{...getFieldHelpers("name")}
|
|
disabled={isSubmitting}
|
|
onChange={onChangeTrimmed(form)}
|
|
autoFocus
|
|
fullWidth
|
|
required
|
|
label="Name"
|
|
/>
|
|
</FormFields>
|
|
</FormSection>
|
|
|
|
{/* Display info */}
|
|
<FormSection
|
|
title="Display"
|
|
description="A friendly name, description, and icon to help developers identify your template."
|
|
>
|
|
<FormFields>
|
|
<TextField
|
|
{...getFieldHelpers("display_name")}
|
|
disabled={isSubmitting}
|
|
fullWidth
|
|
label="Display name"
|
|
/>
|
|
|
|
<TextField
|
|
{...getFieldHelpers("description", {
|
|
maxLength: MAX_DESCRIPTION_CHAR_LIMIT,
|
|
})}
|
|
disabled={isSubmitting}
|
|
rows={5}
|
|
multiline
|
|
fullWidth
|
|
label="Description"
|
|
/>
|
|
|
|
<IconField
|
|
{...getFieldHelpers("icon")}
|
|
disabled={isSubmitting}
|
|
onChange={onChangeTrimmed(form)}
|
|
fullWidth
|
|
onPickEmoji={(value) => form.setFieldValue("icon", value)}
|
|
/>
|
|
</FormFields>
|
|
</FormSection>
|
|
|
|
{/* Variables */}
|
|
{variables && variables.length > 0 && (
|
|
<FormSection
|
|
ref={variablesSectionRef}
|
|
title="Variables"
|
|
description="Input variables allow you to customize templates without altering their source code."
|
|
>
|
|
<FormFields>
|
|
{variables.map((variable, index) => (
|
|
<VariableInput
|
|
defaultValue={variable.value}
|
|
variable={variable}
|
|
disabled={isSubmitting}
|
|
key={variable.name}
|
|
onChange={async (value) => {
|
|
await form.setFieldValue("user_variable_values." + index, {
|
|
name: variable.name,
|
|
value,
|
|
});
|
|
}}
|
|
/>
|
|
))}
|
|
</FormFields>
|
|
</FormSection>
|
|
)}
|
|
|
|
<div className="flex items-center">
|
|
<FormFooter
|
|
extraActions={
|
|
logs && (
|
|
<button
|
|
type="button"
|
|
onClick={onOpenBuildLogsDrawer}
|
|
css={(theme) => ({
|
|
backgroundColor: "transparent",
|
|
border: 0,
|
|
fontWeight: 500,
|
|
fontSize: 14,
|
|
cursor: "pointer",
|
|
color: theme.palette.text.secondary,
|
|
|
|
"&:hover": {
|
|
textDecoration: "underline",
|
|
textUnderlineOffset: 4,
|
|
color: theme.palette.text.primary,
|
|
},
|
|
})}
|
|
>
|
|
Show build logs
|
|
</button>
|
|
)
|
|
}
|
|
onCancel={onCancel}
|
|
isLoading={isSubmitting}
|
|
submitLabel={jobError ? "Retry" : "Create template"}
|
|
/>
|
|
</div>
|
|
</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)),
|
|
]);
|
|
};
|