feat(site): Ask for version name and if it is active when publishing a new version on editor (#6756)

This commit is contained in:
Bruno Quaresma
2023-03-27 14:26:57 -03:00
committed by GitHub
parent b439c3e167
commit dd4e1f74ff
11 changed files with 651 additions and 248 deletions

View File

@ -123,6 +123,9 @@ func (api *API) patchTemplateVersion(rw http.ResponseWriter, r *http.Request) {
if errors.Is(err, errTemplateVersionNameConflict) { if errors.Is(err, errTemplateVersionNameConflict) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: err.Error(), Message: err.Error(),
Validations: []codersdk.ValidationError{
{Field: "name", Detail: "Name is already used"},
},
}) })
return return
} }

View File

@ -373,6 +373,17 @@ export const updateActiveTemplateVersion = async (
return response.data return response.data
} }
export const patchTemplateVersion = async (
templateVersionId: string,
data: TypesGen.PatchTemplateVersionRequest,
) => {
const response = await axios.patch<TypesGen.TemplateVersion>(
`/api/v2/templateversions/${templateVersionId}`,
data,
)
return response.data
}
export const updateTemplateMeta = async ( export const updateTemplateMeta = async (
templateId: string, templateId: string,
data: TypesGen.UpdateTemplateMeta, data: TypesGen.UpdateTemplateMeta,
@ -1020,3 +1031,26 @@ const getMissingParameters = (
return missingParameters return missingParameters
} }
export const watchBuildLogs = (
versionId: string,
onMessage: (log: TypesGen.ProvisionerJobLog) => void,
) => {
return new Promise<void>((resolve, reject) => {
const proto = location.protocol === "https:" ? "wss:" : "ws:"
const socket = new WebSocket(
`${proto}//${location.host}/api/v2/templateversions/${versionId}/logs?follow=true`,
)
socket.binaryType = "blob"
socket.addEventListener("message", (event) =>
onMessage(JSON.parse(event.data) as TypesGen.ProvisionerJobLog),
)
socket.addEventListener("error", () => {
reject(new Error("Connection for logs failed."))
})
socket.addEventListener("close", () => {
// When the socket closes, logs have finished streaming!
resolve()
})
})
}

View File

@ -0,0 +1,98 @@
import { DialogProps } from "components/Dialogs/Dialog"
import { FC } from "react"
import { getFormHelpers } from "util/formUtils"
import { FormFields } from "components/Form/Form"
import { useFormik } from "formik"
import * as Yup from "yup"
import { PublishVersionData } from "pages/TemplateVersionPage/TemplateVersionEditorPage/types"
import TextField from "@material-ui/core/TextField"
import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog"
import Checkbox from "@material-ui/core/Checkbox"
import FormControlLabel from "@material-ui/core/FormControlLabel"
import { Stack } from "components/Stack/Stack"
export type PublishTemplateVersionDialogProps = DialogProps & {
defaultName: string
isPublishing: boolean
publishingError?: unknown
onClose: () => void
onConfirm: (data: PublishVersionData) => void
}
export const PublishTemplateVersionDialog: FC<
PublishTemplateVersionDialogProps
> = ({
onConfirm,
isPublishing,
onClose,
defaultName,
publishingError,
...dialogProps
}) => {
const form = useFormik({
initialValues: {
name: defaultName,
isActiveVersion: false,
},
validationSchema: Yup.object({
name: Yup.string().required(),
isActiveVersion: Yup.boolean(),
}),
onSubmit: onConfirm,
})
const getFieldHelpers = getFormHelpers(form, publishingError)
const handleClose = () => {
form.resetForm()
onClose()
}
return (
<ConfirmDialog
{...dialogProps}
confirmLoading={isPublishing}
onClose={handleClose}
onConfirm={async () => {
await form.submitForm()
}}
hideCancel={false}
type="success"
cancelText="Cancel"
confirmText="Publish"
title="Publish new version"
description={
<Stack>
<p>You are about to publish a new version of this template.</p>
<FormFields>
<TextField
{...getFieldHelpers("name")}
label="Version name"
autoFocus
disabled={isPublishing}
InputLabelProps={{
shrink: true,
}}
/>
<FormControlLabel
label="Promote to default version"
control={
<Checkbox
size="small"
checked={form.values.isActiveVersion}
onChange={async (e) => {
await form.setFieldValue(
"isActiveVersion",
e.target.checked,
)
}}
name="isActiveVersion"
color="primary"
/>
}
/>
</FormFields>
</Stack>
}
/>
)
}

View File

@ -16,6 +16,7 @@ import { AvatarData } from "components/AvatarData/AvatarData"
import { bannerHeight } from "components/DeploymentBanner/DeploymentBannerView" import { bannerHeight } from "components/DeploymentBanner/DeploymentBannerView"
import { TemplateResourcesTable } from "components/TemplateResourcesTable/TemplateResourcesTable" import { TemplateResourcesTable } from "components/TemplateResourcesTable/TemplateResourcesTable"
import { WorkspaceBuildLogs } from "components/WorkspaceBuildLogs/WorkspaceBuildLogs" import { WorkspaceBuildLogs } from "components/WorkspaceBuildLogs/WorkspaceBuildLogs"
import { PublishVersionData } from "pages/TemplateVersionPage/TemplateVersionEditorPage/types"
import { FC, useCallback, useEffect, useRef, useState } from "react" import { FC, useCallback, useEffect, useRef, useState } from "react"
import { navHeight, dashboardContentBottomPadding } from "theme/constants" import { navHeight, dashboardContentBottomPadding } from "theme/constants"
import { import {
@ -36,6 +37,7 @@ import {
} from "./FileDialog" } from "./FileDialog"
import { FileTreeView } from "./FileTreeView" import { FileTreeView } from "./FileTreeView"
import { MonacoEditor } from "./MonacoEditor" import { MonacoEditor } from "./MonacoEditor"
import { PublishTemplateVersionDialog } from "./PublishTemplateVersionDialog"
import { import {
getStatus, getStatus,
TemplateVersionStatusBadge, TemplateVersionStatusBadge,
@ -51,7 +53,12 @@ export interface TemplateVersionEditorProps {
disablePreview: boolean disablePreview: boolean
disableUpdate: boolean disableUpdate: boolean
onPreview: (files: FileTree) => void onPreview: (files: FileTree) => void
onUpdate: () => void onPublish: () => void
onConfirmPublish: (data: PublishVersionData) => void
onCancelPublish: () => void
publishingError: unknown
isAskingPublishParameters: boolean
isPublishing: boolean
} }
const topbarHeight = 80 const topbarHeight = 80
@ -76,7 +83,12 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
templateVersion, templateVersion,
defaultFileTree, defaultFileTree,
onPreview, onPreview,
onUpdate, onPublish,
onConfirmPublish,
onCancelPublish,
publishingError,
isAskingPublishParameters,
isPublishing,
buildLogs, buildLogs,
resources, resources,
}) => { }) => {
@ -156,233 +168,249 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
}) })
return ( return (
<div className={styles.root}> <>
<div className={styles.topbar}> <div className={styles.root}>
<div className={styles.topbarSides}> <div className={styles.topbar} data-testid="topbar">
<AvatarData <div className={styles.topbarSides}>
title={template.display_name || template.name} <AvatarData
subtitle={template.description} title={template.display_name || template.name}
avatar={ subtitle={template.description}
hasIcon && ( avatar={
<Avatar src={template.icon} variant="square" fitImage /> hasIcon && (
) <Avatar src={template.icon} variant="square" fitImage />
}
/>
</div>
<div className={styles.topbarSides}>
{/* Only start to show the build when a new template version is building */}
{templateVersion.id !== firstTemplateVersionOnEditor.current.id && (
<div className={styles.buildStatus}>
<TemplateVersionStatusBadge version={templateVersion} />
</div>
)}
<Button
title="Build template (Ctrl + Enter)"
size="small"
variant="outlined"
disabled={disablePreview}
onClick={() => {
triggerPreview()
}}
>
Build template
</Button>
<Button
title={
dirty
? "You have edited files! Run another build before updating."
: templateVersion.job.status !== "succeeded"
? "Something"
: ""
}
size="small"
disabled={dirty || disableUpdate}
onClick={onUpdate}
>
Publish version
</Button>
</div>
</div>
<div className={styles.sidebarAndEditor}>
<div className={styles.sidebar}>
<div className={styles.sidebarTitle}>
Template files
<div className={styles.sidebarActions}>
<Tooltip title="Create File" placement="top">
<IconButton
size="small"
aria-label="Create File"
onClick={(event) => {
setCreateFileOpen(true)
event.currentTarget.blur()
}}
>
<CreateIcon />
</IconButton>
</Tooltip>
</div>
<CreateFileDialog
fileTree={fileTree}
open={createFileOpen}
onClose={() => {
setCreateFileOpen(false)
}}
checkExists={(path) => existsFile(path, fileTree)}
onConfirm={(path) => {
setFileTree((fileTree) => createFile(path, fileTree, ""))
setActivePath(path)
setCreateFileOpen(false)
setDirty(true)
}}
/>
<DeleteFileDialog
onConfirm={() => {
if (!deleteFileOpen) {
throw new Error("delete file must be set")
}
setFileTree((fileTree) => removeFile(deleteFileOpen, fileTree))
setDeleteFileOpen(undefined)
if (activePath === deleteFileOpen) {
setActivePath(undefined)
}
setDirty(true)
}}
open={Boolean(deleteFileOpen)}
onClose={() => setDeleteFileOpen(undefined)}
filename={deleteFileOpen || ""}
/>
<RenameFileDialog
fileTree={fileTree}
open={Boolean(renameFileOpen)}
onClose={() => {
setRenameFileOpen(undefined)
}}
filename={renameFileOpen || ""}
checkExists={(path) => existsFile(path, fileTree)}
onConfirm={(newPath) => {
if (!renameFileOpen) {
return
}
setFileTree((fileTree) =>
moveFile(renameFileOpen, newPath, fileTree),
) )
setActivePath(newPath) }
setRenameFileOpen(undefined)
setDirty(true)
}}
/> />
</div> </div>
<FileTreeView
fileTree={fileTree} <div className={styles.topbarSides}>
onDelete={(file) => setDeleteFileOpen(file)} {/* Only start to show the build when a new template version is building */}
onSelect={(filePath) => { {templateVersion.id !== firstTemplateVersionOnEditor.current.id && (
if (!isFolder(filePath, fileTree)) { <div className={styles.buildStatus}>
setActivePath(filePath) <TemplateVersionStatusBadge version={templateVersion} />
</div>
)}
<Button
title="Build template (Ctrl + Enter)"
size="small"
variant="outlined"
disabled={disablePreview}
onClick={() => {
triggerPreview()
}}
>
Build template
</Button>
<Button
title={
dirty
? "You have edited files! Run another build before updating."
: templateVersion.job.status !== "succeeded"
? "Something"
: ""
} }
}} size="small"
onRename={(file) => setRenameFileOpen(file)} disabled={dirty || disableUpdate}
activePath={activePath} onClick={onPublish}
/> >
Publish version
</Button>
</div>
</div> </div>
<div className={styles.editorPane}> <div className={styles.sidebarAndEditor}>
<div className={styles.editor} data-chromatic="ignore"> <div className={styles.sidebar}>
{activePath ? ( <div className={styles.sidebarTitle}>
<MonacoEditor Template files
value={editorValue} <div className={styles.sidebarActions}>
path={activePath} <Tooltip title="Create File" placement="top">
onChange={(value) => { <IconButton
if (!activePath) { size="small"
return aria-label="Create File"
} onClick={(event) => {
setFileTree((fileTree) => setCreateFileOpen(true)
updateFile(activePath, value, fileTree), event.currentTarget.blur()
) }}
>
<CreateIcon />
</IconButton>
</Tooltip>
</div>
<CreateFileDialog
fileTree={fileTree}
open={createFileOpen}
onClose={() => {
setCreateFileOpen(false)
}}
checkExists={(path) => existsFile(path, fileTree)}
onConfirm={(path) => {
setFileTree((fileTree) => createFile(path, fileTree, ""))
setActivePath(path)
setCreateFileOpen(false)
setDirty(true) setDirty(true)
}} }}
/> />
) : ( <DeleteFileDialog
<div>No file opened</div> onConfirm={() => {
)} if (!deleteFileOpen) {
throw new Error("delete file must be set")
}
setFileTree((fileTree) =>
removeFile(deleteFileOpen, fileTree),
)
setDeleteFileOpen(undefined)
if (activePath === deleteFileOpen) {
setActivePath(undefined)
}
setDirty(true)
}}
open={Boolean(deleteFileOpen)}
onClose={() => setDeleteFileOpen(undefined)}
filename={deleteFileOpen || ""}
/>
<RenameFileDialog
fileTree={fileTree}
open={Boolean(renameFileOpen)}
onClose={() => {
setRenameFileOpen(undefined)
}}
filename={renameFileOpen || ""}
checkExists={(path) => existsFile(path, fileTree)}
onConfirm={(newPath) => {
if (!renameFileOpen) {
return
}
setFileTree((fileTree) =>
moveFile(renameFileOpen, newPath, fileTree),
)
setActivePath(newPath)
setRenameFileOpen(undefined)
setDirty(true)
}}
/>
</div>
<FileTreeView
fileTree={fileTree}
onDelete={(file) => setDeleteFileOpen(file)}
onSelect={(filePath) => {
if (!isFolder(filePath, fileTree)) {
setActivePath(filePath)
}
}}
onRename={(file) => setRenameFileOpen(file)}
activePath={activePath}
/>
</div> </div>
<div className={styles.panelWrapper}> <div className={styles.editorPane}>
<div className={styles.tabs}> <div className={styles.editor} data-chromatic="ignore">
<button {activePath ? (
className={`${styles.tab} ${selectedTab === 0 ? "active" : ""}`} <MonacoEditor
onClick={() => { value={editorValue}
setSelectedTab(0) path={activePath}
}} onChange={(value) => {
> if (!activePath) {
{templateVersion.job.status !== "succeeded" ? ( return
getStatus(templateVersion).icon }
) : ( setFileTree((fileTree) =>
<BuildIcon /> updateFile(activePath, value, fileTree),
)} )
Build Log setDirty(true)
</button> }}
/>
) : (
<div>No file opened</div>
)}
</div>
{!disableUpdate && ( <div className={styles.panelWrapper}>
<div className={styles.tabs}>
<button <button
className={`${styles.tab} ${ className={`${styles.tab} ${
selectedTab === 1 ? "active" : "" selectedTab === 0 ? "active" : ""
}`} }`}
onClick={() => { onClick={() => {
setSelectedTab(1) setSelectedTab(0)
}} }}
> >
<PreviewIcon /> {templateVersion.job.status !== "succeeded" ? (
Workspace Preview getStatus(templateVersion).icon
</button> ) : (
)} <BuildIcon />
</div>
<div
className={`${styles.panel} ${styles.buildLogs} ${
selectedTab === 0 ? "" : "hidden"
}`}
>
{buildLogs && (
<WorkspaceBuildLogs
templateEditorPane
hideTimestamps
logs={buildLogs}
/>
)}
{templateVersion.job.error && (
<div className={styles.buildLogError}>
{templateVersion.job.error}
</div>
)}
</div>
<div
className={`${styles.panel} ${styles.resources} ${
selectedTab === 1 ? "" : "hidden"
}`}
>
{resources && (
<TemplateResourcesTable
resources={resources.filter(
(r) => r.workspace_transition === "start",
)} )}
/> Build Log
)} </button>
</div>
</div>
{templateVersionSucceeded && ( {!disableUpdate && (
<> <button
<div className={styles.panelDivider} /> className={`${styles.tab} ${
</> selectedTab === 1 ? "active" : ""
)} }`}
onClick={() => {
setSelectedTab(1)
}}
>
<PreviewIcon />
Workspace Preview
</button>
)}
</div>
<div
className={`${styles.panel} ${styles.buildLogs} ${
selectedTab === 0 ? "" : "hidden"
}`}
>
{buildLogs && (
<WorkspaceBuildLogs
templateEditorPane
hideTimestamps
logs={buildLogs}
/>
)}
{templateVersion.job.error && (
<div className={styles.buildLogError}>
{templateVersion.job.error}
</div>
)}
</div>
<div
className={`${styles.panel} ${styles.resources} ${
selectedTab === 1 ? "" : "hidden"
}`}
>
{resources && (
<TemplateResourcesTable
resources={resources.filter(
(r) => r.workspace_transition === "start",
)}
/>
)}
</div>
</div>
{templateVersionSucceeded && (
<>
<div className={styles.panelDivider} />
</>
)}
</div>
</div> </div>
</div> </div>
</div>
<PublishTemplateVersionDialog
key={templateVersion.name}
publishingError={publishingError}
open={isAskingPublishParameters || isPublishing}
onClose={onCancelPublish}
onConfirm={onConfirmPublish}
isPublishing={isPublishing}
defaultName={templateVersion.name}
/>
</>
) )
} }

View File

@ -0,0 +1,183 @@
import {
MockTemplateVersion,
MockWorkspaceBuildLogs,
renderWithAuth,
} from "testHelpers/renderHelpers"
import TemplateVersionEditorPage from "./TemplateVersionEditorPage"
import { screen, waitFor, within } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import * as api from "api/api"
// For some reason this component in Jest is throwing a MUI style warning so,
// since we don't need it for this test, we can mock it out
jest.mock("components/TemplateResourcesTable/TemplateResourcesTable", () => {
return {
TemplateResourcesTable: () => <div />,
}
})
test("Use custom name and set it as active when publishing", async () => {
const user = userEvent.setup()
renderWithAuth(<TemplateVersionEditorPage />, {
extraRoutes: [
{
path: "/templates/:templateId",
element: <div />,
},
],
})
const topbar = await screen.findByTestId("topbar")
// Build Template
jest.spyOn(api, "uploadTemplateFile").mockResolvedValueOnce({ hash: "hash" })
jest
.spyOn(api, "createTemplateVersion")
.mockResolvedValueOnce(MockTemplateVersion)
jest
.spyOn(api, "getTemplateVersion")
.mockResolvedValue({ ...MockTemplateVersion, id: "new-version-id" })
jest.spyOn(api, "watchBuildLogs").mockImplementation((_, onMessage) => {
onMessage(MockWorkspaceBuildLogs[0])
return Promise.resolve()
})
const buildButton = within(topbar).getByRole("button", {
name: "Build template",
})
await user.click(buildButton)
// Publish
const patchTemplateVersion = jest
.spyOn(api, "patchTemplateVersion")
.mockResolvedValue(MockTemplateVersion)
const updateActiveTemplateVersion = jest
.spyOn(api, "updateActiveTemplateVersion")
.mockResolvedValue({ message: "" })
await within(topbar).findByText("Success")
const publishButton = within(topbar).getByRole("button", {
name: "Publish version",
})
await user.click(publishButton)
const publishDialog = await screen.findByTestId("dialog")
const nameField = within(publishDialog).getByLabelText("Version name")
await user.clear(nameField)
await user.type(nameField, "v1.0")
await user.click(
within(publishDialog).getByLabelText("Promote to default version"),
)
await user.click(
within(publishDialog).getByRole("button", { name: "Publish" }),
)
await waitFor(() => {
expect(patchTemplateVersion).toBeCalledWith("new-version-id", {
name: "v1.0",
})
expect(updateActiveTemplateVersion).toBeCalledWith("test-template", {
id: "new-version-id",
})
})
})
test("Do not mark as active if promote is not checked", async () => {
const user = userEvent.setup()
renderWithAuth(<TemplateVersionEditorPage />, {
extraRoutes: [
{
path: "/templates/:templateId",
element: <div />,
},
],
})
const topbar = await screen.findByTestId("topbar")
// Build Template
jest.spyOn(api, "uploadTemplateFile").mockResolvedValueOnce({ hash: "hash" })
jest
.spyOn(api, "createTemplateVersion")
.mockResolvedValueOnce(MockTemplateVersion)
jest
.spyOn(api, "getTemplateVersion")
.mockResolvedValue({ ...MockTemplateVersion, id: "new-version-id" })
jest.spyOn(api, "watchBuildLogs").mockImplementation((_, onMessage) => {
onMessage(MockWorkspaceBuildLogs[0])
return Promise.resolve()
})
const buildButton = within(topbar).getByRole("button", {
name: "Build template",
})
await user.click(buildButton)
// Publish
const patchTemplateVersion = jest
.spyOn(api, "patchTemplateVersion")
.mockResolvedValue(MockTemplateVersion)
const updateActiveTemplateVersion = jest
.spyOn(api, "updateActiveTemplateVersion")
.mockResolvedValue({ message: "" })
await within(topbar).findByText("Success")
const publishButton = within(topbar).getByRole("button", {
name: "Publish version",
})
await user.click(publishButton)
const publishDialog = await screen.findByTestId("dialog")
const nameField = within(publishDialog).getByLabelText("Version name")
await user.clear(nameField)
await user.type(nameField, "v1.0")
await user.click(
within(publishDialog).getByRole("button", { name: "Publish" }),
)
await waitFor(() => {
expect(patchTemplateVersion).toBeCalledWith("new-version-id", {
name: "v1.0",
})
})
expect(updateActiveTemplateVersion).toBeCalledTimes(0)
})
test("The default version name is used when a new one is not used", async () => {
const user = userEvent.setup()
renderWithAuth(<TemplateVersionEditorPage />, {
extraRoutes: [
{
path: "/templates/:templateId",
element: <div />,
},
],
})
const topbar = await screen.findByTestId("topbar")
// Build Template
jest.spyOn(api, "uploadTemplateFile").mockResolvedValueOnce({ hash: "hash" })
jest
.spyOn(api, "createTemplateVersion")
.mockResolvedValueOnce(MockTemplateVersion)
jest
.spyOn(api, "getTemplateVersion")
.mockResolvedValue({ ...MockTemplateVersion, id: "new-version-id" })
jest.spyOn(api, "watchBuildLogs").mockImplementation((_, onMessage) => {
onMessage(MockWorkspaceBuildLogs[0])
return Promise.resolve()
})
const buildButton = within(topbar).getByRole("button", {
name: "Build template",
})
await user.click(buildButton)
// Publish
const patchTemplateVersion = jest
.spyOn(api, "patchTemplateVersion")
.mockResolvedValue(MockTemplateVersion)
await within(topbar).findByText("Success")
const publishButton = within(topbar).getByRole("button", {
name: "Publish version",
})
await user.click(publishButton)
const publishDialog = await screen.findByTestId("dialog")
await user.click(
within(publishDialog).getByRole("button", { name: "Publish" }),
)
await waitFor(() => {
expect(patchTemplateVersion).toBeCalledWith("new-version-id", {
name: MockTemplateVersion.name,
})
})
})

View File

@ -4,7 +4,7 @@ import { useOrganizationId } from "hooks/useOrganizationId"
import { usePermissions } from "hooks/usePermissions" import { usePermissions } from "hooks/usePermissions"
import { FC } from "react" import { FC } from "react"
import { Helmet } from "react-helmet-async" import { Helmet } from "react-helmet-async"
import { useParams } from "react-router-dom" import { useNavigate, useParams } from "react-router-dom"
import { pageTitle } from "util/page" import { pageTitle } from "util/page"
import { templateVersionEditorMachine } from "xServices/templateVersionEditor/templateVersionEditorXService" import { templateVersionEditorMachine } from "xServices/templateVersionEditor/templateVersionEditorXService"
import { useTemplateVersionData } from "./data" import { useTemplateVersionData } from "./data"
@ -16,9 +16,15 @@ type Params = {
export const TemplateVersionEditorPage: FC = () => { export const TemplateVersionEditorPage: FC = () => {
const { version: versionName, template: templateName } = useParams() as Params const { version: versionName, template: templateName } = useParams() as Params
const navigate = useNavigate()
const orgId = useOrganizationId() const orgId = useOrganizationId()
const [editorState, sendEvent] = useMachine(templateVersionEditorMachine, { const [editorState, sendEvent] = useMachine(templateVersionEditorMachine, {
context: { orgId }, context: { orgId },
actions: {
onPublish: () => {
navigate(`/templates/${templateName}`)
},
},
}) })
const permissions = usePermissions() const permissions = usePermissions()
const { isSuccess, data } = useTemplateVersionData( const { isSuccess, data } = useTemplateVersionData(
@ -53,11 +59,27 @@ export const TemplateVersionEditorPage: FC = () => {
templateId: data.template.id, templateId: data.template.id,
}) })
}} }}
onUpdate={() => { onCancelPublish={() => {
sendEvent({ sendEvent({
type: "UPDATE_ACTIVE_VERSION", type: "CANCEL_PUBLISH",
}) })
}} }}
onPublish={() => {
sendEvent({
type: "PUBLISH",
})
}}
onConfirmPublish={(data) => {
sendEvent({
type: "CONFIRM_PUBLISH",
...data,
})
}}
isAskingPublishParameters={editorState.matches(
"askPublishParameters",
)}
publishingError={editorState.context.publishingError}
isPublishing={editorState.matches("publishingVersion")}
disablePreview={editorState.hasTag("loading")} disablePreview={editorState.hasTag("loading")}
disableUpdate={ disableUpdate={
editorState.hasTag("loading") || editorState.hasTag("loading") ||

View File

@ -0,0 +1,4 @@
export type PublishVersionData = {
name: string
isActiveVersion: boolean
}

View File

@ -669,8 +669,7 @@ export const MockWorkspace: TypesGen.Workspace = {
autostart_schedule: MockWorkspaceAutostartEnabled.schedule, autostart_schedule: MockWorkspaceAutostartEnabled.schedule,
ttl_ms: 2 * 60 * 60 * 1000, ttl_ms: 2 * 60 * 60 * 1000,
latest_build: MockWorkspaceBuild, latest_build: MockWorkspaceBuild,
last_used_at: "", last_used_at: "2022-05-16T15:29:10.302441433Z",
organization_id: MockOrganization.id,
} }
export const MockStoppedWorkspace: TypesGen.Workspace = { export const MockStoppedWorkspace: TypesGen.Workspace = {

View File

@ -4,6 +4,8 @@ import { CreateWorkspaceBuildRequest } from "../api/typesGenerated"
import { permissionsToCheck } from "../xServices/auth/authXService" import { permissionsToCheck } from "../xServices/auth/authXService"
import * as M from "./entities" import * as M from "./entities"
import { MockGroup, MockWorkspaceQuota } from "./entities" import { MockGroup, MockWorkspaceQuota } from "./entities"
import fs from "fs"
import path from "path"
export const handlers = [ export const handlers = [
rest.get("/api/v2/templates/:templateId/daus", async (req, res, ctx) => { rest.get("/api/v2/templates/:templateId/daus", async (req, res, ctx) => {
@ -318,4 +320,17 @@ export const handlers = [
return res(ctx.status(200), ctx.json([M.MockWorkspaceBuildParameter1])) return res(ctx.status(200), ctx.json([M.MockWorkspaceBuildParameter1]))
}, },
), ),
rest.get("api/v2/files/:fileId", (_, res, ctx) => {
const fileBuffer = fs.readFileSync(
path.resolve(__dirname, "./templateFiles.tar"),
)
return res(
ctx.set("Content-Length", fileBuffer.byteLength.toString()),
ctx.set("Content-Type", "application/octet-stream"),
// Respond with the "ArrayBuffer".
ctx.body(fileBuffer),
)
}),
] ]

Binary file not shown.

View File

@ -10,6 +10,7 @@ import * as API from "api/api"
import { FileTree, traverse } from "util/filetree" import { FileTree, traverse } from "util/filetree"
import { isAllowedFile } from "util/templateVersion" import { isAllowedFile } from "util/templateVersion"
import { TarReader, TarWriter } from "util/tar" import { TarReader, TarWriter } from "util/tar"
import { PublishVersionData } from "pages/TemplateVersionPage/TemplateVersionEditorPage/types"
export interface CreateVersionData { export interface CreateVersionData {
file: File file: File
@ -24,6 +25,7 @@ export interface TemplateVersionEditorMachineContext {
resources?: WorkspaceResource[] resources?: WorkspaceResource[]
buildLogs?: ProvisionerJobLog[] buildLogs?: ProvisionerJobLog[]
tarReader?: TarReader tarReader?: TarReader
publishingError?: unknown
} }
export const templateVersionEditorMachine = createMachine( export const templateVersionEditorMachine = createMachine(
@ -41,7 +43,10 @@ export const templateVersionEditorMachine = createMachine(
} }
| { type: "CANCEL_VERSION" } | { type: "CANCEL_VERSION" }
| { type: "ADD_BUILD_LOG"; log: ProvisionerJobLog } | { type: "ADD_BUILD_LOG"; log: ProvisionerJobLog }
| { type: "UPDATE_ACTIVE_VERSION" }, | { type: "PUBLISH" }
| ({ type: "CONFIRM_PUBLISH" } & PublishVersionData)
| { type: "CANCEL_PUBLISH" },
services: {} as { services: {} as {
uploadTar: { uploadTar: {
data: UploadResponse data: UploadResponse
@ -58,7 +63,7 @@ export const templateVersionEditorMachine = createMachine(
getResources: { getResources: {
data: WorkspaceResource[] data: WorkspaceResource[]
} }
updateActiveVersion: { publishingVersion: {
data: void data: void
} }
}, },
@ -80,18 +85,29 @@ export const templateVersionEditorMachine = createMachine(
actions: ["assignCreateBuild"], actions: ["assignCreateBuild"],
target: "cancelingBuild", target: "cancelingBuild",
}, },
UPDATE_ACTIVE_VERSION: { PUBLISH: {
target: "updatingActiveVersion", target: "askPublishParameters",
}, },
}, },
}, },
updatingActiveVersion: { askPublishParameters: {
on: {
CANCEL_PUBLISH: "idle",
CONFIRM_PUBLISH: "publishingVersion",
},
},
publishingVersion: {
tags: "loading", tags: "loading",
entry: ["clearPublishingError"],
invoke: { invoke: {
id: "updateActiveVersion", id: "publishingVersion",
src: "updateActiveVersion", src: "publishingVersion",
onDone: { onDone: {
target: "idle", actions: ["onPublish"],
},
onError: {
actions: ["assignPublishingError"],
target: "askPublishParameters",
}, },
}, },
}, },
@ -215,6 +231,10 @@ export const templateVersionEditorMachine = createMachine(
assignTarReader: assign({ assignTarReader: assign({
tarReader: (_, { tarReader }) => tarReader, tarReader: (_, { tarReader }) => tarReader,
}), }),
assignPublishingError: assign({
publishingError: (_, event) => event.data,
}),
clearPublishingError: assign({ publishingError: (_) => undefined }),
}, },
services: { services: {
uploadTar: async ({ fileTree, tarReader }) => { uploadTar: async ({ fileTree, tarReader }) => {
@ -285,28 +305,17 @@ export const templateVersionEditorMachine = createMachine(
} }
return API.getTemplateVersion(ctx.version.id) return API.getTemplateVersion(ctx.version.id)
}, },
watchBuildLogs: (ctx) => async (callback) => { watchBuildLogs:
return new Promise<void>((resolve, reject) => { ({ version }) =>
if (!ctx.version) { async (callback) => {
return reject("version must be set") if (!version) {
throw new Error("version must be set")
} }
const proto = location.protocol === "https:" ? "wss:" : "ws:"
const socket = new WebSocket( return API.watchBuildLogs(version.id, (log) => {
`${proto}//${location.host}/api/v2/templateversions/${ctx.version?.id}/logs?follow=true`, callback({ type: "ADD_BUILD_LOG", log })
)
socket.binaryType = "blob"
socket.addEventListener("message", (event) => {
callback({ type: "ADD_BUILD_LOG", log: JSON.parse(event.data) })
}) })
socket.addEventListener("error", () => { },
reject(new Error("socket errored"))
})
socket.addEventListener("close", () => {
// When the socket closes, logs have finished streaming!
resolve()
})
})
},
getResources: (ctx) => { getResources: (ctx) => {
if (!ctx.version) { if (!ctx.version) {
throw new Error("template version must be set") throw new Error("template version must be set")
@ -321,16 +330,24 @@ export const templateVersionEditorMachine = createMachine(
await API.cancelTemplateVersionBuild(ctx.version.id) await API.cancelTemplateVersionBuild(ctx.version.id)
} }
}, },
updateActiveVersion: async (ctx) => { publishingVersion: async (
if (!ctx.templateId) { { version, templateId },
throw new Error("template must be set") { name, isActiveVersion },
) => {
if (!version) {
throw new Error("Version is not set")
} }
if (!ctx.version) { if (!templateId) {
throw new Error("template version must be set") throw new Error("Template is not set")
} }
await API.updateActiveTemplateVersion(ctx.templateId, { await Promise.all([
id: ctx.version.id, API.patchTemplateVersion(version.id, { name }),
}) isActiveVersion
? API.updateActiveTemplateVersion(templateId, {
id: version.id,
})
: Promise.resolve(),
])
}, },
}, },
}, },