Skip to content

Commit fc8b5db

Browse files
committed
bug #721 [Live] Adding failing test case for updating individual parts of deeply writable paths (weaverryan)
This PR was squashed before being merged into the 2.x branch. Discussion ---------- [Live] Adding failing test case for updating individual parts of deeply writable paths | Q | A | ------------- | --- | Bug fix? | yes | New feature? | no | Tickets | Fix #720 | License | MIT In the unreleased code, writing a "deep" path to an array `LiveProp` is broken (e.g. `formValues` in `ComponentWithFormTrait` when you have an embedded form). Test case here, solution coming shortly. Commits ------- 7954675 [Live] Adding failing test case for updating individual parts of deeply writable paths
2 parents e45a8ed + 7954675 commit fc8b5db

27 files changed

+618
-725
lines changed

src/LiveComponent/assets/dist/Component/ElementDriver.d.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
export interface ElementDriver {
22
getModelName(element: HTMLElement): string | null;
3-
getComponentProps(rootElement: HTMLElement): any;
3+
getComponentProps(rootElement: HTMLElement): {
4+
props: any;
5+
nestedProps: any;
6+
};
47
findChildComponentElement(id: string, element: HTMLElement): HTMLElement | null;
58
getKeyFromElement(element: HTMLElement): string | null;
69
}
Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,17 @@
11
export default class {
2-
private readonly identifierKey;
32
private props;
3+
private nestedProps;
44
private dirtyProps;
55
private pendingProps;
6-
constructor(props: any);
6+
constructor(props: any, nestedProps: any);
77
get(name: string): any;
88
has(name: string): boolean;
99
set(name: string, value: any): boolean;
1010
getOriginalProps(): any;
11+
getOriginalNestedProps(): any;
1112
getDirtyProps(): any;
1213
flushDirtyPropsToPending(): void;
13-
reinitializeAllProps(props: any): void;
14+
reinitializeAllProps(props: any, nestedProps: any): void;
1415
pushPendingPropsBackToDirty(): void;
1516
reinitializeProvidedProps(props: any): boolean;
16-
private isPropNameTopLevel;
17-
private findIdentifier;
1817
}

src/LiveComponent/assets/dist/Component/index.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export default class Component {
2222
private nextRequestPromiseResolve;
2323
private children;
2424
private parent;
25-
constructor(element: HTMLElement, props: any, fingerprint: string | null, id: string | null, backend: BackendInterface, elementDriver: ElementDriver);
25+
constructor(element: HTMLElement, props: any, nestedProps: any, fingerprint: string | null, id: string | null, backend: BackendInterface, elementDriver: ElementDriver);
2626
_swapBackend(backend: BackendInterface): void;
2727
addPlugin(plugin: PluginInterface): void;
2828
connect(): void;

src/LiveComponent/assets/dist/live_controller.d.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ export default class LiveControllerDefault extends Controller<HTMLElement> imple
1616
static values: {
1717
url: StringConstructor;
1818
props: ObjectConstructor;
19+
nestedProps: {
20+
type: ObjectConstructor;
21+
default: {};
22+
};
1923
csrf: StringConstructor;
2024
debounce: {
2125
type: NumberConstructor;
@@ -26,6 +30,7 @@ export default class LiveControllerDefault extends Controller<HTMLElement> imple
2630
};
2731
readonly urlValue: string;
2832
readonly propsValue: any;
33+
readonly nestedPropsValue: any;
2934
readonly csrfValue: string;
3035
readonly hasDebounceValue: boolean;
3136
readonly debounceValue: number;

src/LiveComponent/assets/dist/live_controller.js

Lines changed: 27 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -354,12 +354,13 @@ const parseDeepData = function (data, propertyPath) {
354354
};
355355

356356
class ValueStore {
357-
constructor(props) {
358-
this.identifierKey = '@id';
357+
constructor(props, nestedProps) {
359358
this.props = {};
359+
this.nestedProps = {};
360360
this.dirtyProps = {};
361361
this.pendingProps = {};
362362
this.props = props;
363+
this.nestedProps = nestedProps;
363364
}
364365
get(name) {
365366
const normalizedName = normalizeModelName(name);
@@ -369,14 +370,10 @@ class ValueStore {
369370
if (this.pendingProps[normalizedName] !== undefined) {
370371
return this.pendingProps[normalizedName];
371372
}
372-
const value = getDeepData(this.props, normalizedName);
373-
if (null === value) {
374-
return value;
373+
if (this.nestedProps[normalizedName] !== undefined) {
374+
return this.nestedProps[normalizedName];
375375
}
376-
if (this.isPropNameTopLevel(normalizedName) && typeof value === 'object' && value[this.identifierKey] !== undefined) {
377-
return value[this.identifierKey];
378-
}
379-
return value;
376+
return getDeepData(this.props, normalizedName);
380377
}
381378
has(name) {
382379
return this.get(name) !== undefined;
@@ -393,15 +390,19 @@ class ValueStore {
393390
getOriginalProps() {
394391
return Object.assign({}, this.props);
395392
}
393+
getOriginalNestedProps() {
394+
return Object.assign({}, this.nestedProps);
395+
}
396396
getDirtyProps() {
397397
return Object.assign({}, this.dirtyProps);
398398
}
399399
flushDirtyPropsToPending() {
400400
this.pendingProps = Object.assign({}, this.dirtyProps);
401401
this.dirtyProps = {};
402402
}
403-
reinitializeAllProps(props) {
403+
reinitializeAllProps(props, nestedProps) {
404404
this.props = props;
405+
this.nestedProps = nestedProps;
405406
this.pendingProps = {};
406407
}
407408
pushPendingPropsBackToDirty() {
@@ -411,24 +412,14 @@ class ValueStore {
411412
reinitializeProvidedProps(props) {
412413
let changed = false;
413414
for (const [key, value] of Object.entries(props)) {
414-
const currentIdentifier = this.get(key);
415-
const newIdentifier = this.findIdentifier(value);
416-
if (currentIdentifier !== newIdentifier) {
415+
const currentValue = this.get(key);
416+
if (currentValue !== value) {
417417
changed = true;
418418
this.props[key] = value;
419419
}
420420
}
421421
return changed;
422422
}
423-
isPropNameTopLevel(key) {
424-
return key.indexOf('.') === -1;
425-
}
426-
findIdentifier(value) {
427-
if (typeof value !== 'object' || value[this.identifierKey] === undefined) {
428-
return value;
429-
}
430-
return value[this.identifierKey];
431-
}
432423
}
433424

434425
var DOCUMENT_FRAGMENT_NODE = 11;
@@ -1386,7 +1377,7 @@ class ChildComponentWrapper {
13861377
}
13871378
}
13881379
class Component {
1389-
constructor(element, props, fingerprint, id, backend, elementDriver) {
1380+
constructor(element, props, nestedProps, fingerprint, id, backend, elementDriver) {
13901381
this.defaultDebounce = 150;
13911382
this.backendRequest = null;
13921383
this.pendingActions = [];
@@ -1399,7 +1390,7 @@ class Component {
13991390
this.elementDriver = elementDriver;
14001391
this.id = id;
14011392
this.fingerprint = fingerprint;
1402-
this.valueStore = new ValueStore(props);
1393+
this.valueStore = new ValueStore(props, nestedProps);
14031394
this.unsyncedInputsTracker = new UnsyncedInputsTracker(this, elementDriver);
14041395
this.hooks = new HookManager();
14051396
this.resetPromise();
@@ -1488,7 +1479,7 @@ class Component {
14881479
return children;
14891480
}
14901481
updateFromNewElement(toEl) {
1491-
const props = this.elementDriver.getComponentProps(toEl);
1482+
const { props } = this.elementDriver.getComponentProps(toEl);
14921483
if (props === null) {
14931484
return false;
14941485
}
@@ -1590,7 +1581,8 @@ class Component {
15901581
throw error;
15911582
}
15921583
this.hooks.triggerHook('loading.state:finished', newElement);
1593-
this.valueStore.reinitializeAllProps(this.elementDriver.getComponentProps(newElement));
1584+
const { props: newProps, nestedProps: newNestedProps } = this.elementDriver.getComponentProps(newElement);
1585+
this.valueStore.reinitializeAllProps(newProps, newNestedProps);
15941586
executeMorphdom(this.element, newElement, this.unsyncedInputsTracker.getUnsyncedInputs(), (element) => getValueFromElement(element, this.valueStore), Array.from(this.getChildren().values()), this.elementDriver.findChildComponentElement, this.elementDriver.getKeyFromElement);
15951587
Object.keys(modifiedModelValues).forEach((modelName) => {
15961588
this.valueStore.set(modelName, modifiedModelValues[modelName]);
@@ -1806,10 +1798,13 @@ class StandardElementDriver {
18061798
return modelDirective.action;
18071799
}
18081800
getComponentProps(rootElement) {
1809-
if (!rootElement.dataset.livePropsValue) {
1810-
return null;
1811-
}
1812-
return JSON.parse(rootElement.dataset.livePropsValue);
1801+
var _a, _b;
1802+
const propsJson = (_a = rootElement.dataset.livePropsValue) !== null && _a !== void 0 ? _a : '{}';
1803+
const nestedPropsJson = (_b = rootElement.dataset.liveNestedPropsValue) !== null && _b !== void 0 ? _b : '{}';
1804+
return {
1805+
props: JSON.parse(propsJson),
1806+
nestedProps: JSON.parse(nestedPropsJson),
1807+
};
18131808
}
18141809
findChildComponentElement(id, element) {
18151810
return element.querySelector(`[data-live-id=${id}]`);
@@ -2234,7 +2229,7 @@ class LiveControllerDefault extends Controller {
22342229
initialize() {
22352230
this.handleDisconnectedChildControllerEvent = this.handleDisconnectedChildControllerEvent.bind(this);
22362231
const id = this.element.dataset.liveId || null;
2237-
this.component = new Component(this.element, this.propsValue, this.fingerprintValue, id, new Backend(this.urlValue, this.csrfValue), new StandardElementDriver());
2232+
this.component = new Component(this.element, this.propsValue, this.nestedPropsValue, this.fingerprintValue, id, new Backend(this.urlValue, this.csrfValue), new StandardElementDriver());
22382233
this.proxiedComponent = proxifyComponent(this.component);
22392234
this.element.__component = this.proxiedComponent;
22402235
if (this.hasDebounceValue) {
@@ -2393,6 +2388,7 @@ class LiveControllerDefault extends Controller {
23932388
LiveControllerDefault.values = {
23942389
url: String,
23952390
props: Object,
2391+
nestedProps: { type: Object, default: {} },
23962392
csrf: String,
23972393
debounce: { type: Number, default: 150 },
23982394
id: String,

src/LiveComponent/assets/src/Component/ElementDriver.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import {getModelDirectiveFromElement} from '../dom_utils';
33
export interface ElementDriver {
44
getModelName(element: HTMLElement): string|null;
55

6-
getComponentProps(rootElement: HTMLElement): any;
6+
getComponentProps(rootElement: HTMLElement): { props: any, nestedProps: any };
77

88
/**
99
* Given an HtmlElement and a child id, find the root element for that child.
@@ -28,11 +28,13 @@ export class StandardElementDriver implements ElementDriver {
2828
}
2929

3030
getComponentProps(rootElement: HTMLElement): any {
31-
if (!rootElement.dataset.livePropsValue) {
32-
return null;
33-
}
31+
const propsJson = rootElement.dataset.livePropsValue ?? '{}';
32+
const nestedPropsJson = rootElement.dataset.liveNestedPropsValue ?? '{}';
3433

35-
return JSON.parse(rootElement.dataset.livePropsValue as string);
34+
return {
35+
props: JSON.parse(propsJson),
36+
nestedProps: JSON.parse(nestedPropsJson),
37+
}
3638
}
3739

3840
findChildComponentElement(id: string, element: HTMLElement): HTMLElement|null {

src/LiveComponent/assets/src/Component/ValueStore.ts

Lines changed: 23 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,20 @@ import { getDeepData } from '../data_manipulation_utils';
22
import { normalizeModelName } from '../string_utils';
33

44
export default class {
5-
private readonly identifierKey = '@id';
6-
75
/**
8-
* The original props passed to the component.
6+
* Original, read-only props that represent the original component state.
97
*
108
* @private
119
*/
1210
private props: any = {};
1311

12+
/**
13+
* A list of extra, nested props added to make them available as models.
14+
*
15+
* @private
16+
*/
17+
private nestedProps: any = {};
18+
1419
/**
1520
* A list of props that have been "dirty" (changed) since the last request to the server.
1621
*/
@@ -22,8 +27,9 @@ export default class {
2227
*/
2328
private pendingProps: {[key: string]: any} = {};
2429

25-
constructor(props: any) {
30+
constructor(props: any, nestedProps: any) {
2631
this.props = props;
32+
this.nestedProps = nestedProps;
2733
}
2834

2935
/**
@@ -44,19 +50,11 @@ export default class {
4450
return this.pendingProps[normalizedName];
4551
}
4652

47-
const value = getDeepData(this.props, normalizedName);
48-
49-
if (null === value) {
50-
return value;
53+
if (this.nestedProps[normalizedName] !== undefined) {
54+
return this.nestedProps[normalizedName];
5155
}
5256

53-
// if normalizedName is "top level" and value is an object,
54-
// and the value has an "@id" key, then return the "@id" key.
55-
if (this.isPropNameTopLevel(normalizedName) && typeof value === 'object' && value[this.identifierKey] !== undefined) {
56-
return value[this.identifierKey];
57-
}
58-
59-
return value;
57+
return getDeepData(this.props, normalizedName);
6058
}
6159

6260
has(name: string): boolean {
@@ -88,6 +86,10 @@ export default class {
8886
return { ...this.props };
8987
}
9088

89+
getOriginalNestedProps(): any {
90+
return { ...this.nestedProps };
91+
}
92+
9193
getDirtyProps(): any {
9294
return { ...this.dirtyProps };
9395
}
@@ -102,11 +104,10 @@ export default class {
102104

103105
/**
104106
* Called when an update request finishes successfully.
105-
*
106-
* @param props
107107
*/
108-
reinitializeAllProps(props: any): void {
108+
reinitializeAllProps(props: any, nestedProps: any): void {
109109
this.props = props;
110+
this.nestedProps = nestedProps;
110111
this.pendingProps = {};
111112
}
112113

@@ -129,42 +130,22 @@ export default class {
129130
* The server manages returning only the readonly props, so we don't need to
130131
* worry about that.
131132
*
132-
* If a prop is readonly, it will also include all of its "writable" paths
133-
* data. So, that embedded, writable data *is* overwritten. For example,
134-
* if the "user" data is currently { '@id': 123, firstName: 'Ryan' } and
135-
* the "user" prop changes to "456", the new "user" prop passed here will
136-
* be { '@id': 456, firstName: 'Kevin' }. This will overwrite the "firstName",
137-
* writable embedded data.
138-
*
139133
* Returns true if any of the props changed.
140134
*/
141135
reinitializeProvidedProps(props: any): boolean {
142136
let changed = false;
143137

144138
for (const [key, value] of Object.entries(props)) {
145-
const currentIdentifier = this.get(key);
146-
const newIdentifier = this.findIdentifier(value);
139+
const currentValue = this.get(key);
147140

148-
// if the readonly identifier is different, then overwrite
149-
// the prop entirely, including embedded writable data.
150-
if (currentIdentifier !== newIdentifier) {
141+
// if the readonly identifier is different, then overwrite the
142+
// prop entirely
143+
if (currentValue !== value) {
151144
changed = true;
152145
this.props[key] = value;
153146
}
154147
}
155148

156149
return changed;
157150
}
158-
159-
private isPropNameTopLevel(key: string): boolean {
160-
return key.indexOf('.') === -1;
161-
}
162-
163-
private findIdentifier(value: any): any {
164-
if (typeof value !== 'object' || value[this.identifierKey] === undefined) {
165-
return value;
166-
}
167-
168-
return value[this.identifierKey];
169-
}
170151
}

0 commit comments

Comments
 (0)