feat(backend): sort tags by category then tag name

The category_name tag sort now breaks ties within a category by the
tag's own name (same direction), so tags group by category and read
alphabetically inside each group; uncategorized tags stay last.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 21:47:42 +03:00
parent 73ae8a046f
commit cb1588ecc0
2 changed files with 59 additions and 2 deletions
+9 -2
View File
@@ -155,6 +155,13 @@ func (r *TagRepo) listTags(ctx context.Context, params port.OffsetParams, catego
}
sortCol := tagSortColumn(params.Sort)
// When sorting by category, break ties within a category by the tag's own
// name (same direction), so tags are grouped by category then alphabetical.
secondarySort := ""
if params.Sort == "category_name" {
secondarySort = fmt.Sprintf("t.name %s, ", order)
}
args := []any{}
n := 1
var conditions []string
@@ -204,8 +211,8 @@ FROM data.tags t
LEFT JOIN data.categories c ON c.id = t.category_id
JOIN core.users u ON u.id = t.creator_id
%s
ORDER BY %s %s NULLS LAST, t.id ASC
LIMIT $%d OFFSET $%d`, where, sortCol, order, n, n+1)
ORDER BY %s %s NULLS LAST, %st.id ASC
LIMIT $%d OFFSET $%d`, where, sortCol, order, secondarySort, n, n+1)
args = append(args, limit, offset)
@@ -807,6 +807,56 @@ func TestRecordTagUses(t *testing.T) {
assert.Equal(t, 2, h.countTagUses(ctx), "pagination should not add tag_uses rows")
}
// TestTagSortByCategoryThenName verifies the category_name sort groups tags by
// category and orders them by their own name within each category, with
// uncategorized tags last (NULLS LAST).
func TestTagSortByCategoryThenName(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
h := setupSuite(t)
adminToken := h.login("admin", "admin")
mkCategory := func(name string) string {
resp := h.doJSON("POST", "/categories", map[string]any{"name": name}, adminToken)
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
var c map[string]any
resp.decode(t, &c)
return c["id"].(string)
}
mkTag := func(name string, categoryID *string) {
body := map[string]any{"name": name}
if categoryID != nil {
body["category_id"] = *categoryID
}
resp := h.doJSON("POST", "/tags", body, adminToken)
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
}
alpha := mkCategory("Alpha")
bravo := mkCategory("Bravo")
// Insert out of order to prove the sort, not insertion order, decides output.
mkTag("zebra", &alpha)
mkTag("mid", &bravo)
mkTag("solo", nil) // uncategorized
mkTag("ant", &alpha)
resp := h.doJSON("GET", "/tags?sort=category_name&order=asc", nil, adminToken)
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
var page map[string]any
resp.decode(t, &page)
items := page["items"].([]any)
names := make([]string, len(items))
for i, it := range items {
names[i] = it.(map[string]any)["name"].(string)
}
// Alpha (ant, zebra) → Bravo (mid) → uncategorized (solo) last.
assert.Equal(t, []string{"ant", "zebra", "mid", "solo"}, names)
}
// TestBulkTagAutoRule verifies the bulk add path also applies then_tags.
func TestBulkTagAutoRule(t *testing.T) {
if testing.Short() {