Merge branch 'main' into v14/feature/readonly-media-picker-property-editor-ui

This commit is contained in:
Mads Rasmussen
2024-08-21 21:35:42 +02:00
committed by GitHub
7 changed files with 350 additions and 25 deletions

View File

@@ -0,0 +1,205 @@
import { UmbSorterController } from './sorter.controller.js';
import { aTimeout, expect, fixture, html } from '@open-wc/testing';
import { customElement } from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '../lit-element/lit-element.element.js';
@customElement('test-my-sorter')
class UmbSorterTestElement extends UmbLitElement {
sorter = new UmbSorterController<string, HTMLElement>(this, {
getUniqueOfElement: (element) => {
return element.id;
},
getUniqueOfModel: (modelEntry) => {
return modelEntry;
},
identifier: 'Umb.SorterIdentifier.Test',
itemSelector: '.item',
containerSelector: '#container',
disabledItemSelector: '.disabled',
onChange: ({ model }) => {
this.model = model;
},
});
getAllItems() {
return Array.from(this.shadowRoot!.querySelectorAll('.item')) as HTMLElement[];
}
getSortableItems() {
return Array.from(this.shadowRoot!.querySelectorAll('.item:not(.disabled')) as HTMLElement[];
}
getDisabledItems() {
return Array.from(this.shadowRoot!.querySelectorAll('.item.disabled')) as HTMLElement[];
}
override render() {
return html`<div id="container">
<div id="1" class="item">Item 1</div>
<div id="2" class="item">Item 2</div>
<div id="3" class="item disabled">Item 3</div>
<div id="4" class="item">Item 4</div>
</div>`;
}
}
describe('UmbSorterController', () => {
let element: UmbSorterTestElement;
beforeEach(async () => {
element = await fixture(html`<test-my-sorter></test-my-sorter>`);
await aTimeout(10);
});
it('is defined with its own instance', () => {
expect(element).to.be.instanceOf(UmbSorterTestElement);
expect(element.sorter).to.be.instanceOf(UmbSorterController);
});
describe('Public API', () => {
describe('methods', () => {
it('has a enable method', () => {
expect(element.sorter).to.have.property('enable').that.is.a('function');
});
it('has a disable method', () => {
expect(element.sorter).to.have.property('disable').that.is.a('function');
});
it('has a setModel method', () => {
expect(element.sorter).to.have.property('setModel').that.is.a('function');
});
it('has a hasItem method', () => {
expect(element.sorter).to.have.property('hasItem').that.is.a('function');
});
it('has a getItem method', () => {
expect(element.sorter).to.have.property('getItem').that.is.a('function');
});
it('has a setupItem method', () => {
expect(element.sorter).to.have.property('setupItem').that.is.a('function');
});
it('has a destroyItem method', () => {
expect(element.sorter).to.have.property('destroyItem').that.is.a('function');
});
it('has a hasOtherItemsThan method', () => {
expect(element.sorter).to.have.property('hasOtherItemsThan').that.is.a('function');
});
it('has a moveItemInModel method', () => {
expect(element.sorter).to.have.property('moveItemInModel').that.is.a('function');
});
it('has a updateAllowIndication method', () => {
expect(element.sorter).to.have.property('updateAllowIndication').that.is.a('function');
});
it('has a removeAllowIndication method', () => {
expect(element.sorter).to.have.property('removeAllowIndication').that.is.a('function');
});
it('has a notifyDisallowed method', () => {
expect(element.sorter).to.have.property('notifyDisallowed').that.is.a('function');
});
it('has a notifyRequestDrop method', () => {
expect(element.sorter).to.have.property('notifyRequestDrop').that.is.a('function');
});
it('has a destroy method', () => {
expect(element.sorter).to.have.property('destroy').that.is.a('function');
});
});
});
describe('Init', () => {
it('should find all items', () => {
const items = element.getAllItems();
expect(items.length).to.equal(4);
});
it('sets all allowed draggable items to draggable', () => {
const items = element.getSortableItems();
expect(items.length).to.equal(3);
items.forEach((item) => {
expect(item.draggable).to.be.true;
});
});
it('sets all disabled items non draggable', () => {
const items = element.getDisabledItems();
expect(items.length).to.equal(1);
items.forEach((item) => {
expect(item.draggable).to.be.false;
});
});
});
describe('disable', () => {
it('sets all items to non draggable', () => {
element.sorter.disable();
const items = element.getAllItems();
items.forEach((item) => {
expect(item.draggable).to.be.false;
});
});
});
describe('enable', () => {
it('sets all allowed items to draggable', () => {
const items = element.getSortableItems();
expect(items.length).to.equal(3);
items.forEach((item) => {
expect(item.draggable).to.be.true;
});
});
it('sets all disabled items non draggable', () => {
const items = element.getDisabledItems();
expect(items.length).to.equal(1);
items.forEach((item) => {
expect(item.draggable).to.be.false;
});
});
});
describe('setModel & getModel', () => {
it('it sets the model', () => {
const model = ['1', '2', '3', '4'];
element.sorter.setModel(model);
expect(element.sorter.getModel()).to.deep.equal(model);
});
});
describe('hasItem', () => {
beforeEach(() => {
element.sorter.setModel(['1', '2', '3', '4']);
});
it('returns true if item exists', () => {
expect(element.sorter.hasItem('1')).to.be.true;
});
it('returns false if item does not exist', () => {
expect(element.sorter.hasItem('5')).to.be.false;
});
});
describe('getItem', () => {
beforeEach(() => {
element.sorter.setModel(['1', '2', '3', '4']);
});
it('returns the item if it exists', () => {
expect(element.sorter.getItem('1')).to.equal('1');
});
it('returns undefined if item does not exist', () => {
expect(element.sorter.getItem('5')).to.be.undefined;
});
});
});

View File

@@ -260,6 +260,8 @@ export class UmbSorterController<T, ElementType extends HTMLElement = HTMLElemen
#dragX = 0;
#dragY = 0;
#items = Array<ElementType>();
public get identifier() {
return this.#config.identifier;
}
@@ -294,6 +296,11 @@ export class UmbSorterController<T, ElementType extends HTMLElement = HTMLElemen
});
}
/**
* Enables the sorter, this will allow sorting to happen.
* @return {*} {void}
* @memberof UmbSorterController
*/
enable(): void {
if (this.#enabled) return;
this.#enabled = true;
@@ -301,6 +308,12 @@ export class UmbSorterController<T, ElementType extends HTMLElement = HTMLElemen
this.#initialize();
}
}
/**
* Disables the sorter, this will prevent any sorting to happen.
* @return {*} {void}
* @memberof UmbSorterController
*/
disable(): void {
if (!this.#enabled) return;
this.#enabled = false;
@@ -316,6 +329,15 @@ export class UmbSorterController<T, ElementType extends HTMLElement = HTMLElemen
}
}
/**
* Returns the model of the sorter.
* @return {Array<T>}
* @memberof UmbSorterController
*/
getModel(): Array<T> {
return this.#model;
}
hasItem(unique: UniqueType) {
return this.#model.find((x) => this.#config.getUniqueOfModel(x) === unique) !== undefined;
}
@@ -330,12 +352,14 @@ export class UmbSorterController<T, ElementType extends HTMLElement = HTMLElemen
requestAnimationFrame(this.#initialize);
}
}
override hostDisconnected() {
this.#isConnected = false;
if (this.#enabled) {
this.#uninitialize();
}
}
#initialize = () => {
const containerEl =
(this.#config.containerSelector
@@ -365,6 +389,7 @@ export class UmbSorterController<T, ElementType extends HTMLElement = HTMLElemen
subtree: false,
});
};
#uninitialize() {
// TODO: Is there more clean up to do??
this.#observer.disconnect();
@@ -377,6 +402,8 @@ export class UmbSorterController<T, ElementType extends HTMLElement = HTMLElemen
containerElement.removeEventListener('dragover', this._itemDraggedOver as unknown as EventListener);
(this.#containerElement as unknown) = undefined;
}
this.#items.forEach((item) => this.destroyItem(item));
}
_itemDraggedOver = (e: DragEvent) => {
@@ -442,6 +469,9 @@ export class UmbSorterController<T, ElementType extends HTMLElement = HTMLElemen
}
}
}
this.#items.push(element);
this.#items = Array.from(new Set(this.#items));
}
destroyItem(element: HTMLElement) {
@@ -453,6 +483,10 @@ export class UmbSorterController<T, ElementType extends HTMLElement = HTMLElemen
draggableElement.removeEventListener('dragstart', this.#handleDragStart);
// We are not ready to remove the dragend or drop, as this is might be the active one just moving container:
//draggableElement.removeEventListener('dragend', this.#handleDragEnd);
(draggableElement as HTMLElement).draggable = false;
this.#items = this.#items.filter((x) => x !== element);
}
#setupPlaceholderStyle() {

View File

@@ -225,6 +225,7 @@ export class UmbWorkspaceSplitViewVariantSelectorElement extends UmbLitElement {
.value=${this._name ?? ''}
@input=${this.#handleInput}
required
?readonly=${this.#isReadOnly(this._activeVariant?.culture ?? null)}
${umbBindToValidation(this, `$.variants[${UmbDataPathVariantQuery(this._variantId)}].name`, this._name ?? '')}
${umbFocus()}
>

View File

@@ -5,26 +5,50 @@ import { UUITextareaEvent } from '@umbraco-cms/backoffice/external/uui';
import { css, html, customElement, state, repeat, ifDefined, unsafeHTML } from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { UmbLanguageCollectionRepository, type UmbLanguageDetailModel } from '@umbraco-cms/backoffice/language';
import { UMB_CURRENT_USER_CONTEXT } from '@umbraco-cms/backoffice/current-user';
@customElement('umb-workspace-view-dictionary-editor')
export class UmbWorkspaceViewDictionaryEditorElement extends UmbLitElement {
@state()
private _dictionary?: UmbDictionaryDetailModel;
#languageCollectionRepository = new UmbLanguageCollectionRepository(this);
@state()
private _languages: Array<UmbLanguageDetailModel> = [];
#workspaceContext!: typeof UMB_DICTIONARY_WORKSPACE_CONTEXT.TYPE;
@state()
private _currentUserLanguageAccess?: Array<string> = [];
override async connectedCallback() {
super.connectedCallback();
@state()
private _currentUserHasAccessToAllLanguages?: boolean = false;
#languageCollectionRepository = new UmbLanguageCollectionRepository(this);
#workspaceContext!: typeof UMB_DICTIONARY_WORKSPACE_CONTEXT.TYPE;
#currentUserContext?: typeof UMB_CURRENT_USER_CONTEXT.TYPE;
constructor() {
super();
this.consumeContext(UMB_DICTIONARY_WORKSPACE_CONTEXT, (_instance) => {
this.#workspaceContext = _instance;
this.#observeDictionary();
});
this.consumeContext(UMB_CURRENT_USER_CONTEXT, (context) => {
this.#currentUserContext = context;
this.#observeCurrentUserLanguageAccess();
});
}
#observeCurrentUserLanguageAccess() {
if (!this.#currentUserContext) return;
this.observe(this.#currentUserContext.languages, (languages) => {
this._currentUserLanguageAccess = languages;
});
this.observe(this.#currentUserContext.hasAccessToAllLanguages, (hasAccess) => {
this._currentUserHasAccessToAllLanguages = hasAccess;
});
}
override async firstUpdated() {
@@ -40,19 +64,11 @@ export class UmbWorkspaceViewDictionaryEditorElement extends UmbLitElement {
});
}
#renderTranslation(language: UmbLanguageDetailModel) {
if (!language.unique) return;
const translation = this._dictionary?.translations?.find((x) => x.isoCode === language.unique);
return html` <umb-property-layout label=${language.name ?? language.unique}>
<uui-textarea
slot="editor"
name=${language.unique}
label="translation"
@change=${this.#onTextareaChange}
value=${ifDefined(translation?.translation)}></uui-textarea>
</umb-property-layout>`;
#isReadOnly(culture: string | null) {
if (!this.#currentUserContext) return true;
if (!culture) return false;
if (this._currentUserHasAccessToAllLanguages) return false;
return !this._currentUserLanguageAccess?.includes(culture);
}
#onTextareaChange(e: Event) {
@@ -78,6 +94,22 @@ export class UmbWorkspaceViewDictionaryEditorElement extends UmbLitElement {
`;
}
#renderTranslation(language: UmbLanguageDetailModel) {
if (!language.unique) return;
const translation = this._dictionary?.translations?.find((x) => x.isoCode === language.unique);
return html` <umb-property-layout label=${language.name ?? language.unique}>
<uui-textarea
slot="editor"
name=${language.unique}
label="translation"
@change=${this.#onTextareaChange}
value=${ifDefined(translation?.translation)}
?readonly=${this.#isReadOnly(language.unique)}></uui-textarea>
</umb-property-layout>`;
}
static override styles = [
css`
:host {

View File

@@ -1,5 +1,14 @@
import { UmbDocumentPickerContext } from './input-document.context.js';
import { classMap, css, customElement, html, property, repeat, state } from '@umbraco-cms/backoffice/external/lit';
import {
classMap,
css,
customElement,
html,
nothing,
property,
repeat,
state,
} from '@umbraco-cms/backoffice/external/lit';
import { splitStringToArray } from '@umbraco-cms/backoffice/utils';
import { UmbChangeEvent } from '@umbraco-cms/backoffice/event';
import { UmbFormControlMixin } from '@umbraco-cms/backoffice/validation';
@@ -103,6 +112,27 @@ export class UmbInputDocumentElement extends UmbFormControlMixin<string | undefi
return this.selection.length > 0 ? this.selection.join(',') : undefined;
}
/**
* Sets the input to readonly mode, meaning value cannot be changed but still able to read and select its content.
* @type {boolean}
* @attr
* @default false
*/
@property({ type: Boolean, reflect: true })
public get readonly() {
return this.#readonly;
}
public set readonly(value) {
this.#readonly = value;
if (this.#readonly) {
this.#sorter.disable();
} else {
this.#sorter.enable();
}
}
#readonly = false;
@state()
private _editDocumentPath = '';
@@ -173,7 +203,8 @@ export class UmbInputDocumentElement extends UmbFormControlMixin<string | undefi
id="btn-add"
look="placeholder"
@click=${this.#openPicker}
label=${this.localize.term('general_choose')}></uui-button>
label=${this.localize.term('general_choose')}
?disabled=${this.readonly}></uui-button>
`;
}
@@ -193,11 +224,14 @@ export class UmbInputDocumentElement extends UmbFormControlMixin<string | undefi
#renderItem(item: UmbDocumentItemModel) {
if (!item.unique) return;
return html`
<uui-ref-node name=${item.name} id=${item.unique} class=${classMap({ draft: this.#isDraft(item) })}>
<uui-ref-node
name=${item.name}
id=${item.unique}
class=${classMap({ draft: this.#isDraft(item) })}
?readonly=${this.readonly}>
${this.#renderIcon(item)} ${this.#renderIsTrashed(item)}
<uui-action-bar slot="actions">
${this.#renderOpenButton(item)}
<uui-button @click=${() => this.#onRemove(item)} label=${this.localize.term('general_remove')}></uui-button>
${this.#renderOpenButton(item)} ${this.#renderRemoveButton(item)}
</uui-action-bar>
</uui-ref-node>
`;
@@ -213,8 +247,16 @@ export class UmbInputDocumentElement extends UmbFormControlMixin<string | undefi
return html`<uui-tag size="s" slot="tag" color="danger">Trashed</uui-tag>`;
}
#renderRemoveButton(item: UmbDocumentItemModel) {
if (this.readonly) return nothing;
return html`
<uui-button @click=${() => this.#onRemove(item)} label=${this.localize.term('general_remove')}></uui-button>
`;
}
#renderOpenButton(item: UmbDocumentItemModel) {
if (!this.showOpenButton) return;
if (this.readonly) return nothing;
if (!this.showOpenButton) return nothing;
return html`
<uui-button
href="${this._editDocumentPath}edit/${item.unique}"

View File

@@ -12,6 +12,7 @@ export const manifests: Array<ManifestTypes> = [
propertyEditorSchemaAlias: 'Umbraco.ContentPicker',
icon: 'icon-document',
group: 'pickers',
supportsReadOnly: true,
settings: {
properties: [
{

View File

@@ -26,6 +26,15 @@ export class UmbPropertyEditorUIDocumentPickerElement extends UmbLitElement impl
this._showOpenButton = config.getValueByAlias('showOpenButton') ?? false;
}
/**
* Sets the input to readonly mode, meaning value cannot be changed but still able to read and select its content.
* @type {boolean}
* @attr
* @default false
*/
@property({ type: Boolean, reflect: true })
readonly = false;
@state()
private _min = 0;
@@ -57,7 +66,8 @@ export class UmbPropertyEditorUIDocumentPickerElement extends UmbLitElement impl
.startNode=${startNode}
.value=${this.value}
?showOpenButton=${this._showOpenButton}
@change=${this.#onChange}>
@change=${this.#onChange}
?readonly=${this.readonly}>
</umb-input-document>
`;
}