Skip to content

Commit 9e9faaf

Browse files
committed
feat(web): Table view + persistent List/Table/Graph selection
The List|Graph toggle from Slice 5 only stuck per-page; switching from /users to /groups in the topnav reset to default List. Adds: - A third "Table" view for users/groups/computers — full-width scannable table without OU filter rail or detail drawer. Each row links straight to the entity's detail page. Same data as List view, optimised for browsing many rows at once. - Cookie-backed view persistence (graph-view, 30 days, SameSite=Strict, HttpOnly). pickView() reads the ?view= query when present (and refreshes the cookie) or falls back to the cookie value, so picking Table on /users sticks across /groups and /computers. - Three-segment toggle (List | Table | Graph) with always-explicit ?view= so clicking re-asserts the user's choice and refreshes the cookie even when clicking the segment that's already active. - Template-cache key now includes the graph-view cookie so /users (no query) caches separately per view preference. Without this the first cached render won regardless of cookie, making the toggle appear broken — see the "sometimes works" review feedback. - Content-Type explicitly set on the new Table renders. The list view's existing render does this; the new branches needed it too, otherwise the browser was shown the literal HTML source. Signed-off-by: Sebastian Mendel <info@sebastianmendel.de>
1 parent 7d64bd4 commit 9e9faaf

7 files changed

Lines changed: 279 additions & 31 deletions

File tree

internal/web/computers_v2_handler.go

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,18 +85,25 @@ func (a *App) handleComputersV2(c *fiber.Ctx) error {
8585

8686
sortComputersByCN(computers)
8787

88-
currentView := c.Query("view")
88+
currentView := pickView(c)
8989
if a.ldapCache == nil {
90-
currentView = ""
90+
currentView = "list"
9191
}
9292

93-
if currentView == "graph" && a.ldapCache != nil {
93+
if currentView == "graph" {
9494
data := a.ldapCache.BuildListGraph(nil, computers)
9595
vm := templates.GraphPageVM{Data: data, BackHref: "/computers", FocusLabel: "Computers"}
9696

9797
return a.templateCache.RenderWithCache(c, templates.GraphPageV2(vm))
9898
}
9999

100+
if currentView == "table" {
101+
c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
102+
103+
return templates.ComputersListTableV2(computers, currentView, a.takeFlash(c), a.paletteContextFor(viewerDN)).
104+
Render(c.UserContext(), c.Response().BodyWriter())
105+
}
106+
100107
ous := distinctImmediateOUsFromComputers(all)
101108

102109
c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)

internal/web/graph_view.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// internal/web/graph_view.go — list-page view-mode helpers.
2+
package web
3+
4+
import (
5+
"github.com/gofiber/fiber/v2"
6+
)
7+
8+
// graphViewCookie is the cookie name used to remember the user's
9+
// preferred list-page view mode. Reads / writes go through pickView so
10+
// the storage detail stays local to this file.
11+
const graphViewCookie = "graph-view"
12+
13+
// pickView resolves the effective list-page view mode for the current
14+
// request and persists explicit choices to a cookie so the preference
15+
// follows the user across /users, /groups, and /computers.
16+
//
17+
// Resolution order:
18+
// 1. ?view=… query parameter (explicit choice — set the cookie).
19+
// 2. graph-view cookie (sticky preference from a previous request).
20+
// 3. "list" (default).
21+
//
22+
// Unknown values normalise to "list" so a typo'd URL doesn't render
23+
// nothing.
24+
func pickView(c *fiber.Ctx) string {
25+
if raw := c.Query("view"); raw != "" {
26+
v := normaliseView(raw)
27+
setViewCookie(c, v)
28+
29+
return v
30+
}
31+
32+
return normaliseView(c.Cookies(graphViewCookie))
33+
}
34+
35+
// normaliseView clamps an unknown view string to the safe default. Any
36+
// new view modes need to be added here AND to the segmented toggle in
37+
// internal/web/templates/graph_toggle.templ.
38+
func normaliseView(s string) string {
39+
switch s {
40+
case "list", "table", "graph":
41+
return s
42+
default:
43+
return "list"
44+
}
45+
}
46+
47+
// setViewCookie writes the user's explicit choice so future requests
48+
// without ?view= still resolve to the same mode. SameSite=Strict
49+
// matches the rest of the session security profile; HTTPOnly keeps the
50+
// preference out of JS reach (the segmented toggle reads `currentView`
51+
// from the rendered template, not from the cookie).
52+
func setViewCookie(c *fiber.Ctx, v string) {
53+
c.Cookie(&fiber.Cookie{
54+
Name: graphViewCookie,
55+
Value: v,
56+
Path: "/",
57+
MaxAge: 30 * 24 * 3600, // 30 days
58+
HTTPOnly: true,
59+
SameSite: "Strict",
60+
})
61+
}

internal/web/groups_v2_handler.go

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -296,12 +296,12 @@ func (a *App) handleGroupsV2(c *fiber.Ctx) error {
296296
groups = filterGroupsByMember(groups, memberDN)
297297
sortGroupsByCN(groups)
298298

299-
currentView := c.Query("view")
299+
currentView := pickView(c)
300300
if a.ldapCache == nil {
301-
currentView = ""
301+
currentView = "list"
302302
}
303303

304-
if currentView == "graph" && a.ldapCache != nil {
304+
if currentView == "graph" {
305305
members := make([]ldap.User, 0)
306306
computers := make([]ldap.Computer, 0)
307307
seenMember := make(map[string]struct{})
@@ -324,6 +324,13 @@ func (a *App) handleGroupsV2(c *fiber.Ctx) error {
324324
return a.templateCache.RenderWithCache(c, templates.GraphPageV2(vm))
325325
}
326326

327+
if currentView == "table" {
328+
c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
329+
330+
return templates.GroupsListTableV2(groups, currentView, a.takeFlash(c), a.paletteContextFor(viewerDN)).
331+
Render(c.UserContext(), c.Response().BodyWriter())
332+
}
333+
327334
memberCN := lookupUserCN(memberDN, a.ldapCache)
328335

329336
c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)

internal/web/template_cache.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,15 @@ func (tc *TemplateCache) generateCacheKey(c *fiber.Ctx, additionalData ...string
8282
h.Write([]byte(userDN))
8383
}
8484

85+
// Include the list-view preference cookie so /users (no query) with
86+
// graph-view=table caches separately from /users with graph-view=list.
87+
// Without this, the first cached response wins regardless of the
88+
// per-user preference and the segmented toggle appears broken.
89+
if v := c.Cookies(graphViewCookie); v != "" {
90+
h.Write([]byte("view:"))
91+
h.Write([]byte(v))
92+
}
93+
8594
// Include any additional data for cache differentiation
8695
for _, data := range additionalData {
8796
h.Write([]byte(data))

internal/web/templates/graph_toggle.templ

Lines changed: 24 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,29 @@
1-
// internal/web/templates/graph_toggle.templ — segmented "List | Graph"
2-
// view toggle reused by all three list pages (Phase 3 §6.6).
1+
// internal/web/templates/graph_toggle.templ — segmented "List | Table | Graph"
2+
// view toggle reused by all three list pages.
33
package templates
44

55
import "strings"
66

7-
// listGraphToggle renders the segmented control. `base` is the bare list
8-
// route ("/users", "/groups", "/computers"). `currentView` is the value
9-
// of c.Query("view") at request time (empty string for default list).
10-
// `filters` is the URL-encoded query string of OTHER filters to preserve
11-
// (without leading "?", without view=…). Both branches preserve filters.
7+
// listGraphToggle renders the segmented control for switching between
8+
// the three list-page views. `base` is the bare list route ("/users",
9+
// "/groups", "/computers"). `currentView` is the value returned by
10+
// pickView (one of "list", "table", "graph"). `filters` is the URL-
11+
// encoded query string of OTHER filters to preserve (without leading
12+
// "?", without view=…). All segments include an explicit ?view= so
13+
// clicking re-asserts the user's choice and (server-side) refreshes
14+
// the persistence cookie.
1215
templ listGraphToggle(base, currentView, filters string) {
13-
<nav class="graph-segmented" aria-label="List or graph view">
16+
<nav class="graph-segmented" aria-label="List, table, or graph view">
1417
<a
15-
class={ "graph-segmented__option", toggleClass(currentView, "") }
16-
href={ templ.URL(buildToggleHref(base, "", filters)) }
17-
aria-current={ toggleAriaCurrent(currentView, "") }
18+
class={ "graph-segmented__option", toggleClass(currentView, "list") }
19+
href={ templ.URL(buildToggleHref(base, "list", filters)) }
20+
aria-current={ toggleAriaCurrent(currentView, "list") }
1821
>List</a>
22+
<a
23+
class={ "graph-segmented__option", toggleClass(currentView, "table") }
24+
href={ templ.URL(buildToggleHref(base, "table", filters)) }
25+
aria-current={ toggleAriaCurrent(currentView, "table") }
26+
>Table</a>
1927
<a
2028
class={ "graph-segmented__option", toggleClass(currentView, "graph") }
2129
href={ templ.URL(buildToggleHref(base, "graph", filters)) }
@@ -49,21 +57,15 @@ func toggleAriaCurrent(current, target string) string {
4957
return "false"
5058
}
5159

52-
// buildToggleHref combines base path + view selector + preserved filters.
53-
// "view=graph" goes first when set; other filter pairs follow.
60+
// buildToggleHref combines base path + view selector + preserved
61+
// filters. The view selector is always emitted so the server's
62+
// pickView() helper can refresh its persistence cookie even when the
63+
// user clicks the segment that's already active.
5464
func buildToggleHref(base, view, filters string) string {
55-
parts := []string{}
56-
if view != "" {
57-
parts = append(parts, "view="+view)
58-
}
59-
65+
parts := []string{"view=" + view}
6066
if filters != "" {
6167
parts = append(parts, filters)
6268
}
6369

64-
if len(parts) == 0 {
65-
return base
66-
}
67-
6870
return base + "?" + strings.Join(parts, "&")
6971
}
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
// internal/web/templates/list_table_v2.templ — full-width "Table"
2+
// view for /users, /groups, /computers. Companion to UsersListV2 /
3+
// GroupsListV2 / ComputersListV2 (List view) and GraphPageV2 (Graph
4+
// view). The third option in the segmented toggle.
5+
//
6+
// Goal: scan many rows fast. No filter rail, no drawer. Each row links
7+
// directly to the entity's detail page — clicking goes "all the way",
8+
// not into a side pane.
9+
package templates
10+
11+
import (
12+
"fmt"
13+
14+
ldap "github.com/netresearch/simple-ldap-go"
15+
)
16+
17+
// UsersListTableV2 renders the full-width users table.
18+
templ UsersListTableV2(users []ldap.User, currentView string, flashes []Flash, palettePinned []PinnedEntry) {
19+
@baseV2PageScroll("Users — Table") {
20+
@topnavV2("/users")
21+
<main id="main-content" class="list-table-page">
22+
<header class="list-page__head">
23+
<div class="list-page__head-titles">
24+
<h1 class="list-page__title">Users</h1>
25+
<p class="list-page__count">{ fmt.Sprintf("%d users", len(users)) }</p>
26+
</div>
27+
<div class="list-page__head-controls">
28+
@listGraphToggle("/users", currentView, "")
29+
</div>
30+
</header>
31+
@listFlashes(flashes)
32+
<table class="list-table">
33+
<thead>
34+
<tr>
35+
<th scope="col">CN</th>
36+
<th scope="col">SAM account</th>
37+
<th scope="col">Mail</th>
38+
<th scope="col">Status</th>
39+
</tr>
40+
</thead>
41+
<tbody>
42+
for _, u := range users {
43+
<tr>
44+
<td><a class="list-table__link" href={ userDetailHref(u) }>{ u.CN() }</a></td>
45+
<td>{ u.SAMAccountName }</td>
46+
<td>{ derefString(u.Mail) }</td>
47+
<td>{ userStatusLabel(u) }</td>
48+
</tr>
49+
}
50+
</tbody>
51+
</table>
52+
@paletteV2WithPinned(palettePinned)
53+
</main>
54+
}
55+
}
56+
57+
// GroupsListTableV2 renders the full-width groups table.
58+
templ GroupsListTableV2(groups []ldap.Group, currentView string, flashes []Flash, palettePinned []PinnedEntry) {
59+
@baseV2PageScroll("Groups — Table") {
60+
@topnavV2("/groups")
61+
<main id="main-content" class="list-table-page">
62+
<header class="list-page__head">
63+
<div class="list-page__head-titles">
64+
<h1 class="list-page__title">Groups</h1>
65+
<p class="list-page__count">{ fmt.Sprintf("%d groups", len(groups)) }</p>
66+
</div>
67+
<div class="list-page__head-controls">
68+
@listGraphToggle("/groups", currentView, "")
69+
</div>
70+
</header>
71+
@listFlashes(flashes)
72+
<table class="list-table">
73+
<thead>
74+
<tr>
75+
<th scope="col">CN</th>
76+
<th scope="col">Members</th>
77+
<th scope="col">DN</th>
78+
</tr>
79+
</thead>
80+
<tbody>
81+
for _, g := range groups {
82+
<tr>
83+
<td><a class="list-table__link" href={ groupDetailHref(g) }>{ g.CN() }</a></td>
84+
<td class="list-table__num">{ fmt.Sprintf("%d", len(g.Members)) }</td>
85+
<td class="list-table__dn">{ g.DN() }</td>
86+
</tr>
87+
}
88+
</tbody>
89+
</table>
90+
@paletteV2WithPinned(palettePinned)
91+
</main>
92+
}
93+
}
94+
95+
// ComputersListTableV2 renders the full-width computers table.
96+
templ ComputersListTableV2(computers []ldap.Computer, currentView string, flashes []Flash, palettePinned []PinnedEntry) {
97+
@baseV2PageScroll("Computers — Table") {
98+
@topnavV2("/computers")
99+
<main id="main-content" class="list-table-page">
100+
<header class="list-page__head">
101+
<div class="list-page__head-titles">
102+
<h1 class="list-page__title">Computers</h1>
103+
<p class="list-page__count">{ fmt.Sprintf("%d computers", len(computers)) }</p>
104+
</div>
105+
<div class="list-page__head-controls">
106+
@listGraphToggle("/computers", currentView, "")
107+
</div>
108+
</header>
109+
@listFlashes(flashes)
110+
<table class="list-table">
111+
<thead>
112+
<tr>
113+
<th scope="col">CN</th>
114+
<th scope="col">SAM account</th>
115+
<th scope="col">Status</th>
116+
<th scope="col">DN</th>
117+
</tr>
118+
</thead>
119+
<tbody>
120+
for _, c := range computers {
121+
<tr>
122+
<td><a class="list-table__link" href={ computerDetailHref(c) }>{ c.CN() }</a></td>
123+
<td>{ c.SAMAccountName }</td>
124+
<td>{ computerStatusLabel(c) }</td>
125+
<td class="list-table__dn">{ c.DN() }</td>
126+
</tr>
127+
}
128+
</tbody>
129+
</table>
130+
@paletteV2WithPinned(palettePinned)
131+
</main>
132+
}
133+
}
134+
135+
// userStatusLabel — "Enabled" / "Disabled" cell content.
136+
func userStatusLabel(u ldap.User) string {
137+
if u.Enabled {
138+
return "Enabled"
139+
}
140+
141+
return "Disabled"
142+
}
143+
144+
// computerStatusLabel — "Enabled" / "Disabled" cell content.
145+
func computerStatusLabel(c ldap.Computer) string {
146+
if c.Enabled {
147+
return "Enabled"
148+
}
149+
150+
return "Disabled"
151+
}

internal/web/users_v2_handler.go

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,18 +102,29 @@ func (a *App) handleUsersV2(c *fiber.Ctx) error {
102102
users = filterUsersByMemberOf(users, memberOf, a.ldapCache)
103103
sortUsersByCN(users)
104104

105-
currentView := c.Query("view")
105+
currentView := pickView(c)
106106
if a.ldapCache == nil {
107-
currentView = ""
107+
// Cache-less mode can't render graph or table — they need cache lookups
108+
// for membership and the list filters. Force list view, but DON'T
109+
// rewrite the cookie; if the user re-enables the service account,
110+
// their previous preference returns.
111+
currentView = "list"
108112
}
109113

110-
if currentView == "graph" && a.ldapCache != nil {
114+
if currentView == "graph" {
111115
data := a.ldapCache.BuildListGraph(users, nil)
112116
vm := templates.GraphPageVM{Data: data, BackHref: "/users", FocusLabel: "Users"}
113117

114118
return a.templateCache.RenderWithCache(c, templates.GraphPageV2(vm))
115119
}
116120

121+
if currentView == "table" {
122+
c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
123+
124+
return templates.UsersListTableV2(users, currentView, a.takeFlash(c), a.paletteContextFor(viewerDN)).
125+
Render(c.UserContext(), c.Response().BodyWriter())
126+
}
127+
117128
memberOfCN := lookupGroupCN(memberOf, a.ldapCache)
118129
adminDNs := adminUserDNs(a.ldapCache)
119130

0 commit comments

Comments
 (0)