mirror of
https://github.com/coder/coder.git
synced 2026-09-21 20:51:01 +08:00
feat(coderd): add q search parameter to chats endpoint (#22913)
Replace the standalone `?archived=` query parameter on the chats listing endpoint with a `?q=` search parameter, consistent with how workspaces, tasks, templates, and other list endpoints work. The `q` parameter uses the standard `key:value` search syntax parsed by the `searchquery` package. Currently supports: - `archived:true/false` (default: `false`, hides archived chats) When `q` is empty or omits the archived filter, archived chats are excluded by default. This is a behavioral change — the previous API returned all chats (including archived) when no filter was specified. ### Changes **Backend:** - Add `searchquery.Chats()` parser following the same pattern as `Tasks()`, `Workspaces()`, etc. - Update `listChats` handler to read `q` instead of `archived` - Update `codersdk.ListChatsOptions` to use `Q string` instead of `Archived *bool` **Frontend:** - Update `getChats` API method to accept `q` parameter - Update `infiniteChats` query to pass `q` instead of `archived` **Tests:** - Add `TestSearchChats` unit tests for the parser - Update existing archive/unarchive integration tests to use `Q: "archived:true"` syntax
This commit is contained in:
+14
-15
@@ -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 {
|
||||
|
||||
+12
-14
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -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()
|
||||
})
|
||||
}
|
||||
|
||||
+1
-1
@@ -2941,7 +2941,7 @@ class ApiMethods {
|
||||
after_id?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
archived?: string;
|
||||
q?: string;
|
||||
}): Promise<TypesGen.Chat[]> => {
|
||||
const response = await this.axios.get<TypesGen.Chat[]>(
|
||||
getURLWithSearchParams("/api/experimental/chats", req),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Generated
+1
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user