@eclipse-scout/core
Version:
Eclipse Scout runtime
1,552 lines (1,354 loc) • 53.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 {
AbstractLayout, Action, aria, arrays, clipboard, CloneOptions, Column, Device, dragAndDrop, DragAndDropHandler, DragAndDropOptions, DropType, EnumObject, EventHandler, fields, FieldStatus, FormFieldClipboardExportEvent, FormFieldEventMap,
FormFieldLayout, FormFieldModel, FormFieldValidationResultProvider, GridData, GroupBox, HierarchyChangeEvent, HtmlComponent, InitModelOf, KeyStrokeContext, LoadingSupport, Menu, menus as menuUtil, ObjectOrChildModel, ObjectOrModel,
ObjectOrType, objects, Predicate, PropertyChangeEvent, scout, Status, StatusMenuMapping, StatusOrModel, strings, styles, TableRow, Tooltip, tooltips, TooltipSupport, TreeVisitor, TreeVisitResult, Widget
} from '../../index';
import $ from 'jquery';
/**
* Base class for all form-fields.
*/
export class FormField extends Widget implements FormFieldModel {
declare model: FormFieldModel;
declare eventMap: FormFieldEventMap;
declare self: FormField;
dropType: DropType;
dropMaximumSize: number;
empty: boolean;
errorStatus: Status;
fieldStyle: FormFieldStyle;
gridData: GridData;
gridDataHints: GridData;
mode: FormFieldMode;
fieldStatus: FieldStatus;
keyStrokes: Action[];
displayText: string;
label: string;
labelVisible: boolean;
labelPosition: FormFieldLabelPosition;
labelWidthInPixel: number;
labelUseUiWidth: boolean;
labelHtmlEnabled: boolean;
mandatory: boolean;
statusMenuMappings: StatusMenuMapping[];
menus: Menu[];
menusVisible: boolean;
defaultMenuTypes: string[];
preventInitialFocus: boolean;
/** If set to true, the field needs to be saved. This will be computed by {@link computeSaveNeeded}. */
saveNeeded: boolean;
checkSaveNeeded: boolean;
lifecycleBoundary: boolean;
statusPosition: FormFieldStatusPosition;
statusVisible: boolean;
suppressStatus: FormFieldSuppressStatus;
/** If set to true, {@link saveNeeded} will return true as well, even if the value has not been changed. */
touched: boolean;
tooltipText: string;
font: string;
foregroundColor: string;
backgroundColor: string;
labelFont: string;
labelForegroundColor: string;
labelBackgroundColor: string;
tooltipAnchor: FormFieldTooltipAnchor;
onFieldTooltipOptionsCreator: (this: FormField) => InitModelOf<TooltipSupport>;
dragAndDropHandler: DragAndDropHandler;
validationResultProvider: FormFieldValidationResultProvider;
$label: JQuery;
/**
* Note the difference between $field and $fieldContainer:
* - $field points to the input-field (typically a browser-text field)
* - $fieldContainer could point to the same input-field or when the field is a composite,
* to the parent DIV of that composite. For instance: the multi-line-smartfield is a
* composite with an input-field and a DIV showing the additional lines. In that case $field
* points to the input-field and $fieldContainer to the parent DIV of the input-field.
* This property should be used primarily for layout-functionality.
*/
$field: JQuery;
$clearIcon: JQuery;
$fieldContainer: JQuery;
$icon: JQuery;
$pseudoStatus: JQuery;
/**
* The status is used for error-status, tooltip-icon and menus.
*/
$status: JQuery;
$mandatory: JQuery;
protected _menuPropertyChangeHandler: EventHandler<PropertyChangeEvent<any, Menu>>;
protected _hierarchyChangeHandler: EventHandler<HierarchyChangeEvent>;
constructor() {
super();
this.dropType = DropType.NONE;
this.dropMaximumSize = dragAndDrop.DEFAULT_DROP_MAXIMUM_SIZE;
this.empty = true;
this.errorStatus = null;
this.fieldStyle = FormField.DEFAULT_FIELD_STYLE;
this.gridData = null;
this.gridDataHints = new GridData();
this.mode = FormField.Mode.DEFAULT;
this.keyStrokes = [];
this.label = null;
this.labelVisible = true;
this.labelPosition = FormField.LabelPosition.DEFAULT;
this.labelWidthInPixel = 0;
this.labelUseUiWidth = false;
this.labelHtmlEnabled = false;
this.mandatory = false;
this.statusMenuMappings = [];
this.menus = [];
this.menusVisible = true;
this.defaultMenuTypes = [];
this.preventInitialFocus = false;
this.saveNeeded = false;
this.checkSaveNeeded = true;
this.lifecycleBoundary = false;
this.statusPosition = FormField.StatusPosition.DEFAULT;
this.statusVisible = true;
this.suppressStatus = null;
this.touched = false;
this.tooltipText = null;
this.tooltipAnchor = FormField.TooltipAnchor.DEFAULT;
this.onFieldTooltipOptionsCreator = null;
this.validationResultProvider = this._createValidationResultProvider();
this.$label = null;
this.$field = null;
this.$fieldContainer = null;
this.$icon = null;
this.$status = null;
this._addWidgetProperties(['keyStrokes', 'menus', 'statusMenuMappings']);
this._addCloneProperties(['dropType', 'dropMaximumSize', 'errorStatus', 'fieldStyle', 'gridDataHints', 'gridData', 'label', 'labelVisible', 'labelPosition',
'labelWidthInPixel', 'labelUseUiWidth', 'mandatory', 'mode', 'preventInitialFocus', 'saveNeeded', 'touched', 'statusVisible', 'statusPosition', 'statusMenuMappings',
'tooltipText', 'tooltipAnchor']);
this._menuPropertyChangeHandler = this._onMenuPropertyChange.bind(this);
this._hierarchyChangeHandler = this._onHierarchyChange.bind(this);
}
static FieldStyle = {
CLASSIC: 'classic',
ALTERNATIVE: 'alternative'
} as const;
static SuppressStatus = {
/**
* Suppress status on icon and field (CSS class).
*/
ALL: 'all',
/**
* Suppress status on icon, but still show status on field (CSS class).
*/
ICON: 'icon',
/**
* Suppress status on field (CSS class), but still show status as icon.
*/
FIELD: 'field'
} as const;
/** Global variable to make it easier to adjust the default field style for all fields */
static DEFAULT_FIELD_STYLE = FormField.FieldStyle.ALTERNATIVE;
static StatusPosition = {
DEFAULT: 'default',
TOP: 'top'
} as const;
static LabelPosition = {
DEFAULT: 0,
LEFT: 1,
ON_FIELD: 2,
RIGHT: 3,
TOP: 4,
BOTTOM: 5
} as const;
static TooltipAnchor = {
DEFAULT: 'default',
ON_FIELD: 'onField'
} as const;
static LabelWidth = {
DEFAULT: 0,
UI: -1
} as const;
// see org.eclipse.scout.rt.client.ui.form.fields.IFormField.FULL_WIDTH
static FULL_WIDTH = 0;
static Mode = {
DEFAULT: 'default',
CELLEDITOR: 'celleditor'
} as const;
static SEVERITY_CSS_CLASSES = 'has-error has-warning has-info has-ok';
protected override _createKeyStrokeContext(): KeyStrokeContext {
return new KeyStrokeContext();
}
protected override _createLoadingSupport(): LoadingSupport {
return new LoadingSupport({
widget: this
});
}
protected override _init(model: InitModelOf<this>) {
super._init(model);
this.resolveConsts([{
property: 'labelPosition',
constType: FormField.LabelPosition
}]);
this.resolveTextKeys(['label', 'tooltipText']);
this._setValidationResultProvider(this.validationResultProvider);
this._setKeyStrokes(this.keyStrokes);
this._setMenus(this.menus);
this._setErrorStatus(this.errorStatus);
this._setGridDataHints(this.gridDataHints);
this._setGridData(this.gridData);
this._updateEmpty();
this._watchFieldHierarchy();
}
protected override _destroy() {
this._unwatchFieldHierarchy();
super._destroy();
}
protected override _initProperty(propertyName: string, value: any) {
if ('gridDataHints' === propertyName) {
this._initGridDataHints(value);
} else {
super._initProperty(propertyName, value);
}
}
/**
* This function <strong>extends</strong> the default grid data hints of the form field.
* The default values for grid data hints are set in the constructor of the FormField and its subclasses.
* When the given gridDataHints is a plain object, we extend our default values. When gridDataHints is
* already instanceof GridData we overwrite default values completely.
*/
private _initGridDataHints(gridDataHints: GridData) {
if (gridDataHints instanceof GridData) {
this.gridDataHints = gridDataHints;
} else if (objects.isObject(gridDataHints)) {
$.extend(this.gridDataHints, gridDataHints);
} else {
this.gridDataHints = gridDataHints;
}
}
/**
* All subclasses of FormField should implement a _render method. It should call the various add* methods provided by the FormField class.
*
* A possible _render implementation could look like this.
* <pre>
* this.addContainer(this.$parent, 'form-field');
* this.addLabel();
* this.addField(this.$parent.makeDiv('foo', 'bar'));
* this.addMandatoryIndicator();
* this.addStatus();
* </pre>
*/
protected override _render() {
// Render all the necessary parts of a form field.
// Subclasses typically override _render completely and add these parts by themselves
this.addContainer(this.$parent);
this.addLabel();
this.addField(this.$parent.makeDiv());
this.addMandatoryIndicator();
this.addStatus();
}
protected override _renderProperties() {
super._renderProperties();
this._renderMandatory();
this._renderTooltipText();
this._renderErrorStatus();
this._renderMenus();
this._renderLabel();
this._renderLabelVisible();
this._renderStatusVisible();
this._renderStatusPosition();
this._renderFont();
this._renderForegroundColor();
this._renderBackgroundColor();
this._renderLabelFont();
this._renderLabelForegroundColor();
this._renderLabelBackgroundColor();
this._renderGridData();
this._renderPreventInitialFocus();
this._renderFieldStyle();
}
protected override _remove() {
super._remove();
this._removeField();
this._removeStatus();
this._removeLabel();
this._removeIcon();
this.removeMandatoryIndicator();
dragAndDrop.uninstallDragAndDropHandler(this);
}
/** @see FormFieldModel.fieldStyle */
setFieldStyle(fieldStyle: FormFieldStyle) {
this.setProperty('fieldStyle', fieldStyle);
}
protected _renderFieldStyle() {
this._renderFieldStyleInternal(this.$container);
this._renderFieldStyleInternal(this.$fieldContainer);
this._renderFieldStyleInternal(this.$field);
if (this.rendered) {
// See _renderLabelPosition why it is necessary to invalidate parent as well.
let htmlCompParent = this.htmlComp.getParent();
if (htmlCompParent) {
htmlCompParent.invalidateLayoutTree();
}
this.invalidateLayoutTree();
}
}
protected _renderFieldStyleInternal($element: JQuery) {
if (!$element) {
return;
}
$element.toggleClass('alternative', this.fieldStyle === FormField.FieldStyle.ALTERNATIVE);
}
/** @see FormFieldModel.mandatory */
setMandatory(mandatory: boolean) {
this.setProperty('mandatory', mandatory);
}
protected _renderMandatory() {
this.$container.toggleClass('mandatory', this.mandatory);
aria.required(this.$field, this.mandatory || null);
}
/**
* Override this function to return another error status property.
* The default implementation returns the property 'errorStatus'.
*/
protected _errorStatus(): Status {
return this.errorStatus;
}
/** @see FormFieldModel.errorStatus */
setErrorStatus(errorStatus: StatusOrModel) {
this.setProperty('errorStatus', errorStatus);
}
protected _setErrorStatus(errorStatus: StatusOrModel) {
errorStatus = Status.ensure(errorStatus);
this._setProperty('errorStatus', errorStatus);
}
/**
* Adds the given (functional) error status to the list of error status. Prefer this function over #setErrorStatus
* when you don't want to mess with the internal error states of the field (parsing, validation).
*/
addErrorStatus(errorStatus: string | Status) {
if (typeof errorStatus === 'string') {
errorStatus = this._createErrorStatus(errorStatus);
}
if (!(errorStatus instanceof Status)) {
throw new Error('errorStatus is not a Status');
}
let status = this._errorStatus();
if (status) {
status = status.ensureChildren(); // new instance is required for property change
} else {
status = Status.ok('Root');
}
status.addStatus(errorStatus);
this.setErrorStatus(status);
}
/**
* Create an error status with severity {@link Status.Severity.ERROR} containing the given message.
*
* @param message The message for the error status.
* @returns containing the given message.
*/
protected _createErrorStatus(message: string): Status {
return Status.error(message);
}
/**
* Whether the error status is or has the given status type.
*/
containsStatus(statusType: abstract new() => Status): boolean {
if (!this.errorStatus) {
return false;
}
return this.errorStatus.containsStatus(statusType);
}
/** @see FormFieldModel.suppressStatus */
setSuppressStatus(suppressStatus: FormFieldSuppressStatus) {
this.setProperty('suppressStatus', suppressStatus);
}
protected _renderSuppressStatus() {
this._renderErrorStatus();
}
/**
* @returns Whether or not error status icon is suppressed
*/
protected _isSuppressStatusIcon(): boolean {
return scout.isOneOf(this.suppressStatus, FormField.SuppressStatus.ALL, FormField.SuppressStatus.ICON);
}
/**
* @returns Whether or not error status CSS class is suppressed on field
*/
protected _isSuppressStatusField(): boolean {
return scout.isOneOf(this.suppressStatus, FormField.SuppressStatus.ALL, FormField.SuppressStatus.FIELD);
}
/**
* Removes all status (incl. children) with the given type.
*/
removeErrorStatus(statusType: new() => Status) {
this.removeErrorStatusByPredicate(status => status instanceof statusType);
}
removeErrorStatusByPredicate(predicate: Predicate<Status>) {
let status = this._errorStatus();
if (!status) {
return;
}
if (status.containsStatusByPredicate(predicate)) {
let newStatus = status.clone();
newStatus.removeAllStatusByPredicate(predicate);
// If no other status remains -> clear error status
if (newStatus.hasChildren()) {
this.setErrorStatus(newStatus);
} else {
this.clearErrorStatus();
}
}
}
clearErrorStatus() {
this.setErrorStatus(null);
}
/** @internal */
_renderErrorStatus() {
let status = this._errorStatus(),
hasStatus = !!status,
statusClass = (hasStatus && !this._isSuppressStatusField()) ? 'has-' + status.cssClass() : '';
this._updateErrorStatusClasses(statusClass, hasStatus);
this._updateFieldStatus();
}
protected _updateErrorStatusClasses(statusClass: string, hasStatus: boolean) {
this._updateErrorStatusClassesOnElement(this.$container, statusClass, hasStatus);
this._updateErrorStatusClassesOnElement(this.$field, statusClass, hasStatus);
}
protected _updateErrorStatusClassesOnElement($element: JQuery, statusClass: string, hasStatus: boolean) {
if (!$element) {
return;
}
$element
.removeClass(FormField.SEVERITY_CSS_CLASSES)
.addClass(statusClass);
}
/** @see FormFieldModel.tooltipText */
setTooltipText(tooltipText: string) {
this.setProperty('tooltipText', tooltipText);
}
/** @internal */
_renderTooltipText() {
this._updateTooltip();
}
/** @see FormFieldModel.tooltipAnchor */
setTooltipAnchor(tooltipAnchor: FormFieldTooltipAnchor) {
this.setProperty('tooltipAnchor', tooltipAnchor);
}
protected _renderTooltipAnchor() {
this._updateTooltip();
}
protected _updateTooltip() {
let hasTooltipText = this.hasStatusTooltip();
this.$container.toggleClass('has-tooltip', hasTooltipText);
if (this.$field) {
this.$field.toggleClass('has-tooltip', hasTooltipText);
}
this._updateFieldStatus();
aria.description(this.$field, this.tooltipText);
if (this.$fieldContainer) {
if (this.hasOnFieldTooltip()) {
let creatorFunc = this.onFieldTooltipOptionsCreator || this._createOnFieldTooltipOptions;
tooltips.install(this.$fieldContainer, creatorFunc.call(this));
} else {
tooltips.uninstall(this.$fieldContainer);
}
}
}
hasStatusTooltip(): boolean {
return this.tooltipAnchor === FormField.TooltipAnchor.DEFAULT && strings.hasText(this.tooltipText);
}
hasOnFieldTooltip(): boolean {
return this.tooltipAnchor === FormField.TooltipAnchor.ON_FIELD && strings.hasText(this.tooltipText);
}
/** @see FormFieldModel.onFieldTooltipOptionsCreator */
setOnFieldTooltipOptionsCreator(onFieldTooltipOptionsCreator: (this: FormField) => InitModelOf<TooltipSupport>) {
this.onFieldTooltipOptionsCreator = onFieldTooltipOptionsCreator;
}
protected _createOnFieldTooltipOptions(): InitModelOf<TooltipSupport> {
return {
parent: this,
text: this.tooltipText,
arrowPosition: 50
};
}
protected override _renderVisible() {
super._renderVisible();
if (this.rendered) {
// Make sure error status is hidden / shown when visibility changes
this._renderErrorStatus();
}
}
/** @see FormFieldModel.label */
setLabel(label: string) {
this.setProperty('label', label);
}
protected _renderLabel() {
let label = this.label;
if (this.labelPosition === FormField.LabelPosition.ON_FIELD) {
this._renderPlaceholder();
if (this.$label) {
this.$label.text('');
}
aria.label(this.$field, this.label);
} else if (this.$label) {
this._removePlaceholder();
// Make sure an empty label has the same height as the other labels, especially important for top labels
this.$label
.contentOrNbsp(this.labelHtmlEnabled, label, 'empty')
.toggleClass('top', this.labelPosition === FormField.LabelPosition.TOP);
// Invalidate layout if label width depends on its content
if (this.labelUseUiWidth || this.labelWidthInPixel === FormField.LabelWidth.UI) {
this.invalidateLayoutTree();
}
}
}
/**
* Renders an empty label for button-like fields that don't have a regular label but which do want to support the 'labelVisible'
* property in order to provide some layout-flexibility. Makes sure the empty label has the same height as the other labels,
* which is especially important for top labels.
*/
protected _renderEmptyLabel() {
this.$label
.html(' ')
.toggleClass('top', this.labelPosition === FormField.LabelPosition.TOP);
}
protected _renderPlaceholder($field?: JQuery) {
$field = scout.nvl($field, this.$field);
if ($field) {
$field.placeholder(this.label);
}
}
/**
* @param $field argument is required by DateField.js, when not set this.$field is used
*/
protected _removePlaceholder($field?: JQuery) {
$field = scout.nvl($field, this.$field);
if ($field) {
$field.placeholder('');
}
}
/** @see FormFieldModel.labelVisible */
setLabelVisible(visible: boolean) {
this.setProperty('labelVisible', visible);
}
protected _renderLabelVisible() {
let visible = this.labelVisible;
this._renderChildVisible(this.$label, visible);
this.$container.toggleClass('label-hidden', !visible);
if (this.rendered && this.labelPosition === FormField.LabelPosition.TOP) {
// See _renderLabelPosition why it is necessary to invalidate parent as well.
let htmlCompParent = this.htmlComp.getParent();
if (htmlCompParent) {
htmlCompParent.invalidateLayoutTree();
}
}
}
/** @see FormFieldModel.labelWidthInPixel */
setLabelWidthInPixel(labelWidthInPixel: number) {
this.setProperty('labelWidthInPixel', labelWidthInPixel);
}
protected _renderLabelWidthInPixel() {
this.invalidateLayoutTree();
}
/** @see FormFieldModel.labelUseUiWidth */
setLabelUseUiWidth(labelUseUiWidth: number) {
this.setProperty('labelUseUiWidth', labelUseUiWidth);
}
protected _renderLabelUseUiWidth() {
this.invalidateLayoutTree();
}
/** @see FormFieldModel.statusVisible */
setStatusVisible(visible: boolean) {
this.setProperty('statusVisible', visible);
}
protected _renderStatusVisible() {
this._updateFieldStatus();
}
/** @see FormFieldModel.statusPosition */
setStatusPosition(statusPosition: FormFieldStatusPosition) {
this.setProperty('statusPosition', statusPosition);
}
protected _renderStatusPosition() {
this._updateFieldStatus();
}
/**
* The tooltip of the {@link fieldStatus}, if it is shown.
*/
tooltip(): Tooltip {
if (this.fieldStatus) {
return this.fieldStatus.tooltip;
}
return null;
}
protected _updateFieldStatus() {
if (!this.fieldStatus) {
return;
}
// compute status
let menus: Menu[],
errorStatus = this._errorStatus(),
status: Status = null,
statusVisible = this._computeStatusVisible(),
autoRemove = true;
this.fieldStatus.setPosition(this.statusPosition);
this.fieldStatus.setVisible(statusVisible);
if (!statusVisible) {
return;
}
if (errorStatus) {
// If the field is used as a cell editor in an editable table, then no validation errors should be shown.
// (parsing and validation will be handled by the cell/column itself)
if (this.mode === FormField.Mode.CELLEDITOR) {
return;
}
status = errorStatus;
autoRemove = !status.isError();
menus = this._getMenusForStatus(errorStatus);
} else if (this.hasStatusTooltip()) {
status = scout.create(Status, {
message: this.tooltipText,
severity: Status.Severity.INFO
});
// If there are menus, show them in the tooltip. But only if there is a tooltipText, don't do it if there is an error status.
// Menus make most likely no sense if an error status is displayed
menus = this.getContextMenuItems();
} else {
// If there are menus, show them in the tooltip. But only if there is a tooltipText, don't do it if there is an error status.
// Menus make most likely no sense if an error status is displayed
menus = this.getContextMenuItems();
}
this.fieldStatus.update(status, menus, autoRemove, this._isInitialShowStatus());
}
protected _isInitialShowStatus(): boolean {
return !!this._errorStatus();
}
/**
* Computes whether the $status should be visible based on statusVisible, errorStatus and tooltip.
* -> errorStatus and tooltip override statusVisible, so $status may be visible event though statusVisible is set to false
*/
protected _computeStatusVisible(): boolean {
let status = this._errorStatus(),
statusVisible = this.statusVisible,
hasStatus = !!status,
hasTooltip = this.hasStatusTooltip();
return !this._isSuppressStatusIcon() && this.visible && (statusVisible || hasStatus || hasTooltip || (this._hasMenus() && this.menusVisible));
}
protected _renderChildVisible($child: JQuery, visible: boolean): boolean {
if (!$child) {
return;
}
if ($child.isVisible() !== visible) {
$child.setVisible(visible);
this.invalidateLayoutTree();
return true;
}
}
/** @see FormFieldModel.labelPosition */
setLabelPosition(labelPosition: FormFieldLabelPosition) {
this.setProperty('labelPosition', labelPosition);
}
// Don't include in renderProperties, it is not necessary to execute it initially because the positioning is done by _renderLabel
protected _renderLabelPosition() {
this._renderLabel();
if (this.rendered) {
// Necessary to invalidate parent as well if parent uses the logical grid.
// LogicalGridData uses another row height depending on the label position
let htmlCompParent = this.htmlComp.getParent();
if (htmlCompParent) {
htmlCompParent.invalidateLayoutTree();
}
this.invalidateLayoutTree();
}
}
/** @see FormFieldModel.labelHtmlEnabled */
setLabelHtmlEnabled(labelHtmlEnabled: boolean) {
this.setProperty('labelHtmlEnabled', labelHtmlEnabled);
}
protected _renderLabelHtmlEnabled() {
// Render the label again when html enabled changes dynamically
this._renderLabel();
}
protected override _renderEnabled() {
super._renderEnabled();
if (this.$field) {
this.$field.setEnabled(this.enabledComputed);
}
this._installOrUninstallDragAndDropHandler();
}
protected override _renderDisabledStyle() {
this._renderDisabledStyleInternal(this.$container);
this._renderDisabledStyleInternal(this.$fieldContainer);
this._renderDisabledStyleInternal(this.$field);
this._renderDisabledStyleInternal(this.$mandatory);
}
/** @see FormFieldModel.font */
setFont(font: string) {
this.setProperty('font', font);
}
protected _renderFont() {
styles.legacyFont(this, this.$field);
}
/** @see FormFieldModel.foregroundColor */
setForegroundColor(foregroundColor: string) {
this.setProperty('foregroundColor', foregroundColor);
}
protected _renderForegroundColor() {
styles.legacyForegroundColor(this, this.$field);
}
/** @see FormFieldModel.backgroundColor */
setBackgroundColor(backgroundColor: string) {
this.setProperty('backgroundColor', backgroundColor);
}
protected _renderBackgroundColor() {
styles.legacyBackgroundColor(this, this.$field);
}
/** @see FormFieldModel.labelFont */
setLabelFont(labelFont: string) {
this.setProperty('labelFont', labelFont);
}
protected _renderLabelFont() {
styles.legacyFont(this, this.$label, 'label');
}
/** @see FormFieldModel.labelForegroundColor */
setLabelForegroundColor(labelForegroundColor: string) {
this.setProperty('labelForegroundColor', labelForegroundColor);
}
protected _renderLabelForegroundColor() {
styles.legacyForegroundColor(this, this.$label, 'label');
}
/** @see FormFieldModel.labelBackgroundColor */
setLabelBackgroundColor(labelBackgroundColor: string) {
this.setProperty('labelBackgroundColor', labelBackgroundColor);
}
protected _renderLabelBackgroundColor() {
styles.legacyBackgroundColor(this, this.$label, 'label');
}
/** @see FormFieldModel.gridDataHints */
setGridDataHints(gridData: ObjectOrModel<GridData>) {
this.setProperty('gridDataHints', gridData);
}
protected _setGridDataHints(gridData: ObjectOrModel<GridData>) {
this._setProperty('gridDataHints', GridData.ensure(gridData || new GridData()));
}
protected _renderGridDataHints() {
this.parent.invalidateLogicalGrid();
}
/** @internal */
_setGridData(gridData: ObjectOrModel<GridData>) {
this._setProperty('gridData', GridData.ensure(gridData || new GridData()));
}
protected _renderGridData() {
if (this.rendered) {
let htmlCompParent = this.htmlComp.getParent();
if (htmlCompParent) { // may be null if $container is detached
htmlCompParent.invalidateLayoutTree();
}
}
}
/** @see FormFieldModel.menus */
setMenus(menus: ObjectOrChildModel<Menu>[]) {
this.setProperty('menus', menus);
}
protected _setMenus(menus: Menu | Menu[]) {
menus = arrays.ensure(menus);
this.menus.forEach(menu => menu.off('propertyChange', this._menuPropertyChangeHandler));
this.updateKeyStrokes(menus, this.menus);
this._setProperty('menus', menus);
this.menus.forEach(menu => menu.on('propertyChange', this._menuPropertyChangeHandler));
}
insertMenu(menuToInsert: ObjectOrChildModel<Menu>) {
this.insertMenus([menuToInsert]);
}
insertMenus(menusToInsert: ObjectOrChildModel<Menu>[]) {
menuUtil.insertMenus(this, menusToInsert);
}
deleteMenu(menuToDelete: Menu) {
this.deleteMenus([menuToDelete]);
}
deleteMenus(menusToDelete: Menu[]) {
menuUtil.deleteMenus(this, menusToDelete);
}
protected _onMenuPropertyChange(event: PropertyChangeEvent<any, Menu>) {
if (event.propertyName === 'visible' && this.rendered) {
this._updateMenus();
}
}
getContextMenuItems(onlyVisible = true): Menu[] {
let currentMenuTypes = this.getCurrentMenuTypes();
if (currentMenuTypes.length) {
return menuUtil.filter(this.menus, currentMenuTypes, {onlyVisible: onlyVisible, defaultMenuTypes: this.defaultMenuTypes});
} else if (onlyVisible) {
return this.menus.filter(menu => menu.visible);
}
return this.menus;
}
protected _getMenusForStatus(status: Status): Menu[] {
return this.statusMenuMappings.filter(mapping => {
if (!mapping.menu || !mapping.menu.visible) {
return false;
}
// Show the menus which are mapped to the status code and severity (if set)
return (mapping.codes.length === 0 || mapping.codes.indexOf(status.code) > -1)
&& (mapping.severities.length === 0 || mapping.severities.indexOf(status.severity) > -1);
}).map(mapping => mapping.menu);
}
protected _hasMenus(): boolean {
return !!(this.menus && this.getContextMenuItems().length > 0);
}
/** @internal */
_updateMenus() {
if (!this.rendered && !this.rendering) {
return;
}
this.$container.toggleClass('has-menus', this._hasMenus() && this.menusVisible);
this._updateFieldStatus();
}
/** @internal */
_renderMenus() {
this._updateMenus();
}
protected _renderStatusMenuMappings() {
this._updateMenus();
}
setMenusVisible(menusVisible: boolean) {
this.setProperty('menusVisible', menusVisible);
}
protected _setMenusVisible(menusVisible: boolean) {
this._setProperty('menusVisible', menusVisible);
}
protected _renderMenusVisible() {
this._updateMenus();
}
getCurrentMenuTypes(): string[] {
return this._getCurrentMenuTypes();
}
protected _getCurrentMenuTypes(): string[] {
return [];
}
protected _setKeyStrokes(keyStrokes: Action[]) {
this.updateKeyStrokes(keyStrokes, this.keyStrokes);
this._setProperty('keyStrokes', keyStrokes);
}
/**
* May be overridden to explicitly provide a tooltip $parent
* @internal
*/
_$tooltipParent(): JQuery {
// Will be determined by the tooltip itself
return undefined;
}
/** @internal */
_hideStatusMessage() {
if (this.fieldStatus) {
this.fieldStatus.hideTooltip();
}
}
protected _renderPreventInitialFocus() {
this.$container.toggleClass('prevent-initial-focus', !!this.preventInitialFocus);
}
/**
* Sets the focus on this field. If the field is not rendered, the focus will be set as soon as it is rendered.
* @returns true if the element could be focused, false if not
*/
override focus(): boolean {
if (!this.rendered) {
this.session.layoutValidator.schedulePostValidateFunction(this.focus.bind(this));
return false;
}
if (!this.enabledComputed) {
return false;
}
let focusableElement = this.getFocusableElement();
if (focusableElement) {
return this.session.focusManager.requestFocus(focusableElement);
}
return false;
}
/**
* This method returns the HtmlElement to be used as initial focus element or when {@link #focus()} is called.
* It can be overridden, in case the FormField needs to return something other than this.$field[0].
*/
override getFocusableElement(): HTMLElement | JQuery {
if (this.rendered && this.$field) {
return this.$field[0];
}
return null;
}
protected _onFieldFocus(event: JQuery.FocusEvent) {
this.setFocused(true);
}
protected _onFieldBlur(event: JQuery.BlurEvent) {
this.setFocused(false);
}
/**
* When calling this function, the same should happen as when clicking into the field. It is used when the label is clicked.<br>
* The most basic action is focusing the field but this may differ from field to field.
*/
activate() {
if (!this.enabledComputed || !this.rendered) {
return;
}
// Explicitly don't use this.focus() because this.focus uses the focus manager which may be disabled (e.g. on mobile devices)
let focusableElement = this.getFocusableElement();
if (focusableElement) {
$.ensure(focusableElement).focus();
}
}
override get$Scrollable(): JQuery {
return this.$field;
}
getParentGroupBox(): GroupBox {
return this.findParent(GroupBox);
}
getParentField(): FormField {
return this.findParent(FormField);
}
/**
* Appends a LABEL element to this.$container and sets the this.$label property.
*/
addLabel() {
this.$label = this.$container.appendElement('<label>');
tooltips.installForEllipsis(this.$label, {
parent: this
});
// Setting the focus programmatically does not work in a mousedown listener on mobile devices,
// that is why a click listener is used instead
this.$label.on('click', this._onLabelClick.bind(this));
}
protected _onLabelClick(event: JQuery.ClickEvent) {
if (!strings.hasText(this.label)) {
// Clicking on "invisible" labels should not have any effect since it is confusing
return;
}
this.activate();
}
protected _removeLabel() {
if (!this.$label) {
return;
}
tooltips.uninstall(this.$label);
this.$label.remove();
this.$label = null;
}
/**
* Links the given element with the label by setting aria-labelledby.<br>
* This allows screen readers to build a catalog of the elements on the screen and their relationships, for example, to read the label when the input is focused.
*/
protected _linkWithLabel($element: JQuery) {
if (strings.empty(this.label)) { // no label, do not link field to nbsp
return;
}
if (this.labelPosition !== FormField.LabelPosition.ON_FIELD) {
aria.linkElementWithLabel($element, this.$label);
}
}
protected _removeIcon() {
if (!this.$icon) {
return;
}
this.$icon.remove();
this.$icon = null;
}
/**
* Appends the given field to the this.$container and sets the property this.$field.
* The $field is used as $fieldContainer as long as you don't explicitly call addFieldContainer before calling addField.
*/
addField($field: JQuery) {
if (!this.$fieldContainer) {
this.addFieldContainer($field);
}
this.$field = $field;
this._linkWithLabel($field);
this.$field.on('blur', this._onFieldBlur.bind(this))
.on('focus', this._onFieldFocus.bind(this));
}
/**
* Call this method before addField if you'd like to have a different field container than $field.
*/
addFieldContainer($fieldContainer: JQuery) {
this.$fieldContainer = $fieldContainer
.addClass('field');
// Only append if not already appended, or it is not the last element so that append would move it to the end
// This can be important for some widgets, e.g. iframe which would cancel and restart the request on every dom insertion
if (this.$container.has($fieldContainer[0]).length === 0 || $fieldContainer.next().length > 0) {
$fieldContainer.appendTo(this.$container);
}
}
/**
* Removes this.$field and this.$fieldContainer and sets the properties to null.
*/
protected _removeField() {
if (this.$field) {
this.$field.remove();
this.$field = null;
}
if (this.$fieldContainer) {
this.$fieldContainer.remove();
this.$fieldContainer = null;
}
}
/**
* Appends a span element for form-field status to this.$container and sets the this.$status property.
*/
addStatus() {
if (this.fieldStatus) {
return;
}
this.fieldStatus = scout.create(FieldStatus, {
parent: this,
position: this.statusPosition,
// This will be done by _updateFieldStatus again, but doing it here prevents unnecessary layout invalidations later on
visible: this._computeStatusVisible()
});
this.fieldStatus.render();
this.$status = this.fieldStatus.$container;
this._updateFieldStatus();
}
protected _removeStatus() {
if (!this.fieldStatus) {
return;
}
this.fieldStatus.destroy();
this.$status = null;
this.fieldStatus = null;
}
/**
* Appends a span element to this.$container and sets the this.$pseudoStatus property.
* The purpose of a pseudo status is to consume the space an ordinary status would.
* This makes it possible to make components without a status as width as components with a status.
*/
addPseudoStatus() {
this.$pseudoStatus = this.$container.appendSpan('status');
}
addMandatoryIndicator() {
this.$mandatory = this.$container.appendSpan('mandatory-indicator');
}
removeMandatoryIndicator() {
if (!this.$mandatory) {
return;
}
this.$mandatory.remove();
this.$mandatory = null;
}
/**
* Adds a span element with class 'icon' the given optional $parent.
* When $parent is not set, the element is added to this.$container.
*/
addIcon($parent?: JQuery) {
if (!$parent) {
$parent = this.$container;
}
this.$icon = fields.appendIcon($parent)
.on('mousedown', this._onIconMouseDown.bind(this));
aria.hidden(this.$icon, true);
}
protected _onIconMouseDown(event: JQuery.MouseDownEvent) {
if (!this.enabledComputed) {
return;
}
this.$field.focus();
}
/**
* Appends a DIV element as form-field container to $parent and sets the this.$container property.
* Applies FormFieldLayout to this.$container (if container does not define another layout).
* Sets this.htmlComp to the HtmlComponent created for this.$container.
*
* @param $parent to which container is appended
* @param cssClass cssClass to add to the new container DIV
* @param layout when layout is undefined, {@link _createLayout} is called
*
*/
addContainer($parent: JQuery, cssClass?: string, layout?: AbstractLayout) {
this.$container = $parent.appendDiv('form-field');
if (cssClass) {
this.$container.addClass(cssClass);
}
let htmlComp = HtmlComponent.install(this.$container, this.session);
htmlComp.setLayout(layout || this._createLayout());
this.htmlComp = htmlComp;
}
/**
* @returns the default layout FormFieldLayout. Override this function if your field needs another layout.
*/
protected _createLayout(): AbstractLayout {
return new FormFieldLayout(this);
}
/**
* Updates the "inner alignment" of a field. Usually, the GridData hints only have influence on the LogicalGridLayout.
* However, the properties "horizontalAlignment" and "verticalAlignment" are sometimes used differently.
* Instead of controlling the field alignment in case fillHorizontal/fillVertical is false, the developer expects the _contents_ of the field to be aligned correspondingly inside the field.
* Technically, this is not correct, but is supported for legacy and convenience reasons for some Scout fields.
* Those who support the behavior may override _renderGridData() and call this method.
* Some CSS classes are then added to the field.
*/
updateInnerAlignment(opts?: FormFieldAlignmentUpdateOptions) {
opts = opts || {};
let $fieldContainer = opts.$fieldContainer || this.$fieldContainer;
this._updateElementInnerAlignment(opts, $fieldContainer);
if ($fieldContainer !== this.$container) {
// also set the styles to the container
this._updateElementInnerAlignment(opts, this.$container);
}
}
protected _updateElementInnerAlignment(opts: FormFieldAlignmentUpdateOptions, $field: JQuery) {
opts = opts || {};
let useHorizontalAlignment = scout.nvl(opts.useHorizontalAlignment, true);
let useVerticalAlignment = scout.nvl(opts.useVerticalAlignment, true);
if (!$field) {
return;
}
$field.removeClass('has-inner-alignment halign-left halign-center halign-right valign-top valign-middle valign-bottom');
if (useHorizontalAlignment || useVerticalAlignment) {
// Set horizontal and vertical alignment (from gridData)
$field.addClass('has-inner-alignment');
let gridData = this.gridData;
if (this.parent.logicalGrid) {
// If the logical grid is calculated by JS, use the hints instead of the calculated grid data
gridData = this.gridDataHints;
}
if (useHorizontalAlignment) {
let hAlign = gridData.horizontalAlignment;
$field.addClass(hAlign < 0 ? 'halign-left' : (hAlign > 0 ? 'halign-right' : 'halign-center'));
}
if (useVerticalAlignment) {
let vAlign = gridData.verticalAlignment;
$field.addClass(vAlign < 0 ? 'valign-top' : (vAlign > 0 ? 'valign-bottom' : 'valign-middle'));
}
// Alignment might have affected inner elements (e.g. clear icon)
this.invalidateLayout();
}
}
addCellEditorFieldCssClasses($field: JQuery, opts: AddCellEditorFieldCssClassesOptions) {
$field
.addClass('cell-editor-field')
.addClass(Device.get().cssClassForEdge());
if (opts.cssClass) {
$field.addClass(opts.cssClass);
}
}
/**
* Called by the {@link CellEditorPopup} after having rendered this field.
*/
prepareForCellEdit(opts?: AddCellEditorFieldCssClassesOptions) {
opts = opts || {};
// remove mandatory and status indicators (popup should 'fill' the whole cell)
if (this.$mandatory) {
this.removeMandatoryIndicator();
}
if (this.$status) {
this.$status.remove();
this.$status = null;
}
if (this.$container) {
this.$container.addClass('cell-editor-form-field');
}
if (this.$field) {
this.addCellEditorFieldCssClasses(this.$field, opts);
}
}
/**
* Adjusts the field for use as a cell editor field. This operation is irreversible.
* The {@link #mode} property is set to {@link FormField.Mode.CELLEDITOR}.
*
* Unlike {@link #prepareForCellEdit}, this method does not depend on the "rendered" state.
* It is called by an editable column just before the cell editor popup is created, see
* {@link Column#startCellEdit}.
*
* @param options
* When called by an editable column, this object holds information about the
* column and row being edited.
*/
activateCellEditorMode(options?: FormFieldActivateCellEditorModeOptions) {
// Set hint that this field is used within a cell-editor
this.mode = FormField.Mode.CELLEDITOR;
if (options?.column && options?.row) {
let cell = options.column.cell(options.row);
// Override field alignment with the cell's alignment
this.gridData.horizontalAlignment = cell.horizontalAlignment;
}
}
/** @see FormFieldModel.dropType */
setDropType(dropType: DropType) {
this.setProperty('dropType', dropType);
}
protected _renderDropType() {
this._installOrUninstallDragAndDropHandler();
}
/** @see FormFieldModel.dropMaximumSize */
setDropMaximumSize(dropMaximumSize: number) {
this.setProperty('dropMaximumSize', dropMaximumSize);
}
protected _installOrUninstallDragAndDropHandler() {
dragAndDrop.installOrUninstallDragAndDropHandler(this._getDragAndDropHandlerOptions());
}
protected _getDragAndDropHandlerOptions(): DragAndDropOptions {
return {
target: this,
doInstall: () => this.dropType && this.enabledComputed,
container: () => this.$field || this.$container,
dropType: () => this.dropType,
onDrop: event => this.trigger('drop', event)
};
}
/**
* Visits this field and all child {@link FormField}s in pre-order (top-down).
*/
visitFields(visitor: TreeVisitor<FormField>, options: VisitFieldsOptions = {}): TreeVisitResult {
return this.visit(child => {
if (child instanceof FormField) {
let visitResult = visitor(child);
return scout.nvl(options.firstLevelFieldsOnly, false) && this !== child ? TreeVisitResult.SKIP_SUBTREE : visitResult;
}
if (scout.nvl(options.limitToSameFieldTree, false)) {
return TreeVisitResult.SKIP_SUBTREE;
}
return TreeVisitResult.CONTINUE;
}, {visitSelf: options.visitSelf});
}
visitFirstChildFields(visitor: TreeVisitor<FormField>) {
this.visitFields(field => visitor(field), {firstLevelFieldsOnly: true, visitSelf: false});
}
/**
* Visits all parent form fields.
* To stop the visiting if the parent field is no form field anymore (e.g. a form or the desktop), you can set {@link VisitParentFieldsOptions.limitToSameFieldTree} to true.
*/
visitParentFields(visitor: (parent: FormField) => void, options: VisitParentFieldsOptions = {}) {
let parent = this.parent;
while (parent) {
if (parent instanceof FormField) {
visitor(parent);
} else if (scout.nvl(options.limitToSameFieldTree, false)) {
return;
}
parent = parent.parent;
}
}
/**
* Sets {@link saveNeeded} and {@link touched} to false on this field and every child field.
**/
markAsSaved() {
this.visitFirstChildFields(field => {
field.markAsSaved();
});
this._markAsSaved();
this.updateSaveNeeded();
}
protected _markAsSaved() {
this.setProperty('touched', false);
}
/**
* Marks the field as {@link touched} which means {@link saveNeeded} will return true even if the value has not been changed.
**/
touch() {
this.setProperty('touched', true);
this.updateSaveNeeded();
}
/**
* Updates {@link saveNeeded} depending on whether the field was {@link touched} using {@link touch} or {@link computeSaveNeeded} returns true.
*
* @param child the child updating its save needed state and informing its parent about it. If passed and `child.saveNeeded` is true, {@link computeSaveNeeded} will be skipped.
*/
updateSaveNeeded(child?: FormField) {
if (!this.initialized || this.destroying) {
return;
}
this._setSaveNeeded(this.touched || (this.checkSaveNeeded && (child?.saveNeeded || this.computeSaveNeeded())));
}
protected _setSaveNeeded(saveNeeded: boolean) {
if (this._setProperty('saveNeeded', saveNeeded)) {
this.getParentField()?.updateSaveNeeded(this);
}
}
setCheckSaveNeeded(checkSaveNeeded: boolean) {
if (this.setProperty('checkSaveNeeded', checkSaveNeeded)) {
this.updateSaveNeeded();
}
}
/**
* Used by {@link updateSaveNeeded} to update the {@link saveNeeded} property.
*
* By default, all first level child fields are checked. The method returns true, if one of these fields needs to be saved.
*/
computeSaveNeeded(): boolean {
let saveNeeded = false;
this.visitFirstChildFields(field => {
if (!field.destroying && field.saveNeeded) {
saveNeeded = true;
return true;
}
});
return saveNeeded;
}
getValidationResult(): ValidationResult {
return this.validationResultProvider.provide(this._errorStatus());
}
protected _createValidationResultProvider() {
return scout.create(FormFieldValidationResultProvider, {field: this});
}
/** @see FormFieldModel.validationResultProvider */
setValidationResultProvider(provider: ObjectOrType<FormFieldValidationResultProvider>) {
this.setProperty('validationResultProvider', provider);
}
protected _setValidationResultProvider(provider: ObjectOrType<FormFieldValidationResultProvider>) {
scout.assertParameter('provider', provider);
provider = scout.ensure(provider, {field: this});
this._setProperty('validationResultProvider', provider);
}
protected _updateEmpty() {
this.setProperty('empty', this._computeEmpty());
}
/**
* @returns true if the field is considered empty, false if not.
* Mandatory fields that are empty will return a {@link ValidationResult} with {@link ValidationResult.valid} and {@link ValidationResult.validByMandatory} set to false.
*
* @see getValidationResult
*/
protected _computeEmpty() {
return true;
}
requestInput() {
if (this.enabledComputed && this.rendered) {
this.focus();
}
}
override clone(model: FormFieldModel, options?: CloneOptions): this {
let clone = super.clone(model, options);
this._deepCloneProperties(clone, 'menus', options);
return clone;
}
exportToClipboard() {
if (!this.displayText) {
return;
}
let event = this.trigger('clipboardExport', {
text: this.displayText
}) as FormFieldClipboardExportEvent;
if (!event.defaultPrevented) {
this._exportToClipboard(event.text);
}
}
protected _exportToClipboard(text: string) {
clipboard.copyText({
parent: this,
text: text
});
}
/**
* Updates save needed state on parent field if the hierarchy changed, e.g. if the field was moved into another composite field.
* Also handles the case where a field is not directly connected to a form field parent, but has a non-form-field in between
* (e.g. FormFieldMenu has a Menu as parent -> if the menu is moved the state needs to be recomputed as well).
*/
protected _watchFieldHierarchy(parent?: Widget) {
if (!this.initialized) {
this.on('hierarchyChange', this._hierarchyChangeHandler);
}
parent = scout.nvl(parent, this.parent);
// Each form field adds its own hierarchyChangeListener but non-form-fields don't -> add listener for every non-form field between this field and the next parent field
let parentField = this._visitParentsUntilField(parent, parent => parent.on('hierarchyChange', this._hierarchyChangeHandler));
parentField?.updateSaveNeeded(this);
}
protected _unwatchFieldHierarchy(oldParent?: Wid