Skip to content

Commit 3ccf783

Browse files
committed
Abstracting more markup-related thing to the new "driver" + plugin system
Also removing redundant event (there is a hook)
1 parent 1394883 commit 3ccf783

File tree

11 files changed

+107
-79
lines changed

11 files changed

+107
-79
lines changed
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import {getModelDirectiveFromElement} from '../dom_utils';
2+
3+
export interface ElementDriver {
4+
getModelName(element: HTMLElement): string|null;
5+
6+
/**
7+
* Given the root element of a component, returns its "data".
8+
*
9+
* This is used during a re-render to get the fresh data from the server.
10+
*/
11+
getComponentData(rootElement: HTMLElement): any;
12+
13+
getComponentProps(rootElement: HTMLElement): any;
14+
}
15+
16+
export class StandardElementDriver implements ElementDriver {
17+
getModelName(element: HTMLElement): string|null {
18+
const modelDirective = getModelDirectiveFromElement(element, false);
19+
20+
if (!modelDirective) {
21+
return null;
22+
}
23+
24+
return modelDirective.action;
25+
}
26+
27+
getComponentData(rootElement: HTMLElement): any {
28+
if (!rootElement.dataset.liveDataValue) {
29+
return null;
30+
}
31+
32+
return JSON.parse(rootElement.dataset.liveDataValue as string);
33+
}
34+
35+
getComponentProps(rootElement: HTMLElement): any {
36+
if (!rootElement.dataset.livePropsValue) {
37+
return null;
38+
}
39+
40+
return JSON.parse(rootElement.dataset.livePropsValue as string);
41+
}
42+
}

src/LiveComponent/assets/src/Component/ModelElementResolver.ts

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

src/LiveComponent/assets/src/Component/UnsyncedInputsTracker.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,18 @@
1-
import {ModelElementResolver} from './ModelElementResolver';
1+
import {ElementDriver} from './ElementDriver';
22
import {elementBelongsToThisComponent} from '../dom_utils';
33
import Component from './index';
44

55
export default class {
66
private readonly component: Component;
7-
private readonly modelElementResolver: ModelElementResolver;
7+
private readonly modelElementResolver: ElementDriver;
88
/** Fields that have changed, but whose value is not set back onto the value store */
99
private readonly unsyncedInputs: UnsyncedInputContainer;
1010

1111
private elementEventListeners: Array<{ event: string, callback: (event: any) => void }> = [
1212
{ event: 'input', callback: (event) => this.handleInputEvent(event) },
1313
];
1414

15-
constructor(component: Component, modelElementResolver: ModelElementResolver) {
15+
constructor(component: Component, modelElementResolver: ElementDriver) {
1616
this.component = component;
1717
this.modelElementResolver = modelElementResolver;
1818
this.unsyncedInputs = new UnsyncedInputContainer();

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,10 @@ export default class {
6363
}
6464

6565
/**
66+
* Set the props to a fresh set from the server.
67+
*
68+
* Props can only change as a result of a parent component re-rendering.
69+
*
6670
* Returns true if any of the props changed.
6771
*/
6872
reinitializeProps(props: any): boolean {

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

Lines changed: 15 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
} from '../dom_utils';
88
import {executeMorphdom} from '../morphdom';
99
import UnsyncedInputsTracker from './UnsyncedInputsTracker';
10-
import {ModelElementResolver} from './ModelElementResolver';
10+
import {ElementDriver} from './ElementDriver';
1111
import HookManager from '../HookManager';
1212
import PollingDirectory from '../PollingDirector';
1313

@@ -16,6 +16,7 @@ declare const Turbo: any;
1616
export default class Component {
1717
readonly element: HTMLElement;
1818
private readonly backend: BackendInterface;
19+
private readonly elementDriver: ElementDriver;
1920
id: string|null;
2021

2122
/**
@@ -52,16 +53,17 @@ export default class Component {
5253
* @param fingerprint
5354
* @param id Some unique id to identify this component. Needed to be a child component
5455
* @param backend Backend instance for updating
55-
* @param modelElementResolver Class to get "model" name from any element.
56+
* @param elementDriver Class to get "model" name from any element.
5657
*/
57-
constructor(element: HTMLElement, props: any, data: any, fingerprint: string|null, id: string|null, backend: BackendInterface, modelElementResolver: ModelElementResolver) {
58+
constructor(element: HTMLElement, props: any, data: any, fingerprint: string|null, id: string|null, backend: BackendInterface, elementDriver: ElementDriver) {
5859
this.element = element;
5960
this.backend = backend;
61+
this.elementDriver = elementDriver;
6062
this.id = id;
6163
this.fingerprint = fingerprint;
6264

6365
this.valueStore = new ValueStore(props, data);
64-
this.unsyncedInputsTracker = new UnsyncedInputsTracker(this, modelElementResolver);
66+
this.unsyncedInputsTracker = new UnsyncedInputsTracker(this, elementDriver);
6567
this.hooks = new HookManager();
6668
this.pollingDirector = new PollingDirectory(this);
6769
}
@@ -80,10 +82,11 @@ export default class Component {
8082
/**
8183
* Add a named hook to the component. Available hooks are:
8284
*
83-
* * render.started: (html: string, response: Response, controls: { shouldRender: boolean }) => {}
84-
* * render.finished: (component: Component) => {}
85+
* * render:started (html: string, response: Response, controls: { shouldRender: boolean }) => {}
86+
* * render:finished (component: Component) => {}
8587
* * loading.state:started (element: HTMLElement, request: BackendRequest) => {}
8688
* * loading.state:finished (element: HTMLElement) => {}
89+
* * model:set (model, value) => {}
8790
*/
8891
on(hookName: string, callback: (...args: any[]) => void): void {
8992
this.hooks.register(hookName, callback);
@@ -94,17 +97,7 @@ export default class Component {
9497
const modelName = normalizeModelName(model);
9598
this.valueStore.set(modelName, value);
9699

97-
// if there is a "validatedFields" data, it means this component wants
98-
// to track which fields have been / should be validated.
99-
// in that case, when the model is updated, mark that it should be validated
100-
// TODO: could this be done with a hook?
101-
if (this.valueStore.has('validatedFields')) {
102-
const validatedFields = [...this.valueStore.get('validatedFields')];
103-
if (!validatedFields.includes(modelName)) {
104-
validatedFields.push(modelName);
105-
}
106-
this.valueStore.set('validatedFields', validatedFields);
107-
}
100+
this.hooks.triggerHook('model:set', model, value);
108101

109102
// the model's data is no longer unsynced
110103
this.unsyncedInputsTracker.markModelAsSynced(modelName);
@@ -179,17 +172,15 @@ export default class Component {
179172
}
180173

181174
updateFromNewElement(toEl: HTMLElement): boolean {
182-
// TODO: need a driver here to be agnostic of markup
183-
const propsString = toEl.dataset.livePropsValue;
175+
const props = this.elementDriver.getComponentProps(toEl);
184176

185177
// if no props are on the element, use the existing element completely
186178
// this means the parent is signaling that the child does not need to be re-rendered
187-
if (propsString === undefined) {
179+
if (props === null) {
188180
return false;
189181
}
190182

191183
// push props directly down onto the value store
192-
const props = JSON.parse(propsString);
193184
const isChanged = this.valueStore.reinitializeProps(props);
194185

195186
const fingerprint = toEl.dataset.liveFingerprintValue;
@@ -255,7 +246,7 @@ export default class Component {
255246

256247
private processRerender(html: string, response: Response) {
257248
const controls = { shouldRender: true };
258-
this.hooks.triggerHook('render.started', html, response, controls);
249+
this.hooks.triggerHook('render:started', html, response, controls);
259250
// used to notify that the component doesn't live on the page anymore
260251
if (!controls.shouldRender) {
261252
return;
@@ -277,11 +268,6 @@ export default class Component {
277268
// elements to appear different unnecessarily
278269
this.hooks.triggerHook('loading.state:finished', this.element);
279270

280-
if (!this.dispatchEvent('live:render', html, true, true)) {
281-
// preventDefault() was called
282-
return;
283-
}
284-
285271
/**
286272
* For any models modified since the last request started, grab
287273
* their value now: we will re-set them after the new data from
@@ -296,32 +282,21 @@ export default class Component {
296282
// normalize new element into non-loading state before diff
297283
this.hooks.triggerHook('loading.state:finished', newElement);
298284

299-
// TODO: maybe abstract where the new data comes from
300-
const newDataFromServer: any = JSON.parse(newElement.dataset.liveDataValue as string);
285+
this.valueStore.reinitializeData(this.elementDriver.getComponentData(newElement));
301286
executeMorphdom(
302287
this.element,
303288
newElement,
304289
this.unsyncedInputsTracker.getUnsyncedInputs(),
305290
(element: HTMLElement) => getValueFromElement(element, this.valueStore),
306291
Array.from(this.getChildren().values())
307292
);
308-
// TODO: could possibly do this by listening to the dataValue value change
309-
this.valueStore.reinitializeData(newDataFromServer);
310293

311294
// reset the modified values back to their client-side version
312295
Object.keys(modifiedModelValues).forEach((modelName) => {
313296
this.valueStore.set(modelName, modifiedModelValues[modelName]);
314297
});
315298

316-
this.hooks.triggerHook('render.finished', this);
317-
}
318-
319-
private dispatchEvent(name: string, payload: any = null, canBubble = true, cancelable = false) {
320-
return this.element.dispatchEvent(new CustomEvent(name, {
321-
bubbles: canBubble,
322-
cancelable,
323-
detail: payload
324-
}));
299+
this.hooks.triggerHook('render:finished', this);
325300
}
326301

327302
private calculateDebounce(debounce: number|boolean): number {

src/LiveComponent/assets/src/LoadingHelper.ts renamed to src/LiveComponent/assets/src/Component/plugins/LoadingPlugin.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,17 @@ import {
22
Directive,
33
DirectiveModifier,
44
parseDirectives
5-
} from './directives_parser';
6-
import { combineSpacedArray} from './string_utils';
7-
import BackendRequest from "./BackendRequest";
8-
import Component from "./Component";
5+
} from '../../directives_parser';
6+
import { combineSpacedArray} from '../../string_utils';
7+
import BackendRequest from '../../BackendRequest';
8+
import Component from '../../Component';
99

1010
interface ElementLoadingDirectives {
1111
element: HTMLElement|SVGElement,
1212
directives: Directive[]
1313
}
1414

15-
export default class LoadingHelper {
15+
export default class LoadingPlugin {
1616
attachToComponent(component: Component): void {
1717
component.on('loading.state:started', (element: HTMLElement, request: BackendRequest) => {
1818
this.startLoading(element, request);
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import Component from '../index';
2+
import ValueStore from '../ValueStore';
3+
4+
export default class ValidatedFieldsPlugin {
5+
attachToComponent(component: Component): void {
6+
component.on('model:set', (modelName: string) => {
7+
this.handleModelSet(modelName, component.valueStore);
8+
});
9+
}
10+
11+
private handleModelSet(modelName: string, valueStore: ValueStore): void {
12+
if (valueStore.has('validatedFields')) {
13+
const validatedFields = [...valueStore.get('validatedFields')];
14+
if (!validatedFields.includes(modelName)) {
15+
validatedFields.push(modelName);
16+
}
17+
valueStore.set('validatedFields', validatedFields);
18+
}
19+
}
20+
}

src/LiveComponent/assets/src/live_controller.ts

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,10 @@ import {
1111
import Component, {proxifyComponent} from './Component';
1212
import Backend from './Backend';
1313
import {
14-
DataModelElementResolver,
15-
} from './Component/ModelElementResolver';
16-
import LoadingHelper from './LoadingHelper';
14+
StandardElementDriver,
15+
} from './Component/ElementDriver';
16+
import LoadingPlugin from './Component/plugins/LoadingPlugin';
17+
import ValidatedFieldsPlugin from './Component/plugins/ValidatedFieldsPlugin';
1718

1819
interface UpdateModelOptions {
1920
dispatch?: boolean;
@@ -78,7 +79,7 @@ export default class extends Controller<HTMLElement> implements LiveController {
7879
this.fingerprintValue,
7980
id,
8081
new Backend(this.urlValue, this.csrfValue),
81-
new DataModelElementResolver(),
82+
new StandardElementDriver(),
8283
);
8384
this.proxiedComponent = proxifyComponent(this.component);
8485

@@ -89,20 +90,24 @@ export default class extends Controller<HTMLElement> implements LiveController {
8990
this.component.defaultDebounce = this.debounceValue;
9091
}
9192
// after we finish rendering, re-set the "value" of model fields
92-
this.component.on('render.finished', () => {
93+
this.component.on('render:finished', () => {
9394
this.synchronizeValueOfModelFields();
9495

9596
// re-start polling, in case polling changed
97+
// TODO: moving polling to plugin
9698
this.initializePolling();
9799
});
98-
this.component.on('render.started', (html: string, response: Response, controls: { shouldRender: boolean }) => {
100+
this.component.on('render:started', (html: string, response: Response, controls: { shouldRender: boolean }) => {
99101
if (!this.isConnected) {
100102
controls.shouldRender = false;
101103
}
102104
});
103-
const loadingHelper = new LoadingHelper();
105+
const loadingHelper = new LoadingPlugin();
104106
loadingHelper.attachToComponent(this.component);
105107

108+
const validatedFieldsPlugin = new ValidatedFieldsPlugin();
109+
validatedFieldsPlugin.attachToComponent(this.component);
110+
106111
this.synchronizeValueOfModelFields();
107112
}
108113

@@ -439,7 +444,6 @@ export default class extends Controller<HTMLElement> implements LiveController {
439444
* This method will set the "value" of that element to the value of
440445
* the "firstName" model.
441446
*/
442-
// TODO: call this when needed
443447
synchronizeValueOfModelFields(): void {
444448
this.component.element.querySelectorAll('[data-model]').forEach((element: Element) => {
445449
if (!(element instanceof HTMLElement)) {

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import Component, {proxifyComponent} from '../../src/Component';
22
import {BackendAction, BackendInterface} from '../../src/Backend';
33
import {
4-
DataModelElementResolver
5-
} from '../../src/Component/ModelElementResolver';
4+
StandardElementDriver
5+
} from '../../src/Component/ElementDriver';
66
import BackendRequest from '../../src/BackendRequest';
77
import { Response } from 'node-fetch';
88

@@ -34,7 +34,7 @@ describe('Component class', () => {
3434
null,
3535
null,
3636
backend,
37-
new DataModelElementResolver()
37+
new StandardElementDriver()
3838
);
3939
return {
4040
proxy: proxifyComponent(component),

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
'use strict';
1111

1212
import { shutdownTests, createTest, initComponent } from '../tools';
13-
import { createEvent, fireEvent, getByText, waitFor } from '@testing-library/dom';
13+
import { getByText, waitFor } from '@testing-library/dom';
1414
import userEvent from '@testing-library/user-event';
1515
import fetchMock from 'fetch-mock-jest';
1616
import { htmlToElement } from '../../src/dom_utils';

0 commit comments

Comments
 (0)