6277 lines
204 KiB
Go
6277 lines
204 KiB
Go
// Port of web/uikit/AutoTable.tsx — the whole of it, in one file, as the original
|
||
// was. The banners below divide it into sections:
|
||
//
|
||
// - Render shell the column model, and the header/body/pagination markup
|
||
// - Data pipeline filter, sort, paginate (pure Go — no browser needed)
|
||
// - AutoTableState the controller: search, sort, page, expansion
|
||
// - Column management show-hide, drag-to-reorder, drag-to-resize, persistence
|
||
// - Calculated columns and summary rows, wired to the formula engine
|
||
// - Formula engine tokenizer, parser, evaluator (pure Go)
|
||
// - Export CSV, PDF, print (the PDF writer itself is in pdf.go)
|
||
//
|
||
// Most callers want AutoTableState, which owns the state and calls AutoTable for
|
||
// them; AutoTable itself stays usable directly when the caller already owns the
|
||
// filtering and paging.
|
||
//
|
||
// Anything that measures the page — resizing a column, dragging one, remembering
|
||
// either — reaches the browser through the host API in kjol/wasmruntime, which is
|
||
// dual-build (real in the browser, no-op stubs natively). That is what lets this
|
||
// file server-render: on the server every measurement is the zero Rect, no listener
|
||
// is installed, and the table renders in its declared column order.
|
||
//
|
||
// Two things in the original TSX are deliberately NOT reproduced:
|
||
//
|
||
// - Virtual scrolling. Neither the TSX nor this has it — pagination IS the
|
||
// windowing strategy. (An earlier note here claimed it was a missing feature;
|
||
// it never was one.)
|
||
// - JS-driven header pinning. The TSX has none either; the sticky thead below is
|
||
// an addition.
|
||
package webui
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/csv"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"html"
|
||
"math"
|
||
"net/url"
|
||
"reflect"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"kjol/vdom"
|
||
"kjol/wasmruntime"
|
||
)
|
||
|
||
// ColumnPosition is a cell/header alignment (mirrors the TSX 0|1|2 union).
|
||
type ColumnPosition int
|
||
|
||
const (
|
||
COL_POS_LEFT ColumnPosition = 0
|
||
COL_POS_RIGHT ColumnPosition = 1
|
||
COL_POS_CENTER ColumnPosition = 2
|
||
)
|
||
|
||
// AutoTableHeaderColor selects the header/body color scheme.
|
||
type AutoTableHeaderColor int
|
||
|
||
const (
|
||
AUTOTABLE_HEADER_COLOR_DEFAULT AutoTableHeaderColor = 0
|
||
AUTOTABLE_HEADER_COLOR_BLUE AutoTableHeaderColor = 1
|
||
AUTOTABLE_HEADER_COLOR_GREEN AutoTableHeaderColor = 2
|
||
AUTOTABLE_HEADER_COLOR_GRAY AutoTableHeaderColor = 3
|
||
AUTOTABLE_HEADER_COLOR_DARK_BLUE AutoTableHeaderColor = 4
|
||
)
|
||
|
||
// AutoTableSize selects header/body/pagination density.
|
||
type AutoTableSize int
|
||
|
||
const (
|
||
AUTOTABLE_SIZE_DEFAULT AutoTableSize = 0
|
||
AUTOTABLE_SIZE_COMPACT AutoTableSize = 1
|
||
AUTOTABLE_SIZE_SUPERCOMPACT AutoTableSize = 2
|
||
)
|
||
|
||
// -- Tailwind class maps (copied verbatim from AutoTable.tsx) ---------------
|
||
|
||
// HEADER_COLOR_CLS is the background + text color per header color.
|
||
var HEADER_COLOR_CLS = map[AutoTableHeaderColor]string{
|
||
AUTOTABLE_HEADER_COLOR_DEFAULT: "bg-neutral-50",
|
||
AUTOTABLE_HEADER_COLOR_BLUE: "bg-sky-700 text-white",
|
||
AUTOTABLE_HEADER_COLOR_GREEN: "bg-green-700 text-white",
|
||
AUTOTABLE_HEADER_COLOR_GRAY: "bg-neutral-600 text-white",
|
||
AUTOTABLE_HEADER_COLOR_DARK_BLUE: "bg-sky-900 text-white",
|
||
}
|
||
|
||
// atHeaderSortHoverCls is the sortable-hover override per color.
|
||
var atHeaderSortHoverCls = map[AutoTableHeaderColor]string{
|
||
AUTOTABLE_HEADER_COLOR_DEFAULT: "hover:bg-neutral-300",
|
||
AUTOTABLE_HEADER_COLOR_BLUE: "hover:bg-sky-500",
|
||
AUTOTABLE_HEADER_COLOR_GREEN: "hover:bg-green-800",
|
||
AUTOTABLE_HEADER_COLOR_GRAY: "hover:bg-neutral-500",
|
||
AUTOTABLE_HEADER_COLOR_DARK_BLUE: "hover:bg-sky-800",
|
||
}
|
||
|
||
// HEADER_TEXT_CLS is the header text weight/case per color.
|
||
var HEADER_TEXT_CLS = map[AutoTableHeaderColor]string{
|
||
AUTOTABLE_HEADER_COLOR_DEFAULT: "font-bold uppercase tracking-wider",
|
||
AUTOTABLE_HEADER_COLOR_BLUE: "font-semibold",
|
||
AUTOTABLE_HEADER_COLOR_GREEN: "font-semibold",
|
||
AUTOTABLE_HEADER_COLOR_GRAY: "font-semibold",
|
||
AUTOTABLE_HEADER_COLOR_DARK_BLUE: "font-semibold",
|
||
}
|
||
|
||
// atHeaderSortIconCls is the sort-icon color (matches header text) per color.
|
||
var atHeaderSortIconCls = map[AutoTableHeaderColor]string{
|
||
AUTOTABLE_HEADER_COLOR_DEFAULT: "text-black",
|
||
AUTOTABLE_HEADER_COLOR_BLUE: "text-white",
|
||
AUTOTABLE_HEADER_COLOR_GREEN: "text-white",
|
||
AUTOTABLE_HEADER_COLOR_GRAY: "text-white",
|
||
AUTOTABLE_HEADER_COLOR_DARK_BLUE: "text-white",
|
||
}
|
||
|
||
// HEADER_PADDING_CLS is the th padding per table size.
|
||
var HEADER_PADDING_CLS = map[AutoTableSize]string{
|
||
AUTOTABLE_SIZE_DEFAULT: "p-4 text-sm",
|
||
AUTOTABLE_SIZE_COMPACT: "py-1.5 px-2 text-sm",
|
||
AUTOTABLE_SIZE_SUPERCOMPACT: "py-1 px-2 text-xs",
|
||
}
|
||
|
||
// BODY_PADDING_CLS is the body cell padding per table size (applied via [&_td]:).
|
||
var BODY_PADDING_CLS = map[AutoTableSize]string{
|
||
AUTOTABLE_SIZE_DEFAULT: "text-sm [&_td]:p-4",
|
||
AUTOTABLE_SIZE_COMPACT: "text-sm [&_td]:py-1 [&_td]:px-2",
|
||
AUTOTABLE_SIZE_SUPERCOMPACT: "text-xs [&_td]:py-0.5 [&_td]:px-2",
|
||
}
|
||
|
||
// atPaginationPaddingCls is the pagination bar padding per table size.
|
||
var atPaginationPaddingCls = map[AutoTableSize]string{
|
||
AUTOTABLE_SIZE_DEFAULT: "py-3 px-4",
|
||
AUTOTABLE_SIZE_COMPACT: "py-1 px-4",
|
||
AUTOTABLE_SIZE_SUPERCOMPACT: "py-1 px-4",
|
||
}
|
||
|
||
// atRowHoverCls is the body-row hover background per color (when hover is on).
|
||
var atRowHoverCls = map[AutoTableHeaderColor]string{
|
||
AUTOTABLE_HEADER_COLOR_DEFAULT: "hover:bg-neutral-200",
|
||
AUTOTABLE_HEADER_COLOR_BLUE: "hover:bg-sky-100",
|
||
AUTOTABLE_HEADER_COLOR_GREEN: "hover:bg-green-100",
|
||
AUTOTABLE_HEADER_COLOR_GRAY: "hover:bg-neutral-200",
|
||
AUTOTABLE_HEADER_COLOR_DARK_BLUE: "hover:bg-sky-100",
|
||
}
|
||
|
||
// POS_CLS is the text alignment per ColumnPosition.
|
||
var POS_CLS = map[ColumnPosition]string{
|
||
COL_POS_LEFT: "text-left",
|
||
COL_POS_RIGHT: "text-right",
|
||
COL_POS_CENTER: "text-center",
|
||
}
|
||
|
||
// HEADER_INNER_POS is the header inner flex direction per position.
|
||
var HEADER_INNER_POS = map[ColumnPosition]string{
|
||
COL_POS_LEFT: "",
|
||
COL_POS_RIGHT: "flex-row-reverse",
|
||
COL_POS_CENTER: "justify-center",
|
||
}
|
||
|
||
// -- Class string constants for parts that don't vary by config ------------
|
||
const (
|
||
TBL_CONTAINER = "relative flex flex-col w-full h-full bg-white rounded-default overflow-hidden"
|
||
TBL_WRAPPER = "overflow-x-auto w-full"
|
||
TBL_BASE = "min-w-full"
|
||
|
||
HEADER_CONTENT = "transition-transform duration-150 ease-in-out"
|
||
HEADER_INNER_BASE = "flex justify-between gap-2 items-center"
|
||
|
||
atSortIconWrap = "leading-none shrink-0 opacity-50"
|
||
|
||
atSkeleton = "h-4 bg-neutral-200 rounded-default animate-pulse"
|
||
atErrorCell = "text-center text-red-600"
|
||
atEmptyCell = "text-center text-neutral-500"
|
||
|
||
atPaginationBase = "flex justify-between items-center border-t border-neutral-300"
|
||
atPaginationInfo = "hidden sm:flex items-center text-sm text-neutral-500"
|
||
atPaginationControls = "flex items-center"
|
||
atPaginationLabel = "hidden sm:block text-sm text-neutral-500 mr-2"
|
||
atPaginationPage = "text-sm text-neutral-500 px-3"
|
||
atPaginationBtn = "p-1 min-h-9 text-sm font-normal leading-none bg-transparent border-0 cursor-pointer hover:bg-neutral-100 disabled:text-neutral-300 disabled:cursor-not-allowed disabled:hover:bg-transparent"
|
||
|
||
// thead sticky classes are the STATIC replacement for the TSX's JS-driven
|
||
// header pinning (transform tracking on scroll). See file-level NOTE.
|
||
atTheadCls = "sticky top-0 z-10 [&_th]:border-b [&_th]:border-neutral-300"
|
||
)
|
||
|
||
// AutoTableColumn describes one column: its header, alignment, width, whether it
|
||
// sorts (and how), whether it exports, and how to render its cell.
|
||
type AutoTableColumn struct {
|
||
DisplayName string
|
||
DisplayPosition ColumnPosition
|
||
WidthClass string // Tailwind width, e.g. "w-32". A drag-resized column overrides it with an inline px width.
|
||
HeaderClasses string
|
||
Cell func(row any) *vdom.VNode // returns the whole <td>; nil renders an empty aligned cell
|
||
|
||
// CellAt is Cell with the row's index in the full filtered result set (not the
|
||
// page). Use it when the cell depends on where the row sits — a running total, a
|
||
// rank, anything a calculated column's ROW() can reach. Takes precedence over Cell.
|
||
CellAt func(row any, rowIndex int) *vdom.VNode
|
||
|
||
// Sorting. SortIdentifier names the field to sort on (use PositionalIdentifier(i)
|
||
// for a column with no natural field name). SortType picks the comparison —
|
||
// SortTypeNumeric makes "Item 2" sort before "Item 10"; SortTypeMoney parses
|
||
// "$1,234.50". SortValue overrides the whole lookup when the sort key is not a
|
||
// field at all (a computed total, a status rank).
|
||
Sortable bool
|
||
SortIdentifier string
|
||
SortType string
|
||
SortValue func(row any) any
|
||
|
||
// Export. CSV includes the column in exports; CSVValue produces its text (the
|
||
// rendered cell is a VNode, so exports cannot reuse it). A column with CSV set
|
||
// and no CSVValue falls back to the field named by SortIdentifier.
|
||
//
|
||
// CSVValueAt is CSVValue with the row's index in the exported set, for a value
|
||
// that depends on position (a calculated column's running total). It takes
|
||
// precedence over CSVValue.
|
||
CSV bool
|
||
CSVValue func(row any) string
|
||
CSVValueAt func(row any, rowIndex int) string
|
||
|
||
// Column visibility. Toggleable columns can be hidden by the user; a column with
|
||
// Toggleable false is pinned on. HiddenByDefault starts hidden.
|
||
Toggleable bool
|
||
HiddenByDefault bool
|
||
|
||
// Key identifies the column across reorder/resize/visibility persistence. It
|
||
// must be stable across renders — if empty, the column's index is used, which
|
||
// breaks the moment columns are reordered. Set it whenever those features are on.
|
||
Key string
|
||
}
|
||
|
||
// AutoTableOrderBy is the active sort (mirrors the TSX interface).
|
||
type AutoTableOrderBy struct {
|
||
Identifier string
|
||
Descending bool
|
||
}
|
||
|
||
// AutoTablePagination is the display-only pagination state (mirrors the TSX
|
||
// interface; only the display fields are used by this core).
|
||
type AutoTablePagination struct {
|
||
CurrentPage int
|
||
TotalPages int
|
||
TotalItems int
|
||
MaxItemsPerPage int
|
||
ViewRangeLower int
|
||
ViewRangeUpper int
|
||
}
|
||
|
||
// atConfig holds resolved AutoTable options. Defaults mirror the TSX opts memo.
|
||
type atConfig struct {
|
||
size AutoTableSize
|
||
color AutoTableHeaderColor
|
||
shadow bool
|
||
hover bool
|
||
alternate bool
|
||
headerBorderY bool
|
||
surroundingBorder bool
|
||
borderX bool
|
||
borderY bool
|
||
tableLayoutAuto bool
|
||
hidePagination bool
|
||
|
||
loading bool
|
||
errorMsg string
|
||
emptyMessage string
|
||
|
||
sortIdentifier string
|
||
sortDescending bool
|
||
onSort func(identifier string)
|
||
|
||
pagination *AutoTablePagination
|
||
onPageChange func(page int)
|
||
onItemsPerPage func(n int)
|
||
paginationShowAll bool
|
||
|
||
// Expandable rows. rowKey must be stable for a row across renders — it is what
|
||
// remembers which rows are open.
|
||
accordion bool
|
||
rowKey func(row any, idx int) string
|
||
isExpanded func(key string) bool
|
||
onToggleExpand func(key string)
|
||
accordionContent func(row any) *vdom.VNode
|
||
|
||
// highlight flags a row (a record the user came here to find).
|
||
highlight func(row any) bool
|
||
|
||
// columns carries drag/resize/width behavior, or nil when it is off.
|
||
columns *atColumnHooks
|
||
|
||
// rowOffset is the index of the page's first row within the full filtered set,
|
||
// so a CellAt sees the row's true position rather than its position on screen.
|
||
rowOffset int
|
||
// foot is the <tfoot> (summary rows), or nil.
|
||
foot *vdom.VNode
|
||
// reset is the Reset menu, shown at the bottom-left of the pagination bar.
|
||
reset *vdom.VNode
|
||
|
||
// Chrome around the table.
|
||
searchFields []*vdom.VNode
|
||
toolbarActions []*vdom.VNode
|
||
filtersToggle *vdom.VNode
|
||
filtersOpen bool
|
||
searchAside bool
|
||
above, below *vdom.VNode
|
||
|
||
class string
|
||
}
|
||
|
||
// AutoTableOption configures AutoTable (functional-options for the variadic opts).
|
||
type AutoTableOption func(*atConfig)
|
||
|
||
// AutoTableWithSize sets the density (default / compact / supercompact).
|
||
func AutoTableWithSize(s AutoTableSize) AutoTableOption {
|
||
return func(c *atConfig) { c.size = s }
|
||
}
|
||
|
||
// AutoTableWithColor sets the header/body color scheme.
|
||
func AutoTableWithColor(color AutoTableHeaderColor) AutoTableOption {
|
||
return func(c *atConfig) { c.color = color }
|
||
}
|
||
|
||
// AutoTableWithHover enables per-row hover highlighting.
|
||
func AutoTableWithHover() AutoTableOption { return func(c *atConfig) { c.hover = true } }
|
||
|
||
// AutoTableWithAlternate enables zebra striping on odd rows.
|
||
func AutoTableWithAlternate() AutoTableOption { return func(c *atConfig) { c.alternate = true } }
|
||
|
||
// AutoTableWithShadow adds a drop shadow to the table container.
|
||
func AutoTableWithShadow() AutoTableOption { return func(c *atConfig) { c.shadow = true } }
|
||
|
||
// AutoTableWithSurroundingBorder draws a border around the table container.
|
||
func AutoTableWithSurroundingBorder() AutoTableOption {
|
||
return func(c *atConfig) { c.surroundingBorder = true }
|
||
}
|
||
|
||
// AutoTableWithHeaderBorderY adds vertical dividers between header cells.
|
||
func AutoTableWithHeaderBorderY() AutoTableOption {
|
||
return func(c *atConfig) { c.headerBorderY = true }
|
||
}
|
||
|
||
// AutoTableWithBorderX draws horizontal dividers between body rows.
|
||
func AutoTableWithBorderX() AutoTableOption { return func(c *atConfig) { c.borderX = true } }
|
||
|
||
// AutoTableWithBorderY draws vertical dividers between body cells.
|
||
func AutoTableWithBorderY() AutoTableOption { return func(c *atConfig) { c.borderY = true } }
|
||
|
||
// AutoTableWithTableLayoutAuto uses auto table layout instead of table-fixed.
|
||
func AutoTableWithTableLayoutAuto() AutoTableOption {
|
||
return func(c *atConfig) { c.tableLayoutAuto = true }
|
||
}
|
||
|
||
// AutoTableWithLoading renders skeleton placeholder rows instead of data.
|
||
func AutoTableWithLoading(loading bool) AutoTableOption {
|
||
return func(c *atConfig) { c.loading = loading }
|
||
}
|
||
|
||
// AutoTableWithError renders a single error row with the given message.
|
||
func AutoTableWithError(msg string) AutoTableOption {
|
||
return func(c *atConfig) { c.errorMsg = msg }
|
||
}
|
||
|
||
// AutoTableWithEmptyMessage overrides the "No entries found." empty-state text.
|
||
func AutoTableWithEmptyMessage(msg string) AutoTableOption {
|
||
return func(c *atConfig) { c.emptyMessage = msg }
|
||
}
|
||
|
||
// AutoTableWithSort surfaces the active sort as a plain value + callback. onSort
|
||
// is invoked with a sortable column's SortIdentifier when its header is clicked;
|
||
// the caller owns the actual re-sorting (the TSX's local sort is out of scope).
|
||
func AutoTableWithSort(identifier string, descending bool, onSort func(identifier string)) AutoTableOption {
|
||
return func(c *atConfig) {
|
||
c.sortIdentifier = identifier
|
||
c.sortDescending = descending
|
||
c.onSort = onSort
|
||
}
|
||
}
|
||
|
||
// AutoTableWithPagination surfaces display-only pagination state + callbacks. The
|
||
// caller owns the actual paging/query computation.
|
||
func AutoTableWithPagination(p *AutoTablePagination, onPageChange func(page int), onItemsPerPage func(n int)) AutoTableOption {
|
||
return func(c *atConfig) {
|
||
c.pagination = p
|
||
c.onPageChange = onPageChange
|
||
c.onItemsPerPage = onItemsPerPage
|
||
}
|
||
}
|
||
|
||
// AutoTableWithHidePagination hides the pagination bar.
|
||
func AutoTableWithHidePagination() AutoTableOption {
|
||
return func(c *atConfig) { c.hidePagination = true }
|
||
}
|
||
|
||
// AutoTableWithClass appends classes to the outermost wrapper.
|
||
func AutoTableWithClass(class string) AutoTableOption {
|
||
return func(c *atConfig) { c.class = class }
|
||
}
|
||
|
||
// AutoTableWithPaginationShowAll adds an "All" entry to the page-size picker.
|
||
func AutoTableWithPaginationShowAll() AutoTableOption {
|
||
return func(c *atConfig) { c.paginationShowAll = true }
|
||
}
|
||
|
||
// AutoTableWithAccordion makes rows expandable: an extra toggle column appears,
|
||
// and clicking a row reveals content(row) in a full-width row beneath it.
|
||
func AutoTableWithAccordion(
|
||
rowKey func(row any, idx int) string,
|
||
isExpanded func(key string) bool,
|
||
onToggle func(key string),
|
||
content func(row any) *vdom.VNode,
|
||
) AutoTableOption {
|
||
return func(c *atConfig) {
|
||
c.accordion = true
|
||
c.rowKey = rowKey
|
||
c.isExpanded = isExpanded
|
||
c.onToggleExpand = onToggle
|
||
c.accordionContent = content
|
||
}
|
||
}
|
||
|
||
// AutoTableWithHighlight flags matching rows.
|
||
func AutoTableWithHighlight(match func(row any) bool) AutoTableOption {
|
||
return func(c *atConfig) { c.highlight = match }
|
||
}
|
||
|
||
// AutoTableWithSearchFields puts filter controls in the toolbar above the table.
|
||
func AutoTableWithSearchFields(fields ...*vdom.VNode) AutoTableOption {
|
||
return func(c *atConfig) { c.searchFields = fields }
|
||
}
|
||
|
||
// AutoTableWithToolbarActions puts buttons (export, add, …) on the toolbar's right.
|
||
func AutoTableWithToolbarActions(actions ...*vdom.VNode) AutoTableOption {
|
||
return func(c *atConfig) { c.toolbarActions = actions }
|
||
}
|
||
|
||
// AutoTableWithFiltersToggle supplies the narrow-screen Filters button and whether
|
||
// the panel is currently open. Below the `sm` breakpoint the filter fields are
|
||
// hidden until it is.
|
||
func AutoTableWithFiltersToggle(toggle *vdom.VNode, open bool) AutoTableOption {
|
||
return func(c *atConfig) {
|
||
c.filtersToggle = toggle
|
||
c.filtersOpen = open
|
||
}
|
||
}
|
||
|
||
// AutoTableWithSearchAside moves the filters into a card beside the table instead
|
||
// of a strip above it.
|
||
func AutoTableWithSearchAside() AutoTableOption {
|
||
return func(c *atConfig) { c.searchAside = true }
|
||
}
|
||
|
||
// AutoTableWithAbove / AutoTableWithBelow inject arbitrary content around the table.
|
||
func AutoTableWithAbove(n *vdom.VNode) AutoTableOption {
|
||
return func(c *atConfig) { c.above = n }
|
||
}
|
||
|
||
func AutoTableWithBelow(n *vdom.VNode) AutoTableOption {
|
||
return func(c *atConfig) { c.below = n }
|
||
}
|
||
|
||
// AutoTable renders the table shell for the given columns and rows. Sort state,
|
||
// loading, and pagination are plain value props supplied via opts (reactive
|
||
// accessors collapse). See the file-level NOTE for out-of-scope features.
|
||
func AutoTable(cols []AutoTableColumn, rows []any, opts ...AutoTableOption) *vdom.VNode {
|
||
cfg := &atConfig{}
|
||
for _, o := range opts {
|
||
o(cfg)
|
||
}
|
||
|
||
tableCls := cx("border-collapse", TBL_BASE)
|
||
if !cfg.tableLayoutAuto {
|
||
tableCls = cx(tableCls, "table-fixed")
|
||
}
|
||
|
||
containerCls := TBL_CONTAINER
|
||
if cfg.surroundingBorder {
|
||
containerCls = cx(containerCls, "border border-neutral-300")
|
||
}
|
||
if cfg.shadow {
|
||
containerCls = cx(containerCls, "shadow-sm")
|
||
}
|
||
|
||
table := vdom.Table(vdom.Attr("class", tableCls),
|
||
atRenderHead(cols, cfg),
|
||
atRenderBody(cols, rows, cfg),
|
||
)
|
||
if cfg.foot != nil {
|
||
table.Children = append(table.Children, cfg.foot)
|
||
}
|
||
|
||
container := vdom.Div(vdom.Attr("class", containerCls),
|
||
vdom.Div(vdom.Attr("class", TBL_WRAPPER), table),
|
||
)
|
||
if footer := atRenderPagination(cfg); footer != nil {
|
||
container.Children = append(container.Children, footer)
|
||
}
|
||
|
||
// The table plus its chrome. With searchAside the filters become a card to the
|
||
// left; otherwise they are a strip above.
|
||
body := []*vdom.VNode{}
|
||
if cfg.above != nil {
|
||
body = append(body, cfg.above)
|
||
}
|
||
if cfg.searchAside && len(cfg.searchFields) > 0 {
|
||
aside := vdom.Div(kids([]vdom.Mod{vdom.Attr("class", AUTOTABLE_ASIDE)}, cfg.searchFields)...)
|
||
body = append(body, vdom.Div(vdom.Attr("class", "flex flex-col sm:flex-row gap-4"),
|
||
aside,
|
||
vdom.Div(vdom.Attr("class", "min-w-0 grow"), container),
|
||
))
|
||
} else {
|
||
if toolbar := atRenderToolbar(cfg); toolbar != nil {
|
||
body = append(body, toolbar)
|
||
}
|
||
body = append(body, container)
|
||
}
|
||
if cfg.below != nil {
|
||
body = append(body, cfg.below)
|
||
}
|
||
|
||
return vdom.Div(kids([]vdom.Mod{vdom.Attr("class", cx("min-w-0 w-full max-w-full", cfg.class))}, body)...,
|
||
)
|
||
}
|
||
|
||
// atRenderToolbar builds the filter strip above the table: the search fields, the
|
||
// narrow-screen Filters toggle, and any caller-supplied actions.
|
||
//
|
||
// Below the `sm` breakpoint the fields collapse behind the toggle — which is why
|
||
// the toggle carries a badge with the active-filter count. A user who cannot see
|
||
// the fields still needs to know the table is filtered.
|
||
func atRenderToolbar(cfg *atConfig) *vdom.VNode {
|
||
if len(cfg.searchFields) == 0 && len(cfg.toolbarActions) == 0 {
|
||
return nil
|
||
}
|
||
|
||
left := []*vdom.VNode{}
|
||
if cfg.filtersToggle != nil {
|
||
left = append(left, cfg.filtersToggle)
|
||
}
|
||
if len(cfg.searchFields) > 0 {
|
||
visibility := "hidden sm:flex"
|
||
if cfg.filtersToggle == nil || cfg.filtersOpen {
|
||
visibility = "flex"
|
||
}
|
||
left = append(left, vdom.Div(kids([]vdom.Mod{vdom.Attr("class", cx(AUTOTABLE_SEARCH_FIELDS, visibility))}, cfg.searchFields)...,
|
||
))
|
||
}
|
||
|
||
row := vdom.Div(vdom.Attr("class", AUTOTABLE_TOOLBAR),
|
||
vdom.Div(kids([]vdom.Mod{vdom.Attr("class", "flex flex-wrap items-end gap-2")}, left)...),
|
||
)
|
||
if len(cfg.toolbarActions) > 0 {
|
||
row.Children = append(row.Children, vdom.Div(kids([]vdom.Mod{vdom.Attr("class", "flex items-center gap-2")}, cfg.toolbarActions)...,
|
||
))
|
||
}
|
||
return row
|
||
}
|
||
|
||
// atTotalColumns is the real column count, including the accordion toggle — what a
|
||
// full-width expanded row has to span.
|
||
func atTotalColumns(cols []AutoTableColumn, cfg *atConfig) int {
|
||
n := len(cols)
|
||
if n == 0 {
|
||
n = 1
|
||
}
|
||
if cfg.accordion {
|
||
n++
|
||
}
|
||
return n
|
||
}
|
||
|
||
// atRenderHead builds the <thead> with one header <tr>.
|
||
func atRenderHead(cols []AutoTableColumn, cfg *atConfig) *vdom.VNode {
|
||
headerColor := HEADER_COLOR_CLS[cfg.color]
|
||
headerPadding := HEADER_PADDING_CLS[cfg.size]
|
||
|
||
tr := vdom.Tr()
|
||
if cfg.accordion {
|
||
// A spacer above the expand/collapse toggle column.
|
||
tr.Children = append(tr.Children, vdom.Th(vdom.Attr("class", cx(headerPadding, headerColor, AUTOTABLE_ACCORDION_CELL))))
|
||
}
|
||
if len(cols) == 0 {
|
||
tr.Children = append(tr.Children,
|
||
vdom.Th(vdom.Attr("class", cx(headerPadding, headerColor)), vdom.Text(" ")))
|
||
}
|
||
for i, col := range cols {
|
||
tr.Children = append(tr.Children, atRenderHeaderCell(col, i, cfg, headerColor, headerPadding))
|
||
}
|
||
return vdom.Thead(vdom.Attr("class", atTheadCls), tr)
|
||
}
|
||
|
||
// atColumnHooks is how AutoTableState injects drag/resize/width behavior into the
|
||
// header without AutoTable itself owning any of that state. Nil when column
|
||
// management is off, which is why every use below is guarded.
|
||
type atColumnHooks struct {
|
||
key func(col AutoTableColumn, i int) string
|
||
ref func(key string) *vdom.Ref
|
||
width func(key string) float64
|
||
|
||
draggable bool
|
||
resizable bool
|
||
dragging string // the column currently being dragged
|
||
dropTarget string // the column it is hovering over
|
||
|
||
onDragStart func(key string)
|
||
onDragOver func(key string)
|
||
onDrop func(from, to string)
|
||
onDragEnd func()
|
||
onResizeStart func(key string, clientX int)
|
||
}
|
||
|
||
func atWithColumnHooks(h *atColumnHooks) AutoTableOption {
|
||
return func(c *atConfig) { c.columns = h }
|
||
}
|
||
|
||
// Tailwind for the drag/resize affordances, from AutoTable.tsx.
|
||
const (
|
||
RESIZE_HANDLE_CLS = "absolute top-0 right-0 h-full w-1 cursor-col-resize select-none hover:bg-sky-500/50"
|
||
DRAG_GRIP_CLS = "cursor-grab opacity-0 group-hover/th:opacity-50"
|
||
DRAGGING_TH_CLS = "scale-95 opacity-60"
|
||
DROP_TARGET_TH_CLS = "outline-2 outline-sky-500"
|
||
)
|
||
|
||
// atRenderHeaderCell builds one <th>, including its drag grip and resize handle
|
||
// when column management is on.
|
||
func atRenderHeaderCell(col AutoTableColumn, displayIdx int, cfg *atConfig, headerColor, headerPadding string) *vdom.VNode {
|
||
pos := col.DisplayPosition
|
||
posCls := POS_CLS[pos]
|
||
hooks := cfg.columns
|
||
|
||
key := ""
|
||
if hooks != nil {
|
||
key = hooks.key(col, displayIdx)
|
||
}
|
||
|
||
thCls := cx(headerPadding, headerColor, posCls)
|
||
// A user-dragged width wins over the declared Tailwind class: the class would
|
||
// fight the inline width, and the user's intent is the more specific one.
|
||
width := 0.0
|
||
if hooks != nil {
|
||
width = hooks.width(key)
|
||
}
|
||
if width == 0 {
|
||
thCls = cx(thCls, col.WidthClass)
|
||
}
|
||
if cfg.headerBorderY && displayIdx > 0 {
|
||
thCls = cx(thCls, "border-l border-l-neutral-300")
|
||
}
|
||
if col.Sortable {
|
||
thCls = cx(thCls, "cursor-pointer", atHeaderSortHoverCls[cfg.color])
|
||
}
|
||
if hooks != nil && (hooks.draggable || hooks.resizable) {
|
||
thCls = cx(thCls, "relative group/th")
|
||
}
|
||
if hooks != nil && hooks.dragging == key && key != "" {
|
||
thCls = cx(thCls, DRAGGING_TH_CLS)
|
||
}
|
||
if hooks != nil && hooks.dropTarget == key && hooks.dragging != key && key != "" {
|
||
thCls = cx(thCls, DROP_TARGET_TH_CLS)
|
||
}
|
||
thCls = cx(thCls, col.HeaderClasses)
|
||
|
||
mods := []vdom.Mod{vdom.Attr("class", thCls)}
|
||
if hooks != nil {
|
||
mods = append(mods, vdom.WithRef(hooks.ref(key)))
|
||
if width > 0 {
|
||
mods = append(mods, vdom.Attr("style", "width:"+px(width)))
|
||
}
|
||
}
|
||
if col.Sortable {
|
||
sortID := col.SortIdentifier
|
||
if sortID != "" && cfg.onSort != nil {
|
||
mods = append(mods, vdom.On(vdom.EVENT_CLICK, func() { cfg.onSort(sortID) }))
|
||
}
|
||
}
|
||
if hooks != nil && hooks.draggable && key != "" {
|
||
mods = append(mods, atDragMods(key, hooks)...)
|
||
}
|
||
|
||
// Inner content: optional grip, label (grows), sort caret.
|
||
inner := vdom.Div(vdom.Attr("class", cx(HEADER_INNER_BASE, HEADER_INNER_POS[pos])))
|
||
if hooks != nil && hooks.draggable {
|
||
inner.Children = append(inner.Children,
|
||
vdom.Span(vdom.Attr("class", DRAG_GRIP_CLS), Icon("grip-vertical", 12, "")))
|
||
}
|
||
inner.Children = append(inner.Children,
|
||
vdom.Div(vdom.Attr("class", cx("grow text-sm", HEADER_TEXT_CLS[cfg.color])), vdom.Text(col.DisplayName)))
|
||
|
||
if col.Sortable {
|
||
iconWrap := vdom.Div(vdom.Attr("class", cx(atSortIconWrap, "w-4 text-center", atHeaderSortIconCls[cfg.color])))
|
||
if col.SortIdentifier != "" && cfg.sortIdentifier == col.SortIdentifier {
|
||
// caret-up/caret-down are not in the default icon registry, so they
|
||
// render as empty boxes until an app registers them (see Icons.go).
|
||
if cfg.sortDescending {
|
||
iconWrap.Children = append(iconWrap.Children, Icon("caret-down", 16, ""))
|
||
} else {
|
||
iconWrap.Children = append(iconWrap.Children, Icon("caret-up", 16, ""))
|
||
}
|
||
}
|
||
inner.Children = append(inner.Children, iconWrap)
|
||
}
|
||
|
||
mods = append(mods, vdom.Div(vdom.Attr("class", HEADER_CONTENT), inner))
|
||
|
||
if hooks != nil && hooks.resizable && key != "" {
|
||
mods = append(mods, atResizeHandle(key, hooks))
|
||
}
|
||
return vdom.Th(mods...)
|
||
}
|
||
|
||
// atDragMods wires HTML5 drag-and-drop on a header.
|
||
//
|
||
// The dragover handler MUST call preventDefault: the browser's default is to reject
|
||
// the drop, and without it the drop event never fires at all. That is the classic
|
||
// HTML5 DnD footgun.
|
||
func atDragMods(key string, h *atColumnHooks) []vdom.Mod {
|
||
return []vdom.Mod{
|
||
vdom.Attr("draggable", "true"),
|
||
vdom.OnEvent(vdom.EVENT_DRAGSTART, func(e vdom.Event) {
|
||
e.SetData("text/plain", key)
|
||
h.onDragStart(key)
|
||
}),
|
||
vdom.OnEvent(vdom.EVENT_DRAGOVER, func(e vdom.Event) {
|
||
e.PreventDefault() // without this the drop never fires
|
||
h.onDragOver(key)
|
||
}),
|
||
vdom.OnEvent(vdom.EVENT_DROP, func(e vdom.Event) {
|
||
e.PreventDefault()
|
||
from := e.GetData("text/plain")
|
||
h.onDrop(from, key)
|
||
h.onDragEnd()
|
||
}),
|
||
vdom.OnEvent(vdom.EVENT_DRAGEND, func(vdom.Event) { h.onDragEnd() }),
|
||
}
|
||
}
|
||
|
||
// atResizeHandle is the grab strip on a header's right edge.
|
||
//
|
||
// It stops the mousedown propagating, or the click would also fire the header's
|
||
// sort handler — you would re-sort the table every time you resized a column.
|
||
func atResizeHandle(key string, h *atColumnHooks) *vdom.VNode {
|
||
return vdom.Div(vdom.Attr("class", RESIZE_HANDLE_CLS),
|
||
// A resize drag must not also start a column drag.
|
||
vdom.Attr("draggable", "false"),
|
||
vdom.OnEvent(vdom.EVENT_MOUSEDOWN, func(e vdom.Event) {
|
||
e.PreventDefault()
|
||
e.StopPropagation()
|
||
h.onResizeStart(key, e.ClientX())
|
||
}),
|
||
)
|
||
}
|
||
|
||
// atRenderBody builds the <tbody> with its loading / error / empty / data states.
|
||
func atRenderBody(cols []AutoTableColumn, rows []any, cfg *atConfig) *vdom.VNode {
|
||
bodyCls := BODY_PADDING_CLS[cfg.size]
|
||
if cfg.borderY {
|
||
bodyCls = cx(bodyCls, "[&_td+td]:border-l [&_td+td]:border-neutral-300")
|
||
}
|
||
|
||
colspan := atTotalColumns(cols, cfg)
|
||
|
||
tbody := vdom.Tbody(vdom.Attr("class", bodyCls))
|
||
|
||
switch {
|
||
case cfg.loading:
|
||
// NOTE: the TSX randomizes each skeleton's width; a fixed width is used
|
||
// here (no measurement/randomness in the neutral runtime).
|
||
for r := 0; r < 5; r++ {
|
||
tr := vdom.Tr()
|
||
if cfg.alternate && r%2 == 1 {
|
||
tr.Attrs["class"] = "bg-neutral-100"
|
||
}
|
||
if cfg.accordion {
|
||
tr.Children = append(tr.Children, vdom.Td(vdom.Attr("class", AUTOTABLE_ACCORDION_CELL)))
|
||
}
|
||
for range cols {
|
||
tr.Children = append(tr.Children, vdom.Td(vdom.Div(vdom.Attr("class", atSkeleton), vdom.Attr("style", "width: 70%"))))
|
||
}
|
||
tbody.Children = append(tbody.Children, tr)
|
||
}
|
||
case cfg.errorMsg != "":
|
||
tbody.Children = append(tbody.Children, vdom.Tr(vdom.Td(vdom.Attr("colspan", strconv.Itoa(colspan)), vdom.Attr("class", atErrorCell),
|
||
vdom.Text("Error: "+cfg.errorMsg)),
|
||
))
|
||
case len(cols) == 0:
|
||
tbody.Children = append(tbody.Children, vdom.Tr(vdom.Td(vdom.Attr("colspan", strconv.Itoa(colspan)), vdom.Attr("class", atEmptyCell),
|
||
vdom.Text("No columns selected.")),
|
||
))
|
||
case len(rows) == 0:
|
||
tbody.Children = append(tbody.Children, vdom.Tr(vdom.Td(vdom.Attr("colspan", strconv.Itoa(colspan)), vdom.Attr("class", atEmptyCell),
|
||
vdom.Text(pick(cfg.emptyMessage, "No entries found."))),
|
||
))
|
||
default:
|
||
hoverCls := ""
|
||
if cfg.hover {
|
||
hoverCls = atRowHoverCls[cfg.color]
|
||
}
|
||
for rowIdx, row := range rows {
|
||
isLast := rowIdx == len(rows)-1
|
||
rowCls := ""
|
||
if cfg.alternate && rowIdx%2 == 1 {
|
||
rowCls = cx(rowCls, "bg-neutral-100")
|
||
}
|
||
rowCls = cx(rowCls, hoverCls)
|
||
if cfg.borderX && !isLast {
|
||
rowCls = cx(rowCls, "border-b border-neutral-300")
|
||
}
|
||
if cfg.highlight != nil && cfg.highlight(row) {
|
||
rowCls = cx(rowCls, AUTOTABLE_HIGHLIGHT_ROW)
|
||
}
|
||
|
||
key := ""
|
||
expanded := false
|
||
if cfg.accordion {
|
||
key = cfg.rowKey(row, rowIdx)
|
||
expanded = cfg.isExpanded != nil && cfg.isExpanded(key)
|
||
rowCls = cx(rowCls, "cursor-pointer")
|
||
}
|
||
|
||
tr := vdom.Tr()
|
||
if rowCls != "" {
|
||
tr.Attrs["class"] = rowCls
|
||
}
|
||
if cfg.accordion {
|
||
rowKey := key // capture per row, not the loop variable's final value
|
||
if cfg.onToggleExpand != nil {
|
||
tr.Events[vdom.EVENT_CLICK] = func(vdom.Event) { cfg.onToggleExpand(rowKey) }
|
||
}
|
||
tr.Children = append(tr.Children, atRenderAccordionToggle(expanded))
|
||
}
|
||
for _, col := range cols {
|
||
tr.Children = append(tr.Children, atRenderCell(col, row, cfg.rowOffset+rowIdx))
|
||
}
|
||
tbody.Children = append(tbody.Children, tr)
|
||
|
||
if expanded && cfg.accordionContent != nil {
|
||
tbody.Children = append(tbody.Children, vdom.Tr(vdom.Attr("class", AUTOTABLE_ACCORDION_ROW),
|
||
vdom.Td(vdom.Attr("colspan", strconv.Itoa(colspan)),
|
||
cfg.accordionContent(row),
|
||
),
|
||
))
|
||
}
|
||
}
|
||
}
|
||
return tbody
|
||
}
|
||
|
||
// atRenderAccordionToggle is the chevron cell. It rotates rather than swapping
|
||
// icons, so the transition reads as the row opening.
|
||
func atRenderAccordionToggle(expanded bool) *vdom.VNode {
|
||
rotate := "transition-transform duration-150"
|
||
if expanded {
|
||
rotate = cx(rotate, "rotate-90")
|
||
}
|
||
return vdom.Td(vdom.Attr("class", AUTOTABLE_ACCORDION_CELL),
|
||
vdom.Span(vdom.Attr("class", rotate), Icon("chevron-right", 14, "")),
|
||
)
|
||
}
|
||
|
||
// atRenderCell renders a column's cell. rowIndex is the row's position in the full
|
||
// filtered set, not on the page — a calculated column's running total has to keep
|
||
// counting across page boundaries.
|
||
func atRenderCell(col AutoTableColumn, row any, rowIndex int) *vdom.VNode {
|
||
if col.CellAt != nil {
|
||
if td := col.CellAt(row, rowIndex); td != nil {
|
||
return td
|
||
}
|
||
}
|
||
if col.Cell != nil {
|
||
if td := col.Cell(row); td != nil {
|
||
return td
|
||
}
|
||
}
|
||
return vdom.Td(vdom.Attr("class", POS_CLS[col.DisplayPosition]))
|
||
}
|
||
|
||
// atRenderPagination builds the display-only pagination bar, or nil when there is
|
||
// nothing to show. Page/items-per-page changes are surfaced via callbacks.
|
||
func atRenderPagination(cfg *atConfig) *vdom.VNode {
|
||
if cfg.pagination == nil || cfg.hidePagination {
|
||
// The Reset menu lives in this bar, so it still needs a bar to live in when
|
||
// there is no pagination to show.
|
||
if cfg.reset != nil {
|
||
return vdom.Div(vdom.Attr("class", cx(atPaginationBase, atPaginationPaddingCls[cfg.size])),
|
||
cfg.reset,
|
||
)
|
||
}
|
||
return nil
|
||
}
|
||
p := cfg.pagination
|
||
|
||
info := vdom.Div(vdom.Attr("class", atPaginationInfo),
|
||
vdom.B(vdom.Attr("class", "leading-none"), Icon("list-ol", 16, "")),
|
||
vdom.Span(vdom.Attr("class", "ml-3"),
|
||
vdom.Text(strconv.Itoa(p.ViewRangeLower)+"-"+strconv.Itoa(p.ViewRangeUpper)+" of "+strconv.Itoa(p.TotalItems))),
|
||
)
|
||
|
||
sizes := []int{5, 10, 25, 50, 100}
|
||
if cfg.paginationShowAll {
|
||
sizes = append(sizes, PageSizeAll)
|
||
}
|
||
sel := vdom.Select(vdom.Attr("class", "mr-5"))
|
||
for _, n := range sizes {
|
||
label := strconv.Itoa(n)
|
||
if n == PageSizeAll {
|
||
label = "All"
|
||
}
|
||
optMods := []vdom.Mod{vdom.Attr("value", strconv.Itoa(n)), vdom.Text(label)}
|
||
if n == p.MaxItemsPerPage {
|
||
optMods = append(optMods, vdom.Attr("selected", "selected"))
|
||
}
|
||
sel.Children = append(sel.Children, vdom.Option(optMods...))
|
||
}
|
||
if cfg.onItemsPerPage != nil {
|
||
sel.Events[vdom.EVENT_CHANGE] = func(e vdom.Event) {
|
||
if n, err := strconv.Atoi(e.Value()); err == nil {
|
||
cfg.onItemsPerPage(n)
|
||
}
|
||
}
|
||
}
|
||
|
||
page := func(to int) func() {
|
||
return func() {
|
||
if cfg.onPageChange != nil {
|
||
cfg.onPageChange(to)
|
||
}
|
||
}
|
||
}
|
||
|
||
controls := vdom.Div(vdom.Attr("class", atPaginationControls),
|
||
vdom.Div(vdom.Attr("class", atPaginationLabel), vdom.Text("Items per page:")),
|
||
sel,
|
||
atPaginationButton(page(1), p.CurrentPage <= 1, Icon("angles-left", 16, "")),
|
||
atPaginationButton(page(p.CurrentPage-1), p.CurrentPage <= 1, Icon("chevron-left", 16, "")),
|
||
vdom.Div(vdom.Attr("class", atPaginationPage),
|
||
vdom.Text("Page "+strconv.Itoa(p.CurrentPage)+" of "+strconv.Itoa(p.TotalPages))),
|
||
atPaginationButton(page(p.CurrentPage+1), p.CurrentPage >= p.TotalPages, Icon("chevron-right", 16, "")),
|
||
atPaginationButton(page(p.TotalPages), p.CurrentPage >= p.TotalPages, Icon("angles-right", 16, "")),
|
||
)
|
||
|
||
// Bottom-left: Reset, then the "3-12 of 57" range. Bottom-right: the page
|
||
// controls. Reset belongs down here, next to the other table-wide controls,
|
||
// rather than up in the toolbar with the filters — it undoes the layout, not the
|
||
// query.
|
||
left := []vdom.Mod{vdom.Attr("class", "flex items-center gap-3")}
|
||
if cfg.reset != nil {
|
||
left = append(left, cfg.reset)
|
||
}
|
||
left = append(left, info)
|
||
|
||
return vdom.Div(vdom.Attr("class", cx(atPaginationBase, atPaginationPaddingCls[cfg.size])),
|
||
vdom.Div(left...),
|
||
controls,
|
||
)
|
||
}
|
||
|
||
// AutoTableWithResetMenu puts a Reset menu at the bottom-left of the table.
|
||
func AutoTableWithResetMenu(menu *vdom.VNode) AutoTableOption {
|
||
return func(c *atConfig) { c.reset = menu }
|
||
}
|
||
|
||
// atPaginationButton is one pagination control button (mirrors TSX PaginationButton).
|
||
func atPaginationButton(onClick func(), disabled bool, child *vdom.VNode) *vdom.VNode {
|
||
mods := []vdom.Mod{vdom.Attr("class", atPaginationBtn)}
|
||
if disabled {
|
||
mods = append(mods, vdom.Attr("disabled", "disabled"))
|
||
} else if onClick != nil {
|
||
mods = append(mods, vdom.On(vdom.EVENT_CLICK, onClick))
|
||
}
|
||
if child != nil {
|
||
mods = append(mods, child)
|
||
}
|
||
return vdom.Button(mods...)
|
||
}
|
||
|
||
// AutoTableTdLeft / AutoTableTdRight / AutoTableTdCenter build an aligned <td>,
|
||
// convenient for AutoTableColumn.Cell funcs (mirror the TSX TdLeft/Right/Center).
|
||
func AutoTableTdLeft(class string, children ...*vdom.VNode) *vdom.VNode {
|
||
return vdom.Td(kids([]vdom.Mod{vdom.Attr("class", cx("text-left", class))}, children)...)
|
||
}
|
||
|
||
func AutoTableTdRight(class string, children ...*vdom.VNode) *vdom.VNode {
|
||
return vdom.Td(kids([]vdom.Mod{vdom.Attr("class", cx("text-right", class))}, children)...)
|
||
}
|
||
|
||
func AutoTableTdCenter(class string, children ...*vdom.VNode) *vdom.VNode {
|
||
return vdom.Td(kids([]vdom.Mod{vdom.Attr("class", cx("text-center", class))}, children)...)
|
||
}
|
||
|
||
// ==========================================================================
|
||
// Data pipeline: filter, sort, paginate
|
||
// ==========================================================================
|
||
|
||
// AutoTable's data pipeline: filter, then sort, then paginate. Pure Go — no DOM,
|
||
// no browser — so it runs identically on the server (SSR) and in the client, and
|
||
// tests without a harness.
|
||
//
|
||
// This is the half of AutoTable.tsx that never actually needed a browser and was
|
||
// dropped anyway: the original Go port surfaced sort and pagination as callbacks
|
||
// and made every caller compute the result themselves.
|
||
//
|
||
// A note on "filtering": despite the name, the TSX has no filter-operator model —
|
||
// no gt/lt/between, no typed filter widgets. It has a flat list of search entries,
|
||
// each naming a field and one or more values, and exactly three behaviors (see
|
||
// ApplySearchFilters). Reproducing a richer model here would be inventing an API
|
||
// the two consuming apps do not use.
|
||
|
||
// AutoTableSearchEntry is one active filter: match `Identifier` against `Values`.
|
||
//
|
||
// - len(Values) > 1 -> the field must equal ONE OF the values (an IN-set test —
|
||
// this is what a multi-select filter produces). Case-insensitive.
|
||
// - len(Values) == 1 && Exact -> case-insensitive equality.
|
||
// - len(Values) == 1 -> case-insensitive substring match. The default.
|
||
//
|
||
// An Identifier built by MultiSearchIdentifier searches several fields at once,
|
||
// OR-ing across them — the closest thing the kit has to a global search box.
|
||
type AutoTableSearchEntry struct {
|
||
Identifier string
|
||
Values []string
|
||
Exact bool
|
||
}
|
||
|
||
// AutoTableFilter is the complete query state: what to match, how to order, and
|
||
// which page. It is what a remote endpoint receives (see BuildQueryString) and
|
||
// what ProcessLocally applies in memory.
|
||
type AutoTableFilter struct {
|
||
Search []AutoTableSearchEntry
|
||
OrderBy AutoTableOrderBy
|
||
Pagination AutoTablePagination
|
||
}
|
||
|
||
// multiSearchPrefix marks an identifier that spans several fields.
|
||
const multiSearchPrefix = "_multi_"
|
||
|
||
// MultiSearchIdentifier builds an identifier that matches a value against ANY of
|
||
// the given fields — one search box over "name, email, phone".
|
||
func MultiSearchIdentifier(fields ...string) string {
|
||
return multiSearchPrefix + strings.Join(fields, ",")
|
||
}
|
||
|
||
// SearchFields returns the fields an identifier covers: the several fields of a
|
||
// multi-search identifier, or the single field it names.
|
||
func SearchFields(identifier string) []string {
|
||
if rest, ok := strings.CutPrefix(identifier, multiSearchPrefix); ok {
|
||
return strings.Split(rest, ",")
|
||
}
|
||
return []string{identifier}
|
||
}
|
||
|
||
// PageSizeAll is the "All" page size: one page holding everything.
|
||
const PageSizeAll = -1
|
||
|
||
// ---- reading fields out of a row ----
|
||
|
||
// FieldReader pulls a named field out of a row. Rows are `any` — the app's own
|
||
// structs, or maps — so the pipeline needs a way to ask for "the Status field"
|
||
// without knowing the type. DefaultFieldReader handles the usual cases; supply
|
||
// your own for computed or nested fields.
|
||
type FieldReader func(row any, field string) any
|
||
|
||
// DefaultFieldReader reads a field from a map (map[string]any, map[string]string)
|
||
// or a struct (by exact field name, then `json` tag, then case-insensitive name —
|
||
// which is what makes a snake_case identifier from an API line up with a Go field).
|
||
// It follows pointers and returns nil when there is no such field.
|
||
func DefaultFieldReader(row any, field string) any {
|
||
v := reflect.ValueOf(row)
|
||
for v.Kind() == reflect.Pointer || v.Kind() == reflect.Interface {
|
||
if v.IsNil() {
|
||
return nil
|
||
}
|
||
v = v.Elem()
|
||
}
|
||
|
||
switch v.Kind() {
|
||
case reflect.Map:
|
||
key := reflect.ValueOf(field)
|
||
if !key.Type().AssignableTo(v.Type().Key()) {
|
||
return nil
|
||
}
|
||
got := v.MapIndex(key)
|
||
if !got.IsValid() {
|
||
return nil
|
||
}
|
||
return got.Interface()
|
||
|
||
case reflect.Struct:
|
||
t := v.Type()
|
||
if f, ok := t.FieldByName(field); ok && f.IsExported() {
|
||
return v.FieldByIndex(f.Index).Interface()
|
||
}
|
||
for i := range t.NumField() {
|
||
f := t.Field(i)
|
||
if !f.IsExported() {
|
||
continue
|
||
}
|
||
if tag, _, _ := strings.Cut(f.Tag.Get("json"), ","); tag == field {
|
||
return v.Field(i).Interface()
|
||
}
|
||
if strings.EqualFold(f.Name, field) {
|
||
return v.Field(i).Interface()
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// nthField is the fallback for a sortable column with no explicit identifier: the
|
||
// TSX resolves `_col_<n>` to the nth key of the first row.
|
||
//
|
||
// Divergence, deliberate: JavaScript objects have a stable key order, Go maps do
|
||
// not. For a struct we use declaration order (the natural analogue); for a map we
|
||
// sort the keys, so the result is at least deterministic across runs. Anything
|
||
// relying on `_col_<n>` over a map was already relying on luck.
|
||
func nthField(row any, n int) any {
|
||
v := reflect.ValueOf(row)
|
||
for v.Kind() == reflect.Pointer || v.Kind() == reflect.Interface {
|
||
if v.IsNil() {
|
||
return nil
|
||
}
|
||
v = v.Elem()
|
||
}
|
||
|
||
switch v.Kind() {
|
||
case reflect.Struct:
|
||
t := v.Type()
|
||
var exported []int
|
||
for i := range t.NumField() {
|
||
if t.Field(i).IsExported() {
|
||
exported = append(exported, i)
|
||
}
|
||
}
|
||
if n < 0 || n >= len(exported) {
|
||
return nil
|
||
}
|
||
return v.Field(exported[n]).Interface()
|
||
|
||
case reflect.Map:
|
||
keys := make([]string, 0, v.Len())
|
||
for _, k := range v.MapKeys() {
|
||
keys = append(keys, k.String())
|
||
}
|
||
sort.Strings(keys)
|
||
if n < 0 || n >= len(keys) {
|
||
return nil
|
||
}
|
||
return v.MapIndex(reflect.ValueOf(keys[n])).Interface()
|
||
}
|
||
return nil
|
||
}
|
||
|
||
const positionalPrefix = "_col_"
|
||
|
||
// PositionalIdentifier names a column by index, for a table whose columns have no
|
||
// explicit sort identifiers.
|
||
func PositionalIdentifier(i int) string { return positionalPrefix + strconv.Itoa(i) }
|
||
|
||
// ---- filtering ----
|
||
|
||
// ApplySearchFilters keeps the rows matching every entry (entries AND together;
|
||
// the values within one entry OR together). An entry with no values is inert, so
|
||
// an empty search box does not filter everything away.
|
||
func ApplySearchFilters(rows []any, search []AutoTableSearchEntry, read FieldReader) []any {
|
||
if read == nil {
|
||
read = DefaultFieldReader
|
||
}
|
||
active := make([]AutoTableSearchEntry, 0, len(search))
|
||
for _, e := range search {
|
||
if e.Identifier == "" {
|
||
continue
|
||
}
|
||
vals := make([]string, 0, len(e.Values))
|
||
for _, v := range e.Values {
|
||
if strings.TrimSpace(v) != "" {
|
||
vals = append(vals, v)
|
||
}
|
||
}
|
||
if len(vals) > 0 {
|
||
active = append(active, AutoTableSearchEntry{Identifier: e.Identifier, Values: vals, Exact: e.Exact})
|
||
}
|
||
}
|
||
if len(active) == 0 {
|
||
return rows
|
||
}
|
||
|
||
out := make([]any, 0, len(rows))
|
||
for _, row := range rows {
|
||
if matchesAll(row, active, read) {
|
||
out = append(out, row)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func matchesAll(row any, search []AutoTableSearchEntry, read FieldReader) bool {
|
||
for _, e := range search {
|
||
if !matchesEntry(row, e, read) {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
// matchesEntry ORs across the entry's fields (a multi-search identifier covers
|
||
// several) and across its values.
|
||
func matchesEntry(row any, e AutoTableSearchEntry, read FieldReader) bool {
|
||
for _, field := range SearchFields(e.Identifier) {
|
||
cell := strings.ToLower(stringify(read(row, field)))
|
||
for _, want := range e.Values {
|
||
want = strings.ToLower(strings.TrimSpace(want))
|
||
switch {
|
||
case len(e.Values) > 1 || e.Exact:
|
||
if cell == want {
|
||
return true
|
||
}
|
||
default:
|
||
if strings.Contains(cell, want) {
|
||
return true
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// ---- sorting ----
|
||
|
||
// Sort types, mirroring the TSX's `sortType`.
|
||
const (
|
||
SortTypeString = "" // case-insensitive string compare (the default)
|
||
// SortTypeNumeric orders embedded numbers the way a person reads them:
|
||
// "Item 2" before "Item 10". Note it compares runs of digits, so "1.5" sorts
|
||
// like a version (1.5 < 1.10), not a decimal — for decimal strings use
|
||
// SortTypeMoney, or store a real numeric field.
|
||
SortTypeNumeric = "numeric"
|
||
SortTypeMoney = "money" // "$1,234.50" / "(1,234.50)" parsed as a number
|
||
)
|
||
|
||
// SortRows orders rows by the active sort. It is stable, so rows that compare
|
||
// equal keep their original order.
|
||
//
|
||
// Empty values ALWAYS sort last — in both directions. That is deliberate (and
|
||
// matches the TSX): reversing the sort should not drag a wall of blanks to the top.
|
||
func SortRows(rows []any, order AutoTableOrderBy, cols []AutoTableColumn, read FieldReader) []any {
|
||
if order.Identifier == "" {
|
||
return rows
|
||
}
|
||
if read == nil {
|
||
read = DefaultFieldReader
|
||
}
|
||
col, ok := columnFor(cols, order.Identifier)
|
||
sortType := SortTypeString
|
||
if ok {
|
||
sortType = col.SortType
|
||
}
|
||
|
||
key := func(row any) any { return sortKey(row, order.Identifier, col, ok, read) }
|
||
|
||
out := make([]any, len(rows))
|
||
copy(out, rows)
|
||
sort.SliceStable(out, func(i, j int) bool {
|
||
a, b := key(out[i]), key(out[j])
|
||
aEmpty, bEmpty := isEmptyValue(a), isEmptyValue(b)
|
||
if aEmpty != bEmpty {
|
||
return bEmpty // the non-empty one comes first, whatever the direction
|
||
}
|
||
if aEmpty {
|
||
return false
|
||
}
|
||
c := compareValues(a, b, sortType)
|
||
if order.Descending {
|
||
c = -c
|
||
}
|
||
return c < 0
|
||
})
|
||
return out
|
||
}
|
||
|
||
// sortKey resolves what a row's value for this sort actually is: an explicit
|
||
// SortValue func, else the named field, else a positional `_col_<n>` reference.
|
||
func sortKey(row any, identifier string, col AutoTableColumn, haveCol bool, read FieldReader) any {
|
||
if haveCol && col.SortValue != nil {
|
||
return col.SortValue(row)
|
||
}
|
||
if n, ok := strings.CutPrefix(identifier, positionalPrefix); ok {
|
||
if i, err := strconv.Atoi(n); err == nil {
|
||
return nthField(row, i)
|
||
}
|
||
}
|
||
return read(row, identifier)
|
||
}
|
||
|
||
func columnFor(cols []AutoTableColumn, identifier string) (AutoTableColumn, bool) {
|
||
for _, c := range cols {
|
||
if c.SortIdentifier == identifier {
|
||
return c, true
|
||
}
|
||
}
|
||
return AutoTableColumn{}, false
|
||
}
|
||
|
||
func isEmptyValue(v any) bool {
|
||
switch t := v.(type) {
|
||
case nil:
|
||
return true
|
||
case string:
|
||
return strings.TrimSpace(t) == ""
|
||
case time.Time:
|
||
return t.IsZero()
|
||
}
|
||
rv := reflect.ValueOf(v)
|
||
if rv.Kind() == reflect.Pointer && rv.IsNil() {
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// compareValues orders two non-empty values. Typed values (numbers, times, bools)
|
||
// compare natively; everything else falls back to the sort type.
|
||
func compareValues(a, b any, sortType string) int {
|
||
if at, ok := toTime(a); ok {
|
||
if bt, ok := toTime(b); ok {
|
||
return at.Compare(bt)
|
||
}
|
||
}
|
||
if af, ok := toFloat(a); ok {
|
||
if bf, ok := toFloat(b); ok {
|
||
return cmpFloat(af, bf)
|
||
}
|
||
}
|
||
if ab, ok := a.(bool); ok {
|
||
if bb, ok := b.(bool); ok {
|
||
return cmpBool(ab, bb)
|
||
}
|
||
}
|
||
|
||
as, bs := stringify(a), stringify(b)
|
||
switch sortType {
|
||
case SortTypeMoney:
|
||
return cmpFloat(parseMoney(as), parseMoney(bs))
|
||
case SortTypeNumeric:
|
||
return compareNatural(as, bs)
|
||
}
|
||
return compareFold(as, bs)
|
||
}
|
||
|
||
// compareNatural compares strings with embedded numbers the way a person would:
|
||
// "Item 2" before "Item 10", "A9" before "A10". It walks both strings together,
|
||
// comparing runs of digits numerically and everything else as text.
|
||
//
|
||
// Divergence from the TSX, deliberate: the original's "numeric" sort called
|
||
// parseInt on the whole value, which yields NaN for anything that does not START
|
||
// with a digit ("Item 10") and silently degrades to a string compare — so the
|
||
// original sorted Item 1, Item 10, Item 2. This is a superset: for values that are
|
||
// plain numbers ("10" vs "2") it agrees with the original exactly, and for the
|
||
// cases the original got wrong it now gets them right.
|
||
func compareNatural(a, b string) int {
|
||
i, j := 0, 0
|
||
for i < len(a) && j < len(b) {
|
||
ad, bd := isDigit(a[i]), isDigit(b[j])
|
||
if ad && bd {
|
||
ai, an := digitRun(a, i)
|
||
bj, bn := digitRun(b, j)
|
||
if c := cmpFloat(an, bn); c != 0 {
|
||
return c
|
||
}
|
||
i, j = ai, bj
|
||
continue
|
||
}
|
||
ca, cb := lowerByte(a[i]), lowerByte(b[j])
|
||
if ca != cb {
|
||
return cmpInt(int(ca), int(cb))
|
||
}
|
||
i++
|
||
j++
|
||
}
|
||
// One string is a prefix of the other: the shorter sorts first. If they are the
|
||
// same length here they matched case-insensitively, so break the tie on case to
|
||
// keep the ordering total.
|
||
if c := cmpInt(len(a)-i, len(b)-j); c != 0 {
|
||
return c
|
||
}
|
||
return strings.Compare(a, b)
|
||
}
|
||
|
||
// digitRun reads the number starting at i and returns the index just past it. It
|
||
// parses as a float so a run longer than an int64 does not wrap.
|
||
func digitRun(s string, i int) (int, float64) {
|
||
start := i
|
||
for i < len(s) && isDigit(s[i]) {
|
||
i++
|
||
}
|
||
n, _ := strconv.ParseFloat(s[start:i], 64)
|
||
return i, n
|
||
}
|
||
|
||
func isDigit(c byte) bool { return c >= '0' && c <= '9' }
|
||
|
||
func lowerByte(c byte) byte {
|
||
if c >= 'A' && c <= 'Z' {
|
||
return c + ('a' - 'A')
|
||
}
|
||
return c
|
||
}
|
||
|
||
// compareFold compares case-insensitively, then case-sensitively to break ties, so
|
||
// the order is total and stable ("apple" and "Apple" never compare equal).
|
||
func compareFold(a, b string) int {
|
||
if c := strings.Compare(strings.ToLower(a), strings.ToLower(b)); c != 0 {
|
||
return c
|
||
}
|
||
return strings.Compare(a, b)
|
||
}
|
||
|
||
// parseMoney reads "$1,234.50" / "(1,234.50)" / "-1234.5" as a number. Unparseable
|
||
// input is 0, which sorts with the other zeroes rather than blowing up.
|
||
func parseMoney(s string) float64 {
|
||
s = strings.TrimSpace(s)
|
||
negative := strings.HasPrefix(s, "(") && strings.HasSuffix(s, ")")
|
||
s = strings.Map(func(r rune) rune {
|
||
if (r >= '0' && r <= '9') || r == '.' || r == '-' {
|
||
return r
|
||
}
|
||
return -1
|
||
}, s)
|
||
f, err := strconv.ParseFloat(s, 64)
|
||
if err != nil {
|
||
return 0
|
||
}
|
||
if negative {
|
||
return -f
|
||
}
|
||
return f
|
||
}
|
||
|
||
func toFloat(v any) (float64, bool) {
|
||
switch n := v.(type) {
|
||
case int:
|
||
return float64(n), true
|
||
case int8:
|
||
return float64(n), true
|
||
case int16:
|
||
return float64(n), true
|
||
case int32:
|
||
return float64(n), true
|
||
case int64:
|
||
return float64(n), true
|
||
case uint:
|
||
return float64(n), true
|
||
case uint8:
|
||
return float64(n), true
|
||
case uint16:
|
||
return float64(n), true
|
||
case uint32:
|
||
return float64(n), true
|
||
case uint64:
|
||
return float64(n), true
|
||
case float32:
|
||
return float64(n), true
|
||
case float64:
|
||
return n, true
|
||
}
|
||
return 0, false
|
||
}
|
||
|
||
func toTime(v any) (time.Time, bool) {
|
||
t, ok := v.(time.Time)
|
||
return t, ok
|
||
}
|
||
|
||
func cmpFloat(a, b float64) int {
|
||
switch {
|
||
case a < b:
|
||
return -1
|
||
case a > b:
|
||
return 1
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func cmpInt(a, b int) int {
|
||
switch {
|
||
case a < b:
|
||
return -1
|
||
case a > b:
|
||
return 1
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func cmpBool(a, b bool) int {
|
||
switch {
|
||
case a == b:
|
||
return 0
|
||
case !a:
|
||
return -1
|
||
}
|
||
return 1
|
||
}
|
||
|
||
// stringify renders a value the way the table displays it, which is what search
|
||
// and string-sorting compare against.
|
||
func stringify(v any) string {
|
||
switch t := v.(type) {
|
||
case nil:
|
||
return ""
|
||
case string:
|
||
return t
|
||
case bool:
|
||
return strconv.FormatBool(t)
|
||
case time.Time:
|
||
return t.Format("2006-01-02")
|
||
case float64:
|
||
return strconv.FormatFloat(t, 'f', -1, 64)
|
||
case float32:
|
||
return strconv.FormatFloat(float64(t), 'f', -1, 32)
|
||
}
|
||
if f, ok := toFloat(v); ok {
|
||
return strconv.FormatFloat(f, 'f', -1, 64)
|
||
}
|
||
if s, ok := v.(interface{ String() string }); ok {
|
||
return s.String()
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// ---- pagination ----
|
||
|
||
// Paginate slices out one page and fills in the derived counts (total pages, the
|
||
// "3-12 of 57" view range). A MaxItemsPerPage of PageSizeAll yields a single page.
|
||
//
|
||
// It is defensive about the requested page: a filter that shrinks the result set
|
||
// can leave CurrentPage past the end, and silently showing an empty table is worse
|
||
// than showing the last page.
|
||
func Paginate(rows []any, p AutoTablePagination) ([]any, AutoTablePagination) {
|
||
out := p
|
||
out.TotalItems = len(rows)
|
||
|
||
perPage := p.MaxItemsPerPage
|
||
if perPage == PageSizeAll || perPage <= 0 {
|
||
out.TotalPages = 1
|
||
out.CurrentPage = 1
|
||
out.MaxItemsPerPage = perPage
|
||
out.ViewRangeLower, out.ViewRangeUpper = viewRange(len(rows), 1, len(rows))
|
||
return rows, out
|
||
}
|
||
|
||
out.TotalPages = max((len(rows)+perPage-1)/perPage, 1)
|
||
out.CurrentPage = clampInt(p.CurrentPage, 1, out.TotalPages)
|
||
|
||
lo := (out.CurrentPage - 1) * perPage
|
||
hi := min(lo+perPage, len(rows))
|
||
out.ViewRangeLower, out.ViewRangeUpper = viewRange(len(rows), lo+1, hi)
|
||
return rows[lo:hi], out
|
||
}
|
||
|
||
func viewRange(total, lo, hi int) (int, int) {
|
||
if total == 0 {
|
||
return 0, 0
|
||
}
|
||
return lo, hi
|
||
}
|
||
|
||
func clampInt(v, lo, hi int) int {
|
||
if v < lo {
|
||
return lo
|
||
}
|
||
if v > hi {
|
||
return hi
|
||
}
|
||
return v
|
||
}
|
||
|
||
// ---- the whole pipeline ----
|
||
|
||
// ProcessLocally runs the full client-side pipeline — filter, sort, paginate — and
|
||
// returns the rows for the current page plus the resolved pagination state (total
|
||
// pages, view range) to hand back to the table.
|
||
//
|
||
// allFiltered is every row that survived the filter, before paging: the export
|
||
// path needs it (you export what you filtered, not what happens to be on screen),
|
||
// and so does anything that summarises the whole result set.
|
||
func ProcessLocally(rows []any, filter AutoTableFilter, cols []AutoTableColumn, read FieldReader) (page, allFiltered []any, pagination AutoTablePagination) {
|
||
allFiltered = ApplySearchFilters(rows, filter.Search, read)
|
||
allFiltered = SortRows(allFiltered, filter.OrderBy, cols, read)
|
||
page, pagination = Paginate(allFiltered, filter.Pagination)
|
||
return page, allFiltered, pagination
|
||
}
|
||
|
||
// ---- remote mode ----
|
||
|
||
// BuildQueryString encodes a filter for a server that does the filtering itself.
|
||
// Mirrors the TSX's buildQueryString: one query parameter per search identifier
|
||
// (repeated for a multi-value entry), plus order_by / order_desc / page_num /
|
||
// items_per_page.
|
||
//
|
||
// noPagination is what the export path passes: export the whole filtered set, not
|
||
// just the page currently on screen.
|
||
func BuildQueryString(filter AutoTableFilter, noPagination bool) string {
|
||
q := url.Values{}
|
||
for _, e := range filter.Search {
|
||
for _, v := range e.Values {
|
||
if strings.TrimSpace(v) == "" {
|
||
continue
|
||
}
|
||
q.Add(e.Identifier, v)
|
||
}
|
||
if e.Exact && len(e.Values) == 1 {
|
||
q.Set(e.Identifier+"_exact", "true")
|
||
}
|
||
}
|
||
if filter.OrderBy.Identifier != "" {
|
||
q.Set("order_by", filter.OrderBy.Identifier)
|
||
q.Set("order_desc", strconv.FormatBool(filter.OrderBy.Descending))
|
||
}
|
||
if noPagination {
|
||
q.Set("items_per_page", strconv.Itoa(PageSizeAll))
|
||
} else {
|
||
q.Set("page_num", strconv.Itoa(max(filter.Pagination.CurrentPage, 1)))
|
||
q.Set("items_per_page", strconv.Itoa(filter.Pagination.MaxItemsPerPage))
|
||
}
|
||
return q.Encode()
|
||
}
|
||
|
||
// ==========================================================================
|
||
// AutoTableState: the live controller
|
||
// ==========================================================================
|
||
|
||
// AutoTableState is the live controller for a table: it owns the search, sort,
|
||
// page and expansion state, runs the pipeline in autotable_data.go, and renders
|
||
// the result.
|
||
//
|
||
// The original Go port had no state at all — it surfaced sort and pagination as
|
||
// callbacks and left every caller to implement filtering, sorting and paging
|
||
// themselves. This is that missing half.
|
||
//
|
||
// Create it ONCE, alongside your signals, never inside a render function:
|
||
//
|
||
// table := webui.NewAutoTableState(columns, webui.AutoTableStateOptions{
|
||
// PerPage: 25,
|
||
// })
|
||
// table.SetRows(rows)
|
||
//
|
||
// return func() *vdom.VNode {
|
||
// return table.Render(
|
||
// webui.AutoTableWithHover(),
|
||
// webui.AutoTableWithSearchFields(
|
||
// table.TextSearch("Name", "Search names…"),
|
||
// table.SelectSearch("Status", []string{"active", "inactive"}, "Any status"),
|
||
// ),
|
||
// )
|
||
// }
|
||
type AutoTableState struct {
|
||
cols []AutoTableColumn
|
||
read FieldReader
|
||
opts AutoTableStateOptions
|
||
|
||
rows []any // the source rows, in local mode
|
||
|
||
search *vdom.Signal[[]AutoTableSearchEntry]
|
||
orderBy *vdom.Signal[AutoTableOrderBy]
|
||
page *vdom.Signal[int]
|
||
perPage *vdom.Signal[int]
|
||
expanded *vdom.Signal[map[string]bool]
|
||
filtersOpen *vdom.Signal[bool]
|
||
|
||
// Column layout (see autotable_columns.go).
|
||
order *vdom.Signal[[]string]
|
||
hidden *vdom.Signal[map[string]bool]
|
||
widths *vdom.Signal[map[string]float64]
|
||
dragKey *vdom.Signal[string]
|
||
dropKey *vdom.Signal[string]
|
||
|
||
// Calculated columns and summary rows are LIVE, not fixed at construction: the
|
||
// point of the editor is that a user builds them at runtime. Seeded from the
|
||
// options, then owned here.
|
||
calculated *vdom.Signal[[]UserCalculatedColumn]
|
||
summaries *vdom.Signal[[]UserSummaryRow]
|
||
editor *calcEditor // lazily built by CalculatedColumnEditor
|
||
resetMenu *Menu // lazily built by ResetMenu
|
||
|
||
// restored is false until the user's saved layout has been applied. It gates what
|
||
// is shown in the meantime — see RestoreLayout and pendingSkeleton.
|
||
restored *vdom.Signal[bool]
|
||
calcSeq int // generates IDs for user-created calc columns / summary rows
|
||
|
||
// thRefs memoizes header-cell refs, so a resize can measure them.
|
||
thRefs map[string]*vdom.Ref
|
||
|
||
// multiSelects memoizes the dropdown controllers (see multiSelect).
|
||
multiSelects map[string]*MultiSelect
|
||
|
||
// resolved by the last Render; kept so callers (export, toolbar actions) can ask
|
||
// what the current filter actually selected.
|
||
lastFiltered []any
|
||
lastPage AutoTablePagination
|
||
}
|
||
|
||
// AutoTableStateOptions configures the controller itself (as opposed to the
|
||
// table's appearance, which is still the AutoTableOption functional options).
|
||
type AutoTableStateOptions struct {
|
||
// PerPage is the initial page size. Zero means 25 (the TSX default);
|
||
// PageSizeAll shows everything on one page.
|
||
PerPage int
|
||
|
||
// Read overrides how a field is pulled out of a row. Defaults to
|
||
// DefaultFieldReader (maps, struct fields, json tags).
|
||
Read FieldReader
|
||
|
||
// Accordion turns rows into expandable ones. RowKey must be stable for a row
|
||
// across renders — it is what remembers which rows are open. AccordionContent
|
||
// renders the expanded panel.
|
||
Accordion bool
|
||
AccordionSingle bool // only one row open at a time
|
||
RowKey func(row any) string
|
||
AccordionContent func(row any) *vdom.VNode
|
||
|
||
// HighlightMatch flags a row visually. If the matching row is on another page,
|
||
// the table jumps to it — the point being to lead the user to a record they
|
||
// searched for elsewhere.
|
||
HighlightMatch func(row any) bool
|
||
|
||
// OnFilterChange fires whenever search/sort/page changes — for a caller that
|
||
// wants to mirror the filter into the URL, or drive a remote fetch.
|
||
OnFilterChange func(AutoTableFilter)
|
||
|
||
// Columns turns on the column picker, drag-to-reorder, drag-to-resize, and
|
||
// persistence of all three.
|
||
Columns AutoTableColumnOptions
|
||
|
||
// Calculated adds columns whose values come from the formula engine — an
|
||
// aggregation over other columns, or an Excel-style expression. They are
|
||
// evaluated against the FILTERED, SORTED rows, so a running total re-runs when
|
||
// you filter or re-sort. See autotable_calc.go.
|
||
Calculated []UserCalculatedColumn
|
||
// SummaryRows adds a <tfoot> line per entry, each evaluated once over the whole
|
||
// filtered set (not just the current page).
|
||
SummaryRows []UserSummaryRow
|
||
}
|
||
|
||
// NewAutoTableState creates the controller.
|
||
func NewAutoTableState(cols []AutoTableColumn, o AutoTableStateOptions) *AutoTableState {
|
||
if o.PerPage == 0 {
|
||
o.PerPage = 25
|
||
}
|
||
if o.Read == nil {
|
||
o.Read = DefaultFieldReader
|
||
}
|
||
s := &AutoTableState{
|
||
cols: cols,
|
||
read: o.Read,
|
||
opts: o,
|
||
search: vdom.NewSignal([]AutoTableSearchEntry{}),
|
||
orderBy: vdom.NewSignal(AutoTableOrderBy{}),
|
||
page: vdom.NewSignal(1),
|
||
perPage: vdom.NewSignal(o.PerPage),
|
||
expanded: vdom.NewSignal(map[string]bool{}),
|
||
filtersOpen: vdom.NewSignal(false),
|
||
order: vdom.NewSignal[[]string](nil),
|
||
hidden: vdom.NewSignal(map[string]bool{}),
|
||
widths: vdom.NewSignal(map[string]float64{}),
|
||
dragKey: vdom.NewSignal(""),
|
||
dropKey: vdom.NewSignal(""),
|
||
calculated: vdom.NewSignal(o.Calculated),
|
||
summaries: vdom.NewSignal(o.SummaryRows),
|
||
// Settled from the start unless a saved layout could still arrive and move
|
||
// things. A table with no StorageKey has no personal layout at all — it renders
|
||
// fully, server-side, exactly as declared. So does one that has opted out of
|
||
// waiting (ShowWhileRestoring).
|
||
restored: vdom.NewSignal(o.Columns.StorageKey == "" || o.Columns.ShowWhileRestoring),
|
||
}
|
||
s.hidden.Set(s.defaultHidden())
|
||
|
||
// The layout lives in localStorage, which the server cannot see. So the server
|
||
// renders the DECLARED layout, and the client can only apply the personal one on
|
||
// its first commit — see RestoreLayout, and `restored` for what the user sees in
|
||
// between.
|
||
wasmruntime.AfterRender(s.RestoreLayout)
|
||
return s
|
||
}
|
||
|
||
// SetRows replaces the source data. Call it when your data signal changes.
|
||
func (s *AutoTableState) SetRows(rows []any) { s.rows = rows }
|
||
|
||
// Columns returns the declared columns.
|
||
func (s *AutoTableState) Columns() []AutoTableColumn { return s.cols }
|
||
|
||
// Filter is the current query state — what to pass to a server in remote mode
|
||
// (see BuildQueryString), or to persist.
|
||
func (s *AutoTableState) Filter() AutoTableFilter {
|
||
return AutoTableFilter{
|
||
Search: s.search.Get(),
|
||
OrderBy: s.orderBy.Get(),
|
||
Pagination: AutoTablePagination{
|
||
CurrentPage: s.page.Get(),
|
||
MaxItemsPerPage: s.perPage.Get(),
|
||
},
|
||
}
|
||
}
|
||
|
||
// FilteredRows is every row matching the current filter, across all pages — what
|
||
// an export writes, and what a summary row totals. Valid after Render.
|
||
func (s *AutoTableState) FilteredRows() []any { return s.lastFiltered }
|
||
|
||
func (s *AutoTableState) changed() {
|
||
if s.opts.OnFilterChange != nil {
|
||
s.opts.OnFilterChange(s.Filter())
|
||
}
|
||
}
|
||
|
||
// ---- search ----
|
||
|
||
// SearchValue reads the single value for an identifier (the text in that box).
|
||
func (s *AutoTableState) SearchValue(identifier string) string {
|
||
for _, e := range s.search.Get() {
|
||
if e.Identifier == identifier && len(e.Values) > 0 {
|
||
return e.Values[0]
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// SearchValues reads all values for an identifier (a multi-select's selection).
|
||
func (s *AutoTableState) SearchValues(identifier string) []string {
|
||
for _, e := range s.search.Get() {
|
||
if e.Identifier == identifier {
|
||
return e.Values
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// SetSearchValue sets a single-value filter. An empty value REMOVES the entry
|
||
// rather than storing a blank one, so "cleared the box" and "never typed in it"
|
||
// are the same state — which is what makes ActiveFilterCount honest.
|
||
func (s *AutoTableState) SetSearchValue(identifier, value string, exact bool) {
|
||
if strings.TrimSpace(value) == "" {
|
||
s.SetSearchValues(identifier, nil, exact)
|
||
return
|
||
}
|
||
s.SetSearchValues(identifier, []string{value}, exact)
|
||
}
|
||
|
||
// SetSearchValues sets a multi-value (IN-set) filter. Empty values remove it.
|
||
func (s *AutoTableState) SetSearchValues(identifier string, values []string, exact bool) {
|
||
next := make([]AutoTableSearchEntry, 0, len(s.search.Get())+1)
|
||
for _, e := range s.search.Get() {
|
||
if e.Identifier != identifier {
|
||
next = append(next, e)
|
||
}
|
||
}
|
||
if len(values) > 0 {
|
||
next = append(next, AutoTableSearchEntry{Identifier: identifier, Values: values, Exact: exact})
|
||
}
|
||
s.search.Set(next)
|
||
s.page.Set(1) // a new filter invalidates the page you were on
|
||
s.clearExpanded()
|
||
s.changed()
|
||
}
|
||
|
||
// ActiveFilterCount is how many filters are actually doing something — the number
|
||
// on the badge next to the Filters button on mobile.
|
||
func (s *AutoTableState) ActiveFilterCount() int {
|
||
n := 0
|
||
for _, e := range s.search.Get() {
|
||
for _, v := range e.Values {
|
||
if strings.TrimSpace(v) != "" {
|
||
n++
|
||
break
|
||
}
|
||
}
|
||
}
|
||
return n
|
||
}
|
||
|
||
// ClearFilters drops every search entry.
|
||
func (s *AutoTableState) ClearFilters() {
|
||
s.search.Set(nil)
|
||
s.page.Set(1)
|
||
s.clearExpanded()
|
||
s.changed()
|
||
}
|
||
|
||
// FiltersOpen / ToggleFilters drive the collapsible filter panel on narrow screens.
|
||
func (s *AutoTableState) FiltersOpen() bool { return s.filtersOpen.Get() }
|
||
func (s *AutoTableState) ToggleFilters() { s.filtersOpen.Set(!s.filtersOpen.Get()) }
|
||
|
||
// ---- sort ----
|
||
|
||
// ToggleSort cycles a column's sort: unsorted -> ascending -> descending. Sorting
|
||
// resets to page 1 and collapses expanded rows, both of which would otherwise be
|
||
// pointing at rows that just moved.
|
||
func (s *AutoTableState) ToggleSort(identifier string) {
|
||
cur := s.orderBy.Get()
|
||
if cur.Identifier == identifier {
|
||
s.orderBy.Set(AutoTableOrderBy{Identifier: identifier, Descending: !cur.Descending})
|
||
} else {
|
||
s.orderBy.Set(AutoTableOrderBy{Identifier: identifier})
|
||
}
|
||
s.page.Set(1)
|
||
s.clearExpanded()
|
||
s.changed()
|
||
}
|
||
|
||
// OrderBy is the active sort.
|
||
func (s *AutoTableState) OrderBy() AutoTableOrderBy { return s.orderBy.Get() }
|
||
|
||
// ---- pagination ----
|
||
|
||
func (s *AutoTableState) SetPage(n int) {
|
||
s.page.Set(max(n, 1))
|
||
s.clearExpanded()
|
||
s.changed()
|
||
}
|
||
|
||
func (s *AutoTableState) SetPerPage(n int) {
|
||
s.perPage.Set(n)
|
||
s.page.Set(1)
|
||
s.clearExpanded()
|
||
s.changed()
|
||
}
|
||
|
||
// ---- expansion ----
|
||
|
||
func (s *AutoTableState) rowKey(row any, idx int) string {
|
||
if s.opts.RowKey != nil {
|
||
return s.opts.RowKey(row)
|
||
}
|
||
return strconv.Itoa(idx)
|
||
}
|
||
|
||
// IsExpanded reports whether a row's accordion panel is open.
|
||
func (s *AutoTableState) IsExpanded(key string) bool { return s.expanded.Get()[key] }
|
||
|
||
// ToggleExpanded opens or closes a row's panel.
|
||
func (s *AutoTableState) ToggleExpanded(key string) {
|
||
cur := s.expanded.Get()
|
||
next := map[string]bool{}
|
||
if !s.opts.AccordionSingle {
|
||
for k, v := range cur {
|
||
next[k] = v
|
||
}
|
||
}
|
||
if cur[key] {
|
||
delete(next, key)
|
||
} else {
|
||
next[key] = true
|
||
}
|
||
s.expanded.Set(next)
|
||
}
|
||
|
||
func (s *AutoTableState) clearExpanded() {
|
||
if len(s.expanded.Get()) > 0 {
|
||
s.expanded.Set(map[string]bool{})
|
||
}
|
||
}
|
||
|
||
// ---- the pipeline ----
|
||
|
||
// Process runs filter -> sort -> paginate against the current state, and jumps to
|
||
// the page holding the highlighted row if it is not on the current one.
|
||
func (s *AutoTableState) Process() (page []any, pagination AutoTablePagination) {
|
||
filtered := ApplySearchFilters(s.rows, s.search.Get(), s.read)
|
||
|
||
order := s.orderBy.Get()
|
||
if id, ok := strings.CutPrefix(order.Identifier, CalcRefPrefix); ok && len(s.Calculated()) > 0 {
|
||
// Sorting by a calculated column: its value is not in the row, so evaluate the
|
||
// column first and permute the rows to match (see sortByCalc).
|
||
_, values := s.evalCalcColumns(filtered)
|
||
if vals, ok := values[id]; ok {
|
||
filtered = sortByCalc(filtered, vals, order.Descending)
|
||
}
|
||
} else {
|
||
filtered = SortRows(filtered, order, s.cols, s.read)
|
||
}
|
||
|
||
// A highlighted row the user cannot see is useless — if the match landed on
|
||
// another page, go there. Done before slicing, on the sorted+filtered set, so
|
||
// the index is the one paging actually uses.
|
||
if s.opts.HighlightMatch != nil {
|
||
s.jumpToHighlight(filtered)
|
||
}
|
||
|
||
page, pagination = Paginate(filtered, AutoTablePagination{
|
||
CurrentPage: s.page.Get(),
|
||
MaxItemsPerPage: s.perPage.Get(),
|
||
})
|
||
s.lastFiltered = filtered
|
||
s.lastPage = pagination
|
||
return page, pagination
|
||
}
|
||
|
||
func (s *AutoTableState) jumpToHighlight(filtered []any) {
|
||
perPage := s.perPage.Get()
|
||
if perPage == PageSizeAll || perPage <= 0 {
|
||
return
|
||
}
|
||
for i, row := range filtered {
|
||
if !s.opts.HighlightMatch(row) {
|
||
continue
|
||
}
|
||
want := i/perPage + 1
|
||
if want != s.page.Get() {
|
||
s.page.Set(want)
|
||
}
|
||
return
|
||
}
|
||
}
|
||
|
||
// Render runs the pipeline and renders the table, with sort, pagination, accordion
|
||
// and highlighting already wired to this controller. Pass the usual AutoTableOption
|
||
// values for appearance; the state-driven ones are supplied here.
|
||
func (s *AutoTableState) Render(opts ...AutoTableOption) *vdom.VNode {
|
||
// The user's saved layout has not landed yet — which, on a server-rendered page,
|
||
// means the server does not know it either (it cannot read localStorage). Rendering
|
||
// the real table here would render the DEFAULT one, and the user would then watch
|
||
// their columns rearrange themselves once the wasm booted.
|
||
//
|
||
// So show a skeleton instead, and reveal the table once the layout is settled. The
|
||
// wait is the same; what they never see is the wrong table.
|
||
//
|
||
// It costs nothing on a client-rendered page: RestoreLayout runs on the first
|
||
// commit, before the browser has painted, so the skeleton is never actually seen.
|
||
if !s.restored.Get() {
|
||
return s.pendingSkeleton()
|
||
}
|
||
|
||
page, pagination := s.Process()
|
||
|
||
wired := []AutoTableOption{
|
||
AutoTableWithSort(s.orderBy.Get().Identifier, s.orderBy.Get().Descending, s.ToggleSort),
|
||
AutoTableWithPagination(&pagination, s.SetPage, s.SetPerPage),
|
||
AutoTableWithFiltersToggle(s.FiltersToggle(), s.FiltersOpen()),
|
||
AutoTableWithResetMenu(s.ResetMenu()),
|
||
}
|
||
if s.opts.Accordion {
|
||
wired = append(wired, AutoTableWithAccordion(
|
||
s.rowKey, s.IsExpanded, s.ToggleExpanded, s.opts.AccordionContent,
|
||
))
|
||
}
|
||
if s.opts.HighlightMatch != nil {
|
||
wired = append(wired, AutoTableWithHighlight(s.opts.HighlightMatch))
|
||
}
|
||
if h := s.columnHooks(); h != nil {
|
||
wired = append(wired, atWithColumnHooks(h))
|
||
}
|
||
|
||
cols := s.VisibleColumns()
|
||
|
||
if len(s.Calculated()) > 0 || len(s.SummaryRows()) > 0 {
|
||
// Evaluated against the FINAL row order, so ROW() and running totals count
|
||
// down the screen the way the user reads them.
|
||
ctx, values := s.evalCalcColumns(s.lastFiltered)
|
||
cols = append(cols, s.calcColumns(ctx, values)...)
|
||
|
||
if foot := s.summaryFoot(ctx, atTotalColumnsFor(cols, s.opts.Accordion)); foot != nil {
|
||
wired = append(wired, atWithFoot(foot))
|
||
}
|
||
// The page's first row is not row 0 of the filtered set — a running total on
|
||
// page 3 has to keep counting from where page 2 left off.
|
||
wired = append(wired, atWithRowOffset(max(pagination.ViewRangeLower-1, 0)))
|
||
}
|
||
|
||
// Caller options go last so they can override anything above.
|
||
return AutoTable(cols, page, append(wired, opts...)...)
|
||
}
|
||
|
||
// atTotalColumnsFor mirrors atTotalColumns without needing a built atConfig.
|
||
func atTotalColumnsFor(cols []AutoTableColumn, accordion bool) int {
|
||
n := max(len(cols), 1)
|
||
if accordion {
|
||
n++
|
||
}
|
||
return n
|
||
}
|
||
|
||
// ---- search field components ----
|
||
|
||
// TextSearch renders a search box bound to a field. Typing filters by substring.
|
||
func (s *AutoTableState) TextSearch(identifier, placeholder string) *vdom.VNode {
|
||
return vdom.Div(vdom.Attr("class", AUTOTABLE_SEARCH_FIELD),
|
||
FormInput(FormInputProps{
|
||
Value: s.SearchValue(identifier),
|
||
Placeholder: placeholder,
|
||
OnInput: func(v string) { s.SetSearchValue(identifier, v, false) },
|
||
}),
|
||
)
|
||
}
|
||
|
||
// GlobalSearch renders one box that searches across several fields at once.
|
||
func (s *AutoTableState) GlobalSearch(placeholder string, fields ...string) *vdom.VNode {
|
||
return s.TextSearch(MultiSearchIdentifier(fields...), placeholder)
|
||
}
|
||
|
||
// SelectSearch renders a dropdown that filters by exact value. The empty option
|
||
// clears the filter — which is why SetSearchValue treats a blank as "remove the
|
||
// entry" rather than "match the empty string".
|
||
func (s *AutoTableState) SelectSearch(identifier string, values []string, anyLabel string) *vdom.VNode {
|
||
options := []*vdom.VNode{FormOption("", pick(anyLabel, "All"), false)}
|
||
for _, v := range values {
|
||
options = append(options, FormOption(v, v, false))
|
||
}
|
||
sel := FormSelect(FormSelectProps{
|
||
Value: s.SearchValue(identifier),
|
||
OnChange: func(v string) { s.SetSearchValue(identifier, v, true) },
|
||
}, options...)
|
||
|
||
return vdom.Div(vdom.Attr("class", AUTOTABLE_SEARCH_FIELD), sel)
|
||
}
|
||
|
||
// MultiSelectSearch filters by an IN-set: a row matches if its value is any of the
|
||
// selected ones. This is the entry shape with several Values — and the reason
|
||
// ApplySearchFilters treats a multi-value entry as exact rather than substring
|
||
// (a substring test across a set would match far too much).
|
||
func (s *AutoTableState) MultiSelectSearch(identifier, placeholder string, values []string) *vdom.VNode {
|
||
options := make([]FormSelectOption, 0, len(values))
|
||
for _, v := range values {
|
||
options = append(options, FormSelectOption{Value: v, Label: v})
|
||
}
|
||
return vdom.Div(vdom.Attr("class", AUTOTABLE_SEARCH_FIELD),
|
||
s.multiSelect(identifier).Render(FormMultiSelectProps{
|
||
Options: options,
|
||
Value: s.SearchValues(identifier),
|
||
Placeholder: pick(placeholder, "Any"),
|
||
Searchable: true,
|
||
ShowSelectAll: true,
|
||
OnChange: func(vs []string) { s.SetSearchValues(identifier, vs, true) },
|
||
}),
|
||
)
|
||
}
|
||
|
||
// multiSelect memoizes a dropdown controller, keyed by identifier.
|
||
//
|
||
// It MUST be memoized. A controller built inside a render would get fresh refs and a
|
||
// fresh closed state every frame — the dropdown could never stay open, and the
|
||
// outside-click listener would be watching an element that no longer exists.
|
||
func (s *AutoTableState) multiSelect(key string) *MultiSelect {
|
||
if s.multiSelects == nil {
|
||
s.multiSelects = map[string]*MultiSelect{}
|
||
}
|
||
ms, ok := s.multiSelects[key]
|
||
if !ok {
|
||
ms = NewMultiSelect(DropdownOptions{})
|
||
s.multiSelects[key] = ms
|
||
}
|
||
return ms
|
||
}
|
||
|
||
// DateSearch renders a date field bound to an identifier. A date RANGE is two of
|
||
// these under two identifiers (e.g. "hired_from" and "hired_to") — which is how
|
||
// the TSX does it, resolved server-side.
|
||
func (s *AutoTableState) DateSearch(identifier, label string) *vdom.VNode {
|
||
return vdom.Div(vdom.Attr("class", AUTOTABLE_SEARCH_FIELD),
|
||
FormInput(FormInputProps{
|
||
Type: "date",
|
||
Value: s.SearchValue(identifier),
|
||
Placeholder: label,
|
||
OnInput: func(v string) { s.SetSearchValue(identifier, v, false) },
|
||
}),
|
||
)
|
||
}
|
||
|
||
// FiltersToggle is the button that reveals the filter panel on narrow screens. It
|
||
// carries a badge with the number of filters currently doing something, so a user
|
||
// who scrolled past a collapsed panel can still see that the table is filtered.
|
||
func (s *AutoTableState) FiltersToggle() *vdom.VNode {
|
||
mods := []vdom.Mod{
|
||
vdom.Attr("type", "button"),
|
||
vdom.Attr("class", AUTOTABLE_FILTERS_TOGGLE),
|
||
vdom.On(vdom.EVENT_CLICK, s.ToggleFilters),
|
||
Icon("filter", 16, ""),
|
||
vdom.Span(vdom.Text("Filters")),
|
||
}
|
||
if n := s.ActiveFilterCount(); n > 0 {
|
||
mods = append(mods, vdom.Span(vdom.Attr("class", AUTOTABLE_FILTERS_BADGE),
|
||
vdom.Text(strconv.Itoa(n)),
|
||
))
|
||
}
|
||
return vdom.Button(mods...)
|
||
}
|
||
|
||
// Tailwind for the search/filter chrome, copied from AutoTable.tsx.
|
||
const (
|
||
AUTOTABLE_SEARCH_FIELDS = "flex flex-wrap items-end gap-2"
|
||
AUTOTABLE_SEARCH_FIELD = "flex flex-col gap-1 min-w-0 grow sm:grow-0 sm:w-48"
|
||
AUTOTABLE_SEARCH_SELECT = "w-full rounded-default border border-neutral-300 bg-white px-3 py-2 text-sm"
|
||
AUTOTABLE_FILTERS_TOGGLE = "sm:hidden inline-flex items-center gap-2 rounded-default border border-neutral-300 px-3 py-2 text-sm"
|
||
AUTOTABLE_FILTERS_BADGE = "ml-1 inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-sky-600 px-1.5 text-xs font-semibold text-white"
|
||
AUTOTABLE_TOOLBAR = "flex flex-wrap items-end justify-between gap-3 pb-3"
|
||
AUTOTABLE_ASIDE = "w-full sm:w-64 shrink-0 rounded-default border border-neutral-300 bg-white p-3"
|
||
AUTOTABLE_ACCORDION_CELL = "w-10 text-center"
|
||
AUTOTABLE_ACCORDION_ROW = "bg-neutral-50"
|
||
AUTOTABLE_HIGHLIGHT_ROW = "bg-amber-100! shadow-[inset_3px_0_0_0_theme(colors.amber.500)]"
|
||
)
|
||
|
||
// ==========================================================================
|
||
// Column management: visibility, reorder, resize, persistence
|
||
// ==========================================================================
|
||
|
||
// Column management: which columns are shown, in what order, and how wide — plus
|
||
// remembering all three across reloads.
|
||
//
|
||
// This is the part of AutoTable that genuinely could not be ported before. Resizing
|
||
// needs to measure the real header cells (getBoundingClientRect), track the pointer
|
||
// through document-level mousemove/mouseup, and read event.clientX; reordering needs
|
||
// HTML5 drag events and a DataTransfer; and remembering any of it needs
|
||
// localStorage. None of that existed in the neutral runtime until now.
|
||
//
|
||
// The one design decision worth calling out: while a resize drag is IN FLIGHT, the
|
||
// widths are written straight to the DOM with SetStyle. They are only committed to
|
||
// a signal (and to storage) on mouseup. Driving the drag through a signal would
|
||
// re-render the entire application on every mousemove frame.
|
||
|
||
// MinColumnWidth is the narrowest a column can be dragged, in px.
|
||
const MinColumnWidth = 56
|
||
|
||
// AutoTableColumnOptions turns on column management. It is part of
|
||
// AutoTableStateOptions.
|
||
type AutoTableColumnOptions struct {
|
||
// Toggleable shows a column picker; a column must also set Toggleable itself to
|
||
// appear in it (so a table can pin its identifying column on).
|
||
Toggleable bool
|
||
// Draggable lets the user reorder columns by dragging their headers.
|
||
Draggable bool
|
||
// Resizable puts a drag handle on each header's right edge.
|
||
Resizable bool
|
||
|
||
// StorageKey persists the layout under a key derived from it. Empty means do not
|
||
// persist — and a table that does not persist has nothing that can arrive late, so
|
||
// it renders in full, server-side, exactly as declared. Every column must have a
|
||
// stable Key for persistence to mean anything; an index-based key would scramble
|
||
// the moment the columns are reordered.
|
||
StorageKey string
|
||
|
||
// ShowWhileRestoring renders the DECLARED table immediately, instead of holding a
|
||
// skeleton until the user's saved layout has been read.
|
||
//
|
||
// The trade it makes: the saved layout lives in localStorage, which the server
|
||
// cannot read, so a server-rendered table can either show the declared layout at
|
||
// once (and rearrange itself when the client applies the personal one — the flash)
|
||
// or show nothing until it knows (a skeleton, then the right table). The default is
|
||
// the second. Set this to choose the first — worth it when the page is public, or
|
||
// SEO matters, or the layout rarely differs enough to be jarring.
|
||
//
|
||
// It changes nothing for a table with no StorageKey: that one has no personal
|
||
// layout to wait for, and always renders in full.
|
||
ShowWhileRestoring bool
|
||
}
|
||
|
||
// ---- keys ----
|
||
|
||
// ColumnKey identifies a column for ordering, hiding and sizing. It falls back to
|
||
// the index, which is fine for a table that never persists or reorders and wrong
|
||
// for one that does — hence the warning on AutoTableColumn.Key.
|
||
func ColumnKey(col AutoTableColumn, i int) string {
|
||
if col.Key != "" {
|
||
return col.Key
|
||
}
|
||
return strconv.Itoa(i)
|
||
}
|
||
|
||
func (s *AutoTableState) columnKey(col AutoTableColumn, i int) string { return ColumnKey(col, i) }
|
||
|
||
// thRef memoizes a header cell's ref. Memoized because a resize has to measure the
|
||
// cells, and a ref rebuilt each render would never be attached to anything by the
|
||
// time the drag reads it.
|
||
func (s *AutoTableState) thRef(key string) *vdom.Ref {
|
||
if s.thRefs == nil {
|
||
s.thRefs = map[string]*vdom.Ref{}
|
||
}
|
||
r, ok := s.thRefs[key]
|
||
if !ok {
|
||
r = vdom.NewRef()
|
||
s.thRefs[key] = r
|
||
}
|
||
return r
|
||
}
|
||
|
||
// ---- visible columns ----
|
||
|
||
// VisibleColumns applies the user's order and hides what they have hidden. It is
|
||
// what Render actually draws, and what an export writes.
|
||
func (s *AutoTableState) VisibleColumns() []AutoTableColumn {
|
||
hidden := s.hidden.Get()
|
||
order := s.order.Get()
|
||
|
||
byKey := make(map[string]AutoTableColumn, len(s.cols))
|
||
for i, c := range s.cols {
|
||
byKey[s.columnKey(c, i)] = c
|
||
}
|
||
|
||
out := make([]AutoTableColumn, 0, len(s.cols))
|
||
seen := map[string]bool{}
|
||
// The saved order first — but only for columns that still exist. A persisted
|
||
// order outlives the code that produced it, so a column that has since been
|
||
// deleted must not resurrect, and one that has since been ADDED must still show
|
||
// up (below), rather than silently vanishing because it is not in the old list.
|
||
for _, k := range order {
|
||
if c, ok := byKey[k]; ok && !hidden[k] && !seen[k] {
|
||
out = append(out, c)
|
||
seen[k] = true
|
||
}
|
||
}
|
||
for i, c := range s.cols {
|
||
k := s.columnKey(c, i)
|
||
if seen[k] || hidden[k] {
|
||
continue
|
||
}
|
||
out = append(out, c)
|
||
seen[k] = true
|
||
}
|
||
return out
|
||
}
|
||
|
||
// HiddenColumns reports which columns are currently hidden.
|
||
func (s *AutoTableState) HiddenColumns() map[string]bool { return s.hidden.Get() }
|
||
|
||
// ToggleColumn shows or hides a column. A column with Toggleable false cannot be
|
||
// hidden — it is the table's identifying column and hiding it would leave rows
|
||
// unrecognisable.
|
||
func (s *AutoTableState) ToggleColumn(key string) {
|
||
for i, c := range s.cols {
|
||
if s.columnKey(c, i) == key && !c.Toggleable {
|
||
return
|
||
}
|
||
}
|
||
next := map[string]bool{}
|
||
for k, v := range s.hidden.Get() {
|
||
next[k] = v
|
||
}
|
||
if next[key] {
|
||
delete(next, key)
|
||
} else {
|
||
next[key] = true
|
||
}
|
||
s.hidden.Set(next)
|
||
s.persistColumns()
|
||
}
|
||
|
||
// ---- reordering ----
|
||
|
||
// MoveColumn moves the column with key `from` to the position of `to`.
|
||
func (s *AutoTableState) MoveColumn(from, to string) {
|
||
if from == to || from == "" || to == "" {
|
||
return
|
||
}
|
||
order := s.currentOrder()
|
||
|
||
fromIdx, toIdx := indexOf(order, from), indexOf(order, to)
|
||
if fromIdx < 0 || toIdx < 0 {
|
||
return
|
||
}
|
||
|
||
moved := append(order[:fromIdx:fromIdx], order[fromIdx+1:]...)
|
||
|
||
// Where the target sits once the dragged column has been pulled out — removing it
|
||
// shifts everything to its right left by one, so the pre-removal index is stale.
|
||
insertAt := indexOf(moved, to)
|
||
// Dragging rightward means dropping AFTER the target; dragging leftward, before
|
||
// it. Compare the ORIGINAL indices: comparing against the recomputed one makes an
|
||
// adjacent forward move a no-op.
|
||
if fromIdx < toIdx {
|
||
insertAt++
|
||
}
|
||
|
||
next := make([]string, 0, len(order))
|
||
next = append(next, moved[:insertAt]...)
|
||
next = append(next, from)
|
||
next = append(next, moved[insertAt:]...)
|
||
|
||
s.order.Set(next)
|
||
s.persistColumns()
|
||
}
|
||
|
||
// currentOrder is the saved order, backfilled with any columns it does not mention
|
||
// (newly added ones).
|
||
func (s *AutoTableState) currentOrder() []string {
|
||
saved := s.order.Get()
|
||
out := make([]string, 0, len(s.cols))
|
||
seen := map[string]bool{}
|
||
for _, k := range saved {
|
||
for i, c := range s.cols {
|
||
if s.columnKey(c, i) == k && !seen[k] {
|
||
out = append(out, k)
|
||
seen[k] = true
|
||
}
|
||
}
|
||
}
|
||
for i, c := range s.cols {
|
||
if k := s.columnKey(c, i); !seen[k] {
|
||
out = append(out, k)
|
||
seen[k] = true
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func indexOf(xs []string, x string) int {
|
||
for i, v := range xs {
|
||
if v == x {
|
||
return i
|
||
}
|
||
}
|
||
return -1
|
||
}
|
||
|
||
// ---- resizing ----
|
||
|
||
// ColumnWidth is a column's user-set width in px, or 0 when it has never been
|
||
// resized (in which case its Tailwind WidthClass applies).
|
||
func (s *AutoTableState) ColumnWidth(key string) float64 { return s.widths.Get()[key] }
|
||
|
||
// beginResize starts a drag on a column's right edge. It measures every header cell
|
||
// up front (widths must be known in px before they can be traded), then tracks the
|
||
// pointer at the document level — the cursor leaves the 4px handle immediately, so
|
||
// listening on the handle itself would drop the drag the moment it started.
|
||
func (s *AutoTableState) beginResize(key string, startX int) {
|
||
visible := s.VisibleColumns()
|
||
idx := -1
|
||
for i, c := range visible {
|
||
if s.columnKey(c, i) == key {
|
||
idx = i
|
||
break
|
||
}
|
||
}
|
||
if idx < 0 || idx+1 >= len(visible) {
|
||
return // the last column has no neighbour to trade width with
|
||
}
|
||
neighbourKey := s.columnKey(visible[idx+1], idx+1)
|
||
|
||
selfRef, neighbourRef := s.thRef(key), s.thRef(neighbourKey)
|
||
startSelf := wasmruntime.Measure(selfRef).Width
|
||
startNeighbour := wasmruntime.Measure(neighbourRef).Width
|
||
if startSelf == 0 || startNeighbour == 0 {
|
||
return // not laid out (or we are on the server); nothing to resize
|
||
}
|
||
|
||
var stopMove, stopUp wasmruntime.Unsub
|
||
finish := func() {
|
||
if stopMove != nil {
|
||
stopMove()
|
||
}
|
||
if stopUp != nil {
|
||
stopUp()
|
||
}
|
||
}
|
||
|
||
// Live: write straight to the DOM. Committing to a signal here would re-render
|
||
// the whole app on every mousemove.
|
||
var selfW, neighbourW float64
|
||
stopMove = wasmruntime.OnDocument(vdom.EVENT_MOUSEMOVE, false, func(e vdom.Event) {
|
||
delta := float64(e.ClientX() - startX)
|
||
// Clamp so neither side goes below the minimum. The pair's total is preserved,
|
||
// so the table's overall width never changes mid-drag.
|
||
delta = clamp(delta, MinColumnWidth-startSelf, startNeighbour-MinColumnWidth)
|
||
selfW = startSelf + delta
|
||
neighbourW = startNeighbour - delta
|
||
wasmruntime.SetStyle(selfRef, "width", px(selfW))
|
||
wasmruntime.SetStyle(neighbourRef, "width", px(neighbourW))
|
||
})
|
||
|
||
// Commit once, on release: one re-render, one write to storage.
|
||
stopUp = wasmruntime.OnDocument(vdom.EVENT_MOUSEUP, false, func(vdom.Event) {
|
||
finish()
|
||
if selfW == 0 {
|
||
return // a click with no movement
|
||
}
|
||
next := map[string]float64{}
|
||
for k, v := range s.widths.Get() {
|
||
next[k] = v
|
||
}
|
||
next[key] = selfW
|
||
next[neighbourKey] = neighbourW
|
||
s.widths.Set(next)
|
||
s.persistColumns()
|
||
})
|
||
}
|
||
|
||
// ResetColumns restores the declared order, visibility and widths, and forgets the
|
||
// persisted ones.
|
||
func (s *AutoTableState) ResetColumns() {
|
||
s.order.Set(nil)
|
||
s.hidden.Set(s.defaultHidden())
|
||
s.widths.Set(map[string]float64{})
|
||
s.persistColumns()
|
||
}
|
||
|
||
// The reset actions, one per thing a user can customise. They are separate because
|
||
// they are separate decisions: someone who dragged a column three seats to the left
|
||
// and then wrote a formula wants to undo one of those, not both.
|
||
|
||
// ResetColumnOrder restores the declared column order.
|
||
func (s *AutoTableState) ResetColumnOrder() {
|
||
s.order.Set(nil)
|
||
s.persistColumns()
|
||
}
|
||
|
||
// ResetColumnWidths drops every dragged width, so columns size themselves again.
|
||
func (s *AutoTableState) ResetColumnWidths() {
|
||
s.widths.Set(map[string]float64{})
|
||
s.persistColumns()
|
||
}
|
||
|
||
// ResetColumnVisibility shows every toggleable column again (minus the ones declared
|
||
// HiddenByDefault, which are part of the default, not a user choice).
|
||
func (s *AutoTableState) ResetColumnVisibility() {
|
||
s.hidden.Set(s.defaultHidden())
|
||
s.persistColumns()
|
||
}
|
||
|
||
// ResetCalculated deletes every calculated column, and clears the sort if it pointed
|
||
// at one — a sort naming a column that no longer exists silently stops sorting.
|
||
func (s *AutoTableState) ResetCalculated() {
|
||
if strings.HasPrefix(s.orderBy.Get().Identifier, CalcRefPrefix) {
|
||
s.orderBy.Set(AutoTableOrderBy{})
|
||
}
|
||
s.calculated.Set(nil)
|
||
s.persistColumns()
|
||
}
|
||
|
||
// ResetSummaryRows deletes every footer row.
|
||
func (s *AutoTableState) ResetSummaryRows() {
|
||
s.summaries.Set(nil)
|
||
s.persistColumns()
|
||
}
|
||
|
||
// ResetAll restores everything the user has customised — layout and formulas both.
|
||
func (s *AutoTableState) ResetAll() {
|
||
if strings.HasPrefix(s.orderBy.Get().Identifier, CalcRefPrefix) {
|
||
s.orderBy.Set(AutoTableOrderBy{})
|
||
}
|
||
s.order.Set(nil)
|
||
s.hidden.Set(s.defaultHidden())
|
||
s.widths.Set(map[string]float64{})
|
||
s.calculated.Set(nil)
|
||
s.summaries.Set(nil)
|
||
s.persistColumns()
|
||
}
|
||
|
||
func (s *AutoTableState) defaultHidden() map[string]bool {
|
||
out := map[string]bool{}
|
||
for i, c := range s.cols {
|
||
if c.HiddenByDefault {
|
||
out[s.columnKey(c, i)] = true
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// ---- persistence ----
|
||
|
||
type persistedColumns struct {
|
||
Order []string `json:"order,omitempty"`
|
||
Hidden map[string]bool `json:"hidden,omitempty"`
|
||
Widths map[string]float64 `json:"widths,omitempty"`
|
||
// Columns a user BUILT are worth keeping more than any of the above — losing a
|
||
// formula they wrote is losing work, not just a preference.
|
||
Calculated []UserCalculatedColumn `json:"calculated,omitempty"`
|
||
Summaries []UserSummaryRow `json:"summaries,omitempty"`
|
||
// CalcSeq keeps generated IDs unique across reloads, so a restored calc column
|
||
// and a newly added one cannot collide.
|
||
CalcSeq int `json:"calcSeq,omitempty"`
|
||
}
|
||
|
||
func (s *AutoTableState) storageKey() string {
|
||
if s.opts.Columns.StorageKey == "" {
|
||
return ""
|
||
}
|
||
return "autotable:" + s.opts.Columns.StorageKey
|
||
}
|
||
|
||
// LayoutSettled reports whether the user's saved layout has been applied. False means
|
||
// the table is still showing its skeleton.
|
||
func (s *AutoTableState) LayoutSettled() bool { return s.restored.Get() }
|
||
|
||
// pendingSkeleton stands in for the table until the saved layout has been applied.
|
||
//
|
||
// It is deliberately a SKELETON and not the real table dimmed or hidden: the whole
|
||
// point is that the user must not see column headers, orders or values that are about
|
||
// to change under them. A skeleton promises nothing, so it cannot lie.
|
||
//
|
||
// The bar widths vary a little so it reads as a table rather than a loading bar, and
|
||
// they are fixed rather than random — a random width would differ between the server's
|
||
// render and the client's, and hydration would have to correct every one of them.
|
||
func (s *AutoTableState) pendingSkeleton() *vdom.VNode {
|
||
widths := []string{"70%", "45%", "60%", "38%", "66%", "50%"}
|
||
|
||
bars := make([]*vdom.VNode, 0, len(widths)+1)
|
||
bars = append(bars, vdom.Div(
|
||
vdom.Attr("class", cx(atSkeleton, "h-6 mb-4")),
|
||
vdom.Attr("style", "width: 30%"),
|
||
))
|
||
for _, w := range widths {
|
||
bars = append(bars, vdom.Div(
|
||
vdom.Attr("class", atSkeleton),
|
||
vdom.Attr("style", "width: "+w),
|
||
))
|
||
}
|
||
|
||
return vdom.Div(
|
||
vdom.Attr("class", "min-w-0 w-full max-w-full"),
|
||
vdom.Attr("aria-busy", "true"),
|
||
vdom.Attr("aria-label", "Loading table"),
|
||
vdom.Div(
|
||
kids([]vdom.Mod{
|
||
vdom.Attr("class", cx(TBL_CONTAINER, "border border-neutral-300 p-4 flex flex-col gap-3")),
|
||
}, bars)...,
|
||
),
|
||
)
|
||
}
|
||
|
||
// LayoutJSON is the table's current personal layout — order, widths, hidden columns,
|
||
// and any calculated columns or summary rows the user built — as the JSON that gets
|
||
// persisted.
|
||
func (s *AutoTableState) LayoutJSON() string {
|
||
blob, err := json.Marshal(persistedColumns{
|
||
Order: s.order.Get(),
|
||
Hidden: s.hidden.Get(),
|
||
Widths: s.widths.Get(),
|
||
Calculated: s.calculated.Get(),
|
||
Summaries: s.summaries.Get(),
|
||
CalcSeq: s.calcSeq,
|
||
})
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
return string(blob)
|
||
}
|
||
|
||
func (s *AutoTableState) persistColumns() {
|
||
key := s.storageKey()
|
||
if key == "" {
|
||
return
|
||
}
|
||
blob := s.LayoutJSON()
|
||
if blob == "" {
|
||
return // a table forgetting its layout is not worth failing over
|
||
}
|
||
wasmruntime.StorageSet(key, blob)
|
||
}
|
||
|
||
// RestoreLayout reads the user's saved layout and applies it, then marks the table
|
||
// as settled so it can be shown.
|
||
//
|
||
// It runs automatically on the client's first commit. Call it directly only to say
|
||
// "there is nothing to restore" — a server, or a test, that wants the table rendered
|
||
// rather than its skeleton.
|
||
//
|
||
// # Why this cannot run before the first render, and what that costs
|
||
//
|
||
// The layout lives in localStorage, which the SERVER cannot read. So the server
|
||
// renders the DECLARED table. If the client read storage before its first render, it
|
||
// would disagree with the HTML it is hydrating and adopt the wrong nodes — so it must
|
||
// render the declared table too, and can only apply the personal one afterwards.
|
||
//
|
||
// That gap is unavoidable, and with a multi-megabyte wasm it is not a frame, it is
|
||
// seconds. What IS avoidable is showing the WRONG table across it: see `restored`,
|
||
// which holds a skeleton in place until this has run. The user waits, but they never
|
||
// watch their columns rearrange themselves.
|
||
func (s *AutoTableState) RestoreLayout() {
|
||
defer s.restored.Set(true) // settled either way: nothing to restore is still settled
|
||
|
||
key := s.storageKey()
|
||
if key == "" {
|
||
return
|
||
}
|
||
raw, ok := wasmruntime.StorageGet(key)
|
||
if !ok {
|
||
return
|
||
}
|
||
var p persistedColumns
|
||
if json.Unmarshal([]byte(raw), &p) != nil {
|
||
wasmruntime.StorageRemove(key) // corrupt, or an older shape; drop it rather than half-apply it
|
||
return
|
||
}
|
||
if p.Order != nil {
|
||
s.order.Set(p.Order)
|
||
}
|
||
if p.Hidden != nil {
|
||
s.hidden.Set(p.Hidden)
|
||
}
|
||
if p.Widths != nil {
|
||
s.widths.Set(p.Widths)
|
||
}
|
||
// A user's own calculated columns REPLACE the declared ones rather than merging:
|
||
// the editor's list is what they last saw, and silently re-adding a column they
|
||
// deleted would be worse than losing one they added.
|
||
if p.Calculated != nil {
|
||
s.calculated.Set(p.Calculated)
|
||
}
|
||
if p.Summaries != nil {
|
||
s.summaries.Set(p.Summaries)
|
||
}
|
||
if p.CalcSeq > s.calcSeq {
|
||
s.calcSeq = p.CalcSeq
|
||
}
|
||
}
|
||
|
||
// ---- the column picker ----
|
||
|
||
// ColumnPicker renders the show/hide control. Columns that are not Toggleable are
|
||
// omitted: they cannot be hidden, so offering them would be a lie.
|
||
func (s *AutoTableState) ColumnPicker() *vdom.VNode {
|
||
if !s.opts.Columns.Toggleable {
|
||
return nil
|
||
}
|
||
var options []FormSelectOption
|
||
var selected []string
|
||
for i, c := range s.cols {
|
||
if !c.Toggleable {
|
||
continue
|
||
}
|
||
k := s.columnKey(c, i)
|
||
options = append(options, FormSelectOption{Value: k, Label: c.DisplayName})
|
||
if !s.hidden.Get()[k] {
|
||
selected = append(selected, k)
|
||
}
|
||
}
|
||
if len(options) == 0 {
|
||
return nil
|
||
}
|
||
return s.multiSelect("__columns__").Render(FormMultiSelectProps{
|
||
Options: options,
|
||
Value: selected,
|
||
Placeholder: "Columns",
|
||
Searchable: true,
|
||
ShowSelectAll: true,
|
||
FieldWidth: "w-52",
|
||
OnChange: func(visible []string) {
|
||
show := map[string]bool{}
|
||
for _, k := range visible {
|
||
show[k] = true
|
||
}
|
||
next := map[string]bool{}
|
||
for _, o := range options {
|
||
if !show[o.Value] {
|
||
next[o.Value] = true
|
||
}
|
||
}
|
||
s.hidden.Set(next)
|
||
s.persistColumns()
|
||
},
|
||
})
|
||
}
|
||
|
||
// ResetColumnsButton restores the default layout.
|
||
// ResetMenu is the "Reset" control that sits at the bottom-left of the table, in the
|
||
// pagination bar. It opens UPWARD (it lives at the foot of the page) and offers one
|
||
// entry per thing the user can have customised, plus an Everything.
|
||
//
|
||
// Entries appear only when there is something to undo: offering "Reset column widths"
|
||
// to someone who has never dragged one is noise, and worse, it implies the table has
|
||
// state it does not have.
|
||
func (s *AutoTableState) ResetMenu() *vdom.VNode {
|
||
if s.resetMenu == nil {
|
||
s.resetMenu = NewMenu(MenuOptions{Placement: PlacementTopStart})
|
||
}
|
||
m := s.resetMenu
|
||
|
||
var items []*vdom.VNode
|
||
if len(s.order.Get()) > 0 {
|
||
items = append(items, m.Item(MenuItemProps{Icon: "grip-vertical", OnClick: s.ResetColumnOrder},
|
||
vdom.Text("Column order")))
|
||
}
|
||
if len(s.widths.Get()) > 0 {
|
||
items = append(items, m.Item(MenuItemProps{Icon: "arrow-right", OnClick: s.ResetColumnWidths},
|
||
vdom.Text("Column widths")))
|
||
}
|
||
if len(s.hidden.Get()) > 0 {
|
||
items = append(items, m.Item(MenuItemProps{Icon: "table-columns", OnClick: s.ResetColumnVisibility},
|
||
vdom.Text("Hidden columns")))
|
||
}
|
||
if len(s.Calculated()) > 0 {
|
||
items = append(items, m.Item(MenuItemProps{Icon: "calculator", OnClick: s.ResetCalculated},
|
||
vdom.Text("Calculated columns")))
|
||
}
|
||
if len(s.SummaryRows()) > 0 {
|
||
items = append(items, m.Item(MenuItemProps{Icon: "list-ol", OnClick: s.ResetSummaryRows},
|
||
vdom.Text("Summary rows")))
|
||
}
|
||
|
||
if len(items) == 0 {
|
||
items = append(items, vdom.Div(vdom.Attr("class", "px-3 py-2 text-sm text-neutral-500"),
|
||
vdom.Text("Nothing to reset"),
|
||
))
|
||
} else {
|
||
items = append(items,
|
||
MenuDivider(""),
|
||
m.Item(MenuItemProps{Icon: "trash", OnClick: s.ResetAll}, vdom.Text("Everything")),
|
||
)
|
||
}
|
||
|
||
return vdom.Div(vdom.Attr("class", "contents"),
|
||
m.TriggerFunc(MenuTriggerProps{Tag: "div"}, func(open bool) *vdom.VNode {
|
||
icon := "chevron-up"
|
||
if open {
|
||
icon = "chevron-down"
|
||
}
|
||
return Button(ButtonProps{Color: ButtonLightNeutral, Small: true, Text: "Reset",
|
||
Class: "gap-1"}, Icon(icon, 12, ""))
|
||
}),
|
||
m.Content("", items...),
|
||
)
|
||
}
|
||
|
||
// columnHooks bundles the state the header needs to render drag handles, resize
|
||
// handles and explicit widths, without AutoTable itself owning any of it.
|
||
func (s *AutoTableState) columnHooks() *atColumnHooks {
|
||
c := s.opts.Columns
|
||
if !c.Draggable && !c.Resizable && len(s.widths.Get()) == 0 {
|
||
return nil
|
||
}
|
||
return &atColumnHooks{
|
||
key: s.columnKey,
|
||
ref: s.thRef,
|
||
width: s.ColumnWidth,
|
||
draggable: c.Draggable,
|
||
resizable: c.Resizable,
|
||
dragging: s.dragKey.Get(),
|
||
dropTarget: s.dropKey.Get(),
|
||
|
||
onDragStart: func(k string) { s.dragKey.Set(k) },
|
||
onDragOver: func(k string) { s.dropKey.Set(k) },
|
||
onDrop: s.MoveColumn,
|
||
onDragEnd: func() { s.dragKey.Set(""); s.dropKey.Set("") },
|
||
onResizeStart: s.beginResize,
|
||
}
|
||
}
|
||
|
||
// ==========================================================================
|
||
// Calculated columns and summary rows
|
||
// ==========================================================================
|
||
|
||
// Wiring the formula engine (autotable_formula.go) into the table: user-defined
|
||
// calculated columns become real columns, and summary rows become a <tfoot>.
|
||
//
|
||
// The subtlety here is WHICH rows a formula sees, and in what order. A calculated
|
||
// column can reference the whole column ({Revenue}), a range of it
|
||
// ({Revenue:1:ROW()} — a running total), or the current row's position (ROW()). So
|
||
// its value depends on the row's index in the FILTERED, SORTED result set — not on
|
||
// the page you happen to be looking at, and not on the unfiltered data. Filter the
|
||
// table and a running total re-runs; sort it and it re-runs again. That is what the
|
||
// user means by a running total, and it is why cells are rendered through CellAt
|
||
// (which gets the absolute index) rather than Cell.
|
||
|
||
// atWithFoot supplies the <tfoot>.
|
||
func atWithFoot(foot *vdom.VNode) AutoTableOption {
|
||
return func(c *atConfig) { c.foot = foot }
|
||
}
|
||
|
||
// atWithRowOffset tells the body where the current page starts within the filtered
|
||
// set, so CellAt sees true row positions.
|
||
func atWithRowOffset(n int) AutoTableOption {
|
||
return func(c *atConfig) { c.rowOffset = n }
|
||
}
|
||
|
||
// calcColumns turns each UserCalculatedColumn into an AutoTableColumn whose cell
|
||
// evaluates the formula for that row.
|
||
//
|
||
// values is the pre-evaluated column, aligned to the sorted+filtered rows — every
|
||
// cell is computed once per render rather than once per cell, which matters because
|
||
// an aggregate formula ({Revenue}) walks the whole column each time it is asked.
|
||
func (s *AutoTableState) calcColumns(ctx *CalcContext, values map[string][]float64) []AutoTableColumn {
|
||
out := make([]AutoTableColumn, 0, len(s.Calculated()))
|
||
for _, uc := range s.Calculated() {
|
||
calc := uc
|
||
vals := values[calc.ID]
|
||
|
||
out = append(out, AutoTableColumn{
|
||
Key: CalcRef(calc.ID),
|
||
DisplayName: calc.DisplayName,
|
||
DisplayPosition: calc.DisplayPosition,
|
||
Toggleable: true,
|
||
|
||
// Sortable under its _calc_<id> identifier; Process handles the sort
|
||
// specially, because the value depends on the row index and so cannot be
|
||
// read out of the row itself.
|
||
Sortable: true,
|
||
SortIdentifier: CalcRef(calc.ID),
|
||
|
||
CSV: true,
|
||
// CSVValueAt, not CSVValue: a calculated value is a function of the row's
|
||
// POSITION (ROW(), a running total), which an index-free callback cannot
|
||
// express. It would have exported every running total as the same number.
|
||
CSVValueAt: func(_ any, rowIndex int) string {
|
||
if rowIndex < 0 || rowIndex >= len(vals) {
|
||
return CalcEmptyValue
|
||
}
|
||
return FormatCalcResult(vals[rowIndex], calc.DataType, calc.Precision, "", "", "")
|
||
},
|
||
|
||
CellAt: func(_ any, rowIndex int) *vdom.VNode {
|
||
text := CalcEmptyValue
|
||
if rowIndex >= 0 && rowIndex < len(vals) {
|
||
text = FormatCalcResult(vals[rowIndex], calc.DataType, calc.Precision, "", "", "")
|
||
}
|
||
return vdom.Td(vdom.Attr("class", cx(POS_CLS[calc.DisplayPosition], "tabular-nums")),
|
||
vdom.Text(text),
|
||
)
|
||
},
|
||
})
|
||
}
|
||
return out
|
||
}
|
||
|
||
// ---- calculated columns and summary rows as live state ----
|
||
|
||
// Calculated is the current list of calculated columns. It is state, not a fixed
|
||
// option: the whole point of CalculatedColumnEditor is that a user builds these at
|
||
// runtime, the way they would in a spreadsheet.
|
||
func (s *AutoTableState) Calculated() []UserCalculatedColumn { return s.calculated.Get() }
|
||
|
||
// SummaryRows is the current list of footer rows.
|
||
func (s *AutoTableState) SummaryRows() []UserSummaryRow { return s.summaries.Get() }
|
||
|
||
// AddCalculated appends a calculated column (replacing one with the same ID, so it
|
||
// doubles as an update). An empty ID gets one.
|
||
func (s *AutoTableState) AddCalculated(uc UserCalculatedColumn) {
|
||
if uc.ID == "" {
|
||
s.calcSeq++
|
||
uc.ID = "calc" + strconv.Itoa(s.calcSeq)
|
||
}
|
||
next := make([]UserCalculatedColumn, 0, len(s.Calculated())+1)
|
||
replaced := false
|
||
for _, existing := range s.Calculated() {
|
||
if existing.ID == uc.ID {
|
||
next = append(next, uc)
|
||
replaced = true
|
||
} else {
|
||
next = append(next, existing)
|
||
}
|
||
}
|
||
if !replaced {
|
||
next = append(next, uc)
|
||
}
|
||
s.calculated.Set(next)
|
||
s.persistColumns()
|
||
}
|
||
|
||
// RemoveCalculated deletes a calculated column, and drops the sort if the table was
|
||
// ordered by it — leaving a sort pointing at a column that no longer exists would
|
||
// silently stop sorting.
|
||
func (s *AutoTableState) RemoveCalculated(id string) {
|
||
next := []UserCalculatedColumn{}
|
||
for _, uc := range s.Calculated() {
|
||
if uc.ID != id {
|
||
next = append(next, uc)
|
||
}
|
||
}
|
||
s.calculated.Set(next)
|
||
if s.orderBy.Get().Identifier == CalcRef(id) {
|
||
s.orderBy.Set(AutoTableOrderBy{})
|
||
}
|
||
s.persistColumns()
|
||
}
|
||
|
||
// AddSummaryRow appends (or replaces, by ID) a footer row.
|
||
func (s *AutoTableState) AddSummaryRow(sr UserSummaryRow) {
|
||
if sr.ID == "" {
|
||
s.calcSeq++
|
||
sr.ID = "sum" + strconv.Itoa(s.calcSeq)
|
||
}
|
||
next := make([]UserSummaryRow, 0, len(s.SummaryRows())+1)
|
||
replaced := false
|
||
for _, existing := range s.SummaryRows() {
|
||
if existing.ID == sr.ID {
|
||
next = append(next, sr)
|
||
replaced = true
|
||
} else {
|
||
next = append(next, existing)
|
||
}
|
||
}
|
||
if !replaced {
|
||
next = append(next, sr)
|
||
}
|
||
s.summaries.Set(next)
|
||
s.persistColumns()
|
||
}
|
||
|
||
// RemoveSummaryRow deletes a footer row.
|
||
func (s *AutoTableState) RemoveSummaryRow(id string) {
|
||
next := []UserSummaryRow{}
|
||
for _, sr := range s.SummaryRows() {
|
||
if sr.ID != id {
|
||
next = append(next, sr)
|
||
}
|
||
}
|
||
s.summaries.Set(next)
|
||
s.persistColumns()
|
||
}
|
||
|
||
// ExportColumns are the columns an export writes: the ones the user can actually
|
||
// see, in the order they put them in, plus any calculated columns.
|
||
//
|
||
// NOT the declared columns. Exporting those would write columns the user had hidden
|
||
// and ignore the order they dragged them into — the export would not match the table
|
||
// it came from.
|
||
func (s *AutoTableState) ExportColumns() []AutoTableColumn {
|
||
cols := s.VisibleColumns()
|
||
if len(s.Calculated()) == 0 {
|
||
return cols
|
||
}
|
||
// Evaluated against the filtered rows in their final order, which is exactly what
|
||
// the export walks.
|
||
ctx, values := s.evalCalcColumns(s.FilteredRows())
|
||
return append(cols, s.calcColumns(ctx, values)...)
|
||
}
|
||
|
||
// evalCalcColumns evaluates every calculated column against `rows`, in order.
|
||
func (s *AutoTableState) evalCalcColumns(rows []any) (*CalcContext, map[string][]float64) {
|
||
ctx := NewCalcContext(rows, s.cols, s.Calculated(), s.read)
|
||
values := make(map[string][]float64, len(s.Calculated()))
|
||
for _, uc := range s.Calculated() {
|
||
col := make([]float64, len(rows))
|
||
for i := range rows {
|
||
// The error is a diagnostic; the value is already NaN, which formats as
|
||
// CalcEmptyValue. A broken formula shows a dash, it does not break the table.
|
||
col[i], _ = ComputeCalculatedColumn(uc, ctx.ForRow(i))
|
||
}
|
||
values[uc.ID] = col
|
||
}
|
||
return ctx, values
|
||
}
|
||
|
||
// sortByCalc orders rows by a calculated column.
|
||
//
|
||
// It cannot go through SortRows: that reads the sort key out of the row, and a
|
||
// calculated value is not in the row — it is a function of the row's POSITION. So
|
||
// the values are evaluated against the pre-sort order, and the rows are permuted to
|
||
// match. (The values are then re-evaluated against the final order by Process, so a
|
||
// running total still counts down the screen. Sorting by a running total is
|
||
// therefore self-referential — as it is in the TSX. Sorting by a position-independent
|
||
// formula, which is the normal case, is exact.)
|
||
func sortByCalc(rows []any, values []float64, descending bool) []any {
|
||
idx := make([]int, len(rows))
|
||
for i := range idx {
|
||
idx[i] = i
|
||
}
|
||
stableSortInts(idx, func(a, b int) bool {
|
||
av, bv := values[a], values[b]
|
||
aNaN, bNaN := av != av, bv != bv
|
||
if aNaN != bNaN {
|
||
return bNaN // NaN (an unevaluable formula) sorts last, like an empty cell
|
||
}
|
||
if aNaN {
|
||
return false
|
||
}
|
||
if av == bv {
|
||
return false
|
||
}
|
||
if descending {
|
||
return av > bv
|
||
}
|
||
return av < bv
|
||
})
|
||
|
||
out := make([]any, len(rows))
|
||
for i, j := range idx {
|
||
out[i] = rows[j]
|
||
}
|
||
return out
|
||
}
|
||
|
||
// stableSortInts is an insertion sort — stable, and the row counts here are the ones
|
||
// a person is going to look at, not a million.
|
||
func stableSortInts(xs []int, less func(a, b int) bool) {
|
||
for i := 1; i < len(xs); i++ {
|
||
for j := i; j > 0 && less(xs[j], xs[j-1]); j-- {
|
||
xs[j], xs[j-1] = xs[j-1], xs[j]
|
||
}
|
||
}
|
||
}
|
||
|
||
// summaryFoot renders the <tfoot>: one row per UserSummaryRow, each evaluated once
|
||
// over the whole filtered set.
|
||
func (s *AutoTableState) summaryFoot(ctx *CalcContext, colCount int) *vdom.VNode {
|
||
if len(s.SummaryRows()) == 0 {
|
||
return nil
|
||
}
|
||
foot := vdom.Tfoot(vdom.Attr("class", AUTOTABLE_TFOOT))
|
||
for _, sr := range s.SummaryRows() {
|
||
foot.Children = append(foot.Children, vdom.Tr(vdom.Td(vdom.Attr("colspan", strconv.Itoa(max(colCount-1, 1))),
|
||
vdom.Attr("class", "text-right font-semibold"),
|
||
vdom.Text(sr.Label),
|
||
),
|
||
vdom.Td(vdom.Attr("class", "text-right font-semibold tabular-nums"),
|
||
vdom.Text(FormatSummaryRow(sr, ctx)),
|
||
),
|
||
))
|
||
}
|
||
return foot
|
||
}
|
||
|
||
// AUTOTABLE_TFOOT styles the summary footer.
|
||
const AUTOTABLE_TFOOT = "border-t-2 border-neutral-300 bg-neutral-50 [&_td]:p-3"
|
||
|
||
// ---- the calculated-column editor ----
|
||
|
||
// The port of the TSX's AddCalcMenu / CalculatedColumnForm / SummaryRowForm /
|
||
// FormulaField.
|
||
//
|
||
// The model it edits (the part that is easy to get backwards):
|
||
//
|
||
// - A calculated COLUMN with a predefined function combines its operand columns
|
||
// ACROSS THE ROW: sum over [Revenue, Cost] is revenue + cost, per row. It does
|
||
// NOT aggregate a column down the table.
|
||
// - A SUMMARY ROW does the opposite: it aggregates ONE operand column DOWN the
|
||
// rows.
|
||
// - subtract and divide are BINARY and ORDERED — a − b, a ÷ b.
|
||
// - Operands are column KEYS (a column's SortIdentifier, or _calc_<id>). Formulas
|
||
// name columns by their DISPLAY name instead: [Revenue] is this row's cell,
|
||
// {Revenue} is the whole column.
|
||
//
|
||
// The two are separate forms behind a chooser, as in the TSX, because they are
|
||
// different things — not one form with a "summary?" switch.
|
||
|
||
// isBinaryCalcFn reports whether a function takes exactly two ordered operands.
|
||
func isBinaryCalcFn(fn CalculatedFunction) bool {
|
||
return fn == CALC_FN_SUBTRACT || fn == CALC_FN_DIVIDE
|
||
}
|
||
|
||
// Tailwind for the editor, copied from AutoTable.tsx.
|
||
//
|
||
// The formula input is a single horizontally-scrolling line, so it is never taller
|
||
// than any other field. It is TRANSPARENT text with a visible caret, sitting on top
|
||
// of an overlay that renders the same text with syntax colouring — which is how you
|
||
// get highlighting in a plain <textarea> at all. Both share FORMULA_EDIT_BASE so
|
||
// their metrics line up to the pixel; if they ever drift, the caret stops landing on
|
||
// the glyph it appears to be on.
|
||
const (
|
||
CALC_ADD_TRIGGER_CLS = "inline-flex items-center justify-center gap-2 cursor-pointer text-sm font-normal rounded-default transition shadow-xs bg-neutral-50 text-black border border-neutral-300 hover:bg-neutral-100 py-1 px-3"
|
||
|
||
FORMULA_EDIT_BASE = "block w-full h-[30px] font-mono text-sm p-1 rounded-default box-border whitespace-pre"
|
||
FORMULA_OVERLAY_CLS = FORMULA_EDIT_BASE + " absolute inset-0 overflow-hidden pointer-events-none border border-transparent text-neutral-800"
|
||
FORMULA_TEXTAREA_CLS = FORMULA_EDIT_BASE + " relative bg-transparent text-transparent caret-neutral-800 resize-none overflow-x-auto overflow-y-hidden shadow-xs border border-neutral-300 focus:border-sky-500 outline-hidden"
|
||
|
||
formulaPlaceholder = `<span class="text-neutral-400">e.g. [Revenue] / SUM({Revenue}) * 100</span>`
|
||
|
||
calcMenuTrigger = "inline-flex items-center gap-1 rounded-default border border-neutral-300 bg-neutral-50 px-2 py-1 text-xs leading-none text-neutral-700 cursor-pointer hover:bg-neutral-100"
|
||
calcChooserItem = "w-full text-left px-2 py-2 rounded-default hover:bg-neutral-100 cursor-pointer border-0 bg-transparent flex items-start gap-2.5"
|
||
)
|
||
|
||
// HighlightFormula renders a formula as HTML with syntax colouring: cell refs
|
||
// [Revenue], column refs {Revenue}, numbers, function names, constants, operators.
|
||
//
|
||
// Purely visual, and deliberately LEXICAL rather than a real parse — it has to
|
||
// colour a half-typed formula that does not compile yet, which is exactly when the
|
||
// colours are worth the most.
|
||
func HighlightFormula(src string) string {
|
||
var b strings.Builder
|
||
i := 0
|
||
for i < len(src) {
|
||
c := src[i]
|
||
|
||
switch {
|
||
// [Cell] — this row's value.
|
||
case c == '[':
|
||
j := strings.IndexByte(src[i:], ']')
|
||
end := len(src)
|
||
if j >= 0 {
|
||
end = i + j + 1
|
||
}
|
||
b.WriteString(`<span class="text-sky-600">`)
|
||
b.WriteString(html.EscapeString(src[i:end]))
|
||
b.WriteString(`</span>`)
|
||
i = end
|
||
|
||
// {Column} — the whole column. Nested braces so {Col:1:ROW()} stays one span.
|
||
case c == '{':
|
||
depth, j := 1, i+1
|
||
for j < len(src) && depth > 0 {
|
||
switch src[j] {
|
||
case '{':
|
||
depth++
|
||
case '}':
|
||
depth--
|
||
}
|
||
j++
|
||
}
|
||
b.WriteString(`<span class="text-violet-600">`)
|
||
b.WriteString(html.EscapeString(src[i:j]))
|
||
b.WriteString(`</span>`)
|
||
i = j
|
||
|
||
case isDigit(c) || (c == '.' && i+1 < len(src) && isDigit(src[i+1])):
|
||
j := i + 1
|
||
for j < len(src) && (isDigit(src[j]) || src[j] == '.') {
|
||
j++
|
||
}
|
||
b.WriteString(`<span class="text-amber-600">`)
|
||
b.WriteString(html.EscapeString(src[i:j]))
|
||
b.WriteString(`</span>`)
|
||
i = j
|
||
|
||
case isFormulaAlpha(c):
|
||
j := i + 1
|
||
for j < len(src) && (isFormulaAlpha(src[j]) || isDigit(src[j])) {
|
||
j++
|
||
}
|
||
word := src[i:j]
|
||
|
||
// A name followed by "(" is a call; otherwise it may be a constant.
|
||
k := j
|
||
for k < len(src) && src[k] == ' ' {
|
||
k++
|
||
}
|
||
switch {
|
||
case k < len(src) && src[k] == '(':
|
||
b.WriteString(`<span class="text-emerald-700 font-semibold">`)
|
||
b.WriteString(html.EscapeString(word))
|
||
b.WriteString(`</span>`)
|
||
case isFormulaConstant(word):
|
||
b.WriteString(`<span class="text-amber-600">`)
|
||
b.WriteString(html.EscapeString(word))
|
||
b.WriteString(`</span>`)
|
||
default:
|
||
b.WriteString(html.EscapeString(word))
|
||
}
|
||
i = j
|
||
|
||
case strings.IndexByte("+-*/^%=<>(),:", c) >= 0:
|
||
b.WriteString(`<span class="text-neutral-400">`)
|
||
b.WriteString(html.EscapeString(string(c)))
|
||
b.WriteString(`</span>`)
|
||
i++
|
||
|
||
default:
|
||
b.WriteString(html.EscapeString(string(c)))
|
||
i++
|
||
}
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
// isFormulaConstant reports whether a bare word is one of PI, E, TAU, … — matched
|
||
// case-insensitively, as the evaluator does.
|
||
func isFormulaConstant(word string) bool {
|
||
_, ok := FormulaConstants[strings.ToUpper(word)]
|
||
return ok
|
||
}
|
||
|
||
// calcEditor is the editor's state. It lives on the AutoTableState (memoized) rather
|
||
// than being rebuilt per render, or every keystroke would reset the field being typed
|
||
// into.
|
||
type calcEditor struct {
|
||
pop *Popover
|
||
|
||
// view is the chooser's state: "menu" (pick what to add, and see what exists),
|
||
// "column", or "summary". The two forms are separate things, so they are separate
|
||
// views rather than one form with a switch on it.
|
||
view *vdom.Signal[string]
|
||
|
||
editingID *vdom.Signal[string] // "" = adding
|
||
advanced *vdom.Signal[bool] // write a formula instead of picking a function
|
||
|
||
name *vdom.Signal[string]
|
||
fn *vdom.Signal[string]
|
||
operands *vdom.Signal[[]string] // column KEYS, ordered (order matters for binary fns)
|
||
formula *vdom.Signal[string]
|
||
dataType *vdom.Signal[string]
|
||
precision *vdom.Signal[string]
|
||
position *vdom.Signal[int]
|
||
errorMsg *vdom.Signal[string]
|
||
|
||
opSelect *MultiSelect // the Basic tab's operand picker
|
||
|
||
// The function menu's own search box.
|
||
fnSearch *vdom.Signal[string]
|
||
fnSearchRef *vdom.Ref
|
||
colMenu *Menu
|
||
fnMenu *Menu
|
||
constMenu *Menu
|
||
|
||
// The textarea and the highlight overlay behind it. Both are needed: the caret
|
||
// insert writes into the textarea, and the overlay's scroll has to follow it.
|
||
formulaRef *vdom.Ref
|
||
overlayRef *vdom.Ref
|
||
}
|
||
|
||
func (s *AutoTableState) calcEditorState() *calcEditor {
|
||
if s.editor != nil {
|
||
return s.editor
|
||
}
|
||
e := &calcEditor{
|
||
view: vdom.NewSignal("menu"),
|
||
editingID: vdom.NewSignal(""),
|
||
advanced: vdom.NewSignal(false),
|
||
name: vdom.NewSignal(""),
|
||
fn: vdom.NewSignal(string(CALC_FN_SUM)),
|
||
operands: vdom.NewSignal([]string{}),
|
||
formula: vdom.NewSignal(""),
|
||
dataType: vdom.NewSignal(string(CALC_TYPE_NUMBER)),
|
||
precision: vdom.NewSignal(""),
|
||
position: vdom.NewSignal(int(COL_POS_RIGHT)),
|
||
errorMsg: vdom.NewSignal(""),
|
||
opSelect: NewMultiSelect(DropdownOptions{}),
|
||
fnSearch: vdom.NewSignal(""),
|
||
fnSearchRef: vdom.NewRef(),
|
||
formulaRef: vdom.NewRef(),
|
||
overlayRef: vdom.NewRef(),
|
||
// Standalone: these menus live INSIDE the editor's popover, and without it the
|
||
// single-open manager would read them as a rival panel and close their own
|
||
// parent as they opened.
|
||
colMenu: NewMenu(MenuOptions{Placement: PlacementBottomStart, Standalone: true}),
|
||
constMenu: NewMenu(MenuOptions{Placement: PlacementBottomStart, Standalone: true}),
|
||
}
|
||
// Built after e exists, because it closes over it: closing the menu abandons the
|
||
// query. Reopening to a stale filter — four of thirty-five functions showing, for
|
||
// no visible reason — is worse than retyping.
|
||
e.fnMenu = NewMenu(MenuOptions{
|
||
Placement: PlacementBottomStart,
|
||
Standalone: true,
|
||
OnOpenChange: func(open bool) {
|
||
if !open {
|
||
e.fnSearch.Set("")
|
||
}
|
||
},
|
||
})
|
||
e.pop = NewPopover(PopoverProps{
|
||
Placement: PlacementBottomEnd,
|
||
// Closing the popover abandons the draft — reopening should start clean rather
|
||
// than resume a half-written formula the user walked away from.
|
||
OnOpenChange: func(open bool) {
|
||
if !open {
|
||
e.reset()
|
||
}
|
||
},
|
||
})
|
||
s.editor = e
|
||
return e
|
||
}
|
||
|
||
func (e *calcEditor) reset() {
|
||
e.view.Set("menu")
|
||
e.editingID.Set("")
|
||
e.advanced.Set(false)
|
||
e.name.Set("")
|
||
e.fn.Set(string(CALC_FN_SUM))
|
||
e.operands.Set(nil)
|
||
e.formula.Set("")
|
||
e.dataType.Set(string(CALC_TYPE_NUMBER))
|
||
e.precision.Set("")
|
||
e.position.Set(int(COL_POS_RIGHT))
|
||
e.errorMsg.Set("")
|
||
}
|
||
|
||
func (e *calcEditor) currentFn() CalculatedFunction { return CalculatedFunction(e.fn.Get()) }
|
||
func (e *calcEditor) isSummary() bool { return e.view.Get() == "summary" }
|
||
|
||
// setOperand writes the operand at index i, growing the list as needed — how the two
|
||
// ordered boxes of a binary function are bound.
|
||
func (e *calcEditor) setOperand(i int, v string) {
|
||
ops := append([]string{}, e.operands.Get()...)
|
||
for len(ops) <= i {
|
||
ops = append(ops, "")
|
||
}
|
||
ops[i] = v
|
||
e.operands.Set(ops)
|
||
}
|
||
|
||
func (e *calcEditor) operandAt(i int) string {
|
||
ops := e.operands.Get()
|
||
if i < len(ops) {
|
||
return ops[i]
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// calcFunctions are the predefined functions, in the order the TSX lists them.
|
||
var calcFunctions = []struct {
|
||
Value CalculatedFunction
|
||
Label string
|
||
}{
|
||
{CALC_FN_SUM, "Sum"},
|
||
{CALC_FN_SUBTRACT, "Subtract"},
|
||
{CALC_FN_MULTIPLY, "Multiply"},
|
||
{CALC_FN_DIVIDE, "Divide"},
|
||
{CALC_FN_AVERAGE, "Average"},
|
||
{CALC_FN_MEDIAN, "Median"},
|
||
{CALC_FN_MODE, "Mode"},
|
||
{CALC_FN_MIN, "Min"},
|
||
{CALC_FN_MAX, "Max"},
|
||
{CALC_FN_COUNT, "Count"},
|
||
}
|
||
|
||
var calcDataTypes = []struct {
|
||
Value CalculatedDataType
|
||
Label string
|
||
}{
|
||
{CALC_TYPE_NUMBER, "Number"},
|
||
{CALC_TYPE_INTEGER, "Integer"},
|
||
{CALC_TYPE_DECIMAL, "Decimal"},
|
||
{CALC_TYPE_MONEY, "Money"},
|
||
{CALC_TYPE_PERCENT, "Percent"},
|
||
{CALC_TYPE_PLAIN, "Plain"},
|
||
}
|
||
|
||
// FormulaFunction is one entry in the insert menu: what to type, what it takes, and
|
||
// what it does. The signature and the description are not decoration — they are what
|
||
// makes a list of thirty-five names usable by someone who does not already know them,
|
||
// and they are what the menu's search matches against.
|
||
type FormulaFunction struct {
|
||
Name string
|
||
Sig string
|
||
Desc string
|
||
}
|
||
|
||
// FormulaFunctionGroup is a category of functions.
|
||
type FormulaFunctionGroup struct {
|
||
Label string
|
||
Fns []FormulaFunction
|
||
}
|
||
|
||
// FormulaFunctionGroups are the functions a formula may call, grouped the way someone
|
||
// looking for one would think about them — by what they are FOR, not alphabetically.
|
||
// Someone who wants a total looks under Aggregate; nobody scans an A-to-Z list from
|
||
// ABS to TANH hoping to recognise something.
|
||
var FormulaFunctionGroups = []FormulaFunctionGroup{
|
||
{Label: "Aggregate", Fns: []FormulaFunction{
|
||
{"SUM", "SUM(range)", "Total of the values"},
|
||
{"AVERAGE", "AVERAGE(range)", "Mean of the values"},
|
||
{"MEDIAN", "MEDIAN(range)", "Middle value"},
|
||
{"MODE", "MODE(range)", "Most frequent value"},
|
||
{"MIN", "MIN(range)", "Smallest value"},
|
||
{"MAX", "MAX(range)", "Largest value"},
|
||
{"COUNT", "COUNT(range)", "How many numbers"},
|
||
}},
|
||
{Label: "Math", Fns: []FormulaFunction{
|
||
{"ABS", "ABS(n)", "Absolute value"},
|
||
{"ROUND", "ROUND(n, digits)", "Round to digits"},
|
||
{"FLOOR", "FLOOR(n)", "Round down"},
|
||
{"CEILING", "CEILING(n)", "Round up"},
|
||
{"SQRT", "SQRT(n)", "Square root"},
|
||
{"POWER", "POWER(n, p)", "n to the power p"},
|
||
{"MOD", "MOD(n, d)", "Remainder of n ÷ d"},
|
||
{"EXP", "EXP(n)", "e to the power n"},
|
||
{"LN", "LN(n)", "Natural log (base e)"},
|
||
{"LOG", "LOG(n, base)", "Log, base 10 by default"},
|
||
}},
|
||
{Label: "Trigonometry", Fns: []FormulaFunction{
|
||
{"SIN", "SIN(angle)", "Sine (radians)"},
|
||
{"COS", "COS(angle)", "Cosine (radians)"},
|
||
{"TAN", "TAN(angle)", "Tangent (radians)"},
|
||
{"ASIN", "ASIN(n)", "Inverse sine"},
|
||
{"ACOS", "ACOS(n)", "Inverse cosine"},
|
||
{"ATAN", "ATAN(n)", "Inverse tangent"},
|
||
{"ATAN2", "ATAN2(x, y)", "Angle of point (x, y)"},
|
||
{"SINH", "SINH(n)", "Hyperbolic sine"},
|
||
{"COSH", "COSH(n)", "Hyperbolic cosine"},
|
||
{"TANH", "TANH(n)", "Hyperbolic tangent"},
|
||
{"PI", "PI()", "π constant"},
|
||
{"RADIANS", "RADIANS(deg)", "Degrees → radians"},
|
||
{"DEGREES", "DEGREES(rad)", "Radians → degrees"},
|
||
}},
|
||
{Label: "Logic", Fns: []FormulaFunction{
|
||
{"IF", "IF(test, then, else)", "Choose by condition"},
|
||
{"AND", "AND(a, b, …)", "True if all are true"},
|
||
{"OR", "OR(a, b, …)", "True if any are true"},
|
||
{"NOT", "NOT(a)", "Negate"},
|
||
}},
|
||
{Label: "Row", Fns: []FormulaFunction{
|
||
{"ROW", "ROW()", "Current row number"},
|
||
}},
|
||
}
|
||
|
||
// FormulaConstant is a named constant, with what it is worth.
|
||
type FormulaConstant struct {
|
||
Name string
|
||
Desc string
|
||
}
|
||
|
||
// FormulaConstantOptions are the constants a formula may write bare, for the insert
|
||
// menu. The approximate value is shown because "PHI" tells you nothing and "≈ 1.618"
|
||
// tells you everything.
|
||
var FormulaConstantOptions = []FormulaConstant{
|
||
{"PI", "π ≈ 3.14159"},
|
||
{"E", "Euler's number ≈ 2.71828"},
|
||
{"TAU", "2π ≈ 6.28319"},
|
||
{"PHI", "Golden ratio ≈ 1.61803"},
|
||
{"SQRT2", "√2 ≈ 1.41421"},
|
||
}
|
||
|
||
// FormulaFunctionNames flattens the groups — for anything that just needs the names.
|
||
func FormulaFunctionNames() []string {
|
||
var out []string
|
||
for _, g := range FormulaFunctionGroups {
|
||
for _, f := range g.Fns {
|
||
out = append(out, f.Name)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// filterFormulaGroups narrows the menu by a query, matching name, signature AND
|
||
// description — so "total" finds SUM, which is the whole point of carrying the prose
|
||
// around. Empty groups drop out rather than leaving a bare heading behind.
|
||
func filterFormulaGroups(query string) []FormulaFunctionGroup {
|
||
q := strings.TrimSpace(strings.ToLower(query))
|
||
if q == "" {
|
||
return FormulaFunctionGroups
|
||
}
|
||
var out []FormulaFunctionGroup
|
||
for _, g := range FormulaFunctionGroups {
|
||
var fns []FormulaFunction
|
||
for _, f := range g.Fns {
|
||
hay := strings.ToLower(f.Name + " " + f.Sig + " " + f.Desc)
|
||
if strings.Contains(hay, q) {
|
||
fns = append(fns, f)
|
||
}
|
||
}
|
||
if len(fns) > 0 {
|
||
out = append(out, FormulaFunctionGroup{Label: g.Label, Fns: fns})
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// operandOption is one entry in the operand pickers and the column insert menu: the
|
||
// KEY an operand uses, and the DISPLAY name a formula uses.
|
||
type operandOption struct {
|
||
Key string // SortIdentifier, or _calc_<id>
|
||
Label string // DisplayName
|
||
}
|
||
|
||
// operandOptions lists the columns an operand may reference — data columns with a
|
||
// sort identifier, plus every OTHER calculated column. The one being edited is
|
||
// excluded: a column referencing itself is a cycle, and the engine would (rightly)
|
||
// refuse to evaluate it.
|
||
func (s *AutoTableState) operandOptions(editingID string) []operandOption {
|
||
var out []operandOption
|
||
for _, c := range s.cols {
|
||
if c.SortIdentifier == "" || c.DisplayName == "" {
|
||
continue // nothing to reference it by
|
||
}
|
||
out = append(out, operandOption{Key: c.SortIdentifier, Label: c.DisplayName})
|
||
}
|
||
for _, uc := range s.Calculated() {
|
||
if uc.ID == editingID {
|
||
continue // no self-reference
|
||
}
|
||
out = append(out, operandOption{Key: CalcRef(uc.ID), Label: uc.DisplayName})
|
||
}
|
||
return out
|
||
}
|
||
|
||
// CalculatedColumnEditor renders the control for building calculated columns and
|
||
// summary rows at runtime.
|
||
//
|
||
// It opens on a chooser — what already exists (with edit and delete), and the two
|
||
// things you can add — because a column and a footer row are different objects, not
|
||
// one object with a flag. Picking either swaps the popover to that form.
|
||
func (s *AutoTableState) CalculatedColumnEditor() *vdom.VNode {
|
||
e := s.calcEditorState()
|
||
|
||
var body *vdom.VNode
|
||
switch e.view.Get() {
|
||
case "column":
|
||
body = s.calcForm(e, false)
|
||
case "summary":
|
||
body = s.calcForm(e, true)
|
||
default:
|
||
body = s.calcChooser(e)
|
||
}
|
||
|
||
return vdom.Div(vdom.Attr("class", "contents"),
|
||
e.pop.Trigger(PopoverTriggerProps{Class: CALC_ADD_TRIGGER_CLS},
|
||
Icon("calculator", 16, ""), vdom.Text("Calculated"),
|
||
),
|
||
e.pop.Content(PopoverContentProps{Class: "w-[26rem] max-h-[34rem] overflow-y-auto"}, body),
|
||
)
|
||
}
|
||
|
||
// calcChooser: what exists, and the two things you can add.
|
||
func (s *AutoTableState) calcChooser(e *calcEditor) *vdom.VNode {
|
||
children := []*vdom.VNode{}
|
||
|
||
// What already exists.
|
||
existing := []*vdom.VNode{}
|
||
for _, uc := range s.Calculated() {
|
||
calc := uc
|
||
existing = append(existing, calcListRow(calc.DisplayName, s.calcDescribe(calc.Fn, calc.Formula, calc.Operands),
|
||
func() { s.loadColumn(e, calc) },
|
||
func() { s.RemoveCalculated(calc.ID) },
|
||
))
|
||
}
|
||
for _, sr := range s.SummaryRows() {
|
||
row := sr
|
||
existing = append(existing, calcListRow(row.Label+" (footer)", s.calcDescribe(row.Fn, row.Formula, row.Operands),
|
||
func() { s.loadSummary(e, row) },
|
||
func() { s.RemoveSummaryRow(row.ID) },
|
||
))
|
||
}
|
||
if len(existing) > 0 {
|
||
children = append(children, vdom.Div(kids([]vdom.Mod{vdom.Attr("class", "flex flex-col gap-1 border-b border-neutral-200 pb-2 mb-1")}, existing)...))
|
||
}
|
||
|
||
children = append(children,
|
||
calcChooserButton("table-columns", "Calculated column", "A new column computed for each row",
|
||
func() { e.view.Set("column") }),
|
||
calcChooserButton("list-ol", "Summary row", "A total or aggregate shown in the footer",
|
||
func() { e.view.Set("summary") }),
|
||
)
|
||
|
||
return vdom.Div(kids([]vdom.Mod{vdom.Attr("class", "flex flex-col gap-0.5 p-2")}, children)...)
|
||
}
|
||
|
||
func calcChooserButton(icon, title, subtitle string, onClick func()) *vdom.VNode {
|
||
return vdom.Button(vdom.Attr("type", "button"),
|
||
vdom.Attr("class", calcChooserItem),
|
||
vdom.On(vdom.EVENT_CLICK, onClick),
|
||
Icon(icon, 16, "mt-0.5 text-neutral-500"),
|
||
vdom.Span(vdom.Attr("class", "flex flex-col"),
|
||
vdom.Span(vdom.Attr("class", "text-sm font-medium text-neutral-800"), vdom.Text(title)),
|
||
vdom.Span(vdom.Attr("class", "text-xs text-neutral-500"), vdom.Text(subtitle)),
|
||
),
|
||
)
|
||
}
|
||
|
||
func calcListRow(title, subtitle string, onEdit, onDelete func()) *vdom.VNode {
|
||
return vdom.Div(vdom.Attr("class", "flex items-center gap-2 rounded-default px-2 py-1 hover:bg-neutral-50"),
|
||
vdom.Div(vdom.Attr("class", "min-w-0 grow"),
|
||
vdom.Div(vdom.Attr("class", "truncate text-sm font-medium text-neutral-800"), vdom.Text(title)),
|
||
vdom.Div(vdom.Attr("class", "truncate font-mono text-xs text-neutral-500"), vdom.Text(subtitle)),
|
||
),
|
||
Button(ButtonProps{Color: ButtonLightNeutral, Small: true, Icon: "pencil", Title: "Edit", OnClick: onEdit}),
|
||
Button(ButtonProps{Color: ButtonLightNeutral, Small: true, Icon: "trash", Title: "Delete", OnClick: onDelete}),
|
||
)
|
||
}
|
||
|
||
// calcDescribe summarises a definition in the table's own vocabulary: operands are
|
||
// stored as keys, so they are rendered back as display names.
|
||
func (s *AutoTableState) calcDescribe(fn CalculatedFunction, formula string, operands []string) string {
|
||
if fn == CALC_FN_CUSTOM || fn == "" {
|
||
return formula
|
||
}
|
||
labels := make([]string, 0, len(operands))
|
||
for _, key := range operands {
|
||
labels = append(labels, s.operandLabel(key))
|
||
}
|
||
return string(fn) + "(" + strings.Join(labels, ", ") + ")"
|
||
}
|
||
|
||
func (s *AutoTableState) operandLabel(key string) string {
|
||
for _, o := range s.operandOptions("") {
|
||
if o.Key == key {
|
||
return o.Label
|
||
}
|
||
}
|
||
return key
|
||
}
|
||
|
||
func (s *AutoTableState) loadColumn(e *calcEditor, uc UserCalculatedColumn) {
|
||
e.view.Set("column")
|
||
e.editingID.Set(uc.ID)
|
||
e.advanced.Set(uc.Fn == CALC_FN_CUSTOM)
|
||
e.name.Set(uc.DisplayName)
|
||
e.fn.Set(basicFnOf(uc.Fn))
|
||
e.operands.Set(append([]string{}, uc.Operands...))
|
||
e.formula.Set(uc.Formula)
|
||
e.dataType.Set(string(pickCalcType(uc.DataType)))
|
||
e.precision.Set(precisionText(uc.Precision))
|
||
e.position.Set(int(uc.DisplayPosition))
|
||
e.errorMsg.Set("")
|
||
}
|
||
|
||
func (s *AutoTableState) loadSummary(e *calcEditor, sr UserSummaryRow) {
|
||
e.view.Set("summary")
|
||
e.editingID.Set(sr.ID)
|
||
e.advanced.Set(sr.Fn == CALC_FN_CUSTOM)
|
||
e.name.Set(sr.Label)
|
||
e.fn.Set(basicFnOf(sr.Fn))
|
||
e.operands.Set(append([]string{}, sr.Operands...))
|
||
e.formula.Set(sr.Formula)
|
||
e.dataType.Set(string(pickCalcType(sr.DataType)))
|
||
e.precision.Set(precisionText(sr.Precision))
|
||
e.errorMsg.Set("")
|
||
}
|
||
|
||
// basicFnOf is the function the Basic tab should show. A custom definition has no
|
||
// basic function, so the tab falls back to a sensible default rather than an empty
|
||
// select — switching to Basic then means "start over as an aggregation".
|
||
func basicFnOf(fn CalculatedFunction) string {
|
||
if fn == CALC_FN_CUSTOM || fn == "" {
|
||
return string(CALC_FN_SUM)
|
||
}
|
||
return string(fn)
|
||
}
|
||
|
||
func pickCalcType(t CalculatedDataType) CalculatedDataType {
|
||
if t == "" {
|
||
return CALC_TYPE_NUMBER
|
||
}
|
||
return t
|
||
}
|
||
|
||
func precisionText(p *int) string {
|
||
if p == nil {
|
||
return ""
|
||
}
|
||
return strconv.Itoa(*p)
|
||
}
|
||
|
||
// calcForm is the column form or the summary-row form — the same shape, but the
|
||
// operand model differs (see calcBasicEditor), so `summary` runs right through it.
|
||
func (s *AutoTableState) calcForm(e *calcEditor, summary bool) *vdom.VNode {
|
||
editing := e.editingID.Get() != ""
|
||
|
||
title, nameLabel := "Calculated column", "Column name"
|
||
if summary {
|
||
title, nameLabel = "Summary row", "Label"
|
||
}
|
||
if editing {
|
||
title = "Edit " + strings.ToLower(title)
|
||
}
|
||
|
||
header := vdom.Div(vdom.Attr("class", "flex items-center gap-2 border-b border-neutral-200 px-3 py-2"),
|
||
vdom.Button(vdom.Attr("type", "button"),
|
||
vdom.Attr("class", "shrink-0 cursor-pointer rounded-default p-1 text-neutral-500 hover:bg-neutral-100 border-0 bg-transparent"),
|
||
vdom.Attr("aria-label", "Back"),
|
||
vdom.On(vdom.EVENT_CLICK, e.reset),
|
||
Icon("chevron-left", 14, ""),
|
||
),
|
||
vdom.Span(vdom.Attr("class", "text-sm font-medium text-neutral-800"), vdom.Text(title)),
|
||
// Basic vs Advanced.
|
||
vdom.Div(vdom.Attr("class", "ml-auto"),
|
||
SegmentedButtons([]SegmentedButtonOption{
|
||
{Value: "basic", Label: "Basic"},
|
||
{Value: "advanced", Label: "Advanced"},
|
||
}, calcModeValue(e.advanced.Get()),
|
||
func(v string) { e.advanced.Set(v == "advanced") }, true, "w-40"),
|
||
),
|
||
)
|
||
|
||
fields := []*vdom.VNode{
|
||
calcField(nameLabel, FormInput(FormInputProps{
|
||
Value: e.name.Get(),
|
||
Placeholder: "Annual salary",
|
||
Small: true,
|
||
OnInput: func(v string) { e.name.Set(v) },
|
||
})),
|
||
}
|
||
|
||
if e.advanced.Get() {
|
||
fields = append(fields, s.calcAdvancedEditor(e, summary))
|
||
} else {
|
||
fields = append(fields, s.calcBasicEditor(e, summary))
|
||
}
|
||
|
||
fields = append(fields, vdom.Div(vdom.Attr("class", "grid grid-cols-2 gap-2"),
|
||
calcField("Format", FormSelect(FormSelectProps{
|
||
Value: e.dataType.Get(), Small: true,
|
||
OnChange: func(v string) { e.dataType.Set(v) },
|
||
}, calcTypeOptions()...)),
|
||
calcField("Decimals", FormInput(FormInputProps{
|
||
Value: e.precision.Get(), Type: "number", Placeholder: "auto", Small: true,
|
||
OnInput: func(v string) { e.precision.Set(v) },
|
||
})),
|
||
))
|
||
|
||
// A footer row has no column of its own to align.
|
||
if !summary {
|
||
fields = append(fields, calcField("Align", FormSelect(FormSelectProps{
|
||
Value: strconv.Itoa(e.position.Get()), Small: true,
|
||
OnChange: func(v string) {
|
||
if n, err := strconv.Atoi(v); err == nil {
|
||
e.position.Set(n)
|
||
}
|
||
},
|
||
},
|
||
FormOption(strconv.Itoa(int(COL_POS_LEFT)), "Left", false),
|
||
FormOption(strconv.Itoa(int(COL_POS_CENTER)), "Center", false),
|
||
FormOption(strconv.Itoa(int(COL_POS_RIGHT)), "Right", false),
|
||
)))
|
||
}
|
||
|
||
if msg := e.errorMsg.Get(); msg != "" {
|
||
fields = append(fields, vdom.P(vdom.Attr("class", "text-xs text-red-600"), vdom.Text(msg)))
|
||
}
|
||
|
||
saveLabel := "Add"
|
||
if editing {
|
||
saveLabel = "Save"
|
||
}
|
||
fields = append(fields, vdom.Div(vdom.Attr("class", "flex items-center gap-2 pt-1"),
|
||
Button(ButtonProps{Color: ButtonPrimary, Small: true, Text: saveLabel,
|
||
OnClick: func() { s.saveCalc(e, summary) }}),
|
||
Button(ButtonProps{Color: ButtonLightNeutral, Small: true, Text: "Cancel", OnClick: e.reset}),
|
||
))
|
||
|
||
return vdom.Div(vdom.Attr("class", "flex flex-col"),
|
||
header,
|
||
vdom.Div(kids([]vdom.Mod{vdom.Attr("class", "flex flex-col gap-2 p-3")}, fields)...),
|
||
)
|
||
}
|
||
|
||
func calcModeValue(advanced bool) string {
|
||
if advanced {
|
||
return "advanced"
|
||
}
|
||
return "basic"
|
||
}
|
||
|
||
func calcTypeOptions() []*vdom.VNode {
|
||
out := make([]*vdom.VNode, 0, len(calcDataTypes))
|
||
for _, dt := range calcDataTypes {
|
||
out = append(out, FormOption(string(dt.Value), dt.Label, false))
|
||
}
|
||
return out
|
||
}
|
||
|
||
// calcBasicEditor: a function, and the columns it applies to.
|
||
//
|
||
// The shape of the operand picker follows from what the function MEANS:
|
||
//
|
||
// - a footer row aggregates ONE column down the table -> one picker
|
||
// - subtract / divide are binary and ORDERED -> two pickers, in order
|
||
// - everything else combines N columns across the row -> a multi-select
|
||
func (s *AutoTableState) calcBasicEditor(e *calcEditor, summary bool) *vdom.VNode {
|
||
opts := s.operandOptions(e.editingID.Get())
|
||
|
||
selectOpts := make([]*vdom.VNode, 0, len(opts)+1)
|
||
selectOpts = append(selectOpts, FormOption("", "Choose a column…", false))
|
||
for _, o := range opts {
|
||
selectOpts = append(selectOpts, FormOption(o.Key, o.Label, false))
|
||
}
|
||
fnOpts := make([]*vdom.VNode, 0, len(calcFunctions))
|
||
for _, f := range calcFunctions {
|
||
fnOpts = append(fnOpts, FormOption(string(f.Value), f.Label, false))
|
||
}
|
||
|
||
children := []*vdom.VNode{
|
||
calcField("Function", FormSelect(FormSelectProps{
|
||
Value: e.fn.Get(), Small: true,
|
||
OnChange: func(v string) {
|
||
e.fn.Set(v)
|
||
e.errorMsg.Set("")
|
||
},
|
||
}, fnOpts...)),
|
||
}
|
||
|
||
switch {
|
||
case summary:
|
||
children = append(children, calcField("Aggregate down this column",
|
||
FormSelect(FormSelectProps{
|
||
Value: e.operandAt(0), Small: true,
|
||
OnChange: func(v string) { e.operands.Set([]string{v}) },
|
||
}, selectOpts...)))
|
||
|
||
case isBinaryCalcFn(e.currentFn()):
|
||
first, second := "Value", "Minus"
|
||
if e.currentFn() == CALC_FN_DIVIDE {
|
||
first, second = "Numerator", "Denominator"
|
||
}
|
||
children = append(children, vdom.Div(vdom.Attr("class", "grid grid-cols-2 gap-2"),
|
||
calcField(first, FormSelect(FormSelectProps{
|
||
Value: e.operandAt(0), Small: true,
|
||
OnChange: func(v string) { e.setOperand(0, v) },
|
||
}, selectOpts...)),
|
||
calcField(second, FormSelect(FormSelectProps{
|
||
Value: e.operandAt(1), Small: true,
|
||
OnChange: func(v string) { e.setOperand(1, v) },
|
||
}, selectOpts...)),
|
||
))
|
||
|
||
default:
|
||
msOpts := make([]FormSelectOption, 0, len(opts))
|
||
for _, o := range opts {
|
||
msOpts = append(msOpts, FormSelectOption{Value: o.Key, Label: o.Label})
|
||
}
|
||
children = append(children, calcField("Columns (combined per row)",
|
||
e.opSelect.Render(FormMultiSelectProps{
|
||
Options: msOpts,
|
||
Value: e.operands.Get(),
|
||
Placeholder: "Select columns…",
|
||
Searchable: true,
|
||
Small: true,
|
||
OnChange: func(v []string) { e.operands.Set(v) },
|
||
})))
|
||
}
|
||
|
||
children = append(children, vdom.P(vdom.Attr("class", "text-xs text-neutral-500"),
|
||
vdom.Text(calcBasicHint(e, summary))))
|
||
|
||
return vdom.Div(kids([]vdom.Mod{vdom.Attr("class", "flex flex-col gap-2")}, children)...)
|
||
}
|
||
|
||
// calcBasicHint spells out what the current choice will actually do — the single
|
||
// most confusable thing about this model.
|
||
func calcBasicHint(e *calcEditor, summary bool) string {
|
||
switch {
|
||
case summary:
|
||
return "Aggregates one column down the whole filtered table."
|
||
case isBinaryCalcFn(e.currentFn()):
|
||
return "Two columns, in order, combined within each row."
|
||
default:
|
||
return "The chosen columns are combined ACROSS each row — not down the table. " +
|
||
"For a column total, add a summary row instead."
|
||
}
|
||
}
|
||
|
||
// calcAdvancedEditor: the formula box, its insert menus, and a live preview.
|
||
func (s *AutoTableState) calcAdvancedEditor(e *calcEditor, summary bool) *vdom.VNode {
|
||
src := e.formula.Get()
|
||
opts := s.operandOptions(e.editingID.Get())
|
||
|
||
// --- insert menus ---
|
||
colItems := make([]*vdom.VNode, 0, len(opts)*2)
|
||
for _, o := range opts {
|
||
label := o.Label
|
||
colItems = append(colItems,
|
||
e.colMenu.Item(MenuItemProps{OnClick: func() { e.insertAtCaret("[" + label + "]") }},
|
||
vdom.Span(vdom.Attr("class", "font-mono text-sky-600"), vdom.Text("["+label+"]")),
|
||
vdom.Span(vdom.Attr("class", "ml-2 text-xs text-neutral-500"), vdom.Text("this row")),
|
||
),
|
||
e.colMenu.Item(MenuItemProps{OnClick: func() { e.insertAtCaret("{" + label + "}") }},
|
||
vdom.Span(vdom.Attr("class", "font-mono text-violet-600"), vdom.Text("{"+label+"}")),
|
||
vdom.Span(vdom.Attr("class", "ml-2 text-xs text-neutral-500"), vdom.Text("whole column")),
|
||
),
|
||
)
|
||
}
|
||
|
||
// The function menu, grouped by category and searchable. Thirty-five names in one
|
||
// flat list is a wall; grouped by what they are FOR, with a signature and a
|
||
// sentence, it is something you can actually find SUM in.
|
||
fnItems := []*vdom.VNode{e.fnSearchBox()}
|
||
groups := filterFormulaGroups(e.fnSearch.Get())
|
||
if len(groups) == 0 {
|
||
fnItems = append(fnItems, vdom.Div(
|
||
vdom.Attr("class", "px-2 py-3 text-center text-xs text-neutral-500"),
|
||
vdom.Text("No functions match"),
|
||
))
|
||
}
|
||
for _, g := range groups {
|
||
fnItems = append(fnItems, MenuSection("", vdom.Text(g.Label)))
|
||
for _, fn := range g.Fns {
|
||
f := fn
|
||
fnItems = append(fnItems, e.fnMenu.Item(MenuItemProps{
|
||
OnClick: func() { e.insertFunction(f) },
|
||
},
|
||
vdom.Div(vdom.Attr("class", "flex flex-col items-start gap-0.5 min-w-0"),
|
||
// The signature is syntax-highlighted with the same highlighter the
|
||
// formula box uses, so the menu and the editor speak one language.
|
||
vdom.Span(vdom.Attr("class", "font-mono text-xs"), vdom.Raw(HighlightFormula(f.Sig))),
|
||
vdom.Span(vdom.Attr("class", "text-xs text-neutral-500"), vdom.Text(f.Desc)),
|
||
),
|
||
))
|
||
}
|
||
}
|
||
|
||
constItems := make([]*vdom.VNode, 0, len(FormulaConstantOptions))
|
||
for _, c := range FormulaConstantOptions {
|
||
cc := c
|
||
constItems = append(constItems, e.constMenu.Item(MenuItemProps{
|
||
OnClick: func() { e.insertAtCaret(cc.Name) },
|
||
},
|
||
vdom.Div(vdom.Attr("class", "flex flex-col items-start gap-0.5"),
|
||
vdom.Span(vdom.Attr("class", "font-mono text-xs text-amber-600"), vdom.Text(cc.Name)),
|
||
vdom.Span(vdom.Attr("class", "text-xs text-neutral-500"), vdom.Text(cc.Desc)),
|
||
),
|
||
))
|
||
}
|
||
|
||
menus := vdom.Div(vdom.Attr("class", "flex flex-wrap items-center gap-1"),
|
||
e.colMenu.TriggerFunc(MenuTriggerProps{Tag: "div"}, func(bool) *vdom.VNode {
|
||
return vdom.Span(vdom.Attr("class", calcMenuTrigger), vdom.Text("Column"), Icon("caret-down", 12, ""))
|
||
}),
|
||
e.colMenu.Content("max-h-64 overflow-y-auto", colItems...),
|
||
|
||
e.fnMenu.TriggerFunc(MenuTriggerProps{Tag: "div"}, func(bool) *vdom.VNode {
|
||
return vdom.Span(vdom.Attr("class", calcMenuTrigger), vdom.Text("Function"), Icon("caret-down", 12, ""))
|
||
}),
|
||
e.fnMenu.Content("max-h-64 overflow-y-auto", fnItems...),
|
||
|
||
e.constMenu.TriggerFunc(MenuTriggerProps{Tag: "div"}, func(bool) *vdom.VNode {
|
||
return vdom.Span(vdom.Attr("class", calcMenuTrigger), vdom.Text("Constant"), Icon("caret-down", 12, ""))
|
||
}),
|
||
e.constMenu.Content("", constItems...),
|
||
)
|
||
|
||
// The highlighted overlay, and the transparent textarea on top of it.
|
||
//
|
||
// The overlay is rendered declaratively (a signal write per keystroke re-renders
|
||
// anyway), so it cannot drift out of sync with the value the way an imperatively
|
||
// repainted one can. Its scroll, though, must follow the textarea's — the caret
|
||
// runs off the right edge on a long formula, and the colours have to travel with
|
||
// it.
|
||
overlayHTML := formulaPlaceholder
|
||
if strings.TrimSpace(src) != "" {
|
||
overlayHTML = HighlightFormula(src)
|
||
}
|
||
|
||
editor := vdom.Div(vdom.Attr("class", "relative"),
|
||
vdom.Div(vdom.WithRef(e.overlayRef),
|
||
vdom.Attr("class", FORMULA_OVERLAY_CLS),
|
||
vdom.Attr("aria-hidden", "true"),
|
||
vdom.Raw(overlayHTML),
|
||
),
|
||
vdom.Textarea(vdom.WithRef(e.formulaRef),
|
||
vdom.Attr("class", FORMULA_TEXTAREA_CLS),
|
||
vdom.Attr("rows", "1"),
|
||
vdom.Attr("wrap", "off"),
|
||
vdom.Attr("spellcheck", "false"),
|
||
vdom.Prop("value", src),
|
||
vdom.OnEvent(vdom.EVENT_INPUT, func(ev vdom.Event) {
|
||
e.formula.Set(ev.Value())
|
||
e.errorMsg.Set("")
|
||
}),
|
||
vdom.On(vdom.EVENT_SCROLL, e.syncOverlayScroll),
|
||
),
|
||
)
|
||
|
||
help := vdom.P(vdom.Attr("class", "text-xs text-neutral-500"),
|
||
vdom.Text("[Column] is this row's cell · {Column} is the whole column · {Column:1:ROW()} is a running total."))
|
||
|
||
children := []*vdom.VNode{menus, editor, help}
|
||
if status := s.calcPreview(e, src, summary); status != nil {
|
||
children = append(children, status)
|
||
}
|
||
return calcField("Formula", children...)
|
||
}
|
||
|
||
// fnSearchBox filters the function menu. It is sticky, so it stays reachable as the
|
||
// list scrolls, and it is focused when the menu opens — you can type "total", see SUM,
|
||
// and never touch the mouse.
|
||
func (e *calcEditor) fnSearchBox() *vdom.VNode {
|
||
wasmruntime.AfterRender(func() { wasmruntime.Focus(e.fnSearchRef) })
|
||
|
||
return vdom.Div(vdom.Attr("class", "sticky top-0 z-10 -m-1.5 mb-1 border-b border-neutral-200 bg-white p-1.5"),
|
||
vdom.Input(
|
||
vdom.WithRef(e.fnSearchRef),
|
||
vdom.Attr("type", "text"),
|
||
vdom.Attr("class", "w-full rounded-default border border-neutral-300 p-1 text-xs focus:outline-2 focus:outline-sky-500"),
|
||
vdom.Attr("placeholder", "Search functions…"),
|
||
vdom.Attr("spellcheck", "false"),
|
||
vdom.Prop("value", e.fnSearch.Get()),
|
||
vdom.OnEvent(vdom.EVENT_INPUT, func(ev vdom.Event) { e.fnSearch.Set(ev.Value()) }),
|
||
),
|
||
)
|
||
}
|
||
|
||
// insertFunction drops a call in and puts the caret INSIDE the parentheses, ready for
|
||
// the first argument — SUM() with the cursor after the "(" rather than after the ")",
|
||
// which would make you move it back every single time.
|
||
//
|
||
// A no-argument function (ROW(), PI()) has nothing to type, so the caret goes after it.
|
||
func (e *calcEditor) insertFunction(f FormulaFunction) {
|
||
noArgs := strings.HasSuffix(f.Sig, "()")
|
||
e.insertAtCaretOffset(f.Name+"()", boolToInt(!noArgs))
|
||
e.fnSearch.Set("")
|
||
e.fnMenu.Close()
|
||
}
|
||
|
||
func boolToInt(b bool) int {
|
||
if b {
|
||
return 1
|
||
}
|
||
return 0
|
||
}
|
||
|
||
// syncOverlayScroll keeps the colours under the caret. Imperative on purpose: this
|
||
// fires on every scroll frame, and a signal write would re-render the whole table.
|
||
func (e *calcEditor) syncOverlayScroll() {
|
||
wasmruntime.SetScrollLeft(e.overlayRef, wasmruntime.ScrollLeft(e.formulaRef))
|
||
}
|
||
|
||
// calcPreview compiles the draft and evaluates it against the table's real first row.
|
||
// A formula that does not compile says why; one that does shows what it will produce.
|
||
func (s *AutoTableState) calcPreview(e *calcEditor, src string, summary bool) *vdom.VNode {
|
||
if strings.TrimSpace(src) == "" {
|
||
return nil
|
||
}
|
||
if _, err := CompileFormula(src); err != nil {
|
||
return vdom.P(vdom.Attr("class", "text-xs text-red-600"), vdom.Text(err.Error()))
|
||
}
|
||
|
||
rows := s.FilteredRows()
|
||
if len(rows) == 0 {
|
||
return vdom.P(vdom.Attr("class", "text-xs text-neutral-500"), vdom.Text("Formula is valid."))
|
||
}
|
||
ctx := NewCalcContext(rows, s.cols, s.Calculated(), s.read)
|
||
|
||
// A footer row is evaluated with NO current row, so [Cell] and ROW() are NaN
|
||
// there — preview it the way it will actually run.
|
||
target, prefix := ctx.ForRow(0), "First row → "
|
||
if summary {
|
||
target, prefix = ctx.ForRow(NoRow), "Result → "
|
||
}
|
||
|
||
n, err := EvalFormula(src, target)
|
||
if err != nil {
|
||
return vdom.P(vdom.Attr("class", "text-xs text-red-600"), vdom.Text(err.Error()))
|
||
}
|
||
out := FormatCalcResult(n, CalculatedDataType(e.dataType.Get()), calcPrecisionOf(e.precision.Get()), "", "", "")
|
||
return vdom.P(vdom.Attr("class", "text-xs text-neutral-500"), vdom.Text(prefix+out))
|
||
}
|
||
|
||
// insertAtCaret drops text where the cursor is, rather than at the end — which is
|
||
// the whole point of an insert menu. It needs the caret position and focus back
|
||
// afterwards, both of which come from the host API.
|
||
func (e *calcEditor) insertAtCaret(text string) { e.insertAtCaretOffset(text, 0) }
|
||
|
||
// insertAtCaretOffset inserts text and leaves the caret `back` characters from its
|
||
// end — so a function can land with the cursor between its parentheses.
|
||
func (e *calcEditor) insertAtCaretOffset(text string, back int) {
|
||
src := e.formula.Get()
|
||
start, end := wasmruntime.SelectionRange(e.formulaRef)
|
||
if start < 0 || start > len(src) || end < start || end > len(src) {
|
||
start, end = len(src), len(src) // not mounted (or SSR): append
|
||
}
|
||
e.formula.Set(src[:start] + text + src[end:])
|
||
e.errorMsg.Set("")
|
||
|
||
// The textarea's value is written by the render this signal just scheduled, so the
|
||
// caret can only be placed once that render has landed.
|
||
caret := start + len(text) - back
|
||
wasmruntime.AfterRender(func() {
|
||
wasmruntime.Focus(e.formulaRef)
|
||
wasmruntime.SetSelectionRange(e.formulaRef, caret, caret)
|
||
e.syncOverlayScroll()
|
||
})
|
||
}
|
||
|
||
func calcField(label string, children ...*vdom.VNode) *vdom.VNode {
|
||
nodes := []*vdom.VNode{
|
||
vdom.Span(vdom.Attr("class", "text-xs font-medium text-neutral-600"), vdom.Text(label)),
|
||
}
|
||
nodes = append(nodes, children...)
|
||
return vdom.Div(kids([]vdom.Mod{vdom.Attr("class", "flex flex-col gap-1")}, nodes)...)
|
||
}
|
||
|
||
// calcValidate returns the reason the current form cannot be saved, or "".
|
||
//
|
||
// The operand rules are the model's, not the form's: a binary function needs both of
|
||
// its ordered operands, everything else needs at least one, and a formula has to
|
||
// compile. Saving something that cannot evaluate would add a column of dashes and
|
||
// leave the user with no idea why.
|
||
func (s *AutoTableState) calcValidate(e *calcEditor, summary bool) string {
|
||
if strings.TrimSpace(e.name.Get()) == "" {
|
||
return "Enter a name."
|
||
}
|
||
if e.advanced.Get() {
|
||
if strings.TrimSpace(e.formula.Get()) == "" {
|
||
return "Enter a formula."
|
||
}
|
||
if _, err := CompileFormula(e.formula.Get()); err != nil {
|
||
return err.Error()
|
||
}
|
||
return ""
|
||
}
|
||
|
||
operands := nonEmpty(e.operands.Get())
|
||
switch {
|
||
case summary:
|
||
if len(operands) < 1 {
|
||
return "Pick a column to aggregate."
|
||
}
|
||
case isBinaryCalcFn(e.currentFn()):
|
||
if len(operands) < 2 {
|
||
return "Pick both columns."
|
||
}
|
||
default:
|
||
if len(operands) < 1 {
|
||
return "Pick at least one column."
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func nonEmpty(xs []string) []string {
|
||
out := make([]string, 0, len(xs))
|
||
for _, x := range xs {
|
||
if strings.TrimSpace(x) != "" {
|
||
out = append(out, x)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func (s *AutoTableState) saveCalc(e *calcEditor, summary bool) {
|
||
if msg := s.calcValidate(e, summary); msg != "" {
|
||
e.errorMsg.Set(msg)
|
||
return
|
||
}
|
||
|
||
fn := e.currentFn()
|
||
operands := nonEmpty(e.operands.Get())
|
||
formula := strings.TrimSpace(e.formula.Get())
|
||
if e.advanced.Get() {
|
||
// An advanced definition is always saved as "custom": the formula IS the
|
||
// definition, and a stale basic fn/operand pair left beside it would be a
|
||
// second, contradictory source of truth.
|
||
fn = CALC_FN_CUSTOM
|
||
operands = nil
|
||
} else {
|
||
formula = ""
|
||
// A binary function's operands are positional, so only the first two mean
|
||
// anything — the engine would silently ignore the rest.
|
||
if isBinaryCalcFn(fn) && len(operands) > 2 {
|
||
operands = operands[:2]
|
||
}
|
||
}
|
||
|
||
if summary {
|
||
s.AddSummaryRow(UserSummaryRow{
|
||
ID: e.editingID.Get(),
|
||
Label: e.name.Get(),
|
||
Fn: fn,
|
||
Operands: operands,
|
||
Formula: formula,
|
||
DataType: CalculatedDataType(e.dataType.Get()),
|
||
Precision: calcPrecisionOf(e.precision.Get()),
|
||
})
|
||
} else {
|
||
s.AddCalculated(UserCalculatedColumn{
|
||
ID: e.editingID.Get(),
|
||
DisplayName: e.name.Get(),
|
||
Fn: fn,
|
||
Operands: operands,
|
||
Formula: formula,
|
||
DataType: CalculatedDataType(e.dataType.Get()),
|
||
Precision: calcPrecisionOf(e.precision.Get()),
|
||
DisplayPosition: ColumnPosition(e.position.Get()),
|
||
})
|
||
}
|
||
e.reset()
|
||
}
|
||
|
||
// calcPrecisionOf turns the form's text into the *int the model wants. Blank means
|
||
// unset, which is NOT the same as 0: unset formats naturally ("1,234.5"), 0 rounds to
|
||
// whole numbers ("1,235").
|
||
func calcPrecisionOf(s string) *int {
|
||
s = strings.TrimSpace(s)
|
||
if s == "" {
|
||
return nil
|
||
}
|
||
n, err := strconv.Atoi(s)
|
||
if err != nil || n < 0 {
|
||
return nil
|
||
}
|
||
return CalcPrecision(n)
|
||
}
|
||
|
||
// ==========================================================================
|
||
// The formula engine: tokenizer, parser, evaluator
|
||
// ==========================================================================
|
||
|
||
// AutoTable's calculated columns and summary rows: an Excel-style formula engine,
|
||
// ported from AutoTable.tsx. A user builds a column at runtime either from one of
|
||
// the ten predefined functions over a list of operand columns, or from a custom
|
||
// formula — a small expression language with operators, ~30 functions, and
|
||
// references that reach both across columns and down rows:
|
||
//
|
||
// [Name] the current row's value for that column (a scalar)
|
||
// {Name} that column's value in every row (an array)
|
||
// {Name:n} the nth (1-based) row of that column (a scalar)
|
||
// {Name:a:b} rows a..b inclusive (an array; Excel's ":" range)
|
||
//
|
||
// e.g. [Revenue] / SUM({Revenue}) * 100 (% of the column total)
|
||
// SUM({Revenue:1:ROW()}) (running total to the current row)
|
||
// AVERAGE({Sales:ROW()-2:ROW()}) (trailing 3-row moving average)
|
||
//
|
||
// Indices and bounds are themselves expressions, so ROW() composes with them.
|
||
// Aggregate functions flatten array arguments; arithmetic and comparisons are
|
||
// scalar-only (an array operand yields NaN). Everything here is pure Go — no DOM
|
||
// — so it runs under wasm, on the server, and in tests.
|
||
//
|
||
// Two layers, deliberately separable:
|
||
//
|
||
// - CompileFormula parses a formula once (it is the only thing that reports a
|
||
// syntax error) into a *Formula that evaluates against any FormulaContext.
|
||
// - CalcContext is the standard context: it resolves [Name] / {Name} against a
|
||
// set of rows plus the user's other calculated columns, with cycle detection.
|
||
//
|
||
// On errors, and the one place this port deliberately parts company with the TSX:
|
||
// the original signals every RUNTIME problem — an unknown column, a cycle, a
|
||
// division by zero — by quietly returning NaN, which the display path then renders
|
||
// as the empty value. Silently correct, but it leaves a user with a broken formula
|
||
// staring at "—" with no idea why. Here evaluation still produces NaN (so the
|
||
// rendered result is identical), but the first fault is also returned as an error,
|
||
// which the editor can surface. Callers that only want the TSX's behavior ignore
|
||
// it — FormatCalculatedColumn does exactly that.
|
||
|
||
// CalculatedFunction is a predefined (non-formula) function for a calculated
|
||
// column or a summary row. The aggregates (sum/average/median/mode/min/max/count)
|
||
// ignore blank and non-numeric operands, the way Excel's do; the arithmetic ones
|
||
// (subtract/multiply/divide) require every operand to be a number and otherwise
|
||
// yield NaN. CALC_FN_CUSTOM instead evaluates the column's Formula.
|
||
type CalculatedFunction string
|
||
|
||
const (
|
||
CALC_FN_SUM CalculatedFunction = "sum"
|
||
CALC_FN_SUBTRACT CalculatedFunction = "subtract"
|
||
CALC_FN_MULTIPLY CalculatedFunction = "multiply"
|
||
CALC_FN_DIVIDE CalculatedFunction = "divide"
|
||
CALC_FN_AVERAGE CalculatedFunction = "average"
|
||
CALC_FN_MEDIAN CalculatedFunction = "median"
|
||
CALC_FN_MODE CalculatedFunction = "mode"
|
||
CALC_FN_MIN CalculatedFunction = "min"
|
||
CALC_FN_MAX CalculatedFunction = "max"
|
||
CALC_FN_COUNT CalculatedFunction = "count"
|
||
CALC_FN_CUSTOM CalculatedFunction = "custom"
|
||
)
|
||
|
||
// CalculatedDataType drives how a calculated result is formatted for display, CSV
|
||
// and PDF. See FormatCalcResult.
|
||
type CalculatedDataType string
|
||
|
||
const (
|
||
CALC_TYPE_PLAIN CalculatedDataType = "plain"
|
||
CALC_TYPE_NUMBER CalculatedDataType = "number"
|
||
CALC_TYPE_INTEGER CalculatedDataType = "integer"
|
||
CALC_TYPE_DECIMAL CalculatedDataType = "decimal"
|
||
CALC_TYPE_MONEY CalculatedDataType = "money"
|
||
CALC_TYPE_PERCENT CalculatedDataType = "percent"
|
||
)
|
||
|
||
// CalcEmptyValue is what a result that is not a finite number renders as.
|
||
const CalcEmptyValue = "—"
|
||
|
||
// CalcRefPrefix marks an operand that names another calculated column rather than
|
||
// a data field. Data operands are a column's SortIdentifier (the row field to
|
||
// read); calculated ones are CalcRef(id).
|
||
const CalcRefPrefix = "_calc_"
|
||
|
||
// CalcRef is the operand key (and sort identifier) of a calculated column.
|
||
func CalcRef(id string) string { return CalcRefPrefix + id }
|
||
|
||
// UserCalculatedColumn is a column the user builds at runtime. For a predefined
|
||
// Fn, Operands name the columns to combine (a data column's SortIdentifier, or
|
||
// CalcRef(id) for another calculated column). For CALC_FN_CUSTOM, Formula holds an
|
||
// expression that names columns by display name — [Revenue], {Revenue}.
|
||
type UserCalculatedColumn struct {
|
||
ID string
|
||
DisplayName string
|
||
Fn CalculatedFunction
|
||
Operands []string
|
||
Formula string
|
||
DataType CalculatedDataType
|
||
// Precision is the fixed number of decimal places. It is a pointer because
|
||
// "unset" and "zero" mean different things: CALC_TYPE_NUMBER with no precision
|
||
// formats naturally ("1,234.5"), with precision 0 it rounds ("1,235").
|
||
Precision *int
|
||
DisplayPosition ColumnPosition
|
||
}
|
||
|
||
// UserSummaryRow is a footer line (Total, Subtotal, …). Unlike a calculated
|
||
// column it is evaluated ONCE, with no current row, so [cell] references and ROW()
|
||
// are NaN and only whole-column aggregates like SUM({Revenue}) are meaningful. In
|
||
// basic mode Fn aggregates Operands[0] down the rows; in custom mode Formula is
|
||
// evaluated.
|
||
type UserSummaryRow struct {
|
||
ID string
|
||
Label string
|
||
Fn CalculatedFunction
|
||
Operands []string
|
||
Formula string
|
||
DataType CalculatedDataType
|
||
Precision *int
|
||
}
|
||
|
||
// CalcPrecision is the pointer form of a precision, for a struct literal:
|
||
// UserCalculatedColumn{..., Precision: CalcPrecision(0)}.
|
||
func CalcPrecision(n int) *int { return &n }
|
||
|
||
// Faults an evaluation can report. They are diagnostics: the value NaN is produced
|
||
// regardless, so a table renders the empty value rather than failing (see the note
|
||
// at the top of the file).
|
||
var (
|
||
ErrEmptyFormula = errors.New("empty formula")
|
||
ErrFormulaSyntax = errors.New("formula syntax error")
|
||
ErrUnknownFunction = errors.New("unknown function")
|
||
ErrUnknownName = errors.New("unknown name")
|
||
ErrUnknownColumn = errors.New("unknown column")
|
||
ErrFormulaCycle = errors.New("formula cycle")
|
||
ErrDivideByZero = errors.New("division by zero")
|
||
)
|
||
|
||
// ---- reading and combining operand values ----
|
||
|
||
// ToCalcNumber parses a row value as a number, tolerating the formatted strings a
|
||
// row often carries ("$1,234.56", "12%"). NaN when there is nothing numeric to
|
||
// read, which is how a blank cell drops out of an aggregate.
|
||
func ToCalcNumber(v any) float64 {
|
||
if f, ok := toFloat(v); ok {
|
||
return f
|
||
}
|
||
if v == nil {
|
||
return math.NaN()
|
||
}
|
||
cleaned := strings.Map(func(r rune) rune {
|
||
switch r {
|
||
case '$', ',', '%', ' ', '\t', '\n', '\r', '\v', '\f':
|
||
return -1
|
||
}
|
||
return r
|
||
}, stringify(v))
|
||
if cleaned == "" {
|
||
return math.NaN()
|
||
}
|
||
return calcParseFloat(cleaned)
|
||
}
|
||
|
||
// calcParseFloat mirrors JavaScript's parseFloat: it reads the longest numeric
|
||
// prefix and ignores the rest ("12abc" is 12), and is NaN when there is no leading
|
||
// number at all. strconv.ParseFloat rejects both, and a stricter rule here would
|
||
// silently drop values the TSX accepts.
|
||
func calcParseFloat(s string) float64 {
|
||
i := 0
|
||
if i < len(s) && (s[i] == '+' || s[i] == '-') {
|
||
i++
|
||
}
|
||
digits := false
|
||
for i < len(s) && isDigit(s[i]) {
|
||
i++
|
||
digits = true
|
||
}
|
||
if i < len(s) && s[i] == '.' {
|
||
i++
|
||
for i < len(s) && isDigit(s[i]) {
|
||
i++
|
||
digits = true
|
||
}
|
||
}
|
||
if !digits {
|
||
return math.NaN()
|
||
}
|
||
end := i
|
||
if i < len(s) && (s[i] == 'e' || s[i] == 'E') {
|
||
j := i + 1
|
||
if j < len(s) && (s[j] == '+' || s[j] == '-') {
|
||
j++
|
||
}
|
||
k := j
|
||
for k < len(s) && isDigit(s[k]) {
|
||
k++
|
||
}
|
||
if k > j {
|
||
end = k
|
||
}
|
||
}
|
||
f, err := strconv.ParseFloat(s[:end], 64)
|
||
if err != nil {
|
||
return math.NaN()
|
||
}
|
||
return f
|
||
}
|
||
|
||
// ApplyCalcFunction combines operand values with a predefined function. The
|
||
// aggregates skip NaN operands (a blank cell is not a zero); the arithmetic ones
|
||
// propagate NaN, so a row missing one of its operands shows the empty value rather
|
||
// than a plausible-looking wrong number.
|
||
//
|
||
// An empty operand list is NaN — except for COUNT, which is 0. MODE is NaN when no
|
||
// value repeats. Division by zero is NaN.
|
||
func ApplyCalcFunction(fn CalculatedFunction, operands []float64) float64 {
|
||
if fn == CALC_FN_COUNT {
|
||
return float64(len(calcNumbers(operands)))
|
||
}
|
||
if len(operands) == 0 {
|
||
return math.NaN()
|
||
}
|
||
|
||
switch fn {
|
||
case CALC_FN_SUM, CALC_FN_AVERAGE, CALC_FN_MIN, CALC_FN_MAX:
|
||
nums := calcNumbers(operands)
|
||
if len(nums) == 0 {
|
||
return math.NaN()
|
||
}
|
||
switch fn {
|
||
case CALC_FN_SUM:
|
||
return calcTotal(nums)
|
||
case CALC_FN_AVERAGE:
|
||
return calcTotal(nums) / float64(len(nums))
|
||
case CALC_FN_MIN:
|
||
out := nums[0]
|
||
for _, n := range nums[1:] {
|
||
out = math.Min(out, n)
|
||
}
|
||
return out
|
||
default:
|
||
out := nums[0]
|
||
for _, n := range nums[1:] {
|
||
out = math.Max(out, n)
|
||
}
|
||
return out
|
||
}
|
||
|
||
case CALC_FN_MEDIAN:
|
||
nums := calcNumbers(operands)
|
||
if len(nums) == 0 {
|
||
return math.NaN()
|
||
}
|
||
sort.Float64s(nums)
|
||
mid := len(nums) / 2
|
||
if len(nums)%2 == 1 {
|
||
return nums[mid]
|
||
}
|
||
return (nums[mid-1] + nums[mid]) / 2
|
||
|
||
case CALC_FN_MODE:
|
||
nums := calcNumbers(operands)
|
||
counts := make(map[float64]int, len(nums))
|
||
best, bestCount := math.NaN(), 0
|
||
for _, n := range nums {
|
||
counts[n]++
|
||
if counts[n] > bestCount {
|
||
bestCount, best = counts[n], n
|
||
}
|
||
}
|
||
if bestCount > 1 {
|
||
return best // no repeated value -> no mode
|
||
}
|
||
return math.NaN()
|
||
|
||
case CALC_FN_SUBTRACT:
|
||
if calcHasNaN(operands) {
|
||
return math.NaN()
|
||
}
|
||
out := operands[0]
|
||
for _, n := range operands[1:] {
|
||
out -= n
|
||
}
|
||
return out
|
||
|
||
case CALC_FN_MULTIPLY:
|
||
if calcHasNaN(operands) {
|
||
return math.NaN()
|
||
}
|
||
out := 1.0
|
||
for _, n := range operands {
|
||
out *= n
|
||
}
|
||
return out
|
||
|
||
case CALC_FN_DIVIDE:
|
||
if calcHasNaN(operands) {
|
||
return math.NaN()
|
||
}
|
||
out := operands[0]
|
||
for _, n := range operands[1:] {
|
||
if n == 0 {
|
||
return math.NaN()
|
||
}
|
||
out /= n
|
||
}
|
||
return out
|
||
}
|
||
return math.NaN()
|
||
}
|
||
|
||
// calcNumbers drops the NaNs (a copy: median sorts it in place).
|
||
func calcNumbers(operands []float64) []float64 {
|
||
out := make([]float64, 0, len(operands))
|
||
for _, n := range operands {
|
||
if !math.IsNaN(n) {
|
||
out = append(out, n)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func calcHasNaN(operands []float64) bool {
|
||
for _, n := range operands {
|
||
if math.IsNaN(n) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func calcTotal(nums []float64) float64 {
|
||
var sum float64
|
||
for _, n := range nums {
|
||
sum += n
|
||
}
|
||
return sum
|
||
}
|
||
|
||
// ---- the formula language ----
|
||
|
||
// FormulaContext resolves the references a formula makes. CalcContext is the
|
||
// standard implementation; implement it yourself to evaluate a formula against
|
||
// something that is not an AutoTable.
|
||
type FormulaContext interface {
|
||
// Cell is [Name]: the named column's value in the current row. NaN — not an
|
||
// error — when the row simply has no numeric value there, or when there is no
|
||
// current row at all (a summary). The error is for a name that is not a column.
|
||
Cell(name string) (float64, error)
|
||
// Column is {Name}: the named column's value in every row.
|
||
Column(name string) ([]float64, error)
|
||
// Row is ROW(): the current row's 1-based position, NaN when there is none.
|
||
Row() float64
|
||
}
|
||
|
||
// formulaValue is the TSX's `number | number[]`: a formula's operands are scalars,
|
||
// but a {Column} reference produces the whole column and aggregates flatten it.
|
||
type formulaValue struct {
|
||
num float64
|
||
arr []float64
|
||
isArray bool
|
||
}
|
||
|
||
func scalarValue(n float64) formulaValue { return formulaValue{num: n} }
|
||
func arrayValue(a []float64) formulaValue { return formulaValue{arr: a, isArray: true} }
|
||
func (v formulaValue) scalar() float64 {
|
||
if v.isArray {
|
||
return math.NaN() // an array in scalar position is not a number
|
||
}
|
||
return v.num
|
||
}
|
||
|
||
// evalState carries the context plus the first runtime fault. Faults do not stop
|
||
// evaluation (the TSX has no way to stop); they are collected and returned.
|
||
type evalState struct {
|
||
ctx FormulaContext
|
||
err error
|
||
}
|
||
|
||
func (s *evalState) fail(err error) {
|
||
if s.err == nil {
|
||
s.err = err
|
||
}
|
||
}
|
||
|
||
type formulaNode func(s *evalState) formulaValue
|
||
|
||
// Formula is a parsed formula, ready to evaluate against any context. Compile once
|
||
// (per column) and evaluate per row — parsing is by far the expensive half.
|
||
type Formula struct {
|
||
root formulaNode
|
||
src string
|
||
}
|
||
|
||
// Source is the formula text this was compiled from.
|
||
func (f *Formula) Source() string { return f.src }
|
||
|
||
// Eval evaluates the formula. The result is NaN when the formula is not
|
||
// computable; err additionally names the first fault (see the note at the top of
|
||
// the file). A nil context resolves every reference to NaN, which is enough for a
|
||
// formula of pure literals and constants.
|
||
func (f *Formula) Eval(ctx FormulaContext) (float64, error) {
|
||
if ctx == nil {
|
||
ctx = emptyFormulaContext{}
|
||
}
|
||
s := &evalState{ctx: ctx}
|
||
v := f.root(s)
|
||
return v.scalar(), s.err
|
||
}
|
||
|
||
// EvalFormula compiles and evaluates in one step. Prefer CompileFormula when the
|
||
// same formula runs over many rows.
|
||
func EvalFormula(formula string, ctx FormulaContext) (float64, error) {
|
||
f, err := CompileFormula(formula)
|
||
if err != nil {
|
||
return math.NaN(), err
|
||
}
|
||
return f.Eval(ctx)
|
||
}
|
||
|
||
// emptyFormulaContext knows no columns and has no current row.
|
||
type emptyFormulaContext struct{}
|
||
|
||
func (emptyFormulaContext) Cell(name string) (float64, error) {
|
||
return math.NaN(), fmt.Errorf("%w: %q", ErrUnknownColumn, name)
|
||
}
|
||
func (emptyFormulaContext) Column(name string) ([]float64, error) {
|
||
return nil, fmt.Errorf("%w: %q", ErrUnknownColumn, name)
|
||
}
|
||
func (emptyFormulaContext) Row() float64 { return math.NaN() }
|
||
|
||
// FormulaConstants are the names a formula can write bare, e.g. 2 * PI * [r].
|
||
// Matched case-insensitively.
|
||
var FormulaConstants = map[string]float64{
|
||
"PI": math.Pi,
|
||
"E": math.E,
|
||
"TAU": math.Pi * 2,
|
||
"PHI": (1 + math.Sqrt(5)) / 2, // the golden ratio
|
||
"SQRT2": math.Sqrt2,
|
||
}
|
||
|
||
// ---- tokenizer ----
|
||
|
||
type formulaTokenKind int
|
||
|
||
const (
|
||
tokNum formulaTokenKind = iota
|
||
tokID
|
||
tokOp
|
||
tokPunc
|
||
tokRef // [Name] -- current-row cell
|
||
tokColRef // {Name}, {Name:i}, {Name:a:b} -- raw, split at parse time
|
||
)
|
||
|
||
type formulaToken struct {
|
||
kind formulaTokenKind
|
||
text string
|
||
}
|
||
|
||
func tokenizeFormula(src string) ([]formulaToken, error) {
|
||
var tokens []formulaToken
|
||
i := 0
|
||
for i < len(src) {
|
||
c := src[i]
|
||
switch {
|
||
case c == ' ' || c == '\t' || c == '\n' || c == '\r':
|
||
i++
|
||
|
||
case c == '[':
|
||
end := strings.IndexByte(src[i+1:], ']')
|
||
if end < 0 {
|
||
return nil, fmt.Errorf("%w: unclosed '[' reference", ErrFormulaSyntax)
|
||
}
|
||
end += i + 1
|
||
tokens = append(tokens, formulaToken{tokRef, strings.TrimSpace(src[i+1 : end])})
|
||
i = end + 1
|
||
|
||
case c == '{':
|
||
// Balanced-brace scan, so a nested {Col:...} used as an index survives
|
||
// intact ({A:{B:1}}).
|
||
depth, j := 1, i+1
|
||
for ; j < len(src); j++ {
|
||
if src[j] == '{' {
|
||
depth++
|
||
} else if src[j] == '}' {
|
||
depth--
|
||
if depth == 0 {
|
||
break
|
||
}
|
||
}
|
||
}
|
||
if depth != 0 {
|
||
return nil, fmt.Errorf("%w: unclosed '{' column reference", ErrFormulaSyntax)
|
||
}
|
||
tokens = append(tokens, formulaToken{tokColRef, strings.TrimSpace(src[i+1 : j])})
|
||
i = j + 1
|
||
|
||
case isDigit(c) || (c == '.' && i+1 < len(src) && isDigit(src[i+1])):
|
||
j := i + 1
|
||
for j < len(src) && (isDigit(src[j]) || src[j] == '.') {
|
||
j++
|
||
}
|
||
tokens = append(tokens, formulaToken{tokNum, src[i:j]})
|
||
i = j
|
||
|
||
case isFormulaAlpha(c):
|
||
j := i + 1
|
||
for j < len(src) && (isFormulaAlpha(src[j]) || isDigit(src[j])) {
|
||
j++
|
||
}
|
||
tokens = append(tokens, formulaToken{tokID, src[i:j]})
|
||
i = j
|
||
|
||
default:
|
||
if i+1 < len(src) {
|
||
if two := src[i : i+2]; two == "<=" || two == ">=" || two == "<>" {
|
||
tokens = append(tokens, formulaToken{tokOp, two})
|
||
i += 2
|
||
continue
|
||
}
|
||
}
|
||
switch {
|
||
case strings.IndexByte("+-*/^%=<>", c) >= 0:
|
||
tokens = append(tokens, formulaToken{tokOp, string(c)})
|
||
i++
|
||
case strings.IndexByte("(),", c) >= 0:
|
||
tokens = append(tokens, formulaToken{tokPunc, string(c)})
|
||
i++
|
||
default:
|
||
return nil, fmt.Errorf("%w: unexpected character %q", ErrFormulaSyntax, string(c))
|
||
}
|
||
}
|
||
}
|
||
return tokens, nil
|
||
}
|
||
|
||
// isFormulaAlpha: identifiers are letters and '_' (digits may follow).
|
||
func isFormulaAlpha(c byte) bool {
|
||
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_'
|
||
}
|
||
|
||
// topLevelColon is the index of the first ':' at brace/bracket/paren depth 0, or
|
||
// -1. It splits a column reference's name from its row index/range without
|
||
// tripping on a ':' inside a nested {ref} or a function's arguments.
|
||
func topLevelColon(s string) int {
|
||
depth := 0
|
||
for i := 0; i < len(s); i++ {
|
||
switch s[i] {
|
||
case '{', '[', '(':
|
||
depth++
|
||
case '}', ']', ')':
|
||
depth--
|
||
case ':':
|
||
if depth == 0 {
|
||
return i
|
||
}
|
||
}
|
||
}
|
||
return -1
|
||
}
|
||
|
||
// ---- parser ----
|
||
//
|
||
// Recursive descent, with the TSX's precedence exactly:
|
||
//
|
||
// comparison (= <> < > <= >=) lowest, left-assoc
|
||
// + - left-assoc
|
||
// * / % left-assoc ('%' is remainder, not percent)
|
||
// ^ right-assoc
|
||
// unary - + binds TIGHTER than '^', so -2^2 is 4 (as in Excel)
|
||
// primary literal, constant, call, [ref], {colref}, ( … )
|
||
|
||
// CompileFormula parses a formula. It is the only step that reports a syntax
|
||
// error; everything that can go wrong later (an unknown column, a cycle, a
|
||
// division by zero) surfaces at evaluation.
|
||
func CompileFormula(src string) (*Formula, error) {
|
||
tokens, err := tokenizeFormula(src)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if len(tokens) == 0 {
|
||
return nil, ErrEmptyFormula
|
||
}
|
||
p := &formulaParser{tokens: tokens}
|
||
root, err := p.parseComparison()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if p.pos < len(p.tokens) {
|
||
return nil, fmt.Errorf("%w: unexpected token %q", ErrFormulaSyntax, p.tokens[p.pos].text)
|
||
}
|
||
return &Formula{root: root, src: src}, nil
|
||
}
|
||
|
||
type formulaParser struct {
|
||
tokens []formulaToken
|
||
pos int
|
||
}
|
||
|
||
func (p *formulaParser) peek() (formulaToken, bool) {
|
||
if p.pos >= len(p.tokens) {
|
||
return formulaToken{}, false
|
||
}
|
||
return p.tokens[p.pos], true
|
||
}
|
||
|
||
func (p *formulaParser) next() (formulaToken, bool) {
|
||
t, ok := p.peek()
|
||
if ok {
|
||
p.pos++
|
||
}
|
||
return t, ok
|
||
}
|
||
|
||
func (p *formulaParser) expect(text string) error {
|
||
t, ok := p.next()
|
||
if !ok || t.text != text {
|
||
return fmt.Errorf("%w: expected %q", ErrFormulaSyntax, text)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// isOp reports whether the next token is one of the given operators.
|
||
func (p *formulaParser) isOp(ops ...string) bool {
|
||
t, ok := p.peek()
|
||
if !ok || t.kind != tokOp {
|
||
return false
|
||
}
|
||
for _, op := range ops {
|
||
if t.text == op {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func (p *formulaParser) isPunc(text string) bool {
|
||
t, ok := p.peek()
|
||
return ok && t.kind == tokPunc && t.text == text
|
||
}
|
||
|
||
// A comparison yields 1 or 0, never a bool: the result feeds straight back into
|
||
// arithmetic ([Qty] > 10) * 5. A NaN operand compares as 0, not NaN — so a blank
|
||
// cell fails a test rather than poisoning it.
|
||
func (p *formulaParser) parseComparison() (formulaNode, error) {
|
||
left, err := p.parseAddSub()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
for p.isOp("=", "<>", "<", ">", "<=", ">=") {
|
||
op, _ := p.next()
|
||
right, err := p.parseAddSub()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
l, r, o := left, right, op.text
|
||
left = func(s *evalState) formulaValue {
|
||
a, b := l(s).scalar(), r(s).scalar()
|
||
if math.IsNaN(a) || math.IsNaN(b) {
|
||
return scalarValue(0)
|
||
}
|
||
var got bool
|
||
switch o {
|
||
case "=":
|
||
got = a == b
|
||
case "<>":
|
||
got = a != b
|
||
case "<":
|
||
got = a < b
|
||
case ">":
|
||
got = a > b
|
||
case "<=":
|
||
got = a <= b
|
||
default:
|
||
got = a >= b
|
||
}
|
||
if got {
|
||
return scalarValue(1)
|
||
}
|
||
return scalarValue(0)
|
||
}
|
||
}
|
||
return left, nil
|
||
}
|
||
|
||
func (p *formulaParser) parseAddSub() (formulaNode, error) {
|
||
left, err := p.parseMulDiv()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
for p.isOp("+", "-") {
|
||
op, _ := p.next()
|
||
right, err := p.parseMulDiv()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
l, r, plus := left, right, op.text == "+"
|
||
left = func(s *evalState) formulaValue {
|
||
a, b := l(s).scalar(), r(s).scalar()
|
||
if plus {
|
||
return scalarValue(a + b)
|
||
}
|
||
return scalarValue(a - b)
|
||
}
|
||
}
|
||
return left, nil
|
||
}
|
||
|
||
func (p *formulaParser) parseMulDiv() (formulaNode, error) {
|
||
left, err := p.parsePow()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
for p.isOp("*", "/", "%") {
|
||
op, _ := p.next()
|
||
right, err := p.parsePow()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
l, r, o := left, right, op.text
|
||
left = func(s *evalState) formulaValue {
|
||
a, b := l(s).scalar(), r(s).scalar()
|
||
if o == "*" {
|
||
return scalarValue(a * b)
|
||
}
|
||
if b == 0 {
|
||
s.fail(ErrDivideByZero)
|
||
return scalarValue(math.NaN())
|
||
}
|
||
if o == "/" {
|
||
return scalarValue(a / b)
|
||
}
|
||
return scalarValue(math.Mod(a, b)) // '%' is the remainder, as in Excel's MOD
|
||
}
|
||
}
|
||
return left, nil
|
||
}
|
||
|
||
// '^' is right-associative: 2^3^2 is 2^(3^2).
|
||
func (p *formulaParser) parsePow() (formulaNode, error) {
|
||
left, err := p.parseUnary()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if !p.isOp("^") {
|
||
return left, nil
|
||
}
|
||
p.next()
|
||
right, err := p.parsePow()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
l, r := left, right
|
||
return func(s *evalState) formulaValue {
|
||
return scalarValue(math.Pow(l(s).scalar(), r(s).scalar()))
|
||
}, nil
|
||
}
|
||
|
||
func (p *formulaParser) parseUnary() (formulaNode, error) {
|
||
if p.isOp("-") {
|
||
p.next()
|
||
operand, err := p.parseUnary()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return func(s *evalState) formulaValue { return scalarValue(-operand(s).scalar()) }, nil
|
||
}
|
||
if p.isOp("+") {
|
||
p.next()
|
||
return p.parseUnary()
|
||
}
|
||
return p.parsePrimary()
|
||
}
|
||
|
||
func (p *formulaParser) parsePrimary() (formulaNode, error) {
|
||
t, ok := p.peek()
|
||
if !ok {
|
||
return nil, fmt.Errorf("%w: unexpected end of formula", ErrFormulaSyntax)
|
||
}
|
||
|
||
switch t.kind {
|
||
case tokNum:
|
||
p.next()
|
||
// Divergence, deliberate: the tokenizer accepts any run of digits and dots,
|
||
// so "1.2.3" reaches here. JavaScript's parseFloat quietly reads it as 1.2;
|
||
// a malformed literal is a typo, and a typo should be a syntax error.
|
||
v, err := strconv.ParseFloat(t.text, 64)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("%w: invalid number %q", ErrFormulaSyntax, t.text)
|
||
}
|
||
return func(*evalState) formulaValue { return scalarValue(v) }, nil
|
||
|
||
case tokRef:
|
||
p.next()
|
||
name := t.text
|
||
return func(s *evalState) formulaValue {
|
||
n, err := s.ctx.Cell(name)
|
||
if err != nil {
|
||
s.fail(err)
|
||
}
|
||
return scalarValue(n)
|
||
}, nil
|
||
|
||
case tokColRef:
|
||
p.next()
|
||
return p.colRefNode(t.text)
|
||
|
||
case tokPunc:
|
||
if t.text == "(" {
|
||
p.next()
|
||
inner, err := p.parseComparison()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if err := p.expect(")"); err != nil {
|
||
return nil, err
|
||
}
|
||
return inner, nil
|
||
}
|
||
|
||
case tokID:
|
||
p.next()
|
||
// A bare identifier (no '(' after it) is a named constant, not a call.
|
||
if !p.isPunc("(") {
|
||
v, ok := FormulaConstants[strings.ToUpper(t.text)]
|
||
if !ok {
|
||
return nil, fmt.Errorf("%w: %q", ErrUnknownName, t.text)
|
||
}
|
||
return func(*evalState) formulaValue { return scalarValue(v) }, nil
|
||
}
|
||
p.next() // '('
|
||
var args []formulaNode
|
||
if !p.isPunc(")") {
|
||
for {
|
||
arg, err := p.parseComparison()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
args = append(args, arg)
|
||
if !p.isPunc(",") {
|
||
break
|
||
}
|
||
p.next()
|
||
}
|
||
}
|
||
if err := p.expect(")"); err != nil {
|
||
return nil, err
|
||
}
|
||
return formulaFunction(strings.ToUpper(t.text), args)
|
||
}
|
||
|
||
return nil, fmt.Errorf("%w: unexpected token %q", ErrFormulaSyntax, t.text)
|
||
}
|
||
|
||
// colRefNode compiles the inside of a {…}: a bare name, a name plus an index, or a
|
||
// name plus a range. Indices are full expressions (compiled recursively), which is
|
||
// what makes SUM({Revenue:1:ROW()}) — a running total — work.
|
||
func (p *formulaParser) colRefNode(raw string) (formulaNode, error) {
|
||
ci := topLevelColon(raw)
|
||
if ci < 0 {
|
||
name := strings.TrimSpace(raw)
|
||
return func(s *evalState) formulaValue {
|
||
col, err := s.ctx.Column(name)
|
||
if err != nil {
|
||
s.fail(err)
|
||
}
|
||
return arrayValue(col)
|
||
}, nil
|
||
}
|
||
|
||
name := strings.TrimSpace(raw[:ci])
|
||
spec := strings.TrimSpace(raw[ci+1:])
|
||
|
||
// {Name:i} -- one row, 1-based. Out of range is NaN, not an error: a formula
|
||
// like {Sales:ROW()-1} is expected to run off the top on the first row.
|
||
ri := topLevelColon(spec)
|
||
if ri < 0 {
|
||
idx, err := CompileFormula(spec)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return func(s *evalState) formulaValue {
|
||
col, err := s.ctx.Column(name)
|
||
if err != nil {
|
||
s.fail(err)
|
||
}
|
||
n := idx.root(s).scalar()
|
||
if math.IsNaN(n) {
|
||
return scalarValue(math.NaN())
|
||
}
|
||
i := int(math.Trunc(n)) - 1
|
||
if i < 0 || i >= len(col) {
|
||
return scalarValue(math.NaN())
|
||
}
|
||
return scalarValue(col[i])
|
||
}, nil
|
||
}
|
||
|
||
// {Name:a:b} -- an inclusive range, clipped to the column. Reversed bounds are
|
||
// swapped, so {Sales:ROW():1} means the same as {Sales:1:ROW()}.
|
||
start, err := CompileFormula(strings.TrimSpace(spec[:ri]))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
end, err := CompileFormula(strings.TrimSpace(spec[ri+1:]))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return func(s *evalState) formulaValue {
|
||
col, err := s.ctx.Column(name)
|
||
if err != nil {
|
||
s.fail(err)
|
||
}
|
||
lo, hi := start.root(s).scalar(), end.root(s).scalar()
|
||
if math.IsNaN(lo) || math.IsNaN(hi) {
|
||
return arrayValue(nil)
|
||
}
|
||
a, b := int(math.Trunc(lo)), int(math.Trunc(hi))
|
||
if a > b {
|
||
a, b = b, a
|
||
}
|
||
out := make([]float64, 0, max(b-a+1, 0))
|
||
for k := a; k <= b; k++ {
|
||
if i := k - 1; i >= 0 && i < len(col) {
|
||
out = append(out, col[i])
|
||
}
|
||
}
|
||
return arrayValue(out)
|
||
}, nil
|
||
}
|
||
|
||
// ---- functions ----
|
||
|
||
// formulaFunction binds a call to its implementation, or fails the compile if the
|
||
// name is not a function. Aggregates flatten their array arguments (so {Col} spans
|
||
// the rows); everything else coerces each argument to a scalar. A missing argument
|
||
// is NaN, which is how the optional second argument of ROUND/LOG works.
|
||
func formulaFunction(name string, args []formulaNode) (formulaNode, error) {
|
||
// arg evaluates the nth argument, NaN when it was not supplied.
|
||
arg := func(s *evalState, n int) float64 {
|
||
if n >= len(args) {
|
||
return math.NaN()
|
||
}
|
||
return args[n](s).scalar()
|
||
}
|
||
agg := func(s *evalState) []float64 {
|
||
var out []float64
|
||
for _, a := range args {
|
||
v := a(s)
|
||
if v.isArray {
|
||
out = append(out, v.arr...)
|
||
} else {
|
||
out = append(out, v.num)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
// A value is "true" if it is a number and not zero.
|
||
truthy := func(n float64) bool { return !math.IsNaN(n) && n != 0 }
|
||
|
||
aggregate := func(fn CalculatedFunction) (formulaNode, error) {
|
||
return func(s *evalState) formulaValue { return scalarValue(ApplyCalcFunction(fn, agg(s))) }, nil
|
||
}
|
||
unary := func(f func(float64) float64) (formulaNode, error) {
|
||
return func(s *evalState) formulaValue { return scalarValue(f(arg(s, 0))) }, nil
|
||
}
|
||
|
||
switch name {
|
||
case "SUM":
|
||
return aggregate(CALC_FN_SUM)
|
||
case "AVERAGE", "AVG":
|
||
return aggregate(CALC_FN_AVERAGE)
|
||
case "MEDIAN":
|
||
return aggregate(CALC_FN_MEDIAN)
|
||
case "MODE":
|
||
return aggregate(CALC_FN_MODE)
|
||
case "MIN":
|
||
return aggregate(CALC_FN_MIN)
|
||
case "MAX":
|
||
return aggregate(CALC_FN_MAX)
|
||
case "COUNT":
|
||
return aggregate(CALC_FN_COUNT)
|
||
|
||
case "ABS":
|
||
return unary(math.Abs)
|
||
case "ROUND":
|
||
return func(s *evalState) formulaValue {
|
||
digits := 0.0
|
||
if len(args) > 1 {
|
||
digits = arg(s, 1)
|
||
}
|
||
f := math.Pow(10, digits)
|
||
return scalarValue(calcRound(arg(s, 0)*f) / f)
|
||
}, nil
|
||
case "FLOOR":
|
||
return unary(math.Floor)
|
||
case "CEILING", "CEIL":
|
||
return unary(math.Ceil)
|
||
case "SQRT":
|
||
return unary(math.Sqrt)
|
||
case "POWER":
|
||
return func(s *evalState) formulaValue { return scalarValue(math.Pow(arg(s, 0), arg(s, 1))) }, nil
|
||
case "MOD":
|
||
return func(s *evalState) formulaValue {
|
||
b := arg(s, 1)
|
||
if b == 0 {
|
||
s.fail(ErrDivideByZero)
|
||
return scalarValue(math.NaN())
|
||
}
|
||
return scalarValue(math.Mod(arg(s, 0), b))
|
||
}, nil
|
||
case "EXP":
|
||
return unary(math.Exp)
|
||
case "LN":
|
||
return unary(math.Log)
|
||
case "LOG":
|
||
// LOG(n, base) -- base 10 by default, matching Excel.
|
||
return func(s *evalState) formulaValue {
|
||
base := 10.0
|
||
if len(args) > 1 {
|
||
base = arg(s, 1)
|
||
}
|
||
return scalarValue(math.Log(arg(s, 0)) / math.Log(base))
|
||
}, nil
|
||
|
||
// Angles are in radians, like Excel; RADIANS/DEGREES convert.
|
||
case "SIN":
|
||
return unary(math.Sin)
|
||
case "COS":
|
||
return unary(math.Cos)
|
||
case "TAN":
|
||
return unary(math.Tan)
|
||
case "ASIN":
|
||
return unary(math.Asin)
|
||
case "ACOS":
|
||
return unary(math.Acos)
|
||
case "ATAN":
|
||
return unary(math.Atan)
|
||
case "ATAN2":
|
||
// ATAN2(x, y) -- Excel's argument order (the angle of the point (x, y)),
|
||
// which is the reverse of math.Atan2's.
|
||
return func(s *evalState) formulaValue {
|
||
return scalarValue(math.Atan2(arg(s, 1), arg(s, 0)))
|
||
}, nil
|
||
case "SINH":
|
||
return unary(math.Sinh)
|
||
case "COSH":
|
||
return unary(math.Cosh)
|
||
case "TANH":
|
||
return unary(math.Tanh)
|
||
case "PI":
|
||
return func(*evalState) formulaValue { return scalarValue(math.Pi) }, nil
|
||
case "RADIANS":
|
||
return unary(func(n float64) float64 { return n * math.Pi / 180 })
|
||
case "DEGREES":
|
||
return unary(func(n float64) float64 { return n * 180 / math.Pi })
|
||
|
||
case "IF":
|
||
// The untaken branch is not evaluated, so IF([d]=0, 0, [n]/[d]) is safe.
|
||
return func(s *evalState) formulaValue {
|
||
if truthy(arg(s, 0)) {
|
||
return scalarValue(arg(s, 1))
|
||
}
|
||
if len(args) > 2 {
|
||
return scalarValue(arg(s, 2))
|
||
}
|
||
return scalarValue(0)
|
||
}, nil
|
||
case "AND":
|
||
return func(s *evalState) formulaValue {
|
||
for i := range args {
|
||
if !truthy(arg(s, i)) {
|
||
return scalarValue(0)
|
||
}
|
||
}
|
||
return scalarValue(1)
|
||
}, nil
|
||
case "OR":
|
||
return func(s *evalState) formulaValue {
|
||
for i := range args {
|
||
if truthy(arg(s, i)) {
|
||
return scalarValue(1)
|
||
}
|
||
}
|
||
return scalarValue(0)
|
||
}, nil
|
||
case "NOT":
|
||
return func(s *evalState) formulaValue {
|
||
if truthy(arg(s, 0)) {
|
||
return scalarValue(0)
|
||
}
|
||
return scalarValue(1)
|
||
}, nil
|
||
|
||
case "ROW":
|
||
return func(s *evalState) formulaValue { return scalarValue(s.ctx.Row()) }, nil
|
||
}
|
||
|
||
return nil, fmt.Errorf("%w: %q", ErrUnknownFunction, name)
|
||
}
|
||
|
||
// calcRound is JavaScript's Math.round, which the TSX's ROUND() and integer
|
||
// formatting are built on: it rounds a half UP (toward +Inf), so -2.5 rounds to
|
||
// -2. Go's math.Round rounds a half AWAY FROM ZERO (-2.5 to -3). Kept faithful to
|
||
// the TSX so the two implementations agree cell for cell.
|
||
func calcRound(x float64) float64 {
|
||
if math.IsNaN(x) || math.IsInf(x, 0) {
|
||
return x
|
||
}
|
||
f := math.Floor(x)
|
||
if x-f >= 0.5 {
|
||
f++
|
||
}
|
||
return f
|
||
}
|
||
|
||
// ---- the standard context: rows + calculated columns ----
|
||
|
||
// NoRow is the "there is no current row" row index — a summary line. [Cell]
|
||
// references and ROW() are NaN under it.
|
||
const NoRow = -1
|
||
|
||
// CalcContext resolves a formula's references against a set of rows (the FILTERED
|
||
// rows, unpaginated: an aggregate spans what the user is looking at, not the page
|
||
// they happen to be on), the table's columns, and the user's other calculated
|
||
// columns.
|
||
//
|
||
// Build it once per render with NewCalcContext, then derive a per-row view with
|
||
// ForRow — the name lookups and compiled formulas are shared, so evaluating a
|
||
// column over a thousand rows parses nothing a thousand times.
|
||
type CalcContext struct {
|
||
rows []any
|
||
rowIndex int
|
||
read FieldReader
|
||
shared *calcShared
|
||
visiting map[string]bool // ids of the calc columns being evaluated up the stack
|
||
}
|
||
|
||
type calcShared struct {
|
||
byID map[string]UserCalculatedColumn
|
||
nameToRef map[string]string // lowercased display name -> operand key
|
||
compiled map[string]*Formula // custom formulas, compiled once per render
|
||
}
|
||
|
||
// NewCalcContext builds a context over rows. Columns and calcs are what a formula
|
||
// can name: a data column is referenceable when it has a SortIdentifier (that is
|
||
// the field its value is read from), a calculated column always is. read may be
|
||
// nil for DefaultFieldReader.
|
||
//
|
||
// The returned context has no current row (NoRow), which is what a summary wants;
|
||
// call ForRow for a calculated column.
|
||
func NewCalcContext(rows []any, cols []AutoTableColumn, calcs []UserCalculatedColumn, read FieldReader) *CalcContext {
|
||
if read == nil {
|
||
read = DefaultFieldReader
|
||
}
|
||
shared := &calcShared{
|
||
byID: make(map[string]UserCalculatedColumn, len(calcs)),
|
||
nameToRef: make(map[string]string, len(cols)+len(calcs)),
|
||
compiled: make(map[string]*Formula),
|
||
}
|
||
for _, c := range cols {
|
||
if c.SortIdentifier != "" {
|
||
shared.nameToRef[calcNameKey(c.DisplayName)] = c.SortIdentifier
|
||
}
|
||
}
|
||
// Calculated columns are registered last, so one that shadows a data column's
|
||
// display name wins — as in the TSX.
|
||
for _, c := range calcs {
|
||
shared.byID[c.ID] = c
|
||
shared.nameToRef[calcNameKey(c.DisplayName)] = CalcRef(c.ID)
|
||
if c.Fn == CALC_FN_CUSTOM {
|
||
// A formula that does not compile is left out of the cache; the error
|
||
// resurfaces per row from formulaFor, which is where it can be reported.
|
||
if f, err := CompileFormula(c.Formula); err == nil {
|
||
shared.compiled[c.ID] = f
|
||
}
|
||
}
|
||
}
|
||
return &CalcContext{rows: rows, rowIndex: NoRow, read: read, shared: shared}
|
||
}
|
||
|
||
// calcNameKey is how a display name is matched inside a formula: trimmed, and
|
||
// case-insensitively.
|
||
func calcNameKey(name string) string { return strings.ToLower(strings.TrimSpace(name)) }
|
||
|
||
// ForRow derives a context whose current row is the 0-based rowIndex (NoRow for
|
||
// none). The rows, lookups and compiled formulas are shared.
|
||
func (c *CalcContext) ForRow(rowIndex int) *CalcContext {
|
||
out := *c
|
||
out.rowIndex = rowIndex
|
||
return &out
|
||
}
|
||
|
||
// Rows are the rows the context aggregates over.
|
||
func (c *CalcContext) Rows() []any { return c.rows }
|
||
|
||
// RowIndex is the current row's 0-based index, or NoRow.
|
||
func (c *CalcContext) RowIndex() int { return c.rowIndex }
|
||
|
||
// withVisiting marks a calculated column as being evaluated. The set is copied,
|
||
// not mutated: two sibling references to the same column are not a cycle, only a
|
||
// reference back into the chain above is.
|
||
func (c *CalcContext) withVisiting(id string) *CalcContext {
|
||
out := *c
|
||
out.visiting = make(map[string]bool, len(c.visiting)+1)
|
||
for k := range c.visiting {
|
||
out.visiting[k] = true
|
||
}
|
||
out.visiting[id] = true
|
||
return &out
|
||
}
|
||
|
||
// Cell implements FormulaContext: [Name] in the current row.
|
||
func (c *CalcContext) Cell(name string) (float64, error) {
|
||
ref, ok := c.shared.nameToRef[calcNameKey(name)]
|
||
if !ok {
|
||
return math.NaN(), fmt.Errorf("%w: %q", ErrUnknownColumn, name)
|
||
}
|
||
return c.resolveRef(ref)
|
||
}
|
||
|
||
// Column implements FormulaContext: {Name} across the rows.
|
||
func (c *CalcContext) Column(name string) ([]float64, error) {
|
||
ref, ok := c.shared.nameToRef[calcNameKey(name)]
|
||
if !ok {
|
||
return nil, fmt.Errorf("%w: %q", ErrUnknownColumn, name)
|
||
}
|
||
return c.resolveColumn(ref)
|
||
}
|
||
|
||
// Row implements FormulaContext: the current row's 1-based position.
|
||
func (c *CalcContext) Row() float64 {
|
||
if c.rowIndex < 0 || c.rowIndex >= len(c.rows) {
|
||
return math.NaN()
|
||
}
|
||
return float64(c.rowIndex + 1)
|
||
}
|
||
|
||
// resolveRef reads one operand key in the current row: a calculated column
|
||
// (evaluated recursively) or a data field.
|
||
func (c *CalcContext) resolveRef(ref string) (float64, error) {
|
||
if id, ok := strings.CutPrefix(ref, CalcRefPrefix); ok {
|
||
target, err := c.calcTarget(id)
|
||
if err != nil {
|
||
return math.NaN(), err
|
||
}
|
||
// A calculated column may not need a row at all (SUM({Revenue})), so this
|
||
// recurses even when there is no current row, exactly as the TSX does.
|
||
return c.withVisiting(id).computeCalc(target)
|
||
}
|
||
if c.rowIndex < 0 || c.rowIndex >= len(c.rows) {
|
||
return math.NaN(), nil // a summary has no current row: not an error
|
||
}
|
||
return ToCalcNumber(c.read(c.rows[c.rowIndex], ref)), nil
|
||
}
|
||
|
||
// resolveColumn reads one operand key in every row.
|
||
func (c *CalcContext) resolveColumn(ref string) ([]float64, error) {
|
||
out := make([]float64, len(c.rows))
|
||
if id, ok := strings.CutPrefix(ref, CalcRefPrefix); ok {
|
||
target, err := c.calcTarget(id)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
child := c.withVisiting(id)
|
||
var firstErr error
|
||
for i := range c.rows {
|
||
n, err := child.ForRow(i).computeCalc(target)
|
||
if err != nil && firstErr == nil {
|
||
firstErr = err
|
||
}
|
||
out[i] = n
|
||
}
|
||
return out, firstErr
|
||
}
|
||
for i, row := range c.rows {
|
||
out[i] = ToCalcNumber(c.read(row, ref))
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// calcTarget looks up a calculated column being referenced, refusing one already
|
||
// on the evaluation stack — that is the cycle check, and without it a formula that
|
||
// names itself would recurse until the stack ran out.
|
||
func (c *CalcContext) calcTarget(id string) (UserCalculatedColumn, error) {
|
||
if c.visiting[id] {
|
||
return UserCalculatedColumn{}, fmt.Errorf("%w: %q references itself", ErrFormulaCycle, id)
|
||
}
|
||
target, ok := c.shared.byID[id]
|
||
if !ok {
|
||
return UserCalculatedColumn{}, fmt.Errorf("%w: %q", ErrUnknownColumn, CalcRef(id))
|
||
}
|
||
return target, nil
|
||
}
|
||
|
||
// computeCalc evaluates one calculated column for the current row. The caller has
|
||
// already marked it visiting.
|
||
func (c *CalcContext) computeCalc(uc UserCalculatedColumn) (float64, error) {
|
||
if uc.Fn == CALC_FN_CUSTOM {
|
||
f, err := c.formulaFor(uc)
|
||
if err != nil {
|
||
return math.NaN(), err
|
||
}
|
||
return f.Eval(c)
|
||
}
|
||
operands := make([]float64, len(uc.Operands))
|
||
var firstErr error
|
||
for i, op := range uc.Operands {
|
||
n, err := c.resolveRef(op)
|
||
if err != nil && firstErr == nil {
|
||
firstErr = err
|
||
}
|
||
operands[i] = n
|
||
}
|
||
return ApplyCalcFunction(uc.Fn, operands), firstErr
|
||
}
|
||
|
||
// formulaFor returns the column's compiled formula, from the context's cache when
|
||
// the column is one it was built with (the common case: compiled once, evaluated
|
||
// per row) and compiling on the spot when it is not. The source check is what makes
|
||
// the editor's live preview of an unsaved draft — same id, edited formula — use the
|
||
// draft rather than the stale compile.
|
||
func (c *CalcContext) formulaFor(uc UserCalculatedColumn) (*Formula, error) {
|
||
if f, ok := c.shared.compiled[uc.ID]; ok && f.src == uc.Formula {
|
||
return f, nil
|
||
}
|
||
return CompileFormula(uc.Formula)
|
||
}
|
||
|
||
// ---- computing and formatting a user's column / summary ----
|
||
|
||
// ComputeCalculatedColumn evaluates a calculated column for the context's current
|
||
// row (use ctx.ForRow). The result is NaN when it is not computable, with the
|
||
// first fault — a cycle, an unknown column, a division by zero — returned
|
||
// alongside; the display path ignores the error and renders CalcEmptyValue.
|
||
func ComputeCalculatedColumn(uc UserCalculatedColumn, ctx *CalcContext) (float64, error) {
|
||
// Seed the visiting set with this column, so a formula that names itself is a
|
||
// cycle on the first hop rather than the second.
|
||
return ctx.withVisiting(uc.ID).computeCalc(uc)
|
||
}
|
||
|
||
// FormatCalculatedColumn is ComputeCalculatedColumn plus the column's formatting —
|
||
// the text a cell, a CSV field and a PDF cell all show.
|
||
func FormatCalculatedColumn(uc UserCalculatedColumn, ctx *CalcContext) string {
|
||
n, _ := ComputeCalculatedColumn(uc, ctx)
|
||
return FormatCalcResult(n, uc.DataType, uc.Precision, "", "", "")
|
||
}
|
||
|
||
// ComputeSummaryRow evaluates a footer summary over the context's rows. There is
|
||
// no current row, so a custom formula's [cell] references and ROW() are NaN and
|
||
// only whole-column aggregates mean anything; a basic summary aggregates
|
||
// Operands[0] down the rows.
|
||
func ComputeSummaryRow(s UserSummaryRow, ctx *CalcContext) (float64, error) {
|
||
base := ctx.ForRow(NoRow)
|
||
if s.Fn == CALC_FN_CUSTOM || s.Fn == "" {
|
||
return EvalFormula(s.Formula, base)
|
||
}
|
||
if len(s.Operands) == 0 || s.Operands[0] == "" {
|
||
return math.NaN(), fmt.Errorf("%w: summary %q has no operand", ErrUnknownColumn, s.Label)
|
||
}
|
||
col, err := base.resolveColumn(s.Operands[0])
|
||
if err != nil {
|
||
return math.NaN(), err
|
||
}
|
||
return ApplyCalcFunction(s.Fn, col), nil
|
||
}
|
||
|
||
// FormatSummaryRow is ComputeSummaryRow plus formatting.
|
||
func FormatSummaryRow(s UserSummaryRow, ctx *CalcContext) string {
|
||
n, _ := ComputeSummaryRow(s, ctx)
|
||
return FormatCalcResult(n, s.DataType, s.Precision, "", "", "")
|
||
}
|
||
|
||
// FormatCalcResult renders a computed number per its data type. A result that is
|
||
// not a finite number (an empty column, a division by zero, a broken formula)
|
||
// renders as emptyValue, defaulting to CalcEmptyValue.
|
||
//
|
||
// precision is nil when unset, which only the "number" and "plain" types care
|
||
// about: they then format the value naturally rather than to a fixed width.
|
||
// prefix/suffix wrap the formatted text (the "$" of money and the "%" of percent
|
||
// are added inside them, so a prefix still leads).
|
||
func FormatCalcResult(result float64, dataType CalculatedDataType, precision *int, prefix, suffix, emptyValue string) string {
|
||
if math.IsNaN(result) || math.IsInf(result, 0) {
|
||
if emptyValue == "" {
|
||
return CalcEmptyValue
|
||
}
|
||
return emptyValue
|
||
}
|
||
|
||
money, percent := "", ""
|
||
var text string
|
||
switch dataType {
|
||
case CALC_TYPE_MONEY:
|
||
money = "$"
|
||
text = FormatDecimal(result, precisionOr(precision, 2))
|
||
case CALC_TYPE_DECIMAL:
|
||
text = FormatDecimal(result, precisionOr(precision, 2))
|
||
case CALC_TYPE_INTEGER:
|
||
text = FormatNumber(calcRound(result))
|
||
case CALC_TYPE_PERCENT:
|
||
percent = "%"
|
||
text = calcToFixed(result, precisionOr(precision, 2)) // no thousands grouping, as in the TSX
|
||
case CALC_TYPE_NUMBER:
|
||
if precision != nil {
|
||
text = FormatDecimal(result, *precision)
|
||
} else {
|
||
text = FormatNumber(result)
|
||
}
|
||
default: // CALC_TYPE_PLAIN, and anything unset
|
||
if precision != nil {
|
||
text = calcToFixed(result, *precision)
|
||
} else {
|
||
text = stringify(result)
|
||
}
|
||
}
|
||
|
||
// Never render a negative zero: rounded to the display precision, a value like
|
||
// SIN(2*PI) (about -2.4e-16) would otherwise show as "-0" / "-0.00".
|
||
if strings.HasPrefix(text, "-") && !strings.ContainsAny(text, "123456789") {
|
||
text = text[1:]
|
||
}
|
||
return prefix + money + text + percent + suffix
|
||
}
|
||
|
||
func precisionOr(precision *int, fallback int) int {
|
||
if precision == nil {
|
||
return fallback
|
||
}
|
||
return *precision
|
||
}
|
||
|
||
// calcToFixed is JavaScript's Number.toFixed: a fixed number of decimals, no
|
||
// thousands separators.
|
||
//
|
||
// Divergence, minor: Go rounds a tie to even on the exact binary value where
|
||
// JavaScript rounds it away from zero, so (2.5).toFixed(0) is "3" there and "2"
|
||
// here. It only bites on values that are exactly representable halves, and it is
|
||
// how the rest of this package already rounds (see formattersFormatGrouped).
|
||
func calcToFixed(v float64, digits int) string {
|
||
if digits < 0 {
|
||
digits = 0
|
||
}
|
||
return strconv.FormatFloat(v, 'f', digits, 64)
|
||
}
|
||
|
||
// ==========================================================================
|
||
// Export: CSV, PDF, print
|
||
// ==========================================================================
|
||
|
||
// AutoTable's export path: CSV, PDF, and Print — the port of downloadCSV /
|
||
// buildTablePDF / downloadPDF / handlePrintPDF from AutoTable.tsx.
|
||
//
|
||
// The one rule that matters: an export writes what the filter SELECTED, not what
|
||
// happens to be on screen. The TSX achieved that by re-running its pipeline with
|
||
// MaxItemsPerPage: -1; here it is AutoTableState.FilteredRows(), which is already
|
||
// every matching row across all pages. Exporting the current page would be a bug
|
||
// the user only discovers in the spreadsheet.
|
||
//
|
||
// Bytes leave wasm through wasmruntime.Download / wasmruntime.Print, which are
|
||
// no-ops on the server — so an SSR render that touches this code path renders,
|
||
// rather than panicking.
|
||
|
||
// exportColumns keeps the columns an export actually covers: CSV set, and some
|
||
// way to produce a value — an explicit CSVValue, or a field to read via
|
||
// SortIdentifier. A column with CSV set but neither is silently dropped rather
|
||
// than emitted as a column of blanks (this mirrors the TSX, which required
|
||
// csvValue or a calculated spec).
|
||
func exportColumns(cols []AutoTableColumn) []AutoTableColumn {
|
||
out := make([]AutoTableColumn, 0, len(cols))
|
||
for _, c := range cols {
|
||
if c.CSV && (c.CSVValue != nil || c.SortIdentifier != "") {
|
||
out = append(out, c)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// exportValue is a column's text for one row: CSVValue if it has one, else the
|
||
// field named by SortIdentifier, rendered the way the table renders it.
|
||
//
|
||
// The rendered cell is a *vdom.VNode, so an export can never reuse it — which is
|
||
// exactly why AutoTableColumn carries CSVValue at all.
|
||
// rowIndex is the row's position in the exported set. A calculated column's value
|
||
// can depend on it (a running total, ROW()), which is why CSVValueAt exists — the
|
||
// index-free CSVValue cannot express one.
|
||
func exportValue(col AutoTableColumn, row any, rowIndex int, read FieldReader) string {
|
||
if col.CSVValueAt != nil {
|
||
return col.CSVValueAt(row, rowIndex)
|
||
}
|
||
if col.CSVValue != nil {
|
||
return col.CSVValue(row)
|
||
}
|
||
if read == nil {
|
||
read = DefaultFieldReader
|
||
}
|
||
return stringify(read(row, col.SortIdentifier))
|
||
}
|
||
|
||
// ---- CSV ----
|
||
|
||
// ExportCSV renders the rows as a CSV file (header row + one line per row),
|
||
// covering the columns with CSV set. Returns nil when there is nothing to export
|
||
// — no export columns, or no rows — so a caller can skip the download entirely.
|
||
//
|
||
// Quoting is RFC 4180 via encoding/csv: a field containing a comma, a quote, a
|
||
// newline, or a leading space is wrapped in quotes and its own quotes doubled.
|
||
// Records are separated by "\n" (not CRLF): that is what the TSX emitted, and
|
||
// every spreadsheet reads it.
|
||
//
|
||
// On kjol/csv: it exists, and MakeCSV does the same quoting (it wraps
|
||
// encoding/csv too) — but it is not reused here. Two reasons. It lives in a
|
||
// package that imports net/http for WriteCSVtoHTTP, and importing it would drag
|
||
// the HTTP stack into every WebAssembly bundle that renders a table, for twenty
|
||
// lines of buffer plumbing. And it has no formula-injection guard, which an
|
||
// export destined for a spreadsheet needs.
|
||
func ExportCSV(cols []AutoTableColumn, rows []any, read FieldReader) []byte {
|
||
return exportCSV(cols, rows, read, true)
|
||
}
|
||
|
||
func exportCSV(cols []AutoTableColumn, rows []any, read FieldReader, guard bool) []byte {
|
||
cols = exportColumns(cols)
|
||
if len(cols) == 0 || len(rows) == 0 {
|
||
return nil
|
||
}
|
||
cell := csvSafe
|
||
if !guard {
|
||
cell = func(s string) string { return s }
|
||
}
|
||
|
||
var buf bytes.Buffer
|
||
w := csv.NewWriter(&buf)
|
||
|
||
rec := make([]string, len(cols))
|
||
for i, c := range cols {
|
||
rec[i] = cell(c.DisplayName)
|
||
}
|
||
w.Write(rec) //nolint:errcheck // a bytes.Buffer cannot fail
|
||
|
||
for rowIdx, row := range rows {
|
||
for i, c := range cols {
|
||
rec[i] = cell(exportValue(c, row, rowIdx, read))
|
||
}
|
||
w.Write(rec) //nolint:errcheck
|
||
}
|
||
|
||
w.Flush()
|
||
return buf.Bytes()
|
||
}
|
||
|
||
// ExportCSVRaw is ExportCSV with the formula-injection guard OFF: cell values are
|
||
// written exactly as they are, with no leading quote.
|
||
//
|
||
// Use it only when the file is consumed by a program rather than opened in a
|
||
// spreadsheet — a downstream parser sees the guard's apostrophe as part of the
|
||
// value, whereas Excel and Sheets hide it. Do NOT use it for a file a person will
|
||
// open: that is precisely the injection ExportCSV exists to stop. See csvSafe.
|
||
func ExportCSVRaw(cols []AutoTableColumn, rows []any, read FieldReader) []byte {
|
||
return exportCSV(cols, rows, read, false)
|
||
}
|
||
|
||
// csvSafe defuses CSV formula injection: a spreadsheet treats a cell beginning
|
||
// with =, +, -, @, tab or CR as a FORMULA, so a row of user-supplied data reading
|
||
// `=cmd|'/c calc'!A1` becomes code the moment someone opens the file. Prefixing a
|
||
// single quote makes the spreadsheet read it as text.
|
||
//
|
||
// The catch is the minus sign: blindly guarding it would rewrite every negative
|
||
// number in a financial table into a quoted string, which is worse than the
|
||
// disease. So a value that is merely a NUMBER dressed up as currency —
|
||
// "-1,234.50", "-$99", "-12.5%" — passes through untouched, and only a leading
|
||
// sigil on something that is NOT a number is escaped.
|
||
//
|
||
// Which does mean an E.164 phone number ("+1 (555) 010-9999") gets the quote: it
|
||
// is not a number, and a spreadsheet would try to evaluate that leading +. The
|
||
// quote is what makes it display as typed.
|
||
func csvSafe(s string) string {
|
||
if s == "" {
|
||
return s
|
||
}
|
||
switch s[0] {
|
||
case '=', '+', '-', '@', '\t', '\r':
|
||
default:
|
||
return s
|
||
}
|
||
if looksNumeric(s) {
|
||
return s
|
||
}
|
||
return "'" + s
|
||
}
|
||
|
||
// looksNumeric reports whether s is a number once the decoration a formatter adds
|
||
// (currency, grouping commas, percent signs, parens) is removed.
|
||
func looksNumeric(s string) bool {
|
||
// The decoration a money/percent formatter adds, plus ordinary and non-breaking
|
||
// spaces.
|
||
const strip = "$,%() "
|
||
stripped := strings.Map(func(r rune) rune {
|
||
if strings.ContainsRune(strip, r) {
|
||
return -1
|
||
}
|
||
return r
|
||
}, s)
|
||
if stripped == "" {
|
||
return false
|
||
}
|
||
_, err := strconv.ParseFloat(stripped, 64)
|
||
return err == nil
|
||
}
|
||
|
||
// DownloadCSV builds the CSV and hands it to the browser as a file. A no-op when
|
||
// there is nothing to export, and on the server (wasmruntime.Download is a no-op
|
||
// there), so it is safe to call from anywhere.
|
||
func DownloadCSV(filename string, cols []AutoTableColumn, rows []any, read FieldReader) {
|
||
data := ExportCSV(cols, rows, read)
|
||
if len(data) == 0 {
|
||
return
|
||
}
|
||
wasmruntime.Download(withExt(filename, ".csv"), "text/csv;charset=utf-8;", data)
|
||
}
|
||
|
||
// withExt appends ext (lower-case, dotted) unless name already carries it.
|
||
func withExt(name, ext string) string {
|
||
name = strings.TrimSpace(name)
|
||
if name == "" {
|
||
name = "export"
|
||
}
|
||
if strings.HasSuffix(strings.ToLower(name), ext) {
|
||
return name
|
||
}
|
||
return name + ext
|
||
}
|
||
|
||
// ---- PDF ----
|
||
|
||
// AutoTablePDFHeader is the PDF's cover matter: what is printed above the table on
|
||
// the first page, and how the page is turned. It is the port of the TSX's
|
||
// AutoTablePDFHeader.
|
||
//
|
||
// Two deliberate divergences from the TSX:
|
||
//
|
||
// - showDate defaulted to TRUE there. A Go zero value cannot default to true, so
|
||
// ShowDate is opt-in. Set it.
|
||
// - logoUrl / showLogo are GONE. Drawing a logo means decoding a PNG or JPEG and
|
||
// embedding it as an image XObject; the PDF writer here does not do images at
|
||
// all (see pdf.go). Rather than silently drop the field, it is not offered.
|
||
// BelowTable is the escape hatch for anything else you want on the page.
|
||
type AutoTablePDFHeader struct {
|
||
Title string
|
||
Subtitle string
|
||
|
||
// ShowDate prints the date at the top right of the first page.
|
||
ShowDate bool
|
||
// Date overrides "today" — mostly so an export is reproducible in a test.
|
||
Date time.Time
|
||
|
||
Orientation PDFOrientation
|
||
|
||
// Summaries are footer rows (Total, Subtotal, …) drawn in a shaded band under
|
||
// the table: the label right-aligned in the second-to-last column, the value in
|
||
// the last, mirroring the on-screen <tfoot>.
|
||
Summaries []AutoTablePDFSummary
|
||
|
||
// BelowTable draws whatever the app wants under the table — the TSX's escape
|
||
// hatch, kept. It runs after the summaries, with the cursor where the table
|
||
// left it.
|
||
BelowTable func(PDFBelowTableContext)
|
||
}
|
||
|
||
// AutoTablePDFSummary is one footer summary line.
|
||
type AutoTablePDFSummary struct{ Label, Value string }
|
||
|
||
// PDFBelowTableContext is what BelowTable gets: the document, where the table
|
||
// ended, the page geometry, and the palette the table itself used — so anything
|
||
// drawn underneath matches it.
|
||
type PDFBelowTableContext struct {
|
||
PDF *PDF
|
||
// Y is the current cursor: the baseline-agnostic top edge of whatever comes
|
||
// next, in PDF coordinates (y grows UP, so subtract as you descend).
|
||
Y float64
|
||
|
||
PageWidth, PageHeight float64
|
||
Margin, ContentWidth float64
|
||
|
||
Text, Muted, Border PDFColor
|
||
|
||
// AddPage starts a fresh page and returns the Y to continue from.
|
||
AddPage func() float64
|
||
}
|
||
|
||
// PDF layout, in points. Copied from the TSX so the two exports line up.
|
||
const (
|
||
pdfMargin = 40.0
|
||
pdfRowHeight = 18.0
|
||
pdfColHeaderHeight = 22.0
|
||
pdfFontSize = 8.0
|
||
pdfColHeaderFontSize = 9.0
|
||
pdfTitleSize = 12.0
|
||
pdfSubtitleSize = 9.0
|
||
pdfDateSize = 9.0
|
||
pdfCellPad = 4.0
|
||
)
|
||
|
||
// The palette, likewise.
|
||
var (
|
||
pdfTextColor = PDFGray(0.1)
|
||
pdfMutedColor = PDFGray(0.5)
|
||
pdfBorderColor = PDFGray(0.8)
|
||
pdfRowAltColor = PDFGray(0.95)
|
||
pdfRuleColor = PDFGray(0.15)
|
||
pdfSummaryBand = PDFGray(0.96)
|
||
pdfSummaryDivider = PDFGray(0.55)
|
||
)
|
||
|
||
// ExportPDF renders the rows as a paginated PDF table: the header row repeats on
|
||
// every page, rows zebra-stripe, and each page is footed with "Page n of m".
|
||
// Returns nil when there is nothing to export.
|
||
//
|
||
// Column widths are MEASURED, not guessed from character counts as the TSX did:
|
||
// each column's weight is the widest thing it has to hold (its header at the
|
||
// header size, or any cell at the body size), and the weights are then scaled to
|
||
// fill the content width. Text that still does not fit its column is truncated
|
||
// with an ellipsis — measured too, so it really does fit.
|
||
//
|
||
// Cells honor the column's DisplayPosition, so a right-aligned money column is
|
||
// right-aligned in the PDF as well. (The TSX left-aligned everything; this is the
|
||
// whole point of carrying a width table.)
|
||
func ExportPDF(cols []AutoTableColumn, rows []any, read FieldReader, h AutoTablePDFHeader) []byte {
|
||
cols = exportColumns(cols)
|
||
if len(cols) == 0 || len(rows) == 0 {
|
||
return nil
|
||
}
|
||
|
||
pdf := NewPDF(PDFOptions{Orientation: h.Orientation})
|
||
contentWidth := pdf.Width() - 2*pdfMargin
|
||
widths := pdfColumnWidths(cols, rows, read, contentWidth)
|
||
|
||
// colX[i] is the left edge of column i; the last entry is the right edge of the
|
||
// table, which is what a right-aligned cell measures back from.
|
||
colX := make([]float64, len(cols)+1)
|
||
colX[0] = pdfMargin
|
||
for i, w := range widths {
|
||
colX[i+1] = colX[i] + w
|
||
}
|
||
|
||
body := PDFTextStyle{Size: pdfFontSize, Color: pdfTextColor}
|
||
head := PDFTextStyle{Size: pdfColHeaderFontSize, Bold: true, Color: pdfTextColor}
|
||
|
||
y := 0.0
|
||
firstPage := true
|
||
|
||
// drawCell writes one cell's text, truncated to the column and aligned the way
|
||
// the column is aligned on screen.
|
||
drawCell := func(i int, baseline float64, text string, st PDFTextStyle) {
|
||
w := widths[i]
|
||
text = PDFTruncate(text, w-2*pdfCellPad, st.Size, st.Bold)
|
||
if text == "" {
|
||
return
|
||
}
|
||
switch cols[i].DisplayPosition {
|
||
case COL_POS_RIGHT:
|
||
pdf.TextRight(colX[i+1]-pdfCellPad, baseline, text, st)
|
||
case COL_POS_CENTER:
|
||
pdf.TextCenter(colX[i]+w/2, baseline, text, st)
|
||
default:
|
||
pdf.Text(colX[i]+pdfCellPad, baseline, text, st)
|
||
}
|
||
}
|
||
|
||
// drawPageHeader is the title block: title left, subtitle under it, date right.
|
||
// First page only — a report's title on page 7 is noise.
|
||
drawPageHeader := func() {
|
||
if h.Title == "" && h.Subtitle == "" && !h.ShowDate {
|
||
return
|
||
}
|
||
if h.ShowDate {
|
||
pdf.TextRight(pdfMargin+contentWidth, y-pdfDateSize-2, pdfDate(h),
|
||
PDFTextStyle{Size: pdfDateSize, Color: pdfMutedColor})
|
||
}
|
||
|
||
rightHeight := 0.0
|
||
if h.ShowDate {
|
||
rightHeight = pdfDateSize + 2
|
||
}
|
||
leftHeight := 0.0
|
||
if h.Title != "" {
|
||
pdf.Text(pdfMargin, y-pdfTitleSize, h.Title,
|
||
PDFTextStyle{Size: pdfTitleSize, Bold: true, Color: pdfTextColor})
|
||
leftHeight = pdfTitleSize + 6
|
||
}
|
||
if h.Subtitle != "" {
|
||
pdf.Text(pdfMargin, y-leftHeight-pdfSubtitleSize-4, h.Subtitle,
|
||
PDFTextStyle{Size: pdfSubtitleSize, Color: pdfMutedColor})
|
||
leftHeight += pdfSubtitleSize + 4
|
||
}
|
||
|
||
if block := max(rightHeight, leftHeight); block > 0 {
|
||
y -= block + 16
|
||
}
|
||
pdf.Line(pdfMargin, y, pdfMargin+contentWidth, y, 0.75, pdfBorderColor)
|
||
y -= 12
|
||
}
|
||
|
||
drawColumnHeaders := func() {
|
||
for i, c := range cols {
|
||
drawCell(i, y-pdfColHeaderHeight+7, c.DisplayName, head)
|
||
}
|
||
y -= pdfColHeaderHeight
|
||
pdf.Line(pdfMargin, y, pdfMargin+contentWidth, y, 1, pdfRuleColor)
|
||
}
|
||
|
||
addPage := func() {
|
||
pdf.AddPage()
|
||
y = pdf.Height() - pdfMargin
|
||
if firstPage {
|
||
drawPageHeader()
|
||
firstPage = false
|
||
}
|
||
drawColumnHeaders()
|
||
}
|
||
|
||
addPage()
|
||
|
||
for i, row := range rows {
|
||
// The bottom margin is the floor: a row that would cross it starts a new
|
||
// page, whose repeated header is what makes a multi-page table readable.
|
||
if y-pdfRowHeight < pdfMargin {
|
||
addPage()
|
||
}
|
||
if i%2 == 1 {
|
||
pdf.Rect(pdfMargin, y-pdfRowHeight, contentWidth, pdfRowHeight, pdfRowAltColor)
|
||
}
|
||
// Rules go BETWEEN rows. The table's closing rule is drawn once, after the
|
||
// loop — drawing one under the last row as well would put it at exactly the
|
||
// same y as the summary divider below, and two rules at one y read as a double
|
||
// border.
|
||
if i < len(rows)-1 {
|
||
pdf.Line(pdfMargin, y-pdfRowHeight, pdfMargin+contentWidth, y-pdfRowHeight, 0.5, pdfBorderColor)
|
||
}
|
||
|
||
for c := range cols {
|
||
drawCell(c, y-pdfRowHeight+6, exportValue(cols[c], row, i, read), body)
|
||
}
|
||
y -= pdfRowHeight
|
||
}
|
||
|
||
// The one rule that closes the table: heavier when summary rows follow, because
|
||
// then it is also the divider that separates them from the data.
|
||
if len(rows) > 0 {
|
||
if len(h.Summaries) > 0 {
|
||
pdf.Line(pdfMargin, y, pdfMargin+contentWidth, y, 1, pdfSummaryDivider)
|
||
} else {
|
||
pdf.Line(pdfMargin, y, pdfMargin+contentWidth, y, 0.5, pdfBorderColor)
|
||
}
|
||
}
|
||
|
||
if len(h.Summaries) > 0 {
|
||
last := len(cols) - 1
|
||
bold := PDFTextStyle{Size: pdfFontSize, Bold: true, Color: pdfTextColor}
|
||
for _, s := range h.Summaries {
|
||
if y-pdfRowHeight < pdfMargin {
|
||
addPage()
|
||
}
|
||
pdf.Rect(pdfMargin, y-pdfRowHeight, contentWidth, pdfRowHeight, pdfSummaryBand)
|
||
pdf.Line(pdfMargin, y-pdfRowHeight, pdfMargin+contentWidth, y-pdfRowHeight, 0.5, pdfBorderColor)
|
||
|
||
baseline := y - pdfRowHeight + 6
|
||
right := colX[last+1] - pdfCellPad
|
||
pdf.TextRight(right, baseline, s.Value, body)
|
||
if last > 0 {
|
||
// Label in the cell to the left of the value.
|
||
pdf.TextRight(colX[last]-pdfCellPad, baseline, s.Label, bold)
|
||
} else {
|
||
// Single-column table: both share the one cell.
|
||
pdf.TextRight(right-PDFTextWidth(s.Value, pdfFontSize, false)-8, baseline, s.Label, bold)
|
||
}
|
||
y -= pdfRowHeight
|
||
}
|
||
}
|
||
|
||
if h.BelowTable != nil {
|
||
h.BelowTable(PDFBelowTableContext{
|
||
PDF: pdf,
|
||
Y: y,
|
||
PageWidth: pdf.Width(),
|
||
PageHeight: pdf.Height(),
|
||
Margin: pdfMargin,
|
||
ContentWidth: contentWidth,
|
||
Text: pdfTextColor,
|
||
Muted: pdfMutedColor,
|
||
Border: pdfBorderColor,
|
||
AddPage: func() float64 {
|
||
pdf.AddPage()
|
||
y = pdf.Height() - pdfMargin
|
||
return y
|
||
},
|
||
})
|
||
}
|
||
|
||
// Footers last: "of m" is not knowable until every page exists, which is why
|
||
// PDF.SetPage exists at all. (The TSX drew the logo here too — see the type doc.)
|
||
n := pdf.PageCount()
|
||
foot := PDFTextStyle{Size: 8, Color: pdfMutedColor}
|
||
for i := 0; i < n; i++ {
|
||
pdf.SetPage(i)
|
||
pdf.Text(pdfMargin, pdfMargin-20, "Page "+strconv.Itoa(i+1)+" of "+strconv.Itoa(n), foot)
|
||
}
|
||
|
||
return pdf.Bytes()
|
||
}
|
||
|
||
// pdfDate is the date printed in the header — a fixed one if the caller supplied
|
||
// it (a reproducible export), else today.
|
||
func pdfDate(h AutoTablePDFHeader) string {
|
||
d := h.Date
|
||
if d.IsZero() {
|
||
d = time.Now()
|
||
}
|
||
return FormatDateLong(d)
|
||
}
|
||
|
||
// pdfColumnSample caps how many rows are measured when sizing columns. Measuring
|
||
// 100k rows to pick a width is a waste; the first few hundred are representative,
|
||
// and anything wider than its column gets truncated anyway.
|
||
const pdfColumnSample = 200
|
||
|
||
// pdfColumnWidths sizes the columns: each one's natural width (the widest of its
|
||
// header and its sampled cells, plus padding), scaled so the row exactly fills the
|
||
// content width.
|
||
func pdfColumnWidths(cols []AutoTableColumn, rows []any, read FieldReader, contentWidth float64) []float64 {
|
||
widths := make([]float64, len(cols))
|
||
total := 0.0
|
||
for i, c := range cols {
|
||
w := PDFTextWidth(c.DisplayName, pdfColHeaderFontSize, true)
|
||
for r := 0; r < len(rows) && r < pdfColumnSample; r++ {
|
||
if cw := PDFTextWidth(exportValue(c, rows[r], r, read), pdfFontSize, false); cw > w {
|
||
w = cw
|
||
}
|
||
}
|
||
w += 2 * pdfCellPad
|
||
// A floor, so a column of empty cells under a one-letter header does not
|
||
// collapse to a sliver.
|
||
w = max(w, 24)
|
||
widths[i] = w
|
||
total += w
|
||
}
|
||
if total <= 0 {
|
||
for i := range widths {
|
||
widths[i] = contentWidth / float64(len(widths))
|
||
}
|
||
return widths
|
||
}
|
||
scale := contentWidth / total
|
||
for i := range widths {
|
||
widths[i] *= scale
|
||
}
|
||
return widths
|
||
}
|
||
|
||
// DownloadPDF builds the PDF and hands it to the browser as a file.
|
||
func DownloadPDF(filename string, cols []AutoTableColumn, rows []any, read FieldReader, h AutoTablePDFHeader) {
|
||
data := ExportPDF(cols, rows, read, h)
|
||
if len(data) == 0 {
|
||
return
|
||
}
|
||
wasmruntime.Download(withExt(filename, ".pdf"), "application/pdf", data)
|
||
}
|
||
|
||
// PrintPDF builds the PDF and opens the browser's print dialog on it, without ever
|
||
// writing a file (wasmruntime.Print loads it into a hidden iframe).
|
||
func PrintPDF(cols []AutoTableColumn, rows []any, read FieldReader, h AutoTablePDFHeader) {
|
||
data := ExportPDF(cols, rows, read, h)
|
||
if len(data) == 0 {
|
||
return
|
||
}
|
||
wasmruntime.Print("application/pdf", data)
|
||
}
|
||
|
||
// ---- the controller's half ----
|
||
//
|
||
// These are the methods a toolbar button actually calls. They export
|
||
// FilteredRows() — every row matching the current filter, across every page — so
|
||
// what lands in the spreadsheet is what the user filtered, not the twenty-five
|
||
// rows they happened to be looking at.
|
||
//
|
||
// FilteredRows is resolved by Render, so these are meaningful only after the table
|
||
// has rendered once. In practice they hang off a button inside that table.
|
||
|
||
// ExportCSVBytes is the current filtered result set as a CSV file.
|
||
func (s *AutoTableState) ExportCSVBytes() []byte {
|
||
return ExportCSV(s.ExportColumns(), s.FilteredRows(), s.read)
|
||
}
|
||
|
||
// withSummaries fills in the PDF's footer lines from the table's OWN summary rows —
|
||
// including any the user built at runtime in the editor.
|
||
//
|
||
// Without this the caller had to restate them, which meant a summary row someone
|
||
// created showed up on screen and then quietly vanished from the export: the one
|
||
// number they most likely wanted in the file. They are evaluated here, against the
|
||
// same filtered rows the PDF is about to print, so the footer agrees with the table
|
||
// above it.
|
||
//
|
||
// A caller who passes Summaries explicitly keeps them — that is the escape hatch for
|
||
// a footer that is not one of the table's own rows.
|
||
func (s *AutoTableState) withSummaries(h AutoTablePDFHeader) AutoTablePDFHeader {
|
||
if len(h.Summaries) > 0 || len(s.SummaryRows()) == 0 {
|
||
return h
|
||
}
|
||
rows := s.FilteredRows()
|
||
ctx := NewCalcContext(rows, s.cols, s.Calculated(), s.read)
|
||
|
||
h.Summaries = make([]AutoTablePDFSummary, 0, len(s.SummaryRows()))
|
||
for _, sr := range s.SummaryRows() {
|
||
h.Summaries = append(h.Summaries, AutoTablePDFSummary{
|
||
Label: sr.Label,
|
||
Value: FormatSummaryRow(sr, ctx),
|
||
})
|
||
}
|
||
return h
|
||
}
|
||
|
||
// ExportPDFBytes is the current filtered result set as a PDF file.
|
||
func (s *AutoTableState) ExportPDFBytes(h AutoTablePDFHeader) []byte {
|
||
return ExportPDF(s.ExportColumns(), s.FilteredRows(), s.read, s.withSummaries(h))
|
||
}
|
||
|
||
// DownloadCSV downloads the current filtered result set as `filename`.csv.
|
||
func (s *AutoTableState) DownloadCSV(filename string) {
|
||
DownloadCSV(filename, s.ExportColumns(), s.FilteredRows(), s.read)
|
||
}
|
||
|
||
// DownloadPDF downloads the current filtered result set as `filename`.pdf.
|
||
func (s *AutoTableState) DownloadPDF(filename string, h AutoTablePDFHeader) {
|
||
DownloadPDF(filename, s.ExportColumns(), s.FilteredRows(), s.read, s.withSummaries(h))
|
||
}
|
||
|
||
// PrintPDF opens the print dialog on the current filtered result set.
|
||
func (s *AutoTableState) PrintPDF(h AutoTablePDFHeader) {
|
||
PrintPDF(s.ExportColumns(), s.FilteredRows(), s.read, s.withSummaries(h))
|
||
}
|