/* eslint-disable import/no-duplicates */ import '@digital-realty/grid'; import type { GridItemModel } from '@digital-realty/grid'; import { columnHeaderRenderer } from '@digital-realty/grid/lit.js'; import { GridColumn } from '@digital-realty/grid/src/vaadin-grid-column.js'; import '@digital-realty/ix-icon-button/ix-icon-button.js'; import '@digital-realty/ix-icon/ix-icon.js'; import '@digital-realty/ix-progress/ix-progress.js'; import { html, LitElement, nothing, render } from 'lit'; import { property, query, state } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import { formatDate } from 'date-fns/format.js'; import './components/IxGridColumnFilter.js'; import './components/IxGridDownloadMenu.js'; import './components/IxGridRowFilter.js'; import type { Filter } from './components/IxGridRowFilter.js'; import './components/IxPagination.js'; import { IxGridViewStyles } from './grid-view-styles.js'; import { copy } from './ix-grid-copy.js'; import { IxGridDownloadMenuItemModel } from './models/IxGridDownloadMenuItemModel.js'; export interface Row { [key: string]: unknown; } export type FilterOperator = 'equals' | 'contains'; export type DataType = 'string' | 'dateTime'; export type BodyRenderer = ( item: any, model: GridItemModel, column: GridColumn ) => any; export interface Column { name: string; header: string; bodyRenderer: BodyRenderer; width?: string; sortable?: boolean; filterable?: boolean; hidden?: boolean; frozenToEnd?: boolean; dataType?: DataType; filterOperators?: FilterOperator[]; autoWidth?: boolean; minWidth?: string; maxWidth?: string; responsive?: [string, string][]; flexGrow?: number; } export interface FieldOperator { columnField: string; operator: FilterOperator; } export interface SessionStorageData { pageSize?: number; } export class IxGrid extends LitElement { static readonly styles = [IxGridViewStyles]; private defaultPageSize = 10; private defaultPage = 1; private originalSearchParams: URLSearchParams | undefined = undefined; @query('vaadin-grid') grid!: HTMLElement; @property({ type: Boolean, attribute: 'column-reordering-allowed' }) columnReorderingAllowed: boolean = false; @property({ type: String }) variantClass = ''; @property({ type: Boolean, attribute: 'simple-pagination' }) hasSimplePagination = false; @property({ type: String }) theme = 'no-border'; @property({ type: Array }) readonly columns: Column[] = []; @property({ type: Array }) rows: Row[] = []; @property({ type: String }) defaultEmptyText = 'No data to display'; @property({ type: String }) sortedColumn = ''; @property({ type: String }) sortDirection = ''; @property({ type: Boolean }) hideHeader = false; @property({ type: Boolean, attribute: 'hide-filters' }) hideFilters = false; @property({ type: Number }) rowLimit: number = 0; @property({ type: Number }) page = this.defaultPage; @property({ type: Number }) pageSize = this.defaultPageSize; @property({ type: Array }) pageSizes: number[] = [5, 10, 25, 100]; @property({ type: Number }) recordCount = 0; @property({ type: String }) localStorageID: string | undefined = undefined; @property({ type: Boolean }) showDownload = true; @property({ type: Boolean }) isDownloading = false; @property({ type: Boolean }) isLoading = false; @property({ type: Array }) downloadMenuItems: IxGridDownloadMenuItemModel[] = []; @property({ type: Boolean, attribute: 'add-params-to-url' }) addParamsToURL = true; @property({ type: Boolean }) readParamsFromURL = false; @property({ type: Boolean }) refreshDataOnColumnVisibilityChange: boolean = true; @property({ type: Number }) filterValueChangeDebounceTime: number = 300; @property({ type: Boolean }) hideColumnHeaders = false; @property({ type: Array }) preservedQueryParamKeys: string[] = []; @property({ type: String }) filterMaxDate?: string = formatDate( new Date(), 'yyyy-MM-dd' ); @property({ type: String }) hashedTableState: string = ''; @property({ type: Boolean }) hideViewMoreLessButtonIcon = true; @property({ type: Boolean }) showAddButton = false; @property({ type: Boolean }) disableAddButton = false; @property({ type: Boolean, attribute: 'show-view-more' }) showViewMore = false; @property({ type: String }) addButtonLabel: string = copy.add; @property({ type: Function }) onAddButtonClick?: any; @property({ type: Boolean }) showRemoveAllButton = false; @property({ type: Boolean }) disableRemoveAllButton = false; @property({ type: String }) removeAllButtonLabel: string = copy.removeAll; @property({ type: Function }) onRemoveAllButtonClick?: any; @property({ type: String, attribute: 'session-storage-key' }) sessionStorageKey: string | undefined = undefined; @property({ type: Boolean }) useNewDatePicker = false; @state() private filters: Filter[] = []; @state() isColumnsReordering = false; @state() isExpanded = false; @state() displayColumns: Column[] = []; @state() sessionStorageData: SessionStorageData | undefined = undefined; private defaultFilterKeys = ['sort', 'order', 'page', 'size']; private initialised = false; get isPersistable() { if (this.localStorageID) return true; return false; } get columnNames() { return this.columns.map((column: Column) => column.name); } get columnsLocalStorageKey() { if (this.hashedTableState === '') { const columnsWithoutFunctions = this.columns.map( // eslint-disable-next-line @typescript-eslint/no-unused-vars ({ bodyRenderer, hidden, ...rest }) => rest ); const serializedColumns = JSON.stringify(columnsWithoutFunctions); let hash = 0; for (let i = 0; i < serializedColumns.length; i += 1) { // Update hash using prime number multiplier (31) for better distribution and ensure it doesn't exceed 32-bit integer // eslint-disable-next-line no-bitwise hash = (hash * 31 + serializedColumns.charCodeAt(i)) >>> 0; } // Convert the string to base36 for brevity this.hashedTableState = hash.toString(36); } return `ix-grid-${this.localStorageID}-${this.hashedTableState}-columns`; } get arrangedColumns() { let columnsToDisplay: Column[] = []; columnsToDisplay = this.getColumnsToDisplayFromLocalStorage(); if (columnsToDisplay.length === 0) columnsToDisplay = [...this.columns]; return columnsToDisplay .filter(col => col) .map((column: Column) => ({ ...column, width: !column.width ? undefined : column.width, })); } connectedCallback() { super.connectedCallback?.(); window.addEventListener('popstate', this.handlePopState); window.addEventListener('beforeunload', this.handleUnload); } disconnectedCallback() { window.removeEventListener('popstate', this.handlePopState); window.removeEventListener('beforeunload', this.handleUnload); super.disconnectedCallback?.(); } private handlePopState = () => { this.updateSearchParamsFromUri(true); this.dispatchChangeEvent(); }; private handleUnload = () => { sessionStorage.removeItem(`urlPageSizeRead`); }; private updateSearchParamsFromUri( rebuildFiltersFromUri: boolean = false ): void { // If the grid is not visible, do not update from search params if (this.grid.getBoundingClientRect().width === 0) return; if (this.readParamsFromURL) { const url = new URL(window.location.href); const searchParams = new URLSearchParams(url.search); const [sortKey, orderKey, pageKey, sizeKey] = this.defaultFilterKeys; const sort = searchParams.get(sortKey); const order = searchParams.get(orderKey); const page = searchParams.get(pageKey); const size = searchParams.get(sizeKey); if (sort && order) { this.sortedColumn = sort; this.sortDirection = order; } if (page) { this.page = parseInt(page, 10) || this.defaultPage; } if (size) { // update pageSize with url param only on load or refresh // otherwise use session storage value if ( !this.getSessionStorageData() || (!sessionStorage.getItem(`urlPageSizeRead`) && this.grid.getBoundingClientRect().width > 0) ) { sessionStorage.setItem(`urlPageSizeRead`, true.toString()); this.pageSize = parseInt(size, 10) || this.defaultPageSize; this.updateSessionStorage({ pageSize: this.pageSize }); } } if (rebuildFiltersFromUri) { this.rebuildFiltersFromUri(searchParams); } } } private rebuildFiltersFromUri(searchParams: URLSearchParams): void { const filters: Filter[] = []; for (const [key, value] of searchParams.entries()) { const isDefaultKey = this.defaultFilterKeys.includes(key); const [columnField, operatorValue] = key.split('_'); if (!isDefaultKey && columnField && operatorValue) { filters.push({ columnField, operatorValue, value, }); } } this.filters = filters; } private dispatchChangeEvent = () => { const filters = this.filters.reduce( (columnFilters: { [key: string]: string }, { columnField, value }) => ({ ...columnFilters, [columnField]: value, }), {} ); this.dispatchEvent( new CustomEvent('change', { detail: { columnName: this.sortedColumn, sortOrder: this.sortDirection, page: this.page, pageSize: this.pageSize, filters, filtersOperators: this.filters.map( (f: Filter): FieldOperator => ({ columnField: f.columnField, operator: f.operatorValue, }) ), }, bubbles: true, composed: true, }) ); }; update(changedProperties: Map) { if (!this.initialised && this.columns.length > 0) { this.displayColumns = [...this.columns]; this.checkLocalStorageUpdate(); this.initialised = true; } if ( changedProperties.has('sessionStorageData') && this.sessionStorageData ) { const newPageSize = this.sessionStorageData.pageSize || this.defaultPageSize; if (this.pageSize !== newPageSize) { this.pageSize = newPageSize; } } super.update(changedProperties); } firstUpdated() { this.updateSearchParamsFromUri(); this.removeOldLocalStorageValues(); this.sessionStorageData = this.getSessionStorageData(); } private getSessionStorageData() { if (this.sessionStorageKey) { const sessionData = sessionStorage.getItem( `grid-${this.sessionStorageKey}` ); if (sessionData) { return JSON.parse(sessionData); } } return undefined; } private updateSessionStorage(data: SessionStorageData) { const sessionData = this.getSessionStorageData() || {}; if (this.sessionStorageKey && data) { const updatedData = { ...sessionData, ...data, }; sessionStorage.setItem( `grid-${this.sessionStorageKey}`, JSON.stringify(updatedData) ); this.sessionStorageData = updatedData; } } private checkLocalStorageUpdate(): void { if (this.isPersistable) { const preservedColumns = JSON.parse( localStorage.getItem(this.columnsLocalStorageKey) || '[]' ); if (preservedColumns.length > 0) { let updateStorage = false; // Scenarios where we should update appData with the latest display columns data if (preservedColumns.length !== this.columns.length) updateStorage = true; const allColumnNamesFound = this.columns.every(column => preservedColumns.some(pc => pc.name === column.name) ) && preservedColumns.every(pc => this.columns.some(column => column.name === pc.name) ); if (!allColumnNamesFound) updateStorage = true; if (updateStorage) { this.setColumnsToLocalStorage(this.columns); } } } } buildQueryFromFilters() { const params = new URLSearchParams(); this.filters.forEach((f: Filter) => { params.append(`${f.columnField}_${f.operatorValue}`, f.value); }); return Object.fromEntries(params); } rebuildQueryFromMatchingQuerystringParams(): Record { const params = new URLSearchParams(); if (this.preservedQueryParamKeys.length === 0) return {}; const url = new URL(window.location.href); const originalSearchParams = new URLSearchParams(url.search); originalSearchParams.forEach((value, key) => { if (this.preservedQueryParamKeys.includes(key)) { params.append(key, value); } }); return Object.fromEntries(params); } private getColumnsToDisplayFromLocalStorage(): Column[] { let columnsToDisplay: Column[] = []; if (this.isPersistable) { const preservedColumns = JSON.parse( localStorage.getItem(this.columnsLocalStorageKey) || '[]' ); if (preservedColumns.length > 0) { columnsToDisplay = this.mapColumnsWithPersistedSettings(preservedColumns); } } return columnsToDisplay; } private mapColumnsWithPersistedSettings( preservedColumns: Column[] ): Column[] { const preservedMap = new Map(preservedColumns.map(col => [col.name, col])); const mappedColumns = this.columns.map(configuredColumn => { const preservedColumn = preservedMap.get(configuredColumn.name); if (!preservedColumn) { return { ...configuredColumn }; } return { ...configuredColumn, hidden: preservedColumn.hidden, frozenToEnd: preservedColumn.frozenToEnd, width: preservedColumn.width || undefined, }; }); mappedColumns.sort((a, b) => { const indexA = preservedColumns.findIndex(col => col.name === a.name); const indexB = preservedColumns.findIndex(col => col.name === b.name); if (indexA === -1 && indexB === -1) return 0; if (indexA === -1) return 1; if (indexB === -1) return -1; return indexA - indexB; }); return mappedColumns; } private removeOldLocalStorageValues() { const oldKeys = this.findMatchingLocalStorageKeys( `ix-grid-${this.localStorageID}-`, '-columns', this.hashedTableState ); for (let i = 0; i <= oldKeys.length; i += 1) { localStorage.removeItem(oldKeys[i]); } } private findMatchingLocalStorageKeys( prefix: string, suffix: string, currentTableStateHash: string ): string[] { const matchingKeys: string[] = []; const currentKey = prefix + currentTableStateHash + suffix; for (let i = 0; i < localStorage.length; i += 1) { const key = localStorage.key(i); if ( key && key.startsWith(prefix) && key.endsWith(suffix) && key !== currentKey ) { matchingKeys.push(key); } } return matchingKeys; } private async updatePage(refreshUrlParams = true) { this.dispatchChangeEvent(); if (this.addParamsToURL && refreshUrlParams) { const urlParams: { [key: string]: string } = { sort: this.sortedColumn, order: this.sortDirection, page: this.page.toString(), size: this.pageSize.toString(), ...this.buildQueryFromFilters(), ...this.rebuildQueryFromMatchingQuerystringParams(), }; const url = new URL(window.location.href); const gridSearchParams = new URLSearchParams(urlParams); if (!this.originalSearchParams) { this.saveOriginalSearchParams(gridSearchParams); } const combinedParams = new URLSearchParams([ ...(this.originalSearchParams ?? []), ...gridSearchParams, ]); url.search = combinedParams.toString(); window.history.pushState(null, '', url.toString()); } } saveOriginalSearchParams(gridSearchParams: URLSearchParams) { const url = new URL(window.location.href); const originalSearchParams = new URLSearchParams(url.search); this.filters.forEach((f: Filter) => { originalSearchParams.delete(`${f.columnField}_${f.operatorValue}`); }); gridSearchParams.forEach((value, key) => { originalSearchParams.delete(key); }); this.originalSearchParams = originalSearchParams; } handleSort(column: string = '') { if (this.sortedColumn !== column) { this.sortDirection = 'asc'; } else { this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc'; } this.sortedColumn = column; this.updatePage(); } private renderColumnHeader = ( column: Column, nextColumn: Column, index: number, length: number ) => { const headerClasses = classMap({ header: true, frozen: !!column.frozenToEnd, first: index === 0, last: index === length - 1, border: !nextColumn?.frozenToEnd, }); return html`
column.sortable && this.handleSort(column.name)} @keyDown=${() => column.sortable && this.handleSort(column.name)} class=${headerClasses} > ${column.header} ${column.sortable ? html`${this.sortDirection === 'desc' && this.sortedColumn === column.name ? `arrow_upward` : `arrow_downward`}` : nothing}
`; }; setColumnsToLocalStorage(columns: Column[]) { if (this.isPersistable) { localStorage.setItem( this.columnsLocalStorageKey, JSON.stringify(columns) ); } } async reorderColumnsFromTable() { const columns = [...this.arrangedColumns]; const visibleColumns = columns.filter( (column: Column) => column.hidden !== true ); const hiddenColumns = columns.filter( (column: Column) => column.hidden === true ); const frozenColumns = columns.filter( (column: Column) => column.frozenToEnd === true ); const allColumns = [ ...visibleColumns.filter(column => column?.frozenToEnd !== true), ...hiddenColumns.filter(column => column?.frozenToEnd !== true), ...frozenColumns, ]; // calulate column order from table header flex order const headerNodes = Array.from( this.grid?.shadowRoot?.querySelectorAll('th') || [] ); if (headerNodes.length) { const columnOrder = headerNodes .map((el, id) => ({ id, flexPosition: Number(el.style.order) })) .sort((a, b) => a.flexPosition - b.flexPosition) .map(el => el.id); const columnsCorrectlyOrdered = columnOrder.every( (x, i) => i === 0 || x > columnOrder[i - 1] ); let reorderedColumns: Column[] = []; if (!columnsCorrectlyOrdered) { reorderedColumns = columnOrder.map(id => allColumns[id]); this.displayColumns = [ ...reorderedColumns.filter( column => column.hidden !== true && column?.frozenToEnd !== true ), ...hiddenColumns.filter(column => column?.frozenToEnd !== true), ...frozenColumns, ]; this.isColumnsReordering = true; await this.updateComplete; this.isColumnsReordering = false; this.setColumnsToLocalStorage(this.displayColumns); } } } async reorderColumnsFromFilter(e: CustomEvent) { this.displayColumns = [...e.detail.reorderedColumns]; this.setColumnsToLocalStorage([...this.displayColumns]); this.isColumnsReordering = true; await this.updateComplete; this.isColumnsReordering = false; } handleOnColumnFilter(e: CustomEvent) { e.detail.columns.forEach((column: Column, id: number) => { if (!this.displayColumns[id]) return; this.displayColumns[id].hidden = column?.hidden; }); this.displayColumns = [...this.displayColumns]; this.updatePage(false); } cellPartNameGenerator(_column: Column, model: { item: Row }): string { let parts = ''; if (model.item.disabled) { parts += ' ix-disabled-cell'; } return parts; } private columnRenderer = ( column: Column, root: HTMLElement, columnElement: GridColumn, model: any ) => { /* Due to a quirk of vaadin-grid, in order for the column cells to react to changes to bodyRenderer output, we must clear the contents of the cell before rendering the new content. Otherwise the new content will be appended to the existing content. */ render(nothing, root); const templateResult = column.bodyRenderer( model.item, model, columnElement ); let styledWrapper = templateResult; const shouldApplyMaxWidth = !!column.maxWidth; const maxWidthSetClass = 'column-max-width-set'; if (shouldApplyMaxWidth) { styledWrapper = html`
${templateResult}
`; } render(styledWrapper, root); if (shouldApplyMaxWidth) { requestAnimationFrame(() => { const el = root.querySelector(`.${maxWidthSetClass}`) as HTMLElement; if (el && el.scrollWidth > el.clientWidth) { if (!el.querySelector('.custom-tooltip') && el.textContent?.trim()) { const tooltip = document.createElement('div'); tooltip.className = 'custom-tooltip'; tooltip.style.cssText = ` background-color: #092241; font-size: 0.75rem; color: white; text-align: left; padding: 0.313rem 0.5rem; border-radius: 0.188rem; max-height: 31.25rem; overflow: hidden; position: absolute; z-index: 1000; display: none; `; tooltip.textContent = el.textContent; el.addEventListener('mouseenter', () => { tooltip.style.display = 'flex'; const rect = el.getBoundingClientRect(); tooltip.style.left = `${rect.left}px`; tooltip.style.top = `${rect.bottom + window.scrollY + 4}px`; document.body.appendChild(tooltip); }); el.addEventListener('mouseleave', () => { tooltip.remove(); }); } } }); } }; private renderHeader = () => html`
${this.hideFilters ? nothing : html`
this.handleOnColumnFilter(e)} @reorderColumns=${this.reorderColumnsFromFilter} .columnReorderingAllowed=${this.columnReorderingAllowed} .refreshDataOnColumnVisibilityChange=${this .refreshDataOnColumnVisibilityChange} .requestGridUpdate=${() => this.requestUpdate()} > ${this.showDownload ? html`` : nothing} { this.filters = e.detail.filters; if (e.detail.resetPage) { this.page = this.defaultPage; } this.updatePage(); }} >
`}
`; renderAddNewButton() { if (!this.showAddButton) return nothing; return html` this.onAddButtonClick()} > add ${this.addButtonLabel} `; } renderViewMore() { if (!this.showViewMore) return nothing; return html`
`; } get showViewMoreLessButton() { return ( !this.showViewMore && this.rowLimit > 0 && this.rows.length > this.rowLimit ); } renderViewMoreLessButton() { if (!this.showViewMoreLessButton) return nothing; return html` { this.isExpanded = !this.isExpanded; }} has-icon > ${this.isExpanded ? copy.viewLess : copy.viewMore} ${this.hideViewMoreLessButtonIcon ? nothing : html`${this.isExpanded ? 'remove' : 'add'}`} `; } renderRemoveAllButton() { if (!this.showRemoveAllButton) return nothing; return html` this.onRemoveAllButtonClick()} > ${this.removeAllButtonLabel} `; } private renderRowControls = () => { if ( this.showAddButton === false && this.showRemoveAllButton === false && this.showViewMoreLessButton === false ) { return nothing; } return html`
${this.renderAddNewButton()} ${this.renderViewMoreLessButton()} ${this.renderRemoveAllButton()}
`; }; private renderPaginationControls = () => { if (this.rowLimit > 0) { return nothing; } return html` { this.page = e.detail.page; this.pageSize = e.detail.pageSize; this.updateSessionStorage({ pageSize: this.pageSize, }); this.updatePage(); }} > `; }; renderColumns() { const arrangedColumnsInstance = [...this.arrangedColumns]; if (arrangedColumnsInstance.length > 0) { return html`${arrangedColumnsInstance.map( (column: Column, id: number) => { if (column.hidden === true) return nothing; return html` this.renderColumnHeader( column, arrangedColumnsInstance[id + 1], id, arrangedColumnsInstance.length ), this.sortDirection )} .renderer=${( root: HTMLElement, columnElement: GridColumn, model: any ) => this.columnRenderer(column, root, columnElement, model)} resizable width=${ifDefined(column.width)} min-width=${ifDefined(column.minWidth)} .responsive=${column.responsive} ?hidden=${column.hidden} ?frozen-to-end=${column.frozenToEnd} path=${column.name} ?auto-width=${column.autoWidth} flex-grow=${ifDefined(column.flexGrow)} >`; } )}`; } return html``; } renderLoading() { return html`
`; } renderGrid() { if (this.isColumnsReordering) { return nothing; } const columnDisplayed = this.displayColumns.find( (column: Column) => column.hidden !== true ); const displayRows = this.rowLimit > 0 && !this.isExpanded ? this.rows.slice(0, this.rowLimit) : this.rows; return html` ${this.renderColumns()}
`; } render() { return html`
${this.hideHeader ? nothing : this.renderHeader()} ${this.renderLoading()} ${this.renderGrid()} ${this.renderRowControls()}
${this.renderViewMore()} ${this.renderPaginationControls()}
`; } }