diff --git a/site/src/AppRouter.tsx b/site/src/AppRouter.tsx index 569b0241b5de1..ff59edb4924ef 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 1663e0333dabb..496bf493e98ef 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 0000000000000..5646574345e51 --- /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 78b667654b51b..6b70dd552c849 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 639d0257067af..8becdb8cba783 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 0000000000000..042e9904a323b --- /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 edf9aaeed5626..ea857defc75ec 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 0000000000000..96780e2a0a050 --- /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 0000000000000..e31df28043151 --- /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 37b8b66dca66b..b96f381abee63 100644 --- a/site/src/i18n/en/index.ts +++ b/site/src/i18n/en/index.ts @@ -13,6 +13,9 @@ import templateVersionPage from "./templateVersionPage.json" import loginPage from "./loginPage.json" import workspaceChangeVersionPage from "./workspaceChangeVersionPage.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, @@ -30,4 +33,7 @@ export const en = { loginPage, workspaceChangeVersionPage, 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 0000000000000..b1a5a894392e2 --- /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 0000000000000..f9abcbae9491d --- /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 34d56b2788f07..44e14723ec0e4 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 0000000000000..0af1e1bf8a327 --- /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 0000000000000..66adcca70fc7b --- /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 ( + <> + + Codestin Search App + + + + + + + + + + + + + {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 0000000000000..6aa121db68481 --- /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 0dcb577cd77c4..295cb34d5e686 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 0000000000000..323164440d0d2 --- /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 0000000000000..b0ec53fa3ab8d --- /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 ( + <> + + Codestin Search App + + + + + ) +} + +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 0000000000000..fc23097efb5e4 --- /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 0000000000000..3264e5ce12557 --- /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 0000000000000..63d5ed7ce18ad --- /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 0000000000000..1c0937d707d6d --- /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 ( + <> + + Codestin Search App + + + + + ) +} + +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 0000000000000..bd2e3c06e7904 --- /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 0000000000000..40d4a58448877 --- /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 0000000000000..f21d5b0d377e4 --- /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 f504c4209a1df..b424ebb55048e 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 970da10d7908c..8c58ba64d2ef7 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 = () => { Codestin Search App ) diff --git a/site/src/pages/TemplatesPage/TemplatesPageView.stories.tsx b/site/src/pages/TemplatesPage/TemplatesPageView.stories.tsx index c150cbb902abc..abb84f5b03f20 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 64acc2f5f9fb0..8f06ed341f043 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 56adf8f02722b..026f39377b994 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 06a577ec48488..791a1df7d8ab4 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 c76010e81b394..b750375961744 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 0000000000000..628cf21aa8a62 --- /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 0000000000000..73490ec17598d --- /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 0000000000000..077b5b8d4fec9 --- /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 0000000000000..744f0c5f2ff82 --- /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 33224b564ccb7..2486e57337050 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, - }), - assignGetOrganizationsError: assign({ - getOrganizationsError: (_, event) => event.data, + assignData: assign({ + templates: (_, event) => event.data.templates, + examples: (_, event) => event.data.examples, }), - clearGetOrganizationsError: assign((context) => ({ - ...context, - getOrganizationsError: undefined, - })), - assignTemplates: assign({ - templates: (_, event) => event.data, + assignError: assign({ + error: (_, { data }) => 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 af2effa289d01..b15292720c423 100644 Binary files a/site/static/icon/aws.png and b/site/static/icon/aws.png differ diff --git a/site/static/icon/azure.png b/site/static/icon/azure.png index aba18fe3f5af5..1516339df5192 100644 Binary files a/site/static/icon/azure.png and b/site/static/icon/azure.png differ diff --git a/site/static/icon/do.png b/site/static/icon/do.png index ab6e78246c531..827bcaa6446ba 100644 Binary files a/site/static/icon/do.png and b/site/static/icon/do.png differ diff --git a/site/static/icon/docker.png b/site/static/icon/docker.png index f88b81ca415fc..f07559cbc34d5 100644 Binary files a/site/static/icon/docker.png and b/site/static/icon/docker.png differ diff --git a/site/static/icon/gcp.png b/site/static/icon/gcp.png index 9585ea1244aef..350bd4881ae45 100644 Binary files a/site/static/icon/gcp.png and b/site/static/icon/gcp.png differ diff --git a/site/static/icon/k8s.png b/site/static/icon/k8s.png index 45e8614e2a506..7a9e3c2b850fe 100644 Binary files a/site/static/icon/k8s.png and b/site/static/icon/k8s.png differ