diff --git a/src/Autocomplete/assets/src/controller.ts b/src/Autocomplete/assets/src/controller.ts index e9a551231e7..38b324fcb68 100644 --- a/src/Autocomplete/assets/src/controller.ts +++ b/src/Autocomplete/assets/src/controller.ts @@ -3,10 +3,10 @@ import TomSelect from 'tom-select'; import type { TPluginHash } from 'tom-select/dist/types/contrib/microplugin'; import type { RecursivePartial, - TomSettings, - TomTemplates, TomLoadCallback, TomOption, + TomSettings, + TomTemplates, } from 'tom-select/dist/types/types'; import type { escape_html } from 'tom-select/dist/types/utils'; diff --git a/src/Autocomplete/assets/test/controller.test.ts b/src/Autocomplete/assets/test/controller.test.ts index 3f9a1d92f67..e17dc6ae8d3 100644 --- a/src/Autocomplete/assets/test/controller.test.ts +++ b/src/Autocomplete/assets/test/controller.test.ts @@ -9,14 +9,14 @@ import { Application } from '@hotwired/stimulus'; import { getByTestId, waitFor } from '@testing-library/dom'; +import userEvent from '@testing-library/user-event'; +import type TomSelect from 'tom-select'; +import { vi } from 'vitest'; +import createFetchMock from 'vitest-fetch-mock'; import AutocompleteController, { type AutocompleteConnectOptions, type AutocompletePreConnectOptions, } from '../src/controller'; -import userEvent from '@testing-library/user-event'; -import type TomSelect from 'tom-select'; -import createFetchMock from 'vitest-fetch-mock'; -import { vi } from 'vitest'; const shortDelay = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); diff --git a/src/Cropperjs/assets/test/controller.test.ts b/src/Cropperjs/assets/test/controller.test.ts index 4cc5983fea4..5f1de56ab8b 100644 --- a/src/Cropperjs/assets/test/controller.test.ts +++ b/src/Cropperjs/assets/test/controller.test.ts @@ -8,8 +8,8 @@ */ import { Application, Controller } from '@hotwired/stimulus'; -import { getByTestId, waitFor } from '@testing-library/dom'; import { clearDOM, mountDOM } from '@symfony/stimulus-testing'; +import { getByTestId, waitFor } from '@testing-library/dom'; import CropperjsController from '../src/controller'; let cropper: Cropper | null = null; diff --git a/src/Dropzone/assets/test/controller.test.ts b/src/Dropzone/assets/test/controller.test.ts index 84859f0665b..b37dadf4bbb 100644 --- a/src/Dropzone/assets/test/controller.test.ts +++ b/src/Dropzone/assets/test/controller.test.ts @@ -8,9 +8,9 @@ */ import { Application, Controller } from '@hotwired/stimulus'; +import { clearDOM, mountDOM } from '@symfony/stimulus-testing'; import { getByTestId, waitFor } from '@testing-library/dom'; import user from '@testing-library/user-event'; -import { clearDOM, mountDOM } from '@symfony/stimulus-testing'; import DropzoneController from '../src/controller'; // Controller used to check the actual controller was properly booted diff --git a/src/LazyImage/assets/test/controller.test.ts b/src/LazyImage/assets/test/controller.test.ts index 5802fe16101..346a2e7be79 100644 --- a/src/LazyImage/assets/test/controller.test.ts +++ b/src/LazyImage/assets/test/controller.test.ts @@ -8,8 +8,8 @@ */ import { Application, Controller } from '@hotwired/stimulus'; -import { getByTestId, waitFor } from '@testing-library/dom'; import { clearDOM, mountDOM } from '@symfony/stimulus-testing'; +import { getByTestId, waitFor } from '@testing-library/dom'; import LazyImageController from '../src/controller'; // Controller used to check the actual controller was properly booted diff --git a/src/LiveComponent/assets/dist/Component/index.d.ts b/src/LiveComponent/assets/dist/Component/index.d.ts index b43dae9f8e9..84b3336c289 100644 --- a/src/LiveComponent/assets/dist/Component/index.d.ts +++ b/src/LiveComponent/assets/dist/Component/index.d.ts @@ -1,9 +1,9 @@ import type { BackendInterface } from '../Backend/Backend'; -import ValueStore from './ValueStore'; import type BackendRequest from '../Backend/BackendRequest'; +import BackendResponse from '../Backend/BackendResponse'; import type { ElementDriver } from './ElementDriver'; +import ValueStore from './ValueStore'; import type { PluginInterface } from './plugins/PluginInterface'; -import BackendResponse from '../Backend/BackendResponse'; type MaybePromise = T | Promise; export type ComponentHooks = { connect: (component: Component) => MaybePromise; diff --git a/src/LiveComponent/assets/dist/Component/plugins/LazyPlugin.d.ts b/src/LiveComponent/assets/dist/Component/plugins/LazyPlugin.d.ts index 19c7ca892b7..a69a080ae83 100644 --- a/src/LiveComponent/assets/dist/Component/plugins/LazyPlugin.d.ts +++ b/src/LiveComponent/assets/dist/Component/plugins/LazyPlugin.d.ts @@ -1,5 +1,5 @@ -import type { PluginInterface } from './PluginInterface'; import type Component from '../index'; +import type { PluginInterface } from './PluginInterface'; export default class implements PluginInterface { private intersectionObserver; attachToComponent(component: Component): void; diff --git a/src/LiveComponent/assets/dist/Component/plugins/LoadingPlugin.d.ts b/src/LiveComponent/assets/dist/Component/plugins/LoadingPlugin.d.ts index f19f2107090..0ecb4a3bad8 100644 --- a/src/LiveComponent/assets/dist/Component/plugins/LoadingPlugin.d.ts +++ b/src/LiveComponent/assets/dist/Component/plugins/LoadingPlugin.d.ts @@ -1,6 +1,6 @@ -import { type Directive } from '../../Directive/directives_parser'; import type BackendRequest from '../../Backend/BackendRequest'; import type Component from '../../Component'; +import { type Directive } from '../../Directive/directives_parser'; import type { PluginInterface } from './PluginInterface'; interface ElementLoadingDirectives { element: HTMLElement | SVGElement; diff --git a/src/LiveComponent/assets/dist/HookManager.d.ts b/src/LiveComponent/assets/dist/HookManager.d.ts index ef92e72aa0f..51ad961a9b5 100644 --- a/src/LiveComponent/assets/dist/HookManager.d.ts +++ b/src/LiveComponent/assets/dist/HookManager.d.ts @@ -1,4 +1,4 @@ -import type { ComponentHookName, ComponentHookCallback } from './Component'; +import type { ComponentHookCallback, ComponentHookName } from './Component'; export default class { private hooks; register(hookName: T, callback: ComponentHookCallback): void; diff --git a/src/LiveComponent/assets/dist/dom_utils.d.ts b/src/LiveComponent/assets/dist/dom_utils.d.ts index 732dc6b01b4..72dac3db5b1 100644 --- a/src/LiveComponent/assets/dist/dom_utils.d.ts +++ b/src/LiveComponent/assets/dist/dom_utils.d.ts @@ -1,6 +1,6 @@ +import type Component from './Component'; import type ValueStore from './Component/ValueStore'; import { type Directive } from './Directive/directives_parser'; -import type Component from './Component'; export declare function getValueFromElement(element: HTMLElement, valueStore: ValueStore): string | string[] | null | boolean; export declare function setValueOnElement(element: HTMLElement, value: any): void; export declare function getAllModelDirectiveFromElements(element: HTMLElement): Directive[]; diff --git a/src/LiveComponent/assets/dist/live_controller.d.ts b/src/LiveComponent/assets/dist/live_controller.d.ts index b25a5d06531..7e5cff52474 100644 --- a/src/LiveComponent/assets/dist/live_controller.d.ts +++ b/src/LiveComponent/assets/dist/live_controller.d.ts @@ -1,6 +1,6 @@ import { Controller } from '@hotwired/stimulus'; -import Component from './Component'; import { type BackendInterface } from './Backend/Backend'; +import Component from './Component'; export { Component }; export { getComponent } from './ComponentRegistry'; export interface LiveEvent extends CustomEvent { diff --git a/src/LiveComponent/assets/dist/live_controller.js b/src/LiveComponent/assets/dist/live_controller.js index 8894e0334da..375a5b15bd5 100644 --- a/src/LiveComponent/assets/dist/live_controller.js +++ b/src/LiveComponent/assets/dist/live_controller.js @@ -1,128 +1,116 @@ import { Controller } from '@hotwired/stimulus'; -function parseDirectives(content) { - const directives = []; - if (!content) { - return directives; - } - let currentActionName = ''; - let currentArgumentValue = ''; - let currentArguments = []; - let currentModifiers = []; - let state = 'action'; - const getLastActionName = () => { - if (currentActionName) { - return currentActionName; - } - if (directives.length === 0) { - throw new Error('Could not find any directives'); - } - return directives[directives.length - 1].action; - }; - const pushInstruction = () => { - directives.push({ - action: currentActionName, - args: currentArguments, - modifiers: currentModifiers, - getString: () => { - return content; - }, +class BackendRequest { + constructor(promise, actions, updateModels) { + this.isResolved = false; + this.promise = promise; + this.promise.then((response) => { + this.isResolved = true; + return response; }); - currentActionName = ''; - currentArgumentValue = ''; - currentArguments = []; - currentModifiers = []; - state = 'action'; - }; - const pushArgument = () => { - currentArguments.push(currentArgumentValue.trim()); - currentArgumentValue = ''; - }; - const pushModifier = () => { - if (currentArguments.length > 1) { - throw new Error(`The modifier "${currentActionName}()" does not support multiple arguments.`); + this.actions = actions; + this.updatedModels = updateModels; + } + containsOneOfActions(targetedActions) { + return this.actions.filter((action) => targetedActions.includes(action)).length > 0; + } + areAnyModelsUpdated(targetedModels) { + return this.updatedModels.filter((model) => targetedModels.includes(model)).length > 0; + } +} + +class RequestBuilder { + constructor(url, method = 'post') { + this.url = url; + this.method = method; + } + buildRequest(props, actions, updated, children, updatedPropsFromParent, files) { + const splitUrl = this.url.split('?'); + let [url] = splitUrl; + const [, queryString] = splitUrl; + const params = new URLSearchParams(queryString || ''); + const fetchOptions = {}; + fetchOptions.headers = { + Accept: 'application/vnd.live-component+html', + 'X-Requested-With': 'XMLHttpRequest', + }; + const totalFiles = Object.entries(files).reduce((total, current) => total + current.length, 0); + const hasFingerprints = Object.keys(children).length > 0; + if (actions.length === 0 && + totalFiles === 0 && + this.method === 'get' && + this.willDataFitInUrl(JSON.stringify(props), JSON.stringify(updated), params, JSON.stringify(children), JSON.stringify(updatedPropsFromParent))) { + params.set('props', JSON.stringify(props)); + params.set('updated', JSON.stringify(updated)); + if (Object.keys(updatedPropsFromParent).length > 0) { + params.set('propsFromParent', JSON.stringify(updatedPropsFromParent)); + } + if (hasFingerprints) { + params.set('children', JSON.stringify(children)); + } + fetchOptions.method = 'GET'; } - currentModifiers.push({ - name: currentActionName, - value: currentArguments.length > 0 ? currentArguments[0] : null, - }); - currentActionName = ''; - 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; + else { + fetchOptions.method = 'POST'; + const requestData = { props, updated }; + if (Object.keys(updatedPropsFromParent).length > 0) { + requestData.propsFromParent = updatedPropsFromParent; + } + if (hasFingerprints) { + requestData.children = children; + } + if (actions.length > 0) { + if (actions.length === 1) { + requestData.args = actions[0].args; + url += `/${encodeURIComponent(actions[0].name)}`; } - currentArgumentValue += char; - break; - case 'after_arguments': - if (char === '|') { - pushModifier(); - break; + else { + url += '/_batch'; + requestData.actions = actions; } - if (char !== ' ') { - throw new Error(`Missing space after ${getLastActionName()}()`); + } + const formData = new FormData(); + formData.append('data', JSON.stringify(requestData)); + for (const [key, value] of Object.entries(files)) { + const length = value.length; + for (let i = 0; i < length; ++i) { + formData.append(key, value[i]); } - pushInstruction(); - break; + } + fetchOptions.body = formData; } + const paramsString = params.toString(); + return { + url: `${url}${paramsString.length > 0 ? `?${paramsString}` : ''}`, + fetchOptions, + }; } - switch (state) { - case 'action': - case 'after_arguments': - if (currentActionName) { - pushInstruction(); - } - break; - default: - throw new Error(`Did you forget to add a closing ")" after "${currentActionName}"?`); + willDataFitInUrl(propsJson, updatedJson, params, childrenJson, propsFromParentJson) { + const urlEncodedJsonData = new URLSearchParams(propsJson + updatedJson + childrenJson + propsFromParentJson).toString(); + return (urlEncodedJsonData + params.toString()).length < 1500; } - return directives; } -function combineSpacedArray(parts) { - const finalParts = []; - parts.forEach((part) => { - finalParts.push(...trimAll(part).split(' ')); - }); - return finalParts; -} -function trimAll(str) { - return str.replace(/[\s]+/g, ' ').trim(); +class Backend { + constructor(url, method = 'post') { + this.requestBuilder = new RequestBuilder(url, method); + } + makeRequest(props, actions, updated, children, updatedPropsFromParent, files) { + const { url, fetchOptions } = this.requestBuilder.buildRequest(props, actions, updated, children, updatedPropsFromParent, files); + return new BackendRequest(fetch(url, fetchOptions), actions.map((backendAction) => backendAction.name), Object.keys(updated)); + } } -function normalizeModelName(model) { - return (model - .replace(/\[]$/, '') - .split('[') - .map((s) => s.replace(']', '')) - .join('.')); + +class BackendResponse { + constructor(response) { + this.response = response; + } + async getBody() { + if (!this.body) { + this.body = await this.response.text(); + } + return this.body; + } } function getElementAsTagText(element) { @@ -131,360 +119,810 @@ function getElementAsTagText(element) { : element.outerHTML; } -function getValueFromElement(element, valueStore) { - if (element instanceof HTMLInputElement) { - if (element.type === 'checkbox') { - const modelNameData = getModelDirectiveFromElement(element, false); - if (modelNameData !== null) { - const modelValue = valueStore.get(modelNameData.action); - if (Array.isArray(modelValue)) { - return getMultipleCheckboxValue(element, modelValue); - } - if (Object(modelValue) === modelValue) { - return getMultipleCheckboxValue(element, Object.values(modelValue)); - } - } - if (element.hasAttribute('value')) { - return element.checked ? element.getAttribute('value') : null; - } - return element.checked; +let componentMapByElement = new WeakMap(); +let componentMapByComponent = new Map(); +const registerComponent = (component) => { + componentMapByElement.set(component.element, component); + componentMapByComponent.set(component, component.name); +}; +const unregisterComponent = (component) => { + componentMapByElement.delete(component.element); + componentMapByComponent.delete(component); +}; +const getComponent = (element) => new Promise((resolve, reject) => { + let count = 0; + const maxCount = 10; + const interval = setInterval(() => { + const component = componentMapByElement.get(element); + if (component) { + clearInterval(interval); + resolve(component); } - return inputValue(element); - } - if (element instanceof HTMLSelectElement) { - if (element.multiple) { - return Array.from(element.selectedOptions).map((el) => el.value); + count++; + if (count > maxCount) { + clearInterval(interval); + reject(new Error(`Component not found for element ${getElementAsTagText(element)}`)); } - 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') { + }, 5); +}); +const findComponents = (currentComponent, onlyParents, onlyMatchName) => { + const components = []; + componentMapByComponent.forEach((componentName, component) => { + if (onlyParents && (currentComponent === component || !component.element.contains(currentComponent.element))) { return; } - if (element.type === 'radio') { - element.checked = element.value == value; + if (onlyMatchName && componentName !== onlyMatchName) { return; } - if (element.type === 'checkbox') { - if (Array.isArray(value)) { - element.checked = value.some((val) => val == element.value); + components.push(component); + }); + return components; +}; +const findChildren = (currentComponent) => { + const children = []; + componentMapByComponent.forEach((componentName, component) => { + if (currentComponent === component) { + return; + } + if (!currentComponent.element.contains(component.element)) { + return; + } + let foundChildComponent = false; + componentMapByComponent.forEach((childComponentName, childComponent) => { + if (foundChildComponent) { + return; } - else if (element.hasAttribute('value')) { - element.checked = element.value == value; + if (childComponent === component) { + return; } - else { - element.checked = value; + if (childComponent.element.contains(component.element)) { + foundChildComponent = true; } + }); + children.push(component); + }); + return children; +}; +const findParent = (currentComponent) => { + let parentElement = currentComponent.element.parentElement; + while (parentElement) { + const component = componentMapByElement.get(parentElement); + if (component) { + return component; + } + parentElement = parentElement.parentElement; + } + return null; +}; + +class HookManager { + constructor() { + this.hooks = new Map(); + } + register(hookName, callback) { + const hooks = this.hooks.get(hookName) || []; + hooks.push(callback); + this.hooks.set(hookName, hooks); + } + unregister(hookName, callback) { + const hooks = this.hooks.get(hookName) || []; + const index = hooks.indexOf(callback); + if (index === -1) { return; } + hooks.splice(index, 1); + this.hooks.set(hookName, hooks); } - 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; + triggerHook(hookName, ...args) { + const hooks = this.hooks.get(hookName) || []; + hooks.forEach((callback) => callback(...args)); } - value = value === undefined ? '' : value; - element.value = value; } -function getAllModelDirectiveFromElements(element) { - if (!element.dataset.model) { - return []; + +class ChangingItemsTracker { + constructor() { + this.changedItems = new Map(); + this.removedItems = new Map(); } - const directives = parseDirectives(element.dataset.model); - directives.forEach((directive) => { - if (directive.args.length > 0) { - throw new Error(`The data-model="${element.dataset.model}" format is invalid: it does not support passing arguments to the model.`); + setItem(itemName, newValue, previousValue) { + if (this.removedItems.has(itemName)) { + const removedRecord = this.removedItems.get(itemName); + this.removedItems.delete(itemName); + if (removedRecord.original === newValue) { + return; + } } - directive.action = normalizeModelName(directive.action); - }); - return directives; -} -function getModelDirectiveFromElement(element, throwOnMissing = true) { - const dataModelDirectives = getAllModelDirectiveFromElements(element); - if (dataModelDirectives.length > 0) { - return dataModelDirectives[0]; + if (this.changedItems.has(itemName)) { + const originalRecord = this.changedItems.get(itemName); + if (originalRecord.original === newValue) { + this.changedItems.delete(itemName); + return; + } + this.changedItems.set(itemName, { original: originalRecord.original, new: newValue }); + return; + } + this.changedItems.set(itemName, { original: previousValue, new: newValue }); } - 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) { - throw new Error(`The data-model="${formElement.dataset.model}" format is invalid: it does not support passing arguments to the model.`); + removeItem(itemName, currentValue) { + let trueOriginalValue = currentValue; + if (this.changedItems.has(itemName)) { + const originalRecord = this.changedItems.get(itemName); + trueOriginalValue = originalRecord.original; + this.changedItems.delete(itemName); + if (trueOriginalValue === null) { + return; } - directive.action = normalizeModelName(element.getAttribute('name')); - return directive; + } + if (!this.removedItems.has(itemName)) { + this.removedItems.set(itemName, { original: trueOriginalValue }); } } - if (!throwOnMissing) { - return null; + getChangedItems() { + return Array.from(this.changedItems, ([name, { new: value }]) => ({ name, value })); } - throw new Error(`Cannot determine the model name for "${getElementAsTagText(element)}": the element must either have a "data-model" (or "name" attribute living inside a
).`); -} -function elementBelongsToThisComponent(element, component) { - if (component.element === element) { - return true; + getRemovedItems() { + return Array.from(this.removedItems.keys()); } - if (!component.element.contains(element)) { - return false; + isEmpty() { + return this.changedItems.size === 0 && this.removedItems.size === 0; } - const closestLiveComponent = element.closest('[data-controller~="live"]'); - return closestLiveComponent === component.element; } -function cloneHTMLElement(element) { - const newElement = element.cloneNode(true); - if (!(newElement instanceof HTMLElement)) { - throw new Error('Could not clone element'); + +class ElementChanges { + constructor() { + this.addedClasses = new Set(); + this.removedClasses = new Set(); + this.styleChanges = new ChangingItemsTracker(); + this.attributeChanges = new ChangingItemsTracker(); } - return newElement; -} -function htmlToElement(html) { - const template = document.createElement('template'); - html = html.trim(); - template.innerHTML = html; - if (template.content.childElementCount > 1) { - throw new Error(`Component HTML contains ${template.content.childElementCount} elements, but only 1 root element is allowed.`); + addClass(className) { + if (!this.removedClasses.delete(className)) { + this.addedClasses.add(className); + } } - const child = template.content.firstElementChild; - if (!child) { - throw new Error('Child not found'); + removeClass(className) { + if (!this.addedClasses.delete(className)) { + this.removedClasses.add(className); + } } - if (!(child instanceof HTMLElement)) { - throw new Error(`Created element is not an HTMLElement: ${html.trim()}`); + addStyle(styleName, newValue, originalValue) { + this.styleChanges.setItem(styleName, newValue, originalValue); } - return child; -} -const getMultipleCheckboxValue = (element, currentValues) => { - const finalValues = [...currentValues]; - const value = inputValue(element); - const index = currentValues.indexOf(value); - if (element.checked) { - if (index === -1) { - finalValues.push(value); - } - return finalValues; + removeStyle(styleName, originalValue) { + this.styleChanges.removeItem(styleName, originalValue); } - if (index > -1) { - finalValues.splice(index, 1); + addAttribute(attributeName, newValue, originalValue) { + this.attributeChanges.setItem(attributeName, newValue, originalValue); } - return finalValues; -}; -const inputValue = (element) => element.dataset.value ? element.dataset.value : element.value; - -function getDeepData(data, propertyPath) { - const { currentLevelData, finalKey } = parseDeepData(data, propertyPath); - if (currentLevelData === undefined) { - return undefined; + removeAttribute(attributeName, originalValue) { + this.attributeChanges.removeItem(attributeName, originalValue); } - return currentLevelData[finalKey]; -} -const 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]]; + getAddedClasses() { + return [...this.addedClasses]; } - const finalKey = parts[parts.length - 1]; - return { - currentLevelData, - finalData, - finalKey, - parts, - }; -}; - -class ValueStore { - constructor(props) { - this.props = {}; - this.dirtyProps = {}; - this.pendingProps = {}; - this.updatedPropsFromParent = {}; - this.props = props; - } - get(name) { - const normalizedName = normalizeModelName(name); - if (this.dirtyProps[normalizedName] !== undefined) { - return this.dirtyProps[normalizedName]; - } - if (this.pendingProps[normalizedName] !== undefined) { - return this.pendingProps[normalizedName]; - } - if (this.props[normalizedName] !== undefined) { - return this.props[normalizedName]; - } - return getDeepData(this.props, normalizedName); - } - has(name) { - return this.get(name) !== undefined; + getRemovedClasses() { + return [...this.removedClasses]; } - set(name, value) { - const normalizedName = normalizeModelName(name); - if (this.get(normalizedName) === value) { - return false; - } - this.dirtyProps[normalizedName] = value; - return true; + getChangedStyles() { + return this.styleChanges.getChangedItems(); } - getOriginalProps() { - return { ...this.props }; + getRemovedStyles() { + return this.styleChanges.getRemovedItems(); } - getDirtyProps() { - return { ...this.dirtyProps }; + getChangedAttributes() { + return this.attributeChanges.getChangedItems(); } - getUpdatedPropsFromParent() { - return { ...this.updatedPropsFromParent }; + getRemovedAttributes() { + return this.attributeChanges.getRemovedItems(); } - flushDirtyPropsToPending() { - this.pendingProps = { ...this.dirtyProps }; - this.dirtyProps = {}; + applyToElement(element) { + element.classList.add(...this.addedClasses); + element.classList.remove(...this.removedClasses); + this.styleChanges.getChangedItems().forEach((change) => { + element.style.setProperty(change.name, change.value); + return; + }); + this.styleChanges.getRemovedItems().forEach((styleName) => { + element.style.removeProperty(styleName); + }); + this.attributeChanges.getChangedItems().forEach((change) => { + element.setAttribute(change.name, change.value); + }); + this.attributeChanges.getRemovedItems().forEach((attributeName) => { + element.removeAttribute(attributeName); + }); } - reinitializeAllProps(props) { - this.props = props; - this.updatedPropsFromParent = {}; - this.pendingProps = {}; + isEmpty() { + return (this.addedClasses.size === 0 && + this.removedClasses.size === 0 && + this.styleChanges.isEmpty() && + this.attributeChanges.isEmpty()); } - pushPendingPropsBackToDirty() { - this.dirtyProps = { ...this.pendingProps, ...this.dirtyProps }; - this.pendingProps = {}; +} + +class ExternalMutationTracker { + constructor(element, shouldTrackChangeCallback) { + this.changedElements = new WeakMap(); + this.changedElementsCount = 0; + this.addedElements = []; + this.removedElements = []; + this.isStarted = false; + this.element = element; + this.shouldTrackChangeCallback = shouldTrackChangeCallback; + this.mutationObserver = new MutationObserver(this.onMutations.bind(this)); } - storeNewPropsFromParent(props) { - let changed = false; - for (const [key, value] of Object.entries(props)) { - const currentValue = this.get(key); - if (currentValue !== value) { - changed = true; - } + start() { + if (this.isStarted) { + return; } - if (changed) { - this.updatedPropsFromParent = props; + this.mutationObserver.observe(this.element, { + childList: true, + subtree: true, + attributes: true, + attributeOldValue: true, + }); + this.isStarted = true; + } + stop() { + if (this.isStarted) { + this.mutationObserver.disconnect(); + this.isStarted = false; } - return changed; } -} - -// base IIFE to define idiomorph -var Idiomorph = (function () { - - //============================================================================= - // AND NOW IT BEGINS... - //============================================================================= - let EMPTY_SET = new Set(); - - // default configuration values, updatable by users now - let defaults = { - morphStyle: "outerHTML", - callbacks : { - beforeNodeAdded: noOp, - afterNodeAdded: noOp, - beforeNodeMorphed: noOp, - afterNodeMorphed: noOp, - beforeNodeRemoved: noOp, - afterNodeRemoved: noOp, - beforeAttributeUpdated: noOp, - - }, - head: { - style: 'merge', - shouldPreserve: function (elt) { - return elt.getAttribute("im-preserve") === "true"; - }, - shouldReAppend: function (elt) { - return elt.getAttribute("im-re-append") === "true"; - }, - shouldRemove: noOp, - afterHeadMorphed: noOp, - } - }; - - //============================================================================= - // Core Morphing Algorithm - morph, morphNormalizedContent, morphOldNodeTo, morphChildren - //============================================================================= - function morph(oldNode, newContent, config = {}) { - - if (oldNode instanceof Document) { - oldNode = oldNode.documentElement; + getChangedElement(element) { + return this.changedElements.has(element) ? this.changedElements.get(element) : null; + } + getAddedElements() { + return this.addedElements; + } + wasElementAdded(element) { + return this.addedElements.includes(element); + } + handlePendingChanges() { + this.onMutations(this.mutationObserver.takeRecords()); + } + onMutations(mutations) { + const handledAttributeMutations = new WeakMap(); + for (const mutation of mutations) { + const element = mutation.target; + if (!this.shouldTrackChangeCallback(element)) { + continue; } - - if (typeof newContent === 'string') { - newContent = parseContent(newContent); + if (this.isElementAddedByTranslation(element)) { + continue; } - - let normalizedContent = normalizeContent(newContent); - - let ctx = createMorphContext(oldNode, normalizedContent, config); - - return morphNormalizedContent(oldNode, normalizedContent, ctx); - } - - function morphNormalizedContent(oldNode, normalizedNewContent, ctx) { - if (ctx.head.block) { - let oldHead = oldNode.querySelector('head'); - let newHead = normalizedNewContent.querySelector('head'); - if (oldHead && newHead) { - let promises = handleHeadElement(newHead, oldHead, ctx); - // when head promises resolve, call morph again, ignoring the head tag - Promise.all(promises).then(function () { - morphNormalizedContent(oldNode, normalizedNewContent, Object.assign(ctx, { - head: { - block: false, - ignore: true - } - })); - }); - return; + let isChangeInAddedElement = false; + for (const addedElement of this.addedElements) { + if (addedElement.contains(element)) { + isChangeInAddedElement = true; + break; } } - - if (ctx.morphStyle === "innerHTML") { - - // innerHTML, so we are only updating the children - morphChildren(normalizedNewContent, oldNode, ctx); - return oldNode.children; - - } else if (ctx.morphStyle === "outerHTML" || ctx.morphStyle == null) { - // otherwise find the best element match in the new content, morph that, and merge its siblings - // into either side of the best match - let bestMatch = findBestNodeMatch(normalizedNewContent, oldNode, ctx); - - // stash the siblings that will need to be inserted on either side of the best match - let previousSibling = bestMatch?.previousSibling; - let nextSibling = bestMatch?.nextSibling; - - // morph it - let morphedNode = morphOldNodeTo(oldNode, bestMatch, ctx); - - if (bestMatch) { - // if there was a best match, merge the siblings in too and return the - // whole bunch - return insertSiblings(previousSibling, morphedNode, nextSibling); - } else { - // otherwise nothing was added to the DOM - return [] - } - } else { - throw "Do not understand how to morph style " + ctx.morphStyle; + if (isChangeInAddedElement) { + continue; + } + switch (mutation.type) { + case 'childList': + this.handleChildListMutation(mutation); + break; + case 'attributes': + if (!handledAttributeMutations.has(element)) { + handledAttributeMutations.set(element, []); + } + if (!handledAttributeMutations.get(element).includes(mutation.attributeName)) { + this.handleAttributeMutation(mutation); + handledAttributeMutations.set(element, [ + ...handledAttributeMutations.get(element), + mutation.attributeName, + ]); + } + break; } } - - - /** + } + handleChildListMutation(mutation) { + mutation.addedNodes.forEach((node) => { + if (!(node instanceof Element)) { + return; + } + if (this.removedElements.includes(node)) { + this.removedElements.splice(this.removedElements.indexOf(node), 1); + return; + } + if (this.isElementAddedByTranslation(node)) { + return; + } + this.addedElements.push(node); + }); + mutation.removedNodes.forEach((node) => { + if (!(node instanceof Element)) { + return; + } + if (this.addedElements.includes(node)) { + this.addedElements.splice(this.addedElements.indexOf(node), 1); + return; + } + this.removedElements.push(node); + }); + } + handleAttributeMutation(mutation) { + const element = mutation.target; + if (!this.changedElements.has(element)) { + this.changedElements.set(element, new ElementChanges()); + this.changedElementsCount++; + } + const changedElement = this.changedElements.get(element); + switch (mutation.attributeName) { + case 'class': + this.handleClassAttributeMutation(mutation, changedElement); + break; + case 'style': + this.handleStyleAttributeMutation(mutation, changedElement); + break; + default: + this.handleGenericAttributeMutation(mutation, changedElement); + } + if (changedElement.isEmpty()) { + this.changedElements.delete(element); + this.changedElementsCount--; + } + } + handleClassAttributeMutation(mutation, elementChanges) { + const element = mutation.target; + const previousValue = mutation.oldValue || ''; + const previousValues = previousValue.match(/(\S+)/gu) || []; + const newValues = [].slice.call(element.classList); + const addedValues = newValues.filter((value) => !previousValues.includes(value)); + const removedValues = previousValues.filter((value) => !newValues.includes(value)); + addedValues.forEach((value) => { + elementChanges.addClass(value); + }); + removedValues.forEach((value) => { + elementChanges.removeClass(value); + }); + } + handleStyleAttributeMutation(mutation, elementChanges) { + const element = mutation.target; + const previousValue = mutation.oldValue || ''; + const previousStyles = this.extractStyles(previousValue); + const newValue = element.getAttribute('style') || ''; + const newStyles = this.extractStyles(newValue); + const addedOrChangedStyles = Object.keys(newStyles).filter((key) => previousStyles[key] === undefined || previousStyles[key] !== newStyles[key]); + const removedStyles = Object.keys(previousStyles).filter((key) => !newStyles[key]); + addedOrChangedStyles.forEach((style) => { + elementChanges.addStyle(style, newStyles[style], previousStyles[style] === undefined ? null : previousStyles[style]); + }); + removedStyles.forEach((style) => { + elementChanges.removeStyle(style, previousStyles[style]); + }); + } + handleGenericAttributeMutation(mutation, elementChanges) { + const attributeName = mutation.attributeName; + const element = mutation.target; + let oldValue = mutation.oldValue; + let newValue = element.getAttribute(attributeName); + if (oldValue === attributeName) { + oldValue = ''; + } + if (newValue === attributeName) { + newValue = ''; + } + if (!element.hasAttribute(attributeName)) { + if (oldValue === null) { + return; + } + elementChanges.removeAttribute(attributeName, mutation.oldValue); + return; + } + if (newValue === oldValue) { + return; + } + elementChanges.addAttribute(attributeName, element.getAttribute(attributeName), mutation.oldValue); + } + extractStyles(styles) { + const styleObject = {}; + styles.split(';').forEach((style) => { + const parts = style.split(':'); + if (parts.length === 1) { + return; + } + const property = parts[0].trim(); + styleObject[property] = parts.slice(1).join(':').trim(); + }); + return styleObject; + } + isElementAddedByTranslation(element) { + return element.tagName === 'FONT' && element.getAttribute('style') === 'vertical-align: inherit;'; + } +} + +function parseDirectives(content) { + const directives = []; + if (!content) { + return directives; + } + let currentActionName = ''; + let currentArgumentValue = ''; + let currentArguments = []; + let currentModifiers = []; + let state = 'action'; + const getLastActionName = () => { + if (currentActionName) { + return currentActionName; + } + if (directives.length === 0) { + throw new Error('Could not find any directives'); + } + return directives[directives.length - 1].action; + }; + const pushInstruction = () => { + directives.push({ + action: currentActionName, + args: currentArguments, + modifiers: currentModifiers, + getString: () => { + return content; + }, + }); + currentActionName = ''; + currentArgumentValue = ''; + currentArguments = []; + currentModifiers = []; + state = 'action'; + }; + const pushArgument = () => { + currentArguments.push(currentArgumentValue.trim()); + currentArgumentValue = ''; + }; + const pushModifier = () => { + if (currentArguments.length > 1) { + throw new Error(`The modifier "${currentActionName}()" does not support multiple arguments.`); + } + currentModifiers.push({ + name: currentActionName, + value: currentArguments.length > 0 ? currentArguments[0] : null, + }); + currentActionName = ''; + 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; + } + currentArgumentValue += char; + break; + case 'after_arguments': + if (char === '|') { + pushModifier(); + break; + } + if (char !== ' ') { + throw new Error(`Missing space after ${getLastActionName()}()`); + } + pushInstruction(); + break; + } + } + switch (state) { + case 'action': + case 'after_arguments': + if (currentActionName) { + pushInstruction(); + } + break; + default: + throw new Error(`Did you forget to add a closing ")" after "${currentActionName}"?`); + } + return directives; +} + +function combineSpacedArray(parts) { + const finalParts = []; + parts.forEach((part) => { + finalParts.push(...trimAll(part).split(' ')); + }); + return finalParts; +} +function trimAll(str) { + return str.replace(/[\s]+/g, ' ').trim(); +} +function normalizeModelName(model) { + return (model + .replace(/\[]$/, '') + .split('[') + .map((s) => s.replace(']', '')) + .join('.')); +} + +function getValueFromElement(element, valueStore) { + if (element instanceof HTMLInputElement) { + if (element.type === 'checkbox') { + const modelNameData = getModelDirectiveFromElement(element, false); + if (modelNameData !== null) { + const modelValue = valueStore.get(modelNameData.action); + if (Array.isArray(modelValue)) { + return getMultipleCheckboxValue(element, modelValue); + } + if (Object(modelValue) === modelValue) { + return getMultipleCheckboxValue(element, Object.values(modelValue)); + } + } + if (element.hasAttribute('value')) { + return element.checked ? element.getAttribute('value') : null; + } + return element.checked; + } + 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)) { + element.checked = value.some((val) => val == element.value); + } + else if (element.hasAttribute('value')) { + element.checked = element.value == value; + } + else { + element.checked = 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) { + 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) { + throw new Error(`The data-model="${formElement.dataset.model}" format is invalid: it does not support passing arguments to the model.`); + } + directive.action = normalizeModelName(element.getAttribute('name')); + return directive; + } + } + if (!throwOnMissing) { + return null; + } + throw new Error(`Cannot determine the model name for "${getElementAsTagText(element)}": the element must either have a "data-model" (or "name" attribute living inside a ).`); +} +function elementBelongsToThisComponent(element, component) { + if (component.element === element) { + return true; + } + if (!component.element.contains(element)) { + return false; + } + const closestLiveComponent = element.closest('[data-controller~="live"]'); + return closestLiveComponent === component.element; +} +function cloneHTMLElement(element) { + const newElement = element.cloneNode(true); + if (!(newElement instanceof HTMLElement)) { + throw new Error('Could not clone element'); + } + return newElement; +} +function htmlToElement(html) { + const template = document.createElement('template'); + html = html.trim(); + template.innerHTML = html; + if (template.content.childElementCount > 1) { + throw new Error(`Component HTML contains ${template.content.childElementCount} elements, but only 1 root element is allowed.`); + } + const child = template.content.firstElementChild; + if (!child) { + throw new Error('Child not found'); + } + if (!(child instanceof HTMLElement)) { + throw new Error(`Created element is not an HTMLElement: ${html.trim()}`); + } + return child; +} +const getMultipleCheckboxValue = (element, currentValues) => { + const finalValues = [...currentValues]; + const value = inputValue(element); + const index = currentValues.indexOf(value); + if (element.checked) { + if (index === -1) { + finalValues.push(value); + } + return finalValues; + } + if (index > -1) { + finalValues.splice(index, 1); + } + return finalValues; +}; +const inputValue = (element) => element.dataset.value ? element.dataset.value : element.value; + +// base IIFE to define idiomorph +var Idiomorph = (function () { + + //============================================================================= + // AND NOW IT BEGINS... + //============================================================================= + let EMPTY_SET = new Set(); + + // default configuration values, updatable by users now + let defaults = { + morphStyle: "outerHTML", + callbacks : { + beforeNodeAdded: noOp, + afterNodeAdded: noOp, + beforeNodeMorphed: noOp, + afterNodeMorphed: noOp, + beforeNodeRemoved: noOp, + afterNodeRemoved: noOp, + beforeAttributeUpdated: noOp, + + }, + head: { + style: 'merge', + shouldPreserve: function (elt) { + return elt.getAttribute("im-preserve") === "true"; + }, + shouldReAppend: function (elt) { + return elt.getAttribute("im-re-append") === "true"; + }, + shouldRemove: noOp, + afterHeadMorphed: noOp, + } + }; + + //============================================================================= + // Core Morphing Algorithm - morph, morphNormalizedContent, morphOldNodeTo, morphChildren + //============================================================================= + function morph(oldNode, newContent, config = {}) { + + if (oldNode instanceof Document) { + oldNode = oldNode.documentElement; + } + + if (typeof newContent === 'string') { + newContent = parseContent(newContent); + } + + let normalizedContent = normalizeContent(newContent); + + let ctx = createMorphContext(oldNode, normalizedContent, config); + + return morphNormalizedContent(oldNode, normalizedContent, ctx); + } + + function morphNormalizedContent(oldNode, normalizedNewContent, ctx) { + if (ctx.head.block) { + let oldHead = oldNode.querySelector('head'); + let newHead = normalizedNewContent.querySelector('head'); + if (oldHead && newHead) { + let promises = handleHeadElement(newHead, oldHead, ctx); + // when head promises resolve, call morph again, ignoring the head tag + Promise.all(promises).then(function () { + morphNormalizedContent(oldNode, normalizedNewContent, Object.assign(ctx, { + head: { + block: false, + ignore: true + } + })); + }); + return; + } + } + + if (ctx.morphStyle === "innerHTML") { + + // innerHTML, so we are only updating the children + morphChildren(normalizedNewContent, oldNode, ctx); + return oldNode.children; + + } else if (ctx.morphStyle === "outerHTML" || ctx.morphStyle == null) { + // otherwise find the best element match in the new content, morph that, and merge its siblings + // into either side of the best match + let bestMatch = findBestNodeMatch(normalizedNewContent, oldNode, ctx); + + // stash the siblings that will need to be inserted on either side of the best match + let previousSibling = bestMatch?.previousSibling; + let nextSibling = bestMatch?.nextSibling; + + // morph it + let morphedNode = morphOldNodeTo(oldNode, bestMatch, ctx); + + if (bestMatch) { + // if there was a best match, merge the siblings in too and return the + // whole bunch + return insertSiblings(previousSibling, morphedNode, nextSibling); + } else { + // otherwise nothing was added to the DOM + return [] + } + } else { + throw "Do not understand how to morph style " + ctx.morphStyle; + } + } + + + /** * @param possibleActiveElement * @param ctx * @returns {boolean} @@ -950,922 +1388,585 @@ var Idiomorph = (function () { // If we have an id match, return the current potential match if (isIdSetMatch(newChild, potentialMatch, ctx)) { return potentialMatch; - } - - // computer the other potential matches of this new content - otherMatchCount += getIdIntersectionCount(ctx, potentialMatch, newContent); - if (otherMatchCount > newChildPotentialIdCount) { - // if we have more potential id matches in _other_ content, we - // do not have a good candidate for an id match, so return null - return null; - } - - // advanced to the next old content child - potentialMatch = potentialMatch.nextSibling; - } - } - return potentialMatch; - } - - //============================================================================= - // Scans forward from the insertionPoint in the old parent looking for a potential soft match - // for the newChild. We stop if we find a potential soft match for the new child OR - // if we find a potential id match in the old parents children OR if we find two - // potential soft matches for the next two pieces of new content - //============================================================================= - function findSoftMatch(newContent, oldParent, newChild, insertionPoint, ctx) { - - let potentialSoftMatch = insertionPoint; - let nextSibling = newChild.nextSibling; - let siblingSoftMatchCount = 0; - - while (potentialSoftMatch != null) { - - if (getIdIntersectionCount(ctx, potentialSoftMatch, newContent) > 0) { - // the current potential soft match has a potential id set match with the remaining new - // content so bail out of looking - return null; - } - - // if we have a soft match with the current node, return it - if (isSoftMatch(newChild, potentialSoftMatch)) { - return potentialSoftMatch; - } - - if (isSoftMatch(nextSibling, potentialSoftMatch)) { - // the next new node has a soft match with this node, so - // increment the count of future soft matches - siblingSoftMatchCount++; - nextSibling = nextSibling.nextSibling; - - // If there are two future soft matches, bail to allow the siblings to soft match - // so that we don't consume future soft matches for the sake of the current node - if (siblingSoftMatchCount >= 2) { - return null; - } - } - - // advanced to the next old content child - potentialSoftMatch = potentialSoftMatch.nextSibling; - } - - return potentialSoftMatch; - } - - function parseContent(newContent) { - let parser = new DOMParser(); - - // remove svgs to avoid false-positive matches on head, etc. - let contentWithSvgsRemoved = newContent.replace(/]*>|>)([\s\S]*?)<\/svg>/gim, ''); - - // if the newContent contains a html, head or body tag, we can simply parse it w/o wrapping - if (contentWithSvgsRemoved.match(/<\/html>/) || contentWithSvgsRemoved.match(/<\/head>/) || contentWithSvgsRemoved.match(/<\/body>/)) { - let content = parser.parseFromString(newContent, "text/html"); - // if it is a full HTML document, return the document itself as the parent container - if (contentWithSvgsRemoved.match(/<\/html>/)) { - content.generatedByIdiomorph = true; - return content; - } else { - // otherwise return the html element as the parent container - let htmlElement = content.firstChild; - if (htmlElement) { - htmlElement.generatedByIdiomorph = true; - return htmlElement; - } else { - return null; - } - } - } else { - // if it is partial HTML, wrap it in a template tag to provide a parent element and also to help - // deal with touchy tags like tr, tbody, etc. - let responseDoc = parser.parseFromString("", "text/html"); - let content = responseDoc.body.querySelector('template').content; - content.generatedByIdiomorph = true; - return content - } - } - - function normalizeContent(newContent) { - if (newContent == null) { - // noinspection UnnecessaryLocalVariableJS - const dummyParent = document.createElement('div'); - return dummyParent; - } else if (newContent.generatedByIdiomorph) { - // the template tag created by idiomorph parsing can serve as a dummy parent - return newContent; - } else if (newContent instanceof Node) { - // a single node is added as a child to a dummy parent - const dummyParent = document.createElement('div'); - dummyParent.append(newContent); - return dummyParent; - } else { - // all nodes in the array or HTMLElement collection are consolidated under - // a single dummy parent element - const dummyParent = document.createElement('div'); - for (const elt of [...newContent]) { - dummyParent.append(elt); - } - return dummyParent; - } - } - - function insertSiblings(previousSibling, morphedNode, nextSibling) { - let stack = []; - let added = []; - while (previousSibling != null) { - stack.push(previousSibling); - previousSibling = previousSibling.previousSibling; - } - while (stack.length > 0) { - let node = stack.pop(); - added.push(node); // push added preceding siblings on in order and insert - morphedNode.parentElement.insertBefore(node, morphedNode); - } - added.push(morphedNode); - while (nextSibling != null) { - stack.push(nextSibling); - added.push(nextSibling); // here we are going in order, so push on as we scan, rather than add - nextSibling = nextSibling.nextSibling; - } - while (stack.length > 0) { - morphedNode.parentElement.insertBefore(stack.pop(), morphedNode.nextSibling); - } - return added; - } - - function findBestNodeMatch(newContent, oldNode, ctx) { - let currentElement; - currentElement = newContent.firstChild; - let bestElement = currentElement; - let score = 0; - while (currentElement) { - let newScore = scoreElement(currentElement, oldNode, ctx); - if (newScore > score) { - bestElement = currentElement; - score = newScore; - } - currentElement = currentElement.nextSibling; - } - return bestElement; - } - - function scoreElement(node1, node2, ctx) { - if (isSoftMatch(node1, node2)) { - return .5 + getIdIntersectionCount(ctx, node1, node2); - } - return 0; - } - - function removeNode(tempNode, ctx) { - removeIdsFromConsideration(ctx, tempNode); - if (ctx.callbacks.beforeNodeRemoved(tempNode) === false) return; - - tempNode.remove(); - ctx.callbacks.afterNodeRemoved(tempNode); - } - - //============================================================================= - // ID Set Functions - //============================================================================= - - function isIdInConsideration(ctx, id) { - return !ctx.deadIds.has(id); - } - - function idIsWithinNode(ctx, id, targetNode) { - let idSet = ctx.idMap.get(targetNode) || EMPTY_SET; - return idSet.has(id); - } - - function removeIdsFromConsideration(ctx, node) { - let idSet = ctx.idMap.get(node) || EMPTY_SET; - for (const id of idSet) { - ctx.deadIds.add(id); - } - } - - function getIdIntersectionCount(ctx, node1, node2) { - let sourceSet = ctx.idMap.get(node1) || EMPTY_SET; - let matchCount = 0; - for (const id of sourceSet) { - // a potential match is an id in the source and potentialIdsSet, but - // that has not already been merged into the DOM - if (isIdInConsideration(ctx, id) && idIsWithinNode(ctx, id, node2)) { - ++matchCount; - } - } - return matchCount; - } + } - /** - * A bottom up algorithm that finds all elements with ids inside of the node - * argument and populates id sets for those nodes and all their parents, generating - * a set of ids contained within all nodes for the entire hierarchy in the DOM - * - * @param node {Element} - * @param {Map>} idMap - */ - function populateIdMapForNode(node, idMap) { - let nodeParent = node.parentElement; - // find all elements with an id property - let idElements = node.querySelectorAll('[id]'); - for (const elt of idElements) { - let current = elt; - // walk up the parent hierarchy of that element, adding the id - // of element to the parent's id set - while (current !== nodeParent && current != null) { - let idSet = idMap.get(current); - // if the id set doesn't exist, create it and insert it in the map - if (idSet == null) { - idSet = new Set(); - idMap.set(current, idSet); + // computer the other potential matches of this new content + otherMatchCount += getIdIntersectionCount(ctx, potentialMatch, newContent); + if (otherMatchCount > newChildPotentialIdCount) { + // if we have more potential id matches in _other_ content, we + // do not have a good candidate for an id match, so return null + return null; } - idSet.add(elt.id); - current = current.parentElement; + + // advanced to the next old content child + potentialMatch = potentialMatch.nextSibling; } } - } - - /** - * This function computes a map of nodes to all ids contained within that node (inclusive of the - * node). This map can be used to ask if two nodes have intersecting sets of ids, which allows - * for a looser definition of "matching" than tradition id matching, and allows child nodes - * to contribute to a parent nodes matching. - * - * @param {Element} oldContent the old content that will be morphed - * @param {Element} newContent the new content to morph to - * @returns {Map>} a map of nodes to id sets for the - */ - function createIdMap(oldContent, newContent) { - let idMap = new Map(); - populateIdMapForNode(oldContent, idMap); - populateIdMapForNode(newContent, idMap); - return idMap; + return potentialMatch; } //============================================================================= - // This is what ends up becoming the Idiomorph global object + // Scans forward from the insertionPoint in the old parent looking for a potential soft match + // for the newChild. We stop if we find a potential soft match for the new child OR + // if we find a potential id match in the old parents children OR if we find two + // potential soft matches for the next two pieces of new content //============================================================================= - return { - morph, - defaults - } - })(); + function findSoftMatch(newContent, oldParent, newChild, insertionPoint, ctx) { -function normalizeAttributesForComparison(element) { - const isFileInput = element instanceof HTMLInputElement && element.type === 'file'; - if (!isFileInput) { - if ('value' in element) { - element.setAttribute('value', element.value); - } - else if (element.hasAttribute('value')) { - element.setAttribute('value', ''); - } - } - Array.from(element.children).forEach((child) => { - normalizeAttributesForComparison(child); - }); -} + let potentialSoftMatch = insertionPoint; + let nextSibling = newChild.nextSibling; + let siblingSoftMatchCount = 0; -const syncAttributes = (fromEl, toEl) => { - for (let i = 0; i < fromEl.attributes.length; i++) { - const attr = fromEl.attributes[i]; - toEl.setAttribute(attr.name, attr.value); - } -}; -function executeMorphdom(rootFromElement, rootToElement, modifiedFieldElements, getElementValue, externalMutationTracker) { - const originalElementIdsToSwapAfter = []; - const originalElementsToPreserve = new Map(); - const markElementAsNeedingPostMorphSwap = (id, replaceWithClone) => { - const oldElement = originalElementsToPreserve.get(id); - if (!(oldElement instanceof HTMLElement)) { - throw new Error(`Original element with id ${id} not found`); - } - originalElementIdsToSwapAfter.push(id); - if (!replaceWithClone) { - return null; - } - const clonedOldElement = cloneHTMLElement(oldElement); - oldElement.replaceWith(clonedOldElement); - return clonedOldElement; - }; - rootToElement.querySelectorAll('[data-live-preserve]').forEach((newElement) => { - const id = newElement.id; - if (!id) { - throw new Error('The data-live-preserve attribute requires an id attribute to be set on the element'); - } - const oldElement = rootFromElement.querySelector(`#${id}`); - if (!(oldElement instanceof HTMLElement)) { - throw new Error(`The element with id "${id}" was not found in the original HTML`); - } - newElement.removeAttribute('data-live-preserve'); - originalElementsToPreserve.set(id, oldElement); - syncAttributes(newElement, oldElement); - }); - Idiomorph.morph(rootFromElement, rootToElement, { - callbacks: { - beforeNodeMorphed: (fromEl, toEl) => { - if (!(fromEl instanceof Element) || !(toEl instanceof Element)) { - return true; + while (potentialSoftMatch != null) { + + if (getIdIntersectionCount(ctx, potentialSoftMatch, newContent) > 0) { + // the current potential soft match has a potential id set match with the remaining new + // content so bail out of looking + return null; } - if (fromEl === rootFromElement) { - return true; + + // if we have a soft match with the current node, return it + if (isSoftMatch(newChild, potentialSoftMatch)) { + return potentialSoftMatch; } - if (fromEl.id && originalElementsToPreserve.has(fromEl.id)) { - if (fromEl.id === toEl.id) { - return false; - } - const clonedFromEl = markElementAsNeedingPostMorphSwap(fromEl.id, true); - if (!clonedFromEl) { - throw new Error('missing clone'); + + if (isSoftMatch(nextSibling, potentialSoftMatch)) { + // the next new node has a soft match with this node, so + // increment the count of future soft matches + siblingSoftMatchCount++; + nextSibling = nextSibling.nextSibling; + + // If there are two future soft matches, bail to allow the siblings to soft match + // so that we don't consume future soft matches for the sake of the current node + if (siblingSoftMatchCount >= 2) { + return null; } - Idiomorph.morph(clonedFromEl, toEl); - return false; } - if (fromEl instanceof HTMLElement && toEl instanceof HTMLElement) { - if (typeof fromEl.__x !== 'undefined') { - if (!window.Alpine) { - throw new Error('Unable to access Alpine.js though the global window.Alpine variable. Please make sure Alpine.js is loaded before Symfony UX LiveComponent.'); - } - if (typeof window.Alpine.morph !== 'function') { - throw new Error('Unable to access Alpine.js morph function. Please make sure the Alpine.js Morph plugin is installed and loaded, see https://alpinejs.dev/plugins/morph for more information.'); - } - window.Alpine.morph(fromEl.__x, toEl); - } - if (externalMutationTracker.wasElementAdded(fromEl)) { - fromEl.insertAdjacentElement('afterend', toEl); - return false; - } - if (modifiedFieldElements.includes(fromEl)) { - setValueOnElement(toEl, getElementValue(fromEl)); - } - if (fromEl === document.activeElement && - fromEl !== document.body && - null !== getModelDirectiveFromElement(fromEl, false)) { - setValueOnElement(toEl, getElementValue(fromEl)); - } - const elementChanges = externalMutationTracker.getChangedElement(fromEl); - if (elementChanges) { - elementChanges.applyToElement(toEl); - } - if (fromEl.nodeName.toUpperCase() !== 'OPTION' && fromEl.isEqualNode(toEl)) { - const normalizedFromEl = cloneHTMLElement(fromEl); - normalizeAttributesForComparison(normalizedFromEl); - const normalizedToEl = cloneHTMLElement(toEl); - normalizeAttributesForComparison(normalizedToEl); - if (normalizedFromEl.isEqualNode(normalizedToEl)) { - return false; - } + + // advanced to the next old content child + potentialSoftMatch = potentialSoftMatch.nextSibling; + } + + return potentialSoftMatch; + } + + function parseContent(newContent) { + let parser = new DOMParser(); + + // remove svgs to avoid false-positive matches on head, etc. + let contentWithSvgsRemoved = newContent.replace(/]*>|>)([\s\S]*?)<\/svg>/gim, ''); + + // if the newContent contains a html, head or body tag, we can simply parse it w/o wrapping + if (contentWithSvgsRemoved.match(/<\/html>/) || contentWithSvgsRemoved.match(/<\/head>/) || contentWithSvgsRemoved.match(/<\/body>/)) { + let content = parser.parseFromString(newContent, "text/html"); + // if it is a full HTML document, return the document itself as the parent container + if (contentWithSvgsRemoved.match(/<\/html>/)) { + content.generatedByIdiomorph = true; + return content; + } else { + // otherwise return the html element as the parent container + let htmlElement = content.firstChild; + if (htmlElement) { + htmlElement.generatedByIdiomorph = true; + return htmlElement; + } else { + return null; } } - if (fromEl.hasAttribute('data-skip-morph') || (fromEl.id && fromEl.id !== toEl.id)) { - fromEl.innerHTML = toEl.innerHTML; - return true; - } - if (fromEl.parentElement?.hasAttribute('data-skip-morph')) { - return false; - } - return !fromEl.hasAttribute('data-live-ignore'); - }, - beforeNodeRemoved(node) { - if (!(node instanceof HTMLElement)) { - return true; - } - if (node.id && originalElementsToPreserve.has(node.id)) { - markElementAsNeedingPostMorphSwap(node.id, false); - return true; - } - if (externalMutationTracker.wasElementAdded(node)) { - return false; + } else { + // if it is partial HTML, wrap it in a template tag to provide a parent element and also to help + // deal with touchy tags like tr, tbody, etc. + let responseDoc = parser.parseFromString("", "text/html"); + let content = responseDoc.body.querySelector('template').content; + content.generatedByIdiomorph = true; + return content + } + } + + function normalizeContent(newContent) { + if (newContent == null) { + // noinspection UnnecessaryLocalVariableJS + const dummyParent = document.createElement('div'); + return dummyParent; + } else if (newContent.generatedByIdiomorph) { + // the template tag created by idiomorph parsing can serve as a dummy parent + return newContent; + } else if (newContent instanceof Node) { + // a single node is added as a child to a dummy parent + const dummyParent = document.createElement('div'); + dummyParent.append(newContent); + return dummyParent; + } else { + // all nodes in the array or HTMLElement collection are consolidated under + // a single dummy parent element + const dummyParent = document.createElement('div'); + for (const elt of [...newContent]) { + dummyParent.append(elt); } - return !node.hasAttribute('data-live-ignore'); - }, - }, - }); - originalElementIdsToSwapAfter.forEach((id) => { - const newElement = rootFromElement.querySelector(`#${id}`); - const originalElement = originalElementsToPreserve.get(id); - if (!(newElement instanceof HTMLElement) || !(originalElement instanceof HTMLElement)) { - throw new Error('Missing elements.'); + return dummyParent; + } } - newElement.replaceWith(originalElement); - }); -} -class UnsyncedInputsTracker { - constructor(component, modelElementResolver) { - this.elementEventListeners = [ - { event: 'input', callback: (event) => this.handleInputEvent(event) }, - ]; - this.component = component; - this.modelElementResolver = modelElementResolver; - this.unsyncedInputs = new UnsyncedInputContainer(); - } - activate() { - this.elementEventListeners.forEach(({ event, callback }) => { - this.component.element.addEventListener(event, callback); - }); - } - deactivate() { - this.elementEventListeners.forEach(({ event, callback }) => { - this.component.element.removeEventListener(event, callback); - }); - } - markModelAsSynced(modelName) { - this.unsyncedInputs.markModelAsSynced(modelName); - } - handleInputEvent(event) { - const target = event.target; - if (!target) { - return; - } - this.updateModelFromElement(target); - } - updateModelFromElement(element) { - if (!elementBelongsToThisComponent(element, this.component)) { - return; - } - if (!(element instanceof HTMLElement)) { - throw new Error('Could not update model for non HTMLElement'); + function insertSiblings(previousSibling, morphedNode, nextSibling) { + let stack = []; + let added = []; + while (previousSibling != null) { + stack.push(previousSibling); + previousSibling = previousSibling.previousSibling; + } + while (stack.length > 0) { + let node = stack.pop(); + added.push(node); // push added preceding siblings on in order and insert + morphedNode.parentElement.insertBefore(node, morphedNode); + } + added.push(morphedNode); + while (nextSibling != null) { + stack.push(nextSibling); + added.push(nextSibling); // here we are going in order, so push on as we scan, rather than add + nextSibling = nextSibling.nextSibling; + } + while (stack.length > 0) { + morphedNode.parentElement.insertBefore(stack.pop(), morphedNode.nextSibling); + } + return added; } - const modelName = this.modelElementResolver.getModelName(element); - this.unsyncedInputs.add(element, modelName); - } - getUnsyncedInputs() { - return this.unsyncedInputs.allUnsyncedInputs(); - } - getUnsyncedModels() { - return Array.from(this.unsyncedInputs.getUnsyncedModelNames()); - } - resetUnsyncedFields() { - this.unsyncedInputs.resetUnsyncedFields(); - } -} -class UnsyncedInputContainer { - constructor() { - this.unsyncedNonModelFields = []; - this.unsyncedModelNames = []; - this.unsyncedModelFields = new Map(); - } - add(element, modelName = null) { - if (modelName) { - this.unsyncedModelFields.set(modelName, element); - if (!this.unsyncedModelNames.includes(modelName)) { - this.unsyncedModelNames.push(modelName); + + function findBestNodeMatch(newContent, oldNode, ctx) { + let currentElement; + currentElement = newContent.firstChild; + let bestElement = currentElement; + let score = 0; + while (currentElement) { + let newScore = scoreElement(currentElement, oldNode, ctx); + if (newScore > score) { + bestElement = currentElement; + score = newScore; + } + currentElement = currentElement.nextSibling; } - return; + return bestElement; } - this.unsyncedNonModelFields.push(element); - } - resetUnsyncedFields() { - this.unsyncedModelFields.forEach((value, key) => { - if (!this.unsyncedModelNames.includes(key)) { - this.unsyncedModelFields.delete(key); + + function scoreElement(node1, node2, ctx) { + if (isSoftMatch(node1, node2)) { + return .5 + getIdIntersectionCount(ctx, node1, node2); } - }); - } - allUnsyncedInputs() { - return [...this.unsyncedNonModelFields, ...this.unsyncedModelFields.values()]; - } - markModelAsSynced(modelName) { - const index = this.unsyncedModelNames.indexOf(modelName); - if (index !== -1) { - this.unsyncedModelNames.splice(index, 1); + return 0; } - } - getUnsyncedModelNames() { - return this.unsyncedModelNames; - } -} -class HookManager { - constructor() { - this.hooks = new Map(); - } - register(hookName, callback) { - const hooks = this.hooks.get(hookName) || []; - hooks.push(callback); - this.hooks.set(hookName, hooks); - } - unregister(hookName, callback) { - const hooks = this.hooks.get(hookName) || []; - const index = hooks.indexOf(callback); - if (index === -1) { - return; + function removeNode(tempNode, ctx) { + removeIdsFromConsideration(ctx, tempNode); + if (ctx.callbacks.beforeNodeRemoved(tempNode) === false) return; + + tempNode.remove(); + ctx.callbacks.afterNodeRemoved(tempNode); } - hooks.splice(index, 1); - this.hooks.set(hookName, hooks); - } - triggerHook(hookName, ...args) { - const hooks = this.hooks.get(hookName) || []; - hooks.forEach((callback) => callback(...args)); - } -} -class BackendResponse { - constructor(response) { - this.response = response; - } - async getBody() { - if (!this.body) { - this.body = await this.response.text(); + //============================================================================= + // ID Set Functions + //============================================================================= + + function isIdInConsideration(ctx, id) { + return !ctx.deadIds.has(id); } - return this.body; - } -} -class ChangingItemsTracker { - constructor() { - this.changedItems = new Map(); - this.removedItems = new Map(); - } - setItem(itemName, newValue, previousValue) { - if (this.removedItems.has(itemName)) { - const removedRecord = this.removedItems.get(itemName); - this.removedItems.delete(itemName); - if (removedRecord.original === newValue) { - return; + function idIsWithinNode(ctx, id, targetNode) { + let idSet = ctx.idMap.get(targetNode) || EMPTY_SET; + return idSet.has(id); + } + + function removeIdsFromConsideration(ctx, node) { + let idSet = ctx.idMap.get(node) || EMPTY_SET; + for (const id of idSet) { + ctx.deadIds.add(id); } } - if (this.changedItems.has(itemName)) { - const originalRecord = this.changedItems.get(itemName); - if (originalRecord.original === newValue) { - this.changedItems.delete(itemName); - return; + + function getIdIntersectionCount(ctx, node1, node2) { + let sourceSet = ctx.idMap.get(node1) || EMPTY_SET; + let matchCount = 0; + for (const id of sourceSet) { + // a potential match is an id in the source and potentialIdsSet, but + // that has not already been merged into the DOM + if (isIdInConsideration(ctx, id) && idIsWithinNode(ctx, id, node2)) { + ++matchCount; + } } - this.changedItems.set(itemName, { original: originalRecord.original, new: newValue }); - return; + return matchCount; } - this.changedItems.set(itemName, { original: previousValue, new: newValue }); - } - removeItem(itemName, currentValue) { - let trueOriginalValue = currentValue; - if (this.changedItems.has(itemName)) { - const originalRecord = this.changedItems.get(itemName); - trueOriginalValue = originalRecord.original; - this.changedItems.delete(itemName); - if (trueOriginalValue === null) { - return; + + /** + * A bottom up algorithm that finds all elements with ids inside of the node + * argument and populates id sets for those nodes and all their parents, generating + * a set of ids contained within all nodes for the entire hierarchy in the DOM + * + * @param node {Element} + * @param {Map>} idMap + */ + function populateIdMapForNode(node, idMap) { + let nodeParent = node.parentElement; + // find all elements with an id property + let idElements = node.querySelectorAll('[id]'); + for (const elt of idElements) { + let current = elt; + // walk up the parent hierarchy of that element, adding the id + // of element to the parent's id set + while (current !== nodeParent && current != null) { + let idSet = idMap.get(current); + // if the id set doesn't exist, create it and insert it in the map + if (idSet == null) { + idSet = new Set(); + idMap.set(current, idSet); + } + idSet.add(elt.id); + current = current.parentElement; + } } } - if (!this.removedItems.has(itemName)) { - this.removedItems.set(itemName, { original: trueOriginalValue }); + + /** + * This function computes a map of nodes to all ids contained within that node (inclusive of the + * node). This map can be used to ask if two nodes have intersecting sets of ids, which allows + * for a looser definition of "matching" than tradition id matching, and allows child nodes + * to contribute to a parent nodes matching. + * + * @param {Element} oldContent the old content that will be morphed + * @param {Element} newContent the new content to morph to + * @returns {Map>} a map of nodes to id sets for the + */ + function createIdMap(oldContent, newContent) { + let idMap = new Map(); + populateIdMapForNode(oldContent, idMap); + populateIdMapForNode(newContent, idMap); + return idMap; } - } - getChangedItems() { - return Array.from(this.changedItems, ([name, { new: value }]) => ({ name, value })); - } - getRemovedItems() { - return Array.from(this.removedItems.keys()); - } - isEmpty() { - return this.changedItems.size === 0 && this.removedItems.size === 0; - } -} -class ElementChanges { - constructor() { - this.addedClasses = new Set(); - this.removedClasses = new Set(); - this.styleChanges = new ChangingItemsTracker(); - this.attributeChanges = new ChangingItemsTracker(); - } - addClass(className) { - if (!this.removedClasses.delete(className)) { - this.addedClasses.add(className); + //============================================================================= + // This is what ends up becoming the Idiomorph global object + //============================================================================= + return { + morph, + defaults } - } - removeClass(className) { - if (!this.addedClasses.delete(className)) { - this.removedClasses.add(className); + })(); + +function normalizeAttributesForComparison(element) { + const isFileInput = element instanceof HTMLInputElement && element.type === 'file'; + if (!isFileInput) { + if ('value' in element) { + element.setAttribute('value', element.value); + } + else if (element.hasAttribute('value')) { + element.setAttribute('value', ''); } } - addStyle(styleName, newValue, originalValue) { - this.styleChanges.setItem(styleName, newValue, originalValue); - } - removeStyle(styleName, originalValue) { - this.styleChanges.removeItem(styleName, originalValue); - } - addAttribute(attributeName, newValue, originalValue) { - this.attributeChanges.setItem(attributeName, newValue, originalValue); - } - removeAttribute(attributeName, originalValue) { - this.attributeChanges.removeItem(attributeName, originalValue); - } - getAddedClasses() { - return [...this.addedClasses]; - } - getRemovedClasses() { - return [...this.removedClasses]; - } - getChangedStyles() { - return this.styleChanges.getChangedItems(); - } - getRemovedStyles() { - return this.styleChanges.getRemovedItems(); - } - getChangedAttributes() { - return this.attributeChanges.getChangedItems(); - } - getRemovedAttributes() { - return this.attributeChanges.getRemovedItems(); - } - applyToElement(element) { - element.classList.add(...this.addedClasses); - element.classList.remove(...this.removedClasses); - this.styleChanges.getChangedItems().forEach((change) => { - element.style.setProperty(change.name, change.value); - return; - }); - this.styleChanges.getRemovedItems().forEach((styleName) => { - element.style.removeProperty(styleName); - }); - this.attributeChanges.getChangedItems().forEach((change) => { - element.setAttribute(change.name, change.value); - }); - this.attributeChanges.getRemovedItems().forEach((attributeName) => { - element.removeAttribute(attributeName); - }); - } - isEmpty() { - return (this.addedClasses.size === 0 && - this.removedClasses.size === 0 && - this.styleChanges.isEmpty() && - this.attributeChanges.isEmpty()); - } + Array.from(element.children).forEach((child) => { + normalizeAttributesForComparison(child); + }); } -class ExternalMutationTracker { - constructor(element, shouldTrackChangeCallback) { - this.changedElements = new WeakMap(); - this.changedElementsCount = 0; - this.addedElements = []; - this.removedElements = []; - this.isStarted = false; - this.element = element; - this.shouldTrackChangeCallback = shouldTrackChangeCallback; - this.mutationObserver = new MutationObserver(this.onMutations.bind(this)); +const syncAttributes = (fromEl, toEl) => { + for (let i = 0; i < fromEl.attributes.length; i++) { + const attr = fromEl.attributes[i]; + toEl.setAttribute(attr.name, attr.value); } - start() { - if (this.isStarted) { - return; +}; +function executeMorphdom(rootFromElement, rootToElement, modifiedFieldElements, getElementValue, externalMutationTracker) { + const originalElementIdsToSwapAfter = []; + const originalElementsToPreserve = new Map(); + const markElementAsNeedingPostMorphSwap = (id, replaceWithClone) => { + const oldElement = originalElementsToPreserve.get(id); + if (!(oldElement instanceof HTMLElement)) { + throw new Error(`Original element with id ${id} not found`); } - this.mutationObserver.observe(this.element, { - childList: true, - subtree: true, - attributes: true, - attributeOldValue: true, - }); - this.isStarted = true; - } - stop() { - if (this.isStarted) { - this.mutationObserver.disconnect(); - this.isStarted = false; + originalElementIdsToSwapAfter.push(id); + if (!replaceWithClone) { + return null; } - } - getChangedElement(element) { - return this.changedElements.has(element) ? this.changedElements.get(element) : null; - } - getAddedElements() { - return this.addedElements; - } - wasElementAdded(element) { - return this.addedElements.includes(element); - } - handlePendingChanges() { - this.onMutations(this.mutationObserver.takeRecords()); - } - onMutations(mutations) { - const handledAttributeMutations = new WeakMap(); - for (const mutation of mutations) { - const element = mutation.target; - if (!this.shouldTrackChangeCallback(element)) { - continue; - } - if (this.isElementAddedByTranslation(element)) { - continue; - } - let isChangeInAddedElement = false; - for (const addedElement of this.addedElements) { - if (addedElement.contains(element)) { - isChangeInAddedElement = true; - break; + const clonedOldElement = cloneHTMLElement(oldElement); + oldElement.replaceWith(clonedOldElement); + return clonedOldElement; + }; + rootToElement.querySelectorAll('[data-live-preserve]').forEach((newElement) => { + const id = newElement.id; + if (!id) { + throw new Error('The data-live-preserve attribute requires an id attribute to be set on the element'); + } + const oldElement = rootFromElement.querySelector(`#${id}`); + if (!(oldElement instanceof HTMLElement)) { + throw new Error(`The element with id "${id}" was not found in the original HTML`); + } + newElement.removeAttribute('data-live-preserve'); + originalElementsToPreserve.set(id, oldElement); + syncAttributes(newElement, oldElement); + }); + Idiomorph.morph(rootFromElement, rootToElement, { + callbacks: { + beforeNodeMorphed: (fromEl, toEl) => { + if (!(fromEl instanceof Element) || !(toEl instanceof Element)) { + return true; } - } - if (isChangeInAddedElement) { - continue; - } - switch (mutation.type) { - case 'childList': - this.handleChildListMutation(mutation); - break; - case 'attributes': - if (!handledAttributeMutations.has(element)) { - handledAttributeMutations.set(element, []); + if (fromEl === rootFromElement) { + return true; + } + if (fromEl.id && originalElementsToPreserve.has(fromEl.id)) { + if (fromEl.id === toEl.id) { + return false; + } + const clonedFromEl = markElementAsNeedingPostMorphSwap(fromEl.id, true); + if (!clonedFromEl) { + throw new Error('missing clone'); + } + Idiomorph.morph(clonedFromEl, toEl); + return false; + } + if (fromEl instanceof HTMLElement && toEl instanceof HTMLElement) { + if (typeof fromEl.__x !== 'undefined') { + if (!window.Alpine) { + throw new Error('Unable to access Alpine.js though the global window.Alpine variable. Please make sure Alpine.js is loaded before Symfony UX LiveComponent.'); + } + if (typeof window.Alpine.morph !== 'function') { + throw new Error('Unable to access Alpine.js morph function. Please make sure the Alpine.js Morph plugin is installed and loaded, see https://alpinejs.dev/plugins/morph for more information.'); + } + window.Alpine.morph(fromEl.__x, toEl); + } + if (externalMutationTracker.wasElementAdded(fromEl)) { + fromEl.insertAdjacentElement('afterend', toEl); + return false; + } + if (modifiedFieldElements.includes(fromEl)) { + setValueOnElement(toEl, getElementValue(fromEl)); + } + if (fromEl === document.activeElement && + fromEl !== document.body && + null !== getModelDirectiveFromElement(fromEl, false)) { + setValueOnElement(toEl, getElementValue(fromEl)); } - if (!handledAttributeMutations.get(element).includes(mutation.attributeName)) { - this.handleAttributeMutation(mutation); - handledAttributeMutations.set(element, [ - ...handledAttributeMutations.get(element), - mutation.attributeName, - ]); + const elementChanges = externalMutationTracker.getChangedElement(fromEl); + if (elementChanges) { + elementChanges.applyToElement(toEl); } - break; - } + if (fromEl.nodeName.toUpperCase() !== 'OPTION' && fromEl.isEqualNode(toEl)) { + const normalizedFromEl = cloneHTMLElement(fromEl); + normalizeAttributesForComparison(normalizedFromEl); + const normalizedToEl = cloneHTMLElement(toEl); + normalizeAttributesForComparison(normalizedToEl); + if (normalizedFromEl.isEqualNode(normalizedToEl)) { + return false; + } + } + } + if (fromEl.hasAttribute('data-skip-morph') || (fromEl.id && fromEl.id !== toEl.id)) { + fromEl.innerHTML = toEl.innerHTML; + return true; + } + if (fromEl.parentElement?.hasAttribute('data-skip-morph')) { + return false; + } + return !fromEl.hasAttribute('data-live-ignore'); + }, + beforeNodeRemoved(node) { + if (!(node instanceof HTMLElement)) { + return true; + } + if (node.id && originalElementsToPreserve.has(node.id)) { + markElementAsNeedingPostMorphSwap(node.id, false); + return true; + } + if (externalMutationTracker.wasElementAdded(node)) { + return false; + } + return !node.hasAttribute('data-live-ignore'); + }, + }, + }); + originalElementIdsToSwapAfter.forEach((id) => { + const newElement = rootFromElement.querySelector(`#${id}`); + const originalElement = originalElementsToPreserve.get(id); + if (!(newElement instanceof HTMLElement) || !(originalElement instanceof HTMLElement)) { + throw new Error('Missing elements.'); } + newElement.replaceWith(originalElement); + }); +} + +class UnsyncedInputsTracker { + constructor(component, modelElementResolver) { + this.elementEventListeners = [ + { event: 'input', callback: (event) => this.handleInputEvent(event) }, + ]; + this.component = component; + this.modelElementResolver = modelElementResolver; + this.unsyncedInputs = new UnsyncedInputContainer(); } - handleChildListMutation(mutation) { - mutation.addedNodes.forEach((node) => { - if (!(node instanceof Element)) { - return; - } - if (this.removedElements.includes(node)) { - this.removedElements.splice(this.removedElements.indexOf(node), 1); - return; - } - if (this.isElementAddedByTranslation(node)) { - return; - } - this.addedElements.push(node); + activate() { + this.elementEventListeners.forEach(({ event, callback }) => { + this.component.element.addEventListener(event, callback); }); - mutation.removedNodes.forEach((node) => { - if (!(node instanceof Element)) { - return; - } - if (this.addedElements.includes(node)) { - this.addedElements.splice(this.addedElements.indexOf(node), 1); - return; - } - this.removedElements.push(node); + } + deactivate() { + this.elementEventListeners.forEach(({ event, callback }) => { + this.component.element.removeEventListener(event, callback); }); } - handleAttributeMutation(mutation) { - const element = mutation.target; - if (!this.changedElements.has(element)) { - this.changedElements.set(element, new ElementChanges()); - this.changedElementsCount++; + markModelAsSynced(modelName) { + this.unsyncedInputs.markModelAsSynced(modelName); + } + handleInputEvent(event) { + const target = event.target; + if (!target) { + return; } - const changedElement = this.changedElements.get(element); - switch (mutation.attributeName) { - case 'class': - this.handleClassAttributeMutation(mutation, changedElement); - break; - case 'style': - this.handleStyleAttributeMutation(mutation, changedElement); - break; - default: - this.handleGenericAttributeMutation(mutation, changedElement); + this.updateModelFromElement(target); + } + updateModelFromElement(element) { + if (!elementBelongsToThisComponent(element, this.component)) { + return; } - if (changedElement.isEmpty()) { - this.changedElements.delete(element); - this.changedElementsCount--; + if (!(element instanceof HTMLElement)) { + throw new Error('Could not update model for non HTMLElement'); } + const modelName = this.modelElementResolver.getModelName(element); + this.unsyncedInputs.add(element, modelName); } - handleClassAttributeMutation(mutation, elementChanges) { - const element = mutation.target; - const previousValue = mutation.oldValue || ''; - const previousValues = previousValue.match(/(\S+)/gu) || []; - const newValues = [].slice.call(element.classList); - const addedValues = newValues.filter((value) => !previousValues.includes(value)); - const removedValues = previousValues.filter((value) => !newValues.includes(value)); - addedValues.forEach((value) => { - elementChanges.addClass(value); - }); - removedValues.forEach((value) => { - elementChanges.removeClass(value); - }); + getUnsyncedInputs() { + return this.unsyncedInputs.allUnsyncedInputs(); } - handleStyleAttributeMutation(mutation, elementChanges) { - const element = mutation.target; - const previousValue = mutation.oldValue || ''; - const previousStyles = this.extractStyles(previousValue); - const newValue = element.getAttribute('style') || ''; - const newStyles = this.extractStyles(newValue); - const addedOrChangedStyles = Object.keys(newStyles).filter((key) => previousStyles[key] === undefined || previousStyles[key] !== newStyles[key]); - const removedStyles = Object.keys(previousStyles).filter((key) => !newStyles[key]); - addedOrChangedStyles.forEach((style) => { - elementChanges.addStyle(style, newStyles[style], previousStyles[style] === undefined ? null : previousStyles[style]); - }); - removedStyles.forEach((style) => { - elementChanges.removeStyle(style, previousStyles[style]); - }); + getUnsyncedModels() { + return Array.from(this.unsyncedInputs.getUnsyncedModelNames()); } - handleGenericAttributeMutation(mutation, elementChanges) { - const attributeName = mutation.attributeName; - const element = mutation.target; - let oldValue = mutation.oldValue; - let newValue = element.getAttribute(attributeName); - if (oldValue === attributeName) { - oldValue = ''; - } - if (newValue === attributeName) { - newValue = ''; - } - if (!element.hasAttribute(attributeName)) { - if (oldValue === null) { - return; + resetUnsyncedFields() { + this.unsyncedInputs.resetUnsyncedFields(); + } +} +class UnsyncedInputContainer { + constructor() { + this.unsyncedNonModelFields = []; + this.unsyncedModelNames = []; + this.unsyncedModelFields = new Map(); + } + add(element, modelName = null) { + if (modelName) { + this.unsyncedModelFields.set(modelName, element); + if (!this.unsyncedModelNames.includes(modelName)) { + this.unsyncedModelNames.push(modelName); } - elementChanges.removeAttribute(attributeName, mutation.oldValue); - return; - } - if (newValue === oldValue) { return; } - elementChanges.addAttribute(attributeName, element.getAttribute(attributeName), mutation.oldValue); + this.unsyncedNonModelFields.push(element); } - extractStyles(styles) { - const styleObject = {}; - styles.split(';').forEach((style) => { - const parts = style.split(':'); - if (parts.length === 1) { - return; + resetUnsyncedFields() { + this.unsyncedModelFields.forEach((value, key) => { + if (!this.unsyncedModelNames.includes(key)) { + this.unsyncedModelFields.delete(key); } - const property = parts[0].trim(); - styleObject[property] = parts.slice(1).join(':').trim(); }); - return styleObject; } - isElementAddedByTranslation(element) { - return element.tagName === 'FONT' && element.getAttribute('style') === 'vertical-align: inherit;'; + allUnsyncedInputs() { + return [...this.unsyncedNonModelFields, ...this.unsyncedModelFields.values()]; + } + markModelAsSynced(modelName) { + const index = this.unsyncedModelNames.indexOf(modelName); + if (index !== -1) { + this.unsyncedModelNames.splice(index, 1); + } + } + getUnsyncedModelNames() { + return this.unsyncedModelNames; } } -let componentMapByElement = new WeakMap(); -let componentMapByComponent = new Map(); -const registerComponent = (component) => { - componentMapByElement.set(component.element, component); - componentMapByComponent.set(component, component.name); -}; -const unregisterComponent = (component) => { - componentMapByElement.delete(component.element); - componentMapByComponent.delete(component); -}; -const getComponent = (element) => new Promise((resolve, reject) => { - let count = 0; - const maxCount = 10; - const interval = setInterval(() => { - const component = componentMapByElement.get(element); - if (component) { - clearInterval(interval); - resolve(component); - } - count++; - if (count > maxCount) { - clearInterval(interval); - reject(new Error(`Component not found for element ${getElementAsTagText(element)}`)); - } - }, 5); -}); -const findComponents = (currentComponent, onlyParents, onlyMatchName) => { - const components = []; - componentMapByComponent.forEach((componentName, component) => { - if (onlyParents && (currentComponent === component || !component.element.contains(currentComponent.element))) { - return; +function getDeepData(data, propertyPath) { + const { currentLevelData, finalKey } = parseDeepData(data, propertyPath); + if (currentLevelData === undefined) { + return undefined; + } + return currentLevelData[finalKey]; +} +const 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, + }; +}; + +class ValueStore { + constructor(props) { + this.props = {}; + this.dirtyProps = {}; + this.pendingProps = {}; + this.updatedPropsFromParent = {}; + this.props = props; + } + get(name) { + const normalizedName = normalizeModelName(name); + if (this.dirtyProps[normalizedName] !== undefined) { + return this.dirtyProps[normalizedName]; } - if (onlyMatchName && componentName !== onlyMatchName) { - return; + if (this.pendingProps[normalizedName] !== undefined) { + return this.pendingProps[normalizedName]; } - components.push(component); - }); - return components; -}; -const findChildren = (currentComponent) => { - const children = []; - componentMapByComponent.forEach((componentName, component) => { - if (currentComponent === component) { - return; + if (this.props[normalizedName] !== undefined) { + return this.props[normalizedName]; } - if (!currentComponent.element.contains(component.element)) { - return; + return getDeepData(this.props, normalizedName); + } + has(name) { + return this.get(name) !== undefined; + } + set(name, value) { + const normalizedName = normalizeModelName(name); + if (this.get(normalizedName) === value) { + return false; } - let foundChildComponent = false; - componentMapByComponent.forEach((childComponentName, childComponent) => { - if (foundChildComponent) { - return; - } - if (childComponent === component) { - return; - } - if (childComponent.element.contains(component.element)) { - foundChildComponent = true; + this.dirtyProps[normalizedName] = value; + return true; + } + getOriginalProps() { + return { ...this.props }; + } + getDirtyProps() { + return { ...this.dirtyProps }; + } + getUpdatedPropsFromParent() { + return { ...this.updatedPropsFromParent }; + } + flushDirtyPropsToPending() { + this.pendingProps = { ...this.dirtyProps }; + this.dirtyProps = {}; + } + reinitializeAllProps(props) { + this.props = props; + this.updatedPropsFromParent = {}; + this.pendingProps = {}; + } + pushPendingPropsBackToDirty() { + this.dirtyProps = { ...this.pendingProps, ...this.dirtyProps }; + this.pendingProps = {}; + } + storeNewPropsFromParent(props) { + let changed = false; + for (const [key, value] of Object.entries(props)) { + const currentValue = this.get(key); + if (currentValue !== value) { + changed = true; } - }); - children.push(component); - }); - return children; -}; -const findParent = (currentComponent) => { - let parentElement = currentComponent.element.parentElement; - while (parentElement) { - const component = componentMapByElement.get(parentElement); - if (component) { - return component; } - parentElement = parentElement.parentElement; + if (changed) { + this.updatedPropsFromParent = props; + } + return changed; } - return null; -}; +} class Component { constructor(element, name, props, listeners, id, backend, elementDriver) { @@ -2216,126 +2317,136 @@ function proxifyComponent(component) { }); } -class BackendRequest { - constructor(promise, actions, updateModels) { - this.isResolved = false; - this.promise = promise; - this.promise.then((response) => { - this.isResolved = true; - return response; - }); - this.actions = actions; - this.updatedModels = updateModels; +class StimulusElementDriver { + constructor(controller) { + this.controller = controller; } - containsOneOfActions(targetedActions) { - return this.actions.filter((action) => targetedActions.includes(action)).length > 0; + getModelName(element) { + const modelDirective = getModelDirectiveFromElement(element, false); + if (!modelDirective) { + return null; + } + return modelDirective.action; } - areAnyModelsUpdated(targetedModels) { - return this.updatedModels.filter((model) => targetedModels.includes(model)).length > 0; + getComponentProps() { + return this.controller.propsValue; + } + getEventsToEmit() { + return this.controller.eventsToEmitValue; + } + getBrowserEventsToDispatch() { + return this.controller.eventsToDispatchValue; } } -class RequestBuilder { - constructor(url, method = 'post') { - this.url = url; - this.method = method; - } - buildRequest(props, actions, updated, children, updatedPropsFromParent, files) { - const splitUrl = this.url.split('?'); - let [url] = splitUrl; - const [, queryString] = splitUrl; - const params = new URLSearchParams(queryString || ''); - const fetchOptions = {}; - fetchOptions.headers = { - Accept: 'application/vnd.live-component+html', - 'X-Requested-With': 'XMLHttpRequest', - }; - const totalFiles = Object.entries(files).reduce((total, current) => total + current.length, 0); - const hasFingerprints = Object.keys(children).length > 0; - if (actions.length === 0 && - totalFiles === 0 && - this.method === 'get' && - this.willDataFitInUrl(JSON.stringify(props), JSON.stringify(updated), params, JSON.stringify(children), JSON.stringify(updatedPropsFromParent))) { - params.set('props', JSON.stringify(props)); - params.set('updated', JSON.stringify(updated)); - if (Object.keys(updatedPropsFromParent).length > 0) { - params.set('propsFromParent', JSON.stringify(updatedPropsFromParent)); - } - if (hasFingerprints) { - params.set('children', JSON.stringify(children)); - } - fetchOptions.method = 'GET'; - } - else { - fetchOptions.method = 'POST'; - const requestData = { props, updated }; - if (Object.keys(updatedPropsFromParent).length > 0) { - requestData.propsFromParent = updatedPropsFromParent; - } - if (hasFingerprints) { - requestData.children = children; - } - if (actions.length > 0) { - if (actions.length === 1) { - requestData.args = actions[0].args; - url += `/${encodeURIComponent(actions[0].name)}`; - } - else { - url += '/_batch'; - requestData.actions = actions; +function getModelBinding (modelDirective) { + let shouldRender = true; + let targetEventName = null; + let debounce = false; + modelDirective.modifiers.forEach((modifier) => { + switch (modifier.name) { + case 'on': + if (!modifier.value) { + throw new Error(`The "on" modifier in ${modelDirective.getString()} requires a value - e.g. on(change).`); } - } - const formData = new FormData(); - formData.append('data', JSON.stringify(requestData)); - for (const [key, value] of Object.entries(files)) { - const length = value.length; - for (let i = 0; i < length; ++i) { - formData.append(key, value[i]); + if (!['input', 'change'].includes(modifier.value)) { + throw new Error(`The "on" modifier in ${modelDirective.getString()} only accepts the arguments "input" or "change".`); } - } - fetchOptions.body = formData; + targetEventName = modifier.value; + break; + case 'norender': + shouldRender = false; + break; + case 'debounce': + debounce = modifier.value ? Number.parseInt(modifier.value) : true; + break; + default: + throw new Error(`Unknown modifier "${modifier.name}" in data-model="${modelDirective.getString()}".`); } - const paramsString = params.toString(); - return { - url: `${url}${paramsString.length > 0 ? `?${paramsString}` : ''}`, - fetchOptions, - }; + }); + const [modelName, innerModelName] = modelDirective.action.split(':'); + return { + modelName, + innerModelName: innerModelName || null, + shouldRender, + debounce, + targetEventName, + }; +} + +class ChildComponentPlugin { + constructor(component) { + this.parentModelBindings = []; + this.component = component; + const modelDirectives = getAllModelDirectiveFromElements(this.component.element); + this.parentModelBindings = modelDirectives.map(getModelBinding); } - willDataFitInUrl(propsJson, updatedJson, params, childrenJson, propsFromParentJson) { - const urlEncodedJsonData = new URLSearchParams(propsJson + updatedJson + childrenJson + propsFromParentJson).toString(); - return (urlEncodedJsonData + params.toString()).length < 1500; + attachToComponent(component) { + component.on('request:started', (requestData) => { + requestData.children = this.getChildrenFingerprints(); + }); + component.on('model:set', (model, value) => { + this.notifyParentModelChange(model, value); + }); + } + getChildrenFingerprints() { + const fingerprints = {}; + this.getChildren().forEach((child) => { + if (!child.id) { + throw new Error('missing id'); + } + fingerprints[child.id] = { + fingerprint: child.fingerprint, + tag: child.element.tagName.toLowerCase(), + }; + }); + return fingerprints; } -} - -class Backend { - constructor(url, method = 'post') { - this.requestBuilder = new RequestBuilder(url, method); + notifyParentModelChange(modelName, value) { + const parentComponent = findParent(this.component); + if (!parentComponent) { + return; + } + this.parentModelBindings.forEach((modelBinding) => { + const childModelName = modelBinding.innerModelName || 'value'; + if (childModelName !== modelName) { + return; + } + parentComponent.set(modelBinding.modelName, value, modelBinding.shouldRender, modelBinding.debounce); + }); } - makeRequest(props, actions, updated, children, updatedPropsFromParent, files) { - const { url, fetchOptions } = this.requestBuilder.buildRequest(props, actions, updated, children, updatedPropsFromParent, files); - return new BackendRequest(fetch(url, fetchOptions), actions.map((backendAction) => backendAction.name), Object.keys(updated)); + getChildren() { + return findChildren(this.component); } } -class StimulusElementDriver { - constructor(controller) { - this.controller = controller; +class LazyPlugin { + constructor() { + this.intersectionObserver = null; } - getModelName(element) { - const modelDirective = getModelDirectiveFromElement(element, false); - if (!modelDirective) { - return null; + attachToComponent(component) { + if ('lazy' !== component.element.attributes.getNamedItem('loading')?.value) { + return; } - return modelDirective.action; - } - getComponentProps() { - return this.controller.propsValue; - } - getEventsToEmit() { - return this.controller.eventsToEmitValue; + component.on('connect', () => { + this.getObserver().observe(component.element); + }); + component.on('disconnect', () => { + this.intersectionObserver?.unobserve(component.element); + }); } - getBrowserEventsToDispatch() { - return this.controller.eventsToDispatchValue; + getObserver() { + if (!this.intersectionObserver) { + this.intersectionObserver = new IntersectionObserver((entries, observer) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + entry.target.dispatchEvent(new CustomEvent('live:appear')); + observer.unobserve(entry.target); + } + }); + }); + } + return this.intersectionObserver; } } @@ -2514,23 +2625,6 @@ const parseLoadingAction = (action, isLoading) => { throw new Error(`Unknown data-loading action "${action}"`); }; -class ValidatedFieldsPlugin { - attachToComponent(component) { - component.on('model:set', (modelName) => { - this.handleModelSet(modelName, component.valueStore); - }); - } - handleModelSet(modelName, valueStore) { - if (valueStore.has('validatedFields')) { - const validatedFields = [...valueStore.get('validatedFields')]; - if (!validatedFields.includes(modelName)) { - validatedFields.push(modelName); - } - valueStore.set('validatedFields', validatedFields); - } - } -} - class PageUnloadingPlugin { constructor() { this.isConnected = false; @@ -2647,77 +2741,6 @@ class PollingPlugin { } } -class SetValueOntoModelFieldsPlugin { - attachToComponent(component) { - this.synchronizeValueOfModelFields(component); - component.on('render:finished', () => { - this.synchronizeValueOfModelFields(component); - }); - } - synchronizeValueOfModelFields(component) { - component.element.querySelectorAll('[data-model]').forEach((element) => { - if (!(element instanceof HTMLElement)) { - throw new Error('Invalid element using data-model.'); - } - if (element instanceof HTMLFormElement) { - return; - } - if (!elementBelongsToThisComponent(element, component)) { - return; - } - const modelDirective = getModelDirectiveFromElement(element); - if (!modelDirective) { - return; - } - const modelName = modelDirective.action; - if (component.getUnsyncedModels().includes(modelName)) { - return; - } - if (component.valueStore.has(modelName)) { - setValueOnElement(element, component.valueStore.get(modelName)); - } - if (element instanceof HTMLSelectElement && !element.multiple) { - component.valueStore.set(modelName, getValueFromElement(element, component.valueStore)); - } - }); - } -} - -function getModelBinding (modelDirective) { - let shouldRender = true; - let targetEventName = null; - let debounce = false; - modelDirective.modifiers.forEach((modifier) => { - switch (modifier.name) { - case 'on': - if (!modifier.value) { - throw new Error(`The "on" modifier in ${modelDirective.getString()} requires a value - e.g. on(change).`); - } - if (!['input', 'change'].includes(modifier.value)) { - throw new Error(`The "on" modifier in ${modelDirective.getString()} only accepts the arguments "input" or "change".`); - } - targetEventName = modifier.value; - break; - case 'norender': - shouldRender = false; - break; - case 'debounce': - debounce = modifier.value ? Number.parseInt(modifier.value) : true; - break; - default: - throw new Error(`Unknown modifier "${modifier.name}" in data-model="${modelDirective.getString()}".`); - } - }); - const [modelName, innerModelName] = modelDirective.action.split(':'); - return { - modelName, - innerModelName: innerModelName || null, - shouldRender, - debounce, - targetEventName, - }; -} - function isValueEmpty(value) { if (null === value || value === '' || undefined === value || (Array.isArray(value) && value.length === 0)) { return true; @@ -2841,79 +2864,56 @@ class QueryStringPlugin { } } -class ChildComponentPlugin { - constructor(component) { - this.parentModelBindings = []; - this.component = component; - const modelDirectives = getAllModelDirectiveFromElements(this.component.element); - this.parentModelBindings = modelDirectives.map(getModelBinding); - } +class SetValueOntoModelFieldsPlugin { attachToComponent(component) { - component.on('request:started', (requestData) => { - requestData.children = this.getChildrenFingerprints(); - }); - component.on('model:set', (model, value) => { - this.notifyParentModelChange(model, value); + this.synchronizeValueOfModelFields(component); + component.on('render:finished', () => { + this.synchronizeValueOfModelFields(component); }); } - getChildrenFingerprints() { - const fingerprints = {}; - this.getChildren().forEach((child) => { - if (!child.id) { - throw new Error('missing id'); + synchronizeValueOfModelFields(component) { + component.element.querySelectorAll('[data-model]').forEach((element) => { + if (!(element instanceof HTMLElement)) { + throw new Error('Invalid element using data-model.'); } - fingerprints[child.id] = { - fingerprint: child.fingerprint, - tag: child.element.tagName.toLowerCase(), - }; - }); - return fingerprints; - } - notifyParentModelChange(modelName, value) { - const parentComponent = findParent(this.component); - if (!parentComponent) { - return; - } - this.parentModelBindings.forEach((modelBinding) => { - const childModelName = modelBinding.innerModelName || 'value'; - if (childModelName !== modelName) { + if (element instanceof HTMLFormElement) { return; } - parentComponent.set(modelBinding.modelName, value, modelBinding.shouldRender, modelBinding.debounce); + if (!elementBelongsToThisComponent(element, component)) { + return; + } + const modelDirective = getModelDirectiveFromElement(element); + if (!modelDirective) { + return; + } + const modelName = modelDirective.action; + if (component.getUnsyncedModels().includes(modelName)) { + return; + } + if (component.valueStore.has(modelName)) { + setValueOnElement(element, component.valueStore.get(modelName)); + } + if (element instanceof HTMLSelectElement && !element.multiple) { + component.valueStore.set(modelName, getValueFromElement(element, component.valueStore)); + } }); } - getChildren() { - return findChildren(this.component); - } } -class LazyPlugin { - constructor() { - this.intersectionObserver = null; - } +class ValidatedFieldsPlugin { attachToComponent(component) { - if ('lazy' !== component.element.attributes.getNamedItem('loading')?.value) { - return; - } - component.on('connect', () => { - this.getObserver().observe(component.element); - }); - component.on('disconnect', () => { - this.intersectionObserver?.unobserve(component.element); + component.on('model:set', (modelName) => { + this.handleModelSet(modelName, component.valueStore); }); } - getObserver() { - if (!this.intersectionObserver) { - this.intersectionObserver = new IntersectionObserver((entries, observer) => { - entries.forEach((entry) => { - if (entry.isIntersecting) { - entry.target.dispatchEvent(new CustomEvent('live:appear')); - observer.unobserve(entry.target); - } - }); - }); + handleModelSet(modelName, valueStore) { + if (valueStore.has('validatedFields')) { + const validatedFields = [...valueStore.get('validatedFields')]; + if (!validatedFields.includes(modelName)) { + validatedFields.push(modelName); + } + valueStore.set('validatedFields', validatedFields); } - return this.intersectionObserver; } } diff --git a/src/LiveComponent/assets/src/Component/UnsyncedInputsTracker.ts b/src/LiveComponent/assets/src/Component/UnsyncedInputsTracker.ts index 151b785a10f..ed8bd1f83b1 100644 --- a/src/LiveComponent/assets/src/Component/UnsyncedInputsTracker.ts +++ b/src/LiveComponent/assets/src/Component/UnsyncedInputsTracker.ts @@ -1,5 +1,5 @@ -import type { ElementDriver } from './ElementDriver'; import { elementBelongsToThisComponent } from '../dom_utils'; +import type { ElementDriver } from './ElementDriver'; import type Component from './index'; export default class { diff --git a/src/LiveComponent/assets/src/Component/index.ts b/src/LiveComponent/assets/src/Component/index.ts index 28807c57884..7db1f564a7b 100644 --- a/src/LiveComponent/assets/src/Component/index.ts +++ b/src/LiveComponent/assets/src/Component/index.ts @@ -1,16 +1,16 @@ import type { BackendAction, BackendInterface } from '../Backend/Backend'; -import ValueStore from './ValueStore'; -import { normalizeModelName } from '../string_utils'; import type BackendRequest from '../Backend/BackendRequest'; +import BackendResponse from '../Backend/BackendResponse'; +import { findComponents, registerComponent, unregisterComponent } from '../ComponentRegistry'; +import HookManager from '../HookManager'; +import ExternalMutationTracker from '../Rendering/ExternalMutationTracker'; import { elementBelongsToThisComponent, getValueFromElement, htmlToElement } from '../dom_utils'; import { executeMorphdom } from '../morphdom'; -import UnsyncedInputsTracker from './UnsyncedInputsTracker'; +import { normalizeModelName } from '../string_utils'; import type { ElementDriver } from './ElementDriver'; -import HookManager from '../HookManager'; +import UnsyncedInputsTracker from './UnsyncedInputsTracker'; +import ValueStore from './ValueStore'; import type { PluginInterface } from './plugins/PluginInterface'; -import BackendResponse from '../Backend/BackendResponse'; -import ExternalMutationTracker from '../Rendering/ExternalMutationTracker'; -import { findComponents, registerComponent, unregisterComponent } from '../ComponentRegistry'; declare const Turbo: any; diff --git a/src/LiveComponent/assets/src/Component/plugins/ChildComponentPlugin.ts b/src/LiveComponent/assets/src/Component/plugins/ChildComponentPlugin.ts index be7b8ef3a9b..36a0918f878 100644 --- a/src/LiveComponent/assets/src/Component/plugins/ChildComponentPlugin.ts +++ b/src/LiveComponent/assets/src/Component/plugins/ChildComponentPlugin.ts @@ -1,9 +1,9 @@ -import type Component from '../../Component'; -import type { PluginInterface } from './PluginInterface'; import type { ChildrenFingerprints } from '../../Backend/Backend'; +import type Component from '../../Component'; +import { findChildren, findParent } from '../../ComponentRegistry'; import getModelBinding, { type ModelBinding } from '../../Directive/get_model_binding'; import { getAllModelDirectiveFromElements } from '../../dom_utils'; -import { findChildren, findParent } from '../../ComponentRegistry'; +import type { PluginInterface } from './PluginInterface'; /** * Handles all interactions for child components of a component. diff --git a/src/LiveComponent/assets/src/Component/plugins/LazyPlugin.ts b/src/LiveComponent/assets/src/Component/plugins/LazyPlugin.ts index 3cdae8ecfb2..944c1eaa61a 100644 --- a/src/LiveComponent/assets/src/Component/plugins/LazyPlugin.ts +++ b/src/LiveComponent/assets/src/Component/plugins/LazyPlugin.ts @@ -1,5 +1,5 @@ -import type { PluginInterface } from './PluginInterface'; import type Component from '../index'; +import type { PluginInterface } from './PluginInterface'; export default class implements PluginInterface { private intersectionObserver: IntersectionObserver | null = null; diff --git a/src/LiveComponent/assets/src/Component/plugins/LoadingPlugin.ts b/src/LiveComponent/assets/src/Component/plugins/LoadingPlugin.ts index 211a7f35155..de52293dcfb 100644 --- a/src/LiveComponent/assets/src/Component/plugins/LoadingPlugin.ts +++ b/src/LiveComponent/assets/src/Component/plugins/LoadingPlugin.ts @@ -1,8 +1,8 @@ +import type BackendRequest from '../../Backend/BackendRequest'; +import type Component from '../../Component'; import { type Directive, type DirectiveModifier, parseDirectives } from '../../Directive/directives_parser'; import { elementBelongsToThisComponent } from '../../dom_utils'; import { combineSpacedArray } from '../../string_utils'; -import type BackendRequest from '../../Backend/BackendRequest'; -import type Component from '../../Component'; import type { PluginInterface } from './PluginInterface'; interface ElementLoadingDirectives { diff --git a/src/LiveComponent/assets/src/Component/plugins/PollingPlugin.ts b/src/LiveComponent/assets/src/Component/plugins/PollingPlugin.ts index 505ecc1d28e..8a22cabefb3 100644 --- a/src/LiveComponent/assets/src/Component/plugins/PollingPlugin.ts +++ b/src/LiveComponent/assets/src/Component/plugins/PollingPlugin.ts @@ -1,6 +1,6 @@ -import type Component from '../index'; import { parseDirectives } from '../../Directive/directives_parser'; import PollingDirector from '../../PollingDirector'; +import type Component from '../index'; import type { PluginInterface } from './PluginInterface'; export default class implements PluginInterface { diff --git a/src/LiveComponent/assets/src/Component/plugins/QueryStringPlugin.ts b/src/LiveComponent/assets/src/Component/plugins/QueryStringPlugin.ts index 2d2b29ea55c..c0ac2f08849 100644 --- a/src/LiveComponent/assets/src/Component/plugins/QueryStringPlugin.ts +++ b/src/LiveComponent/assets/src/Component/plugins/QueryStringPlugin.ts @@ -1,6 +1,6 @@ +import { HistoryStrategy, UrlUtils } from '../../url_utils'; import type Component from '../index'; import type { PluginInterface } from './PluginInterface'; -import { UrlUtils, HistoryStrategy } from '../../url_utils'; interface QueryMapping { /** diff --git a/src/LiveComponent/assets/src/Component/plugins/SetValueOntoModelFieldsPlugin.ts b/src/LiveComponent/assets/src/Component/plugins/SetValueOntoModelFieldsPlugin.ts index 9606c487348..3ff33c0f0a5 100644 --- a/src/LiveComponent/assets/src/Component/plugins/SetValueOntoModelFieldsPlugin.ts +++ b/src/LiveComponent/assets/src/Component/plugins/SetValueOntoModelFieldsPlugin.ts @@ -1,10 +1,10 @@ -import type Component from '../index'; import { elementBelongsToThisComponent, getModelDirectiveFromElement, getValueFromElement, setValueOnElement, } from '../../dom_utils'; +import type Component from '../index'; import type { PluginInterface } from './PluginInterface'; /** diff --git a/src/LiveComponent/assets/src/Component/plugins/ValidatedFieldsPlugin.ts b/src/LiveComponent/assets/src/Component/plugins/ValidatedFieldsPlugin.ts index dbc67681211..5225b5f3c05 100644 --- a/src/LiveComponent/assets/src/Component/plugins/ValidatedFieldsPlugin.ts +++ b/src/LiveComponent/assets/src/Component/plugins/ValidatedFieldsPlugin.ts @@ -1,5 +1,5 @@ -import type Component from '../index'; import type ValueStore from '../ValueStore'; +import type Component from '../index'; import type { PluginInterface } from './PluginInterface'; export default class implements PluginInterface { diff --git a/src/LiveComponent/assets/src/HookManager.ts b/src/LiveComponent/assets/src/HookManager.ts index 9db14fccb9d..87b4a7bfb0f 100644 --- a/src/LiveComponent/assets/src/HookManager.ts +++ b/src/LiveComponent/assets/src/HookManager.ts @@ -1,4 +1,4 @@ -import type { ComponentHookName, ComponentHookCallback } from './Component'; +import type { ComponentHookCallback, ComponentHookName } from './Component'; export default class { private hooks: Map void>> = new Map(); diff --git a/src/LiveComponent/assets/src/dom_utils.ts b/src/LiveComponent/assets/src/dom_utils.ts index 28b8d233a59..745e40c58a1 100644 --- a/src/LiveComponent/assets/src/dom_utils.ts +++ b/src/LiveComponent/assets/src/dom_utils.ts @@ -1,8 +1,8 @@ +import type Component from './Component'; import type ValueStore from './Component/ValueStore'; import { type Directive, parseDirectives } from './Directive/directives_parser'; -import { normalizeModelName } from './string_utils'; -import type Component from './Component'; import getElementAsTagText from './Util/getElementAsTagText'; +import { normalizeModelName } from './string_utils'; /** * Return the "value" of any given element. diff --git a/src/LiveComponent/assets/src/live_controller.ts b/src/LiveComponent/assets/src/live_controller.ts index 3902d56f8ef..278915e0b06 100644 --- a/src/LiveComponent/assets/src/live_controller.ts +++ b/src/LiveComponent/assets/src/live_controller.ts @@ -1,20 +1,20 @@ import { Controller } from '@hotwired/stimulus'; -import { parseDirectives, type DirectiveModifier } from './Directive/directives_parser'; -import { getModelDirectiveFromElement, getValueFromElement, elementBelongsToThisComponent } from './dom_utils'; -import Component, { proxifyComponent } from './Component'; import Backend, { type BackendInterface } from './Backend/Backend'; +import Component, { proxifyComponent } from './Component'; import { StimulusElementDriver } from './Component/ElementDriver'; +import ChildComponentPlugin from './Component/plugins/ChildComponentPlugin'; +import LazyPlugin from './Component/plugins/LazyPlugin'; import LoadingPlugin from './Component/plugins/LoadingPlugin'; -import ValidatedFieldsPlugin from './Component/plugins/ValidatedFieldsPlugin'; import PageUnloadingPlugin from './Component/plugins/PageUnloadingPlugin'; +import type { PluginInterface } from './Component/plugins/PluginInterface'; import PollingPlugin from './Component/plugins/PollingPlugin'; +import QueryStringPlugin from './Component/plugins/QueryStringPlugin'; import SetValueOntoModelFieldsPlugin from './Component/plugins/SetValueOntoModelFieldsPlugin'; -import type { PluginInterface } from './Component/plugins/PluginInterface'; +import ValidatedFieldsPlugin from './Component/plugins/ValidatedFieldsPlugin'; +import { type DirectiveModifier, parseDirectives } from './Directive/directives_parser'; import getModelBinding from './Directive/get_model_binding'; -import QueryStringPlugin from './Component/plugins/QueryStringPlugin'; -import ChildComponentPlugin from './Component/plugins/ChildComponentPlugin'; import getElementAsTagText from './Util/getElementAsTagText'; -import LazyPlugin from './Component/plugins/LazyPlugin'; +import { elementBelongsToThisComponent, getModelDirectiveFromElement, getValueFromElement } from './dom_utils'; export { Component }; export { getComponent } from './ComponentRegistry'; diff --git a/src/LiveComponent/assets/src/morphdom.ts b/src/LiveComponent/assets/src/morphdom.ts index 9df94e02da1..af0eb630a9a 100644 --- a/src/LiveComponent/assets/src/morphdom.ts +++ b/src/LiveComponent/assets/src/morphdom.ts @@ -1,8 +1,8 @@ -import { cloneHTMLElement, getModelDirectiveFromElement, setValueOnElement } from './dom_utils'; // @ts-ignore import { Idiomorph } from 'idiomorph/dist/idiomorph.esm.js'; -import { normalizeAttributesForComparison } from './normalize_attributes_for_comparison'; import type ExternalMutationTracker from './Rendering/ExternalMutationTracker'; +import { cloneHTMLElement, getModelDirectiveFromElement, setValueOnElement } from './dom_utils'; +import { normalizeAttributesForComparison } from './normalize_attributes_for_comparison'; const syncAttributes = (fromEl: Element, toEl: Element): void => { for (let i = 0; i < fromEl.attributes.length; i++) { diff --git a/src/LiveComponent/assets/test/Component/index.test.ts b/src/LiveComponent/assets/test/Component/index.test.ts index 1d72ee0adcf..79ef365581c 100644 --- a/src/LiveComponent/assets/test/Component/index.test.ts +++ b/src/LiveComponent/assets/test/Component/index.test.ts @@ -1,9 +1,9 @@ -import Component, { proxifyComponent } from '../../src/Component'; +import { waitFor } from '@testing-library/dom'; +import { Response } from 'node-fetch'; import type { BackendAction, BackendInterface } from '../../src/Backend/Backend'; import BackendRequest from '../../src/Backend/BackendRequest'; -import { Response } from 'node-fetch'; -import { waitFor } from '@testing-library/dom'; import type BackendResponse from '../../src/Backend/BackendResponse'; +import Component, { proxifyComponent } from '../../src/Component'; import { noopElementDriver } from '../tools'; interface MockBackend extends BackendInterface { diff --git a/src/LiveComponent/assets/test/ComponentRegistry.test.ts b/src/LiveComponent/assets/test/ComponentRegistry.test.ts index e47cc14e394..3d1867deb27 100644 --- a/src/LiveComponent/assets/test/ComponentRegistry.test.ts +++ b/src/LiveComponent/assets/test/ComponentRegistry.test.ts @@ -1,8 +1,8 @@ -import Component from '../src/Component'; -import { registerComponent, resetRegistry, getComponent, findComponents } from '../src/ComponentRegistry'; -import BackendRequest from '../src/Backend/BackendRequest'; -import type { BackendInterface } from '../src/Backend/Backend'; import { Response } from 'node-fetch'; +import type { BackendInterface } from '../src/Backend/Backend'; +import BackendRequest from '../src/Backend/BackendRequest'; +import Component from '../src/Component'; +import { findComponents, getComponent, registerComponent, resetRegistry } from '../src/ComponentRegistry'; import { noopElementDriver } from './tools'; const createComponent = (element: HTMLElement, name = 'foo-component'): Component => { diff --git a/src/LiveComponent/assets/test/Directive/get_model_binding.test.ts b/src/LiveComponent/assets/test/Directive/get_model_binding.test.ts index 4c4ff6c1f9f..cbaaaf5a42a 100644 --- a/src/LiveComponent/assets/test/Directive/get_model_binding.test.ts +++ b/src/LiveComponent/assets/test/Directive/get_model_binding.test.ts @@ -1,5 +1,5 @@ -import getModelBinding from '../../src/Directive/get_model_binding'; import { parseDirectives } from '../../src/Directive/directives_parser'; +import getModelBinding from '../../src/Directive/get_model_binding'; describe('get_model_binding', () => { it('returns correctly with simple directive', () => { diff --git a/src/LiveComponent/assets/test/Util/getElementAsTagText.test.ts b/src/LiveComponent/assets/test/Util/getElementAsTagText.test.ts index 4bd12b3a907..b311ea7cc58 100644 --- a/src/LiveComponent/assets/test/Util/getElementAsTagText.test.ts +++ b/src/LiveComponent/assets/test/Util/getElementAsTagText.test.ts @@ -1,5 +1,5 @@ -import { htmlToElement } from '../../src/dom_utils'; import getElementAsTagText from '../../src/Util/getElementAsTagText'; +import { htmlToElement } from '../../src/dom_utils'; describe('getElementAsTagText', () => { it('returns self-closing tag correctly', () => { diff --git a/src/LiveComponent/assets/test/controller/action.test.ts b/src/LiveComponent/assets/test/controller/action.test.ts index 283e2515b51..d3e10c1ada8 100644 --- a/src/LiveComponent/assets/test/controller/action.test.ts +++ b/src/LiveComponent/assets/test/controller/action.test.ts @@ -7,9 +7,9 @@ * file that was distributed with this source code. */ -import { createTest, initComponent, shutdownTests } from '../tools'; import { getByText, waitFor } from '@testing-library/dom'; import userEvent from '@testing-library/user-event'; +import { createTest, initComponent, shutdownTests } from '../tools'; describe('LiveController Action Tests', () => { afterEach(() => { diff --git a/src/LiveComponent/assets/test/controller/basic.test.ts b/src/LiveComponent/assets/test/controller/basic.test.ts index bfe91598a9c..3b4d149a188 100644 --- a/src/LiveComponent/assets/test/controller/basic.test.ts +++ b/src/LiveComponent/assets/test/controller/basic.test.ts @@ -7,11 +7,11 @@ * file that was distributed with this source code. */ -import { createTest, initComponent, shutdownTests, startStimulus } from '../tools'; -import { htmlToElement } from '../../src/dom_utils'; import Component from '../../src/Component'; -import { getComponent } from '../../src/live_controller'; import { findComponents } from '../../src/ComponentRegistry'; +import { htmlToElement } from '../../src/dom_utils'; +import { getComponent } from '../../src/live_controller'; +import { createTest, initComponent, shutdownTests, startStimulus } from '../tools'; describe('LiveController Basic Tests', () => { afterEach(() => { diff --git a/src/LiveComponent/assets/test/controller/child-model.test.ts b/src/LiveComponent/assets/test/controller/child-model.test.ts index 50b26d05715..1bfa53a2e4e 100644 --- a/src/LiveComponent/assets/test/controller/child-model.test.ts +++ b/src/LiveComponent/assets/test/controller/child-model.test.ts @@ -7,9 +7,9 @@ * file that was distributed with this source code. */ -import { createTest, initComponent, shutdownTests } from '../tools'; import { getByTestId, waitFor } from '@testing-library/dom'; import userEvent from '@testing-library/user-event'; +import { createTest, initComponent, shutdownTests } from '../tools'; describe('Component parent -> child data-model binding tests', () => { afterEach(() => { diff --git a/src/LiveComponent/assets/test/controller/child.test.ts b/src/LiveComponent/assets/test/controller/child.test.ts index 8d091a5c53f..58f5006090d 100644 --- a/src/LiveComponent/assets/test/controller/child.test.ts +++ b/src/LiveComponent/assets/test/controller/child.test.ts @@ -7,19 +7,19 @@ * file that was distributed with this source code. */ +import { Controller } from '@hotwired/stimulus'; +import { getByTestId, waitFor } from '@testing-library/dom'; +import userEvent from '@testing-library/user-event'; +import { findChildren } from '../../src/ComponentRegistry'; import { - createTestForExistingComponent, createTest, - initComponent, - shutdownTests, - getComponent, + createTestForExistingComponent, dataToJsonAttribute, + getComponent, getStimulusApplication, + initComponent, + shutdownTests, } from '../tools'; -import { getByTestId, waitFor } from '@testing-library/dom'; -import userEvent from '@testing-library/user-event'; -import { findChildren } from '../../src/ComponentRegistry'; -import { Controller } from '@hotwired/stimulus'; describe('Component parent -> child initialization and rendering tests', () => { afterEach(() => { diff --git a/src/LiveComponent/assets/test/controller/emit.test.ts b/src/LiveComponent/assets/test/controller/emit.test.ts index f9727671d76..1451f7c963e 100644 --- a/src/LiveComponent/assets/test/controller/emit.test.ts +++ b/src/LiveComponent/assets/test/controller/emit.test.ts @@ -7,8 +7,8 @@ * file that was distributed with this source code. */ -import { createTest, initComponent, shutdownTests } from '../tools'; import { getByText, waitFor } from '@testing-library/dom'; +import { createTest, initComponent, shutdownTests } from '../tools'; describe('LiveController Emit Tests', () => { afterEach(() => { diff --git a/src/LiveComponent/assets/test/controller/error.test.ts b/src/LiveComponent/assets/test/controller/error.test.ts index 883cc77ed0c..70acd75b4ef 100644 --- a/src/LiveComponent/assets/test/controller/error.test.ts +++ b/src/LiveComponent/assets/test/controller/error.test.ts @@ -7,9 +7,9 @@ * file that was distributed with this source code. */ -import { createTest, initComponent, shutdownTests } from '../tools'; import { getByText, waitFor } from '@testing-library/dom'; import type BackendResponse from '../../src/Backend/BackendResponse'; +import { createTest, initComponent, shutdownTests } from '../tools'; const getErrorElement = (): Element | null => { return document.getElementById('live-component-error'); diff --git a/src/LiveComponent/assets/test/controller/loading.test.ts b/src/LiveComponent/assets/test/controller/loading.test.ts index 72a078547a5..69236671173 100644 --- a/src/LiveComponent/assets/test/controller/loading.test.ts +++ b/src/LiveComponent/assets/test/controller/loading.test.ts @@ -7,9 +7,9 @@ * file that was distributed with this source code. */ -import { createTest, initComponent, shutdownTests } from '../tools'; import { getByTestId, getByText, waitFor } from '@testing-library/dom'; import userEvent from '@testing-library/user-event'; +import { createTest, initComponent, shutdownTests } from '../tools'; describe('LiveController data-loading Tests', () => { afterEach(() => { diff --git a/src/LiveComponent/assets/test/controller/model.test.ts b/src/LiveComponent/assets/test/controller/model.test.ts index e0d92ce061f..2aef9dcd068 100644 --- a/src/LiveComponent/assets/test/controller/model.test.ts +++ b/src/LiveComponent/assets/test/controller/model.test.ts @@ -7,9 +7,9 @@ * file that was distributed with this source code. */ -import { createTest, initComponent, shutdownTests } from '../tools'; import { getByLabelText, getByTestId, getByText, waitFor } from '@testing-library/dom'; import userEvent from '@testing-library/user-event'; +import { createTest, initComponent, shutdownTests } from '../tools'; describe('LiveController data-model Tests', () => { afterEach(() => { diff --git a/src/LiveComponent/assets/test/controller/poll.test.ts b/src/LiveComponent/assets/test/controller/poll.test.ts index 4825261bc5c..1519172f394 100644 --- a/src/LiveComponent/assets/test/controller/poll.test.ts +++ b/src/LiveComponent/assets/test/controller/poll.test.ts @@ -7,9 +7,9 @@ * file that was distributed with this source code. */ -import { shutdownTests, createTest, initComponent } from '../tools'; import { waitFor } from '@testing-library/dom'; import userEvent from '@testing-library/user-event'; +import { createTest, initComponent, shutdownTests } from '../tools'; describe('LiveController polling Tests', () => { afterEach(() => { diff --git a/src/LiveComponent/assets/test/controller/query-binding.test.ts b/src/LiveComponent/assets/test/controller/query-binding.test.ts index a61f1c0e938..f0654efe8e9 100644 --- a/src/LiveComponent/assets/test/controller/query-binding.test.ts +++ b/src/LiveComponent/assets/test/controller/query-binding.test.ts @@ -7,8 +7,8 @@ * file that was distributed with this source code. */ -import { createTest, initComponent, shutdownTests, setCurrentSearch, expectCurrentSearch } from '../tools'; import { getByText, waitFor } from '@testing-library/dom'; +import { createTest, expectCurrentSearch, initComponent, setCurrentSearch, shutdownTests } from '../tools'; describe('LiveController query string binding', () => { afterEach(() => { diff --git a/src/LiveComponent/assets/test/controller/render-with-external-changes.test.ts b/src/LiveComponent/assets/test/controller/render-with-external-changes.test.ts index 8eb3e0348a8..7109dcc7e14 100644 --- a/src/LiveComponent/assets/test/controller/render-with-external-changes.test.ts +++ b/src/LiveComponent/assets/test/controller/render-with-external-changes.test.ts @@ -7,9 +7,9 @@ * file that was distributed with this source code. */ -import { shutdownTests, createTest, initComponent } from '../tools'; import { getByTestId } from '@testing-library/dom'; import { htmlToElement } from '../../src/dom_utils'; +import { createTest, initComponent, shutdownTests } from '../tools'; describe('LiveController rendering with external changes tests', () => { afterEach(() => { diff --git a/src/LiveComponent/assets/test/controller/render.test.ts b/src/LiveComponent/assets/test/controller/render.test.ts index efa875c0ba9..5dc6c64ddd7 100644 --- a/src/LiveComponent/assets/test/controller/render.test.ts +++ b/src/LiveComponent/assets/test/controller/render.test.ts @@ -7,10 +7,10 @@ * file that was distributed with this source code. */ -import { shutdownTests, createTest, initComponent } from '../tools'; import { getByTestId, getByText, waitFor } from '@testing-library/dom'; import userEvent from '@testing-library/user-event'; import { htmlToElement } from '../../src/dom_utils'; +import { createTest, initComponent, shutdownTests } from '../tools'; describe('LiveController rendering Tests', () => { afterEach(() => { diff --git a/src/LiveComponent/assets/test/dom_utils.test.ts b/src/LiveComponent/assets/test/dom_utils.test.ts index 40a4bfb6f8e..a1b0a17da39 100644 --- a/src/LiveComponent/assets/test/dom_utils.test.ts +++ b/src/LiveComponent/assets/test/dom_utils.test.ts @@ -1,14 +1,14 @@ +import Backend from '../src/Backend/Backend'; +import Component from '../src/Component'; +import ValueStore from '../src/Component/ValueStore'; import { - getValueFromElement, cloneHTMLElement, - htmlToElement, - getModelDirectiveFromElement, elementBelongsToThisComponent, + getModelDirectiveFromElement, + getValueFromElement, + htmlToElement, setValueOnElement, } from '../src/dom_utils'; -import ValueStore from '../src/Component/ValueStore'; -import Component from '../src/Component'; -import Backend from '../src/Backend/Backend'; import { noopElementDriver } from './tools'; const createStore = (props: any = {}): ValueStore => new ValueStore(props); diff --git a/src/LiveComponent/assets/test/normalize_attributes_for_comparison.test.ts b/src/LiveComponent/assets/test/normalize_attributes_for_comparison.test.ts index bce5d55f057..2ed1b2a5a26 100644 --- a/src/LiveComponent/assets/test/normalize_attributes_for_comparison.test.ts +++ b/src/LiveComponent/assets/test/normalize_attributes_for_comparison.test.ts @@ -1,5 +1,5 @@ -import { normalizeAttributesForComparison } from '../src/normalize_attributes_for_comparison'; import { htmlToElement } from '../src/dom_utils'; +import { normalizeAttributesForComparison } from '../src/normalize_attributes_for_comparison'; describe('normalizeAttributesForComparison', () => { it('makes no changes if value and attribute not set', () => { diff --git a/src/LiveComponent/assets/test/tools.ts b/src/LiveComponent/assets/test/tools.ts index 791d2fe299b..41a6b11a29c 100644 --- a/src/LiveComponent/assets/test/tools.ts +++ b/src/LiveComponent/assets/test/tools.ts @@ -1,14 +1,14 @@ import { Application } from '@hotwired/stimulus'; -import LiveController from '../src/live_controller'; import { waitFor } from '@testing-library/dom'; -import { htmlToElement } from '../src/dom_utils'; -import Component from '../src/Component'; +import { Response } from 'node-fetch'; import type { BackendAction, BackendInterface, ChildrenFingerprints } from '../src/Backend/Backend'; import BackendRequest from '../src/Backend/BackendRequest'; -import { Response } from 'node-fetch'; +import Component from '../src/Component'; +import type { ElementDriver } from '../src/Component/ElementDriver'; import { setDeepData } from '../src/data_manipulation_utils'; +import { htmlToElement } from '../src/dom_utils'; +import LiveController from '../src/live_controller'; import LiveControllerDefault from '../src/live_controller'; -import type { ElementDriver } from '../src/Component/ElementDriver'; let activeTests: FunctionalTest[] = []; diff --git a/src/Map/assets/test/abstract_map_controller.test.ts b/src/Map/assets/test/abstract_map_controller.test.ts index 691f2c5d5ec..eee7def47db 100644 --- a/src/Map/assets/test/abstract_map_controller.test.ts +++ b/src/Map/assets/test/abstract_map_controller.test.ts @@ -1,8 +1,8 @@ import { Application } from '@hotwired/stimulus'; -import { getByTestId, waitFor } from '@testing-library/dom'; import { clearDOM, mountDOM } from '@symfony/stimulus-testing'; -import AbstractMapController from '../src/abstract_map_controller.ts'; +import { getByTestId, waitFor } from '@testing-library/dom'; import * as L from 'leaflet'; +import AbstractMapController from '../src/abstract_map_controller.ts'; class MyMapController extends AbstractMapController { protected dispatchEvent(name: string, payload: Record = {}): void { diff --git a/src/Map/src/Bridge/Google/assets/dist/map_controller.d.ts b/src/Map/src/Bridge/Google/assets/dist/map_controller.d.ts index 9f99a6c354c..14fab318737 100644 --- a/src/Map/src/Bridge/Google/assets/dist/map_controller.d.ts +++ b/src/Map/src/Bridge/Google/assets/dist/map_controller.d.ts @@ -1,6 +1,6 @@ -import AbstractMapController from '@symfony/ux-map'; -import type { Point, MarkerDefinition, PolygonDefinition, PolylineDefinition, InfoWindowWithoutPositionDefinition } from '@symfony/ux-map'; import type { LoaderOptions } from '@googlemaps/js-api-loader'; +import AbstractMapController from '@symfony/ux-map'; +import type { InfoWindowWithoutPositionDefinition, MarkerDefinition, Point, PolygonDefinition, PolylineDefinition } from '@symfony/ux-map'; type MapOptions = Pick; export default class extends AbstractMapController { providerOptionsValue: Pick; diff --git a/src/Map/src/Bridge/Google/assets/dist/map_controller.js b/src/Map/src/Bridge/Google/assets/dist/map_controller.js index 3ce719b79ea..412db2c1210 100644 --- a/src/Map/src/Bridge/Google/assets/dist/map_controller.js +++ b/src/Map/src/Bridge/Google/assets/dist/map_controller.js @@ -1,5 +1,5 @@ -import { Controller } from '@hotwired/stimulus'; import { Loader } from '@googlemaps/js-api-loader'; +import { Controller } from '@hotwired/stimulus'; class default_1 extends Controller { constructor() { diff --git a/src/Map/src/Bridge/Google/assets/src/map_controller.ts b/src/Map/src/Bridge/Google/assets/src/map_controller.ts index 3dcd91e68be..ff2df70a624 100644 --- a/src/Map/src/Bridge/Google/assets/src/map_controller.ts +++ b/src/Map/src/Bridge/Google/assets/src/map_controller.ts @@ -7,16 +7,16 @@ * file that was distributed with this source code. */ +import type { LoaderOptions } from '@googlemaps/js-api-loader'; +import { Loader } from '@googlemaps/js-api-loader'; import AbstractMapController from '@symfony/ux-map'; import type { - Point, + InfoWindowWithoutPositionDefinition, MarkerDefinition, + Point, PolygonDefinition, PolylineDefinition, - InfoWindowWithoutPositionDefinition, } from '@symfony/ux-map'; -import type { LoaderOptions } from '@googlemaps/js-api-loader'; -import { Loader } from '@googlemaps/js-api-loader'; type MapOptions = Pick< google.maps.MapOptions, diff --git a/src/Map/src/Bridge/Google/assets/test/map_controller.test.ts b/src/Map/src/Bridge/Google/assets/test/map_controller.test.ts index edee670cf64..ff5d31d041a 100644 --- a/src/Map/src/Bridge/Google/assets/test/map_controller.test.ts +++ b/src/Map/src/Bridge/Google/assets/test/map_controller.test.ts @@ -8,8 +8,8 @@ */ import { Application, Controller } from '@hotwired/stimulus'; -import { getByTestId, waitFor } from '@testing-library/dom'; import { clearDOM, mountDOM } from '@symfony/stimulus-testing'; +import { getByTestId, waitFor } from '@testing-library/dom'; import GoogleController from '../src/map_controller'; // Controller used to check the actual controller was properly booted diff --git a/src/Map/src/Bridge/Leaflet/assets/dist/map_controller.d.ts b/src/Map/src/Bridge/Leaflet/assets/dist/map_controller.d.ts index fbe96749d1d..f7475812e19 100644 --- a/src/Map/src/Bridge/Leaflet/assets/dist/map_controller.d.ts +++ b/src/Map/src/Bridge/Leaflet/assets/dist/map_controller.d.ts @@ -1,8 +1,8 @@ import AbstractMapController from '@symfony/ux-map'; -import type { Point, MarkerDefinition, PolygonDefinition, PolylineDefinition, InfoWindowWithoutPositionDefinition } from '@symfony/ux-map'; +import type { InfoWindowWithoutPositionDefinition, MarkerDefinition, Point, PolygonDefinition, PolylineDefinition } from '@symfony/ux-map'; import 'leaflet/dist/leaflet.min.css'; import * as L from 'leaflet'; -import type { MapOptions as LeafletMapOptions, MarkerOptions, PopupOptions, PolylineOptions as PolygonOptions, PolylineOptions } from 'leaflet'; +import type { MapOptions as LeafletMapOptions, MarkerOptions, PolylineOptions as PolygonOptions, PolylineOptions, PopupOptions } from 'leaflet'; type MapOptions = Pick & { tileLayer: { url: string; diff --git a/src/Map/src/Bridge/Leaflet/assets/src/map_controller.ts b/src/Map/src/Bridge/Leaflet/assets/src/map_controller.ts index 54e10b4d6a0..b7684446a7c 100644 --- a/src/Map/src/Bridge/Leaflet/assets/src/map_controller.ts +++ b/src/Map/src/Bridge/Leaflet/assets/src/map_controller.ts @@ -1,20 +1,20 @@ import AbstractMapController from '@symfony/ux-map'; import type { - Point, + InfoWindowWithoutPositionDefinition, MarkerDefinition, + Point, PolygonDefinition, PolylineDefinition, - InfoWindowWithoutPositionDefinition, } from '@symfony/ux-map'; import 'leaflet/dist/leaflet.min.css'; import * as L from 'leaflet'; import type { + LatLngBoundsExpression, MapOptions as LeafletMapOptions, MarkerOptions, - PopupOptions, PolylineOptions as PolygonOptions, PolylineOptions, - LatLngBoundsExpression, + PopupOptions, } from 'leaflet'; type MapOptions = Pick & { diff --git a/src/Map/src/Bridge/Leaflet/assets/test/map_controller.test.ts b/src/Map/src/Bridge/Leaflet/assets/test/map_controller.test.ts index 379eb603fed..680f6d3a718 100644 --- a/src/Map/src/Bridge/Leaflet/assets/test/map_controller.test.ts +++ b/src/Map/src/Bridge/Leaflet/assets/test/map_controller.test.ts @@ -8,8 +8,8 @@ */ import { Application, Controller } from '@hotwired/stimulus'; -import { getByTestId, waitFor } from '@testing-library/dom'; import { clearDOM, mountDOM } from '@symfony/stimulus-testing'; +import { getByTestId, waitFor } from '@testing-library/dom'; import LeafletController from '../src/map_controller'; // Controller used to check the actual controller was properly booted diff --git a/src/Notify/assets/test/controller.test.ts b/src/Notify/assets/test/controller.test.ts index bfccfdfd2fe..08a84dc26fc 100644 --- a/src/Notify/assets/test/controller.test.ts +++ b/src/Notify/assets/test/controller.test.ts @@ -8,10 +8,10 @@ */ import { Application, Controller } from '@hotwired/stimulus'; -import { getByTestId, waitFor } from '@testing-library/dom'; import { clearDOM, mountDOM } from '@symfony/stimulus-testing'; -import NotifyController from '../src/controller'; +import { getByTestId, waitFor } from '@testing-library/dom'; import { vi } from 'vitest'; +import NotifyController from '../src/controller'; // Controller used to check the actual controller was properly booted class CheckController extends Controller { diff --git a/src/React/assets/dist/loader.d.ts b/src/React/assets/dist/loader.d.ts index 5d3cd679ebb..109970c9fc5 100644 --- a/src/React/assets/dist/loader.d.ts +++ b/src/React/assets/dist/loader.d.ts @@ -1,5 +1,5 @@ -import { type ComponentCollection } from './components.js'; import type { ComponentClass, FunctionComponent } from 'react'; +import { type ComponentCollection } from './components.js'; type Component = string | FunctionComponent | ComponentClass; declare global { function resolveReactComponent(name: string): Component; diff --git a/src/React/assets/dist/render_controller.d.ts b/src/React/assets/dist/render_controller.d.ts index 8f6dbd18037..f8f47bb98a9 100644 --- a/src/React/assets/dist/render_controller.d.ts +++ b/src/React/assets/dist/render_controller.d.ts @@ -1,5 +1,5 @@ -import { type ReactElement } from 'react'; import { Controller } from '@hotwired/stimulus'; +import { type ReactElement } from 'react'; export default class extends Controller { readonly componentValue?: string; readonly propsValue?: object; diff --git a/src/React/assets/dist/render_controller.js b/src/React/assets/dist/render_controller.js index c66a32044ed..44dbaafcd2d 100644 --- a/src/React/assets/dist/render_controller.js +++ b/src/React/assets/dist/render_controller.js @@ -1,6 +1,6 @@ +import { Controller } from '@hotwired/stimulus'; import React from 'react'; import require$$0 from 'react-dom'; -import { Controller } from '@hotwired/stimulus'; var client = {}; diff --git a/src/React/assets/src/loader.ts b/src/React/assets/src/loader.ts index ad162fea9f7..605d592e7c0 100644 --- a/src/React/assets/src/loader.ts +++ b/src/React/assets/src/loader.ts @@ -7,8 +7,8 @@ * file that was distributed with this source code. */ -import { type ComponentCollection, components } from './components.js'; import type { ComponentClass, FunctionComponent } from 'react'; +import { type ComponentCollection, components } from './components.js'; type Component = string | FunctionComponent | ComponentClass; diff --git a/src/React/assets/src/render_controller.ts b/src/React/assets/src/render_controller.ts index b35d049956b..215c6ce1ff1 100644 --- a/src/React/assets/src/render_controller.ts +++ b/src/React/assets/src/render_controller.ts @@ -7,9 +7,9 @@ * file that was distributed with this source code. */ +import { Controller } from '@hotwired/stimulus'; import React, { type ReactElement } from 'react'; import { createRoot } from 'react-dom/client'; -import { Controller } from '@hotwired/stimulus'; export default class extends Controller { declare readonly componentValue?: string; diff --git a/src/React/assets/test/register_controller.test.tsx b/src/React/assets/test/register_controller.test.tsx index 756de1cdba4..4595a7d9996 100644 --- a/src/React/assets/test/register_controller.test.tsx +++ b/src/React/assets/test/register_controller.test.tsx @@ -8,9 +8,9 @@ */ import { registerReactControllerComponents } from '../src/register_controller'; -import MyTsxComponent from './fixtures/MyTsxComponent'; // @ts-ignore import MyJsxComponent from './fixtures/MyJsxComponent'; +import MyTsxComponent from './fixtures/MyTsxComponent'; import RequireContext = __WebpackModuleApi.RequireContext; const createFakeFixturesContext = (): RequireContext => { diff --git a/src/React/assets/test/render_controller.test.tsx b/src/React/assets/test/render_controller.test.tsx index c9d7d326668..f8202d88658 100644 --- a/src/React/assets/test/render_controller.test.tsx +++ b/src/React/assets/test/render_controller.test.tsx @@ -7,10 +7,10 @@ * file that was distributed with this source code. */ -import React from 'react'; import { Application, Controller } from '@hotwired/stimulus'; -import { getByTestId, waitFor } from '@testing-library/dom'; import { clearDOM, mountDOM } from '@symfony/stimulus-testing'; +import { getByTestId, waitFor } from '@testing-library/dom'; +import React from 'react'; import ReactController from '../src/render_controller'; // Controller used to check the actual controller was properly booted diff --git a/src/StimulusBundle/assets/src/loader.ts b/src/StimulusBundle/assets/src/loader.ts index d937a61cb60..5b5009ae95e 100644 --- a/src/StimulusBundle/assets/src/loader.ts +++ b/src/StimulusBundle/assets/src/loader.ts @@ -14,11 +14,11 @@ */ import { Application, type ControllerConstructor } from '@hotwired/stimulus'; import { - eagerControllers, - lazyControllers, - isApplicationDebug, type EagerControllersCollection, type LazyControllersCollection, + eagerControllers, + isApplicationDebug, + lazyControllers, } from './controllers.js'; const controllerAttribute = 'data-controller'; diff --git a/src/StimulusBundle/assets/test/loader.test.ts b/src/StimulusBundle/assets/test/loader.test.ts index cd7f884fe55..27fdb650713 100644 --- a/src/StimulusBundle/assets/test/loader.test.ts +++ b/src/StimulusBundle/assets/test/loader.test.ts @@ -1,9 +1,9 @@ +import { Application, Controller } from '@hotwired/stimulus'; +import { waitFor } from '@testing-library/dom'; // load from dist because the source TypeScript file points directly to controllers.js, // which does not actually exist in the source code import { loadControllers } from '../dist/loader'; -import { Application, Controller } from '@hotwired/stimulus'; import type { EagerControllersCollection, LazyControllersCollection } from '../src/controllers'; -import { waitFor } from '@testing-library/dom'; let isController1Initialized = false; let isController2Initialized = false; diff --git a/src/Svelte/assets/test/render_controller.test.ts b/src/Svelte/assets/test/render_controller.test.ts index b2d3fbe5130..d65f9adedf9 100644 --- a/src/Svelte/assets/test/render_controller.test.ts +++ b/src/Svelte/assets/test/render_controller.test.ts @@ -8,8 +8,8 @@ */ import { Application, Controller } from '@hotwired/stimulus'; -import { getByTestId, waitFor } from '@testing-library/dom'; import { clearDOM, mountDOM } from '@symfony/stimulus-testing'; +import { getByTestId, waitFor } from '@testing-library/dom'; import SvelteController from '../src/render_controller'; import MyComponent from './fixtures/MyComponent.svelte'; diff --git a/src/Swup/assets/dist/controller.js b/src/Swup/assets/dist/controller.js index f3a05eefed0..3eee54355e4 100644 --- a/src/Swup/assets/dist/controller.js +++ b/src/Swup/assets/dist/controller.js @@ -1,9 +1,9 @@ import { Controller } from '@hotwired/stimulus'; -import Swup from 'swup'; import SwupDebugPlugin from '@swup/debug-plugin'; -import SwupFormsPlugin from '@swup/forms-plugin'; import SwupFadeTheme from '@swup/fade-theme'; +import SwupFormsPlugin from '@swup/forms-plugin'; import SwupSlideTheme from '@swup/slide-theme'; +import Swup from 'swup'; class default_1 extends Controller { connect() { diff --git a/src/Swup/assets/src/controller.ts b/src/Swup/assets/src/controller.ts index a9facc16079..753079eb130 100644 --- a/src/Swup/assets/src/controller.ts +++ b/src/Swup/assets/src/controller.ts @@ -8,11 +8,11 @@ */ import { Controller } from '@hotwired/stimulus'; -import Swup from 'swup'; import SwupDebugPlugin from '@swup/debug-plugin'; -import SwupFormsPlugin from '@swup/forms-plugin'; import SwupFadeTheme from '@swup/fade-theme'; +import SwupFormsPlugin from '@swup/forms-plugin'; import SwupSlideTheme from '@swup/slide-theme'; +import Swup from 'swup'; export default class extends Controller { declare readonly animateHistoryBrowsingValue: boolean; diff --git a/src/Swup/assets/test/controller.test.ts b/src/Swup/assets/test/controller.test.ts index 841dc5d3c8f..c8261b101b4 100644 --- a/src/Swup/assets/test/controller.test.ts +++ b/src/Swup/assets/test/controller.test.ts @@ -8,8 +8,8 @@ */ import { Application, Controller } from '@hotwired/stimulus'; -import { getByTestId, waitFor } from '@testing-library/dom'; import { clearDOM, mountDOM } from '@symfony/stimulus-testing'; +import { getByTestId, waitFor } from '@testing-library/dom'; import SwupController from '../src/controller'; let actualSwupOptions: any = null; diff --git a/src/TogglePassword/assets/test/controller.test.ts b/src/TogglePassword/assets/test/controller.test.ts index 6a186ff6c5d..1ce797742d3 100644 --- a/src/TogglePassword/assets/test/controller.test.ts +++ b/src/TogglePassword/assets/test/controller.test.ts @@ -8,9 +8,9 @@ */ import { Application, Controller } from '@hotwired/stimulus'; -import { getByTestId, waitFor, getByText } from '@testing-library/dom'; -import user from '@testing-library/user-event'; import { clearDOM, mountDOM } from '@symfony/stimulus-testing'; +import { getByTestId, getByText, waitFor } from '@testing-library/dom'; +import user from '@testing-library/user-event'; import TogglePasswordController from '../src/controller'; // Controller used to check the actual controller was properly booted diff --git a/src/Translator/assets/dist/translator_controller.js b/src/Translator/assets/dist/translator_controller.js index b45f3f06c24..c4504b75e3c 100644 --- a/src/Translator/assets/dist/translator_controller.js +++ b/src/Translator/assets/dist/translator_controller.js @@ -1,20 +1,5 @@ import { IntlMessageFormat } from 'intl-messageformat'; -function formatIntl(id, parameters, locale) { - if (id === '') { - return ''; - } - const intlMessage = new IntlMessageFormat(id, [locale.replace('_', '-')], undefined, { ignoreTag: true }); - parameters = { ...parameters }; - Object.entries(parameters).forEach(([key, value]) => { - if (key.includes('%') || key.includes('{')) { - delete parameters[key]; - parameters[key.replace(/[%{} ]/g, '').trim()] = value; - } - }); - return intlMessage.format(parameters); -} - function strtr(string, replacePairs) { const regex = Object.entries(replacePairs).map(([from]) => { return from.replace(/([-[\]{}()*+?.\\^$|#,])/g, '\\$1'); @@ -218,6 +203,21 @@ function getPluralizationRule(number, locale) { } } +function formatIntl(id, parameters, locale) { + if (id === '') { + return ''; + } + const intlMessage = new IntlMessageFormat(id, [locale.replace('_', '-')], undefined, { ignoreTag: true }); + parameters = { ...parameters }; + Object.entries(parameters).forEach(([key, value]) => { + if (key.includes('%') || key.includes('{')) { + delete parameters[key]; + parameters[key.replace(/[%{} ]/g, '').trim()] = value; + } + }); + return intlMessage.format(parameters); +} + let _locale = null; let _localeFallbacks = {}; let _throwWhenNotFound = false; diff --git a/src/Translator/assets/src/translator.ts b/src/Translator/assets/src/translator.ts index 2659d171a42..6c00199e08e 100644 --- a/src/Translator/assets/src/translator.ts +++ b/src/Translator/assets/src/translator.ts @@ -33,8 +33,8 @@ export interface Message = {}; diff --git a/src/Translator/assets/test/translator.test.ts b/src/Translator/assets/test/translator.test.ts index 77cf2c780c9..2ca0ee28399 100644 --- a/src/Translator/assets/test/translator.test.ts +++ b/src/Translator/assets/test/translator.test.ts @@ -1,7 +1,7 @@ import { - getLocale, type Message, type NoParametersType, + getLocale, setLocale, setLocaleFallbacks, throwWhenNotFound, diff --git a/src/Turbo/assets/test/turbo_controller.test.ts b/src/Turbo/assets/test/turbo_controller.test.ts index 8b9584246b7..996ae01dfa7 100644 --- a/src/Turbo/assets/test/turbo_controller.test.ts +++ b/src/Turbo/assets/test/turbo_controller.test.ts @@ -8,8 +8,8 @@ */ import { Application } from '@hotwired/stimulus'; -import { getByTestId } from '@testing-library/dom'; import { clearDOM, mountDOM } from '@symfony/stimulus-testing'; +import { getByTestId } from '@testing-library/dom'; import TurboController from '../src/turbo_controller'; const startStimulus = () => { diff --git a/src/Turbo/assets/test/turbo_stream_controller.test.ts b/src/Turbo/assets/test/turbo_stream_controller.test.ts index 66c33f9b254..81042b140f5 100644 --- a/src/Turbo/assets/test/turbo_stream_controller.test.ts +++ b/src/Turbo/assets/test/turbo_stream_controller.test.ts @@ -8,10 +8,10 @@ */ import { Application } from '@hotwired/stimulus'; -import { getByTestId } from '@testing-library/dom'; import { clearDOM, mountDOM } from '@symfony/stimulus-testing'; -import TurboStreamController from '../src/turbo_stream_controller'; +import { getByTestId } from '@testing-library/dom'; import { vi } from 'vitest'; +import TurboStreamController from '../src/turbo_stream_controller'; const startStimulus = () => { const application = Application.start(); diff --git a/src/Typed/assets/test/controller.test.ts b/src/Typed/assets/test/controller.test.ts index 982eb19d4b4..82d87efad65 100644 --- a/src/Typed/assets/test/controller.test.ts +++ b/src/Typed/assets/test/controller.test.ts @@ -8,8 +8,8 @@ */ import { Application, Controller } from '@hotwired/stimulus'; -import { getByTestId, waitFor } from '@testing-library/dom'; import { clearDOM, mountDOM } from '@symfony/stimulus-testing'; +import { getByTestId, waitFor } from '@testing-library/dom'; import TypedController from '../src/controller'; // Controller used to check the actual controller was properly booted diff --git a/src/Vue/assets/test/register_controller.test.ts b/src/Vue/assets/test/register_controller.test.ts index 9b2ed03e4f2..290ba5b8223 100644 --- a/src/Vue/assets/test/register_controller.test.ts +++ b/src/Vue/assets/test/register_controller.test.ts @@ -8,8 +8,8 @@ */ import { registerVueControllerComponents } from '../src/register_controller'; -import Hello from './fixtures/Hello.vue'; import Goodbye from './fixtures-lazy/Goodbye.vue'; +import Hello from './fixtures/Hello.vue'; import RequireContext = __WebpackModuleApi.RequireContext; const createFakeFixturesContext = (lazyDir: boolean): RequireContext => { diff --git a/src/Vue/assets/test/render_controller.test.ts b/src/Vue/assets/test/render_controller.test.ts index 58112b2338d..8cc2d3a8048 100644 --- a/src/Vue/assets/test/render_controller.test.ts +++ b/src/Vue/assets/test/render_controller.test.ts @@ -8,8 +8,8 @@ */ import { Application, Controller } from '@hotwired/stimulus'; -import { getByTestId, waitFor } from '@testing-library/dom'; import { clearDOM, mountDOM } from '@symfony/stimulus-testing'; +import { getByTestId, waitFor } from '@testing-library/dom'; import VueController from '../src/render_controller'; // Controller used to check the actual controller was properly booted