Fixes the Checkbox, Dropdown and Select list when the models change the UI updates. (#19487)
* Fix CheckboxList UI not updating when values are set programmatically * WIP * Added unit tests for the new functionality in the checkbox list element. As requested by Copilot, here are some unit tests to ensure this addition passes all of the possible edge cases mentioned. * Small change based on CoPilot feedback Removed a check that was redundant and removed a unit test that was also not needed for the current PR and fixed one of the other tests. * Fixing code quality issues highlighted in the unit tests * Fix CheckboxList UI not updating when values are set programmatically * WIP * Standardizes property editor UI state management Introduces a utility for managing the state of property editor UI elements when their values are set programmatically. This ensures that UI components like dropdowns, checkbox lists, and selects correctly reflect the selected values, especially when these values are updated via code rather than direct user interaction. The changes include: - A mixin to simplify state updates - A helper function to ensure values are handled as arrays - Consistent state updating logic across components. * Update src/Umbraco.Web.UI.Client/src/packages/property-editors/select/property-editor-ui-select.element.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Removed the hard coded label * Fixed the short-circuit issue raised by co-pilot * Fixing more co-pilot suggestions Also cleaned up the test files based on the JSDocs suggestions. * Update src/Umbraco.Web.UI.Client/src/packages/property-editors/dropdown/property-editor-ui-dropdown.element.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Refactors checkbox and dropdown tests Refactors checkbox-list and dropdown property editor UI tests to share common test utilities, reducing code duplication and improving maintainability. Uses Sets for faster selection lookup in `updateItemsState` function. * Fixing CodeScene suggestion based on "String Heavy Function Arguments" * Fix for an issue that was stopping the Bellissima build. * Improves property editor UI state updates Ensures UI updates in checkbox list, dropdown and select property editors only occur when necessary. Avoids unnecessary re-renders by comparing the updated state with the current state, and only triggering an update if there are actual changes. This improves performance and prevents potential issues caused by excessive re-rendering. * Changes based on feedback from @nielslyngsoe * removing unnecessary call to requestUpdate --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Niels Lyngsø <nsl@umbraco.dk> Co-authored-by: Niels Lyngsø <niels.lyngso@gmail.com>
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
import { ensureArray, updateItemsSelectedState } from '../utils/property-editor-ui-state-manager.js';
|
||||
import './components/input-checkbox-list/input-checkbox-list.element.js';
|
||||
import type {
|
||||
UmbCheckboxListItem,
|
||||
UmbInputCheckboxListElement,
|
||||
@@ -11,8 +13,6 @@ import type {
|
||||
UmbPropertyEditorUiElement,
|
||||
} from '@umbraco-cms/backoffice/property-editor';
|
||||
|
||||
import './components/input-checkbox-list/input-checkbox-list.element.js';
|
||||
|
||||
/**
|
||||
* @element umb-property-editor-ui-checkbox-list
|
||||
*/
|
||||
@@ -28,8 +28,11 @@ export class UmbPropertyEditorUICheckboxListElement
|
||||
|
||||
@property({ type: Array })
|
||||
public override set value(value: Array<string> | string | undefined) {
|
||||
this.#selection = Array.isArray(value) ? value : value ? [value] : [];
|
||||
this.#selection = ensureArray(value);
|
||||
// Update the checked state of existing list items when value changes
|
||||
this.#updateCheckedState();
|
||||
}
|
||||
|
||||
public override get value(): Array<string> | undefined {
|
||||
return this.#selection;
|
||||
}
|
||||
@@ -89,6 +92,21 @@ export class UmbPropertyEditorUICheckboxListElement
|
||||
this.dispatchEvent(new UmbChangeEvent());
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the checked state of all list items based on current selection.
|
||||
* This fixes the issue where UI doesn't update when values are set programmatically.
|
||||
*/
|
||||
#updateCheckedState() {
|
||||
// Only update if we have list items loaded
|
||||
if (this._list.length > 0) {
|
||||
// Update state only if changes are needed
|
||||
const updatedList = updateItemsSelectedState(this._list, this.#selection, 'checked');
|
||||
if (updatedList !== this._list) {
|
||||
this._list = updatedList;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<umb-input-checkbox-list
|
||||
|
||||
@@ -1,14 +1,46 @@
|
||||
import { UmbPropertyEditorUICheckboxListElement } from './property-editor-ui-checkbox-list.element.js';
|
||||
import { expect, fixture, html } from '@open-wc/testing';
|
||||
import { type UmbTestRunnerWindow, defaultA11yConfig } from '@umbraco-cms/internal/test-utils';
|
||||
import {
|
||||
setupBasicStringConfig,
|
||||
setupObjectConfig,
|
||||
setupEmptyConfig,
|
||||
getCheckboxListElement,
|
||||
getCheckboxSelection,
|
||||
verifyMultiSelectValueAndDOM,
|
||||
MULTI_SELECT_TEST_DATA
|
||||
} from '../utils/property-editor-test-utils.js';
|
||||
|
||||
describe('UmbPropertyEditorUICheckboxListElement', () => {
|
||||
let element: UmbPropertyEditorUICheckboxListElement;
|
||||
|
||||
beforeEach(async () => {
|
||||
element = await fixture(html` <umb-property-editor-ui-checkbox-list></umb-property-editor-ui-checkbox-list> `);
|
||||
element = await fixture(html`<umb-property-editor-ui-checkbox-list></umb-property-editor-ui-checkbox-list>`);
|
||||
});
|
||||
|
||||
// Local helper function to get checked values from DOM (specific to this component)
|
||||
function getLocalCheckedValues() {
|
||||
const checkboxListInput = getCheckboxListElement(element);
|
||||
const checkboxElements = checkboxListInput?.shadowRoot?.querySelectorAll('uui-checkbox') || [];
|
||||
const checkedValues: string[] = [];
|
||||
|
||||
checkboxElements.forEach((checkbox: Element) => {
|
||||
const uuiCheckbox = checkbox as any;
|
||||
if (uuiCheckbox.checked) {
|
||||
checkedValues.push(uuiCheckbox.value);
|
||||
}
|
||||
});
|
||||
|
||||
return checkedValues;
|
||||
}
|
||||
|
||||
// Local helper function to verify both selection and DOM state
|
||||
function verifyLocalSelectionAndDOM(expectedSelection: string[], expectedChecked: string[]) {
|
||||
const checkboxListInput = getCheckboxListElement(element) as { selection?: string[] } | null;
|
||||
expect(checkboxListInput?.selection).to.deep.equal(expectedSelection);
|
||||
expect(getLocalCheckedValues().sort()).to.deep.equal(expectedChecked.sort());
|
||||
}
|
||||
|
||||
it('is defined with its own instance', () => {
|
||||
expect(element).to.be.instanceOf(UmbPropertyEditorUICheckboxListElement);
|
||||
});
|
||||
@@ -18,4 +50,106 @@ describe('UmbPropertyEditorUICheckboxListElement', () => {
|
||||
await expect(element).shadowDom.to.be.accessible(defaultA11yConfig);
|
||||
});
|
||||
}
|
||||
|
||||
describe('programmatic value setting', () => {
|
||||
beforeEach(async () => {
|
||||
setupBasicStringConfig(element);
|
||||
await element.updateComplete;
|
||||
});
|
||||
|
||||
it('should update UI immediately when value is set programmatically with array', async () => {
|
||||
element.value = ['Red', 'Blue'];
|
||||
await element.updateComplete;
|
||||
|
||||
expect(getCheckboxListElement(element)).to.exist;
|
||||
verifyLocalSelectionAndDOM(['Red', 'Blue'], ['Red', 'Blue']);
|
||||
});
|
||||
|
||||
it('should update UI immediately when value is set to empty array', async () => {
|
||||
// First set some values
|
||||
element.value = ['Red', 'Green'];
|
||||
await element.updateComplete;
|
||||
|
||||
// Then clear them
|
||||
element.value = [];
|
||||
await element.updateComplete;
|
||||
|
||||
verifyLocalSelectionAndDOM([], []);
|
||||
});
|
||||
|
||||
it('should update UI immediately when value is set to single string', async () => {
|
||||
element.value = 'Green';
|
||||
await element.updateComplete;
|
||||
|
||||
verifyLocalSelectionAndDOM(['Green'], ['Green']);
|
||||
});
|
||||
|
||||
it('should handle undefined value gracefully', async () => {
|
||||
element.value = undefined;
|
||||
await element.updateComplete;
|
||||
|
||||
verifyLocalSelectionAndDOM([], []);
|
||||
});
|
||||
|
||||
it('should handle invalid values gracefully', async () => {
|
||||
// Set value with invalid option that doesn't exist in the configured list ['Red', 'Green', 'Blue']
|
||||
element.value = ['Red', 'InvalidColor', 'Blue'];
|
||||
await element.updateComplete;
|
||||
|
||||
// Should preserve all values in selection but only check valid ones in DOM
|
||||
verifyLocalSelectionAndDOM(['Red', 'InvalidColor', 'Blue'], ['Red', 'Blue']);
|
||||
});
|
||||
|
||||
it('should maintain value consistency between getter and setter', async () => {
|
||||
const testValue = ['Red', 'Green'];
|
||||
element.value = testValue;
|
||||
await element.updateComplete;
|
||||
|
||||
expect(element.value).to.deep.equal(testValue);
|
||||
verifyLocalSelectionAndDOM(testValue, testValue);
|
||||
});
|
||||
|
||||
it('should update multiple times correctly', async () => {
|
||||
for (const update of MULTI_SELECT_TEST_DATA) {
|
||||
element.value = update.value;
|
||||
await element.updateComplete;
|
||||
verifyLocalSelectionAndDOM(update.expected, update.expected);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('configuration handling', () => {
|
||||
it('should handle string array configuration', async () => {
|
||||
setupBasicStringConfig(element, ['Option1', 'Option2', 'Option3']);
|
||||
|
||||
element.value = ['Option1', 'Option3'];
|
||||
await element.updateComplete;
|
||||
|
||||
verifyLocalSelectionAndDOM(['Option1', 'Option3'], ['Option1', 'Option3']);
|
||||
});
|
||||
|
||||
it('should handle object array configuration', async () => {
|
||||
setupObjectConfig(element);
|
||||
|
||||
element.value = ['red', 'blue'];
|
||||
await element.updateComplete;
|
||||
|
||||
verifyLocalSelectionAndDOM(['red', 'blue'], ['red', 'blue']);
|
||||
});
|
||||
|
||||
it('should handle empty configuration gracefully', async () => {
|
||||
setupEmptyConfig(element);
|
||||
|
||||
element.value = ['test'];
|
||||
await element.updateComplete;
|
||||
|
||||
// Should not throw error
|
||||
expect(element.value).to.deep.equal(['test']);
|
||||
|
||||
// Should have no uui-checkboxes since configuration is empty
|
||||
const checkboxListInput = getCheckboxListElement(element);
|
||||
const checkboxElements = checkboxListInput?.shadowRoot?.querySelectorAll('uui-checkbox') || [];
|
||||
expect(checkboxElements).to.have.length(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ensureArray, updateItemsSelectedState } from '../utils/property-editor-ui-state-manager.js';
|
||||
import { css, customElement, html, map, nothing, property, state, when } from '@umbraco-cms/backoffice/external/lit';
|
||||
import { UmbChangeEvent } from '@umbraco-cms/backoffice/event';
|
||||
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
|
||||
@@ -30,7 +31,9 @@ export class UmbPropertyEditorUIDropdownElement
|
||||
|
||||
@property({ type: Array })
|
||||
public override set value(value: Array<string> | string | undefined) {
|
||||
this.#selection = this.#ensureValueIsArray(value);
|
||||
this.#selection = ensureArray(value);
|
||||
// Update the selected state of existing options when value changes
|
||||
this.#updateSelectedState();
|
||||
}
|
||||
public override get value(): Array<string> | undefined {
|
||||
return this.#selection;
|
||||
@@ -97,10 +100,6 @@ export class UmbPropertyEditorUIDropdownElement
|
||||
}
|
||||
}
|
||||
|
||||
#ensureValueIsArray(value: Array<string> | string | null | undefined): Array<string> {
|
||||
return Array.isArray(value) ? value : value ? [value] : [];
|
||||
}
|
||||
|
||||
#onChange(event: CustomEvent & { target: UmbInputDropdownListElement }) {
|
||||
const value = event.target.value as string;
|
||||
this.#setValue(value ? [value] : []);
|
||||
@@ -114,12 +113,27 @@ export class UmbPropertyEditorUIDropdownElement
|
||||
|
||||
#setValue(value: Array<string> | string | null | undefined) {
|
||||
if (!value) return;
|
||||
const selection = this.#ensureValueIsArray(value);
|
||||
const selection = ensureArray(value);
|
||||
this._options.forEach((item) => (item.selected = selection.includes(item.value)));
|
||||
this.value = value;
|
||||
this.dispatchEvent(new UmbChangeEvent());
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the selected state of all options based on current selection.
|
||||
* This fixes the issue where UI doesn't update when values are set programmatically.
|
||||
*/
|
||||
#updateSelectedState() {
|
||||
// Only update if we have options loaded
|
||||
if (this._options.length > 0) {
|
||||
// Update state only if changes are needed
|
||||
const updatedOptions = updateItemsSelectedState(this._options, this.#selection, 'selected');
|
||||
if (updatedOptions !== this._options) {
|
||||
this._options = updatedOptions;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
${when(
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { UmbPropertyEditorUIDropdownElement } from './property-editor-ui-dropdown.element.js';
|
||||
import { expect, fixture, html } from '@open-wc/testing';
|
||||
import { type UmbTestRunnerWindow, defaultA11yConfig } from '@umbraco-cms/internal/test-utils';
|
||||
import {
|
||||
setupBasicStringConfig,
|
||||
setupObjectConfig,
|
||||
setupEmptyConfig,
|
||||
MULTI_SELECT_TEST_DATA
|
||||
} from '../utils/property-editor-test-utils.js';
|
||||
|
||||
describe('UmbPropertyEditorUIDropdownElement', () => {
|
||||
let element: UmbPropertyEditorUIDropdownElement;
|
||||
@@ -9,6 +15,53 @@ describe('UmbPropertyEditorUIDropdownElement', () => {
|
||||
element = await fixture(html` <umb-property-editor-ui-dropdown></umb-property-editor-ui-dropdown> `);
|
||||
});
|
||||
|
||||
// Local helper functions to avoid conflicts with shared utilities
|
||||
function getLocalDropdownInput() {
|
||||
return element.shadowRoot?.querySelector('umb-input-dropdown-list');
|
||||
}
|
||||
|
||||
function getNativeSelectElement() {
|
||||
return element.shadowRoot?.querySelector('select');
|
||||
}
|
||||
|
||||
function getLocalSelectedValues() {
|
||||
const dropdownInput = getLocalDropdownInput();
|
||||
const selectElement = getNativeSelectElement();
|
||||
|
||||
if (dropdownInput) {
|
||||
// Single mode - the dropdown input value might be a string or comma-separated string
|
||||
const value = dropdownInput.value;
|
||||
if (!value) return [];
|
||||
// Handle both single values and comma-separated values
|
||||
return typeof value === 'string' ? value.split(', ').filter(v => v.length > 0) : [value];
|
||||
} else if (selectElement) {
|
||||
// Multiple mode
|
||||
const selectedOptions = selectElement.selectedOptions;
|
||||
return selectedOptions ? Array.from(selectedOptions).map(option => option.value) : [];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function verifyLocalSelectionAndDOM(expectedSelection: string[], expectedSelected: string[]) {
|
||||
expect(element.value).to.deep.equal(expectedSelection);
|
||||
expect(getLocalSelectedValues().sort()).to.deep.equal(expectedSelected.sort());
|
||||
}
|
||||
|
||||
function setupBasicConfigWithMultiple(multiple = false) {
|
||||
element.config = {
|
||||
getValueByAlias: (alias: string) => {
|
||||
if (alias === 'items') {
|
||||
return ['Red', 'Green', 'Blue'];
|
||||
}
|
||||
if (alias === 'multiple') {
|
||||
return multiple;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
} as any;
|
||||
}
|
||||
|
||||
it('is defined with its own instance', () => {
|
||||
expect(element).to.be.instanceOf(UmbPropertyEditorUIDropdownElement);
|
||||
});
|
||||
@@ -18,4 +71,187 @@ describe('UmbPropertyEditorUIDropdownElement', () => {
|
||||
await expect(element).shadowDom.to.be.accessible(defaultA11yConfig);
|
||||
});
|
||||
}
|
||||
|
||||
describe('programmatic value setting - single mode', () => {
|
||||
beforeEach(async () => {
|
||||
setupBasicConfigWithMultiple(false);
|
||||
await element.updateComplete;
|
||||
});
|
||||
|
||||
it('should update UI immediately when value is set programmatically with array', async () => {
|
||||
element.value = ['Red'];
|
||||
await element.updateComplete;
|
||||
|
||||
expect(getLocalDropdownInput()).to.exist;
|
||||
verifyLocalSelectionAndDOM(['Red'], ['Red']);
|
||||
});
|
||||
|
||||
it('should update UI immediately when value is set to empty array', async () => {
|
||||
// First set some values
|
||||
element.value = ['Green'];
|
||||
await element.updateComplete;
|
||||
|
||||
// Then clear them
|
||||
element.value = [];
|
||||
await element.updateComplete;
|
||||
|
||||
verifyLocalSelectionAndDOM([], []);
|
||||
});
|
||||
|
||||
it('should update UI immediately when value is set to single string', async () => {
|
||||
element.value = 'Blue';
|
||||
await element.updateComplete;
|
||||
|
||||
verifyLocalSelectionAndDOM(['Blue'], ['Blue']);
|
||||
});
|
||||
|
||||
it('should handle undefined value gracefully', async () => {
|
||||
element.value = undefined;
|
||||
await element.updateComplete;
|
||||
|
||||
verifyLocalSelectionAndDOM([], []);
|
||||
});
|
||||
|
||||
it('should handle invalid values gracefully', async () => {
|
||||
// Set value with invalid option that doesn't exist in the configured list ['Red', 'Green', 'Blue']
|
||||
element.value = ['InvalidColor'];
|
||||
await element.updateComplete;
|
||||
|
||||
// Should preserve all values in selection
|
||||
expect(element.value).to.deep.equal(['InvalidColor']);
|
||||
});
|
||||
|
||||
it('should maintain value consistency between getter and setter', async () => {
|
||||
const testValue = ['Green'];
|
||||
element.value = testValue;
|
||||
await element.updateComplete;
|
||||
|
||||
expect(element.value).to.deep.equal(testValue);
|
||||
verifyLocalSelectionAndDOM(testValue, testValue);
|
||||
});
|
||||
|
||||
it('should update multiple times correctly', async () => {
|
||||
for (const update of MULTI_SELECT_TEST_DATA) {
|
||||
element.value = update.value;
|
||||
await element.updateComplete;
|
||||
verifyLocalSelectionAndDOM(update.expected, update.expected);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('programmatic value setting - multiple mode', () => {
|
||||
beforeEach(async () => {
|
||||
setupBasicConfigWithMultiple(true);
|
||||
await element.updateComplete;
|
||||
});
|
||||
|
||||
it('should update UI immediately when value is set programmatically with array', async () => {
|
||||
element.value = ['Red', 'Blue'];
|
||||
await element.updateComplete;
|
||||
|
||||
expect(getNativeSelectElement()).to.exist;
|
||||
verifyLocalSelectionAndDOM(['Red', 'Blue'], ['Red', 'Blue']);
|
||||
});
|
||||
|
||||
it('should update UI immediately when value is set to empty array', async () => {
|
||||
// First set some values
|
||||
element.value = ['Red', 'Green'];
|
||||
await element.updateComplete;
|
||||
|
||||
// Then clear them
|
||||
element.value = [];
|
||||
await element.updateComplete;
|
||||
|
||||
verifyLocalSelectionAndDOM([], []);
|
||||
});
|
||||
|
||||
it('should handle multiple selections correctly', async () => {
|
||||
element.value = ['Red', 'Green', 'Blue'];
|
||||
await element.updateComplete;
|
||||
|
||||
verifyLocalSelectionAndDOM(['Red', 'Green', 'Blue'], ['Red', 'Green', 'Blue']);
|
||||
});
|
||||
|
||||
it('should handle invalid values gracefully', async () => {
|
||||
// Set value with invalid option that doesn't exist in the configured list ['Red', 'Green', 'Blue']
|
||||
element.value = ['Red', 'InvalidColor', 'Blue'];
|
||||
await element.updateComplete;
|
||||
|
||||
// Should preserve all values in selection
|
||||
expect(element.value).to.deep.equal(['Red', 'InvalidColor', 'Blue']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('configuration handling', () => {
|
||||
it('should handle string array configuration', async () => {
|
||||
element.config = {
|
||||
getValueByAlias: (alias: string) => {
|
||||
if (alias === 'items') {
|
||||
return ['Option1', 'Option2', 'Option3'];
|
||||
}
|
||||
if (alias === 'multiple') {
|
||||
return false;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
} as any;
|
||||
|
||||
element.value = ['Option1'];
|
||||
await element.updateComplete;
|
||||
|
||||
verifyLocalSelectionAndDOM(['Option1'], ['Option1']);
|
||||
});
|
||||
|
||||
it('should handle object array configuration', async () => {
|
||||
element.config = {
|
||||
getValueByAlias: (alias: string) => {
|
||||
if (alias === 'items') {
|
||||
return [
|
||||
{ name: 'Red Color', value: 'red' },
|
||||
{ name: 'Green Color', value: 'green' },
|
||||
{ name: 'Blue Color', value: 'blue' }
|
||||
];
|
||||
}
|
||||
if (alias === 'multiple') {
|
||||
return false;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
} as any;
|
||||
|
||||
element.value = ['red'];
|
||||
await element.updateComplete;
|
||||
|
||||
verifyLocalSelectionAndDOM(['red'], ['red']);
|
||||
});
|
||||
|
||||
it('should handle empty configuration gracefully', async () => {
|
||||
element.config = {
|
||||
getValueByAlias: () => undefined
|
||||
} as any;
|
||||
|
||||
element.value = ['test'];
|
||||
await element.updateComplete;
|
||||
|
||||
// Should not throw error
|
||||
expect(element.value).to.deep.equal(['test']);
|
||||
});
|
||||
|
||||
it('should switch between single and multiple modes correctly', async () => {
|
||||
// Start with single mode
|
||||
setupBasicConfigWithMultiple(false);
|
||||
element.value = ['Red'];
|
||||
await element.updateComplete;
|
||||
|
||||
expect(getLocalDropdownInput()).to.exist;
|
||||
expect(getNativeSelectElement()).to.not.exist;
|
||||
|
||||
// Switch to multiple mode
|
||||
setupBasicConfigWithMultiple(true);
|
||||
await element.updateComplete;
|
||||
|
||||
expect(getLocalDropdownInput()).to.not.exist;
|
||||
expect(getNativeSelectElement()).to.exist;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { updateItemsSelectedState } from '../utils/property-editor-ui-state-manager.js';
|
||||
import { customElement, html, property, state } from '@umbraco-cms/backoffice/external/lit';
|
||||
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
|
||||
import type {
|
||||
@@ -12,8 +13,17 @@ import { UmbChangeEvent } from '@umbraco-cms/backoffice/event';
|
||||
*/
|
||||
@customElement('umb-property-editor-ui-select')
|
||||
export class UmbPropertyEditorUISelectElement extends UmbLitElement implements UmbPropertyEditorUiElement {
|
||||
private _value: string = '';
|
||||
|
||||
// Update the selected state of existing options when value changes
|
||||
@property()
|
||||
value?: string = '';
|
||||
public set value(newValue: string | undefined) {
|
||||
this._value = newValue || '';
|
||||
this.#updateSelectedState();
|
||||
}
|
||||
public get value(): string {
|
||||
return this._value;
|
||||
}
|
||||
|
||||
public set config(config: UmbPropertyEditorConfigCollection | undefined) {
|
||||
if (!config) return;
|
||||
@@ -23,8 +33,8 @@ export class UmbPropertyEditorUISelectElement extends UmbLitElement implements U
|
||||
if (Array.isArray(items) && items.length > 0) {
|
||||
this._options =
|
||||
typeof items[0] === 'string'
|
||||
? items.map((item) => ({ name: item, value: item, selected: item === this.value }))
|
||||
: items.map((item) => ({ name: item.name, value: item.value, selected: item.value === this.value }));
|
||||
? items.map((item) => ({ name: item, value: item, selected: item === this._value }))
|
||||
: items.map((item) => ({ name: item.name, value: item.value, selected: item.value === this._value }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +46,21 @@ export class UmbPropertyEditorUISelectElement extends UmbLitElement implements U
|
||||
this.dispatchEvent(new UmbChangeEvent());
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the selected state of all options based on current value.
|
||||
* This fixes the issue where UI doesn't update when values are set programmatically.
|
||||
*/
|
||||
#updateSelectedState() {
|
||||
// Only update if we have options loaded
|
||||
if (this._options.length > 0) {
|
||||
// Update state only if changes are needed
|
||||
const updatedOptions = updateItemsSelectedState(this._options, [this._value], 'selected');
|
||||
if (updatedOptions !== this._options) {
|
||||
this._options = updatedOptions;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`<uui-select .options=${this._options} @change=${this.#onChange}></uui-select>`;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { UmbPropertyEditorUISelectElement } from './property-editor-ui-select.element.js';
|
||||
import { expect, fixture, html } from '@open-wc/testing';
|
||||
import { fixture, html } from '@open-wc/testing';
|
||||
import { type UmbTestRunnerWindow, defaultA11yConfig } from '@umbraco-cms/internal/test-utils';
|
||||
import {
|
||||
expect,
|
||||
setupBasicStringConfig,
|
||||
setupObjectConfig,
|
||||
setupEmptyConfig,
|
||||
getSelectElement,
|
||||
getSelectedValue,
|
||||
verifySelectValueAndDOM,
|
||||
SINGLE_SELECT_TEST_DATA
|
||||
} from '../utils/property-editor-test-utils.js';
|
||||
|
||||
describe('UmbPropertyEditorUISelectElement', () => {
|
||||
let element: UmbPropertyEditorUISelectElement;
|
||||
@@ -9,6 +19,8 @@ describe('UmbPropertyEditorUISelectElement', () => {
|
||||
element = await fixture(html`<umb-property-editor-ui-select></umb-property-editor-ui-select>`);
|
||||
});
|
||||
|
||||
|
||||
|
||||
it('is defined with its own instance', () => {
|
||||
expect(element).to.be.instanceOf(UmbPropertyEditorUISelectElement);
|
||||
});
|
||||
@@ -18,4 +30,117 @@ describe('UmbPropertyEditorUISelectElement', () => {
|
||||
await expect(element).shadowDom.to.be.accessible(defaultA11yConfig);
|
||||
});
|
||||
}
|
||||
|
||||
describe('programmatic value setting', () => {
|
||||
beforeEach(async () => {
|
||||
setupBasicStringConfig(element);
|
||||
await element.updateComplete;
|
||||
});
|
||||
|
||||
it('should update UI immediately when value is set programmatically', async () => {
|
||||
element.value = 'Red';
|
||||
await element.updateComplete;
|
||||
|
||||
expect(getSelectElement(element)).to.exist;
|
||||
verifySelectValueAndDOM(element, 'Red', 'Red');
|
||||
});
|
||||
|
||||
it('should update UI immediately when value is set to empty string', async () => {
|
||||
// First set some value
|
||||
element.value = 'Green';
|
||||
await element.updateComplete;
|
||||
|
||||
// Then clear it
|
||||
element.value = '';
|
||||
await element.updateComplete;
|
||||
|
||||
verifySelectValueAndDOM(element, '', '');
|
||||
});
|
||||
|
||||
it('should handle undefined value gracefully', async () => {
|
||||
element.value = undefined;
|
||||
await element.updateComplete;
|
||||
|
||||
verifySelectValueAndDOM(element, '', '');
|
||||
});
|
||||
|
||||
it('should handle invalid values gracefully', async () => {
|
||||
// Set value with invalid option that doesn't exist in the configured list ['Red', 'Green', 'Blue']
|
||||
element.value = 'InvalidColor';
|
||||
await element.updateComplete;
|
||||
|
||||
// Should preserve the value even if it's not in the options
|
||||
expect(element.value).to.equal('InvalidColor');
|
||||
});
|
||||
|
||||
it('should maintain value consistency between getter and setter', async () => {
|
||||
const testValue = 'Blue';
|
||||
element.value = testValue;
|
||||
await element.updateComplete;
|
||||
|
||||
expect(element.value).to.equal(testValue);
|
||||
verifySelectValueAndDOM(element, testValue, testValue);
|
||||
});
|
||||
|
||||
it('should update multiple times correctly', async () => {
|
||||
for (const update of SINGLE_SELECT_TEST_DATA) {
|
||||
element.value = update.value;
|
||||
await element.updateComplete;
|
||||
verifySelectValueAndDOM(element, update.expected, update.expected);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('configuration handling', () => {
|
||||
it('should handle string array configuration', async () => {
|
||||
setupBasicStringConfig(element, ['Option1', 'Option2', 'Option3']);
|
||||
element.value = 'Option2';
|
||||
await element.updateComplete;
|
||||
|
||||
verifySelectValueAndDOM(element, 'Option2', 'Option2');
|
||||
});
|
||||
|
||||
it('should handle object array configuration', async () => {
|
||||
setupObjectConfig(element);
|
||||
element.value = 'green';
|
||||
await element.updateComplete;
|
||||
|
||||
verifySelectValueAndDOM(element, 'green', 'green');
|
||||
});
|
||||
|
||||
it('should handle empty configuration gracefully', async () => {
|
||||
setupEmptyConfig(element);
|
||||
element.value = 'test';
|
||||
await element.updateComplete;
|
||||
|
||||
// Should not throw error
|
||||
expect(element.value).to.equal('test');
|
||||
|
||||
// Should have no options since configuration is empty
|
||||
const selectElement = getSelectElement(element) as { options?: HTMLOptionElement[] } | null;
|
||||
expect(selectElement?.options).to.have.length(0);
|
||||
});
|
||||
|
||||
it('should update options when configuration changes', async () => {
|
||||
// Start with initial config
|
||||
setupBasicStringConfig(element);
|
||||
element.value = 'Red';
|
||||
await element.updateComplete;
|
||||
|
||||
verifySelectValueAndDOM(element, 'Red', 'Red');
|
||||
|
||||
// Change configuration
|
||||
setupBasicStringConfig(element, ['Yellow', 'Purple', 'Orange']);
|
||||
await element.updateComplete;
|
||||
|
||||
// Value should be preserved even if not in new options
|
||||
expect(element.value).to.equal('Red');
|
||||
|
||||
// Set a value from the new options
|
||||
element.value = 'Yellow';
|
||||
await element.updateComplete;
|
||||
|
||||
verifySelectValueAndDOM(element, 'Yellow', 'Yellow');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { expect } from '@open-wc/testing';
|
||||
|
||||
/**
|
||||
* Shared test utilities for property editor components
|
||||
*/
|
||||
|
||||
/**
|
||||
* Type definitions for better domain modeling
|
||||
*/
|
||||
export type PropertyEditorElement = { config?: unknown };
|
||||
export type ShadowDOMElement = { shadowRoot?: ShadowRoot | null };
|
||||
export type SingleSelectElement = { value: string } & ShadowDOMElement;
|
||||
export type MultiSelectElement = { value: string[] } & ShadowDOMElement;
|
||||
|
||||
export interface ConfigItem {
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface TestDataEntry<T> {
|
||||
value: T;
|
||||
expected: T;
|
||||
}
|
||||
|
||||
export type ConfigAlias = 'items' | 'multiple';
|
||||
export type CSSSelector = 'uui-select' | 'umb-input-checkbox-list' | 'umb-input-dropdown-list';
|
||||
|
||||
/**
|
||||
* Helper function to setup basic string array configuration
|
||||
* @param {PropertyEditorElement} element - The property editor element to configure
|
||||
* @param {string[]} items - Array of string items for configuration
|
||||
*/
|
||||
export function setupBasicStringConfig(element: PropertyEditorElement, items: string[] = ['Red', 'Green', 'Blue']) {
|
||||
element.config = {
|
||||
getValueByAlias: (alias: ConfigAlias) => {
|
||||
if (alias === 'items') {
|
||||
return items;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
} as { getValueByAlias: (alias: ConfigAlias) => unknown };
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to setup object array configuration
|
||||
* @param {PropertyEditorElement} element - The property editor element to configure
|
||||
* @param {ConfigItem[]} items - Array of object items for configuration
|
||||
*/
|
||||
export function setupObjectConfig(
|
||||
element: PropertyEditorElement,
|
||||
items: ConfigItem[] = [
|
||||
{ name: 'Red Color', value: 'red' },
|
||||
{ name: 'Green Color', value: 'green' },
|
||||
{ name: 'Blue Color', value: 'blue' },
|
||||
],
|
||||
) {
|
||||
element.config = {
|
||||
getValueByAlias: (alias: ConfigAlias) => {
|
||||
if (alias === 'items') {
|
||||
return items;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
} as { getValueByAlias: (alias: ConfigAlias) => unknown };
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to setup empty configuration
|
||||
* @param {PropertyEditorElement} element - The property editor element to configure
|
||||
*/
|
||||
export function setupEmptyConfig(element: PropertyEditorElement) {
|
||||
element.config = {
|
||||
getValueByAlias: () => undefined,
|
||||
} as { getValueByAlias: (alias: ConfigAlias) => unknown };
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to get select element from shadow DOM
|
||||
* @param {ShadowDOMElement} element - The property editor element
|
||||
* @returns {Element | null} The UUI select element or null
|
||||
*/
|
||||
export function getSelectElement(element: ShadowDOMElement) {
|
||||
return element.shadowRoot?.querySelector('uui-select' as CSSSelector);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to get checkbox list element from shadow DOM
|
||||
* @param {ShadowDOMElement} element - The property editor element
|
||||
* @returns {Element | null} The checkbox list element or null
|
||||
*/
|
||||
export function getCheckboxListElement(element: ShadowDOMElement) {
|
||||
return element.shadowRoot?.querySelector('umb-input-checkbox-list' as CSSSelector);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to get dropdown element from shadow DOM
|
||||
* @param {ShadowDOMElement} element - The property editor element
|
||||
* @returns {Element | null} The dropdown element or null
|
||||
*/
|
||||
export function getDropdownElement(element: ShadowDOMElement) {
|
||||
return element.shadowRoot?.querySelector('umb-input-dropdown-list' as CSSSelector);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to get selected value from select DOM
|
||||
* @param {ShadowDOMElement} element - The property editor element
|
||||
* @returns {string} The selected value string
|
||||
*/
|
||||
export function getSelectedValue(element: ShadowDOMElement): string {
|
||||
const selectElement = getSelectElement(element) as { value?: string } | null;
|
||||
return selectElement?.value || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to get selection from checkbox list DOM
|
||||
* @param {ShadowDOMElement} element - The property editor element
|
||||
* @returns {string[]} Array of selected values
|
||||
*/
|
||||
export function getCheckboxSelection(element: ShadowDOMElement): string[] {
|
||||
const checkboxElement = getCheckboxListElement(element) as { selection?: string[] } | null;
|
||||
return checkboxElement?.selection || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to get selection from dropdown DOM
|
||||
* @param {ShadowDOMElement} element - The property editor element
|
||||
* @returns {string[]} Array of selected values
|
||||
*/
|
||||
export function getDropdownSelection(element: ShadowDOMElement): string[] {
|
||||
const dropdownElement = getDropdownElement(element) as { value?: string; selection?: string[] } | null;
|
||||
// Prefer selection array if available, fallback to parsing value string
|
||||
if (dropdownElement?.selection) {
|
||||
return dropdownElement.selection;
|
||||
}
|
||||
// Fallback: parse comma-separated string (note: assumes values don't contain commas)
|
||||
return dropdownElement?.value ? dropdownElement.value.split(', ').filter(v => v.trim().length > 0) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to verify both value and DOM state for single select
|
||||
* @param {SingleSelectElement} element - The property editor element
|
||||
* @param {string} expectedValue - Expected element value
|
||||
* @param {string} expectedSelected - Expected selected value in DOM
|
||||
*/
|
||||
export function verifySelectValueAndDOM(element: SingleSelectElement, expectedValue: string, expectedSelected: string) {
|
||||
expect(element.value).to.equal(expectedValue);
|
||||
expect(getSelectedValue(element)).to.equal(expectedSelected);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to verify both value and DOM state for multi-select
|
||||
* @param {MultiSelectElement} element - The property editor element
|
||||
* @param {string[]} expectedValue - Expected element value array
|
||||
* @param {string[]} expectedSelection - Expected selection array in DOM
|
||||
*/
|
||||
export function verifyMultiSelectValueAndDOM(
|
||||
element: MultiSelectElement,
|
||||
expectedValue: string[],
|
||||
expectedSelection: string[],
|
||||
) {
|
||||
expect(element.value).to.deep.equal(expectedValue);
|
||||
expect(getCheckboxSelection(element)).to.deep.equal(expectedSelection);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to verify both value and DOM state for dropdown
|
||||
* @param {MultiSelectElement} element - The property editor element
|
||||
* @param {string[]} expectedValue - Expected element value array
|
||||
* @param {string[]} expectedSelection - Expected selection array in DOM
|
||||
*/
|
||||
export function verifyDropdownValueAndDOM(
|
||||
element: MultiSelectElement,
|
||||
expectedValue: string[],
|
||||
expectedSelection: string[],
|
||||
) {
|
||||
expect(element.value).to.deep.equal(expectedValue);
|
||||
expect(getDropdownSelection(element)).to.deep.equal(expectedSelection);
|
||||
}
|
||||
|
||||
/**
|
||||
* Common test data for multiple updates
|
||||
*/
|
||||
export const SINGLE_SELECT_TEST_DATA: TestDataEntry<string>[] = [
|
||||
{ value: 'Red', expected: 'Red' },
|
||||
{ value: 'Green', expected: 'Green' },
|
||||
{ value: 'Blue', expected: 'Blue' },
|
||||
{ value: '', expected: '' },
|
||||
];
|
||||
|
||||
export const MULTI_SELECT_TEST_DATA: TestDataEntry<string[]>[] = [
|
||||
{ value: ['Red'], expected: ['Red'] },
|
||||
{ value: ['Red', 'Blue'], expected: ['Red', 'Blue'] },
|
||||
{ value: ['Green'], expected: ['Green'] },
|
||||
{ value: [], expected: [] },
|
||||
];
|
||||
|
||||
// Re-export expect for convenience
|
||||
export { expect };
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Utility functions for managing property editor UI state updates
|
||||
* when values are set programmatically.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Interface for items that can be selected/checked
|
||||
*/
|
||||
export interface UmbSelectableItem {
|
||||
value: string;
|
||||
selected?: boolean;
|
||||
checked?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the selected state of items based on current selection.
|
||||
* This function is for internal use only within the property-editors package and should not be exposed
|
||||
* to external consumers to avoid unwanted external dependencies.
|
||||
*
|
||||
* @internal
|
||||
* @template T
|
||||
* @param {T[]} items - Array of items to update
|
||||
* @param {string[]} selection - Array of selected values
|
||||
* @param {'selected' | 'checked'} stateProperty - Property name to update ('selected' or 'checked')
|
||||
* @returns {T[]} New array with updated state, or original array if no changes needed
|
||||
*/
|
||||
export function updateItemsSelectedState<T extends UmbSelectableItem>(
|
||||
items: T[],
|
||||
selection: string[],
|
||||
stateProperty: 'selected' | 'checked' = 'selected',
|
||||
): T[] {
|
||||
// Convert to Set for O(1) lookups instead of O(n) includes
|
||||
const selectionSet = new Set(selection);
|
||||
|
||||
// Check if any state changes are needed to avoid unnecessary array allocations
|
||||
let hasChanges = false;
|
||||
for (const item of items) {
|
||||
const shouldBeSelected = selectionSet.has(item.value);
|
||||
const currentState = item[stateProperty] ?? false;
|
||||
if (currentState !== shouldBeSelected) {
|
||||
hasChanges = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Return original array if no changes needed
|
||||
if (!hasChanges) {
|
||||
return items;
|
||||
}
|
||||
|
||||
// Only create new array if changes are needed
|
||||
return items.map((item) => ({
|
||||
...item,
|
||||
[stateProperty]: selectionSet.has(item.value),
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Helper function to ensure a value is an array
|
||||
* @param {string | string[] | null | undefined} value - Value to convert to array
|
||||
* @returns {string[]} Array representation of the value
|
||||
*/
|
||||
export function ensureArray(value: string | string[] | null | undefined): string[] {
|
||||
return Array.isArray(value) ? value : value ? [value] : [];
|
||||
}
|
||||
Reference in New Issue
Block a user