@eclipse-scout/core
Version:
Eclipse Scout runtime
613 lines (542 loc) • 19.2 kB
text/typescript
/*
* Copyright (c) 2010, 2024 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 {
ButtonTile, ChildModelOf, EnumObject, Event, EventHandler, EventListener, EventMapOf, EventModel, EventSupport, Form, HtmlComponent, icons, InitModelOf, inspector, Menu, MenuBar, menus, ObjectOrChildModel, Outline, PageEventMap,
PageModel, PropertyChangeEvent, scout, strings, Table, TableRow, TableRowClickEvent, TileOutlineOverview, TileOverviewForm, TreeNode, Widget
} from '../../../index';
import $ from 'jquery';
/**
* This class is used differently in online and JS-only case. In the online case we only have instances
* of Page in an outline. The server sets the property <code>nodeType</code> which is used to distinct
* between pages with tables and pages with nodes in some cases. In the JS only case, Page is an abstract
* class and is never instantiated directly, instead we always use subclasses of PageWithTable or PageWithNodes.
* Implementations of these classes contain code which loads table data or child nodes.
*/
export class Page extends TreeNode implements PageModel {
declare model: PageModel;
declare eventMap: PageEventMap;
declare self: Page;
declare parent: Outline;
declare childNodes: Page[];
declare parentNode: Page;
/**
* This property is set by the server, see: JsonOutline#putNodeType.
*/
nodeType: NodeType;
compactRoot: boolean;
detailTable: Table;
detailTableVisible: boolean;
detailForm: Form;
detailFormVisible: boolean;
detailFormVisibleByUi: boolean;
navigateButtonsVisible: boolean;
tableStatusVisible: boolean;
htmlComp: HtmlComponent;
/**
* True to select the page linked with the selected row when the row was selected. May be useful on touch devices.
*/
drillDownOnRowClick: boolean;
/**
* The icon id which is used for icons in the tile outline overview.
*/
overviewIconId: string;
showTileOverview: boolean;
inheritMenusFromParentTablePage: boolean;
row: TableRow;
tile: ButtonTile;
events: EventSupport;
pageChanging: number;
protected _tableFilterHandler: EventHandler<Event<Table>>;
protected _tableRowClickHandler: EventHandler<TableRowClickEvent>;
protected _detailTableModel: ChildModelOf<Table>;
/** @internal */
_detailFormModel: ChildModelOf<Form>;
protected _menuOwnerMenusChangeHandler: (event: Event<MenuOwner>) => void;
constructor() {
super();
this.nodeType = null;
this.compactRoot = false;
this.detailTable = null;
this.detailTableVisible = true;
this.detailForm = null;
this.detailFormVisible = true;
this.detailFormVisibleByUi = true;
this.navigateButtonsVisible = true;
this.tableStatusVisible = true;
this.drillDownOnRowClick = false;
this.overviewIconId = null;
this.showTileOverview = false;
this.inheritMenusFromParentTablePage = true;
this.events = new EventSupport();
this.events.registerSubTypePredicate('propertyChange', (event: PropertyChangeEvent, propertyName) => event.propertyName === propertyName);
this.pageChanging = 0;
this._tableFilterHandler = this._onTableFilter.bind(this);
this._tableRowClickHandler = this._onTableRowClick.bind(this);
this._detailTableModel = null;
this._detailFormModel = null;
this._menuOwnerMenusChangeHandler = this._onMenuOwnerMenusChange.bind(this);
}
/**
* This enum defines a node-type. This is basically used for the Scout Classic case where we only have instances
* of Page, but never instances of PageWithTable or PageWithNodes. The server simply sets a nodeType instead.
*/
static NodeType = {
NODES: 'nodes',
TABLE: 'table'
} as const;
protected override _init(model: InitModelOf<this>) {
try {
this.setPageChanging(true);
this._detailTableModel = Page._removePropertyIfLazyLoading(model, 'detailTable') as ChildModelOf<Table>;
this._detailFormModel = Page._removePropertyIfLazyLoading(model, 'detailForm') as ChildModelOf<Form>;
super._init(model);
icons.resolveIconProperty(this, 'overviewIconId');
// init necessary if the properties are still available (e.g. Scout classic)
this._internalInitTable();
this._internalInitDetailForm();
} finally {
this.setPageChanging(false);
}
}
protected static _removePropertyIfLazyLoading(object: PageModel, name: string): any {
let prop = object[name];
if (typeof prop === 'string') {
// Scout Classic: it is an object id -> do not remove it. directly create the widget. lazy loading is done on backend
return null;
}
if (prop instanceof Widget) {
// it already is a widget. directly use it.
return null;
}
// otherwise: remove the property and return it
delete object[name];
return prop;
}
protected override _destroy() {
this.trigger('destroying');
super._destroy();
if (this.detailTable) {
this.detailTable.destroy();
this.detailTable = null;
}
if (this.detailForm) {
this.detailForm.destroy();
this.detailForm = null;
}
this.trigger('destroy');
}
protected _internalInitTable() {
let tableModel = this.detailTable;
if (tableModel) {
// this case is used for Scout classic
let newDetailTable = this.getOutline()._createChild(tableModel);
this._setDetailTable(newDetailTable);
}
}
protected _internalInitDetailForm() {
let formModel = this.detailForm;
if (formModel) {
let newDetailForm = this.getOutline()._createChild(formModel);
this._setDetailForm(newDetailForm);
}
}
ensureDetailTable() {
if (this.detailTable) {
return;
}
this.setDetailTable(this.createDetailTable());
}
/**
* Creates the detail table
* @returns the created table or null
*/
createDetailTable(): Table {
let detailTable = this._createDetailTable();
if (!detailTable && this._detailTableModel) {
detailTable = this.getOutline()._createChild(this._detailTableModel);
this._detailTableModel = null; // no longer needed
}
return detailTable;
}
/**
* Override this function to create the internal table. Default impl. returns null.
*/
protected _createDetailTable(): Table {
return null;
}
ensureDetailForm() {
if (this.detailForm) {
return;
}
this.setDetailForm(this.createDetailForm());
}
/**
* Creates the detail form
* @returns the created form or null
*/
createDetailForm(): Form {
let detailForm = this._createDetailForm();
if (!detailForm && this._detailFormModel) {
detailForm = this.getOutline()._createChild(this._detailFormModel);
this._detailFormModel = null; // no longer needed
}
return detailForm;
}
/**
* Override this function to return a detail form which is displayed in the outline when this page is selected.
* The default implementation returns null.
*/
protected _createDetailForm(): Form {
return null;
}
/**
* Override this function to initialize the internal detail form.
* @param form the form to initialize.
*/
protected _initDetailForm(form: Form) {
if (form instanceof Form) {
form.setModal(false);
form.setClosable(false);
form.setDisplayHint(Form.DisplayHint.VIEW);
form.setDisplayViewId('C');
form.setShowOnOpen(false);
this._updateParentTablePageMenusForDetailForm();
}
if (form instanceof TileOverviewForm) {
form.setPage(this);
}
}
protected _updateParentTablePageMenusForDetailFormAndDetailTable() {
this._updateParentTablePageMenusForDetailForm();
this._updateParentTablePageMenusForDetailTable();
}
protected _updateParentTablePageMenusForDetailForm() {
this._updateParentTablePageMenusForMenuOwner(this.detailForm && this.detailForm.rootGroupBox);
}
protected _updateParentTablePageMenusForDetailTable() {
this._updateParentTablePageMenusForMenuOwner(this.detailTable);
}
protected _updateParentTablePageMenusForMenuOwner(menuOwner: MenuOwner) {
if (!menuOwner) {
return;
}
menuOwner.off('propertyChange:menus', this._menuOwnerMenusChangeHandler);
const originalMenus = menuOwner.menus || [];
const parentTablePageMenus = this._computeParentTablePageMenus(menuOwner);
menuOwner.setMenus(parentTablePageMenus
.concat(originalMenus
.filter((menu: Menu & { __parentTablePageMenu?: boolean }) => !menu.__parentTablePageMenu)
));
menuOwner.on('propertyChange:menus', this._menuOwnerMenusChangeHandler);
}
protected _onMenuOwnerMenusChange(event: Event<MenuOwner>) {
this._updateParentTablePageMenusForMenuOwner(event.source);
}
protected _computeParentTablePageMenus(newParent: Widget): Menu[] {
if (!this.parentNode) {
return [];
}
const table = this.parentNode.detailTable;
const row = this.row;
if (!table || !row || table !== row.getTable()) {
return [];
}
return this._filterAndCloneParentTablePageMenus(table.menus, newParent);
}
protected _filterAndCloneParentTablePageMenus(tablePageMenus: Menu[], newParent: Widget): Menu[] {
return this._filterParentTablePageMenus(tablePageMenus)
.filter(this._isMenuInheritedFromParentTablePage.bind(this))
.map(menu => this._cloneParentTablePageMenu(menu, newParent));
}
protected _filterParentTablePageMenus(tablePageMenus: Menu[]): Menu[] {
return menus.filter(tablePageMenus, Table.MenuType.SingleSelection);
}
protected _cloneParentTablePageMenu(menu: Menu, newParent: Widget): Menu {
if (!menu) {
return null;
}
const clone = menu.clone(
{
parent: newParent,
menuTypes: [],
__parentTablePageMenu: true
},
{
delegateEventsToOriginal: ['action'],
delegateAllPropertiesToClone: true,
excludePropertiesToClone: ['menuTypes', 'childActions']
});
if (menu.childActions && menu.childActions.length) {
clone.setChildActions(this._filterAndCloneParentTablePageMenus(menu.childActions, clone));
}
return clone;
}
protected _isMenuInheritedFromParentTablePage(menu: Menu): boolean {
return this.inheritMenusFromParentTablePage;
}
/**
* Override this function to destroy the internal (detail) form.
* @param form the form to destroy.
*/
protected _destroyDetailForm(form: Form) {
if (form instanceof TileOverviewForm) {
form.setPage(null);
}
if (form.owner === this.getOutline()) {
// in Scout classic the owner is not an outline but the NullWidget.
// Then destroy is controlled by the backend
form.destroy();
}
}
/**
* Override this function to initialize the internal (detail) table.
* Default impl. delegates filter events to the outline mediator.
* @param table The table to initialize.
*/
protected _initDetailTable(table: Table) {
table.menuBar.setPosition(MenuBar.Position.TOP);
table.on('filter', this._tableFilterHandler);
if (this.drillDownOnRowClick) {
table.on('rowClick', this._tableRowClickHandler);
table.setMultiSelect(false);
}
table.setTableStatusVisible(this.tableStatusVisible);
this._updateParentTablePageMenusForDetailTable();
}
/**
* Override this function to destroy the internal (detail) table.
* @param table the table to destroy.
*/
protected _destroyDetailTable(table: Table) {
table.off('filter', this._tableFilterHandler);
table.off('rowClick', this._tableRowClickHandler);
if (table.owner === this.getOutline()) {
// in Scout classic the owner is not an outline but the NullWidget.
// Then destroy is controlled by the backend
table.destroy();
}
}
/** @internal */
override _decorate() {
super._decorate();
if (!this.$node) {
return;
}
if (this.session.inspector) {
inspector.applyInfo(this, this.$node);
}
this.$node.toggleClass('compact-root', this.compactRoot);
this.$node.toggleClass('has-tile-overview', this.showTileOverview ||
(this.compactRoot && this.getOutline().detailContent instanceof TileOutlineOverview));
}
// see Java: AbstractPage#pageActivatedNotify
activate() {
this.ensureDetailTable();
this.ensureDetailForm();
}
// see Java: AbstractPage#pageDeactivatedNotify
deactivate() {
// NOP
}
/**
* @returns the tree / outline / parent instance. it's all the same,
* but it's more intuitive to work with the 'outline' when we deal with pages.
*/
getOutline(): Outline {
return this.parent;
}
/**
* Returns an array of pages linked with the given rows. The order of the returned pages corresponds to the
* order of the rows. Rows that are not linked to a page are ignored.
*/
pagesForTableRows(rows: TableRow[]): Page[] {
return rows.map(row => row.page).filter(Boolean);
}
/**
* @param form The new form
*/
setDetailForm(form: Form) {
if (form === this.detailForm) {
return;
}
this._setDetailForm(form);
}
protected _setDetailForm(form: Form) {
let oldDetailForm = this.detailForm;
if (oldDetailForm !== form && oldDetailForm instanceof Widget) {
// must be a widget to be destroyed. At startup in Scout Classic it might be a string (the widget id)
this._destroyDetailForm(oldDetailForm);
}
this.detailForm = form;
if (form) {
form.one('destroy', () => {
if (this.detailForm === form) {
this.detailForm = null;
}
});
this._initDetailForm(form);
}
this.triggerPropertyChange('detailForm', oldDetailForm, form);
this.getOutline().pageChanged(this);
}
/**
* @param table The new table
*/
setDetailTable(table: Table) {
if (table === this.detailTable) {
return;
}
this._setDetailTable(table);
}
protected _setDetailTable(table: Table) {
let oldDetailTable = this.detailTable;
if (oldDetailTable !== table && oldDetailTable instanceof Widget) {
// must be a widget to be destroyed. At startup in Scout Classic it might be a string (the widget id)
this._destroyDetailTable(oldDetailTable);
}
this.detailTable = table;
if (table) {
table.one('destroy', () => {
if (this.detailTable === table) {
this.detailTable = null;
}
});
this._initDetailTable(table);
}
this.triggerPropertyChange('detailTable', oldDetailTable, table);
this.getOutline().pageChanged(this);
}
/**
* Updates relevant properties from the pages linked with the given rows using the method updatePageFromTableRow and
* returns the pages. Rows that are not linked to a page are ignored.
*
* @returns pages linked with the given rows.
*/
updatePagesFromTableRows(rows: TableRow[]): Page[] {
return rows
.filter(row => !!row.page)
.map(row => row.page.updatePageFromTableRow(row));
}
/**
* Updates relevant properties (text, enabled, htmlEnabled) from the page linked with the given row.
* Only call this method if {@link TableRow#page} is set!
*
* @returns page linked with the given row.
*/
updatePageFromTableRow(row: TableRow): Page {
let page = row.page;
page.enabled = row.enabled;
page.text = page.computeTextForRow(row);
if (row.cells.length) {
page.htmlEnabled = row.cells[0].htmlEnabled;
page.cssClass = row.cells[0].cssClass;
}
return page;
}
/**
* This function creates the text property of this page. The default implementation returns the texts of the summary columns of the table or
* from the first cell of the given row. It's allowed to ignore the given row entirely, when you override this function.
*/
computeTextForRow(row: TableRow): string {
const summaryColumns = row.getTable().summaryColumns();
if (summaryColumns.length) {
return strings.join(' ', ...summaryColumns.map(summaryColumn => summaryColumn.cellText(row)));
}
if (row.cells.length >= 1) {
return row.cells[0].text;
}
return '';
}
/**
* @returns a page parameter object used to pass to newly created child pages. Sets the parent
* to our outline instance and adds optional other properties. Typically, you'll pass an
* object (entity-key or arbitrary data) to a child page.
*/
protected _pageParam<T extends object>(paramProperties?: T): T & { parent: Outline } {
let param = {
parent: this.getOutline()
};
$.extend(param, paramProperties);
return param as T & { parent: Outline };
}
reloadPage() {
let outline = this.getOutline();
if (outline) {
this.loadChildren();
}
}
linkWithRow(row: TableRow) {
this.row = row;
row.page = this;
this.getOutline().trigger('pageRowLink', {
page: this,
row: row
});
}
unlinkWithRow(row: TableRow) {
delete this.row;
delete row.page;
}
protected _onTableFilter(event: Event<Table>) {
this.getOutline().mediator.onTableFilter(event, this);
}
protected _onTableRowClick(event: TableRowClickEvent) {
if (!this.drillDownOnRowClick) {
return;
}
if (this.leaf) {
return;
}
this.getOutline().drillDown(event.row.page);
event.source.deselectRow(event.row);
}
setPageChanging(changing: boolean) {
if (changing) {
this.pageChanging++;
return;
}
if (this.pageChanging) {
this.pageChanging--;
}
}
/**
* Triggers a property change for a single property.
*/
triggerPropertyChange<T>(propertyName: string, oldValue: T, newValue: T): PropertyChangeEvent<T, this> {
scout.assertParameter('propertyName', propertyName);
return this.trigger('propertyChange', {
propertyName: propertyName,
oldValue: oldValue,
newValue: newValue
}) as PropertyChangeEvent<T, this>;
}
trigger<K extends string & keyof EventMapOf<Page>>(type: K, eventOrModel?: Event<Page> | EventModel<EventMapOf<Page>[K]>): EventMapOf<Page>[K] {
let event: Event<Page>;
if (eventOrModel instanceof Event) {
event = eventOrModel;
} else {
event = new Event(eventOrModel);
}
event.source = this;
this.events.trigger(type, event);
return event;
}
one<K extends string & keyof EventMapOf<this>>(type: K, handler: EventHandler<EventMapOf<this>[K] & Event<this>>) {
this.events.one(type, handler);
}
on<K extends string & keyof EventMapOf<this>>(type: K, handler: EventHandler<(EventMapOf<this>)[K] & Event<this>>): EventListener {
return this.events.on(type, handler);
}
off<K extends string & keyof EventMapOf<this>>(type: K, handler?: EventHandler<EventMapOf<this>[K]>) {
this.events.off(type, handler);
}
}
export type NodeType = EnumObject<typeof Page.NodeType>;
export type MenuOwner = Widget & { menus: Menu[]; setMenus: (menus: ObjectOrChildModel<Menu>[]) => void };