diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/modal/token/member-type-picker-modal.token.ts b/src/Umbraco.Web.UI.Client/src/packages/core/modal/token/member-type-picker-modal.token.ts new file mode 100644 index 0000000000..cbb0d5f635 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/modal/token/member-type-picker-modal.token.ts @@ -0,0 +1,18 @@ +import { UmbModalToken, UmbPickerModalValue, UmbTreePickerModalData } from '@umbraco-cms/backoffice/modal'; +import { UmbEntityTreeItemModel } from '@umbraco-cms/backoffice/tree'; + +export type UmbMemberTypePickerModalData = UmbTreePickerModalData; +export type UmbMemberTypePickerModalValue = UmbPickerModalValue; + +export const UMB_MEMBER_TYPE_PICKER_MODAL = new UmbModalToken( + 'Umb.Modal.TreePicker', + { + modal: { + type: 'sidebar', + size: 'small', + }, + data: { + treeAlias: 'Umb.Tree.MemberType', + }, + }, +); diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/members/manifests.ts index f928869b69..a643b8be78 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/members/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/members/manifests.ts @@ -4,6 +4,8 @@ import { manifests as memberGroupManifests } from './member-groups/manifests.js' import { manifests as memberTypeManifests } from './member-types/manifests.js'; import { manifests as memberManifests } from './members/manifests.js'; +import './members/components/index.js'; + export const manifests = [ ...memberSectionManifests, ...menuSectionManifests, diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/member-types/components/index.ts b/src/Umbraco.Web.UI.Client/src/packages/members/member-types/components/index.ts new file mode 100644 index 0000000000..eacc86c77b --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/members/member-types/components/index.ts @@ -0,0 +1 @@ +import './input-member-type/input-member-type.element.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/member-types/components/input-member-type/input-member-type.context.ts b/src/Umbraco.Web.UI.Client/src/packages/members/member-types/components/input-member-type/input-member-type.context.ts new file mode 100644 index 0000000000..e2dbb59411 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/members/member-types/components/input-member-type/input-member-type.context.ts @@ -0,0 +1,13 @@ +import { UMB_MEMBER_TYPE_PICKER_MODAL } from '../../../../core/modal/token/member-type-picker-modal.token.js'; +import { UMB_MEMBER_TYPE_REPOSITORY_ALIAS } from '../../repository/index.js'; +import { UmbPickerInputContext } from '@umbraco-cms/backoffice/picker-input'; +import { UmbControllerHostElement } from '@umbraco-cms/backoffice/controller-api'; +import { MemberTypeItemResponseModel } from '@umbraco-cms/backoffice/backend-api'; + +export class UmbMemberTypePickerContext extends UmbPickerInputContext { + constructor(host: UmbControllerHostElement) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + super(host, UMB_MEMBER_TYPE_REPOSITORY_ALIAS, UMB_MEMBER_TYPE_PICKER_MODAL); + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/member-types/components/input-member-type/input-member-type.element.ts b/src/Umbraco.Web.UI.Client/src/packages/members/member-types/components/input-member-type/input-member-type.element.ts new file mode 100644 index 0000000000..12e3e64ffe --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/members/member-types/components/input-member-type/input-member-type.element.ts @@ -0,0 +1,179 @@ +import { UmbMemberTypePickerContext } from './input-member-type.context.js'; +import { css, html, customElement, property, state, ifDefined, repeat } from '@umbraco-cms/backoffice/external/lit'; +import { FormControlMixin } from '@umbraco-cms/backoffice/external/uui'; +import { UmbLitElement } from '@umbraco-cms/internal/lit-element'; +import type { MemberTypeItemResponseModel } from '@umbraco-cms/backoffice/backend-api'; +import { splitStringToArray } from '@umbraco-cms/backoffice/utils'; + +@customElement('umb-input-member-type') +export class UmbMemberTypeInputElement extends FormControlMixin(UmbLitElement) { + /** + * This is a minimum amount of selected items in this input. + * @type {number} + * @attr + * @default 0 + */ + @property({ type: Number }) + public get min(): number { + return this.#pickerContext.min; + } + public set min(value: number) { + this.#pickerContext.min = value; + } + + /** + * Min validation message. + * @type {boolean} + * @attr + * @default + */ + @property({ type: String, attribute: 'min-message' }) + minMessage = 'This field need more items'; + + /** + * This is a maximum amount of selected items in this input. + * @type {number} + * @attr + * @default Infinity + */ + @property({ type: Number }) + public get max(): number { + return this.#pickerContext.max; + } + public set max(value: number) { + this.#pickerContext.max = value; + } + + /** + * Max validation message. + * @type {boolean} + * @attr + * @default + */ + @property({ type: String, attribute: 'min-message' }) + maxMessage = 'This field exceeds the allowed amount of items'; + + public get selectedIds(): Array { + return this.#pickerContext.getSelection(); + } + public set selectedIds(ids: Array) { + this.#pickerContext.setSelection(ids); + } + + @property() + public set value(idsString: string) { + // Its with full purpose we don't call super.value, as thats being handled by the observation of the context selection. + this.selectedIds = splitStringToArray(idsString); + } + + @property() + get pickableFilter() { + return this.#pickerContext.pickableFilter; + } + set pickableFilter(newVal) { + this.#pickerContext.pickableFilter = newVal; + } + + @state() + private _items?: Array; + + #pickerContext = new UmbMemberTypePickerContext(this); + + constructor() { + super(); + } + + connectedCallback() { + super.connectedCallback(); + + this.addValidator( + 'rangeUnderflow', + () => this.minMessage, + () => !!this.min && this.#pickerContext.getSelection().length < this.min, + ); + + this.addValidator( + 'rangeOverflow', + () => this.maxMessage, + () => !!this.max && this.#pickerContext.getSelection().length > this.max, + ); + + this.observe(this.#pickerContext.selection, (selection) => (super.value = selection.join(','))); + this.observe(this.#pickerContext.selectedItems, (selectedItems) => (this._items = selectedItems)); + } + + protected _openPicker() { + this.#pickerContext.openPicker({ + hideTreeRoot: true, + }); + } + + protected getFormElement() { + return undefined; + } + + render() { + return html` + ${this.#renderItems()} + ${this.#renderAddButton()} + `; + } + + #renderItems() { + if (!this._items) return; + // TODO: Add sorting. [LK] + return html` + ${repeat( + this._items, + (item) => item.id, + (item) => this._renderItem(item), + )} + `; + } + + #renderAddButton() { + if (this.max > 0 && this.selectedIds.length >= this.max) return; + return html` + ${this.localize.term('general_choose')} + `; + } + + private _renderItem(item: MemberTypeItemResponseModel) { + if (!item.id) return; + return html` + + + this.#pickerContext.requestRemoveItem(item.id!)} + label="Remove Member Type ${item.name}" + >${this.localize.term('general_remove')} + + + `; + } + + static styles = [ + css` + #add-button { + width: 100%; + } + `, + ]; +} + +export default UmbMemberTypeInputElement; + +declare global { + interface HTMLElementTagNameMap { + 'umb-input-member-type': UmbMemberTypeInputElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/member-types/index.ts b/src/Umbraco.Web.UI.Client/src/packages/members/member-types/index.ts new file mode 100644 index 0000000000..48ebd24242 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/members/member-types/index.ts @@ -0,0 +1,5 @@ +import './components/index.js'; + +export * from './components/index.js'; +export * from './repository/index.js'; +export * from './entity.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/member-types/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/members/member-types/manifests.ts index c2dbcfe939..47e4ffa227 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/members/member-types/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/members/member-types/manifests.ts @@ -1,13 +1,15 @@ +import { manifests as entityActionsManifests } from './entity-actions/manifests.js'; import { manifests as menuItemManifests } from './menu-item/manifests.js'; -import { manifests as treeManifests } from './tree/manifests.js'; import { manifests as repositoryManifests } from './repository/manifests.js'; +import { manifests as treeManifests } from './tree/manifests.js'; import { manifests as workspaceManifests } from './workspace/manifests.js'; -import { manifests as entityActionManifests } from './entity-actions/manifests.js'; + +import './components/index.js'; export const manifests = [ + ...entityActionsManifests, ...menuItemManifests, - ...treeManifests, ...repositoryManifests, + ...treeManifests, ...workspaceManifests, - ...entityActionManifests, ]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/member-types/repository/index.ts b/src/Umbraco.Web.UI.Client/src/packages/members/member-types/repository/index.ts index 8cddbed0b2..8be388a62c 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/members/member-types/repository/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/members/member-types/repository/index.ts @@ -1 +1,2 @@ -export { UmbMemberTypeRepository } from './member-type.repository.js'; +export * from './member-type.repository.js'; +export * from './manifests.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/members/components/index.ts b/src/Umbraco.Web.UI.Client/src/packages/members/members/components/index.ts new file mode 100644 index 0000000000..90f47707f8 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/members/members/components/index.ts @@ -0,0 +1,3 @@ +import './input-member/input-member.element.js'; + +export * from './input-member/input-member.element.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/members/components/input-member/input-member.element.ts b/src/Umbraco.Web.UI.Client/src/packages/members/members/components/input-member/input-member.element.ts new file mode 100644 index 0000000000..d1106e1f2e --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/members/members/components/input-member/input-member.element.ts @@ -0,0 +1,171 @@ +import { css, html, customElement, property, state, ifDefined, repeat } from '@umbraco-cms/backoffice/external/lit'; +import { FormControlMixin } from '@umbraco-cms/backoffice/external/uui'; +import { UmbLitElement } from '@umbraco-cms/internal/lit-element'; +import type { MemberItemResponseModel } from '@umbraco-cms/backoffice/backend-api'; +import { splitStringToArray } from '@umbraco-cms/backoffice/utils'; + +@customElement('umb-input-member') +export class UmbInputMemberElement extends FormControlMixin(UmbLitElement) { + /** + * This is a minimum amount of selected items in this input. + * @type {number} + * @attr + * @default 0 + */ + @property({ type: Number }) + public get min(): number { + //return this.#pickerContext.min; + return 0; + } + public set min(value: number) { + //this.#pickerContext.min = value; + } + + /** + * Min validation message. + * @type {boolean} + * @attr + * @default + */ + @property({ type: String, attribute: 'min-message' }) + minMessage = 'This field need more items'; + + /** + * This is a maximum amount of selected items in this input. + * @type {number} + * @attr + * @default Infinity + */ + @property({ type: Number }) + public get max(): number { + //return this.#pickerContext.max; + return Infinity; + } + public set max(value: number) { + //this.#pickerContext.max = value; + } + + /** + * Max validation message. + * @type {boolean} + * @attr + * @default + */ + @property({ type: String, attribute: 'min-message' }) + maxMessage = 'This field exceeds the allowed amount of items'; + + public get selectedIds(): Array { + //return this.#pickerContext.getSelection(); + return []; + } + public set selectedIds(ids: Array) { + //this.#pickerContext.setSelection(ids); + } + + @property() + public set value(idsString: string) { + // Its with full purpose we don't call super.value, as thats being handled by the observation of the context selection. + this.selectedIds = splitStringToArray(idsString); + } + + @state() + private _items?: Array; + + // TODO: Create the `UmbMemberPickerContext` [LK] + //#pickerContext = new UmbMemberPickerContext(this); + + constructor() { + super(); + + // this.addValidator( + // 'rangeUnderflow', + // () => this.minMessage, + // () => !!this.min && this.#pickerContext.getSelection().length < this.min, + // ); + + // this.addValidator( + // 'rangeOverflow', + // () => this.maxMessage, + // () => !!this.max && this.#pickerContext.getSelection().length > this.max, + // ); + + // this.observe(this.#pickerContext.selection, (selection) => (super.value = selection.join(','))); + // this.observe(this.#pickerContext.selectedItems, (selectedItems) => (this._items = selectedItems)); + } + + protected _openPicker() { + console.log("member.openPicker"); + // this.#pickerContext.openPicker({ + // hideTreeRoot: true, + // }); + } + + protected _requestRemoveItem(item: MemberItemResponseModel){ + console.log("member.requestRemoveItem", item); + //this.#pickerContext.requestRemoveItem(item.id!); + } + + protected getFormElement() { + return undefined; + } + + render() { + return html` + ${this.#renderItems()} + ${this.#renderAddButton()} + `; + } + + #renderItems() { + if (!this._items) return; + // TODO: Add sorting. [LK] + return html`${repeat( + this._items, + (item) => item.id, + (item) => this._renderItem(item), + )} + `; + } + + #renderAddButton() { + if (this.max > 0 && this.selectedIds.length >= this.max) return; + return html``; + } + + private _renderItem(item: MemberItemResponseModel) { + if (!item.id) return; + return html` + + + + this._requestRemoveItem(item)} + label="Remove member ${item.name}" + >Remove + + + `; + } + + static styles = [ + css` + #add-button { + width: 100%; + } + `, + ]; +} + +export default UmbInputMemberElement; + +declare global { + interface HTMLElementTagNameMap { + 'umb-input-member': UmbInputMemberElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/members/components/input-member/input-member.stories.ts b/src/Umbraco.Web.UI.Client/src/packages/members/members/components/input-member/input-member.stories.ts new file mode 100644 index 0000000000..7bca7fc5db --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/members/members/components/input-member/input-member.stories.ts @@ -0,0 +1,14 @@ +import { Meta, StoryObj } from '@storybook/web-components'; +import './input-member.element.js'; +import type { UmbInputMemberElement } from './input-member.element.js'; + +const meta: Meta = { + title: 'Components/Inputs/Member', + component: 'umb-input-member', +}; + +export default meta; +type Story = StoryObj; +export const Overview: Story = { + args: {}, +}; diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/members/components/input-member/input-member.test.ts b/src/Umbraco.Web.UI.Client/src/packages/members/members/components/input-member/input-member.test.ts new file mode 100644 index 0000000000..7bef94720e --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/members/members/components/input-member/input-member.test.ts @@ -0,0 +1,20 @@ +import { expect, fixture, html } from '@open-wc/testing'; +import { UmbInputMemberElement } from './input-member.element.js'; +import { defaultA11yConfig } from '@umbraco-cms/internal/test-utils'; +describe('UmbInputMemberElement', () => { + let element: UmbInputMemberElement; + + beforeEach(async () => { + element = await fixture(html` `); + }); + + it('is defined with its own instance', () => { + expect(element).to.be.instanceOf(UmbInputMemberElement); + }); + + if ((window as any).__UMBRACO_TEST_RUN_A11Y_TEST) { + it('passes the a11y audit', async () => { + await expect(element).shadowDom.to.be.accessible(defaultA11yConfig); + }); + } +}); diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/members/index.ts b/src/Umbraco.Web.UI.Client/src/packages/members/members/index.ts index 3d76f338dd..f23e6176e5 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/members/members/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/members/members/index.ts @@ -1 +1,4 @@ +import './components/index.js'; + +export * from './components/index.js'; export * from './repository/index.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/settings/languages/entity-actions/language-create-entity-action.ts b/src/Umbraco.Web.UI.Client/src/packages/settings/languages/entity-actions/language-create-entity-action.ts index 908018aa52..aae26d3732 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/settings/languages/entity-actions/language-create-entity-action.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/settings/languages/entity-actions/language-create-entity-action.ts @@ -8,8 +8,8 @@ export class UmbLanguageCreateEntityAction extends UmbEntityActionBase) { if (this.getSelectable() === false) return; if (value === undefined) throw new Error('Value cannot be undefined'); - const newSelection = this.getMultiple() ? value : [value[0]]; + const newSelection = this.getMultiple() ? value : value.slice(0, 1); this.#selection.next(newSelection); } @@ -78,7 +78,7 @@ export class UmbSelectionManager extends UmbBaseController { public setMultiple(value: boolean) { this.#multiple.next(value); - /* If multiple is set to false, and the current selection is more than one, + /* 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]]);