oficina / sheets Public

feat: sort and filter, non-destructively — and why that is not a stopgap

Spec 13 item 9, minus conditional formatting, which ruling 17 already
names as the second slip candidate.

**The sort reorders what is displayed and never what is stored, and
that is a constraint rather than a preference.** calc's dependency
graph is keyed on POSITION — store/handle.go's cellKey builds
CellKey{rowPos+1, colPos+1} — so physically moving a row changes what
every existing formula points at, silently. Plan 1's review flagged
exactly this: "valid only until rows move". A test writes =A1*2, sorts,
and asserts it still reads 60.

Destructive sort now has a row in the spec's deferred table with its
real trigger: calc's key space rebuilt on stable ids. That is a
subsystem, not a feature, and pretending otherwise would have shipped a
sort that quietly corrupted formulas.

Criteria are sealed. A filter value IS a cell value — spec 2.1 lists
persisted sort and filter criteria for that reason — and `> 50000`
against a salary column is the number it filters on.

Numbers sort numerically, so 9 comes before 10. String order putting 10
first is the single most obvious way a spreadsheet sort can be wrong,
so it has its own test. Empties sort last, so a sorted column does not
open with a screen of blanks.

Paging happens after ordering, never before: paging first sorts one
page at a time, which looks like sorting and is not.

An invalid submission leaves the stored view alone rather than clearing
it — losing somebody's filter because they mistyped a column is a worse
answer than ignoring the submission.

handlers_table.go hit the 300-line cap, so the view handler moved to
its own file, which is where it belonged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018RN4HhHR4rHH3Fxh4LMds5
Paul Campbell pushed by paul@keymail.dev 01e8adf2709aa064031500682d6756104c6f032c parent 03242ae
11 files changed, +692 −15
  • docs/superpowers/specs/2026-08-30-sheets-design.md +1 −0
  • internal/sheets/app.go +4 −0
  • internal/sheets/handlers_table.go +32 −14
  • internal/sheets/handlers_view.go +64 −0
  • internal/sheets/migrations.go +1 −1
  • internal/sheets/migrations/0007_create_tab_views.sql +4 −0
  • internal/sheets/migrations/schema.sql +4 −0
  • internal/sheets/models.go +17 −0
  • internal/sheets/templates/table.html +45 −0
  • internal/sheets/viewspec.go +251 −0
  • internal/sheets/viewspec_test.go +269 −0

CI passed — run details

diff --git a/docs/superpowers/specs/2026-08-30-sheets-design.md b/docs/superpowers/specs/2026-08-30-sheets-design.md
index 261c2cf..df97a7c 100644
--- a/docs/superpowers/specs/2026-08-30-sheets-design.md
+++ b/docs/superpowers/specs/2026-08-30-sheets-design.md
@@ -1009,6 +1009,7 @@ it is a fallback rather than a scramble.
|---|---|
| Pegamento's real vault facet (sealed team names, §9.2) | The vault facet ships with a recovery story |
| Moving the provisioning queue out of the home (§9.4) | The home spec rules on it |
+| **Destructive sort — physically reordering rows** | calc's dependency graph is keyed on POSITION (`store/handle.go`'s `cellKey` builds `CellKey{rowPos+1, colPos+1}`), so moving a row changes what every existing formula points at, silently. Plan 1's review flagged this as "valid only until rows move". Trigger: **calc's key space rebuilt on stable ids**, which is a subsystem and not a feature. Sort and filter ship non-destructively meanwhile — they reorder what is displayed and never what is stored, which is correct rather than a stopgap. |
| Pivot tables | Asked for twice |
| Rich presence beyond who's-here | Teams above ~5 concurrent editors |
| Offline editing | — |
diff --git a/internal/sheets/app.go b/internal/sheets/app.go
index 3c6a113..c143b2f 100644
--- a/internal/sheets/app.go
+++ b/internal/sheets/app.go
@@ -166,6 +166,10 @@ func (a *App) Router() http.Handler {
r.Get("/sheets/{sheetID}/comments", a.handleComments)
r.Post("/sheets/{sheetID}/comments", a.handleCommentAdd)
r.Post("/sheets/{sheetID}/comments/resolve", a.handleCommentResolve)
+ // Sort and filter. Non-destructive: it reorders what is shown,
+ // never what is stored — see ViewSpec for why that is a
+ // constraint and not a preference.
+ r.Post("/sheets/{sheetID}/view", a.handleViewSave)
// The grid's own API. Under /api because it answers JSON and
// carries no chrome; behind the same session as everything
diff --git a/internal/sheets/handlers_table.go b/internal/sheets/handlers_table.go
index 491c126..d570e05 100644
--- a/internal/sheets/handlers_table.go
+++ b/internal/sheets/handlers_table.go
@@ -40,16 +40,18 @@ type tableRow struct {
// tableView is the page's payload.
type tableView struct {
- Sheet SheetSummary
- Rows []tableRow
- Cols []string
- Warned []string
- Page int
- Pages int
- PrevPage int
- NextPage int
- HasPrev bool
- HasNext bool
+ Sheet SheetSummary
+ Rows []tableRow
+ Cols []string
+ Warned []string
+ View ViewSpec
+ FilterOps []string
+ Page int
+ Pages int
+ PrevPage int
+ NextPage int
+ HasPrev bool
+ HasNext bool
}
// handleTable renders a page of a tab as an ordinary HTML table.
@@ -90,7 +92,10 @@ func (a *App) handleTable(w http.ResponseWriter, r *http.Request) {
for c := 0; c < tableCols; c++ {
view.Cols = append(view.Cols, colLabel(c))
}
- view.Rows = a.tableRows(h, first, editR, editC)
+ spec := a.loadView(s.TabID)
+ view.View = spec
+ view.FilterOps = filterOps
+ view.Rows = a.tableRowsIn(h, applyView(h, spec, first, tableRowsPerPage), editR, editC)
view.Warned = warnedCells(h, newPosIndex(h), maxWindowRows, tableCols)
a.render(w, r, "table", s.Title+" — table view", http.StatusOK, view)
@@ -222,10 +227,23 @@ func colLabel(n int) string {
// template is trusted to honour — means the read-only view cannot grow
// an edit form by somebody adding a branch to the template.
func (a *App) tableRows(h *store.Handle, first, editR, editC int) []tableRow {
- idx := newPosIndex(h)
- rows := make([]tableRow, 0, tableRowsPerPage)
+ order := make([]int, 0, tableRowsPerPage)
for i := 0; i < tableRowsPerPage; i++ {
- rowIdx := first + i
+ order = append(order, first+i)
+ }
+ return a.tableRowsIn(h, order, editR, editC)
+}
+
+// tableRowsIn builds rows in a given order.
+//
+// The order is a list of row POSITIONS, which is what lets a sorted or
+// filtered view reuse every other part of this: each cell still carries
+// its real coordinate, so an edit form posts the row it was always
+// posting and nothing downstream needs to know a view is in effect.
+func (a *App) tableRowsIn(h *store.Handle, order []int, editR, editC int) []tableRow {
+ idx := newPosIndex(h)
+ rows := make([]tableRow, 0, len(order))
+ for _, rowIdx := range order {
row := tableRow{Number: rowIdx + 1}
for c := 0; c < tableCols; c++ {
cell := tableCell{
diff --git a/internal/sheets/handlers_view.go b/internal/sheets/handlers_view.go
new file mode 100644
index 0000000..f387682
--- /dev/null
+++ b/internal/sheets/handlers_view.go
@@ -0,0 +1,64 @@
+package sheets
+
+import (
+ "net/http"
+ "strconv"
+)
+
+// handleViewSave stores a tab's sort and filter.
+//
+// Editor, not viewer: the view is persisted on the tab and everybody
+// looking at that tab sees it, so changing it changes what colleagues
+// see. That is a write, and it is gated like one.
+func (a *App) handleViewSave(w http.ResponseWriter, r *http.Request) {
+ s, ok := a.sheetFromURL(w, r)
+ if !ok {
+ return
+ }
+ viewer, ok := a.actor(r)
+ if !ok {
+ a.notFound(w, r)
+ return
+ }
+ if !a.mayWrite(s.ID, viewer) {
+ a.notFound(w, r)
+ return
+ }
+
+ spec := emptyView()
+ if v := r.FormValue("sortCol"); v != "" && v != "-1" {
+ n, err := strconv.Atoi(v)
+ if err != nil {
+ a.tableRedirect(w, r, s)
+ return
+ }
+ spec.SortCol = n
+ spec.SortDsc = r.FormValue("sortDesc") != ""
+ }
+ if v := r.FormValue("filterCol"); v != "" && v != "-1" {
+ n, err := strconv.Atoi(v)
+ if err != nil {
+ a.tableRedirect(w, r, s)
+ return
+ }
+ spec.FilterCol = n
+ spec.FilterOp = r.FormValue("filterOp")
+ spec.FilterVal = r.FormValue("filterVal")
+ }
+ // An invalid spec leaves the stored one alone rather than clearing
+ // it: losing somebody's filter because they mistyped a column is a
+ // worse answer than ignoring the submission.
+ if err := spec.Validate(); err != nil {
+ a.tableRedirect(w, r, s)
+ return
+ }
+ if err := a.saveView(s.TabID, spec, viewer); err != nil {
+ a.fail(w, r, err)
+ return
+ }
+ a.tableRedirect(w, r, s)
+}
+
+func (a *App) tableRedirect(w http.ResponseWriter, r *http.Request, s SheetSummary) {
+ http.Redirect(w, r, "/sheets/"+strconv.FormatUint(s.ID, 10)+"/table", http.StatusSeeOther)
+}
diff --git a/internal/sheets/migrations.go b/internal/sheets/migrations.go
index 1036731..69faa16 100644
--- a/internal/sheets/migrations.go
+++ b/internal/sheets/migrations.go
@@ -50,5 +50,5 @@ var BootSchema = migrate.Merge(sessions.Schema, auth.Schema, Schema)
// moved.
var Models = []any{
store.Sheet{}, store.Tab{}, store.Row{}, store.Col{},
- store.Cell{}, store.Format{}, store.Event{}, Member{}, Grant{}, ShareLink{}, Comment{},
+ store.Cell{}, store.Format{}, store.Event{}, Member{}, Grant{}, ShareLink{}, Comment{}, TabView{},
}
diff --git a/internal/sheets/migrations/0007_create_tab_views.sql b/internal/sheets/migrations/0007_create_tab_views.sql
new file mode 100644
index 0000000..936324f
--- /dev/null
+++ b/internal/sheets/migrations/0007_create_tab_views.sql
@@ -0,0 +1,4 @@
+CREATE TABLE `tab_views` (`id` integer PRIMARY KEY AUTOINCREMENT,`tab_id` integer,`spec_sealed` blob,`updated_by` integer,`updated_at` datetime);
+
+CREATE UNIQUE INDEX `idx_tab_views_tab_id` ON `tab_views`(`tab_id`);
+
diff --git a/internal/sheets/migrations/schema.sql b/internal/sheets/migrations/schema.sql
index 509c9ac..f386203 100644
--- a/internal/sheets/migrations/schema.sql
+++ b/internal/sheets/migrations/schema.sql
@@ -21,6 +21,8 @@ CREATE TABLE `share_links` (`id` integer PRIMARY KEY AUTOINCREMENT,`sheet_id` in
CREATE TABLE `sheets` (`id` integer PRIMARY KEY AUTOINCREMENT,`title_sealed` blob,`created_by` integer,`created_at` datetime,`updated_at` datetime,`deleted_at` datetime);
+CREATE TABLE `tab_views` (`id` integer PRIMARY KEY AUTOINCREMENT,`tab_id` integer,`spec_sealed` blob,`updated_by` integer,`updated_at` datetime);
+
CREATE TABLE `tabs` (`id` integer PRIMARY KEY AUTOINCREMENT,`sheet_id` integer,`name_sealed` blob,`position` integer,`frozen_rows` integer,`frozen_cols` integer);
CREATE UNIQUE INDEX `idx_col_tab_pos` ON `cols`(`tab_id`,`position`);
@@ -47,5 +49,7 @@ CREATE UNIQUE INDEX `idx_share_links_token_hash` ON `share_links`(`token_hash`);
CREATE INDEX `idx_sheets_deleted_at` ON `sheets`(`deleted_at`);
+CREATE UNIQUE INDEX `idx_tab_views_tab_id` ON `tab_views`(`tab_id`);
+
CREATE INDEX `idx_tabs_sheet_id` ON `tabs`(`sheet_id`);
diff --git a/internal/sheets/models.go b/internal/sheets/models.go
index ff754e2..aa62525 100644
--- a/internal/sheets/models.go
+++ b/internal/sheets/models.go
@@ -178,3 +178,20 @@ type Comment struct {
ResolvedAt *time.Time
ResolvedBy uint64
}
+
+// TabView is a tab's persisted sort and filter.
+//
+// SpecSealed, because a filter value IS a cell value — spec §2.1 lists
+// "persisted sort and filter criteria" among what this design seals,
+// for exactly that reason. A filter reading `> 50000` against a salary
+// column is the number it is filtering on.
+//
+// One row per tab (uniqueIndex): a tab has one view, and two rows would
+// make "how is this sorted" depend on which the query returned first.
+type TabView struct {
+ ID uint64 `gorm:"primaryKey"`
+ TabID uint64 `gorm:"uniqueIndex"`
+ SpecSealed []byte // seal purpose "tab.view"
+ UpdatedBy uint64
+ UpdatedAt time.Time
+}
diff --git a/internal/sheets/templates/table.html b/internal/sheets/templates/table.html
index 00dba93..31fc509 100644
--- a/internal/sheets/templates/table.html
+++ b/internal/sheets/templates/table.html
@@ -10,6 +10,51 @@ Every cell is editable here, with no JavaScript at all.</p>
<a rst-btn="primary" href="/sheets/{{.Data.Sheet.ID}}">Open the grid</a>
</header>
+{{/* Sort and filter reorder what is SHOWN and never what is stored:
+ calc's dependency graph is keyed on position, so physically moving
+ rows would change what every formula points at. See ViewSpec. */}}
+<form rst-form method="post" action="/sheets/{{.Data.Sheet.ID}}/view">
+<div rst-field-row>
+<div rst-field>
+<label rst-field-label for="sortCol">Sort by</label>
+<select rst-input id="sortCol" name="sortCol">
+<option value="-1">nothing</option>
+{{$view := .Data.View}}
+{{range $i, $name := .Data.Cols}}<option value="{{$i}}"{{if eq $i $view.SortCol}} selected{{end}}>{{$name}}</option>{{end}}
+</select>
+</div>
+<div rst-field>
+<label rst-field-label for="sortDesc">
+<input rst-check id="sortDesc" name="sortDesc" type="checkbox" value="1"{{if .Data.View.SortDsc}} checked{{end}}>
+Descending</label>
+</div>
+<div rst-field>
+<label rst-field-label for="filterCol">Show rows where</label>
+<select rst-input id="filterCol" name="filterCol">
+<option value="-1">everything</option>
+{{range $i, $name := .Data.Cols}}<option value="{{$i}}"{{if eq $i $view.FilterCol}} selected{{end}}>{{$name}}</option>{{end}}
+</select>
+</div>
+<div rst-field>
+<label rst-field-label for="filterOp">is</label>
+<select rst-input id="filterOp" name="filterOp">
+{{range .Data.FilterOps}}<option value="{{.}}"{{if eq . $view.FilterOp}} selected{{end}}>{{.}}</option>{{end}}
+</select>
+</div>
+<div rst-field>
+<label rst-field-label for="filterVal">value</label>
+<input rst-input="short" id="filterVal" name="filterVal" type="text" value="{{.Data.View.FilterVal}}">
+</div>
+<button rst-btn="primary" type="submit">Apply</button>
+</div>
+</form>
+
+{{if .Data.View.Active}}
+<p rst-help>This view is sorted or filtered. It changes what everybody
+sees on this tab, and it changes nothing about the sheet itself — the
+rows are where they always were.</p>
+{{end}}
+
{{template "contrast-notice" .Data.Warned}}
<div style="overflow-x:auto">
<table data-cell-table>
diff --git a/internal/sheets/viewspec.go b/internal/sheets/viewspec.go
new file mode 100644
index 0000000..d9aafd0
--- /dev/null
+++ b/internal/sheets/viewspec.go
@@ -0,0 +1,251 @@
+package sheets
+
+import (
+ "encoding/json"
+ "errors"
+ "sort"
+ "strconv"
+ "strings"
+
+ "gorm.io/gorm/clause"
+
+ "amadan.net/oficina/sheets/internal/sheets/store"
+)
+
+// ViewSpec is how a tab is sorted and filtered.
+//
+// **Non-destructive, and that is a constraint rather than a
+// preference.** calc's dependency graph is keyed on POSITION —
+// store/handle.go's cellKey builds CellKey{rowPos+1, colPos+1} — so
+// physically reordering rows changes what every existing formula
+// points at, silently. Plan 1's review flagged exactly this ("valid
+// only until rows move"). Sorting therefore reorders what is DISPLAYED
+// and never what is stored; the graph, and every reference in it, is
+// untouched.
+//
+// A destructive sort is a real thing to want and is not built. Its
+// trigger is written into the spec's deferred table: it needs calc's
+// key space rebuilt on stable ids first, which is a subsystem, not a
+// feature.
+type ViewSpec struct {
+ // SortCol is a zero-based column, or -1 for no sort.
+ SortCol int `json:"sortCol"`
+ SortDsc bool `json:"sortDesc"`
+
+ // FilterCol is a zero-based column, or -1 for no filter.
+ FilterCol int `json:"filterCol"`
+ FilterOp string `json:"filterOp"`
+ FilterVal string `json:"filterVal"`
+}
+
+// emptyView is what a tab has before anybody sorts it.
+func emptyView() ViewSpec { return ViewSpec{SortCol: -1, FilterCol: -1} }
+
+// Active reports whether this view does anything.
+func (v ViewSpec) Active() bool { return v.SortCol >= 0 || v.FilterCol >= 0 }
+
+// filterOps are the comparisons a filter may use.
+//
+// A small, closed set. Each is a thing somebody can hold in their head,
+// and a closed set is what lets the criteria be validated on the way in
+// rather than interpreted on the way out.
+var filterOps = []string{"contains", "equals", "gt", "lt", "nonempty"}
+
+func filterOpValid(op string) bool {
+ for _, ok := range filterOps {
+ if op == ok {
+ return true
+ }
+ }
+ return false
+}
+
+// Validate bounds a spec before it is stored.
+func (v ViewSpec) Validate() error {
+ if v.SortCol < -1 || v.SortCol >= tableCols {
+ return errors.New("sheets: sort column out of range")
+ }
+ if v.FilterCol < -1 || v.FilterCol >= tableCols {
+ return errors.New("sheets: filter column out of range")
+ }
+ if v.FilterCol >= 0 && !filterOpValid(v.FilterOp) {
+ return errors.New("sheets: unknown filter comparison")
+ }
+ if len(v.FilterVal) > maxRawBytes {
+ return errors.New("sheets: filter value too long")
+ }
+ return nil
+}
+
+// loadView reads a tab's view, or the empty one.
+func (a *App) loadView(tabID uint64) ViewSpec {
+ if a.db == nil || a.sealer == nil {
+ return emptyView()
+ }
+ var row TabView
+ if err := a.db.Where("tab_id = ?", tabID).First(&row).Error; err != nil {
+ return emptyView()
+ }
+ body, err := a.sealer.Open("tab.view", row.SpecSealed)
+ if err != nil {
+ return emptyView()
+ }
+ spec := emptyView()
+ if err := json.Unmarshal(body, &spec); err != nil {
+ return emptyView()
+ }
+ if spec.Validate() != nil {
+ // A stored spec this build cannot make sense of shows the tab
+ // unsorted rather than refusing to render it.
+ return emptyView()
+ }
+ return spec
+}
+
+// saveView stores a tab's view, sealed.
+func (a *App) saveView(tabID uint64, spec ViewSpec, by Member) error {
+ if a.db == nil {
+ return errNoDB
+ }
+ if a.sealer == nil {
+ return errNoSealer
+ }
+ if err := spec.Validate(); err != nil {
+ return err
+ }
+ body, err := json.Marshal(spec)
+ if err != nil {
+ return err
+ }
+ sealed, err := a.sealer.Seal("tab.view", body)
+ if err != nil {
+ return err
+ }
+ return a.db.Clauses(clause.OnConflict{
+ Columns: []clause.Column{{Name: "tab_id"}},
+ DoUpdates: clause.AssignmentColumns([]string{"spec_sealed", "updated_by", "updated_at"}),
+ }).Create(&TabView{TabID: tabID, SpecSealed: sealed, UpdatedBy: by.ID}).Error
+}
+
+// applyView turns a stored spec into the order rows should be shown in.
+//
+// It returns display positions: the row position to render at each
+// place. Cells keep their real coordinates everywhere else in the app,
+// so nothing downstream has to know a view is in effect — and a write
+// still addresses the row it was always addressing.
+func applyView(h *store.Handle, spec ViewSpec, first, count int) []int {
+ rows := make([]int, 0, count)
+ for _, r := range h.GridRows() {
+ rows = append(rows, r.Pos)
+ }
+ if len(rows) == 0 {
+ // Nothing stored yet: show the plain window, so an empty sheet
+ // still has rows to type into.
+ for i := 0; i < count; i++ {
+ rows = append(rows, first+i)
+ }
+ return rows
+ }
+
+ idx := newPosIndex(h)
+ if spec.FilterCol >= 0 {
+ kept := rows[:0:0]
+ for _, rp := range rows {
+ if matches(h, idx, rp, spec) {
+ kept = append(kept, rp)
+ }
+ }
+ rows = kept
+ }
+ if spec.SortCol >= 0 {
+ sort.SliceStable(rows, func(i, j int) bool {
+ a := cellText(h, idx, rows[i], spec.SortCol)
+ b := cellText(h, idx, rows[j], spec.SortCol)
+ if spec.SortDsc {
+ return less(b, a)
+ }
+ return less(a, b)
+ })
+ }
+
+ // Page after ordering, not before: paging first would sort one
+ // page at a time, which looks like sorting and is not.
+ if first >= len(rows) {
+ return nil
+ }
+ end := first + count
+ if end > len(rows) {
+ end = len(rows)
+ }
+ return rows[first:end]
+}
+
+// cellText reads a cell's displayed string at a position.
+func cellText(h *store.Handle, idx posIndex, rowPos, colPos int) string {
+ rowID, ok := idx.rowID[rowPos]
+ if !ok {
+ return ""
+ }
+ colID, ok := idx.colID[colPos]
+ if !ok {
+ return ""
+ }
+ return display(h.Value(rowID, colID), h.Format(rowID, colID))
+}
+
+// matches applies a filter to one row.
+func matches(h *store.Handle, idx posIndex, rowPos int, spec ViewSpec) bool {
+ got := cellText(h, idx, rowPos, spec.FilterCol)
+ want := spec.FilterVal
+ switch spec.FilterOp {
+ case "nonempty":
+ return strings.TrimSpace(got) != ""
+ case "equals":
+ return strings.EqualFold(strings.TrimSpace(got), strings.TrimSpace(want))
+ case "contains":
+ return strings.Contains(strings.ToLower(got), strings.ToLower(want))
+ case "gt", "lt":
+ g, err1 := strconv.ParseFloat(strings.TrimSpace(got), 64)
+ wv, err2 := strconv.ParseFloat(strings.TrimSpace(want), 64)
+ if err1 != nil || err2 != nil {
+ // A numeric comparison against something that is not a
+ // number excludes the row rather than guessing an order.
+ return false
+ }
+ if spec.FilterOp == "gt" {
+ return g > wv
+ }
+ return g < wv
+ }
+ return true
+}
+
+// less orders two displayed strings, numbers before text.
+//
+// Numbers compare numerically, so 9 sorts before 10 — string order
+// would put 10 first, which is the single most obvious way a
+// spreadsheet sort can be wrong. Everything else compares
+// case-insensitively, and empties sort last so a sorted column does not
+// open with a screen of blanks.
+func less(a, b string) bool {
+ af, aNum := parseNum(a)
+ bf, bNum := parseNum(b)
+ switch {
+ case a == "" && b == "":
+ return false
+ case a == "":
+ return false
+ case b == "":
+ return true
+ case aNum && bNum:
+ return af < bf
+ case aNum != bNum:
+ return aNum
+ }
+ return strings.ToLower(a) < strings.ToLower(b)
+}
+
+func parseNum(s string) (float64, bool) {
+ f, err := strconv.ParseFloat(strings.TrimSpace(s), 64)
+ return f, err == nil
+}
diff --git a/internal/sheets/viewspec_test.go b/internal/sheets/viewspec_test.go
new file mode 100644
index 0000000..04c06b2
--- /dev/null
+++ b/internal/sheets/viewspec_test.go
@@ -0,0 +1,269 @@
+package sheets
+
+import (
+ "bytes"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+)
+
+// seedColumn writes one value per row into column 0.
+func seedColumn(t *testing.T, app *App, cookies []*http.Cookie, cells string, values []string) {
+ t.Helper()
+ for i, v := range values {
+ rec := postCell(t, app, cookies, cells, fmt.Sprintf(`{"r":%d,"c":0,"raw":%q}`, i, v))
+ if rec.Code != http.StatusOK {
+ t.Fatalf("seeding row %d = %d: %s", i, rec.Code, rec.Body.String())
+ }
+ }
+}
+
+func tablePath(s SheetSummary) string { return fmt.Sprintf("/sheets/%d/table", s.ID) }
+
+func saveViewForm(t *testing.T, app *App, s SheetSummary, cookies []*http.Cookie, form url.Values) {
+ t.Helper()
+ rec := do(t, app, as(postForm(fmt.Sprintf("/sheets/%d/view", s.ID), form), cookies))
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("saving the view = %d: %s", rec.Code, rec.Body.String())
+ }
+}
+
+// rowOrder reads the row numbers out of a rendered table, in order.
+func rowOrder(body string) []string {
+ var out []string
+ for _, part := range strings.Split(body, `<th scope="row">`)[1:] {
+ if i := strings.Index(part, "<"); i > 0 {
+ out = append(out, part[:i])
+ }
+ }
+ return out
+}
+
+// The single most obvious way a spreadsheet sort can be wrong.
+func TestSortIsNumericNotLexicographic(t *testing.T) {
+ if !less("9", "10") {
+ t.Fatal("9 sorts after 10; the sort is comparing numbers as strings")
+ }
+ if less("10", "9") {
+ t.Fatal("10 sorts before 9")
+ }
+ // Text still compares as text, case-insensitively.
+ if !less("apple", "Banana") {
+ t.Error("text does not compare case-insensitively")
+ }
+ // Numbers before text, and empties last, so a sorted column does
+ // not open with a screen of blanks.
+ if !less("1", "apple") {
+ t.Error("text sorts before numbers")
+ }
+ if less("", "apple") {
+ t.Error("an empty cell sorts before a value")
+ }
+}
+
+func TestSortReordersWhatIsShown(t *testing.T) {
+ app, cookies := signIn(t)
+ s, cells := newSheet(t, app, cookies, "Budget")
+ seedColumn(t, app, cookies, cells, []string{"30", "10", "20"})
+
+ saveViewForm(t, app, s, cookies, url.Values{"sortCol": {"0"}})
+ body := do(t, app, as(httptest.NewRequest(http.MethodGet, tablePath(s), nil), cookies)).Body.String()
+
+ order := rowOrder(body)
+ if len(order) < 3 {
+ t.Fatalf("only %d rows rendered", len(order))
+ }
+ // Rows 1, 2, 3 hold 30, 10, 20 — so ascending shows 2, 3, 1.
+ if got := strings.Join(order[:3], ","); got != "2,3,1" {
+ t.Fatalf("sorted row order = %s, want 2,3,1", got)
+ }
+}
+
+func TestSortDescending(t *testing.T) {
+ app, cookies := signIn(t)
+ s, cells := newSheet(t, app, cookies, "Budget")
+ seedColumn(t, app, cookies, cells, []string{"30", "10", "20"})
+
+ saveViewForm(t, app, s, cookies, url.Values{"sortCol": {"0"}, "sortDesc": {"1"}})
+ order := rowOrder(do(t, app, as(httptest.NewRequest(http.MethodGet, tablePath(s), nil), cookies)).Body.String())
+ if got := strings.Join(order[:3], ","); got != "1,3,2" {
+ t.Fatalf("descending row order = %s, want 1,3,2", got)
+ }
+}
+
+func TestFilterHidesRows(t *testing.T) {
+ app, cookies := signIn(t)
+ s, cells := newSheet(t, app, cookies, "Budget")
+ seedColumn(t, app, cookies, cells, []string{"alpha", "beta", "gamma"})
+
+ // "ma", not "a": every one of alpha, beta and gamma contains an
+ // "a", so filtering on it would pass whether the filter worked or
+ // not — a fixture that cannot fail.
+ saveViewForm(t, app, s, cookies, url.Values{
+ "filterCol": {"0"}, "filterOp": {"contains"}, "filterVal": {"ma"},
+ })
+ body := do(t, app, as(httptest.NewRequest(http.MethodGet, tablePath(s), nil), cookies)).Body.String()
+ if !strings.Contains(body, "gamma") {
+ t.Error("the filter hid a row that matches")
+ }
+ for _, hidden := range []string{"alpha", "beta"} {
+ if strings.Contains(body, hidden) {
+ t.Errorf("the filter kept %q, which does not match", hidden)
+ }
+ }
+}
+
+func TestNumericFilters(t *testing.T) {
+ app, cookies := signIn(t)
+ s, cells := newSheet(t, app, cookies, "Budget")
+ seedColumn(t, app, cookies, cells, []string{"5", "50", "500"})
+
+ saveViewForm(t, app, s, cookies, url.Values{
+ "filterCol": {"0"}, "filterOp": {"gt"}, "filterVal": {"40"},
+ })
+ body := do(t, app, as(httptest.NewRequest(http.MethodGet, tablePath(s), nil), cookies)).Body.String()
+ if strings.Contains(body, ">5<") {
+ t.Error("gt 40 kept the row holding 5")
+ }
+ for _, want := range []string{"50", "500"} {
+ if !strings.Contains(body, want) {
+ t.Errorf("gt 40 hid the row holding %s", want)
+ }
+ }
+}
+
+// The constraint that shaped the whole feature: reordering rows would
+// change what every formula points at, because calc's keys are
+// position-derived. So the sort must not move anything stored.
+func TestSortingDoesNotMoveStoredRowsOrBreakFormulas(t *testing.T) {
+ app, cookies := signIn(t)
+ s, cells := newSheet(t, app, cookies, "Budget")
+ seedColumn(t, app, cookies, cells, []string{"30", "10", "20"})
+ // B1 = A1 * 2 = 60.
+ postCell(t, app, cookies, cells, `{"r":0,"c":1,"raw":"=A1*2"}`)
+
+ before := decodeGrid(t, do(t, app, as(httptest.NewRequest(http.MethodGet, cells, nil), cookies)))
+ saveViewForm(t, app, s, cookies, url.Values{"sortCol": {"0"}})
+ after := decodeGrid(t, do(t, app, as(httptest.NewRequest(http.MethodGet, cells, nil), cookies)))
+
+ find := func(g gridResponse, r, c int) string {
+ for _, cell := range g.Cells {
+ if cell.R == r && cell.C == c {
+ return cell.Display
+ }
+ }
+ return ""
+ }
+ // The grid — the stored order — is untouched by a view.
+ for _, rc := range [][2]int{{0, 0}, {1, 0}, {2, 0}, {0, 1}} {
+ if b, a := find(before, rc[0], rc[1]), find(after, rc[0], rc[1]); b != a {
+ t.Errorf("sorting moved the stored cell (%d,%d): %q became %q", rc[0], rc[1], b, a)
+ }
+ }
+ if got := find(after, 0, 1); got != "60" {
+ t.Fatalf("=A1*2 now reads %q, want 60 — the formula's reference moved", got)
+ }
+}
+
+// A filter value IS a cell value (spec §2.1), so the criteria are
+// sealed like anything else somebody typed.
+func TestViewCriteriaAreSealedAtRest(t *testing.T) {
+ app, cookies := signIn(t)
+ s, _ := newSheet(t, app, cookies, "Budget")
+
+ const secret = "ConfidentialThreshold"
+ saveViewForm(t, app, s, cookies, url.Values{
+ "filterCol": {"0"}, "filterOp": {"contains"}, "filterVal": {secret},
+ })
+
+ var raw []byte
+ if err := app.db.Raw("SELECT spec_sealed FROM tab_views LIMIT 1").Row().Scan(&raw); err != nil {
+ t.Fatalf("read back: %v", err)
+ }
+ if len(raw) == 0 {
+ t.Fatal("no sealed spec was stored")
+ }
+ if bytes.Contains(raw, []byte(secret)) {
+ t.Fatal("the filter value reached the database in plaintext")
+ }
+}
+
+func TestTheViewPersistsAcrossRequests(t *testing.T) {
+ app, cookies := signIn(t)
+ s, cells := newSheet(t, app, cookies, "Budget")
+ seedColumn(t, app, cookies, cells, []string{"30", "10"})
+
+ saveViewForm(t, app, s, cookies, url.Values{"sortCol": {"0"}, "sortDesc": {"1"}})
+ got := app.loadView(s.TabID)
+ if got.SortCol != 0 || !got.SortDsc {
+ t.Fatalf("stored view = %+v, want column 0 descending", got)
+ }
+}
+
+// A viewer must not change what everybody else sees.
+func TestAViewerCannotChangeTheView(t *testing.T) {
+ app, cookies := signIn(t)
+ s, _ := newSheet(t, app, cookies, "Budget")
+ guestOn(t, app, s.ID, "ada@elsewhere.example", SheetViewer)
+ adaCookies := signInAs(t, app, "ada@elsewhere.example")
+
+ rec := do(t, app, as(postForm(fmt.Sprintf("/sheets/%d/view", s.ID),
+ url.Values{"sortCol": {"0"}}), adaCookies))
+ if rec.Code != http.StatusNotFound {
+ t.Fatalf("a viewer changed the view: %d", rec.Code)
+ }
+ if app.loadView(s.TabID).Active() {
+ t.Fatal("a viewer's submission was stored anyway")
+ }
+}
+
+func TestAnInvalidSpecLeavesTheStoredOneAlone(t *testing.T) {
+ app, cookies := signIn(t)
+ s, _ := newSheet(t, app, cookies, "Budget")
+
+ saveViewForm(t, app, s, cookies, url.Values{"sortCol": {"1"}})
+ // An unknown comparison. Losing somebody's view because they
+ // mistyped is a worse answer than ignoring the submission.
+ saveViewForm(t, app, s, cookies, url.Values{
+ "filterCol": {"0"}, "filterOp": {"regex"}, "filterVal": {"x"},
+ })
+ if got := app.loadView(s.TabID); got.SortCol != 1 {
+ t.Fatalf("an invalid submission cleared the stored view: %+v", got)
+ }
+}
+
+func TestViewValidationBounds(t *testing.T) {
+ for _, spec := range []ViewSpec{
+ {SortCol: tableCols, FilterCol: -1},
+ {SortCol: -2, FilterCol: -1},
+ {SortCol: -1, FilterCol: tableCols},
+ {SortCol: -1, FilterCol: 0, FilterOp: "regex"},
+ {SortCol: -1, FilterCol: 0, FilterOp: "contains", FilterVal: strings.Repeat("x", maxRawBytes+1)},
+ } {
+ if err := spec.Validate(); err == nil {
+ t.Errorf("Validate accepted %+v", spec)
+ }
+ }
+ if err := (ViewSpec{SortCol: 0, FilterCol: 1, FilterOp: "gt", FilterVal: "5"}).Validate(); err != nil {
+ t.Errorf("Validate refused a good spec: %v", err)
+ }
+}
+
+func TestSortAndFilterAreScriptless(t *testing.T) {
+ app, cookies := signIn(t)
+ s, _ := newSheet(t, app, cookies, "Budget")
+
+ body := withoutScript(do(t, app,
+ as(httptest.NewRequest(http.MethodGet, tablePath(s), nil), cookies)).Body.String())
+ for _, want := range []string{
+ fmt.Sprintf(`action="/sheets/%d/view"`, s.ID),
+ `name="sortCol"`, `name="filterCol"`, `name="filterOp"`, `name="filterVal"`,
+ } {
+ if !strings.Contains(body, want) {
+ t.Errorf("with scripts stripped, %q is gone", want)
+ }
+ }
+}