Skip to content

Commit bb5b7c7

Browse files
committed
feature #264 Send data back and forth as JSON (weaverryan)
This PR was squashed before being merged into the 2.x branch. Discussion ---------- Send data back and forth as JSON | Q | A | ------------- | --- | Bug fix? | yes | New feature? | no | Tickets | Fixes #251 (a bit unrelated, but it fixes it) | License | MIT tl;dr the "data" of your component is now sent via JSON for the Ajax calls. Ajax calls are still made via `GET` (unless you're triggering an action or your data is too long) with a new `?data={... json}`. **Longer Explanation** Until now, I've been trying to send data back to the server as `?query` params or as POST data (which is just query params stored in the body of the request). However, using query parameters simply isn't robust enough. Most notably, it makes handling `null` values impossible. If you have a LiveProp - e.g. `firstName` - on the server set to `null`, how should that be sent back to the server? As `?firstName=`? In that case, how do you distinguish from an empty string? One option is to NOT send `firstName` at all on the Ajax request. But then, what if you have a `LiveProp` that is an array with `['firstName' => null]`? If we don't send `firstName`, the array will dehydrate to an empty array. In the end, the goal is to effectively "freeze" your component's data, send it to the frontend, and have the frontend send it back. Using query parameters simply isn't robust enough to do this without, but JSON is. There is no real downside to this: it just felt more pure to send data back and forth as query parameters and return HTML. We still return HTML, but sending JSON will make the behavior solid. Commits ------- 2b808ea Send data back and forth as JSON
2 parents e2df324 + 2b808ea commit bb5b7c7

File tree

15 files changed

+111
-222
lines changed

15 files changed

+111
-222
lines changed

.php-cs-fixer.dist.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
->append([__FILE__])
2525
->notPath('#/Fixtures/#')
2626
->notPath('#/app/var/#')
27+
->notPath('#/var/cache/#')
2728
->notPath('Turbo/Attribute/Broadcast.php') // Need https://github.com/FriendsOfPHP/PHP-CS-Fixer/issues/4702
2829
)
2930
;

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@
4545
"plugin:@typescript-eslint/recommended"
4646
],
4747
"rules": {
48-
"@typescript-eslint/no-explicit-any": "off"
48+
"@typescript-eslint/no-explicit-any": "off",
49+
"@typescript-eslint/no-empty-function": "off"
4950
},
5051
"env": {
5152
"browser": true

src/LiveComponent/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22

33
## 2.1.0
44

5+
- Your component's live "data" is now send over Ajax as a JSON string.
6+
Previously data was sent as pure query parameters or as pure POST data.
7+
However, this made it impossible to keep certain data types, like
8+
distinguishing between `null` and `''`. This has no impact on end-users.
9+
510
- Added `data-live-ignore` attribute. If included in an element, that element
611
will not be updated on re-render.
712

src/LiveComponent/assets/dist/live_controller.js

Lines changed: 14 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -898,61 +898,6 @@ function combineSpacedArray(parts) {
898898
return finalParts;
899899
}
900900

901-
const buildFormKey = function (key, parentKeys) {
902-
let fieldName = '';
903-
[...parentKeys, key].forEach((name) => {
904-
fieldName += fieldName ? `[${name}]` : name;
905-
});
906-
return fieldName;
907-
};
908-
const addObjectToFormData = function (formData, data, parentKeys) {
909-
Object.keys(data).forEach((key => {
910-
let value = data[key];
911-
if (value === true) {
912-
value = 1;
913-
}
914-
if (value === false) {
915-
value = 0;
916-
}
917-
if (value === null) {
918-
return;
919-
}
920-
if (typeof value === 'object') {
921-
addObjectToFormData(formData, value, [...parentKeys, key]);
922-
return;
923-
}
924-
formData.append(buildFormKey(key, parentKeys), value);
925-
}));
926-
};
927-
const addObjectToSearchParams = function (searchParams, data, parentKeys) {
928-
Object.keys(data).forEach((key => {
929-
let value = data[key];
930-
if (value === true) {
931-
value = 1;
932-
}
933-
if (value === false) {
934-
value = 0;
935-
}
936-
if (value === null) {
937-
return;
938-
}
939-
if (typeof value === 'object') {
940-
addObjectToSearchParams(searchParams, value, [...parentKeys, key]);
941-
return;
942-
}
943-
searchParams.set(buildFormKey(key, parentKeys), value);
944-
}));
945-
};
946-
function buildFormData(data) {
947-
const formData = new FormData();
948-
addObjectToFormData(formData, data, []);
949-
return formData;
950-
}
951-
function buildSearchParams(searchParams, data) {
952-
addObjectToSearchParams(searchParams, data, []);
953-
return searchParams;
954-
}
955-
956901
function setDeepData(data, propertyPath, value) {
957902
const finalData = JSON.parse(JSON.stringify(data));
958903
let currentLevelData = finalData;
@@ -1200,13 +1145,19 @@ class default_1 extends Controller {
12001145
fetchOptions.headers['X-CSRF-TOKEN'] = this.csrfValue;
12011146
}
12021147
}
1203-
if (!action && this._willDataFitInUrl()) {
1204-
buildSearchParams(params, this.dataValue);
1205-
fetchOptions.method = 'GET';
1148+
let dataAdded = false;
1149+
if (!action) {
1150+
const dataJson = JSON.stringify(this.dataValue);
1151+
if (this._willDataFitInUrl(dataJson, params)) {
1152+
params.set('data', dataJson);
1153+
fetchOptions.method = 'GET';
1154+
dataAdded = true;
1155+
}
12061156
}
1207-
else {
1157+
if (!dataAdded) {
12081158
fetchOptions.method = 'POST';
1209-
fetchOptions.body = buildFormData(this.dataValue);
1159+
fetchOptions.body = JSON.stringify(this.dataValue);
1160+
fetchOptions.headers['Content-Type'] = 'application/json';
12101161
}
12111162
this._onLoadingStart();
12121163
const paramsString = params.toString();
@@ -1358,8 +1309,9 @@ class default_1 extends Controller {
13581309
element.removeAttribute(attribute);
13591310
});
13601311
}
1361-
_willDataFitInUrl() {
1362-
return Object.values(this.dataValue).join(',').length < 1500;
1312+
_willDataFitInUrl(dataJson, params) {
1313+
const urlEncodedJsonData = new URLSearchParams(dataJson).toString();
1314+
return (urlEncodedJsonData + params.toString()).length < 1500;
13631315
}
13641316
_executeMorphdom(newHtml) {
13651317
function htmlToElement(html) {

src/LiveComponent/assets/src/http_data_helper.ts

Lines changed: 0 additions & 94 deletions
This file was deleted.

src/LiveComponent/assets/src/live_controller.ts

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { Controller } from '@hotwired/stimulus';
22
import morphdom from 'morphdom';
33
import { parseDirectives, Directive } from './directives_parser';
44
import { combineSpacedArray } from './string_utils';
5-
import { buildFormData, buildSearchParams } from './http_data_helper';
65
import { setDeepData, doesDeepPropertyExist, normalizeModelName } from './set_deep_data';
76
import { haveRenderedValuesChanged } from './have_rendered_values_changed';
87
import { normalizeAttributesForComparison } from './normalize_attributes_for_comparison';
@@ -315,12 +314,21 @@ export default class extends Controller {
315314
}
316315
}
317316

318-
if (!action && this._willDataFitInUrl()) {
319-
buildSearchParams(params, this.dataValue);
320-
fetchOptions.method = 'GET';
321-
} else {
317+
let dataAdded = false;
318+
if (!action) {
319+
const dataJson = JSON.stringify(this.dataValue);
320+
if (this._willDataFitInUrl(dataJson, params)) {
321+
params.set('data', dataJson);
322+
fetchOptions.method = 'GET';
323+
dataAdded = true;
324+
}
325+
}
326+
327+
// if GET can't be used, fallback to POST
328+
if (!dataAdded) {
322329
fetchOptions.method = 'POST';
323-
fetchOptions.body = buildFormData(this.dataValue);
330+
fetchOptions.body = JSON.stringify(this.dataValue);
331+
fetchOptions.headers['Content-Type'] = 'application/json';
324332
}
325333

326334
this._onLoadingStart();
@@ -531,9 +539,11 @@ export default class extends Controller {
531539
})
532540
}
533541

534-
_willDataFitInUrl() {
542+
_willDataFitInUrl(dataJson: string, params: URLSearchParams) {
543+
const urlEncodedJsonData = new URLSearchParams(dataJson).toString();
544+
535545
// if the URL gets remotely close to 2000 chars, it may not fit
536-
return Object.values(this.dataValue).join(',').length < 1500;
546+
return (urlEncodedJsonData + params.toString()).length < 1500;
537547
}
538548

539549
_executeMorphdom(newHtml: string) {

src/LiveComponent/assets/test/controller/action.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,8 @@ describe('LiveController Action Tests', () => {
6565
await waitFor(() => expect(element).toHaveTextContent('Comment Saved!'));
6666
expect(getByLabelText(element, 'Comments:')).toHaveValue('hi weaver');
6767

68-
expect(postMock.lastOptions().body.get('comments')).toEqual('hi WEAVER');
68+
const bodyData = JSON.parse(postMock.lastOptions().body);
69+
expect(bodyData.comments).toEqual('hi WEAVER');
6970
});
7071

7172
it('Sends action named args', async () => {

src/LiveComponent/assets/test/controller/child.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ describe('LiveController parent -> child component tests', () => {
187187
const inputElement = getByLabelText(element, 'Content:');
188188
await userEvent.clear(inputElement);
189189
await userEvent.type(inputElement, 'changed content');
190-
mockRerender({value: 'changed content'}, childTemplate);
190+
mockRerender({value: 'changed content', error: null}, childTemplate);
191191

192192
await waitFor(() => expect(element).toHaveTextContent('Value in child: changed content'));
193193

src/LiveComponent/assets/test/controller/model.test.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,9 @@ describe('LiveController data-model Tests', () => {
4444
const data = { name: 'Ryan' };
4545
const { element, controller } = await startStimulus(template(data));
4646

47-
fetchMock.getOnce('end:?name=Ryan+WEAVER', template({ name: 'Ryan Weaver' }));
47+
mockRerender({name: 'Ryan WEAVER'}, template, (data: any) => {
48+
data.name = 'Ryan Weaver';
49+
});
4850

4951
await userEvent.type(getByLabelText(element, 'Name:'), ' WEAVER', {
5052
// this tests the debounce: characters have a 10ms delay
@@ -63,7 +65,7 @@ describe('LiveController data-model Tests', () => {
6365
const data = { name: 'Ryan' };
6466
const { element, controller } = await startStimulus(template(data));
6567

66-
fetchMock.getOnce('end:?name=Jan', template({ name: 'Jan' }));
68+
mockRerender({name: 'Jan'}, template);
6769

6870
userEvent.click(getByText(element, 'Change name to Jan'));
6971

@@ -87,11 +89,9 @@ describe('LiveController data-model Tests', () => {
8789
['guy', 150]
8890
];
8991
requests.forEach(([string, delay]) => {
90-
fetchMock.getOnce(
91-
`end:my_component?name=Ryan${string}`,
92-
template({ name: `Ryan${string}_` }),
93-
{ delay }
94-
);
92+
mockRerender({name: `Ryan${string}`}, template, (data: any) => {
93+
data.name = `Ryan${string}_`;
94+
}, { delay });
9595
});
9696

9797
await userEvent.type(getByLabelText(element, 'Name:'), 'guy', {
@@ -121,7 +121,7 @@ describe('LiveController data-model Tests', () => {
121121
delete inputElement.dataset.model;
122122
inputElement.setAttribute('name', 'name');
123123

124-
mockRerender({name: 'Ryan WEAVER'}, template, (data) => {
124+
mockRerender({name: 'Ryan WEAVER'}, template, (data: any) => {
125125
data.name = 'Ryan Weaver';
126126
});
127127

0 commit comments

Comments
 (0)