Files
kjol/go/cmd/kjol-web/app/table.go

356 lines
17 KiB
Go

package app
import (
. "kjol/vdom"
ui "kjol/webui"
)
// Employee is a row in the table demo. Salary and Bonus are both money, so a
// calculated column has two numeric columns to combine ACROSS a row.
type Employee struct {
Name string
Email string
Team string
Status string
Salary string
Bonus string
Rank string
Note string
}
func employees() []any {
rows := []Employee{
{"Ada Lovelace", "ada@example.com", "Engineering", "active", "$1,200.50", "$150.00", "Item 2", "Wrote the first algorithm."},
{"Alan Turing", "alan@example.com", "Research", "active", "$1,500.00", "$300.00", "Item 10", "Decidability, and the machine."},
{"Grace Hopper", "grace@example.com", "Engineering", "inactive", "$980.00", "$0.00", "Item 1", "Found the first bug. Literally."},
{"Katherine Johnson", "katherine@example.com", "Research", "active", "$1,340.00", "$220.00", "Item 3", "Orbital mechanics, by hand."},
{"Margaret Hamilton", "margaret@example.com", "Engineering", "active", "$1,610.25", "$400.00", "Item 21", "Coined 'software engineering'."},
{"Barbara Liskov", "barbara@example.com", "Research", "inactive", "$1,120.00", "$90.00", "Item 7", "The substitution principle."},
{"Radia Perlman", "radia@example.com", "Networking", "active", "$1,275.75", "$180.00", "Item 12", "Spanning tree protocol."},
{"Karen Sparck Jones", "karen@example.com", "Research", "active", "$1,050.00", "$60.00", "Item 5", "Inverse document frequency."},
{"Frances Allen", "frances@example.com", "Engineering", "inactive", "$1,400.00", "$250.00", "Item 9", "Optimizing compilers."},
{"Jean Bartik", "jean@example.com", "Engineering", "active", "$860.00", "$40.00", "Item 4", "Programmed the ENIAC."},
{"Evelyn Boyd Granville", "evelyn@example.com", "Research", "active", "$1,180.00", "$130.00", "Item 15", "Trajectory analysis."},
{"Annie Easley", "annie@example.com", "Networking", "inactive", "$990.00", "$75.00", "Item 6", "Rocket propulsion code."},
}
out := make([]any, len(rows))
for i, r := range rows {
out[i] = r
}
return out
}
func emp(row any) Employee { return row.(Employee) }
func tableColumns() []ui.AutoTableColumn {
return []ui.AutoTableColumn{
{
Key: "name", DisplayName: "Name", Sortable: true, SortIdentifier: "Name",
CSV: true, CSVValue: func(r any) string { return emp(r).Name },
// No Toggleable: the name is what identifies a row, so it cannot be hidden.
Cell: func(r any) *VNode { return ui.AutoTableTdLeft("text-ink", Text(emp(r).Name)) },
},
{
Key: "email", DisplayName: "Email", Sortable: true, SortIdentifier: "Email",
Toggleable: true, CSV: true, CSVValue: func(r any) string { return emp(r).Email },
Cell: func(r any) *VNode { return ui.AutoTableTdLeft("text-ink-muted", Text(emp(r).Email)) },
},
{
Key: "team", DisplayName: "Team", Sortable: true, SortIdentifier: "Team",
Toggleable: true, CSV: true, CSVValue: func(r any) string { return emp(r).Team },
Cell: func(r any) *VNode { return ui.AutoTableTdLeft("", Text(emp(r).Team)) },
},
{
Key: "status", DisplayName: "Status", Sortable: true, SortIdentifier: "Status",
Toggleable: true, CSV: true, CSVValue: func(r any) string { return emp(r).Status },
Cell: func(r any) *VNode {
color := ui.BadgeGreen
if emp(r).Status != "active" {
color = ui.BadgeNeutral
}
return ui.AutoTableTdLeft("", ui.Badge(ui.BadgeProps{Color: color}, Text(emp(r).Status)))
},
},
{
// SortTypeMoney parses "$1,200.50" as a number — a plain string sort would
// put $1,200.50 before $980.00.
Key: "salary", DisplayName: "Salary", DisplayPosition: ui.COL_POS_RIGHT,
Sortable: true, SortIdentifier: "Salary", SortType: ui.SortTypeMoney,
Toggleable: true, CSV: true, CSVValue: func(r any) string { return emp(r).Salary },
Cell: func(r any) *VNode { return ui.AutoTableTdRight("tabular-nums", Text(emp(r).Salary)) },
},
{
Key: "bonus", DisplayName: "Bonus", DisplayPosition: ui.COL_POS_RIGHT,
Sortable: true, SortIdentifier: "Bonus", SortType: ui.SortTypeMoney,
Toggleable: true, CSV: true, CSVValue: func(r any) string { return emp(r).Bonus },
Cell: func(r any) *VNode { return ui.AutoTableTdRight("tabular-nums", Text(emp(r).Bonus)) },
},
{
// SortTypeNumeric sorts "Item 2" before "Item 10".
Key: "rank", DisplayName: "Rank", Sortable: true, SortIdentifier: "Rank",
SortType: ui.SortTypeNumeric, Toggleable: true, HiddenByDefault: true,
CSV: true, CSVValue: func(r any) string { return emp(r).Rank },
Cell: func(r any) *VNode { return ui.AutoTableTdLeft("", Text(emp(r).Rank)) },
},
}
}
// newEmployeeTable builds the table controller.
//
// It is factored out of TablePage so a test can drive the very same table the page
// renders — the export test checks the bytes this exact configuration produces,
// rather than a second copy of it that could drift.
//
// The controller owns the search, sort, page, expansion and column state. Build it
// ONCE, never inside a render closure: rebuilding it per frame would reset every
// filter on each keystroke.
func newEmployeeTable(highlight *Signal[string]) *ui.AutoTableState {
return ui.NewAutoTableState(tableColumns(), ui.AutoTableStateOptions{
PerPage: 5,
// The table PAGES ITSELF to wherever the highlighted row landed after
// filtering and sorting.
HighlightMatch: func(r any) bool {
return highlight.Get() != "" && emp(r).Email == highlight.Get()
},
// Calculated columns come in two shapes, and the difference is the thing to
// understand:
//
// BASIC — a function over OPERAND COLUMNS, combined ACROSS each row.
// Sum over [Salary, Bonus] is this row's salary + bonus. It does
// NOT total the column. Operands are column KEYS (SortIdentifier),
// and subtract/divide are binary and ORDERED.
//
// ADVANCED — an Excel-style formula, which names columns by DISPLAY name:
// [Salary] is this row's cell, {Salary} is the whole column, and
// {Salary:1:ROW()} is everything up to this row — a running total.
//
// Either way they are evaluated against the FILTERED, SORTED rows, so filtering
// re-runs them. (ToCalcNumber parses "$1,200.50" for you.)
Calculated: []ui.UserCalculatedColumn{
{
// Basic: two columns, added together, per row.
ID: "comp", DisplayName: "Total comp", Fn: ui.CALC_FN_SUM,
Operands: []string{"Salary", "Bonus"},
DataType: ui.CALC_TYPE_MONEY, DisplayPosition: ui.COL_POS_RIGHT,
},
{
// Advanced: a formula.
ID: "annual", DisplayName: "Annual", Fn: ui.CALC_FN_CUSTOM,
Formula: "([Salary] + [Bonus]) * 12", DataType: ui.CALC_TYPE_MONEY,
DisplayPosition: ui.COL_POS_RIGHT,
},
{
// Advanced, and position-dependent: a running total down the page.
ID: "running", DisplayName: "Running total", Fn: ui.CALC_FN_CUSTOM,
Formula: "SUM({Salary:1:ROW()})", DataType: ui.CALC_TYPE_MONEY,
DisplayPosition: ui.COL_POS_RIGHT,
},
},
// A summary row goes the OTHER way: one column, aggregated DOWN the whole
// filtered set — not just the page on screen. Basic mode does that with a
// function + one operand; this one uses a formula for the same thing.
SummaryRows: []ui.UserSummaryRow{
{ID: "total", Label: "Total salary", Fn: ui.CALC_FN_SUM,
Operands: []string{"Salary"}, DataType: ui.CALC_TYPE_MONEY},
{ID: "avg", Label: "Average salary", Fn: ui.CALC_FN_CUSTOM,
Formula: "AVERAGE({Salary})", DataType: ui.CALC_TYPE_MONEY},
},
Accordion: true,
RowKey: func(r any) string { return emp(r).Email },
AccordionContent: func(r any) *VNode {
return P(Attr("class", "px-4 py-2 text-sm text-ink-soft"), Text(emp(r).Note))
},
Columns: ui.AutoTableColumnOptions{
Toggleable: true,
Draggable: true,
Resizable: true,
StorageKey: "gowasm-example-employees",
},
})
}
//gowasm:page /wasm/table layout=app static
func TablePage(d Deps) func() *VNode {
// Which row to spotlight, if any.
highlight := NewSignal("")
table := newEmployeeTable(highlight)
table.SetRows(employees())
// The export menu, with a submenu for the PDF's page orientation. Both are
// controllers, both built once. A submenu is Standalone — opening it must not
// close the menu it lives in.
exportMenu := ui.NewMenu(ui.MenuOptions{Placement: ui.PlacementBottomEnd})
pdfSub := ui.NewSubmenu(exportMenu)
// What the PDF prints above the table.
//
// Note what is NOT here: the footer lines. The export takes the table's OWN
// summary rows — including any the user builds at runtime in the Calculated
// editor — and evaluates them against the same filtered rows it is printing. Only
// pass Summaries explicitly to print something that is not one of the table's own
// rows.
pdfHeader := func(landscape bool) ui.AutoTablePDFHeader {
orientation := ui.PDF_ORIENTATION_PORTRAIT
if landscape {
orientation = ui.PDF_ORIENTATION_LANDSCAPE
}
return ui.AutoTablePDFHeader{
Title: "Employees",
Subtitle: "Exported from the Kjol Web example",
ShowDate: true,
Orientation: orientation,
}
}
return func() *VNode {
return docPage("Components", "AutoTable",
"A table that filters, sorts, pages, reorders, resizes, computes and exports — configured with "+
"a column list and a slice of rows. Everything a user changes about it is theirs and persists; "+
"everything it exports is what they filtered, not what happened to be on screen.",
docSection("defining", "Defining one",
prose("A column says how to read a field, how to sort it, and how to render it. The state object "+
"is a CONTROLLER: build it once, alongside your signals — never inside the render, which "+
"would hand it fresh refs and a fresh idea of which page it was on every frame."),
code("app/table.go", tableSnippet),
note("The server renders a skeleton, on purpose",
"The layout — column order, widths, what is hidden, the calculated columns — lives in the "+
"browser's localStorage, which the server cannot read. So the server ships a skeleton "+
"rather than the DEFAULT table: a user who had reordered their columns would otherwise "+
"watch them rearrange themselves the moment the WebAssembly booted."),
),
docSection("try-it", "Try it",
prose("Search matches name or email. Sort by Salary and it parses the currency, so $980 sorts "+
"below $1,200.50. Unhide Rank and sort that: \"Item 2\" comes before \"Item 10\", because "+
"numbers inside text are compared as numbers. Drag a header to reorder it, drag its right "+
"edge to resize — reload the page and both are still where you left them."),
prose("Filter it, then export. You get every matching row across every page, in the column order "+
"you dragged them into, with the calculated columns computed per row."),
),
table.Render(
ui.AutoTableWithHover(),
ui.AutoTableWithAlternate(),
ui.AutoTableWithSurroundingBorder(),
ui.AutoTableWithPaginationShowAll(),
ui.AutoTableWithSearchFields(
// One box, several fields: a global search.
table.GlobalSearch("Search name or email…", "Name", "Email"),
// Exact-match dropdown.
table.SelectSearch("Status", []string{"active", "inactive"}, "Any status"),
// IN-set: matches any of the selected teams.
table.MultiSelectSearch("Team", "Any team", []string{"Engineering", "Research", "Networking"}),
),
ui.AutoTableWithToolbarActions(
table.ColumnPicker(),
// Build calculated columns and footer rows at runtime. Basic picks a
// function and the columns it combines across each row; Advanced writes
// a formula, with insert menus for columns, functions and constants.
// The formula is compiled and previewed against the real first row as
// you type, so a typo shows up immediately rather than as a column of
// dashes. What you build is persisted with the rest of the layout.
table.CalculatedColumnEditor(),
// Export writes what the FILTER selected — every matching row across
// every page — not the five rows on screen. And it writes the columns
// you can actually see, in the order you dragged them into.
exportMenu.TriggerFunc(ui.MenuTriggerProps{Tag: "div"}, func(open bool) *VNode {
return ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Small: true,
Icon: "download", Text: "Export"})
}),
exportMenu.Content("",
exportMenu.Item(ui.MenuItemProps{Icon: "file-csv",
OnClick: func() { table.DownloadCSV("employees") }}, Text("Download CSV")),
// A submenu — portaled, so it is not clipped by the menu's own
// overflow-y-auto, which is what broke it before.
pdfSub.Submenu(ui.SubmenuProps{Trigger: "Download PDF", Icon: "file-pdf"},
pdfSub.Item(ui.MenuItemProps{
OnClick: func() { table.DownloadPDF("employees", pdfHeader(false)) }}, Text("Portrait")),
pdfSub.Item(ui.MenuItemProps{
OnClick: func() { table.DownloadPDF("employees", pdfHeader(true)) }}, Text("Landscape")),
),
ui.MenuDivider(""),
exportMenu.Item(ui.MenuItemProps{Icon: "print",
OnClick: func() { table.PrintPDF(pdfHeader(true)) }}, Text("Print")),
),
),
),
// Highlight + auto-page-jump: Radia is on page 3 by default, and the table
// pages itself to wherever she actually is once filters and sorting move her.
row("mt-4 flex flex-wrap items-center gap-2",
ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Small: true,
Text: "Find Radia Perlman",
OnClick: func() { highlight.Set("radia@example.com") }}),
ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Small: true,
Text: "Clear highlight",
OnClick: func() { highlight.Set("") }}),
),
docSection("calculated", "Calculated columns",
prose("The toolbar's calculator builds new columns at runtime, in two modes. Basic picks a "+
"function and the columns it combines ACROSS each row — sum of Salary and Bonus, per "+
"person. Advanced writes a formula, with insert menus for columns, functions and constants: "+
"([Salary] + [Bonus]) * 12."),
prose("A summary row is the other axis: it aggregates ONE column DOWN the filtered rows and "+
"prints the result in the footer. Confusing the two is the classic bug here — a column that "+
"aggregates down shows every row the same number, and it looks plausible enough to ship."),
codeLang("formulas", "syntax", formulaSnippet),
note("Compiled as you type",
"The formula is parsed and evaluated against the real first row while you write it, so a "+
"typo shows up as an error under the box — not as a column of dashes discovered later."),
),
docSection("export", "Export",
prose("CSV and PDF are written in Go, standard library only — the PDF writer builds its own "+
"xref table and embeds Helvetica metrics. Export takes the FILTERED rows, the VISIBLE "+
"columns, in the user's order, including whatever they calculated."),
apiTable(
apiRow{"NewAutoTableState", "Build the controller: the columns, and where to persist the layout."},
apiRow{".SetRows", "Hand it the data. It filters, sorts and pages from there."},
apiRow{".RestoreLayout", "Read the saved layout and reveal the table over its skeleton. Call it once, on the client."},
apiRow{".FilteredRows / .ExportColumns", "What the user selected, and what they can see — the inputs to any export."},
apiRow{"ExportCSV / ExportPDF", "Write the bytes. DownloadCSV / DownloadPDF / PrintPDF do it and hand them to the browser."},
),
),
)
}
}
const tableSnippet = `// A controller: built ONCE, next to your signals — never inside the render.
table := ui.NewAutoTableState([]ui.AutoTableColumn{
{DisplayName: "Name", SortIdentifier: "Name", Sortable: true,
Cell: func(r any) *VNode { return Text(r.(Employee).Name) }},
{DisplayName: "Salary", SortIdentifier: "Salary", Sortable: true,
SortType: ui.SortTypeNumeric, // parses the currency: $980 < $1,200.50
Cell: func(r any) *VNode { return Text(money(r.(Employee).Salary)) }},
{DisplayName: "Rank", HiddenByDefault: true},
}, ui.AutoTableStateOptions{
PerPage: 5,
Columns: ui.AutoTableColumnOptions{
StorageKey: "employees", // order, widths, visibility — the user's, and persisted
},
})
table.SetRows(employees())`
const formulaSnippet = `A COLUMN combines operands ACROSS one row:
sum[Salary, Bonus] -> 1200.50 + 150.00 = 1350.50 (per person)
([Salary] + [Bonus]) * 12 -> the annualised figure
SUM({Salary:1:ROW()}) -> a running total, down the rows
A SUMMARY ROW aggregates ONE column DOWN the filtered rows:
avg[Salary] -> one number, printed in the footer`