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.")), ) } }