Skip to content

Commit 321f2e4

Browse files
committed
Moving more systems into plugins
making the plugin system a bit more formal
1 parent 45bcaf4 commit 321f2e4

File tree

9 files changed

+189
-131
lines changed

9 files changed

+189
-131
lines changed

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

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

1414
declare const Turbo: any;
1515

@@ -31,7 +31,7 @@ export default class Component {
3131
readonly valueStore: ValueStore;
3232
private readonly unsyncedInputsTracker: UnsyncedInputsTracker;
3333
private hooks: HookManager;
34-
private pollingDirector: PollingDirectory;
34+
3535

3636
defaultDebounce = 150;
3737

@@ -65,28 +65,33 @@ export default class Component {
6565
this.valueStore = new ValueStore(props, data);
6666
this.unsyncedInputsTracker = new UnsyncedInputsTracker(this, elementDriver);
6767
this.hooks = new HookManager();
68-
this.pollingDirector = new PollingDirectory(this);
68+
}
69+
70+
addPlugin(plugin: PluginInterface) {
71+
plugin.attachToComponent(this);
6972
}
7073

7174
connect(): void {
72-
this.pollingDirector.startAllPolling();
75+
this.hooks.triggerHook('connect', this);
7376
this.unsyncedInputsTracker.activate();
7477
}
7578

7679
disconnect(): void {
77-
this.pollingDirector.stopAllPolling();
80+
this.hooks.triggerHook('disconnect', this);
7881
this.clearRequestDebounceTimeout();
7982
this.unsyncedInputsTracker.deactivate();
8083
}
8184

8285
/**
8386
* Add a named hook to the component. Available hooks are:
8487
*
88+
* * connect (component: Component) => {}
89+
* * disconnect (component: Component) => {}
8590
* * render:started (html: string, response: Response, controls: { shouldRender: boolean }) => {}
8691
* * render:finished (component: Component) => {}
8792
* * loading.state:started (element: HTMLElement, request: BackendRequest) => {}
8893
* * loading.state:finished (element: HTMLElement) => {}
89-
* * model:set (model, value) => {}
94+
* * model:set (model: string, value: any) => {}
9095
*/
9196
on(hookName: string, callback: (...args: any[]) => void): void {
9297
this.hooks.register(hookName, callback);
@@ -137,14 +142,6 @@ export default class Component {
137142
return this.unsyncedInputsTracker.getModifiedModels();
138143
}
139144

140-
addPoll(actionName: string, duration: number) {
141-
this.pollingDirector.addPoll(actionName, duration);
142-
}
143-
144-
clearPolling(): void {
145-
this.pollingDirector.clearPolling();
146-
}
147-
148145
addChild(component: Component): void {
149146
if (!component.id) {
150147
throw new Error('Children components must have an id.');

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,14 @@ import {
66
import { combineSpacedArray} from '../../string_utils';
77
import BackendRequest from '../../BackendRequest';
88
import Component from '../../Component';
9+
import { PluginInterface } from './PluginInterface';
910

1011
interface ElementLoadingDirectives {
1112
element: HTMLElement|SVGElement,
1213
directives: Directive[]
1314
}
1415

15-
export default class LoadingPlugin {
16+
export default class implements PluginInterface {
1617
attachToComponent(component: Component): void {
1718
component.on('loading.state:started', (element: HTMLElement, request: BackendRequest) => {
1819
this.startLoading(element, request);
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import Component from '../index';
2+
import { PluginInterface } from './PluginInterface';
3+
4+
export default class implements PluginInterface {
5+
private isConnected = false;
6+
7+
attachToComponent(component: Component): void {
8+
component.on('render:started', (html: string, response: Response, controls: { shouldRender: boolean }) => {
9+
if (!this.isConnected) {
10+
controls.shouldRender = false;
11+
}
12+
});
13+
14+
component.on('connect', () => {
15+
this.isConnected = true;
16+
});
17+
18+
component.on('disconnect', () => {
19+
this.isConnected = false;
20+
});
21+
}
22+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import Component from '../index';
2+
3+
export interface PluginInterface {
4+
attachToComponent(component: Component): void;
5+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import Component from '../index';
2+
import { parseDirectives } from '../../directives_parser';
3+
import PollingDirector from '../../PollingDirector';
4+
import { PluginInterface } from './PluginInterface';
5+
6+
export default class implements PluginInterface {
7+
private element: Element;
8+
private pollingDirector: PollingDirector;
9+
10+
attachToComponent(component: Component): void {
11+
this.element = component.element;
12+
this.pollingDirector = new PollingDirector(component);
13+
this.initializePolling();
14+
15+
component.on('connect', () => {
16+
this.pollingDirector.startAllPolling();
17+
});
18+
component.on('disconnect', () => {
19+
this.pollingDirector.stopAllPolling();
20+
});
21+
component.on('render:finished', () => {
22+
// re-start polling, in case polling changed
23+
this.initializePolling();
24+
});
25+
}
26+
27+
addPoll(actionName: string, duration: number): void {
28+
this.pollingDirector.addPoll(actionName, duration);
29+
}
30+
31+
clearPolling(): void {
32+
this.pollingDirector.clearPolling();
33+
}
34+
35+
private initializePolling(): void {
36+
this.clearPolling();
37+
38+
if ((this.element as HTMLElement).dataset.poll === undefined) {
39+
return;
40+
}
41+
42+
const rawPollConfig = (this.element as HTMLElement).dataset.poll;
43+
const directives = parseDirectives(rawPollConfig || '$render');
44+
45+
directives.forEach((directive) => {
46+
let duration = 2000;
47+
48+
directive.modifiers.forEach((modifier) => {
49+
switch (modifier.name) {
50+
case 'delay':
51+
if (modifier.value) {
52+
duration = parseInt(modifier.value);
53+
}
54+
55+
break;
56+
default:
57+
console.warn(`Unknown modifier "${modifier.name}" in data-poll "${rawPollConfig}".`);
58+
}
59+
});
60+
61+
this.addPoll(directive.action, duration);
62+
});
63+
}
64+
}
65+
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import Component from '../index';
2+
import {
3+
getModelDirectiveFromElement,
4+
getValueFromElement,
5+
setValueOnElement
6+
} from '../../dom_utils';
7+
import { PluginInterface } from './PluginInterface';
8+
9+
/**
10+
* Handles setting the "value" onto data-model fields automatically from the data store.
11+
*/
12+
export default class implements PluginInterface {
13+
attachToComponent(component: Component): void {
14+
this.synchronizeValueOfModelFields(component);
15+
component.on('render:finished', () => {
16+
this.synchronizeValueOfModelFields(component);
17+
});
18+
}
19+
20+
/**
21+
* Sets the "value" of all model fields to the component data.
22+
*
23+
* This is called when the component initializes and after re-render.
24+
* Take the following element:
25+
*
26+
* <input data-model="firstName">
27+
*
28+
* This method will set the "value" of that element to the value of
29+
* the "firstName" model.
30+
*/
31+
private synchronizeValueOfModelFields(component: Component): void {
32+
component.element.querySelectorAll('[data-model]').forEach((element: Element) => {
33+
if (!(element instanceof HTMLElement)) {
34+
throw new Error('Invalid element using data-model.');
35+
}
36+
37+
if (element instanceof HTMLFormElement) {
38+
return;
39+
}
40+
41+
const modelDirective = getModelDirectiveFromElement(element);
42+
if (!modelDirective) {
43+
return;
44+
}
45+
46+
const modelName = modelDirective.action;
47+
48+
// skip any elements whose model name is currently in an unsynced state
49+
if (component.getUnsyncedModels().includes(modelName)) {
50+
return;
51+
}
52+
53+
if (component.valueStore.has(modelName)) {
54+
setValueOnElement(element, component.valueStore.get(modelName))
55+
}
56+
57+
// for select elements without a blank value, one might be selected automatically
58+
// https://github.com/symfony/ux/issues/469
59+
if (element instanceof HTMLSelectElement && !element.multiple) {
60+
component.valueStore.set(modelName, getValueFromElement(element, component.valueStore));
61+
}
62+
})
63+
}
64+
}

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import Component from '../index';
22
import ValueStore from '../ValueStore';
3+
import { PluginInterface } from './PluginInterface';
34

4-
export default class ValidatedFieldsPlugin {
5+
export default class implements PluginInterface {
56
attachToComponent(component: Component): void {
67
component.on('model:set', (modelName: string) => {
78
this.handleModelSet(modelName, component.valueStore);

src/LiveComponent/assets/src/PollingDirector.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import Component from "./Component";
1+
import Component from './Component';
22

33
export default class {
44
component: Component;

0 commit comments

Comments
 (0)