Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,10 @@
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-empty-function": "off",
"@typescript-eslint/ban-ts-comment": "off",
"quotes": ["error", "single"]
"quotes": [
"error",
"single"
]
},
"env": {
"browser": true
Expand Down
2 changes: 1 addition & 1 deletion src/Cropperjs/Resources/assets/test/controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ describe('CropperjsController', () => {
data-cropperjs-public-url-value="https://symfony.com/logos/symfony_black_02.png"
data-cropperjs-options-value="${dataToJsonAttribute({
viewMode: 1,
dragMode: "move"
dragMode: 'move'
})}"
>
</div>
Expand Down
2 changes: 1 addition & 1 deletion src/LiveComponent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@

- [BEHAVIOR CHANGE] The way that child components re-render when a parent re-renders
has changed, but shouldn't be drastically different. Child components will now
avoid re-rendering if no "input" to the component changed *and* will maintain
avoid re-rendering if no "input" to the component changed _and_ will maintain
any writable `LiveProp` values after the re-render. Also, the re-render happens
in a separate Ajax call after the parent has finished re-rendering.

Expand Down
78 changes: 69 additions & 9 deletions src/LiveComponent/assets/src/Component/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,20 @@ import { ElementDriver } from './ElementDriver';
import HookManager from '../HookManager';
import { PluginInterface } from './plugins/PluginInterface';
import BackendResponse from '../BackendResponse';
import { ModelBinding } from '../Directive/get_model_binding';

declare const Turbo: any;

class ChildComponentWrapper {
component: Component;
modelBindings: ModelBinding[];

constructor(component: Component, modelBindings: ModelBinding[]) {
this.component = component;
this.modelBindings = modelBindings;
}
}

export default class Component {
readonly element: HTMLElement;
private readonly backend: BackendInterface;
Expand Down Expand Up @@ -46,7 +57,7 @@ export default class Component {
private nextRequestPromise: Promise<BackendResponse>;
private nextRequestPromiseResolve: (response: BackendResponse) => any;

private children: Map<string, Component> = new Map();
private children: Map<string, ChildComponentWrapper> = new Map();
private parent: Component|null = null;

/**
Expand All @@ -69,6 +80,8 @@ export default class Component {
this.unsyncedInputsTracker = new UnsyncedInputsTracker(this, elementDriver);
this.hooks = new HookManager();
this.resetPromise();

this.onChildComponentModelUpdate = this.onChildComponentModelUpdate.bind(this);
}

addPlugin(plugin: PluginInterface) {
Expand All @@ -95,18 +108,22 @@ export default class Component {
* * render:finished (component: Component) => {}
* * loading.state:started (element: HTMLElement, request: BackendRequest) => {}
* * loading.state:finished (element: HTMLElement) => {}
* * model:set (model: string, value: any) => {}
* * model:set (model: string, value: any, component: Component) => {}
*/
on(hookName: string, callback: (...args: any[]) => void): void {
this.hooks.register(hookName, callback);
}

off(hookName: string, callback: (...args: any[]) => void): void {
this.hooks.unregister(hookName, callback);
}

set(model: string, value: any, reRender = false, debounce: number|boolean = false): Promise<BackendResponse> {
const promise = this.nextRequestPromise;
const modelName = normalizeModelName(model);
const isChanged = this.valueStore.set(modelName, value);

this.hooks.triggerHook('model:set', model, value);
this.hooks.triggerHook('model:set', model, value, this);

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

addChild(component: Component): void {
if (!component.id) {
addChild(child: Component, modelBindings: ModelBinding[] = []): void {
if (!child.id) {
throw new Error('Children components must have an id.');
}

this.children.set(component.id, component);
component.parent = this;
this.children.set(child.id, new ChildComponentWrapper(child, modelBindings));
child.parent = this;
child.on('model:set', this.onChildComponentModelUpdate);
}

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

this.children.delete(child.id);
child.parent = null;
child.off('model:set', this.onChildComponentModelUpdate);
}

getParent(): Component|null {
return this.parent;
}

getChildren(): Map<string, Component> {
return new Map(this.children);
const children: Map<string, Component> = new Map();
this.children.forEach((childComponent, id) => {
children.set(id, childComponent.component);
});

return children;
}

/**
* Called during morphdom: read props from toEl and re-render if necessary.
*
* @param toEl
*/
updateFromNewElement(toEl: HTMLElement): boolean {
const props = this.elementDriver.getComponentProps(toEl);

Expand All @@ -201,6 +230,36 @@ export default class Component {
return false;
}

/**
* Handles data-model binding from a parent component onto a child.
*/
onChildComponentModelUpdate(modelName: string, value: any, childComponent: Component): void {
if (!childComponent.id) {
throw new Error('Missing id');
}

const childWrapper = this.children.get(childComponent.id);
if (!childWrapper) {
throw new Error('Missing child');
}

childWrapper.modelBindings.forEach((modelBinding) => {
const childModelName = modelBinding.innerModelName || 'value';

// skip, unless childModelName matches the model that just changed
if (childModelName !== modelName) {
return;
}

this.set(
modelBinding.modelName,
value,
modelBinding.shouldRender,
modelBinding.debounce
);
});
}

private tryStartingRequest(): void {
if (!this.backendRequest) {
this.performRequest()
Expand Down Expand Up @@ -391,7 +450,8 @@ export default class Component {
private getChildrenFingerprints(): any {
const fingerprints: any = {};

this.children.forEach((child) => {
this.children.forEach((childComponent) => {
const child = childComponent.component;
if (!child.id) {
throw new Error('missing id');
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import {
Directive,
DirectiveModifier,
parseDirectives
} from '../../directives_parser';
} from '../../Directive/directives_parser';
import { combineSpacedArray} from '../../string_utils';
import BackendRequest from '../../BackendRequest';
import Component from '../../Component';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import Component from '../index';
import { parseDirectives } from '../../directives_parser';
import { parseDirectives } from '../../Directive/directives_parser';
import PollingDirector from '../../PollingDirector';
import { PluginInterface } from './PluginInterface';

Expand Down
52 changes: 52 additions & 0 deletions src/LiveComponent/assets/src/Directive/get_model_binding.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import {Directive} from './directives_parser';

export interface ModelBinding {
modelName: string,
innerModelName: string|null,
shouldRender: boolean,
debounce: number|boolean,
targetEventName: string|null
}

export default function(modelDirective: Directive): ModelBinding {
let shouldRender = true;
let targetEventName = null;
let debounce: number|boolean = false;

modelDirective.modifiers.forEach((modifier) => {
switch (modifier.name) {
case 'on':
if (!modifier.value) {
throw new Error(`The "on" modifier in ${modelDirective.getString()} requires a value - e.g. on(change).`);
}
if (!['input', 'change'].includes(modifier.value)) {
throw new Error(`The "on" modifier in ${modelDirective.getString()} only accepts the arguments "input" or "change".`);
}

targetEventName = modifier.value;

break;
case 'norender':
shouldRender = false;

break;

case 'debounce':
debounce = modifier.value ? parseInt(modifier.value) : true;

break;
default:
throw new Error(`Unknown modifier "${modifier.name}" in data-model="${modelDirective.getString()}".`);
}
});

const [ modelName, innerModelName ] = modelDirective.action.split(':');

return {
modelName,
innerModelName: innerModelName || null,
shouldRender,
debounce,
targetEventName
}
}
12 changes: 12 additions & 0 deletions src/LiveComponent/assets/src/HookManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,18 @@ export default class {
this.hooks.set(hookName, hooks);
}

unregister(hookName: string, callback: () => void): void {
const hooks = this.hooks.get(hookName) || [];

const index = hooks.indexOf(callback);
if (index === -1) {
return;
}

hooks.splice(index, 1);
this.hooks.set(hookName, hooks);
}

triggerHook(hookName: string, ...args: any[]): void {
const hooks = this.hooks.get(hookName) || [];
hooks.forEach((callback) => {
Expand Down
27 changes: 21 additions & 6 deletions src/LiveComponent/assets/src/dom_utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import ValueStore from './Component/ValueStore';
import { Directive, parseDirectives } from './directives_parser';
import { Directive, parseDirectives } from './Directive/directives_parser';
import { normalizeModelName } from './string_utils';
import Component from './Component';

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

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

const directives = parseDirectives(element.dataset.model);

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

directive.action = normalizeModelName(directive.action);
});

return directive;
return directives;
}

export function getModelDirectiveFromElement(element: HTMLElement, throwOnMissing = true): null|Directive {
const dataModelDirectives = getAllModelDirectiveFromElements(element);
if (dataModelDirectives.length > 0) {
return dataModelDirectives[0];
}

if (element.getAttribute('name')) {
Expand Down
Loading