diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/info-app/global-components/workspace-info-app-layout.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/info-app/global-components/workspace-info-app-layout.element.ts index 981a93651c..972d45755d 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/info-app/global-components/workspace-info-app-layout.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/info-app/global-components/workspace-info-app-layout.element.ts @@ -22,10 +22,6 @@ export class UmbWorkspaceInfoAppLayoutElement extends UmbLitElement { uui-box { --uui-box-default-padding: 0; } - - #container { - padding-left: var(--uui-size-space-4); - } `, ]; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/document-urls-data-resolver.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/document-urls-data-resolver.ts new file mode 100644 index 0000000000..83ab17f7b3 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/document-urls-data-resolver.ts @@ -0,0 +1,94 @@ +import type { UmbDocumentUrlModel } from './repository/types.js'; +import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { UmbArrayState } from '@umbraco-cms/backoffice/observable-api'; +import { UMB_PROPERTY_DATASET_CONTEXT } from '@umbraco-cms/backoffice/property'; +import type { UmbVariantId } from '@umbraco-cms/backoffice/variant'; + +/** + * A controller for resolving data for document urls + * @exports + * @class UmbDocumentUrlsDataResolver + * @augments {UmbControllerBase} + */ +export class UmbDocumentUrlsDataResolver extends UmbControllerBase { + #appCulture?: string; + #propertyDataSetCulture?: UmbVariantId; + #data?: Array | undefined; + + #init: Promise; + + #urls = new UmbArrayState([], (url) => url.url); + /** + * The urls for the current culture + * @returns {ObservableArray} The urls for the current culture + * @memberof UmbDocumentUrlsDataResolver + */ + public readonly urls = this.#urls.asObservable(); + + constructor(host: UmbControllerHost) { + super(host); + + // TODO: listen for UMB_VARIANT_CONTEXT when available + this.#init = Promise.all([ + this.consumeContext(UMB_PROPERTY_DATASET_CONTEXT, (context) => { + this.#propertyDataSetCulture = context?.getVariantId(); + this.#setCultureAwareValues(); + }).asPromise(), + ]); + } + + /** + * Get the current data + * @returns {Array | undefined} The current data + * @memberof UmbDocumentUrlsDataResolver + */ + getData(): Array | undefined { + return this.#data; + } + + /** + * Set the current data + * @param {Array | undefined} data The current data + * @memberof UmbDocumentUrlsDataResolver + */ + setData(data: Array | undefined) { + this.#data = data; + + if (!this.#data) { + this.#urls.setValue([]); + return; + } + + this.#setCultureAwareValues(); + } + + /** + * Get the urls for the current culture + * @returns {(Promise | []>)} The urls for the current culture + * @memberof UmbDocumentUrlsDataResolver + */ + async getUrls(): Promise | []> { + await this.#init; + return this.#urls.getValue(); + } + + #setCultureAwareValues() { + this.#setUrls(); + } + + #setUrls() { + const data = this.#getDataForCurrentCulture(); + this.#urls.setValue(data ?? []); + } + + #getCurrentCulture(): string | undefined { + return this.#propertyDataSetCulture?.culture || this.#appCulture; + } + + #getDataForCurrentCulture(): Array | undefined { + const culture = this.#getCurrentCulture(); + // If there is no culture context (invariant data) we return all urls + return culture ? this.#data?.filter((x) => x.culture === culture) : this.#data; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/index.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/index.ts index 03eef61de7..0ca6b4e91b 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/index.ts @@ -1,3 +1,4 @@ export { UmbDocumentUrlRepository, UMB_DOCUMENT_URL_REPOSITORY_ALIAS } from './repository/index.js'; export * from './constants.js'; +export * from './document-urls-data-resolver.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/info-app/document-links-workspace-info-app.element.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/info-app/document-links-workspace-info-app.element.ts index f0630d09a7..cab255c185 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/info-app/document-links-workspace-info-app.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/info-app/document-links-workspace-info-app.element.ts @@ -2,7 +2,17 @@ import { UmbDocumentUrlRepository } from '../repository/index.js'; import type { UmbDocumentVariantOptionModel } from '../../types.js'; import { UMB_DOCUMENT_WORKSPACE_CONTEXT } from '../../workspace/constants.js'; import type { UmbDocumentUrlModel } from '../repository/types.js'; -import { css, customElement, html, nothing, repeat, state, when } from '@umbraco-cms/backoffice/external/lit'; +import { UmbDocumentUrlsDataResolver } from '../document-urls-data-resolver.js'; +import { + css, + customElement, + html, + ifDefined, + nothing, + repeat, + state, + when, +} from '@umbraco-cms/backoffice/external/lit'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import type { UmbEntityActionEvent } from '@umbraco-cms/backoffice/entity-action'; import { UmbRequestReloadStructureForEntityEvent } from '@umbraco-cms/backoffice/entity-action'; @@ -10,11 +20,12 @@ import { UMB_ACTION_EVENT_CONTEXT } from '@umbraco-cms/backoffice/action'; import { observeMultiple } from '@umbraco-cms/backoffice/observable-api'; import { DocumentVariantStateModel } from '@umbraco-cms/backoffice/external/backend-api'; import { debounce } from '@umbraco-cms/backoffice/utils'; -import { UMB_APP_LANGUAGE_CONTEXT } from '@umbraco-cms/backoffice/language'; +import { UMB_PROPERTY_DATASET_CONTEXT } from '@umbraco-cms/backoffice/property'; +import type { UmbVariantId } from '@umbraco-cms/backoffice/variant'; interface UmbDocumentInfoViewLink { - culture: string; - url: string | undefined; + culture: string | null; + url: string | null | undefined; state: DocumentVariantStateModel | null | undefined; } @@ -37,13 +48,13 @@ export class UmbDocumentLinksWorkspaceInfoAppElement extends UmbLitElement { @state() private _links: Array = []; - @state() - private _defaultCulture?: string; - #urls: Array = []; #documentWorkspaceContext?: typeof UMB_DOCUMENT_WORKSPACE_CONTEXT.TYPE; #eventContext?: typeof UMB_ACTION_EVENT_CONTEXT.TYPE; + #propertyDataSetVariantId?: UmbVariantId; + + #documentUrlsDataResolver? = new UmbDocumentUrlsDataResolver(this); constructor() { super(); @@ -88,20 +99,26 @@ export class UmbDocumentLinksWorkspaceInfoAppElement extends UmbLitElement { }); }); - this.consumeContext(UMB_APP_LANGUAGE_CONTEXT, (instance) => { - this.observe(instance?.appDefaultLanguage, (value) => { - this._defaultCulture = value?.unique; - this.#setLinks(); - }); + this.consumeContext(UMB_PROPERTY_DATASET_CONTEXT, (context) => { + this.#propertyDataSetVariantId = context?.getVariantId(); + this.#setLinks(); + }); + + this.observe(this.#documentUrlsDataResolver?.urls, (urls) => { + this.#urls = urls ?? []; + this.#setLinks(); }); } #setLinks() { - const links: Array = this.#urls.map((u) => { - const culture = u.culture ?? this._defaultCulture ?? ''; - const url = u.url; + const links: Array = this.#urls.map((url) => { + const culture = url.culture; const state = this._variantOptions?.find((variantOption) => variantOption.culture === culture)?.variant?.state; - return { culture, url, state }; + return { + culture, + url: url.url, + state, + }; }); this._links = links; @@ -124,14 +141,12 @@ export class UmbDocumentLinksWorkspaceInfoAppElement extends UmbLitElement { if (!this._unique) return; this._loading = true; - this.#urls = []; + this.#documentUrlsDataResolver?.setData([]); const { data } = await this.#documentUrlRepository.requestItems([this._unique]); if (data?.length) { - const item = data[0]; - this.#urls = item.urls; - this.#setLinks(); + this.#documentUrlsDataResolver?.setData(data[0].urls); } this._loading = false; @@ -207,7 +222,7 @@ export class UmbDocumentLinksWorkspaceInfoAppElement extends UmbLitElement { } return html` - + ${this.#renderLinkCulture(link.culture)} ${link.url} @@ -218,9 +233,9 @@ export class UmbDocumentLinksWorkspaceInfoAppElement extends UmbLitElement { } #renderNoLinks() { - return html` ${this._variantOptions?.map((variantOption) => - this.#renderEmptyLink(variantOption.culture, variantOption.variant?.state), - )}`; + return html` ${this._variantOptions + ?.filter((variantOption) => variantOption.culture === this.#propertyDataSetVariantId?.culture) + .map((variantOption) => this.#renderEmptyLink(variantOption.culture, variantOption.variant?.state))}`; } #renderEmptyLink(culture: string | null, state: DocumentVariantStateModel | null | undefined) { @@ -235,6 +250,8 @@ export class UmbDocumentLinksWorkspaceInfoAppElement extends UmbLitElement { #renderLinkCulture(culture: string | null) { if (!culture) return nothing; if (this._links.length === 1) return nothing; + const allLinksHaveSameCulture = this._links?.every((link) => link.culture === culture); + if (allLinksHaveSameCulture) return nothing; return html`${culture}`; } @@ -249,10 +266,6 @@ export class UmbDocumentLinksWorkspaceInfoAppElement extends UmbLitElement { static override styles = [ css` - uui-box { - --uui-box-default-padding: 0; - } - #loader-container { display: flex; justify-content: center; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/repository/types.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/repository/types.ts index 7291179f31..78089e5118 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/repository/types.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/repository/types.ts @@ -4,6 +4,6 @@ export interface UmbDocumentUrlsModel { } export interface UmbDocumentUrlModel { - culture?: string | null; + culture: string | null; url?: string; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/multi-url-picker/components/input-multi-url/input-multi-url.element.ts b/src/Umbraco.Web.UI.Client/src/packages/multi-url-picker/components/input-multi-url/input-multi-url.element.ts index 69171ad191..f7e23b6231 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/multi-url-picker/components/input-multi-url/input-multi-url.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/multi-url-picker/components/input-multi-url/input-multi-url.element.ts @@ -21,6 +21,8 @@ import { UUIFormControlMixin } from '@umbraco-cms/backoffice/external/uui'; import type { UmbModalRouteBuilder } from '@umbraco-cms/backoffice/router'; import type { UmbVariantId } from '@umbraco-cms/backoffice/variant'; import type { UUIModalSidebarSize } from '@umbraco-cms/backoffice/external/uui'; +import { UmbDocumentUrlRepository, UmbDocumentUrlsDataResolver } from '@umbraco-cms/backoffice/document'; +import { UmbMediaUrlRepository } from '@umbraco-cms/backoffice/media'; /** * @element umb-input-multi-url @@ -129,6 +131,7 @@ export class UmbInputMultiUrlElement extends UUIFormControlMixin(UmbLitElement, this.#urls = [...data]; // Unfreeze data coming from State, so we can manipulate it. super.value = this.#urls.map((x) => x.url).join(','); this.#sorter.setModel(this.#urls); + this.#populateLinksUrl(); } get urls(): Array { return this.#urls; @@ -160,6 +163,9 @@ export class UmbInputMultiUrlElement extends UUIFormControlMixin(UmbLitElement, @state() private _modalRoute?: UmbModalRouteBuilder; + @state() + _resolvedLinkUrls: Array<{ unique: string; url: string }> = []; + #linkPickerModal; constructor() { @@ -229,6 +235,49 @@ export class UmbInputMultiUrlElement extends UUIFormControlMixin(UmbLitElement, }); } + #populateLinksUrl() { + // Documents and media have URLs saved in the local link format. Display the actual URL to align with what + // the user sees when they selected it initially. + this.#urls.forEach(async (link) => { + if (!link.unique) return; + + let url: string | undefined = undefined; + switch (link.type) { + case 'document': { + url = await this.#getUrlForDocument(link.unique); + break; + } + case 'media': { + url = await this.#getUrlForMedia(link.unique); + break; + } + default: + break; + } + + if (url) { + const resolvedUrl = { unique: link.unique, url }; + this._resolvedLinkUrls = [...this._resolvedLinkUrls, resolvedUrl]; + } + }); + } + + async #getUrlForDocument(unique: string) { + const documentUrlRepository = new UmbDocumentUrlRepository(this); + const { data: documentUrlData } = await documentUrlRepository.requestItems([unique]); + const urlsItem = documentUrlData?.[0]; + const dataResolver = new UmbDocumentUrlsDataResolver(this); + dataResolver.setData(urlsItem?.urls); + const resolvedUrls = await dataResolver.getUrls(); + return resolvedUrls?.[0]?.url ?? ''; + } + + async #getUrlForMedia(unique: string) { + const mediaUrlRepository = new UmbMediaUrlRepository(this); + const { data: mediaUrlData } = await mediaUrlRepository.requestItems([unique]); + return mediaUrlData?.[0].url ?? ''; + } + async #requestRemoveItem(index: number) { const item = this.#urls[index]; if (!item) throw new Error('Could not find item at index: ' + index); @@ -307,12 +356,13 @@ export class UmbInputMultiUrlElement extends UUIFormControlMixin(UmbLitElement, #renderItem(link: UmbLinkPickerLink, index: number) { const unique = this.#getUnique(link); const href = this.readonly ? undefined : (this._modalRoute?.({ index }) ?? undefined); + const resolvedUrl = this._resolvedLinkUrls.find((url) => url.unique === link.unique)?.url ?? ''; return html` ${when( diff --git a/src/Umbraco.Web.UI.Client/src/packages/multi-url-picker/link-picker-modal/link-picker-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/multi-url-picker/link-picker-modal/link-picker-modal.element.ts index a2a4e46d0f..f8cdb79671 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/multi-url-picker/link-picker-modal/link-picker-modal.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/multi-url-picker/link-picker-modal/link-picker-modal.element.ts @@ -6,13 +6,83 @@ import type { } from './link-picker-modal.token.js'; import { css, customElement, html, nothing, query, state, when } from '@umbraco-cms/backoffice/external/lit'; import { isUmbracoFolder, UmbMediaTypeStructureRepository } from '@umbraco-cms/backoffice/media-type'; -import { umbBindToValidation, UmbValidationContext } from '@umbraco-cms/backoffice/validation'; +import { + UMB_VALIDATION_CONTEXT, + umbBindToValidation, + UmbObserveValidationStateController, + UmbValidationContext, + type UmbValidator, +} from '@umbraco-cms/backoffice/validation'; import { umbConfirmModal, UmbModalBaseElement } from '@umbraco-cms/backoffice/modal'; -import { UmbDocumentDetailRepository, UmbDocumentUrlRepository } from '@umbraco-cms/backoffice/document'; -import { UmbMediaDetailRepository, UmbMediaUrlRepository } from '@umbraco-cms/backoffice/media'; +import { + UmbDocumentItemDataResolver, + UmbDocumentItemRepository, + UmbDocumentUrlRepository, + UmbDocumentUrlsDataResolver, +} from '@umbraco-cms/backoffice/document'; +import { UmbMediaItemRepository, UmbMediaUrlRepository } from '@umbraco-cms/backoffice/media'; import type { UmbInputDocumentElement } from '@umbraco-cms/backoffice/document'; import type { UmbInputMediaElement } from '@umbraco-cms/backoffice/media'; -import type { UUIBooleanInputEvent, UUIInputElement, UUIInputEvent } from '@umbraco-cms/backoffice/external/uui'; +import type { UUIBooleanInputEvent, UUIInputEvent } from '@umbraco-cms/backoffice/external/uui'; +import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { umbFocus } from '@umbraco-cms/backoffice/lit-element'; + +class UmbLinkPickerValueValidator extends UmbControllerBase implements UmbValidator { + #context?: typeof UMB_VALIDATION_CONTEXT.TYPE; + + #isValid = true; + get isValid(): boolean { + return this.#isValid; + } + + #value: unknown; + + #unique = 'UmbLinkPickerValueValidator'; + + setValue(value: unknown) { + this.#value = value; + this.validate(); + } + + getValue(): unknown { + return this.#value; + } + + // The path to the data that this validator is validating. + readonly #dataPath: string; + + constructor(host: UmbControllerHost, dataPath: string) { + super(host); + this.#dataPath = dataPath; + this.consumeContext(UMB_VALIDATION_CONTEXT, (context) => { + if (this.#context) { + this.#context.removeValidator(this); + } + this.#context = context; + context?.addValidator(this); + }); + } + + async validate(): Promise { + this.#isValid = !!this.getValue(); + + if (this.#isValid) { + this.#context?.messages.removeMessageByKey(this.#unique); + } else { + this.#context?.messages.addMessage( + 'client', + this.#dataPath, + '#linkPicker_modalAnchorValidationMessage', + this.#unique, + ); + } + } + + reset(): void {} + + focusFirstInvalidElement(): void {} +} type UmbInputPickerEvent = CustomEvent & { target: { value?: string } }; @@ -31,14 +101,22 @@ export class UmbLinkPickerModalElement extends UmbModalBaseElement { + this._missingLinkUrl = invalid; + }); + } override connectedCallback() { super.connectedCallback(); @@ -57,14 +135,12 @@ export class UmbLinkPickerModalElement extends UmbModalBaseElement this.localize.term('linkPicker_modalAnchorValidationMessage'), - () => !this.value.link.name && !this.value.link.queryString, - ); + const validator = new UmbLinkPickerValueValidator(this, '$.type'); + + this.observe(this.modalContext?.value, (value) => { + validator.setValue(value?.link.type); + }); } async #getMediaTypes() { @@ -78,7 +154,7 @@ export class UmbLinkPickerModalElement extends UmbModalBaseElement 0 ? (documentUrlData?.[0].urls[0].url ?? '') : ''; + const urlsItem = documentUrlData?.[0]; + const dataResolver = new UmbDocumentUrlsDataResolver(this); + dataResolver.setData(urlsItem?.urls); + const resolvedUrls = await dataResolver.getUrls(); + return resolvedUrls?.[0]?.url ?? ''; } async #getUrlForMedia(unique: string) { @@ -215,6 +302,10 @@ export class UmbLinkPickerModalElement extends UmbModalBaseElement ${this.#renderLinkType()} ${this.#renderLinkAnchorInput()} ${this.#renderLinkTitleInput()} @@ -250,8 +341,7 @@ export class UmbLinkPickerModalElement extends UmbModalBaseElement @@ -263,7 +353,8 @@ export class UmbLinkPickerModalElement extends UmbModalBaseElement + mandatory + ?invalid=${this._missingLinkUrl}>
${this.#renderLinkTypeSelection()} ${this.#renderDocumentPicker()} ${this.#renderMediaPicker()} ${this.#renderLinkUrlInput()} ${this.#renderLinkUrlInputReadOnly()} @@ -326,9 +417,10 @@ export class UmbLinkPickerModalElement extends UmbModalBaseElement + required + @input=${this.#onLinkUrlInput} + ${umbBindToValidation(this)} + ${umbFocus()}> ${when( !this.value.link.unique, () => html` @@ -361,12 +453,10 @@ export class UmbLinkPickerModalElement extends UmbModalBaseElement + @change=${this.#onLinkAnchorInput}> `; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/relations/relations/reference/workspace-info-app/entity-references-workspace-view-info.element.ts b/src/Umbraco.Web.UI.Client/src/packages/relations/relations/reference/workspace-info-app/entity-references-workspace-view-info.element.ts index 89a1bbaf4d..1fd0aedcba 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/relations/relations/reference/workspace-info-app/entity-references-workspace-view-info.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/relations/relations/reference/workspace-info-app/entity-references-workspace-view-info.element.ts @@ -106,7 +106,7 @@ export class UmbEntityReferencesWorkspaceInfoAppElement extends UmbLitElement { if (!this._items?.length) return nothing; return html` - ${this.#renderItems()} ${this.#renderReferencePagination()} +
${this.#renderItems()} ${this.#renderReferencePagination()}
`; } @@ -144,6 +144,11 @@ export class UmbEntityReferencesWorkspaceInfoAppElement extends UmbLitElement { display: contents; } + #content { + display: block; + padding: var(--uui-size-space-3) var(--uui-size-space-4); + } + .pagination-container { display: flex; justify-content: center;