diff --git a/package.json b/package.json index 817fbe80478..e16d02d010f 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,12 @@ ], "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", + "quotes": [ + "error", + "single" + ] }, "env": { "browser": true diff --git a/src/Cropperjs/Resources/assets/test/controller.test.ts b/src/Cropperjs/Resources/assets/test/controller.test.ts index 6027c9e0f9f..4b5d6b11dff 100644 --- a/src/Cropperjs/Resources/assets/test/controller.test.ts +++ b/src/Cropperjs/Resources/assets/test/controller.test.ts @@ -52,7 +52,7 @@ describe('CropperjsController', () => { data-cropperjs-public-url-value="https://symfony.com/logos/symfony_black_02.png" data-cropperjs-options-value="${dataToJsonAttribute({ viewMode: 1, - dragMode: "move" + dragMode: 'move' })}" > diff --git a/src/LiveComponent/CHANGELOG.md b/src/LiveComponent/CHANGELOG.md index b4bd77bcc4a..1c49c6dfc32 100644 --- a/src/LiveComponent/CHANGELOG.md +++ b/src/LiveComponent/CHANGELOG.md @@ -26,7 +26,25 @@ ``` -- Added the ability to add `data-loading` behavior, which is only activated +- [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/ + +- [BC BREAK] The `LiveComponentHydrator::dehydrate()` method now returns a + `DehydratedComponent` object. + +- Added a new JavaScript `Component` object, which is attached to the `__component` + property of all root component elements. + +- the ability to add `data-loading` behavior, which is only activated when a specific **action** is triggered - e.g. `Loading`. - Added the ability to add `data-loading` behavior, which is only activated diff --git a/src/LiveComponent/assets/dist/live_controller.js b/src/LiveComponent/assets/dist/live_controller.js index 3bfd9407d63..f7a5fa88c02 100644 --- a/src/LiveComponent/assets/dist/live_controller.js +++ b/src/LiveComponent/assets/dist/live_controller.js @@ -1,31 +1,418 @@ import { Controller } from '@hotwired/stimulus'; -/****************************************************************************** -Copyright (c) Microsoft Corporation. +function parseDirectives(content) { + const directives = []; + if (!content) { + return directives; + } + let currentActionName = ''; + let currentArgumentName = ''; + let currentArgumentValue = ''; + let currentArguments = []; + let currentNamedArguments = {}; + let currentModifiers = []; + let state = 'action'; + const getLastActionName = function () { + if (currentActionName) { + return currentActionName; + } + if (directives.length === 0) { + throw new Error('Could not find any directives'); + } + return directives[directives.length - 1].action; + }; + const pushInstruction = function () { + directives.push({ + action: currentActionName, + args: currentArguments, + named: currentNamedArguments, + modifiers: currentModifiers, + getString: () => { + return content; + } + }); + currentActionName = ''; + currentArgumentName = ''; + currentArgumentValue = ''; + currentArguments = []; + currentNamedArguments = {}; + currentModifiers = []; + state = 'action'; + }; + const pushArgument = function () { + const mixedArgTypesError = () => { + throw new Error(`Normal and named arguments cannot be mixed inside "${currentActionName}()"`); + }; + if (currentArgumentName) { + if (currentArguments.length > 0) { + mixedArgTypesError(); + } + currentNamedArguments[currentArgumentName.trim()] = currentArgumentValue; + } + else { + if (Object.keys(currentNamedArguments).length > 0) { + mixedArgTypesError(); + } + currentArguments.push(currentArgumentValue.trim()); + } + currentArgumentName = ''; + currentArgumentValue = ''; + }; + const pushModifier = function () { + if (currentArguments.length > 1) { + throw new Error(`The modifier "${currentActionName}()" does not support multiple arguments.`); + } + if (Object.keys(currentNamedArguments).length > 0) { + throw new Error(`The modifier "${currentActionName}()" does not support named arguments.`); + } + currentModifiers.push({ + name: currentActionName, + value: currentArguments.length > 0 ? currentArguments[0] : null, + }); + currentActionName = ''; + currentArgumentName = ''; + currentArguments = []; + state = 'action'; + }; + for (let i = 0; i < content.length; i++) { + const char = content[i]; + switch (state) { + case 'action': + if (char === '(') { + state = 'arguments'; + break; + } + if (char === ' ') { + if (currentActionName) { + pushInstruction(); + } + break; + } + if (char === '|') { + pushModifier(); + break; + } + currentActionName += char; + break; + case 'arguments': + if (char === ')') { + pushArgument(); + state = 'after_arguments'; + break; + } + if (char === ',') { + pushArgument(); + break; + } + if (char === '=') { + currentArgumentName = currentArgumentValue; + currentArgumentValue = ''; + break; + } + currentArgumentValue += char; + break; + case 'after_arguments': + if (char === '|') { + pushModifier(); + break; + } + if (char !== ' ') { + throw new Error(`Missing space after ${getLastActionName()}()`); + } + pushInstruction(); + break; + } + } + switch (state) { + case 'action': + case 'after_arguments': + if (currentActionName) { + pushInstruction(); + } + break; + default: + throw new Error(`Did you forget to add a closing ")" after "${currentActionName}"?`); + } + return directives; +} -Permission to use, copy, modify, and/or distribute this software for any -purpose with or without fee is hereby granted. +function combineSpacedArray(parts) { + const finalParts = []; + parts.forEach((part) => { + finalParts.push(...part.split(' ')); + }); + return finalParts; +} +function normalizeModelName(model) { + return model + .replace(/\[]$/, '') + .split('[') + .map(function (s) { + return s.replace(']', ''); + }) + .join('.'); +} -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH -REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY -AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, -INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM -LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR -OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR -PERFORMANCE OF THIS SOFTWARE. -***************************************************************************** */ +function getValueFromElement(element, valueStore) { + if (element instanceof HTMLInputElement) { + if (element.type === 'checkbox') { + const modelNameData = getModelDirectiveFromElement(element); + if (modelNameData === null) { + return null; + } + const modelValue = valueStore.get(modelNameData.action); + if (Array.isArray(modelValue)) { + return getMultipleCheckboxValue(element, modelValue); + } + return element.checked ? inputValue(element) : null; + } + return inputValue(element); + } + if (element instanceof HTMLSelectElement) { + if (element.multiple) { + return Array.from(element.selectedOptions).map(el => el.value); + } + return element.value; + } + if (element.dataset.value) { + return element.dataset.value; + } + if ('value' in element) { + return element.value; + } + if (element.hasAttribute('value')) { + return element.getAttribute('value'); + } + return null; +} +function setValueOnElement(element, value) { + if (element instanceof HTMLInputElement) { + if (element.type === 'file') { + return; + } + if (element.type === 'radio') { + element.checked = element.value == value; + return; + } + if (element.type === 'checkbox') { + if (Array.isArray(value)) { + let valueFound = false; + value.forEach(val => { + if (val == element.value) { + valueFound = true; + } + }); + element.checked = valueFound; + } + else { + element.checked = element.value == value; + } + return; + } + } + if (element instanceof HTMLSelectElement) { + const arrayWrappedValue = [].concat(value).map(value => { + return value + ''; + }); + Array.from(element.options).forEach(option => { + option.selected = arrayWrappedValue.includes(option.value); + }); + return; + } + value = value === undefined ? '' : value; + element.value = value; +} +function getAllModelDirectiveFromElements(element) { + if (!element.dataset.model) { + return []; + } + const directives = parseDirectives(element.dataset.model); + directives.forEach((directive) => { + if (directive.args.length > 0 || directive.named.length > 0) { + throw new Error(`The data-model="${element.dataset.model}" format is invalid: it does not support passing arguments to the model.`); + } + directive.action = normalizeModelName(directive.action); + }); + return directives; +} +function getModelDirectiveFromElement(element, throwOnMissing = true) { + const dataModelDirectives = getAllModelDirectiveFromElements(element); + if (dataModelDirectives.length > 0) { + return dataModelDirectives[0]; + } + if (element.getAttribute('name')) { + const formElement = element.closest('form'); + if (formElement && ('model' in formElement.dataset)) { + const directives = parseDirectives(formElement.dataset.model || '*'); + const directive = directives[0]; + if (directive.args.length > 0 || directive.named.length > 0) { + throw new Error(`The data-model="${formElement.dataset.model}" format is invalid: it does not support passing arguments to the model.`); + } + directive.action = normalizeModelName(element.getAttribute('name')); + return directive; + } + } + if (!throwOnMissing) { + return null; + } + throw new Error(`Cannot determine the model name for "${getElementAsTagText(element)}": the element must either have a "data-model" (or "name" attribute living inside a
).`); +} +function elementBelongsToThisComponent(element, component) { + if (component.element === element) { + return true; + } + if (!component.element.contains(element)) { + return false; + } + let foundChildComponent = false; + component.getChildren().forEach((childComponent) => { + if (foundChildComponent) { + return; + } + if (childComponent.element === element || childComponent.element.contains(element)) { + foundChildComponent = true; + } + }); + return !foundChildComponent; +} +function cloneHTMLElement(element) { + const newElement = element.cloneNode(true); + if (!(newElement instanceof HTMLElement)) { + throw new Error('Could not clone element'); + } + return newElement; +} +function htmlToElement(html) { + const template = document.createElement('template'); + html = html.trim(); + template.innerHTML = html; + const child = template.content.firstChild; + if (!child) { + throw new Error('Child not found'); + } + if (!(child instanceof HTMLElement)) { + throw new Error(`Created element is not an Element from HTML: ${html.trim()}`); + } + return child; +} +function cloneElementWithNewTagName(element, newTag) { + const originalTag = element.tagName; + const startRX = new RegExp('^<' + originalTag, 'i'); + const endRX = new RegExp(originalTag + '>$', 'i'); + const startSubst = '<' + newTag; + const endSubst = newTag + '>'; + const newHTML = element.outerHTML + .replace(startRX, startSubst) + .replace(endRX, endSubst); + return htmlToElement(newHTML); +} +function getElementAsTagText(element) { + return element.innerHTML ? element.outerHTML.slice(0, element.outerHTML.indexOf(element.innerHTML)) : element.outerHTML; +} +const getMultipleCheckboxValue = function (element, currentValues) { + const value = inputValue(element); + const index = currentValues.indexOf(value); + if (element.checked) { + if (index === -1) { + currentValues.push(value); + } + return currentValues; + } + if (index > -1) { + currentValues.splice(index, 1); + } + return currentValues; +}; +const inputValue = function (element) { + return element.dataset.value ? element.dataset.value : element.value; +}; -function __classPrivateFieldGet(receiver, state, kind, f) { - if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter"); - if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it"); - return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver); +function getDeepData(data, propertyPath) { + const { currentLevelData, finalKey } = parseDeepData(data, propertyPath); + if (currentLevelData === undefined) { + return undefined; + } + return currentLevelData[finalKey]; +} +const parseDeepData = function (data, propertyPath) { + const finalData = JSON.parse(JSON.stringify(data)); + let currentLevelData = finalData; + const parts = propertyPath.split('.'); + for (let i = 0; i < parts.length - 1; i++) { + currentLevelData = currentLevelData[parts[i]]; + } + const finalKey = parts[parts.length - 1]; + return { + currentLevelData, + finalData, + finalKey, + parts + }; +}; +function setDeepData(data, propertyPath, value) { + const { currentLevelData, finalData, finalKey, parts } = parseDeepData(data, propertyPath); + if (typeof currentLevelData !== 'object') { + const lastPart = parts.pop(); + if (typeof currentLevelData === 'undefined') { + throw new Error(`Cannot set data-model="${propertyPath}". The parent "${parts.join('.')}" data does not exist. Did you forget to expose "${parts[0]}" as a LiveProp?`); + } + throw new Error(`Cannot set data-model="${propertyPath}". The parent "${parts.join('.')}" data does not appear to be an object (it's "${currentLevelData}"). Did you forget to add exposed={"${lastPart}"} to its LiveProp?`); + } + if (currentLevelData[finalKey] === undefined) { + const lastPart = parts.pop(); + if (parts.length > 0) { + throw new Error(`The model name ${propertyPath} was never initialized. Did you forget to add exposed={"${lastPart}"} to its LiveProp?`); + } + else { + throw new Error(`The model name "${propertyPath}" was never initialized. Did you forget to expose "${lastPart}" as a LiveProp? Available models values are: ${Object.keys(data).length > 0 ? Object.keys(data).join(', ') : '(none)'}`); + } + } + currentLevelData[finalKey] = value; + return finalData; } -function __classPrivateFieldSet(receiver, state, value, kind, f) { - if (kind === "m") throw new TypeError("Private method is not writable"); - if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a setter"); - if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it"); - return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value; +class ValueStore { + constructor(props, data) { + this.updatedModels = []; + this.props = {}; + this.data = {}; + this.props = props; + this.data = data; + } + get(name) { + const normalizedName = normalizeModelName(name); + const result = getDeepData(this.data, normalizedName); + if (result !== undefined) { + return result; + } + return getDeepData(this.props, normalizedName); + } + has(name) { + return this.get(name) !== undefined; + } + set(name, value) { + const normalizedName = normalizeModelName(name); + 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() { + return Object.assign(Object.assign({}, this.props), this.data); + } + reinitializeData(data) { + this.updatedModels = []; + this.data = data; + } + reinitializeProps(props) { + if (JSON.stringify(props) == JSON.stringify(this.props)) { + return false; + } + this.props = props; + return true; + } } var DOCUMENT_FRAGMENT_NODE = 11; @@ -773,193 +1160,15 @@ function morphdomFactory(morphAttrs) { // replace the old DOM node in the original DOM tree. This is only // possible if the original DOM node was part of a DOM tree which // we know is the case if it has a parent node. - fromNode.parentNode.replaceChild(morphedNode, fromNode); - } - - return morphedNode; - }; -} - -var morphdom = morphdomFactory(morphAttrs); - -function parseDirectives(content) { - const directives = []; - if (!content) { - return directives; - } - let currentActionName = ''; - let currentArgumentName = ''; - let currentArgumentValue = ''; - let currentArguments = []; - let currentNamedArguments = {}; - let currentModifiers = []; - let state = 'action'; - const getLastActionName = function () { - if (currentActionName) { - return currentActionName; - } - if (directives.length === 0) { - throw new Error('Could not find any directives'); - } - return directives[directives.length - 1].action; - }; - const pushInstruction = function () { - directives.push({ - action: currentActionName, - args: currentArguments, - named: currentNamedArguments, - modifiers: currentModifiers, - getString: () => { - return content; - } - }); - currentActionName = ''; - currentArgumentName = ''; - currentArgumentValue = ''; - currentArguments = []; - currentNamedArguments = {}; - currentModifiers = []; - state = 'action'; - }; - const pushArgument = function () { - const mixedArgTypesError = () => { - throw new Error(`Normal and named arguments cannot be mixed inside "${currentActionName}()"`); - }; - if (currentArgumentName) { - if (currentArguments.length > 0) { - mixedArgTypesError(); - } - currentNamedArguments[currentArgumentName.trim()] = currentArgumentValue; - } - else { - if (Object.keys(currentNamedArguments).length > 0) { - mixedArgTypesError(); - } - currentArguments.push(currentArgumentValue.trim()); - } - currentArgumentName = ''; - currentArgumentValue = ''; - }; - const pushModifier = function () { - if (currentArguments.length > 1) { - throw new Error(`The modifier "${currentActionName}()" does not support multiple arguments.`); - } - if (Object.keys(currentNamedArguments).length > 0) { - throw new Error(`The modifier "${currentActionName}()" does not support named arguments.`); - } - currentModifiers.push({ - name: currentActionName, - value: currentArguments.length > 0 ? currentArguments[0] : null, - }); - currentActionName = ''; - currentArgumentName = ''; - currentArguments = []; - state = 'action'; - }; - for (let i = 0; i < content.length; i++) { - const char = content[i]; - switch (state) { - case 'action': - if (char === '(') { - state = 'arguments'; - break; - } - if (char === ' ') { - if (currentActionName) { - pushInstruction(); - } - break; - } - if (char === '|') { - pushModifier(); - break; - } - currentActionName += char; - break; - case 'arguments': - if (char === ')') { - pushArgument(); - state = 'after_arguments'; - break; - } - if (char === ',') { - pushArgument(); - break; - } - if (char === '=') { - currentArgumentName = currentArgumentValue; - currentArgumentValue = ''; - break; - } - currentArgumentValue += char; - break; - case 'after_arguments': - if (char === '|') { - pushModifier(); - break; - } - if (char !== ' ') { - throw new Error(`Missing space after ${getLastActionName()}()`); - } - pushInstruction(); - break; - } - } - switch (state) { - case 'action': - case 'after_arguments': - if (currentActionName) { - pushInstruction(); - } - break; - default: - throw new Error(`Did you forget to add a closing ")" after "${currentActionName}"?`); - } - return directives; -} - -function combineSpacedArray(parts) { - const finalParts = []; - parts.forEach((part) => { - finalParts.push(...part.split(' ')); - }); - return finalParts; -} -function normalizeModelName(model) { - return model - .replace(/\[]$/, '') - .split('[') - .map(function (s) { - return s.replace(']', ''); - }) - .join('.'); -} - -function haveRenderedValuesChanged(originalDataJson, currentDataJson, newDataJson) { - if (originalDataJson === newDataJson) { - return false; - } - if (currentDataJson === newDataJson) { - return false; - } - const originalData = JSON.parse(originalDataJson); - const newData = JSON.parse(newDataJson); - const changedKeys = Object.keys(newData); - Object.entries(originalData).forEach(([key, value]) => { - if (value === newData[key]) { - changedKeys.splice(changedKeys.indexOf(key), 1); - } - }); - const currentData = JSON.parse(currentDataJson); - let keyHasChanged = false; - changedKeys.forEach((key) => { - if (currentData[key] !== newData[key]) { - keyHasChanged = true; + fromNode.parentNode.replaceChild(morphedNode, fromNode); } - }); - return keyHasChanged; + + return morphedNode; + }; } +var morphdom = morphdomFactory(morphAttrs); + function normalizeAttributesForComparison(element) { const isFileInput = element instanceof HTMLInputElement && element.type === 'file'; if (!isFileInput) { @@ -975,234 +1184,135 @@ function normalizeAttributesForComparison(element) { }); } -function getDeepData(data, propertyPath) { - const { currentLevelData, finalKey } = parseDeepData(data, propertyPath); - return currentLevelData[finalKey]; -} -const parseDeepData = function (data, propertyPath) { - const finalData = JSON.parse(JSON.stringify(data)); - let currentLevelData = finalData; - const parts = propertyPath.split('.'); - for (let i = 0; i < parts.length - 1; i++) { - currentLevelData = currentLevelData[parts[i]]; - } - const finalKey = parts[parts.length - 1]; - return { - currentLevelData, - finalData, - finalKey, - parts - }; -}; -function setDeepData(data, propertyPath, value) { - const { currentLevelData, finalData, finalKey, parts } = parseDeepData(data, propertyPath); - if (typeof currentLevelData !== 'object') { - const lastPart = parts.pop(); - if (typeof currentLevelData === 'undefined') { - throw new Error(`Cannot set data-model="${propertyPath}". The parent "${parts.join('.')}" data does not exist. Did you forget to expose "${parts[0]}" as a LiveProp?`); +function executeMorphdom(rootFromElement, rootToElement, modifiedElements, getElementValue, childComponents, findChildComponent, getKeyFromElement) { + const childComponentMap = new Map(); + childComponents.forEach((childComponent) => { + childComponentMap.set(childComponent.element, childComponent); + if (!childComponent.id) { + throw new Error('Child is missing id.'); } - throw new Error(`Cannot set data-model="${propertyPath}". The parent "${parts.join('.')}" data does not appear to be an object (it's "${currentLevelData}"). Did you forget to add exposed={"${lastPart}"} to its LiveProp?`); - } - if (currentLevelData[finalKey] === undefined) { - const lastPart = parts.pop(); - if (parts.length > 0) { - throw new Error(`The model name ${propertyPath} was never initialized. Did you forget to add exposed={"${lastPart}"} to its LiveProp?`); + const childComponentToElement = findChildComponent(childComponent.id, rootToElement); + if (childComponentToElement && childComponentToElement.tagName !== childComponent.element.tagName) { + const newTag = cloneElementWithNewTagName(childComponentToElement, childComponent.element.tagName); + rootToElement.replaceChild(newTag, childComponentToElement); } - else { - throw new Error(`The model name "${propertyPath}" was never initialized. Did you forget to expose "${lastPart}" as a LiveProp? Available models values are: ${Object.keys(data).length > 0 ? Object.keys(data).join(', ') : '(none)'}`); + }); + morphdom(rootFromElement, rootToElement, { + getNodeKey: (node) => { + if (!(node instanceof HTMLElement)) { + return; + } + return getKeyFromElement(node); + }, + onBeforeElUpdated: (fromEl, toEl) => { + if (fromEl === rootFromElement) { + return true; + } + if (!(fromEl instanceof HTMLElement) || !(toEl instanceof HTMLElement)) { + return false; + } + const childComponent = childComponentMap.get(fromEl) || false; + if (childComponent) { + return childComponent.updateFromNewElement(toEl); + } + if (modifiedElements.includes(fromEl)) { + setValueOnElement(toEl, getElementValue(fromEl)); + } + if (fromEl.isEqualNode(toEl)) { + const normalizedFromEl = cloneHTMLElement(fromEl); + normalizeAttributesForComparison(normalizedFromEl); + const normalizedToEl = cloneHTMLElement(toEl); + normalizeAttributesForComparison(normalizedToEl); + if (normalizedFromEl.isEqualNode(normalizedToEl)) { + return false; + } + } + return !fromEl.hasAttribute('data-live-ignore'); + }, + onBeforeNodeDiscarded(node) { + if (!(node instanceof HTMLElement)) { + return true; + } + return !node.hasAttribute('data-live-ignore'); } - } - currentLevelData[finalKey] = value; - return finalData; + }); } -class ValueStore { - constructor(liveController) { - this.updatedModels = []; - this.controller = liveController; - } - get(name) { - const normalizedName = normalizeModelName(name); - return getDeepData(this.controller.dataValue, normalizedName); - } - has(name) { - return this.get(name) !== undefined; - } - set(name, value) { - const normalizedName = normalizeModelName(name); - if (!this.updatedModels.includes(normalizedName)) { - this.updatedModels.push(normalizedName); - } - this.controller.dataValue = setDeepData(this.controller.dataValue, normalizedName, value); - } - hasAtTopLevel(name) { - const parts = name.split('.'); - return this.controller.dataValue[parts[0]] !== undefined; - } - asJson() { - return JSON.stringify(this.controller.dataValue); - } - all() { - return this.controller.dataValue; - } - areAnyModelsUpdated(targetedModels) { - return (this.updatedModels.filter(modelName => targetedModels.includes(modelName))).length > 0; - } +/****************************************************************************** +Copyright (c) Microsoft Corporation. + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. +***************************************************************************** */ + +function __classPrivateFieldGet(receiver, state, kind, f) { + if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter"); + if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it"); + return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver); } -function getValueFromElement(element, valueStore) { - if (element instanceof HTMLInputElement) { - if (element.type === 'checkbox') { - const modelNameData = getModelDirectiveFromElement(element); - if (modelNameData === null) { - return null; - } - const modelValue = valueStore.get(modelNameData.action); - if (Array.isArray(modelValue)) { - return getMultipleCheckboxValue(element, modelValue); - } - return element.checked ? inputValue(element) : null; - } - return inputValue(element); - } - if (element instanceof HTMLSelectElement) { - if (element.multiple) { - return Array.from(element.selectedOptions).map(el => el.value); - } - return element.value; - } - if (element.dataset.value) { - return element.dataset.value; - } - if ('value' in element) { - return element.value; - } - if (element.hasAttribute('value')) { - return element.getAttribute('value'); - } - return null; +function __classPrivateFieldSet(receiver, state, value, kind, f) { + if (kind === "m") throw new TypeError("Private method is not writable"); + if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a setter"); + if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it"); + return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value; } -function setValueOnElement(element, value) { - if (element instanceof HTMLInputElement) { - if (element.type === 'file') { - return; - } - if (element.type === 'radio') { - element.checked = element.value == value; - return; - } - if (element.type === 'checkbox') { - if (Array.isArray(value)) { - let valueFound = false; - value.forEach(val => { - if (val == element.value) { - valueFound = true; - } - }); - element.checked = valueFound; - } - else { - element.checked = element.value == value; - } - return; - } + +var _UnsyncedInputContainer_mappedFields, _UnsyncedInputContainer_unmappedFields; +class UnsyncedInputsTracker { + constructor(component, modelElementResolver) { + this.elementEventListeners = [ + { event: 'input', callback: (event) => this.handleInputEvent(event) }, + ]; + this.component = component; + this.modelElementResolver = modelElementResolver; + this.unsyncedInputs = new UnsyncedInputContainer(); } - if (element instanceof HTMLSelectElement) { - const arrayWrappedValue = [].concat(value).map(value => { - return value + ''; + activate() { + this.elementEventListeners.forEach(({ event, callback }) => { + this.component.element.addEventListener(event, callback); }); - Array.from(element.options).forEach(option => { - option.selected = arrayWrappedValue.includes(option.value); + } + deactivate() { + this.elementEventListeners.forEach(({ event, callback }) => { + this.component.element.removeEventListener(event, callback); }); - return; } - value = value === undefined ? '' : value; - element.value = value; -} -function getModelDirectiveFromElement(element, throwOnMissing = true) { - if (element.dataset.model) { - const directives = parseDirectives(element.dataset.model); - const directive = directives[0]; - if (directive.args.length > 0 || directive.named.length > 0) { - throw new Error(`The data-model="${element.dataset.model}" format is invalid: it does not support passing arguments to the model.`); - } - directive.action = normalizeModelName(directive.action); - return directive; + markModelAsSynced(modelName) { + this.unsyncedInputs.markModelAsSynced(modelName); } - if (element.getAttribute('name')) { - const formElement = element.closest('form'); - if (formElement && ('model' in formElement.dataset)) { - const directives = parseDirectives(formElement.dataset.model || '*'); - const directive = directives[0]; - if (directive.args.length > 0 || directive.named.length > 0) { - throw new Error(`The data-model="${formElement.dataset.model}" format is invalid: it does not support passing arguments to the model.`); - } - directive.action = normalizeModelName(element.getAttribute('name')); - return directive; + handleInputEvent(event) { + const target = event.target; + if (!target) { + return; } + this.updateModelFromElement(target); } - if (!throwOnMissing) { - return null; - } - throw new Error(`Cannot determine the model name for "${getElementAsTagText(element)}": the element must either have a "data-model" (or "name" attribute living inside a ).`); -} -function elementBelongsToThisController(element, controller) { - if (controller.element !== element && !controller.element.contains(element)) { - return false; - } - let foundChildController = false; - controller.childComponentControllers.forEach((childComponentController) => { - if (foundChildController) { + updateModelFromElement(element) { + if (!elementBelongsToThisComponent(element, this.component)) { return; } - if (childComponentController.element === element || childComponentController.element.contains(element)) { - foundChildController = true; + if (!(element instanceof HTMLElement)) { + throw new Error('Could not update model for non HTMLElement'); } - }); - return !foundChildController; -} -function cloneHTMLElement(element) { - const newElement = element.cloneNode(true); - if (!(newElement instanceof HTMLElement)) { - throw new Error('Could not clone element'); + const modelName = this.modelElementResolver.getModelName(element); + this.unsyncedInputs.add(element, modelName); } - return newElement; -} -function htmlToElement(html) { - const template = document.createElement('template'); - html = html.trim(); - template.innerHTML = html; - const child = template.content.firstChild; - if (!child) { - throw new Error('Child not found'); + getUnsyncedInputs() { + return this.unsyncedInputs.all(); } - if (!(child instanceof HTMLElement)) { - throw new Error(`Created element is not an Element from HTML: ${html.trim()}`); + getModifiedModels() { + return Array.from(this.unsyncedInputs.getModifiedModels()); } - return child; -} -function getElementAsTagText(element) { - return element.innerHTML ? element.outerHTML.slice(0, element.outerHTML.indexOf(element.innerHTML)) : element.outerHTML; } -const getMultipleCheckboxValue = function (element, currentValues) { - const value = inputValue(element); - const index = currentValues.indexOf(value); - if (element.checked) { - if (index === -1) { - currentValues.push(value); - } - return currentValues; - } - if (index > -1) { - currentValues.splice(index, 1); - } - return currentValues; -}; -const inputValue = function (element) { - return element.dataset.value ? element.dataset.value : element.value; -}; - -var _UnsyncedInputContainer_mappedFields, _UnsyncedInputContainer_unmappedFields; class UnsyncedInputContainer { constructor() { _UnsyncedInputContainer_mappedFields.set(this, void 0); @@ -1219,250 +1329,502 @@ class UnsyncedInputContainer { all() { return [...__classPrivateFieldGet(this, _UnsyncedInputContainer_unmappedFields, "f"), ...__classPrivateFieldGet(this, _UnsyncedInputContainer_mappedFields, "f").values()]; } - markModelAsSynced(modelName) { - __classPrivateFieldGet(this, _UnsyncedInputContainer_mappedFields, "f").delete(modelName); + markModelAsSynced(modelName) { + __classPrivateFieldGet(this, _UnsyncedInputContainer_mappedFields, "f").delete(modelName); + } + getModifiedModels() { + return Array.from(__classPrivateFieldGet(this, _UnsyncedInputContainer_mappedFields, "f").keys()); + } +} +_UnsyncedInputContainer_mappedFields = new WeakMap(), _UnsyncedInputContainer_unmappedFields = new WeakMap(); + +class HookManager { + constructor() { + this.hooks = new Map(); + } + register(hookName, callback) { + const hooks = this.hooks.get(hookName) || []; + hooks.push(callback); + this.hooks.set(hookName, hooks); + } + unregister(hookName, callback) { + const hooks = this.hooks.get(hookName) || []; + const index = hooks.indexOf(callback); + if (index === -1) { + return; + } + hooks.splice(index, 1); + this.hooks.set(hookName, hooks); + } + triggerHook(hookName, ...args) { + const hooks = this.hooks.get(hookName) || []; + hooks.forEach((callback) => { + callback(...args); + }); + } +} + +class BackendResponse { + constructor(response) { + this.response = response; } - getModifiedModels() { - return Array.from(__classPrivateFieldGet(this, _UnsyncedInputContainer_mappedFields, "f").keys()); + async getBody() { + if (!this.body) { + this.body = await this.response.text(); + } + return this.body; } } -_UnsyncedInputContainer_mappedFields = new WeakMap(), _UnsyncedInputContainer_unmappedFields = new WeakMap(); -var _instances, _startPendingRequest, _makeRequest, _processRerender, _clearRequestDebounceTimeout; -const DEFAULT_DEBOUNCE = 150; -class default_1 extends Controller { - constructor() { - super(...arguments); - _instances.add(this); +class ChildComponentWrapper { + constructor(component, modelBindings) { + this.component = component; + this.modelBindings = modelBindings; + } +} +class Component { + constructor(element, props, data, fingerprint, id, backend, elementDriver) { + this.defaultDebounce = 150; this.pendingActions = []; - this.isRerenderRequested = false; + this.isRequestPending = false; this.requestDebounceTimeout = null; - this.pollingIntervals = []; - this.isConnected = false; - this.originalDataJSON = '{}'; - this.mutationObserver = null; - this.childComponentControllers = []; - this.pendingActionTriggerModelElement = null; - } - 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(); - this.synchronizeValueOfModelFields(); + this.children = new Map(); + this.parent = null; + this.element = element; + this.backend = backend; + this.elementDriver = elementDriver; + this.id = id; + this.fingerprint = fingerprint; + this.valueStore = new ValueStore(props, data); + this.unsyncedInputsTracker = new UnsyncedInputsTracker(this, elementDriver); + this.hooks = new HookManager(); + this.resetPromise(); + this.onChildComponentModelUpdate = this.onChildComponentModelUpdate.bind(this); + } + addPlugin(plugin) { + plugin.attachToComponent(this); } connect() { - this.isConnected = true; - this._onLoadingFinish(); - 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 }); + this.hooks.triggerHook('connect', this); + this.unsyncedInputsTracker.activate(); } disconnect() { - this._stopAllPolling(); - __classPrivateFieldGet(this, _instances, "m", _clearRequestDebounceTimeout).call(this); - 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.hooks.triggerHook('disconnect', this); + this.clearRequestDebounceTimeout(); + this.unsyncedInputsTracker.deactivate(); + } + on(hookName, callback) { + this.hooks.register(hookName, callback); + } + off(hookName, callback) { + this.hooks.unregister(hookName, callback); + } + set(model, value, reRender = false, debounce = false) { + const promise = this.nextRequestPromise; + const modelName = normalizeModelName(model); + const isChanged = this.valueStore.set(modelName, value); + this.hooks.triggerHook('model:set', model, value, this); + this.unsyncedInputsTracker.markModelAsSynced(modelName); + if (reRender && isChanged) { + this.debouncedStartRequest(debounce); } - this.isConnected = false; + return promise; } - update(event) { - if (event.type === 'input' || event.type === 'change') { - throw new Error(`Since LiveComponents 2.3, you no longer need data-action="live#update" on form elements. Found on element: ${getElementAsTagText(event.target)}`); + getData(model) { + const modelName = normalizeModelName(model); + if (!this.valueStore.has(modelName)) { + throw new Error(`Invalid model "${model}".`); } - this._updateModelFromElement(event.target, null); + return this.valueStore.get(modelName); } - action(event) { - const rawAction = event.currentTarget.dataset.actionName; - const directives = parseDirectives(rawAction); - directives.forEach((directive) => { - this.pendingActions.push({ - name: directive.action, - args: directive.named - }); - let handled = false; - const validModifiers = new Map(); - validModifiers.set('prevent', () => { - event.preventDefault(); - }); - validModifiers.set('stop', () => { - event.stopPropagation(); - }); - validModifiers.set('self', () => { - if (event.target !== event.currentTarget) { - return; - } - }); - validModifiers.set('debounce', (modifier) => { - const length = modifier.value ? parseInt(modifier.value) : this.getDefaultDebounce(); - __classPrivateFieldGet(this, _instances, "m", _clearRequestDebounceTimeout).call(this); - this.requestDebounceTimeout = window.setTimeout(() => { - this.requestDebounceTimeout = null; - __classPrivateFieldGet(this, _instances, "m", _startPendingRequest).call(this); - }, length); - handled = true; - }); - directive.modifiers.forEach((modifier) => { - var _a; - if (validModifiers.has(modifier.name)) { - const callable = (_a = validModifiers.get(modifier.name)) !== null && _a !== void 0 ? _a : (() => { }); - callable(modifier); - return; - } - console.warn(`Unknown modifier ${modifier.name} in action "${rawAction}". Available modifiers are: ${Array.from(validModifiers.keys()).join(', ')}.`); - }); - if (!handled) { - if (getModelDirectiveFromElement(event.currentTarget, false)) { - this.pendingActionTriggerModelElement = event.currentTarget; - __classPrivateFieldGet(this, _instances, "m", _clearRequestDebounceTimeout).call(this); - window.setTimeout(() => { - this.pendingActionTriggerModelElement = null; - __classPrivateFieldGet(this, _instances, "m", _startPendingRequest).call(this); - }, 10); - return; - } - __classPrivateFieldGet(this, _instances, "m", _startPendingRequest).call(this); - } + action(name, args, debounce = false) { + const promise = this.nextRequestPromise; + this.pendingActions.push({ + name, + args }); + this.debouncedStartRequest(debounce); + return promise; } - $render() { - this.isRerenderRequested = true; - __classPrivateFieldGet(this, _instances, "m", _startPendingRequest).call(this); + render() { + const promise = this.nextRequestPromise; + this.tryStartingRequest(); + return promise; } - _updateModelFromElement(element, eventName) { - if (!elementBelongsToThisController(element, this)) { - return; + getUnsyncedModels() { + return this.unsyncedInputsTracker.getModifiedModels(); + } + addChild(child, modelBindings = []) { + if (!child.id) { + throw new Error('Children components must have an id.'); } - if (!(element instanceof HTMLElement)) { - throw new Error('Could not update model for non HTMLElement'); + this.children.set(child.id, new ChildComponentWrapper(child, modelBindings)); + child.parent = this; + child.on('model:set', this.onChildComponentModelUpdate); + } + removeChild(child) { + if (!child.id) { + throw new Error('Children components must have an id.'); } - const modelDirective = getModelDirectiveFromElement(element, false); - if (eventName === 'input') { - const modelName = modelDirective ? modelDirective.action : null; - this.unsyncedInputs.add(element, modelName); + this.children.delete(child.id); + child.parent = null; + child.off('model:set', this.onChildComponentModelUpdate); + } + getParent() { + return this.parent; + } + getChildren() { + const children = new Map(); + this.children.forEach((childComponent, id) => { + children.set(id, childComponent.component); + }); + return children; + } + updateFromNewElement(toEl) { + const props = this.elementDriver.getComponentProps(toEl); + if (props === null) { + return false; } - if (!modelDirective) { - return; + const isChanged = this.valueStore.reinitializeProps(props); + const fingerprint = toEl.dataset.liveFingerprintValue; + if (fingerprint !== undefined) { + this.fingerprint = fingerprint; } - let shouldRender = true; - let targetEventName = 'input'; - let debounce = null; - modelDirective.modifiers.forEach((modifier) => { - switch (modifier.name) { - case 'on': - if (!modifier.value) { - throw new Error(`The "on" modifier in ${modelDirective.getString()} requires a value - e.g. on(change).`); - } - if (!['input', 'change'].includes(modifier.value)) { - throw new Error(`The "on" modifier in ${modelDirective.getString()} only accepts the arguments "input" or "change".`); - } - targetEventName = modifier.value; - break; - case 'norender': - shouldRender = false; - break; - case 'debounce': - debounce = modifier.value ? parseInt(modifier.value) : this.getDefaultDebounce(); - break; - default: - console.warn(`Unknown modifier "${modifier.name}" in data-model="${modelDirective.getString()}".`); + if (isChanged) { + this.render(); + } + return false; + } + onChildComponentModelUpdate(modelName, value, childComponent) { + if (!childComponent.id) { + throw new Error('Missing id'); + } + const childWrapper = this.children.get(childComponent.id); + if (!childWrapper) { + throw new Error('Missing child'); + } + childWrapper.modelBindings.forEach((modelBinding) => { + const childModelName = modelBinding.innerModelName || 'value'; + if (childModelName !== modelName) { + return; } + this.set(modelBinding.modelName, value, modelBinding.shouldRender, modelBinding.debounce); }); - if (this.pendingActionTriggerModelElement === element) { - shouldRender = false; + } + tryStartingRequest() { + if (!this.backendRequest) { + this.performRequest(); + return; } - if (eventName && targetEventName !== eventName) { + this.isRequestPending = true; + } + performRequest() { + const thisPromiseResolve = this.nextRequestPromiseResolve; + this.resetPromise(); + this.backendRequest = this.backend.makeRequest(this.valueStore.all(), this.pendingActions, this.valueStore.updatedModels, this.getChildrenFingerprints()); + 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 backendResponse = new BackendResponse(response); + thisPromiseResolve(backendResponse); + const html = await backendResponse.getBody(); + if (backendResponse.response.headers.get('Content-Type') !== 'application/vnd.live-component+html') { + this.renderError(html); + return response; + } + this.processRerender(html, backendResponse); + this.backendRequest = null; + if (this.isRequestPending) { + this.isRequestPending = false; + this.performRequest(); + } + return response; + }); + } + processRerender(html, backendResponse) { + const controls = { shouldRender: true }; + this.hooks.triggerHook('render:started', html, backendResponse, controls); + if (!controls.shouldRender) { return; } - if (null === debounce) { - if (targetEventName === 'input') { - debounce = this.getDefaultDebounce(); + if (backendResponse.response.headers.get('Location')) { + if (typeof Turbo !== 'undefined') { + Turbo.visit(backendResponse.response.headers.get('Location')); } else { - debounce = 0; + window.location.href = backendResponse.response.headers.get('Location') || ''; } + return; } - const finalValue = getValueFromElement(element, this.valueStore); - this.$updateModel(modelDirective.action, finalValue, shouldRender, element.hasAttribute('name') ? element.getAttribute('name') : null, { - debounce + this.hooks.triggerHook('loading.state:finished', this.element); + const modifiedModelValues = {}; + this.valueStore.updatedModels.forEach((modelName) => { + modifiedModelValues[modelName] = this.valueStore.get(modelName); }); + const newElement = htmlToElement(html); + this.hooks.triggerHook('loading.state:finished', newElement); + this.valueStore.reinitializeData(this.elementDriver.getComponentData(newElement)); + executeMorphdom(this.element, newElement, this.unsyncedInputsTracker.getUnsyncedInputs(), (element) => getValueFromElement(element, this.valueStore), Array.from(this.getChildren().values()), this.elementDriver.findChildComponentElement, this.elementDriver.getKeyFromElement); + Object.keys(modifiedModelValues).forEach((modelName) => { + this.valueStore.set(modelName, modifiedModelValues[modelName]); + }); + this.hooks.triggerHook('render:finished', this); } - $updateModel(model, value, shouldRender = true, extraModelName = null, options = {}) { - const modelName = normalizeModelName(model); - const normalizedExtraModelName = extraModelName ? normalizeModelName(extraModelName) : null; - if (this.valueStore.has('validatedFields')) { - const validatedFields = [...this.valueStore.get('validatedFields')]; - if (validatedFields.indexOf(modelName) === -1) { - validatedFields.push(modelName); - } - this.valueStore.set('validatedFields', validatedFields); + calculateDebounce(debounce) { + if (debounce === true) { + return this.defaultDebounce; + } + if (debounce === false) { + return 0; + } + return debounce; + } + clearRequestDebounceTimeout() { + if (this.requestDebounceTimeout) { + clearTimeout(this.requestDebounceTimeout); + this.requestDebounceTimeout = null; + } + } + debouncedStartRequest(debounce) { + this.clearRequestDebounceTimeout(); + this.requestDebounceTimeout = window.setTimeout(() => { + this.render(); + }, this.calculateDebounce(debounce)); + } + renderError(html) { + 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(); } - if (options.dispatch !== false) { - this._dispatchEvent('live:update-model', { - modelName, - extraModelName: normalizedExtraModelName, - value + const closeModal = (modal) => { + if (modal) { + modal.outerHTML = ''; + } + document.body.style.overflow = 'visible'; + }; + modal.addEventListener('click', () => closeModal(modal)); + modal.setAttribute('tabindex', '0'); + modal.addEventListener('keydown', e => { + if (e.key === 'Escape') { + closeModal(modal); + } + }); + modal.focus(); + } + getChildrenFingerprints() { + const fingerprints = {}; + this.children.forEach((childComponent) => { + const child = childComponent.component; + if (!child.id) { + throw new Error('missing id'); + } + fingerprints[child.id] = child.fingerprint; + }); + return fingerprints; + } + resetPromise() { + this.nextRequestPromise = new Promise((resolve) => { + this.nextRequestPromiseResolve = resolve; + }); + } +} +function proxifyComponent(component) { + return new Proxy(component, { + get(component, prop) { + if (prop in component || typeof prop !== 'string') { + if (typeof component[prop] === 'function') { + const callable = component[prop]; + return (...args) => { + return callable.apply(component, args); + }; + } + return Reflect.get(component, prop); + } + if (component.valueStore.has(prop)) { + return component.getData(prop); + } + return (args) => { + return component.action.apply(component, [prop, args]); + }; + }, + set(target, property, value) { + if (property in target) { + target[property] = value; + return true; + } + target.set(property, value); + return true; + }, + }); +} + +class BackendRequest { + constructor(promise, actions, updateModels) { + this.isResolved = false; + this.promise = promise; + this.promise.then((response) => { + this.isResolved = true; + return response; + }); + this.actions = actions; + this.updatedModels = updateModels; + } + containsOneOfActions(targetedActions) { + return (this.actions.filter(action => targetedActions.includes(action))).length > 0; + } + areAnyModelsUpdated(targetedModels) { + return (this.updatedModels.filter(model => targetedModels.includes(model))).length > 0; + } +} + +class Backend { + constructor(url, csrfToken = null) { + this.url = url; + this.csrfToken = csrfToken; + } + makeRequest(data, actions, updatedModels, childrenFingerprints) { + const splitUrl = this.url.split('?'); + let [url] = splitUrl; + const [, queryString] = splitUrl; + const params = new URLSearchParams(queryString || ''); + const fetchOptions = {}; + fetchOptions.headers = { + '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)); + if (hasFingerprints) { + params.set('childrenFingerprints', JSON.stringify(childrenFingerprints)); + } + updatedModels.forEach((model) => { + params.append('updatedModels[]', model); }); + fetchOptions.method = 'GET'; } - this.valueStore.set(modelName, value); - this.unsyncedInputs.markModelAsSynced(modelName); - if (shouldRender) { - let debounce = this.getDefaultDebounce(); - if (options.debounce !== undefined && options.debounce !== null) { - debounce = options.debounce; + else { + fetchOptions.method = 'POST'; + fetchOptions.headers['Content-Type'] = 'application/json'; + const requestData = { data }; + if (hasUpdatedModels) { + requestData.updatedModels = updatedModels; + } + if (hasFingerprints) { + requestData.childrenFingerprints = childrenFingerprints; + } + if (actions.length > 0) { + if (this.csrfToken) { + fetchOptions.headers['X-CSRF-TOKEN'] = this.csrfToken; + } + if (actions.length === 1) { + requestData.args = actions[0].args; + url += `/${encodeURIComponent(actions[0].name)}`; + } + else { + url += '/_batch'; + requestData.actions = actions; + } } - __classPrivateFieldGet(this, _instances, "m", _clearRequestDebounceTimeout).call(this); - this.requestDebounceTimeout = window.setTimeout(() => { - this.requestDebounceTimeout = null; - this.isRerenderRequested = true; - __classPrivateFieldGet(this, _instances, "m", _startPendingRequest).call(this); - }, debounce); + 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); + } + willDataFitInUrl(dataJson, params, childrenFingerprintsJson) { + const urlEncodedJsonData = new URLSearchParams(dataJson + childrenFingerprintsJson).toString(); + return (urlEncodedJsonData + params.toString()).length < 1500; + } +} + +class StandardElementDriver { + getModelName(element) { + const modelDirective = getModelDirectiveFromElement(element, false); + if (!modelDirective) { + return null; + } + return modelDirective.action; + } + getComponentData(rootElement) { + if (!rootElement.dataset.liveDataValue) { + return null; + } + return JSON.parse(rootElement.dataset.liveDataValue); + } + getComponentProps(rootElement) { + if (!rootElement.dataset.livePropsValue) { + return null; } + return JSON.parse(rootElement.dataset.livePropsValue); + } + findChildComponentElement(id, element) { + return element.querySelector(`[data-live-id=${id}]`); } - _onLoadingStart() { - this._handleLoadingToggle(true); + getKeyFromElement(element) { + return element.dataset.liveId || null; } - _onLoadingFinish(targetElement = null) { - this._handleLoadingToggle(false, targetElement); +} + +class LoadingPlugin { + attachToComponent(component) { + component.on('loading.state:started', (element, request) => { + this.startLoading(element, request); + }); + component.on('loading.state:finished', (element) => { + this.finishLoading(element); + }); + this.finishLoading(component.element); } - _handleLoadingToggle(isLoading, targetElement = null) { + startLoading(targetElement, backendRequest) { + this.handleLoadingToggle(true, targetElement, backendRequest); + } + finishLoading(targetElement) { + this.handleLoadingToggle(false, targetElement, null); + } + handleLoadingToggle(isLoading, targetElement, backendRequest) { if (isLoading) { - this._addAttributes(this.element, ['busy']); + this.addAttributes(targetElement, ['busy']); } else { - this._removeAttributes(this.element, ['busy']); + this.removeAttributes(targetElement, ['busy']); } - this._getLoadingDirectives(targetElement).forEach(({ element, directives }) => { + this.getLoadingDirectives(targetElement).forEach(({ element, directives }) => { if (isLoading) { - this._addAttributes(element, ['data-live-is-loading']); + this.addAttributes(element, ['data-live-is-loading']); } else { - this._removeAttributes(element, ['data-live-is-loading']); + this.removeAttributes(element, ['data-live-is-loading']); } directives.forEach((directive) => { - this._handleLoadingDirective(element, isLoading, directive); + this.handleLoadingDirective(element, isLoading, directive, backendRequest); }); }); } - _handleLoadingDirective(element, isLoading, directive) { + handleLoadingDirective(element, isLoading, directive, backendRequest) { const finalAction = parseLoadingAction(directive.action, isLoading); const targetedActions = []; const targetedModels = []; @@ -1495,40 +1857,40 @@ class default_1 extends Controller { } throw new Error(`Unknown modifier "${modifier.name}" used in data-loading="${directive.getString()}". Available modifiers are: ${Array.from(validModifiers.keys()).join(', ')}.`); }); - if (isLoading && targetedActions.length > 0 && this.backendRequest && !this.backendRequest.containsOneOfActions(targetedActions)) { + if (isLoading && targetedActions.length > 0 && backendRequest && !backendRequest.containsOneOfActions(targetedActions)) { return; } - if (isLoading && targetedModels.length > 0 && !this.valueStore.areAnyModelsUpdated(targetedModels)) { + if (isLoading && targetedModels.length > 0 && backendRequest && !backendRequest.areAnyModelsUpdated(targetedModels)) { return; } let loadingDirective; switch (finalAction) { case 'show': loadingDirective = () => { - this._showElement(element); + this.showElement(element); }; break; case 'hide': - loadingDirective = () => this._hideElement(element); + loadingDirective = () => this.hideElement(element); break; case 'addClass': - loadingDirective = () => this._addClass(element, directive.args); + loadingDirective = () => this.addClass(element, directive.args); break; case 'removeClass': - loadingDirective = () => this._removeClass(element, directive.args); + loadingDirective = () => this.removeClass(element, directive.args); break; case 'addAttribute': - loadingDirective = () => this._addAttributes(element, directive.args); + loadingDirective = () => this.addAttributes(element, directive.args); break; case 'removeAttribute': - loadingDirective = () => this._removeAttributes(element, directive.args); + loadingDirective = () => this.removeAttributes(element, directive.args); break; default: throw new Error(`Unknown data-loading action "${finalAction}"`); } if (delay) { window.setTimeout(() => { - if (this.isRequestActive()) { + if (backendRequest && !backendRequest.isResolved) { loadingDirective(); } }, delay); @@ -1536,9 +1898,8 @@ class default_1 extends Controller { } loadingDirective(); } - _getLoadingDirectives(targetElement = null) { + getLoadingDirectives(element) { const loadingDirectives = []; - const element = targetElement || this.element; element.querySelectorAll('[data-loading]').forEach((element => { if (!(element instanceof HTMLElement) && !(element instanceof SVGElement)) { throw new Error('Invalid Element Type'); @@ -1551,149 +1912,128 @@ class default_1 extends Controller { })); return loadingDirectives; } - _showElement(element) { + showElement(element) { element.style.display = 'inline-block'; } - _hideElement(element) { + hideElement(element) { element.style.display = 'none'; } - _addClass(element, classes) { + addClass(element, classes) { element.classList.add(...combineSpacedArray(classes)); } - _removeClass(element, classes) { + removeClass(element, classes) { element.classList.remove(...combineSpacedArray(classes)); if (element.classList.length === 0) { - this._removeAttributes(element, ['class']); + this.removeAttributes(element, ['class']); } } - _addAttributes(element, attributes) { + addAttributes(element, attributes) { attributes.forEach((attribute) => { element.setAttribute(attribute, ''); }); } - _removeAttributes(element, attributes) { + removeAttributes(element, attributes) { attributes.forEach((attribute) => { element.removeAttribute(attribute); }); } - _willDataFitInUrl(dataJson, params) { - const urlEncodedJsonData = new URLSearchParams(dataJson).toString(); - return (urlEncodedJsonData + params.toString()).length < 1500; +} +const parseLoadingAction = function (action, isLoading) { + 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'; } - _executeMorphdom(newHtml, modifiedElements) { - const newElement = htmlToElement(newHtml); - this._onLoadingFinish(newElement); - morphdom(this.element, newElement, { - getNodeKey: (node) => { - if (!(node instanceof HTMLElement)) { - return; - } - return node.dataset.liveId; - }, - onBeforeElUpdated: (fromEl, toEl) => { - if (!(fromEl instanceof HTMLElement) || !(toEl instanceof HTMLElement)) { - return false; - } - if (modifiedElements.includes(fromEl)) { - setValueOnElement(toEl, getValueFromElement(fromEl, this.valueStore)); - } - if (fromEl.isEqualNode(toEl)) { - const normalizedFromEl = cloneHTMLElement(fromEl); - normalizeAttributesForComparison(normalizedFromEl); - const normalizedToEl = cloneHTMLElement(toEl); - normalizeAttributesForComparison(normalizedToEl); - if (normalizedFromEl.isEqualNode(normalizedToEl)) { - return false; - } - } - 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; - } - return !fromEl.hasAttribute('data-live-ignore'); - }, - onBeforeNodeDiscarded(node) { - if (!(node instanceof HTMLElement)) { - return true; - } - return !node.hasAttribute('data-live-ignore'); - } + throw new Error(`Unknown data-loading action "${action}"`); +}; + +class ValidatedFieldsPlugin { + attachToComponent(component) { + component.on('model:set', (modelName) => { + this.handleModelSet(modelName, component.valueStore); }); - this._exposeOriginalData(); } - handleConnectedControllerEvent(event) { - if (event.target === this.element) { - return; + handleModelSet(modelName, valueStore) { + if (valueStore.has('validatedFields')) { + const validatedFields = [...valueStore.get('validatedFields')]; + if (!validatedFields.includes(modelName)) { + validatedFields.push(modelName); + } + valueStore.set('validatedFields', validatedFields); } - this.childComponentControllers.push(event.detail.controller); - event.detail.controller.element.addEventListener('live:disconnect', this.handleDisconnectedControllerEvent); } - handleDisconnectedControllerEvent(event) { - if (event.target === this.element) { - return; - } - const index = this.childComponentControllers.indexOf(event.detail.controller); - if (index > -1) { - this.childComponentControllers.splice(index, 1); - } +} + +class PageUnloadingPlugin { + constructor() { + this.isConnected = false; } - handleUpdateModelEvent(event) { - if (event.target === this.element) { - return; - } - this._handleChildComponentUpdateModel(event); + attachToComponent(component) { + component.on('render:started', (html, response, controls) => { + if (!this.isConnected) { + controls.shouldRender = false; + } + }); + component.on('connect', () => { + this.isConnected = true; + }); + component.on('disconnect', () => { + this.isConnected = false; + }); } - handleInputEvent(event) { - const target = event.target; - if (!target) { - return; - } - this._updateModelFromElement(target, 'input'); +} + +class PollingDirector { + constructor(component) { + this.isPollingActive = true; + this.pollingIntervals = []; + this.component = component; } - handleChangeEvent(event) { - const target = event.target; - if (!target) { - return; + addPoll(actionName, duration) { + this.polls.push({ actionName, duration }); + if (this.isPollingActive) { + this.initiatePoll(actionName, duration); } - this._updateModelFromElement(target, 'change'); } - _initiatePolling() { - this._stopAllPolling(); - if (this.element.dataset.poll === undefined) { + startAllPolling() { + if (this.isPollingActive) { return; } - const rawPollConfig = this.element.dataset.poll; - const directives = parseDirectives(rawPollConfig || '$render'); - directives.forEach((directive) => { - let duration = 2000; - directive.modifiers.forEach((modifier) => { - switch (modifier.name) { - case 'delay': - if (modifier.value) { - duration = parseInt(modifier.value); - } - break; - default: - console.warn(`Unknown modifier "${modifier.name}" in data-poll "${rawPollConfig}".`); - } - }); - this._startPoll(directive.action, duration); + this.isPollingActive = true; + this.polls.forEach(({ actionName, duration }) => { + this.initiatePoll(actionName, duration); + }); + } + stopAllPolling() { + this.isPollingActive = false; + this.pollingIntervals.forEach((interval) => { + clearInterval(interval); }); } - _startPoll(actionName, duration) { + clearPolling() { + this.stopAllPolling(); + this.polls = []; + this.startAllPolling(); + } + initiatePoll(actionName, duration) { let callback; - if (actionName.charAt(0) === '$') { + if (actionName === '$render') { callback = () => { - this[actionName](); + this.component.render(); }; } else { callback = () => { - this.pendingActions.push({ name: actionName, args: {} }); - __classPrivateFieldGet(this, _instances, "m", _startPendingRequest).call(this); + this.component.action(actionName, {}, 0); }; } const timer = setInterval(() => { @@ -1701,158 +2041,63 @@ class default_1 extends Controller { }, duration); this.pollingIntervals.push(timer); } - _dispatchEvent(name, payload = null, canBubble = true, cancelable = false) { - return this.element.dispatchEvent(new CustomEvent(name, { - bubbles: canBubble, - cancelable, - detail: payload - })); - } - _handleChildComponentUpdateModel(event) { - 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"`); - } - if (from !== mainModelName) { - return; - } - potentialModelNames.push({ name: directive.action, required: true }); - }); - } - potentialModelNames.reverse(); - let foundModelName = 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}"`); - } +} + +class PollingPlugin { + attachToComponent(component) { + this.element = component.element; + this.pollingDirector = new PollingDirector(component); + this.initializePolling(); + component.on('connect', () => { + this.pollingDirector.startAllPolling(); }); - if (!foundModelName) { - return; - } - this.$updateModel(foundModelName, event.detail.value, false, null, { - dispatch: false + component.on('disconnect', () => { + this.pollingDirector.stopAllPolling(); + }); + component.on('render:finished', () => { + this.initializePolling(); }); } - _shouldChildLiveElementUpdate(fromEl, toEl) { - 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); + addPoll(actionName, duration) { + this.pollingDirector.addPoll(actionName, duration); } - _exposeOriginalData() { - if (!(this.element instanceof HTMLElement)) { - throw new Error('Invalid Element Type'); - } - this.element.dataset.originalData = this.originalDataJSON; + clearPolling() { + this.pollingDirector.clearPolling(); } - _startAttributesMutationObserver() { - if (!(this.element instanceof HTMLElement)) { - throw new Error('Invalid Element Type'); + initializePolling() { + this.clearPolling(); + if (this.element.dataset.poll === undefined) { + return; } - const element = 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(); + const rawPollConfig = this.element.dataset.poll; + const directives = parseDirectives(rawPollConfig || '$render'); + directives.forEach((directive) => { + let duration = 2000; + directive.modifiers.forEach((modifier) => { + switch (modifier.name) { + case 'delay': + if (modifier.value) { + duration = parseInt(modifier.value); + } + break; + default: + console.warn(`Unknown modifier "${modifier.name}" in data-poll "${rawPollConfig}".`); } }); - }); - this.mutationObserver.observe(this.element, { - attributes: true - }); - } - getDefaultDebounce() { - return this.hasDebounceValue ? this.debounceValue : DEFAULT_DEBOUNCE; - } - _stopAllPolling() { - this.pollingIntervals.forEach((interval) => { - clearInterval(interval); + this.addPoll(directive.action, duration); }); } - async renderError(html) { - 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) => { - if (modal) { - modal.outerHTML = ''; - } - document.body.style.overflow = 'visible'; - }; - modal.addEventListener('click', () => closeModal(modal)); - modal.setAttribute('tabindex', '0'); - modal.addEventListener('keydown', e => { - if (e.key === 'Escape') { - closeModal(modal); - } +} + +class SetValueOntoModelFieldsPlugin { + attachToComponent(component) { + this.synchronizeValueOfModelFields(component); + component.on('render:finished', () => { + this.synchronizeValueOfModelFields(component); }); - modal.focus(); } - synchronizeValueOfModelFields() { - this.element.querySelectorAll('[data-model]').forEach((element) => { + synchronizeValueOfModelFields(component) { + component.element.querySelectorAll('[data-model]').forEach((element) => { if (!(element instanceof HTMLElement)) { throw new Error('Invalid element using data-model.'); } @@ -1864,144 +2109,233 @@ class default_1 extends Controller { return; } const modelName = modelDirective.action; - if (this.unsyncedInputs.getModifiedModels().includes(modelName)) { + if (component.getUnsyncedModels().includes(modelName)) { return; } - if (this.valueStore.has(modelName)) { - setValueOnElement(element, this.valueStore.get(modelName)); + if (component.valueStore.has(modelName)) { + setValueOnElement(element, component.valueStore.get(modelName)); } if (element instanceof HTMLSelectElement && !element.multiple) { - this.valueStore.set(modelName, getValueFromElement(element, this.valueStore)); + component.valueStore.set(modelName, getValueFromElement(element, component.valueStore)); } }); } - isRequestActive() { - return !!this.backendRequest; - } } -_instances = new WeakSet(), _startPendingRequest = function _startPendingRequest() { - if (!this.backendRequest && (this.pendingActions.length > 0 || this.isRerenderRequested)) { - __classPrivateFieldGet(this, _instances, "m", _makeRequest).call(this); - } -}, _makeRequest = function _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; - __classPrivateFieldGet(this, _instances, "m", _clearRequestDebounceTimeout).call(this); - const fetchOptions = {}; - fetchOptions.headers = { - 'Accept': 'application/vnd.live-component+html', + +function getModelBinding (modelDirective) { + let shouldRender = true; + let targetEventName = null; + let debounce = false; + modelDirective.modifiers.forEach((modifier) => { + switch (modifier.name) { + case 'on': + if (!modifier.value) { + throw new Error(`The "on" modifier in ${modelDirective.getString()} requires a value - e.g. on(change).`); + } + if (!['input', 'change'].includes(modifier.value)) { + throw new Error(`The "on" modifier in ${modelDirective.getString()} only accepts the arguments "input" or "change".`); + } + targetEventName = modifier.value; + break; + case 'norender': + shouldRender = false; + break; + case 'debounce': + debounce = modifier.value ? parseInt(modifier.value) : true; + break; + default: + throw new Error(`Unknown modifier "${modifier.name}" in data-model="${modelDirective.getString()}".`); + } + }); + const [modelName, innerModelName] = modelDirective.action.split(':'); + return { + modelName, + innerModelName: innerModelName || null, + shouldRender, + debounce, + targetEventName }; - 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); +} + +class default_1 extends Controller { + constructor() { + super(...arguments); + this.pendingActionTriggerModelElement = null; + this.elementEventListeners = [ + { event: 'input', callback: (event) => this.handleInputEvent(event) }, + { event: 'change', callback: (event) => this.handleChangeEvent(event) }, + { event: 'live:connect', callback: (event) => this.handleConnectedControllerEvent(event) }, + ]; + } + initialize() { + this.handleDisconnectedChildControllerEvent = this.handleDisconnectedChildControllerEvent.bind(this); + const id = this.element.dataset.liveId || null; + this.component = new Component(this.element, this.propsValue, this.dataValue, this.fingerprintValue, id, new Backend(this.urlValue, this.csrfValue), new StandardElementDriver()); + this.proxiedComponent = proxifyComponent(this.component); + this.element.__component = this.proxiedComponent; + if (this.hasDebounceValue) { + this.component.defaultDebounce = this.debounceValue; + } + const plugins = [ + new LoadingPlugin(), + new ValidatedFieldsPlugin(), + new PageUnloadingPlugin(), + new PollingPlugin(), + new SetValueOntoModelFieldsPlugin(), + ]; + plugins.forEach((plugin) => { + this.component.addPlugin(plugin); + }); + } + connect() { + this.component.connect(); + this.elementEventListeners.forEach(({ event, callback }) => { + this.component.element.addEventListener(event, callback); + }); + this._dispatchEvent('live:connect'); + } + disconnect() { + this.component.disconnect(); + this.elementEventListeners.forEach(({ event, callback }) => { + this.component.element.removeEventListener(event, callback); }); - fetchOptions.method = 'GET'; + this._dispatchEvent('live:disconnect'); + } + update(event) { + if (event.type === 'input' || event.type === 'change') { + 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.updateModelFromElementEvent(event.target, null); } - else { - fetchOptions.method = 'POST'; - fetchOptions.headers['Content-Type'] = 'application/json'; - const requestData = { data: this.valueStore.all() }; - requestData.updatedModels = updatedModels; - if (actions.length > 0) { - if (this.csrfValue) { - fetchOptions.headers['X-CSRF-TOKEN'] = this.csrfValue; + action(event) { + const rawAction = event.currentTarget.dataset.actionName; + const directives = parseDirectives(rawAction); + let debounce = false; + directives.forEach((directive) => { + const validModifiers = new Map(); + validModifiers.set('prevent', () => { + event.preventDefault(); + }); + validModifiers.set('stop', () => { + event.stopPropagation(); + }); + validModifiers.set('self', () => { + if (event.target !== event.currentTarget) { + return; + } + }); + validModifiers.set('debounce', (modifier) => { + debounce = modifier.value ? parseInt(modifier.value) : true; + }); + directive.modifiers.forEach((modifier) => { + var _a; + if (validModifiers.has(modifier.name)) { + const callable = (_a = validModifiers.get(modifier.name)) !== null && _a !== void 0 ? _a : (() => { }); + callable(modifier); + return; + } + console.warn(`Unknown modifier ${modifier.name} in action "${rawAction}". Available modifiers are: ${Array.from(validModifiers.keys()).join(', ')}.`); + }); + this.component.action(directive.action, directive.named, debounce); + if (getModelDirectiveFromElement(event.currentTarget, false)) { + this.pendingActionTriggerModelElement = event.currentTarget; } - if (actions.length === 1) { - requestData.args = actions[0].args; - url += `/${encodeURIComponent(actions[0].name)}`; + }); + } + $render() { + this.component.render(); + } + $updateModel(model, value, shouldRender = true, debounce = true) { + this.component.set(model, value, shouldRender, debounce); + } + handleInputEvent(event) { + const target = event.target; + if (!target) { + return; + } + this.updateModelFromElementEvent(target, 'input'); + } + handleChangeEvent(event) { + const target = event.target; + if (!target) { + return; + } + this.updateModelFromElementEvent(target, 'change'); + } + updateModelFromElementEvent(element, eventName) { + if (!elementBelongsToThisComponent(element, this.component)) { + return; + } + if (!(element instanceof HTMLElement)) { + throw new Error('Could not update model for non HTMLElement'); + } + const modelDirective = getModelDirectiveFromElement(element, false); + if (!modelDirective) { + return; + } + const modelBinding = getModelBinding(modelDirective); + if (!modelBinding.targetEventName) { + modelBinding.targetEventName = 'input'; + } + if (this.pendingActionTriggerModelElement === element) { + modelBinding.shouldRender = false; + } + if (eventName === 'change' && modelBinding.targetEventName === 'input') { + modelBinding.targetEventName = 'change'; + } + if (eventName && modelBinding.targetEventName !== eventName) { + return; + } + if (false === modelBinding.debounce) { + if (modelBinding.targetEventName === 'input') { + modelBinding.debounce = true; } else { - url += '/_batch'; - requestData.actions = actions; + modelBinding.debounce = 0; } } - fetchOptions.body = JSON.stringify(requestData); + const finalValue = getValueFromElement(element, this.component.valueStore); + this.component.set(modelBinding.modelName, finalValue, modelBinding.shouldRender, modelBinding.debounce); } - const paramsString = params.toString(); - const thisPromise = fetch(`${url}${paramsString.length > 0 ? `?${paramsString}` : ''}`, fetchOptions); - this.backendRequest = new BackendRequest(thisPromise, actions.map(action => action.name)); - this._onLoadingStart(); - this.valueStore.updatedModels = []; - thisPromise.then(async (response) => { - const html = await response.text(); - if (response.headers.get('Content-Type') !== 'application/vnd.live-component+html') { - this.renderError(html); + handleConnectedControllerEvent(event) { + if (event.target === this.element) { return; } - __classPrivateFieldGet(this, _instances, "m", _processRerender).call(this, html, response); - this.backendRequest = null; - __classPrivateFieldGet(this, _instances, "m", _startPendingRequest).call(this); - }); -}, _processRerender = function _processRerender(html, response) { - if (!this.isConnected) { - return; - } - if (response.headers.get('Location')) { - if (typeof Turbo !== 'undefined') { - Turbo.visit(response.headers.get('Location')); - } - else { - window.location.href = response.headers.get('Location') || ''; + const childController = event.detail.controller; + if (childController.component.getParent()) { + return; } - return; + const modelDirectives = getAllModelDirectiveFromElements(childController.element); + const modelBindings = modelDirectives.map(getModelBinding); + this.component.addChild(childController.component, modelBindings); + childController.element.addEventListener('live:disconnect', this.handleDisconnectedChildControllerEvent); } - this._onLoadingFinish(); - if (!this._dispatchEvent('live:render', html, true, true)) { - return; + handleDisconnectedChildControllerEvent(event) { + const childController = event.detail.controller; + childController.element.removeEventListener('live:disconnect', this.handleDisconnectedChildControllerEvent); + if (childController.component.getParent() !== this.component) { + return; + } + this.component.removeChild(childController.component); } - const modifiedModelValues = {}; - this.valueStore.updatedModels.forEach((modelName) => { - modifiedModelValues[modelName] = this.valueStore.get(modelName); - }); - this._executeMorphdom(html, this.unsyncedInputs.all()); - Object.keys(modifiedModelValues).forEach((modelName) => { - this.valueStore.set(modelName, modifiedModelValues[modelName]); - }); - this.synchronizeValueOfModelFields(); -}, _clearRequestDebounceTimeout = function _clearRequestDebounceTimeout() { - if (this.requestDebounceTimeout) { - clearTimeout(this.requestDebounceTimeout); - this.requestDebounceTimeout = null; + _dispatchEvent(name, detail = {}, canBubble = true, cancelable = false) { + detail.controller = this; + detail.component = this.proxiedComponent; + return this.element.dispatchEvent(new CustomEvent(name, { + bubbles: canBubble, + cancelable, + detail + })); } -}; +} default_1.values = { url: String, data: Object, + props: Object, csrf: String, - debounce: Number, -}; -class BackendRequest { - constructor(promise, actions) { - this.promise = promise; - this.actions = actions; - } - containsOneOfActions(targetedActions) { - return (this.actions.filter(action => targetedActions.includes(action))).length > 0; - } -} -const parseLoadingAction = function (action, isLoading) { - 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}"`); + debounce: { type: Number, default: 150 }, + id: String, + fingerprint: String, }; export { default_1 as default }; 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..30ac7d8260a --- /dev/null +++ b/src/LiveComponent/assets/src/Backend.ts @@ -0,0 +1,89 @@ +import BackendRequest from './BackendRequest'; + +export interface BackendInterface { + makeRequest(data: any, actions: BackendAction[], updatedModels: string[], childrenFingerprints: any): BackendRequest; +} + +export interface BackendAction { + name: string, + args: Record +} + +export default class implements BackendInterface { + private url: string; + 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[], childrenFingerprints: any): 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', + }; + + 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)); + if (hasFingerprints) { + params.set('childrenFingerprints', JSON.stringify(childrenFingerprints)); + } + updatedModels.forEach((model) => { + params.append('updatedModels[]', model); + }); + fetchOptions.method = 'GET'; + } else { + fetchOptions.method = 'POST'; + fetchOptions.headers['Content-Type'] = 'application/json'; + const requestData: any = { data }; + if (hasUpdatedModels) { + requestData.updatedModels = updatedModels; + } + if (hasFingerprints) { + requestData.childrenFingerprints = childrenFingerprints; + } + + 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, 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/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/BackendResponse.ts b/src/LiveComponent/assets/src/BackendResponse.ts new file mode 100644 index 00000000000..034359325ae --- /dev/null +++ b/src/LiveComponent/assets/src/BackendResponse.ts @@ -0,0 +1,16 @@ +export default class { + response: Response + private body: string; + + constructor(response: Response) { + this.response = response; + } + + async getBody(): Promise { + if (!this.body) { + this.body = await this.response.text(); + } + + return this.body; + } +} diff --git a/src/LiveComponent/assets/src/Component/ElementDriver.ts b/src/LiveComponent/assets/src/Component/ElementDriver.ts new file mode 100644 index 00000000000..91d28e876b7 --- /dev/null +++ b/src/LiveComponent/assets/src/Component/ElementDriver.ts @@ -0,0 +1,60 @@ +import {getModelDirectiveFromElement} from '../dom_utils'; + +export interface ElementDriver { + getModelName(element: HTMLElement): string|null; + + /** + * Given the root element of a component, returns its "data". + * + * This is used during a re-render to get the fresh data from the server. + */ + getComponentData(rootElement: HTMLElement): any; + + getComponentProps(rootElement: HTMLElement): any; + + /** + * Given an HtmlElement and a child id, find the root element for that child. + */ + findChildComponentElement(id: string, element: HTMLElement): HTMLElement|null; + + /** + * Given an element, find the "key" that should be used to identify it; + */ + getKeyFromElement(element: HTMLElement): string|null; +} + +export class StandardElementDriver implements ElementDriver { + getModelName(element: HTMLElement): string|null { + const modelDirective = getModelDirectiveFromElement(element, false); + + if (!modelDirective) { + return null; + } + + return modelDirective.action; + } + + getComponentData(rootElement: HTMLElement): any { + if (!rootElement.dataset.liveDataValue) { + return null; + } + + return JSON.parse(rootElement.dataset.liveDataValue as string); + } + + getComponentProps(rootElement: HTMLElement): any { + if (!rootElement.dataset.livePropsValue) { + return null; + } + + return JSON.parse(rootElement.dataset.livePropsValue as string); + } + + findChildComponentElement(id: string, element: HTMLElement): HTMLElement|null { + return element.querySelector(`[data-live-id=${id}]`); + } + + getKeyFromElement(element: HTMLElement): string|null { + return element.dataset.liveId || null; + } +} diff --git a/src/LiveComponent/assets/src/Component/UnsyncedInputsTracker.ts b/src/LiveComponent/assets/src/Component/UnsyncedInputsTracker.ts new file mode 100644 index 00000000000..1c30e758417 --- /dev/null +++ b/src/LiveComponent/assets/src/Component/UnsyncedInputsTracker.ts @@ -0,0 +1,106 @@ +import {ElementDriver} from './ElementDriver'; +import {elementBelongsToThisComponent} from '../dom_utils'; +import Component from './index'; + +export default class { + private readonly component: Component; + private readonly modelElementResolver: ElementDriver; + /** 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(component: Component, modelElementResolver: ElementDriver) { + this.component = component; + this.modelElementResolver = modelElementResolver; + this.unsyncedInputs = new UnsyncedInputContainer(); + } + + activate(): void { + this.elementEventListeners.forEach(({event, callback}) => { + this.component.element.addEventListener(event, callback); + }); + } + + deactivate(): void { + this.elementEventListeners.forEach(({event, callback}) => { + this.component.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) { + if (!elementBelongsToThisComponent(element, this.component)) { + 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/Component/ValueStore.ts b/src/LiveComponent/assets/src/Component/ValueStore.ts new file mode 100644 index 00000000000..22ffad72820 --- /dev/null +++ b/src/LiveComponent/assets/src/Component/ValueStore.ts @@ -0,0 +1,87 @@ +import { getDeepData, setDeepData } from '../data_manipulation_utils'; +import { normalizeModelName } from '../string_utils'; + +export default class { + updatedModels: string[] = []; + private props: any = {}; + private data: any = {}; + + constructor(props: any, data: any) { + this.props = props; + this.data = data; + } + + /** + * 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 + * deeply (fetching the "firstName" sub-key from the "user" key). + */ + get(name: string): any { + const normalizedName = normalizeModelName(name); + + const result = getDeepData(this.data, normalizedName); + + if (result !== undefined) { + return result; + } + + return getDeepData(this.props, normalizedName); + } + + has(name: string): boolean { + return this.get(name) !== undefined; + } + + /** + * 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): boolean { + const normalizedName = normalizeModelName(name); + 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 { + return { ...this.props, ...this.data }; + } + + /** + * Set the data to a fresh set from the server. + * + * @param data + */ + reinitializeData(data: any): void { + this.updatedModels = []; + this.data = data; + } + + /** + * Set the props to a fresh set from the server. + * + * Props can only change as a result of a parent component re-rendering. + * + * Returns true if any of the props changed. + */ + reinitializeProps(props: any): boolean { + if (JSON.stringify(props) == JSON.stringify(this.props)) { + return false; + } + + this.props = props; + + return true; + } +} diff --git a/src/LiveComponent/assets/src/Component/index.ts b/src/LiveComponent/assets/src/Component/index.ts new file mode 100644 index 00000000000..795435fa412 --- /dev/null +++ b/src/LiveComponent/assets/src/Component/index.ts @@ -0,0 +1,523 @@ +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 { ElementDriver } from './ElementDriver'; +import HookManager from '../HookManager'; +import { PluginInterface } from './plugins/PluginInterface'; +import BackendResponse from '../BackendResponse'; +import { ModelBinding } from '../Directive/get_model_binding'; + +declare const Turbo: any; + +class ChildComponentWrapper { + component: Component; + modelBindings: ModelBinding[]; + + constructor(component: Component, modelBindings: ModelBinding[]) { + this.component = component; + this.modelBindings = modelBindings; + } +} + +export default class Component { + readonly element: HTMLElement; + private readonly backend: BackendInterface; + private readonly elementDriver: ElementDriver; + 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; + + + defaultDebounce = 150; + + 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; + private nextRequestPromise: Promise; + private nextRequestPromiseResolve: (response: BackendResponse) => any; + + private children: Map = new Map(); + private parent: Component|null = null; + + /** + * @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 elementDriver Class to get "model" name from any element. + */ + constructor(element: HTMLElement, props: any, data: any, fingerprint: string|null, id: string|null, backend: BackendInterface, elementDriver: ElementDriver) { + this.element = element; + this.backend = backend; + this.elementDriver = elementDriver; + this.id = id; + this.fingerprint = fingerprint; + + this.valueStore = new ValueStore(props, data); + this.unsyncedInputsTracker = new UnsyncedInputsTracker(this, elementDriver); + this.hooks = new HookManager(); + this.resetPromise(); + + this.onChildComponentModelUpdate = this.onChildComponentModelUpdate.bind(this); + } + + addPlugin(plugin: PluginInterface) { + plugin.attachToComponent(this); + } + + connect(): void { + this.hooks.triggerHook('connect', this); + this.unsyncedInputsTracker.activate(); + } + + disconnect(): void { + this.hooks.triggerHook('disconnect', this); + this.clearRequestDebounceTimeout(); + this.unsyncedInputsTracker.deactivate(); + } + + /** + * Add a named hook to the component. Available hooks are: + * + * * connect (component: Component) => {} + * * disconnect (component: Component) => {} + * * render:started (html: string, response: BackendResponse, controls: { shouldRender: boolean }) => {} + * * render:finished (component: Component) => {} + * * loading.state:started (element: HTMLElement, request: BackendRequest) => {} + * * loading.state:finished (element: HTMLElement) => {} + * * model:set (model: string, value: any, component: Component) => {} + */ + on(hookName: string, callback: (...args: any[]) => void): void { + this.hooks.register(hookName, callback); + } + + off(hookName: string, callback: (...args: any[]) => void): void { + this.hooks.unregister(hookName, callback); + } + + set(model: string, value: any, reRender = false, debounce: number|boolean = false): Promise { + const promise = this.nextRequestPromise; + const modelName = normalizeModelName(model); + const isChanged = this.valueStore.set(modelName, value); + + this.hooks.triggerHook('model:set', model, value, this); + + // the model's data is no longer unsynced + this.unsyncedInputsTracker.markModelAsSynced(modelName); + + // don't bother re-rendering if the value didn't change + if (reRender && isChanged) { + this.debouncedStartRequest(debounce); + } + + return promise; + } + + getData(model: string): any { + const modelName = normalizeModelName(model); + if (!this.valueStore.has(modelName)) { + throw new Error(`Invalid model "${model}".`); + } + + return this.valueStore.get(modelName); + } + + action(name: string, args: any, debounce: number|boolean = false): Promise { + const promise = this.nextRequestPromise; + this.pendingActions.push({ + name, + args + }); + + this.debouncedStartRequest(debounce); + + return promise; + } + + render(): Promise { + const promise = this.nextRequestPromise; + this.tryStartingRequest(); + + return promise; + } + + getUnsyncedModels(): string[] { + return this.unsyncedInputsTracker.getModifiedModels(); + } + + addChild(child: Component, modelBindings: ModelBinding[] = []): void { + if (!child.id) { + throw new Error('Children components must have an id.'); + } + + this.children.set(child.id, new ChildComponentWrapper(child, modelBindings)); + child.parent = this; + child.on('model:set', this.onChildComponentModelUpdate); + } + + removeChild(child: Component): void { + if (!child.id) { + throw new Error('Children components must have an id.'); + } + + this.children.delete(child.id); + child.parent = null; + child.off('model:set', this.onChildComponentModelUpdate); + } + + getParent(): Component|null { + return this.parent; + } + + getChildren(): Map { + const children: Map = new Map(); + this.children.forEach((childComponent, id) => { + children.set(id, childComponent.component); + }); + + return children; + } + + /** + * Called during morphdom: read props from toEl and re-render if necessary. + * + * @param toEl + */ + updateFromNewElement(toEl: HTMLElement): boolean { + const props = this.elementDriver.getComponentProps(toEl); + + // if no props are on the element, use the existing element completely + // this means the parent is signaling that the child does not need to be re-rendered + if (props === null) { + return false; + } + + // push props directly down onto the value store + const isChanged = this.valueStore.reinitializeProps(props); + + const fingerprint = toEl.dataset.liveFingerprintValue; + if (fingerprint !== undefined) { + this.fingerprint = fingerprint; + } + + if (isChanged) { + this.render(); + } + + return false; + } + + /** + * Handles data-model binding from a parent component onto a child. + */ + onChildComponentModelUpdate(modelName: string, value: any, childComponent: Component): void { + if (!childComponent.id) { + throw new Error('Missing id'); + } + + const childWrapper = this.children.get(childComponent.id); + if (!childWrapper) { + throw new Error('Missing child'); + } + + childWrapper.modelBindings.forEach((modelBinding) => { + const childModelName = modelBinding.innerModelName || 'value'; + + // skip, unless childModelName matches the model that just changed + if (childModelName !== modelName) { + return; + } + + this.set( + modelBinding.modelName, + value, + modelBinding.shouldRender, + modelBinding.debounce + ); + }); + } + + private tryStartingRequest(): void { + if (!this.backendRequest) { + this.performRequest() + + return; + } + + // mark that a request is wanted + this.isRequestPending = true; + } + + private performRequest(): void { + // grab the resolve() function for the current promise + const thisPromiseResolve = this.nextRequestPromiseResolve; + // then create a fresh Promise, so any future .then() apply to it + this.resetPromise(); + + this.backendRequest = this.backend.makeRequest( + this.valueStore.all(), + this.pendingActions, + this.valueStore.updatedModels, + this.getChildrenFingerprints() + ); + 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 backendResponse = new BackendResponse(response); + thisPromiseResolve(backendResponse); + const html = await backendResponse.getBody(); + // if the response does not contain a component, render as an error + if (backendResponse.response.headers.get('Content-Type') !== 'application/vnd.live-component+html') { + this.renderError(html); + + return response; + } + + this.processRerender(html, backendResponse); + + this.backendRequest = null; + + // do we already have another request pending? + if (this.isRequestPending) { + this.isRequestPending = false; + this.performRequest(); + } + + return response; + }); + } + + private processRerender(html: string, backendResponse: BackendResponse) { + const controls = { shouldRender: true }; + this.hooks.triggerHook('render:started', html, backendResponse, controls); + // used to notify that the component doesn't live on the page anymore + if (!controls.shouldRender) { + return; + } + + if (backendResponse.response.headers.get('Location')) { + // action returned a redirect + if (typeof Turbo !== 'undefined') { + Turbo.visit(backendResponse.response.headers.get('Location')); + } else { + window.location.href = backendResponse.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); + + /** + * 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); + + this.valueStore.reinitializeData(this.elementDriver.getComponentData(newElement)); + executeMorphdom( + this.element, + newElement, + this.unsyncedInputsTracker.getUnsyncedInputs(), + (element: HTMLElement) => getValueFromElement(element, this.valueStore), + Array.from(this.getChildren().values()), + this.elementDriver.findChildComponentElement, + this.elementDriver.getKeyFromElement + ); + + // 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 calculateDebounce(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.calculateDebounce(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(); + } + + private getChildrenFingerprints(): any { + const fingerprints: any = {}; + + this.children.forEach((childComponent) => { + const child = childComponent.component; + if (!child.id) { + throw new Error('missing id'); + } + + fingerprints[child.id] = child.fingerprint; + }); + + return fingerprints; + } + + private resetPromise(): void { + this.nextRequestPromise = new Promise((resolve) => { + this.nextRequestPromiseResolve = resolve; + }); + } +} + +/** + * 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 + 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.getData(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/Component/plugins/LoadingPlugin.ts b/src/LiveComponent/assets/src/Component/plugins/LoadingPlugin.ts new file mode 100644 index 00000000000..6dcad90789b --- /dev/null +++ b/src/LiveComponent/assets/src/Component/plugins/LoadingPlugin.ts @@ -0,0 +1,227 @@ +import { + Directive, + DirectiveModifier, + parseDirectives +} from '../../Directive/directives_parser'; +import { combineSpacedArray} from '../../string_utils'; +import BackendRequest from '../../BackendRequest'; +import Component from '../../Component'; +import { PluginInterface } from './PluginInterface'; + +interface ElementLoadingDirectives { + element: HTMLElement|SVGElement, + directives: Directive[] +} + +export default class implements PluginInterface { + 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/Component/plugins/PageUnloadingPlugin.ts b/src/LiveComponent/assets/src/Component/plugins/PageUnloadingPlugin.ts new file mode 100644 index 00000000000..fcd10a37dc2 --- /dev/null +++ b/src/LiveComponent/assets/src/Component/plugins/PageUnloadingPlugin.ts @@ -0,0 +1,22 @@ +import Component from '../index'; +import { PluginInterface } from './PluginInterface'; + +export default class implements PluginInterface { + private isConnected = false; + + attachToComponent(component: Component): void { + component.on('render:started', (html: string, response: Response, controls: { shouldRender: boolean }) => { + if (!this.isConnected) { + controls.shouldRender = false; + } + }); + + component.on('connect', () => { + this.isConnected = true; + }); + + component.on('disconnect', () => { + this.isConnected = false; + }); + } +} diff --git a/src/LiveComponent/assets/src/Component/plugins/PluginInterface.ts b/src/LiveComponent/assets/src/Component/plugins/PluginInterface.ts new file mode 100644 index 00000000000..4d4842fe4d1 --- /dev/null +++ b/src/LiveComponent/assets/src/Component/plugins/PluginInterface.ts @@ -0,0 +1,5 @@ +import Component from '../index'; + +export interface PluginInterface { + attachToComponent(component: Component): void; +} diff --git a/src/LiveComponent/assets/src/Component/plugins/PollingPlugin.ts b/src/LiveComponent/assets/src/Component/plugins/PollingPlugin.ts new file mode 100644 index 00000000000..a74c4745415 --- /dev/null +++ b/src/LiveComponent/assets/src/Component/plugins/PollingPlugin.ts @@ -0,0 +1,65 @@ +import Component from '../index'; +import { parseDirectives } from '../../Directive/directives_parser'; +import PollingDirector from '../../PollingDirector'; +import { PluginInterface } from './PluginInterface'; + +export default class implements PluginInterface { + private element: Element; + private pollingDirector: PollingDirector; + + attachToComponent(component: Component): void { + this.element = component.element; + this.pollingDirector = new PollingDirector(component); + this.initializePolling(); + + component.on('connect', () => { + this.pollingDirector.startAllPolling(); + }); + component.on('disconnect', () => { + this.pollingDirector.stopAllPolling(); + }); + component.on('render:finished', () => { + // re-start polling, in case polling changed + this.initializePolling(); + }); + } + + addPoll(actionName: string, duration: number): void { + this.pollingDirector.addPoll(actionName, duration); + } + + clearPolling(): void { + this.pollingDirector.clearPolling(); + } + + private initializePolling(): void { + this.clearPolling(); + + if ((this.element as HTMLElement).dataset.poll === undefined) { + return; + } + + const rawPollConfig = (this.element as HTMLElement).dataset.poll; + const directives = parseDirectives(rawPollConfig || '$render'); + + directives.forEach((directive) => { + let duration = 2000; + + directive.modifiers.forEach((modifier) => { + switch (modifier.name) { + case 'delay': + if (modifier.value) { + duration = parseInt(modifier.value); + } + + break; + default: + console.warn(`Unknown modifier "${modifier.name}" in data-poll "${rawPollConfig}".`); + } + }); + + this.addPoll(directive.action, duration); + }); + } +} + diff --git a/src/LiveComponent/assets/src/Component/plugins/SetValueOntoModelFieldsPlugin.ts b/src/LiveComponent/assets/src/Component/plugins/SetValueOntoModelFieldsPlugin.ts new file mode 100644 index 00000000000..551b0ba310d --- /dev/null +++ b/src/LiveComponent/assets/src/Component/plugins/SetValueOntoModelFieldsPlugin.ts @@ -0,0 +1,64 @@ +import Component from '../index'; +import { + getModelDirectiveFromElement, + getValueFromElement, + setValueOnElement +} from '../../dom_utils'; +import { PluginInterface } from './PluginInterface'; + +/** + * Handles setting the "value" onto data-model fields automatically from the data store. + */ +export default class implements PluginInterface { + attachToComponent(component: Component): void { + this.synchronizeValueOfModelFields(component); + component.on('render:finished', () => { + this.synchronizeValueOfModelFields(component); + }); + } + + /** + * Sets the "value" of all model fields to the component data. + * + * This is called when the component initializes and after re-render. + * Take the following element: + * + * + * + * This method will set the "value" of that element to the value of + * the "firstName" model. + */ + private synchronizeValueOfModelFields(component: Component): void { + component.element.querySelectorAll('[data-model]').forEach((element: Element) => { + if (!(element instanceof HTMLElement)) { + throw new Error('Invalid element using data-model.'); + } + + if (element instanceof HTMLFormElement) { + return; + } + + const modelDirective = getModelDirectiveFromElement(element); + if (!modelDirective) { + return; + } + + const modelName = modelDirective.action; + + // skip any elements whose model name is currently in an unsynced state + if (component.getUnsyncedModels().includes(modelName)) { + return; + } + + if (component.valueStore.has(modelName)) { + setValueOnElement(element, 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) { + component.valueStore.set(modelName, getValueFromElement(element, component.valueStore)); + } + }) + } +} diff --git a/src/LiveComponent/assets/src/Component/plugins/ValidatedFieldsPlugin.ts b/src/LiveComponent/assets/src/Component/plugins/ValidatedFieldsPlugin.ts new file mode 100644 index 00000000000..72b4f28c764 --- /dev/null +++ b/src/LiveComponent/assets/src/Component/plugins/ValidatedFieldsPlugin.ts @@ -0,0 +1,21 @@ +import Component from '../index'; +import ValueStore from '../ValueStore'; +import { PluginInterface } from './PluginInterface'; + +export default class implements PluginInterface { + attachToComponent(component: Component): void { + component.on('model:set', (modelName: string) => { + this.handleModelSet(modelName, component.valueStore); + }); + } + + private handleModelSet(modelName: string, valueStore: ValueStore): void { + if (valueStore.has('validatedFields')) { + const validatedFields = [...valueStore.get('validatedFields')]; + if (!validatedFields.includes(modelName)) { + validatedFields.push(modelName); + } + valueStore.set('validatedFields', validatedFields); + } + } +} diff --git a/src/LiveComponent/assets/src/directives_parser.ts b/src/LiveComponent/assets/src/Directive/directives_parser.ts similarity index 100% rename from src/LiveComponent/assets/src/directives_parser.ts rename to src/LiveComponent/assets/src/Directive/directives_parser.ts diff --git a/src/LiveComponent/assets/src/Directive/get_model_binding.ts b/src/LiveComponent/assets/src/Directive/get_model_binding.ts new file mode 100644 index 00000000000..587f38e05e9 --- /dev/null +++ b/src/LiveComponent/assets/src/Directive/get_model_binding.ts @@ -0,0 +1,52 @@ +import {Directive} from './directives_parser'; + +export interface ModelBinding { + modelName: string, + innerModelName: string|null, + shouldRender: boolean, + debounce: number|boolean, + targetEventName: string|null +} + +export default function(modelDirective: Directive): ModelBinding { + let shouldRender = true; + let targetEventName = null; + let debounce: number|boolean = false; + + modelDirective.modifiers.forEach((modifier) => { + switch (modifier.name) { + case 'on': + if (!modifier.value) { + throw new Error(`The "on" modifier in ${modelDirective.getString()} requires a value - e.g. on(change).`); + } + if (!['input', 'change'].includes(modifier.value)) { + throw new Error(`The "on" modifier in ${modelDirective.getString()} only accepts the arguments "input" or "change".`); + } + + targetEventName = modifier.value; + + break; + case 'norender': + shouldRender = false; + + break; + + case 'debounce': + debounce = modifier.value ? parseInt(modifier.value) : true; + + break; + default: + throw new Error(`Unknown modifier "${modifier.name}" in data-model="${modelDirective.getString()}".`); + } + }); + + const [ modelName, innerModelName ] = modelDirective.action.split(':'); + + return { + modelName, + innerModelName: innerModelName || null, + shouldRender, + debounce, + targetEventName + } +} diff --git a/src/LiveComponent/assets/src/HookManager.ts b/src/LiveComponent/assets/src/HookManager.ts new file mode 100644 index 00000000000..835bfb2fbfe --- /dev/null +++ b/src/LiveComponent/assets/src/HookManager.ts @@ -0,0 +1,32 @@ +export default class { + private hooks: Map void>>; + + constructor() { + this.hooks = new Map(); + } + + register(hookName: string, callback: () => void): void { + const hooks = this.hooks.get(hookName) || []; + hooks.push(callback); + this.hooks.set(hookName, hooks); + } + + unregister(hookName: string, callback: () => void): void { + const hooks = this.hooks.get(hookName) || []; + + const index = hooks.indexOf(callback); + if (index === -1) { + return; + } + + hooks.splice(index, 1); + this.hooks.set(hookName, hooks); + } + + triggerHook(hookName: string, ...args: any[]): void { + const hooks = this.hooks.get(hookName) || []; + hooks.forEach((callback) => { + callback(...args); + }); + } +} diff --git a/src/LiveComponent/assets/src/PollingDirector.ts b/src/LiveComponent/assets/src/PollingDirector.ts new file mode 100644 index 00000000000..2434d0e7e89 --- /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/ValueStore.ts b/src/LiveComponent/assets/src/ValueStore.ts deleted file mode 100644 index 500331fa8ca..00000000000 --- a/src/LiveComponent/assets/src/ValueStore.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { getDeepData, setDeepData } from './data_manipulation_utils'; -import { LiveController } from './live_controller'; -import { normalizeModelName } from './string_utils'; - -export default class { - controller: LiveController; - updatedModels: string[] = []; - - constructor(liveController: LiveController) { - this.controller = liveController; - } - - /** - * Returns the data with the given name. - * - * This allows for non-normalized model names - e.g. - * user[firstName] -> user.firstName and also will fetch - * deeply (fetching the "firstName" sub-key from the "user" key). - */ - get(name: string): any { - const normalizedName = normalizeModelName(name); - - return getDeepData(this.controller.dataValue, normalizedName); - } - - has(name: string): boolean { - return this.get(name) !== undefined; - } - - /** - * Sets data back onto the value store. - * - * The name can be in the non-normalized format. - */ - set(name: string, value: any): void { - const normalizedName = normalizeModelName(name); - if (!this.updatedModels.includes(normalizedName)) { - this.updatedModels.push(normalizedName); - } - - this.controller.dataValue = setDeepData(this.controller.dataValue, 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.controller.dataValue[parts[0]] !== undefined; - } - - asJson(): string { - return JSON.stringify(this.controller.dataValue); - } - - all(): any { - return this.controller.dataValue; - } - - /** - * Are any of the passed models currently "updated"? - */ - areAnyModelsUpdated(targetedModels: string[]): boolean { - return (this.updatedModels.filter(modelName => targetedModels.includes(modelName))).length > 0; - } -} 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 01372413e79..76cf16daf9b 100644 --- a/src/LiveComponent/assets/src/dom_utils.ts +++ b/src/LiveComponent/assets/src/dom_utils.ts @@ -1,7 +1,7 @@ -import ValueStore from './ValueStore'; -import { Directive, parseDirectives } from './directives_parser'; -import { LiveController } from './live_controller'; +import ValueStore from './Component/ValueStore'; +import { Directive, parseDirectives } from './Directive/directives_parser'; import { normalizeModelName } from './string_utils'; +import Component from './Component'; /** * Return the "value" of any given element. @@ -111,18 +111,33 @@ export function setValueOnElement(element: HTMLElement, value: any): void { (element as HTMLInputElement).value = value } -export function getModelDirectiveFromElement(element: HTMLElement, throwOnMissing = true): null|Directive { - if (element.dataset.model) { - const directives = parseDirectives(element.dataset.model); - const directive = directives[0]; +/** + * Fetches *all* "data-model" directives for a given element. + * + * @param element + */ +export function getAllModelDirectiveFromElements(element: HTMLElement): Directive[] { + if (!element.dataset.model) { + return []; + } + + const directives = parseDirectives(element.dataset.model); + directives.forEach((directive) => { if (directive.args.length > 0 || directive.named.length > 0) { throw new Error(`The data-model="${element.dataset.model}" format is invalid: it does not support passing arguments to the model.`); } directive.action = normalizeModelName(directive.action); + }); + + return directives; +} - return directive; +export function getModelDirectiveFromElement(element: HTMLElement, throwOnMissing = true): null|Directive { + const dataModelDirectives = getAllModelDirectiveFromElements(element); + if (dataModelDirectives.length > 0) { + return dataModelDirectives[0]; } if (element.getAttribute('name')) { @@ -152,30 +167,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 { - 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 { @@ -206,6 +225,21 @@ export function htmlToElement(html: string): HTMLElement { return child; } +// Inspired by https://stackoverflow.com/questions/13389751/change-tag-using-javascript +export function cloneElementWithNewTagName(element: Element, newTag: string): HTMLElement { + const originalTag = element.tagName + const startRX = new RegExp('^<'+originalTag, 'i') + const endRX = new RegExp(originalTag+'>$', 'i') + const startSubst = '<'+newTag + const endSubst = newTag+'>' + + const newHTML = element.outerHTML + .replace(startRX, startSubst) + .replace(endRX, endSubst); + + return htmlToElement(newHTML); +} + /** * Returns just the outer element's HTML as a string - useful for error messages. * 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 abeabd50d9a..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 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 2caf92d4841..7dfd1422b1b 100644 --- a/src/LiveComponent/assets/src/live_controller.ts +++ b/src/LiveComponent/assets/src/live_controller.ts @@ -1,142 +1,120 @@ 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 { - elementBelongsToThisController, + parseDirectives, + DirectiveModifier, +} from './Directive/directives_parser'; +import { getModelDirectiveFromElement, - getValueFromElement, - cloneHTMLElement, - htmlToElement, getElementAsTagText, - setValueOnElement + getValueFromElement, + elementBelongsToThisComponent, getAllModelDirectiveFromElements, } from './dom_utils'; -import UnsyncedInputContainer from './UnsyncedInputContainer'; - -interface ElementLoadingDirectives { - element: HTMLElement|SVGElement, - directives: Directive[] +import Component, { proxifyComponent } from './Component'; +import Backend from './Backend'; +import { StandardElementDriver } from './Component/ElementDriver'; +import LoadingPlugin from './Component/plugins/LoadingPlugin'; +import ValidatedFieldsPlugin from './Component/plugins/ValidatedFieldsPlugin'; +import PageUnloadingPlugin from './Component/plugins/PageUnloadingPlugin'; +import PollingPlugin from './Component/plugins/PollingPlugin'; +import SetValueOntoModelFieldsPlugin from './Component/plugins/SetValueOntoModelFieldsPlugin'; +import {PluginInterface} from './Component/plugins/PluginInterface'; +import getModelBinding from './Directive/get_model_binding'; + +export interface LiveEvent extends CustomEvent { + detail: { + controller: LiveController, + component: Component + }, } -interface UpdateModelOptions { - dispatch?: boolean; - debounce?: number|null; -} - -declare const Turbo: any; - -const DEFAULT_DEBOUNCE = 150; - export interface LiveController { - dataValue: any; - element: Element, - childComponentControllers: Array + element: HTMLElement, + component: Component } - -export default class extends Controller implements LiveController { +export default class extends Controller implements LiveController { static values = { url: String, data: Object, + props: Object, csrf: String, - /** - * The Debounce timeout. - * - * Default: 150 - */ - debounce: Number, + debounce: { type: Number, default: 150 }, + id: String, + fingerprint: String, } readonly urlValue!: string; - dataValue!: any; + readonly dataValue!: any; + readonly propsValue!: any; readonly csrfValue!: string; - readonly debounceValue!: number; readonly hasDebounceValue: boolean; + readonly debounceValue: number; + readonly fingerprintValue: string - 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[] = []; - - isConnected = false; - - originalDataJSON = '{}'; + /** The component, wrapped in the convenience Proxy */ + private proxiedComponent: Component; + /** The raw Component object */ + component: Component; + pendingActionTriggerModelElement: HTMLElement|null = null; - mutationObserver: MutationObserver|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: 'live:connect', callback: (event) => this.handleConnectedControllerEvent(event) }, + ]; - /** - * Model form fields that have "changed", but whose model value hasn't been set yet. - */ - unsyncedInputs!: UnsyncedInputContainer; + initialize() { + this.handleDisconnectedChildControllerEvent = this.handleDisconnectedChildControllerEvent.bind(this); + + const id = this.element.dataset.liveId || null; + + this.component = new Component( + this.element, + this.propsValue, + this.dataValue, + this.fingerprintValue, + id, + new Backend(this.urlValue, this.csrfValue), + new StandardElementDriver(), + ); + this.proxiedComponent = proxifyComponent(this.component); - childComponentControllers: Array = []; + // @ts-ignore Adding the dynamic property + this.element.__component = this.proxiedComponent; - pendingActionTriggerModelElement: HTMLElement|null = null; + if (this.hasDebounceValue) { + this.component.defaultDebounce = this.debounceValue; + } - 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(); - this.synchronizeValueOfModelFields(); + const plugins: PluginInterface[] = [ + new LoadingPlugin(), + new ValidatedFieldsPlugin(), + new PageUnloadingPlugin(), + new PollingPlugin(), + new SetValueOntoModelFieldsPlugin(), + ]; + plugins.forEach((plugin) => { + this.component.addPlugin(plugin); + }); } connect() { - this.isConnected = true; - // 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.component.connect(); - 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.elementEventListeners.forEach(({event, callback}) => { + this.component.element.addEventListener(event, callback); + }); - this._dispatchEvent('live:connect', { controller: this }); + this._dispatchEvent('live:connect'); } disconnect() { - this._stopAllPolling(); - this.#clearRequestDebounceTimeout(); - - 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 }); + this.component.disconnect(); - if (this.mutationObserver) { - this.mutationObserver.disconnect(); - } + this.elementEventListeners.forEach(({event, callback}) => { + this.component.element.removeEventListener(event, callback); + }); - this.isConnected = false; + this._dispatchEvent('live:disconnect'); } /** @@ -149,7 +127,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,21 +139,16 @@ 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(); }); validModifiers.set('stop', () => { - event.stopPropagation(); + event.stopPropagation(); }); validModifiers.set('self', () => { if (event.target !== event.currentTarget) { @@ -183,15 +156,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,124 +171,20 @@ 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); + this.component.action(directive.action, directive.named, debounce); - return; - } - - this.#startPendingRequest(); + // 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; } }) } $render() { - this.isRerenderRequested = true; - this.#startPendingRequest(); - } - - /** - * @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) { - if (!elementBelongsToThisController(element, this)) { - return; - } - - if (!(element instanceof HTMLElement)) { - throw new Error('Could not update model for non HTMLElement'); - } - - 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) { - return; - } - - let shouldRender = true; - let targetEventName = 'input'; - let debounce: number|null = null; - - modelDirective.modifiers.forEach((modifier) => { - switch (modifier.name) { - case 'on': - if (!modifier.value) { - throw new Error(`The "on" modifier in ${modelDirective.getString()} requires a value - e.g. on(change).`); - } - if (!['input', 'change'].includes(modifier.value)) { - throw new Error(`The "on" modifier in ${modelDirective.getString()} only accepts the arguments "input" or "change".`); - } - - targetEventName = modifier.value; - - break; - case 'norender': - shouldRender = false; - - break; - - case 'debounce': - debounce = modifier.value ? parseInt(modifier.value) : this.getDefaultDebounce(); - - break; - default: - console.warn(`Unknown modifier "${modifier.name}" in data-model="${modelDirective.getString()}".`); - } - }); - - // rare case where the same event+element triggers a model - // update *and* an action. The action is already scheduled - // to occur, so we do not need to *also* trigger a re-render. - if (this.pendingActionTriggerModelElement === element) { - shouldRender = false; - } - - // e.g. we are targeting "change" and this is the "input" event - // so do *not* update the model yet - if (eventName && targetEventName !== eventName) { - return; - } - - if (null === debounce) { - if (targetEventName === 'input') { - // for the input event, add a debounce by default - debounce = this.getDefaultDebounce(); - } 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 - } - ); + this.component.render(); } /** @@ -340,871 +201,152 @@ export default class extends Controller implements LiveController { * @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 + * @param {number|boolean} debounce */ - $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); - } + $updateModel(model: string, value: any, shouldRender = true, debounce: number|boolean = true) { + this.component.set(model, value, shouldRender, 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(); + private handleInputEvent(event: Event) { + const target = event.target as Element; + if (!target) { + return; } - } - - #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; - } - } + this.updateModelFromElementEvent(target, 'input') + } - fetchOptions.body = JSON.stringify(requestData); + private handleChangeEvent(event: Event) { + const target = event.target as Element; + if (!target) { + return; } - 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(); - }) + this.updateModelFromElementEvent(target, 'change') } /** - * Processes the response from an AJAX call and uses it to re-render. + * 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"". * - * @private + * @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. */ - #processRerender(html: string, response: Response) { - // check if the page is navigating away - if (!this.isConnected) { + private updateModelFromElementEvent(element: Element, eventName: string|null) { + if (!elementBelongsToThisComponent(element, this.component)) { 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; + if (!(element instanceof HTMLElement)) { + throw new Error('Could not update model for non HTMLElement'); } - // 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(); + const modelDirective = getModelDirectiveFromElement(element, false); - if (!this._dispatchEvent('live:render', html, true, true)) { - // preventDefault() was called + // if not tied to a model, no more work to be done + if (!modelDirective) { 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; + const modelBinding = getModelBinding(modelDirective); + if (!modelBinding.targetEventName) { + modelBinding.targetEventName = 'input'; } - // if loading is being activated + model modifier, only apply if the model is modified - if (isLoading && targetedModels.length > 0 && !this.valueStore.areAnyModelsUpdated(targetedModels)) { - return; + // rare case where the same event+element triggers a model + // update *and* an action. The action is already scheduled + // to occur, so we do not need to *also* trigger a re-render. + if (this.pendingActionTriggerModelElement === element) { + modelBinding.shouldRender = false; } - 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}"`); + // 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' && modelBinding.targetEventName === 'input') { + modelBinding.targetEventName = 'change'; } - if (delay) { - window.setTimeout(() => { - if (this.isRequestActive()) { - loadingDirective(); - } - }, delay); - + // e.g. we are targeting "change" and this is the "input" event + // so do *not* update the model yet + if (eventName && modelBinding.targetEventName !== eventName) { 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'); + if (false === modelBinding.debounce) { + if (modelBinding.targetEventName === 'input') { + // true debounce will cause default to be used + modelBinding.debounce = true; + } else { + // for change, add no debounce + modelBinding.debounce = 0; } - - // 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(); + const finalValue = getValueFromElement(element, this.component.valueStore); - // 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; - } - - // 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(); - } - - handleConnectedControllerEvent(event: any) { - if (event.target === this.element) { - return; - } - - this.childComponentControllers.push(event.detail.controller); - // 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); + this.component.set(modelBinding.modelName, finalValue, modelBinding.shouldRender, modelBinding.debounce); } - handleDisconnectedControllerEvent(event: any) { + handleConnectedControllerEvent(event: LiveEvent) { if (event.target === this.element) { return; } - const index = this.childComponentControllers.indexOf(event.detail.controller); - - // Remove value from an array - if (index > -1) { - this.childComponentControllers.splice(index, 1); - } - } - - handleUpdateModelEvent(event: any) { - // ignore events that we dispatched - if (event.target === this.element) { + const childController = event.detail.controller; + if (childController.component.getParent()) { + // child already has a parent - we are a grandparent return; } - this._handleChildComponentUpdateModel(event); - } + const modelDirectives = getAllModelDirectiveFromElements(childController.element); + const modelBindings = modelDirectives.map(getModelBinding); - handleInputEvent(event: Event) { - const target = event.target as Element; - if (!target) { - return; - } + this.component.addChild( + childController.component, + modelBindings + ); - this._updateModelFromElement(target, 'input') + // 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. + // @ts-ignore TS doesn't like the LiveEvent arg in the listener, not sure how to fix + childController.element.addEventListener('live:disconnect', this.handleDisconnectedChildControllerEvent); } - handleChangeEvent(event: Event) { - const target = event.target as Element; - if (!target) { - return; - } - - this._updateModelFromElement(target, 'change') - } + handleDisconnectedChildControllerEvent(event: LiveEvent): void { + const childController = event.detail.controller; - _initiatePolling() { - this._stopAllPolling(); + // @ts-ignore TS doesn't like the LiveEvent arg in the listener, not sure how to fix + childController.element.removeEventListener('live:disconnect', this.handleDisconnectedChildControllerEvent); - if ((this.element as HTMLElement).dataset.poll === undefined) { + // this shouldn't happen: but double-check we're the parent + if (childController.component.getParent() !== this.component) { return; } - const rawPollConfig = (this.element as HTMLElement).dataset.poll; - const directives = parseDirectives(rawPollConfig || '$render'); - - directives.forEach((directive) => { - let duration = 2000; - - directive.modifiers.forEach((modifier) => { - switch (modifier.name) { - case 'delay': - if (modifier.value) { - duration = parseInt(modifier.value); - } - - break; - default: - console.warn(`Unknown modifier "${modifier.name}" in data-poll "${rawPollConfig}".`); - } - }); - - this._startPoll(directive.action, duration); - }) + this.component.removeChild(childController.component); } - _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, detail: any = {}, canBubble = true, cancelable = false) { + detail.controller = this; + detail.component = this.proxiedComponent; - _dispatchEvent(name: string, payload: object | string | null = null, canBubble = true, cancelable = false) { return this.element.dispatchEvent(new CustomEvent(name, { bubbles: canBubble, cancelable, - detail: payload + detail })); } - - _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); - } - }); - modal.focus(); - } - - #clearRequestDebounceTimeout() { - // clear any pending renders - if (this.requestDebounceTimeout) { - clearTimeout(this.requestDebounceTimeout); - this.requestDebounceTimeout = null; - } - } - - /** - * Sets the "value" of all model fields to the component data. - * - * This is called when the component initializes and after re-render. - * Take the following element: - * - * - * - * 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) => { - if (!(element instanceof HTMLElement)) { - throw new Error('Invalid element using data-model.'); - } - - if (element instanceof HTMLFormElement) { - return; - } - - const modelDirective = getModelDirectiveFromElement(element); - if (!modelDirective) { - return; - } - - const modelName = modelDirective.action; - - // skip any elements whose model name is currently in an unsynced state - if (this.unsyncedInputs.getModifiedModels().includes(modelName)) { - return; - } - - if (this.valueStore.has(modelName)) { - setValueOnElement(element, this.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)); - } - }) - } - - 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..a4098164cb9 --- /dev/null +++ b/src/LiveComponent/assets/src/morphdom.ts @@ -0,0 +1,93 @@ +import { + cloneElementWithNewTagName, + cloneHTMLElement, + setValueOnElement +} from './dom_utils'; +import morphdom from 'morphdom'; +import { + normalizeAttributesForComparison +} from './normalize_attributes_for_comparison'; +import Component from './Component'; + +export function executeMorphdom( + rootFromElement: HTMLElement, + rootToElement: HTMLElement, + modifiedElements: Array, + getElementValue: (element: HTMLElement) => any, + childComponents: Component[], + findChildComponent: (id: string, element: HTMLElement) => HTMLElement|null, + getKeyFromElement: (element: HTMLElement) => string|null, +) { + const childComponentMap: Map = new Map(); + childComponents.forEach((childComponent) => { + childComponentMap.set(childComponent.element, childComponent); + if (!childComponent.id) { + throw new Error('Child is missing id.'); + } + const childComponentToElement = findChildComponent(childComponent.id, rootToElement); + if (childComponentToElement && childComponentToElement.tagName !== childComponent.element.tagName) { + // we need to "correct" the tag name for the child to match the "from" + // so that we always get a "diff", not a remove/add + const newTag = cloneElementWithNewTagName(childComponentToElement, childComponent.element.tagName); + rootToElement.replaceChild(newTag, childComponentToElement); + } + }); + + morphdom(rootFromElement, rootToElement, { + getNodeKey: (node: Node) => { + if (!(node instanceof HTMLElement)) { + return; + } + + return getKeyFromElement(node); + }, + onBeforeElUpdated: (fromEl, toEl) => { + if (fromEl === rootFromElement) { + return true; + } + + if (!(fromEl instanceof HTMLElement) || !(toEl instanceof HTMLElement)) { + return false; + } + + const childComponent = childComponentMap.get(fromEl) || false + if (childComponent) { + return childComponent.updateFromNewElement(toEl); + } + + // 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; + } + } + + // 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'); + } + }); +} 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..22b51f6414b --- /dev/null +++ b/src/LiveComponent/assets/test/Component/index.test.ts @@ -0,0 +1,123 @@ +import Component, {proxifyComponent} from '../../src/Component'; +import {BackendAction, BackendInterface} from '../../src/Backend'; +import { + StandardElementDriver +} from '../../src/Component/ElementDriver'; +import BackendRequest from '../../src/BackendRequest'; +import { Response } from 'node-fetch'; +import {waitFor} from '@testing-library/dom'; +import BackendResponse from '../../src/BackendResponse'; + +interface MockBackend extends BackendInterface { + actions: BackendAction[], +} + +const makeTestComponent = (): { component: Component, backend: MockBackend } => { + const backend: MockBackend = { + actions: [], + makeRequest(data: any, actions: BackendAction[]): BackendRequest { + this.actions = actions; + + return new BackendRequest( + // @ts-ignore Response doesn't quite match the underlying interface + new Promise((resolve) => resolve(new Response('
'))), + [], + [] + ) + } + } + + const component = new Component( + document.createElement('div'), + {}, + {firstName: ''}, + null, + null, + backend, + new StandardElementDriver() + ); + + return { + component, + backend + } +} + +describe('Component class', () => { + describe('set() method', () => { + it('returns a Promise that eventually resolves', async () => { + const { component } = makeTestComponent(); + + let backendResponse: BackendResponse|null = null; + + // set model but no re-render + const promise = component.set('firstName', 'Ryan', false); + // when this promise IS finally resolved, set the flag to true + promise.then((response) => backendResponse = response); + // it should not have happened yet + expect(backendResponse).toBeNull(); + + // set model WITH re-render + component.set('firstName', 'Kevin', true); + // it's still not *instantly* resolve - it'll + expect(backendResponse).toBeNull(); + await waitFor(() => expect(backendResponse).not.toBeNull()); + // @ts-ignore + expect(await backendResponse?.getBody()).toEqual('
'); + }); + }); + + describe('Proxy wrapper', () => { + const makeDummyComponent = (): { proxy: Component, backend: MockBackend } => { + const { backend, component} = makeTestComponent(); + return { + proxy: proxifyComponent(component), + 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.getData('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/directives_parser.test.ts b/src/LiveComponent/assets/test/Directive/directives_parser.test.ts similarity index 98% rename from src/LiveComponent/assets/test/directives_parser.test.ts rename to src/LiveComponent/assets/test/Directive/directives_parser.test.ts index b79f7add4e0..ececf9a972e 100644 --- a/src/LiveComponent/assets/test/directives_parser.test.ts +++ b/src/LiveComponent/assets/test/Directive/directives_parser.test.ts @@ -1,4 +1,4 @@ -import {Directive, parseDirectives} from '../src/directives_parser'; +import {Directive, parseDirectives} from '../../src/Directive/directives_parser'; const assertDirectiveEquals = function(actual: Directive, expected: any) { // normalize this so that it doesn't trip up the comparison diff --git a/src/LiveComponent/assets/test/Directive/get_model_binding.test.ts b/src/LiveComponent/assets/test/Directive/get_model_binding.test.ts new file mode 100644 index 00000000000..b251cc75641 --- /dev/null +++ b/src/LiveComponent/assets/test/Directive/get_model_binding.test.ts @@ -0,0 +1,37 @@ +import getModelBinding from '../../src/Directive/get_model_binding'; +import {parseDirectives} from '../../src/Directive/directives_parser'; + +describe('get_model_binding', () => { + it('returns correctly with simple directive', () => { + const directive = parseDirectives('firstName')[0]; + expect(getModelBinding(directive)).toEqual({ + modelName: 'firstName', + innerModelName: null, + shouldRender: true, + debounce: false, + targetEventName: null, + }); + }); + + it('returns all modifiers correctly', () => { + const directive = parseDirectives('on(change)|norender|debounce(100)|firstName')[0]; + expect(getModelBinding(directive)).toEqual({ + modelName: 'firstName', + innerModelName: null, + shouldRender: false, + debounce: 100, + targetEventName: 'change', + }); + }); + + it('parses the parent:inner model name correctly', () => { + const directive = parseDirectives('firstName:first')[0]; + expect(getModelBinding(directive)).toEqual({ + modelName: 'firstName', + innerModelName: 'first', + shouldRender: true, + debounce: false, + targetEventName: null, + }); + }); +}); 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..0f630e78aae 100644 --- a/src/LiveComponent/assets/test/ValueStore.test.ts +++ b/src/LiveComponent/assets/test/ValueStore.test.ts @@ -1,63 +1,62 @@ -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 props', () => { + const container = new ValueStore({ + firstName: 'Ryan' + }, {}); + + expect(container.get('firstName')).toEqual('Ryan'); + }); + 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 +64,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 +76,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 +84,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 +92,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,12 +102,20 @@ 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'); }).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/controller/action.test.ts b/src/LiveComponent/assets/test/controller/action.test.ts index d8103f5a54f..6a0da0f67bc 100644 --- a/src/LiveComponent/assets/test/controller/action.test.ts +++ b/src/LiveComponent/assets/test/controller/action.test.ts @@ -9,13 +9,13 @@ 'use strict'; -import { createTest, initComponent, shutdownTest } from '../tools'; +import { createTest, initComponent, shutdownTests } from '../tools'; import { getByText, waitFor } from '@testing-library/dom'; import userEvent from '@testing-library/user-event'; describe('LiveController Action Tests', () => { afterEach(() => { - shutdownTest(); + shutdownTests(); }) it('sends an action and renders the result', async () => { @@ -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!' : ''} @@ -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/basic.test.ts b/src/LiveComponent/assets/test/controller/basic.test.ts index e185b5c07b0..c5f89a149fa 100644 --- a/src/LiveComponent/assets/test/controller/basic.test.ts +++ b/src/LiveComponent/assets/test/controller/basic.test.ts @@ -9,12 +9,13 @@ 'use strict'; -import { shutdownTest, startStimulus } from '../tools'; +import {createTest, initComponent, shutdownTests, startStimulus} from '../tools'; import { htmlToElement } from '../../src/dom_utils'; +import Component from '../../src/Component'; describe('LiveController Basic Tests', () => { afterEach(() => { - shutdownTest() + shutdownTests() }); it('dispatches connect event', async () => { @@ -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.component).toBeInstanceOf(Component); + expect(test.component.defaultDebounce).toEqual(115); + expect(test.component.id).toEqual('the-id'); + expect(test.component.fingerprint).toEqual('the-fingerprint'); + }); }); diff --git a/src/LiveComponent/assets/test/controller/child-model.test.ts b/src/LiveComponent/assets/test/controller/child-model.test.ts new file mode 100644 index 00000000000..01ed2fc615b --- /dev/null +++ b/src/LiveComponent/assets/test/controller/child-model.test.ts @@ -0,0 +1,139 @@ +/* + * 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, shutdownTests } from '../tools'; +import {getByTestId, waitFor} from '@testing-library/dom'; +import userEvent from '@testing-library/user-event'; + +describe('Component parent -> child data-model binding tests', () => { + afterEach(() => { + shutdownTests(); + }) + + // updating stops when child is removed, restarts after + // more complex foo:bar model binding works + // multiple model bindings work + + it('updates parent model in simple setup', async () => { + const test = await createTest({ foodName: ''}, (data: any) => ` +
+ Food Name ${data.foodName} +
+ +
+
+ `); + + test.expectsAjaxCall('get') + .expectSentData({ foodName: 'ice cream' }) + .init(); + + // type into the child component + await userEvent.type(test.queryByDataModel('value'), 'ice cream'); + + // wait for parent to start/stop loading + await waitFor(() => expect(test.element).toHaveAttribute('busy')); + await waitFor(() => expect(test.element).toHaveTextContent('Food Name ice cream')); + }); + + it('will default to "value" for the model name', async () => { + const test = await createTest({ foodName: ''}, (data: any) => ` +
+ Food Name ${data.foodName} +
+ +
+
+ `); + + test.expectsAjaxCall('get') + .expectSentData({ foodName: 'ice cream' }) + .init(); + + // type into the child component + await userEvent.type(test.queryByDataModel('value'), 'ice cream'); + + // wait for parent to start/stop loading + await waitFor(() => expect(test.element).toHaveAttribute('busy')); + await waitFor(() => expect(test.element).toHaveTextContent('Food Name ice cream')); + }); + + it('considers modifiers when updating parent model', async () => { + const test = await createTest({ foodName: ''}, (data: any) => ` +
+ Food Name ${data.foodName} +
+ +
+
+ `); + + // type into the child component + await userEvent.type(test.queryByDataModel('value'), 'ice cream'); + + // wait for parent model to be set + await waitFor(() => expect(test.component.getData('foodName')).toEqual('ice cream')); + // but it never triggers an Ajax call, because the norender modifier + expect(test.element).not.toHaveAttribute('busy'); + // wait for a potential Ajax call to start + await (new Promise(resolve => setTimeout(resolve, 50))); + expect(test.element).not.toHaveAttribute('busy'); + }); + + it('start and stops model binding as child is added/removed', async () => { + const test = await createTest({ foodName: ''}, (data: any) => ` +
+ Food Name ${data.foodName} +
+ +
+
+ `); + + test.expectsAjaxCall('get') + .expectSentData({ foodName: 'ice cream' }) + .init(); + + // type into the child component + const inputElement = test.queryByDataModel('value'); + await userEvent.type(inputElement, 'ice cream'); + + // wait for parent to start/stop loading + await waitFor(() => expect(test.element).toHaveAttribute('busy')); + await waitFor(() => expect(test.element).toHaveTextContent('Food Name ice cream')); + + // remove child component + const otherContainer = document.createElement('div'); + otherContainer.appendChild(getByTestId(test.element, 'child')); + + // type into the child component + await userEvent.type(inputElement, ' sandwhich'); + // wait for a potential Ajax call to start + await (new Promise(resolve => setTimeout(resolve, 50))); + expect(test.element).not.toHaveAttribute('busy'); + }); +}); diff --git a/src/LiveComponent/assets/test/controller/child.test.ts b/src/LiveComponent/assets/test/controller/child.test.ts index 818dc1fef6c..e85b14271e5 100644 --- a/src/LiveComponent/assets/test/controller/child.test.ts +++ b/src/LiveComponent/assets/test/controller/child.test.ts @@ -9,244 +9,434 @@ 'use strict'; -import { createTest, initComponent, shutdownTest } from '../tools'; -import { getByText, waitFor } from '@testing-library/dom'; +import { createTestForExistingComponent, createTest, initComponent, shutdownTests, getComponent } from '../tools'; +import { getByTestId, waitFor } from '@testing-library/dom'; import userEvent from '@testing-library/user-event'; -import { htmlToElement } from '../../src/dom_utils'; -describe('LiveController parent -> child component tests', () => { +describe('Component parent -> child initialization and rendering tests', () => { afterEach(() => { - shutdownTest(); + shutdownTests(); }) - it('renders parent component without affecting child component', async () => { + it('adds & removes the child correctly', async () => { const childTemplate = (data: any) => ` -
- Child component original text - Favorite food: ${data.food} - - -
+
`; - const test = await createTest({ count: 0 }, (data: any) => ` + const test = await createTest({}, (data: any) => ` +
+ ${childTemplate({})} +
+ `); + + const parentComponent = test.component; + const childComponent = getComponent(getByTestId(test.element, 'child')); + // setting a marker to help verify THIS exact Component instance continues to be used + childComponent.fingerprint = 'FOO-FINGERPRINT'; + + // check that the relationships all loaded correctly + expect(parentComponent.getChildren().size).toEqual(1); + // check fingerprint instead of checking object equality with childComponent + // because childComponent is actually the proxied Component + expect(parentComponent.getChildren().get('the-child-id')?.fingerprint).toEqual('FOO-FINGERPRINT'); + expect(childComponent.getParent()).toBe(parentComponent); + + // remove the child + childComponent.element.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(childComponent.element); + await waitFor(() => expect(parentComponent.getChildren().size).toEqual(1)); + expect(parentComponent.getChildren().get('the-child-id')?.fingerprint).toEqual('FOO-FINGERPRINT'); + 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')?.fingerprint).toEqual('FOO-FINGERPRINT'); + 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')); + }); + + it('removes missing child component on re-render', async () => { + const test = await createTest({renderChild: true}, (data: any) => `
- Parent component count: ${data.count} - - ${childTemplate({food: 'pizza'})} + ${data.renderChild + ? `
Child Component
` + : '' + }
`); - // for the child component render test.expectsAjaxCall('get') - .expectSentData({food: 'pizza'}) + .expectSentData(test.initialData) .serverWillChangeData((data: any) => { - data.food = 'popcorn'; + data.renderChild = false; }) - .willReturn(childTemplate) .init(); - // re-render *just* the child - userEvent.click(getByText(test.element, 'Render Child')); - await waitFor(() => expect(test.element).toHaveTextContent('Favorite food: popcorn')); + 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
` + : '' + } +
+ `); - // now let's re-render the parent test.expectsAjaxCall('get') .expectSentData(test.initialData) .serverWillChangeData((data: any) => { - data.count = 1; + data.renderChild = true; }) .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'); + 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('renders parent component AND replaces child component when changed parent data affects child data', async () => { - const childTemplate = (data: any) => ` -
- Child component count: ${data.childCount} + it('existing child component that has no props is ignored', async () => { + const originalChild = ` +
+ Original Child Component
`; - - const test = await createTest({ count: 0 }, (data: any) => ` -
- Parent component count: ${data.count} - - ${childTemplate({childCount: data.count})} + const updatedChild = ` +
+ Updated Child Component
- `); + `; - // change some content on the child - (document.getElementById('child-component') as HTMLElement).innerHTML = 'changed child content'; + const test = await createTest({useOriginalChild: true}, (data: any) => ` +
+ ${data.useOriginalChild ? originalChild : updatedChild} +
+ `); - // re-render the parent test.expectsAjaxCall('get') .expectSentData(test.initialData) .serverWillChangeData((data: any) => { - data.count = 1; + data.useOriginalChild = false; }) .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'); + expect(test.element).toHaveTextContent('Original Child Component') + test.component.render(); + // wait for Ajax call + await waitFor(() => expect(test.element).toHaveAttribute('busy')); + await waitFor(() => expect(test.element).not.toHaveAttribute('busy')); + // child component is STILL here: the new rendering was ignored + expect(test.element).toHaveTextContent('Original Child Component') }); - it('updates the parent model when the child model updates', async () => { + it('existing child component gets props & triggers re-render', async () => { const childTemplate = (data: any) => ` -
- - - Child Content: ${data.content} +
+ + Full Name: ${data.toUppercase ? data.fullName.toUpperCase() : data.fullName}
`; - const test = await createTest({ post: { content: 'i love'} }, (data: any) => ` -
- - Parent Post Content: ${data.post.content} - - ${childTemplate({content: data.post.content})} - -
- `); + // a simpler version of the child is returned from the prent component's re-render + const childReturnedFromParentCall = ` +
+ `; - // request for the child render + const test = await createTest({useOriginalChild: true}, (data: any) => ` +
+ Using Original child: ${data.useOriginalChild ? 'yes' : 'no'} + ${data.useOriginalChild + ? childTemplate({ fullName: 'Ryan', toUppercase: false }) + : childReturnedFromParentCall + } +
+ `); + + const childComponent = getComponent(getByTestId(test.element, 'child-component')); + // just used to mock the Ajax call + const childTest = createTestForExistingComponent(childComponent); + + /* + * The flow of this test: + * A) Original parent & child are rendered + * B) We type into a child "model" input, but it has "norender". + * So, no Ajax call is made, but the "data" on the child has been updated. + * C) We Re-render the parent component + * D) On re-render, the child element is empty, but its element has + * updated "props" and also a new "fingerprint". This mimics + * the condition on the server when the old child fingerprint + * that was just sent does not match the new fingerprint, indicating + * that data passed "into" the component to create it has changed. + * And so, the server returns the new set of props and the new + * fingerprint representing that "input" data. + * E) Seeing that its props have changed, the child component makes + * an Ajax call to re-render itself. But it keeps its modified data. + */ + + // B) Type into the child + // |norender is used on this field + userEvent.type(childTest.queryByDataModel('fullName'), ' Weaver'); + + // C) Re-render the parent test.expectsAjaxCall('get') - .expectSentData({content: 'i love turtles'}) + .expectSentData(test.initialData) + .serverWillChangeData((data: any) => { + data.useOriginalChild = false; + }) + .init(); + test.component.render(); + // wait for parent Ajax call to start + await waitFor(() => expect(test.element).toHaveAttribute('busy')); + + // E) Expect the child to re-render + // after the parent Ajax call has finished, but shortly before it's + // done processing, the child component should start its own Aja call + childTest.expectsAjaxCall('get') + // expect the modified firstName data + // expect the new prop + .expectSentData({ toUppercase: true, fullName: 'Ryan Weaver' }) .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')); + // wait for parent Ajax call to finish + await waitFor(() => expect(test.element).not.toHaveAttribute('busy')); + // sanity check + expect(test.element).toHaveTextContent('Using Original child: no') - // 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'); + // after the parent re-renders, the child should already have received its new fingerprint + expect(childComponent.fingerprint).toEqual('updated fingerprint'); + + // wait for child to start and stop loading + await waitFor(() => expect(childComponent.element).toHaveAttribute('busy')); + await waitFor(() => expect(childComponent.element).not.toHaveAttribute('busy')); + + // child component re-rendered and there are a few important things here + // 1) the toUppercase prop was changed after by the parent and that change remains + // 2) The " Weaver" change to the "firstName" data was kept, not "run over" + expect(childComponent.element).toHaveTextContent('Full Name: RYAN WEAVER') }); - it('uses data-model-map to map child models to parent models', async () => { - const childTemplate = (data: any) => ` -
- - - Child Content: ${data.value} -
+ it('existing child gets new props even though the element type differs', async () => { + const realChildTemplate = (data: any) => ` + + Child prop1: ${data.prop1} + + `; + // the empty-ish child element used on re-render + const parentReRenderedChildTemplate = (data: any) => ` +
`; - const test = await createTest({ post: { content: 'i love'} }, (data: any) => ` -
-
- ${childTemplate({value: data.post.content})} -
-
- `); + const test = await createTest({prop1: 'original_prop', useRealChild: true}, (data: any) => ` +
+ Parent Component + ${data.useRealChild ? realChildTemplate({data}) : parentReRenderedChildTemplate(data)} +
+ `); - // request for the child render + const childComponent = getComponent(getByTestId(test.element, 'child-component')); + // just used to mock the Ajax call + const childTest = createTestForExistingComponent(childComponent); + + // Re-render the parent test.expectsAjaxCall('get') - .expectSentData({ value: 'i love dragons' }) - .willReturn(childTemplate) + .expectSentData(test.initialData) + .serverWillChangeData((data: any) => { + // change the child prop + data.prop1 = 'updated_prop'; + // re-render the "fake" component with a different tag + data.useRealChild = false; + }) + .init(); + test.component.render(); + // wait for parent Ajax call to start + await waitFor(() => expect(test.element).toHaveAttribute('busy')); + + // Expect the child to re-render + childTest.expectsAjaxCall('get') + // expect the new prop + .expectSentData({ prop1: 'updated_prop' }) + .willReturn(realChildTemplate) .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'); + // wait for parent Ajax call to finish + await waitFor(() => expect(test.element).not.toHaveAttribute('busy')); + // wait for child to start and stop loading + await waitFor(() => expect(childComponent.element).toHaveAttribute('busy')); + await waitFor(() => expect(childComponent.element).not.toHaveAttribute('busy')); + + // child component re-rendered successfully + // re-grabbing child-component fresh to prove it's not stale + const childElement2 = getByTestId(test.element, 'child-component'); + expect(childElement2).toHaveTextContent('Child prop1: updated_prop') + // it should still be a span, even though the initial re-render was a div + expect(childElement2.tagName).toEqual('SPAN'); }); - 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} -
  • - ` - })} -
-
- `); + it('replaces old child with new child if the "id" changes', async () => { + const originalChildTemplate = ` + + Original Child + + `; + const reRenderedChildTemplate = ` + + New Child + + `; + + const test = await createTest({useOriginalChild: true}, (data: any) => ` +
+ Parent Component + ${data.useOriginalChild ? originalChildTemplate : reRenderedChildTemplate} +
+ `); + // Re-render the parent test.expectsAjaxCall('get') .expectSentData(test.initialData) .serverWillChangeData((data: any) => { - // "remove" child2 - data.children = [{ name: 'child1' }, { name: 'child3' }]; + // trigger the re-rendered child to be used + data.useOriginalChild = false; }) .init(); + test.component.render(); + // wait for parent Ajax call to start/finish + await waitFor(() => expect(test.element).toHaveAttribute('busy')); + await waitFor(() => expect(test.element).not.toHaveAttribute('busy')); - test.controller.$render(); + // no child Ajax call made: we simply use the new child's content + expect(test.element).toHaveTextContent('New Child') + expect(test.element).not.toHaveTextContent('Original Child') - 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'})); + expect(test.component.getChildren().size).toEqual(1); }); - it('notices as children are connected and disconnected', async () => { + it('tracks various children correctly, even if position changes', async () => { + const childTemplate = (data: any) => ` + + Child number: ${data.number} value "${data.value}" + + `; + // the empty-ish child element used on re-render + const childRenderedFromParentTemplate = (data: any) => ` + + `; + const test = await createTest({}, (data: any) => ` -
- Parent component -
- `); +
+ ${childTemplate({ number: 1, value: 'Original value for child 1' })} +
Parent Component
+ ${childTemplate({ number: 2, value: 'Original value for child 2' })} +
+ `); + + // Re-render the parent + test.expectsAjaxCall('get') + .expectSentData(test.initialData) + // return the template in a different order + // and render children with an updated value prop + .willReturn((data: any) => ` +
+
+ ${childRenderedFromParentTemplate({ number: 2, value: 'New value for child 2' })} +
+
Parent Component Updated
+
    +
  • + ${childRenderedFromParentTemplate({ number: 1, value: 'New value for child 1' })} +
  • +
+
+ `) + .init(); + test.component.render(); + // wait for parent Ajax call to start + await waitFor(() => expect(test.element).toHaveAttribute('busy')); + + const childComponent1 = getComponent(getByTestId(test.element, 'child-component-1')); + const childTest1 = createTestForExistingComponent(childComponent1); + const childComponent2 = getComponent(getByTestId(test.element, 'child-component-2')); + const childTest2 = createTestForExistingComponent(childComponent2); + + // Expect both children to re-render + childTest1.expectsAjaxCall('get') + // expect the new prop + .expectSentData({ number: 1, value: 'New value for child 1' }) + .willReturn(childTemplate) + .init(); + childTest2.expectsAjaxCall('get') + // expect the new prop + .expectSentData({ number: 2, value: 'New value for child 2' }) + .willReturn(childTemplate) + .init(); - 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); + // wait for parent Ajax call to finish + await waitFor(() => expect(test.element).not.toHaveAttribute('busy')); + // wait for child to start and stop loading + await waitFor(() => expect(childTest1.element).toHaveAttribute('busy')); + await waitFor(() => expect(childTest1.element).not.toHaveAttribute('busy')); + await waitFor(() => expect(childTest2.element).not.toHaveAttribute('busy')); + + expect(test.element).toHaveTextContent('Child number: 1 value "New value for child 1"'); + expect(test.element).toHaveTextContent('Child number: 2 value "New value for child 2"'); + expect(test.element).not.toHaveTextContent('Child number: 1 value "Original value for child 1"'); + expect(test.element).not.toHaveTextContent('Child number: 2 value "Original value for child 2"'); + // make sure child 2 is in the correct spot + expect(test.element.querySelector('#foo')).toHaveTextContent('Child number: 2 value "New value for child 2"'); + expect(test.component.getChildren().size).toEqual(2); }); }); diff --git a/src/LiveComponent/assets/test/controller/csrf.test.ts b/src/LiveComponent/assets/test/controller/csrf.test.ts index d61c7d99ce2..78480eaa930 100644 --- a/src/LiveComponent/assets/test/controller/csrf.test.ts +++ b/src/LiveComponent/assets/test/controller/csrf.test.ts @@ -9,17 +9,17 @@ 'use strict'; -import { createTest, initComponent, shutdownTest } from '../tools'; +import { createTest, initComponent, shutdownTests } from '../tools'; import { getByText, waitFor } from '@testing-library/dom'; describe('LiveController CSRF Tests', () => { afterEach(() => { - shutdownTest(); + shutdownTests(); }) it('Sends the CSRF token on an action', async () => { const test = await createTest({ isSaved: 0 }, (data: any) => ` -
+
${data.isSaved ? 'Saved' : ''}