UNPKG

@eclipse-scout/core

Version:
1,493 lines (1,332 loc) 222 kB
/* * Copyright (c) 2010, 2025 BSI Business Systems Integration AG * * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0 * which is available at https://www.eclipse.org/legal/epl-2.0/ * * SPDX-License-Identifier: EPL-2.0 */ import { AbstractTableAccessibilityRenderer, Action, AggregateTableControl, Alignment, AppLinkKeyStroke, aria, arrays, BooleanColumn, Cell, CellEditorPopup, clipboard, Column, ColumnModel, CompactColumn, Comparator, ContextMenuKeyStroke, ContextMenuPopup, dataObjects, DefaultTableAccessibilityRenderer, Desktop, DesktopPopupOpenEvent, Device, DisplayViewId, DoubleClickSupport, dragAndDrop, DragAndDropHandler, DropType, EnumObject, ErrorHandler, EventHandler, Filter, Filterable, FilterOrFunction, FilterResult, FilterSupport, FullModelOf, graphics, HierarchicalTableAccessibilityRenderer, HtmlComponent, IconColumn, InitModelOf, Insets, IUserFilterStateDo, keys, KeyStrokeContext, LimitedResultTableStatus, LoadingSupport, Menu, MenuBar, MenuDestinations, MenuItemsOrder, menus as menuUtil, menus, NumberColumn, NumberColumnAggregationFunction, NumberColumnBackgroundEffect, ObjectOrChildModel, ObjectOrModel, objects, Predicate, PropertyChangeEvent, Range, scout, scrollbars, ScrollToOptions, Status, StatusOrModel, strings, styles, TableClientUiPreferenceProfileDo, TableCompactHandler, TableControl, TableCopyKeyStroke, TableCustomizer, TableEventMap, TableFooter, TableHeader, TableLayout, TableModel, TableNavigationCollapseKeyStroke, TableNavigationDownKeyStroke, TableNavigationEndKeyStroke, TableNavigationExpandKeyStroke, TableNavigationHomeKeyStroke, TableNavigationPageDownKeyStroke, TableNavigationPageUpKeyStroke, TableNavigationUpKeyStroke, TableOrganizer, TableRefreshKeyStroke, TableRow, TableRowModel, TableSelectAllKeyStroke, TableSelectionHandler, TableStartCellEditKeyStroke, TableTextUserFilter, TableTileGridMediator, TableToggleRowKeyStroke, TableTooltip, TableUiPreferences, tableUiPreferences, TableUpdateBuffer, TableUserFilter, TableUserFilterModel, Tile, TileTableHeaderBox, tooltips, TooltipSupport, UiPreferences, UpdateFilteredElementsOptions, UserFilterStateMappers, ValueField, Widget } from '../index'; import $ from 'jquery'; export class Table extends Widget implements TableModel, Filterable<TableRow> { declare model: TableModel; declare eventMap: TableEventMap; declare self: Table; declare columnMap: ColumnMap; autoResizeColumns: boolean; columnAddable: boolean; columnLayoutDirty: boolean; columns: Column<any>[]; contextColumn: Column<any>; checkable: boolean; displayViewId: DisplayViewId; // set by DesktopBench checkableStyle: TableCheckableStyle; cellEditorPopup: CellEditorPopup<any>; compact: boolean; openFieldPopupOnCellEdit: boolean; compactHandler: TableCompactHandler; compactColumn: CompactColumn; dropType: DropType; dropMaximumSize: number; dragAndDropHandler: DragAndDropHandler; groupingStyle: TableGroupingStyle; header: TableHeader; tableStatus: Status; rowBorders: Insets; headerEnabled: boolean; headerVisible: boolean; headerMenusEnabled: boolean; hasReloadHandler: boolean; /** * Defines whether hierarchical mode is active, meaning the table is grouping rows with the same {@link TableRow.parentRow} and allowing the user to expand and collapse these groups. * * The property returns true if there is at least one row with a parent row, false otherwise. */ hierarchical: boolean; hierarchicalStyle: TableHierarchicalStyle; keyStrokes: Action[]; menus: Menu[]; menuBar: MenuBar; menuBarVisible: boolean; contextMenu: ContextMenuPopup; multiCheck: boolean; multiSelect: boolean; multilineText: boolean; scrollToSelection: boolean; selectedRows: TableRow[]; sortEnabled: boolean; tableControls: TableControl[]; tableStatusVisible: boolean; tableTileGridMediator: TableTileGridMediator; tileMode: boolean; tileTableHeader: TileTableHeaderBox; tileProducer: (row: TableRow) => Tile; footer: TableFooter; footerVisible: boolean; filters: Filter<TableRow>[]; /** * Contains all rows of the table. */ rows: TableRow[]; /** * Contains only the root rows of the table. * If the table is not {@link hierarchical}, it is the same as {@link rows}. */ rootRows: TableRow[]; /** * Contains only the rows that are expanded and accepted by all filters. */ visibleRows: TableRow[]; estimatedRowCount: number; maxRowCount: number; aggregateRowHeight: number; truncatedCellTooltipEnabled: boolean; checkableColumn: BooleanColumn; rowIconColumn: IconColumn; uiCssClass: string; /** visible rows by id */ visibleRowsMap: Record<string, TableRow>; rowLevelPadding: number; /** rows by id */ rowsMap: Record<string, TableRow>; rowHeight: number; rowWidth: number; /** read-only, set by _calculateRowInsets(), also used in TableHeader.js */ rowInsets: Insets; /** read-only, set by _calculateRowInsets(), also used in TableLayout.js */ rowMargins: Insets; rowIconVisible: boolean; rowIconColumnWidth: number; staticMenus: Menu[]; selectionHandler: TableSelectionHandler; tooltips: TableTooltip[]; tableNodeColumn: Column<any>; updateBuffer: TableUpdateBuffer; /** * Initial value must be > 0 to make prefSize work (if it is 0, no filler will be generated). * If rows have a variable height, prefSize is only correct for 10 rows. * Layout will adjust this value depending on the view port size. */ viewRangeSize: number; viewRangeDirty: boolean; viewRangeRendered: Range; virtual: boolean; textFilterEnabled: boolean; filterSupport: FilterSupport<TableRow>; filteredElementsDirty: boolean; defaultMenuTypes: string[]; accessibilityRenderer: AbstractTableAccessibilityRenderer; organizer: TableOrganizer; customizer: TableCustomizer; defaultRowAction: Action; userPreferenceContext: string; uiPreferencesEnabled: boolean; $data: JQuery; $emptyData: JQuery; $fillBefore: JQuery; $fillAfter: JQuery; /** @internal */ _renderViewportBlocked: boolean; /** @internal */ _filterMenusHandler: (menuItems: Menu[], destination: MenuDestinations) => Menu[]; /** @internal */ _aggregateRows: AggregateTableRow[]; protected _filteredRows: TableRow[]; protected _maxLevel: number; protected _animationRowLimit: number; protected _blockLoadThreshold: number; protected _doubleClickSupport: DoubleClickSupport; protected _permanentHeadSortColumns: Column<any>[]; protected _permanentTailSortColumns: Column<any>[]; protected _popupOpenHandler: EventHandler<DesktopPopupOpenEvent>; protected _rerenderViewPortAfterAttach: boolean; protected _renderViewPortAfterAttach: boolean; protected _triggerRowsSelectedPending: boolean; protected _animateAggregateRows: boolean; protected _postAttachActions: (() => void)[]; protected _desktopPropertyChangeHandler: EventHandler<PropertyChangeEvent<any, Desktop>>; protected _menuInheritAccessibilityChangeHandler: EventHandler<PropertyChangeEvent<boolean, Menu>>; protected _imageLoadListener: (event: ErrorEvent) => void; protected _insertedRows: TableRow[]; protected _$mouseDownRow: JQuery; protected _mouseDownRowId: string; protected _mouseDownColumn: Column<any>; protected _initialUiPreferences: TableClientUiPreferenceProfileDo; constructor() { super(); this.autoResizeColumns = false; this.columnAddable = true; this.columnLayoutDirty = false; this.columns = []; this.contextColumn = null; this.checkable = false; this.checkableStyle = Table.CheckableStyle.CHECKBOX; this.cellEditorPopup = null; this.compact = false; this.compactHandler = scout.create(TableCompactHandler, {table: this}); this.compactColumn = null; this.dropType = DropType.NONE; this.dropMaximumSize = dragAndDrop.DEFAULT_DROP_MAXIMUM_SIZE; this.groupingStyle = Table.GroupingStyle.TOP; this.header = null; this.headerEnabled = true; this.headerVisible = true; this.headerMenusEnabled = true; this.hasReloadHandler = false; this.hierarchical = false; this.hierarchicalStyle = Table.HierarchicalStyle.DEFAULT; this.keyStrokes = []; this.menus = []; this.menuBar = null; this.menuBarVisible = true; this.contextMenu = null; this.multiCheck = true; this.multiSelect = true; this.multilineText = false; this.scrollToSelection = false; this.scrollTop = 0; this.selectedRows = []; this.sortEnabled = true; this.tableControls = []; this.tableStatusVisible = false; this.tableTileGridMediator = null; this.tileMode = false; this.tileTableHeader = null; this.tileProducer = null; this.footer = null; this.footerVisible = false; this.filters = []; this.rows = []; this.rootRows = []; this.visibleRows = []; this.estimatedRowCount = 0; this.maxRowCount = 0; this.truncatedCellTooltipEnabled = null; this.visibleRowsMap = {}; this.rowLevelPadding = 0; this.rowsMap = {}; this.rowHeight = 0; this.rowWidth = 0; this.rowInsets = new Insets(); this.rowMargins = new Insets(); this.rowIconVisible = false; this.rowIconColumnWidth = Column.NARROW_MIN_WIDTH; this.staticMenus = []; this.selectionHandler = new TableSelectionHandler(this); this.tooltips = []; this._filteredRows = []; this.tableNodeColumn = null; this._maxLevel = 0; this._aggregateRows = []; this._animationRowLimit = 25; this._blockLoadThreshold = 25; this.updateBuffer = new TableUpdateBuffer(this); this.viewRangeSize = 10; this.viewRangeDirty = false; this.viewRangeRendered = new Range(0, 0); this.virtual = true; this.textFilterEnabled = true; this.filterSupport = this._createFilterSupport(); this.filteredElementsDirty = false; this.defaultMenuTypes = [Table.MenuType.EmptySpace]; this.accessibilityRenderer = new DefaultTableAccessibilityRenderer(); this.organizer = null; this.customizer = null; this._doubleClickSupport = new DoubleClickSupport(); this._permanentHeadSortColumns = []; this._permanentTailSortColumns = []; this._filterMenusHandler = this._filterMenus.bind(this); this._popupOpenHandler = this._onDesktopPopupOpen.bind(this); this._rerenderViewPortAfterAttach = false; this._renderViewPortAfterAttach = false; this._postAttachActions = []; this._desktopPropertyChangeHandler = this._onDesktopPropertyChange.bind(this); this._menuInheritAccessibilityChangeHandler = this._updateMenusEnabled.bind(this); this._addWidgetProperties(['tableControls', 'menus', 'keyStrokes', 'staticMenus', 'tileTableHeader', 'tableTileGridMediator', 'defaultRowAction']); this._addPreserveOnPropertyChangeProperties(['defaultRowAction']); this.$data = null; this.$emptyData = null; this.$fillBefore = null; this.$fillAfter = null; } // TODO [7.0] cgu create StringColumn.js incl. defaultValues from defaultValues.json static MenuType = { /** * The menu is always visible and displayed first in the {@link MenuBar}. * The menu won't be displayed in the context menu. */ EmptySpace: 'Table.EmptySpace', /** * The menu is only visible if exactly one row has been selected. * The menu is also displayed in the context menu. */ SingleSelection: 'Table.SingleSelection', /** * The menu is only visible if at least two row have been selected. * The menu is also displayed in the context menu. */ MultiSelection: 'Table.MultiSelection', /** * The menu is displayed in the {@link TableHeader} on the right side. */ Header: 'Table.Header' } as const; static HierarchicalStyle = { DEFAULT: 'default', STRUCTURED: 'structured' } as const; static GroupingStyle = { /** * Aggregate row is rendered on top of the row-group. */ TOP: 'top', /** * Aggregate row is rendered on the bottom of the row-group (default). */ BOTTOM: 'bottom' } as const; static CheckableStyle = { /** * When row is checked a boolean column with a checkbox is inserted into the table. */ CHECKBOX: 'checkbox', /** * When a row is checked the table-row is marked as checked. By default, a background * color is set on the table-row when the row is checked. */ TABLE_ROW: 'tableRow', /** * Like the CHECKBOX Style but a click anywhere on the row triggers the check. */ CHECKBOX_TABLE_ROW: 'checkbox_table_row' } as const; /** * This enum defines the reload-reasons for a table reload operation */ static ReloadReason = { /** * No specific reason, just reload data using the current search settings, the current row limits and the current * filter (Default) */ UNSPECIFIED: 'unspecified', /** * Some search parameters changed or the search was reset and the search was triggered */ SEARCH: 'search', /** * The user requested loading more data than his soft limit, up to the application specific hard limit */ OVERRIDE_ROW_LIMIT: 'overrideRowLimit', /** * The user requested loading no more data than his soft limit; */ RESET_ROW_LIMIT: 'resetRowLimit', /** * The column structure of the table was changed */ ORGANIZE_COLUMNS: 'organizeColumns', /** * Any call to IPage#dataChanged */ DATA_CHANGED_TRIGGER: 'dataChangedTrigger' } as const; static SELECTION_CLASSES = 'select-middle select-top select-bottom select-single selected'; protected override _init(model: InitModelOf<this>) { super._init(model); this.resolveConsts([{ property: 'hierarchicalStyle', constType: Table.HierarchicalStyle }, { property: 'checkableStyle', constType: Table.CheckableStyle }, { property: 'groupingStyle', constType: Table.GroupingStyle }]); this._initOrganizer(model.organizer === undefined); // auto-create unless explicitly defined in the model this._initCustomizer(); this._initColumns(); this.rows.forEach((row, i) => { this.rows[i] = this._initRow(row); }); this.setFilters(this.filters); this._updateRowStructure({ updateTree: true }); this.menuBar = this._createMenuBar(); this.menuBar.on('propertyChange:visible', () => this._refreshMenuBarClasses()); this._setSelectedRows(this.selectedRows); this._setKeyStrokes(this.keyStrokes); this._setMenus(this.menus); this._setDefaultRowAction(this.defaultRowAction); this._setTableControls(this.tableControls); this._setTableStatus(this.tableStatus); this._calculateValuesForBackgroundEffect(); this._setTileMode(this.tileMode); this._setTileTableHeader(this.tileTableHeader); this._sortWhileInit(); // required in case the rows are already provided in the initial model this._updateMenusEnabled(); this._initTablePreferences(); } protected _initTablePreferences() { // Wait for event so that changes made by subclasses in _init are also saved as initial preferences this.one('init', event => { this._setUiPreferencesEnabled(this.uiPreferencesEnabled); }); } protected _initRow(row: ObjectOrModel<TableRow>): TableRow { let tableRow: TableRow; if (row instanceof TableRow) { tableRow = row; } else { tableRow = this._createRow(row); } this.rowsMap[tableRow.id] = tableRow; this.trigger('rowInit', { row: tableRow }); return tableRow; } protected _createRow(rowModel?: TableRowModel): TableRow { let model = (rowModel || {}) as FullModelOf<TableRow>; model.objectType = scout.nvl(model.objectType, TableRow); model.parent = this; return scout.create(model); } protected _initColumns() { let cols = this.columns as ObjectOrChildModel<Column<any>>[]; this.columns = cols.map((colModel, index) => { const column = this._ensureColumn(colModel); if (column.index < 0) { column.index = index; } return column; }); // Add gui only row icon column at the beginning if (this.rowIconVisible) { this._insertRowIconColumn(); } this._setCompact(this.compact); // update checkable column, table node column and sort columns this._calculateCheckableTableNodeAndSortColumns(); this.columnLayoutDirty = true; } /** * Calculates the {@link Table.checkableColumn} (see {@link Table._calculateCheckableColumn}), the {@link Table.tableNodeColumn} (see {@link Table._calculateTableNodeColumn}) * and the {@link Table._permanentHeadSortColumns} and {@link Table._permanentTailSortColumns} (see {@link Table._setHeadAndTailSortColumns}). */ protected _calculateCheckableTableNodeAndSortColumns() { // update checkable column this._calculateCheckableColumn(); this._setCheckable(this.checkable); // update table node column this._calculateTableNodeColumn(); // update sort columns this._setHeadAndTailSortColumns(); } protected override _destroy() { this._destroyOrganizer(); this._destroyCustomizer(); this._destroyColumns(); super._destroy(); } protected _destroyColumns() { this.columns.forEach(column => column.destroy()); this.checkableColumn = null; this.compactColumn = null; this.rowIconColumn = null; this.columns = []; } protected _calculateTableNodeColumn() { let candidateColumns = this.visibleColumns().filter(column => column.nodeColumnCandidate); let tableNodeColumn = arrays.first(candidateColumns); if (this.tableNodeColumn && this.tableNodeColumn !== tableNodeColumn) { // restore this.tableNodeColumn.minWidth = this.tableNodeColumn['__initialMinWidth']; } this.tableNodeColumn = tableNodeColumn; if (this.tableNodeColumn) { this.tableNodeColumn['__initialMinWidth'] = this.tableNodeColumn.minWidth; this.tableNodeColumn.minWidth = this.rowLevelPadding * this._maxLevel + this.tableNodeColumn.tableNodeLevel0CellPadding + 8; if (this.tableNodeColumn.minWidth > this.tableNodeColumn.width) { if (this._isDataRendered()) { this.resizeColumn(this.tableNodeColumn, this.tableNodeColumn.minWidth); } else { this.tableNodeColumn.width = this.tableNodeColumn.minWidth; } } } } protected override _createLoadingSupport(): LoadingSupport { return new LoadingSupport({ widget: this, withGlassPane: true, $container: () => { return this.tileMode ? this.tableTileGridMediator.tileAccordion.$container : this.$data; } }); } protected override _createKeyStrokeContext(): KeyStrokeContext { return new KeyStrokeContext(); } protected override _initKeyStrokeContext() { super._initKeyStrokeContext(); this._initTableKeyStrokeContext(); } protected _initTableKeyStrokeContext() { this.keyStrokeContext.registerKeyStrokes([ new TableNavigationUpKeyStroke(this), new TableNavigationDownKeyStroke(this), new TableNavigationPageUpKeyStroke(this), new TableNavigationPageDownKeyStroke(this), new TableNavigationHomeKeyStroke(this), new TableNavigationEndKeyStroke(this), new TableNavigationCollapseKeyStroke(this, keys.LEFT, '←'), new TableNavigationCollapseKeyStroke(this, keys.SUBTRACT, '-'), new TableNavigationExpandKeyStroke(this, keys.RIGHT, '→'), new TableNavigationExpandKeyStroke(this, keys.ADD, '+'), new TableStartCellEditKeyStroke(this), new TableSelectAllKeyStroke(this), new TableRefreshKeyStroke(this), new TableToggleRowKeyStroke(this), new TableCopyKeyStroke(this), new ContextMenuKeyStroke(this, this.showContextMenu, this), new AppLinkKeyStroke(this, this.handleAppLinkAction) ]); } protected _insertBooleanColumn() { // don't add checkbox column when we're in checkableStyle mode if (this.checkableStyle === Table.CheckableStyle.TABLE_ROW) { return; } let column = scout.create(BooleanColumn, { parent: this, fixedWidth: true, fixedPosition: true, guiOnly: true, nodeColumnCandidate: false, headerMenuEnabled: false, showSeparator: false, width: Column.NARROW_MIN_WIDTH }); arrays.insert(this.columns, column, 0); this.checkableColumn = column; } protected _insertRowIconColumn() { let position = 0, column = scout.create(IconColumn, { parent: this, fixedWidth: true, fixedPosition: true, guiOnly: true, nodeColumnCandidate: false, headerMenuEnabled: false, showSeparator: false, width: this.rowIconColumnWidth }); if (this.columns[0] === this.checkableColumn) { position = 1; } arrays.insert(this.columns, column, position); this.rowIconColumn = column; } handleAppLinkAction(event: JQuery.KeyboardEventBase) { let $appLink = $(event.target); let column = this._columnAtX($appLink.offset().left); let row = $appLink.findUp($elem => $elem.hasClass('table-row'), this.$container).data('row') as TableRow; this._triggerAppLinkAction(column, row, $appLink.data('ref'), $appLink); } /** @internal */ _isDataRendered(): boolean { return this.rendered && this.$data !== null; } protected override _render() { this.$container = this.$parent.appendDiv('table') .addDeviceClass(); this.accessibilityRenderer.renderTable(this.$container); this.htmlComp = HtmlComponent.install(this.$container, this.session); this.htmlComp.setLayout(new TableLayout(this)); if (this.uiCssClass) { this.$container.addClass(this.uiCssClass); } if (this.tileMode) { this._renderTileMode(); } else { this._renderData(); } this.session.desktop.on('popupOpen', this._popupOpenHandler); this.session.desktop.on('propertyChange', this._desktopPropertyChangeHandler); } /** @internal */ _renderData() { this.$data = this.$container.appendDiv('table-data'); this.accessibilityRenderer.renderRowGroup(this.$data); this.$data.on('mousedown', '.table-row', this._onRowMouseDown.bind(this)) .on('mouseup', '.table-row', this._onRowMouseUp.bind(this)) .on('dblclick', '.table-row', this._onRowDoubleClick.bind(this)) .on('contextmenu', event => event.preventDefault()); this._installScrollbars({ axis: 'both' }); this._installImageListeners(); this._installCellTooltipSupport(); this._calculateRowInsets(); this._updateRowWidth(); this._updateRowHeight(); this._renderViewport(); if (this.scrollToSelection) { this.revealSelection(); } } protected override _renderProperties() { super._renderProperties(); this._renderTableHeader(); this._renderMenuBarVisible(); this._renderFooterVisible(); this._renderCheckableStyle(); this._renderHierarchicalStyle(); this._renderTextFilterEnabled(); this._renderMultiSelect(); this._renderMultiCheck(); } protected override _setCssClass(cssClass: string) { super._setCssClass(cssClass); // calculate row level padding let paddingClasses = ['table-row-level-padding']; if (this.cssClass) { paddingClasses.push(this.cssClass); } let classes = paddingClasses.reduce((acc, cssClass) => acc + ' ' + cssClass, ''); this.setRowLevelPadding(styles.getSize(classes, 'width', 'width', 15)); } /** @internal */ _removeData() { this._removeAggregateRows(); this._uninstallImageListeners(); this._uninstallCellTooltipSupport(); this._uninstallScrollbars(); this._removeRows(); this.$fillBefore = null; this.$fillAfter = null; this.$data.remove(); this.$data = null; this.$emptyData = null; } protected override _remove() { this.session.desktop.off('propertyChange', this._desktopPropertyChangeHandler); this.session.desktop.off('popupOpen', this._popupOpenHandler); dragAndDrop.uninstallDragAndDropHandler(this); // TODO [7.0] cgu do not delete header, implement according to footer this.header = null; if (this.$data) { this._removeData(); } this.filterSupport.remove(); super._remove(); } setRowLevelPadding(rowLevelPadding: number) { this.setProperty('rowLevelPadding', rowLevelPadding); } protected _renderRowLevelPadding() { this._rerenderViewport(); } setTableControls(controls: ObjectOrChildModel<TableControl>[]) { this.setProperty('tableControls', controls); } protected _renderTableControls() { if (this.footer) { this.footer._renderControls(); } } protected _setTableControls(controls: TableControl[]) { let i; for (i = 0; i < this.tableControls.length; i++) { this.keyStrokeContext.unregisterKeyStroke(this.tableControls[i]); } this._setProperty('tableControls', controls); for (i = 0; i < this.tableControls.length; i++) { this.keyStrokeContext.registerKeyStroke(this.tableControls[i]); } this._updateFooterVisibility(); this.tableControls.forEach(control => { control.tableFooter = this.footer; }); } /** * When an IMG has been loaded we must update the stored height in the model-row. * Note: we don't change the width of the row or table. */ protected _onImageLoadOrError(event: ErrorEvent) { let $target = $(event.target) as JQuery; if ($target.data('measure') === 'in-progress') { // Ignore events created by autoOptimizeWidth measurement (see ColumnOptimalWidthMeasurer) // Using event.stopPropagation() is not possible because the image load event does not bubble return; } $target.toggleClass('broken', event.type === 'error'); let $row = $target.closest('.table-row'); let row = $row.data('row') as TableRow; if (!row) { return; // row was removed while loading the image } let oldRowHeight = row.height; row.height = this._measureRowHeight($row); if (oldRowHeight !== row.height) { this.invalidateLayoutTree(); } } protected _onRowMouseDown(event: JQuery.MouseDownEvent) { this._doubleClickSupport.mousedown(event); this._$mouseDownRow = $(event.currentTarget); this._mouseDownRowId = this._$mouseDownRow.data('row').id; this._mouseDownColumn = this._columnAtX(event.pageX); this._$mouseDownRow.window().one('mouseup', () => { this._$mouseDownRow = null; this._mouseDownRowId = null; this._mouseDownColumn = null; }); this.setContextColumn(this._columnAtX(event.pageX)); // The row referenced by this._$mouseDownRow might become invalid in the onMouseDown event (e.g. if the event removes all rows). // Hence, we put the row aside before the event. let row = this._$mouseDownRow.data('row') as TableRow; this.selectionHandler.onMouseDown(event); this._$mouseDownRow = row.$row; let isRightClick = event.which === 3; let $target = $(event.target); // handle expansion if (this._isRowControl($target)) { if (row.expanded) { this.collapseRow(row); } else { this.expandRow(row); } } // For checkableStyle TABLE_ROW & CHECKBOX_TABLE_ROW only: check row if left click OR clicked row was not checked yet if (scout.isOneOf(this.checkableStyle, Table.CheckableStyle.TABLE_ROW, Table.CheckableStyle.CHECKBOX_TABLE_ROW) && (!isRightClick || !row.checked) && !$(event.target).is('.table-row-control') && // Click on BooleanColumns should not trigger a row check. The only exception is if the BooleanColumn is the checkableColumn of this table (handled in BooleanColumn.js) !($target.hasClass('checkable') || $target.parent().hasClass('checkable'))) { this.checkRow(row, !row.checked); } if (isRightClick) { event.preventDefault(); this.showContextMenu({ pageX: event.pageX, pageY: event.pageY }); } // set active descendant to the clicked row, so it is announced by screen readers. // This should be done last so selection state/focus/etc. is all set correctly before // the change of active descendant triggers the screen readers announcement. aria.linkElementWithActiveDescendant(this.$container, row.$row); } protected _isRowControl($target: JQuery): boolean { return $target.hasClass('table-row-control') || $target.parent().hasClass('table-row-control'); } protected _onRowMouseUp(event: JQuery.MouseUpEvent) { let $appLink: JQuery, mouseButton = event.which; if (this._doubleClickSupport.doubleClicked()) { // Don't execute on double click events return; } let $mouseUpRow = $(event.currentTarget); this.selectionHandler.onMouseUp(event); if (!this._$mouseDownRow || this._mouseDownRowId !== $mouseUpRow.data('row').id) { // Don't accept if mouse up happens on another row than mouse down, or mousedown didn't happen on a row at all return; } let $row = $mouseUpRow; let column = this._columnAtX(event.pageX); if (column !== this._mouseDownColumn) { // Don't execute click / appLinks when the mouse gets pressed and moved outside a cell return; } let $target = $(event.target); if (this._isRowControl($target)) { // Don't start cell editor or trigger click if row control was clicked (expansion itself is handled by the mouse down handler) return; } let row = $row.data('row') as TableRow; // read row before the $row potentially could be replaced by the column specific logic on mouse up if (mouseButton === 1) { column.onMouseUp(event, $row); $appLink = this._find$AppLink(event); } if ($appLink) { this._triggerAppLinkAction(column, row, $appLink.data('ref'), $appLink); } else { this._triggerRowClick(event, row, mouseButton, column); } } protected _onRowDoubleClick(event: JQuery.DoubleClickEvent) { let $row = $(event.currentTarget), column = this._columnAtX(event.pageX); this.doRowAction($row.data('row'), column); } showContextMenu(options: { pageX?: number; pageY?: number }) { this.session.onRequestsDone(this._showContextMenu.bind(this, options)); } protected _showContextMenu(options: { pageX?: number; pageY?: number }) { options = options || {}; if (!this._isDataRendered() || !this.attached) { // check needed because function is called asynchronously return; } if (this.selectedRows.length === 0) { return; } let menuItems = this._filterMenusForContextMenu(); if (menuItems.length === 0) { return; } let pageX: number = scout.nvl(options.pageX, null); let pageY: number = scout.nvl(options.pageY, null); if (pageX === null || pageY === null) { let rowToDisplay = this.isRowSelectedAndVisible(this.selectionHandler.lastActionRow) ? this.selectionHandler.lastActionRow : this.getLastSelectedAndVisibleRow(); if (rowToDisplay !== null) { let $rowToDisplay = rowToDisplay.$row; let offset = $rowToDisplay.offset(); let dataOffsetBounds = graphics.offsetBounds(this.$data); offset.left += this.$data.scrollLeft(); pageX = offset.left + 10; pageY = offset.top + $rowToDisplay.outerHeight() / 2; pageY = Math.min(Math.max(pageY, dataOffsetBounds.y + 1), dataOffsetBounds.bottom() - 1); } else { pageX = this.$data.offset().left + 10; pageY = this.$data.offset().top + 10; } } // Prevent firing of 'onClose'-handler during contextMenu.open() // (Can lead to null-access when adding a new handler to this.contextMenu) if (this.contextMenu) { this.contextMenu.close(); } this.contextMenu = scout.create(ContextMenuPopup, { parent: this, menuItems: menuItems, location: { x: pageX, y: pageY }, $anchor: this.$data, menuFilter: this._filterMenusHandler }); this.contextMenu.open(); } isRowSelectedAndVisible(row: TableRow): boolean { if (!this.isRowSelected(row) || !row.$row) { return false; } return graphics.offsetBounds(row.$row).intersects(graphics.offsetBounds(this.$data)); } getLastSelectedAndVisibleRow(): TableRow { for (let i = this.viewRangeRendered.to; i >= this.viewRangeRendered.from; i--) { if (this.isRowSelectedAndVisible(this.rows[i])) { return this.rows[i]; } } return null; } onColumnVisibilityChanged() { this.columnLayoutDirty = true; this._calculateTableNodeColumn(); this._triggerColumnStructureChanged(this.columns, this.columns); // Rebuild aggregate rows. This computes missing aggregate values for previously hidden columns. It is also a convenient // way to fix the column indices. The aggregate table control was already updated via 'columnStructureChanged' event. this._group(false); if (this._isDataRendered()) { this._updateRowWidth(); this.redraw(); } } protected override _onScroll(event: JQuery.ScrollEvent) { let scrollTop = this.$data[0].scrollTop; let scrollLeft = this.$data[0].scrollLeft; if (this.scrollTop !== scrollTop) { this._renderViewport(); } this.scrollTop = scrollTop; this.scrollLeft = scrollLeft; } protected _renderTableStatus() { this.trigger('statusChanged'); } setContextColumn(contextColumn: Column<any>) { this.setProperty('contextColumn', contextColumn); } protected _hasVisibleTableControls(): boolean { return this.tableControls.some(control => control.visible); } hasAggregateTableControl(): boolean { return !!this.findTableControl(AggregateTableControl); } findTableControl<T extends TableControl>(type: new () => T, predicate?: (tableControl: T) => boolean): T { for (let tableControl of this.tableControls) { if (tableControl instanceof type && (!predicate || predicate(tableControl))) { return tableControl; } } return null; } protected _createHeader(): TableHeader { return scout.create(TableHeader, { parent: this, table: this, enabled: this.headerEnabled, headerMenusEnabled: this.headerMenusEnabled }); } protected _createFooter(): TableFooter { return scout.create(TableFooter, { parent: this, table: this }); } protected _initOrganizer(autoCreate = true) { let organizer = this.organizer || (autoCreate ? this._createOrganizer() : null); this._setOrganizer(organizer); } protected _createOrganizer(): TableOrganizer { return scout.create(TableOrganizer); } protected _destroyOrganizer() { this.setOrganizer(null); } setOrganizer(organizer: TableOrganizer) { this.setProperty('organizer', organizer); } protected _setOrganizer(organizer: TableOrganizer) { if (this.organizer) { this.organizer.uninstall(); } this._setProperty('organizer', organizer); if (this.organizer) { this.organizer.install(this); } } protected _initCustomizer() { this._setCustomizer(this.customizer); // validate table } protected _destroyCustomizer() { this.setCustomizer(null); } setCustomizer(customizer: TableCustomizer) { this.setProperty('customizer', customizer); } protected _setCustomizer(customizer: TableCustomizer) { if (customizer && customizer.table !== this) { throw new Error(`Unexpected table in customizer (${customizer.table.id} instead of ${this.id}`); } this._setProperty('customizer', customizer); } setDefaultRowAction(defaultRowAction: Action | string) { this.setProperty('defaultRowAction', defaultRowAction); } protected _setDefaultRowAction(defaultRowAction: Action) { this._setProperty('defaultRowAction', defaultRowAction); } protected _installCellTooltipSupport() { tooltips.install(this.$data, { parent: this, selector: '.table-cell', text: this._cellTooltipText.bind(this), htmlEnabled: this._isCellTooltipHtmlEnabled.bind(this), arrowPosition: 50, arrowPositionUnit: '%', clipOrigin: true, nativeTooltip: !Device.get().isCustomEllipsisTooltipPossible() }); } protected _uninstallCellTooltipSupport() { tooltips.uninstall(this.$data); } /** @internal */ _cellTooltipText($cell: JQuery): string { let tooltipText: string, $row = $cell.parent(), column = this.columnFor$Cell($cell, $row), row = $row.data('row') as TableRow; if (row) { let cell = this.cell(column, row); tooltipText = cell.tooltipText; } if (tooltipText) { return tooltipText; } if (this._isAggregatedTooltip($cell) && $cell.text().trim()) { let $textSpan = $cell.children('.text'); let $iconSpan = $cell.children('.table-cell-icon'); let iconAvailableButHidden = $iconSpan.length && !$iconSpan.isVisible(); if ($textSpan.length && $textSpan.isContentTruncated() || iconAvailableButHidden) { let $clone = $cell.clone(); $clone.children('.table-cell-icon').setVisible(true); if ($cell.css('direction') === 'rtl') { let childrenHtml = $clone.children().get().map(c => c.outerHTML).reverse(); return strings.join('', ...childrenHtml); } return $clone.html(); } } if (this._isTruncatedCellTooltipEnabled(column) && $cell.isContentTruncated()) { return strings.plainText($cell.html(), { trim: true, removeFontIcons: true }); } } /** @see TableModel.truncatedCellTooltipEnabled */ setTruncatedCellTooltipEnabled(truncatedCellTooltipEnabled: boolean) { this.setProperty('truncatedCellTooltipEnabled', truncatedCellTooltipEnabled); } /** * Decides if a cell tooltip should be shown for a truncated cell. */ protected _isTruncatedCellTooltipEnabled(column: Column<any>): boolean { if (this.truncatedCellTooltipEnabled === null) { // Show cell tooltip only if it is not possible to resize the column. return !this.headerVisible || !this.headerEnabled || column.fixedWidth; } return this.truncatedCellTooltipEnabled; } protected _isAggregatedTooltip($cell: JQuery): boolean { let $row = $cell.parent(); return $row.data('aggregateRow') /* row in the table */ || $row.hasClass('table-aggregate'); /* aggregate table control */ } protected _isCellTooltipHtmlEnabled($cell: JQuery): boolean { return this._isAggregatedTooltip($cell); } reload(reloadReason?: TableReloadReason) { if (!this.hasReloadHandler) { return; } this._removeRows(); if (this._isDataRendered()) { this._removeAggregateRows(); this._renderFiller(); } this._triggerReload(reloadReason); } override setLoading(loading: boolean) { if (!loading && this.updateBuffer.isBuffering()) { // Don't abort loading while buffering, the buffer will do it at the end return; } super.setLoading(loading); } exportToClipboard() { this._triggerClipboardExport(); } /** * JS implementation of AbstractTable.execCopy(rows) */ protected _exportToClipboard() { clipboard.copyText({ parent: this, text: this._selectedRowsToText() }); } protected _selectedRowsToText(): string { let columns = this.visibleColumns(); return this.selectedRows.map(row => { return columns.map(column => { let cell = column.cell(row); let text; if (column instanceof BooleanColumn) { text = cell.value ? 'X' : ''; } else if (cell.htmlEnabled) { text = strings.plainText(cell.text); } else { text = cell.text; } // unwrap return this._unwrapText(text); }).join('\t'); }).join('\n'); } protected _unwrapText(text?: string): string { // Same implementation as in AbstractTable#unwrapText(String) return strings.nvl(text) .split(/[\n\r]/) .map(line => line.replace(/\t/g, ' ')) .map(line => line.trim()) .filter(line => !!line.length) .join(' '); } /** @see TableModel.multiSelect */ setMultiSelect(multiSelect: boolean) { this.setProperty('multiSelect', multiSelect); } toggleSelection() { if (this.selectedRows.length === this.visibleRows.length) { this.deselectAll(); } else { this.selectAll(); } } selectAll() { this.selectRows(this.visibleRows); } deselectAll() { this.selectRows([]); } checkAll(checked?: boolean, options?: TableRowCheckOptions) { let opts: TableRowCheckOptions = $.extend(options, { checked: checked }); this.checkRows(this.visibleRows, opts); } uncheckAll(options?: TableRowCheckOptions) { this.checkAll(false, options); } updateScrollbars() { scrollbars.update(this.$data); } protected _sort(animateAggregateRows?: boolean): boolean { if (!this.rows.length) { // nothing to sort return true; } let sortColumns = this._sortColumns(); // Initialize comparators if (!this._isSortingPossible(sortColumns)) { return false; } this.clearAggregateRows(animateAggregateRows); if (!sortColumns.length) { // no sort column defined. return true; } // add all visible columns as fallback sorting to guarantee same sorting as in Java. sortColumns = arrays.union(sortColumns, this.columns); this._sortImpl(sortColumns); this._triggerRowOrderChanged(); if (this._isDataRendered()) { this._renderRowOrderChanges(); } // Do it after row order has been rendered, because renderRowOrderChanges re-renders the whole viewport which would destroy the animation this._group(animateAggregateRows); // Sort was possible -> return true return true; } /** * @returns whether or not sorting is possible. Asks each column to answer this question by calling Column#isSortingPossible. */ protected _isSortingPossible(sortColumns: Column<any>[]): boolean { return sortColumns.every(column => column.isSortingPossible()); } protected _sortColumns(): Column<any>[] { let sortColumns = []; for (let c = 0; c < this.columns.length; c++) { let column = this.columns[c]; let sortIndex = column.sortIndex; if (sortIndex >= 0) { sortColumns[sortIndex] = column; } } return sortColumns; } protected _sortImpl(sortColumns: Column<any>[]) { let sortFunction: Comparator<TableRow> = (row1, row2) => { for (let s = 0; s < sortColumns.length; s++) { let column = sortColumns[s]; let result = column.compare(row1, row2); if (column.sortActive && !column.sortAscending) { // only consider sortAscending flag when sort is active // columns with !sortActive are always sorted ascending (sortAscending represents last state for those, thus not considered) result = -result; } if (result !== 0) { return result; } } return 0; }; if (this.hierarchical) { // sort tree and set flat row array afterward. this._sortHierarchical(sortFunction); let sortedFlatRows: TableRow[] = []; this.visitRows(row => sortedFlatRows.push(row)); this.rows = sortedFlatRows; } else { // sort the flat rows and set the rootRows afterward. this.rows.sort(sortFunction); this.rootRows = this.rows; } this._updateRowStructure({ filteredRows: true, applyFilters: false, visibleRows: true }); } /** * Pre-order (top-down) traversal of all rows in this table (if hierarchical). */ visitRows(visitFunc: (row: TableRow, level: number) => void, rows?: TableRow[], level?: number) { level = scout.nvl(level, 0); rows = rows || this.rootRows; rows.forEach(row => { visitFunc(row, level); this.visitRows(visitFunc, row.childRows, level + 1); }); } protected _sortHierarchical(sortFunc: Comparator<TableRow>, rows?: TableRow[]) { rows = rows || this.rootRows; rows.sort(sortFunc); rows.forEach(row => this._sortHierarchical(sortFunc, row.childRows)); } protected _renderRowOrderChanges() { let animate: boolean, $rows = this.$rows(), oldRowPositions: Record<string, number> = {}; // store old position // animate only if every row is rendered, otherwise some rows would be animated and some not if ($rows.length === this.visibleRows.length) { $rows.each((index, elem) => { let rowWasInserted = false, $row = $(elem), row = $row.data('row') as TableRow; // Prevent the order animation for newly inserted rows (to not confuse the user) if (this._insertedRows) { for (let i = 0; i < this._insertedRows.length; i++) { if (this._insertedRows[i].id === row.id) { rowWasInserted = true; break; } } } if (!rowWasInserted) { animate = true; oldRowPositions[row.id] = $row.offset().top; } }); } this._rerenderViewport(); // If aggregate rows are being removed by animation, rerenderViewport does not delete them -> reorder // This may happen if grouping gets deactivated and another column will get the new first sort column this._order$AggregateRows(); // Ensure selected row is visible after ordering if (this.scrollToSelection) { this.revealSelection(); } // for less than animationRowLimit rows: move to old position and then animate if (animate) { $rows = this.$rows(); $rows.each((index, elem) => { let $row = $(elem), row = $row.data('row') as TableRow, oldTop = oldRowPositions[row.id]; if (oldTop !== undefined) { $row.css('top', oldTop - $row.offset().top).animate({ top: 0 }, { progress: () => { this._triggerRowOrderChangeAnimation(row); this.updateScrollbars(); } }); } }); } } /** @see TableModel.sortEnabled */ setSortEnabled(sortEnabled: boolean) { this.setProperty('sortEnabled', sortEnabled); } /** * Adds the column to the list of sorted columns and re-sorts the table rows. * * @param direction the sorting direction. Either 'asc' or 'desc'. If not specified, the direction specified by the column is used ({@link Column.sortAscending}). */ addSortColumn(column: Column<any>, direction?: 'asc' | 'desc') { this.sort(column, direction, true); } /** * Removes the column from the list of sorted columns and re-sorts the table rows. */ removeSortColumn(column: Column<any>) { this.sort(column, null, true, true); } /** * Clears the list of sorted columns and re-sort the table rows. */ removeAllSortColumns() { this.columns .filter(column => column.sortActive) .forEach(this.removeSortColumn.bind(this)); } /** * The number of visible columns that are sorted. */ visibleSortColumnsCount(): number { return this.visibleColumns().filter(column => column.sortActive).length; } /** * @param column * the column to sort by. * @param direction * the sorting direction. Either 'asc' or 'desc'. * If not specified, the direction specified by the column is used ({@link Column.sortAscending}). * @param multiGroup * true to add the column to the list of sorted columns. * False to use this column exclusively as sort column (reset other columns). * Does not have an effect is `remove` is set to true. * Default is false. * @param remove * true to remove the column from the list of sorted columns. * Default is false. */ sort(column: Column<any>, direction?: 'asc' | 'desc', multiSort?: boolean, remove?: boolean) { multiSort = scout.nvl(multiSort, false); remove = scout.nvl(remove, false); // Animate if sort removes aggregate rows let animateAggregateRows = !multiSort; if (remove) { this._removeSortColumn(column); } else { this._addSortColumn(column, direction, multiSort); } if (this.header) { this.header.onSortingChanged(); } let sorted = this._sort(animateAggregateRows); let data: any = { column: column, sortAscending: column.sortAscending }; if (remove) { data.sortingRemoved = true; } if (multiSort) { data.multiSort = true; } if (!sorted) { // Delegate sorting to server when it is not possible on client side data.sortingRequested = true; // hint to animate the aggregate after the row order changed event this._animateAggregateRows = animateAggregateRows; } this.trigger('sort', data); } protected _addSortColumn(column: Column<any>, direction?: 'asc' | 'desc', multiSort?: boolean) { direction = scout.nvl(direction, column.sortAscending ? 'asc' : 'desc'); multiSort = scout.nvl(multiSort, true); this._updateSortIndexForColumn(column, multiSort); // Reset grouped flag if column should be sorted exclusively if (!multiSort) { let groupColCount = this._groupedColumns().length; let sortColCount = this._sortColumns().length; if (sortColCount === 1 && groupColCount === 1) { // special case: if it is the only sort column and also grouped, do not remove grouped property. } else { column.grouped = false;