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:
@@ -30,6 +30,8 @@ type poolRow struct {
|
|||||||
CreatorID int16 `db:"creator_id"`
|
CreatorID int16 `db:"creator_id"`
|
||||||
CreatorName string `db:"creator_name"`
|
CreatorName string `db:"creator_name"`
|
||||||
IsPublic bool `db:"is_public"`
|
IsPublic bool `db:"is_public"`
|
||||||
|
SortKey string `db:"sort_key"`
|
||||||
|
SortOrder string `db:"sort_order"`
|
||||||
FileCount int `db:"file_count"`
|
FileCount int `db:"file_count"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,6 +70,8 @@ func toPool(r poolRow) domain.Pool {
|
|||||||
CreatorID: r.CreatorID,
|
CreatorID: r.CreatorID,
|
||||||
CreatorName: r.CreatorName,
|
CreatorName: r.CreatorName,
|
||||||
IsPublic: r.IsPublic,
|
IsPublic: r.IsPublic,
|
||||||
|
SortKey: r.SortKey,
|
||||||
|
SortOrder: r.SortOrder,
|
||||||
FileCount: r.FileCount,
|
FileCount: r.FileCount,
|
||||||
CreatedAt: domain.UUIDCreatedAt(r.ID),
|
CreatedAt: domain.UUIDCreatedAt(r.ID),
|
||||||
}
|
}
|
||||||
@@ -103,9 +107,15 @@ func toPoolFile(r poolFileRow) domain.PoolFile {
|
|||||||
// Cursor
|
// Cursor
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// poolFileCursor is the keyset paging cursor for pool files. Which fields are
|
||||||
|
// populated depends on the pool's sort key: Pos for manual (position), Val for
|
||||||
|
// content_datetime (RFC3339Nano) / original_name (the coalesced name); the
|
||||||
|
// "created" sort orders by file id alone, so only FileID matters. FileID is
|
||||||
|
// always the final tiebreak.
|
||||||
type poolFileCursor struct {
|
type poolFileCursor struct {
|
||||||
Position int `json:"p"`
|
Pos int `json:"p,omitempty"`
|
||||||
FileID string `json:"id"`
|
Val string `json:"v,omitempty"`
|
||||||
|
FileID string `json:"id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func encodePoolCursor(c poolFileCursor) string {
|
func encodePoolCursor(c poolFileCursor) string {
|
||||||
@@ -135,6 +145,7 @@ const poolCountSubquery = `(SELECT pool_id, COUNT(*) AS cnt FROM data.file_pool
|
|||||||
const poolSelectFrom = `
|
const poolSelectFrom = `
|
||||||
SELECT p.id, p.name, p.notes, p.metadata,
|
SELECT p.id, p.name, p.notes, p.metadata,
|
||||||
p.creator_id, u.name AS creator_name, p.is_public,
|
p.creator_id, u.name AS creator_name, p.is_public,
|
||||||
|
p.sort_key, p.sort_order,
|
||||||
COALESCE(fc.cnt, 0) AS file_count
|
COALESCE(fc.cnt, 0) AS file_count
|
||||||
FROM data.pools p
|
FROM data.pools p
|
||||||
JOIN core.users u ON u.id = p.creator_id
|
JOIN core.users u ON u.id = p.creator_id
|
||||||
@@ -147,6 +158,29 @@ func poolSortColumn(s string) string {
|
|||||||
return "p.id" // "created"
|
return "p.id" // "created"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// poolFileSort maps a pool's stored sort settings to the SQL column expression,
|
||||||
|
// the ORDER BY direction, and the keyset comparison operator used for paging.
|
||||||
|
// The column is chosen so it is never NULL (original_name is coalesced), which
|
||||||
|
// keeps the keyset comparison total.
|
||||||
|
func poolFileSort(sortKey, sortOrder string) (col, dir, cmp string) {
|
||||||
|
dir, cmp = "ASC", ">"
|
||||||
|
if strings.EqualFold(sortOrder, domain.SortOrderDesc) {
|
||||||
|
dir, cmp = "DESC", "<"
|
||||||
|
}
|
||||||
|
switch sortKey {
|
||||||
|
case domain.PoolSortContentDatetime:
|
||||||
|
col = "f.content_datetime"
|
||||||
|
case domain.PoolSortOriginalName:
|
||||||
|
col = "COALESCE(f.original_name, '')"
|
||||||
|
case domain.PoolSortCreated:
|
||||||
|
col = "f.id"
|
||||||
|
default: // manual — the user-arranged sequence; direction does not apply
|
||||||
|
col = "fp.position"
|
||||||
|
dir, cmp = "ASC", ">"
|
||||||
|
}
|
||||||
|
return col, dir, cmp
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// PoolRepo
|
// PoolRepo
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -207,6 +241,7 @@ func (r *PoolRepo) List(ctx context.Context, params port.OffsetParams) (*domain.
|
|||||||
query := fmt.Sprintf(`
|
query := fmt.Sprintf(`
|
||||||
SELECT p.id, p.name, p.notes, p.metadata,
|
SELECT p.id, p.name, p.notes, p.metadata,
|
||||||
p.creator_id, u.name AS creator_name, p.is_public,
|
p.creator_id, u.name AS creator_name, p.is_public,
|
||||||
|
p.sort_key, p.sort_order,
|
||||||
COALESCE(fc.cnt, 0) AS file_count,
|
COALESCE(fc.cnt, 0) AS file_count,
|
||||||
COUNT(*) OVER() AS total
|
COUNT(*) OVER() AS total
|
||||||
FROM data.pools p
|
FROM data.pools p
|
||||||
@@ -289,6 +324,7 @@ WITH ins AS (
|
|||||||
)
|
)
|
||||||
SELECT ins.id, ins.name, ins.notes, ins.metadata,
|
SELECT ins.id, ins.name, ins.notes, ins.metadata,
|
||||||
ins.creator_id, u.name AS creator_name, ins.is_public,
|
ins.creator_id, u.name AS creator_name, ins.is_public,
|
||||||
|
ins.sort_key, ins.sort_order,
|
||||||
0 AS file_count
|
0 AS file_count
|
||||||
FROM ins
|
FROM ins
|
||||||
JOIN core.users u ON u.id = ins.creator_id`
|
JOIN core.users u ON u.id = ins.creator_id`
|
||||||
@@ -322,15 +358,18 @@ func (r *PoolRepo) Update(ctx context.Context, id uuid.UUID, p *domain.Pool) (*d
|
|||||||
const query = `
|
const query = `
|
||||||
WITH upd AS (
|
WITH upd AS (
|
||||||
UPDATE data.pools SET
|
UPDATE data.pools SET
|
||||||
name = $2,
|
name = $2,
|
||||||
notes = $3,
|
notes = $3,
|
||||||
metadata = COALESCE($4, metadata),
|
metadata = COALESCE($4, metadata),
|
||||||
is_public = $5
|
is_public = $5,
|
||||||
|
sort_key = $6,
|
||||||
|
sort_order = $7
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING *
|
RETURNING *
|
||||||
)
|
)
|
||||||
SELECT upd.id, upd.name, upd.notes, upd.metadata,
|
SELECT upd.id, upd.name, upd.notes, upd.metadata,
|
||||||
upd.creator_id, u.name AS creator_name, upd.is_public,
|
upd.creator_id, u.name AS creator_name, upd.is_public,
|
||||||
|
upd.sort_key, upd.sort_order,
|
||||||
COALESCE(fc.cnt, 0) AS file_count
|
COALESCE(fc.cnt, 0) AS file_count
|
||||||
FROM upd
|
FROM upd
|
||||||
JOIN core.users u ON u.id = upd.creator_id
|
JOIN core.users u ON u.id = upd.creator_id
|
||||||
@@ -343,7 +382,7 @@ LEFT JOIN (SELECT pool_id, COUNT(*) AS cnt FROM data.file_pool WHERE pool_id = $
|
|||||||
}
|
}
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
q := connOrTx(ctx, r.pool)
|
||||||
rows, err := q.Query(ctx, query, id, p.Name, p.Notes, meta, p.IsPublic)
|
rows, err := q.Query(ctx, query, id, p.Name, p.Notes, meta, p.IsPublic, p.SortKey, p.SortOrder)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("PoolRepo.Update: %w", err)
|
return nil, fmt.Errorf("PoolRepo.Update: %w", err)
|
||||||
}
|
}
|
||||||
@@ -412,8 +451,16 @@ func (r *PoolRepo) ListFiles(ctx context.Context, poolID uuid.UUID, params port.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cursor condition.
|
// Resolve the pool's sort setting (defaulting to manual position order) into
|
||||||
var orderBy string
|
// the ORDER BY column, direction, and keyset comparison operator.
|
||||||
|
sortKey := params.SortKey
|
||||||
|
if !domain.ValidPoolSortKey(sortKey) {
|
||||||
|
sortKey = domain.PoolSortManual
|
||||||
|
}
|
||||||
|
col, dir, cmp := poolFileSort(sortKey, params.SortOrder)
|
||||||
|
|
||||||
|
// Keyset cursor condition. For "created" the file id is both the sort key and
|
||||||
|
// the tiebreak, so a single comparison suffices; the others compare (col, id).
|
||||||
if params.Cursor != "" {
|
if params.Cursor != "" {
|
||||||
cur, err := decodePoolCursor(params.Cursor)
|
cur, err := decodePoolCursor(params.Cursor)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -423,13 +470,36 @@ func (r *PoolRepo) ListFiles(ctx context.Context, poolID uuid.UUID, params port.
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, domain.ErrValidation
|
return nil, domain.ErrValidation
|
||||||
}
|
}
|
||||||
conds = append(conds, fmt.Sprintf(
|
if sortKey == domain.PoolSortCreated {
|
||||||
"(fp.position > $%d OR (fp.position = $%d AND fp.file_id > $%d))",
|
conds = append(conds, fmt.Sprintf("f.id %s $%d", cmp, n))
|
||||||
n, n, n+1))
|
args = append(args, fileID)
|
||||||
args = append(args, cur.Position, fileID)
|
n++
|
||||||
n += 2
|
} else {
|
||||||
|
conds = append(conds, fmt.Sprintf(
|
||||||
|
"(%s %s $%d OR (%s = $%d AND f.id %s $%d))", col, cmp, n, col, n, cmp, n+1))
|
||||||
|
switch sortKey {
|
||||||
|
case domain.PoolSortContentDatetime:
|
||||||
|
t, err := time.Parse(time.RFC3339Nano, cur.Val)
|
||||||
|
if err != nil {
|
||||||
|
return nil, domain.ErrValidation
|
||||||
|
}
|
||||||
|
args = append(args, t)
|
||||||
|
case domain.PoolSortOriginalName:
|
||||||
|
args = append(args, cur.Val)
|
||||||
|
default: // manual
|
||||||
|
args = append(args, cur.Pos)
|
||||||
|
}
|
||||||
|
args = append(args, fileID)
|
||||||
|
n += 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var orderBy string
|
||||||
|
if sortKey == domain.PoolSortCreated {
|
||||||
|
orderBy = fmt.Sprintf("f.id %s", dir)
|
||||||
|
} else {
|
||||||
|
orderBy = fmt.Sprintf("%s %s, f.id %s", col, dir, dir)
|
||||||
}
|
}
|
||||||
orderBy = "fp.position ASC, fp.file_id ASC"
|
|
||||||
|
|
||||||
where := "WHERE " + strings.Join(conds, " AND ")
|
where := "WHERE " + strings.Join(conds, " AND ")
|
||||||
args = append(args, limit+1)
|
args = append(args, limit+1)
|
||||||
@@ -468,11 +538,21 @@ LIMIT $%d`, fileSelectForPool, where, orderBy, n)
|
|||||||
|
|
||||||
if hasMore && len(collected) > 0 {
|
if hasMore && len(collected) > 0 {
|
||||||
last := collected[len(collected)-1]
|
last := collected[len(collected)-1]
|
||||||
cur := encodePoolCursor(poolFileCursor{
|
cursor := poolFileCursor{FileID: last.ID.String()}
|
||||||
Position: last.Position,
|
switch sortKey {
|
||||||
FileID: last.ID.String(),
|
case domain.PoolSortContentDatetime:
|
||||||
})
|
cursor.Val = last.ContentDatetime.UTC().Format(time.RFC3339Nano)
|
||||||
page.NextCursor = &cur
|
case domain.PoolSortOriginalName:
|
||||||
|
if last.OriginalName != nil {
|
||||||
|
cursor.Val = *last.OriginalName
|
||||||
|
}
|
||||||
|
case domain.PoolSortCreated:
|
||||||
|
// file id alone orders; nothing else to carry
|
||||||
|
default: // manual
|
||||||
|
cursor.Pos = last.Position
|
||||||
|
}
|
||||||
|
enc := encodePoolCursor(cursor)
|
||||||
|
page.NextCursor = &enc
|
||||||
}
|
}
|
||||||
|
|
||||||
// Batch-load tags.
|
// Batch-load tags.
|
||||||
|
|||||||
@@ -7,6 +7,32 @@ import (
|
|||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Pool file sort keys. PoolSortManual keeps the user-defined order in
|
||||||
|
// file_pool.position; the others sort the pool's files by that file field.
|
||||||
|
const (
|
||||||
|
PoolSortManual = "manual"
|
||||||
|
PoolSortContentDatetime = "content_datetime"
|
||||||
|
PoolSortCreated = "created"
|
||||||
|
PoolSortOriginalName = "original_name"
|
||||||
|
|
||||||
|
SortOrderAsc = "asc"
|
||||||
|
SortOrderDesc = "desc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ValidPoolSortKey reports whether s is an accepted pool sort key.
|
||||||
|
func ValidPoolSortKey(s string) bool {
|
||||||
|
switch s {
|
||||||
|
case PoolSortManual, PoolSortContentDatetime, PoolSortCreated, PoolSortOriginalName:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidSortOrder reports whether s is an accepted sort direction.
|
||||||
|
func ValidSortOrder(s string) bool {
|
||||||
|
return s == SortOrderAsc || s == SortOrderDesc
|
||||||
|
}
|
||||||
|
|
||||||
// Pool is an ordered collection of files.
|
// Pool is an ordered collection of files.
|
||||||
type Pool struct {
|
type Pool struct {
|
||||||
ID uuid.UUID
|
ID uuid.UUID
|
||||||
@@ -16,6 +42,11 @@ type Pool struct {
|
|||||||
CreatorID int16
|
CreatorID int16
|
||||||
CreatorName string // denormalized
|
CreatorName string // denormalized
|
||||||
IsPublic bool
|
IsPublic bool
|
||||||
|
// SortKey / SortOrder control how the pool's files are ordered. When SortKey
|
||||||
|
// is PoolSortManual, files follow the manual position order and can be
|
||||||
|
// reordered; otherwise they are sorted automatically and reordering is a no-op.
|
||||||
|
SortKey string
|
||||||
|
SortOrder string
|
||||||
FileCount int
|
FileCount int
|
||||||
CreatedAt time.Time // extracted from UUID v7 via UUIDCreatedAt
|
CreatedAt time.Time // extracted from UUID v7 via UUIDCreatedAt
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ type poolJSON struct {
|
|||||||
CreatorID int16 `json:"creator_id"`
|
CreatorID int16 `json:"creator_id"`
|
||||||
CreatorName string `json:"creator_name"`
|
CreatorName string `json:"creator_name"`
|
||||||
IsPublic bool `json:"is_public"`
|
IsPublic bool `json:"is_public"`
|
||||||
|
SortKey string `json:"sort_key"`
|
||||||
|
SortOrder string `json:"sort_order"`
|
||||||
FileCount int `json:"file_count"`
|
FileCount int `json:"file_count"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
}
|
}
|
||||||
@@ -51,6 +53,8 @@ func toPoolJSON(p domain.Pool) poolJSON {
|
|||||||
CreatorID: p.CreatorID,
|
CreatorID: p.CreatorID,
|
||||||
CreatorName: p.CreatorName,
|
CreatorName: p.CreatorName,
|
||||||
IsPublic: p.IsPublic,
|
IsPublic: p.IsPublic,
|
||||||
|
SortKey: p.SortKey,
|
||||||
|
SortOrder: p.SortOrder,
|
||||||
FileCount: p.FileCount,
|
FileCount: p.FileCount,
|
||||||
CreatedAt: p.CreatedAt.UTC().Format(time.RFC3339),
|
CreatedAt: p.CreatedAt.UTC().Format(time.RFC3339),
|
||||||
}
|
}
|
||||||
@@ -214,6 +218,16 @@ func (h *PoolHandler) Update(c *gin.Context) {
|
|||||||
params.IsPublic = &b
|
params.IsPublic = &b
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if v, ok := raw["sort_key"]; ok {
|
||||||
|
if s, ok := v.(string); ok {
|
||||||
|
params.SortKey = &s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v, ok := raw["sort_order"]; ok {
|
||||||
|
if s, ok := v.(string); ok {
|
||||||
|
params.SortOrder = &s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
updated, err := h.poolSvc.Update(c.Request.Context(), id, params)
|
updated, err := h.poolSvc.Update(c.Request.Context(), id, params)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -1745,3 +1746,115 @@ func TestDuplicateDetection(t *testing.T) {
|
|||||||
resp.decode(t, &list)
|
resp.decode(t, &list)
|
||||||
assert.Equal(t, 0, list.Total, "dismissal must survive a rescan")
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ type PoolFileListParams struct {
|
|||||||
Cursor string
|
Cursor string
|
||||||
Limit int
|
Limit int
|
||||||
Filter string // filter DSL expression
|
Filter string // filter DSL expression
|
||||||
|
// SortKey / SortOrder come from the pool itself (not the request) and select
|
||||||
|
// how files are ordered: manual position order or an automatic file-field sort.
|
||||||
|
SortKey string
|
||||||
|
SortOrder string
|
||||||
}
|
}
|
||||||
|
|
||||||
// FileRepo is the persistence interface for file records.
|
// FileRepo is the persistence interface for file records.
|
||||||
|
|||||||
@@ -13,12 +13,15 @@ import (
|
|||||||
const poolObjectType = "pool"
|
const poolObjectType = "pool"
|
||||||
const poolObjectTypeID int16 = 4 // fourth row in 007_seed_data.sql object_types
|
const poolObjectTypeID int16 = 4 // fourth row in 007_seed_data.sql object_types
|
||||||
|
|
||||||
// PoolParams holds the fields for creating or patching a pool.
|
// PoolParams holds the fields for creating or patching a pool. SortKey and
|
||||||
|
// SortOrder are pointers so a patch can leave them unchanged (nil) vs set them.
|
||||||
type PoolParams struct {
|
type PoolParams struct {
|
||||||
Name string
|
Name string
|
||||||
Notes *string
|
Notes *string
|
||||||
Metadata json.RawMessage
|
Metadata json.RawMessage
|
||||||
IsPublic *bool
|
IsPublic *bool
|
||||||
|
SortKey *string
|
||||||
|
SortOrder *string
|
||||||
}
|
}
|
||||||
|
|
||||||
// PoolService handles pool CRUD and pool–file management with ACL + audit.
|
// PoolService handles pool CRUD and pool–file management with ACL + audit.
|
||||||
@@ -164,6 +167,18 @@ func (s *PoolService) Update(ctx context.Context, id uuid.UUID, p PoolParams) (*
|
|||||||
if p.IsPublic != nil {
|
if p.IsPublic != nil {
|
||||||
patch.IsPublic = *p.IsPublic
|
patch.IsPublic = *p.IsPublic
|
||||||
}
|
}
|
||||||
|
if p.SortKey != nil {
|
||||||
|
if !domain.ValidPoolSortKey(*p.SortKey) {
|
||||||
|
return nil, domain.ErrValidation
|
||||||
|
}
|
||||||
|
patch.SortKey = *p.SortKey
|
||||||
|
}
|
||||||
|
if p.SortOrder != nil {
|
||||||
|
if !domain.ValidSortOrder(*p.SortOrder) {
|
||||||
|
return nil, domain.ErrValidation
|
||||||
|
}
|
||||||
|
patch.SortOrder = *p.SortOrder
|
||||||
|
}
|
||||||
|
|
||||||
updated, err := s.pools.Update(ctx, id, &patch)
|
updated, err := s.pools.Update(ctx, id, &patch)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -205,12 +220,24 @@ func (s *PoolService) Delete(ctx context.Context, id uuid.UUID) error {
|
|||||||
// Pool–file operations
|
// Pool–file operations
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// ListFiles returns cursor-paginated files within a pool ordered by position,
|
// ListFiles returns cursor-paginated files within a pool, enforcing view ACL.
|
||||||
// enforcing view ACL on the pool.
|
// The ordering is the pool's own stored sort setting (manual position order or
|
||||||
|
// an automatic file-field sort), not a request parameter.
|
||||||
func (s *PoolService) ListFiles(ctx context.Context, poolID uuid.UUID, params port.PoolFileListParams) (*domain.PoolFilePage, error) {
|
func (s *PoolService) ListFiles(ctx context.Context, poolID uuid.UUID, params port.PoolFileListParams) (*domain.PoolFilePage, error) {
|
||||||
if err := s.authorizeView(ctx, poolID); err != nil {
|
userID, isAdmin, _ := domain.UserFromContext(ctx)
|
||||||
|
pool, err := s.pools.GetByID(ctx, poolID)
|
||||||
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
ok, err := s.acl.CanView(ctx, userID, isAdmin, pool.CreatorID, pool.IsPublic, poolObjectTypeID, poolID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return nil, domain.ErrForbidden
|
||||||
|
}
|
||||||
|
params.SortKey = pool.SortKey
|
||||||
|
params.SortOrder = pool.SortOrder
|
||||||
return s.pools.ListFiles(ctx, poolID, params)
|
return s.pools.ListFiles(ctx, poolID, params)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,11 +269,24 @@ func (s *PoolService) RemoveFiles(ctx context.Context, poolID uuid.UUID, fileIDs
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Reorder sets the ordered sequence of file IDs within a pool, enforcing edit
|
// Reorder sets the ordered sequence of file IDs within a pool, enforcing edit
|
||||||
// ACL on the pool.
|
// ACL on the pool. Manual ordering only applies when the pool's sort key is
|
||||||
|
// "manual"; reordering an auto-sorted pool is rejected as a validation error.
|
||||||
func (s *PoolService) Reorder(ctx context.Context, poolID uuid.UUID, fileIDs []uuid.UUID) error {
|
func (s *PoolService) Reorder(ctx context.Context, poolID uuid.UUID, fileIDs []uuid.UUID) error {
|
||||||
if err := s.authorizeEdit(ctx, poolID); err != nil {
|
userID, isAdmin, _ := domain.UserFromContext(ctx)
|
||||||
|
pool, err := s.pools.GetByID(ctx, poolID)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
ok, err := s.acl.CanEdit(ctx, userID, isAdmin, pool.CreatorID, poolObjectTypeID, poolID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return domain.ErrForbidden
|
||||||
|
}
|
||||||
|
if pool.SortKey != domain.PoolSortManual {
|
||||||
|
return domain.ErrValidation
|
||||||
|
}
|
||||||
if err := s.pools.Reorder(ctx, poolID, fileIDs); err != nil {
|
if err := s.pools.Reorder(ctx, poolID, fileIDs); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,8 +76,17 @@ CREATE TABLE data.pools (
|
|||||||
creator_id smallint NOT NULL REFERENCES core.users(id)
|
creator_id smallint NOT NULL REFERENCES core.users(id)
|
||||||
ON UPDATE CASCADE ON DELETE RESTRICT,
|
ON UPDATE CASCADE ON DELETE RESTRICT,
|
||||||
is_public boolean NOT NULL DEFAULT false,
|
is_public boolean NOT NULL DEFAULT false,
|
||||||
|
-- File ordering within the pool. 'manual' keeps the user-defined order in
|
||||||
|
-- data.file_pool.position (drag-to-reorder); any other key sorts the pool's
|
||||||
|
-- files automatically by that file field, in which case reordering is disabled.
|
||||||
|
sort_key varchar(32) NOT NULL DEFAULT 'manual',
|
||||||
|
sort_order varchar(4) NOT NULL DEFAULT 'asc',
|
||||||
|
|
||||||
CONSTRAINT uni__pools__name UNIQUE (name)
|
CONSTRAINT uni__pools__name UNIQUE (name),
|
||||||
|
CONSTRAINT chk__pools__sort_key
|
||||||
|
CHECK (sort_key IN ('manual', 'content_datetime', 'created', 'original_name')),
|
||||||
|
CONSTRAINT chk__pools__sort_order
|
||||||
|
CHECK (sort_order IN ('asc', 'desc'))
|
||||||
);
|
);
|
||||||
|
|
||||||
-- `position` uses integer with gaps (e.g. 1000, 2000, 3000) to allow
|
-- `position` uses integer with gaps (e.g. 1000, 2000, 3000) to allow
|
||||||
|
|||||||
@@ -61,8 +61,15 @@
|
|||||||
let addSelected = $state(new Set<string>());
|
let addSelected = $state(new Set<string>());
|
||||||
let addSearchPrev = $state('');
|
let addSearchPrev = $state('');
|
||||||
|
|
||||||
// ---- Drag-to-reorder (disabled when filter active) ----
|
// ---- Sorting ----
|
||||||
let canReorder = $derived(!filterParam);
|
// The pool stores its own file sort. "manual" keeps the drag order; any other
|
||||||
|
// key sorts automatically (server-side) and disables reordering.
|
||||||
|
let sortKey = $derived(pool?.sort_key ?? 'manual');
|
||||||
|
let sortOrder = $derived(pool?.sort_order ?? 'asc');
|
||||||
|
let sortChanging = $state(false);
|
||||||
|
|
||||||
|
// ---- Drag-to-reorder (only in manual order, and not while filtering) ----
|
||||||
|
let canReorder = $derived(!filterParam && sortKey === 'manual');
|
||||||
let dragSrcIdx = $state<number | null>(null);
|
let dragSrcIdx = $state<number | null>(null);
|
||||||
let dragOverIdx = $state<number | null>(null);
|
let dragOverIdx = $state<number | null>(null);
|
||||||
let reorderPending = $state(false);
|
let reorderPending = $state(false);
|
||||||
@@ -131,6 +138,27 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Change the pool's file sort ----
|
||||||
|
// Persist the new sort on the pool, then reload the files from the top so the
|
||||||
|
// new server-side order takes effect. A no-op if the setting hasn't changed.
|
||||||
|
async function changeSort(key: string, order: string) {
|
||||||
|
if (!pool || sortChanging) return;
|
||||||
|
if (key === sortKey && order === sortOrder) return;
|
||||||
|
sortChanging = true;
|
||||||
|
try {
|
||||||
|
pool = await api.patch<Pool>(`/pools/${poolId}`, { sort_key: key, sort_order: order });
|
||||||
|
files = [];
|
||||||
|
nextCursor = null;
|
||||||
|
hasMore = true;
|
||||||
|
filesError = '';
|
||||||
|
await loadMore();
|
||||||
|
} catch (e) {
|
||||||
|
filesError = e instanceof ApiError ? e.message : 'Failed to change sort';
|
||||||
|
} finally {
|
||||||
|
sortChanging = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Save pool ----
|
// ---- Save pool ----
|
||||||
async function save() {
|
async function save() {
|
||||||
if (!name.trim() || saving) return;
|
if (!name.trim() || saving) return;
|
||||||
@@ -591,6 +619,32 @@
|
|||||||
{#if pool?.file_count != null}<span class="count">({pool.file_count})</span>{/if}
|
{#if pool?.file_count != null}<span class="count">({pool.file_count})</span>{/if}
|
||||||
</span>
|
</span>
|
||||||
<div class="files-header-actions">
|
<div class="files-header-actions">
|
||||||
|
<div class="sort-control">
|
||||||
|
<select
|
||||||
|
class="sort-select"
|
||||||
|
value={sortKey}
|
||||||
|
disabled={sortChanging}
|
||||||
|
onchange={(e) => changeSort((e.currentTarget as HTMLSelectElement).value, sortOrder)}
|
||||||
|
title="Sort files"
|
||||||
|
aria-label="Sort files"
|
||||||
|
>
|
||||||
|
<option value="manual">Manual order</option>
|
||||||
|
<option value="content_datetime">Date</option>
|
||||||
|
<option value="created">Date added</option>
|
||||||
|
<option value="original_name">Name</option>
|
||||||
|
</select>
|
||||||
|
{#if sortKey !== 'manual'}
|
||||||
|
<button
|
||||||
|
class="order-btn"
|
||||||
|
disabled={sortChanging}
|
||||||
|
onclick={() => changeSort(sortKey, sortOrder === 'asc' ? 'desc' : 'asc')}
|
||||||
|
title={sortOrder === 'asc' ? 'Ascending' : 'Descending'}
|
||||||
|
aria-label="Toggle sort direction"
|
||||||
|
>
|
||||||
|
{sortOrder === 'asc' ? '↑' : '↓'}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{#if canReorder && files.length > 1}
|
{#if canReorder && files.length > 1}
|
||||||
<span class="reorder-hint" title="Drag thumbnails to reorder">
|
<span class="reorder-hint" title="Drag thumbnails to reorder">
|
||||||
<svg width="13" height="13" viewBox="0 0 13 13" fill="none" aria-hidden="true">
|
<svg width="13" height="13" viewBox="0 0 13 13" fill="none" aria-hidden="true">
|
||||||
@@ -1043,6 +1097,57 @@
|
|||||||
border-color: var(--color-accent);
|
border-color: var(--color-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sort-control {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-select {
|
||||||
|
height: 26px;
|
||||||
|
padding: 0 6px;
|
||||||
|
border-radius: 5px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-accent) 25%, transparent);
|
||||||
|
background-color: var(--color-bg-elevated);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.sort-select:hover,
|
||||||
|
.sort-select:focus {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
.sort-select:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 5px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-accent) 25%, transparent);
|
||||||
|
background: none;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.order-btn:hover {
|
||||||
|
color: var(--color-accent);
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
.order-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- File grid ---- */
|
/* ---- File grid ---- */
|
||||||
main {
|
main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|||||||
@@ -2281,6 +2281,16 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
is_public:
|
is_public:
|
||||||
type: boolean
|
type: boolean
|
||||||
|
sort_key:
|
||||||
|
type: string
|
||||||
|
enum: [manual, content_datetime, created, original_name]
|
||||||
|
description: >-
|
||||||
|
How the pool's files are ordered. "manual" keeps the user-defined
|
||||||
|
order (drag-to-reorder); any other key sorts the files automatically
|
||||||
|
by that file field and disables manual reordering.
|
||||||
|
sort_order:
|
||||||
|
type: string
|
||||||
|
enum: [asc, desc]
|
||||||
file_count:
|
file_count:
|
||||||
type: integer
|
type: integer
|
||||||
created_at:
|
created_at:
|
||||||
@@ -2312,6 +2322,12 @@ components:
|
|||||||
type: object
|
type: object
|
||||||
is_public:
|
is_public:
|
||||||
type: boolean
|
type: boolean
|
||||||
|
sort_key:
|
||||||
|
type: string
|
||||||
|
enum: [manual, content_datetime, created, original_name]
|
||||||
|
sort_order:
|
||||||
|
type: string
|
||||||
|
enum: [asc, desc]
|
||||||
|
|
||||||
PoolOffsetPage:
|
PoolOffsetPage:
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
Reference in New Issue
Block a user