From 4cc8d53fd8b8d74c2dc8f74e5259608efe890d6c Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Fri, 31 Jul 2026 11:49:34 +0100 Subject: [PATCH 1/3] fix(core): page through all allowed content types (closes #23512) The Create dialogs, collection create actions, media dropzone and media picker only ever requested one page of allowed content types, so anything past the server's default take of 100 was silently unavailable. Page through the full set with the existing fetchAllPages helper, via a new additive requestAllAllowedChildrenOf. The existing requestAllowedChildrenOf gains an optional paging argument so a single page can still be requested. GetAllowedChildrenAsync applied Take before Skip, so any page after the first came back empty while Total reported the true count. This never surfaced while every caller requested only skip=0. Also page requestMediaTypesOfFolders and requestMediaTypesOf, which the dropzone and media picker intersect with allowed children, and which build the folder-type set that decides whether an item renders as a folder. Co-Authored-By: Claude Opus 5 (1M context) --- ...ntentTypeServiceBase{TRepository,TItem}.cs | 2 +- .../document-type/structure.handlers.ts | 9 +- .../msw-handlers/media-type/item.handlers.ts | 3 +- .../media-type/structure.handlers.ts | 9 +- .../member-type/structure.handlers.ts | 5 +- src/Umbraco.Web.UI.Client/mocks/utils.ts | 18 +++ ...nt-type-structure-data-source.interface.ts | 10 ++ .../content-type-structure-repository-base.ts | 39 +++++- ...ent-type-structure-repository.interface.ts | 16 ++- ...-type-structure-server-data-source-base.ts | 20 ++- ...document-type-structure.repository.test.ts | 120 ++++++++++++++++++ ...ument-type-structure.server.data-source.ts | 13 +- ...eate-document-collection-action.element.ts | 5 +- .../document-create-options-modal.element.ts | 6 +- .../media-type-structure.repository.ts | 56 ++++++-- ...media-type-structure.server.data-source.ts | 72 +++++++---- .../create-media-collection-action.element.ts | 2 +- .../media/dropzone/media-dropzone.manager.ts | 4 +- .../media-create-options-modal.element.ts | 3 +- .../media-picker-folder-path.element.ts | 2 +- ...ember-type-structure.server.data-source.ts | 11 +- ...create-member-collection-action.element.ts | 2 +- .../member-create-options-modal.element.ts | 2 +- .../Services/ContentTypeServiceTests.cs | 96 ++++++++++++++ .../Services/MediaTypeServiceTests.cs | 32 +++++ .../Services/MemberTypeServiceTests.cs | 22 ++++ 26 files changed, 502 insertions(+), 77 deletions(-) create mode 100644 src/Umbraco.Web.UI.Client/src/packages/documents/document-types/repository/structure/document-type-structure.repository.test.ts diff --git a/src/Umbraco.Core/Services/ContentTypeServiceBase{TRepository,TItem}.cs b/src/Umbraco.Core/Services/ContentTypeServiceBase{TRepository,TItem}.cs index 1151e9a790ec..ee50d818032a 100644 --- a/src/Umbraco.Core/Services/ContentTypeServiceBase{TRepository,TItem}.cs +++ b/src/Umbraco.Core/Services/ContentTypeServiceBase{TRepository,TItem}.cs @@ -1489,7 +1489,7 @@ public async Task> GetAllAllowedAsRootAsync(int skip, int take TItem[] allowedChildren = GetMany(sortedKeys).ToArray(); result = new PagedModel { - Items = allowedChildren.OrderBy(x => sortedKeys.IndexOf(x.Key)).Take(take).Skip(skip), + Items = allowedChildren.OrderBy(x => sortedKeys.IndexOf(x.Key)).Skip(skip).Take(take), Total = allowedChildren.Length, }; } diff --git a/src/Umbraco.Web.UI.Client/mocks/msw-handlers/document-type/structure.handlers.ts b/src/Umbraco.Web.UI.Client/mocks/msw-handlers/document-type/structure.handlers.ts index 55fe8c098ac6..2383e362cc5c 100644 --- a/src/Umbraco.Web.UI.Client/mocks/msw-handlers/document-type/structure.handlers.ts +++ b/src/Umbraco.Web.UI.Client/mocks/msw-handlers/document-type/structure.handlers.ts @@ -1,19 +1,20 @@ const { http, HttpResponse } = window.MockServiceWorker; import { umbDocumentTypeMockDb } from '../../db/document-type.db.js'; import { UMB_SLUG } from './slug.js'; +import { pageResponse } from '../../utils.js'; import { umbracoPath } from '@umbraco-cms/backoffice/utils'; export const structureHandlers = [ - http.get(umbracoPath(`${UMB_SLUG}/:id/allowed-children`), ({ params }) => { + http.get(umbracoPath(`${UMB_SLUG}/:id/allowed-children`), ({ params, request }) => { const id = params.id as string; if (!id) return new HttpResponse(null, { status: 400 }); const response = umbDocumentTypeMockDb.getAllowedChildren(id); - return HttpResponse.json(response); + return HttpResponse.json(pageResponse(response, request)); }), - http.get(umbracoPath(`${UMB_SLUG}/allowed-at-root`), () => { + http.get(umbracoPath(`${UMB_SLUG}/allowed-at-root`), ({ request }) => { const response = umbDocumentTypeMockDb.getAllowedAtRoot(); - return HttpResponse.json(response); + return HttpResponse.json(pageResponse(response, request)); }), http.get(umbracoPath(`${UMB_SLUG}/:id/allowed-parents`), ({ params }) => { diff --git a/src/Umbraco.Web.UI.Client/mocks/msw-handlers/media-type/item.handlers.ts b/src/Umbraco.Web.UI.Client/mocks/msw-handlers/media-type/item.handlers.ts index 9f923ed6d437..0452f98cace1 100644 --- a/src/Umbraco.Web.UI.Client/mocks/msw-handlers/media-type/item.handlers.ts +++ b/src/Umbraco.Web.UI.Client/mocks/msw-handlers/media-type/item.handlers.ts @@ -1,6 +1,7 @@ const { http, HttpResponse } = window.MockServiceWorker; import { umbMediaTypeMockDb } from '../../db/media-type.db.js'; import { UMB_SLUG } from './slug.js'; +import { pageResponse } from '../../utils.js'; import { umbracoPath } from '@umbraco-cms/backoffice/utils'; export const itemHandlers = [ @@ -43,6 +44,6 @@ export const itemHandlers = [ const response = umbMediaTypeMockDb.getAllowedByFileExtension(fileExtension); - return HttpResponse.json(response); + return HttpResponse.json(pageResponse(response, request)); }), ]; diff --git a/src/Umbraco.Web.UI.Client/mocks/msw-handlers/media-type/structure.handlers.ts b/src/Umbraco.Web.UI.Client/mocks/msw-handlers/media-type/structure.handlers.ts index de6f2522cc8b..b40ba5073181 100644 --- a/src/Umbraco.Web.UI.Client/mocks/msw-handlers/media-type/structure.handlers.ts +++ b/src/Umbraco.Web.UI.Client/mocks/msw-handlers/media-type/structure.handlers.ts @@ -1,19 +1,20 @@ const { http, HttpResponse } = window.MockServiceWorker; import { umbMediaTypeMockDb } from '../../db/media-type.db.js'; import { UMB_SLUG } from './slug.js'; +import { pageResponse } from '../../utils.js'; import { umbracoPath } from '@umbraco-cms/backoffice/utils'; export const structureHandlers = [ - http.get(umbracoPath(`${UMB_SLUG}/:id/allowed-children`), ({ params }) => { + http.get(umbracoPath(`${UMB_SLUG}/:id/allowed-children`), ({ params, request }) => { const id = params.id as string; if (!id) return new HttpResponse(null, { status: 400 }); const response = umbMediaTypeMockDb.getAllowedChildren(id); - return HttpResponse.json(response); + return HttpResponse.json(pageResponse(response, request)); }), - http.get(umbracoPath(`${UMB_SLUG}/allowed-at-root`), () => { + http.get(umbracoPath(`${UMB_SLUG}/allowed-at-root`), ({ request }) => { const response = umbMediaTypeMockDb.getAllowedAtRoot(); - return HttpResponse.json(response); + return HttpResponse.json(pageResponse(response, request)); }), http.get(umbracoPath(`${UMB_SLUG}/:id/allowed-parents`), ({ params }) => { diff --git a/src/Umbraco.Web.UI.Client/mocks/msw-handlers/member-type/structure.handlers.ts b/src/Umbraco.Web.UI.Client/mocks/msw-handlers/member-type/structure.handlers.ts index 9a0ffbcf4fe1..8202a7fa3138 100644 --- a/src/Umbraco.Web.UI.Client/mocks/msw-handlers/member-type/structure.handlers.ts +++ b/src/Umbraco.Web.UI.Client/mocks/msw-handlers/member-type/structure.handlers.ts @@ -1,11 +1,12 @@ const { http, HttpResponse } = window.MockServiceWorker; import { umbMemberTypeMockDb } from '../../db/member-type.db.js'; import { UMB_SLUG } from './slug.js'; +import { pageResponse } from '../../utils.js'; import { umbracoPath } from '@umbraco-cms/backoffice/utils'; export const structureHandlers = [ - http.get(umbracoPath(`${UMB_SLUG}/allowed-at-root`), () => { + http.get(umbracoPath(`${UMB_SLUG}/allowed-at-root`), ({ request }) => { const response = umbMemberTypeMockDb.getAllowedAtRoot(); - return HttpResponse.json(response); + return HttpResponse.json(pageResponse(response, request)); }), ]; diff --git a/src/Umbraco.Web.UI.Client/mocks/utils.ts b/src/Umbraco.Web.UI.Client/mocks/utils.ts index d78db703ed83..fd9c7784d691 100644 --- a/src/Umbraco.Web.UI.Client/mocks/utils.ts +++ b/src/Umbraco.Web.UI.Client/mocks/utils.ts @@ -50,3 +50,21 @@ export function createProblemDetails(problemDetails: { }): object { return problemDetails; } + +/** + * Applies the `skip` and `take` query parameters of a request to an already-collected paged response, so a mocked + * endpoint pages like the real one does. `total` remains the full count. + * @param {object} response The full set of items, with their total. + * @param {Request} request The intercepted request to read `skip` and `take` from. + * @returns {object} The requested page of items, with the unchanged total. + */ +export function pageResponse( + response: { items: Array; total: number }, + request: Request, +): { items: Array; total: number } { + const url = new URL(request.url); + const skip = Number(url.searchParams.get('skip')) || 0; + const take = Number(url.searchParams.get('take')) || 100; + + return { items: response.items.slice(skip, skip + take), total: response.total }; +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/content/content-type/repository/structure/content-type-structure-data-source.interface.ts b/src/Umbraco.Web.UI.Client/src/packages/content/content-type/repository/structure/content-type-structure-data-source.interface.ts index 2af9263ad468..189a7d1b4551 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/content/content-type/repository/structure/content-type-structure-data-source.interface.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/content/content-type/repository/structure/content-type-structure-data-source.interface.ts @@ -1,15 +1,25 @@ import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import type { UmbEntityModel } from '@umbraco-cms/backoffice/entity'; import type { UmbDataSourceResponse, UmbPagedModel } from '@umbraco-cms/backoffice/repository'; +import type { UmbOffsetPaginationRequestModel } from '@umbraco-cms/backoffice/utils'; export interface UmbContentTypeStructureDataSourceConstructor { new (host: UmbControllerHost): UmbContentTypeStructureDataSource; } export interface UmbContentTypeStructureDataSource { + /** + * Returns a single page of the content types allowed as children. + * @param {string | null} unique - The content type to get the allowed children of, or `null` for the root. + * @param {string | null} parentContentUnique - The content item the children will be created under, if any. + * @param {UmbOffsetPaginationRequestModel} paging - The page to return. Implementations must honour this, as a + * source that ignores it returns the same page repeatedly when a caller pages through the full set. + * @returns {Promise} A promise resolving to `{ data: { items, total } }`, or `{ error }`. + */ getAllowedChildrenOf( unique: string | null, parentContentUnique: string | null, + paging?: UmbOffsetPaginationRequestModel, ): Promise>>; getAllowedParentsOf?(unique: string): Promise>>; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/content/content-type/repository/structure/content-type-structure-repository-base.ts b/src/Umbraco.Web.UI.Client/src/packages/content/content-type/repository/structure/content-type-structure-repository-base.ts index 8df98a06157b..d3db7d7f40d7 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/content/content-type/repository/structure/content-type-structure-repository-base.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/content/content-type/repository/structure/content-type-structure-repository-base.ts @@ -3,8 +3,12 @@ import type { UmbContentTypeStructureDataSource, UmbContentTypeStructureDataSourceConstructor, } from './content-type-structure-data-source.interface.js'; -import { UmbRepositoryBase } from '@umbraco-cms/backoffice/repository'; +import { fetchAllPages, UmbRepositoryBase } from '@umbraco-cms/backoffice/repository'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import type { UmbOffsetPaginationRequestModel } from '@umbraco-cms/backoffice/utils'; + +// Mirrors the server's default `take` for the allowed-children and allowed-at-root endpoints. +const ALLOWED_CHILDREN_PAGE_SIZE = 100; export abstract class UmbContentTypeStructureRepositoryBase extends UmbRepositoryBase @@ -18,13 +22,34 @@ export abstract class UmbContentTypeStructureRepositoryBase } /** - * Returns a promise with the allowed children of a content type - * @param {string} unique - * @param parentContentUnique - * @returns {*} + * Returns a promise with a single page of the allowed children of a content type. The server returns the + * first 100 when no paging is given, so prefer `requestAllAllowedChildrenOf` when every allowed child is needed. + * @param {string | null} unique - The content type to get the allowed children of, or `null` for the root. + * @param {string | null} parentContentUnique - The content item the children will be created under, if any. + * @param {UmbOffsetPaginationRequestModel} paging - The page to return. + * @returns {Promise} A promise resolving to `{ data: { items, total } }`, or `{ error }`. + * @memberof UmbContentTypeStructureRepositoryBase + */ + requestAllowedChildrenOf( + unique: string | null, + parentContentUnique: string | null, + paging?: UmbOffsetPaginationRequestModel, + ) { + return this._dataSource.getAllowedChildrenOf(unique, parentContentUnique, paging); + } + + /** + * Returns a promise with every allowed child of a content type, by paging through the data source until all + * items have been retrieved. + * @param {string | null} unique - The content type to get the allowed children of, or `null` for the root. + * @param {string | null} parentContentUnique - The content item the children will be created under, if any. + * @returns {Promise} A promise resolving to `{ data: { items, total } }` containing every allowed child, or `{ error }`. * @memberof UmbContentTypeStructureRepositoryBase */ - requestAllowedChildrenOf(unique: string | null, parentContentUnique: string | null) { - return this._dataSource.getAllowedChildrenOf(unique, parentContentUnique); + requestAllAllowedChildrenOf(unique: string | null, parentContentUnique: string | null) { + return fetchAllPages( + (skip, take) => this._dataSource.getAllowedChildrenOf(unique, parentContentUnique, { skip, take }), + ALLOWED_CHILDREN_PAGE_SIZE, + ); } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/content/content-type/repository/structure/content-type-structure-repository.interface.ts b/src/Umbraco.Web.UI.Client/src/packages/content/content-type/repository/structure/content-type-structure-repository.interface.ts index 751161e992a3..16f63115444a 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/content/content-type/repository/structure/content-type-structure-repository.interface.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/content/content-type/repository/structure/content-type-structure-repository.interface.ts @@ -1,9 +1,23 @@ import type { UmbDataSourceResponse, UmbPagedModel } from '@umbraco-cms/backoffice/repository'; import type { UmbEntityModel } from '@umbraco-cms/backoffice/entity'; +import type { UmbOffsetPaginationRequestModel } from '@umbraco-cms/backoffice/utils'; export interface UmbContentTypeStructureRepository { + /** + * Returns a single page of the content types allowed as children. The server returns the first 100 when + * no paging is given, so use `requestAllAllowedChildrenOf` when every allowed child is needed. + */ requestAllowedChildrenOf( - unique: string, + unique: string | null, + parentContentUnique: string | null, + paging?: UmbOffsetPaginationRequestModel, + ): Promise>>; + + /** + * Returns every content type allowed as a child, paging through the data source until all have been retrieved. + */ + requestAllAllowedChildrenOf?( + unique: string | null, parentContentUnique: string | null, ): Promise>>; diff --git a/src/Umbraco.Web.UI.Client/src/packages/content/content-type/repository/structure/content-type-structure-server-data-source-base.ts b/src/Umbraco.Web.UI.Client/src/packages/content/content-type/repository/structure/content-type-structure-server-data-source-base.ts index 27189474a5cd..e294ad6d00ee 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/content/content-type/repository/structure/content-type-structure-server-data-source-base.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/content/content-type/repository/structure/content-type-structure-server-data-source-base.ts @@ -3,6 +3,7 @@ import type { UmbEntityModel } from '@umbraco-cms/backoffice/entity'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import { tryExecute } from '@umbraco-cms/backoffice/resources'; import type { UmbPagedModel, UmbDataSourceResponse } from '@umbraco-cms/backoffice/repository'; +import type { UmbOffsetPaginationRequestModel } from '@umbraco-cms/backoffice/utils'; // Keep this type internal type AllowedContentTypeBaseModel = { @@ -19,6 +20,7 @@ export interface UmbContentTypeStructureServerDataSourceBaseArgs< getAllowedChildrenOf: ( unique: string | null, parentContentUnique: string | null, + paging?: UmbOffsetPaginationRequestModel, ) => Promise>>; mapper: (item: ServerItemType) => ClientItemType; } @@ -48,14 +50,22 @@ export abstract class UmbContentTypeStructureServerDataSourceBase< } /** - * Returns a promise with the allowed content types for the given unique - * @param {string} unique - * @param parentContentUnique + * Returns a promise with a single page of the allowed content types for the given unique + * @param {string | null} unique - The content type to get the allowed children of, or `null` for the root. + * @param {string | null} parentContentUnique - The content item the children will be created under, if any. + * @param {UmbOffsetPaginationRequestModel} paging - The page to return. * @returns {*} * @memberof UmbContentTypeStructureServerDataSourceBase */ - async getAllowedChildrenOf(unique: string | null, parentContentUnique: string | null) { - const { data, error } = await tryExecute(this.#host, this.#getAllowedChildrenOf(unique, parentContentUnique)); + async getAllowedChildrenOf( + unique: string | null, + parentContentUnique: string | null, + paging?: UmbOffsetPaginationRequestModel, + ) { + const { data, error } = await tryExecute( + this.#host, + this.#getAllowedChildrenOf(unique, parentContentUnique, paging), + ); if (data) { const items = data.items.map((item) => this.#mapper(item)); diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/repository/structure/document-type-structure.repository.test.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/repository/structure/document-type-structure.repository.test.ts new file mode 100644 index 000000000000..ae8b2f4a7630 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/repository/structure/document-type-structure.repository.test.ts @@ -0,0 +1,120 @@ +import { useMockHandlers, resetMockHandlers } from '../../../../../../mocks/index.js'; +import { UmbDocumentTypeStructureRepository } from './document-type-structure.repository.js'; +import { expect } from '@open-wc/testing'; +import { customElement } from '@umbraco-cms/backoffice/external/lit'; +import { UmbControllerHostElementMixin } from '@umbraco-cms/backoffice/controller-api'; +import { umbracoPath } from '@umbraco-cms/backoffice/utils'; + +const { http, HttpResponse } = window.MockServiceWorker; + +const UMB_SLUG = '/document-type'; + +const TOTAL_ITEMS = 150; + +const allItems = Array.from({ length: TOTAL_ITEMS }, (_, index) => ({ + id: `document-type-${index}`, + name: `Document Type ${index}`, + description: null, + icon: 'icon-document', +})); + +type PageRequest = { skip: string | null; take: string | null }; + +@customElement('umb-test-document-type-structure-repository-host') +class UmbTestDocumentTypeStructureRepositoryHostElement extends UmbControllerHostElementMixin(HTMLElement) {} + +describe('UmbDocumentTypeStructureRepository', () => { + let host: UmbTestDocumentTypeStructureRepositoryHostElement; + let repository: UmbDocumentTypeStructureRepository; + let requests: Array; + + // The global mock handlers ignore skip/take and always return everything, which would let an unpaged + // repository pass. These handlers page for real, so the assertions below only hold if the repository pages. + const pagingHandler = (path: string) => + http.get(umbracoPath(path), ({ request }) => { + const url = new URL(request.url); + const skip = url.searchParams.get('skip'); + const take = url.searchParams.get('take'); + requests.push({ skip, take }); + + const from = Number(skip) || 0; + const size = take === null ? 100 : Number(take); + + return HttpResponse.json({ items: allItems.slice(from, from + size), total: allItems.length }); + }); + + beforeEach(() => { + requests = []; + host = new UmbTestDocumentTypeStructureRepositoryHostElement(); + document.body.appendChild(host); + repository = new UmbDocumentTypeStructureRepository(host); + }); + + afterEach(() => { + repository.destroy(); + document.body.innerHTML = ''; + resetMockHandlers(); + }); + + describe('requestAllAllowedChildrenOf', () => { + it('pages through every allowed child of a document type', async () => { + useMockHandlers(pagingHandler(`${UMB_SLUG}/:id/allowed-children`)); + + const { data } = await repository.requestAllAllowedChildrenOf('parent-document-type', null); + + expect(data?.items).to.have.lengthOf(TOTAL_ITEMS); + expect(data?.total).to.equal(TOTAL_ITEMS); + expect(requests).to.eql([ + { skip: '0', take: '100' }, + { skip: '100', take: '100' }, + ]); + }); + + it('pages through every document type allowed at root', async () => { + useMockHandlers(pagingHandler(`${UMB_SLUG}/allowed-at-root`)); + + const { data } = await repository.requestAllAllowedChildrenOf(null, null); + + expect(data?.items).to.have.lengthOf(TOTAL_ITEMS); + expect(requests).to.eql([ + { skip: '0', take: '100' }, + { skip: '100', take: '100' }, + ]); + }); + + it('returns an error when a page fails', async () => { + useMockHandlers( + http.get(umbracoPath(`${UMB_SLUG}/:id/allowed-children`), () => new HttpResponse(null, { status: 500 })), + ); + + const { data, error } = await repository.requestAllAllowedChildrenOf('parent-document-type', null); + + expect(error).to.exist; + expect(data).to.be.undefined; + }); + }); + + describe('requestAllowedChildrenOf', () => { + it('makes a single unpaged request when no paging is given', async () => { + useMockHandlers(pagingHandler(`${UMB_SLUG}/:id/allowed-children`)); + + const { data } = await repository.requestAllowedChildrenOf('parent-document-type', null); + + expect(requests).to.eql([{ skip: null, take: null }]); + expect(data?.total).to.equal(TOTAL_ITEMS); + }); + + it('requests only the given page', async () => { + useMockHandlers(pagingHandler(`${UMB_SLUG}/:id/allowed-children`)); + + const { data } = await repository.requestAllowedChildrenOf('parent-document-type', null, { + skip: 100, + take: 100, + }); + + expect(requests).to.eql([{ skip: '100', take: '100' }]); + expect(data?.items).to.have.lengthOf(TOTAL_ITEMS - 100); + expect(data?.total).to.equal(TOTAL_ITEMS); + }); + }); +}); diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/repository/structure/document-type-structure.server.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/repository/structure/document-type-structure.server.data-source.ts index 529ee3ec1620..5433a7e25113 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/repository/structure/document-type-structure.server.data-source.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/repository/structure/document-type-structure.server.data-source.ts @@ -7,6 +7,7 @@ import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import { tryExecute } from '@umbraco-cms/backoffice/resources'; import type { UmbDataSourceResponse } from '@umbraco-cms/backoffice/repository'; import type { UmbEntityModel } from '@umbraco-cms/backoffice/entity'; +import type { UmbOffsetPaginationRequestModel } from '@umbraco-cms/backoffice/utils'; /** * @@ -44,16 +45,22 @@ export class UmbDocumentTypeStructureServerDataSource } } -const getAllowedChildrenOf = (unique: string | null, parentContentUnique: string | null) => { +const getAllowedChildrenOf = ( + unique: string | null, + parentContentUnique: string | null, + paging?: UmbOffsetPaginationRequestModel, +) => { if (unique) { // eslint-disable-next-line local-rules/no-direct-api-import return DocumentTypeService.getDocumentTypeByIdAllowedChildren({ path: { id: unique }, - query: { parentContentKey: parentContentUnique ?? undefined }, + query: { parentContentKey: parentContentUnique ?? undefined, skip: paging?.skip, take: paging?.take }, }); } else { // eslint-disable-next-line local-rules/no-direct-api-import - return DocumentTypeService.getDocumentTypeAllowedAtRoot({}); + return DocumentTypeService.getDocumentTypeAllowedAtRoot({ + query: { skip: paging?.skip, take: paging?.take }, + }); } }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/collection/action/create-document-collection-action.element.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/collection/action/create-document-collection-action.element.ts index 73aadb5f7434..d2ff78db13a4 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/collection/action/create-document-collection-action.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/collection/action/create-document-collection-action.element.ts @@ -51,7 +51,10 @@ export class UmbCreateDocumentCollectionActionElement extends UmbLitElement { } async #retrieveAllowedDocumentTypesOf(unique: string | null, parentContentUnique: string | null) { - const { data } = await this.#documentTypeStructureRepository.requestAllowedChildrenOf(unique, parentContentUnique); + const { data } = await this.#documentTypeStructureRepository.requestAllAllowedChildrenOf( + unique, + parentContentUnique, + ); if (data && data.items) { this._allowedDocumentTypes = data.items; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/entity-actions/create/document-create-options-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/entity-actions/create/document-create-options-modal.element.ts index f60e3be4519d..9c4558fa8d72 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/entity-actions/create/document-create-options-modal.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/entity-actions/create/document-create-options-modal.element.ts @@ -97,10 +97,12 @@ export class UmbDocumentCreateOptionsModalElement extends UmbModalBaseElement< } async #retrieveAllowedDocumentTypesOf(unique: string | null, parentContentUnique: string | null) { - const { data } = await this.#documentTypeStructureRepository.requestAllowedChildrenOf(unique, parentContentUnique); + const { data } = await this.#documentTypeStructureRepository.requestAllAllowedChildrenOf( + unique, + parentContentUnique, + ); if (data) { - // TODO: implement pagination, or get 1000? this._allowedDocumentTypes = data.items; } this._loading = false; diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media-types/repository/structure/media-type-structure.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media-types/repository/structure/media-type-structure.repository.ts index 334d37e5ba52..1ac05721d82b 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media-types/repository/structure/media-type-structure.repository.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media-types/repository/structure/media-type-structure.repository.ts @@ -2,6 +2,10 @@ import { UmbMediaTypeStructureServerDataSource } from './media-type-structure.se import type { UmbAllowedMediaTypeModel } from './types.js'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import { UmbContentTypeStructureRepositoryBase } from '@umbraco-cms/backoffice/content-type'; +import { fetchAllPages, type UmbOffsetPageFetcher } from '@umbraco-cms/backoffice/repository'; + +// Mirrors the server's default `take` for the media type item endpoints. +const MEDIA_TYPE_PAGE_SIZE = 100; export class UmbMediaTypeStructureRepository extends UmbContentTypeStructureRepositoryBase { constructor(host: UmbControllerHost) { @@ -16,20 +20,48 @@ export class UmbMediaTypeStructureRepository extends UmbContentTypeStructureRepo return this.#mediaTypeDataSource.getAllowedParentsOf(unique); } - async requestMediaTypesOf({ - fileExtension, - skip = 0, - take = 100, - }: { - fileExtension: string; - skip?: number; - take?: number; - }) { - return this.#mediaTypeDataSource.getMediaTypesOfFileExtension({ fileExtension, skip, take }); + /** + * Returns the media types that allow the given file extension. Pages through them all unless an explicit + * `skip`/`take` is given, in which case only that page is returned. + * @param {object} args - The file extension to match, and optionally the single page to return. + * @returns {Promise} A promise resolving to the matching media types, or an empty array if the request failed. + * @memberof UmbMediaTypeStructureRepository + */ + async requestMediaTypesOf({ fileExtension, skip, take }: { fileExtension: string; skip?: number; take?: number }) { + const { data } = await this.#requestPagedOrAll( + (pageSkip, pageTake) => + this.#mediaTypeDataSource.getMediaTypesOfFileExtension({ fileExtension, skip: pageSkip, take: pageTake }), + { skip, take }, + ); + + return data?.items ?? []; + } + + /** + * Returns the media types that represent folders. Pages through them all unless an explicit `skip`/`take` is + * given, in which case only that page is returned. + * @param {object} args - Optionally the single page to return. + * @returns {Promise} A promise resolving to the folder media types, or an empty array if the request failed. + * @memberof UmbMediaTypeStructureRepository + */ + async requestMediaTypesOfFolders({ skip, take }: { skip?: number; take?: number } = {}) { + const { data } = await this.#requestPagedOrAll( + (pageSkip, pageTake) => this.#mediaTypeDataSource.getMediaTypesOfFolders({ skip: pageSkip, take: pageTake }), + { skip, take }, + ); + + return data?.items ?? []; } - async requestMediaTypesOfFolders({ skip = 0, take = 100 } = {}) { - return this.#mediaTypeDataSource.getMediaTypesOfFolders({ skip, take }); + #requestPagedOrAll( + fetchPage: UmbOffsetPageFetcher, + { skip, take }: { skip?: number; take?: number }, + ) { + if (skip !== undefined || take !== undefined) { + return fetchPage(skip ?? 0, take ?? MEDIA_TYPE_PAGE_SIZE); + } + + return fetchAllPages(fetchPage, MEDIA_TYPE_PAGE_SIZE); } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media-types/repository/structure/media-type-structure.server.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media-types/repository/structure/media-type-structure.server.data-source.ts index dd6d4e25d859..c6f450509db7 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media-types/repository/structure/media-type-structure.server.data-source.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media-types/repository/structure/media-type-structure.server.data-source.ts @@ -5,6 +5,8 @@ import { UmbContentTypeStructureServerDataSourceBase } from '@umbraco-cms/backof import type { AllowedMediaTypeModel } from '@umbraco-cms/backoffice/external/backend-api'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import { tryExecute } from '@umbraco-cms/backoffice/resources'; +import type { UmbDataSourceResponse, UmbPagedModel } from '@umbraco-cms/backoffice/repository'; +import type { UmbOffsetPaginationRequestModel } from '@umbraco-cms/backoffice/utils'; /** * @class UmbMediaTypeStructureServerDataSource @@ -21,11 +23,11 @@ export class UmbMediaTypeStructureServerDataSource } getMediaTypesOfFileExtension({ fileExtension, skip, take }: { fileExtension: string; skip: number; take: number }) { - return getAllowedMediaTypesOfExtension({ fileExtension, skip, take }); + return getAllowedMediaTypesOfExtension(this.#host, { fileExtension, skip, take }); } getMediaTypesOfFolders({ skip, take }: { skip: number; take: number }) { - return getAllowedMediaTypesOfFolders({ skip, take }); + return getAllowedMediaTypesOfFolders(this.#host, { skip, take }); } async getAllowedParentsOf(unique: string) { @@ -47,16 +49,22 @@ export class UmbMediaTypeStructureServerDataSource } } -const getAllowedChildrenOf = (unique: string | null, parentContentUnique: string | null) => { +const getAllowedChildrenOf = ( + unique: string | null, + parentContentUnique: string | null, + paging?: UmbOffsetPaginationRequestModel, +) => { if (unique) { // eslint-disable-next-line local-rules/no-direct-api-import return MediaTypeService.getMediaTypeByIdAllowedChildren({ path: { id: unique }, - query: { parentContentKey: parentContentUnique ?? undefined }, + query: { parentContentKey: parentContentUnique ?? undefined, skip: paging?.skip, take: paging?.take }, }); } else { // eslint-disable-next-line local-rules/no-direct-api-import - return MediaTypeService.getMediaTypeAllowedAtRoot({}); + return MediaTypeService.getMediaTypeAllowedAtRoot({ + query: { skip: paging?.skip, take: paging?.take }, + }); } }; @@ -70,25 +78,41 @@ const mapper = (item: AllowedMediaTypeModel): UmbAllowedMediaTypeModel => { }; }; -const getAllowedMediaTypesOfFolders = async ({ skip, take }: { skip: number; take: number }) => { - // eslint-disable-next-line local-rules/no-direct-api-import - const { data } = await MediaTypeService.getItemMediaTypeFolders({ query: { skip, take } }); - return data.items.map((item) => mapper(item)); +const getAllowedMediaTypesOfFolders = async ( + host: UmbControllerHost, + { skip, take }: { skip: number; take: number }, +): Promise>> => { + const { data, error } = await tryExecute( + host, + // eslint-disable-next-line local-rules/no-direct-api-import + MediaTypeService.getItemMediaTypeFolders({ query: { skip, take } }), + ); + + if (!data) { + return { error }; + } + + return { data: { items: data.items.map((item) => mapper(item)), total: data.total } }; }; -const getAllowedMediaTypesOfExtension = async ({ - fileExtension, - skip, - take, -}: { - fileExtension: string; - skip: number; - take: number; -}) => { - // eslint-disable-next-line local-rules/no-direct-api-import - const { data } = await MediaTypeService.getItemMediaTypeAllowed({ query: { fileExtension, skip, take } }); - return data.items.map((item) => ({ - ...mapper(item), - matchedFileExtension: item.matchedFileExtension, - })); +const getAllowedMediaTypesOfExtension = async ( + host: UmbControllerHost, + { fileExtension, skip, take }: { fileExtension: string; skip: number; take: number }, +): Promise>> => { + const { data, error } = await tryExecute( + host, + // eslint-disable-next-line local-rules/no-direct-api-import + MediaTypeService.getItemMediaTypeAllowed({ query: { fileExtension, skip, take } }), + ); + + if (!data) { + return { error }; + } + + return { + data: { + items: data.items.map((item) => ({ ...mapper(item), matchedFileExtension: item.matchedFileExtension })), + total: data.total, + }, + }; }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/collection/action/create-media-collection-action.element.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/collection/action/create-media-collection-action.element.ts index 4947fc71894e..7b5854205ab2 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/collection/action/create-media-collection-action.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/collection/action/create-media-collection-action.element.ts @@ -46,7 +46,7 @@ export class UmbCreateMediaCollectionActionElement extends UmbLitElement { } async #retrieveAllowedMediaTypesOf(unique: string | null, parentContentUnique: string | null) { - const { data } = await this.#mediaTypeStructureRepository.requestAllowedChildrenOf(unique, parentContentUnique); + const { data } = await this.#mediaTypeStructureRepository.requestAllAllowedChildrenOf(unique, parentContentUnique); if (data && data.items) { this._allowedMediaTypes = data.items; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/dropzone/media-dropzone.manager.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/dropzone/media-dropzone.manager.ts index ec8aa543c800..440301929840 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/dropzone/media-dropzone.manager.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/dropzone/media-dropzone.manager.ts @@ -234,8 +234,8 @@ export class UmbMediaDropzoneManager extends UmbDropzoneManager { if (allowed) return allowed; // Request information on this media type. - const { data } = await this.#mediaTypeStructure.requestAllowedChildrenOf(mediaTypeUnique, parentUnique); - if (!data) throw new Error('Parent media type does not exist'); + const { data } = await this.#mediaTypeStructure.requestAllAllowedChildrenOf(mediaTypeUnique, parentUnique); + if (!data) throw new Error('Could not retrieve the media types allowed under the parent media type'); this.#allowedChildrenOf.appendOne({ mediaTypeUnique, allowedChildren: data.items }); return data.items; diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/entity-actions/create/media-create-options-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/entity-actions/create/media-create-options-modal.element.ts index ddcb55b3fc4c..cebc53c1702a 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/entity-actions/create/media-create-options-modal.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/entity-actions/create/media-create-options-modal.element.ts @@ -58,10 +58,9 @@ export class UmbMediaCreateOptionsModalElement extends UmbModalBaseElement< } async #retrieveAllowedMediaTypesOf(unique: string | null, parentContentUnique: string | null) { - const { data } = await this.#mediaTypeStructureRepository.requestAllowedChildrenOf(unique, parentContentUnique); + const { data } = await this.#mediaTypeStructureRepository.requestAllAllowedChildrenOf(unique, parentContentUnique); if (data) { - // TODO: implement pagination, or get 1000? this._allowedMediaTypes = data.items; } this._loading = false; diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/modals/media-picker/components/media-picker-folder-path.element.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/modals/media-picker/components/media-picker-folder-path.element.ts index 5985d4e3743c..fcf14fbb164a 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/modals/media-picker/components/media-picker-folder-path.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/modals/media-picker/components/media-picker-folder-path.element.ts @@ -107,7 +107,7 @@ export class UmbMediaPickerFolderPathElement extends UmbLitElement { const parentUnique = currentPath?.unique ?? null; // Fetch allowed children of the current parent's media type - const { data: allowedChildrenData } = await this.#mediaTypeStructureRepository.requestAllowedChildrenOf( + const { data: allowedChildrenData } = await this.#mediaTypeStructureRepository.requestAllAllowedChildrenOf( mediaTypeUnique, parentUnique, ); diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/member-type/repository/structure/member-type-structure.server.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/members/member-type/repository/structure/member-type-structure.server.data-source.ts index 3cc1a7314577..5a8de57119f2 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/members/member-type/repository/structure/member-type-structure.server.data-source.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/members/member-type/repository/structure/member-type-structure.server.data-source.ts @@ -4,6 +4,7 @@ import type { AllowedMemberTypeModel } from '@umbraco-cms/backoffice/external/ba import { MemberTypeService } from '@umbraco-cms/backoffice/external/backend-api'; import { UmbContentTypeStructureServerDataSourceBase } from '@umbraco-cms/backoffice/content-type'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import type { UmbOffsetPaginationRequestModel } from '@umbraco-cms/backoffice/utils'; /** * @class UmbMemberTypeStructureServerDataSource @@ -18,9 +19,15 @@ export class UmbMemberTypeStructureServerDataSource extends UmbContentTypeStruct } } -const getAllowedChildrenOf = () => { +const getAllowedChildrenOf = ( + _unique: string | null, + _parentContentUnique: string | null, + paging?: UmbOffsetPaginationRequestModel, +) => { // eslint-disable-next-line local-rules/no-direct-api-import - return MemberTypeService.getMemberTypeAllowedAtRoot({}); + return MemberTypeService.getMemberTypeAllowedAtRoot({ + query: { skip: paging?.skip, take: paging?.take }, + }); }; const mapper = (item: AllowedMemberTypeModel): UmbAllowedMemberTypeModel => { diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/member/collection/action/create-member-collection-action.element.ts b/src/Umbraco.Web.UI.Client/src/packages/members/member/collection/action/create-member-collection-action.element.ts index 2189ff547475..4c9e3cb9cdf4 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/members/member/collection/action/create-member-collection-action.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/members/member/collection/action/create-member-collection-action.element.ts @@ -23,7 +23,7 @@ export class UmbCreateMemberCollectionActionElement extends UmbLitElement { } async #retrieveAllowedMemberTypes() { - const { data } = await this.#memberTypeStructureRepository.requestAllowedChildrenOf(null, null); + const { data } = await this.#memberTypeStructureRepository.requestAllAllowedChildrenOf(null, null); if (data && data.items) { this._allowedMemberTypes = data.items; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/member/entity-actions/create/member-create-options-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/members/member/entity-actions/create/member-create-options-modal.element.ts index dfdc1aaebc5f..7c50704f395e 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/members/member/entity-actions/create/member-create-options-modal.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/members/member/entity-actions/create/member-create-options-modal.element.ts @@ -24,7 +24,7 @@ export class UmbMemberCreateOptionsModalElement extends UmbModalBaseElement< } async #getOptions() { - const { data } = await this.#memberTypeStructureRepository.requestAllowedChildrenOf(null, null); + const { data } = await this.#memberTypeStructureRepository.requestAllAllowedChildrenOf(null, null); if (!data) return; this._options = data.items diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentTypeServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentTypeServiceTests.cs index ec35da90a66b..b2a2da054252 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentTypeServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentTypeServiceTests.cs @@ -2718,4 +2718,100 @@ public async Task GetAllAllowedAsRootAsync_Returns_Only_ContentTypes_Allowed_At_ Assert.IsFalse(result.Items.Any(x => x.Key == notAllowedAtRoot.Key)); }); } + + [Test] + public async Task Can_Get_First_Page_Of_Allowed_Children() + { + IContentType[] children = await CreateContentTypesAllowedAsChildren(); + + Attempt?, ContentTypeOperationStatus> result = + await ContentTypeService.GetAllowedChildrenAsync(children[0].Key, skip: 0, take: 2); + + Assert.IsTrue(result.Success); + Assert.Multiple(() => + { + Assert.AreEqual(3, result.Result!.Total); + Assert.AreEqual(new[] { children[0].Key, children[1].Key }, result.Result.Items.Select(x => x.Key).ToArray()); + }); + } + + [Test] + public async Task Can_Get_Subsequent_Page_Of_Allowed_Children() + { + IContentType[] children = await CreateContentTypesAllowedAsChildren(); + + Attempt?, ContentTypeOperationStatus> result = + await ContentTypeService.GetAllowedChildrenAsync(children[0].Key, skip: 1, take: 2); + + Assert.IsTrue(result.Success); + Assert.Multiple(() => + { + Assert.AreEqual(3, result.Result!.Total); + Assert.AreEqual(new[] { children[1].Key, children[2].Key }, result.Result.Items.Select(x => x.Key).ToArray()); + }); + } + + [Test] + public async Task Can_Get_Last_Page_Of_Allowed_Children() + { + IContentType[] children = await CreateContentTypesAllowedAsChildren(); + + Attempt?, ContentTypeOperationStatus> result = + await ContentTypeService.GetAllowedChildrenAsync(children[0].Key, skip: 2, take: 2); + + Assert.IsTrue(result.Success); + Assert.Multiple(() => + { + Assert.AreEqual(3, result.Result!.Total); + Assert.AreEqual(new[] { children[2].Key }, result.Result.Items.Select(x => x.Key).ToArray()); + }); + } + + [Test] + public async Task Can_Get_Subsequent_Page_Of_ContentTypes_Allowed_At_Root() + { + foreach (var alias in new[] { "rootOne", "rootTwo", "rootThree" }) + { + ContentType contentType = ContentTypeBuilder.CreateBasicContentType(alias, alias); + contentType.AllowedAsRoot = true; + await ContentTypeService.CreateAsync(contentType, Constants.Security.SuperUserKey); + } + + PagedModel all = await ContentTypeService.GetAllAllowedAsRootAsync(0, 1000); + Assert.GreaterOrEqual(all.Total, 3); + Guid[] expectedKeys = all.Items.Skip(1).Take(2).Select(x => x.Key).ToArray(); + + PagedModel result = await ContentTypeService.GetAllAllowedAsRootAsync(1, 2); + + Assert.Multiple(() => + { + Assert.AreEqual(all.Total, result.Total); + Assert.AreEqual(expectedKeys, result.Items.Select(x => x.Key).ToArray()); + }); + } + + /// + /// Creates three content types, each allowed as a child of the first, in a known order. + /// + private async Task CreateContentTypesAllowedAsChildren() + { + IContentType[] children = + [ + ContentTypeBuilder.CreateBasicContentType("childOne", "Child One"), + ContentTypeBuilder.CreateBasicContentType("childTwo", "Child Two"), + ContentTypeBuilder.CreateBasicContentType("childThree", "Child Three"), + ]; + + foreach (IContentType child in children) + { + await ContentTypeService.CreateAsync(child, Constants.Security.SuperUserKey); + } + + children[0].AllowedContentTypes = children + .Select((child, index) => new ContentTypeSort(child.Key, index, child.Alias)) + .ToArray(); + await ContentTypeService.UpdateAsync(children[0], Constants.Security.SuperUserKey); + + return children; + } } diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/MediaTypeServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/MediaTypeServiceTests.cs index bfcc23b2ffb5..31d5b382fc2b 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/MediaTypeServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/MediaTypeServiceTests.cs @@ -7,6 +7,7 @@ using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Notifications; using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; using Umbraco.Cms.Tests.Common.Attributes; using Umbraco.Cms.Tests.Common.Builders; using Umbraco.Cms.Tests.Common.Testing; @@ -382,4 +383,35 @@ public async Task GetAllAllowedAsRootAsync_Returns_Only_MediaTypes_Allowed_At_Ro Assert.IsFalse(result.Items.Any(x => x.Key == notAllowedAtRoot.Key)); }); } + + [Test] + public async Task Can_Get_Last_Page_Of_Allowed_Children() + { + IMediaType[] children = + [ + MediaTypeBuilder.CreateSimpleMediaType("childOne", "Child One"), + MediaTypeBuilder.CreateSimpleMediaType("childTwo", "Child Two"), + MediaTypeBuilder.CreateSimpleMediaType("childThree", "Child Three"), + ]; + + foreach (IMediaType child in children) + { + await MediaTypeService.CreateAsync(child, Constants.Security.SuperUserKey); + } + + children[0].AllowedContentTypes = children + .Select((child, index) => new ContentTypeSort(child.Key, index, child.Alias)) + .ToArray(); + await MediaTypeService.UpdateAsync(children[0], Constants.Security.SuperUserKey); + + Attempt?, ContentTypeOperationStatus> result = + await MediaTypeService.GetAllowedChildrenAsync(children[0].Key, skip: 2, take: 2); + + Assert.IsTrue(result.Success); + Assert.Multiple(() => + { + Assert.AreEqual(3, result.Result!.Total); + Assert.AreEqual(new[] { children[2].Key }, result.Result.Items.Select(x => x.Key).ToArray()); + }); + } } diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/MemberTypeServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/MemberTypeServiceTests.cs index 3fc6d416a2b7..c8f0565a9096 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/MemberTypeServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/MemberTypeServiceTests.cs @@ -163,4 +163,26 @@ public async Task GetAllAllowedAsRootAsync_Returns_All_MemberTypes() Assert.IsTrue(result.Items.Any(x => x.Key == memberType2.Key)); }); } + + [Test] + public async Task Can_Get_Subsequent_Page_Of_MemberTypes_Allowed_At_Root() + { + foreach (var alias in new[] { "typeOne", "typeTwo", "typeThree" }) + { + IMemberType memberType = MemberTypeBuilder.CreateSimpleMemberType(alias, alias); + await MemberTypeService.CreateAsync(memberType, Constants.Security.SuperUserKey); + } + + PagedModel all = await MemberTypeService.GetAllAllowedAsRootAsync(0, 1000); + Assert.GreaterOrEqual(all.Total, 3); + Guid[] expectedKeys = all.Items.Skip(1).Take(2).Select(x => x.Key).ToArray(); + + PagedModel result = await MemberTypeService.GetAllAllowedAsRootAsync(1, 2); + + Assert.Multiple(() => + { + Assert.AreEqual(all.Total, result.Total); + Assert.AreEqual(expectedKeys, result.Items.Select(x => x.Key).ToArray()); + }); + } } From a3f388a9ab6dc8668fb47ec6e9480e2b98d12d0d Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Fri, 31 Jul 2026 12:13:32 +0100 Subject: [PATCH 2/3] test(media): cover the media type structure repository paging branch The "explicit skip/take means one page, otherwise fetch all" branch in requestMediaTypesOf and requestMediaTypesOfFolders was the novel logic in this PR and had no test. Also honour an explicit skip=0/take=0 in the mock paging helper rather than relying on 0 being falsy, while still falling back on an unparseable value. Co-Authored-By: Claude Opus 5 (1M context) --- src/Umbraco.Web.UI.Client/mocks/utils.ts | 20 ++- .../media-type-structure.repository.test.ts | 121 ++++++++++++++++++ 2 files changed, 139 insertions(+), 2 deletions(-) create mode 100644 src/Umbraco.Web.UI.Client/src/packages/media/media-types/repository/structure/media-type-structure.repository.test.ts diff --git a/src/Umbraco.Web.UI.Client/mocks/utils.ts b/src/Umbraco.Web.UI.Client/mocks/utils.ts index fd9c7784d691..cd35c252e6d0 100644 --- a/src/Umbraco.Web.UI.Client/mocks/utils.ts +++ b/src/Umbraco.Web.UI.Client/mocks/utils.ts @@ -63,8 +63,24 @@ export function pageResponse( request: Request, ): { items: Array; total: number } { const url = new URL(request.url); - const skip = Number(url.searchParams.get('skip')) || 0; - const take = Number(url.searchParams.get('take')) || 100; + const skip = readNumber(url.searchParams.get('skip'), 0); + const take = readNumber(url.searchParams.get('take'), 100); return { items: response.items.slice(skip, skip + take), total: response.total }; } + +/** + * Reads a numeric query parameter, falling back when it is absent or unparseable. An explicit `0` is honoured + * rather than treated as absent. + * @param {string | null} value The raw query parameter value. + * @param {number} fallback The value to use when the parameter is absent or not a number. + * @returns {number} The parsed number, or the fallback. + */ +function readNumber(value: string | null, fallback: number): number { + if (value === null) { + return fallback; + } + + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media-types/repository/structure/media-type-structure.repository.test.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media-types/repository/structure/media-type-structure.repository.test.ts new file mode 100644 index 000000000000..0009ea78b2bd --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media-types/repository/structure/media-type-structure.repository.test.ts @@ -0,0 +1,121 @@ +import { useMockHandlers, resetMockHandlers } from '../../../../../../mocks/index.js'; +import { UmbMediaTypeStructureRepository } from './media-type-structure.repository.js'; +import { expect } from '@open-wc/testing'; +import { customElement } from '@umbraco-cms/backoffice/external/lit'; +import { UmbControllerHostElementMixin } from '@umbraco-cms/backoffice/controller-api'; +import { umbracoPath } from '@umbraco-cms/backoffice/utils'; + +const { http, HttpResponse } = window.MockServiceWorker; + +const UMB_SLUG = '/item/media-type'; + +const TOTAL_ITEMS = 150; + +const allItems = Array.from({ length: TOTAL_ITEMS }, (_, index) => ({ + id: `media-type-${index}`, + name: `Media Type ${index}`, + description: null, + icon: 'icon-picture', + matchedFileExtension: true, +})); + +type PageRequest = { skip: string | null; take: string | null }; + +@customElement('umb-test-media-type-structure-repository-host') +class UmbTestMediaTypeStructureRepositoryHostElement extends UmbControllerHostElementMixin(HTMLElement) {} + +describe('UmbMediaTypeStructureRepository', () => { + let host: UmbTestMediaTypeStructureRepositoryHostElement; + let repository: UmbMediaTypeStructureRepository; + let requests: Array; + + // The default handlers for these endpoints page only for `/folders`, and a handler that ignores skip/take + // would let an unpaged repository pass. These page for real so the assertions below mean something. + const pagingHandler = (path: string) => + http.get(umbracoPath(path), ({ request }) => { + const url = new URL(request.url); + const skip = url.searchParams.get('skip'); + const take = url.searchParams.get('take'); + requests.push({ skip, take }); + + const from = Number(skip) || 0; + const size = take === null ? 100 : Number(take); + + return HttpResponse.json({ items: allItems.slice(from, from + size), total: allItems.length }); + }); + + beforeEach(() => { + requests = []; + host = new UmbTestMediaTypeStructureRepositoryHostElement(); + document.body.appendChild(host); + repository = new UmbMediaTypeStructureRepository(host); + }); + + afterEach(() => { + repository.destroy(); + document.body.innerHTML = ''; + resetMockHandlers(); + }); + + describe('requestMediaTypesOfFolders', () => { + it('pages through every folder media type when no paging is given', async () => { + useMockHandlers(pagingHandler(`${UMB_SLUG}/folders`)); + + const items = await repository.requestMediaTypesOfFolders(); + + expect(items).to.have.lengthOf(TOTAL_ITEMS); + expect(requests).to.eql([ + { skip: '0', take: '100' }, + { skip: '100', take: '100' }, + ]); + }); + + it('requests only the given page when paging is given', async () => { + useMockHandlers(pagingHandler(`${UMB_SLUG}/folders`)); + + const items = await repository.requestMediaTypesOfFolders({ skip: 100, take: 100 }); + + expect(items).to.have.lengthOf(TOTAL_ITEMS - 100); + expect(requests).to.eql([{ skip: '100', take: '100' }]); + }); + + it('returns an empty array when the request fails', async () => { + useMockHandlers(http.get(umbracoPath(`${UMB_SLUG}/folders`), () => new HttpResponse(null, { status: 500 }))); + + const items = await repository.requestMediaTypesOfFolders(); + + expect(items).to.have.lengthOf(0); + }); + }); + + describe('requestMediaTypesOf', () => { + it('pages through every media type of a file extension when no paging is given', async () => { + useMockHandlers(pagingHandler(`${UMB_SLUG}/allowed`)); + + const items = await repository.requestMediaTypesOf({ fileExtension: 'jpg' }); + + expect(items).to.have.lengthOf(TOTAL_ITEMS); + expect(requests).to.eql([ + { skip: '0', take: '100' }, + { skip: '100', take: '100' }, + ]); + }); + + it('requests only the given page when paging is given', async () => { + useMockHandlers(pagingHandler(`${UMB_SLUG}/allowed`)); + + const items = await repository.requestMediaTypesOf({ fileExtension: 'jpg', skip: 0, take: 5 }); + + expect(items).to.have.lengthOf(5); + expect(requests).to.eql([{ skip: '0', take: '5' }]); + }); + + it('retains the matched file extension flag', async () => { + useMockHandlers(pagingHandler(`${UMB_SLUG}/allowed`)); + + const items = await repository.requestMediaTypesOf({ fileExtension: 'jpg', take: 1 }); + + expect(items[0].matchedFileExtension).to.be.true; + }); + }); +}); From a1990d4511cb1637a9f4c67e1aad1233c38b3efc Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Fri, 31 Jul 2026 13:52:30 +0100 Subject: [PATCH 3/3] refactor(mocks): reuse pagedResult instead of a new paging helper The paged mock endpoints keep their paging in the db/manager layer and hand skip/take down from the handler, with pagedResult doing the slice. The allowed-children and allowed-at-root db methods now do the same, rather than collecting everything and re-slicing it in the handler through a helper that duplicated pagedResult. Co-Authored-By: Claude Opus 5 (1M context) --- .../mocks/db/document-type.db.ts | 9 ++--- .../mocks/db/media-type.db.ts | 14 ++++---- .../mocks/db/member-type.db.ts | 5 +-- .../document-type/structure.handlers.ts | 17 +++++++--- .../msw-handlers/media-type/item.handlers.ts | 11 +++--- .../media-type/structure.handlers.ts | 17 +++++++--- .../member-type/structure.handlers.ts | 9 +++-- src/Umbraco.Web.UI.Client/mocks/utils.ts | 34 ------------------- 8 files changed, 53 insertions(+), 63 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/mocks/db/document-type.db.ts b/src/Umbraco.Web.UI.Client/mocks/db/document-type.db.ts index 178ce96b4495..8b84996e5f86 100644 --- a/src/Umbraco.Web.UI.Client/mocks/db/document-type.db.ts +++ b/src/Umbraco.Web.UI.Client/mocks/db/document-type.db.ts @@ -5,6 +5,7 @@ import { UmbMockEntityFolderManager } from './utils/entity/entity-folder.manager import { UmbMockEntityTreeManager } from './utils/entity/entity-tree.manager.js'; import { UmbMockEntityNamedItemManager } from './utils/entity/entity-named-item.manager.js'; import { UmbMockEntityDetailManager } from './utils/entity/entity-detail.manager.js'; +import { pagedResult } from './utils/paged-result.js'; import { UmbId } from '@umbraco-cms/backoffice/id'; import { DataTypeChangeModeModel, @@ -42,19 +43,19 @@ class UmbDocumentTypeMockDB extends UmbEntityMockDbBase this.detail.read(sortModel.documentType.id), ); const mappedItems = allowedDocumentTypes.map((item: UmbMockDocumentTypeModel) => allowedDocumentTypeMapper(item)); - return { items: mappedItems, total: mappedItems.length }; + return pagedResult(mappedItems, skip, take); } - getAllowedAtRoot(): PagedAllowedDocumentTypeModel { + getAllowedAtRoot(skip = 0, take = 100): PagedAllowedDocumentTypeModel { const mockItems = this.data.filter((item) => item.allowedAsRoot); const mappedItems = mockItems.map((item) => allowedDocumentTypeMapper(item)); - return { items: mappedItems, total: mappedItems.length }; + return pagedResult(mappedItems, skip, take); } getAllowedParents(id: string): DocumentTypeAllowedParentsResponseModel { diff --git a/src/Umbraco.Web.UI.Client/mocks/db/media-type.db.ts b/src/Umbraco.Web.UI.Client/mocks/db/media-type.db.ts index 3e8b0af70f10..c266faa65ab2 100644 --- a/src/Umbraco.Web.UI.Client/mocks/db/media-type.db.ts +++ b/src/Umbraco.Web.UI.Client/mocks/db/media-type.db.ts @@ -5,6 +5,7 @@ import { UmbMockEntityTreeManager } from './utils/entity/entity-tree.manager.js' import { UmbMockEntityNamedItemManager } from './utils/entity/entity-named-item.manager.js'; import { UmbMockEntityDetailManager } from './utils/entity/entity-detail.manager.js'; import { umbDataTypeMockDb } from './data-type.db.js'; +import { pagedResult } from './utils/paged-result.js'; import { UmbId } from '@umbraco-cms/backoffice/id'; import type { AllowedMediaTypeItemResponseModel, @@ -34,19 +35,19 @@ class UmbMediaTypeMockDB extends UmbEntityMockDbBase { super('mediaType', data); } - getAllowedChildren(id: string): PagedAllowedMediaTypeModel { + getAllowedChildren(id: string, skip = 0, take = 100): PagedAllowedMediaTypeModel { const mediaType = this.detail.read(id); const allowedMediaTypes = mediaType.allowedMediaTypes.map((sortModel: MediaTypeSortModel) => this.detail.read(sortModel.mediaType.id), ); const mappedItems = allowedMediaTypes.map((item: UmbMockMediaTypeModel) => allowedMediaTypeMapper(item)); - return { items: mappedItems, total: mappedItems.length }; + return pagedResult(mappedItems, skip, take); } - getAllowedAtRoot(): PagedAllowedMediaTypeModel { + getAllowedAtRoot(skip = 0, take = 100): PagedAllowedMediaTypeModel { const mockItems = this.data.filter((item) => item.allowedAsRoot); const mappedItems = mockItems.map((item) => allowedMediaTypeMapper(item)); - return { items: mappedItems, total: mappedItems.length }; + return pagedResult(mappedItems, skip, take); } getAllowedParents(id: string) { @@ -56,7 +57,7 @@ class UmbMediaTypeMockDB extends UmbEntityMockDbBase { return { allowedParentIds }; } - getAllowedByFileExtension(fileExtension: string): GetItemMediaTypeAllowedResponse { + getAllowedByFileExtension(fileExtension: string, skip = 0, take = 100): GetItemMediaTypeAllowedResponse { const allowedTypes = this.data.filter((field) => { const allProperties = field.properties.flat(); @@ -73,7 +74,8 @@ class UmbMediaTypeMockDB extends UmbEntityMockDbBase { }); const mappedTypes = allowedTypes.map((item) => allowedExtensionMediaTypeItemMapper(item, true)); - return allowedExtensionMediaTypeMapper(mappedTypes, mappedTypes.length); + const paged = pagedResult(mappedTypes, skip, take); + return allowedExtensionMediaTypeMapper(paged.items, paged.total); } } diff --git a/src/Umbraco.Web.UI.Client/mocks/db/member-type.db.ts b/src/Umbraco.Web.UI.Client/mocks/db/member-type.db.ts index 6dc345858ca8..2ada38323a2e 100644 --- a/src/Umbraco.Web.UI.Client/mocks/db/member-type.db.ts +++ b/src/Umbraco.Web.UI.Client/mocks/db/member-type.db.ts @@ -3,6 +3,7 @@ import { UmbEntityMockDbBase } from './utils/entity/entity-base.js'; import { UmbMockEntityTreeManager } from './utils/entity/entity-tree.manager.js'; import { UmbMockEntityNamedItemManager } from './utils/entity/entity-named-item.manager.js'; import { UmbMockEntityDetailManager } from './utils/entity/entity-detail.manager.js'; +import { pagedResult } from './utils/paged-result.js'; import { UmbId } from '@umbraco-cms/backoffice/id'; import type { AllowedMemberTypeModel, @@ -21,10 +22,10 @@ class UmbMemberTypeMockDB extends UmbEntityMockDbBase { super('memberType', data); } - getAllowedAtRoot(): PagedAllowedMemberTypeModel { + getAllowedAtRoot(skip = 0, take = 100): PagedAllowedMemberTypeModel { const mockItems = this.data.filter((item) => item.allowedAsRoot); const mappedItems = mockItems.map((item) => allowedMemberTypeMapper(item)); - return { items: mappedItems, total: mappedItems.length }; + return pagedResult(mappedItems, skip, take); } } diff --git a/src/Umbraco.Web.UI.Client/mocks/msw-handlers/document-type/structure.handlers.ts b/src/Umbraco.Web.UI.Client/mocks/msw-handlers/document-type/structure.handlers.ts index 2383e362cc5c..f27e9401a007 100644 --- a/src/Umbraco.Web.UI.Client/mocks/msw-handlers/document-type/structure.handlers.ts +++ b/src/Umbraco.Web.UI.Client/mocks/msw-handlers/document-type/structure.handlers.ts @@ -1,20 +1,27 @@ const { http, HttpResponse } = window.MockServiceWorker; import { umbDocumentTypeMockDb } from '../../db/document-type.db.js'; import { UMB_SLUG } from './slug.js'; -import { pageResponse } from '../../utils.js'; import { umbracoPath } from '@umbraco-cms/backoffice/utils'; export const structureHandlers = [ http.get(umbracoPath(`${UMB_SLUG}/:id/allowed-children`), ({ params, request }) => { const id = params.id as string; if (!id) return new HttpResponse(null, { status: 400 }); - const response = umbDocumentTypeMockDb.getAllowedChildren(id); - return HttpResponse.json(pageResponse(response, request)); + const url = new URL(request.url); + const skip = Number(url.searchParams.get('skip')) || 0; + const take = Number(url.searchParams.get('take')) || 100; + + const response = umbDocumentTypeMockDb.getAllowedChildren(id, skip, take); + return HttpResponse.json(response); }), http.get(umbracoPath(`${UMB_SLUG}/allowed-at-root`), ({ request }) => { - const response = umbDocumentTypeMockDb.getAllowedAtRoot(); - return HttpResponse.json(pageResponse(response, request)); + const url = new URL(request.url); + const skip = Number(url.searchParams.get('skip')) || 0; + const take = Number(url.searchParams.get('take')) || 100; + + const response = umbDocumentTypeMockDb.getAllowedAtRoot(skip, take); + return HttpResponse.json(response); }), http.get(umbracoPath(`${UMB_SLUG}/:id/allowed-parents`), ({ params }) => { diff --git a/src/Umbraco.Web.UI.Client/mocks/msw-handlers/media-type/item.handlers.ts b/src/Umbraco.Web.UI.Client/mocks/msw-handlers/media-type/item.handlers.ts index 0452f98cace1..7e52a6faf24b 100644 --- a/src/Umbraco.Web.UI.Client/mocks/msw-handlers/media-type/item.handlers.ts +++ b/src/Umbraco.Web.UI.Client/mocks/msw-handlers/media-type/item.handlers.ts @@ -1,7 +1,6 @@ const { http, HttpResponse } = window.MockServiceWorker; import { umbMediaTypeMockDb } from '../../db/media-type.db.js'; import { UMB_SLUG } from './slug.js'; -import { pageResponse } from '../../utils.js'; import { umbracoPath } from '@umbraco-cms/backoffice/utils'; export const itemHandlers = [ @@ -39,11 +38,15 @@ export const itemHandlers = [ }), http.get(umbracoPath(`/item${UMB_SLUG}/allowed`), ({ request }) => { - const fileExtension = new URL(request.url).searchParams.get('fileExtension'); + const url = new URL(request.url); + const fileExtension = url.searchParams.get('fileExtension'); if (!fileExtension) return; - const response = umbMediaTypeMockDb.getAllowedByFileExtension(fileExtension); + const skip = Number(url.searchParams.get('skip')) || 0; + const take = Number(url.searchParams.get('take')) || 100; - return HttpResponse.json(pageResponse(response, request)); + const response = umbMediaTypeMockDb.getAllowedByFileExtension(fileExtension, skip, take); + + return HttpResponse.json(response); }), ]; diff --git a/src/Umbraco.Web.UI.Client/mocks/msw-handlers/media-type/structure.handlers.ts b/src/Umbraco.Web.UI.Client/mocks/msw-handlers/media-type/structure.handlers.ts index b40ba5073181..254f17faf3b1 100644 --- a/src/Umbraco.Web.UI.Client/mocks/msw-handlers/media-type/structure.handlers.ts +++ b/src/Umbraco.Web.UI.Client/mocks/msw-handlers/media-type/structure.handlers.ts @@ -1,20 +1,27 @@ const { http, HttpResponse } = window.MockServiceWorker; import { umbMediaTypeMockDb } from '../../db/media-type.db.js'; import { UMB_SLUG } from './slug.js'; -import { pageResponse } from '../../utils.js'; import { umbracoPath } from '@umbraco-cms/backoffice/utils'; export const structureHandlers = [ http.get(umbracoPath(`${UMB_SLUG}/:id/allowed-children`), ({ params, request }) => { const id = params.id as string; if (!id) return new HttpResponse(null, { status: 400 }); - const response = umbMediaTypeMockDb.getAllowedChildren(id); - return HttpResponse.json(pageResponse(response, request)); + const url = new URL(request.url); + const skip = Number(url.searchParams.get('skip')) || 0; + const take = Number(url.searchParams.get('take')) || 100; + + const response = umbMediaTypeMockDb.getAllowedChildren(id, skip, take); + return HttpResponse.json(response); }), http.get(umbracoPath(`${UMB_SLUG}/allowed-at-root`), ({ request }) => { - const response = umbMediaTypeMockDb.getAllowedAtRoot(); - return HttpResponse.json(pageResponse(response, request)); + const url = new URL(request.url); + const skip = Number(url.searchParams.get('skip')) || 0; + const take = Number(url.searchParams.get('take')) || 100; + + const response = umbMediaTypeMockDb.getAllowedAtRoot(skip, take); + return HttpResponse.json(response); }), http.get(umbracoPath(`${UMB_SLUG}/:id/allowed-parents`), ({ params }) => { diff --git a/src/Umbraco.Web.UI.Client/mocks/msw-handlers/member-type/structure.handlers.ts b/src/Umbraco.Web.UI.Client/mocks/msw-handlers/member-type/structure.handlers.ts index 8202a7fa3138..30399f127e61 100644 --- a/src/Umbraco.Web.UI.Client/mocks/msw-handlers/member-type/structure.handlers.ts +++ b/src/Umbraco.Web.UI.Client/mocks/msw-handlers/member-type/structure.handlers.ts @@ -1,12 +1,15 @@ const { http, HttpResponse } = window.MockServiceWorker; import { umbMemberTypeMockDb } from '../../db/member-type.db.js'; import { UMB_SLUG } from './slug.js'; -import { pageResponse } from '../../utils.js'; import { umbracoPath } from '@umbraco-cms/backoffice/utils'; export const structureHandlers = [ http.get(umbracoPath(`${UMB_SLUG}/allowed-at-root`), ({ request }) => { - const response = umbMemberTypeMockDb.getAllowedAtRoot(); - return HttpResponse.json(pageResponse(response, request)); + const url = new URL(request.url); + const skip = Number(url.searchParams.get('skip')) || 0; + const take = Number(url.searchParams.get('take')) || 100; + + const response = umbMemberTypeMockDb.getAllowedAtRoot(skip, take); + return HttpResponse.json(response); }), ]; diff --git a/src/Umbraco.Web.UI.Client/mocks/utils.ts b/src/Umbraco.Web.UI.Client/mocks/utils.ts index cd35c252e6d0..d78db703ed83 100644 --- a/src/Umbraco.Web.UI.Client/mocks/utils.ts +++ b/src/Umbraco.Web.UI.Client/mocks/utils.ts @@ -50,37 +50,3 @@ export function createProblemDetails(problemDetails: { }): object { return problemDetails; } - -/** - * Applies the `skip` and `take` query parameters of a request to an already-collected paged response, so a mocked - * endpoint pages like the real one does. `total` remains the full count. - * @param {object} response The full set of items, with their total. - * @param {Request} request The intercepted request to read `skip` and `take` from. - * @returns {object} The requested page of items, with the unchanged total. - */ -export function pageResponse( - response: { items: Array; total: number }, - request: Request, -): { items: Array; total: number } { - const url = new URL(request.url); - const skip = readNumber(url.searchParams.get('skip'), 0); - const take = readNumber(url.searchParams.get('take'), 100); - - return { items: response.items.slice(skip, skip + take), total: response.total }; -} - -/** - * Reads a numeric query parameter, falling back when it is absent or unparseable. An explicit `0` is honoured - * rather than treated as absent. - * @param {string | null} value The raw query parameter value. - * @param {number} fallback The value to use when the parameter is absent or not a number. - * @returns {number} The parsed number, or the fallback. - */ -function readNumber(value: string | null, fallback: number): number { - if (value === null) { - return fallback; - } - - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : fallback; -}