/* eslint-disable style/max-statements-per-line */ /* eslint-disable no-new */ /* eslint-disable ts/no-this-alias */ import type { IWebApiClient, WebClientApiMethod } from '../../common/IWebClient'; import type { DropdownButtonItemArgs } from '../dropdown-button/dropdown-button-item'; import type { DaterangeChangedArgs } from '../input/daterange-picker'; import type { ColFilterModalShowResponse } from './filter-modal'; import Mark from 'mark.js'; import Sortable from 'sortablejs'; import { Prop, toNative } from 'vue-facing-decorator'; import { globalState } from '../../app/global-state'; import PowerduckState from '../../app/powerduck-state'; import TsxComponent, { Component } from '../../app/vuetsx'; import { DialogResult, DialogUtils } from '../../common/dialog-utils'; import { DialogIcons } from '../../common/enums/dialog-icons'; import { sortBy } from '../../common/extensions/array-extensions'; import { capitalize, latinize } from '../../common/extensions/string-extensions'; import StorageProvider from '../../common/local-storage-shim'; import { QueryStringUtils } from '../../common/query-string-utils'; import CheckboxUtils from '../../common/utils/checkbox-utils'; import DropdownUtils from '../../common/utils/dropdown-utils'; import { isNullOrEmpty } from '../../common/utils/is-null-or-empty'; import TemporalUtils from '../../common/utils/temporal-utils'; import { PortalUtils } from '../../common/utils/utils'; import Button from '../button/button'; import { ButtonLayout } from '../button/button-layout'; import DropdownList, { MultiSelectExclusivity, MultiselectMode } from '../dropdown'; import DropdownButton from '../dropdown-button/dropdown-button'; import DropdownButtonItem from '../dropdown-button/dropdown-button-item'; import CheckBox, { CheckBoxSkin } from '../input/checkbox'; import DaterangePicker from '../input/daterange-picker'; import LoadingIndicator from '../loading-indicator'; import Teleport from '../teleport/teleport'; import NotificationProvider from '../ui/notification'; import TimezoneHelper from './../../common/timezone-helper'; import TextBox from './../input/textbox'; import ColVisModal from './col-vis-modal'; import TableExportModal from './export-excel-modal'; import ColFilterModal from './filter-modal'; import loadingGif from './img/loading_16_16.gif'; import { DataTableConfig } from './ts/datatable-config'; import ReorderProvider from './ts/reorder'; import './css/datatable.css'; interface DataTableArgs { id: string; apiClient: IWebApiClient; apiMethod: WebClientApiMethod; allowMassOperations?: boolean; allowExport?: boolean; autoFetch?: boolean; apiArgs?: any; exportConfig?: DataTableExportConfig; paginations?: number[]; paginationLength?: number; autoFilter?: boolean; fullSizeTable?: boolean; insetTop?: boolean; fullSizeHasButtonBelow?: boolean; filterMode?: DataTableFilterMode; checkboxesVisible?: boolean; checkboxButtonsVisible?: boolean; topVisible?: boolean; bottomVisible?: boolean; cssClass?: string; columns: TableColumn[]; buttons?: TableButton[]; rowIndexMode?: RowIndexMode; fulltextPlaceholder?: string; skin?: DataTableSkin; preserveOrderBy?: boolean; preserveFilter?: boolean; sortableRows?: boolean; customAjaxCall?: (args: DataTablePostBackData) => Promise; parseLoadedRowset?: (serverResp: any) => DataTableLoadedRowset; rowClicked?: (row: any) => void; rowCssClass?: (row: any) => string; rowCheckstateChanged?: (row: any, checked: boolean, selectedRows: any[]) => void; /** * Fired after the built-in mass-operations Cancel button runs its baseline * reset (`clearSelection()` + `toggleCheckboxes(false)`). Use it to scrub * page-level state the table doesn't own — e.g. a derived selection array * or a bulk-warning alert. Not invoked when checkboxes are toggled off * through other paths, only by the built-in Cancel button. */ massOperationsCancelClicked?: () => void; mobileBehavior?: DataTableMobileBehavior; mobileModeCustomRender?: (h, columns: TableColumn[], rows: any) => void; sortComplete?: (args: DataTableOnSortedArgs) => void; mobileModeRowIcon?: string; mobileModeShouldAutoCollapse?: boolean; massOperationOptions?: MassOperationItem[]; handleInitialFilter?: boolean; timezoneGmtOffset?: number; timezoneDstOffset?: number; timeFilterShiftTimezone?: boolean; hidePagination?: boolean; } export interface DataTableExportConfig { attrName: string; limitName?: string; pageName?: string; } export interface DataTablePostBackData { ShowHidden: boolean; PaginationPosition: number; PaginationLength: number; SessionId: number; Filter: DataTableFilterDefinition; Sort: DataTableSortDefinition; } export interface MassOperationItem extends DropdownButtonItemArgs { } interface DataTableFilterDefinition { FullText: string; FilterItems: DataTablePostBackFilterItem[]; } export interface DataTableOnSortedArgs { oldIndex: number; newIndex: number; } export interface DataTablePostBackFilterItem { PropertyName: string; ContainsValue?: string; EqualsValue?: string; FilterType: DataTableFilterItemType; NumFrom?: number; NumTo?: number; DateFrom?: Temporal.PlainDateTime; DateTo?: Temporal.PlainDateTime; ValueArr?: string[]; ValueArrStrategy?: MultiSelectExclusivity; } interface DataTableSortDefinition { PropertyName: string; Direction: DataTableSortDirection; } export enum DataTableFilterMode { Serverside = 0, Clientside = 1, } export enum DataTableFilterItemType { None = 0, Text = 1, Dropdown = 2, DateRange = 3, NumericRange = 4, DateRangeWithTime = 5, } export enum DataTableSkin { Default = 'default', Compact = 'compact', } export enum DataTableMobileBehavior { MobileLayout = 'mobile', Compact = 'compact', VerticalTransform = 'vertical', // Do not use, shitty } export enum RowIndexMode { Identifier = 0, Index = 1, } export interface DataTableLoadedRowset { totalCount: number; totalFilteredCount: number; rows: any[]; /** * Count-free mode: the server skipped the (expensive) total count and * signals whether a next page exists instead. Paired with * `totalCount: -1`, it switches the table to simple prev/next pagination. */ hasMore?: boolean; } interface StoredState { sortOrder: string[]; filter: DataTablePostBackFilterItem[]; hiddenColumns: string[]; visibleColumns: string[]; paginationLength: number; orderBy: SortDefinition; skin: DataTableSkin; mobileBehavior: DataTableMobileBehavior; } class RowIdentifier { name: string = null; surname: string = null; dtColumns: TableColumn[] = null; constructor(name: string, surname: string) { this.name = name; this.surname = surname; } get fullName(): string { return (`${this.name || ''} ${this.surname || ''}`).trim(); } } export enum DataTableSortDirection { Ascending = 0, Descending = 1, } interface SortDefinition { columnId: string; direction: DataTableSortDirection; } const enum DataTableBreakpoint { Desktop = 'desktop', Mobile = 'mobile', } /** * Page sizes offered when a table does not pass its own `paginations` list. * The `-1` entry renders as "All" and asks the API for every row. */ const DEFAULT_PAGINATIONS = [ 10, 25, 50, 100, 250, 500, 1000, -1, ]; class StorageHelper { private static getStorageKey(id: string) { return `bldt-${(globalState.location?.pathname || '').split('/').join('_')}${id}`; } static getStoredState(id: string): StoredState { const retVal = StorageProvider.getString(StorageHelper.getStorageKey(id)); if (retVal != null) { try { return JSON.parse(retVal); } catch (e) { } } return {} as any; } static saveStoredState(id: string, state: StoredState): void { StorageProvider.setString(StorageHelper.getStorageKey(id), JSON.stringify(state)); } static storeSortOrder(id: string, sortOrder: string[]) { const state = StorageHelper.getStoredState(id); if (sortOrder != null) { state.sortOrder = sortOrder; } else if (state.sortOrder != null) { delete state.sortOrder; } StorageHelper.saveStoredState(id, state); } static storeOrderBy(id: string, orderBy: SortDefinition) { const state = StorageHelper.getStoredState(id); if (orderBy != null) { state.orderBy = orderBy; } else if (state.orderBy != null) { delete state.orderBy; } StorageHelper.saveStoredState(id, state); } static storeColVis(id: string, columns: TableColumn[]) { const state = StorageHelper.getStoredState(id); state.hiddenColumns = columns.filter(p => p.visible == false).map(p => p.id); state.visibleColumns = columns.filter(p => (p as any)._enforceVisible == true).map(p => p.id); StorageHelper.saveStoredState(id, state); } static storePaginationLength(id: string, paginationLength: number) { const state = StorageHelper.getStoredState(id); state.paginationLength = paginationLength; StorageHelper.saveStoredState(id, state); } static storeSkin(id: string, skin: DataTableSkin) { const state = StorageHelper.getStoredState(id); state.skin = skin; StorageHelper.saveStoredState(id, state); } static storeMobileBehavior(id: string, mobileBehavior: DataTableMobileBehavior) { const state = StorageHelper.getStoredState(id); state.mobileBehavior = mobileBehavior; StorageHelper.saveStoredState(id, state); } static storeFilter(id: string, filter: DataTablePostBackFilterItem) { const state = StorageHelper.getStoredState(id); if (filter != null) { if (state.filter == null || state.filter.length == 0) { state.filter = [filter]; } else { if (state.filter.some(p => p.PropertyName.includes(filter.PropertyName))) { state.filter = state.filter.filter(p => p.PropertyName != filter.PropertyName); } state.filter.push(filter); } } else if (state.filter != null) { delete state.filter; } StorageHelper.saveStoredState(id, state); } } export interface TableColumn { id: string; caption: string; cssClass?: string; visible?: boolean; sortable?: boolean; searchable?: boolean; filterType?: DataTableFilterItemType; filterItems?: DataTableFilterItemCollection; filterAllowExclusivity?: boolean; customFilterValue?: (row) => string; mobileOrder?: number; mobileRender?: (h, row) => void; mobileCaption?: boolean; mobileVisible?: boolean; mobileShouldRenderRow?: (row) => boolean; exportInclude?: boolean; exportValue?: (row) => any; customValue?: (row) => string; customRender?: (h, row) => void; clientsideFilter?: (row: any, filterDefinition: DataTablePostBackFilterItem) => boolean; } export interface TableButton { title: string; icon: string; clicked: () => void; childItems?: TableButtonChildItem[]; customRender?: (h, row) => void; } export interface TableButtonChildItem extends TableButton { isSelectable?: boolean; isSelected?: boolean; } export interface DataTableFilterItem { id: string; text: string; } export class DataTableFilterItemCollection extends Array { allowExclusiveSearch: boolean = false; constructor(allowExclusive: boolean, items: DataTableFilterItem[]) { super(); this.allowExclusiveSearch = allowExclusive; (items || []).forEach((item) => { this.push(item); }); } } @Component class TableButtonComponent extends TsxComponent implements TableButton { @Prop() title!: string; @Prop() icon!: string; @Prop() cssClass!: string; @Prop() clicked: () => void; @Prop() customRender: (h, row) => void; @Prop() childItems!: TableButtonChildItem[]; randomUUID: string = `ddl-${PortalUtils.randomString(10)}`; render(h) { if (isNullOrEmpty(this.childItems)) { return ( ); } else { return ( ); } } } @Component class DataTableComponent extends TsxComponent implements DataTableArgs { @Prop() id!: string; @Prop() apiClient!: IWebApiClient; @Prop() apiMethod!: WebClientApiMethod; @Prop() allowMassOperations!: boolean; @Prop() allowExport!: boolean; @Prop() apiArgs!: any; @Prop() exportConfig?: DataTableExportConfig; @Prop() paginations?: number[]; @Prop() paginationLengthProp!: number; @Prop() cssClass!: string; @Prop() autoFilter!: boolean; @Prop() insetTop!: boolean; @Prop() topVisible!: boolean; @Prop() bottomVisible!: boolean; @Prop() skin!: DataTableSkin; @Prop() preserveOrderBy!: boolean; @Prop() preserveFilter!: boolean; @Prop() sortableRows!: boolean; @Prop() filterMode!: DataTableFilterMode; @Prop() fullSizeTable!: boolean; @Prop() fullSizeHasButtonBelow!: boolean; @Prop() mobileBehavior!: DataTableMobileBehavior; @Prop() mobileModeCustomRender!: (h, columns: TableColumn[], rows: any) => void; @Prop() mobileModeRowIcon!: string; @Prop() mobileModeShouldAutoCollapse!: boolean; @Prop() handleInitialFilter!: boolean; @Prop() massOperationOptions!: MassOperationItem[]; @Prop() checkboxesVisible!: boolean; @Prop() checkboxesTitle!: string; @Prop() checkboxButtonsVisible!: boolean; @Prop() rowIndexMode!: RowIndexMode; @Prop() fulltextPlaceholder!: string; @Prop() timezoneGmtOffset!: number; @Prop() timezoneDstOffset!: number; @Prop() timeFilterShiftTimezone!: boolean; @Prop() columns!: TableColumn[]; @Prop() buttons!: TableButton[]; @Prop() rowClicked: (row: any) => void; @Prop() rowCssClass: (row: any) => string; @Prop() rowCheckstateChanged: (row: any, checked: boolean, selectedRows: any[]) => void; @Prop() massOperationsCancelClicked?: () => void; @Prop() parseLoadedRowset?: (serverResp: any) => DataTableLoadedRowset; @Prop() customAjaxCall?: (args: DataTablePostBackData) => Promise; @Prop() sortComplete?: (args: DataTableOnSortedArgs) => void; @Prop() autoFetch!: boolean; @Prop() hidePagination!: boolean; paginationLength: number = this.paginationLengthProp ?? 50; paginationPosition: number = 1; isLoading: boolean = false; initialized: boolean = false; initDataLoaded: boolean = false; currentMobileBehavior: DataTableMobileBehavior = null; currentSkin: DataTableSkin = null; currentAdvancedFilterState: any = {}; fullTextQuery: string; rows: any[] = []; loadedRows: any[]; tableFilterTimeout: any = null; filterArr: DataTablePostBackFilterItem[] = []; sessionId: number = null; totalCount: number = 0; totalFilteredCount: number = 0; hasMore: boolean = false; colSortOrder: string[] = null; enforceHeaderRedraw: boolean = false; enforceBodyRedraw: boolean = false; checkboxesShown: boolean = false; sortDefinition: SortDefinition = null; activeBreakPoint: DataTableBreakpoint = null; markInstance: any = null; mounted(): void { this.checkboxesShown = this.checkboxesVisible == true; this.currentSkin = this.skin; this.currentMobileBehavior = this.mobileBehavior; this.performColumnRefresh(); this.handleWindowResized(); globalState.addEventListener( 'resize', this.handleWindowResized, true, ); if (DataTableConfig.filterMarking) { this.markInstance = new Mark(this.$el); } if (this.handleInitialFilter) { this.parseInitialFilter(); } if (this.fullSizeTable) { const htmlElem = document.documentElement; htmlElem.classList.add('has-dt-fullsize'); if (PortalUtils.treatAsMobileDevice()) { htmlElem.classList.add('has-dt-fullsize-mobile'); } if (PortalUtils.isIOS()) { htmlElem.classList.add('has-dt-fullsize-ios'); } } if (this.sortableRows == true) { this.initRowSortable(); } if (this.preserveFilter == true) { this.initRowFilters(); } if (this.autoFetch != false) { setTimeout(async () => { this.loadInitialData(); }, 300); } else { this.initialized = true; } } updated(): void { if (this.autoFetch != false) { this.loadInitialData(); } else { this.initialized = true; } } beforeUnmount(): void { if (this.fullSizeTable) { this.removeFullsizeModeLayoutCssClass(); document.documentElement.classList.remove( 'has-dt-fullsize', 'has-dt-fullsize-mobile', 'has-dt-fullsize-ios', ); } globalState.removeEventListener( 'resize', this.handleWindowResized, true, ); } loadInitialData(): void { if (this.apiArgs == null) { return; } if (!this.initialized && !this.initDataLoaded) { this.initDataLoaded = true; this.reloadDataPromise().then(() => { setTimeout(() => { this.initialized = true; }, 1); }); } } parseInitialFilter(): void { const self = this; const filterBy = QueryStringUtils.getString('filterBy'); const orderBy = QueryStringUtils.getString('orderBy'); const getColumn = function (id: string) { let column = self.columns.filter(p => p.id == id)[0]; if (column == null && id.toLowerCase() == 'name') { column = self.columns.filter(p => (p as any).customField != null && (p as any).customField.MappingType == 1)[0]; } if (column == null && id.toLowerCase() == 'surname') { column = self.columns.filter(p => (p as any).customField != null && (p as any).customField.MappingType == 2)[0]; } return column; }; const getParamArr = function (val: string): Array> { let arrOfArr: Array>; try { arrOfArr = JSON.parse(val); } catch (e) { } if (arrOfArr == null) { return null; } if (arrOfArr.length && arrOfArr.splice) { if (PortalUtils.isString(arrOfArr[0])) { arrOfArr = [arrOfArr as any]; } return arrOfArr; } return null; }; if (!isNullOrEmpty(filterBy)) { const arrOfArr = getParamArr(filterBy); if (!isNullOrEmpty(arrOfArr)) { arrOfArr.forEach((pair) => { const column = getColumn(pair[0]); if (column != null) { if (column.filterType == null || column.filterType == DataTableFilterItemType.Text) { this.addFilterItem(column, { PropertyName: column.id, ContainsValue: pair[1], FilterType: DataTableFilterItemType.Text, }); } else if (column.filterType == DataTableFilterItemType.Dropdown) { this.addFilterItem(column, { PropertyName: column.id, ValueArr: [pair[1]], ValueArrStrategy: MultiSelectExclusivity.Exclusive, FilterType: DataTableFilterItemType.Dropdown, }); } } }); } } if (!isNullOrEmpty(orderBy)) { const arrOfArr = getParamArr(orderBy); if (!isNullOrEmpty(arrOfArr)) { arrOfArr.forEach((pair) => { const column = getColumn(pair[0]); if (column != null) { this.sortDefinition = { columnId: column.id, direction: pair[1] == 'desc' ? DataTableSortDirection.Descending : DataTableSortDirection.Ascending, }; } }); } } } async ensureMassPaginationConsent(): Promise { const selectedCount = this.getSelectedRows().length; const paginationLength = this.getPaginationLength(); if (paginationLength < 0) { return DialogResult.Confirm; } if (this.isCountlessMode()) { // Count-free mode: no grand total; a single page with no further rows needs no consent. if (!this.hasMore && this.paginationPosition <= 1) { return DialogResult.Confirm; } } else if (this.totalCount < paginationLength) { return DialogResult.Confirm; } if (selectedCount < paginationLength - 7) { return DialogResult.Confirm; } const knownRows = (this.paginationPosition - 1) * paginationLength + (this.rows?.length || 0); const totalLabel = this.isCountlessMode() ? `${knownRows}${this.hasMore ? '+' : ''}` : this.totalCount.toString(); const continueLabel = `${PowerduckState.getResourceValue('continueLabel')}  `; const messageHtml = PowerduckState.getResourceValue('dtMassOperationWarningText').replace('{0}', selectedCount.toString()).replace('{1}', totalLabel); const dialogResult = await DialogUtils.showConfirmDialog( PowerduckState.getResourceValue('warning'), messageHtml, continueLabel, PowerduckState.getResourceValue('cancel'), DialogIcons.Warning, ); return dialogResult; } performColumnRefresh(): void { const savedState = StorageHelper.getStoredState(this.id); this.colSortOrder = savedState.sortOrder; this.handleColumnsVisibility(savedState, this.columns); this.paginationLength = savedState.paginationLength; // A stored page size outlives the option list it was picked from. Once a table stops // offering an option — e.g. one that drops the unlimited "All" (-1) — the stored value // would otherwise be restored on every mount, with no matching