import {classMap} from "lit/directives/class-map.js"; import {type CSSResultGroup, html, nothing, type PropertyValues, type TemplateResult, unsafeCSS} from 'lit'; import {HasSlotController} from "../../internal/slot"; import {ifDefined} from "lit/directives/if-defined.js"; import {property, state} from 'lit/decorators.js'; import {ref} from "lit/directives/ref.js"; import {ResizeController} from '@lit-labs/observers/resize-controller.js'; import {Task} from "@lit/task"; import {unsafeHTML} from "lit/directives/unsafe-html.js"; import {type ZnFilterChangeEvent} from "../../events/zn-filter-change"; import {type ZnSearchChangeEvent} from "../../events/zn-search-change"; import ZincElement from '../../internal/zinc-element'; import ZnAlert from "../alert"; import ZnButton from "../button"; import ZnButtonGroup from "../button-group"; import ZnChip from "../chip"; import ZnConfirm from "../confirm"; import ZnDataTableFilter from "../data-table-filter"; import ZnDataTableSearch from "../data-table-search"; import ZnDropdown from "../dropdown"; import ZnEmptyState from "../empty-state"; import ZnHoverContainer from "../hover-container"; import ZnMenu from "../menu"; import ZnMenuItem from "../menu-item"; import ZnPanel from "../panel"; import ZnSkeleton from "../skeleton"; import ZnStyle from "../style"; import type ZnDataSelect from "../data-select"; import type ZnInput from "../input"; import type ZnQueryBuilder from "../query-builder"; import type ZnSelect from "../select"; // Tables load in such a way that the global sheet has to be re-applied inside the shadow root. import bootStyles from '../../../scss/boot.scss'; import styles from './data-table.scss'; const DEFAULT_PAGE = 1; const DEFAULT_PER_PAGE = 10; // Numbered page buttons shown at once; the window slides so the control never grows const PAGE_WINDOW = 3; const SUGGESTION_LIMIT = 25; interface Cell { text: string; subText?: string; column: string; color?: string; style?: string; iconSrc?: string; iconColor?: string; iconSize?: number; iconStyle?: string; hoverContent?: string; hoverPlacement?: string; chipColor?: string; gaid?: string; sortValue?: string; uri?: string; target?: string; copyable?: boolean; title?: string; } interface RowGroup { label: string; rows: Row[]; } interface Row { id: string; uri?: string; target?: string; actions?: ActionConfig[]; cells: Cell[]; } interface ResponseError { text: string; icon?: string; level?: 'primary' | 'error' | 'info' | 'success' | 'warning' | 'note'; } interface Response { rows: Row[]; perPage: number; total: number; page: number; error?: ResponseError; } export enum ActionSlots { delete = 'delete-action', modify = 'modify-action', actions = 'actions', create = 'create-action', filter = 'filter', filter_top = 'filter-top', sort = 'sort', search = 'search', inputs = 'inputs' } interface ActionConfig { text: string; uri: string; target: string; gaid: string; confirmType: string; confirmTitle: string; confirmContent: string; icon: string; iconSrc?: string; color?: string; type: string; } interface HeaderConfig { key: string; label: string; required?: boolean; default?: boolean; sortable?: boolean; filterable?: boolean; hideHeader?: boolean; hideColumn?: boolean; secondary?: boolean; type?: string; cellTemplate?: Cell; ifEmpty?: Cell; } type DisplayTemplate = (cell: Cell, row: Row, header: HeaderConfig) => TemplateResult | string; const defaultTemplates = { dateTime: ((cell) => { const t = cell.text || ''; return html`
${t.slice(0, 10)} ${t.slice(11)}
` }) satisfies DisplayTemplate }; interface DataRequest { page: number; perPage: number; sortColumn: string; sortDirection: string; filter: string; search: string; searchFields?: Record | null; } type AllowedInputElement = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement | ZnInput | ZnSelect | ZnDataSelect | ZnQueryBuilder /** * @summary Short summary of the component's intended use. * @documentation https://zinc.style/components/data-table * @status experimental * @since 1.0 * * @dependency zn-alert * @dependency zn-button * @dependency zn-empty-state * @dependency zn-chip * @dependency zn-hover-container * @dependency zn-dropdown * @dependency zn-menu * @dependency zn-menu-item * @dependency zn-panel * @dependency zn-button-group * @dependency zn-confirm * @dependency zn-skeleton * @dependency zn-data-table-search * * @slot - The default slot. * @slot search - Slot for search component, in the header's right-hand group. * @slot sort - Slot for sort component, in the header's right-hand group. * @slot filter - Slot for filter component, in the header's right-hand group. * @slot filter-top - Slot for a top-level filter component, rendered above the table's panel. * @slot delete-action - Slot for delete action button, shown in the header once rows are selected. * @slot modify-action - Slot for modify action button, shown in the header once rows are selected. * @slot actions - `zn-button` or `zn-confirm` elements listed in a header menu, enabled once rows are selected. * @slot create-action - Slot for create action button, at the end of the header's right-hand group. * @slot inputs - Slot for additional input controls. * @slot empty-state - Slot for custom empty state. * @slot no-results - Slot for a custom no-results state, shown when a search on a no-initial-load table returns no rows. * * @csspart base - The component's base wrapper. * * @cssproperty --example - An example CSS custom property. */ export default class ZnDataTable extends ZincElement { static styles: CSSResultGroup = [unsafeCSS(bootStyles), unsafeCSS(styles)]; static dependencies = { 'zn-alert': ZnAlert, 'zn-button': ZnButton, 'zn-empty-state': ZnEmptyState, 'zn-chip': ZnChip, 'zn-hover-container': ZnHoverContainer, 'zn-dropdown': ZnDropdown, 'zn-menu': ZnMenu, 'zn-menu-item': ZnMenuItem, 'zn-panel': ZnPanel, 'zn-button-group': ZnButtonGroup, 'zn-confirm': ZnConfirm, 'zn-skeleton': ZnSkeleton, 'zn-style': ZnStyle, 'zn-data-table-search': ZnDataTableSearch, }; @property({attribute: 'data-uri'}) dataUri: string; @property({attribute: 'data', type: Object}) data: Row[] | Row = []; @property({attribute: 'sort-column'}) sortColumn: string; @property({attribute: 'sort-direction'}) sortDirection: string = "asc"; @property({attribute: 'local-sort', type: Boolean}) localSort: boolean = false; @property({attribute: 'filter'}) filter: string = ''; @property({attribute: 'search'}) search: string = ''; @property({attribute: 'wide-column'}) wideColumn: string; @property({attribute: 'key'}) key: string = 'id'; @property({attribute: 'headers', type: Object}) headers: Record = {}; @property({attribute: false}) displayTemplates: Record = {}; // Hide header text keeping the content - e.g. Action buttons without a header @property({attribute: 'hide-headers', type: Object}) hiddenHeaders = '{}'; // Hide entire columns of data - e.g IDs @property({attribute: 'hide-columns', type: Object}) hiddenColumns = '{}'; // Specify individual unsortable headers @property({attribute: 'unsortable-headers', type: Object}) unsortableHeaders = '{}'; // Make table unsortable @property({attribute: 'unsortable', type: Boolean}) unsortable: boolean = false; // Hide pagination @property({attribute: 'hide-pagination', type: Boolean}) hidePagination: boolean; // Hide the column select dropdown in the table header @property({attribute: 'hide-column-select', type: Boolean}) hideColumnSelect: boolean = false; // Hide the refresh button in the table header @property({attribute: 'hide-refresh', type: Boolean}) hideRefresh: boolean = false; // Wraps the caption, header controls, rows and pagination in a zn-panel @property({type: Boolean}) standalone: boolean = false; @property() caption: string; @property({attribute: "empty-state-caption"}) emptyStateCaption: string; @property({attribute: "empty-state-icon"}) emptyStateIcon: string = "data_alert"; @property() filters: [] = []; @property() method: 'GET' | 'POST' = 'POST'; @property({attribute: "no-initial-load", type: Boolean}) noInitialLoad: boolean = false; /** * When set, the table's non-default state (search, filter, sort, page, per-page and field values) *is mirrored to the URL query string and restored from it on load, so the view is shareable. */ @property({attribute: 'sharable', type: Boolean}) sharable: boolean = false; /** * When set, extra request params (search-component field values) are nested under a * `searchFields` object - with `q` mirroring the search text - instead of being merged at the * root of the request body. */ @property({attribute: 'wrap-search-fields', type: Boolean}) wrapSearchFields: boolean = false; @property({attribute: 'group-by'}) groupBy = ''; @property() groups = ''; @property({attribute: 'per-page-size', type: Number}) itemsPerPage: number = DEFAULT_PER_PAGE; // Data Table Properties private _initialLoad = true; private _hasLoadedData = false; private _lastLoadHadRows = false; private _lastTableContent: TemplateResult = html``; // Sharable (URL <-> state) sync private _sharableInitialised = false; private _sharableDefaults: Record | null = null; private readonly _urlManagedKeys = new Set(['search', 'filter', 'sortColumn', 'sortDirection', 'page', 'perPage']); private readonly resizeObserver = new ResizeController(this, { target: null, callback: () => { if (this.tableContainer) { this.tableContainer.scrollIntoView({behavior: 'smooth', block: 'nearest'}); } }, }); // private itemsPerPage: number = DEFAULT_PER_PAGE; private page: number = DEFAULT_PAGE; private totalPages: number; private _totalRows = 0; private _rows: Row[] = []; private _suggestionsKey = ''; private numberOfRowsSelected: number = 0; private selectedRows: any[] = []; private tableContainer: Element | undefined; private hasSlotController = new HasSlotController( this, '[default]', ActionSlots.search.valueOf(), ActionSlots.delete.valueOf(), ActionSlots.modify.valueOf(), ActionSlots.actions.valueOf(), ActionSlots.create.valueOf(), ActionSlots.filter.valueOf(), ActionSlots.sort.valueOf(), ActionSlots.inputs.valueOf(), 'empty-state', 'no-results' ); private _dataTask = new Task(this, { task: async ([dataUri, requestParams], {signal}) => { // Every request path funnels through the task, so this is the single place to mirror the // current (non-default) state to the URL when `sharable` is set. this._updateSharableUrl(); if (dataUri === undefined || this.noInitialLoad && this._initialLoad) { return {rows: [], page: 1, perPage: this.itemsPerPage, total: 0}; } if (this.groupBy) { // we want to load all the data possible so we can group and show multiple tables this.itemsPerPage = 1000; } const requestData: DataRequest = { page: this.page, perPage: this.itemsPerPage, sortColumn: this.sortColumn, sortDirection: this.sortDirection, filter: this.filter, search: this.search, }; // Inputs-slot values are context/system params (e.g. csrf token, package name) sent with // every request. const inputs = this.hasSlotController.getSlots(ActionSlots.inputs.valueOf()); const params: Record = {}; if (inputs) { inputs.forEach((input) => { const allowedInputs = ['zn-input', 'zn-select', 'zn-query-builder', 'zn-multiselect', 'zn-params-select', 'zn-datepicker', 'input', 'select', 'textarea']; if (allowedInputs.includes(input.tagName.toLowerCase())) { const raw = (input as AllowedInputElement).value as string || input.getAttribute('value'); const value = typeof raw === 'string' ? raw.trim() : raw; const name = (input as AllowedInputElement).name || input.getAttribute('name'); if (name) { params[name] = value; } } }); Object.assign(requestData, params); } // Add any extra request params const extraParams = requestParams && typeof requestParams === 'object' ? requestParams as Record : {}; if (this.wrapSearchFields) { // Opt-in shape: field values nested under `searchFields`, with `q` mirroring the search // text, so a backend can bind them to one map. Empty values are dropped, and the key is // null when nothing is set. The root `search` key is sent either way. const searchFields: Record = {}; for (const [key, value] of Object.entries(extraParams)) { if (value !== undefined && value !== null && value !== '') { searchFields[key] = value; } } if (this.search || Object.keys(searchFields).length > 0) { searchFields.q = this.search; } requestData.searchFields = Object.keys(searchFields).length > 0 ? searchFields : null; } else { Object.assign(requestData, extraParams); } // This is also used for Rubix, so it may not work for your application. const response = await fetch(dataUri, { method: this.method, headers: { 'x-kx-fetch-style': 'zn-data-table', }, signal, credentials: 'same-origin', body: this.method === 'POST' ? JSON.stringify(requestData) : undefined }); if (!response.ok) throw new Error(response.statusText); return response.json(); }, args: () => [this.dataUri, this.requestParams] }); private rowHasActions: boolean = false; private _expandedRows: Set = new Set(); private _hiddenCells: Map = new Map(); private _secondaryHeaders: HeaderConfig[]; @state() private _deselectedColumns: Set = new Set(); @state() private _filtersOpen = false; private _columnDefaultsApplied = false; private _formatTemplates: Record = defaultTemplates; requestParams: Record = {}; refresh(): Promise { // Allow manual refresh to trigger the first data load when no-initial-load is set this._initialLoad = false; return this._dataTask.run(); } // Headers that make up the visible columns, in header order private visibleHeaders(): HeaderConfig[] { return this.selectableHeaders().filter((header: HeaderConfig) => this.isColumnVisible(header)); } // Headers the column select can toggle - everything but the permanently hidden and secondary ones private selectableHeaders(): HeaderConfig[] { return (Object.values(this.headers) as HeaderConfig[]).filter((header: HeaderConfig) => { if (header.hideHeader || header.hideColumn || header.secondary) return false; return !Object.values(this.hiddenColumns).includes(header.key); }); } private isColumnVisible(header: HeaderConfig): boolean { return header.required === true || !this._deselectedColumns.has(header.key); } private applyColumnDefaults() { if (this._columnDefaultsApplied) return; const headers = Object.values(this.headers) as HeaderConfig[]; if (headers.length === 0) return; this._columnDefaultsApplied = true; const deselected = new Set(this._deselectedColumns); headers.forEach((header: HeaderConfig) => { if (header.default === false && header.required !== true) deselected.add(header.key); }); this._deselectedColumns = deselected; } private toggleColumn(header: HeaderConfig) { if (header.required === true) return; const deselected = new Set(this._deselectedColumns); if (!deselected.delete(header.key)) { // Never let the last visible column be turned off if (this.visibleHeaders().length <= 1) return; deselected.add(header.key); } this._deselectedColumns = deselected; } // Reserved (non-field) URL param names that map onto dedicated table state. private static readonly _sharableKnownKeys = ['search', 'filter', 'sortColumn', 'sortDirection', 'page', 'perPage']; /** * Snapshot the initial (attribute-provided) value of every managed key, so the URL writer can * omit any key still holding its default - e.g. the table's default sort/direction, page 1 or the * default page size. Captured once, before the URL is read, so URL values are treated as deltas. */ private _captureSharableDefaults() { if (this._sharableDefaults) return; this._sharableDefaults = { search: this.search || '', filter: this.filter || '', sortColumn: this.sortColumn || '', sortDirection: this.sortDirection || '', page: String(this.page), perPage: String(this.itemsPerPage), }; } /** * Seed the table state from the URL query string (raw param names) when `sharable` is set. Runs * once, before the first render, so the initial data request already carries the shared state. */ private _readSharableState() { if (!this.sharable || this._sharableInitialised) return; if (typeof window === 'undefined') return; this._sharableInitialised = true; const params = new URLSearchParams(window.location.search); let hasRelevant = false; if (params.has('search')) { this.search = params.get('search')!; hasRelevant = true; } if (params.has('filter')) { this.filter = params.get('filter')!; hasRelevant = true; } if (params.has('sortColumn')) { this.sortColumn = params.get('sortColumn')!; hasRelevant = true; } if (params.has('sortDirection')) { this.sortDirection = params.get('sortDirection')!; hasRelevant = true; } if (params.has('page')) { const page = parseInt(params.get('page')!, 10); if (!isNaN(page) && page > 0) this.page = page; hasRelevant = true; } if (params.has('perPage')) { const perPage = parseInt(params.get('perPage')!, 10); if (!isNaN(perPage) && perPage > 0) this.itemsPerPage = perPage; hasRelevant = true; } // Any remaining param is adopted as an extra field value only when it maps to an actual field in // the table. Unrelated params (e.g. utm_*) are left untouched so they survive the round-trip. const known = new Set(ZnDataTable._sharableKnownKeys); const extras: Record = {}; params.forEach((value, key) => { if (known.has(key) || !this._hasFieldNamed(key)) return; hasRelevant = true; this._urlManagedKeys.add(key); extras[key] = value; }); if (Object.keys(extras).length > 0) { this.requestParams = {...this.requestParams, ...extras}; } // A shared link must render results even when no-initial-load is set - otherwise the recipient // would land on an empty table. Force the first load when the URL carries relevant params. if (hasRelevant && this.noInitialLoad) { this._initialLoad = false; } } /** * Push the shared state back into the actual DOM fields so the UI reflects it. The search value * lives on the slotted and extra field params on the search/inputs fields * (all light-DOM descendants). */ private _populateSharableFields() { if (this.search) { const searchEl = this.querySelector('zn-data-table-search') as (Element & { value?: unknown }) | null; if (searchEl) searchEl.value = this.search; } const known = new Set(ZnDataTable._sharableKnownKeys); Object.entries(this.requestParams).forEach(([name, value]) => { if (known.has(name) || name === 'searchUri' || value === undefined || value === null) return; this._setSharableFieldValue(name, String(value)); }); } private _hasFieldNamed(name: string): boolean { try { const selector = `[name="${window.CSS && CSS.escape ? CSS.escape(name) : name}"]`; return this.querySelector(selector) !== null; } catch { return false; } } private _setSharableFieldValue(name: string, value: string) { let selector: string; try { selector = `[name="${window.CSS && CSS.escape ? CSS.escape(name) : name}"]`; } catch { selector = `[name="${name}"]`; } this.querySelectorAll(selector).forEach((field) => { (field as Element & { value?: unknown }).value = value; // Custom elements may read the value from the attribute (and it survives an upgrade). if (field.tagName.includes('-')) { field.setAttribute('value', value); } }); } /** * Mirror the current table state to the URL query string (raw param names) via replaceState. * A key is written only when its value differs from the captured default, so the default sort, * page 1, the default page size and empty search/filter/fields never clutter the URL. Unrelated * params (e.g. utm_*) are preserved. */ private _updateSharableUrl() { if (!this.sharable || !window?.history) return; const defaults: Record = this._sharableDefaults ?? {}; const params = new URLSearchParams(window.location.search); // Drop every key the table manages, then re-add only the ones that differ from their default. this._urlManagedKeys.forEach((key) => params.delete(key)); const setParam = (key: string, value: unknown) => { this._urlManagedKeys.add(key); const str = value === undefined || value === null ? '' : String(value); if (str === '' || str === (defaults[key] || '')) return; params.set(key, str); }; // Extra field values - their default is empty, so setParam writes them only when set. const known = new Set(ZnDataTable._sharableKnownKeys); Object.entries(this.requestParams).forEach(([key, value]) => { if (known.has(key) || key === 'searchUri') return; setParam(key, value); }); setParam('search', this.search); setParam('filter', this.filter); setParam('sortColumn', this.sortColumn); setParam('sortDirection', this.sortDirection); setParam('page', this.page); setParam('perPage', this.itemsPerPage); const queryString = params.toString(); const newUrl = `${window.location.pathname}${queryString ? `?${queryString}` : ''}${window.location.hash}`; window.history.replaceState(window.history.state, '', newUrl); } render() { this.applyColumnDefaults(); // If no-initial-load is set, do not invoke the Task on the first render let tableBody: TemplateResult = html``; let emptyBody = false; if (this.noInitialLoad && this._initialLoad) { tableBody = html` `; emptyBody = true; } else if (this.dataUri) { tableBody = this._dataTask.render({ pending: () => { // Show the skeleton until data has rendered at least once, so the // first fetch on a no-initial-load table doesn't render blank space. // Also show it when the last load had no rows - a reduced-opacity // empty state gives no visible indication that a search is running if (this._initialLoad || !this._hasLoadedData || !this._lastLoadHadRows) { return html`
${this.loadingTable()}
`; } return this.tableContent(this._lastTableContent, true); }, complete: (data) => { this._initialLoad = false; this._hasLoadedData = true; this._lastLoadHadRows = ((data as Response)?.rows?.length ?? 0) > 0; emptyBody = !this._lastLoadHadRows; this._lastTableContent = html`
${this.renderTable(data as Response)}
`; return this.tableContent(this._lastTableContent, false); }, error: (error) => { if (error instanceof Error) { if (error.name === "SyntaxError") { console.debug(error.message) return html` Unable to load data `; } else if (error.message === "Not Found") { return this.emptyState(); } } return html`
${error}
` } }) as TemplateResult; } else if (Array.isArray(this.data) ? this.data.length > 0 : !!this.data) { const rows: Row[] = Array.isArray(this.data) ? this.data : [this.data]; const response: Response = { rows, perPage: rows.length, total: rows.length, page: 1, }; this._initialLoad = false; this._hasLoadedData = true; this._lastLoadHadRows = rows.length > 0; this._lastTableContent = html`
${this.renderTable(response)}
`; tableBody = this._lastTableContent; } const hasActions = this.hasSlotController.test(ActionSlots.delete.valueOf()) || this.hasSlotController.test(ActionSlots.modify.valueOf()) || this.hasSlotController.test(ActionSlots.actions.valueOf()) || this.hasSlotController.test(ActionSlots.create.valueOf()) || this.hasSlotController.test(ActionSlots.sort.valueOf()) || this.hasSlotController.test(ActionSlots.search.valueOf()); const hasInputs = this.hasSlotController.test(ActionSlots.inputs.valueOf()); const hasControls = hasActions || this.hasColumnSelect() || this.hasRefresh(); // An empty state with no header has nothing to hang off the panel, and consumers // often bring their own panel for it, so let it stand on its own const bareEmptyState = emptyBody && !hasControls && !this.caption; // The filter bar is inline, so it gets its own row rather than a slot in the header const filters = this.hasSlotController.test(ActionSlots.filter.valueOf()) ? html`
` : nothing; // Headers do not need to be re-rendered with new data return html`
(this.tableContainer = el))}> ${hasInputs ? html` ` : null} ${this.standalone && !bareEmptyState ? html` ${hasControls ? html`
${this.getActions()} ${this.getHeaderControls()}
` : nothing} ${filters} ${tableBody} ${this.getTableFooter('footer')}
` : html` ${hasControls || this.caption ? this.getTableHeader() : nothing} ${filters} ${tableBody} ${this.getTableFooter()}`}
`; } connectedCallback() { super.connectedCallback(); this.addEventListener('zn-filter-change', this.filterChangeListener); this.addEventListener('zn-clear', this.filterClearListener); this.addEventListener('zn-search-change', this.searchChangeListener); // Capture defaults before reading the URL so URL values are treated as deltas, then seed state. this._captureSharableDefaults(); this._readSharableState(); } protected firstUpdated() { // The filter may come from the URL or straight from a server-rendered attribute if (this.filter) { const filterEl = this.querySelector('zn-data-table-filter') as (Element & { value?: unknown }) | null; if (filterEl) filterEl.value = this.filter; } if (this.sharable) { this._populateSharableFields(); } } protected updated(changed: PropertyValues) { super.updated(changed); this.publishFilterSuggestions(); } // The filter bar can't see the data, so hand it the values already on screen to type against private publishFilterSuggestions() { const bar = this.querySelector('zn-data-table-filter'); if (!bar || this._rows.length === 0) return; const suggestions: Record = {}; this._rows.forEach((row: Row) => row.cells.forEach((cell: Cell) => { const text = cell.text?.trim(); if (!text) return; const values = suggestions[cell.column] ?? (suggestions[cell.column] = []); if (values.length < SUGGESTION_LIMIT && !values.includes(text)) values.push(text); })); const key = JSON.stringify(suggestions); if (key === this._suggestionsKey) return; this._suggestionsKey = key; bar.suggestions = suggestions; } private getTemplate(name: string): DisplayTemplate | undefined { return this.displayTemplates[name] ?? this._formatTemplates[name]; } disconnectedCallback() { super.disconnectedCallback(); this.removeEventListener('zn-filter-change', this.filterChangeListener); this.removeEventListener('zn-clear', this.filterClearListener); this.removeEventListener('zn-search-change', this.searchChangeListener); } // Clearing the filters leaves an empty row behind, so fold it away filterClearListener = (e: Event) => { if (e.target instanceof ZnDataTableFilter) { this._filtersOpen = false; } } filterChangeListener = (e: ZnFilterChangeEvent) => { if (e.target instanceof ZnDataTableFilter) { this.filter = (e.target as ZnDataTableFilter).value as string; this._dataTask.run().then(r => r); } } searchChangeListener = (e: ZnSearchChangeEvent) => { const target = e.target as ZnDataTableSearch | null; if (target && target.tagName === 'ZN-DATA-TABLE-SEARCH') { this.search = (target.value as string).trim(); // Get form data and search URI from event detail if available if (e.detail) { const {formData, searchUri} = e.detail as { formData?: Record; searchUri?: string }; // Merge form data into request params if (formData && typeof formData === 'object') { this.requestParams = {...this.requestParams, ...formData}; } // Temporarily override dataUri for this search if searchUri is provided if (searchUri) { const originalUri = this.dataUri; this.dataUri = searchUri; this._dataTask.run().then(() => { // Restore original URI after search this.dataUri = originalUri; }); return; } } this._dataTask.run().then(r => r); } } // A table narrowed by a search or filter has rows, just none matching them, so // its empty state must not claim there is nothing to create private get isNarrowed(): boolean { return this.search.length > 0 || this.filter.length > 0; } emptyState() { if (this.isNarrowed || (this.noInitialLoad && !this._initialLoad)) { return html`

Nothing matched your search. Try adjusting your filters.

`; } return html`
`; } renderTable(data: Response) { this.itemsPerPage = Math.max(DEFAULT_PER_PAGE, data.perPage ?? DEFAULT_PER_PAGE); this.page = Math.max(1, data.page ?? DEFAULT_PAGE); this._totalRows = Math.max(0, data.total ?? 0); this.totalPages = Math.ceil(Math.max(1, data.total) / this.itemsPerPage); const error = data?.error?.text ? data.error : undefined; if (!data?.rows || data.rows.length === 0) { this._rows = []; this.pruneSelectedRows(); return html`${error ? html`
${this.renderErrorAlert(error)}
` : nothing}${this.emptyState()}`; } this._rows = this.getRows(data); this.pruneSelectedRows(); if (this.groupBy) { // we need to group the rows by the group column const groupedRows: Record = {}; // add groups to the current rows const toAdd = this.groups.split(',').map(g => g.trim()).filter(g => g.length > 0); const hasWildcard = toAdd.includes('*'); const explicitGroups = new Set( toAdd .filter(g => g !== '*') .map(g => this.humanize(g)) ); // pre-create explicit groups explicitGroups.forEach((group) => { if (!groupedRows[group]) { groupedRows[group] = []; } }); // pre-create wildcard grouping if requested if (hasWildcard) { groupedRows['*'] = []; } this._rows.forEach((row: Row) => { const groupCell = row.cells.find((cell: Cell) => cell.column === this.groupBy); const rawGroupValue = groupCell ? groupCell.text : 'Ungrouped'; const groupValue = this.humanize(rawGroupValue); if (explicitGroups.has(groupValue)) { groupedRows[groupValue].push(row); return; } if (hasWildcard) { groupedRows['*'].push(row); return; } // If no groups specified, or handling ungrouped fallback, create groups dynamically if (!this.groups || groupValue === 'Ungrouped') { if (!groupedRows[groupValue]) { groupedRows[groupValue] = []; } groupedRows[groupValue].push(row); } }); // remove empty groups Object.keys(groupedRows).forEach((groupKey) => { if (groupedRows[groupKey].length === 0) { delete groupedRows[groupKey]; } }); const groups: RowGroup[] = Object.keys(groupedRows).map((groupKey) => ({ label: (groupKey === 'Ungrouped' || groupKey === '*') ? '' : groupKey, rows: groupedRows[groupKey], })); return this.renderTableData(groups, error); } return this.renderTableData([{label: '', rows: this._rows}], error); } private renderErrorAlert(error: ResponseError) { return html` ${error.text} `; } public humanize(str: string) { return str.charAt(0).toUpperCase() + str.slice(1); } public renderTableData(groups: RowGroup[], error?: ResponseError) { // Primary (visible) headers exclude those explicitly hidden, deselected and those marked as secondary const filteredHeaders = this.visibleHeaders(); // Secondary headers (shown in expandable details) this._secondaryHeaders = Object.values(this.headers).filter((header: HeaderConfig) => { if (header.hideColumn) return false; if (Object.values(this.hiddenColumns).includes(header.key)) return false; return header.secondary === true; }); this.rowHasActions = this._rows.some((row: Row) => row.actions && row.actions.length > 0); // Compute and store hidden cells per row (secondary items only), and reorder visible cells to match header order const secondaryKeys = new Set(this._secondaryHeaders.map(h => h.key)); this._hiddenCells.clear(); const visibleRowCells: Map = new Map(); this._rows.forEach((row: Row) => { const originalCells = Array.isArray(row.cells) ? row.cells : []; const orderedCells: Cell[] = []; const hiddenCells = originalCells.filter((c: Cell) => secondaryKeys.has(c.column)); this._hiddenCells.set(row.id, hiddenCells); filteredHeaders.forEach((header: HeaderConfig) => { const cell = originalCells.find((c: Cell) => c.column === header.key); if (cell) { orderedCells.push(cell); } else { orderedCells.push({text: '', column: header.key}); } }); visibleRowCells.set(row.id, orderedCells); }); const anyHidden = this.hasHiddenColumns(); const showCheckboxes = this.isSelectable(); const colCount = filteredHeaders.length + (this.rowHasActions ? 1 : 0) + (anyHidden ? 1 : 0) + (showCheckboxes ? 1 : 0); return html`
${showCheckboxes ? this.renderSelectAllCheckbox() : nothing} ${anyHidden ? html` ` : nothing} ${filteredHeaders.map((header: HeaderConfig) => this.renderCellHeader(header))} ${this.rowHasActions ? html` ` : html``} ${error ? html` ` : nothing} ${groups.map((group: RowGroup) => html` ${group.label ? html` ` : nothing} ${group.rows.map((row: Row) => html` ${showCheckboxes ? this.renderRowCheckbox(row) : nothing} ${anyHidden ? this.renderExpanderCell(row) : nothing} ${(visibleRowCells.get(row.id) || row.cells).map((value: Cell, index: number) => this.renderCellBody(index, value, row))} ${this.rowHasActions ? this.renderActions(row) : nothing} ${this._expandedRows.has(row.id) ? this.renderDetailsRow(row, colCount) : nothing} `)} `)}
${this.renderErrorAlert(error)}
${group.label}
`; } // Pending and loaded states share this template so Lit keeps the table's DOM (and focus) across loads private tableContent(content: TemplateResult, pending: boolean) { return html`
${content}
`; } getTableHeader() { return html`
${this.caption ? html`

${this.caption}

` : nothing}
${this.getActions()} ${this.getHeaderControls()}
`; } private getHeaderControls() { return html` ${this.getColumnSelect()} ${this.getFilterToggle()} ${this.getRefreshButton()} `; } // Nothing to pick columns on or refresh into while the table has no rows private hasRows(): boolean { return this._hasLoadedData && this._lastLoadHadRows; } private getFilterToggle() { if (!this.hasSlotController.test(ActionSlots.filter.valueOf())) return nothing; const applied = this.appliedFilterCount(); return html` `; } private toggleFilters = () => { this._filtersOpen = !this._filtersOpen; } private appliedFilterCount(): number { if (!this.filter) return 0; try { const applied: unknown = JSON.parse(atob(this.filter)); return Array.isArray(applied) ? applied.length : 0; } catch { return 0; } } private hasColumnSelect(): boolean { return !this.hideColumnSelect && this.hasRows() && this.selectableHeaders().length > 1; } private hasRefresh(): boolean { return !this.hideRefresh && this.hasRows() && !!this.dataUri; } private getColumnSelect() { if (!this.hasColumnSelect()) return nothing; return html` ${this.selectableHeaders().map((header: HeaderConfig) => html` ${header.label} `)} `; } private getRefreshButton() { if (!this.hasRefresh()) return nothing; return html` `; } private handleColumnSelect = (event: CustomEvent<{ value: string }>) => { const header = (Object.values(this.headers) as HeaderConfig[]) .find((h: HeaderConfig) => h.key === event.detail.value); if (header) this.toggleColumn(header); } // Returns null when there is nothing to show, so a panel footer is only slotted when it has content getTableFooter(slot?: string) { if (!this._hasLoadedData) return null; const rowSelected = this.getRowsSelected(); const pagination = this.getPagination(); const rowsPerPage = this.getRowsPerPage(); if (rowSelected === null && pagination === null && rowsPerPage === null) return null; return html` `; } getRowsSelected() { if (this.selectedRows.length <= 0) return null; return html`

${this.numberOfRowsSelected} of ${this._rows.length} rows selected

` } getRowsPerPage() { if (this.hidePagination) return null; const optionsRowsPerPage = [10, 20, 30, 40, 50]; // Keyed to the dataset, not the current page size - raising the size past the total // must not hide the only control that can lower it again if (this._totalRows <= optionsRowsPerPage[0]) return null; return html` `; } // A window of PAGE_WINDOW pages that slides with the current page and stays inside the page // count; the first/last buttons cover jumping to either end private getPageRange(): number[] { const size = Math.min(PAGE_WINDOW, this.totalPages); const start = Math.min( Math.max(1, this.page - Math.floor(size / 2)), this.totalPages - size + 1 ); return Array.from({length: size}, (_, i) => start + i); } getPagination() { if (this.hidePagination || this.totalPages <= 1) return null; return html` ${this.getPageRange().map((p: number) => html` ${p} `)} `; } getActions() { const actions = []; if (this.selectedRows.length > 0) { if (this.hasSlotController.test(ActionSlots.delete.valueOf())) { actions.push(html` `); } if (this.hasSlotController.test(ActionSlots.modify.valueOf())) { actions.push(html` `); } } if (this.hasSlotController.test(ActionSlots.actions.valueOf())) { actions.push(this.getActionsMenu()); } return actions; } private getActionsMenu() { const items = [...this.hasSlotController.getSlots(ActionSlots.actions.valueOf())]; const disabled = this.selectedRows.length === 0; return html` ${items.map((item: HTMLElement) => { const trigger = this.actionTrigger(item); const icon = trigger?.getAttribute('icon') || item.getAttribute('icon'); return html` ${icon ? html` ` : nothing} ${this.actionLabel(item, trigger)} `; })}
`; } private actionTrigger(item: HTMLElement): HTMLElement | null { if (item instanceof ZnConfirm) { return item.querySelector(':scope > [slot="trigger"]'); } return item; } private actionLabel(item: HTMLElement, trigger: HTMLElement | null): string { return trigger?.textContent?.trim() || trigger?.getAttribute('content') || trigger?.getAttribute('tooltip') || item.getAttribute('caption') || ''; } private runAction(item: HTMLElement) { if (item instanceof ZnConfirm) { item.show(); return; } // ZnButton.click() skips the native click, so listeners and href would never fire const target = item instanceof ZnButton ? item.button : item; target?.click(); } goToPage(page: number) { this.page = page; this.selectedRows = []; this.numberOfRowsSelected = 0; this._dataTask.run().then(r => r); this.resizeObserver.observe(this); } goToFirstPage() { this.goToPage(1); } goToPreviousPage() { this.goToPage(Math.max(this.page - 1, 1)); } goToNextPage() { this.goToPage(Math.min(this.page + 1, this.totalPages)); } goToLastPage() { this.goToPage(this.totalPages); } updateRowsPerPage(event: Event) { const select = event.target as ZnSelect; this.itemsPerPage = parseInt(select.value as string); this.page = 1; // reset the page to 1 when changing the number of rows per page this.requestUpdate(); this._dataTask.run().then(r => r); } /** Selects every row on the page, or clears the selection when every row is already selected. */ selectAll() { if (!this.isSelectable()) return; if (this.numberOfRowsSelected === this._rows.length) { this.clearSelectedRows(); return; } this.selectedRows = this._rows; this.numberOfRowsSelected = this.selectedRows.length; this.updateKeys(); this.requestUpdate(); } selectRow(e: Event) { if (!this.isSelectable()) { return; } if (!(e.target && (e.target instanceof Element))) { return; } const target: Element | null = e.target; // if is a button or a link continue if (target?.tagName === 'ZN-BUTTON' || target?.tagName === 'A' || target?.tagName === 'BUTTON') { return; } // Find the parent row let parent: Element | null = target.closest ? target.closest('tr') : target.parentElement; while (parent && parent.tagName !== 'TR') { parent = parent.parentElement; } if (parent === null) { return; } const rowId = (parent as HTMLElement).dataset.rowId; const row = this._rows.find((r: Row) => r.id === rowId); if (!row) return; this.toggleRowSelection(row); } private toggleRowSelection(row: Row) { const alreadySelected = this.selectedRows.some((r: Row) => r.id === row.id); if (alreadySelected) { this.selectedRows = this.selectedRows.filter((r: Row) => r.id !== row.id); } else { this.selectedRows = [...(this.selectedRows as Row[]), row]; } this.numberOfRowsSelected = this.selectedRows.length; this.updateKeys(); this.requestUpdate(); } clearSelectedRows() { this.selectedRows = []; this.numberOfRowsSelected = 0; this.updateKeys(); this.requestUpdate(); } updateSort(key: string) { // Rerun the render task with the extra params return () => { this.sortColumn = key; this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc'; if (this.localSort) { this._rows = this.sortLocalData(this._rows as Row[]); // Local sort never runs the data task (our URL-sync choke point), so mirror it here. this._updateSharableUrl(); this.requestUpdate(); } else { this._dataTask.run().then(r => r); } }; } renderCell(data: Cell, row?: Row, header?: HeaderConfig): TemplateResult | ZincElement { const fn = (header?.type ? this.getTemplate(header.type) : undefined); if ((!data.text && !data.iconSrc) && header?.ifEmpty !== undefined) { const header2 = {...header, type: undefined, render: undefined, renderTemplate: undefined}; return this.renderCell({...data, ...header.ifEmpty}, row, header2) } if (fn && row && header) { const out = fn(data, row, header); return typeof out === 'string' ? html`${unsafeHTML(out)}` : out; } if (!data || typeof data !== 'object') { return data; } if (header?.cellTemplate !== undefined) { data = {...header.cellTemplate, ...data} } let content: TemplateResult | ZincElement = html`${data.text}`; if (data.style || data.color) { const styleStr = typeof data.style === 'string' ? data.style : ''; const tokens = new Set(styleStr.split(',').filter(Boolean)); const isMono = tokens.has('mono') || tokens.has('code'); const isBorder = tokens.has('border'); const isCenter = tokens.has('center'); const isMuted = tokens.has('muted'); if (tokens.has('bold') || tokens.has('strong')) { content = html`${content}`; } if (tokens.has('italic')) { content = html`${content}`; } content = html` ${content} `; } if (data.uri) { content = html` ${content}`; } if (data.chipColor) { content = html` ${content}`; } if (data.iconSrc) { const src = data.iconSrc; const color = data.iconColor; const size = data.iconSize; const tokens = (data.iconStyle ?? '').split(',').map(s => s.trim()).filter(Boolean); content = html` { if (!el) return; tokens.forEach(t => el.setAttribute(t, '')); })} title="${ifDefined(data.title)}" > ${content}`; } if (data.hoverContent) { const placement = data.hoverPlacement ?? 'top'; if (data.iconSrc) { content = html` ${content}
${unsafeHTML(data.hoverContent)}
`; } else { content = html` ${content}
${unsafeHTML(data.hoverContent)}
`; } } if (data.copyable) { content = html` ${content} `; } if (data.subText) { content = html`
${content}
${data.subText}
` } return content; } private updateActionKeys(slotName: string) { if (this.hasSlotController.test(slotName)) { // we need to look into the slot controller for the keys input const slots = this.hasSlotController.getSlots(slotName); if (slots) { slots.forEach((slot) => { const input = slot.querySelector('input[name="keys"]'); if (input && input instanceof HTMLInputElement) { input.value = this.getSelectedKeys().join(','); } }); } } } private getTableSortIcon(key: any) { if (this.sortColumn !== key) { return html`
`; } if (this.sortDirection === 'asc') { return html`
`; } return html`
`; } private renderCellHeader(header: HeaderConfig) { const sortable = !Object.values(this.unsortableHeaders).includes(header.key) && !Object.values(this.hiddenHeaders).includes(header.key) && !this.unsortable && header.sortable !== false; const lastVisibleHeaderKey = this.visibleHeaders().slice(-1)[0]?.key; return html`
${header.label} ${sortable ? this.getTableSortIcon(header.key) : nothing}
`; } private sortState(key: string): 'ascending' | 'descending' | 'none' { if (this.sortColumn !== key) return 'none'; return this.sortDirection === 'asc' ? 'ascending' : 'descending'; } private handleSortKeyDown(e: KeyboardEvent, key: string) { if (e.key !== 'Enter' && e.key !== ' ') return; e.preventDefault(); this.updateSort(key)(); } private renderCellBody(index: number, value: Cell, row: Row) { const filteredHeaders = this.visibleHeaders(); const header: HeaderConfig | undefined = filteredHeaders[index]; const headerKey: string | undefined = header?.key; return html`
${this.renderCell(value, row, header)}
`; } private hasHiddenColumns(): boolean { return this._secondaryHeaders.length > 0; } private renderExpanderCell(row: Row) { const hasHidden = (this._hiddenCells.get(row.id) || []).length > 0; if (!hasHidden) return html` `; const expanded = this._expandedRows.has(row.id); const icon = expanded ? 'chevron_down@lu' : 'chevron_right@lu'; return html` `; } private renderDetailsRow(row: Row, colSpan: number) { const hiddenCells = this._hiddenCells.get(row.id) || []; if (hiddenCells.length === 0) return html``; return html`
${hiddenCells.map((cell: Cell) => { const header = Object.values(this.headers).find((h: HeaderConfig) => h.key === cell.column); const label = header?.label || this.humanize(cell.column); return html`
${label}
${this.renderCell(cell, row, header)}
`; })}
`; } private toggleRowExpansion(e: Event, row: Row) { e.stopPropagation(); if (this._expandedRows.has(row.id)) { this._expandedRows.delete(row.id); } else { this._expandedRows.add(row.id); } this.requestUpdate(); } private isRowSelected(row: Row): boolean { return this.selectedRows.some((r: Row) => r.id === row.id); } // Drop selections for rows no longer in the result set (filtered out, archived, paged away) // and swap survivors for the fresh row objects so selected keys reflect current cell data. private pruneSelectedRows() { if (this.selectedRows.length === 0) return; const current = new Map(this._rows.map((row: Row) => [row.id, row])); const kept = this.selectedRows .map((row: Row) => current.get(row.id)) .filter((row): row is Row => row !== undefined); const changed = kept.length !== this.selectedRows.length; this.selectedRows = kept; this.numberOfRowsSelected = kept.length; if (changed) this.updateKeys(); } private isSelectable(): boolean { return [ActionSlots.delete, ActionSlots.modify, ActionSlots.actions, ActionSlots.create] .some(slot => this.hasSlotController.test(slot.valueOf())); } private renderSelectAllCheckbox() { const allSelected = this.numberOfRowsSelected === this._rows.length; const someSelected = this.numberOfRowsSelected > 0 && !allSelected; return html`
`; } private renderRowCheckbox(row: Row) { return html`
`; } // A click on the checkbox itself arrives twice (label, then input), so leave it to zn-change private handleCheckboxCellClick(e: Event, row: Row) { if ((e.target as Element).closest('zn-checkbox')) return; this.toggleRowSelection(row); } private getRows(data: Response): Row[] { // Copy rows to avoid mutating original data const sourceRows = Array.isArray(data.rows) ? data.rows.slice() : []; if (this.localSort && this.sortColumn) { return this.sortLocalData(sourceRows); } return sourceRows; } private getSelectedKeys(): (string)[] { const headerKeys = Object.keys(this.headers); const keyIndex = headerKeys.indexOf(this.key); return this.selectedRows .map((row: Row) => { if (this.key === 'id' || keyIndex === -1) { return row.id; } const cell = row.cells?.[keyIndex] as Cell; if (cell.sortValue) { return cell.sortValue; } return cell.text as string; }) .filter((v): v is string => v !== undefined && v !== null); } private updateKeys() { this.updateModifyKeys(); this.updateDeleteKeys(); this.updateActionKeys(ActionSlots.actions.valueOf()); } private updateModifyKeys() { this.updateActionKeys('modify-action'); } private updateDeleteKeys() { this.updateActionKeys('delete-action'); } private extractComparable(cell: Cell): string { const sortValue = cell.sortValue; if (sortValue) { return sortValue; } return cell.text; } private sortData(a: Cell, b: Cell) { const aCompare = this.extractComparable(a); const bCompare = this.extractComparable(b); const aNormal = aCompare.toLowerCase(); const bNormal = bCompare.toLowerCase(); if (this.sortDirection === 'asc') { return aNormal > bNormal ? 1 : aNormal < bNormal ? -1 : 0; } return aNormal < bNormal ? 1 : aNormal > bNormal ? -1 : 0; } private sortLocalData(rows: Row[]): Row[] { if (!this.sortColumn) return rows; const headerKeys = Object.keys(this.headers); const sortIndex = headerKeys.indexOf(this.sortColumn); const getCellForSort = (row: Row): Cell => { const byHeading = row.cells.find((cell: Cell) => cell.column === this.sortColumn); if (byHeading !== undefined) return byHeading as unknown as Cell; return row.cells[sortIndex]; }; return rows.slice().sort((rowA: Row, rowB: Row) => { const aVal = getCellForSort(rowA); const bVal = getCellForSort(rowB); return this.sortData(aVal, bVal); }); } private loadingTable() { return html`
`; } private renderActions(row: Row) { if (!row.actions || row.actions.length === 0) { if (this.rowHasActions) { return html` `; } return nothing; } return html` ${row.actions?.map((action: ActionConfig) => { if (action.confirmContent) { const triggerId = 'confirm-action-' + Math.random().toString(36).substring(2, 15); return html` ${(action.icon || action.iconSrc) ? html` ` : html``} ${action.text} `; } else { return html` ${(action.icon || action.iconSrc) ? html` ` : html``} ${action.text} `; } })} `; } }