feat: Add create template from the UI (#5427)
@ -92,6 +92,15 @@ const GitAuthPage = lazy(() => import("./pages/GitAuthPage/GitAuthPage"))
|
||||
const TemplateVersionPage = lazy(
|
||||
() => import("./pages/TemplateVersionPage/TemplateVersionPage"),
|
||||
)
|
||||
const StarterTemplatesPage = lazy(
|
||||
() => import("./pages/StarterTemplatesPage/StarterTemplatesPage"),
|
||||
)
|
||||
const StarterTemplatePage = lazy(
|
||||
() => import("pages/StarterTemplatePage/StarterTemplatePage"),
|
||||
)
|
||||
const CreateTemplatePage = lazy(
|
||||
() => import("./pages/CreateTemplatePage/CreateTemplatePage"),
|
||||
)
|
||||
|
||||
export const AppRouter: FC = () => {
|
||||
const xServices = useContext(XServiceContext)
|
||||
@ -141,6 +150,26 @@ export const AppRouter: FC = () => {
|
||||
}
|
||||
/>
|
||||
|
||||
<Route path="starter-templates">
|
||||
<Route
|
||||
index
|
||||
element={
|
||||
<AuthAndFrame>
|
||||
<StarterTemplatesPage />
|
||||
</AuthAndFrame>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path=":exampleId"
|
||||
element={
|
||||
<AuthAndFrame>
|
||||
<StarterTemplatePage />
|
||||
</AuthAndFrame>
|
||||
}
|
||||
></Route>
|
||||
</Route>
|
||||
|
||||
<Route path="templates">
|
||||
<Route
|
||||
index
|
||||
@ -151,6 +180,15 @@ export const AppRouter: FC = () => {
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="new"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<CreateTemplatePage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route path=":template">
|
||||
<Route
|
||||
index
|
||||
|
@ -259,6 +259,37 @@ export const getPreviousTemplateVersionByName = async (
|
||||
}
|
||||
}
|
||||
|
||||
export const createTemplateVersion = async (
|
||||
organizationId: string,
|
||||
data: TypesGen.CreateTemplateVersionRequest,
|
||||
): Promise<TypesGen.TemplateVersion> => {
|
||||
const response = await axios.post<TypesGen.TemplateVersion>(
|
||||
`/api/v2/organizations/${organizationId}/templateversions`,
|
||||
data,
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const getTemplateVersionParameters = async (
|
||||
versionId: string,
|
||||
): Promise<TypesGen.Parameter[]> => {
|
||||
const response = await axios.get(
|
||||
`/api/v2/templateversions/${versionId}/parameters`,
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const createTemplate = async (
|
||||
organizationId: string,
|
||||
data: TypesGen.CreateTemplateRequest,
|
||||
): Promise<TypesGen.Template> => {
|
||||
const response = await axios.post(
|
||||
`/api/v2/organizations/${organizationId}/templates`,
|
||||
data,
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const updateTemplateMeta = async (
|
||||
templateId: string,
|
||||
data: TypesGen.UpdateTemplateMeta,
|
||||
@ -703,3 +734,32 @@ export const setServiceBanner = async (
|
||||
const response = await axios.put(`/api/v2/service-banner`, b)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const getTemplateExamples = async (
|
||||
organizationId: string,
|
||||
): Promise<TypesGen.TemplateExample[]> => {
|
||||
const response = await axios.get(
|
||||
`/api/v2/organizations/${organizationId}/templates/examples`,
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const uploadTemplateFile = async (
|
||||
file: File,
|
||||
): Promise<TypesGen.UploadResponse> => {
|
||||
const response = await axios.post("/api/v2/files", file, {
|
||||
headers: {
|
||||
"Content-Type": "application/x-tar",
|
||||
},
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const getTemplateVersionLogs = async (
|
||||
versionId: string,
|
||||
): Promise<TypesGen.ProvisionerJobLog[]> => {
|
||||
const response = await axios.get<TypesGen.ProvisionerJobLog[]>(
|
||||
`/api/v2/templateversions/${versionId}/logs`,
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
@ -63,6 +63,10 @@ export const mapApiErrorToFieldErrors = (
|
||||
return result
|
||||
}
|
||||
|
||||
export const isApiValidationError = (error: unknown): error is ApiError => {
|
||||
return isApiError(error) && hasApiFieldErrors(error)
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param error
|
||||
|
113
site/src/components/IconField/IconField.tsx
Normal file
@ -0,0 +1,113 @@
|
||||
import Button from "@material-ui/core/Button"
|
||||
import InputAdornment from "@material-ui/core/InputAdornment"
|
||||
import Popover from "@material-ui/core/Popover"
|
||||
import TextField, { TextFieldProps } from "@material-ui/core/TextField"
|
||||
import { OpenDropdown } from "components/DropdownArrows/DropdownArrows"
|
||||
import { useRef, FC, useState } from "react"
|
||||
import Picker from "@emoji-mart/react"
|
||||
import { makeStyles } from "@material-ui/core/styles"
|
||||
import { colors } from "theme/colors"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import data from "@emoji-mart/data/sets/14/twitter.json"
|
||||
|
||||
export const IconField: FC<
|
||||
TextFieldProps & { onPickEmoji: (value: string) => void }
|
||||
> = ({ onPickEmoji, ...textFieldProps }) => {
|
||||
if (
|
||||
typeof textFieldProps.value !== "string" &&
|
||||
typeof textFieldProps.value !== "undefined"
|
||||
) {
|
||||
throw new Error(`Invalid icon value "${typeof textFieldProps.value}"`)
|
||||
}
|
||||
|
||||
const styles = useStyles()
|
||||
const emojiButtonRef = useRef<HTMLButtonElement>(null)
|
||||
const [isEmojiPickerOpen, setIsEmojiPickerOpen] = useState(false)
|
||||
const { t } = useTranslation("templateSettingsPage")
|
||||
const hasIcon = textFieldProps.value && textFieldProps.value !== ""
|
||||
|
||||
return (
|
||||
<div className={styles.iconField}>
|
||||
<TextField
|
||||
{...textFieldProps}
|
||||
fullWidth
|
||||
label={t("iconLabel")}
|
||||
variant="outlined"
|
||||
InputProps={{
|
||||
endAdornment: hasIcon ? (
|
||||
<InputAdornment position="end" className={styles.adornment}>
|
||||
<img
|
||||
alt=""
|
||||
src={textFieldProps.value}
|
||||
// This prevent browser to display the ugly error icon if the
|
||||
// image path is wrong or user didn't finish typing the url
|
||||
onError={(e) => (e.currentTarget.style.display = "none")}
|
||||
onLoad={(e) => (e.currentTarget.style.display = "inline")}
|
||||
/>
|
||||
</InputAdornment>
|
||||
) : undefined,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
ref={emojiButtonRef}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
endIcon={<OpenDropdown />}
|
||||
onClick={() => {
|
||||
setIsEmojiPickerOpen((v) => !v)
|
||||
}}
|
||||
>
|
||||
{t("selectEmoji")}
|
||||
</Button>
|
||||
|
||||
<Popover
|
||||
id="emoji"
|
||||
open={isEmojiPickerOpen}
|
||||
anchorEl={emojiButtonRef.current}
|
||||
onClose={() => {
|
||||
setIsEmojiPickerOpen(false)
|
||||
}}
|
||||
>
|
||||
<Picker
|
||||
theme="dark"
|
||||
data={data}
|
||||
onEmojiSelect={(emojiData) => {
|
||||
// See: https://github.com/missive/emoji-mart/issues/51#issuecomment-287353222
|
||||
const value = `/emojis/${emojiData.unified.replace(
|
||||
/-fe0f$/,
|
||||
"",
|
||||
)}.png`
|
||||
onPickEmoji(value)
|
||||
setIsEmojiPickerOpen(false)
|
||||
}}
|
||||
/>
|
||||
</Popover>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
"@global": {
|
||||
"em-emoji-picker": {
|
||||
"--rgb-background": theme.palette.background.paper,
|
||||
"--rgb-input": colors.gray[17],
|
||||
"--rgb-color": colors.gray[4],
|
||||
},
|
||||
},
|
||||
adornment: {
|
||||
width: theme.spacing(3),
|
||||
height: theme.spacing(3),
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
|
||||
"& img": {
|
||||
maxWidth: "100%",
|
||||
},
|
||||
},
|
||||
iconField: {
|
||||
paddingBottom: theme.spacing(0.5),
|
||||
},
|
||||
}))
|
@ -1,4 +1,5 @@
|
||||
import { makeStyles } from "@material-ui/core/styles"
|
||||
import { LogLevel } from "api/typesGenerated"
|
||||
import dayjs from "dayjs"
|
||||
import { FC } from "react"
|
||||
import { MONOSPACE_FONT_FAMILY } from "../../theme/constants"
|
||||
@ -7,6 +8,7 @@ import { combineClasses } from "../../util/combineClasses"
|
||||
interface Line {
|
||||
time: string
|
||||
output: string
|
||||
level: LogLevel
|
||||
}
|
||||
|
||||
export interface LogsProps {
|
||||
@ -22,8 +24,9 @@ export const Logs: FC<React.PropsWithChildren<LogsProps>> = ({
|
||||
|
||||
return (
|
||||
<div className={combineClasses([className, styles.root])}>
|
||||
<div className={styles.scrollWrapper}>
|
||||
{lines.map((line, idx) => (
|
||||
<div className={styles.line} key={idx}>
|
||||
<div className={combineClasses([styles.line, line.level])} key={idx}>
|
||||
<span className={styles.time}>
|
||||
{dayjs(line.time).format(`HH:mm:ss.SSS`)}
|
||||
</span>
|
||||
@ -32,6 +35,7 @@ export const Logs: FC<React.PropsWithChildren<LogsProps>> = ({
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -43,13 +47,25 @@ const useStyles = makeStyles((theme) => ({
|
||||
fontFamily: MONOSPACE_FONT_FAMILY,
|
||||
fontSize: 13,
|
||||
wordBreak: "break-all",
|
||||
padding: theme.spacing(2),
|
||||
padding: theme.spacing(2, 0),
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
overflowX: "auto",
|
||||
},
|
||||
scrollWrapper: {
|
||||
width: "fit-content",
|
||||
},
|
||||
line: {
|
||||
// Whitespace is significant in terminal output for alignment
|
||||
whiteSpace: "pre",
|
||||
padding: theme.spacing(0, 3),
|
||||
|
||||
"&.error": {
|
||||
backgroundColor: theme.palette.error.dark,
|
||||
},
|
||||
|
||||
"&.warning": {
|
||||
backgroundColor: theme.palette.warning.dark,
|
||||
},
|
||||
},
|
||||
space: {
|
||||
userSelect: "none",
|
||||
|
@ -48,14 +48,14 @@ export const Markdown: FC<{ children: string }> = ({ children }) => {
|
||||
<SyntaxHighlighter
|
||||
style={darcula}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- this can be undefined
|
||||
language={match[1] ?? "language-shell"}
|
||||
language={match[1].toLowerCase() ?? "language-shell"}
|
||||
useInlineStyles={false}
|
||||
// Use inline styles does not work correctly
|
||||
// https://github.com/react-syntax-highlighter/react-syntax-highlighter/issues/329
|
||||
codeTagProps={{ style: {} }}
|
||||
{...props}
|
||||
>
|
||||
{String(children).replace(/\n$/, "")}
|
||||
{String(children)}
|
||||
</SyntaxHighlighter>
|
||||
) : (
|
||||
<code className={styles.codeWithoutLanguage} {...props}>
|
||||
@ -135,19 +135,24 @@ const useStyles = makeStyles((theme) => ({
|
||||
background: theme.palette.background.paperLight,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
padding: theme.spacing(2, 3),
|
||||
overflowX: "auto",
|
||||
|
||||
"& code": {
|
||||
color: theme.palette.text.secondary,
|
||||
},
|
||||
|
||||
"& .key, & .property": {
|
||||
"& .key, & .property, & .inserted, .keyword": {
|
||||
color: colors.turquoise[7],
|
||||
},
|
||||
|
||||
"& .deleted": {
|
||||
color: theme.palette.error.light,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
codeWithoutLanguage: {
|
||||
padding: theme.spacing(0.5, 1),
|
||||
padding: theme.spacing(0.125, 0.5),
|
||||
background: theme.palette.divider,
|
||||
borderRadius: 4,
|
||||
color: theme.palette.text.primary,
|
||||
|
@ -0,0 +1,91 @@
|
||||
import { makeStyles } from "@material-ui/core/styles"
|
||||
import { TemplateExample } from "api/typesGenerated"
|
||||
import { FC } from "react"
|
||||
import { Link } from "react-router-dom"
|
||||
import { combineClasses } from "util/combineClasses"
|
||||
|
||||
export interface TemplateExampleCardProps {
|
||||
example: TemplateExample
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const TemplateExampleCard: FC<TemplateExampleCardProps> = ({
|
||||
example,
|
||||
className,
|
||||
}) => {
|
||||
const styles = useStyles()
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={`/starter-templates/${example.id}`}
|
||||
className={combineClasses([styles.template, className])}
|
||||
key={example.id}
|
||||
>
|
||||
<div className={styles.templateIcon}>
|
||||
<img src={example.icon} alt="" />
|
||||
</div>
|
||||
<div className={styles.templateInfo}>
|
||||
<span className={styles.templateName}>{example.name}</span>
|
||||
<span className={styles.templateDescription}>
|
||||
{example.description}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
template: {
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
background: theme.palette.background.paper,
|
||||
textDecoration: "none",
|
||||
textAlign: "left",
|
||||
color: "inherit",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
height: "fit-content",
|
||||
|
||||
"&:hover": {
|
||||
backgroundColor: theme.palette.background.paperLight,
|
||||
},
|
||||
},
|
||||
|
||||
templateIcon: {
|
||||
width: theme.spacing(12),
|
||||
height: theme.spacing(12),
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
|
||||
"& img": {
|
||||
height: theme.spacing(4),
|
||||
},
|
||||
},
|
||||
|
||||
templateInfo: {
|
||||
padding: theme.spacing(2, 2, 2, 0),
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: theme.spacing(0.5),
|
||||
overflow: "hidden",
|
||||
},
|
||||
|
||||
templateName: {
|
||||
fontSize: theme.spacing(2),
|
||||
textOverflow: "ellipsis",
|
||||
width: "100%",
|
||||
overflow: "hidden",
|
||||
whiteSpace: "nowrap",
|
||||
},
|
||||
|
||||
templateDescription: {
|
||||
fontSize: theme.spacing(1.75),
|
||||
color: theme.palette.text.secondary,
|
||||
textOverflow: "ellipsis",
|
||||
width: "100%",
|
||||
overflow: "hidden",
|
||||
whiteSpace: "nowrap",
|
||||
},
|
||||
}))
|
@ -54,6 +54,7 @@ export const WorkspaceBuildLogs: FC<WorkspaceBuildLogsProps> = ({ logs }) => {
|
||||
const lines = logs.map((log) => ({
|
||||
time: log.created_at,
|
||||
output: log.output,
|
||||
level: log.log_level,
|
||||
}))
|
||||
const duration = getStageDurationInSeconds(logs)
|
||||
const shouldDisplayDuration = duration !== undefined
|
||||
@ -68,7 +69,7 @@ export const WorkspaceBuildLogs: FC<WorkspaceBuildLogsProps> = ({ logs }) => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!isEmpty && <Logs lines={lines} className={styles.codeBlock} />}
|
||||
{!isEmpty && <Logs lines={lines} />}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
@ -86,8 +87,8 @@ const useStyles = makeStyles((theme) => ({
|
||||
header: {
|
||||
fontSize: 14,
|
||||
padding: theme.spacing(2),
|
||||
paddingLeft: theme.spacing(4),
|
||||
paddingRight: theme.spacing(4),
|
||||
paddingLeft: theme.spacing(3),
|
||||
paddingRight: theme.spacing(3),
|
||||
borderBottom: `1px solid ${theme.palette.divider}`,
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
display: "flex",
|
||||
@ -112,9 +113,4 @@ const useStyles = makeStyles((theme) => ({
|
||||
color: theme.palette.text.secondary,
|
||||
fontSize: theme.typography.body2.fontSize,
|
||||
},
|
||||
|
||||
codeBlock: {
|
||||
padding: theme.spacing(2),
|
||||
paddingLeft: theme.spacing(4),
|
||||
},
|
||||
}))
|
||||
|
14
site/src/hooks/useEntitlements.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { useSelector } from "@xstate/react"
|
||||
import { Entitlements } from "api/typesGenerated"
|
||||
import { useContext } from "react"
|
||||
import { XServiceContext } from "xServices/StateContext"
|
||||
|
||||
export const useEntitlements = (): Entitlements => {
|
||||
const xServices = useContext(XServiceContext)
|
||||
const entitlements = useSelector(
|
||||
xServices.entitlementsXService,
|
||||
(state) => state.context.entitlements,
|
||||
)
|
||||
|
||||
return entitlements
|
||||
}
|
44
site/src/i18n/en/createTemplatePage.json
Normal file
@ -0,0 +1,44 @@
|
||||
{
|
||||
"title": "Create Template",
|
||||
"form": {
|
||||
"generalInfo": {
|
||||
"title": "General info",
|
||||
"description": "The name is used to identify the template on the URL and also API. It has to be unique across your organization."
|
||||
},
|
||||
"displayInfo": {
|
||||
"title": "Display info",
|
||||
"description": "Set the name that you want to use to display your template, a helpful description and icon."
|
||||
},
|
||||
"schedule": {
|
||||
"title": "Schedule",
|
||||
"description": "Define when a workspace created from this template is going to stop."
|
||||
},
|
||||
"operations": {
|
||||
"title": "Operations",
|
||||
"description": "Allow or disallow users to run specific actions on the workspace."
|
||||
},
|
||||
"parameters": {
|
||||
"title": "Template params",
|
||||
"description": "These params are provided by your template's Terraform configuration."
|
||||
},
|
||||
"fields": {
|
||||
"name": "Name",
|
||||
"displayName": "Display name",
|
||||
"description": "Description",
|
||||
"icon": "Icon",
|
||||
"autoStop": "Auto-stop default",
|
||||
"allowUsersToCancel": "Allow users to cancel in-progress workspace jobs"
|
||||
},
|
||||
"helperText": {
|
||||
"autoStop": "Time in hours",
|
||||
"allowUsersToCancel": "Not recommended"
|
||||
},
|
||||
"upload": {
|
||||
"removeTitle": "Remove file",
|
||||
"title": "Upload template"
|
||||
},
|
||||
"tooltip": {
|
||||
"allowUsersToCancel": "Depending on your template, canceling builds may leave workspaces in an unhealthy state. This option isn't recommended for most use cases."
|
||||
}
|
||||
}
|
||||
}
|
@ -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,
|
||||
}
|
||||
|
6
site/src/i18n/en/starterTemplatePage.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"actions": {
|
||||
"viewSourceCode": "View source code",
|
||||
"useTemplate": "Use template"
|
||||
}
|
||||
}
|
11
site/src/i18n/en/starterTemplatesPage.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"title": "Starter Templates",
|
||||
"subtitle": "Pick one of the built-in templates to start using Coder",
|
||||
"filterCaption": "Filter",
|
||||
"tags": {
|
||||
"all": "All templates",
|
||||
"digitalocean": "Digital Ocean",
|
||||
"aws": "AWS",
|
||||
"google": "Google Cloud"
|
||||
}
|
||||
}
|
@ -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."
|
||||
}
|
||||
}
|
||||
|
403
site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx
Normal file
@ -0,0 +1,403 @@
|
||||
import Checkbox from "@material-ui/core/Checkbox"
|
||||
import { makeStyles } from "@material-ui/core/styles"
|
||||
import TextField from "@material-ui/core/TextField"
|
||||
import {
|
||||
ParameterSchema,
|
||||
ProvisionerJobLog,
|
||||
TemplateExample,
|
||||
} from "api/typesGenerated"
|
||||
import { FormFooter } from "components/FormFooter/FormFooter"
|
||||
import { IconField } from "components/IconField/IconField"
|
||||
import { ParameterInput } from "components/ParameterInput/ParameterInput"
|
||||
import { Stack } from "components/Stack/Stack"
|
||||
import {
|
||||
TemplateUpload,
|
||||
TemplateUploadProps,
|
||||
} from "pages/CreateTemplatePage/TemplateUpload"
|
||||
import { useFormik } from "formik"
|
||||
import { SelectedTemplate } from "pages/CreateWorkspacePage/SelectedTemplate"
|
||||
import { FC } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { nameValidator, getFormHelpers, onChangeTrimmed } from "util/formUtils"
|
||||
import { CreateTemplateData } from "xServices/createTemplate/createTemplateXService"
|
||||
import * as Yup from "yup"
|
||||
import { WorkspaceBuildLogs } from "components/WorkspaceBuildLogs/WorkspaceBuildLogs"
|
||||
import { HelpTooltip, HelpTooltipText } from "components/Tooltips/HelpTooltip"
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
name: nameValidator("Name"),
|
||||
display_name: Yup.string().optional(),
|
||||
description: Yup.string().optional(),
|
||||
icon: Yup.string().optional(),
|
||||
default_ttl_hours: Yup.number(),
|
||||
allow_user_cancel_workspace_jobs: Yup.boolean(),
|
||||
parameter_values_by_name: Yup.object().optional(),
|
||||
})
|
||||
|
||||
const defaultInitialValues: CreateTemplateData = {
|
||||
name: "",
|
||||
display_name: "",
|
||||
description: "",
|
||||
icon: "",
|
||||
default_ttl_hours: 24,
|
||||
allow_user_cancel_workspace_jobs: false,
|
||||
parameter_values_by_name: undefined,
|
||||
}
|
||||
|
||||
const getInitialValues = (starterTemplate?: TemplateExample) => {
|
||||
if (!starterTemplate) {
|
||||
return defaultInitialValues
|
||||
}
|
||||
|
||||
return {
|
||||
...defaultInitialValues,
|
||||
name: starterTemplate.id,
|
||||
display_name: starterTemplate.name,
|
||||
icon: starterTemplate.icon,
|
||||
description: starterTemplate.description,
|
||||
}
|
||||
}
|
||||
|
||||
interface CreateTemplateFormProps {
|
||||
starterTemplate?: TemplateExample
|
||||
error?: unknown
|
||||
parameters?: ParameterSchema[]
|
||||
isSubmitting: boolean
|
||||
onCancel: () => void
|
||||
onSubmit: (data: CreateTemplateData) => void
|
||||
upload: TemplateUploadProps
|
||||
jobError?: string
|
||||
logs?: ProvisionerJobLog[]
|
||||
}
|
||||
|
||||
export const CreateTemplateForm: FC<CreateTemplateFormProps> = ({
|
||||
starterTemplate,
|
||||
error,
|
||||
parameters,
|
||||
isSubmitting,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
upload,
|
||||
jobError,
|
||||
logs,
|
||||
}) => {
|
||||
const styles = useStyles()
|
||||
const formFooterStyles = useFormFooterStyles()
|
||||
const form = useFormik<CreateTemplateData>({
|
||||
initialValues: getInitialValues(starterTemplate),
|
||||
validationSchema,
|
||||
onSubmit,
|
||||
})
|
||||
const getFieldHelpers = getFormHelpers<CreateTemplateData>(form, error)
|
||||
const { t } = useTranslation("createTemplatePage")
|
||||
|
||||
return (
|
||||
<form onSubmit={form.handleSubmit}>
|
||||
<Stack direction="column" spacing={10} className={styles.formSections}>
|
||||
{/* General info */}
|
||||
<div className={styles.formSection}>
|
||||
<div className={styles.formSectionInfo}>
|
||||
<h2 className={styles.formSectionInfoTitle}>
|
||||
{t("form.generalInfo.title")}
|
||||
</h2>
|
||||
<p className={styles.formSectionInfoDescription}>
|
||||
{t("form.generalInfo.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Stack direction="column" className={styles.formSectionFields}>
|
||||
{starterTemplate ? (
|
||||
<SelectedTemplate template={starterTemplate} />
|
||||
) : (
|
||||
<TemplateUpload {...upload} />
|
||||
)}
|
||||
|
||||
<TextField
|
||||
{...getFieldHelpers("name")}
|
||||
disabled={isSubmitting}
|
||||
onChange={onChangeTrimmed(form)}
|
||||
autoFocus
|
||||
fullWidth
|
||||
label={t("form.fields.name")}
|
||||
variant="outlined"
|
||||
/>
|
||||
</Stack>
|
||||
</div>
|
||||
|
||||
{/* Display info */}
|
||||
<div className={styles.formSection}>
|
||||
<div className={styles.formSectionInfo}>
|
||||
<h2 className={styles.formSectionInfoTitle}>
|
||||
{t("form.displayInfo.title")}
|
||||
</h2>
|
||||
<p className={styles.formSectionInfoDescription}>
|
||||
{t("form.displayInfo.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Stack direction="column" className={styles.formSectionFields}>
|
||||
<TextField
|
||||
{...getFieldHelpers("display_name")}
|
||||
disabled={isSubmitting}
|
||||
fullWidth
|
||||
label={t("form.fields.displayName")}
|
||||
variant="outlined"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
{...getFieldHelpers("description")}
|
||||
disabled={isSubmitting}
|
||||
rows={5}
|
||||
multiline
|
||||
fullWidth
|
||||
label={t("form.fields.description")}
|
||||
variant="outlined"
|
||||
/>
|
||||
|
||||
<IconField
|
||||
{...getFieldHelpers("icon")}
|
||||
disabled={isSubmitting}
|
||||
onChange={onChangeTrimmed(form)}
|
||||
fullWidth
|
||||
label={t("form.fields.icon")}
|
||||
variant="outlined"
|
||||
onPickEmoji={(value) => form.setFieldValue("icon", value)}
|
||||
/>
|
||||
</Stack>
|
||||
</div>
|
||||
|
||||
{/* Schedule */}
|
||||
<div className={styles.formSection}>
|
||||
<div className={styles.formSectionInfo}>
|
||||
<h2 className={styles.formSectionInfoTitle}>
|
||||
{t("form.schedule.title")}
|
||||
</h2>
|
||||
<p className={styles.formSectionInfoDescription}>
|
||||
{t("form.schedule.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Stack direction="column" className={styles.formSectionFields}>
|
||||
<TextField
|
||||
{...getFieldHelpers("default_ttl_hours")}
|
||||
disabled={isSubmitting}
|
||||
onChange={onChangeTrimmed(form)}
|
||||
fullWidth
|
||||
label={t("form.fields.autoStop")}
|
||||
variant="outlined"
|
||||
type="number"
|
||||
helperText={t("form.helperText.autoStop")}
|
||||
/>
|
||||
</Stack>
|
||||
</div>
|
||||
|
||||
{/* Operations */}
|
||||
<div className={styles.formSection}>
|
||||
<div className={styles.formSectionInfo}>
|
||||
<h2 className={styles.formSectionInfoTitle}>
|
||||
{t("form.operations.title")}
|
||||
</h2>
|
||||
<p className={styles.formSectionInfoDescription}>
|
||||
{t("form.operations.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Stack direction="column" className={styles.formSectionFields}>
|
||||
<label htmlFor="allow_user_cancel_workspace_jobs">
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Checkbox
|
||||
color="primary"
|
||||
id="allow_user_cancel_workspace_jobs"
|
||||
name="allow_user_cancel_workspace_jobs"
|
||||
disabled={isSubmitting}
|
||||
checked={form.values.allow_user_cancel_workspace_jobs}
|
||||
onChange={form.handleChange}
|
||||
/>
|
||||
|
||||
<Stack direction="column" spacing={0.5}>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
spacing={0.5}
|
||||
className={styles.optionText}
|
||||
>
|
||||
{t("form.fields.allowUsersToCancel")}
|
||||
|
||||
<HelpTooltip>
|
||||
<HelpTooltipText>
|
||||
{t("form.tooltip.allowUsersToCancel")}
|
||||
</HelpTooltipText>
|
||||
</HelpTooltip>
|
||||
</Stack>
|
||||
<span className={styles.optionHelperText}>
|
||||
{t("form.helperText.allowUsersToCancel")}
|
||||
</span>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</label>
|
||||
</Stack>
|
||||
</div>
|
||||
|
||||
{/* Parameters */}
|
||||
{parameters && (
|
||||
<div className={styles.formSection}>
|
||||
<div className={styles.formSectionInfo}>
|
||||
<h2 className={styles.formSectionInfoTitle}>
|
||||
{t("form.parameters.title")}
|
||||
</h2>
|
||||
<p className={styles.formSectionInfoDescription}>
|
||||
{t("form.parameters.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Stack direction="column" className={styles.formSectionFields}>
|
||||
{parameters.map((schema) => (
|
||||
<ParameterInput
|
||||
schema={schema}
|
||||
disabled={isSubmitting}
|
||||
key={schema.id}
|
||||
onChange={async (value) => {
|
||||
await form.setFieldValue(
|
||||
`parameter_values_by_name.${schema.name}`,
|
||||
value,
|
||||
)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{jobError && (
|
||||
<Stack>
|
||||
<div className={styles.error}>
|
||||
<h5 className={styles.errorTitle}>Error during provisioning</h5>
|
||||
<p className={styles.errorDescription}>
|
||||
Looks like we found an error during the template provisioning.
|
||||
You can see the logs bellow.
|
||||
</p>
|
||||
|
||||
<code className={styles.errorDetails}>{jobError}</code>
|
||||
</div>
|
||||
|
||||
<WorkspaceBuildLogs logs={logs ?? []} />
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<FormFooter
|
||||
styles={formFooterStyles}
|
||||
onCancel={onCancel}
|
||||
isLoading={isSubmitting}
|
||||
submitLabel={jobError ? "Retry" : "Create template"}
|
||||
/>
|
||||
</Stack>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
formSections: {
|
||||
[theme.breakpoints.down("sm")]: {
|
||||
gap: theme.spacing(8),
|
||||
},
|
||||
},
|
||||
|
||||
formSection: {
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
gap: theme.spacing(15),
|
||||
|
||||
[theme.breakpoints.down("sm")]: {
|
||||
flexDirection: "column",
|
||||
gap: theme.spacing(2),
|
||||
},
|
||||
},
|
||||
|
||||
formSectionInfo: {
|
||||
width: 312,
|
||||
flexShrink: 0,
|
||||
position: "sticky",
|
||||
top: theme.spacing(3),
|
||||
|
||||
[theme.breakpoints.down("sm")]: {
|
||||
width: "100%",
|
||||
position: "initial",
|
||||
},
|
||||
},
|
||||
|
||||
formSectionInfoTitle: {
|
||||
fontSize: 20,
|
||||
color: theme.palette.text.primary,
|
||||
fontWeight: 400,
|
||||
margin: 0,
|
||||
marginBottom: theme.spacing(1),
|
||||
},
|
||||
|
||||
formSectionInfoDescription: {
|
||||
fontSize: 14,
|
||||
color: theme.palette.text.secondary,
|
||||
lineHeight: "160%",
|
||||
margin: 0,
|
||||
},
|
||||
|
||||
formSectionFields: {
|
||||
width: "100%",
|
||||
},
|
||||
|
||||
optionText: {
|
||||
fontSize: theme.spacing(2),
|
||||
color: theme.palette.text.primary,
|
||||
},
|
||||
|
||||
optionHelperText: {
|
||||
fontSize: theme.spacing(1.5),
|
||||
color: theme.palette.text.secondary,
|
||||
},
|
||||
|
||||
error: {
|
||||
padding: theme.spacing(3),
|
||||
borderRadius: theme.spacing(1),
|
||||
background: theme.palette.background.paper,
|
||||
border: `1px solid ${theme.palette.error.main}`,
|
||||
},
|
||||
|
||||
errorTitle: {
|
||||
fontSize: 16,
|
||||
margin: 0,
|
||||
},
|
||||
|
||||
errorDescription: {
|
||||
margin: 0,
|
||||
color: theme.palette.text.secondary,
|
||||
marginTop: theme.spacing(0.5),
|
||||
},
|
||||
|
||||
errorDetails: {
|
||||
display: "block",
|
||||
marginTop: theme.spacing(1),
|
||||
color: theme.palette.error.light,
|
||||
fontSize: theme.spacing(2),
|
||||
},
|
||||
}))
|
||||
|
||||
const useFormFooterStyles = makeStyles((theme) => ({
|
||||
button: {
|
||||
minWidth: theme.spacing(23),
|
||||
|
||||
[theme.breakpoints.down("sm")]: {
|
||||
width: "100%",
|
||||
},
|
||||
},
|
||||
footer: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-start",
|
||||
flexDirection: "row-reverse",
|
||||
gap: theme.spacing(2),
|
||||
|
||||
[theme.breakpoints.down("sm")]: {
|
||||
flexDirection: "column",
|
||||
gap: theme.spacing(1),
|
||||
},
|
||||
},
|
||||
}))
|
90
site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
import { useMachine } from "@xstate/react"
|
||||
import { isApiValidationError } from "api/errors"
|
||||
import { AlertBanner } from "components/AlertBanner/AlertBanner"
|
||||
import { Maybe } from "components/Conditionals/Maybe"
|
||||
import { FullPageHorizontalForm } from "components/FullPageForm/FullPageHorizontalForm"
|
||||
import { Loader } from "components/Loader/Loader"
|
||||
import { Stack } from "components/Stack/Stack"
|
||||
import { useOrganizationId } from "hooks/useOrganizationId"
|
||||
import { FC } from "react"
|
||||
import { Helmet } from "react-helmet-async"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { useNavigate, useSearchParams } from "react-router-dom"
|
||||
import { pageTitle } from "util/page"
|
||||
import { createTemplateMachine } from "xServices/createTemplate/createTemplateXService"
|
||||
import { CreateTemplateForm } from "./CreateTemplateForm"
|
||||
|
||||
const CreateTemplatePage: FC = () => {
|
||||
const { t } = useTranslation("createTemplatePage")
|
||||
const navigate = useNavigate()
|
||||
const organizationId = useOrganizationId()
|
||||
const [searchParams] = useSearchParams()
|
||||
const [state, send] = useMachine(createTemplateMachine, {
|
||||
context: {
|
||||
organizationId,
|
||||
exampleId: searchParams.get("exampleId"),
|
||||
},
|
||||
actions: {
|
||||
onCreate: (_, { data }) => {
|
||||
navigate(`/templates/${data.name}`)
|
||||
},
|
||||
},
|
||||
})
|
||||
const { starterTemplate, parameters, error, file, jobError, jobLogs } =
|
||||
state.context
|
||||
const shouldDisplayForm = !state.hasTag("loading")
|
||||
|
||||
const onCancel = () => {
|
||||
navigate(-1)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{pageTitle(t("title"))}</title>
|
||||
</Helmet>
|
||||
|
||||
<FullPageHorizontalForm title={t("title")} onCancel={onCancel}>
|
||||
<Maybe condition={state.hasTag("loading")}>
|
||||
<Loader />
|
||||
</Maybe>
|
||||
|
||||
<Stack spacing={6}>
|
||||
<Maybe condition={Boolean(error && !isApiValidationError(error))}>
|
||||
<AlertBanner error={error} severity="error" />
|
||||
</Maybe>
|
||||
|
||||
{shouldDisplayForm && (
|
||||
<CreateTemplateForm
|
||||
error={error}
|
||||
starterTemplate={starterTemplate}
|
||||
isSubmitting={state.hasTag("submitting")}
|
||||
parameters={parameters}
|
||||
onCancel={onCancel}
|
||||
onSubmit={(data) => {
|
||||
send({
|
||||
type: "CREATE",
|
||||
data,
|
||||
})
|
||||
}}
|
||||
upload={{
|
||||
file,
|
||||
isUploading: state.matches("uploading"),
|
||||
onRemove: () => {
|
||||
send("REMOVE_FILE")
|
||||
},
|
||||
onUpload: (file) => {
|
||||
send({ type: "UPLOAD_FILE", file })
|
||||
},
|
||||
}}
|
||||
jobError={jobError}
|
||||
logs={jobLogs}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</FullPageHorizontalForm>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default CreateTemplatePage
|
185
site/src/pages/CreateTemplatePage/TemplateUpload.tsx
Normal file
@ -0,0 +1,185 @@
|
||||
import { makeStyles } from "@material-ui/core/styles"
|
||||
import { Stack } from "components/Stack/Stack"
|
||||
import { FC, DragEvent, useRef } from "react"
|
||||
import UploadIcon from "@material-ui/icons/CloudUploadOutlined"
|
||||
import { useClickable } from "hooks/useClickable"
|
||||
import CircularProgress from "@material-ui/core/CircularProgress"
|
||||
import { combineClasses } from "util/combineClasses"
|
||||
import IconButton from "@material-ui/core/IconButton"
|
||||
import RemoveIcon from "@material-ui/icons/DeleteOutline"
|
||||
import FileIcon from "@material-ui/icons/FolderOutlined"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import Link from "@material-ui/core/Link"
|
||||
import { Link as RouterLink } from "react-router-dom"
|
||||
|
||||
const useTarDrop = (
|
||||
callback: (file: File) => void,
|
||||
): {
|
||||
onDragOver: (e: DragEvent<HTMLDivElement>) => void
|
||||
onDrop: (e: DragEvent<HTMLDivElement>) => void
|
||||
} => {
|
||||
const onDragOver = (e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
const onDrop = (e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault()
|
||||
const file = e.dataTransfer.files[0]
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- file can be undefined
|
||||
if (!file || file.type !== "application/x-tar") {
|
||||
return
|
||||
}
|
||||
callback(file)
|
||||
}
|
||||
|
||||
return {
|
||||
onDragOver,
|
||||
onDrop,
|
||||
}
|
||||
}
|
||||
|
||||
export interface TemplateUploadProps {
|
||||
isUploading: boolean
|
||||
onUpload: (file: File) => void
|
||||
onRemove: () => void
|
||||
file?: File
|
||||
}
|
||||
|
||||
export const TemplateUpload: FC<TemplateUploadProps> = ({
|
||||
isUploading,
|
||||
onUpload,
|
||||
onRemove,
|
||||
file,
|
||||
}) => {
|
||||
const styles = useStyles()
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const tarDrop = useTarDrop(onUpload)
|
||||
const clickable = useClickable(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.click()
|
||||
}
|
||||
})
|
||||
const { t } = useTranslation("createTemplatePage")
|
||||
|
||||
if (!isUploading && file) {
|
||||
return (
|
||||
<Stack
|
||||
className={styles.file}
|
||||
direction="row"
|
||||
justifyContent="space-between"
|
||||
alignItems="center"
|
||||
>
|
||||
<Stack direction="row" alignItems="center">
|
||||
<FileIcon />
|
||||
<span>{file.name}</span>
|
||||
</Stack>
|
||||
|
||||
<IconButton
|
||||
title={t("form.upload.removeFile")}
|
||||
size="small"
|
||||
onClick={onRemove}
|
||||
>
|
||||
<RemoveIcon />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={combineClasses({
|
||||
[styles.root]: true,
|
||||
[styles.disabled]: isUploading,
|
||||
})}
|
||||
{...clickable}
|
||||
{...tarDrop}
|
||||
>
|
||||
<Stack alignItems="center" spacing={1}>
|
||||
{isUploading ? (
|
||||
<CircularProgress size={32} />
|
||||
) : (
|
||||
<UploadIcon className={styles.icon} />
|
||||
)}
|
||||
|
||||
<Stack alignItems="center" spacing={0.5}>
|
||||
<span className={styles.title}>{t("form.upload.title")}</span>
|
||||
<span className={styles.description}>
|
||||
The template has to be a .tar file. You can also use our{" "}
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to="/starter-templates"
|
||||
// Prevent trigger the upload
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
>
|
||||
starter templates
|
||||
</Link>{" "}
|
||||
to getting started with Coder.
|
||||
</span>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
ref={inputRef}
|
||||
className={styles.input}
|
||||
accept=".tar"
|
||||
onChange={(event) => {
|
||||
const file = event.currentTarget.files?.[0]
|
||||
if (file) {
|
||||
onUpload(file)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
root: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
border: `2px dashed ${theme.palette.divider}`,
|
||||
padding: theme.spacing(6),
|
||||
cursor: "pointer",
|
||||
|
||||
"&:hover": {
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
},
|
||||
},
|
||||
|
||||
disabled: {
|
||||
pointerEvents: "none",
|
||||
opacity: 0.75,
|
||||
},
|
||||
|
||||
icon: {
|
||||
fontSize: theme.spacing(8),
|
||||
},
|
||||
|
||||
title: {
|
||||
fontSize: theme.spacing(2),
|
||||
},
|
||||
|
||||
description: {
|
||||
color: theme.palette.text.secondary,
|
||||
textAlign: "center",
|
||||
maxWidth: theme.spacing(50),
|
||||
},
|
||||
|
||||
input: {
|
||||
display: "none",
|
||||
},
|
||||
|
||||
file: {
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
padding: theme.spacing(2),
|
||||
background: theme.palette.background.paper,
|
||||
},
|
||||
}))
|
@ -1,12 +1,12 @@
|
||||
import Avatar from "@material-ui/core/Avatar"
|
||||
import { makeStyles } from "@material-ui/core/styles"
|
||||
import { Template } from "api/typesGenerated"
|
||||
import { Template, TemplateExample } from "api/typesGenerated"
|
||||
import { Stack } from "components/Stack/Stack"
|
||||
import React, { FC } from "react"
|
||||
import { firstLetter } from "util/firstLetter"
|
||||
|
||||
export interface SelectedTemplateProps {
|
||||
template: Template
|
||||
template: Template | TemplateExample
|
||||
}
|
||||
|
||||
export const SelectedTemplate: FC<SelectedTemplateProps> = ({ template }) => {
|
||||
@ -28,7 +28,7 @@ export const SelectedTemplate: FC<SelectedTemplateProps> = ({ template }) => {
|
||||
</div>
|
||||
<Stack direction="column" spacing={0.5}>
|
||||
<span className={styles.templateName}>
|
||||
{template.display_name.length > 0
|
||||
{"display_name" in template && template.display_name.length > 0
|
||||
? template.display_name
|
||||
: template.name}
|
||||
</span>
|
||||
|
@ -0,0 +1,20 @@
|
||||
import { screen } from "@testing-library/react"
|
||||
import {
|
||||
MockTemplateExample,
|
||||
renderWithAuth,
|
||||
waitForLoaderToBeRemoved,
|
||||
} from "testHelpers/renderHelpers"
|
||||
import StarterTemplatePage from "./StarterTemplatePage"
|
||||
|
||||
jest.mock("remark-gfm", () => jest.fn())
|
||||
|
||||
describe("StarterTemplatePage", () => {
|
||||
it("shows the starter template", async () => {
|
||||
renderWithAuth(<StarterTemplatePage />, {
|
||||
route: `/starter-templates/${MockTemplateExample.id}`,
|
||||
path: "/starter-templates/:exampleId",
|
||||
})
|
||||
await waitForLoaderToBeRemoved()
|
||||
expect(screen.getByText(MockTemplateExample.name)).toBeInTheDocument()
|
||||
})
|
||||
})
|
33
site/src/pages/StarterTemplatePage/StarterTemplatePage.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import { useMachine } from "@xstate/react"
|
||||
import { useOrganizationId } from "hooks/useOrganizationId"
|
||||
import { FC } from "react"
|
||||
import { Helmet } from "react-helmet-async"
|
||||
import { useParams } from "react-router-dom"
|
||||
import { pageTitle } from "util/page"
|
||||
import { starterTemplateMachine } from "xServices/starterTemplates/starterTemplateXService"
|
||||
import { StarterTemplatePageView } from "./StarterTemplatePageView"
|
||||
|
||||
const StarterTemplatePage: FC = () => {
|
||||
const { exampleId } = useParams() as { exampleId: string }
|
||||
const organizationId = useOrganizationId()
|
||||
const [state] = useMachine(starterTemplateMachine, {
|
||||
context: {
|
||||
organizationId,
|
||||
exampleId,
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>
|
||||
{pageTitle(state.context.starterTemplate?.name ?? exampleId)}
|
||||
</title>
|
||||
</Helmet>
|
||||
|
||||
<StarterTemplatePageView context={state.context} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default StarterTemplatePage
|
@ -0,0 +1,41 @@
|
||||
import { Story } from "@storybook/react"
|
||||
import {
|
||||
makeMockApiError,
|
||||
MockOrganization,
|
||||
MockTemplateExample,
|
||||
} from "testHelpers/entities"
|
||||
import {
|
||||
StarterTemplatePageView,
|
||||
StarterTemplatePageViewProps,
|
||||
} from "./StarterTemplatePageView"
|
||||
|
||||
export default {
|
||||
title: "pages/StarterTemplatePageView",
|
||||
component: StarterTemplatePageView,
|
||||
}
|
||||
|
||||
const Template: Story<StarterTemplatePageViewProps> = (args) => (
|
||||
<StarterTemplatePageView {...args} />
|
||||
)
|
||||
|
||||
export const Default = Template.bind({})
|
||||
Default.args = {
|
||||
context: {
|
||||
exampleId: MockTemplateExample.id,
|
||||
organizationId: MockOrganization.id,
|
||||
error: undefined,
|
||||
starterTemplate: MockTemplateExample,
|
||||
},
|
||||
}
|
||||
|
||||
export const Error = Template.bind({})
|
||||
Error.args = {
|
||||
context: {
|
||||
exampleId: MockTemplateExample.id,
|
||||
organizationId: MockOrganization.id,
|
||||
error: makeMockApiError({
|
||||
message: `Example ${MockTemplateExample.id} not found.`,
|
||||
}),
|
||||
starterTemplate: undefined,
|
||||
},
|
||||
}
|
115
site/src/pages/StarterTemplatePage/StarterTemplatePageView.tsx
Normal file
@ -0,0 +1,115 @@
|
||||
import Button from "@material-ui/core/Button"
|
||||
import { makeStyles } from "@material-ui/core/styles"
|
||||
import { Loader } from "components/Loader/Loader"
|
||||
import { Margins } from "components/Margins/Margins"
|
||||
import { MemoizedMarkdown } from "components/Markdown/Markdown"
|
||||
import {
|
||||
PageHeader,
|
||||
PageHeaderSubtitle,
|
||||
PageHeaderTitle,
|
||||
} from "components/PageHeader/PageHeader"
|
||||
import { FC } from "react"
|
||||
import { StarterTemplateContext } from "xServices/starterTemplates/starterTemplateXService"
|
||||
import EyeIcon from "@material-ui/icons/VisibilityOutlined"
|
||||
import PlusIcon from "@material-ui/icons/AddOutlined"
|
||||
import { AlertBanner } from "components/AlertBanner/AlertBanner"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Stack } from "components/Stack/Stack"
|
||||
import { Link } from "react-router-dom"
|
||||
|
||||
export interface StarterTemplatePageViewProps {
|
||||
context: StarterTemplateContext
|
||||
}
|
||||
|
||||
export const StarterTemplatePageView: FC<StarterTemplatePageViewProps> = ({
|
||||
context,
|
||||
}) => {
|
||||
const styles = useStyles()
|
||||
const { starterTemplate } = context
|
||||
const { t } = useTranslation("starterTemplatePage")
|
||||
|
||||
if (context.error) {
|
||||
return (
|
||||
<Margins>
|
||||
<AlertBanner error={context.error} severity="error" />
|
||||
</Margins>
|
||||
)
|
||||
}
|
||||
|
||||
if (!starterTemplate) {
|
||||
return <Loader />
|
||||
}
|
||||
|
||||
return (
|
||||
<Margins>
|
||||
<PageHeader
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
component="a"
|
||||
target="_blank"
|
||||
href={starterTemplate.url}
|
||||
rel="noreferrer"
|
||||
startIcon={<EyeIcon />}
|
||||
>
|
||||
{t("actions.viewSourceCode")}
|
||||
</Button>
|
||||
<Button
|
||||
component={Link}
|
||||
to={`/templates/new?exampleId=${starterTemplate.id}`}
|
||||
startIcon={<PlusIcon />}
|
||||
>
|
||||
{t("actions.useTemplate")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Stack direction="row" spacing={3} alignItems="center">
|
||||
<div className={styles.icon}>
|
||||
<img src={starterTemplate.icon} alt="" />
|
||||
</div>
|
||||
<div>
|
||||
<PageHeaderTitle>{starterTemplate.name}</PageHeaderTitle>
|
||||
<PageHeaderSubtitle condensed>
|
||||
{starterTemplate.description}
|
||||
</PageHeaderSubtitle>
|
||||
</div>
|
||||
</Stack>
|
||||
</PageHeader>
|
||||
|
||||
<div className={styles.markdownSection} id="readme">
|
||||
<div className={styles.markdownWrapper}>
|
||||
<MemoizedMarkdown>{starterTemplate.markdown}</MemoizedMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
</Margins>
|
||||
)
|
||||
}
|
||||
|
||||
export const useStyles = makeStyles((theme) => {
|
||||
return {
|
||||
icon: {
|
||||
height: theme.spacing(6),
|
||||
width: theme.spacing(6),
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
|
||||
"& img": {
|
||||
width: "100%",
|
||||
},
|
||||
},
|
||||
|
||||
markdownSection: {
|
||||
background: theme.palette.background.paper,
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
},
|
||||
|
||||
markdownWrapper: {
|
||||
padding: theme.spacing(5, 5, 8),
|
||||
maxWidth: 800,
|
||||
margin: "auto",
|
||||
},
|
||||
}
|
||||
})
|
@ -0,0 +1,20 @@
|
||||
import { screen } from "@testing-library/react"
|
||||
import {
|
||||
MockTemplateExample,
|
||||
MockTemplateExample2,
|
||||
renderWithAuth,
|
||||
waitForLoaderToBeRemoved,
|
||||
} from "testHelpers/renderHelpers"
|
||||
import StarterTemplatesPage from "./StarterTemplatesPage"
|
||||
|
||||
describe("StarterTemplatesPage", () => {
|
||||
it("shows the starter template", async () => {
|
||||
renderWithAuth(<StarterTemplatesPage />, {
|
||||
route: `/starter-templates`,
|
||||
path: "/starter-templates",
|
||||
})
|
||||
await waitForLoaderToBeRemoved()
|
||||
expect(screen.getByText(MockTemplateExample.name)).toBeInTheDocument()
|
||||
expect(screen.getByText(MockTemplateExample2.name)).toBeInTheDocument()
|
||||
})
|
||||
})
|
28
site/src/pages/StarterTemplatesPage/StarterTemplatesPage.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import { useMachine } from "@xstate/react"
|
||||
import { useOrganizationId } from "hooks/useOrganizationId"
|
||||
import { FC } from "react"
|
||||
import { Helmet } from "react-helmet-async"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { pageTitle } from "util/page"
|
||||
import { starterTemplatesMachine } from "xServices/starterTemplates/starterTemplatesXService"
|
||||
import { StarterTemplatesPageView } from "./StarterTemplatesPageView"
|
||||
|
||||
const StarterTemplatesPage: FC = () => {
|
||||
const { t } = useTranslation("starterTemplatesPage")
|
||||
const organizationId = useOrganizationId()
|
||||
const [state] = useMachine(starterTemplatesMachine, {
|
||||
context: { organizationId },
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{pageTitle(t("title"))}</title>
|
||||
</Helmet>
|
||||
|
||||
<StarterTemplatesPageView context={state.context} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default StarterTemplatesPage
|
@ -0,0 +1,44 @@
|
||||
import { Story } from "@storybook/react"
|
||||
import {
|
||||
makeMockApiError,
|
||||
MockOrganization,
|
||||
MockTemplateExample,
|
||||
MockTemplateExample2,
|
||||
} from "testHelpers/entities"
|
||||
import { getTemplatesByTag } from "util/starterTemplates"
|
||||
import {
|
||||
StarterTemplatesPageView,
|
||||
StarterTemplatesPageViewProps,
|
||||
} from "./StarterTemplatesPageView"
|
||||
|
||||
export default {
|
||||
title: "pages/StarterTemplatesPageView",
|
||||
component: StarterTemplatesPageView,
|
||||
}
|
||||
|
||||
const Template: Story<StarterTemplatesPageViewProps> = (args) => (
|
||||
<StarterTemplatesPageView {...args} />
|
||||
)
|
||||
|
||||
export const Default = Template.bind({})
|
||||
Default.args = {
|
||||
context: {
|
||||
organizationId: MockOrganization.id,
|
||||
error: undefined,
|
||||
starterTemplatesByTag: getTemplatesByTag([
|
||||
MockTemplateExample,
|
||||
MockTemplateExample2,
|
||||
]),
|
||||
},
|
||||
}
|
||||
|
||||
export const Error = Template.bind({})
|
||||
Error.args = {
|
||||
context: {
|
||||
organizationId: MockOrganization.id,
|
||||
error: makeMockApiError({
|
||||
message: "Error on loading the template examples",
|
||||
}),
|
||||
starterTemplatesByTag: undefined,
|
||||
},
|
||||
}
|
134
site/src/pages/StarterTemplatesPage/StarterTemplatesPageView.tsx
Normal file
@ -0,0 +1,134 @@
|
||||
import { makeStyles } from "@material-ui/core/styles"
|
||||
import { AlertBanner } from "components/AlertBanner/AlertBanner"
|
||||
import { Maybe } from "components/Conditionals/Maybe"
|
||||
import { Loader } from "components/Loader/Loader"
|
||||
import { Margins } from "components/Margins/Margins"
|
||||
import {
|
||||
PageHeader,
|
||||
PageHeaderSubtitle,
|
||||
PageHeaderTitle,
|
||||
} from "components/PageHeader/PageHeader"
|
||||
import { Stack } from "components/Stack/Stack"
|
||||
import { TemplateExampleCard } from "components/TemplateExampleCard/TemplateExampleCard"
|
||||
import { FC } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Link, useSearchParams } from "react-router-dom"
|
||||
import { combineClasses } from "util/combineClasses"
|
||||
import { StarterTemplatesContext } from "xServices/starterTemplates/starterTemplatesXService"
|
||||
|
||||
const getTagLabel = (tag: string, t: (key: string) => string) => {
|
||||
const labelByTag: Record<string, string> = {
|
||||
all: t("tags.all"),
|
||||
digitalocean: t("tags.digitalocean"),
|
||||
aws: t("tags.aws"),
|
||||
google: t("tags.google"),
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- this can be undefined
|
||||
return labelByTag[tag] ?? tag
|
||||
}
|
||||
|
||||
const selectTags = ({ starterTemplatesByTag }: StarterTemplatesContext) => {
|
||||
return starterTemplatesByTag
|
||||
? Object.keys(starterTemplatesByTag).sort((a, b) => a.localeCompare(b))
|
||||
: undefined
|
||||
}
|
||||
export interface StarterTemplatesPageViewProps {
|
||||
context: StarterTemplatesContext
|
||||
}
|
||||
|
||||
export const StarterTemplatesPageView: FC<StarterTemplatesPageViewProps> = ({
|
||||
context,
|
||||
}) => {
|
||||
const { t } = useTranslation("starterTemplatesPage")
|
||||
const [urlParams] = useSearchParams()
|
||||
const styles = useStyles()
|
||||
const { starterTemplatesByTag } = context
|
||||
const tags = selectTags(context)
|
||||
const activeTag = urlParams.get("tag") ?? "all"
|
||||
const visibleTemplates = starterTemplatesByTag
|
||||
? starterTemplatesByTag[activeTag]
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<Margins>
|
||||
<PageHeader>
|
||||
<PageHeaderTitle>{t("title")}</PageHeaderTitle>
|
||||
<PageHeaderSubtitle>{t("subtitle")}</PageHeaderSubtitle>
|
||||
</PageHeader>
|
||||
|
||||
<Maybe condition={Boolean(context.error)}>
|
||||
<AlertBanner error={context.error} severity="error" />
|
||||
</Maybe>
|
||||
|
||||
<Maybe condition={Boolean(!starterTemplatesByTag)}>
|
||||
<Loader />
|
||||
</Maybe>
|
||||
|
||||
<Stack direction="row" spacing={4}>
|
||||
{starterTemplatesByTag && tags && (
|
||||
<Stack className={styles.filter}>
|
||||
<span className={styles.filterCaption}>{t("filterCaption")}</span>
|
||||
{tags.map((tag) => (
|
||||
<Link
|
||||
key={tag}
|
||||
to={`?tag=${tag}`}
|
||||
className={combineClasses({
|
||||
[styles.tagLink]: true,
|
||||
[styles.tagLinkActive]: tag === activeTag,
|
||||
})}
|
||||
>
|
||||
{getTagLabel(tag, t)} ({starterTemplatesByTag[tag].length})
|
||||
</Link>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<div className={styles.templates}>
|
||||
{visibleTemplates &&
|
||||
visibleTemplates.map((example) => (
|
||||
<TemplateExampleCard example={example} key={example.id} />
|
||||
))}
|
||||
</div>
|
||||
</Stack>
|
||||
</Margins>
|
||||
)
|
||||
}
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
filter: {
|
||||
width: theme.spacing(26),
|
||||
flexShrink: 0,
|
||||
},
|
||||
|
||||
filterCaption: {
|
||||
textTransform: "uppercase",
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
color: theme.palette.text.secondary,
|
||||
letterSpacing: "0.1em",
|
||||
},
|
||||
|
||||
tagLink: {
|
||||
color: theme.palette.text.secondary,
|
||||
textDecoration: "none",
|
||||
fontSize: 14,
|
||||
textTransform: "capitalize",
|
||||
|
||||
"&:hover": {
|
||||
color: theme.palette.text.primary,
|
||||
},
|
||||
},
|
||||
|
||||
tagLinkActive: {
|
||||
color: theme.palette.text.primary,
|
||||
fontWeight: 600,
|
||||
},
|
||||
|
||||
templates: {
|
||||
flex: "1",
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
|
||||
gap: theme.spacing(2),
|
||||
gridAutoRows: "min-content",
|
||||
},
|
||||
}))
|
161
site/src/pages/TemplatesPage/EmptyTemplates.tsx
Normal file
@ -0,0 +1,161 @@
|
||||
import Button from "@material-ui/core/Button"
|
||||
import Link from "@material-ui/core/Link"
|
||||
import { makeStyles } from "@material-ui/core/styles"
|
||||
import { Entitlements, TemplateExample } from "api/typesGenerated"
|
||||
import { CodeExample } from "components/CodeExample/CodeExample"
|
||||
import { Stack } from "components/Stack/Stack"
|
||||
import { TableEmpty } from "components/TableEmpty/TableEmpty"
|
||||
import { TemplateExampleCard } from "components/TemplateExampleCard/TemplateExampleCard"
|
||||
import { FC } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Link as RouterLink } from "react-router-dom"
|
||||
import { Permissions } from "xServices/auth/authXService"
|
||||
|
||||
// Those are from https://github.com/coder/coder/tree/main/examples/templates
|
||||
const featuredExamples = [
|
||||
"docker",
|
||||
"kubernetes",
|
||||
"aws-linux",
|
||||
"aws-windows",
|
||||
"gcp-linux",
|
||||
"gcp-windows",
|
||||
]
|
||||
|
||||
const findFeaturedExamples = (examples: TemplateExample[]) => {
|
||||
return examples.filter((example) => featuredExamples.includes(example.id))
|
||||
}
|
||||
|
||||
export const EmptyTemplates: FC<{
|
||||
permissions: Permissions
|
||||
examples: TemplateExample[]
|
||||
entitlements: Entitlements
|
||||
}> = ({ permissions, examples, entitlements }) => {
|
||||
const styles = useStyles()
|
||||
const { t } = useTranslation("templatesPage")
|
||||
const featuredExamples = findFeaturedExamples(examples)
|
||||
|
||||
if (permissions.createTemplates && entitlements.experimental) {
|
||||
return (
|
||||
<TableEmpty
|
||||
message={t("empty.message")}
|
||||
description={
|
||||
<>
|
||||
You can create a template using our starter templates or{" "}
|
||||
<Link component={RouterLink} to="/new">
|
||||
uploading a template
|
||||
</Link>
|
||||
. You can also{" "}
|
||||
<Link
|
||||
href="https://coder.com/docs/coder-oss/latest/templates#add-a-template"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
use the CLI
|
||||
</Link>
|
||||
.
|
||||
</>
|
||||
}
|
||||
cta={
|
||||
<Stack alignItems="center" spacing={4}>
|
||||
<div className={styles.featuredExamples}>
|
||||
{featuredExamples.map((example) => (
|
||||
<TemplateExampleCard
|
||||
example={example}
|
||||
key={example.id}
|
||||
className={styles.template}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="small"
|
||||
component={RouterLink}
|
||||
to="/starter-templates"
|
||||
className={styles.viewAllButton}
|
||||
>
|
||||
View all starter templates
|
||||
</Button>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (permissions.createTemplates) {
|
||||
return (
|
||||
<TableEmpty
|
||||
className={styles.withImage}
|
||||
message={t("empty.message")}
|
||||
description={
|
||||
<>
|
||||
To create a workspace you need to have a template. You can{" "}
|
||||
<Link
|
||||
target="_blank"
|
||||
href="https://coder.com/docs/coder-oss/latest/templates"
|
||||
>
|
||||
create one from scratch
|
||||
</Link>{" "}
|
||||
or use a built-in template using the following Coder CLI command:
|
||||
</>
|
||||
}
|
||||
cta={<CodeExample code="coder templates init" />}
|
||||
image={
|
||||
<div className={styles.emptyImage}>
|
||||
<img src="/featured/templates.webp" alt="" />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<TableEmpty
|
||||
className={styles.withImage}
|
||||
message={t("empty.message")}
|
||||
description={t("empty.descriptionWithoutPermissions")}
|
||||
cta={<CodeExample code="coder templates init" />}
|
||||
image={
|
||||
<div className={styles.emptyImage}>
|
||||
<img src="/featured/templates.webp" alt="" />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
withImage: {
|
||||
paddingBottom: 0,
|
||||
},
|
||||
|
||||
emptyImage: {
|
||||
maxWidth: "50%",
|
||||
height: theme.spacing(40),
|
||||
overflow: "hidden",
|
||||
opacity: 0.85,
|
||||
|
||||
"& img": {
|
||||
maxWidth: "100%",
|
||||
},
|
||||
},
|
||||
|
||||
featuredExamples: {
|
||||
maxWidth: theme.spacing(100),
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
|
||||
gap: theme.spacing(2),
|
||||
gridAutoRows: "min-content",
|
||||
},
|
||||
|
||||
template: {
|
||||
backgroundColor: theme.palette.background.paperLight,
|
||||
|
||||
"&:hover": {
|
||||
backgroundColor: theme.palette.divider,
|
||||
},
|
||||
},
|
||||
|
||||
viewAllButton: {
|
||||
borderRadius: 9999,
|
||||
},
|
||||
}))
|
@ -2,17 +2,18 @@ import { screen } from "@testing-library/react"
|
||||
import { rest } from "msw"
|
||||
import * as CreateDayString from "util/createDayString"
|
||||
import { MockTemplate } from "../../testHelpers/entities"
|
||||
import { history, render } from "../../testHelpers/renderHelpers"
|
||||
import { renderWithAuth } from "../../testHelpers/renderHelpers"
|
||||
import { server } from "../../testHelpers/server"
|
||||
import { TemplatesPage } from "./TemplatesPage"
|
||||
import { Language } from "./TemplatesPageView"
|
||||
import i18next from "i18next"
|
||||
|
||||
const { t } = i18next
|
||||
|
||||
describe("TemplatesPage", () => {
|
||||
beforeEach(() => {
|
||||
// Mocking the dayjs module within the createDayString file
|
||||
const mock = jest.spyOn(CreateDayString, "createDayString")
|
||||
mock.mockImplementation(() => "a minute ago")
|
||||
history.replace("/workspaces")
|
||||
})
|
||||
|
||||
it("renders an empty templates page", async () => {
|
||||
@ -35,15 +36,24 @@ describe("TemplatesPage", () => {
|
||||
)
|
||||
|
||||
// When
|
||||
render(<TemplatesPage />)
|
||||
renderWithAuth(<TemplatesPage />, {
|
||||
route: `/templates`,
|
||||
path: "/templates",
|
||||
})
|
||||
|
||||
// Then
|
||||
await screen.findByText(Language.emptyMessage)
|
||||
const emptyMessage = t("empty.message", {
|
||||
ns: "templatesPage",
|
||||
})
|
||||
await screen.findByText(emptyMessage)
|
||||
})
|
||||
|
||||
it("renders a filled templates page", async () => {
|
||||
// When
|
||||
render(<TemplatesPage />)
|
||||
renderWithAuth(<TemplatesPage />, {
|
||||
route: `/templates`,
|
||||
path: "/templates",
|
||||
})
|
||||
|
||||
// Then
|
||||
await screen.findByText(MockTemplate.display_name)
|
||||
@ -68,9 +78,14 @@ describe("TemplatesPage", () => {
|
||||
)
|
||||
|
||||
// When
|
||||
render(<TemplatesPage />)
|
||||
|
||||
renderWithAuth(<TemplatesPage />, {
|
||||
route: `/templates`,
|
||||
path: "/templates",
|
||||
})
|
||||
// Then
|
||||
await screen.findByText(Language.emptyViewNoPerms)
|
||||
const emptyMessage = t("empty.descriptionWithoutPermissions", {
|
||||
ns: "templatesPage",
|
||||
})
|
||||
await screen.findByText(emptyMessage)
|
||||
})
|
||||
})
|
||||
|
@ -1,17 +1,23 @@
|
||||
import { useActor, useMachine } from "@xstate/react"
|
||||
import React, { useContext } from "react"
|
||||
import { useMachine } from "@xstate/react"
|
||||
import { useEntitlements } from "hooks/useEntitlements"
|
||||
import { useOrganizationId } from "hooks/useOrganizationId"
|
||||
import { usePermissions } from "hooks/usePermissions"
|
||||
import React from "react"
|
||||
import { Helmet } from "react-helmet-async"
|
||||
import { pageTitle } from "../../util/page"
|
||||
import { XServiceContext } from "../../xServices/StateContext"
|
||||
import { templatesMachine } from "../../xServices/templates/templatesXService"
|
||||
import { TemplatesPageView } from "./TemplatesPageView"
|
||||
|
||||
export const TemplatesPage: React.FC = () => {
|
||||
const xServices = useContext(XServiceContext)
|
||||
const [authState] = useActor(xServices.authXService)
|
||||
const [templatesState] = useMachine(templatesMachine)
|
||||
const { templates, getOrganizationsError, getTemplatesError } =
|
||||
templatesState.context
|
||||
const organizationId = useOrganizationId()
|
||||
const permissions = usePermissions()
|
||||
const entitlements = useEntitlements()
|
||||
const [templatesState] = useMachine(templatesMachine, {
|
||||
context: {
|
||||
organizationId,
|
||||
permissions,
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -19,11 +25,8 @@ export const TemplatesPage: React.FC = () => {
|
||||
<title>{pageTitle("Templates")}</title>
|
||||
</Helmet>
|
||||
<TemplatesPageView
|
||||
templates={templates}
|
||||
canCreateTemplate={authState.context.permissions?.createTemplates}
|
||||
loading={templatesState.hasTag("loading")}
|
||||
getOrganizationsError={getOrganizationsError}
|
||||
getTemplatesError={getTemplatesError}
|
||||
context={templatesState.context}
|
||||
entitlements={entitlements}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
@ -1,5 +1,13 @@
|
||||
import { ComponentMeta, Story } from "@storybook/react"
|
||||
import { makeMockApiError, MockTemplate } from "../../testHelpers/entities"
|
||||
import {
|
||||
makeMockApiError,
|
||||
MockEntitlements,
|
||||
MockOrganization,
|
||||
MockPermissions,
|
||||
MockTemplate,
|
||||
MockTemplateExample,
|
||||
MockTemplateExample2,
|
||||
} from "../../testHelpers/entities"
|
||||
import { TemplatesPageView, TemplatesPageViewProps } from "./TemplatesPageView"
|
||||
|
||||
export default {
|
||||
@ -11,9 +19,13 @@ const Template: Story<TemplatesPageViewProps> = (args) => (
|
||||
<TemplatesPageView {...args} />
|
||||
)
|
||||
|
||||
export const AllStates = Template.bind({})
|
||||
AllStates.args = {
|
||||
canCreateTemplate: true,
|
||||
export const WithTemplates = Template.bind({})
|
||||
WithTemplates.args = {
|
||||
entitlements: MockEntitlements,
|
||||
context: {
|
||||
organizationId: MockOrganization.id,
|
||||
permissions: MockPermissions,
|
||||
error: undefined,
|
||||
templates: [
|
||||
MockTemplate,
|
||||
{
|
||||
@ -34,27 +46,73 @@ AllStates.args = {
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. ",
|
||||
},
|
||||
],
|
||||
examples: [],
|
||||
},
|
||||
}
|
||||
|
||||
export const SmallViewport = Template.bind({})
|
||||
SmallViewport.args = {
|
||||
...AllStates.args,
|
||||
export const WithTemplatesSmallViewPort = Template.bind({})
|
||||
WithTemplatesSmallViewPort.args = {
|
||||
...WithTemplates.args,
|
||||
}
|
||||
SmallViewport.parameters = {
|
||||
WithTemplatesSmallViewPort.parameters = {
|
||||
chromatic: { viewports: [600] },
|
||||
}
|
||||
|
||||
export const EmptyCanCreate = Template.bind({})
|
||||
EmptyCanCreate.args = {
|
||||
canCreateTemplate: true,
|
||||
entitlements: MockEntitlements,
|
||||
context: {
|
||||
organizationId: MockOrganization.id,
|
||||
permissions: MockPermissions,
|
||||
error: undefined,
|
||||
templates: [],
|
||||
examples: [MockTemplateExample, MockTemplateExample2],
|
||||
},
|
||||
}
|
||||
|
||||
export const EmptyCanCreateExperimental = Template.bind({})
|
||||
EmptyCanCreateExperimental.args = {
|
||||
entitlements: {
|
||||
...MockEntitlements,
|
||||
experimental: true,
|
||||
},
|
||||
context: {
|
||||
organizationId: MockOrganization.id,
|
||||
permissions: MockPermissions,
|
||||
error: undefined,
|
||||
templates: [],
|
||||
examples: [MockTemplateExample, MockTemplateExample2],
|
||||
},
|
||||
}
|
||||
|
||||
export const EmptyCannotCreate = Template.bind({})
|
||||
EmptyCannotCreate.args = {}
|
||||
EmptyCannotCreate.args = {
|
||||
entitlements: MockEntitlements,
|
||||
context: {
|
||||
organizationId: MockOrganization.id,
|
||||
permissions: {
|
||||
...MockPermissions,
|
||||
createTemplates: false,
|
||||
},
|
||||
error: undefined,
|
||||
templates: [],
|
||||
examples: [MockTemplateExample, MockTemplateExample2],
|
||||
},
|
||||
}
|
||||
|
||||
export const Error = Template.bind({})
|
||||
Error.args = {
|
||||
getTemplatesError: makeMockApiError({
|
||||
entitlements: MockEntitlements,
|
||||
context: {
|
||||
organizationId: MockOrganization.id,
|
||||
permissions: {
|
||||
...MockPermissions,
|
||||
createTemplates: false,
|
||||
},
|
||||
error: makeMockApiError({
|
||||
message: "Something went wrong fetching templates.",
|
||||
}),
|
||||
templates: undefined,
|
||||
examples: undefined,
|
||||
},
|
||||
}
|
||||
|
@ -1,3 +1,4 @@
|
||||
import Button from "@material-ui/core/Button"
|
||||
import Link from "@material-ui/core/Link"
|
||||
import { makeStyles, Theme } from "@material-ui/core/styles"
|
||||
import Table from "@material-ui/core/Table"
|
||||
@ -7,22 +8,19 @@ import TableContainer from "@material-ui/core/TableContainer"
|
||||
import TableHead from "@material-ui/core/TableHead"
|
||||
import TableRow from "@material-ui/core/TableRow"
|
||||
import KeyboardArrowRight from "@material-ui/icons/KeyboardArrowRight"
|
||||
import AddIcon from "@material-ui/icons/AddOutlined"
|
||||
import useTheme from "@material-ui/styles/useTheme"
|
||||
import { AlertBanner } from "components/AlertBanner/AlertBanner"
|
||||
import { ChooseOne, Cond } from "components/Conditionals/ChooseOne"
|
||||
import { Maybe } from "components/Conditionals/Maybe"
|
||||
import { TableEmpty } from "components/TableEmpty/TableEmpty"
|
||||
import { FC } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { useNavigate, Link as RouterLink } from "react-router-dom"
|
||||
import { createDayString } from "util/createDayString"
|
||||
import {
|
||||
formatTemplateBuildTime,
|
||||
formatTemplateActiveDevelopers,
|
||||
} from "util/templates"
|
||||
import * as TypesGen from "../../api/typesGenerated"
|
||||
import { AvatarData } from "../../components/AvatarData/AvatarData"
|
||||
import { CodeExample } from "../../components/CodeExample/CodeExample"
|
||||
import { Margins } from "../../components/Margins/Margins"
|
||||
import {
|
||||
PageHeader,
|
||||
@ -39,6 +37,9 @@ import {
|
||||
HelpTooltipText,
|
||||
HelpTooltipTitle,
|
||||
} from "../../components/Tooltips/HelpTooltip/HelpTooltip"
|
||||
import { EmptyTemplates } from "./EmptyTemplates"
|
||||
import { TemplatesContext } from "xServices/templates/templatesXService"
|
||||
import { Entitlements } from "api/typesGenerated"
|
||||
|
||||
export const Language = {
|
||||
developerCount: (activeCount: number): string => {
|
||||
@ -50,21 +51,6 @@ export const Language = {
|
||||
buildTimeLabel: "Build time",
|
||||
usedByLabel: "Used by",
|
||||
lastUpdatedLabel: "Last updated",
|
||||
emptyViewNoPerms:
|
||||
"Contact your Coder administrator to create a template. You can share the code below.",
|
||||
emptyMessage: "Create your first template",
|
||||
emptyDescription: (
|
||||
<>
|
||||
To create a workspace you need to have a template. You can{" "}
|
||||
<Link
|
||||
target="_blank"
|
||||
href="https://coder.com/docs/coder-oss/latest/templates"
|
||||
>
|
||||
create one from scratch
|
||||
</Link>{" "}
|
||||
or use a built-in template using the following Coder CLI command:
|
||||
</>
|
||||
),
|
||||
templateTooltipTitle: "What is template?",
|
||||
templateTooltipText:
|
||||
"With templates you can create a common configuration for your workspaces using Terraform.",
|
||||
@ -87,41 +73,46 @@ const TemplateHelpTooltip: React.FC = () => {
|
||||
}
|
||||
|
||||
export interface TemplatesPageViewProps {
|
||||
loading?: boolean
|
||||
canCreateTemplate?: boolean
|
||||
templates?: TypesGen.Template[]
|
||||
getOrganizationsError?: Error | unknown
|
||||
getTemplatesError?: Error | unknown
|
||||
context: TemplatesContext
|
||||
entitlements: Entitlements
|
||||
}
|
||||
|
||||
export const TemplatesPageView: FC<
|
||||
React.PropsWithChildren<TemplatesPageViewProps>
|
||||
> = (props) => {
|
||||
> = ({ context, entitlements }) => {
|
||||
const styles = useStyles()
|
||||
const navigate = useNavigate()
|
||||
const { t } = useTranslation("templatesPage")
|
||||
const theme: Theme = useTheme()
|
||||
const empty =
|
||||
!props.loading &&
|
||||
!props.getOrganizationsError &&
|
||||
!props.getTemplatesError &&
|
||||
!props.templates?.length
|
||||
const { templates, error, examples, permissions } = context
|
||||
const isLoading = !templates
|
||||
const isEmpty = Boolean(templates && templates.length === 0)
|
||||
|
||||
return (
|
||||
<Margins>
|
||||
<PageHeader>
|
||||
<PageHeader
|
||||
actions={
|
||||
<Maybe
|
||||
condition={entitlements.experimental && permissions.createTemplates}
|
||||
>
|
||||
<Button component={RouterLink} to="/starter-templates">
|
||||
Starter templates
|
||||
</Button>
|
||||
<Button startIcon={<AddIcon />} component={RouterLink} to="new">
|
||||
Add template
|
||||
</Button>
|
||||
</Maybe>
|
||||
}
|
||||
>
|
||||
<PageHeaderTitle>
|
||||
<Stack spacing={1} direction="row" alignItems="center">
|
||||
Templates
|
||||
<TemplateHelpTooltip />
|
||||
</Stack>
|
||||
</PageHeaderTitle>
|
||||
<Maybe
|
||||
condition={Boolean(props.templates && props.templates.length > 0)}
|
||||
>
|
||||
<Maybe condition={Boolean(templates && templates.length > 0)}>
|
||||
<PageHeaderSubtitle>
|
||||
Choose a template to create a new workspace
|
||||
{props.canCreateTemplate ? (
|
||||
{permissions.createTemplates ? (
|
||||
<>
|
||||
, or{" "}
|
||||
<Link
|
||||
@ -140,20 +131,10 @@ export const TemplatesPageView: FC<
|
||||
</PageHeader>
|
||||
|
||||
<ChooseOne>
|
||||
<Cond condition={Boolean(props.getOrganizationsError)}>
|
||||
<AlertBanner
|
||||
severity="error"
|
||||
error={props.getOrganizationsError}
|
||||
text={t("errors.getOrganizationsError")}
|
||||
/>
|
||||
</Cond>
|
||||
<Cond condition={Boolean(props.getTemplatesError)}>
|
||||
<AlertBanner
|
||||
severity="error"
|
||||
error={props.getTemplatesError}
|
||||
text={t("errors.getTemplatesError")}
|
||||
/>
|
||||
<Cond condition={Boolean(error)}>
|
||||
<AlertBanner severity="error" error={error} />
|
||||
</Cond>
|
||||
|
||||
<Cond>
|
||||
<TableContainer>
|
||||
<Table>
|
||||
@ -168,30 +149,21 @@ export const TemplatesPageView: FC<
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
<Maybe condition={Boolean(props.loading)}>
|
||||
<Maybe condition={isLoading}>
|
||||
<TableLoader />
|
||||
</Maybe>
|
||||
|
||||
<ChooseOne>
|
||||
<Cond condition={empty}>
|
||||
<TableEmpty
|
||||
className={styles.empty}
|
||||
message={Language.emptyMessage}
|
||||
description={
|
||||
props.canCreateTemplate
|
||||
? Language.emptyDescription
|
||||
: Language.emptyViewNoPerms
|
||||
}
|
||||
cta={<CodeExample code="coder templates init" />}
|
||||
image={
|
||||
<div className={styles.emptyImage}>
|
||||
<img src="/featured/templates.webp" alt="" />
|
||||
</div>
|
||||
}
|
||||
<Cond condition={isEmpty}>
|
||||
<EmptyTemplates
|
||||
permissions={permissions}
|
||||
examples={examples ?? []}
|
||||
entitlements={entitlements}
|
||||
/>
|
||||
</Cond>
|
||||
|
||||
<Cond>
|
||||
{props.templates?.map((template) => {
|
||||
{templates?.map((template) => {
|
||||
const templatePageLink = `/templates/${template.name}`
|
||||
const hasIcon = template.icon && template.icon !== ""
|
||||
|
||||
@ -319,18 +291,4 @@ const useStyles = makeStyles((theme) => ({
|
||||
width: "100%",
|
||||
},
|
||||
},
|
||||
empty: {
|
||||
paddingBottom: 0,
|
||||
},
|
||||
|
||||
emptyImage: {
|
||||
maxWidth: "50%",
|
||||
height: theme.spacing(40),
|
||||
overflow: "hidden",
|
||||
opacity: 0.85,
|
||||
|
||||
"& img": {
|
||||
maxWidth: "100%",
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
@ -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,
|
||||
}
|
||||
|
@ -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) => {
|
||||
|
@ -1,8 +1,4 @@
|
||||
import {
|
||||
hasApiFieldErrors,
|
||||
isApiError,
|
||||
mapApiErrorToFieldErrors,
|
||||
} from "api/errors"
|
||||
import { isApiValidationError, mapApiErrorToFieldErrors } from "api/errors"
|
||||
import { FormikContextType, FormikErrors, getIn } from "formik"
|
||||
import {
|
||||
ChangeEvent,
|
||||
@ -44,10 +40,11 @@ export const getFormHelpers =
|
||||
HelperText: ReactNode = "",
|
||||
backendErrorName?: string,
|
||||
): FormHelpers => {
|
||||
const apiValidationErrors =
|
||||
isApiError(error) && hasApiFieldErrors(error)
|
||||
const apiValidationErrors = isApiValidationError(error)
|
||||
? (mapApiErrorToFieldErrors(error.response.data) as FormikErrors<T>)
|
||||
: error
|
||||
: // This should not return the error since it is not and api validation error but I didn't have time to fix this and tests
|
||||
error
|
||||
|
||||
if (typeof name !== "string") {
|
||||
throw new Error(
|
||||
`name must be type of string, instead received '${typeof name}'`,
|
||||
@ -62,6 +59,7 @@ export const getFormHelpers =
|
||||
const apiError = getIn(apiValidationErrors, apiErrorName)
|
||||
const frontendError = getIn(form.errors, name)
|
||||
const returnError = apiError ?? frontendError
|
||||
|
||||
return {
|
||||
...form.getFieldProps(name),
|
||||
id: name,
|
||||
|
24
site/src/util/starterTemplates.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { TemplateExample } from "api/typesGenerated"
|
||||
|
||||
export type StarterTemplatesByTag = Record<string, TemplateExample[]>
|
||||
|
||||
export const getTemplatesByTag = (
|
||||
templates: TemplateExample[],
|
||||
): StarterTemplatesByTag => {
|
||||
const tags: StarterTemplatesByTag = {
|
||||
all: templates,
|
||||
}
|
||||
|
||||
templates.forEach((template) => {
|
||||
template.tags.forEach((tag) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- this can be undefined
|
||||
if (tags[tag]) {
|
||||
tags[tag].push(template)
|
||||
} else {
|
||||
tags[tag] = [template]
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return tags
|
||||
}
|
418
site/src/xServices/createTemplate/createTemplateXService.ts
Normal file
@ -0,0 +1,418 @@
|
||||
import {
|
||||
getTemplateExamples,
|
||||
createTemplateVersion,
|
||||
getTemplateVersion,
|
||||
createTemplate,
|
||||
getTemplateVersionSchema,
|
||||
uploadTemplateFile,
|
||||
getTemplateVersionLogs,
|
||||
} from "api/api"
|
||||
import {
|
||||
CreateTemplateVersionRequest,
|
||||
ParameterSchema,
|
||||
ProvisionerJobLog,
|
||||
Template,
|
||||
TemplateExample,
|
||||
TemplateVersion,
|
||||
UploadResponse,
|
||||
} from "api/typesGenerated"
|
||||
import { displayError } from "components/GlobalSnackbar/utils"
|
||||
import { assign, createMachine } from "xstate"
|
||||
|
||||
// for creating a new template:
|
||||
// 1. upload template tar or use the example ID
|
||||
// 2. create template version
|
||||
// 3. wait for it to complete
|
||||
// 4. if the job failed with the missing parameter error then:
|
||||
// a. prompt for params
|
||||
// b. create template version again with the same file hash
|
||||
// c. wait for it to complete
|
||||
// 5.create template with the successful template version ID
|
||||
// https://github.com/coder/coder/blob/b6703b11c6578b2f91a310d28b6a7e57f0069be6/cli/templatecreate.go#L169-L170
|
||||
|
||||
export interface CreateTemplateData {
|
||||
name: string
|
||||
display_name: string
|
||||
description: string
|
||||
icon: string
|
||||
default_ttl_hours: number
|
||||
allow_user_cancel_workspace_jobs: boolean
|
||||
parameter_values_by_name?: Record<string, string>
|
||||
}
|
||||
interface CreateTemplateContext {
|
||||
organizationId: string
|
||||
error?: unknown
|
||||
jobError?: string
|
||||
jobLogs?: ProvisionerJobLog[]
|
||||
starterTemplate?: TemplateExample
|
||||
exampleId?: string | null // It can be null because it is being passed from query string
|
||||
version?: TemplateVersion
|
||||
templateData?: CreateTemplateData
|
||||
parameters?: ParameterSchema[]
|
||||
// file is used in the FE to show the filename and some other visual stuff
|
||||
// uploadedFile is the response from the server to use in the API
|
||||
file?: File
|
||||
uploadResponse?: UploadResponse
|
||||
}
|
||||
|
||||
export const createTemplateMachine =
|
||||
/** @xstate-layout N4IgpgJg5mDOIC5QGMBOYCGAXMAVMAtgA4A22YAdLFhqlgJYB2UAxANoAMAuoqEQPax6Dfo14gAHogCMAVgDMFDgA5ZATgBM86dO3z5agDQgAnoi0B2CmosA2WbNsGOF5WpsBfD8bSYc+YjIcKho6JlY2aR4kEAEhETEYqQQ5RRV1LR09A2MzBGVpClkOEo5pABZlZVsNNWllLx90cgDScgoSfgwIcIBlUJxUVqCwFghRSiYAN34Aa0pfFsI24M7uvoGwIeWRhGn+ZGx6UU4uU-E44WPE0GSaxQsLeXLZaTVntQVc8xKlUo59LZbHUXhZGiBFv4du01j1mP1aINhuQWFtUPxUBQVgAzDEECiQvDQ1ZdOFQBF0LbInB7RgzQ4JU7nGKXBLiO5aCiPZ6vd7lT7yb4IWqKF7aN5ucrSYEcWTgwnUyYQEijADCACUAKIAQVwmuZfEEV1E7MQsg0QulHHKFBqxXcNhqWnk8uaUMC7XoytGAFUAAoAGQA8tqACIAfQAYgBJAP67gXI1spLmV5c57yCxyWzWmwOIXyarWDg1R4lDTKDQaaSuvxEj3BL0qlhagCyQYAapqo7H49FDfFrqaEPIOBobQK9C4LLVZELXjanAZ3Pz3LUwd4IW76ytKABXUik8JjCYUfbzAnbxUUA+w8K0+lHE7cA2xJNDlPCtNPcqZ7O5ix81MRBKkUeR1ClcoSlHKVbFrJYG33Q91mYVFUHRTEcTxS862vW8j2YB8DifRgmQTFl3xNT8q3kDQKG0DQs1eJxZHKec7AoAoNAUWVR1qODNwVYkFjdcIcKOZhI3oVBqA7LYhFEE9GEmOk5hE3DhPEhhmC08IpJkrA5Jk64iIZa4yP7N9Byo24QLkIo7Q4NRlABJ55CcS1rVFMdpVAysnI3JoNMQ3SdMhPTpNk+TrjQjCsSCXFUHxISQvCsLRMkyLDOi0RTJIizE2sm5JHMbi6IYpjpXAtjgIQYErGrZQLBsconm45QXUEq9NLSqAKAAdwwK5JIxAApfgACNcH4AAhMBVX4QIwBwCAlJUmYLxS3dQr6wbhqgSMxsm6a5oWpaVryxkX3IgdjWK5JpDHG0oJqNQnPKCtWMtCorA+t4HEqDqXE6oKEO23qBqG7SDqOqbZvmxbSGWyA1rPVTNu61KMt2qG9Nhk6EfOyBLvMl8okKu7hyrc16OkRi5Cqr7auajhbSzDqK0zJiBNB91wexyH9sO1Bxrh07EZVFbUfPdSwZGHbBeh4XRYJs6kYu-YzOfM4NEs1kP1slInooF7anez6aryao6LUWwmuUSpzXNOR4L5+WIb2pX8fhtXJZRtEMXi7BEuSzH+b8MTPbxkXjp9iXkYgEntdffWbJK4Vx3KunKpYy3ECqG06f5fl3P5QKt2C8OJL6u9mFbehYCEZg-VoDACGRmTpfR2W3faCHa6gevG-CFvUDbjvYCT0jrr1yj7pkQCrE0Zz1A66pbe+2xClsLM6hLB1bblLrK-dgWB6HpuoFH8fBlgWLA6wpKtJ3U+I508+G8v6-29vqeCoooqVMtBZ3psxaqnksxKAYixSsjEeYVzln3AWRB0TECwN-CeLANQ6j1CnOew56hOQoJnKCspXAKE0JaCsyg2ZvFok4R6VRXYvyQW-PqvUjIKUYAAdWEAACwwbfLuG0e4sOCBDDhOUeH8MEfJP+M8KbJkNlKWQDluJORcpmQEgpapZFsJxBwFZdD2HKLYD6zDrwSOxpw64vCsACNbj-eS99MIJWwltV+1cdo2NEHYhxY8nEyXkWcG6VlKafhUWo+0mi3IeV0b+RQbgHYWBcNbAo5cPGsK8b1RUwi1LP0sQLHJwlgl4MAZ+aQi9rC1FUM5QswJbBCiag1eozUpQzgsB9DQFiepFOxrkgOrjg7uLDp46GO1FSlNCaneeGdaK01AYzPOCAbBWFkK4H6spijViPpuRg-AIBwHEJknAiiDbpwALRGFqhc1RB97n3JBgg3uwRqCInCGctOyQPpNP0dKIEKTCw6H5DvHpIUB4UiRMJT5sz3JWEqbbR4NQgStSeEKasdEZz2CcBWFw7gmpgu2k2MAMKqauFZs8VwTUVAFDUC8IUW99EqCzO5YoZQS6EvlvhFCUBSURItLVKCVgSiuFgjvT4spOVZOhnyw2ORmZPGsC8KC7k3gzlkA0Y+iDxF9LYfpKKxk04zIIesig-1RxuBsBoMoQJPKMVtBWO2GrrWfHKOUKVOq2GK2jirOORMICyvTik4V7xOkHzFAKvIOhrXEMqKYt6FQmqsQ9T3MSH9h7N0cRPQND1Kj6JBPUYEtFKzOW+i8TiK8NBOCxSylNCsUGI3QVm2+OafglmsBUKtegTHKEtAxM1jtzSZmXgSrVLzU3pTYT46R9jZEyVbfkCwlooJqC5GyqojhxzZzrVYthioF0MxtHYDqjxXgA10L8wobqFAMwBtUTVvMxETvYduANADwmG2te2kEXbjGsV7bVNwS8xTPCMTYMcXgvBAA */
|
||||
createMachine(
|
||||
{
|
||||
id: "createTemplate",
|
||||
predictableActionArguments: true,
|
||||
schema: {
|
||||
context: {} as CreateTemplateContext,
|
||||
events: {} as
|
||||
| { type: "CREATE"; data: CreateTemplateData }
|
||||
| { type: "UPLOAD_FILE"; file: File }
|
||||
| { type: "REMOVE_FILE" },
|
||||
services: {} as {
|
||||
uploadFile: {
|
||||
data: UploadResponse
|
||||
}
|
||||
loadStarterTemplate: {
|
||||
data: TemplateExample
|
||||
}
|
||||
createFirstVersion: {
|
||||
data: TemplateVersion
|
||||
}
|
||||
createVersionWithParameters: {
|
||||
data: TemplateVersion
|
||||
}
|
||||
waitForJobToBeCompleted: {
|
||||
data: TemplateVersion
|
||||
}
|
||||
loadParameterSchema: {
|
||||
data: ParameterSchema[]
|
||||
}
|
||||
createTemplate: {
|
||||
data: Template
|
||||
}
|
||||
loadVersionLogs: {
|
||||
data: ProvisionerJobLog[]
|
||||
}
|
||||
},
|
||||
},
|
||||
tsTypes: {} as import("./createTemplateXService.typegen").Typegen0,
|
||||
initial: "starting",
|
||||
states: {
|
||||
starting: {
|
||||
always: [
|
||||
{ target: "loadingStarterTemplate", cond: "isExampleProvided" },
|
||||
{ target: "idle" },
|
||||
],
|
||||
tags: ["loading"],
|
||||
},
|
||||
loadingStarterTemplate: {
|
||||
invoke: {
|
||||
src: "loadStarterTemplate",
|
||||
onDone: {
|
||||
target: "idle",
|
||||
actions: ["assignStarterTemplate"],
|
||||
},
|
||||
onError: {
|
||||
target: "idle",
|
||||
actions: ["assignError"],
|
||||
},
|
||||
},
|
||||
tags: ["loading"],
|
||||
},
|
||||
idle: {
|
||||
on: {
|
||||
CREATE: {
|
||||
target: "creating",
|
||||
actions: ["assignTemplateData"],
|
||||
},
|
||||
UPLOAD_FILE: {
|
||||
actions: ["assignFile"],
|
||||
target: "uploading",
|
||||
cond: "isNotUsingExample",
|
||||
},
|
||||
REMOVE_FILE: {
|
||||
actions: ["removeFile"],
|
||||
cond: "hasFile",
|
||||
},
|
||||
},
|
||||
},
|
||||
uploading: {
|
||||
invoke: {
|
||||
src: "uploadFile",
|
||||
onDone: {
|
||||
target: "idle",
|
||||
actions: ["assignUploadResponse"],
|
||||
},
|
||||
onError: {
|
||||
target: "idle",
|
||||
actions: ["displayUploadError", "removeFile"],
|
||||
},
|
||||
},
|
||||
},
|
||||
creating: {
|
||||
initial: "creatingFirstVersion",
|
||||
states: {
|
||||
creatingFirstVersion: {
|
||||
invoke: {
|
||||
src: "createFirstVersion",
|
||||
onDone: {
|
||||
target: "waitingForJobToBeCompleted",
|
||||
actions: ["assignVersion"],
|
||||
},
|
||||
onError: {
|
||||
actions: ["assignError"],
|
||||
target: "#createTemplate.idle",
|
||||
},
|
||||
},
|
||||
tags: ["submitting"],
|
||||
},
|
||||
waitingForJobToBeCompleted: {
|
||||
invoke: {
|
||||
src: "waitForJobToBeCompleted",
|
||||
onDone: [
|
||||
{
|
||||
target: "loadingMissingParameters",
|
||||
cond: "hasMissingParameters",
|
||||
actions: ["assignVersion"],
|
||||
},
|
||||
{
|
||||
target: "loadingVersionLogs",
|
||||
actions: ["assignJobError", "assignVersion"],
|
||||
cond: "hasFailed",
|
||||
},
|
||||
{ target: "creatingTemplate", actions: ["assignVersion"] },
|
||||
],
|
||||
onError: {
|
||||
target: "#createTemplate.idle",
|
||||
actions: ["assignError"],
|
||||
},
|
||||
},
|
||||
tags: ["submitting"],
|
||||
},
|
||||
loadingVersionLogs: {
|
||||
invoke: {
|
||||
src: "loadVersionLogs",
|
||||
onDone: {
|
||||
target: "#createTemplate.idle",
|
||||
actions: ["assignJobLogs"],
|
||||
},
|
||||
onError: {
|
||||
target: "#createTemplate.idle",
|
||||
actions: ["assignError"],
|
||||
},
|
||||
},
|
||||
},
|
||||
loadingMissingParameters: {
|
||||
invoke: {
|
||||
src: "loadParameterSchema",
|
||||
onDone: {
|
||||
target: "promptParameters",
|
||||
actions: ["assignParameters"],
|
||||
},
|
||||
onError: {
|
||||
target: "#createTemplate.idle",
|
||||
actions: ["assignError"],
|
||||
},
|
||||
},
|
||||
tags: ["submitting"],
|
||||
},
|
||||
promptParameters: {
|
||||
on: {
|
||||
CREATE: {
|
||||
target: "creatingVersionWithParameters",
|
||||
actions: ["assignTemplateData"],
|
||||
},
|
||||
},
|
||||
},
|
||||
creatingVersionWithParameters: {
|
||||
invoke: {
|
||||
src: "createVersionWithParameters",
|
||||
onDone: {
|
||||
target: "waitingForJobToBeCompleted",
|
||||
actions: ["assignVersion"],
|
||||
},
|
||||
onError: {
|
||||
actions: ["assignError"],
|
||||
target: "promptParameters",
|
||||
},
|
||||
},
|
||||
tags: ["submitting"],
|
||||
},
|
||||
creatingTemplate: {
|
||||
invoke: {
|
||||
src: "createTemplate",
|
||||
onDone: {
|
||||
target: "created",
|
||||
actions: ["onCreate"],
|
||||
},
|
||||
onError: {
|
||||
actions: ["assignError"],
|
||||
target: "#createTemplate.idle",
|
||||
},
|
||||
},
|
||||
tags: ["submitting"],
|
||||
},
|
||||
created: {
|
||||
type: "final",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
services: {
|
||||
uploadFile: (_, { file }) => uploadTemplateFile(file),
|
||||
loadStarterTemplate: async ({ organizationId, exampleId }) => {
|
||||
if (!exampleId) {
|
||||
throw new Error(`Example ID is not defined.`)
|
||||
}
|
||||
const examples = await getTemplateExamples(organizationId)
|
||||
const starterTemplate = examples.find(
|
||||
(example) => example.id === exampleId,
|
||||
)
|
||||
if (!starterTemplate) {
|
||||
throw new Error(`Example ${exampleId} not found.`)
|
||||
}
|
||||
return starterTemplate
|
||||
},
|
||||
createFirstVersion: async ({
|
||||
organizationId,
|
||||
exampleId,
|
||||
uploadResponse,
|
||||
}) => {
|
||||
if (exampleId) {
|
||||
return createTemplateVersion(organizationId, {
|
||||
storage_method: "file",
|
||||
example_id: exampleId,
|
||||
provisioner: "terraform",
|
||||
tags: {},
|
||||
})
|
||||
}
|
||||
|
||||
if (uploadResponse) {
|
||||
return createTemplateVersion(organizationId, {
|
||||
storage_method: "file",
|
||||
file_id: uploadResponse.hash,
|
||||
provisioner: "terraform",
|
||||
tags: {},
|
||||
})
|
||||
}
|
||||
|
||||
throw new Error("No file or example provided")
|
||||
},
|
||||
createVersionWithParameters: async ({
|
||||
organizationId,
|
||||
parameters,
|
||||
templateData,
|
||||
version,
|
||||
}) => {
|
||||
if (!version) {
|
||||
throw new Error("No previous version found")
|
||||
}
|
||||
if (!templateData) {
|
||||
throw new Error("No template data defined")
|
||||
}
|
||||
|
||||
const { parameter_values_by_name } = templateData
|
||||
// Get parameter values if they are needed/present
|
||||
const parameterValues: CreateTemplateVersionRequest["parameter_values"] =
|
||||
[]
|
||||
if (parameters) {
|
||||
parameters.forEach((schema) => {
|
||||
const value = parameter_values_by_name?.[schema.name]
|
||||
parameterValues.push({
|
||||
name: schema.name,
|
||||
source_value: value ?? schema.default_source_value,
|
||||
destination_scheme: schema.default_destination_scheme,
|
||||
source_scheme: "data",
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return createTemplateVersion(organizationId, {
|
||||
storage_method: "file",
|
||||
file_id: version.job.file_id,
|
||||
provisioner: "terraform",
|
||||
parameter_values: parameterValues,
|
||||
tags: {},
|
||||
})
|
||||
},
|
||||
waitForJobToBeCompleted: async ({ version }) => {
|
||||
if (!version) {
|
||||
throw new Error("Version not defined")
|
||||
}
|
||||
|
||||
let status = version.job.status
|
||||
while (["pending", "running"].includes(status)) {
|
||||
version = await getTemplateVersion(version.id)
|
||||
status = version.job.status
|
||||
}
|
||||
return version
|
||||
},
|
||||
loadParameterSchema: async ({ version }) => {
|
||||
if (!version) {
|
||||
throw new Error("Version not defined")
|
||||
}
|
||||
|
||||
return getTemplateVersionSchema(version.id)
|
||||
},
|
||||
createTemplate: async ({ organizationId, version, templateData }) => {
|
||||
if (!version) {
|
||||
throw new Error("Version not defined")
|
||||
}
|
||||
|
||||
if (!templateData) {
|
||||
throw new Error("Template data not defined")
|
||||
}
|
||||
|
||||
const {
|
||||
default_ttl_hours,
|
||||
parameter_values_by_name,
|
||||
...safeTemplateData
|
||||
} = templateData
|
||||
|
||||
return createTemplate(organizationId, {
|
||||
...safeTemplateData,
|
||||
default_ttl_ms: templateData.default_ttl_hours * 60 * 60 * 1000, // Convert hours to ms
|
||||
template_version_id: version.id,
|
||||
})
|
||||
},
|
||||
loadVersionLogs: ({ version }) => {
|
||||
if (!version) {
|
||||
throw new Error("Version is not set")
|
||||
}
|
||||
|
||||
return getTemplateVersionLogs(version.id)
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
assignError: assign({ error: (_, { data }) => data }),
|
||||
assignJobError: assign({ jobError: (_, { data }) => data.job.error }),
|
||||
displayUploadError: () => {
|
||||
displayError("Error on upload the file.")
|
||||
},
|
||||
assignStarterTemplate: assign({
|
||||
starterTemplate: (_, { data }) => data,
|
||||
}),
|
||||
assignVersion: assign({ version: (_, { data }) => data }),
|
||||
assignTemplateData: assign({ templateData: (_, { data }) => data }),
|
||||
assignParameters: assign({ parameters: (_, { data }) => data }),
|
||||
assignFile: assign({ file: (_, { file }) => file }),
|
||||
assignUploadResponse: assign({ uploadResponse: (_, { data }) => data }),
|
||||
removeFile: assign({
|
||||
file: (_) => undefined,
|
||||
uploadResponse: (_) => undefined,
|
||||
}),
|
||||
assignJobLogs: assign({ jobLogs: (_, { data }) => data }),
|
||||
},
|
||||
guards: {
|
||||
isExampleProvided: ({ exampleId }) => Boolean(exampleId),
|
||||
isNotUsingExample: ({ exampleId }) => !exampleId,
|
||||
hasFile: ({ file }) => Boolean(file),
|
||||
hasFailed: (_, { data }) => data.job.status === "failed",
|
||||
hasMissingParameters: (_, { data }) =>
|
||||
Boolean(
|
||||
data.job.error && data.job.error.includes("missing parameter"),
|
||||
),
|
||||
},
|
||||
},
|
||||
)
|
@ -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,
|
||||
}),
|
||||
},
|
||||
},
|
||||
)
|
@ -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),
|
||||
}),
|
||||
},
|
||||
},
|
||||
)
|
@ -1,13 +1,14 @@
|
||||
import { Permissions } from "xServices/auth/authXService"
|
||||
import { assign, createMachine } from "xstate"
|
||||
import * as API from "../../api/api"
|
||||
import * as TypesGen from "../../api/typesGenerated"
|
||||
|
||||
interface TemplatesContext {
|
||||
organizations?: TypesGen.Organization[]
|
||||
export interface TemplatesContext {
|
||||
organizationId: string
|
||||
permissions: Permissions
|
||||
templates?: TypesGen.Template[]
|
||||
canCreateTemplate?: boolean
|
||||
getOrganizationsError?: Error | unknown
|
||||
getTemplatesError?: Error | unknown
|
||||
examples?: TypesGen.TemplateExample[]
|
||||
error?: Error | unknown
|
||||
}
|
||||
|
||||
export const templatesMachine = createMachine(
|
||||
@ -18,80 +19,58 @@ export const templatesMachine = createMachine(
|
||||
schema: {
|
||||
context: {} as TemplatesContext,
|
||||
services: {} as {
|
||||
getOrganizations: {
|
||||
data: TypesGen.Organization[]
|
||||
load: {
|
||||
data: {
|
||||
templates: TypesGen.Template[]
|
||||
examples: TypesGen.TemplateExample[]
|
||||
}
|
||||
getTemplates: {
|
||||
data: TypesGen.Template[]
|
||||
}
|
||||
},
|
||||
},
|
||||
initial: "gettingOrganizations",
|
||||
initial: "loading",
|
||||
states: {
|
||||
gettingOrganizations: {
|
||||
entry: "clearGetOrganizationsError",
|
||||
loading: {
|
||||
invoke: {
|
||||
src: "getOrganizations",
|
||||
id: "getOrganizations",
|
||||
src: "load",
|
||||
id: "load",
|
||||
onDone: {
|
||||
actions: ["assignOrganizations"],
|
||||
target: "gettingTemplates",
|
||||
actions: ["assignData"],
|
||||
target: "idle",
|
||||
},
|
||||
onError: {
|
||||
actions: "assignGetOrganizationsError",
|
||||
target: "error",
|
||||
actions: "assignError",
|
||||
target: "idle",
|
||||
},
|
||||
},
|
||||
tags: "loading",
|
||||
},
|
||||
gettingTemplates: {
|
||||
entry: "clearGetTemplatesError",
|
||||
invoke: {
|
||||
src: "getTemplates",
|
||||
id: "getTemplates",
|
||||
onDone: {
|
||||
actions: "assignTemplates",
|
||||
target: "done",
|
||||
idle: {
|
||||
type: "final",
|
||||
},
|
||||
onError: {
|
||||
actions: "assignGetTemplatesError",
|
||||
target: "error",
|
||||
},
|
||||
},
|
||||
tags: "loading",
|
||||
},
|
||||
done: {},
|
||||
error: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
actions: {
|
||||
assignOrganizations: assign({
|
||||
organizations: (_, event) => event.data,
|
||||
assignData: assign({
|
||||
templates: (_, event) => event.data.templates,
|
||||
examples: (_, event) => event.data.examples,
|
||||
}),
|
||||
assignGetOrganizationsError: assign({
|
||||
getOrganizationsError: (_, event) => event.data,
|
||||
assignError: assign({
|
||||
error: (_, { data }) => data,
|
||||
}),
|
||||
clearGetOrganizationsError: assign((context) => ({
|
||||
...context,
|
||||
getOrganizationsError: undefined,
|
||||
})),
|
||||
assignTemplates: assign({
|
||||
templates: (_, event) => event.data,
|
||||
}),
|
||||
assignGetTemplatesError: assign({
|
||||
getTemplatesError: (_, event) => event.data,
|
||||
}),
|
||||
clearGetTemplatesError: (context) =>
|
||||
assign({ ...context, getTemplatesError: undefined }),
|
||||
},
|
||||
services: {
|
||||
getOrganizations: API.getOrganizations,
|
||||
getTemplates: async (context) => {
|
||||
if (!context.organizations || context.organizations.length === 0) {
|
||||
throw new Error("no organizations")
|
||||
load: async ({ organizationId, permissions }) => {
|
||||
const [templates, examples] = await Promise.all([
|
||||
API.getTemplates(organizationId),
|
||||
permissions.createTemplates
|
||||
? API.getTemplateExamples(organizationId)
|
||||
: Promise.resolve([]),
|
||||
])
|
||||
|
||||
return {
|
||||
templates,
|
||||
examples,
|
||||
}
|
||||
return API.getTemplates(context.organizations[0].id)
|
||||
},
|
||||
},
|
||||
},
|
||||
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 4.2 KiB |
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 2.7 KiB |
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 2.0 KiB |
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 5.7 KiB |
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 4.0 KiB |
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 5.2 KiB |