Compare commits
4 Commits
a9f062a67e
...
mai/kahn/p
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a8ea8f31e | |||
| 1f8c626aed | |||
| f820fa5830 | |||
| e305f0e0ae |
@@ -45,7 +45,7 @@ func TestLayoutHasAdminNavLink(t *testing.T) {
|
|||||||
srv, pool := mustServer(t)
|
srv, pool := mustServer(t)
|
||||||
defer pool.Close()
|
defer pool.Close()
|
||||||
h := srv.Routes()
|
h := srv.Routes()
|
||||||
for _, path := range []string{"/", "/dashboard", "/graph", "/admin/bulk", "/admin/classify"} {
|
for _, path := range []string{"/views/tree", "/views/dashboard", "/views/graph", "/admin/bulk", "/admin/classify"} {
|
||||||
_, body := get(t, h, path)
|
_, body := get(t, h, path)
|
||||||
if !strings.Contains(body, `href="/admin"`) {
|
if !strings.Contains(body, `href="/admin"`) {
|
||||||
t.Errorf("GET %s: nav missing /admin link", path)
|
t.Errorf("GET %s: nav missing /admin link", path)
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
|
|||||||
"Query": q,
|
"Query": q,
|
||||||
"Now": now,
|
"Now": now,
|
||||||
"Projects": projects,
|
"Projects": projects,
|
||||||
"BasePath": "/calendar",
|
"BasePath": "/views/calendar",
|
||||||
"ProjectChipTarget": "#calendar-section",
|
"ProjectChipTarget": "#calendar-section",
|
||||||
}
|
}
|
||||||
if r.Header.Get("HX-Request") == "true" {
|
if r.Header.Get("HX-Request") == "true" {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ func TestCalendarRendersMonthGrid(t *testing.T) {
|
|||||||
srv, pool := mustServer(t)
|
srv, pool := mustServer(t)
|
||||||
defer pool.Close()
|
defer pool.Close()
|
||||||
h := srv.Routes()
|
h := srv.Routes()
|
||||||
code, body := get(t, h, "/calendar")
|
code, body := get(t, h, "/views/calendar")
|
||||||
if code != 200 {
|
if code != 200 {
|
||||||
t.Fatalf("GET /calendar → %d body=%s", code, body)
|
t.Fatalf("GET /calendar → %d body=%s", code, body)
|
||||||
}
|
}
|
||||||
@@ -27,7 +27,7 @@ func TestCalendarRendersMonthGrid(t *testing.T) {
|
|||||||
`<th scope="col">Mon</th>`,
|
`<th scope="col">Mon</th>`,
|
||||||
`<th scope="col">Sun</th>`,
|
`<th scope="col">Sun</th>`,
|
||||||
`class="calendar-nav"`,
|
`class="calendar-nav"`,
|
||||||
`href="/calendar?month=`, // prev/next anchors present
|
`href="/views/calendar?month=`, // prev/next anchors present
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(body, want) {
|
if !strings.Contains(body, want) {
|
||||||
t.Errorf("calendar body missing %q", want)
|
t.Errorf("calendar body missing %q", want)
|
||||||
@@ -71,7 +71,7 @@ func TestCalendarSurfacesDatedLink(t *testing.T) {
|
|||||||
t.Fatalf("seed link: %v", err)
|
t.Fatalf("seed link: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
code, body := get(t, h, "/calendar")
|
code, body := get(t, h, "/views/calendar")
|
||||||
if code != 200 {
|
if code != 200 {
|
||||||
t.Fatalf("GET /calendar → %d", code)
|
t.Fatalf("GET /calendar → %d", code)
|
||||||
}
|
}
|
||||||
@@ -130,7 +130,7 @@ func TestCalendarFilterScopeByTag(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Unfiltered: both notes show.
|
// Unfiltered: both notes show.
|
||||||
_, all := get(t, h, "/calendar?refresh=1")
|
_, all := get(t, h, "/views/calendar?refresh=1")
|
||||||
if !strings.Contains(all, workNote) {
|
if !strings.Contains(all, workNote) {
|
||||||
t.Errorf("unfiltered calendar missing work note %q", workNote)
|
t.Errorf("unfiltered calendar missing work note %q", workNote)
|
||||||
}
|
}
|
||||||
@@ -139,7 +139,7 @@ func TestCalendarFilterScopeByTag(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Filtered: only work note shows.
|
// Filtered: only work note shows.
|
||||||
_, scoped := get(t, h, "/calendar?refresh=1&tag=cal-test-work-"+stamp)
|
_, scoped := get(t, h, "/views/calendar?refresh=1&tag=cal-test-work-"+stamp)
|
||||||
if !strings.Contains(scoped, workNote) {
|
if !strings.Contains(scoped, workNote) {
|
||||||
t.Errorf("filtered calendar missing work note %q", workNote)
|
t.Errorf("filtered calendar missing work note %q", workNote)
|
||||||
}
|
}
|
||||||
@@ -157,7 +157,7 @@ func TestCalendarAdjacentMonthDays(t *testing.T) {
|
|||||||
h := srv.Routes()
|
h := srv.Routes()
|
||||||
// Pick a month whose first day is NOT a Monday so leading days appear.
|
// Pick a month whose first day is NOT a Monday so leading days appear.
|
||||||
// May 2026 starts on a Friday; lead = Apr 27/28/29/30.
|
// May 2026 starts on a Friday; lead = Apr 27/28/29/30.
|
||||||
_, body := get(t, h, "/calendar?month=2026-05&refresh=1")
|
_, body := get(t, h, "/views/calendar?month=2026-05&refresh=1")
|
||||||
if !strings.Contains(body, "adjacent-month") {
|
if !strings.Contains(body, "adjacent-month") {
|
||||||
t.Errorf("expected adjacent-month class on lead-in cells for May 2026, body did not include it")
|
t.Errorf("expected adjacent-month class on lead-in cells for May 2026, body did not include it")
|
||||||
}
|
}
|
||||||
@@ -173,11 +173,11 @@ func TestCalendarNavPrevNextLinks(t *testing.T) {
|
|||||||
srv, pool := mustServer(t)
|
srv, pool := mustServer(t)
|
||||||
defer pool.Close()
|
defer pool.Close()
|
||||||
h := srv.Routes()
|
h := srv.Routes()
|
||||||
_, body := get(t, h, "/calendar?month=2026-05")
|
_, body := get(t, h, "/views/calendar?month=2026-05")
|
||||||
if !strings.Contains(body, `href="/calendar?month=2026-04"`) {
|
if !strings.Contains(body, `href="/views/calendar?month=2026-04"`) {
|
||||||
t.Errorf("expected prev link to 2026-04, body did not include it")
|
t.Errorf("expected prev link to 2026-04, body did not include it")
|
||||||
}
|
}
|
||||||
if !strings.Contains(body, `href="/calendar?month=2026-06"`) {
|
if !strings.Contains(body, `href="/views/calendar?month=2026-06"`) {
|
||||||
t.Errorf("expected next link to 2026-06, body did not include it")
|
t.Errorf("expected next link to 2026-06, body did not include it")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -190,11 +190,11 @@ func TestCalendarFilterChipStripRenders(t *testing.T) {
|
|||||||
srv, pool := mustServer(t)
|
srv, pool := mustServer(t)
|
||||||
defer pool.Close()
|
defer pool.Close()
|
||||||
h := srv.Routes()
|
h := srv.Routes()
|
||||||
_, body := get(t, h, "/calendar?month=2026-05")
|
_, body := get(t, h, "/views/calendar?month=2026-05")
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
`id="calendar-filterbar"`,
|
`id="calendar-filterbar"`,
|
||||||
`hx-target="#calendar-section"`,
|
`hx-target="#calendar-section"`,
|
||||||
`hx-get="/calendar"`,
|
`hx-get="/views/calendar"`,
|
||||||
`<input type="hidden" name="month" value="2026-05">`, // preserves month across chip changes
|
`<input type="hidden" name="month" value="2026-05">`, // preserves month across chip changes
|
||||||
`name="kind"`,
|
`name="kind"`,
|
||||||
`name="tag"`,
|
`name="tag"`,
|
||||||
@@ -213,7 +213,7 @@ func TestCalendarHTMXReturnsSectionOnly(t *testing.T) {
|
|||||||
srv, pool := mustServer(t)
|
srv, pool := mustServer(t)
|
||||||
defer pool.Close()
|
defer pool.Close()
|
||||||
h := srv.Routes()
|
h := srv.Routes()
|
||||||
req := httptest.NewRequest("GET", "/calendar?month=2026-05", nil)
|
req := httptest.NewRequest("GET", "/views/calendar?month=2026-05", nil)
|
||||||
req.Header.Set("HX-Request", "true")
|
req.Header.Set("HX-Request", "true")
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
h.ServeHTTP(w, req)
|
h.ServeHTTP(w, req)
|
||||||
@@ -294,7 +294,7 @@ func TestCalendarFilterMultiValueTagsFromForm(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// HTMX-style multi-value submission: two `tag=` params, not comma-joined.
|
// HTMX-style multi-value submission: two `tag=` params, not comma-joined.
|
||||||
url := "/calendar?refresh=1&tag=" + tagA + "&tag=" + tagB
|
url := "/views/calendar?refresh=1&tag=" + tagA + "&tag=" + tagB
|
||||||
_, body := get(t, h, url)
|
_, body := get(t, h, url)
|
||||||
|
|
||||||
// Item AB has BOTH tags — must appear.
|
// Item AB has BOTH tags — must appear.
|
||||||
@@ -322,7 +322,7 @@ func TestCalendarCellCarriesLongLabel(t *testing.T) {
|
|||||||
srv, pool := mustServer(t)
|
srv, pool := mustServer(t)
|
||||||
defer pool.Close()
|
defer pool.Close()
|
||||||
h := srv.Routes()
|
h := srv.Routes()
|
||||||
_, body := get(t, h, "/calendar?month=2026-05")
|
_, body := get(t, h, "/views/calendar?month=2026-05")
|
||||||
// May 4 2026 is a Monday → "Mo., 4. Mai".
|
// May 4 2026 is a Monday → "Mo., 4. Mai".
|
||||||
if !strings.Contains(body, `Mo., 4. Mai`) {
|
if !strings.Contains(body, `Mo., 4. Mai`) {
|
||||||
t.Errorf("expected long label 'Mo., 4. Mai' for 2026-05-04 cell, body did not include it")
|
t.Errorf("expected long label 'Mo., 4. Mai' for 2026-05-04 cell, body did not include it")
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ func TestFormatMonthLabel(t *testing.T) {
|
|||||||
// month + all-three.
|
// month + all-three.
|
||||||
func TestParseCalendarQueryDefaults(t *testing.T) {
|
func TestParseCalendarQueryDefaults(t *testing.T) {
|
||||||
now := time.Date(2026, 5, 14, 10, 0, 0, 0, time.UTC)
|
now := time.Date(2026, 5, 14, 10, 0, 0, 0, time.UTC)
|
||||||
r := httptest.NewRequest("GET", "/calendar", nil)
|
r := httptest.NewRequest("GET", "/views/calendar", nil)
|
||||||
q := parseCalendarQuery(r, now)
|
q := parseCalendarQuery(r, now)
|
||||||
if q.Month.Format("2006-01") != "2026-05" {
|
if q.Month.Format("2006-01") != "2026-05" {
|
||||||
t.Errorf("default month = %s, want 2026-05", q.Month.Format("2006-01"))
|
t.Errorf("default month = %s, want 2026-05", q.Month.Format("2006-01"))
|
||||||
@@ -223,7 +223,7 @@ func TestParseCalendarQueryDefaults(t *testing.T) {
|
|||||||
// nav writes to this exact key.
|
// nav writes to this exact key.
|
||||||
func TestParseCalendarQueryMonthParam(t *testing.T) {
|
func TestParseCalendarQueryMonthParam(t *testing.T) {
|
||||||
now := time.Date(2026, 5, 14, 10, 0, 0, 0, time.UTC)
|
now := time.Date(2026, 5, 14, 10, 0, 0, 0, time.UTC)
|
||||||
r := httptest.NewRequest("GET", "/calendar?month=2026-08", nil)
|
r := httptest.NewRequest("GET", "/views/calendar?month=2026-08", nil)
|
||||||
q := parseCalendarQuery(r, now)
|
q := parseCalendarQuery(r, now)
|
||||||
if q.Month.Format("2006-01") != "2026-08" {
|
if q.Month.Format("2006-01") != "2026-08" {
|
||||||
t.Errorf("parsed month = %s, want 2026-08", q.Month.Format("2006-01"))
|
t.Errorf("parsed month = %s, want 2026-08", q.Month.Format("2006-01"))
|
||||||
@@ -234,7 +234,7 @@ func TestParseCalendarQueryMonthParam(t *testing.T) {
|
|||||||
// kind set and drops unknown values.
|
// kind set and drops unknown values.
|
||||||
func TestParseCalendarQueryKindFilter(t *testing.T) {
|
func TestParseCalendarQueryKindFilter(t *testing.T) {
|
||||||
now := time.Date(2026, 5, 14, 10, 0, 0, 0, time.UTC)
|
now := time.Date(2026, 5, 14, 10, 0, 0, 0, time.UTC)
|
||||||
r := httptest.NewRequest("GET", "/calendar?kind=event,doc,junk,creation", nil)
|
r := httptest.NewRequest("GET", "/views/calendar?kind=event,doc,junk,creation", nil)
|
||||||
q := parseCalendarQuery(r, now)
|
q := parseCalendarQuery(r, now)
|
||||||
got := strings.Join(q.activeKinds(), ",")
|
got := strings.Join(q.activeKinds(), ",")
|
||||||
want := "doc,event" // sorted alphabetically; creation is excluded by design, junk dropped
|
want := "doc,event" // sorted alphabetically; creation is excluded by design, junk dropped
|
||||||
|
|||||||
@@ -233,7 +233,7 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
refreshQuery += "scope=" + scope
|
refreshQuery += "scope=" + scope
|
||||||
}
|
}
|
||||||
refreshURL := "/dashboard?"
|
refreshURL := "/views/dashboard?"
|
||||||
if refreshQuery != "" {
|
if refreshQuery != "" {
|
||||||
refreshURL += refreshQuery + "&"
|
refreshURL += refreshQuery + "&"
|
||||||
}
|
}
|
||||||
@@ -256,7 +256,7 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
|||||||
"RefreshURL": refreshURL,
|
"RefreshURL": refreshURL,
|
||||||
"FilterActive": filter.Active(),
|
"FilterActive": filter.Active(),
|
||||||
"Projects": projects,
|
"Projects": projects,
|
||||||
"BasePath": "/dashboard",
|
"BasePath": "/views/dashboard",
|
||||||
"ProjectChipTarget": "#dashboard-section",
|
"ProjectChipTarget": "#dashboard-section",
|
||||||
}
|
}
|
||||||
if r.Header.Get("HX-Request") == "true" {
|
if r.Header.Get("HX-Request") == "true" {
|
||||||
@@ -304,9 +304,9 @@ func dashboardScopeToggleURL(view, scope, filterKey string) string {
|
|||||||
parts = append(parts, "scope="+next)
|
parts = append(parts, "scope="+next)
|
||||||
}
|
}
|
||||||
if len(parts) == 0 {
|
if len(parts) == 0 {
|
||||||
return "/dashboard"
|
return "/views/dashboard"
|
||||||
}
|
}
|
||||||
return "/dashboard?" + strings.Join(parts, "&")
|
return "/views/dashboard?" + strings.Join(parts, "&")
|
||||||
}
|
}
|
||||||
|
|
||||||
// dashboardTab is a single entry in the view-switcher strip.
|
// dashboardTab is a single entry in the view-switcher strip.
|
||||||
@@ -322,7 +322,7 @@ type dashboardTab struct {
|
|||||||
// scope (current) elide from the URL so the address bar stays clean
|
// scope (current) elide from the URL so the address bar stays clean
|
||||||
// on the daily-driver path.
|
// on the daily-driver path.
|
||||||
func dashboardTabs(active, filterKey, scope string) []dashboardTab {
|
func dashboardTabs(active, filterKey, scope string) []dashboardTab {
|
||||||
prefix := "/dashboard"
|
prefix := "/views/dashboard"
|
||||||
filterQuery := ""
|
filterQuery := ""
|
||||||
if filterKey != "__empty__" && filterKey != "" {
|
if filterKey != "__empty__" && filterKey != "" {
|
||||||
filterQuery = filterKey
|
filterQuery = filterKey
|
||||||
|
|||||||
@@ -70,9 +70,9 @@ END:VCALENDAR`
|
|||||||
|
|
||||||
h := srv.Routes()
|
h := srv.Routes()
|
||||||
// Inline VTODO writeback rows live on the Tasks tab (Phase 5h).
|
// Inline VTODO writeback rows live on the Tasks tab (Phase 5h).
|
||||||
code, body := get(t, h, "/dashboard?view=tasks")
|
code, body := get(t, h, "/views/dashboard?view=tasks")
|
||||||
if code != 200 {
|
if code != 200 {
|
||||||
t.Fatalf("GET /dashboard?view=tasks → %d", code)
|
t.Fatalf("GET /views/dashboard?view=tasks → %d", code)
|
||||||
}
|
}
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
`Edit me please`,
|
`Edit me please`,
|
||||||
|
|||||||
@@ -70,9 +70,9 @@ func TestDashboardEventsCardSurfacesUpcoming(t *testing.T) {
|
|||||||
|
|
||||||
h := srv.Routes()
|
h := srv.Routes()
|
||||||
// The card-events markup lives on the Tasks tab (Phase 5h).
|
// The card-events markup lives on the Tasks tab (Phase 5h).
|
||||||
code, body := get(t, h, "/dashboard?view=tasks")
|
code, body := get(t, h, "/views/dashboard?view=tasks")
|
||||||
if code != 200 {
|
if code != 200 {
|
||||||
t.Fatalf("GET /dashboard?view=tasks → %d", code)
|
t.Fatalf("GET /views/dashboard?view=tasks → %d", code)
|
||||||
}
|
}
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
`card-events`,
|
`card-events`,
|
||||||
@@ -106,7 +106,7 @@ func TestDashboardEventsCardCollapsesWhenEmpty(t *testing.T) {
|
|||||||
srv.CalDAV = &web.CalDAVDeps{Client: caldav.New(fake.URL+"/", "u", "p")}
|
srv.CalDAV = &web.CalDAVDeps{Client: caldav.New(fake.URL+"/", "u", "p")}
|
||||||
|
|
||||||
h := srv.Routes()
|
h := srv.Routes()
|
||||||
_, body := get(t, h, "/dashboard?view=tasks")
|
_, body := get(t, h, "/views/dashboard?view=tasks")
|
||||||
if !strings.Contains(body, "No upcoming events") {
|
if !strings.Contains(body, "No upcoming events") {
|
||||||
t.Errorf("expected collapsed Events card with 'No upcoming events' note")
|
t.Errorf("expected collapsed Events card with 'No upcoming events' note")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ func TestDashboardPinTogglesItem(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The re-render should mark the tile as .tile-pinned.
|
// The re-render should mark the tile as .tile-pinned.
|
||||||
_, body := get(t, h, "/dashboard")
|
_, body := get(t, h, "/views/dashboard")
|
||||||
tileIdx := strings.Index(body, `data-item-id="`+id+`"`)
|
tileIdx := strings.Index(body, `data-item-id="`+id+`"`)
|
||||||
if tileIdx < 0 {
|
if tileIdx < 0 {
|
||||||
t.Fatalf("pinned tile not found in re-render")
|
t.Fatalf("pinned tile not found in re-render")
|
||||||
@@ -141,7 +141,7 @@ func TestDashboardPinInvalidatesCache(t *testing.T) {
|
|||||||
defer pool.Exec(context.Background(), `delete from projax.items where id=$1`, id)
|
defer pool.Exec(context.Background(), `delete from projax.items where id=$1`, id)
|
||||||
|
|
||||||
// Prime the cache — first GET caches an unpinned tile state.
|
// Prime the cache — first GET caches an unpinned tile state.
|
||||||
_, primed := get(t, h, "/dashboard")
|
_, primed := get(t, h, "/views/dashboard")
|
||||||
tileIdx := strings.Index(primed, `data-item-id="`+id+`"`)
|
tileIdx := strings.Index(primed, `data-item-id="`+id+`"`)
|
||||||
if tileIdx < 0 {
|
if tileIdx < 0 {
|
||||||
t.Fatalf("seeded tile missing from primed dashboard")
|
t.Fatalf("seeded tile missing from primed dashboard")
|
||||||
@@ -157,7 +157,7 @@ func TestDashboardPinInvalidatesCache(t *testing.T) {
|
|||||||
|
|
||||||
// Next GET must reflect the new pinned state — proves the cache
|
// Next GET must reflect the new pinned state — proves the cache
|
||||||
// entry for the previous (unpinned) state was invalidated.
|
// entry for the previous (unpinned) state was invalidated.
|
||||||
_, after := get(t, h, "/dashboard")
|
_, after := get(t, h, "/views/dashboard")
|
||||||
tileIdx2 := strings.Index(after, `data-item-id="`+id+`"`)
|
tileIdx2 := strings.Index(after, `data-item-id="`+id+`"`)
|
||||||
if tileIdx2 < 0 {
|
if tileIdx2 < 0 {
|
||||||
t.Fatalf("tile missing from post-pin dashboard")
|
t.Fatalf("tile missing from post-pin dashboard")
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ func TestDashboardRendersWithoutDeps(t *testing.T) {
|
|||||||
srv, pool := mustServer(t)
|
srv, pool := mustServer(t)
|
||||||
defer pool.Close()
|
defer pool.Close()
|
||||||
h := srv.Routes()
|
h := srv.Routes()
|
||||||
code, body := get(t, h, "/dashboard?view=tasks")
|
code, body := get(t, h, "/views/dashboard?view=tasks")
|
||||||
if code != 200 {
|
if code != 200 {
|
||||||
t.Fatalf("GET /dashboard?view=tasks → %d body=%s", code, body)
|
t.Fatalf("GET /dashboard?view=tasks → %d body=%s", code, body)
|
||||||
}
|
}
|
||||||
@@ -77,7 +77,7 @@ func TestDashboardRecentDocsSurfacesDatedLinks(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The Recent Documents card lives on the Tasks tab (Phase 5h).
|
// The Recent Documents card lives on the Tasks tab (Phase 5h).
|
||||||
code, body := get(t, h, "/dashboard?view=tasks")
|
code, body := get(t, h, "/views/dashboard?view=tasks")
|
||||||
if code != 200 {
|
if code != 200 {
|
||||||
t.Fatalf("GET /dashboard?view=tasks → %d", code)
|
t.Fatalf("GET /dashboard?view=tasks → %d", code)
|
||||||
}
|
}
|
||||||
@@ -134,7 +134,7 @@ func TestDashboardFilterByTagNarrowsCard(t *testing.T) {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
// Doc rows surface on the Tasks tab; the filter narrows both views.
|
// Doc rows surface on the Tasks tab; the filter narrows both views.
|
||||||
code, body := get(t, h, "/dashboard?tag=dev&view=tasks")
|
code, body := get(t, h, "/views/dashboard?tag=dev&view=tasks")
|
||||||
if code != 200 {
|
if code != 200 {
|
||||||
t.Fatalf("GET /dashboard?tag=dev&view=tasks → %d", code)
|
t.Fatalf("GET /dashboard?tag=dev&view=tasks → %d", code)
|
||||||
}
|
}
|
||||||
@@ -159,9 +159,9 @@ func TestDashboardRefreshBustsCache(t *testing.T) {
|
|||||||
h := srv.Routes()
|
h := srv.Routes()
|
||||||
|
|
||||||
// Prime the cache.
|
// Prime the cache.
|
||||||
_, _ = get(t, h, "/dashboard")
|
_, _ = get(t, h, "/views/dashboard")
|
||||||
// Second hit shows cached label.
|
// Second hit shows cached label.
|
||||||
_, cachedBody := get(t, h, "/dashboard")
|
_, cachedBody := get(t, h, "/views/dashboard")
|
||||||
if !strings.Contains(cachedBody, "cached") {
|
if !strings.Contains(cachedBody, "cached") {
|
||||||
n := len(cachedBody)
|
n := len(cachedBody)
|
||||||
if n > 600 {
|
if n > 600 {
|
||||||
@@ -170,7 +170,7 @@ func TestDashboardRefreshBustsCache(t *testing.T) {
|
|||||||
t.Fatalf("setup: second load should be cached, got body:\n%s", cachedBody[:n])
|
t.Fatalf("setup: second load should be cached, got body:\n%s", cachedBody[:n])
|
||||||
}
|
}
|
||||||
// Third hit with ?refresh=1 should be fresh again.
|
// Third hit with ?refresh=1 should be fresh again.
|
||||||
code, body := get(t, h, "/dashboard?refresh=1")
|
code, body := get(t, h, "/views/dashboard?refresh=1")
|
||||||
if code != 200 {
|
if code != 200 {
|
||||||
t.Fatalf("GET /dashboard?refresh=1 → %d", code)
|
t.Fatalf("GET /dashboard?refresh=1 → %d", code)
|
||||||
}
|
}
|
||||||
@@ -190,7 +190,7 @@ func TestDashboardCollapsesEmptyCardsWhenNoFilter(t *testing.T) {
|
|||||||
srv, pool := mustServer(t)
|
srv, pool := mustServer(t)
|
||||||
defer pool.Close()
|
defer pool.Close()
|
||||||
h := srv.Routes()
|
h := srv.Routes()
|
||||||
code, body := get(t, h, "/dashboard?view=tasks")
|
code, body := get(t, h, "/views/dashboard?view=tasks")
|
||||||
if code != 200 {
|
if code != 200 {
|
||||||
t.Fatalf("GET /dashboard?view=tasks → %d", code)
|
t.Fatalf("GET /dashboard?view=tasks → %d", code)
|
||||||
}
|
}
|
||||||
@@ -210,7 +210,7 @@ func TestDashboardFilterKeepsFullCardChrome(t *testing.T) {
|
|||||||
srv, pool := mustServer(t)
|
srv, pool := mustServer(t)
|
||||||
defer pool.Close()
|
defer pool.Close()
|
||||||
h := srv.Routes()
|
h := srv.Routes()
|
||||||
code, body := get(t, h, "/dashboard?tag=nothing-matches-zzz&view=tasks")
|
code, body := get(t, h, "/views/dashboard?tag=nothing-matches-zzz&view=tasks")
|
||||||
if code != 200 {
|
if code != 200 {
|
||||||
t.Fatalf("GET /dashboard?tag=… → %d", code)
|
t.Fatalf("GET /dashboard?tag=… → %d", code)
|
||||||
}
|
}
|
||||||
@@ -271,7 +271,7 @@ func TestDashboardStaleCardSurfacesDormantMaiProject(t *testing.T) {
|
|||||||
h := srv.Routes()
|
h := srv.Routes()
|
||||||
// Phase 5h: the Stale card retired. The stale project now appears
|
// Phase 5h: the Stale card retired. The stale project now appears
|
||||||
// inside the Tiles Quiet fold with a tile-stale flag on the tile.
|
// inside the Tiles Quiet fold with a tile-stale flag on the tile.
|
||||||
code, body := get(t, h, "/dashboard")
|
code, body := get(t, h, "/views/dashboard")
|
||||||
if code != 200 {
|
if code != 200 {
|
||||||
t.Fatalf("GET /dashboard → %d", code)
|
t.Fatalf("GET /dashboard → %d", code)
|
||||||
}
|
}
|
||||||
@@ -335,7 +335,7 @@ func TestDashboardStaleCardSkipsRecentRepo(t *testing.T) {
|
|||||||
// Phase 5h: assert the tile for this slug is NOT flagged stale.
|
// Phase 5h: assert the tile for this slug is NOT flagged stale.
|
||||||
// Recent repo activity (3d old) puts it solidly inside the activity
|
// Recent repo activity (3d old) puts it solidly inside the activity
|
||||||
// window AND fails the staleness probe, so no tile-stale class.
|
// window AND fails the staleness probe, so no tile-stale class.
|
||||||
_, body := get(t, h, "/dashboard")
|
_, body := get(t, h, "/views/dashboard")
|
||||||
// Find the tile for this slug and check its class attribute.
|
// Find the tile for this slug and check its class attribute.
|
||||||
marker := `data-item-path="dev.` + slug + `"`
|
marker := `data-item-path="dev.` + slug + `"`
|
||||||
idx := strings.Index(body, marker)
|
idx := strings.Index(body, marker)
|
||||||
@@ -362,8 +362,8 @@ func TestDashboardCacheHitOnSecondLoad(t *testing.T) {
|
|||||||
defer pool.Close()
|
defer pool.Close()
|
||||||
h := srv.Routes()
|
h := srv.Routes()
|
||||||
|
|
||||||
_, _ = get(t, h, "/dashboard")
|
_, _ = get(t, h, "/views/dashboard")
|
||||||
code, body := get(t, h, "/dashboard")
|
code, body := get(t, h, "/views/dashboard")
|
||||||
if code != 200 {
|
if code != 200 {
|
||||||
t.Fatalf("second GET /dashboard → %d", code)
|
t.Fatalf("second GET /dashboard → %d", code)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ func TestDashboardDefaultViewIsTiles(t *testing.T) {
|
|||||||
srv, pool := mustServer(t)
|
srv, pool := mustServer(t)
|
||||||
defer pool.Close()
|
defer pool.Close()
|
||||||
h := srv.Routes()
|
h := srv.Routes()
|
||||||
code, body := get(t, h, "/dashboard")
|
code, body := get(t, h, "/views/dashboard")
|
||||||
if code != 200 {
|
if code != 200 {
|
||||||
t.Fatalf("GET /dashboard → %d", code)
|
t.Fatalf("GET /dashboard → %d", code)
|
||||||
}
|
}
|
||||||
@@ -36,9 +36,9 @@ func TestDashboardTabsRenderAllThree(t *testing.T) {
|
|||||||
activeTab string
|
activeTab string
|
||||||
activeLabel string
|
activeLabel string
|
||||||
}{
|
}{
|
||||||
{"/dashboard", "tiles", "Tiles"},
|
{"/views/dashboard", "tiles", "Tiles"},
|
||||||
{"/dashboard?view=tasks", "tasks", "Tasks"},
|
{"/views/dashboard?view=tasks", "tasks", "Tasks"},
|
||||||
{"/dashboard?view=events", "events", "Events"},
|
{"/views/dashboard?view=events", "events", "Events"},
|
||||||
}
|
}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
t.Run(c.activeTab, func(t *testing.T) {
|
t.Run(c.activeTab, func(t *testing.T) {
|
||||||
@@ -80,7 +80,7 @@ func TestDashboardTasksViewFallback(t *testing.T) {
|
|||||||
srv, pool := mustServer(t)
|
srv, pool := mustServer(t)
|
||||||
defer pool.Close()
|
defer pool.Close()
|
||||||
h := srv.Routes()
|
h := srv.Routes()
|
||||||
_, body := get(t, h, "/dashboard?view=tasks")
|
_, body := get(t, h, "/views/dashboard?view=tasks")
|
||||||
if strings.Contains(body, `class="dash-tiles"`) {
|
if strings.Contains(body, `class="dash-tiles"`) {
|
||||||
t.Errorf("view=tasks should NOT render the Tiles grid")
|
t.Errorf("view=tasks should NOT render the Tiles grid")
|
||||||
}
|
}
|
||||||
@@ -99,7 +99,7 @@ func TestDashboardEventsViewRenders(t *testing.T) {
|
|||||||
srv, pool := mustServer(t)
|
srv, pool := mustServer(t)
|
||||||
defer pool.Close()
|
defer pool.Close()
|
||||||
h := srv.Routes()
|
h := srv.Routes()
|
||||||
_, body := get(t, h, "/dashboard?view=events")
|
_, body := get(t, h, "/views/dashboard?view=events")
|
||||||
if !strings.Contains(body, `class="dash-events-view"`) {
|
if !strings.Contains(body, `class="dash-events-view"`) {
|
||||||
t.Errorf("view=events should render the promoted Events surface")
|
t.Errorf("view=events should render the promoted Events surface")
|
||||||
}
|
}
|
||||||
@@ -120,7 +120,7 @@ func TestDashboardUnknownViewFallsBackToTiles(t *testing.T) {
|
|||||||
srv, pool := mustServer(t)
|
srv, pool := mustServer(t)
|
||||||
defer pool.Close()
|
defer pool.Close()
|
||||||
h := srv.Routes()
|
h := srv.Routes()
|
||||||
code, body := get(t, h, "/dashboard?view=gibberish")
|
code, body := get(t, h, "/views/dashboard?view=gibberish")
|
||||||
if code != 200 {
|
if code != 200 {
|
||||||
t.Fatalf("GET /dashboard?view=gibberish → %d", code)
|
t.Fatalf("GET /dashboard?view=gibberish → %d", code)
|
||||||
}
|
}
|
||||||
@@ -155,7 +155,7 @@ func TestDashboardTilesViewShowsRollupForSeededItem(t *testing.T) {
|
|||||||
}
|
}
|
||||||
defer pool.Exec(context.Background(), `delete from projax.items where id=$1`, id)
|
defer pool.Exec(context.Background(), `delete from projax.items where id=$1`, id)
|
||||||
|
|
||||||
code, body := get(t, h, "/dashboard")
|
code, body := get(t, h, "/views/dashboard")
|
||||||
if code != 200 {
|
if code != 200 {
|
||||||
t.Fatalf("GET /dashboard → %d", code)
|
t.Fatalf("GET /dashboard → %d", code)
|
||||||
}
|
}
|
||||||
@@ -172,19 +172,19 @@ func TestDashboardTilesViewShowsRollupForSeededItem(t *testing.T) {
|
|||||||
// TestDashboardCacheKeySeparatesViews ensures the cache layer keys by
|
// TestDashboardCacheKeySeparatesViews ensures the cache layer keys by
|
||||||
// (filter, view): the same filter under different views must hit
|
// (filter, view): the same filter under different views must hit
|
||||||
// independent cache entries. We prove this by priming /dashboard, then
|
// independent cache entries. We prove this by priming /dashboard, then
|
||||||
// /dashboard?view=tasks, and asserting both report "fresh" on their
|
// /views/dashboard?view=tasks, and asserting both report "fresh" on their
|
||||||
// first call (i.e. they don't share a cache slot).
|
// first call (i.e. they don't share a cache slot).
|
||||||
func TestDashboardCacheKeySeparatesViews(t *testing.T) {
|
func TestDashboardCacheKeySeparatesViews(t *testing.T) {
|
||||||
srv, pool := mustServer(t)
|
srv, pool := mustServer(t)
|
||||||
defer pool.Close()
|
defer pool.Close()
|
||||||
h := srv.Routes()
|
h := srv.Routes()
|
||||||
_, body1 := get(t, h, "/dashboard")
|
_, body1 := get(t, h, "/views/dashboard")
|
||||||
if !strings.Contains(body1, "fresh") {
|
if !strings.Contains(body1, "fresh") {
|
||||||
t.Fatalf("first /dashboard load should be fresh")
|
t.Fatalf("first /dashboard load should be fresh")
|
||||||
}
|
}
|
||||||
_, body2 := get(t, h, "/dashboard?view=tasks")
|
_, body2 := get(t, h, "/views/dashboard?view=tasks")
|
||||||
if !strings.Contains(body2, "fresh") {
|
if !strings.Contains(body2, "fresh") {
|
||||||
t.Errorf("first /dashboard?view=tasks load should be fresh — sharing a cache slot with Tiles would mark it cached")
|
t.Errorf("first /views/dashboard?view=tasks load should be fresh — sharing a cache slot with Tiles would mark it cached")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,18 +195,18 @@ func TestDashboardScopeChipRendersOnTilesOnly(t *testing.T) {
|
|||||||
srv, pool := mustServer(t)
|
srv, pool := mustServer(t)
|
||||||
defer pool.Close()
|
defer pool.Close()
|
||||||
h := srv.Routes()
|
h := srv.Routes()
|
||||||
_, tiles := get(t, h, "/dashboard")
|
_, tiles := get(t, h, "/views/dashboard")
|
||||||
if !strings.Contains(tiles, `class="dash-scope-chip"`) {
|
if !strings.Contains(tiles, `class="dash-scope-chip"`) {
|
||||||
t.Errorf("Tiles view should render the scope chip")
|
t.Errorf("Tiles view should render the scope chip")
|
||||||
}
|
}
|
||||||
if !strings.Contains(tiles, "◇ current") {
|
if !strings.Contains(tiles, "◇ current") {
|
||||||
t.Errorf("default scope chip should show '◇ current'")
|
t.Errorf("default scope chip should show '◇ current'")
|
||||||
}
|
}
|
||||||
_, tasks := get(t, h, "/dashboard?view=tasks")
|
_, tasks := get(t, h, "/views/dashboard?view=tasks")
|
||||||
if strings.Contains(tasks, `class="dash-scope-chip"`) {
|
if strings.Contains(tasks, `class="dash-scope-chip"`) {
|
||||||
t.Errorf("Tasks view should NOT render the scope chip")
|
t.Errorf("Tasks view should NOT render the scope chip")
|
||||||
}
|
}
|
||||||
_, events := get(t, h, "/dashboard?view=events")
|
_, events := get(t, h, "/views/dashboard?view=events")
|
||||||
if strings.Contains(events, `class="dash-scope-chip"`) {
|
if strings.Contains(events, `class="dash-scope-chip"`) {
|
||||||
t.Errorf("Events view should NOT render the scope chip")
|
t.Errorf("Events view should NOT render the scope chip")
|
||||||
}
|
}
|
||||||
@@ -218,7 +218,7 @@ func TestDashboardScopeAllChipFlipsLabel(t *testing.T) {
|
|||||||
srv, pool := mustServer(t)
|
srv, pool := mustServer(t)
|
||||||
defer pool.Close()
|
defer pool.Close()
|
||||||
h := srv.Routes()
|
h := srv.Routes()
|
||||||
_, body := get(t, h, "/dashboard?scope=all")
|
_, body := get(t, h, "/views/dashboard?scope=all")
|
||||||
if !strings.Contains(body, "○ all") {
|
if !strings.Contains(body, "○ all") {
|
||||||
t.Errorf("scope=all should render '○ all' chip label")
|
t.Errorf("scope=all should render '○ all' chip label")
|
||||||
}
|
}
|
||||||
@@ -234,7 +234,7 @@ func TestDashboardScopeAllHidesQuietFold(t *testing.T) {
|
|||||||
srv, pool := mustServer(t)
|
srv, pool := mustServer(t)
|
||||||
defer pool.Close()
|
defer pool.Close()
|
||||||
h := srv.Routes()
|
h := srv.Routes()
|
||||||
_, body := get(t, h, "/dashboard?scope=all")
|
_, body := get(t, h, "/views/dashboard?scope=all")
|
||||||
if strings.Contains(body, `class="dash-quiet"`) {
|
if strings.Contains(body, `class="dash-quiet"`) {
|
||||||
t.Errorf("scope=all should NOT render the Quiet fold — everything is in the primary grid")
|
t.Errorf("scope=all should NOT render the Quiet fold — everything is in the primary grid")
|
||||||
}
|
}
|
||||||
@@ -246,12 +246,12 @@ func TestDashboardScopeChipURLFlips(t *testing.T) {
|
|||||||
srv, pool := mustServer(t)
|
srv, pool := mustServer(t)
|
||||||
defer pool.Close()
|
defer pool.Close()
|
||||||
h := srv.Routes()
|
h := srv.Routes()
|
||||||
_, defaultBody := get(t, h, "/dashboard")
|
_, defaultBody := get(t, h, "/views/dashboard")
|
||||||
if !strings.Contains(defaultBody, `href="/dashboard?scope=all"`) {
|
if !strings.Contains(defaultBody, `href="/views/dashboard?scope=all"`) {
|
||||||
t.Errorf("default scope chip should link to ?scope=all")
|
t.Errorf("default scope chip should link to ?scope=all")
|
||||||
}
|
}
|
||||||
_, allBody := get(t, h, "/dashboard?scope=all")
|
_, allBody := get(t, h, "/views/dashboard?scope=all")
|
||||||
if !strings.Contains(allBody, `href="/dashboard"`) {
|
if !strings.Contains(allBody, `href="/views/dashboard"`) {
|
||||||
t.Errorf("scope=all chip should link back to /dashboard (scope=current is default+elided)")
|
t.Errorf("scope=all chip should link back to /dashboard (scope=current is default+elided)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ func TestGraphPageRenders(t *testing.T) {
|
|||||||
srv, pool := mustServer(t)
|
srv, pool := mustServer(t)
|
||||||
defer pool.Close()
|
defer pool.Close()
|
||||||
h := srv.Routes()
|
h := srv.Routes()
|
||||||
code, body := get(t, h, "/graph")
|
code, body := get(t, h, "/views/graph")
|
||||||
if code != 200 {
|
if code != 200 {
|
||||||
t.Fatalf("GET /graph → %d body=%s", code, body)
|
t.Fatalf("GET /graph → %d body=%s", code, body)
|
||||||
}
|
}
|
||||||
@@ -42,7 +42,7 @@ func TestGraphFilterDimsNonMatching(t *testing.T) {
|
|||||||
h := srv.Routes()
|
h := srv.Routes()
|
||||||
|
|
||||||
// Use a definitely-unused tag to force every node to mismatch.
|
// Use a definitely-unused tag to force every node to mismatch.
|
||||||
code, body := get(t, h, "/graph?tag=ZZZZ-unused-tag")
|
code, body := get(t, h, "/views/graph?tag=ZZZZ-unused-tag")
|
||||||
if code != 200 {
|
if code != 200 {
|
||||||
t.Fatalf("GET /graph?tag=ZZZ → %d", code)
|
t.Fatalf("GET /graph?tag=ZZZ → %d", code)
|
||||||
}
|
}
|
||||||
@@ -83,7 +83,7 @@ func TestGraphIsolateHidesNonMatching(t *testing.T) {
|
|||||||
}
|
}
|
||||||
defer pool.Exec(context.Background(), `delete from projax.items where id=$1`, id)
|
defer pool.Exec(context.Background(), `delete from projax.items where id=$1`, id)
|
||||||
|
|
||||||
code, body := get(t, h, "/graph?tag="+tag+"&isolate=1")
|
code, body := get(t, h, "/views/graph?tag="+tag+"&isolate=1")
|
||||||
if code != 200 {
|
if code != 200 {
|
||||||
t.Fatalf("GET /graph?isolate → %d", code)
|
t.Fatalf("GET /graph?isolate → %d", code)
|
||||||
}
|
}
|
||||||
@@ -102,7 +102,7 @@ func TestGraphSVGDownload(t *testing.T) {
|
|||||||
srv, pool := mustServer(t)
|
srv, pool := mustServer(t)
|
||||||
defer pool.Close()
|
defer pool.Close()
|
||||||
h := srv.Routes()
|
h := srv.Routes()
|
||||||
req := httptest.NewRequest(http.MethodGet, "/graph?download=svg", nil)
|
req := httptest.NewRequest(http.MethodGet, "/views/graph?download=svg", nil)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
h.ServeHTTP(w, req)
|
h.ServeHTTP(w, req)
|
||||||
if w.Result().StatusCode != 200 {
|
if w.Result().StatusCode != 200 {
|
||||||
|
|||||||
43
web/icons.go
Normal file
43
web/icons.go
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import "html/template"
|
||||||
|
|
||||||
|
// Phase 5j slice G — icon registry per m's Q6 pick (2026-05-29). The
|
||||||
|
// curated set of keys mirrors the editor's <select> options so the round-
|
||||||
|
// trip works: editor save persists the key string, layout renders the SVG
|
||||||
|
// at look-up time. Unknown / empty keys fall back to the default folder
|
||||||
|
// glyph.
|
||||||
|
//
|
||||||
|
// Stored as html/template.HTML so layout.tmpl can emit the markup
|
||||||
|
// directly without html-escaping the angle brackets. Each SVG is sized
|
||||||
|
// to 18px square and inherits currentColor like the existing nav-icon
|
||||||
|
// glyphs.
|
||||||
|
|
||||||
|
var iconRegistry = map[string]template.HTML{
|
||||||
|
"folder": template.HTML(`<svg class="nav-icon user-view-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>`),
|
||||||
|
"clock": template.HTML(`<svg class="nav-icon user-view-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>`),
|
||||||
|
"star": template.HTML(`<svg class="nav-icon user-view-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>`),
|
||||||
|
"tag": template.HTML(`<svg class="nav-icon user-view-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20.59 13.41 13.42 20.58a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>`),
|
||||||
|
"inbox": template.HTML(`<svg class="nav-icon user-view-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg>`),
|
||||||
|
"box": template.HTML(`<svg class="nav-icon user-view-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>`),
|
||||||
|
"file-text": template.HTML(`<svg class="nav-icon user-view-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>`),
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderViewIcon returns the SVG for an icon key, falling back to the
|
||||||
|
// folder default for nil or unknown keys. Template-callable so
|
||||||
|
// layout.tmpl can emit `{{renderIcon .Icon}}`.
|
||||||
|
func RenderViewIcon(icon *string) template.HTML {
|
||||||
|
key := "folder"
|
||||||
|
if icon != nil && *icon != "" {
|
||||||
|
if _, ok := iconRegistry[*icon]; ok {
|
||||||
|
key = *icon
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return iconRegistry[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
// IconRegistryKeys returns the available icon keys in display order, for
|
||||||
|
// the editor's <select>. The first key (folder) is the default.
|
||||||
|
func IconRegistryKeys() []string {
|
||||||
|
return []string{"folder", "clock", "star", "tag", "inbox", "box", "file-text"}
|
||||||
|
}
|
||||||
@@ -13,18 +13,18 @@ func TestLayoutSidebarOnDesktop(t *testing.T) {
|
|||||||
srv, pool := mustServer(t)
|
srv, pool := mustServer(t)
|
||||||
defer pool.Close()
|
defer pool.Close()
|
||||||
h := srv.Routes()
|
h := srv.Routes()
|
||||||
_, body := get(t, h, "/dashboard")
|
_, body := get(t, h, "/views/dashboard")
|
||||||
if !strings.Contains(body, `<aside class="projax-sidebar"`) {
|
if !strings.Contains(body, `<aside class="projax-sidebar"`) {
|
||||||
t.Fatalf("expected <aside class=\"projax-sidebar\"> in body, got: %s", truncate(body, 400))
|
t.Fatalf("expected <aside class=\"projax-sidebar\"> in body, got: %s", truncate(body, 400))
|
||||||
}
|
}
|
||||||
for _, want := range []struct {
|
for _, want := range []struct {
|
||||||
href, label string
|
href, label string
|
||||||
}{
|
}{
|
||||||
{`/`, "Tree"},
|
{`/views/tree`, "Tree"},
|
||||||
{`/dashboard`, "Dashboard"},
|
{`/views/dashboard`, "Dashboard"},
|
||||||
{`/calendar`, "Calendar"},
|
{`/views/calendar`, "Calendar"},
|
||||||
{`/timeline`, "Timeline"},
|
{`/views/timeline`, "Timeline"},
|
||||||
{`/graph`, "Graph"},
|
{`/views/graph`, "Graph"},
|
||||||
{`/admin`, "Admin"},
|
{`/admin`, "Admin"},
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(body, `href="`+want.href+`"`) {
|
if !strings.Contains(body, `href="`+want.href+`"`) {
|
||||||
@@ -43,12 +43,12 @@ func TestLayoutActiveClass(t *testing.T) {
|
|||||||
srv, pool := mustServer(t)
|
srv, pool := mustServer(t)
|
||||||
defer pool.Close()
|
defer pool.Close()
|
||||||
h := srv.Routes()
|
h := srv.Routes()
|
||||||
_, body := get(t, h, "/dashboard")
|
_, body := get(t, h, "/views/dashboard")
|
||||||
// Dashboard item should be active.
|
// Dashboard item should be active.
|
||||||
if !strings.Contains(body, `class="nav-item active" title="Dashboard"`) {
|
if !strings.Contains(body, `class="nav-item active" title="Dashboard"`) {
|
||||||
t.Errorf("expected Dashboard nav-item to carry .active on /dashboard, body: %s", truncate(body, 400))
|
t.Errorf("expected Dashboard nav-item to carry .active on /dashboard, body: %s", truncate(body, 400))
|
||||||
}
|
}
|
||||||
// Tree item (href="/") must NOT be active on the /dashboard page.
|
// Tree item (href="/views/tree") must NOT be active on the /dashboard page.
|
||||||
// The Tree anchor opens with the exact-path active match; on /dashboard
|
// The Tree anchor opens with the exact-path active match; on /dashboard
|
||||||
// the substring `class="nav-item" title="Tree"` should be present and
|
// the substring `class="nav-item" title="Tree"` should be present and
|
||||||
// not its `active` sibling.
|
// not its `active` sibling.
|
||||||
@@ -68,7 +68,7 @@ func TestLayoutCollapseScript(t *testing.T) {
|
|||||||
srv, pool := mustServer(t)
|
srv, pool := mustServer(t)
|
||||||
defer pool.Close()
|
defer pool.Close()
|
||||||
h := srv.Routes()
|
h := srv.Routes()
|
||||||
_, body := get(t, h, "/dashboard")
|
_, body := get(t, h, "/views/dashboard")
|
||||||
// Pre-paint restore script.
|
// Pre-paint restore script.
|
||||||
if !strings.Contains(body, `localStorage.getItem('projax.sidebar.collapsed')`) {
|
if !strings.Contains(body, `localStorage.getItem('projax.sidebar.collapsed')`) {
|
||||||
t.Errorf("expected pre-paint localStorage restore script in layout")
|
t.Errorf("expected pre-paint localStorage restore script in layout")
|
||||||
@@ -93,7 +93,7 @@ func TestLayoutNoTopHeader(t *testing.T) {
|
|||||||
srv, pool := mustServer(t)
|
srv, pool := mustServer(t)
|
||||||
defer pool.Close()
|
defer pool.Close()
|
||||||
h := srv.Routes()
|
h := srv.Routes()
|
||||||
_, body := get(t, h, "/dashboard")
|
_, body := get(t, h, "/views/dashboard")
|
||||||
// Slice out the region between <body> and <main> — that's where the
|
// Slice out the region between <body> and <main> — that's where the
|
||||||
// pre-5g top header lived. Inside <main> belongs to content templates.
|
// pre-5g top header lived. Inside <main> belongs to content templates.
|
||||||
chrome := body
|
chrome := body
|
||||||
@@ -116,17 +116,17 @@ func TestLayoutBottomNavMarkup(t *testing.T) {
|
|||||||
srv, pool := mustServer(t)
|
srv, pool := mustServer(t)
|
||||||
defer pool.Close()
|
defer pool.Close()
|
||||||
h := srv.Routes()
|
h := srv.Routes()
|
||||||
_, body := get(t, h, "/dashboard")
|
_, body := get(t, h, "/views/dashboard")
|
||||||
if !strings.Contains(body, `<nav class="projax-bottom-nav"`) {
|
if !strings.Contains(body, `<nav class="projax-bottom-nav"`) {
|
||||||
t.Fatalf("expected <nav class=\"projax-bottom-nav\"> in body, got: %s", truncate(body, 400))
|
t.Fatalf("expected <nav class=\"projax-bottom-nav\"> in body, got: %s", truncate(body, 400))
|
||||||
}
|
}
|
||||||
// 5-slot anchors / details element.
|
// 5-slot anchors / details element.
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
`<a href="/" class="bottom-nav-item`,
|
`<a href="/views/tree" class="bottom-nav-item`,
|
||||||
`<a href="/dashboard" class="bottom-nav-item`,
|
`<a href="/views/dashboard" class="bottom-nav-item`,
|
||||||
`<a href="/new" class="bottom-nav-item capture-btn"`,
|
`<a href="/new" class="bottom-nav-item capture-btn"`,
|
||||||
`class="capture-circle"`,
|
`class="capture-circle"`,
|
||||||
`<a href="/calendar" class="bottom-nav-item`,
|
`<a href="/views/calendar" class="bottom-nav-item`,
|
||||||
`<details class="projax-mobile-drawer"`,
|
`<details class="projax-mobile-drawer"`,
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(body, want) {
|
if !strings.Contains(body, want) {
|
||||||
@@ -135,8 +135,8 @@ func TestLayoutBottomNavMarkup(t *testing.T) {
|
|||||||
}
|
}
|
||||||
// Drawer overflow items: Timeline, Graph, Admin, theme toggle, sign-out.
|
// Drawer overflow items: Timeline, Graph, Admin, theme toggle, sign-out.
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
`<a href="/timeline" class="drawer-item`,
|
`<a href="/views/timeline" class="drawer-item`,
|
||||||
`<a href="/graph" class="drawer-item`,
|
`<a href="/views/graph" class="drawer-item`,
|
||||||
`<a href="/admin" class="drawer-item`,
|
`<a href="/admin" class="drawer-item`,
|
||||||
`id="theme-toggle-drawer"`,
|
`id="theme-toggle-drawer"`,
|
||||||
`<form method="post" action="/logout" class="drawer-form">`,
|
`<form method="post" action="/logout" class="drawer-form">`,
|
||||||
@@ -154,11 +154,11 @@ func TestLayoutBottomNavActiveClass(t *testing.T) {
|
|||||||
srv, pool := mustServer(t)
|
srv, pool := mustServer(t)
|
||||||
defer pool.Close()
|
defer pool.Close()
|
||||||
h := srv.Routes()
|
h := srv.Routes()
|
||||||
_, body := get(t, h, "/calendar")
|
_, body := get(t, h, "/views/calendar")
|
||||||
if !strings.Contains(body, `<a href="/calendar" class="bottom-nav-item active"`) {
|
if !strings.Contains(body, `<a href="/views/calendar" class="bottom-nav-item active"`) {
|
||||||
t.Errorf("expected Calendar bottom-nav-item to carry .active on /calendar")
|
t.Errorf("expected Calendar bottom-nav-item to carry .active on /calendar")
|
||||||
}
|
}
|
||||||
if strings.Contains(body, `<a href="/" class="bottom-nav-item active"`) {
|
if strings.Contains(body, `<a href="/views/tree" class="bottom-nav-item active"`) {
|
||||||
t.Errorf("Tree bottom-nav-item should NOT be active on /calendar")
|
t.Errorf("Tree bottom-nav-item should NOT be active on /calendar")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -171,7 +171,7 @@ func TestLayoutThemeToggleBoundToBothButtons(t *testing.T) {
|
|||||||
srv, pool := mustServer(t)
|
srv, pool := mustServer(t)
|
||||||
defer pool.Close()
|
defer pool.Close()
|
||||||
h := srv.Routes()
|
h := srv.Routes()
|
||||||
_, body := get(t, h, "/dashboard")
|
_, body := get(t, h, "/views/dashboard")
|
||||||
// Both buttons present.
|
// Both buttons present.
|
||||||
if !strings.Contains(body, `id="theme-toggle"`) {
|
if !strings.Contains(body, `id="theme-toggle"`) {
|
||||||
t.Errorf("sidebar theme-toggle button missing")
|
t.Errorf("sidebar theme-toggle button missing")
|
||||||
|
|||||||
@@ -245,14 +245,14 @@ func TestTreeFilterPublicNarrows(t *testing.T) {
|
|||||||
// filter.
|
// filter.
|
||||||
pubLink := `href="/i/dev.` + pubSlug + `"`
|
pubLink := `href="/i/dev.` + pubSlug + `"`
|
||||||
prvLink := `href="/i/dev.` + prvSlug + `"`
|
prvLink := `href="/i/dev.` + prvSlug + `"`
|
||||||
_, yesBody := get(t, h, "/?public=1")
|
_, yesBody := get(t, h, "/views/tree?public=1")
|
||||||
if !strings.Contains(yesBody, pubLink) {
|
if !strings.Contains(yesBody, pubLink) {
|
||||||
t.Errorf("?public=1 should show pub-filt-yes row")
|
t.Errorf("?public=1 should show pub-filt-yes row")
|
||||||
}
|
}
|
||||||
if strings.Contains(yesBody, prvLink) {
|
if strings.Contains(yesBody, prvLink) {
|
||||||
t.Errorf("?public=1 should hide pub-filt-no row")
|
t.Errorf("?public=1 should hide pub-filt-no row")
|
||||||
}
|
}
|
||||||
_, noBody := get(t, h, "/?public=0")
|
_, noBody := get(t, h, "/views/tree?public=0")
|
||||||
if strings.Contains(noBody, pubLink) {
|
if strings.Contains(noBody, pubLink) {
|
||||||
t.Errorf("?public=0 should hide pub-filt-yes row")
|
t.Errorf("?public=0 should hide pub-filt-yes row")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ func TestLayoutHasManifestAndAppleTouchIcon(t *testing.T) {
|
|||||||
srv, pool := mustServer(t)
|
srv, pool := mustServer(t)
|
||||||
defer pool.Close()
|
defer pool.Close()
|
||||||
h := srv.Routes()
|
h := srv.Routes()
|
||||||
_, body := get(t, h, "/dashboard")
|
_, body := get(t, h, "/views/dashboard")
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
`rel="manifest"`,
|
`rel="manifest"`,
|
||||||
`/static/manifest.webmanifest`,
|
`/static/manifest.webmanifest`,
|
||||||
|
|||||||
116
web/server.go
116
web/server.go
@@ -132,6 +132,10 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) {
|
|||||||
"addF": func(a, b any) float64 { return toFloat(a) + toFloat(b) },
|
"addF": func(a, b any) float64 { return toFloat(a) + toFloat(b) },
|
||||||
"subF": func(a, b any) float64 { return toFloat(a) - toFloat(b) },
|
"subF": func(a, b any) float64 { return toFloat(a) - toFloat(b) },
|
||||||
"mulF": func(a, b any) float64 { return toFloat(a) * toFloat(b) },
|
"mulF": func(a, b any) float64 { return toFloat(a) * toFloat(b) },
|
||||||
|
// Phase 5j slice G — sidebar icon registry. layout.tmpl calls
|
||||||
|
// `renderIcon .View.Icon` to emit the matching SVG, falling back to
|
||||||
|
// the folder default for nil / unknown keys.
|
||||||
|
"renderIcon": RenderViewIcon,
|
||||||
"tagToggleURL": func(active []string, tag string, isActive bool) string {
|
"tagToggleURL": func(active []string, tag string, isActive bool) string {
|
||||||
next := []string{}
|
next := []string{}
|
||||||
if isActive {
|
if isActive {
|
||||||
@@ -151,7 +155,7 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
pages := map[string]*template.Template{}
|
pages := map[string]*template.Template{}
|
||||||
for _, name := range []string{"new", "classify", "caldav_admin", "caldav_disabled", "error"} {
|
for _, name := range []string{"new", "classify", "caldav_admin", "caldav_disabled", "error", "views_landing", "view_editor"} {
|
||||||
t, err := template.New(name).Funcs(funcs).ParseFS(templatesFS,
|
t, err := template.New(name).Funcs(funcs).ParseFS(templatesFS,
|
||||||
"templates/layout.tmpl",
|
"templates/layout.tmpl",
|
||||||
"templates/"+name+".tmpl",
|
"templates/"+name+".tmpl",
|
||||||
@@ -188,6 +192,21 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) {
|
|||||||
return nil, fmt.Errorf("parse tree_section: %w", err)
|
return nil, fmt.Errorf("parse tree_section: %w", err)
|
||||||
}
|
}
|
||||||
pages["tree_section"] = treeSection
|
pages["tree_section"] = treeSection
|
||||||
|
// Phase 5j view-render template bundles the tree-section partials so a
|
||||||
|
// rendered view at /views/{slug} can use the same dispatch (list / card
|
||||||
|
// / kanban via .ViewType).
|
||||||
|
viewRender, err := template.New("view_render").Funcs(funcs).ParseFS(templatesFS,
|
||||||
|
"templates/layout.tmpl",
|
||||||
|
"templates/view_render.tmpl",
|
||||||
|
"templates/tree_section.tmpl",
|
||||||
|
"templates/tree_card.tmpl",
|
||||||
|
"templates/tree_kanban.tmpl",
|
||||||
|
"templates/project_chip.tmpl",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse view_render: %w", err)
|
||||||
|
}
|
||||||
|
pages["view_render"] = viewRender
|
||||||
// detail bundles the shared tasks-section + issues-section partials so
|
// detail bundles the shared tasks-section + issues-section partials so
|
||||||
// HTMX swaps and the initial page render hit the same template definitions.
|
// HTMX swaps and the initial page render hit the same template definitions.
|
||||||
detailTmpl, err := template.New("detail").Funcs(funcs).ParseFS(templatesFS,
|
detailTmpl, err := template.New("detail").Funcs(funcs).ParseFS(templatesFS,
|
||||||
@@ -361,17 +380,26 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) {
|
|||||||
func (s *Server) Routes() http.Handler {
|
func (s *Server) Routes() http.Handler {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
mux.HandleFunc("GET /", s.handleTree)
|
// Phase 5j slice C — full URL migration. The five legacy pages live at
|
||||||
|
// /views/{system-slug} now; the old top-level URLs 301-redirect to
|
||||||
|
// their new home (with the legacy ?view=<uuid> param resolved through
|
||||||
|
// the old uuid → new slug if it still maps to a row).
|
||||||
|
mux.HandleFunc("GET /views/tree", s.handleTree)
|
||||||
|
mux.HandleFunc("GET /views/dashboard", s.handleDashboard)
|
||||||
|
mux.HandleFunc("GET /views/timeline", s.handleTimeline)
|
||||||
|
mux.HandleFunc("GET /views/calendar", s.handleCalendar)
|
||||||
|
mux.HandleFunc("GET /views/graph", s.handleGraph)
|
||||||
|
mux.HandleFunc("GET /", s.legacyRedirect("tree"))
|
||||||
|
mux.HandleFunc("GET /dashboard", s.legacyRedirect("dashboard"))
|
||||||
|
mux.HandleFunc("GET /timeline", s.legacyRedirect("timeline"))
|
||||||
|
mux.HandleFunc("GET /calendar", s.legacyRedirect("calendar"))
|
||||||
|
mux.HandleFunc("GET /graph", s.legacyRedirect("graph"))
|
||||||
mux.HandleFunc("GET /i/", s.handleDetail)
|
mux.HandleFunc("GET /i/", s.handleDetail)
|
||||||
mux.HandleFunc("POST /i/", s.handleDetailWrite)
|
mux.HandleFunc("POST /i/", s.handleDetailWrite)
|
||||||
mux.HandleFunc("GET /new", s.handleNewForm)
|
mux.HandleFunc("GET /new", s.handleNewForm)
|
||||||
mux.HandleFunc("POST /new", s.handleNewSubmit)
|
mux.HandleFunc("POST /new", s.handleNewSubmit)
|
||||||
mux.HandleFunc("GET /admin", s.handleAdminIndex)
|
mux.HandleFunc("GET /admin", s.handleAdminIndex)
|
||||||
mux.HandleFunc("GET /admin/classify", s.handleClassify)
|
mux.HandleFunc("GET /admin/classify", s.handleClassify)
|
||||||
mux.HandleFunc("GET /dashboard", s.handleDashboard)
|
|
||||||
mux.HandleFunc("GET /timeline", s.handleTimeline)
|
|
||||||
mux.HandleFunc("GET /calendar", s.handleCalendar)
|
|
||||||
mux.HandleFunc("GET /graph", s.handleGraph)
|
|
||||||
mux.HandleFunc("POST /dashboard/task/done", s.handleDashboardTaskDone)
|
mux.HandleFunc("POST /dashboard/task/done", s.handleDashboardTaskDone)
|
||||||
mux.HandleFunc("POST /dashboard/task/edit", s.handleDashboardTaskEdit)
|
mux.HandleFunc("POST /dashboard/task/edit", s.handleDashboardTaskEdit)
|
||||||
mux.HandleFunc("POST /dashboard/task/delete", s.handleDashboardTaskDelete)
|
mux.HandleFunc("POST /dashboard/task/delete", s.handleDashboardTaskDelete)
|
||||||
@@ -382,9 +410,18 @@ func (s *Server) Routes() http.Handler {
|
|||||||
mux.HandleFunc("GET /admin/caldav", s.handleCalDAVAdmin)
|
mux.HandleFunc("GET /admin/caldav", s.handleCalDAVAdmin)
|
||||||
mux.HandleFunc("POST /admin/caldav/link", s.handleCalDAVLink)
|
mux.HandleFunc("POST /admin/caldav/link", s.handleCalDAVLink)
|
||||||
mux.HandleFunc("POST /admin/caldav/unlink", s.handleCalDAVUnlink)
|
mux.HandleFunc("POST /admin/caldav/unlink", s.handleCalDAVUnlink)
|
||||||
// /views routes land in slice B (paliad-shape: GET /views, GET
|
// Phase 5j paliad-shape views routes (slice B). /views = MRU landing
|
||||||
// /views/{slug}, GET /views/new, GET /views/{slug}/edit, plus POST CRUD).
|
// or onboarding shell; /views/{slug} = render the saved view as its
|
||||||
// Between slice A and slice B these URLs 404 by design.
|
// own page; /views/new + /views/{slug}/edit = editor. POST CRUD
|
||||||
|
// rounds out the family; reorder is wired now for slice G's drag UI.
|
||||||
|
mux.HandleFunc("GET /views", s.handleViewsLanding)
|
||||||
|
mux.HandleFunc("POST /views", s.handleViewCreate)
|
||||||
|
mux.HandleFunc("POST /views/reorder", s.handleViewReorder)
|
||||||
|
mux.HandleFunc("GET /views/new", s.handleViewEditor)
|
||||||
|
mux.HandleFunc("GET /views/{slug}", s.handleViewRender)
|
||||||
|
mux.HandleFunc("GET /views/{slug}/edit", s.handleViewEditor)
|
||||||
|
mux.HandleFunc("POST /views/{slug}", s.handleViewUpdate)
|
||||||
|
mux.HandleFunc("POST /views/{slug}/delete", s.handleViewDelete)
|
||||||
mux.HandleFunc("GET /login", s.handleLoginForm)
|
mux.HandleFunc("GET /login", s.handleLoginForm)
|
||||||
mux.HandleFunc("POST /login", s.handleLoginSubmit)
|
mux.HandleFunc("POST /login", s.handleLoginSubmit)
|
||||||
mux.HandleFunc("POST /logout", s.handleLogout)
|
mux.HandleFunc("POST /logout", s.handleLogout)
|
||||||
@@ -427,10 +464,9 @@ type treeNode struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.URL.Path != "/" {
|
// Phase 5j slice C: handleTree is reached at /views/tree (system view)
|
||||||
http.NotFound(w, r)
|
// only. The legacy / route 301-redirects via legacyRedirect — see
|
||||||
return
|
// Routes(). Any 404-on-unknown-path responsibility moved with it.
|
||||||
}
|
|
||||||
items, err := s.Store.ListAll(r.Context())
|
items, err := s.Store.ListAll(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.fail(w, r, err)
|
s.fail(w, r, err)
|
||||||
@@ -455,7 +491,10 @@ func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) {
|
|||||||
// handler (slice C). handleTree stays focused on the tree-as-tree
|
// handler (slice C). handleTree stays focused on the tree-as-tree
|
||||||
// surface and no longer hijacks itself based on a query param.
|
// surface and no longer hijacks itself based on a query param.
|
||||||
roots, orphans, total, orphanN, matched := applyTreeFilter(items, filter, linkKinds)
|
roots, orphans, total, orphanN, matched := applyTreeFilter(items, filter, linkKinds)
|
||||||
counts := computeChipCounts(items, filter, linkKinds, tags)
|
// Phase 5j slice C: tree lives at /views/tree now. Chip URLs need to
|
||||||
|
// anchor on the new base so chip clicks stay on this page.
|
||||||
|
const treeBase = "/views/tree"
|
||||||
|
counts := computeChipCounts(items, filter, linkKinds, tags, treeBase)
|
||||||
// Phase 5i Slice B: the card view renders a flat grid of matched items
|
// Phase 5i Slice B: the card view renders a flat grid of matched items
|
||||||
// (no tree structure). Build from items + filter directly rather than
|
// (no tree structure). Build from items + filter directly rather than
|
||||||
// reusing the post-prune `roots` (which still keeps ancestors).
|
// reusing the post-prune `roots` (which still keeps ancestors).
|
||||||
@@ -463,7 +502,7 @@ func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Phase 5i Slice C: kanban groups the same matched set into columns.
|
// Phase 5i Slice C: kanban groups the same matched set into columns.
|
||||||
groupBy := ParseGroupBy(r.URL.Query())
|
groupBy := ParseGroupBy(r.URL.Query())
|
||||||
kanban := BuildKanbanBoard(cardItems, groupBy)
|
kanban := BuildKanbanBoard(cardItems, groupBy)
|
||||||
groupByChips := GroupByChips("/", filter, groupBy)
|
groupByChips := GroupByChips(treeBase, filter, groupBy)
|
||||||
data := map[string]any{
|
data := map[string]any{
|
||||||
"Title": "tree",
|
"Title": "tree",
|
||||||
"Roots": roots,
|
"Roots": roots,
|
||||||
@@ -475,10 +514,10 @@ func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) {
|
|||||||
"Filter": filter,
|
"Filter": filter,
|
||||||
"Counts": counts,
|
"Counts": counts,
|
||||||
"Projects": parentOptionsFromItems(items),
|
"Projects": parentOptionsFromItems(items),
|
||||||
"BasePath": "/",
|
"BasePath": treeBase,
|
||||||
"ProjectChipTarget": "#tree-section",
|
"ProjectChipTarget": "#tree-section",
|
||||||
"ViewType": view,
|
"ViewType": view,
|
||||||
"ViewTypeChips": ViewTypeChips("/", filter, view),
|
"ViewTypeChips": ViewTypeChips(treeBase, filter, view),
|
||||||
"CardItems": cardItems,
|
"CardItems": cardItems,
|
||||||
"Kanban": kanban,
|
"Kanban": kanban,
|
||||||
"GroupBy": groupBy,
|
"GroupBy": groupBy,
|
||||||
@@ -973,6 +1012,49 @@ func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, dat
|
|||||||
if _, set := data["Path"]; !set {
|
if _, set := data["Path"]; !set {
|
||||||
data["Path"] = r.URL.Path
|
data["Path"] = r.URL.Path
|
||||||
}
|
}
|
||||||
|
// Phase 5j slice E: layout's "Views" sidebar section lists every
|
||||||
|
// user view. Lookup is one indexed query per render — at m's scale
|
||||||
|
// (≤30 saved views) the cost is negligible and dwarfed by the
|
||||||
|
// dashboard/timeline aggregation cards. The login page bypasses the
|
||||||
|
// layout entirely so we don't fetch for it; stub servers without a
|
||||||
|
// configured store also skip cleanly.
|
||||||
|
if _, set := data["UserViews"]; !set && name != "login" && s.Store != nil {
|
||||||
|
if uv, err := s.Store.ListViews(r.Context()); err == nil {
|
||||||
|
data["UserViews"] = uv
|
||||||
|
// Phase 5j slice G — show_count badges. For every view with
|
||||||
|
// ShowCount=true, run its persisted filter against ListAll and
|
||||||
|
// pass a slug→count map to the template. Caching is one
|
||||||
|
// ListAll per render shared across all show-count views.
|
||||||
|
counts := map[string]int{}
|
||||||
|
needsCount := false
|
||||||
|
for _, v := range uv {
|
||||||
|
if v.ShowCount {
|
||||||
|
needsCount = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if needsCount {
|
||||||
|
items, err := s.Store.ListAll(r.Context())
|
||||||
|
if err == nil {
|
||||||
|
linkKinds, _ := s.linkKindsByItem(r.Context())
|
||||||
|
for _, v := range uv {
|
||||||
|
if !v.ShowCount {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
f, _, _ := decodeViewSpec(v.FilterJSON)
|
||||||
|
n := 0
|
||||||
|
for _, it := range items {
|
||||||
|
if f.Matches(it, linkKinds[it.ID]) {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
counts[v.Slug] = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data["UserViewCounts"] = counts
|
||||||
|
}
|
||||||
|
}
|
||||||
entry := "layout"
|
entry := "layout"
|
||||||
switch name {
|
switch name {
|
||||||
case "login":
|
case "login":
|
||||||
|
|||||||
@@ -81,9 +81,9 @@ func TestTreeRenders(t *testing.T) {
|
|||||||
srv, pool := mustServer(t)
|
srv, pool := mustServer(t)
|
||||||
defer pool.Close()
|
defer pool.Close()
|
||||||
h := srv.Routes()
|
h := srv.Routes()
|
||||||
code, body := get(t, h, "/")
|
code, body := get(t, h, "/views/tree")
|
||||||
if code != 200 {
|
if code != 200 {
|
||||||
t.Fatalf("GET / status %d body=%s", code, body)
|
t.Fatalf("GET /views/tree status %d body=%s", code, body)
|
||||||
}
|
}
|
||||||
// /admin/classify used to live in the nav; Phase 3o consolidated all
|
// /admin/classify used to live in the nav; Phase 3o consolidated all
|
||||||
// admin links under the new /admin index. Assert /admin instead.
|
// admin links under the new /admin index. Assert /admin instead.
|
||||||
@@ -102,7 +102,7 @@ func TestLayoutHasViewportMeta(t *testing.T) {
|
|||||||
srv, pool := mustServer(t)
|
srv, pool := mustServer(t)
|
||||||
defer pool.Close()
|
defer pool.Close()
|
||||||
h := srv.Routes()
|
h := srv.Routes()
|
||||||
for _, path := range []string{"/", "/dashboard", "/calendar", "/graph", "/admin/bulk", "/admin/classify", "/new", "/login"} {
|
for _, path := range []string{"/views/tree", "/views/dashboard", "/views/calendar", "/views/graph", "/admin/bulk", "/admin/classify", "/new", "/login"} {
|
||||||
_, body := get(t, h, path)
|
_, body := get(t, h, path)
|
||||||
if !strings.Contains(body, `name="viewport"`) {
|
if !strings.Contains(body, `name="viewport"`) {
|
||||||
t.Errorf("GET %s: missing <meta name=\"viewport\">", path)
|
t.Errorf("GET %s: missing <meta name=\"viewport\">", path)
|
||||||
@@ -302,7 +302,7 @@ func TestTreeRendersKanbanWhenViewTypeIsKanban(t *testing.T) {
|
|||||||
srv, pool := mustServer(t)
|
srv, pool := mustServer(t)
|
||||||
defer pool.Close()
|
defer pool.Close()
|
||||||
h := srv.Routes()
|
h := srv.Routes()
|
||||||
_, body := get(t, h, "/?view_type=kanban")
|
_, body := get(t, h, "/views/tree?view_type=kanban")
|
||||||
if !strings.Contains(body, `class="kanban-board"`) {
|
if !strings.Contains(body, `class="kanban-board"`) {
|
||||||
t.Error("?view_type=kanban should render the kanban board")
|
t.Error("?view_type=kanban should render the kanban board")
|
||||||
}
|
}
|
||||||
@@ -324,7 +324,7 @@ func TestTreeRendersCardGridWhenViewTypeIsCard(t *testing.T) {
|
|||||||
defer pool.Close()
|
defer pool.Close()
|
||||||
h := srv.Routes()
|
h := srv.Routes()
|
||||||
// List view (default): forest markup expected; tree-card-grid absent.
|
// List view (default): forest markup expected; tree-card-grid absent.
|
||||||
_, listBody := get(t, h, "/")
|
_, listBody := get(t, h, "/views/tree")
|
||||||
if !strings.Contains(listBody, `<ul class="forest">`) {
|
if !strings.Contains(listBody, `<ul class="forest">`) {
|
||||||
t.Error("default GET / should render the tree forest")
|
t.Error("default GET / should render the tree forest")
|
||||||
}
|
}
|
||||||
@@ -335,7 +335,7 @@ func TestTreeRendersCardGridWhenViewTypeIsCard(t *testing.T) {
|
|||||||
t.Error("view-type chip strip should appear on every view")
|
t.Error("view-type chip strip should appear on every view")
|
||||||
}
|
}
|
||||||
// Card view: card grid present, forest absent.
|
// Card view: card grid present, forest absent.
|
||||||
_, cardBody := get(t, h, "/?view_type=card")
|
_, cardBody := get(t, h, "/views/tree?view_type=card")
|
||||||
if !strings.Contains(cardBody, `class="tree-card-grid"`) {
|
if !strings.Contains(cardBody, `class="tree-card-grid"`) {
|
||||||
t.Error("GET /?view_type=card should render the card grid")
|
t.Error("GET /?view_type=card should render the card grid")
|
||||||
}
|
}
|
||||||
@@ -343,7 +343,7 @@ func TestTreeRendersCardGridWhenViewTypeIsCard(t *testing.T) {
|
|||||||
t.Error("GET /?view_type=card should not render the tree forest")
|
t.Error("GET /?view_type=card should not render the tree forest")
|
||||||
}
|
}
|
||||||
// Unknown view_type falls back to list.
|
// Unknown view_type falls back to list.
|
||||||
_, unknownBody := get(t, h, "/?view_type=junk")
|
_, unknownBody := get(t, h, "/views/tree?view_type=junk")
|
||||||
if !strings.Contains(unknownBody, `<ul class="forest">`) {
|
if !strings.Contains(unknownBody, `<ul class="forest">`) {
|
||||||
t.Error("unknown view_type should fall back to list")
|
t.Error("unknown view_type should fall back to list")
|
||||||
}
|
}
|
||||||
@@ -393,7 +393,7 @@ func TestProjectFilterScopesTreeToDescendants(t *testing.T) {
|
|||||||
siblingLink := `href="/i/dev.` + siblingSlug + `"`
|
siblingLink := `href="/i/dev.` + siblingSlug + `"`
|
||||||
|
|
||||||
// Descendants on (default): parent + child visible, sibling hidden.
|
// Descendants on (default): parent + child visible, sibling hidden.
|
||||||
_, withDesc := get(t, h, "/?project="+parentPath)
|
_, withDesc := get(t, h, "/views/tree?project="+parentPath)
|
||||||
if !strings.Contains(withDesc, parentLink) {
|
if !strings.Contains(withDesc, parentLink) {
|
||||||
t.Errorf("?project=%s should show parent row", parentPath)
|
t.Errorf("?project=%s should show parent row", parentPath)
|
||||||
}
|
}
|
||||||
@@ -405,7 +405,7 @@ func TestProjectFilterScopesTreeToDescendants(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Descendants off: only the picked item, no children.
|
// Descendants off: only the picked item, no children.
|
||||||
_, noDesc := get(t, h, "/?project="+parentPath+"&project_descendants=0")
|
_, noDesc := get(t, h, "/views/tree?project="+parentPath+"&project_descendants=0")
|
||||||
if !strings.Contains(noDesc, parentLink) {
|
if !strings.Contains(noDesc, parentLink) {
|
||||||
t.Errorf("?project_descendants=0 should still show the picked parent row")
|
t.Errorf("?project_descendants=0 should still show the picked parent row")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1211,6 +1211,23 @@ html[data-sidebar-collapsed="true"] .projax-sidebar .brand-label {
|
|||||||
border-left: 2px solid var(--accent);
|
border-left: 2px solid var(--accent);
|
||||||
padding-left: 14px;
|
padding-left: 14px;
|
||||||
}
|
}
|
||||||
|
/* Phase 5j slice E — Views sub-section: user-view entries sit below the
|
||||||
|
main nav items, slightly indented + smaller, so the system rows stay
|
||||||
|
visually anchored. The Views section header (the "Views" main entry)
|
||||||
|
is unchanged; this just styles the per-saved-view rows. */
|
||||||
|
.projax-sidebar .sidebar-user-views { display: flex; flex-direction: column; gap: 2px; padding: 4px 0; }
|
||||||
|
.projax-sidebar .nav-item-user-view { font-size: 0.92em; padding-left: 24px; }
|
||||||
|
.projax-sidebar .nav-item-user-view.active { padding-left: 22px; }
|
||||||
|
.projax-sidebar .user-view-icon { width: 1em; text-align: center; }
|
||||||
|
.projax-sidebar .nav-item-new-view { color: var(--muted); }
|
||||||
|
.projax-sidebar .nav-badge {
|
||||||
|
margin-left: auto; font-size: 0.78em; color: var(--muted);
|
||||||
|
background: var(--surface); border: 1px solid var(--border);
|
||||||
|
border-radius: 10px; padding: 0 6px;
|
||||||
|
}
|
||||||
|
.projax-sidebar .nav-item-user-view.active .nav-badge {
|
||||||
|
color: var(--accent); border-color: var(--accent);
|
||||||
|
}
|
||||||
.projax-sidebar .nav-icon {
|
.projax-sidebar .nav-icon {
|
||||||
width: 18px;
|
width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
|
|||||||
87
web/system_views.go
Normal file
87
web/system_views.go
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Phase 5j Slice C — system views. Per m's Q1 pick (b) (2026-05-29):
|
||||||
|
// FULL MIGRATION of the legacy pages into the /views/{slug} family.
|
||||||
|
// /, /dashboard, /calendar, /timeline, /graph all 301-redirect to their
|
||||||
|
// /views/{system-slug} counterparts; the handlers stay (now reachable
|
||||||
|
// under the new URL).
|
||||||
|
//
|
||||||
|
// System views are code-resident — they never appear as rows in
|
||||||
|
// projax.views. Their slugs are reserved at the validator level (see
|
||||||
|
// store.IsReservedViewSlug) so user-created views can't shadow them.
|
||||||
|
|
||||||
|
// SystemView is a code-resident view definition. The sidebar's Views
|
||||||
|
// section (slice E) lists every entry returned by AllSystemViews
|
||||||
|
// alongside user views. The render path for system slugs goes directly
|
||||||
|
// to the legacy handler (handleTree / handleDashboard / …); the struct
|
||||||
|
// here is metadata for navigation, not a render spec.
|
||||||
|
type SystemView struct {
|
||||||
|
Slug string
|
||||||
|
Name string
|
||||||
|
Icon string
|
||||||
|
URL string // /views/{slug}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllSystemViews returns every code-resident view in display order. Used
|
||||||
|
// by the sidebar (slice E) and the reserved-slug validation (slice A
|
||||||
|
// already pre-seeded the same slugs in store.IsReservedViewSlug — keep
|
||||||
|
// in sync with this list).
|
||||||
|
func AllSystemViews() []SystemView {
|
||||||
|
return []SystemView{
|
||||||
|
{Slug: "tree", Name: "Tree", Icon: "tree", URL: "/views/tree"},
|
||||||
|
{Slug: "dashboard", Name: "Dashboard", Icon: "dashboard", URL: "/views/dashboard"},
|
||||||
|
{Slug: "calendar", Name: "Calendar", Icon: "calendar", URL: "/views/calendar"},
|
||||||
|
{Slug: "timeline", Name: "Timeline", Icon: "clock", URL: "/views/timeline"},
|
||||||
|
{Slug: "graph", Name: "Graph", Icon: "graph", URL: "/views/graph"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LookupSystemView returns the SystemView matching slug, or nil. Used by
|
||||||
|
// handleViewRender's fallback path and by tests that need to assert
|
||||||
|
// metadata.
|
||||||
|
func LookupSystemView(slug string) *SystemView {
|
||||||
|
for _, sv := range AllSystemViews() {
|
||||||
|
if sv.Slug == slug {
|
||||||
|
s := sv
|
||||||
|
return &s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// legacyRedirect returns a handler that 301s the legacy URL onto its
|
||||||
|
// /views/{system-slug} counterpart. Per m's Q3 pick (b): when the
|
||||||
|
// request carries a legacy `?view=<uuid>` param (the 5i overlay scheme)
|
||||||
|
// the redirect resolves the uuid → current slug so old bookmarks land
|
||||||
|
// on the user view they pointed at. A miss falls through to the system
|
||||||
|
// slug.
|
||||||
|
func (s *Server) legacyRedirect(systemSlug string) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// / is a path-prefix in Go's mux; only redirect when the request
|
||||||
|
// path is exactly "/". Any other root-relative path that fell
|
||||||
|
// through to GET / (e.g. "/some-unknown") gets a 404.
|
||||||
|
if systemSlug == "tree" && r.URL.Path != "/" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
target := "/views/" + systemSlug
|
||||||
|
if id := strings.TrimSpace(r.URL.Query().Get("view")); id != "" {
|
||||||
|
if v, err := s.Store.GetViewByID(r.Context(), id); err == nil && v != nil {
|
||||||
|
target = "/views/" + v.Slug
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Preserve any non-`view` query params so existing bookmarks
|
||||||
|
// carrying ?tag=… etc. still narrow the redirected view.
|
||||||
|
q := r.URL.Query()
|
||||||
|
q.Del("view")
|
||||||
|
if encoded := q.Encode(); encoded != "" {
|
||||||
|
target += "?" + encoded
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, target, http.StatusMovedPermanently)
|
||||||
|
}
|
||||||
|
}
|
||||||
175
web/system_views_test.go
Normal file
175
web/system_views_test.go
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
package web_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/m/projax/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSystemViewLookup verifies the code-resident lookup returns the
|
||||||
|
// expected slugs in display order, and that LookupSystemView round-trips
|
||||||
|
// each entry.
|
||||||
|
func TestSystemViewLookup(t *testing.T) {
|
||||||
|
all := web.AllSystemViews()
|
||||||
|
wantSlugs := []string{"tree", "dashboard", "calendar", "timeline", "graph"}
|
||||||
|
if len(all) != len(wantSlugs) {
|
||||||
|
t.Fatalf("AllSystemViews len = %d, want %d", len(all), len(wantSlugs))
|
||||||
|
}
|
||||||
|
for i, sv := range all {
|
||||||
|
if sv.Slug != wantSlugs[i] {
|
||||||
|
t.Errorf("position %d: slug = %q, want %q", i, sv.Slug, wantSlugs[i])
|
||||||
|
}
|
||||||
|
if sv.URL != "/views/"+sv.Slug {
|
||||||
|
t.Errorf("position %d: URL = %q, want /views/%s", i, sv.URL, sv.Slug)
|
||||||
|
}
|
||||||
|
round := web.LookupSystemView(sv.Slug)
|
||||||
|
if round == nil || round.Slug != sv.Slug {
|
||||||
|
t.Errorf("LookupSystemView(%q) round-trip failed", sv.Slug)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if web.LookupSystemView("not-a-system-slug") != nil {
|
||||||
|
t.Error("LookupSystemView should return nil for unknown slugs")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLegacyRedirects verifies the slice C URL migration: each legacy
|
||||||
|
// route 301-redirects to its /views/{slug} counterpart with chip params
|
||||||
|
// preserved.
|
||||||
|
func TestLegacyRedirects(t *testing.T) {
|
||||||
|
srv, pool := mustServer(t)
|
||||||
|
defer pool.Close()
|
||||||
|
h := srv.Routes()
|
||||||
|
cases := []struct {
|
||||||
|
path, want string
|
||||||
|
}{
|
||||||
|
{"/", "/views/tree"},
|
||||||
|
{"/dashboard", "/views/dashboard"},
|
||||||
|
{"/calendar", "/views/calendar"},
|
||||||
|
{"/timeline", "/views/timeline"},
|
||||||
|
{"/graph", "/views/graph"},
|
||||||
|
// chip params survive the redirect:
|
||||||
|
{"/dashboard?tag=work", "/views/dashboard?tag=work"},
|
||||||
|
{"/timeline?from=2026-05-01", "/views/timeline?from=2026-05-01"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
code, body := get(t, h, tc.path)
|
||||||
|
if code != 301 {
|
||||||
|
t.Errorf("GET %s status=%d body=%q, want 301", tc.path, code, body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, `href="`+tc.want+`"`) {
|
||||||
|
t.Errorf("GET %s body=%q, want redirect to %q", tc.path, body, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSidebarListsUserViews — slice E: every chrome-bearing page renders
|
||||||
|
// the saved-view list under the main nav. Each entry links to
|
||||||
|
// /views/{slug} with the name as the label. Active state fires when the
|
||||||
|
// current URL matches.
|
||||||
|
func TestSidebarListsUserViews(t *testing.T) {
|
||||||
|
srv, pool := mustServer(t)
|
||||||
|
defer pool.Close()
|
||||||
|
h := srv.Routes()
|
||||||
|
ctx := context.Background()
|
||||||
|
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "")
|
||||||
|
slug := "p5j-e-sidebar-" + stamp
|
||||||
|
defer pool.Exec(context.Background(), `DELETE FROM projax.views WHERE slug = $1`, slug)
|
||||||
|
if _, err := pool.Exec(ctx, `
|
||||||
|
INSERT INTO projax.views (slug, name, filter_json)
|
||||||
|
VALUES ($1, 'P5jE Sidebar', '{"view_type":"list"}'::jsonb)`, slug); err != nil {
|
||||||
|
t.Fatalf("seed: %v", err)
|
||||||
|
}
|
||||||
|
_, body := get(t, h, "/views/tree")
|
||||||
|
if !strings.Contains(body, `href="/views/`+slug+`"`) {
|
||||||
|
t.Error("sidebar should list saved view as /views/<slug>")
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "P5jE Sidebar") {
|
||||||
|
t.Error("sidebar should show saved view's display name")
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, `href="/views/new"`) {
|
||||||
|
t.Error("sidebar Views section should include a + New view link")
|
||||||
|
}
|
||||||
|
// Active state when the URL matches.
|
||||||
|
_, onView := get(t, h, "/views/"+slug)
|
||||||
|
if !strings.Contains(onView, `class="nav-item nav-item-user-view active"`) {
|
||||||
|
t.Error("user-view nav-item should carry .active when its URL is current")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSidebarShowCountBadge — slice G: a saved view with show_count=true
|
||||||
|
// renders a row-count badge in the sidebar reflecting the filter's match
|
||||||
|
// count against ListAll().
|
||||||
|
func TestSidebarShowCountBadge(t *testing.T) {
|
||||||
|
srv, pool := mustServer(t)
|
||||||
|
defer pool.Close()
|
||||||
|
h := srv.Routes()
|
||||||
|
ctx := context.Background()
|
||||||
|
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "")
|
||||||
|
slug := "p5j-g-badge-" + stamp
|
||||||
|
defer pool.Exec(context.Background(), `DELETE FROM projax.views WHERE slug = $1`, slug)
|
||||||
|
// Seed a view scoped to dev → its count = count of items under dev that
|
||||||
|
// match status=active (default).
|
||||||
|
if _, err := pool.Exec(ctx, `
|
||||||
|
INSERT INTO projax.views (slug, name, filter_json, show_count)
|
||||||
|
VALUES ($1, 'P5jG Badge', '{"view_type":"list","project_path":"dev"}'::jsonb, true)`,
|
||||||
|
slug); err != nil {
|
||||||
|
t.Fatalf("seed view: %v", err)
|
||||||
|
}
|
||||||
|
_, body := get(t, h, "/views/tree")
|
||||||
|
if !strings.Contains(body, `class="nav-badge"`) {
|
||||||
|
t.Error("show_count view should render a nav-badge in the sidebar")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSidebarIconRenders — slice G: a view with an icon key emits the
|
||||||
|
// SVG from the registry; missing key falls back to folder default.
|
||||||
|
func TestSidebarIconRenders(t *testing.T) {
|
||||||
|
srv, pool := mustServer(t)
|
||||||
|
defer pool.Close()
|
||||||
|
h := srv.Routes()
|
||||||
|
ctx := context.Background()
|
||||||
|
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "")
|
||||||
|
slug := "p5j-g-icon-" + stamp
|
||||||
|
defer pool.Exec(context.Background(), `DELETE FROM projax.views WHERE slug = $1`, slug)
|
||||||
|
if _, err := pool.Exec(ctx, `
|
||||||
|
INSERT INTO projax.views (slug, name, filter_json, icon)
|
||||||
|
VALUES ($1, 'P5jG Icon', '{"view_type":"list"}'::jsonb, 'star')`, slug); err != nil {
|
||||||
|
t.Fatalf("seed: %v", err)
|
||||||
|
}
|
||||||
|
_, body := get(t, h, "/views/tree")
|
||||||
|
// The star icon's SVG path includes its distinctive 5-point polygon.
|
||||||
|
if !strings.Contains(body, `polygon points="12 2 15.09 8.26`) {
|
||||||
|
t.Error("sidebar should render the star icon SVG for icon=star")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLegacyViewUUIDRedirect — when a legacy URL carries the 5i overlay
|
||||||
|
// `?view=<uuid>` param, the redirect resolves the uuid to the current
|
||||||
|
// slug (per m's Q3 pick), so old bookmarks land on the right user view.
|
||||||
|
func TestLegacyViewUUIDRedirect(t *testing.T) {
|
||||||
|
srv, pool := mustServer(t)
|
||||||
|
defer pool.Close()
|
||||||
|
h := srv.Routes()
|
||||||
|
ctx := context.Background()
|
||||||
|
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "")
|
||||||
|
slug := "p5j-c-legacy-" + stamp
|
||||||
|
defer pool.Exec(context.Background(), `DELETE FROM projax.views WHERE slug = $1`, slug)
|
||||||
|
var id string
|
||||||
|
if err := pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO projax.views (slug, name, filter_json)
|
||||||
|
VALUES ($1, 'Legacy', '{"view_type":"list"}'::jsonb)
|
||||||
|
RETURNING id`, slug).Scan(&id); err != nil {
|
||||||
|
t.Fatalf("seed view: %v", err)
|
||||||
|
}
|
||||||
|
// Old-style URL: /?view=<uuid>
|
||||||
|
code, body := get(t, h, "/?view="+id)
|
||||||
|
if code != 301 {
|
||||||
|
t.Fatalf("GET /?view=<uuid> status=%d body=%q want 301", code, body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "/views/"+slug) {
|
||||||
|
t.Errorf("redirect should resolve uuid → slug; got body=%q", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,9 +3,9 @@
|
|||||||
<header class="calendar-header">
|
<header class="calendar-header">
|
||||||
<h1>{{.P.MonthLabel}}</h1>
|
<h1>{{.P.MonthLabel}}</h1>
|
||||||
<nav class="calendar-nav" aria-label="Monatsnavigation">
|
<nav class="calendar-nav" aria-label="Monatsnavigation">
|
||||||
<a class="prev" href="/calendar?month={{.P.PrevMonth}}{{with .Filter.QueryString}}&{{.}}{{end}}">< {{.P.PrevMonth}}</a>
|
<a class="prev" href="/views/calendar?month={{.P.PrevMonth}}{{with .Filter.QueryString}}&{{.}}{{end}}">< {{.P.PrevMonth}}</a>
|
||||||
<a class="today" href="/calendar{{with .Filter.QueryString}}?{{.}}{{end}}">heute</a>
|
<a class="today" href="/views/calendar{{with .Filter.QueryString}}?{{.}}{{end}}">heute</a>
|
||||||
<a class="next" href="/calendar?month={{.P.NextMonth}}{{with .Filter.QueryString}}&{{.}}{{end}}">{{.P.NextMonth}} ></a>
|
<a class="next" href="/views/calendar?month={{.P.NextMonth}}{{with .Filter.QueryString}}&{{.}}{{end}}">{{.P.NextMonth}} ></a>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
{{template "calendar-section" .}}
|
{{template "calendar-section" .}}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
<section class="tagbar" id="calendar-filterbar">
|
<section class="tagbar" id="calendar-filterbar">
|
||||||
<form id="calendar-filter" class="search"
|
<form id="calendar-filter" class="search"
|
||||||
hx-get="/calendar"
|
hx-get="/views/calendar"
|
||||||
hx-target="#calendar-section"
|
hx-target="#calendar-section"
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
hx-trigger="change from:select"
|
hx-trigger="change from:select"
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
</label>
|
</label>
|
||||||
{{if .Filter.ProjectPath}}<input type="hidden" name="project" value="{{.Filter.ProjectPath}}">{{end}}
|
{{if .Filter.ProjectPath}}<input type="hidden" name="project" value="{{.Filter.ProjectPath}}">{{end}}
|
||||||
{{if and .Filter.ProjectPath (not .Filter.IncludeDescendants)}}<input type="hidden" name="project_descendants" value="0">{{end}}
|
{{if and .Filter.ProjectPath (not .Filter.IncludeDescendants)}}<input type="hidden" name="project_descendants" value="0">{{end}}
|
||||||
{{if .Filter.Active}}<a class="clear" href="/calendar?month={{.P.MonthKey}}">clear filters</a>{{end}}
|
{{if .Filter.Active}}<a class="clear" href="/views/calendar?month={{.P.MonthKey}}">clear filters</a>{{end}}
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{{template "view-project-chip" .}}
|
{{template "view-project-chip" .}}
|
||||||
@@ -83,7 +83,7 @@
|
|||||||
</ul>
|
</ul>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if gt .ExtraCount 0}}
|
{{if gt .ExtraCount 0}}
|
||||||
<a class="cell-more muted" href="/timeline?from={{.DateKey}}&to={{.DateKey}}">+{{.ExtraCount}} more</a>
|
<a class="cell-more muted" href="/views/timeline?from={{.DateKey}}&to={{.DateKey}}">+{{.ExtraCount}} more</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
</td>
|
</td>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
<section class="tagbar" id="dashboard-filterbar">
|
<section class="tagbar" id="dashboard-filterbar">
|
||||||
<form id="dashboard-filter" class="search"
|
<form id="dashboard-filter" class="search"
|
||||||
hx-get="/dashboard{{if ne .View "tiles"}}?view={{.View}}{{end}}"
|
hx-get="/views/dashboard{{if ne .View "tiles"}}?view={{.View}}{{end}}"
|
||||||
hx-target="#dashboard-section"
|
hx-target="#dashboard-section"
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
hx-trigger="change from:select"
|
hx-trigger="change from:select"
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
{{if ne .View "tiles"}}<input type="hidden" name="view" value="{{.View}}">{{end}}
|
{{if ne .View "tiles"}}<input type="hidden" name="view" value="{{.View}}">{{end}}
|
||||||
{{if .Filter.ProjectPath}}<input type="hidden" name="project" value="{{.Filter.ProjectPath}}">{{end}}
|
{{if .Filter.ProjectPath}}<input type="hidden" name="project" value="{{.Filter.ProjectPath}}">{{end}}
|
||||||
{{if and .Filter.ProjectPath (not .Filter.IncludeDescendants)}}<input type="hidden" name="project_descendants" value="0">{{end}}
|
{{if and .Filter.ProjectPath (not .Filter.IncludeDescendants)}}<input type="hidden" name="project_descendants" value="0">{{end}}
|
||||||
{{if .Filter.Active}}<a class="clear" href="/dashboard{{if ne .View "tiles"}}?view={{.View}}{{end}}">clear filters</a>{{end}}
|
{{if .Filter.Active}}<a class="clear" href="/views/dashboard{{if ne .View "tiles"}}?view={{.View}}{{end}}">clear filters</a>{{end}}
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{{template "view-project-chip" .}}
|
{{template "view-project-chip" .}}
|
||||||
|
|||||||
@@ -122,7 +122,7 @@
|
|||||||
<summary class="proj-section-summary">Timeline behaviour {{if .Item.TimelineExclude}}<small class="muted">(hiding {{len .Item.TimelineExclude}})</small>{{end}}</summary>
|
<summary class="proj-section-summary">Timeline behaviour {{if .Item.TimelineExclude}}<small class="muted">(hiding {{len .Item.TimelineExclude}})</small>{{end}}</summary>
|
||||||
<fieldset class="timeline-exclude">
|
<fieldset class="timeline-exclude">
|
||||||
<legend class="visually-hidden">Timeline behaviour</legend>
|
<legend class="visually-hidden">Timeline behaviour</legend>
|
||||||
<p class="muted">Check a kind to hide it from <a href="/timeline">/timeline</a>. Items remain visible on this detail page either way; the toggle only affects the aggregated chronological spine. Use <a href="/timeline?include_excluded=1">?include_excluded=1</a> to peek at everything anyway.</p>
|
<p class="muted">Check a kind to hide it from <a href="/views/timeline">/timeline</a>. Items remain visible on this detail page either way; the toggle only affects the aggregated chronological spine. Use <a href="/views/timeline?include_excluded=1">?include_excluded=1</a> to peek at everything anyway.</p>
|
||||||
{{$ex := .Item.TimelineExclude}}
|
{{$ex := .Item.TimelineExclude}}
|
||||||
<label class="checkbox"><input type="checkbox" name="timeline_exclude" value="todos" {{if contains $ex "todos"}}checked{{end}}> exclude todos (VTODOs from linked calendars)</label>
|
<label class="checkbox"><input type="checkbox" name="timeline_exclude" value="todos" {{if contains $ex "todos"}}checked{{end}}> exclude todos (VTODOs from linked calendars)</label>
|
||||||
<label class="checkbox"><input type="checkbox" name="timeline_exclude" value="events" {{if contains $ex "events"}}checked{{end}}> exclude events (VEVENTs from linked calendars)</label>
|
<label class="checkbox"><input type="checkbox" name="timeline_exclude" value="events" {{if contains $ex "events"}}checked{{end}}> exclude events (VEVENTs from linked calendars)</label>
|
||||||
@@ -133,7 +133,7 @@
|
|||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button type="submit">Save</button>
|
<button type="submit">Save</button>
|
||||||
<a class="cancel" href="/">Cancel</a>
|
<a class="cancel" href="/views/tree">Cancel</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<h1>Error</h1>
|
<h1>Error</h1>
|
||||||
<p class="error">{{.Message}}</p>
|
<p class="error">{{.Message}}</p>
|
||||||
<p><a href="/">Back to tree</a></p>
|
<p><a href="/views/tree">Back to tree</a></p>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
<section class="tagbar" id="graph-filterbar">
|
<section class="tagbar" id="graph-filterbar">
|
||||||
<form id="graph-filter" class="search"
|
<form id="graph-filter" class="search"
|
||||||
hx-get="/graph"
|
hx-get="/views/graph"
|
||||||
hx-target="main"
|
hx-target="main"
|
||||||
hx-select="main"
|
hx-select="main"
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
@@ -29,8 +29,8 @@
|
|||||||
<input type="checkbox" name="isolate" value="1" {{if .Isolate}}checked{{end}}>
|
<input type="checkbox" name="isolate" value="1" {{if .Isolate}}checked{{end}}>
|
||||||
isolate (hide non-matches)
|
isolate (hide non-matches)
|
||||||
</label>
|
</label>
|
||||||
{{if .Filter.Active}}<a class="clear" href="/graph">clear filters</a>{{end}}
|
{{if .Filter.Active}}<a class="clear" href="/views/graph">clear filters</a>{{end}}
|
||||||
<a class="download" href="/graph?download=svg">download SVG</a>
|
<a class="download" href="/views/graph?download=svg">download SVG</a>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -42,38 +42,38 @@
|
|||||||
{{$path := .Path}}
|
{{$path := .Path}}
|
||||||
<aside class="projax-sidebar" aria-label="Primary navigation">
|
<aside class="projax-sidebar" aria-label="Primary navigation">
|
||||||
<div class="sidebar-top">
|
<div class="sidebar-top">
|
||||||
<a href="/" class="brand" title="projax">
|
<a href="/views/tree" class="brand" title="projax">
|
||||||
<span class="brand-icon" aria-hidden="true">▦</span>
|
<span class="brand-icon" aria-hidden="true">▦</span>
|
||||||
<strong class="brand-label">projax</strong>
|
<strong class="brand-label">projax</strong>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<nav class="sidebar-nav">
|
<nav class="sidebar-nav">
|
||||||
<a href="/" class="nav-item{{if eq $path "/"}} active{{end}}" title="Tree">
|
<a href="/views/tree" class="nav-item{{if eq $path "/views/tree"}} active{{end}}" title="Tree">
|
||||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/>
|
<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/>
|
||||||
<line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>
|
<line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>
|
||||||
</svg>
|
</svg>
|
||||||
<span class="nav-label">Tree</span>
|
<span class="nav-label">Tree</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="/dashboard" class="nav-item{{if eq $path "/dashboard"}} active{{end}}" title="Dashboard">
|
<a href="/views/dashboard" class="nav-item{{if eq $path "/views/dashboard"}} active{{end}}" title="Dashboard">
|
||||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
<rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/>
|
<rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/>
|
||||||
</svg>
|
</svg>
|
||||||
<span class="nav-label">Dashboard</span>
|
<span class="nav-label">Dashboard</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="/calendar" class="nav-item{{if eq $path "/calendar"}} active{{end}}" title="Calendar">
|
<a href="/views/calendar" class="nav-item{{if eq $path "/views/calendar"}} active{{end}}" title="Calendar">
|
||||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>
|
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>
|
||||||
</svg>
|
</svg>
|
||||||
<span class="nav-label">Calendar</span>
|
<span class="nav-label">Calendar</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="/timeline" class="nav-item{{if eq $path "/timeline"}} active{{end}}" title="Timeline">
|
<a href="/views/timeline" class="nav-item{{if eq $path "/views/timeline"}} active{{end}}" title="Timeline">
|
||||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
|
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
|
||||||
</svg>
|
</svg>
|
||||||
<span class="nav-label">Timeline</span>
|
<span class="nav-label">Timeline</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="/graph" class="nav-item{{if eq $path "/graph"}} active{{end}}" title="Graph">
|
<a href="/views/graph" class="nav-item{{if eq $path "/views/graph"}} active{{end}}" title="Graph">
|
||||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/>
|
<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/>
|
||||||
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>
|
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>
|
||||||
@@ -89,6 +89,25 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<span class="nav-label">Views</span>
|
<span class="nav-label">Views</span>
|
||||||
</a>
|
</a>
|
||||||
|
{{if .UserViews}}
|
||||||
|
{{$counts := .UserViewCounts}}
|
||||||
|
<div class="sidebar-user-views" aria-label="Saved views">
|
||||||
|
{{range .UserViews}}
|
||||||
|
{{$slug := .Slug}}
|
||||||
|
<a href="/views/{{.Slug}}"
|
||||||
|
class="nav-item nav-item-user-view{{if eq $path (printf "/views/%s" .Slug)}} active{{end}}"
|
||||||
|
title="{{.Name}}">
|
||||||
|
{{renderIcon .Icon}}
|
||||||
|
<span class="nav-label">{{.Name}}</span>
|
||||||
|
{{if .ShowCount}}<span class="nav-badge" aria-label="Item count">{{index $counts $slug}}</span>{{end}}
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
|
<a href="/views/new" class="nav-item nav-item-user-view nav-item-new-view" title="New view">
|
||||||
|
<span class="nav-icon" aria-hidden="true">+</span>
|
||||||
|
<span class="nav-label">New view</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
<a href="/admin" class="nav-item{{if eq $path "/admin"}} active{{end}}" title="Admin">
|
<a href="/admin" class="nav-item{{if eq $path "/admin"}} active{{end}}" title="Admin">
|
||||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
<circle cx="12" cy="12" r="3"/>
|
<circle cx="12" cy="12" r="3"/>
|
||||||
@@ -124,14 +143,14 @@
|
|||||||
{{template "content" .}}
|
{{template "content" .}}
|
||||||
</main>
|
</main>
|
||||||
<nav class="projax-bottom-nav" aria-label="Mobile navigation">
|
<nav class="projax-bottom-nav" aria-label="Mobile navigation">
|
||||||
<a href="/" class="bottom-nav-item{{if eq $path "/"}} active{{end}}" aria-label="Tree">
|
<a href="/views/tree" class="bottom-nav-item{{if eq $path "/views/tree"}} active{{end}}" aria-label="Tree">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="22" height="22" aria-hidden="true">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="22" height="22" aria-hidden="true">
|
||||||
<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/>
|
<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/>
|
||||||
<line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>
|
<line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>
|
||||||
</svg>
|
</svg>
|
||||||
<span>Tree</span>
|
<span>Tree</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="/dashboard" class="bottom-nav-item{{if eq $path "/dashboard"}} active{{end}}" aria-label="Dashboard">
|
<a href="/views/dashboard" class="bottom-nav-item{{if eq $path "/views/dashboard"}} active{{end}}" aria-label="Dashboard">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="22" height="22" aria-hidden="true">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="22" height="22" aria-hidden="true">
|
||||||
<rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/>
|
<rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -144,7 +163,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="/calendar" class="bottom-nav-item{{if eq $path "/calendar"}} active{{end}}" aria-label="Calendar">
|
<a href="/views/calendar" class="bottom-nav-item{{if eq $path "/views/calendar"}} active{{end}}" aria-label="Calendar">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="22" height="22" aria-hidden="true">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="22" height="22" aria-hidden="true">
|
||||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>
|
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -158,13 +177,13 @@
|
|||||||
<span>Menu</span>
|
<span>Menu</span>
|
||||||
</summary>
|
</summary>
|
||||||
<div class="drawer-sheet" role="menu">
|
<div class="drawer-sheet" role="menu">
|
||||||
<a href="/timeline" class="drawer-item{{if eq $path "/timeline"}} active{{end}}" role="menuitem">
|
<a href="/views/timeline" class="drawer-item{{if eq $path "/views/timeline"}} active{{end}}" role="menuitem">
|
||||||
<svg class="drawer-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
<svg class="drawer-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
|
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
|
||||||
</svg>
|
</svg>
|
||||||
<span>Timeline</span>
|
<span>Timeline</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="/graph" class="drawer-item{{if eq $path "/graph"}} active{{end}}" role="menuitem">
|
<a href="/views/graph" class="drawer-item{{if eq $path "/views/graph"}} active{{end}}" role="menuitem">
|
||||||
<svg class="drawer-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
<svg class="drawer-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/>
|
<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/>
|
||||||
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>
|
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
<section class="tagbar" id="timeline-filterbar">
|
<section class="tagbar" id="timeline-filterbar">
|
||||||
<form id="timeline-filter" class="search"
|
<form id="timeline-filter" class="search"
|
||||||
hx-get="/timeline"
|
hx-get="/views/timeline"
|
||||||
hx-target="#timeline-section"
|
hx-target="#timeline-section"
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
hx-trigger="change from:select"
|
hx-trigger="change from:select"
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
</label>
|
</label>
|
||||||
{{if .Filter.ProjectPath}}<input type="hidden" name="project" value="{{.Filter.ProjectPath}}">{{end}}
|
{{if .Filter.ProjectPath}}<input type="hidden" name="project" value="{{.Filter.ProjectPath}}">{{end}}
|
||||||
{{if and .Filter.ProjectPath (not .Filter.IncludeDescendants)}}<input type="hidden" name="project_descendants" value="0">{{end}}
|
{{if and .Filter.ProjectPath (not .Filter.IncludeDescendants)}}<input type="hidden" name="project_descendants" value="0">{{end}}
|
||||||
{{if .Filter.Active}}<a class="clear" href="/timeline">clear filters</a>{{end}}
|
{{if .Filter.Active}}<a class="clear" href="/views/timeline">clear filters</a>{{end}}
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{{template "view-project-chip" .}}
|
{{template "view-project-chip" .}}
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
<li class="spine-day{{if .Sticky}} sticky-{{.Sticky}}{{end}}" data-date="{{.DateKey}}">
|
<li class="spine-day{{if .Sticky}} sticky-{{.Sticky}}{{end}}" data-date="{{.DateKey}}">
|
||||||
<header class="day-header">
|
<header class="day-header">
|
||||||
{{if .Sticky}}<span class="sticky-pill">{{.Sticky}}</span>{{end}}
|
{{if .Sticky}}<span class="sticky-pill">{{.Sticky}}</span>{{end}}
|
||||||
<h2><a class="muted" href="/timeline?from={{.DateKey}}&to={{.DateKey}}">{{.Label}}</a> <small class="muted">({{len .Rows}})</small></h2>
|
<h2><a class="muted" href="/views/timeline?from={{.DateKey}}&to={{.DateKey}}">{{.Label}}</a> <small class="muted">({{len .Rows}})</small></h2>
|
||||||
</header>
|
</header>
|
||||||
<ul class="day-rows">
|
<ul class="day-rows">
|
||||||
{{range .Rows}}
|
{{range .Rows}}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ the visual difference is layout, not data shape.
|
|||||||
</article>
|
</article>
|
||||||
{{else}}
|
{{else}}
|
||||||
<div class="tree-card-empty">
|
<div class="tree-card-empty">
|
||||||
<em>No items match. Try fewer filters or <a href="/">clear all</a>.</em>
|
<em>No items match. Try fewer filters or <a href="/views/tree">clear all</a>.</em>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ set surfaces a friendly empty-state message.
|
|||||||
</div>
|
</div>
|
||||||
{{else}}
|
{{else}}
|
||||||
<div class="kanban-empty muted">
|
<div class="kanban-empty muted">
|
||||||
<em>No items match. Try fewer filters or <a href="/?view_type=kanban">clear filters</a>.</em>
|
<em>No items match. Try fewer filters or <a href="/views/tree?view_type=kanban">clear filters</a>.</em>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<p class="counts">
|
<p class="counts">
|
||||||
<strong>{{.Matched}}</strong> / <strong>{{.Total}}</strong> items match
|
<strong>{{.Matched}}</strong> / <strong>{{.Total}}</strong> items match
|
||||||
{{if .OrphanN}} · <strong>{{.OrphanN}}</strong> unclassified mai-managed roots <a href="/admin/classify">→ classify</a>{{end}}
|
{{if .OrphanN}} · <strong>{{.OrphanN}}</strong> unclassified mai-managed roots <a href="/admin/classify">→ classify</a>{{end}}
|
||||||
{{if .Filter.Active}} · <a class="clear" href="/">clear filters</a>{{end}}
|
{{if .Filter.Active}} · <a class="clear" href="/views/tree">clear filters</a>{{end}}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<section class="tagbar" id="tree-filterbar">
|
<section class="tagbar" id="tree-filterbar">
|
||||||
@@ -93,7 +93,7 @@
|
|||||||
{{template "children" .}}
|
{{template "children" .}}
|
||||||
</li>
|
</li>
|
||||||
{{else}}
|
{{else}}
|
||||||
<li class="empty"><em>No items match. Try fewer filters or <a href="/">clear all</a>.</em></li>
|
<li class="empty"><em>No items match. Try fewer filters or <a href="/views/tree">clear all</a>.</em></li>
|
||||||
{{end}}
|
{{end}}
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
49
web/templates/view_editor.tmpl
Normal file
49
web/templates/view_editor.tmpl
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<h1>{{if .View}}Edit {{.View.Name}}{{else}}New view{{end}}</h1>
|
||||||
|
<p class="muted"><a href="/views">← back to views</a></p>
|
||||||
|
|
||||||
|
<form class="view-editor"
|
||||||
|
method="post"
|
||||||
|
action="{{if .View}}/views/{{.View.Slug}}{{else}}/views{{end}}">
|
||||||
|
<label>Name <input type="text" name="name" required maxlength="80" value="{{if .View}}{{.View.Name}}{{end}}"></label>
|
||||||
|
<label>Slug
|
||||||
|
<input type="text" name="slug" required maxlength="63"
|
||||||
|
pattern="^[a-z0-9][a-z0-9-]{0,62}$"
|
||||||
|
value="{{if .View}}{{.View.Slug}}{{end}}">
|
||||||
|
<small class="muted">lowercase letters, digits, dashes. No reserved system slugs.</small>
|
||||||
|
</label>
|
||||||
|
<label>Icon
|
||||||
|
<select name="icon">
|
||||||
|
{{$cur := ""}}
|
||||||
|
{{if and .View .View.Icon}}{{$cur = deref .View.Icon}}{{end}}
|
||||||
|
{{range .IconKeys}}
|
||||||
|
<option value="{{.}}"{{if eq . $cur}} selected{{end}}>{{.}}</option>
|
||||||
|
{{end}}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<fieldset class="view-type-radios">
|
||||||
|
<legend>View type</legend>
|
||||||
|
{{range .ViewTypes}}
|
||||||
|
<label><input type="radio" name="view_type" value="{{.}}" {{if eq . $.CurrentVT}}checked{{end}}> {{.}}</label>
|
||||||
|
{{end}}
|
||||||
|
</fieldset>
|
||||||
|
<label>Group by
|
||||||
|
<select name="group_by">
|
||||||
|
{{range .GroupByOptions}}<option value="{{.}}">{{if eq . ""}}—{{else}}{{.}}{{end}}</option>{{end}}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Sort field <input type="text" name="sort_field" placeholder="title / updated_at" maxlength="40"></label>
|
||||||
|
<label>Sort dir
|
||||||
|
<select name="sort_dir">
|
||||||
|
{{range .SortDirOptions}}<option value="{{.}}">{{if eq . ""}}—{{else}}{{.}}{{end}}</option>{{end}}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label><input type="checkbox" name="show_count" value="1"
|
||||||
|
{{if and .View .View.ShowCount}}checked{{end}}> Show row-count badge in sidebar</label>
|
||||||
|
<label>Filter (URL query form)
|
||||||
|
<input type="text" name="filter_query" placeholder="tag=work&mgmt=mai" value="{{.FilterQuery}}">
|
||||||
|
</label>
|
||||||
|
<button type="submit">{{if .View}}Save changes{{else}}Create view{{end}}</button>
|
||||||
|
<a class="muted" href="/views">cancel</a>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
14
web/templates/view_render.tmpl
Normal file
14
web/templates/view_render.tmpl
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<section class="view-header">
|
||||||
|
<h1>{{.View.Name}}</h1>
|
||||||
|
<p class="muted view-meta">
|
||||||
|
<code>/views/{{.View.Slug}}</code> ·
|
||||||
|
<a href="/views/{{.View.Slug}}/edit">edit</a> ·
|
||||||
|
<form method="post" action="/views/{{.View.Slug}}/delete" style="display:inline">
|
||||||
|
<button type="submit" class="link-button" onclick="return confirm('Delete view {{.View.Name}}?')">delete</button>
|
||||||
|
</form>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{template "tree-section" .}}
|
||||||
|
{{end}}
|
||||||
26
web/templates/views_landing.tmpl
Normal file
26
web/templates/views_landing.tmpl
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<h1>Views</h1>
|
||||||
|
|
||||||
|
<p class="muted">First-class saved pages. Each view has its own URL and renders on its own.</p>
|
||||||
|
|
||||||
|
{{if .Views}}
|
||||||
|
<section class="views-list">
|
||||||
|
<ul class="views-list-grid">
|
||||||
|
{{range .Views}}
|
||||||
|
<li>
|
||||||
|
<a class="view-card" href="/views/{{.Slug}}">
|
||||||
|
<span class="view-card-name">{{.Name}}</span>
|
||||||
|
<span class="view-card-slug muted">/views/{{.Slug}}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
{{else}}
|
||||||
|
<section class="views-empty">
|
||||||
|
<p class="muted"><em>No saved views yet.</em></p>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<p><a class="view-create-link" href="/views/new">+ New view</a></p>
|
||||||
|
{{end}}
|
||||||
@@ -14,9 +14,9 @@ func TestThemeDefaultIsDark(t *testing.T) {
|
|||||||
srv, pool := mustServer(t)
|
srv, pool := mustServer(t)
|
||||||
defer pool.Close()
|
defer pool.Close()
|
||||||
h := srv.Routes()
|
h := srv.Routes()
|
||||||
code, body := get(t, h, "/")
|
code, body := get(t, h, "/views/tree")
|
||||||
if code != 200 {
|
if code != 200 {
|
||||||
t.Fatalf("GET / → %d", code)
|
t.Fatalf("GET /views/tree → %d", code)
|
||||||
}
|
}
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
`<html lang="en" data-theme="dark">`,
|
`<html lang="en" data-theme="dark">`,
|
||||||
@@ -42,7 +42,7 @@ func TestThemeCookieRoundTrips(t *testing.T) {
|
|||||||
srv, pool := mustServer(t)
|
srv, pool := mustServer(t)
|
||||||
defer pool.Close()
|
defer pool.Close()
|
||||||
h := srv.Routes()
|
h := srv.Routes()
|
||||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
req := httptest.NewRequest(http.MethodGet, "/views/tree", nil)
|
||||||
req.AddCookie(&http.Cookie{Name: "projax_theme", Value: "light"})
|
req.AddCookie(&http.Cookie{Name: "projax_theme", Value: "light"})
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
h.ServeHTTP(w, req)
|
h.ServeHTTP(w, req)
|
||||||
@@ -66,7 +66,7 @@ func TestThemeCookieUnknownFallsBackToDark(t *testing.T) {
|
|||||||
srv, pool := mustServer(t)
|
srv, pool := mustServer(t)
|
||||||
defer pool.Close()
|
defer pool.Close()
|
||||||
h := srv.Routes()
|
h := srv.Routes()
|
||||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
req := httptest.NewRequest(http.MethodGet, "/views/tree", nil)
|
||||||
req.AddCookie(&http.Cookie{Name: "projax_theme", Value: "neon-puke"})
|
req.AddCookie(&http.Cookie{Name: "projax_theme", Value: "neon-puke"})
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
h.ServeHTTP(w, req)
|
h.ServeHTTP(w, req)
|
||||||
@@ -94,7 +94,7 @@ func TestThemeTogglePagesShareSameTheme(t *testing.T) {
|
|||||||
body, _ := io.ReadAll(w.Result().Body)
|
body, _ := io.ReadAll(w.Result().Body)
|
||||||
return string(body)
|
return string(body)
|
||||||
}
|
}
|
||||||
for _, path := range []string{"/", "/dashboard", "/timeline", "/graph", "/admin", "/admin/bulk", "/admin/classify"} {
|
for _, path := range []string{"/views/tree", "/views/dashboard", "/views/timeline", "/views/graph", "/admin", "/admin/bulk", "/admin/classify"} {
|
||||||
dark := probe(path, "")
|
dark := probe(path, "")
|
||||||
light := probe(path, "light")
|
light := probe(path, "light")
|
||||||
if !strings.Contains(dark, `data-theme="dark"`) {
|
if !strings.Contains(dark, `data-theme="dark"`) {
|
||||||
@@ -112,7 +112,7 @@ func TestThemeToggleScriptPresent(t *testing.T) {
|
|||||||
srv, pool := mustServer(t)
|
srv, pool := mustServer(t)
|
||||||
defer pool.Close()
|
defer pool.Close()
|
||||||
h := srv.Routes()
|
h := srv.Routes()
|
||||||
_, body := get(t, h, "/dashboard")
|
_, body := get(t, h, "/views/dashboard")
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
"document.cookie = 'projax_theme=",
|
"document.cookie = 'projax_theme=",
|
||||||
`getElementById('theme-toggle')`,
|
`getElementById('theme-toggle')`,
|
||||||
@@ -132,7 +132,7 @@ func TestThemeColorMetaHelper(t *testing.T) {
|
|||||||
defer pool.Close()
|
defer pool.Close()
|
||||||
// Indirect: render a fragment with a Theme override to confirm injection
|
// Indirect: render a fragment with a Theme override to confirm injection
|
||||||
// does not double-write the meta when caller already populates it.
|
// does not double-write the meta when caller already populates it.
|
||||||
req := httptest.NewRequest(http.MethodGet, "/dashboard", nil)
|
req := httptest.NewRequest(http.MethodGet, "/views/dashboard", nil)
|
||||||
req.AddCookie(&http.Cookie{Name: "projax_theme", Value: "light"})
|
req.AddCookie(&http.Cookie{Name: "projax_theme", Value: "light"})
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
srv.Routes().ServeHTTP(w, req)
|
srv.Routes().ServeHTTP(w, req)
|
||||||
|
|||||||
@@ -200,7 +200,7 @@ func (s *Server) handleTimeline(w http.ResponseWriter, r *http.Request) {
|
|||||||
"Query": q,
|
"Query": q,
|
||||||
"Now": now,
|
"Now": now,
|
||||||
"Projects": projects,
|
"Projects": projects,
|
||||||
"BasePath": "/timeline",
|
"BasePath": "/views/timeline",
|
||||||
"ProjectChipTarget": "#timeline-section",
|
"ProjectChipTarget": "#timeline-section",
|
||||||
}
|
}
|
||||||
if r.Header.Get("HX-Request") == "true" {
|
if r.Header.Get("HX-Request") == "true" {
|
||||||
|
|||||||
@@ -108,13 +108,13 @@ END:VCALENDAR`
|
|||||||
}
|
}
|
||||||
|
|
||||||
h := srv.Routes()
|
h := srv.Routes()
|
||||||
_, body := get(t, h, "/timeline")
|
_, body := get(t, h, "/views/timeline")
|
||||||
if strings.Contains(body, "Shopping list item") {
|
if strings.Contains(body, "Shopping list item") {
|
||||||
t.Errorf("/timeline should NOT include excluded todo summary; body contained it")
|
t.Errorf("/timeline should NOT include excluded todo summary; body contained it")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Override: ?include_excluded=1 brings it back.
|
// Override: ?include_excluded=1 brings it back.
|
||||||
_, peekBody := get(t, h, "/timeline?include_excluded=1")
|
_, peekBody := get(t, h, "/views/timeline?include_excluded=1")
|
||||||
if !strings.Contains(peekBody, "Shopping list item") {
|
if !strings.Contains(peekBody, "Shopping list item") {
|
||||||
t.Errorf("?include_excluded=1 should surface the excluded todo; body lacked it")
|
t.Errorf("?include_excluded=1 should surface the excluded todo; body lacked it")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ func TestTimelineRendersEmpty(t *testing.T) {
|
|||||||
srv, pool := mustServer(t)
|
srv, pool := mustServer(t)
|
||||||
defer pool.Close()
|
defer pool.Close()
|
||||||
h := srv.Routes()
|
h := srv.Routes()
|
||||||
code, body := get(t, h, "/timeline")
|
code, body := get(t, h, "/views/timeline")
|
||||||
if code != 200 {
|
if code != 200 {
|
||||||
t.Fatalf("GET /timeline → %d body=%s", code, body)
|
t.Fatalf("GET /timeline → %d body=%s", code, body)
|
||||||
}
|
}
|
||||||
@@ -67,7 +67,7 @@ func TestTimelineSurfacesDatedDocs(t *testing.T) {
|
|||||||
t.Fatalf("seed link: %v", err)
|
t.Fatalf("seed link: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
code, body := get(t, h, "/timeline")
|
code, body := get(t, h, "/views/timeline")
|
||||||
if code != 200 {
|
if code != 200 {
|
||||||
t.Fatalf("GET /timeline → %d", code)
|
t.Fatalf("GET /timeline → %d", code)
|
||||||
}
|
}
|
||||||
@@ -115,7 +115,7 @@ func TestTimelineFilterByKindNarrowsRows(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Unfiltered: both the creation marker and the dated doc should be present.
|
// Unfiltered: both the creation marker and the dated doc should be present.
|
||||||
_, allBody := get(t, h, "/timeline")
|
_, allBody := get(t, h, "/views/timeline")
|
||||||
if !strings.Contains(allBody, "added <a class=\"proj\" href=\"/i/dev."+slug) {
|
if !strings.Contains(allBody, "added <a class=\"proj\" href=\"/i/dev."+slug) {
|
||||||
t.Errorf("expected creation marker in unfiltered timeline body")
|
t.Errorf("expected creation marker in unfiltered timeline body")
|
||||||
}
|
}
|
||||||
@@ -124,7 +124,7 @@ func TestTimelineFilterByKindNarrowsRows(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// kind=doc only: the doc row stays; the creation marker drops.
|
// kind=doc only: the doc row stays; the creation marker drops.
|
||||||
_, docOnly := get(t, h, "/timeline?kind=doc")
|
_, docOnly := get(t, h, "/views/timeline?kind=doc")
|
||||||
if strings.Contains(docOnly, "added <a class=\"proj\" href=\"/i/dev."+slug) {
|
if strings.Contains(docOnly, "added <a class=\"proj\" href=\"/i/dev."+slug) {
|
||||||
t.Errorf("kind=doc should hide creation marker")
|
t.Errorf("kind=doc should hide creation marker")
|
||||||
}
|
}
|
||||||
@@ -171,7 +171,7 @@ func TestTimelineOrderToggleReversesDays(t *testing.T) {
|
|||||||
older := "dev." + slug + "." + time.Now().UTC().AddDate(0, 0, -3).Format("060102")
|
older := "dev." + slug + "." + time.Now().UTC().AddDate(0, 0, -3).Format("060102")
|
||||||
newer := "dev." + slug + "." + time.Now().UTC().AddDate(0, 0, 5).Format("060102")
|
newer := "dev." + slug + "." + time.Now().UTC().AddDate(0, 0, 5).Format("060102")
|
||||||
|
|
||||||
_, desc := get(t, h, "/timeline")
|
_, desc := get(t, h, "/views/timeline")
|
||||||
idxNewerDesc := strings.Index(desc, newer)
|
idxNewerDesc := strings.Index(desc, newer)
|
||||||
idxOlderDesc := strings.Index(desc, older)
|
idxOlderDesc := strings.Index(desc, older)
|
||||||
if idxNewerDesc < 0 || idxOlderDesc < 0 {
|
if idxNewerDesc < 0 || idxOlderDesc < 0 {
|
||||||
@@ -181,7 +181,7 @@ func TestTimelineOrderToggleReversesDays(t *testing.T) {
|
|||||||
t.Errorf("default order should be desc (newest first); newer at %d, older at %d", idxNewerDesc, idxOlderDesc)
|
t.Errorf("default order should be desc (newest first); newer at %d, older at %d", idxNewerDesc, idxOlderDesc)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, asc := get(t, h, "/timeline?order=asc")
|
_, asc := get(t, h, "/views/timeline?order=asc")
|
||||||
idxNewerAsc := strings.Index(asc, newer)
|
idxNewerAsc := strings.Index(asc, newer)
|
||||||
idxOlderAsc := strings.Index(asc, older)
|
idxOlderAsc := strings.Index(asc, older)
|
||||||
if !(idxOlderAsc < idxNewerAsc) {
|
if !(idxOlderAsc < idxNewerAsc) {
|
||||||
@@ -277,7 +277,7 @@ END:VCALENDAR`
|
|||||||
}
|
}
|
||||||
|
|
||||||
h := srv.Routes()
|
h := srv.Routes()
|
||||||
code, body := get(t, h, "/timeline")
|
code, body := get(t, h, "/views/timeline")
|
||||||
if code != 200 {
|
if code != 200 {
|
||||||
t.Fatalf("GET /timeline → %d", code)
|
t.Fatalf("GET /timeline → %d", code)
|
||||||
}
|
}
|
||||||
@@ -337,7 +337,7 @@ func TestTimelineFilterByTagAppliesAcrossKinds(t *testing.T) {
|
|||||||
defer pool.Exec(context.Background(), `delete from projax.items where id in ($1, $2)`, devID, homeID)
|
defer pool.Exec(context.Background(), `delete from projax.items where id in ($1, $2)`, devID, homeID)
|
||||||
|
|
||||||
tag := "tl-tag-work-" + stamp
|
tag := "tl-tag-work-" + stamp
|
||||||
_, body := get(t, h, "/timeline?tag="+tag)
|
_, body := get(t, h, "/views/timeline?tag="+tag)
|
||||||
// Phase 5i Slice A: the project picker renders every item path as a
|
// Phase 5i Slice A: the project picker renders every item path as a
|
||||||
// <select> option, so a naive substring match also sees filtered-out
|
// <select> option, so a naive substring match also sees filtered-out
|
||||||
// items inside the dropdown. Anchor on the timeline-row link instead.
|
// items inside the dropdown. Anchor on the timeline-row link instead.
|
||||||
|
|||||||
@@ -479,7 +479,10 @@ type ChipCounts struct {
|
|||||||
// see what they're filtered down to). For an inactive chip the count is what
|
// see what they're filtered down to). For an inactive chip the count is what
|
||||||
// they'd get if they added it. At m's scale (≤100 items × ≤30 chips) this is
|
// they'd get if they added it. At m's scale (≤100 items × ≤30 chips) this is
|
||||||
// trivially cheap; no caching needed.
|
// trivially cheap; no caching needed.
|
||||||
func computeChipCounts(items []*store.Item, current TreeFilter, linkKinds map[string]map[string]struct{}, allTags []string) ChipCounts {
|
func computeChipCounts(items []*store.Item, current TreeFilter, linkKinds map[string]map[string]struct{}, allTags []string, base string) ChipCounts {
|
||||||
|
if base == "" {
|
||||||
|
base = "/"
|
||||||
|
}
|
||||||
count := func(f TreeFilter) int {
|
count := func(f TreeFilter) int {
|
||||||
// Branch-keep semantics aren't relevant for chip counts — we want a
|
// Branch-keep semantics aren't relevant for chip counts — we want a
|
||||||
// raw "how many items match this filter directly" so the chip number
|
// raw "how many items match this filter directly" so the chip number
|
||||||
@@ -497,7 +500,7 @@ func computeChipCounts(items []*store.Item, current TreeFilter, linkKinds map[st
|
|||||||
next := current.ToggleTag(tag)
|
next := current.ToggleTag(tag)
|
||||||
out.Tags = append(out.Tags, ChipCount{
|
out.Tags = append(out.Tags, ChipCount{
|
||||||
Label: tag,
|
Label: tag,
|
||||||
URL: next.URL(),
|
URL: next.URLOn(base),
|
||||||
Count: count(next),
|
Count: count(next),
|
||||||
Active: contains(current.Tags, tag),
|
Active: contains(current.Tags, tag),
|
||||||
})
|
})
|
||||||
@@ -506,7 +509,7 @@ func computeChipCounts(items []*store.Item, current TreeFilter, linkKinds map[st
|
|||||||
next := current.ToggleManagement(mode)
|
next := current.ToggleManagement(mode)
|
||||||
out.Management = append(out.Management, ChipCount{
|
out.Management = append(out.Management, ChipCount{
|
||||||
Label: mode,
|
Label: mode,
|
||||||
URL: next.URL(),
|
URL: next.URLOn(base),
|
||||||
Count: count(next),
|
Count: count(next),
|
||||||
Active: contains(current.Management, mode),
|
Active: contains(current.Management, mode),
|
||||||
})
|
})
|
||||||
@@ -515,7 +518,7 @@ func computeChipCounts(items []*store.Item, current TreeFilter, linkKinds map[st
|
|||||||
next := current.ToggleStatus(st)
|
next := current.ToggleStatus(st)
|
||||||
out.Status = append(out.Status, ChipCount{
|
out.Status = append(out.Status, ChipCount{
|
||||||
Label: st,
|
Label: st,
|
||||||
URL: next.URL(),
|
URL: next.URLOn(base),
|
||||||
Count: count(next),
|
Count: count(next),
|
||||||
Active: contains(current.Status, st),
|
Active: contains(current.Status, st),
|
||||||
})
|
})
|
||||||
@@ -524,7 +527,7 @@ func computeChipCounts(items []*store.Item, current TreeFilter, linkKinds map[st
|
|||||||
next := current.ToggleHas(h)
|
next := current.ToggleHas(h)
|
||||||
out.Has = append(out.Has, ChipCount{
|
out.Has = append(out.Has, ChipCount{
|
||||||
Label: h,
|
Label: h,
|
||||||
URL: next.URL(),
|
URL: next.URLOn(base),
|
||||||
Count: count(next),
|
Count: count(next),
|
||||||
Active: contains(current.HasLinks, h),
|
Active: contains(current.HasLinks, h),
|
||||||
})
|
})
|
||||||
@@ -533,7 +536,7 @@ func computeChipCounts(items []*store.Item, current TreeFilter, linkKinds map[st
|
|||||||
next := current.ToggleShowArchived()
|
next := current.ToggleShowArchived()
|
||||||
out.ShowArchived = ChipCount{
|
out.ShowArchived = ChipCount{
|
||||||
Label: "show archived",
|
Label: "show archived",
|
||||||
URL: next.URL(),
|
URL: next.URLOn(base),
|
||||||
Count: count(next),
|
Count: count(next),
|
||||||
Active: current.ShowArchived,
|
Active: current.ShowArchived,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ func TestComputeChipCountsTagCounts(t *testing.T) {
|
|||||||
both := &store.Item{ID: "x", Slug: "x", Title: "X", Tags: []string{"work", "dev"}, Status: "active"}
|
both := &store.Item{ID: "x", Slug: "x", Title: "X", Tags: []string{"work", "dev"}, Status: "active"}
|
||||||
items := []*store.Item{work, dev, both}
|
items := []*store.Item{work, dev, both}
|
||||||
f := TreeFilter{Status: []string{"active"}}
|
f := TreeFilter{Status: []string{"active"}}
|
||||||
counts := computeChipCounts(items, f, map[string]map[string]struct{}{}, []string{"work", "dev"})
|
counts := computeChipCounts(items, f, map[string]map[string]struct{}{}, []string{"work", "dev"}, "/views/tree")
|
||||||
if len(counts.Tags) != 2 {
|
if len(counts.Tags) != 2 {
|
||||||
t.Fatalf("expected 2 tag chips, got %d", len(counts.Tags))
|
t.Fatalf("expected 2 tag chips, got %d", len(counts.Tags))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,13 +63,13 @@ func (s ViewTypeSet) Resolve(vt string) string {
|
|||||||
// this. The narrow tree/dashboard set is the seed; slices C–E grow it.
|
// this. The narrow tree/dashboard set is the seed; slices C–E grow it.
|
||||||
func PageViewTypes(route string) ViewTypeSet {
|
func PageViewTypes(route string) ViewTypeSet {
|
||||||
switch route {
|
switch route {
|
||||||
case "/", "tree":
|
case "/", "/views/tree", "tree":
|
||||||
return ViewTypeSet{
|
return ViewTypeSet{
|
||||||
Default: ViewTypeList,
|
Default: ViewTypeList,
|
||||||
// Slice B: list + card. Slice C: kanban joins.
|
// Slice B: list + card. Slice C: kanban joins.
|
||||||
Allowed: []string{ViewTypeList, ViewTypeCard, ViewTypeKanban},
|
Allowed: []string{ViewTypeList, ViewTypeCard, ViewTypeKanban},
|
||||||
}
|
}
|
||||||
case "/dashboard", "dashboard":
|
case "/dashboard", "/views/dashboard", "dashboard":
|
||||||
// Dashboard is locked to its Phase 5h tabbed-tiles surface in slice B.
|
// Dashboard is locked to its Phase 5h tabbed-tiles surface in slice B.
|
||||||
// The view_type chip is informational only here; switching templates
|
// The view_type chip is informational only here; switching templates
|
||||||
// for card vs list on /dashboard is a follow-up slice (the tabbed
|
// for card vs list on /dashboard is a follow-up slice (the tabbed
|
||||||
@@ -79,12 +79,12 @@ func PageViewTypes(route string) ViewTypeSet {
|
|||||||
Default: ViewTypeCard,
|
Default: ViewTypeCard,
|
||||||
Allowed: []string{ViewTypeCard},
|
Allowed: []string{ViewTypeCard},
|
||||||
}
|
}
|
||||||
case "/timeline", "timeline":
|
case "/timeline", "/views/timeline", "timeline":
|
||||||
return ViewTypeSet{
|
return ViewTypeSet{
|
||||||
Default: ViewTypeTimeline,
|
Default: ViewTypeTimeline,
|
||||||
Allowed: []string{ViewTypeTimeline},
|
Allowed: []string{ViewTypeTimeline},
|
||||||
}
|
}
|
||||||
case "/calendar", "calendar":
|
case "/calendar", "/views/calendar", "calendar":
|
||||||
return ViewTypeSet{
|
return ViewTypeSet{
|
||||||
Default: ViewTypeCalendar,
|
Default: ViewTypeCalendar,
|
||||||
Allowed: []string{ViewTypeCalendar},
|
Allowed: []string{ViewTypeCalendar},
|
||||||
|
|||||||
@@ -18,10 +18,10 @@ func TestParseViewTypeFallsBackOnUnknown(t *testing.T) {
|
|||||||
{"/", "list", ViewTypeList}, // explicit default
|
{"/", "list", ViewTypeList}, // explicit default
|
||||||
{"/", "kanban", ViewTypeKanban}, // unlocked in slice C
|
{"/", "kanban", ViewTypeKanban}, // unlocked in slice C
|
||||||
{"/", "junk", ViewTypeList}, // unknown → default
|
{"/", "junk", ViewTypeList}, // unknown → default
|
||||||
{"/dashboard", "", ViewTypeCard}, // default for dashboard
|
{"/views/dashboard", "", ViewTypeCard}, // default for dashboard
|
||||||
{"/dashboard", "list", ViewTypeCard}, // not allowed in slice B → default
|
{"/views/dashboard", "list", ViewTypeCard}, // not allowed in slice B → default
|
||||||
{"/timeline", "card", ViewTypeTimeline}, // locked
|
{"/views/timeline", "card", ViewTypeTimeline}, // locked
|
||||||
{"/calendar", "kanban", ViewTypeCalendar}, // locked
|
{"/views/calendar", "kanban", ViewTypeCalendar}, // locked
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
set := PageViewTypes(tc.route)
|
set := PageViewTypes(tc.route)
|
||||||
|
|||||||
472
web/views.go
472
web/views.go
@@ -1,10 +1,468 @@
|
|||||||
package web
|
package web
|
||||||
|
|
||||||
// Phase 5j Slice A — paliad-shape redesign. The 5i overlay handlers
|
import (
|
||||||
// (handleViewsIndex / handleViewCreate / handleViewWrite / handleViewEdit
|
"encoding/json"
|
||||||
// / handleViewRedirect / applySavedView / applyDefaultView / friends)
|
"errors"
|
||||||
// are deleted here. The new /views/{slug} route family lands in slice B;
|
"fmt"
|
||||||
// system-view migration lands in slice C.
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/m/projax/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Phase 5j paliad-shape views handlers. Slice B introduces the route
|
||||||
|
// family; slices C–G evolve the render, editor, system-views, sidebar,
|
||||||
|
// and polish layers.
|
||||||
//
|
//
|
||||||
// Between slices A and B the /views URLs return 404 — by design, no real
|
// Route table:
|
||||||
// user data was on the old shape (hours-old after the 5i ship).
|
// GET /views → handleViewsLanding (MRU 302 or shell)
|
||||||
|
// GET /views/{slug} → handleViewRender (saved or system)
|
||||||
|
// GET /views/new → handleViewEditor (blank)
|
||||||
|
// GET /views/{slug}/edit → handleViewEditor (existing)
|
||||||
|
// POST /views → handleViewCreate
|
||||||
|
// POST /views/{slug} → handleViewUpdate
|
||||||
|
// POST /views/{slug}/delete → handleViewDelete
|
||||||
|
// POST /views/reorder → handleViewReorder (slice G — wired now,
|
||||||
|
// used in v2)
|
||||||
|
|
||||||
|
// handleViewsLanding implements m's Q5 pick: 302 to the most-recently-used
|
||||||
|
// view if any, else render the onboarding shell listing every saved view.
|
||||||
|
func (s *Server) handleViewsLanding(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Query().Get("nodefault") != "1" {
|
||||||
|
mr, err := s.Store.MostRecentView(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
s.Logger.Warn("views landing: mru", "err", err)
|
||||||
|
} else if mr != nil {
|
||||||
|
http.Redirect(w, r, "/views/"+mr.Slug, http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
views, err := s.Store.ListViews(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
s.fail(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.render(w, r, "views_landing", map[string]any{
|
||||||
|
"Title": "views",
|
||||||
|
"Views": views,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleViewRender resolves a slug into either a user view (Slice A
|
||||||
|
// schema) or a system view (Slice C), then renders the appropriate
|
||||||
|
// template. The render path also fire-and-forgets a TouchView so the
|
||||||
|
// view climbs the MRU ladder for the next /views landing redirect.
|
||||||
|
//
|
||||||
|
// Slice B implementation: only user views are wired; system views
|
||||||
|
// resolve via LookupSystemView (added in Slice C) and 404 in this slice
|
||||||
|
// when the slug is unknown.
|
||||||
|
func (s *Server) handleViewRender(w http.ResponseWriter, r *http.Request) {
|
||||||
|
slug := r.PathValue("slug")
|
||||||
|
if slug == "" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v, err := s.Store.GetView(r.Context(), slug)
|
||||||
|
if errors.Is(err, store.ErrViewNotFound) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
s.fail(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.Store.TouchView(r.Context(), slug); err != nil {
|
||||||
|
s.Logger.Warn("touch view", "slug", slug, "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the saved spec.
|
||||||
|
filter, viewType, groupBy := decodeViewSpec(v.FilterJSON)
|
||||||
|
// Allow URL chip overlay so chip clicks inside a saved view narrow
|
||||||
|
// further. The page chip URLs round-trip ?view= via the URL anchor
|
||||||
|
// added in slice E's sidebar wiring; here we just respect anything
|
||||||
|
// the user typed in the query.
|
||||||
|
urlFilter := ParseTreeFilter(r.URL.Query())
|
||||||
|
overlayURLOntoSavedFilter(&filter, urlFilter, r.URL.Query())
|
||||||
|
if raw := strings.TrimSpace(r.URL.Query().Get("view_type")); raw != "" {
|
||||||
|
viewType = raw
|
||||||
|
}
|
||||||
|
if raw := strings.TrimSpace(r.URL.Query().Get("group_by")); raw != "" {
|
||||||
|
groupBy = raw
|
||||||
|
}
|
||||||
|
|
||||||
|
s.renderViewPage(w, r, v, filter, viewType, groupBy)
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderViewPage runs the shared render path for a resolved view (user
|
||||||
|
// view or future system view). Slice B reuses the tree handler's
|
||||||
|
// rendering pieces — list / card / kanban share the tree-section
|
||||||
|
// dispatch shape. Calendar / timeline view_types fall back to list in
|
||||||
|
// slice B; slice D wires their dedicated templates.
|
||||||
|
func (s *Server) renderViewPage(w http.ResponseWriter, r *http.Request, v *store.View, filter TreeFilter, viewType, groupBy string) {
|
||||||
|
items, err := s.Store.ListAll(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
s.fail(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tags, err := s.Store.AllTags(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
s.fail(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
linkKinds, err := s.linkKindsByItem(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
s.fail(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
viewSet := PageViewTypes("/")
|
||||||
|
if viewType == "" {
|
||||||
|
viewType = viewSet.Default
|
||||||
|
}
|
||||||
|
viewType = viewSet.Resolve(viewType)
|
||||||
|
roots, orphans, total, orphanN, matched := applyTreeFilter(items, filter, linkKinds)
|
||||||
|
base := "/views/" + v.Slug
|
||||||
|
counts := computeChipCounts(items, filter, linkKinds, tags, base)
|
||||||
|
cardItems := flatMatchedItems(items, filter, linkKinds)
|
||||||
|
if groupBy == "" {
|
||||||
|
groupBy = ParseGroupBy(r.URL.Query())
|
||||||
|
}
|
||||||
|
kanban := BuildKanbanBoard(cardItems, groupBy)
|
||||||
|
groupByChips := GroupByChips(base, filter, groupBy)
|
||||||
|
data := map[string]any{
|
||||||
|
"Title": v.Name,
|
||||||
|
"View": v,
|
||||||
|
"Roots": roots,
|
||||||
|
"Orphans": orphans,
|
||||||
|
"Total": total,
|
||||||
|
"OrphanN": orphanN,
|
||||||
|
"Matched": matched,
|
||||||
|
"AllTags": tags,
|
||||||
|
"Filter": filter,
|
||||||
|
"Counts": counts,
|
||||||
|
"Projects": parentOptionsFromItems(items),
|
||||||
|
"BasePath": base,
|
||||||
|
"ProjectChipTarget": "#tree-section",
|
||||||
|
"ViewType": viewType,
|
||||||
|
"ViewTypeChips": ViewTypeChips(base, filter, viewType),
|
||||||
|
"CardItems": cardItems,
|
||||||
|
"Kanban": kanban,
|
||||||
|
"GroupBy": groupBy,
|
||||||
|
"GroupByChips": groupByChips,
|
||||||
|
"ActiveTags": filter.Tags,
|
||||||
|
}
|
||||||
|
if r.Header.Get("HX-Request") == "true" {
|
||||||
|
s.render(w, r, "tree_section", data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.render(w, r, "view_render", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleViewEditor renders the create / edit form. Slice B ships a
|
||||||
|
// minimal placeholder; Slice D rebuilds the form with the chip strip
|
||||||
|
// + slug derivation + icon picker.
|
||||||
|
func (s *Server) handleViewEditor(w http.ResponseWriter, r *http.Request) {
|
||||||
|
slug := r.PathValue("slug")
|
||||||
|
var (
|
||||||
|
view *store.View
|
||||||
|
err error
|
||||||
|
title = "new view"
|
||||||
|
)
|
||||||
|
if slug != "" {
|
||||||
|
view, err = s.Store.GetView(r.Context(), slug)
|
||||||
|
if errors.Is(err, store.ErrViewNotFound) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
s.fail(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
title = "edit " + view.Name
|
||||||
|
}
|
||||||
|
filterQuery := ""
|
||||||
|
currentViewType := "list"
|
||||||
|
if view != nil {
|
||||||
|
f, vt, _ := decodeViewSpec(view.FilterJSON)
|
||||||
|
filterQuery = f.QueryString()
|
||||||
|
if vt != "" {
|
||||||
|
currentViewType = vt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.render(w, r, "view_editor", map[string]any{
|
||||||
|
"Title": title,
|
||||||
|
"View": view,
|
||||||
|
"FilterQuery": filterQuery,
|
||||||
|
"ViewTypes": []string{ViewTypeList, ViewTypeCard, ViewTypeKanban},
|
||||||
|
"CurrentVT": currentViewType,
|
||||||
|
"GroupByOptions": []string{"", "status", "area", "tag", "management"},
|
||||||
|
"SortDirOptions": []string{"", "asc", "desc"},
|
||||||
|
"IconKeys": IconRegistryKeys(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleViewCreate accepts the create form POST.
|
||||||
|
func (s *Server) handleViewCreate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
s.fail(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
in, err := viewInputFromForm(r.PostForm)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v, err := s.Store.CreateView(r.Context(), in)
|
||||||
|
if err != nil {
|
||||||
|
s.writeViewError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/views/"+v.Slug, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleViewUpdate accepts the edit form POST.
|
||||||
|
func (s *Server) handleViewUpdate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
slug := r.PathValue("slug")
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
s.fail(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
in, err := viewInputFromForm(r.PostForm)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v, err := s.Store.UpdateView(r.Context(), slug, in)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, store.ErrViewNotFound) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.writeViewError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/views/"+v.Slug, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleViewDelete soft-… nope. New schema is hard-delete (no
|
||||||
|
// deleted_at). One POST removes the row.
|
||||||
|
func (s *Server) handleViewDelete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
slug := r.PathValue("slug")
|
||||||
|
if err := s.Store.DeleteView(r.Context(), slug); err != nil {
|
||||||
|
if errors.Is(err, store.ErrViewNotFound) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.fail(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/views", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleViewReorder takes a comma-separated slug list and applies new
|
||||||
|
// sort_order values. Wired now so slice G's drag UI has a target.
|
||||||
|
func (s *Server) handleViewReorder(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
s.fail(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
raw := strings.TrimSpace(r.PostForm.Get("slugs"))
|
||||||
|
if raw == "" {
|
||||||
|
http.Error(w, "slugs is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slugs := strings.Split(raw, ",")
|
||||||
|
for i, slug := range slugs {
|
||||||
|
slugs[i] = strings.TrimSpace(slug)
|
||||||
|
}
|
||||||
|
if err := s.Store.ReorderViews(r.Context(), slugs); err != nil {
|
||||||
|
s.fail(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeViewError maps the typed store errors to friendly HTTP status +
|
||||||
|
// banner copy. Falls back to 400 for anything else.
|
||||||
|
func (s *Server) writeViewError(w http.ResponseWriter, err error) {
|
||||||
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, store.ErrViewSlugFormat):
|
||||||
|
http.Error(w, "slug must match ^[a-z0-9][a-z0-9-]{0,62}$ (lowercase, no underscores, no leading dash)", http.StatusBadRequest)
|
||||||
|
case errors.Is(err, store.ErrViewSlugReserved):
|
||||||
|
http.Error(w, "slug is reserved (system views and top-level routes shadow it)", http.StatusBadRequest)
|
||||||
|
case errors.Is(err, store.ErrViewSlugTaken):
|
||||||
|
http.Error(w, "slug already exists — pick a different one", http.StatusConflict)
|
||||||
|
default:
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// viewInputFromForm decodes the create/edit form. Slug + name are
|
||||||
|
// required; the rest defaults sensibly. filter_query is optional and
|
||||||
|
// canonicalises into filter_json on save (URL-query form is what the
|
||||||
|
// editor's chip strip emits in slice D).
|
||||||
|
func viewInputFromForm(form url.Values) (store.ViewInput, error) {
|
||||||
|
in := store.ViewInput{
|
||||||
|
Slug: strings.TrimSpace(form.Get("slug")),
|
||||||
|
Name: strings.TrimSpace(form.Get("name")),
|
||||||
|
SortField: strings.TrimSpace(form.Get("sort_field")),
|
||||||
|
SortDir: strings.TrimSpace(form.Get("sort_dir")),
|
||||||
|
GroupBy: strings.TrimSpace(form.Get("group_by")),
|
||||||
|
ShowCount: form.Get("show_count") == "1",
|
||||||
|
}
|
||||||
|
if iconRaw := strings.TrimSpace(form.Get("icon")); iconRaw != "" {
|
||||||
|
in.Icon = &iconRaw
|
||||||
|
}
|
||||||
|
viewType := strings.TrimSpace(form.Get("view_type"))
|
||||||
|
if viewType == "" {
|
||||||
|
viewType = ViewTypeList
|
||||||
|
}
|
||||||
|
fq := strings.TrimSpace(form.Get("filter_query"))
|
||||||
|
filterJSON, err := encodeFilterToJSON(fq, viewType)
|
||||||
|
if err != nil {
|
||||||
|
return in, fmt.Errorf("filter_query: %w", err)
|
||||||
|
}
|
||||||
|
in.FilterJSON = filterJSON
|
||||||
|
return in, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// encodeFilterToJSON turns a URL-query-form filter + view_type into the
|
||||||
|
// canonical filter_json shape stored on the view. view_type lives inside
|
||||||
|
// the JSON per m's Q2 pick.
|
||||||
|
func encodeFilterToJSON(query, viewType string) ([]byte, error) {
|
||||||
|
q, err := url.ParseQuery(strings.TrimPrefix(query, "?"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
f := ParseTreeFilter(q)
|
||||||
|
payload := map[string]any{
|
||||||
|
"view_type": viewType,
|
||||||
|
}
|
||||||
|
if f.Q != "" {
|
||||||
|
payload["q"] = f.Q
|
||||||
|
}
|
||||||
|
if len(f.Tags) > 0 {
|
||||||
|
payload["tags"] = f.Tags
|
||||||
|
}
|
||||||
|
if len(f.Management) > 0 {
|
||||||
|
payload["management"] = f.Management
|
||||||
|
}
|
||||||
|
if !(len(f.Status) == 1 && f.Status[0] == "active") {
|
||||||
|
payload["status"] = f.Status
|
||||||
|
}
|
||||||
|
if len(f.HasLinks) > 0 {
|
||||||
|
payload["has_links"] = f.HasLinks
|
||||||
|
}
|
||||||
|
if f.Public != nil {
|
||||||
|
payload["public"] = *f.Public
|
||||||
|
}
|
||||||
|
if f.ShowArchived {
|
||||||
|
payload["show_archived"] = true
|
||||||
|
}
|
||||||
|
if f.ProjectPath != "" {
|
||||||
|
payload["project_path"] = f.ProjectPath
|
||||||
|
if !f.IncludeDescendants {
|
||||||
|
payload["include_descendants"] = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return json.Marshal(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeViewSpec parses filter_json into a TreeFilter + view_type +
|
||||||
|
// group_by. Inverse of encodeFilterToJSON.
|
||||||
|
func decodeViewSpec(filterJSON []byte) (TreeFilter, string, string) {
|
||||||
|
f := TreeFilter{
|
||||||
|
Status: []string{"active"},
|
||||||
|
IncludeDescendants: true,
|
||||||
|
}
|
||||||
|
viewType := ""
|
||||||
|
groupBy := ""
|
||||||
|
if len(filterJSON) == 0 {
|
||||||
|
return f, viewType, groupBy
|
||||||
|
}
|
||||||
|
payload := map[string]any{}
|
||||||
|
if err := json.Unmarshal(filterJSON, &payload); err != nil {
|
||||||
|
return f, viewType, groupBy
|
||||||
|
}
|
||||||
|
if v, ok := payload["view_type"].(string); ok {
|
||||||
|
viewType = v
|
||||||
|
}
|
||||||
|
if v, ok := payload["group_by"].(string); ok {
|
||||||
|
groupBy = v
|
||||||
|
}
|
||||||
|
if v, ok := payload["q"].(string); ok {
|
||||||
|
f.Q = v
|
||||||
|
}
|
||||||
|
if v, ok := payload["tags"].([]any); ok {
|
||||||
|
f.Tags = anySliceToStrings(v)
|
||||||
|
}
|
||||||
|
if v, ok := payload["management"].([]any); ok {
|
||||||
|
f.Management = anySliceToStrings(v)
|
||||||
|
}
|
||||||
|
if v, ok := payload["status"].([]any); ok {
|
||||||
|
f.Status = anySliceToStrings(v)
|
||||||
|
if len(f.Status) == 0 {
|
||||||
|
f.Status = []string{"active"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v, ok := payload["has_links"].([]any); ok {
|
||||||
|
f.HasLinks = anySliceToStrings(v)
|
||||||
|
}
|
||||||
|
if v, ok := payload["public"].(bool); ok {
|
||||||
|
f.Public = &v
|
||||||
|
}
|
||||||
|
if v, ok := payload["show_archived"].(bool); ok && v {
|
||||||
|
f.ShowArchived = true
|
||||||
|
}
|
||||||
|
if v, ok := payload["project_path"].(string); ok {
|
||||||
|
f.ProjectPath = v
|
||||||
|
}
|
||||||
|
if v, ok := payload["include_descendants"].(bool); ok {
|
||||||
|
f.IncludeDescendants = v
|
||||||
|
}
|
||||||
|
return f, viewType, groupBy
|
||||||
|
}
|
||||||
|
|
||||||
|
// overlayURLOntoSavedFilter applies URL-query chip values on top of the
|
||||||
|
// saved-view baseline. Same pattern the 5i fix-shift had (URL overrides
|
||||||
|
// saved); slice B reintroduces it here on the /views/{slug} render path.
|
||||||
|
func overlayURLOntoSavedFilter(base *TreeFilter, urlFilter TreeFilter, q url.Values) {
|
||||||
|
if q.Get("q") != "" {
|
||||||
|
base.Q = urlFilter.Q
|
||||||
|
}
|
||||||
|
if _, ok := q["tag"]; ok {
|
||||||
|
base.Tags = urlFilter.Tags
|
||||||
|
}
|
||||||
|
if _, ok := q["mgmt"]; ok {
|
||||||
|
base.Management = urlFilter.Management
|
||||||
|
}
|
||||||
|
if _, ok := q["status"]; ok {
|
||||||
|
base.Status = urlFilter.Status
|
||||||
|
}
|
||||||
|
if _, ok := q["has"]; ok {
|
||||||
|
base.HasLinks = urlFilter.HasLinks
|
||||||
|
}
|
||||||
|
if q.Get("show-archived") != "" {
|
||||||
|
base.ShowArchived = urlFilter.ShowArchived
|
||||||
|
}
|
||||||
|
if q.Get("public") != "" {
|
||||||
|
base.Public = urlFilter.Public
|
||||||
|
}
|
||||||
|
if q.Get("project") != "" {
|
||||||
|
base.ProjectPath = urlFilter.ProjectPath
|
||||||
|
}
|
||||||
|
if q.Get("project_descendants") != "" {
|
||||||
|
base.IncludeDescendants = urlFilter.IncludeDescendants
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func anySliceToStrings(in []any) []string {
|
||||||
|
out := make([]string, 0, len(in))
|
||||||
|
for _, v := range in {
|
||||||
|
if s, ok := v.(string); ok {
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|||||||
197
web/views_test.go
Normal file
197
web/views_test.go
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
package web_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestViewsLandingOnboarding asserts that GET /views with no views and no
|
||||||
|
// MRU renders the onboarding shell ("No saved views yet" + "+ New view").
|
||||||
|
func TestViewsLandingOnboarding(t *testing.T) {
|
||||||
|
srv, pool := mustServer(t)
|
||||||
|
defer pool.Close()
|
||||||
|
h := srv.Routes()
|
||||||
|
// Clear any leftover touched views from prior runs so the MRU 302
|
||||||
|
// doesn't fire and steal the response.
|
||||||
|
if _, err := pool.Exec(context.Background(),
|
||||||
|
`UPDATE projax.views SET last_used_at = NULL`); err != nil {
|
||||||
|
t.Fatalf("reset mru: %v", err)
|
||||||
|
}
|
||||||
|
// Also clear ALL views so the onboarding shell renders (othewise the
|
||||||
|
// landing still ListViews-displays them).
|
||||||
|
if _, err := pool.Exec(context.Background(), `DELETE FROM projax.views`); err != nil {
|
||||||
|
t.Fatalf("clear views: %v", err)
|
||||||
|
}
|
||||||
|
code, body := get(t, h, "/views")
|
||||||
|
if code != 200 {
|
||||||
|
t.Fatalf("GET /views status=%d body=%q", code, body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "No saved views yet") {
|
||||||
|
t.Error("onboarding shell should surface the no-views nudge")
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, `href="/views/new"`) {
|
||||||
|
t.Error("onboarding shell should link to /views/new")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestViewsLandingMRURedirects asserts that GET /views 302s to the most
|
||||||
|
// recently used view when one exists.
|
||||||
|
func TestViewsLandingMRURedirects(t *testing.T) {
|
||||||
|
srv, pool := mustServer(t)
|
||||||
|
defer pool.Close()
|
||||||
|
h := srv.Routes()
|
||||||
|
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "")
|
||||||
|
slug := "p5j-b-landing-" + stamp
|
||||||
|
defer pool.Exec(context.Background(), `DELETE FROM projax.views WHERE slug = $1`, slug)
|
||||||
|
// Seed + touch.
|
||||||
|
if _, err := pool.Exec(context.Background(), `
|
||||||
|
INSERT INTO projax.views (slug, name, filter_json, last_used_at)
|
||||||
|
VALUES ($1, 'P5j B Landing', '{"view_type":"list"}'::jsonb, now())`, slug); err != nil {
|
||||||
|
t.Fatalf("seed view: %v", err)
|
||||||
|
}
|
||||||
|
code, body := get(t, h, "/views")
|
||||||
|
if code != 302 {
|
||||||
|
t.Errorf("GET /views status=%d (want 302 to MRU); body=%q", code, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestViewRenderShowsSavedView asserts that GET /views/{slug} renders the
|
||||||
|
// view's name + slug in the header and the tree-section body.
|
||||||
|
func TestViewRenderShowsSavedView(t *testing.T) {
|
||||||
|
srv, pool := mustServer(t)
|
||||||
|
defer pool.Close()
|
||||||
|
h := srv.Routes()
|
||||||
|
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "")
|
||||||
|
slug := "p5j-b-render-" + stamp
|
||||||
|
defer pool.Exec(context.Background(), `DELETE FROM projax.views WHERE slug = $1`, slug)
|
||||||
|
if _, err := pool.Exec(context.Background(), `
|
||||||
|
INSERT INTO projax.views (slug, name, filter_json)
|
||||||
|
VALUES ($1, 'P5j B Render', '{"view_type":"card"}'::jsonb)`, slug); err != nil {
|
||||||
|
t.Fatalf("seed: %v", err)
|
||||||
|
}
|
||||||
|
code, body := get(t, h, "/views/"+slug)
|
||||||
|
if code != 200 {
|
||||||
|
t.Fatalf("GET /views/<slug> status=%d body=%q", code, body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "P5j B Render") {
|
||||||
|
t.Error("render should surface the view's name")
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, `/views/`+slug) {
|
||||||
|
t.Error("render should surface the view's slug in the header")
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, `class="tree-card-grid"`) {
|
||||||
|
t.Error("view_type=card should render the card grid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestViewRender404OnUnknownSlug — an unknown slug returns 404, not a
|
||||||
|
// silent fallback to the tree.
|
||||||
|
func TestViewRender404OnUnknownSlug(t *testing.T) {
|
||||||
|
srv, pool := mustServer(t)
|
||||||
|
defer pool.Close()
|
||||||
|
h := srv.Routes()
|
||||||
|
code, _ := get(t, h, "/views/this-slug-does-not-exist-anywhere-9876")
|
||||||
|
if code != 404 {
|
||||||
|
t.Errorf("unknown slug should 404, got %d", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestViewCreateAndDelete — POST /views creates; POST /views/<slug>/delete
|
||||||
|
// removes. Verifies the slug-format error path too.
|
||||||
|
func TestViewCreateAndDelete(t *testing.T) {
|
||||||
|
srv, pool := mustServer(t)
|
||||||
|
defer pool.Close()
|
||||||
|
h := srv.Routes()
|
||||||
|
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "")
|
||||||
|
slug := "p5j-b-crud-" + stamp
|
||||||
|
defer pool.Exec(context.Background(), `DELETE FROM projax.views WHERE slug = $1`, slug)
|
||||||
|
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("slug", slug)
|
||||||
|
form.Set("name", "P5j B CRUD")
|
||||||
|
form.Set("view_type", "list")
|
||||||
|
form.Set("filter_query", "tag=work")
|
||||||
|
code, _ := post(t, h, "/views", form)
|
||||||
|
if code != 303 {
|
||||||
|
t.Fatalf("create status=%d want 303", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reserved-slug 400.
|
||||||
|
form2 := url.Values{}
|
||||||
|
form2.Set("slug", "dashboard")
|
||||||
|
form2.Set("name", "Should be rejected")
|
||||||
|
form2.Set("view_type", "list")
|
||||||
|
code, body := post(t, h, "/views", form2)
|
||||||
|
if code != 400 {
|
||||||
|
t.Errorf("reserved-slug create should 400, got %d body=%q", code, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete.
|
||||||
|
code, _ = post(t, h, "/views/"+slug+"/delete", url.Values{})
|
||||||
|
if code != 303 {
|
||||||
|
t.Errorf("delete status=%d want 303", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSavedViewFilterOverlay — chip params on /views/<slug>?tag=x narrow
|
||||||
|
// the saved filter. Verifies the slice B render-path overlay.
|
||||||
|
func TestSavedViewFilterOverlay(t *testing.T) {
|
||||||
|
srv, pool := mustServer(t)
|
||||||
|
defer pool.Close()
|
||||||
|
h := srv.Routes()
|
||||||
|
ctx := context.Background()
|
||||||
|
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "")
|
||||||
|
slug := "p5j-b-overlay-" + stamp
|
||||||
|
devSlug := "p5j-b-overlay-d-" + stamp
|
||||||
|
homeSlug := "p5j-b-overlay-h-" + stamp
|
||||||
|
defer pool.Exec(context.Background(), `DELETE FROM projax.views WHERE slug = $1`, slug)
|
||||||
|
|
||||||
|
var dev, home string
|
||||||
|
if err := pool.QueryRow(ctx, `select id from projax.items where slug='dev' and cardinality(parent_ids)=0`).Scan(&dev); err != nil {
|
||||||
|
t.Fatalf("dev: %v", err)
|
||||||
|
}
|
||||||
|
if err := pool.QueryRow(ctx, `select id from projax.items where slug='home' and cardinality(parent_ids)=0`).Scan(&home); err != nil {
|
||||||
|
t.Fatalf("home: %v", err)
|
||||||
|
}
|
||||||
|
var devID, homeID string
|
||||||
|
if err := pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO projax.items (kind, title, slug, parent_ids, tags)
|
||||||
|
VALUES (array['project']::text[], 'P5jB Dev', $1, ARRAY[$2]::uuid[], ARRAY['work'])
|
||||||
|
RETURNING id`, devSlug, dev).Scan(&devID); err != nil {
|
||||||
|
t.Fatalf("seed dev item: %v", err)
|
||||||
|
}
|
||||||
|
if err := pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO projax.items (kind, title, slug, parent_ids, tags)
|
||||||
|
VALUES (array['project']::text[], 'P5jB Home', $1, ARRAY[$2]::uuid[], ARRAY['home'])
|
||||||
|
RETURNING id`, homeSlug, home).Scan(&homeID); err != nil {
|
||||||
|
t.Fatalf("seed home item: %v", err)
|
||||||
|
}
|
||||||
|
defer pool.Exec(context.Background(), `DELETE FROM projax.items WHERE id IN ($1,$2)`, devID, homeID)
|
||||||
|
|
||||||
|
if _, err := pool.Exec(ctx, `
|
||||||
|
INSERT INTO projax.views (slug, name, filter_json)
|
||||||
|
VALUES ($1, 'P5jB Overlay', '{"view_type":"list"}'::jsonb)`, slug); err != nil {
|
||||||
|
t.Fatalf("seed view: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
devLink := `href="/i/dev.` + devSlug + `"`
|
||||||
|
homeLink := `href="/i/home.` + homeSlug + `"`
|
||||||
|
|
||||||
|
_, base := get(t, h, "/views/"+slug)
|
||||||
|
if !strings.Contains(base, devLink) {
|
||||||
|
t.Error("saved view without tag should show dev row")
|
||||||
|
}
|
||||||
|
if !strings.Contains(base, homeLink) {
|
||||||
|
t.Error("saved view without tag should show home row")
|
||||||
|
}
|
||||||
|
_, narrowed := get(t, h, "/views/"+slug+"?tag=work")
|
||||||
|
if !strings.Contains(narrowed, devLink) {
|
||||||
|
t.Error("URL chip tag=work should keep dev (work-tagged)")
|
||||||
|
}
|
||||||
|
if strings.Contains(narrowed, homeLink) {
|
||||||
|
t.Error("URL chip tag=work should hide home")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user