UNPKG

@eclipse-scout/core

Version:
1,591 lines (1,415 loc) 55.7 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 { AbortKeyStroke, App, aria, AriaLabelledByInsertPosition, arrays, BusyIndicatorOptions, Button, ButtonSystemType, DialogLayout, DisabledStyle, DisplayParent, DisplayViewId, EnumObject, ErrorHandler, Event, EventHandler, FileChooser, FileChooserController, FocusRule, FormController, FormEventMap, FormGrid, FormInvalidEvent, FormLayout, FormLifecycle, FormModel, FormRevealInvalidFieldEvent, GlassPaneRenderer, GroupBox, HtmlComponent, InitModelOf, KeyStroke, KeyStrokeContext, MessageBox, MessageBoxController, MessageBoxes, NotificationBadgeStatus, ObjectOrChildModel, objects, Point, PopupWindow, PropertyChangeEvent, Rectangle, scout, Status, StatusOrModel, strings, tooltips, TreeVisitResult, ValidationResult, webstorage, Widget, WrappedFormField } from '../index'; import $ from 'jquery'; export type DisplayHint = EnumObject<typeof Form.DisplayHint>; export class Form extends Widget implements FormModel, DisplayParent { declare model: FormModel; declare eventMap: FormEventMap; declare self: Form; animateOpening: boolean; askIfNeedSave: boolean; validationFailedText: string; emptyMandatoryElementsText: string; invalidElementsErrorText: string; invalidElementsWarningText: string; askIfNeedSaveText: string; data: any; exclusiveKey: () => any; displayViewId: DisplayViewId; displayHint: DisplayHint; maximized: boolean; headerVisible: boolean; displayParent: DisplayParent; dialogs: Form[]; views: Form[]; messageBoxes: MessageBox[]; fileChoosers: FileChooser[]; focusedElement: Widget; closable: boolean; cacheBounds: boolean; cacheBoundsKey: string; resizable: boolean; movable: boolean; rootGroupBox: GroupBox; /** * Indicates whether the form needs to be saved. * If set to true, a marker will be visible in the form header or tab, unless {@link saveNeededVisible} is set to false. * * The value will be computed by {@link updateSaveNeeded}. */ saveNeeded: boolean; saveNeededVisible: boolean; formController: FormController; messageBoxController: MessageBoxController; fileChooserController: FileChooserController; closeKeyStroke: KeyStroke; showOnOpen: boolean; initialFocus: Widget; renderInitialFocusEnabled: boolean; formLoading: boolean; formLoaded: boolean; /** * true if the form was saved (e.g. by calling {@link ok} or {@link save}) since it was created. */ formSaved: boolean; /** set by PopupWindow if this form has displayHint=Form.DisplayHint.POPUP_WINDOW */ popupWindow: PopupWindow; title: string; subTitle: string; iconId: string; status: Status; uiCssClass: string; lifecycle: FormLifecycle; detailForm: boolean; /** @internal */ blockRendering: boolean; validators: FormValidator[]; protected _defaultValidator: FormValidator; $statusIcons: JQuery[]; $header: JQuery; $statusContainer: JQuery; $close: JQuery; $saveNeeded: JQuery; $icon: JQuery; $title: JQuery; $subTitle: JQuery; $dragHandle: JQuery; protected _modal: boolean; protected _glassPaneRenderer: GlassPaneRenderer; protected _preMaximizedBounds: Rectangle; protected _resizeHandler: (Event) => boolean; protected _windowResizeHandler: () => void; protected _mainBoxSaveNeededChangeHandler: EventHandler<PropertyChangeEvent>; constructor() { super(); this._addWidgetProperties(['rootGroupBox', 'views', 'dialogs', 'initialFocus', 'messageBoxes', 'fileChoosers']); this._addPreserveOnPropertyChangeProperties(['initialFocus']); this._addComputedProperties(['modal']); this.animateOpening = true; this.askIfNeedSave = true; this.validationFailedText = '${textKey:FormValidationFailedTitle}'; this.emptyMandatoryElementsText = null; this.invalidElementsErrorText = null; this.invalidElementsWarningText = null; this.askIfNeedSaveText = null; this.data = {}; this.exclusiveKey = null; this.displayViewId = null; this.displayHint = Form.DisplayHint.DIALOG; this.displayParent = null; // only relevant if form is opened, not relevant if form is just rendered into another widget (not managed by a form controller) this.maximized = false; this.headerVisible = null; this._modal = null; this.logicalGrid = scout.create(FormGrid); this.dialogs = []; this.views = []; this.messageBoxes = []; this.fileChoosers = []; this.focusedElement = null; this.closable = true; this.cacheBounds = false; this.cacheBoundsKey = null; this.resizable = true; this.movable = true; this.rootGroupBox = null; this.saveNeeded = false; this.formSaved = false; this.saveNeededVisible = true; this.formController = null; this.messageBoxController = null; this.fileChooserController = null; this.closeKeyStroke = null; this.showOnOpen = true; this.initialFocus = null; this.renderInitialFocusEnabled = true; this.popupWindow = null; this.title = null; this.subTitle = null; this.iconId = null; this.formLoading = false; this.formLoaded = false; this.validators = []; this._defaultValidator = this._validate.bind(this); this.$statusIcons = []; this.$header = null; this.$statusContainer = null; this.$close = null; this.$saveNeeded = null; this.$icon = null; this.$title = null; this.$subTitle = null; this.$dragHandle = null; this._glassPaneRenderer = null; this._preMaximizedBounds = null; this._resizeHandler = this._onResize.bind(this); this._windowResizeHandler = this._onWindowResize.bind(this); this._mainBoxSaveNeededChangeHandler = () => this.updateSaveNeeded(); } static DisplayHint = { DIALOG: 'dialog', POPUP_WINDOW: 'popupWindow', VIEW: 'view' } as const; protected static _NOTIFICATION_BADGE_STATUS_CODE = 197821; protected override _init(model: InitModelOf<this>) { super._init(model); this.resolveTextKeys(['title', 'validationFailedText', 'emptyMandatoryElementsText', 'invalidElementsErrorText', 'invalidElementsWarningText', 'askIfNeedSaveText']); this.resolveIconIds(['iconId']); this._setDisplayParent(this.displayParent); this._setViews(this.views); this.formController = scout.create(FormController, { displayParent: this, session: this.session }); this.messageBoxController = scout.create(MessageBoxController, { displayParent: this, session: this.session }); this.fileChooserController = scout.create(FileChooserController, { displayParent: this, session: this.session }); this._setRootGroupBox(this.rootGroupBox); this._setStatus(this.status); this.cacheBoundsKey = scout.nvl(model.cacheBoundsKey, this.objectType); this._installLifecycle(); this._setClosable(this.closable); this._setExclusiveKey(this.exclusiveKey); this._setValidators(this.validators); } protected override _createKeyStrokeContext(): KeyStrokeContext { return new KeyStrokeContext(); } protected override _render() { this.$container = this.$parent.appendDiv('form') .data('model', this); if (!(this.parent instanceof WrappedFormField)) { aria.role(this.$container, this.isDialog() || this.isPopupWindow() ? 'dialog' : 'form'); } if (this.uiCssClass) { this.$container.addClass(this.uiCssClass); } this.htmlComp = HtmlComponent.install(this.$container, this.session); let layout; if (this.isDialog()) { this.$container.addClass('dialog'); layout = new DialogLayout(this); this.htmlComp.validateRoot = true; // Attach to capture phase to activate focus context before regular mouse down handlers may set the focus. // E.g. clicking a checkbox label on another dialog executes mouse down handler of the checkbox which will focus the box. This only works if the focus context of the dialog is active. this.$container[0].addEventListener('mousedown', this._onDialogMouseDown.bind(this), true); } else { if (this.isPopupWindow()) { this.$container.addClass('popup-window'); } layout = new FormLayout(this); } this.htmlComp.setLayout(layout); this._renderRootGroupBox(); } protected override _renderProperties() { super._renderProperties(); this._renderMaximized(); this._renderMovable(); this._renderResizable(); this._renderHeaderVisible(); this._renderCssClass(); this._renderModal(); this._installFocusContext(); if (this.renderInitialFocusEnabled) { this.renderInitialFocus(); } } protected override _postRender() { super._postRender(); // Render attached forms, message boxes and file choosers. this.formController.render(); this.messageBoxController.render(); this.fileChooserController.render(); if (this._glassPaneRenderer) { this._glassPaneRenderer.renderGlassPanes(); } } protected override _destroy() { super._destroy(); if (this._glassPaneRenderer) { this._glassPaneRenderer = null; } } protected override _remove() { this.formController.remove(); this.messageBoxController.remove(); this.fileChooserController.remove(); if (this._glassPaneRenderer) { this._glassPaneRenderer.removeGlassPanes(); this._glassPaneRenderer = null; } this._uninstallFocusContext(); this._removeHeader(); this.$dragHandle = null; super._remove(); } /** @see FormModel.modal */ setModal(modal: boolean) { this.setProperty('modal', modal); } get modal(): boolean { return this._modal === null ? this.isDialog() : this._modal; } protected _renderModal() { if (this.parent instanceof WrappedFormField) { return; } let modal = this.modal; aria.modal(this.$container, modal || null); if (modal && !this._glassPaneRenderer) { this._glassPaneRenderer = new GlassPaneRenderer(this); this._glassPaneRenderer.renderGlassPanes(); } else if (!modal && this._glassPaneRenderer) { this._glassPaneRenderer.removeGlassPanes(); this._glassPaneRenderer = null; } } protected _installLifecycle() { this.lifecycle = this._createLifecycle(); this.lifecycle.handle('load', this._onLifecycleLoad.bind(this)); this.lifecycle.handle('save', this._onLifecycleSave.bind(this)); this.lifecycle.on('postLoad', this._onLifecyclePostLoad.bind(this)); this.lifecycle.on('reset', this._onLifecycleReset.bind(this)); this.lifecycle.on('close', this._onLifecycleClose.bind(this)); } protected _createLifecycle(): FormLifecycle { return scout.create(FormLifecycle, { widget: this, askIfNeedSave: this.askIfNeedSave, emptyMandatoryElementsText: this.emptyMandatoryElementsText, invalidElementsErrorText: this.invalidElementsErrorText, invalidElementsWarningText: this.invalidElementsWarningText, askIfNeedSaveText: this.askIfNeedSaveText }); } setExclusiveKey(exclusiveKey: any) { this.setProperty('exclusiveKey', exclusiveKey); } protected _setExclusiveKey(exclusiveKey: any) { let key = exclusiveKey; if (!exclusiveKey) { key = () => null; } else if (typeof exclusiveKey !== 'function') { key = () => exclusiveKey; } this._setProperty('exclusiveKey', key); } /** * Loads the data and renders the form afterward by adding it to the desktop. * * Calling this method is equivalent to calling {@link load} first and once the promise is resolved, calling {@link show}. * * Keep in mind that the form won't be rendered immediately after calling {@link open}. Because promises are always resolved asynchronously, * {@link show} will be called delayed even if {@link load} does nothing but return a resolved promise. * * This is only relevant if you need to access properties which are only available when the form is rendered (e.g. {@link $container}), which is not recommended anyway. */ open(): JQuery.Promise<void> { return this.load(false) .then(() => { if (this.destroyed) { // If form has been closed right after it was opened don't try to show it return; } if (this.isShown()) { this.activate(); } else if (this.showOnOpen) { this.show(); } }); } /** * Initializes the life cycle and calls the {@link _load} function. * Does nothing, if form is still loading (= {@link formLoading} is true). * * @param allowReload controls whether loading should be allowed even if it has already been loaded once (= if {@link formLoaded is true}). * @returns promise which is resolved when the form is loaded. */ load(allowReload = true): JQuery.Promise<void> { if (this.formLoading) { return this.whenLoad().then(() => undefined); } if (!allowReload && this.formLoaded) { return $.resolvedPromise(); } try { return this.withBusyHandling(() => this.lifecycle.load()) .catch(error => this._handleLoadErrorInternal(error)); } catch (error) { return this._handleLoadErrorInternal(error); } } /** * Enables the {@link BusyIndicator} while the given action runs. * * @see setBusy */ withBusyHandling<T>(action: () => JQuery.Promise<T>): JQuery.Promise<T> { this.setBusy(true); try { return action() .always(() => this.setBusy(false)); } catch (error) { this.setBusy(false); throw error; } } /** * @deprecated use {@link withBusyHandling}. */ protected _withBusyHandling<T>(action: () => JQuery.Promise<T>): JQuery.Promise<T> { return this.withBusyHandling(action); } /** * @returns promise which is resolved when the form is loaded, respectively when the 'load' event is triggered. */ whenLoad(): JQuery.Promise<Event<Form>> { return this.when('load'); } /** * Lifecycle handle function registered for 'load'. */ protected _onLifecycleLoad(): JQuery.Promise<void> { try { this._setFormLoading(true); return this._load() .then(data => { if (this.destroyed) { // If form has been closed right after it was opened ignore the load result return; } this.setData(data); this.importData(); this._setFormLoading(false); this.formLoaded = true; this.trigger('load'); }) .always(() => this._setFormLoading(false)); } catch (error) { this._setFormLoading(false); throw error; } } protected _setFormLoading(loading: boolean) { this._setProperty('formLoading', loading); } /** * This function is called when an error occurs in the {@link _onLifecycleLoad} function or when the {@link _load} function returns with a rejected promise. * By default, the error is forwarded to the {@link ErrorHandler}, the form is closed and a rejected promise is returned so a caller of {@link load} may catch the error. */ protected _handleLoadErrorInternal(error: any): JQuery.Promise<void> { return this._handleErrorInternal(error, 'load', error => this._handleLoadError(error)); } /** * Default load error handler. May be overridden by sub-classes. */ protected _handleLoadError(error: any): JQuery.Promise<void> { this.close(); return this._handleError(error); } /** * Method may be implemented to load the data. * By default, a resolved promise containing {@link data} is returned. */ protected _load(): JQuery.Promise<any> { return $.resolvedPromise().then(() => { return this.data; }); } /** * @returns promise which is resolved when the form is post loaded, respectively when the 'postLoad' event is triggered. */ whenPostLoad(): JQuery.Promise<Event<Form>> { return this.when('postLoad'); } protected _onLifecyclePostLoad(): JQuery.Promise<void> { try { return this._postLoad() .then(() => this.trigger('postLoad')) .catch(error => this._handlePostLoadErrorInternal(error)); } catch (error) { return this._handlePostLoadErrorInternal(error); } } /** * This function is called when an error occurs in the {@link _onLifecyclePostLoad} function or when the {@link _postLoad} function returns with a rejected promise. * By default, the error is forwarded to the {@link ErrorHandler} and a rejected promise is returned. */ protected _handlePostLoadErrorInternal(error: any): JQuery.Promise<void> { return this._handleErrorInternal(error, 'postLoad', error => this._handlePostLoadError(error)); } /** * Default postLoad error handler. May be overridden by sub-classes. */ protected _handlePostLoadError(error: any): JQuery.Promise<void> { return this._handleError(error); } protected _postLoad(): JQuery.Promise<void> { return $.resolvedPromise(); } /** @see FormModel.data */ setData(data: any) { this.setProperty('data', data); } /** * Imports the {@link data} to the form. */ importData() { // NOP } /** * Exports the form to {@link data}. */ exportData(): any { return null; } /** * Saves and closes the form. * * **Note:** The resulting promise will be resolved even if the form does not require save and therefore even if {@link _save} is not called. * If you only want to be informed when save is required and {@link _save} executed then you could use {@link whenSave} or `on('save')` instead. * * @returns promise which is resolved when the save completes and rejected on an error. */ ok(): JQuery.Promise<void> { return this.lifecycle.ok(); } /** * Saves the changes without closing the form. * * **Note:** The resulting promise it will be resolved even if the form does not require save and therefore even if {@link _save} is not called. * If you only want to be informed when save is required and {@link _save} executed then you could use {@link whenSave} or `on('save')` instead. * * @returns promise which is resolved when the save completes and rejected on an error. */ save(): JQuery.Promise<void> { return this.lifecycle.save(); } /** * @returns promise which is resolved when the form is saved, respectively when the 'save' event is triggered. */ whenSave(): JQuery.Promise<Event<Form>> { return this.when('save'); } protected _onLifecycleSave(): JQuery.Promise<void> { try { return this.withBusyHandling(() => { let data = this.exportData(); return this._save(data) .then(() => { this.formSaved = true; this.setData(data); this.trigger('save'); }); }).catch(error => this._handleSaveErrorInternal(error)); } catch (error) { return this._handleSaveErrorInternal(error); } } /** * This function is called when an error occurs in {@link _onLifecycleSave} or when {@link _save} returns with a rejected promise. * By default, the error is forwarded to the {@link ErrorHandler} and the promise is rejected so a caller of {@link save} may catch the error. */ protected _handleSaveErrorInternal(error: any): JQuery.Promise<void> { return this._handleErrorInternal(error, 'save', error => this._handleSaveError(error)); } /** * Default save error handler. May be overridden by sub-classes. */ protected _handleSaveError(error: any): JQuery.Promise<void> { return this._handleError(error); // do not close as the user might want to change any value causing the error or just to retry. } protected _handleErrorInternal(error: any, phase: string, errorHandler: (error: any) => JQuery.Promise<void>): JQuery.Promise<void> { const event = this.trigger('error', {phase, error}); let promise; if (event.defaultPrevented) { promise = $.resolvedPromise(); } else { promise = errorHandler(error).catch(errorInErrorHandler => { // prevents that a rejected promise from the error handler overwrites the actual error from the form. $.log.error('Error in error handler while trying to handle error "' + error + '".', errorInErrorHandler); }); } return promise.then(() => { throw error; // always throw so it can be catched. }); } /** * Default error handler for {@link _load}, {@link _save} and {@link _postLoad}. May be overridden by subclasses. * @returns A promise that resolves when the error is handled. */ protected _handleError(error: any): JQuery.Promise<void> { const errorHandler = App.get().errorHandler; return errorHandler .handle(error) // shows a message box with the error .then(errorInfo => undefined); } /** * Validates the form. * * @returns a promise resolved with the validation result as {@link Status}. */ validate(): JQuery.Promise<Status> { return this.lifecycle.validate(); } /** * The function is called every time {@link _lifecycleValidate} is called. The function should be used * to implement an overall validate logic which is not related to a specific field. For instance, you * could validate the state of an internal member variable. * * You should return a {@link Status} object with severity ERROR or WARNING in case the validation fails. */ protected _validate(): Status | JQuery.Promise<Status> { return Status.ok(); } /** * This function is called by the lifecycle, for instance when the 'ok' function is called. * The function is called every time the 'ok' function is called, which means it runs even when * there is not a single touched field. The function should be used to implement an overall validate * logic which is not related to a specific field. For instance, you could validate the state of an * internal member variable. * * Do not override this method, use {@link _validate} instead. */ _lifecycleValidate(): Status | JQuery.Promise<Status> { const combineStatuses = (statuses: Status[]) => { const status = Status.ok(); statuses.forEach(s => status.addStatus(s)); return status; }; // separate statuses from promises const statuses: Status[] = []; const promises: JQuery.Promise<Status>[] = []; for (const statusOrPromise of this.validators.map(validator => validator(this))) { if (objects.isPromise(statusOrPromise)) { promises.push(statusOrPromise); continue; } statuses.push(statusOrPromise); } // return combined status if there are no promises const status = combineStatuses(statuses); if (!promises.length) { return status; } // wait for promises and combine results return $.promiseAll([$.resolvedPromise(status), ...promises]) .then((...statusArr: Status[]) => combineStatuses(statusArr)); } /** @see FormModel.validators */ addValidator(validator: FormValidator) { let validators = this.validators.slice(); validators.push(validator); this.setValidators(validators); } /** @see FormModel.validators */ removeValidator(validator: FormValidator) { let validators = this.validators.slice(); arrays.remove(validators, validator); this.setValidators(validators); } /** @see FormModel.validators */ setValidators(validators: FormValidator | FormValidator[]) { this.setProperty('validators', validators); } protected _setValidators(validators: FormValidator | FormValidator[]) { validators = arrays.ensure(validators).slice(); arrays.pushSet(validators, this._defaultValidator); this._setProperty('validators', validators); } /** * Called when the validation of this form failed. * @param status The {@link Status} that describes why the validation failed. It is always invalid (error or warning). * @internal */ _handleInvalid(status: Status): JQuery.Promise<Status> { const event = this.trigger('invalid', {status}) as FormInvalidEvent; if (event.defaultPrevented) { return $.resolvedPromise(event.status); } return this._showFormInvalidMessageBox(event.status); } protected _showFormInvalidMessageBox(status: Status): JQuery.Promise<Status> { if (!status || status.isValid()) { return $.resolvedPromise(status); } return this._createStatusMessageBox(status).buildAndOpen().then(option => { if (!status.isError() && option === MessageBox.Buttons.YES) { return $.resolvedPromise(Status.ok(status.message)); } return $.resolvedPromise(status); }); } protected _createStatusMessageBox(status: Status): MessageBoxes { let messageBoxes = MessageBoxes.createOk(this) .withSeverity(status.severity) .withHeader(this.validationFailedText) .withBody(status.message, true); if (!status.isError()) { messageBoxes = messageBoxes .withYes(this.session.text('ProceedAnyway')) .withNo(this.session.text('Cancel')); } return messageBoxes; } /** * This function is called by the lifecycle, when {@link save} is called. * * The data given to this function is the result of {@link exportData} which was called in advance. */ protected _save(data: any): JQuery.Promise<void> { return $.resolvedPromise(); } /** * Resets the form to its initial state. */ reset(): JQuery.Promise<void> { return this.lifecycle.reset(); } /** * @returns promise which is resolved when the form is reset, respectively when the 'reset' event is triggered. */ whenReset(): JQuery.Promise<Event<Form>> { return this.when('reset'); } protected _onLifecycleReset() { this.trigger('reset'); } /** * Closes the form if there are no changes made. Otherwise, it shows a message box asking to save the changes. */ cancel(): JQuery.Promise<void> { return this.lifecycle.cancel(); } /** * Closes the form and discards any unsaved changes. */ close(): JQuery.Promise<void> { return this.lifecycle.close(); } /** * @returns promise which is resolved when the form is closed, respectively when the 'close' event is triggered. */ whenClose(): JQuery.Promise<Event<Form>> { return this.when('close'); } protected _onLifecycleClose() { const event = this.trigger('close'); if (!event.defaultPrevented) { this._close(); } } /** * Destroys the form and removes it from the desktop. */ protected _close() { this.hide(); this.destroy(); } /** * This function is called when the user presses the "x" icon. * * It will either call {@link #close()} or {@link #cancel()}, depending on the enabled and visible system buttons, see {@link _abort}. */ abort() { let event = new Event(); this.trigger('abort', event); if (!event.defaultPrevented) { this._abort(); } } /** * @returns promise which is resolved when the form is aborted, respectively when the 'abort' event is triggered. */ whenAbort(): JQuery.Promise<Event<Form>> { return this.when('abort'); } /** * Will call {@link #close()} if there is a close menu or button, otherwise {@link #cancel()} will be called. */ protected _abort() { // Search for a close button in the menus and buttons of the root group box let controls: (Widget & { systemType?: ButtonSystemType })[] = this.rootGroupBox?.controls || []; controls = controls.concat(this.rootGroupBox?.menus || []); let hasCloseButton = controls .filter(control => { let enabled = control.enabled; if (control.enabledComputed !== undefined) { enabled = control.enabledComputed; // Menus don't have enabledComputed, only form fields } return control.visible && enabled && control.systemType; }) .some(control => control.systemType === Button.SystemType.CLOSE); if (hasCloseButton) { this.close(); } else { this.cancel(); } this._afterAbort(); } /** @internal */ _afterAbort() { if (!this.destroyed && this.isShown()) { // If the form is still shown after an abort request, something (e.g. a validation message box) is probably open // -> activate the form to show the validation message // Checking for destroyed would be sufficient for most cases. But maybe a certain form does not really close the form on an abort request but just hides it. This is where isShown comes in. this.activate(); } } revealInvalidField(validationResult: ValidationResult) { if (!validationResult) { return; } this._revealInvalidField(validationResult); } protected _revealInvalidField(validationResult: ValidationResult) { let event = this._createRevealInvalidFieldEvent(validationResult); this.trigger('revealInvalidField', event); if (event.defaultPrevented) { return; } validationResult.reveal(); } protected _createRevealInvalidFieldEvent(validationResult: ValidationResult): FormRevealInvalidFieldEvent { return new Event({validationResult: validationResult}) as FormRevealInvalidFieldEvent; } /** * Override this method to provide a keystroke which closes the form. * The default implementation returns an AbortKeyStroke which handles the ESC key and calls {@link abort}. * * The keystroke is only active if {@link closable} is set to true. */ protected _createCloseKeyStroke(): KeyStroke { return new AbortKeyStroke(this, () => this.$close); } protected _setClosable(closable: boolean) { this._setProperty('closable', closable); if (this.closable) { this.closeKeyStroke = this._createCloseKeyStroke(); this.keyStrokeContext.registerKeyStroke(this.closeKeyStroke); } else { this.keyStrokeContext.unregisterKeyStroke(this.closeKeyStroke); } } /** @see FormModel.closable */ setClosable(closable: boolean) { this.setProperty('closable', closable); } protected _renderClosable() { if (!this.$header) { return; } this.$container.toggleClass('closable'); if (this.closable) { if (this.$close) { return; } this.$close = this.$statusContainer.appendDiv('status closer') .on('click', this._onCloseIconClick.bind(this)); aria.role(this.$close, 'button'); aria.label(this.$close, this.session.text('ui.Close')); } else { if (!this.$close) { return; } this.$close.remove(); this.$close = null; } } protected _onCloseIconClick() { this.abort(); } /** @see FormModel.resizable */ setResizable(resizable: boolean) { this.setProperty('resizable', resizable); } protected _renderResizable() { if (this.resizable && this.isDialog() && !this.maximized) { this.$container .resizable() .on('resizeStep', this._resizeHandler); } else { this.$container .unresizable() .off('resizeStep', this._resizeHandler); } } protected _onResize(event: Event): boolean { let layout = this.htmlComp.layout as DialogLayout, autoSizeOld = layout.autoSize; layout.autoSize = false; this.htmlComp.revalidateLayoutTree(false); layout.autoSize = autoSizeOld; this.updateCacheBounds(); return false; } /** @see FormModel.movable */ setMovable(movable: boolean) { this.setProperty('movable', movable); } protected _renderMovable() { if (this.movable && this.isDialog() && !this.maximized) { if (this.$dragHandle) { return; } this.$dragHandle = this.$container.prependDiv('drag-handle'); this.$container.draggable(this.$dragHandle, $.throttle(this._onMove.bind(this), 1000 / 60)); // 60fps } else { if (!this.$dragHandle) { return; } this.$container.undraggable(this.$dragHandle); this.$dragHandle.remove(); this.$dragHandle = null; } } protected _onDialogMouseDown() { this.activate(); } /** * @see Desktop.activateForm */ activate() { this.session.desktop.activateForm(this); } show() { this.session.desktop.showForm(this); } hide() { this.session.desktop.hideForm(this); } /** * Checks whether the form is shown, which means whether a form has been added to the form stack of the display parent, e.g. by using {@link showForm}. * * It does not necessarily mean the user can see the content of the form for sure, * e.g. if the form is opened as a view the tab may be inactive because another view is active, or in case of a dialog it may be hidden behind another dialog or shown in an inactive view. */ isShown(): boolean { return this.session.desktop.isFormShown(this); } protected _renderHeader() { this.$header = this.$container.prependDiv('header'); this.$statusContainer = this.$header.appendDiv('status-container'); this.$icon = this.$header.appendDiv('icon-container'); this.$title = this.$header.appendDiv('title'); tooltips.installForEllipsis(this.$title, { parent: this }); this.$subTitle = this.$header.appendDiv('sub-title'); tooltips.installForEllipsis(this.$subTitle, { parent: this }); aria.linkElementWithLabel(this.$container, this.$title); aria.linkElementWithLabel(this.$container, this.$subTitle, AriaLabelledByInsertPosition.BACK); this._renderTitle(); this._renderSubTitle(); this._renderIconId(); this._renderStatus(); this._renderClosable(); this._renderSaveNeeded(); } protected _removeHeader() { if (this.$title) { tooltips.uninstall(this.$title); } if (this.$subTitle) { tooltips.uninstall(this.$subTitle); } if (this.$header) { this.$header.remove(); this.$header = null; } this.$title = null; this.$subTitle = null; this.$statusContainer = null; this.$icon = null; this.$close = null; this.$saveNeeded = null; } /** @see FormModel.rootGroupBox */ setRootGroupBox(rootGroupBox: ObjectOrChildModel<GroupBox>) { this.setProperty('rootGroupBox', rootGroupBox); } protected _setRootGroupBox(rootGroupBox: GroupBox) { if (this.initialized && this.rootGroupBox) { this.rootGroupBox.setMainBox(false); this.rootGroupBox.off('propertyChange:saveNeeded', this._mainBoxSaveNeededChangeHandler); } this._setProperty('rootGroupBox', rootGroupBox); if (this.rootGroupBox) { this.rootGroupBox.setMainBox(true); this.rootGroupBox.on('propertyChange:saveNeeded', this._mainBoxSaveNeededChangeHandler); } this.updateSaveNeeded(); } protected _renderRootGroupBox() { this.rootGroupBox?.render(); this.invalidateLayoutTree(); } /** * Updates the {@link saveNeeded} property based on the {@link GroupBox.saveNeeded} state of the {@link rootGroupBox} and the result of {@link _computeSaveNeeded}. */ updateSaveNeeded() { if (!this.initialized || this.destroying) { return; } this.setSaveNeeded(this.rootGroupBox?.saveNeeded || this._computeSaveNeeded()); } /** * Used by {@link updateSaveNeeded} to update the {@link saveNeeded} property. * * Can be implemented to add a custom save needed check for this specific form. * * By default, there is no implementation and {@link saveNeeded} depends on the state of the {@link rootGroupBox} which itself depends on the state of its fields. * * @returns true if the form needs to be saved, false if only the state of the group should be considered */ protected _computeSaveNeeded(): boolean { return false; } /** * Marks the root group box as saved so that {@link saveNeeded} returns false. * * *Note*: {@link saveNeeded} may still return true afterward if a custom save needed computation is active, see {@link _computeSaveNeeded}. * * @see GroupBox.markAsSaved * @see touch */ markAsSaved() { this.rootGroupBox?.markAsSaved(); this.updateSaveNeeded(); } protected setSaveNeeded(saveNeeded: boolean) { this.setProperty('saveNeeded', saveNeeded); } protected _renderSaveNeeded() { if (!this.$header) { return; } if (this.saveNeeded && this.saveNeededVisible) { this.$container.addClass('save-needed'); if (this.$saveNeeded) { return; } if (this.$close) { this.$saveNeeded = this.$close.beforeDiv('status save-needer'); } else { this.$saveNeeded = this.$statusContainer .appendDiv('status save-needer'); } } else { this.$container.removeClass('save-needed'); if (!this.$saveNeeded) { return; } this.$saveNeeded.remove(); this.$saveNeeded = null; } // Layout could have been changed, e.g. if subtitle becomes visible this.invalidateLayoutTree(); } /** @see FormModel.askIfNeedSave */ setAskIfNeedSave(askIfNeedSave: boolean) { this.setProperty('askIfNeedSave', askIfNeedSave); if (this.lifecycle) { this.lifecycle.setAskIfNeedSave(askIfNeedSave); } } /** @see FormModel.displayViewId */ setDisplayViewId(displayViewId: DisplayViewId) { this.setProperty('displayViewId', displayViewId); } /** @see FormModel.displayHint */ setDisplayHint(displayHint: DisplayHint) { this.setProperty('displayHint', displayHint); } /** @see FormModel.saveNeededVisible */ setSaveNeededVisible(visible: boolean) { this.setProperty('saveNeededVisible', visible); } protected _renderSaveNeededVisible() { this._renderSaveNeeded(); } protected override _renderCssClass(cssClass?: string, oldCssClass?: string) { cssClass = cssClass || this.cssClass; this.$container.removeClass(oldCssClass); this.$container.addClass(cssClass); // Layout could have been changed, e.g. if subtitle becomes visible this.invalidateLayoutTree(); } /** @see FormModel.status */ setStatus(status: StatusOrModel) { this.setProperty('status', status); } protected _setStatus(status: StatusOrModel) { status = Status.ensure(status); this._setProperty('status', status); } protected _renderStatus() { if (!this.$header) { return; } this.$statusIcons.forEach($icn => { $icn.remove(); }); this.$statusIcons = []; if (this.status) { let statusList = this.status.asFlatList(); let $prevIcon; statusList.forEach(sts => { $prevIcon = this._renderSingleStatus(sts, $prevIcon); if ($prevIcon) { this.$statusIcons.push($prevIcon); } }); } // Layout could have been changed, e.g. if subtitle becomes visible this.invalidateLayoutTree(); } protected _renderSingleStatus(status: Status, $prevIcon: JQuery): JQuery { if (status && status.iconId) { let $statusIcon = this.$statusContainer.appendIcon(status.iconId, 'status'); if (status.cssClass()) { $statusIcon.addClass(status.cssClass()); } $statusIcon.prependTo(this.$statusContainer); return $statusIcon; } return $prevIcon; } addStatus(status: Status) { if (!status) { return; } const children = this.status && this.status.children, ms = new Status({children}); ms.addStatus(status); this.setStatus(ms); } removeStatus(status: Status) { if (!this.status || !status) { return; } if (this.status.equals(status)) { this.setStatus(null); return; } if (this.status.containsStatusByPredicate(s => status.equals(s))) { const newStatus = this.status.clone(); newStatus.removeAllStatusByPredicate(s => status.equals(s)); this.setStatus(newStatus); } } getNotificationBadgeText(): string { const status = this._getNotificationBadgeStatus(); if (status) { return status.message; } } setNotificationBadgeText(notificationBadgeText: string) { this.removeStatus(this._getNotificationBadgeStatus()); if (!notificationBadgeText) { return; } this.addStatus(new NotificationBadgeStatus({ message: notificationBadgeText, code: Form._NOTIFICATION_BADGE_STATUS_CODE })); } protected _getNotificationBadgeStatus(): NotificationBadgeStatus { if (!this.status) { return; } return this.status.asFlatList().find(s => Form._NOTIFICATION_BADGE_STATUS_CODE === s.code); } /** @see FormModel.showOnOpen */ setShowOnOpen(showOnOpen: boolean) { this.setProperty('showOnOpen', showOnOpen); } protected _updateTitleForDom() { let titleText = this.title; if (!titleText && this.closable) { // Add '&nbsp;' to prevent title-box of a closable form from collapsing if title is empty titleText = strings.plainText('&nbsp;'); } if (titleText || this.subTitle) { let $titles = getOrAppendChildDiv(this.$container, 'title-box'); // Render title if (titleText) { getOrAppendChildDiv($titles, 'title') .text(titleText) .icon(this.iconId); } else { removeChildDiv($titles, 'title'); } // Render subTitle if (strings.hasText(titleText)) { getOrAppendChildDiv($titles, 'sub-title').text(this.subTitle); } else { removeChildDiv($titles, 'sub-title'); } } else { removeChildDiv(this.$container, 'title-box'); } // ----- Helper functions ----- function getOrAppendChildDiv($parent, cssClass) { let $div = $parent.children('.' + cssClass); if ($div.length === 0) { $div = this.$parent.appendDiv(cssClass); } return $div; } function removeChildDiv($parent, cssClass) { $parent.children('.' + cssClass).remove(); } } isDialog(): boolean { return this.displayHint === Form.DisplayHint.DIALOG; } isPopupWindow(): boolean { return this.displayHint === Form.DisplayHint.POPUP_WINDOW; } isView(): boolean { return this.displayHint === Form.DisplayHint.VIEW; } protected _onMove(newOffset: { top: number; left: number }) { this.trigger('move', newOffset); this.updateCacheBounds(); } moveTo(position: Point) { this.$container.cssPosition(position); this.trigger('move', { left: position.x, top: position.y }); this.updateCacheBounds(); } position() { let position; let prefBounds = this.prefBounds(); if (prefBounds) { position = prefBounds.point(); // Cached bounds may be off-screen -> adjust if necessary let windowSize = this.$container.windowSize(); let margins = this.htmlComp.margins(); let minX = 0; let minY = 0; let maxX = windowSize.width - prefBounds.width - margins.horizontal(); let maxY = windowSize.height - prefBounds.height - margins.vertical(); position.x = Math.max(minX, Math.min(maxX, position.x)); position.y = Math.max(minY, Math.min(maxY, position.y)); } else { position = DialogLayout.positionContainerInWindow(this.$container); } this.moveTo(position); } updateCacheBounds() { if (this.cacheBounds && !this.maximized) { this.storeCacheBounds(this.htmlComp.bounds()); } } appendTo($parent: JQuery) { this.$container.appendTo($parent); } /** @see FormModel.headerVisible */ setHeaderVisible(headerVisible: boolean) { this.setProperty('headerVisible', headerVisible); } protected _renderHeaderVisible() { let headerVisible = this.headerVisible === null ? this.isDialog() : this.headerVisible; if (headerVisible && !this.$header) { this._renderHeader(); } else if (!headerVisible && this.$header) { this._removeHeader(); } // If header contains no title it won't be a real header, it will be in the top right corner just containing icons. let noTitleHeader = this.$header && this.$header.hasClass('no-title'); this.$container.toggleClass('header-visible', headerVisible && !noTitleHeader); let ariaLabel = strings.join(' ', this.title, this.subTitle); aria.label(this.$container, (!headerVisible && !noTitleHeader) ? ariaLabel : null); this.invalidateLayoutTree(); } /** @see FormModel.title */ setTitle(title: string) { this.setProperty('title', title); } protected _renderTitle() { if (this.$header) { this.$title.text(this.title); this.$header.toggleClass('no-title', !this.title && !this.subTitle); } // Layout could have been changed, e.g. if subtitle becomes visible this.invalidateLayoutTree(); } /** @see FormModel.subTitle */ setSubTitle(subTitle: string) { this.setProperty('subTitle', subTitle); } protected _renderSubTitle() { if (this.$header) { this.$subTitle.text(this.subTitle); this.$header.toggleClass('no-title', !this.title && !this.subTitle); } // Layout could have been changed, e.g. if subtitle becomes visible this.invalidateLayoutTree(); } /** @see FormModel.iconId */ setIconId(iconId: string) { this.setProperty('iconId', iconId); } protected _renderIconId() { if (this.$header) { this.$icon.icon(this.iconId); // Layout could have been changed, e.g. if subtitle becomes visible this.invalidateLayoutTree(); } } protected _setViews(views: Form[]) { if (views) { views.forEach(view => { view.setDisplayParent(this); }); } this._setProperty('views', views); } override setDisabledStyle(disabledStyle: DisabledStyle) { this.rootGroupBox?.setDisabledStyle(disabledStyle); } /** @see FormModel.displayParent */ setDisplayParent(displayParent: DisplayParent) { this.setProperty('displayParent', displayParent); } protected _setDisplayParent(displayParent: DisplayParent) { if (displayParent instanceof Form && displayParent.parent instanceof WrappedFormField && displayParent.isView()) { displayParent = Form.findNonWrappedForm(displayParent); } this._setProperty('displayParent', displayParent); if (displayParent) { this.setParent(this.findDesktop().computeParentForDisplayParent(displayParent)); } } /** @see FormModel.maximized */ setMaximized(maximized: boolean) { this.setProperty('maximized', maximized); } protected _renderMaximized() { if (!this.isDialog()) { return; } if (this.maximized && this.htmlComp.layouted) { // Store the current bounds before it is maximized. // The layout will read it using this.prefBounds() the first time it is called when maximized is set to false. this._preMaximizedBounds = this.htmlComp.bounds(); } this._maximize(); if (!this.maximized) { this._preMaximizedBounds = null; } if (this.maximized) { this.$container.window() .on('resize', this._windowResizeHandler); } else { this.$container.window() .off('resize', this._windowResizeHandler); } if (this.rendered) { // Remove move and resize handles when maximized this._renderMovable(); this._renderResizable(); } } protected _onWindowResize() { if (this.maximized) { this._maximize(); } } protected _maximize() { if (!this.rendered) { return; } let layout = this.htmlComp.layout as DialogLayout, shrinkEnabled = layout.shrinkEnabled; layout.shrinkEnabled = true; this.revalidateLayoutTree(); this.position(); layout.shrinkEnabled = shrinkEnabled; } prefBounds(): Rectangle { if (this.maximized) { return null; } if (this._preMaximizedBounds) { return this._preMaximizedBounds; } if (this.cacheBounds) { return this.readCacheBounds(); } return null; } protected override _attach() { this.$parent.append(this.$container); // If the parent was resized while this view was detached, the view has a wrong size. if (this.isView()) { this.invalidateLayoutTree(false); } // form is attached even if children are not yet if ((this.isView() || this.isDialog()) && !this.detailForm) { // notify model this form is active this.session.desktop._setFormActivated(this); } super._attach(); } /** * Method invoked when: * - this is a 'detailForm' and the outline content is displayed; * - this is a 'view' and the view tab is selected; * - this is a child 'dialog' or 'view' and its 'displayParent' is attached; */ protected override _postAttach() { // Attach child dialogs, message boxes and file choosers. this.formController.attachDialogs(); this.messageBoxController.attach(); this.fileChooserController.attach(); super._attach(); } /** * Method invoked when: * - this is a 'detailForm' and the outline content is hidden; * - this is a 'view' and the view tab is deselected; * - this is a child 'dialog' or 'view' and its 'displayParent' is detached; */ protected override _detach() { // Detach child dialogs, message boxes and file choosers, not views. this.formController.detachDialogs(); this.messageBoxController.detach(); this.fileChooserController.detach(); this.$container.detach(); super._detach(); } renderInitialFocus() { let focused = false; if (this.initialFocus) { focused = this.initialFocus.focus(); } else { // If no explicit focus is requested, try to focus the first focusable element. // Do it only if the focus is not already on an element in the form (e.g. focus co