diff --git a/src/Umbraco.Web.UI.Client/src/libs/class-api/class.mixin.ts b/src/Umbraco.Web.UI.Client/src/libs/class-api/class.mixin.ts index 465d7e3460..a755cebc59 100644 --- a/src/Umbraco.Web.UI.Client/src/libs/class-api/class.mixin.ts +++ b/src/Umbraco.Web.UI.Client/src/libs/class-api/class.mixin.ts @@ -65,6 +65,16 @@ declare class UmbClassMixinDeclaration extends EventTarget implements UmbClassMi callback: UmbContextCallback, ): UmbContextConsumerController; + /** + * @description Retrieve a context. Notice this is a one time retrieving of a context, meaning if you expect this to be up to date with reality you should instead use the consumeContext method. + * @param {string} contextAlias + * @return {Promise} A Promise with the reference to the Context Api Instance + * @memberof UmbClassMixin + */ + getContext( + alias: string | UmbContextToken, + ): Promise; + hasController(controller: UmbController): boolean; getControllers(filterMethod: (ctrl: UmbController) => boolean): UmbController[]; addController(controller: UmbController): void; @@ -129,6 +139,17 @@ export const UmbClassMixin = (superClass: T) => { return new UmbContextConsumerController(this, contextAlias, callback); } + async getContext( + contextAlias: string | UmbContextToken, + ): Promise { + const controller = new UmbContextConsumerController(this, contextAlias); + const promise = controller.asPromise().then((result) => { + controller.destroy(); + return result; + }); + return promise; + } + public destroy(): void { if (this._host) { this._host.removeController(this); diff --git a/src/Umbraco.Web.UI.Client/src/libs/context-api/consume/context-consumer.controller.ts b/src/Umbraco.Web.UI.Client/src/libs/context-api/consume/context-consumer.controller.ts index fcafe8efb8..4faf16708d 100644 --- a/src/Umbraco.Web.UI.Client/src/libs/context-api/consume/context-consumer.controller.ts +++ b/src/Umbraco.Web.UI.Client/src/libs/context-api/consume/context-consumer.controller.ts @@ -17,7 +17,7 @@ export class UmbContextConsumerController, - callback: UmbContextCallback, + callback?: UmbContextCallback, ) { super(host.getHostElement(), contextAlias, callback); this.#host = host; diff --git a/src/Umbraco.Web.UI.Client/src/libs/controller-api/controller-alias.type.ts b/src/Umbraco.Web.UI.Client/src/libs/controller-api/controller-alias.type.ts index e25e734e02..6d57214862 100644 --- a/src/Umbraco.Web.UI.Client/src/libs/controller-api/controller-alias.type.ts +++ b/src/Umbraco.Web.UI.Client/src/libs/controller-api/controller-alias.type.ts @@ -1 +1 @@ -export type UmbControllerAlias = string | symbol | undefined; +export type UmbControllerAlias = string | number | symbol | undefined; diff --git a/src/Umbraco.Web.UI.Client/src/libs/controller-api/controller.test.ts b/src/Umbraco.Web.UI.Client/src/libs/controller-api/controller.test.ts index e00bf00316..631d169f13 100644 --- a/src/Umbraco.Web.UI.Client/src/libs/controller-api/controller.test.ts +++ b/src/Umbraco.Web.UI.Client/src/libs/controller-api/controller.test.ts @@ -6,9 +6,9 @@ import type { UmbControllerHost } from './controller-host.interface.js'; import { customElement } from '@umbraco-cms/backoffice/external/lit'; @customElement('test-my-controller-host') -export class UmbTestControllerHostElement extends UmbControllerHostElementMixin(HTMLElement) {} +class UmbTestControllerHostElement extends UmbControllerHostElementMixin(HTMLElement) {} -export class UmbTestControllerImplementation extends UmbControllerHostMixin(class {}) { +class UmbTestControllerImplementation extends UmbControllerHostMixin(class {}) { testIsConnected = false; testIsDestroyed = false; @@ -47,9 +47,7 @@ export class UmbTestControllerImplementation extends UmbControllerHostMixin(clas } describe('UmbController', () => { - type NewType = UmbControllerHostElement; - - let hostElement: NewType; + let hostElement: UmbControllerHostElement; beforeEach(() => { hostElement = document.createElement('test-my-controller-host') as UmbControllerHostElement; diff --git a/src/Umbraco.Web.UI.Client/src/libs/element-api/element.mixin.ts b/src/Umbraco.Web.UI.Client/src/libs/element-api/element.mixin.ts index dbf72e6a23..feb052e9ab 100644 --- a/src/Umbraco.Web.UI.Client/src/libs/element-api/element.mixin.ts +++ b/src/Umbraco.Web.UI.Client/src/libs/element-api/element.mixin.ts @@ -32,6 +32,9 @@ export declare class UmbElement extends UmbControllerHostElement { alias: string | UmbContextToken, callback: UmbContextCallback, ): UmbContextConsumerController; + getContext( + alias: string | UmbContextToken, + ): Promise; /** * Use the UmbLocalizeController to localize your element. * @see UmbLocalizationController @@ -86,6 +89,24 @@ export const UmbElementMixin = (superClass: T) return new UmbContextConsumerController(this, alias, callback); } + /** + * @description Setup a subscription for a context. The callback is called when the context is resolved. + * @param {string} contextAlias + * @param {method} callback Callback method called when context is resolved. + * @return {UmbContextConsumerController} Reference to a Context Consumer Controller instance + * @memberof UmbElementMixin + */ + async getContext( + contextAlias: string | UmbContextToken, + ): Promise { + const controller = new UmbContextConsumerController(this, contextAlias); + const promise = controller.asPromise().then((result) => { + controller.destroy(); + return result; + }); + return promise; + } + destroy(): void { super.destroy(); (this.localize as any) = undefined; diff --git a/src/Umbraco.Web.UI.Client/src/libs/extension-api/controller/base-extension-initializer.controller.test.ts b/src/Umbraco.Web.UI.Client/src/libs/extension-api/controller/base-extension-initializer.controller.test.ts index a276cee811..23e639a49f 100644 --- a/src/Umbraco.Web.UI.Client/src/libs/extension-api/controller/base-extension-initializer.controller.test.ts +++ b/src/Umbraco.Web.UI.Client/src/libs/extension-api/controller/base-extension-initializer.controller.test.ts @@ -16,7 +16,7 @@ import { UmbSwitchCondition } from '@umbraco-cms/backoffice/extension-registry'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; @customElement('umb-test-controller-host') -export class UmbTestControllerHostElement extends UmbControllerHostElementMixin(HTMLElement) {} +class UmbTestControllerHostElement extends UmbControllerHostElementMixin(HTMLElement) {} class UmbTestExtensionController extends UmbBaseExtensionInitializer { constructor( diff --git a/src/Umbraco.Web.UI.Client/src/libs/extension-api/controller/base-extensions-initializer.controller.ts b/src/Umbraco.Web.UI.Client/src/libs/extension-api/controller/base-extensions-initializer.controller.ts index bd0846aebf..5600fdbc44 100644 --- a/src/Umbraco.Web.UI.Client/src/libs/extension-api/controller/base-extensions-initializer.controller.ts +++ b/src/Umbraco.Web.UI.Client/src/libs/extension-api/controller/base-extensions-initializer.controller.ts @@ -130,6 +130,8 @@ export abstract class UmbBaseExtensionsInitializer< #notifyChange = () => { this.#changeDebounce = undefined; + // This means that we have been destroyed: + if (this.#permittedExts === undefined) return; // The final list of permitted extensions to be displayed, this will be stripped from extensions that are overwritten by another extension and sorted accordingly. this.#exposedPermittedExts = [...this.#permittedExts]; diff --git a/src/Umbraco.Web.UI.Client/src/libs/extension-api/controller/extension-api-initializer.test.ts b/src/Umbraco.Web.UI.Client/src/libs/extension-api/controller/extension-api-initializer.test.ts index 469d237d91..96569a144f 100644 --- a/src/Umbraco.Web.UI.Client/src/libs/extension-api/controller/extension-api-initializer.test.ts +++ b/src/Umbraco.Web.UI.Client/src/libs/extension-api/controller/extension-api-initializer.test.ts @@ -9,9 +9,9 @@ import { customElement, html } from '@umbraco-cms/backoffice/external/lit'; import { type ManifestSection, UmbSwitchCondition } from '@umbraco-cms/backoffice/extension-registry'; @customElement('umb-test-controller-host') -export class UmbTestControllerHostElement extends UmbControllerHostElementMixin(HTMLElement) {} +class UmbTestControllerHostElement extends UmbControllerHostElementMixin(HTMLElement) {} -export class UmbTestApiController extends UmbBaseController { +class UmbTestApiController extends UmbBaseController { public i_am_test_api_controller = true; constructor(host: UmbControllerHost) { diff --git a/src/Umbraco.Web.UI.Client/src/libs/extension-api/controller/extension-element-initializer.test.ts b/src/Umbraco.Web.UI.Client/src/libs/extension-api/controller/extension-element-initializer.test.ts index a76dffd077..5ba23d9f44 100644 --- a/src/Umbraco.Web.UI.Client/src/libs/extension-api/controller/extension-element-initializer.test.ts +++ b/src/Umbraco.Web.UI.Client/src/libs/extension-api/controller/extension-element-initializer.test.ts @@ -1,13 +1,13 @@ import { expect, fixture } from '@open-wc/testing'; import { UmbExtensionRegistry } from '../registry/extension.registry.js'; import { UmbExtensionElementInitializer } from './index.js'; -import type { UmbControllerHostElement} from '@umbraco-cms/backoffice/controller-api'; +import type { UmbControllerHostElement } from '@umbraco-cms/backoffice/controller-api'; import { UmbControllerHostElementMixin } from '@umbraco-cms/backoffice/controller-api'; import { customElement, html } from '@umbraco-cms/backoffice/external/lit'; import { type ManifestSection, UmbSwitchCondition } from '@umbraco-cms/backoffice/extension-registry'; @customElement('umb-test-controller-host') -export class UmbTestControllerHostElement extends UmbControllerHostElementMixin(HTMLElement) {} +class UmbTestControllerHostElement extends UmbControllerHostElementMixin(HTMLElement) {} describe('UmbExtensionElementController', () => { describe('Manifest without conditions', () => { diff --git a/src/Umbraco.Web.UI.Client/src/libs/observable-api/observer.controller.test.ts b/src/Umbraco.Web.UI.Client/src/libs/observable-api/observer.controller.test.ts new file mode 100644 index 0000000000..076b93d452 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/libs/observable-api/observer.controller.test.ts @@ -0,0 +1,71 @@ +import { expect } from '@open-wc/testing'; +import { UmbObjectState } from './states/object-state.js'; +import { UmbObserverController } from './observer.controller.js'; +import { customElement } from '@umbraco-cms/backoffice/external/lit'; +import { UmbControllerHostElementMixin } from '@umbraco-cms/backoffice/controller-api'; + +@customElement('test-my-observer-controller-host') +class UmbTestControllerHostElement extends UmbControllerHostElementMixin(HTMLElement) {} + +describe('UmbObserverController', () => { + describe('Observer Controllers against other Observer Controllers', () => { + let hostElement: UmbTestControllerHostElement; + + beforeEach(() => { + hostElement = document.createElement('test-my-observer-controller-host') as UmbTestControllerHostElement; + }); + + it('controller is replaced by another controller using the same string as controller-alias', () => { + const state = new UmbObjectState(undefined); + const observable = state.asObservable(); + + const callbackMethod = (state: unknown) => {}; + + const firstCtrl = new UmbObserverController(hostElement, observable, callbackMethod, 'my-test-alias'); + const secondCtrl = new UmbObserverController(hostElement, observable, callbackMethod, 'my-test-alias'); + + expect(hostElement.hasController(firstCtrl)).to.be.false; + expect(hostElement.hasController(secondCtrl)).to.be.true; + }); + + it('controller is replaced by another controller using the the same symbol as controller-alias', () => { + const state = new UmbObjectState(undefined); + const observable = state.asObservable(); + + const callbackMethod = (state: unknown) => {}; + + const mySymbol = Symbol(); + const firstCtrl = new UmbObserverController(hostElement, observable, callbackMethod, mySymbol); + const secondCtrl = new UmbObserverController(hostElement, observable, callbackMethod, mySymbol); + + expect(hostElement.hasController(firstCtrl)).to.be.false; + expect(hostElement.hasController(secondCtrl)).to.be.true; + }); + + it('controller is replacing another controller when using the same callback method and no controller-alias', () => { + const state = new UmbObjectState(undefined); + const observable = state.asObservable(); + + const callbackMethod = (state: unknown) => {}; + + const firstCtrl = new UmbObserverController(hostElement, observable, callbackMethod); + const secondCtrl = new UmbObserverController(hostElement, observable, callbackMethod); + + expect(hostElement.hasController(firstCtrl)).to.be.false; + expect(hostElement.hasController(secondCtrl)).to.be.true; + }); + + it('controller is NOT replacing another controller when using a null for controller-alias', () => { + const state = new UmbObjectState(undefined); + const observable = state.asObservable(); + + const callbackMethod = (state: unknown) => {}; + + const firstCtrl = new UmbObserverController(hostElement, observable, callbackMethod, null); + const secondCtrl = new UmbObserverController(hostElement, observable, callbackMethod, null); + + expect(hostElement.hasController(firstCtrl)).to.be.true; + expect(hostElement.hasController(secondCtrl)).to.be.true; + }); + }); +}); diff --git a/src/Umbraco.Web.UI.Client/src/libs/observable-api/observer.controller.ts b/src/Umbraco.Web.UI.Client/src/libs/observable-api/observer.controller.ts index 5f790c6047..9fa48b5bff 100644 --- a/src/Umbraco.Web.UI.Client/src/libs/observable-api/observer.controller.ts +++ b/src/Umbraco.Web.UI.Client/src/libs/observable-api/observer.controller.ts @@ -1,4 +1,5 @@ import { type ObserverCallback, UmbObserver } from './observer.js'; +import { simpleHashCode } from './utils/simple-hash-code.function.js'; import type { Observable } from '@umbraco-cms/backoffice/external/rxjs'; import type { UmbController, UmbControllerAlias, UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; @@ -14,11 +15,12 @@ export class UmbObserverController extends UmbObserver implement host: UmbControllerHost, source: Observable, callback: ObserverCallback, - alias?: UmbControllerAlias, + alias?: UmbControllerAlias | null, ) { super(source, callback); this.#host = host; - this.#alias = alias; + // Fallback to use a hash of the provided method, but only if the alias is undefined. + this.#alias = alias ?? (alias === undefined ? simpleHashCode(callback.toString()) : undefined); // Lets check if controller is already here: // No we don't want this, as multiple different controllers might be looking at the same source. diff --git a/src/Umbraco.Web.UI.Client/src/libs/observable-api/utils/index.ts b/src/Umbraco.Web.UI.Client/src/libs/observable-api/utils/index.ts index c0c329548a..fce5474f94 100644 --- a/src/Umbraco.Web.UI.Client/src/libs/observable-api/utils/index.ts +++ b/src/Umbraco.Web.UI.Client/src/libs/observable-api/utils/index.ts @@ -8,3 +8,4 @@ export * from './naive-object-comparison.function.js'; export * from './observe-multiple.function.js'; export * from './partial-update-frozen-array.function.js'; export * from './push-to-unique-array.function.js'; +export * from './simple-hash-code.function.js'; diff --git a/src/Umbraco.Web.UI.Client/src/libs/observable-api/utils/simple-hash-code.function.ts b/src/Umbraco.Web.UI.Client/src/libs/observable-api/utils/simple-hash-code.function.ts new file mode 100644 index 0000000000..2e02997326 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/libs/observable-api/utils/simple-hash-code.function.ts @@ -0,0 +1,15 @@ +/** + * Returns a hash code from a string + * @param {String} str - The string to hash. + * @return {Number} - A 32bit integer + */ +export function simpleHashCode(str: string) { + let hash = 0, + i = 0; + const len = str.length; + while (i < len) { + hash = (hash << 5) - hash + str.charCodeAt(i++); + hash |= 0; // Convert to 32bit integer + } + return hash; +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/components/block-grid-area-config-entry/block-grid-area-config-entry.context.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/components/block-grid-area-config-entry/block-grid-area-config-entry.context.ts index c138fb6300..3067cfc101 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/components/block-grid-area-config-entry/block-grid-area-config-entry.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/components/block-grid-area-config-entry/block-grid-area-config-entry.context.ts @@ -9,7 +9,7 @@ import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import { UmbObjectState } from '@umbraco-cms/backoffice/observable-api'; import { UmbContextBase } from '@umbraco-cms/backoffice/class-api'; import { UMB_PROPERTY_CONTEXT } from '@umbraco-cms/backoffice/property'; -import { UMB_CONFIRM_MODAL, UMB_MODAL_MANAGER_CONTEXT } from '@umbraco-cms/backoffice/modal'; +import { umbConfirmModal } from '@umbraco-cms/backoffice/modal'; export class UmbBlockGridAreaConfigEntryContext extends UmbContextBase implements UmbBlockGridScalableContext @@ -86,19 +86,14 @@ export class UmbBlockGridAreaConfigEntryContext ); } - requestDelete() { - this.consumeContext(UMB_MODAL_MANAGER_CONTEXT, async (modalManager) => { - const modalContext = modalManager.open(UMB_CONFIRM_MODAL, { - data: { - headline: `Delete ${this.alias}`, - content: 'Are you sure you want to delete this Area?', - confirmLabel: 'Delete', - color: 'danger', - }, - }); - await modalContext.onSubmit(); - this.delete(); + async requestDelete() { + await umbConfirmModal(this, { + headline: `Delete ${this.alias}`, + content: 'Are you sure you want to delete this Area?', + confirmLabel: 'Delete', + color: 'danger', }); + this.delete(); } public delete() { if (!this.#areaKey || !this.#propertyContext) return; diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/context/block-grid-entries.context.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/context/block-grid-entries.context.ts index 039d16f8e9..fc2b1924f8 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/context/block-grid-entries.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/context/block-grid-entries.context.ts @@ -244,33 +244,32 @@ export class UmbBlockGridEntriesContext // Area entries: if (!this.#areaType) return []; - if (this.#areaType.specifiedAllowance && this.#areaType.specifiedAllowance.length > 0) { - return this.#areaType.specifiedAllowance - .flatMap((permission) => { - if (permission.groupKey) { - return ( - this._manager - ?.getBlockTypes() - .filter( - (blockType) => blockType.groupKey === permission.groupKey && blockType.allowInAreas === true, - ) ?? [] - ); - } else if (permission.elementTypeKey) { - return ( - this._manager?.getBlockTypes().filter((x) => x.contentElementTypeKey === permission.elementTypeKey) ?? - [] - ); - } - return []; - }) - .filter((v, i, a) => a.find((x) => x.contentElementTypeKey === v.contentElementTypeKey) === undefined); + if (this.#areaType.specifiedAllowance && this.#areaType.specifiedAllowance?.length > 0) { + return ( + this.#areaType.specifiedAllowance + .flatMap((permission) => { + if (permission.groupKey) { + return ( + this._manager?.getBlockTypes().filter((blockType) => blockType.groupKey === permission.groupKey) ?? [] + ); + } else if (permission.elementTypeKey) { + return ( + this._manager?.getBlockTypes().filter((x) => x.contentElementTypeKey === permission.elementTypeKey) ?? + [] + ); + } + return []; + }) + // Remove duplicates: + .filter((v, i, a) => a.findIndex((x) => x.contentElementTypeKey === v.contentElementTypeKey) === i) + ); } + // No specific permissions setup, so we will fallback to items allowed in areas: return this._manager.getBlockTypes().filter((x) => x.allowInAreas); } - // If no AreaKey, then we are representing the items of the root: - // Root entries: + // If no AreaKey, then we are in the root, looking for items allowed as root: return this._manager.getBlockTypes().filter((x) => x.allowAtRoot); } diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-type/components/input-block-type/input-block-type.element.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-type/components/input-block-type/input-block-type.element.ts index 8caac68e41..31593e2b2b 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-type/components/input-block-type/input-block-type.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-type/components/input-block-type/input-block-type.element.ts @@ -1,8 +1,8 @@ import type { UmbBlockTypeBaseModel } from '../../types.js'; import { - UMB_CONFIRM_MODAL, UMB_DOCUMENT_TYPE_PICKER_MODAL, UMB_MODAL_MANAGER_CONTEXT, + umbConfirmModal, } from '@umbraco-cms/backoffice/modal'; import '../block-type-card/index.js'; import { css, html, customElement, property, state, repeat } from '@umbraco-cms/backoffice/external/lit'; @@ -42,35 +42,33 @@ export class UmbInputBlockTypeElement< }); } - create() { - this.consumeContext(UMB_MODAL_MANAGER_CONTEXT, async (modalManager) => { - if (modalManager) { - // TODO: Make as mode for the Picker Modal, so the click to select immediately submits the modal(And in that mode we do not want to see a Submit button). - const modalContext = modalManager.open(UMB_DOCUMENT_TYPE_PICKER_MODAL, { - data: { - hideTreeRoot: true, - multiple: false, - pickableFilter: (docType) => - // Only pick elements: - docType.isElement && - // Prevent picking the an already used element type: - this.#filter && - this.#filter.find((x) => x.contentElementTypeKey === docType.unique) === undefined, - }, - }); + async create() { + const modalManager = await this.getContext(UMB_MODAL_MANAGER_CONTEXT); - const modalValue = await modalContext?.onSubmit(); - const selectedElementType = modalValue.selection[0]; - - if (selectedElementType) { - this.dispatchEvent(new CustomEvent('create', { detail: { contentElementTypeKey: selectedElementType } })); - } - } + // TODO: Make as mode for the Picker Modal, so the click to select immediately submits the modal(And in that mode we do not want to see a Submit button). + const modalContext = modalManager.open(UMB_DOCUMENT_TYPE_PICKER_MODAL, { + data: { + hideTreeRoot: true, + multiple: false, + pickableFilter: (docType) => + // Only pick elements: + docType.isElement && + // Prevent picking the an already used element type: + this.#filter && + this.#filter.find((x) => x.contentElementTypeKey === docType.unique) === undefined, + }, }); + + const modalValue = await modalContext?.onSubmit(); + const selectedElementType = modalValue.selection[0]; + + if (selectedElementType) { + this.dispatchEvent(new CustomEvent('create', { detail: { contentElementTypeKey: selectedElementType } })); + } } deleteItem(contentElementTypeKey: string) { - this.value = this._items.filter((x) => x.contentElementTypeKey !== contentElementTypeKey); + this.value = this.value.filter((x) => x.contentElementTypeKey !== contentElementTypeKey); this.dispatchEvent(new UmbChangeEvent()); } @@ -78,20 +76,14 @@ export class UmbInputBlockTypeElement< return undefined; } - #onRequestDelete(item: BlockType) { - this.consumeContext(UMB_MODAL_MANAGER_CONTEXT, async (modalManager) => { - const modalContext = modalManager.open(UMB_CONFIRM_MODAL, { - data: { - color: 'danger', - headline: `Remove [TODO: Get name]?`, - content: 'Are you sure you want to remove this block type?', - confirmLabel: 'Remove', - }, - }); - - await modalContext?.onSubmit(); - this.deleteItem(item.contentElementTypeKey); + async #onRequestDelete(item: BlockType) { + await umbConfirmModal(this, { + color: 'danger', + headline: `Remove [TODO: Get name]?`, + content: 'Are you sure you want to remove this block type?', + confirmLabel: 'Remove', }); + this.deleteItem(item.contentElementTypeKey); } render() { @@ -109,7 +101,7 @@ export class UmbInputBlockTypeElement< .href="${this.workspacePath}/edit/${block.contentElementTypeKey}" .contentElementTypeKey=${block.contentElementTypeKey}> - + this.#onRequestDelete(block)} label="Remove block"> diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block/context/block-entry.context.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block/context/block-entry.context.ts index 323889f06a..2cdac3315d 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block/context/block-entry.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block/context/block-entry.context.ts @@ -7,7 +7,7 @@ import { UmbContextBase } from '@umbraco-cms/backoffice/class-api'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import { UmbNumberState, UmbObjectState, UmbStringState } from '@umbraco-cms/backoffice/observable-api'; import { encodeFilePath } from '@umbraco-cms/backoffice/utils'; -import { UMB_CONFIRM_MODAL, UMB_MODAL_MANAGER_CONTEXT } from '@umbraco-cms/backoffice/modal'; +import { umbConfirmModal } from '@umbraco-cms/backoffice/modal'; import type { UmbContentTypeModel } from '@umbraco-cms/backoffice/content-type'; import type { Observable } from '@umbraco-cms/backoffice/external/rxjs'; @@ -353,20 +353,16 @@ export abstract class UmbBlockEntryContext< window.location.href = this.#generateWorkspaceEditSettingsPath(this.#workspacePath.value); } - requestDelete() { - this.consumeContext(UMB_MODAL_MANAGER_CONTEXT, async (modalManager) => { - const modalContext = modalManager.open(UMB_CONFIRM_MODAL, { - data: { - headline: `Delete ${this.getLabel()}`, - content: 'Are you sure you want to delete this [INSERT BLOCK TYPE NAME]?', - confirmLabel: 'Delete', - color: 'danger', - }, - }); - await modalContext.onSubmit(); - this.delete(); + async requestDelete() { + await umbConfirmModal(this, { + headline: `Delete ${this.getLabel()}`, + content: 'Are you sure you want to delete this [INSERT BLOCK TYPE NAME]?', + confirmLabel: 'Delete', + color: 'danger', }); + this.delete(); } + public delete() { if (!this._entries) return; const contentUdi = this._layout.value?.contentUdi; diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block/modals/block-catalogue/block-catalogue-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block/modals/block-catalogue/block-catalogue-modal.element.ts index 7935c14d6f..7765df3f9c 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block/modals/block-catalogue/block-catalogue-modal.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block/modals/block-catalogue/block-catalogue-modal.element.ts @@ -1,7 +1,8 @@ import { UMB_BLOCK_WORKSPACE_MODAL } from '../../workspace/index.js'; -import type { UmbBlockCatalogueModalData, UmbBlockCatalogueModalValue } from '@umbraco-cms/backoffice/block'; import type { UmbBlockTypeGroup, UmbBlockTypeWithGroupKey } from '@umbraco-cms/backoffice/block-type'; +import type { UmbBlockCatalogueModalData, UmbBlockCatalogueModalValue } from '@umbraco-cms/backoffice/block'; import { css, html, customElement, state, repeat, nothing } from '@umbraco-cms/backoffice/external/lit'; +import type { UUIInputEvent } from '@umbraco-cms/backoffice/external/uui'; import { UMB_MODAL_CONTEXT, UmbModalBaseElement, @@ -15,14 +16,19 @@ export class UmbBlockCatalogueModalElement extends UmbModalBaseElement< UmbBlockCatalogueModalData, UmbBlockCatalogueModalValue > { - @state() + // + private _search = ''; + private _groupedBlocks: Array<{ name?: string; blocks: Array }> = []; @state() - _openClipboard?: boolean; + private _openClipboard?: boolean; @state() - _workspacePath?: string; + private _workspacePath?: string; + + @state() + private _filtered: Array<{ name?: string; blocks: Array }> = []; constructor() { super(); @@ -61,6 +67,23 @@ export class UmbBlockCatalogueModalElement extends UmbModalBaseElement< })); this._groupedBlocks = [{ blocks: noGroupBlocks }, ...grouped]; + this.#updateFiltered(); + } + + #updateFiltered() { + if (this._search.length === 0) { + this._filtered = this._groupedBlocks; + } else { + const search = this._search.toLowerCase(); + this._filtered = this._groupedBlocks.map((group) => { + return { ...group, blocks: group.blocks.filter((block) => block.label?.toLocaleLowerCase().includes(search)) }; + }); + } + } + + #onSearch(e: UUIInputEvent) { + this._search = e.target.value as string; + this.#updateFiltered(); } render() { @@ -85,28 +108,35 @@ export class UmbBlockCatalogueModalElement extends UmbModalBaseElement< #renderCreateEmpty() { return html` - ${this._groupedBlocks - ? this._groupedBlocks.map( - (group) => html` - ${group.name && group.name !== '' ? html`

${group.name}

` : nothing} -
- ${repeat( - group.blocks, - (block) => block.contentElementTypeKey, - (block) => html` - - - `, - )} -
- `, - ) - : ''} + ${this.data?.blocks && this.data.blocks.length > 8 + ? html` + + ` + : nothing} + ${this._filtered.map( + (group) => html` + ${group.name && group.name !== '' ? html`

${group.name}

` : nothing} +
+ ${repeat( + group.blocks, + (block) => block.contentElementTypeKey, + (block) => html` + + + `, + )} +
+ `, + )} `; } @@ -133,6 +163,14 @@ export class UmbBlockCatalogueModalElement extends UmbModalBaseElement< static styles = [ css` + #search { + width: 100%; + align-items: center; + margin-bottom: var(--uui-size-layout-1); + } + #search uui-icon { + padding-left: var(--uui-size-space-3); + } .blockGroup { display: grid; gap: 1rem; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/collection/collection-view.manager.test.ts b/src/Umbraco.Web.UI.Client/src/packages/core/collection/collection-view.manager.test.ts index 57bed25f32..f3e06f7078 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/collection/collection-view.manager.test.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/collection/collection-view.manager.test.ts @@ -2,12 +2,12 @@ import { expect } from '@open-wc/testing'; import { UmbCollectionViewManager } from './collection-view.manager.js'; import { Observable } from '@umbraco-cms/backoffice/external/rxjs'; import { UmbControllerHostElementMixin } from '@umbraco-cms/backoffice/controller-api'; -import type { ManifestCollectionView} from '@umbraco-cms/backoffice/extension-registry'; +import type { ManifestCollectionView } from '@umbraco-cms/backoffice/extension-registry'; import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry'; import { customElement } from '@umbraco-cms/backoffice/external/lit'; @customElement('test-my-controller-host') -export class UmbTestControllerHostElement extends UmbControllerHostElementMixin(HTMLElement) {} +class UmbTestControllerHostElement extends UmbControllerHostElementMixin(HTMLElement) {} const VIEW_1_ALIAS = 'UmbTest.CollectionView.1'; const VIEW_2_ALIAS = 'UmbTest.CollectionView.2'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/components/multiple-color-picker-input/multiple-color-picker-item-input.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/components/multiple-color-picker-input/multiple-color-picker-item-input.element.ts index ad85c0695d..b64bb63ac9 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/components/multiple-color-picker-input/multiple-color-picker-item-input.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/components/multiple-color-picker-input/multiple-color-picker-item-input.element.ts @@ -1,8 +1,7 @@ import { css, html, nothing, customElement, property, query, ifDefined } from '@umbraco-cms/backoffice/external/lit'; import type { UUIColorPickerElement, UUIInputElement, UUIInputEvent } from '@umbraco-cms/backoffice/external/uui'; import { FormControlMixin } from '@umbraco-cms/backoffice/external/uui'; -import type { UmbModalManagerContext } from '@umbraco-cms/backoffice/modal'; -import { UMB_MODAL_MANAGER_CONTEXT, UMB_CONFIRM_MODAL } from '@umbraco-cms/backoffice/modal'; +import { umbConfirmModal } from '@umbraco-cms/backoffice/modal'; import { UmbChangeEvent, UmbInputEvent, UmbDeleteEvent } from '@umbraco-cms/backoffice/event'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; @@ -38,32 +37,18 @@ export class UmbMultipleColorPickerItemInputElement extends FormControlMixin(Umb @query('#color') protected _colorPicker!: UUIColorPickerElement; - private _modalContext?: UmbModalManagerContext; - @property({ type: Boolean }) showLabels = true; - constructor() { - super(); - - this.consumeContext(UMB_MODAL_MANAGER_CONTEXT, (instance) => { - this._modalContext = instance; - }); - } - - #onDelete() { - const modalContext = this._modalContext?.open(UMB_CONFIRM_MODAL, { - data: { - headline: `${this.localize.term('actions_delete')} ${this.value || ''}`, - content: this.localize.term('content_nestedContentDeleteItem'), - color: 'danger', - confirmLabel: this.localize.term('actions_delete'), - }, + async #onDelete() { + await umbConfirmModal(this, { + headline: `${this.localize.term('actions_delete')} ${this.value || ''}`, + content: this.localize.term('content_nestedContentDeleteItem'), + color: 'danger', + confirmLabel: this.localize.term('actions_delete'), }); - modalContext?.onSubmit().then(() => { - this.dispatchEvent(new UmbDeleteEvent()); - }); + this.dispatchEvent(new UmbDeleteEvent()); } #onLabelInput(event: UUIInputEvent) { diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/components/multiple-text-string-input/input-multiple-text-string-item.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/components/multiple-text-string-input/input-multiple-text-string-item.element.ts index ef72181645..f606197309 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/components/multiple-text-string-input/input-multiple-text-string-item.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/components/multiple-text-string-input/input-multiple-text-string-item.element.ts @@ -1,8 +1,7 @@ import { css, html, nothing, customElement, property, query } from '@umbraco-cms/backoffice/external/lit'; import type { UUIInputElement, UUIInputEvent } from '@umbraco-cms/backoffice/external/uui'; import { FormControlMixin } from '@umbraco-cms/backoffice/external/uui'; -import type { UmbModalManagerContext } from '@umbraco-cms/backoffice/modal'; -import { UMB_MODAL_MANAGER_CONTEXT, UMB_CONFIRM_MODAL } from '@umbraco-cms/backoffice/modal'; +import { umbConfirmModal } from '@umbraco-cms/backoffice/modal'; import { UmbChangeEvent, UmbInputEvent, UmbDeleteEvent } from '@umbraco-cms/backoffice/event'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; @@ -32,29 +31,15 @@ export class UmbInputMultipleTextStringItemElement extends FormControlMixin(UmbL @query('#input') protected _input?: UUIInputElement; - private _modalContext?: UmbModalManagerContext; - - constructor() { - super(); - - this.consumeContext(UMB_MODAL_MANAGER_CONTEXT, (instance) => { - this._modalContext = instance; - }); - } - - #onDelete() { - const modalContext = this._modalContext?.open(UMB_CONFIRM_MODAL, { - data: { - headline: `Delete ${this.value || 'item'}`, - content: 'Are you sure you want to delete this item?', - color: 'danger', - confirmLabel: 'Delete', - }, + async #onDelete() { + await umbConfirmModal(this, { + headline: `Delete ${this.value || 'item'}`, + content: 'Are you sure you want to delete this item?', + color: 'danger', + confirmLabel: 'Delete', }); - modalContext?.onSubmit().then(() => { - this.dispatchEvent(new UmbDeleteEvent()); - }); + this.dispatchEvent(new UmbDeleteEvent()); } #onInput(event: UUIInputEvent) { diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/entity-action/common/delete/delete.action.ts b/src/Umbraco.Web.UI.Client/src/packages/core/entity-action/common/delete/delete.action.ts index ff428c2f09..95f4d3a407 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/entity-action/common/delete/delete.action.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/entity-action/common/delete/delete.action.ts @@ -1,39 +1,22 @@ import { UmbEntityActionBase } from '../../entity-action.js'; -import { UmbContextConsumerController } from '@umbraco-cms/backoffice/context-api'; -import type { UmbControllerHostElement } from '@umbraco-cms/backoffice/controller-api'; -import type { UmbModalManagerContext} from '@umbraco-cms/backoffice/modal'; -import { UMB_MODAL_MANAGER_CONTEXT, UMB_CONFIRM_MODAL } from '@umbraco-cms/backoffice/modal'; +import { umbConfirmModal } from '@umbraco-cms/backoffice/modal'; import type { UmbDetailRepository, UmbItemRepository } from '@umbraco-cms/backoffice/repository'; export class UmbDeleteEntityAction< T extends UmbDetailRepository & UmbItemRepository, > extends UmbEntityActionBase { - #modalManager?: UmbModalManagerContext; - - constructor(host: UmbControllerHostElement, repositoryAlias: string, unique: string, entityType: string) { - super(host, repositoryAlias, unique, entityType); - - new UmbContextConsumerController(this._host, UMB_MODAL_MANAGER_CONTEXT, (instance) => { - this.#modalManager = instance; - }); - } - async execute() { - if (!this.repository || !this.#modalManager) return; + if (!this.repository) return; // TOOD: add back when entity actions can support multiple repositories //const { data } = await this.repository.requestItems([this.unique]); - const modalContext = this.#modalManager.open(UMB_CONFIRM_MODAL, { - data: { - headline: `Delete`, - content: 'Are you sure you want to delete this item?', - color: 'danger', - confirmLabel: 'Delete', - }, + await umbConfirmModal(this._host, { + headline: `Delete`, + content: 'Are you sure you want to delete this item?', + color: 'danger', + confirmLabel: 'Delete', }); - - await modalContext.onSubmit(); await this.repository?.delete(this.unique); } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/entity-action/common/trash/trash.action.ts b/src/Umbraco.Web.UI.Client/src/packages/core/entity-action/common/trash/trash.action.ts index eb51e0520c..8db299898a 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/entity-action/common/trash/trash.action.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/entity-action/common/trash/trash.action.ts @@ -1,23 +1,10 @@ import { UmbEntityActionBase } from '../../entity-action.js'; -import { UmbContextConsumerController } from '@umbraco-cms/backoffice/context-api'; -import type { UmbControllerHostElement } from '@umbraco-cms/backoffice/controller-api'; -import type { UmbModalManagerContext} from '@umbraco-cms/backoffice/modal'; -import { UMB_MODAL_MANAGER_CONTEXT, UMB_CONFIRM_MODAL } from '@umbraco-cms/backoffice/modal'; +import { umbConfirmModal } from '@umbraco-cms/backoffice/modal'; import type { UmbItemRepository } from '@umbraco-cms/backoffice/repository'; export class UmbTrashEntityAction< T extends UmbItemRepository & { trash(unique: string): Promise }, > extends UmbEntityActionBase { - #modalContext?: UmbModalManagerContext; - - constructor(host: UmbControllerHostElement, repositoryAlias: string, unique: string, entityType: string) { - super(host, repositoryAlias, unique, entityType); - - new UmbContextConsumerController(this._host, UMB_MODAL_MANAGER_CONTEXT, (instance) => { - this.#modalContext = instance; - }); - } - async execute() { if (!this.repository) return; @@ -26,18 +13,14 @@ export class UmbTrashEntityAction< if (data) { const item = data[0]; - const modalContext = this.#modalContext?.open(UMB_CONFIRM_MODAL, { - data: { - headline: `Trash ${item.name}`, - content: 'Are you sure you want to move this item to the recycle bin?', - color: 'danger', - confirmLabel: 'Trash', - }, + await umbConfirmModal(this._host, { + headline: `Trash ${item.name}`, + content: 'Are you sure you want to move this item to the recycle bin?', + color: 'danger', + confirmLabel: 'Trash', }); - modalContext?.onSubmit().then(() => { - this.repository?.trash(this.unique); - }); + this.repository?.trash(this.unique); } } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/extension-registry/collection/views/extension-table-action-column-layout.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/extension-registry/collection/views/extension-table-action-column-layout.element.ts index 1ae41e0fb1..92cdf10ec1 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/extension-registry/collection/views/extension-table-action-column-layout.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/extension-registry/collection/views/extension-table-action-column-layout.element.ts @@ -2,34 +2,20 @@ import { umbExtensionsRegistry } from '../../index.js'; import type { ManifestBase } from '@umbraco-cms/backoffice/extension-api'; import { html, customElement, property } from '@umbraco-cms/backoffice/external/lit'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; -import type { UmbModalManagerContext } from '@umbraco-cms/backoffice/modal'; -import { UMB_CONFIRM_MODAL, UMB_MODAL_MANAGER_CONTEXT } from '@umbraco-cms/backoffice/modal'; +import { umbConfirmModal } from '@umbraco-cms/backoffice/modal'; @customElement('umb-extension-table-action-column-layout') export class UmbExtensionTableActionColumnLayoutElement extends UmbLitElement { @property({ attribute: false }) value!: ManifestBase; - #modalContext?: UmbModalManagerContext; - - constructor() { - super(); - this.consumeContext(UMB_MODAL_MANAGER_CONTEXT, (instance) => { - this.#modalContext = instance; - }); - } - async #removeExtension() { - const modalContext = this.#modalContext?.open(UMB_CONFIRM_MODAL, { - data: { - headline: 'Unload extension', - confirmLabel: 'Unload', - content: html`

Are you sure you want to unload the extension ${this.value.alias}?

`, - color: 'danger', - }, + await umbConfirmModal(this, { + headline: 'Unload extension', + confirmLabel: 'Unload', + content: html`

Are you sure you want to unload the extension ${this.value.alias}?

`, + color: 'danger', }); - - await modalContext?.onSubmit(); umbExtensionsRegistry.unregister(this.value.alias); } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/extension-registry/models/store.model.ts b/src/Umbraco.Web.UI.Client/src/packages/core/extension-registry/models/store.model.ts index b092a15597..6dcd6f0a2c 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/extension-registry/models/store.model.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/extension-registry/models/store.model.ts @@ -1,8 +1,8 @@ import type { ManifestApi } from '@umbraco-cms/backoffice/extension-api'; -import type { UmbItemStore, UmbStoreBase } from '@umbraco-cms/backoffice/store'; +import type { UmbItemStore } from '@umbraco-cms/backoffice/store'; import type { UmbTreeStore } from '@umbraco-cms/backoffice/tree'; -export interface ManifestStore extends ManifestApi { +export interface ManifestStore extends ManifestApi { type: 'store'; } // TODO: TREE STORE TYPE PROBLEM: Provide a base tree item type? diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/modal/common/confirm/confirm-modal.controller.ts b/src/Umbraco.Web.UI.Client/src/packages/core/modal/common/confirm/confirm-modal.controller.ts new file mode 100644 index 0000000000..1fa482ab54 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/modal/common/confirm/confirm-modal.controller.ts @@ -0,0 +1,30 @@ +import { UMB_CONFIRM_MODAL, type UmbConfirmModalData } from '../../token/confirm-modal.token.js'; +import { UMB_MODAL_MANAGER_CONTEXT } from '../../context/modal-manager.context.js'; +import { UmbBaseController } from '@umbraco-cms/backoffice/class-api'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; + +export interface UmbConfirmModalArgs extends UmbConfirmModalData {} + +export class UmbConfirmModalController extends UmbBaseController { + async open(args: UmbConfirmModalArgs): Promise { + const modalManagerContext = await this.getContext(UMB_MODAL_MANAGER_CONTEXT); + + const modalContext = modalManagerContext.open(UMB_CONFIRM_MODAL, { + data: args, + }); + + await modalContext.onSubmit().catch(() => { + this.destroy(); + }); + + // This is a one time off, so we can destroy our selfs. + this.destroy(); + + // Map back into UmbVariantId instances: + return; + } +} + +export function umbConfirmModal(host: UmbControllerHost, args: UmbConfirmModalArgs) { + return new UmbConfirmModalController(host).open(args); +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/modal/modal-element.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/modal/component/modal-element.element.ts similarity index 100% rename from src/Umbraco.Web.UI.Client/src/packages/core/modal/modal-element.element.ts rename to src/Umbraco.Web.UI.Client/src/packages/core/modal/component/modal-element.element.ts diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/modal/modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/modal/component/modal.element.ts similarity index 97% rename from src/Umbraco.Web.UI.Client/src/packages/core/modal/modal.element.ts rename to src/Umbraco.Web.UI.Client/src/packages/core/modal/component/modal.element.ts index 7fcefa91c8..a7bb8308fd 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/modal/modal.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/modal/component/modal.element.ts @@ -1,5 +1,5 @@ -import type { UmbModalContext } from './modal.context.js'; -import { UMB_MODAL_CONTEXT } from './modal.context.js'; +import type { UmbModalContext } from '../context/modal.context.js'; +import { UMB_MODAL_CONTEXT } from '../context/modal.context.js'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import type { ManifestModal } from '@umbraco-cms/backoffice/extension-registry'; import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/modal/modal-manager.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/modal/context/modal-manager.context.ts similarity index 97% rename from src/Umbraco.Web.UI.Client/src/packages/core/modal/modal-manager.context.ts rename to src/Umbraco.Web.UI.Client/src/packages/core/modal/context/modal-manager.context.ts index f76066bbc3..6edd817eac 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/modal/modal-manager.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/modal/context/modal-manager.context.ts @@ -1,4 +1,4 @@ -import type { UmbModalToken } from './token/modal-token.js'; +import type { UmbModalToken } from '../token/modal-token.js'; import { UmbModalContext, type UmbModalContextClassArgs } from './modal.context.js'; import type { UUIModalSidebarSize } from '@umbraco-cms/backoffice/external/uui'; import { UmbBasicState, appendToFrozenArray } from '@umbraco-cms/backoffice/observable-api'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/modal/modal.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/modal/context/modal.context.ts similarity index 94% rename from src/Umbraco.Web.UI.Client/src/packages/core/modal/modal.context.ts rename to src/Umbraco.Web.UI.Client/src/packages/core/modal/context/modal.context.ts index c9c9fbeb74..227ba41e63 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/modal/modal.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/modal/context/modal.context.ts @@ -1,5 +1,5 @@ import type { UmbModalConfig, UmbModalType } from './modal-manager.context.js'; -import { UmbModalToken } from './token/modal-token.js'; +import { UmbModalToken } from '../token/modal-token.js'; import type { IRouterSlot } from '@umbraco-cms/backoffice/external/router-slot'; import type { UUIModalSidebarSize } from '@umbraco-cms/backoffice/external/uui'; import { UmbId } from '@umbraco-cms/backoffice/id'; @@ -81,6 +81,7 @@ export class UmbModalContext { modal?: UmbModalConfig; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/modal/modal.interfaces.ts b/src/Umbraco.Web.UI.Client/src/packages/core/modal/types.ts similarity index 100% rename from src/Umbraco.Web.UI.Client/src/packages/core/modal/modal.interfaces.ts rename to src/Umbraco.Web.UI.Client/src/packages/core/modal/types.ts diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/picker-input/picker-input.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/picker-input/picker-input.context.ts index 75e162dde2..01ccc82f7f 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/picker-input/picker-input.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/picker-input/picker-input.context.ts @@ -2,13 +2,8 @@ import { UmbChangeEvent } from '@umbraco-cms/backoffice/event'; import { type UmbItemRepository, UmbRepositoryItemsManager } from '@umbraco-cms/backoffice/repository'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import { UmbBaseController } from '@umbraco-cms/backoffice/class-api'; -import type { - UmbModalManagerContext, - UmbModalToken, - UmbPickerModalData, - UmbPickerModalValue, -} from '@umbraco-cms/backoffice/modal'; -import { UMB_CONFIRM_MODAL, UMB_MODAL_MANAGER_CONTEXT } from '@umbraco-cms/backoffice/modal'; +import type { UmbModalToken, UmbPickerModalData, UmbPickerModalValue } from '@umbraco-cms/backoffice/modal'; +import { UMB_MODAL_MANAGER_CONTEXT, umbConfirmModal } from '@umbraco-cms/backoffice/modal'; export class UmbPickerInputContext extends UmbBaseController { // TODO: We are way too unsecure about the requirements for the Modal Token, as we have certain expectation for the data and value. @@ -16,10 +11,6 @@ export class UmbPickerInputContext; #getUnique: (entry: ItemType) => string | undefined; - public modalManager?: UmbModalManagerContext; - - #init: Promise; - #itemManager; selection; @@ -63,13 +54,6 @@ export class UmbPickerInputContext { - this.modalManager = instance; - }).asPromise(), - ]); } getSelection() { @@ -82,10 +66,9 @@ export class UmbPickerInputContext>) { - await this.#init; - if (!this.modalManager) throw new Error('Modal manager context is not initialized'); - - const modalContext = this.modalManager.open(this.modalAlias, { + await this.#itemManager.init; + const modalManager = await this.getContext(UMB_MODAL_MANAGER_CONTEXT); + const modalContext = modalManager.open(this.modalAlias, { data: { multiple: this._max === 1 ? false : true, ...pickerData, @@ -105,16 +88,12 @@ export class UmbPickerInputContext this.#getUnique(item) === unique); if (!item) throw new Error('Could not find item with unique: ' + unique); - const modalContext = this.modalManager?.open(UMB_CONFIRM_MODAL, { - data: { - color: 'danger', - headline: `Remove ${item.name}?`, - content: 'Are you sure you want to remove this item', - confirmLabel: 'Remove', - }, + await umbConfirmModal(this, { + color: 'danger', + headline: `Remove ${item.name}?`, + content: 'Are you sure you want to remove this item', + confirmLabel: 'Remove', }); - - await modalContext?.onSubmit(); this.#removeItem(unique); } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/tree/folder/entity-action/delete-folder/delete-folder.action.ts b/src/Umbraco.Web.UI.Client/src/packages/core/tree/folder/entity-action/delete-folder/delete-folder.action.ts index 076f360877..ce2fcfc959 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/tree/folder/entity-action/delete-folder/delete-folder.action.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/tree/folder/entity-action/delete-folder/delete-folder.action.ts @@ -1,38 +1,22 @@ import { UmbEntityActionBase } from '../../../../entity-action/entity-action.js'; -import { UmbContextConsumerController } from '@umbraco-cms/backoffice/context-api'; -import type { UmbControllerHostElement } from '@umbraco-cms/backoffice/controller-api'; -import type { UmbModalManagerContext} from '@umbraco-cms/backoffice/modal'; -import { UMB_MODAL_MANAGER_CONTEXT, UMB_CONFIRM_MODAL } from '@umbraco-cms/backoffice/modal'; +import { umbConfirmModal } from '@umbraco-cms/backoffice/modal'; import type { UmbFolderRepository } from '@umbraco-cms/backoffice/tree'; export class UmbDeleteFolderEntityAction extends UmbEntityActionBase { - #modalContext?: UmbModalManagerContext; - - constructor(host: UmbControllerHostElement, repositoryAlias: string, unique: string, entityType: string) { - super(host, repositoryAlias, unique, entityType); - - new UmbContextConsumerController(this._host, UMB_MODAL_MANAGER_CONTEXT, (instance) => { - this.#modalContext = instance; - }); - } - async execute() { - if (!this.repository || !this.#modalContext) return; + if (!this.repository) return; const { data: folder } = await this.repository.request(this.unique); if (folder) { // TODO: maybe we can show something about how many items are part of the folder? - const modalContext = this.#modalContext.open(UMB_CONFIRM_MODAL, { - data: { - headline: `Delete folder ${folder.name}`, - content: 'Are you sure you want to delete this folder?', - color: 'danger', - confirmLabel: 'Delete', - }, - }); - await modalContext.onSubmit(); + await umbConfirmModal(this._host, { + headline: `Delete folder ${folder.name}`, + content: 'Are you sure you want to delete this folder?', + color: 'danger', + confirmLabel: 'Delete', + }); await this.repository?.delete(this.unique); } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/utils/selection-manager/selection.manager.test.ts b/src/Umbraco.Web.UI.Client/src/packages/core/utils/selection-manager/selection.manager.test.ts index adf7318509..32f70c9825 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/utils/selection-manager/selection.manager.test.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/utils/selection-manager/selection.manager.test.ts @@ -5,7 +5,7 @@ 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) {} +class UmbTestControllerHostElement extends UmbControllerHostElementMixin(HTMLElement) {} describe('UmbSelectionManager', () => { let manager: UmbSelectionManager; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/utils/selection-manager/selection.manager.ts b/src/Umbraco.Web.UI.Client/src/packages/core/utils/selection-manager/selection.manager.ts index 9451be884d..4f1f5a9d49 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/utils/selection-manager/selection.manager.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/utils/selection-manager/selection.manager.ts @@ -8,11 +8,11 @@ import { UmbArrayState, UmbBooleanState } from '@umbraco-cms/backoffice/observab * @export * @class UmbSelectionManager */ -export class UmbSelectionManager extends UmbBaseController { +export class UmbSelectionManager extends UmbBaseController { #selectable = new UmbBooleanState(false); public readonly selectable = this.#selectable.asObservable(); - #selection = new UmbArrayState(>[], (x) => x); + #selection = new UmbArrayState(>[], (x) => x); public readonly selection = this.#selection.asObservable(); #multiple = new UmbBooleanState(false); @@ -51,10 +51,10 @@ export class UmbSelectionManager extends UmbBaseController { /** * Sets the current selection. - * @param {Array} value + * @param {Array} value * @memberof UmbSelectionManager */ - public setSelection(value: Array) { + 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.slice(0, 1); @@ -87,20 +87,20 @@ export class UmbSelectionManager extends UmbBaseController { /** * Toggles the given unique id in the current selection. - * @param {(string | null)} unique + * @param {(ValueType)} unique * @memberof UmbSelectionManager */ - public toggleSelect(unique: string | null) { + public toggleSelect(unique: ValueType) { if (this.getSelectable() === false) return; this.isSelected(unique) ? this.deselect(unique) : this.select(unique); } /** * Appends the given unique id to the current selection. - * @param {(string | null)} unique + * @param {(ValueType)} unique * @memberof UmbSelectionManager */ - public select(unique: string | null) { + public select(unique: ValueType) { if (this.getSelectable() === false) return; if (this.isSelected(unique)) return; const newSelection = this.getMultiple() ? [...this.getSelection(), unique] : [unique]; @@ -110,10 +110,10 @@ export class UmbSelectionManager extends UmbBaseController { /** * Removes the given unique id from the current selection. - * @param {(string | null)} unique + * @param {(ValueType)} unique * @memberof UmbSelectionManager */ - public deselect(unique: string | null) { + public deselect(unique: ValueType) { if (this.getSelectable() === false) return; const newSelection = this.getSelection().filter((x) => x !== unique); this.#selection.setValue(newSelection); @@ -122,11 +122,11 @@ export class UmbSelectionManager extends UmbBaseController { /** * Returns true if the given unique id is selected. - * @param {(string | null)} unique + * @param {(ValueType)} unique * @return {*} * @memberof UmbSelectionManager */ - public isSelected(unique: string | null) { + public isSelected(unique: ValueType) { return this.getSelection().includes(unique); } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/variant/types.ts b/src/Umbraco.Web.UI.Client/src/packages/core/variant/types.ts index 2b5997280d..ee32d57e86 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/variant/types.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/variant/types.ts @@ -1,3 +1,5 @@ +import type { UmbLanguageDetailModel } from '@umbraco-cms/backoffice/language'; + export interface UmbVariantModel { createDate: string | null; culture: string | null; @@ -5,3 +7,12 @@ export interface UmbVariantModel { segment: string | null; updateDate: string | null; } + +export interface UmbVariantOptionModel { + variant?: VariantType; + language: UmbLanguageDetailModel; + /** + * The unique identifier is a VariantId string. + */ + unique: string; +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/variant/variant-id.class.ts b/src/Umbraco.Web.UI.Client/src/packages/core/variant/variant-id.class.ts index 39a8b85b12..c131029e8a 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/variant/variant-id.class.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/variant/variant-id.class.ts @@ -1,32 +1,45 @@ -export type variantObject = { +export type UmbObjectWithVariantProperties = { culture: string | null; segment: string | null; - schedule?: { publishTime?: string | null; unpublishTime?: string | null }; }; +export function variantPropertiesObjectToString(variant: UmbObjectWithVariantProperties): string { + // Currently a direct copy of the toString method of variantId. + return (variant.culture || UMB_INVARIANT_CULTURE) + (variant.segment ? `_${variant.segment}` : ''); +} + export const UMB_INVARIANT_CULTURE = 'invariant'; +/** + * An identifier representing a Variant. This is at current state a culture and a segment. + * The identifier is not specific for ContentType Variants, but is used for many type of identification of a culture and a segment. One case is any property of a ContentType can be resolved into a VariantId depending on their structural settings such as Vary by Culture and Vary by Segmentation. + */ export class UmbVariantId { - public static Create(variantData: variantObject): UmbVariantId { - return Object.freeze(new UmbVariantId(variantData)); + public static Create(variantData: UmbObjectWithVariantProperties): UmbVariantId { + return Object.freeze(new UmbVariantId(variantData.culture, variantData.segment)); } public static CreateInvariant(): UmbVariantId { - return Object.freeze(new UmbVariantId({ culture: null, segment: null })); + return Object.freeze(new UmbVariantId(null, null)); + } + + public static FromString(str: string): UmbVariantId { + const split = str.split('_'); + const culture = split[0] === UMB_INVARIANT_CULTURE ? null : split[0]; + const segment = split[1] ?? null; + return Object.freeze(new UmbVariantId(segment, culture)); } public readonly culture: string | null = null; public readonly segment: string | null = null; - public readonly schedule: { publishTime?: string | null; unpublishTime?: string | null } | null = null; - constructor(variantData: variantObject) { - this.culture = (variantData.culture === UMB_INVARIANT_CULTURE ? null : variantData.culture) ?? null; - this.segment = variantData.segment ?? null; - this.schedule = variantData.schedule ?? null; + constructor(culture?: string | null, segment?: string | null) { + this.culture = (culture === UMB_INVARIANT_CULTURE ? null : culture?.toLowerCase()) ?? null; + this.segment = segment ?? null; } - public compare(obj: variantObject): boolean { - return this.equal(new UmbVariantId(obj)); + public compare(obj: UmbObjectWithVariantProperties): boolean { + return this.equal(new UmbVariantId(obj.culture, obj.segment)); } public equal(variantId: UmbVariantId): boolean { @@ -34,6 +47,7 @@ export class UmbVariantId { } public toString(): string { + // Currently a direct copy of the VariantPropertiesObjectToString method const. return (this.culture || UMB_INVARIANT_CULTURE) + (this.segment ? `_${this.segment}` : ''); } @@ -57,11 +71,12 @@ export class UmbVariantId { return this.culture === null && this.segment === null; } - public toObject(): variantObject { + public toObject(): UmbObjectWithVariantProperties { return { culture: this.culture, segment: this.segment }; } // TODO: needs localization option: + // TODO: Consider if this should be handled else where, it does not seem like the responsibility of this class, since it contains wordings: public toDifferencesString(variantId: UmbVariantId): string { let r = ''; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/variant-selector/variant-selector.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/variant-selector/variant-selector.element.ts index 19f614d289..d3a961829c 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/variant-selector/variant-selector.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/variant-selector/variant-selector.element.ts @@ -5,20 +5,22 @@ import { UUIInputEvent, type UUIPopoverContainerElement, } from '@umbraco-cms/backoffice/external/uui'; -import { - css, - html, - nothing, - customElement, - property, - state, - ifDefined, - query, -} from '@umbraco-cms/backoffice/external/lit'; +import { css, html, nothing, customElement, state, query } from '@umbraco-cms/backoffice/external/lit'; import { UMB_WORKSPACE_SPLIT_VIEW_CONTEXT, type ActiveVariant } from '@umbraco-cms/backoffice/workspace'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import { DocumentVariantStateModel } from '@umbraco-cms/backoffice/external/backend-api'; -import type { UmbDocumentVariantModel } from '@umbraco-cms/backoffice/document'; +import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; +import type { UmbDocumentWorkspaceContext } from '@umbraco-cms/backoffice/document'; + +type UmbDocumentVariantOption = { + culture: string | null; + segment: string | null; + title: string; + displayName: string; + state: DocumentVariantStateModel; +}; + +type UmbDocumentVariantOptions = Array; @customElement('umb-variant-selector') export class UmbVariantSelectorElement extends UmbLitElement { @@ -26,73 +28,75 @@ export class UmbVariantSelectorElement extends UmbLitElement { private _popoverElement?: UUIPopoverContainerElement; @state() - _variants: Array = []; + private _variants: UmbDocumentVariantOptions = []; // TODO: Stop using document context specific ActiveVariant type. @state() _activeVariants: Array = []; - @property({ attribute: false }) - public get _activeVariantsCultures(): string[] { - return this._activeVariants.map((el) => el.culture ?? '') ?? []; - } + @state() + _activeVariantsCultures: string[] = []; #splitViewContext?: typeof UMB_WORKSPACE_SPLIT_VIEW_CONTEXT.TYPE; - #variantContext?: typeof UMB_PROPERTY_DATASET_CONTEXT.TYPE; + #datasetContext?: typeof UMB_PROPERTY_DATASET_CONTEXT.TYPE; @state() private _name?: string; - private _culture?: string | null; - private _segment?: string | null; + @state() + private _variantDisplayName = ''; @state() - private _variantDisplayName?: string; - - @state() - private _variantTitleName?: string; + private _variantTitleName = ''; @state() private _variantSelectorOpen = false; - // TODO: make adapt to backoffice locale. - private _cultureNames = new Intl.DisplayNames('en', { type: 'language' }); - constructor() { super(); this.consumeContext(UMB_WORKSPACE_SPLIT_VIEW_CONTEXT, (instance) => { this.#splitViewContext = instance; - this._observeVariants(); - this._observeActiveVariants(); + this.#observeVariants(); + this.#observeActiveVariants(); + this.#observeCurrentVariant(); }); this.consumeContext(UMB_PROPERTY_DATASET_CONTEXT, (instance) => { - this.#variantContext = instance; - this._observeVariantContext(); + this.#datasetContext = instance; + this.#observeDatasetContext(); + this.#observeCurrentVariant(); }); } - private async _observeVariants() { + async #observeVariants() { if (!this.#splitViewContext) return; - const workspaceContext = this.#splitViewContext.getWorkspaceContext(); - if (workspaceContext) { - this.observe( - workspaceContext.variants, - (variants) => { - if (variants) { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - // TODO: figure out what we do with the different variant models. Document has a state, but the variant model does not. - this._variants = variants; - } - }, - '_observeVariants', - ); - } + // NOTICE: This is dirty (the TypeScript casting), we can only accept doing this so far because we currently only use the Variant Selector on Document Workspace. [NL] + // This would need a refactor to enable the code below to work with different ContentTypes. Main problem here is the state, which is not generic for them all. [NL] + const workspaceContext = this.#splitViewContext.getWorkspaceContext() as UmbDocumentWorkspaceContext; + if (!workspaceContext) throw new Error('Split View Workspace context not found'); + + this.observe( + workspaceContext.variantOptions, + (options) => { + this._variants = options.map((option) => { + const name = option.variant?.name ?? option.language.name; + const segment = option.variant?.segment ?? null; + return { + // Notice the option object has a unique property, but it's not used here. (Its equivalent to a UmbVariantId string) [NL] + culture: option.language.unique, + segment: segment, + title: name + (segment ? ` — ${segment}` : ''), + displayName: name + (segment ? ` — ${segment}` : ''), + state: option.variant?.state ?? DocumentVariantStateModel.NOT_CREATED, + }; + }); + }, + '_observeVariants', + ); } - private async _observeActiveVariants() { + async #observeActiveVariants() { if (!this.#splitViewContext) return; const workspaceContext = this.#splitViewContext.getWorkspaceContext(); @@ -102,6 +106,7 @@ export class UmbVariantSelectorElement extends UmbLitElement { (activeVariants) => { if (activeVariants) { this._activeVariants = activeVariants; + this._activeVariantsCultures = this._activeVariants.map((el) => el.culture ?? '') ?? []; } }, '_observeActiveVariants', @@ -109,16 +114,10 @@ export class UmbVariantSelectorElement extends UmbLitElement { } } - private async _observeVariantContext() { - if (!this.#variantContext) return; - - const variantId = this.#variantContext.getVariantId(); - this._culture = variantId.culture; - this._segment = variantId.segment; - this.updateVariantDisplayName(); - + async #observeDatasetContext() { + if (!this.#datasetContext) return; this.observe( - this.#variantContext.name, + this.#datasetContext.name, (name) => { this._name = name; }, @@ -126,48 +125,62 @@ export class UmbVariantSelectorElement extends UmbLitElement { ); } - private updateVariantDisplayName() { - if (!this._culture && !this._segment) return; - this._variantTitleName = - (this._culture ? this._cultureNames.of(this._culture) + ` (${this._culture})` : '') + - (this._segment ? ' — ' + this._segment : ''); - this._variantDisplayName = - (this._culture ? this._cultureNames.of(this._culture) : '') + (this._segment ? ' — ' + this._segment : ''); + async #observeCurrentVariant() { + if (!this.#datasetContext || !this.#splitViewContext) return; + const workspaceContext = this.#splitViewContext.getWorkspaceContext(); + if (!workspaceContext) return; + + const variantId = this.#datasetContext.getVariantId(); + // Find the variant option matching this, to get the language name... + + const culture = variantId.culture; + const segment = variantId.segment; + + this.observe( + workspaceContext.variantOptions, + (options) => { + const option = options.find((option) => option.language.unique === culture); + const languageName = option?.language.name; + this._variantDisplayName = (languageName ? languageName : '') + (segment ? ` — ${segment}` : ''); + this._variantTitleName = + (languageName ? `${languageName} (${culture})` : '') + (segment ? ` — ${segment}` : ''); + }, + '_currentLanguage', + ); } - // TODO: find a way where we don't have to do this for all workspaces. - private _handleInput(event: UUIInputEvent) { + #handleInput(event: UUIInputEvent) { if (event instanceof UUIInputEvent) { const target = event.composedPath()[0] as UUIInputElement; if ( typeof target?.value === 'string' && - this.#variantContext && - isNameablePropertyDatasetContext(this.#variantContext) + this.#datasetContext && + isNameablePropertyDatasetContext(this.#datasetContext) ) { - this.#variantContext.setName(target.value); + this.#datasetContext.setName(target.value); } } } - private _switchVariant(variant: UmbDocumentVariantModel) { + #switchVariant(variant: UmbDocumentVariantOption) { this.#splitViewContext?.switchVariant(UmbVariantId.Create(variant)); } - private _openSplitView(variant: UmbDocumentVariantModel) { + #openSplitView(variant: UmbDocumentVariantOption) { this.#splitViewContext?.openSplitView(UmbVariantId.Create(variant)); } - private _closeSplitView() { + #closeSplitView() { this.#splitViewContext?.closeSplitView(); } - private _isVariantActive(culture: string) { - return this._activeVariantsCultures.includes(culture); + #isVariantActive(culture: string | null) { + return culture !== null ? this._activeVariantsCultures.includes(culture) : true; } - private _isNotPublishedMode(culture: string, state: DocumentVariantStateModel) { - return state !== DocumentVariantStateModel.PUBLISHED && !this._isVariantActive(culture!); + #isNotPublishedMode(culture: string | null, state: DocumentVariantStateModel) { + return state !== DocumentVariantStateModel.PUBLISHED && !this.#isVariantActive(culture); } // TODO: This ignorer is just needed for JSON SCHEMA TO WORK, As its not updated with latest TS jet. @@ -182,26 +195,27 @@ export class UmbVariantSelectorElement extends UmbLitElement { if (!isOpen) return; const host = this.getBoundingClientRect(); + // TODO: Ideally this is kept updated while open, but for now we just set it once: this._popoverElement.style.width = `${host.width}px`; } render() { return html` - + ${ - this._variants && this._variants.length > 0 + this._variants?.length ? html` + title=${this._variantTitleName}> ${this._variantDisplayName} ${this._activeVariants.length > 1 ? html` - + ` @@ -212,7 +226,7 @@ export class UmbVariantSelectorElement extends UmbLitElement { ${ - this._variants && this._variants.length > 0 + this._variants?.length ? html` ${this._variants.map( (variant) => html` -
  • +
  • - ${this._isVariantActive(variant.culture!) + ${this.#isVariantActive(variant.culture) ? nothing : html` this._openSplitView(variant)}> + @click=${() => this.#openSplitView(variant)}> Split view `} @@ -260,6 +275,7 @@ export class UmbVariantSelectorElement extends UmbLitElement { } static styles = [ + UmbTextStyles, css` #name-input { width: 100%; @@ -331,12 +347,6 @@ export class UmbVariantSelectorElement extends UmbLitElement { font-size: 14px; cursor: pointer; border-bottom: 1px solid var(--uui-color-divider-standalone); - font-family: - Lato, - Helvetica Neue, - Helvetica, - Arial, - sans-serif; } .variant-selector-switch-button:hover { diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/workspace-context/workspace-variantable-context.interface.ts b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/workspace-context/workspace-variantable-context.interface.ts index 4e2004e5f2..341f6b897a 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/workspace-context/workspace-variantable-context.interface.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/workspace-context/workspace-variantable-context.interface.ts @@ -2,16 +2,18 @@ import type { UmbWorkspaceSplitViewManager } from '../workspace-split-view-manag import type { UmbPropertyDatasetContext } from '../../property/property-dataset/property-dataset-context.interface.js'; import type { UmbSaveableWorkspaceContextInterface } from './saveable-workspace-context.interface.js'; import type { Observable } from '@umbraco-cms/backoffice/external/rxjs'; -import type { UmbVariantId, UmbVariantModel } from '@umbraco-cms/backoffice/variant'; +import type { UmbVariantId, UmbVariantModel, UmbVariantOptionModel } from '@umbraco-cms/backoffice/variant'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; -export interface UmbVariantableWorkspaceContextInterface extends UmbSaveableWorkspaceContextInterface { +export interface UmbVariantableWorkspaceContextInterface + extends UmbSaveableWorkspaceContextInterface { // Name: getName(variantId?: UmbVariantId): string | undefined; setName(name: string, variantId?: UmbVariantId): void; // Variant: variants: Observable>; + variantOptions: Observable>>; splitView: UmbWorkspaceSplitViewManager; getVariant(variantId: UmbVariantId): UmbVariantModel | undefined; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/workspace-split-view-manager.class.ts b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/workspace-split-view-manager.class.ts index b7907e9277..63bbf04d0a 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/workspace-split-view-manager.class.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/workspace-split-view-manager.class.ts @@ -25,7 +25,7 @@ export class UmbWorkspaceSplitViewManager { } setActiveVariant(index: number, culture: string | null, segment: string | null) { - this.#activeVariantsInfo.appendOne({ index, culture, segment }); + this.#activeVariantsInfo.appendOne({ index, culture: culture || null, segment: segment || null }); } getActiveVariants() { @@ -53,7 +53,7 @@ export class UmbWorkspaceSplitViewManager { const newVariants = [...activeVariants]; newVariants[index] = { index, culture: variantId.culture, segment: variantId.segment }; - const variantPart: string = newVariants.map((v) => new UmbVariantId(v).toString()).join('_&_'); + const variantPart: string = newVariants.map((v) => UmbVariantId.Create(v).toString()).join('_&_'); history.pushState(null, '', `${workspaceRoute}/${variantPart}`); return true; @@ -70,7 +70,7 @@ export class UmbWorkspaceSplitViewManager { const currentVariant = this.getActiveVariants()[0]; const workspaceRoute = this.getWorkspaceRoute(); if (currentVariant && workspaceRoute) { - history.pushState(null, '', `${workspaceRoute}/${new UmbVariantId(currentVariant)}_&_${newVariant.toString()}`); + history.pushState(null, '', `${workspaceRoute}/${UmbVariantId.Create(currentVariant)}_&_${newVariant}`); return true; } return false; @@ -83,7 +83,7 @@ export class UmbWorkspaceSplitViewManager { if (activeVariants && index < activeVariants.length) { const newVariants = activeVariants.filter((x) => x.index !== index); - const variantPart: string = newVariants.map((v) => new UmbVariantId(v).toString()).join('_&_'); + const variantPart: string = newVariants.map((v) => UmbVariantId.Create(v)).join('_&_'); history.pushState(null, '', `${workspaceRoute}/${variantPart}`); return true; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/dashboards/redirect-management/dashboard-redirect-management.element.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/dashboards/redirect-management/dashboard-redirect-management.element.ts index 20e98a216d..55c4451cfd 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/dashboards/redirect-management/dashboard-redirect-management.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/dashboards/redirect-management/dashboard-redirect-management.element.ts @@ -1,7 +1,6 @@ import type { UUIButtonState, UUIPaginationElement, UUIPaginationEvent } from '@umbraco-cms/backoffice/external/uui'; import { css, html, nothing, customElement, state, query, property } from '@umbraco-cms/backoffice/external/lit'; -import type { UmbModalManagerContext } from '@umbraco-cms/backoffice/modal'; -import { UMB_MODAL_MANAGER_CONTEXT, UMB_CONFIRM_MODAL } from '@umbraco-cms/backoffice/modal'; +import { umbConfirmModal } from '@umbraco-cms/backoffice/modal'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import type { RedirectUrlResponseModel } from '@umbraco-cms/backoffice/external/backend-api'; import { RedirectManagementResource, RedirectStatusModel } from '@umbraco-cms/backoffice/external/backend-api'; @@ -37,15 +36,6 @@ export class UmbDashboardRedirectManagementElement extends UmbLitElement { @query('uui-pagination') private _pagination?: UUIPaginationElement; - private _modalContext?: UmbModalManagerContext; - - constructor() { - super(); - this.consumeContext(UMB_MODAL_MANAGER_CONTEXT, (_instance) => { - this._modalContext = _instance; - }); - } - connectedCallback() { super.connectedCallback(); this.#getTrackerStatus(); @@ -80,29 +70,23 @@ export class UmbDashboardRedirectManagementElement extends UmbLitElement { } // Delete Redirect Action - #onRequestDelete(data: RedirectUrlResponseModel) { + async #onRequestDelete(data: RedirectUrlResponseModel) { if (!data.id) return; - const modalContext = this._modalContext?.open(UMB_CONFIRM_MODAL, { - data: { - headline: 'Delete', - content: html` -
    -

    ${this.localize.term('redirectUrls_redirectRemoveWarning')}

    - ${this.localize.term('redirectUrls_originalUrl')}: ${data.originalUrl}
    - ${this.localize.term('redirectUrls_redirectedTo')}: ${data.destinationUrl} -
    - `, - color: 'danger', - confirmLabel: 'Delete', - }, + + await umbConfirmModal(this, { + headline: 'Delete', + content: html` +
    +

    ${this.localize.term('redirectUrls_redirectRemoveWarning')}

    + ${this.localize.term('redirectUrls_originalUrl')}: ${data.originalUrl}
    + ${this.localize.term('redirectUrls_redirectedTo')}: ${data.destinationUrl} +
    + `, + color: 'danger', + confirmLabel: 'Delete', }); - modalContext - ?.onSubmit() - .then(() => { - this.#redirectDelete(data.id!); - }) - .catch(() => undefined); + this.#redirectDelete(data.id!); } async #redirectDelete(id: string) { const { error } = await tryExecuteAndNotify(this, RedirectManagementResource.deleteRedirectManagementById({ id })); @@ -125,26 +109,20 @@ export class UmbDashboardRedirectManagementElement extends UmbLitElement { } // Tracker disable/enable - #onRequestTrackerToggle() { + async #onRequestTrackerToggle() { if (!this._trackerEnabled) { this.#trackerToggle(); return; } - const modalContext = this._modalContext?.open(UMB_CONFIRM_MODAL, { - data: { - headline: `${this.localize.term('redirectUrls_disableUrlTracker')}`, - content: `${this.localize.term('redirectUrls_confirmDisable')}`, - color: 'danger', - confirmLabel: 'Disable', - }, + await umbConfirmModal(this, { + headline: `${this.localize.term('redirectUrls_disableUrlTracker')}`, + content: `${this.localize.term('redirectUrls_confirmDisable')}`, + color: 'danger', + confirmLabel: 'Disable', }); - modalContext - ?.onSubmit() - .then(() => { - this.#trackerToggle(); - }) - .catch(() => undefined); + + this.#trackerToggle(); } async #trackerToggle() { diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/workspace/views/design/document-type-workspace-view-edit-property.element.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/workspace/views/design/document-type-workspace-view-edit-property.element.ts index b9e219b200..6172d07212 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/workspace/views/design/document-type-workspace-view-edit-property.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/workspace/views/design/document-type-workspace-view-edit-property.element.ts @@ -2,13 +2,11 @@ import { UmbDataTypeDetailRepository } from '@umbraco-cms/backoffice/data-type'; import type { UUIInputElement } from '@umbraco-cms/backoffice/external/uui'; import { UUIInputEvent } from '@umbraco-cms/backoffice/external/uui'; import { css, html, customElement, property, state, ifDefined, nothing } from '@umbraco-cms/backoffice/external/lit'; -import type { UmbConfirmModalData } from '@umbraco-cms/backoffice/modal'; import { - UMB_CONFIRM_MODAL, - UMB_MODAL_MANAGER_CONTEXT, UMB_PROPERTY_SETTINGS_MODAL, UMB_WORKSPACE_MODAL, UmbModalRouteRegistrationController, + umbConfirmModal, } from '@umbraco-cms/backoffice/modal'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import { generateAlias } from '@umbraco-cms/backoffice/utils'; @@ -57,7 +55,6 @@ export class UmbDocumentTypeWorkspacePropertyElement extends UmbLitElement { #dataTypeDetailRepository = new UmbDataTypeDetailRepository(this); #modalRegistration; - private _modalManagerContext?: typeof UMB_MODAL_MANAGER_CONTEXT.TYPE; @state() protected _modalRoute?: string; @@ -113,10 +110,6 @@ export class UmbDocumentTypeWorkspacePropertyElement extends UmbLitElement { .observeRouteBuilder((routeBuilder) => { this._editDocumentTypePath = routeBuilder({}); }); - - this.consumeContext(UMB_MODAL_MANAGER_CONTEXT, (context) => { - this._modalManagerContext = context; - }); } _partialUpdate(partialObject: UmbPropertyTypeModel) { @@ -137,12 +130,12 @@ export class UmbDocumentTypeWorkspacePropertyElement extends UmbLitElement { this._aliasLocked = !this._aliasLocked; } - #requestRemove(e: Event) { + async #requestRemove(e: Event) { e.preventDefault(); e.stopImmediatePropagation(); if (!this.property || !this.property.id) return; - const modalData: UmbConfirmModalData = { + await umbConfirmModal(this, { headline: `${this.localize.term('actions_delete')} property`, content: html``, confirmLabel: this.localize.term('actions_delete'), color: 'danger', - }; + }); - const modalHandler = this._modalManagerContext?.open(UMB_CONFIRM_MODAL, { data: modalData }); - - modalHandler - ?.onSubmit() - .then(() => { - this.dispatchEvent(new CustomEvent('property-delete')); - }) - .catch(() => { - // We do not need to react to cancel, so we will leave an empty method to prevent Uncaught Promise Rejection error. - return; - }); + this.dispatchEvent(new CustomEvent('property-delete')); } #onNameChange(event: UUIInputEvent) { diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/workspace/views/design/document-type-workspace-view-edit.element.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/workspace/views/design/document-type-workspace-view-edit.element.ts index 494e6d5596..be960c0205 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/workspace/views/design/document-type-workspace-view-edit.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/workspace/views/design/document-type-workspace-view-edit.element.ts @@ -15,7 +15,7 @@ import { UMB_WORKSPACE_CONTEXT } from '@umbraco-cms/backoffice/workspace'; import type { UmbRoute, UmbRouterSlotChangeEvent, UmbRouterSlotInitEvent } from '@umbraco-cms/backoffice/router'; import type { UmbWorkspaceViewElement } from '@umbraco-cms/backoffice/extension-registry'; import type { UmbConfirmModalData } from '@umbraco-cms/backoffice/modal'; -import { UMB_CONFIRM_MODAL, UMB_MODAL_MANAGER_CONTEXT } from '@umbraco-cms/backoffice/modal'; +import { UMB_MODAL_MANAGER_CONTEXT, umbConfirmModal } from '@umbraco-cms/backoffice/modal'; import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; import { UmbSorterController } from '@umbraco-cms/backoffice/sorter'; @@ -99,8 +99,6 @@ export class UmbDocumentTypeWorkspaceViewEditElement extends UmbLitElement imple private _tabsStructureHelper = new UmbContentTypeContainerStructureHelper(this); - private _modalManagerContext?: typeof UMB_MODAL_MANAGER_CONTEXT.TYPE; - @state() private _compositionConfiguration?: UmbCompositionPickerModalData; @@ -154,10 +152,6 @@ export class UmbDocumentTypeWorkspaceViewEditElement extends UmbLitElement imple this._observeRootGroups(); }); - - this.consumeContext(UMB_MODAL_MANAGER_CONTEXT, (context) => { - this._modalManagerContext = context; - }); } private _observeRootGroups() { @@ -220,7 +214,7 @@ export class UmbDocumentTypeWorkspaceViewEditElement extends UmbLitElement imple this._routes = routes; } - #requestRemoveTab(tab: PropertyTypeContainerModelBaseModel | undefined) { + async #requestRemoveTab(tab: PropertyTypeContainerModelBaseModel | undefined) { const modalData: UmbConfirmModalData = { headline: 'Delete tab', content: html` @@ -237,11 +231,9 @@ export class UmbDocumentTypeWorkspaceViewEditElement extends UmbLitElement imple // TODO: If this tab is composed of other tabs, then notify that it will only delete the local tab. - const modalHandler = this._modalManagerContext?.open(UMB_CONFIRM_MODAL, { data: modalData }); + await umbConfirmModal(this, modalData); - modalHandler?.onSubmit().then(() => { - this.#remove(tab?.id); - }); + this.#remove(tab?.id); } #remove(tabId?: string) { if (!tabId) return; @@ -303,7 +295,8 @@ export class UmbDocumentTypeWorkspaceViewEditElement extends UmbLitElement imple } async #openCompositionModal() { - const modalContext = this._modalManagerContext?.open(UMB_COMPOSITION_PICKER_MODAL, { + const modalManagerContext = await this.getContext(UMB_MODAL_MANAGER_CONTEXT); + const modalContext = modalManagerContext.open(UMB_COMPOSITION_PICKER_MODAL, { data: this._compositionConfiguration, }); await modalContext?.onSubmit(); diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/conditions/document-workspace-has-collection.condition.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/conditions/document-workspace-has-collection.condition.ts index 7b8c839c06..7d89100596 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/conditions/document-workspace-has-collection.condition.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/conditions/document-workspace-has-collection.condition.ts @@ -19,9 +19,9 @@ export class UmbDocumentWorkspaceHasCollectionCondition extends UmbBaseControlle this.consumeContext(UMB_DOCUMENT_WORKSPACE_CONTEXT, (context) => { this.observe( - context.contentTypeCollection, - (collection) => { - this.permitted = !!collection?.unique; + context.contentTypeHasCollection, + (hasCollection) => { + this.permitted = hasCollection; this.#onChange(); }, 'observeCollection', diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/entity-actions/publish.action.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/entity-actions/publish.action.ts index d7c794512d..f5b25bef73 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/entity-actions/publish.action.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/entity-actions/publish.action.ts @@ -1,21 +1,50 @@ -import { UMB_DOCUMENT_VARIANT_MANAGER_CONTEXT } from '../global-contexts/index.js'; -import type { UmbDocumentPublishingRepository } from '../repository/index.js'; +import { umbPickDocumentVariantModal } from '../modals/pick-document-variant-modal.controller.js'; +import { type UmbDocumentDetailRepository, UmbDocumentPublishingRepository } from '../repository/index.js'; +import { UmbDocumentVariantState } from '../types.js'; +import { UmbLanguageCollectionRepository } from '@umbraco-cms/backoffice/language'; import { UmbEntityActionBase } from '@umbraco-cms/backoffice/entity-action'; -import type { UmbControllerHostElement } from '@umbraco-cms/backoffice/controller-api'; - -export class UmbPublishDocumentEntityAction extends UmbEntityActionBase { - #variantManagerContext?: typeof UMB_DOCUMENT_VARIANT_MANAGER_CONTEXT.TYPE; - - constructor(host: UmbControllerHostElement, repositoryAlias: string, unique: string, entityType: string) { - super(host, repositoryAlias, unique, entityType); - - this.consumeContext(UMB_DOCUMENT_VARIANT_MANAGER_CONTEXT, (context) => { - this.#variantManagerContext = context; - }); - } +import { UmbVariantId } from '@umbraco-cms/backoffice/variant'; +export class UmbPublishDocumentEntityAction extends UmbEntityActionBase { async execute() { - if (!this.#variantManagerContext) throw new Error('Variant manager context is missing'); - await this.#variantManagerContext.publish(this.unique); + if (!this.repository) throw new Error('Document repository not set'); + + const languageRepository = new UmbLanguageCollectionRepository(this._host); + const { data: languageData } = await languageRepository.requestCollection({}); + const { data: documentData } = await this.repository.requestByUnique(this.unique); + + if (!documentData) throw new Error('The document was not found'); + + // If the document has only one variant, we can skip the modal and publish directly: + if (documentData.variants.length === 1) { + const variantId = UmbVariantId.Create(documentData.variants[0]); + const publishingRepository = new UmbDocumentPublishingRepository(this._host); + await publishingRepository.publish(this.unique, [variantId]); + return; + } + + const allOptions = (languageData?.items ?? []).map((language) => ({ + language: language, + variant: documentData.variants.find((variant) => variant.culture === language.unique), + unique: new UmbVariantId(language.unique, null).toString(), + })); + + // TODO: Maybe move this to modal [NL] + // Only display variants that are relevant to pick from, i.e. variants that are draft or published with pending changes: + const options = allOptions.filter( + (option) => + option.variant && + (option.variant.state === UmbDocumentVariantState.DRAFT || + option.variant.state === UmbDocumentVariantState.PUBLISHED || + option.variant.state === UmbDocumentVariantState.PUBLISHED_PENDING_CHANGES), + ); + + // TODO: Missing features to pre-select the variant that fits with the variant-id of the tree/collection? (Again only relevant if the action is executed from a Tree or Collection) [NL] + const selectedVariants = await umbPickDocumentVariantModal(this, { type: 'publish', options }); + + if (selectedVariants.length) { + const publishingRepository = new UmbDocumentPublishingRepository(this._host); + await publishingRepository.publish(this.unique, selectedVariants); + } } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/entity-actions/unpublish.action.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/entity-actions/unpublish.action.ts index 978963da48..4eebb4348c 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/entity-actions/unpublish.action.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/entity-actions/unpublish.action.ts @@ -1,21 +1,41 @@ -import { UMB_DOCUMENT_VARIANT_MANAGER_CONTEXT } from '../global-contexts/index.js'; -import type { UmbDocumentPublishingRepository } from '../repository/index.js'; +import { umbPickDocumentVariantModal } from '../modals/pick-document-variant-modal.controller.js'; +import { type UmbDocumentDetailRepository, UmbDocumentPublishingRepository } from '../repository/index.js'; +import { UmbDocumentVariantState } from '../types.js'; +import { UmbLanguageCollectionRepository } from '@umbraco-cms/backoffice/language'; import { UmbEntityActionBase } from '@umbraco-cms/backoffice/entity-action'; -import type { UmbControllerHostElement } from '@umbraco-cms/backoffice/controller-api'; - -export class UmbUnpublishDocumentEntityAction extends UmbEntityActionBase { - #variantManagerContext?: typeof UMB_DOCUMENT_VARIANT_MANAGER_CONTEXT.TYPE; - - constructor(host: UmbControllerHostElement, repositoryAlias: string, unique: string, entityType: string) { - super(host, repositoryAlias, unique, entityType); - - this.consumeContext(UMB_DOCUMENT_VARIANT_MANAGER_CONTEXT, (context) => { - this.#variantManagerContext = context; - }); - } +import { UmbVariantId } from '@umbraco-cms/backoffice/variant'; +export class UmbUnpublishDocumentEntityAction extends UmbEntityActionBase { async execute() { - if (!this.#variantManagerContext) throw new Error('Variant manager context is missing'); - await this.#variantManagerContext.unpublish(this.unique); + if (!this.repository) throw new Error('Document repository not set'); + + const languageRepository = new UmbLanguageCollectionRepository(this._host); + const { data: languageData } = await languageRepository.requestCollection({}); + const { data: documentData } = await this.repository.requestByUnique(this.unique); + + if (!documentData) throw new Error('The document was not found'); + + const allOptions = (languageData?.items ?? []).map((language) => ({ + language: language, + variant: documentData.variants.find((variant) => variant.culture === language.unique), + unique: new UmbVariantId(language.unique, null).toString(), + })); + + // TODO: Maybe move this to modal [NL] + // Only display variants that are relevant to pick from, i.e. variants that are published or published with pending changes: + const options = allOptions.filter( + (option) => + option.variant && + (option.variant.state === UmbDocumentVariantState.PUBLISHED || + option.variant.state === UmbDocumentVariantState.PUBLISHED_PENDING_CHANGES), + ); + + // TODO: Missing features to pre-select the variant that fits with the variant-id of the tree/collection? (Again only relevant if the action is executed from a Tree or Collection) [NL] + const selectedVariants = await umbPickDocumentVariantModal(this, { type: 'unpublish', options }); + + if (selectedVariants.length) { + const publishingRepository = new UmbDocumentPublishingRepository(this._host); + await publishingRepository.unpublish(this.unique, selectedVariants); + } } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/global-contexts/document-variant-manager.context.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/global-contexts/document-variant-manager.context.ts deleted file mode 100644 index d0cababfed..0000000000 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/global-contexts/document-variant-manager.context.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { UmbDocumentVariantState, type UmbDocumentVariantModel } from '../types.js'; -import { UmbDocumentDetailRepository } from '../repository/detail/document-detail.repository.js'; -import { - UMB_DOCUMENT_LANGUAGE_PICKER_MODAL, - type UmbDocumentVariantPickerModalData, -} from '../modals/variant-picker/index.js'; -import { UmbDocumentPublishingRepository } from '../repository/publishing/index.js'; -import { UmbContextBase } from '@umbraco-cms/backoffice/class-api'; -import { UmbContextToken } from '@umbraco-cms/backoffice/context-api'; -import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; -import type { UmbApi } from '@umbraco-cms/backoffice/extension-api'; -import { UMB_MODAL_MANAGER_CONTEXT } from '@umbraco-cms/backoffice/modal'; -import { UmbVariantId } from '@umbraco-cms/backoffice/variant'; -import { UMB_APP_LANGUAGE_CONTEXT } from '@umbraco-cms/backoffice/language'; - -export class UmbDocumentVariantManagerContext - extends UmbContextBase - implements UmbApi -{ - #publishingRepository = new UmbDocumentPublishingRepository(this); - #documentRepository = new UmbDocumentDetailRepository(this); - #modalManagerContext?: typeof UMB_MODAL_MANAGER_CONTEXT.TYPE; - #appLanguageCulture?: string; - - constructor(host: UmbControllerHost) { - super(host, UMB_DOCUMENT_VARIANT_MANAGER_CONTEXT); - - this.consumeContext(UMB_MODAL_MANAGER_CONTEXT, (instance) => { - this.#modalManagerContext = instance; - }); - - this.consumeContext(UMB_APP_LANGUAGE_CONTEXT, (appLanguageContext) => { - this.observe(appLanguageContext.appLanguageCulture, (culture) => { - this.#appLanguageCulture = culture?.toLowerCase(); - }); - }); - } - - /** - * Helps the user pick variants for a specific operation. - * If there is only one variant, it will be selected automatically. - * If there are multiple variants, a modal will be shown to the user. - * @param type The type of operation to perform. - * @param documentUnique The unique identifier of the document. - * @param activeVariantCulture The culture of the active variant (will be pre-selected in the modal). - * @param filterFn Optional filter function to filter the available variants. - * @returns The selected variants to perform the operation on. - */ - async pickVariants( - availableVariants: Array, - type: UmbDocumentVariantPickerModalData['type'], - activeVariantCulture?: string, - ): Promise { - // If there is only one variant, we don't need to select anything. - if (availableVariants.length === 1) { - return [UmbVariantId.Create(availableVariants[0])]; - } - - if (!this.#modalManagerContext) throw new Error('Modal manager context is missing'); - - const modalData: UmbDocumentVariantPickerModalData = { - type, - variants: availableVariants, - }; - - const modalContext = this.#modalManagerContext.open(UMB_DOCUMENT_LANGUAGE_PICKER_MODAL, { - data: modalData, - value: { selection: activeVariantCulture ? [activeVariantCulture] : [] }, - }); - - const result = await modalContext.onSubmit().catch(() => undefined); - - if (!result?.selection.length) return []; - - const selectedVariants = result.selection.map((x) => x?.toLowerCase() ?? ''); - - // Match the result to the available variants. - const variantIds = availableVariants - .filter((x) => selectedVariants.includes(x.culture!)) - .map((x) => UmbVariantId.Create(x)); - - return variantIds; - } - - /** - * Publish the latest version of a document indescriminately. - * @param documentUnique The unique identifier of the document. - */ - async publish(documentUnique: string) { - const { data } = await this.#documentRepository.requestByUnique(documentUnique); - if (!data) throw new Error('Document not found'); - const variantIds = await this.pickVariants(data.variants, 'publish', this.#appLanguageCulture); - if (variantIds.length) { - await this.#publishingRepository.publish(documentUnique, variantIds); - } - } - - /** - * Unpublish the latest version of a document indescriminately. - * @param documentUnique The unique identifier of the document. - */ - async unpublish(documentUnique: string) { - const { data } = await this.#documentRepository.requestByUnique(documentUnique); - if (!data) throw new Error('Document not found'); - - // Only show published variants - const variants = data.variants.filter((variant) => variant.state === UmbDocumentVariantState.PUBLISHED); - - const variantIds = await this.pickVariants(variants, 'unpublish', this.#appLanguageCulture); - - if (variantIds.length) { - await this.#publishingRepository.unpublish(documentUnique, variantIds); - } - } -} - -export default UmbDocumentVariantManagerContext; - -export const UMB_DOCUMENT_VARIANT_MANAGER_CONTEXT = new UmbContextToken( - 'UmbDocumentVariantManagerContext', -); diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/global-contexts/index.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/global-contexts/index.ts deleted file mode 100644 index 8eed586555..0000000000 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/global-contexts/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './document-variant-manager.context.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/global-contexts/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/global-contexts/manifests.ts deleted file mode 100644 index 2b5b972ebb..0000000000 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/global-contexts/manifests.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { ManifestGlobalContext } from '@umbraco-cms/backoffice/extension-registry'; - -export const manifests: Array = [ - { - type: 'globalContext', - alias: 'Umb.GlobalContext.DocumentVariantManager', - name: 'Document Variant Manager Context', - js: () => import('./document-variant-manager.context.js'), - }, -]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/manifests.ts index 857816321f..4b900ea615 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/manifests.ts @@ -12,7 +12,6 @@ import { manifests as modalManifests } from './modals/manifests.js'; import { manifests as treeManifests } from './tree/manifests.js'; import { manifests as userPermissionManifests } from './user-permissions/manifests.js'; import { manifests as workspaceManifests } from './workspace/manifests.js'; -import { manifests as globalContextManifests } from './global-contexts/manifests.js'; export const manifests = [ ...breadcrumbManifests, @@ -29,5 +28,4 @@ export const manifests = [ ...treeManifests, ...userPermissionManifests, ...workspaceManifests, - ...globalContextManifests, ]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/modals/index.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/modals/index.ts index 6db24e7b9a..1d4ae479e6 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/modals/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/modals/index.ts @@ -1 +1,2 @@ export * from './variant-picker/index.js'; +export * from './pick-document-variant-modal.controller.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/modals/pick-document-variant-modal.controller.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/modals/pick-document-variant-modal.controller.ts new file mode 100644 index 0000000000..a5a03b7463 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/modals/pick-document-variant-modal.controller.ts @@ -0,0 +1,50 @@ +import type { UmbDocumentVariantOptionModel } from '../types.js'; +import { + UMB_DOCUMENT_LANGUAGE_PICKER_MODAL, + type UmbDocumentVariantPickerModalData, + type UmbDocumentVariantPickerModalType, +} from './variant-picker/document-variant-picker-modal.token.js'; +import { UmbVariantId } from '@umbraco-cms/backoffice/variant'; +import { UmbBaseController } from '@umbraco-cms/backoffice/class-api'; +import { UMB_MODAL_MANAGER_CONTEXT } from '@umbraco-cms/backoffice/modal'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; + +export interface UmbPickDocumentVariantModalArgs { + type: UmbDocumentVariantPickerModalType; + options: Array; + selected?: Array; +} + +export class UmbPickDocumentVariantModalController extends UmbBaseController { + async open(args: UmbPickDocumentVariantModalArgs): Promise { + const modalManagerContext = await this.consumeContext(UMB_MODAL_MANAGER_CONTEXT, () => {}).asPromise(); + const selected = args.selected ?? []; + + const modalData: UmbDocumentVariantPickerModalData = { + type: args.type, + options: args.options, + }; + + if (modalData.options.length === 0) { + // TODO: What do to when there is no options? + } + + const modalContext = modalManagerContext.open(UMB_DOCUMENT_LANGUAGE_PICKER_MODAL, { + data: modalData, + // We need to turn the selected variant ids into strings for them to be serializable to the value state, in other words the value of a modal cannot hold class instances: + value: { selection: selected.map((x) => x.toString()) ?? [] }, + }); + + const result = await modalContext.onSubmit().catch(() => undefined); + + // This is a one time off, so we can destroy our selfs. + this.destroy(); + + // Map back into UmbVariantId instances: + return result?.selection.map((x) => UmbVariantId.FromString(x)) ?? []; + } +} + +export function umbPickDocumentVariantModal(host: UmbControllerHost, args: UmbPickDocumentVariantModalArgs) { + return new UmbPickDocumentVariantModalController(host).open(args); +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/modals/variant-picker/document-variant-picker-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/modals/variant-picker/document-variant-picker-modal.element.ts index 3e85234acd..5d237d3d46 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/modals/variant-picker/document-variant-picker-modal.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/modals/variant-picker/document-variant-picker-modal.element.ts @@ -1,36 +1,66 @@ -import { type UmbDocumentVariantModel, UmbDocumentVariantState } from '../../types.js'; +import { UmbDocumentVariantState, type UmbDocumentVariantOptionModel } from '../../types.js'; import type { UmbDocumentVariantPickerModalValue, UmbDocumentVariantPickerModalData, } from './document-variant-picker-modal.token.js'; import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; -import { css, html, customElement, repeat } from '@umbraco-cms/backoffice/external/lit'; +import { css, html, customElement, repeat, state } from '@umbraco-cms/backoffice/external/lit'; import { UmbSelectionManager } from '@umbraco-cms/backoffice/utils'; import { UmbModalBaseElement } from '@umbraco-cms/backoffice/modal'; +import { UMB_APP_LANGUAGE_CONTEXT } from '@umbraco-cms/backoffice/language'; +import { UmbVariantId } from '@umbraco-cms/backoffice/variant'; +import { appendToFrozenArray } from '@umbraco-cms/backoffice/observable-api'; @customElement('umb-document-variant-picker-modal') export class UmbDocumentVariantPickerModalElement extends UmbModalBaseElement< UmbDocumentVariantPickerModalData, UmbDocumentVariantPickerModalValue > { - #selectionManager = new UmbSelectionManager(this); + #selectionManager = new UmbSelectionManager(this); + + @state() + _selection: Array = []; + + constructor() { + super(); + this.observe(this.#selectionManager.selection, (selection) => { + this._selection = selection; + }); + } connectedCallback(): void { super.connectedCallback(); - this.#selectionManager.setSelectable(true); - this.#selectionManager.setMultiple(true); + this.#setInitialSelection(); + } + + async #setInitialSelection() { + let selected = this.value?.selection ?? []; + + if (selected.length === 0) { + // TODO: Make it possible to use consume context without callback. [NL] + const ctrl = this.consumeContext(UMB_APP_LANGUAGE_CONTEXT, () => {}); + const context = await ctrl.asPromise(); + const appCulture = context.getAppCulture(); + // If the app language is one of the options, select it by default: + if (appCulture && this.data?.options.some((o) => o.language.unique === appCulture)) { + selected = appendToFrozenArray(selected, new UmbVariantId(appCulture, null).toString()); + } + ctrl.destroy(); + } + + this.#selectionManager.setMultiple(true); + this.#selectionManager.setSelectable(true); + this.#selectionManager.setSelection(selected); - // Make sure all mandatory variants are selected when not in unpublish mode - this.#selectionManager.setSelection(this.value?.selection ?? []); if (this.data?.type !== 'unpublish') { this.#selectMandatoryVariants(); } } #selectMandatoryVariants() { - this.data?.variants.forEach((variant) => { - if (variant.isMandatory) { - this.#selectionManager.select(variant.culture); + this.data?.options.forEach((variant) => { + if (variant.language?.isMandatory) { + this.#selectionManager.select(variant.unique); } }); } @@ -87,17 +117,17 @@ export class UmbDocumentVariantPickerModalElement extends UmbModalBaseElement< return html`

    ${this.localize.term(this.#subtitle)}

    ${repeat( - this.data?.variants ?? [], - (item) => item.culture, - (item) => html` + this.data?.options ?? [], + (option) => option.unique, + (option) => html` this.#selectionManager.select(item.culture)} - @deselected=${() => this.#selectionManager.deselect(item.culture)} - ?selected=${this.#selectionManager.isSelected(item.culture)}> + label=${option.variant?.name ?? option.language.name} + @selected=${() => this.#selectionManager.select(option.unique)} + @deselected=${() => this.#selectionManager.deselect(option.unique)} + ?selected=${this._selection.includes(option.language.unique)}> - ${this.#renderLabel(item)} + ${this.#renderLabel(option)} `, )} @@ -114,11 +144,14 @@ export class UmbDocumentVariantPickerModalElement extends UmbModalBaseElement<
    `; } - #renderLabel(variant: UmbDocumentVariantModel) { + #renderLabel(option: UmbDocumentVariantOptionModel) { return html`
    - ${variant.segment ? variant.segment + ' - ' : ''}${variant.name} -
    ${this.#renderVariantStatus(variant)}
    - ${variant.isMandatory && variant.state !== UmbDocumentVariantState.PUBLISHED + ${option.variant?.segment ? option.variant.segment + ' - ' : ''}${option.variant?.name ?? + option.language.name} +
    ${this.#renderVariantStatus(option)}
    + ${option.language.isMandatory && option.variant?.state !== UmbDocumentVariantState.PUBLISHED ? html`
    Mandatory language
    ` @@ -126,16 +159,17 @@ export class UmbDocumentVariantPickerModalElement extends UmbModalBaseElement<
    `; } - #renderVariantStatus(variant: UmbDocumentVariantModel) { - switch (variant.state) { + #renderVariantStatus(option: UmbDocumentVariantOptionModel) { + switch (option.variant?.state) { case UmbDocumentVariantState.PUBLISHED: return this.localize.term('content_published'); case UmbDocumentVariantState.PUBLISHED_PENDING_CHANGES: return this.localize.term('content_publishedPendingChanges'); - case UmbDocumentVariantState.NOT_CREATED: case UmbDocumentVariantState.DRAFT: - default: return this.localize.term('content_unpublished'); + case UmbDocumentVariantState.NOT_CREATED: + default: + return this.localize.term('content_notCreated'); } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/modals/variant-picker/document-variant-picker-modal.stories.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/modals/variant-picker/document-variant-picker-modal.stories.ts index 19d38ffac6..0b20dbbaaf 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/modals/variant-picker/document-variant-picker-modal.stories.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/modals/variant-picker/document-variant-picker-modal.stories.ts @@ -12,17 +12,29 @@ import { html } from '@umbraco-cms/backoffice/external/lit'; const modalData: UmbDocumentVariantPickerModalData = { type: 'save', - variants: [ + options: [ { - name: 'English', - culture: 'en-us', - state: UmbDocumentVariantState.PUBLISHED, - createDate: '2021-08-25T14:00:00Z', - publishDate: null, - updateDate: null, - segment: null, - isMandatory: true, + unique: 'en-us', + variant: { + name: 'English variant name', + culture: 'en-us', + state: UmbDocumentVariantState.PUBLISHED, + createDate: '2021-08-25T14:00:00Z', + publishDate: null, + updateDate: null, + segment: null, + }, + language: { + entityType: 'language', + name: 'English', + unique: 'en-us', + isDefault: true, + isMandatory: true, + fallbackIsoCode: null, + }, }, + /* + // TODO: We do not support segments currently { name: 'English', culture: 'en-us', @@ -31,17 +43,27 @@ const modalData: UmbDocumentVariantPickerModalData = { publishDate: null, updateDate: null, segment: 'GTM', - isMandatory: true, }, + */ { - name: 'Danish', - culture: 'da-dk', - state: UmbDocumentVariantState.NOT_CREATED, - createDate: null, - publishDate: null, - updateDate: null, - segment: null, - isMandatory: false, + unique: 'da-dk', + variant: { + name: 'Danish variant name', + culture: 'da-dk', + state: UmbDocumentVariantState.NOT_CREATED, + createDate: null, + publishDate: null, + updateDate: null, + segment: null, + }, + language: { + entityType: 'language', + name: 'Danish', + unique: 'da-dk', + isDefault: false, + isMandatory: false, + fallbackIsoCode: null, + }, }, ], }; @@ -80,7 +102,6 @@ this.consumeContext(UMB_MODAL_MANAGER_CONTEXT, (modalManager) => { publishDate: '2021-08-25T14:00:00Z', updateDate: null, segment: null, - isMandatory: true, }, { name: 'English', @@ -90,7 +111,6 @@ this.consumeContext(UMB_MODAL_MANAGER_CONTEXT, (modalManager) => { publishDate: '2021-08-25T14:00:00Z', updateDate: null, segment: 'GTM', - isMandatory: false, }, { name: 'Danish', @@ -100,7 +120,6 @@ this.consumeContext(UMB_MODAL_MANAGER_CONTEXT, (modalManager) => { publishDate: null, updateDate: null, segment: null, - isMandatory: false, }, ], } diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/modals/variant-picker/document-variant-picker-modal.token.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/modals/variant-picker/document-variant-picker-modal.token.ts index 4c1d67688b..8ce9615f0e 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/modals/variant-picker/document-variant-picker-modal.token.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/modals/variant-picker/document-variant-picker-modal.token.ts @@ -1,14 +1,16 @@ import { UMB_DOCUMENT_VARIANT_PICKER_MODAL_ALIAS } from '../manifests.js'; -import type { UmbDocumentVariantModel } from '../../types.js'; +import type { UmbDocumentVariantOptionModel } from '../../types.js'; import { UmbModalToken } from '@umbraco-cms/backoffice/modal'; +export type UmbDocumentVariantPickerModalType = 'save' | 'publish' | 'schedule' | 'unpublish'; + export interface UmbDocumentVariantPickerModalData { - type: 'save' | 'publish' | 'schedule' | 'unpublish'; - variants: Array; + type: UmbDocumentVariantPickerModalType; + options: Array; } export interface UmbDocumentVariantPickerModalValue { - selection: Array; + selection: Array; } export const UMB_DOCUMENT_LANGUAGE_PICKER_MODAL = new UmbModalToken< diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/repository/detail/document-detail.server.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/repository/detail/document-detail.server.data-source.ts index 32a5fa60e3..4fa666761e 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/repository/detail/document-detail.server.data-source.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/repository/detail/document-detail.server.data-source.ts @@ -1,4 +1,4 @@ -import type { UmbDocumentDetailModel } from '../../types.js'; +import type { UmbDocumentDetailModel, UmbDocumentVariantModel } from '../../types.js'; import { UMB_DOCUMENT_ENTITY_TYPE } from '../../entity.js'; import { UmbId } from '@umbraco-cms/backoffice/id'; import type { UmbDetailDataSource } from '@umbraco-cms/backoffice/repository'; @@ -47,24 +47,32 @@ export class UmbDocumentServerDataSource implements UmbDetailDataSource { diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/repository/publishing/document-publishing.server.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/repository/publishing/document-publishing.server.data-source.ts index 9abc1b8274..e51124f109 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/repository/publishing/document-publishing.server.data-source.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/repository/publishing/document-publishing.server.data-source.ts @@ -40,7 +40,8 @@ export class UmbDocumentPublishingServerDataSource { (variant) => { return { culture: variant.isCultureInvariant() ? null : variant.toCultureString(), - schedule: variant.schedule, + // TODO: NO, this does not belong as part of the UmbVariantID, we need another way to parse that around: + //schedule: variant.schedule, }; }, ); diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/types.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/types.ts index c52581f930..e95b6522f2 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/types.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/types.ts @@ -1,5 +1,5 @@ import type { UmbDocumentEntityType } from './entity.js'; -import type { UmbVariantModel } from '@umbraco-cms/backoffice/variant'; +import type { UmbVariantModel, UmbVariantOptionModel } from '@umbraco-cms/backoffice/variant'; import type { UmbReferenceByUnique } from '@umbraco-cms/backoffice/models'; import { DocumentVariantStateModel as UmbDocumentVariantState } from '@umbraco-cms/backoffice/external/backend-api'; export { UmbDocumentVariantState }; @@ -22,7 +22,6 @@ export interface UmbDocumentDetailModel { export interface UmbDocumentVariantModel extends UmbVariantModel { state: UmbDocumentVariantState | null; publishDate: string | null; - isMandatory: boolean; } export interface UmbDocumentUrlInfoModel { @@ -36,3 +35,5 @@ export interface UmbDocumentValueModel { alias: string; value: ValueType; } + +export interface UmbDocumentVariantOptionModel extends UmbVariantOptionModel {} diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/workspace/document-workspace-editor.element.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/workspace/document-workspace-editor.element.ts index 49a5d76926..2d7715adba 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/workspace/document-workspace-editor.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/workspace/document-workspace-editor.element.ts @@ -1,62 +1,36 @@ +import type { UmbDocumentVariantOptionModel } from '../types.js'; import { UmbDocumentWorkspaceSplitViewElement } from './document-workspace-split-view.element.js'; import { UMB_DOCUMENT_WORKSPACE_CONTEXT } from './document-workspace.context-token.js'; import { customElement, state, css, html } from '@umbraco-cms/backoffice/external/lit'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; -import { UmbVariantId } from '@umbraco-cms/backoffice/variant'; -import type { ActiveVariant } from '@umbraco-cms/backoffice/workspace'; import type { UmbRoute, UmbRouterSlotInitEvent } from '@umbraco-cms/backoffice/router'; -import type { UmbVariantModel } from '@umbraco-cms/backoffice/variant'; +// TODO: This seem fully identical with Media Workspace Editor, so we can refactor this to a generic component. [NL] @customElement('umb-document-workspace-editor') export class UmbDocumentWorkspaceEditorElement extends UmbLitElement { - //private _defaultVariant?: VariantViewModelBaseModel; - - // TODO: Refactor: when having a split view/variants context token, we can rename the split view/variants component to a generic and make this component generic as well. + // + // TODO: Refactor: when having a split view/variants context token, we can rename the split view/variants component to a generic and make this component generic as well. [NL] private splitViewElement = new UmbDocumentWorkspaceSplitViewElement(); + #workspaceContext?: typeof UMB_DOCUMENT_WORKSPACE_CONTEXT.TYPE; + @state() _routes?: Array; - @state() - _availableVariants: Array = []; - - @state() - _workspaceSplitViews: Array = []; - - #workspaceContext?: typeof UMB_DOCUMENT_WORKSPACE_CONTEXT.TYPE; - constructor() { super(); this.consumeContext(UMB_DOCUMENT_WORKSPACE_CONTEXT, (instance) => { this.#workspaceContext = instance; this.#observeVariants(); - this.#observeSplitViews(); }); } #observeVariants() { if (!this.#workspaceContext) return; - this.observe( - this.#workspaceContext.variants, - (variants) => { - this._availableVariants = variants; - this._generateRoutes(); - }, - '_observeVariants', - ); - } - - #observeSplitViews() { - if (!this.#workspaceContext) return; - this.observe( - this.#workspaceContext.splitView.activeVariantsInfo, - (variants) => { - this._workspaceSplitViews = variants; - }, - '_observeSplitViews', - ); + // TODO: the variantOptions observable is like too broad as this will be triggered then there is any change in the variant options, we need to only update routes when there is a relevant change to them. [NL] + this.observe(this.#workspaceContext.variantOptions, (options) => this._generateRoutes(options), '_observeVariants'); } private _handleVariantFolderPart(index: number, folderPart: string) { @@ -66,17 +40,18 @@ export class UmbDocumentWorkspaceEditorElement extends UmbLitElement { this.#workspaceContext?.splitView.setActiveVariant(index, culture, segment); } - private _generateRoutes() { - if (!this._availableVariants || this._availableVariants.length === 0) return; + private async _generateRoutes(options: Array) { + if (!options || options.length === 0) return; // Generate split view routes for all available routes const routes: Array = []; // Split view routes: - this._availableVariants.forEach((variantA) => { - this._availableVariants.forEach((variantB) => { + options.forEach((variantA) => { + options.forEach((variantB) => { routes.push({ - path: new UmbVariantId(variantA).toString() + '_&_' + new UmbVariantId(variantB).toString(), + // TODO: When implementing Segments, be aware if using the unique is URL Safe... [NL] + path: variantA.unique + '_&_' + variantB.unique, component: this.splitViewElement, setup: (_component, info) => { // Set split view/active info.. @@ -90,9 +65,10 @@ export class UmbDocumentWorkspaceEditorElement extends UmbLitElement { }); // Single view: - this._availableVariants.forEach((variant) => { + options.forEach((variant) => { routes.push({ - path: new UmbVariantId(variant).toString(), + // TODO: When implementing Segments, be aware if using the unique is URL Safe... [NL] + path: variant.unique, component: this.splitViewElement, setup: (_component, info) => { // cause we might come from a split-view, we need to reset index 1. @@ -106,11 +82,21 @@ export class UmbDocumentWorkspaceEditorElement extends UmbLitElement { // Using first single view as the default route for now (hence the math below): routes.push({ path: '', - redirectTo: routes[this._availableVariants.length * this._availableVariants.length]?.path, + redirectTo: routes[options.length * options.length]?.path, }); } + const oldValue = this._routes; + + // is there any differences in the amount ot the paths? [NL] + // TODO: if we make a memorization function as the observer, we can avoid this check and avoid the whole build of routes. [NL] + if (oldValue && oldValue.length === routes.length) { + // is there any differences in the paths? [NL] + const hasDifferences = oldValue.some((route, index) => route.path !== routes[index].path); + if (!hasDifferences) return; + } this._routes = routes; + this.requestUpdate('_routes', oldValue); } private _gotWorkspaceRoute = (e: UmbRouterSlotInitEvent) => { diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/workspace/document-workspace.context.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/workspace/document-workspace.context.ts index 64adf863e1..049eef1b58 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/workspace/document-workspace.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/workspace/document-workspace.context.ts @@ -2,12 +2,12 @@ import { UmbDocumentTypeDetailRepository } from '../../document-types/repository import { UmbDocumentPropertyDataContext } from '../property-dataset-context/document-property-dataset-context.js'; import { UMB_DOCUMENT_ENTITY_TYPE } from '../entity.js'; import { UmbDocumentDetailRepository } from '../repository/index.js'; -import type { UmbDocumentDetailModel } from '../types.js'; -import type { UmbDocumentVariantPickerModalData } from '../modals/index.js'; +import type { UmbDocumentDetailModel, UmbDocumentVariantModel, UmbDocumentVariantOptionModel } from '../types.js'; +import { umbPickDocumentVariantModal, type UmbDocumentVariantPickerModalType } from '../modals/index.js'; import { UmbDocumentPublishingRepository } from '../repository/publishing/index.js'; -import { UMB_DOCUMENT_VARIANT_MANAGER_CONTEXT } from '../global-contexts/document-variant-manager.context.js'; +import { UmbUnpublishDocumentEntityAction } from '../entity-actions/unpublish.action.js'; import { UMB_DOCUMENT_WORKSPACE_ALIAS } from './manifests.js'; -import type { UmbVariantId } from '@umbraco-cms/backoffice/variant'; +import { UmbVariantId } from '@umbraco-cms/backoffice/variant'; import { UmbContentTypePropertyStructureManager } from '@umbraco-cms/backoffice/content-type'; import { UmbEditableWorkspaceContextBase, @@ -15,13 +15,21 @@ import { type UmbVariantableWorkspaceContextInterface, type UmbPublishableWorkspaceContextInterface, } from '@umbraco-cms/backoffice/workspace'; -import { appendToFrozenArray, partialUpdateFrozenArray, UmbObjectState } from '@umbraco-cms/backoffice/observable-api'; +import { + appendToFrozenArray, + mergeObservables, + naiveObjectComparison, + UmbArrayState, + UmbObjectState, +} from '@umbraco-cms/backoffice/observable-api'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { UmbLanguageCollectionRepository, type UmbLanguageDetailModel } from '@umbraco-cms/backoffice/language'; +import { firstValueFrom } from '@umbraco-cms/backoffice/external/rxjs'; type EntityType = UmbDocumentDetailModel; export class UmbDocumentWorkspaceContext extends UmbEditableWorkspaceContextBase - implements UmbVariantableWorkspaceContextInterface, UmbPublishableWorkspaceContextInterface + implements UmbVariantableWorkspaceContextInterface, UmbPublishableWorkspaceContextInterface { // public readonly repository = new UmbDocumentDetailRepository(this); @@ -30,8 +38,14 @@ export class UmbDocumentWorkspaceContext /** * The document is the current state/draft version of the document. */ + #persistedData = new UmbObjectState(undefined); #currentData = new UmbObjectState(undefined); #getDataPromise?: Promise; + // TODo: Optimize this so it uses either a App Language Context? [NL] + #languageRepository = new UmbLanguageCollectionRepository(this); + #languages = new UmbArrayState([], (x) => x.unique); + public readonly languages = this.#languages.asObservable(); + public isLoaded() { return this.#getDataPromise; } @@ -39,29 +53,37 @@ export class UmbDocumentWorkspaceContext readonly unique = this.#currentData.asObservablePart((data) => data?.unique); readonly contentTypeUnique = this.#currentData.asObservablePart((data) => data?.documentType.unique); - readonly contentTypeCollection = this.#currentData.asObservablePart((data) => data?.documentType.collection); + readonly contentTypeHasCollection = this.#currentData.asObservablePart((data) => !!data?.documentType.collection); + readonly variants = this.#currentData.asObservablePart((data) => data?.variants ?? []); + readonly variantOptions = mergeObservables([this.variants, this.languages], ([variants, languages]) => { + return languages.map((language) => { + return { + variant: variants.find((x) => x.culture === language.unique), + language, + // TODO: When including segments, this should be updated to include the segment as well. [NL] + unique: language.unique, // This must be a variantId string! + } as UmbDocumentVariantOptionModel; + }); + }); - readonly variants = this.#currentData.asObservablePart((data) => data?.variants || []); readonly urls = this.#currentData.asObservablePart((data) => data?.urls || []); readonly templateId = this.#currentData.asObservablePart((data) => data?.template?.unique || null); readonly structure = new UmbContentTypePropertyStructureManager(this, new UmbDocumentTypeDetailRepository(this)); readonly splitView = new UmbWorkspaceSplitViewManager(); - #variantManagerContext?: typeof UMB_DOCUMENT_VARIANT_MANAGER_CONTEXT.TYPE; - constructor(host: UmbControllerHost) { super(host, UMB_DOCUMENT_WORKSPACE_ALIAS); - this.consumeContext(UMB_DOCUMENT_VARIANT_MANAGER_CONTEXT, (instance) => { - this.#variantManagerContext = instance; - }); - this.observe(this.contentTypeUnique, (unique) => this.structure.loadType(unique)); - /* - TODO: Make something to ensure all variants are present in data? Seems like a good idea?. - */ + this.loadLanguages(); + } + + async loadLanguages() { + // TODO: If we don't end up having a Global Context for languages, then we should at least change this into using a asObservable which should be returned from the repository. [Nl] + const { data } = await this.#languageRepository.requestCollection({}); + this.#languages.setValue(data?.items ?? []); } async load(unique: string) { @@ -70,7 +92,7 @@ export class UmbDocumentWorkspaceContext if (!data) return undefined; this.setIsNew(false); - //this.#persisted.next(data); + this.#persistedData.setValue(data); this.#currentData.setValue(data); return data || undefined; } @@ -86,6 +108,7 @@ export class UmbDocumentWorkspaceContext if (!data) return undefined; this.setIsNew(true); + this.#persistedData.setValue(undefined); this.#currentData.setValue(data); return data || undefined; } @@ -125,6 +148,7 @@ export class UmbDocumentWorkspaceContext } setName(name: string, variantId?: UmbVariantId) { + /* const oldVariants = this.#currentData.getValue()?.variants || []; const variants = partialUpdateFrozenArray( oldVariants, @@ -132,6 +156,9 @@ export class UmbDocumentWorkspaceContext variantId ? (x) => variantId.compare(x) : () => true, ); this.#currentData.update({ variants }); + */ + // TODO: We should move this type of logic to the act of saving [NL] + this.#updateVariantData(variantId ?? UmbVariantId.CreateInvariant(), { name }); } setTemplate(templateUnique: string) { @@ -171,7 +198,7 @@ export class UmbDocumentWorkspaceContext value: UmbDocumentValueModel, variantId?: UmbVariantId, ) { - if (!variantId) throw new Error('VariantId is missing'); + variantId ??= UmbVariantId.CreateInvariant(); const entry = { ...variantId.toObject(), alias, value }; const currentData = this.getData(); @@ -182,27 +209,95 @@ export class UmbDocumentWorkspaceContext (x) => x.alias === alias && (variantId ? variantId.compare(x) : true), ); this.#currentData.update({ values }); + + // TODO: We should move this type of logic to the act of saving [NL] + this.#updateVariantData(variantId); } } - async #createOrSave(type: UmbDocumentVariantPickerModalData['type']): Promise { - const data = this.getData(); - if (!data) throw new Error('Data is missing'); - if (!data.unique) throw new Error('Unique is missing'); - if (!this.#variantManagerContext) throw new Error('Variant manager context is missing'); + #calculateChangedVariants() { + const persisted = this.#persistedData.getValue(); + const current = this.#currentData.getValue(); + if (!current) throw new Error('Current data is missing'); - const activeVariants = this.splitView.getActiveVariants(); - const activeVariant = activeVariants.length ? activeVariants[0] : undefined; + const changedVariants = current?.variants.map((variant) => { + const persistedVariant = persisted?.variants.find((x) => UmbVariantId.Create(variant).compare(x)); + return { + culture: variant.culture, + segment: variant.segment, + equal: persistedVariant ? naiveObjectComparison(variant, persistedVariant) : false, + }; + }); - const selectedVariants = await this.#variantManagerContext.pickVariants( - data.variants, // TODO: Add a filter function to only show variants that have been changed - type, - activeVariant?.culture ?? undefined, + const changedProperties = current?.values.map((value) => { + const persistedValues = persisted?.values.find((x) => UmbVariantId.Create(value).compare(x)); + return { + culture: value.culture, + segment: value.segment, + equal: persistedValues ? naiveObjectComparison(value, persistedValues) : false, + }; + }); + + // calculate the variantIds of those who either have a change in properties or in variants: + return ( + changedVariants + ?.concat(changedProperties ?? []) + .filter((x) => x.equal === false) + .map((x) => new UmbVariantId(x.culture, x.segment)) ?? [] ); + } + + #updateVariantData(variantId: UmbVariantId, update?: Partial) { + const currentData = this.getData(); + if (!currentData) throw new Error('Data is missing'); + const variant = currentData.variants.find((x) => variantId.compare(x)); + const newVariants = appendToFrozenArray( + currentData.variants, + { + state: null, + name: '', + publishDate: null, + createDate: null, + updateDate: null, + ...variantId.toObject(), + ...variant, + ...update, + }, + (x) => variantId.compare(x), + ); + this.#currentData.update({ variants: newVariants }); + } + + async #pickVariantsForAction(type: UmbDocumentVariantPickerModalType): Promise { + const activeVariants = this.splitView.getActiveVariants(); + + // TODO: Picked variants should include the ones that has been changed (but not jet saved) this requires some more awareness about the state of runtime data. [NL] + const activeVariantIds = activeVariants.map((activeVariant) => UmbVariantId.Create(activeVariant)); + const selected = activeVariantIds.concat(this.#calculateChangedVariants()); + const options = await firstValueFrom(this.variantOptions); + + // If there is only one variant, we don't need to open the modal. + if (options.length === 0) { + throw new Error('No variants are available'); + } else if (options.length === 1) { + // If only one option we will skip ahead and save the document with the only variant available: + const firstVariant = new UmbVariantId(options[0].language.unique, null); + return await this.#performSaveOrCreate([firstVariant]); + } + + const selectedVariants = await umbPickDocumentVariantModal(this, { type, options, selected }); // If no variants are selected, we don't save anything. if (!selectedVariants.length) return []; + return await this.#performSaveOrCreate(selectedVariants); + } + + async #performSaveOrCreate(selectedVariants: Array) { + const data = this.getData(); + if (!data) throw new Error('Data is missing'); + if (!data.unique) throw new Error('Unique is missing'); + if (this.getIsNew()) { if ((await this.repository.create(data)).data !== undefined) { this.setIsNew(false); @@ -215,14 +310,18 @@ export class UmbDocumentWorkspaceContext } async save() { - await this.#createOrSave('save'); + await this.#pickVariantsForAction('save'); const data = this.getData(); if (!data) throw new Error('Data is missing'); + + this.#persistedData.setValue(data); + this.#currentData.setValue(data); + this.saveComplete(data); } public async publish() { - const variantIds = await this.#createOrSave('publish'); + const variantIds = await this.#pickVariantsForAction('publish'); const unique = this.getEntityId(); if (variantIds.length && unique) { await this.publishingRepository.publish(unique, variantIds); @@ -237,9 +336,7 @@ export class UmbDocumentWorkspaceContext const unique = this.getEntityId(); if (!unique) throw new Error('Unique is missing'); - if (!this.#variantManagerContext) throw new Error('Variant manager context is missing'); - - this.#variantManagerContext.unpublish(unique); + new UmbUnpublishDocumentEntityAction(this, '', unique, '').execute(); } async delete() { diff --git a/src/Umbraco.Web.UI.Client/src/packages/language/global-contexts/app-language.context.ts b/src/Umbraco.Web.UI.Client/src/packages/language/global-contexts/app-language.context.ts index ce2ca1abec..e26d796c27 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/language/global-contexts/app-language.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/language/global-contexts/app-language.context.ts @@ -13,6 +13,10 @@ export class UmbAppLanguageContext extends UmbBaseController implements UmbApi { appLanguage = this.#appLanguage.asObservable(); appLanguageCulture = this.#appLanguage.asObservablePart((x) => x?.unique); + getAppCulture() { + return this.#appLanguage.getValue()?.unique; + } + constructor(host: UmbControllerHost) { super(host); this.provideContext(UMB_APP_LANGUAGE_CONTEXT, this); @@ -26,7 +30,7 @@ export class UmbAppLanguageContext extends UmbBaseController implements UmbApi { } async #observeLanguages() { - const { data } = await this.#languageCollectionRepository.requestCollection({ skip: 0, take: 100 }); + const { data } = await this.#languageCollectionRepository.requestCollection({}); // TODO: make this observable / update when languages are added/removed/updated if (data) { diff --git a/src/Umbraco.Web.UI.Client/src/packages/language/repository/detail/language-detail.server.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/language/repository/detail/language-detail.server.data-source.ts index 4bcbf6ead2..48536e1ef3 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/language/repository/detail/language-detail.server.data-source.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/language/repository/detail/language-detail.server.data-source.ts @@ -65,11 +65,11 @@ export class UmbLanguageServerDataSource implements UmbDetailDataSource { - this.#logViewerContext?.removeSearch({ name }); - //this.dispatchEvent(new UmbDeleteEvent()); - }); + this.#logViewerContext?.removeSearch({ name }); + //this.dispatchEvent(new UmbDeleteEvent()); } #openSaveSearchDialog() { diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media-types/workspace/views/design/media-type-workspace-view-edit-property.element.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media-types/workspace/views/design/media-type-workspace-view-edit-property.element.ts index ebd4bfb9ac..5a5f2a0731 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media-types/workspace/views/design/media-type-workspace-view-edit-property.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media-types/workspace/views/design/media-type-workspace-view-edit-property.element.ts @@ -4,11 +4,10 @@ import { UUIInputEvent } from '@umbraco-cms/backoffice/external/uui'; import { css, html, customElement, property, state, ifDefined, nothing } from '@umbraco-cms/backoffice/external/lit'; import type { UmbConfirmModalData } from '@umbraco-cms/backoffice/modal'; import { - UMB_CONFIRM_MODAL, - UMB_MODAL_MANAGER_CONTEXT, UMB_PROPERTY_SETTINGS_MODAL, UMB_WORKSPACE_MODAL, UmbModalRouteRegistrationController, + umbConfirmModal, } from '@umbraco-cms/backoffice/modal'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import { generateAlias } from '@umbraco-cms/backoffice/utils'; @@ -57,7 +56,6 @@ export class UmbMediaTypeWorkspacePropertyElement extends UmbLitElement { #dataTypeDetailRepository = new UmbDataTypeDetailRepository(this); #modalRegistration; - private _modalManagerContext?: typeof UMB_MODAL_MANAGER_CONTEXT.TYPE; @state() protected _modalRoute?: string; @@ -113,10 +111,6 @@ export class UmbMediaTypeWorkspacePropertyElement extends UmbLitElement { .observeRouteBuilder((routeBuilder) => { this._editMediaTypePath = routeBuilder({}); }); - - this.consumeContext(UMB_MODAL_MANAGER_CONTEXT, (context) => { - this._modalManagerContext = context; - }); } _partialUpdate(partialObject: UmbPropertyTypeModel) { @@ -137,7 +131,7 @@ export class UmbMediaTypeWorkspacePropertyElement extends UmbLitElement { this._aliasLocked = !this._aliasLocked; } - #requestRemove(e: Event) { + async #requestRemove(e: Event) { e.preventDefault(); e.stopImmediatePropagation(); if (!this.property || !this.property.id) return; @@ -154,17 +148,9 @@ export class UmbMediaTypeWorkspacePropertyElement extends UmbLitElement { color: 'danger', }; - const modalHandler = this._modalManagerContext?.open(UMB_CONFIRM_MODAL, { data: modalData }); + await umbConfirmModal(this, modalData); - modalHandler - ?.onSubmit() - .then(() => { - this.dispatchEvent(new CustomEvent('property-delete')); - }) - .catch(() => { - // We do not need to react to cancel, so we will leave an empty method to prevent Uncaught Promise Rejection error. - return; - }); + this.dispatchEvent(new CustomEvent('property-delete')); } #onNameChange(event: UUIInputEvent) { diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media-types/workspace/views/design/media-type-workspace-view-edit.element.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media-types/workspace/views/design/media-type-workspace-view-edit.element.ts index 1b2d9d7628..3979152bf3 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media-types/workspace/views/design/media-type-workspace-view-edit.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media-types/workspace/views/design/media-type-workspace-view-edit.element.ts @@ -11,7 +11,7 @@ import { UMB_WORKSPACE_CONTEXT } from '@umbraco-cms/backoffice/workspace'; import type { UmbRoute, UmbRouterSlotChangeEvent, UmbRouterSlotInitEvent } from '@umbraco-cms/backoffice/router'; import type { UmbWorkspaceViewElement } from '@umbraco-cms/backoffice/extension-registry'; import type { UmbConfirmModalData } from '@umbraco-cms/backoffice/modal'; -import { UMB_CONFIRM_MODAL, UMB_MODAL_MANAGER_CONTEXT } from '@umbraco-cms/backoffice/modal'; +import { umbConfirmModal } from '@umbraco-cms/backoffice/modal'; import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; import type { UmbSorterConfig } from '@umbraco-cms/backoffice/sorter'; import { UmbSorterController } from '@umbraco-cms/backoffice/sorter'; @@ -89,8 +89,6 @@ export class UmbMediaTypeWorkspaceViewEditElement extends UmbLitElement implemen private _tabsStructureHelper = new UmbContentTypeContainerStructureHelper(this); - private _modalManagerContext?: typeof UMB_MODAL_MANAGER_CONTEXT.TYPE; - constructor() { super(); this.sorter = new UmbSorterController(this, this.config); @@ -116,10 +114,6 @@ export class UmbMediaTypeWorkspaceViewEditElement extends UmbLitElement implemen ); this._observeRootGroups(); }); - - this.consumeContext(UMB_MODAL_MANAGER_CONTEXT, (context) => { - this._modalManagerContext = context; - }); } private _observeRootGroups() { @@ -188,7 +182,7 @@ export class UmbMediaTypeWorkspaceViewEditElement extends UmbLitElement implemen this._routes = routes; } - #requestRemoveTab(tab: PropertyTypeContainerModelBaseModel | undefined) { + async #requestRemoveTab(tab: PropertyTypeContainerModelBaseModel | undefined) { const modalData: UmbConfirmModalData = { headline: 'Delete tab', content: html` @@ -204,12 +198,9 @@ export class UmbMediaTypeWorkspaceViewEditElement extends UmbLitElement implemen }; // TODO: If this tab is composed of other tabs, then notify that it will only delete the local tab. + await umbConfirmModal(this, modalData); - const modalHandler = this._modalManagerContext?.open(UMB_CONFIRM_MODAL, { data: modalData }); - - modalHandler?.onSubmit().then(() => { - this.#remove(tab?.id); - }); + this.#remove(tab?.id); } #remove(tabId?: string) { if (!tabId) return; diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/types.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/types.ts index c54ec97df2..50a1bf5080 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/types.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/types.ts @@ -1,7 +1,7 @@ import type { UmbMediaEntityType } from './entity.js'; -import type { UmbReferenceByUnique } from '@umbraco-cms/backoffice/models'; -import type { UmbVariantModel } from '@umbraco-cms/backoffice/variant'; +import type { UmbVariantModel, UmbVariantOptionModel } from '@umbraco-cms/backoffice/variant'; import type { MediaUrlInfoModel, MediaValueModel } from '@umbraco-cms/backoffice/external/backend-api'; +import type { UmbReferenceByUnique } from '@umbraco-cms/backoffice/models'; export interface UmbMediaDetailModel { mediaType: { @@ -16,3 +16,5 @@ export interface UmbMediaDetailModel { values: Array; variants: Array; } + +export interface UmbMediaVariantOptionModel extends UmbVariantOptionModel {} diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/workspace/media-workspace-editor.element.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/workspace/media-workspace-editor.element.ts index 32f6b32487..f7d8f0e56a 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/workspace/media-workspace-editor.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/workspace/media-workspace-editor.element.ts @@ -1,28 +1,19 @@ +import type { UmbMediaVariantOptionModel } from '../types.js'; import { UmbMediaWorkspaceSplitViewElement } from './media-workspace-split-view.element.js'; import { UMB_MEDIA_WORKSPACE_CONTEXT } from './media-workspace.context-token.js'; import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import { customElement, state, css, html } from '@umbraco-cms/backoffice/external/lit'; -import type { UmbVariantModel } from '@umbraco-cms/backoffice/variant'; -import { UmbVariantId } from '@umbraco-cms/backoffice/variant'; import type { UmbRoute, UmbRouterSlotInitEvent } from '@umbraco-cms/backoffice/router'; -import type { ActiveVariant } from '@umbraco-cms/backoffice/workspace'; @customElement('umb-media-workspace-editor') export class UmbMediaWorkspaceEditorElement extends UmbLitElement { - //private _defaultVariant?: VariantViewModelBaseModel; - - // TODO: Refactor: when having a split view/variants context token, we can rename the split view/variants component to a generic and make this component generic as well. + // + // TODO: Refactor: when having a split view/variants context token, we can rename the split view/variants component to a generic and make this component generic as well. [NL] private splitViewElement = new UmbMediaWorkspaceSplitViewElement(); @state() _routes?: Array; - @state() - _availableVariants: Array = []; - - @state() - _workspaceSplitViews: Array = []; - #workspaceContext?: typeof UMB_MEDIA_WORKSPACE_CONTEXT.TYPE; constructor() { @@ -31,31 +22,12 @@ export class UmbMediaWorkspaceEditorElement extends UmbLitElement { this.consumeContext(UMB_MEDIA_WORKSPACE_CONTEXT, (instance) => { this.#workspaceContext = instance; this.#observeVariants(); - this.#observeSplitViews(); }); } #observeVariants() { if (!this.#workspaceContext) return; - this.observe( - this.#workspaceContext.variants, - (variants) => { - this._availableVariants = variants; - this._generateRoutes(); - }, - '_observeVariants', - ); - } - - #observeSplitViews() { - if (!this.#workspaceContext) return; - this.observe( - this.#workspaceContext.splitView.activeVariantsInfo, - (variants) => { - this._workspaceSplitViews = variants; - }, - '_observeSplitViews', - ); + this.observe(this.#workspaceContext.variantOptions, (options) => this._generateRoutes(options), '_observeVariants'); } private _handleVariantFolderPart(index: number, folderPart: string) { @@ -65,17 +37,18 @@ export class UmbMediaWorkspaceEditorElement extends UmbLitElement { this.#workspaceContext?.splitView.setActiveVariant(index, culture, segment); } - private _generateRoutes() { - if (!this._availableVariants || this._availableVariants.length === 0) return; + private async _generateRoutes(variants: Array) { + if (!variants || variants.length === 0) return; // Generate split view routes for all available routes const routes: Array = []; // Split view routes: - this._availableVariants.forEach((variantA) => { - this._availableVariants.forEach((variantB) => { + variants.forEach((variantA) => { + variants.forEach((variantB) => { routes.push({ - path: new UmbVariantId(variantA).toString() + '_&_' + new UmbVariantId(variantB).toString(), + // TODO: When implementing Segments, be aware if using the unique is URL Safe... [NL] + path: variantA.unique + '_&_' + variantB.unique, component: this.splitViewElement, setup: (_component, info) => { // Set split view/active info.. @@ -89,9 +62,10 @@ export class UmbMediaWorkspaceEditorElement extends UmbLitElement { }); // Single view: - this._availableVariants.forEach((variant) => { + variants.forEach((variant) => { routes.push({ - path: new UmbVariantId(variant).toString(), + // TODO: When implementing Segments, be aware if using the unique is URL Safe... [NL] + path: variant.unique, component: this.splitViewElement, setup: (_component, info) => { // cause we might come from a split-view, we need to reset index 1. @@ -105,11 +79,21 @@ export class UmbMediaWorkspaceEditorElement extends UmbLitElement { // Using first single view as the default route for now (hence the math below): routes.push({ path: '', - redirectTo: routes[this._availableVariants.length * this._availableVariants.length]?.path, + redirectTo: routes[variants.length * variants.length]?.path, }); } + const oldValue = this._routes; + + // is there any differences in the amount ot the paths? [NL] + // TODO: if we make a memorization function as the observer, we can avoid this check and avoid the whole build of routes. [NL] + if (oldValue && oldValue.length === routes.length) { + // is there any differences in the paths? [NL] + const hasDifferences = oldValue.some((route, index) => route.path !== routes[index].path); + if (!hasDifferences) return; + } this._routes = routes; + this.requestUpdate('_routes', oldValue); } private _gotWorkspaceRoute = (e: UmbRouterSlotInitEvent) => { diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/workspace/media-workspace.context.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/workspace/media-workspace.context.ts index 6344f36472..4aa4d0997f 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/workspace/media-workspace.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/workspace/media-workspace.context.ts @@ -2,7 +2,7 @@ import { UmbMediaTypeDetailRepository } from '../../media-types/repository/detai import { UmbMediaPropertyDataContext } from '../property-dataset-context/media-property-dataset-context.js'; import { UMB_MEDIA_ENTITY_TYPE } from '../entity.js'; import { UmbMediaDetailRepository } from '../repository/index.js'; -import type { UmbMediaDetailModel } from '../types.js'; +import type { UmbMediaDetailModel, UmbMediaVariantOptionModel } from '../types.js'; import type { UmbVariantId } from '@umbraco-cms/backoffice/variant'; import { UmbContentTypePropertyStructureManager } from '@umbraco-cms/backoffice/content-type'; import { @@ -10,8 +10,15 @@ import { UmbWorkspaceSplitViewManager, type UmbVariantableWorkspaceContextInterface, } from '@umbraco-cms/backoffice/workspace'; -import { appendToFrozenArray, partialUpdateFrozenArray, UmbObjectState } from '@umbraco-cms/backoffice/observable-api'; +import { + appendToFrozenArray, + mergeObservables, + partialUpdateFrozenArray, + UmbArrayState, + UmbObjectState, +} from '@umbraco-cms/backoffice/observable-api'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { UmbLanguageCollectionRepository, type UmbLanguageDetailModel } from '@umbraco-cms/backoffice/language'; type EntityType = UmbMediaDetailModel; export class UmbMediaWorkspaceContext @@ -26,6 +33,11 @@ export class UmbMediaWorkspaceContext */ #currentData = new UmbObjectState(undefined); #getDataPromise?: Promise; + // TODo: Optimize this so it uses either a App Language Context? [NL] + #languageRepository = new UmbLanguageCollectionRepository(this); + #languages = new UmbArrayState([], (x) => x.unique); + public readonly languages = this.#languages.asObservable(); + public isLoaded() { return this.#getDataPromise; } @@ -35,6 +47,16 @@ export class UmbMediaWorkspaceContext readonly contentTypeCollection = this.#currentData.asObservablePart((data) => data?.mediaType.collection); readonly variants = this.#currentData.asObservablePart((data) => data?.variants || []); + readonly variantOptions = mergeObservables([this.variants, this.languages], ([variants, languages]) => { + return languages.map((language) => { + return { + variant: variants.find((x) => x.culture === language.unique), + language, + // TODO: When including segments, this should be updated to include the segment as well. [NL] + unique: language.unique, // This must be a variantId string! + } as UmbMediaVariantOptionModel; + }); + }); readonly urls = this.#currentData.asObservablePart((data) => data?.urls || []); readonly structure = new UmbContentTypePropertyStructureManager(this, new UmbMediaTypeDetailRepository(this)); @@ -47,6 +69,11 @@ export class UmbMediaWorkspaceContext this.observe(this.contentTypeUnique, (unique) => this.structure.loadType(unique)); } + async loadLanguages() { + const { data } = await this.#languageRepository.requestCollection({}); + this.#languages.setValue(data?.items ?? []); + } + async load(unique: string) { this.#getDataPromise = this.repository.requestByUnique(unique); const { data } = await this.#getDataPromise; diff --git a/src/Umbraco.Web.UI.Client/src/packages/packages/package-section/views/created/packages-created-overview.element.ts b/src/Umbraco.Web.UI.Client/src/packages/packages/package-section/views/created/packages-created-overview.element.ts index c279b8bf73..cdf0d64146 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/packages/package-section/views/created/packages-created-overview.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/packages/package-section/views/created/packages-created-overview.element.ts @@ -4,8 +4,7 @@ import type { PackageDefinitionResponseModel } from '@umbraco-cms/backoffice/ext import { PackageResource } from '@umbraco-cms/backoffice/external/backend-api'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import { tryExecuteAndNotify } from '@umbraco-cms/backoffice/resources'; -import type { UmbModalManagerContext } from '@umbraco-cms/backoffice/modal'; -import { UMB_MODAL_MANAGER_CONTEXT, UMB_CONFIRM_MODAL } from '@umbraco-cms/backoffice/modal'; +import { umbConfirmModal } from '@umbraco-cms/backoffice/modal'; @customElement('umb-packages-created-overview') export class UmbPackagesCreatedOverviewElement extends UmbLitElement { @@ -23,19 +22,10 @@ export class UmbPackagesCreatedOverviewElement extends UmbLitElement { @state() private _total?: number; - private _modalContext?: UmbModalManagerContext; - constructor() { super(); - } - connectedCallback(): void { - super.connectedCallback(); this.#getPackages(); - - this.consumeContext(UMB_MODAL_MANAGER_CONTEXT, (instance) => { - this._modalContext = instance; - }); } async #getPackages() { @@ -106,17 +96,13 @@ export class UmbPackagesCreatedOverviewElement extends UmbLitElement { async #deletePackage(p: PackageDefinitionResponseModel) { if (!p.id) return; - const modalContext = this._modalContext?.open(UMB_CONFIRM_MODAL, { - data: { - color: 'danger', - headline: `Remove ${p.name}?`, - content: 'Are you sure you want to delete this package', - confirmLabel: 'Delete', - }, + await umbConfirmModal(this, { + color: 'danger', + headline: `Remove ${p.name}?`, + content: 'Are you sure you want to delete this package', + confirmLabel: 'Delete', }); - await modalContext?.onSubmit(); - const { error } = await tryExecuteAndNotify(this, PackageResource.deletePackageCreatedById({ id: p.id })); if (error) return; const index = this._createdPackages.findIndex((x) => x.id === p.id); diff --git a/src/Umbraco.Web.UI.Client/src/packages/packages/package-section/views/installed/installed-packages-section-view-item.element.ts b/src/Umbraco.Web.UI.Client/src/packages/packages/package-section/views/installed/installed-packages-section-view-item.element.ts index bd0836c6e0..5f363a6fe3 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/packages/package-section/views/installed/installed-packages-section-view-item.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/packages/package-section/views/installed/installed-packages-section-view-item.element.ts @@ -1,8 +1,7 @@ import { html, css, nothing, ifDefined, customElement, property, state } from '@umbraco-cms/backoffice/external/lit'; import type { UUIButtonState } from '@umbraco-cms/backoffice/external/uui'; import { map } from '@umbraco-cms/backoffice/external/rxjs'; -import type { UmbModalManagerContext } from '@umbraco-cms/backoffice/modal'; -import { UMB_MODAL_MANAGER_CONTEXT, UMB_CONFIRM_MODAL } from '@umbraco-cms/backoffice/modal'; +import { umbConfirmModal } from '@umbraco-cms/backoffice/modal'; import { createExtensionElement } from '@umbraco-cms/backoffice/extension-api'; import type { ManifestPackageView } from '@umbraco-cms/backoffice/extension-registry'; import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry'; @@ -43,7 +42,6 @@ export class UmbInstalledPackagesSectionViewItemElement extends UmbLitElement { private _packageView?: ManifestPackageView; #notificationContext?: UmbNotificationContext; - #modalContext?: UmbModalManagerContext; constructor() { super(); @@ -51,9 +49,6 @@ export class UmbInstalledPackagesSectionViewItemElement extends UmbLitElement { this.consumeContext(UMB_NOTIFICATION_CONTEXT, (instance) => { this.#notificationContext = instance; }); - this.consumeContext(UMB_MODAL_MANAGER_CONTEXT, (instance) => { - this.#modalContext = instance; - }); } #observePackageView() { @@ -76,16 +71,13 @@ export class UmbInstalledPackagesSectionViewItemElement extends UmbLitElement { async _onMigration() { if (!this.name) return; - const modalContext = this.#modalContext?.open(UMB_CONFIRM_MODAL, { - data: { - color: 'positive', - headline: `Run migrations for ${this.name}?`, - content: `Do you want to start run migrations for ${this.name}`, - confirmLabel: 'Run migrations', - }, - }); - await modalContext?.onSubmit(); + await umbConfirmModal(this, { + color: 'positive', + headline: `Run migrations for ${this.name}?`, + content: `Do you want to start run migrations for ${this.name}`, + confirmLabel: 'Run migrations', + }); this._migrationButtonState = 'waiting'; const { error } = await tryExecuteAndNotify( diff --git a/src/Umbraco.Web.UI.Client/src/packages/search/examine-management-dashboard/views/section-view-examine-indexers.ts b/src/Umbraco.Web.UI.Client/src/packages/search/examine-management-dashboard/views/section-view-examine-indexers.ts index 5550281658..0f0a80fa9e 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/search/examine-management-dashboard/views/section-view-examine-indexers.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/search/examine-management-dashboard/views/section-view-examine-indexers.ts @@ -1,7 +1,6 @@ import type { UUIButtonState } from '@umbraco-cms/backoffice/external/uui'; import { css, html, nothing, customElement, property, state } from '@umbraco-cms/backoffice/external/lit'; -import type { UmbModalManagerContext } from '@umbraco-cms/backoffice/modal'; -import { UMB_MODAL_MANAGER_CONTEXT, UMB_CONFIRM_MODAL } from '@umbraco-cms/backoffice/modal'; +import { umbConfirmModal } from '@umbraco-cms/backoffice/modal'; import type { IndexResponseModel } from '@umbraco-cms/backoffice/external/backend-api'; import { HealthStatusModel, IndexerResource } from '@umbraco-cms/backoffice/external/backend-api'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; @@ -24,16 +23,6 @@ export class UmbDashboardExamineIndexElement extends UmbLitElement { @state() private _loading = true; - private _modalContext?: UmbModalManagerContext; - - constructor() { - super(); - - this.consumeContext(UMB_MODAL_MANAGER_CONTEXT, (_instance) => { - this._modalContext = _instance; - }); - } - connectedCallback() { super.connectedCallback(); this._getIndexData(); @@ -55,22 +44,19 @@ export class UmbDashboardExamineIndexElement extends UmbLitElement { } private async _onRebuildHandler() { - const modalContext = this._modalContext?.open(UMB_CONFIRM_MODAL, { - data: { - headline: `Rebuild ${this.indexName}`, - content: html` - This will cause the index to be rebuilt.
    - Depending on how much content there is in your site this could take a while.
    - It is not recommended to rebuild an index during times of high website traffic or when editors are editing - content. - `, - color: 'danger', - confirmLabel: 'Rebuild', - }, - }); - modalContext?.onSubmit().then(() => { - this._rebuild(); + await umbConfirmModal(this, { + headline: `Rebuild ${this.indexName}`, + content: html` + This will cause the index to be rebuilt.
    + Depending on how much content there is in your site this could take a while.
    + It is not recommended to rebuild an index during times of high website traffic or when editors are editing + content. + `, + color: 'danger', + confirmLabel: 'Rebuild', }); + + this._rebuild(); } private async _rebuild() { this._buttonState = 'waiting'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/settings/dashboards/published-status/dashboard-published-status.element.ts b/src/Umbraco.Web.UI.Client/src/packages/settings/dashboards/published-status/dashboard-published-status.element.ts index 2f3f4e3f54..a5b796a9e8 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/settings/dashboards/published-status/dashboard-published-status.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/settings/dashboards/published-status/dashboard-published-status.element.ts @@ -1,7 +1,6 @@ import type { UUIButtonState } from '@umbraco-cms/backoffice/external/uui'; import { css, html, customElement, state } from '@umbraco-cms/backoffice/external/lit'; -import type { UmbModalManagerContext } from '@umbraco-cms/backoffice/modal'; -import { UMB_MODAL_MANAGER_CONTEXT, UMB_CONFIRM_MODAL } from '@umbraco-cms/backoffice/modal'; +import { umbConfirmModal } from '@umbraco-cms/backoffice/modal'; import { PublishedCacheResource } from '@umbraco-cms/backoffice/external/backend-api'; import { tryExecuteAndNotify } from '@umbraco-cms/backoffice/resources'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; @@ -24,16 +23,6 @@ export class UmbDashboardPublishedStatusElement extends UmbLitElement { @state() private _buttonStateCollect: UUIButtonState = undefined; - private _modalContext?: UmbModalManagerContext; - - constructor() { - super(); - - this.consumeContext(UMB_MODAL_MANAGER_CONTEXT, (instance) => { - this._modalContext = instance; - }); - } - connectedCallback() { super.connectedCallback(); this._getPublishedStatus(); @@ -68,17 +57,14 @@ export class UmbDashboardPublishedStatusElement extends UmbLitElement { } } private async _onReloadCacheHandler() { - const modalContext = this._modalContext?.open(UMB_CONFIRM_MODAL, { - data: { - headline: 'Reload', - content: html` Trigger a in-memory and local file cache reload on all servers.`, - color: 'danger', - confirmLabel: 'Continue', - }, - }); - modalContext?.onSubmit().then(() => { - this._reloadMemoryCache(); + await umbConfirmModal(this, { + headline: 'Reload', + content: html` Trigger a in-memory and local file cache reload on all servers.`, + color: 'danger', + confirmLabel: 'Continue', }); + + this._reloadMemoryCache(); } // Rebuild @@ -93,17 +79,14 @@ export class UmbDashboardPublishedStatusElement extends UmbLitElement { } private async _onRebuildCacheHandler() { - const modalContex = this._modalContext?.open(UMB_CONFIRM_MODAL, { - data: { - headline: 'Rebuild', - content: html` Rebuild content in cmsContentNu database table. Expensive.`, - color: 'danger', - confirmLabel: 'Continue', - }, - }); - modalContex?.onSubmit().then(() => { - this._rebuildDatabaseCache(); + await umbConfirmModal(this, { + headline: 'Rebuild', + content: html` Rebuild content in cmsContentNu database table. Expensive.`, + color: 'danger', + confirmLabel: 'Continue', }); + + this._rebuildDatabaseCache(); } //Collect @@ -118,17 +101,13 @@ export class UmbDashboardPublishedStatusElement extends UmbLitElement { } private async _onSnapshotCacheHandler() { - const modalContex = this._modalContext?.open(UMB_CONFIRM_MODAL, { - data: { - headline: 'Snapshot', - content: html` Trigger a NuCache snapshots collection.`, - color: 'danger', - confirmLabel: 'Continue', - }, - }); - modalContex?.onSubmit().then(() => { - this._cacheCollect(); + await umbConfirmModal(this, { + headline: 'Snapshot', + content: html` Trigger a NuCache snapshots collection.`, + color: 'danger', + confirmLabel: 'Continue', }); + this._cacheCollect(); } render() { diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/current-user/current-user-header-app.element.ts b/src/Umbraco.Web.UI.Client/src/packages/user/current-user/current-user-header-app.element.ts index 2dee14ca6d..cf5f6b4228 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/current-user/current-user-header-app.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/current-user/current-user-header-app.element.ts @@ -1,7 +1,7 @@ import { UMB_CURRENT_USER_MODAL } from './modals/current-user/current-user-modal.token.js'; import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; import type { CSSResultGroup } from '@umbraco-cms/backoffice/external/lit'; -import { css, html, customElement, state } from '@umbraco-cms/backoffice/external/lit'; +import { css, html, customElement, state, ifDefined } from '@umbraco-cms/backoffice/external/lit'; import type { UmbModalManagerContext } from '@umbraco-cms/backoffice/modal'; import { UMB_MODAL_MANAGER_CONTEXT } from '@umbraco-cms/backoffice/modal'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; @@ -12,6 +12,9 @@ export class UmbCurrentUserHeaderAppElement extends UmbLitElement { @state() private _currentUser?: UmbCurrentUserModel; + @state() + private _userAvatarUrls: Array<{ url: string; scale: string }> = []; + #currentUserContext?: typeof UMB_CURRENT_USER_CONTEXT.TYPE; #modalManagerContext?: UmbModalManagerContext; @@ -35,6 +38,8 @@ export class UmbCurrentUserHeaderAppElement extends UmbLitElement { this.#currentUserContext.currentUser, (currentUser) => { this._currentUser = currentUser; + if (!currentUser) return; + this.#setUserAvatarUrls(currentUser); }, 'umbCurrentUserObserver', ); @@ -44,6 +49,41 @@ export class UmbCurrentUserHeaderAppElement extends UmbLitElement { this.#modalManagerContext?.open(UMB_CURRENT_USER_MODAL); } + #setUserAvatarUrls = async (user: UmbCurrentUserModel | undefined) => { + if (!user || !user.avatarUrls || user.avatarUrls.length === 0) { + this._userAvatarUrls = []; + return; + } + + this._userAvatarUrls = [ + { + scale: '1x', + url: user.avatarUrls?.[0], + }, + { + scale: '2x', + url: user.avatarUrls?.[1], + }, + { + scale: '3x', + url: user.avatarUrls?.[2], + }, + ]; + }; + + #getAvatarSrcset() { + let string = ''; + + this._userAvatarUrls?.forEach((url) => { + string += `${url.url} ${url.scale},`; + }); + return string; + } + + #hasAvatar() { + return this._userAvatarUrls.length > 0; + } + render() { return html` - + `; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/current-user/current-user.context.ts b/src/Umbraco.Web.UI.Client/src/packages/user/current-user/current-user.context.ts index eab539d16a..7c7e759f0f 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/current-user/current-user.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/current-user/current-user.context.ts @@ -1,14 +1,14 @@ import type { UmbCurrentUserModel } from './types.js'; import { UmbCurrentUserRepository } from './repository/index.js'; import { UmbContextToken } from '@umbraco-cms/backoffice/context-api'; -import { UmbBaseController } from '@umbraco-cms/backoffice/class-api'; +import { UmbContextBase } from '@umbraco-cms/backoffice/class-api'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import { firstValueFrom } from '@umbraco-cms/backoffice/external/rxjs'; import { UMB_AUTH_CONTEXT } from '@umbraco-cms/backoffice/auth'; import { UmbObjectState } from '@umbraco-cms/backoffice/observable-api'; import { umbLocalizationRegistry } from '@umbraco-cms/backoffice/localization'; -export class UmbCurrentUserContext extends UmbBaseController { +export class UmbCurrentUserContext extends UmbContextBase { #currentUser = new UmbObjectState(undefined); readonly currentUser = this.#currentUser.asObservable(); @@ -18,7 +18,7 @@ export class UmbCurrentUserContext extends UmbBaseController { #currentUserRepository = new UmbCurrentUserRepository(this); constructor(host: UmbControllerHost) { - super(host); + super(host, UMB_CURRENT_USER_CONTEXT); this.consumeContext(UMB_AUTH_CONTEXT, (instance) => { this.#authContext = instance; @@ -29,16 +29,18 @@ export class UmbCurrentUserContext extends UmbBaseController { if (!currentLanguageIsoCode) return; umbLocalizationRegistry.loadLanguage(currentLanguageIsoCode); }); - - this.provideContext(UMB_CURRENT_USER_CONTEXT, this); } - async requestCurrentUser() { - const { data } = await this.#currentUserRepository.requestCurrentUser(); + /** + * Loads the current user + */ + async load() { + const { asObservable } = await this.#currentUserRepository.requestCurrentUser(); - if (data) { - // TODO: observe current user - this.#currentUser.setValue(data); + if (asObservable) { + this.observe(asObservable(), (currentUser) => { + this.#currentUser?.setValue(currentUser); + }); } } @@ -57,7 +59,7 @@ export class UmbCurrentUserContext extends UmbBaseController { if (!this.#authContext) return; this.observe(this.#authContext.isAuthorized, (isAuthorized) => { if (isAuthorized) { - this.requestCurrentUser(); + this.load(); } }); } diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/current-user/user-profile-apps/user-profile-app-external-login-providers.element.ts b/src/Umbraco.Web.UI.Client/src/packages/user/current-user/external-login/external-login-providers-user-profile-app.element.ts similarity index 65% rename from src/Umbraco.Web.UI.Client/src/packages/user/current-user/user-profile-apps/user-profile-app-external-login-providers.element.ts rename to src/Umbraco.Web.UI.Client/src/packages/user/current-user/external-login/external-login-providers-user-profile-app.element.ts index 20c8a3885c..87e434b2f2 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/current-user/user-profile-apps/user-profile-app-external-login-providers.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/current-user/external-login/external-login-providers-user-profile-app.element.ts @@ -2,8 +2,8 @@ import { html, customElement } from '@umbraco-cms/backoffice/external/lit'; import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; -@customElement('umb-user-profile-app-external-login-providers') -export class UmbUserProfileAppExternalLoginProvidersElement extends UmbLitElement { +@customElement('umb-external-login-providers-user-profile-app') +export class UmbExternalLoginProvidersUserProfileAppElement extends UmbLitElement { render() { return html` @@ -16,10 +16,10 @@ export class UmbUserProfileAppExternalLoginProvidersElement extends UmbLitElemen static styles = [UmbTextStyles]; } -export default UmbUserProfileAppExternalLoginProvidersElement; +export default UmbExternalLoginProvidersUserProfileAppElement; declare global { interface HTMLElementTagNameMap { - 'umb-user-profile-app-external-login-providers': UmbUserProfileAppExternalLoginProvidersElement; + 'umb-external-login-providers-user-profile-app': UmbExternalLoginProvidersUserProfileAppElement; } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/current-user/external-login/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/user/current-user/external-login/manifests.ts new file mode 100644 index 0000000000..2295688d88 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/user/current-user/external-login/manifests.ts @@ -0,0 +1,16 @@ +import type { ManifestUserProfileApp } from '@umbraco-cms/backoffice/extension-registry'; + +export const userProfileApps: Array = [ + { + type: 'userProfileApp', + alias: 'Umb.UserProfileApp.CurrentUser.ExternalLoginProviders', + name: 'External Login Providers User Profile App', + element: () => import('./external-login-providers-user-profile-app.element.js'), + weight: 800, + meta: { + label: 'External Login Providers User Profile App', + pathname: 'externalLoginProviders', + }, + }, +]; +export const manifests = [...userProfileApps]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/current-user/user-profile-apps/user-profile-app-history.element.ts b/src/Umbraco.Web.UI.Client/src/packages/user/current-user/history/current-user-history-user-profile-app.element.ts similarity index 86% rename from src/Umbraco.Web.UI.Client/src/packages/user/current-user/user-profile-apps/user-profile-app-history.element.ts rename to src/Umbraco.Web.UI.Client/src/packages/user/current-user/history/current-user-history-user-profile-app.element.ts index bc58fe7156..c2b58fac26 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/current-user/user-profile-apps/user-profile-app-history.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/current-user/history/current-user-history-user-profile-app.element.ts @@ -1,11 +1,11 @@ -import type { UmbCurrentUserHistoryItem, UmbCurrentUserHistoryStore } from '../current-user-history.store.js'; -import { UMB_CURRENT_USER_HISTORY_STORE_CONTEXT } from '../current-user-history.store.js'; +import type { UmbCurrentUserHistoryItem, UmbCurrentUserHistoryStore } from './current-user-history.store.js'; +import { UMB_CURRENT_USER_HISTORY_STORE_CONTEXT } from './current-user-history.store.js'; import { css, html, nothing, customElement, state } from '@umbraco-cms/backoffice/external/lit'; import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; -@customElement('umb-user-profile-app-history') -export class UmbUserProfileAppHistoryElement extends UmbLitElement { +@customElement('umb-current-user-history-user-profile-app') +export class UmbCurrentUserHistoryUserProfileAppElement extends UmbLitElement { @state() private _history: Array = []; @@ -109,10 +109,10 @@ export class UmbUserProfileAppHistoryElement extends UmbLitElement { ]; } -export default UmbUserProfileAppHistoryElement; +export default UmbCurrentUserHistoryUserProfileAppElement; declare global { interface HTMLElementTagNameMap { - 'umb-user-dashboard-test': UmbUserProfileAppHistoryElement; + 'umb-current-user-history-user-profile-app': UmbCurrentUserHistoryUserProfileAppElement; } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/current-user/current-user-history.store.ts b/src/Umbraco.Web.UI.Client/src/packages/user/current-user/history/current-user-history.store.ts similarity index 100% rename from src/Umbraco.Web.UI.Client/src/packages/user/current-user/current-user-history.store.ts rename to src/Umbraco.Web.UI.Client/src/packages/user/current-user/history/current-user-history.store.ts diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/current-user/history/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/user/current-user/history/manifests.ts new file mode 100644 index 0000000000..491eb883eb --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/user/current-user/history/manifests.ts @@ -0,0 +1,22 @@ +import type { ManifestTypes } from '@umbraco-cms/backoffice/extension-registry'; + +export const userProfileApps: Array = [ + { + type: 'userProfileApp', + alias: 'Umb.UserProfileApp.CurrentUser.History', + name: 'Current User History User Profile App', + element: () => import('../history/current-user-history-user-profile-app.element.js'), + weight: 100, + meta: { + label: 'History', + pathname: 'history', + }, + }, + { + type: 'store', + alias: 'Umb.Store.CurrentUser.History', + name: 'Current User History Store', + api: () => import('./current-user-history.store.js'), + }, +]; +export const manifests = [...userProfileApps]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/current-user/index.ts b/src/Umbraco.Web.UI.Client/src/packages/user/current-user/index.ts index 8d63217a6a..4327ecb296 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/current-user/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/current-user/index.ts @@ -1,5 +1,4 @@ -// TODO:Do not export store, but instead export future repository -export * from './current-user-history.store.js'; +export * from './history/current-user-history.store.js'; export * from './utils/index.js'; export * from './current-user.context.js'; export * from './types.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/current-user/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/user/current-user/manifests.ts index 399fc1a8b7..63d6f32e1d 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/current-user/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/current-user/manifests.ts @@ -1,14 +1,12 @@ import { manifests as modalManifests } from './modals/manifests.js'; -import { manifests as userProfileAppsManifests } from './user-profile-apps/manifests.js'; +import { manifests as externalLoginProviderManifests } from './external-login/manifests.js'; +import { manifests as historyManifests } from './history/manifests.js'; +import { manifests as profileManifests } from './profile/manifests.js'; +import { manifests as themeManifests } from './theme/manifests.js'; +import { manifests as repositoryManifests } from './repository/manifests.js'; import type { ManifestTypes } from '@umbraco-cms/backoffice/extension-registry'; export const headerApps: Array = [ - { - type: 'store', - alias: 'Umb.Store.CurrentUser', - name: 'Current User Store', - js: () => import('./current-user-history.store.js'), - }, { type: 'globalContext', alias: 'Umb.GlobalContext.CurrentUser', @@ -19,7 +17,7 @@ export const headerApps: Array = [ type: 'headerApp', alias: 'Umb.HeaderApp.CurrentUser', name: 'Current User', - js: () => import('./current-user-header-app.element.js'), + element: () => import('./current-user-header-app.element.js'), weight: 0, meta: { label: 'TODO: how should we enable this to not be set.', @@ -29,4 +27,12 @@ export const headerApps: Array = [ }, ]; -export const manifests = [...headerApps, ...modalManifests, ...userProfileAppsManifests]; +export const manifests = [ + ...externalLoginProviderManifests, + ...headerApps, + ...historyManifests, + ...modalManifests, + ...profileManifests, + ...repositoryManifests, + ...themeManifests, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/current-user/user-profile-apps/user-profile-app-profile.element.ts b/src/Umbraco.Web.UI.Client/src/packages/user/current-user/profile/current-user-profile-user-profile-app.element.ts similarity index 88% rename from src/Umbraco.Web.UI.Client/src/packages/user/current-user/user-profile-apps/user-profile-app-profile.element.ts rename to src/Umbraco.Web.UI.Client/src/packages/user/current-user/profile/current-user-profile-user-profile-app.element.ts index b9f053d507..1916485fdf 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/current-user/user-profile-apps/user-profile-app-profile.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/current-user/profile/current-user-profile-user-profile-app.element.ts @@ -4,8 +4,8 @@ import type { UmbModalManagerContext } from '@umbraco-cms/backoffice/modal'; import { UMB_CHANGE_PASSWORD_MODAL, UMB_MODAL_MANAGER_CONTEXT } from '@umbraco-cms/backoffice/modal'; import { UMB_CURRENT_USER_CONTEXT, type UmbCurrentUserModel } from '@umbraco-cms/backoffice/current-user'; -@customElement('umb-user-profile-app-profile') -export class UmbUserProfileAppProfileElement extends UmbLitElement { +@customElement('umb-current-user-profile-user-profile-app') +export class UmbCurrentUserProfileUserProfileAppElement extends UmbLitElement { @state() private _currentUser?: UmbCurrentUserModel; @@ -70,10 +70,10 @@ export class UmbUserProfileAppProfileElement extends UmbLitElement { } } -export default UmbUserProfileAppProfileElement; +export default UmbCurrentUserProfileUserProfileAppElement; declare global { interface HTMLElementTagNameMap { - 'umb-user-profile-app-profile': UmbUserProfileAppProfileElement; + 'umb-current-user-profile-user-profile-app': UmbCurrentUserProfileUserProfileAppElement; } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/current-user/profile/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/user/current-user/profile/manifests.ts new file mode 100644 index 0000000000..632490be5f --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/user/current-user/profile/manifests.ts @@ -0,0 +1,16 @@ +import type { ManifestUserProfileApp } from '@umbraco-cms/backoffice/extension-registry'; + +export const userProfileApps: Array = [ + { + type: 'userProfileApp', + alias: 'Umb.UserProfileApp.CurrentUser.Profile', + name: 'Current User Profile User Profile App', + element: () => import('./current-user-profile-user-profile-app.element.js'), + weight: 900, + meta: { + label: 'Current User Profile User Profile App', + pathname: 'profile', + }, + }, +]; +export const manifests = [...userProfileApps]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/current-user/repository/current-user.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/user/current-user/repository/current-user.repository.ts index 3c239b15fe..a48e199660 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/current-user/repository/current-user.repository.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/current-user/repository/current-user.repository.ts @@ -1,4 +1,5 @@ import { UmbCurrentUserServerDataSource } from './current-user.server.data-source.js'; +import { UMB_CURRENT_USER_STORE_CONTEXT } from './current-user.store.js'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import { UmbRepositoryBase } from '@umbraco-cms/backoffice/repository'; @@ -10,11 +11,19 @@ import { UmbRepositoryBase } from '@umbraco-cms/backoffice/repository'; */ export class UmbCurrentUserRepository extends UmbRepositoryBase { #currentUserSource: UmbCurrentUserServerDataSource; + #currentUserStore?: typeof UMB_CURRENT_USER_STORE_CONTEXT.TYPE; + #init: Promise; constructor(host: UmbControllerHost) { super(host); this.#currentUserSource = new UmbCurrentUserServerDataSource(host); + + this.#init = Promise.all([ + this.consumeContext(UMB_CURRENT_USER_STORE_CONTEXT, (instance) => { + this.#currentUserStore = instance; + }).asPromise(), + ]); } /** @@ -23,8 +32,14 @@ export class UmbCurrentUserRepository extends UmbRepositoryBase { * @memberof UmbCurrentUserRepository */ async requestCurrentUser() { - // TODO: add observable option - return this.#currentUserSource.getCurrentUser(); + await this.#init; + const { data, error } = await this.#currentUserSource.getCurrentUser(); + + if (data) { + this.#currentUserStore?.set(data); + } + + return { data, error, asObservable: () => this.#currentUserStore!.data }; } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/current-user/repository/current-user.server.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/user/current-user/repository/current-user.server.data-source.ts index 5038b01a3e..50bae0fe8c 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/current-user/repository/current-user.server.data-source.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/current-user/repository/current-user.server.data-source.ts @@ -35,8 +35,8 @@ export class UmbCurrentUserServerDataSource { userName: data.userName, name: data.name, languageIsoCode: data.languageIsoCode || 'en-us', // TODO: make global variable - documentStartNodeIds: data.documentStartNodeIds, - mediaStartNodeIds: data.mediaStartNodeIds, + documentStartNodeUniques: data.documentStartNodeIds, + mediaStartNodeUniques: data.mediaStartNodeIds, avatarUrls: data.avatarUrls, languages: data.languages, hasAccessToAllLanguages: data.hasAccessToAllLanguages, diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/current-user/repository/current-user.store.ts b/src/Umbraco.Web.UI.Client/src/packages/user/current-user/repository/current-user.store.ts new file mode 100644 index 0000000000..47806bb260 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/user/current-user/repository/current-user.store.ts @@ -0,0 +1,78 @@ +import type { UmbCurrentUserModel } from '../types.js'; +import type { UmbUserDetailModel } from '@umbraco-cms/backoffice/user'; +import { UMB_USER_DETAIL_STORE_CONTEXT } from '@umbraco-cms/backoffice/user'; +import { UmbContextBase } from '@umbraco-cms/backoffice/class-api'; +import { UmbContextToken } from '@umbraco-cms/backoffice/context-api'; +import type { UmbControllerHostElement } from '@umbraco-cms/backoffice/controller-api'; +import { UmbObjectState } from '@umbraco-cms/backoffice/observable-api'; + +export class UmbCurrentUserStore extends UmbContextBase { + #data = new UmbObjectState(undefined); + readonly data = this.#data.asObservable(); + + constructor(host: UmbControllerHostElement) { + super(host, UMB_CURRENT_USER_STORE_CONTEXT.toString()); + + this.consumeContext(UMB_USER_DETAIL_STORE_CONTEXT, (instance) => { + this.observe(instance?.all(), (users) => this.#onUserDetailStoreUpdate(users)); + }); + } + + /** + * Get the current user + * @readonly + * @type {UmbCurrentUserModel} + * @memberof UmbCurrentUserStore + */ + get() { + return this.#data.getValue(); + } + + /** + * Set the current user + * @param {UmbCurrentUserModel} data + * @memberof UmbCurrentUserStore + */ + set(data: UmbCurrentUserModel) { + this.#data.setValue(data); + } + + /** + * Update the current user + * @param {Partial} data + * @memberof UmbCurrentUserStore + */ + update(data: Partial) { + this.#data.update(data); + } + + /** + * Clear the current user + * @memberof UmbCurrentUserStore + */ + clear() { + this.#data.setValue(undefined); + } + + #onUserDetailStoreUpdate = (users: Array) => { + const currentUser = this.get(); + if (!currentUser) return; + + const updatedCurrentUser = users.find((user) => user.unique === currentUser.unique); + if (!updatedCurrentUser) return; + + const mappedCurrentUser: Partial = { + email: updatedCurrentUser.email, + userName: updatedCurrentUser.userName, + name: updatedCurrentUser.name, + languageIsoCode: updatedCurrentUser.languageIsoCode || '', // TODO: default value? + documentStartNodeUniques: updatedCurrentUser.documentStartNodeUniques, + mediaStartNodeUniques: updatedCurrentUser.mediaStartNodeUniques, + avatarUrls: updatedCurrentUser.avatarUrls, + }; + + this.update(mappedCurrentUser); + }; +} + +export const UMB_CURRENT_USER_STORE_CONTEXT = new UmbContextToken('UmbCurrentUserStore'); diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/current-user/repository/index.ts b/src/Umbraco.Web.UI.Client/src/packages/user/current-user/repository/index.ts index a5a259f95c..b00cb02a9e 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/current-user/repository/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/current-user/repository/index.ts @@ -1,2 +1,3 @@ export { UmbCurrentUserRepository } from './current-user.repository.js'; export { UMB_CURRENT_USER_REPOSITORY_ALIAS } from './manifests.js'; +export { UmbCurrentUserStore, UMB_CURRENT_USER_STORE_CONTEXT } from './current-user.store.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/current-user/repository/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/user/current-user/repository/manifests.ts index ae80661dbd..0a98822fa5 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/current-user/repository/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/current-user/repository/manifests.ts @@ -1,12 +1,20 @@ -import type { ManifestRepository } from '@umbraco-cms/backoffice/extension-registry'; +import { UmbCurrentUserStore } from './current-user.store.js'; +import type { ManifestRepository, ManifestStore } from '@umbraco-cms/backoffice/extension-registry'; export const UMB_CURRENT_USER_REPOSITORY_ALIAS = 'Umb.Repository.CurrentUser'; -const avatarRepository: ManifestRepository = { +const repository: ManifestRepository = { type: 'repository', alias: UMB_CURRENT_USER_REPOSITORY_ALIAS, name: 'Current User Repository', api: () => import('./current-user.repository.js'), }; -export const manifests = [avatarRepository]; +const store: ManifestStore = { + type: 'store', + alias: 'Umb.Store.CurrentUser', + name: 'Current User Store', + api: UmbCurrentUserStore, +}; + +export const manifests = [repository, store]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/current-user/user-profile-apps/user-profile-app-themes.element.ts b/src/Umbraco.Web.UI.Client/src/packages/user/current-user/theme/current-user-theme-user-profile-app.element.ts similarity index 87% rename from src/Umbraco.Web.UI.Client/src/packages/user/current-user/user-profile-apps/user-profile-app-themes.element.ts rename to src/Umbraco.Web.UI.Client/src/packages/user/current-user/theme/current-user-theme-user-profile-app.element.ts index 56c556d1ff..c3c294372e 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/current-user/user-profile-apps/user-profile-app-themes.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/current-user/theme/current-user-theme-user-profile-app.element.ts @@ -6,8 +6,8 @@ import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import type { ManifestTheme } from '@umbraco-cms/backoffice/extension-registry'; import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry'; -@customElement('umb-user-profile-app-themes') -export class UmbUserProfileAppThemesElement extends UmbLitElement { +@customElement('umb-current-user-theme-user-profile-app') +export class UmbCurrentUserThemeUserProfileAppElement extends UmbLitElement { #themeContext?: UmbThemeContext; @state() @@ -77,10 +77,10 @@ export class UmbUserProfileAppThemesElement extends UmbLitElement { ]; } -export default UmbUserProfileAppThemesElement; +export default UmbCurrentUserThemeUserProfileAppElement; declare global { interface HTMLElementTagNameMap { - 'umb-user-profile-app-themes': UmbUserProfileAppThemesElement; + 'umb-current-user-theme-user-profile-app': UmbCurrentUserThemeUserProfileAppElement; } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/current-user/theme/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/user/current-user/theme/manifests.ts new file mode 100644 index 0000000000..19c24ea470 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/user/current-user/theme/manifests.ts @@ -0,0 +1,16 @@ +import type { ManifestUserProfileApp } from '@umbraco-cms/backoffice/extension-registry'; + +export const userProfileApps: Array = [ + { + type: 'userProfileApp', + alias: 'Umb.UserProfileApp.CurrentUser.Theme', + name: 'Current User Theme User Profile App', + element: () => import('./current-user-theme-user-profile-app.element.js'), + weight: 200, + meta: { + label: 'Current User Theme User Profile App', + pathname: 'themes', + }, + }, +]; +export const manifests = [...userProfileApps]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/current-user/types.ts b/src/Umbraco.Web.UI.Client/src/packages/user/current-user/types.ts index 21a3296d5a..0cb54ad319 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/current-user/types.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/current-user/types.ts @@ -4,8 +4,8 @@ export interface UmbCurrentUserModel { userName: string; name: string; languageIsoCode: string; - documentStartNodeIds: Array; - mediaStartNodeIds: Array; + documentStartNodeUniques: Array; + mediaStartNodeUniques: Array; avatarUrls: Array; languages: Array; hasAccessToAllLanguages: boolean; diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/current-user/user-profile-apps/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/user/current-user/user-profile-apps/manifests.ts deleted file mode 100644 index 98d5a2e84e..0000000000 --- a/src/Umbraco.Web.UI.Client/src/packages/user/current-user/user-profile-apps/manifests.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { ManifestUserProfileApp } from '@umbraco-cms/backoffice/extension-registry'; - -export const userProfileApps: Array = [ - { - type: 'userProfileApp', - alias: 'Umb.UserProfileApp.profile', - name: 'Profile User Profile App', - js: () => import('./user-profile-app-profile.element.js'), - weight: 900, - meta: { - label: 'Profile User Profile App', - pathname: 'profile', - }, - }, - { - type: 'userProfileApp', - alias: 'Umb.UserProfileApp.ExternalLoginProviders', - name: 'External Login Providers User Profile App', - js: () => import('./user-profile-app-external-login-providers.element.js'), - weight: 800, - meta: { - label: 'External Login Providers User Profile App', - pathname: 'externalLoginProviders', - }, - }, - { - type: 'userProfileApp', - alias: 'Umb.UserProfileApp.Themes', - name: 'Themes User Profile App', - js: () => import('./user-profile-app-themes.element.js'), - weight: 200, - meta: { - label: 'Themes User Profile App', - pathname: 'themes', - }, - }, - { - type: 'userProfileApp', - alias: 'Umb.UserProfileApp.History', - name: 'History User Profile App', - js: () => import('./user-profile-app-history.element.js'), - weight: 100, - meta: { - label: 'History User Profile App', - pathname: 'history', - }, - }, -]; -export const manifests = [...userProfileApps]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user-group/entity-bulk-actions/delete/delete.action.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user-group/entity-bulk-actions/delete/delete.action.ts index 4152be03b1..8d26668d56 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/user-group/entity-bulk-actions/delete/delete.action.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user-group/entity-bulk-actions/delete/delete.action.ts @@ -1,36 +1,19 @@ import type { UmbUserGroupDetailRepository } from '../../repository/index.js'; import { html } from '@umbraco-cms/backoffice/external/lit'; import { UmbEntityBulkActionBase } from '@umbraco-cms/backoffice/entity-bulk-action'; -import type { UmbControllerHostElement } from '@umbraco-cms/backoffice/controller-api'; -import { UmbContextConsumerController } from '@umbraco-cms/backoffice/context-api'; -import type { UmbModalManagerContext } from '@umbraco-cms/backoffice/modal'; -import { UMB_MODAL_MANAGER_CONTEXT, UMB_CONFIRM_MODAL } from '@umbraco-cms/backoffice/modal'; +import { umbConfirmModal } from '@umbraco-cms/backoffice/modal'; export class UmbDeleteUserGroupEntityBulkAction extends UmbEntityBulkActionBase { - #modalContext?: UmbModalManagerContext; - - constructor(host: UmbControllerHostElement, repositoryAlias: string, selection: Array) { - super(host, repositoryAlias, selection); - - new UmbContextConsumerController(host, UMB_MODAL_MANAGER_CONTEXT, (instance) => { - this.#modalContext = instance; - }); - } - async execute() { - if (!this.#modalContext || this.selection.length === 0) return; + if (this.selection.length === 0) return; - const modalContext = this.#modalContext.open(UMB_CONFIRM_MODAL, { - data: { - color: 'danger', - headline: `Delete user groups?`, - content: html`Are you sure you want to delete selected user groups?`, - confirmLabel: 'Delete', - }, + await umbConfirmModal(this._host, { + color: 'danger', + headline: `Delete user groups?`, + content: html`Are you sure you want to delete selected user groups?`, + confirmLabel: 'Delete', }); - await modalContext.onSubmit(); - //TODO: How should we handle bulk actions? right now we send a request per item we want to change. //TODO: For now we have to reload the page to see the update for (let index = 0; index < this.selection.length; index++) { diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user/collection/views/grid/user-grid-collection-view.element.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user/collection/views/grid/user-grid-collection-view.element.ts index 1289e53543..0f58d7e51f 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/user/collection/views/grid/user-grid-collection-view.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user/collection/views/grid/user-grid-collection-view.element.ts @@ -1,4 +1,4 @@ -import { getDisplayStateFromUserStatus } from '../../../../utils.js'; +import { getDisplayStateFromUserStatus } from '../../../utils.js'; import type { UmbUserCollectionContext } from '../../user-collection.context.js'; import type { UmbUserDetailModel } from '../../../types.js'; import { css, html, nothing, customElement, state, repeat, ifDefined } from '@umbraco-cms/backoffice/external/lit'; @@ -76,6 +76,27 @@ export class UmbUserGridCollectionViewElement extends UmbLitElement { } #renderUserCard(user: UmbUserDetailModel) { + const avatarUrls = [ + { + scale: '1x', + url: user.avatarUrls?.[0], + }, + { + scale: '2x', + url: user.avatarUrls?.[1], + }, + { + scale: '3x', + url: user.avatarUrls?.[2], + }, + ]; + + let avatarSrcset = ''; + + avatarUrls.forEach((url) => { + avatarSrcset += `${url.url} ${url.scale},`; + }); + return html` this.#onSelect(user)} @deselected=${() => this.#onDeselect(user)}> ${this.#renderUserTag(user)} ${this.#renderUserGroupNames(user)} ${this.#renderUserLoginDate(user)} + + 0 ? avatarUrls[0].url : undefined)} + img-srcset=${ifDefined(user.avatarUrls.length > 0 ? avatarSrcset : undefined)}> `; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user/collection/views/table/column-layouts/name/user-table-name-column-layout.element.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user/collection/views/table/column-layouts/name/user-table-name-column-layout.element.ts index a288dc49c1..620eee98c7 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/user/collection/views/table/column-layouts/name/user-table-name-column-layout.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user/collection/views/table/column-layouts/name/user-table-name-column-layout.element.ts @@ -1,4 +1,4 @@ -import { html, LitElement, customElement, property } from '@umbraco-cms/backoffice/external/lit'; +import { html, LitElement, customElement, property, ifDefined } from '@umbraco-cms/backoffice/external/lit'; import type { UmbTableColumn, UmbTableItem } from '@umbraco-cms/backoffice/components'; @customElement('umb-user-table-name-column-layout') @@ -13,9 +13,34 @@ export class UmbUserTableNameColumnLayoutElement extends LitElement { value!: any; render() { + const avatarUrls = [ + { + scale: '1x', + url: this.value.avatarUrls?.[0], + }, + { + scale: '2x', + url: this.value.avatarUrls?.[1], + }, + { + scale: '3x', + url: this.value.avatarUrls?.[2], + }, + ]; + + let avatarSrcset = ''; + + avatarUrls.forEach((url) => { + avatarSrcset += `${url.url} ${url.scale},`; + }); + return html` `; diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user/collection/views/table/column-layouts/status/user-table-status-column-layout.element.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user/collection/views/table/column-layouts/status/user-table-status-column-layout.element.ts index c8d4bbf3f1..19880ac087 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/user/collection/views/table/column-layouts/status/user-table-status-column-layout.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user/collection/views/table/column-layouts/status/user-table-status-column-layout.element.ts @@ -1,4 +1,4 @@ -import { getDisplayStateFromUserStatus } from '../../../../../../utils.js'; +import { getDisplayStateFromUserStatus } from '../../../../../utils.js'; import { html, LitElement, nothing, customElement, property } from '@umbraco-cms/backoffice/external/lit'; @customElement('umb-user-table-status-column-layout') diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user/collection/views/table/user-table-collection-view.element.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user/collection/views/table/user-table-collection-view.element.ts index 6cb43059d4..144849bd97 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/user/collection/views/table/user-table-collection-view.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user/collection/views/table/user-table-collection-view.element.ts @@ -87,8 +87,8 @@ export class UmbUserTableCollectionViewElement extends UmbLitElement { async #observeUserGroups() { if (this._users.length === 0) return; - const userGroupsIds = [...new Set(this._users.flatMap((user) => user.userGroupUniques))]; - const { asObservable } = await this.#userGroupItemRepository.requestItems(userGroupsIds); + const userGroupsUniques = [...new Set(this._users.flatMap((user) => user.userGroupUniques))]; + const { asObservable } = await this.#userGroupItemRepository.requestItems(userGroupsUniques); this.observe( asObservable(), (userGroups) => { @@ -116,7 +116,9 @@ export class UmbUserTableCollectionViewElement extends UmbLitElement { { columnAlias: 'userName', value: { + unique: user.unique, name: user.name, + avatarUrls: user.avatarUrls, }, }, { diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user/entity-actions/disable/disable-user.action.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user/entity-actions/disable/disable-user.action.ts index 389273d25d..a6ac483143 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/user/entity-actions/disable/disable-user.action.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user/entity-actions/disable/disable-user.action.ts @@ -2,44 +2,32 @@ import type { UmbDisableUserRepository } from '../../repository/disable/disable- import { UmbUserItemRepository } from '../../repository/item/user-item.repository.js'; import { UmbEntityActionBase } from '@umbraco-cms/backoffice/entity-action'; import type { UmbControllerHostElement } from '@umbraco-cms/backoffice/controller-api'; -import { - type UmbModalManagerContext, - UMB_MODAL_MANAGER_CONTEXT, - UMB_CONFIRM_MODAL, -} from '@umbraco-cms/backoffice/modal'; +import { umbConfirmModal } from '@umbraco-cms/backoffice/modal'; export class UmbDisableUserEntityAction extends UmbEntityActionBase { - #modalManager?: UmbModalManagerContext; #itemRepository: UmbUserItemRepository; constructor(host: UmbControllerHostElement, repositoryAlias: string, unique: string, entityType: string) { super(host, repositoryAlias, unique, entityType); this.#itemRepository = new UmbUserItemRepository(this); - - this.consumeContext(UMB_MODAL_MANAGER_CONTEXT, (instance) => { - this.#modalManager = instance; - }); } async execute() { - if (!this.repository || !this.#modalManager) return; + if (!this.repository) return; const { data } = await this.#itemRepository.requestItems([this.unique]); if (data) { const item = data[0]; - const modalContext = this.#modalManager.open(UMB_CONFIRM_MODAL, { - data: { - headline: `Disable ${item.name}`, - content: 'Are you sure you want to disable this user?', - color: 'danger', - confirmLabel: 'Disable', - }, + await umbConfirmModal(this._host, { + headline: `Disable ${item.name}`, + content: 'Are you sure you want to disable this user?', + color: 'danger', + confirmLabel: 'Disable', }); - await modalContext.onSubmit(); await this.repository?.disable([this.unique]); } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user/entity-actions/enable/enable-user.action.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user/entity-actions/enable/enable-user.action.ts index 9b2893ffad..43e690800c 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/user/entity-actions/enable/enable-user.action.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user/entity-actions/enable/enable-user.action.ts @@ -2,43 +2,31 @@ import type { UmbEnableUserRepository } from '../../repository/enable/enable-use import { UmbUserItemRepository } from '../../repository/item/user-item.repository.js'; import { UmbEntityActionBase } from '@umbraco-cms/backoffice/entity-action'; import type { UmbControllerHostElement } from '@umbraco-cms/backoffice/controller-api'; -import { - type UmbModalManagerContext, - UMB_MODAL_MANAGER_CONTEXT, - UMB_CONFIRM_MODAL, -} from '@umbraco-cms/backoffice/modal'; +import { umbConfirmModal } from '@umbraco-cms/backoffice/modal'; export class UmbEnableUserEntityAction extends UmbEntityActionBase { - #modalManager?: UmbModalManagerContext; #itemRepository: UmbUserItemRepository; constructor(host: UmbControllerHostElement, repositoryAlias: string, unique: string, entityType: string) { super(host, repositoryAlias, unique, entityType); this.#itemRepository = new UmbUserItemRepository(this); - - this.consumeContext(UMB_MODAL_MANAGER_CONTEXT, (instance) => { - this.#modalManager = instance; - }); } async execute() { - if (!this.repository || !this.#modalManager) return; + if (!this.repository) return; const { data } = await this.#itemRepository.requestItems([this.unique]); if (data) { const item = data[0]; - const modalContext = this.#modalManager.open(UMB_CONFIRM_MODAL, { - data: { - headline: `Enable ${item.name}`, - content: 'Are you sure you want to enable this user?', - confirmLabel: 'Enable', - }, + await umbConfirmModal(this._host, { + headline: `Enable ${item.name}`, + content: 'Are you sure you want to enable this user?', + confirmLabel: 'Enable', }); - await modalContext.onSubmit(); await this.repository?.enable([this.unique]); } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user/entity-actions/unlock/unlock-user.action.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user/entity-actions/unlock/unlock-user.action.ts index f1ad6e0d1b..718a69af66 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/user/entity-actions/unlock/unlock-user.action.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user/entity-actions/unlock/unlock-user.action.ts @@ -2,43 +2,31 @@ import type { UmbUnlockUserRepository } from '../../repository/index.js'; import { UmbUserItemRepository } from '../../repository/item/user-item.repository.js'; import { UmbEntityActionBase } from '@umbraco-cms/backoffice/entity-action'; import type { UmbControllerHostElement } from '@umbraco-cms/backoffice/controller-api'; -import { - type UmbModalManagerContext, - UMB_MODAL_MANAGER_CONTEXT, - UMB_CONFIRM_MODAL, -} from '@umbraco-cms/backoffice/modal'; +import { umbConfirmModal } from '@umbraco-cms/backoffice/modal'; export class UmbUnlockUserEntityAction extends UmbEntityActionBase { - #modalManager?: UmbModalManagerContext; #itemRepository: UmbUserItemRepository; constructor(host: UmbControllerHostElement, repositoryAlias: string, unique: string, entityType: string) { super(host, repositoryAlias, unique, entityType); this.#itemRepository = new UmbUserItemRepository(this); - - this.consumeContext(UMB_MODAL_MANAGER_CONTEXT, (instance) => { - this.#modalManager = instance; - }); } async execute() { - if (!this.repository || !this.#modalManager) return; + if (!this.repository) return; const { data } = await this.#itemRepository.requestItems([this.unique]); if (data) { const item = data[0]; - const modalContext = this.#modalManager.open(UMB_CONFIRM_MODAL, { - data: { - headline: `Unlock ${item.name}`, - content: 'Are you sure you want to unlock this user?', - confirmLabel: 'Unlock', - }, + await umbConfirmModal(this._host, { + headline: `Unlock ${item.name}`, + content: 'Are you sure you want to unlock this user?', + confirmLabel: 'Unlock', }); - await modalContext.onSubmit(); await this.repository?.unlock([this.unique]); } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user/entity-bulk-actions/delete/delete.action.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user/entity-bulk-actions/delete/delete.action.ts index a01a74021b..cc041c3a4d 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/user/entity-bulk-actions/delete/delete.action.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user/entity-bulk-actions/delete/delete.action.ts @@ -1,36 +1,19 @@ import type { UmbUserDetailRepository } from '../../repository/index.js'; import { html } from '@umbraco-cms/backoffice/external/lit'; import { UmbEntityBulkActionBase } from '@umbraco-cms/backoffice/entity-bulk-action'; -import type { UmbControllerHostElement } from '@umbraco-cms/backoffice/controller-api'; -import { UmbContextConsumerController } from '@umbraco-cms/backoffice/context-api'; -import type { UmbModalManagerContext} from '@umbraco-cms/backoffice/modal'; -import { UMB_MODAL_MANAGER_CONTEXT, UMB_CONFIRM_MODAL } from '@umbraco-cms/backoffice/modal'; +import { umbConfirmModal } from '@umbraco-cms/backoffice/modal'; export class UmbUserDeleteEntityBulkAction extends UmbEntityBulkActionBase { - #modalContext?: UmbModalManagerContext; - - constructor(host: UmbControllerHostElement, repositoryAlias: string, selection: Array) { - super(host, repositoryAlias, selection); - - new UmbContextConsumerController(host, UMB_MODAL_MANAGER_CONTEXT, (instance) => { - this.#modalContext = instance; - }); - } - async execute() { - if (!this.#modalContext || this.selection.length === 0) return; + if (this.selection.length === 0) return; - const modalContext = this.#modalContext.open(UMB_CONFIRM_MODAL, { - data: { - color: 'danger', - headline: `Delete users?`, - content: html`Are you sure you want to delete selected users?`, - confirmLabel: 'Delete', - }, + await umbConfirmModal(this._host, { + color: 'danger', + headline: `Delete users?`, + content: html`Are you sure you want to delete selected users?`, + confirmLabel: 'Delete', }); - await modalContext.onSubmit(); - //TODO: How should we handle bulk actions? right now we send a request per item we want to change. //TODO: For now we have to reload the page to see the update for (let index = 0; index < this.selection.length; index++) { diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user/repository/avatar/user-avatar.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user/repository/avatar/user-avatar.repository.ts index 11f1e15852..3f11546de1 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/user/repository/avatar/user-avatar.repository.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user/repository/avatar/user-avatar.repository.ts @@ -7,6 +7,7 @@ import { UmbTemporaryFileRepository } from '@umbraco-cms/backoffice/temporary-fi export class UmbUserAvatarRepository extends UmbUserRepositoryBase { #temporaryFileRepository: UmbTemporaryFileRepository; #avatarSource: UmbUserAvatarServerDataSource; + #avatarFile: File | null = null; constructor(host: UmbControllerHost) { super(host); @@ -17,13 +18,13 @@ export class UmbUserAvatarRepository extends UmbUserRepositoryBase { /** * Uploads an avatar for the user with the given id - * @param {string} userId + * @param {string} userUnique * @param {File} file * @return {Promise} * @memberof UmbUserRepository */ - async uploadAvatar(userId: string, file: File) { - if (!userId) throw new Error('Id is missing'); + async uploadAvatar(userUnique: string, file: File) { + if (!userUnique) throw new Error('Id is missing'); await this.init; // upload temp file @@ -31,10 +32,15 @@ export class UmbUserAvatarRepository extends UmbUserRepositoryBase { await this.#temporaryFileRepository.upload(fileId, file); // assign temp file to avatar - const { error } = await this.#avatarSource.createAvatar(userId, fileId); + const { error } = await this.#avatarSource.createAvatar(userUnique, fileId); if (!error) { // TODO: update store + current user + const localUrl = URL.createObjectURL(file); + + // The server returns 5 different sizes of the avatar, so we need to mimick that here + this.detailStore?.updateItem(userUnique, { avatarUrls: [localUrl, localUrl, localUrl, localUrl, localUrl] }); + const notification = { data: { message: `Avatar uploaded` } }; this.notificationContext?.peek('positive', notification); } @@ -44,24 +50,29 @@ export class UmbUserAvatarRepository extends UmbUserRepositoryBase { /** * Removes the avatar for the user with the given id - * @param {string} id + * @param {string} userUnique * @return {Promise} * @memberof UmbUserRepository */ - async deleteAvatar(id: string) { - if (!id) throw new Error('Id is missing'); + async deleteAvatar(userUnique: string) { + if (!userUnique) throw new Error('Id is missing'); await this.init; - const { error } = await this.#avatarSource.deleteAvatar(id); + const { error } = await this.#avatarSource.deleteAvatar(userUnique); if (!error) { - // TODO: update store + current user + this.detailStore?.updateItem(userUnique, { avatarUrls: [] }); + const notification = { data: { message: `Avatar deleted` } }; this.notificationContext?.peek('positive', notification); } return { error }; } + + destroy() { + super.destroy(); + } } export default UmbUserAvatarRepository; diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/utils.test.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user/utils.test.ts similarity index 100% rename from src/Umbraco.Web.UI.Client/src/packages/user/utils.test.ts rename to src/Umbraco.Web.UI.Client/src/packages/user/user/utils.test.ts diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/utils.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user/utils.ts similarity index 95% rename from src/Umbraco.Web.UI.Client/src/packages/user/utils.ts rename to src/Umbraco.Web.UI.Client/src/packages/user/user/utils.ts index 08a526e165..75a22b572b 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/utils.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user/utils.ts @@ -1,7 +1,7 @@ import type { UUIInterfaceColor, UUIInterfaceLook } from '@umbraco-cms/backoffice/external/uui'; import type { UserStateModel } from '@umbraco-cms/backoffice/external/backend-api'; -interface UmbUserDisplayStatus { +export interface UmbUserDisplayStatus { look: UUIInterfaceLook; color: UUIInterfaceColor; key: string; diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user/workspace/components/user-workspace-avatar/user-workspace-avatar.element.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user/workspace/components/user-workspace-avatar/user-workspace-avatar.element.ts new file mode 100644 index 0000000000..c047539e6a --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user/workspace/components/user-workspace-avatar/user-workspace-avatar.element.ts @@ -0,0 +1,170 @@ +import type { UmbUserDetailModel } from '../../../types.js'; +import { UMB_USER_WORKSPACE_CONTEXT } from '../../user-workspace.context.js'; +import { css, html, customElement, query, nothing, ifDefined, state } from '@umbraco-cms/backoffice/external/lit'; +import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; + +@customElement('umb-user-workspace-avatar') +export class UmbUserAvatarElement extends UmbLitElement { + @state() + private _user?: UmbUserDetailModel; + + @state() + private _userAvatarUrls: Array<{ url: string; scale: string }> = []; + + @query('#AvatarFileField') + _avatarFileField?: HTMLInputElement; + + @query('uui-combobox') + private _selectElement!: HTMLInputElement; + + #userWorkspaceContext?: typeof UMB_USER_WORKSPACE_CONTEXT.TYPE; + + constructor() { + super(); + + this.consumeContext(UMB_USER_WORKSPACE_CONTEXT, (instance) => { + this.#userWorkspaceContext = instance; + if (!this.#userWorkspaceContext) return; + this.#observeUser(); + }); + } + + protected getFormElement() { + return this._selectElement; + } + + #observeUser = () => { + this.observe( + this.#userWorkspaceContext!.data, + async (user) => { + this._user = user; + this.#setUserAvatarUrls(user); + }, + 'umbUserObserver', + ); + }; + + #setUserAvatarUrls = async (user: UmbUserDetailModel | undefined) => { + if (!user || !user.avatarUrls || user.avatarUrls.length === 0) { + this._userAvatarUrls = []; + return; + } + + this._userAvatarUrls = [ + { + scale: '1x', + url: user.avatarUrls?.[3], + }, + { + scale: '2x', + url: user.avatarUrls?.[4], + }, + ]; + }; + + #uploadAvatar = async () => { + try { + const selectedFile = await this.#selectAvatar(); + this.#userWorkspaceContext?.uploadAvatar(selectedFile); + } catch (error) { + console.log(error); + } + }; + + #selectAvatar() { + return new Promise((resolve, reject) => { + if (!this._avatarFileField) { + reject("Can't find avatar file field"); + return; + } + + this._avatarFileField.addEventListener('change', (event) => { + const target = event?.target as HTMLInputElement; + const file = target.files?.[0] as File; + if (!file) { + reject("Can't find avatar file"); + return; + } + + resolve(file); + }); + + this._avatarFileField.click(); + }); + } + + #deleteAvatar = async () => { + if (!this.#userWorkspaceContext) return; + const { error } = await this.#userWorkspaceContext.deleteAvatar(); + + if (!error) { + this._userAvatarUrls = []; + } + }; + + #getAvatarSrcset() { + let string = ''; + + this._userAvatarUrls?.forEach((url) => { + string += `${url.url} ${url.scale},`; + }); + return string; + } + + #hasAvatar() { + return this._userAvatarUrls.length > 0; + } + + render() { + return html` + +
    + + + + ${this.#hasAvatar() + ? html` + + ` + : nothing} +
    +
    + `; + } + + static styles = [ + css` + :host { + display: block; + margin-bottom: var(--uui-size-space-4); + } + + #Avatar { + font-size: 75px; + place-self: center; + } + + form { + text-align: center; + display: flex; + flex-direction: column; + gap: var(--uui-size-space-2); + } + `, + ]; +} + +export default UmbUserAvatarElement; + +declare global { + interface HTMLElementTagNameMap { + 'umb-user-workspace-avatar': UmbUserAvatarElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user/workspace/components/user-workspace-info/user-workspace-info.element.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user/workspace/components/user-workspace-info/user-workspace-info.element.ts index a994c84640..161a6a4c29 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/user/workspace/components/user-workspace-info/user-workspace-info.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user/workspace/components/user-workspace-info/user-workspace-info.element.ts @@ -1,35 +1,20 @@ -import { getDisplayStateFromUserStatus } from '../../../../utils.js'; +import type { UmbUserDisplayStatus } from '../../../utils.js'; +import { getDisplayStateFromUserStatus } from '../../../utils.js'; import { UMB_USER_WORKSPACE_CONTEXT } from '../../user-workspace.context.js'; import type { UmbUserDetailModel } from '../../../types.js'; -import { - html, - customElement, - state, - css, - repeat, - ifDefined, - query, - nothing, -} from '@umbraco-cms/backoffice/external/lit'; +import { html, customElement, state, css, repeat, ifDefined, nothing } from '@umbraco-cms/backoffice/external/lit'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; -import { UMB_APP_CONTEXT } from '@umbraco-cms/backoffice/app'; type UmbUserWorkspaceInfoItem = { labelKey: string; value: string | number | undefined }; @customElement('umb-user-workspace-info') export class UmbUserWorkspaceInfoElement extends UmbLitElement { - @state() - private _user?: UmbUserDetailModel; - - @state() - private _userAvatarUrls: Array<{ url: string; scale: string }> = []; - @state() private _userInfo: Array = []; - @query('#AvatarFileField') - _avatarFileField?: HTMLInputElement; + @state() + private _userDisplayState: UmbUserDisplayStatus | null = null; #userWorkspaceContext?: typeof UMB_USER_WORKSPACE_CONTEXT.TYPE; @@ -41,63 +26,15 @@ export class UmbUserWorkspaceInfoElement extends UmbLitElement { this.observe( this.#userWorkspaceContext.data, async (user) => { - this._user = user; - this.#setUserAvatarUrls(user); + if (!user) return; this.#setUserInfoItems(user); + this._userDisplayState = user.state ? getDisplayStateFromUserStatus(user.state) : null; }, 'umbUserObserver', ); }); } - async #getAppContext() { - // TODO: remove this when we get absolute urls from the server - return this.consumeContext(UMB_APP_CONTEXT, (instance) => {}).asPromise(); - } - - // TODO: remove this when we get absolute urls from the server - #setUserAvatarUrls = async (user: UmbUserDetailModel | undefined) => { - if (user?.avatarUrls?.length === 0) return; - - const serverUrl = (await this.#getAppContext()).getServerUrl(); - if (!serverUrl) return; - - this._userAvatarUrls = [ - { - scale: '1x', - url: `${serverUrl}${user?.avatarUrls?.[3]}`, - }, - { - scale: '2x', - url: `${serverUrl}${user?.avatarUrls?.[4]}`, - }, - ]; - }; - - #onAvatarUploadSubmit = (event: SubmitEvent) => { - event.preventDefault(); - - const form = event.target as HTMLFormElement; - if (!form) return; - - if (!form.checkValidity()) return; - - const formData = new FormData(form); - - const avatarFile = formData.get('avatarFile') as File; - - this.#userWorkspaceContext?.uploadAvatar(avatarFile); - }; - - #deleteAvatar = async () => { - if (!this.#userWorkspaceContext) return; - const { error } = await this.#userWorkspaceContext.deleteAvatar(); - - if (!error) { - this._userAvatarUrls = []; - } - }; - #setUserInfoItems = (user: UmbUserDetailModel | undefined) => { if (!user) { this._userInfo = []; @@ -131,67 +68,27 @@ export class UmbUserWorkspaceInfoElement extends UmbLitElement { }; render() { - if (!this._user) return html`User not found`; - - const displayState = this._user.state ? getDisplayStateFromUserStatus(this._user.state) : undefined; + if (this._userInfo.length === 0) return nothing; + return html`${this.#renderState()} ${this.#renderInfoList()} `; + } + #renderState() { return html` - ${this.#renderAvatar()} - - - - - ${repeat( - this._userInfo, - (item) => item.labelKey, - (item) => this.#renderInfoItem(item.labelKey, item.value), - )} - + `; } - #getAvatarSrcset() { - let string = ''; - - this._userAvatarUrls?.forEach((url) => { - string += `${url.url} ${url.scale},`; - }); - return string; - } - - #hasAvatar() { - return this._userAvatarUrls.length > 0; - } - - #renderAvatar() { + #renderInfoList() { return html` - - - + ${repeat( + this._userInfo, + (item) => item.labelKey, + (item) => this.#renderInfoItem(item.labelKey, item.value), + )} `; } @@ -211,27 +108,20 @@ export class UmbUserWorkspaceInfoElement extends UmbLitElement { width: fit-content; } - #Avatar { - font-size: 75px; - place-self: center; - } - #user-info { margin-bottom: var(--uui-size-space-4); } - #user-info > .user-info-item { + #state { + border-bottom: 1px solid var(--uui-color-divider); + padding-bottom: var(--uui-size-space-4); + } + + .user-info-item { display: flex; flex-direction: column; margin-bottom: var(--uui-size-space-3); } - - #user-avatar-settings form { - text-align: center; - display: flex; - flex-direction: column; - gap: var(--uui-size-space-2); - } `, ]; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user/workspace/user-workspace-editor.element.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user/workspace/user-workspace-editor.element.ts index 055020aa15..7445d002ae 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/user/workspace/user-workspace-editor.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user/workspace/user-workspace-editor.element.ts @@ -8,10 +8,11 @@ import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import { UMB_WORKSPACE_CONTEXT } from '@umbraco-cms/backoffice/workspace'; import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; -// Import of local components that should only be used here +// import local components. Theses are not meant to be used outside of this component. import './components/user-workspace-profile-settings/user-workspace-profile-settings.element.js'; import './components/user-workspace-access-settings/user-workspace-access-settings.element.js'; import './components/user-workspace-info/user-workspace-info.element.js'; +import './components/user-workspace-avatar/user-workspace-avatar.element.js'; @customElement('umb-user-workspace-editor') export class UmbUserWorkspaceEditorElement extends UmbLitElement { @@ -83,6 +84,7 @@ export class UmbUserWorkspaceEditorElement extends UmbLitElement { if (!this._user || !this.#workspaceContext) return nothing; return html` + diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user/workspace/user-workspace.context.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user/workspace/user-workspace.context.ts index 1beaf6c5e9..54fdc0e98a 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/user/workspace/user-workspace.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user/workspace/user-workspace.context.ts @@ -7,9 +7,7 @@ import type { UmbSaveableWorkspaceContextInterface } from '@umbraco-cms/backoffi import { UmbEditableWorkspaceContextBase } from '@umbraco-cms/backoffice/workspace'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import { UmbObjectState } from '@umbraco-cms/backoffice/observable-api'; -import { UmbContextConsumerController, UmbContextToken } from '@umbraco-cms/backoffice/context-api'; -import { UMB_CURRENT_USER_CONTEXT } from '@umbraco-cms/backoffice/current-user'; -import { firstValueFrom } from '@umbraco-cms/backoffice/external/rxjs'; +import { UmbContextToken } from '@umbraco-cms/backoffice/context-api'; export class UmbUserWorkspaceContext extends UmbEditableWorkspaceContextBase @@ -18,14 +16,8 @@ export class UmbUserWorkspaceContext public readonly detailRepository: UmbUserDetailRepository = new UmbUserDetailRepository(this); public readonly avatarRepository: UmbUserAvatarRepository = new UmbUserAvatarRepository(this); - #currentUserContext?: typeof UMB_CURRENT_USER_CONTEXT.TYPE; - constructor(host: UmbControllerHost) { super(host, UMB_USER_WORKSPACE_ALIAS); - - new UmbContextConsumerController(host, UMB_CURRENT_USER_CONTEXT, (instance) => { - this.#currentUserContext = instance; - }); } #data = new UmbObjectState(undefined); @@ -48,7 +40,7 @@ export class UmbUserWorkspaceContext */ onUserStoreChanges(user: UmbUserDetailModel | undefined) { if (!user) return; - this.#data.update({ state: user.state }); + this.#data.update({ state: user.state, avatarUrls: user.avatarUrls }); } getEntityId(): string | undefined { @@ -74,36 +66,32 @@ export class UmbUserWorkspaceContext if (!this.#data.value) throw new Error('Data is missing'); if (!this.#data.value.unique) throw new Error('Unique is missing'); + let newData = undefined; + if (this.getIsNew()) { - await this.detailRepository.create(this.#data.value); + const { data } = await this.detailRepository.create(this.#data.value); + newData = data; } else { - await this.detailRepository.save(this.#data.value); + const { data } = await this.detailRepository.save(this.#data.value); + newData = data; } - // If it went well, then its not new anymore?. - this.setIsNew(false); - // If we are saving the current user, we need to refetch it - await this.#reloadCurrentUser(this.#data.value.unique); - } - - async #reloadCurrentUser(savedUserUnique: string): Promise { - if (!this.#currentUserContext) return; - const currentUser = await firstValueFrom(this.#currentUserContext.currentUser); - if (currentUser?.unique === savedUserUnique) { - await this.#currentUserContext.requestCurrentUser(); + if (newData) { + this.#data.setValue(newData); + this.saveComplete(newData); } } // TODO: implement upload progress - async uploadAvatar(file: File) { + uploadAvatar(file: File) { const unique = this.getEntityId(); - if (!unique) throw new Error('Unique is missing'); + if (!unique) throw new Error('Id is missing'); return this.avatarRepository.uploadAvatar(unique, file); } - async deleteAvatar() { + deleteAvatar() { const unique = this.getEntityId(); - if (!unique) throw new Error('Unique is missing'); + if (!unique) throw new Error('Id is missing'); return this.avatarRepository.deleteAvatar(unique); }