diff --git a/src/Umbraco.Web.UI.Client/src/packages/content/content/workspace/content-detail-workspace-base.ts b/src/Umbraco.Web.UI.Client/src/packages/content/content/workspace/content-detail-workspace-base.ts index 5485ba985222..39701440ef1b 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/content/content/workspace/content-detail-workspace-base.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/content/content/workspace/content-detail-workspace-base.ts @@ -394,7 +394,7 @@ export abstract class UmbContentDetailWorkspaceContextBase< public 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({}); + const { data } = await this.#languageRepository.requestAllItems(); this.#languages.setValue(data?.items ?? []); } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/utils/pagination/offset/fetch-all-pages.function.test.ts b/src/Umbraco.Web.UI.Client/src/packages/core/utils/pagination/offset/fetch-all-pages.function.test.ts new file mode 100644 index 000000000000..ebdc3178bee4 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/utils/pagination/offset/fetch-all-pages.function.test.ts @@ -0,0 +1,121 @@ +import { expect } from '@open-wc/testing'; +import { fetchAllPages } from './fetch-all-pages.function.js'; +import type { UmbDataSourceResponse, UmbPagedModel } from '@umbraco-cms/backoffice/repository'; + +interface TestItem { + id: number; +} + +const buildFakeFetcher = (allItems: Array) => { + const calls: Array<{ skip: number; take: number }> = []; + const fetchPage = async (skip: number, take: number) => { + calls.push({ skip, take }); + return { data: { items: allItems.slice(skip, skip + take), total: allItems.length } }; + }; + return { fetchPage, calls }; +}; + +describe('fetchAllPages', () => { + it('returns all items in a single call when total <= take', async () => { + const all: Array = [{ id: 1 }, { id: 2 }]; + const { fetchPage, calls } = buildFakeFetcher(all); + + const { data } = await fetchAllPages(fetchPage, 10); + + expect(data?.items).to.eql(all); + expect(data?.total).to.equal(2); + expect(calls).to.have.lengthOf(1); + expect(calls[0]).to.eql({ skip: 0, take: 10 }); + }); + + it('pages through and returns all items when total > take', async () => { + const all: Array = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }, { id: 5 }]; + const { fetchPage, calls } = buildFakeFetcher(all); + + const { data } = await fetchAllPages(fetchPage, 2); + + expect(data?.items).to.eql(all); + expect(data?.total).to.equal(5); + expect(calls.map((c) => c.skip)).to.eql([0, 2, 4]); + }); + + it('makes no extra call when the last page exactly fills `take`', async () => { + const all: Array = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; + const { fetchPage, calls } = buildFakeFetcher(all); + + const { data } = await fetchAllPages(fetchPage, 2); + + expect(data?.items).to.eql(all); + expect(data?.total).to.equal(4); + expect(calls).to.have.lengthOf(2); + }); + + it('returns an empty result when there are no items', async () => { + const { fetchPage, calls } = buildFakeFetcher([]); + + const { data } = await fetchAllPages(fetchPage, 100); + + expect(data?.items).to.eql([]); + expect(data?.total).to.equal(0); + expect(calls).to.have.lengthOf(1); + }); + + it('returns the error and stops paging when a page fetch fails', async () => { + let callCount = 0; + const fetchPage = async (skip: number, take: number) => { + callCount++; + if (callCount === 1) { + return { data: { items: [{ id: 1 }, { id: 2 }] as Array, total: 100 } }; + } + return { error: new Error('boom') }; + }; + + const { data, error } = await fetchAllPages(fetchPage, 2); + + expect(data).to.be.undefined; + expect(error).to.exist; + expect(callCount).to.equal(2); + }); + + it('returns a synthesised error when the fetcher returns neither data nor error', async () => { + const fetchPage = async () => ({}) as UmbDataSourceResponse>; + + const { data, error } = await fetchAllPages(fetchPage, 2); + + expect(data).to.be.undefined; + expect(error).to.be.an.instanceOf(Error); + }); + + it('rejects when `take` is not a positive finite number', async () => { + const { fetchPage } = buildFakeFetcher([{ id: 1 }, { id: 2 }]); + + for (const invalid of [0, -1, NaN, Number.POSITIVE_INFINITY]) { + let thrown: unknown; + try { + await fetchAllPages(fetchPage, invalid); + } catch (e) { + thrown = e; + } + expect(thrown, `take=${invalid}`).to.be.an.instanceOf(RangeError); + } + }); + + it('returns an error if the server delivers an empty page before reaching the reported total', async () => { + // Surfaces (rather than masks) a server that reports more items than it returns. Also guards against + // an infinite loop if `total` and the actual items disagree. + let callCount = 0; + const fetchPage = async (skip: number, take: number) => { + callCount++; + if (callCount === 1) { + return { data: { items: [{ id: 1 }, { id: 2 }] as Array, total: 10 } }; + } + return { data: { items: [] as Array, total: 10 } }; + }; + + const { data, error } = await fetchAllPages(fetchPage, 2); + + expect(data).to.be.undefined; + expect(error).to.be.an.instanceOf(Error); + expect(callCount).to.equal(2); + }); +}); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/utils/pagination/offset/fetch-all-pages.function.ts b/src/Umbraco.Web.UI.Client/src/packages/core/utils/pagination/offset/fetch-all-pages.function.ts new file mode 100644 index 000000000000..1f64afe9d9b7 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/utils/pagination/offset/fetch-all-pages.function.ts @@ -0,0 +1,59 @@ +import type { UmbDataSourceResponse, UmbPagedModel } from '@umbraco-cms/backoffice/repository'; + +/** + * A function that returns a single page of an offset-paginated collection. + * @template T - The type of items in the page. + */ +export type UmbOffsetPageFetcher = ( + skip: number, + take: number, +) => Promise>>; + +/** + * Pages through an offset-paginated data source, accumulating every item until `total` has been reached. + * Use when a caller genuinely needs the full set rather than a single page — for example, populating a + * dropdown of every configured language. Returns the same `{ data: { items, total } }` shape as a + * single-page fetch, or `{ error }` if any page fails. + * + * If the server reports a higher `total` than it actually delivers — i.e. an empty page is returned + * before `allItems.length` reaches `total` — the function fails with an error rather than silently + * truncating, since a partial "fetch all" result would mislead the caller. + * @param {UmbOffsetPageFetcher} fetchPage - Called once per page with the current `skip` and `take`. + * @param {number} take - Page size used for every request. Must be a positive finite number. + * @returns {Promise} A promise resolving to all items, or the first error encountered. + * @throws {RangeError} If `take` is not a positive finite number. + */ +export async function fetchAllPages( + fetchPage: UmbOffsetPageFetcher, + take: number, +): Promise>> { + if (!Number.isFinite(take) || take <= 0) { + throw new RangeError(`fetchAllPages: \`take\` must be a positive finite number, got ${take}.`); + } + + const allItems: Array = []; + let skip = 0; + let total = Number.POSITIVE_INFINITY; + + while (allItems.length < total) { + const { data, error } = await fetchPage(skip, take); + if (error) return { error }; + if (!data) return { error: new Error('fetchAllPages: page fetcher returned neither data nor error.') }; + + // If the server reports more items than it delivers, fail rather than silently truncating — + // also guards against an infinite loop on a misbehaving source. + if (data.items.length === 0 && allItems.length < data.total) { + return { + error: new Error( + `fetchAllPages: page fetcher returned an empty page after ${allItems.length} items but reported a total of ${data.total}.`, + ), + }; + } + + allItems.push(...data.items); + total = data.total; + skip += data.items.length; + } + + return { data: { items: allItems, total: allItems.length } }; +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/utils/pagination/offset/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/utils/pagination/offset/index.ts index 8b187410407f..f7fa3ccd010d 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/utils/pagination/offset/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/utils/pagination/offset/index.ts @@ -1 +1,2 @@ +export * from './fetch-all-pages.function.js'; export * from './is-offset-request.guard.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/dictionary/collection/views/table/dictionary-table-collection-view.element.ts b/src/Umbraco.Web.UI.Client/src/packages/dictionary/collection/views/table/dictionary-table-collection-view.element.ts index 3f2e8901bf66..111de77cd6b9 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/dictionary/collection/views/table/dictionary-table-collection-view.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/dictionary/collection/views/table/dictionary-table-collection-view.element.ts @@ -42,7 +42,7 @@ export class UmbDictionaryTableCollectionViewElement extends UmbLitElement { async #observeCollectionItems() { if (!this.#collectionContext) return; - const { data: languageData } = await this.#languageCollectionRepository.requestCollection({}); + const { data: languageData } = await this.#languageCollectionRepository.requestAllItems(); if (!languageData) return; this.observe( diff --git a/src/Umbraco.Web.UI.Client/src/packages/dictionary/workspace/views/workspace-view-dictionary-editor.element.ts b/src/Umbraco.Web.UI.Client/src/packages/dictionary/workspace/views/workspace-view-dictionary-editor.element.ts index dff606e5197f..fde686fd3f8e 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/dictionary/workspace/views/workspace-view-dictionary-editor.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/dictionary/workspace/views/workspace-view-dictionary-editor.element.ts @@ -51,7 +51,7 @@ export class UmbWorkspaceViewDictionaryEditorElement extends UmbLitElement { } override async firstUpdated() { - const { data } = await this.#languageCollectionRepository.requestCollection({}); + const { data } = await this.#languageCollectionRepository.requestAllItems(); if (data) { this._languages = data.items; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/entity-actions/culture-and-hostnames/modal/culture-and-hostnames-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/entity-actions/culture-and-hostnames/modal/culture-and-hostnames-modal.element.ts index 863c048788aa..68b56e9c5f8c 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/entity-actions/culture-and-hostnames/modal/culture-and-hostnames-modal.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/entity-actions/culture-and-hostnames/modal/culture-and-hostnames-modal.element.ts @@ -103,7 +103,7 @@ export class UmbCultureAndHostnamesModalElement extends UmbModalBaseElement< } async #requestLanguages() { - const { data } = await this.#languageCollectionRepository.requestCollection({ take: 999 }); + const { data } = await this.#languageCollectionRepository.requestAllItems(); // Set to empty array if no data, to indicate loading is complete this._languageModel = data?.items ?? []; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/publish/entity-action/publish.action.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/publish/entity-action/publish.action.ts index cba68820a77e..071869af5702 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/publish/entity-action/publish.action.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/publish/entity-action/publish.action.ts @@ -25,7 +25,7 @@ export class UmbPublishDocumentEntityAction extends UmbEntityActionBase { const localize = new UmbLocalizationController(this); const languageRepository = new UmbLanguageCollectionRepository(this._host); - const { data: languageData } = await languageRepository.requestCollection({}); + const { data: languageData } = await languageRepository.requestAllItems(); const documentRepository = new UmbDocumentDetailRepository(this._host); const { data: documentData } = await documentRepository.requestByUnique(this.args.unique); diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/publish/entity-bulk-action/publish.bulk-action.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/publish/entity-bulk-action/publish.bulk-action.ts index a0ba33696bc9..bfb0d55b0da5 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/publish/entity-bulk-action/publish.bulk-action.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/publish/entity-bulk-action/publish.bulk-action.ts @@ -154,7 +154,7 @@ export class UmbDocumentPublishEntityBulkAction extends UmbEntityBulkActionBase< const [{ data: documentItems }, { data: languageData }] = await Promise.all([ itemRepository.requestItems(this.selection), - languageRepository.requestCollection({}), + languageRepository.requestAllItems(), ]); if (!documentItems?.length) return; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/unpublish/entity-action/unpublish.action.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/unpublish/entity-action/unpublish.action.ts index 667fb4dbebc1..b868ac7d445f 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/unpublish/entity-action/unpublish.action.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/unpublish/entity-action/unpublish.action.ts @@ -28,7 +28,7 @@ export class UmbUnpublishDocumentEntityAction extends UmbEntityActionBase const localize = new UmbLocalizationController(this); const languageRepository = new UmbLanguageCollectionRepository(this._host); - const { data: languageData } = await languageRepository.requestCollection({}); + const { data: languageData } = await languageRepository.requestAllItems(); const documentRepository = new UmbDocumentDetailRepository(this._host); const { data: documentData } = await documentRepository.requestByUnique(this.args.unique); diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/unpublish/entity-bulk-action/unpublish.bulk-action.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/unpublish/entity-bulk-action/unpublish.bulk-action.ts index 3d2bcbcd66f3..da4095bb2c64 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/unpublish/entity-bulk-action/unpublish.bulk-action.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/unpublish/entity-bulk-action/unpublish.bulk-action.ts @@ -44,7 +44,7 @@ export class UmbDocumentUnpublishEntityBulkAction extends UmbEntityBulkActionBas const [{ data: documentItems }, { data: languageData }] = await Promise.all([ itemRepository.requestItems(this.selection), - languageRepository.requestCollection({}), + languageRepository.requestAllItems(), ]); if (!documentItems?.length) return; diff --git a/src/Umbraco.Web.UI.Client/src/packages/language/app-language-select/app-language-select.element.ts b/src/Umbraco.Web.UI.Client/src/packages/language/app-language-select/app-language-select.element.ts index 9839519f2b8a..a675facb5cad 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/language/app-language-select/app-language-select.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/language/app-language-select/app-language-select.element.ts @@ -85,7 +85,7 @@ export class UmbAppLanguageSelectElement extends UmbLitElement { } async #observeLanguages() { - const { data } = await this.#collectionRepository.requestCollection({}); + const { data } = await this.#collectionRepository.requestAllItems(); // TODO: listen to changes if (data) { diff --git a/src/Umbraco.Web.UI.Client/src/packages/language/collection/repository/language-collection.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/language/collection/repository/language-collection.repository.ts index f91e75385921..d0e9793be2b8 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/language/collection/repository/language-collection.repository.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/language/collection/repository/language-collection.repository.ts @@ -1,9 +1,15 @@ import type { UmbLanguageCollectionFilterModel } from '../types.js'; +import type { UmbLanguageDetailModel } from '../../types.js'; import { UmbLanguageCollectionServerDataSource } from './language-collection.server.data-source.js'; import type { UmbLanguageCollectionDataSource } from './types.js'; import { UmbRepositoryBase } from '@umbraco-cms/backoffice/repository'; import type { UmbCollectionRepository } from '@umbraco-cms/backoffice/collection'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { fetchAllPages } from '@umbraco-cms/backoffice/utils'; + +// Mirrors the server's default page size for `GET /language` — chosen so the underlying request matches +// the unconfigured server contract. +const LANGUAGE_PAGE_SIZE = 100; export class UmbLanguageCollectionRepository extends UmbRepositoryBase implements UmbCollectionRepository { #collectionSource: UmbLanguageCollectionDataSource; @@ -16,6 +22,19 @@ export class UmbLanguageCollectionRepository extends UmbRepositoryBase implement async requestCollection(filter: UmbLanguageCollectionFilterModel) { return this.#collectionSource.getCollection(filter); } + + /** + * Requests all languages by paging through the collection until every item has been retrieved. + * Use this in preference to `requestCollection` when callers need the full set — the server defaults + * `take` to 100, so a single un-paged request would silently truncate installations with more languages. + * @returns {Promise} A promise resolving to `{ data: { items, total } }` containing every language, or `{ error }`. + */ + async requestAllItems() { + return fetchAllPages( + (skip, take) => this.#collectionSource.getCollection({ skip, take }), + LANGUAGE_PAGE_SIZE, + ); + } } export default UmbLanguageCollectionRepository; 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 f3ae0cf99aa7..f03d516fe2b3 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 @@ -111,7 +111,7 @@ export class UmbAppLanguageContext extends UmbContextBase implements UmbApi { } async #requestLanguages() { - const { data } = await this.#languageCollectionRepository.requestCollection({}); + const { data } = await this.#languageCollectionRepository.requestAllItems(); // TODO: make this observable / update when languages are added/removed/updated if (data) { diff --git a/src/Umbraco.Web.UI.Client/src/packages/language/modals/language-picker/language-picker-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/language/modals/language-picker/language-picker-modal.element.ts index 27fa719104dc..f0d47409a3f6 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/language/modals/language-picker/language-picker-modal.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/language/modals/language-picker/language-picker-modal.element.ts @@ -28,7 +28,7 @@ export class UmbLanguagePickerModalElement extends UmbModalBaseElement< } override async firstUpdated() { - const { data } = await this.#collectionRepository.requestCollection({}); + const { data } = await this.#collectionRepository.requestAllItems(); this._languages = data?.items ?? []; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/multi-url-picker/document-link-picker-modal/document-link-picker.context.ts b/src/Umbraco.Web.UI.Client/src/packages/multi-url-picker/document-link-picker-modal/document-link-picker.context.ts index 4f0c9d8c393e..9624078f4560 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/multi-url-picker/document-link-picker-modal/document-link-picker.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/multi-url-picker/document-link-picker-modal/document-link-picker.context.ts @@ -35,7 +35,7 @@ export class UmbDocumentLinkPickerContext extends UmbPickerContext { } async #loadLanguages() { - const { data } = await this.#languageCollectionRepository.requestCollection({ skip: 0, take: 1000 }); + const { data } = await this.#languageCollectionRepository.requestAllItems(); const languages = data?.items || []; this.#languages.setValue(languages); diff --git a/src/Umbraco.Web.UI.Client/src/packages/preview/preview-apps/preview-culture.element.ts b/src/Umbraco.Web.UI.Client/src/packages/preview/preview-apps/preview-culture.element.ts index fd7f9fa75632..fddd255b3108 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/preview/preview-apps/preview-culture.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/preview/preview-apps/preview-culture.element.ts @@ -34,8 +34,8 @@ export class UmbPreviewCultureElement extends UmbLitElement { } async #loadCultures() { - const { data: langauges } = await this.#languageRepository.requestCollection({ skip: 0, take: 100 }); - this._cultures = langauges?.items ?? []; + const { data: languages } = await this.#languageRepository.requestAllItems(); + this._cultures = languages?.items ?? []; const searchParams = new URLSearchParams(window.location.search); const culture = searchParams.get('culture');