diff --git a/src/Autocomplete/assets/dist/controller.js b/src/Autocomplete/assets/dist/controller.js index a25187c2374..a559b224749 100644 --- a/src/Autocomplete/assets/dist/controller.js +++ b/src/Autocomplete/assets/dist/controller.js @@ -1,7 +1,7 @@ import { Controller } from '@hotwired/stimulus'; import TomSelect from 'tom-select'; -/*! ***************************************************************************** +/****************************************************************************** Copyright (c) Microsoft Corporation. Permission to use, copy, modify, and/or distribute this software for any @@ -42,6 +42,9 @@ class default_1 extends Controller { } this.tomSelect = __classPrivateFieldGet(this, _instances, "m", _createAutocomplete).call(this); } + disconnect() { + this.tomSelect.destroy(); + } get selectElement() { if (!(this.element instanceof HTMLSelectElement)) { return null; diff --git a/src/Autocomplete/src/Resources/doc/index.rst b/src/Autocomplete/src/Resources/doc/index.rst index e6a3e47661a..00a98563b71 100644 --- a/src/Autocomplete/src/Resources/doc/index.rst +++ b/src/Autocomplete/src/Resources/doc/index.rst @@ -447,7 +447,7 @@ outside of the Form component. For example: + + + +``` + +- [BC BREAK] The `live#updateDefer` action was removed entirely. + Now, to update a model without triggering a re-render, use the + `norender` modifier for `data-model`: + +```twig + + + + + +``` + +- [BC BREAK] The `name` attribute is no longer automatically used to + update a model when a parent component has `data-action="change->live#update"`. + To make a form's fields behave like "model" fields (but using the + `name` attribute instead of `data-model`) you need to add a `data-model` + attribute to the `
` element around your fields (NOTE: the + new attribute is automatically added to your `form` element when + using `ComponentWithFormTrait`): + +```twig + + + +
+ + +
+ +
+``` + ## 2.2.0 - The bundle now properly exposes a `live` controller, which can be diff --git a/src/LiveComponent/assets/dist/live_controller.js b/src/LiveComponent/assets/dist/live_controller.js index 6bc2d23fb15..d75ccabbe7a 100644 --- a/src/LiveComponent/assets/dist/live_controller.js +++ b/src/LiveComponent/assets/dist/live_controller.js @@ -1,5 +1,33 @@ import { Controller } from '@hotwired/stimulus'; +/****************************************************************************** +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 __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; +} + var DOCUMENT_FRAGMENT_NODE = 11; function morphAttrs(fromNode, toNode) { @@ -897,44 +925,6 @@ function combineSpacedArray(parts) { }); return finalParts; } - -function parseDeepData(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(); - 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 property used in data-model="${propertyPath}" was never initialized. Did you forget to add exposed={"${lastPart}"} to its LiveProp?`); - } - else { - throw new Error(`The property used in data-model="${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 doesDeepPropertyExist(data, propertyPath) { - const parts = propertyPath.split('.'); - return data[parts[0]] !== undefined; -} function normalizeModelName(model) { return model .replace(/\[]$/, '') @@ -973,7 +963,7 @@ function haveRenderedValuesChanged(originalDataJson, currentDataJson, newDataJso function normalizeAttributesForComparison(element) { const isFileInput = element instanceof HTMLInputElement && element.type === 'file'; if (!isFileInput) { - if (element.value) { + if ('value' in element) { element.setAttribute('value', element.value); } else if (element.hasAttribute('value')) { @@ -985,6 +975,145 @@ 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?`); + } + 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; +} + +class ValueStore { + constructor(liveController) { + 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); + 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); + } +} + +function getValueFromInput(element, valueStore) { + if (element instanceof HTMLInputElement) { + if (element.type === 'checkbox') { + const modelNameData = getModelDirectiveFromInput(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 getModelDirectiveFromInput(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; + } + if (element.getAttribute('name')) { + const formElement = element.closest('form'); + if (formElement && formElement.dataset.model) { + 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 elementBelongsToThisController(element, controller) { + if (controller.element !== element && !controller.element.contains(element)) { + return false; + } + let foundChildController = false; + controller.childComponentControllers.forEach((childComponentController) => { + if (foundChildController) { + return; + } + if (childComponentController.element === element || childComponentController.element.contains(element)) { + foundChildController = true; + } + }); + return !foundChildController; +} function cloneHTMLElement(element) { const newElement = element.cloneNode(true); if (!(newElement instanceof HTMLElement)) { @@ -992,31 +1121,73 @@ function cloneHTMLElement(element) { } return newElement; } - -function updateArrayDataFromChangedElement(element, value, currentValues) { - if (!(currentValues instanceof Array)) { - currentValues = []; +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 (element instanceof HTMLInputElement && element.type === 'checkbox') { - const index = currentValues.indexOf(value); - if (element.checked) { - if (index === -1) { - currentValues.push(value); - } - return currentValues; - } - if (index > -1) { - currentValues.splice(index, 1); + if (!(child instanceof HTMLElement)) { + throw new Error(`Created element is not an Element from HTML: ${html.trim()}`); + } + 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 (element instanceof HTMLSelectElement) { - currentValues = Array.from(element.selectedOptions).map(el => el.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); + _UnsyncedInputContainer_unmappedFields.set(this, []); + __classPrivateFieldSet(this, _UnsyncedInputContainer_mappedFields, new Map(), "f"); + } + add(element, modelName = null) { + if (modelName) { + __classPrivateFieldGet(this, _UnsyncedInputContainer_mappedFields, "f").set(modelName, element); + return; + } + __classPrivateFieldGet(this, _UnsyncedInputContainer_unmappedFields, "f").push(element); + } + all() { + return [...__classPrivateFieldGet(this, _UnsyncedInputContainer_unmappedFields, "f"), ...__classPrivateFieldGet(this, _UnsyncedInputContainer_mappedFields, "f").values()]; + } + clone() { + const container = new UnsyncedInputContainer(); + __classPrivateFieldSet(container, _UnsyncedInputContainer_mappedFields, new Map(__classPrivateFieldGet(this, _UnsyncedInputContainer_mappedFields, "f")), "f"); + __classPrivateFieldSet(container, _UnsyncedInputContainer_unmappedFields, [...__classPrivateFieldGet(this, _UnsyncedInputContainer_unmappedFields, "f")], "f"); + return container; + } + allMappedFields() { + return __classPrivateFieldGet(this, _UnsyncedInputContainer_mappedFields, "f"); + } + remove(modelName) { + __classPrivateFieldGet(this, _UnsyncedInputContainer_mappedFields, "f").delete(modelName); } - throw new Error(`The element used to determine array data from is unsupported (${element.tagName} provided)`); } +_UnsyncedInputContainer_mappedFields = new WeakMap(), _UnsyncedInputContainer_unmappedFields = new WeakMap(); +var _PromiseStack_instances, _PromiseStack_findPromiseIndex; const DEFAULT_DEBOUNCE = 150; class default_1 extends Controller { constructor() { @@ -1028,13 +1199,22 @@ class default_1 extends Controller { this.isWindowUnloaded = false; this.originalDataJSON = '{}'; this.mutationObserver = null; + this.childComponentControllers = []; + this.pendingActionTriggerModelElement = null; this.markAsWindowUnloaded = () => { this.isWindowUnloaded = true; }; } initialize() { this.markAsWindowUnloaded = this.markAsWindowUnloaded.bind(this); - this.originalDataJSON = JSON.stringify(this.dataValue); + 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(); } connect() { @@ -1047,28 +1227,32 @@ class default_1 extends Controller { } window.addEventListener('beforeunload', this.markAsWindowUnloaded); this._startAttributesMutationObserver(); - this.element.addEventListener('live:update-model', (event) => { - if (event.target === this.element) { - return; - } - this._handleChildComponentUpdateModel(event); - }); - this._dispatchEvent('live:connect'); + this.element.addEventListener('live:update-model', this.handleUpdateModelEvent); + this.element.addEventListener('input', this.handleInputEvent); + this.element.addEventListener('change', this.handleChangeEvent); + this.element.addEventListener('live:connect', this.handleConnectedControllerEvent); + this._dispatchEvent('live:connect', { controller: this }); } disconnect() { this.pollingIntervals.forEach((interval) => { clearInterval(interval); }); window.removeEventListener('beforeunload', this.markAsWindowUnloaded); + 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(); } } update(event) { - this._updateModelFromElement(event.target, this._getValueFromElement(event.target), true); - } - updateDefer(event) { - this._updateModelFromElement(event.target, this._getValueFromElement(event.target), false); + 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._updateModelFromElement(event.target, null); } action(event) { const rawAction = event.currentTarget.dataset.actionName; @@ -1093,7 +1277,7 @@ class default_1 extends Controller { } break; case 'debounce': { - const length = modifier.value ? parseInt(modifier.value) : DEFAULT_DEBOUNCE; + const length = modifier.value ? parseInt(modifier.value) : this.getDefaultDebounce(); if (this.actionDebounceTimeout) { clearTimeout(this.actionDebounceTimeout); this.actionDebounceTimeout = null; @@ -1110,6 +1294,14 @@ class default_1 extends Controller { } }); if (!handled) { + if (getModelDirectiveFromInput(event.currentTarget, false)) { + this.pendingActionTriggerModelElement = event.currentTarget; + window.setTimeout(() => { + this.pendingActionTriggerModelElement = null; + _executeAction(); + }, 10); + return; + } _executeAction(); } }); @@ -1117,48 +1309,74 @@ class default_1 extends Controller { $render() { this._makeRequest(null, {}); } - _getValueFromElement(element) { - return element.dataset.value || element.value; - } - _updateModelFromElement(element, value, shouldRender) { + _updateModelFromElement(element, eventName) { + if (!elementBelongsToThisController(element, this)) { + return; + } if (!(element instanceof HTMLElement)) { throw new Error('Could not update model for non HTMLElement'); } - const model = element.dataset.model || element.getAttribute('name'); - if (!model) { - const clonedElement = cloneHTMLElement(element); - throw new Error(`The update() method could not be called for "${clonedElement.outerHTML}": the element must either have a "data-model" or "name" attribute set to the model name.`); + const modelDirective = getModelDirectiveFromInput(element, false); + if (eventName === 'input') { + const modelName = modelDirective ? modelDirective.action : null; + this.renderPromiseStack.addModifiedElement(element, modelName); + this.unsyncedInputs.add(element, modelName); } - let finalValue = value; - if (/\[]$/.test(model)) { - const { currentLevelData, finalKey } = parseDeepData(this.dataValue, normalizeModelName(model)); - const currentValue = currentLevelData[finalKey]; - finalValue = updateArrayDataFromChangedElement(element, value, currentValue); + if (!modelDirective) { + return; } - else if (element instanceof HTMLInputElement - && element.type === 'checkbox' - && !element.checked) { - finalValue = null; + 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 (this.pendingActionTriggerModelElement === element) { + shouldRender = false; } - this.$updateModel(model, finalValue, shouldRender, element.hasAttribute('name') ? element.getAttribute('name') : null, {}); - } - $updateModel(model, value, shouldRender = true, extraModelName = null, options = {}) { - const directives = parseDirectives(model); - if (directives.length > 1) { - throw new Error(`The data-model="${model}" format is invalid: it does not support multiple directives (i.e. remove any spaces).`); + if (eventName && targetEventName !== eventName) { + return; } - const directive = directives[0]; - if (directive.args.length > 0 || directive.named.length > 0) { - throw new Error(`The data-model="${model}" format is invalid: it does not support passing arguments to the model.`); + if (null === debounce) { + if (targetEventName === 'input') { + debounce = this.getDefaultDebounce(); + } + else { + debounce = 0; + } } - const modelName = normalizeModelName(directive.action); + const finalValue = getValueFromInput(element, this.valueStore); + this.$updateModel(modelDirective.action, finalValue, shouldRender, element.hasAttribute('name') ? element.getAttribute('name') : null, { + debounce + }); + } + $updateModel(model, value, shouldRender = true, extraModelName = null, options = {}) { + const modelName = normalizeModelName(model); const normalizedExtraModelName = extraModelName ? normalizeModelName(extraModelName) : null; - if (this.dataValue.validatedFields !== undefined) { - const validatedFields = [...this.dataValue.validatedFields]; + if (this.valueStore.has('validatedFields')) { + const validatedFields = [...this.valueStore.get('validatedFields')]; if (validatedFields.indexOf(modelName) === -1) { validatedFields.push(modelName); } - this.dataValue = setDeepData(this.dataValue, 'validatedFields', validatedFields); + this.valueStore.set('validatedFields', validatedFields); } if (options.dispatch !== false) { this._dispatchEvent('live:update-model', { @@ -1167,19 +1385,18 @@ class default_1 extends Controller { value }); } - this.dataValue = setDeepData(this.dataValue, modelName, value); - directive.modifiers.forEach((modifier => { - switch (modifier.name) { - default: - throw new Error(`Unknown modifier ${modifier.name} used in data-model="${model}"`); - } - })); + this.valueStore.set(modelName, value); + this.unsyncedInputs.remove(modelName); if (shouldRender) { this._clearWaitingDebouncedRenders(); + let debounce = this.getDefaultDebounce(); + if (options.debounce !== undefined && options.debounce !== null) { + debounce = options.debounce; + } this.renderDebounceTimeout = window.setTimeout(() => { this.renderDebounceTimeout = null; this.$render(); - }, this.debounceValue || DEFAULT_DEBOUNCE); + }, debounce); } } _makeRequest(action, args) { @@ -1202,7 +1419,7 @@ class default_1 extends Controller { } let dataAdded = false; if (!action) { - const dataJson = JSON.stringify(this.dataValue); + const dataJson = this.valueStore.asJson(); if (this._willDataFitInUrl(dataJson, params)) { params.set('data', dataJson); fetchOptions.method = 'GET'; @@ -1211,13 +1428,14 @@ class default_1 extends Controller { } if (!dataAdded) { fetchOptions.method = 'POST'; - fetchOptions.body = JSON.stringify(this.dataValue); + fetchOptions.body = this.valueStore.asJson(); fetchOptions.headers['Content-Type'] = 'application/json'; } this._onLoadingStart(); const paramsString = params.toString(); const thisPromise = fetch(`${url}${paramsString.length > 0 ? `?${paramsString}` : ''}`, fetchOptions); - this.renderPromiseStack.addPromise(thisPromise); + const reRenderPromise = new ReRenderPromise(thisPromise, this.unsyncedInputs.clone()); + this.renderPromiseStack.addPromise(reRenderPromise); thisPromise.then((response) => { if (this.renderDebounceTimeout) { return; @@ -1225,12 +1443,12 @@ class default_1 extends Controller { const isMostRecent = this.renderPromiseStack.removePromise(thisPromise); if (isMostRecent) { response.text().then((html) => { - this._processRerender(html, response); + this._processRerender(html, response, reRenderPromise.unsyncedInputContainer); }); } }); } - _processRerender(html, response) { + _processRerender(html, response, unsyncedInputContainer) { if (this.isWindowUnloaded) { return; } @@ -1247,7 +1465,16 @@ class default_1 extends Controller { return; } this._onLoadingFinish(); - this._executeMorphdom(html); + const modifiedModelValues = {}; + if (unsyncedInputContainer.allMappedFields().size > 0) { + for (const [modelName] of unsyncedInputContainer.allMappedFields()) { + modifiedModelValues[modelName] = this.valueStore.get(modelName); + } + } + this._executeMorphdom(html, unsyncedInputContainer.all()); + Object.keys(modifiedModelValues).forEach((modelName) => { + this.valueStore.set(modelName, modifiedModelValues[modelName]); + }); } _clearWaitingDebouncedRenders() { if (this.renderDebounceTimeout) { @@ -1368,23 +1595,16 @@ class default_1 extends Controller { const urlEncodedJsonData = new URLSearchParams(dataJson).toString(); return (urlEncodedJsonData + params.toString()).length < 1500; } - _executeMorphdom(newHtml) { - 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'); - } - return child; - } + _executeMorphdom(newHtml, modifiedElements) { const newElement = htmlToElement(newHtml); morphdom(this.element, newElement, { onBeforeElUpdated: (fromEl, toEl) => { if (!(fromEl instanceof HTMLElement) || !(toEl instanceof HTMLElement)) { return false; } + if (modifiedElements.includes(fromEl)) { + return false; + } if (fromEl.isEqualNode(toEl)) { const normalizedFromEl = cloneHTMLElement(fromEl); normalizeAttributesForComparison(normalizedFromEl); @@ -1409,6 +1629,42 @@ class default_1 extends Controller { }); this._exposeOriginalData(); } + handleConnectedControllerEvent(event) { + if (event.target === this.element) { + return; + } + 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); + } + } + handleUpdateModelEvent(event) { + if (event.target === this.element) { + return; + } + this._handleChildComponentUpdateModel(event); + } + handleInputEvent(event) { + const target = event.target; + if (!target) { + return; + } + this._updateModelFromElement(target, 'input'); + } + handleChangeEvent(event) { + const target = event.target; + if (!target) { + return; + } + this._updateModelFromElement(target, 'change'); + } _initiatePolling(rawPollConfig) { const directives = parseDirectives(rawPollConfig || '$render'); directives.forEach((directive) => { @@ -1451,15 +1707,17 @@ class default_1 extends Controller { return this.element.dispatchEvent(new CustomEvent(name, { bubbles: canBubble, cancelable, - detail: payload, + detail: payload })); } _handleChildComponentUpdateModel(event) { const mainModelName = event.detail.modelName; const potentialModelNames = [ { name: mainModelName, required: false }, - { name: event.detail.extraModelName, 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); @@ -1492,7 +1750,7 @@ class default_1 extends Controller { if (foundModelName) { return; } - if (doesDeepPropertyExist(this.dataValue, potentialModel.name)) { + if (this.valueStore.hasAtTopLevel(potentialModel.name)) { foundModelName = potentialModel.name; return; } @@ -1526,10 +1784,14 @@ class default_1 extends Controller { this.element.dataset.originalData = this.originalDataJSON; } _startAttributesMutationObserver() { + if (!(this.element instanceof HTMLElement)) { + throw new Error('Invalid Element Type'); + } + const element = this.element; this.mutationObserver = new MutationObserver((mutations) => { mutations.forEach((mutation) => { - if (mutation.type === 'attributes' && !this.element.dataset.originalData) { - this.originalDataJSON = JSON.stringify(this.dataValue); + if (mutation.type === 'attributes' && !element.dataset.originalData) { + this.originalDataJSON = this.valueStore.asJson(); this._exposeOriginalData(); } }); @@ -1538,6 +1800,9 @@ class default_1 extends Controller { attributes: true }); } + getDefaultDebounce() { + return this.hasDebounceValue ? this.debounceValue : DEFAULT_DEBOUNCE; + } } default_1.values = { url: String, @@ -1547,13 +1812,14 @@ default_1.values = { }; class PromiseStack { constructor() { + _PromiseStack_instances.add(this); this.stack = []; } - addPromise(promise) { - this.stack.push(promise); + addPromise(reRenderPromise) { + this.stack.push(reRenderPromise); } removePromise(promise) { - const index = this.findPromiseIndex(promise); + const index = __classPrivateFieldGet(this, _PromiseStack_instances, "m", _PromiseStack_findPromiseIndex).call(this, promise); if (index === -1) { return false; } @@ -1561,12 +1827,26 @@ class PromiseStack { this.stack.splice(0, index + 1); return isMostRecent; } - findPromiseIndex(promise) { - return this.stack.findIndex((item) => item === promise); - } countActivePromises() { return this.stack.length; } + addModifiedElement(element, modelName = null) { + this.stack.forEach((reRenderPromise) => { + reRenderPromise.addModifiedElement(element, modelName); + }); + } +} +_PromiseStack_instances = new WeakSet(), _PromiseStack_findPromiseIndex = function _PromiseStack_findPromiseIndex(promise) { + return this.stack.findIndex((item) => item.promise === promise); +}; +class ReRenderPromise { + constructor(promise, unsyncedInputContainer) { + this.promise = promise; + this.unsyncedInputContainer = unsyncedInputContainer; + } + addModifiedElement(element, modelName = null) { + this.unsyncedInputContainer.add(element, modelName); + } } const parseLoadingAction = function (action, isLoading) { switch (action) { diff --git a/src/LiveComponent/assets/src/UnsyncedInputContainer.ts b/src/LiveComponent/assets/src/UnsyncedInputContainer.ts new file mode 100644 index 00000000000..f545bf6b214 --- /dev/null +++ b/src/LiveComponent/assets/src/UnsyncedInputContainer.ts @@ -0,0 +1,38 @@ +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()] + } + + clone(): UnsyncedInputContainer { + const container = new UnsyncedInputContainer(); + container.#mappedFields = new Map(this.#mappedFields); + container.#unmappedFields = [...this.#unmappedFields]; + + return container; + } + + allMappedFields(): Map { + return this.#mappedFields; + } + + remove(modelName: string) { + this.#mappedFields.delete(modelName); + } +} diff --git a/src/LiveComponent/assets/src/ValueStore.ts b/src/LiveComponent/assets/src/ValueStore.ts new file mode 100644 index 00000000000..ea8bf051cbe --- /dev/null +++ b/src/LiveComponent/assets/src/ValueStore.ts @@ -0,0 +1,52 @@ +import { getDeepData, setDeepData } from './data_manipulation_utils'; +import { LiveController } from './live_controller'; +import { normalizeModelName } from './string_utils'; + +export default class { + controller: LiveController; + + 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); + + 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); + } +} diff --git a/src/LiveComponent/assets/src/clone_html_element.ts b/src/LiveComponent/assets/src/clone_html_element.ts deleted file mode 100644 index b4ea6b5eecc..00000000000 --- a/src/LiveComponent/assets/src/clone_html_element.ts +++ /dev/null @@ -1,8 +0,0 @@ -export function cloneHTMLElement(element: HTMLElement): HTMLElement { - const newElement = element.cloneNode(true); - if (!(newElement instanceof HTMLElement)) { - throw new Error('Could not clone element'); - } - - return newElement; -} diff --git a/src/LiveComponent/assets/src/set_deep_data.ts b/src/LiveComponent/assets/src/data_manipulation_utils.ts similarity index 53% rename from src/LiveComponent/assets/src/set_deep_data.ts rename to src/LiveComponent/assets/src/data_manipulation_utils.ts index 36d18187895..ac37301da5f 100644 --- a/src/LiveComponent/assets/src/set_deep_data.ts +++ b/src/LiveComponent/assets/src/data_manipulation_utils.ts @@ -1,5 +1,11 @@ +export function getDeepData(data: any, propertyPath: string) { + const { currentLevelData, finalKey } = parseDeepData(data, propertyPath); + + return currentLevelData[finalKey]; +} + // post.user.username -export function parseDeepData(data, propertyPath) { +const parseDeepData = function(data: any, propertyPath: string) { const finalData = JSON.parse(JSON.stringify(data)); let currentLevelData = finalData; @@ -22,7 +28,7 @@ export function parseDeepData(data, propertyPath) { } // post.user.username -export function setDeepData(data, propertyPath, value) { +export function setDeepData(data: any, propertyPath: string, value: any): any { const { currentLevelData, finalData, finalKey, parts } = parseDeepData(data, propertyPath) // make sure the currentLevelData is an object, not a scalar @@ -32,6 +38,11 @@ export function setDeepData(data, propertyPath, value) { // an integer (2). 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?`) } @@ -41,9 +52,9 @@ export function setDeepData(data, propertyPath, value) { if (currentLevelData[finalKey] === undefined) { const lastPart = parts.pop(); if (parts.length > 0) { - throw new Error(`The property used in data-model="${propertyPath}" was never initialized. Did you forget to add exposed={"${lastPart}"} to its LiveProp?`) + 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 property used in data-model="${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)'}`) + 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)'}`) } } @@ -51,38 +62,3 @@ export function setDeepData(data, propertyPath, value) { return finalData; } - -/** - * Checks if the given propertyPath is for a valid top-level key. - * - * @param {Object} data - * @param {string} propertyPath - * @return {boolean} - */ -export function doesDeepPropertyExist(data, propertyPath) { - const parts = propertyPath.split('.'); - - return data[parts[0]] !== undefined; -} - -/** - * Normalizes model names with [] into the "." syntax. - * - * For example: "user[firstName]" becomes "user.firstName" - * - * @param {string} model - * @return {string} - */ -export function normalizeModelName(model) { - return model - // Names ending in "[]" represent arrays in HTML. - // To get normalized name we need to ignore this part. - // For example: "user[mailing][]" becomes "user.mailing" (and has array typed value) - .replace(/\[]$/, '') - .split('[') - // ['object', 'foo', 'bar', 'ya'] - .map(function (s) { - return s.replace(']', '') - }) - .join('.') -} diff --git a/src/LiveComponent/assets/src/directives_parser.ts b/src/LiveComponent/assets/src/directives_parser.ts index b7669ee8a4b..6f278b9de07 100644 --- a/src/LiveComponent/assets/src/directives_parser.ts +++ b/src/LiveComponent/assets/src/directives_parser.ts @@ -56,7 +56,7 @@ export interface Directive { * * @param {string} content The value of the attribute */ -export function parseDirectives(content: string): Directive[] { +export function parseDirectives(content: string|null): Directive[] { const directives: Directive[] = []; if (!content) { diff --git a/src/LiveComponent/assets/src/dom_utils.ts b/src/LiveComponent/assets/src/dom_utils.ts new file mode 100644 index 00000000000..1fee876ae6d --- /dev/null +++ b/src/LiveComponent/assets/src/dom_utils.ts @@ -0,0 +1,191 @@ +import ValueStore from './ValueStore'; +import { Directive, parseDirectives } from './directives_parser'; +import { LiveController } from './live_controller'; +import { normalizeModelName } from './string_utils'; + +/** + * Return the "value" of any given element. + * + * This takes into account that the element may be a "multiple" + * value input, like an where there are multiple + * elements. In those cases, it will return the "full", final value + * for the model, which includes previously-selected values. + */ +export function getValueFromInput(element: HTMLElement, valueStore: ValueStore): string|string[]|null { + if (element instanceof HTMLInputElement) { + if (element.type === 'checkbox') { + const modelNameData = getModelDirectiveFromInput(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) { + // Select elements with `multiple` option require mapping chosen options to their values + return Array.from(element.selectedOptions).map(el => el.value); + } + + return element.value; + } + + // element is some other element + if (element.dataset.value) { + return element.dataset.value; + } + + // e.g. a textarea + if ('value' in element) { + // the "as" is a cheap way to hint to TypeScript that the value propery exists + return (element as HTMLInputElement).value; + } + + if (element.hasAttribute('value')) { + return element.getAttribute('value'); + } + + return null; +} + +export function getModelDirectiveFromInput(element: HTMLElement, throwOnMissing = true): null|Directive { + 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; + } + + if (element.getAttribute('name')) { + const formElement = element.closest('form'); + // require a around elements in order to + // activate automatic "data binding" via the "name" attribute + if (formElement && formElement.dataset.model) { + 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.`); + } + + // use the actual field's name as the "action" + directive.action = normalizeModelName(element.getAttribute('name') as string); + + 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 ).`); +} + +/** + * Does the given element "belong" to the given live controller. + * + * To "belong" the element needs to: + * A) Live inside the controller element (of course) + * B) NOT also live inside a child "live controller" element + */ +export function elementBelongsToThisController(element: Element, controller: LiveController): boolean { + if (controller.element !== element && !controller.element.contains(element)) { + return false; + } + + let foundChildController = false; + controller.childComponentControllers.forEach((childComponentController) => { + if (foundChildController) { + // return early + return; + } + + if (childComponentController.element === element || childComponentController.element.contains(element)) { + foundChildController = true; + } + }); + + return !foundChildController; +} + +export function cloneHTMLElement(element: HTMLElement): HTMLElement { + const newElement = element.cloneNode(true); + if (!(newElement instanceof HTMLElement)) { + throw new Error('Could not clone element'); + } + + return newElement; +} + +// https://stackoverflow.com/questions/494143/creating-a-new-dom-element-from-an-html-string-using-built-in-dom-methods-or-pro#answer-35385518 +export function htmlToElement(html: string): HTMLElement { + const template = document.createElement('template'); + html = html.trim(); + template.innerHTML = html; + + const child = template.content.firstChild; + if (!child) { + throw new Error('Child not found'); + } + + // enforcing this for type simplicity: in practice, this is only use for HTMLElements + if (!(child instanceof HTMLElement)) { + throw new Error(`Created element is not an Element from HTML: ${html.trim()}`); + } + + return child; +} + +/** + * Returns just the outer element's HTML as a string - useful for error messages. + * + * For example: + *
And text inside

more text

+ * + * Would return: + *
+ */ +export function getElementAsTagText(element: HTMLElement): string { + return element.innerHTML ? element.outerHTML.slice(0, element.outerHTML.indexOf(element.innerHTML)) : element.outerHTML; +} + +const getMultipleCheckboxValue = function(element: HTMLInputElement, currentValues: Array): Array { + const value = inputValue(element); + const index = currentValues.indexOf(value); + + if (element.checked) { + // Add value to an array if it's not in it already + if (index === -1) { + currentValues.push(value); + } + + return currentValues; + } + + // Remove value from an array + if (index > -1) { + currentValues.splice(index, 1); + } + + return currentValues; +} + +const inputValue = function(element: HTMLInputElement): string { + return element.dataset.value ? element.dataset.value : element.value; +} diff --git a/src/LiveComponent/assets/src/have_rendered_values_changed.ts b/src/LiveComponent/assets/src/have_rendered_values_changed.ts index 4643f4ffebb..abeabd50d9a 100644 --- a/src/LiveComponent/assets/src/have_rendered_values_changed.ts +++ b/src/LiveComponent/assets/src/have_rendered_values_changed.ts @@ -1,4 +1,4 @@ -export function haveRenderedValuesChanged(originalDataJson: string, currentDataJson: string, newDataJson: string) { +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 diff --git a/src/LiveComponent/assets/src/live_controller.ts b/src/LiveComponent/assets/src/live_controller.ts index 0c46700dfb2..c8035552040 100644 --- a/src/LiveComponent/assets/src/live_controller.ts +++ b/src/LiveComponent/assets/src/live_controller.ts @@ -1,23 +1,34 @@ import { Controller } from '@hotwired/stimulus'; import morphdom from 'morphdom'; import { parseDirectives, Directive } from './directives_parser'; -import { combineSpacedArray } from './string_utils'; -import { setDeepData, doesDeepPropertyExist, normalizeModelName, parseDeepData } from './set_deep_data'; +import { combineSpacedArray, normalizeModelName } from './string_utils'; import { haveRenderedValuesChanged } from './have_rendered_values_changed'; import { normalizeAttributesForComparison } from './normalize_attributes_for_comparison'; -import { cloneHTMLElement } from './clone_html_element'; -import { updateArrayDataFromChangedElement } from "./update_array_data"; +import ValueStore from './ValueStore'; +import { elementBelongsToThisController, getModelDirectiveFromInput, getValueFromInput, cloneHTMLElement, htmlToElement, getElementAsTagText } from './dom_utils'; +import UnsyncedInputContainer from './UnsyncedInputContainer'; interface ElementLoadingDirectives { element: HTMLElement|SVGElement, directives: Directive[] } +interface UpdateModelOptions { + dispatch?: boolean; + debounce?: number|null; +} + declare const Turbo: any; const DEFAULT_DEBOUNCE = 150; -export default class extends Controller { +export interface LiveController { + dataValue: any; + element: Element, + childComponentControllers: Array +} + +export default class extends Controller implements LiveController { static values = { url: String, data: Object, @@ -34,6 +45,9 @@ export default class extends Controller { dataValue!: any; readonly csrfValue!: string; readonly debounceValue!: number; + readonly hasDebounceValue: boolean; + + valueStore!: ValueStore; /** * The current "timeout" that's waiting before a model update @@ -62,9 +76,25 @@ export default class extends Controller { mutationObserver: MutationObserver|null = null; + /** + * Input fields that have "changed", but whose model value hasn't been set yet. + */ + unsyncedInputs!: UnsyncedInputContainer; + + childComponentControllers: Array = []; + + pendingActionTriggerModelElement: HTMLElement|null = null; + initialize() { this.markAsWindowUnloaded = this.markAsWindowUnloaded.bind(this); - this.originalDataJSON = JSON.stringify(this.dataValue); + 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(); } @@ -83,19 +113,13 @@ export default class extends Controller { } window.addEventListener('beforeunload', this.markAsWindowUnloaded); - 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.element.addEventListener('live:update-model', (event) => { - // ignore events that we dispatched - if (event.target === this.element) { - return; - } - - this._handleChildComponentUpdateModel(event); - }); - - this._dispatchEvent('live:connect'); + this._dispatchEvent('live:connect', { controller: this }); } disconnect() { @@ -104,6 +128,13 @@ export default class extends Controller { }); window.removeEventListener('beforeunload', this.markAsWindowUnloaded); + 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(); @@ -111,14 +142,16 @@ export default class extends Controller { } /** - * Called to update one piece of the model + * Called to update one piece of the model. + * + * - - -
- `; - afterEach(() => { - clearDOM(); - if (!fetchMock.done()) { - throw new Error('Mocked requests did not match'); - } - fetchMock.reset(); - }); + shutdownTest(); + }) - it('Sends an action and cancels any re-renders', async () => { - const data = { comments: 'hi' }; - const { element } = await startStimulus(template(data)); + it('sends an action and cancels pending (debounce) re-renders', async () => { + const test = await createTest({ comment: '', isSaved: false }, (data: any) => ` +
+ - // ONLY a post is sent, not a re-render GET - const postMock = fetchMock.postOnce( - 'http://localhost/_components/my_component/save', - template({ comments: 'hi weaver', isSaved: true }) - ); + ${data.isSaved ? 'Comment Saved!' : ''} - await userEvent.type(getByLabelText(element, 'Comments:'), ' WEAVER'); + +
+ `); - getByText(element, 'Save').click(); + // ONLY a post is sent, not a re-render GET + test.expectsAjaxCall('post') + .expectSentData({ + comment: 'great turtles!', + isSaved: false + }) + .expectActionCalled('save') + .serverWillChangeData((data: any) => { + // server marks component as "saved" + data.isSaved = true; + }) + .init(); - await waitFor(() => expect(element).toHaveTextContent('Comment Saved!')); - expect(getByLabelText(element, 'Comments:')).toHaveValue('hi weaver'); + await userEvent.type(test.queryByDataModel('comment'), 'great turtles!'); + getByText(test.element, 'Save').click(); - const bodyData = JSON.parse(postMock.lastOptions().body); - expect(bodyData.comments).toEqual('hi WEAVER'); + await waitFor(() => expect(test.element).toHaveTextContent('Comment Saved!')); }); - it('Sends action named args', async () => { - const data = { comments: 'hi' }; - const { element } = await startStimulus(template(data)); + it('Sends action with named args', async () => { + const test = await createTest({ isSaved: false}, (data: any) => ` +
+ ${data.isSaved ? 'Component Saved!' : ''} + + +
+ `); + + // ONLY a post is sent, not a re-render GET + test.expectsAjaxCall('post') + .expectSentData({ isSaved: false }) + .expectActionCalled('sendNamedArgs', {a: 1, b: 2, c: 3}) + .serverWillChangeData((data: any) => { + // server marks component as "saved" + data.isSaved = true; + }) + .init(); - fetchMock.postOnce('http://localhost/_components/my_component/sendNamedArgs?args=a%3D1%26b%3D2%26c%3D3', { - html: template({ comments: 'hi' }), - }); + getByText(test.element, 'Send named args').click(); - getByText(element, 'Send named args').click(); + await waitFor(() => expect(test.element).toHaveTextContent('Component Saved!')); }); + + it('sends an action but allows for the model to be updated', async () => { + const test = await createTest({ food: '' }, (data: any) => ` +
+ + + Food: ${data.food} +
+ `); + + // ONLY a post is sent + // the re-render GET from "input" of the select should be avoided + // because an action immediately happens + test.expectsAjaxCall('post') + .expectSentData({ food: 'pizza' }) + .expectActionCalled('changeFood') + .init(); + + await userEvent.selectOptions(test.queryByDataModel('food'), 'pizza'); + + await waitFor(() => expect(test.element).toHaveTextContent('Food: pizza')); + }); }); diff --git a/src/LiveComponent/assets/test/controller/basic.test.ts b/src/LiveComponent/assets/test/controller/basic.test.ts index 473b2062d4d..e185b5c07b0 100644 --- a/src/LiveComponent/assets/test/controller/basic.test.ts +++ b/src/LiveComponent/assets/test/controller/basic.test.ts @@ -9,25 +9,22 @@ 'use strict'; -import { clearDOM, mountDOM } from '@symfony/stimulus-testing'; -import { startStimulus } from '../tools'; +import { shutdownTest, startStimulus } from '../tools'; +import { htmlToElement } from '../../src/dom_utils'; describe('LiveController Basic Tests', () => { afterEach(() => { - clearDOM(); + shutdownTest() }); it('dispatches connect event', async () => { - const container = mountDOM(''); + const container = htmlToElement('
'); let eventTriggered = false; container.addEventListener('live:connect', () => { eventTriggered = true; }) - const { element } = await startStimulus( - '
', - container - ); + const { element } = await startStimulus(container); // smoke test expect(element).toHaveAttribute('data-controller', 'live'); diff --git a/src/LiveComponent/assets/test/controller/child.test.ts b/src/LiveComponent/assets/test/controller/child.test.ts index 2f1dfc8ebef..818dc1fef6c 100644 --- a/src/LiveComponent/assets/test/controller/child.test.ts +++ b/src/LiveComponent/assets/test/controller/child.test.ts @@ -9,228 +9,244 @@ 'use strict'; -import { clearDOM } from '@symfony/stimulus-testing'; -import { initLiveComponent, mockRerender, startStimulus } from '../tools'; -import { getByLabelText, getByText, waitFor } from '@testing-library/dom'; +import { createTest, initComponent, shutdownTest } from '../tools'; +import { getByText, waitFor } from '@testing-library/dom'; import userEvent from '@testing-library/user-event'; -import fetchMock from 'fetch-mock-jest'; +import { htmlToElement } from '../../src/dom_utils'; describe('LiveController parent -> child component tests', () => { - const parentTemplate = (data) => { - const errors = data.errors || { post: {} }; - - return ` -
- Title: ${data.post.title} - Description in Parent: ${data.post.content} - - - - ${childTemplate({ value: data.post.content, error: errors.post.content })} - - + afterEach(() => { + shutdownTest(); + }) + + it('renders parent component without affecting child component', async () => { + const childTemplate = (data: any) => ` +
+ Child component original text + Favorite food: ${data.food} + +
- ` - } - - const childTemplate = (data) => ` -
- - -
Value in child: ${data.value}
-
Error in child: ${data.error ? data.error : 'none'}
- {# Rows represents a writable prop that's private to the child component #} -
Rows in child: ${data.rows ? data.rows : 'not set'}
- - -
- `; + `; - afterEach(() => { - clearDOM(); - if (!fetchMock.done()) { - throw new Error('Mocked requests did not match'); - } - fetchMock.reset(); + const test = await createTest({ count: 0 }, (data: any) => ` +
+ Parent component count: ${data.count} + + ${childTemplate({food: 'pizza'})} +
+ `); + + // for the child component render + test.expectsAjaxCall('get') + .expectSentData({food: 'pizza'}) + .serverWillChangeData((data: any) => { + data.food = 'popcorn'; + }) + .willReturn(childTemplate) + .init(); + + // re-render *just* the child + userEvent.click(getByText(test.element, 'Render Child')); + await waitFor(() => expect(test.element).toHaveTextContent('Favorite food: popcorn')); + + // now let's re-render the parent + test.expectsAjaxCall('get') + .expectSentData(test.initialData) + .serverWillChangeData((data: any) => { + data.count = 1; + }) + .init(); + + test.controller.$render(); + await waitFor(() => expect(test.element).toHaveTextContent('Parent component count: 1')); + // child component retains its re-rendered, custom text + // this is because the parent's data changed, but the changed data is not passed to the child + expect(test.element).toHaveTextContent('Favorite food: popcorn'); }); - it('renders parent component without affecting child component', async () => { - const data = { post: { title: 'Parent component', content: 'i love' } }; - const { element } = await startStimulus(parentTemplate(data)); - - // on child re-render, expect the new value, change rows on the server - mockRerender({ value: 'i love popcorn' }, childTemplate, (data) => { - // change the "rows" data on the "server" - data.rows = 5; - }); - await userEvent.type(getByLabelText(element, 'Content:'), ' popcorn'); - - await waitFor(() => expect(element).toHaveTextContent('Value in child: i love popcorn')); - expect(element).toHaveTextContent('Rows in child: 5'); - - // when the parent re-renders, expect the changed title AND content (from child) - // but, importantly, the only "changed" data that will be passed into - // the child component will be "content", which will match what the - // child already has. This will NOT trigger a re-render. - mockRerender( - { post: { title: 'Parent component changed', content: 'i love popcorn' } }, - parentTemplate - ) - await userEvent.type(getByLabelText(element, 'Title:'), ' changed'); - await waitFor(() => expect(element).toHaveTextContent('Title: Parent component changed')); - - // the child component should *not* have updated - expect(element).toHaveTextContent('Rows in child: 5'); + it('renders parent component AND replaces child component when changed parent data affects child data', async () => { + const childTemplate = (data: any) => ` +
+ Child component count: ${data.childCount} +
+ `; + + const test = await createTest({ count: 0 }, (data: any) => ` +
+ Parent component count: ${data.count} + + ${childTemplate({childCount: data.count})} +
+ `); + + // change some content on the child + (document.getElementById('child-component') as HTMLElement).innerHTML = 'changed child content'; + + // re-render the parent + test.expectsAjaxCall('get') + .expectSentData(test.initialData) + .serverWillChangeData((data: any) => { + data.count = 1; + }) + .init(); + + test.controller.$render(); + await waitFor(() => expect(test.element).toHaveTextContent('Parent component count: 1')); + // child component reverts to its original text + // this is because the parent's data changed AND that change affected child's data + expect(test.element).toHaveTextContent('Child component count: 1'); }); - it('updates child model and parent model in a deferred way', async () => { - const data = { post: { title: 'Parent component', content: 'i love' } }; - const { element, controller } = await startStimulus(parentTemplate(data)); + it('updates the parent model when the child model updates', async () => { + const childTemplate = (data: any) => ` +
+ + + Child Content: ${data.content} +
+ `; - // verify the child request contains the correct description & re-render - mockRerender({ value: 'i love turtles' }, childTemplate); + const test = await createTest({ post: { content: 'i love'} }, (data: any) => ` +
+ + Parent Post Content: ${data.post.content} + + ${childTemplate({content: data.post.content})} + +
+ `); - // change the description in the child - const inputElement = getByLabelText(element, 'Content:'); - await userEvent.type(inputElement, ' turtles'); + // request for the child render + test.expectsAjaxCall('get') + .expectSentData({content: 'i love turtles'}) + .willReturn(childTemplate) + .init(); + await userEvent.type(test.queryByDataModel('content'), ' turtles'); // wait for the render to complete - await waitFor(() => expect(element).toHaveTextContent('Value in child: i love turtles')); + await waitFor(() => expect(test.element).toHaveTextContent('Child Content: i love turtles')); // the parent should not re-render - expect(element).not.toHaveTextContent('Content in parent: i love turtles'); + // 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(controller.dataValue.post.content).toEqual('i love turtles'); - }); - - it('updates re-renders a child component if data has changed from initial', async () => { - const data = { post: { title: 'Parent component', content: 'initial content' } }; - const { element } = await startStimulus(parentTemplate(data)); - - // allow the child to re-render, but change the "rows" value - const inputElement = getByLabelText(element, 'Content:'); - await userEvent.clear(inputElement); - await userEvent.type(inputElement, 'changed content'); - // change the rows on the server - mockRerender({'value': 'changed content'}, childTemplate, (data) => { - data.rows = 5; - }); - - // reload, which will give us rows=5 - getByText(element, 'Child Re-render').click(); - await waitFor(() => expect(element).toHaveTextContent('Rows in child: 5')); - - // simulate an action in the parent component where "errors" changes - mockRerender({'post': { title: 'Parent component', content: 'changed content' }}, parentTemplate, (data) => { - data.post.title = 'Changed title'; - data.errors = { post: { content: 'the content is not interesting enough' }}; - }); - - getByText(element, 'Parent Re-render').click(); - await waitFor(() => expect(element).toHaveTextContent('Title: Changed title')); - // the child, of course, still has the "changed content" value - expect(element).toHaveTextContent('Value in child: changed content'); - // but because some child data *changed* from its original value, the child DOES re-render - expect(element).toHaveTextContent('Error in child: the content is not interesting enough'); - // however, this means that the updated "rows" data on the child is lost - expect(element).toHaveTextContent('Rows in child: not set'); + expect(test.controller.dataValue.post.content).toEqual('i love turtles'); }); it('uses data-model-map to map child models to parent models', async () => { - const parentTemplateDifferentModel = (data) => ` -
- Parent textarea content: ${data.textareaContent} - -
- ${childTemplate({ value: data.textareaContent, error: null })} -
- - + const childTemplate = (data: any) => ` +
+ + + Child Content: ${data.value}
`; - const data = { textareaContent: 'Original content' }; - const { element, controller } = await startStimulus(parentTemplateDifferentModel(data)); - - // update & re-render the child component - const inputElement = getByLabelText(element, 'Content:'); - await userEvent.clear(inputElement); - await userEvent.type(inputElement, 'changed content'); - mockRerender({value: 'changed content', error: null}, childTemplate); + const test = await createTest({ post: { content: 'i love'} }, (data: any) => ` +
+
+ ${childTemplate({value: data.post.content})} +
+
+ `); - await waitFor(() => expect(element).toHaveTextContent('Value in child: changed content')); + // request for the child render + test.expectsAjaxCall('get') + .expectSentData({ value: 'i love dragons' }) + .willReturn(childTemplate) + .init(); - expect(controller.dataValue).toEqual({ textareaContent: 'changed content' }); - }); + await userEvent.type(test.queryByDataModel('value'), ' dragons'); + // wait for the render to complete + await waitFor(() => expect(test.element).toHaveTextContent('Child Content: i love dragons')); + expect(test.controller.dataValue.post.content).toEqual('i love dragons'); + }); it('updates child data-original-data on parent re-render', async () => { - const parentTemplateListOfChildren = (data) => ` -
+ const initialData = { children: [{ name: 'child1' }, { name: 'child2' }, { name: 'child3' }] }; + const test = await createTest(initialData, (data: any) => ` +
+ Parent count: ${data.count} +
    - ${data.children.map((child) => { + ${data.children.map((child: any) => { return ` -
  • +
  • ${child.name}
  • ` })}
- -
- `; + `); - const data = { children: [{name: 'child1'}, {name: 'child2'}, {name: 'child3'}] }; - const { element, controller } = await startStimulus(parentTemplateListOfChildren(data)); + test.expectsAjaxCall('get') + .expectSentData(test.initialData) + .serverWillChangeData((data: any) => { + // "remove" child2 + data.children = [{ name: 'child1' }, { name: 'child3' }]; + }) + .init(); - // mock a re-render where "child2" disappears - mockRerender(data, parentTemplateListOfChildren, (returnedData) => { - returnedData.children = [{name: 'child1'}, {name: 'child3'}]; - }); - getByText(element, 'Parent Re-render').click(); + test.controller.$render(); - await waitFor(() => expect(element).not.toHaveTextContent('child2')); - const secondLi = element.querySelectorAll('li').item(1); + await waitFor(() => expect(test.element).not.toHaveTextContent('child2')); + const secondLi = test.element.querySelectorAll('li').item(1); expect(secondLi).not.toBeNull(); // the 2nd li was just "updated" by the parent component, which // would have eliminated its data-original-data attribute. Check // that it was re-established to the 3rd child's data. // see MutationObserver in live_controller for more details. expect(secondLi.dataset.originalData).toEqual(JSON.stringify({name: 'child3'})); - }); + }); + + it('notices as children are connected and disconnected', async () => { + const test = await createTest({}, (data: any) => ` +
+ Parent component +
+ `); + + expect(test.controller.childComponentControllers).toHaveLength(0); + + const createChildElement = () => { + const childElement = htmlToElement(`
child
`); + childElement.addEventListener('live:connect', (event: any) => { + event.detail.controller.element.setAttribute('connected', '1'); + }); + childElement.addEventListener('live:disconnect', (event: any) => { + event.detail.controller.element.setAttribute('connected', '0'); + }); + + return childElement; + }; + + const childElement1 = createChildElement(); + const childElement2 = createChildElement(); + const childElement3 = createChildElement(); + + test.element.appendChild(childElement1); + await waitFor(() => expect(childElement1).toHaveAttribute('connected', '1')); + expect(test.controller.childComponentControllers).toHaveLength(1); + + test.element.appendChild(childElement2); + test.element.appendChild(childElement3); + await waitFor(() => expect(childElement2).toHaveAttribute('connected', '1')); + await waitFor(() => expect(childElement3).toHaveAttribute('connected', '1')); + expect(test.controller.childComponentControllers).toHaveLength(3); + + test.element.removeChild(childElement2); + await waitFor(() => expect(childElement2).toHaveAttribute('connected', '0')); + expect(test.controller.childComponentControllers).toHaveLength(2); + test.element.removeChild(childElement1); + test.element.removeChild(childElement3); + await waitFor(() => expect(childElement1).toHaveAttribute('connected', '0')); + await waitFor(() => expect(childElement3).toHaveAttribute('connected', '0')); + expect(test.controller.childComponentControllers).toHaveLength(0); + }); }); diff --git a/src/LiveComponent/assets/test/controller/csrf.test.ts b/src/LiveComponent/assets/test/controller/csrf.test.ts index 17896ce7d60..0c8bad26f87 100644 --- a/src/LiveComponent/assets/test/controller/csrf.test.ts +++ b/src/LiveComponent/assets/test/controller/csrf.test.ts @@ -9,55 +9,35 @@ 'use strict'; -import { clearDOM } from '@symfony/stimulus-testing'; -import { initLiveComponent, startStimulus } from '../tools'; +import { createTest, initComponent, shutdownTest } from '../tools'; import { getByText, waitFor } from '@testing-library/dom'; -import fetchMock from 'fetch-mock-jest'; describe('LiveController CSRF Tests', () => { - const template = (data) => ` -
- - - ${data.isSaved ? 'Comment Saved!' : ''} - - -
- `; - afterEach(() => { - clearDOM(); - if (!fetchMock.done()) { - throw new Error('Mocked requests did not match'); - } - fetchMock.reset(); - }); + shutdownTest(); + }) it('Sends the CSRF token on an action', async () => { - const data = { comments: 'hi' }; - const { element } = await startStimulus(template(data)); - - const postMock = fetchMock.postOnce( - 'http://localhost/_components/my_component/save', - template({ comments: 'hi', isSaved: true }) - ); - getByText(element, 'Save').click(); - - await waitFor(() => expect(element).toHaveTextContent('Comment Saved!')); - - expect(postMock.lastOptions().headers['X-CSRF-TOKEN']).toEqual('123TOKEN'); + const test = await createTest({ isSaved: 0 }, (data: any) => ` +
+ ${data.isSaved ? 'Saved' : ''} + +
+ `); + + test.expectsAjaxCall('post') + .expectSentData(test.initialData) + .expectHeader('X-CSRF-TOKEN', '123TOKEN') + .serverWillChangeData((data: any) => { + data.isSaved = true; + }) + .init(); + + getByText(test.element, 'Save').click(); + + await waitFor(() => expect(test.element).toHaveTextContent('Saved')); }); }); diff --git a/src/LiveComponent/assets/test/controller/model.test.ts b/src/LiveComponent/assets/test/controller/model.test.ts index 6d501bf49f6..543161d3325 100644 --- a/src/LiveComponent/assets/test/controller/model.test.ts +++ b/src/LiveComponent/assets/test/controller/model.test.ts @@ -9,92 +9,155 @@ 'use strict'; -import { clearDOM } from '@symfony/stimulus-testing'; -import { initLiveComponent, mockRerender, startStimulus } from '../tools'; +import { createTest, initComponent, shutdownTest } from '../tools'; import { getByLabelText, getByText, waitFor } from '@testing-library/dom'; import userEvent from '@testing-library/user-event'; -import fetchMock from 'fetch-mock-jest'; describe('LiveController data-model Tests', () => { - const template = (data) => ` -
- - Change name to Jan -
- `; - afterEach(() => { - clearDOM(); - if (!fetchMock.done()) { - throw new Error('Mocked requests did not match'); - } - fetchMock.reset(); - }); + shutdownTest(); + }) - it('renders correctly with data-model and live#update', async () => { - const data = { name: 'Ryan' }; - const { element, controller } = await startStimulus(template(data)); + it('sends data and re-renders correctly when data-model element is changed', async () => { + const test = await createTest({ name: 'Ryan' }, (data: any) => ` +
+ + + Name is: ${data.name} +
+ `); - mockRerender({name: 'Ryan WEAVER'}, template, (data: any) => { - data.name = 'Ryan Weaver'; - }); + test.expectsAjaxCall('get') + .expectSentData({ name: 'Ryan Weaver' }) + .init(); - await userEvent.type(getByLabelText(element, 'Name:'), ' WEAVER', { + await userEvent.type(test.queryByDataModel('name'), ' Weaver', { // this tests the debounce: characters have a 10ms delay // in between, but the debouncing prevents multiple calls delay: 10 }); - await waitFor(() => expect(getByLabelText(element, 'Name:')).toHaveValue('Ryan Weaver')); - expect(controller.dataValue).toEqual({name: 'Ryan Weaver'}); + await waitFor(() => expect(test.element).toHaveTextContent('Name is: Ryan Weaver')); + expect(test.controller.dataValue).toEqual({name: 'Ryan Weaver'}); // assert the input is still focused after rendering - expect(document.activeElement.dataset.model).toEqual('name'); + expect(document.activeElement).toBeInstanceOf(HTMLElement); + expect((document.activeElement as HTMLElement).dataset.model).toEqual('name'); + }); + + it('updates the data without re-rendering if "norender" is used', async () => { + const test = await createTest({ name: 'Ryan' }, (data: any) => ` +
+ + + Name is: ${data.name} +
+ `); + + await userEvent.type(test.queryByDataModel('name'), ' Weaver', { + // debounce is only 1, so this "would" send MANY Ajax requests + // if "norender" were NOT used + delay: 10 + }); + + // component never re-rendered + expect(test.element).toHaveTextContent('Name is: Ryan'); + // but the value was updated + expect(test.controller.dataValue).toEqual({name: 'Ryan Weaver'}); + }); + + it('waits to update data and rerender until change event with on(change)', async () => { + const test = await createTest({ name: 'Ryan' }, (data: any) => ` +
+ + + Name is: ${data.name} + +
+ `); + + await userEvent.type(test.queryByDataModel('name'), ' Weaver', { + // debounce is only 1, so this "would" send MANY Ajax requests + // if on(change) were NOT used (each character only triggers an "input" event) + delay: 10 + }); + + // component has not *yet* re-rendered + expect(test.element).toHaveTextContent('Name is: Ryan'); + // the value has not *yet* been updated + expect(test.controller.dataValue).toEqual({name: 'Ryan'}); + + // NOW we expect the render + test.expectsAjaxCall('get') + .expectSentData({ name: 'Ryan Weaver' }) + .init(); + + // this will cause the input to "blur" and trigger the change event + userEvent.click(getByText(test.element, 'Do nothing')); + + await waitFor(() => expect(test.element).toHaveTextContent('Name is: Ryan Weaver')); + expect(test.controller.dataValue).toEqual({name: 'Ryan Weaver'}); }); - it('renders correctly with data-value and live#update', async () => { - const data = { name: 'Ryan' }; - const { element, controller } = await startStimulus(template(data)); + it('renders correctly with data-value and live#update on a non-input', async () => { + const test = await createTest({ name: 'Ryan' }, (data: any) => ` +
+ Change name to Jan + + Name is: ${data.name} +
+ `); - mockRerender({name: 'Jan'}, template); + test.expectsAjaxCall('get') + .expectSentData({ name: 'Jan' }) + .init(); - userEvent.click(getByText(element, 'Change name to Jan')); + userEvent.click(getByText(test.element, 'Change name to Jan')); - await waitFor(() => expect(getByLabelText(element, 'Name:')).toHaveValue('Jan')); - expect(controller.dataValue).toEqual({name: 'Jan'}); + await waitFor(() => expect(test.element).toHaveTextContent('Name is: Jan')); + expect(test.controller.dataValue).toEqual({name: 'Jan'}); }); - it('correctly only uses the most recent render call results', async () => { - const data = { name: 'Ryan' }; - const { element, controller } = await startStimulus(template(data)); + it('only uses the most recent render call result', async () => { + const test = await createTest({ name: 'Ryan' }, (data: any) => ` +
+ + + Name is: ${data.name} +
+ `); let renderCount = 0; - element.addEventListener('live:render', () => { + test.element.addEventListener('live:render', () => { renderCount++; }) - const requests = [ - ['g', 650], - ['gu', 250], - ['guy', 150] + const requests: Array<{letters: string, delay: number}> = [ + { letters: 'g', delay: 650 }, + { letters: 'gu', delay: 250 }, + { letters: 'guy', delay: 150 }, ]; - requests.forEach(([string, delay]) => { - mockRerender({name: `Ryan${string}`}, template, (data: any) => { - data.name = `Ryan${string}_`; - }, { delay }); + requests.forEach((request) => { + test.expectsAjaxCall('get') + .expectSentData({ name: `Ryan${request.letters}` }) + .delayResponse(request.delay) + .init(); }); - await userEvent.type(getByLabelText(element, 'Name:'), 'guy', { + await userEvent.type(test.queryByDataModel('name'), 'guy', { // This will result in this sequence: // A) "g" starts 200ms // B) "gu" starts 400ms @@ -105,378 +168,399 @@ describe('LiveController data-model Tests', () => { delay: 200 }); - await waitFor(() => expect(getByLabelText(element, 'Name:')).toHaveValue('Ryanguy_')); - expect(controller.dataValue).toEqual({name: 'Ryanguy_'}); + await waitFor(() => expect(test.element).toHaveTextContent('Name is: Ryanguy')); + expect(test.queryByDataModel('name')).toHaveValue('Ryanguy'); + expect(test.controller.dataValue).toEqual({name: 'Ryanguy'}); // only 1 render should have ultimately occurred expect(renderCount).toEqual(1); }); - it('falls back to using the name attribute when no data-model is present', async () => { - const data = { name: 'Ryan' }; - const { element, controller } = await startStimulus(template(data)); - - // replace data-model with name attribute - const inputElement = getByLabelText(element, 'Name:'); - delete inputElement.dataset.model; - inputElement.setAttribute('name', 'name'); + it('falls back to using the name attribute when no data-model is present and
is ancestor', async () => { + const test = await createTest({ color: '' }, (data: any) => ` +
+ + + + + Favorite color: ${data.color} +
+ `); - mockRerender({name: 'Ryan WEAVER'}, template, (data: any) => { - data.name = 'Ryan Weaver'; - }); + test.expectsAjaxCall('get') + .expectSentData({ color: `orange` }) + .init(); - await userEvent.type(inputElement, ' WEAVER'); + await userEvent.type(test.queryByNameAttribute('color'), 'orange'); - await waitFor(() => expect(inputElement).toHaveValue('Ryan Weaver')); - expect(controller.dataValue).toEqual({name: 'Ryan Weaver'}); + await waitFor(() => expect(test.element).toHaveTextContent('Favorite color: orange')); + expect(test.controller.dataValue).toEqual({ color: 'orange' }); }); it('uses data-model when both name and data-model is present', async () => { - const data = { name: 'Ryan' }; - const { element, controller } = await startStimulus(template(data)); - - // give element data-model="name" and name="first_name" - const inputElement = getByLabelText(element, 'Name:'); - inputElement.setAttribute('name', 'first_name'); + const test = await createTest({ name: '', firstName: '' }, (data: any) => ` +
+
+ +
+ + First name: ${data.firstName} +
+ `); - // ?name should be what's sent to the server - mockRerender({name: 'Ryan WEAVER'}, template, (data) => { - data.name = 'Ryan Weaver'; - }) + test.expectsAjaxCall('get') + // firstName is the model that is matched and used + .expectSentData({ name: '', firstName: 'Ryan' }) + .init(); - await userEvent.type(inputElement, ' WEAVER'); + await userEvent.type(test.queryByDataModel('firstName'), 'Ryan'); - await waitFor(() => expect(inputElement).toHaveValue('Ryan Weaver')); - expect(controller.dataValue).toEqual({name: 'Ryan Weaver'}); + await waitFor(() => expect(test.element).toHaveTextContent('First name: Ryan')); + expect(test.controller.dataValue).toEqual({ firstName: 'Ryan', name: '' }); }); it('uses data-value when both value and data-value is present', async () => { - const data = { name: 'Ryan' }; - const { element, controller } = await startStimulus(template(data)); - - // give element data-model="name" and name="first_name" - const inputElement = getByLabelText(element, 'Name:'); - inputElement.dataset.value = 'first_name'; + const test = await createTest({ sport: '' }, (data: any) => ` +
+ + + Sport: ${data.sport} +
+ `); - // ?name should be what's sent to the server - mockRerender({name: 'first_name'}, template, (data) => { - data.name = 'first_name'; - }) + test.expectsAjaxCall('get') + // "cross country" takes precedence over real value + .expectSentData({ sport: 'cross country' }) + .init(); - await userEvent.type(inputElement, ' WEAVER'); + await userEvent.type(test.queryByDataModel('sport'), 'steeple chase'); - await waitFor(() => expect(inputElement).toHaveValue('first_name')); - expect(controller.dataValue).toEqual({name: 'first_name'}); + await waitFor(() => expect(test.element).toHaveTextContent('Sport: cross country')); }); - it('standardizes user[firstName] style models into post.name', async () => { - const deeperModelTemplate = (data) => ` -
-