import ComponentBaseStyles from "../styles/ComponentBaseStyles.wrn" // // DataTable -- columns, sorting, filtering, pagination and selection over an // array of records. // // // // Columns are plain objects so they survive being passed as an attribute: // key which field of the row to show (required) // label header text; defaults to the key // align start | center | end // width any CSS width for the column // sortable show a sort control on the header // // Rows are matched by `rowKey` (default "id"). Sorting, filtering and paging // are all derived through shared functions, so the first paint is server // rendered and the same code keeps it live in the browser. // // Turn pagination off with paginated={false} to render every row. // // NOTE: the style block uses /* */ comments only -- // is not a CSS comment // and silently swallows the rule that follows it. import Dropdown from "./Dropdown.wrn" component DataTable { outputs { sort(payload: { key: string; direction: string }) search(payload: { query: string }) pageChange(payload: { page: number; pageSize: number }) select(payload: { selected: Array; all: boolean }) change(payload: { page: number; pageSize: number; total: number; query: string; sortKey: string; sortDirection: string }) rowClick(payload: { row: object; sourceEvent: Event }) action(payload: { id: string; selected: Array; rows: object[]; sourceEvent: Event }) request(payload: { instanceId: number; page: number; pageSize: number; sortKey: string; sortDirection: string; query: string }) } props { color: string = "primary" size: string = "default" columns: unknown[] = [] rows: unknown[] = [] rowKey: string = "id" // Async source. // // Set remote={true} and the table stops deriving anything locally: it // emits a `request` output carrying { instanceId, page, pageSize, // sortKey, sortDirection, query } whenever the view changes, and waits to // be handed rows back. Sorting, filtering and paging all become the // server's job, because the table only ever sees the page it was given. // // Answer it by dispatching the result back with the SAME instanceId: // // function loadRows(payload) { // fetch("/api/rows?page=" + payload.page) // .then(function (r) { return r.json() }) // .then(function (data) { // window.dispatchEvent(new CustomEvent("wrnexus:datatable:rows", { // detail: { instanceId: payload.instanceId, // rows: data.rows, total: data.total } // })) // }) // } // // It is an event rather than a `load` function prop because props travel // as HTML attributes: a function passed that way arrives as its own source // text, not something callable. remote: boolean = false loadingLabel: string = "Loading" errorLabel: string = "Could not load this data" retryLabel: string = "Try again" caption: string = "" description: string = "" // Filtering searchable: boolean = true searchPlaceholder: string = "Search" // Pagination. paginated={false} renders every row and hides the footer. paginated: boolean = true pageSize: number = 10 // compact = previous/next arrows only. numbered = clickable page numbers. paginationStyle: string = "compact" // Typed concretely, not unknown[]: the showcase generator fills an // unknown[] prop with rich demo objects, and every option in the picker // then rendered as [object Object]. pageSizes: number[] = [10, 25, 50] // Selection selectable: boolean = false // Toolbar actions. Each entry is an object: // id returned with the action output so a host can tell them apart // label button text // icon optional icon class // tone "danger" styles it destructively // when "always" (default) or "selection" -- selection-only actions // stay hidden until at least one row is ticked // onClick optional callback, receives the selected rows actions: unknown[] = [] // Presentation striped: boolean = true bordered: boolean = true // rows = horizontal rules only. grid = full row and column gridlines. gridlines: string = "rows" density: string = "default" emptyLabel: string = "No records to show" // Shown when a filter matches nothing, as opposed to there being no data // at all -- the two need different wording and different remedies. noResultsLabel: string = "No records match your search" clearSearchLabel: string = "Clear search" // Keeps the first column in view while the rest scrolls sideways. stickyFirstColumn: boolean = false // rows = one record per row. comparison = transposed, fields down the // left and one column per record. layout: string = "rows" class: string = "" } state query = "" state sortKey = "" state sortDirection = "asc" state paginationPage = 1 state perPage = pageSize state selectedKeys = [] state remoteRows = [] state remoteTotal = 0 state loading = false state loadError = "" // Distinguishes this instance from any other table on the page, since the // async result comes back through a window event. state instanceId = 0 state lastAnchor = "" functions { // --- derivation ------------------------------------------------------- // Shared so the server renders the first page and the browser keeps it in // step from the same source. Each step is a pure function of state, which // is what lets the view call them straight from data-for. shared function columnList() { return Array.isArray(columns) ? columns : [] } shared function isRemote() { return !!remote } shared function allRows() { if (isRemote()) { return Array.isArray(remoteRows) ? remoteRows : [] } return Array.isArray(rows) ? rows : [] } // Falls back to the row's position when it carries no key. Returning a // constant made every keyless row share one identity, so ticking one // ticked them all -- a silent, and very confusing, data bug. shared function keyOf(row, index) { if (row && row[rowKey] !== undefined && row[rowKey] !== null) { return row[rowKey] } return "__row:" + (index === undefined ? allRows().indexOf(row) : index) } shared function cellText(row, column) { var value = row ? row[column.key] : "" return value === undefined || value === null ? "" : String(value) } // A column may render markup instead of text, either by naming a field // that already holds markup (html: "statusBadge") or by supplying a // render(row) function. Returns "" when the column is plain text, which // is what keeps the escaping default in place for everything else. shared function cellMarkup(row, column) { if (!column || !row) { return "" } if (typeof column.render === "function") { try { var rendered = column.render(row) return rendered === undefined || rendered === null ? "" : String(rendered) } catch (error) { console.error("[wrnexus] data table column render failed", error) return "" } } if (column.html) { var field = column.html === true ? column.key : column.html var value = row[field] return value === undefined || value === null ? "" : String(value) } return "" } shared function matchesQuery(row) { if (!query) { return true } var needle = String(query).toLowerCase() return columnList().some(function (column) { return cellText(row, column).toLowerCase().indexOf(needle) !== -1 }) } shared function filteredRows() { // The server did the filtering; re-filtering here would hide rows it // deliberately returned. if (isRemote()) { return allRows() } return allRows().filter(function (row) { return matchesQuery(row) }) } shared function sortedRows() { var list = filteredRows().slice() if (isRemote() || !sortKey) { return list } var factor = sortDirection === "desc" ? -1 : 1 var kind = sortColumnType() return list.sort(function (left, right) { var a = left ? left[sortKey] : "" var b = right ? right[sortKey] : "" // Dates compared as text put "9 Feb" after "10 Jan"; parse them. if (kind === "date") { var at = Date.parse(a) var bt = Date.parse(b) if (!isNaN(at) && !isNaN(bt)) { return (at - bt) * factor } } if (kind === "number" || (typeof a === "number" && typeof b === "number")) { return ((Number(a) || 0) - (Number(b) || 0)) * factor } var as = a === undefined || a === null ? "" : String(a) var bs = b === undefined || b === null ? "" : String(b) return as.localeCompare(bs) * factor }) } // A column may declare type: "date" | "number" | "text". shared function sortColumnType() { var found = "" columnList().forEach(function (column) { if (column.key === sortKey && column.type) { found = column.type } }) return found } shared function totalCount() { // Only the source knows how many records exist beyond this page. if (isRemote()) { return Number(remoteTotal) || 0 } return sortedRows().length } shared function pageCount() { if (!paginated) { return 1 } var rowsPerPage = Number(perPage) > 0 ? Number(perPage) : 10 return Math.max(1, Math.ceil(totalCount() / rowsPerPage)) } // Clamped rather than stored: deleting or filtering rows can strand the // page number past the end, and a table showing nothing with rows // available is worse than one that quietly lands on the last page. shared function currentPage() { return Math.min(Math.max(1, Number(paginationPage) || 1), pageCount()) } shared function visibleRows() { var list = sortedRows() if (isRemote()) { return list } if (!paginated) { return list } var rowsPerPage = Number(perPage) > 0 ? Number(perPage) : 10 var start = (currentPage() - 1) * rowsPerPage return list.slice(start, start + rowsPerPage) } // Cells are built per row so the view can nest a loop inside its rows. shared function cellsFor(row) { return columnList().map(function (column) { return { key: column.key, label: columnLabel(column), value: cellText(row, column), html: cellMarkup(row, column), align: columnAlign(column), width: column.width || "" } }) } /* * One view object per visible row, carrying everything the row renders. * * Same rule as headerColumns: a loop only re-runs when something its LIST * expression reads changes. The row loop read visibleRows(), which knows * nothing about selection, so ticking a checkbox left every row exactly as * it was built. Reading selectedKeys here ties the two together, and the * cells come along so the nested loop has nothing left to compute. */ shared function rowViews() { var keys = selectedKeys return visibleRows().map(function (row, index) { var identity = keyOf(row, index) return { key: identity, raw: row, selected: keys.indexOf(identity) !== -1 ? "true" : "false", cells: cellsFor(row) } }) } // Comparison layout: the table is transposed, so a rendered row is a // FIELD and each of its cells is one record. Built here rather than in the // view because a loop can only iterate one list. shared function comparisonRows() { var records = visibleRows() return columnList().map(function (column, columnIndex) { return { key: column.key || String(columnIndex), label: columnLabel(column), align: columnAlign(column), cells: records.map(function (record, recordIndex) { return { key: keyOf(record, recordIndex), value: cellText(record, column), html: cellMarkup(record, column), align: columnAlign(column) } }) } }) } // Column headings for the transposed table: the first record field makes // a readable caption for each record column. shared function comparisonHeadings() { var first = columnList()[0] return visibleRows().map(function (record, index) { return { key: keyOf(record, index), label: first ? cellText(record, first) : String(index + 1) } }) } shared function rangeStart() { return totalCount() === 0 ? 0 : (currentPage() - 1) * (Number(perPage) || 10) + 1 } shared function rangeEnd() { if (!paginated) { return totalCount() } return Math.min(currentPage() * (Number(perPage) || 10), totalCount()) } // No data at all, versus a filter that happens to match nothing. shared function isFiltered() { return !!query } shared function emptyMessage() { return isFiltered() ? noResultsLabel : emptyLabel } shared function rangeLabel() { if (totalCount() === 0) { return emptyLabel } return "Showing " + rangeStart() + " to " + rangeEnd() + " of " + totalCount() } shared function columnAlign(column) { return column && column.align ? column.align : "start" } shared function columnLabel(column) { return column && column.label ? column.label : (column ? column.key : "") } /* * Headers carry their own sort state rather than each cell asking for it. * Attributes inside a data-for row are resolved when the row is built, and * this loop only depends on the column list -- so changing the sort left * aria-sort and the active highlight frozen at whatever they were on first * render. Reading sortKey/sortDirection here makes the header loop itself * depend on them, so the row is rebuilt with fresh values. */ shared function headerColumns() { var activeKey = sortKey var direction = sortDirection return columnList().map(function (column) { var isActive = activeKey === column.key return { key: column.key, label: columnLabel(column), align: columnAlign(column), width: column.width || "", sortable: column.sortable ? true : false, active: isActive ? "true" : "false", ariaSort: isActive ? (direction === "desc" ? "descending" : "ascending") : "none" } }) } // Windowed around the current page: a thousand-page table should not try // to render a thousand buttons. shared function actionList() { return Array.isArray(actions) ? actions : [] } // The rows behind the current selection, in the order they appear in the // data, so a handler receives records rather than bare keys. shared function selectedRows() { var keys = selectedKeys return allRows().filter(function (row, index) { return keys.indexOf(keyOf(row, index)) !== -1 }) } shared function selectedCount() { return selectedRows().length } // Reads selectedKeys so the toolbar re-renders as the selection changes; // a loop only re-runs when its list expression depends on what changed. shared function visibleActions() { var count = selectedKeys.length return actionList() .filter(function (action) { return action.when === "selection" ? count > 0 : true }) .map(function (action) { return { id: action.id || action.label, label: action.label || "", icon: action.icon || "", tone: action.tone || "", raw: action } }) } shared function pageSizeItems() { var current = Number(perPage) || 10 return (Array.isArray(pageSizes) ? pageSizes : [10, 25, 50]).map(function (option) { return { label: String(option), value: Number(option), selected: Number(option) === current } }) } shared function pageNumbers() { var total = pageCount() var active = currentPage() var first = Math.max(1, active - 2) var last = Math.min(total, active + 2) var list = [] var index = first while (index <= last) { list.push({ number: index, active: index === active ? "true" : "false" }) index = index + 1 } return list } shared function sortState(key) { if (sortKey !== key) { return "none" } return sortDirection === "desc" ? "descending" : "ascending" } shared function isSelectedKey(identity) { return selectedKeys.indexOf(identity) !== -1 } shared function isSelected(row, index) { return isSelectedKey(keyOf(row, index)) } shared function allVisibleSelected() { var visible = visibleRows() if (!visible.length) { return false } return visible.every(function (row, index) { return isSelected(row, index) }) } // A snapshot of the view, sent with `change` after anything that alters // what the table is showing, so a host can mirror the state (deep links, // a server query) without wiring up five separate outputs. shared function viewState() { return { page: currentPage(), pageSize: Number(perPage) || 10, total: totalCount(), query: query, sortKey: sortKey, sortDirection: sortDirection } } // --- async source ----------------------------------------------------- // // The result of load() cannot be written to state from its own .then(): // a client function copies state in at entry and flushes it back when the // body returns, so anything a promise callback assigns lands in a dead // local. The callback therefore only DISPATCHES, carrying the instance id // so two tables on one page cannot answer each other, and the declarative // @window handlers below apply the result with live state. // Begins a load: flags the pending state and asks for rows. The answer // arrives later on a window event, because a promise callback cannot write // state -- see applyRows. client function reload() { if (!remote) { return } loading = true loadError = "" output.request({ instanceId: instanceId, page: currentPage(), pageSize: Number(perPage) || 10, sortKey: sortKey, sortDirection: sortDirection, query: query }) } client function applyRows(sourceEvent) { var detail = sourceEvent.detail || {} if (detail.instanceId !== instanceId) { return } remoteRows = detail.rows remoteTotal = detail.total loading = false loadError = "" } client function applyError(sourceEvent) { var detail = sourceEvent.detail || {} if (detail.instanceId !== instanceId) { return } loading = false loadError = detail.message || errorLabel } // --- interaction ------------------------------------------------------ // Every write below is synchronous: a client function only flushes its // state when its body returns, so anything deferred would be lost. client function sortBy(key) { if (sortKey === key) { sortDirection = sortDirection === "asc" ? "desc" : "asc" } else { sortKey = key sortDirection = "asc" } paginationPage = 1 output.sort({ key: sortKey, direction: sortDirection }) reload() output.change(viewState()) } client function updateQuery(sourceEvent) { query = sourceEvent.target.value paginationPage = 1 output.search({ query: query }) reload() output.change(viewState()) } client function goToPage(next) { var target = Math.min(Math.max(1, next), pageCount()) paginationPage = target output.pageChange({ page: target, pageSize: Number(perPage) || 10 }) reload() output.change(viewState()) } // Component outputs deliver a payload rather than a DOM event, so this // takes the Dropdown select detail directly. client function pickPageSize(detail) { perPage = Number(detail && detail.value) || 10 paginationPage = 1 output.pageChange({ page: 1, pageSize: perPage }) reload() output.change(viewState()) } // Shift-click extends from the previous click, the way every file list and // mail client behaves; without it selecting twenty rows means twenty // clicks. client function clearSearch() { query = "" paginationPage = 1 output.search({ query: "" }) reload() } client function toggleRowAt(identity, sourceEvent) { if (sourceEvent && sourceEvent.shiftKey && lastAnchor) { var order = rowViews().map(function (view) { return view.key }) var from = order.indexOf(lastAnchor) var to = order.indexOf(identity) if (from !== -1 && to !== -1) { var start = Math.min(from, to) var end = Math.max(from, to) var next = selectedKeys.slice() var step = start while (step <= end) { if (next.indexOf(order[step]) === -1) { next.push(order[step]) } step = step + 1 } selectedKeys = next output.select({ selected: next, all: allVisibleSelected() }) return } } lastAnchor = identity toggleKey(identity) } client function toggleKey(key) { var next = [] var found = false selectedKeys.forEach(function (existing) { if (existing === key) { found = true } else { next.push(existing) } }) if (!found) { next.push(key) } selectedKeys = next output.select({ selected: next, all: allVisibleSelected() }) } client function toggleAll() { var visible = visibleRows() var everySelected = visible.every(function (row, index) { return isSelected(row, index) }) var next = [] if (!everySelected) { selectedKeys.forEach(function (existing) { next.push(existing) }) visible.forEach(function (row, index) { var key = keyOf(row, index) if (next.indexOf(key) === -1) { next.push(key) } }) } else { selectedKeys.forEach(function (existing) { var stillVisible = visible.some(function (row, index) { return keyOf(row, index) === existing }) if (!stillVisible) { next.push(existing) } }) } selectedKeys = next output.select({ selected: next, all: !everySelected }) } // Runs a toolbar action with the selected records. // // NOTHING may call a peer function after the callback: it is application // code and may change state (clearing the selection, adding a row), and a // peer call would flush this function's pre-callback snapshot back over // whatever it did. This function assigns no state, so it flushes nothing. client function runAction(entry, sourceEvent) { var rows = selectedRows() output.action({ id: entry.id, selected: selectedKeys, rows: rows, sourceEvent: sourceEvent }) var handler = entry.raw && entry.raw.onClick if (typeof handler === "function") { try { handler(rows, selectedKeys) } catch (error) { console.error("[wrnexus] data table action handler failed", error) } } } client function emitRowClick(row, sourceEvent) { output.rowClick({ row: row, sourceEvent: sourceEvent }) } } lifecycle { mount { // A per-instance id so the async result, which travels on a window // event, is only picked up by the table that asked for it. instanceId = Math.floor(Math.random() * 1000000) + 1 reload() } } view {
{#if caption || description || searchable}
{#if caption}

{caption}

{/if} {#if description}

{description}

{/if}
{selectedCount()} selected
{#if searchable} {/if}
{/if}
{#if selectable} {/if} {#if selectable} {/if}
{column.label}
{cell.value}
{caption} {heading.label}
{field.label} {cell.value}
{loadingLabel}
{loadError}
{emptyMessage()}