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) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: err.Error(),
Validations: []codersdk.ValidationError{
{Field: "name", Detail: "Name is already used"},
},
})
return
}

View File

@ -373,6 +373,17 @@ export const updateActiveTemplateVersion = async (
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 (
templateId: string,
data: TypesGen.UpdateTemplateMeta,
@ -1020,3 +1031,26 @@ const getMissingParameters = (
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 { TemplateResourcesTable } from "components/TemplateResourcesTable/TemplateResourcesTable"
import { WorkspaceBuildLogs } from "components/WorkspaceBuildLogs/WorkspaceBuildLogs"
import { PublishVersionData } from "pages/TemplateVersionPage/TemplateVersionEditorPage/types"
import { FC, useCallback, useEffect, useRef, useState } from "react"
import { navHeight, dashboardContentBottomPadding } from "theme/constants"
import {
@ -36,6 +37,7 @@ import {
} from "./FileDialog"
import { FileTreeView } from "./FileTreeView"
import { MonacoEditor } from "./MonacoEditor"
import { PublishTemplateVersionDialog } from "./PublishTemplateVersionDialog"
import {
getStatus,
TemplateVersionStatusBadge,
@ -51,7 +53,12 @@ export interface TemplateVersionEditorProps {
disablePreview: boolean
disableUpdate: boolean
onPreview: (files: FileTree) => void
onUpdate: () => void
onPublish: () => void
onConfirmPublish: (data: PublishVersionData) => void
onCancelPublish: () => void
publishingError: unknown
isAskingPublishParameters: boolean
isPublishing: boolean
}
const topbarHeight = 80
@ -76,7 +83,12 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
templateVersion,
defaultFileTree,
onPreview,
onUpdate,
onPublish,
onConfirmPublish,
onCancelPublish,
publishingError,
isAskingPublishParameters,
isPublishing,
buildLogs,
resources,
}) => {
@ -156,8 +168,9 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
})
return (
<>
<div className={styles.root}>
<div className={styles.topbar}>
<div className={styles.topbar} data-testid="topbar">
<div className={styles.topbarSides}>
<AvatarData
title={template.display_name || template.name}
@ -200,7 +213,7 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
}
size="small"
disabled={dirty || disableUpdate}
onClick={onUpdate}
onClick={onPublish}
>
Publish version
</Button>
@ -244,7 +257,9 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
if (!deleteFileOpen) {
throw new Error("delete file must be set")
}
setFileTree((fileTree) => removeFile(deleteFileOpen, fileTree))
setFileTree((fileTree) =>
removeFile(deleteFileOpen, fileTree),
)
setDeleteFileOpen(undefined)
if (activePath === deleteFileOpen) {
setActivePath(undefined)
@ -313,7 +328,9 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
<div className={styles.panelWrapper}>
<div className={styles.tabs}>
<button
className={`${styles.tab} ${selectedTab === 0 ? "active" : ""}`}
className={`${styles.tab} ${
selectedTab === 0 ? "active" : ""
}`}
onClick={() => {
setSelectedTab(0)
}}
@ -383,6 +400,17 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
</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 { FC } from "react"
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 { templateVersionEditorMachine } from "xServices/templateVersionEditor/templateVersionEditorXService"
import { useTemplateVersionData } from "./data"
@ -16,9 +16,15 @@ type Params = {
export const TemplateVersionEditorPage: FC = () => {
const { version: versionName, template: templateName } = useParams() as Params
const navigate = useNavigate()
const orgId = useOrganizationId()
const [editorState, sendEvent] = useMachine(templateVersionEditorMachine, {
context: { orgId },
actions: {
onPublish: () => {
navigate(`/templates/${templateName}`)
},
},
})
const permissions = usePermissions()
const { isSuccess, data } = useTemplateVersionData(
@ -53,11 +59,27 @@ export const TemplateVersionEditorPage: FC = () => {
templateId: data.template.id,
})
}}
onUpdate={() => {
onCancelPublish={() => {
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")}
disableUpdate={
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,
ttl_ms: 2 * 60 * 60 * 1000,
latest_build: MockWorkspaceBuild,
last_used_at: "",
organization_id: MockOrganization.id,
last_used_at: "2022-05-16T15:29:10.302441433Z",
}
export const MockStoppedWorkspace: TypesGen.Workspace = {

View File

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