Skip to content

Commit 822cfe9

Browse files
Batch item rest requests (#19233)
1 parent 2c58c70 commit 822cfe9

File tree

37 files changed

+448
-113
lines changed

37 files changed

+448
-113
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1+
export * from './item-data-api-get-request-controller/index.js';
12
export * from './entity-item-ref/index.js';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './item-data-api-get-request.controller.js';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import type { UmbItemDataApiGetRequestControllerArgs } from './types.js';
2+
import {
3+
batchTryExecute,
4+
tryExecute,
5+
UmbError,
6+
type UmbApiError,
7+
type UmbCancelError,
8+
type UmbDataApiResponse,
9+
} from '@umbraco-cms/backoffice/resources';
10+
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
11+
import { batchArray } from '@umbraco-cms/backoffice/utils';
12+
import { umbPeekError } from '@umbraco-cms/backoffice/notification';
13+
import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api';
14+
15+
export class UmbItemDataApiGetRequestController<
16+
ResponseModelType extends UmbDataApiResponse,
17+
> extends UmbControllerBase {
18+
#apiCallback: (args: { uniques: Array<string> }) => Promise<ResponseModelType>;
19+
#uniques: Array<string>;
20+
#batchSize: number = 40;
21+
22+
constructor(host: UmbControllerHost, args: UmbItemDataApiGetRequestControllerArgs<ResponseModelType>) {
23+
super(host);
24+
this.#apiCallback = args.api;
25+
this.#uniques = args.uniques;
26+
}
27+
28+
async request() {
29+
if (!this.#uniques) throw new Error('Uniques are missing');
30+
31+
let data: ResponseModelType['data'] | undefined;
32+
let error: UmbError | UmbApiError | UmbCancelError | Error | undefined;
33+
34+
if (this.#uniques.length > this.#batchSize) {
35+
const chunks = batchArray<string>(this.#uniques, this.#batchSize);
36+
const results = await batchTryExecute(this, chunks, (chunk) => this.#apiCallback({ uniques: chunk }));
37+
38+
const errors = results.filter((promiseResult) => promiseResult.status === 'rejected');
39+
40+
if (errors.length > 0) {
41+
error = await this.#getAndHandleErrorResult(errors);
42+
}
43+
44+
data = results
45+
.filter((promiseResult) => promiseResult.status === 'fulfilled')
46+
.flatMap((promiseResult) => promiseResult.value.data);
47+
} else {
48+
const result = await tryExecute(this, this.#apiCallback({ uniques: this.#uniques }));
49+
data = result.data;
50+
error = result.error;
51+
}
52+
53+
return { data, error };
54+
}
55+
56+
async #getAndHandleErrorResult(errors: Array<PromiseRejectedResult>) {
57+
// TODO: We currently expect all the errors to be the same, but we should handle this better in the future.
58+
const error = errors[0];
59+
await umbPeekError(this, {
60+
headline: 'Error fetching items',
61+
message: 'An error occurred while fetching items.',
62+
});
63+
64+
return new UmbError(error.reason);
65+
}
66+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import type { UmbDataApiResponse } from '@umbraco-cms/backoffice/resources';
2+
3+
export interface UmbItemDataApiGetRequestControllerArgs<ResponseModelType extends UmbDataApiResponse> {
4+
api: (args: { uniques: Array<string> }) => Promise<ResponseModelType>;
5+
uniques: Array<string>;
6+
}

src/Umbraco.Web.UI.Client/src/packages/core/entity-item/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { UmbNamedEntityModel } from '@umbraco-cms/backoffice/entity';
2+
export type * from './item-data-api-get-request-controller/types.js';
23

34
export interface UmbDefaultItemModel extends UmbNamedEntityModel {
45
icon?: string;
Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1+
import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api';
12
import type { UmbDataSourceResponse } from '../data-source-response.interface.js';
23
import type { UmbItemDataSource } from './item-data-source.interface.js';
34
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
45
import { tryExecute } from '@umbraco-cms/backoffice/resources';
56

67
export interface UmbItemServerDataSourceBaseArgs<ServerItemType, ClientItemType extends { unique: string }> {
7-
getItems: (uniques: Array<string>) => Promise<UmbDataSourceResponse<Array<ServerItemType>>>;
8+
getItems?: (uniques: Array<string>) => Promise<UmbDataSourceResponse<Array<ServerItemType>>>;
89
mapper: (item: ServerItemType) => ClientItemType;
910
}
1011

@@ -14,10 +15,10 @@ export interface UmbItemServerDataSourceBaseArgs<ServerItemType, ClientItemType
1415
* @implements {DocumentTreeDataSource}
1516
*/
1617
export abstract class UmbItemServerDataSourceBase<ServerItemType, ClientItemType extends { unique: string }>
18+
extends UmbControllerBase
1719
implements UmbItemDataSource<ClientItemType>
1820
{
19-
#host: UmbControllerHost;
20-
#getItems: (uniques: Array<string>) => Promise<UmbDataSourceResponse<Array<ServerItemType>>>;
21+
#getItems?: (uniques: Array<string>) => Promise<UmbDataSourceResponse<Array<ServerItemType>>>;
2122
#mapper: (item: ServerItemType) => ClientItemType;
2223

2324
/**
@@ -27,7 +28,7 @@ export abstract class UmbItemServerDataSourceBase<ServerItemType, ClientItemType
2728
* @memberof UmbItemServerDataSourceBase
2829
*/
2930
constructor(host: UmbControllerHost, args: UmbItemServerDataSourceBaseArgs<ServerItemType, ClientItemType>) {
30-
this.#host = host;
31+
super(host);
3132
this.#getItems = args.getItems;
3233
this.#mapper = args.mapper;
3334
}
@@ -39,14 +40,17 @@ export abstract class UmbItemServerDataSourceBase<ServerItemType, ClientItemType
3940
* @memberof UmbItemServerDataSourceBase
4041
*/
4142
async getItems(uniques: Array<string>) {
43+
if (!this.#getItems) throw new Error('getItems is not implemented');
4244
if (!uniques) throw new Error('Uniques are missing');
43-
const { data, error } = await tryExecute(this.#host, this.#getItems(uniques));
4445

45-
if (data) {
46-
const items = data.map((item) => this.#mapper(item));
47-
return { data: items };
48-
}
46+
const { data, error } = await tryExecute(this, this.#getItems(uniques));
4947

50-
return { error };
48+
return { data: this._getMappedItems(data), error };
49+
}
50+
51+
protected _getMappedItems(items: Array<ServerItemType> | undefined): Array<ClientItemType> | undefined {
52+
if (!items) return undefined;
53+
if (!this.#mapper) throw new Error('Mapper is not implemented');
54+
return items.map((item) => this.#mapper(item));
5155
}
5256
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export interface UmbDataApiResponse<ResponseType extends { data: unknown } = { data: unknown }> {
2+
data: ResponseType['data'];
3+
}
Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,9 @@
11
export * from './api-interceptor.controller.js';
2-
export * from './resource.controller.js';
3-
export * from './try-execute.controller.js';
4-
export * from './tryExecute.function.js';
5-
export * from './tryExecuteAndNotify.function.js';
6-
export * from './tryXhrRequest.function.js';
7-
export * from './extractUmbNotificationColor.function.js';
2+
export * from './apiTypeValidators.function.js';
83
export * from './extractUmbColorVariable.function.js';
4+
export * from './extractUmbNotificationColor.function.js';
95
export * from './isUmbNotifications.function.js';
10-
export * from './apiTypeValidators.function.js';
6+
export * from './resource.controller.js';
7+
export * from './try-execute/index.js';
118
export * from './umb-error.js';
129
export type * from './types.js';
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { tryExecute } from './tryExecute.function.js';
2+
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
3+
4+
/**
5+
* Batches promises and returns a promise that resolves to an array of results
6+
* @param {UmbControllerHost} host - The host to use for the request and where notifications will be shown
7+
* @param {Array<Array<BatchEntryType>>} chunks - The array of chunks to process
8+
* @param {(chunk: Array<BatchEntryType>) => Promise<PromiseResult>} callback - The function to call for each chunk
9+
* @returns {Promise<PromiseSettledResult<PromiseResult>[]>} - A promise that resolves to an array of results
10+
*/
11+
export function batchTryExecute<BatchEntryType, PromiseResult>(
12+
host: UmbControllerHost,
13+
chunks: Array<Array<BatchEntryType>>,
14+
callback: (chunk: Array<BatchEntryType>) => Promise<PromiseResult>,
15+
): Promise<PromiseSettledResult<PromiseResult>[]> {
16+
return Promise.allSettled(chunks.map((chunk) => tryExecute(host, callback(chunk), { disableNotifications: true })));
17+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export * from './batch-try-execute.function.js';
2+
export * from './try-execute.controller.js';
3+
export * from './tryExecute.function.js';
4+
export * from './tryExecuteAndNotify.function.js';
5+
export * from './tryXhrRequest.function.js';

src/Umbraco.Web.UI.Client/src/packages/core/resources/try-execute.controller.ts renamed to src/Umbraco.Web.UI.Client/src/packages/core/resources/try-execute/try-execute.controller.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import { isProblemDetailsLike } from './apiTypeValidators.function.js';
2-
import { UmbResourceController } from './resource.controller.js';
3-
import type { UmbApiResponse, UmbTryExecuteOptions } from './types.js';
4-
import { UmbApiError, UmbCancelError } from './umb-error.js';
1+
import { isProblemDetailsLike } from '../apiTypeValidators.function.js';
2+
import { UmbResourceController } from '../resource.controller.js';
3+
import type { UmbApiResponse, UmbTryExecuteOptions } from '../types.js';
4+
import { UmbApiError, UmbCancelError } from '../umb-error.js';
55

66
export class UmbTryExecuteController<T> extends UmbResourceController<T> {
77
#abortSignal?: AbortSignal;

src/Umbraco.Web.UI.Client/src/packages/core/resources/tryExecute.function.ts renamed to src/Umbraco.Web.UI.Client/src/packages/core/resources/try-execute/tryExecute.function.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1+
import type { UmbApiResponse, UmbTryExecuteOptions } from '../types.js';
12
import { UmbTryExecuteController } from './try-execute.controller.js';
2-
import type { UmbApiResponse, UmbTryExecuteOptions } from './types.js';
33
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
44

55
/**

src/Umbraco.Web.UI.Client/src/packages/core/resources/tryExecuteAndNotify.function.ts renamed to src/Umbraco.Web.UI.Client/src/packages/core/resources/try-execute/tryExecuteAndNotify.function.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1+
import type { UmbApiResponse } from '../types.js';
12
import { UmbTryExecuteController } from './try-execute.controller.js';
2-
import type { UmbApiResponse } from './types.js';
33
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
44
import { UmbDeprecation } from '@umbraco-cms/backoffice/utils';
55

src/Umbraco.Web.UI.Client/src/packages/core/resources/tryXhrRequest.function.ts renamed to src/Umbraco.Web.UI.Client/src/packages/core/resources/try-execute/tryXhrRequest.function.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1+
import { UmbCancelablePromise } from '../cancelable-promise.js';
2+
import { UmbApiError } from '../umb-error.js';
3+
import { isProblemDetailsLike } from '../apiTypeValidators.function.js';
4+
import type { UmbApiResponse, XhrRequestOptions } from '../types.js';
15
import { UmbTryExecuteController } from './try-execute.controller.js';
2-
import { UmbCancelablePromise } from './cancelable-promise.js';
3-
import { UmbApiError } from './umb-error.js';
4-
import { isProblemDetailsLike } from './apiTypeValidators.function.js';
5-
import type { UmbApiResponse, XhrRequestOptions } from './types.js';
66
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
77
import { umbHttpClient } from '@umbraco-cms/backoffice/http-client';
88

src/Umbraco.Web.UI.Client/src/packages/core/resources/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { UmbApiError, UmbCancelError, UmbError } from './umb-error.js';
2+
export type * from './data-api/types.js';
23

34
export interface XhrRequestOptions extends UmbTryExecuteOptions {
45
baseUrl?: string;
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { expect } from '@open-wc/testing';
2+
import { batchArray } from './batch-array.js';
3+
4+
describe('batchArray', () => {
5+
it('should split an array into chunks of the specified size', () => {
6+
const array = [1, 2, 3, 4, 5];
7+
const batchSize = 2;
8+
const result = batchArray(array, batchSize);
9+
expect(result).to.deep.equal([[1, 2], [3, 4], [5]]);
10+
});
11+
12+
it('should handle arrays smaller than the batch size', () => {
13+
const array = [1];
14+
const batchSize = 2;
15+
const result = batchArray(array, batchSize);
16+
expect(result).to.deep.equal([[1]]);
17+
});
18+
19+
it('should handle empty arrays', () => {
20+
const array: number[] = [];
21+
const batchSize = 2;
22+
const result = batchArray(array, batchSize);
23+
expect(result).to.deep.equal([]);
24+
});
25+
});
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/**
2+
* Splits an array into chunks of a specified size
3+
* @param { Array<BatchEntryType> } array - The array to split
4+
* @param {number }batchSize - The size of each chunk
5+
* @returns {Array<Array<T>>} - An array of chunks
6+
*/
7+
export function batchArray<BatchEntryType>(
8+
array: Array<BatchEntryType>,
9+
batchSize: number,
10+
): Array<Array<BatchEntryType>> {
11+
const chunks: Array<Array<BatchEntryType>> = [];
12+
for (let i = 0; i < array.length; i += batchSize) {
13+
chunks.push(array.slice(i, i + batchSize));
14+
}
15+
return chunks;
16+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './batch-array.js';

src/Umbraco.Web.UI.Client/src/packages/core/utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export * from './array/index.js';
12
export * from './bytes/bytes.function.js';
23
export * from './debounce/debounce.function.js';
34
export * from './deprecation/index.js';

src/Umbraco.Web.UI.Client/src/packages/data-type/repository/item/data-type-item.server.data-source.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { DataTypeService } from '@umbraco-cms/backoffice/external/backend-api';
66
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
77
import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry';
88
import type { ManifestPropertyEditorUi } from '@umbraco-cms/backoffice/property-editor';
9+
import { UmbItemDataApiGetRequestController } from '@umbraco-cms/backoffice/entity-item';
910

1011
let manifestPropertyEditorUis: Array<ManifestPropertyEditorUi> = [];
1112

@@ -26,7 +27,6 @@ export class UmbDataTypeItemServerDataSource extends UmbItemServerDataSourceBase
2627

2728
constructor(host: UmbControllerHost) {
2829
super(host, {
29-
getItems,
3030
mapper,
3131
});
3232

@@ -37,10 +37,21 @@ export class UmbDataTypeItemServerDataSource extends UmbItemServerDataSourceBase
3737
})
3838
.unsubscribe();
3939
}
40-
}
4140

42-
/* eslint-disable local-rules/no-direct-api-import */
43-
const getItems = (uniques: Array<string>) => DataTypeService.getItemDataType({ query: { id: uniques } });
41+
override async getItems(uniques: Array<string>) {
42+
if (!uniques) throw new Error('Uniques are missing');
43+
44+
const itemRequestManager = new UmbItemDataApiGetRequestController(this, {
45+
// eslint-disable-next-line local-rules/no-direct-api-import
46+
api: (args) => DataTypeService.getItemDataType({ query: { id: args.uniques } }),
47+
uniques,
48+
});
49+
50+
const { data, error } = await itemRequestManager.request();
51+
52+
return { data: this._getMappedItems(data), error };
53+
}
54+
}
4455

4556
const mapper = (item: DataTypeItemResponseModel): UmbDataTypeItemModel => {
4657
return {

src/Umbraco.Web.UI.Client/src/packages/dictionary/repository/item/dictionary-item.server.data-source.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { UmbItemServerDataSourceBase } from '@umbraco-cms/backoffice/repository'
44
import type { DictionaryItemItemResponseModel } from '@umbraco-cms/backoffice/external/backend-api';
55
import { DictionaryService } from '@umbraco-cms/backoffice/external/backend-api';
66
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
7+
import { UmbItemDataApiGetRequestController } from '@umbraco-cms/backoffice/entity-item';
78

89
/**
910
* A server data source for Dictionary items
@@ -21,14 +22,24 @@ export class UmbDictionaryItemServerDataSource extends UmbItemServerDataSourceBa
2122
*/
2223
constructor(host: UmbControllerHost) {
2324
super(host, {
24-
getItems,
2525
mapper,
2626
});
2727
}
28-
}
2928

30-
/* eslint-disable local-rules/no-direct-api-import */
31-
const getItems = (uniques: Array<string>) => DictionaryService.getItemDictionary({ query: { id: uniques } });
29+
override async getItems(uniques: Array<string>) {
30+
if (!uniques) throw new Error('Uniques are missing');
31+
32+
const itemRequestManager = new UmbItemDataApiGetRequestController(this, {
33+
// eslint-disable-next-line local-rules/no-direct-api-import
34+
api: (args) => DictionaryService.getItemDictionary({ query: { id: args.uniques } }),
35+
uniques,
36+
});
37+
38+
const { data, error } = await itemRequestManager.request();
39+
40+
return { data: this._getMappedItems(data), error };
41+
}
42+
}
3243

3344
const mapper = (item: DictionaryItemItemResponseModel): UmbDictionaryItemModel => {
3445
return {

0 commit comments

Comments
 (0)