fix: add support for spaces in search & enable searching by display name in templates (#19552)

## Summary

In this pull request we're updating search to support queries with
spaces in addition to the `field:value` pattern that is currently
supported.

Additionally templates search now defaults to `display_name` (since
`display_name` is optional the search will fallback to `name`) when
searching without the `field:value` pattern

Closes: https://github.com/coder/coder/issues/14384

### Downsides with searching on `name` and `display_name`

Because the `name` field cannot include spaces, we end up in a situation
where including a space in the query will result in no results since the
query searches on both `name` AND `display_name`. In the following
example, we can see the results of searching by both `name` and
`display_name` on these templates:

| Name | Display Name |
| ------ | ------------- |
| docker | Docker Template |
| faketemplate | A Fake Template |
| azure | Fake Azure Template |
| anotherfake | Another Fake Template |
| azurefake | Another Fake Fake Azure Template |



https://github.com/user-attachments/assets/b0e0793e-e77d-46bc-9a42-d7cf4f8bd910

### Proposal: Search on `display_name` by default and allow for `name`
using the `field:value` pattern

If we remove `name` from the default template search, we're now able to
search with spaces on template `display_names`. Since `display_names`
are what users see in the templates list they might expect the search to
work this way.

Below is an example of `name` being removed from the default template
search.


https://github.com/user-attachments/assets/9aba5911-4960-4384-befb-08ea1acaa3ab

With this approach users would still be able to search on template names
by specifying `exact_name:foo`.

### Testing

Added additional test cases to ensure spaces were handled as expected in
combination with `field:value` patterns.
This commit is contained in:
Rafael Rodriguez
2025-09-08 17:13:27 -05:00
committed by GitHub
parent ff18499cb0
commit 1677a30a1d
5 changed files with 121 additions and 18 deletions
+27 -3
View File
@@ -268,8 +268,8 @@ func Templates(ctx context.Context, db database.Store, actorID uuid.UUID, query
// Always lowercase for all searches.
query = strings.ToLower(query)
values, errors := searchTerms(query, func(term string, values url.Values) error {
// Default to the template name
values.Add("name", term)
// Default to the display name
values.Add("display_name", term)
return nil
})
if len(errors) > 0 {
@@ -281,7 +281,9 @@ func Templates(ctx context.Context, db database.Store, actorID uuid.UUID, query
Deleted: parser.Boolean(values, false, "deleted"),
OrganizationID: parseOrganization(ctx, db, parser, values, "organization"),
ExactName: parser.String(values, "", "exact_name"),
ExactDisplayName: parser.String(values, "", "exact_display_name"),
FuzzyName: parser.String(values, "", "name"),
FuzzyDisplayName: parser.String(values, "", "display_name"),
IDs: parser.UUIDs(values, []uuid.UUID{}, "ids"),
Deprecated: parser.NullableBoolean(values, sql.NullBool{}, "deprecated"),
HasAITask: parser.NullableBoolean(values, sql.NullBool{}, "has-ai-task"),
@@ -305,7 +307,8 @@ func searchTerms(query string, defaultKey func(term string, values url.Values) e
// Because we do this in 2 passes, we want to maintain quotes on the first
// pass. Further splitting occurs on the second pass and quotes will be
// dropped.
elements := splitQueryParameterByDelimiter(query, ' ', true)
tokens := splitQueryParameterByDelimiter(query, ' ', true)
elements := processTokens(tokens)
for _, element := range elements {
if strings.HasPrefix(element, ":") || strings.HasSuffix(element, ":") {
return nil, []codersdk.ValidationError{
@@ -385,3 +388,24 @@ func splitQueryParameterByDelimiter(query string, delimiter rune, maintainQuotes
return parts
}
// processTokens takes the split tokens and groups them based on a delimiter (':').
// Tokens without a delimiter present are joined to support searching with spaces.
//
// Example Input: ['deprecated:false', 'test', 'template']
// Example Output: ['deprecated:false', 'test template']
func processTokens(tokens []string) []string {
var results []string
var nonFieldTerms []string
for _, token := range tokens {
if strings.Contains(token, string(':')) {
results = append(results, token)
} else {
nonFieldTerms = append(nonFieldTerms, token)
}
}
if len(nonFieldTerms) > 0 {
results = append(results, strings.Join(nonFieldTerms, " "))
}
return results
}
+38 -1
View File
@@ -686,7 +686,7 @@ func TestSearchTemplates(t *testing.T) {
Name: "OnlyName",
Query: "foobar",
Expected: database.GetTemplatesWithFilterParams{
FuzzyName: "foobar",
FuzzyDisplayName: "foobar",
},
},
{
@@ -757,6 +757,43 @@ func TestSearchTemplates(t *testing.T) {
AuthorID: userID,
},
},
{
Name: "SearchOnDisplayName",
Query: "test name",
Expected: database.GetTemplatesWithFilterParams{
FuzzyDisplayName: "test name",
},
},
{
Name: "NameField",
Query: "name:testname",
Expected: database.GetTemplatesWithFilterParams{
FuzzyName: "testname",
},
},
{
Name: "QuotedValue",
Query: `name:"test name"`,
Expected: database.GetTemplatesWithFilterParams{
FuzzyName: "test name",
},
},
{
Name: "MultipleTerms",
Query: `foo bar exact_name:"test display name"`,
Expected: database.GetTemplatesWithFilterParams{
ExactName: "test display name",
FuzzyDisplayName: "foo bar",
},
},
{
Name: "FieldAndSpaces",
Query: "deprecated:false test template",
Expected: database.GetTemplatesWithFilterParams{
Deprecated: sql.NullBool{Bool: false, Valid: true},
FuzzyDisplayName: "test template",
},
},
}
for _, c := range testCases {