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:
@@ -155,6 +155,13 @@ func (r *TagRepo) listTags(ctx context.Context, params port.OffsetParams, catego
|
|||||||
}
|
}
|
||||||
sortCol := tagSortColumn(params.Sort)
|
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{}
|
args := []any{}
|
||||||
n := 1
|
n := 1
|
||||||
var conditions []string
|
var conditions []string
|
||||||
@@ -204,8 +211,8 @@ FROM data.tags t
|
|||||||
LEFT JOIN data.categories c ON c.id = t.category_id
|
LEFT JOIN data.categories c ON c.id = t.category_id
|
||||||
JOIN core.users u ON u.id = t.creator_id
|
JOIN core.users u ON u.id = t.creator_id
|
||||||
%s
|
%s
|
||||||
ORDER BY %s %s NULLS LAST, t.id ASC
|
ORDER BY %s %s NULLS LAST, %st.id ASC
|
||||||
LIMIT $%d OFFSET $%d`, where, sortCol, order, n, n+1)
|
LIMIT $%d OFFSET $%d`, where, sortCol, order, secondarySort, n, n+1)
|
||||||
|
|
||||||
args = append(args, limit, offset)
|
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")
|
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.
|
// TestBulkTagAutoRule verifies the bulk add path also applies then_tags.
|
||||||
func TestBulkTagAutoRule(t *testing.T) {
|
func TestBulkTagAutoRule(t *testing.T) {
|
||||||
if testing.Short() {
|
if testing.Short() {
|
||||||
|
|||||||
Reference in New Issue
Block a user