diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index a14f813bcd1..84162cee094 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -24,6 +24,7 @@ ->append([__FILE__]) ->notPath('#/Fixtures/#') ->notPath('#/app/var/#') + ->notPath('#/var/cache/#') ->notPath('Turbo/Attribute/Broadcast.php') // Need https://github.com/FriendsOfPHP/PHP-CS-Fixer/issues/4702 ) ; diff --git a/package.json b/package.json index a3c45faaea8..4f79006e47b 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,8 @@ "plugin:@typescript-eslint/recommended" ], "rules": { - "@typescript-eslint/no-explicit-any": "off" + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-empty-function": "off" }, "env": { "browser": true diff --git a/src/LiveComponent/CHANGELOG.md b/src/LiveComponent/CHANGELOG.md index 0b766c29c2c..810078b8203 100644 --- a/src/LiveComponent/CHANGELOG.md +++ b/src/LiveComponent/CHANGELOG.md @@ -2,6 +2,11 @@ ## 2.1.0 +- Your component's live "data" is now send over Ajax as a JSON string. + Previously data was sent as pure query parameters or as pure POST data. + However, this made it impossible to keep certain data types, like + distinguishing between `null` and `''`. This has no impact on end-users. + - Added `data-live-ignore` attribute. If included in an element, that element will not be updated on re-render. diff --git a/src/LiveComponent/assets/dist/live_controller.js b/src/LiveComponent/assets/dist/live_controller.js index 7834400b096..ddc36e20d09 100644 --- a/src/LiveComponent/assets/dist/live_controller.js +++ b/src/LiveComponent/assets/dist/live_controller.js @@ -898,61 +898,6 @@ function combineSpacedArray(parts) { return finalParts; } -const buildFormKey = function (key, parentKeys) { - let fieldName = ''; - [...parentKeys, key].forEach((name) => { - fieldName += fieldName ? `[${name}]` : name; - }); - return fieldName; -}; -const addObjectToFormData = function (formData, data, parentKeys) { - Object.keys(data).forEach((key => { - let value = data[key]; - if (value === true) { - value = 1; - } - if (value === false) { - value = 0; - } - if (value === null) { - return; - } - if (typeof value === 'object') { - addObjectToFormData(formData, value, [...parentKeys, key]); - return; - } - formData.append(buildFormKey(key, parentKeys), value); - })); -}; -const addObjectToSearchParams = function (searchParams, data, parentKeys) { - Object.keys(data).forEach((key => { - let value = data[key]; - if (value === true) { - value = 1; - } - if (value === false) { - value = 0; - } - if (value === null) { - return; - } - if (typeof value === 'object') { - addObjectToSearchParams(searchParams, value, [...parentKeys, key]); - return; - } - searchParams.set(buildFormKey(key, parentKeys), value); - })); -}; -function buildFormData(data) { - const formData = new FormData(); - addObjectToFormData(formData, data, []); - return formData; -} -function buildSearchParams(searchParams, data) { - addObjectToSearchParams(searchParams, data, []); - return searchParams; -} - function setDeepData(data, propertyPath, value) { const finalData = JSON.parse(JSON.stringify(data)); let currentLevelData = finalData; @@ -1200,13 +1145,19 @@ class default_1 extends Controller { fetchOptions.headers['X-CSRF-TOKEN'] = this.csrfValue; } } - if (!action && this._willDataFitInUrl()) { - buildSearchParams(params, this.dataValue); - fetchOptions.method = 'GET'; + let dataAdded = false; + if (!action) { + const dataJson = JSON.stringify(this.dataValue); + if (this._willDataFitInUrl(dataJson, params)) { + params.set('data', dataJson); + fetchOptions.method = 'GET'; + dataAdded = true; + } } - else { + if (!dataAdded) { fetchOptions.method = 'POST'; - fetchOptions.body = buildFormData(this.dataValue); + fetchOptions.body = JSON.stringify(this.dataValue); + fetchOptions.headers['Content-Type'] = 'application/json'; } this._onLoadingStart(); const paramsString = params.toString(); @@ -1358,8 +1309,9 @@ class default_1 extends Controller { element.removeAttribute(attribute); }); } - _willDataFitInUrl() { - return Object.values(this.dataValue).join(',').length < 1500; + _willDataFitInUrl(dataJson, params) { + const urlEncodedJsonData = new URLSearchParams(dataJson).toString(); + return (urlEncodedJsonData + params.toString()).length < 1500; } _executeMorphdom(newHtml) { function htmlToElement(html) { diff --git a/src/LiveComponent/assets/src/http_data_helper.ts b/src/LiveComponent/assets/src/http_data_helper.ts deleted file mode 100644 index b52ac9d0229..00000000000 --- a/src/LiveComponent/assets/src/http_data_helper.ts +++ /dev/null @@ -1,94 +0,0 @@ -/* - * Helper to convert a deep object of data into a format - * that can be transmitted as GET or POST data. - * - * Likely there is an easier way to do this with no duplication. - */ - -const buildFormKey = function(key: string, parentKeys: string[]) { - let fieldName = ''; - [...parentKeys, key].forEach((name) => { - fieldName += fieldName ? `[${name}]` : name; - }); - - return fieldName; -} - -const addObjectToFormData = function(formData: FormData, data: any, parentKeys: string[]) { - // todo - handles files - Object.keys(data).forEach((key => { - let value = data[key]; - - // TODO: there is probably a better way to normalize this - if (value === true) { - value = 1; - } - if (value === false) { - value = 0; - } - // don't send null values at all - if (value === null) { - return; - } - - // handle embedded objects - if (typeof value === 'object') { - addObjectToFormData(formData, value, [...parentKeys, key]); - - return; - } - - formData.append(buildFormKey(key, parentKeys), value); - })); -} - -const addObjectToSearchParams = function(searchParams: URLSearchParams, data: any, parentKeys: string[]) { - Object.keys(data).forEach((key => { - let value = data[key]; - - // TODO: there is probably a better way to normalize this - // TODO: duplication - if (value === true) { - value = 1; - } - if (value === false) { - value = 0; - } - // don't send null values at all - if (value === null) { - return; - } - - // handle embedded objects - if (typeof value === 'object') { - addObjectToSearchParams(searchParams, value, [...parentKeys, key]); - - return; - } - - searchParams.set(buildFormKey(key, parentKeys), value); - })); -} - -/** - * @param {Object} data - * @return {FormData} - */ -export function buildFormData(data: any): FormData { - const formData = new FormData(); - - addObjectToFormData(formData, data, []); - - return formData; -} - -/** - * @param {URLSearchParams} searchParams - * @param {Object} data - * @return {URLSearchParams} - */ -export function buildSearchParams(searchParams: URLSearchParams, data: any): URLSearchParams { - addObjectToSearchParams(searchParams, data, []); - - return searchParams; -} diff --git a/src/LiveComponent/assets/src/live_controller.ts b/src/LiveComponent/assets/src/live_controller.ts index e13768932bb..680d8842a00 100644 --- a/src/LiveComponent/assets/src/live_controller.ts +++ b/src/LiveComponent/assets/src/live_controller.ts @@ -2,7 +2,6 @@ import { Controller } from '@hotwired/stimulus'; import morphdom from 'morphdom'; import { parseDirectives, Directive } from './directives_parser'; import { combineSpacedArray } from './string_utils'; -import { buildFormData, buildSearchParams } from './http_data_helper'; import { setDeepData, doesDeepPropertyExist, normalizeModelName } from './set_deep_data'; import { haveRenderedValuesChanged } from './have_rendered_values_changed'; import { normalizeAttributesForComparison } from './normalize_attributes_for_comparison'; @@ -315,12 +314,21 @@ export default class extends Controller { } } - if (!action && this._willDataFitInUrl()) { - buildSearchParams(params, this.dataValue); - fetchOptions.method = 'GET'; - } else { + let dataAdded = false; + if (!action) { + const dataJson = JSON.stringify(this.dataValue); + if (this._willDataFitInUrl(dataJson, params)) { + params.set('data', dataJson); + fetchOptions.method = 'GET'; + dataAdded = true; + } + } + + // if GET can't be used, fallback to POST + if (!dataAdded) { fetchOptions.method = 'POST'; - fetchOptions.body = buildFormData(this.dataValue); + fetchOptions.body = JSON.stringify(this.dataValue); + fetchOptions.headers['Content-Type'] = 'application/json'; } this._onLoadingStart(); @@ -531,9 +539,11 @@ export default class extends Controller { }) } - _willDataFitInUrl() { + _willDataFitInUrl(dataJson: string, params: URLSearchParams) { + const urlEncodedJsonData = new URLSearchParams(dataJson).toString(); + // if the URL gets remotely close to 2000 chars, it may not fit - return Object.values(this.dataValue).join(',').length < 1500; + return (urlEncodedJsonData + params.toString()).length < 1500; } _executeMorphdom(newHtml: string) { diff --git a/src/LiveComponent/assets/test/controller/action.test.ts b/src/LiveComponent/assets/test/controller/action.test.ts index d2a00ff1fe0..fb4e69d4191 100644 --- a/src/LiveComponent/assets/test/controller/action.test.ts +++ b/src/LiveComponent/assets/test/controller/action.test.ts @@ -65,7 +65,8 @@ describe('LiveController Action Tests', () => { await waitFor(() => expect(element).toHaveTextContent('Comment Saved!')); expect(getByLabelText(element, 'Comments:')).toHaveValue('hi weaver'); - expect(postMock.lastOptions().body.get('comments')).toEqual('hi WEAVER'); + const bodyData = JSON.parse(postMock.lastOptions().body); + expect(bodyData.comments).toEqual('hi WEAVER'); }); it('Sends action named args', async () => { diff --git a/src/LiveComponent/assets/test/controller/child.test.ts b/src/LiveComponent/assets/test/controller/child.test.ts index f313c1bb6a8..f1e77698fdc 100644 --- a/src/LiveComponent/assets/test/controller/child.test.ts +++ b/src/LiveComponent/assets/test/controller/child.test.ts @@ -187,7 +187,7 @@ describe('LiveController parent -> child component tests', () => { const inputElement = getByLabelText(element, 'Content:'); await userEvent.clear(inputElement); await userEvent.type(inputElement, 'changed content'); - mockRerender({value: 'changed content'}, childTemplate); + mockRerender({value: 'changed content', error: null}, childTemplate); await waitFor(() => expect(element).toHaveTextContent('Value in child: changed content')); diff --git a/src/LiveComponent/assets/test/controller/model.test.ts b/src/LiveComponent/assets/test/controller/model.test.ts index 3bed409f951..937df1b10b3 100644 --- a/src/LiveComponent/assets/test/controller/model.test.ts +++ b/src/LiveComponent/assets/test/controller/model.test.ts @@ -44,7 +44,9 @@ describe('LiveController data-model Tests', () => { const data = { name: 'Ryan' }; const { element, controller } = await startStimulus(template(data)); - fetchMock.getOnce('end:?name=Ryan+WEAVER', template({ name: 'Ryan Weaver' })); + mockRerender({name: 'Ryan WEAVER'}, template, (data: any) => { + data.name = 'Ryan Weaver'; + }); await userEvent.type(getByLabelText(element, 'Name:'), ' WEAVER', { // this tests the debounce: characters have a 10ms delay @@ -63,7 +65,7 @@ describe('LiveController data-model Tests', () => { const data = { name: 'Ryan' }; const { element, controller } = await startStimulus(template(data)); - fetchMock.getOnce('end:?name=Jan', template({ name: 'Jan' })); + mockRerender({name: 'Jan'}, template); userEvent.click(getByText(element, 'Change name to Jan')); @@ -87,11 +89,9 @@ describe('LiveController data-model Tests', () => { ['guy', 150] ]; requests.forEach(([string, delay]) => { - fetchMock.getOnce( - `end:my_component?name=Ryan${string}`, - template({ name: `Ryan${string}_` }), - { delay } - ); + mockRerender({name: `Ryan${string}`}, template, (data: any) => { + data.name = `Ryan${string}_`; + }, { delay }); }); await userEvent.type(getByLabelText(element, 'Name:'), 'guy', { @@ -121,7 +121,7 @@ describe('LiveController data-model Tests', () => { delete inputElement.dataset.model; inputElement.setAttribute('name', 'name'); - mockRerender({name: 'Ryan WEAVER'}, template, (data) => { + mockRerender({name: 'Ryan WEAVER'}, template, (data: any) => { data.name = 'Ryan Weaver'; }); diff --git a/src/LiveComponent/assets/test/controller/render.test.ts b/src/LiveComponent/assets/test/controller/render.test.ts index f29f0ee4b91..8b6cc6befa1 100644 --- a/src/LiveComponent/assets/test/controller/render.test.ts +++ b/src/LiveComponent/assets/test/controller/render.test.ts @@ -64,13 +64,9 @@ describe('LiveController rendering Tests', () => { const data = { name: 'Ryan' }; const { element } = await startStimulus(template(data)); - fetchMock.get( - // name=Ryan is sent to the server - 'http://localhost/_components/my_component?name=Ryan', - // server changes that data - template({ name: 'Kevin' }), - { delay: 100 } - ); + mockRerender({name: 'Ryan'}, template, (data: any) => { + data.name = 'Kevin'; + }, { delay: 100 }); // type into the input that is not bound to a model userEvent.type(getByLabelText(element, 'Comments:'), '!!'); getByText(element, 'Reload').click(); @@ -85,13 +81,11 @@ describe('LiveController rendering Tests', () => { const data = { name: 'Ryan' }; const { element } = await startStimulus(template(data)); - fetchMock.get( - // name=Ryan is sent to the server - 'http://localhost/_components/my_component?name=Ryan', - // server changes that data - template({ name: 'Kevin' }), - { delay: 100 } - ); + // name=Ryan is sent to the server + mockRerender({name: 'Ryan'}, template, (data: any) => { + data.name = 'Kevin'; + }, { delay: 100 }); + // type into the input that is not bound to a model const input = getByLabelText(element, 'Comments:'); input.setAttribute('data-live-ignore', ''); @@ -113,11 +107,14 @@ describe('LiveController rendering Tests', () => { template(data, true) ); - fetchMock.get( - 'http://localhost/_components/my_component?name=Ryan', - template({ name: 'Kevin' }, true), + mockRerender( + { name: 'Ryan' }, + // re-render but passing true as the second arg + (data: any) => template(data, true), + (data: any) => { data.name = 'Kevin'; }, { delay: 100 } ); + const input = getByLabelText(element, 'Comments:'); input.setAttribute('data-live-ignore', ''); userEvent.type(input, '!!'); @@ -132,9 +129,12 @@ describe('LiveController rendering Tests', () => { const data = { name: 'Ryan' }; const { element } = await startStimulus(template(data)); - fetchMock.get('end:?name=Ryan', '