Skip to content

Commit ea63d08

Browse files
authored
V15: Media library crashes when uploading large files (#18113)
* chore(mock): adds endpoint handler for allowed media types * feat: adds new event `UmbDropzoneSubmittedEvent` * fix: do not await unnecessarily * fix: simplify error checking * fix: only proceed if array contains elements * feat: adds support to render an error state * fix: react to error state on temporary file badges * fix: cancel events and simplify error check and react to any status changes * feat: adds new tryXhrRequest function * fix: use tryXhrRequest to upload all temporary files * fix: use error types from hey-api as a temporary solution * fix: changes limit from int32 to long (64-bit) to allow larger files to be uploaded * fix: set default baseURL * fix: use same unique * fix: do not overwrite status * fix: adds progress callback for tinymce * generate openapi.json * Revert "generate openapi.json" This reverts commit 3c723e0. * Revert "fix: changes limit from int32 to long (64-bit) to allow larger files to be uploaded" This reverts commit c883a45. * chore: generate OpenApi.json
1 parent eeafb3c commit ea63d08

20 files changed

+341
-82
lines changed

src/Umbraco.Cms.Api.Management/OpenApi.json

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35978,14 +35978,11 @@
3597835978
"mediaStartNodeIds",
3597935979
"name",
3598035980
"permissions",
35981+
"userGroupIds",
3598135982
"userName"
3598235983
],
3598335984
"type": "object",
3598435985
"properties": {
35985-
"id": {
35986-
"type": "string",
35987-
"format": "uuid"
35988-
},
3598935986
"email": {
3599035987
"type": "string"
3599135988
},
@@ -35995,6 +35992,21 @@
3599535992
"name": {
3599635993
"type": "string"
3599735994
},
35995+
"userGroupIds": {
35996+
"uniqueItems": true,
35997+
"type": "array",
35998+
"items": {
35999+
"oneOf": [
36000+
{
36001+
"$ref": "#/components/schemas/ReferenceByIdModel"
36002+
}
36003+
]
36004+
}
36005+
},
36006+
"id": {
36007+
"type": "string",
36008+
"format": "uuid"
36009+
},
3599836010
"languageIsoCode": {
3599936011
"type": "string",
3600036012
"nullable": true
@@ -37794,6 +37806,16 @@
3779437806
"type": "string",
3779537807
"format": "date-time",
3779637808
"nullable": true
37809+
},
37810+
"scheduledPublishDate": {
37811+
"type": "string",
37812+
"format": "date-time",
37813+
"nullable": true
37814+
},
37815+
"scheduledUnpublishDate": {
37816+
"type": "string",
37817+
"format": "date-time",
37818+
"nullable": true
3779737819
}
3779837820
},
3779937821
"additionalProperties": false

src/Umbraco.Web.UI.Client/src/mocks/data/media-type/media-type.data.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ export type UmbMockMediaTypeModel = MediaTypeResponseModel &
88
MediaTypeTreeItemResponseModel &
99
MediaTypeItemResponseModel;
1010

11+
export type UmbMockMediaTypeUnionModel =
12+
| MediaTypeResponseModel
13+
| MediaTypeTreeItemResponseModel
14+
| MediaTypeItemResponseModel;
15+
1116
export const data: Array<UmbMockMediaTypeModel> = [
1217
{
1318
name: 'Media Type 1',

src/Umbraco.Web.UI.Client/src/mocks/data/media-type/media-type.db.ts

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,21 @@ import { UmbMockEntityFolderManager } from '../utils/entity/entity-folder.manage
33
import { UmbMockEntityTreeManager } from '../utils/entity/entity-tree.manager.js';
44
import { UmbMockEntityItemManager } from '../utils/entity/entity-item.manager.js';
55
import { UmbMockEntityDetailManager } from '../utils/entity/entity-detail.manager.js';
6-
import type { UmbMockMediaTypeModel } from './media-type.data.js';
6+
import type { UmbMockMediaTypeModel, UmbMockMediaTypeUnionModel } from './media-type.data.js';
77
import { data } from './media-type.data.js';
88
import { UmbId } from '@umbraco-cms/backoffice/id';
99
import type {
1010
AllowedMediaTypeModel,
1111
CreateFolderRequestModel,
1212
CreateMediaTypeRequestModel,
13+
GetItemMediaTypeAllowedResponse,
1314
MediaTypeItemResponseModel,
1415
MediaTypeResponseModel,
1516
MediaTypeSortModel,
1617
MediaTypeTreeItemResponseModel,
1718
PagedAllowedMediaTypeModel,
1819
} from '@umbraco-cms/backoffice/external/backend-api';
20+
import { umbDataTypeMockDb } from '../data-type/data-type.db.js';
1921

2022
class UmbMediaTypeMockDB extends UmbEntityMockDbBase<UmbMockMediaTypeModel> {
2123
tree = new UmbMockEntityTreeManager<UmbMockMediaTypeModel>(this, mediaTypeTreeItemMapper);
@@ -45,6 +47,26 @@ class UmbMediaTypeMockDB extends UmbEntityMockDbBase<UmbMockMediaTypeModel> {
4547
const mappedItems = mockItems.map((item) => allowedMediaTypeMapper(item));
4648
return { items: mappedItems, total: mappedItems.length };
4749
}
50+
51+
getAllowedByFileExtension(fileExtension: string): GetItemMediaTypeAllowedResponse {
52+
const allowedTypes = this.data.filter((field) => {
53+
const allProperties = field.properties.flat();
54+
55+
const fileUploadType = allProperties.find((prop) => prop.alias === 'umbracoFile');
56+
if (!fileUploadType) return false;
57+
58+
const dataType = umbDataTypeMockDb.read(fileUploadType.dataType.id);
59+
if (dataType?.editorAlias !== 'Umbraco.UploadField') return false;
60+
61+
const allowedFileExtensions = dataType.values.find((value) => value.alias === 'fileExtensions')?.value;
62+
if (!allowedFileExtensions || !Array.isArray(allowedFileExtensions)) return false;
63+
64+
return allowedFileExtensions.includes(fileExtension);
65+
});
66+
67+
const mappedTypes = allowedTypes.map(mediaTypeItemMapper);
68+
return allowedExtensionMediaTypeMapper(mappedTypes, mappedTypes.length);
69+
}
4870
}
4971

5072
const createMockMediaTypeFolderMapper = (request: CreateFolderRequestModel): UmbMockMediaTypeModel => {
@@ -128,7 +150,7 @@ const mediaTypeTreeItemMapper = (item: UmbMockMediaTypeModel): MediaTypeTreeItem
128150
};
129151
};
130152

131-
const mediaTypeItemMapper = (item: UmbMockMediaTypeModel): MediaTypeItemResponseModel => {
153+
const mediaTypeItemMapper = (item: UmbMockMediaTypeUnionModel): MediaTypeItemResponseModel => {
132154
return {
133155
id: item.id,
134156
name: item.name,
@@ -145,4 +167,14 @@ const allowedMediaTypeMapper = (item: UmbMockMediaTypeModel): AllowedMediaTypeMo
145167
};
146168
};
147169

170+
const allowedExtensionMediaTypeMapper = (
171+
items: Array<MediaTypeItemResponseModel>,
172+
total: number,
173+
): GetItemMediaTypeAllowedResponse => {
174+
return {
175+
items,
176+
total,
177+
};
178+
};
179+
148180
export const umbMediaTypeMockDb = new UmbMediaTypeMockDB(data);

src/Umbraco.Web.UI.Client/src/mocks/handlers/media-type/item.handlers.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,13 @@ export const itemHandlers = [
1010
const items = umbMediaTypeMockDb.item.getItems(ids);
1111
return res(ctx.status(200), ctx.json(items));
1212
}),
13+
14+
rest.get(umbracoPath(`/item${UMB_SLUG}/allowed`), (req, res, ctx) => {
15+
const fileExtension = req.url.searchParams.get('fileExtension');
16+
if (!fileExtension) return;
17+
18+
const response = umbMediaTypeMockDb.getAllowedByFileExtension(fileExtension);
19+
20+
return res(ctx.status(200), ctx.json(response));
21+
}),
1322
];

src/Umbraco.Web.UI.Client/src/packages/core/repository/data-source-response.interface.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,9 @@ export interface UmbDataSourceErrorResponse {
88
// TODO: we should not rely on the ApiError and CancelError types from the backend-api package
99
// We need to be able to return a generic error type that can be used in the frontend
1010
// Example: the clipboard is getting is data from local storage, so it should not use the ApiError type
11+
/**
12+
* The error that occurred when fetching the data.
13+
* The {ApiError} and {CancelError} types will change in the future to be a more generic error type.
14+
*/
1115
error?: ApiError | CancelError;
1216
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
export * from './resource.controller.js';
22
export * from './tryExecute.function.js';
33
export * from './tryExecuteAndNotify.function.js';
4+
export * from './tryXhrRequest.function.js';
45
export * from './extractUmbColorVariable.function.js';
56
export * from './apiTypeValidators.function.js';
7+
export type * from './types.js';

src/Umbraco.Web.UI.Client/src/packages/core/resources/resource.controller.ts

Lines changed: 119 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
11
/* eslint-disable @typescript-eslint/no-explicit-any */
22
import { UMB_AUTH_CONTEXT } from '../auth/index.js';
33
import { isApiError, isCancelError, isCancelablePromise } from './apiTypeValidators.function.js';
4+
import type { XhrRequestOptions } from './types.js';
45
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
56
import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api';
67
import { UmbContextConsumerController } from '@umbraco-cms/backoffice/context-api';
78
import { UMB_NOTIFICATION_CONTEXT, type UmbNotificationOptions } from '@umbraco-cms/backoffice/notification';
89
import type { UmbDataSourceResponse } from '@umbraco-cms/backoffice/repository';
9-
import type { ProblemDetails } from '@umbraco-cms/backoffice/external/backend-api';
10+
import {
11+
ApiError,
12+
CancelablePromise,
13+
CancelError,
14+
type ProblemDetails,
15+
} from '@umbraco-cms/backoffice/external/backend-api';
1016

1117
export class UmbResourceController extends UmbControllerBase {
1218
#promise: Promise<any>;
@@ -72,7 +78,7 @@ export class UmbResourceController extends UmbControllerBase {
7278
// Cancelled - do nothing
7379
return {};
7480
} else {
75-
console.group('ApiError caught in UmbResourceController');
81+
console.groupCollapsed('ApiError caught in UmbResourceController');
7682
console.error('Request failed', error.request);
7783
console.error('Request body', error.body);
7884
console.error('Error', error);
@@ -167,6 +173,117 @@ export class UmbResourceController extends UmbControllerBase {
167173
return { data, error };
168174
}
169175

176+
/**
177+
* Make an XHR request.
178+
* @param host The controller host for this controller to be appended to.
179+
* @param options The options for the XHR request.
180+
*/
181+
static xhrRequest<T>(options: XhrRequestOptions): CancelablePromise<T> {
182+
const baseUrl = options.baseUrl || '/umbraco';
183+
184+
const promise = new CancelablePromise<T>(async (resolve, reject, onCancel) => {
185+
const xhr = new XMLHttpRequest();
186+
xhr.open(options.method, `${baseUrl}${options.url}`, true);
187+
188+
// Set default headers
189+
if (options.token) {
190+
const token = typeof options.token === 'function' ? await options.token() : options.token;
191+
if (token) {
192+
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
193+
}
194+
}
195+
196+
// Infer Content-Type header based on body type
197+
if (options.body instanceof FormData) {
198+
// Note: 'multipart/form-data' is automatically set by the browser for FormData
199+
} else {
200+
xhr.setRequestHeader('Content-Type', 'application/json');
201+
}
202+
203+
// Set custom headers
204+
if (options.headers) {
205+
for (const [key, value] of Object.entries(options.headers)) {
206+
xhr.setRequestHeader(key, value);
207+
}
208+
}
209+
210+
xhr.upload.onprogress = (event) => {
211+
if (options.onProgress) {
212+
options.onProgress(event);
213+
}
214+
};
215+
216+
xhr.onload = () => {
217+
try {
218+
if (xhr.status >= 200 && xhr.status < 300) {
219+
if (options.responseHeader) {
220+
const response = xhr.getResponseHeader(options.responseHeader);
221+
resolve(response as T);
222+
} else {
223+
resolve(JSON.parse(xhr.responseText));
224+
}
225+
} else {
226+
// TODO: [JOV] This has to be changed into our own error type, when we have a chance to introduce a breaking change in the future.
227+
const error = new ApiError(
228+
{
229+
method: options.method,
230+
url: `${baseUrl}${options.url}`,
231+
},
232+
{
233+
body: xhr.responseText,
234+
ok: false,
235+
status: xhr.status,
236+
statusText: xhr.statusText,
237+
url: xhr.responseURL,
238+
},
239+
xhr.statusText,
240+
);
241+
reject(error);
242+
}
243+
} catch {
244+
// This most likely happens when the response is not JSON
245+
reject(new Error(`Failed to make request: ${xhr.statusText}`));
246+
}
247+
};
248+
249+
xhr.onerror = () => {
250+
// TODO: [JOV] This has to be changed into our own error type, when we have a chance to introduce a breaking change in the future.
251+
const error = new ApiError(
252+
{
253+
method: options.method,
254+
url: `${baseUrl}${options.url}`,
255+
},
256+
{
257+
body: xhr.responseText,
258+
ok: false,
259+
status: xhr.status,
260+
statusText: xhr.statusText,
261+
url: xhr.responseURL,
262+
},
263+
xhr.statusText,
264+
);
265+
reject(error);
266+
};
267+
268+
if (!onCancel.isCancelled) {
269+
// Handle body based on Content-Type
270+
if (options.body instanceof FormData) {
271+
xhr.send(options.body);
272+
} else {
273+
xhr.send(JSON.stringify(options.body));
274+
}
275+
}
276+
277+
onCancel(() => {
278+
xhr.abort();
279+
// TODO: [JOV] This has to be changed into our own error type, when we have a chance to introduce a breaking change in the future.
280+
reject(new CancelError('Request was cancelled.'));
281+
});
282+
});
283+
284+
return promise;
285+
}
286+
170287
/**
171288
* Cancel all resources that are currently being executed by this controller if they are cancelable.
172289
*
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { UMB_AUTH_CONTEXT } from '../auth/auth.context.token.js';
2+
import type { XhrRequestOptions } from './types.js';
3+
import { UmbResourceController } from './resource.controller.js';
4+
import { UmbContextConsumerController } from '@umbraco-cms/backoffice/context-api';
5+
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
6+
import { OpenAPI, type CancelablePromise } from '@umbraco-cms/backoffice/external/backend-api';
7+
8+
/**
9+
* Make an XHR request.
10+
* @param host The controller host for this controller to be appended to.
11+
* @param options The options for the XHR request.
12+
*/
13+
export function tryXhrRequest<T>(host: UmbControllerHost, options: XhrRequestOptions): CancelablePromise<T> {
14+
return UmbResourceController.xhrRequest<T>({
15+
...options,
16+
baseUrl: OpenAPI.BASE,
17+
async token() {
18+
const contextConsumer = new UmbContextConsumerController(host, UMB_AUTH_CONTEXT).asPromise();
19+
const authContext = await contextConsumer;
20+
return authContext.getLatestToken();
21+
},
22+
});
23+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
export interface XhrRequestOptions {
2+
baseUrl?: string;
3+
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS';
4+
url: string;
5+
body?: unknown;
6+
token?: string | (() => string | Promise<string>);
7+
headers?: Record<string, string>;
8+
responseHeader?: string;
9+
onProgress?: (event: ProgressEvent) => void;
10+
}

0 commit comments

Comments
 (0)