@eclipse-scout/core
Version:
Eclipse Scout runtime
1,591 lines (1,415 loc) • 55.7 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 {
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 ' ' to prevent title-box of a closable form from collapsing if title is empty
titleText = strings.plainText(' ');
}
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