291 lines
13 KiB
Go
291 lines
13 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-neutral-800", 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-neutral-500", 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-neutral-600"), Text(emp(r).Note))
|
|
},
|
|
|
|
Columns: ui.AutoTableColumnOptions{
|
|
Toggleable: true,
|
|
Draggable: true,
|
|
Resizable: true,
|
|
StorageKey: "gowasm-example-employees",
|
|
},
|
|
})
|
|
}
|
|
|
|
//gowasm:page /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 gowasm example",
|
|
ShowDate: true,
|
|
Orientation: orientation,
|
|
}
|
|
}
|
|
|
|
return func() *VNode {
|
|
return Div(Attr("class", "space-y-6"),
|
|
Div(
|
|
H2(Attr("class", "text-2xl font-semibold tracking-tight text-text-heading"), Text("AutoTable")),
|
|
P(Attr("class", "mt-1 text-neutral-500"),
|
|
Text("Filtering, sorting, pagination, expandable rows and column management — all in Go. "+
|
|
"Drag a header to reorder, drag its right edge to resize; both persist across reloads.")),
|
|
),
|
|
|
|
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("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("") }}),
|
|
),
|
|
|
|
ui.Alert(ui.AlertBlue, "What to try",
|
|
Text("Search (it matches name OR email); pick a status; select several teams. Sort by Salary — "+
|
|
"it parses the currency, so $980 sorts below $1,200.50. Unhide Rank and sort it: 'Item 2' "+
|
|
"comes before 'Item 10'. Click a row to expand it. Drag a header to reorder, drag its right "+
|
|
"edge to resize — both survive a reload. Filter the table, then export: you get every "+
|
|
"matching row, not just this page. 'Find Radia' jumps to whichever page she is on.")),
|
|
)
|
|
}
|
|
}
|