UNPKG

@eclipse-scout/core

Version:
582 lines (508 loc) 23.4 kB
/* * Copyright (c) 2010, 2025 BSI Business Systems Integration AG * * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0 * which is available at https://www.eclipse.org/legal/epl-2.0/ * * SPDX-License-Identifier: EPL-2.0 */ import { App, arrays, BaseDoEntity, BookmarkDo, BookmarkDoBuilder, BookmarkDoBuilderModel, BookmarkSupportModel, BookmarkTableRowIdentifierDo, BookmarkTableRowIdentifierDoFactory, ChartTableControlConfigHelper, Constructor, Desktop, IBookmarkDo, IBookmarkPageDo, InitModelOf, MaxRowCountContributionDo, MessageBoxes, NodeBookmarkPageDo, objects, ObjectWithType, Outline, OutlineBookmarkDefinitionDo, Page, PageParamDo, PageWithNodes, PageWithTable, scout, Session, Status, TableBookmarkPageDo, TableRow, TableUiPreferences, tableUiPreferences } from '../index'; import $ from 'jquery'; export class BookmarkSupport implements ObjectWithType, BookmarkSupportModel { declare model: BookmarkSupportModel; protected static _INSTANCES: Map<Session, BookmarkSupport> = new Map(); static ERROR_ALREADY_LOADING = 'already-loading'; static ERROR_WRONG_DEFINITION_TYPE = 'wrong-definition-type'; static ERROR_OUTLINE_NOT_FOUND = 'outline-not-found'; static ERROR_PAGE_NOT_FOUND = 'page-not-found'; static ERROR_PAGE_WRONG_OUTLINE = 'page-wrong-outline'; objectType: string; desktop: Desktop; loading: boolean; // -------------------------------------- /** * Returns an instance of {@link BookmarkSupport} for the given {@link Session}. If no instance is registered * for the session yet, a new instance is created. * * @param session Session object providing a desktop. If this is omitted, the first session of the app is used. * If the app does not have any active sessions (e.g. during unit testing), this argument is mandatory. */ static get(session?: Session): BookmarkSupport { session = session || App.get().sessions[0]; scout.assertParameter('session', session); return objects.getOrSetIfAbsent(BookmarkSupport._INSTANCES, session, session => scout.create(BookmarkSupport, {desktop: session.desktop})); } // -------------------------------------- constructor() { this.desktop = null; this.loading = false; } init(model: InitModelOf<this>) { Object.assign(this, model); scout.assertValue(this.desktop); } get session(): Session { return this.desktop.session; } setLoading(loading: boolean) { this.loading = loading; this.desktop.setBusy(this.loading); } resolveOutline(outlineId: string) { return this.desktop.getOutlines().find(outline => { let id = outline.buildUuid(); return id === outlineId; }); } // -------------------------------------- /** * Returns `true` if the given page params are equivalent. Unlike {@link BaseDoEntity#equals}, this method * ignores certain data object contributions that are considered to be irrelevant when identifying pages * (e.g. {@link MaxRowCountContributionDo}). */ pageParamsMatch(pageParam1: PageParamDo, pageParam2: PageParamDo) { if (!pageParam1 && !pageParam2) { return true; } if (!pageParam1 || !pageParam2) { return false; } pageParam1 = this._normalizePageParam(pageParam1); pageParam2 = this._normalizePageParam(pageParam2); return pageParam1.equals(pageParam2); } protected _normalizePageParam(pageParam: PageParamDo): PageParamDo { pageParam = pageParam.clone(); for (const contribution of this._getIgnoredContributionClassesForPageParamComparison()) { pageParam.removeContribution(contribution); } return pageParam; } /** * @returns contributions that may be added to page params but are irrelevant when comparing page params */ protected _getIgnoredContributionClassesForPageParamComparison(): Constructor<BaseDoEntity>[] { return [MaxRowCountContributionDo]; } /** * 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. */ createTableRowIdentifier(page: PageWithTable, row: TableRow, allowObjectFallback = false): BookmarkTableRowIdentifierDo { return scout.create(BookmarkTableRowIdentifierDoFactory).createTableRowIdentifier(page, row, allowObjectFallback); } // -------------------------------------- /** * Creates a new bookmark for the specified page, or the {@link Outline#activePage active page} of the {@link Desktop#outline current outline} * if no explicit page is specified. By default, {@link CreateBookmarkParam#createTableRowSelections} is set to `false`. * * @param param Optional parameters to {@link BookmarkDoBuilder}, can be used to override the defaults. * @param options Optional settings to change the behavior of this method. */ createBookmark(param?: CreateBookmarkParam, options?: CreateBookmarkOptions): JQuery.Promise<IBookmarkDo> { let builder = scout.create(BookmarkDoBuilder, $.extend({ desktop: this.desktop, createTableRowSelections: false }, param)); return builder.build() .catch(error => { if (scout.nvl(options?.handleErrors, true)) { return this.handleCreateBookmarkError(error); } throw error; }); } /** * Creates a bookmark for the given page to be used to refresh an outline. It differs from {@link createBookmark} * in that the resulting bookmark can contain non-bookmarkable pages and non-serializable data. Additionally, * title and description are not returned. */ createBookmarkForRefresh(param?: CreateBookmarkParam, options?: CreateBookmarkOptions): JQuery.Promise<IBookmarkDo> { return this.createBookmark($.extend({ fallbackAllowed: false, persistableRequired: false, createTitle: false, createDescription: false, createTablePreferences: false }, param), options); } /** * Returns the implementation-specific identifier for the given bookmark, or `undefined` if the bookmark does not * have a recognizable identifier. */ getBookmarkId(bookmark: IBookmarkDo): string { if (bookmark instanceof BookmarkDo) { return bookmark.id; } return undefined; } // -------------------------------------- /** * Navigates to the original location of the given bookmark. * * @param options Optional settings to change the behavior of this method */ activateBookmark(bookmark: IBookmarkDo, options?: ActivateBookmarkOptions): JQuery.Promise<void> { return $.when(this._activateBookmarkAsync(bookmark, options)); } // Native-promise version of activateBookmark() protected async _activateBookmarkAsync(bookmark: IBookmarkDo, options?: ActivateBookmarkOptions): Promise<void> { try { if (!(bookmark?.definition instanceof OutlineBookmarkDefinitionDo)) { // noinspection ExceptionCaughtLocallyJS throw BookmarkSupport.ERROR_WRONG_DEFINITION_TYPE; } if (this.loading) { // noinspection ExceptionCaughtLocallyJS throw BookmarkSupport.ERROR_ALREADY_LOADING; } this.setLoading(true); try { await this._activateOutlineBookmarkDefinition(bookmark.definition, options); } finally { this.setLoading(false); } } catch (error) { if (scout.nvl(options?.handleErrors, true)) { return this.handleActivateBookmarkError(error); } throw error; } } protected async _activateOutlineBookmarkDefinition(bookmarkDefinition: OutlineBookmarkDefinitionDo, options: ActivateBookmarkOptions): Promise<void> { let outline = this.resolveOutline(bookmarkDefinition.outlineId); let pagePath = bookmarkDefinition.pagePath || []; let bookmarkedPage = bookmarkDefinition.bookmarkedPage; let bookmarkPath = bookmarkedPage ? [...pagePath, bookmarkedPage] : null; await this._activateBookmarkPath({ parentOutline: outline, pagePath: bookmarkPath }, options); } /** * Navigates to the page specified by the given page path, starting from the given parent page or outline. Every page on the * way is resolved and populated according to the bookmark page, but only the last page is selected at the end. * * @param param Specifies the starting point and the page path to activate from there * @param options Optional settings to change the behavior of this method */ activateBookmarkPath(param: ActivateBookmarkPathParam, options?: ActivateBookmarkOptions): JQuery.Promise<void> { return $.when(this._activateBookmarkPathAsync(param, options)); } // Native-promise version of activateBookmarkPath() protected async _activateBookmarkPathAsync(param: ActivateBookmarkPathParam, options?: ActivateBookmarkOptions): Promise<void> { try { if (this.loading) { // noinspection ExceptionCaughtLocallyJS throw BookmarkSupport.ERROR_ALREADY_LOADING; } this.setLoading(true); try { await this._activateBookmarkPath(param, options); } finally { this.setLoading(false); } } catch (error) { if (scout.nvl(options?.handleErrors, true)) { return this.handleActivateBookmarkError(error); } throw error; } } protected async _activateBookmarkPath(param: ActivateBookmarkPathParam, options?: ActivateBookmarkOptions): Promise<void> { // Check if we are already on the correct outline let outline = param.parentOutline || param.parentPage?.outline; if (!outline || !outline.visible || !outline.enabled) { throw BookmarkSupport.ERROR_OUTLINE_NOT_FOUND; } if (scout.nvl(options?.activateOutline, true)) { this.desktop.setOutline(outline); this.desktop.bringOutlineToFront(); } if (param.parentPage && param.parentPage.outline !== outline) { throw BookmarkSupport.ERROR_PAGE_WRONG_OUTLINE; } let parentPage = param.parentPage; let parentBookmarkPage = param.parentBookmarkPage; // Check if the currently selected page is a child of 'parentPage'. If yes, change the current selection to parentPage. // Otherwise, after reloading the parent page, the selection would be restored (see PageWithTable#restoreSelection). // This would cause the child page to be loaded _before_ the search data from the bookmark has been applied, resulting // in the wrong data being shown. // TODO bsh [js-bookmark] This workaround has a negative side-effect in that the parent page is visible for a short moment. Is there a better solution? if (parentPage) { let currentPage = outline.selectedNode(); while (currentPage) { if (currentPage === parentPage) { outline.selectNode(parentPage); } currentPage = currentPage.parentNode; } } if (parentPage && parentBookmarkPage) { this._applyBookmarkPage(parentPage, parentBookmarkPage, false); } if (arrays.empty(param.pagePath)) { this._revealPage(parentPage); return; // done! } let pagePath = param.pagePath.slice(); // create copy because array is altered while (arrays.hasElements(pagePath)) { let bookmarkPage = pagePath[0]; let page = await this._resolvePage(outline, parentPage, parentBookmarkPage, bookmarkPage, options); if (!page) { break; // no child page found matching the given bookmarkPage } await this._applyBookmarkPageAndReload(page, bookmarkPage, false); parentPage = page; parentBookmarkPage = bookmarkPage; pagePath.shift(); } if (!parentPage) { throw BookmarkSupport.ERROR_PAGE_NOT_FOUND; } this._revealPage(parentPage); if (arrays.hasElements(pagePath) && scout.nvl(options?.resetViewAndWarnOnFail, true)) { // Path not fully restored parentPage.detailTable.setTableStatus(Status.error(this.session.text('BookmarkResolutionCanceled'))); } } protected async _resolvePage(outline: Outline, parentPage: Page, parentBookmarkPage: IBookmarkPageDo, bookmarkPage: IBookmarkPageDo, options?: ActivateBookmarkOptions): Promise<Page> { if (!parentPage) { // Lookup top-level page by page param return outline.nodes.find(node => node.matchesPageParam(bookmarkPage.pageParam)); } await parentPage.ensureLoadChildren(); // If the bookmark contains a row identifier, try to find the corresponding row if (parentPage instanceof PageWithTable && parentBookmarkPage instanceof TableBookmarkPageDo && parentBookmarkPage.expandedChildRow) { let row = parentPage.detailTable.rows.find(row => { let rowIdentifier = parentPage.getTableRowIdentifier(row); return objects.equals(rowIdentifier, parentBookmarkPage.expandedChildRow); }); // If we found the row, but it is currently filtered by the parent table, remove the filter and try again. // If the row is still not accepted, the filter is apparently a non-user filter which cannot be removed -> assume page not found. if (row && !row.filterAccepted && parentPage.detailTable.hasUserFilter() && scout.nvl(options?.resetViewAndWarnOnFail, true)) { parentPage.detailTable.resetUserFilter(); parentPage.detailTable.setTableStatus(Status.warning(this.session.text('BookmarkResetColumnFilters'))); if (!row.filterAccepted) { return null; // still filtered -> not found } } if (row) { return row.page; } } // For all other cases, identify the child page by page param (works for both PageWithNodes and PageWithTable). return parentPage.childNodes.find(node => node.matchesPageParam(bookmarkPage.pageParam)); } protected _revealPage(page: Page) { if (!page) { return; } let outline = page.outline; // expand restored path, expand the target page if it is not a table page let expandLeaf = page.nodeType !== Page.NodeType.TABLE; this._expandPath(page, expandLeaf); outline.deselectAll(); // reselection triggers owner changes of menu in case we come here by execDataChanged outline.selectNode(page); outline.revealSelection(); } protected _expandPath(page: Page, expandLeaf: boolean) { let outline = page.outline; if (expandLeaf) { outline.expandNode(page, {renderAnimated: false}); } let nodeToExpand = page.parentNode; while (nodeToExpand) { outline.expandNode(nodeToExpand, {renderAnimated: false}); nodeToExpand = nodeToExpand.parentNode; } } /** * Handles errors that occurred during bookmark creation. */ handleCreateBookmarkError(error: any): JQuery.Promise<any> { if (scout.isOneOf(error, BookmarkDoBuilder.ERROR_MISSING_OUTLINE, BookmarkDoBuilder.ERROR_MISSING_PAGE_PARAM, BookmarkDoBuilder.ERROR_PAGE_NOT_BOOKMARKABLE, BookmarkDoBuilder.ERROR_PAGE_PATH_NOT_BOOKMARKABLE, BookmarkDoBuilder.ERROR_MISSING_ROW_BOOKMARK_IDENTIFIER )) { return MessageBoxes.openOk(this.desktop, this.session.text('CannotCreateBookmarkAtThisLocation'), Status.Severity.ERROR); } return App.get().errorHandler.handle(error); } /** * Handles errors that occurred during bookmark activation. */ handleActivateBookmarkError(error: any): JQuery.Promise<any> { if (error === BookmarkSupport.ERROR_ALREADY_LOADING) { $.log.error('Another bookmark is currently loading'); return; // ignore silently } if (error === BookmarkSupport.ERROR_WRONG_DEFINITION_TYPE) { return MessageBoxes.openOk(this.desktop, this.session.text('BookmarkWrongDefinitionType'), Status.Severity.ERROR); } if (error === BookmarkSupport.ERROR_OUTLINE_NOT_FOUND) { return MessageBoxes.openOk(this.desktop, this.session.text('BookmarkOutlineNotFound'), Status.Severity.ERROR); } if (error === BookmarkSupport.ERROR_PAGE_NOT_FOUND) { return MessageBoxes.openOk(this.desktop, this.session.text('BookmarkResolvingFailed'), Status.Severity.ERROR); } return App.get().errorHandler.handle(error); } // -------------------------------------- /** * Adapts the given target page with the information from the given bookmark. * Useful when bookmarked page is opened inline, i.e. in bookmark outline. * * @param saveState Specifies whether the new state of the page (table configuration, search form) should be the * saved state, i.e. resetting to factory settings should revert the page to the state from the bookmark rather * than to the original state. The default value is `true`. */ applyBookmarkToPage(page: Page, bookmark: IBookmarkDo, saveState = true) { if (!page || !bookmark || !bookmark.definition) { return; } let bookmarkPage = bookmark.definition.bookmarkedPage; this._applyBookmarkPage(page, bookmarkPage, saveState); } /** * Same as {@link applyBookmarkToPage}, but also reloads the page. The returned promise is not resolved until the * reload is done. */ applyBookmarkToPageAndReload(page: Page, bookmark: IBookmarkDo, saveState = true): JQuery.Promise<void> { return $.when(this._applyBookmarkToPageAndReloadAsync(page, bookmark, saveState)); } // Native-promise version of applyBookmarkToPageAndReload() protected async _applyBookmarkToPageAndReloadAsync(page: Page, bookmark: IBookmarkDo, saveState = true): Promise<void> { if (!page || !bookmark || !bookmark.definition) { return; } let bookmarkPage = bookmark.definition.bookmarkedPage; await this._applyBookmarkPageAndReload(page, bookmarkPage, saveState); } protected async _applyBookmarkPageAndReload(page: Page, bookmarkPage: IBookmarkPageDo, saveState = true): Promise<void> { this._applyBookmarkPage(page, bookmarkPage, saveState); await page.ensureLoadChildren(); if (page instanceof PageWithTable && bookmarkPage instanceof TableBookmarkPageDo) { this._restoreSelection(page, bookmarkPage.selectedChildRows); } } protected _applyBookmarkPage(page: Page, bookmarkPage: IBookmarkPageDo, saveState = true) { if (page instanceof PageWithTable && bookmarkPage instanceof TableBookmarkPageDo) { this._applyBookmarkToTablePage(page, bookmarkPage, saveState); } else if (page instanceof PageWithNodes && bookmarkPage instanceof NodeBookmarkPageDo) { this._applyBookmarkToNodePage(page, bookmarkPage); } } protected _applyBookmarkToTablePage(page: PageWithTable, bookmarkPage: TableBookmarkPageDo, saveState = true) { this._prepareTablePage(page, bookmarkPage, saveState); } protected _applyBookmarkToNodePage(page: PageWithNodes, bookmarkPage: NodeBookmarkPageDo) { // hook-method provided for subclasses } protected _prepareTablePage(page: PageWithTable, bookmarkPage: TableBookmarkPageDo, saveState = true) { page.ensureDetailTable(); this._prepareTablePreferences(page, bookmarkPage, saveState); this._prepareSearchFilter(page, bookmarkPage, saveState); this._prepareChartTableControlState(page, bookmarkPage); } protected _prepareTablePreferences(page: PageWithTable, bookmarkPage: TableBookmarkPageDo, saveState: boolean) { const table = page.detailTable; const prefs = bookmarkPage.tablePreferences; const profile = tableUiPreferences.getProfile(prefs, TableUiPreferences.PROFILE_ID_BOOKMARK); tableUiPreferences.apply(table, prefs); tableUiPreferences.applyProfile(table, profile, {applyUserFilters: true}); if (saveState) { // Set bookmarked profile as default preferences (no need to store the GLOBAL setting, but "reset to factory settings" should still be possible) table.setInitialUiPreferences(profile || tableUiPreferences.createProfile(table)); } else { // Store applied settings as GLOBAL setting tableUiPreferences.storeGlobalProfile(table); } } protected _prepareSearchFilter(page: PageWithTable, bookmarkPage: TableBookmarkPageDo, saveState: boolean) { // Import search data page.setSearchFilter(bookmarkPage.searchData, saveState); // Mark page as dirty so ensureChildrenLoaded() will reload the data page.childrenLoaded = false; } protected _prepareChartTableControlState(page: PageWithTable, bookmarkPage: TableBookmarkPageDo) { const helper = scout.create(ChartTableControlConfigHelper); helper.importConfig(page, bookmarkPage.chartTableControlConfig); } protected _restoreSelection(page: PageWithTable, selectedRowIdentifiers: BookmarkTableRowIdentifierDo[]) { page.ensureDetailTable(); let table = page.detailTable; if (!table) { return; } let selectedRows: TableRow[] = []; if (arrays.hasElements(selectedRowIdentifiers)) { selectedRows = table.rows.filter(row => { if (!row.filterAccepted) { return false; // row must not be filtered out } let rowIdentifier = page.getTableRowIdentifier(row); return selectedRowIdentifiers.some(selectedRowIdentifier => objects.equals(selectedRowIdentifier, rowIdentifier)); }); } table.expandParentRows(selectedRows); table.selectRows(selectedRows); } } export type CreateBookmarkParam = Omit<BookmarkDoBuilderModel, 'desktop'>; export interface CreateBookmarkOptions { /** * Specifies whether runtime errors should be handled (e.g. by showing a message). The promise will still be rejected. * The default value is `true`. */ handleErrors?: boolean; } export interface ActivateBookmarkPathParam { /** * The outline where to activate the page path. If this is omitted, the desktop's active outline is used instead. */ parentOutline?: Outline; /** * The page where to start activating the page path. If this is omitted, the process starts at the outline root. */ parentPage?: Page; /** * Optional bookmark information for the specified parent page. If set, this information is applied to parent page. */ parentBookmarkPage?: IBookmarkPageDo; /** * The list of path elements to activate, beginning at the specified starting point. The element is processed * from left to right, i.e. the last entry corresponds to the page that will be selected at the end. */ pagePath?: IBookmarkPageDo[]; } export interface ActivateBookmarkOptions { /** * Specifies whether the target outline should be activated. The default value is `true`. */ activateOutline?: boolean; /** * If `true`, the user is warned when the bookmark could not be opened. Useful when a persisted bookmark is activated. * The default value is `true`. */ resetViewAndWarnOnFail?: boolean; /** * Specifies whether runtime errors should be handled (e.g. by showing a message). The promise will still be rejected. * The default value is `true`. */ handleErrors?: boolean; }