diff --git a/coderd/chats.go b/coderd/chats.go index 7f1b172b91..8cfdad06cb 100644 --- a/coderd/chats.go +++ b/coderd/chats.go @@ -39,6 +39,7 @@ import ( "github.com/coder/coder/v2/coderd/pubsub" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/rbac/policy" + "github.com/coder/coder/v2/coderd/searchquery" "github.com/coder/coder/v2/coderd/tracing" "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" @@ -145,27 +146,25 @@ func (api *API) listChats(rw http.ResponseWriter, r *http.Request) { return } + queryStr := r.URL.Query().Get("q") + searchParams, errs := searchquery.Chats(queryStr) + if len(errs) > 0 { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid chat search query.", + Validations: errs, + }) + return + } + params := database.GetChatsByOwnerIDParams{ - OwnerID: apiKey.UserID, - AfterID: paginationParams.AfterID, + OwnerID: apiKey.UserID, + Archived: searchParams.Archived, + AfterID: paginationParams.AfterID, // #nosec G115 - Pagination offsets are small and fit in int32 OffsetOpt: int32(paginationParams.Offset), // #nosec G115 - Pagination limits are small and fit in int32 LimitOpt: int32(paginationParams.Limit), } - if v := r.URL.Query().Get("archived"); v != "" { - b, err := strconv.ParseBool(v) - if err != nil { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Invalid query parameter.", - Validations: []codersdk.ValidationError{ - {Field: "archived", Detail: "Must be a valid boolean"}, - }, - }) - return - } - params.Archived = sql.NullBool{Bool: b, Valid: true} - } chats, err := api.Database.GetChatsByOwnerID(ctx, params) if err != nil { diff --git a/coderd/chats_test.go b/coderd/chats_test.go index b016c6bf9d..458c74e714 100644 --- a/coderd/chats_test.go +++ b/coderd/chats_test.go @@ -20,7 +20,6 @@ import ( "github.com/coder/coder/v2/coderd/database/dbfake" "github.com/coder/coder/v2/coderd/externalauth" coderdpubsub "github.com/coder/coder/v2/coderd/pubsub" - "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" "github.com/coder/websocket" @@ -1279,30 +1278,30 @@ func TestArchiveChat(t *testing.T) { err = client.ArchiveChat(ctx, chatToArchive.ID) require.NoError(t, err) - // Default (no filter) returns all chats including archived. + // Default (no filter) returns only non-archived chats. allChats, err := client.ListChats(ctx, nil) require.NoError(t, err) - require.Len(t, allChats, 2) + require.Len(t, allChats, 1) + require.Equal(t, chatToKeep.ID, allChats[0].ID) - // archived=false returns only non-archived chats. + // archived:false returns only non-archived chats. activeChats, err := client.ListChats(ctx, &codersdk.ListChatsOptions{ - Archived: ptr.Ref(false), + Query: "archived:false", }) require.NoError(t, err) require.Len(t, activeChats, 1) require.Equal(t, chatToKeep.ID, activeChats[0].ID) require.False(t, activeChats[0].Archived) - // archived=true returns only archived chats. + // archived:true returns only archived chats. archivedChats, err := client.ListChats(ctx, &codersdk.ListChatsOptions{ - Archived: ptr.Ref(true), + Query: "archived:true", }) require.NoError(t, err) require.Len(t, archivedChats, 1) require.Equal(t, chatToArchive.ID, archivedChats[0].ID) require.True(t, archivedChats[0].Archived) }) - t.Run("NotFound", func(t *testing.T) { t.Parallel() @@ -1356,9 +1355,9 @@ func TestArchiveChat(t *testing.T) { err = client.ArchiveChat(ctx, parentChat.ID) require.NoError(t, err) - // archived=false should exclude the entire archived family. + // archived:false should exclude the entire archived family. activeChats, err := client.ListChats(ctx, &codersdk.ListChatsOptions{ - Archived: ptr.Ref(false), + Query: "archived:false", }) require.NoError(t, err) for _, c := range activeChats { @@ -1405,19 +1404,18 @@ func TestUnarchiveChat(t *testing.T) { // Verify it's archived. archivedChats, err := client.ListChats(ctx, &codersdk.ListChatsOptions{ - Archived: ptr.Ref(true), + Query: "archived:true", }) require.NoError(t, err) require.Len(t, archivedChats, 1) require.True(t, archivedChats[0].Archived) - // Unarchive the chat. err = client.UnarchiveChat(ctx, chat.ID) require.NoError(t, err) // Verify it's no longer archived. activeChats, err := client.ListChats(ctx, &codersdk.ListChatsOptions{ - Archived: ptr.Ref(false), + Query: "archived:false", }) require.NoError(t, err) require.Len(t, activeChats, 1) @@ -1426,7 +1424,7 @@ func TestUnarchiveChat(t *testing.T) { // No archived chats remain. archivedChats, err = client.ListChats(ctx, &codersdk.ListChatsOptions{ - Archived: ptr.Ref(true), + Query: "archived:true", }) require.NoError(t, err) require.Empty(t, archivedChats) diff --git a/coderd/searchquery/search.go b/coderd/searchquery/search.go index 1c4c3bce11..581b004598 100644 --- a/coderd/searchquery/search.go +++ b/coderd/searchquery/search.go @@ -467,6 +467,36 @@ func Tasks(ctx context.Context, db database.Store, query string, actorID uuid.UU return filter, parser.Errors } +// Chats parses a search query for chats. +// +// Supported query parameters: +// - archived: boolean (default: false, excludes archived chats unless explicitly set) +func Chats(query string) (database.GetChatsByOwnerIDParams, []codersdk.ValidationError) { + filter := database.GetChatsByOwnerIDParams{ + // Default to hiding archived chats. + Archived: sql.NullBool{Bool: false, Valid: true}, + } + + if query == "" { + return filter, nil + } + + // Always lowercase for all searches. + query = strings.ToLower(query) + values, errors := searchTerms(query, func(term string, _ url.Values) error { + return xerrors.Errorf("unsupported search term: %q", term) + }) + if len(errors) > 0 { + return filter, errors + } + + parser := httpapi.NewQueryParamParser() + filter.Archived = parser.NullableBoolean(values, filter.Archived, "archived") + + parser.ErrorExcessParams(values) + return filter, parser.Errors +} + func searchTerms(query string, defaultKey func(term string, values url.Values) error) (url.Values, []codersdk.ValidationError) { searchValues := make(url.Values) diff --git a/coderd/searchquery/search_test.go b/coderd/searchquery/search_test.go index 7e7196ca93..2f6bfb41b0 100644 --- a/coderd/searchquery/search_test.go +++ b/coderd/searchquery/search_test.go @@ -1215,3 +1215,75 @@ func TestSearchTasks(t *testing.T) { }) } } + +func TestSearchChats(t *testing.T) { + t.Parallel() + + testCases := []struct { + Name string + Query string + Expected database.GetChatsByOwnerIDParams + ExpectedErrorContains string + }{ + { + Name: "Empty", + Query: "", + Expected: database.GetChatsByOwnerIDParams{ + Archived: sql.NullBool{Bool: false, Valid: true}, + }, + }, + { + Name: "ArchivedTrue", + Query: "archived:true", + Expected: database.GetChatsByOwnerIDParams{ + Archived: sql.NullBool{Bool: true, Valid: true}, + }, + }, + { + Name: "ArchivedFalse", + Query: "archived:false", + Expected: database.GetChatsByOwnerIDParams{ + Archived: sql.NullBool{Bool: false, Valid: true}, + }, + }, + { + Name: "ExtraParam", + Query: "archived:true invalid:param", + ExpectedErrorContains: "is not a valid query param", + }, + { + Name: "ExtraColon", + Query: "archived:true:extra", + ExpectedErrorContains: "can only contain 1 ':'", + }, + { + Name: "PrefixColon", + Query: ":archived", + ExpectedErrorContains: "cannot start or end with ':'", + }, + { + Name: "SuffixColon", + Query: "archived:", + ExpectedErrorContains: "cannot start or end with ':'", + }, + } + + for _, c := range testCases { + t.Run(c.Name, func(t *testing.T) { + t.Parallel() + + values, errs := searchquery.Chats(c.Query) + if c.ExpectedErrorContains != "" { + require.True(t, len(errs) > 0, "expect some errors") + var s strings.Builder + for _, err := range errs { + _, _ = s.WriteString(fmt.Sprintf("%s: %s\n", err.Field, err.Detail)) + } + require.Contains(t, s.String(), c.ExpectedErrorContains) + } else { + require.Len(t, errs, 0, "expected no error") + require.Equal(t, c.Expected, values, "expected values") + } + }) + } +} diff --git a/codersdk/chats.go b/codersdk/chats.go index 0f54deef80..bd3fd9d88e 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -551,7 +551,7 @@ type chatStreamEnvelope struct { // ListChatsOptions are optional parameters for ListChats. type ListChatsOptions struct { - Archived *bool + Query string Pagination } @@ -560,10 +560,10 @@ func (c *Client) ListChats(ctx context.Context, opts *ListChatsOptions) ([]Chat, var reqOpts []RequestOption if opts != nil { reqOpts = append(reqOpts, opts.Pagination.asRequestOption()) - if opts.Archived != nil { + if opts.Query != "" { reqOpts = append(reqOpts, func(r *http.Request) { q := r.URL.Query() - q.Set("archived", fmt.Sprintf("%t", *opts.Archived)) + q.Set("q", opts.Query) r.URL.RawQuery = q.Encode() }) } diff --git a/site/src/api/api.ts b/site/src/api/api.ts index e797e571f4..854518e9b4 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -2941,7 +2941,7 @@ class ApiMethods { after_id?: string; limit?: number; offset?: number; - archived?: string; + q?: string; }): Promise => { const response = await this.axios.get( getURLWithSearchParams("/api/experimental/chats", req), diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts index 8d7d279f16..ce05e263a0 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -49,7 +49,7 @@ export const readInfiniteChatsCache = ( const DEFAULT_CHAT_PAGE_LIMIT = 50; -export const infiniteChats = (opts?: { archived?: boolean }) => { +export const infiniteChats = (opts?: { q?: string }) => { const limit = DEFAULT_CHAT_PAGE_LIMIT; return { @@ -68,7 +68,7 @@ export const infiniteChats = (opts?: { archived?: boolean }) => { return API.getChats({ limit, offset: pageParam <= 0 ? 0 : (pageParam - 1) * limit, - archived: opts?.archived?.toString(), + q: opts?.q, }); }, refetchOnWindowFocus: true as const, diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index b7aee91bca..7b20180f56 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -3268,7 +3268,7 @@ export interface LinkConfig { * ListChatsOptions are optional parameters for ListChats. */ export interface ListChatsOptions extends Pagination { - readonly Archived: boolean | null; + readonly Query: string; } // From codersdk/inboxnotification.go