From 5b80c47e8cb7464dd8e86f1bdeef3b7a01e1cb5a Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 6 Aug 2025 10:41:01 -0500 Subject: [PATCH 1/4] feat: add `author` filter command to template filtering (#19202) Can do `author:username` to filter templates created by a certain author. Adding to help clean out some templates that I created on our dev instance. This makes sorting a bit easier. --- coderd/database/modelqueries.go | 2 ++ coderd/database/queries.sql.go | 17 ++++++++++++ coderd/database/queries/templates.sql | 13 +++++++++ coderd/searchquery/search.go | 4 ++- coderd/templates_test.go | 40 +++++++++++++++++++++++++++ codersdk/organizations.go | 6 ++++ 6 files changed, 81 insertions(+), 1 deletion(-) diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index 2a0abbccfdd9b..dceddd2e8c3da 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -82,6 +82,8 @@ func (q *sqlQuerier) GetAuthorizedTemplates(ctx context.Context, arg GetTemplate pq.Array(arg.IDs), arg.Deprecated, arg.HasAITask, + arg.AuthorID, + arg.AuthorUsername, ) if err != nil { return nil, err diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 2c1381a3b99f1..b078e2dbb29c0 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -12059,6 +12059,19 @@ WHERE tv.has_ai_task = $7 :: boolean ELSE true END + -- Filter by author_id + AND CASE + WHEN $8 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN + t.created_by = $8 + ELSE true + END + -- Filter by author_username + AND CASE + WHEN $9 :: text != '' THEN + t.created_by = (SELECT id FROM users WHERE lower(users.username) = lower($9) AND deleted = false) + ELSE true + END + -- Authorize Filter clause will be injected below in GetAuthorizedTemplates -- @authorize_filter ORDER BY (t.name, t.id) ASC @@ -12072,6 +12085,8 @@ type GetTemplatesWithFilterParams struct { IDs []uuid.UUID `db:"ids" json:"ids"` Deprecated sql.NullBool `db:"deprecated" json:"deprecated"` HasAITask sql.NullBool `db:"has_ai_task" json:"has_ai_task"` + AuthorID uuid.UUID `db:"author_id" json:"author_id"` + AuthorUsername string `db:"author_username" json:"author_username"` } func (q *sqlQuerier) GetTemplatesWithFilter(ctx context.Context, arg GetTemplatesWithFilterParams) ([]Template, error) { @@ -12083,6 +12098,8 @@ func (q *sqlQuerier) GetTemplatesWithFilter(ctx context.Context, arg GetTemplate pq.Array(arg.IDs), arg.Deprecated, arg.HasAITask, + arg.AuthorID, + arg.AuthorUsername, ) if err != nil { return nil, err diff --git a/coderd/database/queries/templates.sql b/coderd/database/queries/templates.sql index 4a37bd2d1058b..a922a9bef1918 100644 --- a/coderd/database/queries/templates.sql +++ b/coderd/database/queries/templates.sql @@ -59,6 +59,19 @@ WHERE tv.has_ai_task = sqlc.narg('has_ai_task') :: boolean ELSE true END + -- Filter by author_id + AND CASE + WHEN @author_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN + t.created_by = @author_id + ELSE true + END + -- Filter by author_username + AND CASE + WHEN @author_username :: text != '' THEN + t.created_by = (SELECT id FROM users WHERE lower(users.username) = lower(@author_username) AND deleted = false) + ELSE true + END + -- Authorize Filter clause will be injected below in GetAuthorizedTemplates -- @authorize_filter ORDER BY (t.name, t.id) ASC diff --git a/coderd/searchquery/search.go b/coderd/searchquery/search.go index d35f3c94b5ff7..4f3f3259c09d9 100644 --- a/coderd/searchquery/search.go +++ b/coderd/searchquery/search.go @@ -278,12 +278,14 @@ func Templates(ctx context.Context, db database.Store, query string) (database.G parser := httpapi.NewQueryParamParser() filter := database.GetTemplatesWithFilterParams{ Deleted: parser.Boolean(values, false, "deleted"), + OrganizationID: parseOrganization(ctx, db, parser, values, "organization"), ExactName: parser.String(values, "", "exact_name"), FuzzyName: parser.String(values, "", "name"), IDs: parser.UUIDs(values, []uuid.UUID{}, "ids"), Deprecated: parser.NullableBoolean(values, sql.NullBool{}, "deprecated"), - OrganizationID: parseOrganization(ctx, db, parser, values, "organization"), HasAITask: parser.NullableBoolean(values, sql.NullBool{}, "has-ai-task"), + AuthorID: parser.UUID(values, uuid.Nil, "author_id"), + AuthorUsername: parser.String(values, "", "author"), } parser.ErrorExcessParams(values) diff --git a/coderd/templates_test.go b/coderd/templates_test.go index 0858ce83325cc..8cd5a6ba9bf30 100644 --- a/coderd/templates_test.go +++ b/coderd/templates_test.go @@ -814,6 +814,46 @@ func TestTemplatesByOrganization(t *testing.T) { require.False(t, templates[0].Deprecated) require.Empty(t, templates[0].DeprecationMessage) }) + + t.Run("ListByAuthor", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + owner := coderdtest.CreateFirstUser(t, client) + adminAlpha, adminAlphaData := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin()) + adminBravo, adminBravoData := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin()) + adminCharlie, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin()) + + versionA := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) + versionB := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) + versionC := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) + foo := coderdtest.CreateTemplate(t, adminAlpha, owner.OrganizationID, versionA.ID, func(request *codersdk.CreateTemplateRequest) { + request.Name = "foo" + }) + bar := coderdtest.CreateTemplate(t, adminBravo, owner.OrganizationID, versionB.ID, func(request *codersdk.CreateTemplateRequest) { + request.Name = "bar" + }) + _ = coderdtest.CreateTemplate(t, adminCharlie, owner.OrganizationID, versionC.ID, func(request *codersdk.CreateTemplateRequest) { + request.Name = "baz" + }) + + ctx := testutil.Context(t, testutil.WaitLong) + + // List alpha + alpha, err := client.Templates(ctx, codersdk.TemplateFilter{ + AuthorUsername: adminAlphaData.Username, + }) + require.NoError(t, err) + require.Len(t, alpha, 1) + require.Equal(t, foo.ID, alpha[0].ID) + + // List bravo + bravo, err := client.Templates(ctx, codersdk.TemplateFilter{ + AuthorUsername: adminBravoData.Username, + }) + require.NoError(t, err) + require.Len(t, bravo, 1) + require.Equal(t, bar.ID, bravo[0].ID) + }) } func TestTemplateByOrganizationAndName(t *testing.T) { diff --git a/codersdk/organizations.go b/codersdk/organizations.go index 86bc47bce2375..f87d0eae188ba 100644 --- a/codersdk/organizations.go +++ b/codersdk/organizations.go @@ -541,6 +541,7 @@ type TemplateFilter struct { OrganizationID uuid.UUID `typescript:"-"` ExactName string `typescript:"-"` FuzzyName string `typescript:"-"` + AuthorUsername string `typescript:"-"` SearchQuery string `json:"q,omitempty"` } @@ -562,6 +563,11 @@ func (f TemplateFilter) asRequestOption() RequestOption { if f.FuzzyName != "" { params = append(params, fmt.Sprintf("name:%q", f.FuzzyName)) } + + if f.AuthorUsername != "" { + params = append(params, fmt.Sprintf("author:%q", f.AuthorUsername)) + } + if f.SearchQuery != "" { params = append(params, f.SearchQuery) } From 3024bdebcb5d641076a37b577e9def92d3bb94dc Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 6 Aug 2025 11:45:07 -0500 Subject: [PATCH 2/4] chore: support 'me' as the username for template author (#19204) `author:me` to find my templates. Much nicer than knowing my own username --- coderd/searchquery/search.go | 7 ++++++- coderd/searchquery/search_test.go | 11 ++++++++++- coderd/templates.go | 3 ++- coderd/templates_test.go | 6 +++--- 4 files changed, 21 insertions(+), 6 deletions(-) diff --git a/coderd/searchquery/search.go b/coderd/searchquery/search.go index 4f3f3259c09d9..cbaaa74a848eb 100644 --- a/coderd/searchquery/search.go +++ b/coderd/searchquery/search.go @@ -263,7 +263,7 @@ func Workspaces(ctx context.Context, db database.Store, query string, page coder return filter, parser.Errors } -func Templates(ctx context.Context, db database.Store, query string) (database.GetTemplatesWithFilterParams, []codersdk.ValidationError) { +func Templates(ctx context.Context, db database.Store, actorID uuid.UUID, query string) (database.GetTemplatesWithFilterParams, []codersdk.ValidationError) { // Always lowercase for all searches. query = strings.ToLower(query) values, errors := searchTerms(query, func(term string, values url.Values) error { @@ -288,6 +288,11 @@ func Templates(ctx context.Context, db database.Store, query string) (database.G AuthorUsername: parser.String(values, "", "author"), } + if filter.AuthorUsername == codersdk.Me { + filter.AuthorID = actorID + filter.AuthorUsername = "" + } + parser.ErrorExcessParams(values) return filter, parser.Errors } diff --git a/coderd/searchquery/search_test.go b/coderd/searchquery/search_test.go index 4744b57edff4a..5c45274668b25 100644 --- a/coderd/searchquery/search_test.go +++ b/coderd/searchquery/search_test.go @@ -640,6 +640,7 @@ func TestSearchUsers(t *testing.T) { func TestSearchTemplates(t *testing.T) { t.Parallel() + userID := uuid.New() testCases := []struct { Name string Query string @@ -688,6 +689,14 @@ func TestSearchTemplates(t *testing.T) { }, }, }, + { + Name: "MyTemplates", + Query: "author:me", + Expected: database.GetTemplatesWithFilterParams{ + AuthorUsername: "", + AuthorID: userID, + }, + }, } for _, c := range testCases { @@ -696,7 +705,7 @@ func TestSearchTemplates(t *testing.T) { // Do not use a real database, this is only used for an // organization lookup. db, _ := dbtestutil.NewDB(t) - values, errs := searchquery.Templates(context.Background(), db, c.Query) + values, errs := searchquery.Templates(context.Background(), db, userID, c.Query) if c.ExpectedErrorContains != "" { require.True(t, len(errs) > 0, "expect some errors") var s strings.Builder diff --git a/coderd/templates.go b/coderd/templates.go index 694bb90b86a4d..f9c5d8271a1e6 100644 --- a/coderd/templates.go +++ b/coderd/templates.go @@ -544,9 +544,10 @@ func (api *API) templatesByOrganization() http.HandlerFunc { func (api *API) fetchTemplates(mutate func(r *http.Request, arg *database.GetTemplatesWithFilterParams)) http.HandlerFunc { return func(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() + key := httpmw.APIKey(r) queryStr := r.URL.Query().Get("q") - filter, errs := searchquery.Templates(ctx, api.Database, queryStr) + filter, errs := searchquery.Templates(ctx, api.Database, key.UserID, queryStr) if len(errs) > 0 { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Invalid template search query.", diff --git a/coderd/templates_test.go b/coderd/templates_test.go index 8cd5a6ba9bf30..050ae77f8ca49 100644 --- a/coderd/templates_test.go +++ b/coderd/templates_test.go @@ -820,7 +820,7 @@ func TestTemplatesByOrganization(t *testing.T) { client := coderdtest.New(t, nil) owner := coderdtest.CreateFirstUser(t, client) adminAlpha, adminAlphaData := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin()) - adminBravo, adminBravoData := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin()) + adminBravo, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin()) adminCharlie, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin()) versionA := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) @@ -847,8 +847,8 @@ func TestTemplatesByOrganization(t *testing.T) { require.Equal(t, foo.ID, alpha[0].ID) // List bravo - bravo, err := client.Templates(ctx, codersdk.TemplateFilter{ - AuthorUsername: adminBravoData.Username, + bravo, err := adminBravo.Templates(ctx, codersdk.TemplateFilter{ + AuthorUsername: codersdk.Me, }) require.NoError(t, err) require.Len(t, bravo, 1) From 1c70d32ef36593011af9fdb5c3b082cc97ed3775 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Wed, 6 Aug 2025 18:24:38 +0100 Subject: [PATCH 3/4] fix(site): hide "Show parent apps" when no running or starting devcontainers (#19200) Fixes https://github.com/coder/coder/issues/19199 We now hide the "Show parent apps" button when there are no running or starting devcontainers. --- .../modules/resources/AgentRow.stories.tsx | 43 ++++++++++++++++--- site/src/modules/resources/AgentRow.tsx | 21 +++++---- 2 files changed, 48 insertions(+), 16 deletions(-) diff --git a/site/src/modules/resources/AgentRow.stories.tsx b/site/src/modules/resources/AgentRow.stories.tsx index a5ad16ae9f97b..c1bc40e98eb1d 100644 --- a/site/src/modules/resources/AgentRow.stories.tsx +++ b/site/src/modules/resources/AgentRow.stories.tsx @@ -286,10 +286,43 @@ export const GroupApp: Story = { }; export const Devcontainer: Story = { - beforeEach: () => { - spyOn(API, "getAgentContainers").mockResolvedValue({ - devcontainers: [M.MockWorkspaceAgentDevcontainer], - containers: [M.MockWorkspaceAgentContainer], - }); + parameters: { + queries: [ + { + key: ["agents", M.MockWorkspaceAgent.id, "containers"], + data: { + devcontainers: [M.MockWorkspaceAgentDevcontainer], + containers: [M.MockWorkspaceAgentContainer], + }, + }, + ], + webSocket: [], + }, +}; + +export const FoundDevcontainer: Story = { + args: { + agent: { + ...M.MockWorkspaceAgentReady, + }, + }, + parameters: { + queries: [ + { + key: ["agents", M.MockWorkspaceAgentReady.id, "containers"], + data: { + devcontainers: [ + { + ...M.MockWorkspaceAgentDevcontainer, + status: "stopped", + container: undefined, + agent: undefined, + }, + ], + containers: [], + }, + }, + ], + webSocket: [], }, }; diff --git a/site/src/modules/resources/AgentRow.tsx b/site/src/modules/resources/AgentRow.tsx index ab0e5884c48e9..3cf757a15c2ab 100644 --- a/site/src/modules/resources/AgentRow.tsx +++ b/site/src/modules/resources/AgentRow.tsx @@ -137,17 +137,16 @@ export const AgentRow: FC = ({ // This is used to show the parent apps of the devcontainer. const [showParentApps, setShowParentApps] = useState(false); - let shouldDisplayAppsSection = shouldDisplayAgentApps; - if ( - devcontainers && - devcontainers.find( - // We only want to hide the parent apps by default when there are dev - // containers that are either starting or running. If they are all in - // the stopped state, it doesn't make sense to hide the parent apps. + const anyRunningOrStartingDevcontainers = + devcontainers?.find( (dc) => dc.status === "running" || dc.status === "starting", - ) !== undefined && - !showParentApps - ) { + ) !== undefined; + + // We only want to hide the parent apps by default when there are dev + // containers that are either starting or running. If they are all in + // the stopped state, it doesn't make sense to hide the parent apps. + let shouldDisplayAppsSection = shouldDisplayAgentApps; + if (anyRunningOrStartingDevcontainers && !showParentApps) { shouldDisplayAppsSection = false; } @@ -187,7 +186,7 @@ export const AgentRow: FC = ({
- {devcontainers && devcontainers.length > 0 && ( + {anyRunningOrStartingDevcontainers && (