From c505e8b20782ec923a701691f4448885ffff8eb5 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Wed, 21 Dec 2022 18:07:00 -0300 Subject: [PATCH] feat: Add create template from the UI (#5427) --- site/src/AppRouter.tsx | 38 ++ site/src/api/api.ts | 60 +++ site/src/api/errors.ts | 4 + site/src/components/IconField/IconField.tsx | 113 +++++ site/src/components/Logs/Logs.tsx | 36 +- site/src/components/Markdown/Markdown.tsx | 13 +- .../TemplateExampleCard.tsx | 91 ++++ .../WorkspaceBuildLogs/WorkspaceBuildLogs.tsx | 12 +- site/src/hooks/useEntitlements.ts | 14 + site/src/i18n/en/createTemplatePage.json | 44 ++ site/src/i18n/en/index.ts | 6 + site/src/i18n/en/starterTemplatePage.json | 6 + site/src/i18n/en/starterTemplatesPage.json | 11 + site/src/i18n/en/templatesPage.json | 4 + .../CreateTemplatePage/CreateTemplateForm.tsx | 403 +++++++++++++++++ .../CreateTemplatePage/CreateTemplatePage.tsx | 90 ++++ .../CreateTemplatePage/TemplateUpload.tsx | 185 ++++++++ .../CreateWorkspacePage/SelectedTemplate.tsx | 6 +- .../StarterTemplatePage.test.tsx | 20 + .../StarterTemplatePage.tsx | 33 ++ .../StarterTemplatePageView.stories.tsx | 41 ++ .../StarterTemplatePageView.tsx | 115 +++++ .../StarterTemplatesPage.test.tsx | 20 + .../StarterTemplatesPage.tsx | 28 ++ .../StarterTemplatesPageView.stories.tsx | 44 ++ .../StarterTemplatesPageView.tsx | 134 ++++++ .../pages/TemplatesPage/EmptyTemplates.tsx | 161 +++++++ .../TemplatesPage/TemplatesPage.test.tsx | 33 +- .../src/pages/TemplatesPage/TemplatesPage.tsx | 29 +- .../TemplatesPageView.stories.tsx | 124 ++++-- .../pages/TemplatesPage/TemplatesPageView.tsx | 120 ++--- site/src/testHelpers/entities.ts | 34 ++ site/src/testHelpers/handlers.ts | 9 + site/src/util/formUtils.ts | 16 +- site/src/util/starterTemplates.ts | 24 + .../createTemplate/createTemplateXService.ts | 418 ++++++++++++++++++ .../starterTemplateXService.ts | 71 +++ .../starterTemplatesXService.ts | 63 +++ .../xServices/templates/templatesXService.ts | 95 ++-- site/static/icon/aws.png | Bin 1828 -> 4283 bytes site/static/icon/azure.png | Bin 1375 -> 2753 bytes site/static/icon/do.png | Bin 1032 -> 2009 bytes site/static/icon/docker.png | Bin 2491 -> 5864 bytes site/static/icon/gcp.png | Bin 1887 -> 4105 bytes site/static/icon/k8s.png | Bin 2451 -> 5274 bytes 45 files changed, 2540 insertions(+), 228 deletions(-) create mode 100644 site/src/components/IconField/IconField.tsx create mode 100644 site/src/components/TemplateExampleCard/TemplateExampleCard.tsx create mode 100644 site/src/hooks/useEntitlements.ts create mode 100644 site/src/i18n/en/createTemplatePage.json create mode 100644 site/src/i18n/en/starterTemplatePage.json create mode 100644 site/src/i18n/en/starterTemplatesPage.json create mode 100644 site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx create mode 100644 site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx create mode 100644 site/src/pages/CreateTemplatePage/TemplateUpload.tsx create mode 100644 site/src/pages/StarterTemplatePage/StarterTemplatePage.test.tsx create mode 100644 site/src/pages/StarterTemplatePage/StarterTemplatePage.tsx create mode 100644 site/src/pages/StarterTemplatePage/StarterTemplatePageView.stories.tsx create mode 100644 site/src/pages/StarterTemplatePage/StarterTemplatePageView.tsx create mode 100644 site/src/pages/StarterTemplatesPage/StarterTemplatesPage.test.tsx create mode 100644 site/src/pages/StarterTemplatesPage/StarterTemplatesPage.tsx create mode 100644 site/src/pages/StarterTemplatesPage/StarterTemplatesPageView.stories.tsx create mode 100644 site/src/pages/StarterTemplatesPage/StarterTemplatesPageView.tsx create mode 100644 site/src/pages/TemplatesPage/EmptyTemplates.tsx create mode 100644 site/src/util/starterTemplates.ts create mode 100644 site/src/xServices/createTemplate/createTemplateXService.ts create mode 100644 site/src/xServices/starterTemplates/starterTemplateXService.ts create mode 100644 site/src/xServices/starterTemplates/starterTemplatesXService.ts diff --git a/site/src/AppRouter.tsx b/site/src/AppRouter.tsx index 569b0241b5..ff59edb492 100644 --- a/site/src/AppRouter.tsx +++ b/site/src/AppRouter.tsx @@ -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 = () => { } /> + + + + + } + /> + + + + + } + > + + { } /> + + + + } + /> + => { + const response = await axios.post( + `/api/v2/organizations/${organizationId}/templateversions`, + data, + ) + return response.data +} + +export const getTemplateVersionParameters = async ( + versionId: string, +): Promise => { + const response = await axios.get( + `/api/v2/templateversions/${versionId}/parameters`, + ) + return response.data +} + +export const createTemplate = async ( + organizationId: string, + data: TypesGen.CreateTemplateRequest, +): Promise => { + 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 => { + const response = await axios.get( + `/api/v2/organizations/${organizationId}/templates/examples`, + ) + return response.data +} + +export const uploadTemplateFile = async ( + file: File, +): Promise => { + 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 => { + const response = await axios.get( + `/api/v2/templateversions/${versionId}/logs`, + ) + return response.data +} diff --git a/site/src/api/errors.ts b/site/src/api/errors.ts index 1663e0333d..496bf493e9 100644 --- a/site/src/api/errors.ts +++ b/site/src/api/errors.ts @@ -63,6 +63,10 @@ export const mapApiErrorToFieldErrors = ( return result } +export const isApiValidationError = (error: unknown): error is ApiError => { + return isApiError(error) && hasApiFieldErrors(error) +} + /** * * @param error diff --git a/site/src/components/IconField/IconField.tsx b/site/src/components/IconField/IconField.tsx new file mode 100644 index 0000000000..5646574345 --- /dev/null +++ b/site/src/components/IconField/IconField.tsx @@ -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(null) + const [isEmojiPickerOpen, setIsEmojiPickerOpen] = useState(false) + const { t } = useTranslation("templateSettingsPage") + const hasIcon = textFieldProps.value && textFieldProps.value !== "" + + return ( +
+ + (e.currentTarget.style.display = "none")} + onLoad={(e) => (e.currentTarget.style.display = "inline")} + /> + + ) : undefined, + }} + /> + + + + { + setIsEmojiPickerOpen(false) + }} + > + { + // See: https://github.com/missive/emoji-mart/issues/51#issuecomment-287353222 + const value = `/emojis/${emojiData.unified.replace( + /-fe0f$/, + "", + )}.png` + onPickEmoji(value) + setIsEmojiPickerOpen(false) + }} + /> + +
+ ) +} + +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), + }, +})) diff --git a/site/src/components/Logs/Logs.tsx b/site/src/components/Logs/Logs.tsx index 78b667654b..6b70dd552c 100644 --- a/site/src/components/Logs/Logs.tsx +++ b/site/src/components/Logs/Logs.tsx @@ -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,15 +24,17 @@ export const Logs: FC> = ({ return (
- {lines.map((line, idx) => ( -
- - {dayjs(line.time).format(`HH:mm:ss.SSS`)} - -      - {line.output} -
- ))} +
+ {lines.map((line, idx) => ( +
+ + {dayjs(line.time).format(`HH:mm:ss.SSS`)} + +      + {line.output} +
+ ))} +
) } @@ -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", diff --git a/site/src/components/Markdown/Markdown.tsx b/site/src/components/Markdown/Markdown.tsx index 639d025706..8becdb8cba 100644 --- a/site/src/components/Markdown/Markdown.tsx +++ b/site/src/components/Markdown/Markdown.tsx @@ -48,14 +48,14 @@ export const Markdown: FC<{ children: string }> = ({ children }) => { - {String(children).replace(/\n$/, "")} + {String(children)} ) : ( @@ -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, diff --git a/site/src/components/TemplateExampleCard/TemplateExampleCard.tsx b/site/src/components/TemplateExampleCard/TemplateExampleCard.tsx new file mode 100644 index 0000000000..042e9904a3 --- /dev/null +++ b/site/src/components/TemplateExampleCard/TemplateExampleCard.tsx @@ -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 = ({ + example, + className, +}) => { + const styles = useStyles() + + return ( + +
+ +
+
+ {example.name} + + {example.description} + +
+ + ) +} + +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", + }, +})) diff --git a/site/src/components/WorkspaceBuildLogs/WorkspaceBuildLogs.tsx b/site/src/components/WorkspaceBuildLogs/WorkspaceBuildLogs.tsx index edf9aaeed5..ea857defc7 100644 --- a/site/src/components/WorkspaceBuildLogs/WorkspaceBuildLogs.tsx +++ b/site/src/components/WorkspaceBuildLogs/WorkspaceBuildLogs.tsx @@ -54,6 +54,7 @@ export const WorkspaceBuildLogs: FC = ({ 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 = ({ logs }) => { )} - {!isEmpty && } + {!isEmpty && } ) })} @@ -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), - }, })) diff --git a/site/src/hooks/useEntitlements.ts b/site/src/hooks/useEntitlements.ts new file mode 100644 index 0000000000..96780e2a0a --- /dev/null +++ b/site/src/hooks/useEntitlements.ts @@ -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 +} diff --git a/site/src/i18n/en/createTemplatePage.json b/site/src/i18n/en/createTemplatePage.json new file mode 100644 index 0000000000..e31df28043 --- /dev/null +++ b/site/src/i18n/en/createTemplatePage.json @@ -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." + } + } +} diff --git a/site/src/i18n/en/index.ts b/site/src/i18n/en/index.ts index 3be9daf125..9b38f40976 100644 --- a/site/src/i18n/en/index.ts +++ b/site/src/i18n/en/index.ts @@ -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, } diff --git a/site/src/i18n/en/starterTemplatePage.json b/site/src/i18n/en/starterTemplatePage.json new file mode 100644 index 0000000000..b1a5a89439 --- /dev/null +++ b/site/src/i18n/en/starterTemplatePage.json @@ -0,0 +1,6 @@ +{ + "actions": { + "viewSourceCode": "View source code", + "useTemplate": "Use template" + } +} diff --git a/site/src/i18n/en/starterTemplatesPage.json b/site/src/i18n/en/starterTemplatesPage.json new file mode 100644 index 0000000000..f9abcbae94 --- /dev/null +++ b/site/src/i18n/en/starterTemplatesPage.json @@ -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" + } +} diff --git a/site/src/i18n/en/templatesPage.json b/site/src/i18n/en/templatesPage.json index 34d56b2788..44e14723ec 100644 --- a/site/src/i18n/en/templatesPage.json +++ b/site/src/i18n/en/templatesPage.json @@ -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." } } diff --git a/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx b/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx new file mode 100644 index 0000000000..0af1e1bf8a --- /dev/null +++ b/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx @@ -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 = ({ + starterTemplate, + error, + parameters, + isSubmitting, + onCancel, + onSubmit, + upload, + jobError, + logs, +}) => { + const styles = useStyles() + const formFooterStyles = useFormFooterStyles() + const form = useFormik({ + initialValues: getInitialValues(starterTemplate), + validationSchema, + onSubmit, + }) + const getFieldHelpers = getFormHelpers(form, error) + const { t } = useTranslation("createTemplatePage") + + return ( +
+ + {/* General info */} +
+
+

+ {t("form.generalInfo.title")} +

+

+ {t("form.generalInfo.description")} +

+
+ + + {starterTemplate ? ( + + ) : ( + + )} + + + +
+ + {/* Display info */} +
+
+

+ {t("form.displayInfo.title")} +

+

+ {t("form.displayInfo.description")} +

+
+ + + + + + + form.setFieldValue("icon", value)} + /> + +
+ + {/* Schedule */} +
+
+

+ {t("form.schedule.title")} +

+

+ {t("form.schedule.description")} +

+
+ + + + +
+ + {/* Operations */} +
+
+

+ {t("form.operations.title")} +

+

+ {t("form.operations.description")} +

+
+ + + + +
+ + {/* Parameters */} + {parameters && ( +
+
+

+ {t("form.parameters.title")} +

+

+ {t("form.parameters.description")} +

+
+ + + {parameters.map((schema) => ( + { + await form.setFieldValue( + `parameter_values_by_name.${schema.name}`, + value, + ) + }} + /> + ))} + +
+ )} + + {jobError && ( + +
+
Error during provisioning
+

+ Looks like we found an error during the template provisioning. + You can see the logs bellow. +

+ + {jobError} +
+ + +
+ )} + + +
+
+ ) +} + +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), + }, + }, +})) diff --git a/site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx b/site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx new file mode 100644 index 0000000000..66adcca70f --- /dev/null +++ b/site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx @@ -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 ( + <> + + {pageTitle(t("title"))} + + + + + + + + + + + + + {shouldDisplayForm && ( + { + 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} + /> + )} + + + + ) +} + +export default CreateTemplatePage diff --git a/site/src/pages/CreateTemplatePage/TemplateUpload.tsx b/site/src/pages/CreateTemplatePage/TemplateUpload.tsx new file mode 100644 index 0000000000..6aa121db68 --- /dev/null +++ b/site/src/pages/CreateTemplatePage/TemplateUpload.tsx @@ -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) => void + onDrop: (e: DragEvent) => void +} => { + const onDragOver = (e: DragEvent) => { + e.preventDefault() + } + + const onDrop = (e: DragEvent) => { + 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 = ({ + isUploading, + onUpload, + onRemove, + file, +}) => { + const styles = useStyles() + const inputRef = useRef(null) + const tarDrop = useTarDrop(onUpload) + const clickable = useClickable(() => { + if (inputRef.current) { + inputRef.current.click() + } + }) + const { t } = useTranslation("createTemplatePage") + + if (!isUploading && file) { + return ( + + + + {file.name} + + + + + + + ) + } + + return ( + <> +
+ + {isUploading ? ( + + ) : ( + + )} + + + {t("form.upload.title")} + + The template has to be a .tar file. You can also use our{" "} + { + e.stopPropagation() + }} + > + starter templates + {" "} + to getting started with Coder. + + + +
+ + { + 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, + }, +})) diff --git a/site/src/pages/CreateWorkspacePage/SelectedTemplate.tsx b/site/src/pages/CreateWorkspacePage/SelectedTemplate.tsx index 0dcb577cd7..295cb34d5e 100644 --- a/site/src/pages/CreateWorkspacePage/SelectedTemplate.tsx +++ b/site/src/pages/CreateWorkspacePage/SelectedTemplate.tsx @@ -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 = ({ template }) => { @@ -28,7 +28,7 @@ export const SelectedTemplate: FC = ({ template }) => { - {template.display_name.length > 0 + {"display_name" in template && template.display_name.length > 0 ? template.display_name : template.name} diff --git a/site/src/pages/StarterTemplatePage/StarterTemplatePage.test.tsx b/site/src/pages/StarterTemplatePage/StarterTemplatePage.test.tsx new file mode 100644 index 0000000000..323164440d --- /dev/null +++ b/site/src/pages/StarterTemplatePage/StarterTemplatePage.test.tsx @@ -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(, { + route: `/starter-templates/${MockTemplateExample.id}`, + path: "/starter-templates/:exampleId", + }) + await waitForLoaderToBeRemoved() + expect(screen.getByText(MockTemplateExample.name)).toBeInTheDocument() + }) +}) diff --git a/site/src/pages/StarterTemplatePage/StarterTemplatePage.tsx b/site/src/pages/StarterTemplatePage/StarterTemplatePage.tsx new file mode 100644 index 0000000000..b0ec53fa3a --- /dev/null +++ b/site/src/pages/StarterTemplatePage/StarterTemplatePage.tsx @@ -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 ( + <> + + + {pageTitle(state.context.starterTemplate?.name ?? exampleId)} + + + + + + ) +} + +export default StarterTemplatePage diff --git a/site/src/pages/StarterTemplatePage/StarterTemplatePageView.stories.tsx b/site/src/pages/StarterTemplatePage/StarterTemplatePageView.stories.tsx new file mode 100644 index 0000000000..fc23097efb --- /dev/null +++ b/site/src/pages/StarterTemplatePage/StarterTemplatePageView.stories.tsx @@ -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 = (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, + }, +} diff --git a/site/src/pages/StarterTemplatePage/StarterTemplatePageView.tsx b/site/src/pages/StarterTemplatePage/StarterTemplatePageView.tsx new file mode 100644 index 0000000000..3264e5ce12 --- /dev/null +++ b/site/src/pages/StarterTemplatePage/StarterTemplatePageView.tsx @@ -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 = ({ + context, +}) => { + const styles = useStyles() + const { starterTemplate } = context + const { t } = useTranslation("starterTemplatePage") + + if (context.error) { + return ( + + + + ) + } + + if (!starterTemplate) { + return + } + + return ( + + + + + + } + > + +
+ +
+
+ {starterTemplate.name} + + {starterTemplate.description} + +
+
+
+ +
+
+ {starterTemplate.markdown} +
+
+
+ ) +} + +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", + }, + } +}) diff --git a/site/src/pages/StarterTemplatesPage/StarterTemplatesPage.test.tsx b/site/src/pages/StarterTemplatesPage/StarterTemplatesPage.test.tsx new file mode 100644 index 0000000000..63d5ed7ce1 --- /dev/null +++ b/site/src/pages/StarterTemplatesPage/StarterTemplatesPage.test.tsx @@ -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(, { + route: `/starter-templates`, + path: "/starter-templates", + }) + await waitForLoaderToBeRemoved() + expect(screen.getByText(MockTemplateExample.name)).toBeInTheDocument() + expect(screen.getByText(MockTemplateExample2.name)).toBeInTheDocument() + }) +}) diff --git a/site/src/pages/StarterTemplatesPage/StarterTemplatesPage.tsx b/site/src/pages/StarterTemplatesPage/StarterTemplatesPage.tsx new file mode 100644 index 0000000000..1c0937d707 --- /dev/null +++ b/site/src/pages/StarterTemplatesPage/StarterTemplatesPage.tsx @@ -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 ( + <> + + {pageTitle(t("title"))} + + + + + ) +} + +export default StarterTemplatesPage diff --git a/site/src/pages/StarterTemplatesPage/StarterTemplatesPageView.stories.tsx b/site/src/pages/StarterTemplatesPage/StarterTemplatesPageView.stories.tsx new file mode 100644 index 0000000000..bd2e3c06e7 --- /dev/null +++ b/site/src/pages/StarterTemplatesPage/StarterTemplatesPageView.stories.tsx @@ -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 = (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, + }, +} diff --git a/site/src/pages/StarterTemplatesPage/StarterTemplatesPageView.tsx b/site/src/pages/StarterTemplatesPage/StarterTemplatesPageView.tsx new file mode 100644 index 0000000000..40d4a58448 --- /dev/null +++ b/site/src/pages/StarterTemplatesPage/StarterTemplatesPageView.tsx @@ -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 = { + 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 = ({ + 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 ( + + + {t("title")} + {t("subtitle")} + + + + + + + + + + + + {starterTemplatesByTag && tags && ( + + {t("filterCaption")} + {tags.map((tag) => ( + + {getTagLabel(tag, t)} ({starterTemplatesByTag[tag].length}) + + ))} + + )} + +
+ {visibleTemplates && + visibleTemplates.map((example) => ( + + ))} +
+
+
+ ) +} + +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", + }, +})) diff --git a/site/src/pages/TemplatesPage/EmptyTemplates.tsx b/site/src/pages/TemplatesPage/EmptyTemplates.tsx new file mode 100644 index 0000000000..f21d5b0d37 --- /dev/null +++ b/site/src/pages/TemplatesPage/EmptyTemplates.tsx @@ -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 ( + + You can create a template using our starter templates or{" "} + + uploading a template + + . You can also{" "} + + use the CLI + + . + + } + cta={ + +
+ {featuredExamples.map((example) => ( + + ))} +
+ + +
+ } + /> + ) + } + + if (permissions.createTemplates) { + return ( + + To create a workspace you need to have a template. You can{" "} + + create one from scratch + {" "} + or use a built-in template using the following Coder CLI command: + + } + cta={} + image={ +
+ +
+ } + /> + ) + } + + return ( + } + image={ +
+ +
+ } + /> + ) +} + +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, + }, +})) diff --git a/site/src/pages/TemplatesPage/TemplatesPage.test.tsx b/site/src/pages/TemplatesPage/TemplatesPage.test.tsx index f504c4209a..b424ebb550 100644 --- a/site/src/pages/TemplatesPage/TemplatesPage.test.tsx +++ b/site/src/pages/TemplatesPage/TemplatesPage.test.tsx @@ -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() + renderWithAuth(, { + 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() + renderWithAuth(, { + route: `/templates`, + path: "/templates", + }) // Then await screen.findByText(MockTemplate.display_name) @@ -68,9 +78,14 @@ describe("TemplatesPage", () => { ) // When - render() - + renderWithAuth(, { + route: `/templates`, + path: "/templates", + }) // Then - await screen.findByText(Language.emptyViewNoPerms) + const emptyMessage = t("empty.descriptionWithoutPermissions", { + ns: "templatesPage", + }) + await screen.findByText(emptyMessage) }) }) diff --git a/site/src/pages/TemplatesPage/TemplatesPage.tsx b/site/src/pages/TemplatesPage/TemplatesPage.tsx index 970da10d79..8c58ba64d2 100644 --- a/site/src/pages/TemplatesPage/TemplatesPage.tsx +++ b/site/src/pages/TemplatesPage/TemplatesPage.tsx @@ -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 = () => { {pageTitle("Templates")} ) diff --git a/site/src/pages/TemplatesPage/TemplatesPageView.stories.tsx b/site/src/pages/TemplatesPage/TemplatesPageView.stories.tsx index c150cbb902..abb84f5b03 100644 --- a/site/src/pages/TemplatesPage/TemplatesPageView.stories.tsx +++ b/site/src/pages/TemplatesPage/TemplatesPageView.stories.tsx @@ -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,50 +19,100 @@ const Template: Story = (args) => ( ) -export const AllStates = Template.bind({}) -AllStates.args = { - canCreateTemplate: true, - templates: [ - MockTemplate, - { - ...MockTemplate, - active_user_count: -1, - description: "🚀 Some new template that has no activity data", - icon: "/icon/goland.svg", - }, - { - ...MockTemplate, - active_user_count: 150, - description: "😮 Wow, this one has a bunch of usage!", - icon: "", - }, - { - ...MockTemplate, - description: - "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. ", - }, - ], +export const WithTemplates = Template.bind({}) +WithTemplates.args = { + entitlements: MockEntitlements, + context: { + organizationId: MockOrganization.id, + permissions: MockPermissions, + error: undefined, + templates: [ + MockTemplate, + { + ...MockTemplate, + active_user_count: -1, + description: "🚀 Some new template that has no activity data", + icon: "/icon/goland.svg", + }, + { + ...MockTemplate, + active_user_count: 150, + description: "😮 Wow, this one has a bunch of usage!", + icon: "", + }, + { + ...MockTemplate, + description: + "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({ - message: "Something went wrong fetching templates.", - }), + entitlements: MockEntitlements, + context: { + organizationId: MockOrganization.id, + permissions: { + ...MockPermissions, + createTemplates: false, + }, + error: makeMockApiError({ + message: "Something went wrong fetching templates.", + }), + templates: undefined, + examples: undefined, + }, } diff --git a/site/src/pages/TemplatesPage/TemplatesPageView.tsx b/site/src/pages/TemplatesPage/TemplatesPageView.tsx index 64acc2f5f9..8f06ed341f 100644 --- a/site/src/pages/TemplatesPage/TemplatesPageView.tsx +++ b/site/src/pages/TemplatesPage/TemplatesPageView.tsx @@ -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{" "} - - create one from scratch - {" "} - 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 -> = (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 ( - + + + + + } + > Templates - 0)} - > + 0)}> Choose a template to create a new workspace - {props.canCreateTemplate ? ( + {permissions.createTemplates ? ( <> , or{" "} - - - - - + + + @@ -168,30 +149,21 @@ export const TemplatesPageView: FC< - + - - } - image={ -
- -
- } + + + - {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%", - }, - }, })) diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 56adf8f027..026f39377b 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -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, +} diff --git a/site/src/testHelpers/handlers.ts b/site/src/testHelpers/handlers.ts index 9c41216ef0..5fcd6154a7 100644 --- a/site/src/testHelpers/handlers.ts +++ b/site/src/testHelpers/handlers.ts @@ -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) => { diff --git a/site/src/util/formUtils.ts b/site/src/util/formUtils.ts index c76010e81b..b750375961 100644 --- a/site/src/util/formUtils.ts +++ b/site/src/util/formUtils.ts @@ -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) - ? (mapApiErrorToFieldErrors(error.response.data) as FormikErrors) - : error + const apiValidationErrors = isApiValidationError(error) + ? (mapApiErrorToFieldErrors(error.response.data) as FormikErrors) + : // 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, diff --git a/site/src/util/starterTemplates.ts b/site/src/util/starterTemplates.ts new file mode 100644 index 0000000000..628cf21aa8 --- /dev/null +++ b/site/src/util/starterTemplates.ts @@ -0,0 +1,24 @@ +import { TemplateExample } from "api/typesGenerated" + +export type StarterTemplatesByTag = Record + +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 +} diff --git a/site/src/xServices/createTemplate/createTemplateXService.ts b/site/src/xServices/createTemplate/createTemplateXService.ts new file mode 100644 index 0000000000..73490ec175 --- /dev/null +++ b/site/src/xServices/createTemplate/createTemplateXService.ts @@ -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 +} +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"), + ), + }, + }, + ) diff --git a/site/src/xServices/starterTemplates/starterTemplateXService.ts b/site/src/xServices/starterTemplates/starterTemplateXService.ts new file mode 100644 index 0000000000..077b5b8d4f --- /dev/null +++ b/site/src/xServices/starterTemplates/starterTemplateXService.ts @@ -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, + }), + }, + }, +) diff --git a/site/src/xServices/starterTemplates/starterTemplatesXService.ts b/site/src/xServices/starterTemplates/starterTemplatesXService.ts new file mode 100644 index 0000000000..744f0c5f2f --- /dev/null +++ b/site/src/xServices/starterTemplates/starterTemplatesXService.ts @@ -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), + }), + }, + }, +) diff --git a/site/src/xServices/templates/templatesXService.ts b/site/src/xServices/templates/templatesXService.ts index 33224b564c..2486e57337 100644 --- a/site/src/xServices/templates/templatesXService.ts +++ b/site/src/xServices/templates/templatesXService.ts @@ -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[] - } - getTemplates: { - data: TypesGen.Template[] + load: { + data: { + templates: TypesGen.Template[] + examples: TypesGen.TemplateExample[] + } } }, }, - 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", - }, - onError: { - actions: "assignGetTemplatesError", - target: "error", - }, - }, - tags: "loading", + idle: { + type: "final", }, - 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) }, }, }, diff --git a/site/static/icon/aws.png b/site/static/icon/aws.png index af2effa289d01627775971e6a339fa8d7c144857..b15292720c423ff07fa302392c3f54757e36e1d7 100644 GIT binary patch literal 4283 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 delta 1819 zcmV+$2juv>A*2pAiBL{Q4GJ0x0000DNk~Le0000v0000e2nGNE07Q~fy8r+H32;bR za{vGf6951U69E94oEVWdAAbh9Nklo@tJT`M&UW14S z-UH4kZz=-j8G1Ro%?~X0u)SE=r~n3f$n6kvj|=`VV0Vyll>sX#1Shi%#WtWxmy+0- z`-R>*(U#(2JHxPXtAC43r0&n51kE1-lpFdrz-gda@XAAR@(18A;1l2hU^H+YFcw${ z6bgfdLT{GfS9#bzZVd7@7nwPty-4u43;s@EGB5-fpzJry{U-w16oQkZfZjpchZ{3} zv*!0v?U(o%LoZ&moz-Q!i+#Bw-qyT`|u3b|*2V%TSA?*sZ%xCEt@$`0{v zRA=nH=M3E((KcT5IB5Ab?;*`Qte;bb?)yUbZo{q**y1rJj=Cwp1PX;9xXm!o0OSGt zff6xTFDG9VZLezH!-6+J^OE%Q3Bc04A2shJ5aBVt1%G-XTUAHmWA2U49Gou{T7c!s z&QPRGU)4UGn@)}sZRwi#p?*FjD21e1cu zedgt)Za<>icvQAQK2+JOgz0J3J{!7+!XU_B1!M{RRM~C@o>);oahQwT3`34{YmWH2E|bYT&$QGr$cKJ|a;Kw8e>o`oTw=-uu6D6A z$IxBk(OZT_kR8bQW48=)XM@<|eaC@}v!_vHXGi?S^E26dxs-b7GA4^IBT~r}Ma?)C ztEkahOKlFnTRVyTX((KJ5W9IU${SE}Ud)$ib?~el&tF+DP@<(aQC~2!;r9pDGWwjl z0b$I1M7#s6Cfg}@vfyR}4f4U$dORcon}4BPpgy7{bg5G-#o@OacnmLR8nw^_+!rKZ zsZPQhoey8^yC;QOZ)!WYT;bgHVDXndhy4QJUEtfmBy=-^FYDpU8dR$>{vqdbS1i>( zkQ(#V!!Ce7K2tFhN7ZIpr?${|x&%qBB_ABR1uHNG<1A8JX|!&Nw*~C$n05)ewSN+a zeibc1^(CMYvk?cu*RdpifuX(F=qpji(*I0qBaMS|Yv@`!j4^VO@&v}&h|i0ffPgP> z^Ur%S-7dThjmDNvg0lH-=Lhcqkb&A>TrurKxNfD3NGcIoR}{_0WbJxctoX4zMdF2t8iJe&L0kHF^SNKaZaDw|002ov JPDHLkV1jI*RyqIx diff --git a/site/static/icon/azure.png b/site/static/icon/azure.png index aba18fe3f5af5616a108c68044c5485d218101a0..1516339df51920292edfbf32fef6c78cfb003dca 100644 GIT binary patch delta 2752 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 delta 1017 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 BwT}P* diff --git a/site/static/icon/gcp.png b/site/static/icon/gcp.png index 9585ea1244aef74735eb43d34cb89cee7e8f7cc1..350bd4881ae4531d1d859cf19807d862d11c6337 100644 GIT binary patch literal 4105 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 Hu0mjf*AB|5 delta 1879 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 delta 2447 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