From c6c45cf6cc1b6772189de5aac14d6d38df59a849 Mon Sep 17 00:00:00 2001 From: leekelleher Date: Wed, 20 Dec 2023 17:39:18 +0000 Subject: [PATCH 1/5] [WIP] Adds Member Type picker A placeholder component for ``, for use with the Multinode Treepicker property-editor. Code files were largely duplicated from `umb-document-type-input`. The modal context code hasn't been copied over as it requires substantial work with developing the Member Type repository. --- .../token/member-type-picker-modal.token.ts | 18 ++ .../members/member-types/components/index.ts | 1 + .../input-member-type.context.ts | 13 ++ .../input-member-type.element.ts | 179 ++++++++++++++++++ .../packages/members/member-types/index.ts | 5 + .../members/member-types/manifests.ts | 10 +- .../members/member-types/repository/index.ts | 3 +- 7 files changed, 224 insertions(+), 5 deletions(-) create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/modal/token/member-type-picker-modal.token.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/members/member-types/components/index.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/members/member-types/components/input-member-type/input-member-type.context.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/members/member-types/components/input-member-type/input-member-type.element.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/members/member-types/index.ts 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/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'; From 550c7710cc3fa271bfe87ce82e9fabfc2a6bf1ee Mon Sep 17 00:00:00 2001 From: leekelleher Date: Wed, 3 Jan 2024 15:29:15 +0000 Subject: [PATCH 2/5] [WIP] Input Member picker A placeholder component for ``, for use with the Multinode Treepicker property-editor. Code files were largely duplicated from `input-document`. The modal context code hasn't been copied over as it requires substantial work with developing the Member repository. --- .../src/packages/members/manifests.ts | 2 + .../members/members/components/index.ts | 3 + .../input-member/input-member.element.ts | 171 ++++++++++++++++++ .../input-member/input-member.stories.ts | 14 ++ .../input-member/input-member.test.ts | 20 ++ .../src/packages/members/members/index.ts | 3 + 6 files changed, 213 insertions(+) create mode 100644 src/Umbraco.Web.UI.Client/src/packages/members/members/components/index.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/members/members/components/input-member/input-member.element.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/members/members/components/input-member/input-member.stories.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/members/members/components/input-member/input-member.test.ts 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/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'; From d67f4f26effdcad74b20e5b7be85470459893cc2 Mon Sep 17 00:00:00 2001 From: leekelleher Date: Wed, 3 Jan 2024 12:04:35 +0000 Subject: [PATCH 3/5] Selection manager: fixes initial value When the modal initially opens, the `#multiple` is `false`, (regardless of how it is configured), so the initial value is set to `[undefined]`. Then when the `#multiple` is observed as `true` (there must be an underlying bug with the modal context code here), then the `#selection` array already has an initial value of `[undefined]` so will append newly selected values to that array. I discovered this issue due to a bug with the Tree Picker editor. --- .../src/shared/utils/selection-manager/selection.manager.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/shared/utils/selection-manager/selection.manager.ts b/src/Umbraco.Web.UI.Client/src/shared/utils/selection-manager/selection.manager.ts index b71f1a66d6..ff63bfd93d 100644 --- a/src/Umbraco.Web.UI.Client/src/shared/utils/selection-manager/selection.manager.ts +++ b/src/Umbraco.Web.UI.Client/src/shared/utils/selection-manager/selection.manager.ts @@ -57,7 +57,7 @@ export class UmbSelectionManager extends UmbBaseController { public setSelection(value: Array) { 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.length > 0 ? [value[0]] : value; 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]]); From 877274cac47d30114c1b75d418817ee4159bcc56 Mon Sep 17 00:00:00 2001 From: leekelleher Date: Thu, 4 Jan 2024 15:36:19 +0000 Subject: [PATCH 4/5] Refactored the initial selection value --- .../src/shared/utils/selection-manager/selection.manager.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Umbraco.Web.UI.Client/src/shared/utils/selection-manager/selection.manager.ts b/src/Umbraco.Web.UI.Client/src/shared/utils/selection-manager/selection.manager.ts index ff63bfd93d..477a6d3daa 100644 --- a/src/Umbraco.Web.UI.Client/src/shared/utils/selection-manager/selection.manager.ts +++ b/src/Umbraco.Web.UI.Client/src/shared/utils/selection-manager/selection.manager.ts @@ -57,7 +57,7 @@ export class UmbSelectionManager extends UmbBaseController { public setSelection(value: Array) { if (this.getSelectable() === false) return; if (value === undefined) throw new Error('Value cannot be undefined'); - const newSelection = this.getMultiple() ? value : value.length > 0 ? [value[0]] : value; + const newSelection = this.getMultiple() ? value : value.slice(0, 1); this.#selection.next(newSelection); } From 0e7fca53634e780222288be4f069fda77cac360e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesper=20M=C3=B8ller=20Jensen?= <26099018+JesmoDev@users.noreply.github.com> Date: Wed, 13 Dec 2023 11:53:56 +1300 Subject: [PATCH 5/5] Dont do full page reload when creating language from the entity action button --- .../entity-actions/language-create-entity-action.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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