diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/index.ts b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/index.ts index 7f1622bfd3..10a47367aa 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/index.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/index.ts @@ -14,5 +14,7 @@ import './section/section-sidebar/section-sidebar.element'; import './section/section.element'; import './tree/tree.element'; import './workspace/workspace-content/workspace-content.element'; +import './input-media-picker/input-media-picker.element'; +import './input-document-picker/input-document-picker.element'; import './empty-state/empty-state.element'; import './color-picker/color-picker.element'; diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/input-checkbox-list/input-checkbox-list.element.ts b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/input-checkbox-list/input-checkbox-list.element.ts new file mode 100644 index 0000000000..98d3dbdc59 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/input-checkbox-list/input-checkbox-list.element.ts @@ -0,0 +1,81 @@ +import { css, html, nothing } from 'lit'; +import { UUITextStyles } from '@umbraco-ui/uui-css/lib'; +import { customElement, property, state } from 'lit/decorators.js'; +import { FormControlMixin } from '@umbraco-ui/uui-base/lib/mixins'; +import { repeat } from 'lit/directives/repeat.js'; +import { UUIBooleanInputEvent } from '@umbraco-ui/uui'; +import { UmbLitElement } from '@umbraco-cms/element'; + +@customElement('umb-input-checkbox-list') +export class UmbInputCheckboxListElement extends FormControlMixin(UmbLitElement) { + static styles = [ + UUITextStyles, + css` + uui-checkbox { + width: 100%; + } + `, + ]; + + /** + * List of items. + */ + @property() + list?: []; + + private _selectedKeys: Array = []; + public get selectedKeys(): Array { + return this._selectedKeys; + } + public set selectedKeys(keys: Array) { + this._selectedKeys = keys; + super.value = keys.join(','); + } + + @property() + public set value(keysString: string) { + if (keysString !== this._value) { + this.selectedKeys = keysString.split(/[ ,]+/); + } + } + + protected getFormElement() { + return undefined; + } + + private _setSelection(e: UUIBooleanInputEvent) { + e.stopPropagation(); + if (e.target.checked) this.selectedKeys = [...this.selectedKeys, e.target.value]; + else this._removeFromSelection(this.selectedKeys.findIndex((key) => e.target.value === key)); + + this.dispatchEvent(new CustomEvent('change', { bubbles: true, composed: true })); + } + + private _removeFromSelection(index: number) { + if (index == -1) return; + const keys = [...this.selectedKeys]; + keys.splice(index, 1); + this.selectedKeys = keys; + } + + render() { + if (!this.list) return nothing; + return html`
+ + ${repeat(this.list, (item) => item.key, this.renderCheckbox)} + +
`; + } + + renderCheckbox(item: any) { + return html``; + } +} + +export default UmbInputCheckboxListElement; + +declare global { + interface HTMLElementTagNameMap { + 'umb-input-checkbox-list': UmbInputCheckboxListElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/input-document-picker/input-document-picker.element.ts b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/input-document-picker/input-document-picker.element.ts index d02dc7ed63..698c17cc11 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/input-document-picker/input-document-picker.element.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/input-document-picker/input-document-picker.element.ts @@ -20,7 +20,6 @@ export class UmbInputDocumentPickerElement extends FormControlMixin(UmbLitElemen } `, ]; - /** * This is a minimum amount of selected items in this input. * @type {number} @@ -122,7 +121,10 @@ export class UmbInputDocumentPickerElement extends FormControlMixin(UmbLitElemen private _openPicker() { // We send a shallow copy(good enough as its just an array of keys) of our this._selectedKeys, as we don't want the modal to manipulate our data: - const modalHandler = this._modalService?.contentPicker({ multiple: true, selection: [...this._selectedKeys] }); + const modalHandler = this._modalService?.contentPicker({ + multiple: this.max === 1 ? false : true, + selection: [...this._selectedKeys], + }); modalHandler?.onClose().then(({ selection }: any) => { this._setSelection(selection); }); diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/input-document-picker/input-document-picker.test.ts b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/input-document-picker/input-document-picker.test.ts new file mode 100644 index 0000000000..b8841ac2db --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/input-document-picker/input-document-picker.test.ts @@ -0,0 +1,18 @@ +import { expect, fixture, html } from '@open-wc/testing'; +import { UmbInputDocumentPickerElement } from './input-document-picker.element'; +import { defaultA11yConfig } from '@umbraco-cms/test-utils'; +describe('UmbPropertyEditorUINumberRangeElement', () => { + let element: UmbInputDocumentPickerElement; + + beforeEach(async () => { + element = await fixture(html` `); + }); + + it('is defined with its own instance', () => { + expect(element).to.be.instanceOf(UmbInputDocumentPickerElement); + }); + + it('passes the a11y audit', async () => { + await expect(element).shadowDom.to.be.accessible(defaultA11yConfig); + }); +}); diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/input-media-picker/input-media-picker.element.ts b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/input-media-picker/input-media-picker.element.ts new file mode 100644 index 0000000000..4c2adfe105 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/input-media-picker/input-media-picker.element.ts @@ -0,0 +1,210 @@ +import { css, html, nothing } from 'lit'; +import { UUITextStyles } from '@umbraco-ui/uui-css/lib'; +import { customElement, property, state } from 'lit/decorators.js'; +import { ifDefined } from 'lit-html/directives/if-defined.js'; +import { FormControlMixin } from '@umbraco-ui/uui-base/lib/mixins'; +import { UmbModalService, UMB_MODAL_SERVICE_CONTEXT_TOKEN } from '../../../../core/modal'; +import { + MediaTreeItem, + UmbMediaTreeStore, + UMB_MEDIA_TREE_STORE_CONTEXT_TOKEN, +} from '../../../../backoffice/media/media/media.tree.store'; +import { UmbLitElement } from '@umbraco-cms/element'; +import type { FolderTreeItem } from '@umbraco-cms/backend-api'; +import type { UmbObserverController } from '@umbraco-cms/observable-api'; + +@customElement('umb-input-media-picker') +export class UmbInputMediaPickerElement extends FormControlMixin(UmbLitElement) { + static styles = [ + UUITextStyles, + css` + :host { + display: grid; + gap: var(--uui-size-space-3); + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + } + #add-button { + text-align: center; + min-height: 160px; + } + uui-icon { + display: block; + margin: 0 auto; + } + `, + ]; + + /** + * This is a minimum amount of selected items in this input. + * @type {number} + * @attr + * @default undefined + */ + @property({ type: Number }) + min?: number; + + /** + * 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 undefined + */ + @property({ type: Number }) + max?: number; + + /** + * Max validation message. + * @type {boolean} + * @attr + * @default + */ + @property({ type: String, attribute: 'min-message' }) + maxMessage = 'This field exceeds the allowed amount of items'; + + // TODO: do we need both selectedKeys and value? If we just use value we follow the same pattern as native form controls. + private _selectedKeys: Array = []; + public get selectedKeys(): Array { + return this._selectedKeys; + } + public set selectedKeys(keys: Array) { + this._selectedKeys = keys; + super.value = keys.join(','); + this._observePickedMedias(); + } + + @property() + public set value(keysString: string) { + if (keysString !== this._value) { + this.selectedKeys = keysString.split(/[ ,]+/); + } + } + + @state() + private _items?: Array; + + private _modalService?: UmbModalService; + private _mediaStore?: UmbMediaTreeStore; + private _pickedItemsObserver?: UmbObserverController; + + constructor() { + super(); + + this.addValidator( + 'rangeUnderflow', + () => this.minMessage, + () => !!this.min && this._selectedKeys.length < this.min + ); + this.addValidator( + 'rangeOverflow', + () => this.maxMessage, + () => !!this.max && this._selectedKeys.length > this.max + ); + + this.consumeContext(UMB_MEDIA_TREE_STORE_CONTEXT_TOKEN, (instance) => { + this._mediaStore = instance; + this._observePickedMedias(); + }); + this.consumeContext(UMB_MODAL_SERVICE_CONTEXT_TOKEN, (instance) => { + this._modalService = instance; + }); + } + + protected getFormElement() { + return undefined; + } + + private _observePickedMedias() { + this._pickedItemsObserver?.destroy(); + + if (!this._mediaStore) return; + + // TODO: consider changing this to the list data endpoint when it is available + this._pickedItemsObserver = this.observe(this._mediaStore.getTreeItems(this._selectedKeys), (items) => { + this._items = items; + }); + } + + private _openPicker() { + // We send a shallow copy(good enough as its just an array of keys) of our this._selectedKeys, as we don't want the modal to manipulate our data: + const modalHandler = this._modalService?.mediaPicker({ + multiple: this.max === 1 ? false : true, + selection: [...this._selectedKeys], + }); + modalHandler?.onClose().then(({ selection }: any) => { + this._setSelection(selection); + }); + } + + private _removeItem(item: FolderTreeItem) { + const modalHandler = this._modalService?.confirm({ + color: 'danger', + headline: `Remove ${item.name}?`, + content: 'Are you sure you want to remove this item', + confirmLabel: 'Remove', + }); + + modalHandler?.onClose().then(({ confirmed }) => { + if (confirmed) { + const newSelection = this._selectedKeys.filter((value) => value !== item.key); + this._setSelection(newSelection); + } + }); + } + + private _setSelection(newSelection: Array) { + this.selectedKeys = newSelection; + this.dispatchEvent(new CustomEvent('change', { bubbles: true, composed: true })); + console.log(this._items); + } + + render() { + return html` ${this._items?.map((item) => this._renderItem(item))} ${this._renderButton()} `; + } + private _renderButton() { + if (this.max == 1 && this._items && this._items.length > 0) return; + return html` + + Add + `; + } + + private _renderItem(item: FolderTreeItem) { + // TODO: remove when we have a way to handle trashed items + const tempItem = item as FolderTreeItem & { isTrashed: boolean }; + + return html` + + ${tempItem.isTrashed ? html` Trashed ` : nothing} + + + + + this._removeItem(item)} label="Remove media ${item.name}"> + + + + + `; + //TODO: + } +} + +export default UmbInputMediaPickerElement; + +declare global { + interface HTMLElementTagNameMap { + 'umb-input-media-picker': UmbInputMediaPickerElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/input-media-picker/input-media-picker.test.ts b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/input-media-picker/input-media-picker.test.ts new file mode 100644 index 0000000000..c101ab99ae --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/input-media-picker/input-media-picker.test.ts @@ -0,0 +1,18 @@ +import { expect, fixture, html } from '@open-wc/testing'; +import { UmbInputMediaPickerElement } from './input-media-picker.element'; +import { defaultA11yConfig } from '@umbraco-cms/test-utils'; +describe('UmbPropertyEditorUINumberRangeElement', () => { + let element: UmbInputMediaPickerElement; + + beforeEach(async () => { + element = await fixture(html` `); + }); + + it('is defined with its own instance', () => { + expect(element).to.be.instanceOf(UmbInputMediaPickerElement); + }); + + it('passes the a11y audit', async () => { + await expect(element).shadowDom.to.be.accessible(defaultA11yConfig); + }); +}); diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/shared/property-editors/uis/checkbox-list/property-editor-ui-checkbox-list.element.ts b/src/Umbraco.Web.UI.Client/src/backoffice/shared/property-editors/uis/checkbox-list/property-editor-ui-checkbox-list.element.ts index 0c501fc88b..a522d15741 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/shared/property-editors/uis/checkbox-list/property-editor-ui-checkbox-list.element.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/shared/property-editors/uis/checkbox-list/property-editor-ui-checkbox-list.element.ts @@ -1,7 +1,10 @@ import { html } from 'lit'; -import { customElement, property } from 'lit/decorators.js'; +import { customElement, property, state } from 'lit/decorators.js'; import { UUITextStyles } from '@umbraco-ui/uui-css/lib'; +import '../../../components/input-checkbox-list/input-checkbox-list.element'; +import { UmbInputCheckboxListElement } from '../../../components/input-checkbox-list/input-checkbox-list.element'; import { UmbLitElement } from '@umbraco-cms/element'; +import type { DataTypePropertyData } from '@umbraco-cms/models'; /** * @element umb-property-editor-ui-checkbox-list @@ -10,14 +13,37 @@ import { UmbLitElement } from '@umbraco-cms/element'; export class UmbPropertyEditorUICheckboxListElement extends UmbLitElement { static styles = [UUITextStyles]; - @property() - value = ''; + private _value: Array = []; + @property({ type: Array }) + public get value(): Array { + return this._value; + } + public set value(value: Array) { + this._value = value || []; + } @property({ type: Array, attribute: false }) - public config = []; + public set config(config: Array) { + const listData = config.find((x) => x.alias === 'itemList'); + + if (!listData) return; + this._list = listData.value; + } + + @state() + private _list: [] = []; + + private _onChange(event: CustomEvent) { + this.value = (event.target as UmbInputCheckboxListElement).selectedKeys; + this.dispatchEvent(new CustomEvent('property-value-change')); + console.log(this._value); + } render() { - return html`
umb-property-editor-ui-checkbox-list
`; + return html``; } } diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/shared/property-editors/uis/document-picker/property-editor-ui-document-picker.element.ts b/src/Umbraco.Web.UI.Client/src/backoffice/shared/property-editors/uis/document-picker/property-editor-ui-document-picker.element.ts index 13254a1cf4..202b47cbef 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/shared/property-editors/uis/document-picker/property-editor-ui-document-picker.element.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/shared/property-editors/uis/document-picker/property-editor-ui-document-picker.element.ts @@ -1,8 +1,7 @@ import { html } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; +import { UmbInputDocumentPickerElement } from '../../../components/input-document-picker/input-document-picker.element'; import { UmbLitElement } from '@umbraco-cms/element'; -import type { UmbInputDocumentPickerElement } from 'src/backoffice/shared/components/input-document-picker/input-document-picker.element'; -import '../../../components/input-document-picker/input-document-picker.element'; import type { DataTypePropertyData } from '@umbraco-cms/models'; @customElement('umb-property-editor-ui-document-picker') diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/shared/property-editors/uis/media-picker/property-editor-ui-media-picker.element.ts b/src/Umbraco.Web.UI.Client/src/backoffice/shared/property-editors/uis/media-picker/property-editor-ui-media-picker.element.ts index b7c18cd63b..e96df3ce5f 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/shared/property-editors/uis/media-picker/property-editor-ui-media-picker.element.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/shared/property-editors/uis/media-picker/property-editor-ui-media-picker.element.ts @@ -1,23 +1,53 @@ import { html } from 'lit'; -import { customElement, property } from 'lit/decorators.js'; -import { UUITextStyles } from '@umbraco-ui/uui-css/lib'; +import { customElement, property, state } from 'lit/decorators.js'; +import { UmbInputMediaPickerElement } from '../../../../../backoffice/shared/components/input-media-picker/input-media-picker.element'; import { UmbLitElement } from '@umbraco-cms/element'; +import type { DataTypePropertyData } from '@umbraco-cms/models'; /** * @element umb-property-editor-ui-media-picker */ @customElement('umb-property-editor-ui-media-picker') export class UmbPropertyEditorUIMediaPickerElement extends UmbLitElement { - static styles = [UUITextStyles]; + private _value: Array = []; - @property() - value = ''; + @property({ type: Array }) + public get value(): Array { + return this._value; + } + public set value(value: Array) { + this._value = value || []; + } @property({ type: Array, attribute: false }) - public config = []; + public set config(config: Array) { + const validationLimit = config.find((x) => x.alias === 'validationLimit'); + if (!validationLimit) return; + + this._limitMin = (validationLimit?.value as any).min; + this._limitMax = (validationLimit?.value as any).max; + } + + @state() + private _limitMin?: number; + @state() + private _limitMax?: number; + + private _onChange(event: CustomEvent) { + this.value = (event.target as UmbInputMediaPickerElement).selectedKeys; + this.dispatchEvent(new CustomEvent('property-value-change')); + } render() { - return html`
umb-property-editor-ui-media-picker
`; + return html` + Add + `; } } diff --git a/src/Umbraco.Web.UI.Client/src/core/mocks/data/data-type.data.ts b/src/Umbraco.Web.UI.Client/src/core/mocks/data/data-type.data.ts index 524c135b00..da58a9bb6b 100644 --- a/src/Umbraco.Web.UI.Client/src/core/mocks/data/data-type.data.ts +++ b/src/Umbraco.Web.UI.Client/src/core/mocks/data/data-type.data.ts @@ -264,7 +264,15 @@ export const data: Array = [ isFolder: false, propertyEditorModelAlias: 'Umbraco.CheckboxList', propertyEditorUIAlias: 'Umb.PropertyEditorUI.CheckboxList', - data: [], + data: [ + { + alias: 'itemList', + value: [ + { label: 'Label 1', key: '123' }, + { label: 'Label 2', key: '456' }, + ], + }, + ], }, { name: 'Block List',