diff --git a/package.json b/package.json index 817fbe80478..e16d02d010f 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,12 @@ ], "rules": { "@typescript-eslint/no-explicit-any": "off", - "@typescript-eslint/no-empty-function": "off" + "@typescript-eslint/no-empty-function": "off", + "@typescript-eslint/ban-ts-comment": "off", + "quotes": [ + "error", + "single" + ] }, "env": { "browser": true diff --git a/src/Cropperjs/Resources/assets/test/controller.test.ts b/src/Cropperjs/Resources/assets/test/controller.test.ts index 6027c9e0f9f..4b5d6b11dff 100644 --- a/src/Cropperjs/Resources/assets/test/controller.test.ts +++ b/src/Cropperjs/Resources/assets/test/controller.test.ts @@ -52,7 +52,7 @@ describe('CropperjsController', () => { data-cropperjs-public-url-value="https://symfony.com/logos/symfony_black_02.png" data-cropperjs-options-value="${dataToJsonAttribute({ viewMode: 1, - dragMode: "move" + dragMode: 'move' })}" > diff --git a/src/LiveComponent/CHANGELOG.md b/src/LiveComponent/CHANGELOG.md index b4bd77bcc4a..1c49c6dfc32 100644 --- a/src/LiveComponent/CHANGELOG.md +++ b/src/LiveComponent/CHANGELOG.md @@ -26,7 +26,25 @@ ``` -- Added the ability to add `data-loading` behavior, which is only activated +- [BEHAVIOR CHANGE] The way that child components re-render when a parent re-renders + has changed, but shouldn't be drastically different. Child components will now + avoid re-rendering if no "input" to the component changed _and_ will maintain + any writable `LiveProp` values after the re-render. Also, the re-render happens + in a separate Ajax call after the parent has finished re-rendering. + +- [BEHAVIOR CHANGE] If a model is updated, but the new value is equal to the old + one, a re-render will now be avoided. + +- [BC BREAK] The `live:update-model` and `live:render` events are not longer + dispatched. You can now use the "hook" system directly on the `Component` object/ + +- [BC BREAK] The `LiveComponentHydrator::dehydrate()` method now returns a + `DehydratedComponent` object. + +- Added a new JavaScript `Component` object, which is attached to the `__component` + property of all root component elements. + +- the ability to add `data-loading` behavior, which is only activated when a specific **action** is triggered - e.g. `Loading`. - Added the ability to add `data-loading` behavior, which is only activated diff --git a/src/LiveComponent/assets/dist/live_controller.js b/src/LiveComponent/assets/dist/live_controller.js index 3bfd9407d63..f7a5fa88c02 100644 --- a/src/LiveComponent/assets/dist/live_controller.js +++ b/src/LiveComponent/assets/dist/live_controller.js @@ -1,31 +1,418 @@ import { Controller } from '@hotwired/stimulus'; -/****************************************************************************** -Copyright (c) Microsoft Corporation. +function parseDirectives(content) { + const directives = []; + if (!content) { + return directives; + } + let currentActionName = ''; + let currentArgumentName = ''; + let currentArgumentValue = ''; + let currentArguments = []; + let currentNamedArguments = {}; + let currentModifiers = []; + let state = 'action'; + const getLastActionName = function () { + if (currentActionName) { + return currentActionName; + } + if (directives.length === 0) { + throw new Error('Could not find any directives'); + } + return directives[directives.length - 1].action; + }; + const pushInstruction = function () { + directives.push({ + action: currentActionName, + args: currentArguments, + named: currentNamedArguments, + modifiers: currentModifiers, + getString: () => { + return content; + } + }); + currentActionName = ''; + currentArgumentName = ''; + currentArgumentValue = ''; + currentArguments = []; + currentNamedArguments = {}; + currentModifiers = []; + state = 'action'; + }; + const pushArgument = function () { + const mixedArgTypesError = () => { + throw new Error(`Normal and named arguments cannot be mixed inside "${currentActionName}()"`); + }; + if (currentArgumentName) { + if (currentArguments.length > 0) { + mixedArgTypesError(); + } + currentNamedArguments[currentArgumentName.trim()] = currentArgumentValue; + } + else { + if (Object.keys(currentNamedArguments).length > 0) { + mixedArgTypesError(); + } + currentArguments.push(currentArgumentValue.trim()); + } + currentArgumentName = ''; + currentArgumentValue = ''; + }; + const pushModifier = function () { + if (currentArguments.length > 1) { + throw new Error(`The modifier "${currentActionName}()" does not support multiple arguments.`); + } + if (Object.keys(currentNamedArguments).length > 0) { + throw new Error(`The modifier "${currentActionName}()" does not support named arguments.`); + } + currentModifiers.push({ + name: currentActionName, + value: currentArguments.length > 0 ? currentArguments[0] : null, + }); + currentActionName = ''; + currentArgumentName = ''; + currentArguments = []; + state = 'action'; + }; + for (let i = 0; i < content.length; i++) { + const char = content[i]; + switch (state) { + case 'action': + if (char === '(') { + state = 'arguments'; + break; + } + if (char === ' ') { + if (currentActionName) { + pushInstruction(); + } + break; + } + if (char === '|') { + pushModifier(); + break; + } + currentActionName += char; + break; + case 'arguments': + if (char === ')') { + pushArgument(); + state = 'after_arguments'; + break; + } + if (char === ',') { + pushArgument(); + break; + } + if (char === '=') { + currentArgumentName = currentArgumentValue; + currentArgumentValue = ''; + break; + } + currentArgumentValue += char; + break; + case 'after_arguments': + if (char === '|') { + pushModifier(); + break; + } + if (char !== ' ') { + throw new Error(`Missing space after ${getLastActionName()}()`); + } + pushInstruction(); + break; + } + } + switch (state) { + case 'action': + case 'after_arguments': + if (currentActionName) { + pushInstruction(); + } + break; + default: + throw new Error(`Did you forget to add a closing ")" after "${currentActionName}"?`); + } + return directives; +} -Permission to use, copy, modify, and/or distribute this software for any -purpose with or without fee is hereby granted. +function combineSpacedArray(parts) { + const finalParts = []; + parts.forEach((part) => { + finalParts.push(...part.split(' ')); + }); + return finalParts; +} +function normalizeModelName(model) { + return model + .replace(/\[]$/, '') + .split('[') + .map(function (s) { + return s.replace(']', ''); + }) + .join('.'); +} -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH -REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY -AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, -INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM -LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR -OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR -PERFORMANCE OF THIS SOFTWARE. -***************************************************************************** */ +function getValueFromElement(element, valueStore) { + if (element instanceof HTMLInputElement) { + if (element.type === 'checkbox') { + const modelNameData = getModelDirectiveFromElement(element); + if (modelNameData === null) { + return null; + } + const modelValue = valueStore.get(modelNameData.action); + if (Array.isArray(modelValue)) { + return getMultipleCheckboxValue(element, modelValue); + } + return element.checked ? inputValue(element) : null; + } + return inputValue(element); + } + if (element instanceof HTMLSelectElement) { + if (element.multiple) { + return Array.from(element.selectedOptions).map(el => el.value); + } + return element.value; + } + if (element.dataset.value) { + return element.dataset.value; + } + if ('value' in element) { + return element.value; + } + if (element.hasAttribute('value')) { + return element.getAttribute('value'); + } + return null; +} +function setValueOnElement(element, value) { + if (element instanceof HTMLInputElement) { + if (element.type === 'file') { + return; + } + if (element.type === 'radio') { + element.checked = element.value == value; + return; + } + if (element.type === 'checkbox') { + if (Array.isArray(value)) { + let valueFound = false; + value.forEach(val => { + if (val == element.value) { + valueFound = true; + } + }); + element.checked = valueFound; + } + else { + element.checked = element.value == value; + } + return; + } + } + if (element instanceof HTMLSelectElement) { + const arrayWrappedValue = [].concat(value).map(value => { + return value + ''; + }); + Array.from(element.options).forEach(option => { + option.selected = arrayWrappedValue.includes(option.value); + }); + return; + } + value = value === undefined ? '' : value; + element.value = value; +} +function getAllModelDirectiveFromElements(element) { + if (!element.dataset.model) { + return []; + } + const directives = parseDirectives(element.dataset.model); + directives.forEach((directive) => { + if (directive.args.length > 0 || directive.named.length > 0) { + throw new Error(`The data-model="${element.dataset.model}" format is invalid: it does not support passing arguments to the model.`); + } + directive.action = normalizeModelName(directive.action); + }); + return directives; +} +function getModelDirectiveFromElement(element, throwOnMissing = true) { + const dataModelDirectives = getAllModelDirectiveFromElements(element); + if (dataModelDirectives.length > 0) { + return dataModelDirectives[0]; + } + if (element.getAttribute('name')) { + const formElement = element.closest('form'); + if (formElement && ('model' in formElement.dataset)) { + const directives = parseDirectives(formElement.dataset.model || '*'); + const directive = directives[0]; + if (directive.args.length > 0 || directive.named.length > 0) { + throw new Error(`The data-model="${formElement.dataset.model}" format is invalid: it does not support passing arguments to the model.`); + } + directive.action = normalizeModelName(element.getAttribute('name')); + return directive; + } + } + if (!throwOnMissing) { + return null; + } + throw new Error(`Cannot determine the model name for "${getElementAsTagText(element)}": the element must either have a "data-model" (or "name" attribute living inside a