diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/collection/default/collection-default.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/collection/default/collection-default.context.ts index 51ecbb17a7..5e8e5a228f 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/collection/default/collection-default.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/collection/default/collection-default.context.ts @@ -43,7 +43,7 @@ export class UmbDefaultCollectionContext< }); public readonly pagination = new UmbPaginationManager(); - public readonly selection = new UmbSelectionManager(); + public readonly selection = new UmbSelectionManager(this); public readonly view; constructor(host: UmbControllerHostElement, config: UmbCollectionConfiguration = { pageSize: 50 }) { diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/modal/common/section-picker/section-picker-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/modal/common/section-picker/section-picker-modal.element.ts index b94ebd19b0..5f936a8af5 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/modal/common/section-picker/section-picker-modal.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/modal/common/section-picker/section-picker-modal.element.ts @@ -16,7 +16,7 @@ export class UmbSectionPickerModalElement extends UmbModalBaseElement< @state() private _sections: Array = []; - #selectionManager = new UmbSelectionManager(); + #selectionManager = new UmbSelectionManager(this); connectedCallback(): void { super.connectedCallback(); @@ -33,14 +33,8 @@ export class UmbSectionPickerModalElement extends UmbModalBaseElement< } #submit() { - this.value = { - selection: this.#selectionManager.getSelection(), - }; - this.modalContext?.submit(); - } - - #close() { - this.modalContext?.reject(); + this.value = { selection: this.#selectionManager.getSelection() }; + this._submitModal(); } render() { @@ -59,7 +53,7 @@ export class UmbSectionPickerModalElement extends UmbModalBaseElement< )}
- +
diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-item-base/tree-item-base.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-item-base/tree-item-base.context.ts index 736ca1c302..a32f53051b 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-item-base/tree-item-base.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-item-base/tree-item-base.context.ts @@ -104,13 +104,13 @@ export class UmbTreeItemContextBase } public select() { - if (this.unique === undefined) throw new Error('Could not select, unique key is missing'); - this.treeContext?.select(this.unique); + if (this.unique === undefined) throw new Error('Could not select. Unique is missing'); + this.treeContext?.selection.select(this.unique); } public deselect() { - if (this.unique === undefined) throw new Error('Could not deselect, unique key is missing'); - this.treeContext?.deselect(this.unique); + if (this.unique === undefined) throw new Error('Could not deselect. Unique is missing'); + this.treeContext?.selection.deselect(this.unique); } #consumeContexts() { @@ -138,7 +138,7 @@ export class UmbTreeItemContextBase #observeIsSelectable() { if (!this.treeContext) return; this.observe( - this.treeContext.selectable, + this.treeContext.selection.selectable, (value) => { this.#isSelectableContext.next(value); @@ -156,7 +156,7 @@ export class UmbTreeItemContextBase if (!this.treeContext || !this.unique) return; this.observe( - this.treeContext.selection.pipe(map((selection) => selection.includes(this.unique!))), + this.treeContext.selection.selection.pipe(map((selection) => selection.includes(this.unique!))), (isSelected) => { this.#isSelected.next(isSelected); }, diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree.context.ts index ddd3c0af26..2c89cfe9c5 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree.context.ts @@ -7,26 +7,15 @@ import { type ManifestTree, umbExtensionsRegistry, } from '@umbraco-cms/backoffice/extension-registry'; -import { UmbBooleanState } from '@umbraco-cms/backoffice/observable-api'; import { UmbBaseController } from '@umbraco-cms/backoffice/class-api'; import { type UmbControllerHostElement } from '@umbraco-cms/backoffice/controller-api'; import { UmbExtensionApiInitializer } from '@umbraco-cms/backoffice/extension-api'; import { ProblemDetails } from '@umbraco-cms/backoffice/backend-api'; import { UmbSelectionManager } from '@umbraco-cms/backoffice/utils'; -import { UmbSelectionChangeEvent } from '@umbraco-cms/backoffice/event'; // TODO: update interface export interface UmbTreeContext extends UmbBaseController { - readonly selectable: Observable; - readonly selection: Observable>; - setSelectable(value: boolean): void; - getSelectable(): boolean; - setMultiple(value: boolean): void; - getMultiple(): boolean; - setSelection(value: Array): void; - getSelection(): Array; - select(unique: string | null): void; - deselect(unique: string | null): void; + selection: UmbSelectionManager; requestChildrenOf: (parentUnique: string | null) => Promise<{ data?: UmbPagedData; error?: ProblemDetails; @@ -38,17 +27,11 @@ export class UmbTreeContextBase extends UmbBaseController implements UmbTreeContext { - #selectionManager = new UmbSelectionManager(); - - #selectable = new UmbBooleanState(false); - public readonly selectable = this.#selectable.asObservable(); - - public readonly multiple = this.#selectionManager.multiple; - public readonly selection = this.#selectionManager.selection; - public repository?: UmbTreeRepository; public selectableFilter?: (item: TreeItemType) => boolean = () => true; + public readonly selection = new UmbSelectionManager(this._host); + #treeAlias?: string; #initResolver?: () => void; @@ -82,41 +65,6 @@ export class UmbTreeContextBase return this.#treeAlias; } - public setSelectable(value: boolean) { - this.#selectable.next(value); - } - - public getSelectable() { - return this.#selectable.getValue(); - } - - public setMultiple(value: boolean) { - this.#selectionManager.setMultiple(value); - } - - public getMultiple() { - return this.#selectionManager.getMultiple(); - } - - public setSelection(value: Array) { - this.#selectionManager.setSelection(value); - } - - public getSelection() { - return this.#selectionManager.getSelection(); - } - - public select(unique: string | null) { - if (!this.getSelectable()) return; - this.#selectionManager.select(unique); - this._host.getHostElement().dispatchEvent(new UmbSelectionChangeEvent()); - } - - public deselect(unique: string | null) { - this.#selectionManager.deselect(unique); - this._host.getHostElement().dispatchEvent(new UmbSelectionChangeEvent()); - } - public async requestTreeRoot() { await this.#init; return this.repository!.requestTreeRoot(); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree.element.ts index a429a52544..fc28079c8a 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree.element.ts @@ -19,27 +19,27 @@ export class UmbTreeElement extends UmbLitElement { @property({ type: Boolean, reflect: true }) get selectable() { - return this.#treeContext.getSelectable(); + return this.#treeContext.selection.getSelectable(); } set selectable(newVal) { - this.#treeContext.setSelectable(newVal); + this.#treeContext.selection.setSelectable(newVal); } @property({ type: Array }) get selection() { - return this.#treeContext.getSelection(); + return this.#treeContext.selection.getSelection(); } set selection(newVal) { if (!Array.isArray(newVal)) return; - this.#treeContext?.setSelection(newVal); + this.#treeContext?.selection.setSelection(newVal); } @property({ type: Boolean, reflect: true }) get multiple() { - return this.#treeContext.getMultiple(); + return this.#treeContext.selection.getMultiple(); } set multiple(newVal) { - this.#treeContext.setMultiple(newVal); + this.#treeContext.selection.setMultiple(newVal); } // TODO: what is the best name for this functionality? diff --git a/src/Umbraco.Web.UI.Client/src/packages/settings/languages/modals/language-picker/language-picker-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/settings/languages/modals/language-picker/language-picker-modal.element.ts index dcce820c0d..72dc6590c8 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/settings/languages/modals/language-picker/language-picker-modal.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/settings/languages/modals/language-picker/language-picker-modal.element.ts @@ -18,7 +18,7 @@ export class UmbLanguagePickerModalElement extends UmbModalBaseElement< private _languages: Array = []; #languageRepository = new UmbLanguageRepository(this); - #selectionManager = new UmbSelectionManager(); + #selectionManager = new UmbSelectionManager(this); connectedCallback(): void { super.connectedCallback(); diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user-group/modals/user-group-picker/user-group-picker-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user-group/modals/user-group-picker/user-group-picker-modal.element.ts index 7cd074e9e6..667593c6fa 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/user-group/modals/user-group-picker/user-group-picker-modal.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user-group/modals/user-group-picker/user-group-picker-modal.element.ts @@ -14,7 +14,7 @@ export class UmbUserGroupPickerModalElement extends UmbModalBaseElement< @state() private _userGroups: Array = []; - #selectionManager = new UmbSelectionManager(); + #selectionManager = new UmbSelectionManager(this); #userGroupCollectionRepository = new UmbUserGroupCollectionRepository(this); connectedCallback(): void { diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user/modals/user-picker/user-picker-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user/modals/user-picker/user-picker-modal.element.ts index 2960354e57..389459999a 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/user/modals/user-picker/user-picker-modal.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user/modals/user-picker/user-picker-modal.element.ts @@ -10,7 +10,7 @@ export class UmbUserPickerModalElement extends UmbModalBaseElement = []; - #selectionManager = new UmbSelectionManager(); + #selectionManager = new UmbSelectionManager(this); #userCollectionRepository = new UmbUserCollectionRepository(this); connectedCallback(): void { diff --git a/src/Umbraco.Web.UI.Client/src/shared/utils/index.ts b/src/Umbraco.Web.UI.Client/src/shared/utils/index.ts index 966c6dbabc..b9a04aff5f 100644 --- a/src/Umbraco.Web.UI.Client/src/shared/utils/index.ts +++ b/src/Umbraco.Web.UI.Client/src/shared/utils/index.ts @@ -8,7 +8,7 @@ export * from './pagination-manager/pagination.manager.js'; export * from './path-decode.function.js'; export * from './path-encode.function.js'; export * from './path-folder-name.function.js'; -export * from './selection-manager.js'; +export * from './selection-manager/selection.manager.js'; export * from './udi-service.js'; export * from './umbraco-path.function.js'; export * from './split-string-to-array.js'; diff --git a/src/Umbraco.Web.UI.Client/src/shared/utils/selection-manager/selection.manager.test.ts b/src/Umbraco.Web.UI.Client/src/shared/utils/selection-manager/selection.manager.test.ts new file mode 100644 index 0000000000..adf7318509 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/shared/utils/selection-manager/selection.manager.test.ts @@ -0,0 +1,259 @@ +import { expect } from '@open-wc/testing'; +import { UmbSelectionManager } from './selection.manager.js'; +import { Observable } from '@umbraco-cms/backoffice/external/rxjs'; +import { customElement } from '@umbraco-cms/backoffice/external/lit'; +import { UmbControllerHostElementMixin } from '@umbraco-cms/backoffice/controller-api'; + +@customElement('test-my-controller-host') +export class UmbTestControllerHostElement extends UmbControllerHostElementMixin(HTMLElement) {} + +describe('UmbSelectionManager', () => { + let manager: UmbSelectionManager; + + beforeEach(() => { + const hostElement = new UmbTestControllerHostElement(); + manager = new UmbSelectionManager(hostElement); + manager.setSelectable(true); + manager.setMultiple(true); + }); + + describe('Public API', () => { + describe('properties', () => { + it('has a selectable property', () => { + expect(manager).to.have.property('selectable').to.be.an.instanceOf(Observable); + }); + + it('has a selection property', () => { + expect(manager).to.have.property('selection').to.be.an.instanceOf(Observable); + }); + + it('has a multiple property', () => { + expect(manager).to.have.property('multiple').to.be.an.instanceOf(Observable); + }); + }); + + describe('methods', () => { + it('has a getSelectable method', () => { + expect(manager).to.have.property('getSelectable').that.is.a('function'); + }); + + it('has a setSelectable method', () => { + expect(manager).to.have.property('setSelectable').that.is.a('function'); + }); + + it('has a getSelection method', () => { + expect(manager).to.have.property('getSelection').that.is.a('function'); + }); + + it('has a setSelection method', () => { + expect(manager).to.have.property('setSelection').that.is.a('function'); + }); + + it('has a getMultiple method', () => { + expect(manager).to.have.property('getMultiple').that.is.a('function'); + }); + + it('has a setMultiple method', () => { + expect(manager).to.have.property('setMultiple').that.is.a('function'); + }); + + it('has a toggleSelect method', () => { + expect(manager).to.have.property('toggleSelect').that.is.a('function'); + }); + + it('has a select method', () => { + expect(manager).to.have.property('select').that.is.a('function'); + }); + + it('has a deselect method', () => { + expect(manager).to.have.property('deselect').that.is.a('function'); + }); + + it('has a isSelected method', () => { + expect(manager).to.have.property('isSelected').that.is.a('function'); + }); + + it('has a clearSelection method', () => { + expect(manager).to.have.property('clearSelection').that.is.a('function'); + }); + }); + }); + + describe('Selectable', () => { + it('sets and gets the selectable value', () => { + manager.setSelectable(false); + expect(manager.getSelectable()).to.equal(false); + }); + + it('updates the observable', (done) => { + manager.setSelectable(false); + + manager.selectable + .subscribe((value) => { + expect(value).to.equal(false); + done(); + }) + .unsubscribe(); + }); + }); + + describe('Selection', () => { + it('sets and gets the selection value', () => { + manager.setSelection(['1', '2']); + expect(manager.getSelection()).to.deep.equal(['1', '2']); + }); + + it('updates the observable', (done) => { + manager.setSelection(['1', '2']); + + manager.selection + .subscribe((value) => { + expect(value).to.deep.equal(['1', '2']); + done(); + }) + .unsubscribe(); + }); + }); + + describe('Multiple', () => { + it('sets and gets the multiple value', () => { + manager.setMultiple(true); + expect(manager.getMultiple()).to.equal(true); + }); + + it('updates the observable', (done) => { + manager.setMultiple(true); + + manager.multiple + .subscribe((value) => { + expect(value).to.equal(true); + done(); + }) + .unsubscribe(); + }); + }); + + describe('Select', () => { + it('selects an item', () => { + manager.select('3'); + expect(manager.getSelection()).to.deep.equal(['3']); + }); + + it('does nothing if the item is already selected', () => { + manager.setSelection(['1', '2']); + manager.select('2'); + expect(manager.getSelection()).to.deep.equal(['1', '2']); + }); + + it('does nothing if selection isnt supported', () => { + manager.setSelectable(false); + manager.select('3'); + expect(manager.getSelection()).to.deep.equal([]); + }); + }); + + describe('Deselect', () => { + it('deselects an item', () => { + manager.setSelection(['1', '2', '3']); + manager.deselect('2'); + expect(manager.getSelection()).to.deep.equal(['1', '3']); + }); + + it('does nothing if the item isnt selected', () => { + manager.setSelection(['1', '2']); + manager.deselect('3'); + expect(manager.getSelection()).to.deep.equal(['1', '2']); + }); + + it('does nothing if selection isnt supported', () => { + manager.setSelection(['1', '2']); + manager.setSelectable(false); + manager.deselect('2'); + expect(manager.getSelection()).to.deep.equal(['1', '2']); + }); + }); + + describe('Toggle select', () => { + it('toggle selects an item', () => { + manager.toggleSelect('1'); + manager.toggleSelect('2'); + expect(manager.getSelection()).to.deep.equal(['1', '2']); + manager.toggleSelect('1'); + expect(manager.getSelection()).to.deep.equal(['2']); + }); + + it('does nothing if selection isnt supported', () => { + manager.setSelectable(false); + manager.toggleSelect('1'); + manager.toggleSelect('2'); + expect(manager.getSelection()).to.deep.equal([]); + }); + }); + + describe('Is selected', () => { + it('returns true if the item is selected', () => { + manager.setSelection(['1', '2']); + expect(manager.isSelected('1')).to.equal(true); + }); + + it('returns false if the item isnt selected', () => { + manager.setSelection(['1', '2']); + expect(manager.isSelected('3')).to.equal(false); + }); + }); + + describe('Clear selection', () => { + it('clears the selection', () => { + manager.setSelection(['1', '2']); + expect(manager.getSelection()).to.deep.equal(['1', '2']); + manager.clearSelection(); + expect(manager.getSelection()).to.deep.equal([]); + }); + + it('does nothing if selection isnt supported', () => { + manager.setSelection(['1', '2']); + manager.setSelectable(false); + manager.clearSelection(); + expect(manager.getSelection()).to.deep.equal(['1', '2']); + }); + }); + + describe('Multi selection', () => { + it('selects multiple items', () => { + manager.select('1'); + manager.select('2'); + expect(manager.getSelection()).to.deep.equal(['1', '2']); + }); + + it('deselects multiple items', () => { + manager.setSelection(['1', '2', '3']); + manager.deselect('1'); + manager.deselect('2'); + expect(manager.getSelection()).to.deep.equal(['3']); + }); + + it('toggles multiple items', () => { + manager.toggleSelect('1'); + manager.toggleSelect('2'); + expect(manager.getSelection()).to.deep.equal(['1', '2']); + manager.toggleSelect('1'); + expect(manager.getSelection()).to.deep.equal(['2']); + }); + }); + + describe('Single selection', () => { + it('selects a single item', () => { + manager.setMultiple(false); + manager.select('1'); + manager.select('2'); + expect(manager.getSelection()).to.deep.equal(['2']); + }); + + it('keeps the first item if multiple is disabled mid selection', () => { + manager.select('1'); + manager.select('2'); + manager.setMultiple(false); + expect(manager.getSelection()).to.deep.equal(['1']); + }); + }); +}); diff --git a/src/Umbraco.Web.UI.Client/src/shared/utils/selection-manager.ts b/src/Umbraco.Web.UI.Client/src/shared/utils/selection-manager/selection.manager.ts similarity index 60% rename from src/Umbraco.Web.UI.Client/src/shared/utils/selection-manager.ts rename to src/Umbraco.Web.UI.Client/src/shared/utils/selection-manager/selection.manager.ts index b8d73d63e9..b71f1a66d6 100644 --- a/src/Umbraco.Web.UI.Client/src/shared/utils/selection-manager.ts +++ b/src/Umbraco.Web.UI.Client/src/shared/utils/selection-manager/selection.manager.ts @@ -1,3 +1,6 @@ +import { UmbBaseController } from '@umbraco-cms/backoffice/class-api'; +import { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { UmbSelectionChangeEvent } from '@umbraco-cms/backoffice/event'; import { UmbArrayState, UmbBooleanState } from '@umbraco-cms/backoffice/observable-api'; /** @@ -5,13 +8,38 @@ import { UmbArrayState, UmbBooleanState } from '@umbraco-cms/backoffice/observab * @export * @class UmbSelectionManager */ -export class UmbSelectionManager { +export class UmbSelectionManager extends UmbBaseController { + #selectable = new UmbBooleanState(false); + public readonly selectable = this.#selectable.asObservable(); + #selection = new UmbArrayState(>[], (x) => x); public readonly selection = this.#selection.asObservable(); #multiple = new UmbBooleanState(false); public readonly multiple = this.#multiple.asObservable(); + constructor(host: UmbControllerHost) { + super(host); + } + + /** + * Returns whether items can be selected. + * @return {*} + * @memberof UmbSelectionManager + */ + public getSelectable() { + return this.#selectable.getValue(); + } + + /** + * Sets whether items can be selected. + * @param {boolean} value + * @memberof UmbSelectionManager + */ + public setSelectable(value: boolean) { + this.#selectable.next(value); + } + /** * Returns the current selection. * @return {*} @@ -27,8 +55,10 @@ export class UmbSelectionManager { * @memberof UmbSelectionManager */ public setSelection(value: Array) { + if (this.getSelectable() === false) return; if (value === undefined) throw new Error('Value cannot be undefined'); - this.#selection.next(value); + const newSelection = this.getMultiple() ? value : [value[0]]; + this.#selection.next(newSelection); } /** @@ -47,6 +77,12 @@ export class UmbSelectionManager { */ public setMultiple(value: boolean) { this.#multiple.next(value); + + /* If multiple is set to false, and the current selection is more than one, + then we need to set the selection to the first item. */ + if (value === false && this.getSelection().length > 1) { + this.setSelection([this.getSelection()[0]]); + } } /** @@ -55,6 +91,7 @@ export class UmbSelectionManager { * @memberof UmbSelectionManager */ public toggleSelect(unique: string | null) { + if (this.getSelectable() === false) return; this.isSelected(unique) ? this.deselect(unique) : this.select(unique); } @@ -64,8 +101,11 @@ export class UmbSelectionManager { * @memberof UmbSelectionManager */ public select(unique: string | null) { + if (this.getSelectable() === false) return; + if (this.isSelected(unique)) return; const newSelection = this.getMultiple() ? [...this.getSelection(), unique] : [unique]; this.#selection.next(newSelection); + this.getHostElement().dispatchEvent(new UmbSelectionChangeEvent()); } /** @@ -74,8 +114,10 @@ export class UmbSelectionManager { * @memberof UmbSelectionManager */ public deselect(unique: string | null) { + if (this.getSelectable() === false) return; const newSelection = this.getSelection().filter((x) => x !== unique); this.#selection.next(newSelection); + this.getHostElement().dispatchEvent(new UmbSelectionChangeEvent()); } /** @@ -93,6 +135,7 @@ export class UmbSelectionManager { * @memberof UmbSelectionManager */ public clearSelection() { + if (this.getSelectable() === false) return; this.#selection.next([]); } }