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..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 { @@ -278,12 +278,19 @@ 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"), + } + + if filter.AuthorUsername == codersdk.Me { + filter.AuthorID = actorID + filter.AuthorUsername = "" } parser.ErrorExcessParams(values) 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 0858ce83325cc..050ae77f8ca49 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, _ := 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 := adminBravo.Templates(ctx, codersdk.TemplateFilter{ + AuthorUsername: codersdk.Me, + }) + 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) } diff --git a/go.mod b/go.mod index 748e6538fdcba..be89bfcbb8747 100644 --- a/go.mod +++ b/go.mod @@ -479,7 +479,7 @@ require ( require ( github.com/coder/agentapi-sdk-go v0.0.0-20250505131810-560d1d88d225 github.com/coder/aisdk-go v0.0.9 - github.com/coder/preview v1.0.3-0.20250714153828-a737d4750448 + github.com/coder/preview v1.0.3 github.com/fsnotify/fsnotify v1.9.0 github.com/go-git/go-git/v5 v5.16.2 github.com/mark3labs/mcp-go v0.36.0 diff --git a/go.sum b/go.sum index cb29e1ab12f81..a4124c70ab7e9 100644 --- a/go.sum +++ b/go.sum @@ -920,8 +920,8 @@ github.com/coder/pq v1.10.5-0.20250630052411-a259f96b6102 h1:ahTJlTRmTogsubgRVGO github.com/coder/pq v1.10.5-0.20250630052411-a259f96b6102/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0 h1:3A0ES21Ke+FxEM8CXx9n47SZOKOpgSE1bbJzlE4qPVs= github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0/go.mod h1:5UuS2Ts+nTToAMeOjNlnHFkPahrtDkmpydBen/3wgZc= -github.com/coder/preview v1.0.3-0.20250714153828-a737d4750448 h1:S86sFp4Dr4dUn++fXOMOTu6ClnEZ/NrGCYv7bxZjYYc= -github.com/coder/preview v1.0.3-0.20250714153828-a737d4750448/go.mod h1:hQtBEqOFMJ3SHl9Q9pVvDA9CpeCEXBwbONNK29+3MLk= +github.com/coder/preview v1.0.3 h1:et0/frnLB68PPwsGaa1KAZQdBKBxNSqzMplYKsBpcNA= +github.com/coder/preview v1.0.3/go.mod h1:hQtBEqOFMJ3SHl9Q9pVvDA9CpeCEXBwbONNK29+3MLk= github.com/coder/quartz v0.2.1 h1:QgQ2Vc1+mvzewg2uD/nj8MJ9p9gE+QhGJm+Z+NGnrSE= github.com/coder/quartz v0.2.1/go.mod h1:vsiCc+AHViMKH2CQpGIpFgdHIEQsxwm8yCscqKmzbRA= github.com/coder/retry v1.5.1 h1:iWu8YnD8YqHs3XwqrqsjoBTAVqT9ml6z9ViJ2wlMiqc= 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 && (