Each major section on /i/{path} is now wrapped in a native <details>
element with a smart-default `open` attribute. The inline JS overrides
the default from localStorage so m's per-item collapse state survives
reloads.
## Smart defaults (server-rendered open attr)
- Tasks: open if any linked calendar has >=1 open VTODO
- Issues: open if total open issues <= 10
- Documents: open if dated link count <= 5
- Public listing: closed by default
## Persistence
localStorage["projax.section." + item_id + "." + section] = "open" | "closed".
Inline JS reads on boot, writes on toggle. The "reset section state" link
in the form actions wipes every key for the current item and reloads —
smart defaults take over again.
## What's not collapsed
- Title + status/tags chip line (always visible breadcrumb)
- The inline edit form's standard fields (title/slug/parents/content)
Only the auxiliary sections — Tasks, Issues, Documents, Public listing —
collapse. m always sees what an item *is* without expanding anything.
## Tests
- TestDetailIncludesSectionToggleScript — script fragments ship
- TestDetailSectionsWrappedInDetails — every section has its wrapper
- TestDetailDocumentsClosedDefaultsWhenManyItems — 0-doc baseline is open
## docs/design.md
New section before §15 documents thresholds, persistence semantics, and
the non-collapsible carve-outs.
94 lines
2.9 KiB
Go
94 lines
2.9 KiB
Go
package web_test
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// TestDetailIncludesSectionToggleScript proves the inline JS that powers
|
|
// the per-item localStorage persistence ships on the detail page. Without
|
|
// it, the smart defaults would render but toggles wouldn't survive a
|
|
// reload — silent UX regression that's worth a guard.
|
|
func TestDetailIncludesSectionToggleScript(t *testing.T) {
|
|
srv, pool := mustServer(t)
|
|
defer pool.Close()
|
|
h := srv.Routes()
|
|
code, body := get(t, h, "/i/dev")
|
|
if code != 200 {
|
|
t.Fatalf("GET /i/dev → %d", code)
|
|
}
|
|
for _, want := range []string{
|
|
`details.proj-section[data-section][data-item-id]`,
|
|
`projax.section.`,
|
|
`proj-section-reset`,
|
|
`localStorage.setItem`,
|
|
} {
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("detail page missing collapsible-script fragment %q", want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestDetailSectionsWrappedInDetails proves every section on /i/{path}
|
|
// gets a <details data-section="..."> wrapper, regardless of count. We
|
|
// can't easily set up dozens of issues for the count-driven default
|
|
// assertion in a unit test (Gitea is mocked), so this just verifies the
|
|
// wrapper exists and the data-section attribute is correct.
|
|
func TestDetailSectionsWrappedInDetails(t *testing.T) {
|
|
srv, pool := mustServer(t)
|
|
defer pool.Close()
|
|
h := srv.Routes()
|
|
code, body := get(t, h, "/i/dev")
|
|
if code != 200 {
|
|
t.Fatalf("GET /i/dev → %d", code)
|
|
}
|
|
// Documents always renders (no integration deps); Public listing
|
|
// always renders inside the form.
|
|
for _, want := range []string{
|
|
`data-section="documents"`,
|
|
`data-section="public"`,
|
|
`class="proj-section-summary"`,
|
|
} {
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("detail page missing section wrapper %q", want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestDetailDocumentsClosedDefaultsWhenManyItems is the threshold check
|
|
// for the Documents section (default open when ≤5). With a fresh item
|
|
// holding 0 dated links, the wrapper should be open. We can't easily
|
|
// seed >5 in a fast unit test against the live DB, so this just probes
|
|
// the open-state baseline; the count-driven branch is exercised by the
|
|
// template logic and visually verified during the deploy probe.
|
|
func TestDetailDocumentsClosedDefaultsWhenManyItems(t *testing.T) {
|
|
srv, pool := mustServer(t)
|
|
defer pool.Close()
|
|
h := srv.Routes()
|
|
_, body := get(t, h, "/i/dev")
|
|
// Find the documents section's opening tag and check for ` open` attr.
|
|
idx := strings.Index(body, `data-section="documents"`)
|
|
if idx < 0 {
|
|
t.Fatalf("documents section not found in body")
|
|
}
|
|
// Slice the surrounding 200 chars to look for the `open` attribute.
|
|
slice := body[max0(idx-100):min(len(body), idx+200)]
|
|
if !strings.Contains(slice, "open") {
|
|
t.Errorf("expected documents section to be open by default with 0 docs (≤5 threshold), got:\n%s", slice)
|
|
}
|
|
}
|
|
|
|
func max0(x int) int {
|
|
if x < 0 {
|
|
return 0
|
|
}
|
|
return x
|
|
}
|
|
|
|
func min(a, b int) int {
|
|
if a < b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|