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, {