diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 328107b39d54b..dd27529604ffb 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -8223,6 +8223,9 @@ const docTemplate = `{ "codersdk.PatchTemplateVersionRequest": { "type": "object", "properties": { + "message": { + "type": "string" + }, "name": { "type": "string" } diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 7ea1a1de0633c..a9b0bfed7b336 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -7385,6 +7385,9 @@ "codersdk.PatchTemplateVersionRequest": { "type": "object", "properties": { + "message": { + "type": "string" + }, "name": { "type": "string" } diff --git a/coderd/database/dbfake/dbfake.go b/coderd/database/dbfake/dbfake.go index bdcfd9366dabb..4aa8334e5fb62 100644 --- a/coderd/database/dbfake/dbfake.go +++ b/coderd/database/dbfake/dbfake.go @@ -4771,6 +4771,7 @@ func (q *FakeQuerier) UpdateTemplateVersionByID(_ context.Context, arg database. templateVersion.TemplateID = arg.TemplateID templateVersion.UpdatedAt = arg.UpdatedAt templateVersion.Name = arg.Name + templateVersion.Message = arg.Message q.templateVersions[index] = templateVersion return templateVersion, nil } diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 78537c65ff5df..1cb61174b2913 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -4688,7 +4688,8 @@ UPDATE SET template_id = $2, updated_at = $3, - name = $4 + name = $4, + message = $5 WHERE id = $1 RETURNING id, template_id, organization_id, created_at, updated_at, name, readme, job_id, created_by, git_auth_providers, message ` @@ -4698,6 +4699,7 @@ type UpdateTemplateVersionByIDParams struct { TemplateID uuid.NullUUID `db:"template_id" json:"template_id"` UpdatedAt time.Time `db:"updated_at" json:"updated_at"` Name string `db:"name" json:"name"` + Message string `db:"message" json:"message"` } func (q *sqlQuerier) UpdateTemplateVersionByID(ctx context.Context, arg UpdateTemplateVersionByIDParams) (TemplateVersion, error) { @@ -4706,6 +4708,7 @@ func (q *sqlQuerier) UpdateTemplateVersionByID(ctx context.Context, arg UpdateTe arg.TemplateID, arg.UpdatedAt, arg.Name, + arg.Message, ) var i TemplateVersion err := row.Scan( diff --git a/coderd/database/queries/templateversions.sql b/coderd/database/queries/templateversions.sql index 2245ef72fec1c..e60fe42604ec6 100644 --- a/coderd/database/queries/templateversions.sql +++ b/coderd/database/queries/templateversions.sql @@ -91,7 +91,8 @@ UPDATE SET template_id = $2, updated_at = $3, - name = $4 + name = $4, + message = $5 WHERE id = $1 RETURNING *; diff --git a/coderd/templates.go b/coderd/templates.go index b2cfb4bf3c229..3404a3ee16677 100644 --- a/coderd/templates.go +++ b/coderd/templates.go @@ -336,6 +336,7 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque }, UpdatedAt: database.Now(), Name: templateVersion.Name, + Message: templateVersion.Message, }) if err != nil { return xerrors.Errorf("insert template version: %s", err) diff --git a/coderd/templateversions.go b/coderd/templateversions.go index 37a7bba98b2be..f90de5639135a 100644 --- a/coderd/templateversions.go +++ b/coderd/templateversions.go @@ -106,12 +106,17 @@ func (api *API) patchTemplateVersion(rw http.ResponseWriter, r *http.Request) { TemplateID: templateVersion.TemplateID, UpdatedAt: database.Now(), Name: templateVersion.Name, + Message: templateVersion.Message, } if params.Name != "" { updateParams.Name = params.Name } + if params.Message != nil { + updateParams.Message = *params.Message + } + errTemplateVersionNameConflict := xerrors.New("template version name must be unique for a template") var updatedTemplateVersion database.TemplateVersion diff --git a/coderd/templateversions_test.go b/coderd/templateversions_test.go index cef88235a08a4..5826d31b06286 100644 --- a/coderd/templateversions_test.go +++ b/coderd/templateversions_test.go @@ -1223,6 +1223,71 @@ func TestTemplateVersionPatch(t *testing.T) { assert.NotEqual(t, updatedVersion.Name, version.Name) }) + t.Run("Update the message", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil, func(req *codersdk.CreateTemplateVersionRequest) { + req.Message = "Example message" + }) + coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + wantMessage := "Updated message" + updatedVersion, err := client.UpdateTemplateVersion(ctx, version.ID, codersdk.PatchTemplateVersionRequest{ + Message: &wantMessage, + }) + + require.NoError(t, err) + assert.Equal(t, wantMessage, updatedVersion.Message) + }) + + t.Run("Remove the message", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil, func(req *codersdk.CreateTemplateVersionRequest) { + req.Message = "Example message" + }) + coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + wantMessage := "" + updatedVersion, err := client.UpdateTemplateVersion(ctx, version.ID, codersdk.PatchTemplateVersionRequest{ + Message: &wantMessage, + }) + + require.NoError(t, err) + assert.Equal(t, wantMessage, updatedVersion.Message) + }) + + t.Run("Keep the message", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + wantMessage := "Example message" + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil, func(req *codersdk.CreateTemplateVersionRequest) { + req.Message = wantMessage + }) + coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + + t.Log(version.Message) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + updatedVersion, err := client.UpdateTemplateVersion(ctx, version.ID, codersdk.PatchTemplateVersionRequest{ + Message: nil, + }) + + require.NoError(t, err) + assert.Equal(t, wantMessage, updatedVersion.Message) + }) + t.Run("Use the same name if a new name is not passed", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) diff --git a/codersdk/templateversions.go b/codersdk/templateversions.go index de3719fce53c0..86a62541550ef 100644 --- a/codersdk/templateversions.go +++ b/codersdk/templateversions.go @@ -87,7 +87,8 @@ type TemplateVersionVariable struct { } type PatchTemplateVersionRequest struct { - Name string `json:"name" validate:"omitempty,template_version_name"` + Name string `json:"name" validate:"omitempty,template_version_name"` + Message *string `json:"message,omitempty" validate:"omitempty,lt=1048577"` } // TemplateVersion returns a template version by ID. diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 2a0861d413573..9a568004d2652 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -3226,15 +3226,17 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in ```json { + "message": "string", "name": "string" } ``` ### Properties -| Name | Type | Required | Restrictions | Description | -| ------ | ------ | -------- | ------------ | ----------- | -| `name` | string | false | | | +| Name | Type | Required | Restrictions | Description | +| --------- | ------ | -------- | ------------ | ----------- | +| `message` | string | false | | | +| `name` | string | false | | | ## codersdk.PatchWorkspaceProxy diff --git a/docs/api/templates.md b/docs/api/templates.md index 4d2cf8d4874b7..032c620900834 100644 --- a/docs/api/templates.md +++ b/docs/api/templates.md @@ -1249,6 +1249,7 @@ curl -X PATCH http://coder-server:8080/api/v2/templateversions/{templateversion} ```json { + "message": "string", "name": "string" } ``` diff --git a/site/.storybook/preview.jsx b/site/.storybook/preview.jsx index b84e9d25caa48..371a5007ab9d4 100644 --- a/site/.storybook/preview.jsx +++ b/site/.storybook/preview.jsx @@ -5,6 +5,7 @@ import { HelmetProvider } from "react-helmet-async" import { dark } from "../src/theme" import "../src/theme/globalFonts" import "../src/i18n" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" export const decorators = [ (Story) => ( @@ -23,6 +24,13 @@ export const decorators = [ ) }, + (Story) => { + return ( + + + + ) + }, ] export const parameters = { diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index ee1bcda3736e9..5b8eef0ff8f0b 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -644,6 +644,7 @@ export interface PatchGroupRequest { // From codersdk/templateversions.go export interface PatchTemplateVersionRequest { readonly name: string + readonly message?: string } // From codersdk/workspaceproxy.go diff --git a/site/src/components/TemplateVersionEditor/PublishTemplateVersionDialog.tsx b/site/src/components/TemplateVersionEditor/PublishTemplateVersionDialog.tsx index c64cc31a1386f..8fcd96bcce157 100644 --- a/site/src/components/TemplateVersionEditor/PublishTemplateVersionDialog.tsx +++ b/site/src/components/TemplateVersionEditor/PublishTemplateVersionDialog.tsx @@ -32,10 +32,12 @@ export const PublishTemplateVersionDialog: FC< const form = useFormik({ initialValues: { name: defaultName, + message: "", isActiveVersion: false, }, validationSchema: Yup.object({ name: Yup.string().required(), + message: Yup.string(), isActiveVersion: Yup.boolean(), }), onSubmit: onConfirm, @@ -70,6 +72,16 @@ export const PublishTemplateVersionDialog: FC< disabled={isPublishing} /> + + void - ariaLabel?: string -} - -export const OutdatedHelpTooltip: FC> = ({ - onUpdateVersion, - ariaLabel, -}) => { - const styles = useStyles() - - return ( - - {Language.outdatedLabel} - {Language.versionTooltipText} - - - {Language.updateVersionLabel} - - - - ) -} - -const useStyles = makeStyles(() => ({ - icon: { - color: colors.yellow[5], - }, - - button: { - opacity: 1, - - "&:hover": { - opacity: 1, - }, - }, -})) diff --git a/site/src/components/Tooltips/WorkspaceOutdatedTooltip.tsx b/site/src/components/Tooltips/WorkspaceOutdatedTooltip.tsx new file mode 100644 index 0000000000000..a00ab665b5854 --- /dev/null +++ b/site/src/components/Tooltips/WorkspaceOutdatedTooltip.tsx @@ -0,0 +1,140 @@ +import RefreshIcon from "@mui/icons-material/Refresh" +import { FC } from "react" +import { + HelpTooltip, + HelpTooltipAction, + HelpTooltipLinksGroup, + HelpTooltipText, + HelpTooltipTitle, +} from "./HelpTooltip" +import InfoIcon from "@mui/icons-material/InfoOutlined" +import { makeStyles } from "@mui/styles" +import { colors } from "theme/colors" +import { useQuery } from "@tanstack/react-query" +import { getTemplate, getTemplateVersion } from "api/api" +import Box from "@mui/material/Box" +import Skeleton from "@mui/material/Skeleton" +import Link from "@mui/material/Link" + +export const Language = { + outdatedLabel: "Outdated", + versionTooltipText: + "This workspace version is outdated and a newer version is available.", + updateVersionLabel: "Update version", +} + +interface TooltipProps { + onUpdateVersion: () => void + templateId: string + templateName: string + ariaLabel?: string +} + +export const WorkspaceOutdatedTooltip: FC = ({ + onUpdateVersion, + ariaLabel, + templateId, + templateName, +}) => { + const styles = useStyles() + const { data: activeVersion } = useQuery({ + queryFn: async () => { + const template = await getTemplate(templateId) + const activeVersion = await getTemplateVersion(template.active_version_id) + return activeVersion + }, + queryKey: ["templates", templateId, "activeVersion"], + }) + + return ( + + {Language.outdatedLabel} + {Language.versionTooltipText} + + + + theme.palette.text.primary, + fontWeight: 600, + }} + > + New version + + + {activeVersion ? ( + theme.palette.primary.light }} + > + {activeVersion.name} + + ) : ( + + )} + + + + + theme.palette.text.primary, + fontWeight: 600, + }} + > + Message + + + {activeVersion ? ( + activeVersion.message === "" ? ( + "No message" + ) : ( + activeVersion.message + ) + ) : ( + + )} + + + + + + + {Language.updateVersionLabel} + + + + ) +} + +const useStyles = makeStyles(() => ({ + icon: { + color: colors.yellow[5], + }, + + button: { + opacity: 1, + + "&:hover": { + opacity: 1, + }, + }, +})) diff --git a/site/src/components/Tooltips/index.ts b/site/src/components/Tooltips/index.ts index a0f00ee3b8764..a1b1d768038da 100644 --- a/site/src/components/Tooltips/index.ts +++ b/site/src/components/Tooltips/index.ts @@ -1,4 +1,4 @@ export { AuditHelpTooltip } from "./AuditHelpTooltip" -export { OutdatedHelpTooltip } from "./OutdatedHelpTooltip" +export { WorkspaceOutdatedTooltip } from "./WorkspaceOutdatedTooltip" export { UserRoleHelpTooltip } from "./UserRoleHelpTooltip" export { WorkspaceHelpTooltip } from "./WorkspaceHelpTooltip" diff --git a/site/src/components/WorkspaceStats/WorkspaceStats.tsx b/site/src/components/WorkspaceStats/WorkspaceStats.tsx index c7d2f1a941de9..9a474d152f83e 100644 --- a/site/src/components/WorkspaceStats/WorkspaceStats.tsx +++ b/site/src/components/WorkspaceStats/WorkspaceStats.tsx @@ -1,5 +1,5 @@ import Link from "@mui/material/Link" -import { OutdatedHelpTooltip } from "components/Tooltips" +import { WorkspaceOutdatedTooltip } from "components/Tooltips" import { FC, useRef, useState } from "react" import { Link as RouterLink } from "react-router-dom" import { createDayString } from "utils/createDayString" @@ -101,7 +101,9 @@ export const WorkspaceStats: FC = ({ {workspace.outdated && ( - diff --git a/site/src/components/WorkspacesTable/WorkspacesRow.tsx b/site/src/components/WorkspacesTable/WorkspacesRow.tsx index fb76f4c298c33..d1e34b33294ee 100644 --- a/site/src/components/WorkspacesTable/WorkspacesRow.tsx +++ b/site/src/components/WorkspacesTable/WorkspacesRow.tsx @@ -9,7 +9,7 @@ import { useNavigate } from "react-router-dom" import { getDisplayWorkspaceTemplateName } from "utils/workspace" import { LastUsed } from "../LastUsed/LastUsed" import { Workspace } from "api/typesGenerated" -import { OutdatedHelpTooltip } from "components/Tooltips/OutdatedHelpTooltip" +import { WorkspaceOutdatedTooltip } from "components/Tooltips/WorkspaceOutdatedTooltip" import { Avatar } from "components/Avatar/Avatar" import { Stack } from "components/Stack/Stack" import { useClickableTableRow } from "hooks/useClickableTableRow" @@ -42,7 +42,9 @@ export const WorkspacesRow: FC<{ {workspace.name} {workspace.outdated && ( - { onUpdateWorkspace(workspace) }} diff --git a/site/src/pages/TemplateVersionPage/TemplateVersionEditorPage/TemplateVersionEditorPage.test.tsx b/site/src/pages/TemplateVersionPage/TemplateVersionEditorPage/TemplateVersionEditorPage.test.tsx index 39c01932d7d8c..b969363951fc8 100644 --- a/site/src/pages/TemplateVersionPage/TemplateVersionEditorPage/TemplateVersionEditorPage.test.tsx +++ b/site/src/pages/TemplateVersionPage/TemplateVersionEditorPage/TemplateVersionEditorPage.test.tsx @@ -16,7 +16,7 @@ jest.mock("components/TemplateResourcesTable/TemplateResourcesTable", () => { } }) -test("Use custom name and set it as active when publishing", async () => { +test("Use custom name, message and set it as active when publishing", async () => { const user = userEvent.setup() renderWithAuth(, { extraRoutes: [ @@ -64,6 +64,9 @@ test("Use custom name and set it as active when publishing", async () => { const nameField = within(publishDialog).getByLabelText("Version name") await user.clear(nameField) await user.type(nameField, "v1.0") + const messageField = within(publishDialog).getByLabelText("Message") + await user.clear(messageField) + await user.type(messageField, "Informative message") await user.click( within(publishDialog).getByLabelText("Promote to default version"), ) @@ -73,6 +76,7 @@ test("Use custom name and set it as active when publishing", async () => { await waitFor(() => { expect(patchTemplateVersion).toBeCalledWith("new-version-id", { name: "v1.0", + message: "Informative message", }) }) expect(updateActiveTemplateVersion).toBeCalledWith("test-template", { @@ -134,12 +138,17 @@ test("Do not mark as active if promote is not checked", async () => { await waitFor(() => { expect(patchTemplateVersion).toBeCalledWith("new-version-id", { name: "v1.0", + message: "", }) }) expect(updateActiveTemplateVersion).toBeCalledTimes(0) }) -test("Patch request is not send when the name is not updated", async () => { +test("Patch request is not send when there are no changes", async () => { + const MockTemplateVersionWithEmptyMessage = { + ...MockTemplateVersion, + message: "", + } const user = userEvent.setup() renderWithAuth(, { extraRoutes: [ @@ -155,10 +164,11 @@ test("Patch request is not send when the name is not updated", async () => { jest.spyOn(api, "uploadTemplateFile").mockResolvedValueOnce({ hash: "hash" }) jest .spyOn(api, "createTemplateVersion") - .mockResolvedValueOnce(MockTemplateVersion) - jest - .spyOn(api, "getTemplateVersion") - .mockResolvedValue({ ...MockTemplateVersion, id: "new-version-id" }) + .mockResolvedValueOnce(MockTemplateVersionWithEmptyMessage) + jest.spyOn(api, "getTemplateVersion").mockResolvedValue({ + ...MockTemplateVersionWithEmptyMessage, + id: "new-version-id", + }) jest .spyOn(api, "watchBuildLogsByTemplateVersionId") .mockImplementation((_, options) => { @@ -174,7 +184,7 @@ test("Patch request is not send when the name is not updated", async () => { // Publish const patchTemplateVersion = jest .spyOn(api, "patchTemplateVersion") - .mockResolvedValue(MockTemplateVersion) + .mockResolvedValue(MockTemplateVersionWithEmptyMessage) await within(topbar).findByText("Success") const publishButton = within(topbar).getByRole("button", { name: "Publish version", @@ -183,7 +193,7 @@ test("Patch request is not send when the name is not updated", async () => { const publishDialog = await screen.findByTestId("dialog") // It is using the name from the template version const nameField = within(publishDialog).getByLabelText("Version name") - expect(nameField).toHaveValue(MockTemplateVersion.name) + expect(nameField).toHaveValue(MockTemplateVersionWithEmptyMessage.name) // Publish await user.click( within(publishDialog).getByRole("button", { name: "Publish" }), diff --git a/site/src/pages/TemplateVersionPage/TemplateVersionEditorPage/types.ts b/site/src/pages/TemplateVersionPage/TemplateVersionEditorPage/types.ts index ca8dc50c48ebe..f3ab9aa537e3e 100644 --- a/site/src/pages/TemplateVersionPage/TemplateVersionEditorPage/types.ts +++ b/site/src/pages/TemplateVersionPage/TemplateVersionEditorPage/types.ts @@ -1,4 +1,5 @@ export type PublishVersionData = { name: string + message: string isActiveVersion: boolean } diff --git a/site/src/pages/TemplateVersionPage/TemplateVersionPageView.tsx b/site/src/pages/TemplateVersionPage/TemplateVersionPageView.tsx index ca51318043d17..5a87a120f77ae 100644 --- a/site/src/pages/TemplateVersionPage/TemplateVersionPageView.tsx +++ b/site/src/pages/TemplateVersionPage/TemplateVersionPageView.tsx @@ -6,6 +6,7 @@ import { Margins } from "components/Margins/Margins" import { PageHeader, PageHeaderCaption, + PageHeaderSubtitle, PageHeaderTitle, } from "components/PageHeader/PageHeader" import { Stack } from "components/Stack/Stack" @@ -52,6 +53,11 @@ export const TemplateVersionPageView: FC = ({ > {t("header.caption")} {versionName} + {currentVersion && + currentVersion.message && + currentVersion.message !== "" && ( + {currentVersion.message} + )} {!currentFiles && !error && } diff --git a/site/src/xServices/templateVersionEditor/templateVersionEditorXService.ts b/site/src/xServices/templateVersionEditor/templateVersionEditorXService.ts index 853b417423f90..e540ec39317c8 100644 --- a/site/src/xServices/templateVersionEditor/templateVersionEditorXService.ts +++ b/site/src/xServices/templateVersionEditor/templateVersionEditorXService.ts @@ -387,7 +387,7 @@ export const templateVersionEditorMachine = createMachine( }, publishingVersion: async ( { version, templateId }, - { name, isActiveVersion }, + { name, message, isActiveVersion }, ) => { if (!version) { throw new Error("Version is not set") @@ -395,10 +395,10 @@ export const templateVersionEditorMachine = createMachine( if (!templateId) { throw new Error("Template is not set") } + const haveChanges = name !== version.name || message !== version.message await Promise.all([ - // Only do a patch if the name is different - name !== version.name - ? API.patchTemplateVersion(version.id, { name }) + haveChanges + ? API.patchTemplateVersion(version.id, { name, message }) : Promise.resolve(), isActiveVersion ? API.updateActiveTemplateVersion(templateId, {