UNPKG

@eclipse-scout/core

Version:
1,488 lines (1,331 loc) 199 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, DefaultTableAccessibilityRenderer, Desktop, DesktopPopupOpenEvent, Device, DisplayViewId, DoubleClickSupport, dragAndDrop, DragAndDropHandler, DropType, EnumObject, EventHandler, Filter, Filterable, FilterOrFunction, FilterResult, FilterSupport, FullModelOf, graphics, HierarchicalTableAccessibilityRenderer, HtmlComponent, IconColumn, InitModelOf, Insets, keys, KeyStrokeContext, LimitedResultTableStatus, LoadingSupport, Menu, MenuBar, MenuDestinations, MenuItemsOrder, menus, NumberColumn, NumberColumnAggregationFunction, NumberColumnBackgroundEffect, ObjectOrChildModel, ObjectOrModel, objects, Predicate, PropertyChangeEvent, Range, scout, scrollbars, ScrollToOptions, Status, StatusOrModel, strings, styles, TableCompactHandler, TableControl, TableCopyKeyStroke, TableEventMap, TableFooter, TableHeader, TableLayout, TableModel, TableNavigationCollapseKeyStroke, TableNavigationDownKeyStroke, TableNavigationEndKeyStroke, TableNavigationExpandKeyStroke, TableNavigationHomeKeyStroke, TableNavigationPageDownKeyStroke, TableNavigationPageUpKeyStroke, TableNavigationUpKeyStroke, TableOrganizer, TableRefreshKeyStroke, TableRow, TableRowModel, TableSelectAllKeyStroke, TableSelectionHandler, TableStartCellEditKeyStroke, TableTextUserFilter, TableTileGridMediator, TableToggleRowKeyStroke, TableTooltip, TableUpdateBuffer, TableUserFilter, TableUserFilterModel, Tile, TileTableHeaderBox, tooltips, TooltipSupport, UpdateFilteredElementsOptions, 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; $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>; 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._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']); 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._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._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(); } 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) => { let column: Column<any>; let columnOrModel = colModel as FullModelOf<Column<any>>; columnOrModel.session = this.session; if (columnOrModel instanceof Column) { column = columnOrModel; column._setTable(this); } else { columnOrModel.table = this; column = scout.create(columnOrModel); } if (column.index < 0) { column.index = index; } if (column.checkable) { // set checkable column if this column is the checkable one this.checkableColumn = column as BooleanColumn; } return column; }); // Add gui only checkbox column at the beginning this._setCheckable(this.checkable); // Add gui only row icon column at the beginning if (this.rowIconVisible) { this._insertRowIconColumn(); } this._setCompact(this.compact); this._calculateTableNodeColumn(); // Sync head and tail sort columns this._setHeadAndTailSortColumns(); this.columnLayoutDirty = true; } protected override _destroy() { this._destroyOrganizer(); 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, $container: () => { if (this.$container.hasClass('knight-rider-loading')) { return this.$container; } return 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, { session: this.session, fixedWidth: true, fixedPosition: true, guiOnly: true, nodeColumnCandidate: false, headerMenuEnabled: false, showSeparator: false, width: Column.NARROW_MIN_WIDTH, table: this }); arrays.insert(this.columns, column, 0); this.checkableColumn = column; } protected _insertRowIconColumn() { let position = 0, column = scout.create(IconColumn, { session: this.session, fixedWidth: true, fixedPosition: true, guiOnly: true, nodeColumnCandidate: false, headerMenuEnabled: false, showSeparator: false, width: this.rowIconColumnWidth, table: this }); 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.trigger('columnStructureChanged'); // 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.tableControls.some(control => control instanceof AggregateTableControl); } 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 _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 { 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: function() { this._triggerRowOrderChanged(row, true); this.updateScrollbars(); }.bind(this) }); } }); } } /** @see TableModel.sortEnabled */ setSortEnabled(sortEnabled: boolean) { this.setProperty('sortEnabled', sortEnabled); } /** * @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 multiSort true to add the column to the list of sorted columns. False to use this column exclusively as sort column (reset other columns). Default is false. * @param remove true to remove the column from the sort 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; } } column.sortAscending = direction === 'asc'; column.sortActive = true; } /** * Intended to be called for new sort columns. * Sets the sortIndex of the given column and its siblings. */ protected _updateSortIndexForColumn(column: Column<any>, multiSort: boolean) { let sortIndex = -1; if (multiSort) { // if not already sorted set the appropriate sort index (check for sortIndex necessary if called by _onColumnHeadersUpdated) if (!column.sortActive || column.sortIndex === -1) { sortIndex = Math.max(-1, arrays.max(this.columns.map(c => c.sortIndex === undefined || c.initialAlwaysIncludeSortAtEnd ? -1 : c.sortIndex))); column.sortIndex = sortIndex + 1; // increase sortIndex for all permanent tail columns (a column has been added in front of them) this._permanentTailSortColumns.forEach(c => { c.sortIndex++; }); } } else { // do not update sort index for permanent head/tail sort columns, their order is fixed (see ColumnSet.java) if (!(column.initialAlwaysIncludeSortAtBegin || column.initialAlwaysIncludeSortAtEnd)) { column.sortIndex = this._permanentHeadSortColumns.length; } // remove sort index for siblings (ignore permanent head/tail columns, only if not multi sort) arrays.eachSibling(this.columns, column, siblingColumn => { if (siblingColumn.sortActive) { this._removeSortColumnInternal(siblingColumn); } }); // set correct sort index for all permanent tail sort columns let deviation = column.initialAlwaysIncludeSortAtBegin || column.initialAlwaysIncludeSortAtEnd ? 0 : 1; this._permanentTailSortColumns.forEach((c, index) => { c.sortIndex = this._permanentHeadSortColumns.length + deviation + index; }); } } protected _removeSortColumn(column: Column<any>) { if (column.initialAlwaysIncludeSortAtBegin || column.initialAlwaysIncludeSortAtEnd) { return; } // Adjust sibling columns with higher index arrays.eachSibling(this.columns, column, siblingColumn => { if (siblingColumn.sortIndex > column.sortIndex) { siblingColumn.sortIndex = siblingColumn.sortIndex - 1; } }); this._removeSortColumnInternal(column); } protected _removeSortColumnInternal(column: Column<any>) { if (column.initialAlwaysIncludeSortAtBegin || column.initialAlwaysIncludeSortAtEnd) { return; } column.sortActive = false; column.grouped = false; column.sortIndex = -1; } isGroupingPossible(column: Column<any>): boolean { let possible = true; if (this.hierarchical) { return false; } if (!this.sortEnabled) { // grouping without sorting is not possible return false; } if (this._permanentHeadSortColumns && this._permanentHeadSortColumns.length === 0) { // no permanent head sort columns. grouping ok. return true; } if (column.initialAlwaysIncludeSortAtBegin) { possible = true; arrays.eachSibling(this._permanentHeadSortColumns, column, c => { if (c.sortIndex < column.sortIndex) { possible = possible && c.grouped; } }); ret