From d1e93c8a0666f8af1aa293ba5c42d3d7c467f6ab Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Tue, 21 Mar 2023 14:20:13 +0000 Subject: [PATCH 1/4] Add basic publish new version flow --- .../PublishTemplateVersionDialog.tsx | 89 ++++ .../TemplateVersionEditor.tsx | 417 +++++++++--------- .../TemplateVersionEditorPage.tsx | 27 +- .../TemplateVersionEditorPage/types.ts | 4 + .../templateVersionEditorXService.ts | 51 ++- 5 files changed, 374 insertions(+), 214 deletions(-) create mode 100644 site/src/components/TemplateVersionEditor/PublishTemplateVersionDialog.tsx create mode 100644 site/src/pages/TemplateVersionPage/TemplateVersionEditorPage/types.ts diff --git a/site/src/components/TemplateVersionEditor/PublishTemplateVersionDialog.tsx b/site/src/components/TemplateVersionEditor/PublishTemplateVersionDialog.tsx new file mode 100644 index 0000000000000..1d5df8625414e --- /dev/null +++ b/site/src/components/TemplateVersionEditor/PublishTemplateVersionDialog.tsx @@ -0,0 +1,89 @@ +import { DialogProps } from "components/Dialogs/Dialog" +import { FC } from "react" +import { getFormHelpers, nameValidator } 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 & { + isPublishing: boolean + onClose: () => void + onConfirm: (data: PublishVersionData) => void +} + +export const PublishTemplateVersionDialog: FC< + PublishTemplateVersionDialogProps +> = ({ onConfirm, isPublishing, onClose, ...dialogProps }) => { + const form = useFormik({ + initialValues: { + name: "", + isActiveVersion: false, + }, + validationSchema: Yup.object({ + name: nameValidator("name"), + isActiveVersion: Yup.boolean(), + }), + onSubmit: onConfirm, + }) + const getFieldHelpers = getFormHelpers(form) + const handleClose = () => { + form.resetForm() + onClose() + } + + return ( + { + await form.submitForm() + }} + hideCancel={false} + type="success" + cancelText="Cancel" + confirmText="Publish" + title="Publish new version" + description={ + +

You are about to publish a new version of this template.

+ + + + { + await form.setFieldValue( + "isActiveVersion", + e.target.checked, + ) + }} + name="isActiveVersion" + color="primary" + /> + } + /> + +
+ } + /> + ) +} diff --git a/site/src/components/TemplateVersionEditor/TemplateVersionEditor.tsx b/site/src/components/TemplateVersionEditor/TemplateVersionEditor.tsx index 73689d69a1e55..102901109f40c 100644 --- a/site/src/components/TemplateVersionEditor/TemplateVersionEditor.tsx +++ b/site/src/components/TemplateVersionEditor/TemplateVersionEditor.tsx @@ -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,11 @@ export interface TemplateVersionEditorProps { disablePreview: boolean disableUpdate: boolean onPreview: (files: FileTree) => void - onUpdate: () => void + onPublish: () => void + onConfirmPublish: (data: PublishVersionData) => void + onCancelPublish: () => void + isAskingPublishParameters: boolean + isPublishing: boolean } const topbarHeight = 80 @@ -76,7 +82,11 @@ export const TemplateVersionEditor: FC = ({ templateVersion, defaultFileTree, onPreview, - onUpdate, + onPublish, + onConfirmPublish, + onCancelPublish, + isAskingPublishParameters, + isPublishing, buildLogs, resources, }) => { @@ -155,230 +165,243 @@ export const TemplateVersionEditor: FC = ({ }) return ( -
-
-
- - ) - } - /> -
- -
-
- + <> +
+
+
+ + ) + } + />
- - - -
-
- -
-
-
- Template files -
- - { - setCreateFileOpen(true) - event.currentTarget.blur() - }} - > - - - +
+
+
- { - setCreateFileOpen(false) - }} - checkExists={(path) => existsFile(path, fileTree)} - onConfirm={(path) => { - setFileTree((fileTree) => createFile(path, fileTree, "")) - setActivePath(path) - setCreateFileOpen(false) - setDirty(true) - }} - /> - { - 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 || ""} - /> - { - 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) + +
- setDeleteFileOpen(file)} - onSelect={(filePath) => { - if (!isFolder(filePath, fileTree)) { - setActivePath(filePath) + > + Build template + + + +
-
-
- {activePath ? ( - { - if (!activePath) { +
+
+
+ Template files +
+ + { + setCreateFileOpen(true) + event.currentTarget.blur() + }} + > + + + +
+ { + setCreateFileOpen(false) + }} + checkExists={(path) => existsFile(path, fileTree)} + onConfirm={(path) => { + setFileTree((fileTree) => createFile(path, fileTree, "")) + setActivePath(path) + setCreateFileOpen(false) + setDirty(true) + }} + /> + { + 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 || ""} + /> + { + setRenameFileOpen(undefined) + }} + filename={renameFileOpen || ""} + checkExists={(path) => existsFile(path, fileTree)} + onConfirm={(newPath) => { + if (!renameFileOpen) { return } setFileTree((fileTree) => - updateFile(activePath, value, fileTree), + moveFile(renameFileOpen, newPath, fileTree), ) + setActivePath(newPath) + setRenameFileOpen(undefined) setDirty(true) }} /> - ) : ( -
No file opened
- )} +
+ setDeleteFileOpen(file)} + onSelect={(filePath) => { + if (!isFolder(filePath, fileTree)) { + setActivePath(filePath) + } + }} + onRename={(file) => setRenameFileOpen(file)} + activePath={activePath} + />
-
-
- +
+
+ {activePath ? ( + { + if (!activePath) { + return + } + setFileTree((fileTree) => + updateFile(activePath, value, fileTree), + ) + setDirty(true) + }} + /> + ) : ( +
No file opened
+ )} +
- {!disableUpdate && ( +
+
- )} -
-
- {buildLogs && ( - - )} - {templateVersion.job.error && ( -
- {templateVersion.job.error} -
- )} -
+ {!disableUpdate && ( + + )} +
-
- {resources && ( - r.workspace_transition === "start", - )} - /> - )} +
+ {buildLogs && ( + + )} + {templateVersion.job.error && ( +
+ {templateVersion.job.error} +
+ )} +
+ +
+ {resources && ( + r.workspace_transition === "start", + )} + /> + )} +
-
- {templateVersionSucceeded && ( - <> -
- - )} + {templateVersionSucceeded && ( + <> +
+ + )} +
-
+ + + ) } @@ -387,7 +410,7 @@ const useStyles = makeStyles< { templateVersionSucceeded: boolean showBuildLogs: boolean - deploymentBannerVisible: boolean + deploymentBannerVisible?: boolean } >((theme) => ({ root: { diff --git a/site/src/pages/TemplateVersionPage/TemplateVersionEditorPage/TemplateVersionEditorPage.tsx b/site/src/pages/TemplateVersionPage/TemplateVersionEditorPage/TemplateVersionEditorPage.tsx index d5dcfd71abfa3..915d88c99bae3 100644 --- a/site/src/pages/TemplateVersionPage/TemplateVersionEditorPage/TemplateVersionEditorPage.tsx +++ b/site/src/pages/TemplateVersionPage/TemplateVersionEditorPage/TemplateVersionEditorPage.tsx @@ -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,26 @@ export const TemplateVersionEditorPage: FC = () => { templateId: data.template.id, }) }} - onUpdate={() => { + onCancelPublish={() => { + sendEvent({ + type: "CANCEL_PUBLISH", + }) + }} + onPublish={() => { + sendEvent({ + type: "PUBLISH", + }) + }} + onConfirmPublish={(data) => { sendEvent({ - type: "UPDATE_ACTIVE_VERSION", + type: "CONFIRM_PUBLISH", + ...data, }) }} + isAskingPublishParameters={editorState.matches( + "askPublishParameters", + )} + isPublishing={editorState.matches("publishingVersion")} disablePreview={editorState.hasTag("loading")} disableUpdate={ editorState.hasTag("loading") || diff --git a/site/src/pages/TemplateVersionPage/TemplateVersionEditorPage/types.ts b/site/src/pages/TemplateVersionPage/TemplateVersionEditorPage/types.ts new file mode 100644 index 0000000000000..552e76a99fc04 --- /dev/null +++ b/site/src/pages/TemplateVersionPage/TemplateVersionEditorPage/types.ts @@ -0,0 +1,4 @@ +export type PublishVersionData = { + name?: string + isActiveVersion: boolean +} diff --git a/site/src/xServices/templateVersionEditor/templateVersionEditorXService.ts b/site/src/xServices/templateVersionEditor/templateVersionEditorXService.ts index 75b28b2888bd2..fc7f4dd5d6a8f 100644 --- a/site/src/xServices/templateVersionEditor/templateVersionEditorXService.ts +++ b/site/src/xServices/templateVersionEditor/templateVersionEditorXService.ts @@ -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 @@ -41,7 +42,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 +62,7 @@ export const templateVersionEditorMachine = createMachine( getResources: { data: WorkspaceResource[] } - updateActiveVersion: { + publishingVersion: { data: void } }, @@ -80,18 +84,24 @@ 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", invoke: { - id: "updateActiveVersion", - src: "updateActiveVersion", + id: "publishingVersion", + src: "publishingVersion", onDone: { - target: "idle", + actions: ["onPublish"], }, }, }, @@ -321,16 +331,29 @@ export const templateVersionEditorMachine = createMachine( await API.cancelTemplateVersionBuild(ctx.version.id) } }, - updateActiveVersion: async (ctx) => { - if (!ctx.templateId) { + publishingVersion: async ( + { orgId, templateId, uploadResponse }, + { name, isActiveVersion }, + ) => { + if (!templateId) { throw new Error("template must be set") } - if (!ctx.version) { - throw new Error("template version must be set") + if (!uploadResponse) { + throw new Error("upload response must be set") } - await API.updateActiveTemplateVersion(ctx.templateId, { - id: ctx.version.id, + const newestVersion = await API.createTemplateVersion(orgId, { + name, + provisioner: "terraform", + storage_method: "file", + tags: {}, + template_id: templateId, + file_id: uploadResponse.hash, }) + if (isActiveVersion) { + await API.updateActiveTemplateVersion(templateId, { + id: newestVersion.id, + }) + } }, }, }, From 351340449c6e2497cb58dc1e8ad35d68196cf458 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Thu, 23 Mar 2023 16:59:52 +0000 Subject: [PATCH 2/4] Fix patch --- site/src/api/api.ts | 11 +++++++ .../PublishTemplateVersionDialog.tsx | 3 +- .../templateVersionEditorXService.ts | 31 ++++++++----------- 3 files changed, 26 insertions(+), 19 deletions(-) diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 715665b77e3a5..8fea7958f6a98 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -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( + `/api/v2/templateversions/${templateVersionId}`, + data, + ) + return response.data +} + export const updateTemplateMeta = async ( templateId: string, data: TypesGen.UpdateTemplateMeta, diff --git a/site/src/components/TemplateVersionEditor/PublishTemplateVersionDialog.tsx b/site/src/components/TemplateVersionEditor/PublishTemplateVersionDialog.tsx index 1d5df8625414e..d34f3e100b069 100644 --- a/site/src/components/TemplateVersionEditor/PublishTemplateVersionDialog.tsx +++ b/site/src/components/TemplateVersionEditor/PublishTemplateVersionDialog.tsx @@ -26,7 +26,7 @@ export const PublishTemplateVersionDialog: FC< isActiveVersion: false, }, validationSchema: Yup.object({ - name: nameValidator("name"), + name: nameValidator("name").optional(), isActiveVersion: Yup.boolean(), }), onSubmit: onConfirm, @@ -62,6 +62,7 @@ export const PublishTemplateVersionDialog: FC< InputLabelProps={{ shrink: true, }} + helperText="If you leave this blank, the version name will be automatically generated." /> { - if (!templateId) { - throw new Error("template must be set") - } - if (!uploadResponse) { - throw new Error("upload response must be set") + if (!version) { + throw new Error("Version is not set") } - const newestVersion = await API.createTemplateVersion(orgId, { - name, - provisioner: "terraform", - storage_method: "file", - tags: {}, - template_id: templateId, - file_id: uploadResponse.hash, - }) - if (isActiveVersion) { - await API.updateActiveTemplateVersion(templateId, { - id: newestVersion.id, - }) + if (!templateId) { + throw new Error("Template is not set") } + await Promise.all([ + API.patchTemplateVersion(version.id, { name: name ?? version.name }), + isActiveVersion + ? API.updateActiveTemplateVersion(templateId, { + id: version.id, + }) + : Promise.resolve(), + ]) }, }, }, From a2eec188e8a78d2ad0836dbcf6ff5ac25b1c8003 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Thu, 23 Mar 2023 18:27:01 +0000 Subject: [PATCH 3/4] Add tests --- site/src/api/api.ts | 25 ++- .../PublishTemplateVersionDialog.tsx | 10 +- .../TemplateVersionEditor.tsx | 3 +- .../TemplateVersionEditorPage.test.tsx | 183 ++++++++++++++++++ .../TemplateVersionEditorPage/types.ts | 2 +- site/src/testHelpers/entities.ts | 5 +- site/src/testHelpers/handlers.ts | 15 ++ site/src/testHelpers/templateFiles.tar | Bin 0 -> 27648 bytes .../templateVersionEditorXService.ts | 31 +-- 9 files changed, 243 insertions(+), 31 deletions(-) create mode 100644 site/src/pages/TemplateVersionPage/TemplateVersionEditorPage/TemplateVersionEditorPage.test.tsx create mode 100644 site/src/testHelpers/templateFiles.tar diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 8fea7958f6a98..f0fb6129f47cc 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -377,7 +377,7 @@ export const patchTemplateVersion = async ( templateVersionId: string, data: TypesGen.PatchTemplateVersionRequest, ) => { - const response = await axios.patch( + const response = await axios.patch( `/api/v2/templateversions/${templateVersionId}`, data, ) @@ -1015,3 +1015,26 @@ const getMissingParameters = ( return missingParameters } + +export const watchBuildLogs = ( + versionId: string, + onMessage: (log: TypesGen.ProvisionerJobLog) => void, +) => { + return new Promise((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() + }) + }) +} diff --git a/site/src/components/TemplateVersionEditor/PublishTemplateVersionDialog.tsx b/site/src/components/TemplateVersionEditor/PublishTemplateVersionDialog.tsx index d34f3e100b069..20ef1d50b0f78 100644 --- a/site/src/components/TemplateVersionEditor/PublishTemplateVersionDialog.tsx +++ b/site/src/components/TemplateVersionEditor/PublishTemplateVersionDialog.tsx @@ -1,6 +1,6 @@ import { DialogProps } from "components/Dialogs/Dialog" import { FC } from "react" -import { getFormHelpers, nameValidator } from "util/formUtils" +import { getFormHelpers } from "util/formUtils" import { FormFields } from "components/Form/Form" import { useFormik } from "formik" import * as Yup from "yup" @@ -12,6 +12,7 @@ import FormControlLabel from "@material-ui/core/FormControlLabel" import { Stack } from "components/Stack/Stack" export type PublishTemplateVersionDialogProps = DialogProps & { + defaultName: string isPublishing: boolean onClose: () => void onConfirm: (data: PublishVersionData) => void @@ -19,14 +20,14 @@ export type PublishTemplateVersionDialogProps = DialogProps & { export const PublishTemplateVersionDialog: FC< PublishTemplateVersionDialogProps -> = ({ onConfirm, isPublishing, onClose, ...dialogProps }) => { +> = ({ onConfirm, isPublishing, onClose, defaultName, ...dialogProps }) => { const form = useFormik({ initialValues: { - name: "", + name: defaultName, isActiveVersion: false, }, validationSchema: Yup.object({ - name: nameValidator("name").optional(), + name: Yup.string().required(), isActiveVersion: Yup.boolean(), }), onSubmit: onConfirm, @@ -62,7 +63,6 @@ export const PublishTemplateVersionDialog: FC< InputLabelProps={{ shrink: true, }} - helperText="If you leave this blank, the version name will be automatically generated." /> = ({ return ( <>
-
+
= ({ onClose={onCancelPublish} onConfirm={onConfirmPublish} isPublishing={isPublishing} + defaultName={templateVersion.name} /> ) diff --git a/site/src/pages/TemplateVersionPage/TemplateVersionEditorPage/TemplateVersionEditorPage.test.tsx b/site/src/pages/TemplateVersionPage/TemplateVersionEditorPage/TemplateVersionEditorPage.test.tsx new file mode 100644 index 0000000000000..07886ec58f7d2 --- /dev/null +++ b/site/src/pages/TemplateVersionPage/TemplateVersionEditorPage/TemplateVersionEditorPage.test.tsx @@ -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: () =>
, + } +}) + +test("Use custom name and set it as active when publishing", async () => { + const user = userEvent.setup() + renderWithAuth(, { + extraRoutes: [ + { + path: "/templates/:templateId", + element:
, + }, + ], + }) + 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(, { + extraRoutes: [ + { + path: "/templates/:templateId", + element:
, + }, + ], + }) + 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(, { + extraRoutes: [ + { + path: "/templates/:templateId", + element:
, + }, + ], + }) + 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, + }) + }) +}) diff --git a/site/src/pages/TemplateVersionPage/TemplateVersionEditorPage/types.ts b/site/src/pages/TemplateVersionPage/TemplateVersionEditorPage/types.ts index 552e76a99fc04..ca8dc50c48ebe 100644 --- a/site/src/pages/TemplateVersionPage/TemplateVersionEditorPage/types.ts +++ b/site/src/pages/TemplateVersionPage/TemplateVersionEditorPage/types.ts @@ -1,4 +1,4 @@ export type PublishVersionData = { - name?: string + name: string isActiveVersion: boolean } diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index a1fb78c6e6636..5f5f1914b4d48 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -657,9 +657,10 @@ export const MockWorkspace: TypesGen.Workspace = { owner_id: MockUser.id, owner_name: MockUser.username, autostart_schedule: MockWorkspaceAutostartEnabled.schedule, - ttl_ms: 2 * 60 * 60 * 1000, // 2 hours as milliseconds + ttl_ms: 2 * 60 * 60 * 1000, latest_build: MockWorkspaceBuild, - last_used_at: "", + last_used_at: "2022-05-16T15:29:10.302441433Z", + organization_id: MockOrganization.id, } export const MockStoppedWorkspace: TypesGen.Workspace = { diff --git a/site/src/testHelpers/handlers.ts b/site/src/testHelpers/handlers.ts index ff18cbf6a6f2c..9a0bc8f309cfb 100644 --- a/site/src/testHelpers/handlers.ts +++ b/site/src/testHelpers/handlers.ts @@ -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), + ) + }), ] diff --git a/site/src/testHelpers/templateFiles.tar b/site/src/testHelpers/templateFiles.tar new file mode 100644 index 0000000000000000000000000000000000000000..b0bbdff89fcb7d9b8c1990e2af1086a1a746506a GIT binary patch literal 27648 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 async (callback) => { - return new Promise((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) => { if (!ctx.version) { throw new Error("template version must be set") @@ -342,7 +331,7 @@ export const templateVersionEditorMachine = createMachine( throw new Error("Template is not set") } await Promise.all([ - API.patchTemplateVersion(version.id, { name: name ?? version.name }), + API.patchTemplateVersion(version.id, { name }), isActiveVersion ? API.updateActiveTemplateVersion(templateId, { id: version.id, From 90dc55fb77c9e8b2dda937db731cc02b9e88d040 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Mon, 27 Mar 2023 12:31:33 +0000 Subject: [PATCH 4/4] Fix duplicated name --- coderd/templateversions.go | 3 +++ .../PublishTemplateVersionDialog.tsx | 12 ++++++++++-- .../TemplateVersionEditor/TemplateVersionEditor.tsx | 4 ++++ .../TemplateVersionEditorPage.tsx | 1 + .../templateVersionEditorXService.ts | 10 ++++++++++ 5 files changed, 28 insertions(+), 2 deletions(-) diff --git a/coderd/templateversions.go b/coderd/templateversions.go index ebbf4ae79f72b..55468a510ae35 100644 --- a/coderd/templateversions.go +++ b/coderd/templateversions.go @@ -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 } diff --git a/site/src/components/TemplateVersionEditor/PublishTemplateVersionDialog.tsx b/site/src/components/TemplateVersionEditor/PublishTemplateVersionDialog.tsx index 20ef1d50b0f78..236a91935dc34 100644 --- a/site/src/components/TemplateVersionEditor/PublishTemplateVersionDialog.tsx +++ b/site/src/components/TemplateVersionEditor/PublishTemplateVersionDialog.tsx @@ -14,13 +14,21 @@ 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, ...dialogProps }) => { +> = ({ + onConfirm, + isPublishing, + onClose, + defaultName, + publishingError, + ...dialogProps +}) => { const form = useFormik({ initialValues: { name: defaultName, @@ -32,7 +40,7 @@ export const PublishTemplateVersionDialog: FC< }), onSubmit: onConfirm, }) - const getFieldHelpers = getFormHelpers(form) + const getFieldHelpers = getFormHelpers(form, publishingError) const handleClose = () => { form.resetForm() onClose() diff --git a/site/src/components/TemplateVersionEditor/TemplateVersionEditor.tsx b/site/src/components/TemplateVersionEditor/TemplateVersionEditor.tsx index e6eae7b7cbd25..267e6debf92e0 100644 --- a/site/src/components/TemplateVersionEditor/TemplateVersionEditor.tsx +++ b/site/src/components/TemplateVersionEditor/TemplateVersionEditor.tsx @@ -56,6 +56,7 @@ export interface TemplateVersionEditorProps { onPublish: () => void onConfirmPublish: (data: PublishVersionData) => void onCancelPublish: () => void + publishingError: unknown isAskingPublishParameters: boolean isPublishing: boolean } @@ -85,6 +86,7 @@ export const TemplateVersionEditor: FC = ({ onPublish, onConfirmPublish, onCancelPublish, + publishingError, isAskingPublishParameters, isPublishing, buildLogs, @@ -400,6 +402,8 @@ export const TemplateVersionEditor: FC = ({
{ isAskingPublishParameters={editorState.matches( "askPublishParameters", )} + publishingError={editorState.context.publishingError} isPublishing={editorState.matches("publishingVersion")} disablePreview={editorState.hasTag("loading")} disableUpdate={ diff --git a/site/src/xServices/templateVersionEditor/templateVersionEditorXService.ts b/site/src/xServices/templateVersionEditor/templateVersionEditorXService.ts index 3db1e915f391e..45f9ee9d2dd68 100644 --- a/site/src/xServices/templateVersionEditor/templateVersionEditorXService.ts +++ b/site/src/xServices/templateVersionEditor/templateVersionEditorXService.ts @@ -25,6 +25,7 @@ export interface TemplateVersionEditorMachineContext { resources?: WorkspaceResource[] buildLogs?: ProvisionerJobLog[] tarReader?: TarReader + publishingError?: unknown } export const templateVersionEditorMachine = createMachine( @@ -97,12 +98,17 @@ export const templateVersionEditorMachine = createMachine( }, publishingVersion: { tags: "loading", + entry: ["clearPublishingError"], invoke: { id: "publishingVersion", src: "publishingVersion", onDone: { actions: ["onPublish"], }, + onError: { + actions: ["assignPublishingError"], + target: "askPublishParameters", + }, }, }, cancelingBuild: { @@ -225,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 }) => {