@eclipse-scout/core
Version:
Eclipse Scout runtime
603 lines (523 loc) • 24.7 kB
text/typescript
/*
* Copyright (c) 2010, 2026 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 {
arrays, Column, ErrorHandler, Event, ITableCustomizerDo, IUserFilterStateDo, NumberColumn, NumberColumnAggregationFunction, NumberColumnBackgroundEffect, objects, ObjectWithType, PropertyChangeEvent, scout, strings, Table,
TableClientUiPreferenceProfileDo, TableClientUiPreferencesDo, TableColumnClientUiPreferenceDo, TableUserFilter, UiPreferences, uiPreferences, UserFilterStateMappers
} from '../index';
/**
* A singleton that represents all {@link Table}-specific UI preferences of the current user. It is populated during the start of the
* application, so the preferences can be accessed synchronously.
*/
export class TableUiPreferences implements ObjectWithType {
/**
* Key for the current global preferences of a table, i.e. preferences that are not stored in a specific settings profile.
*/
static readonly PROFILE_ID_GLOBAL = 'global-' + 'a134390b-bfef-4b9e-a14e-425df161e768';
/**
* Special key used to store table settings of a bookmarked table page. The bookmark support will consider this state as
* the "factory settings" when the page is displayed in the bookmark outline.
*/
static readonly PROFILE_ID_BOOKMARK = 'bookmark-' + 'aebcacd2-ddb6-4b7f-8673-d1585701d388';
objectType: string;
/** Map of all table preferences, indexed by table identifier (see {@link _computeTablePreferencesKey}). */
protected _tablePreferencesMap: Map<string, TableClientUiPreferencesDo> = new Map();
/** If > 0, table events are ignored. Useful when applying preferences. */
protected _ignoreTableEventsCounter = 0;
protected _columResizeTimeoutId: number;
protected _tableColumnListener = this._onTableColumnEvent.bind(this);
protected _tableTileModeListener = this._onTableTileModeChange.bind(this);
// --------------------------------------
/**
* Imports the given data into the internal structures, so individual table preferences can be read and
* modified by the various methods on this class. Any existing data is replaced.
*
* @internal should only be called from the registered {@link UiPreferencesHandler}!
*/
_importTablePreferences(tablePreferences: TableClientUiPreferencesDo[]) {
this._tablePreferencesMap.clear();
tablePreferences?.forEach(tablePrefs => {
let tableId = tablePrefs.tableId;
let userPreferenceContext = tablePrefs.userPreferenceContext;
let key = this._computeTablePreferencesKey(tableId, userPreferenceContext);
this._tablePreferencesMap.set(key, tablePrefs);
});
}
/**
* Exports the current state of the internal data structures as persistable table preferences.
*
* @internal should only be called from the registered {@link UiPreferencesHandler}!
*/
_exportTablePreferences(): TableClientUiPreferencesDo[] {
return [...this._tablePreferencesMap.values()];
}
/**
* Returns the preferences for the given table. If no preferences are registered yet, a new empty
* preferences data object is created and stored.
*/
protected _getOrCreateTablePreferences(table: Table): TableClientUiPreferencesDo {
scout.assertParameter('table', table, Table);
let tableId = table.buildUuidPath();
let userPreferenceContext = table.userPreferenceContext;
let key = this._computeTablePreferencesKey(tableId, userPreferenceContext);
let prefs = this._tablePreferencesMap.get(key);
if (!prefs) {
prefs = this.create(table);
this._tablePreferencesMap.set(key, prefs);
this._scheduleStore();
}
return prefs;
}
protected _computeTablePreferencesKey(tableId: string, userPreferenceContext: string): string {
return strings.join('#', tableId, userPreferenceContext);
}
protected _scheduleStore() {
uiPreferences.scheduleStore(TableUiPreferences);
}
// --------------------------------------
/**
* Installs or uninstalls UI preference support for the given table according to its {@link Table#uiPreferencesEnabled} flag.
*/
updateUiPreferencesEnabled(table: Table) {
scout.assertParameter('table', table, Table);
if (table.uiPreferencesEnabled) {
// Save current state as "factory defaults"
table.saveInitialUiPreferences();
// If there is a stored GLOBAL profile, apply it now
let prefs = this.get(table);
this.apply(table, prefs, TableUiPreferences.PROFILE_ID_GLOBAL);
// Install a table listener that stores all changes into the GLOBAL profile.
// This is only done _after_ applying the initial state, so that no events were triggered.
this._installTableListener(table);
} else {
// Uninstall listener
this._uninstallTableListener(table);
}
}
/**
* Installs a table listener for all preference-related changes and stores them in the {@link TableUiPreferences#PROFILE_ID_GLOBAL} profile for that table.
*/
protected _installTableListener(table: Table) {
this._uninstallTableListener(table);
table.on('columnMoved columnResized columnStructureChanged group sort aggregationFunctionChanged columnBackgroundEffectChanged', this._tableColumnListener);
table.on('propertyChange:tileMode', this._tableTileModeListener);
}
/**
* Uninstalls the listener installed by {@link _installTableListener}.
*/
protected _uninstallTableListener(table: Table) {
table.off('columnMoved columnResized columnStructureChanged group sort aggregationFunctionChanged columnBackgroundEffectChanged', this._tableColumnListener);
table.off('propertyChange:tileMode', this._tableTileModeListener);
}
/**
* Executes the specified runnable immediately. During its execution, all table events are ignored by this
* {@link TableUiPreferences} instance. The events themselves are not suppressed, i.e. other listeners are
* still triggered. Useful for making table adjustments that should _not_ be stored in the global profile.
*/
withIgnoreTableEvents(runnable: () => void) {
if (!runnable) {
return;
}
this._ignoreTableEvents = true;
try {
runnable();
} finally {
this._ignoreTableEvents = false;
}
}
protected get _ignoreTableEvents(): boolean {
return this._ignoreTableEventsCounter > 0;
}
protected set _ignoreTableEvents(applyingTablePreferences: boolean) {
if (applyingTablePreferences) {
this._ignoreTableEventsCounter++;
} else {
this._ignoreTableEventsCounter = Math.max(0, this._ignoreTableEventsCounter - 1);
}
}
protected _onTableColumnEvent(event: Event<Table>) {
if (this._ignoreTableEvents) {
return;
}
// FIXME bsh [js-bookmark] Find a better solution. It would convenient if there was a 'columnResizeEnd' event that is only triggered if the user has finished changing the size.
clearTimeout(this._columResizeTimeoutId);
if (event.type === 'columnResized') {
this._columResizeTimeoutId = setTimeout(() => {
this.storeGlobalProfile(event.source);
}, 750); // same delay as in TableAdapter#_sendColumnResized
} else {
this.storeGlobalProfile(event.source);
}
}
protected _onTableTileModeChange(event: PropertyChangeEvent<boolean, Table>) {
if (this._ignoreTableEvents) {
return;
}
this.store(event.source);
}
// --------------------------------------
/**
* Returns the preferences for the given table, or `null` if no preferences are registered yet.
*/
get(table: Table): TableClientUiPreferencesDo {
scout.assertParameter('table', table, Table);
let tableId = table.buildUuidPath();
let userPreferenceContext = table.userPreferenceContext;
let key = this._computeTablePreferencesKey(tableId, userPreferenceContext);
return this._tablePreferencesMap.get(key);
}
/**
* Returns the profile with the given id from the given table preferences. If no such profile exists, `undefined` is returned.
*/
getProfile(prefs: TableClientUiPreferencesDo, profileId: string): TableClientUiPreferenceProfileDo {
return prefs?.tablePreferenceProfiles?.get(profileId);
}
/**
* Creates a new data object consisting of all profile-independent preferences for the given table, according to its current state.
*
* Note: the `tablePreferences` map is *not* set automatically.
*/
create(table: Table): TableClientUiPreferencesDo {
scout.assertParameter('table', table, Table);
return scout.create(TableClientUiPreferencesDo, {
tableId: table.buildUuidPath(),
userPreferenceContext: table.userPreferenceContext,
tileMode: table.tileMode
});
}
/**
* Creates a new data object consisting of all profile-dependent preferences for the given table, according to its current state.
*/
createProfile(table: Table, options?: CreateTablePreferenceProfileOptions): TableClientUiPreferenceProfileDo {
let columnPreferences = this.createColumnPreferences(table, options?.includeNonDisplayableColumns);
let userFilters = options?.includeUserFilters ? this.createUserFilterStates(table) : null;
let customizerData = this.createCustomizerData(table);
return scout.create(TableClientUiPreferenceProfileDo, {
columns: arrays.nullIfEmpty(columnPreferences) || undefined,
userFilters: arrays.nullIfEmpty(userFilters) || undefined,
tableCustomizerData: customizerData || undefined
});
}
/**
* Creates a list of new data objects consisting of the preferences for each column of the given table, according to their current state.
* The result is never `null`. Invisible columns are included, while `guiOnly` and `displayable=false` columns are ignored. Non-displayable
* columns can be included explicitly by setting the corresponding option.
*/
createColumnPreferences(table: Table, includeNonDisplayableColumns = false): TableColumnClientUiPreferenceDo[] {
scout.assertParameter('table', table, Table);
return table.columns
.filter(column => !column.guiOnly)
.filter(column => column.displayable || includeNonDisplayableColumns)
.map((column, index) => {
return scout.create(TableColumnClientUiPreferenceDo, {
columnId: column.buildUuid(),
viewIndex: index,
visible: column.visibleIgnoreCompacted, // in compact mode, all columns would be invisible otherwise
width: column.width,
sortOrder: column.sortIndex,
sortAscending: column.sortAscending,
groupingActive: column.grouped,
aggregationFunctionId: column instanceof NumberColumn
? column.aggregationFunction
: this.isColumnPreferencesColumn(column)
? column.getColumnPreferences()?.aggregationFunctionId
: undefined,
backgroundEffectId: column instanceof NumberColumn
? column.backgroundEffect
: this.isColumnPreferencesColumn(column)
? column.getColumnPreferences()?.backgroundEffectId
: undefined
});
});
}
/**
* Creates a list of new data objects consisting of the state of each {@link TableUserFilter} of the given table.
* The result is never `null`. Only user filters with a registered {@link UserFilterStateMapper} are returned.
*/
createUserFilterStates(table: Table): IUserFilterStateDo[] {
scout.assertParameter('table', table, Table);
return table.filters
.filter(filter => filter instanceof TableUserFilter)
.map((filter: TableUserFilter) => {
for (let mapper of UserFilterStateMappers.all()) {
let filterState = mapper.tryToDo(table, filter);
if (filterState) {
return filterState;
}
}
scout.create(ErrorHandler, {displayError: false, sendError: true}).handle(`Unable to map filter to data object [table=${table.id}, filterType=${filter?.filterType}, filterLabel=${filter?.createLabel()}`);
return null;
})
.filter(Boolean);
}
/**
* If the table is customizable, returns the customizer data. Otherwise, `null` is returned.
*/
createCustomizerData(table: Table): ITableCustomizerDo {
scout.assertParameter('table', table, Table);
return table.isCustomizable() ? table.customizer.getCustomizerData() : null;
}
// --------------------------------------
/**
* Stores the given profile under the given profileId in the table preferences of the given table.
*/
storeProfile(table: Table, profileId: string, profile: TableClientUiPreferenceProfileDo) {
if (!profileId || !profile) {
return;
}
let prefs = this._getOrCreateTablePreferences(table);
// Check if store is necessary
let existingProfile = this.getProfile(prefs, profileId);
if (existingProfile) {
if (profile.equals(existingProfile)) {
// The new profile is identical to the already stored profile
return;
}
} else {
if (profileId === TableUiPreferences.PROFILE_ID_GLOBAL && profile.equals(table.initialUiPreferences)) {
// If the new profile is equal to the default state, it is not necessary to store it as the global profile.
// For other profileIds, we always want to store, because they are explicitly created by the user.
return;
}
}
prefs.tablePreferenceProfiles = prefs.tablePreferenceProfiles || new Map();
prefs.tablePreferenceProfiles.set(profileId, profile);
this._scheduleStore();
}
/**
* Renames a table preference profile and stores it.
*/
renameProfile(table: Table, oldProfileId: string, newProfileId: string) {
if (!oldProfileId || !newProfileId || oldProfileId === newProfileId) {
return;
}
let prefs = this.get(table);
let profile = prefs?.tablePreferenceProfiles?.get(oldProfileId);
if (profile) {
prefs.tablePreferenceProfiles.set(newProfileId, profile);
prefs.tablePreferenceProfiles.delete(oldProfileId);
this._scheduleStore();
}
}
/**
* Removes the specified profile from the table preferences and stores it.
*/
removeProfile(table: Table, profileId: string) {
if (!profileId) {
return;
}
let prefs = this.get(table);
let profile = prefs?.tablePreferenceProfiles?.get(profileId);
if (profile) {
prefs.tablePreferenceProfiles.delete(profileId);
this._scheduleStore();
}
}
/**
* Updates and stores the profile-independent table preferences to match the current state of the table.
*/
store(table: Table) {
let prefs = this._getOrCreateTablePreferences(table);
if (this._storeTableTileMode(table, prefs)) {
this._scheduleStore();
}
}
protected _storeTableTileMode(table: Table, prefs: TableClientUiPreferencesDo): boolean {
if (prefs.tileMode !== table.tileMode) {
prefs.tileMode = table.tileMode;
return true;
}
return false; // nothing to do
}
/**
* Stores the current profile-dependent preferences of the given table in the {@link TableUiPreferences#PROFILE_ID_GLOBAL} profile.
*/
storeGlobalProfile(table: Table) {
this.storeProfile(table, TableUiPreferences.PROFILE_ID_GLOBAL, this.createProfile(table));
}
/**
* Removes the {@link TableUiPreferences#PROFILE_ID_GLOBAL} profile for the given table.
*/
clearGlobalProfile(table: Table) {
this.removeProfile(table, TableUiPreferences.PROFILE_ID_GLOBAL);
}
// --------------------------------------
/**
* Applies the given preferences to the given table, i.e. changes the table state to match the preferences. If a `profileId` is given
* and the table preferences contain a profile with that id, it is applied as well. Otherwise, only profile-independent preferences
* are applied.
*/
apply(table: Table, prefs: TableClientUiPreferencesDo, profileId?: string, options?: ApplyTablePreferencesOptions) {
if (!prefs) {
return; // nothing to apply
}
scout.assertParameter('table', table, Table);
this.withIgnoreTableEvents(() => {
this._applyTablePreferencesInternal(table, prefs, options);
let profile = this.getProfile(prefs, profileId);
if (profile) {
this._applyTablePreferenceProfileInternal(table, profile, options);
}
});
}
/**
* Applies the given preference profile to the given table, i.e. changes the table state to match the profile.
*/
applyProfile(table: Table, profile: TableClientUiPreferenceProfileDo, options?: ApplyTablePreferencesOptions) {
if (!profile) {
return; // nothing to apply
}
scout.assertParameter('table', table, Table);
this.withIgnoreTableEvents(() => {
this._applyTablePreferenceProfileInternal(table, profile, options);
});
}
protected _applyTablePreferencesInternal(table: Table, prefs: TableClientUiPreferencesDo, options?: ApplyTablePreferencesOptions) {
table.setTileMode(prefs.tileMode);
}
protected _applyTablePreferenceProfileInternal(table: Table, profile: TableClientUiPreferenceProfileDo, options?: ApplyTablePreferencesOptions) {
// Order is important! Applying column preferences requires custom columns to be injected first
this._applyCustomizerData(table, profile.tableCustomizerData, options);
this._applyColumnPreferences(table, profile.columns, options);
this._applyUserFilterStates(table, profile.userFilters, options);
}
protected _applyCustomizerData(table: Table, customizerData: ITableCustomizerDo, options?: ApplyTablePreferencesOptions) {
if (table.isCustomizable() && scout.nvl(options?.applyCustomizerData, true)) { // false while applying customizer data
// noinspection JSIgnoredPromiseFromCall
table.customizer.setCustomizerData(customizerData);
}
}
protected _applyColumnPreferences(table: Table, columnPreferences: TableColumnClientUiPreferenceDo[], options?: ApplyTablePreferencesOptions) {
let columnPreferencesMap = new Map(arrays.ensure(columnPreferences).map(pref => [pref.columnId, pref]));
// Create new list of columns, excluding guiOnly columns as they will be recreated automatically by _setColumns
let newColumns = table.columns.filter(c => !c.guiOnly);
// Sort columns according to the order specified in the preferences. Columns *without* preferences that
// appear before the first column *with* preferences are placed at the front, all others at the end.
let viewIndexMap = new Map<Column<any>, number>();
let defaultViewIndex = -Infinity;
newColumns.forEach(column => {
let viewIndex = columnPreferencesMap.get(column.buildUuid())?.viewIndex;
if (objects.isNullOrUndefined(viewIndex)) {
viewIndexMap.set(column, defaultViewIndex);
} else {
viewIndexMap.set(column, viewIndex);
defaultViewIndex = Infinity;
}
});
newColumns.sort((c1, c2) => {
if (!options?.applyNonDisplayableColumns) {
// Unless explicitly requested, non-displayable columns are always placed at the front, and their preferences are ignored.
if (!c1.displayable && !c2.displayable) {
return (c1.primaryKey === c2.primaryKey ? 0 : (c1.primaryKey ? -1 : 1)); // pk first
}
if (!c1.displayable || !c2.displayable) {
return !c1.displayable ? -1 : 1; // non-displayable first
}
}
return viewIndexMap.get(c1) - viewIndexMap.get(c2);
});
// Apply column preferences. Columns without corresponding entry in the preferences are left
// untouched, while preference entries without corresponding column are simply ignored.
newColumns
.filter(column => column.displayable || options?.applyNonDisplayableColumns)
.forEach(column => this._applyColumnPreferencesToColumn(column, columnPreferencesMap.get(column.buildUuid())));
table.setColumns(newColumns);
}
protected _applyColumnPreferencesToColumn(column: Column<any>, columnPreferences: TableColumnClientUiPreferenceDo) {
if (!columnPreferences) {
return; // can happen if preferences are applied before the customizer is installed or if preferences contains obsolete data
}
// Use setter for 'visible' property because it is a multidimensional property
column.setVisible(columnPreferences.visible, false); // parameter 'false' skips call of onColumnVisibilityChanged()
// Don't use setter for 'width' property to prevent unnecessarily redrawing the table (will be done again later in setColumns anyway)
if (!column.fixedWidth) {
column.width = columnPreferences.width;
}
// Properties without setter (changes will be applied later by _setColumns)
column.sortIndex = columnPreferences.sortOrder;
column.sortAscending = columnPreferences.sortAscending;
column.sortActive = column.sortIndex >= 0;
column.grouped = columnPreferences.groupingActive;
if (column instanceof NumberColumn) {
// Use setters to correctly update internal structures (e.g. aggrStart function)
column.setAggregationFunction(columnPreferences.aggregationFunctionId as NumberColumnAggregationFunction);
column.setBackgroundEffect(columnPreferences.backgroundEffectId as NumberColumnBackgroundEffect, false); // false = don't redraw
}
if (this.isColumnPreferencesColumn(column)) {
column.setColumnPreferences(columnPreferences);
}
}
protected _applyUserFilterStates(table: Table, userFilterStates: IUserFilterStateDo[], options?: ApplyTablePreferencesOptions) {
if (options?.applyUserFilters) { // true when showing a bookmark
table.applyUserFilterStates(userFilterStates);
}
}
isColumnPreferencesColumn<TValue>(column: Column<TValue> & Partial<Omit<ColumnPreferencesColumn<TValue>, keyof Column<TValue>>>): column is ColumnPreferencesColumn<TValue> {
return objects.isFunction(column?.getColumnPreferences) && objects.isFunction(column.setColumnPreferences);
}
}
// --------------------------------------
export interface CreateTablePreferenceProfileOptions {
/**
* Specifies whether to include the state of user filters ({@link IUserFilterStateDo}) in the preference profile.
*
* Default is false.
*/
includeUserFilters?: boolean;
/**
* Specifies whether information about columns with `displayable=false` should be included in the profile. Useful to save the
* initial state of a table. Not intended to be set when creating a profile that is to be persisted.
*
* Default is false.
*/
includeNonDisplayableColumns?: boolean;
}
export interface ApplyTablePreferencesOptions {
/**
* Specifies whether to apply customizer data from the preference profile to the table.
*
* Default is true.
*/
applyCustomizerData?: boolean;
/**
* Specifies whether to apply user filter states from the preference profile to the table.
*
* Default is false.
*/
applyUserFilters?: boolean;
/**
* Specifies whether information about columns with `displayable=false` should be applied. Useful to restore the initial state
* of a table that was previously saved with {@link CreateTablePreferenceProfileOptions#includeNonDisplayableColumns}.
*
* Default is false.
*/
applyNonDisplayableColumns?: boolean;
}
// --------------------------------------
export const tableUiPreferences = objects.createSingletonProxy(TableUiPreferences);
UiPreferences.registerHandler(TableUiPreferences, {
importPreferences: preferences => {
tableUiPreferences._importTablePreferences(preferences.tablePreferences);
},
exportPreferences: preferences => {
preferences.tablePreferences = tableUiPreferences._exportTablePreferences();
}
});
/**
* Interface for {@link Column}s containing a getter and a setter for {@link TableColumnClientUiPreferenceDo}.
* When preferences are applied to such a {@link Column} the whole {@link TableColumnClientUiPreferenceDo} is passed to the setter.
* Later on these preferences are used as a fallback for preference values that can only be extracted from
* specific column types (e.g. {@link NumberColumn#aggregationFunction} or {@link NumberColumn#backgroundEffect}) when the preferences are stored.
*
* With this one can e.g. implement placeholder columns that cache the preferences while the real columns are created asynchronously.
*/
export interface ColumnPreferencesColumn<TValue = any> extends Column<TValue> {
getColumnPreferences: () => TableColumnClientUiPreferenceDo;
setColumnPreferences: (columnPreferences: TableColumnClientUiPreferenceDo) => void;
}