diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/DocumentUrlController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/DocumentUrlController.cs index 974f93373bb7..831086e2017c 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Document/DocumentUrlController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/DocumentUrlController.cs @@ -37,15 +37,26 @@ public DocumentUrlController( /// /// A set of document IDs for which to retrieve URLs. /// A task representing the asynchronous operation. The task result contains an with a collection of URL information for each requested document. + [Obsolete("Please use the overload taking all parameters. Scheduled for removal in Umbraco 19.")] + [ApiExplorerSettings(IgnoreApi = true)] + public Task GetUrls([FromQuery(Name = "id")] HashSet ids) + => GetUrls(ids, null); + + /// + /// Retrieves the URLs for the documents identified by the specified set of IDs. + /// + /// A set of document IDs for which to retrieve URLs. + /// An optional culture to restrict variant document URLs to. When omitted, URLs for all cultures are returned. + /// A task representing the asynchronous operation. The task result contains an with a collection of URL information for each requested document. [MapToApiVersion("1.0")] [HttpGet("urls")] [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] [EndpointSummary("Gets URLs for a document.")] [EndpointDescription("Gets the URLs for the document identified by the provided Id.")] - public async Task GetUrls([FromQuery(Name = "id")] HashSet ids) + public async Task GetUrls([FromQuery(Name = "id")] HashSet ids, [FromQuery] string? culture) { IEnumerable items = _contentService.GetByIds(ids); - return Ok(await _documentUrlFactory.CreateUrlSetsAsync(items)); + return Ok(await _documentUrlFactory.CreateUrlSetsAsync(items, culture)); } } diff --git a/src/Umbraco.Cms.Api.Management/Factories/DocumentUrlFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/DocumentUrlFactory.cs index d053e11e68b4..67d896d3d8c1 100644 --- a/src/Umbraco.Cms.Api.Management/Factories/DocumentUrlFactory.cs +++ b/src/Umbraco.Cms.Api.Management/Factories/DocumentUrlFactory.cs @@ -48,9 +48,18 @@ public DocumentUrlFactory( /// /// The content item for which to generate URLs. /// A task representing the asynchronous operation. The task result contains an with the generated URLs. - public async Task> CreateUrlsAsync(IContent content) + public Task> CreateUrlsAsync(IContent content) + => CreateUrlsAsync(content, culture: null); + + /// + /// Asynchronously generates a collection of instances representing the URLs for the specified content item, optionally restricted to a single culture. + /// + /// The content item for which to generate URLs. + /// The culture to restrict variant content urls to, or null for all cultures. + /// A task representing the asynchronous operation. The task result contains an with the generated URLs. + public async Task> CreateUrlsAsync(IContent content, string? culture) { - ISet urlInfos = await _publishedUrlInfoProvider.GetAllAsync(content); + ISet urlInfos = await _publishedUrlInfoProvider.GetAllAsync(content, culture); return urlInfos .Select(CreateDocumentUrlInfo) .ToArray(); @@ -61,13 +70,22 @@ public async Task> CreateUrlsAsync(IContent content /// /// The collection of items for which to generate URL sets. /// A task representing the asynchronous operation. The task result contains a collection of objects, each representing the URL set for a content item. - public async Task> CreateUrlSetsAsync(IEnumerable contentItems) + public Task> CreateUrlSetsAsync(IEnumerable contentItems) + => CreateUrlSetsAsync(contentItems, culture: null); + + /// + /// Asynchronously creates URL sets for the specified collection of content items, optionally restricted to a single culture. + /// + /// The collection of items for which to generate URL sets. + /// The culture to restrict variant content urls to, or null for all cultures. + /// A task representing the asynchronous operation. The task result contains a collection of objects, each representing the URL set for a content item. + public async Task> CreateUrlSetsAsync(IEnumerable contentItems, string? culture) { var documentUrlInfoResourceSets = new List(); foreach (IContent content in contentItems) { - IEnumerable urls = await CreateUrlsAsync(content); + IEnumerable urls = await CreateUrlsAsync(content, culture); documentUrlInfoResourceSets.Add(new DocumentUrlInfoResponseModel(content.Key, urls)); } diff --git a/src/Umbraco.Cms.Api.Management/Factories/IDocumentUrlFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/IDocumentUrlFactory.cs index 4e2fea671755..e4881b7bee34 100644 --- a/src/Umbraco.Cms.Api.Management/Factories/IDocumentUrlFactory.cs +++ b/src/Umbraco.Cms.Api.Management/Factories/IDocumentUrlFactory.cs @@ -15,6 +15,16 @@ public interface IDocumentUrlFactory /// A task that represents the asynchronous operation. The task result contains a collection of DocumentUrlInfo. Task> CreateUrlsAsync(IContent content); + /// + /// Creates URLs asynchronously for the given content, optionally restricted to a single culture. + /// + /// The content to create URLs for. + /// The culture to restrict variant content urls to, or null for all cultures. + /// A task that represents the asynchronous operation. The task result contains a collection of DocumentUrlInfo. + // TODO (V19): Remove the default implementation. + Task> CreateUrlsAsync(IContent content, string? culture) + => CreateUrlsAsync(content); + /// /// Asynchronously creates URL sets for the specified content items. /// @@ -22,6 +32,16 @@ public interface IDocumentUrlFactory /// A task representing the asynchronous operation, with a result containing an enumerable of representing the URL sets. Task> CreateUrlSetsAsync(IEnumerable contentItems); + /// + /// Asynchronously creates URL sets for the specified content items, optionally restricted to a single culture. + /// + /// The content items for which to generate URL sets. + /// The culture to restrict variant content urls to, or null for all cultures. + /// A task representing the asynchronous operation, with a result containing an enumerable of representing the URL sets. + // TODO (V19): Remove the default implementation. + Task> CreateUrlSetsAsync(IEnumerable contentItems, string? culture) + => CreateUrlSetsAsync(contentItems); + /// /// Asynchronously retrieves preview URL information for the specified content item. /// diff --git a/src/Umbraco.Cms.Api.Management/OpenApi.json b/src/Umbraco.Cms.Api.Management/OpenApi.json index 933db9690eee..53b6867f4692 100644 --- a/src/Umbraco.Cms.Api.Management/OpenApi.json +++ b/src/Umbraco.Cms.Api.Management/OpenApi.json @@ -11978,6 +11978,13 @@ "format": "uuid" } } + }, + { + "name": "culture", + "in": "query", + "schema": { + "type": "string" + } } ], "responses": { diff --git a/src/Umbraco.Core/Routing/IPublishedUrlInfoProvider.cs b/src/Umbraco.Core/Routing/IPublishedUrlInfoProvider.cs index 7a62697aa79b..44cfc81ac2b7 100644 --- a/src/Umbraco.Core/Routing/IPublishedUrlInfoProvider.cs +++ b/src/Umbraco.Core/Routing/IPublishedUrlInfoProvider.cs @@ -13,4 +13,17 @@ public interface IPublishedUrlInfoProvider /// The content to get urls for. /// Set of all published url infos. Task> GetAllAsync(IContent content); + + /// + /// Gets the published urls for a content item, optionally restricted to a single culture. + /// + /// The content to get urls for. + /// + /// The culture to restrict variant content urls to, or null to return urls for all cultures. + /// Ignored for invariant content, which always returns all of its domain urls. + /// + /// Set of published url infos. + // TODO (V19): Remove the default implementation. + Task> GetAllAsync(IContent content, string? culture) + => GetAllAsync(content); } diff --git a/src/Umbraco.Core/Routing/PublishedUrlInfoProvider.cs b/src/Umbraco.Core/Routing/PublishedUrlInfoProvider.cs index 6795538d5446..bff3ae9b01a4 100644 --- a/src/Umbraco.Core/Routing/PublishedUrlInfoProvider.cs +++ b/src/Umbraco.Core/Routing/PublishedUrlInfoProvider.cs @@ -56,41 +56,49 @@ public PublishedUrlInfoProvider( } /// - public async Task> GetAllAsync(IContent content) + public Task> GetAllAsync(IContent content) + => GetAllAsync(content, culture: null); + + /// + public async Task> GetAllAsync(IContent content, string? culture) { - HashSet urlInfos = []; var isInvariant = !content.ContentType.VariesByCulture(); - IEnumerable cultures = await GetCulturesForUrlLookupAsync(content); - - foreach (var culture in cultures) + // Variant content restricted to a single culture, matched against the installed cultures (using their casing). + if (isInvariant is false && culture is not null) { - var url = _publishedUrlProvider.GetUrl(content.Key, culture: culture); + var matchedCulture = (await _languageService.GetAllIsoCodesAsync()) + .FirstOrDefault(x => x.InvariantEquals(culture)); - // Handle "could not get URL" - if (url is Constants.Routing.Unroutable or Constants.Routing.UrlProviderException) - { - // For invariant content, a missing URL just means there's no domain - // for this culture — not a problem worth reporting. - if (isInvariant) - { - continue; - } - - urlInfos.Add(UrlInfo.AsMessage(_localizedTextService.Localize("content", "getUrlException"), UrlProviderAlias, culture)); - continue; - } + // A specific culture was requested that is not an installed culture - there are no urls to report. + return matchedCulture is null + ? new HashSet() + : await BuildUrlInfosAsync(content, [matchedCulture], scopedCulture: matchedCulture, isInvariant); + } - // Check for collision - Attempt hasCollision = await VerifyCollisionAsync(content, url, culture); + // Invariant content (culture ignored), or all cultures. + IReadOnlyCollection cultures = (await GetCulturesForUrlLookupAsync(content)).ToArray(); + return await BuildUrlInfosAsync(content, cultures, scopedCulture: null, isInvariant); + } - if (hasCollision is { Success: true, Result: not null }) + /// + /// Builds the set of for the given cultures, plus the "other" URLs (unless the content is + /// trashed). When is set, the "other" URLs are filtered to that culture. + /// + private async Task> BuildUrlInfosAsync( + IContent content, + IReadOnlyCollection cultures, + string? scopedCulture, + bool isInvariant) + { + var urlInfos = new HashSet(); + foreach (var contentCulture in cultures) + { + UrlInfo? urlInfo = await GetCultureUrlInfoAsync(content, contentCulture, isInvariant); + if (urlInfo is not null) { - urlInfos.Add(hasCollision.Result); - continue; + urlInfos.Add(urlInfo); } - - urlInfos.Add(UrlInfo.AsUrl(url, UrlProviderAlias, culture)); } // If the content is trashed, we can't get the other URLs, as we have no parent structure to navigate through. @@ -99,9 +107,7 @@ public async Task> GetAllAsync(IContent content) return urlInfos; } - // Then get "other" urls - I.E. Not what you'd get with GetUrl(), this includes all the urls registered using domains. - // for these 'other' URLs, we don't check whether they are routable, collide, anything - we just report them. - foreach (UrlInfo otherUrl in _publishedUrlProvider.GetOtherUrls(content.Id).OrderBy(x => x.Message).ThenBy(x => x.Culture)) + foreach (UrlInfo otherUrl in GetOtherUrls(content, scopedCulture)) { urlInfos.Add(otherUrl); } @@ -109,6 +115,39 @@ public async Task> GetAllAsync(IContent content) return urlInfos; } + /// + /// Gets the for a single culture, or null if there is nothing to report + /// (an unroutable URL on invariant content). Reports a message for an unroutable URL or a collision. + /// + private async Task GetCultureUrlInfoAsync(IContent content, string culture, bool isInvariant) + { + var url = _publishedUrlProvider.GetUrl(content.Key, culture: culture); + + if (url is Constants.Routing.Unroutable or Constants.Routing.UrlProviderException) + { + // For invariant content, a missing URL just means there's no domain for this culture - not worth reporting. + return isInvariant + ? null + : UrlInfo.AsMessage(_localizedTextService.Localize("content", "getUrlException"), UrlProviderAlias, culture); + } + + Attempt hasCollision = await VerifyCollisionAsync(content, url, culture); + return hasCollision is { Success: true, Result: not null } + ? hasCollision.Result + : UrlInfo.AsUrl(url, UrlProviderAlias, culture); + } + + /// + /// Gets the "other" URLs - i.e. not what you'd get with GetUrl(), including all the URLs registered using domains. + /// These are not checked for routability or collisions - they are just reported. When scoped to a single culture, + /// only the other URLs for that culture are returned. + /// + private IEnumerable GetOtherUrls(IContent content, string? scopedCulture) + => _publishedUrlProvider.GetOtherUrls(content.Id) + .Where(x => scopedCulture is null || string.Equals(x.Culture, scopedCulture, StringComparison.OrdinalIgnoreCase)) + .OrderBy(x => x.Message) + .ThenBy(x => x.Culture); + /// /// Gets the cultures to query URLs for. /// For invariant content, returns only cultures that have a domain assigned to the content diff --git a/src/Umbraco.Web.UI.Client/mocks/msw-handlers/document/url.handlers.ts b/src/Umbraco.Web.UI.Client/mocks/msw-handlers/document/url.handlers.ts index ea3e91775aec..26b3378ca840 100644 --- a/src/Umbraco.Web.UI.Client/mocks/msw-handlers/document/url.handlers.ts +++ b/src/Umbraco.Web.UI.Client/mocks/msw-handlers/document/url.handlers.ts @@ -10,13 +10,20 @@ export const urlHandlers = [ const ids = url.searchParams.getAll('id'); if (!ids.length) return new HttpResponse(null, { status: 400 }); + // When a culture is provided, variant URLs are restricted to that culture (invariant URLs, which have + // no culture, are always kept). This mirrors the Management API's document URLs endpoint. + const culture = url.searchParams.get('culture'); + const response: GetDocumentUrlsResponse = ids.map((id) => ({ id, - urlInfos: umbDocumentMockDb.url.getUrls(id).map((urlInfo) => ({ - ...urlInfo, - message: null, - provider: 'Default', - })), + urlInfos: umbDocumentMockDb.url + .getUrls(id) + .filter((urlInfo) => !culture || urlInfo.culture === null || urlInfo.culture === culture) + .map((urlInfo) => ({ + ...urlInfo, + message: null, + provider: 'Default', + })), })); return HttpResponse.json(response); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/backend-api/types.gen.ts b/src/Umbraco.Web.UI.Client/src/packages/core/backend-api/types.gen.ts index bfd94ec7838d..0ede83735d5b 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/backend-api/types.gen.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/backend-api/types.gen.ts @@ -7635,6 +7635,7 @@ export type GetDocumentUrlsData = { path?: never; query?: { id?: Array; + culture?: string; }; url: '/umbraco/management/api/v1/document/urls'; }; 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 index 9f6bec2980dc..200a2bac9064 100644 --- 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 @@ -1,7 +1,7 @@ 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 { UmbArrayState, UmbStringState } from '@umbraco-cms/backoffice/observable-api'; import { UMB_VARIANT_CONTEXT, type UmbVariantId } from '@umbraco-cms/backoffice/variant'; /** @@ -11,14 +11,13 @@ import { UMB_VARIANT_CONTEXT, type UmbVariantId } from '@umbraco-cms/backoffice/ * @augments {UmbControllerBase} */ export class UmbDocumentUrlsDataResolver extends UmbControllerBase { - #appCulture?: string; #variantId?: UmbVariantId; #displayVariantId?: UmbVariantId; #data?: Array | undefined; - #init: Promise; + readonly #init: Promise; - #urls = new UmbArrayState([], (url) => url.url); + readonly #urls = new UmbArrayState([], (url) => url.url); /** * The urls for the current culture * @returns {ObservableArray} The urls for the current culture @@ -26,13 +25,35 @@ export class UmbDocumentUrlsDataResolver extends UmbControllerBase { */ public readonly urls = this.#urls.asObservable(); + readonly #requestCulture = new UmbStringState(undefined); + /** + * The culture to request urls for from the server. Emits whenever the displayed culture changes. + * Resolves to undefined for invariant documents (meaning all cultures). + * @returns {Observable} The culture to request urls for + * @memberof UmbDocumentUrlsDataResolver + */ + public readonly requestCulture = this.#requestCulture.asObservable(); + constructor(host: UmbControllerHost) { super(host); this.#init = Promise.all([ this.consumeContext(UMB_VARIANT_CONTEXT, async (context) => { this.#variantId = await context?.getVariantId(); - this.#displayVariantId = await this.observe(context?.displayVariantId)?.asPromise(); + + // Observe the display variant id so the resolver reacts to culture switches, rather than + // capturing the culture only once. + if (context) { + this.observe( + context.displayVariantId, + (displayVariantId) => { + this.#displayVariantId = displayVariantId; + this.#setCultureAwareValues(); + }, + 'observeDisplayVariantId', + ); + } + this.#setCultureAwareValues(); }).asPromise(), ]); @@ -73,8 +94,24 @@ export class UmbDocumentUrlsDataResolver extends UmbControllerBase { return this.#urls.getValue(); } + /** + * Gets the culture to request urls for from the server. + * Returns the current culture for variant documents, or undefined for invariant documents + * (which must return all of their domain urls). + * @returns {Promise} The culture to request, or undefined for all cultures + * @memberof UmbDocumentUrlsDataResolver + */ + async getRequestCulture(): Promise { + await this.#init; + return this.#requestCulture.getValue(); + } + #setCultureAwareValues() { this.#setUrls(); + + // TODO: when the variant context gains a dedicated "request/effective culture" accessor, resolve the + // invariant->all-cultures decision through it instead of here. + this.#requestCulture.setValue(this.#variantId?.isCultureInvariant() ? undefined : this.#getCurrentCulture()); } #setUrls() { @@ -83,7 +120,9 @@ export class UmbDocumentUrlsDataResolver extends UmbControllerBase { } #getCurrentCulture(): string | undefined { - return this.#variantId?.culture || this.#displayVariantId?.culture || this.#appCulture; + // Culture resolution (incl. inheritance/fallback) is owned by the variant context; we read its + // already-resolved display variant rather than re-deriving it here. + return this.#displayVariantId?.culture ?? undefined; } #getDataForCurrentCulture(): Array | undefined { 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 5b3c61c1b69c..7dfc480cbad3 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 @@ -84,7 +84,7 @@ export class UmbDocumentLinksWorkspaceInfoAppElement extends UmbLitElement { if (unique !== this._unique) { this._unique = unique; - this.#requestUrls(); + this.#scheduleRequestUrls(); } }, 'observeWorkspaceState', @@ -99,6 +99,13 @@ export class UmbDocumentLinksWorkspaceInfoAppElement extends UmbLitElement { }); }); + // Re-request when the displayed culture changes, so switching language fetches that culture. + this.observe( + this.#documentUrlsDataResolver?.requestCulture, + () => this.#scheduleRequestUrls(), + 'observeRequestCulture', + ); + this.consumeContext(UMB_PROPERTY_DATASET_CONTEXT, (context) => { this.#propertyDataSetVariantId = context?.getVariantId(); this.#setLinks(); @@ -136,20 +143,33 @@ export class UmbDocumentLinksWorkspaceInfoAppElement extends UmbLitElement { return url; } + // Show the loading indicator immediately (synchronously), before the debounced request runs, so no + // stale "no URL" message is shown while the URL is (re)resolved - e.g. on load or when switching culture. + #scheduleRequestUrls() { + if (this._isNew || !this._unique) return; + + this._loading = true; + this.#debounceRequestUrls(); + } + async #requestUrls() { - if (this._isNew) return; - if (!this._unique) return; + if (this._isNew || !this._unique) return; this._loading = true; this.#documentUrlsDataResolver?.setData([]); - const { data } = await this.#documentUrlRepository.requestItems([this._unique]); + try { + // Only request the culture currently being displayed for variant documents. Invariant documents + // return all of their domain urls, so no culture is passed (getRequestCulture resolves undefined). + const culture = await this.#documentUrlsDataResolver?.getRequestCulture(); + const { data } = await this.#documentUrlRepository.requestUrls([this._unique], culture); - if (data?.length) { - this.#documentUrlsDataResolver?.setData(data[0].urls); + if (data?.length) { + this.#documentUrlsDataResolver?.setData(data[0].urls); + } + } finally { + this._loading = false; } - - this._loading = false; } #getStateLocalizationKey(state: UmbDocumentVariantState | null | undefined): string { @@ -173,7 +193,7 @@ export class UmbDocumentLinksWorkspaceInfoAppElement extends UmbLitElement { // TODO: Introduce "Published Event". We only need to update the url when the document is published. if (event.getUnique() !== this.#documentWorkspaceContext?.getUnique()) return; if (event.getEntityType() !== this.#documentWorkspaceContext.getEntityType()) return; - this.#debounceRequestUrls(); + this.#scheduleRequestUrls(); }; override render() { diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/repository/document-url.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/repository/document-url.repository.ts index 6b65fe354598..54e20d0b9500 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/repository/document-url.repository.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/repository/document-url.repository.ts @@ -5,9 +5,36 @@ import { UmbItemRepositoryBase } from '@umbraco-cms/backoffice/repository'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; export class UmbDocumentUrlRepository extends UmbItemRepositoryBase { + // The culture-aware request needs its own data source instance: `UmbItemRepositoryBase` keeps its + // data source private and the inherited `requestItems` signature cannot carry a culture. Extending + // the item base keeps this consistent with `UmbMediaUrlRepository`; `requestUrls` otherwise mirrors + // the base flow, including store population. + readonly #urlSource = new UmbDocumentUrlServerDataSource(this); + constructor(host: UmbControllerHost) { super(host, UmbDocumentUrlServerDataSource, UMB_DOCUMENT_URL_STORE_CONTEXT); } + + /** + * Requests the urls for the given uniques, optionally restricted to a single culture. + * @param {Array} uniques The document uniques to request urls for. + * @param {string} [culture] The culture to restrict variant document urls to, or undefined for all cultures. + * @returns {*} The requested url data. + * @memberof UmbDocumentUrlRepository + */ + async requestUrls(uniques: Array, culture?: string) { + if (!uniques) throw new Error('Uniques are missing'); + + await this._init; + + const { data, error } = await this.#urlSource.getItems(uniques, culture); + + if (data) { + this._itemStore?.appendItems(data); + } + + return { data, error, asObservable: () => this._itemStore?.items(uniques) }; + } } export { UmbDocumentUrlRepository as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/repository/document-url.server.data-source.test.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/repository/document-url.server.data-source.test.ts new file mode 100644 index 000000000000..dc092a3d6a83 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/repository/document-url.server.data-source.test.ts @@ -0,0 +1,46 @@ +import { UmbDocumentUrlServerDataSource } from './document-url.server.data-source.js'; +import { expect } from '@open-wc/testing'; +import { customElement } from '@umbraco-cms/backoffice/external/lit'; +import { UmbControllerHostElementMixin } from '@umbraco-cms/backoffice/controller-api'; +import { useMockSet } from '@umbraco-cms/internal/mock-manager'; + +@customElement('test-document-url-data-source-host') +class UmbTestDocumentUrlDataSourceHostElement extends UmbControllerHostElementMixin(HTMLElement) {} + +// A variant document from the 'documents' mock set with URLs in two cultures ('en-US' and 'da'). +const VARIANT_DOCUMENT_ID = 'variant-documents-variant-document-id'; + +describe('UmbDocumentUrlServerDataSource', () => { + let hostElement: UmbTestDocumentUrlDataSourceHostElement; + let dataSource: UmbDocumentUrlServerDataSource; + + before(async () => { + await useMockSet('documents'); + }); + + beforeEach(() => { + hostElement = new UmbTestDocumentUrlDataSourceHostElement(); + document.body.appendChild(hostElement); + dataSource = new UmbDocumentUrlServerDataSource(hostElement); + }); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('requests only the given culture when one is provided', async () => { + const { data, error } = await dataSource.getItems([VARIANT_DOCUMENT_ID], 'da'); + + expect(error).to.be.undefined; + expect(data?.[0].unique).to.equal(VARIANT_DOCUMENT_ID); + expect(data?.[0].urls.map((url) => url.culture)).to.eql(['da']); + }); + + it('omits the culture when none is provided, so all cultures are returned', async () => { + const { data, error } = await dataSource.getItems([VARIANT_DOCUMENT_ID]); + + expect(error).to.be.undefined; + expect(data?.[0].unique).to.equal(VARIANT_DOCUMENT_ID); + expect(data?.[0].urls.map((url) => url.culture)).to.have.members(['en-US', 'da']); + }); +}); diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/repository/document-url.server.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/repository/document-url.server.data-source.ts index 89df34cac81c..62eb65960d92 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/repository/document-url.server.data-source.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/repository/document-url.server.data-source.ts @@ -23,12 +23,12 @@ export class UmbDocumentUrlServerDataSource extends UmbItemServerDataSourceBase< super(host, { mapper }); } - override async getItems(uniques: Array) { + override async getItems(uniques: Array, culture?: string) { if (!uniques) throw new Error('Uniques are missing'); const itemRequestManager = new UmbItemDataApiGetRequestController(this, { // eslint-disable-next-line local-rules/no-direct-api-import - api: (args) => DocumentService.getDocumentUrls({ query: { id: args.uniques } }), + api: (args) => DocumentService.getDocumentUrls({ query: { id: args.uniques, culture } }), uniques, }); diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/PublishedUrlInfoProviderTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/PublishedUrlInfoProviderTests.cs index 931d1cde66aa..cbb26e162b74 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/PublishedUrlInfoProviderTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/PublishedUrlInfoProviderTests.cs @@ -44,6 +44,29 @@ public async Task Invariant_Content_Without_Domain_Returns_Only_Default_Language Assert.AreEqual("en-US", urls.First().Culture, "The URL should be for the default language (en-US)"); } + [Test] + public async Task Can_Ignore_Requested_Culture_For_Invariant_Content() + { + // Arrange: Add a second language (Danish) alongside the default English + var danishLanguage = new LanguageBuilder() + .WithCultureInfo("da-DK") + .WithCultureName("Danish") + .Build(); + await LanguageService.CreateAsync(danishLanguage, Constants.Security.SuperUserKey); + + // The base class creates invariant content + ContentService.PublishBranch(Textpage, PublishBranchFilter.IncludeUnpublished, ["*"]); + + // Act: request with and without a culture + var allUrls = await PublishedUrlInfoProvider.GetAllAsync(Textpage); + var cultureScopedUrls = await PublishedUrlInfoProvider.GetAllAsync(Textpage, "da-DK"); + + // Assert: invariant content ignores the requested culture and returns the same urls + CollectionAssert.AreEquivalent( + allUrls.Select(x => x.Url?.ToString()), + cultureScopedUrls.Select(x => x.Url?.ToString())); + } + [Test] public async Task Invariant_Content_Under_Non_Default_Language_Domain_Returns_Only_Domain_Url() { diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Web.BackOffice/UrlAndDomains/DomainAndUrlsTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Web.BackOffice/UrlAndDomains/DomainAndUrlsTests.cs index 552b95218927..397e1aecd2bb 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Web.BackOffice/UrlAndDomains/DomainAndUrlsTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Web.BackOffice/UrlAndDomains/DomainAndUrlsTests.cs @@ -7,7 +7,6 @@ using Umbraco.Cms.Core.Models.PublishedContent; using Umbraco.Cms.Core.Notifications; using Umbraco.Cms.Core.Packaging; -using Umbraco.Cms.Core.PublishedCache; using Umbraco.Cms.Core.Routing; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Services.Navigation; @@ -239,6 +238,66 @@ public async Task Can_Resolve_Urls_With_Domains_For_All_Cultures() }); } + [Test] + public async Task Can_Resolve_Urls_Restricted_To_A_Single_Culture() + { + var domainService = GetRequiredService(); + var updateModel = new DomainsUpdateModel + { + Domains = Cultures.Select(culture => new DomainModel + { + DomainName = GetDomainUrlFromCultureCode(culture), + IsoCode = culture, + }) + }; + + var result = await domainService.UpdateDomainsAsync(Root.Key, updateModel); + Assert.IsTrue(result.Success); + + var culture = Cultures[1]; + var publishedUrlInfoProvider = GetRequiredService(); + + var allUrls = await publishedUrlInfoProvider.GetAllAsync(Root); + var cultureUrls = await publishedUrlInfoProvider.GetAllAsync(Root, culture); + + Assert.Multiple(() => + { + // The culture-scoped result contains only the requested culture. + Assert.IsNotEmpty(cultureUrls); + CollectionAssert.AreEquivalent(new[] { culture }, cultureUrls.Select(x => x.Culture).Distinct()); + + // The culture-scoped result equals the all-cultures result filtered to that culture. + CollectionAssert.AreEquivalent( + allUrls.Where(x => x.Culture == culture).Select(x => x.Url?.ToString()), + cultureUrls.Select(x => x.Url?.ToString())); + }); + } + + [Test] + public async Task Cannot_Resolve_Urls_For_An_Unknown_Culture() + { + var domainService = GetRequiredService(); + var updateModel = new DomainsUpdateModel + { + Domains = Cultures.Select(culture => new DomainModel + { + DomainName = GetDomainUrlFromCultureCode(culture), + IsoCode = culture, + }) + }; + + var result = await domainService.UpdateDomainsAsync(Root.Key, updateModel); + Assert.IsTrue(result.Success); + + var publishedUrlInfoProvider = GetRequiredService(); + + var unknownCultureUrls = await publishedUrlInfoProvider.GetAllAsync(Root, "xx-XX"); + + // An unknown culture is not an installed culture, so there are no urls to report for it - + // rather than falling back to (expensively) resolving all cultures. + Assert.IsEmpty(unknownCultureUrls); + } + [Test] public async Task Can_Resolve_Urls_For_Non_Default_Domain_Culture_Only() { diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/Factories/DocumentUrlFactoryTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/Factories/DocumentUrlFactoryTests.cs new file mode 100644 index 000000000000..1590a2c050a5 --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/Factories/DocumentUrlFactoryTests.cs @@ -0,0 +1,94 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Routing; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Cms.Api.Management.Factories; + +[TestFixture] +public class DocumentUrlFactoryTests +{ + private static (DocumentUrlFactory Factory, Mock Provider) CreateFactory() + { + var provider = new Mock(); + + // Return a single url tagged with the requested culture so assertions can distinguish calls. + provider + .Setup(x => x.GetAllAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((IContent _, string? culture) => + new HashSet { UrlInfo.AsUrl($"/{culture ?? "all"}/", "test", culture) }); + + var factory = new DocumentUrlFactory( + provider.Object, + new UrlProviderCollection(() => []), + Mock.Of(), + Mock.Of(), + NullLogger.Instance); + + return (factory, provider); + } + + private static IContent CreateContent(Guid? key = null) + { + var content = new Mock(); + content.SetupGet(x => x.Key).Returns(key ?? Guid.NewGuid()); + return content.Object; + } + + [Test] + public async Task Can_Pass_Requested_Culture_To_Provider() + { + var (factory, provider) = CreateFactory(); + var content = CreateContent(); + + var result = (await factory.CreateUrlsAsync(content, "da-DK")).ToList(); + + provider.Verify(x => x.GetAllAsync(content, "da-DK"), Times.Once); + Assert.AreEqual(1, result.Count); + Assert.AreEqual("da-DK", result[0].Culture); + } + + [Test] + public async Task Can_Request_All_Cultures_When_No_Culture_Provided() + { + var (factory, provider) = CreateFactory(); + var content = CreateContent(); + + await factory.CreateUrlsAsync(content); + + // The parameterless overload must delegate with a null culture (all cultures). + provider.Verify(x => x.GetAllAsync(content, null), Times.Once); + } + + [Test] + public async Task Can_Pass_Requested_Culture_For_Every_Item() + { + var (factory, provider) = CreateFactory(); + var contentA = CreateContent(); + var contentB = CreateContent(); + + var result = (await factory.CreateUrlSetsAsync([contentA, contentB], "da-DK")).ToList(); + + provider.Verify(x => x.GetAllAsync(contentA, "da-DK"), Times.Once); + provider.Verify(x => x.GetAllAsync(contentB, "da-DK"), Times.Once); + + Assert.AreEqual(2, result.Count); + Assert.AreEqual(contentA.Key, result[0].Id); + Assert.AreEqual(contentB.Key, result[1].Id); + } + + [Test] + public async Task Can_Request_All_Cultures_For_Url_Sets_When_No_Culture_Provided() + { + var (factory, provider) = CreateFactory(); + var content = CreateContent(); + + await factory.CreateUrlSetsAsync([content]); + + provider.Verify(x => x.GetAllAsync(content, null), Times.Once); + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Routing/PublishedUrlInfoProviderTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Routing/PublishedUrlInfoProviderTests.cs new file mode 100644 index 000000000000..5c2e65a79090 --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Routing/PublishedUrlInfoProviderTests.cs @@ -0,0 +1,229 @@ +using System.Globalization; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Hosting; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.Routing; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Web; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Routing; + +[TestFixture] +public class PublishedUrlInfoProviderTests +{ + private static readonly Guid _contentKey = new("2b3f9a4d-0c1e-4b6a-9c2d-1e2f3a4b5c6d"); + private const int ContentId = 1234; + + private static readonly string[] _installedCultures = ["en-US", "da-DK"]; + + private Mock _urlProvider = null!; + private Mock _languageService = null!; + private Mock _router = null!; + private Mock _textService = null!; + private IPublishedRequest _routeResult = null!; + + [SetUp] + public void SetUp() + { + _urlProvider = new Mock(); + _urlProvider + .Setup(x => x.GetUrl(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((Guid _, UrlMode _, string? culture, Uri? _) => $"https://example.com/{culture}/"); + _urlProvider.Setup(x => x.GetOtherUrls(It.IsAny())).Returns(Array.Empty()); + + _languageService = new Mock(); + _languageService.Setup(x => x.GetAllAsync()).ReturnsAsync(_installedCultures.Select(CreateLanguage).ToArray()); + _languageService.Setup(x => x.GetDefaultIsoCodeAsync()).ReturnsAsync("en-US"); + + // Default routing result: the generated URL routes back to the same content (no collision). + _routeResult = CreateRequest(ContentId); + _router = new Mock(); + _router.Setup(x => x.CreateRequestAsync(It.IsAny())).ReturnsAsync(Mock.Of()); + _router + .Setup(x => x.RouteRequestAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(() => _routeResult); + + _textService = new Mock(); + _textService + .Setup(x => x.Localize(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny?>())) + .Returns((string? _, string? alias, CultureInfo? _, IDictionary? _) => alias ?? string.Empty); + } + + [Test] + public async Task Can_Get_Url_For_Each_Installed_Culture_When_No_Culture_Requested() + { + ISet result = await CreateSut().GetAllAsync(CreateContent(variesByCulture: true)); + + Assert.AreEqual(2, result.Count); + CollectionAssert.AreEquivalent(_installedCultures, result.Select(x => x.Culture)); + Assert.IsTrue(result.All(x => x.Url is not null)); + } + + [Test] + public async Task Can_Restrict_Urls_To_A_Single_Requested_Culture() + { + ISet result = await CreateSut().GetAllAsync(CreateContent(variesByCulture: true), "da-DK"); + + Assert.AreEqual(1, result.Count); + Assert.AreEqual("da-DK", result.Single().Culture); + } + + [Test] + public async Task Can_Match_Requested_Culture_Case_Insensitively() + { + ISet result = await CreateSut().GetAllAsync(CreateContent(variesByCulture: true), "DA-dk"); + + Assert.AreEqual(1, result.Count); + Assert.AreEqual("da-DK", result.Single().Culture, "The installed culture's casing should be used."); + } + + [Test] + public async Task Cannot_Get_Urls_For_An_Unknown_Culture() + { + ISet result = await CreateSut().GetAllAsync(CreateContent(variesByCulture: true), "xx-XX"); + + Assert.IsEmpty(result); + _urlProvider.Verify( + x => x.GetUrl(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [Test] + public async Task Cannot_Get_Url_For_Unroutable_Content_And_Reports_Message() + { + _urlProvider + .Setup(x => x.GetUrl(It.IsAny(), It.IsAny(), "da-DK", It.IsAny())) + .Returns(Constants.Routing.Unroutable); + + ISet result = await CreateSut().GetAllAsync(CreateContent(variesByCulture: true), "da-DK"); + + UrlInfo info = result.Single(); + Assert.IsNull(info.Url); + Assert.AreEqual("getUrlException", info.Message); + } + + [Test] + public async Task Cannot_Get_Url_When_Route_Collides_And_Reports_Message() + { + // The generated URL routes back to a different content item. + _routeResult = CreateRequest(resolvedContentId: 9999); + + ISet result = await CreateSut().GetAllAsync(CreateContent(variesByCulture: true), "da-DK"); + + UrlInfo info = result.Single(); + Assert.IsNull(info.Url); + Assert.AreEqual("routeError", info.Message); + } + + [Test] + public async Task Cannot_Get_Url_When_Route_Is_Unresolvable_And_Reports_Message() + { + // The generated URL does not route back to any content item. + _routeResult = CreateRequest(resolvedContentId: null); + + ISet result = await CreateSut().GetAllAsync(CreateContent(variesByCulture: true), "da-DK"); + + UrlInfo info = result.Single(); + Assert.IsNull(info.Url); + Assert.AreEqual("routeErrorCannotRoute", info.Message); + } + + [Test] + public async Task Can_Get_Url_When_Collisions_Are_Ignored() + { + // Routes back to different content, but the request opts out of collision checks. + _routeResult = CreateRequest(resolvedContentId: 9999, ignoreCollisions: true); + + ISet result = await CreateSut().GetAllAsync(CreateContent(variesByCulture: true), "da-DK"); + + UrlInfo info = result.Single(); + Assert.IsNotNull(info.Url); + Assert.AreEqual("da-DK", info.Culture); + } + + [Test] + public async Task Cannot_Get_Other_Urls_For_Trashed_Content() + { + _urlProvider + .Setup(x => x.GetOtherUrls(ContentId)) + .Returns(new[] { UrlInfo.AsUrl("https://example.com/other/", "content", "da-DK") }); + + ISet result = await CreateSut().GetAllAsync(CreateContent(variesByCulture: true, trashed: true), "da-DK"); + + _urlProvider.Verify(x => x.GetOtherUrls(It.IsAny()), Times.Never); + Assert.IsFalse(result.Any(x => x.Url is not null && x.Url.ToString().Contains("/other/"))); + } + + [Test] + public async Task Can_Get_Other_Urls_Filtered_To_The_Scoped_Culture() + { + _urlProvider.Setup(x => x.GetOtherUrls(ContentId)).Returns(new[] + { + UrlInfo.AsUrl("https://example.com/other-da/", "content", "da-DK"), + UrlInfo.AsUrl("https://example.com/other-en/", "content", "en-US"), + }); + + ISet result = await CreateSut().GetAllAsync(CreateContent(variesByCulture: true), "da-DK"); + + Assert.Multiple(() => + { + Assert.IsTrue(result.Any(x => x.Url is not null && x.Url.ToString().Contains("/other-da/"))); + Assert.IsFalse(result.Any(x => x.Url is not null && x.Url.ToString().Contains("/other-en/"))); + }); + } + + private PublishedUrlInfoProvider CreateSut() + { + var hostingEnvironment = new Mock(); + hostingEnvironment.Setup(x => x.ApplicationVirtualPath).Returns("/"); + + return new PublishedUrlInfoProvider( + _urlProvider.Object, + _languageService.Object, + _router.Object, + Mock.Of(), + _textService.Object, + NullLogger.Instance, + new UriUtility(hostingEnvironment.Object), + Mock.Of()); + } + + private static ILanguage CreateLanguage(string isoCode) + { + var language = new Mock(); + language.SetupGet(x => x.IsoCode).Returns(isoCode); + return language.Object; + } + + private static IContent CreateContent(bool variesByCulture, bool trashed = false) + { + var contentType = new Mock(); + contentType.SetupGet(x => x.Variations).Returns(variesByCulture ? ContentVariation.Culture : ContentVariation.Nothing); + + var content = new Mock(); + content.SetupGet(x => x.ContentType).Returns(contentType.Object); + content.SetupGet(x => x.Key).Returns(_contentKey); + content.SetupGet(x => x.Id).Returns(ContentId); + content.SetupGet(x => x.Trashed).Returns(trashed); + return content.Object; + } + + private static IPublishedRequest CreateRequest(int? resolvedContentId, bool ignoreCollisions = false) + { + var request = new Mock(); + if (resolvedContentId is not null) + { + var publishedContent = new Mock(); + publishedContent.SetupGet(x => x.Id).Returns(resolvedContentId.Value); + publishedContent.SetupGet(x => x.Key).Returns(Guid.NewGuid()); + request.SetupGet(x => x.PublishedContent).Returns(publishedContent.Object); + } + + request.SetupGet(x => x.IgnorePublishedContentCollisions).Returns(ignoreCollisions); + return request.Object; + } +}