feat: per-pool automatic file sorting
deploy / deploy (push) Successful in 2s

Pools now store a sort setting (sort_key + sort_order). "manual" keeps the
user-arranged order in file_pool.position and allows drag-to-reorder; any
other key (content_datetime, created, original_name) sorts the pool's files
automatically server-side, in which case reordering is rejected. Manual
order is always ascending by position — direction does not apply.

Backend: add the columns (sort_key defaults to 'manual'), generalise the
pool-files keyset cursor to page by the active sort, persist the setting on
create/update, and guard reorder against non-manual pools.

Frontend: a sort dropdown (+ direction toggle) on the pool page that PATCHes
the pool and reloads; drag-to-reorder is disabled unless the pool is manual.

Closes the "automatic sorting in pools" requirement (REQUIREMENTS.md §4.6.5).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-03 08:36:12 +03:00
parent 28f5b5d150
commit 4b6db84afc
9 changed files with 445 additions and 33 deletions
+113
View File
@@ -26,6 +26,7 @@ import (
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"testing"
"time"
@@ -1745,3 +1746,115 @@ func TestDuplicateDetection(t *testing.T) {
resp.decode(t, &list)
assert.Equal(t, 0, list.Total, "dismissal must survive a rescan")
}
// TestPoolAutomaticSort exercises per-pool file ordering: the default manual
// order, switching to automatic sorts (by name and by creation), that an
// auto-sorted pool rejects manual reordering, and that keyset paging honours the
// active sort.
func TestPoolAutomaticSort(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
h := setupSuite(t)
admin := h.login("admin", "admin")
// Upload three files with distinct names in a non-alphabetical order.
idB := h.uploadJPEG(admin, "b.jpg")["id"].(string)
idA := h.uploadJPEG(admin, "a.jpg")["id"].(string)
idC := h.uploadJPEG(admin, "c.jpg")["id"].(string)
// "created" order is UUID (byte) order — lexicographic on the canonical string.
createdAsc := []string{idA, idB, idC}
sort.Strings(createdAsc)
createdDesc := []string{createdAsc[2], createdAsc[1], createdAsc[0]}
// New pool defaults to manual ordering.
resp := h.doJSON("POST", "/pools", map[string]any{"name": "sortpool"}, admin)
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
var pool map[string]any
resp.decode(t, &pool)
poolID := pool["id"].(string)
require.Equal(t, "manual", pool["sort_key"])
// Add in upload order (b, a, c) → that becomes the manual position order.
resp = h.doJSON("POST", "/pools/"+poolID+"/files",
map[string]any{"file_ids": []string{idB, idA, idC}}, admin)
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
listIDs := func() []string {
resp := h.doJSON("GET", "/pools/"+poolID+"/files", nil, admin)
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
var page struct {
Items []struct {
ID string `json:"id"`
} `json:"items"`
}
resp.decode(t, &page)
ids := make([]string, len(page.Items))
for i, it := range page.Items {
ids[i] = it.ID
}
return ids
}
setSort := func(key, order string) {
body := map[string]any{"sort_key": key}
if order != "" {
body["sort_order"] = order
}
resp := h.doJSON("PATCH", "/pools/"+poolID, body, admin)
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
}
// Manual = insertion order.
require.Equal(t, []string{idB, idA, idC}, listIDs())
// By name ascending → a, b, c.
setSort("original_name", "asc")
require.Equal(t, []string{idA, idB, idC}, listIDs())
// By creation descending → reverse UUID order.
setSort("created", "desc")
require.Equal(t, createdDesc, listIDs())
// Reordering an auto-sorted pool is rejected.
resp = h.doJSON("PUT", "/pools/"+poolID+"/files/reorder",
map[string]any{"file_ids": []string{idC, idB, idA}}, admin)
require.Equal(t, http.StatusBadRequest, resp.StatusCode, resp.String())
// Back to manual → reordering works again and sticks.
setSort("manual", "")
resp = h.doJSON("PUT", "/pools/"+poolID+"/files/reorder",
map[string]any{"file_ids": []string{idC, idB, idA}}, admin)
require.Equal(t, http.StatusNoContent, resp.StatusCode, resp.String())
require.Equal(t, []string{idC, idB, idA}, listIDs())
// Keyset paging (limit 1) under an automatic sort returns the full sequence.
setSort("original_name", "asc")
var paged []string
cursor := ""
for i := 0; i < 10; i++ {
url := "/pools/" + poolID + "/files?limit=1"
if cursor != "" {
url += "&cursor=" + cursor
}
resp := h.doJSON("GET", url, nil, admin)
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
var page struct {
Items []struct {
ID string `json:"id"`
} `json:"items"`
NextCursor *string `json:"next_cursor"`
}
resp.decode(t, &page)
for _, it := range page.Items {
paged = append(paged, it.ID)
}
if page.NextCursor == nil {
break
}
cursor = *page.NextCursor
}
require.Equal(t, []string{idA, idB, idC}, paged)
}