feat(views): Phase 5i slice A — project filter dim + descendants toggle
m's Q5 pick (2026-05-26): project scope on every Views-supporting page, with descendants exposed as an explicit on/off chip toggle rather than always-on. Slice A ships the smallest standalone piece of the Views system; slices B–E (view_type URL param, kanban, saved-views schema, defaults) follow on the same branch. TreeFilter grows two fields: - ProjectPath: scoped item's primary path; "" = no filter. - IncludeDescendants: default true; flipped via ?project_descendants=0. Matching extends to path-prefix across `it.Paths` when ProjectPath is set; equality-only when IncludeDescendants is off. Multi-parent items pass when ANY of their paths qualifies. Picker is a shared partial (templates/project_chip.tmpl) that every Views-supporting filter strip includes (tree, dashboard, timeline, calendar). Two states: <select> picker when no project is set; active chip with × clear + descendants on/off chip when scoped. Hidden inputs added to each form so non-picker chip clicks preserve the project state. Graph and admin tools are NOT Views consumers (per design.md / docs/plans/views-system.md §5) and stay untouched. Test-source edits (per the 5c sharpened rule): - dashboard_test.go, public_listing_test.go, timeline_test.go: row membership assertions tightened from `Contains(body, slug)` to `Contains(body, href="/i/path")`. The picker now renders every item's primary path inside a <select>, so coarse slug substring matches falsely passed across filtered-out picker options. Behaviour preserved (filtered rows still don't render); the impl-detail assertion moved to the row link. New tests: TestProjectFilterIncludesDescendants, TestProjectFilterDescendantsOff, TestParseTreeFilterProjectFields, TestTreeFilterProjectRoundTrip, TestSetProjectAndToggleHelpers, TestProjectFilterScopesTreeToDescendants (end-to-end via /).
This commit is contained in:
@@ -182,12 +182,20 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
|
||||
display := *payload
|
||||
display.Cached = hit
|
||||
|
||||
projects, err := s.parentOptions(r.Context())
|
||||
if err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
}
|
||||
data := map[string]any{
|
||||
"Title": "calendar",
|
||||
"P": display,
|
||||
"Filter": q.Filter,
|
||||
"Query": q,
|
||||
"Now": now,
|
||||
"Title": "calendar",
|
||||
"P": display,
|
||||
"Filter": q.Filter,
|
||||
"Query": q,
|
||||
"Now": now,
|
||||
"Projects": projects,
|
||||
"BasePath": "/calendar",
|
||||
"ProjectChipTarget": "#calendar-section",
|
||||
}
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
s.render(w, r, "calendar_section", data)
|
||||
|
||||
@@ -146,13 +146,21 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
refreshURL = "/dashboard?" + cacheKey + "&refresh=1"
|
||||
}
|
||||
|
||||
projects, err := s.parentOptions(r.Context())
|
||||
if err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
}
|
||||
data := map[string]any{
|
||||
"Title": "dashboard",
|
||||
"P": displayPayload,
|
||||
"Filter": filter,
|
||||
"UpdatedRel": updatedRel,
|
||||
"RefreshURL": refreshURL,
|
||||
"FilterActive": filter.Active(),
|
||||
"Title": "dashboard",
|
||||
"P": displayPayload,
|
||||
"Filter": filter,
|
||||
"UpdatedRel": updatedRel,
|
||||
"RefreshURL": refreshURL,
|
||||
"FilterActive": filter.Active(),
|
||||
"Projects": projects,
|
||||
"BasePath": "/dashboard",
|
||||
"ProjectChipTarget": "#dashboard-section",
|
||||
}
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
s.render(w, r, "dashboard_section", data)
|
||||
|
||||
@@ -134,10 +134,14 @@ func TestDashboardFilterByTagNarrowsCard(t *testing.T) {
|
||||
if code != 200 {
|
||||
t.Fatalf("GET /dashboard?tag=dev → %d", code)
|
||||
}
|
||||
if !strings.Contains(body, "dev."+devSlug) {
|
||||
// Phase 5i Slice A: the project-scope picker renders every item's primary
|
||||
// path as a <select> option, so a naive body substring match would also
|
||||
// see filtered-out paths inside the dropdown. Anchor the row assertion on
|
||||
// the detail link emitted by the dashboard cards instead.
|
||||
if !strings.Contains(body, `href="/i/dev.`+devSlug+`"`) {
|
||||
t.Errorf("expected dev row in filtered dashboard")
|
||||
}
|
||||
if strings.Contains(body, "home."+homeSlug) {
|
||||
if strings.Contains(body, `href="/i/home.`+homeSlug+`"`) {
|
||||
t.Errorf("home row should be filtered out when ?tag=dev")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,18 +238,25 @@ func TestTreeFilterPublicNarrows(t *testing.T) {
|
||||
}
|
||||
defer pool.Exec(context.Background(), `delete from projax.items where id in ($1, $2)`, pubID, prvID)
|
||||
|
||||
// Phase 5i Slice A: the project picker drops every item path into a
|
||||
// <select> dropdown, so naive substring assertions trip on filtered-out
|
||||
// rows visible in the picker. Anchor the row assertion on the
|
||||
// tree-row link instead — that only renders for items that pass the
|
||||
// filter.
|
||||
pubLink := `href="/i/dev.` + pubSlug + `"`
|
||||
prvLink := `href="/i/dev.` + prvSlug + `"`
|
||||
_, yesBody := get(t, h, "/?public=1")
|
||||
if !strings.Contains(yesBody, pubSlug) {
|
||||
t.Errorf("?public=1 should show pub-filt-yes")
|
||||
if !strings.Contains(yesBody, pubLink) {
|
||||
t.Errorf("?public=1 should show pub-filt-yes row")
|
||||
}
|
||||
if strings.Contains(yesBody, prvSlug) {
|
||||
t.Errorf("?public=1 should hide pub-filt-no")
|
||||
if strings.Contains(yesBody, prvLink) {
|
||||
t.Errorf("?public=1 should hide pub-filt-no row")
|
||||
}
|
||||
_, noBody := get(t, h, "/?public=0")
|
||||
if strings.Contains(noBody, pubSlug) {
|
||||
t.Errorf("?public=0 should hide pub-filt-yes")
|
||||
if strings.Contains(noBody, pubLink) {
|
||||
t.Errorf("?public=0 should hide pub-filt-yes row")
|
||||
}
|
||||
if !strings.Contains(noBody, prvSlug) {
|
||||
t.Errorf("?public=0 should show pub-filt-no")
|
||||
if !strings.Contains(noBody, prvLink) {
|
||||
t.Errorf("?public=0 should show pub-filt-no row")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,18 +162,24 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) {
|
||||
pages[name] = t
|
||||
}
|
||||
// tree bundles the tree-section partial so HTMX swaps and the initial
|
||||
// page render share definitions.
|
||||
// page render share definitions. project_chip.tmpl is the Phase 5i Slice
|
||||
// A shared partial that every Views-supporting page includes inside its
|
||||
// filter strip.
|
||||
treeTmpl, err := template.New("tree").Funcs(funcs).ParseFS(templatesFS,
|
||||
"templates/layout.tmpl",
|
||||
"templates/tree.tmpl",
|
||||
"templates/tree_section.tmpl",
|
||||
"templates/project_chip.tmpl",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse tree: %w", err)
|
||||
}
|
||||
pages["tree"] = treeTmpl
|
||||
// Standalone tree-section template for HTMX fragment responses.
|
||||
treeSection, err := template.New("tree_section").Funcs(funcs).ParseFS(templatesFS, "templates/tree_section.tmpl")
|
||||
treeSection, err := template.New("tree_section").Funcs(funcs).ParseFS(templatesFS,
|
||||
"templates/tree_section.tmpl",
|
||||
"templates/project_chip.tmpl",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse tree_section: %w", err)
|
||||
}
|
||||
@@ -248,12 +254,16 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) {
|
||||
"templates/layout.tmpl",
|
||||
"templates/dashboard.tmpl",
|
||||
"templates/dashboard_section.tmpl",
|
||||
"templates/project_chip.tmpl",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse dashboard: %w", err)
|
||||
}
|
||||
pages["dashboard"] = dashTmpl
|
||||
dashSection, err := template.New("dashboard_section").Funcs(funcs).ParseFS(templatesFS, "templates/dashboard_section.tmpl")
|
||||
dashSection, err := template.New("dashboard_section").Funcs(funcs).ParseFS(templatesFS,
|
||||
"templates/dashboard_section.tmpl",
|
||||
"templates/project_chip.tmpl",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse dashboard_section: %w", err)
|
||||
}
|
||||
@@ -264,12 +274,16 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) {
|
||||
"templates/layout.tmpl",
|
||||
"templates/timeline.tmpl",
|
||||
"templates/timeline_section.tmpl",
|
||||
"templates/project_chip.tmpl",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse timeline: %w", err)
|
||||
}
|
||||
pages["timeline"] = timelineTmpl
|
||||
timelineSection, err := template.New("timeline_section").Funcs(funcs).ParseFS(templatesFS, "templates/timeline_section.tmpl")
|
||||
timelineSection, err := template.New("timeline_section").Funcs(funcs).ParseFS(templatesFS,
|
||||
"templates/timeline_section.tmpl",
|
||||
"templates/project_chip.tmpl",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse timeline_section: %w", err)
|
||||
}
|
||||
@@ -282,12 +296,16 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) {
|
||||
"templates/layout.tmpl",
|
||||
"templates/calendar.tmpl",
|
||||
"templates/calendar_section.tmpl",
|
||||
"templates/project_chip.tmpl",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse calendar: %w", err)
|
||||
}
|
||||
pages["calendar"] = calTmpl
|
||||
calSection, err := template.New("calendar_section").Funcs(funcs).ParseFS(templatesFS, "templates/calendar_section.tmpl")
|
||||
calSection, err := template.New("calendar_section").Funcs(funcs).ParseFS(templatesFS,
|
||||
"templates/calendar_section.tmpl",
|
||||
"templates/project_chip.tmpl",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse calendar_section: %w", err)
|
||||
}
|
||||
@@ -420,15 +438,18 @@ func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) {
|
||||
roots, orphans, total, orphanN, matched := applyTreeFilter(items, filter, linkKinds)
|
||||
counts := computeChipCounts(items, filter, linkKinds, tags)
|
||||
data := map[string]any{
|
||||
"Title": "tree",
|
||||
"Roots": roots,
|
||||
"Orphans": orphans,
|
||||
"Total": total,
|
||||
"OrphanN": orphanN,
|
||||
"Matched": matched,
|
||||
"AllTags": tags,
|
||||
"Filter": filter,
|
||||
"Counts": counts,
|
||||
"Title": "tree",
|
||||
"Roots": roots,
|
||||
"Orphans": orphans,
|
||||
"Total": total,
|
||||
"OrphanN": orphanN,
|
||||
"Matched": matched,
|
||||
"AllTags": tags,
|
||||
"Filter": filter,
|
||||
"Counts": counts,
|
||||
"Projects": parentOptionsFromItems(items),
|
||||
"BasePath": "/",
|
||||
"ProjectChipTarget": "#tree-section",
|
||||
// ActiveTags kept for backwards-compat with the old template path; removed
|
||||
// after the template migrates fully.
|
||||
"ActiveTags": filter.Tags,
|
||||
@@ -873,15 +894,20 @@ func (s *Server) parentOptions(ctx context.Context) ([]ParentOption, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []ParentOption
|
||||
return parentOptionsFromItems(items), nil
|
||||
}
|
||||
|
||||
// parentOptionsFromItems builds the same flat option list parentOptions
|
||||
// returns, but from an already-loaded items slice. Callers that have already
|
||||
// fetched items (handleTree, handleDashboard, …) use this to avoid a second
|
||||
// ListAll round-trip when they only need the picker options.
|
||||
func parentOptionsFromItems(items []*store.Item) []ParentOption {
|
||||
out := make([]ParentOption, 0, len(items))
|
||||
for _, it := range items {
|
||||
// Surface every primary path as a candidate parent — multi-parent
|
||||
// items appear once per parent option using their primary path so the
|
||||
// UI stays unambiguous.
|
||||
out = append(out, ParentOption{ID: it.ID, Path: it.PrimaryPath()})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path })
|
||||
return out, nil
|
||||
return out
|
||||
}
|
||||
|
||||
// (buildForest + nodeHasAllTags removed in Phase 3b — superseded by
|
||||
|
||||
@@ -282,3 +282,71 @@ func TestMultiParentBothPathsRouteToSameRow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestProjectFilterScopesTreeToDescendants verifies the Phase 5i Slice A
|
||||
// project scope semantics end-to-end: ?project=<path> narrows / to the picked
|
||||
// item + descendants; ?project_descendants=0 narrows further to the picked
|
||||
// item alone. Both round-trip through ParseTreeFilter + TreeFilter.Matches +
|
||||
// the tree handler.
|
||||
func TestProjectFilterScopesTreeToDescendants(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"), ".", "")
|
||||
parentSlug := "p5i-parent-" + stamp
|
||||
childSlug := "p5i-child-" + stamp
|
||||
siblingSlug := "p5i-sib-" + 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)
|
||||
}
|
||||
var parentID, childID, siblingID string
|
||||
if err := pool.QueryRow(ctx,
|
||||
`insert into projax.items (kind, title, slug, parent_ids) values (array['project']::text[], 'P5i Parent', $1, ARRAY[$2]::uuid[]) returning id`,
|
||||
parentSlug, dev).Scan(&parentID); err != nil {
|
||||
t.Fatalf("seed parent: %v", err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx,
|
||||
`insert into projax.items (kind, title, slug, parent_ids) values (array['project']::text[], 'P5i Child', $1, ARRAY[$2]::uuid[]) returning id`,
|
||||
childSlug, parentID).Scan(&childID); err != nil {
|
||||
t.Fatalf("seed child: %v", err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx,
|
||||
`insert into projax.items (kind, title, slug, parent_ids) values (array['project']::text[], 'P5i Sib', $1, ARRAY[$2]::uuid[]) returning id`,
|
||||
siblingSlug, dev).Scan(&siblingID); err != nil {
|
||||
t.Fatalf("seed sibling: %v", err)
|
||||
}
|
||||
defer pool.Exec(context.Background(), `delete from projax.items where id in ($1,$2,$3)`, childID, parentID, siblingID)
|
||||
|
||||
parentPath := "dev." + parentSlug
|
||||
parentLink := `href="/i/` + parentPath + `"`
|
||||
childLink := `href="/i/` + parentPath + `.` + childSlug + `"`
|
||||
siblingLink := `href="/i/dev.` + siblingSlug + `"`
|
||||
|
||||
// Descendants on (default): parent + child visible, sibling hidden.
|
||||
_, withDesc := get(t, h, "/?project="+parentPath)
|
||||
if !strings.Contains(withDesc, parentLink) {
|
||||
t.Errorf("?project=%s should show parent row", parentPath)
|
||||
}
|
||||
if !strings.Contains(withDesc, childLink) {
|
||||
t.Errorf("?project=%s should include descendant child row", parentPath)
|
||||
}
|
||||
if strings.Contains(withDesc, siblingLink) {
|
||||
t.Errorf("?project=%s should exclude sibling row", parentPath)
|
||||
}
|
||||
|
||||
// Descendants off: only the picked item, no children.
|
||||
_, noDesc := get(t, h, "/?project="+parentPath+"&project_descendants=0")
|
||||
if !strings.Contains(noDesc, parentLink) {
|
||||
t.Errorf("?project_descendants=0 should still show the picked parent row")
|
||||
}
|
||||
if strings.Contains(noDesc, childLink) {
|
||||
t.Errorf("?project_descendants=0 should hide the child row")
|
||||
}
|
||||
if strings.Contains(noDesc, siblingLink) {
|
||||
t.Errorf("?project_descendants=0 should hide the sibling row")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,6 +193,19 @@ table.classify input, table.classify select { width: 100%; }
|
||||
.mgmt-chip:hover, .status-chip:hover, .has-chip:hover { color: var(--fg); border-color: var(--accent); }
|
||||
.chip-on { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); }
|
||||
.chip-on:hover { color: var(--accent-fg); filter: brightness(0.92); }
|
||||
/* Phase 5i Slice A — project scope chip. The picker uses a bare <select>
|
||||
inside a tagbar form; the active state mirrors the mgmt/status/has chips. */
|
||||
.proj-chip, .proj-desc-chip {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
font-size: 0.78em; padding: 1px 8px; border-radius: 999px;
|
||||
background: var(--surface); border: 1px solid var(--border); color: var(--muted); text-decoration: none;
|
||||
}
|
||||
.proj-chip.chip-on { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); }
|
||||
.proj-chip .proj-name { font-weight: 500; }
|
||||
.proj-chip .proj-clear { color: inherit; opacity: 0.75; margin-left: 4px; padding: 0 4px; }
|
||||
.proj-chip .proj-clear:hover { opacity: 1; }
|
||||
.proj-desc-chip:hover { color: var(--fg); border-color: var(--accent); }
|
||||
.proj-picker select { font-size: 0.85em; padding: 1px 4px; }
|
||||
#tree-filterbar small { opacity: 0.75; margin-left: 2px; }
|
||||
.tree-section .empty { padding: 24px; color: var(--muted); }
|
||||
.tree-section .clear { color: var(--bad); }
|
||||
|
||||
@@ -37,8 +37,13 @@
|
||||
<option value="doc" {{if contains $selK "doc"}}selected{{end}}>doc</option>
|
||||
</select>
|
||||
</label>
|
||||
{{if .Filter.ProjectPath}}<input type="hidden" name="project" value="{{.Filter.ProjectPath}}">{{end}}
|
||||
{{if and .Filter.ProjectPath (not .Filter.IncludeDescendants)}}<input type="hidden" name="project_descendants" value="0">{{end}}
|
||||
{{if .Filter.Active}}<a class="clear" href="/calendar?month={{.P.MonthKey}}">clear filters</a>{{end}}
|
||||
</form>
|
||||
|
||||
{{template "view-project-chip" .}}
|
||||
|
||||
<p class="counts muted">
|
||||
<small>{{.P.TotalRows}} {{if eq .P.TotalRows 1}}row{{else}}rows{{end}}</small>
|
||||
{{if .P.Cached}}<small title="Served from 60s in-memory cache · built {{.P.BuiltAt.Format "15:04:05"}}">· cached</small>{{else}}<small>· fresh</small>{{end}}
|
||||
|
||||
@@ -29,8 +29,13 @@
|
||||
<option value="gitea-repo" {{if contains $selH "gitea-repo"}}selected{{end}}>gitea</option>
|
||||
</select>
|
||||
</label>
|
||||
{{if .Filter.ProjectPath}}<input type="hidden" name="project" value="{{.Filter.ProjectPath}}">{{end}}
|
||||
{{if and .Filter.ProjectPath (not .Filter.IncludeDescendants)}}<input type="hidden" name="project_descendants" value="0">{{end}}
|
||||
{{if .Filter.Active}}<a class="clear" href="/dashboard">clear filters</a>{{end}}
|
||||
</form>
|
||||
|
||||
{{template "view-project-chip" .}}
|
||||
|
||||
<p class="counts muted">
|
||||
{{if .P.Cached}}<small title="Served from 60s in-memory cache · built {{.P.BuiltAt.Format "15:04:05"}}">updated {{.UpdatedRel}} · cached</small>
|
||||
{{else}}<small>updated {{.UpdatedRel}} · fresh</small>{{end}}
|
||||
|
||||
58
web/templates/project_chip.tmpl
Normal file
58
web/templates/project_chip.tmpl
Normal file
@@ -0,0 +1,58 @@
|
||||
{{/*
|
||||
Phase 5i Slice A — shared project-scope chip + picker.
|
||||
|
||||
Rendered inside the tagbar on every Views-supporting page (tree, dashboard,
|
||||
timeline, calendar). Two visual states:
|
||||
|
||||
ProjectPath == "" → <select> picker with all items by primary path
|
||||
ProjectPath != "" → active chip with × clear + descendants on/off toggle
|
||||
|
||||
Each caller passes a top-level page-data map with:
|
||||
Filter — TreeFilter
|
||||
Projects — []ParentOption (sorted by path)
|
||||
BasePath — page route, e.g. "/", "/dashboard", "/timeline", "/calendar"
|
||||
ProjectChipTarget — HTMX target selector, e.g. "#tree-section"
|
||||
|
||||
m's Q5 pick (2026-05-26): descendants toggle is on by default but exposed
|
||||
explicitly on the chip, not always-on.
|
||||
*/}}
|
||||
{{define "view-project-chip"}}
|
||||
<div class="chip-row project-chip-row">
|
||||
<span class="muted">project:</span>
|
||||
{{if .Filter.ProjectPath}}
|
||||
{{$cleared := (.Filter.SetProject "").URLOn .BasePath}}
|
||||
{{$toggled := .Filter.ToggleIncludeDescendants.URLOn .BasePath}}
|
||||
<span class="proj-chip chip-on" title="Scoped to {{.Filter.ProjectPath}}">
|
||||
<span class="proj-name">{{.Filter.ProjectPath}}</span>
|
||||
<a class="proj-clear"
|
||||
href="{{$cleared}}"
|
||||
hx-get="{{$cleared}}" hx-target="{{.ProjectChipTarget}}" hx-swap="outerHTML" hx-push-url="true"
|
||||
title="Clear project scope">×</a>
|
||||
</span>
|
||||
<a class="proj-desc-chip {{if .Filter.IncludeDescendants}}chip-on{{end}}"
|
||||
href="{{$toggled}}"
|
||||
hx-get="{{$toggled}}" hx-target="{{.ProjectChipTarget}}" hx-swap="outerHTML" hx-push-url="true"
|
||||
title="Include descendants of {{.Filter.ProjectPath}} in scope">
|
||||
descendants {{if .Filter.IncludeDescendants}}on{{else}}off{{end}}
|
||||
</a>
|
||||
{{else}}
|
||||
<form class="proj-picker"
|
||||
hx-get="{{.BasePath}}"
|
||||
hx-target="{{.ProjectChipTarget}}"
|
||||
hx-swap="outerHTML"
|
||||
hx-trigger="change from:select"
|
||||
hx-push-url="true">
|
||||
{{if .Filter.Q}}<input type="hidden" name="q" value="{{.Filter.Q}}">{{end}}
|
||||
{{if .Filter.Tags}}<input type="hidden" name="tag" value="{{join "," .Filter.Tags}}">{{end}}
|
||||
{{if .Filter.Management}}<input type="hidden" name="mgmt" value="{{join "," .Filter.Management}}">{{end}}
|
||||
{{if ne (join "," .Filter.Status) "active"}}<input type="hidden" name="status" value="{{join "," .Filter.Status}}">{{end}}
|
||||
{{if .Filter.HasLinks}}<input type="hidden" name="has" value="{{join "," .Filter.HasLinks}}">{{end}}
|
||||
{{if .Filter.ShowArchived}}<input type="hidden" name="show-archived" value="1">{{end}}
|
||||
<select name="project" autocomplete="off">
|
||||
<option value="">— any —</option>
|
||||
{{range .Projects}}<option value="{{.Path}}">{{.Path}}</option>{{end}}
|
||||
</select>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -43,8 +43,13 @@
|
||||
<option value="asc" {{if eq .P.Order "asc"}}selected{{end}}>oldest first</option>
|
||||
</select>
|
||||
</label>
|
||||
{{if .Filter.ProjectPath}}<input type="hidden" name="project" value="{{.Filter.ProjectPath}}">{{end}}
|
||||
{{if and .Filter.ProjectPath (not .Filter.IncludeDescendants)}}<input type="hidden" name="project_descendants" value="0">{{end}}
|
||||
{{if .Filter.Active}}<a class="clear" href="/timeline">clear filters</a>{{end}}
|
||||
</form>
|
||||
|
||||
{{template "view-project-chip" .}}
|
||||
|
||||
<p class="counts muted">
|
||||
<small>{{.P.TotalRows}} rows · {{.P.From.Format "2006-01-02"}} → {{.P.ToInclusive.Format "2006-01-02"}}</small>
|
||||
{{if .P.Cached}}<small title="Served from 90s in-memory cache · built {{.P.BuiltAt.Format "15:04:05"}}">· cached</small>{{else}}<small>· fresh</small>{{end}}
|
||||
|
||||
@@ -16,8 +16,12 @@
|
||||
{{if ne (join "," .Filter.Status) "active"}}<input type="hidden" name="status" value="{{join "," .Filter.Status}}">{{end}}
|
||||
{{if .Filter.HasLinks}}<input type="hidden" name="has" value="{{join "," .Filter.HasLinks}}">{{end}}
|
||||
{{if .Filter.ShowArchived}}<input type="hidden" name="show-archived" value="1">{{end}}
|
||||
{{if .Filter.ProjectPath}}<input type="hidden" name="project" value="{{.Filter.ProjectPath}}">{{end}}
|
||||
{{if and .Filter.ProjectPath (not .Filter.IncludeDescendants)}}<input type="hidden" name="project_descendants" value="0">{{end}}
|
||||
</form>
|
||||
|
||||
{{template "view-project-chip" .}}
|
||||
|
||||
{{if .AllTags}}
|
||||
<div class="chip-row">
|
||||
<span class="muted">tag:</span>
|
||||
|
||||
@@ -188,12 +188,20 @@ func (s *Server) handleTimeline(w http.ResponseWriter, r *http.Request) {
|
||||
display := *payload
|
||||
display.Cached = hit
|
||||
|
||||
projects, err := s.parentOptions(r.Context())
|
||||
if err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
}
|
||||
data := map[string]any{
|
||||
"Title": "timeline",
|
||||
"P": display,
|
||||
"Filter": q.Filter,
|
||||
"Query": q,
|
||||
"Now": now,
|
||||
"Title": "timeline",
|
||||
"P": display,
|
||||
"Filter": q.Filter,
|
||||
"Query": q,
|
||||
"Now": now,
|
||||
"Projects": projects,
|
||||
"BasePath": "/timeline",
|
||||
"ProjectChipTarget": "#timeline-section",
|
||||
}
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
s.render(w, r, "timeline_section", data)
|
||||
|
||||
@@ -338,10 +338,15 @@ func TestTimelineFilterByTagAppliesAcrossKinds(t *testing.T) {
|
||||
|
||||
tag := "tl-tag-work-" + stamp
|
||||
_, body := get(t, h, "/timeline?tag="+tag)
|
||||
if !strings.Contains(body, "tl-tag-d-"+stamp) {
|
||||
// Phase 5i Slice A: the project picker renders every item path as a
|
||||
// <select> option, so a naive substring match also sees filtered-out
|
||||
// items inside the dropdown. Anchor on the timeline-row link instead.
|
||||
devLink := `href="/i/dev.tl-tag-d-` + stamp + `"`
|
||||
homeLink := `href="/i/home.tl-tag-h-` + stamp + `"`
|
||||
if !strings.Contains(body, devLink) {
|
||||
t.Errorf("?tag=%s should surface dev-tagged item", tag)
|
||||
}
|
||||
if strings.Contains(body, "tl-tag-h-"+stamp) {
|
||||
if strings.Contains(body, homeLink) {
|
||||
t.Errorf("?tag=%s should hide home-tagged item", tag)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,14 @@ type TreeFilter struct {
|
||||
HasLinks []string // ANY of these ref_types must be linked to the item ("caldav-list", "gitea-repo")
|
||||
ShowArchived bool // when false, hide items with archived=true even if Status matches
|
||||
Public *bool // Phase 4d — nil = no filter; true = public only; false = private only
|
||||
// Phase 5i Slice A — project scope.
|
||||
// ProjectPath is the picked project's primary path (e.g. "work.upc"). Empty
|
||||
// means no project filter. IncludeDescendants defaults to true; when false,
|
||||
// only items whose paths include the exact ProjectPath match (no subtree).
|
||||
// Per m's Q5 pick (2026-05-26), descendants are NOT always-on — the chip
|
||||
// exposes an explicit on/off toggle.
|
||||
ProjectPath string
|
||||
IncludeDescendants bool
|
||||
}
|
||||
|
||||
// Active reports whether any filter dimension is set to something other than
|
||||
@@ -26,6 +34,9 @@ func (f TreeFilter) Active() bool {
|
||||
if f.Q != "" || len(f.Tags) > 0 || len(f.Management) > 0 || len(f.HasLinks) > 0 || f.ShowArchived || f.Public != nil {
|
||||
return true
|
||||
}
|
||||
if f.ProjectPath != "" {
|
||||
return true
|
||||
}
|
||||
// Status is the only dimension with a default; treat it as "active" if it
|
||||
// deviates from {"active"}.
|
||||
if len(f.Status) != 1 || f.Status[0] != "active" {
|
||||
@@ -45,12 +56,14 @@ func (f TreeFilter) Active() bool {
|
||||
// TestCalendarFilterMultiValueTagsFromForm for the regression.
|
||||
func ParseTreeFilter(q url.Values) TreeFilter {
|
||||
f := TreeFilter{
|
||||
Q: strings.TrimSpace(q.Get("q")),
|
||||
Tags: parseValues(q, "tag"),
|
||||
Management: parseValues(q, "mgmt"),
|
||||
Status: parseValues(q, "status"),
|
||||
HasLinks: parseValues(q, "has"),
|
||||
ShowArchived: q.Get("show-archived") == "1",
|
||||
Q: strings.TrimSpace(q.Get("q")),
|
||||
Tags: parseValues(q, "tag"),
|
||||
Management: parseValues(q, "mgmt"),
|
||||
Status: parseValues(q, "status"),
|
||||
HasLinks: parseValues(q, "has"),
|
||||
ShowArchived: q.Get("show-archived") == "1",
|
||||
ProjectPath: strings.TrimSpace(q.Get("project")),
|
||||
IncludeDescendants: true,
|
||||
}
|
||||
if v := strings.TrimSpace(q.Get("public")); v != "" {
|
||||
// Treat 1/true/yes/on as true; 0/false/no/off as false; anything else nil.
|
||||
@@ -63,6 +76,11 @@ func ParseTreeFilter(q url.Values) TreeFilter {
|
||||
f.Public = &b
|
||||
}
|
||||
}
|
||||
// project_descendants=0 flips the toggle off; any other / missing value
|
||||
// leaves the default (true). Matches the show-archived parsing pattern.
|
||||
if q.Get("project_descendants") == "0" {
|
||||
f.IncludeDescendants = false
|
||||
}
|
||||
if len(f.Status) == 0 {
|
||||
f.Status = []string{"active"}
|
||||
}
|
||||
@@ -99,6 +117,14 @@ func (f TreeFilter) QueryString() string {
|
||||
v.Set("public", "0")
|
||||
}
|
||||
}
|
||||
if f.ProjectPath != "" {
|
||||
v.Set("project", f.ProjectPath)
|
||||
// IncludeDescendants=true is the default — elide. Only emit when the
|
||||
// user has explicitly turned descendants off (the chip's "off" state).
|
||||
if !f.IncludeDescendants {
|
||||
v.Set("project_descendants", "0")
|
||||
}
|
||||
}
|
||||
return v.Encode()
|
||||
}
|
||||
|
||||
@@ -120,11 +146,19 @@ func (f TreeFilter) TogglePublic() TreeFilter {
|
||||
|
||||
// URL builds a `/?…` URL for this filter. Empty filter → "/".
|
||||
func (f TreeFilter) URL() string {
|
||||
return f.URLOn("/")
|
||||
}
|
||||
|
||||
// URLOn builds a URL anchored at `base` for this filter. Empty filter →
|
||||
// `base` unchanged. Used by Views-supporting pages (dashboard, timeline,
|
||||
// calendar) to construct chip URLs that stay on the current route, where the
|
||||
// default URL() always lands on "/".
|
||||
func (f TreeFilter) URLOn(base string) string {
|
||||
q := f.QueryString()
|
||||
if q == "" {
|
||||
return "/"
|
||||
return base
|
||||
}
|
||||
return "/?" + q
|
||||
return base + "?" + q
|
||||
}
|
||||
|
||||
// ToggleTag returns a copy with tag added/removed.
|
||||
@@ -166,6 +200,29 @@ func (f TreeFilter) ToggleShowArchived() TreeFilter {
|
||||
return next
|
||||
}
|
||||
|
||||
// SetProject returns a copy scoped to the given primary path. Empty path
|
||||
// clears the scope. IncludeDescendants resets to true (the safe default) when
|
||||
// the project is cleared so a future SetProject doesn't inherit a stale off
|
||||
// state.
|
||||
func (f TreeFilter) SetProject(path string) TreeFilter {
|
||||
next := f
|
||||
next.ProjectPath = strings.TrimSpace(path)
|
||||
if next.ProjectPath == "" {
|
||||
next.IncludeDescendants = true
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
// ToggleIncludeDescendants flips the descendants toggle. The chip stays
|
||||
// settable even with no project picked (so the URL bar can carry the user's
|
||||
// preference for the next project they pick), but Matches only consults it
|
||||
// when ProjectPath is set.
|
||||
func (f TreeFilter) ToggleIncludeDescendants() TreeFilter {
|
||||
next := f
|
||||
next.IncludeDescendants = !f.IncludeDescendants
|
||||
return next
|
||||
}
|
||||
|
||||
func toggleString(in []string, val string) []string {
|
||||
found := false
|
||||
out := make([]string, 0, len(in))
|
||||
@@ -229,6 +286,28 @@ func (f TreeFilter) Matches(it *store.Item, itemLinkKinds map[string]struct{}) b
|
||||
if f.Public != nil && *f.Public != it.Public {
|
||||
return false
|
||||
}
|
||||
// Project scope (Phase 5i Slice A). When set, the item must have at least
|
||||
// one path equal to ProjectPath (exact match), and — when
|
||||
// IncludeDescendants is on — paths that are descendants of ProjectPath
|
||||
// (prefix + ".") also match. Multi-parent items are in scope as long as
|
||||
// ANY of their paths qualifies.
|
||||
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
|
||||
}
|
||||
}
|
||||
// q substring match.
|
||||
if f.Q != "" {
|
||||
q := strings.ToLower(f.Q)
|
||||
|
||||
@@ -189,6 +189,129 @@ func TestComputeChipCountsTagCounts(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestProjectFilterIncludesDescendants verifies Slice A scope semantics: a
|
||||
// picked ProjectPath matches the item itself plus every descendant in the DAG
|
||||
// closure (any path with the ProjectPath + "." prefix). Multi-parent items
|
||||
// are in scope when any of their paths qualifies.
|
||||
func TestProjectFilterIncludesDescendants(t *testing.T) {
|
||||
work := &store.Item{ID: "work", Slug: "work", Paths: []string{"work"}, Status: "active"}
|
||||
upc := &store.Item{ID: "upc", Slug: "upc", Paths: []string{"work.upc"}, ParentIDs: []string{"work"}, Status: "active"}
|
||||
deadlines := &store.Item{ID: "dl", Slug: "deadlines", Paths: []string{"work.upc.deadlines"}, ParentIDs: []string{"upc"}, Status: "active"}
|
||||
other := &store.Item{ID: "other", Slug: "other", Paths: []string{"work.other"}, ParentIDs: []string{"work"}, Status: "active"}
|
||||
// Multi-parent: lives under both dev.paliad and work.paliad. Setting the
|
||||
// scope to "work" must put it in scope via its work.paliad lineage.
|
||||
paliad := &store.Item{ID: "paliad", Slug: "paliad", Paths: []string{"dev.paliad", "work.paliad"}, Status: "active"}
|
||||
dev := &store.Item{ID: "dev", Slug: "dev", Paths: []string{"dev"}, Status: "active"}
|
||||
|
||||
links := map[string]struct{}{}
|
||||
|
||||
f := TreeFilter{Status: []string{"active"}, ProjectPath: "work.upc", IncludeDescendants: true}
|
||||
if !f.Matches(upc, links) {
|
||||
t.Error("exact-match item should pass")
|
||||
}
|
||||
if !f.Matches(deadlines, links) {
|
||||
t.Error("descendant should pass")
|
||||
}
|
||||
if f.Matches(work, links) {
|
||||
t.Error("ancestor should NOT pass project=work.upc")
|
||||
}
|
||||
if f.Matches(other, links) {
|
||||
t.Error("sibling should NOT pass")
|
||||
}
|
||||
|
||||
// Multi-parent: scope=work should match paliad via the work.paliad path.
|
||||
f2 := TreeFilter{Status: []string{"active"}, ProjectPath: "work", IncludeDescendants: true}
|
||||
if !f2.Matches(paliad, links) {
|
||||
t.Error("multi-parent item should match scope=work via work.paliad path")
|
||||
}
|
||||
if f2.Matches(dev, links) {
|
||||
t.Error("dev (sibling root) should NOT pass scope=work")
|
||||
}
|
||||
}
|
||||
|
||||
// TestProjectFilterDescendantsOff verifies that flipping IncludeDescendants
|
||||
// off restricts the scope to items whose paths equal ProjectPath exactly. m
|
||||
// asked for this toggle behaviour explicitly in Q5 (2026-05-26): always-on
|
||||
// descendants was the inventor pick; m wants the chip to expose on/off.
|
||||
func TestProjectFilterDescendantsOff(t *testing.T) {
|
||||
upc := &store.Item{ID: "upc", Slug: "upc", Paths: []string{"work.upc"}, Status: "active"}
|
||||
deadlines := &store.Item{ID: "dl", Slug: "deadlines", Paths: []string{"work.upc.deadlines"}, Status: "active"}
|
||||
|
||||
links := map[string]struct{}{}
|
||||
f := TreeFilter{Status: []string{"active"}, ProjectPath: "work.upc", IncludeDescendants: false}
|
||||
if !f.Matches(upc, links) {
|
||||
t.Error("exact-match item should still pass with descendants off")
|
||||
}
|
||||
if f.Matches(deadlines, links) {
|
||||
t.Error("descendant should NOT pass when IncludeDescendants is off")
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseTreeFilterProjectFields verifies URL-param parsing for the project
|
||||
// scope. IncludeDescendants defaults to true; project_descendants=0 flips it.
|
||||
// project_descendants without an explicit "0" stays at default.
|
||||
func TestParseTreeFilterProjectFields(t *testing.T) {
|
||||
f := parseQS(t, "?project=work.upc")
|
||||
if f.ProjectPath != "work.upc" {
|
||||
t.Errorf("ProjectPath = %q, want %q", f.ProjectPath, "work.upc")
|
||||
}
|
||||
if !f.IncludeDescendants {
|
||||
t.Error("IncludeDescendants should default to true")
|
||||
}
|
||||
if !f.Active() {
|
||||
t.Error("project scope should make filter Active()")
|
||||
}
|
||||
|
||||
off := parseQS(t, "?project=work.upc&project_descendants=0")
|
||||
if off.IncludeDescendants {
|
||||
t.Error("project_descendants=0 should set IncludeDescendants=false")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTreeFilterProjectRoundTrip verifies that emitting + re-parsing the
|
||||
// project fields yields the same TreeFilter, including the descendants
|
||||
// toggle when it deviates from default.
|
||||
func TestTreeFilterProjectRoundTrip(t *testing.T) {
|
||||
for _, qs := range []string{
|
||||
"?project=work.upc",
|
||||
"?project=work.upc&project_descendants=0",
|
||||
"?project=dev&q=paliad&tag=work&status=done",
|
||||
} {
|
||||
f := parseQS(t, qs)
|
||||
out := f.URL()
|
||||
f2 := parseQS(t, strings.TrimPrefix(out, "/"))
|
||||
if f.URL() != f2.URL() {
|
||||
t.Errorf("round-trip mismatch: %q → %q → %q", qs, out, f2.URL())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetProjectAndToggleHelpers spot-checks the two helpers added in Slice A.
|
||||
func TestSetProjectAndToggleHelpers(t *testing.T) {
|
||||
f := TreeFilter{Status: []string{"active"}, IncludeDescendants: true}
|
||||
scoped := f.SetProject("work.upc")
|
||||
if scoped.ProjectPath != "work.upc" {
|
||||
t.Errorf("SetProject did not set path; got %q", scoped.ProjectPath)
|
||||
}
|
||||
if !scoped.IncludeDescendants {
|
||||
t.Error("SetProject should preserve IncludeDescendants when truthy")
|
||||
}
|
||||
// Toggling descendants flips the bool.
|
||||
off := scoped.ToggleIncludeDescendants()
|
||||
if off.IncludeDescendants {
|
||||
t.Error("ToggleIncludeDescendants should flip true → false")
|
||||
}
|
||||
// Clearing the project resets the toggle to the safe default (true) so a
|
||||
// future SetProject call doesn't inherit the off state.
|
||||
cleared := off.SetProject("")
|
||||
if !cleared.IncludeDescendants {
|
||||
t.Error("clearing project should reset IncludeDescendants to true")
|
||||
}
|
||||
if cleared.ProjectPath != "" {
|
||||
t.Errorf("clearing project should empty path; got %q", cleared.ProjectPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToggleStatusKeepsActiveDefault(t *testing.T) {
|
||||
f := TreeFilter{Status: []string{"active"}}
|
||||
// Toggling active off when nothing else is on should leave us at the
|
||||
|
||||
Reference in New Issue
Block a user