diff --git a/Build/Sources/TypeScript/core/Resources/Public/TypeScript/Ajax/AjaxRequest.ts b/Build/Sources/TypeScript/core/Resources/Public/TypeScript/Ajax/AjaxRequest.ts new file mode 100644 index 0000000000000000000000000000000000000000..472f0bc2b9c84260e35cc9098c06a4e7b020a0c9 --- /dev/null +++ b/Build/Sources/TypeScript/core/Resources/Public/TypeScript/Ajax/AjaxRequest.ts @@ -0,0 +1,224 @@ +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +import JQueryNativePromises from '../BackwardCompat/JQueryNativePromises'; +import {AjaxResponse} from './AjaxResponse'; +import {ResponseError} from './ResponseError'; + +type GenericKeyValue = { [key: string]: any}; + +class AjaxRequest { + private static defaultOptions: RequestInit = { + credentials: 'same-origin' + }; + + private readonly url: string; + private readonly abortController: AbortController; + private queryArguments: string = ''; + + /** + * Transforms the incoming object to a flat FormData object used for POST and PUT + * + * @param {GenericKeyValue} data + * @return {FormData} + */ + private static transformToFormData(data: GenericKeyValue): FormData { + const flattenObject = (obj: GenericKeyValue, prefix: string = '') => + Object.keys(obj).reduce((acc: GenericKeyValue, k: any) => { + const objPrefix = prefix.length ? prefix + '[' : ''; + const objSuffix = prefix.length ? ']' : ''; + if (typeof obj[k] === 'object') { + Object.assign(acc, flattenObject(obj[k], objPrefix + k + objSuffix)) + } else { + acc[objPrefix + k + objSuffix] = obj[k] + } + return acc; + }, {}); + + const flattenedData = flattenObject(data); + const formData = new FormData(); + for (const [key, value] of Object.entries(flattenedData)) { + formData.set(key, value); + } + + return formData; + } + + /** + * Creates a query string appended to the URL from either a string (returned as is), an array or an object + * + * @param {string|array|GenericKeyValue} data + * @param {string} prefix Internal argument used for nested objects + * @return {string} + */ + private static createQueryString(data: string | Array<string> | GenericKeyValue, prefix?: string): string { + if (typeof data === 'string') { + return data; + } + + if (data instanceof Array) { + return data.join('&'); + } + + return Object.keys(data).map((key: string) => { + let pKey = prefix ? `${prefix}[${key}]` : key; + let val = data[key]; + if (typeof val === 'object') { + return AjaxRequest.createQueryString(val, pKey); + } + + return `${pKey}=${encodeURIComponent(`${val}`)}` + }).join('&') + } + + constructor(url: string) { + this.url = url; + this.abortController = new AbortController(); + + JQueryNativePromises.support(); + } + + /** + * Clones the AjaxRequest object, generates the final query string and uses it for the request + * + * @param {string|array|GenericKeyValue} data + * @return {AjaxRequest} + */ + public withQueryArguments(data: string | Array<string> | GenericKeyValue): AjaxRequest { + const clone = this.clone(); + clone.queryArguments = (clone.queryArguments !== '' ? '&' : '') + AjaxRequest.createQueryString(data); + return clone; + } + + /** + * Executes a regular GET request + * + * @param {RequestInit} init + * @return {Promise<Response>} + */ + public async get(init: RequestInit = {}): Promise<AjaxResponse> { + const localDefaultOptions: RequestInit = { + method: 'GET', + }; + + const response = await this.send({...localDefaultOptions, ...init}); + return new AjaxResponse(response); + } + + /** + * Executes a (by default uncached) POST request + * + * @param {GenericKeyValue} data + * @param {RequestInit} init + * @return {Promise<Response>} + */ + public async post(data: GenericKeyValue, init: RequestInit = {}): Promise<AjaxResponse> { + const localDefaultOptions: RequestInit = { + body: AjaxRequest.transformToFormData(data), + cache: 'no-cache', + method: 'POST', + }; + + const response = await this.send({...localDefaultOptions, ...init}); + return new AjaxResponse(response); + } + + /** + * Executes a (by default uncached) PUT request + * + * @param {GenericKeyValue} data + * @param {RequestInit} init + * @return {Promise<Response>} + */ + public async put(data: GenericKeyValue, init: RequestInit = {}): Promise<AjaxResponse> { + const localDefaultOptions: RequestInit = { + body: AjaxRequest.transformToFormData(data), + cache: 'no-cache', + method: 'PUT', + }; + + const response = await this.send({...localDefaultOptions, ...init}); + return new AjaxResponse(response); + } + + /** + * Executes a regular DELETE request + * + * @param {GenericKeyValue} data + * @param {RequestInit} init + * @return {Promise<Response>} + */ + public async delete(data: GenericKeyValue = {}, init: RequestInit = {}): Promise<AjaxResponse> { + const localDefaultOptions: RequestInit = { + cache: 'no-cache', + method: 'DELETE', + }; + + if (typeof data === 'object' && Object.keys(data).length > 0) { + localDefaultOptions.body = AjaxRequest.transformToFormData(data); + } + + const response = await this.send({...localDefaultOptions, ...init}); + return new AjaxResponse(response); + } + + /** + * Gets an instance of AbortController used to abort the current request + * + * @return {AbortController} + */ + public getAbort(): AbortController { + return this.abortController; + } + + /** + * Clones the current AjaxRequest object + * + * @return {AjaxRequest} + */ + private clone(): AjaxRequest { + return Object.assign(Object.create(this), this); + } + + /** + * Sends the requests by using the fetch API + * + * @param {RequestInit} init + * @return {Promise<Response>} + */ + private async send(init: RequestInit = {}): Promise<Response> { + // Sanitize URL into a generic format, e.g. ensure a domain only url contains a trailing slash + let url = new URL(this.url).toString(); + if (this.queryArguments !== '') { + const delimiter = !this.url.includes('?') ? '?' : '&'; + url += delimiter + this.queryArguments; + } + const response = await fetch(url, this.getMergedOptions(init)); + if (!response.ok) { + throw new ResponseError(response); + } + return response; + } + + /** + * Merge the incoming RequestInit object with the pre-defined default options + * + * @param {RequestInit} init + * @return {RequestInit} + */ + private getMergedOptions(init: RequestInit): RequestInit { + return {...AjaxRequest.defaultOptions, ...init, signal: this.abortController.signal}; + } +} + +export = AjaxRequest; diff --git a/Build/Sources/TypeScript/core/Resources/Public/TypeScript/Ajax/AjaxResponse.ts b/Build/Sources/TypeScript/core/Resources/Public/TypeScript/Ajax/AjaxResponse.ts new file mode 100644 index 0000000000000000000000000000000000000000..338e2a7f89eac182ac3c0785791a84722a68250b --- /dev/null +++ b/Build/Sources/TypeScript/core/Resources/Public/TypeScript/Ajax/AjaxResponse.ts @@ -0,0 +1,31 @@ +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +export class AjaxResponse { + private readonly response: Response; + + constructor(response: Response) { + this.response = response; + } + + public async resolve(): Promise<any> { + if (this.response.headers.has('Content-Type') && this.response.headers.get('Content-Type').includes('application/json')) { + return await this.response.json(); + } + return await this.response.text(); + } + + public raw(): Response { + return this.response; + } +} diff --git a/Build/Sources/TypeScript/core/Resources/Public/TypeScript/Ajax/ResponseError.ts b/Build/Sources/TypeScript/core/Resources/Public/TypeScript/Ajax/ResponseError.ts new file mode 100644 index 0000000000000000000000000000000000000000..e3f71e66b781126290027dfff86ed4ccb8a8e142 --- /dev/null +++ b/Build/Sources/TypeScript/core/Resources/Public/TypeScript/Ajax/ResponseError.ts @@ -0,0 +1,20 @@ +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +export class ResponseError { + public readonly response: Response; + + constructor(response: Response) { + this.response = response; + } +} diff --git a/Build/Sources/TypeScript/core/Resources/Public/TypeScript/BackwardCompat/JQueryNativePromises.ts b/Build/Sources/TypeScript/core/Resources/Public/TypeScript/BackwardCompat/JQueryNativePromises.ts new file mode 100644 index 0000000000000000000000000000000000000000..3d05cd3481ae65fe35f0fd2ab3bf6930c3dd9ac2 --- /dev/null +++ b/Build/Sources/TypeScript/core/Resources/Public/TypeScript/BackwardCompat/JQueryNativePromises.ts @@ -0,0 +1,39 @@ +/* +* This file is part of the TYPO3 CMS project. +* +* It is free software; you can redistribute it and/or modify it under +* the terms of the GNU General Public License, either version 2 +* of the License, or any later version. +* +* For the full copyright and license information, please read the +* LICENSE.txt file that was distributed with this source code. +* +* The TYPO3 project - inspiring people to share! +*/ + +/** + * Introduces a polyfill to support jQuery callbacks in native promises. This approach has been adopted from + */ +/*! Based on https://www.promisejs.org/polyfills/promise-done-7.0.4.js */ +export default class JQueryNativePromises { + public static support(): void { + if (typeof Promise.prototype.done !== 'function') { + Promise.prototype.done = function (onFulfilled: Function): Promise<any> { + return arguments.length ? this.then.apply(this, arguments) : Promise.prototype.then; + }; + } + + if (typeof Promise.prototype.fail !== 'function') { + Promise.prototype.fail = function (onRejected: Function): Promise<any> { + const self = arguments.length ? this.catch.apply(this, arguments) : Promise.prototype.catch; + self.catch(function (err: string) { + setTimeout(function () { + throw err + }, 0) + }); + + return self; + }; + } + } +} diff --git a/Build/Sources/TypeScript/core/Resources/Public/TypeScript/BackwardCompat/index.d.ts b/Build/Sources/TypeScript/core/Resources/Public/TypeScript/BackwardCompat/index.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..270712d2dc48e7606b204d83b33899395b5b11d7 --- /dev/null +++ b/Build/Sources/TypeScript/core/Resources/Public/TypeScript/BackwardCompat/index.d.ts @@ -0,0 +1,7 @@ +interface Promise<T> { + // This is a copy from TypeScript'slib.es5.d.ts and an added `done` for jQuery legacy reasons + done?<TResult1 = T, TResult2 = never>(onfulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | undefined | null, onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | undefined | null): Promise<TResult1 | TResult2>; + then<TResult1 = T, TResult2 = never>(onfulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | undefined | null, onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | undefined | null): Promise<TResult1 | TResult2>; + fail?<TResult = never>(onrejected?: ((reason: any) => TResult | PromiseLike<TResult>) | undefined | null): Promise<T | TResult>; + catch<TResult = never>(onrejected?: ((reason: any) => TResult | PromiseLike<TResult>) | undefined | null): Promise<T | TResult>; +} diff --git a/Build/Sources/TypeScript/core/Tests/Ajax/AjaxRequestTest.ts b/Build/Sources/TypeScript/core/Tests/Ajax/AjaxRequestTest.ts new file mode 100644 index 0000000000000000000000000000000000000000..d284eef16122e0fa23c6404aa45189c66f999526 --- /dev/null +++ b/Build/Sources/TypeScript/core/Tests/Ajax/AjaxRequestTest.ts @@ -0,0 +1,136 @@ +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +import AjaxRequest = require('TYPO3/CMS/Core/Ajax/AjaxRequest'); +import {AjaxResponse} from 'TYPO3/CMS/Core/Ajax/AjaxResponse'; + +describe('TYPO3/CMS/Core/Ajax/AjaxRequest', (): void => { + let promiseHelper: any; + + beforeEach((): void => { + const fetchPromise: Promise<Response> = new Promise(((resolve: Function, reject: Function): void => { + promiseHelper = { + resolve: resolve, + reject: reject, + } + })); + spyOn(window, 'fetch').and.returnValue(fetchPromise); + }); + + it('sends GET request', (): void => { + (new AjaxRequest('https://example.com')).get(); + expect(window.fetch).toHaveBeenCalledWith('https://example.com/', jasmine.objectContaining({method: 'GET'})); + }); + + it('sends POST request', (): void => { + const payload = {foo: 'bar', bar: 'baz', nested: {works: 'yes'}}; + const expected = new FormData(); + expected.set('foo', 'bar'); + expected.set('bar', 'baz'); + expected.set('nested[works]', 'yes'); + (new AjaxRequest('https://example.com')).post(payload); + expect(window.fetch).toHaveBeenCalledWith('https://example.com/', jasmine.objectContaining({method: 'POST', body: expected})); + }); + + it('sends PUT request', (): void => { + (new AjaxRequest('https://example.com')).put({}); + expect(window.fetch).toHaveBeenCalledWith('https://example.com/', jasmine.objectContaining({method: 'PUT'})); + }); + + it('sends DELETE request', (): void => { + (new AjaxRequest('https://example.com')).delete(); + expect(window.fetch).toHaveBeenCalledWith('https://example.com/', jasmine.objectContaining({method: 'DELETE'})); + }); + + describe('send GET requests', (): void => { + function* responseDataProvider(): any { + yield [ + 'plaintext', + 'foobar huselpusel', + {}, + (data: any, responseBody: any): void => { + expect(typeof data === 'string').toBeTruthy(); + expect(data).toEqual(responseBody); + expect(window.fetch).toHaveBeenCalledWith(jasmine.any(String), jasmine.objectContaining({method: 'GET'})); + } + ]; + yield [ + 'JSON', + JSON.stringify({foo: 'bar', baz: 'bencer'}), + {'Content-Type': 'application/json'}, + (data: any, responseBody: any): void => { + expect(typeof data === 'object').toBeTruthy(); + expect(JSON.stringify(data)).toEqual(responseBody); + expect(window.fetch).toHaveBeenCalledWith(jasmine.any(String), jasmine.objectContaining({method: 'GET'})); + } + ]; + yield [ + 'JSON with utf-8', + JSON.stringify({foo: 'bar', baz: 'bencer'}), + {'Content-Type': 'application/json; charset=utf-8'}, + (data: any, responseBody: any): void => { + expect(typeof data === 'object').toBeTruthy(); + expect(JSON.stringify(data)).toEqual(responseBody); + expect(window.fetch).toHaveBeenCalledWith(jasmine.any(String), jasmine.objectContaining({method: 'GET'})); + } + ]; + } + + for (let providedData of responseDataProvider()) { + let [name, responseText, headers, onfulfill] = providedData; + it('receives a ' + name + ' response', (done: DoneFn): void => { + const response = new Response(responseText, {headers: headers}); + promiseHelper.resolve(response); + + (new AjaxRequest('https://example.com')).get().then(async (response: AjaxResponse): Promise<any> => { + const data = await response.resolve(); + onfulfill(data, responseText); + done(); + }) + }); + } + }); + + describe('send requests with query arguments', (): void => { + function* queryArgumentsDataProvider(): any { + yield [ + 'single level of arguments', + {foo: 'bar', bar: 'baz'}, + 'https://example.com/?foo=bar&bar=baz', + ]; + yield [ + 'nested arguments', + {foo: 'bar', bar: {baz: 'bencer'}}, + 'https://example.com/?foo=bar&bar[baz]=bencer', + ]; + yield [ + 'string argument', + 'hello=world&foo=bar', + 'https://example.com/?hello=world&foo=bar', + ]; + yield [ + 'array of arguments', + ['foo=bar', 'husel=pusel'], + 'https://example.com/?foo=bar&husel=pusel', + ] + } + + for (let providedData of queryArgumentsDataProvider()) { + let [name, input, expected] = providedData; + it('with ' + name, (): void => { + (new AjaxRequest('https://example.com')).withQueryArguments(input).get(); + expect(window.fetch).toHaveBeenCalledWith(expected, jasmine.objectContaining({method: 'GET'})); + }); + } + }); +}); diff --git a/typo3/sysext/core/Documentation/Changelog/master/Feature-89738-ApiForAjaxRequests.rst b/typo3/sysext/core/Documentation/Changelog/master/Feature-89738-ApiForAjaxRequests.rst new file mode 100644 index 0000000000000000000000000000000000000000..36c18a3b074f1660d9ba26810d2a793bd27ed42f --- /dev/null +++ b/typo3/sysext/core/Documentation/Changelog/master/Feature-89738-ApiForAjaxRequests.rst @@ -0,0 +1,217 @@ +.. include:: ../../Includes.txt + +======================================= +Feature: #89738 - API for AJAX Requests +======================================= + +See :issue:`89738` + +Description +=========== + +Request +------- + +In order to become independent of jQuery, a new API to perform AJAX requests has been introduced. This API implements +the `fetch API`_ available in all modern browsers. + +To send a request, a new instance of `AjaxRequest` must be created which receives a single argument: + +* :js:`url` (string) - The endpoint to send the request to + +For compatibility reasons the :js:`Promise` prototype is extended to have basic support for jQuery's `$.Deferred()`. +In all erroneous cases, the internal promise is rejected with an instance of `ResponseError` containing the original +`response object`_. + +withQueryArguments() +~~~~~~~~~~~~~~~~~~~~ + +Clones the current request object and sets query arguments used for requests that get sent. + +This method receives the following arguments: + +* :js:`queryArguments` (string | array | object) - Optional: Query arguments to append to the url + +The method returns a clone of the AjaxRequest instance. + + +get() +~~~~~ + +Sends a `GET` requests to the configured endpoint. + +This method receives the following arguments: + +* :js:`init` (object) - Optional: additional `request configuration`_ for the request object used by :js:`fetch()` + +The method returns a promise resolved to a `AjaxResponse`. + +Example: + +.. code-block:: js + + require(['TYPO3/CMS/Core/Ajax/AjaxRequest'], function (AjaxRequest) { + const request = new AjaxRequest('https://httpbin.org/json'); + request.get().then( + async function (response) { + const data = await response.resolve(); + console.log(data); + }, function (error) { + console.error('Request failed because of error: ' + error.status + ' ' + error.statusText); + } + ); + }); + + +post() +~~~~~~ + +Sends a `POST` requests to the configured endpoint. All responses are uncached by default. + +This method receives the following arguments: + +* :js:`data` (object) - Request body sent to the endpoint, get's converted to :js:`FormData` +* :js:`init` (object) - Optional: additional `request configuration`_ for the request object used by :js:`fetch()` + +The method returns a promise resolved to a `AjaxResponse`. + +Example: + +.. code-block:: js + + require(['TYPO3/CMS/Core/Ajax/AjaxRequest'], function (AjaxRequest) { + const body = { + foo: 'bar', + baz: 'quo' + }; + const init = { + mode: 'cors' + }; + const request = new AjaxRequest('https://example.com'); + request.post(body, init).then( + async function (response) { + console.log('Data has been sent'); + }, function (error) { + console.error('Request failed because of error: ' + error.status + ' ' + error.statusText); + } + ); + }); + + +put() +~~~~~ + +Sends a `PUT` requests to the configured endpoint. All responses are uncached by default. + +This method receives the following arguments: + +* :js:`data` (object) - Request body sent to the endpoint, get's converted to :js:`FormData` +* :js:`init` (object) - Optional: additional `request configuration`_ for the request object used by :js:`fetch()` + +The method returns a promise resolved to a `AjaxResponse`. + +Example: + +.. code-block:: js + + require(['TYPO3/CMS/Core/Ajax/AjaxRequest'], function (AjaxRequest) { + const fileField = document.querySelector('input[type="file"]'); + const body = { + file: fileField.files[0], + username: 'Baz Bencer' + }; + const request = new AjaxRequest('https://example.com'); + request.put(body).then(null, function (error) { + console.error('Request failed because of error: ' + error.status + ' ' + error.statusText); + }); + }); + + +delete() +~~~~~~~~ + +Sends a `DELETE` requests to the configured endpoint. All responses are uncached by default. + +This method receives the following arguments: + +* :js:`data` (object) - Request body sent to the endpoint, get's converted to :js:`FormData` +* :js:`init` (object) - Optional: additional `request configuration`_ for the request object used by :js:`fetch()` + +The method returns a promise resolved to a `AjaxResponse`. + +Example: + +.. code-block:: js + + require(['TYPO3/CMS/Core/Ajax/AjaxRequest'], function (AjaxRequest) { + const request = new AjaxRequest('https://httpbin.org/delete'); + request.delete().then(null, function (error) { + console.error('Request failed because of error: ' + error.status + ' ' + error.statusText); + } + ); + }); + + +getAbort() +~~~~~~~~~~ + +Returns an instance of `AbortController`_ that can be used to abort the request. + + +Response +-------- + +Each response received is wrapped in an :js:`AjaxResponse` object. This object contains some methods to handle the response. + +resolve() +~~~~~~~~~ + +Converts and returns the response body according to the **received** `Content-Type` header either into JSON or plaintext. + +Example: + +.. code-block:: js + + require(['TYPO3/CMS/Core/Ajax/AjaxRequest'], function (AjaxRequest) { + new AjaxRequest('https://httpbin.org/json').get().then( + async function (response) { + // Response is automatically converted into a JSON object + const data = await response.resolve(); + console.log(data); + }, function (error) { + console.error('Request failed because of error: ' + error.status + ' ' + error.statusText); + } + ); + }); + + +raw() +~~~~~ + +Returns the original response object, which is useful for e.g. add additional handling for specific headers in application +logic or to check the response status. + +Example: + +.. code-block:: js + + require(['TYPO3/CMS/Core/Ajax/AjaxRequest'], function (AjaxRequest) { + new AjaxRequest('https://httpbin.org/status/200').get().then( + function (response) { + const raw = response.raw(); + if (raw.headers.get('Content-Type') !== 'application/json') { + console.warn('We didn\'t receive JSON, check your request.'); + } + }, function (error) { + console.error('Request failed because of error: ' + error.status + ' ' + error.statusText); + } + ); + }); + + +.. _`fetch API`: https://developer.mozilla.org/docs/Web/API/Fetch_API +.. _`request configuration`: https://developer.mozilla.org/en-US/docs/Web/API/Request#Properties +.. _`response object`: https://developer.mozilla.org/en-US/docs/Web/API/Response +.. _`AbortController`: https://developer.mozilla.org/en-US/docs/Web/API/AbortController + +.. index:: JavaScript, ext:core diff --git a/typo3/sysext/core/Resources/Public/JavaScript/Ajax/AjaxRequest.js b/typo3/sysext/core/Resources/Public/JavaScript/Ajax/AjaxRequest.js new file mode 100644 index 0000000000000000000000000000000000000000..bfa8eeafb7da918e4fb6791d322d47c3a1c7c29c --- /dev/null +++ b/typo3/sysext/core/Resources/Public/JavaScript/Ajax/AjaxRequest.js @@ -0,0 +1,13 @@ +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ +var __awaiter=this&&this.__awaiter||function(t,e,n,r){return new(n||(n=Promise))((function(s,o){function i(t){try{c(r.next(t))}catch(t){o(t)}}function a(t){try{c(r.throw(t))}catch(t){o(t)}}function c(t){var e;t.done?s(t.value):(e=t.value,e instanceof n?e:new n((function(t){t(e)}))).then(i,a)}c((r=r.apply(t,e||[])).next())}))};define(["require","exports","../BackwardCompat/JQueryNativePromises","./AjaxResponse","./ResponseError"],(function(t,e,n,r,s){"use strict";class o{constructor(t){this.queryArguments="",this.url=t,this.abortController=new AbortController,n.default.support()}static transformToFormData(t){const e=(t,n="")=>Object.keys(t).reduce((r,s)=>{const o=n.length?n+"[":"",i=n.length?"]":"";return"object"==typeof t[s]?Object.assign(r,e(t[s],o+s+i)):r[o+s+i]=t[s],r},{}),n=e(t),r=new FormData;for(const[t,e]of Object.entries(n))r.set(t,e);return r}static createQueryString(t,e){return"string"==typeof t?t:t instanceof Array?t.join("&"):Object.keys(t).map(n=>{let r=e?`${e}[${n}]`:n,s=t[n];return"object"==typeof s?o.createQueryString(s,r):`${r}=${encodeURIComponent(`${s}`)}`}).join("&")}withQueryArguments(t){const e=this.clone();return e.queryArguments=(""!==e.queryArguments?"&":"")+o.createQueryString(t),e}get(t={}){return __awaiter(this,void 0,void 0,(function*(){const e=yield this.send(Object.assign(Object.assign({},{method:"GET"}),t));return new r.AjaxResponse(e)}))}post(t,e={}){return __awaiter(this,void 0,void 0,(function*(){const n={body:o.transformToFormData(t),cache:"no-cache",method:"POST"},s=yield this.send(Object.assign(Object.assign({},n),e));return new r.AjaxResponse(s)}))}put(t,e={}){return __awaiter(this,void 0,void 0,(function*(){const n={body:o.transformToFormData(t),cache:"no-cache",method:"PUT"},s=yield this.send(Object.assign(Object.assign({},n),e));return new r.AjaxResponse(s)}))}delete(t={},e={}){return __awaiter(this,void 0,void 0,(function*(){const n={cache:"no-cache",method:"DELETE"};"object"==typeof t&&Object.keys(t).length>0&&(n.body=o.transformToFormData(t));const s=yield this.send(Object.assign(Object.assign({},n),e));return new r.AjaxResponse(s)}))}getAbort(){return this.abortController}clone(){return Object.assign(Object.create(this),this)}send(t={}){return __awaiter(this,void 0,void 0,(function*(){let e=new URL(this.url).toString();if(""!==this.queryArguments){e+=(this.url.includes("?")?"&":"?")+this.queryArguments}const n=yield fetch(e,this.getMergedOptions(t));if(!n.ok)throw new s.ResponseError(n);return n}))}getMergedOptions(t){return Object.assign(Object.assign(Object.assign({},o.defaultOptions),t),{signal:this.abortController.signal})}}return o.defaultOptions={credentials:"same-origin"},o})); \ No newline at end of file diff --git a/typo3/sysext/core/Resources/Public/JavaScript/Ajax/AjaxResponse.js b/typo3/sysext/core/Resources/Public/JavaScript/Ajax/AjaxResponse.js new file mode 100644 index 0000000000000000000000000000000000000000..e209412972b9e9b30a0c35ed5ba1014370c025a3 --- /dev/null +++ b/typo3/sysext/core/Resources/Public/JavaScript/Ajax/AjaxResponse.js @@ -0,0 +1,13 @@ +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ +var __awaiter=this&&this.__awaiter||function(e,t,n,s){return new(n||(n=Promise))((function(r,i){function o(e){try{c(s.next(e))}catch(e){i(e)}}function a(e){try{c(s.throw(e))}catch(e){i(e)}}function c(e){var t;e.done?r(e.value):(t=e.value,t instanceof n?t:new n((function(e){e(t)}))).then(o,a)}c((s=s.apply(e,t||[])).next())}))};define(["require","exports"],(function(e,t){"use strict";Object.defineProperty(t,"__esModule",{value:!0});t.AjaxResponse=class{constructor(e){this.response=e}resolve(){return __awaiter(this,void 0,void 0,(function*(){return this.response.headers.has("Content-Type")&&this.response.headers.get("Content-Type").includes("application/json")?yield this.response.json():yield this.response.text()}))}raw(){return this.response}}})); \ No newline at end of file diff --git a/typo3/sysext/core/Resources/Public/JavaScript/Ajax/ResponseError.js b/typo3/sysext/core/Resources/Public/JavaScript/Ajax/ResponseError.js new file mode 100644 index 0000000000000000000000000000000000000000..da1e5431c1171c52ae6c480490c13ce5e1bfd247 --- /dev/null +++ b/typo3/sysext/core/Resources/Public/JavaScript/Ajax/ResponseError.js @@ -0,0 +1,13 @@ +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ +define(["require","exports"],(function(e,r){"use strict";Object.defineProperty(r,"__esModule",{value:!0});r.ResponseError=class{constructor(e){this.response=e}}})); \ No newline at end of file diff --git a/typo3/sysext/core/Resources/Public/JavaScript/BackwardCompat/JQueryNativePromises.js b/typo3/sysext/core/Resources/Public/JavaScript/BackwardCompat/JQueryNativePromises.js new file mode 100644 index 0000000000000000000000000000000000000000..ac46faaf027758aff180e5778a856907e0d0874c --- /dev/null +++ b/typo3/sysext/core/Resources/Public/JavaScript/BackwardCompat/JQueryNativePromises.js @@ -0,0 +1,15 @@ +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ +define(["require","exports"],(function(t,e){"use strict";Object.defineProperty(e,"__esModule",{value:!0});e.default= +/*! Based on https://www.promisejs.org/polyfills/promise-done-7.0.4.js */ +class{static support(){"function"!=typeof Promise.prototype.done&&(Promise.prototype.done=function(t){return arguments.length?this.then.apply(this,arguments):Promise.prototype.then}),"function"!=typeof Promise.prototype.fail&&(Promise.prototype.fail=function(t){const e=arguments.length?this.catch.apply(this,arguments):Promise.prototype.catch;return e.catch((function(t){setTimeout((function(){throw t}),0)})),e})}}})); \ No newline at end of file diff --git a/typo3/sysext/core/Tests/JavaScript/Ajax/AjaxRequestTest.js b/typo3/sysext/core/Tests/JavaScript/Ajax/AjaxRequestTest.js new file mode 100644 index 0000000000000000000000000000000000000000..aa3b6a8de43b6b9fb16dd3caf36c21de71b6cee6 --- /dev/null +++ b/typo3/sysext/core/Tests/JavaScript/Ajax/AjaxRequestTest.js @@ -0,0 +1,13 @@ +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ +var __awaiter=this&&this.__awaiter||function(e,t,o,n){return new(o||(o=Promise))((function(a,i){function s(e){try{c(n.next(e))}catch(e){i(e)}}function r(e){try{c(n.throw(e))}catch(e){i(e)}}function c(e){var t;e.done?a(e.value):(t=e.value,t instanceof o?t:new o((function(e){e(t)}))).then(s,r)}c((n=n.apply(e,t||[])).next())}))};define(["require","exports","TYPO3/CMS/Core/Ajax/AjaxRequest"],(function(e,t,o){"use strict";Object.defineProperty(t,"__esModule",{value:!0}),describe("TYPO3/CMS/Core/Ajax/AjaxRequest",()=>{let e;beforeEach(()=>{const t=new Promise((t,o)=>{e={resolve:t,reject:o}});spyOn(window,"fetch").and.returnValue(t)}),it("sends GET request",()=>{new o("https://example.com").get(),expect(window.fetch).toHaveBeenCalledWith("https://example.com/",jasmine.objectContaining({method:"GET"}))}),it("sends POST request",()=>{const e=new FormData;e.set("foo","bar"),e.set("bar","baz"),e.set("nested[works]","yes"),new o("https://example.com").post({foo:"bar",bar:"baz",nested:{works:"yes"}}),expect(window.fetch).toHaveBeenCalledWith("https://example.com/",jasmine.objectContaining({method:"POST",body:e}))}),it("sends PUT request",()=>{new o("https://example.com").put({}),expect(window.fetch).toHaveBeenCalledWith("https://example.com/",jasmine.objectContaining({method:"PUT"}))}),it("sends DELETE request",()=>{new o("https://example.com").delete(),expect(window.fetch).toHaveBeenCalledWith("https://example.com/",jasmine.objectContaining({method:"DELETE"}))}),describe("send GET requests",()=>{for(let t of function*(){yield["plaintext","foobar huselpusel",{},(e,t)=>{expect("string"==typeof e).toBeTruthy(),expect(e).toEqual(t),expect(window.fetch).toHaveBeenCalledWith(jasmine.any(String),jasmine.objectContaining({method:"GET"}))}],yield["JSON",JSON.stringify({foo:"bar",baz:"bencer"}),{"Content-Type":"application/json"},(e,t)=>{expect("object"==typeof e).toBeTruthy(),expect(JSON.stringify(e)).toEqual(t),expect(window.fetch).toHaveBeenCalledWith(jasmine.any(String),jasmine.objectContaining({method:"GET"}))}],yield["JSON with utf-8",JSON.stringify({foo:"bar",baz:"bencer"}),{"Content-Type":"application/json; charset=utf-8"},(e,t)=>{expect("object"==typeof e).toBeTruthy(),expect(JSON.stringify(e)).toEqual(t),expect(window.fetch).toHaveBeenCalledWith(jasmine.any(String),jasmine.objectContaining({method:"GET"}))}]}()){let[n,a,i,s]=t;it("receives a "+n+" response",t=>{const n=new Response(a,{headers:i});e.resolve(n),new o("https://example.com").get().then(e=>__awaiter(void 0,void 0,void 0,(function*(){const o=yield e.resolve();s(o,a),t()})))})}}),describe("send requests with query arguments",()=>{for(let e of function*(){yield["single level of arguments",{foo:"bar",bar:"baz"},"https://example.com/?foo=bar&bar=baz"],yield["nested arguments",{foo:"bar",bar:{baz:"bencer"}},"https://example.com/?foo=bar&bar[baz]=bencer"],yield["string argument","hello=world&foo=bar","https://example.com/?hello=world&foo=bar"],yield["array of arguments",["foo=bar","husel=pusel"],"https://example.com/?foo=bar&husel=pusel"]}()){let[t,n,a]=e;it("with "+t,()=>{new o("https://example.com").withQueryArguments(n).get(),expect(window.fetch).toHaveBeenCalledWith(a,jasmine.objectContaining({method:"GET"}))})}})})})); \ No newline at end of file