fix: hide "Create Workspace" button for deleted templates (#22092)

**Background**

Reported in #17417, there is a `deleted` query parameter supported by
/api/v2/templates, but we do not respect this field on the client,
showing the "Create Workspace" button for deleted templates.

**Expected Behavior**

Don't show the "Create Workspace" button for deleted templates.

**Notes**

This PR adds a new `deleted` field to the templates API response.

Co-authored-by: Danielle Maywood <danielle@themaywoods.com>
This commit is contained in:
Jeremy Ruppel
2026-02-13 19:44:50 -05:00
committed by GitHub
parent ebd7ab11cb
commit 0df864fb88
11 changed files with 118 additions and 19 deletions
+3
View File
@@ -18919,6 +18919,9 @@ const docTemplate = `{
"default_ttl_ms": {
"type": "integer"
},
"deleted": {
"type": "boolean"
},
"deprecated": {
"type": "boolean"
},
+3
View File
@@ -17306,6 +17306,9 @@
"default_ttl_ms": {
"type": "integer"
},
"deleted": {
"type": "boolean"
},
"deprecated": {
"type": "boolean"
},
+1
View File
@@ -1131,6 +1131,7 @@ func (api *API) convertTemplate(
RequireActiveVersion: templateAccessControl.RequireActiveVersion,
Deprecated: templateAccessControl.IsDeprecated(),
DeprecationMessage: templateAccessControl.Deprecated,
Deleted: template.Deleted,
MaxPortShareLevel: maxPortShareLevel,
UseClassicParameterFlow: template.UseClassicParameterFlow,
CORSBehavior: codersdk.CORSBehavior(template.CorsBehavior),
+43
View File
@@ -1801,6 +1801,49 @@ func TestDeleteTemplate(t *testing.T) {
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusForbidden, apiErr.StatusCode())
})
t.Run("DeletedIsSet", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
ctx := testutil.Context(t, testutil.WaitLong)
// Verify the deleted field is exposed in the SDK and set to false for active templates
got, err := client.Template(ctx, template.ID)
require.NoError(t, err)
require.False(t, got.Deleted)
})
t.Run("DeletedIsTrue", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
ctx := testutil.Context(t, testutil.WaitLong)
err := client.DeleteTemplate(ctx, template.ID)
require.NoError(t, err)
// Verify the deleted field is set to true by listing templates with
// deleted:true filter.
templates, err := client.Templates(ctx, codersdk.TemplateFilter{
OrganizationID: user.OrganizationID,
SearchQuery: "deleted:true",
})
require.NoError(t, err)
require.Len(t, templates, 1)
require.Equal(t, template.ID, templates[0].ID)
require.True(t, templates[0].Deleted)
})
}
func TestTemplateMetrics(t *testing.T) {
+1
View File
@@ -32,6 +32,7 @@ type Template struct {
Description string `json:"description"`
Deprecated bool `json:"deprecated"`
DeprecationMessage string `json:"deprecation_message"`
Deleted bool `json:"deleted"`
Icon string `json:"icon"`
DefaultTTLMillis int64 `json:"default_ttl_ms"`
ActivityBumpMillis int64 `json:"activity_bump_ms"`
+2
View File
@@ -8443,6 +8443,7 @@ Only certain features set these fields: - FeatureManagedAgentLimit|
"created_by_id": "9377d689-01fb-4abf-8450-3368d2c1924f",
"created_by_name": "string",
"default_ttl_ms": 0,
"deleted": true,
"deprecated": true,
"deprecation_message": "string",
"description": "string",
@@ -8484,6 +8485,7 @@ Only certain features set these fields: - FeatureManagedAgentLimit|
| `created_by_id` | string | false | | |
| `created_by_name` | string | false | | |
| `default_ttl_ms` | integer | false | | |
| `deleted` | boolean | false | | |
| `deprecated` | boolean | false | | |
| `deprecation_message` | string | false | | |
| `description` | string | false | | |
+8
View File
@@ -62,6 +62,7 @@ To include deprecated templates, specify `deprecated:true` in the search query.
"created_by_id": "9377d689-01fb-4abf-8450-3368d2c1924f",
"created_by_name": "string",
"default_ttl_ms": 0,
"deleted": true,
"deprecated": true,
"deprecation_message": "string",
"description": "string",
@@ -120,6 +121,7 @@ Restarts will only happen on weekdays in this list on weeks which line up with W
|`» created_by_id`|string(uuid)|false|||
|`» created_by_name`|string|false|||
|`» default_ttl_ms`|integer|false|||
|`» deleted`|boolean|false|||
|`» deprecated`|boolean|false|||
|`» deprecation_message`|string|false|||
|`» description`|string|false|||
@@ -246,6 +248,7 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/templa
"created_by_id": "9377d689-01fb-4abf-8450-3368d2c1924f",
"created_by_name": "string",
"default_ttl_ms": 0,
"deleted": true,
"deprecated": true,
"deprecation_message": "string",
"description": "string",
@@ -397,6 +400,7 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/templat
"created_by_id": "9377d689-01fb-4abf-8450-3368d2c1924f",
"created_by_name": "string",
"default_ttl_ms": 0,
"deleted": true,
"deprecated": true,
"deprecation_message": "string",
"description": "string",
@@ -814,6 +818,7 @@ To include deprecated templates, specify `deprecated:true` in the search query.
"created_by_id": "9377d689-01fb-4abf-8450-3368d2c1924f",
"created_by_name": "string",
"default_ttl_ms": 0,
"deleted": true,
"deprecated": true,
"deprecation_message": "string",
"description": "string",
@@ -872,6 +877,7 @@ Restarts will only happen on weekdays in this list on weeks which line up with W
|`» created_by_id`|string(uuid)|false|||
|`» created_by_name`|string|false|||
|`» default_ttl_ms`|integer|false|||
|`» deleted`|boolean|false|||
|`» deprecated`|boolean|false|||
|`» deprecation_message`|string|false|||
|`» description`|string|false|||
@@ -1016,6 +1022,7 @@ curl -X GET http://coder-server:8080/api/v2/templates/{template} \
"created_by_id": "9377d689-01fb-4abf-8450-3368d2c1924f",
"created_by_name": "string",
"default_ttl_ms": 0,
"deleted": true,
"deprecated": true,
"deprecation_message": "string",
"description": "string",
@@ -1189,6 +1196,7 @@ curl -X PATCH http://coder-server:8080/api/v2/templates/{template} \
"created_by_id": "9377d689-01fb-4abf-8450-3368d2c1924f",
"created_by_name": "string",
"default_ttl_ms": 0,
"deleted": true,
"deprecated": true,
"deprecation_message": "string",
"description": "string",
+1
View File
@@ -5155,6 +5155,7 @@ export interface Template {
readonly description: string;
readonly deprecated: boolean;
readonly deprecation_message: string;
readonly deleted: boolean;
readonly icon: string;
readonly default_ttl_ms: number;
readonly activity_bump_ms: number;
@@ -82,6 +82,13 @@ export const WithTemplates: Story = {
display_name: "Deprecated",
description: "Template is incompatible",
},
{
...MockTemplate,
name: "deleted-template",
display_name: "Deleted",
description: "Template has been deleted",
deleted: true,
},
],
examples: [],
workspacePermissions: {
@@ -85,6 +85,49 @@ const TemplateHelpTooltip: FC = () => {
);
};
interface TemplateActionsProps {
template: Template;
workspacePermissions: Record<string, WorkspacePermissions> | undefined;
templatePageLink: string;
}
const TemplateActions: FC<TemplateActionsProps> = ({
template,
workspacePermissions,
templatePageLink,
}) => {
if (template.deleted) {
return null;
}
if (template.deprecated) {
return <DeprecatedBadge />;
}
if (
!workspacePermissions?.[template.organization_id]?.createWorkspaceForUserID
) {
return null;
}
return (
<Button
asChild
variant="outline"
size="sm"
title={`Create a workspace using the ${template.display_name} template`}
onClick={(e) => {
e.stopPropagation();
}}
>
<RouterLink to={`${templatePageLink}/workspace`}>
<ArrowRightIcon />
Create Workspace
</RouterLink>
</Button>
);
};
interface TemplateRowProps {
showOrganizations: boolean;
template: Template;
@@ -149,25 +192,11 @@ const TemplateRow: FC<TemplateRowProps> = ({
</TableCell>
<TableCell css={styles.actionCell}>
{template.deprecated ? (
<DeprecatedBadge />
) : workspacePermissions?.[template.organization_id]
?.createWorkspaceForUserID ? (
<Button
asChild
variant="outline"
size="sm"
title={`Create a workspace using the ${template.display_name} template`}
onClick={(e) => {
e.stopPropagation();
}}
>
<RouterLink to={`${templatePageLink}/workspace`}>
<ArrowRightIcon />
Create Workspace
</RouterLink>
</Button>
) : null}
<TemplateActions
template={template}
workspacePermissions={workspacePermissions}
templatePageLink={templatePageLink}
/>
</TableCell>
</TableRow>
);
+1
View File
@@ -852,6 +852,7 @@ export const MockTemplate: TypesGen.Template = {
require_active_version: false,
deprecated: false,
deprecation_message: "",
deleted: false,
max_port_share_level: "public",
use_classic_parameter_flow: false,
cors_behavior: "simple",