@eclipse-scout/core
Version:
Eclipse Scout runtime
771 lines (681 loc) • 25.2 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 {
arrays, BaseDoEntity, BookmarkSupport, BookmarkTableRowIdentifierDo, ButtonTile, ChildModelOf, Column, comparators, Constructor, dataObjects, DoTypeResolver, EnumObject, Event, EventHandler, EventListener, EventMapOf, EventModel,
EventSupport, Form, HtmlComponent, InitModelOf, inspector, Menu, MenuBar, MenuOwner, ObjectIdProvider, ObjectOrType, ObjectWithUuid, Outline, PageDetailMenuContributor, PageEventMap, PageModel, ParentTablePageMenuContributor,
PropertyChangeEvent, RequiredUnlessNotSubclass, scout, SomeRequired, strings, Table, TableRow, TableRowClickEvent, TileOutlineOverview, TileOverviewForm, TreeNode, typeName, UuidPathOptions, 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, ObjectWithUuid {
declare initModel: SomeRequired<this['model'], 'parent'> & PageParamRequiredIfDeclared<this>;
declare model: PageModel;
declare eventMap: PageEventMap;
declare self: Page;
declare parent: Outline;
declare childNodes: Page[];
declare parentNode: Page;
uuid: string;
pageParam: PageParamDo;
/**
* 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;
detailMenuContributors: PageDetailMenuContributor[];
row: TableRow;
tile: ButtonTile;
events: EventSupport;
pageChanging: number;
userPreferenceContext: string;
// Inspector infos (are only available for remote pages)
modelClass: string;
classId: string;
protected _tableFilterHandler: EventHandler<Event<Table>>;
protected _tableRowClickHandler: EventHandler<TableRowClickEvent>;
protected _detailTableModel: ChildModelOf<Table>;
/** @internal */
_detailFormModel: ChildModelOf<Form>;
protected _detailMenusChangeHandler: (event: Event<MenuOwner>) => void;
constructor() {
super();
this.uuid = null;
this.pageParam = null;
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.modelClass = null;
this.classId = null;
this.tableStatusVisible = true;
this.drillDownOnRowClick = false;
this.overviewIconId = null;
this.showTileOverview = false;
this.inheritMenusFromParentTablePage = true;
this.detailMenuContributors = [];
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._detailMenusChangeHandler = 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);
this._resolveIconIds(['overviewIconId']);
this._setPageParam(this.pageParam);
let detailMenuContributors = this._createDetailMenuContributors();
if (model.detailMenuContributors) {
detailMenuContributors = [...detailMenuContributors, ...model.detailMenuContributors];
}
this._setDetailMenuContributors(detailMenuContributors);
// init necessary if the properties are still available (e.g. Scout classic)
this._internalInitTable();
this._internalInitDetailForm();
} finally {
this.setPageChanging(false);
}
}
/**
* Writes the static model to the page instance and initializes the {@link pageParam}.
* This allows the {@link PageResolver} to find the correct page without having to initialize it completely.
*
* **Important:** Always use {@link scout.create} to create and initialize page instances. This method is *only* intended to be used for page resolving!
*/
minimalInit() {
let staticModel = this._jsonModel();
Object.assign(this, staticModel);
this._setPageParam(this.pageParam);
}
buildUuid(useFallback?: boolean): string {
return ObjectIdProvider.get().uuid(this, useFallback);
}
buildUuidPath(options?: UuidPathOptions): string {
return ObjectIdProvider.get().uuidPath(this, options);
}
setUuid(uuid: string) {
this.uuid = uuid;
}
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();
// DetailTable/Form may still be the model objects and not the real ones if minimalInit() was called instead of the regular init()
if (this.detailTable instanceof Table) {
this.detailTable.destroy();
this.detailTable = null;
}
if (this.detailForm instanceof Form) {
this.detailForm.destroy();
this.detailForm = null;
}
this.trigger('destroy');
}
setOverviewIconId(overviewIconId: string) {
this.overviewIconId = overviewIconId;
}
protected _createDetailMenuContributors(): ObjectOrType<PageDetailMenuContributor>[] {
return [ParentTablePageMenuContributor];
}
protected _setDetailMenuContributors(contributors: ObjectOrType<PageDetailMenuContributor>[]) {
this.detailMenuContributors = (contributors || []).map(contributor => {
contributor = scout.ensure(contributor);
contributor.setPage(this);
return contributor;
});
}
protected _internalInitTable() {
let tableModel = this.detailTable;
if (tableModel) {
// this case is used for Scout classic
let newDetailTable = this.outline._createChild(tableModel);
this._setDetailTable(newDetailTable);
}
}
protected _internalInitDetailForm() {
let formModel = this.detailForm;
if (formModel) {
let newDetailForm = this.outline._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.outline._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.outline._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._updateDetailFormMenus();
}
if (form instanceof TileOverviewForm) {
form.setPage(this);
}
}
protected _updateDetailMenus() {
this._updateDetailFormMenus();
this._updateDetailTableMenus();
}
protected _updateDetailFormMenus() {
this._updateDetailMenusForMenuOwner(this.detailForm?.rootGroupBox);
}
protected _updateDetailTableMenus() {
this._updateDetailMenusForMenuOwner(this.detailTable);
}
protected _updateDetailMenusForMenuOwner(menuOwner: MenuOwner) {
if (!menuOwner) {
return;
}
menuOwner.off('propertyChange:menus', this._detailMenusChangeHandler);
// This function may be called multiple times -> Remove already contributed menus first to ensure they are not added multiple times
let menus = menuOwner.menus.filter((menu: ContributedMenu) => !menu.__contributed);
let adaptedMenus = menus;
for (const contributor of this.detailMenuContributors) {
adaptedMenus = contributor.contribute(adaptedMenus, menuOwner);
}
// Mark all new menus as contributed
arrays.diff(adaptedMenus, menus).forEach((newMenu: ContributedMenu) => {
newMenu.__contributed = true;
});
menuOwner.setMenus(adaptedMenus);
menuOwner.on('propertyChange:menus', this._detailMenusChangeHandler);
}
protected _onMenuOwnerMenusChange(event: Event<MenuOwner>) {
this._updateDetailMenusForMenuOwner(event.source);
}
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.outline) {
// 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.uuidParent = this;
table.userPreferenceContext = this.userPreferenceContext;
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._updateDetailTableMenus();
}
/**
* 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.outline) {
// 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;
}
inspector.applyInfo(this, this.$node);
this.$node.toggleClass('compact-root', this.compactRoot);
this.$node.toggleClass('has-tile-overview', this.showTileOverview ||
(this.compactRoot && this.outline.detailContent instanceof TileOutlineOverview));
}
// see Java: AbstractPage#pageActivatedNotify
activate() {
this.ensureDetailTable();
this.ensureDetailForm();
}
// see Java: AbstractPage#pageDeactivatedNotify
deactivate() {
// NOP
}
/**
* @deprecated use {@link outline} instead
*/
getOutline(): Outline {
return this.parent;
}
/**
* @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.
*/
get outline(): 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.outline.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.outline.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;
const summaryColumns = page._computeSummaryColumns(row);
page.text = page.computeTextForRow(row, summaryColumns);
// get properties from cell of first summary column
const firstSummaryColumn = summaryColumns[0];
if (firstSummaryColumn) {
const cell = firstSummaryColumn.cell(row);
page.htmlEnabled = cell.htmlEnabled;
page.cssClass = cell.cssClass;
page.iconId = cell.iconId || row.iconId;
}
return page;
}
/**
* This function creates the text property of this page. The default implementation returns {@link Column#cellText} of all summary columns.
*/
computeTextForRow(row: TableRow, summaryColumns?: Column[]): string {
if (!summaryColumns) {
summaryColumns = this._computeSummaryColumns(row);
}
return strings.join(' ', ...summaryColumns.map(summaryColumn => summaryColumn.cellText(row)));
}
/**
* Computes the summary columns of the given {@link TableRow}. The summary columns are
* <ol>
* <li> the {@link Table#compactColumn} if it exists and the {@link Outline} is compact
* <li> the {@link Table#summaryColumns}
* <li> the first visible column
* </ol>
*/
protected _computeSummaryColumns(row: TableRow): Column<any>[] {
if (!row) {
return [];
}
const table = row.table;
// use compact column if outline is compact
if (this.outline.compact && table.compactColumn) {
return [table.compactColumn];
}
const summaryColumns = table.summaryColumns();
if (summaryColumns.length) {
return summaryColumns;
}
// find the first visible column considering the originally defined column order (ignoring the column order changes the user did)
const columns = table.visibleColumns(false, true);
columns.sort((c1, c2) => comparators.NUMERIC.compare(c1.index, c2.index));
return columns.slice(0, 1);
}
/**
* Returns the `text` property of this page as plain text.
*/
getDisplayText(): string {
if (this.htmlEnabled) {
return strings.plainText(this.text);
}
return this.text;
}
/**
* @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.
* @deprecated either add the parent by yourself or return a page model instead of a page when creating child pages
*/
protected _pageParam<T extends object>(paramProperties?: T): T & { parent: Outline } {
let param = {
parent: this.outline
};
$.extend(param, paramProperties);
return param as T & { parent: Outline };
}
reloadPage() {
let outline = this.outline;
if (outline) {
this.loadChildren();
}
}
linkWithRow(row: TableRow) {
this.row = row;
row.page = this;
this.outline.trigger('pageRowLink', {
page: this,
row: row
});
}
unlinkWithRow(row: TableRow) {
delete this.row;
delete row.page;
}
protected _onTableFilter(event: Event<Table>) {
this.outline.mediator.onTableFilter(event, this);
}
protected _onTableRowClick(event: TableRowClickEvent) {
if (!this.drillDownOnRowClick) {
return;
}
if (this.leaf) {
return;
}
this.outline.drillDown(event.row.page);
event.source.deselectRow(event.row);
}
matchesPageParam(pageParam: PageParamDo): boolean {
return BookmarkSupport.get(this.session).pageParamsMatch(this.pageParam, pageParam);
}
protected _setPageParam(pageParam: PageParamDo) {
if (pageParam) {
scout.assertInstance(pageParam, PageParamDo);
} else {
pageParam = this._computeDummyPageParam();
}
this.pageParam = pageParam;
}
protected _computeDummyPageParam(): PageParamDo {
let pageId = this.buildUuid();
if (pageId) {
return scout.create(PageIdDummyPageParamDo, {pageId});
}
return null;
}
/**
* Returns an identifier for the given row that can be stored in a bookmark or used to find the same row again when the
* bookmark is activated. Usually, it consists of all primary key values.
*
* By default, all components of a row identifier have to be persistable. If one of the primary keys is of an unsupported
* type, an error is thrown. To return a (non-persistable) {@link BookmarkTableRowIdentifierObjectComponentDo} instead,
* set the optional argument `allowObjectFallback` to `true`.
*
* This method can also return `null`. In that case, the child page is identified by its page param.
*/
getTableRowIdentifier(row: TableRow, allowObjectFallback = false): BookmarkTableRowIdentifierDo {
return null;
}
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>;
interface ContributedMenu extends Menu {
/**
* @returns true if the menu was contributed which means the original menu list did not contain it.
*/
__contributed?: boolean;
}
/**
* A page param contains all parameters that are required to create a {@link Page}.
* Being able to create a page in a generic way is essential when bookmarks are used.
*
* If the application uses bookmarks then each page needs to provide its own page param that inherits from this base class.
* If the page does not have any parameters, the page param can be omitted.
*
* If the application does not use bookmarks, page params are not required.
*
* @see BookmarkSupport
*/
export class PageParamDo extends BaseDoEntity {
}
/**
* Default page param that is used by bookmarks to identify pages that do not provide a {@link PageParamDo}.
* It stores the page's ID so it can be found again when activating the bookmark.
*/
export class PageIdDummyPageParamDo extends PageParamDo {
pageId: string;
}
/**
* If a specific {@link PageParamDo} exists on server side but there is no equivalent on JS side, a {@link BaseDoEntity} would be created.
* This resolver ensures a real {@link PageParamDo} instance will be created instead of a {@link BaseDoEntity}
* whenever a data object is deserialized whose type ends with 'PageParam', if there is no explicit page param found.
*
* This guarantees all page params are actual instances of {@link PageParamDo}.
*/
export class PageParamDoTypeResolver implements DoTypeResolver {
resolve(rawObj: Record<string, any>): Constructor<BaseDoEntity> {
if (rawObj?._type?.endsWith('PageParam')) {
return PageParamDo;
}
return null;
}
}
dataObjects.doTypeResolvers.push(new PageParamDoTypeResolver());
/**
* Makes the pageParam of the given page required if the page declares a concrete page param (= a subclass of {@link PageParamDo}).
*/
export type PageParamRequiredIfDeclared<TPage extends Page> = RequiredUnlessNotSubclass<TPage, 'pageParam', PageParamDo>;