diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/checkbox-list/property-editor-ui-checkbox-list.element.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/checkbox-list/property-editor-ui-checkbox-list.element.ts index a9cc829e44..f6e02d7c4e 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/property-editors/checkbox-list/property-editor-ui-checkbox-list.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/checkbox-list/property-editor-ui-checkbox-list.element.ts @@ -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 | 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 | 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` { let element: UmbPropertyEditorUICheckboxListElement; beforeEach(async () => { - element = await fixture(html` `); + element = await fixture(html``); }); + // 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); + }); + }); }); diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/dropdown/property-editor-ui-dropdown.element.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/dropdown/property-editor-ui-dropdown.element.ts index 3292e55b0c..a74ea4ded9 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/property-editors/dropdown/property-editor-ui-dropdown.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/dropdown/property-editor-ui-dropdown.element.ts @@ -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 | 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 | undefined { return this.#selection; @@ -97,10 +100,6 @@ export class UmbPropertyEditorUIDropdownElement } } - #ensureValueIsArray(value: Array | string | null | undefined): Array { - 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 | 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( diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/dropdown/property-editor-ui-dropdown.test.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/dropdown/property-editor-ui-dropdown.test.ts index bf64df231e..101d1d8dbe 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/property-editors/dropdown/property-editor-ui-dropdown.test.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/dropdown/property-editor-ui-dropdown.test.ts @@ -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` `); }); + // 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; + }); + }); }); diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/select/property-editor-ui-select.element.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/select/property-editor-ui-select.element.ts index 3345b9606b..b4ae7c29a9 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/property-editors/select/property-editor-ui-select.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/select/property-editor-ui-select.element.ts @@ -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``; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/select/property-editor-ui-select.test.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/select/property-editor-ui-select.test.ts index 6243c9f5dd..c286a45874 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/property-editors/select/property-editor-ui-select.test.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/select/property-editor-ui-select.test.ts @@ -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``); }); + + 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'); + }); + }); }); diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/utils/property-editor-test-utils.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/utils/property-editor-test-utils.ts new file mode 100644 index 0000000000..6798205d3f --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/utils/property-editor-test-utils.ts @@ -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 { + 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[] = [ + { value: 'Red', expected: 'Red' }, + { value: 'Green', expected: 'Green' }, + { value: 'Blue', expected: 'Blue' }, + { value: '', expected: '' }, +]; + +export const MULTI_SELECT_TEST_DATA: TestDataEntry[] = [ + { value: ['Red'], expected: ['Red'] }, + { value: ['Red', 'Blue'], expected: ['Red', 'Blue'] }, + { value: ['Green'], expected: ['Green'] }, + { value: [], expected: [] }, +]; + +// Re-export expect for convenience +export { expect }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/utils/property-editor-ui-state-manager.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/utils/property-editor-ui-state-manager.ts new file mode 100644 index 0000000000..bb07cb481a --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/utils/property-editor-ui-state-manager.ts @@ -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( + 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] : []; +}