Compare commits
17 Commits
mai/kahn/p
...
a9f062a67e
| Author | SHA1 | Date | |
|---|---|---|---|
| a9f062a67e | |||
| 173d7ddbb2 | |||
| 731f443569 | |||
| 157c4e659b | |||
| 547d6f77f6 | |||
| 788479c6cb | |||
| a0d6217ebf | |||
| 311cf943bc | |||
| abb329a686 | |||
| b15c222727 | |||
| 590bb28063 | |||
| d0e0669fff | |||
| 59a89ef044 | |||
| 93b751d383 | |||
| 773194c1b7 | |||
| 79fc8b34c9 | |||
| 0cf630d3aa |
@@ -55,9 +55,14 @@ type Todo struct {
|
||||
Due *time.Time
|
||||
Priority int
|
||||
LastModified *time.Time
|
||||
URL string // absolute URL of the .ics resource on the server
|
||||
ETag string // server-issued ETag; pass to PutTodo/DeleteTodo as If-Match
|
||||
Raw string // raw VCALENDAR ICS as returned by the server, preserved for in-place edits
|
||||
// Categories carries the RFC 5545 CATEGORIES property as a flat
|
||||
// slice (already comma-split, trimmed). Phase 5j uses entries
|
||||
// prefixed `projax:<primary-path>` to tag VTODOs to projax items —
|
||||
// see HasProjaxTag + ProjaxCategoryFor in this package.
|
||||
Categories []string
|
||||
URL string // absolute URL of the .ics resource on the server
|
||||
ETag string // server-issued ETag; pass to PutTodo/DeleteTodo as If-Match
|
||||
Raw string // raw VCALENDAR ICS as returned by the server, preserved for in-place edits
|
||||
}
|
||||
|
||||
// Event is one VEVENT returned by ListEvents. Phase 3l: read-only, no
|
||||
|
||||
@@ -54,11 +54,70 @@ func parseVTodos(ics string) []Todo {
|
||||
if t, ok := parseICalTime(val); ok {
|
||||
cur.LastModified = &t
|
||||
}
|
||||
case "CATEGORIES":
|
||||
// CATEGORIES is comma-separated per RFC 5545. Some clients emit
|
||||
// multiple CATEGORIES lines; we merge by appending. The unescape
|
||||
// is per-entry because commas inside a category value MUST be
|
||||
// escaped (`\,`), so we split on bare commas only after unescape.
|
||||
for _, raw := range strings.Split(val, ",") {
|
||||
t := strings.TrimSpace(unescapeText(raw))
|
||||
if t == "" {
|
||||
continue
|
||||
}
|
||||
cur.Categories = append(cur.Categories, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ProjaxCategoryFor returns the projax-namespaced CATEGORIES entry for
|
||||
// the given primary-path (e.g. "projax:admin.vacations.greece"). Used by
|
||||
// both the write side (tag-on-create) and the read side (per-item filter).
|
||||
func ProjaxCategoryFor(primaryPath string) string {
|
||||
return "projax:" + primaryPath
|
||||
}
|
||||
|
||||
// HasProjaxTag reports whether the VTODO carries any `projax:` category.
|
||||
// Used to decide whether the per-item filter kicks in: a list with at
|
||||
// least one projax: tag is "managed" by projax and the detail page only
|
||||
// shows todos matching THIS item's path; a list with zero projax: tags
|
||||
// is a legacy/unmanaged list and the detail page shows everything.
|
||||
func HasProjaxTag(t Todo) bool {
|
||||
for _, c := range t.Categories {
|
||||
if strings.HasPrefix(c, "projax:") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// HasProjaxTagFor reports whether the VTODO carries the specific
|
||||
// `projax:<primaryPath>` category. A todo can carry multiple projax: tags
|
||||
// (when it belongs to multiple projax items) — any match returns true.
|
||||
func HasProjaxTagFor(t Todo, primaryPath string) bool {
|
||||
want := ProjaxCategoryFor(primaryPath)
|
||||
for _, c := range t.Categories {
|
||||
if c == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// AnyTodoHasProjaxTag reports whether the slice contains at least one
|
||||
// projax-tagged VTODO. The detail page uses this to decide between the
|
||||
// projax-managed filter (show only matching) and the legacy unmanaged
|
||||
// path (show all).
|
||||
func AnyTodoHasProjaxTag(todos []Todo) bool {
|
||||
for _, t := range todos {
|
||||
if HasProjaxTag(t) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// parseVEvents extracts every VEVENT block from a calendar-data string.
|
||||
// Mirrors parseVTodos but for read-only event listing (no writeback). DTSTART
|
||||
// with VALUE=DATE marks the event all-day; the parser inspects the raw line
|
||||
@@ -296,6 +355,13 @@ type VTodoEdit struct {
|
||||
Due *time.Time
|
||||
ClearDue bool
|
||||
Priority *int
|
||||
// Categories: optional CATEGORIES list. BuildVTodoICS writes them
|
||||
// directly on a fresh VTODO. ApplyVTodoEdit intentionally ignores
|
||||
// this field — existing categories pass through unchanged via the
|
||||
// unknown-property preserve path, which is what every edit/complete/
|
||||
// delete flow wants. Tag-on-create is the only write path that
|
||||
// uses it.
|
||||
Categories []string
|
||||
}
|
||||
|
||||
// BuildVTodoICS serialises a fresh VTODO as a complete VCALENDAR document,
|
||||
@@ -336,6 +402,15 @@ func BuildVTodoICS(uid string, e VTodoEdit) string {
|
||||
if e.Priority != nil {
|
||||
lines = append(lines, fmt.Sprintf("PRIORITY:%d", *e.Priority))
|
||||
}
|
||||
if len(e.Categories) > 0 {
|
||||
// RFC 5545 CATEGORIES — comma-separated, single line. Escape commas
|
||||
// inside individual entries so the round-trip survives parseVTodos.
|
||||
escaped := make([]string, 0, len(e.Categories))
|
||||
for _, c := range e.Categories {
|
||||
escaped = append(escaped, escapeText(c))
|
||||
}
|
||||
lines = append(lines, "CATEGORIES:"+strings.Join(escaped, ","))
|
||||
}
|
||||
lines = append(lines, "END:VTODO", "END:VCALENDAR")
|
||||
return joinICS(lines)
|
||||
}
|
||||
|
||||
114
caldav/projax_tags_test.go
Normal file
114
caldav/projax_tags_test.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package caldav
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestProjaxCategoryFor pins the tag string format. The format is part
|
||||
// of the projax↔CalDAV contract — `projax:<primary-path>` — and other
|
||||
// tooling (admin triage, future migration scripts) will rely on the
|
||||
// prefix. A typo here silently breaks the per-item filter.
|
||||
func TestProjaxCategoryFor(t *testing.T) {
|
||||
got := ProjaxCategoryFor("admin.vacations.greece")
|
||||
want := "projax:admin.vacations.greece"
|
||||
if got != want {
|
||||
t.Errorf("ProjaxCategoryFor = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHasProjaxTagAndFor exercises the two read-side helpers that drive
|
||||
// the per-item filter on the detail page: HasProjaxTag (any projax: tag
|
||||
// at all) and HasProjaxTagFor (matches THIS path).
|
||||
func TestHasProjaxTagAndFor(t *testing.T) {
|
||||
tagged := Todo{Categories: []string{"home", "projax:admin.vacations.greece", "errands"}}
|
||||
if !HasProjaxTag(tagged) {
|
||||
t.Errorf("HasProjaxTag should fire for any projax: category")
|
||||
}
|
||||
if !HasProjaxTagFor(tagged, "admin.vacations.greece") {
|
||||
t.Errorf("HasProjaxTagFor should match exact projax:<path>")
|
||||
}
|
||||
if HasProjaxTagFor(tagged, "admin.vacations.spain") {
|
||||
t.Errorf("HasProjaxTagFor should NOT match a different path")
|
||||
}
|
||||
|
||||
multi := Todo{Categories: []string{"projax:work.proj1", "projax:work.proj2"}}
|
||||
if !HasProjaxTagFor(multi, "work.proj1") {
|
||||
t.Errorf("multi-tag todo should match first projax: tag")
|
||||
}
|
||||
if !HasProjaxTagFor(multi, "work.proj2") {
|
||||
t.Errorf("multi-tag todo should match second projax: tag")
|
||||
}
|
||||
|
||||
untagged := Todo{Categories: []string{"home", "errands"}}
|
||||
if HasProjaxTag(untagged) {
|
||||
t.Errorf("HasProjaxTag should be false on a no-projax: list")
|
||||
}
|
||||
if HasProjaxTagFor(untagged, "anything") {
|
||||
t.Errorf("HasProjaxTagFor must be false when no projax: tag exists")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAnyTodoHasProjaxTag drives the list-level managed-vs-legacy
|
||||
// decision in detailTodos. Untagged lists keep their pre-5j show-all
|
||||
// behaviour; one tagged todo flips the entire list into managed mode.
|
||||
func TestAnyTodoHasProjaxTag(t *testing.T) {
|
||||
none := []Todo{
|
||||
{Categories: []string{"home"}},
|
||||
{Categories: nil},
|
||||
}
|
||||
if AnyTodoHasProjaxTag(none) {
|
||||
t.Errorf("untagged list should NOT be projax-managed")
|
||||
}
|
||||
mixed := []Todo{
|
||||
{Categories: []string{"home"}},
|
||||
{Categories: []string{"projax:admin.vacations.greece"}},
|
||||
}
|
||||
if !AnyTodoHasProjaxTag(mixed) {
|
||||
t.Errorf("list with one projax-tagged todo should be projax-managed")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildVTodoICSEmitsCategories proves the tag-on-create path. The
|
||||
// Phase 5j write side passes Categories into VTodoEdit; BuildVTodoICS
|
||||
// must render the CATEGORIES line so the server-side round-trip
|
||||
// (parseVTodos picks it back up) carries the tag through.
|
||||
func TestBuildVTodoICSEmitsCategories(t *testing.T) {
|
||||
summary := "Buy gear"
|
||||
ics := BuildVTodoICS("uid-tagged", VTodoEdit{
|
||||
Summary: &summary,
|
||||
Categories: []string{"projax:admin.vacations.greece"},
|
||||
})
|
||||
if !strings.Contains(ics, "CATEGORIES:projax:admin.vacations.greece") {
|
||||
t.Errorf("BuildVTodoICS should emit CATEGORIES line, got:\n%s", ics)
|
||||
}
|
||||
// Round-trip: parse it back, the Categories slice must be populated.
|
||||
todos := parseVTodos(ics)
|
||||
if len(todos) != 1 {
|
||||
t.Fatalf("parseVTodos round-trip expected 1 todo, got %d", len(todos))
|
||||
}
|
||||
if !HasProjaxTagFor(todos[0], "admin.vacations.greece") {
|
||||
t.Errorf("round-trip lost CATEGORIES: %#v", todos[0].Categories)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseVTodosMultiCategory proves the parser handles RFC 5545
|
||||
// comma-separated CATEGORIES correctly (a single CATEGORIES line with
|
||||
// multiple values, not multiple CATEGORIES lines). This is the wire
|
||||
// shape Apple Calendar + Thunderbird + Radicale all emit.
|
||||
func TestParseVTodosMultiCategory(t *testing.T) {
|
||||
ics := "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nUID:multi\r\nSUMMARY:Multi\r\nSTATUS:NEEDS-ACTION\r\nCATEGORIES:home,projax:admin.vacations.greece,projax:work.someproj,errands\r\nEND:VTODO\r\nEND:VCALENDAR\r\n"
|
||||
todos := parseVTodos(ics)
|
||||
if len(todos) != 1 {
|
||||
t.Fatalf("expected 1 todo, got %d", len(todos))
|
||||
}
|
||||
want := []string{"home", "projax:admin.vacations.greece", "projax:work.someproj", "errands"}
|
||||
if len(todos[0].Categories) != len(want) {
|
||||
t.Fatalf("Categories = %v, want %v", todos[0].Categories, want)
|
||||
}
|
||||
for i, c := range todos[0].Categories {
|
||||
if c != want[i] {
|
||||
t.Errorf("Categories[%d] = %q, want %q", i, c, want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
101
db/migrations/0017_views_redesign.sql
Normal file
101
db/migrations/0017_views_redesign.sql
Normal file
@@ -0,0 +1,101 @@
|
||||
-- 0017_views_redesign.sql
|
||||
--
|
||||
-- Phase 5j Slice A: paliad-shape redesign of projax.views.
|
||||
--
|
||||
-- 5i (0016) modelled views as overlays on existing pages keyed by uuid.
|
||||
-- m's feedback: that's the wrong shape — views should be first-class
|
||||
-- pages at /views/{slug}, mirroring paliad's user_views model.
|
||||
--
|
||||
-- This migration HARD-REPLACES the 5i table. m's pick on Q10 (2026-05-29):
|
||||
-- hard-replace is fine because 5i was hours old with no persisted user
|
||||
-- data of value. Any rows present get dropped along with the table.
|
||||
--
|
||||
-- m's other picks worth marking inline:
|
||||
-- Q2 (2026-05-29): view_type lives INSIDE filter_json, not as a
|
||||
-- top-level column with a CHECK constraint. Keeps the
|
||||
-- schema lean — the renderer parses the JSON anyway.
|
||||
-- Q9 (2026-05-29): is_default_for column dropped entirely. MRU
|
||||
-- (last_used_at) replaces the per-page-default model.
|
||||
-- Q11 (2026-05-29): graph stays outside the views enum; no graph
|
||||
-- view_type ever lands in filter_json.
|
||||
|
||||
DROP TABLE IF EXISTS projax.views CASCADE;
|
||||
|
||||
CREATE TABLE projax.views (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
|
||||
-- URL-routable identifier. Application-layer validator enforces the
|
||||
-- regex `^[a-z0-9][a-z0-9-]{0,62}$` + a reserved-slug list (system
|
||||
-- slugs + top-level route segments). Globally unique — single-user
|
||||
-- v1; no user_id prefix.
|
||||
slug text NOT NULL,
|
||||
|
||||
-- Display name. Free-form; user picks whatever language they think in.
|
||||
-- Rendered verbatim in the sidebar.
|
||||
name text NOT NULL,
|
||||
|
||||
-- Frontend icon-registry key. NULL → default folder glyph. Length cap
|
||||
-- keeps stored value sane even if the registry is bypassed.
|
||||
icon text,
|
||||
|
||||
-- Canonical view definition. Includes view_type (per m's Q2 pick),
|
||||
-- plus the standard TreeFilter dimensions (q, tags, management, …),
|
||||
-- plus optional sort/group hints. Renderer parses the JSON; the DB
|
||||
-- never has to look inside.
|
||||
filter_json jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
|
||||
-- Sort + grouping hints used by the renderers (list/card/kanban).
|
||||
-- Kept as top-level columns so the editor can index them quickly,
|
||||
-- though they're conceptually part of the render spec.
|
||||
sort_field text,
|
||||
sort_dir text,
|
||||
group_by text,
|
||||
|
||||
-- Sidebar ordering. Server-assigned MAX+1 on create so two parallel
|
||||
-- inserts don't collide. Drag-reorder UI lands in slice G; this
|
||||
-- column is wired now so the data shape is stable.
|
||||
sort_order integer NOT NULL DEFAULT 0,
|
||||
|
||||
-- Opt-in count badge on the sidebar entry. Defaults false so casual
|
||||
-- views don't pay the COUNT(*) cost.
|
||||
show_count boolean NOT NULL DEFAULT false,
|
||||
|
||||
-- MRU landing on /views — `handleViewsLanding` 302s here when set.
|
||||
-- Touched fire-and-forget on every render.
|
||||
last_used_at timestamptz,
|
||||
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
CONSTRAINT views_sort_dir_chk
|
||||
CHECK (sort_dir IS NULL OR sort_dir IN ('asc','desc')),
|
||||
CONSTRAINT views_slug_format_chk
|
||||
CHECK (slug ~ '^[a-z0-9][a-z0-9-]{0,62}$')
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX views_slug_uniq ON projax.views (slug);
|
||||
CREATE INDEX views_sort_order_idx ON projax.views (sort_order, name);
|
||||
CREATE INDEX views_last_used_idx ON projax.views (last_used_at DESC NULLS LAST);
|
||||
|
||||
-- updated_at trigger. Re-created here (CREATE OR REPLACE on the function)
|
||||
-- because 0016 dropped with CASCADE above.
|
||||
CREATE OR REPLACE FUNCTION projax.views_touch_updated_at()
|
||||
RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
NEW.updated_at := now();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS views_touch_updated_at ON projax.views;
|
||||
CREATE TRIGGER views_touch_updated_at
|
||||
BEFORE UPDATE ON projax.views
|
||||
FOR EACH ROW EXECUTE FUNCTION projax.views_touch_updated_at();
|
||||
|
||||
DO $own$ BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'projax_admin') THEN
|
||||
EXECUTE 'ALTER TABLE projax.views OWNER TO projax_admin';
|
||||
EXECUTE 'ALTER FUNCTION projax.views_touch_updated_at() OWNER TO projax_admin';
|
||||
EXECUTE 'GRANT SELECT, INSERT, UPDATE, DELETE ON projax.views TO projax_admin';
|
||||
END IF;
|
||||
END $own$;
|
||||
496
docs/plans/views-redesign.md
Normal file
496
docs/plans/views-redesign.md
Normal file
@@ -0,0 +1,496 @@
|
||||
# Views redesign — paliad-shape first-class views (Phase 5j)
|
||||
|
||||
**Status**: Phase A design (this doc).
|
||||
**Branch**: `mai/kahn/phase-5j-views-redesign`.
|
||||
**Author**: kahn (inventor), 2026-05-26.
|
||||
**Source feedback** (m, 13:19 2026-05-26): *"It's not really what I wanted. It should like the paliad custom views, not of the existing views a variant but individually created views."*
|
||||
|
||||
**Replaces**: Phase 5i. Hours-old, no real data, drop-and-rebuild is the cleanest path.
|
||||
|
||||
---
|
||||
|
||||
## §1 — Diagnosis: why 5i diverged from intent
|
||||
|
||||
5i modelled views as an **overlay** on top of existing pages. The contract was:
|
||||
|
||||
> User opens `/?view=<uuid>` → the saved filter+view_type fields onto whatever the existing tree handler renders.
|
||||
|
||||
That choice flowed from m's original phrasing: "view types (card / list / calendar / kanban)" — which sounded like skin-on-top-of-pages. Implementation followed: TreeFilter grew a `ViewID`, an `applySavedView` overlay landed in the tree handler, the sidebar `Views` entry pointed to `/views` as a list-management page, and saved views had no URL of their own.
|
||||
|
||||
m's **actual** mental model, anchored in paliad: a view IS a page. The slug goes in the URL. System defaults (dashboard, calendar, timeline, ...) and user-created views share the same `/views/{slug}` route shape. Nothing is "an overlay" — views are first-class destinations, indexed in the sidebar, with their own editor.
|
||||
|
||||
The fix: tear out the 5i overlay code and rebuild around the paliad model. This redesign mirrors paliad's structure but adapts to projax's constraints (single-user, no auth.uid(), no RLS, existing route surface).
|
||||
|
||||
---
|
||||
|
||||
## §2 — paliad-shape data model for projax
|
||||
|
||||
### Schema (migration `0017_views_redesign.sql`)
|
||||
|
||||
**Recommendation: hard-replace.** Drop `projax.views` (created hours ago in 5i Slice D), recreate fresh. No real user data lost — at most a couple of throwaway saved-view rows from m's testing.
|
||||
|
||||
```sql
|
||||
DROP TABLE IF EXISTS projax.views CASCADE;
|
||||
|
||||
CREATE TABLE projax.views (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
slug text NOT NULL,
|
||||
name text NOT NULL,
|
||||
icon text, -- nullable; matches frontend icon registry
|
||||
filter_json jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
view_type text NOT NULL, -- card | list | calendar | kanban | timeline
|
||||
sort_field text,
|
||||
sort_dir text,
|
||||
group_by text,
|
||||
sort_order integer NOT NULL DEFAULT 0,
|
||||
show_count boolean NOT NULL DEFAULT false,
|
||||
last_used_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT views_view_type_chk
|
||||
CHECK (view_type IN ('card','list','calendar','kanban','timeline')),
|
||||
CONSTRAINT views_sort_dir_chk
|
||||
CHECK (sort_dir IS NULL OR sort_dir IN ('asc','desc')),
|
||||
CONSTRAINT views_kanban_needs_group
|
||||
CHECK (view_type <> 'kanban' OR group_by IS NOT NULL),
|
||||
CONSTRAINT views_slug_format_chk
|
||||
CHECK (slug ~ '^[a-z0-9][a-z0-9-]{0,62}$')
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX views_slug_uniq ON projax.views (slug);
|
||||
CREATE INDEX views_sort_order_idx ON projax.views (sort_order, name);
|
||||
|
||||
-- updated_at trigger reused from 0016 (kept under a new name or recreated).
|
||||
CREATE OR REPLACE FUNCTION projax.views_touch_updated_at()
|
||||
RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
NEW.updated_at := now();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
DROP TRIGGER IF EXISTS views_touch_updated_at ON projax.views;
|
||||
CREATE TRIGGER views_touch_updated_at
|
||||
BEFORE UPDATE ON projax.views
|
||||
FOR EACH ROW EXECUTE FUNCTION projax.views_touch_updated_at();
|
||||
```
|
||||
|
||||
### Key shifts from 5i
|
||||
|
||||
| field | 5i | 5j | reason |
|
||||
|---|---|---|---|
|
||||
| primary key | uuid only | uuid; **slug is the URL key** | paliad parity — URLs use slugs, not uuids |
|
||||
| slug | absent | required, unique, regex-validated | URL routability |
|
||||
| icon | absent | nullable text | sidebar icon picker |
|
||||
| sort_order | absent | server-assigned MAX+1 | drag-reorder; paliad parity |
|
||||
| show_count | absent | bool, opt-in | sidebar row-count badge; opt-in cost |
|
||||
| last_used_at | absent | nullable timestamptz | `/views` landing MRU redirect |
|
||||
| pinned | bool | **dropped** | `sort_order` subsumes the use case |
|
||||
| is_default_for | text page | **dropped** | per-page-default model gone; MRU replaces it |
|
||||
|
||||
### `filter_json` shape
|
||||
|
||||
Unchanged from 5i (the JSON shape stayed correct). Keys mirror TreeFilter dims: `q`, `tags[]`, `management[]`, `status[]`, `has_links[]`, `public`, `show_archived`, `project_path`, `include_descendants`. The shape is forward-compatible; new TreeFilter dimensions land without migrations.
|
||||
|
||||
`view_type` stays a top-level column (not inside `filter_json`) because the editor + sidebar both read it without needing to parse JSON.
|
||||
|
||||
### Single-user simplifications vs paliad
|
||||
|
||||
- **No `user_id` column** — projax is Tailscale-only single-user.
|
||||
- **No RLS** — same reason.
|
||||
- **`UNIQUE (slug)` is global**, not per-user.
|
||||
|
||||
If multi-user ever lands, the column + index gain a `user_id` prefix; the rest of the design holds.
|
||||
|
||||
---
|
||||
|
||||
## §3 — Reserved slugs (system views)
|
||||
|
||||
The big call: **do existing pages become system views, or do they stay distinct routes?**
|
||||
|
||||
### Three options
|
||||
|
||||
**(a) Keep current routes; add /views/{slug} for user views only.**
|
||||
- `/`, `/dashboard`, `/calendar`, `/timeline`, `/graph` stay exactly as today.
|
||||
- `/views/{slug}` is exclusively for user-created views.
|
||||
- Reserved-slug list is just `{new, edit}` (the literal route segments) + any future top-level URL we'd not want a user view to shadow.
|
||||
- **Cost**: nothing changes for muscle memory. User views are an additive concept beside existing pages.
|
||||
- **Drawback**: the conceptual asymmetry m flagged stays — system pages live at `/`/`/dashboard`, user views live at `/views/{slug}`. Two URL families.
|
||||
|
||||
**(b) Full migration. Existing pages become system views at `/views/{slug}`.**
|
||||
- New URLs: `/views/tree`, `/views/dashboard`, `/views/calendar`, `/views/timeline`, `/views/graph` (or drop graph from the unified shape — see §3.1).
|
||||
- Legacy `/`, `/dashboard`, etc. become 301 redirects to their `/views/{slug}` counterpart.
|
||||
- Reserved slugs: `{tree, dashboard, calendar, timeline, graph, new, edit, admin, login, logout, healthz, mcp, static, i, views}` — everything projax owns at the top level.
|
||||
- **Cost**: every internal link in templates needs updating; bookmarks 301 (fine); browser muscle memory absorbs after one shift.
|
||||
- **Benefit**: one URL family. The "create a new view" mental model is uniform with how system pages live.
|
||||
|
||||
**(c) Hybrid. Legacy routes stay; `/views/{slug}` aliases system pages and hosts user views.**
|
||||
- `/` keeps serving the tree; **also** `/views/tree` resolves to the same handler.
|
||||
- `/dashboard` keeps; also `/views/dashboard`. Etc.
|
||||
- Reserved slugs match (b) for the same coverage.
|
||||
- User views land at `/views/{their-slug}` alongside system slugs in one URL family.
|
||||
- **Cost**: small — system-view handlers register two route entries instead of one. No redirects to maintain.
|
||||
- **Benefit**: muscle memory + bookmark stability AND first-class /views/{slug} URL family. Two paths to the same render; user picks whichever they remember. If `/views/{slug}` catches on, a future shift can deprecate the legacy URLs cleanly.
|
||||
|
||||
### Inventor pick: (c) hybrid
|
||||
|
||||
**Reasoning**: m's bug report explicitly said "individually created views" — the gap was user-view first-classness, not legacy-URL banishment. (c) closes the gap with zero migration cost. (b) is cleaner architecturally but introduces avoidable churn; the upside (one URL family) doesn't outweigh the risk of breaking some link or muscle-memory in m's daily flow. (a) leaves the two-families asymmetry m's feedback was pointing at.
|
||||
|
||||
This is **Q1 in §9** — head should ratify or override before coder.
|
||||
|
||||
### §3.1 — Graph as a system view?
|
||||
|
||||
Graph is the DAG SVG render. It's NOT in the view_type enum (per 5i design, intentionally — graph is its own visualization, not a "list of items rendered as X"). Recommend: keep `/graph` and `/views/graph` (under (c)) but **graph is not a user-creatable view_type** — the create form omits it. Reserved slug `graph` blocks user views from clobbering it.
|
||||
|
||||
### Reserved-slug list (combining (c) + projax's existing top-level routes)
|
||||
|
||||
```go
|
||||
var reservedViewSlugs = []string{
|
||||
// System pages (also reachable via /views/<slug> as aliases under (c)):
|
||||
"tree", "dashboard", "calendar", "timeline", "graph",
|
||||
// /views sub-routes:
|
||||
"new", "edit",
|
||||
// Top-level application URLs:
|
||||
"admin", "login", "logout", "healthz", "mcp", "static", "i", "views",
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## §4 — Routes
|
||||
|
||||
For option (c). Under (b), drop the legacy entries; under (a), drop the `/views/{system-slug}` aliases.
|
||||
|
||||
| route | handler | renders | semantics |
|
||||
|---|---|---|---|
|
||||
| `GET /views` | `handleViewsLanding` | 302 to MRU view, else onboarding shell | landing |
|
||||
| `GET /views/{slug}` | `handleViewRender` | view template per view_type | render saved or system view |
|
||||
| `GET /views/new` | `handleViewEditor` | editor blank | editor — new |
|
||||
| `GET /views/{slug}/edit` | `handleViewEditor` | editor pre-filled | editor — edit existing |
|
||||
| `POST /views` | `handleViewCreate` | redirect to `/views/{slug}` | create |
|
||||
| `POST /views/{slug}` | `handleViewUpdate` | redirect to `/views/{slug}` | update |
|
||||
| `POST /views/{slug}/delete` | `handleViewDelete` | redirect to `/views` | delete |
|
||||
| `POST /views/reorder` | `handleViewReorder` | 204 / HTMX OK | drag-reorder (slice G) |
|
||||
| `POST /views/{slug}/touch` | `handleViewTouch` | 204 fire-and-forget | bump last_used_at on render |
|
||||
|
||||
The render path (`GET /views/{slug}`):
|
||||
1. Resolve slug. If a user view → load row. If a reserved system slug → load the corresponding code-resident `SystemView` struct.
|
||||
2. Touch `last_used_at` (user views only — system views don't track MRU per call).
|
||||
3. Dispatch to the view_type's renderer (the same per-view-type templates from 5i: `tree_card.tmpl`, `tree_kanban.tmpl`, `tree_section.tmpl` for list, plus the existing `calendar_section.tmpl` and `timeline_section.tmpl`).
|
||||
4. Apply chip-overlay semantics from the 5i fix — URL chips overlay the saved filter so chip clicks narrow within the view (the one piece of 5i worth keeping; see §7).
|
||||
|
||||
Editor (`GET /views/new` and `GET /views/{slug}/edit`) is a dedicated full-page form, not a modal. Paliad shipped dedicated pages; projax inherits the same shape.
|
||||
|
||||
---
|
||||
|
||||
## §5 — Sidebar integration
|
||||
|
||||
Replace the single "Views" sidebar entry (5i) with a "Views" section listing every user view. System views stay in the existing main-nav block at the top; they're already the muscle-memory entries (Tree, Dashboard, Calendar, Timeline, Graph).
|
||||
|
||||
ASCII sketch (5g sidebar shape, with 5j additions):
|
||||
|
||||
```
|
||||
[ sidebar ]
|
||||
─────────────
|
||||
⌂ Tree
|
||||
□ Dashboard
|
||||
▣ Calendar
|
||||
⊿ Timeline
|
||||
⨀ Graph
|
||||
─────────────
|
||||
Views ← new section header
|
||||
📂 Active mai work ← user view (icon + name)
|
||||
⏰ This week deadlines ← row-count badge if show_count
|
||||
★ Patents kanban ← drag-reorder handle on hover
|
||||
+ New view ← /views/new
|
||||
─────────────
|
||||
⚙ Admin
|
||||
─────────────
|
||||
☾ Theme
|
||||
```
|
||||
|
||||
The Views section's entries come from `ListViews()` ordered by `sort_order` ASC, then `name`. Each entry:
|
||||
- Icon resolved against a small frontend registry (the icon column is a key; the registry maps it to an SVG). Keys: `folder`, `clock`, `star`, `tag`, `file-text`, `box`, `inbox`, etc. Default key: `folder`.
|
||||
- Optional badge with row count when `show_count=true` — computed by running the view's filter against `ListAll()` (cheap; projax's scale is ~150 items max).
|
||||
- Active state when the current URL is `/views/{this-slug}` or a legacy alias resolving to it.
|
||||
|
||||
Drag-reorder lands in a later slice (G). Click-to-open is the v1 interaction.
|
||||
|
||||
Mobile bottom-nav drawer (5g slice B) gets the same section.
|
||||
|
||||
---
|
||||
|
||||
## §6 — Editor surface
|
||||
|
||||
Single editor template (`templates/view_editor.tmpl`) reused for both `/views/new` and `/views/{slug}/edit`. Distinguishes via the presence of `.View` in the data map.
|
||||
|
||||
Fields:
|
||||
- **Name** — text input, required, max 80 chars.
|
||||
- **Slug** — text input, regex `^[a-z0-9][a-z0-9-]{0,62}$`, **auto-derived** from name via HTMX on `change` against a `POST /views/derive-slug?name=<x>` helper endpoint OR on the client (simpler: derive on the server side in `handleViewCreate` if the field is empty; provide a "regenerate" link in edit mode). m can hand-edit.
|
||||
- **Icon** — `<select>` with the registered icon keys + a visible preview. Slice D ships the form field; the registry SVG additions can grow incrementally.
|
||||
- **View type** — radio group (5 values: card/list/calendar/kanban/timeline).
|
||||
- **Filter (chip strip)** — full TreeFilter chip strip inline in the editor: tag, mgmt, status, has, public, project picker + descendants toggle. Each chip click updates a hidden `filter_json` field via HTMX — so the editor's preview pane reflects the saved filter live.
|
||||
- **Sort field** — text input (`title` / `updated_at` / `start_time`).
|
||||
- **Sort dir** — `<select>` (asc/desc).
|
||||
- **Group by** — `<select>` (status/area/tag/management). Required when view_type=kanban.
|
||||
- **Show count** — checkbox.
|
||||
|
||||
A small "Preview" pane next to the form shows the first N items the filter currently matches. Optional in slice D; can land in slice G if scope bites.
|
||||
|
||||
Save → 302 to `/views/{slug}`. Cancel → `/views` (or the previous URL if HTMX-loaded).
|
||||
|
||||
**Drops the HTMX modal** the 5i fix-shift added — dedicated pages are clearer for a page-level concept and match paliad's pattern.
|
||||
|
||||
---
|
||||
|
||||
## §7 — Migration from 5i overlay
|
||||
|
||||
Specific deletions and salvages:
|
||||
|
||||
### Code to delete
|
||||
| file | what to remove |
|
||||
|---|---|
|
||||
| `web/tree_filter.go` | `ViewID` field on TreeFilter; `ParseTreeFilter`/`QueryString` handling |
|
||||
| `web/views.go` | `applySavedView`, `applyDefaultView`, `overlayURLFields`, `filterQueryToJSON`/`filterJSONToQuery`, the `Prefill` index handler logic |
|
||||
| `web/server.go` | the `?view=<uuid>` overlay block in `handleTree`; the `DefaultBanner` data map field |
|
||||
| `web/templates/tree_section.tmpl` | the `default-banner` block; the `<input type="hidden" name="view">` |
|
||||
| `web/templates/views.tmpl` | full rewrite — it's the list-management surface, redesigned in §5 + §6 |
|
||||
| `web/templates/view_edit.tmpl` | full rewrite to the new editor shape |
|
||||
|
||||
### Code to keep
|
||||
- `templates/tree_card.tmpl`, `templates/tree_kanban.tmpl` — these are per-view_type renderers, reusable.
|
||||
- `web/view_type.go` (the 5-value enum + `PageViewTypes` catalog) — still valid as the renderer dispatch table.
|
||||
- `web/kanban.go` (`BuildKanbanBoard`) — view_type=kanban consumer.
|
||||
- `templates/project_chip.tmpl` — the project filter chip strip works inside the editor.
|
||||
- The 5i chip-overlay-on-saved-view fix is the **one piece of substance** worth keeping conceptually: on `/views/{slug}`, URL chip params overlay the saved filter. The overlay function gets a new home (`handleViewRender`'s filter-resolution path) but the rule is the same.
|
||||
|
||||
### Backwards compatibility for the old `?view=<uuid>` URL
|
||||
|
||||
Two options:
|
||||
- (i) **404 on `?view=`** for existing pages — the URL never makes sense in the new model. Cost: any stale bookmark dies, but only m used it for hours.
|
||||
- (ii) **302-redirect `/<page>?view=<uuid>` to `/views/<slug>`** by looking up the slug from the uuid. Smoother for m's recent bookmarks. Cost: one extra DB hit on the redirect path; the redirect can target the slug or, if the uuid no longer resolves (because we hard-recreated the table), 302 to `/views`.
|
||||
|
||||
Inventor pick: (ii) — small code, no broken bookmarks for the brief 5i window.
|
||||
|
||||
### `is_default_for` semantics
|
||||
|
||||
Drop entirely. The MRU mechanism (`last_used_at` → `/views` landing) replaces "what should I see on /views". Per-page defaults are gone; if m wants a specific view to be the landing experience, he opens it once and it becomes MRU.
|
||||
|
||||
If m later wants a "this is my default" hint stronger than MRU (i.e., pinning), `sort_order=0` reserved for a pinned slot + an `is_pinned` flag is the natural extension. **Not in scope for v1.**
|
||||
|
||||
---
|
||||
|
||||
## §8 — Implementation slicing
|
||||
|
||||
Seven slices; A → B → C → D → E are the critical path; F + G are polish.
|
||||
|
||||
### Slice A — Schema redesign
|
||||
|
||||
- Migration `0017_views_redesign.sql`: `DROP TABLE projax.views CASCADE; CREATE TABLE` with new shape. (See §2 schema.)
|
||||
- `store/views.go`: rewrite. Rename `View.ID` flow to be slug-driven; `GetView(slug)` instead of `GetView(uuid)`. Keep CRUD shape; add `Touch(slug)` for MRU; add `MostRecent()` returning the MRU view (or nil); add `Reorder([]string slugs)` for slice G.
|
||||
- Drop `DefaultViewFor` (no longer applicable).
|
||||
- Tests: round-trip CRUD by slug; reserved-slug rejection at the validator; slug-format regex enforcement; MRU.
|
||||
|
||||
### Slice B — Route migration (paliad-shape)
|
||||
|
||||
- Replace the 5i `/views/<uuid>` routes with the paliad-shape route table from §4.
|
||||
- `handleViewsLanding` → MRU redirect or onboarding shell.
|
||||
- `handleViewRender` → resolve slug (user view first, then system view), apply chip overlay, dispatch to the view_type's renderer.
|
||||
- `handleViewEditor` → dedicated form page (slug-driven).
|
||||
- `handleViewCreate` / `handleViewUpdate` / `handleViewDelete` → form POST handlers.
|
||||
- `handleViewTouch` → fire-and-forget MRU update.
|
||||
- Wire the legacy `?view=<uuid>` redirect (per §7-ii) on existing pages.
|
||||
- Tests: each route hit, slug routing, MRU redirect, onboarding shell on empty state, reserved-slug rejection.
|
||||
|
||||
### Slice C — System views
|
||||
|
||||
- New `web/system_views.go` with `SystemView` struct + `TreeSystemView()`, `DashboardSystemView()`, `CalendarSystemView()`, `TimelineSystemView()`, `AllSystemViews()`, `LookupSystemView(slug)`.
|
||||
- Each function returns the `(filter_json, view_type, group_by, sort)` tuple matching today's page.
|
||||
- `handleViewRender` falls back to `LookupSystemView` when the slug isn't in the DB.
|
||||
- Reserved-slug list (combining system slugs + route segments).
|
||||
- Under (c) hybrid: legacy routes `/`, `/dashboard`, `/calendar`, `/timeline` each gain a sibling registration so `/views/{system-slug}` resolves to the same handler. (Or: legacy routes 302 to `/views/{slug}` — simpler if m's fine with one canonical URL.)
|
||||
- Tests: system-view lookup, slug aliases hit the same template, reserved-slug rejection during user-view create.
|
||||
|
||||
### Slice D — Editor surface
|
||||
|
||||
- New `templates/view_editor.tmpl` — full form per §6.
|
||||
- Slug derivation helper (`POST /views/derive-slug` or server-side fill).
|
||||
- Icon picker (a `<select>` for v1 — frontend registry expansion is incremental).
|
||||
- Inline chip strip inside the form; HTMX updates a hidden `filter_json` on every chip click.
|
||||
- Tests: GET /views/new renders blank form; GET /views/{slug}/edit pre-fills; POST creates/updates round-trip.
|
||||
|
||||
### Slice E — Sidebar integration
|
||||
|
||||
- `templates/layout.tmpl`: insert a "Views" section between main nav and `/admin`.
|
||||
- Server-side: every page-render pulls `ListViews()` into the layout data map (cached lightly so each request doesn't hit the DB twice).
|
||||
- Active-state CSS + icon rendering.
|
||||
- Mobile drawer (5g slice B) gets the same section.
|
||||
- Tests: sidebar shows user views; clicking navigates to `/views/{slug}`; active state matches URL.
|
||||
|
||||
### Slice F — Migration cleanup (delete 5i overlay)
|
||||
|
||||
- Remove TreeFilter.ViewID.
|
||||
- Remove `applySavedView`, `applyDefaultView`, `overlayURLFields`, the default-view banner.
|
||||
- Remove the 5i `/views/<id>` redirect handler (slice B replaces it).
|
||||
- Tests adjusted: drop the `ViewID` round-trip test; drop `TestSavedViewAppliedOnQueryParam`, `TestDefaultViewAppliedOnCleanURL`, `TestViewEditFlow` — their slice-A successors cover the new shapes.
|
||||
|
||||
### Slice G — Polish
|
||||
|
||||
- Drag-reorder UI via HTMX `hx-post="/views/reorder"` with sortable.js or a tiny vanilla drag-handle (m's HTMX-only constraint allows minimal vendored JS if needed).
|
||||
- `show_count` badge wiring (run filter against `ListAll()`, render the count next to the sidebar entry).
|
||||
- Preview pane in the editor (optional).
|
||||
- Icon registry expansion (curated SVGs).
|
||||
|
||||
Slices F and G are independent. The implementation chain is **A → B → C → D → E → (F either before or after E) → G**.
|
||||
|
||||
---
|
||||
|
||||
## §9 — Open questions for head delegation
|
||||
|
||||
Inventor picks marked. Process: **NO direct chip-picker** without head's explicit grant for this round.
|
||||
|
||||
### Q1 — System-view shape (§3)
|
||||
|
||||
(a) Keep current routes only; user views beside them at `/views/{slug}` — current asymmetry stays.
|
||||
(b) Full migration; existing pages become system views, legacy URLs 301-redirect — paliad parity.
|
||||
(c) Hybrid; both URL families coexist, system slugs aliased — preserves muscle memory.
|
||||
|
||||
**Inventor pick**: (c). Closes the asymmetry m flagged, zero migration cost. (b) is cleaner but risks broken bookmarks for thin upside.
|
||||
|
||||
### Q2 — `view_type` field placement
|
||||
|
||||
- (a) Top-level column (5j inventor pick — matches 5i, query-able without parsing JSON).
|
||||
- (b) Inside `filter_json`.
|
||||
|
||||
**Inventor pick**: (a).
|
||||
|
||||
### Q3 — Legacy `?view=<uuid>` URL handling (§7)
|
||||
|
||||
- (a) 404 — clean break.
|
||||
- (b) 302-redirect to `/views/<slug>` by uuid lookup — smoother for m's recent bookmarks. Inventor pick.
|
||||
|
||||
**Inventor pick**: (b).
|
||||
|
||||
### Q4 — Editor surface (§6)
|
||||
|
||||
- (a) Dedicated pages `/views/new` + `/views/{slug}/edit` — paliad parity, inventor pick.
|
||||
- (b) Keep the HTMX modal from the 5i fix — less navigation but harder to share/bookmark mid-edit.
|
||||
|
||||
**Inventor pick**: (a).
|
||||
|
||||
### Q5 — `/views` landing MRU redirect
|
||||
|
||||
- (a) 302 to MRU saved view if any, else onboarding shell (paliad model, inventor pick).
|
||||
- (b) Always show the views index list page.
|
||||
|
||||
**Inventor pick**: (a).
|
||||
|
||||
### Q6 — Icon picker in v1?
|
||||
|
||||
- (a) Yes — small select + 8-12 curated keys; rendered inline in the sidebar entries.
|
||||
- (b) v2 — ship without icons in v1; sidebar uses a generic folder glyph for every entry.
|
||||
|
||||
**Inventor pick**: (a) — the schema column lands either way; UI cost for a `<select>` is trivial.
|
||||
|
||||
### Q7 — Drag-reorder in v1?
|
||||
|
||||
- (a) Yes (slice G in v1).
|
||||
- (b) v2 — `sort_order` column is server-assigned MAX+1 on create; reorder UI lands later.
|
||||
|
||||
**Inventor pick**: (b). Don't expand v1 scope; reorder is a UX polish that can ship a week after.
|
||||
|
||||
### Q8 — `show_count` badge in v1?
|
||||
|
||||
- (a) Yes — opt-in checkbox in editor + sidebar badge.
|
||||
- (b) v2 — column lands in the schema; UI lands later.
|
||||
|
||||
**Inventor pick**: (a) — checkbox in editor + 2-line render in sidebar is cheap and answers the "how many things match my view" question m asks naturally.
|
||||
|
||||
### Q9 — Legacy `is_default_for` semantics (§7)
|
||||
|
||||
Inventor picks **dropped entirely**, replaced by MRU. Flag if m wants pin / default semantics back.
|
||||
|
||||
### Q10 — Drop and recreate `projax.views`?
|
||||
|
||||
- (a) Hard-replace via `DROP TABLE ... CASCADE` — inventor pick (table is hours old, ~zero data loss).
|
||||
- (b) ALTER TABLE migration that adds new columns + drops old ones gracefully — more conservative; preserves any rows m has created.
|
||||
|
||||
**Inventor pick**: (a). The shape change is large enough that a clean re-create is cleaner than a 6-step ALTER.
|
||||
|
||||
### Q11 — `view_type=graph`?
|
||||
|
||||
The graph DAG SVG render isn't in the view_type enum. Should:
|
||||
- (a) Stay outside the views system — `/graph` and `/views/graph` (system slug) both serve it, user views can't be `view_type=graph`. Inventor pick.
|
||||
- (b) Add `graph` as a sixth view_type — opens user-creatable graph views.
|
||||
|
||||
**Inventor pick**: (a). Graph layout is single-purpose (DAG); a "graph of my filtered set" doesn't have a clear product story today.
|
||||
|
||||
---
|
||||
|
||||
## §10 — Risk register
|
||||
|
||||
| risk | likelihood | mitigation |
|
||||
|---|---|---|
|
||||
| Slug collision on rename | medium | UNIQUE index + handler maps the unique-violation to a friendly "slug already in use" error |
|
||||
| URL drift (legacy bookmarks break) | low under (c), high under (b) | (c) keeps legacy URLs; (b) ships with 301 redirects + a session of m verifying his bookmarks |
|
||||
| MRU thrash on rapid view switches | low | `last_used_at` is fire-and-forget; the worst case is one stale 302 |
|
||||
| System-view + user-view slug collision | n/a | reserved-list rejection in validator (slice A) |
|
||||
| sidebar query cost | low | `ListViews()` is one indexed lookup per page render; cache lightly if it shows in profiling |
|
||||
| Editor's chip strip drifts from the page chip strip | medium | share the same template (project_chip.tmpl already shared); add a dedicated `view_filter_chips.tmpl` if drift bites |
|
||||
|
||||
---
|
||||
|
||||
## §11 — Test plan headlines
|
||||
|
||||
### Slice A
|
||||
- `TestViewSlugCRUD` — create/get/update/delete by slug round-trip.
|
||||
- `TestViewSlugFormatRejected` — uppercase, underscore, leading-digit-allowed but no-leading-dash, length-cap 63.
|
||||
- `TestViewReservedSlugRejected` — create with slug `tree` / `dashboard` / `admin` / `new` etc. all 400.
|
||||
- `TestViewTouch` — Touch bumps `last_used_at`.
|
||||
- `TestViewMostRecent` — MRU returns most recently touched.
|
||||
|
||||
### Slice B
|
||||
- `TestViewsLandingMRU` — `/views` 302s to MRU view when one exists.
|
||||
- `TestViewsLandingOnboarding` — `/views` renders shell when no views.
|
||||
- `TestViewRender` — `/views/{slug}` resolves a user view; renders the right view_type template.
|
||||
- `TestLegacyOverlayRedirect` — `/?view=<uuid>` 302s to `/views/{slug}`.
|
||||
|
||||
### Slice C
|
||||
- `TestSystemViewLookup` — `tree` / `dashboard` / `calendar` / `timeline` / `graph` resolve via `LookupSystemView`.
|
||||
- `TestSystemViewSlugAlias` — `/views/dashboard` and `/dashboard` produce identical render output.
|
||||
|
||||
### Slice D
|
||||
- `TestEditorBlank` — `/views/new` renders empty form.
|
||||
- `TestEditorPrefilled` — `/views/{slug}/edit` reflects every persisted field.
|
||||
- `TestSlugDerivation` — name "Active mai work" → slug "active-mai-work".
|
||||
|
||||
### Slice E
|
||||
- `TestSidebarListsViews` — layout includes every user view.
|
||||
- `TestSidebarActiveState` — `/views/{slug}` marks that entry active.
|
||||
|
||||
### Slice F
|
||||
- All 5i overlay tests deleted; no residue references TreeFilter.ViewID.
|
||||
|
||||
### Slice G
|
||||
- `TestReorderUpdatesSortOrder` — POST `/views/reorder` with a sorted slug list updates the column.
|
||||
- `TestShowCountBadge` — sidebar badge reflects the filter's match count.
|
||||
|
||||
---
|
||||
|
||||
## §12 — References
|
||||
|
||||
- `~/dev/paliad/internal/db/migrations/056_user_views.up.sql` — schema reference.
|
||||
- `~/dev/paliad/internal/services/user_view_service.go` — CRUD reference.
|
||||
- `~/dev/paliad/internal/services/system_views.go` — reserved-slug + system-view registration.
|
||||
- `~/dev/paliad/internal/handlers/views_pages.go` — route table.
|
||||
- `~/dev/paliad/frontend/src/{views,views-editor}.tsx` — editor + sidebar reference (UX only; not ported).
|
||||
- `docs/plans/views-system.md` (5i) — historical record of the wrong-shape implementation.
|
||||
- `docs/design.md` §4 (Interfaces).
|
||||
|
||||
---
|
||||
|
||||
## §13 — Status
|
||||
|
||||
- **Phase A (this doc)**: drafted by kahn, 2026-05-26. Awaiting head delegation of §9 questions to m.
|
||||
- **No chip-picker for 5j** unless head explicitly re-grants per the project's escalation rule.
|
||||
- **Phase B (coder)**: blocked on m's sign-off via head. Slice ordering A → B → C → D → E → F → G.
|
||||
- **No code changes** in this branch beyond this doc.
|
||||
366
store/views.go
366
store/views.go
@@ -5,58 +5,108 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// View is one row in projax.views. Phase 5i Slice D — saved views.
|
||||
//
|
||||
// FilterJSON carries the persisted filter state as raw JSON so callers can
|
||||
// freely round-trip into their TreeFilter or another future filter type
|
||||
// without forcing the store package to depend on web/.
|
||||
// View is one row in projax.views — a first-class /views/{slug} page.
|
||||
// Phase 5j paliad-shape: the slug is the user-facing key; URLs and the
|
||||
// sidebar both index by it. The uuid id stays because it's cheap and
|
||||
// surfaces in future MCP integrations, but it is NOT exposed in URLs.
|
||||
type View struct {
|
||||
ID string
|
||||
Name string
|
||||
Description string
|
||||
FilterJSON []byte // raw jsonb payload
|
||||
ViewType string
|
||||
SortField *string
|
||||
SortDir *string
|
||||
GroupBy *string
|
||||
Pinned bool
|
||||
IsDefaultFor *string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
ID string
|
||||
Slug string
|
||||
Name string
|
||||
Icon *string
|
||||
FilterJSON []byte // raw jsonb payload — includes view_type per m's Q2
|
||||
SortField *string
|
||||
SortDir *string
|
||||
GroupBy *string
|
||||
SortOrder int
|
||||
ShowCount bool
|
||||
LastUsedAt *time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// ErrViewNotFound surfaces from GetView / SoftDeleteView when no row matches.
|
||||
// ErrViewNotFound surfaces from Get*/Update*/Delete when no row matches.
|
||||
var ErrViewNotFound = errors.New("view not found")
|
||||
|
||||
// ViewInput is the writeable subset of View used by Create / Update.
|
||||
type ViewInput struct {
|
||||
Name string
|
||||
Description string
|
||||
FilterJSON []byte
|
||||
ViewType string
|
||||
SortField string
|
||||
SortDir string
|
||||
GroupBy string
|
||||
Pinned bool
|
||||
IsDefaultFor string // "" → clear default
|
||||
// ErrViewSlugTaken is returned by Create / Update when the slug already
|
||||
// belongs to another view. Web handlers map this to 409.
|
||||
var ErrViewSlugTaken = errors.New("view slug already exists")
|
||||
|
||||
// ErrViewSlugReserved is returned when the caller picks a slug that
|
||||
// shadows a system slug or a top-level URL segment. Web handlers map
|
||||
// this to 400 with a friendly message.
|
||||
var ErrViewSlugReserved = errors.New("view slug is reserved")
|
||||
|
||||
// ErrViewSlugFormat is returned when the slug doesn't match the format
|
||||
// regex. Same mapping as reserved.
|
||||
var ErrViewSlugFormat = errors.New("view slug must match ^[a-z0-9][a-z0-9-]{0,62}$")
|
||||
|
||||
// slugRE is the format guard. Mirrors the SQL CHECK constraint so callers
|
||||
// get a friendly error before round-tripping to the DB.
|
||||
var slugRE = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}$`)
|
||||
|
||||
// reservedViewSlugs is the static list of slugs the validator rejects.
|
||||
// Combines system-view slugs (slice C wires them) with top-level route
|
||||
// segments the application owns.
|
||||
var reservedViewSlugs = map[string]struct{}{
|
||||
// System views (slice C):
|
||||
"tree": {}, "dashboard": {}, "calendar": {}, "timeline": {}, "graph": {},
|
||||
// /views sub-routes:
|
||||
"new": {}, "edit": {},
|
||||
// Top-level application URLs:
|
||||
"admin": {}, "login": {}, "logout": {}, "healthz": {}, "mcp": {},
|
||||
"static": {}, "i": {}, "views": {},
|
||||
}
|
||||
|
||||
// ListViews returns every non-deleted view ordered by pinned-first, then name.
|
||||
// IsReservedViewSlug reports whether the slug shadows a system slug or a
|
||||
// top-level URL segment. Exported for the editor's slug-derivation
|
||||
// helper.
|
||||
func IsReservedViewSlug(slug string) bool {
|
||||
_, ok := reservedViewSlugs[strings.ToLower(slug)]
|
||||
return ok
|
||||
}
|
||||
|
||||
// ValidateSlug runs format + reserved checks. Returns nil for valid slugs.
|
||||
func ValidateSlug(slug string) error {
|
||||
if !slugRE.MatchString(slug) {
|
||||
return ErrViewSlugFormat
|
||||
}
|
||||
if IsReservedViewSlug(slug) {
|
||||
return ErrViewSlugReserved
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ViewInput is the writeable subset for Create / Update. Defaults
|
||||
// applied: nil FilterJSON → {}; SortOrder is server-assigned on Create.
|
||||
type ViewInput struct {
|
||||
Slug string
|
||||
Name string
|
||||
Icon *string
|
||||
FilterJSON []byte
|
||||
SortField string
|
||||
SortDir string
|
||||
GroupBy string
|
||||
ShowCount bool
|
||||
}
|
||||
|
||||
// ListViews returns every view ordered by sort_order ASC then name —
|
||||
// matches the sidebar rendering order.
|
||||
func (s *Store) ListViews(ctx context.Context) ([]*View, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT id, name, coalesce(description,''), filter_json, view_type,
|
||||
sort_field, sort_dir, group_by, pinned, is_default_for,
|
||||
SELECT id, slug, name, icon, filter_json,
|
||||
sort_field, sort_dir, group_by,
|
||||
sort_order, show_count, last_used_at,
|
||||
created_at, updated_at
|
||||
FROM projax.views
|
||||
WHERE deleted_at IS NULL
|
||||
ORDER BY pinned DESC, lower(name) ASC`)
|
||||
ORDER BY sort_order ASC, name ASC`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list views: %w", err)
|
||||
}
|
||||
@@ -72,14 +122,25 @@ ORDER BY pinned DESC, lower(name) ASC`)
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetView returns one view by id. ErrViewNotFound when missing or soft-deleted.
|
||||
func (s *Store) GetView(ctx context.Context, id string) (*View, error) {
|
||||
// GetView returns one view by slug. ErrViewNotFound when missing.
|
||||
func (s *Store) GetView(ctx context.Context, slug string) (*View, error) {
|
||||
return s.getView(ctx, `slug = $1`, slug)
|
||||
}
|
||||
|
||||
// GetViewByID returns one view by uuid id. Used by the legacy
|
||||
// `?view=<uuid>` 302-redirect path during the 5i → 5j cutover.
|
||||
func (s *Store) GetViewByID(ctx context.Context, id string) (*View, error) {
|
||||
return s.getView(ctx, `id = $1`, id)
|
||||
}
|
||||
|
||||
func (s *Store) getView(ctx context.Context, where, arg string) (*View, error) {
|
||||
row := s.Pool.QueryRow(ctx, `
|
||||
SELECT id, name, coalesce(description,''), filter_json, view_type,
|
||||
sort_field, sort_dir, group_by, pinned, is_default_for,
|
||||
SELECT id, slug, name, icon, filter_json,
|
||||
sort_field, sort_dir, group_by,
|
||||
sort_order, show_count, last_used_at,
|
||||
created_at, updated_at
|
||||
FROM projax.views
|
||||
WHERE id = $1 AND deleted_at IS NULL`, id)
|
||||
WHERE `+where, arg)
|
||||
v, err := scanView(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrViewNotFound
|
||||
@@ -87,9 +148,29 @@ WHERE id = $1 AND deleted_at IS NULL`, id)
|
||||
return v, err
|
||||
}
|
||||
|
||||
// CreateView inserts a row. When IsDefaultFor is set, the prior default for
|
||||
// that page is cleared in the same transaction so the partial unique index
|
||||
// can't fire after a Postgres rewrite.
|
||||
// MostRecentView returns the view with the most recent last_used_at. nil
|
||||
// when no view has been touched yet (or none exist). Drives the /views
|
||||
// landing redirect.
|
||||
func (s *Store) MostRecentView(ctx context.Context) (*View, error) {
|
||||
row := s.Pool.QueryRow(ctx, `
|
||||
SELECT id, slug, name, icon, filter_json,
|
||||
sort_field, sort_dir, group_by,
|
||||
sort_order, show_count, last_used_at,
|
||||
created_at, updated_at
|
||||
FROM projax.views
|
||||
WHERE last_used_at IS NOT NULL
|
||||
ORDER BY last_used_at DESC
|
||||
LIMIT 1`)
|
||||
v, err := scanView(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return v, err
|
||||
}
|
||||
|
||||
// CreateView inserts a new view. SortOrder is server-assigned to
|
||||
// MAX(existing)+1 inside the same tx so two parallel creates don't
|
||||
// collide on the index.
|
||||
func (s *Store) CreateView(ctx context.Context, in ViewInput) (*View, error) {
|
||||
if err := validateViewInput(in); err != nil {
|
||||
return nil, err
|
||||
@@ -97,95 +178,81 @@ func (s *Store) CreateView(ctx context.Context, in ViewInput) (*View, error) {
|
||||
if in.FilterJSON == nil {
|
||||
in.FilterJSON = []byte("{}")
|
||||
}
|
||||
var id string
|
||||
tx, err := s.Pool.BeginTx(ctx, pgx.TxOptions{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("begin: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
if in.IsDefaultFor != "" {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE projax.views
|
||||
SET is_default_for = NULL
|
||||
WHERE is_default_for = $1 AND deleted_at IS NULL`, in.IsDefaultFor); err != nil {
|
||||
return nil, fmt.Errorf("clear prior default: %w", err)
|
||||
}
|
||||
var nextOrder int
|
||||
if err := tx.QueryRow(ctx,
|
||||
`SELECT COALESCE(MAX(sort_order), -1) + 1 FROM projax.views`,
|
||||
).Scan(&nextOrder); err != nil {
|
||||
return nil, fmt.Errorf("compute next sort_order: %w", err)
|
||||
}
|
||||
var id string
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO projax.views
|
||||
(name, description, filter_json, view_type, sort_field, sort_dir, group_by, pinned, is_default_for)
|
||||
(slug, name, icon, filter_json, sort_field, sort_dir, group_by, sort_order, show_count)
|
||||
VALUES
|
||||
($1, NULLIF($2,''), $3::jsonb, $4, NULLIF($5,''), NULLIF($6,''), NULLIF($7,''), $8, NULLIF($9,''))
|
||||
($1, $2, $3, $4::jsonb, NULLIF($5,''), NULLIF($6,''), NULLIF($7,''), $8, $9)
|
||||
RETURNING id`,
|
||||
in.Name, in.Description, in.FilterJSON, in.ViewType,
|
||||
in.SortField, in.SortDir, in.GroupBy, in.Pinned, in.IsDefaultFor,
|
||||
in.Slug, in.Name, in.Icon, in.FilterJSON,
|
||||
in.SortField, in.SortDir, in.GroupBy, nextOrder, in.ShowCount,
|
||||
).Scan(&id)
|
||||
if err != nil {
|
||||
if isUniqueSlugViolation(err) {
|
||||
return nil, ErrViewSlugTaken
|
||||
}
|
||||
return nil, fmt.Errorf("insert view: %w", err)
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, fmt.Errorf("commit: %w", err)
|
||||
}
|
||||
return s.GetView(ctx, id)
|
||||
return s.GetView(ctx, in.Slug)
|
||||
}
|
||||
|
||||
// UpdateView replaces every writeable field. Same default-clearing semantics
|
||||
// as CreateView.
|
||||
func (s *Store) UpdateView(ctx context.Context, id string, in ViewInput) (*View, error) {
|
||||
// UpdateView replaces every writeable field on the row matching `slug`.
|
||||
// To rename, pass the desired new slug in `in.Slug`; if it collides with
|
||||
// another row, ErrViewSlugTaken surfaces.
|
||||
func (s *Store) UpdateView(ctx context.Context, slug string, in ViewInput) (*View, error) {
|
||||
if err := validateViewInput(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if in.FilterJSON == nil {
|
||||
in.FilterJSON = []byte("{}")
|
||||
}
|
||||
tx, err := s.Pool.BeginTx(ctx, pgx.TxOptions{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("begin: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
if in.IsDefaultFor != "" {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
tag, err := s.Pool.Exec(ctx, `
|
||||
UPDATE projax.views
|
||||
SET is_default_for = NULL
|
||||
WHERE is_default_for = $1 AND id <> $2 AND deleted_at IS NULL`,
|
||||
in.IsDefaultFor, id); err != nil {
|
||||
return nil, fmt.Errorf("clear prior default: %w", err)
|
||||
}
|
||||
}
|
||||
tag, err := tx.Exec(ctx, `
|
||||
UPDATE projax.views
|
||||
SET name = $2,
|
||||
description = NULLIF($3,''),
|
||||
filter_json = $4::jsonb,
|
||||
view_type = $5,
|
||||
sort_field = NULLIF($6,''),
|
||||
sort_dir = NULLIF($7,''),
|
||||
group_by = NULLIF($8,''),
|
||||
pinned = $9,
|
||||
is_default_for = NULLIF($10,'')
|
||||
WHERE id = $1 AND deleted_at IS NULL`,
|
||||
id, in.Name, in.Description, in.FilterJSON, in.ViewType,
|
||||
in.SortField, in.SortDir, in.GroupBy, in.Pinned, in.IsDefaultFor,
|
||||
SET slug = $2,
|
||||
name = $3,
|
||||
icon = $4,
|
||||
filter_json = $5::jsonb,
|
||||
sort_field = NULLIF($6,''),
|
||||
sort_dir = NULLIF($7,''),
|
||||
group_by = NULLIF($8,''),
|
||||
show_count = $9
|
||||
WHERE slug = $1`,
|
||||
slug, in.Slug, in.Name, in.Icon, in.FilterJSON,
|
||||
in.SortField, in.SortDir, in.GroupBy, in.ShowCount,
|
||||
)
|
||||
if err != nil {
|
||||
if isUniqueSlugViolation(err) {
|
||||
return nil, ErrViewSlugTaken
|
||||
}
|
||||
return nil, fmt.Errorf("update view: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return nil, ErrViewNotFound
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, fmt.Errorf("commit: %w", err)
|
||||
}
|
||||
return s.GetView(ctx, id)
|
||||
return s.GetView(ctx, in.Slug)
|
||||
}
|
||||
|
||||
// SoftDeleteView sets deleted_at on the row. Idempotent (returns ErrViewNotFound
|
||||
// only when the row never existed; subsequent calls on a soft-deleted row
|
||||
// silently succeed since deleted_at is just refreshed).
|
||||
func (s *Store) SoftDeleteView(ctx context.Context, id string) error {
|
||||
tag, err := s.Pool.Exec(ctx, `
|
||||
UPDATE projax.views SET deleted_at = now()
|
||||
WHERE id = $1`, id)
|
||||
// DeleteView removes a view by slug. Hard delete (no soft-delete column
|
||||
// in the redesign — single-user, no audit obligation). Idempotent only
|
||||
// on the second call; first call against a non-existent row returns
|
||||
// ErrViewNotFound.
|
||||
func (s *Store) DeleteView(ctx context.Context, slug string) error {
|
||||
tag, err := s.Pool.Exec(ctx, `DELETE FROM projax.views WHERE slug = $1`, slug)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete view: %w", err)
|
||||
}
|
||||
@@ -195,79 +262,100 @@ WHERE id = $1`, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// DefaultViewFor returns the view that should auto-apply on the named page,
|
||||
// or nil if none is set.
|
||||
func (s *Store) DefaultViewFor(ctx context.Context, page string) (*View, error) {
|
||||
row := s.Pool.QueryRow(ctx, `
|
||||
SELECT id, name, coalesce(description,''), filter_json, view_type,
|
||||
sort_field, sort_dir, group_by, pinned, is_default_for,
|
||||
created_at, updated_at
|
||||
FROM projax.views
|
||||
WHERE is_default_for = $1 AND deleted_at IS NULL
|
||||
LIMIT 1`, page)
|
||||
v, err := scanView(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
// TouchView bumps last_used_at to now(). Fire-and-forget from the render
|
||||
// handler — failures are logged but never block the page.
|
||||
func (s *Store) TouchView(ctx context.Context, slug string) error {
|
||||
tag, err := s.Pool.Exec(ctx,
|
||||
`UPDATE projax.views SET last_used_at = now() WHERE slug = $1`, slug)
|
||||
if err != nil {
|
||||
return fmt.Errorf("touch view: %w", err)
|
||||
}
|
||||
return v, err
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrViewNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateViewInput runs the Go-side guards. The DB CHECK constraints provide
|
||||
// the durable contract; these checks let handlers surface a friendlier error.
|
||||
// ReorderViews applies a sort_order rewrite where the provided slugs map
|
||||
// to ascending sort_order values starting at 0. Slugs not present in the
|
||||
// input keep their existing sort_order. Drives slice G's drag-reorder UI.
|
||||
func (s *Store) ReorderViews(ctx context.Context, slugs []string) error {
|
||||
if len(slugs) == 0 {
|
||||
return nil
|
||||
}
|
||||
tx, err := s.Pool.BeginTx(ctx, pgx.TxOptions{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
for i, slug := range slugs {
|
||||
if _, err := tx.Exec(ctx,
|
||||
`UPDATE projax.views SET sort_order = $1 WHERE slug = $2`,
|
||||
i, slug,
|
||||
); err != nil {
|
||||
return fmt.Errorf("reorder %q: %w", slug, err)
|
||||
}
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// validateViewInput runs Go-side guards. The DB CHECK constraints are the
|
||||
// durable contract; these checks let handlers surface friendlier errors.
|
||||
func validateViewInput(in ViewInput) error {
|
||||
if err := ValidateSlug(in.Slug); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(in.Name) == "" {
|
||||
return errors.New("view name is required")
|
||||
}
|
||||
switch in.ViewType {
|
||||
case "card", "list", "calendar", "kanban", "timeline":
|
||||
default:
|
||||
return fmt.Errorf("invalid view_type %q (allowed: card list calendar kanban timeline)", in.ViewType)
|
||||
}
|
||||
if in.SortDir != "" && in.SortDir != "asc" && in.SortDir != "desc" {
|
||||
return fmt.Errorf("invalid sort_dir %q", in.SortDir)
|
||||
}
|
||||
if in.ViewType == "kanban" && strings.TrimSpace(in.GroupBy) == "" {
|
||||
return errors.New("kanban view_type requires group_by")
|
||||
}
|
||||
if in.IsDefaultFor != "" {
|
||||
switch in.IsDefaultFor {
|
||||
case "tree", "dashboard", "calendar", "timeline":
|
||||
default:
|
||||
return fmt.Errorf("invalid is_default_for %q", in.IsDefaultFor)
|
||||
}
|
||||
if in.Icon != nil && len(*in.Icon) > 64 {
|
||||
return errors.New("icon key exceeds 64 characters")
|
||||
}
|
||||
if len(in.FilterJSON) > 0 {
|
||||
var dummy any
|
||||
if err := json.Unmarshal(in.FilterJSON, &dummy); err != nil {
|
||||
var probe any
|
||||
if err := json.Unmarshal(in.FilterJSON, &probe); err != nil {
|
||||
return fmt.Errorf("filter_json is not valid JSON: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isUniqueSlugViolation matches the postgres unique_violation SQLSTATE
|
||||
// (23505) on the views_slug_uniq index. We don't import pgconn here to
|
||||
// avoid widening the package's dep surface; substring match on the
|
||||
// pgx-formatted error covers both the wire-level codes pgx surfaces.
|
||||
func isUniqueSlugViolation(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
s := err.Error()
|
||||
return strings.Contains(s, "views_slug_uniq") ||
|
||||
(strings.Contains(s, "SQLSTATE 23505") && strings.Contains(s, "slug"))
|
||||
}
|
||||
|
||||
type viewScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanView(s viewScanner) (*View, error) {
|
||||
v := &View{}
|
||||
var sortField, sortDir, groupBy, isDefaultFor *string
|
||||
var icon, sortField, sortDir, groupBy *string
|
||||
var lastUsedAt *time.Time
|
||||
if err := s.Scan(
|
||||
&v.ID, &v.Name, &v.Description, &v.FilterJSON, &v.ViewType,
|
||||
&sortField, &sortDir, &groupBy, &v.Pinned, &isDefaultFor,
|
||||
&v.ID, &v.Slug, &v.Name, &icon, &v.FilterJSON,
|
||||
&sortField, &sortDir, &groupBy,
|
||||
&v.SortOrder, &v.ShowCount, &lastUsedAt,
|
||||
&v.CreatedAt, &v.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v.Icon = icon
|
||||
v.SortField = sortField
|
||||
v.SortDir = sortDir
|
||||
v.GroupBy = groupBy
|
||||
v.IsDefaultFor = isDefaultFor
|
||||
v.LastUsedAt = lastUsedAt
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// pgxRowsCompat keeps the linter quiet about importing pgxpool only for
|
||||
// type assertions inside views.go. The Pool method on Store already pulls
|
||||
// pgxpool into the package; nothing to do here, but the unused-import
|
||||
// shadow doesn't bite.
|
||||
var _ = pgxpool.Pool{}
|
||||
|
||||
246
store/views_test.go
Normal file
246
store/views_test.go
Normal file
@@ -0,0 +1,246 @@
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/m/projax/store"
|
||||
)
|
||||
|
||||
// connect mirrors db_test's connect helper. The store package owns its own
|
||||
// integration tests (Phase 5j Slice A introduced this file alongside the
|
||||
// schema redesign); it shares the same env-var convention to skip when no
|
||||
// DB is wired up.
|
||||
func connect(t *testing.T) (*pgxpool.Pool, *store.Store) {
|
||||
t.Helper()
|
||||
url := os.Getenv("PROJAX_DB_URL")
|
||||
if url == "" {
|
||||
url = os.Getenv("SUPABASE_DATABASE_URL")
|
||||
}
|
||||
if url == "" {
|
||||
t.Skip("no PROJAX_DB_URL / SUPABASE_DATABASE_URL set — skipping integration test")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
pool, err := pgxpool.New(ctx, url)
|
||||
if err != nil {
|
||||
t.Fatalf("pool: %v", err)
|
||||
}
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
t.Skipf("DB unreachable: %v", err)
|
||||
}
|
||||
return pool, store.New(pool)
|
||||
}
|
||||
|
||||
// uniqueSlug suffixes a base slug with a timestamp so parallel test runs
|
||||
// don't collide on the views_slug_uniq index.
|
||||
func uniqueSlug(prefix string) string {
|
||||
return prefix + "-" + strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "")
|
||||
}
|
||||
|
||||
func TestViewSlugCRUD(t *testing.T) {
|
||||
pool, s := connect(t)
|
||||
defer pool.Close()
|
||||
ctx := context.Background()
|
||||
slug := uniqueSlug("p5j-a-crud")
|
||||
defer pool.Exec(context.Background(), `DELETE FROM projax.views WHERE slug LIKE 'p5j-a-crud-%' OR slug LIKE 'p5j-a-renamed-%'`)
|
||||
|
||||
// Create.
|
||||
created, err := s.CreateView(ctx, store.ViewInput{
|
||||
Slug: slug,
|
||||
Name: "Slice A CRUD",
|
||||
FilterJSON: []byte(`{"view_type":"list","tags":["work"]}`),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if created.Slug != slug {
|
||||
t.Errorf("slug = %q, want %q", created.Slug, slug)
|
||||
}
|
||||
if created.ID == "" {
|
||||
t.Error("ID should be populated on create")
|
||||
}
|
||||
if created.SortOrder < 0 {
|
||||
t.Errorf("sort_order should be >= 0 (server-assigned), got %d", created.SortOrder)
|
||||
}
|
||||
|
||||
// GetView by slug.
|
||||
got, err := s.GetView(ctx, slug)
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if string(got.FilterJSON) != `{"view_type": "list", "tags": ["work"]}` && string(got.FilterJSON) != `{"tags": ["work"], "view_type": "list"}` {
|
||||
// Postgres jsonb normalises key order — accept either ordering.
|
||||
// Verify it round-trips structurally.
|
||||
if !strings.Contains(string(got.FilterJSON), `"view_type"`) || !strings.Contains(string(got.FilterJSON), `"tags"`) {
|
||||
t.Errorf("filter_json did not round-trip view_type+tags: %s", got.FilterJSON)
|
||||
}
|
||||
}
|
||||
|
||||
// GetViewByID (legacy 5i 302-redirect path uses this).
|
||||
byID, err := s.GetViewByID(ctx, created.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get by id: %v", err)
|
||||
}
|
||||
if byID.Slug != slug {
|
||||
t.Errorf("by-id lookup returned wrong slug: %q", byID.Slug)
|
||||
}
|
||||
|
||||
// Update — rename slug + change filter.
|
||||
renamed := uniqueSlug("p5j-a-renamed")
|
||||
updated, err := s.UpdateView(ctx, slug, store.ViewInput{
|
||||
Slug: renamed,
|
||||
Name: "Renamed",
|
||||
FilterJSON: []byte(`{"view_type":"card"}`),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("update: %v", err)
|
||||
}
|
||||
if updated.Slug != renamed {
|
||||
t.Errorf("renamed slug = %q, want %q", updated.Slug, renamed)
|
||||
}
|
||||
if _, err := s.GetView(ctx, slug); !errors.Is(err, store.ErrViewNotFound) {
|
||||
t.Errorf("old slug should be ErrViewNotFound after rename, got %v", err)
|
||||
}
|
||||
|
||||
// Delete.
|
||||
if err := s.DeleteView(ctx, renamed); err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
if _, err := s.GetView(ctx, renamed); !errors.Is(err, store.ErrViewNotFound) {
|
||||
t.Errorf("post-delete get should be ErrViewNotFound, got %v", err)
|
||||
}
|
||||
if err := s.DeleteView(ctx, renamed); !errors.Is(err, store.ErrViewNotFound) {
|
||||
t.Errorf("second delete should be ErrViewNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewSlugFormatRejected(t *testing.T) {
|
||||
pool, s := connect(t)
|
||||
defer pool.Close()
|
||||
ctx := context.Background()
|
||||
bad := []string{
|
||||
"", // empty
|
||||
"UPPER", // uppercase
|
||||
"under_score", // underscore
|
||||
"-leading-dash", // leading dash
|
||||
"a." + strings.Repeat("x", 100), // too long + invalid char
|
||||
strings.Repeat("a", 64), // length cap is 63 (1 + 62)
|
||||
}
|
||||
for _, slug := range bad {
|
||||
_, err := s.CreateView(ctx, store.ViewInput{
|
||||
Slug: slug, Name: "x", FilterJSON: []byte(`{}`),
|
||||
})
|
||||
if !errors.Is(err, store.ErrViewSlugFormat) {
|
||||
t.Errorf("slug=%q expected ErrViewSlugFormat, got %v", slug, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewReservedSlugRejected(t *testing.T) {
|
||||
_, s := connect(t)
|
||||
ctx := context.Background()
|
||||
for _, slug := range []string{"tree", "dashboard", "calendar", "timeline", "graph", "new", "edit", "admin", "views"} {
|
||||
_, err := s.CreateView(ctx, store.ViewInput{
|
||||
Slug: slug, Name: "x", FilterJSON: []byte(`{}`),
|
||||
})
|
||||
if !errors.Is(err, store.ErrViewSlugReserved) {
|
||||
t.Errorf("reserved slug %q should be rejected, got %v", slug, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewSlugCollision(t *testing.T) {
|
||||
pool, s := connect(t)
|
||||
defer pool.Close()
|
||||
ctx := context.Background()
|
||||
slug := uniqueSlug("p5j-a-collision")
|
||||
defer pool.Exec(context.Background(), `DELETE FROM projax.views WHERE slug = $1`, slug)
|
||||
|
||||
if _, err := s.CreateView(ctx, store.ViewInput{Slug: slug, Name: "First"}); err != nil {
|
||||
t.Fatalf("first create: %v", err)
|
||||
}
|
||||
if _, err := s.CreateView(ctx, store.ViewInput{Slug: slug, Name: "Second"}); !errors.Is(err, store.ErrViewSlugTaken) {
|
||||
t.Errorf("duplicate slug should be ErrViewSlugTaken, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewMRU(t *testing.T) {
|
||||
pool, s := connect(t)
|
||||
defer pool.Close()
|
||||
ctx := context.Background()
|
||||
a := uniqueSlug("p5j-a-mru-a")
|
||||
b := uniqueSlug("p5j-a-mru-b")
|
||||
defer pool.Exec(context.Background(), `DELETE FROM projax.views WHERE slug IN ($1, $2)`, a, b)
|
||||
|
||||
if _, err := s.CreateView(ctx, store.ViewInput{Slug: a, Name: "A"}); err != nil {
|
||||
t.Fatalf("create a: %v", err)
|
||||
}
|
||||
if _, err := s.CreateView(ctx, store.ViewInput{Slug: b, Name: "B"}); err != nil {
|
||||
t.Fatalf("create b: %v", err)
|
||||
}
|
||||
|
||||
// MostRecentView with no touches yet — when no view in the table has
|
||||
// last_used_at set, MRU returns nil. (Other tests may have left their
|
||||
// own touched views, so we only assert on the slugs we control.)
|
||||
if err := s.TouchView(ctx, a); err != nil {
|
||||
t.Fatalf("touch a: %v", err)
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
if err := s.TouchView(ctx, b); err != nil {
|
||||
t.Fatalf("touch b: %v", err)
|
||||
}
|
||||
|
||||
mru, err := s.MostRecentView(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("mru: %v", err)
|
||||
}
|
||||
// Other tests' touched views may rank higher; we only assert that
|
||||
// when MRU is one of OURS, the most-recently-touched (b) wins over a.
|
||||
// To guarantee this test's signal even with contention from other
|
||||
// suites, check b's last_used_at > a's last_used_at directly.
|
||||
aV, _ := s.GetView(ctx, a)
|
||||
bV, _ := s.GetView(ctx, b)
|
||||
if aV.LastUsedAt == nil || bV.LastUsedAt == nil {
|
||||
t.Fatal("both views should have last_used_at after touch")
|
||||
}
|
||||
if !bV.LastUsedAt.After(*aV.LastUsedAt) {
|
||||
t.Errorf("b.last_used_at should be after a.last_used_at; a=%v b=%v", aV.LastUsedAt, bV.LastUsedAt)
|
||||
}
|
||||
if mru == nil {
|
||||
t.Error("MostRecentView returned nil even though touches landed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewReorder(t *testing.T) {
|
||||
pool, s := connect(t)
|
||||
defer pool.Close()
|
||||
ctx := context.Background()
|
||||
a := uniqueSlug("p5j-a-reorder-a")
|
||||
b := uniqueSlug("p5j-a-reorder-b")
|
||||
c := uniqueSlug("p5j-a-reorder-c")
|
||||
defer pool.Exec(context.Background(), `DELETE FROM projax.views WHERE slug IN ($1, $2, $3)`, a, b, c)
|
||||
|
||||
for _, slug := range []string{a, b, c} {
|
||||
if _, err := s.CreateView(ctx, store.ViewInput{Slug: slug, Name: slug}); err != nil {
|
||||
t.Fatalf("create %s: %v", slug, err)
|
||||
}
|
||||
}
|
||||
// Reorder c → b → a.
|
||||
if err := s.ReorderViews(ctx, []string{c, b, a}); err != nil {
|
||||
t.Fatalf("reorder: %v", err)
|
||||
}
|
||||
cV, _ := s.GetView(ctx, c)
|
||||
bV, _ := s.GetView(ctx, b)
|
||||
aV, _ := s.GetView(ctx, a)
|
||||
if cV.SortOrder != 0 || bV.SortOrder != 1 || aV.SortOrder != 2 {
|
||||
t.Errorf("reorder yielded sort_orders c=%d b=%d a=%d, want 0,1,2",
|
||||
cV.SortOrder, bV.SortOrder, aV.SortOrder)
|
||||
}
|
||||
}
|
||||
23
web/bulk.go
23
web/bulk.go
@@ -118,6 +118,29 @@ func bulkMatches(f TreeFilter, it *store.Item, itemLinkKinds map[string]struct{}
|
||||
return false
|
||||
}
|
||||
}
|
||||
// Phase 5i Slice A: project scope. Same predicate as TreeFilter.Matches —
|
||||
// at least one of the item's paths must equal ProjectPath, with the
|
||||
// IncludeDescendants toggle gating the prefix-match for the subtree.
|
||||
// bulkMatches was a near-clone of Matches() that wasn't updated when
|
||||
// the project dim landed, so /admin/bulk silently ignored ?project=…
|
||||
// (and the chip's hidden-input round-trip too).
|
||||
if f.ProjectPath != "" {
|
||||
prefix := f.ProjectPath + "."
|
||||
hit := false
|
||||
for _, p := range it.Paths {
|
||||
if p == f.ProjectPath {
|
||||
hit = true
|
||||
break
|
||||
}
|
||||
if f.IncludeDescendants && strings.HasPrefix(p, prefix) {
|
||||
hit = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hit {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if f.Q != "" {
|
||||
q := strings.ToLower(f.Q)
|
||||
hit := strings.Contains(strings.ToLower(it.Title), q) ||
|
||||
|
||||
136
web/caldav.go
136
web/caldav.go
@@ -151,6 +151,93 @@ func (s *Server) handleCalDAVUnlink(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/admin/caldav", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// availableCalendarsForItem returns the discoverable CalDAV calendars
|
||||
// minus the ones already linked to this item — feeds the per-item
|
||||
// "Link existing list" picker on the detail page. Errors during
|
||||
// discovery (network, auth, parse) are surfaced to the caller; callers
|
||||
// downgrade to an empty list so the rest of the page still renders.
|
||||
//
|
||||
// "Already linked" is computed by the caller's `links` slice rather
|
||||
// than a fresh fetch, since handleDetail/renderTasksSection already
|
||||
// loaded the per-item caldav-list links inside detailTodos and we
|
||||
// avoid a second LinksByType round-trip.
|
||||
func (s *Server) availableCalendarsForItem(ctx context.Context, links []*store.ItemLink) ([]caldav.Calendar, error) {
|
||||
if s.CalDAV == nil {
|
||||
return nil, nil
|
||||
}
|
||||
cals, err := s.CalDAV.Client.ListCalendars(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
linkedURLs := map[string]struct{}{}
|
||||
for _, l := range links {
|
||||
linkedURLs[l.RefID] = struct{}{}
|
||||
}
|
||||
out := make([]caldav.Calendar, 0, len(cals))
|
||||
for _, c := range cals {
|
||||
if _, already := linkedURLs[c.URL]; already {
|
||||
continue
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].DisplayName < out[j].DisplayName })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// handleCalDAVLinkExisting handles POST /i/{path}/caldav/link-existing —
|
||||
// the per-item picker for sharing an existing CalDAV list across
|
||||
// multiple projax items. Re-runs ListCalendars to validate that the
|
||||
// submitted URL is genuinely discoverable (defence against a crafted
|
||||
// form pointing at an arbitrary URL), then inserts the item_link.
|
||||
func (s *Server) handleCalDAVLinkExisting(w http.ResponseWriter, r *http.Request, path string) {
|
||||
if s.CalDAV == nil {
|
||||
http.Error(w, "caldav not configured", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
it, err := s.Store.GetByPath(r.Context(), path)
|
||||
if err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
}
|
||||
calURL := strings.TrimSpace(r.FormValue("calendar_url"))
|
||||
if calURL == "" {
|
||||
http.Error(w, "calendar_url required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// Validate the URL is in the discoverable set — a malicious form must
|
||||
// not be able to seed an item_link pointing at arbitrary HTTP servers.
|
||||
cals, err := s.CalDAV.Client.ListCalendars(r.Context())
|
||||
if err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
}
|
||||
var matched *caldav.Calendar
|
||||
for i := range cals {
|
||||
if cals[i].URL == calURL {
|
||||
matched = &cals[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if matched == nil {
|
||||
http.Error(w, "calendar not in discoverable set", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
meta := map[string]any{
|
||||
"display_name": matched.DisplayName,
|
||||
"calendar_color": matched.Color,
|
||||
"linked_at": time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
if _, err := s.Store.AddLink(r.Context(), it.ID, refTypeCalDAV, calURL, "contains", meta); err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/i/"+it.PrimaryPath(), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleCalDAVCreate handles POST /i/{path}/caldav/create — MKCALENDAR on
|
||||
// dav.msbls.de derived from the item slug, then the item_link insert.
|
||||
func (s *Server) handleCalDAVCreate(w http.ResponseWriter, r *http.Request, path string) {
|
||||
@@ -231,6 +318,22 @@ func (s *Server) detailTodos(ctx context.Context, item *store.Item) ([]calendarT
|
||||
s.Logger.Warn("caldav todos", "calendar", l.RefID, "err", err)
|
||||
continue
|
||||
}
|
||||
// Phase 5j per-item filter: when the linked list contains ANY
|
||||
// projax-tagged VTODO it's a managed list — narrow to entries
|
||||
// carrying this item's `projax:<path>` tag. A list with zero
|
||||
// projax tags is a legacy/unmanaged list and renders unfiltered
|
||||
// (existing pre-5j behaviour, untouched). The cutoff still
|
||||
// applies to DoneRecent on the post-filter slice.
|
||||
if caldav.AnyTodoHasProjaxTag(todos) {
|
||||
want := item.PrimaryPath()
|
||||
filtered := todos[:0:0]
|
||||
for _, td := range todos {
|
||||
if caldav.HasProjaxTagFor(td, want) {
|
||||
filtered = append(filtered, td)
|
||||
}
|
||||
}
|
||||
todos = filtered
|
||||
}
|
||||
ct := calendarTasks{
|
||||
CalendarURL: l.RefID,
|
||||
DisplayName: linkDisplay(l),
|
||||
@@ -310,7 +413,14 @@ func (s *Server) handleCalDAVTodoAction(w http.ResponseWriter, r *http.Request,
|
||||
banner = "Cannot create task with empty summary."
|
||||
break
|
||||
}
|
||||
edit := caldav.VTodoEdit{Summary: &summary}
|
||||
// Phase 5j tag-on-create: every VTODO created from a per-item Add
|
||||
// form gets `projax:<primary-path>` in CATEGORIES so multiple
|
||||
// projax items can share one CalDAV list and the per-item filter
|
||||
// only surfaces the right ones.
|
||||
edit := caldav.VTodoEdit{
|
||||
Summary: &summary,
|
||||
Categories: []string{caldav.ProjaxCategoryFor(it.PrimaryPath())},
|
||||
}
|
||||
if dueStr := strings.TrimSpace(r.FormValue("due")); dueStr != "" {
|
||||
if t, ok := parseDueInput(dueStr); ok {
|
||||
edit.Due = &t
|
||||
@@ -426,11 +536,27 @@ func (s *Server) renderTasksSection(w http.ResponseWriter, r *http.Request, it *
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
}
|
||||
// HTMX swaps re-render the section in place; the picker needs the same
|
||||
// AvailableCalendars data the full /i/{path} render computes. Errors
|
||||
// here are non-fatal — degrade to an empty picker.
|
||||
var available []caldav.Calendar
|
||||
if s.CalDAV != nil {
|
||||
caldavLinks, lerr := s.Store.LinksByType(r.Context(), it.ID, refTypeCalDAV)
|
||||
if lerr != nil {
|
||||
s.Logger.Warn("tasks-section caldav links", "path", it.PrimaryPath(), "err", lerr)
|
||||
}
|
||||
acs, aerr := s.availableCalendarsForItem(r.Context(), caldavLinks)
|
||||
if aerr != nil {
|
||||
s.Logger.Warn("tasks-section available caldav", "path", it.PrimaryPath(), "err", aerr)
|
||||
}
|
||||
available = acs
|
||||
}
|
||||
data := map[string]any{
|
||||
"Item": it,
|
||||
"Tasks": tasks,
|
||||
"CalDAVOn": s.CalDAV != nil,
|
||||
"Banner": banner,
|
||||
"Item": it,
|
||||
"Tasks": tasks,
|
||||
"AvailableCalendars": available,
|
||||
"CalDAVOn": s.CalDAV != nil,
|
||||
"Banner": banner,
|
||||
}
|
||||
s.render(w, r, "tasks_section", data)
|
||||
}
|
||||
|
||||
419
web/caldav_link_existing_test.go
Normal file
419
web/caldav_link_existing_test.go
Normal file
@@ -0,0 +1,419 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/m/projax/caldav"
|
||||
"github.com/m/projax/web"
|
||||
)
|
||||
|
||||
// fakeCalDAVServer is a minimal in-memory CalDAV server: a PROPFIND on
|
||||
// /dav/calendars/m/ returns a fixed two-calendar list, REPORT on each
|
||||
// calendar returns whichever VTODOs the test seeded into todos[url],
|
||||
// and PUT to a calendar URL captures the body so the test can assert
|
||||
// on what projax wrote. Mirrors the pattern in dashboard_events_test.go
|
||||
// but tailored to the Phase 5j flows.
|
||||
type fakeCalDAVServer struct {
|
||||
mu sync.Mutex
|
||||
srv *httptest.Server
|
||||
calendars []caldav.Calendar
|
||||
todos map[string][]string // calendarURL → list of VTODO ICS docs
|
||||
puts map[string]string // url → body of the latest PUT to that url
|
||||
}
|
||||
|
||||
func newFakeCalDAVServer(t *testing.T, cals []caldav.Calendar) *fakeCalDAVServer {
|
||||
t.Helper()
|
||||
f := &fakeCalDAVServer{
|
||||
todos: map[string][]string{},
|
||||
puts: map[string]string{},
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/dav/calendars/m/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "PROPFIND" {
|
||||
f.mu.Lock()
|
||||
cs := f.calendars
|
||||
f.mu.Unlock()
|
||||
w.WriteHeader(207)
|
||||
_, _ = io.WriteString(w, propfindMultistatus(cs))
|
||||
return
|
||||
}
|
||||
http.Error(w, "method "+r.Method, http.StatusMethodNotAllowed)
|
||||
})
|
||||
// Per-calendar handler. Keyed by URL PATH so both the registration
|
||||
// loop and the test's seed lookup (`fake.todos[calURL]`) resolve to
|
||||
// the same map entry regardless of how the httptest host gets baked
|
||||
// into the full URL.
|
||||
for _, c := range cals {
|
||||
path := urlPathOf(c.URL)
|
||||
mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case "REPORT":
|
||||
f.mu.Lock()
|
||||
body := buildReportMultistatus(path, f.todos[path])
|
||||
f.mu.Unlock()
|
||||
w.WriteHeader(207)
|
||||
_, _ = io.WriteString(w, body)
|
||||
case "PUT":
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
f.mu.Lock()
|
||||
f.puts[r.URL.String()] = string(body)
|
||||
f.todos[path] = append(f.todos[path], string(body))
|
||||
f.mu.Unlock()
|
||||
w.Header().Set("ETag", `"fresh"`)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
default:
|
||||
http.Error(w, "method "+r.Method, http.StatusMethodNotAllowed)
|
||||
}
|
||||
})
|
||||
}
|
||||
f.srv = httptest.NewServer(mux)
|
||||
f.calendars = make([]caldav.Calendar, len(cals))
|
||||
// Rewrite URLs to point at the httptest server's host.
|
||||
for i, c := range cals {
|
||||
f.calendars[i] = caldav.Calendar{
|
||||
URL: f.srv.URL + urlPathOf(c.URL),
|
||||
HRef: urlPathOf(c.URL),
|
||||
DisplayName: c.DisplayName,
|
||||
Color: c.Color,
|
||||
}
|
||||
}
|
||||
t.Cleanup(f.srv.Close)
|
||||
return f
|
||||
}
|
||||
|
||||
func urlPathOf(absURL string) string {
|
||||
u, _ := url.Parse(absURL)
|
||||
return u.Path
|
||||
}
|
||||
|
||||
// propfindMultistatus builds the PROPFIND response for the slice of
|
||||
// calendars. Includes the collection itself + each calendar entry, plus
|
||||
// an "inbox" non-calendar that ListCalendars must filter out.
|
||||
func propfindMultistatus(cals []caldav.Calendar) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(`<?xml version="1.0"?><d:multistatus xmlns:d="DAV:" xmlns:cal="urn:ietf:params:xml:ns:caldav">`)
|
||||
b.WriteString(`<d:response><d:href>/dav/calendars/m/</d:href><d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>`)
|
||||
for _, c := range cals {
|
||||
b.WriteString(`<d:response><d:href>` + urlPathOf(c.URL) + `</d:href><d:propstat><d:prop><d:displayname>` + c.DisplayName + `</d:displayname><d:resourcetype><d:collection/><cal:calendar/></d:resourcetype></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>`)
|
||||
}
|
||||
b.WriteString(`</d:multistatus>`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// buildReportMultistatus wraps a slice of VTODO ICS docs into a REPORT
|
||||
// multistatus body, one <d:response> per VTODO.
|
||||
func buildReportMultistatus(calPath string, vtodos []string) string {
|
||||
if len(vtodos) == 0 {
|
||||
return `<?xml version="1.0"?><d:multistatus xmlns:d="DAV:" xmlns:cal="urn:ietf:params:xml:ns:caldav"></d:multistatus>`
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString(`<?xml version="1.0"?><d:multistatus xmlns:d="DAV:" xmlns:cal="urn:ietf:params:xml:ns:caldav">`)
|
||||
for i, ics := range vtodos {
|
||||
b.WriteString(`<d:response><d:href>` + calPath + "t" + itoa(i) + `.ics</d:href><d:propstat><d:prop><d:getetag>"e` + itoa(i) + `"</d:getetag><cal:calendar-data>`)
|
||||
b.WriteString(ics)
|
||||
b.WriteString(`</cal:calendar-data></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>`)
|
||||
}
|
||||
b.WriteString(`</d:multistatus>`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func itoa(n int) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
var buf [20]byte
|
||||
i := len(buf)
|
||||
neg := false
|
||||
if n < 0 {
|
||||
neg = true
|
||||
n = -n
|
||||
}
|
||||
for n > 0 {
|
||||
i--
|
||||
buf[i] = byte('0' + n%10)
|
||||
n /= 10
|
||||
}
|
||||
if neg {
|
||||
i--
|
||||
buf[i] = '-'
|
||||
}
|
||||
return string(buf[i:])
|
||||
}
|
||||
|
||||
// seedItemUnderDev inserts a fresh projax item under dev and returns
|
||||
// its id + primary path. Callers defer cleanup.
|
||||
func seedItemUnderDev(t *testing.T, pool *pgxpool.Pool, slug, title string) (id, primaryPath string) {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
var dev 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,
|
||||
`insert into projax.items (kind, title, slug, parent_ids)
|
||||
values (array['project']::text[], $1, $2, ARRAY[$3]::uuid[])
|
||||
returning id`,
|
||||
title, slug, dev,
|
||||
).Scan(&id); err != nil {
|
||||
t.Fatalf("seed item: %v", err)
|
||||
}
|
||||
return id, "dev." + slug
|
||||
}
|
||||
|
||||
// TestDetailLinkExistingCalendar walks the original ask end-to-end:
|
||||
// 1. Fake CalDAV server exposes 3 calendars + zero VTODOs.
|
||||
// 2. Seed an unlinked projax item under dev.
|
||||
// 3. GET /i/{path} — assert the "link existing" <select> renders with
|
||||
// all 3 calendars.
|
||||
// 4. POST /i/{path}/caldav/link-existing with one URL.
|
||||
// 5. GET /i/{path} again — assert the linked URL is gone from the
|
||||
// picker (already linked) but appears in the tasks section.
|
||||
func TestDetailLinkExistingCalendar(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
|
||||
cals := []caldav.Calendar{
|
||||
{URL: "https://dav.test/dav/calendars/m/Family/", DisplayName: "Family"},
|
||||
{URL: "https://dav.test/dav/calendars/m/Travel/", DisplayName: "Travel"},
|
||||
{URL: "https://dav.test/dav/calendars/m/Vacations-2026/", DisplayName: "Vacations 2026"},
|
||||
}
|
||||
fake := newFakeCalDAVServer(t, cals)
|
||||
srv.CalDAV = &web.CalDAVDeps{Client: caldav.New(fake.srv.URL+"/dav/calendars/m/", "u", "p")}
|
||||
|
||||
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "")
|
||||
slug := "caldav-link-" + stamp
|
||||
id, primary := seedItemUnderDev(t, pool, slug, "Caldav link test")
|
||||
defer pool.Exec(context.Background(), `delete from projax.items where id=$1`, id)
|
||||
|
||||
h := srv.Routes()
|
||||
|
||||
// Step 3: picker renders with three calendars.
|
||||
_, body := get(t, h, "/i/"+primary)
|
||||
for _, want := range []string{
|
||||
`action="/i/` + primary + `/caldav/link-existing"`,
|
||||
`>Family<`,
|
||||
`>Travel<`,
|
||||
`>Vacations 2026<`,
|
||||
`+ Create new list`,
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("unlinked detail page missing %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: POST link-existing. Pick the Vacations 2026 calendar.
|
||||
pickedURL := fake.calendars[2].URL
|
||||
form := url.Values{"calendar_url": {pickedURL}}
|
||||
resp, _ := post(t, h, "/i/"+primary+"/caldav/link-existing", form)
|
||||
if resp != http.StatusSeeOther {
|
||||
t.Fatalf("link-existing POST → %d, want 303", resp)
|
||||
}
|
||||
defer pool.Exec(context.Background(), `delete from projax.item_links where item_id=$1 and ref_id=$2`, id, pickedURL)
|
||||
|
||||
// Step 5: picker no longer offers Vacations 2026 (already linked);
|
||||
// the tasks section now shows the linked calendar's block.
|
||||
_, body = get(t, h, "/i/"+primary)
|
||||
if strings.Contains(body, `<option value="`+pickedURL+`">Vacations 2026</option>`) {
|
||||
t.Errorf("picker should NOT offer the already-linked Vacations 2026 URL")
|
||||
}
|
||||
if !strings.Contains(body, "Vacations 2026") {
|
||||
t.Errorf("tasks section should display the linked Vacations 2026 list")
|
||||
}
|
||||
if !strings.Contains(body, `data-cal="`+pickedURL+`"`) {
|
||||
t.Errorf("tasks section missing cal-block for the linked URL")
|
||||
}
|
||||
}
|
||||
|
||||
// TestVTodoCreateAttachesProjaxCategory exercises the tag-on-create
|
||||
// half of Phase 5j. Posting the Add-task form from /i/{path} must send
|
||||
// a VTODO whose CATEGORIES contains `projax:<path>` so a shared list
|
||||
// can later be filtered per-item.
|
||||
func TestVTodoCreateAttachesProjaxCategory(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
|
||||
cals := []caldav.Calendar{
|
||||
{URL: "https://dav.test/dav/calendars/m/Shared/", DisplayName: "Shared"},
|
||||
}
|
||||
fake := newFakeCalDAVServer(t, cals)
|
||||
srv.CalDAV = &web.CalDAVDeps{Client: caldav.New(fake.srv.URL+"/dav/calendars/m/", "u", "p")}
|
||||
|
||||
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "")
|
||||
slug := "caldav-tag-" + stamp
|
||||
id, primary := seedItemUnderDev(t, pool, slug, "Tag-on-create test")
|
||||
defer pool.Exec(context.Background(), `delete from projax.items where id=$1`, id)
|
||||
calURL := fake.calendars[0].URL
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if _, err := pool.Exec(ctx,
|
||||
`insert into projax.item_links (item_id, ref_type, ref_id, rel)
|
||||
values ($1, 'caldav-list', $2, 'contains')`,
|
||||
id, calURL,
|
||||
); err != nil {
|
||||
t.Fatalf("seed link: %v", err)
|
||||
}
|
||||
defer pool.Exec(context.Background(), `delete from projax.item_links where item_id=$1`, id)
|
||||
|
||||
h := srv.Routes()
|
||||
form := url.Values{
|
||||
"calendar_url": {calURL},
|
||||
"summary": {"Buy travel gear"},
|
||||
}
|
||||
resp, _ := post(t, h, "/i/"+primary+"/caldav/todo/todo-create", form)
|
||||
if resp != http.StatusSeeOther && resp != http.StatusOK {
|
||||
t.Fatalf("todo-create POST → %d", resp)
|
||||
}
|
||||
|
||||
// Inspect what the fake CalDAV server received.
|
||||
fake.mu.Lock()
|
||||
defer fake.mu.Unlock()
|
||||
if len(fake.puts) == 0 {
|
||||
t.Fatalf("expected at least one PUT to the fake CalDAV server")
|
||||
}
|
||||
var got string
|
||||
for _, body := range fake.puts {
|
||||
got = body
|
||||
break
|
||||
}
|
||||
wantTag := "projax:" + primary
|
||||
if !strings.Contains(got, "CATEGORIES:"+wantTag) {
|
||||
t.Errorf("PUT body missing CATEGORIES tag %q. Body:\n%s", wantTag, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetailFilterByProjaxCategory exercises the read-side filter:
|
||||
// when the linked list has ANY projax: tag, the detail page only shows
|
||||
// the VTODOs whose CATEGORIES include THIS item's tag. VTODOs tagged
|
||||
// for OTHER items must NOT leak through.
|
||||
func TestDetailFilterByProjaxCategory(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
|
||||
cals := []caldav.Calendar{
|
||||
{URL: "https://dav.test/dav/calendars/m/Vacations-2026/", DisplayName: "Vacations 2026"},
|
||||
}
|
||||
fake := newFakeCalDAVServer(t, cals)
|
||||
srv.CalDAV = &web.CalDAVDeps{Client: caldav.New(fake.srv.URL+"/dav/calendars/m/", "u", "p")}
|
||||
calURL := fake.calendars[0].URL
|
||||
|
||||
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "")
|
||||
idA, primaryA := seedItemUnderDev(t, pool, "trip-a-"+stamp, "Trip A")
|
||||
idB, primaryB := seedItemUnderDev(t, pool, "trip-b-"+stamp, "Trip B")
|
||||
defer pool.Exec(context.Background(), `delete from projax.items where id in ($1, $2)`, idA, idB)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
for _, id := range []string{idA, idB} {
|
||||
if _, err := pool.Exec(ctx,
|
||||
`insert into projax.item_links (item_id, ref_type, ref_id, rel)
|
||||
values ($1, 'caldav-list', $2, 'contains')`,
|
||||
id, calURL,
|
||||
); err != nil {
|
||||
t.Fatalf("seed link: %v", err)
|
||||
}
|
||||
}
|
||||
defer pool.Exec(context.Background(), `delete from projax.item_links where ref_id=$1`, calURL)
|
||||
|
||||
// Three VTODOs on the SHARED list: one tagged for A, one for B, one
|
||||
// for both.
|
||||
tagA := "projax:" + primaryA
|
||||
tagB := "projax:" + primaryB
|
||||
fake.mu.Lock()
|
||||
fake.todos[urlPathOf(calURL)] = []string{
|
||||
todoICS("uid-only-a", "Book flight A", []string{tagA}),
|
||||
todoICS("uid-only-b", "Book flight B", []string{tagB}),
|
||||
todoICS("uid-shared", "Travel insurance", []string{tagA, tagB}),
|
||||
}
|
||||
fake.mu.Unlock()
|
||||
|
||||
h := srv.Routes()
|
||||
_, body := get(t, h, "/i/"+primaryA)
|
||||
if !strings.Contains(body, "Book flight A") {
|
||||
t.Errorf("Trip A detail missing tagged-A summary")
|
||||
}
|
||||
if strings.Contains(body, "Book flight B") {
|
||||
t.Errorf("Trip A detail leaked tagged-B summary — filter broken")
|
||||
}
|
||||
if !strings.Contains(body, "Travel insurance") {
|
||||
t.Errorf("Trip A detail missing dual-tagged summary (multi-tag contract)")
|
||||
}
|
||||
|
||||
// Trip B sees the mirror image: B + shared, not A.
|
||||
_, body = get(t, h, "/i/"+primaryB)
|
||||
if strings.Contains(body, "Book flight A") {
|
||||
t.Errorf("Trip B detail leaked tagged-A summary")
|
||||
}
|
||||
if !strings.Contains(body, "Book flight B") {
|
||||
t.Errorf("Trip B detail missing tagged-B summary")
|
||||
}
|
||||
if !strings.Contains(body, "Travel insurance") {
|
||||
t.Errorf("Trip B detail missing dual-tagged summary")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetailUntaggedListShowsAll proves the legacy fallback: a linked
|
||||
// list with ZERO projax: tags is treated as unmanaged — every VTODO
|
||||
// renders, untouched. Without this users with pre-5j lists would see
|
||||
// the detail page suddenly hide all their existing tasks.
|
||||
func TestDetailUntaggedListShowsAll(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
|
||||
cals := []caldav.Calendar{
|
||||
{URL: "https://dav.test/dav/calendars/m/Home/", DisplayName: "Home"},
|
||||
}
|
||||
fake := newFakeCalDAVServer(t, cals)
|
||||
srv.CalDAV = &web.CalDAVDeps{Client: caldav.New(fake.srv.URL+"/dav/calendars/m/", "u", "p")}
|
||||
calURL := fake.calendars[0].URL
|
||||
|
||||
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "")
|
||||
id, primary := seedItemUnderDev(t, pool, "home-legacy-"+stamp, "Home legacy")
|
||||
defer pool.Exec(context.Background(), `delete from projax.items where id=$1`, id)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if _, err := pool.Exec(ctx,
|
||||
`insert into projax.item_links (item_id, ref_type, ref_id, rel)
|
||||
values ($1, 'caldav-list', $2, 'contains')`,
|
||||
id, calURL,
|
||||
); err != nil {
|
||||
t.Fatalf("seed link: %v", err)
|
||||
}
|
||||
defer pool.Exec(context.Background(), `delete from projax.item_links where item_id=$1`, id)
|
||||
|
||||
fake.mu.Lock()
|
||||
fake.todos[urlPathOf(calURL)] = []string{
|
||||
todoICS("legacy-1", "Pick up bread", nil),
|
||||
todoICS("legacy-2", "Call dentist", []string{"home", "errands"}),
|
||||
}
|
||||
fake.mu.Unlock()
|
||||
|
||||
h := srv.Routes()
|
||||
_, body := get(t, h, "/i/"+primary)
|
||||
if !strings.Contains(body, "Pick up bread") {
|
||||
t.Errorf("untagged-list detail missing legacy todo 'Pick up bread'")
|
||||
}
|
||||
if !strings.Contains(body, "Call dentist") {
|
||||
t.Errorf("untagged-list detail missing legacy todo with non-projax categories")
|
||||
}
|
||||
}
|
||||
|
||||
// todoICS builds a minimal VTODO ICS doc with optional CATEGORIES.
|
||||
func todoICS(uid, summary string, categories []string) string {
|
||||
cat := ""
|
||||
if len(categories) > 0 {
|
||||
cat = "CATEGORIES:" + strings.Join(categories, ",") + "\r\n"
|
||||
}
|
||||
return "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nUID:" + uid + "\r\nSUMMARY:" + summary + "\r\nSTATUS:NEEDS-ACTION\r\n" + cat + "END:VTODO\r\nEND:VCALENDAR"
|
||||
}
|
||||
123
web/new_form_test.go
Normal file
123
web/new_form_test.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestNewFormPreselectsParent reproduces m's bug report: GET /new?parent=admin
|
||||
// must render the Parents <select> populated with the full project list AND
|
||||
// pre-select the option whose value matches admin's item id. Pre-fix the
|
||||
// handler passed no ParentOptions to the template, so the <select> was empty
|
||||
// and there was nothing to pre-select.
|
||||
func TestNewFormPreselectsParent(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
|
||||
code, body := get(t, h, "/new?parent=admin")
|
||||
if code != 200 {
|
||||
t.Fatalf("GET /new?parent=admin → %d body=%s", code, body)
|
||||
}
|
||||
|
||||
// The Parents <select> must be populated. admin is a root area present
|
||||
// in every projax instance — its option should be there.
|
||||
if !strings.Contains(body, `<option value="`) {
|
||||
t.Fatalf("Parents <select> is empty — no <option> rendered. Body excerpt: %s",
|
||||
body[strings.Index(body, "parent_ids"):min(len(body), strings.Index(body, "parent_ids")+800)])
|
||||
}
|
||||
if !strings.Contains(body, `>admin</option>`) {
|
||||
t.Errorf("expected an <option>...>admin</option> in the Parents <select>")
|
||||
}
|
||||
|
||||
// The admin option must be the selected one — that's the prefill contract.
|
||||
// We anchor on the path (rendered as the option label) since the id is a
|
||||
// uuid we'd otherwise have to look up.
|
||||
adminIdx := strings.Index(body, `>admin</option>`)
|
||||
if adminIdx < 0 {
|
||||
t.Fatalf("admin option not found in rendered Parents select")
|
||||
}
|
||||
// Look back ~200 chars to the <option ... selected> opening tag.
|
||||
from := adminIdx - 200
|
||||
if from < 0 {
|
||||
from = 0
|
||||
}
|
||||
openingTag := body[from:adminIdx]
|
||||
if !strings.Contains(openingTag, "selected") {
|
||||
t.Errorf("admin <option> not marked selected; opening tag was: %s", openingTag)
|
||||
}
|
||||
|
||||
// And other unrelated options must NOT be selected. Pick `dev` (another
|
||||
// root area) as the counter-anchor.
|
||||
devIdx := strings.Index(body, `>dev</option>`)
|
||||
if devIdx >= 0 {
|
||||
from := devIdx - 200
|
||||
if from < 0 {
|
||||
from = 0
|
||||
}
|
||||
devTag := body[from:devIdx]
|
||||
if strings.Contains(devTag, "selected") {
|
||||
t.Errorf("dev <option> should NOT be selected when ?parent=admin; opening tag was: %s", devTag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewFormHasSlugSuggestScript pins the Phase 5k slug auto-suggest:
|
||||
// the new-item template ships an inline <script> that derives a
|
||||
// kebab-case slug from the title as the user types and stops syncing
|
||||
// once the slug is edited manually. Without this guard a future
|
||||
// template refactor could silently strip the script.
|
||||
func TestNewFormHasSlugSuggestScript(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
_, body := get(t, h, "/new")
|
||||
for _, want := range []string{
|
||||
`id="new-title"`,
|
||||
`id="new-slug"`,
|
||||
// Algorithm signatures we don't want a "harmless cleanup" pass
|
||||
// to drop quietly.
|
||||
"normalize('NFD')",
|
||||
"replace(/ß/g, 'ss')",
|
||||
"replace(/[^a-z0-9]+/g, '-')",
|
||||
"slice(0, 63)",
|
||||
"dataset.userEdited",
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("new-item template missing slug-suggest fragment %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewFormNoParentParamRendersAllOptions confirms the Parents <select>
|
||||
// is populated even when no ?parent= is supplied — clicking "+ New" from the
|
||||
// nav should still let the user pick any parent.
|
||||
func TestNewFormNoParentParamRendersAllOptions(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
|
||||
code, body := get(t, h, "/new")
|
||||
if code != 200 {
|
||||
t.Fatalf("GET /new → %d", code)
|
||||
}
|
||||
// At least one option exists.
|
||||
if !strings.Contains(body, `<option value="`) {
|
||||
t.Fatalf("Parents <select> is empty on /new (no ?parent= param)")
|
||||
}
|
||||
// Nothing pre-selected.
|
||||
if strings.Contains(body, `<option value="`) && strings.Contains(body, `" selected>`) {
|
||||
// Make sure no Parents <select> option is selected — Status options
|
||||
// might use selected for the default, so anchor on parent_ids context.
|
||||
pIdx := strings.Index(body, `name="parent_ids"`)
|
||||
if pIdx >= 0 {
|
||||
selectClose := strings.Index(body[pIdx:], `</select>`)
|
||||
if selectClose > 0 {
|
||||
parentBlock := body[pIdx : pIdx+selectClose]
|
||||
if strings.Contains(parentBlock, "selected") {
|
||||
t.Errorf("no Parents option should be selected on bare /new, but block contains 'selected': %s", parentBlock)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
266
web/project_filter_test.go
Normal file
266
web/project_filter_test.go
Normal file
@@ -0,0 +1,266 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// projectFixture seeds a subtree shaped:
|
||||
//
|
||||
// dev/ (existing)
|
||||
// <stamp>-root (root of the test subtree)
|
||||
// <stamp>-child (descendant of root)
|
||||
// <stamp>-outside (sibling of root under dev — NOT a descendant)
|
||||
//
|
||||
// Returns the slugs + primary paths. Callers defer the row cleanup.
|
||||
type projectFixture struct {
|
||||
rootSlug, childSlug, outsideSlug string
|
||||
rootPath, childPath, outsidePath string
|
||||
rootID, childID, outsideID string
|
||||
}
|
||||
|
||||
func seedProjectFixture(t *testing.T, pool *pgxpool.Pool) projectFixture {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
var dev 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)
|
||||
}
|
||||
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "")
|
||||
fx := projectFixture{
|
||||
rootSlug: "proj-root-" + stamp,
|
||||
childSlug: "proj-child-" + stamp,
|
||||
outsideSlug: "proj-outside-" + stamp,
|
||||
}
|
||||
// root + outside both live directly under dev.
|
||||
if err := pool.QueryRow(ctx,
|
||||
`insert into projax.items (kind, title, slug, parent_ids)
|
||||
values (array['project']::text[], $1, $1, ARRAY[$2]::uuid[])
|
||||
returning id`,
|
||||
fx.rootSlug, dev,
|
||||
).Scan(&fx.rootID); err != nil {
|
||||
t.Fatalf("seed root: %v", err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx,
|
||||
`insert into projax.items (kind, title, slug, parent_ids)
|
||||
values (array['project']::text[], $1, $1, ARRAY[$2]::uuid[])
|
||||
returning id`,
|
||||
fx.outsideSlug, dev,
|
||||
).Scan(&fx.outsideID); err != nil {
|
||||
t.Fatalf("seed outside: %v", err)
|
||||
}
|
||||
// child lives under root.
|
||||
if err := pool.QueryRow(ctx,
|
||||
`insert into projax.items (kind, title, slug, parent_ids)
|
||||
values (array['project']::text[], $1, $1, ARRAY[$2]::uuid[])
|
||||
returning id`,
|
||||
fx.childSlug, fx.rootID,
|
||||
).Scan(&fx.childID); err != nil {
|
||||
t.Fatalf("seed child: %v", err)
|
||||
}
|
||||
fx.rootPath = "dev." + fx.rootSlug
|
||||
fx.childPath = fx.rootPath + "." + fx.childSlug
|
||||
fx.outsidePath = "dev." + fx.outsideSlug
|
||||
return fx
|
||||
}
|
||||
|
||||
func cleanupProjectFixture(pool *pgxpool.Pool, fx projectFixture) {
|
||||
pool.Exec(context.Background(), `delete from projax.items where id in ($1, $2, $3)`, fx.rootID, fx.childID, fx.outsideID)
|
||||
}
|
||||
|
||||
// TestProjectFilterNarrowsTree exercises the / (tree) handler — applyTreeFilter
|
||||
// passes the project filter through TreeFilter.Matches, so ?project=<root>
|
||||
// must show only root + descendants.
|
||||
func TestProjectFilterNarrowsTree(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
fx := seedProjectFixture(t, pool)
|
||||
defer cleanupProjectFixture(pool, fx)
|
||||
h := srv.Routes()
|
||||
|
||||
_, body := get(t, h, "/?project="+fx.rootPath)
|
||||
if !strings.Contains(body, fx.rootPath) {
|
||||
t.Errorf("tree ?project=<root> missing root path %q", fx.rootPath)
|
||||
}
|
||||
if !strings.Contains(body, fx.childPath) {
|
||||
t.Errorf("tree ?project=<root> missing child path %q (descendants default ON)", fx.childPath)
|
||||
}
|
||||
if strings.Contains(body, fx.outsidePath) {
|
||||
t.Errorf("tree ?project=<root> leaked outside path %q", fx.outsidePath)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProjectFilterNarrowsTimeline — buildTimeline funnels items via
|
||||
// q.Filter.Matches before fan-out, so ?project=<root> must drop the
|
||||
// creation row for the outside sibling but keep root + child.
|
||||
func TestProjectFilterNarrowsTimeline(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
fx := seedProjectFixture(t, pool)
|
||||
defer cleanupProjectFixture(pool, fx)
|
||||
h := srv.Routes()
|
||||
|
||||
_, body := get(t, h, "/timeline?refresh=1&project="+fx.rootPath)
|
||||
if !strings.Contains(body, fx.rootPath) {
|
||||
t.Errorf("timeline ?project=<root> missing root creation row")
|
||||
}
|
||||
if !strings.Contains(body, fx.childPath) {
|
||||
t.Errorf("timeline ?project=<root> missing child creation row")
|
||||
}
|
||||
if strings.Contains(body, fx.outsidePath) {
|
||||
t.Errorf("timeline ?project=<root> leaked outside creation row %q", fx.outsidePath)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProjectFilterNarrowsCalendar — buildCalendar funnels items via
|
||||
// q.Filter.Matches; rows surface from dated item_links. Seed a dated link
|
||||
// on each fixture item, then verify scoping by ?project=<root>.
|
||||
func TestProjectFilterNarrowsCalendar(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
fx := seedProjectFixture(t, pool)
|
||||
defer cleanupProjectFixture(pool, fx)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
rootNote := "cal-root-" + fx.rootSlug
|
||||
childNote := "cal-child-" + fx.childSlug
|
||||
outsideNote := "cal-outside-" + fx.outsideSlug
|
||||
if _, err := pool.Exec(ctx,
|
||||
`insert into projax.item_links (item_id, ref_type, ref_id, rel, note, event_date)
|
||||
values ($1, 'document', $2, 'contains', $3, current_date),
|
||||
($4, 'document', $5, 'contains', $6, current_date),
|
||||
($7, 'document', $8, 'contains', $9, current_date)`,
|
||||
fx.rootID, "https://example.com/cal-root", rootNote,
|
||||
fx.childID, "https://example.com/cal-child", childNote,
|
||||
fx.outsideID, "https://example.com/cal-outside", outsideNote,
|
||||
); err != nil {
|
||||
t.Fatalf("seed dated links: %v", err)
|
||||
}
|
||||
defer pool.Exec(context.Background(), `delete from projax.item_links where item_id in ($1, $2, $3)`, fx.rootID, fx.childID, fx.outsideID)
|
||||
|
||||
h := srv.Routes()
|
||||
_, body := get(t, h, "/calendar?refresh=1&project="+fx.rootPath)
|
||||
if !strings.Contains(body, rootNote) {
|
||||
t.Errorf("calendar ?project=<root> missing root note %q", rootNote)
|
||||
}
|
||||
if !strings.Contains(body, childNote) {
|
||||
t.Errorf("calendar ?project=<root> missing child note %q (descendants ON)", childNote)
|
||||
}
|
||||
if strings.Contains(body, outsideNote) {
|
||||
t.Errorf("calendar ?project=<root> leaked outside note %q", outsideNote)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProjectFilterNarrowsDashboard — dashboard filters items via Matches
|
||||
// when q.Filter.Active() is true. The Stale-projects card is the most
|
||||
// reliable surface to verify since it iterates the full item set on
|
||||
// every render.
|
||||
func TestProjectFilterNarrowsDashboard(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
fx := seedProjectFixture(t, pool)
|
||||
defer cleanupProjectFixture(pool, fx)
|
||||
h := srv.Routes()
|
||||
|
||||
_, body := get(t, h, "/dashboard?project="+fx.rootPath)
|
||||
if !strings.Contains(body, fx.rootPath) {
|
||||
t.Errorf("dashboard ?project=<root> missing root path %q", fx.rootPath)
|
||||
}
|
||||
if strings.Contains(body, fx.outsidePath) {
|
||||
t.Errorf("dashboard ?project=<root> leaked outside path %q", fx.outsidePath)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProjectFilterNarrowsBulk reproduces the actual bug: /admin/bulk's
|
||||
// bulkMatches was a near-clone of TreeFilter.Matches that never picked up
|
||||
// the Phase 5i Slice A ProjectPath block, so ?project=<root> silently
|
||||
// ignored the filter. Pre-fix the outside item leaked into the bulk list.
|
||||
func TestProjectFilterNarrowsBulk(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
fx := seedProjectFixture(t, pool)
|
||||
defer cleanupProjectFixture(pool, fx)
|
||||
h := srv.Routes()
|
||||
|
||||
_, body := get(t, h, "/admin/bulk?project="+fx.rootPath)
|
||||
if !strings.Contains(body, fx.rootPath) {
|
||||
t.Errorf("bulk ?project=<root> missing root path %q", fx.rootPath)
|
||||
}
|
||||
if !strings.Contains(body, fx.childPath) {
|
||||
t.Errorf("bulk ?project=<root> missing child path %q (descendants ON)", fx.childPath)
|
||||
}
|
||||
if strings.Contains(body, fx.outsidePath) {
|
||||
t.Errorf("BUG: /admin/bulk ?project=<root> leaked outside path %q — bulkMatches missing the ProjectPath gate", fx.outsidePath)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProjectFilterDescendantsToggle pins m's Q5 pick: the toggle is
|
||||
// exposed explicitly. With project_descendants=0 the filter narrows to
|
||||
// the single root item only — the child path must drop out.
|
||||
func TestProjectFilterDescendantsToggle(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
fx := seedProjectFixture(t, pool)
|
||||
defer cleanupProjectFixture(pool, fx)
|
||||
h := srv.Routes()
|
||||
|
||||
// Default (descendants on) — child included.
|
||||
_, on := get(t, h, "/?project="+fx.rootPath)
|
||||
if !strings.Contains(on, fx.childPath) {
|
||||
t.Errorf("descendants=on should include child path %q", fx.childPath)
|
||||
}
|
||||
|
||||
// Toggled off — child dropped, root still in.
|
||||
_, off := get(t, h, "/?project="+fx.rootPath+"&project_descendants=0")
|
||||
if !strings.Contains(off, fx.rootPath) {
|
||||
t.Errorf("descendants=off should still include root %q", fx.rootPath)
|
||||
}
|
||||
if strings.Contains(off, fx.childPath) {
|
||||
t.Errorf("descendants=off leaked child path %q — IncludeDescendants gate not honoured", fx.childPath)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTimelineKindMultiValueSurvives mirrors the earlier calendar-filter
|
||||
// fix: <select multiple> chip submission emits `?kind=event&kind=doc`,
|
||||
// and the timeline's previous q.Get("kind") + comma-split dropped every
|
||||
// value past the first. parseValues threads BOTH URL shapes through.
|
||||
func TestTimelineKindMultiValueSurvives(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
// We probe at the parse level via the page render: a `?kind=event&kind=doc`
|
||||
// URL must round-trip both kinds into q.Kinds, so the kind multi-select
|
||||
// in the rendered form preserves BOTH as selected options.
|
||||
_, body := get(t, h, "/timeline?kind=event&kind=doc")
|
||||
// The timeline's chip-strip <select> emits `<option value="x" selected>`
|
||||
// only when q.Kinds contains "x". Pre-fix only the first value
|
||||
// survived, so the second option lost its selected attr. The template
|
||||
// has whitespace padding between value and selected so we anchor on
|
||||
// the `value="X"` + `selected` pair within a small window — the
|
||||
// `</option>` for the same X then closes the option.
|
||||
checkSelected := func(kind string) {
|
||||
idx := strings.Index(body, `<option value="`+kind+`"`)
|
||||
if idx < 0 {
|
||||
t.Errorf("rendered form missing <option value=%q>", kind)
|
||||
return
|
||||
}
|
||||
// Slice until the following </option>; the selected attribute, if
|
||||
// present, lives in that window.
|
||||
end := strings.Index(body[idx:], `</option>`)
|
||||
if end < 0 {
|
||||
t.Errorf("rendered form malformed near <option value=%q>", kind)
|
||||
return
|
||||
}
|
||||
window := body[idx : idx+end]
|
||||
if !strings.Contains(window, "selected") {
|
||||
t.Errorf("?kind=event&kind=doc lost %q selection: window=%q", kind, window)
|
||||
}
|
||||
}
|
||||
checkSelected("event")
|
||||
checkSelected("doc")
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/m/projax/caldav"
|
||||
"github.com/m/projax/internal/aggregate"
|
||||
"github.com/m/projax/internal/cache"
|
||||
"github.com/m/projax/internal/itemwrite"
|
||||
@@ -151,7 +152,7 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) {
|
||||
},
|
||||
}
|
||||
pages := map[string]*template.Template{}
|
||||
for _, name := range []string{"new", "classify", "caldav_admin", "caldav_disabled", "error", "views"} {
|
||||
for _, name := range []string{"new", "classify", "caldav_admin", "caldav_disabled", "error"} {
|
||||
t, err := template.New(name).Funcs(funcs).ParseFS(templatesFS,
|
||||
"templates/layout.tmpl",
|
||||
"templates/"+name+".tmpl",
|
||||
@@ -382,10 +383,9 @@ func (s *Server) Routes() http.Handler {
|
||||
mux.HandleFunc("GET /admin/caldav", s.handleCalDAVAdmin)
|
||||
mux.HandleFunc("POST /admin/caldav/link", s.handleCalDAVLink)
|
||||
mux.HandleFunc("POST /admin/caldav/unlink", s.handleCalDAVUnlink)
|
||||
mux.HandleFunc("GET /views", s.handleViewsIndex)
|
||||
mux.HandleFunc("POST /views", s.handleViewCreate)
|
||||
mux.HandleFunc("GET /views/", s.handleViewRedirect)
|
||||
mux.HandleFunc("POST /views/", s.handleViewWrite)
|
||||
// /views routes land in slice B (paliad-shape: GET /views, GET
|
||||
// /views/{slug}, GET /views/new, GET /views/{slug}/edit, plus POST CRUD).
|
||||
// Between slice A and slice B these URLs 404 by design.
|
||||
mux.HandleFunc("GET /login", s.handleLoginForm)
|
||||
mux.HandleFunc("POST /login", s.handleLoginSubmit)
|
||||
mux.HandleFunc("POST /logout", s.handleLogout)
|
||||
@@ -450,30 +450,11 @@ func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) {
|
||||
filter := ParseTreeFilter(r.URL.Query())
|
||||
viewSet := PageViewTypes("/")
|
||||
view := ParseViewType(r.URL.Query(), viewSet)
|
||||
var defaultBanner *store.View
|
||||
// Phase 5i Slice D: ?view=<uuid> resolves a saved view's filter +
|
||||
// view_type into the current request, overriding URL-only chip state.
|
||||
// Resolution failure (deleted view, malformed payload) is logged and
|
||||
// silently falls back to the URL-derived filter — the page stays
|
||||
// renderable rather than 500ing.
|
||||
if saved, err := s.applySavedView(r, &filter, &view); err == nil && saved != nil {
|
||||
// Re-validate view_type against the route catalog so a saved
|
||||
// kanban-view URL opened on / (before slice C ships kanban) lands on
|
||||
// the default with the chip showing the wanted view as locked.
|
||||
view = viewSet.Resolve(view)
|
||||
} else if err != nil {
|
||||
s.Logger.Warn("applySavedView", "id", r.URL.Query().Get("view"), "err", err)
|
||||
} else {
|
||||
// Phase 5i Slice E: no explicit ?view= → check for a page default.
|
||||
// applyDefaultView returns nil unless the URL is "clean" (no chip
|
||||
// state) AND a default exists for this page.
|
||||
if def, err := s.applyDefaultView(r, "tree", &filter, &view); err == nil && def != nil {
|
||||
view = viewSet.Resolve(view)
|
||||
defaultBanner = def
|
||||
} else if err != nil {
|
||||
s.Logger.Warn("applyDefaultView", "page", "tree", "err", err)
|
||||
}
|
||||
}
|
||||
// Phase 5j: ?view= overlay + is_default_for resolution deleted with the
|
||||
// 5i shape. /views/{slug} (slice B+) renders saved views as their own
|
||||
// pages; legacy ?view=<uuid> URLs are 302-redirected from a dedicated
|
||||
// handler (slice C). handleTree stays focused on the tree-as-tree
|
||||
// surface and no longer hijacks itself based on a query param.
|
||||
roots, orphans, total, orphanN, matched := applyTreeFilter(items, filter, linkKinds)
|
||||
counts := computeChipCounts(items, filter, linkKinds, tags)
|
||||
// Phase 5i Slice B: the card view renders a flat grid of matched items
|
||||
@@ -503,7 +484,6 @@ func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) {
|
||||
"Kanban": kanban,
|
||||
"GroupBy": groupBy,
|
||||
"GroupByChips": groupByChips,
|
||||
"DefaultBanner": defaultBanner,
|
||||
// ActiveTags kept for backwards-compat with the old template path; removed
|
||||
// after the template migrates fully.
|
||||
"ActiveTags": filter.Tags,
|
||||
@@ -571,6 +551,22 @@ func (s *Server) handleDetail(w http.ResponseWriter, r *http.Request) {
|
||||
if err != nil {
|
||||
s.Logger.Warn("detail tasks", "path", it.PrimaryPath(), "err", err)
|
||||
}
|
||||
// Phase 5j: pre-load discoverable CalDAV calendars (minus the ones
|
||||
// already linked) so the per-item Tasks section can offer a "Link
|
||||
// existing list" picker alongside the create-new affordance. Errors
|
||||
// are non-fatal — the section falls back to its pre-5j shape.
|
||||
var availableCalendars []caldav.Calendar
|
||||
if s.CalDAV != nil {
|
||||
caldavLinks, lerr := s.Store.LinksByType(r.Context(), it.ID, refTypeCalDAV)
|
||||
if lerr != nil {
|
||||
s.Logger.Warn("detail caldav links", "path", it.PrimaryPath(), "err", lerr)
|
||||
}
|
||||
acs, aerr := s.availableCalendarsForItem(r.Context(), caldavLinks)
|
||||
if aerr != nil {
|
||||
s.Logger.Warn("detail available caldav", "path", it.PrimaryPath(), "err", aerr)
|
||||
}
|
||||
availableCalendars = acs
|
||||
}
|
||||
issues, err := s.detailIssues(r.Context(), it)
|
||||
if err != nil {
|
||||
s.Logger.Warn("detail issues", "path", it.PrimaryPath(), "err", err)
|
||||
@@ -589,9 +585,10 @@ func (s *Server) handleDetail(w http.ResponseWriter, r *http.Request) {
|
||||
"Item": it,
|
||||
"ParentOptions": parents,
|
||||
"StatusOptions": []string{"active", "done", "archived"},
|
||||
"Tasks": tasks,
|
||||
"CalDAVOn": s.CalDAV != nil,
|
||||
"Issues": issues,
|
||||
"Tasks": tasks,
|
||||
"AvailableCalendars": availableCalendars,
|
||||
"CalDAVOn": s.CalDAV != nil,
|
||||
"Issues": issues,
|
||||
"IssuesOpenTotal": openTotal,
|
||||
"GiteaOn": s.Gitea != nil,
|
||||
"Documents": documents,
|
||||
@@ -609,6 +606,10 @@ func (s *Server) handleDetailWrite(w http.ResponseWriter, r *http.Request) {
|
||||
s.handleCalDAVCreate(w, r, base)
|
||||
return
|
||||
}
|
||||
if base, ok := strings.CutSuffix(path, "/caldav/link-existing"); ok {
|
||||
s.handleCalDAVLinkExisting(w, r, base)
|
||||
return
|
||||
}
|
||||
for _, action := range []string{"complete", "reopen", "edit", "delete", "todo-create"} {
|
||||
if base, ok := strings.CutSuffix(path, "/caldav/todo/"+action); ok {
|
||||
s.handleCalDAVTodoAction(w, r, base, action)
|
||||
@@ -859,9 +860,18 @@ func (s *Server) handleNewForm(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
parent = p
|
||||
}
|
||||
// new.tmpl iterates {{range .ParentOptions}} to render the Parents
|
||||
// <select>. Without this the dropdown was empty and `?parent=admin`
|
||||
// had nothing to pre-select — the symptom m hit.
|
||||
parents, err := s.parentOptions(r.Context())
|
||||
if err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
}
|
||||
s.render(w, r, "new", map[string]any{
|
||||
"Title": "new",
|
||||
"Parent": parent,
|
||||
"ParentOptions": parents,
|
||||
"StatusOptions": []string{"active", "done", "archived"},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
<h1>New item</h1>
|
||||
<p class="meta">Suggested parent: <strong>{{if .Parent}}{{.Parent.PrimaryPath}}{{else}}(root){{end}}</strong></p>
|
||||
|
||||
<form method="post" action="/new" class="edit">
|
||||
<form method="post" action="/new" class="edit" id="new-item-form">
|
||||
<input type="hidden" name="kind" value="project">
|
||||
<label>Title <input name="title" required></label>
|
||||
<label>Slug <input name="slug" required pattern="[^.]+" placeholder="lowercase, no dots"></label>
|
||||
<label>Title <input id="new-title" name="title" required></label>
|
||||
<label>Slug <input id="new-slug" name="slug" required pattern="[^.]+" placeholder="lowercase, no dots"></label>
|
||||
<label>Parents <small class="muted">(hold Ctrl/Cmd to pick multiple — leave empty for a root item)</small>
|
||||
<select name="parent_ids" multiple size="6">
|
||||
{{range .ParentOptions}}
|
||||
@@ -32,4 +32,38 @@
|
||||
<a class="cancel" href="{{if .Parent}}/i/{{.Parent.PrimaryPath}}{{else}}/{{end}}">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
<script>
|
||||
// Phase 5k: auto-suggest a kebab-case slug from Title as the user types.
|
||||
// Strips diacritics (Müller → muller, São → sao), German ß → ss, collapses
|
||||
// any non-alphanumeric run into a single hyphen, trims edge hyphens, caps
|
||||
// at the 63-char limit the itemwrite validator enforces. Once the user
|
||||
// edits the slug manually, the sync stops — typing in Title no longer
|
||||
// clobbers their override. A pre-filled slug also counts as user-edited
|
||||
// (rare for /new but defensive).
|
||||
(function() {
|
||||
var title = document.getElementById('new-title');
|
||||
var slug = document.getElementById('new-slug');
|
||||
if (!title || !slug) return;
|
||||
function kebab(s) {
|
||||
return s
|
||||
.normalize('NFD').replace(/[̀-ͯ]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/ß/g, 'ss')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.replace(/-{2,}/g, '-')
|
||||
.slice(0, 63);
|
||||
}
|
||||
if (slug.value && slug.value.length > 0) {
|
||||
slug.dataset.userEdited = '1';
|
||||
}
|
||||
title.addEventListener('input', function() {
|
||||
if (slug.dataset.userEdited === '1') return;
|
||||
slug.value = kebab(title.value);
|
||||
});
|
||||
slug.addEventListener('input', function() {
|
||||
slug.dataset.userEdited = '1';
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
@@ -94,9 +94,29 @@
|
||||
{{end}}
|
||||
{{else}}
|
||||
<p class="muted">No CalDAV list linked.</p>
|
||||
<form method="post" action="/i/{{.Item.PrimaryPath}}/caldav/create" class="inline">
|
||||
<button type="submit">Create CalDAV list</button>
|
||||
</form>
|
||||
{{end}}
|
||||
|
||||
{{/* Phase 5j: per-item picker for sharing an existing list across
|
||||
multiple projax items (e.g. one "Vacations 2026" list under
|
||||
several admin.vacations sub-items). Renders in BOTH states:
|
||||
unlinked items see it next to Create-new; already-linked items
|
||||
see it as "+ link another" for the multi-list flow. */}}
|
||||
<div class="caldav-actions">
|
||||
{{if .AvailableCalendars}}
|
||||
<form method="post" action="/i/{{.Item.PrimaryPath}}/caldav/link-existing" class="caldav-link-existing inline">
|
||||
<label class="visually-hidden" for="caldav-link-existing-select">Link existing CalDAV list</label>
|
||||
<select id="caldav-link-existing-select" name="calendar_url" required>
|
||||
<option value="">— link existing list —</option>
|
||||
{{range .AvailableCalendars}}<option value="{{.URL}}">{{.DisplayName}}</option>{{end}}
|
||||
</select>
|
||||
<button type="submit">Link</button>
|
||||
</form>
|
||||
{{end}}
|
||||
{{if not .Tasks}}
|
||||
<form method="post" action="/i/{{.Item.PrimaryPath}}/caldav/create" class="inline">
|
||||
<button type="submit">+ Create new list</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
{{define "tree-section"}}
|
||||
<section id="tree-section" class="tree-section">
|
||||
{{if .DefaultBanner}}
|
||||
<p class="default-banner muted">
|
||||
Showing default view: <strong>{{.DefaultBanner.Name}}</strong> ·
|
||||
<a href="/?nodefault=1"
|
||||
hx-get="/?nodefault=1" hx-target="#tree-section" hx-swap="outerHTML" hx-push-url="true">clear</a>
|
||||
</p>
|
||||
{{end}}
|
||||
<p class="counts">
|
||||
<strong>{{.Matched}}</strong> / <strong>{{.Total}}</strong> items match
|
||||
{{if .OrphanN}} · <strong>{{.OrphanN}}</strong> unclassified mai-managed roots <a href="/admin/classify">→ classify</a>{{end}}
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
{{define "content"}}
|
||||
<h1>Views</h1>
|
||||
|
||||
<p class="muted">Saved bundles of (filter + view_type + sort + group_by). Page-agnostic — open one to render the saved set on the matching page.</p>
|
||||
|
||||
<section class="views-list">
|
||||
{{if .Views}}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>★</th><th>Name</th><th>Type</th><th>Default for</th><th>Group by</th><th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Views}}
|
||||
<tr>
|
||||
<td>{{if .Pinned}}★{{end}}</td>
|
||||
<td><a href="/views/{{.ID}}">{{.Name}}</a>{{if .Description}}<br><small class="muted">{{.Description}}</small>{{end}}</td>
|
||||
<td>{{.ViewType}}</td>
|
||||
<td>{{if .IsDefaultFor}}{{deref .IsDefaultFor}}{{else}}<span class="muted">—</span>{{end}}</td>
|
||||
<td>{{if .GroupBy}}{{deref .GroupBy}}{{else}}<span class="muted">—</span>{{end}}</td>
|
||||
<td>
|
||||
<form method="post" action="/views/{{.ID}}/delete" style="display:inline">
|
||||
<button type="submit" class="link-button" onclick="return confirm('Delete view {{.Name}}?')">delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<p class="empty muted"><em>No saved views yet. Create one with the form below or via the "Save view…" link on any Views-supporting page.</em></p>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
<section class="views-create">
|
||||
<h2>New view</h2>
|
||||
<form method="post" action="/views">
|
||||
<label>Name <input type="text" name="name" required maxlength="80"></label>
|
||||
<label>Description <input type="text" name="description" maxlength="200"></label>
|
||||
<label>View type
|
||||
<select name="view_type" required>
|
||||
{{range .AllViewTypes}}<option value="{{.}}">{{.}}</option>{{end}}
|
||||
</select>
|
||||
</label>
|
||||
<label>Default for
|
||||
<select name="is_default_for">
|
||||
{{range .DefaultForOptions}}<option value="{{.}}">{{if eq . ""}}—{{else}}{{.}}{{end}}</option>{{end}}
|
||||
</select>
|
||||
</label>
|
||||
<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 / start_time" 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="pinned" value="1"> Pinned</label>
|
||||
<label>Filter (URL query form, e.g. <code>tag=work&mgmt=mai</code>)
|
||||
<input type="text" name="filter_query" placeholder="tag=work&mgmt=mai" value="{{.Prefill.filter}}">
|
||||
</label>
|
||||
<button type="submit">Create view</button>
|
||||
</form>
|
||||
</section>
|
||||
{{end}}
|
||||
@@ -149,20 +149,19 @@ func parseTimelineQuery(r *http.Request, now time.Time) TimelineQuery {
|
||||
q.From = startOfDay(now)
|
||||
}
|
||||
}
|
||||
if v := strings.TrimSpace(r.URL.Query().Get("kind")); v != "" {
|
||||
seen := map[string]bool{}
|
||||
for _, k := range strings.Split(v, ",") {
|
||||
k = strings.TrimSpace(strings.ToLower(k))
|
||||
switch k {
|
||||
case timelineKindTodo, timelineKindEvent, timelineKindDoc, timelineKindCreation:
|
||||
if !seen[k] {
|
||||
seen[k] = true
|
||||
q.Kinds = append(q.Kinds, k)
|
||||
}
|
||||
}
|
||||
// Accept both `?kind=event,doc` (comma-joined) and
|
||||
// `?kind=event&kind=doc` (HTMX multi-select submission). The earlier
|
||||
// q.Get + comma-split flavour dropped everything past the first value
|
||||
// when the chip strip's <select multiple> submitted — same pre-5d
|
||||
// shape calendar's parser carried before commit 6f0a318. parseValues
|
||||
// (web/server.go) merges both URL styles into a single slice.
|
||||
for _, k := range parseValues(r.URL.Query(), "kind") {
|
||||
switch k {
|
||||
case timelineKindTodo, timelineKindEvent, timelineKindDoc, timelineKindCreation:
|
||||
q.Kinds = append(q.Kinds, k)
|
||||
}
|
||||
sort.Strings(q.Kinds)
|
||||
}
|
||||
sort.Strings(q.Kinds)
|
||||
return q
|
||||
}
|
||||
|
||||
|
||||
142
web/timeline_filter_test.go
Normal file
142
web/timeline_filter_test.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestTimelineFilterNarrowsByTag reproduces m's bug report: `/timeline?tag=work`
|
||||
// should narrow the spine to only work-tagged items. Pre-fix the page rendered
|
||||
// every dated row regardless of filter.
|
||||
func TestTimelineFilterNarrowsByTag(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "")
|
||||
tagWork := "tl-bug-work-" + stamp
|
||||
tagHome := "tl-bug-home-" + stamp
|
||||
var dev 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)
|
||||
}
|
||||
|
||||
type seed struct {
|
||||
slug, note, tag string
|
||||
}
|
||||
seeds := []seed{
|
||||
{slug: "tl-work-" + stamp, note: "tl-work-note-" + stamp, tag: tagWork},
|
||||
{slug: "tl-home-" + stamp, note: "tl-home-note-" + stamp, tag: tagHome},
|
||||
}
|
||||
var ids []string
|
||||
for _, s := range seeds {
|
||||
var id string
|
||||
if err := pool.QueryRow(ctx,
|
||||
`insert into projax.items (kind, title, slug, parent_ids, tags)
|
||||
values (array['project']::text[], $1, $2, ARRAY[$3]::uuid[], ARRAY[$4]::text[])
|
||||
returning id`,
|
||||
s.slug, s.slug, dev, s.tag,
|
||||
).Scan(&id); err != nil {
|
||||
t.Fatalf("seed %s: %v", s.slug, err)
|
||||
}
|
||||
ids = append(ids, id)
|
||||
if _, err := pool.Exec(ctx,
|
||||
`insert into projax.item_links (item_id, ref_type, ref_id, rel, note, event_date)
|
||||
values ($1, 'document', $2, 'contains', $3, current_date)`,
|
||||
id, "https://example.com/tl-"+s.slug, s.note,
|
||||
); err != nil {
|
||||
t.Fatalf("seed link %s: %v", s.slug, err)
|
||||
}
|
||||
}
|
||||
for _, id := range ids {
|
||||
defer pool.Exec(context.Background(), `delete from projax.items where id=$1`, id)
|
||||
}
|
||||
|
||||
// Unfiltered: both notes should show.
|
||||
_, all := get(t, h, "/timeline?refresh=1")
|
||||
if !strings.Contains(all, seeds[0].note) || !strings.Contains(all, seeds[1].note) {
|
||||
t.Fatalf("baseline timeline missing seeded notes; body excerpt: %s", truncate(all, 600))
|
||||
}
|
||||
|
||||
// Filtered: ?tag=tagWork should drop the home note.
|
||||
_, scoped := get(t, h, "/timeline?refresh=1&tag="+tagWork)
|
||||
if !strings.Contains(scoped, seeds[0].note) {
|
||||
t.Errorf("filtered timeline missing work note %q", seeds[0].note)
|
||||
}
|
||||
if strings.Contains(scoped, seeds[1].note) {
|
||||
t.Errorf("BUG: /timeline?tag=%s leaked home note %q — filter didn't narrow",
|
||||
tagWork, seeds[1].note)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTimelineFilterByKindMultiValue exercises the kind chip via HTMX-style
|
||||
// repeated-param submission (?kind=todo&kind=doc). Pre-fix the timeline's
|
||||
// own ?kind parser used q.Get("kind") which dropped everything past the
|
||||
// first value — same root cause as the calendar's pre-5d kind bug.
|
||||
func TestTimelineFilterByKindMultiValue(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "")
|
||||
var dev 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)
|
||||
}
|
||||
slug := "tl-kind-" + stamp
|
||||
noteText := "tl-kind-doc-note-" + stamp
|
||||
var id string
|
||||
if err := pool.QueryRow(ctx,
|
||||
`insert into projax.items (kind, title, slug, parent_ids)
|
||||
values (array['project']::text[], $1, $2, ARRAY[$3]::uuid[])
|
||||
returning id`,
|
||||
slug, slug, dev,
|
||||
).Scan(&id); err != nil {
|
||||
t.Fatalf("seed item: %v", err)
|
||||
}
|
||||
defer pool.Exec(context.Background(), `delete from projax.items where id=$1`, id)
|
||||
if _, err := pool.Exec(ctx,
|
||||
`insert into projax.item_links (item_id, ref_type, ref_id, rel, note, event_date)
|
||||
values ($1, 'document', $2, 'contains', $3, current_date)`,
|
||||
id, "https://example.com/tl-kind-"+stamp, noteText,
|
||||
); err != nil {
|
||||
t.Fatalf("seed link: %v", err)
|
||||
}
|
||||
|
||||
// HTMX-style repeated kind params: doc AND event selected.
|
||||
// The seeded item only produces a doc row; the event slot is empty
|
||||
// for this test (no linked calendar). Both kinds must parse so the
|
||||
// doc row survives.
|
||||
_, body := get(t, h, "/timeline?refresh=1&kind=doc&kind=event")
|
||||
if !strings.Contains(body, noteText) {
|
||||
t.Errorf("expected ?kind=doc&kind=event to include the seeded doc note %q, body excerpt: %s",
|
||||
noteText, truncate(body, 600))
|
||||
}
|
||||
}
|
||||
|
||||
// TestTimelineFilterStripFormHasCorrectTarget asserts the chip strip's HTMX
|
||||
// wiring is intact — `hx-get="/timeline"`, `hx-target="#timeline-section"`,
|
||||
// `hx-trigger="change from:select"`. A future template edit that drops one
|
||||
// of these would silently break in-place chip swapping.
|
||||
func TestTimelineFilterStripFormHasCorrectTarget(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
_, body := get(t, h, "/timeline")
|
||||
for _, want := range []string{
|
||||
`id="timeline-filter"`,
|
||||
`hx-get="/timeline"`,
|
||||
`hx-target="#timeline-section"`,
|
||||
`hx-trigger="change from:select"`,
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("timeline filter form missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
328
web/views.go
328
web/views.go
@@ -1,324 +1,10 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/m/projax/store"
|
||||
)
|
||||
|
||||
// Phase 5i Slice D — saved views handlers. Page-agnostic: a view bundles a
|
||||
// filter + view_type + sort/group_by and renders on any page that supports
|
||||
// that view_type. The sidebar in layout.tmpl lists every saved view; the
|
||||
// /views index lets m manage them.
|
||||
|
||||
// handleViewsIndex renders the list + create-form page.
|
||||
func (s *Server) handleViewsIndex(w http.ResponseWriter, r *http.Request) {
|
||||
views, err := s.Store.ListViews(r.Context())
|
||||
if err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
}
|
||||
// Prefill: a save-from-page link can pass ?prefill_filter=<encoded TreeFilter
|
||||
// URL query>&prefill_view_type=<vt>&prefill_page=<route> so the form opens
|
||||
// with the user's current state already typed in.
|
||||
prefill := map[string]string{
|
||||
"filter": r.URL.Query().Get("prefill_filter"),
|
||||
"view_type": r.URL.Query().Get("prefill_view_type"),
|
||||
"page": r.URL.Query().Get("prefill_page"),
|
||||
}
|
||||
s.render(w, r, "views", map[string]any{
|
||||
"Title": "views",
|
||||
"Views": views,
|
||||
"Prefill": prefill,
|
||||
// Catalog of selectable values for the form selects.
|
||||
"AllViewTypes": allViewTypes,
|
||||
"DefaultForOptions": []string{"", "tree", "dashboard", "calendar", "timeline"},
|
||||
"SortDirOptions": []string{"", "asc", "desc"},
|
||||
"GroupByOptions": []string{"", "status", "area", "tag", "management"},
|
||||
})
|
||||
}
|
||||
|
||||
// handleViewCreate accepts the create-view 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 {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/views/"+v.ID, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleViewWrite dispatches the /views/<id> POST routes: bare path is
|
||||
// update; /views/<id>/delete is soft-delete.
|
||||
func (s *Server) handleViewWrite(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/views/")
|
||||
if base, ok := strings.CutSuffix(path, "/delete"); ok {
|
||||
s.handleViewDelete(w, r, base)
|
||||
return
|
||||
}
|
||||
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
|
||||
}
|
||||
if _, err := s.Store.UpdateView(r.Context(), path, in); err != nil {
|
||||
if errors.Is(err, store.ErrViewNotFound) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/views", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleViewDelete soft-deletes by id.
|
||||
func (s *Server) handleViewDelete(w http.ResponseWriter, r *http.Request, id string) {
|
||||
if err := s.Store.SoftDeleteView(r.Context(), id); 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)
|
||||
}
|
||||
|
||||
// handleViewRedirect resolves /views/<uuid> GET into a redirect to the
|
||||
// appropriate Views-supporting page with ?view=<uuid> appended. The target
|
||||
// page resolves the saved filter+view_type at render time via
|
||||
// applySavedView.
|
||||
func (s *Server) handleViewRedirect(w http.ResponseWriter, r *http.Request) {
|
||||
id := strings.TrimPrefix(r.URL.Path, "/views/")
|
||||
if id == "" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
v, err := s.Store.GetView(r.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrViewNotFound) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
}
|
||||
target := targetRouteForViewType(v.ViewType)
|
||||
q := url.Values{}
|
||||
q.Set("view", v.ID)
|
||||
http.Redirect(w, r, target+"?"+q.Encode(), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// targetRouteForViewType picks a sensible landing route given the view's
|
||||
// view_type. card/list/kanban land on /; calendar on /calendar; timeline on
|
||||
// /timeline. Slice E will let `is_default_for` override.
|
||||
func targetRouteForViewType(vt string) string {
|
||||
switch vt {
|
||||
case ViewTypeCalendar:
|
||||
return "/calendar"
|
||||
case ViewTypeTimeline:
|
||||
return "/timeline"
|
||||
case ViewTypeCard, ViewTypeList, ViewTypeKanban:
|
||||
return "/"
|
||||
}
|
||||
return "/"
|
||||
}
|
||||
|
||||
// viewInputFromForm decodes the create/update form. filter_json is accepted
|
||||
// as either raw JSON (textarea) OR as an encoded query string under
|
||||
// `filter_query` so the save-from-page workflow can prefill from a TreeFilter
|
||||
// the user assembled via chips.
|
||||
func viewInputFromForm(form url.Values) (store.ViewInput, error) {
|
||||
in := store.ViewInput{
|
||||
Name: strings.TrimSpace(form.Get("name")),
|
||||
Description: strings.TrimSpace(form.Get("description")),
|
||||
ViewType: strings.TrimSpace(form.Get("view_type")),
|
||||
SortField: strings.TrimSpace(form.Get("sort_field")),
|
||||
SortDir: strings.TrimSpace(form.Get("sort_dir")),
|
||||
GroupBy: strings.TrimSpace(form.Get("group_by")),
|
||||
Pinned: form.Get("pinned") == "1",
|
||||
IsDefaultFor: strings.TrimSpace(form.Get("is_default_for")),
|
||||
}
|
||||
// Prefer filter_query when present; otherwise fall back to filter_json.
|
||||
if fq := strings.TrimSpace(form.Get("filter_query")); fq != "" {
|
||||
filterJSON, err := filterQueryToJSON(fq)
|
||||
if err != nil {
|
||||
return in, fmt.Errorf("filter_query: %w", err)
|
||||
}
|
||||
in.FilterJSON = filterJSON
|
||||
} else if fj := strings.TrimSpace(form.Get("filter_json")); fj != "" {
|
||||
in.FilterJSON = []byte(fj)
|
||||
}
|
||||
return in, nil
|
||||
}
|
||||
|
||||
// filterQueryToJSON parses a TreeFilter URL query and returns the canonical
|
||||
// JSON shape stored in `filter_json`. Mirrors the design doc §2 keys.
|
||||
func filterQueryToJSON(query string) ([]byte, error) {
|
||||
q, err := url.ParseQuery(strings.TrimPrefix(query, "?"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f := ParseTreeFilter(q)
|
||||
payload := map[string]any{}
|
||||
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)
|
||||
}
|
||||
|
||||
// applyDefaultView resolves the saved view marked is_default_for=<page>
|
||||
// when the request URL carries no filter/view-specific params and the user
|
||||
// has not opted out via ?nodefault=1. Returns the applied view (for banner
|
||||
// labelling) or nil when no default exists / was applied.
|
||||
// Phase 5j Slice A — paliad-shape redesign. The 5i overlay handlers
|
||||
// (handleViewsIndex / handleViewCreate / handleViewWrite / handleViewEdit
|
||||
// / handleViewRedirect / applySavedView / applyDefaultView / friends)
|
||||
// are deleted here. The new /views/{slug} route family lands in slice B;
|
||||
// system-view migration lands in slice C.
|
||||
//
|
||||
// Per design.md §7 Slice E: defaults are a polish layer. They only kick in
|
||||
// on a "clean" landing — the moment the user types a chip click, the URL
|
||||
// gains a filter param and the default no longer auto-applies. Same with
|
||||
// an explicit ?view=<uuid>.
|
||||
func (s *Server) applyDefaultView(r *http.Request, page string, filter *TreeFilter, viewType *string) (*store.View, error) {
|
||||
q := r.URL.Query()
|
||||
if q.Get("nodefault") == "1" {
|
||||
return nil, nil
|
||||
}
|
||||
// Any filter-affecting param means "user is driving" — skip the default.
|
||||
for _, key := range []string{"q", "tag", "mgmt", "status", "has", "show-archived", "public", "project", "project_id", "project_descendants", "view", "view_type", "group_by"} {
|
||||
if q.Get(key) != "" {
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
v, err := s.Store.DefaultViewFor(r.Context(), page)
|
||||
if err != nil || v == nil {
|
||||
return v, err
|
||||
}
|
||||
payload := map[string]any{}
|
||||
if len(v.FilterJSON) > 0 {
|
||||
if err := json.Unmarshal(v.FilterJSON, &payload); err != nil {
|
||||
return v, fmt.Errorf("decode default filter_json: %w", err)
|
||||
}
|
||||
}
|
||||
*filter = filterFromJSONPayload(payload)
|
||||
*viewType = v.ViewType
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// applySavedView resolves a `?view=<uuid>` reference and folds the persisted
|
||||
// filter + view_type back into the supplied TreeFilter + view-type slot.
|
||||
// Called by every Views-supporting page handler at the top of their render
|
||||
// path. Returns the saved view (for chip labelling) or nil when no `?view=`
|
||||
// was given. Errors are logged + returned (handlers can choose to ignore).
|
||||
func (s *Server) applySavedView(r *http.Request, filter *TreeFilter, viewType *string) (*store.View, error) {
|
||||
id := strings.TrimSpace(r.URL.Query().Get("view"))
|
||||
if id == "" {
|
||||
return nil, nil
|
||||
}
|
||||
v, err := s.Store.GetView(r.Context(), id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payload := map[string]any{}
|
||||
if len(v.FilterJSON) > 0 {
|
||||
if err := json.Unmarshal(v.FilterJSON, &payload); err != nil {
|
||||
return v, fmt.Errorf("decode filter_json: %w", err)
|
||||
}
|
||||
}
|
||||
// Replace filter dimensions with persisted values. Empty / missing keys
|
||||
// reset to TreeFilter defaults so a saved view is the canonical state.
|
||||
*filter = filterFromJSONPayload(payload)
|
||||
*viewType = v.ViewType
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// filterFromJSONPayload is the inverse of filterQueryToJSON. Keys absent
|
||||
// from the payload land at their TreeFilter zero value (Status defaults to
|
||||
// ["active"] to match ParseTreeFilter).
|
||||
func filterFromJSONPayload(p map[string]any) TreeFilter {
|
||||
f := TreeFilter{
|
||||
Status: []string{"active"},
|
||||
IncludeDescendants: true,
|
||||
}
|
||||
if v, ok := p["q"].(string); ok {
|
||||
f.Q = v
|
||||
}
|
||||
if v, ok := p["tags"].([]any); ok {
|
||||
f.Tags = anySliceToStrings(v)
|
||||
}
|
||||
if v, ok := p["management"].([]any); ok {
|
||||
f.Management = anySliceToStrings(v)
|
||||
}
|
||||
if v, ok := p["status"].([]any); ok {
|
||||
f.Status = anySliceToStrings(v)
|
||||
if len(f.Status) == 0 {
|
||||
f.Status = []string{"active"}
|
||||
}
|
||||
}
|
||||
if v, ok := p["has_links"].([]any); ok {
|
||||
f.HasLinks = anySliceToStrings(v)
|
||||
}
|
||||
if v, ok := p["public"].(bool); ok {
|
||||
f.Public = &v
|
||||
}
|
||||
if v, ok := p["show_archived"].(bool); ok && v {
|
||||
f.ShowArchived = true
|
||||
}
|
||||
if v, ok := p["project_path"].(string); ok {
|
||||
f.ProjectPath = v
|
||||
}
|
||||
if v, ok := p["include_descendants"].(bool); ok {
|
||||
f.IncludeDescendants = v
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
// Between slices A and B the /views URLs return 404 — by design, no real
|
||||
// user data was on the old shape (hours-old after the 5i ship).
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestViewsCRUDRoundTrip covers create → list → open (redirect to scoped page) →
|
||||
// delete, end-to-end. Requires DB. Slice D — projax.views table CRUD.
|
||||
func TestViewsCRUDRoundTrip(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
|
||||
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "")
|
||||
name := "p5i-D-view-" + stamp
|
||||
|
||||
defer pool.Exec(context.Background(),
|
||||
`UPDATE projax.views SET deleted_at = now() WHERE name = $1 AND deleted_at IS NULL`, name)
|
||||
|
||||
// Create.
|
||||
form := url.Values{}
|
||||
form.Set("name", name)
|
||||
form.Set("view_type", "card")
|
||||
form.Set("filter_query", "tag=work&mgmt=mai")
|
||||
code, _ := post(t, h, "/views", form)
|
||||
if code != 303 {
|
||||
t.Fatalf("POST /views status=%d, want 303", code)
|
||||
}
|
||||
|
||||
// List page lists the new view.
|
||||
code, body := get(t, h, "/views")
|
||||
if code != 200 {
|
||||
t.Fatalf("GET /views status=%d", code)
|
||||
}
|
||||
if !strings.Contains(body, name) {
|
||||
t.Errorf("GET /views body missing %q", name)
|
||||
}
|
||||
|
||||
// Fetch row to grab the id (and validate filter_json round-trip).
|
||||
var (
|
||||
id string
|
||||
filterJSON []byte
|
||||
viewType string
|
||||
)
|
||||
if err := pool.QueryRow(context.Background(),
|
||||
`SELECT id, filter_json, view_type FROM projax.views WHERE name=$1 AND deleted_at IS NULL`,
|
||||
name,
|
||||
).Scan(&id, &filterJSON, &viewType); err != nil {
|
||||
t.Fatalf("fetch row: %v", err)
|
||||
}
|
||||
if viewType != "card" {
|
||||
t.Errorf("view_type = %q, want 'card'", viewType)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(filterJSON, &payload); err != nil {
|
||||
t.Fatalf("filter_json unmarshal: %v", err)
|
||||
}
|
||||
if got, _ := payload["tags"].([]any); len(got) != 1 || got[0] != "work" {
|
||||
t.Errorf("filter_json tags = %v, want [work]", payload["tags"])
|
||||
}
|
||||
if got, _ := payload["management"].([]any); len(got) != 1 || got[0] != "mai" {
|
||||
t.Errorf("filter_json management = %v, want [mai]", payload["management"])
|
||||
}
|
||||
|
||||
// GET /views/<id> redirects to the right page with ?view=<id>.
|
||||
code, _ = get(t, h, "/views/"+id)
|
||||
if code != 303 {
|
||||
t.Errorf("GET /views/<id> status=%d, want 303 redirect", code)
|
||||
}
|
||||
|
||||
// Soft delete.
|
||||
code, _ = post(t, h, "/views/"+id+"/delete", url.Values{})
|
||||
if code != 303 {
|
||||
t.Errorf("POST delete status=%d, want 303", code)
|
||||
}
|
||||
var deletedAt *time.Time
|
||||
if err := pool.QueryRow(context.Background(),
|
||||
`SELECT deleted_at FROM projax.views WHERE id=$1`, id,
|
||||
).Scan(&deletedAt); err != nil {
|
||||
t.Fatalf("post-delete read: %v", err)
|
||||
}
|
||||
if deletedAt == nil {
|
||||
t.Error("expected deleted_at to be set after POST /views/<id>/delete")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultViewAppliedOnCleanURL verifies the Slice E behaviour: when /
|
||||
// is requested with no chip params and a default view exists for the page,
|
||||
// the saved filter + view_type apply and a "Showing default view: …"
|
||||
// banner renders. Adding any chip param (?tag=…) bypasses the default.
|
||||
// ?nodefault=1 is the explicit opt-out.
|
||||
func TestDefaultViewAppliedOnCleanURL(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"), ".", "")
|
||||
name := "p5i-E-default-" + stamp
|
||||
defer pool.Exec(context.Background(),
|
||||
`UPDATE projax.views SET deleted_at = now() WHERE name = $1 AND deleted_at IS NULL`, name)
|
||||
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO projax.views (name, view_type, filter_json, is_default_for)
|
||||
VALUES ($1, 'card', $2::jsonb, 'tree')`,
|
||||
name, []byte(`{"tags":["work"]}`)); err != nil {
|
||||
t.Fatalf("seed default view: %v", err)
|
||||
}
|
||||
|
||||
// Clean URL: default applies → card view + banner.
|
||||
_, body := get(t, h, "/")
|
||||
if !strings.Contains(body, `class="tree-card-grid"`) {
|
||||
t.Error("clean / should auto-apply default view (card grid expected)")
|
||||
}
|
||||
if !strings.Contains(body, `default-banner`) {
|
||||
t.Error("default-banner should render when a default applies")
|
||||
}
|
||||
if !strings.Contains(body, name) {
|
||||
t.Error("banner should name the applied default view")
|
||||
}
|
||||
|
||||
// Any chip param bypasses the default → list view (no banner).
|
||||
_, withChip := get(t, h, "/?tag=dev")
|
||||
if strings.Contains(withChip, `default-banner`) {
|
||||
t.Error("default banner should disappear once user types a chip")
|
||||
}
|
||||
if !strings.Contains(withChip, `<ul class="forest">`) {
|
||||
t.Error("?tag=dev should render the forest (default not applied)")
|
||||
}
|
||||
|
||||
// Explicit opt-out via ?nodefault=1.
|
||||
_, optOut := get(t, h, "/?nodefault=1")
|
||||
if strings.Contains(optOut, `default-banner`) {
|
||||
t.Error("?nodefault=1 should suppress the default banner")
|
||||
}
|
||||
if !strings.Contains(optOut, `<ul class="forest">`) {
|
||||
t.Error("?nodefault=1 should render the forest (default suppressed)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSavedViewAppliedOnQueryParam verifies that opening / with ?view=<uuid>
|
||||
// re-applies the saved filter+view_type. We seed a view tagged work=patents
|
||||
// and assert the rendered tree has the right ProjectChip / chip-on state.
|
||||
func TestSavedViewAppliedOnQueryParam(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"), ".", "")
|
||||
name := "p5i-D-saved-" + stamp
|
||||
defer pool.Exec(context.Background(),
|
||||
`UPDATE projax.views SET deleted_at = now() WHERE name = $1 AND deleted_at IS NULL`, name)
|
||||
|
||||
// Seed directly via SQL so the assertion focuses on the resolver, not the
|
||||
// form flow tested above.
|
||||
var id string
|
||||
if err := pool.QueryRow(ctx, `
|
||||
INSERT INTO projax.views (name, view_type, filter_json)
|
||||
VALUES ($1, 'card', $2::jsonb)
|
||||
RETURNING id`, name, []byte(`{"project_path":"dev","include_descendants":true}`)).Scan(&id); err != nil {
|
||||
t.Fatalf("seed view: %v", err)
|
||||
}
|
||||
|
||||
_, body := get(t, h, "/?view="+id)
|
||||
if !strings.Contains(body, `class="tree-card-grid"`) {
|
||||
t.Error("?view= should override view_type → card view should render")
|
||||
}
|
||||
if !strings.Contains(body, `class="proj-chip chip-on"`) {
|
||||
t.Error("?view= should apply project filter chip → proj-chip should be on")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user