From 9bf843b9e355ec97b52a0e843e4d23324288122f Mon Sep 17 00:00:00 2001 From: Ryan Weaver Date: Fri, 23 Sep 2022 20:21:02 -0400 Subject: [PATCH 01/26] # This is a combination of 6 commits. # This is the 1st commit message: WIP heavy refactoring to Component Initial "hook" system used to reset model field after re-render Adding a 2nd hook to handle window unloaded reinit polling after re-render Adding Component proxy # This is the commit message #2: fixing some tests # This is the commit message #3: Refactoring loading to a hook # This is the commit message #4: fixing tests # This is the commit message #5: rearranging # This is the commit message #6: Refactoring polling to a separate class --- package.json | 3 +- src/LiveComponent/assets/package.json | 1 + src/LiveComponent/assets/src/Backend.ts | 79 ++ .../assets/src/BackendRequest.ts | 31 + .../src/Component/ModelElementResolver.ts | 17 + .../src/Component/UnsyncedInputsTracker.ts | 105 ++ .../assets/src/{ => Component}/ValueStore.ts | 30 +- .../assets/src/Component/index.ts | 397 ++++++ src/LiveComponent/assets/src/HookManager.ts | 25 + src/LiveComponent/assets/src/LoadingHelper.ts | 226 ++++ .../assets/src/PollingDirector.ts | 63 + .../assets/src/UnsyncedInputContainer.ts | 38 - src/LiveComponent/assets/src/dom_utils.ts | 2 +- .../src/have_rendered_values_changed.ts | 2 +- .../assets/src/live_controller.ts | 1082 +++-------------- src/LiveComponent/assets/src/morphdom.ts | 94 ++ .../assets/test/Component/index.test.ts | 85 ++ .../test/UnsyncedInputContainer.test.ts | 2 +- .../assets/test/ValueStore.test.ts | 49 +- .../assets/test/controller/action.test.ts | 5 +- .../{child.test.ts => child.test.ts.todo} | 0 .../assets/test/controller/loading.test.ts | 4 + .../assets/test/controller/model.test.ts | 32 +- .../assets/test/controller/render.test.ts | 6 +- .../assets/test/dom_utils.test.ts | 8 +- src/LiveComponent/assets/test/tools.ts | 18 +- src/LiveComponent/src/Resources/doc/index.rst | 7 + 27 files changed, 1367 insertions(+), 1044 deletions(-) create mode 100644 src/LiveComponent/assets/src/Backend.ts create mode 100644 src/LiveComponent/assets/src/BackendRequest.ts create mode 100644 src/LiveComponent/assets/src/Component/ModelElementResolver.ts create mode 100644 src/LiveComponent/assets/src/Component/UnsyncedInputsTracker.ts rename src/LiveComponent/assets/src/{ => Component}/ValueStore.ts (54%) create mode 100644 src/LiveComponent/assets/src/Component/index.ts create mode 100644 src/LiveComponent/assets/src/HookManager.ts create mode 100644 src/LiveComponent/assets/src/LoadingHelper.ts create mode 100644 src/LiveComponent/assets/src/PollingDirector.ts delete mode 100644 src/LiveComponent/assets/src/UnsyncedInputContainer.ts create mode 100644 src/LiveComponent/assets/src/morphdom.ts create mode 100644 src/LiveComponent/assets/test/Component/index.test.ts rename src/LiveComponent/assets/test/controller/{child.test.ts => child.test.ts.todo} (100%) diff --git a/package.json b/package.json index 817fbe80478..7d80b620362 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,8 @@ ], "rules": { "@typescript-eslint/no-explicit-any": "off", - "@typescript-eslint/no-empty-function": "off" + "@typescript-eslint/no-empty-function": "off", + "@typescript-eslint/ban-ts-comment": "off" }, "env": { "browser": true diff --git a/src/LiveComponent/assets/package.json b/src/LiveComponent/assets/package.json index 356bafc38f7..5b05871ccaf 100644 --- a/src/LiveComponent/assets/package.json +++ b/src/LiveComponent/assets/package.json @@ -29,6 +29,7 @@ "@hotwired/stimulus": "^3.0.0", "@testing-library/dom": "^7.31.0", "@testing-library/user-event": "^13.1.9", + "@types/node-fetch": "^2.6.2", "fetch-mock-jest": "^1.5.1", "node-fetch": "^2.6.1" } diff --git a/src/LiveComponent/assets/src/Backend.ts b/src/LiveComponent/assets/src/Backend.ts new file mode 100644 index 00000000000..fcb438bc3f2 --- /dev/null +++ b/src/LiveComponent/assets/src/Backend.ts @@ -0,0 +1,79 @@ +import BackendRequest from './BackendRequest'; + +export interface BackendInterface { + makeRequest(data: any, actions: BackendAction[], updatedModels: string[]): BackendRequest; +} + +export interface BackendAction { + name: string, + args: Record +} + +export default class implements BackendInterface { + private url: string; + private csrfToken: string|null; + + constructor(url: string, csrfToken: string|null = null) { + this.url = url; + this.csrfToken = csrfToken; + } + + makeRequest(data: any, actions: BackendAction[], updatedModels: string[]): BackendRequest { + const splitUrl = this.url.split('?'); + let [url] = splitUrl + const [, queryString] = splitUrl; + const params = new URLSearchParams(queryString || ''); + + const fetchOptions: RequestInit = {}; + fetchOptions.headers = { + 'Accept': 'application/vnd.live-component+html', + }; + + if (actions.length === 0 && this.willDataFitInUrl(JSON.stringify(data), params)) { + params.set('data', JSON.stringify(data)); + updatedModels.forEach((model) => { + params.append('updatedModels[]', model); + }); + fetchOptions.method = 'GET'; + } else { + fetchOptions.method = 'POST'; + fetchOptions.headers['Content-Type'] = 'application/json'; + const requestData: any = { data }; + requestData.updatedModels = updatedModels; + + if (actions.length > 0) { + // one or more ACTIONs + if (this.csrfToken) { + fetchOptions.headers['X-CSRF-TOKEN'] = this.csrfToken; + } + + if (actions.length === 1) { + // simple, single action + requestData.args = actions[0].args; + + url += `/${encodeURIComponent(actions[0].name)}`; + } else { + url += '/_batch'; + requestData.actions = actions; + } + } + + fetchOptions.body = JSON.stringify(requestData); + } + + const paramsString = params.toString(); + + return new BackendRequest( + fetch(`${url}${paramsString.length > 0 ? `?${paramsString}` : ''}`, fetchOptions), + actions.map((backendAction) => backendAction.name), + updatedModels + ); + } + + private willDataFitInUrl(dataJson: string, params: URLSearchParams) { + const urlEncodedJsonData = new URLSearchParams(dataJson).toString(); + + // if the URL gets remotely close to 2000 chars, it may not fit + return (urlEncodedJsonData + params.toString()).length < 1500; + } +} diff --git a/src/LiveComponent/assets/src/BackendRequest.ts b/src/LiveComponent/assets/src/BackendRequest.ts new file mode 100644 index 00000000000..7429c66aacd --- /dev/null +++ b/src/LiveComponent/assets/src/BackendRequest.ts @@ -0,0 +1,31 @@ +export default class { + promise: Promise; + actions: string[]; + updatedModels: string[]; + isResolved = false; + + constructor(promise: Promise, actions: string[], updateModels: string[]) { + this.promise = promise; + this.promise.then((response) => { + this.isResolved = true; + + return response; + }); + this.actions = actions; + this.updatedModels = updateModels; + } + + /** + * Does this BackendRequest contain at least on action in targetedActions? + */ + containsOneOfActions(targetedActions: string[]): boolean { + return (this.actions.filter(action => targetedActions.includes(action))).length > 0; + } + + /** + * Does this BackendRequest includes updates for any of these models? + */ + areAnyModelsUpdated(targetedModels: string[]): boolean { + return (this.updatedModels.filter(model => targetedModels.includes(model))).length > 0; + } +} diff --git a/src/LiveComponent/assets/src/Component/ModelElementResolver.ts b/src/LiveComponent/assets/src/Component/ModelElementResolver.ts new file mode 100644 index 00000000000..d26e517cbd0 --- /dev/null +++ b/src/LiveComponent/assets/src/Component/ModelElementResolver.ts @@ -0,0 +1,17 @@ +import {getModelDirectiveFromElement} from "../dom_utils"; + +export interface ModelElementResolver { + getModelName(element: HTMLElement): string|null; +} + +export class DataModelElementResolver implements ModelElementResolver { + getModelName(element: HTMLElement): string|null { + const modelDirective = getModelDirectiveFromElement(element, false); + + if (!modelDirective) { + return null; + } + + return modelDirective.action; + } +} diff --git a/src/LiveComponent/assets/src/Component/UnsyncedInputsTracker.ts b/src/LiveComponent/assets/src/Component/UnsyncedInputsTracker.ts new file mode 100644 index 00000000000..12d9afbedc3 --- /dev/null +++ b/src/LiveComponent/assets/src/Component/UnsyncedInputsTracker.ts @@ -0,0 +1,105 @@ +import {ModelElementResolver} from "./ModelElementResolver"; + +export default class { + private readonly element: HTMLElement; + private readonly modelElementResolver: ModelElementResolver; + /** Fields that have changed, but whose value is not set back onto the value store */ + private readonly unsyncedInputs: UnsyncedInputContainer; + + private elementEventListeners: Array<{ event: string, callback: (event: any) => void }> = [ + { event: 'input', callback: (event) => this.handleInputEvent(event) }, + ]; + + constructor(element: HTMLElement, modelElementResolver: ModelElementResolver) { + this.element = element; + this.modelElementResolver = modelElementResolver; + this.unsyncedInputs = new UnsyncedInputContainer(); + } + + activate(): void { + this.elementEventListeners.forEach(({event, callback}) => { + this.element.addEventListener(event, callback); + }); + } + + deactivate(): void { + this.elementEventListeners.forEach(({event, callback}) => { + this.element.removeEventListener(event, callback); + }); + } + + markModelAsSynced(modelName: string): void { + this.unsyncedInputs.markModelAsSynced(modelName); + } + + private handleInputEvent(event: Event) { + const target = event.target as Element; + if (!target) { + return; + } + + this.updateModelFromElement(target) + } + + private updateModelFromElement(element: Element) { + // TODO: put back this child element check + // if (!elementBelongsToThisController(element, this)) { + // return; + // } + + if (!(element instanceof HTMLElement)) { + throw new Error('Could not update model for non HTMLElement'); + } + + const modelName = this.modelElementResolver.getModelName(element); + // track any inputs that are "unsynced" + this.unsyncedInputs.add(element, modelName); + } + + getUnsyncedInputs(): HTMLElement[] { + return this.unsyncedInputs.all(); + } + + getModifiedModels(): string[] { + return Array.from(this.unsyncedInputs.getModifiedModels()); + } +} + +/** + * Tracks field & models whose values are "unsynced". + * + * Unsynced means that the value has been updated inside of + * a field (e.g. an input), but that this new value hasn't + * yet been set onto the actual model data. It is "unsynced" + * from the underlying model data. + */ +export class UnsyncedInputContainer { + #mappedFields: Map; + #unmappedFields: Array = []; + + constructor() { + this.#mappedFields = new Map(); + } + + add(element: HTMLElement, modelName: string|null = null) { + if (modelName) { + this.#mappedFields.set(modelName, element); + + return; + } + + this.#unmappedFields.push(element); + } + + all(): HTMLElement[] { + return [...this.#unmappedFields, ...this.#mappedFields.values()] + } + + markModelAsSynced(modelName: string): void { + this.#mappedFields.delete(modelName); + } + + getModifiedModels(): string[] { + return Array.from(this.#mappedFields.keys()); + } +} diff --git a/src/LiveComponent/assets/src/ValueStore.ts b/src/LiveComponent/assets/src/Component/ValueStore.ts similarity index 54% rename from src/LiveComponent/assets/src/ValueStore.ts rename to src/LiveComponent/assets/src/Component/ValueStore.ts index 500331fa8ca..259fe3a0613 100644 --- a/src/LiveComponent/assets/src/ValueStore.ts +++ b/src/LiveComponent/assets/src/Component/ValueStore.ts @@ -1,13 +1,12 @@ -import { getDeepData, setDeepData } from './data_manipulation_utils'; -import { LiveController } from './live_controller'; -import { normalizeModelName } from './string_utils'; +import { getDeepData, setDeepData } from '../data_manipulation_utils'; +import { normalizeModelName } from '../string_utils'; export default class { - controller: LiveController; updatedModels: string[] = []; + private data: any = {}; - constructor(liveController: LiveController) { - this.controller = liveController; + constructor(data: any) { + this.data = data; } /** @@ -20,7 +19,7 @@ export default class { get(name: string): any { const normalizedName = normalizeModelName(name); - return getDeepData(this.controller.dataValue, normalizedName); + return getDeepData(this.data, normalizedName); } has(name: string): boolean { @@ -38,7 +37,7 @@ export default class { this.updatedModels.push(normalizedName); } - this.controller.dataValue = setDeepData(this.controller.dataValue, normalizedName, value); + this.data = setDeepData(this.data, normalizedName, value); } /** @@ -47,21 +46,14 @@ export default class { hasAtTopLevel(name: string): boolean { const parts = name.split('.'); - return this.controller.dataValue[parts[0]] !== undefined; - } - - asJson(): string { - return JSON.stringify(this.controller.dataValue); + return this.data[parts[0]] !== undefined; } all(): any { - return this.controller.dataValue; + return this.data; } - /** - * Are any of the passed models currently "updated"? - */ - areAnyModelsUpdated(targetedModels: string[]): boolean { - return (this.updatedModels.filter(modelName => targetedModels.includes(modelName))).length > 0; + reinitialize(data: any) { + this.data = data; } } diff --git a/src/LiveComponent/assets/src/Component/index.ts b/src/LiveComponent/assets/src/Component/index.ts new file mode 100644 index 00000000000..b9f9ec76b14 --- /dev/null +++ b/src/LiveComponent/assets/src/Component/index.ts @@ -0,0 +1,397 @@ +import {BackendAction, BackendInterface} from '../Backend'; +import ValueStore from './ValueStore'; +import { normalizeModelName } from '../string_utils'; +import BackendRequest from "../BackendRequest"; +import { + getValueFromElement, htmlToElement, +} from "../dom_utils"; +import {executeMorphdom} from "../morphdom"; +import UnsyncedInputsTracker from "./UnsyncedInputsTracker"; +import {ModelElementResolver} from "./ModelElementResolver"; +import HookManager from "../HookManager"; +import PollingDirectory from "../PollingDirector"; + +declare const Turbo: any; + +export default class Component { + readonly element: HTMLElement; + private readonly backend: BackendInterface; + + readonly valueStore: ValueStore; + private readonly unsyncedInputsTracker: UnsyncedInputsTracker; + private hooks: HookManager; + private pollingDirector: PollingDirectory; + + defaultDebounce = 150; + + private originalData = {}; + private backendRequest: BackendRequest|null; + /** Actions that are waiting to be executed */ + private pendingActions: BackendAction[] = []; + /** Is a request waiting to be made? */ + private isRequestPending = false; + /** Current "timeout" before the pending request should be sent. */ + private requestDebounceTimeout: number | null = null; + + /** + * @param element The root element + * @param data Component data + * @param backend Backend instance for updating + * @param modelElementResolver Class to get "model" name from any element. + */ + constructor(element: HTMLElement, data: any, backend: BackendInterface, modelElementResolver: ModelElementResolver) { + this.element = element; + this.backend = backend; + + this.valueStore = new ValueStore(data); + this.unsyncedInputsTracker = new UnsyncedInputsTracker(element, modelElementResolver); + this.hooks = new HookManager(); + this.pollingDirector = new PollingDirectory(this); + + // deep clone the data + this.snapshotOriginalData(); + } + + connect(): void { + this.pollingDirector.startAllPolling(); + this.unsyncedInputsTracker.activate(); + } + + disconnect(): void { + this.pollingDirector.stopAllPolling(); + this.clearRequestDebounceTimeout(); + this.unsyncedInputsTracker.deactivate(); + } + + /** + * Add a named hook to the component. Available hooks are: + * + * * render.started: (html: string, response: Response, controls: { shouldRender: boolean }) => {} + * * render.finished: (component: Component) => {} + * * loading.state:started (element: HTMLElement, request: BackendRequest) => {} + * * loading.state:finished (element: HTMLElement) => {} + */ + on(hookName: string, callback: (...args: any[]) => void): void { + this.hooks.register(hookName, callback); + } + + // TODO: return a promise + set(model: string, value: any, reRender = false, debounce: number|boolean = false): void { + const modelName = normalizeModelName(model); + this.valueStore.set(modelName, value); + + // if there is a "validatedFields" data, it means this component wants + // to track which fields have been / should be validated. + // in that case, when the model is updated, mark that it should be validated + // TODO: could this be done with a hook? + if (this.valueStore.has('validatedFields')) { + const validatedFields = [...this.valueStore.get('validatedFields')]; + if (!validatedFields.includes(modelName)) { + validatedFields.push(modelName); + } + this.valueStore.set('validatedFields', validatedFields); + } + + // the model's data is no longer unsynced + this.unsyncedInputsTracker.markModelAsSynced(modelName); + + if (!reRender) { + return; + } + + this.debouncedStartRequest(debounce); + } + + get(model: string): any { + const modelName = normalizeModelName(model); + if (!this.valueStore.has(modelName)) { + throw new Error(`Invalid model "${model}".`); + } + + return this.valueStore.get(modelName); + } + + // TODO: return promise + action(name: string, args: any, debounce: number|boolean = false): void { + this.pendingActions.push({ + name, + args + }); + + this.debouncedStartRequest(debounce); + } + + // TODO return a promise + render(): void { + this.tryStartingRequest(); + } + + getUnsyncedModels(): string[] { + return this.unsyncedInputsTracker.getModifiedModels(); + } + + addPoll(actionName: string, duration: number) { + this.pollingDirector.addPoll(actionName, duration); + } + + clearPolling(): void { + this.pollingDirector.clearPolling(); + } + + private tryStartingRequest(): void { + if (!this.backendRequest) { + this.performRequest() + + return; + } + + // mark that a request is wanted + this.isRequestPending = true; + } + + private performRequest(): BackendRequest { + this.backendRequest = this.backend.makeRequest( + this.valueStore.all(), + this.pendingActions, + this.valueStore.updatedModels + ); + this.hooks.triggerHook('loading.state:started', this.element, this.backendRequest); + + this.pendingActions = []; + this.valueStore.updatedModels = []; + this.isRequestPending = false; + + this.backendRequest.promise.then(async (response) => { + const html = await response.text(); + // if the response does not contain a component, render as an error + if (response.headers.get('Content-Type') !== 'application/vnd.live-component+html') { + this.renderError(html); + + return response; + } + + this.processRerender(html, response); + + this.backendRequest = null; + + // do we already have another request pending? + if (this.isRequestPending) { + this.isRequestPending = false; + this.performRequest(); + } + + return response; + }); + + return this.backendRequest; + } + + private processRerender(html: string, response: Response) { + const controls = { shouldRender: true }; + this.hooks.triggerHook('render.started', html, response, controls); + // used to notify that the component doesn't live on the page anymore + if (!controls.shouldRender) { + return; + } + + // check if the page is navigating away + //if (this.isWindowUnloaded) { + // TODO: bring back windowUnloaded + if (html === 'fooobar') { + return; + } + + if (response.headers.get('Location')) { + // action returned a redirect + if (typeof Turbo !== 'undefined') { + Turbo.visit(response.headers.get('Location')); + } else { + window.location.href = response.headers.get('Location') || ''; + } + + return; + } + + // remove the loading behavior now so that when we morphdom + // "diffs" the elements, any loading differences will not cause + // elements to appear different unnecessarily + this.hooks.triggerHook('loading.state:finished', this.element); + + if (!this.dispatchEvent('live:render', html, true, true)) { + // preventDefault() was called + return; + } + + /** + * For any models modified since the last request started, grab + * their value now: we will re-set them after the new data from + * the server has been processed. + */ + const modifiedModelValues: any = {}; + this.valueStore.updatedModels.forEach((modelName) => { + modifiedModelValues[modelName] = this.valueStore.get(modelName); + }); + + const newElement = htmlToElement(html); + // normalize new element into non-loading state before diff + this.hooks.triggerHook('loading.state:finished', newElement); + + // TODO: maybe abstract where the new data comes from + const newDataFromServer: any = JSON.parse(newElement.dataset.liveDataValue as string); + executeMorphdom( + this.element, + newElement, + this.unsyncedInputsTracker.getUnsyncedInputs(), + (element: HTMLElement) => getValueFromElement(element, this.valueStore), + this.originalData, + this.valueStore.all(), + newDataFromServer + ); + // TODO: could possibly do this by listening to the dataValue value change + this.valueStore.reinitialize(newDataFromServer); + + // take a new snapshot of the "original data" + this.snapshotOriginalData(); + + // reset the modified values back to their client-side version + Object.keys(modifiedModelValues).forEach((modelName) => { + this.valueStore.set(modelName, modifiedModelValues[modelName]); + }); + + this.hooks.triggerHook('render.finished', this); + } + + private dispatchEvent(name: string, payload: any = null, canBubble = true, cancelable = false) { + return this.element.dispatchEvent(new CustomEvent(name, { + bubbles: canBubble, + cancelable, + detail: payload + })); + } + + private snapshotOriginalData() { + this.originalData = JSON.parse(JSON.stringify(this.valueStore.all())); + } + + private caculateDebounce(debounce: number|boolean): number { + if (debounce === true) { + return this.defaultDebounce; + } + + if (debounce === false) { + return 0; + } + + return debounce; + } + + private clearRequestDebounceTimeout() { + if (this.requestDebounceTimeout) { + clearTimeout(this.requestDebounceTimeout); + this.requestDebounceTimeout = null; + } + } + + private debouncedStartRequest(debounce: number|boolean) { + this.clearRequestDebounceTimeout(); + this.requestDebounceTimeout = window.setTimeout(() => { + this.render(); + }, this.caculateDebounce(debounce)); + } + + // inspired by Livewire! + private renderError(html: string): void { + let modal = document.getElementById('live-component-error'); + if (modal) { + modal.innerHTML = ''; + } else { + modal = document.createElement('div'); + modal.id = 'live-component-error'; + modal.style.padding = '50px'; + modal.style.backgroundColor = 'rgba(0, 0, 0, .5)'; + modal.style.zIndex = '100000'; + modal.style.position = 'fixed'; + modal.style.width = '100vw'; + modal.style.height = '100vh'; + } + + const iframe = document.createElement('iframe'); + iframe.style.borderRadius = '5px'; + iframe.style.width = '100%'; + iframe.style.height = '100%'; + modal.appendChild(iframe); + + document.body.prepend(modal); + document.body.style.overflow = 'hidden'; + if (iframe.contentWindow) { + iframe.contentWindow.document.open(); + iframe.contentWindow.document.write(html); + iframe.contentWindow.document.close(); + } + + const closeModal = (modal: HTMLElement|null) => { + if (modal) { + modal.outerHTML = '' + } + document.body.style.overflow = 'visible' + } + + // close on click + modal.addEventListener('click', () => closeModal(modal)); + + // close on escape + modal.setAttribute('tabindex', '0'); + modal.addEventListener('keydown', e => { + if (e.key === 'Escape') { + closeModal(modal); + } + }); + modal.focus(); + } +} + +export function createComponent(element: HTMLElement, data: any, backend: BackendInterface, modelElementResolver: ModelElementResolver): Component { + const component = new Component(element, data, backend, modelElementResolver); + + return new Proxy(component, { + get(component: Component, prop: string|symbol): any { + // string check is to handle symbols + if (prop in component || typeof prop !== 'string') { + if (typeof component[prop as keyof typeof component] === 'function') { + const callable = component[prop as keyof typeof component] as (...args: any) => any; + return (...args: any) => { + return callable.apply(component, args); + } + } + + // forward to public properties + return Reflect.get(component, prop); + } + + // return model + if (component.valueStore.has(prop)) { + return component.get(prop) + } + + // try to call an action + return (args: string[]) => { + return component.action.apply(component, [prop, args]); + } + }, + + set(target: Component, property: string, value: any): boolean { + if (property in target) { + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore Ignoring potentially setting private properties + target[property as keyof typeof target] = value; + + return true; + } + + target.set(property, value); + + return true; + }, + }); +} diff --git a/src/LiveComponent/assets/src/HookManager.ts b/src/LiveComponent/assets/src/HookManager.ts new file mode 100644 index 00000000000..ac78b7af6b7 --- /dev/null +++ b/src/LiveComponent/assets/src/HookManager.ts @@ -0,0 +1,25 @@ +export default class { + private hooks: Map void>>; + + constructor() { + this.hooks = new Map(); + } + + /** + * Add a named hook to the component. Available hooks are: + * + * * request.rendered + */ + register(hookName: string, callback: () => void): void { + const hooks = this.hooks.get(hookName) || []; + hooks.push(callback); + this.hooks.set(hookName, hooks); + } + + triggerHook(hookName: string, ...args: any[]): void { + const hooks = this.hooks.get(hookName) || []; + hooks.forEach((callback) => { + callback(...args); + }); + } +} diff --git a/src/LiveComponent/assets/src/LoadingHelper.ts b/src/LiveComponent/assets/src/LoadingHelper.ts new file mode 100644 index 00000000000..b36fe90ba88 --- /dev/null +++ b/src/LiveComponent/assets/src/LoadingHelper.ts @@ -0,0 +1,226 @@ +import { + Directive, + DirectiveModifier, + parseDirectives +} from './directives_parser'; +import { combineSpacedArray} from './string_utils'; +import BackendRequest from "./BackendRequest"; +import Component from "./Component"; + +interface ElementLoadingDirectives { + element: HTMLElement|SVGElement, + directives: Directive[] +} + +export default class LoadingHelper { + attachToComponent(component: Component): void { + component.on('loading.state:started', (element: HTMLElement, request: BackendRequest) => { + this.startLoading(element, request); + }); + component.on('loading.state:finished', (element: HTMLElement) => { + this.finishLoading(element); + }); + // hide "loading" elements to begin with + // This is done with CSS, but only for the most basic cases + this.finishLoading(component.element); + } + + startLoading(targetElement: HTMLElement|SVGElement, backendRequest: BackendRequest): void { + this.handleLoadingToggle(true, targetElement, backendRequest); + } + + finishLoading(targetElement: HTMLElement|SVGElement): void { + this.handleLoadingToggle(false, targetElement, null); + } + + private handleLoadingToggle(isLoading: boolean, targetElement: HTMLElement|SVGElement, backendRequest: BackendRequest|null) { + if (isLoading) { + this.addAttributes(targetElement, ['busy']); + } else { + this.removeAttributes(targetElement, ['busy']); + } + + this.getLoadingDirectives(targetElement).forEach(({ element, directives }) => { + // so we can track, at any point, if an element is in a "loading" state + if (isLoading) { + this.addAttributes(element, ['data-live-is-loading']); + } else { + this.removeAttributes(element, ['data-live-is-loading']); + } + + directives.forEach((directive) => { + this.handleLoadingDirective(element, isLoading, directive, backendRequest) + }); + }); + } + + private handleLoadingDirective(element: HTMLElement|SVGElement, isLoading: boolean, directive: Directive, backendRequest: BackendRequest|null) { + const finalAction = parseLoadingAction(directive.action, isLoading); + + const targetedActions: string[] = []; + const targetedModels: string[] = []; + let delay = 0; + + const validModifiers: Map void> = new Map(); + validModifiers.set('delay', (modifier: DirectiveModifier) => { + // if loading has *stopped*, the delay modifier has no effect + if (!isLoading) { + return; + } + + delay = modifier.value ? parseInt(modifier.value) : 200; + }); + validModifiers.set('action', (modifier: DirectiveModifier) => { + if (!modifier.value) { + throw new Error(`The "action" in data-loading must have an action name - e.g. action(foo). It's missing for "${directive.getString()}"`); + } + targetedActions.push(modifier.value); + }); + validModifiers.set('model', (modifier: DirectiveModifier) => { + if (!modifier.value) { + throw new Error(`The "model" in data-loading must have an action name - e.g. model(foo). It's missing for "${directive.getString()}"`); + } + targetedModels.push(modifier.value); + }); + + directive.modifiers.forEach((modifier) => { + if (validModifiers.has(modifier.name)) { + // variable is entirely to make ts happy + const callable = validModifiers.get(modifier.name) ?? (() => {}); + callable(modifier); + + return; + } + + throw new Error(`Unknown modifier "${modifier.name}" used in data-loading="${directive.getString()}". Available modifiers are: ${Array.from(validModifiers.keys()).join(', ')}.`) + }); + + // if loading is being activated + action modifier, only apply if the action is on the request + if (isLoading && targetedActions.length > 0 && backendRequest && !backendRequest.containsOneOfActions(targetedActions)) { + return; + } + + // if loading is being activated + model modifier, only apply if the model is modified + if (isLoading && targetedModels.length > 0 && backendRequest && !backendRequest.areAnyModelsUpdated(targetedModels)) { + return; + } + + let loadingDirective: (() => void); + + switch (finalAction) { + case 'show': + loadingDirective = () => { + this.showElement(element) + }; + break; + + case 'hide': + loadingDirective = () => this.hideElement(element); + break; + + case 'addClass': + loadingDirective = () => this.addClass(element, directive.args); + break; + + case 'removeClass': + loadingDirective = () => this.removeClass(element, directive.args); + break; + + case 'addAttribute': + loadingDirective = () => this.addAttributes(element, directive.args); + break; + + case 'removeAttribute': + loadingDirective = () => this.removeAttributes(element, directive.args); + break; + + default: + throw new Error(`Unknown data-loading action "${finalAction}"`); + } + + if (delay) { + window.setTimeout(() => { + if (backendRequest && !backendRequest.isResolved) { + loadingDirective(); + } + }, delay); + + return; + } + + loadingDirective(); + } + + getLoadingDirectives(element: HTMLElement|SVGElement) { + const loadingDirectives: ElementLoadingDirectives[] = []; + + element.querySelectorAll('[data-loading]').forEach((element => { + if (!(element instanceof HTMLElement) && !(element instanceof SVGElement)) { + throw new Error('Invalid Element Type'); + } + + // use "show" if the attribute is empty + const directives = parseDirectives(element.dataset.loading || 'show'); + + loadingDirectives.push({ + element, + directives, + }); + })); + + return loadingDirectives; + } + + private showElement(element: HTMLElement|SVGElement) { + element.style.display = 'inline-block'; + } + + private hideElement(element: HTMLElement|SVGElement) { + element.style.display = 'none'; + } + + private addClass(element: HTMLElement|SVGElement, classes: string[]) { + element.classList.add(...combineSpacedArray(classes)); + } + + private removeClass(element: HTMLElement|SVGElement, classes: string[]) { + element.classList.remove(...combineSpacedArray(classes)); + + // remove empty class="" to avoid morphdom "diff" problem + if (element.classList.length === 0) { + this.removeAttributes(element, ['class']); + } + } + + private addAttributes(element: Element, attributes: string[]) { + attributes.forEach((attribute) => { + element.setAttribute(attribute, ''); + }) + } + + private removeAttributes(element: Element, attributes: string[]) { + attributes.forEach((attribute) => { + element.removeAttribute(attribute); + }) + } +} + +const parseLoadingAction = function(action: string, isLoading: boolean) { + switch (action) { + case 'show': + return isLoading ? 'show' : 'hide'; + case 'hide': + return isLoading ? 'hide' : 'show'; + case 'addClass': + return isLoading ? 'addClass' : 'removeClass'; + case 'removeClass': + return isLoading ? 'removeClass' : 'addClass'; + case 'addAttribute': + return isLoading ? 'addAttribute' : 'removeAttribute'; + case 'removeAttribute': + return isLoading ? 'removeAttribute' : 'addAttribute'; + } + + throw new Error(`Unknown data-loading action "${action}"`); +} + diff --git a/src/LiveComponent/assets/src/PollingDirector.ts b/src/LiveComponent/assets/src/PollingDirector.ts new file mode 100644 index 00000000000..0cccb5b7672 --- /dev/null +++ b/src/LiveComponent/assets/src/PollingDirector.ts @@ -0,0 +1,63 @@ +import Component from "./Component"; + +export default class { + component: Component; + isPollingActive = true; + polls: Array<{actionName: string, duration: number}> + pollingIntervals: NodeJS.Timer[] = []; + + constructor(component: Component) { + this.component = component; + } + + addPoll(actionName: string, duration: number) { + this.polls.push({ actionName, duration }); + + if (this.isPollingActive) { + this.initiatePoll(actionName, duration); + } + } + + startAllPolling(): void { + if (this.isPollingActive) { + return; // already active! + } + + this.isPollingActive = true; + this.polls.forEach(({actionName, duration}) => { + this.initiatePoll(actionName, duration); + }); + } + + stopAllPolling(): void { + this.isPollingActive = false; + this.pollingIntervals.forEach((interval) => { + clearInterval(interval); + }); + } + + clearPolling(): void { + this.stopAllPolling(); + this.polls = []; + // set back to "is polling" status + this.startAllPolling(); + } + + private initiatePoll(actionName: string, duration: number): void { + let callback: () => void; + if (actionName === '$render') { + callback = () => { + this.component.render(); + } + } else { + callback = () => { + this.component.action(actionName, {}, 0); + } + } + + const timer = setInterval(() => { + callback(); + }, duration); + this.pollingIntervals.push(timer); + } +} diff --git a/src/LiveComponent/assets/src/UnsyncedInputContainer.ts b/src/LiveComponent/assets/src/UnsyncedInputContainer.ts deleted file mode 100644 index 6a92956c759..00000000000 --- a/src/LiveComponent/assets/src/UnsyncedInputContainer.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * Tracks field & models whose values are "unsynced". - * - * Unsynced means that the value has been updated inside of - * a field (e.g. an input), but that this new value hasn't - * yet been set onto the actual model data. It is "unsynced" - * from the underlying model data. - */ -export default class UnsyncedInputContainer { - #mappedFields: Map; - #unmappedFields: Array = []; - - constructor() { - this.#mappedFields = new Map(); - } - - add(element: HTMLElement, modelName: string|null = null) { - if (modelName) { - this.#mappedFields.set(modelName, element); - - return; - } - - this.#unmappedFields.push(element); - } - - all() { - return [...this.#unmappedFields, ...this.#mappedFields.values()] - } - - markModelAsSynced(modelName: string): void { - this.#mappedFields.delete(modelName); - } - - getModifiedModels(): string[] { - return Array.from(this.#mappedFields.keys()); - } -} diff --git a/src/LiveComponent/assets/src/dom_utils.ts b/src/LiveComponent/assets/src/dom_utils.ts index 01372413e79..c64c70601eb 100644 --- a/src/LiveComponent/assets/src/dom_utils.ts +++ b/src/LiveComponent/assets/src/dom_utils.ts @@ -1,4 +1,4 @@ -import ValueStore from './ValueStore'; +import ValueStore from './Component/ValueStore'; import { Directive, parseDirectives } from './directives_parser'; import { LiveController } from './live_controller'; import { normalizeModelName } from './string_utils'; diff --git a/src/LiveComponent/assets/src/have_rendered_values_changed.ts b/src/LiveComponent/assets/src/have_rendered_values_changed.ts index abeabd50d9a..24a9a67379d 100644 --- a/src/LiveComponent/assets/src/have_rendered_values_changed.ts +++ b/src/LiveComponent/assets/src/have_rendered_values_changed.ts @@ -52,7 +52,7 @@ export function haveRenderedValuesChanged(originalDataJson: string, currentDataJ // now that we know which keys have changed between originally // rendering the child component and this latest render, we - // can check to see if the the child component *already* has + // can check to see if the child component *already* has // the latest value for those keys. const currentData = JSON.parse(currentDataJson) diff --git a/src/LiveComponent/assets/src/live_controller.ts b/src/LiveComponent/assets/src/live_controller.ts index 2caf92d4841..ca57b92daaa 100644 --- a/src/LiveComponent/assets/src/live_controller.ts +++ b/src/LiveComponent/assets/src/live_controller.ts @@ -1,35 +1,23 @@ import { Controller } from '@hotwired/stimulus'; -import morphdom from 'morphdom'; -import { parseDirectives, Directive, DirectiveModifier } from './directives_parser'; -import { combineSpacedArray, normalizeModelName } from './string_utils'; -import { haveRenderedValuesChanged } from './have_rendered_values_changed'; -import { normalizeAttributesForComparison } from './normalize_attributes_for_comparison'; -import ValueStore from './ValueStore'; +import { parseDirectives, DirectiveModifier } from './directives_parser'; +import { normalizeModelName } from './string_utils'; import { - elementBelongsToThisController, getModelDirectiveFromElement, - getValueFromElement, - cloneHTMLElement, - htmlToElement, getElementAsTagText, - setValueOnElement + setValueOnElement, + getValueFromElement, + elementBelongsToThisController, } from './dom_utils'; -import UnsyncedInputContainer from './UnsyncedInputContainer'; - -interface ElementLoadingDirectives { - element: HTMLElement|SVGElement, - directives: Directive[] -} +import Component, {createComponent} from "./Component"; +import Backend from "./Backend"; +import {DataModelElementResolver} from "./Component/ModelElementResolver"; +import LoadingHelper from "./LoadingHelper"; interface UpdateModelOptions { dispatch?: boolean; - debounce?: number|null; + debounce?: number|boolean; } -declare const Turbo: any; - -const DEFAULT_DEBOUNCE = 150; - export interface LiveController { dataValue: any; element: Element, @@ -41,101 +29,93 @@ export default class extends Controller implements LiveController { url: String, data: Object, csrf: String, - /** - * The Debounce timeout. - * - * Default: 150 - */ - debounce: Number, + debounce: { type: Number, default: 150 }, } readonly urlValue!: string; dataValue!: any; readonly csrfValue!: string; - readonly debounceValue!: number; readonly hasDebounceValue: boolean; + readonly debounceValue: number; - backendRequest: BackendRequest|null; - valueStore!: ValueStore; - - /** Actions that are waiting to be executed */ - pendingActions: Array<{ name: string, args: Record }> = []; - /** Has anything requested a re-render? */ - isRerenderRequested = false; - - /** - * Current "timeout" before the pending request should be sent. - */ - requestDebounceTimeout: number | null = null; - - pollingIntervals: NodeJS.Timer[] = []; - + component: Component; isConnected = false; - - originalDataJSON = '{}'; - - mutationObserver: MutationObserver|null = null; - - /** - * Model form fields that have "changed", but whose model value hasn't been set yet. - */ - unsyncedInputs!: UnsyncedInputContainer; - childComponentControllers: Array = []; - pendingActionTriggerModelElement: HTMLElement|null = null; + private elementEventListeners: Array<{ event: string, callback: (event: any) => void }> = [ + { event: 'input', callback: (event) => this.handleInputEvent(event) }, + { event: 'change', callback: (event) => this.handleChangeEvent(event) }, + ]; + initialize() { - this.handleUpdateModelEvent = this.handleUpdateModelEvent.bind(this); - this.handleInputEvent = this.handleInputEvent.bind(this); - this.handleChangeEvent = this.handleChangeEvent.bind(this); this.handleConnectedControllerEvent = this.handleConnectedControllerEvent.bind(this); this.handleDisconnectedControllerEvent = this.handleDisconnectedControllerEvent.bind(this); - this.valueStore = new ValueStore(this); - this.originalDataJSON = this.valueStore.asJson(); - this.unsyncedInputs = new UnsyncedInputContainer(); - this._exposeOriginalData(); + + if (!(this.element instanceof HTMLElement)) { + throw new Error('Invalid Element Type'); + } + + this.component = createComponent( + this.element, + this.dataValue, + new Backend(this.urlValue, this.csrfValue), + new DataModelElementResolver(), + ); + if (this.hasDebounceValue) { + this.component.defaultDebounce = this.debounceValue; + } + // after we finish rendering, re-set the "value" of model fields + this.component.on('render.finished', () => { + this.synchronizeValueOfModelFields(); + + // re-start polling, in case polling changed + this.initializePolling(); + }); + this.component.on('render.started', (html: string, response: Response, controls: { shouldRender: boolean }) => { + if (!this.isConnected) { + controls.shouldRender = false; + } + }); + const loadingHelper = new LoadingHelper(); + loadingHelper.attachToComponent(this.component); + this.synchronizeValueOfModelFields(); } connect() { this.isConnected = true; + this.component.connect(); + this.initializePolling(); + + this.elementEventListeners.forEach(({event, callback}) => { + this.component.element.addEventListener(event, callback); + }); + // hide "loading" elements to begin with // This is done with CSS, but only for the most basic cases - this._onLoadingFinish(); // helps typescript be sure this is an HTMLElement, not just Element if (!(this.element instanceof HTMLElement)) { throw new Error('Invalid Element Type'); } - this._initiatePolling(); - - this._startAttributesMutationObserver(); - this.element.addEventListener('live:update-model', this.handleUpdateModelEvent); - this.element.addEventListener('input', this.handleInputEvent); - this.element.addEventListener('change', this.handleChangeEvent); this.element.addEventListener('live:connect', this.handleConnectedControllerEvent); this._dispatchEvent('live:connect', { controller: this }); } disconnect() { - this._stopAllPolling(); - this.#clearRequestDebounceTimeout(); + this.component.disconnect(); + + this.elementEventListeners.forEach(({event, callback}) => { + this.component.element.removeEventListener(event, callback); + }); - this.element.removeEventListener('live:update-model', this.handleUpdateModelEvent); - this.element.removeEventListener('input', this.handleInputEvent); - this.element.removeEventListener('change', this.handleChangeEvent); this.element.removeEventListener('live:connect', this.handleConnectedControllerEvent); this.element.removeEventListener('live:disconnect', this.handleDisconnectedControllerEvent); this._dispatchEvent('live:disconnect', { controller: this }); - - if (this.mutationObserver) { - this.mutationObserver.disconnect(); - } - this.isConnected = false; } @@ -149,7 +129,7 @@ export default class extends Controller implements LiveController { throw new Error(`Since LiveComponents 2.3, you no longer need data-action="live#update" on form elements. Found on element: ${getElementAsTagText(event.target)}`); } - this._updateModelFromElement(event.target, null); + this.updateModelFromElementEvent(event.target, null); } action(event: any) { @@ -161,15 +141,10 @@ export default class extends Controller implements LiveController { const rawAction = event.currentTarget.dataset.actionName; // data-action-name="prevent|debounce(1000)|save" - const directives = parseDirectives(rawAction); + const directives = parseDirectives (rawAction); + let debounce: number|boolean = false; directives.forEach((directive) => { - this.pendingActions.push({ - name: directive.action, - args: directive.named - }); - - let handled = false; const validModifiers: Map void> = new Map(); validModifiers.set('prevent', () => { event.preventDefault(); @@ -183,15 +158,7 @@ export default class extends Controller implements LiveController { } }); validModifiers.set('debounce', (modifier: DirectiveModifier) => { - const length: number = modifier.value ? parseInt(modifier.value) : this.getDefaultDebounce(); - - this.#clearRequestDebounceTimeout(); - this.requestDebounceTimeout = window.setTimeout(() => { - this.requestDebounceTimeout = null; - this.#startPendingRequest(); - }, length); - - handled = true; + debounce = modifier.value ? parseInt(modifier.value) : true; }); directive.modifiers.forEach((modifier) => { @@ -206,39 +173,93 @@ export default class extends Controller implements LiveController { console.warn(`Unknown modifier ${modifier.name} in action "${rawAction}". Available modifiers are: ${Array.from(validModifiers.keys()).join(', ')}.`); }); - if (!handled) { - // possible case where this element is also a "model" element - // if so, to be safe, slightly delay the action so that the - // change/input listener on LiveController can process the - // model change *before* sending the action - if (getModelDirectiveFromElement(event.currentTarget, false)) { - this.pendingActionTriggerModelElement = event.currentTarget; - this.#clearRequestDebounceTimeout(); - window.setTimeout(() => { - this.pendingActionTriggerModelElement = null; - this.#startPendingRequest(); - }, 10); - - return; - } + this.component.action(directive.action, directive.named, debounce); - this.#startPendingRequest(); + // TODO: fix this + // possible case where this element is also a "model" element + // if so, to be safe, slightly delay the action so that the + // change/input listener on LiveController can process the + // model change *before* sending the action + if (getModelDirectiveFromElement(event.currentTarget, false)) { + this.pendingActionTriggerModelElement = event.currentTarget; + //this.#clearRequestDebounceTimeout(); } }) } $render() { - this.isRerenderRequested = true; - this.#startPendingRequest(); + this.component.render(); + } + + /** + * Update a model value. + * + * The extraModelName should be set to the "name" attribute of an element + * if it has one. This is only important in a parent/child component, + * where, in the child, you might be updating a "foo" model, but you + * also want this update to "sync" to the parent component's "bar" model. + * Typically, setup on a field like this: + * + * + * + * @param {string} model The model to update + * @param {any} value The new value + * @param {boolean} shouldRender Whether a re-render should be triggered + * @param {string|null} extraModelName Another model name that this might go by in a parent component. + * @param {UpdateModelOptions} options + */ + $updateModel(model: string, value: any, shouldRender = true, extraModelName: string|null = null, options: UpdateModelOptions = {}) { + const modelName = normalizeModelName(model); + const normalizedExtraModelName = extraModelName ? normalizeModelName(extraModelName) : null; + + // TODO: support this again + if (options.dispatch !== false) { + this._dispatchEvent('live:update-model', { + modelName, + extraModelName: normalizedExtraModelName, + value + }); + } + + this.component.set(model, value, shouldRender, options.debounce); + } + + private handleInputEvent(event: Event) { + const target = event.target as Element; + if (!target) { + return; + } + + this.updateModelFromElementEvent(target, 'input') + } + + private handleChangeEvent(event: Event) { + const target = event.target as Element; + if (!target) { + return; + } + + this.updateModelFromElementEvent(target, 'change') } /** + * Sets a model given an element and some event. + * + * This parses the "data-model" from the element and takes + * into account modifiers like "debounce", "norender" and "on()". + * + * This is used, for example, the grab the new value from an input + * on "change" and set that new value onto the model. + * + * It's also used to, on click, set the value from a button + * with data-model="" and data-value"". + * * @param element * @param eventName If specified (e.g. "input" or "change"), the model may * skip updating if the on() modifier is passed (e.g. on(change)). * If not passed, the model will always be updated. */ - _updateModelFromElement(element: Element, eventName: string|null) { + private updateModelFromElementEvent(element: Element, eventName: string|null) { if (!elementBelongsToThisController(element, this)) { return; } @@ -248,11 +269,6 @@ export default class extends Controller implements LiveController { } const modelDirective = getModelDirectiveFromElement(element, false); - if (eventName === 'input') { - const modelName = modelDirective ? modelDirective.action : null; - // track any inputs that are "unsynced" - this.unsyncedInputs.add(element, modelName); - } // if not tied to a model, no more work to be done if (!modelDirective) { @@ -261,7 +277,7 @@ export default class extends Controller implements LiveController { let shouldRender = true; let targetEventName = 'input'; - let debounce: number|null = null; + let debounce: number|boolean = false; modelDirective.modifiers.forEach((modifier) => { switch (modifier.name) { @@ -282,7 +298,7 @@ export default class extends Controller implements LiveController { break; case 'debounce': - debounce = modifier.value ? parseInt(modifier.value) : this.getDefaultDebounce(); + debounce = modifier.value ? parseInt(modifier.value) : true; break; default: @@ -303,496 +319,19 @@ export default class extends Controller implements LiveController { return; } - if (null === debounce) { + if (false === debounce) { if (targetEventName === 'input') { - // for the input event, add a debounce by default - debounce = this.getDefaultDebounce(); + // true debounce will cause default to be used + debounce = true; } else { // for change, add no debounce debounce = 0; } } - const finalValue = getValueFromElement(element, this.valueStore); - - this.$updateModel( - modelDirective.action, - finalValue, - shouldRender, - element.hasAttribute('name') ? element.getAttribute('name') : null, - { - debounce - } - ); - } - - /** - * Update a model value. - * - * The extraModelName should be set to the "name" attribute of an element - * if it has one. This is only important in a parent/child component, - * where, in the child, you might be updating a "foo" model, but you - * also want this update to "sync" to the parent component's "bar" model. - * Typically, setup on a field like this: - * - * - * - * @param {string} model The model to update - * @param {any} value The new value - * @param {boolean} shouldRender Whether a re-render should be triggered - * @param {string|null} extraModelName Another model name that this might go by in a parent component. - * @param {UpdateModelOptions} options - */ - $updateModel(model: string, value: any, shouldRender = true, extraModelName: string|null = null, options: UpdateModelOptions = {}) { - const modelName = normalizeModelName(model); - const normalizedExtraModelName = extraModelName ? normalizeModelName(extraModelName) : null; - - // if there is a "validatedFields" data, it means this component wants - // to track which fields have been / should be validated. - // in that case, when the model is updated, mark that it should be validated - if (this.valueStore.has('validatedFields')) { - const validatedFields = [...this.valueStore.get('validatedFields')]; - if (validatedFields.indexOf(modelName) === -1) { - validatedFields.push(modelName); - } - this.valueStore.set('validatedFields', validatedFields); - } - - if (options.dispatch !== false) { - this._dispatchEvent('live:update-model', { - modelName, - extraModelName: normalizedExtraModelName, - value - }); - } - - // we do not send old and new data to the server - // we merge in the new data now - // TODO: handle edge case for top-level of a model with "exposed" props - // For example, suppose there is a "post" field but "post.title" is exposed. - // If there is a data-model="post", then the "post" data - which was - // previously an array with "id" and "title" fields - will now be set - // directly to the new post id (e.g. 4). From a saving standpoint, - // that is fine: the server sees the "4" and uses it for the post data. - // However, there is an edge case where the user changes data-model="post" - // and then, for some reason, they don't want an immediate re-render. - // Then, they modify the data-model="post.title" field. In theory, - // we should be smart enough to convert the post data - which is now - // the string "4" - back into an array with [id=4, title=new_title]. - this.valueStore.set(modelName, value); - - // the model's data is no longer unsynced - this.unsyncedInputs.markModelAsSynced(modelName); - - // skip rendering if there is an action Ajax call processing - if (shouldRender) { - let debounce: number = this.getDefaultDebounce(); - if (options.debounce !== undefined && options.debounce !== null) { - debounce = options.debounce; - } - - this.#clearRequestDebounceTimeout(); - // debouncing even with a 0 value is enough to allow any other potential - // events happening right now (e.g. from custom user JavaScript) to - // finish setting other models before making the request. - this.requestDebounceTimeout = window.setTimeout(() => { - this.requestDebounceTimeout = null; - this.isRerenderRequested = true; - this.#startPendingRequest(); - }, debounce); - } - } - - /** - * Makes a request to the server with all pending actions/updates, if any. - */ - #startPendingRequest(): void { - if (!this.backendRequest && (this.pendingActions.length > 0 || this.isRerenderRequested)) { - this.#makeRequest(); - } - } - - #makeRequest() { - const splitUrl = this.urlValue.split('?'); - let [url] = splitUrl - const [, queryString] = splitUrl; - const params = new URLSearchParams(queryString || ''); - - const actions = this.pendingActions; - this.pendingActions = []; - this.isRerenderRequested = false; - // we're making a request NOW, so no need to make another one after debouncing - this.#clearRequestDebounceTimeout(); - - const fetchOptions: RequestInit = {}; - fetchOptions.headers = { - 'Accept': 'application/vnd.live-component+html', - }; - - const updatedModels = this.valueStore.updatedModels; - if (actions.length === 0 && this._willDataFitInUrl(this.valueStore.asJson(), params)) { - params.set('data', this.valueStore.asJson()); - updatedModels.forEach((model) => { - params.append('updatedModels[]', model); - }); - fetchOptions.method = 'GET'; - } else { - fetchOptions.method = 'POST'; - fetchOptions.headers['Content-Type'] = 'application/json'; - const requestData: any = { data: this.valueStore.all() }; - requestData.updatedModels = updatedModels; - - if (actions.length > 0) { - // one or more ACTIONs - if (this.csrfValue) { - fetchOptions.headers['X-CSRF-TOKEN'] = this.csrfValue; - } - - if (actions.length === 1) { - // simple, single action - requestData.args = actions[0].args; - - url += `/${encodeURIComponent(actions[0].name)}`; - } else { - url += '/_batch'; - requestData.actions = actions; - } - } - - fetchOptions.body = JSON.stringify(requestData); - } - - const paramsString = params.toString(); - const thisPromise = fetch(`${url}${paramsString.length > 0 ? `?${paramsString}` : ''}`, fetchOptions); - this.backendRequest = new BackendRequest(thisPromise, actions.map(action => action.name)); - // loading should start after this.backendRequest is started but before - // updateModels is cleared so it has full data about actions in the - // current request and also updated models. - this._onLoadingStart(); - this.valueStore.updatedModels = []; - thisPromise.then(async (response) => { - // if the response does not contain a component, render as an error - const html = await response.text(); - if (response.headers.get('Content-Type') !== 'application/vnd.live-component+html') { - this.renderError(html); - - return; - } - - this.#processRerender(html, response); - - this.backendRequest = null; - this.#startPendingRequest(); - }) - } - - /** - * Processes the response from an AJAX call and uses it to re-render. - * - * @private - */ - #processRerender(html: string, response: Response) { - // check if the page is navigating away - if (!this.isConnected) { - return; - } - - if (response.headers.get('Location')) { - // action returned a redirect - if (typeof Turbo !== 'undefined') { - Turbo.visit(response.headers.get('Location')); - } else { - window.location.href = response.headers.get('Location') || ''; - } - - return; - } - - // remove the loading behavior now so that when we morphdom - // "diffs" the elements, any loading differences will not cause - // elements to appear different unnecessarily - this._onLoadingFinish(); - - if (!this._dispatchEvent('live:render', html, true, true)) { - // preventDefault() was called - return; - } - - /** - * For any models modified since the last request started, grab - * their value now: we will re-set them after the new data from - * the server has been processed. - */ - const modifiedModelValues: any = {}; - this.valueStore.updatedModels.forEach((modelName) => { - modifiedModelValues[modelName] = this.valueStore.get(modelName); - }); - - // merge/patch in the new HTML - this._executeMorphdom(html, this.unsyncedInputs.all()); - - // reset the modified values back to their client-side version - Object.keys(modifiedModelValues).forEach((modelName) => { - this.valueStore.set(modelName, modifiedModelValues[modelName]); - }); - - this.synchronizeValueOfModelFields(); - } - - _onLoadingStart() { - this._handleLoadingToggle(true); - } - - _onLoadingFinish(targetElement: HTMLElement|SVGElement|null = null) { - this._handleLoadingToggle(false, targetElement); - } - - _handleLoadingToggle(isLoading: boolean, targetElement: HTMLElement|SVGElement|null = null) { - if (isLoading) { - this._addAttributes(this.element, ['busy']); - } else { - this._removeAttributes(this.element, ['busy']); - } - - this._getLoadingDirectives(targetElement).forEach(({ element, directives }) => { - // so we can track, at any point, if an element is in a "loading" state - if (isLoading) { - this._addAttributes(element, ['data-live-is-loading']); - } else { - this._removeAttributes(element, ['data-live-is-loading']); - } - - directives.forEach((directive) => { - this._handleLoadingDirective(element, isLoading, directive) - }); - }); - } - - /** - * @private - */ - _handleLoadingDirective(element: HTMLElement|SVGElement, isLoading: boolean, directive: Directive) { - const finalAction = parseLoadingAction(directive.action, isLoading); - - const targetedActions: string[] = []; - const targetedModels: string[] = []; - let delay = 0; - - const validModifiers: Map void> = new Map(); - validModifiers.set('delay', (modifier: DirectiveModifier) => { - // if loading has *stopped*, the delay modifier has no effect - if (!isLoading) { - return; - } - - delay = modifier.value ? parseInt(modifier.value) : 200; - }); - validModifiers.set('action', (modifier: DirectiveModifier) => { - if (!modifier.value) { - throw new Error(`The "action" in data-loading must have an action name - e.g. action(foo). It's missing for "${directive.getString()}"`); - } - targetedActions.push(modifier.value); - }); - validModifiers.set('model', (modifier: DirectiveModifier) => { - if (!modifier.value) { - throw new Error(`The "model" in data-loading must have an action name - e.g. model(foo). It's missing for "${directive.getString()}"`); - } - targetedModels.push(modifier.value); - }); - - directive.modifiers.forEach((modifier) => { - if (validModifiers.has(modifier.name)) { - // variable is entirely to make ts happy - const callable = validModifiers.get(modifier.name) ?? (() => {}); - callable(modifier); - - return; - } - - throw new Error(`Unknown modifier "${modifier.name}" used in data-loading="${directive.getString()}". Available modifiers are: ${Array.from(validModifiers.keys()).join(', ')}.`) - }); - - // if loading is being activated + action modifier, only apply if the action is on the request - if (isLoading && targetedActions.length > 0 && this.backendRequest && !this.backendRequest.containsOneOfActions(targetedActions)) { - return; - } - - // if loading is being activated + model modifier, only apply if the model is modified - if (isLoading && targetedModels.length > 0 && !this.valueStore.areAnyModelsUpdated(targetedModels)) { - return; - } - - let loadingDirective: (() => void); - - switch (finalAction) { - case 'show': - loadingDirective = () => { - this._showElement(element) - }; - break; - - case 'hide': - loadingDirective = () => this._hideElement(element); - break; - - case 'addClass': - loadingDirective = () => this._addClass(element, directive.args); - break; - - case 'removeClass': - loadingDirective = () => this._removeClass(element, directive.args); - break; - - case 'addAttribute': - loadingDirective = () => this._addAttributes(element, directive.args); - break; - - case 'removeAttribute': - loadingDirective = () => this._removeAttributes(element, directive.args); - break; - - default: - throw new Error(`Unknown data-loading action "${finalAction}"`); - } - - if (delay) { - window.setTimeout(() => { - if (this.isRequestActive()) { - loadingDirective(); - } - }, delay); - - return; - } - - loadingDirective(); - } - - _getLoadingDirectives(targetElement: HTMLElement|SVGElement|null = null) { - const loadingDirectives: ElementLoadingDirectives[] = []; - const element = targetElement || this.element; - - element.querySelectorAll('[data-loading]').forEach((element => { - if (!(element instanceof HTMLElement) && !(element instanceof SVGElement)) { - throw new Error('Invalid Element Type'); - } - - // use "show" if the attribute is empty - const directives = parseDirectives(element.dataset.loading || 'show'); - - loadingDirectives.push({ - element, - directives, - }); - })); - - return loadingDirectives; - } - - _showElement(element: HTMLElement|SVGElement) { - element.style.display = 'inline-block'; - } - - _hideElement(element: HTMLElement|SVGElement) { - element.style.display = 'none'; - } - - _addClass(element: HTMLElement|SVGElement, classes: string[]) { - element.classList.add(...combineSpacedArray(classes)); - } - - _removeClass(element: HTMLElement|SVGElement, classes: string[]) { - element.classList.remove(...combineSpacedArray(classes)); - - // remove empty class="" to avoid morphdom "diff" problem - if (element.classList.length === 0) { - this._removeAttributes(element, ['class']); - } - } - - _addAttributes(element: Element, attributes: string[]) { - attributes.forEach((attribute) => { - element.setAttribute(attribute, ''); - }) - } - - _removeAttributes(element: Element, attributes: string[]) { - attributes.forEach((attribute) => { - element.removeAttribute(attribute); - }) - } - - _willDataFitInUrl(dataJson: string, params: URLSearchParams) { - const urlEncodedJsonData = new URLSearchParams(dataJson).toString(); - - // if the URL gets remotely close to 2000 chars, it may not fit - return (urlEncodedJsonData + params.toString()).length < 1500; - } - - _executeMorphdom(newHtml: string, modifiedElements: Array) { - const newElement = htmlToElement(newHtml); - // make sure everything is in non-loading state, the same as the HTML currently on the page - this._onLoadingFinish(newElement); - morphdom(this.element, newElement, { - getNodeKey: (node: Node) => { - if (!(node instanceof HTMLElement)) { - return; - } - - return node.dataset.liveId; - }, - onBeforeElUpdated: (fromEl, toEl) => { - if (!(fromEl instanceof HTMLElement) || !(toEl instanceof HTMLElement)) { - return false; - } - - // if this field's value has been modified since this HTML was - // requested, set the toEl's value to match the fromEl - if (modifiedElements.includes(fromEl)) { - setValueOnElement(toEl, getValueFromElement(fromEl, this.valueStore)) - } - - // https://github.com/patrick-steele-idem/morphdom#can-i-make-morphdom-blaze-through-the-dom-tree-even-faster-yes - if (fromEl.isEqualNode(toEl)) { - // the nodes are equal, but the "value" on some might differ - // lets try to quickly compare a bit more deeply - const normalizedFromEl = cloneHTMLElement(fromEl); - normalizeAttributesForComparison(normalizedFromEl); - - const normalizedToEl = cloneHTMLElement(toEl); - normalizeAttributesForComparison(normalizedToEl); - - if (normalizedFromEl.isEqualNode(normalizedToEl)) { - // don't bother updating - return false; - } - } - - // avoid updating child components: they will handle themselves - const controllerName = fromEl.hasAttribute('data-controller') ? fromEl.getAttribute('data-controller') : null; - if (controllerName - && controllerName.split(' ').indexOf('live') !== -1 - && fromEl !== this.element - && !this._shouldChildLiveElementUpdate(fromEl, toEl) - ) { - return false; - } + const finalValue = getValueFromElement(element, this.component.valueStore); - // look for data-live-ignore, and don't update - return !fromEl.hasAttribute('data-live-ignore'); - }, - - onBeforeNodeDiscarded(node) { - if (!(node instanceof HTMLElement)) { - // text element - return true; - } - - return !node.hasAttribute('data-live-ignore'); - } - }); - // restore the data-original-data attribute - this._exposeOriginalData(); + this.component.set(modelDirective.action, finalValue, shouldRender, debounce); } handleConnectedControllerEvent(event: any) { @@ -821,35 +360,16 @@ export default class extends Controller implements LiveController { } } - handleUpdateModelEvent(event: any) { - // ignore events that we dispatched - if (event.target === this.element) { - return; - } - - this._handleChildComponentUpdateModel(event); - } - - handleInputEvent(event: Event) { - const target = event.target as Element; - if (!target) { - return; - } - - this._updateModelFromElement(target, 'input') - } - - handleChangeEvent(event: Event) { - const target = event.target as Element; - if (!target) { - return; - } - - this._updateModelFromElement(target, 'change') + _dispatchEvent(name: string, payload: any = null, canBubble = true, cancelable = false) { + return this.element.dispatchEvent(new CustomEvent(name, { + bubbles: canBubble, + cancelable, + detail: payload + })); } - _initiatePolling() { - this._stopAllPolling(); + private initializePolling(): void { + this.component.clearPolling(); if ((this.element as HTMLElement).dataset.poll === undefined) { return; @@ -874,253 +394,8 @@ export default class extends Controller implements LiveController { } }); - this._startPoll(directive.action, duration); - }) - } - - _startPoll(actionName: string, duration: number) { - let callback: () => void; - if (actionName.charAt(0) === '$') { - callback = () => { - (this as any)[actionName](); - } - } else { - callback = () => { - this.pendingActions.push({ name: actionName, args: {}}) - this.#startPendingRequest(); - } - } - - const timer = setInterval(() => { - callback(); - }, duration); - this.pollingIntervals.push(timer); - } - - _dispatchEvent(name: string, payload: object | string | null = null, canBubble = true, cancelable = false) { - return this.element.dispatchEvent(new CustomEvent(name, { - bubbles: canBubble, - cancelable, - detail: payload - })); - } - - _handleChildComponentUpdateModel(event: any) { - const mainModelName = event.detail.modelName; - const potentialModelNames = [ - { name: mainModelName, required: false }, - ]; - if (event.detail.extraModelName) { - potentialModelNames.push({ name: event.detail.extraModelName, required: false }); - } - - const modelMapElement = event.target.closest('[data-model-map]'); - if (this.element.contains(modelMapElement)) { - const directives = parseDirectives(modelMapElement.dataset.modelMap); - - directives.forEach((directive) => { - let from = null; - directive.modifiers.forEach((modifier) => { - switch (modifier.name) { - case 'from': - if (!modifier.value) { - throw new Error(`The from() modifier requires a model name in data-model-map="${modelMapElement.dataset.modelMap}"`); - } - from = modifier.value; - - break; - default: - console.warn(`Unknown modifier "${modifier.name}" in data-model-map="${modelMapElement.dataset.modelMap}".`); - } - }); - - if (!from) { - throw new Error(`Missing from() modifier in data-model-map="${modelMapElement.dataset.modelMap}". The format should be "from(childModelName)|parentModelName"`); - } - - // only look maps for the model currently being updated - if (from !== mainModelName) { - return; - } - - potentialModelNames.push({ name: directive.action, required: true }); - }); - } - - potentialModelNames.reverse(); - let foundModelName: string | null = null; - potentialModelNames.forEach((potentialModel) => { - if (foundModelName) { - return; - } - - if (this.valueStore.hasAtTopLevel(potentialModel.name)) { - foundModelName = potentialModel.name; - - return; - } - - if (potentialModel.required) { - throw new Error(`The model name "${potentialModel.name}" does not exist! Found in data-model-map="from(${mainModelName})|${potentialModel.name}"`); - } - }); - - if (!foundModelName) { - return; - } - - this.$updateModel( - foundModelName, - event.detail.value, - false, - null, - { - dispatch: false - } - ); - } - - /** - * Determines if a child live element should be re-rendered. - * - * This is called when this element re-renders and detects that - * a child element is inside. Normally, in that case, we do not - * re-render the child element. However, if we detect that the - * "data" on the child element has changed from its initial data, - * then this will trigger a re-render. - */ - _shouldChildLiveElementUpdate(fromEl: HTMLElement, toEl: HTMLElement): boolean { - if (!fromEl.dataset.originalData) { - throw new Error('Missing From Element originalData'); - } - if (!fromEl.dataset.liveDataValue) { - throw new Error('Missing From Element liveDataValue'); - } - if (!toEl.dataset.liveDataValue) { - throw new Error('Missing To Element liveDataValue'); - } - - return haveRenderedValuesChanged( - fromEl.dataset.originalData, - fromEl.dataset.liveDataValue, - toEl.dataset.liveDataValue - ); - } - - _exposeOriginalData() { - if (!(this.element instanceof HTMLElement)) { - throw new Error('Invalid Element Type'); - } - - this.element.dataset.originalData = this.originalDataJSON; - } - - /** - * Helps "re-normalize" certain root element attributes after a re-render. - * - * 1) Re-establishes the data-original-data attribute if missing. - * 2) Stops or re-initializes data-poll - * - * This happens if a parent component re-renders a child component - * and morphdom *updates* child. This commonly happens if a parent - * component is around a list of child components, and changing - * something in the parent causes the list to change. In that case, - * the a child component might be removed while another is added. - * But to morphdom, this sometimes looks like an "update". The result - * is that the child component is re-rendered, but the child component - * is not re-initialized. And so, the data-original-data attribute - * is missing and never re-established. - */ - _startAttributesMutationObserver() { - if (!(this.element instanceof HTMLElement)) { - throw new Error('Invalid Element Type'); - } - const element : HTMLElement = this.element; - - this.mutationObserver = new MutationObserver((mutations) => { - mutations.forEach((mutation) => { - if (mutation.type === 'attributes') { - if (!element.dataset.originalData) { - this.originalDataJSON = this.valueStore.asJson(); - this._exposeOriginalData(); - } - - this._initiatePolling(); - } - }); - }); - - this.mutationObserver.observe(this.element, { - attributes: true - }); - } - - private getDefaultDebounce(): number { - return this.hasDebounceValue ? this.debounceValue : DEFAULT_DEBOUNCE; - } - - private _stopAllPolling() { - this.pollingIntervals.forEach((interval) => { - clearInterval(interval); - }); - } - - // inspired by Livewire! - private async renderError(html: string) { - let modal = document.getElementById('live-component-error'); - if (modal) { - modal.innerHTML = ''; - } else { - modal = document.createElement('div'); - modal.id = 'live-component-error'; - modal.style.padding = '50px'; - modal.style.backgroundColor = 'rgba(0, 0, 0, .5)'; - modal.style.zIndex = '100000'; - modal.style.position = 'fixed'; - modal.style.width = '100vw'; - modal.style.height = '100vh'; - } - - const iframe = document.createElement('iframe'); - iframe.style.borderRadius = '5px'; - iframe.style.width = '100%'; - iframe.style.height = '100%'; - modal.appendChild(iframe); - - document.body.prepend(modal); - document.body.style.overflow = 'hidden'; - if (iframe.contentWindow) { - iframe.contentWindow.document.open(); - iframe.contentWindow.document.write(html); - iframe.contentWindow.document.close(); - } - - const closeModal = (modal: HTMLElement|null) => { - if (modal) { - modal.outerHTML = '' - } - document.body.style.overflow = 'visible' - } - - // close on click - modal.addEventListener('click', () => closeModal(modal)); - - // close on escape - modal.setAttribute('tabindex', '0'); - modal.addEventListener('keydown', e => { - if (e.key === 'Escape') { - closeModal(modal); - } + this.component.addPoll(directive.action, duration); }); - modal.focus(); - } - - #clearRequestDebounceTimeout() { - // clear any pending renders - if (this.requestDebounceTimeout) { - clearTimeout(this.requestDebounceTimeout); - this.requestDebounceTimeout = null; - } } /** @@ -1134,8 +409,9 @@ export default class extends Controller implements LiveController { * This method will set the "value" of that element to the value of * the "firstName" model. */ - private synchronizeValueOfModelFields(): void { - this.element.querySelectorAll('[data-model]').forEach((element) => { + // TODO: call this when needed + synchronizeValueOfModelFields(): void { + this.component.element.querySelectorAll('[data-model]').forEach((element: Element) => { if (!(element instanceof HTMLElement)) { throw new Error('Invalid element using data-model.'); } @@ -1152,59 +428,19 @@ export default class extends Controller implements LiveController { const modelName = modelDirective.action; // skip any elements whose model name is currently in an unsynced state - if (this.unsyncedInputs.getModifiedModels().includes(modelName)) { + if (this.component.getUnsyncedModels().includes(modelName)) { return; } - if (this.valueStore.has(modelName)) { - setValueOnElement(element, this.valueStore.get(modelName)) + if (this.component.valueStore.has(modelName)) { + setValueOnElement(element, this.component.valueStore.get(modelName)) } // for select elements without a blank value, one might be selected automatically // https://github.com/symfony/ux/issues/469 if (element instanceof HTMLSelectElement && !element.multiple) { - this.valueStore.set(modelName, getValueFromElement(element, this.valueStore)); + this.component.valueStore.set(modelName, getValueFromElement(element, this.component.valueStore)); } }) } - - private isRequestActive(): boolean { - return !!this.backendRequest; - } -} - -class BackendRequest { - promise: Promise; - actions: string[]; - - constructor(promise: Promise, actions: string[]) { - this.promise = promise; - this.actions = actions; - } - - /** - * Does this BackendRequest contain at least on action in targetedActions? - */ - containsOneOfActions(targetedActions: string[]) { - return (this.actions.filter(action => targetedActions.includes(action))).length > 0; - } -} - -const parseLoadingAction = function(action: string, isLoading: boolean) { - switch (action) { - case 'show': - return isLoading ? 'show' : 'hide'; - case 'hide': - return isLoading ? 'hide' : 'show'; - case 'addClass': - return isLoading ? 'addClass' : 'removeClass'; - case 'removeClass': - return isLoading ? 'removeClass' : 'addClass'; - case 'addAttribute': - return isLoading ? 'addAttribute' : 'removeAttribute'; - case 'removeAttribute': - return isLoading ? 'removeAttribute' : 'addAttribute'; - } - - throw new Error(`Unknown data-loading action "${action}"`); } diff --git a/src/LiveComponent/assets/src/morphdom.ts b/src/LiveComponent/assets/src/morphdom.ts new file mode 100644 index 00000000000..170f81410a2 --- /dev/null +++ b/src/LiveComponent/assets/src/morphdom.ts @@ -0,0 +1,94 @@ +import { +cloneHTMLElement, + setValueOnElement +} from "./dom_utils"; +import morphdom from "morphdom"; +import { normalizeAttributesForComparison } from "./normalize_attributes_for_comparison"; +import { haveRenderedValuesChanged } from "./have_rendered_values_changed"; + +export function executeMorphdom( + rootFromElement: HTMLElement, + rootToElement: HTMLElement, + modifiedElements: Array, + getElementValue: (element: HTMLElement) => any, + rootFromOriginalData: any, + rootFromCurrentData: any, + rootToCurrentData: any, +) { + // make sure everything is in non-loading state, the same as the HTML currently on the page + morphdom(rootFromElement, rootToElement, { + getNodeKey: (node: Node) => { + if (!(node instanceof HTMLElement)) { + return; + } + + return node.dataset.liveId; + }, + onBeforeElUpdated: (fromEl, toEl) => { + if (!(fromEl instanceof HTMLElement) || !(toEl instanceof HTMLElement)) { + return false; + } + + // if this field's value has been modified since this HTML was + // requested, set the toEl's value to match the fromEl + if (modifiedElements.includes(fromEl)) { + setValueOnElement(toEl, getElementValue(fromEl)) + } + + // https://github.com/patrick-steele-idem/morphdom#can-i-make-morphdom-blaze-through-the-dom-tree-even-faster-yes + if (fromEl.isEqualNode(toEl)) { + // the nodes are equal, but the "value" on some might differ + // lets try to quickly compare a bit more deeply + const normalizedFromEl = cloneHTMLElement(fromEl); + normalizeAttributesForComparison(normalizedFromEl); + + const normalizedToEl = cloneHTMLElement(toEl); + normalizeAttributesForComparison(normalizedToEl); + + if (normalizedFromEl.isEqualNode(normalizedToEl)) { + // don't bother updating + return false; + } + } + + // avoid updating child components: they will handle themselves + const controllerName = fromEl.hasAttribute('data-controller') ? fromEl.getAttribute('data-controller') : null; + if (controllerName + && controllerName.split(' ').indexOf('live') !== -1 + && fromEl !== rootFromElement + && !shouldChildLiveElementUpdate(rootFromOriginalData, rootFromCurrentData, rootToCurrentData) + ) { + return false; + } + + // look for data-live-ignore, and don't update + return !fromEl.hasAttribute('data-live-ignore'); + }, + + onBeforeNodeDiscarded(node) { + if (!(node instanceof HTMLElement)) { + // text element + return true; + } + + return !node.hasAttribute('data-live-ignore'); + } + }); +} + +/** + * Determines if a child live element should be re-rendered. + * + * This is called when this element re-renders and detects that + * a child element is inside. Normally, in that case, we do not + * re-render the child element. However, if we detect that the + * "data" on the child element has changed from its initial data, + * then this will trigger a re-render. + */ +const shouldChildLiveElementUpdate = function(rootFromOriginalData: any, rootFromCurrentData: any, rootToCurrentData: any): boolean { + return haveRenderedValuesChanged( + rootFromOriginalData, + rootFromCurrentData, + rootToCurrentData + ); +} diff --git a/src/LiveComponent/assets/test/Component/index.test.ts b/src/LiveComponent/assets/test/Component/index.test.ts new file mode 100644 index 00000000000..ffd2130f8d2 --- /dev/null +++ b/src/LiveComponent/assets/test/Component/index.test.ts @@ -0,0 +1,85 @@ +import Component, {createComponent} from "../../src/Component"; +import {BackendAction, BackendInterface} from "../../src/Backend"; +import { + DataModelElementResolver +} from "../../src/Component/ModelElementResolver"; +import BackendRequest from "../../src/BackendRequest"; +import { Response } from 'node-fetch'; + +describe('Component class', () => { + describe('Proxy wrapper', () => { + interface MockBackend extends BackendInterface { + actions: BackendAction[], + } + + const makeDummyComponent = (): { proxy: Component, backend: MockBackend } => { + const backend: MockBackend = { + actions: [], + makeRequest(data: any, actions: BackendAction[], updatedModels: string[]): BackendRequest { + this.actions = actions; + + return new BackendRequest( + // @ts-ignore Response doesn't quite match the underlying interface + new Promise((resolve) => resolve(new Response('
'))), + [], + [] + ) + } + } + + return { + proxy: createComponent( + document.createElement('div'), + {firstName: ''}, + backend, + new DataModelElementResolver() + ), + backend + } + } + + it('forwards real property gets', () => { + const { proxy } = makeDummyComponent(); + expect(proxy.element).toBeInstanceOf(HTMLDivElement); + }); + + it('forwards real method calls', () => { + const { proxy } = makeDummyComponent(); + proxy.set('firstName', 'Ryan'); + expect(proxy.valueStore.get('firstName')).toBe('Ryan'); + }); + + it('forwards real property sets', () => { + const { proxy } = makeDummyComponent(); + proxy.defaultDebounce = 123; + expect(proxy.defaultDebounce).toBe(123); + }); + + it('calls get() on the component', () => { + const { proxy } = makeDummyComponent(); + proxy.set('firstName', 'Ryan'); + // @ts-ignore + expect(proxy.firstName).toBe('Ryan'); + }); + + it('calls set() on the component', () => { + const { proxy } = makeDummyComponent(); + // @ts-ignore + proxy.firstName = 'Ryan'; + expect(proxy.get('firstName')).toBe('Ryan'); + }); + + it('calls an action on a component', async () => { + const { proxy, backend } = makeDummyComponent(); + // @ts-ignore + proxy.save({ foo: 'bar', secondArg: 'secondValue' }); + + // ugly: the action delays for 0ms, so we just need a TINy + // delay here before we start asserting + await (new Promise(resolve => setTimeout(resolve, 5))); + expect(backend.actions).toHaveLength(1); + expect(backend.actions[0].name).toBe('save'); + expect(backend.actions[0].args).toEqual({ foo: 'bar', secondArg: 'secondValue' }); + }); + }) +}); diff --git a/src/LiveComponent/assets/test/UnsyncedInputContainer.test.ts b/src/LiveComponent/assets/test/UnsyncedInputContainer.test.ts index 00133f1f683..87d31060b2a 100644 --- a/src/LiveComponent/assets/test/UnsyncedInputContainer.test.ts +++ b/src/LiveComponent/assets/test/UnsyncedInputContainer.test.ts @@ -1,4 +1,4 @@ -import UnsyncedInputContainer from '../src/UnsyncedInputContainer'; +import { UnsyncedInputContainer } from '../src/Component/UnsyncedInputsTracker'; import { htmlToElement } from '../src/dom_utils'; describe('UnsyncedInputContainer', () => { diff --git a/src/LiveComponent/assets/test/ValueStore.test.ts b/src/LiveComponent/assets/test/ValueStore.test.ts index c7dc3388ebe..849c79d00ff 100644 --- a/src/LiveComponent/assets/test/ValueStore.test.ts +++ b/src/LiveComponent/assets/test/ValueStore.test.ts @@ -1,63 +1,54 @@ -import ValueStore from '../src/ValueStore'; -import { LiveController } from '../src/live_controller'; - -const createStore = function(data: any) { - return new class implements LiveController { - dataValue = data; - childComponentControllers = []; - element: Element; - } -} +import ValueStore from '../src/Component/ValueStore'; describe('ValueStore', () => { it('get() returns simple data', () => { - const container = new ValueStore(createStore({ + const container = new ValueStore({ firstName: 'Ryan' - })); + }); expect(container.get('firstName')).toEqual('Ryan'); }); it('get() returns undefined if not set', () => { - const container = new ValueStore(createStore({})); + const container = new ValueStore({}); expect(container.get('firstName')).toBeUndefined(); }); it('get() returns deep data from property path', () => { - const container = new ValueStore(createStore({ + const container = new ValueStore({ user: { firstName: 'Ryan' } - })); + }); expect(container.get('user.firstName')).toEqual('Ryan'); }); it('has() returns true if path exists', () => { - const container = new ValueStore(createStore({ + const container = new ValueStore({ user: { firstName: 'Ryan' } - })); + }); expect(container.has('user.firstName')).toBeTruthy(); }); it('has() returns false if path does not exist', () => { - const container = new ValueStore(createStore({ + const container = new ValueStore({ user: { firstName: 'Ryan' } - })); + }); expect(container.has('user.lastName')).toBeFalsy(); }); it('set() overrides simple data', () => { - const container = new ValueStore(createStore({ + const container = new ValueStore({ firstName: 'Kevin' - })); + }); container.set('firstName', 'Ryan'); @@ -65,11 +56,11 @@ describe('ValueStore', () => { }); it('set() overrides deep data', () => { - const container = new ValueStore(createStore({ + const container = new ValueStore({ user: { firstName: 'Ryan' } - })); + }); container.set('user.firstName', 'Kevin'); @@ -77,7 +68,7 @@ describe('ValueStore', () => { }); it('set() errors if setting key that does not exist', () => { - const container = new ValueStore(createStore({})); + const container = new ValueStore({}); expect(() => { container.set('firstName', 'Ryan'); @@ -85,7 +76,7 @@ describe('ValueStore', () => { }); it('set() errors if setting deep data without parent', () => { - const container = new ValueStore(createStore({})); + const container = new ValueStore({}); expect(() => { container.set('user.firstName', 'Ryan'); @@ -93,9 +84,9 @@ describe('ValueStore', () => { }); it('set() errors if setting deep data that does not exist', () => { - const container = new ValueStore(createStore({ + const container = new ValueStore({ user: {} - })); + }); expect(() => { container.set('user.firstName', 'Ryan'); @@ -103,9 +94,9 @@ describe('ValueStore', () => { }); it('set() errors if setting deep data on a non-object', () => { - const container = new ValueStore(createStore({ + const container = new ValueStore({ user: 'Kevin' - })); + }); expect(() => { container.set('user.firstName', 'Ryan'); diff --git a/src/LiveComponent/assets/test/controller/action.test.ts b/src/LiveComponent/assets/test/controller/action.test.ts index d8103f5a54f..9c426848685 100644 --- a/src/LiveComponent/assets/test/controller/action.test.ts +++ b/src/LiveComponent/assets/test/controller/action.test.ts @@ -167,7 +167,10 @@ describe('LiveController Action Tests', () => { // save first, then type into the box getByText(test.element, 'Save').click(); - await userEvent.type(test.queryByDataModel('comment'), ' holes'); + // slight pause (should allow action request to start), then start typing + setTimeout(() => { + userEvent.type(test.queryByDataModel('comment'), ' holes'); + }, 10); await waitFor(() => expect(test.element).toHaveTextContent('Comment Saved!')); // render has not happened yet diff --git a/src/LiveComponent/assets/test/controller/child.test.ts b/src/LiveComponent/assets/test/controller/child.test.ts.todo similarity index 100% rename from src/LiveComponent/assets/test/controller/child.test.ts rename to src/LiveComponent/assets/test/controller/child.test.ts.todo diff --git a/src/LiveComponent/assets/test/controller/loading.test.ts b/src/LiveComponent/assets/test/controller/loading.test.ts index 1357f975479..7282dfbc432 100644 --- a/src/LiveComponent/assets/test/controller/loading.test.ts +++ b/src/LiveComponent/assets/test/controller/loading.test.ts @@ -79,6 +79,7 @@ describe('LiveController data-loading Tests', () => { .delayResponse(50) .init(); getByText(test.element, 'Other Action').click(); + await waitFor(() => expect(test.element).toHaveAttribute('busy')); // it should not be loading yet expect(getByTestId(test.element, 'loading-element')).not.toBeVisible(); await waitFor(() => expect(test.element).not.toHaveAttribute('busy')); @@ -90,6 +91,8 @@ describe('LiveController data-loading Tests', () => { .delayResponse(50) .init(); getByText(test.element, 'Save').click(); + // wait for the ajax call to start (will be 0ms, but with a timeout, so not *quite* instant) + await waitFor(() => expect(test.element).toHaveAttribute('busy')); // it SHOULD be loading now expect(getByTestId(test.element, 'loading-element')).toBeVisible(); await waitFor(() => expect(test.element).not.toHaveAttribute('busy')); @@ -171,6 +174,7 @@ describe('LiveController data-loading Tests', () => { getByText(test.element, 'Save').click(); getByText(test.element, 'Other Action').click(); + await waitFor(() => expect(test.element).toHaveAttribute('busy')); // it SHOULD be loading now expect(getByTestId(test.element, 'loading-element')).toBeVisible(); await waitFor(() => expect(test.element).not.toHaveAttribute('busy')); diff --git a/src/LiveComponent/assets/test/controller/model.test.ts b/src/LiveComponent/assets/test/controller/model.test.ts index 5ddd30b4451..587519b8145 100644 --- a/src/LiveComponent/assets/test/controller/model.test.ts +++ b/src/LiveComponent/assets/test/controller/model.test.ts @@ -41,7 +41,7 @@ describe('LiveController data-model Tests', () => { }); await waitFor(() => expect(test.element).toHaveTextContent('Name is: Ryan Weaver')); - expect(test.controller.dataValue).toEqual({name: 'Ryan Weaver'}); + expect(test.component.valueStore.all()).toEqual({name: 'Ryan Weaver'}); // assert the input is still focused after rendering expect(document.activeElement).toBeInstanceOf(HTMLElement); @@ -69,7 +69,7 @@ describe('LiveController data-model Tests', () => { // component never re-rendered expect(test.element).toHaveTextContent('Name is: Ryan'); // but the value was updated - expect(test.controller.dataValue).toEqual({name: 'Ryan Weaver'}); + expect(test.component.valueStore.all()).toEqual({name: 'Ryan Weaver'}); }); it('waits to update data and rerender until change event with on(change)', async () => { @@ -94,7 +94,7 @@ describe('LiveController data-model Tests', () => { // component has not *yet* re-rendered expect(test.element).toHaveTextContent('Name is: Ryan'); // the value has not *yet* been updated - expect(test.controller.dataValue).toEqual({name: 'Ryan'}); + expect(test.component.valueStore.all()).toEqual({name: 'Ryan'}); // NOW we expect the render test.expectsAjaxCall('get') @@ -105,7 +105,7 @@ describe('LiveController data-model Tests', () => { userEvent.click(getByText(test.element, 'Do nothing')); await waitFor(() => expect(test.element).toHaveTextContent('Name is: Ryan Weaver')); - expect(test.controller.dataValue).toEqual({name: 'Ryan Weaver'}); + expect(test.component.valueStore.all()).toEqual({name: 'Ryan Weaver'}); }); it('renders correctly with data-value and live#update on a non-input', async () => { @@ -124,7 +124,7 @@ describe('LiveController data-model Tests', () => { userEvent.click(getByText(test.element, 'Change name to Jan')); await waitFor(() => expect(test.element).toHaveTextContent('Name is: Jan')); - expect(test.controller.dataValue).toEqual({name: 'Jan'}); + expect(test.component.valueStore.all()).toEqual({name: 'Jan'}); }); it('falls back to using the name attribute when no data-model is present and
is ancestor', async () => { @@ -148,7 +148,7 @@ describe('LiveController data-model Tests', () => { await userEvent.type(test.queryByNameAttribute('color'), 'orange'); await waitFor(() => expect(test.element).toHaveTextContent('Favorite color: orange')); - expect(test.controller.dataValue).toEqual({ color: 'orange' }); + expect(test.component.valueStore.all()).toEqual({ color: 'orange' }); }); it('uses data-model when both name and data-model is present', async () => { @@ -174,7 +174,7 @@ describe('LiveController data-model Tests', () => { await userEvent.type(test.queryByDataModel('firstName'), 'Ryan'); await waitFor(() => expect(test.element).toHaveTextContent('First name: Ryan')); - expect(test.controller.dataValue).toEqual({ firstName: 'Ryan', name: '' }); + expect(test.component.valueStore.all()).toEqual({ firstName: 'Ryan', name: '' }); }); it('uses data-value when both value and data-value is present', async () => { @@ -219,7 +219,7 @@ describe('LiveController data-model Tests', () => { await userEvent.type(test.queryByDataModel('user[name]'), ' Weaver'); await waitFor(() => expect(test.element).toHaveTextContent('Name: Ryan Weaver')); - expect(test.controller.dataValue).toEqual({ user: { name: 'Ryan Weaver' } }); + expect(test.component.valueStore.all()).toEqual({ user: { name: 'Ryan Weaver' } }); }); it('sends correct data for checkbox fields', async () => { @@ -255,7 +255,7 @@ describe('LiveController data-model Tests', () => { await waitFor(() => expect(test.element).toHaveTextContent('Checkbox 2 is checked')); - expect(test.controller.dataValue).toEqual({form: {check1: '1', check2: '1'}}); + expect(test.component.valueStore.all()).toEqual({form: {check1: '1', check2: '1'}}); }); it('sends correct data for initially checked checkbox fields', async () => { @@ -290,7 +290,7 @@ describe('LiveController data-model Tests', () => { await userEvent.click(check1Element); await waitFor(() => expect(test.element).toHaveTextContent('Checkbox 1 is unchecked')); - expect(test.controller.dataValue).toEqual({form: {check1: null, check2: '1'}}); + expect(test.component.valueStore.all()).toEqual({form: {check1: null, check2: '1'}}); }); it('sends correct data for array valued checkbox fields', async () => { @@ -323,7 +323,7 @@ describe('LiveController data-model Tests', () => { await waitFor(() => expect(test.element).toHaveTextContent('Checkbox 2 is checked')); - expect(test.controller.dataValue).toEqual({form: {check: ['foo', 'bar']}}); + expect(test.component.valueStore.all()).toEqual({form: {check: ['foo', 'bar']}}); }); it('sends correct data for array valued checkbox fields with initial data', async () => { @@ -356,7 +356,7 @@ describe('LiveController data-model Tests', () => { await waitFor(() => expect(test.element).toHaveTextContent('Checkbox 1 is unchecked')); - expect(test.controller.dataValue).toEqual({form: {check: ['bar']}}); + expect(test.component.valueStore.all()).toEqual({form: {check: ['bar']}}); }); it('sends correct data for select multiple field', async () => { @@ -387,7 +387,7 @@ describe('LiveController data-model Tests', () => { await waitFor(() => expect(test.element).toHaveTextContent('Select: foo bar Option 2 is selected')); - expect(test.controller.dataValue).toEqual({form: {select: ['foo', 'bar']}}); + expect(test.component.valueStore.all()).toEqual({form: {select: ['foo', 'bar']}}); }); it('sends correct data for select multiple field with initial data', async () => { @@ -424,7 +424,7 @@ describe('LiveController data-model Tests', () => { await userEvent.deselectOptions(selectElement, 'bar'); await waitFor(() => expect(test.element).toHaveTextContent('Select: foo bar Option 2 is unselected')); - expect(test.controller.dataValue).toEqual({form: {select: []}}); + expect(test.component.valueStore.all()).toEqual({form: {select: []}}); }); it('tracks which fields should be validated and sends, without forgetting previous fields', async () => { @@ -476,7 +476,7 @@ describe('LiveController data-model Tests', () => { await waitFor(() => expect(test.element).toHaveTextContent('Mmmm pineapple pizza')); // the controller sees the new data and adopts it - expect(test.controller.dataValue).toEqual({ pizzaTopping: 'pineapple' }); + expect(test.component.valueStore.all()).toEqual({ pizzaTopping: 'pineapple' }); }); it('sends a render request without debounce for change events', async () => { @@ -675,7 +675,7 @@ describe('LiveController data-model Tests', () => { // the newly-typed characters have been kept expect(commentFieldAfterRender.value).toEqual('Live components ftw!'); // double-check that the model hasn't been updated yet (else bug in test) - expect(test.controller.valueStore.get('comment')).toEqual('Live components'); + expect(test.component.valueStore.get('comment')).toEqual('Live components'); expect(commentFieldAfterRender.getAttribute('class')).toEqual('changed-class'); const unmappedTextarea = getByTestId(test.element, 'unmappedTextarea'); diff --git a/src/LiveComponent/assets/test/controller/render.test.ts b/src/LiveComponent/assets/test/controller/render.test.ts index 07b06a55fb4..147dc639077 100644 --- a/src/LiveComponent/assets/test/controller/render.test.ts +++ b/src/LiveComponent/assets/test/controller/render.test.ts @@ -40,7 +40,7 @@ describe('LiveController rendering Tests', () => { await waitFor(() => expect(test.element).toHaveTextContent('Name: Kevin')); // data returned from the server is used for the new "data" - expect(test.controller.dataValue).toEqual({firstName: 'Kevin'}); + expect(test.component.valueStore.all()).toEqual({firstName: 'Kevin'}); }); it('conserves the value of model field that was modified after a render request', async () => { @@ -86,7 +86,7 @@ describe('LiveController rendering Tests', () => { // the server returned comment as ''. However, this model WAS set // during the last render, and that has not been taken into account yet. // and so, like with the comment textarea, the client-side value is kept - expect(test.controller.dataValue).toEqual({ + expect(test.component.valueStore.all()).toEqual({ title: 'greetings!!', comment: 'I had a great time' }); @@ -94,7 +94,7 @@ describe('LiveController rendering Tests', () => { // trigger render: the new comment data *will* now be sent test.expectsAjaxCall('get') // just repeat what we verified from above - .expectSentData(test.controller.dataValue) + .expectSentData(test.component.valueStore.all()) .serverWillChangeData((data: any) => { // to be EXTRA complicated, the server will change the comment // on the client, we should now recognize that the latest comment diff --git a/src/LiveComponent/assets/test/dom_utils.test.ts b/src/LiveComponent/assets/test/dom_utils.test.ts index a7add1e0a00..6183ff26193 100644 --- a/src/LiveComponent/assets/test/dom_utils.test.ts +++ b/src/LiveComponent/assets/test/dom_utils.test.ts @@ -7,15 +7,11 @@ import { getElementAsTagText, setValueOnElement } from '../src/dom_utils'; -import ValueStore from '../src/ValueStore'; +import ValueStore from '../src/Component/ValueStore'; import { LiveController } from '../src/live_controller'; const createStore = function(data: any = {}): ValueStore { - return new ValueStore({ - dataValue: data, - childComponentControllers: [], - element: document.createElement('div'), - }); + return new ValueStore(data); } describe('getValueFromElement', () => { diff --git a/src/LiveComponent/assets/test/tools.ts b/src/LiveComponent/assets/test/tools.ts index bc70c4e4478..553495d3781 100644 --- a/src/LiveComponent/assets/test/tools.ts +++ b/src/LiveComponent/assets/test/tools.ts @@ -3,6 +3,7 @@ import LiveController from '../src/live_controller'; import { waitFor } from '@testing-library/dom'; import fetchMock from 'fetch-mock-jest'; import { htmlToElement } from '../src/dom_utils'; +import Component from '../src/Component'; let activeTest: FunctionalTest|null = null; let unmatchedFetchErrors: Array<{url: string, method: string, body: any, headers: any}> = []; @@ -72,6 +73,7 @@ export function shutdownTest() { class FunctionalTest { controller: LiveController; + component: Component; element: HTMLElement; initialData: any; template: (data: any) => string; @@ -79,6 +81,7 @@ class FunctionalTest { constructor(controller: LiveController, element: HTMLElement, initialData: any, template: (data: any) => string) { this.controller = controller; + this.component = controller.component; this.element = element; this.initialData = initialData; this.template = template; @@ -231,7 +234,7 @@ class MockedAjaxCall { getVisualSummary(): string { const requestInfo = []; if (this.method === 'GET') { - requestInfo.push(` URL MATCH: ${this.getMockMatcher().url}`); + requestInfo.push(` URL MATCH: end:${this.getMockMatcher(true).url}`); } requestInfo.push(` METHOD: ${this.method}`); if (Object.keys(this.expectedHeaders).length > 0) { @@ -250,7 +253,7 @@ class MockedAjaxCall { } // https://www.wheresrhys.co.uk/fetch-mock/#api-mockingmock_matcher - private getMockMatcher(): any { + private getMockMatcher(forError = false): any { if (!this.expectedSentData) { throw new Error('expectedSentData not set yet'); } @@ -265,9 +268,14 @@ class MockedAjaxCall { const params = new URLSearchParams({ data: JSON.stringify(this.expectedSentData) }); - matcherObject.functionMatcher = (url: string) => { - return url.includes(`?${params.toString()}`); - }; + if (forError) { + // simplified version for error reporting + matcherObject.url = `?${params.toString()}`; + } else { + matcherObject.functionMatcher = (url: string) => { + return url.includes(`?${params.toString()}`); + }; + } } else { // match the body, by without "updatedModels" which is not important // and also difficult/tedious to always assert diff --git a/src/LiveComponent/src/Resources/doc/index.rst b/src/LiveComponent/src/Resources/doc/index.rst index ed7017ac39c..b7604ef034d 100644 --- a/src/LiveComponent/src/Resources/doc/index.rst +++ b/src/LiveComponent/src/Resources/doc/index.rst @@ -1636,6 +1636,13 @@ To validate only on "change", use the ``on(change)`` modifier: class="{{ this.getError('post.content') ? 'has-error' : '' }}" > +Interacting with JavaScript +--------------------------- + +TODO: +- events - like live:connect +- the Component object + Polling ------- From 32d4ff331edf46c3b7013df6631300b3c2c24039 Mon Sep 17 00:00:00 2001 From: Ryan Weaver Date: Tue, 1 Nov 2022 13:15:40 -0400 Subject: [PATCH 02/26] WIP heavy refactoring to Component * hook system used to reset model field after re-render * Adding Component proxy --- src/LiveComponent/assets/src/Component/index.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/LiveComponent/assets/src/Component/index.ts b/src/LiveComponent/assets/src/Component/index.ts index b9f9ec76b14..238c7089827 100644 --- a/src/LiveComponent/assets/src/Component/index.ts +++ b/src/LiveComponent/assets/src/Component/index.ts @@ -194,13 +194,6 @@ export default class Component { return; } - // check if the page is navigating away - //if (this.isWindowUnloaded) { - // TODO: bring back windowUnloaded - if (html === 'fooobar') { - return; - } - if (response.headers.get('Location')) { // action returned a redirect if (typeof Turbo !== 'undefined') { From 2a22483586f1e57c72f29aa9848ee2f60cd26907 Mon Sep 17 00:00:00 2001 From: Ryan Weaver Date: Thu, 29 Sep 2022 09:59:46 -0400 Subject: [PATCH 03/26] initial component child tracking/setup and removing originalData - new child logic will not need this --- .../assets/src/Component/index.ts | 61 ++++++++---- src/LiveComponent/assets/src/dom_utils.ts | 2 + .../src/have_rendered_values_changed.ts | 70 -------------- .../assets/src/live_controller.ts | 92 +++++++++++------- src/LiveComponent/assets/src/morphdom.ts | 23 +---- .../assets/test/Component/index.test.ts | 16 ++-- .../assets/test/controller/child.test.ts | 69 ++++++++++++++ .../test/have_rendered_values_changed.test.ts | 93 ------------------- src/LiveComponent/assets/test/tools.ts | 1 + 9 files changed, 183 insertions(+), 244 deletions(-) delete mode 100644 src/LiveComponent/assets/src/have_rendered_values_changed.ts create mode 100644 src/LiveComponent/assets/test/controller/child.test.ts delete mode 100644 src/LiveComponent/assets/test/have_rendered_values_changed.test.ts diff --git a/src/LiveComponent/assets/src/Component/index.ts b/src/LiveComponent/assets/src/Component/index.ts index 238c7089827..88e092cde18 100644 --- a/src/LiveComponent/assets/src/Component/index.ts +++ b/src/LiveComponent/assets/src/Component/index.ts @@ -16,6 +16,7 @@ declare const Turbo: any; export default class Component { readonly element: HTMLElement; private readonly backend: BackendInterface; + id: string|null; readonly valueStore: ValueStore; private readonly unsyncedInputsTracker: UnsyncedInputsTracker; @@ -24,7 +25,6 @@ export default class Component { defaultDebounce = 150; - private originalData = {}; private backendRequest: BackendRequest|null; /** Actions that are waiting to be executed */ private pendingActions: BackendAction[] = []; @@ -33,23 +33,25 @@ export default class Component { /** Current "timeout" before the pending request should be sent. */ private requestDebounceTimeout: number | null = null; + private children: Map = new Map(); + private parent: Component|null = null; + /** * @param element The root element * @param data Component data + * @param id Some unique id to identify this component. Needed to be a child component * @param backend Backend instance for updating * @param modelElementResolver Class to get "model" name from any element. */ - constructor(element: HTMLElement, data: any, backend: BackendInterface, modelElementResolver: ModelElementResolver) { + constructor(element: HTMLElement, data: any, id: string|null, backend: BackendInterface, modelElementResolver: ModelElementResolver) { this.element = element; this.backend = backend; + this.id = id; this.valueStore = new ValueStore(data); this.unsyncedInputsTracker = new UnsyncedInputsTracker(element, modelElementResolver); this.hooks = new HookManager(); this.pollingDirector = new PollingDirectory(this); - - // deep clone the data - this.snapshotOriginalData(); } connect(): void { @@ -138,6 +140,32 @@ export default class Component { this.pollingDirector.clearPolling(); } + addChild(component: Component): void { + if (!component.id) { + throw new Error('Children components must have an id.'); + } + + this.children.set(component.id, component); + component.parent = this; + } + + removeChild(child: Component): void { + if (!child.id) { + throw new Error('Children components must have an id.'); + } + + this.children.delete(child.id); + child.parent = null; + } + + getParent(): Component|null { + return this.parent; + } + + getChildren(): Map { + return new Map(this.children); + } + private tryStartingRequest(): void { if (!this.backendRequest) { this.performRequest() @@ -236,16 +264,10 @@ export default class Component { newElement, this.unsyncedInputsTracker.getUnsyncedInputs(), (element: HTMLElement) => getValueFromElement(element, this.valueStore), - this.originalData, - this.valueStore.all(), - newDataFromServer ); // TODO: could possibly do this by listening to the dataValue value change this.valueStore.reinitialize(newDataFromServer); - // take a new snapshot of the "original data" - this.snapshotOriginalData(); - // reset the modified values back to their client-side version Object.keys(modifiedModelValues).forEach((modelName) => { this.valueStore.set(modelName, modifiedModelValues[modelName]); @@ -262,10 +284,6 @@ export default class Component { })); } - private snapshotOriginalData() { - this.originalData = JSON.parse(JSON.stringify(this.valueStore.all())); - } - private caculateDebounce(debounce: number|boolean): number { if (debounce === true) { return this.defaultDebounce; @@ -343,9 +361,16 @@ export default class Component { } } -export function createComponent(element: HTMLElement, data: any, backend: BackendInterface, modelElementResolver: ModelElementResolver): Component { - const component = new Component(element, data, backend, modelElementResolver); - +/** + * Makes the Component feel more like a JS-version of the PHP component: + * + * // set model like properties + * component.firstName = 'Ryan'; + * + * // call a live action called "saveStatus" with a "status" arg + * component.saveStatus({ status: 'published' }); + */ +export function proxifyComponent(component: Component): Component { return new Proxy(component, { get(component: Component, prop: string|symbol): any { // string check is to handle symbols diff --git a/src/LiveComponent/assets/src/dom_utils.ts b/src/LiveComponent/assets/src/dom_utils.ts index c64c70601eb..4b502df0002 100644 --- a/src/LiveComponent/assets/src/dom_utils.ts +++ b/src/LiveComponent/assets/src/dom_utils.ts @@ -159,6 +159,8 @@ export function getModelDirectiveFromElement(element: HTMLElement, throwOnMissin * B) NOT also live inside a child "live controller" element */ export function elementBelongsToThisController(element: Element, controller: LiveController): boolean { + // TODO fix this + return true; if (controller.element !== element && !controller.element.contains(element)) { return false; } diff --git a/src/LiveComponent/assets/src/have_rendered_values_changed.ts b/src/LiveComponent/assets/src/have_rendered_values_changed.ts deleted file mode 100644 index 24a9a67379d..00000000000 --- a/src/LiveComponent/assets/src/have_rendered_values_changed.ts +++ /dev/null @@ -1,70 +0,0 @@ -export function haveRenderedValuesChanged(originalDataJson: string, currentDataJson: string, newDataJson: string): boolean { - /* - * Right now, if the "data" on the new value is different than - * the "original data" on the child element, then we force re-render - * the child component. There may be some other cases that we - * add later if they come up. Either way, this is probably the - * behavior we want most of the time, but it's not perfect. For - * example, if the child component has some a writable prop that - * has changed since the initial render, re-rendering the child - * component from the parent component will "eliminate" that - * change. - */ - - // if the original data matches the new data, then the parent - // hasn't changed how they render the child. - if (originalDataJson === newDataJson) { - return false; - } - - // The child component IS now being rendered in a "new way". - // This means that at least one of the "data" pieces used - // to render the child component has changed. - // However, the piece of data that changed might simply - // match the "current data" of that child component. In that case, - // there is no point to re-rendering. - // And, to be safe (in case the child has some "private LiveProp" - // that has been modified), we want to avoid rendering. - - - // if the current data exactly matches the new data, then definitely - // do not re-render. - if (currentDataJson === newDataJson) { - return false; - } - - // here, we will compare the original data for the child component - // with the new data. What we're looking for are they keys that - // have changed between the original "child rendering" and the - // new "child rendering". - const originalData = JSON.parse(originalDataJson); - const newData = JSON.parse(newDataJson); - const changedKeys = Object.keys(newData); - Object.entries(originalData).forEach(([key, value]) => { - // if any key in the new data is different than a key in the - // current data, then we *should* re-render. But if all the - // keys in the new data equal - if (value === newData[key]) { - // value is equal, remove from changedKeys - changedKeys.splice(changedKeys.indexOf(key), 1); - } - }); - - // now that we know which keys have changed between originally - // rendering the child component and this latest render, we - // can check to see if the child component *already* has - // the latest value for those keys. - - const currentData = JSON.parse(currentDataJson) - let keyHasChanged = false; - changedKeys.forEach((key) => { - // if any key in the new data is different than a key in the - // current data, then we *should* re-render. But if all the - // keys in the new data equal - if (currentData[key] !== newData[key]) { - keyHasChanged = true; - } - }); - - return keyHasChanged; -} diff --git a/src/LiveComponent/assets/src/live_controller.ts b/src/LiveComponent/assets/src/live_controller.ts index ca57b92daaa..34f7e814522 100644 --- a/src/LiveComponent/assets/src/live_controller.ts +++ b/src/LiveComponent/assets/src/live_controller.ts @@ -8,9 +8,11 @@ import { getValueFromElement, elementBelongsToThisController, } from './dom_utils'; -import Component, {createComponent} from "./Component"; +import Component, {proxifyComponent} from "./Component"; import Backend from "./Backend"; -import {DataModelElementResolver} from "./Component/ModelElementResolver"; +import { + DataModelElementResolver, +} from "./Component/ModelElementResolver"; import LoadingHelper from "./LoadingHelper"; interface UpdateModelOptions { @@ -18,18 +20,20 @@ interface UpdateModelOptions { debounce?: number|boolean; } -export interface LiveController { - dataValue: any; - element: Element, - childComponentControllers: Array +export interface LiveEvent extends CustomEvent { + detail: { + controller: LiveController, + component: Component + }, } -export default class extends Controller implements LiveController { +export default class LiveController extends Controller { static values = { url: String, data: Object, csrf: String, debounce: { type: Number, default: 150 }, + id: String, } readonly urlValue!: string; @@ -37,31 +41,44 @@ export default class extends Controller implements LiveController { readonly csrfValue!: string; readonly hasDebounceValue: boolean; readonly debounceValue: number; + readonly idValue: string; + + /** The component, wrapped in the convenience Proxy */ + private proxiedComponent: Component; + /** The raw Component object */ + private component: Component; - component: Component; isConnected = false; - childComponentControllers: Array = []; pendingActionTriggerModelElement: HTMLElement|null = null; private elementEventListeners: Array<{ event: string, callback: (event: any) => void }> = [ - { event: 'input', callback: (event) => this.handleInputEvent(event) }, - { event: 'change', callback: (event) => this.handleChangeEvent(event) }, + { event: 'input', callback: (event) => this.handleInputEvent(event) }, + { event: 'change', callback: (event) => this.handleChangeEvent(event) }, + { event: 'live:connect', callback: (event) => this.handleConnectedControllerEvent(event) }, ]; initialize() { - this.handleConnectedControllerEvent = this.handleConnectedControllerEvent.bind(this); - this.handleDisconnectedControllerEvent = this.handleDisconnectedControllerEvent.bind(this); + this.handleDisconnectedChildControllerEvent = this.handleDisconnectedChildControllerEvent.bind(this); if (!(this.element instanceof HTMLElement)) { throw new Error('Invalid Element Type'); } - this.component = createComponent( + + const id = this.idValue || null; + + this.component = new Component( this.element, this.dataValue, + id, new Backend(this.urlValue, this.csrfValue), new DataModelElementResolver(), ); + this.proxiedComponent = proxifyComponent(this.component); + + // @ts-ignore Adding the dynamic property + this.element.__component = this.proxiedComponent; + if (this.hasDebounceValue) { this.component.defaultDebounce = this.debounceValue; } @@ -100,9 +117,7 @@ export default class extends Controller implements LiveController { throw new Error('Invalid Element Type'); } - this.element.addEventListener('live:connect', this.handleConnectedControllerEvent); - - this._dispatchEvent('live:connect', { controller: this }); + this._dispatchEvent('live:connect'); } disconnect() { @@ -112,11 +127,8 @@ export default class extends Controller implements LiveController { this.component.element.removeEventListener(event, callback); }); - this.element.removeEventListener('live:connect', this.handleConnectedControllerEvent); - this.element.removeEventListener('live:disconnect', this.handleDisconnectedControllerEvent); - - this._dispatchEvent('live:disconnect', { controller: this }); this.isConnected = false; + this._dispatchEvent('live:disconnect'); } /** @@ -334,37 +346,49 @@ export default class extends Controller implements LiveController { this.component.set(modelDirective.action, finalValue, shouldRender, debounce); } - handleConnectedControllerEvent(event: any) { + handleConnectedControllerEvent(event: LiveEvent) { if (event.target === this.element) { return; } - this.childComponentControllers.push(event.detail.controller); + const childController = event.detail.controller; + if (childController.component.getParent()) { + // child already has a parent - we are a grandparent + return; + } + + this.component.addChild(childController.component); + // live:disconnect needs to be registered on the child element directly // that's because if the child component is removed from the DOM, then // the parent controller is no longer an ancestor, so the live:disconnect // event would not bubble up to it. - event.detail.controller.element.addEventListener('live:disconnect', this.handleDisconnectedControllerEvent); + // @ts-ignore TS doesn't like the LiveEvent arg in the listener, not sure how to fix + childController.element.addEventListener('live:disconnect', this.handleDisconnectedChildControllerEvent); } - handleDisconnectedControllerEvent(event: any) { - if (event.target === this.element) { - return; - } + handleDisconnectedChildControllerEvent(event: LiveEvent): void { + const childController = event.detail.controller; - const index = this.childComponentControllers.indexOf(event.detail.controller); + // @ts-ignore TS doesn't like the LiveEvent arg in the listener, not sure how to fix + childController.element.removeEventListener('live:disconnect', this.handleDisconnectedChildControllerEvent); - // Remove value from an array - if (index > -1) { - this.childComponentControllers.splice(index, 1); + // this shouldn't happen: but double-check we're the parent + if (childController.component.getParent() !== this.component) { + return; } + + this.component.removeChild(childController.component); } - _dispatchEvent(name: string, payload: any = null, canBubble = true, cancelable = false) { + _dispatchEvent(name: string, detail: any = {}, canBubble = true, cancelable = false) { + detail.controller = this; + detail.component = this.proxiedComponent; + return this.element.dispatchEvent(new CustomEvent(name, { bubbles: canBubble, cancelable, - detail: payload + detail })); } diff --git a/src/LiveComponent/assets/src/morphdom.ts b/src/LiveComponent/assets/src/morphdom.ts index 170f81410a2..c655f2e3522 100644 --- a/src/LiveComponent/assets/src/morphdom.ts +++ b/src/LiveComponent/assets/src/morphdom.ts @@ -4,16 +4,12 @@ cloneHTMLElement, } from "./dom_utils"; import morphdom from "morphdom"; import { normalizeAttributesForComparison } from "./normalize_attributes_for_comparison"; -import { haveRenderedValuesChanged } from "./have_rendered_values_changed"; export function executeMorphdom( rootFromElement: HTMLElement, rootToElement: HTMLElement, modifiedElements: Array, getElementValue: (element: HTMLElement) => any, - rootFromOriginalData: any, - rootFromCurrentData: any, - rootToCurrentData: any, ) { // make sure everything is in non-loading state, the same as the HTML currently on the page morphdom(rootFromElement, rootToElement, { @@ -56,8 +52,8 @@ export function executeMorphdom( if (controllerName && controllerName.split(' ').indexOf('live') !== -1 && fromEl !== rootFromElement - && !shouldChildLiveElementUpdate(rootFromOriginalData, rootFromCurrentData, rootToCurrentData) ) { + // TODO: add new child logic here return false; } @@ -75,20 +71,3 @@ export function executeMorphdom( } }); } - -/** - * Determines if a child live element should be re-rendered. - * - * This is called when this element re-renders and detects that - * a child element is inside. Normally, in that case, we do not - * re-render the child element. However, if we detect that the - * "data" on the child element has changed from its initial data, - * then this will trigger a re-render. - */ -const shouldChildLiveElementUpdate = function(rootFromOriginalData: any, rootFromCurrentData: any, rootToCurrentData: any): boolean { - return haveRenderedValuesChanged( - rootFromOriginalData, - rootFromCurrentData, - rootToCurrentData - ); -} diff --git a/src/LiveComponent/assets/test/Component/index.test.ts b/src/LiveComponent/assets/test/Component/index.test.ts index ffd2130f8d2..a329596932d 100644 --- a/src/LiveComponent/assets/test/Component/index.test.ts +++ b/src/LiveComponent/assets/test/Component/index.test.ts @@ -1,4 +1,4 @@ -import Component, {createComponent} from "../../src/Component"; +import Component, {proxifyComponent} from "../../src/Component"; import {BackendAction, BackendInterface} from "../../src/Backend"; import { DataModelElementResolver @@ -27,13 +27,15 @@ describe('Component class', () => { } } + const component = new Component( + document.createElement('div'), + {firstName: ''}, + null, + backend, + new DataModelElementResolver() + ); return { - proxy: createComponent( - document.createElement('div'), - {firstName: ''}, - backend, - new DataModelElementResolver() - ), + proxy: proxifyComponent(component), backend } } diff --git a/src/LiveComponent/assets/test/controller/child.test.ts b/src/LiveComponent/assets/test/controller/child.test.ts new file mode 100644 index 00000000000..6231c011b5f --- /dev/null +++ b/src/LiveComponent/assets/test/controller/child.test.ts @@ -0,0 +1,69 @@ +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +'use strict'; + +import { createTest, initComponent, shutdownTest } from '../tools'; +import {getByTestId, waitFor} from '@testing-library/dom'; +import Component from "../../src/Component"; + +describe('LiveController parent -> child component tests', () => { + afterEach(() => { + shutdownTest(); + }) + + it('adds & removes the child correctly', async () => { + const childTemplate = (data: any) => ` +
+ `; + + const test = await createTest({}, (data: any) => ` +
+ ${childTemplate({food: 'pizza'})} +
+ `); + + const parentComponent = test.component; + const childElement = getByTestId(test.element, 'child'); + // @ts-ignore + const childComponent = childElement.__component; + if (!(childComponent instanceof Component)) { + throw new Error('Child component did not load correctly') + } + + // check that the relationships all loaded correctly + expect(parentComponent.getChildren().size).toEqual(1); + expect(parentComponent.getChildren().get('the-child-id')).toEqual(childComponent); + expect(childComponent.getParent()).toEqual(parentComponent); + + // remove the child + childElement.remove(); + // wait because the event is slightly async + await waitFor(() => expect(parentComponent.getChildren().size).toEqual(0)); + expect(childComponent.getParent()).toBeNull(); + + // now put it back! + test.element.appendChild(childElement); + await waitFor(() => expect(parentComponent.getChildren().size).toEqual(1)); + expect(parentComponent.getChildren().get('the-child-id')).toEqual(childComponent); + expect(childComponent.getParent()).toEqual(parentComponent); + + // now remove the whole darn thing! + test.element.remove(); + // this will, while disconnected, break the parent-child bond + await waitFor(() => expect(parentComponent.getChildren().size).toEqual(0)); + expect(childComponent.getParent()).toBeNull(); + + // put it *all* back + document.body.appendChild(test.element); + await waitFor(() => expect(parentComponent.getChildren().size).toEqual(1)); + expect(parentComponent.getChildren().get('the-child-id')).toEqual(childComponent); + expect(childComponent.getParent()).toEqual(parentComponent); + }); +}); diff --git a/src/LiveComponent/assets/test/have_rendered_values_changed.test.ts b/src/LiveComponent/assets/test/have_rendered_values_changed.test.ts deleted file mode 100644 index b1322fcb4d5..00000000000 --- a/src/LiveComponent/assets/test/have_rendered_values_changed.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { haveRenderedValuesChanged } from '../src/have_rendered_values_changed'; - -const runTest = (originalData: any, currentData: any, newData: any, expected: boolean) => { - const result = haveRenderedValuesChanged( - JSON.stringify(originalData), - JSON.stringify(currentData), - JSON.stringify(newData), - ); - - expect(result).toBe(expected); -}; - -describe('haveRenderedValuesChanged', () => { - it('returns false if the "new" data matches the "original" data', () => { - runTest( - // original - { value: 'original' }, - // current - { value: 'child component did change' }, - // new - { value: 'original' }, - false - ); - }); - - it('returns false if the "new" data matches the "current" data', () => { - // in this case, the component is already aware of the data - - runTest( - // original - { value: 'original' }, - // current - { value: 'updated' }, - // new - { value: 'updated' }, - false - ); - }); - - it('returns false if the new data that *has* changed vs original is equal to current data', () => { - // This is a combination of the first two cases - each key - // represents one of the first two test situations. - - // we see that the "firstName" key has changed between the - // new data vs the original. But, we also see that "firstName" - // is equal between new & current, meaning the component is already - // aware of this change. - - // the "lastName" key has changed between current and new, but we - // don't care, because this hasn't changed since the original render - - runTest( - // original - { firstName: 'Beckett', lastName: 'Weaver' }, - // current - { firstName: 'Ryan', lastName: 'Pelham' }, - // new - { firstName: 'Ryan', lastName: 'Weaver' }, - false - ); - }); - - it('returns true correctly even with rearranged key', () => { - // same as previous case, but keys rearranged - - runTest( - // original - { firstName: 'Beckett', lastName: 'Weaver', favoriteColor: 'orange' }, - // current - { firstName: 'Beckett', favoriteColor: 'orange', lastName: 'Weaver' }, - // new - { favoriteColor: 'orange', lastName: 'Weaver', firstName: 'Beckett' }, - false - ); - }); - - it('returns true if at least one key in the new data has changed since the original data *and* it is not equal to the current data', () => { - // something truly changed in how the component is rendered that - // the component isn't aware of yet - - runTest( - // original - { firstName: 'Beckett', lastName: 'Weaver', error: null }, - // current - { firstName: 'Ryan', lastName: 'Pelham', error: null }, - // new - { firstName: 'Ryan', lastName: 'Weaver', error: 'That sounds like a fake name' }, - true - ); - }); - - // todo - test with missing keys -}); diff --git a/src/LiveComponent/assets/test/tools.ts b/src/LiveComponent/assets/test/tools.ts index 553495d3781..4578cbade95 100644 --- a/src/LiveComponent/assets/test/tools.ts +++ b/src/LiveComponent/assets/test/tools.ts @@ -395,5 +395,6 @@ export function initComponent(data: any, controllerValues: any = {}) { data-live-data-value="${dataToJsonAttribute(data)}" ${controllerValues.debounce ? `data-live-debounce-value="${controllerValues.debounce}"` : ''} ${controllerValues.csrf ? `data-live-csrf-value="${controllerValues.csrf}"` : ''} + ${controllerValues.id ? `data-live-id-value="${controllerValues.id}"` : ''} `; } From b1ac91c53c9e26b8cc1bc2938e480898fd4dd456 Mon Sep 17 00:00:00 2001 From: Ryan Weaver Date: Thu, 29 Sep 2022 11:56:27 -0400 Subject: [PATCH 04/26] client-side implementation of differentiating between data & props This will be needed later for child component handling, where the server will send down a list of updated "props" only, and the client side will need to update the props of a component, without changing the "data" that may have already been altered by the user. --- package.json | 3 +- .../src/Component/UnsyncedInputsTracker.ts | 21 ++++--- .../assets/src/Component/ValueStore.ts | 27 ++++---- .../assets/src/Component/index.ts | 15 ++--- .../assets/src/data_manipulation_utils.ts | 4 ++ src/LiveComponent/assets/src/dom_utils.ts | 30 ++++----- .../assets/src/live_controller.ts | 23 ++++--- .../assets/test/Component/index.test.ts | 3 +- .../assets/test/ValueStore.test.ts | 38 ++++++++---- .../assets/test/dom_utils.test.ts | 62 +++++++++++-------- 10 files changed, 132 insertions(+), 94 deletions(-) diff --git a/package.json b/package.json index 7d80b620362..12524da414d 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,8 @@ "rules": { "@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/no-empty-function": "off", - "@typescript-eslint/ban-ts-comment": "off" + "@typescript-eslint/ban-ts-comment": "off", + "quotes": ["error", "single"] }, "env": { "browser": true diff --git a/src/LiveComponent/assets/src/Component/UnsyncedInputsTracker.ts b/src/LiveComponent/assets/src/Component/UnsyncedInputsTracker.ts index 12d9afbedc3..426cac23714 100644 --- a/src/LiveComponent/assets/src/Component/UnsyncedInputsTracker.ts +++ b/src/LiveComponent/assets/src/Component/UnsyncedInputsTracker.ts @@ -1,7 +1,9 @@ -import {ModelElementResolver} from "./ModelElementResolver"; +import {ModelElementResolver} from './ModelElementResolver'; +import {elementBelongsToThisComponent} from '../dom_utils'; +import Component from './index'; export default class { - private readonly element: HTMLElement; + private readonly component: Component; private readonly modelElementResolver: ModelElementResolver; /** Fields that have changed, but whose value is not set back onto the value store */ private readonly unsyncedInputs: UnsyncedInputContainer; @@ -10,21 +12,21 @@ export default class { { event: 'input', callback: (event) => this.handleInputEvent(event) }, ]; - constructor(element: HTMLElement, modelElementResolver: ModelElementResolver) { - this.element = element; + constructor(component: Component, modelElementResolver: ModelElementResolver) { + this.component = component; this.modelElementResolver = modelElementResolver; this.unsyncedInputs = new UnsyncedInputContainer(); } activate(): void { this.elementEventListeners.forEach(({event, callback}) => { - this.element.addEventListener(event, callback); + this.component.element.addEventListener(event, callback); }); } deactivate(): void { this.elementEventListeners.forEach(({event, callback}) => { - this.element.removeEventListener(event, callback); + this.component.element.removeEventListener(event, callback); }); } @@ -42,10 +44,9 @@ export default class { } private updateModelFromElement(element: Element) { - // TODO: put back this child element check - // if (!elementBelongsToThisController(element, this)) { - // return; - // } + if (!elementBelongsToThisComponent(element, this.component)) { + return; + } if (!(element instanceof HTMLElement)) { throw new Error('Could not update model for non HTMLElement'); diff --git a/src/LiveComponent/assets/src/Component/ValueStore.ts b/src/LiveComponent/assets/src/Component/ValueStore.ts index 259fe3a0613..3553860689e 100644 --- a/src/LiveComponent/assets/src/Component/ValueStore.ts +++ b/src/LiveComponent/assets/src/Component/ValueStore.ts @@ -3,14 +3,16 @@ import { normalizeModelName } from '../string_utils'; export default class { updatedModels: string[] = []; + private props: any = {}; private data: any = {}; - constructor(data: any) { + constructor(props: any, data: any) { + this.props = props; this.data = data; } /** - * Returns the data with the given name. + * Returns the data or props with the given name. * * This allows for non-normalized model names - e.g. * user[firstName] -> user.firstName and also will fetch @@ -19,7 +21,13 @@ export default class { get(name: string): any { const normalizedName = normalizeModelName(name); - return getDeepData(this.data, normalizedName); + const result = getDeepData(this.data, normalizedName); + + if (result !== undefined) { + return result; + } + + return getDeepData(this.props, normalizedName); } has(name: string): boolean { @@ -40,20 +48,11 @@ export default class { this.data = setDeepData(this.data, normalizedName, value); } - /** - * Checks if the given name/propertyPath is for a valid top-level key. - */ - hasAtTopLevel(name: string): boolean { - const parts = name.split('.'); - - return this.data[parts[0]] !== undefined; - } - all(): any { - return this.data; + return { ...this.props, ...this.data }; } - reinitialize(data: any) { + reinitializeData(data: any) { this.data = data; } } diff --git a/src/LiveComponent/assets/src/Component/index.ts b/src/LiveComponent/assets/src/Component/index.ts index 88e092cde18..e001a887a10 100644 --- a/src/LiveComponent/assets/src/Component/index.ts +++ b/src/LiveComponent/assets/src/Component/index.ts @@ -38,18 +38,19 @@ export default class Component { /** * @param element The root element - * @param data Component data + * @param props Readonly component props + * @param data Modifiable component data/state * @param id Some unique id to identify this component. Needed to be a child component * @param backend Backend instance for updating * @param modelElementResolver Class to get "model" name from any element. */ - constructor(element: HTMLElement, data: any, id: string|null, backend: BackendInterface, modelElementResolver: ModelElementResolver) { + constructor(element: HTMLElement, props: any, data: any, id: string|null, backend: BackendInterface, modelElementResolver: ModelElementResolver) { this.element = element; this.backend = backend; this.id = id; - this.valueStore = new ValueStore(data); - this.unsyncedInputsTracker = new UnsyncedInputsTracker(element, modelElementResolver); + this.valueStore = new ValueStore(props, data); + this.unsyncedInputsTracker = new UnsyncedInputsTracker(this, modelElementResolver); this.hooks = new HookManager(); this.pollingDirector = new PollingDirectory(this); } @@ -104,7 +105,7 @@ export default class Component { this.debouncedStartRequest(debounce); } - get(model: string): any { + getData(model: string): any { const modelName = normalizeModelName(model); if (!this.valueStore.has(modelName)) { throw new Error(`Invalid model "${model}".`); @@ -266,7 +267,7 @@ export default class Component { (element: HTMLElement) => getValueFromElement(element, this.valueStore), ); // TODO: could possibly do this by listening to the dataValue value change - this.valueStore.reinitialize(newDataFromServer); + this.valueStore.reinitializeData(newDataFromServer); // reset the modified values back to their client-side version Object.keys(modifiedModelValues).forEach((modelName) => { @@ -388,7 +389,7 @@ export function proxifyComponent(component: Component): Component { // return model if (component.valueStore.has(prop)) { - return component.get(prop) + return component.getData(prop) } // try to call an action diff --git a/src/LiveComponent/assets/src/data_manipulation_utils.ts b/src/LiveComponent/assets/src/data_manipulation_utils.ts index ac37301da5f..51e33cb4878 100644 --- a/src/LiveComponent/assets/src/data_manipulation_utils.ts +++ b/src/LiveComponent/assets/src/data_manipulation_utils.ts @@ -1,6 +1,10 @@ export function getDeepData(data: any, propertyPath: string) { const { currentLevelData, finalKey } = parseDeepData(data, propertyPath); + if (currentLevelData === undefined) { + return undefined; + } + return currentLevelData[finalKey]; } diff --git a/src/LiveComponent/assets/src/dom_utils.ts b/src/LiveComponent/assets/src/dom_utils.ts index 4b502df0002..e304a6d66a7 100644 --- a/src/LiveComponent/assets/src/dom_utils.ts +++ b/src/LiveComponent/assets/src/dom_utils.ts @@ -1,7 +1,7 @@ import ValueStore from './Component/ValueStore'; import { Directive, parseDirectives } from './directives_parser'; -import { LiveController } from './live_controller'; import { normalizeModelName } from './string_utils'; +import Component from "./Component"; /** * Return the "value" of any given element. @@ -152,32 +152,34 @@ export function getModelDirectiveFromElement(element: HTMLElement, throwOnMissin } /** - * Does the given element "belong" to the given live controller. + * Does the given element "belong" to the given component. * * To "belong" the element needs to: - * A) Live inside the controller element (of course) - * B) NOT also live inside a child "live controller" element + * A) Live inside the component element (of course) + * B) NOT also live inside a child component */ -export function elementBelongsToThisController(element: Element, controller: LiveController): boolean { - // TODO fix this - return true; - if (controller.element !== element && !controller.element.contains(element)) { +export function elementBelongsToThisComponent(element: Element, component: Component): boolean { + if (component.element === element) { + return true; + } + + if (!component.element.contains(element)) { return false; } - let foundChildController = false; - controller.childComponentControllers.forEach((childComponentController) => { - if (foundChildController) { + let foundChildComponent = false; + component.getChildren().forEach((childComponent) => { + if (foundChildComponent) { // return early return; } - if (childComponentController.element === element || childComponentController.element.contains(element)) { - foundChildController = true; + if (childComponent.element === element || childComponent.element.contains(element)) { + foundChildComponent = true; } }); - return !foundChildController; + return !foundChildComponent; } export function cloneHTMLElement(element: HTMLElement): HTMLElement { diff --git a/src/LiveComponent/assets/src/live_controller.ts b/src/LiveComponent/assets/src/live_controller.ts index 34f7e814522..b94ec133d73 100644 --- a/src/LiveComponent/assets/src/live_controller.ts +++ b/src/LiveComponent/assets/src/live_controller.ts @@ -6,7 +6,7 @@ import { getElementAsTagText, setValueOnElement, getValueFromElement, - elementBelongsToThisController, + elementBelongsToThisComponent, } from './dom_utils'; import Component, {proxifyComponent} from "./Component"; import Backend from "./Backend"; @@ -27,17 +27,24 @@ export interface LiveEvent extends CustomEvent { }, } -export default class LiveController extends Controller { +export interface LiveController { + element: HTMLElement, + component: Component +} + +export default class extends Controller implements LiveController { static values = { url: String, data: Object, + props: Object, csrf: String, debounce: { type: Number, default: 150 }, id: String, } readonly urlValue!: string; - dataValue!: any; + readonly dataValue!: any; + readonly propsValue!: any; readonly csrfValue!: string; readonly hasDebounceValue: boolean; readonly debounceValue: number; @@ -46,7 +53,7 @@ export default class LiveController extends Controller { /** The component, wrapped in the convenience Proxy */ private proxiedComponent: Component; /** The raw Component object */ - private component: Component; + component: Component; isConnected = false; pendingActionTriggerModelElement: HTMLElement|null = null; @@ -60,15 +67,11 @@ export default class LiveController extends Controller { initialize() { this.handleDisconnectedChildControllerEvent = this.handleDisconnectedChildControllerEvent.bind(this); - if (!(this.element instanceof HTMLElement)) { - throw new Error('Invalid Element Type'); - } - - const id = this.idValue || null; this.component = new Component( this.element, + this.propsValue, this.dataValue, id, new Backend(this.urlValue, this.csrfValue), @@ -272,7 +275,7 @@ export default class LiveController extends Controller { * If not passed, the model will always be updated. */ private updateModelFromElementEvent(element: Element, eventName: string|null) { - if (!elementBelongsToThisController(element, this)) { + if (!elementBelongsToThisComponent(element, this.component)) { return; } diff --git a/src/LiveComponent/assets/test/Component/index.test.ts b/src/LiveComponent/assets/test/Component/index.test.ts index a329596932d..400e335b663 100644 --- a/src/LiveComponent/assets/test/Component/index.test.ts +++ b/src/LiveComponent/assets/test/Component/index.test.ts @@ -29,6 +29,7 @@ describe('Component class', () => { const component = new Component( document.createElement('div'), + {}, {firstName: ''}, null, backend, @@ -68,7 +69,7 @@ describe('Component class', () => { const { proxy } = makeDummyComponent(); // @ts-ignore proxy.firstName = 'Ryan'; - expect(proxy.get('firstName')).toBe('Ryan'); + expect(proxy.getData('firstName')).toBe('Ryan'); }); it('calls an action on a component', async () => { diff --git a/src/LiveComponent/assets/test/ValueStore.test.ts b/src/LiveComponent/assets/test/ValueStore.test.ts index 849c79d00ff..0f630e78aae 100644 --- a/src/LiveComponent/assets/test/ValueStore.test.ts +++ b/src/LiveComponent/assets/test/ValueStore.test.ts @@ -1,22 +1,30 @@ import ValueStore from '../src/Component/ValueStore'; describe('ValueStore', () => { - it('get() returns simple data', () => { + it('get() returns simple props', () => { const container = new ValueStore({ firstName: 'Ryan' + }, {}); + + expect(container.get('firstName')).toEqual('Ryan'); + }); + + it('get() returns simple data', () => { + const container = new ValueStore({}, { + firstName: 'Ryan' }); expect(container.get('firstName')).toEqual('Ryan'); }); it('get() returns undefined if not set', () => { - const container = new ValueStore({}); + const container = new ValueStore({}, {}); expect(container.get('firstName')).toBeUndefined(); }); it('get() returns deep data from property path', () => { - const container = new ValueStore({ + const container = new ValueStore({}, { user: { firstName: 'Ryan' } @@ -26,7 +34,7 @@ describe('ValueStore', () => { }); it('has() returns true if path exists', () => { - const container = new ValueStore({ + const container = new ValueStore({}, { user: { firstName: 'Ryan' } @@ -36,7 +44,7 @@ describe('ValueStore', () => { }); it('has() returns false if path does not exist', () => { - const container = new ValueStore({ + const container = new ValueStore({}, { user: { firstName: 'Ryan' } @@ -46,7 +54,7 @@ describe('ValueStore', () => { }); it('set() overrides simple data', () => { - const container = new ValueStore({ + const container = new ValueStore({}, { firstName: 'Kevin' }); @@ -56,7 +64,7 @@ describe('ValueStore', () => { }); it('set() overrides deep data', () => { - const container = new ValueStore({ + const container = new ValueStore({}, { user: { firstName: 'Ryan' } @@ -68,7 +76,7 @@ describe('ValueStore', () => { }); it('set() errors if setting key that does not exist', () => { - const container = new ValueStore({}); + const container = new ValueStore({}, {}); expect(() => { container.set('firstName', 'Ryan'); @@ -76,7 +84,7 @@ describe('ValueStore', () => { }); it('set() errors if setting deep data without parent', () => { - const container = new ValueStore({}); + const container = new ValueStore({}, {}); expect(() => { container.set('user.firstName', 'Ryan'); @@ -84,7 +92,7 @@ describe('ValueStore', () => { }); it('set() errors if setting deep data that does not exist', () => { - const container = new ValueStore({ + const container = new ValueStore({}, { user: {} }); @@ -94,7 +102,7 @@ describe('ValueStore', () => { }); it('set() errors if setting deep data on a non-object', () => { - const container = new ValueStore({ + const container = new ValueStore({}, { user: 'Kevin' }); @@ -102,4 +110,12 @@ describe('ValueStore', () => { container.set('user.firstName', 'Ryan'); }).toThrow('The parent "user" data does not appear to be an object'); }); + + it('all() returns props + data', () => { + const container = new ValueStore( + { city: 'Grand Rapids' }, + { user: 'Kevin' }); + + expect(container.all()).toEqual({ city: 'Grand Rapids', user: 'Kevin'}); + }); }); diff --git a/src/LiveComponent/assets/test/dom_utils.test.ts b/src/LiveComponent/assets/test/dom_utils.test.ts index 6183ff26193..3496cde3b07 100644 --- a/src/LiveComponent/assets/test/dom_utils.test.ts +++ b/src/LiveComponent/assets/test/dom_utils.test.ts @@ -3,15 +3,17 @@ import { cloneHTMLElement, htmlToElement, getModelDirectiveFromElement, - elementBelongsToThisController, + elementBelongsToThisComponent, getElementAsTagText, setValueOnElement } from '../src/dom_utils'; import ValueStore from '../src/Component/ValueStore'; -import { LiveController } from '../src/live_controller'; +import Component from "../src/Component"; +import Backend from "../src/Backend"; +import {DataModelElementResolver} from "../src/Component/ModelElementResolver"; -const createStore = function(data: any = {}): ValueStore { - return new ValueStore(data); +const createStore = function(props: any = {}): ValueStore { + return new ValueStore(props, {}); } describe('getValueFromElement', () => { @@ -211,49 +213,57 @@ describe('getModelDirectiveFromInput', () => { }); }); -describe('elementBelongsToThisController', () => { - const createController = (html: string, childComponentControllers: Array = []) => { - return new class implements LiveController { - dataValue = {}; - childComponentControllers = childComponentControllers; - element = htmlToElement(html); - } +describe('elementBelongsToThisComponent', () => { + const createComponent = (html: string, childComponents: Component[] = []) => { + const component = new Component( + htmlToElement(html), + {}, + {}, + 'some-id-' + Math.floor((Math.random() * 100)), + new Backend(''), + new DataModelElementResolver() + ); + childComponents.forEach((childComponent) => { + component.addChild(childComponent); + }) + + return component; }; it('returns false if element lives outside of controller', () => { const targetElement = htmlToElement(''); - const controller = createController('
'); + const component = createComponent('
'); - expect(elementBelongsToThisController(targetElement, controller)).toBeFalsy(); + expect(elementBelongsToThisComponent(targetElement, component)).toBeFalsy(); }); it('returns true if element lives inside of controller', () => { const targetElement = htmlToElement(''); - const controller = createController('
'); - controller.element.appendChild(targetElement); + const component = createComponent('
'); + component.element.appendChild(targetElement); - expect(elementBelongsToThisController(targetElement, controller)).toBeTruthy(); + expect(elementBelongsToThisComponent(targetElement, component)).toBeTruthy(); }); it('returns false if element lives inside of child controller', () => { const targetElement = htmlToElement(''); - const childController = createController('
'); - childController.element.appendChild(targetElement); + const childComponent = createComponent('
'); + childComponent.element.appendChild(targetElement); - const controller = createController('
', [childController]); - controller.element.appendChild(childController.element); + const component = createComponent('
', [childComponent]); + component.element.appendChild(childComponent.element); - expect(elementBelongsToThisController(targetElement, childController)).toBeTruthy(); - expect(elementBelongsToThisController(targetElement, controller)).toBeFalsy(); + expect(elementBelongsToThisComponent(targetElement, childComponent)).toBeTruthy(); + expect(elementBelongsToThisComponent(targetElement, component)).toBeFalsy(); }); it('returns false if element *is* a child controller element', () => { - const childController = createController('
'); + const childComponent = createComponent('
'); - const controller = createController('
', [childController]); - controller.element.appendChild(childController.element); + const component = createComponent('
', [childComponent]); + component.element.appendChild(childComponent.element); - expect(elementBelongsToThisController(childController.element, controller)).toBeFalsy(); + expect(elementBelongsToThisComponent(childComponent.element, component)).toBeFalsy(); }); }); From 526e5703ad9574e18acb964969d42bf8894c6acc Mon Sep 17 00:00:00 2001 From: Ryan Weaver Date: Thu, 29 Sep 2022 12:19:02 -0400 Subject: [PATCH 05/26] Adding the fingerprint to the system, but not using it yet --- .../assets/src/Component/index.ts | 27 +++++++++++++------ .../assets/src/live_controller.ts | 11 +++++--- .../assets/test/Component/index.test.ts | 11 ++++---- .../assets/test/controller/basic.test.ts | 14 +++++++++- .../assets/test/dom_utils.test.ts | 1 + src/LiveComponent/assets/test/tools.ts | 1 + 6 files changed, 47 insertions(+), 18 deletions(-) diff --git a/src/LiveComponent/assets/src/Component/index.ts b/src/LiveComponent/assets/src/Component/index.ts index e001a887a10..0542be6e981 100644 --- a/src/LiveComponent/assets/src/Component/index.ts +++ b/src/LiveComponent/assets/src/Component/index.ts @@ -1,15 +1,15 @@ import {BackendAction, BackendInterface} from '../Backend'; import ValueStore from './ValueStore'; import { normalizeModelName } from '../string_utils'; -import BackendRequest from "../BackendRequest"; +import BackendRequest from '../BackendRequest'; import { getValueFromElement, htmlToElement, -} from "../dom_utils"; -import {executeMorphdom} from "../morphdom"; -import UnsyncedInputsTracker from "./UnsyncedInputsTracker"; -import {ModelElementResolver} from "./ModelElementResolver"; -import HookManager from "../HookManager"; -import PollingDirectory from "../PollingDirector"; +} from '../dom_utils'; +import {executeMorphdom} from '../morphdom'; +import UnsyncedInputsTracker from './UnsyncedInputsTracker'; +import {ModelElementResolver} from './ModelElementResolver'; +import HookManager from '../HookManager'; +import PollingDirectory from '../PollingDirector'; declare const Turbo: any; @@ -18,6 +18,15 @@ export default class Component { private readonly backend: BackendInterface; id: string|null; + /** + * A fingerprint that identifies the props/input that was used on + * the server to create this component, especially if it was a + * child component. This is sent back to the server and can be used + * to determine if any "input" to the child component changed and thus, + * if the child component needs to be re-rendered. + */ + fingerprint: string|null; + readonly valueStore: ValueStore; private readonly unsyncedInputsTracker: UnsyncedInputsTracker; private hooks: HookManager; @@ -40,14 +49,16 @@ export default class Component { * @param element The root element * @param props Readonly component props * @param data Modifiable component data/state + * @param fingerprint * @param id Some unique id to identify this component. Needed to be a child component * @param backend Backend instance for updating * @param modelElementResolver Class to get "model" name from any element. */ - constructor(element: HTMLElement, props: any, data: any, id: string|null, backend: BackendInterface, modelElementResolver: ModelElementResolver) { + constructor(element: HTMLElement, props: any, data: any, fingerprint: string|null, id: string|null, backend: BackendInterface, modelElementResolver: ModelElementResolver) { this.element = element; this.backend = backend; this.id = id; + this.fingerprint = fingerprint; this.valueStore = new ValueStore(props, data); this.unsyncedInputsTracker = new UnsyncedInputsTracker(this, modelElementResolver); diff --git a/src/LiveComponent/assets/src/live_controller.ts b/src/LiveComponent/assets/src/live_controller.ts index b94ec133d73..4aed279fa92 100644 --- a/src/LiveComponent/assets/src/live_controller.ts +++ b/src/LiveComponent/assets/src/live_controller.ts @@ -8,12 +8,12 @@ import { getValueFromElement, elementBelongsToThisComponent, } from './dom_utils'; -import Component, {proxifyComponent} from "./Component"; -import Backend from "./Backend"; +import Component, {proxifyComponent} from './Component'; +import Backend from './Backend'; import { DataModelElementResolver, -} from "./Component/ModelElementResolver"; -import LoadingHelper from "./LoadingHelper"; +} from './Component/ModelElementResolver'; +import LoadingHelper from './LoadingHelper'; interface UpdateModelOptions { dispatch?: boolean; @@ -40,6 +40,7 @@ export default class extends Controller implements LiveController { csrf: String, debounce: { type: Number, default: 150 }, id: String, + fingerprint: String, } readonly urlValue!: string; @@ -49,6 +50,7 @@ export default class extends Controller implements LiveController { readonly hasDebounceValue: boolean; readonly debounceValue: number; readonly idValue: string; + readonly fingerprintValue: string /** The component, wrapped in the convenience Proxy */ private proxiedComponent: Component; @@ -73,6 +75,7 @@ export default class extends Controller implements LiveController { this.element, this.propsValue, this.dataValue, + this.fingerprintValue, id, new Backend(this.urlValue, this.csrfValue), new DataModelElementResolver(), diff --git a/src/LiveComponent/assets/test/Component/index.test.ts b/src/LiveComponent/assets/test/Component/index.test.ts index 400e335b663..13b310531ca 100644 --- a/src/LiveComponent/assets/test/Component/index.test.ts +++ b/src/LiveComponent/assets/test/Component/index.test.ts @@ -1,9 +1,9 @@ -import Component, {proxifyComponent} from "../../src/Component"; -import {BackendAction, BackendInterface} from "../../src/Backend"; +import Component, {proxifyComponent} from '../../src/Component'; +import {BackendAction, BackendInterface} from '../../src/Backend'; import { DataModelElementResolver -} from "../../src/Component/ModelElementResolver"; -import BackendRequest from "../../src/BackendRequest"; +} from '../../src/Component/ModelElementResolver'; +import BackendRequest from '../../src/BackendRequest'; import { Response } from 'node-fetch'; describe('Component class', () => { @@ -15,7 +15,7 @@ describe('Component class', () => { const makeDummyComponent = (): { proxy: Component, backend: MockBackend } => { const backend: MockBackend = { actions: [], - makeRequest(data: any, actions: BackendAction[], updatedModels: string[]): BackendRequest { + makeRequest(data: any, actions: BackendAction[]): BackendRequest { this.actions = actions; return new BackendRequest( @@ -32,6 +32,7 @@ describe('Component class', () => { {}, {firstName: ''}, null, + null, backend, new DataModelElementResolver() ); diff --git a/src/LiveComponent/assets/test/controller/basic.test.ts b/src/LiveComponent/assets/test/controller/basic.test.ts index e185b5c07b0..458f725be86 100644 --- a/src/LiveComponent/assets/test/controller/basic.test.ts +++ b/src/LiveComponent/assets/test/controller/basic.test.ts @@ -9,8 +9,9 @@ 'use strict'; -import { shutdownTest, startStimulus } from '../tools'; +import {createTest, initComponent, shutdownTest, startStimulus} from '../tools'; import { htmlToElement } from '../../src/dom_utils'; +import Component from "../../src/Component"; describe('LiveController Basic Tests', () => { afterEach(() => { @@ -30,4 +31,15 @@ describe('LiveController Basic Tests', () => { expect(element).toHaveAttribute('data-controller', 'live'); expect(eventTriggered).toStrictEqual(true); }); + + it('creates the Component object', async () => { + const test = await createTest({ firstName: 'Ryan' }, (data: any) => ` +
+ `); + + expect(test.controller.component).toBeInstanceOf(Component); + expect(test.controller.component.defaultDebounce).toEqual(115); + expect(test.controller.component.id).toEqual('the-id'); + expect(test.controller.component.fingerprint).toEqual('the-fingerprint'); + }); }); diff --git a/src/LiveComponent/assets/test/dom_utils.test.ts b/src/LiveComponent/assets/test/dom_utils.test.ts index 3496cde3b07..04e20491828 100644 --- a/src/LiveComponent/assets/test/dom_utils.test.ts +++ b/src/LiveComponent/assets/test/dom_utils.test.ts @@ -219,6 +219,7 @@ describe('elementBelongsToThisComponent', () => { htmlToElement(html), {}, {}, + null, 'some-id-' + Math.floor((Math.random() * 100)), new Backend(''), new DataModelElementResolver() diff --git a/src/LiveComponent/assets/test/tools.ts b/src/LiveComponent/assets/test/tools.ts index 4578cbade95..3e93b814490 100644 --- a/src/LiveComponent/assets/test/tools.ts +++ b/src/LiveComponent/assets/test/tools.ts @@ -396,5 +396,6 @@ export function initComponent(data: any, controllerValues: any = {}) { ${controllerValues.debounce ? `data-live-debounce-value="${controllerValues.debounce}"` : ''} ${controllerValues.csrf ? `data-live-csrf-value="${controllerValues.csrf}"` : ''} ${controllerValues.id ? `data-live-id-value="${controllerValues.id}"` : ''} + ${controllerValues.fingerprint ? `data-live-fingerprint-value="${controllerValues.fingerprint}"` : ''} `; } From 6b7fcd20041c52f7448925f52e8a274729e8ff79 Mon Sep 17 00:00:00 2001 From: Ryan Weaver Date: Thu, 29 Sep 2022 13:19:06 -0400 Subject: [PATCH 06/26] Sending childrenFingerprints data to server on Ajax call --- src/LiveComponent/assets/src/Backend.ts | 20 +++++---- .../assets/src/Component/index.ts | 17 +++++++- .../assets/test/controller/child.test.ts | 24 ++++++++++- src/LiveComponent/assets/test/tools.ts | 42 +++++++++++++++---- 4 files changed, 85 insertions(+), 18 deletions(-) diff --git a/src/LiveComponent/assets/src/Backend.ts b/src/LiveComponent/assets/src/Backend.ts index fcb438bc3f2..82a338445aa 100644 --- a/src/LiveComponent/assets/src/Backend.ts +++ b/src/LiveComponent/assets/src/Backend.ts @@ -1,7 +1,7 @@ import BackendRequest from './BackendRequest'; export interface BackendInterface { - makeRequest(data: any, actions: BackendAction[], updatedModels: string[]): BackendRequest; + makeRequest(data: any, actions: BackendAction[], updatedModels: string[], childrenFingerprints: any): BackendRequest; } export interface BackendAction { @@ -11,14 +11,14 @@ export interface BackendAction { export default class implements BackendInterface { private url: string; - private csrfToken: string|null; + private readonly csrfToken: string|null; constructor(url: string, csrfToken: string|null = null) { this.url = url; this.csrfToken = csrfToken; } - makeRequest(data: any, actions: BackendAction[], updatedModels: string[]): BackendRequest { + makeRequest(data: any, actions: BackendAction[], updatedModels: string[], childrenFingerprints: any): BackendRequest { const splitUrl = this.url.split('?'); let [url] = splitUrl const [, queryString] = splitUrl; @@ -29,8 +29,9 @@ export default class implements BackendInterface { 'Accept': 'application/vnd.live-component+html', }; - if (actions.length === 0 && this.willDataFitInUrl(JSON.stringify(data), params)) { + if (actions.length === 0 && this.willDataFitInUrl(JSON.stringify(data), params, JSON.stringify(childrenFingerprints))) { params.set('data', JSON.stringify(data)); + params.set('childrenFingerprints', JSON.stringify(childrenFingerprints)); updatedModels.forEach((model) => { params.append('updatedModels[]', model); }); @@ -39,7 +40,12 @@ export default class implements BackendInterface { fetchOptions.method = 'POST'; fetchOptions.headers['Content-Type'] = 'application/json'; const requestData: any = { data }; - requestData.updatedModels = updatedModels; + if (updatedModels) { + requestData.updatedModels = updatedModels; + } + if (childrenFingerprints) { + requestData.childrenFingerprints = childrenFingerprints; + } if (actions.length > 0) { // one or more ACTIONs @@ -70,8 +76,8 @@ export default class implements BackendInterface { ); } - private willDataFitInUrl(dataJson: string, params: URLSearchParams) { - const urlEncodedJsonData = new URLSearchParams(dataJson).toString(); + private willDataFitInUrl(dataJson: string, params: URLSearchParams, childrenFingerprintsJson: string) { + const urlEncodedJsonData = new URLSearchParams(dataJson + childrenFingerprintsJson).toString(); // if the URL gets remotely close to 2000 chars, it may not fit return (urlEncodedJsonData + params.toString()).length < 1500; diff --git a/src/LiveComponent/assets/src/Component/index.ts b/src/LiveComponent/assets/src/Component/index.ts index 0542be6e981..389d0b5629b 100644 --- a/src/LiveComponent/assets/src/Component/index.ts +++ b/src/LiveComponent/assets/src/Component/index.ts @@ -193,7 +193,8 @@ export default class Component { this.backendRequest = this.backend.makeRequest( this.valueStore.all(), this.pendingActions, - this.valueStore.updatedModels + this.valueStore.updatedModels, + this.getChildrenFingerprints() ); this.hooks.triggerHook('loading.state:started', this.element, this.backendRequest); @@ -371,6 +372,20 @@ export default class Component { }); modal.focus(); } + + private getChildrenFingerprints(): any { + const fingerprints: any = {}; + + this.children.forEach((child) => { + if (!child.id) { + throw new Error('missing id'); + } + + fingerprints[child.id] = child.fingerprint; + }); + + return fingerprints; + } } /** diff --git a/src/LiveComponent/assets/test/controller/child.test.ts b/src/LiveComponent/assets/test/controller/child.test.ts index 6231c011b5f..b490c88b036 100644 --- a/src/LiveComponent/assets/test/controller/child.test.ts +++ b/src/LiveComponent/assets/test/controller/child.test.ts @@ -11,7 +11,7 @@ import { createTest, initComponent, shutdownTest } from '../tools'; import {getByTestId, waitFor} from '@testing-library/dom'; -import Component from "../../src/Component"; +import Component from '../../src/Component'; describe('LiveController parent -> child component tests', () => { afterEach(() => { @@ -25,7 +25,7 @@ describe('LiveController parent -> child component tests', () => { const test = await createTest({}, (data: any) => `
- ${childTemplate({food: 'pizza'})} + ${childTemplate({})}
`); @@ -66,4 +66,24 @@ describe('LiveController parent -> child component tests', () => { expect(parentComponent.getChildren().get('the-child-id')).toEqual(childComponent); expect(childComponent.getParent()).toEqual(parentComponent); }); + + it('sends a map of child fingerprints on re-render', async () => { + const test = await createTest({}, (data: any) => ` +
+
Child1
+
Child2
+
+ `); + + test.expectsAjaxCall('get') + .expectSentData(test.initialData) + .expectChildFingerprints({ + 'the-child-id1': 'child-fingerprint1', + 'the-child-id2': 'child-fingerprint2' + }) + .init(); + + test.component.render(); + await waitFor(() => expect(test.element).toHaveAttribute('busy')); + }); }); diff --git a/src/LiveComponent/assets/test/tools.ts b/src/LiveComponent/assets/test/tools.ts index 3e93b814490..d26d19a4a3f 100644 --- a/src/LiveComponent/assets/test/tools.ts +++ b/src/LiveComponent/assets/test/tools.ts @@ -38,7 +38,7 @@ export function shutdownTest() { requestInfo.push(` HEADERS: ${JSON.stringify(unmatchedFetchError.headers)}`); requestInfo.push(` DATA: ${unmatchedFetchError.method === 'GET' ? urlParams.get('data') : unmatchedFetchError.body}`); - console.log(`UNMATCHED request was made with the following info:`, "\n", requestInfo.join("\n")); + console.log('UNMATCHED request was made with the following info:', '\n', requestInfo.join('\n')); }); unmatchedFetchErrors = []; @@ -118,6 +118,7 @@ class MockedAjaxCall { private expectedSentData?: any; private expectedActions: Array<{ name: string, args: any }> = []; private expectedHeaders: any = {}; + private expectedChildFingerprints: any = null; private changeDataCallback?: (data: any) => void; private template?: (data: any) => string options: any = {}; @@ -142,6 +143,14 @@ class MockedAjaxCall { return this; } + expectChildFingerprints = (fingerprints: any): MockedAjaxCall => { + this.checkInitialization('expectSentData'); + + this.expectedChildFingerprints = fingerprints; + + return this; + } + /** * Call if the "server" will change the data before it re-renders */ @@ -245,15 +254,20 @@ class MockedAjaxCall { } else { requestInfo.push(` DATA: ${JSON.stringify(this.getRequestBody())}`); } + + if (this.expectedChildFingerprints) { + requestInfo.push(` CHILD FINGERPRINTS: ${JSON.stringify(this.expectedChildFingerprints)}`) + } + if (this.expectedActions.length === 1) { requestInfo.push(` Expected URL to contain action /${this.expectedActions[0].name}`) } - return requestInfo.join("\n"); + return requestInfo.join('\n'); } // https://www.wheresrhys.co.uk/fetch-mock/#api-mockingmock_matcher - private getMockMatcher(forError = false): any { + private getMockMatcher(createMatchForShowingError = false): any { if (!this.expectedSentData) { throw new Error('expectedSentData not set yet'); } @@ -265,15 +279,23 @@ class MockedAjaxCall { } if (this.method === 'GET') { - const params = new URLSearchParams({ + const paramsData: any = { data: JSON.stringify(this.expectedSentData) - }); - if (forError) { + }; + if (this.expectedChildFingerprints) { + paramsData.childrenFingerprints = JSON.stringify(this.expectedChildFingerprints); + } + const params = new URLSearchParams(paramsData); + if (createMatchForShowingError) { // simplified version for error reporting matcherObject.url = `?${params.toString()}`; } else { matcherObject.functionMatcher = (url: string) => { - return url.includes(`?${params.toString()}`); + const actualUrl = new URL(url); + const actualParams = new URLSearchParams(actualUrl.search); + actualParams.delete('updatedModels'); + + return actualParams.toString() === params.toString(); }; } } else { @@ -289,7 +311,7 @@ class MockedAjaxCall { if (this.expectedActions.length === 1) { matcherObject.url = `end:/${this.expectedActions[0].name}`; } else if (this.expectedActions.length > 1) { - matcherObject.url = `end:/_batch`; + matcherObject.url = 'end:/_batch'; } } @@ -308,6 +330,10 @@ class MockedAjaxCall { data: this.expectedSentData }; + if (this.expectedChildFingerprints) { + body.childrenFingerprints = this.expectedChildFingerprints; + } + if (this.expectedActions.length === 1) { body.args = this.expectedActions[0].args; } else if (this.expectedActions.length > 1) { From e5cc4e04584c8e4ac7e1f8e029b18db9dc26dc16 Mon Sep 17 00:00:00 2001 From: Ryan Weaver Date: Thu, 29 Sep 2022 19:54:17 -0400 Subject: [PATCH 07/26] Starting new child tests --- src/LiveComponent/assets/src/Backend.ts | 10 +- .../assets/test/controller/action.test.ts | 4 +- .../assets/test/controller/basic.test.ts | 2 +- .../assets/test/controller/child.test.ts | 102 +++++++++++++++++- .../assets/test/controller/csrf.test.ts | 2 +- .../assets/test/controller/model.test.ts | 4 +- .../assets/test/controller/poll.test.ts | 2 +- .../assets/test/controller/render.test.ts | 8 +- src/LiveComponent/assets/test/tools.ts | 11 +- 9 files changed, 125 insertions(+), 20 deletions(-) diff --git a/src/LiveComponent/assets/src/Backend.ts b/src/LiveComponent/assets/src/Backend.ts index 82a338445aa..30ac7d8260a 100644 --- a/src/LiveComponent/assets/src/Backend.ts +++ b/src/LiveComponent/assets/src/Backend.ts @@ -29,9 +29,13 @@ export default class implements BackendInterface { 'Accept': 'application/vnd.live-component+html', }; + const hasFingerprints = Object.keys(childrenFingerprints).length > 0; + const hasUpdatedModels = Object.keys(updatedModels).length > 0; if (actions.length === 0 && this.willDataFitInUrl(JSON.stringify(data), params, JSON.stringify(childrenFingerprints))) { params.set('data', JSON.stringify(data)); - params.set('childrenFingerprints', JSON.stringify(childrenFingerprints)); + if (hasFingerprints) { + params.set('childrenFingerprints', JSON.stringify(childrenFingerprints)); + } updatedModels.forEach((model) => { params.append('updatedModels[]', model); }); @@ -40,10 +44,10 @@ export default class implements BackendInterface { fetchOptions.method = 'POST'; fetchOptions.headers['Content-Type'] = 'application/json'; const requestData: any = { data }; - if (updatedModels) { + if (hasUpdatedModels) { requestData.updatedModels = updatedModels; } - if (childrenFingerprints) { + if (hasFingerprints) { requestData.childrenFingerprints = childrenFingerprints; } diff --git a/src/LiveComponent/assets/test/controller/action.test.ts b/src/LiveComponent/assets/test/controller/action.test.ts index 9c426848685..db96a9a45eb 100644 --- a/src/LiveComponent/assets/test/controller/action.test.ts +++ b/src/LiveComponent/assets/test/controller/action.test.ts @@ -46,7 +46,7 @@ describe('LiveController Action Tests', () => { it('immediately sends an action, includes debouncing model updates and cancels those debounce renders', async () => { const test = await createTest({ comment: '', isSaved: false }, (data: any) => ` -
+
${data.isSaved ? 'Comment Saved!' : ''} @@ -134,7 +134,7 @@ describe('LiveController Action Tests', () => { it('makes model updates wait until action Ajax call finishes', async () => { const test = await createTest({ comment: 'donut', isSaved: false }, (data: any) => ` -
+
${data.isSaved ? 'Comment Saved!' : ''} diff --git a/src/LiveComponent/assets/test/controller/basic.test.ts b/src/LiveComponent/assets/test/controller/basic.test.ts index 458f725be86..bcb5087e5e7 100644 --- a/src/LiveComponent/assets/test/controller/basic.test.ts +++ b/src/LiveComponent/assets/test/controller/basic.test.ts @@ -34,7 +34,7 @@ describe('LiveController Basic Tests', () => { it('creates the Component object', async () => { const test = await createTest({ firstName: 'Ryan' }, (data: any) => ` -
+
`); expect(test.controller.component).toBeInstanceOf(Component); diff --git a/src/LiveComponent/assets/test/controller/child.test.ts b/src/LiveComponent/assets/test/controller/child.test.ts index b490c88b036..43a78f24edd 100644 --- a/src/LiveComponent/assets/test/controller/child.test.ts +++ b/src/LiveComponent/assets/test/controller/child.test.ts @@ -20,7 +20,7 @@ describe('LiveController parent -> child component tests', () => { it('adds & removes the child correctly', async () => { const childTemplate = (data: any) => ` -
+
`; const test = await createTest({}, (data: any) => ` @@ -70,8 +70,8 @@ describe('LiveController parent -> child component tests', () => { it('sends a map of child fingerprints on re-render', async () => { const test = await createTest({}, (data: any) => `
-
Child1
-
Child2
+
Child1
+
Child2
`); @@ -86,4 +86,100 @@ describe('LiveController parent -> child component tests', () => { test.component.render(); await waitFor(() => expect(test.element).toHaveAttribute('busy')); }); + + it('removes missing child component on re-render', async () => { + const test = await createTest({renderChild: true}, (data: any) => ` +
+ ${data.renderChild + ? `
Child Component
` + : '' + } +
+ `); + + test.expectsAjaxCall('get') + .expectSentData(test.initialData) + .serverWillChangeData((data: any) => { + data.renderChild = false; + }) + .init(); + + expect(test.element).toHaveTextContent('Child Component') + expect(test.component.getChildren().size).toEqual(1); + test.component.render(); + // wait for child to disappear + await waitFor(() => expect(test.element).toHaveAttribute('busy')); + await waitFor(() => expect(test.element).not.toHaveAttribute('busy')); + expect(test.element).not.toHaveTextContent('Child Component') + expect(test.component.getChildren().size).toEqual(0); + }); + + it('adds new child component on re-render', async () => { + const test = await createTest({renderChild: false}, (data: any) => ` +
+ ${data.renderChild + ? `
Child Component
` + : '' + } +
+ `); + + test.expectsAjaxCall('get') + .expectSentData(test.initialData) + .serverWillChangeData((data: any) => { + data.renderChild = true; + }) + .init(); + + expect(test.element).not.toHaveTextContent('Child Component') + expect(test.component.getChildren().size).toEqual(0); + test.component.render(); + // wait for child to disappear + await waitFor(() => expect(test.element).toHaveAttribute('busy')); + await waitFor(() => expect(test.element).not.toHaveAttribute('busy')); + expect(test.element).toHaveTextContent('Child Component') + expect(test.component.getChildren().size).toEqual(1); + }); + + // it('existing child component that has no props is ignored', async () => { + // const originalChild = ` + //
+ // Child Component + //
+ // `; + // const updatedChild = ` + //
+ // Child Component + //
+ // `; + // + // const test = await createTest({useOriginalChild: true}, (data: any) => ` + //
+ // ${data.useUpdatedChild ? originalChild : updatedChild} + //
+ // `); + // + // test.expectsAjaxCall('get') + // .expectSentData(test.initialData) + // .serverWillChangeData((data: any) => { + // data.renderChild = true; + // }) + // .init(); + // + // expect(test.element).not.toHaveTextContent('Child Component') + // expect(test.component.getChildren().size).toEqual(0); + // test.component.render(); + // // wait for child to disappear + // await waitFor(() => expect(test.element).toHaveAttribute('busy')); + // await waitFor(() => expect(test.element).not.toHaveAttribute('busy')); + // expect(test.element).toHaveTextContent('Child Component') + // expect(test.component.getChildren().size).toEqual(1); + // }); + + + // TODO: response comes back with existing child element but no props, child is untouched + // TODO: response comes back with empty child element BUT with new fingerprint & props, those are pushed down onto the child, which will trigger a re-render + // TODO: check above situation mixing element types - e.g. span vs div + // TODO: check that data-live-id could be used to remove old component and use new component entirely, with fresh data + // TODO: multiple children, even if they change position, are correctly handled }); diff --git a/src/LiveComponent/assets/test/controller/csrf.test.ts b/src/LiveComponent/assets/test/controller/csrf.test.ts index d61c7d99ce2..2fefb44282b 100644 --- a/src/LiveComponent/assets/test/controller/csrf.test.ts +++ b/src/LiveComponent/assets/test/controller/csrf.test.ts @@ -19,7 +19,7 @@ describe('LiveController CSRF Tests', () => { it('Sends the CSRF token on an action', async () => { const test = await createTest({ isSaved: 0 }, (data: any) => ` -
+
${data.isSaved ? 'Saved' : ''} -
- `; - - const test = await createTest({ count: 0 }, (data: any) => ` -
- Parent component count: ${data.count} - - ${childTemplate({food: 'pizza'})} -
- `); - - // for the child component render - test.expectsAjaxCall('get') - .expectSentData({food: 'pizza'}) - .serverWillChangeData((data: any) => { - data.food = 'popcorn'; - }) - .willReturn(childTemplate) - .init(); - - // re-render *just* the child - userEvent.click(getByText(test.element, 'Render Child')); - await waitFor(() => expect(test.element).toHaveTextContent('Favorite food: popcorn')); - - // now let's re-render the parent - test.expectsAjaxCall('get') - .expectSentData(test.initialData) - .serverWillChangeData((data: any) => { - data.count = 1; - }) - .init(); - - test.controller.$render(); - await waitFor(() => expect(test.element).toHaveTextContent('Parent component count: 1')); - // child component retains its re-rendered, custom text - // this is because the parent's data changed, but the changed data is not passed to the child - expect(test.element).toHaveTextContent('Favorite food: popcorn'); - }); - - it('renders parent component AND replaces child component when changed parent data affects child data', async () => { - const childTemplate = (data: any) => ` -
- Child component count: ${data.childCount} -
- `; - - const test = await createTest({ count: 0 }, (data: any) => ` -
- Parent component count: ${data.count} - - ${childTemplate({childCount: data.count})} -
- `); - - // change some content on the child - (document.getElementById('child-component') as HTMLElement).innerHTML = 'changed child content'; - - // re-render the parent - test.expectsAjaxCall('get') - .expectSentData(test.initialData) - .serverWillChangeData((data: any) => { - data.count = 1; - }) - .init(); - - test.controller.$render(); - await waitFor(() => expect(test.element).toHaveTextContent('Parent component count: 1')); - // child component reverts to its original text - // this is because the parent's data changed AND that change affected child's data - expect(test.element).toHaveTextContent('Child component count: 1'); - }); - - it('updates the parent model when the child model updates', async () => { - const childTemplate = (data: any) => ` -
- - - Child Content: ${data.content} -
- `; - - const test = await createTest({ post: { content: 'i love'} }, (data: any) => ` -
- - Parent Post Content: ${data.post.content} - - ${childTemplate({content: data.post.content})} - -
- `); - - // request for the child render - test.expectsAjaxCall('get') - .expectSentData({content: 'i love turtles'}) - .willReturn(childTemplate) - .init(); - - await userEvent.type(test.queryByDataModel('content'), ' turtles'); - // wait for the render to complete - await waitFor(() => expect(test.element).toHaveTextContent('Child Content: i love turtles')); - - // the parent should not re-render - // TODO: this behavior was originally added, but it's questionabl - expect(test.element).not.toHaveTextContent('Parent Post Content: i love turtles'); - // but the value DID update on the parent component - // this is because the name="post[content]" in the child matches the parent model - expect(test.controller.dataValue.post.content).toEqual('i love turtles'); - }); - - it('uses data-model-map to map child models to parent models', async () => { - const childTemplate = (data: any) => ` -
- - - Child Content: ${data.value} -
- `; - - const test = await createTest({ post: { content: 'i love'} }, (data: any) => ` -
-
- ${childTemplate({value: data.post.content})} -
-
- `); - - // request for the child render - test.expectsAjaxCall('get') - .expectSentData({ value: 'i love dragons' }) - .willReturn(childTemplate) - .init(); - - await userEvent.type(test.queryByDataModel('value'), ' dragons'); - // wait for the render to complete - await waitFor(() => expect(test.element).toHaveTextContent('Child Content: i love dragons')); - expect(test.controller.dataValue.post.content).toEqual('i love dragons'); - }); - - it('updates child data-original-data on parent re-render', async () => { - const initialData = { children: [{ name: 'child1' }, { name: 'child2' }, { name: 'child3' }] }; - const test = await createTest(initialData, (data: any) => ` -
- Parent count: ${data.count} - -
    - ${data.children.map((child: any) => { - return ` -
  • - ${child.name} -
  • - ` - })} -
-
- `); - - test.expectsAjaxCall('get') - .expectSentData(test.initialData) - .serverWillChangeData((data: any) => { - // "remove" child2 - data.children = [{ name: 'child1' }, { name: 'child3' }]; - }) - .init(); - - test.controller.$render(); - - await waitFor(() => expect(test.element).not.toHaveTextContent('child2')); - const secondLi = test.element.querySelectorAll('li').item(1); - expect(secondLi).not.toBeNull(); - // the 2nd li was just "updated" by the parent component, which - // would have eliminated its data-original-data attribute. Check - // that it was re-established to the 3rd child's data. - // see MutationObserver in live_controller for more details. - expect(secondLi.dataset.originalData).toEqual(JSON.stringify({name: 'child3'})); - }); - - it('notices as children are connected and disconnected', async () => { - const test = await createTest({}, (data: any) => ` -
- Parent component -
- `); - - expect(test.controller.childComponentControllers).toHaveLength(0); - - const createChildElement = () => { - const childElement = htmlToElement(`
child
`); - childElement.addEventListener('live:connect', (event: any) => { - event.detail.controller.element.setAttribute('connected', '1'); - }); - childElement.addEventListener('live:disconnect', (event: any) => { - event.detail.controller.element.setAttribute('connected', '0'); - }); - - return childElement; - }; - - const childElement1 = createChildElement(); - const childElement2 = createChildElement(); - const childElement3 = createChildElement(); - - test.element.appendChild(childElement1); - await waitFor(() => expect(childElement1).toHaveAttribute('connected', '1')); - expect(test.controller.childComponentControllers).toHaveLength(1); - - test.element.appendChild(childElement2); - test.element.appendChild(childElement3); - await waitFor(() => expect(childElement2).toHaveAttribute('connected', '1')); - await waitFor(() => expect(childElement3).toHaveAttribute('connected', '1')); - expect(test.controller.childComponentControllers).toHaveLength(3); - - test.element.removeChild(childElement2); - await waitFor(() => expect(childElement2).toHaveAttribute('connected', '0')); - expect(test.controller.childComponentControllers).toHaveLength(2); - test.element.removeChild(childElement1); - test.element.removeChild(childElement3); - await waitFor(() => expect(childElement1).toHaveAttribute('connected', '0')); - await waitFor(() => expect(childElement3).toHaveAttribute('connected', '0')); - expect(test.controller.childComponentControllers).toHaveLength(0); - }); -}); From d1076f8f8a27da05a4ec5d6d9f45733ab90bf6d3 Mon Sep 17 00:00:00 2001 From: Ryan Weaver Date: Sun, 9 Oct 2022 20:23:55 -0400 Subject: [PATCH 16/26] Updating docs for working via JS & making change event always update a model updating CHANGELOG --- src/LiveComponent/CHANGELOG.md | 12 ++ .../assets/src/Component/ValueStore.ts | 10 +- .../assets/src/Component/index.ts | 5 +- .../assets/src/live_controller.ts | 7 + .../assets/test/Component/index.test.ts | 4 +- .../assets/test/controller/loading.test.ts | 2 +- .../assets/test/controller/model.test.ts | 29 +++ src/LiveComponent/src/Resources/doc/index.rst | 191 ++++++++++-------- 8 files changed, 173 insertions(+), 87 deletions(-) diff --git a/src/LiveComponent/CHANGELOG.md b/src/LiveComponent/CHANGELOG.md index b4bd77bcc4a..af309ad1414 100644 --- a/src/LiveComponent/CHANGELOG.md +++ b/src/LiveComponent/CHANGELOG.md @@ -26,6 +26,18 @@ ``` +- [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 + 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. + +- [BEHAVIOR CHANGE] If a model is updated, but the new value is equal to the old + one, a re-render will now be avoided. + +- [BC BREAK] The `live:update-model` and `live:render` events are not longer + dispatched. You can now use the hook system directly on the `Component` object. + - Added the ability to add `data-loading` behavior, which is only activated when a specific **action** is triggered - e.g. `Loading`. diff --git a/src/LiveComponent/assets/src/Component/ValueStore.ts b/src/LiveComponent/assets/src/Component/ValueStore.ts index 525053b2cdd..22ffad72820 100644 --- a/src/LiveComponent/assets/src/Component/ValueStore.ts +++ b/src/LiveComponent/assets/src/Component/ValueStore.ts @@ -38,14 +38,20 @@ export default class { * Sets data back onto the value store. * * The name can be in the non-normalized format. + * + * Returns true if the new value is different than the existing value. */ - set(name: string, value: any): void { + set(name: string, value: any): boolean { const normalizedName = normalizeModelName(name); - if (!this.updatedModels.includes(normalizedName)) { + const currentValue = this.get(name); + + if (currentValue !== value && !this.updatedModels.includes(normalizedName)) { this.updatedModels.push(normalizedName); } this.data = setDeepData(this.data, normalizedName, value); + + return currentValue !== value; } all(): any { diff --git a/src/LiveComponent/assets/src/Component/index.ts b/src/LiveComponent/assets/src/Component/index.ts index d3e1c481aae..85c1d5c06b8 100644 --- a/src/LiveComponent/assets/src/Component/index.ts +++ b/src/LiveComponent/assets/src/Component/index.ts @@ -104,14 +104,15 @@ export default class Component { set(model: string, value: any, reRender = false, debounce: number|boolean = false): Promise { const promise = this.nextRequestPromise; const modelName = normalizeModelName(model); - this.valueStore.set(modelName, value); + const isChanged = this.valueStore.set(modelName, value); this.hooks.triggerHook('model:set', model, value); // the model's data is no longer unsynced this.unsyncedInputsTracker.markModelAsSynced(modelName); - if (reRender) { + // don't bother re-rendering if the value didn't change + if (reRender && isChanged) { this.debouncedStartRequest(debounce); } diff --git a/src/LiveComponent/assets/src/live_controller.ts b/src/LiveComponent/assets/src/live_controller.ts index 132e71fe52c..c6540e2cd9d 100644 --- a/src/LiveComponent/assets/src/live_controller.ts +++ b/src/LiveComponent/assets/src/live_controller.ts @@ -292,6 +292,13 @@ export default class extends Controller implements LiveController { shouldRender = false; } + // just in case, if a "change" event is happening, and this field + // targets "input", set the model to be safe. This helps when people + // manually trigger field updates by dispatching a "change" event + if (eventName === 'change' && targetEventName === 'input') { + targetEventName = 'change'; + } + // e.g. we are targeting "change" and this is the "input" event // so do *not* update the model yet if (eventName && targetEventName !== eventName) { diff --git a/src/LiveComponent/assets/test/Component/index.test.ts b/src/LiveComponent/assets/test/Component/index.test.ts index b04a9476deb..22b51f6414b 100644 --- a/src/LiveComponent/assets/test/Component/index.test.ts +++ b/src/LiveComponent/assets/test/Component/index.test.ts @@ -6,7 +6,7 @@ import { import BackendRequest from '../../src/BackendRequest'; import { Response } from 'node-fetch'; import {waitFor} from '@testing-library/dom'; -import BackendResponse from "../../src/BackendResponse"; +import BackendResponse from '../../src/BackendResponse'; interface MockBackend extends BackendInterface { actions: BackendAction[], @@ -58,7 +58,7 @@ describe('Component class', () => { expect(backendResponse).toBeNull(); // set model WITH re-render - component.set('firstName', 'Ryan', true); + component.set('firstName', 'Kevin', true); // it's still not *instantly* resolve - it'll expect(backendResponse).toBeNull(); await waitFor(() => expect(backendResponse).not.toBeNull()); diff --git a/src/LiveComponent/assets/test/controller/loading.test.ts b/src/LiveComponent/assets/test/controller/loading.test.ts index be5424f7704..fa972a1cbe6 100644 --- a/src/LiveComponent/assets/test/controller/loading.test.ts +++ b/src/LiveComponent/assets/test/controller/loading.test.ts @@ -11,7 +11,7 @@ import {createTest, initComponent, shutdownTests} from '../tools'; import {getByTestId, getByText, waitFor} from '@testing-library/dom'; -import userEvent from "@testing-library/user-event"; +import userEvent from '@testing-library/user-event'; describe('LiveController data-loading Tests', () => { afterEach(() => { diff --git a/src/LiveComponent/assets/test/controller/model.test.ts b/src/LiveComponent/assets/test/controller/model.test.ts index a304613a890..5ff1b1359cd 100644 --- a/src/LiveComponent/assets/test/controller/model.test.ts +++ b/src/LiveComponent/assets/test/controller/model.test.ts @@ -686,4 +686,33 @@ describe('LiveController data-model Tests', () => { expect(unmappedTextarea.value).toEqual('no data-model here!'); expect(unmappedTextarea.getAttribute('class')).toEqual('changed-class'); }); + + it('allows model fields to be manually set as long as change event is dispatched', async () => { + const test = await createTest({ food: '' }, (data: any) => ` +
+ + + + Food: ${data.food} +
+ `); + + test.expectsAjaxCall('get') + .expectSentData({ food: 'carrot' }) + .init(); + + const foodSelect = getByTestId(test.element, 'food-select'); + if (!(foodSelect instanceof HTMLSelectElement)) { + throw new Error('wrong type'); + } + + foodSelect.value = 'carrot'; + foodSelect.dispatchEvent(new Event('change', { bubbles: true })); + + await waitFor(() => expect(test.element).toHaveTextContent('Food: carrot')); + }); }); diff --git a/src/LiveComponent/src/Resources/doc/index.rst b/src/LiveComponent/src/Resources/doc/index.rst index b7604ef034d..fcc0d975e2c 100644 --- a/src/LiveComponent/src/Resources/doc/index.rst +++ b/src/LiveComponent/src/Resources/doc/index.rst @@ -384,31 +384,61 @@ live property on your component to the value ``edit``. The ``data-action="live#update"`` is Stimulus code that triggers the update. -Updating a Field via Custom JavaScript --------------------------------------- +Working with the Component in JavaScript +---------------------------------------- -Sometimes you might want to change the value of a field via your own -custom JavaScript. Suppose you have the following field inside your component: +Want to change the value of a model or even trigger an action from your +own custom JavaScript? No problem, thanks to a JavaScript ``Component`` +object, which is attached to each root component element. -.. code-block:: twig +For example, to write your custom JavaScript, you create a Stimulus +controller and put it around (or attached to) your root component element: - +.. code-block:: javascript + + // assets/controllers/some-custom-controller.js + // ... + + export default class extends Controller { + connect() { + // when the live component inside of this controller is initialized, + // this method will be called and you can access the Component object + this.element.addEventListener('live:connect', (event) => { + this.component = event.detail.component; + }); + } + + // some Stimulus action triggered, for example, on user click + toggleMode() { + // e.g. set some live property called "mode" on your component + this.component.set('mode', 'editing'); + // you can also say + this.component.mode = 'editing'; + + // or call an action + this.action('save', { arg1: 'value1' }); + // you can also say: + this.save({ arg1: 'value1'}); + } + } -To set the value of this field via custom JavaScript (e.g. a Stimulus controller), +You can also access the ``Component`` object via a special property +on the root component element: + +.. code-block:: javascript + + const component = document.getElementById('id-on-your-element').__component; + component.mode = 'editing'; + +Finally, you can also set the value of a model field directly. However, be sure to *also* trigger a ``change`` event so that live components is notified of the change: .. code-block:: javascript - const input = document.getElementById('favorite-food'); + const rootElement = document.getElementById('favorite-food'); input.value = 'sushi'; - element.dispatchEvent(new Event('input', { bubbles: true })); - - // if you have data-model="on(change)|favoriteFood", use the "change" event element.dispatchEvent(new Event('change', { bubbles: true })); Loading States @@ -1636,13 +1666,6 @@ To validate only on "change", use the ``on(change)`` modifier: class="{{ this.getError('post.content') ? 'has-error' : '' }}" > -Interacting with JavaScript ---------------------------- - -TODO: -- events - like live:connect -- the Component object - Polling ------- @@ -1687,49 +1710,82 @@ Nested Components Need to nest one live component inside another one? No problem! As a rule of thumb, **each component exists in its own, isolated universe**. -This means that nesting one component inside another could be really -simple or a bit more complex, depending on how inter-connected you want -your components to be. +This means that if a parent component re-renders, it won't automatically +cause the child to re-render (but it *may* - keep reading). Or, if +a model in a child updates, it won't also update that model in its parent +(but it *can* - keep reading). -Here are a few helpful things to know: +The parent-child system is *smart*. And with a few tricks, you can make +it behave exactly like you need. Each component re-renders independent of one another ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -If a parent component re-renders, the child component will *not* (most -of the time) be updated, even though it lives inside the parent. Each -component is its own, isolated universe. - -But this is not always what you want. For example, suppose you have a -parent component that renders a form and a child component that renders -one field in that form. When you click a "Save" button on the parent -component, that validates the form and re-renders with errors - -including a new ``error`` value that it passes into the child: +If a parent component re-renders, this may or may not cause the child +component to send its own Ajax request to re-render. What determines +that? Let's look at an example of a todo list component with a child +that renders the total number of todo items: .. code-block:: twig - {# templates/components/post_form.html.twig #} + {# templates/components/todo_list.html.twig #} +
+ - {{ component('textarea_field', { - value: this.content, - error: this.getError('content') - }) }} + {% for todo in todos %} + ... + {% endfor %} -In this situation, when the parent component re-renders after clicking -"Save", you *do* want the updated child component (with the validation -error) to be rendered. And this *will* happen automatically. Why? -because the live component system detects that the **parent component -has changed how it's rendering the child**. + {{ component('todo_footer', { + count: todos|length + }) }} +
+ +Suppose the user updates the ``listName`` model and the parent component +re-renders. In this case, the child component will *not* re-render. Why? +Because the live components system will detect that none of the values passed +*into* ``todo_footer`` (just ``count`` in this case). Have change. If no inputs +to the child changed, there's no need to re-render it. + +But if the user added a *new* todo item and the number of todos changed from +5 to 6, this *would* change the ``count`` value that's passed into the ``todo_footer``. +In this case, immediately after the parent component re-renders, the child +request will make a second Ajax request to render itself. Smart! + +Child components keep their modifiable LiveProp values +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +But suppose that the ``todo_footer`` in the previous example also has +an ``isVisible`` ``LiveProp(writable: true)`` property which starts as +``true`` but can be changed (via a link click) to ``false``. Will +re-rendering the child cause this to be reset back to its original +value? Nope! When the child component re-renders, it will keep the +current value for any of its writable props. + +What if you *do* want your entire child component to re-render (including +resetting writable live props) when some value in the parent changes? This +can be done by manually giving your component a ``data-live-id`` attribute +that will change if the component should be totally re-rendered: + +.. code-block:: twig + + {# templates/components/todo_list.html.twig #} +
+ -This may not always be perfect, and if your child component has its own -``LiveProp`` that has changed since it was first rendered, that value -will be lost when the parent component causes the child to re-render. If -you have this situation, use ``data-model-map`` to map that child -``LiveProp`` to a ``LiveProp`` in the parent component, and pass it into -the child when rendering. + {{ component('todo_footer', { + count: todos|length, + 'data-live-id': 'todo-footer-'~todos|length + }) }} +
+ +In this case, if the number of todos change, then the ``data-live-id`` +attribute of the component will also change. This signals that the +component should re-render itself completely, discarding any writable +LiveProp values. -Actions, methods and model updates in a child do not affect the parent -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Actions in a child do not affect the parent +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Again, each component is its own, isolated universe! For example, suppose your child component has: @@ -1750,42 +1806,17 @@ Suppose a child component has a: .. code-block:: html - diff --git a/src/TwigComponent/tests/Integration/EventListener/DataModelPropsSubscriberTest.php b/src/TwigComponent/tests/Integration/EventListener/DataModelPropsSubscriberTest.php new file mode 100644 index 00000000000..5493e26e831 --- /dev/null +++ b/src/TwigComponent/tests/Integration/EventListener/DataModelPropsSubscriberTest.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\TwigComponent\Tests\Integration; + +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\UX\TwigComponent\ComponentRenderer; + +final class DataModelPropsSubscriberTest extends KernelTestCase +{ + public function testDataModelPropsAreSharedToChild(): void + { + /** @var ComponentRenderer $renderer */ + $renderer = self::getContainer()->get('ux.twig_component.component_renderer'); + + $html = $renderer->createAndRender('parent_form_component', [ + // content is mapped down to "value" in a child component + 'content' => 'Hello data-model!', + 'content2' => 'Value for second child', + ]); + + $this->assertStringContainsString('', $html); + $this->assertStringContainsString('', $html); + } +} diff --git a/src/TwigComponent/tests/Unit/Util/ModelBindingParserTest.php b/src/TwigComponent/tests/Unit/Util/ModelBindingParserTest.php new file mode 100644 index 00000000000..cc951fbfb6d --- /dev/null +++ b/src/TwigComponent/tests/Unit/Util/ModelBindingParserTest.php @@ -0,0 +1,54 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\TwigComponent\Tests\Unit\Util; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\TwigComponent\Util\ModelBindingParser; + +final class ModelBindingParserTest extends TestCase +{ + /** + * @dataProvider getModelStringTests + */ + public function testParseAllValidStrings(string $input, array $expectedBindings): void + { + $parser = new ModelBindingParser(); + $this->assertEquals($expectedBindings, $parser->parse($input)); + } + + public function getModelStringTests(): \Generator + { + yield 'empty_string' => ['', []]; + + yield 'valid_but_empty_second_mode' => ['foo:bar ', [ + ['child' => 'foo', 'parent' => 'bar'], + ]]; + + yield 'valid_without_colon_uses_value_default' => ['foo ', [ + ['child' => 'value', 'parent' => 'foo'], + ]]; + + yield 'multiple_spaces_between_models' => ['foo bar:baz', [ + ['child' => 'value', 'parent' => 'foo'], + ['child' => 'bar', 'parent' => 'baz'], + ]]; + } + + public function testParseThrowsExceptionWithMutipleColons(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid value "foo:bar:baz" given for "data-model"'); + + $parser = new ModelBindingParser(); + $parser->parse('foo:bar:baz'); + } +} diff --git a/src/Vue/Resources/assets/test/render_controller.test.ts b/src/Vue/Resources/assets/test/render_controller.test.ts index a43ab520dd4..904dd591aa4 100644 --- a/src/Vue/Resources/assets/test/render_controller.test.ts +++ b/src/Vue/Resources/assets/test/render_controller.test.ts @@ -34,7 +34,7 @@ const startStimulus = () => { }; const Hello = { - template: "

Hello {{ name ?? 'world' }}

", + template: '

Hello {{ name ?? \'world\' }}

', props: ['name'] };