From 532e21a6d7db91aa1e8631fe7e5095fff416edff Mon Sep 17 00:00:00 2001 From: Belisoful Date: Fri, 28 Aug 2026 02:23:02 +0000 Subject: [PATCH 1/3] TInPlaceDropDownList, TInPlaceControlTrait - standardized common properties. TInPlaceTextBox - deprecates AutoHideTextBox for AutoHideEditor; deprecates DisplayTextBox for DisplayEditor --- .../framework/Web/UI/ActiveControls/INDEX.md | 2 + .../Web/UI/ActiveControls/SUMMARY.md | 2 + .../UI/ActiveControls/TInPlaceDropDownList.md | 27 + .../Web/UI/ActiveControls/TInPlaceTextBox.md | 9 +- .../prado/activecontrols/inlineeditor.js | 631 ++++++++++++--- .../Web/Javascripts/source/prado/prado.js | 20 +- .../ActiveControls/TInPlaceControlTrait.php | 178 +++++ .../ActiveControls/TInPlaceDropDownList.php | 258 +++++++ .../Web/UI/ActiveControls/TInPlaceTextBox.php | 122 +-- framework/classes.php | 2 + .../pages/InPlaceDropDownListTest.page | 26 + .../pages/InPlaceDropDownListTest.php | 56 ++ tests/js/activecontrols/inlineeditor.test.js | 157 ++-- .../inplacedropdownlist.test.js | 727 ++++++++++++++++++ tests/js/adapters/inlineeditor.js | 7 +- tests/js/helpers/callbackMock.js | 57 ++ .../web/TInPlaceDropDownListTestCase.spec.js | 127 +++ .../TInPlaceDropDownListTest.php | 305 ++++++++ .../UI/ActiveControls/TInPlaceTextBoxTest.php | 109 +++ 19 files changed, 2577 insertions(+), 245 deletions(-) create mode 100644 agents/framework/Web/UI/ActiveControls/TInPlaceDropDownList.md create mode 100644 framework/Web/UI/ActiveControls/TInPlaceControlTrait.php create mode 100644 framework/Web/UI/ActiveControls/TInPlaceDropDownList.php create mode 100644 tests/harness/web/protected/pages/InPlaceDropDownListTest.page create mode 100644 tests/harness/web/protected/pages/InPlaceDropDownListTest.php create mode 100644 tests/js/activecontrols/inplacedropdownlist.test.js create mode 100644 tests/js/helpers/callbackMock.js create mode 100644 tests/playwright/web/TInPlaceDropDownListTestCase.spec.js create mode 100644 tests/unit/Web/UI/ActiveControls/TInPlaceDropDownListTest.php create mode 100644 tests/unit/Web/UI/ActiveControls/TInPlaceTextBoxTest.php diff --git a/agents/framework/Web/UI/ActiveControls/INDEX.md b/agents/framework/Web/UI/ActiveControls/INDEX.md index d00abf8e5..6da4198d1 100644 --- a/agents/framework/Web/UI/ActiveControls/INDEX.md +++ b/agents/framework/Web/UI/ActiveControls/INDEX.md @@ -69,6 +69,8 @@ AJAX-enabled controls for the Prado framework. ActiveControls trigger server-sid | `TActiveFileUpload` | TFileUpload | Async upload via hidden iframe; `OnFileUpload` event | | `TActiveDatePicker` | TDatePicker | Date picker with `OnDateChanged` callback | | `TInPlaceTextBox` | (custom) | Click-to-edit label; `LoadTextOnEdit` option | +| `TInPlaceDropDownList` | TActiveDropDownList | Click-to-edit label over a hidden select; `OnLoadingItems` event | +| `TInPlaceControlTrait` | (trait) | Shared in-place surface: `AutoHideEditor`, `DisplayEditor`, `EditTriggerControlID`, `EmptyDisplayText`, label ID | | `TActiveCustomValidator` | TCustomValidator | Server-side validation via callback | | `TTimeTriggeredCallback` | (no UI) | Timer-based callback; `Interval`, `StartTimerOnLoad` | | `TValueTriggeredCallback` | (no UI) | Polls a JS expression; fires callback when value changes | diff --git a/agents/framework/Web/UI/ActiveControls/SUMMARY.md b/agents/framework/Web/UI/ActiveControls/SUMMARY.md index 9e4831365..656bd4f77 100644 --- a/agents/framework/Web/UI/ActiveControls/SUMMARY.md +++ b/agents/framework/Web/UI/ActiveControls/SUMMARY.md @@ -40,6 +40,8 @@ AJAX-enabled controls triggering server-side processing via XMLHttpRequest callb - **`TInPlaceTextBox`** — Click-to-edit label; `LoadTextOnEdit` option. +- **`TInPlaceDropDownList`** — Click-to-edit label over a hidden select; `OnLoadingItems` event. + - **`TActiveCustomValidator`** — Server-side validation via callback. - **`TTimeTriggeredCallback`** / **`TValueTriggeredCallback`** / **`TEventTriggeredCallback`** — Timer/poll/event-based callbacks. diff --git a/agents/framework/Web/UI/ActiveControls/TInPlaceDropDownList.md b/agents/framework/Web/UI/ActiveControls/TInPlaceDropDownList.md new file mode 100644 index 000000000..293acaa77 --- /dev/null +++ b/agents/framework/Web/UI/ActiveControls/TInPlaceDropDownList.md @@ -0,0 +1,27 @@ +# Web/UI/ActiveControls/TInPlaceDropDownList + +### Directories +[framework](../../../INDEX.md) / [Web](../../INDEX.md) / [UI](../INDEX.md) / [ActiveControls](./INDEX.md) / **`TInPlaceDropDownList`** + +## Class Info +**Location:** `framework/Web/UI/ActiveControls/TInPlaceDropDownList.php` +**Namespace:** `Prado\Web\UI\ActiveControls` +**Since:** 4.4.0 + +## Overview +Click-to-edit drop down list that displays as a label showing the selected item text. Clicking the label swaps to the server-rendered (hidden) select element. The select's `change` event posts a callback that raises `OnSelectedIndexChanged` and `OnCallback`, updates the label, and re-hides the select when `AutoHideEditor` is true; losing focus leaves edit mode. The label carries the control's CssClass, style, and ToolTip. Shares the label/editor swap machinery with `TInPlaceTextBox` through `TInPlaceControlTrait` (PHP) and `Prado.WebUI.TInPlaceControlBase` (JS, `inlineeditor.js`), which owns the instance registry keyed by editor client ID. + +## Key Properties/Methods + +- `getAutoHideEditor()` / `setAutoHideEditor($value)` - Hide the select after blur (default true); from `TInPlaceControlTrait` +- `getDisplayEditor()` / `setDisplayEditor($value)` - Show/hide the select; from `TInPlaceControlTrait` +- `getEditTriggerControlID()` / `setEditTriggerControlID($value)` - External trigger control ID +- `getReadOnly()` / `setReadOnly($value)` - Prevent entering edit mode +- `getEmptyDisplayText()` / `setEmptyDisplayText($value)` - Label html when the selection has no text; the label carries a `data-prado-empty` mark so the client tells the placeholder apart from a value +- `onLoadingItems($param)` - Event raised to load the item list from the server on edit +- `setSelectedValue($value)` / `setSelectedIndex($index)` - Also update the client-side label during callbacks +- `getClientClassName()` - Returns `Prado.WebUI.TInPlaceDropDownList` + +## See Also + +- [TInPlaceTextBox](./TInPlaceTextBox.md), [TActiveDropDownList](./TActiveDropDownList.md) diff --git a/agents/framework/Web/UI/ActiveControls/TInPlaceTextBox.md b/agents/framework/Web/UI/ActiveControls/TInPlaceTextBox.md index 9118fdfda..94b5a95ec 100644 --- a/agents/framework/Web/UI/ActiveControls/TInPlaceTextBox.md +++ b/agents/framework/Web/UI/ActiveControls/TInPlaceTextBox.md @@ -8,17 +8,18 @@ **Namespace:** `Prado\Web\UI\ActiveControls` ## Overview -Click-to-edit text box that displays as a label until clicked. When clicked, transforms into a text input for editing. Supports loading text from server via callback before allowing edit, and can auto-hide the textbox after losing focus. +Click-to-edit text box that displays as a label until clicked. When clicked, transforms into a text input for editing. Supports loading text from server via callback before allowing edit, and can auto-hide the textbox after losing focus. The client-side input honors the full `TTextBoxMode` set (Date, Number, Email, Color, etc.), matching `TTextBox` type rendering. Shares the label/editor swap machinery with `TInPlaceDropDownList` through `TInPlaceControlTrait` (PHP) and `Prado.WebUI.TInPlaceControlBase` (JS, `inlineeditor.js`). ## Key Properties/Methods -- `getAutoHideTextBox()` / `setAutoHideTextBox($value)` - Hide textbox after blur (default true) -- `getDisplayTextBox()` / `setDisplayTextBox($value)` - Show/hide edit textbox +- `getAutoHideEditor()` / `setAutoHideEditor($value)` - Hide textbox after blur (default true); `AutoHideTextBox` is a deprecated alias +- `getDisplayEditor()` / `setDisplayEditor($value)` - Show/hide edit textbox; `DisplayTextBox` is a deprecated alias - `getEditTriggerControlID()` / `setEditTriggerControlID($value)` - External trigger control ID - `getReadOnly()` / `setReadOnly($value)` - Make control non-editable +- `getEmptyDisplayText()` / `setEmptyDisplayText($value)` - Label html when the text is empty (since 4.4.0, via `TInPlaceControlTrait`); the label carries a `data-prado-empty` mark so the client tells the placeholder apart from a value - `onLoadingText($param)` - Event raised to load text from server before editing - `getClientClassName()` - Returns `Prado.WebUI.TInPlaceTextBox` ## See Also -- [TActiveTextBox](./TActiveTextBox.md), [TCallback](./TCallback.md) +- [TActiveTextBox](./TActiveTextBox.md), [TCallback](./TCallback.md), [TInPlaceDropDownList](./TInPlaceDropDownList.md) diff --git a/framework/Web/Javascripts/source/prado/activecontrols/inlineeditor.js b/framework/Web/Javascripts/source/prado/activecontrols/inlineeditor.js index 96983f183..4d5826638 100644 --- a/framework/Web/Javascripts/source/prado/activecontrols/inlineeditor.js +++ b/framework/Web/Javascripts/source/prado/activecontrols/inlineeditor.js @@ -1,6 +1,13 @@ -/*! PRADO TInPlaceTextBox javascript file | github.com/pradosoft/prado */ - -Prado.WebUI.TInPlaceTextBox = Prado.Class(Prado.WebUI.Control, +/*! PRADO in-place editor javascript file | github.com/pradosoft/prado */ + +/** + * Base class for in-place editor controls. An in-place control renders a + * label element that swaps to an edit element when the label, or an optional + * external control, is clicked. Subclasses supply the edit element through + * createEditorInput() and the changed-value predicate that starts a save. + * @since 4.4.0 + */ +Prado.WebUI.TInPlaceControlBase = Prado.Class(Prado.WebUI.Control, { onInit(options) { @@ -9,16 +16,27 @@ Prado.WebUI.TInPlaceTextBox = Prado.Class(Prado.WebUI.Control, this.editField = null; this.readOnly = options.ReadOnly; - this.options = Object.assign( - { - LoadTextFromSource : false, - TextMode : 'SingleLine' - - }, options || {}); + this.options = Object.assign(this.getDefaultOptions(), options || {}); this.element = document.getElementById(this.options.ID); - Prado.WebUI.TInPlaceTextBox.register(this); + Prado.WebUI.TInPlaceControlBase.register(this); this.createEditorInput(); this.initializeListeners(); + if(this.options.DisplayEditor) + this.enterEditMode(null, true); + }, + + /** + * @return {String} registry key of this control, the editor client ID. + */ + getRegistryKey() { + return this.options.EditorID; + }, + + /** + * @return {Object} default options merged under the server options. + */ + getDefaultOptions() { + return {}; }, /** @@ -32,33 +50,34 @@ Prado.WebUI.TInPlaceTextBox = Prado.Class(Prado.WebUI.Control, }, /** - * Changes the panel to an editable input. + * Changes the label to the edit element. * @param {Event} evt event source + * @param {Boolean} noFocus true to leave the focus untouched */ - enterEditMode(evt) { - if (this.isSaving || this.isEditing || this.readOnly) return; - this.isEditing = true; + enterEditMode(evt, noFocus) { + if (this.isSaving || this.isEditing || this.readOnly) return; + this.isEditing = true; this.onEnterEditMode(); this.createEditorInput(); - this.showTextBox(); + this.showEditor(); this.editField.disabled = false; - if(this.options.LoadTextOnEdit) - this.loadExternalText(); - this.editField.focus(); + this.loadOnEdit(); + if(!noFocus) + this.editField.focus(); if (evt) evt.preventDefault(); - return false; + return false; }, exitEditMode(_evt) { this.isEditing = false; this.isSaving = false; this.editField.disabled = false; - this.element.innerHTML = this.editField.value; + this.refreshLabel(); this.showLabel(); }, - showTextBox() { + showEditor() { this.element.style.display = 'none'; this.editField.style.display = ''; }, @@ -68,6 +87,251 @@ Prado.WebUI.TInPlaceTextBox = Prado.Class(Prado.WebUI.Control, this.editField.style.display = 'none'; }, + /** + * Subclass hook: create or locate the edit element in this.editField. + */ + createEditorInput() { + }, + + /** + * Subclass hook: dispatch the load-on-edit callback when configured. + */ + loadOnEdit() { + }, + + /** + * Subclass hook: called after a save completes successfully. + */ + onAfterSave() { + }, + + /** + * @return {Boolean} whether the editor holds no value. + */ + isEditorEmpty() { + return this.editField.value === ''; + }, + + /** + * @return {String} the editor value as label inner html. + */ + getEditorDisplayText() { + return Prado.htmlEncode(this.editField.value); + }, + + /** + * Writes the label content and records whether that content is the empty + * placeholder, so the placeholder is never read back as a value. + * @param {String} html label content + * @param {Boolean} isEmpty true when the content is options.EmptyDisplayText + */ + setLabelContent(html, isEmpty) { + this.element.innerHTML = html; + if(isEmpty) + this.element.setAttribute(Prado.WebUI.TInPlaceControlBase.EMPTY_ATTRIBUTE, '1'); + else + this.element.removeAttribute(Prado.WebUI.TInPlaceControlBase.EMPTY_ATTRIBUTE); + }, + + /** + * Sets the label from a value, showing options.EmptyDisplayText when the value + * is empty. + * @param {String} html label content for a value that is not empty + */ + setLabelValue(html) { + const isEmpty = (html === '' || html === null || html === undefined); + this.setLabelContent(isEmpty ? (this.options.EmptyDisplayText || '') : html, isEmpty); + }, + + /** + * Refreshes the label from the editor's current state. + */ + refreshLabel() { + if(this.isEditorEmpty()) + this.setLabelContent(this.options.EmptyDisplayText || '', true); + else + this.setLabelContent(this.getEditorDisplayText(), false); + }, + + /** + * @return {Boolean} whether the label shows the empty placeholder. + */ + isShowingEmptyDisplayText() { + return this.element.hasAttribute(Prado.WebUI.TInPlaceControlBase.EMPTY_ATTRIBUTE); + }, + + /** + * Changes the empty placeholder. A label already showing the old + * placeholder is refreshed to the new one. + * @param {String} value placeholder html + */ + setEmptyDisplayText(value) { + this.options.EmptyDisplayText = value; + if(this.isShowingEmptyDisplayText()) + this.setLabelContent(value || '', true); + }, + + /** + * @return {String} label inner html; the empty placeholder reads as an + * empty string. + */ + getText() { + return this.isShowingEmptyDisplayText() ? '' : this.element.innerHTML; + }, + + /** + * Posts the editor state through a callback request. + * @param {mixed} parameter callback parameter + */ + dispatchChange(parameter) { + const request = new Prado.CallbackRequest(this.options.EventTarget, this.options); + request.setCallbackParameter(parameter); + request.options.onSuccess = (sender, param) => this.onChangeSuccess(sender, param); + request.options.onFailure = (sender, param) => this.onChangeFailure(sender, param); + if(request.dispatch()) + { + this.isSaving = true; + this.editField.disabled = true; + } + }, + + /** + * Save dispatch succeeded. Subclasses route their own named handler here. + * @param {Object} sender + * @param {Object} parameter + */ + onChangeSuccess(sender, parameter) { + this.applySaveSuccess(sender, parameter); + }, + + /** + * Save dispatch failed. Subclasses route their own named handler here. + * @param {Object} sender + * @param {Object} parameter + */ + onChangeFailure(sender, parameter) { + this.applySaveFailure(sender, parameter); + }, + + /** + * Applies a successful save: the label shows the new state and editing ends. + * @param {Object} sender + * @param {Object} parameter response text for the label, null to use the editor + */ + applySaveSuccess(sender, parameter) { + this.isSaving = false; + this.isEditing = false; + if(this.options.AutoHide) + this.showLabel(); + if(parameter == null) + this.refreshLabel(); + else + this.setLabelValue(parameter); + this.editField.disabled = false; + this.onAfterSave(); + if(typeof(this.options.onSuccess)=="function") + this.options.onSuccess(sender,parameter); + }, + + /** + * Applies a failed save: editing ends and the editor is usable again. + * @param {Object} sender + * @param {Object} parameter + */ + applySaveFailure(sender, parameter) { + this.editField.disabled = false; + this.isSaving = false; + this.isEditing = false; + if(typeof(this.options.onFailure)=="function") + this.options.onFailure(sender,parameter); + }, + + /** + * Edit mode entered, calls optional event handlers. + */ + onEnterEditMode() { + if(typeof(this.options.onEnterEditMode) == "function") + this.options.onEnterEditMode(this,null); + } +}); + + +Object.assign(Prado.WebUI.TInPlaceControlBase, +{ + //class methods + + // Marks a label element whose content is the empty placeholder. + EMPTY_ATTRIBUTE : 'data-prado-empty', + + // Every in-place control, keyed by its editor client ID. + instances : {}, + + register(obj) { + Prado.WebUI.TInPlaceControlBase.instances[obj.getRegistryKey()] = obj; + }, + + /** + * @param {String} id editor client ID + * @return {Object} the registered in-place control, undefined when unknown + */ + get(id) { + return Prado.WebUI.TInPlaceControlBase.instances[id]; + }, + + setDisplayEditor(id, value) { + const control = Prado.WebUI.TInPlaceControlBase.get(id); + if(control) + { + if(value) + control.enterEditMode(null); + else + control.exitEditMode(null); + } + }, + + setReadOnly(id, value) { + const control = Prado.WebUI.TInPlaceControlBase.get(id); + if(control) + control.readOnly = value; + }, + + setLabelText(id, value) { + const control = Prado.WebUI.TInPlaceControlBase.get(id); + if(control) + control.setLabelValue(value); + }, + + setEmptyDisplayText(id, value) { + const control = Prado.WebUI.TInPlaceControlBase.get(id); + if(control) + control.setEmptyDisplayText(value); + } +}); + + +Prado.WebUI.TInPlaceTextBox = Prado.Class(Prado.WebUI.TInPlaceControlBase, +{ + getDefaultOptions() { + return { + TextMode : 'SingleLine' + }; + }, + + /** + * Backward compatible alias of showEditor(). + */ + showTextBox() { + this.showEditor(); + }, + + /** + * The label of this control holds the text unencoded. + * @return {String} the editor value as label inner html. + */ + getEditorDisplayText() { + return this.editField.value; + }, + /** * Create the edit input field. */ @@ -78,10 +342,15 @@ Prado.WebUI.TInPlaceTextBox = Prado.Class(Prado.WebUI.Control, this.editField.value = this.getText(); }, + loadOnEdit() { + if(this.options.LoadTextOnEdit) + this.loadExternalText(); + }, + loadExternalText() { this.editField.disabled = true; this.onLoadingText(); - const options = new Array('__InlineEditor_loadExternalText__', this.getText()); + const options = ['__InlineEditor_loadExternalText__', this.getText()]; const request = new Prado.CallbackRequest(this.options.EventTarget, this.options); request.setCausesValidation(false); request.setCallbackParameter(options); @@ -97,24 +366,25 @@ Prado.WebUI.TInPlaceTextBox = Prado.Class(Prado.WebUI.Control, const cssClass= this.element.className || ''; const inputName = this.options.EventTarget; - if(this.options.TextMode == 'SingleLine') - { - this.editField = document.createElement("input"); - if(this.options.MaxLength > 0) - this.editField.maxlength = this.options.MaxLength; - if(this.options.Columns > 0) - this.editField.size = this.options.Columns; - } - else + if(this.options.TextMode == 'MultiLine') { this.editField = document.createElement("textarea"); if(this.options.Rows > 0) this.editField.rows = this.options.Rows; if(this.options.Columns > 0) this.editField.cols = this.options.Columns; - if(this.options.Wrap) + if(!this.options.Wrap) this.editField.wrap = 'off'; } + else + { + this.editField = document.createElement("input"); + this.editField.type = Prado.WebUI.TInPlaceTextBox.INPUT_TYPES[this.options.TextMode] || 'text'; + if(this.options.MaxLength > 0) + this.editField.maxlength = this.options.MaxLength; + if(this.options.Columns > 0) + this.editField.size = this.options.Columns; + } this.editField.className = cssClass; this.editField.name = inputName; @@ -122,12 +392,12 @@ Prado.WebUI.TInPlaceTextBox = Prado.Class(Prado.WebUI.Control, this.editField.style.display="none"; this.element.parentNode.insertBefore(this.editField, this.element) - //handle return key within single line textbox - if(this.options.TextMode == 'SingleLine') + //handle return key within single line inputs + if(this.options.TextMode != 'MultiLine') { this.observe(this.editField, "keydown", e => { - if(e.keyCode == 13) //KEY_RETURN - { + if(e.keyCode == 13) //KEY_RETURN + { const target = e.target; if(target) { @@ -142,23 +412,8 @@ Prado.WebUI.TInPlaceTextBox = Prado.Class(Prado.WebUI.Control, this.observe(this.editField, "keypress", this.onKeyPressed.bind(this)); }, - /** - * @return {String} panel inner html text. - */ - getText() { - return this.element.innerHTML; - }, - - /** - * Edit mode entered, calls optional event handlers. - */ - onEnterEditMode() { - if(typeof(this.options.onEnterEditMode) == "function") - this.options.onEnterEditMode(this,null); - }, - onTextBoxBlur(_e) { - const text = this.element.innerHTML; + const text = this.getText(); if(this.options.AutoPostBack && text != this.editField.value) { if(this.isEditing) @@ -166,7 +421,7 @@ Prado.WebUI.TInPlaceTextBox = Prado.Class(Prado.WebUI.Control, } else { - this.element.innerHTML = this.editField.value; + this.refreshLabel(); this.isEditing = false; if(this.options.AutoHide) this.showLabel(); @@ -188,18 +443,10 @@ Prado.WebUI.TInPlaceTextBox = Prado.Class(Prado.WebUI.Control, /** * When the text input value has changed. - * @param {String} original text + * @param {String} text the text before editing */ onTextChanged(text) { - const request = new Prado.CallbackRequest(this.options.EventTarget, this.options); - request.setCallbackParameter(text); - request.options.onSuccess = this.onTextChangedSuccess.bind(this); - request.options.onFailure = this.onTextChangedFailure.bind(this); - if(request.dispatch()) - { - this.isSaving = true; - this.editField.disabled = true; - } + this.dispatchChange(text); }, /** @@ -226,60 +473,266 @@ Prado.WebUI.TInPlaceTextBox = Prado.Class(Prado.WebUI.Control, this.options.onFailure(request, parameter); }, + onChangeSuccess(sender, parameter) { + this.onTextChangedSuccess(sender, parameter); + }, + + onChangeFailure(sender, parameter) { + this.onTextChangedFailure(sender, parameter); + }, + /** * Text change successfully. * @param {Object} sender * @param {Object} parameter */ onTextChangedSuccess(sender, parameter) { - this.isSaving = false; + this.applySaveSuccess(sender, parameter); + }, + + /** + * Text change failed. + * @param {Object} sender + * @param {Object} parameter + */ + onTextChangedFailure(sender, parameter) { + this.applySaveFailure(sender, parameter); + } +}); + + +Object.assign(Prado.WebUI.TInPlaceTextBox, +{ + //class methods + + // TTextBoxMode value => input type attribute, matching TTextBox server rendering. + INPUT_TYPES : { + SingleLine: 'text', + Password: 'password', + Color: 'color', + Date: 'date', + Datetime: 'datetime', + DatetimeLocal: 'datetime-local', + Email: 'email', + Month: 'month', + Number: 'number', + Range: 'range', + Search: 'search', + Tel: 'tel', + Time: 'time', + Url: 'url', + Week: 'week' + }, + + // Backward compatible view of the shared registry. + textboxes : Prado.WebUI.TInPlaceControlBase.instances, + + register(obj) { + Prado.WebUI.TInPlaceControlBase.register(obj); + }, + + setDisplayEditor(id, value) { + Prado.WebUI.TInPlaceControlBase.setDisplayEditor(id, value); + }, + + setDisplayTextBox(id, value) { + Prado.WebUI.TInPlaceControlBase.setDisplayEditor(id, value); + }, + + setReadOnly(id, value) { + Prado.WebUI.TInPlaceControlBase.setReadOnly(id, value); + }, + + setLabelText(id, value) { + Prado.WebUI.TInPlaceControlBase.setLabelText(id, value); + }, + + setEmptyDisplayText(id, value) { + Prado.WebUI.TInPlaceControlBase.setEmptyDisplayText(id, value); + } +}); + + +/** + * In-place drop down list. The edit element is the server-rendered select + * identified by options.EditorID; the label shows the selected option text. + * @since 4.4.0 + */ +Prado.WebUI.TInPlaceDropDownList = Prado.Class(Prado.WebUI.TInPlaceControlBase, +{ + createEditorInput() { + if(this.editField == null) + this.attachDropDown(); + }, + + /** + * Locate the server-rendered select element and bind its listeners. + */ + attachDropDown() { + this.editField = document.getElementById(this.options.EditorID); + this.observe(this.editField, "change", this.onSelectionChanged.bind(this)); + this.observe(this.editField, "blur", this.onDropDownBlur.bind(this)); + this.observe(this.editField, "keydown", this.onKeyPressed.bind(this)); + }, + + onEnterEditMode($super) { + this.originalValue = this.editField.value; + $super(); + }, + + loadOnEdit() { + if(this.options.LoadItemsOnEdit) + this.loadItems(); + }, + + loadItems() { + this.editField.disabled = true; + const options = ['__InlineEditor_loadItems__', this.editField.value]; + const request = new Prado.CallbackRequest(this.options.EventTarget, this.options); + request.setCausesValidation(false); + request.setCallbackParameter(options); + request.options.onSuccess = this.onLoadItemsSuccess.bind(this); + request.options.onFailure = this.onLoadItemsFailure.bind(this); + request.dispatch(); + }, + + /** + * @return {String} text of the selected option, empty when none is selected. + */ + getSelectedText() { + const index = this.editField.selectedIndex; + const option = index >= 0 ? this.editField.options[index] : null; + return option ? option.text : ''; + }, + + isEditorEmpty() { + return this.getSelectedText() === ''; + }, + + getEditorDisplayText() { + return Prado.htmlEncode(this.getSelectedText()); + }, + + /** + * A changed selection posts the new value; this is the only save trigger, + * since a select fires its change event before losing focus. + * @param {Event} _event + */ + onSelectionChanged(_event) { + if(this.options.AutoPostBack && this.isEditing && !this.isSaving) + this.onValueChanged(); + }, + + /** + * Losing focus leaves edit mode and shows the current selection. + * @param {Event} _e + */ + onDropDownBlur(_e) { + if(this.isSaving || !this.isEditing) + return; + this.refreshLabel(); this.isEditing = false; if(this.options.AutoHide) this.showLabel(); - this.element.innerHTML = parameter == null ? this.editField.value : parameter; + }, + + onKeyPressed(e) { + if (e.keyCode == 27) //KEY_ESC + { + this.editField.value = this.originalValue; + this.refreshLabel(); + this.isEditing = false; + if(this.options.AutoHide) + this.showLabel(); + } + else if (e.keyCode == 13) //KEY_RETURN + { + e.preventDefault(); + this.editField.blur(); + } + }, + + /** + * When the selection has changed, posts the selection through a callback. + */ + onValueChanged() { + this.dispatchChange(this.originalValue); + }, + + onAfterSave() { + this.originalValue = this.editField.value; + }, + + onChangeSuccess(sender, parameter) { + this.onValueChangedSuccess(sender, parameter); + }, + + onChangeFailure(sender, parameter) { + this.onValueChangedFailure(sender, parameter); + }, + + /** + * Selection change successfully. + * @param {Object} sender + * @param {Object} parameter + */ + onValueChangedSuccess(sender, parameter) { + this.applySaveSuccess(sender, parameter); + }, + + /** + * Selection change failed. + * @param {Object} sender + * @param {Object} parameter + */ + onValueChangedFailure(sender, parameter) { + this.applySaveFailure(sender, parameter); + }, + + onLoadItemsSuccess(request, parameter) { + this.isEditing = true; this.editField.disabled = false; + this.originalValue = this.editField.value; + this.editField.focus(); if(typeof(this.options.onSuccess)=="function") - this.options.onSuccess(sender,parameter); + this.options.onSuccess(request, parameter); }, - onTextChangedFailure(sender, parameter) { - this.editField.disabled = false; + onLoadItemsFailure(request, parameter) { this.isSaving = false; this.isEditing = false; + this.editField.disabled = false; + this.showLabel(); if(typeof(this.options.onFailure)=="function") - this.options.onFailure(sender,parameter); + this.options.onFailure(request, parameter); } }); -Object.assign(Prado.WebUI.TInPlaceTextBox, +Object.assign(Prado.WebUI.TInPlaceDropDownList, { //class methods - textboxes : {}, + // Backward compatible view of the shared registry. + dropdowns : Prado.WebUI.TInPlaceControlBase.instances, register(obj) { - Prado.WebUI.TInPlaceTextBox.textboxes[obj.options.TextBoxID] = obj; + Prado.WebUI.TInPlaceControlBase.register(obj); }, - setDisplayTextBox(id, value) { - const textbox = Prado.WebUI.TInPlaceTextBox.textboxes[id]; - if(textbox) - { - if(value) - textbox.enterEditMode(null); - else - { - textbox.exitEditMode(null); - } - } + setDisplayEditor(id, value) { + Prado.WebUI.TInPlaceControlBase.setDisplayEditor(id, value); }, setReadOnly(id, value) { - const textbox = Prado.WebUI.TInPlaceTextBox.textboxes[id]; - if (textbox) - { - textbox.readOnly=value; - } + Prado.WebUI.TInPlaceControlBase.setReadOnly(id, value); + }, + + setLabelText(id, value) { + Prado.WebUI.TInPlaceControlBase.setLabelText(id, value); + }, + + setEmptyDisplayText(id, value) { + Prado.WebUI.TInPlaceControlBase.setEmptyDisplayText(id, value); } -}); \ No newline at end of file +}); diff --git a/framework/Web/Javascripts/source/prado/prado.js b/framework/Web/Javascripts/source/prado/prado.js index 6d0a0afdd..32e9b4589 100644 --- a/framework/Web/Javascripts/source/prado/prado.js +++ b/framework/Web/Javascripts/source/prado/prado.js @@ -113,7 +113,25 @@ var Prado = * @var Class * @since 4.4.0 */ - Class: globalThis.__PradoClass + Class: globalThis.__PradoClass, + + /** + * Html-encodes text for insertion into element inner html. Mirrors the + * server-side THttpUtility::htmlEncode translation, and additionally + * encodes the ampersand so a re-render of the same text is stable. + * @param {String} text plain text + * @return {String} encoded text + * @since 4.4.0 + */ + htmlEncode(text) { + if (text === null || text === undefined) return ''; + return String(text) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } }; delete globalThis.__PradoClass; diff --git a/framework/Web/UI/ActiveControls/TInPlaceControlTrait.php b/framework/Web/UI/ActiveControls/TInPlaceControlTrait.php new file mode 100644 index 000000000..e96779f9d --- /dev/null +++ b/framework/Web/UI/ActiveControls/TInPlaceControlTrait.php @@ -0,0 +1,178 @@ + + * @link https://github.com/pradosoft/prado + * @license https://github.com/pradosoft/prado/blob/master/LICENSE + */ + +namespace Prado\Web\UI\ActiveControls; + +use Prado\TPropertyValue; + +/** + * TInPlaceControlTrait trait. + * + * TInPlaceControlTrait implements the surface shared by in-place editor + * controls. An in-place control renders a label element that swaps to an + * edit element when the label, or the control given by + * {@see setEditTriggerControlID EditTriggerControlID}, is clicked. + * + * The trait provides the label client ID, the edit trigger property, calls + * of client-side static methods, and registration of the in-place editor + * client script. + * + * @author Brad Anderson + * @since 4.4.0 + */ +trait TInPlaceControlTrait +{ + /** + * @param bool $value true to hide the editor after losing focus. + */ + public function setAutoHideEditor($value) + { + $this->setViewState('AutoHide', TPropertyValue::ensureBoolean($value), true); + } + + /** + * @return bool true will hide the editor after losing focus. + */ + public function getAutoHideEditor() + { + return $this->getViewState('AutoHide', true); + } + + /** + * The editor is shown on the client during a callback. + * @param bool $value true to display the editor. + */ + public function setDisplayEditor($value) + { + $value = TPropertyValue::ensureBoolean($value); + $this->setViewState('DisplayEditor', $value, false); + if ($this->getActiveControl()->canUpdateClientSide()) { + $this->callClientFunction('setDisplayEditor', $value); + } + } + + /** + * @return bool true to display the editor. + */ + public function getDisplayEditor() + { + return $this->getViewState('DisplayEditor', false); + } + + /** + * @param string $value ID of the control that can trigger editing. + */ + public function setEditTriggerControlID($value) + { + $this->setViewState('EditTriggerControlID', $value); + } + + /** + * @return string ID of the control that can trigger editing. + */ + public function getEditTriggerControlID() + { + return $this->getViewState('EditTriggerControlID'); + } + + /** + * The client-side placeholder is updated during a callback, so a label + * already showing the empty display text follows the change. + * @param string $value label html shown when the value is empty. + */ + public function setEmptyDisplayText($value) + { + $value = TPropertyValue::ensureString($value); + if ($this->getEmptyDisplayText() === $value) { + return; + } + + $this->setViewState('EmptyDisplayText', $value, ''); + if ($this->getActiveControl()->canUpdateClientSide()) { + $this->callClientFunction('setEmptyDisplayText', $value); + } + } + + /** + * @return string label html shown when the value is empty. + */ + public function getEmptyDisplayText() + { + return $this->getViewState('EmptyDisplayText', ''); + } + + /** + * @return string edit trigger control client ID. + */ + protected function getExternalControlID() + { + $extID = $this->getEditTriggerControlID(); + if ($extID === null) { + return ''; + } + if (($control = $this->findControl($extID)) !== null) { + return $control->getClientID(); + } + return $extID; + } + + /** + * Marks the label element when it shows the empty display text. The + * client reads the mark to tell the placeholder apart from a value. + * @param \Prado\Web\UI\THtmlWriter $writer the writer for rendering + * @param bool $isEmpty whether the label shows the empty display text + */ + protected function renderEmptyDisplayAttribute($writer, $isEmpty) + { + if ($isEmpty && $this->getEmptyDisplayText() !== '') { + $writer->addAttribute('data-prado-empty', '1'); + } + } + + /** + * @return string label client ID + */ + protected function getLabelClientID() + { + return $this->getClientID() . '__label'; + } + + /** + * Calls the client-side static method for this control class. + * @param string $func static method name + * @param mixed $value method parameter + */ + protected function callClientFunction($func, $value) + { + $client = $this->getPage()->getCallbackClient(); + $code = $this->getClientClassName() . '.' . $func; + $client->callClientFunction($code, [$this, $value]); + } + + /** + * Registers CSS and JS. + * This method is invoked right before the control rendering, if the control is visible. + * @param mixed $param event parameter + */ + public function onPreRender($param) + { + parent::onPreRender($param); + $this->registerClientScript(); + } + + /** + * Registers the relevant JavaScript. + */ + protected function registerClientScript() + { + $cs = $this->getPage()->getClientScript(); + $cs->registerPradoScript('inlineeditor'); + } +} diff --git a/framework/Web/UI/ActiveControls/TInPlaceDropDownList.php b/framework/Web/UI/ActiveControls/TInPlaceDropDownList.php new file mode 100644 index 000000000..81fc89d6d --- /dev/null +++ b/framework/Web/UI/ActiveControls/TInPlaceDropDownList.php @@ -0,0 +1,258 @@ + + * @link https://github.com/pradosoft/prado + * @license https://github.com/pradosoft/prado/blob/master/LICENSE + */ + +namespace Prado\Web\UI\ActiveControls; + +use Prado\TPropertyValue; +use Prado\Web\THttpUtility; +use Prado\Web\UI\WebControls\TDropDownList; + +/** + * TInPlaceDropDownList class. + * + * TInPlaceDropDownList is a drop down list rendered as a label showing the + * selected item text. Clicking the label, or the control given by + * {@see setEditTriggerControlID EditTriggerControlID}, swaps the label for + * the drop down list. When no item is selected or the selected item text is + * empty, the label shows {@see setEmptyDisplayText EmptyDisplayText}. + * + * When {@see \Prado\Web\UI\WebControls\TListControl::setAutoPostBack AutoPostBack} + * is true (the default), changing the selection makes a callback request that + * raises {@see \Prado\Web\UI\WebControls\TListControl::onSelectedIndexChanged OnSelectedIndexChanged} + * and {@see onCallback OnCallback}. During the request the drop down list is + * disabled. After the request returns successfully, the label shows the new + * selection and, when {@see setAutoHideEditor AutoHideEditor} is true, the + * drop down list is hidden and the label is shown. + * + * If the {@see onLoadingItems OnLoadingItems} event is handled, a callback + * request is made when the label is clicked. The event allows the item list + * to be updated, through the active list adapter, before the client selects + * a value. + * + * The {@see setReadOnly ReadOnly} property prevents entering edit mode. The + * property can be changed during a callback. + * + * @author Brad Anderson + * @since 4.4.0 + */ +class TInPlaceDropDownList extends TActiveDropDownList +{ + use TInPlaceControlTrait { + onPreRender as protected inPlaceOnPreRender; + } + + /** + * @param bool $value true to prevent entering edit mode. + */ + public function setReadOnly($value) + { + $value = TPropertyValue::ensureBoolean($value); + if ($this->getReadOnly() === $value) { + return; + } + + $this->setViewState('ReadOnly', $value, false); + if ($this->getActiveControl()->canUpdateClientSide()) { + $this->callClientFunction('setReadOnly', $value); + } + } + + /** + * @return bool true to prevent entering edit mode. Defaults to false. + */ + public function getReadOnly() + { + return $this->getViewState('ReadOnly', false); + } + + /** + * On callback response, the label is updated with the new selection. + * @param string $value value of the item to be selected + */ + public function setSelectedValue($value) + { + $prior = $this->getSelectedValue(); + parent::setSelectedValue($value); + if ($prior !== $this->getSelectedValue() && $this->getActiveControl()->canUpdateClientSide()) { + $this->updateLabel(); + } + } + + /** + * On callback response, the label is updated with the new selection. + * @param int $index index of the item to be selected + */ + public function setSelectedIndex($index) + { + $prior = $this->getSelectedIndex(); + parent::setSelectedIndex($index); + if ($prior !== $this->getSelectedIndex() && $this->getActiveControl()->canUpdateClientSide()) { + $this->updateLabel(); + } + } + + /** + * Updates the client-side label with the selected display text. + */ + protected function updateLabel() + { + $this->callClientFunction('setLabelText', $this->getSelectedItemText()); + } + + /** + * @return string encoded selected item text, empty when nothing is selected. + */ + protected function getSelectedItemText() + { + $item = $this->getSelectedItem(); + if ($item === null || ($text = $item->getText()) === '') { + return ''; + } + return THttpUtility::htmlEncode($text); + } + + /** + * @return string encoded selected item text, or {@see getEmptyDisplayText} when empty. + */ + protected function getSelectedDisplayText() + { + if (($text = $this->getSelectedItemText()) === '') { + return $this->getEmptyDisplayText(); + } + return $text; + } + + /** + * Renders the label followed by the drop down list. + * @param \Prado\Web\UI\THtmlWriter $writer the writer for rendering + */ + public function render($writer) + { + $this->renderLabel($writer); + parent::render($writer); + } + + /** + * Renders the label span holding the selected display text. + * @param \Prado\Web\UI\THtmlWriter $writer the writer for rendering + */ + protected function renderLabel($writer) + { + $writer->addAttribute('id', $this->getLabelClientID()); + if ($this->getHasStyle()) { + $this->getStyle()->addAttributesToRender($writer); + } + if (($toolTip = $this->getToolTip()) !== '') { + $writer->addAttribute('title', $toolTip); + } + $this->renderEmptyDisplayAttribute($writer, $this->getSelectedItemText() === ''); + if ($this->getDisplayEditor()) { + $writer->addStyleAttribute('display', 'none'); + } + $writer->renderBeginTag('span'); + $writer->write($this->getSelectedDisplayText()); + $writer->renderEndTag(); + } + + /** + * Ensures the ID attribute is rendered, hides the drop down list when the + * label is displayed, and registers the javascript code for initializing + * the active control. + * @param \Prado\Web\UI\THtmlWriter $writer the writer for rendering + */ + protected function addAttributesToRender($writer) + { + //calls the TDropDownList to always register the in-place client class. + TDropDownList::addAttributesToRender($writer); + $writer->addAttribute('id', $this->getClientID()); + if (!$this->getDisplayEditor()) { + $writer->addStyleAttribute('display', 'none'); + } + $this->getActiveControl()->registerCallbackClientScript( + $this->getClientClassName(), + $this->getPostBackOptions() + ); + } + + /** + * @return array callback options. + */ + protected function getPostBackOptions() + { + $options = parent::getPostBackOptions(); + $options['ID'] = $this->getLabelClientID(); + $options['EditorID'] = $this->getClientID(); + $options['ExternalControl'] = $this->getExternalControlID(); + $options['AutoHide'] = $this->getAutoHideEditor(); + $options['AutoPostBack'] = $this->getAutoPostBack(); + $options['EmptyDisplayText'] = $this->getEmptyDisplayText(); + $options['DisplayEditor'] = $this->getDisplayEditor(); + + if ($this->hasEventHandler('OnLoadingItems')) { + $options['LoadItemsOnEdit'] = true; + } + + $options['ReadOnly'] = $this->getReadOnly(); + return $options; + } + + /** + * Refreshes the client-side label when the item list changed during a + * callback, so a new text for the selected value reaches the label. + * @param mixed $param event parameter + */ + public function onPreRender($param) + { + $items = $this->getItems(); + $listChanged = ($items instanceof TActiveListItemCollection) && $items->getListHasChanged(); + + $this->inPlaceOnPreRender($param); + + if ($listChanged && $this->getActiveControl()->canUpdateClientSide()) { + $this->updateLabel(); + } + } + + /** + * This method is invoked when a callback is requested. The method raises + * 'OnCallback' event to fire up the event handlers. If you override this + * method, be sure to call the parent implementation so that the event + * handler can be invoked. + * @param TCallbackEventParameter $param event parameter to be passed to the event handlers + */ + public function onCallback($param) + { + $action = $param->getCallbackParameter(); + if (is_array($action) && $action[0] === '__InlineEditor_loadItems__') { + $parameter = new TCallbackEventParameter($this->getResponse(), $action[1]); + $this->onLoadingItems($parameter); + } + $this->raiseEvent('OnCallback', $this, $param); + } + + /** + * Raised when the item list is requested to be loaded from the server + * side. The callback parameter holds the drop down list value at the time + * editing is entered. + * @param TCallbackEventParameter $param event parameter to be passed to the event handlers + */ + public function onLoadingItems($param) + { + $this->raiseEvent('OnLoadingItems', $this, $param); + } + + /** + * @return string corresponding javascript class name for this TInPlaceDropDownList + */ + protected function getClientClassName() + { + return 'Prado.WebUI.TInPlaceDropDownList'; + } +} diff --git a/framework/Web/UI/ActiveControls/TInPlaceTextBox.php b/framework/Web/UI/ActiveControls/TInPlaceTextBox.php index 2f6df7bdb..525afe25f 100644 --- a/framework/Web/UI/ActiveControls/TInPlaceTextBox.php +++ b/framework/Web/UI/ActiveControls/TInPlaceTextBox.php @@ -42,11 +42,16 @@ * Since 3.1.2, you can set the {@see setReadOnly ReadOnly} property to make * the control not editable. This property can be also changed on callback * + * Since 4.4.0, when the text is empty the label shows + * {@see setEmptyDisplayText EmptyDisplayText}, keeping the label clickable. + * * @author Wei Zhuo * @since 3.1 */ class TInPlaceTextBox extends TActiveTextBox { + use TInPlaceControlTrait; + /** * Sets the auto post back to true by default. */ @@ -57,82 +62,47 @@ public function __construct() } /** + * Alias of {@see setAutoHideEditor AutoHideEditor}, the name shared by the + * in-place control family. * @param bool $value true to hide the textbox after losing focus. + * @deprecated 4.4.0 use {@see setAutoHideEditor AutoHideEditor} */ public function setAutoHideTextBox($value) { - $this->setViewState('AutoHide', TPropertyValue::ensureBoolean($value), true); + $this->setAutoHideEditor($value); } /** + * Alias of {@see getAutoHideEditor AutoHideEditor}, the name shared by the + * in-place control family. * @return bool true will hide the textbox after losing focus. + * @deprecated 4.4.0 use {@see getAutoHideEditor AutoHideEditor} */ public function getAutoHideTextBox() { - return $this->getViewState('AutoHide', true); + return $this->getAutoHideEditor(); } /** + * Alias of {@see setDisplayEditor DisplayEditor}, the name shared by the + * in-place control family. * @param bool $value true to display the edit textbox + * @deprecated 4.4.0 use {@see setDisplayEditor DisplayEditor} */ public function setDisplayTextBox($value) { - $value = TPropertyValue::ensureBoolean($value); - $this->setViewState('DisplayTextBox', $value, false); - if ($this->getActiveControl()->canUpdateClientSide()) { - $this->callClientFunction('setDisplayTextBox', $value); - } + $this->setDisplayEditor($value); } /** + * Alias of {@see getDisplayEditor DisplayEditor}, the name shared by the + * in-place control family. * @return bool true to display the edit textbox + * @deprecated 4.4.0 use {@see getDisplayEditor DisplayEditor} */ public function getDisplayTextBox() { - return $this->getViewState('DisplayTextBox', false); - } - - /** - * Calls the client-side static method for this control class. - * @param string $func static method name - * @param mixed $value method parmaeter - */ - protected function callClientFunction($func, $value) - { - $client = $this->getPage()->getCallbackClient(); - $code = $this->getClientClassName() . '.' . $func; - $client->callClientFunction($code, [$this, $value]); - } - - /** - * @param string $value ID of the control that can trigger to edit the textbox - */ - public function setEditTriggerControlID($value) - { - $this->setViewState('EditTriggerControlID', $value); - } - - /** - * @return string ID of the control that can trigger to edit the textbox - */ - public function getEditTriggerControlID() - { - return $this->getViewState('EditTriggerControlID'); - } - - /** - * @return string edit trigger control client ID. - */ - protected function getExternalControlID() - { - $extID = $this->getEditTriggerControlID(); - if ($extID === null) { - return ''; - } - if (($control = $this->findControl($extID)) !== null) { - return $control->getClientID(); - } - return $extID; + return $this->getDisplayEditor(); } /** @@ -148,9 +118,8 @@ public function setText($value) TTextBox::setText($value); if ($this->getActiveControl()->canUpdateClientSide()) { - $client = $this->getPage()->getCallbackClient(); - $client->update($this->getLabelClientID(), $value); - $client->setValue($this, $value); + $this->callClientFunction('setLabelText', $value); + $this->getPage()->getCallbackClient()->setValue($this, $value); } } @@ -181,26 +150,21 @@ protected function getTagName() } /** - * Renders the body content of the label. + * Renders the body content of the label. An empty text renders + * {@see getEmptyDisplayText EmptyDisplayText} when set. * @param \Prado\Web\UI\THtmlWriter $writer the writer for rendering */ public function renderContents($writer) { - if (($text = $this->getText()) === '') { - parent::renderContents($writer); - } else { + if (($text = $this->getText()) !== '') { $writer->write($text); + } elseif (($emptyText = $this->getEmptyDisplayText()) !== '') { + $writer->write($emptyText); + } else { + parent::renderContents($writer); } } - /** - * @return string label client ID - */ - protected function getLabelClientID() - { - return $this->getClientID() . '__label'; - } - /** * This method is invoked when a callback is requested. The method raises * 'OnCallback' event to fire up the event handlers. If you override this @@ -226,9 +190,12 @@ protected function getPostBackOptions() $options = parent::getPostBackOptions(); $options['ID'] = $this->getLabelClientID(); $options['TextBoxID'] = $this->getClientID(); + $options['EditorID'] = $this->getClientID(); $options['ExternalControl'] = $this->getExternalControlID(); - $options['AutoHide'] = $this->getAutoHideTextBox() == false ? '' : true; + $options['AutoHide'] = $this->getAutoHideEditor() == false ? '' : true; $options['AutoPostBack'] = $this->getAutoPostBack() == false ? '' : true; + $options['EmptyDisplayText'] = $this->getEmptyDisplayText(); + $options['DisplayEditor'] = $this->getDisplayEditor(); $options['Columns'] = $this->getColumns(); if ($this->getTextMode() === 'MultiLine') { $options['Rows'] = $this->getRows(); @@ -274,29 +241,10 @@ protected function addAttributesToRender($writer) //calls the TWebControl to avoid rendering other attribute normally render for a textbox. TWebControl::addAttributesToRender($writer); $writer->addAttribute('id', $this->getLabelClientID()); + $this->renderEmptyDisplayAttribute($writer, $this->getText() === ''); $this->getActiveControl()->registerCallbackClientScript( $this->getClientClassName(), $this->getPostBackOptions() ); } - - /** - * Registers CSS and JS. - * This method is invoked right before the control rendering, if the control is visible. - * @param mixed $param event parameter - */ - public function onPreRender($param) - { - parent::onPreRender($param); - $this->registerClientScript(); - } - - /** - * Registers the relevant JavaScript. - */ - protected function registerClientScript() - { - $cs = $this->getPage()->getClientScript(); - $cs->registerPradoScript('inlineeditor'); - } } diff --git a/framework/classes.php b/framework/classes.php index 607e4d0b5..1c60a7ec1 100644 --- a/framework/classes.php +++ b/framework/classes.php @@ -610,6 +610,8 @@ 'TCallbackResponseAdapter' => 'Prado\Web\UI\ActiveControls\TCallbackResponseAdapter', 'TCallbackResponseWriter' => 'Prado\Web\UI\ActiveControls\TCallbackResponseWriter', 'TEventTriggeredCallback' => 'Prado\Web\UI\ActiveControls\TEventTriggeredCallback', +'TInPlaceControlTrait' => 'Prado\Web\UI\ActiveControls\TInPlaceControlTrait', +'TInPlaceDropDownList' => 'Prado\Web\UI\ActiveControls\TInPlaceDropDownList', 'TInPlaceTextBox' => 'Prado\Web\UI\ActiveControls\TInPlaceTextBox', 'TInvalidCallbackException' => 'Prado\Web\UI\ActiveControls\TInvalidCallbackException', 'TMapCollectionDiff' => 'Prado\Web\UI\ActiveControls\TMapCollectionDiff', diff --git a/tests/harness/web/protected/pages/InPlaceDropDownListTest.page b/tests/harness/web/protected/pages/InPlaceDropDownListTest.page new file mode 100644 index 000000000..b744873af --- /dev/null +++ b/tests/harness/web/protected/pages/InPlaceDropDownListTest.page @@ -0,0 +1,26 @@ + +

In Place Drop Down List Test Case

+ +
+ + + + + +
+ +
Status:
+ + + + + + +

Load items on edit

+
+ + + +
+
Lazy:
+
diff --git a/tests/harness/web/protected/pages/InPlaceDropDownListTest.php b/tests/harness/web/protected/pages/InPlaceDropDownListTest.php new file mode 100644 index 000000000..64497bcfc --- /dev/null +++ b/tests/harness/web/protected/pages/InPlaceDropDownListTest.php @@ -0,0 +1,56 @@ +status->Text = 'changed: ' . $sender->getSelectedValue(); + } + + public function server_select($sender, $param) + { + $this->ddl->setSelectedValue('blue'); + $this->status->Text = 'server selected'; + } + + public function rename_item($sender, $param) + { + // Replaces the selected item with one carrying the same value and a new + // text. The selection does not change, so only the item list update + // refreshes the label. + $items = $this->ddl->getItems(); + $index = $this->ddl->getSelectedIndex(); + $items->removeAt($index); + $items->insertAt($index, new TListItem('Renamed', 'red', true, true)); + $this->status->Text = 'renamed'; + } + + public function change_empty_text($sender, $param) + { + $this->lazy->getItems()->clear(); + $this->lazy->setEmptyDisplayText('(nothing left)'); + $this->lazyStatus->Text = 'empty text changed'; + } + + public function make_readonly($sender, $param) + { + $this->ddl->setReadOnly(true); + $this->status->Text = 'readonly'; + } + + public function load_items($sender, $param) + { + $items = $this->lazy->getItems(); + $items->clear(); + $items->add('Fresh A'); + $items->itemAt(0)->setValue('a'); + $items->add('Fresh B'); + $items->itemAt(1)->setValue('b'); + $this->lazy->setSelectedValue('a'); + } + + public function lazy_changed($sender, $param) + { + $this->lazyStatus->Text = 'changed: ' . $sender->getSelectedValue(); + } +} diff --git a/tests/js/activecontrols/inlineeditor.test.js b/tests/js/activecontrols/inlineeditor.test.js index adb0cda10..42c73380b 100644 --- a/tests/js/activecontrols/inlineeditor.test.js +++ b/tests/js/activecontrols/inlineeditor.test.js @@ -1,64 +1,11 @@ -/** - * Tests for inlineeditor.js — Prado.WebUI.TInPlaceTextBox - * - * Source: - * framework/Web/Javascripts/source/prado/activecontrols/inlineeditor.js - * - * Strategy - * -------- - * Each test sets up a minimal DOM: a label (the display element) and, - * after construction, the hidden injected by createTextBox(). Network - * calls are prevented by replacing Prado.CallbackRequest with a vi mock. - * - * ESM note: only tests/js/adapters/inlineeditor.js changes on ESM conversion. - */ - -import { TInPlaceTextBox, Registry } from '../adapters/inlineeditor.js'; - -// ─── Helpers ───────────────────────────────────────────────────────────────── - -/** Remove all keys from Prado.Registry. */ -function clearRegistry() { - for (const k of Object.keys(global.Prado.Registry)) { - delete global.Prado.Registry[k]; - } -} - -/** Clear TInPlaceTextBox.textboxes between tests. */ -function clearTextboxes() { - for (const k of Object.keys(TInPlaceTextBox.textboxes)) { - delete TInPlaceTextBox.textboxes[k]; - } -} +import { TInPlaceTextBox, EMPTY_ATTRIBUTE, Registry } from '../adapters/inlineeditor.js'; +import { clearRegistry, clearMap, mockCallbackRequest, restoreMocks } from '../helpers/callbackMock.js'; -/** - * Mock Prado.CallbackRequest so that no XHR is ever made. - * Uses a real constructor function (not an arrow) so `new` works. - */ -function mockCallbackRequest(dispatchReturnValue = true) { - const dispatchMock = vi.fn().mockReturnValue(dispatchReturnValue); - const setCallbackParameterMock = vi.fn(); - const setCausesValidationMock = vi.fn(); - const instance = { - dispatch: dispatchMock, - setCallbackParameter: setCallbackParameterMock, - setCausesValidation: setCausesValidationMock, - options: {}, - }; - - const original = global.Prado.CallbackRequest; - const MockCtor = vi.fn(function () { return instance; }); - MockCtor.__original = original; - global.Prado.CallbackRequest = MockCtor; - - return { instance, dispatchMock, setCallbackParameterMock, setCausesValidationMock }; -} +// ─── Helpers ────────────────────────────────────────────────────────── -function restoreMocks() { - vi.restoreAllMocks(); - if (global.Prado.CallbackRequest?.__original !== undefined) { - global.Prado.CallbackRequest = global.Prado.CallbackRequest.__original; - } +/** Clear the shared in-place control registry between tests. */ +function clearTextboxes() { + clearMap(TInPlaceTextBox.textboxes); } /** Standard options for TInPlaceTextBox construction. */ @@ -67,6 +14,7 @@ function makeOptions(overrides = {}) { { ID: 'lbl1', TextBoxID: 'tb_lbl1', + EditorID: 'tb_lbl1', EventTarget: 'lbl1', TextMode: 'SingleLine', ReadOnly: false, @@ -464,9 +412,6 @@ describe('TInPlaceTextBox onKeyPressed', () => { }); it('does NOT call preventDefault on ENTER in MultiLine mode', () => { - clearRegistry(); clearTextboxes(); - ({ container } = buildDOM('lbl1')); - // Re-build container for MultiLine (avoid double-ID) const ctrl = new TInPlaceTextBox(makeOptions({ TextMode: 'MultiLine' })); const evt = { keyCode: 13, preventDefault: vi.fn() }; ctrl.onKeyPressed(evt); @@ -719,6 +664,94 @@ describe('TInPlaceTextBox static helpers', () => { }); }); +// ─── EmptyDisplayText option ───────────────────────────────────────────────────────── + +describe('TInPlaceTextBox EmptyDisplayText', () => { + let container, label; + + beforeEach(() => { + clearRegistry(); clearTextboxes(); + ({ container, label } = buildDOM('lbl1', '(none)')); + }); + + afterEach(() => { restoreMocks(); container.remove(); }); + + it('getText reads the marked EmptyDisplayText placeholder as an empty string', () => { + label.setAttribute(EMPTY_ATTRIBUTE, '1'); + const ctrl = new TInPlaceTextBox(makeOptions({ EmptyDisplayText: '(none)' })); + expect(ctrl.getText()).toBe(''); + }); + + it('getText reads real text equal to EmptyDisplayText as that text', () => { + // No marker: the server rendered a value that happens to match EmptyDisplayText. + const ctrl = new TInPlaceTextBox(makeOptions({ EmptyDisplayText: '(none)' })); + expect(ctrl.getText()).toBe('(none)'); + }); + + it('starts editing with an empty editField when the label shows EmptyDisplayText', () => { + label.setAttribute(EMPTY_ATTRIBUTE, '1'); + const ctrl = new TInPlaceTextBox(makeOptions({ EmptyDisplayText: '(none)' })); + ctrl.enterEditMode(null); + expect(ctrl.editField.value).toBe(''); + }); + + it('restores EmptyDisplayText in the label when blurring with an empty value', () => { + label.setAttribute(EMPTY_ATTRIBUTE, '1'); + const ctrl = new TInPlaceTextBox(makeOptions({ EmptyDisplayText: '(none)', AutoPostBack: false })); + ctrl.enterEditMode(null); + ctrl.onTextBoxBlur({}); + expect(label.innerHTML).toBe('(none)'); + expect(ctrl.isShowingEmptyDisplayText()).toBe(true); + }); + + it('does not dispatch on blur when the empty value is unchanged (AutoPostBack)', () => { + label.setAttribute(EMPTY_ATTRIBUTE, '1'); + const { dispatchMock } = mockCallbackRequest(); + const ctrl = new TInPlaceTextBox(makeOptions({ EmptyDisplayText: '(none)', AutoPostBack: true })); + ctrl.enterEditMode(null); + ctrl.onTextBoxBlur({}); + expect(dispatchMock).not.toHaveBeenCalled(); + expect(label.innerHTML).toBe('(none)'); + }); + + it('shows the typed value in the label after editing, clearing the marker', () => { + label.setAttribute(EMPTY_ATTRIBUTE, '1'); + const ctrl = new TInPlaceTextBox(makeOptions({ EmptyDisplayText: '(none)', AutoPostBack: false })); + ctrl.enterEditMode(null); + ctrl.editField.value = 'Typed'; + ctrl.onTextBoxBlur({}); + expect(label.innerHTML).toBe('Typed'); + expect(ctrl.isShowingEmptyDisplayText()).toBe(false); + }); + + it('onTextChangedSuccess shows EmptyDisplayText when the saved value is empty', () => { + const ctrl = new TInPlaceTextBox(makeOptions({ EmptyDisplayText: '(none)' })); + ctrl.enterEditMode(null); + ctrl.editField.value = ''; + ctrl.onTextChangedSuccess({}, null); + expect(label.innerHTML).toBe('(none)'); + expect(ctrl.isShowingEmptyDisplayText()).toBe(true); + }); + + it('EmptyDisplayText containing markup survives a browser innerHTML round trip', () => { + // The marker, not a string compare, decides emptiness. + label.innerHTML = 'Tom & Jerry'; + label.setAttribute(EMPTY_ATTRIBUTE, '1'); + const ctrl = new TInPlaceTextBox(makeOptions({ EmptyDisplayText: 'Tom & Jerry' })); + expect(ctrl.getText()).toBe(''); + ctrl.enterEditMode(null); + expect(ctrl.editField.value).toBe(''); + }); + + it('without EmptyDisplayText an empty value leaves the label empty (legacy behavior)', () => { + label.innerHTML = ''; + const ctrl = new TInPlaceTextBox(makeOptions({ AutoPostBack: false })); + ctrl.enterEditMode(null); + ctrl.onTextBoxBlur({}); + expect(label.innerHTML).toBe(''); + }); +}); + // ─── ExternalControl option ─────────────────────────────────────────────────── describe('TInPlaceTextBox ExternalControl', () => { diff --git a/tests/js/activecontrols/inplacedropdownlist.test.js b/tests/js/activecontrols/inplacedropdownlist.test.js new file mode 100644 index 000000000..39111b592 --- /dev/null +++ b/tests/js/activecontrols/inplacedropdownlist.test.js @@ -0,0 +1,727 @@ +import { + TInPlaceControlBase, + TInPlaceTextBox, + TInPlaceDropDownList, + EMPTY_ATTRIBUTE, + Registry, +} from '../adapters/inlineeditor.js'; +import { clearRegistry, clearMap, mockCallbackRequest, restoreMocks } from '../helpers/callbackMock.js'; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** Clear the shared in-place control registry between tests. */ +function clearDropDowns() { + clearMap(TInPlaceDropDownList.dropdowns); +} + +/** The registry is shared; the TextMode tests below build text boxes. */ +const clearTextboxes = clearDropDowns; + +/** Standard options for TInPlaceDropDownList construction. */ +function makeOptions(overrides = {}) { + return Object.assign( + { + ID: 'ddl1__label', + EditorID: 'ddl1', + EventTarget: 'ddl1', + ReadOnly: false, + AutoPostBack: true, + AutoHide: true, + EmptyDisplayText: '', + }, + overrides, + ); +} + +/** + * Build the DOM TInPlaceDropDownList expects: a label span followed by a + * hidden select with options, as the server renders them. + */ +function buildDOM(items = ['Alpha', 'Beta', 'Gamma'], selectedIndex = 0) { + const container = document.createElement('div'); + const label = document.createElement('span'); + label.id = 'ddl1__label'; + label.innerHTML = items[selectedIndex] ?? ''; + container.appendChild(label); + + const select = document.createElement('select'); + select.id = 'ddl1'; + select.name = 'ddl1'; + select.style.display = 'none'; + for (const [i, text] of items.entries()) { + const option = document.createElement('option'); + option.value = String(i); + option.text = text; + option.selected = i === selectedIndex; + select.appendChild(option); + } + container.appendChild(select); + document.body.appendChild(container); + return { container, label, select }; +} + +// ─── Class hierarchy ───────────────────────────────────────────────────────── + +describe('TInPlaceControlBase hierarchy', () => { + it('TInPlaceControlBase is a function (constructor)', () => { + expect(typeof TInPlaceControlBase).toBe('function'); + }); + + it('TInPlaceTextBox extends TInPlaceControlBase', () => { + expect(Object.getPrototypeOf(TInPlaceTextBox.prototype)).toBe(TInPlaceControlBase.prototype); + }); + + it('TInPlaceDropDownList extends TInPlaceControlBase', () => { + expect(Object.getPrototypeOf(TInPlaceDropDownList.prototype)).toBe(TInPlaceControlBase.prototype); + }); + + it('TInPlaceDropDownList has static dropdowns registry and helpers', () => { + expect(typeof TInPlaceDropDownList.dropdowns).toBe('object'); + expect(typeof TInPlaceDropDownList.register).toBe('function'); + expect(typeof TInPlaceDropDownList.setDisplayEditor).toBe('function'); + expect(typeof TInPlaceDropDownList.setReadOnly).toBe('function'); + }); +}); + +// ─── Construction ──────────────────────────────────────────────────────────── + +describe('TInPlaceDropDownList construction', () => { + let container, label, select; + + beforeEach(() => { + clearRegistry(); + clearDropDowns(); + ({ container, label, select } = buildDOM()); + }); + + afterEach(() => { + restoreMocks(); + container.remove(); + }); + + it('registers in Prado.Registry on construction', () => { + new TInPlaceDropDownList(makeOptions()); + expect(Registry['ddl1__label']).toBeDefined(); + }); + + it('registers in TInPlaceDropDownList.dropdowns keyed by EditorID', () => { + new TInPlaceDropDownList(makeOptions()); + expect(TInPlaceDropDownList.dropdowns['ddl1']).toBeDefined(); + }); + + it('attaches to the server-rendered select as editField', () => { + const ctrl = new TInPlaceDropDownList(makeOptions()); + expect(ctrl.editField).toBe(select); + }); + + it('does not create a new element', () => { + new TInPlaceDropDownList(makeOptions()); + expect(document.querySelectorAll('select').length).toBe(1); + }); + + it('sets readOnly from options.ReadOnly', () => { + const ctrl = new TInPlaceDropDownList(makeOptions({ ReadOnly: true })); + expect(ctrl.readOnly).toBe(true); + }); +}); + +// ─── enterEditMode / exitEditMode ──────────────────────────────────────────── + +describe('TInPlaceDropDownList edit mode', () => { + let container, label, select; + + beforeEach(() => { + clearRegistry(); + clearDropDowns(); + ({ container, label, select } = buildDOM()); + }); + + afterEach(() => { + restoreMocks(); + container.remove(); + }); + + it('enterEditMode shows the select and hides the label', () => { + const ctrl = new TInPlaceDropDownList(makeOptions()); + ctrl.enterEditMode(null); + expect(ctrl.isEditing).toBe(true); + expect(label.style.display).toBe('none'); + expect(select.style.display).not.toBe('none'); + }); + + it('enterEditMode records the original value for revert', () => { + const ctrl = new TInPlaceDropDownList(makeOptions()); + ctrl.enterEditMode(null); + expect(ctrl.originalValue).toBe('0'); + }); + + it('enterEditMode is a no-op when readOnly', () => { + const ctrl = new TInPlaceDropDownList(makeOptions({ ReadOnly: true })); + ctrl.enterEditMode(null); + expect(ctrl.isEditing).toBe(false); + }); + + it('exitEditMode copies the selected option text to the label', () => { + const ctrl = new TInPlaceDropDownList(makeOptions()); + ctrl.enterEditMode(null); + select.selectedIndex = 1; + ctrl.exitEditMode(null); + expect(label.innerHTML).toBe('Beta'); + expect(label.style.display).not.toBe('none'); + expect(select.style.display).toBe('none'); + }); +}); + +// ─── getEditorDisplayText ──────────────────────────────────────────────────── + +describe('TInPlaceDropDownList getEditorDisplayText', () => { + let container, select; + + afterEach(() => { + restoreMocks(); + container?.remove(); + }); + + it('returns the selected option text', () => { + clearRegistry(); clearDropDowns(); + ({ container, select } = buildDOM(['Alpha', 'Beta'], 1)); + const ctrl = new TInPlaceDropDownList(makeOptions()); + expect(ctrl.getEditorDisplayText()).toBe('Beta'); + }); + + it('html-encodes the option text', () => { + clearRegistry(); clearDropDowns(); + ({ container, select } = buildDOM(['a < b & c'], 0)); + const ctrl = new TInPlaceDropDownList(makeOptions()); + expect(ctrl.getEditorDisplayText()).toBe('a < b & c'); + }); + + it('reports an empty editor when the selected option text is empty', () => { + clearRegistry(); clearDropDowns(); + ({ container, select } = buildDOM([''], 0)); + const ctrl = new TInPlaceDropDownList(makeOptions({ EmptyDisplayText: '(none)' })); + expect(ctrl.isEditorEmpty()).toBe(true); + }); + + it('reports an empty editor when the select has no options', () => { + clearRegistry(); clearDropDowns(); + ({ container, select } = buildDOM([], -1)); + const ctrl = new TInPlaceDropDownList(makeOptions({ EmptyDisplayText: '(none)' })); + expect(ctrl.isEditorEmpty()).toBe(true); + }); + + it('refreshLabel shows EmptyDisplayText and marks the label when empty', () => { + clearRegistry(); clearDropDowns(); + ({ container, select } = buildDOM([''], 0)); + const ctrl = new TInPlaceDropDownList(makeOptions({ EmptyDisplayText: '(none)' })); + ctrl.refreshLabel(); + expect(ctrl.element.innerHTML).toBe('(none)'); + expect(ctrl.isShowingEmptyDisplayText()).toBe(true); + expect(ctrl.getText()).toBe(''); + }); + + it('refreshLabel shows the selected text and clears the mark', () => { + clearRegistry(); clearDropDowns(); + ({ container, select } = buildDOM(['Alpha', 'Beta'], 1)); + const ctrl = new TInPlaceDropDownList(makeOptions({ EmptyDisplayText: '(none)' })); + ctrl.refreshLabel(); + expect(ctrl.element.innerHTML).toBe('Beta'); + expect(ctrl.isShowingEmptyDisplayText()).toBe(false); + }); +}); + +// ─── onSelectionChanged / onValueChanged ───────────────────────────────────── + +describe('TInPlaceDropDownList selection change', () => { + let container, select; + + beforeEach(() => { + clearRegistry(); + clearDropDowns(); + ({ container, select } = buildDOM()); + }); + + afterEach(() => { + restoreMocks(); + container.remove(); + }); + + it('dispatches a CallbackRequest with the original value as parameter', () => { + const { dispatchMock, setCallbackParameterMock } = mockCallbackRequest(); + const ctrl = new TInPlaceDropDownList(makeOptions()); + ctrl.enterEditMode(null); + select.selectedIndex = 2; + ctrl.onSelectionChanged({}); + expect(setCallbackParameterMock).toHaveBeenCalledWith('0'); + expect(dispatchMock).toHaveBeenCalled(); + }); + + it('sets isSaving and disables the select while dispatching', () => { + mockCallbackRequest(true); + const ctrl = new TInPlaceDropDownList(makeOptions()); + ctrl.enterEditMode(null); + select.selectedIndex = 2; + ctrl.onSelectionChanged({}); + expect(ctrl.isSaving).toBe(true); + expect(select.disabled).toBe(true); + }); + + it('does not dispatch when AutoPostBack is false', () => { + const { dispatchMock } = mockCallbackRequest(); + const ctrl = new TInPlaceDropDownList(makeOptions({ AutoPostBack: false })); + ctrl.enterEditMode(null); + select.selectedIndex = 2; + ctrl.onSelectionChanged({}); + expect(dispatchMock).not.toHaveBeenCalled(); + }); + + it('does not dispatch when not in edit mode', () => { + const { dispatchMock } = mockCallbackRequest(); + const ctrl = new TInPlaceDropDownList(makeOptions()); + ctrl.onSelectionChanged({}); + expect(dispatchMock).not.toHaveBeenCalled(); + }); + + it('does not dispatch again while saving', () => { + const { dispatchMock } = mockCallbackRequest(); + const ctrl = new TInPlaceDropDownList(makeOptions()); + ctrl.enterEditMode(null); + ctrl.isSaving = true; + ctrl.onSelectionChanged({}); + expect(dispatchMock).not.toHaveBeenCalled(); + }); +}); + +// ─── onDropDownBlur ────────────────────────────────────────────────────────── + +describe('TInPlaceDropDownList onDropDownBlur', () => { + let container, label, select; + + beforeEach(() => { + clearRegistry(); + clearDropDowns(); + ({ container, label, select } = buildDOM()); + }); + + afterEach(() => { + restoreMocks(); + container.remove(); + }); + + it('is a no-op while saving', () => { + const ctrl = new TInPlaceDropDownList(makeOptions()); + ctrl.enterEditMode(null); + ctrl.isSaving = true; + ctrl.onDropDownBlur({}); + expect(ctrl.isEditing).toBe(true); + }); + + it('exits edit mode without a callback when the value is unchanged', () => { + const { dispatchMock } = mockCallbackRequest(); + const ctrl = new TInPlaceDropDownList(makeOptions()); + ctrl.enterEditMode(null); + ctrl.onDropDownBlur({}); + expect(dispatchMock).not.toHaveBeenCalled(); + expect(ctrl.isEditing).toBe(false); + expect(label.style.display).not.toBe('none'); + }); + + it('does not dispatch on blur; the change event already posted the value', () => { + const { dispatchMock } = mockCallbackRequest(); + const ctrl = new TInPlaceDropDownList(makeOptions()); + ctrl.enterEditMode(null); + select.selectedIndex = 1; + ctrl.onSelectionChanged({}); + expect(dispatchMock).toHaveBeenCalledTimes(1); + // Saving is in flight, so the following blur is a no-op. + ctrl.onDropDownBlur({}); + expect(dispatchMock).toHaveBeenCalledTimes(1); + }); + + it('shows the selection in the label when the change dispatch did not start', () => { + mockCallbackRequest(false); + const ctrl = new TInPlaceDropDownList(makeOptions()); + ctrl.enterEditMode(null); + select.selectedIndex = 1; + ctrl.onSelectionChanged({}); + ctrl.onDropDownBlur({}); + expect(ctrl.isEditing).toBe(false); + expect(label.innerHTML).toBe('Beta'); + }); + + it('applies the selection to the label when AutoPostBack is false', () => { + mockCallbackRequest(); + const ctrl = new TInPlaceDropDownList(makeOptions({ AutoPostBack: false })); + ctrl.enterEditMode(null); + select.selectedIndex = 1; + ctrl.onDropDownBlur({}); + expect(label.innerHTML).toBe('Beta'); + expect(ctrl.isEditing).toBe(false); + }); +}); + +// ─── onKeyPressed ──────────────────────────────────────────────────────────── + +describe('TInPlaceDropDownList onKeyPressed', () => { + let container, select; + + beforeEach(() => { + clearRegistry(); + clearDropDowns(); + ({ container, select } = buildDOM()); + }); + + afterEach(() => { + restoreMocks(); + container.remove(); + }); + + it('reverts the value and exits edit mode on ESC', () => { + const ctrl = new TInPlaceDropDownList(makeOptions()); + ctrl.enterEditMode(null); + select.selectedIndex = 2; + const showLabel = vi.spyOn(ctrl, 'showLabel'); + ctrl.onKeyPressed({ keyCode: 27 }); + expect(select.value).toBe('0'); + expect(ctrl.isEditing).toBe(false); + expect(showLabel).toHaveBeenCalled(); + }); + + it('blurs the select on ENTER', () => { + const ctrl = new TInPlaceDropDownList(makeOptions()); + ctrl.enterEditMode(null); + const blur = vi.spyOn(select, 'blur'); + const evt = { keyCode: 13, preventDefault: vi.fn() }; + ctrl.onKeyPressed(evt); + expect(evt.preventDefault).toHaveBeenCalled(); + expect(blur).toHaveBeenCalled(); + }); +}); + +// ─── onValueChanged success/failure ────────────────────────────────────────── + +describe('TInPlaceDropDownList value changed handlers', () => { + let container, label, select; + + beforeEach(() => { + clearRegistry(); + clearDropDowns(); + ({ container, label, select } = buildDOM()); + }); + + afterEach(() => { + restoreMocks(); + container.remove(); + }); + + it('onValueChangedSuccess updates the label with the selected text', () => { + const ctrl = new TInPlaceDropDownList(makeOptions()); + ctrl.enterEditMode(null); + select.selectedIndex = 1; + ctrl.isSaving = true; + ctrl.onValueChangedSuccess({}, null); + expect(label.innerHTML).toBe('Beta'); + expect(ctrl.isSaving).toBe(false); + expect(ctrl.isEditing).toBe(false); + expect(select.disabled).toBe(false); + }); + + it('onValueChangedSuccess uses the callback parameter as label html when provided', () => { + const ctrl = new TInPlaceDropDownList(makeOptions()); + ctrl.enterEditMode(null); + ctrl.onValueChangedSuccess({}, 'Server'); + expect(label.innerHTML).toBe('Server'); + }); + + it('onValueChangedSuccess resets originalValue to the current selection', () => { + const ctrl = new TInPlaceDropDownList(makeOptions()); + ctrl.enterEditMode(null); + select.selectedIndex = 2; + ctrl.onValueChangedSuccess({}, null); + expect(ctrl.originalValue).toBe('2'); + }); + + it('onValueChangedSuccess hides the select when AutoHide is true', () => { + const ctrl = new TInPlaceDropDownList(makeOptions({ AutoHide: true })); + ctrl.enterEditMode(null); + ctrl.onValueChangedSuccess({}, null); + expect(select.style.display).toBe('none'); + }); + + it('onValueChangedSuccess calls options.onSuccess when defined', () => { + const onSuccess = vi.fn(); + const ctrl = new TInPlaceDropDownList(makeOptions({ onSuccess })); + ctrl.onValueChangedSuccess({}, null); + expect(onSuccess).toHaveBeenCalled(); + }); + + it('onValueChangedFailure re-enables the select and resets state', () => { + const onFailure = vi.fn(); + const ctrl = new TInPlaceDropDownList(makeOptions({ onFailure })); + ctrl.enterEditMode(null); + ctrl.isSaving = true; + select.disabled = true; + ctrl.onValueChangedFailure({}, 'err'); + expect(select.disabled).toBe(false); + expect(ctrl.isSaving).toBe(false); + expect(ctrl.isEditing).toBe(false); + expect(onFailure).toHaveBeenCalled(); + }); +}); + +// ─── loadItems ─────────────────────────────────────────────────────────────── + +describe('TInPlaceDropDownList loadItems', () => { + let container, select; + + beforeEach(() => { + clearRegistry(); + clearDropDowns(); + ({ container, select } = buildDOM()); + }); + + afterEach(() => { + restoreMocks(); + container.remove(); + }); + + it('enterEditMode dispatches the load items callback when LoadItemsOnEdit', () => { + const { dispatchMock, setCallbackParameterMock, setCausesValidationMock } = mockCallbackRequest(); + const ctrl = new TInPlaceDropDownList(makeOptions({ LoadItemsOnEdit: true })); + ctrl.enterEditMode(null); + expect(select.disabled).toBe(true); + expect(setCausesValidationMock).toHaveBeenCalledWith(false); + expect(setCallbackParameterMock).toHaveBeenCalledWith(['__InlineEditor_loadItems__', '0']); + expect(dispatchMock).toHaveBeenCalled(); + }); + + it('does not dispatch the load items callback without LoadItemsOnEdit', () => { + const { dispatchMock } = mockCallbackRequest(); + const ctrl = new TInPlaceDropDownList(makeOptions()); + ctrl.enterEditMode(null); + expect(dispatchMock).not.toHaveBeenCalled(); + }); + + it('onLoadItemsSuccess re-enables editing and refreshes originalValue', () => { + const ctrl = new TInPlaceDropDownList(makeOptions()); + ctrl.enterEditMode(null); + select.disabled = true; + select.selectedIndex = 1; + ctrl.onLoadItemsSuccess({}, null); + expect(ctrl.isEditing).toBe(true); + expect(select.disabled).toBe(false); + expect(ctrl.originalValue).toBe('1'); + }); + + it('onLoadItemsFailure resets state and shows the label', () => { + const ctrl = new TInPlaceDropDownList(makeOptions()); + ctrl.enterEditMode(null); + const showLabel = vi.spyOn(ctrl, 'showLabel'); + ctrl.onLoadItemsFailure({}, 'err'); + expect(ctrl.isEditing).toBe(false); + expect(showLabel).toHaveBeenCalled(); + }); +}); + +// ─── Static helpers ────────────────────────────────────────────────────────── + +describe('TInPlaceDropDownList static helpers', () => { + let container; + + beforeEach(() => { + clearRegistry(); + clearDropDowns(); + ({ container } = buildDOM()); + }); + + afterEach(() => { + restoreMocks(); + container.remove(); + }); + + it('setDisplayEditor(id, true) calls enterEditMode on the registered instance', () => { + const ctrl = new TInPlaceDropDownList(makeOptions()); + const enter = vi.spyOn(ctrl, 'enterEditMode'); + TInPlaceDropDownList.setDisplayEditor('ddl1', true); + expect(enter).toHaveBeenCalledWith(null); + }); + + it('setDisplayEditor(id, false) calls exitEditMode on the registered instance', () => { + const ctrl = new TInPlaceDropDownList(makeOptions()); + const exit = vi.spyOn(ctrl, 'exitEditMode'); + TInPlaceDropDownList.setDisplayEditor('ddl1', false); + expect(exit).toHaveBeenCalledWith(null); + }); + + it('setReadOnly updates readOnly on the registered instance', () => { + const ctrl = new TInPlaceDropDownList(makeOptions({ ReadOnly: false })); + TInPlaceDropDownList.setReadOnly('ddl1', true); + expect(ctrl.readOnly).toBe(true); + }); + + it('helpers are no-ops for unknown IDs', () => { + expect(() => TInPlaceDropDownList.setDisplayEditor('unknown', true)).not.toThrow(); + expect(() => TInPlaceDropDownList.setReadOnly('unknown', true)).not.toThrow(); + }); +}); + +// ─── Shared base behavior ──────────────────────────────────────────────────── + +describe('TInPlaceControlBase shared registry and DisplayEditor', () => { + let container, label, select; + + beforeEach(() => { + clearRegistry(); + clearDropDowns(); + ({ container, label, select } = buildDOM()); + }); + + afterEach(() => { + restoreMocks(); + container.remove(); + }); + + it('registers into the base registry keyed by the editor client ID', () => { + const ctrl = new TInPlaceDropDownList(makeOptions()); + expect(TInPlaceControlBase.get('ddl1')).toBe(ctrl); + }); + + it('base statics drive a control registered by a subclass', () => { + const ctrl = new TInPlaceDropDownList(makeOptions()); + TInPlaceControlBase.setReadOnly('ddl1', true); + expect(ctrl.readOnly).toBe(true); + }); + + it('setLabelText applies a value through the base static', () => { + const ctrl = new TInPlaceDropDownList(makeOptions({ EmptyDisplayText: '(none)' })); + TInPlaceControlBase.setLabelText('ddl1', 'Server text'); + expect(label.innerHTML).toBe('Server text'); + expect(ctrl.isShowingEmptyDisplayText()).toBe(false); + }); + + it('setLabelText with an empty value shows EmptyDisplayText and marks the label', () => { + const ctrl = new TInPlaceDropDownList(makeOptions({ EmptyDisplayText: '(none)' })); + TInPlaceControlBase.setLabelText('ddl1', ''); + expect(label.innerHTML).toBe('(none)'); + expect(ctrl.isShowingEmptyDisplayText()).toBe(true); + }); + + it('setEmptyDisplayText updates the option and refreshes a label showing the placeholder', () => { + const ctrl = new TInPlaceDropDownList(makeOptions({ EmptyDisplayText: '(none)' })); + TInPlaceControlBase.setLabelText('ddl1', ''); + expect(label.innerHTML).toBe('(none)'); + TInPlaceControlBase.setEmptyDisplayText('ddl1', '(pick one)'); + expect(ctrl.options.EmptyDisplayText).toBe('(pick one)'); + expect(label.innerHTML).toBe('(pick one)'); + expect(ctrl.isShowingEmptyDisplayText()).toBe(true); + }); + + it('setEmptyDisplayText leaves a label showing a value untouched', () => { + const ctrl = new TInPlaceDropDownList(makeOptions({ EmptyDisplayText: '(none)' })); + TInPlaceControlBase.setLabelText('ddl1', 'Alpha'); + TInPlaceControlBase.setEmptyDisplayText('ddl1', '(pick one)'); + expect(label.innerHTML).toBe('Alpha'); + expect(ctrl.options.EmptyDisplayText).toBe('(pick one)'); + }); + + it('setEmptyDisplayText applies to the next empty value', () => { + const ctrl = new TInPlaceDropDownList(makeOptions({ EmptyDisplayText: '(none)' })); + TInPlaceControlBase.setEmptyDisplayText('ddl1', '(pick one)'); + TInPlaceControlBase.setLabelText('ddl1', ''); + expect(label.innerHTML).toBe('(pick one)'); + }); + + it('DisplayEditor option enters edit mode at construction', () => { + const ctrl = new TInPlaceDropDownList(makeOptions({ DisplayEditor: true })); + expect(ctrl.isEditing).toBe(true); + expect(select.style.display).not.toBe('none'); + expect(label.style.display).toBe('none'); + }); + + it('DisplayEditor records originalValue so a later change posts correctly', () => { + const { setCallbackParameterMock } = mockCallbackRequest(); + const ctrl = new TInPlaceDropDownList(makeOptions({ DisplayEditor: true })); + select.selectedIndex = 2; + ctrl.onSelectionChanged({}); + expect(setCallbackParameterMock).toHaveBeenCalledWith('0'); + }); + + it('without DisplayEditor the control starts showing the label', () => { + const ctrl = new TInPlaceDropDownList(makeOptions()); + expect(ctrl.isEditing).toBe(false); + expect(select.style.display).toBe('none'); + }); +}); + +// ─── TInPlaceTextBox HTML5 TextMode mapping ────────────────────────────────── + +describe('TInPlaceTextBox HTML5 TextMode input types', () => { + let container; + + function buildLabelDOM() { + const div = document.createElement('div'); + const label = document.createElement('span'); + label.id = 'lbl1'; + label.innerHTML = 'Hello'; + div.appendChild(label); + document.body.appendChild(div); + return div; + } + + function textBoxOptions(textMode) { + return { + ID: 'lbl1', + TextBoxID: 'tb_lbl1', + EditorID: 'tb_lbl1', + EventTarget: 'lbl1', + TextMode: textMode, + ReadOnly: false, + AutoPostBack: false, + AutoHide: false, + LoadTextOnEdit: false, + }; + } + + beforeEach(() => { + clearRegistry(); + clearTextboxes(); + container = buildLabelDOM(); + }); + + afterEach(() => { + restoreMocks(); + container.remove(); + }); + + it.each([ + ['SingleLine', 'text'], + ['Password', 'password'], + ['Color', 'color'], + ['Date', 'date'], + ['DatetimeLocal', 'datetime-local'], + ['Email', 'email'], + ['Month', 'month'], + ['Number', 'number'], + ['Range', 'range'], + ['Search', 'search'], + ['Tel', 'tel'], + ['Time', 'time'], + ['Url', 'url'], + ['Week', 'week'], + ])('TextMode %s creates an input of type %s', (mode, type) => { + new TInPlaceTextBox(textBoxOptions(mode)); + const field = document.getElementById('tb_lbl1'); + expect(field.tagName.toLowerCase()).toBe('input'); + expect(field.type).toBe(type); + }); + + it('unknown TextMode falls back to a text input', () => { + new TInPlaceTextBox(textBoxOptions('Bogus')); + expect(document.getElementById('tb_lbl1').type).toBe('text'); + }); + + it('MultiLine still creates a textarea', () => { + new TInPlaceTextBox(textBoxOptions('MultiLine')); + expect(document.getElementById('tb_lbl1').tagName.toLowerCase()).toBe('textarea'); + }); +}); diff --git a/tests/js/adapters/inlineeditor.js b/tests/js/adapters/inlineeditor.js index f6ac36dbc..aae539790 100644 --- a/tests/js/adapters/inlineeditor.js +++ b/tests/js/adapters/inlineeditor.js @@ -21,5 +21,8 @@ loadScript('framework/Web/Javascripts/source/prado/activecontrols/inlineeditor.j export const CallbackRequest = global.Prado.CallbackRequest; export const Registry = global.Prado.Registry; -// The control under test -export const TInPlaceTextBox = global.Prado.WebUI.TInPlaceTextBox; +// The controls under test +export const TInPlaceControlBase = global.Prado.WebUI.TInPlaceControlBase; +export const EMPTY_ATTRIBUTE = global.Prado.WebUI.TInPlaceControlBase.EMPTY_ATTRIBUTE; +export const TInPlaceTextBox = global.Prado.WebUI.TInPlaceTextBox; +export const TInPlaceDropDownList = global.Prado.WebUI.TInPlaceDropDownList; diff --git a/tests/js/helpers/callbackMock.js b/tests/js/helpers/callbackMock.js new file mode 100644 index 000000000..0324bad55 --- /dev/null +++ b/tests/js/helpers/callbackMock.js @@ -0,0 +1,57 @@ +/** + * Shared test helpers for controls that dispatch Prado.CallbackRequest. + * + * Replacing Prado.CallbackRequest with a mock keeps tests off the network; + * the mock records the calls each control makes so tests can assert on the + * dispatch without a form or an XHR. + */ + +import { vi } from 'vitest'; + +/** Remove all keys from Prado.Registry. */ +export function clearRegistry() { + for (const k of Object.keys(global.Prado.Registry)) { + delete global.Prado.Registry[k]; + } +} + +/** Remove all keys from an object registry (in-place control maps, timers, ...). */ +export function clearMap(map) { + for (const k of Object.keys(map)) { + delete map[k]; + } +} + +/** + * Mock Prado.CallbackRequest so that no XHR is ever made. + * Uses a real constructor function (not an arrow) so `new` works. + * + * @param {boolean} dispatchReturnValue what dispatch() reports to the control + * @return {Object} the mock instance and its call spies + */ +export function mockCallbackRequest(dispatchReturnValue = true) { + const dispatchMock = vi.fn().mockReturnValue(dispatchReturnValue); + const setCallbackParameterMock = vi.fn(); + const setCausesValidationMock = vi.fn(); + const instance = { + dispatch: dispatchMock, + setCallbackParameter: setCallbackParameterMock, + setCausesValidation: setCausesValidationMock, + options: {}, + }; + + const original = global.Prado.CallbackRequest; + const MockCtor = vi.fn(function () { return instance; }); + MockCtor.__original = original; + global.Prado.CallbackRequest = MockCtor; + + return { instance, dispatchMock, setCallbackParameterMock, setCausesValidationMock }; +} + +/** Restore vi mocks and the real Prado.CallbackRequest. */ +export function restoreMocks() { + vi.restoreAllMocks(); + if (global.Prado.CallbackRequest?.__original !== undefined) { + global.Prado.CallbackRequest = global.Prado.CallbackRequest.__original; + } +} diff --git a/tests/playwright/web/TInPlaceDropDownListTestCase.spec.js b/tests/playwright/web/TInPlaceDropDownListTestCase.spec.js new file mode 100644 index 000000000..b96229dc9 --- /dev/null +++ b/tests/playwright/web/TInPlaceDropDownListTestCase.spec.js @@ -0,0 +1,127 @@ +import { test, expect } from '@playwright/test'; +import { PradoTestHelper, GENERIC_BASE_URL } from '../helpers.js'; + +const PAGE_URL = 'web/index.php?page=InPlaceDropDownListTest'; +const LABEL = 'ctl0_Content_ddl__label'; +const SELECT = 'ctl0_Content_ddl'; +const STATUS = 'ctl0_Content_status'; +const LAZY_LABEL = 'ctl0_Content_lazy__label'; +const LAZY_SELECT = 'ctl0_Content_lazy'; +const LAZY_STATUS = 'ctl0_Content_lazyStatus'; + +/** + * TInPlaceDropDownList renders a label span and a hidden select. Clicking the + * label swaps to the select; changing the selection posts a callback that + * raises OnSelectedIndexChanged, updates the label, and re-hides the select. + */ +test('TInPlaceDropDownListTestCase: click to edit and select via callback', async ({ page }) => { + const h = new PradoTestHelper(page, GENERIC_BASE_URL); + await h.url(PAGE_URL); + await h.assertSourceContains('In Place Drop Down List Test Case'); + + // Initial state: label shows the selected item text, select is hidden + await expect(page.locator(`#${LABEL}`)).toBeVisible(); + await expect(page.locator(`#${LABEL}`)).toHaveText('Red'); + await expect(page.locator(`#${SELECT}`)).toBeHidden(); + + // Clicking the label swaps to the select + await page.locator(`#${LABEL}`).click(); + await expect(page.locator(`#${SELECT}`)).toBeVisible(); + await expect(page.locator(`#${LABEL}`)).toBeHidden(); + + // Changing the selection dispatches a callback and re-hides the select + await page.locator(`#${SELECT}`).selectOption('green'); + await h.waitForAjaxCalls(); + await expect(page.locator(`#${STATUS}`)).toHaveText('changed: green'); + await expect(page.locator(`#${LABEL}`)).toBeVisible(); + await expect(page.locator(`#${LABEL}`)).toHaveText('Green'); + await expect(page.locator(`#${SELECT}`)).toBeHidden(); +}); + +/** + * A server-side setSelectedValue() during an unrelated callback updates both + * the select selection (through the active list adapter) and the label text. + */ +test('TInPlaceDropDownListTestCase: server-side selection updates the label', async ({ page }) => { + const h = new PradoTestHelper(page, GENERIC_BASE_URL); + await h.url(PAGE_URL); + + await page.locator('#ctl0_Content_btnServerSelect').click(); + await h.waitForAjaxCalls(); + await expect(page.locator(`#${STATUS}`)).toHaveText('server selected'); + await expect(page.locator(`#${LABEL}`)).toHaveText('Blue'); + await expect(page.locator(`#${SELECT}`)).toHaveValue('blue'); +}); + +/** + * Changing the selected item's text during a callback refreshes the label, + * even though the selection itself did not change. + */ +test('TInPlaceDropDownListTestCase: renaming the selected item updates the label', async ({ page }) => { + const h = new PradoTestHelper(page, GENERIC_BASE_URL); + await h.url(PAGE_URL); + await expect(page.locator(`#${LABEL}`)).toHaveText('Red'); + + await page.locator('#ctl0_Content_btnRenameItem').click(); + await h.waitForAjaxCalls(); + await expect(page.locator(`#${STATUS}`)).toHaveText('renamed'); + await expect(page.locator(`#${LABEL}`)).toHaveText('Renamed'); + await expect(page.locator(`#${SELECT} option`).first()).toHaveText('Renamed'); +}); + +/** + * Changing EmptyDisplayText during a callback reaches a label that is + * currently showing the placeholder. + */ +test('TInPlaceDropDownListTestCase: empty display text follows a server change', async ({ page }) => { + const h = new PradoTestHelper(page, GENERIC_BASE_URL); + await h.url(PAGE_URL); + await expect(page.locator(`#${LAZY_LABEL}`)).toHaveText('Stale'); + + // Clearing the items empties the label, which then shows the placeholder. + await page.locator('#ctl0_Content_btnEmptyText').click(); + await h.waitForAjaxCalls(); + await expect(page.locator(`#${LAZY_STATUS}`)).toHaveText('empty text changed'); + await expect(page.locator(`#${LAZY_LABEL}`)).toHaveText('(nothing left)'); +}); + +/** + * setReadOnly(true) during a callback prevents entering edit mode. + */ +test('TInPlaceDropDownListTestCase: read only blocks edit mode', async ({ page }) => { + const h = new PradoTestHelper(page, GENERIC_BASE_URL); + await h.url(PAGE_URL); + + await page.locator('#ctl0_Content_btnReadOnly').click(); + await h.waitForAjaxCalls(); + await expect(page.locator(`#${STATUS}`)).toHaveText('readonly'); + + await page.locator(`#${LABEL}`).click(); + await expect(page.locator(`#${SELECT}`)).toBeHidden(); + await expect(page.locator(`#${LABEL}`)).toBeVisible(); +}); + +/** + * With an OnLoadingItems handler, entering edit mode first loads the item + * list from the server; the stale items are replaced before selection. + */ +test('TInPlaceDropDownListTestCase: OnLoadingItems replaces items on edit', async ({ page }) => { + const h = new PradoTestHelper(page, GENERIC_BASE_URL); + await h.url(PAGE_URL); + + await expect(page.locator(`#${LAZY_LABEL}`)).toHaveText('Stale'); + + // Entering edit mode dispatches the load-items callback + await page.locator(`#${LAZY_LABEL}`).click(); + await h.waitForAjaxCalls(); + await expect(page.locator(`#${LAZY_SELECT}`)).toBeVisible(); + await expect(page.locator(`#${LAZY_SELECT} option`)).toHaveText(['Fresh A', 'Fresh B']); + await expect(page.locator(`#${LAZY_SELECT}`)).toHaveValue('a'); + + // Selecting one of the fresh items posts the new value + await page.locator(`#${LAZY_SELECT}`).selectOption('b'); + await h.waitForAjaxCalls(); + await expect(page.locator(`#${LAZY_STATUS}`)).toHaveText('changed: b'); + await expect(page.locator(`#${LAZY_LABEL}`)).toHaveText('Fresh B'); + await expect(page.locator(`#${LAZY_SELECT}`)).toBeHidden(); +}); diff --git a/tests/unit/Web/UI/ActiveControls/TInPlaceDropDownListTest.php b/tests/unit/Web/UI/ActiveControls/TInPlaceDropDownListTest.php new file mode 100644 index 000000000..577f6b3f7 --- /dev/null +++ b/tests/unit/Web/UI/ActiveControls/TInPlaceDropDownListTest.php @@ -0,0 +1,305 @@ +_obLevel = ob_get_level(); + } + + protected function tearDown(): void + { + while (ob_get_level() > $this->_obLevel) { + ob_end_clean(); + } + } + + public function testExtendsTActiveDropDownList() + { + $this->assertInstanceOf(TActiveDropDownList::class, new TInPlaceDropDownList()); + } + + public function testImplementsActiveControlInterfaces() + { + $control = new TInPlaceDropDownList(); + $this->assertInstanceOf(IActiveControl::class, $control); + $this->assertInstanceOf(ICallbackEventHandler::class, $control); + } + + public function testAutoPostBackDefaultsToTrue() + { + $control = new TInPlaceDropDownList(); + $this->assertTrue($control->getAutoPostBack()); + } + + public function testAutoHideEditor() + { + $control = new TInPlaceDropDownList(); + $this->assertTrue($control->getAutoHideEditor()); + $control->setAutoHideEditor(false); + $this->assertFalse($control->getAutoHideEditor()); + } + + public function testDisplayEditor() + { + $control = new TInPlaceDropDownList(); + $this->assertFalse($control->getDisplayEditor()); + $control->setDisplayEditor(true); + $this->assertTrue($control->getDisplayEditor()); + } + + public function testReadOnly() + { + $control = new TInPlaceDropDownList(); + $this->assertFalse($control->getReadOnly()); + $control->setReadOnly(true); + $this->assertTrue($control->getReadOnly()); + } + + public function testEmptyDisplayText() + { + $control = new TInPlaceDropDownList(); + $this->assertSame('', $control->getEmptyDisplayText()); + $control->setEmptyDisplayText('(none)'); + $this->assertSame('(none)', $control->getEmptyDisplayText()); + } + + public function testEmptyDisplayTextIsUnchangedWhenSetToTheSameValue() + { + $control = new TInPlaceDropDownList(); + $control->setEmptyDisplayText('(none)'); + $control->setEmptyDisplayText('(none)'); + $this->assertSame('(none)', $control->getEmptyDisplayText()); + } + + public function testItemListStartsUnchanged() + { + // onPreRender refreshes the label only when the item list changed; + // the callback path itself is covered by the functional test. + $control = new TInPlaceDropDownList(); + $control->getItems()->add('Alpha'); + $this->assertFalse($control->getItems()->getListHasChanged()); + } + + public function testAutoHideEditorAndDisplayEditorComeFromTheTrait() + { + $control = new TInPlaceDropDownList(); + $this->assertTrue(method_exists($control, 'getAutoHideEditor')); + $this->assertTrue(method_exists($control, 'getDisplayEditor')); + // The textbox-flavored names belong to TInPlaceTextBox only. + $this->assertFalse(method_exists($control, 'getAutoHideTextBox')); + } + + public function testEditTriggerControlID() + { + $control = new TInPlaceDropDownList(); + $this->assertNull($control->getEditTriggerControlID()); + $control->setEditTriggerControlID('editButton'); + $this->assertSame('editButton', $control->getEditTriggerControlID()); + } + + public function testLabelClientID() + { + $control = new TInPlaceDropDownList(); + $control->setID('dropdown'); + $this->assertSame( + $control->getClientID() . '__label', + PradoUnit::invoke($control, 'getLabelClientID') + ); + } + + public function testClientClassName() + { + $control = new TInPlaceDropDownList(); + $this->assertSame( + 'Prado.WebUI.TInPlaceDropDownList', + PradoUnit::invoke($control, 'getClientClassName') + ); + } + + // --- getSelectedDisplayText --- + + public function testSelectedDisplayTextWithoutItemsIsEmptyDisplayText() + { + $control = new TInPlaceDropDownList(); + $control->setEmptyDisplayText('(none)'); + $this->assertSame('(none)', PradoUnit::invoke($control, 'getSelectedDisplayText')); + } + + public function testSelectedDisplayTextIsSelectedItemText() + { + $control = new TInPlaceDropDownList(); + $control->getItems()->add('Alpha'); + $control->getItems()->add('Beta'); + $control->setSelectedIndex(1); + $this->assertSame('Beta', PradoUnit::invoke($control, 'getSelectedDisplayText')); + } + + public function testSelectedDisplayTextIsHtmlEncoded() + { + $control = new TInPlaceDropDownList(); + $control->getItems()->add('a < b & c'); + $control->setSelectedIndex(0); + // THttpUtility::htmlEncode translates angle brackets and quotes only, + // matching the option text rendering of TListControl. + $this->assertSame('a < b & c', PradoUnit::invoke($control, 'getSelectedDisplayText')); + } + + public function testSelectedItemTextIsEmptyWithoutSelection() + { + $control = new TInPlaceDropDownList(); + $control->setEmptyDisplayText('(none)'); + $this->assertSame('', PradoUnit::invoke($control, 'getSelectedItemText')); + } + + public function testSelectedItemTextIsTheEncodedItemText() + { + $control = new TInPlaceDropDownList(); + $control->getItems()->add('a < b'); + $control->setSelectedIndex(0); + $this->assertSame('a < b', PradoUnit::invoke($control, 'getSelectedItemText')); + } + + // --- renderLabel --- + + private function renderLabel(TInPlaceDropDownList $control): string + { + $textWriter = new TTextWriter(); + PradoUnit::invoke($control, 'renderLabel', new THtmlWriter($textWriter)); + return $textWriter->flush(); + } + + public function testRenderLabelShowsSelectedText() + { + $control = new TInPlaceDropDownList(); + $control->setID('dropdown'); + $control->getItems()->add('Alpha'); + $control->setSelectedIndex(0); + $label = $this->renderLabel($control); + $this->assertStringContainsString('id="' . $control->getClientID() . '__label"', $label); + $this->assertStringContainsString('>Alpha<', $label); + $this->assertStringNotContainsString('data-prado-empty', $label); + } + + public function testRenderLabelMarksTheEmptyDisplayText() + { + $control = new TInPlaceDropDownList(); + $control->setEmptyDisplayText('(none)'); + $label = $this->renderLabel($control); + $this->assertStringContainsString('data-prado-empty="1"', $label); + $this->assertStringContainsString('>(none)<', $label); + } + + public function testRenderLabelCarriesTheControlStyleAndToolTip() + { + $control = new TInPlaceDropDownList(); + $control->setCssClass('editable'); + $control->getStyle()->setCustomStyle('color:red'); + $control->setToolTip('click to edit'); + $label = $this->renderLabel($control); + $this->assertStringContainsString('class="editable"', $label); + $this->assertStringContainsString('color:red', $label); + $this->assertStringContainsString('title="click to edit"', $label); + } + + // --- getPostBackOptions --- + + public function testPostBackOptions() + { + $control = new TInPlaceDropDownList(); + $control->setID('dropdown'); + $control->setEmptyDisplayText('(none)'); + $options = PradoUnit::invoke($control, 'getPostBackOptions'); + $this->assertSame($control->getClientID() . '__label', $options['ID']); + $this->assertSame($control->getClientID(), $options['EditorID']); + $this->assertSame('', $options['ExternalControl']); + $this->assertTrue($options['AutoHide']); + $this->assertTrue($options['AutoPostBack']); + $this->assertSame('(none)', $options['EmptyDisplayText']); + $this->assertFalse($options['DisplayEditor']); + $this->assertFalse($options['ReadOnly']); + $this->assertSame($control->getUniqueID(), $options['EventTarget']); + $this->assertArrayNotHasKey('LoadItemsOnEdit', $options); + } + + public function testPostBackOptionsWithFalseFlags() + { + $control = new TInPlaceDropDownList(); + $control->setAutoHideEditor(false); + $control->setAutoPostBack(false); + $control->setReadOnly(true); + $options = PradoUnit::invoke($control, 'getPostBackOptions'); + $this->assertFalse($options['AutoHide']); + $this->assertFalse($options['AutoPostBack']); + $this->assertTrue($options['ReadOnly']); + } + + public function testPostBackOptionsCarryDisplayEditor() + { + $control = new TInPlaceDropDownList(); + $control->setDisplayEditor(true); + $options = PradoUnit::invoke($control, 'getPostBackOptions'); + $this->assertTrue($options['DisplayEditor']); + } + + public function testPostBackOptionsWithLoadingItemsHandler() + { + $control = new TInPlaceDropDownList(); + $control->attachEventHandler('OnLoadingItems', function ($sender, $param) { + }); + $options = PradoUnit::invoke($control, 'getPostBackOptions'); + $this->assertTrue($options['LoadItemsOnEdit']); + } + + // --- callback events --- + + public function testOnCallbackRaisesOnCallbackEvent() + { + $control = new TInPlaceDropDownList(); + $raised = false; + $control->attachEventHandler('OnCallback', function ($sender, $param) use (&$raised) { + $raised = true; + }); + $param = new TCallbackEventParameter($control->getResponse(), 'value'); + $control->onCallback($param); + $this->assertTrue($raised); + } + + public function testOnCallbackRaisesOnLoadingItemsForLoadItemsAction() + { + $control = new TInPlaceDropDownList(); + $loadingValue = null; + $control->attachEventHandler('OnLoadingItems', function ($sender, $param) use (&$loadingValue) { + $loadingValue = $param->getCallbackParameter(); + }); + $param = new TCallbackEventParameter( + $control->getResponse(), + ['__InlineEditor_loadItems__', 'current'] + ); + $control->onCallback($param); + $this->assertSame('current', $loadingValue); + } + + public function testOnCallbackDoesNotRaiseOnLoadingItemsForPlainParameter() + { + $control = new TInPlaceDropDownList(); + $raised = false; + $control->attachEventHandler('OnLoadingItems', function ($sender, $param) use (&$raised) { + $raised = true; + }); + $param = new TCallbackEventParameter($control->getResponse(), 'current'); + $control->onCallback($param); + $this->assertFalse($raised); + } +} diff --git a/tests/unit/Web/UI/ActiveControls/TInPlaceTextBoxTest.php b/tests/unit/Web/UI/ActiveControls/TInPlaceTextBoxTest.php new file mode 100644 index 000000000..6b0a169f7 --- /dev/null +++ b/tests/unit/Web/UI/ActiveControls/TInPlaceTextBoxTest.php @@ -0,0 +1,109 @@ +renderContents(new THtmlWriter($textWriter)); + return $textWriter->flush(); + } + + public function testEmptyDisplayText() + { + $control = new TInPlaceTextBox(); + $this->assertSame('', $control->getEmptyDisplayText()); + $control->setEmptyDisplayText('(none)'); + $this->assertSame('(none)', $control->getEmptyDisplayText()); + } + + public function testRenderContentsShowsText() + { + $control = new TInPlaceTextBox(); + $control->setText('Hello'); + $control->setEmptyDisplayText('(none)'); + $this->assertSame('Hello', $this->renderContents($control)); + } + + public function testRenderContentsShowsEmptyDisplayTextWhenTextIsEmpty() + { + $control = new TInPlaceTextBox(); + $control->setEmptyDisplayText('(none)'); + $this->assertSame('(none)', $this->renderContents($control)); + } + + public function testRenderContentsIsEmptyWithoutEmptyDisplayText() + { + $control = new TInPlaceTextBox(); + $this->assertSame('', $this->renderContents($control)); + } + + public function testPostBackOptionsIncludeEmptyText() + { + $control = new TInPlaceTextBox(); + $control->setEmptyDisplayText('(none)'); + $options = PradoUnit::invoke($control, 'getPostBackOptions'); + $this->assertSame('(none)', $options['EmptyDisplayText']); + } + + public function testPostBackOptionsCarryDisplayEditor() + { + $control = new TInPlaceTextBox(); + $options = PradoUnit::invoke($control, 'getPostBackOptions'); + $this->assertFalse($options['DisplayEditor']); + $control->setDisplayTextBox(true); + $options = PradoUnit::invoke($control, 'getPostBackOptions'); + $this->assertTrue($options['DisplayEditor']); + } + + public function testAutoHideTextBoxIsAnAliasOfAutoHideEditor() + { + $control = new TInPlaceTextBox(); + $this->assertTrue($control->getAutoHideTextBox()); + $this->assertTrue($control->getAutoHideEditor()); + + $control->setAutoHideTextBox(false); + $this->assertFalse($control->getAutoHideEditor()); + $this->assertFalse($control->getAutoHideTextBox()); + + $control->setAutoHideEditor(true); + $this->assertTrue($control->getAutoHideTextBox()); + } + + public function testDisplayTextBoxIsAnAliasOfDisplayEditor() + { + $control = new TInPlaceTextBox(); + $this->assertFalse($control->getDisplayTextBox()); + $this->assertFalse($control->getDisplayEditor()); + + $control->setDisplayTextBox(true); + $this->assertTrue($control->getDisplayEditor()); + $this->assertTrue($control->getDisplayTextBox()); + + $control->setDisplayEditor(false); + $this->assertFalse($control->getDisplayTextBox()); + } + + public function testPostBackOptionsCarryEditorID() + { + $control = new TInPlaceTextBox(); + $control->setID('textbox'); + $options = PradoUnit::invoke($control, 'getPostBackOptions'); + // EditorID is the family-wide registry key; TextBoxID stays for the input id. + $this->assertSame($control->getClientID(), $options['EditorID']); + $this->assertSame($control->getClientID(), $options['TextBoxID']); + } + + public function testEditTriggerControlID() + { + $control = new TInPlaceTextBox(); + $this->assertNull($control->getEditTriggerControlID()); + $control->setEditTriggerControlID('editButton'); + $this->assertSame('editButton', $control->getEditTriggerControlID()); + } +} From b69183d99556585ab812a40e0d6472d023bd700f Mon Sep 17 00:00:00 2001 From: Belisoful Date: Fri, 28 Aug 2026 04:48:16 +0000 Subject: [PATCH 2/3] TInPlaceListBox, TInPlaceListControlTrait - select multiple, and trait for select based InPlace controls. TInPlaceDropDownList - changed to use TInPlaceListControlTrait --- .../framework/Web/UI/ActiveControls/INDEX.md | 2 + .../Web/UI/ActiveControls/SUMMARY.md | 2 + .../UI/ActiveControls/TInPlaceDropDownList.md | 2 +- .../Web/UI/ActiveControls/TInPlaceListBox.md | 27 ++ .../prado/activecontrols/inlineeditor.js | 209 +++++++--- .../ActiveControls/TInPlaceDropDownList.php | 202 +--------- .../Web/UI/ActiveControls/TInPlaceListBox.php | 108 +++++ .../TInPlaceListControlTrait.php | 224 +++++++++++ framework/classes.php | 2 + .../protected/pages/InPlaceListBoxTest.page | 19 + .../protected/pages/InPlaceListBoxTest.php | 21 + tests/js/activecontrols/inlineeditor.test.js | 25 +- .../inplacedropdownlist.test.js | 33 +- .../js/activecontrols/inplacelistbox.test.js | 375 ++++++++++++++++++ tests/js/adapters/inlineeditor.js | 1 + .../web/TInPlaceListBoxTestCase.spec.js | 81 ++++ .../UI/ActiveControls/TInPlaceListBoxTest.php | 251 ++++++++++++ 17 files changed, 1327 insertions(+), 257 deletions(-) create mode 100644 agents/framework/Web/UI/ActiveControls/TInPlaceListBox.md create mode 100644 framework/Web/UI/ActiveControls/TInPlaceListBox.php create mode 100644 framework/Web/UI/ActiveControls/TInPlaceListControlTrait.php create mode 100644 tests/harness/web/protected/pages/InPlaceListBoxTest.page create mode 100644 tests/harness/web/protected/pages/InPlaceListBoxTest.php create mode 100644 tests/js/activecontrols/inplacelistbox.test.js create mode 100644 tests/playwright/web/TInPlaceListBoxTestCase.spec.js create mode 100644 tests/unit/Web/UI/ActiveControls/TInPlaceListBoxTest.php diff --git a/agents/framework/Web/UI/ActiveControls/INDEX.md b/agents/framework/Web/UI/ActiveControls/INDEX.md index 6da4198d1..35142deeb 100644 --- a/agents/framework/Web/UI/ActiveControls/INDEX.md +++ b/agents/framework/Web/UI/ActiveControls/INDEX.md @@ -70,7 +70,9 @@ AJAX-enabled controls for the Prado framework. ActiveControls trigger server-sid | `TActiveDatePicker` | TDatePicker | Date picker with `OnDateChanged` callback | | `TInPlaceTextBox` | (custom) | Click-to-edit label; `LoadTextOnEdit` option | | `TInPlaceDropDownList` | TActiveDropDownList | Click-to-edit label over a hidden select; `OnLoadingItems` event | +| `TInPlaceListBox` | TActiveListBox | Click-to-edit label over a hidden multi-select; joins selected texts with `SelectionSeparator` | | `TInPlaceControlTrait` | (trait) | Shared in-place surface: `AutoHideEditor`, `DisplayEditor`, `EditTriggerControlID`, `EmptyDisplayText`, label ID | +| `TInPlaceListControlTrait` | (trait) | Shared select-based in-place surface for the drop down list and list box (label sync, rendering, callback options) | | `TActiveCustomValidator` | TCustomValidator | Server-side validation via callback | | `TTimeTriggeredCallback` | (no UI) | Timer-based callback; `Interval`, `StartTimerOnLoad` | | `TValueTriggeredCallback` | (no UI) | Polls a JS expression; fires callback when value changes | diff --git a/agents/framework/Web/UI/ActiveControls/SUMMARY.md b/agents/framework/Web/UI/ActiveControls/SUMMARY.md index 656bd4f77..f9e15f4a6 100644 --- a/agents/framework/Web/UI/ActiveControls/SUMMARY.md +++ b/agents/framework/Web/UI/ActiveControls/SUMMARY.md @@ -42,6 +42,8 @@ AJAX-enabled controls triggering server-side processing via XMLHttpRequest callb - **`TInPlaceDropDownList`** — Click-to-edit label over a hidden select; `OnLoadingItems` event. +- **`TInPlaceListBox`** — Click-to-edit label over a hidden multi-select; joins selected texts with `SelectionSeparator`. + - **`TActiveCustomValidator`** — Server-side validation via callback. - **`TTimeTriggeredCallback`** / **`TValueTriggeredCallback`** / **`TEventTriggeredCallback`** — Timer/poll/event-based callbacks. diff --git a/agents/framework/Web/UI/ActiveControls/TInPlaceDropDownList.md b/agents/framework/Web/UI/ActiveControls/TInPlaceDropDownList.md index 293acaa77..c50c7850a 100644 --- a/agents/framework/Web/UI/ActiveControls/TInPlaceDropDownList.md +++ b/agents/framework/Web/UI/ActiveControls/TInPlaceDropDownList.md @@ -24,4 +24,4 @@ Click-to-edit drop down list that displays as a label showing the selected item ## See Also -- [TInPlaceTextBox](./TInPlaceTextBox.md), [TActiveDropDownList](./TActiveDropDownList.md) +- [TInPlaceTextBox](./TInPlaceTextBox.md), [TInPlaceListBox](./TInPlaceListBox.md), [TActiveDropDownList](./TActiveDropDownList.md) diff --git a/agents/framework/Web/UI/ActiveControls/TInPlaceListBox.md b/agents/framework/Web/UI/ActiveControls/TInPlaceListBox.md new file mode 100644 index 000000000..acd08e116 --- /dev/null +++ b/agents/framework/Web/UI/ActiveControls/TInPlaceListBox.md @@ -0,0 +1,27 @@ +# Web/UI/ActiveControls/TInPlaceListBox + +### Directories +[framework](../../../INDEX.md) / [Web](../../INDEX.md) / [UI](../INDEX.md) / [ActiveControls](./INDEX.md) / **`TInPlaceListBox`** + +## Class Info +**Location:** `framework/Web/UI/ActiveControls/TInPlaceListBox.php` +**Namespace:** `Prado\Web\UI\ActiveControls` +**Since:** 4.4.0 + +## Overview +Click-to-edit list box that displays as a label showing the selected item texts. Clicking the label swaps to the server-rendered (hidden) select, which may allow multiple selection. In multiple mode the label joins the selected item texts with `SelectionSeparator`, and the selection accumulates across clicks and commits on blur (leaving the editor) — an individual toggle does not post, so a multi-item selection can be built interactively. A single-selection list box commits on change like the drop down list. The committing callback raises `OnSelectedIndexChanged` and `OnCallback`; the label follows the server's selection on any callback (the client-side snapshot for revert keys on option index, so duplicate option values are handled correctly). When nothing is selected the label shows `EmptyDisplayText` and carries a `data-prado-empty` mark. Shares the select-based in-place surface with `TInPlaceDropDownList` through `TInPlaceListControlTrait` (PHP) and `Prado.WebUI.TInPlaceDropDownList` (JS, its base class in `inlineeditor.js`). + +## Key Properties/Methods + +- `getSelectionSeparator()` / `setSelectionSeparator($value)` - Text between selected item texts in the label (default ", ") +- `getAutoHideEditor()` / `setAutoHideEditor($value)` - Hide the select after blur (default true); from `TInPlaceControlTrait` +- `getDisplayEditor()` / `setDisplayEditor($value)` - Show/hide the select; from `TInPlaceControlTrait` +- `getEditTriggerControlID()` / `setEditTriggerControlID($value)` - External trigger control ID +- `getReadOnly()` / `setReadOnly($value)` - Prevent entering edit mode +- `getEmptyDisplayText()` / `setEmptyDisplayText($value)` - Label html when nothing is selected +- `onLoadingItems($param)` - Event raised to load the item list from the server on edit +- `getClientClassName()` - Returns `Prado.WebUI.TInPlaceListBox` + +## See Also + +- [TInPlaceDropDownList](./TInPlaceDropDownList.md), [TActiveListBox](./TActiveListBox.md) diff --git a/framework/Web/Javascripts/source/prado/activecontrols/inlineeditor.js b/framework/Web/Javascripts/source/prado/activecontrols/inlineeditor.js index 4d5826638..b91328020 100644 --- a/framework/Web/Javascripts/source/prado/activecontrols/inlineeditor.js +++ b/framework/Web/Javascripts/source/prado/activecontrols/inlineeditor.js @@ -188,10 +188,15 @@ Prado.WebUI.TInPlaceControlBase = Prado.Class(Prado.WebUI.Control, request.setCallbackParameter(parameter); request.options.onSuccess = (sender, param) => this.onChangeSuccess(sender, param); request.options.onFailure = (sender, param) => this.onChangeFailure(sender, param); - if(request.dispatch()) + // CallbackRequest.dispatch() returns false only when validation blocks + // the request; on a dispatched request it returns undefined. Treat any + // non-false result as dispatched so the save is marked in flight. The + // field is not disabled here: the request serializes the form lazily + // from the ajax queue, so disabling now would drop the field's value + // from a POST that a queued request sends after this returns. + if(request.dispatch() !== false) { this.isSaving = true; - this.editField.disabled = true; } }, @@ -309,6 +314,27 @@ Object.assign(Prado.WebUI.TInPlaceControlBase, }); +/** + * Builds the backward compatible static block for an in-place control class: + * the aliased view of the shared registry plus delegators to the base class + * methods. Subclasses assign it and add only class-specific statics. + * @param {String} alias legacy registry property name for the class + * @return {Object} the static members to assign onto the class + */ +Prado.WebUI.TInPlaceControlBase.aliasStatics = function(alias) { + const base = Prado.WebUI.TInPlaceControlBase; + const statics = { + register(obj) { base.register(obj); }, + setDisplayEditor(id, value) { base.setDisplayEditor(id, value); }, + setReadOnly(id, value) { base.setReadOnly(id, value); }, + setLabelText(id, value) { base.setLabelText(id, value); }, + setEmptyDisplayText(id, value) { base.setEmptyDisplayText(id, value); } + }; + statics[alias] = base.instances; + return statics; +}; + + Prado.WebUI.TInPlaceTextBox = Prado.Class(Prado.WebUI.TInPlaceControlBase, { getDefaultOptions() { @@ -416,7 +442,7 @@ Prado.WebUI.TInPlaceTextBox = Prado.Class(Prado.WebUI.TInPlaceControlBase, const text = this.getText(); if(this.options.AutoPostBack && text != this.editField.value) { - if(this.isEditing) + if(this.isEditing && !this.isSaving) this.onTextChanged(text); } else @@ -501,7 +527,7 @@ Prado.WebUI.TInPlaceTextBox = Prado.Class(Prado.WebUI.TInPlaceControlBase, }); -Object.assign(Prado.WebUI.TInPlaceTextBox, +Object.assign(Prado.WebUI.TInPlaceTextBox, Prado.WebUI.TInPlaceControlBase.aliasStatics('textboxes'), { //class methods @@ -524,31 +550,9 @@ Object.assign(Prado.WebUI.TInPlaceTextBox, Week: 'week' }, - // Backward compatible view of the shared registry. - textboxes : Prado.WebUI.TInPlaceControlBase.instances, - - register(obj) { - Prado.WebUI.TInPlaceControlBase.register(obj); - }, - - setDisplayEditor(id, value) { - Prado.WebUI.TInPlaceControlBase.setDisplayEditor(id, value); - }, - + // Backward compatible alias of setDisplayEditor. setDisplayTextBox(id, value) { Prado.WebUI.TInPlaceControlBase.setDisplayEditor(id, value); - }, - - setReadOnly(id, value) { - Prado.WebUI.TInPlaceControlBase.setReadOnly(id, value); - }, - - setLabelText(id, value) { - Prado.WebUI.TInPlaceControlBase.setLabelText(id, value); - }, - - setEmptyDisplayText(id, value) { - Prado.WebUI.TInPlaceControlBase.setEmptyDisplayText(id, value); } }); @@ -576,7 +580,7 @@ Prado.WebUI.TInPlaceDropDownList = Prado.Class(Prado.WebUI.TInPlaceControlBase, }, onEnterEditMode($super) { - this.originalValue = this.editField.value; + this.originalSelection = this.captureSelection(); $super(); }, @@ -587,7 +591,7 @@ Prado.WebUI.TInPlaceDropDownList = Prado.Class(Prado.WebUI.TInPlaceControlBase, loadItems() { this.editField.disabled = true; - const options = ['__InlineEditor_loadItems__', this.editField.value]; + const options = ['__InlineEditor_loadItems__', this.getEditSelection()]; const request = new Prado.CallbackRequest(this.options.EventTarget, this.options); request.setCausesValidation(false); request.setCallbackParameter(options); @@ -596,6 +600,13 @@ Prado.WebUI.TInPlaceDropDownList = Prado.Class(Prado.WebUI.TInPlaceControlBase, request.dispatch(); }, + /** + * @return {mixed} the selection sent to the OnLoadingItems server handler. + */ + getEditSelection() { + return this.editField.value; + }, + /** * @return {String} text of the selected option, empty when none is selected. */ @@ -605,6 +616,21 @@ Prado.WebUI.TInPlaceDropDownList = Prado.Class(Prado.WebUI.TInPlaceControlBase, return option ? option.text : ''; }, + /** + * @return {mixed} snapshot of the current selection, for revert and dispatch. + */ + captureSelection() { + return this.editField.value; + }, + + /** + * Restores a selection snapshot from captureSelection(). + * @param {mixed} selection snapshot to restore + */ + applySelection(selection) { + this.editField.value = selection; + }, + isEditorEmpty() { return this.getSelectedText() === ''; }, @@ -639,7 +665,7 @@ Prado.WebUI.TInPlaceDropDownList = Prado.Class(Prado.WebUI.TInPlaceControlBase, onKeyPressed(e) { if (e.keyCode == 27) //KEY_ESC { - this.editField.value = this.originalValue; + this.applySelection(this.originalSelection); this.refreshLabel(); this.isEditing = false; if(this.options.AutoHide) @@ -656,11 +682,11 @@ Prado.WebUI.TInPlaceDropDownList = Prado.Class(Prado.WebUI.TInPlaceControlBase, * When the selection has changed, posts the selection through a callback. */ onValueChanged() { - this.dispatchChange(this.originalValue); + this.dispatchChange(this.originalSelection); }, onAfterSave() { - this.originalValue = this.editField.value; + this.originalSelection = this.captureSelection(); }, onChangeSuccess(sender, parameter) { @@ -692,7 +718,7 @@ Prado.WebUI.TInPlaceDropDownList = Prado.Class(Prado.WebUI.TInPlaceControlBase, onLoadItemsSuccess(request, parameter) { this.isEditing = true; this.editField.disabled = false; - this.originalValue = this.editField.value; + this.originalSelection = this.captureSelection(); this.editField.focus(); if(typeof(this.options.onSuccess)=="function") this.options.onSuccess(request, parameter); @@ -709,30 +735,119 @@ Prado.WebUI.TInPlaceDropDownList = Prado.Class(Prado.WebUI.TInPlaceControlBase, }); -Object.assign(Prado.WebUI.TInPlaceDropDownList, +Object.assign(Prado.WebUI.TInPlaceDropDownList, Prado.WebUI.TInPlaceControlBase.aliasStatics('dropdowns')); + + +/** + * In-place list box. The edit element is the server-rendered select + * identified by options.EditorID, which may allow multiple selection. The + * label shows the selected option texts joined by options.SelectionSeparator. + * @since 4.4.0 + */ +Prado.WebUI.TInPlaceListBox = Prado.Class(Prado.WebUI.TInPlaceDropDownList, { - //class methods + /** + * Applies fn(option, index) to each selected option and returns the results. + * @param {Function} fn mapping applied to each selected option + * @return {Array} the mapped results + */ + mapSelectedOptions(fn) { + const result = []; + const options = this.editField.options; + for(let i = 0; i < options.length; i++) + { + if(options[i].selected) + result.push(fn(options[i], i)); + } + return result; + }, - // Backward compatible view of the shared registry. - dropdowns : Prado.WebUI.TInPlaceControlBase.instances, + /** + * @return {Array} values of the selected options, sent to OnLoadingItems so + * a multiple selection is fully reported (not just the first value). + */ + getEditSelection() { + return this.mapSelectedOptions(option => option.value); + }, - register(obj) { - Prado.WebUI.TInPlaceControlBase.register(obj); + /** + * @return {Array} texts of the selected options, empty texts skipped to + * match the server label. + */ + getSelectedTexts() { + return this.mapSelectedOptions(option => option.text).filter(text => text !== ''); }, - setDisplayEditor(id, value) { - Prado.WebUI.TInPlaceControlBase.setDisplayEditor(id, value); + /** + * @return {String} selected option texts joined by the separator. An empty + * separator is honored, matching the server. + */ + getSelectedText() { + const separator = this.options.SelectionSeparator ?? ', '; + return this.getSelectedTexts().join(separator); }, - setReadOnly(id, value) { - Prado.WebUI.TInPlaceControlBase.setReadOnly(id, value); + /** + * The snapshot is the set of selected indices, so restore is exact even + * when option values are duplicated. + * @return {Array} indices of the selected options. + */ + captureSelection() { + return this.mapSelectedOptions((option, index) => index); }, - setLabelText(id, value) { - Prado.WebUI.TInPlaceControlBase.setLabelText(id, value); + /** + * Restores a selection snapshot, selecting exactly the given indices. + * @param {Array} selection indices to select + */ + applySelection(selection) { + const set = new Set(selection); + const options = this.editField.options; + for(let i = 0; i < options.length; i++) + options[i].selected = set.has(i); }, - setEmptyDisplayText(id, value) { - Prado.WebUI.TInPlaceControlBase.setEmptyDisplayText(id, value); + /** + * @return {Boolean} whether the selection differs from the edit-start snapshot. + */ + selectionChanged() { + const now = this.captureSelection(); + const original = this.originalSelection || []; + if(now.length !== original.length) + return true; + const set = new Set(original); + return now.some(index => !set.has(index)); + }, + + /** + * Multiple selection accumulates across clicks and commits on blur, so an + * individual toggle does not post and collapse the editor. A single + * selection list box commits on change like the drop down list. + * @param {Event} event change event + */ + onSelectionChanged($super, event) { + if(this.editField.multiple) + return; + $super(event); + }, + + /** + * On blur a multiple selection list box commits the accumulated selection. + * A commit whose dispatch does not start (validation failed, or a request + * is already in flight) leaves the editor open with the selection intact, + * so the user can retry; the label is not touched. + * @param {Event} e blur event + */ + onDropDownBlur($super, e) { + if(this.editField.multiple && this.isEditing && !this.isSaving + && this.options.AutoPostBack && this.selectionChanged()) + { + this.onValueChanged(); + return; + } + $super(e); } }); + + +Object.assign(Prado.WebUI.TInPlaceListBox, Prado.WebUI.TInPlaceControlBase.aliasStatics('listboxes')); diff --git a/framework/Web/UI/ActiveControls/TInPlaceDropDownList.php b/framework/Web/UI/ActiveControls/TInPlaceDropDownList.php index 81fc89d6d..e54eaeb97 100644 --- a/framework/Web/UI/ActiveControls/TInPlaceDropDownList.php +++ b/framework/Web/UI/ActiveControls/TInPlaceDropDownList.php @@ -10,8 +10,6 @@ namespace Prado\Web\UI\ActiveControls; -use Prado\TPropertyValue; -use Prado\Web\THttpUtility; use Prado\Web\UI\WebControls\TDropDownList; /** @@ -44,208 +42,18 @@ */ class TInPlaceDropDownList extends TActiveDropDownList { - use TInPlaceControlTrait { - onPreRender as protected inPlaceOnPreRender; + use TInPlaceControlTrait, TInPlaceListControlTrait { + TInPlaceListControlTrait::onPreRender insteadof TInPlaceControlTrait; } /** - * @param bool $value true to prevent entering edit mode. - */ - public function setReadOnly($value) - { - $value = TPropertyValue::ensureBoolean($value); - if ($this->getReadOnly() === $value) { - return; - } - - $this->setViewState('ReadOnly', $value, false); - if ($this->getActiveControl()->canUpdateClientSide()) { - $this->callClientFunction('setReadOnly', $value); - } - } - - /** - * @return bool true to prevent entering edit mode. Defaults to false. - */ - public function getReadOnly() - { - return $this->getViewState('ReadOnly', false); - } - - /** - * On callback response, the label is updated with the new selection. - * @param string $value value of the item to be selected - */ - public function setSelectedValue($value) - { - $prior = $this->getSelectedValue(); - parent::setSelectedValue($value); - if ($prior !== $this->getSelectedValue() && $this->getActiveControl()->canUpdateClientSide()) { - $this->updateLabel(); - } - } - - /** - * On callback response, the label is updated with the new selection. - * @param int $index index of the item to be selected - */ - public function setSelectedIndex($index) - { - $prior = $this->getSelectedIndex(); - parent::setSelectedIndex($index); - if ($prior !== $this->getSelectedIndex() && $this->getActiveControl()->canUpdateClientSide()) { - $this->updateLabel(); - } - } - - /** - * Updates the client-side label with the selected display text. - */ - protected function updateLabel() - { - $this->callClientFunction('setLabelText', $this->getSelectedItemText()); - } - - /** - * @return string encoded selected item text, empty when nothing is selected. - */ - protected function getSelectedItemText() - { - $item = $this->getSelectedItem(); - if ($item === null || ($text = $item->getText()) === '') { - return ''; - } - return THttpUtility::htmlEncode($text); - } - - /** - * @return string encoded selected item text, or {@see getEmptyDisplayText} when empty. - */ - protected function getSelectedDisplayText() - { - if (($text = $this->getSelectedItemText()) === '') { - return $this->getEmptyDisplayText(); - } - return $text; - } - - /** - * Renders the label followed by the drop down list. + * Renders the drop down list attributes, always registering the in-place + * client class. * @param \Prado\Web\UI\THtmlWriter $writer the writer for rendering */ - public function render($writer) + protected function renderListControlAttributes($writer) { - $this->renderLabel($writer); - parent::render($writer); - } - - /** - * Renders the label span holding the selected display text. - * @param \Prado\Web\UI\THtmlWriter $writer the writer for rendering - */ - protected function renderLabel($writer) - { - $writer->addAttribute('id', $this->getLabelClientID()); - if ($this->getHasStyle()) { - $this->getStyle()->addAttributesToRender($writer); - } - if (($toolTip = $this->getToolTip()) !== '') { - $writer->addAttribute('title', $toolTip); - } - $this->renderEmptyDisplayAttribute($writer, $this->getSelectedItemText() === ''); - if ($this->getDisplayEditor()) { - $writer->addStyleAttribute('display', 'none'); - } - $writer->renderBeginTag('span'); - $writer->write($this->getSelectedDisplayText()); - $writer->renderEndTag(); - } - - /** - * Ensures the ID attribute is rendered, hides the drop down list when the - * label is displayed, and registers the javascript code for initializing - * the active control. - * @param \Prado\Web\UI\THtmlWriter $writer the writer for rendering - */ - protected function addAttributesToRender($writer) - { - //calls the TDropDownList to always register the in-place client class. TDropDownList::addAttributesToRender($writer); - $writer->addAttribute('id', $this->getClientID()); - if (!$this->getDisplayEditor()) { - $writer->addStyleAttribute('display', 'none'); - } - $this->getActiveControl()->registerCallbackClientScript( - $this->getClientClassName(), - $this->getPostBackOptions() - ); - } - - /** - * @return array callback options. - */ - protected function getPostBackOptions() - { - $options = parent::getPostBackOptions(); - $options['ID'] = $this->getLabelClientID(); - $options['EditorID'] = $this->getClientID(); - $options['ExternalControl'] = $this->getExternalControlID(); - $options['AutoHide'] = $this->getAutoHideEditor(); - $options['AutoPostBack'] = $this->getAutoPostBack(); - $options['EmptyDisplayText'] = $this->getEmptyDisplayText(); - $options['DisplayEditor'] = $this->getDisplayEditor(); - - if ($this->hasEventHandler('OnLoadingItems')) { - $options['LoadItemsOnEdit'] = true; - } - - $options['ReadOnly'] = $this->getReadOnly(); - return $options; - } - - /** - * Refreshes the client-side label when the item list changed during a - * callback, so a new text for the selected value reaches the label. - * @param mixed $param event parameter - */ - public function onPreRender($param) - { - $items = $this->getItems(); - $listChanged = ($items instanceof TActiveListItemCollection) && $items->getListHasChanged(); - - $this->inPlaceOnPreRender($param); - - if ($listChanged && $this->getActiveControl()->canUpdateClientSide()) { - $this->updateLabel(); - } - } - - /** - * This method is invoked when a callback is requested. The method raises - * 'OnCallback' event to fire up the event handlers. If you override this - * method, be sure to call the parent implementation so that the event - * handler can be invoked. - * @param TCallbackEventParameter $param event parameter to be passed to the event handlers - */ - public function onCallback($param) - { - $action = $param->getCallbackParameter(); - if (is_array($action) && $action[0] === '__InlineEditor_loadItems__') { - $parameter = new TCallbackEventParameter($this->getResponse(), $action[1]); - $this->onLoadingItems($parameter); - } - $this->raiseEvent('OnCallback', $this, $param); - } - - /** - * Raised when the item list is requested to be loaded from the server - * side. The callback parameter holds the drop down list value at the time - * editing is entered. - * @param TCallbackEventParameter $param event parameter to be passed to the event handlers - */ - public function onLoadingItems($param) - { - $this->raiseEvent('OnLoadingItems', $this, $param); } /** diff --git a/framework/Web/UI/ActiveControls/TInPlaceListBox.php b/framework/Web/UI/ActiveControls/TInPlaceListBox.php new file mode 100644 index 000000000..b177d7a21 --- /dev/null +++ b/framework/Web/UI/ActiveControls/TInPlaceListBox.php @@ -0,0 +1,108 @@ + + * @link https://github.com/pradosoft/prado + * @license https://github.com/pradosoft/prado/blob/master/LICENSE + */ + +namespace Prado\Web\UI\ActiveControls; + +use Prado\TPropertyValue; +use Prado\Web\THttpUtility; +use Prado\Web\UI\WebControls\TListBox; + +/** + * TInPlaceListBox class. + * + * TInPlaceListBox is a list box rendered as a label showing the selected item + * texts. Clicking the label, or the control given by + * {@see setEditTriggerControlID EditTriggerControlID}, swaps the label for the + * list box. In multiple selection mode the label joins the selected item texts + * with {@see setSelectionSeparator SelectionSeparator}. When nothing is + * selected the label shows {@see setEmptyDisplayText EmptyDisplayText}. + * + * When {@see \Prado\Web\UI\WebControls\TListControl::setAutoPostBack AutoPostBack} + * is true (the default), changing the selection makes a callback request that + * raises {@see \Prado\Web\UI\WebControls\TListControl::onSelectedIndexChanged OnSelectedIndexChanged} + * and {@see onCallback OnCallback}. After the request returns successfully the + * label shows the new selection and, when {@see setAutoHideEditor AutoHideEditor} + * is true, the list box is hidden and the label is shown. + * + * If the {@see onLoadingItems OnLoadingItems} event is handled, a callback + * request is made when the label is clicked, so the item list can be updated + * before the client selects. + * + * The {@see setReadOnly ReadOnly} property prevents entering edit mode. The + * property can be changed during a callback. + * + * @author Brad Anderson + * @since 4.4.0 + */ +class TInPlaceListBox extends TActiveListBox +{ + use TInPlaceControlTrait, TInPlaceListControlTrait { + TInPlaceListControlTrait::onPreRender insteadof TInPlaceControlTrait; + } + + /** + * @param string $value text placed between the selected item texts in the label. + */ + public function setSelectionSeparator($value) + { + $this->setViewState('SelectionSeparator', TPropertyValue::ensureString($value), ', '); + } + + /** + * @return string text placed between the selected item texts. Defaults to ", ". + */ + public function getSelectionSeparator() + { + return $this->getViewState('SelectionSeparator', ', '); + } + + /** + * @return string encoded selected item texts joined by the separator, + * empty when nothing is selected. + */ + protected function getSelectedItemText() + { + $texts = []; + foreach ($this->getItems() as $item) { + if ($item->getSelected() && ($text = $item->getText()) !== '') { + $texts[] = $text; + } + } + // Encode the joined string, matching the client which encodes the whole + // join, so an html-special separator renders the same on both sides. + return THttpUtility::htmlEncode(implode($this->getSelectionSeparator(), $texts)); + } + + /** + * @return array additional callback options carrying the selection separator. + */ + protected function getExtraListOptions() + { + return ['SelectionSeparator' => $this->getSelectionSeparator()]; + } + + /** + * Renders the list box attributes, always registering the in-place client + * class. + * @param \Prado\Web\UI\THtmlWriter $writer the writer for rendering + */ + protected function renderListControlAttributes($writer) + { + TListBox::addAttributesToRender($writer); + } + + /** + * @return string corresponding javascript class name for this TInPlaceListBox + */ + protected function getClientClassName() + { + return 'Prado.WebUI.TInPlaceListBox'; + } +} diff --git a/framework/Web/UI/ActiveControls/TInPlaceListControlTrait.php b/framework/Web/UI/ActiveControls/TInPlaceListControlTrait.php new file mode 100644 index 000000000..a583c0cbc --- /dev/null +++ b/framework/Web/UI/ActiveControls/TInPlaceListControlTrait.php @@ -0,0 +1,224 @@ + + * @link https://github.com/pradosoft/prado + * @license https://github.com/pradosoft/prado/blob/master/LICENSE + */ + +namespace Prado\Web\UI\ActiveControls; + +use Prado\TPropertyValue; +use Prado\Web\THttpUtility; + +/** + * TInPlaceListControlTrait trait. + * + * TInPlaceListControlTrait implements the in-place surface shared by the + * select-based in-place controls, {@see TInPlaceDropDownList} and + * {@see TInPlaceListBox}. The control renders a label showing the selected + * item text over the server-rendered (hidden) select. Changing the selection + * posts a callback; the label follows the selection, and follows the item + * list when it is updated during a callback. + * + * The using class supplies two seams: {@see renderListControlAttributes} calls + * the underlying list control's attribute rendering, and + * {@see getClientClassName} names the client class. Multi-select controls + * override {@see getSelectedItemText} to join the selected item texts. + * + * @author Brad Anderson + * @since 4.4.0 + */ +trait TInPlaceListControlTrait +{ + /** + * @param bool $value true to prevent entering edit mode. + */ + public function setReadOnly($value) + { + $value = TPropertyValue::ensureBoolean($value); + if ($this->getReadOnly() === $value) { + return; + } + + $this->setViewState('ReadOnly', $value, false); + if ($this->getActiveControl()->canUpdateClientSide()) { + $this->callClientFunction('setReadOnly', $value); + } + } + + /** + * @return bool true to prevent entering edit mode. Defaults to false. + */ + public function getReadOnly() + { + return $this->getViewState('ReadOnly', false); + } + + /** + * Updates the client-side label with the selected item text. The callback + * request that reaches here posts the whole form, so the server selection + * is current; pushing it on each callback keeps the label authoritative for + * every selection and item-list change, including changes made by other + * controls' handlers. + */ + protected function updateLabel() + { + $this->callClientFunction('setLabelText', $this->getSelectedItemText()); + } + + /** + * @return string encoded selected item text, empty when nothing is selected. + */ + protected function getSelectedItemText() + { + $item = $this->getSelectedItem(); + if ($item === null || ($text = $item->getText()) === '') { + return ''; + } + return THttpUtility::htmlEncode($text); + } + + /** + * @return string encoded selected item text, or {@see getEmptyDisplayText} when empty. + */ + protected function getSelectedDisplayText() + { + if (($text = $this->getSelectedItemText()) === '') { + return $this->getEmptyDisplayText(); + } + return $text; + } + + /** + * Renders the label followed by the select. + * @param \Prado\Web\UI\THtmlWriter $writer the writer for rendering + */ + public function render($writer) + { + $this->renderLabel($writer); + parent::render($writer); + } + + /** + * Renders the label span holding the selected display text. The label + * carries the control's style and tool tip, and a mark when it shows the + * empty display text. + * @param \Prado\Web\UI\THtmlWriter $writer the writer for rendering + */ + protected function renderLabel($writer) + { + $writer->addAttribute('id', $this->getLabelClientID()); + if ($this->getHasStyle()) { + $this->getStyle()->addAttributesToRender($writer); + } + if (($toolTip = $this->getToolTip()) !== '') { + $writer->addAttribute('title', $toolTip); + } + $this->renderEmptyDisplayAttribute($writer, $this->getSelectedItemText() === ''); + if ($this->getDisplayEditor()) { + $writer->addStyleAttribute('display', 'none'); + } + $writer->renderBeginTag('span'); + $writer->write($this->getSelectedDisplayText()); + $writer->renderEndTag(); + } + + /** + * Ensures the ID attribute is rendered, hides the select when the label is + * displayed, and registers the javascript code for the active control. + * @param \Prado\Web\UI\THtmlWriter $writer the writer for rendering + */ + protected function addAttributesToRender($writer) + { + // renderListControlAttributes renders the underlying list control's + // attributes (size/name/multiple/id and the options) but not the active + // callback-script registration, which the trait replaces below with the + // in-place client class. The using class supplies it. + $this->renderListControlAttributes($writer); + $writer->addAttribute('id', $this->getClientID()); + if (!$this->getDisplayEditor()) { + $writer->addStyleAttribute('display', 'none'); + } + $this->getActiveControl()->registerCallbackClientScript( + $this->getClientClassName(), + $this->getPostBackOptions() + ); + } + + /** + * @return array callback options. + */ + protected function getPostBackOptions() + { + $options = parent::getPostBackOptions(); + $options['ID'] = $this->getLabelClientID(); + $options['EditorID'] = $this->getClientID(); + $options['ExternalControl'] = $this->getExternalControlID(); + $options['AutoHide'] = $this->getAutoHideEditor(); + $options['AutoPostBack'] = $this->getAutoPostBack(); + $options['EmptyDisplayText'] = $this->getEmptyDisplayText(); + $options['DisplayEditor'] = $this->getDisplayEditor(); + + if ($this->hasEventHandler('OnLoadingItems')) { + $options['LoadItemsOnEdit'] = true; + } + + $options['ReadOnly'] = $this->getReadOnly(); + return array_merge($options, $this->getExtraListOptions()); + } + + /** + * @return array additional callback options for the using control. + */ + protected function getExtraListOptions() + { + return []; + } + + /** + * Registers the in-place client script and, on a callback, refreshes the + * client-side label from the server's selection. The single authoritative + * update covers every selection mutation and item-list change. + * @param mixed $param event parameter + */ + public function onPreRender($param) + { + parent::onPreRender($param); + $this->registerClientScript(); + if ($this->getActiveControl()->canUpdateClientSide()) { + $this->updateLabel(); + } + } + + /** + * This method is invoked when a callback is requested. The method raises + * 'OnCallback' event to fire up the event handlers. If you override this + * method, be sure to call the parent implementation so that the event + * handler can be invoked. + * @param TCallbackEventParameter $param event parameter to be passed to the event handlers + */ + public function onCallback($param) + { + $action = $param->getCallbackParameter(); + if (is_array($action) && ($action[0] ?? null) === '__InlineEditor_loadItems__') { + $parameter = new TCallbackEventParameter($this->getResponse(), $action[1]); + $this->onLoadingItems($parameter); + } + $this->raiseEvent('OnCallback', $this, $param); + } + + /** + * Raised when the item list is requested to be loaded from the server + * side. The callback parameter holds the selection at the time editing is + * entered: the selected value for a drop down list, and the array of + * selected values for a list box (both single and multiple selection). + * @param TCallbackEventParameter $param event parameter to be passed to the event handlers + */ + public function onLoadingItems($param) + { + $this->raiseEvent('OnLoadingItems', $this, $param); + } +} diff --git a/framework/classes.php b/framework/classes.php index 1c60a7ec1..42dd7ce6b 100644 --- a/framework/classes.php +++ b/framework/classes.php @@ -612,6 +612,8 @@ 'TEventTriggeredCallback' => 'Prado\Web\UI\ActiveControls\TEventTriggeredCallback', 'TInPlaceControlTrait' => 'Prado\Web\UI\ActiveControls\TInPlaceControlTrait', 'TInPlaceDropDownList' => 'Prado\Web\UI\ActiveControls\TInPlaceDropDownList', +'TInPlaceListBox' => 'Prado\Web\UI\ActiveControls\TInPlaceListBox', +'TInPlaceListControlTrait' => 'Prado\Web\UI\ActiveControls\TInPlaceListControlTrait', 'TInPlaceTextBox' => 'Prado\Web\UI\ActiveControls\TInPlaceTextBox', 'TInvalidCallbackException' => 'Prado\Web\UI\ActiveControls\TInvalidCallbackException', 'TMapCollectionDiff' => 'Prado\Web\UI\ActiveControls\TMapCollectionDiff', diff --git a/tests/harness/web/protected/pages/InPlaceListBoxTest.page b/tests/harness/web/protected/pages/InPlaceListBoxTest.page new file mode 100644 index 000000000..207da963b --- /dev/null +++ b/tests/harness/web/protected/pages/InPlaceListBoxTest.page @@ -0,0 +1,19 @@ + +

In Place List Box Test Case

+ +
+ + + + + + +
+ +
Status:
+ + + +
diff --git a/tests/harness/web/protected/pages/InPlaceListBoxTest.php b/tests/harness/web/protected/pages/InPlaceListBoxTest.php new file mode 100644 index 000000000..4a3fce833 --- /dev/null +++ b/tests/harness/web/protected/pages/InPlaceListBoxTest.php @@ -0,0 +1,21 @@ +status->Text = 'changed: ' . implode(',', $sender->getSelectedValues()); + } + + public function server_select($sender, $param) + { + $this->lb->setSelectedValues(['green', 'blue']); + $this->status->Text = 'server selected'; + } + + public function server_clear($sender, $param) + { + $this->lb->clearSelection(); + $this->status->Text = 'cleared'; + } +} diff --git a/tests/js/activecontrols/inlineeditor.test.js b/tests/js/activecontrols/inlineeditor.test.js index 42c73380b..d7170d79c 100644 --- a/tests/js/activecontrols/inlineeditor.test.js +++ b/tests/js/activecontrols/inlineeditor.test.js @@ -439,12 +439,31 @@ describe('TInPlaceTextBox onTextChanged', () => { expect(dispatchMock).toHaveBeenCalled(); }); - it('sets isSaving to true and disables editField when dispatch returns true', () => { - mockCallbackRequest(true); + it('sets isSaving when dispatch does not return false', () => { + // dispatch() returns undefined on a dispatched request; the guard treats + // non-false as dispatched. The field is not disabled during the save + // (the request serializes the form lazily from the ajax queue). + const { instance } = mockCallbackRequest(); + instance.dispatch.mockReturnValue(undefined); const ctrl = new TInPlaceTextBox(makeOptions({ AutoPostBack: true })); ctrl.onTextChanged('old'); expect(ctrl.isSaving).toBe(true); - expect(ctrl.editField.disabled).toBe(true); + expect(ctrl.editField.disabled).toBe(false); + }); + + it('does not dispatch a second save on re-blur while the first is in flight', () => { + // The field is not disabled during a save, so a re-blur must be blocked + // by the isSaving guard (mirroring the dropdown/listbox). + const { instance, dispatchMock } = mockCallbackRequest(); + instance.dispatch.mockReturnValue(undefined); + const ctrl = new TInPlaceTextBox(makeOptions({ AutoPostBack: true })); + ctrl.enterEditMode(null); + ctrl.editField.value = 'first change'; + ctrl.onTextBoxBlur({}); // save 1 dispatched, isSaving latches + expect(ctrl.isSaving).toBe(true); + ctrl.editField.value = 'second change'; + ctrl.onTextBoxBlur({}); // in flight -> must not dispatch again + expect(dispatchMock).toHaveBeenCalledTimes(1); }); it('does NOT set isSaving when dispatch returns false', () => { diff --git a/tests/js/activecontrols/inplacedropdownlist.test.js b/tests/js/activecontrols/inplacedropdownlist.test.js index 39111b592..9225ff9e9 100644 --- a/tests/js/activecontrols/inplacedropdownlist.test.js +++ b/tests/js/activecontrols/inplacedropdownlist.test.js @@ -149,10 +149,10 @@ describe('TInPlaceDropDownList edit mode', () => { expect(select.style.display).not.toBe('none'); }); - it('enterEditMode records the original value for revert', () => { + it('enterEditMode records the original selection for revert', () => { const ctrl = new TInPlaceDropDownList(makeOptions()); ctrl.enterEditMode(null); - expect(ctrl.originalValue).toBe('0'); + expect(ctrl.originalSelection).toBe('0'); }); it('enterEditMode is a no-op when readOnly', () => { @@ -256,14 +256,29 @@ describe('TInPlaceDropDownList selection change', () => { expect(dispatchMock).toHaveBeenCalled(); }); - it('sets isSaving and disables the select while dispatching', () => { + it('sets isSaving while dispatching (the select is not disabled)', () => { mockCallbackRequest(true); const ctrl = new TInPlaceDropDownList(makeOptions()); ctrl.enterEditMode(null); select.selectedIndex = 2; ctrl.onSelectionChanged({}); expect(ctrl.isSaving).toBe(true); - expect(select.disabled).toBe(true); + // Not disabled: a queued request serializes the form after this returns. + expect(select.disabled).toBe(false); + }); + + it('sets isSaving when dispatch returns undefined (the real success value)', () => { + // Prado.CallbackRequest.dispatch() returns undefined on a dispatched + // request and false only on validation failure; the guard must treat + // undefined as dispatched. + const { instance } = mockCallbackRequest(); + instance.dispatch.mockReturnValue(undefined); + const ctrl = new TInPlaceDropDownList(makeOptions()); + ctrl.enterEditMode(null); + select.selectedIndex = 2; + ctrl.onSelectionChanged({}); + expect(ctrl.isSaving).toBe(true); + expect(select.disabled).toBe(false); }); it('does not dispatch when AutoPostBack is false', () => { @@ -433,12 +448,12 @@ describe('TInPlaceDropDownList value changed handlers', () => { expect(label.innerHTML).toBe('Server'); }); - it('onValueChangedSuccess resets originalValue to the current selection', () => { + it('onValueChangedSuccess resets originalSelection to the current selection', () => { const ctrl = new TInPlaceDropDownList(makeOptions()); ctrl.enterEditMode(null); select.selectedIndex = 2; ctrl.onValueChangedSuccess({}, null); - expect(ctrl.originalValue).toBe('2'); + expect(ctrl.originalSelection).toBe('2'); }); it('onValueChangedSuccess hides the select when AutoHide is true', () => { @@ -502,7 +517,7 @@ describe('TInPlaceDropDownList loadItems', () => { expect(dispatchMock).not.toHaveBeenCalled(); }); - it('onLoadItemsSuccess re-enables editing and refreshes originalValue', () => { + it('onLoadItemsSuccess re-enables editing and refreshes originalSelection', () => { const ctrl = new TInPlaceDropDownList(makeOptions()); ctrl.enterEditMode(null); select.disabled = true; @@ -510,7 +525,7 @@ describe('TInPlaceDropDownList loadItems', () => { ctrl.onLoadItemsSuccess({}, null); expect(ctrl.isEditing).toBe(true); expect(select.disabled).toBe(false); - expect(ctrl.originalValue).toBe('1'); + expect(ctrl.originalSelection).toBe('1'); }); it('onLoadItemsFailure resets state and shows the label', () => { @@ -638,7 +653,7 @@ describe('TInPlaceControlBase shared registry and DisplayEditor', () => { expect(label.style.display).toBe('none'); }); - it('DisplayEditor records originalValue so a later change posts correctly', () => { + it('DisplayEditor records originalSelection so a later change posts correctly', () => { const { setCallbackParameterMock } = mockCallbackRequest(); const ctrl = new TInPlaceDropDownList(makeOptions({ DisplayEditor: true })); select.selectedIndex = 2; diff --git a/tests/js/activecontrols/inplacelistbox.test.js b/tests/js/activecontrols/inplacelistbox.test.js new file mode 100644 index 000000000..bd1cfe545 --- /dev/null +++ b/tests/js/activecontrols/inplacelistbox.test.js @@ -0,0 +1,375 @@ +/** + * Tests for Prado.WebUI.TInPlaceListBox — the multi-select in-place control. + * + * Source: + * framework/Web/Javascripts/source/prado/activecontrols/inlineeditor.js + * + * The editor is a server-rendered with the given items. + * `selected` is an array of indices. + */ +function buildDOM(items = ['Alpha', 'Beta', 'Gamma'], selected = [0]) { + const container = document.createElement('div'); + const label = document.createElement('span'); + label.id = 'lb1__label'; + container.appendChild(label); + + const select = document.createElement('select'); + select.id = 'lb1'; + select.name = 'lb1[]'; + select.multiple = true; + select.style.display = 'none'; + for (const [i, text] of items.entries()) { + const option = document.createElement('option'); + option.value = String(i); + option.text = text; + option.selected = selected.includes(i); + select.appendChild(option); + } + container.appendChild(select); + document.body.appendChild(container); + return { container, label, select }; +} + +// ─── Class shape ───────────────────────────────────────────────────────────── + +describe('TInPlaceListBox class shape', () => { + it('extends TInPlaceDropDownList', () => { + expect(Object.getPrototypeOf(TInPlaceListBox.prototype)).toBe(TInPlaceDropDownList.prototype); + }); + + it('shares the base instance registry', () => { + expect(TInPlaceListBox.listboxes).toBe(TInPlaceControlBase.instances); + }); +}); + +// ─── Construction ──────────────────────────────────────────────────────────── + +describe('TInPlaceListBox construction', () => { + let container, label, select; + + beforeEach(() => { + clearRegistry(); + clearListBoxes(); + ({ container, label, select } = buildDOM()); + }); + + afterEach(() => { + restoreMocks(); + container.remove(); + }); + + it('registers in the base registry keyed by the editor client ID', () => { + const ctrl = new TInPlaceListBox(makeOptions()); + expect(TInPlaceControlBase.get('lb1')).toBe(ctrl); + }); + + it('attaches to the server-rendered select as editField', () => { + const ctrl = new TInPlaceListBox(makeOptions()); + expect(ctrl.editField).toBe(select); + }); +}); + +// ─── getSelectedText: joined ───────────────────────────────────────────────── + +describe('TInPlaceListBox getSelectedText', () => { + let container; + + afterEach(() => { restoreMocks(); container?.remove(); }); + + it('joins the selected option texts with the separator', () => { + clearRegistry(); clearListBoxes(); + ({ container } = buildDOM(['Alpha', 'Beta', 'Gamma'], [0, 2])); + const ctrl = new TInPlaceListBox(makeOptions()); + expect(ctrl.getSelectedText()).toBe('Alpha, Gamma'); + }); + + it('uses a custom separator', () => { + clearRegistry(); clearListBoxes(); + ({ container } = buildDOM(['Alpha', 'Beta', 'Gamma'], [0, 1])); + const ctrl = new TInPlaceListBox(makeOptions({ SelectionSeparator: ' | ' })); + expect(ctrl.getSelectedText()).toBe('Alpha | Beta'); + }); + + it('is empty when nothing is selected', () => { + clearRegistry(); clearListBoxes(); + ({ container } = buildDOM(['Alpha', 'Beta'], [])); + const ctrl = new TInPlaceListBox(makeOptions()); + expect(ctrl.getSelectedText()).toBe(''); + expect(ctrl.isEditorEmpty()).toBe(true); + }); + + it('html-encodes each option text', () => { + clearRegistry(); clearListBoxes(); + ({ container } = buildDOM(['a < b', 'c & d'], [0, 1])); + const ctrl = new TInPlaceListBox(makeOptions()); + expect(ctrl.getEditorDisplayText()).toBe('a < b, c & d'); + }); +}); + +// ─── captureSelection / applySelection (index-based) ───────────────────────── + +describe('TInPlaceListBox selection snapshot', () => { + let container, select; + + beforeEach(() => { + clearRegistry(); + clearListBoxes(); + ({ container, select } = buildDOM(['Alpha', 'Beta', 'Gamma'], [0, 2])); + }); + + afterEach(() => { restoreMocks(); container.remove(); }); + + it('captureSelection returns the selected indices', () => { + const ctrl = new TInPlaceListBox(makeOptions()); + expect(ctrl.captureSelection()).toEqual([0, 2]); + }); + + it('applySelection restores exactly the given indices', () => { + const ctrl = new TInPlaceListBox(makeOptions()); + ctrl.applySelection([1]); + expect(select.options[0].selected).toBe(false); + expect(select.options[1].selected).toBe(true); + expect(select.options[2].selected).toBe(false); + }); + + it('ESC reverts to the original multi-selection', () => { + const ctrl = new TInPlaceListBox(makeOptions()); + ctrl.enterEditMode(null); + select.options[0].selected = false; + select.options[1].selected = true; + ctrl.onKeyPressed({ keyCode: 27 }); + expect(ctrl.captureSelection()).toEqual([0, 2]); + }); + + it('revert is exact when option values are duplicated', () => { + clearRegistry(); clearListBoxes(); container.remove(); + // Two options share value "1"; only the first is selected. + ({ container, select } = buildDOM(['Red', 'Blue', 'Green'], [0])); + select.options[0].value = '1'; + select.options[1].value = '1'; + const ctrl = new TInPlaceListBox(makeOptions()); + ctrl.enterEditMode(null); + select.options[2].selected = true; // change + ctrl.applySelection(ctrl.originalSelection); + expect(select.options[0].selected).toBe(true); + expect(select.options[1].selected).toBe(false); // NOT re-selected despite same value + expect(select.options[2].selected).toBe(false); + }); + + it('handles a prototype-name option value without corruption', () => { + clearRegistry(); clearListBoxes(); container.remove(); + ({ container, select } = buildDOM(['Proto', 'Other'], [0])); + select.options[0].value = '__proto__'; + const ctrl = new TInPlaceListBox(makeOptions()); + ctrl.enterEditMode(null); // originalSelection = [0] + select.options[0].selected = false; + select.options[1].selected = true; + ctrl.applySelection(ctrl.originalSelection); + expect(select.options[0].selected).toBe(true); + expect(select.options[1].selected).toBe(false); + }); +}); + +// ─── change dispatches through the shared save flow ────────────────────────── + +describe('TInPlaceListBox selection change', () => { + let container, label, select; + + beforeEach(() => { + clearRegistry(); + clearListBoxes(); + ({ container, label, select } = buildDOM(['Alpha', 'Beta', 'Gamma'], [0])); + }); + + afterEach(() => { restoreMocks(); container.remove(); }); + + it('does NOT dispatch on each option toggle (multi-select accumulates)', () => { + const { dispatchMock } = mockCallbackRequest(); + const ctrl = new TInPlaceListBox(makeOptions()); + ctrl.enterEditMode(null); + // User ctrl-clicks a second, then a third option: each fires change. + select.options[1].selected = true; + ctrl.onSelectionChanged({}); + select.options[2].selected = true; + ctrl.onSelectionChanged({}); + // The editor stays open; nothing posted yet. + expect(dispatchMock).not.toHaveBeenCalled(); + expect(ctrl.isEditing).toBe(true); + }); + + it('commits the accumulated selection on blur with the original as parameter', () => { + const { dispatchMock, setCallbackParameterMock } = mockCallbackRequest(); + const ctrl = new TInPlaceListBox(makeOptions()); + ctrl.enterEditMode(null); // originalSelection = [0] + select.options[1].selected = true; // build a multi-selection + ctrl.onSelectionChanged({}); + select.options[2].selected = true; + ctrl.onSelectionChanged({}); + ctrl.onDropDownBlur({}); // leaving the editor commits + expect(setCallbackParameterMock).toHaveBeenCalledWith([0]); + expect(dispatchMock).toHaveBeenCalledTimes(1); + }); + + it('does not dispatch on blur when the selection is unchanged', () => { + const { dispatchMock } = mockCallbackRequest(); + const ctrl = new TInPlaceListBox(makeOptions()); + ctrl.enterEditMode(null); + ctrl.onDropDownBlur({}); + expect(dispatchMock).not.toHaveBeenCalled(); + expect(ctrl.isEditing).toBe(false); + }); + + it('does not dispatch a second commit while the first save is in flight', () => { + // dispatch() returns undefined on success (the real value); isSaving must + // still latch so a rapid second blur does not stack a concurrent save. + const { instance, dispatchMock } = mockCallbackRequest(); + instance.dispatch.mockReturnValue(undefined); + const ctrl = new TInPlaceListBox(makeOptions()); + ctrl.enterEditMode(null); + select.options[1].selected = true; + ctrl.onSelectionChanged({}); + ctrl.onDropDownBlur({}); // commits: isSaving latches + expect(ctrl.isSaving).toBe(true); + expect(select.disabled).toBe(false); // not disabled (lazy form serialization) + select.options[2].selected = true; + ctrl.onDropDownBlur({}); // in flight -> must not dispatch again + expect(dispatchMock).toHaveBeenCalledTimes(1); + }); + + it('keeps the editor open and the label intact when the commit dispatch does not start', () => { + // dispatch() returns false (e.g. validation failure): the selection is + // preserved and the editor stays open for retry; the label is untouched. + mockCallbackRequest(false); + const ctrl = new TInPlaceListBox(makeOptions()); + ctrl.enterEditMode(null); + label.innerHTML = 'Alpha'; + select.options[1].selected = true; + ctrl.onSelectionChanged({}); + ctrl.onDropDownBlur({}); + expect(ctrl.isEditing).toBe(true); // still editable, retryable + expect(ctrl.isSaving).toBe(false); + expect(label.innerHTML).toBe('Alpha'); // label not changed to the uncommitted value + }); + + it('a single-selection list box commits on change like the drop down', () => { + clearRegistry(); clearListBoxes(); container.remove(); + ({ container, label, select } = buildDOM(['Alpha', 'Beta'], [0])); + select.multiple = false; + const { dispatchMock } = mockCallbackRequest(); + const ctrl = new TInPlaceListBox(makeOptions()); + ctrl.enterEditMode(null); + select.selectedIndex = 1; + ctrl.onSelectionChanged({}); + expect(dispatchMock).toHaveBeenCalledTimes(1); + }); + + it('shows the joined selection in the label on save success', () => { + const ctrl = new TInPlaceListBox(makeOptions()); + ctrl.enterEditMode(null); + select.options[1].selected = true; // now 0 and 1 + ctrl.isSaving = true; + ctrl.onValueChangedSuccess({}, null); + expect(label.innerHTML).toBe('Alpha, Beta'); + expect(ctrl.isShowingEmptyDisplayText()).toBe(false); + }); + + it('shows EmptyDisplayText and marks the label when the selection is cleared', () => { + const ctrl = new TInPlaceListBox(makeOptions({ EmptyDisplayText: '(none)' })); + ctrl.enterEditMode(null); + select.options[0].selected = false; // nothing selected + ctrl.isSaving = true; + ctrl.onValueChangedSuccess({}, null); + expect(label.innerHTML).toBe('(none)'); + expect(ctrl.isShowingEmptyDisplayText()).toBe(true); + }); +}); + +// ─── loadItems selection reporting ─────────────────────────────────────────── + +describe('TInPlaceListBox getEditSelection (OnLoadingItems parameter)', () => { + let container, select; + + afterEach(() => { restoreMocks(); container?.remove(); }); + + it('reports all selected values for a multi-selection, not just the first', () => { + clearRegistry(); clearListBoxes(); + ({ container, select } = buildDOM(['Alpha', 'Beta', 'Gamma'], [0, 2])); + const ctrl = new TInPlaceListBox(makeOptions()); + expect(ctrl.getEditSelection()).toEqual(['0', '2']); + }); + + it('dispatches the full selection in the load-items callback parameter', () => { + clearRegistry(); clearListBoxes(); + ({ container, select } = buildDOM(['Alpha', 'Beta', 'Gamma'], [0, 2])); + const { setCallbackParameterMock } = mockCallbackRequest(); + const ctrl = new TInPlaceListBox(makeOptions({ LoadItemsOnEdit: true })); + ctrl.enterEditMode(null); // triggers loadItems + expect(setCallbackParameterMock).toHaveBeenCalledWith(['__InlineEditor_loadItems__', ['0', '2']]); + }); +}); + +// ─── label parity: empty-text options and empty separator ──────────────────── + +describe('TInPlaceListBox label parity', () => { + let container; + + afterEach(() => { restoreMocks(); container?.remove(); }); + + it('skips selected options with empty text, matching the server', () => { + clearRegistry(); clearListBoxes(); + ({ container } = buildDOM(['', 'Apple', 'Banana'], [0, 1])); + const ctrl = new TInPlaceListBox(makeOptions()); + // No leading separator from the empty-text option. + expect(ctrl.getSelectedText()).toBe('Apple'); + }); + + it('honors an empty SelectionSeparator', () => { + clearRegistry(); clearListBoxes(); + ({ container } = buildDOM(['A', 'B', 'C'], [0, 1])); + const ctrl = new TInPlaceListBox(makeOptions({ SelectionSeparator: '' })); + expect(ctrl.getSelectedText()).toBe('AB'); + }); + + it('uses a custom separator', () => { + clearRegistry(); clearListBoxes(); + ({ container } = buildDOM(['A', 'B'], [0, 1])); + const ctrl = new TInPlaceListBox(makeOptions({ SelectionSeparator: ' / ' })); + expect(ctrl.getSelectedText()).toBe('A / B'); + }); +}); diff --git a/tests/js/adapters/inlineeditor.js b/tests/js/adapters/inlineeditor.js index aae539790..e9f657be1 100644 --- a/tests/js/adapters/inlineeditor.js +++ b/tests/js/adapters/inlineeditor.js @@ -26,3 +26,4 @@ export const TInPlaceControlBase = global.Prado.WebUI.TInPlaceControlBase; export const EMPTY_ATTRIBUTE = global.Prado.WebUI.TInPlaceControlBase.EMPTY_ATTRIBUTE; export const TInPlaceTextBox = global.Prado.WebUI.TInPlaceTextBox; export const TInPlaceDropDownList = global.Prado.WebUI.TInPlaceDropDownList; +export const TInPlaceListBox = global.Prado.WebUI.TInPlaceListBox; diff --git a/tests/playwright/web/TInPlaceListBoxTestCase.spec.js b/tests/playwright/web/TInPlaceListBoxTestCase.spec.js new file mode 100644 index 000000000..a2161b11e --- /dev/null +++ b/tests/playwright/web/TInPlaceListBoxTestCase.spec.js @@ -0,0 +1,81 @@ +import { test, expect } from '@playwright/test'; +import { PradoTestHelper, GENERIC_BASE_URL } from '../helpers.js'; + +const PAGE_URL = 'web/index.php?page=InPlaceListBoxTest'; +const LABEL = 'ctl0_Content_lb__label'; +const SELECT = 'ctl0_Content_lb'; +const STATUS = 'ctl0_Content_status'; + +/** + * A multiple-selection TInPlaceListBox accumulates the selection while the + * editor is open and commits it on blur. Individual toggles must not post a + * callback or collapse the editor, or a multi-item selection could never be + * built interactively. + */ +test('TInPlaceListBoxTestCase: multi-select accumulates and commits on blur', async ({ page }) => { + const h = new PradoTestHelper(page, GENERIC_BASE_URL); + await h.url(PAGE_URL); + await h.assertSourceContains('In Place List Box Test Case'); + + await expect(page.locator(`#${LABEL}`)).toHaveText('Red'); + await expect(page.locator(`#${SELECT}`)).toBeHidden(); + + // Open the editor + await page.locator(`#${LABEL}`).click(); + await expect(page.locator(`#${SELECT}`)).toBeVisible(); + + // Build a multi-selection; the editor must stay open and nothing is posted. + // Settle any callback first: with per-change posting (the bug) the callback + // would fire and collapse the editor here. + await page.locator(`#${SELECT}`).selectOption(['red', 'green']); + await h.waitForAjaxCalls(); + await expect(page.locator(`#${STATUS}`)).toHaveText('none'); + await expect(page.locator(`#${SELECT}`)).toBeVisible(); + + // Blur (click away from the select) commits the accumulated selection + await page.locator('h1').click(); + await h.waitForAjaxCalls(); + await expect(page.locator(`#${STATUS}`)).toHaveText('changed: red,green'); + await expect(page.locator(`#${LABEL}`)).toHaveText('Red + Green'); + await expect(page.locator(`#${SELECT}`)).toBeHidden(); +}); + +/** + * Blurring without changing the selection just closes the editor. + */ +test('TInPlaceListBoxTestCase: blur without change does not post', async ({ page }) => { + const h = new PradoTestHelper(page, GENERIC_BASE_URL); + await h.url(PAGE_URL); + + await page.locator(`#${LABEL}`).click(); + await expect(page.locator(`#${SELECT}`)).toBeVisible(); + await page.locator('h1').click(); + await expect(page.locator(`#${STATUS}`)).toHaveText('none'); + await expect(page.locator(`#${LABEL}`)).toHaveText('Red'); +}); + +/** + * A server-side setSelectedValues() during a callback updates the joined label. + */ +test('TInPlaceListBoxTestCase: server-side multi-selection updates the label', async ({ page }) => { + const h = new PradoTestHelper(page, GENERIC_BASE_URL); + await h.url(PAGE_URL); + + await page.locator('#ctl0_Content_btnServerSelect').click(); + await h.waitForAjaxCalls(); + await expect(page.locator(`#${STATUS}`)).toHaveText('server selected'); + await expect(page.locator(`#${LABEL}`)).toHaveText('Green + Blue'); +}); + +/** + * Clearing the selection during a callback shows the EmptyDisplayText. + */ +test('TInPlaceListBoxTestCase: clearing the selection shows the empty text', async ({ page }) => { + const h = new PradoTestHelper(page, GENERIC_BASE_URL); + await h.url(PAGE_URL); + + await page.locator('#ctl0_Content_btnClear').click(); + await h.waitForAjaxCalls(); + await expect(page.locator(`#${STATUS}`)).toHaveText('cleared'); + await expect(page.locator(`#${LABEL}`)).toHaveText('(none selected)'); +}); diff --git a/tests/unit/Web/UI/ActiveControls/TInPlaceListBoxTest.php b/tests/unit/Web/UI/ActiveControls/TInPlaceListBoxTest.php new file mode 100644 index 000000000..13f0ae28d --- /dev/null +++ b/tests/unit/Web/UI/ActiveControls/TInPlaceListBoxTest.php @@ -0,0 +1,251 @@ +_obLevel = ob_get_level(); + } + + protected function tearDown(): void + { + while (ob_get_level() > $this->_obLevel) { + ob_end_clean(); + } + } + + private function multiSelectListBox(): TInPlaceListBox + { + $control = new TInPlaceListBox(); + $control->setID('listbox'); + $control->setSelectionMode(TListSelectionMode::Multiple); + $control->getItems()->add('Alpha'); + $control->getItems()->add('Beta'); + $control->getItems()->add('Gamma'); + return $control; + } + + public function testExtendsTActiveListBox() + { + $this->assertInstanceOf(TActiveListBox::class, new TInPlaceListBox()); + } + + public function testImplementsActiveControlInterfaces() + { + $control = new TInPlaceListBox(); + $this->assertInstanceOf(IActiveControl::class, $control); + $this->assertInstanceOf(ICallbackEventHandler::class, $control); + } + + public function testInPlaceFamilyProperties() + { + $control = new TInPlaceListBox(); + $this->assertTrue($control->getAutoHideEditor()); + $this->assertFalse($control->getDisplayEditor()); + $this->assertFalse($control->getReadOnly()); + $this->assertSame('', $control->getEmptyDisplayText()); + } + + public function testSelectionSeparatorDefaultsToCommaSpace() + { + $control = new TInPlaceListBox(); + $this->assertSame(', ', $control->getSelectionSeparator()); + $control->setSelectionSeparator(' | '); + $this->assertSame(' | ', $control->getSelectionSeparator()); + } + + public function testClientClassName() + { + $control = new TInPlaceListBox(); + $this->assertSame( + 'Prado.WebUI.TInPlaceListBox', + PradoUnit::invoke($control, 'getClientClassName') + ); + } + + // --- getSelectedItemText: joined, encoded --- + + public function testSelectedItemTextIsEmptyWithoutSelection() + { + $control = $this->multiSelectListBox(); + $this->assertSame('', PradoUnit::invoke($control, 'getSelectedItemText')); + } + + public function testSelectedItemTextJoinsSelectedTexts() + { + $control = $this->multiSelectListBox(); + $control->getItems()->itemAt(0)->setSelected(true); + $control->getItems()->itemAt(2)->setSelected(true); + $this->assertSame('Alpha, Gamma', PradoUnit::invoke($control, 'getSelectedItemText')); + } + + public function testSelectedItemTextUsesTheSeparator() + { + $control = $this->multiSelectListBox(); + $control->setSelectionSeparator(' | '); + $control->getItems()->itemAt(0)->setSelected(true); + $control->getItems()->itemAt(1)->setSelected(true); + $this->assertSame('Alpha | Beta', PradoUnit::invoke($control, 'getSelectedItemText')); + } + + public function testSelectedItemTextEncodesEachText() + { + $control = new TInPlaceListBox(); + $control->setSelectionMode(TListSelectionMode::Multiple); + $control->getItems()->add('a < b'); + $control->getItems()->add('c > d'); + $control->getItems()->itemAt(0)->setSelected(true); + $control->getItems()->itemAt(1)->setSelected(true); + $this->assertSame('a < b, c > d', PradoUnit::invoke($control, 'getSelectedItemText')); + } + + public function testSelectedItemTextSkipsEmptyTextItems() + { + $control = new TInPlaceListBox(); + $control->setSelectionMode(TListSelectionMode::Multiple); + $control->getItems()->add(''); + $control->getItems()->add('Apple'); + $control->getItems()->itemAt(0)->setSelected(true); + $control->getItems()->itemAt(1)->setSelected(true); + // The empty-text item contributes no text and no stray separator. + $this->assertSame('Apple', PradoUnit::invoke($control, 'getSelectedItemText')); + } + + public function testSelectedItemTextHonorsAnEmptySeparator() + { + $control = new TInPlaceListBox(); + $control->setSelectionMode(TListSelectionMode::Multiple); + $control->setSelectionSeparator(''); + $control->getItems()->add('A'); + $control->getItems()->add('B'); + $control->getItems()->itemAt(0)->setSelected(true); + $control->getItems()->itemAt(1)->setSelected(true); + $this->assertSame('AB', PradoUnit::invoke($control, 'getSelectedItemText')); + } + + public function testSelectedItemTextEncodesAnHtmlSpecialSeparator() + { + $control = new TInPlaceListBox(); + $control->setSelectionMode(TListSelectionMode::Multiple); + $control->setSelectionSeparator('
'); + $control->getItems()->add('A'); + $control->getItems()->add('B'); + $control->getItems()->itemAt(0)->setSelected(true); + $control->getItems()->itemAt(1)->setSelected(true); + // The separator is encoded like the item texts, matching the client; + // it does not render as live markup. + $this->assertSame('A<br>B', PradoUnit::invoke($control, 'getSelectedItemText')); + } + + // --- renderLabel --- + + private function renderLabel(TInPlaceListBox $control): string + { + $textWriter = new TTextWriter(); + PradoUnit::invoke($control, 'renderLabel', new THtmlWriter($textWriter)); + return $textWriter->flush(); + } + + public function testRenderLabelShowsJoinedSelection() + { + $control = $this->multiSelectListBox(); + $control->getItems()->itemAt(0)->setSelected(true); + $control->getItems()->itemAt(1)->setSelected(true); + $label = $this->renderLabel($control); + $this->assertStringContainsString('id="' . $control->getClientID() . '__label"', $label); + $this->assertStringContainsString('>Alpha, Beta<', $label); + $this->assertStringNotContainsString('data-prado-empty', $label); + } + + public function testRenderLabelMarksTheEmptyDisplayText() + { + $control = $this->multiSelectListBox(); + $control->setEmptyDisplayText('(none selected)'); + $label = $this->renderLabel($control); + $this->assertStringContainsString('data-prado-empty="1"', $label); + $this->assertStringContainsString('>(none selected)<', $label); + } + + + // --- getPostBackOptions --- + + public function testPostBackOptions() + { + $control = $this->multiSelectListBox(); + $control->setEmptyDisplayText('(none)'); + $control->setSelectionSeparator(' / '); + $options = PradoUnit::invoke($control, 'getPostBackOptions'); + $this->assertSame($control->getClientID() . '__label', $options['ID']); + $this->assertSame($control->getClientID(), $options['EditorID']); + $this->assertTrue($options['AutoHide']); + $this->assertTrue($options['AutoPostBack']); + $this->assertSame('(none)', $options['EmptyDisplayText']); + $this->assertFalse($options['DisplayEditor']); + $this->assertFalse($options['ReadOnly']); + $this->assertSame(' / ', $options['SelectionSeparator']); + } + + public function testPostBackOptionsWithLoadingItemsHandler() + { + $control = $this->multiSelectListBox(); + $control->attachEventHandler('OnLoadingItems', function ($sender, $param) { + }); + $options = PradoUnit::invoke($control, 'getPostBackOptions'); + $this->assertTrue($options['LoadItemsOnEdit']); + } + + // --- callback events --- + + public function testOnCallbackRaisesOnCallbackEvent() + { + $control = $this->multiSelectListBox(); + $raised = false; + $control->attachEventHandler('OnCallback', function ($sender, $param) use (&$raised) { + $raised = true; + }); + $param = new TCallbackEventParameter($control->getResponse(), 'value'); + $control->onCallback($param); + $this->assertTrue($raised); + } + + public function testOnCallbackRaisesOnLoadingItemsForLoadItemsAction() + { + $control = $this->multiSelectListBox(); + $loadingValue = null; + $control->attachEventHandler('OnLoadingItems', function ($sender, $param) use (&$loadingValue) { + $loadingValue = $param->getCallbackParameter(); + }); + $param = new TCallbackEventParameter( + $control->getResponse(), + ['__InlineEditor_loadItems__', 'current'] + ); + $control->onCallback($param); + $this->assertSame('current', $loadingValue); + } + + public function testOnCallbackWithEmptyArrayParameterDoesNotWarnOrMisfire() + { + // The list box sends an array selection snapshot; an empty selection is + // []. onCallback must not raise OnLoadingItems or warn on a missing key 0. + $control = $this->multiSelectListBox(); + $loadingRaised = false; + $control->attachEventHandler('OnLoadingItems', function ($sender, $param) use (&$loadingRaised) { + $loadingRaised = true; + }); + $param = new TCallbackEventParameter($control->getResponse(), []); + $control->onCallback($param); + $this->assertFalse($loadingRaised); + } +} From 73141ef7054f7da0c389142e70b81f3356fecc19 Mon Sep 17 00:00:00 2001 From: Belisoful Date: Fri, 28 Aug 2026 06:31:09 +0000 Subject: [PATCH 3/3] Adds Accessibility to InPlace Controls and their Traits. completes #702. --- .../UI/ActiveControls/TInPlaceDropDownList.md | 4 + .../Web/UI/ActiveControls/TInPlaceListBox.md | 4 + .../Web/UI/ActiveControls/TInPlaceTextBox.md | 4 + .../prado/activecontrols/inlineeditor.js | 78 +++++++++++++ .../ActiveControls/TInPlaceControlTrait.php | 18 +++ .../TInPlaceListControlTrait.php | 2 + .../Web/UI/ActiveControls/TInPlaceTextBox.php | 2 + tests/js/activecontrols/inlineeditor.test.js | 105 ++++++++++++++++++ .../inplacedropdownlist.test.js | 9 ++ .../js/activecontrols/inplacelistbox.test.js | 10 ++ .../web/TInPlaceDropDownListTestCase.spec.js | 32 ++++++ .../TInPlaceDropDownListTest.php | 27 +++++ .../UI/ActiveControls/TInPlaceListBoxTest.php | 27 +++++ .../UI/ActiveControls/TInPlaceTextBoxTest.php | 37 ++++++ 14 files changed, 359 insertions(+) diff --git a/agents/framework/Web/UI/ActiveControls/TInPlaceDropDownList.md b/agents/framework/Web/UI/ActiveControls/TInPlaceDropDownList.md index c50c7850a..ee9b73f9a 100644 --- a/agents/framework/Web/UI/ActiveControls/TInPlaceDropDownList.md +++ b/agents/framework/Web/UI/ActiveControls/TInPlaceDropDownList.md @@ -22,6 +22,10 @@ Click-to-edit drop down list that displays as a label showing the selected item - `setSelectedValue($value)` / `setSelectedIndex($index)` - Also update the client-side label during callbacks - `getClientClassName()` - Returns `Prado.WebUI.TInPlaceDropDownList` +## Accessibility + +The label is rendered as an operable button (`role="button"`, `tabindex="0"`) with `aria-live="polite"`; Enter/Space enter edit mode, and focus returns to the label after an Enter/Escape/change commit. A read-only control renders the label as plain text (no button role). The editor takes its accessible name from `ToolTip` (sent as the `EditorLabel` option). Shared via `TInPlaceControlTrait::renderLabelAccessibilityAttributes` (PHP) and the base class in `inlineeditor.js` (JS). + ## See Also - [TInPlaceTextBox](./TInPlaceTextBox.md), [TInPlaceListBox](./TInPlaceListBox.md), [TActiveDropDownList](./TActiveDropDownList.md) diff --git a/agents/framework/Web/UI/ActiveControls/TInPlaceListBox.md b/agents/framework/Web/UI/ActiveControls/TInPlaceListBox.md index acd08e116..0b2beb94e 100644 --- a/agents/framework/Web/UI/ActiveControls/TInPlaceListBox.md +++ b/agents/framework/Web/UI/ActiveControls/TInPlaceListBox.md @@ -22,6 +22,10 @@ Click-to-edit list box that displays as a label showing the selected item texts. - `onLoadingItems($param)` - Event raised to load the item list from the server on edit - `getClientClassName()` - Returns `Prado.WebUI.TInPlaceListBox` +## Accessibility + +The label is rendered as an operable button (`role="button"`, `tabindex="0"`) with `aria-live="polite"`; Enter/Space enter edit mode, and focus returns to the label after an Enter/Escape/change commit. A read-only control renders the label as plain text (no button role). The editor takes its accessible name from `ToolTip` (sent as the `EditorLabel` option). Shared via `TInPlaceControlTrait::renderLabelAccessibilityAttributes` (PHP) and the base class in `inlineeditor.js` (JS). + ## See Also - [TInPlaceDropDownList](./TInPlaceDropDownList.md), [TActiveListBox](./TActiveListBox.md) diff --git a/agents/framework/Web/UI/ActiveControls/TInPlaceTextBox.md b/agents/framework/Web/UI/ActiveControls/TInPlaceTextBox.md index 94b5a95ec..d95c4390a 100644 --- a/agents/framework/Web/UI/ActiveControls/TInPlaceTextBox.md +++ b/agents/framework/Web/UI/ActiveControls/TInPlaceTextBox.md @@ -20,6 +20,10 @@ Click-to-edit text box that displays as a label until clicked. When clicked, tra - `onLoadingText($param)` - Event raised to load text from server before editing - `getClientClassName()` - Returns `Prado.WebUI.TInPlaceTextBox` +## Accessibility + +The label is rendered as an operable button (`role="button"`, `tabindex="0"`) with `aria-live="polite"`; Enter/Space enter edit mode, and focus returns to the label after an Enter/Escape/change commit. A read-only control renders the label as plain text (no button role). The editor takes its accessible name from `ToolTip` (sent as the `EditorLabel` option). Shared via `TInPlaceControlTrait::renderLabelAccessibilityAttributes` (PHP) and the base class in `inlineeditor.js` (JS). + ## See Also - [TActiveTextBox](./TActiveTextBox.md), [TCallback](./TCallback.md), [TInPlaceDropDownList](./TInPlaceDropDownList.md) diff --git a/framework/Web/Javascripts/source/prado/activecontrols/inlineeditor.js b/framework/Web/Javascripts/source/prado/activecontrols/inlineeditor.js index b91328020..a68877558 100644 --- a/framework/Web/Javascripts/source/prado/activecontrols/inlineeditor.js +++ b/framework/Web/Javascripts/source/prado/activecontrols/inlineeditor.js @@ -20,11 +20,62 @@ Prado.WebUI.TInPlaceControlBase = Prado.Class(Prado.WebUI.Control, this.element = document.getElementById(this.options.ID); Prado.WebUI.TInPlaceControlBase.register(this); this.createEditorInput(); + this.nameEditor(); this.initializeListeners(); if(this.options.DisplayEditor) this.enterEditMode(null, true); }, + /** + * Gives the edit element an accessible name from options.EditorLabel so + * assistive technology announces it, since the created input and the + * server-rendered select otherwise have no associated label. + */ + nameEditor() { + if(this.editField && this.options.EditorLabel) + this.editField.setAttribute('aria-label', this.options.EditorLabel); + }, + + /** + * Returns keyboard focus to the label when it is shown, so exiting the + * editor by Enter or Escape does not drop focus to the document body. + */ + focusLabel() { + if(this.element && this.element.style.display !== 'none' + && typeof this.element.focus === 'function') + this.element.focus(); + }, + + /** + * Focuses the label after a collapse that was requested from the keyboard. + */ + maybeReturnFocus() { + if(this.returnFocusOnCollapse) + { + this.returnFocusOnCollapse = false; + this.focusLabel(); + } + }, + + /** + * Adds or removes the button role and tab stop when the read only state + * changes on a callback, keeping the label's keyboard operability in sync. + */ + updateLabelEditable() { + if(!this.element) + return; + if(this.readOnly) + { + this.element.removeAttribute('role'); + this.element.removeAttribute('tabindex'); + } + else + { + this.element.setAttribute('role', 'button'); + this.element.setAttribute('tabindex', '0'); + } + }, + /** * @return {String} registry key of this control, the editor client ID. */ @@ -45,10 +96,21 @@ Prado.WebUI.TInPlaceControlBase = Prado.Class(Prado.WebUI.Control, initializeListeners() { this.onclickListener = this.enterEditMode.bind(this); this.observe(this.element, 'click', this.onclickListener); + this.onLabelKeyDownListener = this.onLabelKeyDown.bind(this); + this.observe(this.element, 'keydown', this.onLabelKeyDownListener); if (this.options.ExternalControl) this.observe(document.getElementById(this.options.ExternalControl), 'click', this.onclickListener); }, + /** + * Enter or Space on the label enters edit mode, matching a button. + * @param {Event} evt keydown event + */ + onLabelKeyDown(evt) { + if(evt.keyCode == 13 || evt.keyCode == 32) // Enter or Space + this.enterEditMode(evt); + }, + /** * Changes the label to the edit element. * @param {Event} evt event source @@ -234,6 +296,7 @@ Prado.WebUI.TInPlaceControlBase = Prado.Class(Prado.WebUI.Control, this.setLabelValue(parameter); this.editField.disabled = false; this.onAfterSave(); + this.maybeReturnFocus(); if(typeof(this.options.onSuccess)=="function") this.options.onSuccess(sender,parameter); }, @@ -297,7 +360,10 @@ Object.assign(Prado.WebUI.TInPlaceControlBase, setReadOnly(id, value) { const control = Prado.WebUI.TInPlaceControlBase.get(id); if(control) + { control.readOnly = value; + control.updateLabelEditable(); + } }, setLabelText(id, value) { @@ -427,6 +493,7 @@ Prado.WebUI.TInPlaceTextBox = Prado.Class(Prado.WebUI.TInPlaceControlBase, const target = e.target; if(target) { + this.returnFocusOnCollapse = true; target.blur(); e.preventDefault(); } @@ -451,6 +518,7 @@ Prado.WebUI.TInPlaceTextBox = Prado.Class(Prado.WebUI.TInPlaceControlBase, this.isEditing = false; if(this.options.AutoHide) this.showLabel(); + this.maybeReturnFocus(); } }, @@ -461,6 +529,7 @@ Prado.WebUI.TInPlaceTextBox = Prado.Class(Prado.WebUI.TInPlaceControlBase, this.isEditing = false; if(this.options.AutoHide) this.showLabel(); + this.focusLabel(); } else if (e.keyCode == 13 // KEY_RETURN && this.options.TextMode != 'MultiLine') @@ -646,7 +715,13 @@ Prado.WebUI.TInPlaceDropDownList = Prado.Class(Prado.WebUI.TInPlaceControlBase, */ onSelectionChanged(_event) { if(this.options.AutoPostBack && this.isEditing && !this.isSaving) + { + // A change commit collapses the select, which held focus; return + // focus to the label. The multi-select list box commits on blur + // instead (the user has moved focus away) and does not set this. + this.returnFocusOnCollapse = true; this.onValueChanged(); + } }, /** @@ -660,6 +735,7 @@ Prado.WebUI.TInPlaceDropDownList = Prado.Class(Prado.WebUI.TInPlaceControlBase, this.isEditing = false; if(this.options.AutoHide) this.showLabel(); + this.maybeReturnFocus(); }, onKeyPressed(e) { @@ -670,10 +746,12 @@ Prado.WebUI.TInPlaceDropDownList = Prado.Class(Prado.WebUI.TInPlaceControlBase, this.isEditing = false; if(this.options.AutoHide) this.showLabel(); + this.focusLabel(); } else if (e.keyCode == 13) //KEY_RETURN { e.preventDefault(); + this.returnFocusOnCollapse = true; this.editField.blur(); } }, diff --git a/framework/Web/UI/ActiveControls/TInPlaceControlTrait.php b/framework/Web/UI/ActiveControls/TInPlaceControlTrait.php index e96779f9d..e67de91bd 100644 --- a/framework/Web/UI/ActiveControls/TInPlaceControlTrait.php +++ b/framework/Web/UI/ActiveControls/TInPlaceControlTrait.php @@ -136,6 +136,24 @@ protected function renderEmptyDisplayAttribute($writer, $isEmpty) } } + /** + * Renders the accessibility attributes that make the label operable as an + * edit trigger. A polite live region announces value changes; when editing + * is allowed the label also carries a button role and a tab stop so it can + * be reached and activated from the keyboard. A read only label stays plain + * text. + * @param \Prado\Web\UI\THtmlWriter $writer the writer for rendering + * @param bool $readOnly whether the control is read only + */ + protected function renderLabelAccessibilityAttributes($writer, $readOnly) + { + $writer->addAttribute('aria-live', 'polite'); + if (!$readOnly) { + $writer->addAttribute('role', 'button'); + $writer->addAttribute('tabindex', '0'); + } + } + /** * @return string label client ID */ diff --git a/framework/Web/UI/ActiveControls/TInPlaceListControlTrait.php b/framework/Web/UI/ActiveControls/TInPlaceListControlTrait.php index a583c0cbc..033864259 100644 --- a/framework/Web/UI/ActiveControls/TInPlaceListControlTrait.php +++ b/framework/Web/UI/ActiveControls/TInPlaceListControlTrait.php @@ -118,6 +118,7 @@ protected function renderLabel($writer) $writer->addAttribute('title', $toolTip); } $this->renderEmptyDisplayAttribute($writer, $this->getSelectedItemText() === ''); + $this->renderLabelAccessibilityAttributes($writer, $this->getReadOnly()); if ($this->getDisplayEditor()) { $writer->addStyleAttribute('display', 'none'); } @@ -161,6 +162,7 @@ protected function getPostBackOptions() $options['AutoPostBack'] = $this->getAutoPostBack(); $options['EmptyDisplayText'] = $this->getEmptyDisplayText(); $options['DisplayEditor'] = $this->getDisplayEditor(); + $options['EditorLabel'] = $this->getToolTip(); if ($this->hasEventHandler('OnLoadingItems')) { $options['LoadItemsOnEdit'] = true; diff --git a/framework/Web/UI/ActiveControls/TInPlaceTextBox.php b/framework/Web/UI/ActiveControls/TInPlaceTextBox.php index 525afe25f..b86c977ae 100644 --- a/framework/Web/UI/ActiveControls/TInPlaceTextBox.php +++ b/framework/Web/UI/ActiveControls/TInPlaceTextBox.php @@ -196,6 +196,7 @@ protected function getPostBackOptions() $options['AutoPostBack'] = $this->getAutoPostBack() == false ? '' : true; $options['EmptyDisplayText'] = $this->getEmptyDisplayText(); $options['DisplayEditor'] = $this->getDisplayEditor(); + $options['EditorLabel'] = $this->getToolTip(); $options['Columns'] = $this->getColumns(); if ($this->getTextMode() === 'MultiLine') { $options['Rows'] = $this->getRows(); @@ -242,6 +243,7 @@ protected function addAttributesToRender($writer) TWebControl::addAttributesToRender($writer); $writer->addAttribute('id', $this->getLabelClientID()); $this->renderEmptyDisplayAttribute($writer, $this->getText() === ''); + $this->renderLabelAccessibilityAttributes($writer, $this->getReadOnly()); $this->getActiveControl()->registerCallbackClientScript( $this->getClientClassName(), $this->getPostBackOptions() diff --git a/tests/js/activecontrols/inlineeditor.test.js b/tests/js/activecontrols/inlineeditor.test.js index d7170d79c..1d7eee4ca 100644 --- a/tests/js/activecontrols/inlineeditor.test.js +++ b/tests/js/activecontrols/inlineeditor.test.js @@ -771,6 +771,111 @@ describe('TInPlaceTextBox EmptyDisplayText', () => { }); }); +// ─── Accessibility ──────────────────────────────────────────────────────────── + +describe('TInPlaceTextBox accessibility', () => { + let container, label; + + beforeEach(() => { + clearRegistry(); clearTextboxes(); + ({ container, label } = buildDOM()); + }); + + afterEach(() => { restoreMocks(); container.remove(); }); + + it('Enter on the label enters edit mode', () => { + const ctrl = new TInPlaceTextBox(makeOptions()); + const enter = vi.spyOn(ctrl, 'enterEditMode'); + ctrl.onLabelKeyDown({ keyCode: 13, preventDefault() {} }); + expect(enter).toHaveBeenCalled(); + }); + + it('Space on the label enters edit mode', () => { + const ctrl = new TInPlaceTextBox(makeOptions()); + const enter = vi.spyOn(ctrl, 'enterEditMode'); + ctrl.onLabelKeyDown({ keyCode: 32, preventDefault() {} }); + expect(enter).toHaveBeenCalled(); + }); + + it('other keys on the label do not enter edit mode', () => { + const ctrl = new TInPlaceTextBox(makeOptions()); + const enter = vi.spyOn(ctrl, 'enterEditMode'); + ctrl.onLabelKeyDown({ keyCode: 65, preventDefault() {} }); // 'a' + expect(enter).not.toHaveBeenCalled(); + }); + + it('names the editor from options.EditorLabel', () => { + const ctrl = new TInPlaceTextBox(makeOptions({ EditorLabel: 'Favorite color' })); + expect(ctrl.editField.getAttribute('aria-label')).toBe('Favorite color'); + }); + + it('does not set an aria-label when EditorLabel is absent', () => { + const ctrl = new TInPlaceTextBox(makeOptions()); + expect(ctrl.editField.getAttribute('aria-label')).toBeNull(); + }); + + it('focusLabel focuses the label when it is shown', () => { + const ctrl = new TInPlaceTextBox(makeOptions()); + const focus = vi.spyOn(label, 'focus'); + ctrl.focusLabel(); + expect(focus).toHaveBeenCalled(); + }); + + it('focusLabel is a no-op while the label is hidden (mid-edit)', () => { + const ctrl = new TInPlaceTextBox(makeOptions()); + label.style.display = 'none'; + const focus = vi.spyOn(label, 'focus'); + ctrl.focusLabel(); + expect(focus).not.toHaveBeenCalled(); + }); + + it('maybeReturnFocus focuses the label only after a keyboard-initiated collapse', () => { + const ctrl = new TInPlaceTextBox(makeOptions()); + const focus = vi.spyOn(ctrl, 'focusLabel'); + ctrl.maybeReturnFocus(); // no flag -> nothing + expect(focus).not.toHaveBeenCalled(); + ctrl.returnFocusOnCollapse = true; + ctrl.maybeReturnFocus(); // flag -> focus, and clears + expect(focus).toHaveBeenCalledTimes(1); + expect(ctrl.returnFocusOnCollapse).toBe(false); + }); + + it('Escape returns focus to the label', () => { + const ctrl = new TInPlaceTextBox(makeOptions({ AutoHide: true })); + ctrl.enterEditMode(null); + const focus = vi.spyOn(ctrl, 'focusLabel'); + ctrl.onKeyPressed({ keyCode: 27 }); + expect(focus).toHaveBeenCalled(); + }); + + it('updateLabelEditable removes the button role when read only', () => { + const ctrl = new TInPlaceTextBox(makeOptions()); + label.setAttribute('role', 'button'); + label.setAttribute('tabindex', '0'); + ctrl.readOnly = true; + ctrl.updateLabelEditable(); + expect(label.hasAttribute('role')).toBe(false); + expect(label.hasAttribute('tabindex')).toBe(false); + }); + + it('updateLabelEditable restores the button role when editable again', () => { + const ctrl = new TInPlaceTextBox(makeOptions()); + ctrl.readOnly = false; + ctrl.updateLabelEditable(); + expect(label.getAttribute('role')).toBe('button'); + expect(label.getAttribute('tabindex')).toBe('0'); + }); + + it('static setReadOnly keeps the label operability in sync', () => { + const ctrl = new TInPlaceTextBox(makeOptions()); + ctrl.readOnly = false; + ctrl.updateLabelEditable(); + TInPlaceTextBox.setReadOnly('tb_lbl1', true); + expect(ctrl.readOnly).toBe(true); + expect(label.hasAttribute('role')).toBe(false); + }); +}); + // ─── ExternalControl option ─────────────────────────────────────────────────── describe('TInPlaceTextBox ExternalControl', () => { diff --git a/tests/js/activecontrols/inplacedropdownlist.test.js b/tests/js/activecontrols/inplacedropdownlist.test.js index 9225ff9e9..6ba928d7b 100644 --- a/tests/js/activecontrols/inplacedropdownlist.test.js +++ b/tests/js/activecontrols/inplacedropdownlist.test.js @@ -281,6 +281,15 @@ describe('TInPlaceDropDownList selection change', () => { expect(select.disabled).toBe(false); }); + it('flags focus-return to the label on a change commit', () => { + mockCallbackRequest(); + const ctrl = new TInPlaceDropDownList(makeOptions()); + ctrl.enterEditMode(null); + select.selectedIndex = 2; + ctrl.onSelectionChanged({}); + expect(ctrl.returnFocusOnCollapse).toBe(true); + }); + it('does not dispatch when AutoPostBack is false', () => { const { dispatchMock } = mockCallbackRequest(); const ctrl = new TInPlaceDropDownList(makeOptions({ AutoPostBack: false })); diff --git a/tests/js/activecontrols/inplacelistbox.test.js b/tests/js/activecontrols/inplacelistbox.test.js index bd1cfe545..3ee866b90 100644 --- a/tests/js/activecontrols/inplacelistbox.test.js +++ b/tests/js/activecontrols/inplacelistbox.test.js @@ -219,6 +219,16 @@ describe('TInPlaceListBox selection change', () => { afterEach(() => { restoreMocks(); container.remove(); }); + it('does not flag focus-return on a blur commit (the user moved focus away)', () => { + mockCallbackRequest(); + const ctrl = new TInPlaceListBox(makeOptions()); + ctrl.enterEditMode(null); + select.options[1].selected = true; + ctrl.onSelectionChanged({}); // multi: accumulates, no flag + ctrl.onDropDownBlur({}); // commits on blur + expect(ctrl.returnFocusOnCollapse).toBeFalsy(); + }); + it('does NOT dispatch on each option toggle (multi-select accumulates)', () => { const { dispatchMock } = mockCallbackRequest(); const ctrl = new TInPlaceListBox(makeOptions()); diff --git a/tests/playwright/web/TInPlaceDropDownListTestCase.spec.js b/tests/playwright/web/TInPlaceDropDownListTestCase.spec.js index b96229dc9..fe9635d91 100644 --- a/tests/playwright/web/TInPlaceDropDownListTestCase.spec.js +++ b/tests/playwright/web/TInPlaceDropDownListTestCase.spec.js @@ -125,3 +125,35 @@ test('TInPlaceDropDownListTestCase: OnLoadingItems replaces items on edit', asyn await expect(page.locator(`#${LAZY_LABEL}`)).toHaveText('Fresh B'); await expect(page.locator(`#${LAZY_SELECT}`)).toBeHidden(); }); + + +/** + * Accessibility: the label is an operable button reachable and activatable + * from the keyboard, and focus returns to it after a commit collapses the + * editor. + */ +test('TInPlaceDropDownListTestCase: keyboard entry, commit, and focus return', async ({ page }) => { + const h = new PradoTestHelper(page, GENERIC_BASE_URL); + await h.url(PAGE_URL); + + const label = page.locator(`#${LABEL}`); + await expect(label).toHaveAttribute('role', 'button'); + await expect(label).toHaveAttribute('tabindex', '0'); + await expect(label).toHaveAttribute('aria-live', 'polite'); + + // Reach the label with the keyboard and open the editor with Enter + await label.focus(); + expect(await page.evaluate((id) => document.activeElement === document.getElementById(id), LABEL)).toBe(true); + await page.keyboard.press('Enter'); + await expect(page.locator(`#${SELECT}`)).toBeVisible(); + // Focus moved into the editor + expect(await page.evaluate((id) => document.activeElement === document.getElementById(id), SELECT)).toBe(true); + + // Commit a change; the editor collapses and focus returns to the label + await page.locator(`#${SELECT}`).selectOption('green'); + await h.waitForAjaxCalls(); + await expect(page.locator(`#${STATUS}`)).toHaveText('changed: green'); + await expect(label).toHaveText('Green'); + await expect(page.locator(`#${SELECT}`)).toBeHidden(); + expect(await page.evaluate((id) => document.activeElement === document.getElementById(id), LABEL)).toBe(true); +}); diff --git a/tests/unit/Web/UI/ActiveControls/TInPlaceDropDownListTest.php b/tests/unit/Web/UI/ActiveControls/TInPlaceDropDownListTest.php index 577f6b3f7..de286b589 100644 --- a/tests/unit/Web/UI/ActiveControls/TInPlaceDropDownListTest.php +++ b/tests/unit/Web/UI/ActiveControls/TInPlaceDropDownListTest.php @@ -213,6 +213,33 @@ public function testRenderLabelCarriesTheControlStyleAndToolTip() $this->assertStringContainsString('title="click to edit"', $label); } + public function testRenderLabelIsAnOperableButtonWhenEditable() + { + $control = new TInPlaceDropDownList(); + $label = $this->renderLabel($control); + $this->assertStringContainsString('role="button"', $label); + $this->assertStringContainsString('tabindex="0"', $label); + $this->assertStringContainsString('aria-live="polite"', $label); + } + + public function testRenderLabelIsPlainTextWhenReadOnly() + { + $control = new TInPlaceDropDownList(); + $control->setReadOnly(true); + $label = $this->renderLabel($control); + $this->assertStringNotContainsString('role="button"', $label); + $this->assertStringNotContainsString('tabindex', $label); + $this->assertStringContainsString('aria-live="polite"', $label); + } + + public function testPostBackOptionsCarryEditorLabelFromToolTip() + { + $control = new TInPlaceDropDownList(); + $control->setToolTip('Pick a color'); + $options = PradoUnit::invoke($control, 'getPostBackOptions'); + $this->assertSame('Pick a color', $options['EditorLabel']); + } + // --- getPostBackOptions --- public function testPostBackOptions() diff --git a/tests/unit/Web/UI/ActiveControls/TInPlaceListBoxTest.php b/tests/unit/Web/UI/ActiveControls/TInPlaceListBoxTest.php index 13f0ae28d..980bc34e3 100644 --- a/tests/unit/Web/UI/ActiveControls/TInPlaceListBoxTest.php +++ b/tests/unit/Web/UI/ActiveControls/TInPlaceListBoxTest.php @@ -179,6 +179,33 @@ public function testRenderLabelMarksTheEmptyDisplayText() } + public function testRenderLabelIsAnOperableButtonWhenEditable() + { + $control = $this->multiSelectListBox(); + $label = $this->renderLabel($control); + $this->assertStringContainsString('role="button"', $label); + $this->assertStringContainsString('tabindex="0"', $label); + $this->assertStringContainsString('aria-live="polite"', $label); + } + + public function testRenderLabelIsPlainTextWhenReadOnly() + { + $control = $this->multiSelectListBox(); + $control->setReadOnly(true); + $label = $this->renderLabel($control); + $this->assertStringNotContainsString('role="button"', $label); + $this->assertStringNotContainsString('tabindex', $label); + $this->assertStringContainsString('aria-live="polite"', $label); + } + + public function testPostBackOptionsCarryEditorLabelFromToolTip() + { + $control = $this->multiSelectListBox(); + $control->setToolTip('Pick a color'); + $options = PradoUnit::invoke($control, 'getPostBackOptions'); + $this->assertSame('Pick a color', $options['EditorLabel']); + } + // --- getPostBackOptions --- public function testPostBackOptions() diff --git a/tests/unit/Web/UI/ActiveControls/TInPlaceTextBoxTest.php b/tests/unit/Web/UI/ActiveControls/TInPlaceTextBoxTest.php index 6b0a169f7..1f4ad7f0c 100644 --- a/tests/unit/Web/UI/ActiveControls/TInPlaceTextBoxTest.php +++ b/tests/unit/Web/UI/ActiveControls/TInPlaceTextBoxTest.php @@ -99,6 +99,43 @@ public function testPostBackOptionsCarryEditorID() $this->assertSame($control->getClientID(), $options['TextBoxID']); } + private function accessibilityAttributes(TInPlaceTextBox $control, bool $readOnly): string + { + $textWriter = new TTextWriter(); + $writer = new THtmlWriter($textWriter); + PradoUnit::invoke($control, 'renderLabelAccessibilityAttributes', $writer, $readOnly); + $writer->renderBeginTag('span'); + $writer->renderEndTag(); + return $textWriter->flush(); + } + + public function testLabelIsAnOperableButtonWhenEditable() + { + $control = new TInPlaceTextBox(); + $html = $this->accessibilityAttributes($control, false); + $this->assertStringContainsString('role="button"', $html); + $this->assertStringContainsString('tabindex="0"', $html); + $this->assertStringContainsString('aria-live="polite"', $html); + } + + public function testReadOnlyLabelIsPlainTextWithoutButtonRole() + { + $control = new TInPlaceTextBox(); + $html = $this->accessibilityAttributes($control, true); + $this->assertStringNotContainsString('role="button"', $html); + $this->assertStringNotContainsString('tabindex', $html); + // A read only value still announces programmatic changes. + $this->assertStringContainsString('aria-live="polite"', $html); + } + + public function testPostBackOptionsCarryEditorLabelFromToolTip() + { + $control = new TInPlaceTextBox(); + $control->setToolTip('Favorite color'); + $options = PradoUnit::invoke($control, 'getPostBackOptions'); + $this->assertSame('Favorite color', $options['EditorLabel']); + } + public function testEditTriggerControlID() { $control = new TInPlaceTextBox();