feat: Add create template from the UI (#5427)

This commit is contained in:
Bruno Quaresma
2022-12-21 18:07:00 -03:00
committed by GitHub
parent 43b61ce33c
commit c505e8b207
45 changed files with 2540 additions and 228 deletions

View File

@ -92,6 +92,15 @@ const GitAuthPage = lazy(() => import("./pages/GitAuthPage/GitAuthPage"))
const TemplateVersionPage = lazy(
() => import("./pages/TemplateVersionPage/TemplateVersionPage"),
)
const StarterTemplatesPage = lazy(
() => import("./pages/StarterTemplatesPage/StarterTemplatesPage"),
)
const StarterTemplatePage = lazy(
() => import("pages/StarterTemplatePage/StarterTemplatePage"),
)
const CreateTemplatePage = lazy(
() => import("./pages/CreateTemplatePage/CreateTemplatePage"),
)
export const AppRouter: FC = () => {
const xServices = useContext(XServiceContext)
@ -141,6 +150,26 @@ export const AppRouter: FC = () => {
}
/>
<Route path="starter-templates">
<Route
index
element={
<AuthAndFrame>
<StarterTemplatesPage />
</AuthAndFrame>
}
/>
<Route
path=":exampleId"
element={
<AuthAndFrame>
<StarterTemplatePage />
</AuthAndFrame>
}
></Route>
</Route>
<Route path="templates">
<Route
index
@ -151,6 +180,15 @@ export const AppRouter: FC = () => {
}
/>
<Route
path="new"
element={
<RequireAuth>
<CreateTemplatePage />
</RequireAuth>
}
/>
<Route path=":template">
<Route
index

View File

@ -259,6 +259,37 @@ export const getPreviousTemplateVersionByName = async (
}
}
export const createTemplateVersion = async (
organizationId: string,
data: TypesGen.CreateTemplateVersionRequest,
): Promise<TypesGen.TemplateVersion> => {
const response = await axios.post<TypesGen.TemplateVersion>(
`/api/v2/organizations/${organizationId}/templateversions`,
data,
)
return response.data
}
export const getTemplateVersionParameters = async (
versionId: string,
): Promise<TypesGen.Parameter[]> => {
const response = await axios.get(
`/api/v2/templateversions/${versionId}/parameters`,
)
return response.data
}
export const createTemplate = async (
organizationId: string,
data: TypesGen.CreateTemplateRequest,
): Promise<TypesGen.Template> => {
const response = await axios.post(
`/api/v2/organizations/${organizationId}/templates`,
data,
)
return response.data
}
export const updateTemplateMeta = async (
templateId: string,
data: TypesGen.UpdateTemplateMeta,
@ -703,3 +734,32 @@ export const setServiceBanner = async (
const response = await axios.put(`/api/v2/service-banner`, b)
return response.data
}
export const getTemplateExamples = async (
organizationId: string,
): Promise<TypesGen.TemplateExample[]> => {
const response = await axios.get(
`/api/v2/organizations/${organizationId}/templates/examples`,
)
return response.data
}
export const uploadTemplateFile = async (
file: File,
): Promise<TypesGen.UploadResponse> => {
const response = await axios.post("/api/v2/files", file, {
headers: {
"Content-Type": "application/x-tar",
},
})
return response.data
}
export const getTemplateVersionLogs = async (
versionId: string,
): Promise<TypesGen.ProvisionerJobLog[]> => {
const response = await axios.get<TypesGen.ProvisionerJobLog[]>(
`/api/v2/templateversions/${versionId}/logs`,
)
return response.data
}

View File

@ -63,6 +63,10 @@ export const mapApiErrorToFieldErrors = (
return result
}
export const isApiValidationError = (error: unknown): error is ApiError => {
return isApiError(error) && hasApiFieldErrors(error)
}
/**
*
* @param error

View File

@ -0,0 +1,113 @@
import Button from "@material-ui/core/Button"
import InputAdornment from "@material-ui/core/InputAdornment"
import Popover from "@material-ui/core/Popover"
import TextField, { TextFieldProps } from "@material-ui/core/TextField"
import { OpenDropdown } from "components/DropdownArrows/DropdownArrows"
import { useRef, FC, useState } from "react"
import Picker from "@emoji-mart/react"
import { makeStyles } from "@material-ui/core/styles"
import { colors } from "theme/colors"
import { useTranslation } from "react-i18next"
import data from "@emoji-mart/data/sets/14/twitter.json"
export const IconField: FC<
TextFieldProps & { onPickEmoji: (value: string) => void }
> = ({ onPickEmoji, ...textFieldProps }) => {
if (
typeof textFieldProps.value !== "string" &&
typeof textFieldProps.value !== "undefined"
) {
throw new Error(`Invalid icon value "${typeof textFieldProps.value}"`)
}
const styles = useStyles()
const emojiButtonRef = useRef<HTMLButtonElement>(null)
const [isEmojiPickerOpen, setIsEmojiPickerOpen] = useState(false)
const { t } = useTranslation("templateSettingsPage")
const hasIcon = textFieldProps.value && textFieldProps.value !== ""
return (
<div className={styles.iconField}>
<TextField
{...textFieldProps}
fullWidth
label={t("iconLabel")}
variant="outlined"
InputProps={{
endAdornment: hasIcon ? (
<InputAdornment position="end" className={styles.adornment}>
<img
alt=""
src={textFieldProps.value}
// This prevent browser to display the ugly error icon if the
// image path is wrong or user didn't finish typing the url
onError={(e) => (e.currentTarget.style.display = "none")}
onLoad={(e) => (e.currentTarget.style.display = "inline")}
/>
</InputAdornment>
) : undefined,
}}
/>
<Button
fullWidth
ref={emojiButtonRef}
variant="outlined"
size="small"
endIcon={<OpenDropdown />}
onClick={() => {
setIsEmojiPickerOpen((v) => !v)
}}
>
{t("selectEmoji")}
</Button>
<Popover
id="emoji"
open={isEmojiPickerOpen}
anchorEl={emojiButtonRef.current}
onClose={() => {
setIsEmojiPickerOpen(false)
}}
>
<Picker
theme="dark"
data={data}
onEmojiSelect={(emojiData) => {
// See: https://github.com/missive/emoji-mart/issues/51#issuecomment-287353222
const value = `/emojis/${emojiData.unified.replace(
/-fe0f$/,
"",
)}.png`
onPickEmoji(value)
setIsEmojiPickerOpen(false)
}}
/>
</Popover>
</div>
)
}
const useStyles = makeStyles((theme) => ({
"@global": {
"em-emoji-picker": {
"--rgb-background": theme.palette.background.paper,
"--rgb-input": colors.gray[17],
"--rgb-color": colors.gray[4],
},
},
adornment: {
width: theme.spacing(3),
height: theme.spacing(3),
display: "flex",
alignItems: "center",
justifyContent: "center",
"& img": {
maxWidth: "100%",
},
},
iconField: {
paddingBottom: theme.spacing(0.5),
},
}))

View File

@ -1,4 +1,5 @@
import { makeStyles } from "@material-ui/core/styles"
import { LogLevel } from "api/typesGenerated"
import dayjs from "dayjs"
import { FC } from "react"
import { MONOSPACE_FONT_FAMILY } from "../../theme/constants"
@ -7,6 +8,7 @@ import { combineClasses } from "../../util/combineClasses"
interface Line {
time: string
output: string
level: LogLevel
}
export interface LogsProps {
@ -22,8 +24,9 @@ export const Logs: FC<React.PropsWithChildren<LogsProps>> = ({
return (
<div className={combineClasses([className, styles.root])}>
<div className={styles.scrollWrapper}>
{lines.map((line, idx) => (
<div className={styles.line} key={idx}>
<div className={combineClasses([styles.line, line.level])} key={idx}>
<span className={styles.time}>
{dayjs(line.time).format(`HH:mm:ss.SSS`)}
</span>
@ -32,6 +35,7 @@ export const Logs: FC<React.PropsWithChildren<LogsProps>> = ({
</div>
))}
</div>
</div>
)
}
@ -43,13 +47,25 @@ const useStyles = makeStyles((theme) => ({
fontFamily: MONOSPACE_FONT_FAMILY,
fontSize: 13,
wordBreak: "break-all",
padding: theme.spacing(2),
padding: theme.spacing(2, 0),
borderRadius: theme.shape.borderRadius,
overflowX: "auto",
},
scrollWrapper: {
width: "fit-content",
},
line: {
// Whitespace is significant in terminal output for alignment
whiteSpace: "pre",
padding: theme.spacing(0, 3),
"&.error": {
backgroundColor: theme.palette.error.dark,
},
"&.warning": {
backgroundColor: theme.palette.warning.dark,
},
},
space: {
userSelect: "none",

View File

@ -48,14 +48,14 @@ export const Markdown: FC<{ children: string }> = ({ children }) => {
<SyntaxHighlighter
style={darcula}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- this can be undefined
language={match[1] ?? "language-shell"}
language={match[1].toLowerCase() ?? "language-shell"}
useInlineStyles={false}
// Use inline styles does not work correctly
// https://github.com/react-syntax-highlighter/react-syntax-highlighter/issues/329
codeTagProps={{ style: {} }}
{...props}
>
{String(children).replace(/\n$/, "")}
{String(children)}
</SyntaxHighlighter>
) : (
<code className={styles.codeWithoutLanguage} {...props}>
@ -135,19 +135,24 @@ const useStyles = makeStyles((theme) => ({
background: theme.palette.background.paperLight,
borderRadius: theme.shape.borderRadius,
padding: theme.spacing(2, 3),
overflowX: "auto",
"& code": {
color: theme.palette.text.secondary,
},
"& .key, & .property": {
"& .key, & .property, & .inserted, .keyword": {
color: colors.turquoise[7],
},
"& .deleted": {
color: theme.palette.error.light,
},
},
},
codeWithoutLanguage: {
padding: theme.spacing(0.5, 1),
padding: theme.spacing(0.125, 0.5),
background: theme.palette.divider,
borderRadius: 4,
color: theme.palette.text.primary,

View File

@ -0,0 +1,91 @@
import { makeStyles } from "@material-ui/core/styles"
import { TemplateExample } from "api/typesGenerated"
import { FC } from "react"
import { Link } from "react-router-dom"
import { combineClasses } from "util/combineClasses"
export interface TemplateExampleCardProps {
example: TemplateExample
className?: string
}
export const TemplateExampleCard: FC<TemplateExampleCardProps> = ({
example,
className,
}) => {
const styles = useStyles()
return (
<Link
to={`/starter-templates/${example.id}`}
className={combineClasses([styles.template, className])}
key={example.id}
>
<div className={styles.templateIcon}>
<img src={example.icon} alt="" />
</div>
<div className={styles.templateInfo}>
<span className={styles.templateName}>{example.name}</span>
<span className={styles.templateDescription}>
{example.description}
</span>
</div>
</Link>
)
}
const useStyles = makeStyles((theme) => ({
template: {
border: `1px solid ${theme.palette.divider}`,
borderRadius: theme.shape.borderRadius,
background: theme.palette.background.paper,
textDecoration: "none",
textAlign: "left",
color: "inherit",
display: "flex",
alignItems: "center",
height: "fit-content",
"&:hover": {
backgroundColor: theme.palette.background.paperLight,
},
},
templateIcon: {
width: theme.spacing(12),
height: theme.spacing(12),
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
"& img": {
height: theme.spacing(4),
},
},
templateInfo: {
padding: theme.spacing(2, 2, 2, 0),
display: "flex",
flexDirection: "column",
gap: theme.spacing(0.5),
overflow: "hidden",
},
templateName: {
fontSize: theme.spacing(2),
textOverflow: "ellipsis",
width: "100%",
overflow: "hidden",
whiteSpace: "nowrap",
},
templateDescription: {
fontSize: theme.spacing(1.75),
color: theme.palette.text.secondary,
textOverflow: "ellipsis",
width: "100%",
overflow: "hidden",
whiteSpace: "nowrap",
},
}))

View File

@ -54,6 +54,7 @@ export const WorkspaceBuildLogs: FC<WorkspaceBuildLogsProps> = ({ logs }) => {
const lines = logs.map((log) => ({
time: log.created_at,
output: log.output,
level: log.log_level,
}))
const duration = getStageDurationInSeconds(logs)
const shouldDisplayDuration = duration !== undefined
@ -68,7 +69,7 @@ export const WorkspaceBuildLogs: FC<WorkspaceBuildLogsProps> = ({ logs }) => {
</div>
)}
</div>
{!isEmpty && <Logs lines={lines} className={styles.codeBlock} />}
{!isEmpty && <Logs lines={lines} />}
</Fragment>
)
})}
@ -86,8 +87,8 @@ const useStyles = makeStyles((theme) => ({
header: {
fontSize: 14,
padding: theme.spacing(2),
paddingLeft: theme.spacing(4),
paddingRight: theme.spacing(4),
paddingLeft: theme.spacing(3),
paddingRight: theme.spacing(3),
borderBottom: `1px solid ${theme.palette.divider}`,
backgroundColor: theme.palette.background.paper,
display: "flex",
@ -112,9 +113,4 @@ const useStyles = makeStyles((theme) => ({
color: theme.palette.text.secondary,
fontSize: theme.typography.body2.fontSize,
},
codeBlock: {
padding: theme.spacing(2),
paddingLeft: theme.spacing(4),
},
}))

View File

@ -0,0 +1,14 @@
import { useSelector } from "@xstate/react"
import { Entitlements } from "api/typesGenerated"
import { useContext } from "react"
import { XServiceContext } from "xServices/StateContext"
export const useEntitlements = (): Entitlements => {
const xServices = useContext(XServiceContext)
const entitlements = useSelector(
xServices.entitlementsXService,
(state) => state.context.entitlements,
)
return entitlements
}

View File

@ -0,0 +1,44 @@
{
"title": "Create Template",
"form": {
"generalInfo": {
"title": "General info",
"description": "The name is used to identify the template on the URL and also API. It has to be unique across your organization."
},
"displayInfo": {
"title": "Display info",
"description": "Set the name that you want to use to display your template, a helpful description and icon."
},
"schedule": {
"title": "Schedule",
"description": "Define when a workspace created from this template is going to stop."
},
"operations": {
"title": "Operations",
"description": "Allow or disallow users to run specific actions on the workspace."
},
"parameters": {
"title": "Template params",
"description": "These params are provided by your template's Terraform configuration."
},
"fields": {
"name": "Name",
"displayName": "Display name",
"description": "Description",
"icon": "Icon",
"autoStop": "Auto-stop default",
"allowUsersToCancel": "Allow users to cancel in-progress workspace jobs"
},
"helperText": {
"autoStop": "Time in hours",
"allowUsersToCancel": "Not recommended"
},
"upload": {
"removeTitle": "Remove file",
"title": "Upload template"
},
"tooltip": {
"allowUsersToCancel": "Depending on your template, canceling builds may leave workspaces in an unhealthy state. This option isn't recommended for most use cases."
}
}
}

View File

@ -14,6 +14,9 @@ import loginPage from "./loginPage.json"
import workspaceChangeVersionPage from "./workspaceChangeVersionPage.json"
import workspaceSchedulePage from "./workspaceSchedulePage.json"
import serviceBannerSettings from "./serviceBannerSettings.json"
import starterTemplatesPage from "./starterTemplatesPage.json"
import starterTemplatePage from "./starterTemplatePage.json"
import createTemplatePage from "./createTemplatePage.json"
export const en = {
common,
@ -32,4 +35,7 @@ export const en = {
workspaceChangeVersionPage,
workspaceSchedulePage,
serviceBannerSettings,
starterTemplatesPage,
starterTemplatePage,
createTemplatePage,
}

View File

@ -0,0 +1,6 @@
{
"actions": {
"viewSourceCode": "View source code",
"useTemplate": "Use template"
}
}

View File

@ -0,0 +1,11 @@
{
"title": "Starter Templates",
"subtitle": "Pick one of the built-in templates to start using Coder",
"filterCaption": "Filter",
"tags": {
"all": "All templates",
"digitalocean": "Digital Ocean",
"aws": "AWS",
"google": "Google Cloud"
}
}

View File

@ -2,5 +2,9 @@
"errors": {
"getOrganizationError": "Something went wrong fetching organizations.",
"getTemplatesError": "Something went wrong fetching templates."
},
"empty": {
"message": "Create your first template",
"descriptionWithoutPermissions": "Contact your Coder administrator to create a template. You can share the code below."
}
}

View File

@ -0,0 +1,403 @@
import Checkbox from "@material-ui/core/Checkbox"
import { makeStyles } from "@material-ui/core/styles"
import TextField from "@material-ui/core/TextField"
import {
ParameterSchema,
ProvisionerJobLog,
TemplateExample,
} from "api/typesGenerated"
import { FormFooter } from "components/FormFooter/FormFooter"
import { IconField } from "components/IconField/IconField"
import { ParameterInput } from "components/ParameterInput/ParameterInput"
import { Stack } from "components/Stack/Stack"
import {
TemplateUpload,
TemplateUploadProps,
} from "pages/CreateTemplatePage/TemplateUpload"
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 { CreateTemplateData } from "xServices/createTemplate/createTemplateXService"
import * as Yup from "yup"
import { WorkspaceBuildLogs } from "components/WorkspaceBuildLogs/WorkspaceBuildLogs"
import { HelpTooltip, HelpTooltipText } from "components/Tooltips/HelpTooltip"
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(),
})
const defaultInitialValues: CreateTemplateData = {
name: "",
display_name: "",
description: "",
icon: "",
default_ttl_hours: 24,
allow_user_cancel_workspace_jobs: false,
parameter_values_by_name: undefined,
}
const getInitialValues = (starterTemplate?: TemplateExample) => {
if (!starterTemplate) {
return defaultInitialValues
}
return {
...defaultInitialValues,
name: starterTemplate.id,
display_name: starterTemplate.name,
icon: starterTemplate.icon,
description: starterTemplate.description,
}
}
interface CreateTemplateFormProps {
starterTemplate?: TemplateExample
error?: unknown
parameters?: ParameterSchema[]
isSubmitting: boolean
onCancel: () => void
onSubmit: (data: CreateTemplateData) => void
upload: TemplateUploadProps
jobError?: string
logs?: ProvisionerJobLog[]
}
export const CreateTemplateForm: FC<CreateTemplateFormProps> = ({
starterTemplate,
error,
parameters,
isSubmitting,
onCancel,
onSubmit,
upload,
jobError,
logs,
}) => {
const styles = useStyles()
const formFooterStyles = useFormFooterStyles()
const form = useFormik<CreateTemplateData>({
initialValues: getInitialValues(starterTemplate),
validationSchema,
onSubmit,
})
const getFieldHelpers = getFormHelpers<CreateTemplateData>(form, error)
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"
/>
</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>
<Stack direction="column" className={styles.formSectionFields}>
<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"
/>
<IconField
{...getFieldHelpers("icon")}
disabled={isSubmitting}
onChange={onChangeTrimmed(form)}
fullWidth
label={t("form.fields.icon")}
variant="outlined"
onPickEmoji={(value) => form.setFieldValue("icon", value)}
/>
</Stack>
</div>
{/* 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}>
<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 */}
<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}>
<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>
</Stack>
</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>
</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>
<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>
)
}
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,
},
optionHelperText: {
fontSize: theme.spacing(1.5),
color: theme.palette.text.secondary,
},
error: {
padding: theme.spacing(3),
borderRadius: theme.spacing(1),
background: theme.palette.background.paper,
border: `1px solid ${theme.palette.error.main}`,
},
errorTitle: {
fontSize: 16,
margin: 0,
},
errorDescription: {
margin: 0,
color: theme.palette.text.secondary,
marginTop: theme.spacing(0.5),
},
errorDetails: {
display: "block",
marginTop: theme.spacing(1),
color: theme.palette.error.light,
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,90 @@
import { useMachine } from "@xstate/react"
import { isApiValidationError } from "api/errors"
import { AlertBanner } from "components/AlertBanner/AlertBanner"
import { Maybe } from "components/Conditionals/Maybe"
import { FullPageHorizontalForm } from "components/FullPageForm/FullPageHorizontalForm"
import { Loader } from "components/Loader/Loader"
import { Stack } from "components/Stack/Stack"
import { useOrganizationId } from "hooks/useOrganizationId"
import { FC } from "react"
import { Helmet } from "react-helmet-async"
import { useTranslation } from "react-i18next"
import { useNavigate, useSearchParams } from "react-router-dom"
import { pageTitle } from "util/page"
import { createTemplateMachine } from "xServices/createTemplate/createTemplateXService"
import { CreateTemplateForm } from "./CreateTemplateForm"
const CreateTemplatePage: FC = () => {
const { t } = useTranslation("createTemplatePage")
const navigate = useNavigate()
const organizationId = useOrganizationId()
const [searchParams] = useSearchParams()
const [state, send] = useMachine(createTemplateMachine, {
context: {
organizationId,
exampleId: searchParams.get("exampleId"),
},
actions: {
onCreate: (_, { data }) => {
navigate(`/templates/${data.name}`)
},
},
})
const { starterTemplate, parameters, error, file, jobError, jobLogs } =
state.context
const shouldDisplayForm = !state.hasTag("loading")
const onCancel = () => {
navigate(-1)
}
return (
<>
<Helmet>
<title>{pageTitle(t("title"))}</title>
</Helmet>
<FullPageHorizontalForm title={t("title")} onCancel={onCancel}>
<Maybe condition={state.hasTag("loading")}>
<Loader />
</Maybe>
<Stack spacing={6}>
<Maybe condition={Boolean(error && !isApiValidationError(error))}>
<AlertBanner error={error} severity="error" />
</Maybe>
{shouldDisplayForm && (
<CreateTemplateForm
error={error}
starterTemplate={starterTemplate}
isSubmitting={state.hasTag("submitting")}
parameters={parameters}
onCancel={onCancel}
onSubmit={(data) => {
send({
type: "CREATE",
data,
})
}}
upload={{
file,
isUploading: state.matches("uploading"),
onRemove: () => {
send("REMOVE_FILE")
},
onUpload: (file) => {
send({ type: "UPLOAD_FILE", file })
},
}}
jobError={jobError}
logs={jobLogs}
/>
)}
</Stack>
</FullPageHorizontalForm>
</>
)
}
export default CreateTemplatePage

View File

@ -0,0 +1,185 @@
import { makeStyles } from "@material-ui/core/styles"
import { Stack } from "components/Stack/Stack"
import { FC, DragEvent, useRef } from "react"
import UploadIcon from "@material-ui/icons/CloudUploadOutlined"
import { useClickable } from "hooks/useClickable"
import CircularProgress from "@material-ui/core/CircularProgress"
import { combineClasses } from "util/combineClasses"
import IconButton from "@material-ui/core/IconButton"
import RemoveIcon from "@material-ui/icons/DeleteOutline"
import FileIcon from "@material-ui/icons/FolderOutlined"
import { useTranslation } from "react-i18next"
import Link from "@material-ui/core/Link"
import { Link as RouterLink } from "react-router-dom"
const useTarDrop = (
callback: (file: File) => void,
): {
onDragOver: (e: DragEvent<HTMLDivElement>) => void
onDrop: (e: DragEvent<HTMLDivElement>) => void
} => {
const onDragOver = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault()
}
const onDrop = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault()
const file = e.dataTransfer.files[0]
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- file can be undefined
if (!file || file.type !== "application/x-tar") {
return
}
callback(file)
}
return {
onDragOver,
onDrop,
}
}
export interface TemplateUploadProps {
isUploading: boolean
onUpload: (file: File) => void
onRemove: () => void
file?: File
}
export const TemplateUpload: FC<TemplateUploadProps> = ({
isUploading,
onUpload,
onRemove,
file,
}) => {
const styles = useStyles()
const inputRef = useRef<HTMLInputElement>(null)
const tarDrop = useTarDrop(onUpload)
const clickable = useClickable(() => {
if (inputRef.current) {
inputRef.current.click()
}
})
const { t } = useTranslation("createTemplatePage")
if (!isUploading && file) {
return (
<Stack
className={styles.file}
direction="row"
justifyContent="space-between"
alignItems="center"
>
<Stack direction="row" alignItems="center">
<FileIcon />
<span>{file.name}</span>
</Stack>
<IconButton
title={t("form.upload.removeFile")}
size="small"
onClick={onRemove}
>
<RemoveIcon />
</IconButton>
</Stack>
)
}
return (
<>
<div
className={combineClasses({
[styles.root]: true,
[styles.disabled]: isUploading,
})}
{...clickable}
{...tarDrop}
>
<Stack alignItems="center" spacing={1}>
{isUploading ? (
<CircularProgress size={32} />
) : (
<UploadIcon className={styles.icon} />
)}
<Stack alignItems="center" spacing={0.5}>
<span className={styles.title}>{t("form.upload.title")}</span>
<span className={styles.description}>
The template has to be a .tar file. You can also use our{" "}
<Link
component={RouterLink}
to="/starter-templates"
// Prevent trigger the upload
onClick={(e) => {
e.stopPropagation()
}}
>
starter templates
</Link>{" "}
to getting started with Coder.
</span>
</Stack>
</Stack>
</div>
<input
type="file"
ref={inputRef}
className={styles.input}
accept=".tar"
onChange={(event) => {
const file = event.currentTarget.files?.[0]
if (file) {
onUpload(file)
}
}}
/>
</>
)
}
const useStyles = makeStyles((theme) => ({
root: {
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: theme.shape.borderRadius,
border: `2px dashed ${theme.palette.divider}`,
padding: theme.spacing(6),
cursor: "pointer",
"&:hover": {
backgroundColor: theme.palette.background.paper,
},
},
disabled: {
pointerEvents: "none",
opacity: 0.75,
},
icon: {
fontSize: theme.spacing(8),
},
title: {
fontSize: theme.spacing(2),
},
description: {
color: theme.palette.text.secondary,
textAlign: "center",
maxWidth: theme.spacing(50),
},
input: {
display: "none",
},
file: {
borderRadius: theme.shape.borderRadius,
border: `1px solid ${theme.palette.divider}`,
padding: theme.spacing(2),
background: theme.palette.background.paper,
},
}))

View File

@ -1,12 +1,12 @@
import Avatar from "@material-ui/core/Avatar"
import { makeStyles } from "@material-ui/core/styles"
import { Template } from "api/typesGenerated"
import { Template, TemplateExample } from "api/typesGenerated"
import { Stack } from "components/Stack/Stack"
import React, { FC } from "react"
import { firstLetter } from "util/firstLetter"
export interface SelectedTemplateProps {
template: Template
template: Template | TemplateExample
}
export const SelectedTemplate: FC<SelectedTemplateProps> = ({ template }) => {
@ -28,7 +28,7 @@ export const SelectedTemplate: FC<SelectedTemplateProps> = ({ template }) => {
</div>
<Stack direction="column" spacing={0.5}>
<span className={styles.templateName}>
{template.display_name.length > 0
{"display_name" in template && template.display_name.length > 0
? template.display_name
: template.name}
</span>

View File

@ -0,0 +1,20 @@
import { screen } from "@testing-library/react"
import {
MockTemplateExample,
renderWithAuth,
waitForLoaderToBeRemoved,
} from "testHelpers/renderHelpers"
import StarterTemplatePage from "./StarterTemplatePage"
jest.mock("remark-gfm", () => jest.fn())
describe("StarterTemplatePage", () => {
it("shows the starter template", async () => {
renderWithAuth(<StarterTemplatePage />, {
route: `/starter-templates/${MockTemplateExample.id}`,
path: "/starter-templates/:exampleId",
})
await waitForLoaderToBeRemoved()
expect(screen.getByText(MockTemplateExample.name)).toBeInTheDocument()
})
})

View File

@ -0,0 +1,33 @@
import { useMachine } from "@xstate/react"
import { useOrganizationId } from "hooks/useOrganizationId"
import { FC } from "react"
import { Helmet } from "react-helmet-async"
import { useParams } from "react-router-dom"
import { pageTitle } from "util/page"
import { starterTemplateMachine } from "xServices/starterTemplates/starterTemplateXService"
import { StarterTemplatePageView } from "./StarterTemplatePageView"
const StarterTemplatePage: FC = () => {
const { exampleId } = useParams() as { exampleId: string }
const organizationId = useOrganizationId()
const [state] = useMachine(starterTemplateMachine, {
context: {
organizationId,
exampleId,
},
})
return (
<>
<Helmet>
<title>
{pageTitle(state.context.starterTemplate?.name ?? exampleId)}
</title>
</Helmet>
<StarterTemplatePageView context={state.context} />
</>
)
}
export default StarterTemplatePage

View File

@ -0,0 +1,41 @@
import { Story } from "@storybook/react"
import {
makeMockApiError,
MockOrganization,
MockTemplateExample,
} from "testHelpers/entities"
import {
StarterTemplatePageView,
StarterTemplatePageViewProps,
} from "./StarterTemplatePageView"
export default {
title: "pages/StarterTemplatePageView",
component: StarterTemplatePageView,
}
const Template: Story<StarterTemplatePageViewProps> = (args) => (
<StarterTemplatePageView {...args} />
)
export const Default = Template.bind({})
Default.args = {
context: {
exampleId: MockTemplateExample.id,
organizationId: MockOrganization.id,
error: undefined,
starterTemplate: MockTemplateExample,
},
}
export const Error = Template.bind({})
Error.args = {
context: {
exampleId: MockTemplateExample.id,
organizationId: MockOrganization.id,
error: makeMockApiError({
message: `Example ${MockTemplateExample.id} not found.`,
}),
starterTemplate: undefined,
},
}

View File

@ -0,0 +1,115 @@
import Button from "@material-ui/core/Button"
import { makeStyles } from "@material-ui/core/styles"
import { Loader } from "components/Loader/Loader"
import { Margins } from "components/Margins/Margins"
import { MemoizedMarkdown } from "components/Markdown/Markdown"
import {
PageHeader,
PageHeaderSubtitle,
PageHeaderTitle,
} from "components/PageHeader/PageHeader"
import { FC } from "react"
import { StarterTemplateContext } from "xServices/starterTemplates/starterTemplateXService"
import EyeIcon from "@material-ui/icons/VisibilityOutlined"
import PlusIcon from "@material-ui/icons/AddOutlined"
import { AlertBanner } from "components/AlertBanner/AlertBanner"
import { useTranslation } from "react-i18next"
import { Stack } from "components/Stack/Stack"
import { Link } from "react-router-dom"
export interface StarterTemplatePageViewProps {
context: StarterTemplateContext
}
export const StarterTemplatePageView: FC<StarterTemplatePageViewProps> = ({
context,
}) => {
const styles = useStyles()
const { starterTemplate } = context
const { t } = useTranslation("starterTemplatePage")
if (context.error) {
return (
<Margins>
<AlertBanner error={context.error} severity="error" />
</Margins>
)
}
if (!starterTemplate) {
return <Loader />
}
return (
<Margins>
<PageHeader
actions={
<>
<Button
component="a"
target="_blank"
href={starterTemplate.url}
rel="noreferrer"
startIcon={<EyeIcon />}
>
{t("actions.viewSourceCode")}
</Button>
<Button
component={Link}
to={`/templates/new?exampleId=${starterTemplate.id}`}
startIcon={<PlusIcon />}
>
{t("actions.useTemplate")}
</Button>
</>
}
>
<Stack direction="row" spacing={3} alignItems="center">
<div className={styles.icon}>
<img src={starterTemplate.icon} alt="" />
</div>
<div>
<PageHeaderTitle>{starterTemplate.name}</PageHeaderTitle>
<PageHeaderSubtitle condensed>
{starterTemplate.description}
</PageHeaderSubtitle>
</div>
</Stack>
</PageHeader>
<div className={styles.markdownSection} id="readme">
<div className={styles.markdownWrapper}>
<MemoizedMarkdown>{starterTemplate.markdown}</MemoizedMarkdown>
</div>
</div>
</Margins>
)
}
export const useStyles = makeStyles((theme) => {
return {
icon: {
height: theme.spacing(6),
width: theme.spacing(6),
display: "flex",
alignItems: "center",
justifyContent: "center",
"& img": {
width: "100%",
},
},
markdownSection: {
background: theme.palette.background.paper,
border: `1px solid ${theme.palette.divider}`,
borderRadius: theme.shape.borderRadius,
},
markdownWrapper: {
padding: theme.spacing(5, 5, 8),
maxWidth: 800,
margin: "auto",
},
}
})

View File

@ -0,0 +1,20 @@
import { screen } from "@testing-library/react"
import {
MockTemplateExample,
MockTemplateExample2,
renderWithAuth,
waitForLoaderToBeRemoved,
} from "testHelpers/renderHelpers"
import StarterTemplatesPage from "./StarterTemplatesPage"
describe("StarterTemplatesPage", () => {
it("shows the starter template", async () => {
renderWithAuth(<StarterTemplatesPage />, {
route: `/starter-templates`,
path: "/starter-templates",
})
await waitForLoaderToBeRemoved()
expect(screen.getByText(MockTemplateExample.name)).toBeInTheDocument()
expect(screen.getByText(MockTemplateExample2.name)).toBeInTheDocument()
})
})

View File

@ -0,0 +1,28 @@
import { useMachine } from "@xstate/react"
import { useOrganizationId } from "hooks/useOrganizationId"
import { FC } from "react"
import { Helmet } from "react-helmet-async"
import { useTranslation } from "react-i18next"
import { pageTitle } from "util/page"
import { starterTemplatesMachine } from "xServices/starterTemplates/starterTemplatesXService"
import { StarterTemplatesPageView } from "./StarterTemplatesPageView"
const StarterTemplatesPage: FC = () => {
const { t } = useTranslation("starterTemplatesPage")
const organizationId = useOrganizationId()
const [state] = useMachine(starterTemplatesMachine, {
context: { organizationId },
})
return (
<>
<Helmet>
<title>{pageTitle(t("title"))}</title>
</Helmet>
<StarterTemplatesPageView context={state.context} />
</>
)
}
export default StarterTemplatesPage

View File

@ -0,0 +1,44 @@
import { Story } from "@storybook/react"
import {
makeMockApiError,
MockOrganization,
MockTemplateExample,
MockTemplateExample2,
} from "testHelpers/entities"
import { getTemplatesByTag } from "util/starterTemplates"
import {
StarterTemplatesPageView,
StarterTemplatesPageViewProps,
} from "./StarterTemplatesPageView"
export default {
title: "pages/StarterTemplatesPageView",
component: StarterTemplatesPageView,
}
const Template: Story<StarterTemplatesPageViewProps> = (args) => (
<StarterTemplatesPageView {...args} />
)
export const Default = Template.bind({})
Default.args = {
context: {
organizationId: MockOrganization.id,
error: undefined,
starterTemplatesByTag: getTemplatesByTag([
MockTemplateExample,
MockTemplateExample2,
]),
},
}
export const Error = Template.bind({})
Error.args = {
context: {
organizationId: MockOrganization.id,
error: makeMockApiError({
message: "Error on loading the template examples",
}),
starterTemplatesByTag: undefined,
},
}

View File

@ -0,0 +1,134 @@
import { makeStyles } from "@material-ui/core/styles"
import { AlertBanner } from "components/AlertBanner/AlertBanner"
import { Maybe } from "components/Conditionals/Maybe"
import { Loader } from "components/Loader/Loader"
import { Margins } from "components/Margins/Margins"
import {
PageHeader,
PageHeaderSubtitle,
PageHeaderTitle,
} from "components/PageHeader/PageHeader"
import { Stack } from "components/Stack/Stack"
import { TemplateExampleCard } from "components/TemplateExampleCard/TemplateExampleCard"
import { FC } from "react"
import { useTranslation } from "react-i18next"
import { Link, useSearchParams } from "react-router-dom"
import { combineClasses } from "util/combineClasses"
import { StarterTemplatesContext } from "xServices/starterTemplates/starterTemplatesXService"
const getTagLabel = (tag: string, t: (key: string) => string) => {
const labelByTag: Record<string, string> = {
all: t("tags.all"),
digitalocean: t("tags.digitalocean"),
aws: t("tags.aws"),
google: t("tags.google"),
}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- this can be undefined
return labelByTag[tag] ?? tag
}
const selectTags = ({ starterTemplatesByTag }: StarterTemplatesContext) => {
return starterTemplatesByTag
? Object.keys(starterTemplatesByTag).sort((a, b) => a.localeCompare(b))
: undefined
}
export interface StarterTemplatesPageViewProps {
context: StarterTemplatesContext
}
export const StarterTemplatesPageView: FC<StarterTemplatesPageViewProps> = ({
context,
}) => {
const { t } = useTranslation("starterTemplatesPage")
const [urlParams] = useSearchParams()
const styles = useStyles()
const { starterTemplatesByTag } = context
const tags = selectTags(context)
const activeTag = urlParams.get("tag") ?? "all"
const visibleTemplates = starterTemplatesByTag
? starterTemplatesByTag[activeTag]
: undefined
return (
<Margins>
<PageHeader>
<PageHeaderTitle>{t("title")}</PageHeaderTitle>
<PageHeaderSubtitle>{t("subtitle")}</PageHeaderSubtitle>
</PageHeader>
<Maybe condition={Boolean(context.error)}>
<AlertBanner error={context.error} severity="error" />
</Maybe>
<Maybe condition={Boolean(!starterTemplatesByTag)}>
<Loader />
</Maybe>
<Stack direction="row" spacing={4}>
{starterTemplatesByTag && tags && (
<Stack className={styles.filter}>
<span className={styles.filterCaption}>{t("filterCaption")}</span>
{tags.map((tag) => (
<Link
key={tag}
to={`?tag=${tag}`}
className={combineClasses({
[styles.tagLink]: true,
[styles.tagLinkActive]: tag === activeTag,
})}
>
{getTagLabel(tag, t)} ({starterTemplatesByTag[tag].length})
</Link>
))}
</Stack>
)}
<div className={styles.templates}>
{visibleTemplates &&
visibleTemplates.map((example) => (
<TemplateExampleCard example={example} key={example.id} />
))}
</div>
</Stack>
</Margins>
)
}
const useStyles = makeStyles((theme) => ({
filter: {
width: theme.spacing(26),
flexShrink: 0,
},
filterCaption: {
textTransform: "uppercase",
fontWeight: 600,
fontSize: 12,
color: theme.palette.text.secondary,
letterSpacing: "0.1em",
},
tagLink: {
color: theme.palette.text.secondary,
textDecoration: "none",
fontSize: 14,
textTransform: "capitalize",
"&:hover": {
color: theme.palette.text.primary,
},
},
tagLinkActive: {
color: theme.palette.text.primary,
fontWeight: 600,
},
templates: {
flex: "1",
display: "grid",
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
gap: theme.spacing(2),
gridAutoRows: "min-content",
},
}))

View File

@ -0,0 +1,161 @@
import Button from "@material-ui/core/Button"
import Link from "@material-ui/core/Link"
import { makeStyles } from "@material-ui/core/styles"
import { Entitlements, TemplateExample } from "api/typesGenerated"
import { CodeExample } from "components/CodeExample/CodeExample"
import { Stack } from "components/Stack/Stack"
import { TableEmpty } from "components/TableEmpty/TableEmpty"
import { TemplateExampleCard } from "components/TemplateExampleCard/TemplateExampleCard"
import { FC } from "react"
import { useTranslation } from "react-i18next"
import { Link as RouterLink } from "react-router-dom"
import { Permissions } from "xServices/auth/authXService"
// Those are from https://github.com/coder/coder/tree/main/examples/templates
const featuredExamples = [
"docker",
"kubernetes",
"aws-linux",
"aws-windows",
"gcp-linux",
"gcp-windows",
]
const findFeaturedExamples = (examples: TemplateExample[]) => {
return examples.filter((example) => featuredExamples.includes(example.id))
}
export const EmptyTemplates: FC<{
permissions: Permissions
examples: TemplateExample[]
entitlements: Entitlements
}> = ({ permissions, examples, entitlements }) => {
const styles = useStyles()
const { t } = useTranslation("templatesPage")
const featuredExamples = findFeaturedExamples(examples)
if (permissions.createTemplates && entitlements.experimental) {
return (
<TableEmpty
message={t("empty.message")}
description={
<>
You can create a template using our starter templates or{" "}
<Link component={RouterLink} to="/new">
uploading a template
</Link>
. You can also{" "}
<Link
href="https://coder.com/docs/coder-oss/latest/templates#add-a-template"
target="_blank"
rel="noreferrer"
>
use the CLI
</Link>
.
</>
}
cta={
<Stack alignItems="center" spacing={4}>
<div className={styles.featuredExamples}>
{featuredExamples.map((example) => (
<TemplateExampleCard
example={example}
key={example.id}
className={styles.template}
/>
))}
</div>
<Button
size="small"
component={RouterLink}
to="/starter-templates"
className={styles.viewAllButton}
>
View all starter templates
</Button>
</Stack>
}
/>
)
}
if (permissions.createTemplates) {
return (
<TableEmpty
className={styles.withImage}
message={t("empty.message")}
description={
<>
To create a workspace you need to have a template. You can{" "}
<Link
target="_blank"
href="https://coder.com/docs/coder-oss/latest/templates"
>
create one from scratch
</Link>{" "}
or use a built-in template using the following Coder CLI command:
</>
}
cta={<CodeExample code="coder templates init" />}
image={
<div className={styles.emptyImage}>
<img src="/featured/templates.webp" alt="" />
</div>
}
/>
)
}
return (
<TableEmpty
className={styles.withImage}
message={t("empty.message")}
description={t("empty.descriptionWithoutPermissions")}
cta={<CodeExample code="coder templates init" />}
image={
<div className={styles.emptyImage}>
<img src="/featured/templates.webp" alt="" />
</div>
}
/>
)
}
const useStyles = makeStyles((theme) => ({
withImage: {
paddingBottom: 0,
},
emptyImage: {
maxWidth: "50%",
height: theme.spacing(40),
overflow: "hidden",
opacity: 0.85,
"& img": {
maxWidth: "100%",
},
},
featuredExamples: {
maxWidth: theme.spacing(100),
display: "grid",
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
gap: theme.spacing(2),
gridAutoRows: "min-content",
},
template: {
backgroundColor: theme.palette.background.paperLight,
"&:hover": {
backgroundColor: theme.palette.divider,
},
},
viewAllButton: {
borderRadius: 9999,
},
}))

View File

@ -2,17 +2,18 @@ import { screen } from "@testing-library/react"
import { rest } from "msw"
import * as CreateDayString from "util/createDayString"
import { MockTemplate } from "../../testHelpers/entities"
import { history, render } from "../../testHelpers/renderHelpers"
import { renderWithAuth } from "../../testHelpers/renderHelpers"
import { server } from "../../testHelpers/server"
import { TemplatesPage } from "./TemplatesPage"
import { Language } from "./TemplatesPageView"
import i18next from "i18next"
const { t } = i18next
describe("TemplatesPage", () => {
beforeEach(() => {
// Mocking the dayjs module within the createDayString file
const mock = jest.spyOn(CreateDayString, "createDayString")
mock.mockImplementation(() => "a minute ago")
history.replace("/workspaces")
})
it("renders an empty templates page", async () => {
@ -35,15 +36,24 @@ describe("TemplatesPage", () => {
)
// When
render(<TemplatesPage />)
renderWithAuth(<TemplatesPage />, {
route: `/templates`,
path: "/templates",
})
// Then
await screen.findByText(Language.emptyMessage)
const emptyMessage = t("empty.message", {
ns: "templatesPage",
})
await screen.findByText(emptyMessage)
})
it("renders a filled templates page", async () => {
// When
render(<TemplatesPage />)
renderWithAuth(<TemplatesPage />, {
route: `/templates`,
path: "/templates",
})
// Then
await screen.findByText(MockTemplate.display_name)
@ -68,9 +78,14 @@ describe("TemplatesPage", () => {
)
// When
render(<TemplatesPage />)
renderWithAuth(<TemplatesPage />, {
route: `/templates`,
path: "/templates",
})
// Then
await screen.findByText(Language.emptyViewNoPerms)
const emptyMessage = t("empty.descriptionWithoutPermissions", {
ns: "templatesPage",
})
await screen.findByText(emptyMessage)
})
})

View File

@ -1,17 +1,23 @@
import { useActor, useMachine } from "@xstate/react"
import React, { useContext } from "react"
import { useMachine } from "@xstate/react"
import { useEntitlements } from "hooks/useEntitlements"
import { useOrganizationId } from "hooks/useOrganizationId"
import { usePermissions } from "hooks/usePermissions"
import React from "react"
import { Helmet } from "react-helmet-async"
import { pageTitle } from "../../util/page"
import { XServiceContext } from "../../xServices/StateContext"
import { templatesMachine } from "../../xServices/templates/templatesXService"
import { TemplatesPageView } from "./TemplatesPageView"
export const TemplatesPage: React.FC = () => {
const xServices = useContext(XServiceContext)
const [authState] = useActor(xServices.authXService)
const [templatesState] = useMachine(templatesMachine)
const { templates, getOrganizationsError, getTemplatesError } =
templatesState.context
const organizationId = useOrganizationId()
const permissions = usePermissions()
const entitlements = useEntitlements()
const [templatesState] = useMachine(templatesMachine, {
context: {
organizationId,
permissions,
},
})
return (
<>
@ -19,11 +25,8 @@ export const TemplatesPage: React.FC = () => {
<title>{pageTitle("Templates")}</title>
</Helmet>
<TemplatesPageView
templates={templates}
canCreateTemplate={authState.context.permissions?.createTemplates}
loading={templatesState.hasTag("loading")}
getOrganizationsError={getOrganizationsError}
getTemplatesError={getTemplatesError}
context={templatesState.context}
entitlements={entitlements}
/>
</>
)

View File

@ -1,5 +1,13 @@
import { ComponentMeta, Story } from "@storybook/react"
import { makeMockApiError, MockTemplate } from "../../testHelpers/entities"
import {
makeMockApiError,
MockEntitlements,
MockOrganization,
MockPermissions,
MockTemplate,
MockTemplateExample,
MockTemplateExample2,
} from "../../testHelpers/entities"
import { TemplatesPageView, TemplatesPageViewProps } from "./TemplatesPageView"
export default {
@ -11,9 +19,13 @@ const Template: Story<TemplatesPageViewProps> = (args) => (
<TemplatesPageView {...args} />
)
export const AllStates = Template.bind({})
AllStates.args = {
canCreateTemplate: true,
export const WithTemplates = Template.bind({})
WithTemplates.args = {
entitlements: MockEntitlements,
context: {
organizationId: MockOrganization.id,
permissions: MockPermissions,
error: undefined,
templates: [
MockTemplate,
{
@ -34,27 +46,73 @@ AllStates.args = {
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. ",
},
],
examples: [],
},
}
export const SmallViewport = Template.bind({})
SmallViewport.args = {
...AllStates.args,
export const WithTemplatesSmallViewPort = Template.bind({})
WithTemplatesSmallViewPort.args = {
...WithTemplates.args,
}
SmallViewport.parameters = {
WithTemplatesSmallViewPort.parameters = {
chromatic: { viewports: [600] },
}
export const EmptyCanCreate = Template.bind({})
EmptyCanCreate.args = {
canCreateTemplate: true,
entitlements: MockEntitlements,
context: {
organizationId: MockOrganization.id,
permissions: MockPermissions,
error: undefined,
templates: [],
examples: [MockTemplateExample, MockTemplateExample2],
},
}
export const EmptyCanCreateExperimental = Template.bind({})
EmptyCanCreateExperimental.args = {
entitlements: {
...MockEntitlements,
experimental: true,
},
context: {
organizationId: MockOrganization.id,
permissions: MockPermissions,
error: undefined,
templates: [],
examples: [MockTemplateExample, MockTemplateExample2],
},
}
export const EmptyCannotCreate = Template.bind({})
EmptyCannotCreate.args = {}
EmptyCannotCreate.args = {
entitlements: MockEntitlements,
context: {
organizationId: MockOrganization.id,
permissions: {
...MockPermissions,
createTemplates: false,
},
error: undefined,
templates: [],
examples: [MockTemplateExample, MockTemplateExample2],
},
}
export const Error = Template.bind({})
Error.args = {
getTemplatesError: makeMockApiError({
entitlements: MockEntitlements,
context: {
organizationId: MockOrganization.id,
permissions: {
...MockPermissions,
createTemplates: false,
},
error: makeMockApiError({
message: "Something went wrong fetching templates.",
}),
templates: undefined,
examples: undefined,
},
}

View File

@ -1,3 +1,4 @@
import Button from "@material-ui/core/Button"
import Link from "@material-ui/core/Link"
import { makeStyles, Theme } from "@material-ui/core/styles"
import Table from "@material-ui/core/Table"
@ -7,22 +8,19 @@ import TableContainer from "@material-ui/core/TableContainer"
import TableHead from "@material-ui/core/TableHead"
import TableRow from "@material-ui/core/TableRow"
import KeyboardArrowRight from "@material-ui/icons/KeyboardArrowRight"
import AddIcon from "@material-ui/icons/AddOutlined"
import useTheme from "@material-ui/styles/useTheme"
import { AlertBanner } from "components/AlertBanner/AlertBanner"
import { ChooseOne, Cond } from "components/Conditionals/ChooseOne"
import { Maybe } from "components/Conditionals/Maybe"
import { TableEmpty } from "components/TableEmpty/TableEmpty"
import { FC } from "react"
import { useTranslation } from "react-i18next"
import { useNavigate } from "react-router-dom"
import { useNavigate, Link as RouterLink } from "react-router-dom"
import { createDayString } from "util/createDayString"
import {
formatTemplateBuildTime,
formatTemplateActiveDevelopers,
} from "util/templates"
import * as TypesGen from "../../api/typesGenerated"
import { AvatarData } from "../../components/AvatarData/AvatarData"
import { CodeExample } from "../../components/CodeExample/CodeExample"
import { Margins } from "../../components/Margins/Margins"
import {
PageHeader,
@ -39,6 +37,9 @@ import {
HelpTooltipText,
HelpTooltipTitle,
} from "../../components/Tooltips/HelpTooltip/HelpTooltip"
import { EmptyTemplates } from "./EmptyTemplates"
import { TemplatesContext } from "xServices/templates/templatesXService"
import { Entitlements } from "api/typesGenerated"
export const Language = {
developerCount: (activeCount: number): string => {
@ -50,21 +51,6 @@ export const Language = {
buildTimeLabel: "Build time",
usedByLabel: "Used by",
lastUpdatedLabel: "Last updated",
emptyViewNoPerms:
"Contact your Coder administrator to create a template. You can share the code below.",
emptyMessage: "Create your first template",
emptyDescription: (
<>
To create a workspace you need to have a template. You can{" "}
<Link
target="_blank"
href="https://coder.com/docs/coder-oss/latest/templates"
>
create one from scratch
</Link>{" "}
or use a built-in template using the following Coder CLI command:
</>
),
templateTooltipTitle: "What is template?",
templateTooltipText:
"With templates you can create a common configuration for your workspaces using Terraform.",
@ -87,41 +73,46 @@ const TemplateHelpTooltip: React.FC = () => {
}
export interface TemplatesPageViewProps {
loading?: boolean
canCreateTemplate?: boolean
templates?: TypesGen.Template[]
getOrganizationsError?: Error | unknown
getTemplatesError?: Error | unknown
context: TemplatesContext
entitlements: Entitlements
}
export const TemplatesPageView: FC<
React.PropsWithChildren<TemplatesPageViewProps>
> = (props) => {
> = ({ context, entitlements }) => {
const styles = useStyles()
const navigate = useNavigate()
const { t } = useTranslation("templatesPage")
const theme: Theme = useTheme()
const empty =
!props.loading &&
!props.getOrganizationsError &&
!props.getTemplatesError &&
!props.templates?.length
const { templates, error, examples, permissions } = context
const isLoading = !templates
const isEmpty = Boolean(templates && templates.length === 0)
return (
<Margins>
<PageHeader>
<PageHeader
actions={
<Maybe
condition={entitlements.experimental && permissions.createTemplates}
>
<Button component={RouterLink} to="/starter-templates">
Starter templates
</Button>
<Button startIcon={<AddIcon />} component={RouterLink} to="new">
Add template
</Button>
</Maybe>
}
>
<PageHeaderTitle>
<Stack spacing={1} direction="row" alignItems="center">
Templates
<TemplateHelpTooltip />
</Stack>
</PageHeaderTitle>
<Maybe
condition={Boolean(props.templates && props.templates.length > 0)}
>
<Maybe condition={Boolean(templates && templates.length > 0)}>
<PageHeaderSubtitle>
Choose a template to create a new workspace
{props.canCreateTemplate ? (
{permissions.createTemplates ? (
<>
, or{" "}
<Link
@ -140,20 +131,10 @@ export const TemplatesPageView: FC<
</PageHeader>
<ChooseOne>
<Cond condition={Boolean(props.getOrganizationsError)}>
<AlertBanner
severity="error"
error={props.getOrganizationsError}
text={t("errors.getOrganizationsError")}
/>
</Cond>
<Cond condition={Boolean(props.getTemplatesError)}>
<AlertBanner
severity="error"
error={props.getTemplatesError}
text={t("errors.getTemplatesError")}
/>
<Cond condition={Boolean(error)}>
<AlertBanner severity="error" error={error} />
</Cond>
<Cond>
<TableContainer>
<Table>
@ -168,30 +149,21 @@ export const TemplatesPageView: FC<
</TableRow>
</TableHead>
<TableBody>
<Maybe condition={Boolean(props.loading)}>
<Maybe condition={isLoading}>
<TableLoader />
</Maybe>
<ChooseOne>
<Cond condition={empty}>
<TableEmpty
className={styles.empty}
message={Language.emptyMessage}
description={
props.canCreateTemplate
? Language.emptyDescription
: Language.emptyViewNoPerms
}
cta={<CodeExample code="coder templates init" />}
image={
<div className={styles.emptyImage}>
<img src="/featured/templates.webp" alt="" />
</div>
}
<Cond condition={isEmpty}>
<EmptyTemplates
permissions={permissions}
examples={examples ?? []}
entitlements={entitlements}
/>
</Cond>
<Cond>
{props.templates?.map((template) => {
{templates?.map((template) => {
const templatePageLink = `/templates/${template.name}`
const hasIcon = template.icon && template.icon !== ""
@ -319,18 +291,4 @@ const useStyles = makeStyles((theme) => ({
width: "100%",
},
},
empty: {
paddingBottom: 0,
},
emptyImage: {
maxWidth: "50%",
height: theme.spacing(40),
overflow: "hidden",
opacity: 0.85,
"& img": {
maxWidth: "100%",
},
},
}))

View File

@ -3,6 +3,7 @@ import { everyOneGroup } from "util/groups"
import * as Types from "../api/types"
import * as TypesGen from "../api/typesGenerated"
import { range } from "lodash"
import { Permissions } from "xServices/auth/authXService"
export const MockTemplateDAUResponse: TypesGen.TemplateDAUsResponse = {
entries: [
@ -1063,3 +1064,36 @@ export const MockTemplateACLEmpty: TypesGen.TemplateACL = {
group: [],
users: [],
}
export const MockTemplateExample: TypesGen.TemplateExample = {
id: "aws-windows",
url: "https://github.com/coder/coder/tree/main/examples/templates/aws-windows",
name: "Develop in an ECS-hosted container",
description: "Get started with Linux development on AWS ECS.",
markdown:
"\n# aws-ecs\n\nThis is a sample template for running a Coder workspace on ECS. It assumes there\nis a pre-existing ECS cluster with EC2-based compute to host the workspace.\n\n## Architecture\n\nThis workspace is built using the following AWS resources:\n\n- Task definition - the container definition, includes the image, command, volume(s)\n- ECS service - manages the task definition\n\n## code-server\n\n`code-server` is installed via the `startup_script` argument in the `coder_agent`\nresource block. The `coder_app` resource is defined to access `code-server` through\nthe dashboard UI over `localhost:13337`.\n",
icon: "/icon/aws.png",
tags: ["aws", "cloud"],
}
export const MockTemplateExample2: TypesGen.TemplateExample = {
id: "aws-linux",
url: "https://github.com/coder/coder/tree/main/examples/templates/aws-linux",
name: "Develop in Linux on AWS EC2",
description: "Get started with Linux development on AWS EC2.",
markdown:
'\n# aws-linux\n\nTo get started, run `coder templates init`. When prompted, select this template.\nFollow the on-screen instructions to proceed.\n\n## Authentication\n\nThis template assumes that coderd is run in an environment that is authenticated\nwith AWS. For example, run `aws configure import` to import credentials on the\nsystem and user running coderd. For other ways to authenticate [consult the\nTerraform docs](https://registry.terraform.io/providers/hashicorp/aws/latest/docs#authentication-and-configuration).\n\n## Required permissions / policy\n\nThe following sample policy allows Coder to create EC2 instances and modify\ninstances provisioned by Coder:\n\n```json\n{\n "Version": "2012-10-17",\n "Statement": [\n {\n "Sid": "VisualEditor0",\n "Effect": "Allow",\n "Action": [\n "ec2:GetDefaultCreditSpecification",\n "ec2:DescribeIamInstanceProfileAssociations",\n "ec2:DescribeTags",\n "ec2:CreateTags",\n "ec2:RunInstances",\n "ec2:DescribeInstanceCreditSpecifications",\n "ec2:DescribeImages",\n "ec2:ModifyDefaultCreditSpecification",\n "ec2:DescribeVolumes"\n ],\n "Resource": "*"\n },\n {\n "Sid": "CoderResources",\n "Effect": "Allow",\n "Action": [\n "ec2:DescribeInstances",\n "ec2:DescribeInstanceAttribute",\n "ec2:UnmonitorInstances",\n "ec2:TerminateInstances",\n "ec2:StartInstances",\n "ec2:StopInstances",\n "ec2:DeleteTags",\n "ec2:MonitorInstances",\n "ec2:CreateTags",\n "ec2:RunInstances",\n "ec2:ModifyInstanceAttribute",\n "ec2:ModifyInstanceCreditSpecification"\n ],\n "Resource": "arn:aws:ec2:*:*:instance/*",\n "Condition": {\n "StringEquals": {\n "aws:ResourceTag/Coder_Provisioned": "true"\n }\n }\n }\n ]\n}\n```\n\n## code-server\n\n`code-server` is installed via the `startup_script` argument in the `coder_agent`\nresource block. The `coder_app` resource is defined to access `code-server` through\nthe dashboard UI over `localhost:13337`.\n',
icon: "/icon/aws.png",
tags: ["aws", "cloud"],
}
export const MockPermissions: Permissions = {
createGroup: true,
createTemplates: true,
createUser: true,
deleteTemplates: true,
readAllUsers: true,
updateUsers: true,
viewAuditLog: true,
viewDeploymentConfig: true,
}

View File

@ -24,6 +24,15 @@ export const handlers = [
rest.get("/api/v2/organizations/:organizationId", async (req, res, ctx) => {
return res(ctx.status(200), ctx.json(M.MockOrganization))
}),
rest.get(
"api/v2/organizations/:organizationId/templates/examples",
(req, res, ctx) => {
return res(
ctx.status(200),
ctx.json([M.MockTemplateExample, M.MockTemplateExample2]),
)
},
),
rest.get(
"/api/v2/organizations/:organizationId/templates/:templateId",
async (req, res, ctx) => {

View File

@ -1,8 +1,4 @@
import {
hasApiFieldErrors,
isApiError,
mapApiErrorToFieldErrors,
} from "api/errors"
import { isApiValidationError, mapApiErrorToFieldErrors } from "api/errors"
import { FormikContextType, FormikErrors, getIn } from "formik"
import {
ChangeEvent,
@ -44,10 +40,11 @@ export const getFormHelpers =
HelperText: ReactNode = "",
backendErrorName?: string,
): FormHelpers => {
const apiValidationErrors =
isApiError(error) && hasApiFieldErrors(error)
const apiValidationErrors = isApiValidationError(error)
? (mapApiErrorToFieldErrors(error.response.data) as FormikErrors<T>)
: error
: // This should not return the error since it is not and api validation error but I didn't have time to fix this and tests
error
if (typeof name !== "string") {
throw new Error(
`name must be type of string, instead received '${typeof name}'`,
@ -62,6 +59,7 @@ export const getFormHelpers =
const apiError = getIn(apiValidationErrors, apiErrorName)
const frontendError = getIn(form.errors, name)
const returnError = apiError ?? frontendError
return {
...form.getFieldProps(name),
id: name,

View File

@ -0,0 +1,24 @@
import { TemplateExample } from "api/typesGenerated"
export type StarterTemplatesByTag = Record<string, TemplateExample[]>
export const getTemplatesByTag = (
templates: TemplateExample[],
): StarterTemplatesByTag => {
const tags: StarterTemplatesByTag = {
all: templates,
}
templates.forEach((template) => {
template.tags.forEach((tag) => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- this can be undefined
if (tags[tag]) {
tags[tag].push(template)
} else {
tags[tag] = [template]
}
})
})
return tags
}

View File

@ -0,0 +1,418 @@
import {
getTemplateExamples,
createTemplateVersion,
getTemplateVersion,
createTemplate,
getTemplateVersionSchema,
uploadTemplateFile,
getTemplateVersionLogs,
} from "api/api"
import {
CreateTemplateVersionRequest,
ParameterSchema,
ProvisionerJobLog,
Template,
TemplateExample,
TemplateVersion,
UploadResponse,
} from "api/typesGenerated"
import { displayError } from "components/GlobalSnackbar/utils"
import { assign, createMachine } from "xstate"
// for creating a new template:
// 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:
// a. prompt for params
// b. create template version again with the same file hash
// c. wait for it to complete
// 5.create template with the successful template version ID
// https://github.com/coder/coder/blob/b6703b11c6578b2f91a310d28b6a7e57f0069be6/cli/templatecreate.go#L169-L170
export interface CreateTemplateData {
name: string
display_name: string
description: string
icon: string
default_ttl_hours: number
allow_user_cancel_workspace_jobs: boolean
parameter_values_by_name?: Record<string, string>
}
interface CreateTemplateContext {
organizationId: string
error?: unknown
jobError?: string
jobLogs?: ProvisionerJobLog[]
starterTemplate?: TemplateExample
exampleId?: string | null // It can be null because it is being passed from query string
version?: TemplateVersion
templateData?: CreateTemplateData
parameters?: ParameterSchema[]
// 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
uploadResponse?: UploadResponse
}
export const createTemplateMachine =
/** @xstate-layout N4IgpgJg5mDOIC5QGMBOYCGAXMAVMAtgA4A22YAdLFhqlgJYB2UAxANoAMAuoqEQPax6Dfo14gAHogCMAVgDMFDgA5ZATgBM86dO3z5agDQgAnoi0B2CmosA2WbNsGOF5WpsBfD8bSYc+YjIcKho6JlY2aR4kEAEhETEYqQQ5RRV1LR09A2MzBGVpClkOEo5pABZlZVsNNWllLx90cgDScgoSfgwIcIBlUJxUVqCwFghRSiYAN34Aa0pfFsI24M7uvoGwIeWRhGn+ZGx6UU4uU-E44WPE0GSaxQsLeXLZaTVntQVc8xKlUo59LZbHUXhZGiBFv4du01j1mP1aINhuQWFtUPxUBQVgAzDEECiQvDQ1ZdOFQBF0LbInB7RgzQ4JU7nGKXBLiO5aCiPZ6vd7lT7yb4IWqKF7aN5ucrSYEcWTgwnUyYQEijADCACUAKIAQVwmuZfEEV1E7MQsg0QulHHKFBqxXcNhqWnk8uaUMC7XoytGAFUAAoAGQA8tqACIAfQAYgBJAP67gXI1spLmV5c57yCxyWzWmwOIXyarWDg1R4lDTKDQaaSuvxEj3BL0qlhagCyQYAapqo7H49FDfFrqaEPIOBobQK9C4LLVZELXjanAZ3Pz3LUwd4IW76ytKABXUik8JjCYUfbzAnbxUUA+w8K0+lHE7cA2xJNDlPCtNPcqZ7O5ix81MRBKkUeR1ClcoSlHKVbFrJYG33Q91mYVFUHRTEcTxS862vW8j2YB8DifRgmQTFl3xNT8q3kDQKG0DQs1eJxZHKec7AoAoNAUWVR1qODNwVYkFjdcIcKOZhI3oVBqA7LYhFEE9GEmOk5hE3DhPEhhmC08IpJkrA5Jk64iIZa4yP7N9Byo24QLkIo7Q4NRlABJ55CcS1rVFMdpVAysnI3JoNMQ3SdMhPTpNk+TrjQjCsSCXFUHxISQvCsLRMkyLDOi0RTJIizE2sm5JHMbi6IYpjpXAtjgIQYErGrZQLBsconm45QXUEq9NLSqAKAAdwwK5JIxAApfgACNcH4AAhMBVX4QIwBwCAlJUmYLxS3dQr6wbhqgSMxsm6a5oWpaVryxkX3IgdjWK5JpDHG0oJqNQnPKCtWMtCorA+t4HEqDqXE6oKEO23qBqG7SDqOqbZvmxbSGWyA1rPVTNu61KMt2qG9Nhk6EfOyBLvMl8okKu7hyrc16OkRi5Cqr7auajhbSzDqK0zJiBNB91wexyH9sO1Bxrh07EZVFbUfPdSwZGHbBeh4XRYJs6kYu-YzOfM4NEs1kP1slInooF7anez6aryao6LUWwmuUSpzXNOR4L5+WIb2pX8fhtXJZRtEMXi7BEuSzH+b8MTPbxkXjp9iXkYgEntdffWbJK4Vx3KunKpYy3ECqG06f5fl3P5QKt2C8OJL6u9mFbehYCEZg-VoDACGRmTpfR2W3faCHa6gevG-CFvUDbjvYCT0jrr1yj7pkQCrE0Zz1A66pbe+2xClsLM6hLB1bblLrK-dgWB6HpuoFH8fBlgWLA6wpKtJ3U+I508+G8v6-29vqeCoooqVMtBZ3psxaqnksxKAYixSsjEeYVzln3AWRB0TECwN-CeLANQ6j1CnOew56hOQoJnKCspXAKE0JaCsyg2ZvFok4R6VRXYvyQW-PqvUjIKUYAAdWEAACwwbfLuG0e4sOCBDDhOUeH8MEfJP+M8KbJkNlKWQDluJORcpmQEgpapZFsJxBwFZdD2HKLYD6zDrwSOxpw64vCsACNbj-eS99MIJWwltV+1cdo2NEHYhxY8nEyXkWcG6VlKafhUWo+0mi3IeV0b+RQbgHYWBcNbAo5cPGsK8b1RUwi1LP0sQLHJwlgl4MAZ+aQi9rC1FUM5QswJbBCiag1eozUpQzgsB9DQFiepFOxrkgOrjg7uLDp46GO1FSlNCaneeGdaK01AYzPOCAbBWFkK4H6spijViPpuRg-AIBwHEJknAiiDbpwALRGFqhc1RB97n3JBgg3uwRqCInCGctOyQPpNP0dKIEKTCw6H5DvHpIUB4UiRMJT5sz3JWEqbbR4NQgStSeEKasdEZz2CcBWFw7gmpgu2k2MAMKqauFZs8VwTUVAFDUC8IUW99EqCzO5YoZQS6EvlvhFCUBSURItLVKCVgSiuFgjvT4spOVZOhnyw2ORmZPGsC8KC7k3gzlkA0Y+iDxF9LYfpKKxk04zIIesig-1RxuBsBoMoQJPKMVtBWO2GrrWfHKOUKVOq2GK2jirOORMICyvTik4V7xOkHzFAKvIOhrXEMqKYt6FQmqsQ9T3MSH9h7N0cRPQND1Kj6JBPUYEtFKzOW+i8TiK8NBOCxSylNCsUGI3QVm2+OafglmsBUKtegTHKEtAxM1jtzSZmXgSrVLzU3pTYT46R9jZEyVbfkCwlooJqC5GyqojhxzZzrVYthioF0MxtHYDqjxXgA10L8wobqFAMwBtUTVvMxETvYduANADwmG2te2kEXbjGsV7bVNwS8xTPCMTYMcXgvBAA */
createMachine(
{
id: "createTemplate",
predictableActionArguments: true,
schema: {
context: {} as CreateTemplateContext,
events: {} as
| { type: "CREATE"; data: CreateTemplateData }
| { type: "UPLOAD_FILE"; file: File }
| { type: "REMOVE_FILE" },
services: {} as {
uploadFile: {
data: UploadResponse
}
loadStarterTemplate: {
data: TemplateExample
}
createFirstVersion: {
data: TemplateVersion
}
createVersionWithParameters: {
data: TemplateVersion
}
waitForJobToBeCompleted: {
data: TemplateVersion
}
loadParameterSchema: {
data: ParameterSchema[]
}
createTemplate: {
data: Template
}
loadVersionLogs: {
data: ProvisionerJobLog[]
}
},
},
tsTypes: {} as import("./createTemplateXService.typegen").Typegen0,
initial: "starting",
states: {
starting: {
always: [
{ target: "loadingStarterTemplate", cond: "isExampleProvided" },
{ target: "idle" },
],
tags: ["loading"],
},
loadingStarterTemplate: {
invoke: {
src: "loadStarterTemplate",
onDone: {
target: "idle",
actions: ["assignStarterTemplate"],
},
onError: {
target: "idle",
actions: ["assignError"],
},
},
tags: ["loading"],
},
idle: {
on: {
CREATE: {
target: "creating",
actions: ["assignTemplateData"],
},
UPLOAD_FILE: {
actions: ["assignFile"],
target: "uploading",
cond: "isNotUsingExample",
},
REMOVE_FILE: {
actions: ["removeFile"],
cond: "hasFile",
},
},
},
uploading: {
invoke: {
src: "uploadFile",
onDone: {
target: "idle",
actions: ["assignUploadResponse"],
},
onError: {
target: "idle",
actions: ["displayUploadError", "removeFile"],
},
},
},
creating: {
initial: "creatingFirstVersion",
states: {
creatingFirstVersion: {
invoke: {
src: "createFirstVersion",
onDone: {
target: "waitingForJobToBeCompleted",
actions: ["assignVersion"],
},
onError: {
actions: ["assignError"],
target: "#createTemplate.idle",
},
},
tags: ["submitting"],
},
waitingForJobToBeCompleted: {
invoke: {
src: "waitForJobToBeCompleted",
onDone: [
{
target: "loadingMissingParameters",
cond: "hasMissingParameters",
actions: ["assignVersion"],
},
{
target: "loadingVersionLogs",
actions: ["assignJobError", "assignVersion"],
cond: "hasFailed",
},
{ target: "creatingTemplate", actions: ["assignVersion"] },
],
onError: {
target: "#createTemplate.idle",
actions: ["assignError"],
},
},
tags: ["submitting"],
},
loadingVersionLogs: {
invoke: {
src: "loadVersionLogs",
onDone: {
target: "#createTemplate.idle",
actions: ["assignJobLogs"],
},
onError: {
target: "#createTemplate.idle",
actions: ["assignError"],
},
},
},
loadingMissingParameters: {
invoke: {
src: "loadParameterSchema",
onDone: {
target: "promptParameters",
actions: ["assignParameters"],
},
onError: {
target: "#createTemplate.idle",
actions: ["assignError"],
},
},
tags: ["submitting"],
},
promptParameters: {
on: {
CREATE: {
target: "creatingVersionWithParameters",
actions: ["assignTemplateData"],
},
},
},
creatingVersionWithParameters: {
invoke: {
src: "createVersionWithParameters",
onDone: {
target: "waitingForJobToBeCompleted",
actions: ["assignVersion"],
},
onError: {
actions: ["assignError"],
target: "promptParameters",
},
},
tags: ["submitting"],
},
creatingTemplate: {
invoke: {
src: "createTemplate",
onDone: {
target: "created",
actions: ["onCreate"],
},
onError: {
actions: ["assignError"],
target: "#createTemplate.idle",
},
},
tags: ["submitting"],
},
created: {
type: "final",
},
},
},
},
},
{
services: {
uploadFile: (_, { file }) => uploadTemplateFile(file),
loadStarterTemplate: async ({ organizationId, exampleId }) => {
if (!exampleId) {
throw new Error(`Example ID is not defined.`)
}
const examples = await getTemplateExamples(organizationId)
const starterTemplate = examples.find(
(example) => example.id === exampleId,
)
if (!starterTemplate) {
throw new Error(`Example ${exampleId} not found.`)
}
return starterTemplate
},
createFirstVersion: async ({
organizationId,
exampleId,
uploadResponse,
}) => {
if (exampleId) {
return createTemplateVersion(organizationId, {
storage_method: "file",
example_id: exampleId,
provisioner: "terraform",
tags: {},
})
}
if (uploadResponse) {
return createTemplateVersion(organizationId, {
storage_method: "file",
file_id: uploadResponse.hash,
provisioner: "terraform",
tags: {},
})
}
throw new Error("No file or example provided")
},
createVersionWithParameters: async ({
organizationId,
parameters,
templateData,
version,
}) => {
if (!version) {
throw new Error("No previous version found")
}
if (!templateData) {
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) {
parameters.forEach((schema) => {
const value = parameter_values_by_name?.[schema.name]
parameterValues.push({
name: schema.name,
source_value: value ?? schema.default_source_value,
destination_scheme: schema.default_destination_scheme,
source_scheme: "data",
})
})
}
return createTemplateVersion(organizationId, {
storage_method: "file",
file_id: version.job.file_id,
provisioner: "terraform",
parameter_values: parameterValues,
tags: {},
})
},
waitForJobToBeCompleted: async ({ version }) => {
if (!version) {
throw new Error("Version not defined")
}
let status = version.job.status
while (["pending", "running"].includes(status)) {
version = await getTemplateVersion(version.id)
status = version.job.status
}
return version
},
loadParameterSchema: async ({ version }) => {
if (!version) {
throw new Error("Version not defined")
}
return getTemplateVersionSchema(version.id)
},
createTemplate: async ({ organizationId, version, templateData }) => {
if (!version) {
throw new Error("Version not defined")
}
if (!templateData) {
throw new Error("Template data not defined")
}
const {
default_ttl_hours,
parameter_values_by_name,
...safeTemplateData
} = templateData
return createTemplate(organizationId, {
...safeTemplateData,
default_ttl_ms: templateData.default_ttl_hours * 60 * 60 * 1000, // Convert hours to ms
template_version_id: version.id,
})
},
loadVersionLogs: ({ version }) => {
if (!version) {
throw new Error("Version is not set")
}
return getTemplateVersionLogs(version.id)
},
},
actions: {
assignError: assign({ error: (_, { data }) => data }),
assignJobError: assign({ jobError: (_, { data }) => data.job.error }),
displayUploadError: () => {
displayError("Error on upload the file.")
},
assignStarterTemplate: assign({
starterTemplate: (_, { data }) => data,
}),
assignVersion: assign({ version: (_, { data }) => data }),
assignTemplateData: assign({ templateData: (_, { data }) => data }),
assignParameters: assign({ parameters: (_, { data }) => data }),
assignFile: assign({ file: (_, { file }) => file }),
assignUploadResponse: assign({ uploadResponse: (_, { data }) => data }),
removeFile: assign({
file: (_) => undefined,
uploadResponse: (_) => undefined,
}),
assignJobLogs: assign({ jobLogs: (_, { data }) => data }),
},
guards: {
isExampleProvided: ({ exampleId }) => Boolean(exampleId),
isNotUsingExample: ({ exampleId }) => !exampleId,
hasFile: ({ file }) => Boolean(file),
hasFailed: (_, { data }) => data.job.status === "failed",
hasMissingParameters: (_, { data }) =>
Boolean(
data.job.error && data.job.error.includes("missing parameter"),
),
},
},
)

View File

@ -0,0 +1,71 @@
import { getTemplateExamples } from "api/api"
import { TemplateExample } from "api/typesGenerated"
import { assign, createMachine } from "xstate"
export interface StarterTemplateContext {
organizationId: string
exampleId: string
starterTemplate?: TemplateExample
error?: unknown
}
export const starterTemplateMachine = createMachine(
{
id: "starterTemplate",
predictableActionArguments: true,
schema: {
context: {} as StarterTemplateContext,
services: {} as {
loadStarterTemplate: {
data: TemplateExample
}
},
},
tsTypes: {} as import("./starterTemplateXService.typegen").Typegen0,
initial: "loading",
states: {
loading: {
invoke: {
src: "loadStarterTemplate",
onDone: {
actions: ["assignStarterTemplate"],
target: "idle.ok",
},
onError: {
actions: ["assignError"],
target: "idle.error",
},
},
},
idle: {
initial: "ok",
states: {
ok: { type: "final" },
error: { type: "final" },
},
},
},
},
{
services: {
loadStarterTemplate: async ({ organizationId, exampleId }) => {
const examples = await getTemplateExamples(organizationId)
const starterTemplate = examples.find(
(example) => example.id === exampleId,
)
if (!starterTemplate) {
throw new Error(`Example ${exampleId} not found.`)
}
return starterTemplate
},
},
actions: {
assignError: assign({
error: (_, { data }) => data,
}),
assignStarterTemplate: assign({
starterTemplate: (_, { data }) => data,
}),
},
},
)

View File

@ -0,0 +1,63 @@
import { getTemplateExamples } from "api/api"
import { TemplateExample } from "api/typesGenerated"
import { getTemplatesByTag, StarterTemplatesByTag } from "util/starterTemplates"
import { assign, createMachine } from "xstate"
export interface StarterTemplatesContext {
organizationId: string
starterTemplatesByTag?: StarterTemplatesByTag
error?: unknown
}
export const starterTemplatesMachine = createMachine(
{
id: "starterTemplates",
predictableActionArguments: true,
schema: {
context: {} as StarterTemplatesContext,
services: {} as {
loadStarterTemplates: {
data: TemplateExample[]
}
},
},
tsTypes: {} as import("./starterTemplatesXService.typegen").Typegen0,
initial: "loading",
states: {
loading: {
invoke: {
src: "loadStarterTemplates",
onDone: {
actions: ["assignStarterTemplates"],
target: "idle.ok",
},
onError: {
actions: ["assignError"],
target: "idle.error",
},
},
},
idle: {
initial: "ok",
states: {
ok: { type: "final" },
error: { type: "final" },
},
},
},
},
{
services: {
loadStarterTemplates: ({ organizationId }) =>
getTemplateExamples(organizationId),
},
actions: {
assignError: assign({
error: (_, { data }) => data,
}),
assignStarterTemplates: assign({
starterTemplatesByTag: (_, { data }) => getTemplatesByTag(data),
}),
},
},
)

View File

@ -1,13 +1,14 @@
import { Permissions } from "xServices/auth/authXService"
import { assign, createMachine } from "xstate"
import * as API from "../../api/api"
import * as TypesGen from "../../api/typesGenerated"
interface TemplatesContext {
organizations?: TypesGen.Organization[]
export interface TemplatesContext {
organizationId: string
permissions: Permissions
templates?: TypesGen.Template[]
canCreateTemplate?: boolean
getOrganizationsError?: Error | unknown
getTemplatesError?: Error | unknown
examples?: TypesGen.TemplateExample[]
error?: Error | unknown
}
export const templatesMachine = createMachine(
@ -18,80 +19,58 @@ export const templatesMachine = createMachine(
schema: {
context: {} as TemplatesContext,
services: {} as {
getOrganizations: {
data: TypesGen.Organization[]
load: {
data: {
templates: TypesGen.Template[]
examples: TypesGen.TemplateExample[]
}
getTemplates: {
data: TypesGen.Template[]
}
},
},
initial: "gettingOrganizations",
initial: "loading",
states: {
gettingOrganizations: {
entry: "clearGetOrganizationsError",
loading: {
invoke: {
src: "getOrganizations",
id: "getOrganizations",
src: "load",
id: "load",
onDone: {
actions: ["assignOrganizations"],
target: "gettingTemplates",
actions: ["assignData"],
target: "idle",
},
onError: {
actions: "assignGetOrganizationsError",
target: "error",
actions: "assignError",
target: "idle",
},
},
tags: "loading",
},
gettingTemplates: {
entry: "clearGetTemplatesError",
invoke: {
src: "getTemplates",
id: "getTemplates",
onDone: {
actions: "assignTemplates",
target: "done",
idle: {
type: "final",
},
onError: {
actions: "assignGetTemplatesError",
target: "error",
},
},
tags: "loading",
},
done: {},
error: {},
},
},
{
actions: {
assignOrganizations: assign({
organizations: (_, event) => event.data,
assignData: assign({
templates: (_, event) => event.data.templates,
examples: (_, event) => event.data.examples,
}),
assignGetOrganizationsError: assign({
getOrganizationsError: (_, event) => event.data,
assignError: assign({
error: (_, { data }) => data,
}),
clearGetOrganizationsError: assign((context) => ({
...context,
getOrganizationsError: undefined,
})),
assignTemplates: assign({
templates: (_, event) => event.data,
}),
assignGetTemplatesError: assign({
getTemplatesError: (_, event) => event.data,
}),
clearGetTemplatesError: (context) =>
assign({ ...context, getTemplatesError: undefined }),
},
services: {
getOrganizations: API.getOrganizations,
getTemplates: async (context) => {
if (!context.organizations || context.organizations.length === 0) {
throw new Error("no organizations")
load: async ({ organizationId, permissions }) => {
const [templates, examples] = await Promise.all([
API.getTemplates(organizationId),
permissions.createTemplates
? API.getTemplateExamples(organizationId)
: Promise.resolve([]),
])
return {
templates,
examples,
}
return API.getTemplates(context.organizations[0].id)
},
},
},

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB