@eclipse-scout/core
Version:
Eclipse Scout runtime
1,488 lines (1,331 loc) • 199 kB
text/typescript
/*
* 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