Skip to content

Commit 416fb60

Browse files
committed
feat(web): Table view — client-side filter + server-side sortable headers
Two paired UX additions to the Table view (List | Table | Graph): - Search input above the table (`<input data-search-input>` inside a `data-search-filter`/`data-search-scope` container) hooks into the existing v2-search-filter.js — no new JS needed. Each row carries `data-search-text` with CN, SAM, mail, DN concatenated so the substring filter matches both visible and adjacent attributes. - Column headers are now anchor links that toggle the active sort. Server-side sort via ?sort=col&dir=asc|desc (URL captures state for back/forward + bookmarking). Clicking the active column flips dir; clicking another resets to asc. Active-column heading shows ↑ / ↓. New helpers in internal/web/table_sort.go: - normaliseSortDir, sortUsersTable, sortGroupsTable, sortComputersTable - Status sort puts enabled rows first under asc — the more useful default than alphabetical "Disabled" / "Enabled". Tests: - TestNormaliseSortDir + per-entity sort tests cover ascending/ descending, status semantics, unknown-key fallback to CN. - TestHandleUsersV2_TableMode marker assertion updated to match the new sort-link / search-input markup. The cookie-persisted view selection from the previous PR is unaffected — sort/dir don't persist across views (they're meaningful only inside Table view), and the existing filterQS stays threaded through the header links so column clicks keep ?ou=, ?memberOf=, etc. Signed-off-by: Sebastian Mendel <info@sebastianmendel.de>
1 parent d9df5f0 commit 416fb60

8 files changed

Lines changed: 491 additions & 62 deletions

File tree

internal/web/computers_v2_handler.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,9 +100,13 @@ func (a *App) handleComputersV2(c *fiber.Ctx) error {
100100
}
101101

102102
if currentView == "table" {
103+
sortKey := c.Query("sort", "cn")
104+
sortDir := normaliseSortDir(c.Query("dir", "asc"))
105+
sortComputersTable(computers, sortKey, sortDir)
106+
103107
c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
104108

105-
return templates.ComputersListTableV2(computers, currentView, filterQS, a.takeFlash(c), a.paletteContextFor(viewerDN)).
109+
return templates.ComputersListTableV2(computers, currentView, filterQS, sortKey, sortDir, a.takeFlash(c), a.paletteContextFor(viewerDN)).
106110
Render(c.UserContext(), c.Response().BodyWriter())
107111
}
108112

internal/web/graph_v2_handler_test.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -345,7 +345,8 @@ func TestHandleUsersV2_TableMode(t *testing.T) {
345345

346346
for _, marker := range []string{
347347
`class="list-table"`,
348-
`<th scope="col">CN</th>`,
348+
`list-table__sort-link`, // sortable column-header anchor
349+
`data-search-input`, // client-side filter input
349350
"bob",
350351
`graph-segmented`,
351352
} {

internal/web/groups_v2_handler.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -327,9 +327,13 @@ func (a *App) handleGroupsV2(c *fiber.Ctx) error {
327327
}
328328

329329
if currentView == "table" {
330+
sortKey := c.Query("sort", "cn")
331+
sortDir := normaliseSortDir(c.Query("dir", "asc"))
332+
sortGroupsTable(groups, sortKey, sortDir)
333+
330334
c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
331335

332-
return templates.GroupsListTableV2(groups, currentView, filterQS, a.takeFlash(c), a.paletteContextFor(viewerDN)).
336+
return templates.GroupsListTableV2(groups, currentView, filterQS, sortKey, sortDir, a.takeFlash(c), a.paletteContextFor(viewerDN)).
333337
Render(c.UserContext(), c.Response().BodyWriter())
334338
}
335339

internal/web/static/app.css

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2786,3 +2786,38 @@ button.kv-edit__save {
27862786
.list-table__link:hover { text-decoration: underline; }
27872787
.list-table__num { font-variant-numeric: tabular-nums; text-align: right; }
27882788
.list-table__dn { color: var(--fg-muted); font-family: var(--font-mono, monospace); font-size: 0.85rem; word-break: break-all; }
2789+
2790+
/* Search input directly above the table — same data-search-filter
2791+
wiring as the list view, so v2-search-filter.js handles row-hide
2792+
without further JS. */
2793+
.list-table-filter { display: flex; flex-direction: column; gap: 0.75rem; }
2794+
.list-table-filter__input {
2795+
width: 100%;
2796+
padding: 0.45rem 0.75rem;
2797+
font: inherit;
2798+
color: var(--fg);
2799+
background: var(--bg);
2800+
border: 1px solid var(--border);
2801+
border-radius: 6px;
2802+
}
2803+
.list-table-filter__input:focus-visible {
2804+
outline: 2px solid var(--border-strong);
2805+
outline-offset: 2px;
2806+
}
2807+
2808+
/* Sortable column headers — anchor links so click-navigation works
2809+
without JS, indicator arrows show the active sort + direction. */
2810+
.list-table__sort-th { padding: 0; }
2811+
.list-table__sort-link {
2812+
display: block;
2813+
padding: 0.5rem 0.8rem;
2814+
color: var(--fg);
2815+
text-decoration: none;
2816+
text-transform: uppercase;
2817+
font-size: 0.8rem;
2818+
letter-spacing: 0.03em;
2819+
font-weight: 600;
2820+
}
2821+
.list-table__sort-link:hover { background: var(--border); }
2822+
.list-table__sort-link:focus-visible { outline: 2px solid var(--border-strong); outline-offset: -2px; }
2823+
.list-table__sort-indicator { color: var(--fg-muted); font-weight: 400; }

internal/web/table_sort.go

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
// internal/web/table_sort.go — sort helpers for the Table view
2+
// (List | Table | Graph). Sort key + direction are server-side
3+
// query params (?sort=col&dir=asc|desc), so the URL captures the
4+
// sort state and back/forward navigation works without JS.
5+
package web
6+
7+
import (
8+
"sort"
9+
"strings"
10+
11+
ldap "github.com/netresearch/simple-ldap-go"
12+
)
13+
14+
// normaliseSortDir clamps an unknown direction to "asc". Used so an
15+
// arbitrary ?dir=foo doesn't turn into a bug downstream.
16+
func normaliseSortDir(d string) string {
17+
if d == "desc" {
18+
return "desc"
19+
}
20+
21+
return "asc"
22+
}
23+
24+
// sortUsersTable sorts users in place by the requested column. Unknown
25+
// sort keys fall back to CN — the default column the table is
26+
// initially shown in.
27+
func sortUsersTable(users []ldap.User, key, dir string) {
28+
dir = normaliseSortDir(dir)
29+
30+
less := func(i, j int) bool {
31+
var a, b string
32+
33+
switch key {
34+
case "sam":
35+
a, b = users[i].SAMAccountName, users[j].SAMAccountName
36+
case "mail":
37+
if users[i].Mail != nil {
38+
a = *users[i].Mail
39+
}
40+
if users[j].Mail != nil {
41+
b = *users[j].Mail
42+
}
43+
case "status":
44+
a, b = userStatusForSort(users[i]), userStatusForSort(users[j])
45+
default: // "cn"
46+
a, b = users[i].CN(), users[j].CN()
47+
}
48+
49+
if a == b {
50+
// Stable secondary key on DN so otherwise-equal rows have a
51+
// deterministic order across renders.
52+
return users[i].DN() < users[j].DN()
53+
}
54+
55+
if dir == "desc" {
56+
return strings.ToLower(a) > strings.ToLower(b)
57+
}
58+
59+
return strings.ToLower(a) < strings.ToLower(b)
60+
}
61+
62+
sort.SliceStable(users, less)
63+
}
64+
65+
// sortGroupsTable sorts groups by the requested column.
66+
func sortGroupsTable(groups []ldap.Group, key, dir string) {
67+
dir = normaliseSortDir(dir)
68+
69+
less := func(i, j int) bool {
70+
switch key {
71+
case "members":
72+
a, b := len(groups[i].Members), len(groups[j].Members)
73+
if a == b {
74+
return groups[i].DN() < groups[j].DN()
75+
}
76+
77+
if dir == "desc" {
78+
return a > b
79+
}
80+
81+
return a < b
82+
case "dn":
83+
a, b := groups[i].DN(), groups[j].DN()
84+
if dir == "desc" {
85+
return strings.ToLower(a) > strings.ToLower(b)
86+
}
87+
88+
return strings.ToLower(a) < strings.ToLower(b)
89+
default: // "cn"
90+
a, b := groups[i].CN(), groups[j].CN()
91+
if a == b {
92+
return groups[i].DN() < groups[j].DN()
93+
}
94+
95+
if dir == "desc" {
96+
return strings.ToLower(a) > strings.ToLower(b)
97+
}
98+
99+
return strings.ToLower(a) < strings.ToLower(b)
100+
}
101+
}
102+
103+
sort.SliceStable(groups, less)
104+
}
105+
106+
// sortComputersTable sorts computers by the requested column.
107+
func sortComputersTable(computers []ldap.Computer, key, dir string) {
108+
dir = normaliseSortDir(dir)
109+
110+
less := func(i, j int) bool {
111+
var a, b string
112+
113+
switch key {
114+
case "sam":
115+
a, b = computers[i].SAMAccountName, computers[j].SAMAccountName
116+
case "status":
117+
a, b = computerStatusForSort(computers[i]), computerStatusForSort(computers[j])
118+
case "dn":
119+
a, b = computers[i].DN(), computers[j].DN()
120+
default: // "cn"
121+
a, b = computers[i].CN(), computers[j].CN()
122+
}
123+
124+
if a == b {
125+
return computers[i].DN() < computers[j].DN()
126+
}
127+
128+
if dir == "desc" {
129+
return strings.ToLower(a) > strings.ToLower(b)
130+
}
131+
132+
return strings.ToLower(a) < strings.ToLower(b)
133+
}
134+
135+
sort.SliceStable(computers, less)
136+
}
137+
138+
// userStatusForSort maps Enabled→"a" / Disabled→"b" so an asc sort
139+
// puts enabled rows on top — the more useful default than alphabetical
140+
// "Disabled" / "Enabled".
141+
func userStatusForSort(u ldap.User) string {
142+
if u.Enabled {
143+
return "a"
144+
}
145+
146+
return "b"
147+
}
148+
149+
// computerStatusForSort mirrors userStatusForSort.
150+
func computerStatusForSort(c ldap.Computer) string {
151+
if c.Enabled {
152+
return "a"
153+
}
154+
155+
return "b"
156+
}

internal/web/table_sort_test.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
// internal/web/table_sort_test.go — unit coverage for the Table-view
2+
// server-side sort helpers. Stays in the web package so the test sees
3+
// the unexported sortUsersTable / sortGroupsTable / sortComputersTable
4+
// directly.
5+
package web
6+
7+
import (
8+
"testing"
9+
10+
ldap "github.com/netresearch/simple-ldap-go"
11+
"github.com/stretchr/testify/require"
12+
13+
"github.com/netresearch/ldap-manager/internal/ldap_cache/cachetest"
14+
)
15+
16+
func TestNormaliseSortDir(t *testing.T) {
17+
require.Equal(t, "asc", normaliseSortDir(""))
18+
require.Equal(t, "asc", normaliseSortDir("foo"))
19+
require.Equal(t, "asc", normaliseSortDir("ASC")) // case-sensitive: only literal "desc" wins
20+
require.Equal(t, "desc", normaliseSortDir("desc"))
21+
}
22+
23+
func TestSortUsersTable_ByCNAscDesc(t *testing.T) {
24+
users := []ldap.User{
25+
cachetest.NewUserWithDN("cn=charlie,dc=ex,dc=com", "charlie", "csam", true, nil),
26+
cachetest.NewUserWithDN("cn=alice,dc=ex,dc=com", "alice", "asam", true, nil),
27+
cachetest.NewUserWithDN("cn=bob,dc=ex,dc=com", "bob", "bsam", false, nil),
28+
}
29+
30+
sortUsersTable(users, "cn", "asc")
31+
require.Equal(t, "alice", users[0].CN())
32+
require.Equal(t, "bob", users[1].CN())
33+
require.Equal(t, "charlie", users[2].CN())
34+
35+
sortUsersTable(users, "cn", "desc")
36+
require.Equal(t, "charlie", users[0].CN())
37+
require.Equal(t, "bob", users[1].CN())
38+
require.Equal(t, "alice", users[2].CN())
39+
}
40+
41+
func TestSortUsersTable_ByStatusEnabledFirst(t *testing.T) {
42+
users := []ldap.User{
43+
cachetest.NewUserWithDN("cn=disabled,dc=ex,dc=com", "disabled", "d", false, nil),
44+
cachetest.NewUserWithDN("cn=enabled,dc=ex,dc=com", "enabled", "e", true, nil),
45+
}
46+
sortUsersTable(users, "status", "asc")
47+
// "Enabled" (key "a") < "Disabled" (key "b") so enabled rows come
48+
// first under asc — the documented contract for the status sort.
49+
require.True(t, users[0].Enabled, "first row should be enabled under asc status sort")
50+
require.False(t, users[1].Enabled)
51+
}
52+
53+
func TestSortUsersTable_UnknownKeyFallsBackToCN(t *testing.T) {
54+
users := []ldap.User{
55+
cachetest.NewUserWithDN("cn=zed,dc=ex,dc=com", "zed", "z", true, nil),
56+
cachetest.NewUserWithDN("cn=ann,dc=ex,dc=com", "ann", "a", true, nil),
57+
}
58+
sortUsersTable(users, "lol-not-a-column", "asc")
59+
require.Equal(t, "ann", users[0].CN(), "unknown sort key should fall back to CN")
60+
}
61+
62+
func TestSortGroupsTable_ByMembersDesc(t *testing.T) {
63+
groups := []ldap.Group{
64+
cachetest.NewGroupWithDN("cn=small,dc=ex,dc=com", "small", []string{"a"}),
65+
cachetest.NewGroupWithDN("cn=big,dc=ex,dc=com", "big", []string{"a", "b", "c"}),
66+
cachetest.NewGroupWithDN("cn=mid,dc=ex,dc=com", "mid", []string{"a", "b"}),
67+
}
68+
sortGroupsTable(groups, "members", "desc")
69+
require.Equal(t, "big", groups[0].CN())
70+
require.Equal(t, "mid", groups[1].CN())
71+
require.Equal(t, "small", groups[2].CN())
72+
}
73+
74+
func TestSortComputersTable_BySAM(t *testing.T) {
75+
computers := []ldap.Computer{
76+
cachetest.NewComputerWithDN("cn=ws03,dc=ex,dc=com", "ws03", "ws03$", true, nil),
77+
cachetest.NewComputerWithDN("cn=ws01,dc=ex,dc=com", "ws01", "ws01$", true, nil),
78+
}
79+
sortComputersTable(computers, "sam", "asc")
80+
require.Equal(t, "ws01$", computers[0].SAMAccountName)
81+
require.Equal(t, "ws03$", computers[1].SAMAccountName)
82+
}

0 commit comments

Comments
 (0)