Skip to content

Commit 5cc495a

Browse files
committed
Adding a data-model system for sync'ing data between parent and child components
This also includes the dispatching of several new PHP events.
1 parent 338564d commit 5cc495a

File tree

36 files changed

+853
-126
lines changed

36 files changed

+853
-126
lines changed

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,10 @@
5050
"@typescript-eslint/no-explicit-any": "off",
5151
"@typescript-eslint/no-empty-function": "off",
5252
"@typescript-eslint/ban-ts-comment": "off",
53-
"quotes": ["error", "single"]
53+
"quotes": [
54+
"error",
55+
"single"
56+
]
5457
},
5558
"env": {
5659
"browser": true

src/Cropperjs/Resources/assets/test/controller.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ describe('CropperjsController', () => {
5252
data-cropperjs-public-url-value="https://symfony.com/logos/symfony_black_02.png"
5353
data-cropperjs-options-value="${dataToJsonAttribute({
5454
viewMode: 1,
55-
dragMode: "move"
55+
dragMode: 'move'
5656
})}"
5757
>
5858
</div>

src/LiveComponent/CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
2929
- [BEHAVIOR CHANGE] The way that child components re-render when a parent re-renders
3030
has changed, but shouldn't be drastically different. Child components will now
31-
avoid re-rendering if no "input" to the component changed *and* will maintain
31+
avoid re-rendering if no "input" to the component changed _and_ will maintain
3232
any writable `LiveProp` values after the re-render. Also, the re-render happens
3333
in a separate Ajax call after the parent has finished re-rendering.
3434

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

Lines changed: 69 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,20 @@ import { ElementDriver } from './ElementDriver';
1111
import HookManager from '../HookManager';
1212
import { PluginInterface } from './plugins/PluginInterface';
1313
import BackendResponse from '../BackendResponse';
14+
import { ModelBinding } from '../Directive/get_model_binding';
1415

1516
declare const Turbo: any;
1617

18+
class ChildComponentWrapper {
19+
component: Component;
20+
modelBindings: ModelBinding[];
21+
22+
constructor(component: Component, modelBindings: ModelBinding[]) {
23+
this.component = component;
24+
this.modelBindings = modelBindings;
25+
}
26+
}
27+
1728
export default class Component {
1829
readonly element: HTMLElement;
1930
private readonly backend: BackendInterface;
@@ -46,7 +57,7 @@ export default class Component {
4657
private nextRequestPromise: Promise<BackendResponse>;
4758
private nextRequestPromiseResolve: (response: BackendResponse) => any;
4859

49-
private children: Map<string, Component> = new Map();
60+
private children: Map<string, ChildComponentWrapper> = new Map();
5061
private parent: Component|null = null;
5162

5263
/**
@@ -69,6 +80,8 @@ export default class Component {
6980
this.unsyncedInputsTracker = new UnsyncedInputsTracker(this, elementDriver);
7081
this.hooks = new HookManager();
7182
this.resetPromise();
83+
84+
this.onChildComponentModelUpdate = this.onChildComponentModelUpdate.bind(this);
7285
}
7386

7487
addPlugin(plugin: PluginInterface) {
@@ -95,18 +108,22 @@ export default class Component {
95108
* * render:finished (component: Component) => {}
96109
* * loading.state:started (element: HTMLElement, request: BackendRequest) => {}
97110
* * loading.state:finished (element: HTMLElement) => {}
98-
* * model:set (model: string, value: any) => {}
111+
* * model:set (model: string, value: any, component: Component) => {}
99112
*/
100113
on(hookName: string, callback: (...args: any[]) => void): void {
101114
this.hooks.register(hookName, callback);
102115
}
103116

117+
off(hookName: string, callback: (...args: any[]) => void): void {
118+
this.hooks.unregister(hookName, callback);
119+
}
120+
104121
set(model: string, value: any, reRender = false, debounce: number|boolean = false): Promise<BackendResponse> {
105122
const promise = this.nextRequestPromise;
106123
const modelName = normalizeModelName(model);
107124
const isChanged = this.valueStore.set(modelName, value);
108125

109-
this.hooks.triggerHook('model:set', model, value);
126+
this.hooks.triggerHook('model:set', model, value, this);
110127

111128
// the model's data is no longer unsynced
112129
this.unsyncedInputsTracker.markModelAsSynced(modelName);
@@ -151,13 +168,14 @@ export default class Component {
151168
return this.unsyncedInputsTracker.getModifiedModels();
152169
}
153170

154-
addChild(component: Component): void {
155-
if (!component.id) {
171+
addChild(child: Component, modelBindings: ModelBinding[] = []): void {
172+
if (!child.id) {
156173
throw new Error('Children components must have an id.');
157174
}
158175

159-
this.children.set(component.id, component);
160-
component.parent = this;
176+
this.children.set(child.id, new ChildComponentWrapper(child, modelBindings));
177+
child.parent = this;
178+
child.on('model:set', this.onChildComponentModelUpdate);
161179
}
162180

163181
removeChild(child: Component): void {
@@ -167,16 +185,27 @@ export default class Component {
167185

168186
this.children.delete(child.id);
169187
child.parent = null;
188+
child.off('model:set', this.onChildComponentModelUpdate);
170189
}
171190

172191
getParent(): Component|null {
173192
return this.parent;
174193
}
175194

176195
getChildren(): Map<string, Component> {
177-
return new Map(this.children);
196+
const children: Map<string, Component> = new Map();
197+
this.children.forEach((childComponent, id) => {
198+
children.set(id, childComponent.component);
199+
});
200+
201+
return children;
178202
}
179203

204+
/**
205+
* Called during morphdom: read props from toEl and re-render if necessary.
206+
*
207+
* @param toEl
208+
*/
180209
updateFromNewElement(toEl: HTMLElement): boolean {
181210
const props = this.elementDriver.getComponentProps(toEl);
182211

@@ -201,6 +230,36 @@ export default class Component {
201230
return false;
202231
}
203232

233+
/**
234+
* Handles data-model binding from a parent component onto a child.
235+
*/
236+
onChildComponentModelUpdate(modelName: string, value: any, childComponent: Component): void {
237+
if (!childComponent.id) {
238+
throw new Error('Missing id');
239+
}
240+
241+
const childWrapper = this.children.get(childComponent.id);
242+
if (!childWrapper) {
243+
throw new Error('Missing child');
244+
}
245+
246+
childWrapper.modelBindings.forEach((modelBinding) => {
247+
const childModelName = modelBinding.innerModelName || 'value';
248+
249+
// skip, unless childModelName matches the model that just changed
250+
if (childModelName !== modelName) {
251+
return;
252+
}
253+
254+
this.set(
255+
modelBinding.modelName,
256+
value,
257+
modelBinding.shouldRender,
258+
modelBinding.debounce
259+
);
260+
});
261+
}
262+
204263
private tryStartingRequest(): void {
205264
if (!this.backendRequest) {
206265
this.performRequest()
@@ -391,7 +450,8 @@ export default class Component {
391450
private getChildrenFingerprints(): any {
392451
const fingerprints: any = {};
393452

394-
this.children.forEach((child) => {
453+
this.children.forEach((childComponent) => {
454+
const child = childComponent.component;
395455
if (!child.id) {
396456
throw new Error('missing id');
397457
}

src/LiveComponent/assets/src/Component/plugins/LoadingPlugin.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import {
22
Directive,
33
DirectiveModifier,
44
parseDirectives
5-
} from '../../directives_parser';
5+
} from '../../Directive/directives_parser';
66
import { combineSpacedArray} from '../../string_utils';
77
import BackendRequest from '../../BackendRequest';
88
import Component from '../../Component';

src/LiveComponent/assets/src/Component/plugins/PollingPlugin.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import Component from '../index';
2-
import { parseDirectives } from '../../directives_parser';
2+
import { parseDirectives } from '../../Directive/directives_parser';
33
import PollingDirector from '../../PollingDirector';
44
import { PluginInterface } from './PluginInterface';
55

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import {Directive} from './directives_parser';
2+
3+
export interface ModelBinding {
4+
modelName: string,
5+
innerModelName: string|null,
6+
shouldRender: boolean,
7+
debounce: number|boolean,
8+
targetEventName: string|null
9+
}
10+
11+
export default function(modelDirective: Directive): ModelBinding {
12+
let shouldRender = true;
13+
let targetEventName = null;
14+
let debounce: number|boolean = false;
15+
16+
modelDirective.modifiers.forEach((modifier) => {
17+
switch (modifier.name) {
18+
case 'on':
19+
if (!modifier.value) {
20+
throw new Error(`The "on" modifier in ${modelDirective.getString()} requires a value - e.g. on(change).`);
21+
}
22+
if (!['input', 'change'].includes(modifier.value)) {
23+
throw new Error(`The "on" modifier in ${modelDirective.getString()} only accepts the arguments "input" or "change".`);
24+
}
25+
26+
targetEventName = modifier.value;
27+
28+
break;
29+
case 'norender':
30+
shouldRender = false;
31+
32+
break;
33+
34+
case 'debounce':
35+
debounce = modifier.value ? parseInt(modifier.value) : true;
36+
37+
break;
38+
default:
39+
throw new Error(`Unknown modifier "${modifier.name}" in data-model="${modelDirective.getString()}".`);
40+
}
41+
});
42+
43+
const [ modelName, innerModelName ] = modelDirective.action.split(':');
44+
45+
return {
46+
modelName,
47+
innerModelName: innerModelName || null,
48+
shouldRender,
49+
debounce,
50+
targetEventName
51+
}
52+
}

src/LiveComponent/assets/src/HookManager.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,18 @@ export default class {
1616
this.hooks.set(hookName, hooks);
1717
}
1818

19+
unregister(hookName: string, callback: () => void): void {
20+
const hooks = this.hooks.get(hookName) || [];
21+
22+
const index = hooks.indexOf(callback);
23+
if (index === -1) {
24+
return;
25+
}
26+
27+
hooks.splice(index, 1);
28+
this.hooks.set(hookName, hooks);
29+
}
30+
1931
triggerHook(hookName: string, ...args: any[]): void {
2032
const hooks = this.hooks.get(hookName) || [];
2133
hooks.forEach((callback) => {

src/LiveComponent/assets/src/dom_utils.ts

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import ValueStore from './Component/ValueStore';
2-
import { Directive, parseDirectives } from './directives_parser';
2+
import { Directive, parseDirectives } from './Directive/directives_parser';
33
import { normalizeModelName } from './string_utils';
44
import Component from './Component';
55

@@ -111,18 +111,33 @@ export function setValueOnElement(element: HTMLElement, value: any): void {
111111
(element as HTMLInputElement).value = value
112112
}
113113

114-
export function getModelDirectiveFromElement(element: HTMLElement, throwOnMissing = true): null|Directive {
115-
if (element.dataset.model) {
116-
const directives = parseDirectives(element.dataset.model);
117-
const directive = directives[0];
114+
/**
115+
* Fetches *all* "data-model" directives for a given element.
116+
*
117+
* @param element
118+
*/
119+
export function getAllModelDirectiveFromElements(element: HTMLElement): Directive[] {
120+
if (!element.dataset.model) {
121+
return [];
122+
}
118123

124+
const directives = parseDirectives(element.dataset.model);
125+
126+
directives.forEach((directive) => {
119127
if (directive.args.length > 0 || directive.named.length > 0) {
120128
throw new Error(`The data-model="${element.dataset.model}" format is invalid: it does not support passing arguments to the model.`);
121129
}
122130

123131
directive.action = normalizeModelName(directive.action);
132+
});
124133

125-
return directive;
134+
return directives;
135+
}
136+
137+
export function getModelDirectiveFromElement(element: HTMLElement, throwOnMissing = true): null|Directive {
138+
const dataModelDirectives = getAllModelDirectiveFromElements(element);
139+
if (dataModelDirectives.length > 0) {
140+
return dataModelDirectives[0];
126141
}
127142

128143
if (element.getAttribute('name')) {

0 commit comments

Comments
 (0)