diff --git a/src/LiveComponent/README.md b/src/LiveComponent/README.md index 3668d771348..32dfe340461 100644 --- a/src/LiveComponent/README.md +++ b/src/LiveComponent/README.md @@ -361,6 +361,9 @@ code works identically to the previous example: ``` +If an element has _both_ `data-model` and `name` attributes, the +`data-model` attribute takes precedence. + ## Loading States Often, you'll want to show (or hide) an element while a component is @@ -1135,3 +1138,226 @@ You can also trigger a specific "action" instead of a normal re-render: #} > ``` + +## Embedded Components + +Need to embed one live component inside another one? No problem! As a rule +of thumb, **each component exists in its own, isolated universe**. This +means that embedding one component inside another could be really simple +or a bit more complex, depending on how inter-connected you want your components +to be. + +Here are a few helpful things to know: + +### Each component re-renders independent of one another + +If a parent component re-renders, the child component will _not_ (most +of the time) be updated, even though it lives inside the parent. Each +component is its own, isolated universe. + +But this is not always what you want. For example, suppose you have a +parent component that renders a form and a child component that renders +one field in that form. When you click a "Save" button on the parent +component, that validates the form and re-renders with errors - including +a new `error` value that it passes into the child: + +```twig +{# templates/components/post_form.html.twig #} + +{{ component('textarea_field', { + value: this.content, + error: this.getError('content') +}) }} +``` + +In this situation, when the parent component re-renders after clicking +"Save", you _do_ want the updated child component (with the validation +error) to be rendered. And this _will_ happen automatically. Why? because +the live component system detects that the **parent component has +_changed_ how it's rendering the child**. + +This may not always be perfect, and if your child component has its own +`LiveProp` that has changed since it was first rendered, that value will +be lost when the parent component causes the child to re-render. If you +have this situation, use `data-model-map` to map that child `LiveProp` to +a `LiveProp` in the parent component, and pass it into the child when +rendering. + +### Actions, methods and model updates in a child do not affect the parent + +Again, each component is its own, isolated universe! For example, suppose +your child component has: + +```html + +``` + +When the user clicks that button, it will attempt to call the `save` action +in the _child_ component only, even if the `save` action actually only +exists in the parent. The same is true for `data-model`, though there is +some special handling for this case (see next point). + +### If a child model updates, it will attempt to update the parent model + +Suppose a child component has a: + +```html + + +
+ {{ this.value|markdown_to_html }} +
+ +``` + +Notice that `MarkdownTextareaComponent` allows a dynamic `name` attribute to +be passed in. This makes that component re-usable in any form. But it +also makes sure that when the `textarea` changes, both the `value` model +in `MarkdownTextareaComponent` _and_ the `post.content` model in +`EditPostcomponent` will be updated. diff --git a/src/LiveComponent/assets/dist/live-controller.esm.js b/src/LiveComponent/assets/dist/live-controller.esm.js index 6f1c03fc6fb..f79abed20ad 100644 --- a/src/LiveComponent/assets/dist/live-controller.esm.js +++ b/src/LiveComponent/assets/dist/live-controller.esm.js @@ -1250,6 +1250,8 @@ var _default = /*#__PURE__*/function (_Controller) { }, { key: "connect", value: function connect() { + var _this2 = this; + // hide "loading" elements to begin with // This is done with CSS, but only for the most basic cases this._onLoadingFinish(); @@ -1259,6 +1261,14 @@ var _default = /*#__PURE__*/function (_Controller) { } window.addEventListener('beforeunload', this.markAsWindowUnloaded); + this.element.addEventListener('live:update-model', function (event) { + // ignore events that we dispatched + if (event.target === _this2.element) { + return; + } + + _this2._handleChildComponentUpdateModel(event); + }); this._dispatchEvent('live:connect'); } @@ -1291,7 +1301,7 @@ var _default = /*#__PURE__*/function (_Controller) { }, { key: "action", value: function action(event) { - var _this2 = this; + var _this3 = this; // using currentTarget means that the data-action and data-action-name // must live on the same element: you can't add @@ -1311,9 +1321,9 @@ var _default = /*#__PURE__*/function (_Controller) { // then the action Ajax will start. We want to avoid the // re-render request from starting after the debounce and // taking precedence - _this2._clearWaitingDebouncedRenders(); + _this3._clearWaitingDebouncedRenders(); - _this2._makeRequest(directive.action); + _this3._makeRequest(directive.action); }; var handled = false; @@ -1338,13 +1348,13 @@ var _default = /*#__PURE__*/function (_Controller) { { var length = modifier.value ? modifier.value : DEFAULT_DEBOUNCE; // clear any pending renders - if (_this2.actionDebounceTimeout) { - clearTimeout(_this2.actionDebounceTimeout); - _this2.actionDebounceTimeout = null; + if (_this3.actionDebounceTimeout) { + clearTimeout(_this3.actionDebounceTimeout); + _this3.actionDebounceTimeout = null; } - _this2.actionDebounceTimeout = setTimeout(function () { - _this2.actionDebounceTimeout = null; + _this3.actionDebounceTimeout = setTimeout(function () { + _this3.actionDebounceTimeout = null; _executeAction(); }, length); @@ -1378,14 +1388,14 @@ var _default = /*#__PURE__*/function (_Controller) { throw new Error("The update() method could not be called for \"".concat(clonedElement.outerHTML, "\": the element must either have a \"data-model\" or \"name\" attribute set to the model name.")); } - this.$updateModel(model, value, element, shouldRender); + this.$updateModel(model, value, shouldRender); } }, { key: "$updateModel", - value: function $updateModel(model, value, element) { - var _this3 = this; + value: function $updateModel(model, value) { + var _this4 = this; - var shouldRender = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : true; + var shouldRender = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : true; var directives = parseDirectives(model); if (directives.length > 1) { @@ -1414,7 +1424,12 @@ var _default = /*#__PURE__*/function (_Controller) { if (!doesDeepPropertyExist(this.dataValue, modelName)) { console.warn("Model \"".concat(modelName, "\" is not a valid data-model value")); - } // we do not send old and new data to the server + } + + this._dispatchEvent('live:update-model', { + model: modelName, + value: 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. @@ -1443,16 +1458,16 @@ var _default = /*#__PURE__*/function (_Controller) { this._clearWaitingDebouncedRenders(); this.renderDebounceTimeout = setTimeout(function () { - _this3.renderDebounceTimeout = null; + _this4.renderDebounceTimeout = null; - _this3.$render(); + _this4.$render(); }, this.debounceValue || DEFAULT_DEBOUNCE); } } }, { key: "_makeRequest", value: function _makeRequest(action) { - var _this4 = this; + var _this5 = this; var _this$urlValue$split = this.urlValue.split('?'), _this$urlValue$split2 = _slicedToArray(_this$urlValue$split, 2), @@ -1489,15 +1504,15 @@ var _default = /*#__PURE__*/function (_Controller) { this.renderPromiseStack.addPromise(thisPromise); thisPromise.then(function (response) { // if another re-render is scheduled, do not "run it over" - if (_this4.renderDebounceTimeout) { + if (_this5.renderDebounceTimeout) { return; } - var isMostRecent = _this4.renderPromiseStack.removePromise(thisPromise); + var isMostRecent = _this5.renderPromiseStack.removePromise(thisPromise); if (isMostRecent) { response.json().then(function (data) { - _this4._processRerender(data); + _this5._processRerender(data); }); } }); @@ -1566,7 +1581,7 @@ var _default = /*#__PURE__*/function (_Controller) { }, { key: "_handleLoadingToggle", value: function _handleLoadingToggle(isLoading) { - var _this5 = this; + var _this6 = this; this._getLoadingDirectives().forEach(function (_ref) { var element = _ref.element, @@ -1574,13 +1589,13 @@ var _default = /*#__PURE__*/function (_Controller) { // so we can track, at any point, if an element is in a "loading" state if (isLoading) { - _this5._addAttributes(element, ['data-live-is-loading']); + _this6._addAttributes(element, ['data-live-is-loading']); } else { - _this5._removeAttributes(element, ['data-live-is-loading']); + _this6._removeAttributes(element, ['data-live-is-loading']); } directives.forEach(function (directive) { - _this5._handleLoadingDirective(element, isLoading, directive); + _this6._handleLoadingDirective(element, isLoading, directive); }); }); } @@ -1594,7 +1609,7 @@ var _default = /*#__PURE__*/function (_Controller) { }, { key: "_handleLoadingDirective", value: function _handleLoadingDirective(element, isLoading, directive) { - var _this6 = this; + var _this7 = this; var finalAction = parseLoadingAction(directive.action, isLoading); var loadingDirective = null; @@ -1602,42 +1617,42 @@ var _default = /*#__PURE__*/function (_Controller) { switch (finalAction) { case 'show': loadingDirective = function loadingDirective() { - _this6._showElement(element); + _this7._showElement(element); }; break; case 'hide': loadingDirective = function loadingDirective() { - return _this6._hideElement(element); + return _this7._hideElement(element); }; break; case 'addClass': loadingDirective = function loadingDirective() { - return _this6._addClass(element, directive.args); + return _this7._addClass(element, directive.args); }; break; case 'removeClass': loadingDirective = function loadingDirective() { - return _this6._removeClass(element, directive.args); + return _this7._removeClass(element, directive.args); }; break; case 'addAttribute': loadingDirective = function loadingDirective() { - return _this6._addAttributes(element, directive.args); + return _this7._addAttributes(element, directive.args); }; break; case 'removeAttribute': loadingDirective = function loadingDirective() { - return _this6._removeAttributes(element, directive.args); + return _this7._removeAttributes(element, directive.args); }; break; @@ -1741,7 +1756,7 @@ var _default = /*#__PURE__*/function (_Controller) { }, { key: "_executeMorphdom", value: function _executeMorphdom(newHtml) { - var _this7 = this; + var _this8 = this; // https://stackoverflow.com/questions/494143/creating-a-new-dom-element-from-an-html-string-using-built-in-dom-methods-or-pro#answer-35385518 function htmlToElement(html) { @@ -1760,7 +1775,7 @@ var _default = /*#__PURE__*/function (_Controller) { } // avoid updating child components: they will handle themselves - if (fromEl.hasAttribute('data-controller') && fromEl.getAttribute('data-controller').split(' ').indexOf('live') !== -1 && fromEl !== _this7.element) { + if (fromEl.hasAttribute('data-controller') && fromEl.getAttribute('data-controller').split(' ').indexOf('live') !== -1 && fromEl !== _this8.element) { return false; } @@ -1771,7 +1786,7 @@ var _default = /*#__PURE__*/function (_Controller) { }, { key: "_initiatePolling", value: function _initiatePolling(rawPollConfig) { - var _this8 = this; + var _this9 = this; var directives = parseDirectives(rawPollConfig || '$render'); directives.forEach(function (directive) { @@ -1790,23 +1805,23 @@ var _default = /*#__PURE__*/function (_Controller) { } }); - _this8.startPoll(directive.action, duration); + _this9._startPoll(directive.action, duration); }); } }, { - key: "startPoll", - value: function startPoll(actionName, duration) { - var _this9 = this; + key: "_startPoll", + value: function _startPoll(actionName, duration) { + var _this10 = this; var callback; if (actionName.charAt(0) === '$') { callback = function callback() { - _this9[actionName](); + _this10[actionName](); }; } else { callback = function callback() { - _this9._makeRequest(actionName); + _this10._makeRequest(actionName); }; } @@ -1827,6 +1842,15 @@ var _default = /*#__PURE__*/function (_Controller) { }); return this.element.dispatchEvent(userEvent); } + }, { + key: "_handleChildComponentUpdateModel", + value: function _handleChildComponentUpdateModel(event) { + if (!doesDeepPropertyExist(this.dataValue, event.detail.model)) { + return; + } + + this.$updateModel(event.detail.model, event.detail.value, false); + } }]); return _default; diff --git a/src/LiveComponent/assets/src/have_rendered_values_changed.js b/src/LiveComponent/assets/src/have_rendered_values_changed.js new file mode 100644 index 00000000000..98210c6fff9 --- /dev/null +++ b/src/LiveComponent/assets/src/have_rendered_values_changed.js @@ -0,0 +1,70 @@ +export function haveRenderedValuesChanged(originalDataJson, currentDataJson, newDataJson) { + /* + * 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.js b/src/LiveComponent/assets/src/live_controller.js index 34c277b5762..7f997fadd3e 100644 --- a/src/LiveComponent/assets/src/live_controller.js +++ b/src/LiveComponent/assets/src/live_controller.js @@ -5,6 +5,7 @@ import { combineSpacedArray } from './string_utils'; import { buildFormData, buildSearchParams } from './http_data_helper'; import { setDeepData, doesDeepPropertyExist, normalizeModelName } from './set_deep_data'; import './polyfills'; +import { haveRenderedValuesChanged } from './have_rendered_values_changed'; const DEFAULT_DEBOUNCE = '150'; @@ -44,8 +45,12 @@ export default class extends Controller { isWindowUnloaded = false; + originalDataJSON; + initialize() { this.markAsWindowUnloaded = this.markAsWindowUnloaded.bind(this); + this.originalDataJSON = JSON.stringify(this.dataValue); + this._exposeOriginalData(); } connect() { @@ -59,6 +64,15 @@ export default class extends Controller { window.addEventListener('beforeunload', this.markAsWindowUnloaded); + 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'); } @@ -169,10 +183,27 @@ export default class extends Controller { 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.`); } - this.$updateModel(model, value, element, shouldRender); + this.$updateModel(model, value, shouldRender, element.hasAttribute('name') ? element.getAttribute('name') : null); } - $updateModel(model, value, element, shouldRender = true) { + /** + * Update a model value. + * + * The extraModelName should be set to the "name" attribute of an element + * if it has one. This is only important in a parent/child component, + * where, in the child, you might be updating a "foo" model, but you + * also want this update to "sync" to the parent component's "bar" model. + * Typically, setup on a field like this: + * + * + * + * @param {string} model The model update, which could include modifiers + * @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 {Object} options Options include: {bool} dispatch + */ + $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).`); @@ -185,6 +216,7 @@ export default class extends Controller { } const modelName = normalizeModelName(directive.action); + 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. @@ -197,8 +229,12 @@ export default class extends Controller { this.dataValue = setDeepData(this.dataValue, 'validatedFields', validatedFields); } - if (!doesDeepPropertyExist(this.dataValue, modelName)) { - console.warn(`Model "${modelName}" is not a valid data-model value`); + if (options.dispatch !== false) { + this._dispatchEvent('live:update-model', { + modelName, + extraModelName: normalizedExtraModelName, + value, + }); } // we do not send old and new data to the server @@ -499,6 +535,7 @@ export default class extends Controller { if (fromEl.hasAttribute('data-controller') && fromEl.getAttribute('data-controller').split(' ').indexOf('live') !== -1 && fromEl !== this.element + && !this._shouldChildLiveElementUpdate(fromEl, toEl) ) { return false; } @@ -506,6 +543,8 @@ export default class extends Controller { return true; } }); + // restore the data-original-data attribute + this._exposeOriginalData(); } markAsWindowUnloaded = () => { @@ -531,11 +570,11 @@ export default class extends Controller { } }); - this.startPoll(directive.action, duration); + this._startPoll(directive.action, duration); }) } - startPoll(actionName, duration) { + _startPoll(actionName, duration) { let callback; if (actionName.charAt(0) === '$') { callback = () => { @@ -561,6 +600,104 @@ export default class extends Controller { return this.element.dispatchEvent(userEvent); } + + _handleChildComponentUpdateModel(event) { + const mainModelName = event.detail.modelName; + const potentialModelNames = [ + { name: mainModelName, required: false }, + { 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 = null; + potentialModelNames.forEach((potentialModel) => { + if (foundModelName) { + return; + } + + if (doesDeepPropertyExist(this.dataValue, 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 of 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. + * + * @param {Element} fromEl + * @param {Element} toEl + * @return {boolean} + */ + _shouldChildLiveElementUpdate(fromEl, toEl) { + return haveRenderedValuesChanged( + fromEl.dataset.originalData, + fromEl.dataset.liveDataValue, + toEl.dataset.liveDataValue + ); + } + + _exposeOriginalData() { + this.element.dataset.originalData = this.originalDataJSON; + } } /** diff --git a/src/LiveComponent/assets/src/set_deep_data.js b/src/LiveComponent/assets/src/set_deep_data.js index 43d2f265bfa..e65608b9d3d 100644 --- a/src/LiveComponent/assets/src/set_deep_data.js +++ b/src/LiveComponent/assets/src/set_deep_data.js @@ -25,15 +25,14 @@ export function setDeepData(data, propertyPath, value) { } // represents a situation where the key you're setting *is* an object, - // but the key we're setting is a new key. This, perhaps, could be - // allowed. But right now, all keys should be initialized with the - // initial data. + // but the key we're setting is a new key. Currently, all keys should + // be initialized with the initial data. if (currentLevelData[finalKey] === undefined) { const lastPart = parts.pop(); if (parts.length > 0) { - console.warn(`The property used in data-model="${propertyPath}" was never initialized. Did you forget to add exposed={"${lastPart}"} to its LiveProp?`) + throw new Error(`The property used in data-model="${propertyPath}" was never initialized. Did you forget to add exposed={"${lastPart}"} to its LiveProp?`) } else { - console.warn(`The property used in data-model="${propertyPath}" was never initialized. Did you forget to expose "${lastPart}" as a LiveProp?`) + 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)'}`) } } diff --git a/src/LiveComponent/assets/test/controller/action.test.js b/src/LiveComponent/assets/test/controller/action.test.js index 0f4c7679c8d..925d8daedd7 100644 --- a/src/LiveComponent/assets/test/controller/action.test.js +++ b/src/LiveComponent/assets/test/controller/action.test.js @@ -10,7 +10,7 @@ 'use strict'; import { clearDOM } from '@symfony/stimulus-testing'; -import { startStimulus } from '../tools'; +import { initLiveComponent, startStimulus } from '../tools'; import { getByLabelText, getByText, waitFor } from '@testing-library/dom'; import userEvent from '@testing-library/user-event'; import fetchMock from 'fetch-mock-jest'; @@ -18,8 +18,7 @@ import fetchMock from 'fetch-mock-jest'; describe('LiveController Action Tests', () => { const template = (data) => `