From 1f47bedb64b857333373eb41ad1f4513c7c80f7e Mon Sep 17 00:00:00 2001 From: Steffen Deusch Date: Thu, 8 May 2025 14:37:03 +0200 Subject: [PATCH 1/5] Add type definitions for public interfaces This got a bit bigger than expected, but it addresses multiple things: 1. includes type definitions for all public methods and interfaces. No need for `@types/phoenix_live_view`. The exported types are compatible with the currently documented type hints. Editors like VSCode now show detailed Intellisense. 2. rewrites some files in Typescript 3. updates eslint configuration to handle typescript 4. includes https://github.com/phoenixframework/phoenix_live_view/pull/3763 5. changes all commonjs files to modules --- .gitignore | 1 + assets/js/phoenix_live_view/aria.js | 12 +- assets/js/phoenix_live_view/browser.js | 16 +- assets/js/phoenix_live_view/dom.js | 97 +-- assets/js/phoenix_live_view/dom_patch.js | 67 +- .../dom_post_morph_restorer.js | 15 +- assets/js/phoenix_live_view/element_ref.js | 12 +- assets/js/phoenix_live_view/entry_uploader.js | 10 +- assets/js/phoenix_live_view/global.d.ts | 1 + assets/js/phoenix_live_view/hooks.js | 52 +- assets/js/phoenix_live_view/index.js | 49 -- assets/js/phoenix_live_view/index.ts | 294 ++++++++ assets/js/phoenix_live_view/js.js | 70 +- assets/js/phoenix_live_view/js_commands.js | 243 ------- assets/js/phoenix_live_view/js_commands.ts | 305 +++++++++ assets/js/phoenix_live_view/live_socket.js | 258 +++---- assets/js/phoenix_live_view/live_uploader.js | 31 +- assets/js/phoenix_live_view/rendered.js | 80 +-- assets/js/phoenix_live_view/upload_entry.js | 14 +- assets/js/phoenix_live_view/utils.js | 32 +- assets/js/phoenix_live_view/view.js | 332 ++++----- assets/js/phoenix_live_view/view_hook.js | 134 ---- assets/js/phoenix_live_view/view_hook.ts | 399 +++++++++++ assets/test/debounce_test.js | 48 +- assets/test/dom_test.js | 40 +- assets/test/event_test.js | 90 ++- assets/test/integration/event_test.js | 10 +- assets/test/integration/metadata_test.js | 16 +- assets/test/js_test.js | 318 ++++----- assets/test/live_socket_test.js | 66 +- assets/test/modify_root_test.js | 20 +- assets/test/rendered_test.js | 38 +- assets/test/test_helpers.js | 30 +- assets/test/utils_test.js | 16 +- assets/test/view_test.js | 500 ++++++++------ babel.config.json | 3 +- eslint.config.mjs => eslint.config.js | 21 +- guides/client/js-interop.md | 2 +- jest.config.js | 10 +- mix.exs | 10 +- package-lock.json | 635 +++++++++++++----- package.json | 22 +- .../{merge-coverage.mjs => merge-coverage.js} | 0 test/e2e/playwright.config.js | 10 +- test/e2e/teardown.js | 4 +- test/e2e/test-fixtures.js | 2 + test/e2e/tests/errors.spec.js | 4 +- test/e2e/tests/forms.spec.js | 32 +- test/e2e/tests/issues/2787.spec.js | 4 +- test/e2e/tests/issues/2965.spec.js | 6 +- test/e2e/tests/issues/3026.spec.js | 4 +- test/e2e/tests/issues/3040.spec.js | 4 +- test/e2e/tests/issues/3047.spec.js | 4 +- test/e2e/tests/issues/3083.spec.js | 4 +- test/e2e/tests/issues/3107.spec.js | 4 +- test/e2e/tests/issues/3117.spec.js | 4 +- test/e2e/tests/issues/3169.spec.js | 4 +- test/e2e/tests/issues/3194.spec.js | 4 +- test/e2e/tests/issues/3200.spec.js | 4 +- test/e2e/tests/issues/3378.spec.js | 4 +- test/e2e/tests/issues/3448.spec.js | 4 +- test/e2e/tests/issues/3496.spec.js | 4 +- test/e2e/tests/issues/3529.spec.js | 4 +- test/e2e/tests/issues/3530.spec.js | 4 +- test/e2e/tests/issues/3612.spec.js | 4 +- test/e2e/tests/issues/3647.spec.js | 4 +- test/e2e/tests/issues/3651.spec.js | 4 +- test/e2e/tests/issues/3656.spec.js | 6 +- test/e2e/tests/issues/3658.spec.js | 4 +- test/e2e/tests/issues/3681.spec.js | 4 +- test/e2e/tests/issues/3684.spec.js | 4 +- test/e2e/tests/issues/3686.spec.js | 4 +- test/e2e/tests/issues/3709.spec.js | 4 +- test/e2e/tests/issues/3719.spec.js | 4 +- test/e2e/tests/js.spec.js | 4 +- test/e2e/tests/navigation.spec.js | 6 +- test/e2e/tests/select.spec.js | 4 +- test/e2e/tests/streams.spec.js | 10 +- test/e2e/tests/uploads.spec.js | 10 +- test/e2e/utils.js | 16 +- tsconfig.json | 31 + 81 files changed, 2855 insertions(+), 1805 deletions(-) create mode 100644 assets/js/phoenix_live_view/global.d.ts delete mode 100644 assets/js/phoenix_live_view/index.js create mode 100644 assets/js/phoenix_live_view/index.ts delete mode 100644 assets/js/phoenix_live_view/js_commands.js create mode 100644 assets/js/phoenix_live_view/js_commands.ts delete mode 100644 assets/js/phoenix_live_view/view_hook.js create mode 100644 assets/js/phoenix_live_view/view_hook.ts rename eslint.config.mjs => eslint.config.js (80%) rename test/e2e/{merge-coverage.mjs => merge-coverage.js} (100%) create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore index 357011434e..525d09531a 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,4 @@ node_modules /test/e2e/test-results/ /playwright-report/ /coverage/ +/assets/js/dist/ diff --git a/assets/js/phoenix_live_view/aria.js b/assets/js/phoenix_live_view/aria.js index 25ecf74ab3..ca86333a97 100644 --- a/assets/js/phoenix_live_view/aria.js +++ b/assets/js/phoenix_live_view/aria.js @@ -1,4 +1,4 @@ -let ARIA = { +const ARIA = { anyOf(instance, classes){ return classes.find(name => instance instanceof name) }, isFocusable(el, interactiveOnly){ @@ -12,14 +12,20 @@ let ARIA = { }, attemptFocus(el, interactiveOnly){ - if(this.isFocusable(el, interactiveOnly)){ try { el.focus() } catch {} } + if(this.isFocusable(el, interactiveOnly)){ + try { + el.focus() + } catch { + // that's fine + } + } return !!document.activeElement && document.activeElement.isSameNode(el) }, focusFirstInteractive(el){ let child = el.firstElementChild while(child){ - if(this.attemptFocus(child, true) || this.focusFirstInteractive(child, true)){ + if(this.attemptFocus(child, true) || this.focusFirstInteractive(child)){ return true } child = child.nextElementSibling diff --git a/assets/js/phoenix_live_view/browser.js b/assets/js/phoenix_live_view/browser.js index 2284113693..1d5c15b0bf 100644 --- a/assets/js/phoenix_live_view/browser.js +++ b/assets/js/phoenix_live_view/browser.js @@ -1,4 +1,4 @@ -let Browser = { +const Browser = { canPushState(){ return (typeof (history.pushState) !== "undefined") }, dropLocal(localStorage, namespace, subkey){ @@ -6,9 +6,9 @@ let Browser = { }, updateLocal(localStorage, namespace, subkey, initial, func){ - let current = this.getLocal(localStorage, namespace, subkey) - let key = this.localKey(namespace, subkey) - let newVal = current === null ? initial : func(current) + const current = this.getLocal(localStorage, namespace, subkey) + const key = this.localKey(namespace, subkey) + const newVal = current === null ? initial : func(current) localStorage.setItem(key, JSON.stringify(newVal)) return newVal }, @@ -27,7 +27,7 @@ let Browser = { if(to !== window.location.href){ if(meta.type == "redirect" && meta.scroll){ // If we're redirecting store the current scrollY for the current history state. - let currentState = history.state || {} + const currentState = history.state || {} currentState.scroll = meta.scroll history.replaceState(currentState, "", window.location.href) } @@ -40,7 +40,7 @@ let Browser = { // therefore we wait for the next frame (after the DOM patch) and only then try // to scroll to the hashEl window.requestAnimationFrame(() => { - let hashEl = this.getHashTargetEl(window.location.hash) + const hashEl = this.getHashTargetEl(window.location.hash) if(hashEl){ hashEl.scrollIntoView() @@ -55,7 +55,7 @@ let Browser = { }, setCookie(name, value, maxAgeSeconds){ - let expires = typeof(maxAgeSeconds) === "number" ? ` max-age=${maxAgeSeconds};` : "" + const expires = typeof(maxAgeSeconds) === "number" ? ` max-age=${maxAgeSeconds};` : "" document.cookie = `${name}=${value};${expires} path=/` }, @@ -75,7 +75,7 @@ let Browser = { localKey(namespace, subkey){ return `${namespace}-${subkey}` }, getHashTargetEl(maybeHash){ - let hash = maybeHash.toString().substring(1) + const hash = maybeHash.toString().substring(1) if(hash === ""){ return } return document.getElementById(hash) || document.querySelector(`a[name="${hash}"]`) } diff --git a/assets/js/phoenix_live_view/dom.js b/assets/js/phoenix_live_view/dom.js index f078bed71c..63aeab1d1b 100644 --- a/assets/js/phoenix_live_view/dom.js +++ b/assets/js/phoenix_live_view/dom.js @@ -26,7 +26,7 @@ import { logError } from "./utils" -let DOM = { +const DOM = { byId(id){ return document.getElementById(id) || logError(`no id found for ${id}`) }, removeClass(el, className){ @@ -36,12 +36,15 @@ let DOM = { all(node, query, callback){ if(!node){ return [] } - let array = Array.from(node.querySelectorAll(query)) - return callback ? array.forEach(callback) : array + const array = Array.from(node.querySelectorAll(query)) + if(callback){ + array.forEach(callback) + } + return array }, childNodeLength(html){ - let template = document.createElement("template") + const template = document.createElement("template") template.innerHTML = html return template.content.childElementCount }, @@ -65,17 +68,17 @@ let DOM = { }, wantsNewTab(e){ - let wantsNewTab = e.ctrlKey || e.shiftKey || e.metaKey || (e.button && e.button === 1) - let isDownload = (e.target instanceof HTMLAnchorElement && e.target.hasAttribute("download")) - let isTargetBlank = e.target.hasAttribute("target") && e.target.getAttribute("target").toLowerCase() === "_blank" - let isTargetNamedTab = e.target.hasAttribute("target") && !e.target.getAttribute("target").startsWith("_") + const wantsNewTab = e.ctrlKey || e.shiftKey || e.metaKey || (e.button && e.button === 1) + const isDownload = (e.target instanceof HTMLAnchorElement && e.target.hasAttribute("download")) + const isTargetBlank = e.target.hasAttribute("target") && e.target.getAttribute("target").toLowerCase() === "_blank" + const isTargetNamedTab = e.target.hasAttribute("target") && !e.target.getAttribute("target").startsWith("_") return wantsNewTab || isTargetBlank || isDownload || isTargetNamedTab }, isUnloadableFormSubmit(e){ // Ignore form submissions intended to close a native element // https://developer.mozilla.org/en-US/docs/Web/HTML/Element/dialog#usage_notes - let isDialogSubmit = (e.target && e.target.getAttribute("method") === "dialog") || + const isDialogSubmit = (e.target && e.target.getAttribute("method") === "dialog") || (e.submitter && e.submitter.getAttribute("formmethod") === "dialog") if(isDialogSubmit){ @@ -86,7 +89,7 @@ let DOM = { }, isNewPageClick(e, currentLocation){ - let href = e.target instanceof HTMLAnchorElement ? e.target.getAttribute("href") : null + const href = e.target instanceof HTMLAnchorElement ? e.target.getAttribute("href") : null let url if(e.defaultPrevented || href === null || this.wantsNewTab(e)){ return false } @@ -118,7 +121,7 @@ let DOM = { }, findPhxChildrenInFragment(html, parentId){ - let template = document.createElement("template") + const template = document.createElement("template") template.innerHTML = html return this.findPhxChildren(template.content, parentId) }, @@ -143,8 +146,8 @@ let DOM = { // is if a parent adds it back, therefore if a cid does not exist on the page, // we should not try to render it by itself (because it would be rendered twice, // one by the parent, and a second time by itself) - let parentCids = new Set() - let childrenCids = new Set() + const parentCids = new Set() + const childrenCids = new Set() cids.forEach(cid => { this.filterWithinSameLiveView(this.all(node, `[${PHX_COMPONENT}="${cid}"]`), node).forEach(parent => { @@ -185,7 +188,7 @@ let DOM = { }, updatePrivate(el, key, defaultVal, updateFunc){ - let existing = this.private(el, key) + const existing = this.private(el, key) if(existing === undefined){ this.putPrivate(el, key, updateFunc(defaultVal)) } else { @@ -210,13 +213,13 @@ let DOM = { }, putTitle(str){ - let titleEl = document.querySelector("title") + const titleEl = document.querySelector("title") if(titleEl){ - let {prefix, suffix, default: defaultTitle} = titleEl.dataset - let isEmpty = typeof(str) !== "string" || str.trim() === "" + const {prefix, suffix, default: defaultTitle} = titleEl.dataset + const isEmpty = typeof(str) !== "string" || str.trim() === "" if(isEmpty && typeof(defaultTitle) !== "string"){ return } - let inner = isEmpty ? defaultTitle : str + const inner = isEmpty ? defaultTitle : str document.title = `${prefix || ""}${inner || ""}${suffix || ""}` } else { document.title = str @@ -229,7 +232,7 @@ let DOM = { if(debounce === ""){ debounce = defaultDebounce } if(throttle === ""){ throttle = defaultThrottle } - let value = debounce || throttle + const value = debounce || throttle switch(value){ case null: return callback() @@ -243,14 +246,14 @@ let DOM = { return default: - let timeout = parseInt(value) - let trigger = () => throttle ? this.deletePrivate(el, THROTTLED) : callback() - let currentCycle = this.incCycle(el, DEBOUNCE_TRIGGER, trigger) + const timeout = parseInt(value) + const trigger = () => throttle ? this.deletePrivate(el, THROTTLED) : callback() + const currentCycle = this.incCycle(el, DEBOUNCE_TRIGGER, trigger) if(isNaN(timeout)){ return logError(`invalid throttle/debounce value: ${value}`) } if(throttle){ let newKeyDown = false if(event.type === "keydown"){ - let prevKey = this.private(el, DEBOUNCE_PREV_KEY) + const prevKey = this.private(el, DEBOUNCE_PREV_KEY) this.putPrivate(el, DEBOUNCE_PREV_KEY, event.key) newKeyDown = prevKey !== event.key } @@ -270,11 +273,11 @@ let DOM = { }, timeout) } - let form = el.form + const form = el.form if(form && this.once(form, "bind-debounce")){ form.addEventListener("submit", () => { Array.from((new FormData(form)).entries(), ([name]) => { - let input = form.querySelector(`[name="${name}"]`) + const input = form.querySelector(`[name="${name}"]`) this.incCycle(input, DEBOUNCE_TRIGGER) this.deletePrivate(input, THROTTLED) }) @@ -293,7 +296,7 @@ let DOM = { }, triggerCycle(el, key, currentCycle){ - let [cycle, trigger] = this.private(el, key) + const [cycle, trigger] = this.private(el, key) if(!currentCycle){ currentCycle = cycle } if(currentCycle === cycle){ this.incCycle(el, key) @@ -372,13 +375,13 @@ let DOM = { dispatchEvent(target, name, opts = {}){ let defaultBubble = true - let isUploadTarget = target.nodeName === "INPUT" && target.type === "file" + const isUploadTarget = target.nodeName === "INPUT" && target.type === "file" if(isUploadTarget && name === "click"){ defaultBubble = false } - let bubbles = opts.bubbles === undefined ? defaultBubble : !!opts.bubbles - let eventOpts = {bubbles: bubbles, cancelable: true, detail: opts.detail || {}} - let event = name === "click" ? new MouseEvent("click", eventOpts) : new CustomEvent(name, eventOpts) + const bubbles = opts.bubbles === undefined ? defaultBubble : !!opts.bubbles + const eventOpts = {bubbles: bubbles, cancelable: true, detail: opts.detail || {}} + const event = name === "click" ? new MouseEvent("click", eventOpts) : new CustomEvent(name, eventOpts) target.dispatchEvent(event) }, @@ -386,7 +389,7 @@ let DOM = { if(typeof (html) === "undefined"){ return node.cloneNode(true) } else { - let cloned = node.cloneNode(false) + const cloned = node.cloneNode(false) cloned.innerHTML = html return cloned } @@ -396,11 +399,11 @@ let DOM = { // if an element is ignored, we only merge data attributes // including removing data attributes that are no longer in the source mergeAttrs(target, source, opts = {}){ - let exclude = new Set(opts.exclude || []) - let isIgnored = opts.isIgnored - let sourceAttrs = source.attributes + const exclude = new Set(opts.exclude || []) + const isIgnored = opts.isIgnored + const sourceAttrs = source.attributes for(let i = sourceAttrs.length - 1; i >= 0; i--){ - let name = sourceAttrs[i].name + const name = sourceAttrs[i].name if(!exclude.has(name)){ const sourceValue = source.getAttribute(name) if(target.getAttribute(name) !== sourceValue && (!isIgnored || (isIgnored && name.startsWith("data-")))){ @@ -421,9 +424,9 @@ let DOM = { } } - let targetAttrs = target.attributes + const targetAttrs = target.attributes for(let i = targetAttrs.length - 1; i >= 0; i--){ - let name = targetAttrs[i].name + const name = targetAttrs[i].name if(isIgnored){ if(name.startsWith("data-") && !source.hasAttribute(name) && !PHX_PENDING_ATTRS.includes(name)){ target.removeAttribute(name) } } else { @@ -451,7 +454,7 @@ let DOM = { if(focused instanceof HTMLSelectElement){ focused.focus() } if(!DOM.isTextualInput(focused)){ return } - let wasFocused = focused.matches(":focus") + const wasFocused = focused.matches(":focus") if(!wasFocused){ focused.focus() } if(this.hasSelectionRange(focused)){ focused.setSelectionRange(selectionStart, selectionEnd) @@ -474,11 +477,11 @@ let DOM = { cleanChildNodes(container, phxUpdate){ if(DOM.isPhxUpdate(container, phxUpdate, ["append", "prepend"])){ - let toRemove = [] + const toRemove = [] container.childNodes.forEach(childNode => { if(!childNode.id){ // Skip warning if it's an empty text node (e.g. a new-line) - let isEmptyTextNode = childNode.nodeType === Node.TEXT_NODE && childNode.nodeValue.trim() === "" + const isEmptyTextNode = childNode.nodeType === Node.TEXT_NODE && childNode.nodeValue.trim() === "" if(!isEmptyTextNode && childNode.nodeType !== Node.COMMENT_NODE){ logError("only HTML element tags with an id are allowed inside containers with phx-update.\n\n" + `removing illegal node: "${(childNode.outerHTML || childNode.nodeValue).trim()}"\n\n`) @@ -491,7 +494,7 @@ let DOM = { }, replaceRootContainer(container, tagName, attrs){ - let retainedAttrs = new Set(["id", PHX_SESSION, PHX_STATIC, PHX_MAIN, PHX_ROOT_ID]) + const retainedAttrs = new Set(["id", PHX_SESSION, PHX_STATIC, PHX_MAIN, PHX_ROOT_ID]) if(container.tagName.toLowerCase() === tagName.toLowerCase()){ Array.from(container.attributes) .filter(attr => !retainedAttrs.has(attr.name.toLowerCase())) @@ -504,7 +507,7 @@ let DOM = { return container } else { - let newContainer = document.createElement(tagName) + const newContainer = document.createElement(tagName) Object.keys(attrs).forEach(attr => newContainer.setAttribute(attr, attrs[attr])) retainedAttrs.forEach(attr => newContainer.setAttribute(attr, container.getAttribute(attr))) newContainer.innerHTML = container.innerHTML @@ -514,9 +517,9 @@ let DOM = { }, getSticky(el, name, defaultVal){ - let op = (DOM.private(el, "sticky") || []).find(([existingName,]) => name === existingName) + const op = (DOM.private(el, "sticky") || []).find(([existingName,]) => name === existingName) if(op){ - let [_name, _op, stashedResult] = op + const [_name, _op, stashedResult] = op return stashedResult } else { return typeof(defaultVal) === "function" ? defaultVal() : defaultVal @@ -530,9 +533,9 @@ let DOM = { }, putSticky(el, name, op){ - let stashedResult = op(el) + const stashedResult = op(el) this.updatePrivate(el, "sticky", [], ops => { - let existingIndex = ops.findIndex(([existingName,]) => name === existingName) + const existingIndex = ops.findIndex(([existingName,]) => name === existingName) if(existingIndex >= 0){ ops[existingIndex] = [name, op, stashedResult] } else { @@ -543,7 +546,7 @@ let DOM = { }, applyStickyOperations(el){ - let ops = DOM.private(el, "sticky") + const ops = DOM.private(el, "sticky") if(!ops){ return } ops.forEach(([name, op, _stashed]) => this.putSticky(el, name, op)) diff --git a/assets/js/phoenix_live_view/dom_patch.js b/assets/js/phoenix_live_view/dom_patch.js index 2cf9eb5868..9057e88cc4 100644 --- a/assets/js/phoenix_live_view/dom_patch.js +++ b/assets/js/phoenix_live_view/dom_patch.js @@ -63,30 +63,30 @@ export default class DOMPatch { } markPrunableContentForRemoval(){ - let phxUpdate = this.liveSocket.binding(PHX_UPDATE) + const phxUpdate = this.liveSocket.binding(PHX_UPDATE) DOM.all(this.container, `[${phxUpdate}=append] > *, [${phxUpdate}=prepend] > *`, el => { el.setAttribute(PHX_PRUNE, "") }) } perform(isJoinPatch){ - let {view, liveSocket, html, container, targetContainer} = this + const {view, liveSocket, html, container, targetContainer} = this if(this.isCIDPatch() && !targetContainer){ return } - let focused = liveSocket.getActiveElement() - let {selectionStart, selectionEnd} = focused && DOM.hasSelectionRange(focused) ? focused : {} - let phxUpdate = liveSocket.binding(PHX_UPDATE) - let phxViewportTop = liveSocket.binding(PHX_VIEWPORT_TOP) - let phxViewportBottom = liveSocket.binding(PHX_VIEWPORT_BOTTOM) - let phxTriggerExternal = liveSocket.binding(PHX_TRIGGER_ACTION) - let added = [] - let updates = [] - let appendPrependUpdates = [] + const focused = liveSocket.getActiveElement() + const {selectionStart, selectionEnd} = focused && DOM.hasSelectionRange(focused) ? focused : {} + const phxUpdate = liveSocket.binding(PHX_UPDATE) + const phxViewportTop = liveSocket.binding(PHX_VIEWPORT_TOP) + const phxViewportBottom = liveSocket.binding(PHX_VIEWPORT_BOTTOM) + const phxTriggerExternal = liveSocket.binding(PHX_TRIGGER_ACTION) + const added = [] + const updates = [] + const appendPrependUpdates = [] let externalFormTriggered = null function morph(targetContainer, source, withChildren=this.withChildren){ - let morphCallbacks = { + const morphCallbacks = { // normally, we are running with childrenOnly, as the patch HTML for a LV // does not include the LV attrs (data-phx-session, etc.) // when we are patching a live component, we do want to patch the root element as well; @@ -103,7 +103,7 @@ export default class DOMPatch { skipFromChildren: (from) => { return from.getAttribute(phxUpdate) === PHX_STREAM }, // tell morphdom how to add a child addChild: (parent, child) => { - let {ref, streamAt} = this.getStreamInsert(child) + const {ref, streamAt} = this.getStreamInsert(child) if(ref === undefined){ return parent.appendChild(child) } this.setStreamRef(child, ref) @@ -112,15 +112,15 @@ export default class DOMPatch { if(streamAt === 0){ parent.insertAdjacentElement("afterbegin", child) } else if(streamAt === -1){ - let lastChild = parent.lastElementChild + const lastChild = parent.lastElementChild if(lastChild && !lastChild.hasAttribute(PHX_STREAM_REF)){ - let nonStreamChild = Array.from(parent.children).find(c => !c.hasAttribute(PHX_STREAM_REF)) + const nonStreamChild = Array.from(parent.children).find(c => !c.hasAttribute(PHX_STREAM_REF)) parent.insertBefore(child, nonStreamChild) } else { parent.appendChild(child) } } else if(streamAt > 0){ - let sibling = Array.from(parent.children)[streamAt] + const sibling = Array.from(parent.children)[streamAt] parent.insertBefore(child, sibling) } }, @@ -143,6 +143,7 @@ export default class DOMPatch { // hack to fix Safari handling of img srcset and video tags if(el instanceof HTMLImageElement && el.srcset){ + // eslint-disable-next-line no-self-assign el.srcset = el.srcset } else if(el instanceof HTMLVideoElement && el.autoplay){ el.play() @@ -215,8 +216,8 @@ export default class DOMPatch { // We keep a reference to the cloned tree in the element's private data, and // on ack (view.undoRefs), we morph the cloned tree with the true fromEl in the DOM to // apply any changes that happened while the element was locked. - let isFocusedFormEl = focused && fromEl.isSameNode(focused) && DOM.isFormInput(fromEl) - let focusedSelectChanged = isFocusedFormEl && this.isChangedSelect(fromEl, toEl) + const isFocusedFormEl = focused && fromEl.isSameNode(focused) && DOM.isFormInput(fromEl) + const focusedSelectChanged = isFocusedFormEl && this.isChangedSelect(fromEl, toEl) if(fromEl.hasAttribute(PHX_REF_SRC)){ const ref = new ElementRef(fromEl) // only perform the clone step if this is not a patch that unlocks @@ -227,8 +228,8 @@ export default class DOMPatch { updates.push(fromEl) } DOM.applyStickyOperations(fromEl) - let isLocked = fromEl.hasAttribute(PHX_REF_LOCK) - let clone = isLocked ? DOM.private(fromEl, PHX_REF_LOCK) || fromEl.cloneNode(true) : null + const isLocked = fromEl.hasAttribute(PHX_REF_LOCK) + const clone = isLocked ? DOM.private(fromEl, PHX_REF_LOCK) || fromEl.cloneNode(true) : null if(clone){ DOM.putPrivate(fromEl, PHX_REF_LOCK, clone) if(!isFocusedFormEl){ @@ -240,7 +241,7 @@ export default class DOMPatch { // nested view handling if(DOM.isPhxChild(toEl)){ - let prevSession = fromEl.getAttribute(PHX_SESSION) + const prevSession = fromEl.getAttribute(PHX_SESSION) DOM.mergeAttrs(fromEl, toEl, {exclude: [PHX_STATIC]}) if(prevSession !== ""){ fromEl.setAttribute(PHX_SESSION, prevSession) } fromEl.setAttribute(PHX_ROOT_ID, this.rootID) @@ -294,7 +295,7 @@ export default class DOMPatch { }) } deleteIds.forEach(id => { - let child = container.querySelector(`[id="${id}"]`) + const child = container.querySelector(`[id="${id}"]`) if(child){ this.removeStreamChildElement(child) } }) }) @@ -324,7 +325,7 @@ export default class DOMPatch { detectInvalidStreamInserts(this.streamInserts) // warn if there are any inputs named "id" Array.from(document.querySelectorAll("input[name=id]")).forEach(node => { - if(node.form){ + if(node instanceof HTMLInputElement && node.form){ console.error("Detected an input with name=\"id\" inside a form! This will cause problems when patching the DOM.\n", node) } }) @@ -388,7 +389,7 @@ export default class DOMPatch { } getStreamInsert(el){ - let insert = el.id ? this.streamInserts[el.id] : {} + const insert = el.id ? this.streamInserts[el.id] : {} return insert || {} } @@ -397,7 +398,7 @@ export default class DOMPatch { } maybeReOrderStream(el, isNew){ - let {ref, streamAt, reset} = this.getStreamInsert(el) + const {ref, streamAt, reset} = this.getStreamInsert(el) if(streamAt === undefined){ return } // we need to set the PHX_STREAM_REF here as well as addChild is invoked only for parents @@ -417,12 +418,12 @@ export default class DOMPatch { if(streamAt === 0){ el.parentElement.insertBefore(el, el.parentElement.firstElementChild) } else if(streamAt > 0){ - let children = Array.from(el.parentElement.children) - let oldIndex = children.indexOf(el) + const children = Array.from(el.parentElement.children) + const oldIndex = children.indexOf(el) if(streamAt >= children.length - 1){ el.parentElement.appendChild(el) } else { - let sibling = children[streamAt] + const sibling = children[streamAt] if(oldIndex > streamAt){ el.parentElement.insertBefore(el, sibling) } else { @@ -435,8 +436,8 @@ export default class DOMPatch { } maybeLimitStream(el){ - let {limit} = this.getStreamInsert(el) - let children = limit !== null && Array.from(el.parentElement.children) + const {limit} = this.getStreamInsert(el) + const children = limit !== null && Array.from(el.parentElement.children) if(limit && limit < 0 && children.length > limit * -1){ children.slice(0, children.length + limit).forEach(child => this.removeStreamChildElement(child)) } else if(limit && limit >= 0 && children.length > limit){ @@ -445,11 +446,11 @@ export default class DOMPatch { } transitionPendingRemoves(){ - let {pendingRemoves, liveSocket} = this + const {pendingRemoves, liveSocket} = this if(pendingRemoves.length > 0){ liveSocket.transitionRemoves(pendingRemoves, () => { pendingRemoves.forEach(el => { - let child = DOM.firstPhxChild(el) + const child = DOM.firstPhxChild(el) if(child){ liveSocket.destroyViewByEl(child) } el.remove() }) @@ -478,7 +479,7 @@ export default class DOMPatch { targetCIDContainer(html){ if(!this.isCIDPatch()){ return } - let [first, ...rest] = DOM.findComponentNodeList(this.container, this.targetCID) + const [first, ...rest] = DOM.findComponentNodeList(this.container, this.targetCID) if(rest.length === 0 && DOM.childNodeLength(html) === 1){ return first } else { diff --git a/assets/js/phoenix_live_view/dom_post_morph_restorer.js b/assets/js/phoenix_live_view/dom_post_morph_restorer.js index 2fa2ea429b..2b1d73f075 100644 --- a/assets/js/phoenix_live_view/dom_post_morph_restorer.js +++ b/assets/js/phoenix_live_view/dom_post_morph_restorer.js @@ -6,16 +6,16 @@ import DOM from "./dom" export default class DOMPostMorphRestorer { constructor(containerBefore, containerAfter, updateType){ - let idsBefore = new Set() - let idsAfter = new Set([...containerAfter.children].map(child => child.id)) + const idsBefore = new Set() + const idsAfter = new Set([...containerAfter.children].map(child => child.id)) - let elementsToModify = [] + const elementsToModify = [] Array.from(containerBefore.children).forEach(child => { if(child.id){ // all of our children should be elements with ids idsBefore.add(child.id) if(idsAfter.has(child.id)){ - let previousElementId = child.previousElementSibling && child.previousElementSibling.id + const previousElementId = child.previousElementSibling && child.previousElementSibling.id elementsToModify.push({elementId: child.id, previousElementId: previousElementId}) } } @@ -34,12 +34,13 @@ export default class DOMPostMorphRestorer { // 3) New elements are going to be put in the right place by morphdom during append. // For prepend, we move them to the first position in the container perform(){ - let container = DOM.byId(this.containerId) + const container = DOM.byId(this.containerId) + if(!container){ return } this.elementsToModify.forEach(elementToModify => { if(elementToModify.previousElementId){ maybe(document.getElementById(elementToModify.previousElementId), previousElem => { maybe(document.getElementById(elementToModify.elementId), elem => { - let isInRightPlace = elem.previousElementSibling && elem.previousElementSibling.id == previousElem.id + const isInRightPlace = elem.previousElementSibling && elem.previousElementSibling.id == previousElem.id if(!isInRightPlace){ previousElem.insertAdjacentElement("afterend", elem) } @@ -48,7 +49,7 @@ export default class DOMPostMorphRestorer { } else { // This is the first element in the container maybe(document.getElementById(elementToModify.elementId), elem => { - let isInRightPlace = elem.previousElementSibling == null + const isInRightPlace = elem.previousElementSibling == null if(!isInRightPlace){ container.insertAdjacentElement("afterbegin", elem) } diff --git a/assets/js/phoenix_live_view/element_ref.js b/assets/js/phoenix_live_view/element_ref.js index 550d4e393d..6e734cfd3d 100644 --- a/assets/js/phoenix_live_view/element_ref.js +++ b/assets/js/phoenix_live_view/element_ref.js @@ -56,14 +56,14 @@ export default class ElementRef { undoLocks(ref, phxEvent, eachCloneCallback){ if(!this.isLockUndoneBy(ref)){ return } - let clonedTree = DOM.private(this.el, PHX_REF_LOCK) + const clonedTree = DOM.private(this.el, PHX_REF_LOCK) if(clonedTree){ eachCloneCallback(clonedTree) DOM.deletePrivate(this.el, PHX_REF_LOCK) } this.el.removeAttribute(PHX_REF_LOCK) - let opts = {detail: {ref: ref, event: phxEvent}, bubbles: true, cancelable: false} + const opts = {detail: {ref: ref, event: phxEvent}, bubbles: true, cancelable: false} this.el.dispatchEvent(new CustomEvent(`phx:undo-lock:${this.lockRef}`, opts)) } @@ -77,8 +77,8 @@ export default class ElementRef { if(this.canUndoLoading(ref)){ this.el.removeAttribute(PHX_REF_LOADING) - let disabledVal = this.el.getAttribute(PHX_DISABLED) - let readOnlyVal = this.el.getAttribute(PHX_READONLY) + const disabledVal = this.el.getAttribute(PHX_DISABLED) + const readOnlyVal = this.el.getAttribute(PHX_READONLY) // restore inputs if(readOnlyVal !== null){ this.el.readOnly = readOnlyVal === "true" ? true : false @@ -89,13 +89,13 @@ export default class ElementRef { this.el.removeAttribute(PHX_DISABLED) } // restore disables - let disableRestore = this.el.getAttribute(PHX_DISABLE_WITH_RESTORE) + const disableRestore = this.el.getAttribute(PHX_DISABLE_WITH_RESTORE) if(disableRestore !== null){ this.el.innerText = disableRestore this.el.removeAttribute(PHX_DISABLE_WITH_RESTORE) } - let opts = {detail: {ref: ref, event: phxEvent}, bubbles: true, cancelable: false} + const opts = {detail: {ref: ref, event: phxEvent}, bubbles: true, cancelable: false} this.el.dispatchEvent(new CustomEvent(`phx:undo-loading:${this.loadingRef}`, opts)) } diff --git a/assets/js/phoenix_live_view/entry_uploader.js b/assets/js/phoenix_live_view/entry_uploader.js index a7535434f1..9e8f9e89d8 100644 --- a/assets/js/phoenix_live_view/entry_uploader.js +++ b/assets/js/phoenix_live_view/entry_uploader.js @@ -4,7 +4,7 @@ import { export default class EntryUploader { constructor(entry, config, liveSocket){ - let {chunk_size, chunk_timeout} = config + const {chunk_size, chunk_timeout} = config this.liveSocket = liveSocket this.entry = entry this.offset = 0 @@ -33,12 +33,12 @@ export default class EntryUploader { isDone(){ return this.offset >= this.entry.file.size } readNextChunk(){ - let reader = new window.FileReader() - let blob = this.entry.file.slice(this.offset, this.chunkSize + this.offset) + const reader = new window.FileReader() + const blob = this.entry.file.slice(this.offset, this.chunkSize + this.offset) reader.onload = (e) => { if(e.target.error === null){ - this.offset += e.target.result.byteLength - this.pushChunk(e.target.result) + this.offset += (/** @type {ArrayBuffer} */ (e.target.result)).byteLength + this.pushChunk(/** @type {ArrayBuffer} */ (e.target.result)) } else { return logError("Read error: " + e.target.error) } diff --git a/assets/js/phoenix_live_view/global.d.ts b/assets/js/phoenix_live_view/global.d.ts new file mode 100644 index 0000000000..1d65cce094 --- /dev/null +++ b/assets/js/phoenix_live_view/global.d.ts @@ -0,0 +1 @@ +declare let LV_VSN: string diff --git a/assets/js/phoenix_live_view/hooks.js b/assets/js/phoenix_live_view/hooks.js index 6a041b7f92..8922ac109b 100644 --- a/assets/js/phoenix_live_view/hooks.js +++ b/assets/js/phoenix_live_view/hooks.js @@ -8,7 +8,7 @@ import { import LiveUploader from "./live_uploader" import ARIA from "./aria" -let Hooks = { +const Hooks = { LiveFileUpload: { activeRefs(){ return this.el.getAttribute(PHX_ACTIVE_ENTRY_REFS) }, @@ -17,7 +17,7 @@ let Hooks = { mounted(){ this.preflightedWas = this.preflightedRefs() }, updated(){ - let newPreflights = this.preflightedRefs() + const newPreflights = this.preflightedRefs() if(this.preflightedWas !== newPreflights){ this.preflightedWas = newPreflights if(newPreflights === ""){ @@ -78,7 +78,7 @@ let Hooks = { } } -let findScrollContainer = (el) => { +const findScrollContainer = (el) => { // the scroll event won't be fired on the html/body element even if overflow is set // therefore we return null to instead listen for scroll events on document if(["HTML", "BODY"].indexOf(el.nodeName.toUpperCase()) >= 0) return null @@ -86,7 +86,7 @@ let findScrollContainer = (el) => { return findScrollContainer(el.parentElement) } -let scrollTop = (scrollContainer) => { +const scrollTop = (scrollContainer) => { if(scrollContainer){ return scrollContainer.scrollTop } else { @@ -94,7 +94,7 @@ let scrollTop = (scrollContainer) => { } } -let bottom = (scrollContainer) => { +const bottom = (scrollContainer) => { if(scrollContainer){ return scrollContainer.getBoundingClientRect().bottom } else { @@ -104,7 +104,7 @@ let bottom = (scrollContainer) => { } } -let top = (scrollContainer) => { +const top = (scrollContainer) => { if(scrollContainer){ return scrollContainer.getBoundingClientRect().top } else { @@ -114,18 +114,18 @@ let top = (scrollContainer) => { } } -let isAtViewportTop = (el, scrollContainer) => { - let rect = el.getBoundingClientRect() +const isAtViewportTop = (el, scrollContainer) => { + const rect = el.getBoundingClientRect() return Math.ceil(rect.top) >= top(scrollContainer) && Math.ceil(rect.left) >= 0 && Math.floor(rect.top) <= bottom(scrollContainer) } -let isAtViewportBottom = (el, scrollContainer) => { - let rect = el.getBoundingClientRect() +const isAtViewportBottom = (el, scrollContainer) => { + const rect = el.getBoundingClientRect() return Math.ceil(rect.bottom) >= top(scrollContainer) && Math.ceil(rect.left) >= 0 && Math.floor(rect.bottom) <= bottom(scrollContainer) } -let isWithinViewport = (el, scrollContainer) => { - let rect = el.getBoundingClientRect() +const isWithinViewport = (el, scrollContainer) => { + const rect = el.getBoundingClientRect() return Math.ceil(rect.top) >= top(scrollContainer) && Math.ceil(rect.left) >= 0 && Math.floor(rect.top) <= bottom(scrollContainer) } @@ -134,17 +134,17 @@ Hooks.InfiniteScroll = { this.scrollContainer = findScrollContainer(this.el) let scrollBefore = scrollTop(this.scrollContainer) let topOverran = false - let throttleInterval = 500 + const throttleInterval = 500 let pendingOp = null - let onTopOverrun = this.throttle(throttleInterval, (topEvent, firstChild) => { + const onTopOverrun = this.throttle(throttleInterval, (topEvent, firstChild) => { pendingOp = () => true this.liveSocket.js().push(this.el, topEvent, {value: {id: firstChild.id, _overran: true}, callback: () => { pendingOp = null }}) }) - let onFirstChildAtTop = this.throttle(throttleInterval, (topEvent, firstChild) => { + const onFirstChildAtTop = this.throttle(throttleInterval, (topEvent, firstChild) => { pendingOp = () => firstChild.scrollIntoView({block: "start"}) this.liveSocket.js().push(this.el, topEvent, {value: {id: firstChild.id}, callback: () => { pendingOp = null @@ -157,7 +157,7 @@ Hooks.InfiniteScroll = { }}) }) - let onLastChildAtBottom = this.throttle(throttleInterval, (bottomEvent, lastChild) => { + const onLastChildAtBottom = this.throttle(throttleInterval, (bottomEvent, lastChild) => { pendingOp = () => lastChild.scrollIntoView({block: "end"}) this.liveSocket.js().push(this.el, bottomEvent, {value: {id: lastChild.id}, callback: () => { pendingOp = null @@ -171,19 +171,19 @@ Hooks.InfiniteScroll = { }) this.onScroll = (_e) => { - let scrollNow = scrollTop(this.scrollContainer) + const scrollNow = scrollTop(this.scrollContainer) if(pendingOp){ scrollBefore = scrollNow return pendingOp() } - let rect = this.el.getBoundingClientRect() - let topEvent = this.el.getAttribute(this.liveSocket.binding("viewport-top")) - let bottomEvent = this.el.getAttribute(this.liveSocket.binding("viewport-bottom")) - let lastChild = this.el.lastElementChild - let firstChild = this.el.firstElementChild - let isScrollingUp = scrollNow < scrollBefore - let isScrollingDown = scrollNow > scrollBefore + const rect = this.el.getBoundingClientRect() + const topEvent = this.el.getAttribute(this.liveSocket.binding("viewport-top")) + const bottomEvent = this.el.getAttribute(this.liveSocket.binding("viewport-bottom")) + const lastChild = this.el.lastElementChild + const firstChild = this.el.firstElementChild + const isScrollingUp = scrollNow < scrollBefore + const isScrollingDown = scrollNow > scrollBefore // el overran while scrolling up if(isScrollingUp && topEvent && !topOverran && rect.top >= 0){ @@ -221,8 +221,8 @@ Hooks.InfiniteScroll = { let timer return (...args) => { - let now = Date.now() - let remainingTime = interval - (now - lastCallAt) + const now = Date.now() + const remainingTime = interval - (now - lastCallAt) if(remainingTime <= 0 || remainingTime > interval){ if(timer){ diff --git a/assets/js/phoenix_live_view/index.js b/assets/js/phoenix_live_view/index.js deleted file mode 100644 index 86a32b1d6b..0000000000 --- a/assets/js/phoenix_live_view/index.js +++ /dev/null @@ -1,49 +0,0 @@ -/* -================================================================================ -Phoenix LiveView JavaScript Client -================================================================================ - -See the hexdocs at `https://hexdocs.pm/phoenix_live_view` for documentation. - -*/ - -import LiveSocket, {isUsedInput} from "./live_socket" -import DOM from "./dom" -import ViewHook from "./view_hook" -import View from "./view" - -/** Creates a ViewHook instance for the given element and callbacks. - * - * @param {HTMLElement} el - The element to associate with the hook. - * @param {Object} [callbacks] - The list of hook callbacks, such as mounted, - * updated, destroyed, etc. - * - * @example - * - * class MyComponent extends HTMLElement { - * connectedCallback(){ - * let onLiveViewMounted = () => this.hook.pushEvent(...)) - * this.hook = createHook(this, {mounted: onLiveViewMounted}) - * } - * } - * - * *Note*: `createHook` must be called from the `connectedCallback` lifecycle - * which is triggered after the element has been added to the DOM. If you try - * to call `createHook` from the constructor, an error will be logged. - * - * @returns {ViewHook} Returns the ViewHook instance for the custom element. - */ -let createHook = (el, callbacks = {}) => { - let existingHook = DOM.getCustomElHook(el) - if(existingHook){ return existingHook } - - let hook = new ViewHook(View.closestView(el), el, callbacks) - DOM.putCustomElHook(el, hook) - return hook -} - -export { - LiveSocket, - isUsedInput, - createHook -} diff --git a/assets/js/phoenix_live_view/index.ts b/assets/js/phoenix_live_view/index.ts new file mode 100644 index 0000000000..7670994c25 --- /dev/null +++ b/assets/js/phoenix_live_view/index.ts @@ -0,0 +1,294 @@ +/* +================================================================================ +Phoenix LiveView JavaScript Client +================================================================================ + +See the hexdocs at `https://hexdocs.pm/phoenix_live_view` for documentation. +*/ + +import OriginalLiveSocket, {isUsedInput} from "./live_socket" +import DOM from "./dom" +import {ViewHook as Hook} from "./view_hook" +import View from "./view" + +import type {LiveSocketJSCommands} from "./js_commands" +import type {HookObject, HooksOptions} from "./view_hook" +import type {Socket as PhoenixSocket} from "phoenix" + +/** + * Options for configuring the LiveSocket instance. + */ +export interface LiveSocketOptions { + /** + * Defaults for phx-debounce and phx-throttle. + */ + defaults?: { + /** The millisecond phx-debounce time. Defaults 300 */ + debounce?: number; + /** The millisecond phx-throttle time. Defaults 300 */ + throttle?: number; + }; + /** + * The optional function for passing connect params. + * The function receives the element associated with a given LiveView. For example: + * + * (el) => {view: el.getAttribute("data-my-view-name", token: window.myToken} + * + */ + params?: ((el: HTMLElement) => {[key: string]: any}) | {[key: string]: any}; + /** + * The optional prefix to use for all phx DOM annotations. + * + * Defaults to "phx-". + */ + bindingPrefix?: string; + /** + * Callbacks for LiveView hooks. + * + * See [Client hooks via `phx-hook`](https://hexdocs.pm/phoenix_live_view/js-interop.html#client-hooks-via-phx-hook) for more information. + */ + hooks?: HooksOptions; + /** Callbacks for LiveView uploaders. */ + uploaders?: {[key: string]: any}; // TODO: define more specifically + /** Delay in milliseconds before applying loading states. */ + loaderTimeout?: number; + /** Delay in milliseconds before executing phx-disconnected commands. */ + disconnectedTimeout?: number; + /** Maximum reloads before entering failsafe mode. */ + maxReloads?: number; + /** Minimum time between normal reload attempts. */ + reloadJitterMin?: number; + /** Maximum time between normal reload attempts. */ + reloadJitterMax?: number; + /** Time between reload attempts in failsafe mode. */ + failsafeJitter?: number; + /** + * Function to log debug information. For example: + * + * (view, kind, msg, obj) => console.log(`${view.id} ${kind}: ${msg} - `, obj) + */ + viewLogger?: (view: View, kind: string, msg: string, obj: any) => void; + /** + * Object mapping event names to functions for populating event metadata. + * + * metadata: { + * click: (e, el) => { + * return { + * ctrlKey: e.ctrlKey, + * metaKey: e.metaKey, + * detail: e.detail || 1, + * } + * }, + * keydown: (e, el) => { + * return { + * key: e.key, + * ctrlKey: e.ctrlKey, + * metaKey: e.metaKey, + * shiftKey: e.shiftKey + * } + * } + * } + * + */ + metadata?: {[eventName: string]: (e: Event, el: HTMLElement) => object}; + /** + * An optional Storage compatible object + * Useful when LiveView won't have access to `sessionStorage`. For example, This could + * happen if a site loads a cross-domain LiveView in an iframe. + * + * Example usage: + * + * class InMemoryStorage { + * constructor() { this.storage = {} } + * getItem(keyName) { return this.storage[keyName] || null } + * removeItem(keyName) { delete this.storage[keyName] } + * setItem(keyName, keyValue) { this.storage[keyName] = keyValue } + * } + */ + sessionStorage?: Storage; + /** + * An optional Storage compatible object + * Useful when LiveView won't have access to `localStorage`. + * + * See `sessionStorage` for an example. + */ + localStorage?: Storage; + /** DOM callbacks. */ + dom?: { + /** + * An optional function to modify the behavior of querying elements in JS commands. + * @param sourceEl - The source element, e.g. the button that was clicked. + * @param query - The query value. + * @param defaultQuery - A default query function that can be used if no custom query should be applied. + * @returns A list of DOM elements. + */ + jsQuerySelectorAll?: (sourceEl: HTMLElement, query: string, defaultQuery: () => Element[]) => Element[]; + /** + * Called immediately before a DOM patch is applied. + */ + onPatchStart?: (container: HTMLElement) => void; + /** + * Called immediately after a DOM patch is applied. + */ + onPatchEnd?: (container: HTMLElement) => void; + /** + * Called when a new DOM node is added. + */ + onNodeAdded?: (node: Node) => void; + /** + * Called before an element is updated. + */ + onBeforeElUpdated?: (fromEl: Element, toEl: Element) => void; + }; + /** Allow passthrough of other options to the Phoenix Socket constructor. */ + [key: string]: any; +} + +/** + * Interface describing the public API of a LiveSocket instance. + */ +export interface LiveSocketInstanceInterface { + /** + * Returns the version of the LiveView client. + */ + version(): string; + /** + * Returns true if profiling is enabled. See `enableProfiling` and `disableProfiling`. + */ + isProfileEnabled(): boolean; + /** + * Returns true if debugging is enabled. See `enableDebug` and `disableDebug`. + */ + isDebugEnabled(): boolean; + /** + * Returns true if debugging is disabled. See `enableDebug` and `disableDebug`. + */ + isDebugDisabled(): boolean; + /** + * Enables debugging. + * + * When debugging is enabled, the LiveView client will log debug information to the console. + * See [Debugging client events](https://hexdocs.pm/phoenix_live_view/js-interop.html#debugging-client-events) for more information. + */ + enableDebug(): void; + /** + * Enables profiling. + * + * When profiling is enabled, the LiveView client will log profiling information to the console. + */ + enableProfiling(): void; + /** + * Disables debugging. + */ + disableDebug(): void; + /** + * Disables profiling. + */ + disableProfiling(): void; + /** + * Enables latency simulation. + * + * When latency simulation is enabled, the LiveView client will add a delay to requests and responses from the server. + * See [Simulating Latency](https://hexdocs.pm/phoenix_live_view/js-interop.html#simulating-latency) for more information. + */ + enableLatencySim(upperBoundMs: number): void; + /** + * Disables latency simulation. + */ + disableLatencySim(): void; + /** + * Returns the current latency simulation upper bound. + */ + getLatencySim(): number | null; + /** + * Returns the Phoenix Socket instance. + */ + getSocket(): PhoenixSocket; + /** + * Connects to the LiveView server. + */ + connect(): void; + /** + * Disconnects from the LiveView server. + */ + disconnect(callback?: () => void): void; + /** + * Can be used to replace the transport used by the underlying Phoenix Socket. + */ + replaceTransport(transport: any): void; + /** + * Executes an encoded JS command, targeting the given element. + * + * See [`Phoenix.LiveView.JS`](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.JS.html) for more information. + */ + execJS(el: HTMLElement, encodedJS: string, eventType?: string | null): void; + /** + * Returns an object with methods to manipluate the DOM and execute JavaScript. + * The applied changes integrate with server DOM patching. + * + * See [JavaScript interoperability](https://hexdocs.pm/phoenix_live_view/js-interop.html) for more information. + */ + js(): LiveSocketJSCommands; +} + +/** + * Interface describing the LiveSocket constructor. + */ +export interface LiveSocketConstructor { + /** + * Creates a new LiveSocket instance. + * + * @param endpoint - The string WebSocket endpoint, ie, `"wss://example.com/live"`, + * `"/live"` (inherited host & protocol) + * @param socket - the required Phoenix Socket class imported from "phoenix". For example: + * + * import {Socket} from "phoenix" + * import {LiveSocket} from "phoenix_live_view" + * let liveSocket = new LiveSocket("/live", Socket, {...}) + * + * @param opts - Optional configuration. + */ + new (endpoint: string, socket: typeof PhoenixSocket, opts?: LiveSocketOptions): LiveSocketInstanceInterface; +} + +// because LiveSocket is in JS (for now), we cast it to our defined TypeScript constructor. +const LiveSocket = OriginalLiveSocket as unknown as LiveSocketConstructor + +/** Creates a hook instance for the given element and callbacks. + * + * @param el - The element to associate with the hook. + * @param callbacks - The list of hook callbacks, such as mounted, + * updated, destroyed, etc. + * + * *Note*: `createHook` must be called from the `connectedCallback` lifecycle + * which is triggered after the element has been added to the DOM. If you try + * to call `createHook` from the constructor, an error will be logged. + * + * @example + * + * class MyComponent extends HTMLElement { + * connectedCallback(){ + * let onLiveViewMounted = () => this.hook.pushEvent(...)) + * this.hook = createHook(this, {mounted: onLiveViewMounted}) + * } + * } + * + * @returns Returns the Hook instance for the custom element. + */ +function createHook(el: HTMLElement, callbacks: HookObject): Hook{ + let existingHook = DOM.getCustomElHook(el) + if(existingHook){ return existingHook } + + let hook = new Hook(View.closestView(el), el, callbacks) + DOM.putCustomElHook(el, hook) + return hook +} + +export { + LiveSocket, + isUsedInput, + createHook, + Hook, + HookObject, + HooksOptions +} diff --git a/assets/js/phoenix_live_view/js.js b/assets/js/phoenix_live_view/js.js index 1bd6c7a42e..246435c80b 100644 --- a/assets/js/phoenix_live_view/js.js +++ b/assets/js/phoenix_live_view/js.js @@ -1,14 +1,14 @@ import DOM from "./dom" import ARIA from "./aria" -let focusStack = [] -let default_transition_time = 200 +const focusStack = [] +const default_transition_time = 200 -let JS = { +const JS = { // private exec(e, eventType, phxEvent, view, sourceEl, defaults){ - let [defaultKind, defaultArgs] = defaults || [null, {callback: defaults && defaults.callback}] - let commands = phxEvent.charAt(0) === "[" ? + const [defaultKind, defaultArgs] = defaults || [null, {callback: defaults && defaults.callback}] + const commands = phxEvent.charAt(0) === "[" ? JSON.parse(phxEvent) : [[defaultKind, defaultArgs]] commands.forEach(([kind, args]) => { @@ -46,7 +46,7 @@ let JS = { // commands exec_exec(e, eventType, phxEvent, view, sourceEl, el, {attr, to}){ - let encodedJS = el.getAttribute(attr) + const encodedJS = el.getAttribute(attr) if(!encodedJS){ throw new Error(`expected ${attr} to contain JS command on "${to}"`) } view.liveSocket.execJS(el, encodedJS, eventType) }, @@ -58,10 +58,10 @@ let JS = { }, exec_push(e, eventType, phxEvent, view, sourceEl, el, args){ - let {event, data, target, page_loading, loading, value, dispatcher, callback} = args - let pushOpts = {loading, value, target, page_loading: !!page_loading} - let targetSrc = eventType === "change" && dispatcher ? dispatcher : sourceEl - let phxTarget = target || targetSrc.getAttribute(view.binding("target")) || targetSrc + const {event, data, target, page_loading, loading, value, dispatcher, callback} = args + const pushOpts = {loading, value, target, page_loading: !!page_loading} + const targetSrc = eventType === "change" && dispatcher ? dispatcher : sourceEl + const phxTarget = target || targetSrc.getAttribute(view.binding("target")) || targetSrc const handler = (targetView, targetCtx) => { if(!targetView.isConnected()){ return } if(eventType === "change"){ @@ -70,7 +70,7 @@ let JS = { if(_target){ pushOpts._target = _target } targetView.pushInput(sourceEl, targetCtx, newCid, event || phxEvent, pushOpts, callback) } else if(eventType === "submit"){ - let {submitter} = args + const {submitter} = args targetView.submitForm(sourceEl, targetCtx, event || phxEvent, submitter, pushOpts, callback) } else { targetView.pushEvent(eventType, sourceEl, targetCtx, event || phxEvent, data, pushOpts, callback) @@ -203,18 +203,18 @@ let JS = { toggle(eventType, view, el, display, ins, outs, time, blocking){ time = time || default_transition_time - let [inClasses, inStartClasses, inEndClasses] = ins || [[], [], []] - let [outClasses, outStartClasses, outEndClasses] = outs || [[], [], []] + const [inClasses, inStartClasses, inEndClasses] = ins || [[], [], []] + const [outClasses, outStartClasses, outEndClasses] = outs || [[], [], []] if(inClasses.length > 0 || outClasses.length > 0){ if(this.isVisible(el)){ - let onStart = () => { + const onStart = () => { this.addOrRemoveClasses(el, outStartClasses, inClasses.concat(inStartClasses).concat(inEndClasses)) window.requestAnimationFrame(() => { this.addOrRemoveClasses(el, outClasses, []) window.requestAnimationFrame(() => this.addOrRemoveClasses(el, outEndClasses, outStartClasses)) }) } - let onEnd = () => { + const onEnd = () => { this.addOrRemoveClasses(el, [], outClasses.concat(outEndClasses)) DOM.putSticky(el, "toggle", currentEl => currentEl.style.display = "none") el.dispatchEvent(new Event("phx:hide-end")) @@ -228,7 +228,7 @@ let JS = { } } else { if(eventType === "remove"){ return } - let onStart = () => { + const onStart = () => { this.addOrRemoveClasses(el, inStartClasses, outClasses.concat(outStartClasses).concat(outEndClasses)) const stickyDisplay = display || this.defaultDisplay(el) window.requestAnimationFrame(() => { @@ -245,7 +245,7 @@ let JS = { }) }) } - let onEnd = () => { + const onEnd = () => { this.addOrRemoveClasses(el, [], inClasses.concat(inEndClasses)) el.dispatchEvent(new Event("phx:show-end")) } @@ -267,7 +267,7 @@ let JS = { } else { window.requestAnimationFrame(() => { el.dispatchEvent(new Event("phx:show-start")) - let stickyDisplay = display || this.defaultDisplay(el) + const stickyDisplay = display || this.defaultDisplay(el) DOM.putSticky(el, "toggle", currentEl => currentEl.style.display = stickyDisplay) el.dispatchEvent(new Event("phx:show-end")) }) @@ -277,9 +277,9 @@ let JS = { toggleClasses(el, classes, transition, time, view, blocking){ window.requestAnimationFrame(() => { - let [prevAdds, prevRemoves] = DOM.getSticky(el, "classes", [[], []]) - let newAdds = classes.filter(name => prevAdds.indexOf(name) < 0 && !el.classList.contains(name)) - let newRemoves = classes.filter(name => prevRemoves.indexOf(name) < 0 && el.classList.contains(name)) + const [prevAdds, prevRemoves] = DOM.getSticky(el, "classes", [[], []]) + const newAdds = classes.filter(name => prevAdds.indexOf(name) < 0 && !el.classList.contains(name)) + const newRemoves = classes.filter(name => prevRemoves.indexOf(name) < 0 && el.classList.contains(name)) this.addOrRemoveClasses(el, newAdds, newRemoves, transition, time, view, blocking) }) }, @@ -304,16 +304,16 @@ let JS = { addOrRemoveClasses(el, adds, removes, transition, time, view, blocking){ time = time || default_transition_time - let [transitionRun, transitionStart, transitionEnd] = transition || [[], [], []] + const [transitionRun, transitionStart, transitionEnd] = transition || [[], [], []] if(transitionRun.length > 0){ - let onStart = () => { + const onStart = () => { this.addOrRemoveClasses(el, transitionStart, [].concat(transitionRun).concat(transitionEnd)) window.requestAnimationFrame(() => { this.addOrRemoveClasses(el, transitionRun, []) window.requestAnimationFrame(() => this.addOrRemoveClasses(el, transitionEnd, transitionStart)) }) } - let onDone = () => this.addOrRemoveClasses(el, adds.concat(transitionEnd), removes.concat(transitionRun).concat(transitionStart)) + const onDone = () => this.addOrRemoveClasses(el, adds.concat(transitionEnd), removes.concat(transitionRun).concat(transitionStart)) if(blocking === false){ onStart() setTimeout(onDone, time) @@ -324,11 +324,11 @@ let JS = { } window.requestAnimationFrame(() => { - let [prevAdds, prevRemoves] = DOM.getSticky(el, "classes", [[], []]) - let keepAdds = adds.filter(name => prevAdds.indexOf(name) < 0 && !el.classList.contains(name)) - let keepRemoves = removes.filter(name => prevRemoves.indexOf(name) < 0 && el.classList.contains(name)) - let newAdds = prevAdds.filter(name => removes.indexOf(name) < 0).concat(keepAdds) - let newRemoves = prevRemoves.filter(name => adds.indexOf(name) < 0).concat(keepRemoves) + const [prevAdds, prevRemoves] = DOM.getSticky(el, "classes", [[], []]) + const keepAdds = adds.filter(name => prevAdds.indexOf(name) < 0 && !el.classList.contains(name)) + const keepRemoves = removes.filter(name => prevRemoves.indexOf(name) < 0 && el.classList.contains(name)) + const newAdds = prevAdds.filter(name => removes.indexOf(name) < 0).concat(keepAdds) + const newRemoves = prevRemoves.filter(name => adds.indexOf(name) < 0).concat(keepRemoves) DOM.putSticky(el, "classes", currentEl => { currentEl.classList.remove(...newRemoves) @@ -339,11 +339,11 @@ let JS = { }, setOrRemoveAttrs(el, sets, removes){ - let [prevSets, prevRemoves] = DOM.getSticky(el, "attrs", [[], []]) + const [prevSets, prevRemoves] = DOM.getSticky(el, "attrs", [[], []]) - let alteredAttrs = sets.map(([attr, _val]) => attr).concat(removes) - let newSets = prevSets.filter(([attr, _val]) => !alteredAttrs.includes(attr)).concat(sets) - let newRemoves = prevRemoves.filter((attr) => !alteredAttrs.includes(attr)).concat(removes) + const alteredAttrs = sets.map(([attr, _val]) => attr).concat(removes) + const newSets = prevSets.filter(([attr, _val]) => !alteredAttrs.includes(attr)).concat(sets) + const newRemoves = prevRemoves.filter((attr) => !alteredAttrs.includes(attr)).concat(removes) DOM.putSticky(el, "attrs", currentEl => { newRemoves.forEach(attr => currentEl.removeAttribute(attr)) @@ -359,11 +359,11 @@ let JS = { }, filterToEls(liveSocket, sourceEl, {to}){ - let defaultQuery = () => { + const defaultQuery = () => { if(typeof(to) === "string"){ return document.querySelectorAll(to) } else if(to.closest){ - let toEl = sourceEl.closest(to.closest) + const toEl = sourceEl.closest(to.closest) return toEl ? [toEl] : [] } else if(to.inner){ return sourceEl.querySelectorAll(to.inner) diff --git a/assets/js/phoenix_live_view/js_commands.js b/assets/js/phoenix_live_view/js_commands.js deleted file mode 100644 index af5e01ab0e..0000000000 --- a/assets/js/phoenix_live_view/js_commands.js +++ /dev/null @@ -1,243 +0,0 @@ -import JS from "./js" - -export default (liveSocket, eventType) => { - return { - /** - * Executes encoded JavaScript in the context of the element. - * - * @param {string} encodedJS - The encoded JavaScript string to execute. - */ - exec(el, encodedJS){ - liveSocket.execJS(el, encodedJS, eventType) - }, - - /** - * Shows an element. - * - * @param {HTMLElement} el - The element to show. - * @param {Object} [opts={}] - Optional settings. - * @param {string} [opts.display] - The CSS display value to set. Defaults "block". - * @param {string} [opts.transition] - The CSS transition classes to set when showing. - * @param {number} [opts.time] - The transition duration in milliseconds. Defaults 200. - * @param {boolean} [opts.blocking] - The boolean flag to block the UI during the transition. - * Defaults `true`. - */ - show(el, opts = {}){ - let owner = liveSocket.owner(el) - JS.show(eventType, owner, el, opts.display, opts.transition, opts.time, opts.blocking) - }, - - /** - * Hides an element. - * - * @param {HTMLElement} el - The element to hide. - * @param {Object} [opts={}] - Optional settings. - * @param {string} [opts.transition] - The CSS transition classes to set when hiding. - * @param {number} [opts.time] - The transition duration in milliseconds. Defaults 200. - * @param {boolean} [opts.blocking] - The boolean flag to block the UI during the transition. - * Defaults `true`. - */ - hide(el, opts = {}){ - let owner = liveSocket.owner(el) - JS.hide(eventType, owner, el, null, opts.transition, opts.time, opts.blocking) - }, - - /** - * Toggles the visibility of an element. - * - * @param {HTMLElement} el - The element to toggle. - * @param {Object} [opts={}] - Optional settings. - * @param {string} [opts.display] - The CSS display value to set. Defaults "block". - * @param {string} [opts.in] - The CSS transition classes for showing. - * Accepts either the string of classes to apply when toggling in, or - * a 3-tuple containing the transition class, the class to apply - * to start the transition, and the ending transition class, such as: - * - * ["ease-out duration-300", "opacity-0", "opacity-100"] - * - * @param {string} [opts.out] - The CSS transition classes for hiding. - * Accepts either string of classes to apply when toggling out, or - * a 3-tuple containing the transition class, the class to apply - * to start the transition, and the ending transition class, such as: - * - * ["ease-out duration-300", "opacity-100", "opacity-0"] - * - * @param {number} [opts.time] - The transition duration in milliseconds. - * - * @param {boolean} [opts.blocking] - The boolean flag to block the UI during the transition. - * Defaults `true`. - */ - toggle(el, opts = {}){ - let owner = liveSocket.owner(el) - opts.in = JS.transitionClasses(opts.in) - opts.out = JS.transitionClasses(opts.out) - JS.toggle(eventType, owner, el, opts.display, opts.in, opts.out, opts.time, opts.blocking) - }, - - /** - * Adds CSS classes to an element. - * - * @param {HTMLElement} el - The element to add classes to. - * @param {string|string[]} names - The class name(s) to add. - * @param {Object} [opts={}] - Optional settings. - * @param {string} [opts.transition] - The CSS transition property to set. - * Accepts a string of classes to apply when adding classes or - * a 3-tuple containing the transition class, the class to apply - * to start the transition, and the ending transition class, such as: - * - * ["ease-out duration-300", "opacity-0", "opacity-100"] - * - * @param {number} [opts.time] - The transition duration in milliseconds. - * @param {boolean} [opts.blocking] - The boolean flag to block the UI during the transition. - * Defaults `true`. - */ - addClass(el, names, opts = {}){ - names = Array.isArray(names) ? names : names.split(" ") - let owner = liveSocket.owner(el) - JS.addOrRemoveClasses(el, names, [], opts.transition, opts.time, owner, opts.blocking) - }, - - /** - * Removes CSS classes from an element. - * - * @param {HTMLElement} el - The element to remove classes from. - * @param {string|string[]} names - The class name(s) to remove. - * @param {Object} [opts={}] - Optional settings. - * @param {string} [opts.transition] - The CSS transition classes to set. - * Accepts a string of classes to apply when removing classes or - * a 3-tuple containing the transition class, the class to apply - * to start the transition, and the ending transition class, such as: - * - * ["ease-out duration-300", "opacity-100", "opacity-0"] - * - * @param {number} [opts.time] - The transition duration in milliseconds. - * @param {boolean} [opts.blocking] - The boolean flag to block the UI during the transition. - * Defaults `true`. - */ - removeClass(el, names, opts = {}){ - opts.transition = JS.transitionClasses(opts.transition) - names = Array.isArray(names) ? names : names.split(" ") - let owner = liveSocket.owner(el) - JS.addOrRemoveClasses(el, [], names, opts.transition, opts.time, owner, opts.blocking) - }, - - /** - * Toggles CSS classes on an element. - * - * @param {HTMLElement} el - The element to toggle classes on. - * @param {string|string[]} names - The class name(s) to toggle. - * @param {Object} [opts={}] - Optional settings. - * @param {string} [opts.transition] - The CSS transition classes to set. - * Accepts a string of classes to apply when toggling classes or - * a 3-tuple containing the transition class, the class to apply - * to start the transition, and the ending transition class, such as: - * - * ["ease-out duration-300", "opacity-100", "opacity-0"] - * - * @param {number} [opts.time] - The transition duration in milliseconds. - * @param {boolean} [opts.blocking] - The boolean flag to block the UI during the transition. - * Defaults `true`. - */ - toggleClass(el, names, opts = {}){ - opts.transition = JS.transitionClasses(opts.transition) - names = Array.isArray(names) ? names : names.split(" ") - let owner = liveSocket.owner(el) - JS.toggleClasses(el, names, opts.transition, opts.time, owner, opts.blocking) - }, - - /** - * Applies a CSS transition to an element. - * - * @param {HTMLElement} el - The element to apply the transition to. - * @param {string|string[]} transition - The transition class(es) to apply. - * Accepts a string of classes to apply when transitioning or - * a 3-tuple containing the transition class, the class to apply - * to start the transition, and the ending transition class, such as: - * - * ["ease-out duration-300", "opacity-100", "opacity-0"] - * - * @param {Object} [opts={}] - Optional settings. - * @param {number} [opts.time] - The transition duration in milliseconds. - * @param {boolean} [opts.blocking] - The boolean flag to block the UI during the transition. - * Defaults `true`. - */ - transition(el, transition, opts = {}){ - let owner = liveSocket.owner(el) - JS.addOrRemoveClasses(el, [], [], JS.transitionClasses(transition), opts.time, owner, opts.blocking) - }, - - /** - * Sets an attribute on an element. - * - * @param {HTMLElement} el - The element to set the attribute on. - * @param {string} attr - The attribute name to set. - * @param {string} val - The value to set for the attribute. - */ - setAttribute(el, attr, val){ JS.setOrRemoveAttrs(el, [[attr, val]], []) }, - - /** - * Removes an attribute from an element. - * - * @param {HTMLElement} el - The element to remove the attribute from. - * @param {string} attr - The attribute name to remove. - */ - removeAttribute(el, attr){ JS.setOrRemoveAttrs(el, [], [attr]) }, - - /** - * Toggles an attribute on an element between two values. - * - * @param {HTMLElement} el - The element to toggle the attribute on. - * @param {string} attr - The attribute name to toggle. - * @param {string} val1 - The first value to toggle between. - * @param {string} val2 - The second value to toggle between. - */ - toggleAttribute(el, attr, val1, val2){ JS.toggleAttr(el, attr, val1, val2) }, - - /** - * Pushes an event to the server. - * - * @param {(HTMLElement|number)} el - An element that belongs to the target LiveView. - * To target a LiveComponent by its ID, pass a separate `target` in the options. - * @param {string} type - The string event name to push. - * @param {Object} [opts={}] - Optional settings. - */ - push(el, type, opts = {}){ - liveSocket.withinOwners(el, view => { - const data = opts.value || {} - delete opts.value - let e = new CustomEvent("phx:exec", {detail: {sourceElement: el}}) - JS.exec(e, eventType, type, view, el, ["push", {data, ...opts}]) - }) - }, - - /** - * Sends a navigation event to the server and updates the browser's pushState history. - * - * @param {string} href - The URL to navigate to. - * @param {Object} [opts={}] - Optional settings. - */ - navigate(href, opts = {}){ - let e = new CustomEvent("phx:exec") - liveSocket.historyRedirect(e, href, opts.replace ? "replace" : "push", null, null) - }, - - /** - * Sends a patch event to the server and updates the browser's pushState history. - * - * @param {string} href - The URL to patch to. - * @param {Object} [opts={}] - Optional settings. - */ - patch(href, opts = {}){ - let e = new CustomEvent("phx:exec") - liveSocket.pushHistoryPatch(e, href, opts.replace ? "replace" : "push", null) - }, - - /** - * Mark attributes as ignored, skipping them when patching the DOM. - * - * @param {HTMLElement} el - The element to toggle the attribute on. - * @param {Array|string} attrs - The attribute name or names to ignore. - */ - ignoreAttributes(el, attrs){ JS.ignoreAttrs(el, attrs) } - } -} diff --git a/assets/js/phoenix_live_view/js_commands.ts b/assets/js/phoenix_live_view/js_commands.ts new file mode 100644 index 0000000000..c65d57518c --- /dev/null +++ b/assets/js/phoenix_live_view/js_commands.ts @@ -0,0 +1,305 @@ +import JS from "./js" +import LiveSocket from "./live_socket" + +type Transition = string | string[] + +// Base options for commands involving transitions and timing +type BaseOpts = { + /** + * The CSS transition classes to set. + * Accepts a string of classes or a 3-tuple like: + * `["ease-out duration-300", "opacity-0", "opacity-100"]` + */ + transition?: Transition; + /** The transition duration in milliseconds. Defaults 200. */ + time?: number; + /** Whether to block UI during transition. Defaults `true`. */ + blocking?: boolean; +} + +type ShowOpts = BaseOpts & { + /** The CSS display value to set. Defaults "block". */ + display?: string; +} + +type ToggleOpts = { + /** The CSS display value to set. Defaults "block". */ + display?: string; + /** + * The CSS transition classes for showing. + * Accepts either the string of classes to apply when toggling in, or + * a 3-tuple containing the transition class, the class to apply + * to start the transition, and the ending transition class, such as: + * `["ease-out duration-300", "opacity-0", "opacity-100"]` + */ + in?: Transition; + /** + * The CSS transition classes for hiding. + * Accepts either string of classes to apply when toggling out, or + * a 3-tuple containing the transition class, the class to apply + * to start the transition, and the ending transition class, such as: + * `["ease-out duration-300", "opacity-100", "opacity-0"]` + */ + out?: Transition; + /** The transition duration in milliseconds. */ + time?: number; + /** Whether to block UI during transition. Defaults `true`. */ + blocking?: boolean; +} + +// Options specific to the 'transition' command +type TransitionCommandOpts = { + /** The transition duration in milliseconds. */ + time?: number; + /** Whether to block UI during transition. Defaults `true`. */ + blocking?: boolean; +} + +type PushOpts = { + /** Data to be merged into the event payload. */ + value?: any; + /** For targeting a LiveComponent by its ID, a component ID (number), or a CSS selector string. */ + target?: HTMLElement | number | string; + /** Indicates if a page loading state should be shown. */ + page_loading?: boolean; + [key: string]: any; // Allow other properties like 'cid', 'redirect', etc. +} + +type NavigationOpts = { + /** Whether to replace the current history entry instead of pushing a new one. */ + replace?: boolean; +} + +/** + * Represents all possible JS commands that can be generated by the factory. + * This is used as a base for LiveSocketJSCommands and HookJSCommands. + */ +interface AllJSCommands { + /** + * Executes encoded JavaScript in the context of the element. + * This version is for general use via liveSocket.js(). + * + * @param el - The element in whose context to execute the JavaScript. + * @param encodedJS - The encoded JavaScript string to execute. + */ + exec(el: HTMLElement, encodedJS: string): void; + + /** + * Shows an element. + * + * @param el - The element to show. + * @param {ShowOpts} [opts={}] - Optional settings. + * Accepts: `display`, `transition`, `time`, and `blocking`. + */ + show(el: HTMLElement, opts?: ShowOpts): void; + + /** + * Hides an element. + * + * @param el - The element to hide. + * @param [opts={}] - Optional settings. + * Accepts: `transition`, `time`, and `blocking`. + */ + hide(el: HTMLElement, opts?: BaseOpts): void; + + /** + * Toggles the visibility of an element. + * + * @param el - The element to toggle. + * @param [opts={}] - Optional settings. + * Accepts: `display`, `in`, `out`, `time`, and `blocking`. + */ + toggle(el: HTMLElement, opts?: ToggleOpts): void; + + /** + * Adds CSS classes to an element. + * + * @param el - The element to add classes to. + * @param names - The class name(s) to add. + * @param [opts={}] - Optional settings. + * Accepts: `transition`, `time`, and `blocking`. + */ + addClass(el: HTMLElement, names: string | string[], opts?: BaseOpts): void; + + /** + * Removes CSS classes from an element. + * + * @param el - The element to remove classes from. + * @param names - The class name(s) to remove. + * @param [opts={}] - Optional settings. + * Accepts: `transition`, `time`, and `blocking`. + */ + removeClass(el: HTMLElement, names: string | string[], opts?: BaseOpts): void; + + /** + * Toggles CSS classes on an element. + * + * @param el - The element to toggle classes on. + * @param names - The class name(s) to toggle. + * @param [opts={}] - Optional settings. + * Accepts: `transition`, `time`, and `blocking`. + */ + toggleClass(el: HTMLElement, names: string | string[], opts?: BaseOpts): void; + + /** + * Applies a CSS transition to an element. + * + * @param el - The element to apply the transition to. + * @param transition - The transition class(es) to apply. + * Accepts a string of classes to apply when transitioning or + * a 3-tuple containing the transition class, the class to apply + * to start the transition, and the ending transition class, such as: + * + * ["ease-out duration-300", "opacity-100", "opacity-0"] + * + * @param [opts={}] - Optional settings for timing and blocking behavior. + * Accepts: `time` and `blocking`. + */ + transition(el: HTMLElement, transition: string | string[], opts?: TransitionCommandOpts): void; + + /** + * Sets an attribute on an element. + * + * @param el - The element to set the attribute on. + * @param attr - The attribute name to set. + * @param val - The value to set for the attribute. + */ + setAttribute(el: HTMLElement, attr: string, val: string): void; + + /** + * Removes an attribute from an element. + * + * @param el - The element to remove the attribute from. + * @param attr - The attribute name to remove. + */ + removeAttribute(el: HTMLElement, attr: string): void; + + /** + * Toggles an attribute on an element between two values. + * + * @param el - The element to toggle the attribute on. + * @param attr - The attribute name to toggle. + * @param val1 - The first value to toggle between. + * @param val2 - The second value to toggle between. + */ + toggleAttribute(el: HTMLElement, attr: string, val1: string, val2: string): void; + + /** + * Pushes an event to the server. + * + * @param el - An element that belongs to the target LiveView / LiveComponent or a component ID. + * To target a LiveComponent by its ID, pass a separate `target` in the options. + * @param type - The event name to push. + * @param [opts={}] - Optional settings. + * Accepts: `value`, `target`, `page_loading`. + */ + push(el: HTMLElement, type: string, opts?: PushOpts): void; + + /** + * Sends a navigation event to the server and updates the browser's pushState history. + * + * @param href - The URL to navigate to. + * @param [opts={}] - Optional settings. + * Accepts: `replace`. + */ + navigate(href: string, opts?: NavigationOpts): void; + + /** + * Sends a patch event to the server and updates the browser's pushState history. + * + * @param href - The URL to patch to. + * @param [opts={}] - Optional settings. + * Accepts: `replace`. + */ + patch(href: string, opts?: NavigationOpts): void; + + /** + * Mark attributes as ignored, skipping them when patching the DOM. + * + * @param el - The element to ignore attributes on. + * @param attrs - The attribute name or names to ignore. + */ + ignoreAttributes(el: HTMLElement, attrs: string | string[]): void; +} + +export default (liveSocket: LiveSocket, eventType: string | null): AllJSCommands => { + return { + exec(el, encodedJS){ + liveSocket.execJS(el, encodedJS, eventType) + }, + show(el, opts = {}){ + const owner = liveSocket.owner(el) + JS.show(eventType, owner, el, opts.display, JS.transitionClasses(opts.transition), opts.time, opts.blocking) + }, + hide(el, opts = {}){ + const owner = liveSocket.owner(el) + JS.hide(eventType, owner, el, null, JS.transitionClasses(opts.transition), opts.time, opts.blocking) + }, + toggle(el, opts = {}){ + const owner = liveSocket.owner(el) + const inTransition = JS.transitionClasses(opts.in) + const outTransition = JS.transitionClasses(opts.out) + JS.toggle(eventType, owner, el, opts.display, inTransition, outTransition, opts.time, opts.blocking) + }, + addClass(el, names, opts = {}){ + const classNames = Array.isArray(names) ? names : names.split(" ") + const owner = liveSocket.owner(el) + JS.addOrRemoveClasses(el, classNames, [], JS.transitionClasses(opts.transition), opts.time, owner, opts.blocking) + }, + removeClass(el, names, opts = {}){ + const classNames = Array.isArray(names) ? names : names.split(" ") + const owner = liveSocket.owner(el) + JS.addOrRemoveClasses(el, [], classNames, JS.transitionClasses(opts.transition), opts.time, owner, opts.blocking) + }, + toggleClass(el, names, opts = {}){ + const classNames = Array.isArray(names) ? names : names.split(" ") + const owner = liveSocket.owner(el) + JS.toggleClasses(el, classNames, JS.transitionClasses(opts.transition), opts.time, owner, opts.blocking) + }, + transition(el, transition, opts = {}){ + const owner = liveSocket.owner(el) + JS.addOrRemoveClasses(el, [], [], JS.transitionClasses(transition), opts.time, owner, opts.blocking) + }, + setAttribute(el, attr, val){ JS.setOrRemoveAttrs(el, [[attr, val]], []) }, + removeAttribute(el, attr){ JS.setOrRemoveAttrs(el, [], [attr]) }, + toggleAttribute(el, attr, val1, val2){ JS.toggleAttr(el, attr, val1, val2) }, + push(el, type, opts = {}){ + liveSocket.withinOwners(el, view => { + const data = opts.value || {} + delete opts.value + let e = new CustomEvent("phx:exec", {detail: {sourceElement: el}}) + JS.exec(e, eventType, type, view, el, ["push", {data, ...opts}]) + }) + }, + navigate(href, opts = {}){ + const customEvent = new CustomEvent("phx:exec") + liveSocket.historyRedirect(customEvent, href, opts.replace ? "replace" : "push", null, null) + }, + patch(href, opts = {}){ + const customEvent = new CustomEvent("phx:exec") + liveSocket.pushHistoryPatch(customEvent, href, opts.replace ? "replace" : "push", null) + }, + ignoreAttributes(el, attrs){ + JS.ignoreAttrs(el, Array.isArray(attrs) ? attrs : [attrs]) + } + } +} + +/** + * JSCommands for use with `liveSocket.js()`. + * Includes the general `exec` command that requires an element. + */ +export type LiveSocketJSCommands = AllJSCommands + +/** + * JSCommands for use within a Hook. + * The `exec` command is tailored for hooks, not requiring an explicit element. + */ +export interface HookJSCommands extends Omit { + /** + * Executes encoded JavaScript in the context of the hook's element. + * + * @param {string} encodedJS - The encoded JavaScript string to execute. + */ + exec(encodedJS: string): void; +} diff --git a/assets/js/phoenix_live_view/live_socket.js b/assets/js/phoenix_live_view/live_socket.js index 8eef67c472..4cb9ba89ac 100644 --- a/assets/js/phoenix_live_view/live_socket.js +++ b/assets/js/phoenix_live_view/live_socket.js @@ -1,79 +1,3 @@ -/** Initializes the LiveSocket - * - * - * @param {string} endPoint - The string WebSocket endpoint, ie, `"wss://example.com/live"`, - * `"/live"` (inherited host & protocol) - * @param {Phoenix.Socket} socket - the required Phoenix Socket class imported from "phoenix". For example: - * - * import {Socket} from "phoenix" - * import {LiveSocket} from "phoenix_live_view" - * let liveSocket = new LiveSocket("/live", Socket, {...}) - * - * @param {Object} [opts] - Optional configuration. Outside of keys listed below, all - * configuration is passed directly to the Phoenix Socket constructor. - * @param {Object} [opts.defaults] - The optional defaults to use for various bindings, - * such as `phx-debounce`. Supports the following keys: - * - * - debounce - the millisecond phx-debounce time. Defaults 300 - * - throttle - the millisecond phx-throttle time. Defaults 300 - * - * @param {Function} [opts.params] - The optional function for passing connect params. - * The function receives the element associated with a given LiveView. For example: - * - * (el) => {view: el.getAttribute("data-my-view-name", token: window.myToken} - * - * @param {string} [opts.bindingPrefix] - The optional prefix to use for all phx DOM annotations. - * Defaults to "phx-". - * @param {Object} [opts.hooks] - The optional object for referencing LiveView hook callbacks. - * @param {Object} [opts.uploaders] - The optional object for referencing LiveView uploader callbacks. - * @param {integer} [opts.loaderTimeout] - The optional delay in milliseconds to wait before apply - * loading states. - * @param {integer} [opts.disconnectedTimeout] - The delay in milliseconds to wait before - * executing phx-disconnected commands. Defaults to 500. - * @param {integer} [opts.maxReloads] - The maximum reloads before entering failsafe mode. - * @param {integer} [opts.reloadJitterMin] - The minimum time between normal reload attempts. - * @param {integer} [opts.reloadJitterMax] - The maximum time between normal reload attempts. - * @param {integer} [opts.failsafeJitter] - The time between reload attempts in failsafe mode. - * @param {Function} [opts.viewLogger] - The optional function to log debug information. For example: - * - * (view, kind, msg, obj) => console.log(`${view.id} ${kind}: ${msg} - `, obj) - * - * @param {Object} [opts.metadata] - The optional object mapping event names to functions for - * populating event metadata. For example: - * - * metadata: { - * click: (e, el) => { - * return { - * ctrlKey: e.ctrlKey, - * metaKey: e.metaKey, - * detail: e.detail || 1, - * } - * }, - * keydown: (e, el) => { - * return { - * key: e.key, - * ctrlKey: e.ctrlKey, - * metaKey: e.metaKey, - * shiftKey: e.shiftKey - * } - * } - * } - * @param {Object} [opts.sessionStorage] - An optional Storage compatible object - * Useful when LiveView won't have access to `sessionStorage`. For example, This could - * happen if a site loads a cross-domain LiveView in an iframe. Example usage: - * - * class InMemoryStorage { - * constructor() { this.storage = {} } - * getItem(keyName) { return this.storage[keyName] || null } - * removeItem(keyName) { delete this.storage[keyName] } - * setItem(keyName, keyValue) { this.storage[keyName] = keyValue } - * } - * - * @param {Object} [opts.localStorage] - An optional Storage compatible object - * Useful for when LiveView won't have access to `localStorage`. - * See `opts.sessionStorage` for examples. -*/ - import { BINDING_PREFIX, CONSECUTIVE_RELOADS, @@ -121,7 +45,7 @@ import View from "./view" import JS from "./js" import jsCommands from "./js_commands" -export let isUsedInput = (el) => DOM.isUsedInput(el) +export const isUsedInput = (el) => DOM.isUsedInput(el) export default class LiveSocket { constructor(url, phxSocket, opts = {}){ @@ -213,7 +137,7 @@ export default class LiveSocket { disableLatencySim(){ this.sessionStorage.removeItem(PHX_LV_LATENCY_SIM) } getLatencySim(){ - let str = this.sessionStorage.getItem(PHX_LV_LATENCY_SIM) + const str = this.sessionStorage.getItem(PHX_LV_LATENCY_SIM) return str ? parseInt(str) : null } @@ -222,7 +146,7 @@ export default class LiveSocket { connect(){ // enable debug by default if on localhost and not explicitly disabled if(window.location.hostname === "localhost" && !this.isDebugDisabled()){ this.enableDebug() } - let doConnect = () => { + const doConnect = () => { this.resetReloadStatus() if(this.joinRootViews()){ this.bindTopLevelEvents() @@ -259,10 +183,16 @@ export default class LiveSocket { } execJS(el, encodedJS, eventType = null){ - let e = new CustomEvent("phx:exec", {detail: {sourceElement: el}}) + const e = new CustomEvent("phx:exec", {detail: {sourceElement: el}}) this.owner(el, view => JS.exec(e, eventType, encodedJS, view, el)) } + /** + * Returns an object with methods to manipluate the DOM and execute JavaScript. + * The applied changes integrate with server DOM patching. + * + * @returns {import("./js_commands").LiveSocketJSCommands} + */ js(){ return jsCommands(this, "js") } @@ -282,17 +212,17 @@ export default class LiveSocket { time(name, func){ if(!this.isProfileEnabled() || !console.time){ return func() } console.time(name) - let result = func() + const result = func() console.timeEnd(name) return result } log(view, kind, msgCallback){ if(this.viewLogger){ - let [msg, obj] = msgCallback() + const [msg, obj] = msgCallback() this.viewLogger(view, kind, msg, obj) } else if(this.isDebugEnabled()){ - let [msg, obj] = msgCallback() + const [msg, obj] = msgCallback() debug(view, kind, msg, obj) } } @@ -307,7 +237,7 @@ export default class LiveSocket { onChannel(channel, event, cb){ channel.on(event, data => { - let latency = this.getLatencySim() + const latency = this.getLatencySim() if(!latency){ cb(data) } else { @@ -319,10 +249,10 @@ export default class LiveSocket { reloadWithJitter(view, log){ clearTimeout(this.reloadWithJitterTimer) this.disconnect() - let minMs = this.reloadJitterMin - let maxMs = this.reloadJitterMax + const minMs = this.reloadJitterMin + const maxMs = this.reloadJitterMax let afterMs = Math.floor(Math.random() * (maxMs - minMs + 1)) + minMs - let tries = Browser.updateLocal(this.localStorage, window.location.pathname, CONSECUTIVE_RELOADS, 0, count => count + 1) + const tries = Browser.updateLocal(this.localStorage, window.location.pathname, CONSECUTIVE_RELOADS, 0, count => count + 1) if(tries >= this.maxReloads){ afterMs = this.failsafeJitter } @@ -357,9 +287,9 @@ export default class LiveSocket { channel(topic, params){ return this.socket.channel(topic, params) } joinDeadView(){ - let body = document.body + const body = document.body if(body && !this.isPhxView(body) && !this.isPhxView(document.firstElementChild)){ - let view = this.newRootView(body) + const view = this.newRootView(body) view.setHref(this.getHref()) view.joinDead() if(!this.main){ this.main = view } @@ -375,7 +305,7 @@ export default class LiveSocket { let rootsFound = false DOM.all(document, `${PHX_VIEW_SELECTOR}:not([${PHX_PARENT_ID}])`, rootEl => { if(!this.getRootById(rootEl.id)){ - let view = this.newRootView(rootEl) + const view = this.newRootView(rootEl) // stickies cannot be mounted at the router and therefore should not // get a href set on them if(!DOM.isPhxSticky(rootEl)){ view.setHref(this.getHref()) } @@ -424,15 +354,15 @@ export default class LiveSocket { } transitionRemoves(elements, callback){ - let removeAttr = this.binding("remove") - let silenceEvents = (e) => { + const removeAttr = this.binding("remove") + const silenceEvents = (e) => { e.preventDefault() e.stopImmediatePropagation() } elements.forEach(el => { // prevent all listeners we care about from bubbling to window // since we are removing the element - for(let event of this.boundEventNames){ + for(const event of this.boundEventNames){ el.addEventListener(event, silenceEvents, true) } this.execJS(el, el.getAttribute(removeAttr), "remove") @@ -441,7 +371,7 @@ export default class LiveSocket { // and call caller's callback as soon as we are done with transitions this.requestDOMUpdate(() => { elements.forEach(el => { - for(let event of this.boundEventNames){ + for(const event of this.boundEventNames){ el.removeEventListener(event, silenceEvents, true) } }) @@ -452,13 +382,13 @@ export default class LiveSocket { isPhxView(el){ return el.getAttribute && el.getAttribute(PHX_SESSION) !== null } newRootView(el, flash, liveReferer){ - let view = new View(el, this, null, flash, liveReferer) + const view = new View(el, this, null, flash, liveReferer) this.roots[view.id] = view return view } owner(childEl, callback){ - let view = maybe(childEl.closest(PHX_VIEW_SELECTOR), el => this.getViewByEl(el)) || this.main + const view = maybe(childEl.closest(PHX_VIEW_SELECTOR), el => this.getViewByEl(el)) || this.main return view && callback ? callback(view) : view } @@ -467,14 +397,14 @@ export default class LiveSocket { } getViewByEl(el){ - let rootId = el.getAttribute(PHX_ROOT_ID) + const rootId = el.getAttribute(PHX_ROOT_ID) return maybe(this.getRootById(rootId), root => root.getDescendentByEl(el)) } getRootById(id){ return this.roots[id] } destroyAllViews(){ - for(let id in this.roots){ + for(const id in this.roots){ this.roots[id].destroy() delete this.roots[id] } @@ -482,7 +412,7 @@ export default class LiveSocket { } destroyViewByEl(el){ - let root = this.getRootById(el.getAttribute(PHX_ROOT_ID)) + const root = this.getRootById(el.getAttribute(PHX_ROOT_ID)) if(root && root.id === el.id){ root.destroy() delete this.roots[root.id] @@ -502,16 +432,19 @@ export default class LiveSocket { } restorePreviouslyActiveFocus(){ - if(this.prevActive && this.prevActive !== document.body){ + if(this.prevActive && this.prevActive !== document.body && this.prevActive instanceof HTMLElement){ this.prevActive.focus() } } blurActiveElement(){ this.prevActive = this.getActiveElement() - if(this.prevActive !== document.body){ this.prevActive.blur() } + if(this.prevActive !== document.body && this.prevActive instanceof HTMLElement){ this.prevActive.blur() } } + /** + * @param {{dead?: boolean}} [options={}] + */ bindTopLevelEvents({dead} = {}){ if(this.boundTopLevelEvents){ return } @@ -533,50 +466,50 @@ export default class LiveSocket { this.bindClicks() if(!dead){ this.bindForms() } this.bind({keyup: "keyup", keydown: "keydown"}, (e, type, view, targetEl, phxEvent, _phxTarget) => { - let matchKey = targetEl.getAttribute(this.binding(PHX_KEY)) - let pressedKey = e.key && e.key.toLowerCase() // chrome clicked autocompletes send a keydown without key + const matchKey = targetEl.getAttribute(this.binding(PHX_KEY)) + const pressedKey = e.key && e.key.toLowerCase() // chrome clicked autocompletes send a keydown without key if(matchKey && matchKey.toLowerCase() !== pressedKey){ return } - let data = {key: e.key, ...this.eventMeta(type, e, targetEl)} + const data = {key: e.key, ...this.eventMeta(type, e, targetEl)} JS.exec(e, type, phxEvent, view, targetEl, ["push", {data}]) }) this.bind({blur: "focusout", focus: "focusin"}, (e, type, view, targetEl, phxEvent, phxTarget) => { if(!phxTarget){ - let data = {key: e.key, ...this.eventMeta(type, e, targetEl)} + const data = {key: e.key, ...this.eventMeta(type, e, targetEl)} JS.exec(e, type, phxEvent, view, targetEl, ["push", {data}]) } }) this.bind({blur: "blur", focus: "focus"}, (e, type, view, targetEl, phxEvent, phxTarget) => { // blur and focus are triggered on document and window. Discard one to avoid dups if(phxTarget === "window"){ - let data = this.eventMeta(type, e, targetEl) + const data = this.eventMeta(type, e, targetEl) JS.exec(e, type, phxEvent, view, targetEl, ["push", {data}]) } }) this.on("dragover", e => e.preventDefault()) this.on("drop", e => { e.preventDefault() - let dropTargetId = maybe(closestPhxBinding(e.target, this.binding(PHX_DROP_TARGET)), trueTarget => { + const dropTargetId = maybe(closestPhxBinding(e.target, this.binding(PHX_DROP_TARGET)), trueTarget => { return trueTarget.getAttribute(this.binding(PHX_DROP_TARGET)) }) - let dropTarget = dropTargetId && document.getElementById(dropTargetId) - let files = Array.from(e.dataTransfer.files || []) - if(!dropTarget || dropTarget.disabled || files.length === 0 || !(dropTarget.files instanceof FileList)){ return } + const dropTarget = dropTargetId && document.getElementById(dropTargetId) + const files = Array.from(e.dataTransfer.files || []) + if(!dropTarget || !(dropTarget instanceof HTMLInputElement) || dropTarget.disabled || files.length === 0 || !(dropTarget.files instanceof FileList)){ return } LiveUploader.trackFiles(dropTarget, files, e.dataTransfer) dropTarget.dispatchEvent(new Event("input", {bubbles: true})) }) this.on(PHX_TRACK_UPLOADS, e => { - let uploadTarget = e.target + const uploadTarget = e.target if(!DOM.isUploadInput(uploadTarget)){ return } - let files = Array.from(e.detail.files || []).filter(f => f instanceof File || f instanceof Blob) + const files = Array.from(e.detail.files || []).filter(f => f instanceof File || f instanceof Blob) LiveUploader.trackFiles(uploadTarget, files) uploadTarget.dispatchEvent(new Event("input", {bubbles: true})) }) } eventMeta(eventName, e, targetEl){ - let callback = this.metadataCallbacks[eventName] + const callback = this.metadataCallbacks[eventName] return callback ? callback(e, targetEl) : {} } @@ -606,13 +539,13 @@ export default class LiveSocket { hasPendingLink(){ return !!this.pendingLink } bind(events, callback){ - for(let event in events){ - let browserEventName = events[event] + for(const event in events){ + const browserEventName = events[event] this.on(browserEventName, e => { - let binding = this.binding(event) - let windowBinding = this.binding(`window-${event}`) - let targetPhxEvent = e.target.getAttribute && e.target.getAttribute(binding) + const binding = this.binding(event) + const windowBinding = this.binding(`window-${event}`) + const targetPhxEvent = e.target.getAttribute && e.target.getAttribute(binding) if(targetPhxEvent){ this.debounce(e.target, e, browserEventName, () => { this.withinOwners(e.target, view => { @@ -621,7 +554,7 @@ export default class LiveSocket { }) } else { DOM.all(document, `[${windowBinding}]`, el => { - let phxEvent = el.getAttribute(windowBinding) + const phxEvent = el.getAttribute(windowBinding) this.debounce(el, e, browserEventName, () => { this.withinOwners(el, view => { callback(e, event, view, el, phxEvent, "window") @@ -639,19 +572,19 @@ export default class LiveSocket { } bindClick(eventName, bindingName){ - let click = this.binding(bindingName) + const click = this.binding(bindingName) window.addEventListener(eventName, e => { let target = null // a synthetic click event (detail 0) will not have caused a mousedown event, // therefore the clickStartedAtTarget is stale if(e.detail === 0) this.clickStartedAtTarget = e.target - let clickStartedAtTarget = this.clickStartedAtTarget || e.target + const clickStartedAtTarget = this.clickStartedAtTarget || e.target // when searching the target for the click event, we always want to // use the actual event target, see #3372 target = closestPhxBinding(e.target, click) this.dispatchClickAway(e, clickStartedAtTarget) this.clickStartedAtTarget = null - let phxEvent = target && target.getAttribute(click) + const phxEvent = target && target.getAttribute(click) if(!phxEvent){ if(DOM.isNewPageClick(e, window.location)){ this.unload() } return @@ -671,11 +604,11 @@ export default class LiveSocket { } dispatchClickAway(e, clickStartedAt){ - let phxClickAway = this.binding("click-away") + const phxClickAway = this.binding("click-away") DOM.all(document, `[${phxClickAway}]`, el => { if(!(el.isSameNode(clickStartedAt) || el.contains(clickStartedAt))){ this.withinOwners(el, view => { - let phxEvent = el.getAttribute(phxClickAway) + const phxEvent = el.getAttribute(phxClickAway) if(JS.isVisible(el) && JS.isInViewport(el)){ JS.exec(e, "click", phxEvent, view, el, ["push", {data: this.eventMeta("click", e, e.target)}]) } @@ -696,22 +629,21 @@ export default class LiveSocket { }) window.addEventListener("popstate", event => { if(!this.registerNewLocation(window.location)){ return } - let {type, backType, id, scroll, position} = event.state || {} - let href = window.location.href + const {type, backType, id, scroll, position} = event.state || {} + const href = window.location.href // Compare positions to determine direction - let isForward = position > this.currentHistoryPosition - - type = isForward ? type : (backType || type) + const isForward = position > this.currentHistoryPosition + const navType = isForward ? type : (backType || type) // Update current position this.currentHistoryPosition = position || 0 this.sessionStorage.setItem(PHX_LV_HISTORY_POSITION, this.currentHistoryPosition.toString()) - DOM.dispatchEvent(window, "phx:navigate", {detail: {href, patch: type === "patch", pop: true, direction: isForward ? "forward" : "backward"}}) + DOM.dispatchEvent(window, "phx:navigate", {detail: {href, patch: navType === "patch", pop: true, direction: isForward ? "forward" : "backward"}}) this.requestDOMUpdate(() => { const callback = () => { this.maybeScroll(scroll) } - if(this.main.isConnected() && (type === "patch" && id === this.main.id)){ + if(this.main.isConnected() && (navType === "patch" && id === this.main.id)){ this.main.pushLinkPatch(event, href, null, callback) } else { this.replaceMain(href, null, callback) @@ -719,14 +651,14 @@ export default class LiveSocket { }) }, false) window.addEventListener("click", e => { - let target = closestPhxBinding(e.target, PHX_LIVE_LINK) - let type = target && target.getAttribute(PHX_LIVE_LINK) + const target = closestPhxBinding(e.target, PHX_LIVE_LINK) + const type = target && target.getAttribute(PHX_LIVE_LINK) if(!type || !this.isConnected() || !this.main || DOM.wantsNewTab(e)){ return } // When wrapping an SVG element in an anchor tag, the href can be an SVGAnimatedString - let href = target.href instanceof SVGAnimatedString ? target.href.baseVal : target.href + const href = target.href instanceof SVGAnimatedString ? target.href.baseVal : target.href - let linkState = target.getAttribute(PHX_LINK_STATE) + const linkState = target.getAttribute(PHX_LINK_STATE) e.preventDefault() e.stopImmediatePropagation() // do not bubble click to regular phx-click bindings if(this.pendingLink === href){ return } @@ -739,7 +671,7 @@ export default class LiveSocket { } else { throw new Error(`expected ${PHX_LIVE_LINK} to be "patch" or "redirect", got: ${type}`) } - let phxClick = target.getAttribute(this.binding("click")) + const phxClick = target.getAttribute(this.binding("click")) if(phxClick){ this.requestDOMUpdate(() => this.execJS(target, phxClick, "click")) } @@ -765,7 +697,7 @@ export default class LiveSocket { withPageLoading(info, callback){ DOM.dispatchEvent(window, "phx:page-loading-start", {detail: info}) - let done = () => DOM.dispatchEvent(window, "phx:page-loading-stop", {detail: info}) + const done = () => DOM.dispatchEvent(window, "phx:page-loading-stop", {detail: info}) return callback ? callback(done) : done } @@ -807,10 +739,10 @@ export default class LiveSocket { // convert to full href if only path prefix if(/^\/$|^\/[^\/]+.*$/.test(href)){ - let {protocol, host} = window.location + const {protocol, host} = window.location href = `${protocol}//${host}${href}` } - let scroll = window.scrollY + const scroll = window.scrollY this.withPageLoading({to: href, kind: "redirect"}, done => { this.replaceMain(href, flash, (linkRef) => { if(linkRef === this.linkRef){ @@ -840,7 +772,7 @@ export default class LiveSocket { } registerNewLocation(newLocation){ - let {pathname, search} = this.currentLocation + const {pathname, search} = this.currentLocation if(pathname + search === newLocation.pathname + newLocation.search){ return false } else { @@ -855,8 +787,8 @@ export default class LiveSocket { // disable forms on submit that track phx-change but perform external submit this.on("submit", e => { - let phxSubmit = e.target.getAttribute(this.binding("submit")) - let phxChange = e.target.getAttribute(this.binding("change")) + const phxSubmit = e.target.getAttribute(this.binding("submit")) + const phxChange = e.target.getAttribute(this.binding("change")) if(!externalFormSubmitted && phxChange && !phxSubmit){ externalFormSubmitted = true e.preventDefault() @@ -872,7 +804,7 @@ export default class LiveSocket { }) this.on("submit", e => { - let phxEvent = e.target.getAttribute(this.binding("submit")) + const phxEvent = e.target.getAttribute(this.binding("submit")) if(!phxEvent){ if(DOM.isUnloadableFormSubmit(e)){ this.unload() } return @@ -884,17 +816,17 @@ export default class LiveSocket { }) }) - for(let type of ["change", "input"]){ + for(const type of ["change", "input"]){ this.on(type, e => { - if(e instanceof CustomEvent && e.target.form === undefined){ + if(e instanceof CustomEvent && (e.target instanceof HTMLInputElement || e.target instanceof HTMLSelectElement || e.target instanceof HTMLTextAreaElement) && e.target.form === undefined){ // throw on invalid JS.dispatch target and noop if CustomEvent triggered outside JS.dispatch if(e.detail && e.detail.dispatcher){ throw new Error(`dispatching a custom ${type} event is only supported on input elements inside a form`) } return } - let phxChange = this.binding("change") - let input = e.target + const phxChange = this.binding("change") + const input = e.target // do not fire phx-change if we are in the middle of a composition session // https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/isComposing // Safari has issues if the input is updated while composing @@ -911,16 +843,16 @@ export default class LiveSocket { } return } - let inputEvent = input.getAttribute(phxChange) - let formEvent = input.form && input.form.getAttribute(phxChange) - let phxEvent = inputEvent || formEvent + const inputEvent = input.getAttribute(phxChange) + const formEvent = input.form && input.form.getAttribute(phxChange) + const phxEvent = inputEvent || formEvent if(!phxEvent){ return } if(input.type === "number" && input.validity && input.validity.badInput){ return } - let dispatcher = inputEvent ? input : input.form - let currentIterations = iterations + const dispatcher = inputEvent ? input : input.form + const currentIterations = iterations iterations++ - let {at: at, type: lastType} = DOM.private(input, "prev-iteration") || {} + const {at: at, type: lastType} = DOM.private(input, "prev-iteration") || {} // Browsers should always fire at least one "input" event before every "change" // Ignore "change" events, unless there was no prior "input" event. // This could happen if user code triggers a "change" event, or if the browser is non-conforming. @@ -937,9 +869,9 @@ export default class LiveSocket { }) } this.on("reset", (e) => { - let form = e.target + const form = e.target DOM.resetForm(form) - let input = Array.from(form.elements).find(el => el.type === "reset") + const input = Array.from(form.elements).find(el => el.type === "reset") if(input){ // wait until next tick to get updated input value window.requestAnimationFrame(() => { @@ -952,13 +884,13 @@ export default class LiveSocket { debounce(el, event, eventType, callback){ if(eventType === "blur" || eventType === "focusout"){ return callback() } - let phxDebounce = this.binding(PHX_DEBOUNCE) - let phxThrottle = this.binding(PHX_THROTTLE) - let defaultDebounce = this.defaults.debounce.toString() - let defaultThrottle = this.defaults.throttle.toString() + const phxDebounce = this.binding(PHX_DEBOUNCE) + const phxThrottle = this.binding(PHX_THROTTLE) + const defaultDebounce = this.defaults.debounce.toString() + const defaultThrottle = this.defaults.throttle.toString() this.withinOwners(el, view => { - let asyncFilter = () => !view.isDestroyed() && document.body.contains(el) + const asyncFilter = () => !view.isDestroyed() && document.body.contains(el) DOM.debounce(el, event, phxDebounce, defaultDebounce, phxThrottle, defaultThrottle, asyncFilter, () => { callback() }) @@ -979,7 +911,7 @@ export default class LiveSocket { } jsQuerySelectorAll(sourceEl, query, defaultQuery){ - let all = this.domCallbacks.jsQuerySelectorAll + const all = this.domCallbacks.jsQuerySelectorAll return all ? all(sourceEl, query, defaultQuery) : defaultQuery() } } @@ -1008,7 +940,7 @@ class TransitionSet { addTransition(time, onStart, onDone){ onStart() - let timer = setTimeout(() => { + const timer = setTimeout(() => { this.transitions.delete(timer) onDone() this.flushPendingOps() @@ -1022,7 +954,7 @@ class TransitionSet { flushPendingOps(){ if(this.size() > 0){ return } - let op = this.pendingOps.shift() + const op = this.pendingOps.shift() if(op){ op() this.flushPendingOps() diff --git a/assets/js/phoenix_live_view/live_uploader.js b/assets/js/phoenix_live_view/live_uploader.js index 42c67abaa4..720f5a90ec 100644 --- a/assets/js/phoenix_live_view/live_uploader.js +++ b/assets/js/phoenix_live_view/live_uploader.js @@ -14,7 +14,7 @@ let liveUploaderFileRef = 0 export default class LiveUploader { static genFileRef(file){ - let ref = file._phxRef + const ref = file._phxRef if(ref !== undefined){ return ref } else { @@ -24,7 +24,7 @@ export default class LiveUploader { } static getEntryDataURL(inputEl, ref, callback){ - let file = this.activeFiles(inputEl).find(file => this.genFileRef(file) === ref) + const file = this.activeFiles(inputEl).find(file => this.genFileRef(file) === ref) callback(URL.createObjectURL(file)) } @@ -39,11 +39,11 @@ export default class LiveUploader { } static serializeUploads(inputEl){ - let files = this.activeFiles(inputEl) - let fileData = {} + const files = this.activeFiles(inputEl) + const fileData = {} files.forEach(file => { - let entry = {path: inputEl.name} - let uploadRef = inputEl.getAttribute(PHX_UPLOAD_REF) + const entry = {path: inputEl.name} + const uploadRef = inputEl.getAttribute(PHX_UPLOAD_REF) fileData[uploadRef] = fileData[uploadRef] || [] entry.ref = this.genFileRef(file) entry.last_modified = file.lastModified @@ -67,9 +67,14 @@ export default class LiveUploader { DOM.putPrivate(inputEl, "files", DOM.private(inputEl, "files").filter(f => !Object.is(f, file))) } + /** + * @param {HTMLInputElement} inputEl + * @param {Array} files + * @param {DataTransfer} [dataTransfer] + */ static trackFiles(inputEl, files, dataTransfer){ if(inputEl.getAttribute("multiple") !== null){ - let newFiles = files.filter(file => !this.activeFiles(inputEl).find(f => Object.is(f, file))) + const newFiles = files.filter(file => !this.activeFiles(inputEl).find(f => Object.is(f, file))) DOM.updatePrivate(inputEl, "files", [], (existing) => existing.concat(newFiles)) inputEl.value = null } else { @@ -80,7 +85,7 @@ export default class LiveUploader { } static activeFileInputs(formEl){ - let fileInputs = DOM.findUploadInputs(formEl) + const fileInputs = DOM.findUploadInputs(formEl) return Array.from(fileInputs).filter(el => el.files && this.activeFiles(el).length > 0) } @@ -89,7 +94,7 @@ export default class LiveUploader { } static inputsAwaitingPreflight(formEl){ - let fileInputs = DOM.findUploadInputs(formEl) + const fileInputs = DOM.findUploadInputs(formEl) return Array.from(fileInputs).filter(input => this.filesAwaitingPreflight(input).length > 0) } @@ -135,16 +140,16 @@ export default class LiveUploader { return entry }) - let groupedEntries = this._entries.reduce((acc, entry) => { + const groupedEntries = this._entries.reduce((acc, entry) => { if(!entry.meta){ return acc } - let {name, callback} = entry.uploader(liveSocket.uploaders) + const {name, callback} = entry.uploader(liveSocket.uploaders) acc[name] = acc[name] || {callback: callback, entries: []} acc[name].entries.push(entry) return acc }, {}) - for(let name in groupedEntries){ - let {callback, entries} = groupedEntries[name] + for(const name in groupedEntries){ + const {callback, entries} = groupedEntries[name] callback(entries, onError, resp, liveSocket) } } diff --git a/assets/js/phoenix_live_view/rendered.js b/assets/js/phoenix_live_view/rendered.js index 3af461634f..0f62bdd9e3 100644 --- a/assets/js/phoenix_live_view/rendered.js +++ b/assets/js/phoenix_live_view/rendered.js @@ -39,12 +39,12 @@ const VOID_TAGS = new Set([ ]) const quoteChars = new Set(["'", "\""]) -export let modifyRoot = (html, attrs, clearInnerHTML) => { +export const modifyRoot = (html, attrs, clearInnerHTML) => { let i = 0 let insideComment = false let beforeTag, afterTag, tag, tagNameEndsAt, id, newHTML - let lookahead = html.match(/^(\s*(?:\s*)*)<([^\s\/>]+)/) + const lookahead = html.match(/^(\s*(?:\s*)*)<([^\s\/>]+)/) if(lookahead === null){ throw new Error(`malformed html ${html}`) } i = lookahead[0].length @@ -56,11 +56,11 @@ export let modifyRoot = (html, attrs, clearInnerHTML) => { for(i; i < html.length; i++){ if(html.charAt(i) === ">" ){ break } if(html.charAt(i) === "="){ - let isId = html.slice(i - 3, i) === " id" + const isId = html.slice(i - 3, i) === " id" i++ - let char = html.charAt(i) + const char = html.charAt(i) if(quoteChars.has(char)){ - let attrStartsAt = i + const attrStartsAt = i i++ for(i; i < html.length; i++){ if(html.charAt(i) === char){ break } @@ -76,7 +76,7 @@ export let modifyRoot = (html, attrs, clearInnerHTML) => { let closeAt = html.length - 1 insideComment = false while(closeAt >= beforeTag.length + tag.length){ - let char = html.charAt(closeAt) + const char = html.charAt(closeAt) if(insideComment){ if(char === "-" && html.slice(closeAt - 3, closeAt) === " { } afterTag = html.slice(closeAt + 1, html.length) - let attrsStr = + const attrsStr = Object.keys(attrs) .map(attr => attrs[attr] === true ? attr : `${attr}="${attrs[attr]}"`) .join(" ") if(clearInnerHTML){ // Keep the id if any - let idAttrStr = id ? ` id="${id}"` : "" + const idAttrStr = id ? ` id="${id}"` : "" if(VOID_TAGS.has(tag)){ newHTML = `<${tag}${idAttrStr}${attrsStr === "" ? "" : " "}${attrsStr}/>` } else { newHTML = `<${tag}${idAttrStr}${attrsStr === "" ? "" : " "}${attrsStr}>` } } else { - let rest = html.slice(tagNameEndsAt, closeAt + 1) + const rest = html.slice(tagNameEndsAt, closeAt + 1) newHTML = `<${tag}${attrsStr === "" ? "" : " "}${attrsStr}${rest}` } @@ -118,7 +118,7 @@ export let modifyRoot = (html, attrs, clearInnerHTML) => { export default class Rendered { static extract(diff){ - let {[REPLY]: reply, [EVENTS]: events, [TITLE]: title} = diff + const {[REPLY]: reply, [EVENTS]: events, [TITLE]: title} = diff delete diff[REPLY] delete diff[EVENTS] delete diff[TITLE] @@ -135,13 +135,13 @@ export default class Rendered { parentViewId(){ return this.viewId } toString(onlyCids){ - let [str, streams] = this.recursiveToString(this.rendered, this.rendered[COMPONENTS], onlyCids, true, {}) + const [str, streams] = this.recursiveToString(this.rendered, this.rendered[COMPONENTS], onlyCids, true, {}) return [str, streams] } recursiveToString(rendered, components = rendered[COMPONENTS], onlyCids, changeTracking, rootAttrs){ onlyCids = onlyCids ? new Set(onlyCids) : null - let output = {buffer: "", components: components, onlyCids: onlyCids, streams: new Set()} + const output = {buffer: "", components: components, onlyCids: onlyCids, streams: new Set()} this.toOutputBuffer(rendered, null, output, changeTracking, rootAttrs) return [output.buffer, output.streams] } @@ -164,20 +164,20 @@ export default class Rendered { } mergeDiff(diff){ - let newc = diff[COMPONENTS] - let cache = {} + const newc = diff[COMPONENTS] + const cache = {} delete diff[COMPONENTS] this.rendered = this.mutableMerge(this.rendered, diff) this.rendered[COMPONENTS] = this.rendered[COMPONENTS] || {} if(newc){ - let oldc = this.rendered[COMPONENTS] + const oldc = this.rendered[COMPONENTS] - for(let cid in newc){ + for(const cid in newc){ newc[cid] = this.cachedFindComponent(cid, newc[cid], oldc, newc, cache) } - for(let cid in newc){ oldc[cid] = newc[cid] } + for(const cid in newc){ oldc[cid] = newc[cid] } diff[COMPONENTS] = newc } } @@ -220,10 +220,10 @@ export default class Rendered { } doMutableMerge(target, source){ - for(let key in source){ - let val = source[key] - let targetVal = target[key] - let isObjVal = isObject(val) + for(const key in source){ + const val = source[key] + const targetVal = target[key] + const isObjVal = isObject(val) if(isObjVal && val[STATIC] === undefined && isObject(targetVal)){ this.doMutableMerge(targetVal, val) } else { @@ -244,10 +244,10 @@ export default class Rendered { // (effectively forcing the new version to be rendered instead of skipped) // cloneMerge(target, source, pruneMagicId){ - let merged = {...target, ...source} - for(let key in merged){ - let val = source[key] - let targetVal = target[key] + const merged = {...target, ...source} + for(const key in merged){ + const val = source[key] + const targetVal = target[key] if(isObject(val) && val[STATIC] === undefined && isObject(targetVal)){ merged[key] = this.cloneMerge(targetVal, val, pruneMagicId) } else if(val === undefined && isObject(targetVal)){ @@ -264,8 +264,8 @@ export default class Rendered { } componentToString(cid){ - let [str, streams] = this.recursiveCIDToString(this.rendered[COMPONENTS], cid, null) - let [strippedHTML, _before, _after] = modifyRoot(str, {}) + const [str, streams] = this.recursiveCIDToString(this.rendered[COMPONENTS], cid, null) + const [strippedHTML, _before, _after] = modifyRoot(str, {}) return [strippedHTML, streams] } @@ -301,8 +301,8 @@ export default class Rendered { if(rendered[DYNAMICS]){ return this.comprehensionToBuffer(rendered, templates, output) } let {[STATIC]: statics} = rendered statics = this.templateStatic(statics, templates) - let isRoot = rendered[ROOT] - let prevBuffer = output.buffer + const isRoot = rendered[ROOT] + const prevBuffer = output.buffer if(isRoot){ output.buffer = "" } // this condition is called when first rendering an optimizable function component. @@ -336,7 +336,7 @@ export default class Rendered { attrs = rootAttrs } if(skip){ attrs[PHX_SKIP] = true } - let [newRoot, commentBefore, commentAfter] = modifyRoot(output.buffer, attrs, skip) + const [newRoot, commentBefore, commentAfter] = modifyRoot(output.buffer, attrs, skip) rendered.newRender = false output.buffer = prevBuffer + commentBefore + newRoot + commentAfter } @@ -344,18 +344,18 @@ export default class Rendered { comprehensionToBuffer(rendered, templates, output){ let {[DYNAMICS]: dynamics, [STATIC]: statics, [STREAM]: stream} = rendered - let [_ref, _inserts, deleteIds, reset] = stream || [null, {}, [], null] + const [_ref, _inserts, deleteIds, reset] = stream || [null, {}, [], null] statics = this.templateStatic(statics, templates) - let compTemplates = templates || rendered[TEMPLATES] + const compTemplates = templates || rendered[TEMPLATES] for(let d = 0; d < dynamics.length; d++){ - let dynamic = dynamics[d] + const dynamic = dynamics[d] output.buffer += statics[0] for(let i = 1; i < statics.length; i++){ // Inside a comprehension, we don't track how dynamics change // over time (and features like streams would make that impossible // unless we move the stream diffing away from morphdom), // so we can't perform root change tracking. - let changeTracking = false + const changeTracking = false this.dynamicToBuffer(dynamic[i - 1], compTemplates, output, changeTracking) output.buffer += statics[i] } @@ -370,7 +370,7 @@ export default class Rendered { dynamicToBuffer(rendered, templates, output, changeTracking){ if(typeof (rendered) === "number"){ - let [str, streams] = this.recursiveCIDToString(output.components, rendered, output.onlyCids) + const [str, streams] = this.recursiveCIDToString(output.components, rendered, output.onlyCids) output.buffer += str output.streams = new Set([...output.streams, ...streams]) } else if(isObject(rendered)){ @@ -381,9 +381,9 @@ export default class Rendered { } recursiveCIDToString(components, cid, onlyCids){ - let component = components[cid] || logError(`no component for CID ${cid}`, components) - let attrs = {[PHX_COMPONENT]: cid} - let skip = onlyCids && !onlyCids.has(cid) + const component = components[cid] || logError(`no component for CID ${cid}`, components) + const attrs = {[PHX_COMPONENT]: cid} + const skip = onlyCids && !onlyCids.has(cid) // Two optimization paths apply here: // // 1. The onlyCids optimization works by the server diff telling us only specific @@ -408,8 +408,8 @@ export default class Rendered { component.newRender = !skip component.magicId = `c${cid}-${this.parentViewId()}` // enable change tracking as long as the component hasn't been reset - let changeTracking = !component.reset - let [html, streams] = this.recursiveToString(component, components, onlyCids, changeTracking, attrs) + const changeTracking = !component.reset + const [html, streams] = this.recursiveToString(component, components, onlyCids, changeTracking, attrs) // disable reset after we've rendered delete component.reset diff --git a/assets/js/phoenix_live_view/upload_entry.js b/assets/js/phoenix_live_view/upload_entry.js index 0a6a96275b..6be5946412 100644 --- a/assets/js/phoenix_live_view/upload_entry.js +++ b/assets/js/phoenix_live_view/upload_entry.js @@ -13,15 +13,15 @@ import LiveUploader from "./live_uploader" export default class UploadEntry { static isActive(fileEl, file){ - let isNew = file._phxRef === undefined - let activeRefs = fileEl.getAttribute(PHX_ACTIVE_ENTRY_REFS).split(",") - let isActive = activeRefs.indexOf(LiveUploader.genFileRef(file)) >= 0 + const isNew = file._phxRef === undefined + const activeRefs = fileEl.getAttribute(PHX_ACTIVE_ENTRY_REFS).split(",") + const isActive = activeRefs.indexOf(LiveUploader.genFileRef(file)) >= 0 return file.size > 0 && (isNew || isActive) } static isPreflighted(fileEl, file){ - let preflightedRefs = fileEl.getAttribute(PHX_PREFLIGHTED_REFS).split(",") - let isPreflighted = preflightedRefs.indexOf(LiveUploader.genFileRef(file)) >= 0 + const preflightedRefs = fileEl.getAttribute(PHX_PREFLIGHTED_REFS).split(",") + const isPreflighted = preflightedRefs.indexOf(LiveUploader.genFileRef(file)) >= 0 return isPreflighted && this.isActive(fileEl, file) } @@ -98,7 +98,7 @@ export default class UploadEntry { } onElUpdated(){ - let activeRefs = this.fileEl.getAttribute(PHX_ACTIVE_ENTRY_REFS).split(",") + const activeRefs = this.fileEl.getAttribute(PHX_ACTIVE_ENTRY_REFS).split(",") if(activeRefs.indexOf(this.ref) === -1){ LiveUploader.untrackFile(this.fileEl, this.file) this.cancel() @@ -119,7 +119,7 @@ export default class UploadEntry { uploader(uploaders){ if(this.meta.uploader){ - let callback = uploaders[this.meta.uploader] || logError(`no uploader configured for ${this.meta.uploader}`) + const callback = uploaders[this.meta.uploader] || logError(`no uploader configured for ${this.meta.uploader}`) return {name: this.meta.uploader, callback: callback} } else { return {name: "channel", callback: channelUploader} diff --git a/assets/js/phoenix_live_view/utils.js b/assets/js/phoenix_live_view/utils.js index 221a6d8b01..3b47cefb8d 100644 --- a/assets/js/phoenix_live_view/utils.js +++ b/assets/js/phoenix_live_view/utils.js @@ -4,16 +4,16 @@ import { import EntryUploader from "./entry_uploader" -export let logError = (msg, obj) => console.error && console.error(msg, obj) +export const logError = (msg, obj) => console.error && console.error(msg, obj) -export let isCid = (cid) => { - let type = typeof(cid) +export const isCid = (cid) => { + const type = typeof(cid) return type === "number" || (type === "string" && /^(0|[1-9]\d*)$/.test(cid)) } export function detectDuplicateIds(){ - let ids = new Set() - let elems = document.querySelectorAll("*[id]") + const ids = new Set() + const elems = document.querySelectorAll("*[id]") for(let i = 0, len = elems.length; i < len; i++){ if(ids.has(elems[i].id)){ console.error(`Multiple IDs detected: ${elems[i].id}. Ensure unique element ids.`) @@ -34,18 +34,18 @@ export function detectInvalidStreamInserts(inserts){ errors.forEach(error => console.error(error)) } -export let debug = (view, kind, msg, obj) => { +export const debug = (view, kind, msg, obj) => { if(view.liveSocket.isDebugEnabled()){ console.log(`${view.id} ${kind}: ${msg} - `, obj) } } // wraps value in closure or returns closure -export let closure = (val) => typeof val === "function" ? val : function (){ return val } +export const closure = (val) => typeof val === "function" ? val : function (){ return val } -export let clone = (obj) => { return JSON.parse(JSON.stringify(obj)) } +export const clone = (obj) => { return JSON.parse(JSON.stringify(obj)) } -export let closestPhxBinding = (el, binding, borderEl) => { +export const closestPhxBinding = (el, binding, borderEl) => { do { if(el.matches(`[${binding}]`) && !el.disabled){ return el } el = el.parentElement || el.parentNode @@ -53,22 +53,22 @@ export let closestPhxBinding = (el, binding, borderEl) => { return null } -export let isObject = (obj) => { +export const isObject = (obj) => { return obj !== null && typeof obj === "object" && !(obj instanceof Array) } -export let isEqualObj = (obj1, obj2) => JSON.stringify(obj1) === JSON.stringify(obj2) +export const isEqualObj = (obj1, obj2) => JSON.stringify(obj1) === JSON.stringify(obj2) -export let isEmpty = (obj) => { - for(let x in obj){ return false } +export const isEmpty = (obj) => { + for(const x in obj){ return false } return true } -export let maybe = (el, callback) => el && callback(el) +export const maybe = (el, callback) => el && callback(el) -export let channelUploader = function (entries, onError, resp, liveSocket){ +export const channelUploader = function (entries, onError, resp, liveSocket){ entries.forEach(entry => { - let entryUploader = new EntryUploader(entry, resp.config, liveSocket) + const entryUploader = new EntryUploader(entry, resp.config, liveSocket) entryUploader.upload() }) } diff --git a/assets/js/phoenix_live_view/view.js b/assets/js/phoenix_live_view/view.js index a6c247f9e9..b1fc24d7b6 100644 --- a/assets/js/phoenix_live_view/view.js +++ b/assets/js/phoenix_live_view/view.js @@ -54,11 +54,11 @@ import ElementRef from "./element_ref" import DOMPatch from "./dom_patch" import LiveUploader from "./live_uploader" import Rendered from "./rendered" -import ViewHook from "./view_hook" +import {ViewHook} from "./view_hook" import JS from "./js" -export let prependFormDataKey = (key, prefix) => { - let isArray = key.endsWith("[]") +export const prependFormDataKey = (key, prefix) => { + const isArray = key.endsWith("[]") // Remove the "[]" if it's an array let baseKey = isArray ? key.slice(0, -2) : key // Replace last occurrence of key before a closing bracket or the end with key plus suffix @@ -68,7 +68,7 @@ export let prependFormDataKey = (key, prefix) => { return baseKey } -let serializeForm = (form, opts, onlyNames = []) => { +const serializeForm = (form, opts, onlyNames = []) => { const {submitter} = opts // We must inject the submitter in the order that it exists in the DOM @@ -117,14 +117,16 @@ let serializeForm = (form, opts, onlyNames = []) => { return acc }, {inputsUnused: {}, onlyHiddenInputs: {}}) - for(let [key, val] of formData.entries()){ + for(const [key, val] of formData.entries()){ if(onlyNames.length === 0 || onlyNames.indexOf(key) >= 0){ - let isUnused = inputsUnused[key] - let hidden = onlyHiddenInputs[key] + const isUnused = inputsUnused[key] + const hidden = onlyHiddenInputs[key] if(isUnused && !(submitter && submitter.name == key) && !hidden){ params.append(prependFormDataKey(key, "_unused_"), "") } - params.append(key, val) + if(typeof val === "string"){ + params.append(key, val) + } } } @@ -139,7 +141,7 @@ let serializeForm = (form, opts, onlyNames = []) => { export default class View { static closestView(el){ - let liveViewEl = el.closest(PHX_VIEW_SELECTOR) + const liveViewEl = el.closest(PHX_VIEW_SELECTOR) return liveViewEl ? DOM.private(liveViewEl, "view") : null } @@ -174,7 +176,7 @@ export default class View { this.root.children[this.id] = {} this.formsForRecovery = {} this.channel = this.liveSocket.channel(`lv:${this.id}`, () => { - let url = this.href && this.expandURL(this.href) + const url = this.href && this.expandURL(this.href) return { redirect: this.redirect ? url : undefined, url: this.redirect ? undefined : url || undefined, @@ -197,8 +199,8 @@ export default class View { isMain(){ return this.el.hasAttribute(PHX_MAIN) } connectParams(liveReferer){ - let params = this.liveSocket.params(this.el) - let manifest = + const params = this.liveSocket.params(this.el) + const manifest = DOM.all(document, `[${this.binding(PHX_TRACK_STATIC)}]`) .map(node => node.src || node.href).filter(url => typeof (url) === "string") @@ -216,7 +218,7 @@ export default class View { getSession(){ return this.el.getAttribute(PHX_SESSION) } getStatic(){ - let val = this.el.getAttribute(PHX_STATIC) + const val = this.el.getAttribute(PHX_STATIC) return val === "" ? null : val } @@ -226,9 +228,9 @@ export default class View { delete this.root.children[this.id] if(this.parent){ delete this.root.children[this.parent.id][this.id] } clearTimeout(this.loaderTimer) - let onFinished = () => { + const onFinished = () => { callback() - for(let id in this.viewHooks){ + for(const id in this.viewHooks){ this.destroyHook(this.viewHooks[id]) } } @@ -258,7 +260,7 @@ export default class View { if(timeout){ this.loaderTimer = setTimeout(() => this.showLoader(), timeout) } else { - for(let id in this.viewHooks){ this.viewHooks[id].__disconnected() } + for(const id in this.viewHooks){ this.viewHooks[id].__disconnected() } this.setContainerClasses(PHX_LOADING_CLASS) } } @@ -275,7 +277,7 @@ export default class View { } triggerReconnected(){ - for(let id in this.viewHooks){ this.viewHooks[id].__reconnected() } + for(const id in this.viewHooks){ this.viewHooks[id].__reconnected() } } log(kind, msgCallback){ @@ -301,14 +303,14 @@ export default class View { } if(isCid(phxTarget)){ - let targets = DOM.findComponentNodeList(viewEl || this.el, phxTarget) + const targets = DOM.findComponentNodeList(viewEl || this.el, phxTarget) if(targets.length === 0){ logError(`no component found matching phx-target of ${phxTarget}`) } else { callback(this, parseInt(phxTarget)) } } else { - let targets = Array.from(dom.querySelectorAll(phxTarget)) + const targets = Array.from(dom.querySelectorAll(phxTarget)) if(targets.length === 0){ logError(`nothing found matching the phx-target selector "${phxTarget}"`) } targets.forEach(target => this.liveSocket.owner(target, view => callback(view, target))) } @@ -316,15 +318,15 @@ export default class View { applyDiff(type, rawDiff, callback){ this.log(type, () => ["", clone(rawDiff)]) - let {diff, reply, events, title} = Rendered.extract(rawDiff) + const {diff, reply, events, title} = Rendered.extract(rawDiff) callback({diff, reply, events}) if(typeof title === "string" || type == "mount"){ window.requestAnimationFrame(() => DOM.putTitle(title)) } } onJoin(resp){ - let {rendered, container, liveview_version} = resp + const {rendered, container, liveview_version} = resp if(container){ - let [tag, attrs] = container + const [tag, attrs] = container this.el = DOM.replaceRootContainer(this.el, tag, attrs) } this.childJoins = 0 @@ -349,7 +351,7 @@ export default class View { Browser.dropLocal(this.liveSocket.localStorage, window.location.pathname, CONSECUTIVE_RELOADS) this.applyDiff("mount", rendered, ({diff, events}) => { this.rendered = new Rendered(this.id, diff) - let [html, streams] = this.renderContainer(null, "join") + const [html, streams] = this.renderContainer(null, "join") this.dropPendingRefs() this.joinCount++ this.joinAttempts = 0 @@ -379,9 +381,9 @@ export default class View { // in the html fragment, instead of directly on the DOM. The fragment // also does not include PHX_STATIC, so we need to copy it over from // the DOM. - let newChildren = DOM.findPhxChildrenInFragment(html, this.id).filter(toEl => { - let fromEl = toEl.id && this.el.querySelector(`[id="${toEl.id}"]`) - let phxStatic = fromEl && fromEl.getAttribute(PHX_STATIC) + const newChildren = DOM.findPhxChildrenInFragment(html, this.id).filter(toEl => { + const fromEl = toEl.id && this.el.querySelector(`[id="${toEl.id}"]`) + const phxStatic = fromEl && fromEl.getAttribute(PHX_STATIC) if(phxStatic){ toEl.setAttribute(PHX_STATIC, phxStatic) } // set PHX_ROOT_ID to prevent events from being dispatched to the root view // while the child join is still pending @@ -412,8 +414,8 @@ export default class View { // and connected states. This also handles cases where hooks exist // in a root layout with a LV in the body execNewMounted(parent = this.el){ - let phxViewportTop = this.binding(PHX_VIEWPORT_TOP) - let phxViewportBottom = this.binding(PHX_VIEWPORT_BOTTOM) + const phxViewportTop = this.binding(PHX_VIEWPORT_TOP) + const phxViewportBottom = this.binding(PHX_VIEWPORT_BOTTOM) DOM.all(parent, `[${phxViewportTop}], [${phxViewportBottom}]`, hookEl => { if(this.ownsElement(hookEl)){ DOM.maintainPrivateHooks(hookEl, hookEl, phxViewportTop, phxViewportBottom) @@ -434,7 +436,7 @@ export default class View { applyJoinPatch(live_patch, html, streams, events){ this.attachTrueDocEl() - let patch = new DOMPatch(this, this.el, this.id, html, streams, null) + const patch = new DOMPatch(this, this.el, this.id, html, streams, null) patch.markPrunableContentForRemoval() this.performPatch(patch, false, true) this.joinNewChildren() @@ -445,7 +447,7 @@ export default class View { this.applyPendingUpdates() if(live_patch){ - let {kind, to} = live_patch + const {kind, to} = live_patch this.liveSocket.historyPatch(to, kind) } this.hideLoader() @@ -455,8 +457,8 @@ export default class View { triggerBeforeUpdateHook(fromEl, toEl){ this.liveSocket.triggerDOM("onBeforeElUpdated", [fromEl, toEl]) - let hook = this.getHook(fromEl) - let isIgnored = hook && DOM.isIgnored(fromEl, this.binding(PHX_UPDATE)) + const hook = this.getHook(fromEl) + const isIgnored = hook && DOM.isIgnored(fromEl, this.binding(PHX_UPDATE)) if(hook && !fromEl.isEqualNode(toEl) && !(isIgnored && isEqualObj(fromEl.dataset, toEl.dataset))){ hook.__beforeUpdate() return hook @@ -464,8 +466,8 @@ export default class View { } maybeMounted(el){ - let phxMounted = el.getAttribute(this.binding(PHX_MOUNTED)) - let hasBeenInvoked = phxMounted && DOM.private(el, "mounted") + const phxMounted = el.getAttribute(this.binding(PHX_MOUNTED)) + const hasBeenInvoked = phxMounted && DOM.private(el, "mounted") if(phxMounted && !hasBeenInvoked){ this.liveSocket.execJS(el, phxMounted) DOM.putPrivate(el, "mounted", true) @@ -473,21 +475,21 @@ export default class View { } maybeAddNewHook(el){ - let newHook = this.addHook(el) + const newHook = this.addHook(el) if(newHook){ newHook.__mounted() } } performPatch(patch, pruneCids, isJoinPatch = false){ - let removedEls = [] + const removedEls = [] let phxChildrenAdded = false - let updatedHookIds = new Set() + const updatedHookIds = new Set() this.liveSocket.triggerDOM("onPatchStart", [patch.targetContainer]) patch.after("added", el => { this.liveSocket.triggerDOM("onNodeAdded", [el]) - let phxViewportTop = this.binding(PHX_VIEWPORT_TOP) - let phxViewportBottom = this.binding(PHX_VIEWPORT_BOTTOM) + const phxViewportTop = this.binding(PHX_VIEWPORT_TOP) + const phxViewportBottom = this.binding(PHX_VIEWPORT_BOTTOM) DOM.maintainPrivateHooks(el, el, phxViewportTop, phxViewportBottom) this.maybeAddNewHook(el) if(el.getAttribute){ this.maybeMounted(el) } @@ -502,7 +504,7 @@ export default class View { }) patch.before("updated", (fromEl, toEl) => { - let hook = this.triggerBeforeUpdateHook(fromEl, toEl) + const hook = this.triggerBeforeUpdateHook(fromEl, toEl) if(hook){ updatedHookIds.add(fromEl.id) } // trigger JS specific update logic (for example for JS.ignore_attributes) JS.onBeforeElUpdated(fromEl, toEl) @@ -525,16 +527,16 @@ export default class View { } afterElementsRemoved(elements, pruneCids){ - let destroyedCIDs = [] + const destroyedCIDs = [] elements.forEach(parent => { - let components = DOM.all(parent, `[${PHX_COMPONENT}]`) - let hooks = DOM.all(parent, `[${this.binding(PHX_HOOK)}], [data-phx-hook]`) + const components = DOM.all(parent, `[${PHX_COMPONENT}]`) + const hooks = DOM.all(parent, `[${this.binding(PHX_HOOK)}], [data-phx-hook]`) components.concat(parent).forEach(el => { - let cid = this.componentID(el) + const cid = this.componentID(el) if(isCid(cid) && destroyedCIDs.indexOf(cid) === -1){ destroyedCIDs.push(cid) } }) hooks.concat(parent).forEach(hookEl => { - let hook = this.getHook(hookEl) + const hook = this.getHook(hookEl) hook && this.destroyHook(hook) }) }) @@ -560,7 +562,7 @@ export default class View { // until it is restored. Therefore LV decided to do form recovery with the // raw HTML before it is applied and delay the mount patch until the form // recovery events are done. - let template = document.createElement("template") + const template = document.createElement("template") template.innerHTML = html // because we work with a template element, we must manually copy the attributes // otherwise the owner / target helpers don't work properly @@ -619,17 +621,17 @@ export default class View { } destroyDescendent(id){ - for(let parentId in this.root.children){ - for(let childId in this.root.children[parentId]){ + for(const parentId in this.root.children){ + for(const childId in this.root.children[parentId]){ if(childId === id){ return this.root.children[parentId][childId].destroy() } } } } joinChild(el){ - let child = this.getChildById(el.id) + const child = this.getChildById(el.id) if(!child){ - let view = new View(el, this.liveSocket, this) + const view = new View(el, this.liveSocket, this) this.root.children[this.id][view.id] = view view.join() this.childJoins++ @@ -678,15 +680,15 @@ export default class View { // Otherwise, patch entire LV container. if(this.rendered.isComponentOnlyDiff(diff)){ this.liveSocket.time("component patch complete", () => { - let parentCids = DOM.findExistingParentCIDs(this.el, this.rendered.componentCIDs(diff)) + const parentCids = DOM.findExistingParentCIDs(this.el, this.rendered.componentCIDs(diff)) parentCids.forEach(parentCID => { if(this.componentPatch(this.rendered.getComponent(diff, parentCID), parentCID)){ phxChildrenAdded = true } }) }) } else if(!isEmpty(diff)){ this.liveSocket.time("full patch complete", () => { - let [html, streams] = this.renderContainer(diff, "update") - let patch = new DOMPatch(this, this.el, this.id, html, streams, null) + const [html, streams] = this.renderContainer(diff, "update") + const patch = new DOMPatch(this, this.el, this.id, html, streams, null) phxChildrenAdded = this.performPatch(patch, true) }) } @@ -697,34 +699,34 @@ export default class View { renderContainer(diff, kind){ return this.liveSocket.time(`toString diff (${kind})`, () => { - let tag = this.el.tagName + const tag = this.el.tagName // Don't skip any component in the diff nor any marked as pruned // (as they may have been added back) - let cids = diff ? this.rendered.componentCIDs(diff) : null - let [html, streams] = this.rendered.toString(cids) + const cids = diff ? this.rendered.componentCIDs(diff) : null + const [html, streams] = this.rendered.toString(cids) return [`<${tag}>${html}`, streams] }) } componentPatch(diff, cid){ if(isEmpty(diff)) return false - let [html, streams] = this.rendered.componentToString(cid) - let patch = new DOMPatch(this, this.el, this.id, html, streams, cid) - let childrenAdded = this.performPatch(patch, true) + const [html, streams] = this.rendered.componentToString(cid) + const patch = new DOMPatch(this, this.el, this.id, html, streams, cid) + const childrenAdded = this.performPatch(patch, true) return childrenAdded } getHook(el){ return this.viewHooks[ViewHook.elementID(el)] } addHook(el){ - let hookElId = ViewHook.elementID(el) + const hookElId = ViewHook.elementID(el) // only ever try to add hooks to elements owned by this view if(el.getAttribute && !this.ownsElement(el)){ return } if(hookElId && !this.viewHooks[hookElId]){ // hook created, but not attached (createHook for web component) - let hook = DOM.getCustomElHook(el) || logError(`no hook found for custom element: ${el.id}`) + const hook = DOM.getCustomElHook(el) || logError(`no hook found for custom element: ${el.id}`) this.viewHooks[hookElId] = hook hook.__attachView(this) return hook @@ -734,14 +736,32 @@ export default class View { return } else { // new hook found with phx-hook attribute - let hookName = el.getAttribute(`data-phx-${PHX_HOOK}`) || el.getAttribute(this.binding(PHX_HOOK)) - let callbacks = this.liveSocket.getHookCallbacks(hookName) - - if(callbacks){ - if(!el.id){ logError(`no DOM ID for hook "${hookName}". Hooks require a unique ID on each element.`, el) } - let hook = new ViewHook(this, el, callbacks) - this.viewHooks[ViewHook.elementID(hook.el)] = hook - return hook + const hookName = el.getAttribute(`data-phx-${PHX_HOOK}`) || el.getAttribute(this.binding(PHX_HOOK)) + const hookDefinition = this.liveSocket.getHookCallbacks(hookName) + + if(hookDefinition){ + if(!el.id){ logError(`no DOM ID for hook "${hookName}". Hooks require a unique ID on each element.`, el); return } + + let hookInstance + try { + if(typeof hookDefinition === "function" && hookDefinition.prototype instanceof ViewHook){ + // It's a class constructor (subclass of ViewHook) + hookInstance = new hookDefinition(this, el) // `this` is the View instance + } else if(typeof hookDefinition === "object" && hookDefinition !== null){ + // It's an object literal, pass it to the ViewHook constructor for wrapping + hookInstance = new ViewHook(this, el, hookDefinition) + } else { + logError(`Invalid hook definition for "${hookName}". Expected a class extending ViewHook or an object definition.`, el) + return + } + } catch (e){ + const errorMessage = e instanceof Error ? e.message : String(e) + logError(`Failed to create hook "${hookName}": ${errorMessage}`, el) + return + } + + this.viewHooks[ViewHook.elementID(hookInstance.el)] = hookInstance + return hookInstance } else if(hookName !== null){ logError(`unknown hook found for "${hookName}"`, el) } @@ -770,8 +790,8 @@ export default class View { } eachChild(callback){ - let children = this.root.children[this.id] || {} - for(let id in children){ callback(this.getChildById(id)) } + const children = this.root.children[this.id] || {} + for(const id in children){ callback(this.getChildById(id)) } } onChannel(event, cb){ @@ -802,14 +822,14 @@ export default class View { destroyAllChildren(){ this.eachChild(child => child.destroy()) } onLiveRedirect(redir){ - let {to, kind, flash} = redir - let url = this.expandURL(to) - let e = new CustomEvent("phx:server-navigate", {detail: {to, kind, flash}}) + const {to, kind, flash} = redir + const url = this.expandURL(to) + const e = new CustomEvent("phx:server-navigate", {detail: {to, kind, flash}}) this.liveSocket.historyRedirect(e, url, kind, flash) } onLivePatch(redir){ - let {to, kind} = redir + const {to, kind} = redir this.href = this.expandURL(to) this.liveSocket.historyPatch(to, kind) } @@ -818,6 +838,9 @@ export default class View { return to.startsWith("/") ? `${window.location.protocol}//${window.location.host}${to}` : to } + /** + * @param {{to: string, flash?: string, reloadToken?: string}} redirect + */ onRedirect({to, flash, reloadToken}){ this.liveSocket.redirect(to, flash, reloadToken) } isDestroyed(){ return this.destroyed } @@ -874,7 +897,7 @@ export default class View { this.log("error", () => [`giving up trying to mount after ${MAX_CHILD_JOIN_ATTEMPTS} tries`, resp]) this.destroy() } - let trueChildEl = DOM.byId(this.el.id) + const trueChildEl = DOM.byId(this.el.id) if(trueChildEl){ DOM.mergeAttrs(trueChildEl, this.el) this.displayError([PHX_LOADING_CLASS, PHX_ERROR_CLASS, PHX_SERVER_ERROR_CLASS]) @@ -923,8 +946,8 @@ export default class View { } wrapPush(callerPush, receives){ - let latency = this.liveSocket.getLatencySim() - let withLatency = latency ? + const latency = this.liveSocket.getLatencySim() + const withLatency = latency ? (cb) => setTimeout(() => !this.isDestroyed() && cb(), latency) : (cb) => !this.isDestroyed() && cb() @@ -939,8 +962,8 @@ export default class View { pushWithReply(refGenerator, event, payload){ if(!this.isConnected()){ return Promise.reject(new Error("no connection")) } - let [ref, [el], opts] = refGenerator ? refGenerator() : [null, [], {}] - let oldJoinCount = this.joinCount + const [ref, [el], opts] = refGenerator ? refGenerator() : [null, [], {}] + const oldJoinCount = this.joinCount let onLoadingDone = function(){} if(opts.page_loading){ onLoadingDone = this.liveSocket.withPageLoading({kind: "element", target: el}) @@ -952,7 +975,7 @@ export default class View { this.wrapPush(() => this.channel.push(event, payload, PUSH_TIMEOUT), { ok: (resp) => { if(ref !== null){ this.lastAckRef = ref } - let finish = (hookReply) => { + const finish = (hookReply) => { if(resp.redirect){ this.onRedirect(resp.redirect) } if(resp.live_patch){ this.onLivePatch(resp.live_patch) } if(resp.live_redirect){ this.onLiveRedirect(resp.live_redirect) } @@ -989,7 +1012,7 @@ export default class View { undoRefs(ref, phxEvent, onlyEls){ if(!this.isConnected()){ return } // exit if external form triggered - let selector = `[${PHX_REF_SRC}="${this.refSrc()}"]` + const selector = `[${PHX_REF_SRC}="${this.refSrc()}"]` if(onlyEls){ onlyEls = new Set(onlyEls) @@ -1005,12 +1028,12 @@ export default class View { } undoElRef(el, ref, phxEvent){ - let elRef = new ElementRef(el) + const elRef = new ElementRef(el) elRef.maybeUndo(ref, phxEvent, clonedTree => { // we need to perform a full patch on unlocked elements // to perform all the necessary logic (like calling updated for hooks, etc.) - let patch = new DOMPatch(this, el, this.id, clonedTree, [], null, {undoRef: ref}) + const patch = new DOMPatch(this, el, this.id, clonedTree, [], null, {undoRef: ref}) const phxChildrenAdded = this.performPatch(patch, true) DOM.all(el, `[${PHX_REF_SRC}="${this.refSrc()}"]`, child => this.undoElRef(child, ref, phxEvent)) if(phxChildrenAdded){ this.joinNewChildren() } @@ -1020,16 +1043,16 @@ export default class View { refSrc(){ return this.el.id } putRef(elements, phxEvent, eventType, opts = {}){ - let newRef = this.ref++ - let disableWith = this.binding(PHX_DISABLE_WITH) + const newRef = this.ref++ + const disableWith = this.binding(PHX_DISABLE_WITH) if(opts.loading){ - let loadingEls = DOM.all(document, opts.loading).map(el => { + const loadingEls = DOM.all(document, opts.loading).map(el => { return {el, lock: true, loading: true} }) elements = elements.concat(loadingEls) } - for(let {el, lock, loading} of elements){ + for(const {el, lock, loading} of elements){ if(!lock && !loading){ throw new Error("putRef requires lock or loading") } el.setAttribute(PHX_REF_SRC, this.refSrc()) if(loading){ el.setAttribute(PHX_REF_LOADING, newRef) } @@ -1037,16 +1060,16 @@ export default class View { if(!loading || (opts.submitter && !(el === opts.submitter || el === opts.form))){ continue } - let lockCompletePromise = new Promise(resolve => { + const lockCompletePromise = new Promise(resolve => { el.addEventListener(`phx:undo-lock:${newRef}`, () => resolve(detail), {once: true}) }) - let loadingCompletePromise = new Promise(resolve => { + const loadingCompletePromise = new Promise(resolve => { el.addEventListener(`phx:undo-loading:${newRef}`, () => resolve(detail), {once: true}) }) el.classList.add(`phx-${eventType}-loading`) - let disableText = el.getAttribute(disableWith) + const disableText = el.getAttribute(disableWith) if(disableText !== null){ if(!el.getAttribute(PHX_DISABLE_WITH_RESTORE)){ el.setAttribute(PHX_DISABLE_WITH_RESTORE, el.innerText) @@ -1057,7 +1080,7 @@ export default class View { el.setAttribute("disabled", "") } - let detail = { + const detail = { event: phxEvent, eventType: eventType, ref: newRef, @@ -1099,14 +1122,14 @@ export default class View { isAcked(ref){ return this.lastAckRef !== null && this.lastAckRef >= ref } componentID(el){ - let cid = el.getAttribute && el.getAttribute(PHX_COMPONENT) + const cid = el.getAttribute && el.getAttribute(PHX_COMPONENT) return cid ? parseInt(cid) : null } targetComponentID(target, targetCtx, opts = {}){ if(isCid(targetCtx)){ return targetCtx } - let cidOrSelector = opts.target || target.getAttribute(this.binding("target")) + const cidOrSelector = opts.target || target.getAttribute(this.binding("target")) if(isCid(cidOrSelector)){ return parseInt(cidOrSelector) } else if(targetCtx && (cidOrSelector !== null || opts.target)){ @@ -1126,27 +1149,26 @@ export default class View { } } - pushHookEvent(el, targetCtx, event, payload, onReply){ + pushHookEvent(el, targetCtx, event, payload){ if(!this.isConnected()){ this.log("hook", () => ["unable to push hook event. LiveView not connected", event, payload]) - return false + return Promise.reject(new Error("unable to push hook event. LiveView not connected")) } let [ref, els, opts] = this.putRef([{el, loading: true, lock: true}], event, "hook") - this.pushWithReply(() => [ref, els, opts], "event", { + + return this.pushWithReply(() => [ref, els, opts], "event", { type: "hook", event: event, value: payload, cid: this.closestComponentID(targetCtx) - }).then(({resp: _resp, reply: hookReply}) => onReply(hookReply, ref)) - - return ref + }).then(({resp: _resp, reply}) => ({reply, ref})) } extractMeta(el, meta, value){ - let prefix = this.binding("value-") + const prefix = this.binding("value-") for(let i = 0; i < el.attributes.length; i++){ if(!meta){ meta = {} } - let name = el.attributes[i].name + const name = el.attributes[i].name if(name.startsWith(prefix)){ meta[name.replace(prefix, "")] = el.getAttribute(name) } } if(el.value !== undefined && !(el instanceof HTMLFormElement)){ @@ -1159,7 +1181,7 @@ export default class View { } if(value){ if(!meta){ meta = {} } - for(let key in value){ meta[key] = value[key] } + for(const key in value){ meta[key] = value[key] } } return meta } @@ -1184,7 +1206,7 @@ export default class View { progress: progress, cid: view.targetComponentID(fileEl.form, targetCtx) }) - .then(({resp}) => onReply(resp)) + .then(() => onReply()) .catch((error) => logError("Failed to push file progress", error)) }) } @@ -1195,16 +1217,16 @@ export default class View { } let uploads - let cid = isCid(forceCid) ? forceCid : this.targetComponentID(inputEl.form, targetCtx, opts) - let refGenerator = () => { + const cid = isCid(forceCid) ? forceCid : this.targetComponentID(inputEl.form, targetCtx, opts) + const refGenerator = () => { return this.putRef([ {el: inputEl, loading: true, lock: true}, {el: inputEl.form, loading: true, lock: true} ], phxEvent, "change", opts) } let formData - let meta = this.extractMeta(inputEl.form, {}, opts.value) - let serializeOpts = {} + const meta = this.extractMeta(inputEl.form, {}, opts.value) + const serializeOpts = {} if(inputEl instanceof HTMLButtonElement){ serializeOpts.submitter = inputEl } if(inputEl.getAttribute(this.binding("change"))){ formData = serializeForm(inputEl.form, serializeOpts, [inputEl.name]) @@ -1216,7 +1238,7 @@ export default class View { } uploads = LiveUploader.serializeUploads(inputEl) - let event = { + const event = { type: "form", event: phxEvent, value: formData, @@ -1238,7 +1260,7 @@ export default class View { // necessary data attributes are set in the real DOM ElementRef.onUnlock(inputEl, () => { if(LiveUploader.filesAwaitingPreflight(inputEl).length > 0){ - let [ref, _els] = refGenerator() + const [ref, _els] = refGenerator() this.undoRefs(ref, phxEvent, [inputEl.form]) this.uploadFiles(inputEl.form, phxEvent, targetCtx, ref, cid, (_uploads) => { callback && callback(resp) @@ -1254,9 +1276,9 @@ export default class View { } triggerAwaitingSubmit(formEl, phxEvent){ - let awaitingSubmit = this.getScheduledSubmit(formEl) + const awaitingSubmit = this.getScheduledSubmit(formEl) if(awaitingSubmit){ - let [_el, _ref, _opts, callback] = awaitingSubmit + const [_el, _ref, _opts, callback] = awaitingSubmit this.cancelSubmit(formEl, phxEvent) callback() } @@ -1283,21 +1305,21 @@ export default class View { } disableForm(formEl, phxEvent, opts = {}){ - let filterIgnored = el => { - let userIgnored = closestPhxBinding(el, `${this.binding(PHX_UPDATE)}=ignore`, el.form) + const filterIgnored = el => { + const userIgnored = closestPhxBinding(el, `${this.binding(PHX_UPDATE)}=ignore`, el.form) return !(userIgnored || closestPhxBinding(el, "data-phx-update=ignore", el.form)) } - let filterDisables = el => { + const filterDisables = el => { return el.hasAttribute(this.binding(PHX_DISABLE_WITH)) } - let filterButton = el => el.tagName == "BUTTON" + const filterButton = el => el.tagName == "BUTTON" - let filterInput = el => ["INPUT", "TEXTAREA", "SELECT"].includes(el.tagName) + const filterInput = el => ["INPUT", "TEXTAREA", "SELECT"].includes(el.tagName) - let formElements = Array.from(formEl.elements) - let disables = formElements.filter(filterDisables) - let buttons = formElements.filter(filterButton).filter(filterIgnored) - let inputs = formElements.filter(filterInput).filter(filterIgnored) + const formElements = Array.from(formEl.elements) + const disables = formElements.filter(filterDisables) + const buttons = formElements.filter(filterButton).filter(filterIgnored) + const inputs = formElements.filter(filterInput).filter(filterIgnored) buttons.forEach(button => { button.setAttribute(PHX_DISABLED, button.disabled) @@ -1311,38 +1333,38 @@ export default class View { input.disabled = true } }) - let formEls = disables.concat(buttons).concat(inputs).map(el => { + const formEls = disables.concat(buttons).concat(inputs).map(el => { return {el, loading: true, lock: true} }) // we reverse the order so form children are already locked by the time // the form is locked - let els = [{el: formEl, loading: true, lock: false}].concat(formEls).reverse() + const els = [{el: formEl, loading: true, lock: false}].concat(formEls).reverse() return this.putRef(els, phxEvent, "submit", opts) } pushFormSubmit(formEl, targetCtx, phxEvent, submitter, opts, onReply){ - let refGenerator = () => this.disableForm(formEl, phxEvent, { + const refGenerator = () => this.disableForm(formEl, phxEvent, { ...opts, form: formEl, submitter: submitter }) - let cid = this.targetComponentID(formEl, targetCtx) + const cid = this.targetComponentID(formEl, targetCtx) if(LiveUploader.hasUploadsInProgress(formEl)){ - let [ref, _els] = refGenerator() - let push = () => this.pushFormSubmit(formEl, targetCtx, phxEvent, submitter, opts, onReply) + const [ref, _els] = refGenerator() + const push = () => this.pushFormSubmit(formEl, targetCtx, phxEvent, submitter, opts, onReply) return this.scheduleSubmit(formEl, ref, opts, push) } else if(LiveUploader.inputsAwaitingPreflight(formEl).length > 0){ - let [ref, els] = refGenerator() - let proxyRefGen = () => [ref, els, opts] + const [ref, els] = refGenerator() + const proxyRefGen = () => [ref, els, opts] this.uploadFiles(formEl, phxEvent, targetCtx, ref, cid, (_uploads) => { // if we still having pending preflights it means we have invalid entries // and the phx-submit cannot be completed if(LiveUploader.inputsAwaitingPreflight(formEl).length > 0){ return this.undoRefs(ref, phxEvent) } - let meta = this.extractMeta(formEl, {}, opts.value) - let formData = serializeForm(formEl, {submitter}) + const meta = this.extractMeta(formEl, {}, opts.value) + const formData = serializeForm(formEl, {submitter}) this.pushWithReply(proxyRefGen, "event", { type: "form", event: phxEvent, @@ -1354,8 +1376,8 @@ export default class View { .catch((error) => logError("Failed to push form submit", error)) }) } else if(!(formEl.hasAttribute(PHX_REF_SRC) && formEl.classList.contains("phx-submit-loading"))){ - let meta = this.extractMeta(formEl, {}, opts.value) - let formData = serializeForm(formEl, {submitter}) + const meta = this.extractMeta(formEl, {}, opts.value) + const formData = serializeForm(formEl, {submitter}) this.pushWithReply(refGenerator, "event", { type: "form", event: phxEvent, @@ -1369,25 +1391,25 @@ export default class View { } uploadFiles(formEl, phxEvent, targetCtx, ref, cid, onComplete){ - let joinCountAtUpload = this.joinCount - let inputEls = LiveUploader.activeFileInputs(formEl) + const joinCountAtUpload = this.joinCount + const inputEls = LiveUploader.activeFileInputs(formEl) let numFileInputsInProgress = inputEls.length // get each file input inputEls.forEach(inputEl => { - let uploader = new LiveUploader(inputEl, this, () => { + const uploader = new LiveUploader(inputEl, this, () => { numFileInputsInProgress-- if(numFileInputsInProgress === 0){ onComplete() } }) - let entries = uploader.entries().map(entry => entry.toPreflightPayload()) + const entries = uploader.entries().map(entry => entry.toPreflightPayload()) if(entries.length === 0){ numFileInputsInProgress-- return } - let payload = { + const payload = { ref: inputEl.getAttribute(PHX_UPLOAD_REF), entries: entries, cid: this.targetComponentID(inputEl.form, targetCtx) @@ -1408,12 +1430,12 @@ export default class View { // for form submits that contain invalid entries if(resp.error || Object.keys(resp.entries).length === 0){ this.undoRefs(ref, phxEvent) - let errors = resp.error || [] + const errors = resp.error || [] errors.map(([entry_ref, reason]) => { this.handleFailedEntryPreflight(entry_ref, reason, uploader) }) } else { - let onError = (callback) => { + const onError = (callback) => { this.channel.onError(() => { if(this.joinCount === joinCountAtUpload){ callback() } }) @@ -1427,7 +1449,7 @@ export default class View { handleFailedEntryPreflight(uploadRef, reason, uploader){ if(uploader.isAutoUpload()){ // uploadRef may be top level upload config ref or entry ref - let entry = uploader.entries().find(entry => entry.ref === uploadRef.toString()) + const entry = uploader.entries().find(entry => entry.ref === uploadRef.toString()) if(entry){ entry.cancel() } } else { uploader.entries().map(entry => entry.cancel()) @@ -1436,8 +1458,8 @@ export default class View { } dispatchUploads(targetCtx, name, filesOrBlobs){ - let targetElement = this.targetCtxElement(targetCtx) || this.el - let inputs = DOM.findUploadInputs(targetElement).filter(el => el.name === name) + const targetElement = this.targetCtxElement(targetCtx) || this.el + const inputs = DOM.findUploadInputs(targetElement).filter(el => el.name === name) if(inputs.length === 0){ logError(`no live file inputs found matching the name "${name}"`) } else if(inputs.length > 1){ logError(`duplicate live file inputs found matching the name "${name}"`) } else { DOM.dispatchEvent(inputs[0], PHX_TRACK_UPLOADS, {detail: {files: filesOrBlobs}}) } @@ -1445,7 +1467,7 @@ export default class View { targetCtxElement(targetCtx){ if(isCid(targetCtx)){ - let [target] = DOM.findComponentNodeList(this.el, targetCtx) + const [target] = DOM.findComponentNodeList(this.el, targetCtx) return target } else if(targetCtx){ return targetCtx @@ -1467,7 +1489,7 @@ export default class View { inputs.forEach(input => input.hasAttribute(PHX_UPLOAD_REF) && LiveUploader.clearFiles(input)) // pushInput assumes that there is a source element that initiated the change; // because this is not the case when we recover forms, we provide the first input we find - let input = inputs.find(el => el.type !== "hidden") || inputs[0] + const input = inputs.find(el => el.type !== "hidden") || inputs[0] // in the case that there are multiple targets, we count the number of pending recovery events // and only call the callback once all events have been processed @@ -1476,7 +1498,7 @@ export default class View { this.withinTargets(phxTarget, (targetView, targetCtx) => { const cid = this.targetComponentID(newForm, targetCtx) pending++ - let e = new CustomEvent("phx:form-recovery", {detail: {sourceElement: oldForm}}) + const e = new CustomEvent("phx:form-recovery", {detail: {sourceElement: oldForm}}) JS.exec(e, "change", phxEvent, this, input, ["push", { _target: input.name, targetView, @@ -1491,13 +1513,13 @@ export default class View { } pushLinkPatch(e, href, targetEl, callback){ - let linkRef = this.liveSocket.setPendingLink(href) + const linkRef = this.liveSocket.setPendingLink(href) // only add loading states if event is trusted (it was triggered by user, such as click) and // it's not a forward/back navigation from popstate - let loading = e.isTrusted && e.type !== "popstate" - let refGen = targetEl ? () => this.putRef([{el: targetEl, loading: loading, lock: true}], null, "click") : null - let fallback = () => this.liveSocket.redirect(window.location.href) - let url = href.startsWith("/") ? `${location.protocol}//${location.host}${href}` : href + const loading = e.isTrusted && e.type !== "popstate" + const refGen = targetEl ? () => this.putRef([{el: targetEl, loading: loading, lock: true}], null, "click") : null + const fallback = () => this.liveSocket.redirect(window.location.href) + const url = href.startsWith("/") ? `${location.protocol}//${location.host}${href}` : href this.pushWithReply(refGen, "live_patch", {url}).then( ({resp}) => { @@ -1520,7 +1542,7 @@ export default class View { getFormsForRecovery(){ if(this.joinCount === 0){ return {} } - let phxChange = this.binding("change") + const phxChange = this.binding("change") return DOM.all(this.el, `form[${phxChange}]`) .filter(form => form.id) @@ -1534,7 +1556,7 @@ export default class View { } maybePushComponentsDestroyed(destroyedCIDs){ - let willDestroyCIDs = destroyedCIDs.filter(cid => { + const willDestroyCIDs = destroyedCIDs.filter(cid => { return DOM.findComponentNodeList(this.el, cid).length === 0 }) @@ -1549,7 +1571,7 @@ export default class View { this.liveSocket.requestDOMUpdate(() => { // See if any of the cids we wanted to destroy were added back, // if they were added back, we don't actually destroy them. - let completelyDestroyCIDs = willDestroyCIDs.filter(cid => { + const completelyDestroyCIDs = willDestroyCIDs.filter(cid => { return DOM.findComponentNodeList(this.el, cid).length === 0 }) @@ -1564,7 +1586,7 @@ export default class View { } ownsElement(el){ - let parentViewEl = el.closest(PHX_VIEW_SELECTOR) + const parentViewEl = el.closest(PHX_VIEW_SELECTOR) return el.getAttribute(PHX_PARENT_ID) === this.id || (parentViewEl && parentViewEl.id === this.id) || (!parentViewEl && this.isDead) diff --git a/assets/js/phoenix_live_view/view_hook.js b/assets/js/phoenix_live_view/view_hook.js deleted file mode 100644 index f05cec35d0..0000000000 --- a/assets/js/phoenix_live_view/view_hook.js +++ /dev/null @@ -1,134 +0,0 @@ -import jsCommands from "./js_commands" -import DOM from "./dom" - -const HOOK_ID = "hookId" - -let viewHookID = 1 -export default class ViewHook { - static makeID(){ return viewHookID++ } - static elementID(el){ return DOM.private(el, HOOK_ID) } - - constructor(view, el, callbacks){ - this.el = el - this.__attachView(view) - this.__callbacks = callbacks - this.__listeners = new Set() - this.__isDisconnected = false - DOM.putPrivate(this.el, HOOK_ID, this.constructor.makeID()) - for(let key in this.__callbacks){ this[key] = this.__callbacks[key] } - } - - __attachView(view){ - if(view){ - this.__view = () => view - this.liveSocket = view.liveSocket - } else { - this.__view = () => { - throw new Error(`hook not yet attached to a live view: ${this.el.outerHTML}`) - } - this.liveSocket = null - } - } - - __mounted(){ this.mounted && this.mounted() } - __updated(){ this.updated && this.updated() } - __beforeUpdate(){ this.beforeUpdate && this.beforeUpdate() } - __destroyed(){ - this.destroyed && this.destroyed() - DOM.deletePrivate(this.el, HOOK_ID) // https://github.com/phoenixframework/phoenix_live_view/issues/3496 - } - __reconnected(){ - if(this.__isDisconnected){ - this.__isDisconnected = false - this.reconnected && this.reconnected() - } - } - __disconnected(){ - this.__isDisconnected = true - this.disconnected && this.disconnected() - } - - /** - * Binds the hook to JS commands. - * - * @returns {Object} An object with methods to manipulate the DOM and execute JavaScript. - */ - js(){ - let hook = this - - return { - ...jsCommands(hook.__view().liveSocket, "hook"), - /** - * Executes encoded JavaScript in the context of the element. - * - * @param {string} encodedJS - The encoded JavaScript string to execute. - */ - exec(encodedJS){ - hook.__view().liveSocket.execJS(hook.el, encodedJS, "hook") - } - } - } - - pushEvent(event, payload = {}, onReply){ - if(onReply === undefined){ - return new Promise((resolve, reject) => { - try { - const ref = this.__view().pushHookEvent(this.el, null, event, payload, (reply, _ref) => resolve(reply)) - if(ref === false){ - reject(new Error("unable to push hook event. LiveView not connected")) - } - } catch (error){ - reject(error) - } - }) - } - return this.__view().pushHookEvent(this.el, null, event, payload, onReply) - } - - pushEventTo(phxTarget, event, payload = {}, onReply){ - if(onReply === undefined){ - return new Promise((resolve, reject) => { - try { - this.__view().withinTargets(phxTarget, (view, targetCtx) => { - const ref = view.pushHookEvent(this.el, targetCtx, event, payload, (reply, _ref) => resolve(reply)) - if(ref === false){ - reject(new Error("unable to push hook event. LiveView not connected")) - } - }) - } catch (error){ - reject(error) - } - }) - } - return this.__view().withinTargets(phxTarget, (view, targetCtx) => { - return view.pushHookEvent(this.el, targetCtx, event, payload, onReply) - }) - } - - handleEvent(event, callback){ - let callbackRef = (customEvent, bypass) => bypass ? event : callback(customEvent.detail) - window.addEventListener(`phx:${event}`, callbackRef) - this.__listeners.add(callbackRef) - return callbackRef - } - - removeHandleEvent(callbackRef){ - let event = callbackRef(null, true) - window.removeEventListener(`phx:${event}`, callbackRef) - this.__listeners.delete(callbackRef) - } - - upload(name, files){ - return this.__view().dispatchUploads(null, name, files) - } - - uploadTo(phxTarget, name, files){ - return this.__view().withinTargets(phxTarget, (view, targetCtx) => { - view.dispatchUploads(targetCtx, name, files) - }) - } - - __cleanup__(){ - this.__listeners.forEach(callbackRef => this.removeHandleEvent(callbackRef)) - } -} diff --git a/assets/js/phoenix_live_view/view_hook.ts b/assets/js/phoenix_live_view/view_hook.ts new file mode 100644 index 0000000000..c12f9efd0e --- /dev/null +++ b/assets/js/phoenix_live_view/view_hook.ts @@ -0,0 +1,399 @@ +import jsCommands, {HookJSCommands} from "./js_commands" +import DOM from "./dom" +import LiveSocket from "./live_socket" +import View from "./view" + +const HOOK_ID = "hookId" +let viewHookID = 1 + +export type OnReply = (reply: any, ref: number) => any +export type CallbackRef = {event: string, callback: (payload: any) => any} + +export type PhxTarget = string | number | HTMLElement + +export interface HookInterface { + /** + * The DOM element that the hook is attached to. + */ + el: HTMLElement; + + /** + * The LiveSocket instance that the hook is attached to. + */ + liveSocket: LiveSocket; + + /** + * The mounted callback. + * + * Called when the element has been added to the DOM and its server LiveView has finished mounting. + */ + mounted?: () => void; + + /** + * The beforeUpdate callback. + * + * Called when the element is about to be updated in the DOM. + * Note: any call here must be synchronous as the operation cannot be deferred or cancelled. + */ + beforeUpdate?: () => void; + + /** + * The updated callback. + * + * Called when the element has been updated in the DOM by the server + */ + updated?: () => void; + + /** + * The destroyed callback. + * + * Called when the element has been removed from the page, either by a parent update, or by the parent being removed entirely + */ + destroyed?: () => void; + + /** + * The disconnected callback. + * + * Called when the element's parent LiveView has disconnected from the server. + */ + disconnected?: () => void; + + /** + * The reconnected callback. + * + * Called when the element's parent LiveView has reconnected to the server. + */ + reconnected?: () => void; + + /** + * Returns an object with methods to manipluate the DOM and execute JavaScript. + * The applied changes integrate with server DOM patching. + */ + js(): HookJSCommands; + + /** + * Pushes an event to the server. + * + * @param event - The event name. + * @param [payload] - The payload to send to the server. Defaults to an empty object. + * @param [onReply] - A callback to handle the server's reply. + * + * When onReply is not provided, the method returns a Promise that + * When onReply is provided, the method returns void. + */ + pushEvent(event: string, payload: any, onReply: OnReply): void; + pushEvent(event: string, payload?: any): Promise; + + /** + * Pushed a targeted event to the server. + * + * It sends the event to the LiveComponent or LiveView the `selectorOrTarget` is defined in, + * where its value can be either a query selector, an actual DOM element, or a CID (component id) + * returned by the `@myself` assign. + * + * If the query selector returns more than one element it will send the event to all of them, + * even if all the elements are in the same LiveComponent or LiveView. Because of this, + * if no callback is passed, a promise is returned that matches the return value of + * [`Promise.allSettled()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/allSettled#return_value). + * Individual fulfilled values are of the format `{ reply, ref }`, where `reply` is the server's reply. + * + * @param selectorOrTarget - The selector, element, or CID to target. + * @param event - The event name. + * @param [payload] - The payload to send to the server. Defaults to an empty object. + * @param [onReply] - A callback to handle the server's reply. + * + * When onReply is not provided, the method returns a Promise. + * When onReply is provided, the method returns void. + */ + pushEventTo(selectorOrTarget: PhxTarget, event: string, payload: object, onReply: OnReply): void; + pushEventTo(selectorOrTarget: PhxTarget, event: string, payload?: object): Promise[]>; + + /** + * Allows to register a callback to be called when an event is received from the server. + * + * This is used to handle `pushEvent` calls from the server. The callback is called with the payload from the server. + * + * @param event - The event name. + * @param callback - The callback to call when the event is received. + * + * @returns A reference to the callback, which can be used in `removeHandleEvent` to remove the callback. + */ + handleEvent(event: string, callback: (payload: any) => any): CallbackRef; + + /** + * Removes a callback registered with `handleEvent`. + * + * @param callbackRef - The reference to the callback to remove. + */ + removeHandleEvent(ref: CallbackRef): void; + + /** + * Allows to trigger a live file upload. + * + * @param name - The upload name corresponding to the `Phoenix.LiveView.allow_upload/3` call. + * @param files - The files to upload. + */ + upload(name: any, files: any): any; + + /** + * Allows to trigger a live file upload to a specific target. + * + * @param selectorOrTarget - The target to upload the files to. + * @param name - The upload name corresponding to the `Phoenix.LiveView.allow_upload/3` call. + * @param files - The files to upload. + */ + uploadTo(selectorOrTarget: PhxTarget, name: any, files: any): any; + + // allow unknown methods, as people can define them in their hooks + [key: string]: any; +} + +export interface HookObject { + /** + * The mounted callback. + * + * Called when the element has been added to the DOM and its server LiveView has finished mounting. + */ + mounted?: (this: T & HookInterface) => void; + + /** + * The beforeUpdate callback. + * + * Called when the element is about to be updated in the DOM. + * Note: any call here must be synchronous as the operation cannot be deferred or cancelled. + */ + beforeUpdate?: (this: T & HookInterface) => void; + + /** + * The updated callback. + * + * Called when the element has been updated in the DOM by the server + */ + updated?: (this: T & HookInterface) => void; + + /** + * The destroyed callback. + * + * Called when the element has been removed from the page, either by a parent update, or by the parent being removed entirely + */ + destroyed?: (this: T & HookInterface) => void; + + /** + * The disconnected callback. + * + * Called when the element's parent LiveView has disconnected from the server. + */ + disconnected?: (this: T & HookInterface) => void; + + /** + * The reconnected callback. + * + * Called when the element's parent LiveView has reconnected to the server. + */ + reconnected?: (this: T & HookInterface) => void; + + // Allow custom methods with any signature and custom properties + [key: string]: ((this: T & HookInterface, ...args: any[]) => any) | any; +} + +/** + * Base class for LiveView hooks. Users extend this class to define their hooks. + * + * Example: + * ```typescript + * class MyCustomHook extends ViewHook { + * myState = "initial"; + * + * mounted() { + * console.log("Hook mounted on element:", this.el); + * this.el.addEventListener("click", () => { + * this.pushEvent("element-clicked", { state: this.myState }); + * }); + * } + * + * updated() { + * console.log("Hook updated", this.el.id); + * } + * + * myCustomMethod(someArg: string) { + * console.log("myCustomMethod called with:", someArg, "Current state:", this.myState); + * } + * } + * ``` + * + * The `this` context within the hook methods (mounted, updated, custom methods, etc.) + * will refer to the hook instance, providing access to `this.el`, `this.liveSocket`, + * `this.pushEvent()`, etc., as well as any properties or methods defined on the subclass. + */ +export class ViewHook implements HookInterface { + el: HTMLElement + liveSocket: LiveSocket + + private __listeners: Set + private __isDisconnected: boolean + private __view: () => View + + static makeID(){ return viewHookID++ } + static elementID(el: HTMLElement){ return DOM.private(el, HOOK_ID) } + + constructor(view: View | null, el: HTMLElement, callbacks?: HookObject){ + this.el = el + this.__attachView(view) + this.__listeners = new Set() + this.__isDisconnected = false + DOM.putPrivate(this.el, HOOK_ID, ViewHook.makeID()) + + if(callbacks){ + // This instance is for an object-literal hook. Copy methods/properties. + // These are properties that should NOT be overridden by the callbacks object. + const protectedProps = new Set([ + "el", "liveSocket", "__view", "__listeners", "__isDisconnected", + "constructor", // Standard object properties + // Core ViewHook API methods + "js", "pushEvent", "pushEventTo", "handleEvent", "removeHandleEvent", "upload", "uploadTo", + // Internal lifecycle callers + "__mounted", "__updated", "__beforeUpdate", "__destroyed", "__reconnected", "__disconnected", "__cleanup__" + ]) + + for(const key in callbacks){ + if(Object.prototype.hasOwnProperty.call(callbacks, key)){ + if(protectedProps.has(key)){ + // Optionally log a warning if a user tries to overwrite a protected property/method + // For now, we silently prioritize the ViewHook's own properties/methods. + if(typeof (this as any)[key] === "function" && typeof callbacks[key] !== "function" && !["mounted", "beforeUpdate", "updated", "destroyed", "disconnected", "reconnected"].includes(key) ){ + // If core method is a function and callback is not, likely an error from user. + console.warn(`Hook object for element #${el.id} attempted to overwrite core method '${key}' with a non-function value. This is not allowed.`) + } + } else { + (this as any)[key] = callbacks[key] + } + } + } + + const lifecycleMethods: (keyof HookObject)[] = ["mounted", "beforeUpdate", "updated", "destroyed", "disconnected", "reconnected"] + lifecycleMethods.forEach(methodName => { + if(callbacks[methodName] && typeof callbacks[methodName] === "function"){ + (this as any)[methodName] = callbacks[methodName] + } + }) + } + // If 'callbacks' is not provided, this is an instance of a user-defined class (e.g., MyHook). + // Its methods (mounted, updated, custom) are already part of its prototype or instance, + // and will correctly override the defaults from ViewHook.prototype. + } + + /** @internal */ + __attachView(view: View | null){ + if(view){ + this.__view = () => view + this.liveSocket = view.liveSocket + } else { + this.__view = () => { + throw new Error(`hook not yet attached to a live view: ${this.el.outerHTML}`) + } + this.liveSocket = null + } + } + + // Default lifecycle methods + mounted(): void{ } + beforeUpdate(): void{ } + updated(): void{ } + destroyed(): void{ } + disconnected(): void{ } + reconnected(): void{ } + + // Internal lifecycle callers - called by the View + + /** @internal */ + __mounted(){ this.mounted() } + /** @internal */ + __updated(){ this.updated() } + /** @internal */ + __beforeUpdate(){ this.beforeUpdate() } + /** @internal */ + __destroyed(){ + this.destroyed() + DOM.deletePrivate(this.el, HOOK_ID) // https://github.com/phoenixframework/phoenix_live_view/issues/3496 + } + /** @internal */ + __reconnected(){ + if(this.__isDisconnected){ + this.__isDisconnected = false + this.reconnected() + } + } + /** @internal */ + __disconnected(){ + this.__isDisconnected = true + this.disconnected() + } + + js(): HookJSCommands{ + return { + ...jsCommands(this.__view().liveSocket, "hook"), + exec: (encodedJS: string) => { + this.__view().liveSocket.execJS(this.el, encodedJS, "hook") + } + } + } + + pushEvent(event: string, payload?: any, onReply?: OnReply){ + const promise = this.__view().pushHookEvent(this.el, null, event, payload || {}) + if(onReply === undefined){ + return promise.then(({reply}) => reply) + } + promise.then(({reply, ref}) => onReply(reply, ref)).catch(() => {}) + return + } + + pushEventTo(selectorOrTarget: PhxTarget, event: string, payload?: object, onReply?: OnReply){ + if(onReply === undefined){ + const targetPair: {view: View, targetCtx: any}[] = [] + this.__view().withinTargets(selectorOrTarget, (view, targetCtx) => { + targetPair.push({view, targetCtx}) + }) + const promises = targetPair.map(({view, targetCtx}) => { + return view.pushHookEvent(this.el, targetCtx, event, payload || {}) + }) + return Promise.allSettled(promises) + } + this.__view().withinTargets(selectorOrTarget, (view, targetCtx) => { + view.pushHookEvent(this.el, targetCtx, event, payload || {}) + .then(({reply, ref}) => onReply(reply, ref)) + .catch(() => {}) + }) + return + } + + handleEvent(event: string, callback: (payload: any) => any): CallbackRef{ + const callbackRef: CallbackRef = {event, callback: (customEvent: CustomEvent) => callback(customEvent.detail)} + window.addEventListener(`phx:${event}`, callbackRef.callback as EventListener) + this.__listeners.add(callbackRef) + return callbackRef + } + + removeHandleEvent(ref: CallbackRef): void{ + window.removeEventListener(`phx:${ref.event}`, ref.callback as EventListener) + this.__listeners.delete(ref) + } + + upload(name: string, files: FileList): any{ + return this.__view().dispatchUploads(null, name, files) + } + + uploadTo(selectorOrTarget: PhxTarget, name: string, files: FileList): any{ + return this.__view().withinTargets(selectorOrTarget, (view, targetCtx) => { + view.dispatchUploads(targetCtx, name, files) + }) + } + + /** @internal */ + __cleanup__(){ + this.__listeners.forEach(callbackRef => this.removeHandleEvent(callbackRef)) + } +} + +export type HooksOptions = Record + +export default ViewHook diff --git a/assets/test/debounce_test.js b/assets/test/debounce_test.js index e1a5bd7071..b302eab12b 100644 --- a/assets/test/debounce_test.js +++ b/assets/test/debounce_test.js @@ -1,19 +1,19 @@ import DOM from "phoenix_live_view/dom" -let after = (time, func) => setTimeout(func, time) +const after = (time, func) => setTimeout(func, time) -let simulateInput = (input, val) => { +const simulateInput = (input, val) => { input.value = val DOM.dispatchEvent(input, "input") } -let simulateKeyDown = (input, val) => { +const simulateKeyDown = (input, val) => { input.value = input.value + val DOM.dispatchEvent(input, "input") } -let container = () => { - let div = document.createElement("div") +const container = () => { + const div = document.createElement("div") div.innerHTML = `
@@ -37,7 +37,7 @@ let container = () => { describe("debounce", function (){ test("triggers once on input blur", async () => { let calls = 0 - let el = container().querySelector("input[name=blur]") + const el = container().querySelector("input[name=blur]") DOM.debounce(el, {}, "phx-debounce", 100, "phx-throttle", 200, () => true, () => calls++) DOM.dispatchEvent(el, "blur") @@ -51,7 +51,7 @@ describe("debounce", function (){ test("triggers debounce on input blur", async () => { let calls = 0 - let el = container().querySelector("input[name=debounce-200]") + const el = container().querySelector("input[name=debounce-200]") el.addEventListener("input", e => { DOM.debounce(el, e, "phx-debounce", 0, "phx-throttle", 0, () => true, () => calls++) @@ -68,7 +68,7 @@ describe("debounce", function (){ test("triggers debounce on input blur caused by tab", async () => { let calls = 0 - let el = container().querySelector("input[name=debounce-200]") + const el = container().querySelector("input[name=debounce-200]") el.addEventListener("input", e => { DOM.debounce(el, e, "phx-debounce", 0, "phx-throttle", 0, () => true, () => calls++) @@ -83,7 +83,7 @@ describe("debounce", function (){ test("triggers on timeout", done => { let calls = 0 - let el = container().querySelector("input[name=debounce-200]") + const el = container().querySelector("input[name=debounce-200]") el.addEventListener("input", e => { DOM.debounce(el, e, "phx-debounce", 100, "phx-throttle", 200, () => true, () => calls++) @@ -114,7 +114,7 @@ describe("debounce", function (){ test("uses default when value is blank", done => { let calls = 0 - let el = container().querySelector("input[name=debounce-200]") + const el = container().querySelector("input[name=debounce-200]") el.setAttribute("phx-debounce", "") el.addEventListener("input", e => { @@ -139,8 +139,8 @@ describe("debounce", function (){ test("cancels trigger on submit", done => { let calls = 0 - let parent = container() - let el = parent.querySelector("input[name=debounce-200]") + const parent = container() + const el = parent.querySelector("input[name=debounce-200]") el.addEventListener("input", e => { DOM.debounce(el, e, "phx-debounce", 100, "phx-throttle", 200, () => true, () => calls++) @@ -166,7 +166,7 @@ describe("debounce", function (){ describe("throttle", function (){ test("triggers immediately, then on timeout", done => { let calls = 0 - let el = container().querySelector("#throttle-200") + const el = container().querySelector("#throttle-200") el.addEventListener("click", e => { DOM.debounce(el, e, "phx-debounce", 100, "phx-throttle", 200, () => true, () => { @@ -195,7 +195,7 @@ describe("throttle", function (){ test("uses default when value is blank", done => { let calls = 0 - let el = container().querySelector("#throttle-200") + const el = container().querySelector("#throttle-200") el.setAttribute("phx-throttle", "") el.addEventListener("click", e => { @@ -225,7 +225,7 @@ describe("throttle", function (){ test("cancels trigger on submit", done => { let calls = 0 - let el = container().querySelector("input[name=throttle-200]") + const el = container().querySelector("input[name=throttle-200]") el.addEventListener("input", e => { DOM.debounce(el, e, "phx-debounce", 100, "phx-throttle", 200, () => true, () => calls++) @@ -248,7 +248,7 @@ describe("throttle", function (){ test("triggers only once when there is only one event", done => { let calls = 0 - let el = container().querySelector("#throttle-200") + const el = container().querySelector("#throttle-200") el.addEventListener("click", e => { DOM.debounce(el, e, "phx-debounce", 100, "phx-throttle", 200, () => true, () => { @@ -267,7 +267,7 @@ describe("throttle", function (){ test("sends value on blur when phx-blur dispatches change", done => { let calls = 0 - let el = container().querySelector("input[name=throttle-range-with-blur]") + const el = container().querySelector("input[name=throttle-range-with-blur]") el.addEventListener("input", e => { DOM.debounce(el, e, "phx-debounce", 100, "phx-throttle", 200, () => true, () => { @@ -303,8 +303,8 @@ describe("throttle", function (){ describe("throttle keydown", function (){ test("when the same key is pressed triggers immediately, then on timeout", done => { - let keyPresses = {} - let el = container().querySelector("#throttle-keydown") + const keyPresses = {} + const el = container().querySelector("#throttle-keydown") el.addEventListener("keydown", e => { DOM.debounce(el, e, "phx-debounce", 100, "phx-throttle", 200, () => true, () => { @@ -312,7 +312,7 @@ describe("throttle keydown", function (){ }) }) - let pressA = new KeyboardEvent("keydown", {key: "a"}) + const pressA = new KeyboardEvent("keydown", {key: "a"}) el.dispatchEvent(pressA) el.dispatchEvent(pressA) el.dispatchEvent(pressA) @@ -329,8 +329,8 @@ describe("throttle keydown", function (){ }) test("when different key is pressed triggers immediately", done => { - let keyPresses = {} - let el = container().querySelector("#throttle-keydown") + const keyPresses = {} + const el = container().querySelector("#throttle-keydown") el.addEventListener("keydown", e => { DOM.debounce(el, e, "phx-debounce", 100, "phx-throttle", 200, () => true, () => { @@ -338,8 +338,8 @@ describe("throttle keydown", function (){ }) }) - let pressA = new KeyboardEvent("keydown", {key: "a"}) - let pressB = new KeyboardEvent("keydown", {key: "b"}) + const pressA = new KeyboardEvent("keydown", {key: "a"}) + const pressB = new KeyboardEvent("keydown", {key: "b"}) el.dispatchEvent(pressA) el.dispatchEvent(pressB) diff --git a/assets/test/dom_test.js b/assets/test/dom_test.js index e9cc5a1ef4..bc778def06 100644 --- a/assets/test/dom_test.js +++ b/assets/test/dom_test.js @@ -1,9 +1,9 @@ import DOM from "phoenix_live_view/dom" import {appendTitle, tag} from "./test_helpers" -let e = (href) => { - let event = {} - let anchor = document.createElement("a") +const e = (href) => { + const event = {} + const anchor = document.createElement("a") anchor.setAttribute("href", href) event.target = anchor event.defaultPrevented = false @@ -12,13 +12,13 @@ let e = (href) => { describe("DOM", () => { beforeEach(() => { - let curTitle = document.querySelector("title") + const curTitle = document.querySelector("title") curTitle && curTitle.remove() }) describe ("wantsNewTab", () => { test("case insensitive target", () => { - let event = e("https://test.local") + const event = e("https://test.local") expect(DOM.wantsNewTab(event)).toBe(false) // lowercase event.target.setAttribute("target", "_blank") @@ -63,19 +63,19 @@ describe("DOM", () => { }) test("empty hash href", () => { - let currentLoc = new URL("https://test.local/foo") + const currentLoc = new URL("https://test.local/foo") expect(DOM.isNewPageClick(e("#"), currentLoc)).toBe(false) }) test("local hash", () => { - let currentLoc = new URL("https://test.local/foo") + const currentLoc = new URL("https://test.local/foo") expect(DOM.isNewPageClick(e("#foo"), currentLoc)).toBe(false) }) test("with defaultPrevented return sfalse", () => { let currentLoc currentLoc = new URL("https://test.local/foo") - let event = e("/foo") + const event = e("/foo") event.defaultPrevented = true expect(DOM.isNewPageClick(event, currentLoc)).toBe(false) }) @@ -88,7 +88,7 @@ describe("DOM", () => { test("ignores contenteditable", () => { let currentLoc currentLoc = new URL("https://test.local/foo") - let event = e("/bar") + const event = e("/bar") event.target.isContentEditable = true expect(DOM.isNewPageClick(event, currentLoc)).toBe(false) }) @@ -134,7 +134,7 @@ describe("DOM", () => { describe("findExistingParentCIDs", () => { test("returns only parent cids", () => { - let view = tag("div", {}, ` + const view = tag("div", {}, `
{ }) test("ignores elements in child LiveViews #3626", () => { - let view = tag("div", {}, ` + const view = tag("div", {}, `
{ describe("findComponentNodeList", () => { test("returns nodes with cid ID (except indirect children)", () => { - let component1 = tag("div", {"data-phx-component": 0}, "Hello") - let component2 = tag("div", {"data-phx-component": 0}, "World") - let component3 = tag("div", {"data-phx-session": "123"}, ` + const component1 = tag("div", {"data-phx-component": 0}, "Hello") + const component2 = tag("div", {"data-phx-component": 0}, "World") + const component3 = tag("div", {"data-phx-session": "123"}, `
`) document.body.appendChild(component1) @@ -215,42 +215,42 @@ describe("DOM", () => { describe("cleanChildNodes", () => { test("only cleans when phx-update is append or prepend", () => { - let content = ` + const content = `
1
no id
some test `.trim() - let div = tag("div", {}, content) + const div = tag("div", {}, content) DOM.cleanChildNodes(div, "phx-update") expect(div.innerHTML).toBe(content) }) test("silently removes empty text nodes", () => { - let content = ` + const content = `
1
2
`.trim() - let div = tag("div", {"phx-update": "append"}, content) + const div = tag("div", {"phx-update": "append"}, content) DOM.cleanChildNodes(div, "phx-update") expect(div.innerHTML).toBe("
1
2
") }) test("emits warning when removing elements without id", () => { - let content = ` + const content = `
1
no id
some test `.trim() - let div = tag("div", {"phx-update": "append"}, content) + const div = tag("div", {"phx-update": "append"}, content) let errorCount = 0 jest.spyOn(console, "error").mockImplementation(() => errorCount += 1) diff --git a/assets/test/event_test.js b/assets/test/event_test.js index 0f4c7fa3b1..bf0ae46395 100644 --- a/assets/test/event_test.js +++ b/assets/test/event_test.js @@ -35,6 +35,22 @@ let stubNextChannelReply = (view, replyPayload) => { } } +let stubNextChannelReplyWithError = (view, reason) => { + let oldPush = view.channel.push + view.channel.push = () => { + return { + receives: [], + receive(kind, cb){ + if(kind === "error"){ + cb(reason) + view.channel.push = oldPush + } + return this + } + } + } +} + describe("events", () => { let processedEvents beforeEach(() => { @@ -153,13 +169,12 @@ describe("pushEvent replies", () => { test("reply", (done) => { let view - let pushedRef = null let liveSocket = new LiveSocket("/live", Socket, { hooks: { Gateway: { mounted(){ stubNextChannelReply(view, {transactionID: "1001"}) - pushedRef = this.pushEvent("charge", {amount: 123}, (resp, ref) => { + this.pushEvent("charge", {amount: 123}, (resp, ref) => { processedReplies.push({resp, ref}) view.el.dispatchEvent(new CustomEvent("replied", {detail: {resp, ref}})) }) @@ -176,7 +191,6 @@ describe("pushEvent replies", () => { }, []) view.el.addEventListener("replied", () => { - expect(pushedRef).toEqual(0) expect(processedReplies).toEqual([{resp: {transactionID: "1001"}, ref: 0}]) done() }) @@ -211,15 +225,74 @@ describe("pushEvent replies", () => { }) }) - test("pushEvent without connection noops", () => { + test("rejects with error", (done) => { + let view + let liveSocket = new LiveSocket("/live", Socket, { + hooks: { + Gateway: { + mounted(){ + stubNextChannelReplyWithError(view, "error") + this.pushEvent("charge", {amount: 123}).catch((error) => { + expect(error).toEqual(expect.any(Error)) + done() + }) + } + } + } + }) + view = simulateView(liveSocket, [], "") + view.update({ + s: [` +
+
+ `] + }, []) + }) + + test("pushEventTo - promise with multiple targets", (done) => { + let view + let liveSocket = new LiveSocket("/live", Socket, { + hooks: { + Gateway: { + mounted(){ + stubNextChannelReply(view, {transactionID: "1001"}) + this.pushEventTo("[data-foo]", "charge", {amount: 123}).then((result) => { + expect(result).toEqual([ + {status: "fulfilled", value: {ref: 0, reply: {transactionID: "1001"}}}, + // we only stubbed one reply + {status: "rejected", reason: expect.any(Error)} + ]) + done() + }) + } + } + } + }) + view = simulateView(liveSocket, [], "") + liveSocket.main = view + view.update({ + s: [` +
+
+
+
+
+
+ `] + }, []) + }) + + test("pushEvent without connection noops", (done) => { let view - let pushedRef = "before" + const spy = jest.fn() let liveSocket = new LiveSocket("/live", Socket, { hooks: { Gateway: { mounted(){ stubNextChannelReply(view, {transactionID: "1001"}) - pushedRef = this.pushEvent("charge", {amount: 123}, () => {}) + this.pushEvent("charge", {amount: 1233433}).then(spy).catch(() => { + view.el.dispatchEvent(new CustomEvent("pushed")) + }) } } } @@ -233,6 +306,9 @@ describe("pushEvent replies", () => { `] }, []) - expect(pushedRef).toEqual(false) + view.el.addEventListener("pushed", () => { + expect(spy).not.toHaveBeenCalled() + done() + }) }) }) diff --git a/assets/test/integration/event_test.js b/assets/test/integration/event_test.js index 981b69116b..92a39cd8e8 100644 --- a/assets/test/integration/event_test.js +++ b/assets/test/integration/event_test.js @@ -1,13 +1,13 @@ import {Socket} from "phoenix" import LiveSocket from "phoenix_live_view/live_socket" -let stubViewPushInput = (view, callback) => { +const stubViewPushInput = (view, callback) => { view.pushInput = (sourceEl, targetCtx, newCid, event, pushOpts, originalCallback) => { return callback(sourceEl, targetCtx, newCid, event, pushOpts, originalCallback) } } -let prepareLiveViewDOM = (document, rootId) => { +const prepareLiveViewDOM = (document, rootId) => { document.body.innerHTML = `
{ }) test("send change event to correct target", () => { - let liveSocket = new LiveSocket("/live", Socket) + const liveSocket = new LiveSocket("/live", Socket) liveSocket.connect() - let view = liveSocket.getViewByEl(document.getElementById("root")) + const view = liveSocket.getViewByEl(document.getElementById("root")) view.isConnected = () => true - let input = view.el.querySelector("#first_name") + const input = view.el.querySelector("#first_name") let meta = { event: null, target: null, diff --git a/assets/test/integration/metadata_test.js b/assets/test/integration/metadata_test.js index 38b0be70d9..6960cc465a 100644 --- a/assets/test/integration/metadata_test.js +++ b/assets/test/integration/metadata_test.js @@ -1,13 +1,13 @@ import {Socket} from "phoenix" import LiveSocket from "phoenix_live_view/live_socket" -let stubViewPushEvent = (view, callback) => { +const stubViewPushEvent = (view, callback) => { view.pushEvent = (type, el, targetCtx, phxEvent, meta, opts = {}) => { return callback(type, el, targetCtx, phxEvent, meta, opts) } } -let prepareLiveViewDOM = (document, rootId) => { +const prepareLiveViewDOM = (document, rootId) => { document.body.innerHTML = `
{ }) test("is empty by default", () => { - let liveSocket = new LiveSocket("/live", Socket) + const liveSocket = new LiveSocket("/live", Socket) liveSocket.connect() - let view = liveSocket.getViewByEl(document.getElementById("root")) - let btn = view.el.querySelector("button") + const view = liveSocket.getViewByEl(document.getElementById("root")) + const btn = view.el.querySelector("button") let meta = {} stubViewPushEvent(view, (type, el, target, targetCtx, phxEvent, metadata) => { meta = metadata @@ -39,7 +39,7 @@ describe("metadata", () => { }) test("can be user defined", () => { - let liveSocket = new LiveSocket("/live", Socket, { + const liveSocket = new LiveSocket("/live", Socket, { metadata: { click: (e, el) => { return { @@ -51,9 +51,9 @@ describe("metadata", () => { }) liveSocket.connect() liveSocket.isConnected = () => true - let view = liveSocket.getViewByEl(document.getElementById("root")) + const view = liveSocket.getViewByEl(document.getElementById("root")) view.isConnected = () => true - let btn = view.el.querySelector("button") + const btn = view.el.querySelector("button") let meta = {} stubViewPushEvent(view, (type, el, target, phxEvent, metadata, _opts) => { meta = metadata diff --git a/assets/test/js_test.js b/assets/test/js_test.js index e3b494a594..1e03da55af 100644 --- a/assets/test/js_test.js +++ b/assets/test/js_test.js @@ -4,14 +4,14 @@ import JS from "phoenix_live_view/js" import ViewHook from "phoenix_live_view/view_hook" import {simulateJoinedView, simulateVisibility, liveViewDOM} from "./test_helpers" -let setupView = (content) => { - let el = liveViewDOM(content) +const setupView = (content) => { + const el = liveViewDOM(content) global.document.body.appendChild(el) - let liveSocket = new LiveSocket("/live", Socket) + const liveSocket = new LiveSocket("/live", Socket) return simulateJoinedView(el, liveSocket) } -let event = new CustomEvent("phx:exec") +const event = new CustomEvent("phx:exec") describe("JS", () => { beforeEach(() => { @@ -29,7 +29,7 @@ describe("JS", () => { beforeEach(() => { view = setupView("
modal
") modal = view.el.querySelector("#modal") - let hook = new ViewHook(view, view.el, {}) + const hook = new ViewHook(view, view.el, {}) js = hook.js() }) @@ -199,12 +199,12 @@ describe("JS", () => { describe("exec_toggle", () => { test("with defaults", done => { - let view = setupView(` + const view = setupView(`
`) - let modal = simulateVisibility(document.querySelector("#modal")) - let click = document.querySelector("#click") + const modal = simulateVisibility(document.querySelector("#modal")) + const click = document.querySelector("#click") let showEndCalled = false let hideEndCalled = false let showStartCalled = false @@ -233,12 +233,12 @@ describe("JS", () => { }) test("with display", done => { - let view = setupView(` + const view = setupView(`
`) - let modal = simulateVisibility(document.querySelector("#modal")) - let click = document.querySelector("#click") + const modal = simulateVisibility(document.querySelector("#modal")) + const click = document.querySelector("#click") let showEndCalled = false let hideEndCalled = false let showStartCalled = false @@ -266,12 +266,12 @@ describe("JS", () => { }) test("with in and out classes", async () => { - let view = setupView(` + const view = setupView(`
`) - let modal = simulateVisibility(document.querySelector("#modal")) - let click = document.querySelector("#click") + const modal = simulateVisibility(document.querySelector("#modal")) + const click = document.querySelector("#click") let showEndCalled = false let hideEndCalled = false let showStartCalled = false @@ -346,12 +346,12 @@ describe("JS", () => { describe("exec_transition", () => { test("with defaults", done => { - let view = setupView(` + const view = setupView(`
`) - let modal = simulateVisibility(document.querySelector("#modal")) - let click = document.querySelector("#click") + const modal = simulateVisibility(document.querySelector("#modal")) + const click = document.querySelector("#click") expect(Array.from(modal.classList)).toEqual(["modal"]) @@ -366,14 +366,14 @@ describe("JS", () => { }) test("with multiple selector", done => { - let view = setupView(` + const view = setupView(`
`) - let modal1 = document.querySelector("#modal1") - let modal2 = document.querySelector("#modal2") - let click = document.querySelector("#click") + const modal1 = document.querySelector("#modal1") + const modal2 = document.querySelector("#modal2") + const click = document.querySelector("#click") expect(Array.from(modal1.classList)).toEqual(["modal"]) expect(Array.from(modal2.classList)).toEqual(["modal"]) @@ -395,12 +395,12 @@ describe("JS", () => { describe("exec_dispatch", () => { test("with defaults", done => { - let view = setupView(` + const view = setupView(`
`) - let modal = simulateVisibility(document.querySelector("#modal")) - let click = document.querySelector("#click") + const modal = simulateVisibility(document.querySelector("#modal")) + const click = document.querySelector("#click") modal.addEventListener("click", () => { done() @@ -409,41 +409,41 @@ describe("JS", () => { }) test("with to scope inner", done => { - let view = setupView(` + const view = setupView(`
`) - let modal = simulateVisibility(document.querySelector(".modal")) - let click = document.querySelector("#click") + const modal = simulateVisibility(document.querySelector(".modal")) + const click = document.querySelector("#click") modal.addEventListener("click", () => done()) JS.exec(event, "click", click.getAttribute("phx-click"), view, click) }) test("with to scope closest", done => { - let view = setupView(` + const view = setupView(` `) - let modal = simulateVisibility(document.querySelector(".modal")) - let click = document.querySelector("#click") + const modal = simulateVisibility(document.querySelector(".modal")) + const click = document.querySelector("#click") modal.addEventListener("click", () => done()) JS.exec(event, "click", click.getAttribute("phx-click"), view, click) }) test("with details", done => { - let view = setupView(` + const view = setupView(`
`) - let modal = simulateVisibility(document.querySelector("#modal")) - let click = document.querySelector("#click") - let close = document.querySelector("#close") + const modal = simulateVisibility(document.querySelector("#modal")) + const click = document.querySelector("#click") + const close = document.querySelector("#close") modal.addEventListener("close", e => { expect(e.detail).toEqual({id: 1, dispatcher: close}) @@ -457,15 +457,15 @@ describe("JS", () => { }) test("with multiple selector", done => { - let view = setupView(` + const view = setupView(`
`) let modal1Clicked = false - let modal1 = document.querySelector("#modal1") - let modal2 = document.querySelector("#modal2") - let close = document.querySelector("#close") + const modal1 = document.querySelector("#modal1") + const modal2 = document.querySelector("#modal2") + const close = document.querySelector("#close") modal1.addEventListener("close", (e) => { modal1Clicked = true @@ -484,14 +484,14 @@ describe("JS", () => { describe("exec_add_class and exec_remove_class", () => { test("with defaults", done => { - let view = setupView(` + const view = setupView(`
`) - let modal = simulateVisibility(document.querySelector("#modal")) - let add = document.querySelector("#add") - let remove = document.querySelector("#remove") + const modal = simulateVisibility(document.querySelector("#modal")) + const add = document.querySelector("#add") + const remove = document.querySelector("#remove") JS.exec(event, "click", add.getAttribute("phx-click"), view, add) JS.exec(event, "click", add.getAttribute("phx-click"), view, add) @@ -508,16 +508,16 @@ describe("JS", () => { }) test("with multiple selector", done => { - let view = setupView(` + const view = setupView(`
`) - let modal1 = document.querySelector("#modal1") - let modal2 = document.querySelector("#modal2") - let add = document.querySelector("#add") - let remove = document.querySelector("#remove") + const modal1 = document.querySelector("#modal1") + const modal2 = document.querySelector("#modal2") + const add = document.querySelector("#add") + const remove = document.querySelector("#remove") JS.exec(event, "click", add.getAttribute("phx-click"), view, add) jest.runAllTimers() @@ -536,12 +536,12 @@ describe("JS", () => { describe("exec_toggle_class", () => { test("with defaults", done => { - let view = setupView(` + const view = setupView(`
`) - let modal = simulateVisibility(document.querySelector("#modal")) - let toggle = document.querySelector("#toggle") + const modal = simulateVisibility(document.querySelector("#modal")) + const toggle = document.querySelector("#toggle") JS.exec(event, "click", toggle.getAttribute("phx-click"), view, toggle) jest.runAllTimers() @@ -556,14 +556,14 @@ describe("JS", () => { }) test("with multiple selector", done => { - let view = setupView(` + const view = setupView(`
`) - let modal1 = document.querySelector("#modal1") - let modal2 = document.querySelector("#modal2") - let toggle = document.querySelector("#toggle") + const modal1 = document.querySelector("#modal1") + const modal2 = document.querySelector("#modal2") + const toggle = document.querySelector("#toggle") JS.exec(event, "click", toggle.getAttribute("phx-click"), view, toggle) jest.runAllTimers() @@ -579,10 +579,10 @@ describe("JS", () => { }) test("with transition", done => { - let view = setupView(` + const view = setupView(` `) - let button = document.querySelector("button") + const button = document.querySelector("button") expect(Array.from(button.classList)).toEqual([]) @@ -600,11 +600,11 @@ describe("JS", () => { describe("push", () => { test("regular event", done => { - let view = setupView(` + const view = setupView(`
`) - let click = document.querySelector("#click") + const click = document.querySelector("#click") view.pushEvent = (eventType, sourceEl, targetCtx, event, meta) => { expect(eventType).toBe("click") expect(event).toBe("clicked") @@ -615,26 +615,26 @@ describe("JS", () => { }) test("form change event with JS command", done => { - let view = setupView(` + const view = setupView(`
`) - let form = document.querySelector("#my-form") - let input = document.querySelector("#username") + const form = document.querySelector("#my-form") + const input = document.querySelector("#username") view.pushInput = (sourceEl, targetCtx, newCid, phxEvent, {_target}, _callback) => { expect(phxEvent).toBe("validate") expect(sourceEl.isSameNode(input)).toBe(true) expect(_target).toBe(input.name) done() } - let args = ["push", {_target: input.name, dispatcher: input}] + const args = ["push", {_target: input.name, dispatcher: input}] JS.exec(event, "change", form.getAttribute("phx-change"), view, input, args) }) test("form change event with phx-value and JS command value", done => { - let view = setupView(` + const view = setupView(`
{
`) - let form = document.querySelector("#my-form") - let input = document.querySelector("#username") + const form = document.querySelector("#my-form") + const input = document.querySelector("#username") view.pushWithReply = (refGen, event, payload) => { expect(payload).toEqual({ "cid": null, @@ -664,18 +664,18 @@ describe("JS", () => { }) return Promise.resolve({resp: done()}) } - let args = ["push", {_target: input.name, dispatcher: input}] + const args = ["push", {_target: input.name, dispatcher: input}] JS.exec(event, "change", form.getAttribute("phx-change"), view, input, args) }) test("form change event prefers JS.push value over phx-value-* over input value", (done) => { - let view = setupView(` + const view = setupView(`
`) - let form = document.querySelector("#my-form") - let input = document.querySelector("#textField") + const form = document.querySelector("#my-form") + const input = document.querySelector("#textField") view.pushWithReply = (refGen, event, payload) => { expect(payload).toEqual({ "cid": null, @@ -690,18 +690,18 @@ describe("JS", () => { }) return Promise.resolve({resp: done()}) } - let args = ["push", {_target: input.name, dispatcher: input}] + const args = ["push", {_target: input.name, dispatcher: input}] JS.exec(event, "change", form.getAttribute("phx-change"), view, input, args) }) test("form change event prefers phx-value-* over input value", (done) => { - let view = setupView(` + const view = setupView(`
`) - let form = document.querySelector("#my-form") - let input = document.querySelector("#textField") + const form = document.querySelector("#my-form") + const input = document.querySelector("#textField") view.pushWithReply = (refGen, event, payload) => { expect(payload).toEqual({ "cid": null, @@ -716,23 +716,23 @@ describe("JS", () => { }) return Promise.resolve({resp: done()}) } - let args = ["push", {_target: input.name, dispatcher: input}] + const args = ["push", {_target: input.name, dispatcher: input}] JS.exec(event, "change", form.getAttribute("phx-change"), view, input, args) }) test("form change event with string event", done => { - let view = setupView(` + const view = setupView(`
`) - let form = document.querySelector("#my-form") - let input = document.querySelector("#username") - let oldPush = view.pushInput.bind(view) + const form = document.querySelector("#my-form") + const input = document.querySelector("#username") + const oldPush = view.pushInput.bind(view) view.pushInput = (sourceEl, targetCtx, newCid, phxEvent, opts, callback) => { - let {_target} = opts + const {_target} = opts expect(phxEvent).toBe("validate") expect(sourceEl.isSameNode(input)).toBe(true) expect(_target).toBe(input.name) @@ -749,22 +749,22 @@ describe("JS", () => { }) return Promise.resolve({resp: done()}) } - let args = ["push", {_target: input.name, dispatcher: input}] + const args = ["push", {_target: input.name, dispatcher: input}] JS.exec(event, "change", form.getAttribute("phx-change"), view, input, args) }) test("input change event with JS command", done => { - let view = setupView(` + const view = setupView(`
`) - let input = document.querySelector("#username1") - let oldPush = view.pushInput.bind(view) + const input = document.querySelector("#username1") + const oldPush = view.pushInput.bind(view) view.pushInput = (sourceEl, targetCtx, newCid, phxEvent, opts, callback) => { - let {_target} = opts + const {_target} = opts expect(phxEvent).toBe("username_changed") expect(sourceEl.isSameNode(input)).toBe(true) expect(_target).toBe(input.name) @@ -781,22 +781,22 @@ describe("JS", () => { }) return Promise.resolve({resp: done()}) } - let args = ["push", {_target: input.name, dispatcher: input}] + const args = ["push", {_target: input.name, dispatcher: input}] JS.exec(event, "change", input.getAttribute("phx-change"), view, input, args) }) test("input change event with string event", done => { - let view = setupView(` + const view = setupView(`
`) - let input = document.querySelector("#username") - let oldPush = view.pushInput.bind(view) + const input = document.querySelector("#username") + const oldPush = view.pushInput.bind(view) view.pushInput = (sourceEl, targetCtx, newCid, phxEvent, opts, callback) => { - let {_target} = opts + const {_target} = opts expect(phxEvent).toBe("username_changed") expect(sourceEl.isSameNode(input)).toBe(true) expect(_target).toBe(input.name) @@ -813,19 +813,19 @@ describe("JS", () => { }) return Promise.resolve({resp: done()}) } - let args = ["push", {_target: input.name, dispatcher: input}] + const args = ["push", {_target: input.name, dispatcher: input}] JS.exec(event, "change", input.getAttribute("phx-change"), view, input, args) }) test("submit event", done => { - let view = setupView(` + const view = setupView(`
`) - let form = document.querySelector("#my-form") + const form = document.querySelector("#my-form") view.pushWithReply = (refGen, event, payload) => { expect(payload).toEqual({ @@ -841,7 +841,7 @@ describe("JS", () => { }) test("submit event with phx-value and JS command value", done => { - let view = setupView(` + const view = setupView(`
{
`) - let form = document.querySelector("#my-form") + const form = document.querySelector("#my-form") view.pushWithReply = (refGen, event, payload) => { expect(payload).toEqual({ @@ -874,11 +874,11 @@ describe("JS", () => { }) test("page_loading", done => { - let view = setupView(` + const view = setupView(`
`) - let click = document.querySelector("#click") + const click = document.querySelector("#click") view.pushEvent = (eventType, sourceEl, targetCtx, event, meta, opts) => { expect(opts).toEqual({page_loading: true}) done() @@ -887,23 +887,23 @@ describe("JS", () => { }) test("loading", () => { - let view = setupView(` + const view = setupView(`
`) - let click = document.querySelector("#click") - let modal = document.getElementById("modal") + const click = document.querySelector("#click") + const modal = document.getElementById("modal") JS.exec(event, "click", click.getAttribute("phx-click"), view, click) expect(Array.from(modal.classList)).toEqual(["modal", "phx-click-loading"]) expect(Array.from(click.classList)).toEqual(["phx-click-loading"]) }) test("value", done => { - let view = setupView(` + const view = setupView(`
`) - let click = document.querySelector("#click") + const click = document.querySelector("#click") view.pushWithReply = (refGenerator, event, payload, _onReply) => { expect(payload.value).toEqual({"one": 1, "two": 2, "three": "3"}) @@ -915,12 +915,12 @@ describe("JS", () => { describe("multiple instructions", () => { test("push and toggle", done => { - let view = setupView(` + const view = setupView(`
`) - let modal = simulateVisibility(document.querySelector("#modal")) - let click = document.querySelector("#click") + const modal = simulateVisibility(document.querySelector("#modal")) + const click = document.querySelector("#click") view.pushEvent = (eventType, sourceEl, targetCtx, event, _data) => { expect(event).toEqual("clicked") @@ -937,14 +937,14 @@ describe("JS", () => { describe("exec_set_attr and exec_remove_attr", () => { test("with defaults", () => { - let view = setupView(` + const view = setupView(`
`) - let modal = document.querySelector("#modal") - let set = document.querySelector("#set") - let remove = document.querySelector("#remove") + const modal = document.querySelector("#modal") + const set = document.querySelector("#set") + const remove = document.querySelector("#remove") expect(modal.getAttribute("aria-expanded")).toEqual(null) JS.exec(event, "click", set.getAttribute("phx-click"), view, set) @@ -955,12 +955,12 @@ describe("JS", () => { }) test("with no selector", () => { - let view = setupView(` + const view = setupView(`
`) - let set = document.querySelector("#set") - let remove = document.querySelector("#remove") + const set = document.querySelector("#set") + const remove = document.querySelector("#remove") expect(set.getAttribute("aria-expanded")).toEqual(null) JS.exec(event, "click", set.getAttribute("phx-click"), view, set) @@ -972,12 +972,12 @@ describe("JS", () => { }) test("setting a pre-existing attribute updates its value", () => { - let view = setupView(` + const view = setupView(`
`) - let set = document.querySelector("#set") - let modal = document.querySelector("#modal") + const set = document.querySelector("#set") + const modal = document.querySelector("#modal") expect(modal.getAttribute("aria-expanded")).toEqual("false") JS.exec(event, "click", set.getAttribute("phx-click"), view, set) @@ -985,14 +985,14 @@ describe("JS", () => { }) test("setting a dynamically added attribute updates its value", () => { - let view = setupView(` + const view = setupView(`
`) - let setFalse = document.querySelector("#set-false") - let setTrue = document.querySelector("#set-true") - let modal = document.querySelector("#modal") + const setFalse = document.querySelector("#set-false") + const setTrue = document.querySelector("#set-true") + const modal = document.querySelector("#modal") expect(modal.getAttribute("aria-expanded")).toEqual(null) JS.exec(event, "click", setFalse.getAttribute("phx-click"), view, setFalse) @@ -1004,11 +1004,11 @@ describe("JS", () => { describe("exec", () => { test("executes command", done => { - let view = setupView(` + const view = setupView(`
`) - let click = document.querySelector("#click") + const click = document.querySelector("#click") view.pushEvent = (eventType, sourceEl, targetCtx, event, _meta) => { expect(eventType).toBe("exec") expect(event).toBe("clicked") @@ -1018,14 +1018,14 @@ describe("JS", () => { }) test("with no selector", () => { - let view = setupView(` + const view = setupView(`
`) - let click = document.querySelector("#click") + const click = document.querySelector("#click") expect(click.getAttribute("open")).toEqual(null) JS.exec(event, "click", click.getAttribute("phx-click"), view, click) @@ -1033,13 +1033,13 @@ describe("JS", () => { }) test("with to scope inner", () => { - let view = setupView(` + const view = setupView(`
`) - let modal = document.querySelector("#modal") - let click = document.querySelector("#click") + const modal = document.querySelector("#modal") + const click = document.querySelector("#click") expect(modal.getAttribute("open")).toEqual(null) JS.exec(event, "click", click.getAttribute("phx-click"), view, click) @@ -1047,13 +1047,13 @@ describe("JS", () => { }) test("with to scope closest", () => { - let view = setupView(` + const view = setupView(` `) - let modal = document.querySelector("#modal") - let click = document.querySelector("#click") + const modal = document.querySelector("#modal") + const click = document.querySelector("#click") expect(modal.getAttribute("open")).toEqual(null) JS.exec(event, "click", click.getAttribute("phx-click"), view, click) @@ -1061,14 +1061,14 @@ describe("JS", () => { }) test("with multiple selector", () => { - let view = setupView(` + const view = setupView(`
modal
modal
`) - let modal1 = document.querySelector("#modal1") - let modal2 = document.querySelector("#modal2") - let click = document.querySelector("#click") + const modal1 = document.querySelector("#modal1") + const modal2 = document.querySelector("#modal2") + const click = document.querySelector("#click") expect(modal1.getAttribute("open")).toEqual(null) expect(modal2.getAttribute("open")).toEqual("true") @@ -1080,12 +1080,12 @@ describe("JS", () => { describe("exec_toggle_attr", () => { test("with defaults", () => { - let view = setupView(` + const view = setupView(`
`) - let modal = document.querySelector("#modal") - let toggle = document.querySelector("#toggle") + const modal = document.querySelector("#modal") + const toggle = document.querySelector("#toggle") expect(modal.getAttribute("open")).toEqual(null) JS.exec(event, "click", toggle.getAttribute("phx-click"), view, toggle) @@ -1096,10 +1096,10 @@ describe("JS", () => { }) test("with no selector", () => { - let view = setupView(` + const view = setupView(`
`) - let toggle = document.querySelector("#toggle") + const toggle = document.querySelector("#toggle") expect(toggle.getAttribute("open")).toEqual(null) JS.exec(event, "click", toggle.getAttribute("phx-click"), view, toggle) @@ -1107,14 +1107,14 @@ describe("JS", () => { }) test("with multiple selector", () => { - let view = setupView(` + const view = setupView(`
modal
modal
`) - let modal1 = document.querySelector("#modal1") - let modal2 = document.querySelector("#modal2") - let toggle = document.querySelector("#toggle") + const modal1 = document.querySelector("#modal1") + const modal2 = document.querySelector("#modal2") + const toggle = document.querySelector("#toggle") expect(modal1.getAttribute("open")).toEqual(null) expect(modal2.getAttribute("open")).toEqual("true") @@ -1124,12 +1124,12 @@ describe("JS", () => { }) test("toggling a pre-existing attribute updates its value", () => { - let view = setupView(` + const view = setupView(`
`) - let toggle = document.querySelector("#toggle") - let modal = document.querySelector("#modal") + const toggle = document.querySelector("#toggle") + const modal = document.querySelector("#modal") expect(modal.getAttribute("open")).toEqual("true") JS.exec(event, "click", toggle.getAttribute("phx-click"), view, toggle) @@ -1137,14 +1137,14 @@ describe("JS", () => { }) test("toggling a dynamically added attribute updates its value", () => { - let view = setupView(` + const view = setupView(`
`) - let toggle1 = document.querySelector("#toggle1") - let toggle2 = document.querySelector("#toggle2") - let modal = document.querySelector("#modal") + const toggle1 = document.querySelector("#toggle1") + const toggle2 = document.querySelector("#toggle2") + const modal = document.querySelector("#modal") expect(modal.getAttribute("open")).toEqual(null) JS.exec(event, "click", toggle1.getAttribute("phx-click"), view, toggle1) @@ -1154,10 +1154,10 @@ describe("JS", () => { }) test("toggling between two values", () => { - let view = setupView(` + const view = setupView(`
`) - let toggle = document.querySelector("#toggle") + const toggle = document.querySelector("#toggle") JS.exec(event, "click", toggle.getAttribute("phx-click"), view, toggle) expect(toggle.getAttribute("aria-expanded")).toEqual("true") @@ -1168,18 +1168,18 @@ describe("JS", () => { describe("focus", () => { test("works like a stack", () => { - let view = setupView(` + const view = setupView(`
`) - let modal1 = document.querySelector("#modal1") - let modal2 = document.querySelector("#modal2") - let push1 = document.querySelector("#push1") - let push2 = document.querySelector("#push2") - let pop = document.querySelector("#pop") + const modal1 = document.querySelector("#modal1") + const modal2 = document.querySelector("#modal2") + const push1 = document.querySelector("#push1") + const push2 = document.querySelector("#push2") + const pop = document.querySelector("#pop") JS.exec(event, "click", push1.getAttribute("phx-click"), view, push1) JS.exec(event, "click", push2.getAttribute("phx-click"), view, push2) @@ -1196,7 +1196,7 @@ describe("JS", () => { describe("exec_focus_first", () => { test("focuses div with tabindex 0", () => { - let view = setupView(` + const view = setupView(`
@@ -1204,8 +1204,8 @@ describe("JS", () => {
`) - let modal2 = document.querySelector("#modal2") - let push = document.querySelector("#push") + const modal2 = document.querySelector("#modal2") + const push = document.querySelector("#push") JS.exec(event, "click", push.getAttribute("phx-click"), view, push) diff --git a/assets/test/live_socket_test.js b/assets/test/live_socket_test.js index e4dce3ec51..7a15092c23 100644 --- a/assets/test/live_socket_test.js +++ b/assets/test/live_socket_test.js @@ -3,9 +3,9 @@ import LiveSocket from "phoenix_live_view/live_socket" import JS from "phoenix_live_view/js" import {simulateJoinedView, simulateVisibility} from "./test_helpers" -let container = (num) => global.document.getElementById(`container${num}`) +const container = (num) => global.document.getElementById(`container${num}`) -let prepareLiveViewDOM = (document) => { +const prepareLiveViewDOM = (document) => { const div = document.createElement("div") div.setAttribute("data-phx-session", "abc123") div.setAttribute("data-phx-root-id", "container1") @@ -35,7 +35,7 @@ describe("LiveSocket", () => { }) test("sets defaults", async () => { - let liveSocket = new LiveSocket("/live", Socket) + const liveSocket = new LiveSocket("/live", Socket) expect(liveSocket.socket).toBeDefined() expect(liveSocket.socket.onOpen).toBeDefined() expect(liveSocket.viewLogger).toBeUndefined() @@ -45,7 +45,7 @@ describe("LiveSocket", () => { }) test("sets defaults with socket", async () => { - let liveSocket = new LiveSocket(new Socket("//example.org/chat"), Socket) + const liveSocket = new LiveSocket(new Socket("//example.org/chat"), Socket) expect(liveSocket.socket).toBeDefined() expect(liveSocket.socket.onOpen).toBeDefined() expect(liveSocket.unloaded).toBe(false) @@ -54,27 +54,27 @@ describe("LiveSocket", () => { }) test("viewLogger", async () => { - let viewLogger = (view, kind, msg, obj) => { + const viewLogger = (view, kind, msg, obj) => { expect(view.id).toBe("container1") expect(kind).toBe("updated") expect(msg).toBe("") expect(obj).toBe("\"
\"") } - let liveSocket = new LiveSocket("/live", Socket, {viewLogger}) + const liveSocket = new LiveSocket("/live", Socket, {viewLogger}) expect(liveSocket.viewLogger).toBe(viewLogger) liveSocket.connect() - let view = liveSocket.getViewByEl(container(1)) + const view = liveSocket.getViewByEl(container(1)) liveSocket.log(view, "updated", () => ["", JSON.stringify("
")]) }) test("connect", async () => { - let liveSocket = new LiveSocket("/live", Socket) - let _socket = liveSocket.connect() + const liveSocket = new LiveSocket("/live", Socket) + const _socket = liveSocket.connect() expect(liveSocket.getViewByEl(container(1))).toBeDefined() }) test("disconnect", async () => { - let liveSocket = new LiveSocket("/live", Socket) + const liveSocket = new LiveSocket("/live", Socket) liveSocket.connect() liveSocket.disconnect() @@ -83,10 +83,10 @@ describe("LiveSocket", () => { }) test("channel", async () => { - let liveSocket = new LiveSocket("/live", Socket) + const liveSocket = new LiveSocket("/live", Socket) liveSocket.connect() - let channel = liveSocket.channel("lv:def456", () => { + const channel = liveSocket.channel("lv:def456", () => { return {session: this.getSession()} }) @@ -94,7 +94,7 @@ describe("LiveSocket", () => { }) test("getViewByEl", async () => { - let liveSocket = new LiveSocket("/live", Socket) + const liveSocket = new LiveSocket("/live", Socket) liveSocket.connect() @@ -113,10 +113,10 @@ describe("LiveSocket", () => { ` document.body.appendChild(secondLiveView) - let liveSocket = new LiveSocket("/live", Socket) + const liveSocket = new LiveSocket("/live", Socket) liveSocket.connect() - let el = container(1) + const el = container(1) expect(liveSocket.getViewByEl(el)).toBeDefined() liveSocket.destroyAllViews() @@ -129,48 +129,48 @@ describe("LiveSocket", () => { }) test("binding", async () => { - let liveSocket = new LiveSocket("/live", Socket) + const liveSocket = new LiveSocket("/live", Socket) expect(liveSocket.binding("value")).toBe("phx-value") }) test("getBindingPrefix", async () => { - let liveSocket = new LiveSocket("/live", Socket) + const liveSocket = new LiveSocket("/live", Socket) expect(liveSocket.getBindingPrefix()).toEqual("phx-") }) test("getBindingPrefix custom", async () => { - let liveSocket = new LiveSocket("/live", Socket, {bindingPrefix: "company-"}) + const liveSocket = new LiveSocket("/live", Socket, {bindingPrefix: "company-"}) expect(liveSocket.getBindingPrefix()).toEqual("company-") }) test("owner", async () => { - let liveSocket = new LiveSocket("/live", Socket) + const liveSocket = new LiveSocket("/live", Socket) liveSocket.connect() - let _view = liveSocket.getViewByEl(container(1)) - let btn = document.querySelector("button") - let _callback = (view) => { + const _view = liveSocket.getViewByEl(container(1)) + const btn = document.querySelector("button") + const _callback = (view) => { expect(view.id).toBe(view.id) } liveSocket.owner(btn, (view) => view.id) }) test("getActiveElement default before LiveSocket activeElement is set", async () => { - let liveSocket = new LiveSocket("/live", Socket) + const liveSocket = new LiveSocket("/live", Socket) - let input = document.querySelector("input") + const input = document.querySelector("input") input.focus() expect(liveSocket.getActiveElement()).toEqual(input) }) test("blurActiveElement", async () => { - let liveSocket = new LiveSocket("/live", Socket) + const liveSocket = new LiveSocket("/live", Socket) - let input = document.querySelector("input") + const input = document.querySelector("input") input.focus() expect(liveSocket.prevActive).toBeNull() @@ -182,9 +182,9 @@ describe("LiveSocket", () => { }) test("restorePreviouslyActiveFocus", async () => { - let liveSocket = new LiveSocket("/live", Socket) + const liveSocket = new LiveSocket("/live", Socket) - let input = document.querySelector("input") + const input = document.querySelector("input") input.focus() liveSocket.blurActiveElement() @@ -199,16 +199,16 @@ describe("LiveSocket", () => { }) test("dropActiveElement unsets prevActive", async () => { - let liveSocket = new LiveSocket("/live", Socket) + const liveSocket = new LiveSocket("/live", Socket) liveSocket.connect() - let input = document.querySelector("input") + const input = document.querySelector("input") input.focus() liveSocket.blurActiveElement() expect(liveSocket.prevActive).toEqual(input) - let view = liveSocket.getViewByEl(container(1)) + const view = liveSocket.getViewByEl(container(1)) liveSocket.dropActiveElement(view) expect(liveSocket.prevActive).toBeNull() // this fails. Is this correct? @@ -217,11 +217,11 @@ describe("LiveSocket", () => { test("storage can be overridden", async () => { let getItemCalls = 0 - let override = { + const override = { getItem: function (_keyName){ getItemCalls = getItemCalls + 1 } } - let liveSocket = new LiveSocket("/live", Socket, {sessionStorage: override}) + const liveSocket = new LiveSocket("/live", Socket, {sessionStorage: override}) liveSocket.getLatencySim() // liveSocket constructor reads nav history position from sessionStorage diff --git a/assets/test/modify_root_test.js b/assets/test/modify_root_test.js index 390fc96299..b5def9196c 100644 --- a/assets/test/modify_root_test.js +++ b/assets/test/modify_root_test.js @@ -3,12 +3,12 @@ import {modifyRoot} from "phoenix_live_view/rendered" describe("modifyRoot stripping comments", () => { test("starting comments", () => { // starting comments - let html = ` + const html = `
` - let [strippedHTML, commentBefore, commentAfter] = modifyRoot(html, {}) + const [strippedHTML, commentBefore, commentAfter] = modifyRoot(html, {}) expect(strippedHTML).toEqual("
MENU
") expect(commentBefore).toEqual(` @@ -19,11 +19,11 @@ describe("modifyRoot stripping comments", () => { }) test("ending comments", () => { - let html = ` + const html = `
` - let [strippedHTML, commentBefore, commentAfter] = modifyRoot(html, {}) + const [strippedHTML, commentBefore, commentAfter] = modifyRoot(html, {}) expect(strippedHTML).toEqual("
MENU
") expect(commentBefore).toEqual(` `) @@ -33,12 +33,12 @@ describe("modifyRoot stripping comments", () => { }) test("starting and ending comments", () => { - let html = ` + const html = `
` - let [strippedHTML, commentBefore, commentAfter] = modifyRoot(html, {}) + const [strippedHTML, commentBefore, commentAfter] = modifyRoot(html, {}) expect(strippedHTML).toEqual("
MENU
") expect(commentBefore).toEqual(` @@ -49,7 +49,7 @@ describe("modifyRoot stripping comments", () => { }) test("merges new attrs", () => { - let html = ` + const html = `
` expect(modifyRoot(html, {id: 123})[0]).toEqual("
MENU
") @@ -57,14 +57,14 @@ describe("modifyRoot stripping comments", () => { // clearing innerHTML expect(modifyRoot(html, {id: 123, another: ""}, true)[0]).toEqual("
") // self closing - let selfClose = ` + const selfClose = ` ` expect(modifyRoot(selfClose, {id: 123, another: ""})[0]).toEqual("") }) test("mixed whitespace", () => { - let html = ` + const html = `
` @@ -114,7 +114,7 @@ ${"\t"}class="px-5">
`) "data-phx-skip": true } - let [strippedHTML, _commentBefore, _commentAfter] = modifyRoot(html, attrs, true) + const [strippedHTML, _commentBefore, _commentAfter] = modifyRoot(html, attrs, true) expect(strippedHTML).toEqual("
") }) diff --git a/assets/test/rendered_test.js b/assets/test/rendered_test.js index 59d64ad688..293c900c47 100644 --- a/assets/test/rendered_test.js +++ b/assets/test/rendered_test.js @@ -8,11 +8,11 @@ const TEMPLATES = "p" describe("Rendered", () => { describe("mergeDiff", () => { test("recursively merges two diffs", () => { - let simple = new Rendered("123", simpleDiff1) + const simple = new Rendered("123", simpleDiff1) simple.mergeDiff(simpleDiff2) expect(simple.get()).toEqual({...simpleDiffResult, [COMPONENTS]: {}, newRender: true}) - let deep = new Rendered("123", deepDiff1) + const deep = new Rendered("123", deepDiff1) deep.mergeDiff(deepDiff2) expect(deep.get()).toEqual({...deepDiffResult, [COMPONENTS]: {}}) }) @@ -20,7 +20,7 @@ describe("Rendered", () => { test("merges the latter diff if it contains a `static` key", () => { const diff1 = {0: ["a"], 1: ["b"]} const diff2 = {0: ["c"], [STATIC]: ["c"]} - let rendered = new Rendered("123", diff1) + const rendered = new Rendered("123", diff1) rendered.mergeDiff(diff2) expect(rendered.get()).toEqual({...diff2, [COMPONENTS]: {}}) }) @@ -28,7 +28,7 @@ describe("Rendered", () => { test("merges the latter diff if it contains a `static` key even when nested", () => { const diff1 = {0: {0: ["a"], 1: ["b"]}} const diff2 = {0: {0: ["c"], [STATIC]: ["c"]}} - let rendered = new Rendered("123", diff1) + const rendered = new Rendered("123", diff1) rendered.mergeDiff(diff2) expect(rendered.get()).toEqual({...diff2, [COMPONENTS]: {}}) }) @@ -36,7 +36,7 @@ describe("Rendered", () => { test("merges components considering links", () => { const diff1 = {} const diff2 = {[COMPONENTS]: {1: {[STATIC]: ["c"]}, 2: {[STATIC]: 1}}} - let rendered = new Rendered("123", diff1) + const rendered = new Rendered("123", diff1) rendered.mergeDiff(diff2) expect(rendered.get()).toEqual({[COMPONENTS]: {1: {[STATIC]: ["c"]}, 2: {[STATIC]: ["c"]}}}) }) @@ -44,7 +44,7 @@ describe("Rendered", () => { test("merges components considering old and new links", () => { const diff1 = {[COMPONENTS]: {1: {[STATIC]: ["old"]}}} const diff2 = {[COMPONENTS]: {1: {[STATIC]: ["new"]}, 2: {newRender: true, [STATIC]: -1}, 3: {newRender: true, [STATIC]: 1}}} - let rendered = new Rendered("123", diff1) + const rendered = new Rendered("123", diff1) rendered.mergeDiff(diff2) expect(rendered.get()).toEqual({ [COMPONENTS]: { @@ -68,7 +68,7 @@ describe("Rendered", () => { } } - let rendered1 = new Rendered("123", diff1) + const rendered1 = new Rendered("123", diff1) rendered1.mergeDiff(diff2) expect(rendered1.get()).toEqual({ [COMPONENTS]: { @@ -90,7 +90,7 @@ describe("Rendered", () => { } } - let rendered2 = new Rendered("123", diff1) + const rendered2 = new Rendered("123", diff1) rendered2.mergeDiff(diff3) expect(rendered2.get()).toEqual({ [COMPONENTS]: { @@ -106,7 +106,7 @@ describe("Rendered", () => { test("replaces a string when a map is returned", () => { const diff1 = {0: {0: "", [STATIC]: ""}} const diff2 = {0: {0: {0: "val", [STATIC]: ""}, [STATIC]: ""}} - let rendered = new Rendered("123", diff1) + const rendered = new Rendered("123", diff1) rendered.mergeDiff(diff2) expect(rendered.get()).toEqual({...diff2, [COMPONENTS]: {}}) }) @@ -114,7 +114,7 @@ describe("Rendered", () => { test("replaces a map when a string is returned", () => { const diff1 = {0: {0: {0: "val", [STATIC]: ""}, [STATIC]: ""}} const diff2 = {0: {0: "", [STATIC]: ""}} - let rendered = new Rendered("123", diff1) + const rendered = new Rendered("123", diff1) rendered.mergeDiff(diff2) expect(rendered.get()).toEqual({...diff2, [COMPONENTS]: {}}) }) @@ -184,10 +184,10 @@ describe("Rendered", () => { } } - let rendered = new Rendered("123", mountDiff) + const rendered = new Rendered("123", mountDiff) expect(rendered.getComponent(rendered.get(), 1)[STATIC]).toEqual(rendered.getComponent(rendered.get(), 2)[STATIC]) rendered.mergeDiff(updateDiff) - let sharedStatic = rendered.getComponent(rendered.get(), 1)[STATIC] + const sharedStatic = rendered.getComponent(rendered.get(), 1)[STATIC] expect(sharedStatic).toBeTruthy() expect(sharedStatic).toEqual(rendered.getComponent(rendered.get(), 2)[STATIC]) @@ -198,26 +198,26 @@ describe("Rendered", () => { describe("isNewFingerprint", () => { test("returns true if `diff.static` is truthy", () => { const diff = {[STATIC]: ["

"]} - let rendered = new Rendered("123", {}) + const rendered = new Rendered("123", {}) expect(rendered.isNewFingerprint(diff)).toEqual(true) }) test("returns false if `diff.static` is falsy", () => { const diff = {[STATIC]: undefined} - let rendered = new Rendered("123", {}) + const rendered = new Rendered("123", {}) expect(rendered.isNewFingerprint(diff)).toEqual(false) }) test("returns false if `diff` is undefined", () => { - let rendered = new Rendered("123", {}) + const rendered = new Rendered("123", {}) expect(rendered.isNewFingerprint()).toEqual(false) }) }) describe("toString", () => { test("stringifies a diff", () => { - let rendered = new Rendered("123", simpleDiffResult) - let [str, _streams] = rendered.toString() + const rendered = new Rendered("123", simpleDiffResult) + const [str, _streams] = rendered.toString() expect(str.trim()).toEqual( `
@@ -228,8 +228,8 @@ describe("Rendered", () => { }) test("reuses static in components and comprehensions", () => { - let rendered = new Rendered("123", staticReuseDiff) - let [str, _streams] = rendered.toString() + const rendered = new Rendered("123", staticReuseDiff) + const [str, _streams] = rendered.toString() expect(str.trim()).toEqual( `

diff --git a/assets/test/test_helpers.js b/assets/test/test_helpers.js index e2d22639fd..04333e12a8 100644 --- a/assets/test/test_helpers.js +++ b/assets/test/test_helpers.js @@ -1,10 +1,10 @@ import View from "phoenix_live_view/view" import {version as liveview_version} from "../../package.json" -export let appendTitle = (opts, innerHTML) => { +export const appendTitle = (opts, innerHTML) => { Array.from(document.head.querySelectorAll("title")).forEach(el => el.remove()) - let title = document.createElement("title") - let {prefix, suffix, default: defaultTitle} = opts + const title = document.createElement("title") + const {prefix, suffix, default: defaultTitle} = opts if(prefix){ title.setAttribute("data-prefix", prefix) } if(suffix){ title.setAttribute("data-suffix", suffix) } if(defaultTitle){ @@ -16,21 +16,21 @@ export let appendTitle = (opts, innerHTML) => { document.head.appendChild(title) } -export let rootContainer = (content) => { - let div = tag("div", {id: "root"}, content) +export const rootContainer = (content) => { + const div = tag("div", {id: "root"}, content) document.body.appendChild(div) return div } -export let tag = (tagName, attrs, innerHTML) => { - let el = document.createElement(tagName) +export const tag = (tagName, attrs, innerHTML) => { + const el = document.createElement(tagName) el.innerHTML = innerHTML - for(let key in attrs){ el.setAttribute(key, attrs[key]) } + for(const key in attrs){ el.setAttribute(key, attrs[key]) } return el } -export let simulateJoinedView = (el, liveSocket) => { - let view = new View(el, liveSocket) +export const simulateJoinedView = (el, liveSocket) => { + const view = new View(el, liveSocket) stubChannel(view) liveSocket.roots[view.id] = view view.isConnected = () => true @@ -38,17 +38,17 @@ export let simulateJoinedView = (el, liveSocket) => { return view } -export let simulateVisibility = el => { +export const simulateVisibility = el => { el.getClientRects = () => { - let style = window.getComputedStyle(el) - let visible = !(style.opacity === 0 || style.display === "none") + const style = window.getComputedStyle(el) + const visible = !(style.opacity === 0 || style.display === "none") return visible ? {length: 1} : {length: 0} } return el } -export let stubChannel = view => { - let fakePush = { +export const stubChannel = view => { + const fakePush = { receives: [], receive(kind, cb){ this.receives.push([kind, cb]) diff --git a/assets/test/utils_test.js b/assets/test/utils_test.js index 427f365618..dade303ab9 100644 --- a/assets/test/utils_test.js +++ b/assets/test/utils_test.js @@ -3,33 +3,33 @@ import {closestPhxBinding} from "phoenix_live_view/utils" import LiveSocket from "phoenix_live_view/live_socket" import {simulateJoinedView, liveViewDOM} from "./test_helpers" -let setupView = (content) => { - let el = liveViewDOM(content) +const setupView = (content) => { + const el = liveViewDOM(content) global.document.body.appendChild(el) - let liveSocket = new LiveSocket("/live", Socket) + const liveSocket = new LiveSocket("/live", Socket) return simulateJoinedView(el, liveSocket) } describe("utils", () => { describe("closestPhxBinding", () => { test("if an element's parent has a phx-click binding and is not disabled, return the parent", () => { - let _view = setupView(` + const _view = setupView(` `) - let element = global.document.querySelector("#innerContent") - let parent = global.document.querySelector("#button") + const element = global.document.querySelector("#innerContent") + const parent = global.document.querySelector("#button") expect(closestPhxBinding(element, "phx-click")).toBe(parent) }) test("if an element's parent is disabled, return null", () => { - let _view = setupView(` + const _view = setupView(` `) - let element = global.document.querySelector("#innerContent") + const element = global.document.querySelector("#innerContent") expect(closestPhxBinding(element, "phx-click")).toBe(null) }) }) diff --git a/assets/test/view_test.js b/assets/test/view_test.js index 5df3825b21..d9faa38bb9 100644 --- a/assets/test/view_test.js +++ b/assets/test/view_test.js @@ -2,6 +2,7 @@ import {Socket} from "phoenix" import {LiveSocket, createHook} from "phoenix_live_view/index" import DOM from "phoenix_live_view/dom" import View from "phoenix_live_view/view" +import ViewHook from "phoenix_live_view/view_hook" import {version as liveview_version} from "../../package.json" @@ -14,7 +15,7 @@ import { import {tag, simulateJoinedView, stubChannel, rootContainer, liveViewDOM, simulateVisibility, appendTitle} from "./test_helpers" -let simulateUsedInput = (input) => { +const simulateUsedInput = (input) => { DOM.putPrivate(input, PHX_HAS_FOCUSED, true) } @@ -30,14 +31,14 @@ describe("View + DOM", function(){ }) test("update", async () => { - let liveSocket = new LiveSocket("/live", Socket) - let el = liveViewDOM() - let updateDiff = { + const liveSocket = new LiveSocket("/live", Socket) + const el = liveViewDOM() + const updateDiff = { s: ["

", "

"], fingerprint: 123 } - let view = simulateJoinedView(el, liveSocket) + const view = simulateJoinedView(el, liveSocket) view.update(updateDiff, []) expect(view.el.firstChild.tagName).toBe("H2") @@ -47,16 +48,16 @@ describe("View + DOM", function(){ test("applyDiff with empty title uses default if present", async () => { appendTitle({}, "Foo") - let titleEl = document.querySelector("title") - let liveSocket = new LiveSocket("/live", Socket) - let el = liveViewDOM() - let updateDiff = { + const titleEl = document.querySelector("title") + const liveSocket = new LiveSocket("/live", Socket) + const el = liveViewDOM() + const updateDiff = { s: ["

", "

"], fingerprint: 123, t: "" } - let view = simulateJoinedView(el, liveSocket) + const view = simulateJoinedView(el, liveSocket) view.applyDiff("mount", updateDiff, ({diff, events}) => view.update(diff, events)) expect(view.el.firstChild.tagName).toBe("H2") @@ -73,11 +74,11 @@ describe("View + DOM", function(){ test("pushWithReply", function(){ expect.assertions(1) - let liveSocket = new LiveSocket("/live", Socket) - let el = liveViewDOM() + const liveSocket = new LiveSocket("/live", Socket) + const el = liveViewDOM() - let view = simulateJoinedView(el, liveSocket) - let channelStub = { + const view = simulateJoinedView(el, liveSocket) + const channelStub = { push(_evt, payload, _timeout){ expect(payload.value).toBe("increment=1") return { @@ -91,11 +92,11 @@ describe("View + DOM", function(){ }) test("pushWithReply with update", function(){ - let liveSocket = new LiveSocket("/live", Socket) - let el = liveViewDOM() + const liveSocket = new LiveSocket("/live", Socket) + const el = liveViewDOM() - let view = simulateJoinedView(el, liveSocket) - let channelStub = { + const view = simulateJoinedView(el, liveSocket) + const channelStub = { leave(){ return { receive(_status, _cb){ return this } @@ -105,7 +106,7 @@ describe("View + DOM", function(){ expect(payload.value).toBe("increment=1") return { receive(_status, cb){ - let diff = { + const diff = { s: ["

", "

"], fingerprint: 123 } @@ -125,12 +126,12 @@ describe("View + DOM", function(){ test("pushEvent", function(){ expect.assertions(3) - let liveSocket = new LiveSocket("/live", Socket) - let el = liveViewDOM() - let input = el.querySelector("input") + const liveSocket = new LiveSocket("/live", Socket) + const el = liveViewDOM() + const input = el.querySelector("input") - let view = simulateJoinedView(el, liveSocket) - let channelStub = { + const view = simulateJoinedView(el, liveSocket) + const channelStub = { push(_evt, payload, _timeout){ expect(payload.type).toBe("keyup") expect(payload.event).toBeDefined() @@ -148,12 +149,12 @@ describe("View + DOM", function(){ test("pushEvent as checkbox not checked", function(){ expect.assertions(1) - let liveSocket = new LiveSocket("/live", Socket) - let el = liveViewDOM() - let input = el.querySelector("input[type=\"checkbox\"]") + const liveSocket = new LiveSocket("/live", Socket) + const el = liveViewDOM() + const input = el.querySelector("input[type=\"checkbox\"]") - let view = simulateJoinedView(el, liveSocket) - let channelStub = { + const view = simulateJoinedView(el, liveSocket) + const channelStub = { push(_evt, payload, _timeout){ expect(payload.value).toEqual({}) return { @@ -169,14 +170,14 @@ describe("View + DOM", function(){ test("pushEvent as checkbox when checked", function(){ expect.assertions(1) - let liveSocket = new LiveSocket("/live", Socket) - let el = liveViewDOM() - let input = el.querySelector("input[type=\"checkbox\"]") - let view = simulateJoinedView(el, liveSocket) + const liveSocket = new LiveSocket("/live", Socket) + const el = liveViewDOM() + const input = el.querySelector("input[type=\"checkbox\"]") + const view = simulateJoinedView(el, liveSocket) input.checked = true - let channelStub = { + const channelStub = { push(_evt, payload, _timeout){ expect(payload.value).toEqual({"value": "on"}) return { @@ -192,15 +193,15 @@ describe("View + DOM", function(){ test("pushEvent as checkbox with value", function(){ expect.assertions(1) - let liveSocket = new LiveSocket("/live", Socket) - let el = liveViewDOM() - let input = el.querySelector("input[type=\"checkbox\"]") - let view = simulateJoinedView(el, liveSocket) + const liveSocket = new LiveSocket("/live", Socket) + const el = liveViewDOM() + const input = el.querySelector("input[type=\"checkbox\"]") + const view = simulateJoinedView(el, liveSocket) input.value = "1" input.checked = true - let channelStub = { + const channelStub = { push(_evt, payload, _timeout){ expect(payload.value).toEqual({"value": "1"}) return { @@ -216,12 +217,12 @@ describe("View + DOM", function(){ test("pushInput", function(){ expect.assertions(4) - let liveSocket = new LiveSocket("/live", Socket) - let el = liveViewDOM() - let input = el.querySelector("input") + const liveSocket = new LiveSocket("/live", Socket) + const el = liveViewDOM() + const input = el.querySelector("input") simulateUsedInput(input) - let view = simulateJoinedView(el, liveSocket) - let channelStub = { + const view = simulateJoinedView(el, liveSocket) + const channelStub = { push(_evt, payload, _timeout){ expect(payload.type).toBe("form") expect(payload.event).toBeDefined() @@ -240,8 +241,8 @@ describe("View + DOM", function(){ test("pushInput with with phx-value and JS command value", function(){ expect.assertions(4) - let liveSocket = new LiveSocket("/live", Socket) - let el = liveViewDOM(` + const liveSocket = new LiveSocket("/live", Socket) + const el = liveViewDOM(`
@@ -250,10 +251,10 @@ describe("View + DOM", function(){
`) - let input = el.querySelector("input") + const input = el.querySelector("input") simulateUsedInput(input) - let view = simulateJoinedView(el, liveSocket) - let channelStub = { + const view = simulateJoinedView(el, liveSocket) + const channelStub = { push(_evt, payload, _timeout){ expect(payload.type).toBe("form") expect(payload.event).toBeDefined() @@ -272,20 +273,20 @@ describe("View + DOM", function(){ } } view.channel = channelStub - let optValue = {nested: {command_value: "command", array: [1, 2]}} + const optValue = {nested: {command_value: "command", array: [1, 2]}} view.pushInput(input, el, null, "validate", {_target: input.name, value: optValue}) }) test("pushInput with nameless input", function(){ expect.assertions(4) - let liveSocket = new LiveSocket("/live", Socket) - let el = liveViewDOM() - let input = el.querySelector("input") + const liveSocket = new LiveSocket("/live", Socket) + const el = liveViewDOM() + const input = el.querySelector("input") input.removeAttribute("name") simulateUsedInput(input) - let view = simulateJoinedView(el, liveSocket) - let channelStub = { + const view = simulateJoinedView(el, liveSocket) + const channelStub = { push(_evt, payload, _timeout){ expect(payload.type).toBe("form") expect(payload.event).toBeDefined() @@ -342,12 +343,12 @@ describe("View + DOM", function(){ test("submits payload", function(){ expect.assertions(3) - let liveSocket = new LiveSocket("/live", Socket) - let el = liveViewDOM() - let form = el.querySelector("form") + const liveSocket = new LiveSocket("/live", Socket) + const el = liveViewDOM() + const form = el.querySelector("form") - let view = simulateJoinedView(el, liveSocket) - let channelStub = { + const view = simulateJoinedView(el, liveSocket) + const channelStub = { push(_evt, payload, _timeout){ expect(payload.type).toBe("form") expect(payload.event).toBeDefined() @@ -364,8 +365,8 @@ describe("View + DOM", function(){ test("payload includes phx-value and JS command value", function(){ expect.assertions(4) - let liveSocket = new LiveSocket("/live", Socket) - let el = liveViewDOM(` + const liveSocket = new LiveSocket("/live", Socket) + const el = liveViewDOM(`
@@ -374,10 +375,10 @@ describe("View + DOM", function(){
`) - let form = el.querySelector("form") + const form = el.querySelector("form") - let view = simulateJoinedView(el, liveSocket) - let channelStub = { + const view = simulateJoinedView(el, liveSocket) + const channelStub = { push(_evt, payload, _timeout){ expect(payload.type).toBe("form") expect(payload.event).toBeDefined() @@ -395,12 +396,12 @@ describe("View + DOM", function(){ } } view.channel = channelStub - let opts = {value: {nested: {command_value: "command", array: [1, 2]}}} + const opts = {value: {nested: {command_value: "command", array: [1, 2]}}} view.submitForm(form, form, {target: form}, undefined, opts) }) test("payload includes submitter when name is provided", function(){ - let btn = document.createElement("button") + const btn = document.createElement("button") btn.setAttribute("type", "submit") btn.setAttribute("name", "btnName") btn.setAttribute("value", "btnValue") @@ -408,7 +409,7 @@ describe("View + DOM", function(){ }) test("payload includes submitter when name is provided (submitter outside form)", function(){ - let btn = document.createElement("button") + const btn = document.createElement("button") btn.setAttribute("form", "my-form") btn.setAttribute("type", "submit") btn.setAttribute("name", "btnName") @@ -417,24 +418,24 @@ describe("View + DOM", function(){ }) test("payload does not include submitter when name is not provided", function(){ - let btn = document.createElement("button") + const btn = document.createElement("button") btn.setAttribute("type", "submit") btn.setAttribute("value", "btnValue") submitWithButton(btn, "increment=1¬e=2") }) function submitWithButton(btn, queryString, appendTo, opts={}){ - let liveSocket = new LiveSocket("/live", Socket) - let el = liveViewDOM() - let form = el.querySelector("form") + const liveSocket = new LiveSocket("/live", Socket) + const el = liveViewDOM() + const form = el.querySelector("form") if(appendTo){ appendTo.appendChild(btn) } else { form.appendChild(btn) } - let view = simulateJoinedView(el, liveSocket) - let channelStub = { + const view = simulateJoinedView(el, liveSocket) + const channelStub = { push(_evt, payload, _timeout){ expect(payload.type).toBe("form") expect(payload.event).toBeDefined() @@ -450,11 +451,11 @@ describe("View + DOM", function(){ } test("disables elements after submission", function(){ - let liveSocket = new LiveSocket("/live", Socket) - let el = liveViewDOM() - let form = el.querySelector("form") + const liveSocket = new LiveSocket("/live", Socket) + const el = liveViewDOM() + const form = el.querySelector("form") - let view = simulateJoinedView(el, liveSocket) + const view = simulateJoinedView(el, liveSocket) stubChannel(view) view.submitForm(form, form, {target: form}) @@ -469,8 +470,8 @@ describe("View + DOM", function(){ }) test("disables elements outside form", function(){ - let liveSocket = new LiveSocket("/live", Socket) - let el = liveViewDOM(` + const liveSocket = new LiveSocket("/live", Socket) + const el = liveViewDOM(`
@@ -479,9 +480,9 @@ describe("View + DOM", function(){ `) - let form = el.querySelector("form") + const form = el.querySelector("form") - let view = simulateJoinedView(el, liveSocket) + const view = simulateJoinedView(el, liveSocket) stubChannel(view) view.submitForm(form, form, {target: form}) @@ -493,13 +494,13 @@ describe("View + DOM", function(){ }) test("disables elements", function(){ - let liveSocket = new LiveSocket("/live", Socket) - let el = liveViewDOM(` + const liveSocket = new LiveSocket("/live", Socket) + const el = liveViewDOM(` `) - let button = el.querySelector("button") + const button = el.querySelector("button") - let view = simulateJoinedView(el, liveSocket) + const view = simulateJoinedView(el, liveSocket) stubChannel(view) expect(button.disabled).toEqual(false) @@ -510,18 +511,18 @@ describe("View + DOM", function(){ describe("phx-trigger-action", () => { test("triggers external submit on updated DOM el", (done) => { - let liveSocket = new LiveSocket("/live", Socket) - let el = liveViewDOM() - let view = simulateJoinedView(el, liveSocket) - let html = "
" + const liveSocket = new LiveSocket("/live", Socket) + const el = liveViewDOM() + const view = simulateJoinedView(el, liveSocket) + const html = "
" stubChannel(view) view.onJoin({rendered: {s: [html], fingerprint: 123}, liveview_version}) expect(view.el.innerHTML).toBe(html) - let formEl = document.getElementById("form") + const formEl = document.getElementById("form") Object.getPrototypeOf(formEl).submit = done - let updatedHtml = "
" + const updatedHtml = "
" view.update({s: [updatedHtml]}, []) expect(liveSocket.socket.closeWasClean).toBe(true) @@ -529,17 +530,17 @@ describe("View + DOM", function(){ }) test("triggers external submit on added DOM el", (done) => { - let liveSocket = new LiveSocket("/live", Socket) - let el = liveViewDOM() - let view = simulateJoinedView(el, liveSocket) - let html = "
not a form
" + const liveSocket = new LiveSocket("/live", Socket) + const el = liveViewDOM() + const view = simulateJoinedView(el, liveSocket) + const html = "
not a form
" HTMLFormElement.prototype.submit = done stubChannel(view) view.onJoin({rendered: {s: [html], fingerprint: 123}, liveview_version}) expect(view.el.innerHTML).toBe(html) - let updatedHtml = "
" + const updatedHtml = "
" view.update({s: [updatedHtml]}, []) expect(liveSocket.socket.closeWasClean).toBe(true) @@ -548,17 +549,17 @@ describe("View + DOM", function(){ }) describe("phx-update", function(){ - let childIds = () => Array.from(document.getElementById("list").children).map(child => parseInt(child.id)) - let countChildNodes = () => document.getElementById("list").childNodes.length + const childIds = () => Array.from(document.getElementById("list").children).map(child => parseInt(child.id)) + const countChildNodes = () => document.getElementById("list").childNodes.length - let createView = (updateType, initialDynamics) => { - let liveSocket = new LiveSocket("/live", Socket) - let el = liveViewDOM() - let view = simulateJoinedView(el, liveSocket) + const createView = (updateType, initialDynamics) => { + const liveSocket = new LiveSocket("/live", Socket) + const el = liveViewDOM() + const view = simulateJoinedView(el, liveSocket) stubChannel(view) - let joinDiff = { + const joinDiff = { "0": {"d": initialDynamics, "s": ["\n
", "
\n"]}, "s": [`
`, "
"] } @@ -568,8 +569,8 @@ describe("View + DOM", function(){ return view } - let updateDynamics = (view, dynamics) => { - let updateDiff = { + const updateDynamics = (view, dynamics) => { + const updateDiff = { "0": { "d": dynamics } @@ -579,7 +580,7 @@ describe("View + DOM", function(){ } test("replace", async () => { - let view = createView("replace", [["1", "1"]]) + const view = createView("replace", [["1", "1"]]) expect(childIds()).toEqual([1]) updateDynamics(view, @@ -589,7 +590,7 @@ describe("View + DOM", function(){ }) test("append", async () => { - let view = createView("append", [["1", "1"]]) + const view = createView("append", [["1", "1"]]) expect(childIds()).toEqual([1]) // Append two elements @@ -635,7 +636,7 @@ describe("View + DOM", function(){ expect(childIds()).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9]) // Make sure we don't have a memory leak when doing updates - let initialCount = countChildNodes() + const initialCount = countChildNodes() updateDynamics(view, [["1", "1"], ["2", "2"], ["3", "3"]] ) @@ -653,7 +654,7 @@ describe("View + DOM", function(){ }) test("prepend", async () => { - let view = createView("prepend", [["1", "1"]]) + const view = createView("prepend", [["1", "1"]]) expect(childIds()).toEqual([1]) // Append two elements @@ -699,7 +700,7 @@ describe("View + DOM", function(){ expect(childIds()).toEqual([8, 9, 6, 7, 4, 5, 2, 3, 1]) // Make sure we don't have a memory leak when doing updates - let initialCount = countChildNodes() + const initialCount = countChildNodes() updateDynamics(view, [["1", "1"], ["2", "2"], ["3", "3"]] ) @@ -717,7 +718,7 @@ describe("View + DOM", function(){ }) test("ignore", async () => { - let view = createView("ignore", [["1", "1"]]) + const view = createView("ignore", [["1", "1"]]) expect(childIds()).toEqual([1]) // Append two elements @@ -812,9 +813,9 @@ describe("View", function(){ }) test("sets defaults", async () => { - let liveSocket = new LiveSocket("/live", Socket) - let el = liveViewDOM() - let view = simulateJoinedView(el, liveSocket) + const liveSocket = new LiveSocket("/live", Socket) + const el = liveViewDOM() + const view = simulateJoinedView(el, liveSocket) expect(view.liveSocket).toBe(liveSocket) expect(view.parent).toBeUndefined() expect(view.el).toBe(el) @@ -825,22 +826,22 @@ describe("View", function(){ }) test("binding", async () => { - let liveSocket = new LiveSocket("/live", Socket) - let el = liveViewDOM() - let view = simulateJoinedView(el, liveSocket) + const liveSocket = new LiveSocket("/live", Socket) + const el = liveViewDOM() + const view = simulateJoinedView(el, liveSocket) expect(view.binding("submit")).toEqual("phx-submit") }) test("getSession", async () => { - let liveSocket = new LiveSocket("/live", Socket) - let el = liveViewDOM() - let view = simulateJoinedView(el, liveSocket) + const liveSocket = new LiveSocket("/live", Socket) + const el = liveViewDOM() + const view = simulateJoinedView(el, liveSocket) expect(view.getSession()).toEqual("abc123") }) test("getStatic", async () => { - let liveSocket = new LiveSocket("/live", Socket) - let el = liveViewDOM() + const liveSocket = new LiveSocket("/live", Socket) + const el = liveViewDOM() let view = simulateJoinedView(el, liveSocket) expect(view.getStatic()).toEqual(null) @@ -850,10 +851,10 @@ describe("View", function(){ }) test("showLoader and hideLoader", async () => { - let liveSocket = new LiveSocket("/live", Socket) - let el = document.querySelector("[data-phx-session]") + const liveSocket = new LiveSocket("/live", Socket) + const el = document.querySelector("[data-phx-session]") - let view = simulateJoinedView(el, liveSocket) + const view = simulateJoinedView(el, liveSocket) view.showLoader() expect(el.classList.contains("phx-loading")).toBeTruthy() expect(el.classList.contains("phx-connected")).toBeFalsy() @@ -866,14 +867,14 @@ describe("View", function(){ test("displayError and hideLoader", done => { jest.useFakeTimers() - let liveSocket = new LiveSocket("/live", Socket) - let loader = document.createElement("span") - let phxView = document.querySelector("[data-phx-session]") + const liveSocket = new LiveSocket("/live", Socket) + const loader = document.createElement("span") + const phxView = document.querySelector("[data-phx-session]") phxView.parentNode.insertBefore(loader, phxView.nextSibling) - let el = document.querySelector("[data-phx-session]") - let status = el.querySelector("#status") + const el = document.querySelector("[data-phx-session]") + const status = el.querySelector("#status") - let view = simulateJoinedView(el, liveSocket) + const view = simulateJoinedView(el, liveSocket) expect(status.style.display).toBe("none") view.displayError([PHX_LOADING_CLASS, PHX_ERROR_CLASS, PHX_SERVER_ERROR_CLASS]) @@ -891,18 +892,18 @@ describe("View", function(){ }) test("join", async () => { - let liveSocket = new LiveSocket("/live", Socket) - let el = liveViewDOM() - let _view = simulateJoinedView(el, liveSocket) + const liveSocket = new LiveSocket("/live", Socket) + const el = liveViewDOM() + const _view = simulateJoinedView(el, liveSocket) // view.join() // still need a few tests }) test("sends _track_static and _mounts on params", () => { - let liveSocket = new LiveSocket("/live", Socket) - let el = liveViewDOM() - let view = new View(el, liveSocket) + const liveSocket = new LiveSocket("/live", Socket) + const el = liveViewDOM() + const view = new View(el, liveSocket) stubChannel(view) expect(view.channel.params()).toEqual({ @@ -942,13 +943,13 @@ describe("View Hooks", function(){ }) test("phx-mounted", done => { - let liveSocket = new LiveSocket("/live", Socket) - let el = liveViewDOM() + const liveSocket = new LiveSocket("/live", Socket) + const el = liveViewDOM() - let html = "

test mounted

" + const html = "

test mounted

" el.innerHTML = html - let view = simulateJoinedView(el, liveSocket) + const view = simulateJoinedView(el, liveSocket) view.onJoin({ rendered: { @@ -975,7 +976,7 @@ describe("View Hooks", function(){ let upcaseWasDestroyed = false let upcaseBeforeUpdate = false let hookLiveSocket - let Hooks = { + const Hooks = { Upcase: { mounted(){ hookLiveSocket = this.liveSocket @@ -988,10 +989,61 @@ describe("View Hooks", function(){ destroyed(){ upcaseWasDestroyed = true }, } } - let liveSocket = new LiveSocket("/live", Socket, {hooks: Hooks}) - let el = liveViewDOM() + const liveSocket = new LiveSocket("/live", Socket, {hooks: Hooks}) + const el = liveViewDOM() - let view = simulateJoinedView(el, liveSocket) + const view = simulateJoinedView(el, liveSocket) + + view.onJoin({ + rendered: { + s: ["

test mount

"], + fingerprint: 123 + }, + liveview_version + }) + expect(view.el.firstChild.innerHTML).toBe("TEST MOUNT") + expect(Object.keys(view.viewHooks)).toHaveLength(1) + + view.update({ + s: ["

test update

"], + fingerprint: 123 + }, []) + expect(upcaseBeforeUpdate).toBe(true) + expect(view.el.firstChild.innerHTML).toBe("test update updated") + + view.showLoader() + expect(view.el.firstChild.innerHTML).toBe("disconnected") + + view.triggerReconnected() + expect(view.el.firstChild.innerHTML).toBe("connected") + + view.update({s: ["
"], fingerprint: 123}, []) + expect(upcaseWasDestroyed).toBe(true) + expect(hookLiveSocket).toBeDefined() + expect(Object.keys(view.viewHooks)).toEqual([]) + }) + + test("class based hook", async () => { + let upcaseWasDestroyed = false + let upcaseBeforeUpdate = false + let hookLiveSocket + const Hooks = { + Upcase: class extends ViewHook { + mounted(){ + hookLiveSocket = this.liveSocket + this.el.innerHTML = this.el.innerHTML.toUpperCase() + } + beforeUpdate(){ upcaseBeforeUpdate = true } + updated(){ this.el.innerHTML = this.el.innerHTML + " updated" } + disconnected(){ this.el.innerHTML = "disconnected" } + reconnected(){ this.el.innerHTML = "connected" } + destroyed(){ upcaseWasDestroyed = true } + } + } + const liveSocket = new LiveSocket("/live", Socket, {hooks: Hooks}) + const el = liveViewDOM() + + const view = simulateJoinedView(el, liveSocket) view.onJoin({ rendered: { @@ -1023,8 +1075,8 @@ describe("View Hooks", function(){ }) test("createHook", (done) => { - let liveSocket = new LiveSocket("/live", Socket, {}) - let el = liveViewDOM() + const liveSocket = new LiveSocket("/live", Socket, {}) + const el = liveViewDOM() customElements.define("custom-el", class extends HTMLElement { connectedCallback(){ this.hook = createHook(this, {mounted: () => { @@ -1034,22 +1086,22 @@ describe("View Hooks", function(){ expect(this.hook.liveSocket).toBe(null) } }) - let customEl = document.createElement("custom-el") + const customEl = document.createElement("custom-el") el.appendChild(customEl) simulateJoinedView(el, liveSocket) }) test("view destroyed", async () => { - let values = [] - let Hooks = { + const values = [] + const Hooks = { Check: { destroyed(){ values.push("destroyed") }, } } - let liveSocket = new LiveSocket("/live", Socket, {hooks: Hooks}) - let el = liveViewDOM() + const liveSocket = new LiveSocket("/live", Socket, {hooks: Hooks}) + const el = liveViewDOM() - let view = simulateJoinedView(el, liveSocket) + const view = simulateJoinedView(el, liveSocket) view.onJoin({ rendered: { @@ -1066,18 +1118,18 @@ describe("View Hooks", function(){ }) test("view reconnected", async () => { - let values = [] - let Hooks = { + const values = [] + const Hooks = { Check: { mounted(){ values.push("mounted") }, disconnected(){ values.push("disconnected") }, reconnected(){ values.push("reconnected") }, } } - let liveSocket = new LiveSocket("/live", Socket, {hooks: Hooks}) - let el = liveViewDOM() + const liveSocket = new LiveSocket("/live", Socket, {hooks: Hooks}) + const el = liveViewDOM() - let view = simulateJoinedView(el, liveSocket) + const view = simulateJoinedView(el, liveSocket) view.onJoin({ rendered: { @@ -1100,12 +1152,12 @@ describe("View Hooks", function(){ }) test("dispatches uploads", async () => { - let hooks = {Recorder: {}} - let liveSocket = new LiveSocket("/live", Socket, {hooks}) - let el = liveViewDOM() - let view = simulateJoinedView(el, liveSocket) + const hooks = {Recorder: {}} + const liveSocket = new LiveSocket("/live", Socket, {hooks}) + const el = liveViewDOM() + const view = simulateJoinedView(el, liveSocket) - let template = ` + const template = `
@@ -1118,12 +1170,12 @@ describe("View Hooks", function(){ liveview_version }) - let recorderHook = view.getHook(view.el.querySelector("#rec")) - let fileEl = view.el.querySelector("#uploads0") - let dispatchEventSpy = jest.spyOn(fileEl, "dispatchEvent") + const recorderHook = view.getHook(view.el.querySelector("#rec")) + const fileEl = view.el.querySelector("#uploads0") + const dispatchEventSpy = jest.spyOn(fileEl, "dispatchEvent") - let contents = {hello: "world"} - let blob = new Blob([JSON.stringify(contents, null, 2)], {type : "application/json"}) + const contents = {hello: "world"} + const blob = new Blob([JSON.stringify(contents, null, 2)], {type : "application/json"}) recorderHook.upload("doc", [blob]) expect(dispatchEventSpy).toHaveBeenCalledWith(new CustomEvent("track-uploads", { @@ -1135,13 +1187,13 @@ describe("View Hooks", function(){ test("dom hooks", async () => { let fromHTML, toHTML = null - let liveSocket = new LiveSocket("/live", Socket, { + const liveSocket = new LiveSocket("/live", Socket, { dom: { onBeforeElUpdated(from, to){ fromHTML = from.innerHTML; toHTML = to.innerHTML } } }) - let el = liveViewDOM() - let view = simulateJoinedView(el, liveSocket) + const el = liveViewDOM() + const view = simulateJoinedView(el, liveSocket) view.onJoin({rendered: {s: ["
initial
"], fingerprint: 123}, liveview_version}) expect(view.el.firstChild.innerHTML).toBe("initial") @@ -1182,11 +1234,11 @@ describe("View + Component", function(){ }) test("targetComponentID", async () => { - let liveSocket = new LiveSocket("/live", Socket) - let el = liveViewComponent() - let view = simulateJoinedView(el, liveSocket) - let form = el.querySelector("input[type=\"checkbox\"]") - let targetCtx = el.querySelector(".form-wrapper") + const liveSocket = new LiveSocket("/live", Socket) + const el = liveViewComponent() + const view = simulateJoinedView(el, liveSocket) + const form = el.querySelector("input[type=\"checkbox\"]") + const targetCtx = el.querySelector(".form-wrapper") expect(view.targetComponentID(el, targetCtx)).toBe(null) expect(view.targetComponentID(form, targetCtx)).toBe(0) }) @@ -1194,13 +1246,13 @@ describe("View + Component", function(){ test("pushEvent", (done) => { expect.assertions(17) - let liveSocket = new LiveSocket("/live", Socket) - let el = liveViewComponent() - let targetCtx = el.querySelector(".form-wrapper") + const liveSocket = new LiveSocket("/live", Socket) + const el = liveViewComponent() + const targetCtx = el.querySelector(".form-wrapper") - let view = simulateJoinedView(el, liveSocket) - let input = view.el.querySelector("input[id=plus]") - let channelStub = { + const view = simulateJoinedView(el, liveSocket) + const input = view.el.querySelector("input[id=plus]") + const channelStub = { push(_evt, payload, _timeout){ expect(payload.type).toBe("keyup") expect(payload.event).toBeDefined() @@ -1217,7 +1269,7 @@ describe("View + Component", function(){ view.channel = channelStub input.addEventListener("phx:push:myevent", (e) => { - let {ref, lockComplete, loadingComplete} = e.detail + const {ref, lockComplete, loadingComplete} = e.detail expect(ref).toBe(0) expect(e.target).toBe(input) loadingComplete.then((detail) => { @@ -1231,7 +1283,7 @@ describe("View + Component", function(){ }) }) input.addEventListener("phx:push", (e) => { - let {lock, unlock, lockComplete} = e.detail + const {lock, unlock, lockComplete} = e.detail expect(typeof lock).toBe("function") expect(view.el.getAttribute("data-phx-ref-lock")).toBe(null) // lock accepts unlock function to fire, which will done() the test @@ -1252,7 +1304,7 @@ describe("View + Component", function(){ }) test("pushInput", function(done){ - let html = + const html = `
@@ -1260,11 +1312,11 @@ describe("View + Component", function(){
` - let liveSocket = new LiveSocket("/live", Socket) - let el = liveViewDOM(html) - let view = simulateJoinedView(el, liveSocket, html) + const liveSocket = new LiveSocket("/live", Socket) + const el = liveViewDOM(html) + const view = simulateJoinedView(el, liveSocket, html) Array.from(view.el.querySelectorAll("input")).forEach(input => simulateUsedInput(input)) - let channelStub = { + const channelStub = { validate: "", nextValidate(payload, meta){ this.meta = meta @@ -1278,7 +1330,7 @@ describe("View + Component", function(){ return { receive(status, cb){ if(status === "ok"){ - let diff = { + const diff = { s: [`
@@ -1303,8 +1355,8 @@ describe("View + Component", function(){ } view.channel = channelStub - let first_name = view.el.querySelector("#first_name") - let last_name = view.el.querySelector("#last_name") + const first_name = view.el.querySelector("#first_name") + const last_name = view.el.querySelector("#last_name") view.channel.nextValidate({"user[first_name]": null, "user[last_name]": null}, {"_target": "user[first_name]"}) // we have to set this manually since it's set by a change event that would require more plumbing with the liveSocket in the test to hook up DOM.putPrivate(first_name, "phx-has-focused", true) @@ -1319,13 +1371,13 @@ describe("View + Component", function(){ }) test("adds auto ID to prevent teardown/re-add", () => { - let liveSocket = new LiveSocket("/live", Socket) - let el = liveViewDOM() - let view = simulateJoinedView(el, liveSocket) + const liveSocket = new LiveSocket("/live", Socket) + const el = liveViewDOM() + const view = simulateJoinedView(el, liveSocket) stubChannel(view) - let joinDiff = { + const joinDiff = { "0": {"0": "", "1": 0, "s": ["", "", "

2

\n"]}, "c": { "0": {"s": ["
Menu
\n"], "r": 1} @@ -1333,7 +1385,7 @@ describe("View + Component", function(){ "s": ["", ""] } - let updateDiff = { + const updateDiff = { "0": { "0": {"s": ["

1

\n"], "r": 1} } @@ -1347,13 +1399,13 @@ describe("View + Component", function(){ }) test("respects nested components", () => { - let liveSocket = new LiveSocket("/live", Socket) - let el = liveViewDOM() - let view = simulateJoinedView(el, liveSocket) + const liveSocket = new LiveSocket("/live", Socket) + const el = liveViewDOM() + const view = simulateJoinedView(el, liveSocket) stubChannel(view) - let joinDiff = { + const joinDiff = { "0": 0, "c": { "0": {"0": 1, "s": ["
Hello
", ""], "r": 1}, @@ -1367,21 +1419,21 @@ describe("View + Component", function(){ }) test("destroys children when they are removed by an update", () => { - let id = "root" - let childHTML = `
` - let newChildHTML = `
` - let el = document.createElement("div") + const id = "root" + const childHTML = `
` + const newChildHTML = `
` + const el = document.createElement("div") el.setAttribute("data-phx-session", "abc123") el.setAttribute("id", id) document.body.appendChild(el) - let liveSocket = new LiveSocket("/live", Socket) + const liveSocket = new LiveSocket("/live", Socket) - let view = simulateJoinedView(el, liveSocket) + const view = simulateJoinedView(el, liveSocket) - let joinDiff = {"s": [childHTML]} + const joinDiff = {"s": [childHTML]} - let updateDiff = {"s": [newChildHTML]} + const updateDiff = {"s": [newChildHTML]} view.onJoin({rendered: joinDiff, liveview_version}) expect(view.el.innerHTML.trim()).toEqual(childHTML) @@ -1395,7 +1447,7 @@ describe("View + Component", function(){ describe("undoRefs", () => { test("restores phx specific attributes awaiting a ref", () => { - let content = ` + const content = ` @@ -1404,9 +1456,9 @@ describe("View + Component", function(){
`.trim() - let liveSocket = new LiveSocket("/live", Socket) - let el = rootContainer(content) - let view = simulateJoinedView(el, liveSocket) + const liveSocket = new LiveSocket("/live", Socket) + const el = rootContainer(content) + const view = simulateJoinedView(el, liveSocket) view.undoRefs(1) expect(el.innerHTML).toBe(` @@ -1432,16 +1484,16 @@ describe("View + Component", function(){ }) test("replaces any previous applied component", () => { - let liveSocket = new LiveSocket("/live", Socket) - let el = rootContainer("") + const liveSocket = new LiveSocket("/live", Socket) + const el = rootContainer("") - let fromEl = tag("span", {"data-phx-ref-src": el.id, "data-phx-ref-lock": "1"}, "hello") - let toEl = tag("span", {"class": "new"}, "world") + const fromEl = tag("span", {"data-phx-ref-src": el.id, "data-phx-ref-lock": "1"}, "hello") + const toEl = tag("span", {"class": "new"}, "world") DOM.putPrivate(fromEl, "data-phx-ref-lock", toEl) el.appendChild(fromEl) - let view = simulateJoinedView(el, liveSocket) + const view = simulateJoinedView(el, liveSocket) view.undoRefs(1) expect(el.innerHTML).toBe("world") @@ -1451,21 +1503,21 @@ describe("View + Component", function(){ global.document.body.innerHTML = "" let beforeUpdate = false let updated = false - let Hooks = { + const Hooks = { MyHook: { beforeUpdate(){ beforeUpdate = true }, updated(){ updated = true }, } } - let liveSocket = new LiveSocket("/live", Socket, {hooks: Hooks}) - let el = liveViewDOM() - let view = simulateJoinedView(el, liveSocket) + const liveSocket = new LiveSocket("/live", Socket, {hooks: Hooks}) + const el = liveViewDOM() + const view = simulateJoinedView(el, liveSocket) stubChannel(view) view.onJoin({rendered: {s: ["Hello"]}, liveview_version}) view.update({s: ["Hello"]}, []) - let toEl = tag("span", {"id": "myhook", "phx-hook": "MyHook"}, "world") + const toEl = tag("span", {"id": "myhook", "phx-hook": "MyHook"}, "world") DOM.putPrivate(el.querySelector("#myhook"), "data-phx-ref-lock", toEl) view.undoRefs(1) diff --git a/babel.config.json b/babel.config.json index a29ac9986c..c0993b53fd 100644 --- a/babel.config.json +++ b/babel.config.json @@ -1,5 +1,6 @@ { "presets": [ - "@babel/preset-env" + "@babel/preset-env", + "@babel/preset-typescript" ] } diff --git a/eslint.config.mjs b/eslint.config.js similarity index 80% rename from eslint.config.mjs rename to eslint.config.js index f3b6df0042..f8eeb96aae 100644 --- a/eslint.config.mjs +++ b/eslint.config.js @@ -2,7 +2,8 @@ import playwright from "eslint-plugin-playwright" import jest from "eslint-plugin-jest" import globals from "globals" import js from "@eslint/js" -import stylisticJs from "@stylistic/eslint-plugin-js" +import stylisticJs from "@stylistic/eslint-plugin" +import tseslint from "typescript-eslint" const sharedRules = { "@stylistic/js/indent": ["error", 2, { @@ -55,19 +56,24 @@ const sharedRules = { "@stylistic/js/eol-last": ["error", "always"], - "no-unused-vars": ["error", { + "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_", varsIgnorePattern: "^_", }], + "@typescript-eslint/no-unused-expressions": "off", + "@typescript-eslint/no-explicit-any": "off", + "no-useless-escape": "off", "no-cond-assign": "off", "no-case-declarations": "off", + "prefer-const": "off" } -export default [ +export default tseslint.config([ { ignores: [ + "assets/js/dist/", "test/e2e/test-results/", "coverage/", "cover/", @@ -77,7 +83,7 @@ export default [ ] }, { - ...js.configs.recommended, + extends: [js.configs.recommended, ...tseslint.configs.recommended], files: ["*.js", "*.mjs", "test/e2e/**"], ignores: ["assets/**"], @@ -92,9 +98,8 @@ export default [ }, }, { - ...js.configs.recommended, - - files: ["assets/**/*.js", "assets/**/*.mjs"], + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ["assets/**/*.{js,ts}"], ignores: ["test/e2e/**"], plugins: { @@ -116,4 +121,4 @@ export default [ rules: { ...sharedRules, }, - }] + }]) diff --git a/guides/client/js-interop.md b/guides/client/js-interop.md index 7a372f0d27..8ec525e044 100644 --- a/guides/client/js-interop.md +++ b/guides/client/js-interop.md @@ -237,7 +237,7 @@ let liveSocket = new LiveSocket("/live", Socket, { } } } -} +}) ``` In the example above, all attributes starting with `data-js-` won't be replaced when the DOM is patched by LiveView. diff --git a/jest.config.js b/jest.config.js index 699e6d3238..d4879a51ac 100644 --- a/jest.config.js +++ b/jest.config.js @@ -3,7 +3,9 @@ * https://jestjs.io/docs/configuration */ -module.exports = { +import {default as packageJson} from "./package.json" with {type: "json"} + +export default { // All imported modules in your tests should be mocked automatically // automock: false, @@ -59,7 +61,7 @@ module.exports = { // A set of global variables that need to be available in all test environments globals: { - LV_VSN: require("./package.json").version, + LV_VSN: packageJson.version, }, // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. @@ -82,8 +84,8 @@ module.exports = { // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module moduleNameMapper: { - "^phoenix_live_view$": "/assets/js/phoenix_live_view/index.js", - "^phoenix_live_view/(.*)$": "/assets/js/phoenix_live_view/$1.js" + "^phoenix_live_view$": "/assets/js/dist/index.js", + "^phoenix_live_view/(.*)$": "/assets/js/dist/$1.js" }, // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader diff --git a/mix.exs b/mix.exs index deb71a8eb7..84a5cab100 100644 --- a/mix.exs +++ b/mix.exs @@ -185,8 +185,14 @@ defmodule Phoenix.LiveView.MixProject do defp aliases do [ - "assets.build": ["esbuild module", "esbuild cdn", "esbuild cdn_min", "esbuild main"], - "assets.watch": ["esbuild module --watch"] + "assets.build": [ + "cmd npm run build", + "esbuild module", + "esbuild cdn", + "esbuild cdn_min", + "esbuild main" + ], + "assets.watch": ["cmd npm run build -- --watch", "esbuild module --watch"] ] end diff --git a/package-lock.json b/package-lock.json index 3feaee8490..e0f5e501a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,15 +9,17 @@ "version": "1.1.0-dev", "license": "MIT", "dependencies": { - "morphdom": "2.7.5" + "morphdom": "git+https://github.com/SteffenDE/morphdom.git#sd-fix-ts" }, "devDependencies": { "@babel/cli": "7.27.0", "@babel/core": "7.26.10", "@babel/preset-env": "7.26.9", + "@babel/preset-typescript": "^7.27.1", "@eslint/js": "^9.24.0", "@playwright/test": "^1.51.1", - "@stylistic/eslint-plugin-js": "^4.2.0", + "@stylistic/eslint-plugin": "^4.2.0", + "@types/phoenix": "^1.6.6", "css.escape": "^1.5.1", "eslint": "9.24.0", "eslint-plugin-jest": "28.11.0", @@ -27,7 +29,10 @@ "jest-environment-jsdom": "^29.7.0", "jest-monocart-coverage": "^1.1.1", "monocart-reporter": "^2.9.17", - "phoenix": "1.7.21" + "phoenix": "1.7.21", + "ts-jest": "^29.3.2", + "typescript": "^5.8.3", + "typescript-eslint": "^8.32.0" } }, "node_modules/@ampproject/remapping": { @@ -104,14 +109,14 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.26.2", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", - "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", "dev": true, "dependencies": { - "@babel/helper-validator-identifier": "^7.25.9", + "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" + "picocolors": "^1.1.1" }, "engines": { "node": ">=6.9.0" @@ -166,13 +171,13 @@ } }, "node_modules/@babel/generator": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.0.tgz", - "integrity": "sha512-VybsKvpiN1gU1sdMZIp7FcqphVVKEwcuj02x73uvcHE0PTihx1nlBcowYWhDwjpoAXRv43+gDzyggGnn1XZhVw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.1.tgz", + "integrity": "sha512-UnJfnIpc/+JO0/+KRVQNGU+y5taA5vCbwN8+azkX6beii/ZF+enZJSOKo11ZSzGJjlNfJHfQtmQT8H+9TXPG2w==", "dev": true, "dependencies": { - "@babel/parser": "^7.27.0", - "@babel/types": "^7.27.0", + "@babel/parser": "^7.27.1", + "@babel/types": "^7.27.1", "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^3.0.2" @@ -182,12 +187,12 @@ } }, "node_modules/@babel/helper-annotate-as-pure": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.25.9.tgz", - "integrity": "sha512-gv7320KBUFJz1RnylIg5WWYPRXKZ884AGkYpgpWW02TH66Dl+HaC1t1CKd0z3R4b6hdYEcmrNZHUmfCP+1u3/g==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.1.tgz", + "integrity": "sha512-WnuuDILl9oOBbKnb4L+DyODx7iC47XfzmNCpTttFsSp6hTG7XZxu60+4IO+2/hPfcGOoKbFiwoI/+zwARbNQow==", "dev": true, "dependencies": { - "@babel/types": "^7.25.9" + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -219,17 +224,17 @@ } }, "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.25.9.tgz", - "integrity": "sha512-UTZQMvt0d/rSz6KI+qdu7GQze5TIajwTS++GUozlw8VBJDEOAqSXwm1WvmYEZwqdqSGQshRocPDqrt4HBZB3fQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.27.1.tgz", + "integrity": "sha512-QwGAmuvM17btKU5VqXfb+Giw4JcN0hjuufz3DYnpeVDvZLAObloM77bhMXiqry3Iio+Ai4phVRDwl6WU10+r5A==", "dev": true, "dependencies": { - "@babel/helper-annotate-as-pure": "^7.25.9", - "@babel/helper-member-expression-to-functions": "^7.25.9", - "@babel/helper-optimise-call-expression": "^7.25.9", - "@babel/helper-replace-supers": "^7.25.9", - "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9", - "@babel/traverse": "^7.25.9", + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-member-expression-to-functions": "^7.27.1", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/helper-replace-supers": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/traverse": "^7.27.1", "semver": "^6.3.1" }, "engines": { @@ -291,40 +296,40 @@ } }, "node_modules/@babel/helper-member-expression-to-functions": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.25.9.tgz", - "integrity": "sha512-wbfdZ9w5vk0C0oyHqAJbc62+vet5prjj01jjJ8sKn3j9h3MQQlflEdXYvuqRWjHnM12coDEqiC1IRCi0U/EKwQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.27.1.tgz", + "integrity": "sha512-E5chM8eWjTp/aNoVpcbfM7mLxu9XGLWYise2eBKGQomAk/Mb4XoxyqXTZbuTohbsl8EKqdlMhnDI2CCLfcs9wA==", "dev": true, "dependencies": { - "@babel/traverse": "^7.25.9", - "@babel/types": "^7.25.9" + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-imports": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz", - "integrity": "sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", "dev": true, "dependencies": { - "@babel/traverse": "^7.25.9", - "@babel/types": "^7.25.9" + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.26.0.tgz", - "integrity": "sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.1.tgz", + "integrity": "sha512-9yHn519/8KvTU5BjTVEEeIM3w9/2yXNKoD82JifINImhpKkARMJKPP59kLo+BafpdN5zgNeIcS4jsGDmd3l58g==", "dev": true, "dependencies": { - "@babel/helper-module-imports": "^7.25.9", - "@babel/helper-validator-identifier": "^7.25.9", - "@babel/traverse": "^7.25.9" + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -334,21 +339,21 @@ } }, "node_modules/@babel/helper-optimise-call-expression": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.25.9.tgz", - "integrity": "sha512-FIpuNaz5ow8VyrYcnXQTDRGvV6tTjkNtCK/RYNDXGSLlUD6cBuQTSw43CShGxjvfBTfcUA/r6UhUCbtYqkhcuQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", + "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", "dev": true, "dependencies": { - "@babel/types": "^7.25.9" + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.26.5", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.26.5.tgz", - "integrity": "sha512-RS+jZcRdZdRFzMyr+wcsaqOmld1/EqTghfaBGQQd/WnRdzdlvSZ//kF7U8VQTxf1ynZ4cjUcYgjVGx13ewNPMg==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", "dev": true, "engines": { "node": ">=6.9.0" @@ -372,14 +377,14 @@ } }, "node_modules/@babel/helper-replace-supers": { - "version": "7.26.5", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.26.5.tgz", - "integrity": "sha512-bJ6iIVdYX1YooY2X7w1q6VITt+LnUILtNk7zT78ykuwStx8BauCzxvFqFaHjOpW1bVnSUM1PN1f0p5P21wHxvg==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.27.1.tgz", + "integrity": "sha512-7EHz6qDZc8RYS5ElPoShMheWvEgERonFCs7IAonWLLUTXW59DP14bCZt89/GKyreYn8g3S83m21FelHKbeDCKA==", "dev": true, "dependencies": { - "@babel/helper-member-expression-to-functions": "^7.25.9", - "@babel/helper-optimise-call-expression": "^7.25.9", - "@babel/traverse": "^7.26.5" + "@babel/helper-member-expression-to-functions": "^7.27.1", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/traverse": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -389,40 +394,40 @@ } }, "node_modules/@babel/helper-skip-transparent-expression-wrappers": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.25.9.tgz", - "integrity": "sha512-K4Du3BFa3gvyhzgPcntrkDgZzQaq6uozzcpGbOO1OEJaI+EJdqWIMTLgFgQf6lrfiDFo5FU+BxKepI9RmZqahA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", + "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", "dev": true, "dependencies": { - "@babel/traverse": "^7.25.9", - "@babel/types": "^7.25.9" + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-string-parser": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", - "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", "dev": true, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", - "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", "dev": true, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-option": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.25.9.tgz", - "integrity": "sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", "dev": true, "engines": { "node": ">=6.9.0" @@ -456,12 +461,12 @@ } }, "node_modules/@babel/parser": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.0.tgz", - "integrity": "sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg==", + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.2.tgz", + "integrity": "sha512-QYLs8299NA7WM/bZAdp+CviYYkVoYXlDW2rzliy3chxd1PQjej7JORuMJDJXJUb9g0TT+B99EwaVLKmX+sPXWw==", "dev": true, "dependencies": { - "@babel/types": "^7.27.0" + "@babel/types": "^7.27.1" }, "bin": { "parser": "bin/babel-parser.js" @@ -667,12 +672,12 @@ } }, "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.25.9.tgz", - "integrity": "sha512-ld6oezHQMZsZfp6pWtbjaNDF2tiiCYYDqQszHt5VV437lewP9aSi2Of99CK0D0XB21k7FLgnLcmQKyKzynfeAA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz", + "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -784,12 +789,12 @@ } }, "node_modules/@babel/plugin-syntax-typescript": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.25.9.tgz", - "integrity": "sha512-hjMgRy5hb8uJJjUcdWunWVcoi9bGpJp8p5Ol1229PoN6aytsLwNMgmdftO23wnCLMfVmTwZDWMPNq/D1SY60JQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz", + "integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1187,13 +1192,13 @@ } }, "node_modules/@babel/plugin-transform-modules-commonjs": { - "version": "7.26.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.26.3.tgz", - "integrity": "sha512-MgR55l4q9KddUDITEzEFYn5ZsGDXMSsU9E+kh7fjRXTIC3RHqfCo8RPRbyReYJh44HQ/yomFkqbOFohXvDCiIQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.27.1.tgz", + "integrity": "sha512-OJguuwlTYlN0gBZFRPqwOGNWssZjfIUdS7HMYtN8c1KmwpwHFBwTeFZrg9XZa+DFTitWOW5iTAG7tyCUPsCCyw==", "dev": true, "dependencies": { - "@babel/helper-module-transforms": "^7.26.0", - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1547,6 +1552,25 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/plugin-transform-typescript": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.27.1.tgz", + "integrity": "sha512-Q5sT5+O4QUebHdbwKedFBEwRLb02zJ7r4A5Gg2hUoLuU3FjdMcyqcywqUrLCaDsFCxzokf7u9kuy7qz51YUuAg==", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-create-class-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/plugin-transform-unicode-escapes": { "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.25.9.tgz", @@ -1716,6 +1740,25 @@ "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" } }, + "node_modules/@babel/preset-typescript": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.27.1.tgz", + "integrity": "sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-transform-modules-commonjs": "^7.27.1", + "@babel/plugin-transform-typescript": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/runtime": { "version": "7.26.0", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.0.tgz", @@ -1729,30 +1772,30 @@ } }, "node_modules/@babel/template": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.0.tgz", - "integrity": "sha512-2ncevenBqXI6qRMukPlXwHKHchC7RyMuu4xv5JBXRfOGVcTy1mXCD12qrp7Jsoxll1EV3+9sE4GugBVRjT2jFA==", + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", "dev": true, "dependencies": { - "@babel/code-frame": "^7.26.2", - "@babel/parser": "^7.27.0", - "@babel/types": "^7.27.0" + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.0.tgz", - "integrity": "sha512-19lYZFzYVQkkHkl4Cy4WrAVcqBkgvV2YM2TU3xG6DIwO7O3ecbDPfW3yM3bjAGcqcQHi+CCtjMR3dIEHxsd6bA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.1.tgz", + "integrity": "sha512-ZCYtZciz1IWJB4U61UPu4KEaqyfj+r5T1Q5mqPo+IBpcG9kHv30Z0aD8LXPgC1trYa6rK0orRyAhqUgk4MjmEg==", "dev": true, "dependencies": { - "@babel/code-frame": "^7.26.2", - "@babel/generator": "^7.27.0", - "@babel/parser": "^7.27.0", - "@babel/template": "^7.27.0", - "@babel/types": "^7.27.0", + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.27.1", + "@babel/parser": "^7.27.1", + "@babel/template": "^7.27.1", + "@babel/types": "^7.27.1", "debug": "^4.3.1", "globals": "^11.1.0" }, @@ -1770,13 +1813,13 @@ } }, "node_modules/@babel/types": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.0.tgz", - "integrity": "sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.1.tgz", + "integrity": "sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q==", "dev": true, "dependencies": { - "@babel/helper-string-parser": "^7.25.9", - "@babel/helper-validator-identifier": "^7.25.9" + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1789,9 +1832,9 @@ "dev": true }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz", - "integrity": "sha512-s3O3waFUrMV8P/XaF/+ZTp1X9XBZW1a4B97ZnjQF2KYWaFD2A8KyFBsrsfSjEmjn3RGWAIuvlneuZm3CUK3jbA==", + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", + "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", "dev": true, "dependencies": { "eslint-visitor-keys": "^3.4.3" @@ -2561,14 +2604,17 @@ "@sinonjs/commons": "^3.0.0" } }, - "node_modules/@stylistic/eslint-plugin-js": { + "node_modules/@stylistic/eslint-plugin": { "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin-js/-/eslint-plugin-js-4.2.0.tgz", - "integrity": "sha512-MiJr6wvyzMYl/wElmj8Jns8zH7Q1w8XoVtm+WM6yDaTrfxryMyb8n0CMxt82fo42RoLIfxAEtM6tmQVxqhk0/A==", + "resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-4.2.0.tgz", + "integrity": "sha512-8hXezgz7jexGHdo5WN6JBEIPHCSFyyU4vgbxevu4YLVS5vl+sxqAAGyXSzfNDyR6xMNSH5H1x67nsXcYMOHtZA==", "dev": true, "dependencies": { + "@typescript-eslint/utils": "^8.23.0", "eslint-visitor-keys": "^4.2.0", - "espree": "^10.3.0" + "espree": "^10.3.0", + "estraverse": "^5.3.0", + "picomatch": "^4.0.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2577,6 +2623,18 @@ "eslint": ">=9.0.0" } }, + "node_modules/@stylistic/eslint-plugin/node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/@tootallnate/once": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", @@ -2692,6 +2750,12 @@ "undici-types": "~6.20.0" } }, + "node_modules/@types/phoenix": { + "version": "1.6.6", + "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.6.tgz", + "integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==", + "dev": true + }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", @@ -2719,14 +2783,67 @@ "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", "dev": true }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.32.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.32.0.tgz", + "integrity": "sha512-/jU9ettcntkBFmWUzzGgsClEi2ZFiikMX5eEQsmxIAWMOn4H3D4rvHssstmAHGVvrYnaMqdWWWg0b5M6IN/MTQ==", + "dev": true, + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.32.0", + "@typescript-eslint/type-utils": "8.32.0", + "@typescript-eslint/utils": "8.32.0", + "@typescript-eslint/visitor-keys": "8.32.0", + "graphemer": "^1.4.0", + "ignore": "^5.3.1", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.32.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.32.0.tgz", + "integrity": "sha512-B2MdzyWxCE2+SqiZHAjPphft+/2x2FlO9YBx7eKE1BCb+rqBlQdhtAEhzIEdozHd55DXPmxBdpMygFJjfjjA9A==", + "dev": true, + "dependencies": { + "@typescript-eslint/scope-manager": "8.32.0", + "@typescript-eslint/types": "8.32.0", + "@typescript-eslint/typescript-estree": "8.32.0", + "@typescript-eslint/visitor-keys": "8.32.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.19.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.19.1.tgz", - "integrity": "sha512-60L9KIuN/xgmsINzonOcMDSB8p82h95hoBfSBtXuO4jlR1R9L1xSkmVZKgCPVfavDlXihh4ARNjXhh1gGnLC7Q==", + "version": "8.32.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.32.0.tgz", + "integrity": "sha512-jc/4IxGNedXkmG4mx4nJTILb6TMjL66D41vyeaPWvDUmeYQzF3lKtN15WsAeTr65ce4mPxwopPSo1yUUAWw0hQ==", "dev": true, "dependencies": { - "@typescript-eslint/types": "8.19.1", - "@typescript-eslint/visitor-keys": "8.19.1" + "@typescript-eslint/types": "8.32.0", + "@typescript-eslint/visitor-keys": "8.32.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2736,10 +2853,33 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.32.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.32.0.tgz", + "integrity": "sha512-t2vouuYQKEKSLtJaa5bB4jHeha2HJczQ6E5IXPDPgIty9EqcJxpr1QHQ86YyIPwDwxvUmLfP2YADQ5ZY4qddZg==", + "dev": true, + "dependencies": { + "@typescript-eslint/typescript-estree": "8.32.0", + "@typescript-eslint/utils": "8.32.0", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, "node_modules/@typescript-eslint/types": { - "version": "8.19.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.19.1.tgz", - "integrity": "sha512-JBVHMLj7B1K1v1051ZaMMgLW4Q/jre5qGK0Ew6UgXz1Rqh+/xPzV1aW581OM00X6iOfyr1be+QyW8LOUf19BbA==", + "version": "8.32.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.32.0.tgz", + "integrity": "sha512-O5Id6tGadAZEMThM6L9HmVf5hQUXNSxLVKeGJYWNhhVseps/0LddMkp7//VDkzwJ69lPL0UmZdcZwggj9akJaA==", "dev": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2750,19 +2890,19 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.19.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.19.1.tgz", - "integrity": "sha512-jk/TZwSMJlxlNnqhy0Eod1PNEvCkpY6MXOXE/WLlblZ6ibb32i2We4uByoKPv1d0OD2xebDv4hbs3fm11SMw8Q==", + "version": "8.32.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.32.0.tgz", + "integrity": "sha512-pU9VD7anSCOIoBFnhTGfOzlVFQIA1XXiQpH/CezqOBaDppRwTglJzCC6fUQGpfwey4T183NKhF1/mfatYmjRqQ==", "dev": true, "dependencies": { - "@typescript-eslint/types": "8.19.1", - "@typescript-eslint/visitor-keys": "8.19.1", + "@typescript-eslint/types": "8.32.0", + "@typescript-eslint/visitor-keys": "8.32.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", - "ts-api-utils": "^2.0.0" + "ts-api-utils": "^2.1.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2772,7 +2912,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <5.8.0" + "typescript": ">=4.8.4 <5.9.0" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { @@ -2800,15 +2940,15 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.19.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.19.1.tgz", - "integrity": "sha512-IxG5gLO0Ne+KaUc8iW1A+XuKLd63o4wlbI1Zp692n1xojCl/THvgIKXJXBZixTh5dd5+yTJ/VXH7GJaaw21qXA==", + "version": "8.32.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.32.0.tgz", + "integrity": "sha512-8S9hXau6nQ/sYVtC3D6ISIDoJzS1NsCK+gluVhLN2YkBPX+/1wkwyUiDKnxRh15579WoOIyVWnoyIf3yGI9REw==", "dev": true, "dependencies": { - "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.19.1", - "@typescript-eslint/types": "8.19.1", - "@typescript-eslint/typescript-estree": "8.19.1" + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.32.0", + "@typescript-eslint/types": "8.32.0", + "@typescript-eslint/typescript-estree": "8.32.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2819,16 +2959,16 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.8.0" + "typescript": ">=4.8.4 <5.9.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.19.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.19.1.tgz", - "integrity": "sha512-fzmjU8CHK853V/avYZAvuVut3ZTfwN5YtMaoi+X9Y9MA9keaWNHC3zEQ9zvyX/7Hj+5JkNyK1l7TOR2hevHB6Q==", + "version": "8.32.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.32.0.tgz", + "integrity": "sha512-1rYQTCLFFzOI5Nl0c8LUpJT8HxpwVRn9E4CkMsYfuN6ctmQqExjSTzzSk0Tz2apmXy7WU6/6fyaZVVA/thPN+w==", "dev": true, "dependencies": { - "@typescript-eslint/types": "8.19.1", + "@typescript-eslint/types": "8.32.0", "eslint-visitor-keys": "^4.2.0" }, "engines": { @@ -3012,6 +3152,12 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -3267,6 +3413,18 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/bs-logger": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "dev": true, + "dependencies": { + "fast-json-stable-stringify": "2.x" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/bser": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", @@ -3765,6 +3923,21 @@ "integrity": "sha512-7nXPYDeKh6DgJDR/mpt2G7N/hCNSGwwoPVmoI3+4TEwOb07VFN1WMPG0DFf6nMEjrkgdj8Og7l7IaEEk3VE6Zg==", "dev": true }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "dev": true, + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.80", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.80.tgz", @@ -4183,9 +4356,9 @@ "dev": true }, "node_modules/fastq": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.18.0.tgz", - "integrity": "sha512-QKHXPW0hD8g4UET03SdOdunzSouc9N4AuHdsX8XNcTsuz+yYFILVNIX4l9yHABMhiEI9Db0JTTIpu0wB+Y1QQw==", + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", "dev": true, "dependencies": { "reusify": "^1.0.4" @@ -4212,6 +4385,36 @@ "node": ">=16.0.0" } }, + "node_modules/filelist": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", + "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==", + "dev": true, + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -4423,6 +4626,12 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "dev": true }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -4832,6 +5041,24 @@ "node": ">=8" } }, + "node_modules/jake": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.2.tgz", + "integrity": "sha512-2P4SQ0HrLQ+fw6llpLnOaGAvN2Zu6778SJMrCUwns4fOoG9ayrTiZk3VV8sCPkVZF8ab0zksVpS8FDY5pRCNBA==", + "dev": true, + "dependencies": { + "async": "^3.2.3", + "chalk": "^4.0.2", + "filelist": "^1.0.4", + "minimatch": "^3.1.2" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", @@ -5721,6 +5948,12 @@ "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", "dev": true }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "dev": true + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -5757,6 +5990,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true + }, "node_modules/makeerror": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", @@ -5895,8 +6134,8 @@ }, "node_modules/morphdom": { "version": "2.7.5", - "resolved": "https://registry.npmjs.org/morphdom/-/morphdom-2.7.5.tgz", - "integrity": "sha512-z6bfWFMra7kBqDjQGHud1LSXtq5JJC060viEkQFMBX6baIecpkNr2Ywrn2OQfWP3rXiNFQRPoFjD8/TvJcWcDg==" + "resolved": "git+ssh://git@github.com/SteffenDE/morphdom.git#45696269fb8da77345dc96c8fdd1b938b5b1e5f7", + "license": "MIT" }, "node_modules/ms": { "version": "2.1.3", @@ -6570,9 +6809,9 @@ } }, "node_modules/reusify": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", - "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", "dev": true, "engines": { "iojs": ">=1.0.0", @@ -6641,9 +6880,9 @@ } }, "node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", "dev": true, "bin": { "semver": "bin/semver.js" @@ -6929,9 +7168,9 @@ } }, "node_modules/ts-api-utils": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.0.0.tgz", - "integrity": "sha512-xCt/TOAc+EOHS1XPnijD3/yzpH6qg2xppZO1YDqGoVsNXfQfzHpOdNuXwrwOU8u4ITXJyDCTyt8w5g1sZv9ynQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", "dev": true, "engines": { "node": ">=18.12" @@ -6940,6 +7179,67 @@ "typescript": ">=4.8.4" } }, + "node_modules/ts-jest": { + "version": "29.3.2", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.3.2.tgz", + "integrity": "sha512-bJJkrWc6PjFVz5g2DGCNUo8z7oFEYaz1xP1NpeDU7KNLMWPpEyV8Chbpkn8xjzgRDpQhnGMyvyldoL7h8JXyug==", + "dev": true, + "dependencies": { + "bs-logger": "^0.2.6", + "ejs": "^3.1.10", + "fast-json-stable-stringify": "^2.1.0", + "jest-util": "^29.0.0", + "json5": "^2.2.3", + "lodash.memoize": "^4.1.2", + "make-error": "^1.3.6", + "semver": "^7.7.1", + "type-fest": "^4.39.1", + "yargs-parser": "^21.1.1" + }, + "bin": { + "ts-jest": "cli.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "@babel/core": ">=7.0.0-beta.0 <8", + "@jest/transform": "^29.0.0", + "@jest/types": "^29.0.0", + "babel-jest": "^29.0.0", + "jest": "^29.0.0", + "typescript": ">=4.3 <6" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "@jest/transform": { + "optional": true + }, + "@jest/types": { + "optional": true + }, + "babel-jest": { + "optional": true + }, + "esbuild": { + "optional": true + } + } + }, + "node_modules/ts-jest/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tsscmp": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz", @@ -6996,11 +7296,10 @@ } }, "node_modules/typescript": { - "version": "5.7.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", - "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7009,6 +7308,28 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.32.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.32.0.tgz", + "integrity": "sha512-UMq2kxdXCzinFFPsXc9o2ozIpYCCOiEC46MG3yEh5Vipq6BO27otTtEBZA1fQ66DulEUgE97ucQ/3YY66CPg0A==", + "dev": true, + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.32.0", + "@typescript-eslint/parser": "8.32.0", + "@typescript-eslint/utils": "8.32.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, "node_modules/undici-types": { "version": "6.20.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", diff --git a/package.json b/package.json index 6ac39afd6c..4e995f6463 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "version": "1.1.0-dev", "description": "The Phoenix LiveView JavaScript client.", "license": "MIT", + "type": "module", "module": "./priv/static/phoenix_live_view.esm.js", "main": "./priv/static/phoenix_live_view.cjs.js", "unpkg": "./priv/static/phoenix_live_view.min.js", @@ -21,18 +22,21 @@ "LICENSE.md", "package.json", "priv/static/*", - "assets/js/phoenix_live_view/*" + "assets/js/**" ], + "types": "./assets/js/dist/index.d.ts", "dependencies": { - "morphdom": "2.7.5" + "morphdom": "git+https://github.com/SteffenDE/morphdom.git#sd-fix-ts" }, "devDependencies": { "@babel/cli": "7.27.0", "@babel/core": "7.26.10", "@babel/preset-env": "7.26.9", + "@babel/preset-typescript": "^7.27.1", "@eslint/js": "^9.24.0", "@playwright/test": "^1.51.1", - "@stylistic/eslint-plugin-js": "^4.2.0", + "@stylistic/eslint-plugin": "^4.2.0", + "@types/phoenix": "^1.6.6", "css.escape": "^1.5.1", "eslint": "9.24.0", "eslint-plugin-jest": "28.11.0", @@ -42,15 +46,19 @@ "jest-environment-jsdom": "^29.7.0", "jest-monocart-coverage": "^1.1.1", "monocart-reporter": "^2.9.17", - "phoenix": "1.7.21" + "phoenix": "1.7.21", + "ts-jest": "^29.3.2", + "typescript": "^5.8.3", + "typescript-eslint": "^8.32.0" }, "scripts": { "setup": "mix deps.get && npm install", + "build": "tsc", "e2e:server": "MIX_ENV=e2e mix test --cover --export-coverage e2e test/e2e/test_helper.exs", "e2e:test": "mix assets.build && cd test/e2e && npx playwright install && npx playwright test", - "js:test": "jest", - "js:test.coverage": "jest --coverage", - "js:test.watch": "jest --watch", + "js:test": "npm run build && jest", + "js:test.coverage": "npm run build && jest --coverage", + "js:test.watch": "npm run build && jest --watch", "js:lint": "eslint --fix && cd assets && eslint --fix", "test": "npm run js:test && npm run e2e:test", "cover:merge": "node test/e2e/merge-coverage.mjs", diff --git a/test/e2e/merge-coverage.mjs b/test/e2e/merge-coverage.js similarity index 100% rename from test/e2e/merge-coverage.mjs rename to test/e2e/merge-coverage.js diff --git a/test/e2e/playwright.config.js b/test/e2e/playwright.config.js index d0a39eea51..355c7304db 100644 --- a/test/e2e/playwright.config.js +++ b/test/e2e/playwright.config.js @@ -1,6 +1,10 @@ // playwright.config.js // @ts-check -const {devices} = require("@playwright/test") +import {devices} from "@playwright/test" +import {dirname, resolve} from "node:path" +import {fileURLToPath} from "node:url" + +const __dirname = dirname(fileURLToPath(import.meta.url)) /** @type {import("@playwright/test").ReporterDescription} */ const monocartReporter = ["monocart-reporter", { @@ -48,7 +52,7 @@ const config = { } ], outputDir: "test-results", - globalTeardown: require.resolve("./teardown") + globalTeardown: resolve(__dirname, "./teardown.js") } -module.exports = config +export default config diff --git a/test/e2e/teardown.js b/test/e2e/teardown.js index 6a74d85d14..96ab2a2a57 100644 --- a/test/e2e/teardown.js +++ b/test/e2e/teardown.js @@ -1,6 +1,6 @@ -const request = require("@playwright/test").request +import {request} from "@playwright/test" -module.exports = async () => { +export default async () => { try { const context = await request.newContext({baseURL: "http://localhost:4004"}) // gracefully stops the e2e script to export coverage diff --git a/test/e2e/test-fixtures.js b/test/e2e/test-fixtures.js index fc93057848..7786f51283 100644 --- a/test/e2e/test-fixtures.js +++ b/test/e2e/test-fixtures.js @@ -4,7 +4,9 @@ import {addCoverageReport} from "monocart-reporter" import fs from "node:fs" import path from "node:path" +import {fileURLToPath} from "node:url" +const __dirname = path.dirname(fileURLToPath(import.meta.url)) const liveViewSourceMap = JSON.parse(fs.readFileSync(path.resolve(__dirname + "../../../priv/static/phoenix_live_view.esm.js.map")).toString("utf-8")) const test = testBase.extend({ diff --git a/test/e2e/tests/errors.spec.js b/test/e2e/tests/errors.spec.js index 37d9930bbf..bcfc4a069e 100644 --- a/test/e2e/tests/errors.spec.js +++ b/test/e2e/tests/errors.spec.js @@ -1,5 +1,5 @@ -const {test, expect} = require("../test-fixtures") -const {syncLV} = require("../utils") +import {test, expect} from "../test-fixtures" +import {syncLV} from "../utils" /** * https://hexdocs.pm/phoenix_live_view/error-handling.html diff --git a/test/e2e/tests/forms.spec.js b/test/e2e/tests/forms.spec.js index 305f82803a..163afc9e0b 100644 --- a/test/e2e/tests/forms.spec.js +++ b/test/e2e/tests/forms.spec.js @@ -1,7 +1,7 @@ -const {test, expect} = require("../test-fixtures") -const {syncLV, evalLV, evalPlug, attributeMutations} = require("../utils") +import {test, expect} from "../test-fixtures" +import {syncLV, evalLV, evalPlug, attributeMutations} from "../utils" -for(let path of ["/form/nested", "/form"]){ +for(const path of ["/form/nested", "/form"]){ // see also https://github.com/phoenixframework/phoenix_live_view/issues/1759 // https://github.com/phoenixframework/phoenix_live_view/issues/2993 test.describe("restores disabled and readonly states", () => { @@ -9,8 +9,8 @@ for(let path of ["/form/nested", "/form"]){ await page.goto(path) await syncLV(page) await expect(page.locator("input[name=a]")).toHaveAttribute("readonly") - let changesA = attributeMutations(page, "input[name=a]") - let changesB = attributeMutations(page, "input[name=b]") + const changesA = attributeMutations(page, "input[name=a]") + const changesB = attributeMutations(page, "input[name=b]") // can submit multiple times and readonly input stays readonly await page.locator("#submit").click() await syncLV(page) @@ -37,7 +37,7 @@ for(let path of ["/form/nested", "/form"]){ test(`${path} - button disabled state is restored after submits`, async ({page}) => { await page.goto(path) await syncLV(page) - let changes = attributeMutations(page, "#submit") + const changes = attributeMutations(page, "#submit") await page.locator("#submit").click() await syncLV(page) // submit button is disabled while submitting, but then restored @@ -54,7 +54,7 @@ for(let path of ["/form/nested", "/form"]){ test(`${path} - non-form button (phx-disable-with) disabled state is restored after click`, async ({page}) => { await page.goto(path) await syncLV(page) - let changes = attributeMutations(page, "button[type=button]") + const changes = attributeMutations(page, "button[type=button]") await page.locator("button[type=button]").click() await syncLV(page) // submit button is disabled while submitting, but then restored @@ -69,8 +69,8 @@ for(let path of ["/form/nested", "/form"]){ }) }) - for(let additionalParams of ["live-component", ""]){ - let append = additionalParams.length ? ` ${additionalParams}` : "" + for(const additionalParams of ["live-component", ""]){ + const append = additionalParams.length ? ` ${additionalParams}` : "" test.describe(`${path}${append} - form recovery`, () => { test("form state is recovered when socket reconnects", async ({page}) => { let webSocketEvents = [] @@ -185,7 +185,7 @@ for(let path of ["/form/nested", "/form"]){ await page.evaluate(() => new Promise((resolve) => window.liveSocket.disconnect(resolve))) await expect(page.locator(".phx-loading")).toHaveCount(1) - let webSocketEvents = [] + const webSocketEvents = [] page.on("websocket", ws => { ws.on("framesent", event => webSocketEvents.push({type: "sent", payload: event.payload})) ws.on("framereceived", event => webSocketEvents.push({type: "received", payload: event.payload})) @@ -246,8 +246,8 @@ for(let path of ["/form/nested", "/form"]){ end) `, nested ? "#nested" : undefined) await expect(page.getByText("Form was submitted!")).toBeHidden() - let testForm = page.locator("#test-form") - let submitBtn = page.locator("#test-form #submit") + const testForm = page.locator("#test-form") + const submitBtn = page.locator("#test-form #submit") await page.locator("#test-form input[name=b]").fill("test") await expect(testForm).toHaveClass("myformclass phx-change-loading") await expect(testForm).toHaveAttribute("data-phx-ref-loading") @@ -290,7 +290,7 @@ for(let path of ["/form/nested", "/form"]){ test("loading and locked states with latent clone", async ({page, request}) => { await page.goto("/form/stream") - let formHook = page.locator("#form-stream-hook") + const formHook = page.locator("#form-stream-hook") await syncLV(page) const {lv_pid} = await evalLV(page, ` <<"#PID"::binary, pid::binary>> = inspect(self()) @@ -316,9 +316,9 @@ test("loading and locked states with latent clone", async ({page, request}) => { end) `) await expect(formHook).toHaveText("pong") - let testForm = page.locator("#test-form") - let testInput = page.locator("#test-form input[name=myname]") - let submitBtn = page.locator("#test-form button") + const testForm = page.locator("#test-form") + const testInput = page.locator("#test-form input[name=myname]") + const submitBtn = page.locator("#test-form button") // initial 3 stream items await expect(page.locator("#form-stream li")).toHaveCount(3) await testInput.fill("1") diff --git a/test/e2e/tests/issues/2787.spec.js b/test/e2e/tests/issues/2787.spec.js index 81289c7790..ce0ee677d1 100644 --- a/test/e2e/tests/issues/2787.spec.js +++ b/test/e2e/tests/issues/2787.spec.js @@ -1,5 +1,5 @@ -const {test, expect} = require("../../test-fixtures") -const {syncLV} = require("../../utils") +import {test, expect} from "../../test-fixtures" +import {syncLV} from "../../utils" const selectOptions = (locator) => locator.evaluateAll(list => list.map(option => option.value)) diff --git a/test/e2e/tests/issues/2965.spec.js b/test/e2e/tests/issues/2965.spec.js index b944a0de72..c2e1fbccd6 100644 --- a/test/e2e/tests/issues/2965.spec.js +++ b/test/e2e/tests/issues/2965.spec.js @@ -1,6 +1,6 @@ -const {test, expect} = require("../../test-fixtures") -const {syncLV} = require("../../utils") -const {randomBytes} = require("crypto") +import {test, expect} from "../../test-fixtures" +import {syncLV} from "../../utils" +import {randomBytes} from "node:crypto" test("can upload files with custom chunk hook", async ({page}) => { await page.goto("/issues/2965") diff --git a/test/e2e/tests/issues/3026.spec.js b/test/e2e/tests/issues/3026.spec.js index 1439e7a58d..104e0a069c 100644 --- a/test/e2e/tests/issues/3026.spec.js +++ b/test/e2e/tests/issues/3026.spec.js @@ -1,5 +1,5 @@ -const {test, expect} = require("../../test-fixtures") -const {syncLV} = require("../../utils") +import {test, expect} from "../../test-fixtures" +import {syncLV} from "../../utils" test("LiveComponent is re-rendered when racing destory", async ({page}) => { const errors = [] diff --git a/test/e2e/tests/issues/3040.spec.js b/test/e2e/tests/issues/3040.spec.js index 00891387b4..657cfa92e5 100644 --- a/test/e2e/tests/issues/3040.spec.js +++ b/test/e2e/tests/issues/3040.spec.js @@ -1,5 +1,5 @@ -const {test, expect} = require("../../test-fixtures") -const {syncLV} = require("../../utils") +import {test, expect} from "../../test-fixtures" +import {syncLV} from "../../utils" test("click-away does not fire when triggering form submit", async ({page}) => { await page.goto("/issues/3040") diff --git a/test/e2e/tests/issues/3047.spec.js b/test/e2e/tests/issues/3047.spec.js index a9fd399a6e..5c8c0a93cb 100644 --- a/test/e2e/tests/issues/3047.spec.js +++ b/test/e2e/tests/issues/3047.spec.js @@ -1,5 +1,5 @@ -const {test, expect} = require("../../test-fixtures") -const {syncLV} = require("../../utils") +import {test, expect} from "../../test-fixtures" +import {syncLV} from "../../utils" const listItems = async (page) => page.locator("[phx-update=\"stream\"] > span").evaluateAll(list => list.map(el => el.id)) diff --git a/test/e2e/tests/issues/3083.spec.js b/test/e2e/tests/issues/3083.spec.js index 0284c697e8..0a92ed4c20 100644 --- a/test/e2e/tests/issues/3083.spec.js +++ b/test/e2e/tests/issues/3083.spec.js @@ -1,5 +1,5 @@ -const {test, expect} = require("@playwright/test") -const {syncLV, evalLV} = require("../../utils") +import {test, expect} from "../../test-fixtures" +import {syncLV, evalLV} from "../../utils" test("select multiple handles option updates properly", async ({page}) => { await page.goto("/issues/3083?auto=false") diff --git a/test/e2e/tests/issues/3107.spec.js b/test/e2e/tests/issues/3107.spec.js index 7b2cd6bb2f..25a34cf1f6 100644 --- a/test/e2e/tests/issues/3107.spec.js +++ b/test/e2e/tests/issues/3107.spec.js @@ -1,5 +1,5 @@ -const {test, expect} = require("@playwright/test") -const {syncLV} = require("../../utils") +import {test, expect} from "../../test-fixtures" +import {syncLV} from "../../utils" test("keeps value when updating select", async ({page}) => { await page.goto("/issues/3107") diff --git a/test/e2e/tests/issues/3117.spec.js b/test/e2e/tests/issues/3117.spec.js index 39c8bbc538..abad986bfc 100644 --- a/test/e2e/tests/issues/3117.spec.js +++ b/test/e2e/tests/issues/3117.spec.js @@ -1,5 +1,5 @@ -const {test, expect} = require("../../test-fixtures") -const {syncLV} = require("../../utils") +import {test, expect} from "../../test-fixtures" +import {syncLV} from "../../utils" test("LiveComponent with static FC root is not reset", async ({page}) => { const errors = [] diff --git a/test/e2e/tests/issues/3169.spec.js b/test/e2e/tests/issues/3169.spec.js index 217f06a52e..a4bbe55ae9 100644 --- a/test/e2e/tests/issues/3169.spec.js +++ b/test/e2e/tests/issues/3169.spec.js @@ -1,5 +1,5 @@ -const {test, expect} = require("../../test-fixtures") -const {syncLV} = require("../../utils") +import {test, expect} from "../../test-fixtures" +import {syncLV} from "../../utils" const inputVals = async (page) => { return page.locator("input[type=\"text\"]").evaluateAll(list => list.map(i => i.value)) diff --git a/test/e2e/tests/issues/3194.spec.js b/test/e2e/tests/issues/3194.spec.js index 8ae45b9bfe..ed00bb8510 100644 --- a/test/e2e/tests/issues/3194.spec.js +++ b/test/e2e/tests/issues/3194.spec.js @@ -1,5 +1,5 @@ -const {test, expect} = require("../../test-fixtures") -const {syncLV} = require("../../utils") +import {test, expect} from "../../test-fixtures" +import {syncLV} from "../../utils" test("does not send event to wrong LV when submitting form with debounce blur", async ({page}) => { const logs = [] diff --git a/test/e2e/tests/issues/3200.spec.js b/test/e2e/tests/issues/3200.spec.js index b5c02817f6..6ebe2cc53b 100644 --- a/test/e2e/tests/issues/3200.spec.js +++ b/test/e2e/tests/issues/3200.spec.js @@ -1,5 +1,5 @@ -const {test, expect} = require("../../test-fixtures") -const {syncLV} = require("../../utils") +import {test, expect} from "../../test-fixtures" +import {syncLV} from "../../utils" // https://github.com/phoenixframework/phoenix_live_view/issues/3200 test("phx-target='selector' is used correctly for form recovery", async ({page}) => { diff --git a/test/e2e/tests/issues/3378.spec.js b/test/e2e/tests/issues/3378.spec.js index 5ae52d6565..bbc0c968d1 100644 --- a/test/e2e/tests/issues/3378.spec.js +++ b/test/e2e/tests/issues/3378.spec.js @@ -1,5 +1,5 @@ -const {test, expect} = require("../../test-fixtures") -const {syncLV} = require("../../utils") +import {test, expect} from "../../test-fixtures" +import {syncLV} from "../../utils" test("can rejoin with nested streams without errors", async ({page}) => { const errors = [] diff --git a/test/e2e/tests/issues/3448.spec.js b/test/e2e/tests/issues/3448.spec.js index 954a339a78..bd6bd12689 100644 --- a/test/e2e/tests/issues/3448.spec.js +++ b/test/e2e/tests/issues/3448.spec.js @@ -1,5 +1,5 @@ -const {test, expect} = require("../../test-fixtures") -const {syncLV} = require("../../utils") +import {test, expect} from "../../test-fixtures" +import {syncLV} from "../../utils" // https://github.com/phoenixframework/phoenix_live_view/issues/3448 test("focus is handled correctly when patching locked form", async ({page}) => { diff --git a/test/e2e/tests/issues/3496.spec.js b/test/e2e/tests/issues/3496.spec.js index 2e638b6582..5a3bce0ad9 100644 --- a/test/e2e/tests/issues/3496.spec.js +++ b/test/e2e/tests/issues/3496.spec.js @@ -1,5 +1,5 @@ -const {test, expect} = require("../../test-fixtures") -const {syncLV} = require("../../utils") +import {test, expect} from "../../test-fixtures" +import {syncLV} from "../../utils" // https://github.com/phoenixframework/phoenix_live_view/issues/3496 test("hook is initialized properly when reusing id between sticky and non sticky LiveViews", async ({page}) => { diff --git a/test/e2e/tests/issues/3529.spec.js b/test/e2e/tests/issues/3529.spec.js index f2af4cadcb..30f257140e 100644 --- a/test/e2e/tests/issues/3529.spec.js +++ b/test/e2e/tests/issues/3529.spec.js @@ -1,5 +1,5 @@ -const {test, expect} = require("../../test-fixtures") -const {syncLV} = require("../../utils") +import {test, expect} from "../../test-fixtures" +import {syncLV} from "../../utils" const pageText = async (page) => await page.evaluate(() => document.querySelector("h1").innerText) diff --git a/test/e2e/tests/issues/3530.spec.js b/test/e2e/tests/issues/3530.spec.js index a39bd6ea3b..c8d058ac3c 100644 --- a/test/e2e/tests/issues/3530.spec.js +++ b/test/e2e/tests/issues/3530.spec.js @@ -1,5 +1,5 @@ -const {test, expect} = require("../../test-fixtures") -const {syncLV} = require("../../utils") +import {test, expect} from "../../test-fixtures" +import {syncLV} from "../../utils" // https://github.com/phoenixframework/phoenix_live_view/issues/3530 test("hook is initialized properly when using a stream of nested LiveViews", async ({page}) => { diff --git a/test/e2e/tests/issues/3612.spec.js b/test/e2e/tests/issues/3612.spec.js index 63497ca677..85da95642b 100644 --- a/test/e2e/tests/issues/3612.spec.js +++ b/test/e2e/tests/issues/3612.spec.js @@ -1,5 +1,5 @@ -const {test, expect} = require("../../test-fixtures") -const {syncLV} = require("../../utils") +import {test, expect} from "../../test-fixtures" +import {syncLV} from "../../utils" // https://github.com/phoenixframework/phoenix_live_view/issues/3612 test("sticky LiveView stays connected when using push_navigate", async ({page}) => { diff --git a/test/e2e/tests/issues/3647.spec.js b/test/e2e/tests/issues/3647.spec.js index 698e2efe08..238b58e300 100644 --- a/test/e2e/tests/issues/3647.spec.js +++ b/test/e2e/tests/issues/3647.spec.js @@ -1,5 +1,5 @@ -const {test, expect} = require("../../test-fixtures") -const {syncLV} = require("../../utils") +import {test, expect} from "../../test-fixtures" +import {syncLV} from "../../utils" // https://github.com/phoenixframework/phoenix_live_view/issues/3647 test("upload works when input event follows immediately afterwards", async ({page}) => { diff --git a/test/e2e/tests/issues/3651.spec.js b/test/e2e/tests/issues/3651.spec.js index 04a656ea86..e98792ab5f 100644 --- a/test/e2e/tests/issues/3651.spec.js +++ b/test/e2e/tests/issues/3651.spec.js @@ -1,5 +1,5 @@ -const {test, expect} = require("../../test-fixtures") -const {syncLV} = require("../../utils") +import {test, expect} from "../../test-fixtures" +import {syncLV} from "../../utils" // https://github.com/phoenixframework/phoenix_live_view/issues/3651 test("locked hook with dynamic id is properly cleared", async ({page}) => { diff --git a/test/e2e/tests/issues/3656.spec.js b/test/e2e/tests/issues/3656.spec.js index cf7fffbe4a..f6f31efdfb 100644 --- a/test/e2e/tests/issues/3656.spec.js +++ b/test/e2e/tests/issues/3656.spec.js @@ -1,12 +1,12 @@ -const {test, expect} = require("../../test-fixtures") -const {syncLV, attributeMutations} = require("../../utils") +import {test, expect} from "../../test-fixtures" +import {syncLV, attributeMutations} from "../../utils" // https://github.com/phoenixframework/phoenix_live_view/issues/3656 test("phx-click-loading is removed from links in sticky LiveViews", async ({page}) => { await page.goto("/issues/3656") await syncLV(page) - let changes = attributeMutations(page, "nav a") + const changes = attributeMutations(page, "nav a") const link = page.getByRole("link", {name: "Link 1"}) await link.click() diff --git a/test/e2e/tests/issues/3658.spec.js b/test/e2e/tests/issues/3658.spec.js index 450d64fd43..c432d3f956 100644 --- a/test/e2e/tests/issues/3658.spec.js +++ b/test/e2e/tests/issues/3658.spec.js @@ -1,5 +1,5 @@ -const {test, expect} = require("../../test-fixtures") -const {syncLV} = require("../../utils") +import {test, expect} from "../../test-fixtures" +import {syncLV} from "../../utils" // https://github.com/phoenixframework/phoenix_live_view/issues/3658 test("phx-remove elements inside sticky LiveViews are not removed when navigating", async ({page}) => { diff --git a/test/e2e/tests/issues/3681.spec.js b/test/e2e/tests/issues/3681.spec.js index 38a3a5f808..bacea06cfe 100644 --- a/test/e2e/tests/issues/3681.spec.js +++ b/test/e2e/tests/issues/3681.spec.js @@ -1,5 +1,5 @@ -const {test, expect} = require("../../test-fixtures") -const {syncLV} = require("../../utils") +import {test, expect} from "../../test-fixtures" +import {syncLV} from "../../utils" // https://github.com/phoenixframework/phoenix_live_view/issues/3681 test("streams in nested LiveViews are not reset when they share the same stream ref", async ({page, request}) => { diff --git a/test/e2e/tests/issues/3684.spec.js b/test/e2e/tests/issues/3684.spec.js index 26cb302545..eecbd10aca 100644 --- a/test/e2e/tests/issues/3684.spec.js +++ b/test/e2e/tests/issues/3684.spec.js @@ -1,5 +1,5 @@ -const {test, expect} = require("../../test-fixtures") -const {syncLV} = require("../../utils") +import {test, expect} from "../../test-fixtures" +import {syncLV} from "../../utils" // https://github.com/phoenixframework/phoenix_live_view/issues/3684 test("nested clones are correctly applied", async ({page}) => { diff --git a/test/e2e/tests/issues/3686.spec.js b/test/e2e/tests/issues/3686.spec.js index 10ce107c22..2e328c4af0 100644 --- a/test/e2e/tests/issues/3686.spec.js +++ b/test/e2e/tests/issues/3686.spec.js @@ -1,5 +1,5 @@ -const {test, expect} = require("../../test-fixtures") -const {syncLV} = require("../../utils") +import {test, expect} from "../../test-fixtures" +import {syncLV} from "../../utils" // https://github.com/phoenixframework/phoenix_live_view/issues/3686 test("flash is copied across fallback redirect", async ({page}) => { diff --git a/test/e2e/tests/issues/3709.spec.js b/test/e2e/tests/issues/3709.spec.js index 53dceedb27..b3e504027b 100644 --- a/test/e2e/tests/issues/3709.spec.js +++ b/test/e2e/tests/issues/3709.spec.js @@ -1,5 +1,5 @@ -const {test, expect} = require("../../test-fixtures") -const {syncLV} = require("../../utils") +import {test, expect} from "../../test-fixtures" +import {syncLV} from "../../utils" // https://github.com/phoenixframework/phoenix_live_view/issues/3709 test("pendingDiffs don't race with navigation", async ({page}) => { diff --git a/test/e2e/tests/issues/3719.spec.js b/test/e2e/tests/issues/3719.spec.js index 4e49583244..826f52ce37 100644 --- a/test/e2e/tests/issues/3719.spec.js +++ b/test/e2e/tests/issues/3719.spec.js @@ -1,5 +1,5 @@ -const {test, expect} = require("../../test-fixtures") -const {syncLV} = require("../../utils") +import {test, expect} from "../../test-fixtures" +import {syncLV} from "../../utils" // https://github.com/phoenixframework/phoenix_live_view/issues/3719 test("target is properly decoded", async ({page}) => { diff --git a/test/e2e/tests/js.spec.js b/test/e2e/tests/js.spec.js index af40f2e4e8..3c8dba43ba 100644 --- a/test/e2e/tests/js.spec.js +++ b/test/e2e/tests/js.spec.js @@ -1,5 +1,5 @@ -const {test, expect} = require("../test-fixtures") -const {syncLV, attributeMutations} = require("../utils") +import {test, expect} from "../test-fixtures" +import {syncLV, attributeMutations} from "../utils" test("toggle_attribute", async ({page}) => { await page.goto("/js") diff --git a/test/e2e/tests/navigation.spec.js b/test/e2e/tests/navigation.spec.js index f6dc3d7630..2cdaf15549 100644 --- a/test/e2e/tests/navigation.spec.js +++ b/test/e2e/tests/navigation.spec.js @@ -1,5 +1,5 @@ -const {test, expect} = require("../test-fixtures") -const {syncLV} = require("../utils") +import {test, expect} from "../test-fixtures" +import {syncLV} from "../utils" let webSocketEvents = [] let networkEvents = [] @@ -237,7 +237,7 @@ test("scrolls hash el into view after live navigation (issue #3452)", async ({pa await page.getByRole("link", {name: "Navigate to 42"}).click() await expect(page).toHaveURL("/navigation/b#items-item-42") - let scrollTop = await page.evaluate(() => document.documentElement.scrollTop) + const scrollTop = await page.evaluate(() => document.documentElement.scrollTop) const offset = (await page.locator("#items-item-42").evaluate((el) => el.offsetTop)) - 200 expect(scrollTop).not.toBe(0) expect(scrollTop).toBeGreaterThanOrEqual(offset - 500) diff --git a/test/e2e/tests/select.spec.js b/test/e2e/tests/select.spec.js index 8ba89e276d..f9fe5ab6c9 100644 --- a/test/e2e/tests/select.spec.js +++ b/test/e2e/tests/select.spec.js @@ -1,5 +1,5 @@ -const {test, expect} = require("../test-fixtures") -const {syncLV} = require("../utils") +import {test, expect} from "../test-fixtures" +import {syncLV} from "../utils" // this tests issue #2659 // https://github.com/phoenixframework/phoenix_live_view/pull/2659 diff --git a/test/e2e/tests/streams.spec.js b/test/e2e/tests/streams.spec.js index e4c786a67f..ee009cca8d 100644 --- a/test/e2e/tests/streams.spec.js +++ b/test/e2e/tests/streams.spec.js @@ -1,5 +1,5 @@ -const {test, expect} = require("../test-fixtures") -const {syncLV, evalLV} = require("../utils") +import {test, expect} from "../test-fixtures" +import {syncLV, evalLV} from "../utils" const usersInDom = async (page, parent) => { return await page.locator(`#${parent} > *`) @@ -695,7 +695,7 @@ test("stream nested in a LiveComponent is properly restored on reset", async ({p {id: "items-d", text: expect.stringMatching(/D/)} ]) - for(let id of ["a", "b", "c", "d"]){ + for(const id of ["a", "b", "c", "d"]){ expect(await childItems(page, `items-${id}`)).toEqual([ {id: `nested-items-${id}-a`, text: "N-A"}, {id: `nested-items-${id}-b`, text: "N-B"}, @@ -715,7 +715,7 @@ test("stream nested in a LiveComponent is properly restored on reset", async ({p {id: "nested-items-a-g", text: "N-G"}, ]) // unchanged - for(let id of ["b", "c", "d"]){ + for(const id of ["b", "c", "d"]){ expect(await childItems(page, `items-${id}`)).toEqual([ {id: `nested-items-${id}-a`, text: "N-A"}, {id: `nested-items-${id}-b`, text: "N-B"}, @@ -735,7 +735,7 @@ test("stream nested in a LiveComponent is properly restored on reset", async ({p ]) // the new children's stream items have the correct order - for(let id of ["e", "f", "g"]){ + for(const id of ["e", "f", "g"]){ expect(await childItems(page, `items-${id}`)).toEqual([ {id: `nested-items-${id}-a`, text: "N-A"}, {id: `nested-items-${id}-b`, text: "N-B"}, diff --git a/test/e2e/tests/uploads.spec.js b/test/e2e/tests/uploads.spec.js index 34aa71e2be..fcef224999 100644 --- a/test/e2e/tests/uploads.spec.js +++ b/test/e2e/tests/uploads.spec.js @@ -1,5 +1,5 @@ -const {test, expect} = require("../test-fixtures") -const {syncLV, attributeMutations} = require("../utils") +import {test, expect} from "../test-fixtures" +import {syncLV, attributeMutations} from "../utils" // https://stackoverflow.com/questions/10623798/how-do-i-read-the-contents-of-a-node-js-stream-into-a-string-variable const readStream = (stream) => new Promise((resolve) => { @@ -19,8 +19,8 @@ test("can upload a file", async ({page}) => { await page.goto("/upload") await syncLV(page) - let changesForm = attributeMutations(page, "#upload-form") - let changesInput = attributeMutations(page, "#upload-form input") + const changesForm = attributeMutations(page, "#upload-form") + const changesInput = attributeMutations(page, "#upload-form input") // wait for the change listeners to be ready await page.waitForTimeout(50) @@ -157,7 +157,7 @@ test("auto upload", async ({page}) => { await page.goto("/upload?auto_upload=1") await syncLV(page) - let changes = attributeMutations(page, "#upload-form input") + const changes = attributeMutations(page, "#upload-form input") // wait for the change listeners to be ready await page.waitForTimeout(50) await page.locator("#upload-form input").setInputFiles([ diff --git a/test/e2e/utils.js b/test/e2e/utils.js index ca74f681e1..c29a3d4ad6 100644 --- a/test/e2e/utils.js +++ b/test/e2e/utils.js @@ -1,10 +1,10 @@ -const {expect} = require("@playwright/test") -const Crypto = require("crypto") +import {expect} from "@playwright/test" +import Crypto from "node:crypto" -const randomString = (size = 21) => Crypto.randomBytes(size).toString("base64").slice(0, size) +export const randomString = (size = 21) => Crypto.randomBytes(size).toString("base64").slice(0, size) // a helper function to wait until the LV has no pending events -const syncLV = async (page) => { +export const syncLV = async (page) => { const promises = [ expect(page.locator(".phx-connected").first()).toBeVisible(), expect(page.locator(".phx-change-loading")).toHaveCount(0), @@ -18,7 +18,7 @@ const syncLV = async (page) => { // for the given selector; it uses private phoenix live view js functions, so it could // break in the future // we handle the evaluation in a LV hook -const evalLV = async (page, code, selector = "[data-phx-main]") => await page.evaluate(([code, selector]) => { +export const evalLV = async (page, code, selector = "[data-phx-main]") => await page.evaluate(([code, selector]) => { return new Promise((resolve) => { window.liveSocket.main.withinTargets(selector, (targetView, targetCtx) => { targetView.pushEvent( @@ -36,13 +36,13 @@ const evalLV = async (page, code, selector = "[data-phx-main]") => await page.ev // executes the given code inside a new process // (in context of a plug request) -const evalPlug = async (request, code) => { +export const evalPlug = async (request, code) => { return await request.post("/eval", { data: {code} }).then(resp => resp.json()) } -const attributeMutations = (page, selector) => { +export const attributeMutations = (page, selector) => { // this is a bit of a hack, basically we create a MutationObserver on the page // that will record any changes to a selector until the promise is awaited // @@ -80,5 +80,3 @@ const attributeMutations = (page, selector) => { return promise } } - -module.exports = {randomString, syncLV, evalLV, evalPlug, attributeMutations} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000000..b48e0d1689 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "target": "es2016", + "module": "esnext", + "moduleResolution": "node", + "allowJs": true, + "checkJs": true, + "noEmit": false, + "strict": false, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "baseUrl": "./assets/js", + "stripInternal": true, + "paths": { + "*": [ + "*", + "phoenix_live_view/*" + ] + }, + "declaration": true, + "outDir": "./assets/js/dist" + }, + "include": [ + "./assets/js/phoenix_live_view/*.js", + "./assets/js/phoenix_live_view/*.ts" + ], + "exclude": [ + "node_modules", + "assets/test/**/*" + ] +} \ No newline at end of file From 9cbc9e19476ddac25296f88a6555998af1acac35 Mon Sep 17 00:00:00 2001 From: Steffen Deusch Date: Thu, 8 May 2025 16:04:54 +0200 Subject: [PATCH 2/5] don't rely on typescript for tests --- .gitignore | 2 +- assets/js/phoenix_live_view/view_hook.ts | 2 ++ eslint.config.js | 2 +- jest.config.js | 4 ++-- package.json | 4 ++-- tsconfig.json | 3 ++- 6 files changed, 10 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index 525d09531a..3b2b069ca3 100644 --- a/.gitignore +++ b/.gitignore @@ -27,4 +27,4 @@ node_modules /test/e2e/test-results/ /playwright-report/ /coverage/ -/assets/js/dist/ +/assets/js/types/ diff --git a/assets/js/phoenix_live_view/view_hook.ts b/assets/js/phoenix_live_view/view_hook.ts index c12f9efd0e..62b67326ef 100644 --- a/assets/js/phoenix_live_view/view_hook.ts +++ b/assets/js/phoenix_live_view/view_hook.ts @@ -148,6 +148,8 @@ export interface HookInterface { [key: string]: any; } +// based on https://github.com/DefinitelyTyped/DefinitelyTyped/blob/fac1aa75acdddbf4f1a95e98ee2297b54ce4b4c9/types/phoenix_live_view/hooks.d.ts#L26 +// licensed under MIT export interface HookObject { /** * The mounted callback. diff --git a/eslint.config.js b/eslint.config.js index f8eeb96aae..44a215be79 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -73,7 +73,7 @@ const sharedRules = { export default tseslint.config([ { ignores: [ - "assets/js/dist/", + "assets/js/types/", "test/e2e/test-results/", "coverage/", "cover/", diff --git a/jest.config.js b/jest.config.js index d4879a51ac..87368062fb 100644 --- a/jest.config.js +++ b/jest.config.js @@ -84,8 +84,8 @@ export default { // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module moduleNameMapper: { - "^phoenix_live_view$": "/assets/js/dist/index.js", - "^phoenix_live_view/(.*)$": "/assets/js/dist/$1.js" + "^phoenix_live_view$": "/assets/js/phoenix_live_view/index.ts", + "^phoenix_live_view/(.*)$": "/assets/js/phoenix_live_view/$1" }, // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader diff --git a/package.json b/package.json index 4e995f6463..9951c4dd64 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "priv/static/*", "assets/js/**" ], - "types": "./assets/js/dist/index.d.ts", + "types": "./assets/js/types/index.d.ts", "dependencies": { "morphdom": "git+https://github.com/SteffenDE/morphdom.git#sd-fix-ts" }, @@ -61,7 +61,7 @@ "js:test.watch": "npm run build && jest --watch", "js:lint": "eslint --fix && cd assets && eslint --fix", "test": "npm run js:test && npm run e2e:test", - "cover:merge": "node test/e2e/merge-coverage.mjs", + "cover:merge": "node test/e2e/merge-coverage.js", "cover": "npm run test && npm run cover:merge", "cover:report": "npx monocart show-report cover/merged-js/index.html" } diff --git a/tsconfig.json b/tsconfig.json index b48e0d1689..6d80cd0ced 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,7 +18,8 @@ ] }, "declaration": true, - "outDir": "./assets/js/dist" + "emitDeclarationOnly": true, + "outDir": "./assets/js/types" }, "include": [ "./assets/js/phoenix_live_view/*.js", From 7a9d2b03f5cf43e4d8ab844ff292123afc24a6d7 Mon Sep 17 00:00:00 2001 From: Steffen Deusch Date: Thu, 8 May 2025 16:22:56 +0200 Subject: [PATCH 3/5] let's call it ViewHook --- assets/js/phoenix_live_view/index.ts | 10 +++++----- assets/js/phoenix_live_view/view_hook.ts | 8 ++++---- guides/client/js-interop.md | 18 ++++++++++++++++++ 3 files changed, 27 insertions(+), 9 deletions(-) diff --git a/assets/js/phoenix_live_view/index.ts b/assets/js/phoenix_live_view/index.ts index 7670994c25..93b71d8bcb 100644 --- a/assets/js/phoenix_live_view/index.ts +++ b/assets/js/phoenix_live_view/index.ts @@ -8,11 +8,11 @@ See the hexdocs at `https://hexdocs.pm/phoenix_live_view` for documentation. import OriginalLiveSocket, {isUsedInput} from "./live_socket" import DOM from "./dom" -import {ViewHook as Hook} from "./view_hook" +import {ViewHook} from "./view_hook" import View from "./view" import type {LiveSocketJSCommands} from "./js_commands" -import type {HookObject, HooksOptions} from "./view_hook" +import type {Hook, HooksOptions} from "./view_hook" import type {Socket as PhoenixSocket} from "phoenix" /** @@ -275,11 +275,11 @@ const LiveSocket = OriginalLiveSocket as unknown as LiveSocketConstructor * * @returns Returns the Hook instance for the custom element. */ -function createHook(el: HTMLElement, callbacks: HookObject): Hook{ +function createHook(el: HTMLElement, callbacks: Hook): Hook{ let existingHook = DOM.getCustomElHook(el) if(existingHook){ return existingHook } - let hook = new Hook(View.closestView(el), el, callbacks) + let hook = new ViewHook(View.closestView(el), el, callbacks) DOM.putCustomElHook(el, hook) return hook } @@ -288,7 +288,7 @@ export { LiveSocket, isUsedInput, createHook, + ViewHook, Hook, - HookObject, HooksOptions } diff --git a/assets/js/phoenix_live_view/view_hook.ts b/assets/js/phoenix_live_view/view_hook.ts index 62b67326ef..302527a783 100644 --- a/assets/js/phoenix_live_view/view_hook.ts +++ b/assets/js/phoenix_live_view/view_hook.ts @@ -150,7 +150,7 @@ export interface HookInterface { // based on https://github.com/DefinitelyTyped/DefinitelyTyped/blob/fac1aa75acdddbf4f1a95e98ee2297b54ce4b4c9/types/phoenix_live_view/hooks.d.ts#L26 // licensed under MIT -export interface HookObject { +export interface Hook { /** * The mounted callback. * @@ -238,7 +238,7 @@ export class ViewHook implements HookInterface { static makeID(){ return viewHookID++ } static elementID(el: HTMLElement){ return DOM.private(el, HOOK_ID) } - constructor(view: View | null, el: HTMLElement, callbacks?: HookObject){ + constructor(view: View | null, el: HTMLElement, callbacks?: Hook){ this.el = el this.__attachView(view) this.__listeners = new Set() @@ -272,7 +272,7 @@ export class ViewHook implements HookInterface { } } - const lifecycleMethods: (keyof HookObject)[] = ["mounted", "beforeUpdate", "updated", "destroyed", "disconnected", "reconnected"] + const lifecycleMethods: (keyof Hook)[] = ["mounted", "beforeUpdate", "updated", "destroyed", "disconnected", "reconnected"] lifecycleMethods.forEach(methodName => { if(callbacks[methodName] && typeof callbacks[methodName] === "function"){ (this as any)[methodName] = callbacks[methodName] @@ -396,6 +396,6 @@ export class ViewHook implements HookInterface { } } -export type HooksOptions = Record +export type HooksOptions = Record export default ViewHook diff --git a/guides/client/js-interop.md b/guides/client/js-interop.md index 8ec525e044..b6acf2e601 100644 --- a/guides/client/js-interop.md +++ b/guides/client/js-interop.md @@ -242,6 +242,24 @@ let liveSocket = new LiveSocket("/live", Socket, { In the example above, all attributes starting with `data-js-` won't be replaced when the DOM is patched by LiveView. +A hook can also be defined as a subclass of `ViewHook`: + +```javascript +import { ViewHook } from "phoenix_live_view" + +class MyHook extends ViewHook { + mounted() { + ... + } +} + +let liveSocket = new LiveSocket(..., { + hooks: { + MyHook + } +}) +``` + ### Client-server communication A hook can push events to the LiveView by using the `pushEvent` function and receive a From db24dca03a99e1831d630f8f6a3effe88e008f65 Mon Sep 17 00:00:00 2001 From: Steffen Deusch Date: Thu, 8 May 2025 20:35:14 +0200 Subject: [PATCH 4/5] use typescript in tests --- .github/workflows/ci.yml | 10 +- assets/js/phoenix_live_view/browser.js | 2 +- assets/js/phoenix_live_view/index.ts | 2 +- assets/js/phoenix_live_view/rendered.js | 16 +- assets/js/phoenix_live_view/view.js | 4 +- .../test/{browser_test.js => browser_test.ts} | 49 ++++- .../{debounce_test.js => debounce_test.ts} | 28 +-- assets/test/{dom_test.js => dom_test.ts} | 17 +- assets/test/{event_test.js => event_test.ts} | 0 assets/test/globals.d.ts | 7 + assets/test/index_test.js | 8 - assets/test/index_test.ts | 24 +++ .../{event_test.js => event_test.ts} | 0 .../{metadata_test.js => metadata_test.ts} | 0 assets/test/{js_test.js => js_test.ts} | 178 +++++++++--------- ...ive_socket_test.js => live_socket_test.ts} | 2 +- ...odify_root_test.js => modify_root_test.ts} | 0 .../{rendered_test.js => rendered_test.ts} | 4 +- .../test/{test_helpers.js => test_helpers.ts} | 6 +- assets/test/tsconfig.json | 23 +++ assets/test/{utils_test.js => utils_test.ts} | 0 assets/test/{view_test.js => view_test.ts} | 24 +-- eslint.config.js | 30 +-- jest.config.js | 2 +- package-lock.json | 11 ++ package.json | 2 + 26 files changed, 276 insertions(+), 173 deletions(-) rename assets/test/{browser_test.js => browser_test.ts} (55%) rename assets/test/{debounce_test.js => debounce_test.ts} (90%) rename assets/test/{dom_test.js => dom_test.ts} (96%) rename assets/test/{event_test.js => event_test.ts} (100%) create mode 100644 assets/test/globals.d.ts delete mode 100644 assets/test/index_test.js create mode 100644 assets/test/index_test.ts rename assets/test/integration/{event_test.js => event_test.ts} (100%) rename assets/test/integration/{metadata_test.js => metadata_test.ts} (100%) rename assets/test/{js_test.js => js_test.ts} (89%) rename assets/test/{live_socket_test.js => live_socket_test.ts} (99%) rename assets/test/{modify_root_test.js => modify_root_test.ts} (100%) rename assets/test/{rendered_test.js => rendered_test.ts} (99%) rename assets/test/{test_helpers.js => test_helpers.ts} (93%) create mode 100644 assets/test/tsconfig.json rename assets/test/{utils_test.js => utils_test.ts} (100%) rename assets/test/{view_test.js => view_test.ts} (98%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 906986e13a..64abd73603 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -142,12 +142,18 @@ jobs: restore-keys: | ${{ runner.os }}-node- - - name: npm install and test - run: npm run setup && npm run js:test + - name: setup JS + run: npm run setup + + - name: typecheck + run: npm run build && npm run typecheck:tests - name: eslint run: npx eslint + - name: test + run: npm run js:test + - uses: actions/upload-artifact@v4 if: always() with: diff --git a/assets/js/phoenix_live_view/browser.js b/assets/js/phoenix_live_view/browser.js index 1d5c15b0bf..76ce876423 100644 --- a/assets/js/phoenix_live_view/browser.js +++ b/assets/js/phoenix_live_view/browser.js @@ -69,7 +69,7 @@ const Browser = { redirect(toURL, flash){ if(flash){ this.setCookie("__phoenix_flash__", flash, 60) } - window.location = toURL + window.location.href = toURL }, localKey(namespace, subkey){ return `${namespace}-${subkey}` }, diff --git a/assets/js/phoenix_live_view/index.ts b/assets/js/phoenix_live_view/index.ts index 93b71d8bcb..9ca48c860f 100644 --- a/assets/js/phoenix_live_view/index.ts +++ b/assets/js/phoenix_live_view/index.ts @@ -275,7 +275,7 @@ const LiveSocket = OriginalLiveSocket as unknown as LiveSocketConstructor * * @returns Returns the Hook instance for the custom element. */ -function createHook(el: HTMLElement, callbacks: Hook): Hook{ +function createHook(el: HTMLElement, callbacks: Hook): ViewHook{ let existingHook = DOM.getCustomElHook(el) if(existingHook){ return existingHook } diff --git a/assets/js/phoenix_live_view/rendered.js b/assets/js/phoenix_live_view/rendered.js index 0f62bdd9e3..3291f99cd0 100644 --- a/assets/js/phoenix_live_view/rendered.js +++ b/assets/js/phoenix_live_view/rendered.js @@ -135,15 +135,15 @@ export default class Rendered { parentViewId(){ return this.viewId } toString(onlyCids){ - const [str, streams] = this.recursiveToString(this.rendered, this.rendered[COMPONENTS], onlyCids, true, {}) - return [str, streams] + const {buffer: str, streams: streams} = this.recursiveToString(this.rendered, this.rendered[COMPONENTS], onlyCids, true, {}) + return {buffer: str, streams: streams} } recursiveToString(rendered, components = rendered[COMPONENTS], onlyCids, changeTracking, rootAttrs){ onlyCids = onlyCids ? new Set(onlyCids) : null const output = {buffer: "", components: components, onlyCids: onlyCids, streams: new Set()} this.toOutputBuffer(rendered, null, output, changeTracking, rootAttrs) - return [output.buffer, output.streams] + return {buffer: output.buffer, streams: output.streams} } componentCIDs(diff){ return Object.keys(diff[COMPONENTS] || {}).map(i => parseInt(i)) } @@ -264,9 +264,9 @@ export default class Rendered { } componentToString(cid){ - const [str, streams] = this.recursiveCIDToString(this.rendered[COMPONENTS], cid, null) + const {buffer: str, streams} = this.recursiveCIDToString(this.rendered[COMPONENTS], cid, null) const [strippedHTML, _before, _after] = modifyRoot(str, {}) - return [strippedHTML, streams] + return {buffer: strippedHTML, streams: streams} } pruneCIDs(cids){ @@ -370,7 +370,7 @@ export default class Rendered { dynamicToBuffer(rendered, templates, output, changeTracking){ if(typeof (rendered) === "number"){ - const [str, streams] = this.recursiveCIDToString(output.components, rendered, output.onlyCids) + const {buffer: str, streams} = this.recursiveCIDToString(output.components, rendered, output.onlyCids) output.buffer += str output.streams = new Set([...output.streams, ...streams]) } else if(isObject(rendered)){ @@ -409,10 +409,10 @@ export default class Rendered { component.magicId = `c${cid}-${this.parentViewId()}` // enable change tracking as long as the component hasn't been reset const changeTracking = !component.reset - const [html, streams] = this.recursiveToString(component, components, onlyCids, changeTracking, attrs) + const {buffer: html, streams} = this.recursiveToString(component, components, onlyCids, changeTracking, attrs) // disable reset after we've rendered delete component.reset - return [html, streams] + return {buffer: html, streams: streams} } } diff --git a/assets/js/phoenix_live_view/view.js b/assets/js/phoenix_live_view/view.js index b1fc24d7b6..e3c7b96da0 100644 --- a/assets/js/phoenix_live_view/view.js +++ b/assets/js/phoenix_live_view/view.js @@ -703,14 +703,14 @@ export default class View { // Don't skip any component in the diff nor any marked as pruned // (as they may have been added back) const cids = diff ? this.rendered.componentCIDs(diff) : null - const [html, streams] = this.rendered.toString(cids) + const {buffer: html, streams} = this.rendered.toString(cids) return [`<${tag}>${html}`, streams] }) } componentPatch(diff, cid){ if(isEmpty(diff)) return false - const [html, streams] = this.rendered.componentToString(cid) + const {buffer: html, streams} = this.rendered.componentToString(cid) const patch = new DOMPatch(this, this.el, this.id, html, streams, cid) const childrenAdded = this.performPatch(patch, true) return childrenAdded diff --git a/assets/test/browser_test.js b/assets/test/browser_test.ts similarity index 55% rename from assets/test/browser_test.js rename to assets/test/browser_test.ts index 0b8485e3ba..f44ac080d3 100644 --- a/assets/test/browser_test.js +++ b/assets/test/browser_test.ts @@ -30,26 +30,59 @@ describe("Browser", () => { }) describe("redirect", () => { - const originalWindowLocation = global.window.location + let originalLocation: Location + let mockHrefSetter: jest.Mock + let currentHref: string + + beforeAll(() => { + originalLocation = window.location + }) beforeEach(() => { - delete global.window.location - global.window.location = "https://example.com" + currentHref = "https://example.com" // Initial mocked URL + mockHrefSetter = jest.fn((newHref: string) => { + currentHref = newHref + }) + + Object.defineProperty(window, "location", { + writable: true, + configurable: true, + value: { + get href(){ + return currentHref + }, + set href(url: string){ + mockHrefSetter(url) + } + }, + }) }) afterAll(() => { - global.window.location = originalWindowLocation + // Restore the original window.location object + Object.defineProperty(window, "location", { + writable: true, + configurable: true, + value: originalLocation, + }) }) test("redirects to a new URL", () => { - Browser.redirect("https://phoenixframework.com") - expect(window.location).toEqual("https://phoenixframework.com") + const targetUrl = "https://phoenixframework.com" + Browser.redirect(targetUrl) + expect(mockHrefSetter).toHaveBeenCalledWith(targetUrl) + expect(window.location.href).toEqual(targetUrl) }) test("sets a flash cookie before redirecting", () => { - Browser.redirect("https://phoenixframework.com", "mango") + const targetUrl = "https://phoenixframework.com" + const flashMessage = "mango" + Browser.redirect(targetUrl, flashMessage) + expect(document.cookie).toContain("__phoenix_flash__") - expect(document.cookie).toContain("mango") + expect(document.cookie).toContain(flashMessage) + expect(mockHrefSetter).toHaveBeenCalledWith(targetUrl) + expect(window.location.href).toEqual(targetUrl) }) }) }) diff --git a/assets/test/debounce_test.js b/assets/test/debounce_test.ts similarity index 90% rename from assets/test/debounce_test.js rename to assets/test/debounce_test.ts index b302eab12b..2ed8cfd0c1 100644 --- a/assets/test/debounce_test.js +++ b/assets/test/debounce_test.ts @@ -51,7 +51,7 @@ describe("debounce", function (){ test("triggers debounce on input blur", async () => { let calls = 0 - const el = container().querySelector("input[name=debounce-200]") + const el: HTMLInputElement = container().querySelector("input[name=debounce-200]") el.addEventListener("input", e => { DOM.debounce(el, e, "phx-debounce", 0, "phx-throttle", 0, () => true, () => calls++) @@ -68,7 +68,7 @@ describe("debounce", function (){ test("triggers debounce on input blur caused by tab", async () => { let calls = 0 - const el = container().querySelector("input[name=debounce-200]") + const el: HTMLInputElement = container().querySelector("input[name=debounce-200]") el.addEventListener("input", e => { DOM.debounce(el, e, "phx-debounce", 0, "phx-throttle", 0, () => true, () => calls++) @@ -83,7 +83,7 @@ describe("debounce", function (){ test("triggers on timeout", done => { let calls = 0 - const el = container().querySelector("input[name=debounce-200]") + const el: HTMLInputElement = container().querySelector("input[name=debounce-200]") el.addEventListener("input", e => { DOM.debounce(el, e, "phx-debounce", 100, "phx-throttle", 200, () => true, () => calls++) @@ -114,7 +114,7 @@ describe("debounce", function (){ test("uses default when value is blank", done => { let calls = 0 - const el = container().querySelector("input[name=debounce-200]") + const el: HTMLInputElement = container().querySelector("input[name=debounce-200]") el.setAttribute("phx-debounce", "") el.addEventListener("input", e => { @@ -140,7 +140,7 @@ describe("debounce", function (){ test("cancels trigger on submit", done => { let calls = 0 const parent = container() - const el = parent.querySelector("input[name=debounce-200]") + const el: HTMLInputElement = parent.querySelector("input[name=debounce-200]") el.addEventListener("input", e => { DOM.debounce(el, e, "phx-debounce", 100, "phx-throttle", 200, () => true, () => calls++) @@ -166,7 +166,7 @@ describe("debounce", function (){ describe("throttle", function (){ test("triggers immediately, then on timeout", done => { let calls = 0 - const el = container().querySelector("#throttle-200") + const el: HTMLButtonElement = container().querySelector("#throttle-200") el.addEventListener("click", e => { DOM.debounce(el, e, "phx-debounce", 100, "phx-throttle", 200, () => true, () => { @@ -195,7 +195,7 @@ describe("throttle", function (){ test("uses default when value is blank", done => { let calls = 0 - const el = container().querySelector("#throttle-200") + const el: HTMLButtonElement = container().querySelector("#throttle-200") el.setAttribute("phx-throttle", "") el.addEventListener("click", e => { @@ -225,7 +225,7 @@ describe("throttle", function (){ test("cancels trigger on submit", done => { let calls = 0 - const el = container().querySelector("input[name=throttle-200]") + const el: HTMLInputElement = container().querySelector("input[name=throttle-200]") el.addEventListener("input", e => { DOM.debounce(el, e, "phx-debounce", 100, "phx-throttle", 200, () => true, () => calls++) @@ -248,7 +248,7 @@ describe("throttle", function (){ test("triggers only once when there is only one event", done => { let calls = 0 - const el = container().querySelector("#throttle-200") + const el: HTMLButtonElement = container().querySelector("#throttle-200") el.addEventListener("click", e => { DOM.debounce(el, e, "phx-debounce", 100, "phx-throttle", 200, () => true, () => { @@ -267,7 +267,7 @@ describe("throttle", function (){ test("sends value on blur when phx-blur dispatches change", done => { let calls = 0 - const el = container().querySelector("input[name=throttle-range-with-blur]") + const el: HTMLInputElement = container().querySelector("input[name=throttle-range-with-blur]") el.addEventListener("input", e => { DOM.debounce(el, e, "phx-debounce", 100, "phx-throttle", 200, () => true, () => { @@ -275,11 +275,11 @@ describe("throttle", function (){ el.innerText = `now:${calls}` }) }) - el.value = 500 + el.value = "500" DOM.dispatchEvent(el, "input") // these will be throttled for(let i = 0; i < 100; i++){ - el.value = i + el.value = i.toString() DOM.dispatchEvent(el, "input") } expect(calls).toBe(1) @@ -304,7 +304,7 @@ describe("throttle", function (){ describe("throttle keydown", function (){ test("when the same key is pressed triggers immediately, then on timeout", done => { const keyPresses = {} - const el = container().querySelector("#throttle-keydown") + const el: HTMLDivElement = container().querySelector("#throttle-keydown") el.addEventListener("keydown", e => { DOM.debounce(el, e, "phx-debounce", 100, "phx-throttle", 200, () => true, () => { @@ -330,7 +330,7 @@ describe("throttle keydown", function (){ test("when different key is pressed triggers immediately", done => { const keyPresses = {} - const el = container().querySelector("#throttle-keydown") + const el: HTMLDivElement = container().querySelector("#throttle-keydown") el.addEventListener("keydown", e => { DOM.debounce(el, e, "phx-debounce", 100, "phx-throttle", 200, () => true, () => { diff --git a/assets/test/dom_test.js b/assets/test/dom_test.ts similarity index 96% rename from assets/test/dom_test.js rename to assets/test/dom_test.ts index bc778def06..f0ec4a1723 100644 --- a/assets/test/dom_test.js +++ b/assets/test/dom_test.ts @@ -1,12 +1,13 @@ import DOM from "phoenix_live_view/dom" import {appendTitle, tag} from "./test_helpers" -const e = (href) => { - const event = {} +const e = (href: string) => { const anchor = document.createElement("a") anchor.setAttribute("href", href) - event.target = anchor - event.defaultPrevented = false + const event = { + target: anchor, + defaultPrevented: false, + } as unknown as Event & {target: HTMLAnchorElement} return event } @@ -75,8 +76,8 @@ describe("DOM", () => { test("with defaultPrevented return sfalse", () => { let currentLoc currentLoc = new URL("https://test.local/foo") - const event = e("/foo") - event.defaultPrevented = true + const event = e("/foo"); + (event as any).defaultPrevented = true expect(DOM.isNewPageClick(event, currentLoc)).toBe(false) }) @@ -88,8 +89,8 @@ describe("DOM", () => { test("ignores contenteditable", () => { let currentLoc currentLoc = new URL("https://test.local/foo") - const event = e("/bar") - event.target.isContentEditable = true + const event = e("/bar"); + (event.target as any).isContentEditable = true expect(DOM.isNewPageClick(event, currentLoc)).toBe(false) }) }) diff --git a/assets/test/event_test.js b/assets/test/event_test.ts similarity index 100% rename from assets/test/event_test.js rename to assets/test/event_test.ts diff --git a/assets/test/globals.d.ts b/assets/test/globals.d.ts new file mode 100644 index 0000000000..a6ca30d664 --- /dev/null +++ b/assets/test/globals.d.ts @@ -0,0 +1,7 @@ +declare global { + function setStartSystemTime(): void + function advanceTimersToNextFrame(): void + let LV_VSN: string +} + +export {} diff --git a/assets/test/index_test.js b/assets/test/index_test.js deleted file mode 100644 index fa1dfa9f90..0000000000 --- a/assets/test/index_test.js +++ /dev/null @@ -1,8 +0,0 @@ -import {LiveSocket} from "phoenix_live_view" -import * as LiveSocket2 from "phoenix_live_view/live_socket" - -describe("Named Imports", () => { - test("LiveSocket is equal to the actual LiveSocket", () => { - expect(LiveSocket).toBe(LiveSocket2.default) - }) -}) diff --git a/assets/test/index_test.ts b/assets/test/index_test.ts new file mode 100644 index 0000000000..36b8902bc8 --- /dev/null +++ b/assets/test/index_test.ts @@ -0,0 +1,24 @@ +import {LiveSocket, isUsedInput, ViewHook} from "phoenix_live_view" +import * as LiveSocket2 from "phoenix_live_view/live_socket" +import ViewHook2 from "phoenix_live_view/view_hook" +import DOM from "phoenix_live_view/dom" + +describe("Named Imports", () => { + test("LiveSocket is equal to the actual LiveSocket", () => { + expect(LiveSocket).toBe(LiveSocket2.default) + }) + + test("ViewHook is equal to the actual ViewHook", () => { + expect(ViewHook).toBe(ViewHook2) + }) +}) + +describe("isUsedInput", () => { + test("returns true if the input is used", () => { + const input = document.createElement("input") + input.type = "text" + expect(isUsedInput(input)).toBeFalsy() + DOM.putPrivate(input, "phx-has-focused", true) + expect(isUsedInput(input)).toBe(true) + }) +}) diff --git a/assets/test/integration/event_test.js b/assets/test/integration/event_test.ts similarity index 100% rename from assets/test/integration/event_test.js rename to assets/test/integration/event_test.ts diff --git a/assets/test/integration/metadata_test.js b/assets/test/integration/metadata_test.ts similarity index 100% rename from assets/test/integration/metadata_test.js rename to assets/test/integration/metadata_test.ts diff --git a/assets/test/js_test.js b/assets/test/js_test.ts similarity index 89% rename from assets/test/js_test.js rename to assets/test/js_test.ts index 1e03da55af..b4ddd505ff 100644 --- a/assets/test/js_test.js +++ b/assets/test/js_test.ts @@ -3,6 +3,7 @@ import LiveSocket from "phoenix_live_view/live_socket" import JS from "phoenix_live_view/js" import ViewHook from "phoenix_live_view/view_hook" import {simulateJoinedView, simulateVisibility, liveViewDOM} from "./test_helpers" +import {HookJSCommands} from "phoenix_live_view/js_commands" const setupView = (content) => { const el = liveViewDOM(content) @@ -25,7 +26,8 @@ describe("JS", () => { }) describe("hook.js()", () => { - let js, view, modal + let js: HookJSCommands + let view, modal beforeEach(() => { view = setupView("
modal
") modal = view.el.querySelector("#modal") @@ -351,7 +353,7 @@ describe("JS", () => {
`) const modal = simulateVisibility(document.querySelector("#modal")) - const click = document.querySelector("#click") + const click = document.querySelector("#click")! expect(Array.from(modal.classList)).toEqual(["modal"]) @@ -371,9 +373,9 @@ describe("JS", () => {
`) - const modal1 = document.querySelector("#modal1") - const modal2 = document.querySelector("#modal2") - const click = document.querySelector("#click") + const modal1 = document.querySelector("#modal1")! + const modal2 = document.querySelector("#modal2")! + const click = document.querySelector("#click")! expect(Array.from(modal1.classList)).toEqual(["modal"]) expect(Array.from(modal2.classList)).toEqual(["modal"]) @@ -400,7 +402,7 @@ describe("JS", () => {
`) const modal = simulateVisibility(document.querySelector("#modal")) - const click = document.querySelector("#click") + const click = document.querySelector("#click")! modal.addEventListener("click", () => { done() @@ -415,7 +417,7 @@ describe("JS", () => {
`) const modal = simulateVisibility(document.querySelector(".modal")) - const click = document.querySelector("#click") + const click = document.querySelector("#click")! modal.addEventListener("click", () => done()) JS.exec(event, "click", click.getAttribute("phx-click"), view, click) @@ -430,7 +432,7 @@ describe("JS", () => {
`) const modal = simulateVisibility(document.querySelector(".modal")) - const click = document.querySelector("#click") + const click = document.querySelector("#click")! modal.addEventListener("click", () => done()) JS.exec(event, "click", click.getAttribute("phx-click"), view, click) @@ -442,8 +444,8 @@ describe("JS", () => {
`) const modal = simulateVisibility(document.querySelector("#modal")) - const click = document.querySelector("#click") - const close = document.querySelector("#close") + const click = document.querySelector("#click")! + const close = document.querySelector("#close")! modal.addEventListener("close", e => { expect(e.detail).toEqual({id: 1, dispatcher: close}) @@ -464,15 +466,15 @@ describe("JS", () => { `) let modal1Clicked = false const modal1 = document.querySelector("#modal1") - const modal2 = document.querySelector("#modal2") - const close = document.querySelector("#close") + const modal2 = document.querySelector("#modal2")! + const close = document.querySelector("#close")! - modal1.addEventListener("close", (e) => { + modal1.addEventListener("close", (e: CustomEventInit) => { modal1Clicked = true expect(e.detail).toEqual({id: 123, dispatcher: close}) }) - modal2.addEventListener("close", (e) => { + modal2.addEventListener("close", (e: CustomEventInit) => { expect(modal1Clicked).toBe(true) expect(e.detail).toEqual({id: 123, dispatcher: close}) done() @@ -490,8 +492,8 @@ describe("JS", () => {
`) const modal = simulateVisibility(document.querySelector("#modal")) - const add = document.querySelector("#add") - const remove = document.querySelector("#remove") + const add = document.querySelector("#add")! + const remove = document.querySelector("#remove")! JS.exec(event, "click", add.getAttribute("phx-click"), view, add) JS.exec(event, "click", add.getAttribute("phx-click"), view, add) @@ -514,10 +516,10 @@ describe("JS", () => {
`) - const modal1 = document.querySelector("#modal1") - const modal2 = document.querySelector("#modal2") - const add = document.querySelector("#add") - const remove = document.querySelector("#remove") + const modal1 = document.querySelector("#modal1")! + const modal2 = document.querySelector("#modal2")! + const add = document.querySelector("#add")! + const remove = document.querySelector("#remove")! JS.exec(event, "click", add.getAttribute("phx-click"), view, add) jest.runAllTimers() @@ -541,7 +543,7 @@ describe("JS", () => {
`) const modal = simulateVisibility(document.querySelector("#modal")) - const toggle = document.querySelector("#toggle") + const toggle = document.querySelector("#toggle")! JS.exec(event, "click", toggle.getAttribute("phx-click"), view, toggle) jest.runAllTimers() @@ -561,9 +563,9 @@ describe("JS", () => {
`) - const modal1 = document.querySelector("#modal1") - const modal2 = document.querySelector("#modal2") - const toggle = document.querySelector("#toggle") + const modal1 = document.querySelector("#modal1")! + const modal2 = document.querySelector("#modal2")! + const toggle = document.querySelector("#toggle")! JS.exec(event, "click", toggle.getAttribute("phx-click"), view, toggle) jest.runAllTimers() @@ -582,7 +584,7 @@ describe("JS", () => { const view = setupView(` `) - const button = document.querySelector("button") + const button = document.querySelector("button")! expect(Array.from(button.classList)).toEqual([]) @@ -604,7 +606,7 @@ describe("JS", () => {
`) - const click = document.querySelector("#click") + const click = document.querySelector("#click")! view.pushEvent = (eventType, sourceEl, targetCtx, event, meta) => { expect(eventType).toBe("click") expect(event).toBe("clicked") @@ -621,9 +623,9 @@ describe("JS", () => { `) - const form = document.querySelector("#my-form") - const input = document.querySelector("#username") - view.pushInput = (sourceEl, targetCtx, newCid, phxEvent, {_target}, _callback) => { + const form = document.querySelector("#my-form")! + const input: HTMLInputElement = document.querySelector("#username")! + view.pushInput = (sourceEl, _targetCtx, _newCid, phxEvent, {_target}, _callback) => { expect(phxEvent).toBe("validate") expect(sourceEl.isSameNode(input)).toBe(true) expect(_target).toBe(input.name) @@ -644,8 +646,8 @@ describe("JS", () => { `) - const form = document.querySelector("#my-form") - const input = document.querySelector("#username") + const form = document.querySelector("#my-form")! + const input: HTMLInputElement = document.querySelector("#username")! view.pushWithReply = (refGen, event, payload) => { expect(payload).toEqual({ "cid": null, @@ -674,8 +676,8 @@ describe("JS", () => { `) - const form = document.querySelector("#my-form") - const input = document.querySelector("#textField") + const form: HTMLFormElement = document.querySelector("#my-form")! + const input: HTMLInputElement = document.querySelector("#textField")! view.pushWithReply = (refGen, event, payload) => { expect(payload).toEqual({ "cid": null, @@ -700,8 +702,8 @@ describe("JS", () => { `) - const form = document.querySelector("#my-form") - const input = document.querySelector("#textField") + const form: HTMLFormElement = document.querySelector("#my-form")! + const input: HTMLInputElement = document.querySelector("#textField")! view.pushWithReply = (refGen, event, payload) => { expect(payload).toEqual({ "cid": null, @@ -728,8 +730,8 @@ describe("JS", () => { `) - const form = document.querySelector("#my-form") - const input = document.querySelector("#username") + const form: HTMLFormElement = document.querySelector("#my-form")! + const input: HTMLInputElement = document.querySelector("#username")! const oldPush = view.pushInput.bind(view) view.pushInput = (sourceEl, targetCtx, newCid, phxEvent, opts, callback) => { const {_target} = opts @@ -738,7 +740,7 @@ describe("JS", () => { expect(_target).toBe(input.name) oldPush(sourceEl, targetCtx, newCid, phxEvent, opts, callback) } - view.pushWithReply = (refGen, event, payload) => { + view.pushWithReply = (_refGen, _event, payload) => { expect(payload).toEqual({ cid: null, event: "validate", @@ -761,7 +763,7 @@ describe("JS", () => { `) - const input = document.querySelector("#username1") + const input: HTMLInputElement = document.querySelector("#username1")! const oldPush = view.pushInput.bind(view) view.pushInput = (sourceEl, targetCtx, newCid, phxEvent, opts, callback) => { const {_target} = opts @@ -770,7 +772,7 @@ describe("JS", () => { expect(_target).toBe(input.name) oldPush(sourceEl, targetCtx, newCid, phxEvent, opts, callback) } - view.pushWithReply = (refGen, event, payload) => { + view.pushWithReply = (_refGen, _event, payload) => { expect(payload).toEqual({ cid: null, event: "username_changed", @@ -793,7 +795,7 @@ describe("JS", () => { `) - const input = document.querySelector("#username") + const input: HTMLInputElement = document.querySelector("#username")! const oldPush = view.pushInput.bind(view) view.pushInput = (sourceEl, targetCtx, newCid, phxEvent, opts, callback) => { const {_target} = opts @@ -825,7 +827,7 @@ describe("JS", () => { `) - const form = document.querySelector("#my-form") + const form: HTMLFormElement = document.querySelector("#my-form")! view.pushWithReply = (refGen, event, payload) => { expect(payload).toEqual({ @@ -852,7 +854,7 @@ describe("JS", () => { `) - const form = document.querySelector("#my-form") + const form: HTMLFormElement = document.querySelector("#my-form")! view.pushWithReply = (refGen, event, payload) => { expect(payload).toEqual({ @@ -878,7 +880,7 @@ describe("JS", () => {
`) - const click = document.querySelector("#click") + const click = document.querySelector("#click")! view.pushEvent = (eventType, sourceEl, targetCtx, event, meta, opts) => { expect(opts).toEqual({page_loading: true}) done() @@ -891,8 +893,8 @@ describe("JS", () => {
`) - const click = document.querySelector("#click") - const modal = document.getElementById("modal") + const click = document.querySelector("#click")! + const modal = document.getElementById("modal")! JS.exec(event, "click", click.getAttribute("phx-click"), view, click) expect(Array.from(modal.classList)).toEqual(["modal", "phx-click-loading"]) expect(Array.from(click.classList)).toEqual(["phx-click-loading"]) @@ -903,9 +905,9 @@ describe("JS", () => {
`) - const click = document.querySelector("#click") + const click = document.querySelector("#click")! - view.pushWithReply = (refGenerator, event, payload, _onReply) => { + view.pushWithReply = (refGenerator, event, payload) => { expect(payload.value).toEqual({"one": 1, "two": 2, "three": "3"}) return Promise.resolve({resp: done()}) } @@ -920,9 +922,9 @@ describe("JS", () => {
`) const modal = simulateVisibility(document.querySelector("#modal")) - const click = document.querySelector("#click") + const click = document.querySelector("#click")! - view.pushEvent = (eventType, sourceEl, targetCtx, event, _data) => { + view.pushEvent = (_eventType, _sourceEl, _targetCtx, event, _data) => { expect(event).toEqual("clicked") done() } @@ -942,9 +944,9 @@ describe("JS", () => {
`) - const modal = document.querySelector("#modal") - const set = document.querySelector("#set") - const remove = document.querySelector("#remove") + const modal = document.querySelector("#modal")! + const set = document.querySelector("#set")! + const remove = document.querySelector("#remove")! expect(modal.getAttribute("aria-expanded")).toEqual(null) JS.exec(event, "click", set.getAttribute("phx-click"), view, set) @@ -959,8 +961,8 @@ describe("JS", () => {
`) - const set = document.querySelector("#set") - const remove = document.querySelector("#remove") + const set = document.querySelector("#set")! + const remove = document.querySelector("#remove")! expect(set.getAttribute("aria-expanded")).toEqual(null) JS.exec(event, "click", set.getAttribute("phx-click"), view, set) @@ -976,8 +978,8 @@ describe("JS", () => {
`) - const set = document.querySelector("#set") - const modal = document.querySelector("#modal") + const set = document.querySelector("#set")! + const modal = document.querySelector("#modal")! expect(modal.getAttribute("aria-expanded")).toEqual("false") JS.exec(event, "click", set.getAttribute("phx-click"), view, set) @@ -990,9 +992,9 @@ describe("JS", () => {
`) - const setFalse = document.querySelector("#set-false") - const setTrue = document.querySelector("#set-true") - const modal = document.querySelector("#modal") + const setFalse = document.querySelector("#set-false")! + const setTrue = document.querySelector("#set-true")! + const modal = document.querySelector("#modal")! expect(modal.getAttribute("aria-expanded")).toEqual(null) JS.exec(event, "click", setFalse.getAttribute("phx-click"), view, setFalse) @@ -1008,7 +1010,7 @@ describe("JS", () => {
`) - const click = document.querySelector("#click") + const click = document.querySelector("#click")! view.pushEvent = (eventType, sourceEl, targetCtx, event, _meta) => { expect(eventType).toBe("exec") expect(event).toBe("clicked") @@ -1025,7 +1027,7 @@ describe("JS", () => { data-toggle='[["toggle_attr", {"attr": ["open", "true"]}]]' > `) - const click = document.querySelector("#click") + const click = document.querySelector("#click")! expect(click.getAttribute("open")).toEqual(null) JS.exec(event, "click", click.getAttribute("phx-click"), view, click) @@ -1038,8 +1040,8 @@ describe("JS", () => { `) - const modal = document.querySelector("#modal") - const click = document.querySelector("#click") + const modal = document.querySelector("#modal")! + const click = document.querySelector("#click")! expect(modal.getAttribute("open")).toEqual(null) JS.exec(event, "click", click.getAttribute("phx-click"), view, click) @@ -1052,8 +1054,8 @@ describe("JS", () => {
`) - const modal = document.querySelector("#modal") - const click = document.querySelector("#click") + const modal = document.querySelector("#modal")! + const click = document.querySelector("#click")! expect(modal.getAttribute("open")).toEqual(null) JS.exec(event, "click", click.getAttribute("phx-click"), view, click) @@ -1066,9 +1068,9 @@ describe("JS", () => {
modal
`) - const modal1 = document.querySelector("#modal1") - const modal2 = document.querySelector("#modal2") - const click = document.querySelector("#click") + const modal1 = document.querySelector("#modal1")! + const modal2 = document.querySelector("#modal2")! + const click = document.querySelector("#click")! expect(modal1.getAttribute("open")).toEqual(null) expect(modal2.getAttribute("open")).toEqual("true") @@ -1084,8 +1086,8 @@ describe("JS", () => {
`) - const modal = document.querySelector("#modal") - const toggle = document.querySelector("#toggle") + const modal = document.querySelector("#modal")! + const toggle = document.querySelector("#toggle")! expect(modal.getAttribute("open")).toEqual(null) JS.exec(event, "click", toggle.getAttribute("phx-click"), view, toggle) @@ -1099,7 +1101,7 @@ describe("JS", () => { const view = setupView(`
`) - const toggle = document.querySelector("#toggle") + const toggle = document.querySelector("#toggle")! expect(toggle.getAttribute("open")).toEqual(null) JS.exec(event, "click", toggle.getAttribute("phx-click"), view, toggle) @@ -1112,9 +1114,9 @@ describe("JS", () => {
modal
`) - const modal1 = document.querySelector("#modal1") - const modal2 = document.querySelector("#modal2") - const toggle = document.querySelector("#toggle") + const modal1 = document.querySelector("#modal1")! + const modal2 = document.querySelector("#modal2")! + const toggle = document.querySelector("#toggle")! expect(modal1.getAttribute("open")).toEqual(null) expect(modal2.getAttribute("open")).toEqual("true") @@ -1128,8 +1130,8 @@ describe("JS", () => {
`) - const toggle = document.querySelector("#toggle") - const modal = document.querySelector("#modal") + const toggle = document.querySelector("#toggle")! + const modal = document.querySelector("#modal")! expect(modal.getAttribute("open")).toEqual("true") JS.exec(event, "click", toggle.getAttribute("phx-click"), view, toggle) @@ -1142,9 +1144,9 @@ describe("JS", () => {
`) - const toggle1 = document.querySelector("#toggle1") - const toggle2 = document.querySelector("#toggle2") - const modal = document.querySelector("#modal") + const toggle1 = document.querySelector("#toggle1")! + const toggle2 = document.querySelector("#toggle2")! + const modal = document.querySelector("#modal")! expect(modal.getAttribute("open")).toEqual(null) JS.exec(event, "click", toggle1.getAttribute("phx-click"), view, toggle1) @@ -1157,7 +1159,7 @@ describe("JS", () => { const view = setupView(`
`) - const toggle = document.querySelector("#toggle") + const toggle = document.querySelector("#toggle")! JS.exec(event, "click", toggle.getAttribute("phx-click"), view, toggle) expect(toggle.getAttribute("aria-expanded")).toEqual("true") @@ -1175,11 +1177,11 @@ describe("JS", () => {
`) - const modal1 = document.querySelector("#modal1") - const modal2 = document.querySelector("#modal2") - const push1 = document.querySelector("#push1") - const push2 = document.querySelector("#push2") - const pop = document.querySelector("#pop") + const modal1 = document.querySelector("#modal1")! + const modal2 = document.querySelector("#modal2")! + const push1 = document.querySelector("#push1")! + const push2 = document.querySelector("#push2")! + const pop = document.querySelector("#pop")! JS.exec(event, "click", push1.getAttribute("phx-click"), view, push1) JS.exec(event, "click", push2.getAttribute("phx-click"), view, push2) @@ -1204,8 +1206,8 @@ describe("JS", () => {
`) - const modal2 = document.querySelector("#modal2") - const push = document.querySelector("#push") + const modal2 = document.querySelector("#modal2")! + const push = document.querySelector("#push")! JS.exec(event, "click", push.getAttribute("phx-click"), view, push) diff --git a/assets/test/live_socket_test.js b/assets/test/live_socket_test.ts similarity index 99% rename from assets/test/live_socket_test.js rename to assets/test/live_socket_test.ts index 7a15092c23..e4123fdb06 100644 --- a/assets/test/live_socket_test.js +++ b/assets/test/live_socket_test.ts @@ -86,7 +86,7 @@ describe("LiveSocket", () => { const liveSocket = new LiveSocket("/live", Socket) liveSocket.connect() - const channel = liveSocket.channel("lv:def456", () => { + const channel = liveSocket.channel("lv:def456", function(){ return {session: this.getSession()} }) diff --git a/assets/test/modify_root_test.js b/assets/test/modify_root_test.ts similarity index 100% rename from assets/test/modify_root_test.js rename to assets/test/modify_root_test.ts diff --git a/assets/test/rendered_test.js b/assets/test/rendered_test.ts similarity index 99% rename from assets/test/rendered_test.js rename to assets/test/rendered_test.ts index 293c900c47..fe21ae042b 100644 --- a/assets/test/rendered_test.js +++ b/assets/test/rendered_test.ts @@ -217,7 +217,7 @@ describe("Rendered", () => { describe("toString", () => { test("stringifies a diff", () => { const rendered = new Rendered("123", simpleDiffResult) - const [str, _streams] = rendered.toString() + const {buffer: str} = rendered.toString() expect(str.trim()).toEqual( `
@@ -229,7 +229,7 @@ describe("Rendered", () => { test("reuses static in components and comprehensions", () => { const rendered = new Rendered("123", staticReuseDiff) - const [str, _streams] = rendered.toString() + const {buffer: str} = rendered.toString() expect(str.trim()).toEqual( `

diff --git a/assets/test/test_helpers.js b/assets/test/test_helpers.ts similarity index 93% rename from assets/test/test_helpers.js rename to assets/test/test_helpers.ts index 04333e12a8..37162957ab 100644 --- a/assets/test/test_helpers.js +++ b/assets/test/test_helpers.ts @@ -1,7 +1,7 @@ import View from "phoenix_live_view/view" import {version as liveview_version} from "../../package.json" -export const appendTitle = (opts, innerHTML) => { +export const appendTitle = (opts, innerHTML?: string) => { Array.from(document.head.querySelectorAll("title")).forEach(el => el.remove()) const title = document.createElement("title") const {prefix, suffix, default: defaultTitle} = opts @@ -41,7 +41,7 @@ export const simulateJoinedView = (el, liveSocket) => { export const simulateVisibility = el => { el.getClientRects = () => { const style = window.getComputedStyle(el) - const visible = !(style.opacity === 0 || style.display === "none") + const visible = !(style.opacity === "0" || style.display === "none") return visible ? {length: 1} : {length: 0} } return el @@ -58,7 +58,7 @@ export const stubChannel = view => { view.channel.push = () => fakePush } -export function liveViewDOM(content){ +export function liveViewDOM(content?: string){ const div = document.createElement("div") div.setAttribute("data-phx-view", "User.Form") div.setAttribute("data-phx-session", "abc123") diff --git a/assets/test/tsconfig.json b/assets/test/tsconfig.json new file mode 100644 index 0000000000..66d193e19a --- /dev/null +++ b/assets/test/tsconfig.json @@ -0,0 +1,23 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "noEmit": true, + "allowJs": true, + "checkJs": false, + "resolveJsonModule": true, + "baseUrl": ".", + "paths": { + "phoenix_live_view": [ + "../js/phoenix_live_view/index.ts" + ], + "phoenix_live_view*": [ + "../js/phoenix_live_view/*" + ] + } + }, + "include": [ + "./**/*" + ], + "exclude": [ + ] +} \ No newline at end of file diff --git a/assets/test/utils_test.js b/assets/test/utils_test.ts similarity index 100% rename from assets/test/utils_test.js rename to assets/test/utils_test.ts diff --git a/assets/test/view_test.js b/assets/test/view_test.ts similarity index 98% rename from assets/test/view_test.js rename to assets/test/view_test.ts index d9faa38bb9..e0786179df 100644 --- a/assets/test/view_test.js +++ b/assets/test/view_test.ts @@ -1,5 +1,6 @@ import {Socket} from "phoenix" -import {LiveSocket, createHook} from "phoenix_live_view/index" +import {createHook} from "phoenix_live_view/index" +import LiveSocket from "phoenix_live_view/live_socket" import DOM from "phoenix_live_view/dom" import View from "phoenix_live_view/view" import ViewHook from "phoenix_live_view/view_hook" @@ -172,7 +173,7 @@ describe("View + DOM", function(){ const liveSocket = new LiveSocket("/live", Socket) const el = liveViewDOM() - const input = el.querySelector("input[type=\"checkbox\"]") + const input: HTMLInputElement = el.querySelector("input[type=\"checkbox\"]") const view = simulateJoinedView(el, liveSocket) input.checked = true @@ -195,7 +196,7 @@ describe("View + DOM", function(){ const liveSocket = new LiveSocket("/live", Socket) const el = liveViewDOM() - const input = el.querySelector("input[type=\"checkbox\"]") + const input: HTMLInputElement = el.querySelector("input[type=\"checkbox\"]") const view = simulateJoinedView(el, liveSocket) input.value = "1" @@ -424,7 +425,7 @@ describe("View + DOM", function(){ submitWithButton(btn, "increment=1¬e=2") }) - function submitWithButton(btn, queryString, appendTo, opts={}){ + function submitWithButton(btn, queryString, appendTo?: HTMLElement, opts={}){ const liveSocket = new LiveSocket("/live", Socket) const el = liveViewDOM() const form = el.querySelector("form") @@ -872,7 +873,7 @@ describe("View", function(){ const phxView = document.querySelector("[data-phx-session]") phxView.parentNode.insertBefore(loader, phxView.nextSibling) const el = document.querySelector("[data-phx-session]") - const status = el.querySelector("#status") + const status: HTMLElement = el.querySelector("#status") const view = simulateJoinedView(el, liveSocket) @@ -1078,6 +1079,7 @@ describe("View Hooks", function(){ const liveSocket = new LiveSocket("/live", Socket, {}) const el = liveViewDOM() customElements.define("custom-el", class extends HTMLElement { + hook: ViewHook connectedCallback(){ this.hook = createHook(this, {mounted: () => { expect(this.hook.liveSocket).toBeTruthy() @@ -1314,14 +1316,14 @@ describe("View + Component", function(){ ` const liveSocket = new LiveSocket("/live", Socket) const el = liveViewDOM(html) - const view = simulateJoinedView(el, liveSocket, html) + const view = simulateJoinedView(el, liveSocket) Array.from(view.el.querySelectorAll("input")).forEach(input => simulateUsedInput(input)) const channelStub = { validate: "", nextValidate(payload, meta){ this.meta = meta this.validate = Object.entries(payload) - .map(([key, value]) => `${encodeURIComponent(key)}=${value ? encodeURIComponent(value) : ""}`) + .map(([key, value]) => `${encodeURIComponent(key)}=${value ? encodeURIComponent(value as string) : ""}`) .join("&") }, push(_evt, payload, _timeout){ @@ -1533,12 +1535,12 @@ describe("View + Component", function(){ describe("DOM", function(){ it("mergeAttrs attributes", function(){ - const target = document.createElement("target") + const target = document.createElement("input") target.type = "checkbox" target.id = "foo" target.setAttribute("checked", "true") - const source = document.createElement("source") + const source = document.createElement("input") source.type = "checkbox" source.id = "bar" @@ -1552,12 +1554,12 @@ describe("DOM", function(){ }) it("mergeAttrs with properties", function(){ - const target = document.createElement("target") + const target = document.createElement("input") target.type = "checkbox" target.id = "foo" target.checked = true - const source = document.createElement("source") + const source = document.createElement("input") source.type = "checkbox" source.id = "bar" diff --git a/eslint.config.js b/eslint.config.js index 44a215be79..41089ce87c 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -2,39 +2,39 @@ import playwright from "eslint-plugin-playwright" import jest from "eslint-plugin-jest" import globals from "globals" import js from "@eslint/js" -import stylisticJs from "@stylistic/eslint-plugin" +import stylistic from "@stylistic/eslint-plugin" import tseslint from "typescript-eslint" const sharedRules = { - "@stylistic/js/indent": ["error", 2, { + "@stylistic/indent": ["error", 2, { SwitchCase: 1, }], - "@stylistic/js/linebreak-style": ["error", "unix"], - "@stylistic/js/quotes": ["error", "double"], - "@stylistic/js/semi": ["error", "never"], + "@stylistic/linebreak-style": ["error", "unix"], + "@stylistic/quotes": ["error", "double"], + "@stylistic/semi": ["error", "never"], - "@stylistic/js/object-curly-spacing": ["error", "never", { + "@stylistic/object-curly-spacing": ["error", "never", { objectsInObjects: false, arraysInObjects: false, }], - "@stylistic/js/array-bracket-spacing": ["error", "never"], + "@stylistic/array-bracket-spacing": ["error", "never"], - "@stylistic/js/comma-spacing": ["error", { + "@stylistic/comma-spacing": ["error", { before: false, after: true, }], - "@stylistic/js/computed-property-spacing": ["error", "never"], + "@stylistic/computed-property-spacing": ["error", "never"], - "@stylistic/js/space-before-blocks": ["error", { + "@stylistic/space-before-blocks": ["error", { functions: "never", keywords: "never", classes: "always", }], - "@stylistic/js/keyword-spacing": ["error", { + "@stylistic/keyword-spacing": ["error", { overrides: { if: { after: false, @@ -54,7 +54,7 @@ const sharedRules = { }, }], - "@stylistic/js/eol-last": ["error", "always"], + "@stylistic/eol-last": ["error", "always"], "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_", @@ -84,12 +84,12 @@ export default tseslint.config([ }, { extends: [js.configs.recommended, ...tseslint.configs.recommended], - files: ["*.js", "*.mjs", "test/e2e/**"], + files: ["*.js", "*.ts", "test/e2e/**"], ignores: ["assets/**"], plugins: { ...playwright.configs["flat/recommended"].plugins, - "@stylistic/js": stylisticJs, + "@stylistic": stylistic, }, rules: { @@ -104,7 +104,7 @@ export default tseslint.config([ plugins: { jest, - "@stylistic/js": stylisticJs, + "@stylistic": stylistic, }, languageOptions: { diff --git a/jest.config.js b/jest.config.js index 87368062fb..fcb4ea832d 100644 --- a/jest.config.js +++ b/jest.config.js @@ -177,7 +177,7 @@ export default { // ], // The regexp pattern or array of patterns that Jest uses to detect test files - testRegex: "/assets/test/.*_test\\.js$", + testRegex: "/assets/test/.*_test\\.(js|ts)$", // This option allows the use of a custom results processor // testResultsProcessor: undefined, diff --git a/package-lock.json b/package-lock.json index e0f5e501a9..f05092f494 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@eslint/js": "^9.24.0", "@playwright/test": "^1.51.1", "@stylistic/eslint-plugin": "^4.2.0", + "@types/jest": "^29.5.14", "@types/phoenix": "^1.6.6", "css.escape": "^1.5.1", "eslint": "9.24.0", @@ -2724,6 +2725,16 @@ "@types/istanbul-lib-report": "*" } }, + "node_modules/@types/jest": { + "version": "29.5.14", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz", + "integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==", + "dev": true, + "dependencies": { + "expect": "^29.0.0", + "pretty-format": "^29.0.0" + } + }, "node_modules/@types/jsdom": { "version": "20.0.1", "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-20.0.1.tgz", diff --git a/package.json b/package.json index 9951c4dd64..37e204dc81 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "@eslint/js": "^9.24.0", "@playwright/test": "^1.51.1", "@stylistic/eslint-plugin": "^4.2.0", + "@types/jest": "^29.5.14", "@types/phoenix": "^1.6.6", "css.escape": "^1.5.1", "eslint": "9.24.0", @@ -61,6 +62,7 @@ "js:test.watch": "npm run build && jest --watch", "js:lint": "eslint --fix && cd assets && eslint --fix", "test": "npm run js:test && npm run e2e:test", + "typecheck:tests": "tsc -p assets/test/tsconfig.json", "cover:merge": "node test/e2e/merge-coverage.js", "cover": "npm run test && npm run cover:merge", "cover:report": "npx monocart show-report cover/merged-js/index.html" From b660d4c447262e159a22e8a18f035411978c0230 Mon Sep 17 00:00:00 2001 From: Steffen Deusch Date: Tue, 13 May 2025 14:52:19 +0200 Subject: [PATCH 5/5] use prettier for js/ts formatting --- .github/workflows/ci.yml | 4 +- assets/.prettierignore | 1 + assets/.prettierrc | 0 assets/js/phoenix_live_view/aria.js | 73 +- assets/js/phoenix_live_view/browser.js | 119 +- assets/js/phoenix_live_view/constants.js | 195 +- assets/js/phoenix_live_view/dom.js | 816 +++--- assets/js/phoenix_live_view/dom_patch.js | 704 +++-- .../dom_post_morph_restorer.js | 101 +- assets/js/phoenix_live_view/element_ref.js | 169 +- assets/js/phoenix_live_view/entry_uploader.js | 96 +- assets/js/phoenix_live_view/global.d.ts | 2 +- assets/js/phoenix_live_view/hooks.js | 373 +-- assets/js/phoenix_live_view/index.ts | 95 +- assets/js/phoenix_live_view/js.js | 789 +++-- assets/js/phoenix_live_view/js_commands.ts | 215 +- assets/js/phoenix_live_view/live_socket.js | 1629 ++++++----- assets/js/phoenix_live_view/live_uploader.js | 235 +- assets/js/phoenix_live_view/rendered.js | 515 ++-- assets/js/phoenix_live_view/upload_entry.js | 177 +- assets/js/phoenix_live_view/utils.js | 112 +- assets/js/phoenix_live_view/view.js | 2496 +++++++++------- assets/js/phoenix_live_view/view_hook.ts | 348 ++- assets/test/browser_test.ts | 118 +- assets/test/debounce_test.ts | 736 +++-- assets/test/dom_test.ts | 379 ++- assets/test/event_test.ts | 480 ++-- assets/test/globals.d.ts | 8 +- assets/test/index_test.ts | 32 +- assets/test/integration/event_test.ts | 75 +- assets/test/integration/metadata_test.ts | 78 +- assets/test/js_test.ts | 1734 +++++------ assets/test/live_socket_test.ts | 646 +++-- assets/test/modify_root_test.ts | 153 +- assets/test/rendered_test.ts | 406 +-- assets/test/test_helpers.ts | 122 +- assets/test/tsconfig.json | 17 +- assets/test/utils_test.ts | 41 +- assets/test/view_test.ts | 2557 +++++++++-------- eslint.config.js | 53 - package.json | 6 +- test/e2e/.prettierignore | 1 + test/e2e/merge-coverage.js | 22 +- test/e2e/playwright.config.js | 49 +- test/e2e/teardown.js | 12 +- test/e2e/test-fixtures.js | 103 +- test/e2e/tests/errors.spec.js | 412 +-- test/e2e/tests/forms.spec.js | 956 +++--- test/e2e/tests/issues/2787.spec.js | 63 +- test/e2e/tests/issues/2965.spec.js | 32 +- test/e2e/tests/issues/3026.spec.js | 44 +- test/e2e/tests/issues/3040.spec.js | 80 +- test/e2e/tests/issues/3047.spec.js | 71 +- test/e2e/tests/issues/3083.spec.js | 42 +- test/e2e/tests/issues/3107.spec.js | 22 +- test/e2e/tests/issues/3117.spec.js | 30 +- test/e2e/tests/issues/3169.spec.js | 44 +- test/e2e/tests/issues/3194.spec.js | 42 +- test/e2e/tests/issues/3200.spec.js | 44 +- test/e2e/tests/issues/3378.spec.js | 30 +- test/e2e/tests/issues/3448.spec.js | 24 +- test/e2e/tests/issues/3496.spec.js | 36 +- test/e2e/tests/issues/3529.spec.js | 57 +- test/e2e/tests/issues/3530.spec.js | 92 +- test/e2e/tests/issues/3612.spec.js | 28 +- test/e2e/tests/issues/3647.spec.js | 26 +- test/e2e/tests/issues/3651.spec.js | 22 +- test/e2e/tests/issues/3656.spec.js | 34 +- test/e2e/tests/issues/3658.spec.js | 22 +- test/e2e/tests/issues/3681.spec.js | 42 +- test/e2e/tests/issues/3684.spec.js | 20 +- test/e2e/tests/issues/3686.spec.js | 32 +- test/e2e/tests/issues/3709.spec.js | 50 +- test/e2e/tests/issues/3719.spec.js | 32 +- test/e2e/tests/js.spec.js | 196 +- test/e2e/tests/navigation.spec.js | 632 ++-- test/e2e/tests/select.spec.js | 32 +- test/e2e/tests/streams.spec.js | 1354 +++++---- test/e2e/tests/uploads.spec.js | 361 +-- test/e2e/utils.js | 98 +- 80 files changed, 12646 insertions(+), 9548 deletions(-) create mode 100644 assets/.prettierignore create mode 100644 assets/.prettierrc create mode 100644 test/e2e/.prettierignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 64abd73603..c9ad9cf079 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -148,8 +148,8 @@ jobs: - name: typecheck run: npm run build && npm run typecheck:tests - - name: eslint - run: npx eslint + - name: check lint and format + run: npm run js:lint && npm run js:format.check - name: test run: npm run js:test diff --git a/assets/.prettierignore b/assets/.prettierignore new file mode 100644 index 0000000000..700d00a068 --- /dev/null +++ b/assets/.prettierignore @@ -0,0 +1 @@ +js/types/ diff --git a/assets/.prettierrc b/assets/.prettierrc new file mode 100644 index 0000000000..e69de29bb2 diff --git a/assets/js/phoenix_live_view/aria.js b/assets/js/phoenix_live_view/aria.js index ca86333a97..ee0fd7e2f1 100644 --- a/assets/js/phoenix_live_view/aria.js +++ b/assets/js/phoenix_live_view/aria.js @@ -1,55 +1,66 @@ const ARIA = { - anyOf(instance, classes){ return classes.find(name => instance instanceof name) }, + anyOf(instance, classes) { + return classes.find((name) => instance instanceof name); + }, - isFocusable(el, interactiveOnly){ + isFocusable(el, interactiveOnly) { return ( (el instanceof HTMLAnchorElement && el.rel !== "ignore") || (el instanceof HTMLAreaElement && el.href !== undefined) || - (!el.disabled && (this.anyOf(el, [HTMLInputElement, HTMLSelectElement, HTMLTextAreaElement, HTMLButtonElement]))) || - (el instanceof HTMLIFrameElement) || - (el.tabIndex >= 0 || (!interactiveOnly && el.getAttribute("tabindex") !== null && el.getAttribute("aria-hidden") !== "true")) - ) + (!el.disabled && + this.anyOf(el, [ + HTMLInputElement, + HTMLSelectElement, + HTMLTextAreaElement, + HTMLButtonElement, + ])) || + el instanceof HTMLIFrameElement || + el.tabIndex >= 0 || + (!interactiveOnly && + el.getAttribute("tabindex") !== null && + el.getAttribute("aria-hidden") !== "true") + ); }, - attemptFocus(el, interactiveOnly){ - if(this.isFocusable(el, interactiveOnly)){ + attemptFocus(el, interactiveOnly) { + if (this.isFocusable(el, interactiveOnly)) { try { - el.focus() + el.focus(); } catch { // that's fine } } - return !!document.activeElement && document.activeElement.isSameNode(el) + return !!document.activeElement && document.activeElement.isSameNode(el); }, - focusFirstInteractive(el){ - let child = el.firstElementChild - while(child){ - if(this.attemptFocus(child, true) || this.focusFirstInteractive(child)){ - return true + focusFirstInteractive(el) { + let child = el.firstElementChild; + while (child) { + if (this.attemptFocus(child, true) || this.focusFirstInteractive(child)) { + return true; } - child = child.nextElementSibling + child = child.nextElementSibling; } }, - focusFirst(el){ - let child = el.firstElementChild - while(child){ - if(this.attemptFocus(child) || this.focusFirst(child)){ - return true + focusFirst(el) { + let child = el.firstElementChild; + while (child) { + if (this.attemptFocus(child) || this.focusFirst(child)) { + return true; } - child = child.nextElementSibling + child = child.nextElementSibling; } }, - focusLast(el){ - let child = el.lastElementChild - while(child){ - if(this.attemptFocus(child) || this.focusLast(child)){ - return true + focusLast(el) { + let child = el.lastElementChild; + while (child) { + if (this.attemptFocus(child) || this.focusLast(child)) { + return true; } - child = child.previousElementSibling + child = child.previousElementSibling; } - } -} -export default ARIA + }, +}; +export default ARIA; diff --git a/assets/js/phoenix_live_view/browser.js b/assets/js/phoenix_live_view/browser.js index 76ce876423..1cf64a31f8 100644 --- a/assets/js/phoenix_live_view/browser.js +++ b/assets/js/phoenix_live_view/browser.js @@ -1,84 +1,105 @@ const Browser = { - canPushState(){ return (typeof (history.pushState) !== "undefined") }, + canPushState() { + return typeof history.pushState !== "undefined"; + }, - dropLocal(localStorage, namespace, subkey){ - return localStorage.removeItem(this.localKey(namespace, subkey)) + dropLocal(localStorage, namespace, subkey) { + return localStorage.removeItem(this.localKey(namespace, subkey)); }, - updateLocal(localStorage, namespace, subkey, initial, func){ - const current = this.getLocal(localStorage, namespace, subkey) - const key = this.localKey(namespace, subkey) - const newVal = current === null ? initial : func(current) - localStorage.setItem(key, JSON.stringify(newVal)) - return newVal + updateLocal(localStorage, namespace, subkey, initial, func) { + const current = this.getLocal(localStorage, namespace, subkey); + const key = this.localKey(namespace, subkey); + const newVal = current === null ? initial : func(current); + localStorage.setItem(key, JSON.stringify(newVal)); + return newVal; }, - getLocal(localStorage, namespace, subkey){ - return JSON.parse(localStorage.getItem(this.localKey(namespace, subkey))) + getLocal(localStorage, namespace, subkey) { + return JSON.parse(localStorage.getItem(this.localKey(namespace, subkey))); }, - updateCurrentState(callback){ - if(!this.canPushState()){ return } - history.replaceState(callback(history.state || {}), "", window.location.href) + updateCurrentState(callback) { + if (!this.canPushState()) { + return; + } + history.replaceState( + callback(history.state || {}), + "", + window.location.href, + ); }, - pushState(kind, meta, to){ - if(this.canPushState()){ - if(to !== window.location.href){ - if(meta.type == "redirect" && meta.scroll){ + pushState(kind, meta, to) { + if (this.canPushState()) { + if (to !== window.location.href) { + if (meta.type == "redirect" && meta.scroll) { // If we're redirecting store the current scrollY for the current history state. - const currentState = history.state || {} - currentState.scroll = meta.scroll - history.replaceState(currentState, "", window.location.href) + const currentState = history.state || {}; + currentState.scroll = meta.scroll; + history.replaceState(currentState, "", window.location.href); } - delete meta.scroll // Only store the scroll in the redirect case. - history[kind + "State"](meta, "", to || null) // IE will coerce undefined to string + delete meta.scroll; // Only store the scroll in the redirect case. + history[kind + "State"](meta, "", to || null); // IE will coerce undefined to string // when using navigate, we'd call pushState immediately before patching the DOM, // jumping back to the top of the page, effectively ignoring the scrollIntoView; // therefore we wait for the next frame (after the DOM patch) and only then try // to scroll to the hashEl window.requestAnimationFrame(() => { - const hashEl = this.getHashTargetEl(window.location.hash) - - if(hashEl){ - hashEl.scrollIntoView() - } else if(meta.type === "redirect"){ - window.scroll(0, 0) + const hashEl = this.getHashTargetEl(window.location.hash); + + if (hashEl) { + hashEl.scrollIntoView(); + } else if (meta.type === "redirect") { + window.scroll(0, 0); } - }) + }); } } else { - this.redirect(to) + this.redirect(to); } }, - setCookie(name, value, maxAgeSeconds){ - const expires = typeof(maxAgeSeconds) === "number" ? ` max-age=${maxAgeSeconds};` : "" - document.cookie = `${name}=${value};${expires} path=/` + setCookie(name, value, maxAgeSeconds) { + const expires = + typeof maxAgeSeconds === "number" ? ` max-age=${maxAgeSeconds};` : ""; + document.cookie = `${name}=${value};${expires} path=/`; }, - getCookie(name){ - return document.cookie.replace(new RegExp(`(?:(?:^|.*;\s*)${name}\s*\=\s*([^;]*).*$)|^.*$`), "$1") + getCookie(name) { + return document.cookie.replace( + new RegExp(`(?:(?:^|.*;\s*)${name}\s*\=\s*([^;]*).*$)|^.*$`), + "$1", + ); }, - deleteCookie(name){ - document.cookie = `${name}=; max-age=-1; path=/` + deleteCookie(name) { + document.cookie = `${name}=; max-age=-1; path=/`; }, - redirect(toURL, flash){ - if(flash){ this.setCookie("__phoenix_flash__", flash, 60) } - window.location.href = toURL + redirect(toURL, flash) { + if (flash) { + this.setCookie("__phoenix_flash__", flash, 60); + } + window.location.href = toURL; }, - localKey(namespace, subkey){ return `${namespace}-${subkey}` }, + localKey(namespace, subkey) { + return `${namespace}-${subkey}`; + }, - getHashTargetEl(maybeHash){ - const hash = maybeHash.toString().substring(1) - if(hash === ""){ return } - return document.getElementById(hash) || document.querySelector(`a[name="${hash}"]`) - } -} + getHashTargetEl(maybeHash) { + const hash = maybeHash.toString().substring(1); + if (hash === "") { + return; + } + return ( + document.getElementById(hash) || + document.querySelector(`a[name="${hash}"]`) + ); + }, +}; -export default Browser +export default Browser; diff --git a/assets/js/phoenix_live_view/constants.js b/assets/js/phoenix_live_view/constants.js index 35ed909fc3..beffaa6793 100644 --- a/assets/js/phoenix_live_view/constants.js +++ b/assets/js/phoenix_live_view/constants.js @@ -1,92 +1,111 @@ -export const CONSECUTIVE_RELOADS = "consecutive-reloads" -export const MAX_RELOADS = 10 -export const RELOAD_JITTER_MIN = 5000 -export const RELOAD_JITTER_MAX = 10000 -export const FAILSAFE_JITTER = 30000 +export const CONSECUTIVE_RELOADS = "consecutive-reloads"; +export const MAX_RELOADS = 10; +export const RELOAD_JITTER_MIN = 5000; +export const RELOAD_JITTER_MAX = 10000; +export const FAILSAFE_JITTER = 30000; export const PHX_EVENT_CLASSES = [ - "phx-click-loading", "phx-change-loading", "phx-submit-loading", - "phx-keydown-loading", "phx-keyup-loading", "phx-blur-loading", "phx-focus-loading", - "phx-hook-loading" -] -export const PHX_COMPONENT = "data-phx-component" -export const PHX_LIVE_LINK = "data-phx-link" -export const PHX_TRACK_STATIC = "track-static" -export const PHX_LINK_STATE = "data-phx-link-state" -export const PHX_REF_LOADING = "data-phx-ref-loading" -export const PHX_REF_SRC = "data-phx-ref-src" -export const PHX_REF_LOCK = "data-phx-ref-lock" -export const PHX_TRACK_UPLOADS = "track-uploads" -export const PHX_UPLOAD_REF = "data-phx-upload-ref" -export const PHX_PREFLIGHTED_REFS = "data-phx-preflighted-refs" -export const PHX_DONE_REFS = "data-phx-done-refs" -export const PHX_DROP_TARGET = "drop-target" -export const PHX_ACTIVE_ENTRY_REFS = "data-phx-active-refs" -export const PHX_LIVE_FILE_UPDATED = "phx:live-file:updated" -export const PHX_SKIP = "data-phx-skip" -export const PHX_MAGIC_ID = "data-phx-id" -export const PHX_PRUNE = "data-phx-prune" -export const PHX_CONNECTED_CLASS = "phx-connected" -export const PHX_LOADING_CLASS = "phx-loading" -export const PHX_ERROR_CLASS = "phx-error" -export const PHX_CLIENT_ERROR_CLASS = "phx-client-error" -export const PHX_SERVER_ERROR_CLASS = "phx-server-error" -export const PHX_PARENT_ID = "data-phx-parent-id" -export const PHX_MAIN = "data-phx-main" -export const PHX_ROOT_ID = "data-phx-root-id" -export const PHX_VIEWPORT_TOP = "viewport-top" -export const PHX_VIEWPORT_BOTTOM = "viewport-bottom" -export const PHX_TRIGGER_ACTION = "trigger-action" -export const PHX_HAS_FOCUSED = "phx-has-focused" -export const FOCUSABLE_INPUTS = ["text", "textarea", "number", "email", "password", "search", "tel", "url", "date", "time", "datetime-local", "color", "range"] -export const CHECKABLE_INPUTS = ["checkbox", "radio"] -export const PHX_HAS_SUBMITTED = "phx-has-submitted" -export const PHX_SESSION = "data-phx-session" -export const PHX_VIEW_SELECTOR = `[${PHX_SESSION}]` -export const PHX_STICKY = "data-phx-sticky" -export const PHX_STATIC = "data-phx-static" -export const PHX_READONLY = "data-phx-readonly" -export const PHX_DISABLED = "data-phx-disabled" -export const PHX_DISABLE_WITH = "disable-with" -export const PHX_DISABLE_WITH_RESTORE = "data-phx-disable-with-restore" -export const PHX_HOOK = "hook" -export const PHX_DEBOUNCE = "debounce" -export const PHX_THROTTLE = "throttle" -export const PHX_UPDATE = "update" -export const PHX_STREAM = "stream" -export const PHX_STREAM_REF = "data-phx-stream" -export const PHX_KEY = "key" -export const PHX_PRIVATE = "phxPrivate" -export const PHX_AUTO_RECOVER = "auto-recover" -export const PHX_LV_DEBUG = "phx:live-socket:debug" -export const PHX_LV_PROFILE = "phx:live-socket:profiling" -export const PHX_LV_LATENCY_SIM = "phx:live-socket:latency-sim" -export const PHX_LV_HISTORY_POSITION = "phx:nav-history-position" -export const PHX_PROGRESS = "progress" -export const PHX_MOUNTED = "mounted" -export const PHX_RELOAD_STATUS = "__phoenix_reload_status__" -export const LOADER_TIMEOUT = 1 -export const MAX_CHILD_JOIN_ATTEMPTS = 3 -export const BEFORE_UNLOAD_LOADER_TIMEOUT = 200 -export const DISCONNECTED_TIMEOUT = 500 -export const BINDING_PREFIX = "phx-" -export const PUSH_TIMEOUT = 30000 -export const LINK_HEADER = "x-requested-with" -export const RESPONSE_URL_HEADER = "x-response-url" -export const DEBOUNCE_TRIGGER = "debounce-trigger" -export const THROTTLED = "throttled" -export const DEBOUNCE_PREV_KEY = "debounce-prev-key" + "phx-click-loading", + "phx-change-loading", + "phx-submit-loading", + "phx-keydown-loading", + "phx-keyup-loading", + "phx-blur-loading", + "phx-focus-loading", + "phx-hook-loading", +]; +export const PHX_COMPONENT = "data-phx-component"; +export const PHX_LIVE_LINK = "data-phx-link"; +export const PHX_TRACK_STATIC = "track-static"; +export const PHX_LINK_STATE = "data-phx-link-state"; +export const PHX_REF_LOADING = "data-phx-ref-loading"; +export const PHX_REF_SRC = "data-phx-ref-src"; +export const PHX_REF_LOCK = "data-phx-ref-lock"; +export const PHX_TRACK_UPLOADS = "track-uploads"; +export const PHX_UPLOAD_REF = "data-phx-upload-ref"; +export const PHX_PREFLIGHTED_REFS = "data-phx-preflighted-refs"; +export const PHX_DONE_REFS = "data-phx-done-refs"; +export const PHX_DROP_TARGET = "drop-target"; +export const PHX_ACTIVE_ENTRY_REFS = "data-phx-active-refs"; +export const PHX_LIVE_FILE_UPDATED = "phx:live-file:updated"; +export const PHX_SKIP = "data-phx-skip"; +export const PHX_MAGIC_ID = "data-phx-id"; +export const PHX_PRUNE = "data-phx-prune"; +export const PHX_CONNECTED_CLASS = "phx-connected"; +export const PHX_LOADING_CLASS = "phx-loading"; +export const PHX_ERROR_CLASS = "phx-error"; +export const PHX_CLIENT_ERROR_CLASS = "phx-client-error"; +export const PHX_SERVER_ERROR_CLASS = "phx-server-error"; +export const PHX_PARENT_ID = "data-phx-parent-id"; +export const PHX_MAIN = "data-phx-main"; +export const PHX_ROOT_ID = "data-phx-root-id"; +export const PHX_VIEWPORT_TOP = "viewport-top"; +export const PHX_VIEWPORT_BOTTOM = "viewport-bottom"; +export const PHX_TRIGGER_ACTION = "trigger-action"; +export const PHX_HAS_FOCUSED = "phx-has-focused"; +export const FOCUSABLE_INPUTS = [ + "text", + "textarea", + "number", + "email", + "password", + "search", + "tel", + "url", + "date", + "time", + "datetime-local", + "color", + "range", +]; +export const CHECKABLE_INPUTS = ["checkbox", "radio"]; +export const PHX_HAS_SUBMITTED = "phx-has-submitted"; +export const PHX_SESSION = "data-phx-session"; +export const PHX_VIEW_SELECTOR = `[${PHX_SESSION}]`; +export const PHX_STICKY = "data-phx-sticky"; +export const PHX_STATIC = "data-phx-static"; +export const PHX_READONLY = "data-phx-readonly"; +export const PHX_DISABLED = "data-phx-disabled"; +export const PHX_DISABLE_WITH = "disable-with"; +export const PHX_DISABLE_WITH_RESTORE = "data-phx-disable-with-restore"; +export const PHX_HOOK = "hook"; +export const PHX_DEBOUNCE = "debounce"; +export const PHX_THROTTLE = "throttle"; +export const PHX_UPDATE = "update"; +export const PHX_STREAM = "stream"; +export const PHX_STREAM_REF = "data-phx-stream"; +export const PHX_KEY = "key"; +export const PHX_PRIVATE = "phxPrivate"; +export const PHX_AUTO_RECOVER = "auto-recover"; +export const PHX_LV_DEBUG = "phx:live-socket:debug"; +export const PHX_LV_PROFILE = "phx:live-socket:profiling"; +export const PHX_LV_LATENCY_SIM = "phx:live-socket:latency-sim"; +export const PHX_LV_HISTORY_POSITION = "phx:nav-history-position"; +export const PHX_PROGRESS = "progress"; +export const PHX_MOUNTED = "mounted"; +export const PHX_RELOAD_STATUS = "__phoenix_reload_status__"; +export const LOADER_TIMEOUT = 1; +export const MAX_CHILD_JOIN_ATTEMPTS = 3; +export const BEFORE_UNLOAD_LOADER_TIMEOUT = 200; +export const DISCONNECTED_TIMEOUT = 500; +export const BINDING_PREFIX = "phx-"; +export const PUSH_TIMEOUT = 30000; +export const LINK_HEADER = "x-requested-with"; +export const RESPONSE_URL_HEADER = "x-response-url"; +export const DEBOUNCE_TRIGGER = "debounce-trigger"; +export const THROTTLED = "throttled"; +export const DEBOUNCE_PREV_KEY = "debounce-prev-key"; export const DEFAULTS = { debounce: 300, - throttle: 300 -} -export const PHX_PENDING_ATTRS = [PHX_REF_LOADING, PHX_REF_SRC, PHX_REF_LOCK] + throttle: 300, +}; +export const PHX_PENDING_ATTRS = [PHX_REF_LOADING, PHX_REF_SRC, PHX_REF_LOCK]; // Rendered -export const DYNAMICS = "d" -export const STATIC = "s" -export const ROOT = "r" -export const COMPONENTS = "c" -export const EVENTS = "e" -export const REPLY = "r" -export const TITLE = "t" -export const TEMPLATES = "p" -export const STREAM = "stream" +export const DYNAMICS = "d"; +export const STATIC = "s"; +export const ROOT = "r"; +export const COMPONENTS = "c"; +export const EVENTS = "e"; +export const REPLY = "r"; +export const TITLE = "t"; +export const TEMPLATES = "p"; +export const STREAM = "stream"; diff --git a/assets/js/phoenix_live_view/dom.js b/assets/js/phoenix_live_view/dom.js index 63aeab1d1b..4dfbd62a69 100644 --- a/assets/js/phoenix_live_view/dom.js +++ b/assets/js/phoenix_live_view/dom.js @@ -20,394 +20,516 @@ import { PHX_STICKY, PHX_EVENT_CLASSES, THROTTLED, -} from "./constants" +} from "./constants"; -import { - logError -} from "./utils" +import { logError } from "./utils"; const DOM = { - byId(id){ return document.getElementById(id) || logError(`no id found for ${id}`) }, + byId(id) { + return document.getElementById(id) || logError(`no id found for ${id}`); + }, - removeClass(el, className){ - el.classList.remove(className) - if(el.classList.length === 0){ el.removeAttribute("class") } + removeClass(el, className) { + el.classList.remove(className); + if (el.classList.length === 0) { + el.removeAttribute("class"); + } }, - all(node, query, callback){ - if(!node){ return [] } - const array = Array.from(node.querySelectorAll(query)) - if(callback){ - array.forEach(callback) + all(node, query, callback) { + if (!node) { + return []; } - return array + const array = Array.from(node.querySelectorAll(query)); + if (callback) { + array.forEach(callback); + } + return array; }, - childNodeLength(html){ - const template = document.createElement("template") - template.innerHTML = html - return template.content.childElementCount + childNodeLength(html) { + const template = document.createElement("template"); + template.innerHTML = html; + return template.content.childElementCount; }, - isUploadInput(el){ return el.type === "file" && el.getAttribute(PHX_UPLOAD_REF) !== null }, + isUploadInput(el) { + return el.type === "file" && el.getAttribute(PHX_UPLOAD_REF) !== null; + }, - isAutoUpload(inputEl){ return inputEl.hasAttribute("data-phx-auto-upload") }, + isAutoUpload(inputEl) { + return inputEl.hasAttribute("data-phx-auto-upload"); + }, - findUploadInputs(node){ - const formId = node.id - const inputsOutsideForm = this.all(document, `input[type="file"][${PHX_UPLOAD_REF}][form="${formId}"]`) - return this.all(node, `input[type="file"][${PHX_UPLOAD_REF}]`).concat(inputsOutsideForm) + findUploadInputs(node) { + const formId = node.id; + const inputsOutsideForm = this.all( + document, + `input[type="file"][${PHX_UPLOAD_REF}][form="${formId}"]`, + ); + return this.all(node, `input[type="file"][${PHX_UPLOAD_REF}]`).concat( + inputsOutsideForm, + ); }, - findComponentNodeList(node, cid){ - return this.filterWithinSameLiveView(this.all(node, `[${PHX_COMPONENT}="${cid}"]`), node) + findComponentNodeList(node, cid) { + return this.filterWithinSameLiveView( + this.all(node, `[${PHX_COMPONENT}="${cid}"]`), + node, + ); }, - isPhxDestroyed(node){ - return node.id && DOM.private(node, "destroyed") ? true : false + isPhxDestroyed(node) { + return node.id && DOM.private(node, "destroyed") ? true : false; }, - wantsNewTab(e){ - const wantsNewTab = e.ctrlKey || e.shiftKey || e.metaKey || (e.button && e.button === 1) - const isDownload = (e.target instanceof HTMLAnchorElement && e.target.hasAttribute("download")) - const isTargetBlank = e.target.hasAttribute("target") && e.target.getAttribute("target").toLowerCase() === "_blank" - const isTargetNamedTab = e.target.hasAttribute("target") && !e.target.getAttribute("target").startsWith("_") - return wantsNewTab || isTargetBlank || isDownload || isTargetNamedTab + wantsNewTab(e) { + const wantsNewTab = + e.ctrlKey || e.shiftKey || e.metaKey || (e.button && e.button === 1); + const isDownload = + e.target instanceof HTMLAnchorElement && + e.target.hasAttribute("download"); + const isTargetBlank = + e.target.hasAttribute("target") && + e.target.getAttribute("target").toLowerCase() === "_blank"; + const isTargetNamedTab = + e.target.hasAttribute("target") && + !e.target.getAttribute("target").startsWith("_"); + return wantsNewTab || isTargetBlank || isDownload || isTargetNamedTab; }, - isUnloadableFormSubmit(e){ + isUnloadableFormSubmit(e) { // Ignore form submissions intended to close a native

element // https://developer.mozilla.org/en-US/docs/Web/HTML/Element/dialog#usage_notes - const isDialogSubmit = (e.target && e.target.getAttribute("method") === "dialog") || - (e.submitter && e.submitter.getAttribute("formmethod") === "dialog") + const isDialogSubmit = + (e.target && e.target.getAttribute("method") === "dialog") || + (e.submitter && e.submitter.getAttribute("formmethod") === "dialog"); - if(isDialogSubmit){ - return false + if (isDialogSubmit) { + return false; } else { - return !e.defaultPrevented && !this.wantsNewTab(e) + return !e.defaultPrevented && !this.wantsNewTab(e); } }, - isNewPageClick(e, currentLocation){ - const href = e.target instanceof HTMLAnchorElement ? e.target.getAttribute("href") : null - let url + isNewPageClick(e, currentLocation) { + const href = + e.target instanceof HTMLAnchorElement + ? e.target.getAttribute("href") + : null; + let url; - if(e.defaultPrevented || href === null || this.wantsNewTab(e)){ return false } - if(href.startsWith("mailto:") || href.startsWith("tel:")){ return false } - if(e.target.isContentEditable){ return false } + if (e.defaultPrevented || href === null || this.wantsNewTab(e)) { + return false; + } + if (href.startsWith("mailto:") || href.startsWith("tel:")) { + return false; + } + if (e.target.isContentEditable) { + return false; + } try { - url = new URL(href) + url = new URL(href); } catch { try { - url = new URL(href, currentLocation) + url = new URL(href, currentLocation); } catch { // bad URL, fallback to let browser try it as external - return true + return true; } } - if(url.host === currentLocation.host && url.protocol === currentLocation.protocol){ - if(url.pathname === currentLocation.pathname && url.search === currentLocation.search){ - return url.hash === "" && !url.href.endsWith("#") + if ( + url.host === currentLocation.host && + url.protocol === currentLocation.protocol + ) { + if ( + url.pathname === currentLocation.pathname && + url.search === currentLocation.search + ) { + return url.hash === "" && !url.href.endsWith("#"); } } - return url.protocol.startsWith("http") + return url.protocol.startsWith("http"); }, - markPhxChildDestroyed(el){ - if(this.isPhxChild(el)){ el.setAttribute(PHX_SESSION, "") } - this.putPrivate(el, "destroyed", true) + markPhxChildDestroyed(el) { + if (this.isPhxChild(el)) { + el.setAttribute(PHX_SESSION, ""); + } + this.putPrivate(el, "destroyed", true); }, - findPhxChildrenInFragment(html, parentId){ - const template = document.createElement("template") - template.innerHTML = html - return this.findPhxChildren(template.content, parentId) + findPhxChildrenInFragment(html, parentId) { + const template = document.createElement("template"); + template.innerHTML = html; + return this.findPhxChildren(template.content, parentId); }, - isIgnored(el, phxUpdate){ - return (el.getAttribute(phxUpdate) || el.getAttribute("data-phx-update")) === "ignore" + isIgnored(el, phxUpdate) { + return ( + (el.getAttribute(phxUpdate) || el.getAttribute("data-phx-update")) === + "ignore" + ); }, - isPhxUpdate(el, phxUpdate, updateTypes){ - return el.getAttribute && updateTypes.indexOf(el.getAttribute(phxUpdate)) >= 0 + isPhxUpdate(el, phxUpdate, updateTypes) { + return ( + el.getAttribute && updateTypes.indexOf(el.getAttribute(phxUpdate)) >= 0 + ); }, - findPhxSticky(el){ return this.all(el, `[${PHX_STICKY}]`) }, + findPhxSticky(el) { + return this.all(el, `[${PHX_STICKY}]`); + }, - findPhxChildren(el, parentId){ - return this.all(el, `${PHX_VIEW_SELECTOR}[${PHX_PARENT_ID}="${parentId}"]`) + findPhxChildren(el, parentId) { + return this.all(el, `${PHX_VIEW_SELECTOR}[${PHX_PARENT_ID}="${parentId}"]`); }, - findExistingParentCIDs(node, cids){ + findExistingParentCIDs(node, cids) { // we only want to find parents that exist on the page // if a cid is not on the page, the only way it can be added back to the page // is if a parent adds it back, therefore if a cid does not exist on the page, // we should not try to render it by itself (because it would be rendered twice, // one by the parent, and a second time by itself) - const parentCids = new Set() - const childrenCids = new Set() - - cids.forEach(cid => { - this.filterWithinSameLiveView(this.all(node, `[${PHX_COMPONENT}="${cid}"]`), node).forEach(parent => { - parentCids.add(cid) - this.filterWithinSameLiveView(this.all(parent, `[${PHX_COMPONENT}]`), parent) - .map(el => parseInt(el.getAttribute(PHX_COMPONENT))) - .forEach(childCID => childrenCids.add(childCID)) - }) - }) - - childrenCids.forEach(childCid => parentCids.delete(childCid)) - - return parentCids - }, - - filterWithinSameLiveView(nodes, parent){ - if(parent.querySelector(PHX_VIEW_SELECTOR)){ - return nodes.filter(el => this.withinSameLiveView(el, parent)) + const parentCids = new Set(); + const childrenCids = new Set(); + + cids.forEach((cid) => { + this.filterWithinSameLiveView( + this.all(node, `[${PHX_COMPONENT}="${cid}"]`), + node, + ).forEach((parent) => { + parentCids.add(cid); + this.filterWithinSameLiveView( + this.all(parent, `[${PHX_COMPONENT}]`), + parent, + ) + .map((el) => parseInt(el.getAttribute(PHX_COMPONENT))) + .forEach((childCID) => childrenCids.add(childCID)); + }); + }); + + childrenCids.forEach((childCid) => parentCids.delete(childCid)); + + return parentCids; + }, + + filterWithinSameLiveView(nodes, parent) { + if (parent.querySelector(PHX_VIEW_SELECTOR)) { + return nodes.filter((el) => this.withinSameLiveView(el, parent)); } else { - return nodes + return nodes; } }, - withinSameLiveView(node, parent){ - while(node = node.parentNode){ - if(node.isSameNode(parent)){ return true } - if(node.getAttribute(PHX_SESSION) !== null){ return false } + withinSameLiveView(node, parent) { + while ((node = node.parentNode)) { + if (node.isSameNode(parent)) { + return true; + } + if (node.getAttribute(PHX_SESSION) !== null) { + return false; + } } }, - private(el, key){ return el[PHX_PRIVATE] && el[PHX_PRIVATE][key] }, - - deletePrivate(el, key){ el[PHX_PRIVATE] && delete (el[PHX_PRIVATE][key]) }, + private(el, key) { + return el[PHX_PRIVATE] && el[PHX_PRIVATE][key]; + }, - putPrivate(el, key, value){ - if(!el[PHX_PRIVATE]){ el[PHX_PRIVATE] = {} } - el[PHX_PRIVATE][key] = value + deletePrivate(el, key) { + el[PHX_PRIVATE] && delete el[PHX_PRIVATE][key]; }, - updatePrivate(el, key, defaultVal, updateFunc){ - const existing = this.private(el, key) - if(existing === undefined){ - this.putPrivate(el, key, updateFunc(defaultVal)) - } else { - this.putPrivate(el, key, updateFunc(existing)) + putPrivate(el, key, value) { + if (!el[PHX_PRIVATE]) { + el[PHX_PRIVATE] = {}; } + el[PHX_PRIVATE][key] = value; }, - syncPendingAttrs(fromEl, toEl){ - if(!fromEl.hasAttribute(PHX_REF_SRC)){ return } - PHX_EVENT_CLASSES.forEach(className => { - fromEl.classList.contains(className) && toEl.classList.add(className) - }) - PHX_PENDING_ATTRS.filter(attr => fromEl.hasAttribute(attr)).forEach(attr => { - toEl.setAttribute(attr, fromEl.getAttribute(attr)) - }) + updatePrivate(el, key, defaultVal, updateFunc) { + const existing = this.private(el, key); + if (existing === undefined) { + this.putPrivate(el, key, updateFunc(defaultVal)); + } else { + this.putPrivate(el, key, updateFunc(existing)); + } }, - copyPrivates(target, source){ - if(source[PHX_PRIVATE]){ - target[PHX_PRIVATE] = source[PHX_PRIVATE] + syncPendingAttrs(fromEl, toEl) { + if (!fromEl.hasAttribute(PHX_REF_SRC)) { + return; + } + PHX_EVENT_CLASSES.forEach((className) => { + fromEl.classList.contains(className) && toEl.classList.add(className); + }); + PHX_PENDING_ATTRS.filter((attr) => fromEl.hasAttribute(attr)).forEach( + (attr) => { + toEl.setAttribute(attr, fromEl.getAttribute(attr)); + }, + ); + }, + + copyPrivates(target, source) { + if (source[PHX_PRIVATE]) { + target[PHX_PRIVATE] = source[PHX_PRIVATE]; } }, - putTitle(str){ - const titleEl = document.querySelector("title") - if(titleEl){ - const {prefix, suffix, default: defaultTitle} = titleEl.dataset - const isEmpty = typeof(str) !== "string" || str.trim() === "" - if(isEmpty && typeof(defaultTitle) !== "string"){ return } + putTitle(str) { + const titleEl = document.querySelector("title"); + if (titleEl) { + const { prefix, suffix, default: defaultTitle } = titleEl.dataset; + const isEmpty = typeof str !== "string" || str.trim() === ""; + if (isEmpty && typeof defaultTitle !== "string") { + return; + } - const inner = isEmpty ? defaultTitle : str - document.title = `${prefix || ""}${inner || ""}${suffix || ""}` + const inner = isEmpty ? defaultTitle : str; + document.title = `${prefix || ""}${inner || ""}${suffix || ""}`; } else { - document.title = str + document.title = str; } }, - debounce(el, event, phxDebounce, defaultDebounce, phxThrottle, defaultThrottle, asyncFilter, callback){ - let debounce = el.getAttribute(phxDebounce) - let throttle = el.getAttribute(phxThrottle) - - if(debounce === ""){ debounce = defaultDebounce } - if(throttle === ""){ throttle = defaultThrottle } - const value = debounce || throttle - switch(value){ - case null: return callback() + debounce( + el, + event, + phxDebounce, + defaultDebounce, + phxThrottle, + defaultThrottle, + asyncFilter, + callback, + ) { + let debounce = el.getAttribute(phxDebounce); + let throttle = el.getAttribute(phxThrottle); + + if (debounce === "") { + debounce = defaultDebounce; + } + if (throttle === "") { + throttle = defaultThrottle; + } + const value = debounce || throttle; + switch (value) { + case null: + return callback(); case "blur": this.incCycle(el, "debounce-blur-cycle", () => { - if(asyncFilter()){ callback() } - }) - if(this.once(el, "debounce-blur")){ - el.addEventListener("blur", () => this.triggerCycle(el, "debounce-blur-cycle")) + if (asyncFilter()) { + callback(); + } + }); + if (this.once(el, "debounce-blur")) { + el.addEventListener("blur", () => + this.triggerCycle(el, "debounce-blur-cycle"), + ); } - return + return; default: - const timeout = parseInt(value) - const trigger = () => throttle ? this.deletePrivate(el, THROTTLED) : callback() - const currentCycle = this.incCycle(el, DEBOUNCE_TRIGGER, trigger) - if(isNaN(timeout)){ return logError(`invalid throttle/debounce value: ${value}`) } - if(throttle){ - let newKeyDown = false - if(event.type === "keydown"){ - const prevKey = this.private(el, DEBOUNCE_PREV_KEY) - this.putPrivate(el, DEBOUNCE_PREV_KEY, event.key) - newKeyDown = prevKey !== event.key + const timeout = parseInt(value); + const trigger = () => + throttle ? this.deletePrivate(el, THROTTLED) : callback(); + const currentCycle = this.incCycle(el, DEBOUNCE_TRIGGER, trigger); + if (isNaN(timeout)) { + return logError(`invalid throttle/debounce value: ${value}`); + } + if (throttle) { + let newKeyDown = false; + if (event.type === "keydown") { + const prevKey = this.private(el, DEBOUNCE_PREV_KEY); + this.putPrivate(el, DEBOUNCE_PREV_KEY, event.key); + newKeyDown = prevKey !== event.key; } - if(!newKeyDown && this.private(el, THROTTLED)){ - return false + if (!newKeyDown && this.private(el, THROTTLED)) { + return false; } else { - callback() + callback(); const t = setTimeout(() => { - if(asyncFilter()){ this.triggerCycle(el, DEBOUNCE_TRIGGER) } - }, timeout) - this.putPrivate(el, THROTTLED, t) + if (asyncFilter()) { + this.triggerCycle(el, DEBOUNCE_TRIGGER); + } + }, timeout); + this.putPrivate(el, THROTTLED, t); } } else { setTimeout(() => { - if(asyncFilter()){ this.triggerCycle(el, DEBOUNCE_TRIGGER, currentCycle) } - }, timeout) + if (asyncFilter()) { + this.triggerCycle(el, DEBOUNCE_TRIGGER, currentCycle); + } + }, timeout); } - const form = el.form - if(form && this.once(form, "bind-debounce")){ + const form = el.form; + if (form && this.once(form, "bind-debounce")) { form.addEventListener("submit", () => { - Array.from((new FormData(form)).entries(), ([name]) => { - const input = form.querySelector(`[name="${name}"]`) - this.incCycle(input, DEBOUNCE_TRIGGER) - this.deletePrivate(input, THROTTLED) - }) - }) + Array.from(new FormData(form).entries(), ([name]) => { + const input = form.querySelector(`[name="${name}"]`); + this.incCycle(input, DEBOUNCE_TRIGGER); + this.deletePrivate(input, THROTTLED); + }); + }); } - if(this.once(el, "bind-debounce")){ + if (this.once(el, "bind-debounce")) { el.addEventListener("blur", () => { // because we trigger the callback here, // we also clear the throttle timeout to prevent the callback // from being called again after the timeout fires - clearTimeout(this.private(el, THROTTLED)) - this.triggerCycle(el, DEBOUNCE_TRIGGER) - }) + clearTimeout(this.private(el, THROTTLED)); + this.triggerCycle(el, DEBOUNCE_TRIGGER); + }); } } }, - triggerCycle(el, key, currentCycle){ - const [cycle, trigger] = this.private(el, key) - if(!currentCycle){ currentCycle = cycle } - if(currentCycle === cycle){ - this.incCycle(el, key) - trigger() + triggerCycle(el, key, currentCycle) { + const [cycle, trigger] = this.private(el, key); + if (!currentCycle) { + currentCycle = cycle; + } + if (currentCycle === cycle) { + this.incCycle(el, key); + trigger(); } }, - once(el, key){ - if(this.private(el, key) === true){ return false } - this.putPrivate(el, key, true) - return true + once(el, key) { + if (this.private(el, key) === true) { + return false; + } + this.putPrivate(el, key, true); + return true; }, - incCycle(el, key, trigger = function (){ }){ - let [currentCycle] = this.private(el, key) || [0, trigger] - currentCycle++ - this.putPrivate(el, key, [currentCycle, trigger]) - return currentCycle + incCycle(el, key, trigger = function () {}) { + let [currentCycle] = this.private(el, key) || [0, trigger]; + currentCycle++; + this.putPrivate(el, key, [currentCycle, trigger]); + return currentCycle; }, // maintains or adds privately used hook information // fromEl and toEl can be the same element in the case of a newly added node // fromEl and toEl can be any HTML node type, so we need to check if it's an element node - maintainPrivateHooks(fromEl, toEl, phxViewportTop, phxViewportBottom){ + maintainPrivateHooks(fromEl, toEl, phxViewportTop, phxViewportBottom) { // maintain the hooks created with createHook - if(fromEl.hasAttribute && fromEl.hasAttribute("data-phx-hook") && !toEl.hasAttribute("data-phx-hook")){ - toEl.setAttribute("data-phx-hook", fromEl.getAttribute("data-phx-hook")) + if ( + fromEl.hasAttribute && + fromEl.hasAttribute("data-phx-hook") && + !toEl.hasAttribute("data-phx-hook") + ) { + toEl.setAttribute("data-phx-hook", fromEl.getAttribute("data-phx-hook")); } // add hooks to elements with viewport attributes - if(toEl.hasAttribute && (toEl.hasAttribute(phxViewportTop) || toEl.hasAttribute(phxViewportBottom))){ - toEl.setAttribute("data-phx-hook", "Phoenix.InfiniteScroll") + if ( + toEl.hasAttribute && + (toEl.hasAttribute(phxViewportTop) || + toEl.hasAttribute(phxViewportBottom)) + ) { + toEl.setAttribute("data-phx-hook", "Phoenix.InfiniteScroll"); } }, - putCustomElHook(el, hook){ - if(el.isConnected){ - el.setAttribute("data-phx-hook", "") + putCustomElHook(el, hook) { + if (el.isConnected) { + el.setAttribute("data-phx-hook", ""); } else { console.error(` hook attached to non-connected DOM element ensure you are calling createHook within your connectedCallback. ${el.outerHTML} - `) + `); } - this.putPrivate(el, "custom-el-hook", hook) + this.putPrivate(el, "custom-el-hook", hook); }, - getCustomElHook(el){ return this.private(el, "custom-el-hook") }, - - isUsedInput(el){ - return (el.nodeType === Node.ELEMENT_NODE && - (this.private(el, PHX_HAS_FOCUSED) || this.private(el, PHX_HAS_SUBMITTED))) + getCustomElHook(el) { + return this.private(el, "custom-el-hook"); }, - resetForm(form){ - Array.from(form.elements).forEach(input => { - this.deletePrivate(input, PHX_HAS_FOCUSED) - this.deletePrivate(input, PHX_HAS_SUBMITTED) - }) + isUsedInput(el) { + return ( + el.nodeType === Node.ELEMENT_NODE && + (this.private(el, PHX_HAS_FOCUSED) || this.private(el, PHX_HAS_SUBMITTED)) + ); }, - isPhxChild(node){ - return node.getAttribute && node.getAttribute(PHX_PARENT_ID) + resetForm(form) { + Array.from(form.elements).forEach((input) => { + this.deletePrivate(input, PHX_HAS_FOCUSED); + this.deletePrivate(input, PHX_HAS_SUBMITTED); + }); }, - isPhxSticky(node){ - return node.getAttribute && node.getAttribute(PHX_STICKY) !== null + isPhxChild(node) { + return node.getAttribute && node.getAttribute(PHX_PARENT_ID); }, - isChildOfAny(el, parents){ - return !!parents.find(parent => parent.contains(el)) + isPhxSticky(node) { + return node.getAttribute && node.getAttribute(PHX_STICKY) !== null; }, - firstPhxChild(el){ - return this.isPhxChild(el) ? el : this.all(el, `[${PHX_PARENT_ID}]`)[0] + isChildOfAny(el, parents) { + return !!parents.find((parent) => parent.contains(el)); }, - dispatchEvent(target, name, opts = {}){ - let defaultBubble = true - const isUploadTarget = target.nodeName === "INPUT" && target.type === "file" - if(isUploadTarget && name === "click"){ - defaultBubble = false - } - const bubbles = opts.bubbles === undefined ? defaultBubble : !!opts.bubbles - const eventOpts = {bubbles: bubbles, cancelable: true, detail: opts.detail || {}} - const event = name === "click" ? new MouseEvent("click", eventOpts) : new CustomEvent(name, eventOpts) - target.dispatchEvent(event) + firstPhxChild(el) { + return this.isPhxChild(el) ? el : this.all(el, `[${PHX_PARENT_ID}]`)[0]; }, - cloneNode(node, html){ - if(typeof (html) === "undefined"){ - return node.cloneNode(true) + dispatchEvent(target, name, opts = {}) { + let defaultBubble = true; + const isUploadTarget = + target.nodeName === "INPUT" && target.type === "file"; + if (isUploadTarget && name === "click") { + defaultBubble = false; + } + const bubbles = opts.bubbles === undefined ? defaultBubble : !!opts.bubbles; + const eventOpts = { + bubbles: bubbles, + cancelable: true, + detail: opts.detail || {}, + }; + const event = + name === "click" + ? new MouseEvent("click", eventOpts) + : new CustomEvent(name, eventOpts); + target.dispatchEvent(event); + }, + + cloneNode(node, html) { + if (typeof html === "undefined") { + return node.cloneNode(true); } else { - const cloned = node.cloneNode(false) - cloned.innerHTML = html - return cloned + const cloned = node.cloneNode(false); + cloned.innerHTML = html; + return cloned; } }, // merge attributes from source to target // if an element is ignored, we only merge data attributes // including removing data attributes that are no longer in the source - mergeAttrs(target, source, opts = {}){ - const exclude = new Set(opts.exclude || []) - const isIgnored = opts.isIgnored - const sourceAttrs = source.attributes - for(let i = sourceAttrs.length - 1; i >= 0; i--){ - const name = sourceAttrs[i].name - if(!exclude.has(name)){ - const sourceValue = source.getAttribute(name) - if(target.getAttribute(name) !== sourceValue && (!isIgnored || (isIgnored && name.startsWith("data-")))){ - target.setAttribute(name, sourceValue) + mergeAttrs(target, source, opts = {}) { + const exclude = new Set(opts.exclude || []); + const isIgnored = opts.isIgnored; + const sourceAttrs = source.attributes; + for (let i = sourceAttrs.length - 1; i >= 0; i--) { + const name = sourceAttrs[i].name; + if (!exclude.has(name)) { + const sourceValue = source.getAttribute(name); + if ( + target.getAttribute(name) !== sourceValue && + (!isIgnored || (isIgnored && name.startsWith("data-"))) + ) { + target.setAttribute(name, sourceValue); } } else { // We exclude the value from being merged on focused inputs, because the @@ -417,144 +539,194 @@ const DOM = { // when an input is back in its "original state", because the attribute // was never changed, see: // https://github.com/phoenixframework/phoenix_live_view/issues/2163 - if(name === "value" && target.value === source.value){ + if (name === "value" && target.value === source.value) { // actually set the value attribute to sync it with the value property - target.setAttribute("value", source.getAttribute(name)) + target.setAttribute("value", source.getAttribute(name)); } } } - const targetAttrs = target.attributes - for(let i = targetAttrs.length - 1; i >= 0; i--){ - const name = targetAttrs[i].name - if(isIgnored){ - if(name.startsWith("data-") && !source.hasAttribute(name) && !PHX_PENDING_ATTRS.includes(name)){ target.removeAttribute(name) } + const targetAttrs = target.attributes; + for (let i = targetAttrs.length - 1; i >= 0; i--) { + const name = targetAttrs[i].name; + if (isIgnored) { + if ( + name.startsWith("data-") && + !source.hasAttribute(name) && + !PHX_PENDING_ATTRS.includes(name) + ) { + target.removeAttribute(name); + } } else { - if(!source.hasAttribute(name)){ target.removeAttribute(name) } + if (!source.hasAttribute(name)) { + target.removeAttribute(name); + } } } }, - mergeFocusedInput(target, source){ + mergeFocusedInput(target, source) { // skip selects because FF will reset highlighted index for any setAttribute - if(!(target instanceof HTMLSelectElement)){ DOM.mergeAttrs(target, source, {exclude: ["value"]}) } + if (!(target instanceof HTMLSelectElement)) { + DOM.mergeAttrs(target, source, { exclude: ["value"] }); + } - if(source.readOnly){ - target.setAttribute("readonly", true) + if (source.readOnly) { + target.setAttribute("readonly", true); } else { - target.removeAttribute("readonly") + target.removeAttribute("readonly"); } }, - hasSelectionRange(el){ - return el.setSelectionRange && (el.type === "text" || el.type === "textarea") + hasSelectionRange(el) { + return ( + el.setSelectionRange && (el.type === "text" || el.type === "textarea") + ); }, - restoreFocus(focused, selectionStart, selectionEnd){ - if(focused instanceof HTMLSelectElement){ focused.focus() } - if(!DOM.isTextualInput(focused)){ return } + restoreFocus(focused, selectionStart, selectionEnd) { + if (focused instanceof HTMLSelectElement) { + focused.focus(); + } + if (!DOM.isTextualInput(focused)) { + return; + } - const wasFocused = focused.matches(":focus") - if(!wasFocused){ focused.focus() } - if(this.hasSelectionRange(focused)){ - focused.setSelectionRange(selectionStart, selectionEnd) + const wasFocused = focused.matches(":focus"); + if (!wasFocused) { + focused.focus(); + } + if (this.hasSelectionRange(focused)) { + focused.setSelectionRange(selectionStart, selectionEnd); } }, - isFormInput(el){ return /^(?:input|select|textarea)$/i.test(el.tagName) && el.type !== "button" }, + isFormInput(el) { + return ( + /^(?:input|select|textarea)$/i.test(el.tagName) && el.type !== "button" + ); + }, - syncAttrsToProps(el){ - if(el instanceof HTMLInputElement && CHECKABLE_INPUTS.indexOf(el.type.toLocaleLowerCase()) >= 0){ - el.checked = el.getAttribute("checked") !== null + syncAttrsToProps(el) { + if ( + el instanceof HTMLInputElement && + CHECKABLE_INPUTS.indexOf(el.type.toLocaleLowerCase()) >= 0 + ) { + el.checked = el.getAttribute("checked") !== null; } }, - isTextualInput(el){ return FOCUSABLE_INPUTS.indexOf(el.type) >= 0 }, + isTextualInput(el) { + return FOCUSABLE_INPUTS.indexOf(el.type) >= 0; + }, - isNowTriggerFormExternal(el, phxTriggerExternal){ - return el.getAttribute && el.getAttribute(phxTriggerExternal) !== null && document.body.contains(el) + isNowTriggerFormExternal(el, phxTriggerExternal) { + return ( + el.getAttribute && + el.getAttribute(phxTriggerExternal) !== null && + document.body.contains(el) + ); }, - cleanChildNodes(container, phxUpdate){ - if(DOM.isPhxUpdate(container, phxUpdate, ["append", "prepend"])){ - const toRemove = [] - container.childNodes.forEach(childNode => { - if(!childNode.id){ + cleanChildNodes(container, phxUpdate) { + if (DOM.isPhxUpdate(container, phxUpdate, ["append", "prepend"])) { + const toRemove = []; + container.childNodes.forEach((childNode) => { + if (!childNode.id) { // Skip warning if it's an empty text node (e.g. a new-line) - const isEmptyTextNode = childNode.nodeType === Node.TEXT_NODE && childNode.nodeValue.trim() === "" - if(!isEmptyTextNode && childNode.nodeType !== Node.COMMENT_NODE){ - logError("only HTML element tags with an id are allowed inside containers with phx-update.\n\n" + - `removing illegal node: "${(childNode.outerHTML || childNode.nodeValue).trim()}"\n\n`) + const isEmptyTextNode = + childNode.nodeType === Node.TEXT_NODE && + childNode.nodeValue.trim() === ""; + if (!isEmptyTextNode && childNode.nodeType !== Node.COMMENT_NODE) { + logError( + "only HTML element tags with an id are allowed inside containers with phx-update.\n\n" + + `removing illegal node: "${(childNode.outerHTML || childNode.nodeValue).trim()}"\n\n`, + ); } - toRemove.push(childNode) + toRemove.push(childNode); } - }) - toRemove.forEach(childNode => childNode.remove()) + }); + toRemove.forEach((childNode) => childNode.remove()); } }, - replaceRootContainer(container, tagName, attrs){ - const retainedAttrs = new Set(["id", PHX_SESSION, PHX_STATIC, PHX_MAIN, PHX_ROOT_ID]) - if(container.tagName.toLowerCase() === tagName.toLowerCase()){ + replaceRootContainer(container, tagName, attrs) { + const retainedAttrs = new Set([ + "id", + PHX_SESSION, + PHX_STATIC, + PHX_MAIN, + PHX_ROOT_ID, + ]); + if (container.tagName.toLowerCase() === tagName.toLowerCase()) { Array.from(container.attributes) - .filter(attr => !retainedAttrs.has(attr.name.toLowerCase())) - .forEach(attr => container.removeAttribute(attr.name)) + .filter((attr) => !retainedAttrs.has(attr.name.toLowerCase())) + .forEach((attr) => container.removeAttribute(attr.name)); Object.keys(attrs) - .filter(name => !retainedAttrs.has(name.toLowerCase())) - .forEach(attr => container.setAttribute(attr, attrs[attr])) - - return container + .filter((name) => !retainedAttrs.has(name.toLowerCase())) + .forEach((attr) => container.setAttribute(attr, attrs[attr])); + return container; } else { - const newContainer = document.createElement(tagName) - Object.keys(attrs).forEach(attr => newContainer.setAttribute(attr, attrs[attr])) - retainedAttrs.forEach(attr => newContainer.setAttribute(attr, container.getAttribute(attr))) - newContainer.innerHTML = container.innerHTML - container.replaceWith(newContainer) - return newContainer + const newContainer = document.createElement(tagName); + Object.keys(attrs).forEach((attr) => + newContainer.setAttribute(attr, attrs[attr]), + ); + retainedAttrs.forEach((attr) => + newContainer.setAttribute(attr, container.getAttribute(attr)), + ); + newContainer.innerHTML = container.innerHTML; + container.replaceWith(newContainer); + return newContainer; } }, - getSticky(el, name, defaultVal){ - const op = (DOM.private(el, "sticky") || []).find(([existingName,]) => name === existingName) - if(op){ - const [_name, _op, stashedResult] = op - return stashedResult + getSticky(el, name, defaultVal) { + const op = (DOM.private(el, "sticky") || []).find( + ([existingName]) => name === existingName, + ); + if (op) { + const [_name, _op, stashedResult] = op; + return stashedResult; } else { - return typeof(defaultVal) === "function" ? defaultVal() : defaultVal + return typeof defaultVal === "function" ? defaultVal() : defaultVal; } }, - deleteSticky(el, name){ - this.updatePrivate(el, "sticky", [], ops => { - return ops.filter(([existingName, _]) => existingName !== name) - }) + deleteSticky(el, name) { + this.updatePrivate(el, "sticky", [], (ops) => { + return ops.filter(([existingName, _]) => existingName !== name); + }); }, - putSticky(el, name, op){ - const stashedResult = op(el) - this.updatePrivate(el, "sticky", [], ops => { - const existingIndex = ops.findIndex(([existingName,]) => name === existingName) - if(existingIndex >= 0){ - ops[existingIndex] = [name, op, stashedResult] + putSticky(el, name, op) { + const stashedResult = op(el); + this.updatePrivate(el, "sticky", [], (ops) => { + const existingIndex = ops.findIndex( + ([existingName]) => name === existingName, + ); + if (existingIndex >= 0) { + ops[existingIndex] = [name, op, stashedResult]; } else { - ops.push([name, op, stashedResult]) + ops.push([name, op, stashedResult]); } - return ops - }) + return ops; + }); }, - applyStickyOperations(el){ - const ops = DOM.private(el, "sticky") - if(!ops){ return } + applyStickyOperations(el) { + const ops = DOM.private(el, "sticky"); + if (!ops) { + return; + } - ops.forEach(([name, op, _stashed]) => this.putSticky(el, name, op)) + ops.forEach(([name, op, _stashed]) => this.putSticky(el, name, op)); }, - isLocked(el){ - return el.hasAttribute && el.hasAttribute(PHX_REF_LOCK) - } -} + isLocked(el) { + return el.hasAttribute && el.hasAttribute(PHX_REF_LOCK); + }, +}; -export default DOM +export default DOM; diff --git a/assets/js/phoenix_live_view/dom_patch.js b/assets/js/phoenix_live_view/dom_patch.js index 9057e88cc4..174368cb9e 100644 --- a/assets/js/phoenix_live_view/dom_patch.js +++ b/assets/js/phoenix_live_view/dom_patch.js @@ -14,478 +14,614 @@ import { PHX_STREAM_REF, PHX_VIEWPORT_TOP, PHX_VIEWPORT_BOTTOM, -} from "./constants" +} from "./constants"; -import { - detectDuplicateIds, - detectInvalidStreamInserts, - isCid -} from "./utils" -import ElementRef from "./element_ref" -import DOM from "./dom" -import DOMPostMorphRestorer from "./dom_post_morph_restorer" -import morphdom from "morphdom" +import { detectDuplicateIds, detectInvalidStreamInserts, isCid } from "./utils"; +import ElementRef from "./element_ref"; +import DOM from "./dom"; +import DOMPostMorphRestorer from "./dom_post_morph_restorer"; +import morphdom from "morphdom"; export default class DOMPatch { - constructor(view, container, id, html, streams, targetCID, opts={}){ - this.view = view - this.liveSocket = view.liveSocket - this.container = container - this.id = id - this.rootID = view.root.id - this.html = html - this.streams = streams - this.streamInserts = {} - this.streamComponentRestore = {} - this.targetCID = targetCID - this.cidPatch = isCid(this.targetCID) - this.pendingRemoves = [] - this.phxRemove = this.liveSocket.binding("remove") - this.targetContainer = this.isCIDPatch() ? this.targetCIDContainer(html) : container + constructor(view, container, id, html, streams, targetCID, opts = {}) { + this.view = view; + this.liveSocket = view.liveSocket; + this.container = container; + this.id = id; + this.rootID = view.root.id; + this.html = html; + this.streams = streams; + this.streamInserts = {}; + this.streamComponentRestore = {}; + this.targetCID = targetCID; + this.cidPatch = isCid(this.targetCID); + this.pendingRemoves = []; + this.phxRemove = this.liveSocket.binding("remove"); + this.targetContainer = this.isCIDPatch() + ? this.targetCIDContainer(html) + : container; this.callbacks = { - beforeadded: [], beforeupdated: [], beforephxChildAdded: [], - afteradded: [], afterupdated: [], afterdiscarded: [], afterphxChildAdded: [], - aftertransitionsDiscarded: [] - } - this.withChildren = opts.withChildren || opts.undoRef || false - this.undoRef = opts.undoRef + beforeadded: [], + beforeupdated: [], + beforephxChildAdded: [], + afteradded: [], + afterupdated: [], + afterdiscarded: [], + afterphxChildAdded: [], + aftertransitionsDiscarded: [], + }; + this.withChildren = opts.withChildren || opts.undoRef || false; + this.undoRef = opts.undoRef; } - before(kind, callback){ this.callbacks[`before${kind}`].push(callback) } - after(kind, callback){ this.callbacks[`after${kind}`].push(callback) } + before(kind, callback) { + this.callbacks[`before${kind}`].push(callback); + } + after(kind, callback) { + this.callbacks[`after${kind}`].push(callback); + } - trackBefore(kind, ...args){ - this.callbacks[`before${kind}`].forEach(callback => callback(...args)) + trackBefore(kind, ...args) { + this.callbacks[`before${kind}`].forEach((callback) => callback(...args)); } - trackAfter(kind, ...args){ - this.callbacks[`after${kind}`].forEach(callback => callback(...args)) + trackAfter(kind, ...args) { + this.callbacks[`after${kind}`].forEach((callback) => callback(...args)); } - markPrunableContentForRemoval(){ - const phxUpdate = this.liveSocket.binding(PHX_UPDATE) - DOM.all(this.container, `[${phxUpdate}=append] > *, [${phxUpdate}=prepend] > *`, el => { - el.setAttribute(PHX_PRUNE, "") - }) + markPrunableContentForRemoval() { + const phxUpdate = this.liveSocket.binding(PHX_UPDATE); + DOM.all( + this.container, + `[${phxUpdate}=append] > *, [${phxUpdate}=prepend] > *`, + (el) => { + el.setAttribute(PHX_PRUNE, ""); + }, + ); } - perform(isJoinPatch){ - const {view, liveSocket, html, container, targetContainer} = this - if(this.isCIDPatch() && !targetContainer){ return } + perform(isJoinPatch) { + const { view, liveSocket, html, container, targetContainer } = this; + if (this.isCIDPatch() && !targetContainer) { + return; + } - const focused = liveSocket.getActiveElement() - const {selectionStart, selectionEnd} = focused && DOM.hasSelectionRange(focused) ? focused : {} - const phxUpdate = liveSocket.binding(PHX_UPDATE) - const phxViewportTop = liveSocket.binding(PHX_VIEWPORT_TOP) - const phxViewportBottom = liveSocket.binding(PHX_VIEWPORT_BOTTOM) - const phxTriggerExternal = liveSocket.binding(PHX_TRIGGER_ACTION) - const added = [] - const updates = [] - const appendPrependUpdates = [] + const focused = liveSocket.getActiveElement(); + const { selectionStart, selectionEnd } = + focused && DOM.hasSelectionRange(focused) ? focused : {}; + const phxUpdate = liveSocket.binding(PHX_UPDATE); + const phxViewportTop = liveSocket.binding(PHX_VIEWPORT_TOP); + const phxViewportBottom = liveSocket.binding(PHX_VIEWPORT_BOTTOM); + const phxTriggerExternal = liveSocket.binding(PHX_TRIGGER_ACTION); + const added = []; + const updates = []; + const appendPrependUpdates = []; - let externalFormTriggered = null + let externalFormTriggered = null; - function morph(targetContainer, source, withChildren=this.withChildren){ + function morph(targetContainer, source, withChildren = this.withChildren) { const morphCallbacks = { // normally, we are running with childrenOnly, as the patch HTML for a LV // does not include the LV attrs (data-phx-session, etc.) // when we are patching a live component, we do want to patch the root element as well; // another case is the recursive patch of a stream item that was kept on reset (-> onBeforeNodeAdded) - childrenOnly: targetContainer.getAttribute(PHX_COMPONENT) === null && !withChildren, + childrenOnly: + targetContainer.getAttribute(PHX_COMPONENT) === null && !withChildren, getNodeKey: (node) => { - if(DOM.isPhxDestroyed(node)){ return null } + if (DOM.isPhxDestroyed(node)) { + return null; + } // If we have a join patch, then by definition there was no PHX_MAGIC_ID. // This is important to reduce the amount of elements morphdom discards. - if(isJoinPatch){ return node.id } - return node.id || (node.getAttribute && node.getAttribute(PHX_MAGIC_ID)) + if (isJoinPatch) { + return node.id; + } + return ( + node.id || (node.getAttribute && node.getAttribute(PHX_MAGIC_ID)) + ); }, // skip indexing from children when container is stream - skipFromChildren: (from) => { return from.getAttribute(phxUpdate) === PHX_STREAM }, + skipFromChildren: (from) => { + return from.getAttribute(phxUpdate) === PHX_STREAM; + }, // tell morphdom how to add a child addChild: (parent, child) => { - const {ref, streamAt} = this.getStreamInsert(child) - if(ref === undefined){ return parent.appendChild(child) } + const { ref, streamAt } = this.getStreamInsert(child); + if (ref === undefined) { + return parent.appendChild(child); + } - this.setStreamRef(child, ref) + this.setStreamRef(child, ref); // streaming - if(streamAt === 0){ - parent.insertAdjacentElement("afterbegin", child) - } else if(streamAt === -1){ - const lastChild = parent.lastElementChild - if(lastChild && !lastChild.hasAttribute(PHX_STREAM_REF)){ - const nonStreamChild = Array.from(parent.children).find(c => !c.hasAttribute(PHX_STREAM_REF)) - parent.insertBefore(child, nonStreamChild) + if (streamAt === 0) { + parent.insertAdjacentElement("afterbegin", child); + } else if (streamAt === -1) { + const lastChild = parent.lastElementChild; + if (lastChild && !lastChild.hasAttribute(PHX_STREAM_REF)) { + const nonStreamChild = Array.from(parent.children).find( + (c) => !c.hasAttribute(PHX_STREAM_REF), + ); + parent.insertBefore(child, nonStreamChild); } else { - parent.appendChild(child) + parent.appendChild(child); } - } else if(streamAt > 0){ - const sibling = Array.from(parent.children)[streamAt] - parent.insertBefore(child, sibling) + } else if (streamAt > 0) { + const sibling = Array.from(parent.children)[streamAt]; + parent.insertBefore(child, sibling); } }, onBeforeNodeAdded: (el) => { - DOM.maintainPrivateHooks(el, el, phxViewportTop, phxViewportBottom) - this.trackBefore("added", el) + DOM.maintainPrivateHooks(el, el, phxViewportTop, phxViewportBottom); + this.trackBefore("added", el); - let morphedEl = el + let morphedEl = el; // this is a stream item that was kept on reset, recursively morph it - if(this.streamComponentRestore[el.id]){ - morphedEl = this.streamComponentRestore[el.id] - delete this.streamComponentRestore[el.id] - morph.call(this, morphedEl, el, true) + if (this.streamComponentRestore[el.id]) { + morphedEl = this.streamComponentRestore[el.id]; + delete this.streamComponentRestore[el.id]; + morph.call(this, morphedEl, el, true); } - return morphedEl + return morphedEl; }, onNodeAdded: (el) => { - if(el.getAttribute){ this.maybeReOrderStream(el, true) } + if (el.getAttribute) { + this.maybeReOrderStream(el, true); + } // hack to fix Safari handling of img srcset and video tags - if(el instanceof HTMLImageElement && el.srcset){ + if (el instanceof HTMLImageElement && el.srcset) { // eslint-disable-next-line no-self-assign - el.srcset = el.srcset - } else if(el instanceof HTMLVideoElement && el.autoplay){ - el.play() + el.srcset = el.srcset; + } else if (el instanceof HTMLVideoElement && el.autoplay) { + el.play(); } - if(DOM.isNowTriggerFormExternal(el, phxTriggerExternal)){ - externalFormTriggered = el + if (DOM.isNowTriggerFormExternal(el, phxTriggerExternal)) { + externalFormTriggered = el; } // nested view handling - if((DOM.isPhxChild(el) && view.ownsElement(el)) || DOM.isPhxSticky(el) && view.ownsElement(el.parentNode)){ - this.trackAfter("phxChildAdded", el) + if ( + (DOM.isPhxChild(el) && view.ownsElement(el)) || + (DOM.isPhxSticky(el) && view.ownsElement(el.parentNode)) + ) { + this.trackAfter("phxChildAdded", el); } - added.push(el) + added.push(el); }, onNodeDiscarded: (el) => this.onNodeDiscarded(el), onBeforeNodeDiscarded: (el) => { - if(el.getAttribute && el.getAttribute(PHX_PRUNE) !== null){ return true } - if(el.parentElement !== null && el.id && - DOM.isPhxUpdate(el.parentElement, phxUpdate, [PHX_STREAM, "append", "prepend"])){ - return false + if (el.getAttribute && el.getAttribute(PHX_PRUNE) !== null) { + return true; + } + if ( + el.parentElement !== null && + el.id && + DOM.isPhxUpdate(el.parentElement, phxUpdate, [ + PHX_STREAM, + "append", + "prepend", + ]) + ) { + return false; + } + if (this.maybePendingRemove(el)) { + return false; + } + if (this.skipCIDSibling(el)) { + return false; } - if(this.maybePendingRemove(el)){ return false } - if(this.skipCIDSibling(el)){ return false } - return true + return true; }, onElUpdated: (el) => { - if(DOM.isNowTriggerFormExternal(el, phxTriggerExternal)){ - externalFormTriggered = el + if (DOM.isNowTriggerFormExternal(el, phxTriggerExternal)) { + externalFormTriggered = el; } - updates.push(el) - this.maybeReOrderStream(el, false) + updates.push(el); + this.maybeReOrderStream(el, false); }, onBeforeElUpdated: (fromEl, toEl) => { // if we are patching the root target container and the id has changed, treat it as a new node // by replacing the fromEl with the toEl, which ensures hooks are torn down and re-created - if(fromEl.id && fromEl.isSameNode(targetContainer) && fromEl.id !== toEl.id){ - morphCallbacks.onNodeDiscarded(fromEl) - fromEl.replaceWith(toEl) - return morphCallbacks.onNodeAdded(toEl) + if ( + fromEl.id && + fromEl.isSameNode(targetContainer) && + fromEl.id !== toEl.id + ) { + morphCallbacks.onNodeDiscarded(fromEl); + fromEl.replaceWith(toEl); + return morphCallbacks.onNodeAdded(toEl); } - DOM.syncPendingAttrs(fromEl, toEl) - DOM.maintainPrivateHooks(fromEl, toEl, phxViewportTop, phxViewportBottom) - DOM.cleanChildNodes(toEl, phxUpdate) - if(this.skipCIDSibling(toEl)){ + DOM.syncPendingAttrs(fromEl, toEl); + DOM.maintainPrivateHooks( + fromEl, + toEl, + phxViewportTop, + phxViewportBottom, + ); + DOM.cleanChildNodes(toEl, phxUpdate); + if (this.skipCIDSibling(toEl)) { // if this is a live component used in a stream, we may need to reorder it - this.maybeReOrderStream(fromEl) - return false + this.maybeReOrderStream(fromEl); + return false; } - if(DOM.isPhxSticky(fromEl)){ + if (DOM.isPhxSticky(fromEl)) { [PHX_SESSION, PHX_STATIC, PHX_ROOT_ID] - .map(attr => [attr, fromEl.getAttribute(attr), toEl.getAttribute(attr)]) + .map((attr) => [ + attr, + fromEl.getAttribute(attr), + toEl.getAttribute(attr), + ]) .forEach(([attr, fromVal, toVal]) => { - if(toVal && fromVal !== toVal){ fromEl.setAttribute(attr, toVal) } - }) + if (toVal && fromVal !== toVal) { + fromEl.setAttribute(attr, toVal); + } + }); - return false + return false; } - if(DOM.isIgnored(fromEl, phxUpdate) || (fromEl.form && fromEl.form.isSameNode(externalFormTriggered))){ - this.trackBefore("updated", fromEl, toEl) - DOM.mergeAttrs(fromEl, toEl, {isIgnored: DOM.isIgnored(fromEl, phxUpdate)}) - updates.push(fromEl) - DOM.applyStickyOperations(fromEl) - return false + if ( + DOM.isIgnored(fromEl, phxUpdate) || + (fromEl.form && fromEl.form.isSameNode(externalFormTriggered)) + ) { + this.trackBefore("updated", fromEl, toEl); + DOM.mergeAttrs(fromEl, toEl, { + isIgnored: DOM.isIgnored(fromEl, phxUpdate), + }); + updates.push(fromEl); + DOM.applyStickyOperations(fromEl); + return false; + } + if ( + fromEl.type === "number" && + fromEl.validity && + fromEl.validity.badInput + ) { + return false; } - if(fromEl.type === "number" && (fromEl.validity && fromEl.validity.badInput)){ return false } // If the element has PHX_REF_SRC, it is loading or locked and awaiting an ack. // If it's locked, we clone the fromEl tree and instruct morphdom to use // the cloned tree as the source of the morph for this branch from here on out. // We keep a reference to the cloned tree in the element's private data, and // on ack (view.undoRefs), we morph the cloned tree with the true fromEl in the DOM to // apply any changes that happened while the element was locked. - const isFocusedFormEl = focused && fromEl.isSameNode(focused) && DOM.isFormInput(fromEl) - const focusedSelectChanged = isFocusedFormEl && this.isChangedSelect(fromEl, toEl) - if(fromEl.hasAttribute(PHX_REF_SRC)){ - const ref = new ElementRef(fromEl) + const isFocusedFormEl = + focused && fromEl.isSameNode(focused) && DOM.isFormInput(fromEl); + const focusedSelectChanged = + isFocusedFormEl && this.isChangedSelect(fromEl, toEl); + if (fromEl.hasAttribute(PHX_REF_SRC)) { + const ref = new ElementRef(fromEl); // only perform the clone step if this is not a patch that unlocks - if(ref.lockRef && (!this.undoRef || !ref.isLockUndoneBy(this.undoRef))){ - if(DOM.isUploadInput(fromEl)){ - DOM.mergeAttrs(fromEl, toEl, {isIgnored: true}) - this.trackBefore("updated", fromEl, toEl) - updates.push(fromEl) + if ( + ref.lockRef && + (!this.undoRef || !ref.isLockUndoneBy(this.undoRef)) + ) { + if (DOM.isUploadInput(fromEl)) { + DOM.mergeAttrs(fromEl, toEl, { isIgnored: true }); + this.trackBefore("updated", fromEl, toEl); + updates.push(fromEl); } - DOM.applyStickyOperations(fromEl) - const isLocked = fromEl.hasAttribute(PHX_REF_LOCK) - const clone = isLocked ? DOM.private(fromEl, PHX_REF_LOCK) || fromEl.cloneNode(true) : null - if(clone){ - DOM.putPrivate(fromEl, PHX_REF_LOCK, clone) - if(!isFocusedFormEl){ - fromEl = clone + DOM.applyStickyOperations(fromEl); + const isLocked = fromEl.hasAttribute(PHX_REF_LOCK); + const clone = isLocked + ? DOM.private(fromEl, PHX_REF_LOCK) || fromEl.cloneNode(true) + : null; + if (clone) { + DOM.putPrivate(fromEl, PHX_REF_LOCK, clone); + if (!isFocusedFormEl) { + fromEl = clone; } } } } // nested view handling - if(DOM.isPhxChild(toEl)){ - const prevSession = fromEl.getAttribute(PHX_SESSION) - DOM.mergeAttrs(fromEl, toEl, {exclude: [PHX_STATIC]}) - if(prevSession !== ""){ fromEl.setAttribute(PHX_SESSION, prevSession) } - fromEl.setAttribute(PHX_ROOT_ID, this.rootID) - DOM.applyStickyOperations(fromEl) - return false + if (DOM.isPhxChild(toEl)) { + const prevSession = fromEl.getAttribute(PHX_SESSION); + DOM.mergeAttrs(fromEl, toEl, { exclude: [PHX_STATIC] }); + if (prevSession !== "") { + fromEl.setAttribute(PHX_SESSION, prevSession); + } + fromEl.setAttribute(PHX_ROOT_ID, this.rootID); + DOM.applyStickyOperations(fromEl); + return false; } // if we are undoing a lock, copy potentially nested clones over - if(this.undoRef && DOM.private(toEl, PHX_REF_LOCK)){ - DOM.putPrivate(fromEl, PHX_REF_LOCK, DOM.private(toEl, PHX_REF_LOCK)) + if (this.undoRef && DOM.private(toEl, PHX_REF_LOCK)) { + DOM.putPrivate( + fromEl, + PHX_REF_LOCK, + DOM.private(toEl, PHX_REF_LOCK), + ); } // now copy regular DOM.private data - DOM.copyPrivates(toEl, fromEl) + DOM.copyPrivates(toEl, fromEl); // skip patching focused inputs unless focus is a select that has changed options - if(isFocusedFormEl && fromEl.type !== "hidden" && !focusedSelectChanged){ - this.trackBefore("updated", fromEl, toEl) - DOM.mergeFocusedInput(fromEl, toEl) - DOM.syncAttrsToProps(fromEl) - updates.push(fromEl) - DOM.applyStickyOperations(fromEl) - return false + if ( + isFocusedFormEl && + fromEl.type !== "hidden" && + !focusedSelectChanged + ) { + this.trackBefore("updated", fromEl, toEl); + DOM.mergeFocusedInput(fromEl, toEl); + DOM.syncAttrsToProps(fromEl); + updates.push(fromEl); + DOM.applyStickyOperations(fromEl); + return false; } else { // blur focused select if it changed so native UI is updated (ie safari won't update visible options) - if(focusedSelectChanged){ fromEl.blur() } - if(DOM.isPhxUpdate(toEl, phxUpdate, ["append", "prepend"])){ - appendPrependUpdates.push(new DOMPostMorphRestorer(fromEl, toEl, toEl.getAttribute(phxUpdate))) + if (focusedSelectChanged) { + fromEl.blur(); + } + if (DOM.isPhxUpdate(toEl, phxUpdate, ["append", "prepend"])) { + appendPrependUpdates.push( + new DOMPostMorphRestorer( + fromEl, + toEl, + toEl.getAttribute(phxUpdate), + ), + ); } - DOM.syncAttrsToProps(toEl) - DOM.applyStickyOperations(toEl) - this.trackBefore("updated", fromEl, toEl) - return fromEl + DOM.syncAttrsToProps(toEl); + DOM.applyStickyOperations(toEl); + this.trackBefore("updated", fromEl, toEl); + return fromEl; } - } - } - morphdom(targetContainer, source, morphCallbacks) + }, + }; + morphdom(targetContainer, source, morphCallbacks); } - this.trackBefore("added", container) - this.trackBefore("updated", container, container) + this.trackBefore("added", container); + this.trackBefore("updated", container, container); liveSocket.time("morphdom", () => { this.streams.forEach(([ref, inserts, deleteIds, reset]) => { inserts.forEach(([key, streamAt, limit]) => { - this.streamInserts[key] = {ref, streamAt, limit, reset} - }) - if(reset !== undefined){ - DOM.all(container, `[${PHX_STREAM_REF}="${ref}"]`, child => { - this.removeStreamChildElement(child) - }) + this.streamInserts[key] = { ref, streamAt, limit, reset }; + }); + if (reset !== undefined) { + DOM.all(container, `[${PHX_STREAM_REF}="${ref}"]`, (child) => { + this.removeStreamChildElement(child); + }); } - deleteIds.forEach(id => { - const child = container.querySelector(`[id="${id}"]`) - if(child){ this.removeStreamChildElement(child) } - }) - }) + deleteIds.forEach((id) => { + const child = container.querySelector(`[id="${id}"]`); + if (child) { + this.removeStreamChildElement(child); + } + }); + }); // clear stream items from the dead render if they are not inserted again - if(isJoinPatch){ + if (isJoinPatch) { DOM.all(this.container, `[${phxUpdate}=${PHX_STREAM}]`) // it is important to filter the element before removing them, as // it may happen that streams are nested and the owner check fails if // a parent is removed before a child - .filter(el => this.view.ownsElement(el)) - .forEach(el => { - Array.from(el.children).forEach(child => { + .filter((el) => this.view.ownsElement(el)) + .forEach((el) => { + Array.from(el.children).forEach((child) => { // we already performed the owner check, each child is guaranteed to be owned // by the view. To prevent the nested owner check from failing in case of nested // streams where the parent is removed before the child, we force the removal - this.removeStreamChildElement(child, true) - }) - }) + this.removeStreamChildElement(child, true); + }); + }); } - morph.call(this, targetContainer, html) - }) + morph.call(this, targetContainer, html); + }); - if(liveSocket.isDebugEnabled()){ - detectDuplicateIds() - detectInvalidStreamInserts(this.streamInserts) + if (liveSocket.isDebugEnabled()) { + detectDuplicateIds(); + detectInvalidStreamInserts(this.streamInserts); // warn if there are any inputs named "id" - Array.from(document.querySelectorAll("input[name=id]")).forEach(node => { - if(node instanceof HTMLInputElement && node.form){ - console.error("Detected an input with name=\"id\" inside a form! This will cause problems when patching the DOM.\n", node) - } - }) + Array.from(document.querySelectorAll("input[name=id]")).forEach( + (node) => { + if (node instanceof HTMLInputElement && node.form) { + console.error( + 'Detected an input with name="id" inside a form! This will cause problems when patching the DOM.\n', + node, + ); + } + }, + ); } - if(appendPrependUpdates.length > 0){ + if (appendPrependUpdates.length > 0) { liveSocket.time("post-morph append/prepend restoration", () => { - appendPrependUpdates.forEach(update => update.perform()) - }) + appendPrependUpdates.forEach((update) => update.perform()); + }); } - liveSocket.silenceEvents(() => DOM.restoreFocus(focused, selectionStart, selectionEnd)) - DOM.dispatchEvent(document, "phx:update") - added.forEach(el => this.trackAfter("added", el)) - updates.forEach(el => this.trackAfter("updated", el)) + liveSocket.silenceEvents(() => + DOM.restoreFocus(focused, selectionStart, selectionEnd), + ); + DOM.dispatchEvent(document, "phx:update"); + added.forEach((el) => this.trackAfter("added", el)); + updates.forEach((el) => this.trackAfter("updated", el)); - this.transitionPendingRemoves() + this.transitionPendingRemoves(); - if(externalFormTriggered){ - liveSocket.unload() + if (externalFormTriggered) { + liveSocket.unload(); // use prototype's submit in case there's a form control with name or id of "submit" // https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement/submit - Object.getPrototypeOf(externalFormTriggered).submit.call(externalFormTriggered) + Object.getPrototypeOf(externalFormTriggered).submit.call( + externalFormTriggered, + ); } - return true + return true; } - onNodeDiscarded(el){ + onNodeDiscarded(el) { // nested view handling - if(DOM.isPhxChild(el) || DOM.isPhxSticky(el)){ this.liveSocket.destroyViewByEl(el) } - this.trackAfter("discarded", el) + if (DOM.isPhxChild(el) || DOM.isPhxSticky(el)) { + this.liveSocket.destroyViewByEl(el); + } + this.trackAfter("discarded", el); } - maybePendingRemove(node){ - if(node.getAttribute && node.getAttribute(this.phxRemove) !== null){ - this.pendingRemoves.push(node) - return true + maybePendingRemove(node) { + if (node.getAttribute && node.getAttribute(this.phxRemove) !== null) { + this.pendingRemoves.push(node); + return true; } else { - return false + return false; } } - removeStreamChildElement(child, force=false){ + removeStreamChildElement(child, force = false) { // make sure to only remove elements owned by the current view // see https://github.com/phoenixframework/phoenix_live_view/issues/3047 // and https://github.com/phoenixframework/phoenix_live_view/issues/3681 - if(!force && !this.view.ownsElement(child)){ return } + if (!force && !this.view.ownsElement(child)) { + return; + } // we need to store the node if it is actually re-added in the same patch // we do NOT want to execute phx-remove, we do NOT want to call onNodeDiscarded - if(this.streamInserts[child.id]){ - this.streamComponentRestore[child.id] = child - child.remove() + if (this.streamInserts[child.id]) { + this.streamComponentRestore[child.id] = child; + child.remove(); } else { // only remove the element now if it has no phx-remove binding - if(!this.maybePendingRemove(child)){ - child.remove() - this.onNodeDiscarded(child) + if (!this.maybePendingRemove(child)) { + child.remove(); + this.onNodeDiscarded(child); } } } - getStreamInsert(el){ - const insert = el.id ? this.streamInserts[el.id] : {} - return insert || {} + getStreamInsert(el) { + const insert = el.id ? this.streamInserts[el.id] : {}; + return insert || {}; } - setStreamRef(el, ref){ - DOM.putSticky(el, PHX_STREAM_REF, el => el.setAttribute(PHX_STREAM_REF, ref)) + setStreamRef(el, ref) { + DOM.putSticky(el, PHX_STREAM_REF, (el) => + el.setAttribute(PHX_STREAM_REF, ref), + ); } - maybeReOrderStream(el, isNew){ - const {ref, streamAt, reset} = this.getStreamInsert(el) - if(streamAt === undefined){ return } + maybeReOrderStream(el, isNew) { + const { ref, streamAt, reset } = this.getStreamInsert(el); + if (streamAt === undefined) { + return; + } // we need to set the PHX_STREAM_REF here as well as addChild is invoked only for parents - this.setStreamRef(el, ref) + this.setStreamRef(el, ref); - if(!reset && !isNew){ + if (!reset && !isNew) { // we only reorder if the element is new or it's a stream reset - return + return; } // check if the element has a parent element; // it doesn't if we are currently recursively morphing (restoring a saved stream child) // because the element is not yet added to the real dom; // reordering does not make sense in that case anyway - if(!el.parentElement){ return } - - if(streamAt === 0){ - el.parentElement.insertBefore(el, el.parentElement.firstElementChild) - } else if(streamAt > 0){ - const children = Array.from(el.parentElement.children) - const oldIndex = children.indexOf(el) - if(streamAt >= children.length - 1){ - el.parentElement.appendChild(el) + if (!el.parentElement) { + return; + } + + if (streamAt === 0) { + el.parentElement.insertBefore(el, el.parentElement.firstElementChild); + } else if (streamAt > 0) { + const children = Array.from(el.parentElement.children); + const oldIndex = children.indexOf(el); + if (streamAt >= children.length - 1) { + el.parentElement.appendChild(el); } else { - const sibling = children[streamAt] - if(oldIndex > streamAt){ - el.parentElement.insertBefore(el, sibling) + const sibling = children[streamAt]; + if (oldIndex > streamAt) { + el.parentElement.insertBefore(el, sibling); } else { - el.parentElement.insertBefore(el, sibling.nextElementSibling) + el.parentElement.insertBefore(el, sibling.nextElementSibling); } } } - this.maybeLimitStream(el) + this.maybeLimitStream(el); } - maybeLimitStream(el){ - const {limit} = this.getStreamInsert(el) - const children = limit !== null && Array.from(el.parentElement.children) - if(limit && limit < 0 && children.length > limit * -1){ - children.slice(0, children.length + limit).forEach(child => this.removeStreamChildElement(child)) - } else if(limit && limit >= 0 && children.length > limit){ - children.slice(limit).forEach(child => this.removeStreamChildElement(child)) + maybeLimitStream(el) { + const { limit } = this.getStreamInsert(el); + const children = limit !== null && Array.from(el.parentElement.children); + if (limit && limit < 0 && children.length > limit * -1) { + children + .slice(0, children.length + limit) + .forEach((child) => this.removeStreamChildElement(child)); + } else if (limit && limit >= 0 && children.length > limit) { + children + .slice(limit) + .forEach((child) => this.removeStreamChildElement(child)); } } - transitionPendingRemoves(){ - const {pendingRemoves, liveSocket} = this - if(pendingRemoves.length > 0){ + transitionPendingRemoves() { + const { pendingRemoves, liveSocket } = this; + if (pendingRemoves.length > 0) { liveSocket.transitionRemoves(pendingRemoves, () => { - pendingRemoves.forEach(el => { - const child = DOM.firstPhxChild(el) - if(child){ liveSocket.destroyViewByEl(child) } - el.remove() - }) - this.trackAfter("transitionsDiscarded", pendingRemoves) - }) + pendingRemoves.forEach((el) => { + const child = DOM.firstPhxChild(el); + if (child) { + liveSocket.destroyViewByEl(child); + } + el.remove(); + }); + this.trackAfter("transitionsDiscarded", pendingRemoves); + }); } } - isChangedSelect(fromEl, toEl){ - if(!(fromEl instanceof HTMLSelectElement) || fromEl.multiple){ return false } - if(fromEl.options.length !== toEl.options.length){ return true } + isChangedSelect(fromEl, toEl) { + if (!(fromEl instanceof HTMLSelectElement) || fromEl.multiple) { + return false; + } + if (fromEl.options.length !== toEl.options.length) { + return true; + } // keep the current value - toEl.value = fromEl.value + toEl.value = fromEl.value; // in general we have to be very careful with using isEqualNode as it does not a reliable // DOM tree equality check, but for selection attributes and options it works fine - return !fromEl.isEqualNode(toEl) + return !fromEl.isEqualNode(toEl); } - isCIDPatch(){ return this.cidPatch } + isCIDPatch() { + return this.cidPatch; + } - skipCIDSibling(el){ - return el.nodeType === Node.ELEMENT_NODE && el.hasAttribute(PHX_SKIP) + skipCIDSibling(el) { + return el.nodeType === Node.ELEMENT_NODE && el.hasAttribute(PHX_SKIP); } - targetCIDContainer(html){ - if(!this.isCIDPatch()){ return } - const [first, ...rest] = DOM.findComponentNodeList(this.container, this.targetCID) - if(rest.length === 0 && DOM.childNodeLength(html) === 1){ - return first + targetCIDContainer(html) { + if (!this.isCIDPatch()) { + return; + } + const [first, ...rest] = DOM.findComponentNodeList( + this.container, + this.targetCID, + ); + if (rest.length === 0 && DOM.childNodeLength(html) === 1) { + return first; } else { - return first && first.parentNode + return first && first.parentNode; } } - indexOf(parent, child){ return Array.from(parent.children).indexOf(child) } + indexOf(parent, child) { + return Array.from(parent.children).indexOf(child); + } } diff --git a/assets/js/phoenix_live_view/dom_post_morph_restorer.js b/assets/js/phoenix_live_view/dom_post_morph_restorer.js index 2b1d73f075..c7cc7cf0a9 100644 --- a/assets/js/phoenix_live_view/dom_post_morph_restorer.js +++ b/assets/js/phoenix_live_view/dom_post_morph_restorer.js @@ -1,30 +1,35 @@ -import { - maybe -} from "./utils" +import { maybe } from "./utils"; -import DOM from "./dom" +import DOM from "./dom"; export default class DOMPostMorphRestorer { - constructor(containerBefore, containerAfter, updateType){ - const idsBefore = new Set() - const idsAfter = new Set([...containerAfter.children].map(child => child.id)) + constructor(containerBefore, containerAfter, updateType) { + const idsBefore = new Set(); + const idsAfter = new Set( + [...containerAfter.children].map((child) => child.id), + ); - const elementsToModify = [] + const elementsToModify = []; - Array.from(containerBefore.children).forEach(child => { - if(child.id){ // all of our children should be elements with ids - idsBefore.add(child.id) - if(idsAfter.has(child.id)){ - const previousElementId = child.previousElementSibling && child.previousElementSibling.id - elementsToModify.push({elementId: child.id, previousElementId: previousElementId}) + Array.from(containerBefore.children).forEach((child) => { + if (child.id) { + // all of our children should be elements with ids + idsBefore.add(child.id); + if (idsAfter.has(child.id)) { + const previousElementId = + child.previousElementSibling && child.previousElementSibling.id; + elementsToModify.push({ + elementId: child.id, + previousElementId: previousElementId, + }); } } - }) + }); - this.containerId = containerAfter.id - this.updateType = updateType - this.elementsToModify = elementsToModify - this.elementIdsToAdd = [...idsAfter].filter(id => !idsBefore.has(id)) + this.containerId = containerAfter.id; + this.updateType = updateType; + this.elementsToModify = elementsToModify; + this.elementIdsToAdd = [...idsAfter].filter((id) => !idsBefore.has(id)); } // We do the following to optimize append/prepend operations: @@ -33,34 +38,46 @@ export default class DOMPostMorphRestorer { // by storing the id of their previous sibling // 3) New elements are going to be put in the right place by morphdom during append. // For prepend, we move them to the first position in the container - perform(){ - const container = DOM.byId(this.containerId) - if(!container){ return } - this.elementsToModify.forEach(elementToModify => { - if(elementToModify.previousElementId){ - maybe(document.getElementById(elementToModify.previousElementId), previousElem => { - maybe(document.getElementById(elementToModify.elementId), elem => { - const isInRightPlace = elem.previousElementSibling && elem.previousElementSibling.id == previousElem.id - if(!isInRightPlace){ - previousElem.insertAdjacentElement("afterend", elem) - } - }) - }) + perform() { + const container = DOM.byId(this.containerId); + if (!container) { + return; + } + this.elementsToModify.forEach((elementToModify) => { + if (elementToModify.previousElementId) { + maybe( + document.getElementById(elementToModify.previousElementId), + (previousElem) => { + maybe( + document.getElementById(elementToModify.elementId), + (elem) => { + const isInRightPlace = + elem.previousElementSibling && + elem.previousElementSibling.id == previousElem.id; + if (!isInRightPlace) { + previousElem.insertAdjacentElement("afterend", elem); + } + }, + ); + }, + ); } else { // This is the first element in the container - maybe(document.getElementById(elementToModify.elementId), elem => { - const isInRightPlace = elem.previousElementSibling == null - if(!isInRightPlace){ - container.insertAdjacentElement("afterbegin", elem) + maybe(document.getElementById(elementToModify.elementId), (elem) => { + const isInRightPlace = elem.previousElementSibling == null; + if (!isInRightPlace) { + container.insertAdjacentElement("afterbegin", elem); } - }) + }); } - }) + }); - if(this.updateType == "prepend"){ - this.elementIdsToAdd.reverse().forEach(elemId => { - maybe(document.getElementById(elemId), elem => container.insertAdjacentElement("afterbegin", elem)) - }) + if (this.updateType == "prepend") { + this.elementIdsToAdd.reverse().forEach((elemId) => { + maybe(document.getElementById(elemId), (elem) => + container.insertAdjacentElement("afterbegin", elem), + ); + }); } } } diff --git a/assets/js/phoenix_live_view/element_ref.js b/assets/js/phoenix_live_view/element_ref.js index 6e734cfd3d..b179beb5e1 100644 --- a/assets/js/phoenix_live_view/element_ref.js +++ b/assets/js/phoenix_live_view/element_ref.js @@ -5,46 +5,67 @@ import { PHX_EVENT_CLASSES, PHX_DISABLED, PHX_READONLY, - PHX_DISABLE_WITH_RESTORE -} from "./constants" + PHX_DISABLE_WITH_RESTORE, +} from "./constants"; -import DOM from "./dom" +import DOM from "./dom"; export default class ElementRef { - static onUnlock(el, callback){ - if(!DOM.isLocked(el) && !el.closest(`[${PHX_REF_LOCK}]`)){ return callback() } - const closestLock = el.closest(`[${PHX_REF_LOCK}]`) - const ref = closestLock.closest(`[${PHX_REF_LOCK}]`).getAttribute(PHX_REF_LOCK) - closestLock.addEventListener(`phx:undo-lock:${ref}`, () => { - callback() - }, {once: true}) + static onUnlock(el, callback) { + if (!DOM.isLocked(el) && !el.closest(`[${PHX_REF_LOCK}]`)) { + return callback(); + } + const closestLock = el.closest(`[${PHX_REF_LOCK}]`); + const ref = closestLock + .closest(`[${PHX_REF_LOCK}]`) + .getAttribute(PHX_REF_LOCK); + closestLock.addEventListener( + `phx:undo-lock:${ref}`, + () => { + callback(); + }, + { once: true }, + ); } - constructor(el){ - this.el = el - this.loadingRef = el.hasAttribute(PHX_REF_LOADING) ? parseInt(el.getAttribute(PHX_REF_LOADING), 10) : null - this.lockRef = el.hasAttribute(PHX_REF_LOCK) ? parseInt(el.getAttribute(PHX_REF_LOCK), 10) : null + constructor(el) { + this.el = el; + this.loadingRef = el.hasAttribute(PHX_REF_LOADING) + ? parseInt(el.getAttribute(PHX_REF_LOADING), 10) + : null; + this.lockRef = el.hasAttribute(PHX_REF_LOCK) + ? parseInt(el.getAttribute(PHX_REF_LOCK), 10) + : null; } // public - maybeUndo(ref, phxEvent, eachCloneCallback){ - if(!this.isWithin(ref)){ return } + maybeUndo(ref, phxEvent, eachCloneCallback) { + if (!this.isWithin(ref)) { + return; + } // undo locks and apply clones - this.undoLocks(ref, phxEvent, eachCloneCallback) + this.undoLocks(ref, phxEvent, eachCloneCallback); // undo loading states - this.undoLoading(ref, phxEvent) + this.undoLoading(ref, phxEvent); // clean up if fully resolved - if(this.isFullyResolvedBy(ref)){ this.el.removeAttribute(PHX_REF_SRC) } + if (this.isFullyResolvedBy(ref)) { + this.el.removeAttribute(PHX_REF_SRC); + } } // private - isWithin(ref){ - return !((this.loadingRef !== null && this.loadingRef > ref) && (this.lockRef !== null && this.lockRef > ref)) + isWithin(ref) { + return !( + this.loadingRef !== null && + this.loadingRef > ref && + this.lockRef !== null && + this.lockRef > ref + ); } // Check for cloned PHX_REF_LOCK element that has been morphed behind @@ -53,67 +74,93 @@ export default class ElementRef { // // 1. execute pending mounted hooks for nodes now in the DOM // 2. undo any ref inside the cloned tree that has since been ack'd - undoLocks(ref, phxEvent, eachCloneCallback){ - if(!this.isLockUndoneBy(ref)){ return } - - const clonedTree = DOM.private(this.el, PHX_REF_LOCK) - if(clonedTree){ - eachCloneCallback(clonedTree) - DOM.deletePrivate(this.el, PHX_REF_LOCK) + undoLocks(ref, phxEvent, eachCloneCallback) { + if (!this.isLockUndoneBy(ref)) { + return; } - this.el.removeAttribute(PHX_REF_LOCK) - const opts = {detail: {ref: ref, event: phxEvent}, bubbles: true, cancelable: false} - this.el.dispatchEvent(new CustomEvent(`phx:undo-lock:${this.lockRef}`, opts)) + const clonedTree = DOM.private(this.el, PHX_REF_LOCK); + if (clonedTree) { + eachCloneCallback(clonedTree); + DOM.deletePrivate(this.el, PHX_REF_LOCK); + } + this.el.removeAttribute(PHX_REF_LOCK); + + const opts = { + detail: { ref: ref, event: phxEvent }, + bubbles: true, + cancelable: false, + }; + this.el.dispatchEvent( + new CustomEvent(`phx:undo-lock:${this.lockRef}`, opts), + ); } - undoLoading(ref, phxEvent){ - if(!this.isLoadingUndoneBy(ref)){ - if(this.canUndoLoading(ref) && this.el.classList.contains("phx-submit-loading")){ - this.el.classList.remove("phx-change-loading") + undoLoading(ref, phxEvent) { + if (!this.isLoadingUndoneBy(ref)) { + if ( + this.canUndoLoading(ref) && + this.el.classList.contains("phx-submit-loading") + ) { + this.el.classList.remove("phx-change-loading"); } - return + return; } - if(this.canUndoLoading(ref)){ - this.el.removeAttribute(PHX_REF_LOADING) - const disabledVal = this.el.getAttribute(PHX_DISABLED) - const readOnlyVal = this.el.getAttribute(PHX_READONLY) + if (this.canUndoLoading(ref)) { + this.el.removeAttribute(PHX_REF_LOADING); + const disabledVal = this.el.getAttribute(PHX_DISABLED); + const readOnlyVal = this.el.getAttribute(PHX_READONLY); // restore inputs - if(readOnlyVal !== null){ - this.el.readOnly = readOnlyVal === "true" ? true : false - this.el.removeAttribute(PHX_READONLY) + if (readOnlyVal !== null) { + this.el.readOnly = readOnlyVal === "true" ? true : false; + this.el.removeAttribute(PHX_READONLY); } - if(disabledVal !== null){ - this.el.disabled = disabledVal === "true" ? true : false - this.el.removeAttribute(PHX_DISABLED) + if (disabledVal !== null) { + this.el.disabled = disabledVal === "true" ? true : false; + this.el.removeAttribute(PHX_DISABLED); } // restore disables - const disableRestore = this.el.getAttribute(PHX_DISABLE_WITH_RESTORE) - if(disableRestore !== null){ - this.el.innerText = disableRestore - this.el.removeAttribute(PHX_DISABLE_WITH_RESTORE) + const disableRestore = this.el.getAttribute(PHX_DISABLE_WITH_RESTORE); + if (disableRestore !== null) { + this.el.innerText = disableRestore; + this.el.removeAttribute(PHX_DISABLE_WITH_RESTORE); } - const opts = {detail: {ref: ref, event: phxEvent}, bubbles: true, cancelable: false} - this.el.dispatchEvent(new CustomEvent(`phx:undo-loading:${this.loadingRef}`, opts)) + const opts = { + detail: { ref: ref, event: phxEvent }, + bubbles: true, + cancelable: false, + }; + this.el.dispatchEvent( + new CustomEvent(`phx:undo-loading:${this.loadingRef}`, opts), + ); } // remove classes - PHX_EVENT_CLASSES.forEach(name => { - if(name !== "phx-submit-loading" || this.canUndoLoading(ref)){ - DOM.removeClass(this.el, name) + PHX_EVENT_CLASSES.forEach((name) => { + if (name !== "phx-submit-loading" || this.canUndoLoading(ref)) { + DOM.removeClass(this.el, name); } - }) + }); } - isLoadingUndoneBy(ref){ return this.loadingRef === null ? false : this.loadingRef <= ref } - isLockUndoneBy(ref){ return this.lockRef === null ? false : this.lockRef <= ref } + isLoadingUndoneBy(ref) { + return this.loadingRef === null ? false : this.loadingRef <= ref; + } + isLockUndoneBy(ref) { + return this.lockRef === null ? false : this.lockRef <= ref; + } - isFullyResolvedBy(ref){ - return (this.loadingRef === null || this.loadingRef <= ref) && (this.lockRef === null || this.lockRef <= ref) + isFullyResolvedBy(ref) { + return ( + (this.loadingRef === null || this.loadingRef <= ref) && + (this.lockRef === null || this.lockRef <= ref) + ); } // only remove the phx-submit-loading class if we are not locked - canUndoLoading(ref){ return this.lockRef === null || this.lockRef <= ref } + canUndoLoading(ref) { + return this.lockRef === null || this.lockRef <= ref; + } } diff --git a/assets/js/phoenix_live_view/entry_uploader.js b/assets/js/phoenix_live_view/entry_uploader.js index 9e8f9e89d8..efbb5ea443 100644 --- a/assets/js/phoenix_live_view/entry_uploader.js +++ b/assets/js/phoenix_live_view/entry_uploader.js @@ -1,60 +1,74 @@ -import { - logError -} from "./utils" +import { logError } from "./utils"; export default class EntryUploader { - constructor(entry, config, liveSocket){ - const {chunk_size, chunk_timeout} = config - this.liveSocket = liveSocket - this.entry = entry - this.offset = 0 - this.chunkSize = chunk_size - this.chunkTimeout = chunk_timeout - this.chunkTimer = null - this.errored = false - this.uploadChannel = liveSocket.channel(`lvu:${entry.ref}`, {token: entry.metadata()}) + constructor(entry, config, liveSocket) { + const { chunk_size, chunk_timeout } = config; + this.liveSocket = liveSocket; + this.entry = entry; + this.offset = 0; + this.chunkSize = chunk_size; + this.chunkTimeout = chunk_timeout; + this.chunkTimer = null; + this.errored = false; + this.uploadChannel = liveSocket.channel(`lvu:${entry.ref}`, { + token: entry.metadata(), + }); } - error(reason){ - if(this.errored){ return } - this.uploadChannel.leave() - this.errored = true - clearTimeout(this.chunkTimer) - this.entry.error(reason) + error(reason) { + if (this.errored) { + return; + } + this.uploadChannel.leave(); + this.errored = true; + clearTimeout(this.chunkTimer); + this.entry.error(reason); } - upload(){ - this.uploadChannel.onError(reason => this.error(reason)) - this.uploadChannel.join() - .receive("ok", _data => this.readNextChunk()) - .receive("error", reason => this.error(reason)) + upload() { + this.uploadChannel.onError((reason) => this.error(reason)); + this.uploadChannel + .join() + .receive("ok", (_data) => this.readNextChunk()) + .receive("error", (reason) => this.error(reason)); } - isDone(){ return this.offset >= this.entry.file.size } + isDone() { + return this.offset >= this.entry.file.size; + } - readNextChunk(){ - const reader = new window.FileReader() - const blob = this.entry.file.slice(this.offset, this.chunkSize + this.offset) + readNextChunk() { + const reader = new window.FileReader(); + const blob = this.entry.file.slice( + this.offset, + this.chunkSize + this.offset, + ); reader.onload = (e) => { - if(e.target.error === null){ - this.offset += (/** @type {ArrayBuffer} */ (e.target.result)).byteLength - this.pushChunk(/** @type {ArrayBuffer} */ (e.target.result)) + if (e.target.error === null) { + this.offset += /** @type {ArrayBuffer} */ (e.target.result).byteLength; + this.pushChunk(/** @type {ArrayBuffer} */ (e.target.result)); } else { - return logError("Read error: " + e.target.error) + return logError("Read error: " + e.target.error); } - } - reader.readAsArrayBuffer(blob) + }; + reader.readAsArrayBuffer(blob); } - pushChunk(chunk){ - if(!this.uploadChannel.isJoined()){ return } - this.uploadChannel.push("chunk", chunk, this.chunkTimeout) + pushChunk(chunk) { + if (!this.uploadChannel.isJoined()) { + return; + } + this.uploadChannel + .push("chunk", chunk, this.chunkTimeout) .receive("ok", () => { - this.entry.progress((this.offset / this.entry.file.size) * 100) - if(!this.isDone()){ - this.chunkTimer = setTimeout(() => this.readNextChunk(), this.liveSocket.getLatencySim() || 0) + this.entry.progress((this.offset / this.entry.file.size) * 100); + if (!this.isDone()) { + this.chunkTimer = setTimeout( + () => this.readNextChunk(), + this.liveSocket.getLatencySim() || 0, + ); } }) - .receive("error", ({reason}) => this.error(reason)) + .receive("error", ({ reason }) => this.error(reason)); } } diff --git a/assets/js/phoenix_live_view/global.d.ts b/assets/js/phoenix_live_view/global.d.ts index 1d65cce094..6a4a227e8c 100644 --- a/assets/js/phoenix_live_view/global.d.ts +++ b/assets/js/phoenix_live_view/global.d.ts @@ -1 +1 @@ -declare let LV_VSN: string +declare let LV_VSN: string; diff --git a/assets/js/phoenix_live_view/hooks.js b/assets/js/phoenix_live_view/hooks.js index 8922ac109b..4193b431bc 100644 --- a/assets/js/phoenix_live_view/hooks.js +++ b/assets/js/phoenix_live_view/hooks.js @@ -2,243 +2,296 @@ import { PHX_ACTIVE_ENTRY_REFS, PHX_LIVE_FILE_UPDATED, PHX_PREFLIGHTED_REFS, - PHX_UPLOAD_REF -} from "./constants" + PHX_UPLOAD_REF, +} from "./constants"; -import LiveUploader from "./live_uploader" -import ARIA from "./aria" +import LiveUploader from "./live_uploader"; +import ARIA from "./aria"; const Hooks = { LiveFileUpload: { - activeRefs(){ return this.el.getAttribute(PHX_ACTIVE_ENTRY_REFS) }, + activeRefs() { + return this.el.getAttribute(PHX_ACTIVE_ENTRY_REFS); + }, - preflightedRefs(){ return this.el.getAttribute(PHX_PREFLIGHTED_REFS) }, + preflightedRefs() { + return this.el.getAttribute(PHX_PREFLIGHTED_REFS); + }, - mounted(){ this.preflightedWas = this.preflightedRefs() }, + mounted() { + this.preflightedWas = this.preflightedRefs(); + }, - updated(){ - const newPreflights = this.preflightedRefs() - if(this.preflightedWas !== newPreflights){ - this.preflightedWas = newPreflights - if(newPreflights === ""){ - this.__view().cancelSubmit(this.el.form) + updated() { + const newPreflights = this.preflightedRefs(); + if (this.preflightedWas !== newPreflights) { + this.preflightedWas = newPreflights; + if (newPreflights === "") { + this.__view().cancelSubmit(this.el.form); } } - if(this.activeRefs() === ""){ this.el.value = null } - this.el.dispatchEvent(new CustomEvent(PHX_LIVE_FILE_UPDATED)) - } + if (this.activeRefs() === "") { + this.el.value = null; + } + this.el.dispatchEvent(new CustomEvent(PHX_LIVE_FILE_UPDATED)); + }, }, LiveImgPreview: { - mounted(){ - this.ref = this.el.getAttribute("data-phx-entry-ref") - this.inputEl = document.getElementById(this.el.getAttribute(PHX_UPLOAD_REF)) - LiveUploader.getEntryDataURL(this.inputEl, this.ref, url => { - this.url = url - this.el.src = url - }) + mounted() { + this.ref = this.el.getAttribute("data-phx-entry-ref"); + this.inputEl = document.getElementById( + this.el.getAttribute(PHX_UPLOAD_REF), + ); + LiveUploader.getEntryDataURL(this.inputEl, this.ref, (url) => { + this.url = url; + this.el.src = url; + }); + }, + destroyed() { + URL.revokeObjectURL(this.url); }, - destroyed(){ - URL.revokeObjectURL(this.url) - } }, FocusWrap: { - mounted(){ - this.focusStart = this.el.firstElementChild - this.focusEnd = this.el.lastElementChild + mounted() { + this.focusStart = this.el.firstElementChild; + this.focusEnd = this.el.lastElementChild; this.focusStart.addEventListener("focus", (e) => { - if(!e.relatedTarget || !this.el.contains(e.relatedTarget)){ + if (!e.relatedTarget || !this.el.contains(e.relatedTarget)) { // Handle focus entering from outside (e.g. Tab when body is focused) // https://github.com/phoenixframework/phoenix_live_view/issues/3636 - const nextFocus = e.target.nextElementSibling - ARIA.attemptFocus(nextFocus) || ARIA.focusFirst(nextFocus) + const nextFocus = e.target.nextElementSibling; + ARIA.attemptFocus(nextFocus) || ARIA.focusFirst(nextFocus); } else { - ARIA.focusLast(this.el) + ARIA.focusLast(this.el); } - }) + }); this.focusEnd.addEventListener("focus", (e) => { - if(!e.relatedTarget || !this.el.contains(e.relatedTarget)){ + if (!e.relatedTarget || !this.el.contains(e.relatedTarget)) { // Handle focus entering from outside (e.g. Shift+Tab when body is focused) // https://github.com/phoenixframework/phoenix_live_view/issues/3636 - const nextFocus = e.target.previousElementSibling - ARIA.attemptFocus(nextFocus) || ARIA.focusLast(nextFocus) + const nextFocus = e.target.previousElementSibling; + ARIA.attemptFocus(nextFocus) || ARIA.focusLast(nextFocus); } else { - ARIA.focusFirst(this.el) + ARIA.focusFirst(this.el); } - }) + }); // only try to change the focus if it is not already inside - if(!this.el.contains(document.activeElement)){ - this.el.addEventListener("phx:show-end", () => this.el.focus()) - if(window.getComputedStyle(this.el).display !== "none"){ - ARIA.focusFirst(this.el) + if (!this.el.contains(document.activeElement)) { + this.el.addEventListener("phx:show-end", () => this.el.focus()); + if (window.getComputedStyle(this.el).display !== "none") { + ARIA.focusFirst(this.el); } } - } - } -} + }, + }, +}; const findScrollContainer = (el) => { // the scroll event won't be fired on the html/body element even if overflow is set // therefore we return null to instead listen for scroll events on document - if(["HTML", "BODY"].indexOf(el.nodeName.toUpperCase()) >= 0) return null - if(["scroll", "auto"].indexOf(getComputedStyle(el).overflowY) >= 0) return el - return findScrollContainer(el.parentElement) -} + if (["HTML", "BODY"].indexOf(el.nodeName.toUpperCase()) >= 0) return null; + if (["scroll", "auto"].indexOf(getComputedStyle(el).overflowY) >= 0) + return el; + return findScrollContainer(el.parentElement); +}; const scrollTop = (scrollContainer) => { - if(scrollContainer){ - return scrollContainer.scrollTop + if (scrollContainer) { + return scrollContainer.scrollTop; } else { - return document.documentElement.scrollTop || document.body.scrollTop + return document.documentElement.scrollTop || document.body.scrollTop; } -} +}; const bottom = (scrollContainer) => { - if(scrollContainer){ - return scrollContainer.getBoundingClientRect().bottom + if (scrollContainer) { + return scrollContainer.getBoundingClientRect().bottom; } else { // when we have no container, the whole page scrolls, // therefore the bottom coordinate is the viewport height - return window.innerHeight || document.documentElement.clientHeight + return window.innerHeight || document.documentElement.clientHeight; } -} +}; const top = (scrollContainer) => { - if(scrollContainer){ - return scrollContainer.getBoundingClientRect().top + if (scrollContainer) { + return scrollContainer.getBoundingClientRect().top; } else { // when we have no container the whole page scrolls, // therefore the top coordinate is 0 - return 0 + return 0; } -} +}; const isAtViewportTop = (el, scrollContainer) => { - const rect = el.getBoundingClientRect() - return Math.ceil(rect.top) >= top(scrollContainer) && Math.ceil(rect.left) >= 0 && Math.floor(rect.top) <= bottom(scrollContainer) -} + const rect = el.getBoundingClientRect(); + return ( + Math.ceil(rect.top) >= top(scrollContainer) && + Math.ceil(rect.left) >= 0 && + Math.floor(rect.top) <= bottom(scrollContainer) + ); +}; const isAtViewportBottom = (el, scrollContainer) => { - const rect = el.getBoundingClientRect() - return Math.ceil(rect.bottom) >= top(scrollContainer) && Math.ceil(rect.left) >= 0 && Math.floor(rect.bottom) <= bottom(scrollContainer) -} + const rect = el.getBoundingClientRect(); + return ( + Math.ceil(rect.bottom) >= top(scrollContainer) && + Math.ceil(rect.left) >= 0 && + Math.floor(rect.bottom) <= bottom(scrollContainer) + ); +}; const isWithinViewport = (el, scrollContainer) => { - const rect = el.getBoundingClientRect() - return Math.ceil(rect.top) >= top(scrollContainer) && Math.ceil(rect.left) >= 0 && Math.floor(rect.top) <= bottom(scrollContainer) -} + const rect = el.getBoundingClientRect(); + return ( + Math.ceil(rect.top) >= top(scrollContainer) && + Math.ceil(rect.left) >= 0 && + Math.floor(rect.top) <= bottom(scrollContainer) + ); +}; Hooks.InfiniteScroll = { - mounted(){ - this.scrollContainer = findScrollContainer(this.el) - let scrollBefore = scrollTop(this.scrollContainer) - let topOverran = false - const throttleInterval = 500 - let pendingOp = null - - const onTopOverrun = this.throttle(throttleInterval, (topEvent, firstChild) => { - pendingOp = () => true - this.liveSocket.js().push(this.el, topEvent, {value: {id: firstChild.id, _overran: true}, callback: () => { - pendingOp = null - }}) - }) - - const onFirstChildAtTop = this.throttle(throttleInterval, (topEvent, firstChild) => { - pendingOp = () => firstChild.scrollIntoView({block: "start"}) - this.liveSocket.js().push(this.el, topEvent, {value: {id: firstChild.id}, callback: () => { - pendingOp = null - // make sure that the DOM is patched by waiting for the next tick - window.requestAnimationFrame(() => { - if(!isWithinViewport(firstChild, this.scrollContainer)){ - firstChild.scrollIntoView({block: "start"}) - } - }) - }}) - }) - - const onLastChildAtBottom = this.throttle(throttleInterval, (bottomEvent, lastChild) => { - pendingOp = () => lastChild.scrollIntoView({block: "end"}) - this.liveSocket.js().push(this.el, bottomEvent, {value: {id: lastChild.id}, callback: () => { - pendingOp = null - // make sure that the DOM is patched by waiting for the next tick - window.requestAnimationFrame(() => { - if(!isWithinViewport(lastChild, this.scrollContainer)){ - lastChild.scrollIntoView({block: "end"}) - } - }) - }}) - }) + mounted() { + this.scrollContainer = findScrollContainer(this.el); + let scrollBefore = scrollTop(this.scrollContainer); + let topOverran = false; + const throttleInterval = 500; + let pendingOp = null; + + const onTopOverrun = this.throttle( + throttleInterval, + (topEvent, firstChild) => { + pendingOp = () => true; + this.liveSocket.js().push(this.el, topEvent, { + value: { id: firstChild.id, _overran: true }, + callback: () => { + pendingOp = null; + }, + }); + }, + ); + + const onFirstChildAtTop = this.throttle( + throttleInterval, + (topEvent, firstChild) => { + pendingOp = () => firstChild.scrollIntoView({ block: "start" }); + this.liveSocket.js().push(this.el, topEvent, { + value: { id: firstChild.id }, + callback: () => { + pendingOp = null; + // make sure that the DOM is patched by waiting for the next tick + window.requestAnimationFrame(() => { + if (!isWithinViewport(firstChild, this.scrollContainer)) { + firstChild.scrollIntoView({ block: "start" }); + } + }); + }, + }); + }, + ); + + const onLastChildAtBottom = this.throttle( + throttleInterval, + (bottomEvent, lastChild) => { + pendingOp = () => lastChild.scrollIntoView({ block: "end" }); + this.liveSocket.js().push(this.el, bottomEvent, { + value: { id: lastChild.id }, + callback: () => { + pendingOp = null; + // make sure that the DOM is patched by waiting for the next tick + window.requestAnimationFrame(() => { + if (!isWithinViewport(lastChild, this.scrollContainer)) { + lastChild.scrollIntoView({ block: "end" }); + } + }); + }, + }); + }, + ); this.onScroll = (_e) => { - const scrollNow = scrollTop(this.scrollContainer) + const scrollNow = scrollTop(this.scrollContainer); - if(pendingOp){ - scrollBefore = scrollNow - return pendingOp() + if (pendingOp) { + scrollBefore = scrollNow; + return pendingOp(); } - const rect = this.el.getBoundingClientRect() - const topEvent = this.el.getAttribute(this.liveSocket.binding("viewport-top")) - const bottomEvent = this.el.getAttribute(this.liveSocket.binding("viewport-bottom")) - const lastChild = this.el.lastElementChild - const firstChild = this.el.firstElementChild - const isScrollingUp = scrollNow < scrollBefore - const isScrollingDown = scrollNow > scrollBefore + const rect = this.el.getBoundingClientRect(); + const topEvent = this.el.getAttribute( + this.liveSocket.binding("viewport-top"), + ); + const bottomEvent = this.el.getAttribute( + this.liveSocket.binding("viewport-bottom"), + ); + const lastChild = this.el.lastElementChild; + const firstChild = this.el.firstElementChild; + const isScrollingUp = scrollNow < scrollBefore; + const isScrollingDown = scrollNow > scrollBefore; // el overran while scrolling up - if(isScrollingUp && topEvent && !topOverran && rect.top >= 0){ - topOverran = true - onTopOverrun(topEvent, firstChild) - } else if(isScrollingDown && topOverran && rect.top <= 0){ - topOverran = false + if (isScrollingUp && topEvent && !topOverran && rect.top >= 0) { + topOverran = true; + onTopOverrun(topEvent, firstChild); + } else if (isScrollingDown && topOverran && rect.top <= 0) { + topOverran = false; } - if(topEvent && isScrollingUp && isAtViewportTop(firstChild, this.scrollContainer)){ - onFirstChildAtTop(topEvent, firstChild) - } else if(bottomEvent && isScrollingDown && isAtViewportBottom(lastChild, this.scrollContainer)){ - onLastChildAtBottom(bottomEvent, lastChild) + if ( + topEvent && + isScrollingUp && + isAtViewportTop(firstChild, this.scrollContainer) + ) { + onFirstChildAtTop(topEvent, firstChild); + } else if ( + bottomEvent && + isScrollingDown && + isAtViewportBottom(lastChild, this.scrollContainer) + ) { + onLastChildAtBottom(bottomEvent, lastChild); } - scrollBefore = scrollNow - } + scrollBefore = scrollNow; + }; - if(this.scrollContainer){ - this.scrollContainer.addEventListener("scroll", this.onScroll) + if (this.scrollContainer) { + this.scrollContainer.addEventListener("scroll", this.onScroll); } else { - window.addEventListener("scroll", this.onScroll) + window.addEventListener("scroll", this.onScroll); } }, - - destroyed(){ - if(this.scrollContainer){ - this.scrollContainer.removeEventListener("scroll", this.onScroll) + + destroyed() { + if (this.scrollContainer) { + this.scrollContainer.removeEventListener("scroll", this.onScroll); } else { - window.removeEventListener("scroll", this.onScroll) + window.removeEventListener("scroll", this.onScroll); } }, - throttle(interval, callback){ - let lastCallAt = 0 - let timer + throttle(interval, callback) { + let lastCallAt = 0; + let timer; return (...args) => { - const now = Date.now() - const remainingTime = interval - (now - lastCallAt) + const now = Date.now(); + const remainingTime = interval - (now - lastCallAt); - if(remainingTime <= 0 || remainingTime > interval){ - if(timer){ - clearTimeout(timer) - timer = null + if (remainingTime <= 0 || remainingTime > interval) { + if (timer) { + clearTimeout(timer); + timer = null; } - lastCallAt = now - callback(...args) - } else if(!timer){ + lastCallAt = now; + callback(...args); + } else if (!timer) { timer = setTimeout(() => { - lastCallAt = Date.now() - timer = null - callback(...args) - }, remainingTime) + lastCallAt = Date.now(); + timer = null; + callback(...args); + }, remainingTime); } - } - } -} -export default Hooks + }; + }, +}; +export default Hooks; diff --git a/assets/js/phoenix_live_view/index.ts b/assets/js/phoenix_live_view/index.ts index 9ca48c860f..b97b28f280 100644 --- a/assets/js/phoenix_live_view/index.ts +++ b/assets/js/phoenix_live_view/index.ts @@ -6,14 +6,14 @@ Phoenix LiveView JavaScript Client See the hexdocs at `https://hexdocs.pm/phoenix_live_view` for documentation. */ -import OriginalLiveSocket, {isUsedInput} from "./live_socket" -import DOM from "./dom" -import {ViewHook} from "./view_hook" -import View from "./view" +import OriginalLiveSocket, { isUsedInput } from "./live_socket"; +import DOM from "./dom"; +import { ViewHook } from "./view_hook"; +import View from "./view"; -import type {LiveSocketJSCommands} from "./js_commands" -import type {Hook, HooksOptions} from "./view_hook" -import type {Socket as PhoenixSocket} from "phoenix" +import type { LiveSocketJSCommands } from "./js_commands"; +import type { Hook, HooksOptions } from "./view_hook"; +import type { Socket as PhoenixSocket } from "phoenix"; /** * Options for configuring the LiveSocket instance. @@ -35,21 +35,23 @@ export interface LiveSocketOptions { * (el) => {view: el.getAttribute("data-my-view-name", token: window.myToken} * */ - params?: ((el: HTMLElement) => {[key: string]: any}) | {[key: string]: any}; + params?: + | ((el: HTMLElement) => { [key: string]: any }) + | { [key: string]: any }; /** * The optional prefix to use for all phx DOM annotations. - * + * * Defaults to "phx-". */ bindingPrefix?: string; - /** + /** * Callbacks for LiveView hooks. - * + * * See [Client hooks via `phx-hook`](https://hexdocs.pm/phoenix_live_view/js-interop.html#client-hooks-via-phx-hook) for more information. */ hooks?: HooksOptions; /** Callbacks for LiveView uploaders. */ - uploaders?: {[key: string]: any}; // TODO: define more specifically + uploaders?: { [key: string]: any }; // TODO: define more specifically /** Delay in milliseconds before applying loading states. */ loaderTimeout?: number; /** Delay in milliseconds before executing phx-disconnected commands. */ @@ -64,13 +66,13 @@ export interface LiveSocketOptions { failsafeJitter?: number; /** * Function to log debug information. For example: - * + * * (view, kind, msg, obj) => console.log(`${view.id} ${kind}: ${msg} - `, obj) */ viewLogger?: (view: View, kind: string, msg: string, obj: any) => void; /** - * Object mapping event names to functions for populating event metadata. - * + * Object mapping event names to functions for populating event metadata. + * * metadata: { * click: (e, el) => { * return { @@ -88,16 +90,16 @@ export interface LiveSocketOptions { * } * } * } - * + * */ - metadata?: {[eventName: string]: (e: Event, el: HTMLElement) => object}; + metadata?: { [eventName: string]: (e: Event, el: HTMLElement) => object }; /** * An optional Storage compatible object * Useful when LiveView won't have access to `sessionStorage`. For example, This could - * happen if a site loads a cross-domain LiveView in an iframe. - * + * happen if a site loads a cross-domain LiveView in an iframe. + * * Example usage: - * + * * class InMemoryStorage { * constructor() { this.storage = {} } * getItem(keyName) { return this.storage[keyName] || null } @@ -109,7 +111,7 @@ export interface LiveSocketOptions { /** * An optional Storage compatible object * Useful when LiveView won't have access to `localStorage`. - * + * * See `sessionStorage` for an example. */ localStorage?: Storage; @@ -122,7 +124,11 @@ export interface LiveSocketOptions { * @param defaultQuery - A default query function that can be used if no custom query should be applied. * @returns A list of DOM elements. */ - jsQuerySelectorAll?: (sourceEl: HTMLElement, query: string, defaultQuery: () => Element[]) => Element[]; + jsQuerySelectorAll?: ( + sourceEl: HTMLElement, + query: string, + defaultQuery: () => Element[], + ) => Element[]; /** * Called immediately before a DOM patch is applied. */ @@ -166,14 +172,14 @@ export interface LiveSocketInstanceInterface { isDebugDisabled(): boolean; /** * Enables debugging. - * + * * When debugging is enabled, the LiveView client will log debug information to the console. * See [Debugging client events](https://hexdocs.pm/phoenix_live_view/js-interop.html#debugging-client-events) for more information. */ enableDebug(): void; /** * Enables profiling. - * + * * When profiling is enabled, the LiveView client will log profiling information to the console. */ enableProfiling(): void; @@ -187,7 +193,7 @@ export interface LiveSocketInstanceInterface { disableProfiling(): void; /** * Enables latency simulation. - * + * * When latency simulation is enabled, the LiveView client will add a delay to requests and responses from the server. * See [Simulating Latency](https://hexdocs.pm/phoenix_live_view/js-interop.html#simulating-latency) for more information. */ @@ -218,14 +224,14 @@ export interface LiveSocketInstanceInterface { replaceTransport(transport: any): void; /** * Executes an encoded JS command, targeting the given element. - * + * * See [`Phoenix.LiveView.JS`](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.JS.html) for more information. */ execJS(el: HTMLElement, encodedJS: string, eventType?: string | null): void; /** * Returns an object with methods to manipluate the DOM and execute JavaScript. * The applied changes integrate with server DOM patching. - * + * * See [JavaScript interoperability](https://hexdocs.pm/phoenix_live_view/js-interop.html) for more information. */ js(): LiveSocketJSCommands; @@ -237,7 +243,7 @@ export interface LiveSocketInstanceInterface { export interface LiveSocketConstructor { /** * Creates a new LiveSocket instance. - * + * * @param endpoint - The string WebSocket endpoint, ie, `"wss://example.com/live"`, * `"/live"` (inherited host & protocol) * @param socket - the required Phoenix Socket class imported from "phoenix". For example: @@ -248,18 +254,22 @@ export interface LiveSocketConstructor { * * @param opts - Optional configuration. */ - new (endpoint: string, socket: typeof PhoenixSocket, opts?: LiveSocketOptions): LiveSocketInstanceInterface; + new ( + endpoint: string, + socket: typeof PhoenixSocket, + opts?: LiveSocketOptions, + ): LiveSocketInstanceInterface; } // because LiveSocket is in JS (for now), we cast it to our defined TypeScript constructor. -const LiveSocket = OriginalLiveSocket as unknown as LiveSocketConstructor +const LiveSocket = OriginalLiveSocket as unknown as LiveSocketConstructor; /** Creates a hook instance for the given element and callbacks. * * @param el - The element to associate with the hook. * @param callbacks - The list of hook callbacks, such as mounted, * updated, destroyed, etc. - * + * * *Note*: `createHook` must be called from the `connectedCallback` lifecycle * which is triggered after the element has been added to the DOM. If you try * to call `createHook` from the constructor, an error will be logged. @@ -275,20 +285,15 @@ const LiveSocket = OriginalLiveSocket as unknown as LiveSocketConstructor * * @returns Returns the Hook instance for the custom element. */ -function createHook(el: HTMLElement, callbacks: Hook): ViewHook{ - let existingHook = DOM.getCustomElHook(el) - if(existingHook){ return existingHook } +function createHook(el: HTMLElement, callbacks: Hook): ViewHook { + let existingHook = DOM.getCustomElHook(el); + if (existingHook) { + return existingHook; + } - let hook = new ViewHook(View.closestView(el), el, callbacks) - DOM.putCustomElHook(el, hook) - return hook + let hook = new ViewHook(View.closestView(el), el, callbacks); + DOM.putCustomElHook(el, hook); + return hook; } -export { - LiveSocket, - isUsedInput, - createHook, - ViewHook, - Hook, - HooksOptions -} +export { LiveSocket, isUsedInput, createHook, ViewHook, Hook, HooksOptions }; diff --git a/assets/js/phoenix_live_view/js.js b/assets/js/phoenix_live_view/js.js index 246435c80b..0410091e58 100644 --- a/assets/js/phoenix_live_view/js.js +++ b/assets/js/phoenix_live_view/js.js @@ -1,390 +1,637 @@ -import DOM from "./dom" -import ARIA from "./aria" +import DOM from "./dom"; +import ARIA from "./aria"; -const focusStack = [] -const default_transition_time = 200 +const focusStack = []; +const default_transition_time = 200; const JS = { // private - exec(e, eventType, phxEvent, view, sourceEl, defaults){ - const [defaultKind, defaultArgs] = defaults || [null, {callback: defaults && defaults.callback}] - const commands = phxEvent.charAt(0) === "[" ? - JSON.parse(phxEvent) : [[defaultKind, defaultArgs]] + exec(e, eventType, phxEvent, view, sourceEl, defaults) { + const [defaultKind, defaultArgs] = defaults || [ + null, + { callback: defaults && defaults.callback }, + ]; + const commands = + phxEvent.charAt(0) === "[" + ? JSON.parse(phxEvent) + : [[defaultKind, defaultArgs]]; commands.forEach(([kind, args]) => { - if(kind === defaultKind){ + if (kind === defaultKind) { // always prefer the args, but keep existing keys from the defaultArgs - args = {...defaultArgs, ...args} - args.callback = args.callback || defaultArgs.callback + args = { ...defaultArgs, ...args }; + args.callback = args.callback || defaultArgs.callback; } - this.filterToEls(view.liveSocket, sourceEl, args).forEach(el => { - this[`exec_${kind}`](e, eventType, phxEvent, view, sourceEl, el, args) - }) - }) + this.filterToEls(view.liveSocket, sourceEl, args).forEach((el) => { + this[`exec_${kind}`](e, eventType, phxEvent, view, sourceEl, el, args); + }); + }); }, - isVisible(el){ - return !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length > 0) + isVisible(el) { + return !!( + el.offsetWidth || + el.offsetHeight || + el.getClientRects().length > 0 + ); }, // returns true if any part of the element is inside the viewport - isInViewport(el){ - const rect = el.getBoundingClientRect() - const windowHeight = window.innerHeight || document.documentElement.clientHeight - const windowWidth = window.innerWidth || document.documentElement.clientWidth + isInViewport(el) { + const rect = el.getBoundingClientRect(); + const windowHeight = + window.innerHeight || document.documentElement.clientHeight; + const windowWidth = + window.innerWidth || document.documentElement.clientWidth; return ( rect.right > 0 && rect.bottom > 0 && rect.left < windowWidth && rect.top < windowHeight - ) + ); }, // private // commands - exec_exec(e, eventType, phxEvent, view, sourceEl, el, {attr, to}){ - const encodedJS = el.getAttribute(attr) - if(!encodedJS){ throw new Error(`expected ${attr} to contain JS command on "${to}"`) } - view.liveSocket.execJS(el, encodedJS, eventType) - }, - - exec_dispatch(e, eventType, phxEvent, view, sourceEl, el, {event, detail, bubbles}){ - detail = detail || {} - detail.dispatcher = sourceEl - DOM.dispatchEvent(el, event, {detail, bubbles}) - }, - - exec_push(e, eventType, phxEvent, view, sourceEl, el, args){ - const {event, data, target, page_loading, loading, value, dispatcher, callback} = args - const pushOpts = {loading, value, target, page_loading: !!page_loading} - const targetSrc = eventType === "change" && dispatcher ? dispatcher : sourceEl - const phxTarget = target || targetSrc.getAttribute(view.binding("target")) || targetSrc + exec_exec(e, eventType, phxEvent, view, sourceEl, el, { attr, to }) { + const encodedJS = el.getAttribute(attr); + if (!encodedJS) { + throw new Error(`expected ${attr} to contain JS command on "${to}"`); + } + view.liveSocket.execJS(el, encodedJS, eventType); + }, + + exec_dispatch( + e, + eventType, + phxEvent, + view, + sourceEl, + el, + { event, detail, bubbles }, + ) { + detail = detail || {}; + detail.dispatcher = sourceEl; + DOM.dispatchEvent(el, event, { detail, bubbles }); + }, + + exec_push(e, eventType, phxEvent, view, sourceEl, el, args) { + const { + event, + data, + target, + page_loading, + loading, + value, + dispatcher, + callback, + } = args; + const pushOpts = { loading, value, target, page_loading: !!page_loading }; + const targetSrc = + eventType === "change" && dispatcher ? dispatcher : sourceEl; + const phxTarget = + target || targetSrc.getAttribute(view.binding("target")) || targetSrc; const handler = (targetView, targetCtx) => { - if(!targetView.isConnected()){ return } - if(eventType === "change"){ - let {newCid, _target} = args - _target = _target || (DOM.isFormInput(sourceEl) ? sourceEl.name : undefined) - if(_target){ pushOpts._target = _target } - targetView.pushInput(sourceEl, targetCtx, newCid, event || phxEvent, pushOpts, callback) - } else if(eventType === "submit"){ - const {submitter} = args - targetView.submitForm(sourceEl, targetCtx, event || phxEvent, submitter, pushOpts, callback) + if (!targetView.isConnected()) { + return; + } + if (eventType === "change") { + let { newCid, _target } = args; + _target = + _target || (DOM.isFormInput(sourceEl) ? sourceEl.name : undefined); + if (_target) { + pushOpts._target = _target; + } + targetView.pushInput( + sourceEl, + targetCtx, + newCid, + event || phxEvent, + pushOpts, + callback, + ); + } else if (eventType === "submit") { + const { submitter } = args; + targetView.submitForm( + sourceEl, + targetCtx, + event || phxEvent, + submitter, + pushOpts, + callback, + ); } else { - targetView.pushEvent(eventType, sourceEl, targetCtx, event || phxEvent, data, pushOpts, callback) + targetView.pushEvent( + eventType, + sourceEl, + targetCtx, + event || phxEvent, + data, + pushOpts, + callback, + ); } - } + }; // in case of formRecovery, targetView and targetCtx are passed as argument // as they are looked up in a template element, not the real DOM - if(args.targetView && args.targetCtx){ - handler(args.targetView, args.targetCtx) + if (args.targetView && args.targetCtx) { + handler(args.targetView, args.targetCtx); } else { - view.withinTargets(phxTarget, handler) + view.withinTargets(phxTarget, handler); } }, - exec_navigate(e, eventType, phxEvent, view, sourceEl, el, {href, replace}){ - view.liveSocket.historyRedirect(e, href, replace ? "replace" : "push", null, sourceEl) + exec_navigate(e, eventType, phxEvent, view, sourceEl, el, { href, replace }) { + view.liveSocket.historyRedirect( + e, + href, + replace ? "replace" : "push", + null, + sourceEl, + ); }, - exec_patch(e, eventType, phxEvent, view, sourceEl, el, {href, replace}){ - view.liveSocket.pushHistoryPatch(e, href, replace ? "replace" : "push", sourceEl) + exec_patch(e, eventType, phxEvent, view, sourceEl, el, { href, replace }) { + view.liveSocket.pushHistoryPatch( + e, + href, + replace ? "replace" : "push", + sourceEl, + ); }, - exec_focus(e, eventType, phxEvent, view, sourceEl, el){ - ARIA.attemptFocus(el) + exec_focus(e, eventType, phxEvent, view, sourceEl, el) { + ARIA.attemptFocus(el); // in case the JS.focus command is in a JS.show/hide/toggle chain, for show we need // to wait for JS.show to have updated the element's display property (see exec_toggle) // but that run in nested animation frames, therefore we need to use them here as well window.requestAnimationFrame(() => { - window.requestAnimationFrame(() => ARIA.attemptFocus(el)) - }) + window.requestAnimationFrame(() => ARIA.attemptFocus(el)); + }); }, - exec_focus_first(e, eventType, phxEvent, view, sourceEl, el){ - ARIA.focusFirstInteractive(el) || ARIA.focusFirst(el) + exec_focus_first(e, eventType, phxEvent, view, sourceEl, el) { + ARIA.focusFirstInteractive(el) || ARIA.focusFirst(el); // if you wonder about the nested animation frames, see exec_focus window.requestAnimationFrame(() => { - window.requestAnimationFrame(() => ARIA.focusFirstInteractive(el) || ARIA.focusFirst(el)) - }) + window.requestAnimationFrame( + () => ARIA.focusFirstInteractive(el) || ARIA.focusFirst(el), + ); + }); }, - exec_push_focus(e, eventType, phxEvent, view, sourceEl, el){ - focusStack.push(el || sourceEl) + exec_push_focus(e, eventType, phxEvent, view, sourceEl, el) { + focusStack.push(el || sourceEl); }, - exec_pop_focus(_e, _eventType, _phxEvent, _view, _sourceEl, _el){ - const el = focusStack.pop() - if(el){ - el.focus() + exec_pop_focus(_e, _eventType, _phxEvent, _view, _sourceEl, _el) { + const el = focusStack.pop(); + if (el) { + el.focus(); // if you wonder about the nested animation frames, see exec_focus window.requestAnimationFrame(() => { - window.requestAnimationFrame(() => el.focus()) - }) + window.requestAnimationFrame(() => el.focus()); + }); } }, - exec_add_class(e, eventType, phxEvent, view, sourceEl, el, {names, transition, time, blocking}){ - this.addOrRemoveClasses(el, names, [], transition, time, view, blocking) - }, - - exec_remove_class(e, eventType, phxEvent, view, sourceEl, el, {names, transition, time, blocking}){ - this.addOrRemoveClasses(el, [], names, transition, time, view, blocking) - }, - - exec_toggle_class(e, eventType, phxEvent, view, sourceEl, el, {names, transition, time, blocking}){ - this.toggleClasses(el, names, transition, time, view, blocking) - }, - - exec_toggle_attr(e, eventType, phxEvent, view, sourceEl, el, {attr: [attr, val1, val2]}){ - this.toggleAttr(el, attr, val1, val2) - }, - - exec_ignore_attrs(e, eventType, phxEvent, view, sourceEl, el, {attrs}){ - this.ignoreAttrs(el, attrs) - }, - - exec_transition(e, eventType, phxEvent, view, sourceEl, el, {time, transition, blocking}){ - this.addOrRemoveClasses(el, [], [], transition, time, view, blocking) - }, - - exec_toggle(e, eventType, phxEvent, view, sourceEl, el, {display, ins, outs, time, blocking}){ - this.toggle(eventType, view, el, display, ins, outs, time, blocking) - }, - - exec_show(e, eventType, phxEvent, view, sourceEl, el, {display, transition, time, blocking}){ - this.show(eventType, view, el, display, transition, time, blocking) - }, - - exec_hide(e, eventType, phxEvent, view, sourceEl, el, {display, transition, time, blocking}){ - this.hide(eventType, view, el, display, transition, time, blocking) - }, - - exec_set_attr(e, eventType, phxEvent, view, sourceEl, el, {attr: [attr, val]}){ - this.setOrRemoveAttrs(el, [[attr, val]], []) - }, - - exec_remove_attr(e, eventType, phxEvent, view, sourceEl, el, {attr}){ - this.setOrRemoveAttrs(el, [], [attr]) - }, - - ignoreAttrs(el, attrs){ - DOM.putPrivate(el, "JS:ignore_attrs", {apply: (fromEl, toEl) => { - Array.from(fromEl.attributes).forEach(attr => { - if(attrs.some(toIgnore => attr.name == toIgnore || toIgnore.includes("*") && attr.name.match(toIgnore) != null)){ - toEl.setAttribute(attr.name, attr.value) - } - }) - }}) - }, - - onBeforeElUpdated(fromEl, toEl){ - const ignoreAttrs = DOM.private(fromEl, "JS:ignore_attrs") - if(ignoreAttrs){ - ignoreAttrs.apply(fromEl, toEl) + exec_add_class( + e, + eventType, + phxEvent, + view, + sourceEl, + el, + { names, transition, time, blocking }, + ) { + this.addOrRemoveClasses(el, names, [], transition, time, view, blocking); + }, + + exec_remove_class( + e, + eventType, + phxEvent, + view, + sourceEl, + el, + { names, transition, time, blocking }, + ) { + this.addOrRemoveClasses(el, [], names, transition, time, view, blocking); + }, + + exec_toggle_class( + e, + eventType, + phxEvent, + view, + sourceEl, + el, + { names, transition, time, blocking }, + ) { + this.toggleClasses(el, names, transition, time, view, blocking); + }, + + exec_toggle_attr( + e, + eventType, + phxEvent, + view, + sourceEl, + el, + { attr: [attr, val1, val2] }, + ) { + this.toggleAttr(el, attr, val1, val2); + }, + + exec_ignore_attrs(e, eventType, phxEvent, view, sourceEl, el, { attrs }) { + this.ignoreAttrs(el, attrs); + }, + + exec_transition( + e, + eventType, + phxEvent, + view, + sourceEl, + el, + { time, transition, blocking }, + ) { + this.addOrRemoveClasses(el, [], [], transition, time, view, blocking); + }, + + exec_toggle( + e, + eventType, + phxEvent, + view, + sourceEl, + el, + { display, ins, outs, time, blocking }, + ) { + this.toggle(eventType, view, el, display, ins, outs, time, blocking); + }, + + exec_show( + e, + eventType, + phxEvent, + view, + sourceEl, + el, + { display, transition, time, blocking }, + ) { + this.show(eventType, view, el, display, transition, time, blocking); + }, + + exec_hide( + e, + eventType, + phxEvent, + view, + sourceEl, + el, + { display, transition, time, blocking }, + ) { + this.hide(eventType, view, el, display, transition, time, blocking); + }, + + exec_set_attr( + e, + eventType, + phxEvent, + view, + sourceEl, + el, + { attr: [attr, val] }, + ) { + this.setOrRemoveAttrs(el, [[attr, val]], []); + }, + + exec_remove_attr(e, eventType, phxEvent, view, sourceEl, el, { attr }) { + this.setOrRemoveAttrs(el, [], [attr]); + }, + + ignoreAttrs(el, attrs) { + DOM.putPrivate(el, "JS:ignore_attrs", { + apply: (fromEl, toEl) => { + Array.from(fromEl.attributes).forEach((attr) => { + if ( + attrs.some( + (toIgnore) => + attr.name == toIgnore || + (toIgnore.includes("*") && attr.name.match(toIgnore) != null), + ) + ) { + toEl.setAttribute(attr.name, attr.value); + } + }); + }, + }); + }, + + onBeforeElUpdated(fromEl, toEl) { + const ignoreAttrs = DOM.private(fromEl, "JS:ignore_attrs"); + if (ignoreAttrs) { + ignoreAttrs.apply(fromEl, toEl); } }, // utils for commands - show(eventType, view, el, display, transition, time, blocking){ - if(!this.isVisible(el)){ - this.toggle(eventType, view, el, display, transition, null, time, blocking) + show(eventType, view, el, display, transition, time, blocking) { + if (!this.isVisible(el)) { + this.toggle( + eventType, + view, + el, + display, + transition, + null, + time, + blocking, + ); } }, - hide(eventType, view, el, display, transition, time, blocking){ - if(this.isVisible(el)){ - this.toggle(eventType, view, el, display, null, transition, time, blocking) + hide(eventType, view, el, display, transition, time, blocking) { + if (this.isVisible(el)) { + this.toggle( + eventType, + view, + el, + display, + null, + transition, + time, + blocking, + ); } }, - toggle(eventType, view, el, display, ins, outs, time, blocking){ - time = time || default_transition_time - const [inClasses, inStartClasses, inEndClasses] = ins || [[], [], []] - const [outClasses, outStartClasses, outEndClasses] = outs || [[], [], []] - if(inClasses.length > 0 || outClasses.length > 0){ - if(this.isVisible(el)){ + toggle(eventType, view, el, display, ins, outs, time, blocking) { + time = time || default_transition_time; + const [inClasses, inStartClasses, inEndClasses] = ins || [[], [], []]; + const [outClasses, outStartClasses, outEndClasses] = outs || [[], [], []]; + if (inClasses.length > 0 || outClasses.length > 0) { + if (this.isVisible(el)) { const onStart = () => { - this.addOrRemoveClasses(el, outStartClasses, inClasses.concat(inStartClasses).concat(inEndClasses)) + this.addOrRemoveClasses( + el, + outStartClasses, + inClasses.concat(inStartClasses).concat(inEndClasses), + ); window.requestAnimationFrame(() => { - this.addOrRemoveClasses(el, outClasses, []) - window.requestAnimationFrame(() => this.addOrRemoveClasses(el, outEndClasses, outStartClasses)) - }) - } + this.addOrRemoveClasses(el, outClasses, []); + window.requestAnimationFrame(() => + this.addOrRemoveClasses(el, outEndClasses, outStartClasses), + ); + }); + }; const onEnd = () => { - this.addOrRemoveClasses(el, [], outClasses.concat(outEndClasses)) - DOM.putSticky(el, "toggle", currentEl => currentEl.style.display = "none") - el.dispatchEvent(new Event("phx:hide-end")) - } - el.dispatchEvent(new Event("phx:hide-start")) - if(blocking === false){ - onStart() - setTimeout(onEnd, time) + this.addOrRemoveClasses(el, [], outClasses.concat(outEndClasses)); + DOM.putSticky( + el, + "toggle", + (currentEl) => (currentEl.style.display = "none"), + ); + el.dispatchEvent(new Event("phx:hide-end")); + }; + el.dispatchEvent(new Event("phx:hide-start")); + if (blocking === false) { + onStart(); + setTimeout(onEnd, time); } else { - view.transition(time, onStart, onEnd) + view.transition(time, onStart, onEnd); } } else { - if(eventType === "remove"){ return } + if (eventType === "remove") { + return; + } const onStart = () => { - this.addOrRemoveClasses(el, inStartClasses, outClasses.concat(outStartClasses).concat(outEndClasses)) - const stickyDisplay = display || this.defaultDisplay(el) + this.addOrRemoveClasses( + el, + inStartClasses, + outClasses.concat(outStartClasses).concat(outEndClasses), + ); + const stickyDisplay = display || this.defaultDisplay(el); window.requestAnimationFrame(() => { // first add the starting + active class, THEN make the element visible // otherwise if we toggled the visibility earlier css animations // would flicker, as the element becomes visible before the active animation // class is set (see https://github.com/phoenixframework/phoenix_live_view/issues/3456) - this.addOrRemoveClasses(el, inClasses, []) + this.addOrRemoveClasses(el, inClasses, []); // addOrRemoveClasses uses a requestAnimationFrame itself, therefore we need to move the putSticky // into the next requestAnimationFrame... window.requestAnimationFrame(() => { - DOM.putSticky(el, "toggle", currentEl => currentEl.style.display = stickyDisplay) - this.addOrRemoveClasses(el, inEndClasses, inStartClasses) - }) - }) - } + DOM.putSticky( + el, + "toggle", + (currentEl) => (currentEl.style.display = stickyDisplay), + ); + this.addOrRemoveClasses(el, inEndClasses, inStartClasses); + }); + }); + }; const onEnd = () => { - this.addOrRemoveClasses(el, [], inClasses.concat(inEndClasses)) - el.dispatchEvent(new Event("phx:show-end")) - } - el.dispatchEvent(new Event("phx:show-start")) - if(blocking === false){ - onStart() - setTimeout(onEnd, time) + this.addOrRemoveClasses(el, [], inClasses.concat(inEndClasses)); + el.dispatchEvent(new Event("phx:show-end")); + }; + el.dispatchEvent(new Event("phx:show-start")); + if (blocking === false) { + onStart(); + setTimeout(onEnd, time); } else { - view.transition(time, onStart, onEnd) + view.transition(time, onStart, onEnd); } } } else { - if(this.isVisible(el)){ + if (this.isVisible(el)) { window.requestAnimationFrame(() => { - el.dispatchEvent(new Event("phx:hide-start")) - DOM.putSticky(el, "toggle", currentEl => currentEl.style.display = "none") - el.dispatchEvent(new Event("phx:hide-end")) - }) + el.dispatchEvent(new Event("phx:hide-start")); + DOM.putSticky( + el, + "toggle", + (currentEl) => (currentEl.style.display = "none"), + ); + el.dispatchEvent(new Event("phx:hide-end")); + }); } else { window.requestAnimationFrame(() => { - el.dispatchEvent(new Event("phx:show-start")) - const stickyDisplay = display || this.defaultDisplay(el) - DOM.putSticky(el, "toggle", currentEl => currentEl.style.display = stickyDisplay) - el.dispatchEvent(new Event("phx:show-end")) - }) + el.dispatchEvent(new Event("phx:show-start")); + const stickyDisplay = display || this.defaultDisplay(el); + DOM.putSticky( + el, + "toggle", + (currentEl) => (currentEl.style.display = stickyDisplay), + ); + el.dispatchEvent(new Event("phx:show-end")); + }); } } }, - toggleClasses(el, classes, transition, time, view, blocking){ + toggleClasses(el, classes, transition, time, view, blocking) { window.requestAnimationFrame(() => { - const [prevAdds, prevRemoves] = DOM.getSticky(el, "classes", [[], []]) - const newAdds = classes.filter(name => prevAdds.indexOf(name) < 0 && !el.classList.contains(name)) - const newRemoves = classes.filter(name => prevRemoves.indexOf(name) < 0 && el.classList.contains(name)) - this.addOrRemoveClasses(el, newAdds, newRemoves, transition, time, view, blocking) - }) - }, - - toggleAttr(el, attr, val1, val2){ - if(el.hasAttribute(attr)){ - if(val2 !== undefined){ + const [prevAdds, prevRemoves] = DOM.getSticky(el, "classes", [[], []]); + const newAdds = classes.filter( + (name) => prevAdds.indexOf(name) < 0 && !el.classList.contains(name), + ); + const newRemoves = classes.filter( + (name) => prevRemoves.indexOf(name) < 0 && el.classList.contains(name), + ); + this.addOrRemoveClasses( + el, + newAdds, + newRemoves, + transition, + time, + view, + blocking, + ); + }); + }, + + toggleAttr(el, attr, val1, val2) { + if (el.hasAttribute(attr)) { + if (val2 !== undefined) { // toggle between val1 and val2 - if(el.getAttribute(attr) === val1){ - this.setOrRemoveAttrs(el, [[attr, val2]], []) + if (el.getAttribute(attr) === val1) { + this.setOrRemoveAttrs(el, [[attr, val2]], []); } else { - this.setOrRemoveAttrs(el, [[attr, val1]], []) + this.setOrRemoveAttrs(el, [[attr, val1]], []); } } else { // remove attr - this.setOrRemoveAttrs(el, [], [attr]) + this.setOrRemoveAttrs(el, [], [attr]); } } else { - this.setOrRemoveAttrs(el, [[attr, val1]], []) + this.setOrRemoveAttrs(el, [[attr, val1]], []); } }, - addOrRemoveClasses(el, adds, removes, transition, time, view, blocking){ - time = time || default_transition_time - const [transitionRun, transitionStart, transitionEnd] = transition || [[], [], []] - if(transitionRun.length > 0){ + addOrRemoveClasses(el, adds, removes, transition, time, view, blocking) { + time = time || default_transition_time; + const [transitionRun, transitionStart, transitionEnd] = transition || [ + [], + [], + [], + ]; + if (transitionRun.length > 0) { const onStart = () => { - this.addOrRemoveClasses(el, transitionStart, [].concat(transitionRun).concat(transitionEnd)) + this.addOrRemoveClasses( + el, + transitionStart, + [].concat(transitionRun).concat(transitionEnd), + ); window.requestAnimationFrame(() => { - this.addOrRemoveClasses(el, transitionRun, []) - window.requestAnimationFrame(() => this.addOrRemoveClasses(el, transitionEnd, transitionStart)) - }) - } - const onDone = () => this.addOrRemoveClasses(el, adds.concat(transitionEnd), removes.concat(transitionRun).concat(transitionStart)) - if(blocking === false){ - onStart() - setTimeout(onDone, time) + this.addOrRemoveClasses(el, transitionRun, []); + window.requestAnimationFrame(() => + this.addOrRemoveClasses(el, transitionEnd, transitionStart), + ); + }); + }; + const onDone = () => + this.addOrRemoveClasses( + el, + adds.concat(transitionEnd), + removes.concat(transitionRun).concat(transitionStart), + ); + if (blocking === false) { + onStart(); + setTimeout(onDone, time); } else { - view.transition(time, onStart, onDone) + view.transition(time, onStart, onDone); } - return + return; } window.requestAnimationFrame(() => { - const [prevAdds, prevRemoves] = DOM.getSticky(el, "classes", [[], []]) - const keepAdds = adds.filter(name => prevAdds.indexOf(name) < 0 && !el.classList.contains(name)) - const keepRemoves = removes.filter(name => prevRemoves.indexOf(name) < 0 && el.classList.contains(name)) - const newAdds = prevAdds.filter(name => removes.indexOf(name) < 0).concat(keepAdds) - const newRemoves = prevRemoves.filter(name => adds.indexOf(name) < 0).concat(keepRemoves) - - DOM.putSticky(el, "classes", currentEl => { - currentEl.classList.remove(...newRemoves) - currentEl.classList.add(...newAdds) - return [newAdds, newRemoves] - }) - }) - }, - - setOrRemoveAttrs(el, sets, removes){ - const [prevSets, prevRemoves] = DOM.getSticky(el, "attrs", [[], []]) - - const alteredAttrs = sets.map(([attr, _val]) => attr).concat(removes) - const newSets = prevSets.filter(([attr, _val]) => !alteredAttrs.includes(attr)).concat(sets) - const newRemoves = prevRemoves.filter((attr) => !alteredAttrs.includes(attr)).concat(removes) - - DOM.putSticky(el, "attrs", currentEl => { - newRemoves.forEach(attr => currentEl.removeAttribute(attr)) - newSets.forEach(([attr, val]) => currentEl.setAttribute(attr, val)) - return [newSets, newRemoves] - }) - }, - - hasAllClasses(el, classes){ return classes.every(name => el.classList.contains(name)) }, - - isToggledOut(el, outClasses){ - return !this.isVisible(el) || this.hasAllClasses(el, outClasses) - }, - - filterToEls(liveSocket, sourceEl, {to}){ + const [prevAdds, prevRemoves] = DOM.getSticky(el, "classes", [[], []]); + const keepAdds = adds.filter( + (name) => prevAdds.indexOf(name) < 0 && !el.classList.contains(name), + ); + const keepRemoves = removes.filter( + (name) => prevRemoves.indexOf(name) < 0 && el.classList.contains(name), + ); + const newAdds = prevAdds + .filter((name) => removes.indexOf(name) < 0) + .concat(keepAdds); + const newRemoves = prevRemoves + .filter((name) => adds.indexOf(name) < 0) + .concat(keepRemoves); + + DOM.putSticky(el, "classes", (currentEl) => { + currentEl.classList.remove(...newRemoves); + currentEl.classList.add(...newAdds); + return [newAdds, newRemoves]; + }); + }); + }, + + setOrRemoveAttrs(el, sets, removes) { + const [prevSets, prevRemoves] = DOM.getSticky(el, "attrs", [[], []]); + + const alteredAttrs = sets.map(([attr, _val]) => attr).concat(removes); + const newSets = prevSets + .filter(([attr, _val]) => !alteredAttrs.includes(attr)) + .concat(sets); + const newRemoves = prevRemoves + .filter((attr) => !alteredAttrs.includes(attr)) + .concat(removes); + + DOM.putSticky(el, "attrs", (currentEl) => { + newRemoves.forEach((attr) => currentEl.removeAttribute(attr)); + newSets.forEach(([attr, val]) => currentEl.setAttribute(attr, val)); + return [newSets, newRemoves]; + }); + }, + + hasAllClasses(el, classes) { + return classes.every((name) => el.classList.contains(name)); + }, + + isToggledOut(el, outClasses) { + return !this.isVisible(el) || this.hasAllClasses(el, outClasses); + }, + + filterToEls(liveSocket, sourceEl, { to }) { const defaultQuery = () => { - if(typeof(to) === "string"){ - return document.querySelectorAll(to) - } else if(to.closest){ - const toEl = sourceEl.closest(to.closest) - return toEl ? [toEl] : [] - } else if(to.inner){ - return sourceEl.querySelectorAll(to.inner) + if (typeof to === "string") { + return document.querySelectorAll(to); + } else if (to.closest) { + const toEl = sourceEl.closest(to.closest); + return toEl ? [toEl] : []; + } else if (to.inner) { + return sourceEl.querySelectorAll(to.inner); } - } - return to ? liveSocket.jsQuerySelectorAll(sourceEl, to, defaultQuery) : [sourceEl] + }; + return to + ? liveSocket.jsQuerySelectorAll(sourceEl, to, defaultQuery) + : [sourceEl]; }, - defaultDisplay(el){ - return {tr: "table-row", td: "table-cell"}[el.tagName.toLowerCase()] || "block" + defaultDisplay(el) { + return ( + { tr: "table-row", td: "table-cell" }[el.tagName.toLowerCase()] || "block" + ); }, - transitionClasses(val){ - if(!val){ return null } + transitionClasses(val) { + if (!val) { + return null; + } - let [trans, tStart, tEnd] = Array.isArray(val) ? val : [val.split(" "), [], []] - trans = Array.isArray(trans) ? trans : trans.split(" ") - tStart = Array.isArray(tStart) ? tStart : tStart.split(" ") - tEnd = Array.isArray(tEnd) ? tEnd : tEnd.split(" ") - return [trans, tStart, tEnd] - } -} + let [trans, tStart, tEnd] = Array.isArray(val) + ? val + : [val.split(" "), [], []]; + trans = Array.isArray(trans) ? trans : trans.split(" "); + tStart = Array.isArray(tStart) ? tStart : tStart.split(" "); + tEnd = Array.isArray(tEnd) ? tEnd : tEnd.split(" "); + return [trans, tStart, tEnd]; + }, +}; -export default JS +export default JS; diff --git a/assets/js/phoenix_live_view/js_commands.ts b/assets/js/phoenix_live_view/js_commands.ts index c65d57518c..f71b3cf811 100644 --- a/assets/js/phoenix_live_view/js_commands.ts +++ b/assets/js/phoenix_live_view/js_commands.ts @@ -1,7 +1,7 @@ -import JS from "./js" -import LiveSocket from "./live_socket" +import JS from "./js"; +import LiveSocket from "./live_socket"; -type Transition = string | string[] +type Transition = string | string[]; // Base options for commands involving transitions and timing type BaseOpts = { @@ -15,12 +15,12 @@ type BaseOpts = { time?: number; /** Whether to block UI during transition. Defaults `true`. */ blocking?: boolean; -} +}; type ShowOpts = BaseOpts & { /** The CSS display value to set. Defaults "block". */ display?: string; -} +}; type ToggleOpts = { /** The CSS display value to set. Defaults "block". */ @@ -45,7 +45,7 @@ type ToggleOpts = { time?: number; /** Whether to block UI during transition. Defaults `true`. */ blocking?: boolean; -} +}; // Options specific to the 'transition' command type TransitionCommandOpts = { @@ -53,7 +53,7 @@ type TransitionCommandOpts = { time?: number; /** Whether to block UI during transition. Defaults `true`. */ blocking?: boolean; -} +}; type PushOpts = { /** Data to be merged into the event payload. */ @@ -63,12 +63,12 @@ type PushOpts = { /** Indicates if a page loading state should be shown. */ page_loading?: boolean; [key: string]: any; // Allow other properties like 'cid', 'redirect', etc. -} +}; type NavigationOpts = { /** Whether to replace the current history entry instead of pushing a new one. */ replace?: boolean; -} +}; /** * Represents all possible JS commands that can be generated by the factory. @@ -155,7 +155,11 @@ interface AllJSCommands { * @param [opts={}] - Optional settings for timing and blocking behavior. * Accepts: `time` and `blocking`. */ - transition(el: HTMLElement, transition: string | string[], opts?: TransitionCommandOpts): void; + transition( + el: HTMLElement, + transition: string | string[], + opts?: TransitionCommandOpts, + ): void; /** * Sets an attribute on an element. @@ -182,11 +186,16 @@ interface AllJSCommands { * @param val1 - The first value to toggle between. * @param val2 - The second value to toggle between. */ - toggleAttribute(el: HTMLElement, attr: string, val1: string, val2: string): void; + toggleAttribute( + el: HTMLElement, + attr: string, + val1: string, + val2: string, + ): void; /** * Pushes an event to the server. - * + * * @param el - An element that belongs to the target LiveView / LiveComponent or a component ID. * To target a LiveComponent by its ID, pass a separate `target` in the options. * @param type - The event name to push. @@ -197,7 +206,7 @@ interface AllJSCommands { /** * Sends a navigation event to the server and updates the browser's pushState history. - * + * * @param href - The URL to navigate to. * @param [opts={}] - Optional settings. * Accepts: `replace`. @@ -206,7 +215,7 @@ interface AllJSCommands { /** * Sends a patch event to the server and updates the browser's pushState history. - * + * * @param href - The URL to patch to. * @param [opts={}] - Optional settings. * Accepts: `replace`. @@ -222,74 +231,150 @@ interface AllJSCommands { ignoreAttributes(el: HTMLElement, attrs: string | string[]): void; } -export default (liveSocket: LiveSocket, eventType: string | null): AllJSCommands => { +export default ( + liveSocket: LiveSocket, + eventType: string | null, +): AllJSCommands => { return { - exec(el, encodedJS){ - liveSocket.execJS(el, encodedJS, eventType) + exec(el, encodedJS) { + liveSocket.execJS(el, encodedJS, eventType); }, - show(el, opts = {}){ - const owner = liveSocket.owner(el) - JS.show(eventType, owner, el, opts.display, JS.transitionClasses(opts.transition), opts.time, opts.blocking) + show(el, opts = {}) { + const owner = liveSocket.owner(el); + JS.show( + eventType, + owner, + el, + opts.display, + JS.transitionClasses(opts.transition), + opts.time, + opts.blocking, + ); }, - hide(el, opts = {}){ - const owner = liveSocket.owner(el) - JS.hide(eventType, owner, el, null, JS.transitionClasses(opts.transition), opts.time, opts.blocking) + hide(el, opts = {}) { + const owner = liveSocket.owner(el); + JS.hide( + eventType, + owner, + el, + null, + JS.transitionClasses(opts.transition), + opts.time, + opts.blocking, + ); }, - toggle(el, opts = {}){ - const owner = liveSocket.owner(el) - const inTransition = JS.transitionClasses(opts.in) - const outTransition = JS.transitionClasses(opts.out) - JS.toggle(eventType, owner, el, opts.display, inTransition, outTransition, opts.time, opts.blocking) + toggle(el, opts = {}) { + const owner = liveSocket.owner(el); + const inTransition = JS.transitionClasses(opts.in); + const outTransition = JS.transitionClasses(opts.out); + JS.toggle( + eventType, + owner, + el, + opts.display, + inTransition, + outTransition, + opts.time, + opts.blocking, + ); }, - addClass(el, names, opts = {}){ - const classNames = Array.isArray(names) ? names : names.split(" ") - const owner = liveSocket.owner(el) - JS.addOrRemoveClasses(el, classNames, [], JS.transitionClasses(opts.transition), opts.time, owner, opts.blocking) + addClass(el, names, opts = {}) { + const classNames = Array.isArray(names) ? names : names.split(" "); + const owner = liveSocket.owner(el); + JS.addOrRemoveClasses( + el, + classNames, + [], + JS.transitionClasses(opts.transition), + opts.time, + owner, + opts.blocking, + ); }, - removeClass(el, names, opts = {}){ - const classNames = Array.isArray(names) ? names : names.split(" ") - const owner = liveSocket.owner(el) - JS.addOrRemoveClasses(el, [], classNames, JS.transitionClasses(opts.transition), opts.time, owner, opts.blocking) + removeClass(el, names, opts = {}) { + const classNames = Array.isArray(names) ? names : names.split(" "); + const owner = liveSocket.owner(el); + JS.addOrRemoveClasses( + el, + [], + classNames, + JS.transitionClasses(opts.transition), + opts.time, + owner, + opts.blocking, + ); }, - toggleClass(el, names, opts = {}){ - const classNames = Array.isArray(names) ? names : names.split(" ") - const owner = liveSocket.owner(el) - JS.toggleClasses(el, classNames, JS.transitionClasses(opts.transition), opts.time, owner, opts.blocking) + toggleClass(el, names, opts = {}) { + const classNames = Array.isArray(names) ? names : names.split(" "); + const owner = liveSocket.owner(el); + JS.toggleClasses( + el, + classNames, + JS.transitionClasses(opts.transition), + opts.time, + owner, + opts.blocking, + ); }, - transition(el, transition, opts = {}){ - const owner = liveSocket.owner(el) - JS.addOrRemoveClasses(el, [], [], JS.transitionClasses(transition), opts.time, owner, opts.blocking) + transition(el, transition, opts = {}) { + const owner = liveSocket.owner(el); + JS.addOrRemoveClasses( + el, + [], + [], + JS.transitionClasses(transition), + opts.time, + owner, + opts.blocking, + ); }, - setAttribute(el, attr, val){ JS.setOrRemoveAttrs(el, [[attr, val]], []) }, - removeAttribute(el, attr){ JS.setOrRemoveAttrs(el, [], [attr]) }, - toggleAttribute(el, attr, val1, val2){ JS.toggleAttr(el, attr, val1, val2) }, - push(el, type, opts = {}){ - liveSocket.withinOwners(el, view => { - const data = opts.value || {} - delete opts.value - let e = new CustomEvent("phx:exec", {detail: {sourceElement: el}}) - JS.exec(e, eventType, type, view, el, ["push", {data, ...opts}]) - }) + setAttribute(el, attr, val) { + JS.setOrRemoveAttrs(el, [[attr, val]], []); }, - navigate(href, opts = {}){ - const customEvent = new CustomEvent("phx:exec") - liveSocket.historyRedirect(customEvent, href, opts.replace ? "replace" : "push", null, null) + removeAttribute(el, attr) { + JS.setOrRemoveAttrs(el, [], [attr]); }, - patch(href, opts = {}){ - const customEvent = new CustomEvent("phx:exec") - liveSocket.pushHistoryPatch(customEvent, href, opts.replace ? "replace" : "push", null) + toggleAttribute(el, attr, val1, val2) { + JS.toggleAttr(el, attr, val1, val2); }, - ignoreAttributes(el, attrs){ - JS.ignoreAttrs(el, Array.isArray(attrs) ? attrs : [attrs]) - } - } -} + push(el, type, opts = {}) { + liveSocket.withinOwners(el, (view) => { + const data = opts.value || {}; + delete opts.value; + let e = new CustomEvent("phx:exec", { detail: { sourceElement: el } }); + JS.exec(e, eventType, type, view, el, ["push", { data, ...opts }]); + }); + }, + navigate(href, opts = {}) { + const customEvent = new CustomEvent("phx:exec"); + liveSocket.historyRedirect( + customEvent, + href, + opts.replace ? "replace" : "push", + null, + null, + ); + }, + patch(href, opts = {}) { + const customEvent = new CustomEvent("phx:exec"); + liveSocket.pushHistoryPatch( + customEvent, + href, + opts.replace ? "replace" : "push", + null, + ); + }, + ignoreAttributes(el, attrs) { + JS.ignoreAttrs(el, Array.isArray(attrs) ? attrs : [attrs]); + }, + }; +}; /** * JSCommands for use with `liveSocket.js()`. * Includes the general `exec` command that requires an element. */ -export type LiveSocketJSCommands = AllJSCommands +export type LiveSocketJSCommands = AllJSCommands; /** * JSCommands for use within a Hook. diff --git a/assets/js/phoenix_live_view/live_socket.js b/assets/js/phoenix_live_view/live_socket.js index 4cb9ba89ac..0f44b7bed8 100644 --- a/assets/js/phoenix_live_view/live_socket.js +++ b/assets/js/phoenix_live_view/live_socket.js @@ -26,938 +26,1233 @@ import { RELOAD_JITTER_MIN, RELOAD_JITTER_MAX, PHX_REF_SRC, - PHX_RELOAD_STATUS -} from "./constants" + PHX_RELOAD_STATUS, +} from "./constants"; -import { - clone, - closestPhxBinding, - closure, - debug, - maybe -} from "./utils" - -import Browser from "./browser" -import DOM from "./dom" -import Hooks from "./hooks" -import LiveUploader from "./live_uploader" -import View from "./view" -import JS from "./js" -import jsCommands from "./js_commands" - -export const isUsedInput = (el) => DOM.isUsedInput(el) +import { clone, closestPhxBinding, closure, debug, maybe } from "./utils"; + +import Browser from "./browser"; +import DOM from "./dom"; +import Hooks from "./hooks"; +import LiveUploader from "./live_uploader"; +import View from "./view"; +import JS from "./js"; +import jsCommands from "./js_commands"; + +export const isUsedInput = (el) => DOM.isUsedInput(el); export default class LiveSocket { - constructor(url, phxSocket, opts = {}){ - this.unloaded = false - if(!phxSocket || phxSocket.constructor.name === "Object"){ + constructor(url, phxSocket, opts = {}) { + this.unloaded = false; + if (!phxSocket || phxSocket.constructor.name === "Object") { throw new Error(` a phoenix Socket must be provided as the second argument to the LiveSocket constructor. For example: import {Socket} from "phoenix" import {LiveSocket} from "phoenix_live_view" let liveSocket = new LiveSocket("/live", Socket, {...}) - `) - } - this.socket = new phxSocket(url, opts) - this.bindingPrefix = opts.bindingPrefix || BINDING_PREFIX - this.opts = opts - this.params = closure(opts.params || {}) - this.viewLogger = opts.viewLogger - this.metadataCallbacks = opts.metadata || {} - this.defaults = Object.assign(clone(DEFAULTS), opts.defaults || {}) - this.prevActive = null - this.silenced = false - this.main = null - this.outgoingMainEl = null - this.clickStartedAtTarget = null - this.linkRef = 1 - this.roots = {} - this.href = window.location.href - this.pendingLink = null - this.currentLocation = clone(window.location) - this.hooks = opts.hooks || {} - this.uploaders = opts.uploaders || {} - this.loaderTimeout = opts.loaderTimeout || LOADER_TIMEOUT - this.disconnectedTimeout = opts.disconnectedTimeout || DISCONNECTED_TIMEOUT - this.reloadWithJitterTimer = null - this.maxReloads = opts.maxReloads || MAX_RELOADS - this.reloadJitterMin = opts.reloadJitterMin || RELOAD_JITTER_MIN - this.reloadJitterMax = opts.reloadJitterMax || RELOAD_JITTER_MAX - this.failsafeJitter = opts.failsafeJitter || FAILSAFE_JITTER - this.localStorage = opts.localStorage || window.localStorage - this.sessionStorage = opts.sessionStorage || window.sessionStorage - this.boundTopLevelEvents = false - this.boundEventNames = new Set() - this.serverCloseRef = null - this.domCallbacks = Object.assign({ - jsQuerySelectorAll: null, - onPatchStart: closure(), - onPatchEnd: closure(), - onNodeAdded: closure(), - onBeforeElUpdated: closure()}, - opts.dom || {}) - this.transitions = new TransitionSet() - this.currentHistoryPosition = parseInt(this.sessionStorage.getItem(PHX_LV_HISTORY_POSITION)) || 0 - window.addEventListener("pagehide", _e => { - this.unloaded = true - }) + `); + } + this.socket = new phxSocket(url, opts); + this.bindingPrefix = opts.bindingPrefix || BINDING_PREFIX; + this.opts = opts; + this.params = closure(opts.params || {}); + this.viewLogger = opts.viewLogger; + this.metadataCallbacks = opts.metadata || {}; + this.defaults = Object.assign(clone(DEFAULTS), opts.defaults || {}); + this.prevActive = null; + this.silenced = false; + this.main = null; + this.outgoingMainEl = null; + this.clickStartedAtTarget = null; + this.linkRef = 1; + this.roots = {}; + this.href = window.location.href; + this.pendingLink = null; + this.currentLocation = clone(window.location); + this.hooks = opts.hooks || {}; + this.uploaders = opts.uploaders || {}; + this.loaderTimeout = opts.loaderTimeout || LOADER_TIMEOUT; + this.disconnectedTimeout = opts.disconnectedTimeout || DISCONNECTED_TIMEOUT; + this.reloadWithJitterTimer = null; + this.maxReloads = opts.maxReloads || MAX_RELOADS; + this.reloadJitterMin = opts.reloadJitterMin || RELOAD_JITTER_MIN; + this.reloadJitterMax = opts.reloadJitterMax || RELOAD_JITTER_MAX; + this.failsafeJitter = opts.failsafeJitter || FAILSAFE_JITTER; + this.localStorage = opts.localStorage || window.localStorage; + this.sessionStorage = opts.sessionStorage || window.sessionStorage; + this.boundTopLevelEvents = false; + this.boundEventNames = new Set(); + this.serverCloseRef = null; + this.domCallbacks = Object.assign( + { + jsQuerySelectorAll: null, + onPatchStart: closure(), + onPatchEnd: closure(), + onNodeAdded: closure(), + onBeforeElUpdated: closure(), + }, + opts.dom || {}, + ); + this.transitions = new TransitionSet(); + this.currentHistoryPosition = + parseInt(this.sessionStorage.getItem(PHX_LV_HISTORY_POSITION)) || 0; + window.addEventListener("pagehide", (_e) => { + this.unloaded = true; + }); this.socket.onOpen(() => { - if(this.isUnloaded()){ + if (this.isUnloaded()) { // reload page if being restored from back/forward cache and browser does not emit "pageshow" - window.location.reload() + window.location.reload(); } - }) + }); } // public - version(){ return LV_VSN } + version() { + return LV_VSN; + } - isProfileEnabled(){ return this.sessionStorage.getItem(PHX_LV_PROFILE) === "true" } + isProfileEnabled() { + return this.sessionStorage.getItem(PHX_LV_PROFILE) === "true"; + } - isDebugEnabled(){ return this.sessionStorage.getItem(PHX_LV_DEBUG) === "true" } + isDebugEnabled() { + return this.sessionStorage.getItem(PHX_LV_DEBUG) === "true"; + } - isDebugDisabled(){ return this.sessionStorage.getItem(PHX_LV_DEBUG) === "false" } + isDebugDisabled() { + return this.sessionStorage.getItem(PHX_LV_DEBUG) === "false"; + } - enableDebug(){ this.sessionStorage.setItem(PHX_LV_DEBUG, "true") } + enableDebug() { + this.sessionStorage.setItem(PHX_LV_DEBUG, "true"); + } - enableProfiling(){ this.sessionStorage.setItem(PHX_LV_PROFILE, "true") } + enableProfiling() { + this.sessionStorage.setItem(PHX_LV_PROFILE, "true"); + } - disableDebug(){ this.sessionStorage.setItem(PHX_LV_DEBUG, "false") } + disableDebug() { + this.sessionStorage.setItem(PHX_LV_DEBUG, "false"); + } - disableProfiling(){ this.sessionStorage.removeItem(PHX_LV_PROFILE) } + disableProfiling() { + this.sessionStorage.removeItem(PHX_LV_PROFILE); + } - enableLatencySim(upperBoundMs){ - this.enableDebug() - console.log("latency simulator enabled for the duration of this browser session. Call disableLatencySim() to disable") - this.sessionStorage.setItem(PHX_LV_LATENCY_SIM, upperBoundMs) + enableLatencySim(upperBoundMs) { + this.enableDebug(); + console.log( + "latency simulator enabled for the duration of this browser session. Call disableLatencySim() to disable", + ); + this.sessionStorage.setItem(PHX_LV_LATENCY_SIM, upperBoundMs); } - disableLatencySim(){ this.sessionStorage.removeItem(PHX_LV_LATENCY_SIM) } + disableLatencySim() { + this.sessionStorage.removeItem(PHX_LV_LATENCY_SIM); + } - getLatencySim(){ - const str = this.sessionStorage.getItem(PHX_LV_LATENCY_SIM) - return str ? parseInt(str) : null + getLatencySim() { + const str = this.sessionStorage.getItem(PHX_LV_LATENCY_SIM); + return str ? parseInt(str) : null; } - getSocket(){ return this.socket } + getSocket() { + return this.socket; + } - connect(){ + connect() { // enable debug by default if on localhost and not explicitly disabled - if(window.location.hostname === "localhost" && !this.isDebugDisabled()){ this.enableDebug() } + if (window.location.hostname === "localhost" && !this.isDebugDisabled()) { + this.enableDebug(); + } const doConnect = () => { - this.resetReloadStatus() - if(this.joinRootViews()){ - this.bindTopLevelEvents() - this.socket.connect() - } else if(this.main){ - this.socket.connect() + this.resetReloadStatus(); + if (this.joinRootViews()) { + this.bindTopLevelEvents(); + this.socket.connect(); + } else if (this.main) { + this.socket.connect(); } else { - this.bindTopLevelEvents({dead: true}) + this.bindTopLevelEvents({ dead: true }); } - this.joinDeadView() - } - if(["complete", "loaded", "interactive"].indexOf(document.readyState) >= 0){ - doConnect() + this.joinDeadView(); + }; + if ( + ["complete", "loaded", "interactive"].indexOf(document.readyState) >= 0 + ) { + doConnect(); } else { - document.addEventListener("DOMContentLoaded", () => doConnect()) + document.addEventListener("DOMContentLoaded", () => doConnect()); } } - disconnect(callback){ - clearTimeout(this.reloadWithJitterTimer) + disconnect(callback) { + clearTimeout(this.reloadWithJitterTimer); // remove the socket close listener to avoid trying to handle // a server close event when it is actually caused by us disconnecting - if(this.serverCloseRef){ - this.socket.off(this.serverCloseRef) - this.serverCloseRef = null + if (this.serverCloseRef) { + this.socket.off(this.serverCloseRef); + this.serverCloseRef = null; } - this.socket.disconnect(callback) + this.socket.disconnect(callback); } - replaceTransport(transport){ - clearTimeout(this.reloadWithJitterTimer) - this.socket.replaceTransport(transport) - this.connect() + replaceTransport(transport) { + clearTimeout(this.reloadWithJitterTimer); + this.socket.replaceTransport(transport); + this.connect(); } - execJS(el, encodedJS, eventType = null){ - const e = new CustomEvent("phx:exec", {detail: {sourceElement: el}}) - this.owner(el, view => JS.exec(e, eventType, encodedJS, view, el)) + execJS(el, encodedJS, eventType = null) { + const e = new CustomEvent("phx:exec", { detail: { sourceElement: el } }); + this.owner(el, (view) => JS.exec(e, eventType, encodedJS, view, el)); } /** * Returns an object with methods to manipluate the DOM and execute JavaScript. * The applied changes integrate with server DOM patching. - * + * * @returns {import("./js_commands").LiveSocketJSCommands} */ - js(){ - return jsCommands(this, "js") + js() { + return jsCommands(this, "js"); } // private - - unload(){ - if(this.unloaded){ return } - if(this.main && this.isConnected()){ this.log(this.main, "socket", () => ["disconnect for page nav"]) } - this.unloaded = true - this.destroyAllViews() - this.disconnect() + + unload() { + if (this.unloaded) { + return; + } + if (this.main && this.isConnected()) { + this.log(this.main, "socket", () => ["disconnect for page nav"]); + } + this.unloaded = true; + this.destroyAllViews(); + this.disconnect(); } - triggerDOM(kind, args){ this.domCallbacks[kind](...args) } + triggerDOM(kind, args) { + this.domCallbacks[kind](...args); + } - time(name, func){ - if(!this.isProfileEnabled() || !console.time){ return func() } - console.time(name) - const result = func() - console.timeEnd(name) - return result + time(name, func) { + if (!this.isProfileEnabled() || !console.time) { + return func(); + } + console.time(name); + const result = func(); + console.timeEnd(name); + return result; } - log(view, kind, msgCallback){ - if(this.viewLogger){ - const [msg, obj] = msgCallback() - this.viewLogger(view, kind, msg, obj) - } else if(this.isDebugEnabled()){ - const [msg, obj] = msgCallback() - debug(view, kind, msg, obj) + log(view, kind, msgCallback) { + if (this.viewLogger) { + const [msg, obj] = msgCallback(); + this.viewLogger(view, kind, msg, obj); + } else if (this.isDebugEnabled()) { + const [msg, obj] = msgCallback(); + debug(view, kind, msg, obj); } } - requestDOMUpdate(callback){ - this.transitions.after(callback) + requestDOMUpdate(callback) { + this.transitions.after(callback); } - transition(time, onStart, onDone = function(){}){ - this.transitions.addTransition(time, onStart, onDone) + transition(time, onStart, onDone = function () {}) { + this.transitions.addTransition(time, onStart, onDone); } - onChannel(channel, event, cb){ - channel.on(event, data => { - const latency = this.getLatencySim() - if(!latency){ - cb(data) + onChannel(channel, event, cb) { + channel.on(event, (data) => { + const latency = this.getLatencySim(); + if (!latency) { + cb(data); } else { - setTimeout(() => cb(data), latency) + setTimeout(() => cb(data), latency); } - }) + }); } - reloadWithJitter(view, log){ - clearTimeout(this.reloadWithJitterTimer) - this.disconnect() - const minMs = this.reloadJitterMin - const maxMs = this.reloadJitterMax - let afterMs = Math.floor(Math.random() * (maxMs - minMs + 1)) + minMs - const tries = Browser.updateLocal(this.localStorage, window.location.pathname, CONSECUTIVE_RELOADS, 0, count => count + 1) - if(tries >= this.maxReloads){ - afterMs = this.failsafeJitter + reloadWithJitter(view, log) { + clearTimeout(this.reloadWithJitterTimer); + this.disconnect(); + const minMs = this.reloadJitterMin; + const maxMs = this.reloadJitterMax; + let afterMs = Math.floor(Math.random() * (maxMs - minMs + 1)) + minMs; + const tries = Browser.updateLocal( + this.localStorage, + window.location.pathname, + CONSECUTIVE_RELOADS, + 0, + (count) => count + 1, + ); + if (tries >= this.maxReloads) { + afterMs = this.failsafeJitter; } this.reloadWithJitterTimer = setTimeout(() => { // if view has recovered, such as transport replaced, then cancel - if(view.isDestroyed() || view.isConnected()){ return } - view.destroy() - log ? log() : this.log(view, "join", () => [`encountered ${tries} consecutive reloads`]) - if(tries >= this.maxReloads){ - this.log(view, "join", () => [`exceeded ${this.maxReloads} consecutive reloads. Entering failsafe mode`]) + if (view.isDestroyed() || view.isConnected()) { + return; + } + view.destroy(); + log + ? log() + : this.log(view, "join", () => [ + `encountered ${tries} consecutive reloads`, + ]); + if (tries >= this.maxReloads) { + this.log(view, "join", () => [ + `exceeded ${this.maxReloads} consecutive reloads. Entering failsafe mode`, + ]); } - if(this.hasPendingLink()){ - window.location = this.pendingLink + if (this.hasPendingLink()) { + window.location = this.pendingLink; } else { - window.location.reload() + window.location.reload(); } - }, afterMs) + }, afterMs); } - getHookCallbacks(name){ - return name && name.startsWith("Phoenix.") ? Hooks[name.split(".")[1]] : this.hooks[name] + getHookCallbacks(name) { + return name && name.startsWith("Phoenix.") + ? Hooks[name.split(".")[1]] + : this.hooks[name]; } - isUnloaded(){ return this.unloaded } + isUnloaded() { + return this.unloaded; + } - isConnected(){ return this.socket.isConnected() } + isConnected() { + return this.socket.isConnected(); + } - getBindingPrefix(){ return this.bindingPrefix } + getBindingPrefix() { + return this.bindingPrefix; + } - binding(kind){ return `${this.getBindingPrefix()}${kind}` } + binding(kind) { + return `${this.getBindingPrefix()}${kind}`; + } - channel(topic, params){ return this.socket.channel(topic, params) } + channel(topic, params) { + return this.socket.channel(topic, params); + } - joinDeadView(){ - const body = document.body - if(body && !this.isPhxView(body) && !this.isPhxView(document.firstElementChild)){ - const view = this.newRootView(body) - view.setHref(this.getHref()) - view.joinDead() - if(!this.main){ this.main = view } + joinDeadView() { + const body = document.body; + if ( + body && + !this.isPhxView(body) && + !this.isPhxView(document.firstElementChild) + ) { + const view = this.newRootView(body); + view.setHref(this.getHref()); + view.joinDead(); + if (!this.main) { + this.main = view; + } window.requestAnimationFrame(() => { - view.execNewMounted() + view.execNewMounted(); // restore scroll position when navigating from an external / non-live page - this.maybeScroll(history.state?.scroll) - }) + this.maybeScroll(history.state?.scroll); + }); } } - joinRootViews(){ - let rootsFound = false - DOM.all(document, `${PHX_VIEW_SELECTOR}:not([${PHX_PARENT_ID}])`, rootEl => { - if(!this.getRootById(rootEl.id)){ - const view = this.newRootView(rootEl) - // stickies cannot be mounted at the router and therefore should not - // get a href set on them - if(!DOM.isPhxSticky(rootEl)){ view.setHref(this.getHref()) } - view.join() - if(rootEl.hasAttribute(PHX_MAIN)){ this.main = view } - } - rootsFound = true - }) - return rootsFound + joinRootViews() { + let rootsFound = false; + DOM.all( + document, + `${PHX_VIEW_SELECTOR}:not([${PHX_PARENT_ID}])`, + (rootEl) => { + if (!this.getRootById(rootEl.id)) { + const view = this.newRootView(rootEl); + // stickies cannot be mounted at the router and therefore should not + // get a href set on them + if (!DOM.isPhxSticky(rootEl)) { + view.setHref(this.getHref()); + } + view.join(); + if (rootEl.hasAttribute(PHX_MAIN)) { + this.main = view; + } + } + rootsFound = true; + }, + ); + return rootsFound; } - redirect(to, flash, reloadToken){ - if(reloadToken){ Browser.setCookie(PHX_RELOAD_STATUS, reloadToken, 60) } - this.unload() - Browser.redirect(to, flash) + redirect(to, flash, reloadToken) { + if (reloadToken) { + Browser.setCookie(PHX_RELOAD_STATUS, reloadToken, 60); + } + this.unload(); + Browser.redirect(to, flash); } - replaceMain(href, flash, callback = null, linkRef = this.setPendingLink(href)){ - const liveReferer = this.currentLocation.href - this.outgoingMainEl = this.outgoingMainEl || this.main.el - - const stickies = DOM.findPhxSticky(document) || [] - const removeEls = DOM.all(this.outgoingMainEl, `[${this.binding("remove")}]`) - .filter(el => !DOM.isChildOfAny(el, stickies)) - - const newMainEl = DOM.cloneNode(this.outgoingMainEl, "") - this.main.showLoader(this.loaderTimeout) - this.main.destroy() - - this.main = this.newRootView(newMainEl, flash, liveReferer) - this.main.setRedirect(href) - this.transitionRemoves(removeEls) + replaceMain( + href, + flash, + callback = null, + linkRef = this.setPendingLink(href), + ) { + const liveReferer = this.currentLocation.href; + this.outgoingMainEl = this.outgoingMainEl || this.main.el; + + const stickies = DOM.findPhxSticky(document) || []; + const removeEls = DOM.all( + this.outgoingMainEl, + `[${this.binding("remove")}]`, + ).filter((el) => !DOM.isChildOfAny(el, stickies)); + + const newMainEl = DOM.cloneNode(this.outgoingMainEl, ""); + this.main.showLoader(this.loaderTimeout); + this.main.destroy(); + + this.main = this.newRootView(newMainEl, flash, liveReferer); + this.main.setRedirect(href); + this.transitionRemoves(removeEls); this.main.join((joinCount, onDone) => { - if(joinCount === 1 && this.commitPendingLink(linkRef)){ + if (joinCount === 1 && this.commitPendingLink(linkRef)) { this.requestDOMUpdate(() => { // remove phx-remove els right before we replace the main element - removeEls.forEach(el => el.remove()) - stickies.forEach(el => newMainEl.appendChild(el)) - this.outgoingMainEl.replaceWith(newMainEl) - this.outgoingMainEl = null - callback && callback(linkRef) - onDone() - }) + removeEls.forEach((el) => el.remove()); + stickies.forEach((el) => newMainEl.appendChild(el)); + this.outgoingMainEl.replaceWith(newMainEl); + this.outgoingMainEl = null; + callback && callback(linkRef); + onDone(); + }); } - }) + }); } - transitionRemoves(elements, callback){ - const removeAttr = this.binding("remove") + transitionRemoves(elements, callback) { + const removeAttr = this.binding("remove"); const silenceEvents = (e) => { - e.preventDefault() - e.stopImmediatePropagation() - } - elements.forEach(el => { + e.preventDefault(); + e.stopImmediatePropagation(); + }; + elements.forEach((el) => { // prevent all listeners we care about from bubbling to window // since we are removing the element - for(const event of this.boundEventNames){ - el.addEventListener(event, silenceEvents, true) + for (const event of this.boundEventNames) { + el.addEventListener(event, silenceEvents, true); } - this.execJS(el, el.getAttribute(removeAttr), "remove") - }) + this.execJS(el, el.getAttribute(removeAttr), "remove"); + }); // remove the silenced listeners when transitions are done incase the element is re-used // and call caller's callback as soon as we are done with transitions this.requestDOMUpdate(() => { - elements.forEach(el => { - for(const event of this.boundEventNames){ - el.removeEventListener(event, silenceEvents, true) + elements.forEach((el) => { + for (const event of this.boundEventNames) { + el.removeEventListener(event, silenceEvents, true); } - }) - callback && callback() - }) + }); + callback && callback(); + }); } - isPhxView(el){ return el.getAttribute && el.getAttribute(PHX_SESSION) !== null } + isPhxView(el) { + return el.getAttribute && el.getAttribute(PHX_SESSION) !== null; + } - newRootView(el, flash, liveReferer){ - const view = new View(el, this, null, flash, liveReferer) - this.roots[view.id] = view - return view + newRootView(el, flash, liveReferer) { + const view = new View(el, this, null, flash, liveReferer); + this.roots[view.id] = view; + return view; } - owner(childEl, callback){ - const view = maybe(childEl.closest(PHX_VIEW_SELECTOR), el => this.getViewByEl(el)) || this.main - return view && callback ? callback(view) : view + owner(childEl, callback) { + const view = + maybe(childEl.closest(PHX_VIEW_SELECTOR), (el) => this.getViewByEl(el)) || + this.main; + return view && callback ? callback(view) : view; } - withinOwners(childEl, callback){ - this.owner(childEl, view => callback(view, childEl)) + withinOwners(childEl, callback) { + this.owner(childEl, (view) => callback(view, childEl)); } - getViewByEl(el){ - const rootId = el.getAttribute(PHX_ROOT_ID) - return maybe(this.getRootById(rootId), root => root.getDescendentByEl(el)) + getViewByEl(el) { + const rootId = el.getAttribute(PHX_ROOT_ID); + return maybe(this.getRootById(rootId), (root) => + root.getDescendentByEl(el), + ); } - getRootById(id){ return this.roots[id] } + getRootById(id) { + return this.roots[id]; + } - destroyAllViews(){ - for(const id in this.roots){ - this.roots[id].destroy() - delete this.roots[id] + destroyAllViews() { + for (const id in this.roots) { + this.roots[id].destroy(); + delete this.roots[id]; } - this.main = null + this.main = null; } - destroyViewByEl(el){ - const root = this.getRootById(el.getAttribute(PHX_ROOT_ID)) - if(root && root.id === el.id){ - root.destroy() - delete this.roots[root.id] - } else if(root){ - root.destroyDescendent(el.id) + destroyViewByEl(el) { + const root = this.getRootById(el.getAttribute(PHX_ROOT_ID)); + if (root && root.id === el.id) { + root.destroy(); + delete this.roots[root.id]; + } else if (root) { + root.destroyDescendent(el.id); } } - getActiveElement(){ - return document.activeElement + getActiveElement() { + return document.activeElement; } - dropActiveElement(view){ - if(this.prevActive && view.ownsElement(this.prevActive)){ - this.prevActive = null + dropActiveElement(view) { + if (this.prevActive && view.ownsElement(this.prevActive)) { + this.prevActive = null; } } - restorePreviouslyActiveFocus(){ - if(this.prevActive && this.prevActive !== document.body && this.prevActive instanceof HTMLElement){ - this.prevActive.focus() + restorePreviouslyActiveFocus() { + if ( + this.prevActive && + this.prevActive !== document.body && + this.prevActive instanceof HTMLElement + ) { + this.prevActive.focus(); } } - blurActiveElement(){ - this.prevActive = this.getActiveElement() - if(this.prevActive !== document.body && this.prevActive instanceof HTMLElement){ this.prevActive.blur() } + blurActiveElement() { + this.prevActive = this.getActiveElement(); + if ( + this.prevActive !== document.body && + this.prevActive instanceof HTMLElement + ) { + this.prevActive.blur(); + } } /** * @param {{dead?: boolean}} [options={}] */ - bindTopLevelEvents({dead} = {}){ - if(this.boundTopLevelEvents){ return } + bindTopLevelEvents({ dead } = {}) { + if (this.boundTopLevelEvents) { + return; + } - this.boundTopLevelEvents = true + this.boundTopLevelEvents = true; // enter failsafe reload if server has gone away intentionally, such as "disconnect" broadcast - this.serverCloseRef = this.socket.onClose(event => { + this.serverCloseRef = this.socket.onClose((event) => { // failsafe reload if normal closure and we still have a main LV - if(event && event.code === 1000 && this.main){ return this.reloadWithJitter(this.main) } - }) - document.body.addEventListener("click", function (){ }) // ensure all click events bubble for mobile Safari - window.addEventListener("pageshow", e => { - if(e.persisted){ // reload page if being restored from back/forward cache - this.getSocket().disconnect() - this.withPageLoading({to: window.location.href, kind: "redirect"}) - window.location.reload() + if (event && event.code === 1000 && this.main) { + return this.reloadWithJitter(this.main); } - }, true) - if(!dead){ this.bindNav() } - this.bindClicks() - if(!dead){ this.bindForms() } - this.bind({keyup: "keyup", keydown: "keydown"}, (e, type, view, targetEl, phxEvent, _phxTarget) => { - const matchKey = targetEl.getAttribute(this.binding(PHX_KEY)) - const pressedKey = e.key && e.key.toLowerCase() // chrome clicked autocompletes send a keydown without key - if(matchKey && matchKey.toLowerCase() !== pressedKey){ return } - - const data = {key: e.key, ...this.eventMeta(type, e, targetEl)} - JS.exec(e, type, phxEvent, view, targetEl, ["push", {data}]) - }) - this.bind({blur: "focusout", focus: "focusin"}, (e, type, view, targetEl, phxEvent, phxTarget) => { - if(!phxTarget){ - const data = {key: e.key, ...this.eventMeta(type, e, targetEl)} - JS.exec(e, type, phxEvent, view, targetEl, ["push", {data}]) + }); + document.body.addEventListener("click", function () {}); // ensure all click events bubble for mobile Safari + window.addEventListener( + "pageshow", + (e) => { + if (e.persisted) { + // reload page if being restored from back/forward cache + this.getSocket().disconnect(); + this.withPageLoading({ to: window.location.href, kind: "redirect" }); + window.location.reload(); + } + }, + true, + ); + if (!dead) { + this.bindNav(); + } + this.bindClicks(); + if (!dead) { + this.bindForms(); + } + this.bind( + { keyup: "keyup", keydown: "keydown" }, + (e, type, view, targetEl, phxEvent, _phxTarget) => { + const matchKey = targetEl.getAttribute(this.binding(PHX_KEY)); + const pressedKey = e.key && e.key.toLowerCase(); // chrome clicked autocompletes send a keydown without key + if (matchKey && matchKey.toLowerCase() !== pressedKey) { + return; + } + + const data = { key: e.key, ...this.eventMeta(type, e, targetEl) }; + JS.exec(e, type, phxEvent, view, targetEl, ["push", { data }]); + }, + ); + this.bind( + { blur: "focusout", focus: "focusin" }, + (e, type, view, targetEl, phxEvent, phxTarget) => { + if (!phxTarget) { + const data = { key: e.key, ...this.eventMeta(type, e, targetEl) }; + JS.exec(e, type, phxEvent, view, targetEl, ["push", { data }]); + } + }, + ); + this.bind( + { blur: "blur", focus: "focus" }, + (e, type, view, targetEl, phxEvent, phxTarget) => { + // blur and focus are triggered on document and window. Discard one to avoid dups + if (phxTarget === "window") { + const data = this.eventMeta(type, e, targetEl); + JS.exec(e, type, phxEvent, view, targetEl, ["push", { data }]); + } + }, + ); + this.on("dragover", (e) => e.preventDefault()); + this.on("drop", (e) => { + e.preventDefault(); + const dropTargetId = maybe( + closestPhxBinding(e.target, this.binding(PHX_DROP_TARGET)), + (trueTarget) => { + return trueTarget.getAttribute(this.binding(PHX_DROP_TARGET)); + }, + ); + const dropTarget = dropTargetId && document.getElementById(dropTargetId); + const files = Array.from(e.dataTransfer.files || []); + if ( + !dropTarget || + !(dropTarget instanceof HTMLInputElement) || + dropTarget.disabled || + files.length === 0 || + !(dropTarget.files instanceof FileList) + ) { + return; } - }) - this.bind({blur: "blur", focus: "focus"}, (e, type, view, targetEl, phxEvent, phxTarget) => { - // blur and focus are triggered on document and window. Discard one to avoid dups - if(phxTarget === "window"){ - const data = this.eventMeta(type, e, targetEl) - JS.exec(e, type, phxEvent, view, targetEl, ["push", {data}]) + + LiveUploader.trackFiles(dropTarget, files, e.dataTransfer); + dropTarget.dispatchEvent(new Event("input", { bubbles: true })); + }); + this.on(PHX_TRACK_UPLOADS, (e) => { + const uploadTarget = e.target; + if (!DOM.isUploadInput(uploadTarget)) { + return; } - }) - this.on("dragover", e => e.preventDefault()) - this.on("drop", e => { - e.preventDefault() - const dropTargetId = maybe(closestPhxBinding(e.target, this.binding(PHX_DROP_TARGET)), trueTarget => { - return trueTarget.getAttribute(this.binding(PHX_DROP_TARGET)) - }) - const dropTarget = dropTargetId && document.getElementById(dropTargetId) - const files = Array.from(e.dataTransfer.files || []) - if(!dropTarget || !(dropTarget instanceof HTMLInputElement) || dropTarget.disabled || files.length === 0 || !(dropTarget.files instanceof FileList)){ return } - - LiveUploader.trackFiles(dropTarget, files, e.dataTransfer) - dropTarget.dispatchEvent(new Event("input", {bubbles: true})) - }) - this.on(PHX_TRACK_UPLOADS, e => { - const uploadTarget = e.target - if(!DOM.isUploadInput(uploadTarget)){ return } - const files = Array.from(e.detail.files || []).filter(f => f instanceof File || f instanceof Blob) - LiveUploader.trackFiles(uploadTarget, files) - uploadTarget.dispatchEvent(new Event("input", {bubbles: true})) - }) - } - - eventMeta(eventName, e, targetEl){ - const callback = this.metadataCallbacks[eventName] - return callback ? callback(e, targetEl) : {} - } - - setPendingLink(href){ - this.linkRef++ - this.pendingLink = href - this.resetReloadStatus() - return this.linkRef + const files = Array.from(e.detail.files || []).filter( + (f) => f instanceof File || f instanceof Blob, + ); + LiveUploader.trackFiles(uploadTarget, files); + uploadTarget.dispatchEvent(new Event("input", { bubbles: true })); + }); + } + + eventMeta(eventName, e, targetEl) { + const callback = this.metadataCallbacks[eventName]; + return callback ? callback(e, targetEl) : {}; + } + + setPendingLink(href) { + this.linkRef++; + this.pendingLink = href; + this.resetReloadStatus(); + return this.linkRef; } // anytime we are navigating or connecting, drop reload cookie in case // we issue the cookie but the next request was interrupted and the server never dropped it - resetReloadStatus(){ Browser.deleteCookie(PHX_RELOAD_STATUS) } + resetReloadStatus() { + Browser.deleteCookie(PHX_RELOAD_STATUS); + } - commitPendingLink(linkRef){ - if(this.linkRef !== linkRef){ - return false + commitPendingLink(linkRef) { + if (this.linkRef !== linkRef) { + return false; } else { - this.href = this.pendingLink - this.pendingLink = null - return true + this.href = this.pendingLink; + this.pendingLink = null; + return true; } } - getHref(){ return this.href } + getHref() { + return this.href; + } - hasPendingLink(){ return !!this.pendingLink } + hasPendingLink() { + return !!this.pendingLink; + } - bind(events, callback){ - for(const event in events){ - const browserEventName = events[event] + bind(events, callback) { + for (const event in events) { + const browserEventName = events[event]; - this.on(browserEventName, e => { - const binding = this.binding(event) - const windowBinding = this.binding(`window-${event}`) - const targetPhxEvent = e.target.getAttribute && e.target.getAttribute(binding) - if(targetPhxEvent){ + this.on(browserEventName, (e) => { + const binding = this.binding(event); + const windowBinding = this.binding(`window-${event}`); + const targetPhxEvent = + e.target.getAttribute && e.target.getAttribute(binding); + if (targetPhxEvent) { this.debounce(e.target, e, browserEventName, () => { - this.withinOwners(e.target, view => { - callback(e, event, view, e.target, targetPhxEvent, null) - }) - }) + this.withinOwners(e.target, (view) => { + callback(e, event, view, e.target, targetPhxEvent, null); + }); + }); } else { - DOM.all(document, `[${windowBinding}]`, el => { - const phxEvent = el.getAttribute(windowBinding) + DOM.all(document, `[${windowBinding}]`, (el) => { + const phxEvent = el.getAttribute(windowBinding); this.debounce(el, e, browserEventName, () => { - this.withinOwners(el, view => { - callback(e, event, view, el, phxEvent, "window") - }) - }) - }) + this.withinOwners(el, (view) => { + callback(e, event, view, el, phxEvent, "window"); + }); + }); + }); } - }) - } - } - - bindClicks(){ - this.on("mousedown", e => this.clickStartedAtTarget = e.target) - this.bindClick("click", "click") - } - - bindClick(eventName, bindingName){ - const click = this.binding(bindingName) - window.addEventListener(eventName, e => { - let target = null - // a synthetic click event (detail 0) will not have caused a mousedown event, - // therefore the clickStartedAtTarget is stale - if(e.detail === 0) this.clickStartedAtTarget = e.target - const clickStartedAtTarget = this.clickStartedAtTarget || e.target - // when searching the target for the click event, we always want to - // use the actual event target, see #3372 - target = closestPhxBinding(e.target, click) - this.dispatchClickAway(e, clickStartedAtTarget) - this.clickStartedAtTarget = null - const phxEvent = target && target.getAttribute(click) - if(!phxEvent){ - if(DOM.isNewPageClick(e, window.location)){ this.unload() } - return - } + }); + } + } + + bindClicks() { + this.on("mousedown", (e) => (this.clickStartedAtTarget = e.target)); + this.bindClick("click", "click"); + } - if(target.getAttribute("href") === "#"){ e.preventDefault() } + bindClick(eventName, bindingName) { + const click = this.binding(bindingName); + window.addEventListener( + eventName, + (e) => { + let target = null; + // a synthetic click event (detail 0) will not have caused a mousedown event, + // therefore the clickStartedAtTarget is stale + if (e.detail === 0) this.clickStartedAtTarget = e.target; + const clickStartedAtTarget = this.clickStartedAtTarget || e.target; + // when searching the target for the click event, we always want to + // use the actual event target, see #3372 + target = closestPhxBinding(e.target, click); + this.dispatchClickAway(e, clickStartedAtTarget); + this.clickStartedAtTarget = null; + const phxEvent = target && target.getAttribute(click); + if (!phxEvent) { + if (DOM.isNewPageClick(e, window.location)) { + this.unload(); + } + return; + } + + if (target.getAttribute("href") === "#") { + e.preventDefault(); + } - // noop if we are in the middle of awaiting an ack for this el already - if(target.hasAttribute(PHX_REF_SRC)){ return } + // noop if we are in the middle of awaiting an ack for this el already + if (target.hasAttribute(PHX_REF_SRC)) { + return; + } - this.debounce(target, e, "click", () => { - this.withinOwners(target, view => { - JS.exec(e, "click", phxEvent, view, target, ["push", {data: this.eventMeta("click", e, target)}]) - }) - }) - }, false) + this.debounce(target, e, "click", () => { + this.withinOwners(target, (view) => { + JS.exec(e, "click", phxEvent, view, target, [ + "push", + { data: this.eventMeta("click", e, target) }, + ]); + }); + }); + }, + false, + ); } - dispatchClickAway(e, clickStartedAt){ - const phxClickAway = this.binding("click-away") - DOM.all(document, `[${phxClickAway}]`, el => { - if(!(el.isSameNode(clickStartedAt) || el.contains(clickStartedAt))){ - this.withinOwners(el, view => { - const phxEvent = el.getAttribute(phxClickAway) - if(JS.isVisible(el) && JS.isInViewport(el)){ - JS.exec(e, "click", phxEvent, view, el, ["push", {data: this.eventMeta("click", e, e.target)}]) + dispatchClickAway(e, clickStartedAt) { + const phxClickAway = this.binding("click-away"); + DOM.all(document, `[${phxClickAway}]`, (el) => { + if (!(el.isSameNode(clickStartedAt) || el.contains(clickStartedAt))) { + this.withinOwners(el, (view) => { + const phxEvent = el.getAttribute(phxClickAway); + if (JS.isVisible(el) && JS.isInViewport(el)) { + JS.exec(e, "click", phxEvent, view, el, [ + "push", + { data: this.eventMeta("click", e, e.target) }, + ]); } - }) + }); } - }) + }); } - bindNav(){ - if(!Browser.canPushState()){ return } - if(history.scrollRestoration){ history.scrollRestoration = "manual" } - let scrollTimer = null - window.addEventListener("scroll", _e => { - clearTimeout(scrollTimer) + bindNav() { + if (!Browser.canPushState()) { + return; + } + if (history.scrollRestoration) { + history.scrollRestoration = "manual"; + } + let scrollTimer = null; + window.addEventListener("scroll", (_e) => { + clearTimeout(scrollTimer); scrollTimer = setTimeout(() => { - Browser.updateCurrentState(state => Object.assign(state, {scroll: window.scrollY})) - }, 100) - }) - window.addEventListener("popstate", event => { - if(!this.registerNewLocation(window.location)){ return } - const {type, backType, id, scroll, position} = event.state || {} - const href = window.location.href - - // Compare positions to determine direction - const isForward = position > this.currentHistoryPosition - const navType = isForward ? type : (backType || type) - - // Update current position - this.currentHistoryPosition = position || 0 - this.sessionStorage.setItem(PHX_LV_HISTORY_POSITION, this.currentHistoryPosition.toString()) - - DOM.dispatchEvent(window, "phx:navigate", {detail: {href, patch: navType === "patch", pop: true, direction: isForward ? "forward" : "backward"}}) - this.requestDOMUpdate(() => { - const callback = () => { this.maybeScroll(scroll) } - if(this.main.isConnected() && (navType === "patch" && id === this.main.id)){ - this.main.pushLinkPatch(event, href, null, callback) - } else { - this.replaceMain(href, null, callback) + Browser.updateCurrentState((state) => + Object.assign(state, { scroll: window.scrollY }), + ); + }, 100); + }); + window.addEventListener( + "popstate", + (event) => { + if (!this.registerNewLocation(window.location)) { + return; } - }) - }, false) - window.addEventListener("click", e => { - const target = closestPhxBinding(e.target, PHX_LIVE_LINK) - const type = target && target.getAttribute(PHX_LIVE_LINK) - if(!type || !this.isConnected() || !this.main || DOM.wantsNewTab(e)){ return } - - // When wrapping an SVG element in an anchor tag, the href can be an SVGAnimatedString - const href = target.href instanceof SVGAnimatedString ? target.href.baseVal : target.href - - const linkState = target.getAttribute(PHX_LINK_STATE) - e.preventDefault() - e.stopImmediatePropagation() // do not bubble click to regular phx-click bindings - if(this.pendingLink === href){ return } - - this.requestDOMUpdate(() => { - if(type === "patch"){ - this.pushHistoryPatch(e, href, linkState, target) - } else if(type === "redirect"){ - this.historyRedirect(e, href, linkState, null, target) - } else { - throw new Error(`expected ${PHX_LIVE_LINK} to be "patch" or "redirect", got: ${type}`) + const { type, backType, id, scroll, position } = event.state || {}; + const href = window.location.href; + + // Compare positions to determine direction + const isForward = position > this.currentHistoryPosition; + const navType = isForward ? type : backType || type; + + // Update current position + this.currentHistoryPosition = position || 0; + this.sessionStorage.setItem( + PHX_LV_HISTORY_POSITION, + this.currentHistoryPosition.toString(), + ); + + DOM.dispatchEvent(window, "phx:navigate", { + detail: { + href, + patch: navType === "patch", + pop: true, + direction: isForward ? "forward" : "backward", + }, + }); + this.requestDOMUpdate(() => { + const callback = () => { + this.maybeScroll(scroll); + }; + if ( + this.main.isConnected() && + navType === "patch" && + id === this.main.id + ) { + this.main.pushLinkPatch(event, href, null, callback); + } else { + this.replaceMain(href, null, callback); + } + }); + }, + false, + ); + window.addEventListener( + "click", + (e) => { + const target = closestPhxBinding(e.target, PHX_LIVE_LINK); + const type = target && target.getAttribute(PHX_LIVE_LINK); + if (!type || !this.isConnected() || !this.main || DOM.wantsNewTab(e)) { + return; } - const phxClick = target.getAttribute(this.binding("click")) - if(phxClick){ - this.requestDOMUpdate(() => this.execJS(target, phxClick, "click")) + + // When wrapping an SVG element in an anchor tag, the href can be an SVGAnimatedString + const href = + target.href instanceof SVGAnimatedString + ? target.href.baseVal + : target.href; + + const linkState = target.getAttribute(PHX_LINK_STATE); + e.preventDefault(); + e.stopImmediatePropagation(); // do not bubble click to regular phx-click bindings + if (this.pendingLink === href) { + return; } - }) - }, false) + + this.requestDOMUpdate(() => { + if (type === "patch") { + this.pushHistoryPatch(e, href, linkState, target); + } else if (type === "redirect") { + this.historyRedirect(e, href, linkState, null, target); + } else { + throw new Error( + `expected ${PHX_LIVE_LINK} to be "patch" or "redirect", got: ${type}`, + ); + } + const phxClick = target.getAttribute(this.binding("click")); + if (phxClick) { + this.requestDOMUpdate(() => this.execJS(target, phxClick, "click")); + } + }); + }, + false, + ); } - maybeScroll(scroll){ - if(typeof(scroll) === "number"){ + maybeScroll(scroll) { + if (typeof scroll === "number") { requestAnimationFrame(() => { - window.scrollTo(0, scroll) - }) // the body needs to render before we scroll. + window.scrollTo(0, scroll); + }); // the body needs to render before we scroll. } } - dispatchEvent(event, payload = {}){ - DOM.dispatchEvent(window, `phx:${event}`, {detail: payload}) + dispatchEvent(event, payload = {}) { + DOM.dispatchEvent(window, `phx:${event}`, { detail: payload }); } - dispatchEvents(events){ - events.forEach(([event, payload]) => this.dispatchEvent(event, payload)) + dispatchEvents(events) { + events.forEach(([event, payload]) => this.dispatchEvent(event, payload)); } - withPageLoading(info, callback){ - DOM.dispatchEvent(window, "phx:page-loading-start", {detail: info}) - const done = () => DOM.dispatchEvent(window, "phx:page-loading-stop", {detail: info}) - return callback ? callback(done) : done + withPageLoading(info, callback) { + DOM.dispatchEvent(window, "phx:page-loading-start", { detail: info }); + const done = () => + DOM.dispatchEvent(window, "phx:page-loading-stop", { detail: info }); + return callback ? callback(done) : done; } - pushHistoryPatch(e, href, linkState, targetEl){ - if(!this.isConnected() || !this.main.isMain()){ return Browser.redirect(href) } + pushHistoryPatch(e, href, linkState, targetEl) { + if (!this.isConnected() || !this.main.isMain()) { + return Browser.redirect(href); + } - this.withPageLoading({to: href, kind: "patch"}, done => { - this.main.pushLinkPatch(e, href, targetEl, linkRef => { - this.historyPatch(href, linkState, linkRef) - done() - }) - }) + this.withPageLoading({ to: href, kind: "patch" }, (done) => { + this.main.pushLinkPatch(e, href, targetEl, (linkRef) => { + this.historyPatch(href, linkState, linkRef); + done(); + }); + }); } - historyPatch(href, linkState, linkRef = this.setPendingLink(href)){ - if(!this.commitPendingLink(linkRef)){ return } + historyPatch(href, linkState, linkRef = this.setPendingLink(href)) { + if (!this.commitPendingLink(linkRef)) { + return; + } // Increment position for new state - this.currentHistoryPosition++ - this.sessionStorage.setItem(PHX_LV_HISTORY_POSITION, this.currentHistoryPosition.toString()) + this.currentHistoryPosition++; + this.sessionStorage.setItem( + PHX_LV_HISTORY_POSITION, + this.currentHistoryPosition.toString(), + ); // store the type for back navigation - Browser.updateCurrentState((state) => ({...state, backType: "patch"})) - - Browser.pushState(linkState, { - type: "patch", - id: this.main.id, - position: this.currentHistoryPosition - }, href) - - DOM.dispatchEvent(window, "phx:navigate", {detail: {patch: true, href, pop: false, direction: "forward"}}) - this.registerNewLocation(window.location) + Browser.updateCurrentState((state) => ({ ...state, backType: "patch" })); + + Browser.pushState( + linkState, + { + type: "patch", + id: this.main.id, + position: this.currentHistoryPosition, + }, + href, + ); + + DOM.dispatchEvent(window, "phx:navigate", { + detail: { patch: true, href, pop: false, direction: "forward" }, + }); + this.registerNewLocation(window.location); } - historyRedirect(e, href, linkState, flash, targetEl){ - const clickLoading = targetEl && e.isTrusted && e.type !== "popstate" - if(clickLoading){ targetEl.classList.add("phx-click-loading") } - if(!this.isConnected() || !this.main.isMain()){ return Browser.redirect(href, flash) } + historyRedirect(e, href, linkState, flash, targetEl) { + const clickLoading = targetEl && e.isTrusted && e.type !== "popstate"; + if (clickLoading) { + targetEl.classList.add("phx-click-loading"); + } + if (!this.isConnected() || !this.main.isMain()) { + return Browser.redirect(href, flash); + } // convert to full href if only path prefix - if(/^\/$|^\/[^\/]+.*$/.test(href)){ - const {protocol, host} = window.location - href = `${protocol}//${host}${href}` + if (/^\/$|^\/[^\/]+.*$/.test(href)) { + const { protocol, host } = window.location; + href = `${protocol}//${host}${href}`; } - const scroll = window.scrollY - this.withPageLoading({to: href, kind: "redirect"}, done => { + const scroll = window.scrollY; + this.withPageLoading({ to: href, kind: "redirect" }, (done) => { this.replaceMain(href, flash, (linkRef) => { - if(linkRef === this.linkRef){ + if (linkRef === this.linkRef) { // Increment position for new state - this.currentHistoryPosition++ - this.sessionStorage.setItem(PHX_LV_HISTORY_POSITION, this.currentHistoryPosition.toString()) + this.currentHistoryPosition++; + this.sessionStorage.setItem( + PHX_LV_HISTORY_POSITION, + this.currentHistoryPosition.toString(), + ); // store the type for back navigation - Browser.updateCurrentState((state) => ({...state, backType: "redirect"})) - - Browser.pushState(linkState, { - type: "redirect", - id: this.main.id, - scroll: scroll, - position: this.currentHistoryPosition - }, href) - - DOM.dispatchEvent(window, "phx:navigate", {detail: {href, patch: false, pop: false, direction: "forward"}}) - this.registerNewLocation(window.location) + Browser.updateCurrentState((state) => ({ + ...state, + backType: "redirect", + })); + + Browser.pushState( + linkState, + { + type: "redirect", + id: this.main.id, + scroll: scroll, + position: this.currentHistoryPosition, + }, + href, + ); + + DOM.dispatchEvent(window, "phx:navigate", { + detail: { href, patch: false, pop: false, direction: "forward" }, + }); + this.registerNewLocation(window.location); } // explicitly undo click-loading class // (in case it originated in a sticky live view, otherwise it would be removed anyway) - if(clickLoading){ targetEl.classList.remove("phx-click-loading") } - done() - }) - }) + if (clickLoading) { + targetEl.classList.remove("phx-click-loading"); + } + done(); + }); + }); } - registerNewLocation(newLocation){ - const {pathname, search} = this.currentLocation - if(pathname + search === newLocation.pathname + newLocation.search){ - return false + registerNewLocation(newLocation) { + const { pathname, search } = this.currentLocation; + if (pathname + search === newLocation.pathname + newLocation.search) { + return false; } else { - this.currentLocation = clone(newLocation) - return true + this.currentLocation = clone(newLocation); + return true; } } - bindForms(){ - let iterations = 0 - let externalFormSubmitted = false + bindForms() { + let iterations = 0; + let externalFormSubmitted = false; // disable forms on submit that track phx-change but perform external submit - this.on("submit", e => { - const phxSubmit = e.target.getAttribute(this.binding("submit")) - const phxChange = e.target.getAttribute(this.binding("change")) - if(!externalFormSubmitted && phxChange && !phxSubmit){ - externalFormSubmitted = true - e.preventDefault() - this.withinOwners(e.target, view => { - view.disableForm(e.target) + this.on("submit", (e) => { + const phxSubmit = e.target.getAttribute(this.binding("submit")); + const phxChange = e.target.getAttribute(this.binding("change")); + if (!externalFormSubmitted && phxChange && !phxSubmit) { + externalFormSubmitted = true; + e.preventDefault(); + this.withinOwners(e.target, (view) => { + view.disableForm(e.target); // safari needs next tick window.requestAnimationFrame(() => { - if(DOM.isUnloadableFormSubmit(e)){ this.unload() } - e.target.submit() - }) - }) + if (DOM.isUnloadableFormSubmit(e)) { + this.unload(); + } + e.target.submit(); + }); + }); } - }) + }); - this.on("submit", e => { - const phxEvent = e.target.getAttribute(this.binding("submit")) - if(!phxEvent){ - if(DOM.isUnloadableFormSubmit(e)){ this.unload() } - return + this.on("submit", (e) => { + const phxEvent = e.target.getAttribute(this.binding("submit")); + if (!phxEvent) { + if (DOM.isUnloadableFormSubmit(e)) { + this.unload(); + } + return; } - e.preventDefault() - e.target.disabled = true - this.withinOwners(e.target, view => { - JS.exec(e, "submit", phxEvent, view, e.target, ["push", {submitter: e.submitter}]) - }) - }) - - for(const type of ["change", "input"]){ - this.on(type, e => { - if(e instanceof CustomEvent && (e.target instanceof HTMLInputElement || e.target instanceof HTMLSelectElement || e.target instanceof HTMLTextAreaElement) && e.target.form === undefined){ + e.preventDefault(); + e.target.disabled = true; + this.withinOwners(e.target, (view) => { + JS.exec(e, "submit", phxEvent, view, e.target, [ + "push", + { submitter: e.submitter }, + ]); + }); + }); + + for (const type of ["change", "input"]) { + this.on(type, (e) => { + if ( + e instanceof CustomEvent && + (e.target instanceof HTMLInputElement || + e.target instanceof HTMLSelectElement || + e.target instanceof HTMLTextAreaElement) && + e.target.form === undefined + ) { // throw on invalid JS.dispatch target and noop if CustomEvent triggered outside JS.dispatch - if(e.detail && e.detail.dispatcher){ - throw new Error(`dispatching a custom ${type} event is only supported on input elements inside a form`) + if (e.detail && e.detail.dispatcher) { + throw new Error( + `dispatching a custom ${type} event is only supported on input elements inside a form`, + ); } - return + return; } - const phxChange = this.binding("change") - const input = e.target + const phxChange = this.binding("change"); + const input = e.target; // do not fire phx-change if we are in the middle of a composition session // https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/isComposing // Safari has issues if the input is updated while composing // see https://github.com/phoenixframework/phoenix_live_view/issues/3322 - if(e.isComposing){ - const key = `composition-listener-${type}` - if(!DOM.private(input, key)){ - DOM.putPrivate(input, key, true) - input.addEventListener("compositionend", () => { - // trigger a new input/change event - input.dispatchEvent(new Event(type, {bubbles: true})) - DOM.deletePrivate(input, key) - }, {once: true}) + if (e.isComposing) { + const key = `composition-listener-${type}`; + if (!DOM.private(input, key)) { + DOM.putPrivate(input, key, true); + input.addEventListener( + "compositionend", + () => { + // trigger a new input/change event + input.dispatchEvent(new Event(type, { bubbles: true })); + DOM.deletePrivate(input, key); + }, + { once: true }, + ); } - return + return; + } + const inputEvent = input.getAttribute(phxChange); + const formEvent = input.form && input.form.getAttribute(phxChange); + const phxEvent = inputEvent || formEvent; + if (!phxEvent) { + return; } - const inputEvent = input.getAttribute(phxChange) - const formEvent = input.form && input.form.getAttribute(phxChange) - const phxEvent = inputEvent || formEvent - if(!phxEvent){ return } - if(input.type === "number" && input.validity && input.validity.badInput){ return } - - const dispatcher = inputEvent ? input : input.form - const currentIterations = iterations - iterations++ - const {at: at, type: lastType} = DOM.private(input, "prev-iteration") || {} + if ( + input.type === "number" && + input.validity && + input.validity.badInput + ) { + return; + } + + const dispatcher = inputEvent ? input : input.form; + const currentIterations = iterations; + iterations++; + const { at: at, type: lastType } = + DOM.private(input, "prev-iteration") || {}; // Browsers should always fire at least one "input" event before every "change" // Ignore "change" events, unless there was no prior "input" event. // This could happen if user code triggers a "change" event, or if the browser is non-conforming. - if(at === currentIterations - 1 && type === "change" && lastType === "input"){ return } + if ( + at === currentIterations - 1 && + type === "change" && + lastType === "input" + ) { + return; + } - DOM.putPrivate(input, "prev-iteration", {at: currentIterations, type: type}) + DOM.putPrivate(input, "prev-iteration", { + at: currentIterations, + type: type, + }); this.debounce(input, e, type, () => { - this.withinOwners(dispatcher, view => { - DOM.putPrivate(input, PHX_HAS_FOCUSED, true) - JS.exec(e, "change", phxEvent, view, input, ["push", {_target: e.target.name, dispatcher: dispatcher}]) - }) - }) - }) + this.withinOwners(dispatcher, (view) => { + DOM.putPrivate(input, PHX_HAS_FOCUSED, true); + JS.exec(e, "change", phxEvent, view, input, [ + "push", + { _target: e.target.name, dispatcher: dispatcher }, + ]); + }); + }); + }); } this.on("reset", (e) => { - const form = e.target - DOM.resetForm(form) - const input = Array.from(form.elements).find(el => el.type === "reset") - if(input){ + const form = e.target; + DOM.resetForm(form); + const input = Array.from(form.elements).find((el) => el.type === "reset"); + if (input) { // wait until next tick to get updated input value window.requestAnimationFrame(() => { - input.dispatchEvent(new Event("input", {bubbles: true, cancelable: false})) - }) + input.dispatchEvent( + new Event("input", { bubbles: true, cancelable: false }), + ); + }); } - }) + }); } - debounce(el, event, eventType, callback){ - if(eventType === "blur" || eventType === "focusout"){ return callback() } - - const phxDebounce = this.binding(PHX_DEBOUNCE) - const phxThrottle = this.binding(PHX_THROTTLE) - const defaultDebounce = this.defaults.debounce.toString() - const defaultThrottle = this.defaults.throttle.toString() + debounce(el, event, eventType, callback) { + if (eventType === "blur" || eventType === "focusout") { + return callback(); + } - this.withinOwners(el, view => { - const asyncFilter = () => !view.isDestroyed() && document.body.contains(el) - DOM.debounce(el, event, phxDebounce, defaultDebounce, phxThrottle, defaultThrottle, asyncFilter, () => { - callback() - }) - }) + const phxDebounce = this.binding(PHX_DEBOUNCE); + const phxThrottle = this.binding(PHX_THROTTLE); + const defaultDebounce = this.defaults.debounce.toString(); + const defaultThrottle = this.defaults.throttle.toString(); + + this.withinOwners(el, (view) => { + const asyncFilter = () => + !view.isDestroyed() && document.body.contains(el); + DOM.debounce( + el, + event, + phxDebounce, + defaultDebounce, + phxThrottle, + defaultThrottle, + asyncFilter, + () => { + callback(); + }, + ); + }); } - silenceEvents(callback){ - this.silenced = true - callback() - this.silenced = false + silenceEvents(callback) { + this.silenced = true; + callback(); + this.silenced = false; } - on(event, callback){ - this.boundEventNames.add(event) - window.addEventListener(event, e => { - if(!this.silenced){ callback(e) } - }) + on(event, callback) { + this.boundEventNames.add(event); + window.addEventListener(event, (e) => { + if (!this.silenced) { + callback(e); + } + }); } - jsQuerySelectorAll(sourceEl, query, defaultQuery){ - const all = this.domCallbacks.jsQuerySelectorAll - return all ? all(sourceEl, query, defaultQuery) : defaultQuery() + jsQuerySelectorAll(sourceEl, query, defaultQuery) { + const all = this.domCallbacks.jsQuerySelectorAll; + return all ? all(sourceEl, query, defaultQuery) : defaultQuery(); } } class TransitionSet { - constructor(){ - this.transitions = new Set() - this.pendingOps = [] + constructor() { + this.transitions = new Set(); + this.pendingOps = []; } - reset(){ - this.transitions.forEach(timer => { - clearTimeout(timer) - this.transitions.delete(timer) - }) - this.flushPendingOps() + reset() { + this.transitions.forEach((timer) => { + clearTimeout(timer); + this.transitions.delete(timer); + }); + this.flushPendingOps(); } - after(callback){ - if(this.size() === 0){ - callback() + after(callback) { + if (this.size() === 0) { + callback(); } else { - this.pushPendingOp(callback) + this.pushPendingOp(callback); } } - addTransition(time, onStart, onDone){ - onStart() + addTransition(time, onStart, onDone) { + onStart(); const timer = setTimeout(() => { - this.transitions.delete(timer) - onDone() - this.flushPendingOps() - }, time) - this.transitions.add(timer) + this.transitions.delete(timer); + onDone(); + this.flushPendingOps(); + }, time); + this.transitions.add(timer); } - pushPendingOp(op){ this.pendingOps.push(op) } + pushPendingOp(op) { + this.pendingOps.push(op); + } - size(){ return this.transitions.size } + size() { + return this.transitions.size; + } - flushPendingOps(){ - if(this.size() > 0){ return } - const op = this.pendingOps.shift() - if(op){ - op() - this.flushPendingOps() + flushPendingOps() { + if (this.size() > 0) { + return; + } + const op = this.pendingOps.shift(); + if (op) { + op(); + this.flushPendingOps(); } } } diff --git a/assets/js/phoenix_live_view/live_uploader.js b/assets/js/phoenix_live_view/live_uploader.js index 720f5a90ec..34b46b9bf6 100644 --- a/assets/js/phoenix_live_view/live_uploader.js +++ b/assets/js/phoenix_live_view/live_uploader.js @@ -1,70 +1,80 @@ import { PHX_DONE_REFS, PHX_PREFLIGHTED_REFS, - PHX_UPLOAD_REF -} from "./constants" + PHX_UPLOAD_REF, +} from "./constants"; -import { -} from "./utils" +import {} from "./utils"; -import DOM from "./dom" -import UploadEntry from "./upload_entry" +import DOM from "./dom"; +import UploadEntry from "./upload_entry"; -let liveUploaderFileRef = 0 +let liveUploaderFileRef = 0; export default class LiveUploader { - static genFileRef(file){ - const ref = file._phxRef - if(ref !== undefined){ - return ref + static genFileRef(file) { + const ref = file._phxRef; + if (ref !== undefined) { + return ref; } else { - file._phxRef = (liveUploaderFileRef++).toString() - return file._phxRef + file._phxRef = (liveUploaderFileRef++).toString(); + return file._phxRef; } } - static getEntryDataURL(inputEl, ref, callback){ - const file = this.activeFiles(inputEl).find(file => this.genFileRef(file) === ref) - callback(URL.createObjectURL(file)) + static getEntryDataURL(inputEl, ref, callback) { + const file = this.activeFiles(inputEl).find( + (file) => this.genFileRef(file) === ref, + ); + callback(URL.createObjectURL(file)); } - static hasUploadsInProgress(formEl){ - let active = 0 - DOM.findUploadInputs(formEl).forEach(input => { - if(input.getAttribute(PHX_PREFLIGHTED_REFS) !== input.getAttribute(PHX_DONE_REFS)){ - active++ + static hasUploadsInProgress(formEl) { + let active = 0; + DOM.findUploadInputs(formEl).forEach((input) => { + if ( + input.getAttribute(PHX_PREFLIGHTED_REFS) !== + input.getAttribute(PHX_DONE_REFS) + ) { + active++; } - }) - return active > 0 + }); + return active > 0; } - static serializeUploads(inputEl){ - const files = this.activeFiles(inputEl) - const fileData = {} - files.forEach(file => { - const entry = {path: inputEl.name} - const uploadRef = inputEl.getAttribute(PHX_UPLOAD_REF) - fileData[uploadRef] = fileData[uploadRef] || [] - entry.ref = this.genFileRef(file) - entry.last_modified = file.lastModified - entry.name = file.name || entry.ref - entry.relative_path = file.webkitRelativePath - entry.type = file.type - entry.size = file.size - if(typeof(file.meta) === "function"){ entry.meta = file.meta() } - fileData[uploadRef].push(entry) - }) - return fileData + static serializeUploads(inputEl) { + const files = this.activeFiles(inputEl); + const fileData = {}; + files.forEach((file) => { + const entry = { path: inputEl.name }; + const uploadRef = inputEl.getAttribute(PHX_UPLOAD_REF); + fileData[uploadRef] = fileData[uploadRef] || []; + entry.ref = this.genFileRef(file); + entry.last_modified = file.lastModified; + entry.name = file.name || entry.ref; + entry.relative_path = file.webkitRelativePath; + entry.type = file.type; + entry.size = file.size; + if (typeof file.meta === "function") { + entry.meta = file.meta(); + } + fileData[uploadRef].push(entry); + }); + return fileData; } - static clearFiles(inputEl){ - inputEl.value = null - inputEl.removeAttribute(PHX_UPLOAD_REF) - DOM.putPrivate(inputEl, "files", []) + static clearFiles(inputEl) { + inputEl.value = null; + inputEl.removeAttribute(PHX_UPLOAD_REF); + DOM.putPrivate(inputEl, "files", []); } - static untrackFile(inputEl, file){ - DOM.putPrivate(inputEl, "files", DOM.private(inputEl, "files").filter(f => !Object.is(f, file))) + static untrackFile(inputEl, file) { + DOM.putPrivate( + inputEl, + "files", + DOM.private(inputEl, "files").filter((f) => !Object.is(f, file)), + ); } /** @@ -72,85 +82,110 @@ export default class LiveUploader { * @param {Array} files * @param {DataTransfer} [dataTransfer] */ - static trackFiles(inputEl, files, dataTransfer){ - if(inputEl.getAttribute("multiple") !== null){ - const newFiles = files.filter(file => !this.activeFiles(inputEl).find(f => Object.is(f, file))) - DOM.updatePrivate(inputEl, "files", [], (existing) => existing.concat(newFiles)) - inputEl.value = null + static trackFiles(inputEl, files, dataTransfer) { + if (inputEl.getAttribute("multiple") !== null) { + const newFiles = files.filter( + (file) => !this.activeFiles(inputEl).find((f) => Object.is(f, file)), + ); + DOM.updatePrivate(inputEl, "files", [], (existing) => + existing.concat(newFiles), + ); + inputEl.value = null; } else { // Reset inputEl files to align output with programmatic changes (i.e. drag and drop) - if(dataTransfer && dataTransfer.files.length > 0){ inputEl.files = dataTransfer.files } - DOM.putPrivate(inputEl, "files", files) + if (dataTransfer && dataTransfer.files.length > 0) { + inputEl.files = dataTransfer.files; + } + DOM.putPrivate(inputEl, "files", files); } } - static activeFileInputs(formEl){ - const fileInputs = DOM.findUploadInputs(formEl) - return Array.from(fileInputs).filter(el => el.files && this.activeFiles(el).length > 0) + static activeFileInputs(formEl) { + const fileInputs = DOM.findUploadInputs(formEl); + return Array.from(fileInputs).filter( + (el) => el.files && this.activeFiles(el).length > 0, + ); } - static activeFiles(input){ - return (DOM.private(input, "files") || []).filter(f => UploadEntry.isActive(input, f)) + static activeFiles(input) { + return (DOM.private(input, "files") || []).filter((f) => + UploadEntry.isActive(input, f), + ); } - static inputsAwaitingPreflight(formEl){ - const fileInputs = DOM.findUploadInputs(formEl) - return Array.from(fileInputs).filter(input => this.filesAwaitingPreflight(input).length > 0) + static inputsAwaitingPreflight(formEl) { + const fileInputs = DOM.findUploadInputs(formEl); + return Array.from(fileInputs).filter( + (input) => this.filesAwaitingPreflight(input).length > 0, + ); } - static filesAwaitingPreflight(input){ - return this.activeFiles(input).filter(f => !UploadEntry.isPreflighted(input, f) && !UploadEntry.isPreflightInProgress(f)) + static filesAwaitingPreflight(input) { + return this.activeFiles(input).filter( + (f) => + !UploadEntry.isPreflighted(input, f) && + !UploadEntry.isPreflightInProgress(f), + ); } - static markPreflightInProgress(entries){ - entries.forEach(entry => UploadEntry.markPreflightInProgress(entry.file)) + static markPreflightInProgress(entries) { + entries.forEach((entry) => UploadEntry.markPreflightInProgress(entry.file)); } - constructor(inputEl, view, onComplete){ - this.autoUpload = DOM.isAutoUpload(inputEl) - this.view = view - this.onComplete = onComplete - this._entries = - Array.from(LiveUploader.filesAwaitingPreflight(inputEl) || []) - .map(file => new UploadEntry(inputEl, file, view, this.autoUpload)) + constructor(inputEl, view, onComplete) { + this.autoUpload = DOM.isAutoUpload(inputEl); + this.view = view; + this.onComplete = onComplete; + this._entries = Array.from( + LiveUploader.filesAwaitingPreflight(inputEl) || [], + ).map((file) => new UploadEntry(inputEl, file, view, this.autoUpload)); // prevent sending duplicate preflight requests - LiveUploader.markPreflightInProgress(this._entries) + LiveUploader.markPreflightInProgress(this._entries); - this.numEntriesInProgress = this._entries.length + this.numEntriesInProgress = this._entries.length; } - isAutoUpload(){ return this.autoUpload } + isAutoUpload() { + return this.autoUpload; + } - entries(){ return this._entries } + entries() { + return this._entries; + } - initAdapterUpload(resp, onError, liveSocket){ - this._entries = - this._entries.map(entry => { - if(entry.isCancelled()){ - this.numEntriesInProgress-- - if(this.numEntriesInProgress === 0){ this.onComplete() } - } else { - entry.zipPostFlight(resp) - entry.onDone(() => { - this.numEntriesInProgress-- - if(this.numEntriesInProgress === 0){ this.onComplete() } - }) + initAdapterUpload(resp, onError, liveSocket) { + this._entries = this._entries.map((entry) => { + if (entry.isCancelled()) { + this.numEntriesInProgress--; + if (this.numEntriesInProgress === 0) { + this.onComplete(); } - return entry - }) + } else { + entry.zipPostFlight(resp); + entry.onDone(() => { + this.numEntriesInProgress--; + if (this.numEntriesInProgress === 0) { + this.onComplete(); + } + }); + } + return entry; + }); const groupedEntries = this._entries.reduce((acc, entry) => { - if(!entry.meta){ return acc } - const {name, callback} = entry.uploader(liveSocket.uploaders) - acc[name] = acc[name] || {callback: callback, entries: []} - acc[name].entries.push(entry) - return acc - }, {}) - - for(const name in groupedEntries){ - const {callback, entries} = groupedEntries[name] - callback(entries, onError, resp, liveSocket) + if (!entry.meta) { + return acc; + } + const { name, callback } = entry.uploader(liveSocket.uploaders); + acc[name] = acc[name] || { callback: callback, entries: [] }; + acc[name].entries.push(entry); + return acc; + }, {}); + + for (const name in groupedEntries) { + const { callback, entries } = groupedEntries[name]; + callback(entries, onError, resp, liveSocket); } } } diff --git a/assets/js/phoenix_live_view/rendered.js b/assets/js/phoenix_live_view/rendered.js index 3291f99cd0..3cdf101888 100644 --- a/assets/js/phoenix_live_view/rendered.js +++ b/assets/js/phoenix_live_view/rendered.js @@ -11,13 +11,9 @@ import { TITLE, STREAM, ROOT, -} from "./constants" +} from "./constants"; -import { - isObject, - logError, - isCid, -} from "./utils" +import { isObject, logError, isCid } from "./utils"; const VOID_TAGS = new Set([ "area", @@ -35,203 +31,239 @@ const VOID_TAGS = new Set([ "param", "source", "track", - "wbr" -]) -const quoteChars = new Set(["'", "\""]) + "wbr", +]); +const quoteChars = new Set(["'", '"']); export const modifyRoot = (html, attrs, clearInnerHTML) => { - let i = 0 - let insideComment = false - let beforeTag, afterTag, tag, tagNameEndsAt, id, newHTML + let i = 0; + let insideComment = false; + let beforeTag, afterTag, tag, tagNameEndsAt, id, newHTML; - const lookahead = html.match(/^(\s*(?:\s*)*)<([^\s\/>]+)/) - if(lookahead === null){ throw new Error(`malformed html ${html}`) } + const lookahead = html.match(/^(\s*(?:\s*)*)<([^\s\/>]+)/); + if (lookahead === null) { + throw new Error(`malformed html ${html}`); + } - i = lookahead[0].length - beforeTag = lookahead[1] - tag = lookahead[2] - tagNameEndsAt = i + i = lookahead[0].length; + beforeTag = lookahead[1]; + tag = lookahead[2]; + tagNameEndsAt = i; // Scan the opening tag for id, if there is any - for(i; i < html.length; i++){ - if(html.charAt(i) === ">" ){ break } - if(html.charAt(i) === "="){ - const isId = html.slice(i - 3, i) === " id" - i++ - const char = html.charAt(i) - if(quoteChars.has(char)){ - const attrStartsAt = i - i++ - for(i; i < html.length; i++){ - if(html.charAt(i) === char){ break } + for (i; i < html.length; i++) { + if (html.charAt(i) === ">") { + break; + } + if (html.charAt(i) === "=") { + const isId = html.slice(i - 3, i) === " id"; + i++; + const char = html.charAt(i); + if (quoteChars.has(char)) { + const attrStartsAt = i; + i++; + for (i; i < html.length; i++) { + if (html.charAt(i) === char) { + break; + } } - if(isId){ - id = html.slice(attrStartsAt + 1, i) - break + if (isId) { + id = html.slice(attrStartsAt + 1, i); + break; } } } } - let closeAt = html.length - 1 - insideComment = false - while(closeAt >= beforeTag.length + tag.length){ - const char = html.charAt(closeAt) - if(insideComment){ - if(char === "-" && html.slice(closeAt - 3, closeAt) === "= beforeTag.length + tag.length) { + const char = html.charAt(closeAt); + if (insideComment) { + if (char === "-" && html.slice(closeAt - 3, closeAt) === "" && html.slice(closeAt - 2, closeAt) === "--"){ - insideComment = true - closeAt -= 3 - } else if(char === ">"){ - break + } else if (char === ">" && html.slice(closeAt - 2, closeAt) === "--") { + insideComment = true; + closeAt -= 3; + } else if (char === ">") { + break; } else { - closeAt -= 1 + closeAt -= 1; } } - afterTag = html.slice(closeAt + 1, html.length) + afterTag = html.slice(closeAt + 1, html.length); - const attrsStr = - Object.keys(attrs) - .map(attr => attrs[attr] === true ? attr : `${attr}="${attrs[attr]}"`) - .join(" ") + const attrsStr = Object.keys(attrs) + .map((attr) => (attrs[attr] === true ? attr : `${attr}="${attrs[attr]}"`)) + .join(" "); - if(clearInnerHTML){ + if (clearInnerHTML) { // Keep the id if any - const idAttrStr = id ? ` id="${id}"` : "" - if(VOID_TAGS.has(tag)){ - newHTML = `<${tag}${idAttrStr}${attrsStr === "" ? "" : " "}${attrsStr}/>` + const idAttrStr = id ? ` id="${id}"` : ""; + if (VOID_TAGS.has(tag)) { + newHTML = `<${tag}${idAttrStr}${attrsStr === "" ? "" : " "}${attrsStr}/>`; } else { - newHTML = `<${tag}${idAttrStr}${attrsStr === "" ? "" : " "}${attrsStr}>` + newHTML = `<${tag}${idAttrStr}${attrsStr === "" ? "" : " "}${attrsStr}>`; } } else { - const rest = html.slice(tagNameEndsAt, closeAt + 1) - newHTML = `<${tag}${attrsStr === "" ? "" : " "}${attrsStr}${rest}` + const rest = html.slice(tagNameEndsAt, closeAt + 1); + newHTML = `<${tag}${attrsStr === "" ? "" : " "}${attrsStr}${rest}`; } - return [newHTML, beforeTag, afterTag] -} + return [newHTML, beforeTag, afterTag]; +}; export default class Rendered { - static extract(diff){ - const {[REPLY]: reply, [EVENTS]: events, [TITLE]: title} = diff - delete diff[REPLY] - delete diff[EVENTS] - delete diff[TITLE] - return {diff, title, reply: reply || null, events: events || []} + static extract(diff) { + const { [REPLY]: reply, [EVENTS]: events, [TITLE]: title } = diff; + delete diff[REPLY]; + delete diff[EVENTS]; + delete diff[TITLE]; + return { diff, title, reply: reply || null, events: events || [] }; } - constructor(viewId, rendered){ - this.viewId = viewId - this.rendered = {} - this.magicId = 0 - this.mergeDiff(rendered) + constructor(viewId, rendered) { + this.viewId = viewId; + this.rendered = {}; + this.magicId = 0; + this.mergeDiff(rendered); } - parentViewId(){ return this.viewId } + parentViewId() { + return this.viewId; + } - toString(onlyCids){ - const {buffer: str, streams: streams} = this.recursiveToString(this.rendered, this.rendered[COMPONENTS], onlyCids, true, {}) - return {buffer: str, streams: streams} + toString(onlyCids) { + const { buffer: str, streams: streams } = this.recursiveToString( + this.rendered, + this.rendered[COMPONENTS], + onlyCids, + true, + {}, + ); + return { buffer: str, streams: streams }; } - recursiveToString(rendered, components = rendered[COMPONENTS], onlyCids, changeTracking, rootAttrs){ - onlyCids = onlyCids ? new Set(onlyCids) : null - const output = {buffer: "", components: components, onlyCids: onlyCids, streams: new Set()} - this.toOutputBuffer(rendered, null, output, changeTracking, rootAttrs) - return {buffer: output.buffer, streams: output.streams} + recursiveToString( + rendered, + components = rendered[COMPONENTS], + onlyCids, + changeTracking, + rootAttrs, + ) { + onlyCids = onlyCids ? new Set(onlyCids) : null; + const output = { + buffer: "", + components: components, + onlyCids: onlyCids, + streams: new Set(), + }; + this.toOutputBuffer(rendered, null, output, changeTracking, rootAttrs); + return { buffer: output.buffer, streams: output.streams }; } - componentCIDs(diff){ return Object.keys(diff[COMPONENTS] || {}).map(i => parseInt(i)) } + componentCIDs(diff) { + return Object.keys(diff[COMPONENTS] || {}).map((i) => parseInt(i)); + } - isComponentOnlyDiff(diff){ - if(!diff[COMPONENTS]){ return false } - return Object.keys(diff).length === 1 + isComponentOnlyDiff(diff) { + if (!diff[COMPONENTS]) { + return false; + } + return Object.keys(diff).length === 1; } - getComponent(diff, cid){ return diff[COMPONENTS][cid] } + getComponent(diff, cid) { + return diff[COMPONENTS][cid]; + } - resetRender(cid){ + resetRender(cid) { // we are racing a component destroy, it could not exist, so // make sure that we don't try to set reset on undefined - if(this.rendered[COMPONENTS][cid]){ - this.rendered[COMPONENTS][cid].reset = true + if (this.rendered[COMPONENTS][cid]) { + this.rendered[COMPONENTS][cid].reset = true; } } - mergeDiff(diff){ - const newc = diff[COMPONENTS] - const cache = {} - delete diff[COMPONENTS] - this.rendered = this.mutableMerge(this.rendered, diff) - this.rendered[COMPONENTS] = this.rendered[COMPONENTS] || {} + mergeDiff(diff) { + const newc = diff[COMPONENTS]; + const cache = {}; + delete diff[COMPONENTS]; + this.rendered = this.mutableMerge(this.rendered, diff); + this.rendered[COMPONENTS] = this.rendered[COMPONENTS] || {}; - if(newc){ - const oldc = this.rendered[COMPONENTS] + if (newc) { + const oldc = this.rendered[COMPONENTS]; - for(const cid in newc){ - newc[cid] = this.cachedFindComponent(cid, newc[cid], oldc, newc, cache) + for (const cid in newc) { + newc[cid] = this.cachedFindComponent(cid, newc[cid], oldc, newc, cache); } - for(const cid in newc){ oldc[cid] = newc[cid] } - diff[COMPONENTS] = newc + for (const cid in newc) { + oldc[cid] = newc[cid]; + } + diff[COMPONENTS] = newc; } } - cachedFindComponent(cid, cdiff, oldc, newc, cache){ - if(cache[cid]){ - return cache[cid] + cachedFindComponent(cid, cdiff, oldc, newc, cache) { + if (cache[cid]) { + return cache[cid]; } else { - let ndiff, stat, scid = cdiff[STATIC] + let ndiff, + stat, + scid = cdiff[STATIC]; - if(isCid(scid)){ - let tdiff + if (isCid(scid)) { + let tdiff; - if(scid > 0){ - tdiff = this.cachedFindComponent(scid, newc[scid], oldc, newc, cache) + if (scid > 0) { + tdiff = this.cachedFindComponent(scid, newc[scid], oldc, newc, cache); } else { - tdiff = oldc[-scid] + tdiff = oldc[-scid]; } - stat = tdiff[STATIC] - ndiff = this.cloneMerge(tdiff, cdiff, true) - ndiff[STATIC] = stat + stat = tdiff[STATIC]; + ndiff = this.cloneMerge(tdiff, cdiff, true); + ndiff[STATIC] = stat; } else { - ndiff = cdiff[STATIC] !== undefined || oldc[cid] === undefined ? - cdiff : this.cloneMerge(oldc[cid], cdiff, false) + ndiff = + cdiff[STATIC] !== undefined || oldc[cid] === undefined + ? cdiff + : this.cloneMerge(oldc[cid], cdiff, false); } - cache[cid] = ndiff - return ndiff + cache[cid] = ndiff; + return ndiff; } } - mutableMerge(target, source){ - if(source[STATIC] !== undefined){ - return source + mutableMerge(target, source) { + if (source[STATIC] !== undefined) { + return source; } else { - this.doMutableMerge(target, source) - return target + this.doMutableMerge(target, source); + return target; } } - doMutableMerge(target, source){ - for(const key in source){ - const val = source[key] - const targetVal = target[key] - const isObjVal = isObject(val) - if(isObjVal && val[STATIC] === undefined && isObject(targetVal)){ - this.doMutableMerge(targetVal, val) + doMutableMerge(target, source) { + for (const key in source) { + const val = source[key]; + const targetVal = target[key]; + const isObjVal = isObject(val); + if (isObjVal && val[STATIC] === undefined && isObject(targetVal)) { + this.doMutableMerge(targetVal, val); } else { - target[key] = val + target[key] = val; } } - if(target[ROOT]){ - target.newRender = true + if (target[ROOT]) { + target.newRender = true; } } @@ -243,53 +275,61 @@ export default class Rendered { // mutableMerge, where we set newRender to true if there is a root // (effectively forcing the new version to be rendered instead of skipped) // - cloneMerge(target, source, pruneMagicId){ - const merged = {...target, ...source} - for(const key in merged){ - const val = source[key] - const targetVal = target[key] - if(isObject(val) && val[STATIC] === undefined && isObject(targetVal)){ - merged[key] = this.cloneMerge(targetVal, val, pruneMagicId) - } else if(val === undefined && isObject(targetVal)){ - merged[key] = this.cloneMerge(targetVal, {}, pruneMagicId) + cloneMerge(target, source, pruneMagicId) { + const merged = { ...target, ...source }; + for (const key in merged) { + const val = source[key]; + const targetVal = target[key]; + if (isObject(val) && val[STATIC] === undefined && isObject(targetVal)) { + merged[key] = this.cloneMerge(targetVal, val, pruneMagicId); + } else if (val === undefined && isObject(targetVal)) { + merged[key] = this.cloneMerge(targetVal, {}, pruneMagicId); } } - if(pruneMagicId){ - delete merged.magicId - delete merged.newRender - } else if(target[ROOT]){ - merged.newRender = true + if (pruneMagicId) { + delete merged.magicId; + delete merged.newRender; + } else if (target[ROOT]) { + merged.newRender = true; } - return merged + return merged; } - componentToString(cid){ - const {buffer: str, streams} = this.recursiveCIDToString(this.rendered[COMPONENTS], cid, null) - const [strippedHTML, _before, _after] = modifyRoot(str, {}) - return {buffer: strippedHTML, streams: streams} + componentToString(cid) { + const { buffer: str, streams } = this.recursiveCIDToString( + this.rendered[COMPONENTS], + cid, + null, + ); + const [strippedHTML, _before, _after] = modifyRoot(str, {}); + return { buffer: strippedHTML, streams: streams }; } - pruneCIDs(cids){ - cids.forEach(cid => delete this.rendered[COMPONENTS][cid]) + pruneCIDs(cids) { + cids.forEach((cid) => delete this.rendered[COMPONENTS][cid]); } // private - get(){ return this.rendered } + get() { + return this.rendered; + } - isNewFingerprint(diff = {}){ return !!diff[STATIC] } + isNewFingerprint(diff = {}) { + return !!diff[STATIC]; + } - templateStatic(part, templates){ - if(typeof (part) === "number"){ - return templates[part] + templateStatic(part, templates) { + if (typeof part === "number") { + return templates[part]; } else { - return part + return part; } } - nextMagicID(){ - this.magicId++ - return `m${this.magicId}-${this.parentViewId()}` + nextMagicID() { + this.magicId++; + return `m${this.magicId}-${this.parentViewId()}`; } // Converts rendered tree to output buffer. @@ -297,93 +337,120 @@ export default class Rendered { // changeTracking controls if we can apply the PHX_SKIP optimization. // It is disabled for comprehensions since we must re-render the entire collection // and no individual element is tracked inside the comprehension. - toOutputBuffer(rendered, templates, output, changeTracking, rootAttrs = {}){ - if(rendered[DYNAMICS]){ return this.comprehensionToBuffer(rendered, templates, output) } - let {[STATIC]: statics} = rendered - statics = this.templateStatic(statics, templates) - const isRoot = rendered[ROOT] - const prevBuffer = output.buffer - if(isRoot){ output.buffer = "" } + toOutputBuffer(rendered, templates, output, changeTracking, rootAttrs = {}) { + if (rendered[DYNAMICS]) { + return this.comprehensionToBuffer(rendered, templates, output); + } + let { [STATIC]: statics } = rendered; + statics = this.templateStatic(statics, templates); + const isRoot = rendered[ROOT]; + const prevBuffer = output.buffer; + if (isRoot) { + output.buffer = ""; + } // this condition is called when first rendering an optimizable function component. // LC have their magicId previously set - if(changeTracking && isRoot && !rendered.magicId){ - rendered.newRender = true - rendered.magicId = this.nextMagicID() + if (changeTracking && isRoot && !rendered.magicId) { + rendered.newRender = true; + rendered.magicId = this.nextMagicID(); } - output.buffer += statics[0] - for(let i = 1; i < statics.length; i++){ - this.dynamicToBuffer(rendered[i - 1], templates, output, changeTracking) - output.buffer += statics[i] + output.buffer += statics[0]; + for (let i = 1; i < statics.length; i++) { + this.dynamicToBuffer(rendered[i - 1], templates, output, changeTracking); + output.buffer += statics[i]; } // Applies the root tag "skip" optimization if supported, which clears // the root tag attributes and innerHTML, and only maintains the magicId. // We can only skip when changeTracking is supported (outside of a comprehension), // and when the root element hasn't experienced an unrendered merge (newRender true). - if(isRoot){ - let skip = false - let attrs + if (isRoot) { + let skip = false; + let attrs; // When a LC is re-added to the page, we need to re-render the entire LC tree, // therefore changeTracking is false; however, we need to keep all the magicIds // from any function component so the next time the LC is updated, we can apply // the skip optimization - if(changeTracking || rendered.magicId){ - skip = changeTracking && !rendered.newRender - attrs = {[PHX_MAGIC_ID]: rendered.magicId, ...rootAttrs} + if (changeTracking || rendered.magicId) { + skip = changeTracking && !rendered.newRender; + attrs = { [PHX_MAGIC_ID]: rendered.magicId, ...rootAttrs }; } else { - attrs = rootAttrs + attrs = rootAttrs; + } + if (skip) { + attrs[PHX_SKIP] = true; } - if(skip){ attrs[PHX_SKIP] = true } - const [newRoot, commentBefore, commentAfter] = modifyRoot(output.buffer, attrs, skip) - rendered.newRender = false - output.buffer = prevBuffer + commentBefore + newRoot + commentAfter + const [newRoot, commentBefore, commentAfter] = modifyRoot( + output.buffer, + attrs, + skip, + ); + rendered.newRender = false; + output.buffer = prevBuffer + commentBefore + newRoot + commentAfter; } } - comprehensionToBuffer(rendered, templates, output){ - let {[DYNAMICS]: dynamics, [STATIC]: statics, [STREAM]: stream} = rendered - const [_ref, _inserts, deleteIds, reset] = stream || [null, {}, [], null] - statics = this.templateStatic(statics, templates) - const compTemplates = templates || rendered[TEMPLATES] - for(let d = 0; d < dynamics.length; d++){ - const dynamic = dynamics[d] - output.buffer += statics[0] - for(let i = 1; i < statics.length; i++){ + comprehensionToBuffer(rendered, templates, output) { + let { + [DYNAMICS]: dynamics, + [STATIC]: statics, + [STREAM]: stream, + } = rendered; + const [_ref, _inserts, deleteIds, reset] = stream || [null, {}, [], null]; + statics = this.templateStatic(statics, templates); + const compTemplates = templates || rendered[TEMPLATES]; + for (let d = 0; d < dynamics.length; d++) { + const dynamic = dynamics[d]; + output.buffer += statics[0]; + for (let i = 1; i < statics.length; i++) { // Inside a comprehension, we don't track how dynamics change // over time (and features like streams would make that impossible // unless we move the stream diffing away from morphdom), // so we can't perform root change tracking. - const changeTracking = false - this.dynamicToBuffer(dynamic[i - 1], compTemplates, output, changeTracking) - output.buffer += statics[i] + const changeTracking = false; + this.dynamicToBuffer( + dynamic[i - 1], + compTemplates, + output, + changeTracking, + ); + output.buffer += statics[i]; } } - if(stream !== undefined && (rendered[DYNAMICS].length > 0 || deleteIds.length > 0 || reset)){ - delete rendered[STREAM] - rendered[DYNAMICS] = [] - output.streams.add(stream) + if ( + stream !== undefined && + (rendered[DYNAMICS].length > 0 || deleteIds.length > 0 || reset) + ) { + delete rendered[STREAM]; + rendered[DYNAMICS] = []; + output.streams.add(stream); } } - dynamicToBuffer(rendered, templates, output, changeTracking){ - if(typeof (rendered) === "number"){ - const {buffer: str, streams} = this.recursiveCIDToString(output.components, rendered, output.onlyCids) - output.buffer += str - output.streams = new Set([...output.streams, ...streams]) - } else if(isObject(rendered)){ - this.toOutputBuffer(rendered, templates, output, changeTracking, {}) + dynamicToBuffer(rendered, templates, output, changeTracking) { + if (typeof rendered === "number") { + const { buffer: str, streams } = this.recursiveCIDToString( + output.components, + rendered, + output.onlyCids, + ); + output.buffer += str; + output.streams = new Set([...output.streams, ...streams]); + } else if (isObject(rendered)) { + this.toOutputBuffer(rendered, templates, output, changeTracking, {}); } else { - output.buffer += rendered + output.buffer += rendered; } } - recursiveCIDToString(components, cid, onlyCids){ - const component = components[cid] || logError(`no component for CID ${cid}`, components) - const attrs = {[PHX_COMPONENT]: cid} - const skip = onlyCids && !onlyCids.has(cid) + recursiveCIDToString(components, cid, onlyCids) { + const component = + components[cid] || logError(`no component for CID ${cid}`, components); + const attrs = { [PHX_COMPONENT]: cid }; + const skip = onlyCids && !onlyCids.has(cid); // Two optimization paths apply here: // // 1. The onlyCids optimization works by the server diff telling us only specific @@ -405,14 +472,20 @@ export default class Rendered { // cids and the server adds the component back. In such cases, we explicitly disable changeTracking // with resetRender for this cid, then re-enable it after the recursive call to skip the optimization // for the entire component tree. - component.newRender = !skip - component.magicId = `c${cid}-${this.parentViewId()}` + component.newRender = !skip; + component.magicId = `c${cid}-${this.parentViewId()}`; // enable change tracking as long as the component hasn't been reset - const changeTracking = !component.reset - const {buffer: html, streams} = this.recursiveToString(component, components, onlyCids, changeTracking, attrs) + const changeTracking = !component.reset; + const { buffer: html, streams } = this.recursiveToString( + component, + components, + onlyCids, + changeTracking, + attrs, + ); // disable reset after we've rendered - delete component.reset + delete component.reset; - return {buffer: html, streams: streams} + return { buffer: html, streams: streams }; } } diff --git a/assets/js/phoenix_live_view/upload_entry.js b/assets/js/phoenix_live_view/upload_entry.js index 6be5946412..801f466fda 100644 --- a/assets/js/phoenix_live_view/upload_entry.js +++ b/assets/js/phoenix_live_view/upload_entry.js @@ -1,111 +1,123 @@ import { PHX_ACTIVE_ENTRY_REFS, PHX_LIVE_FILE_UPDATED, - PHX_PREFLIGHTED_REFS -} from "./constants" + PHX_PREFLIGHTED_REFS, +} from "./constants"; -import { - channelUploader, - logError -} from "./utils" +import { channelUploader, logError } from "./utils"; -import LiveUploader from "./live_uploader" +import LiveUploader from "./live_uploader"; export default class UploadEntry { - static isActive(fileEl, file){ - const isNew = file._phxRef === undefined - const activeRefs = fileEl.getAttribute(PHX_ACTIVE_ENTRY_REFS).split(",") - const isActive = activeRefs.indexOf(LiveUploader.genFileRef(file)) >= 0 - return file.size > 0 && (isNew || isActive) + static isActive(fileEl, file) { + const isNew = file._phxRef === undefined; + const activeRefs = fileEl.getAttribute(PHX_ACTIVE_ENTRY_REFS).split(","); + const isActive = activeRefs.indexOf(LiveUploader.genFileRef(file)) >= 0; + return file.size > 0 && (isNew || isActive); } - static isPreflighted(fileEl, file){ - const preflightedRefs = fileEl.getAttribute(PHX_PREFLIGHTED_REFS).split(",") - const isPreflighted = preflightedRefs.indexOf(LiveUploader.genFileRef(file)) >= 0 - return isPreflighted && this.isActive(fileEl, file) + static isPreflighted(fileEl, file) { + const preflightedRefs = fileEl + .getAttribute(PHX_PREFLIGHTED_REFS) + .split(","); + const isPreflighted = + preflightedRefs.indexOf(LiveUploader.genFileRef(file)) >= 0; + return isPreflighted && this.isActive(fileEl, file); } - static isPreflightInProgress(file){ - return file._preflightInProgress === true + static isPreflightInProgress(file) { + return file._preflightInProgress === true; } - static markPreflightInProgress(file){ - file._preflightInProgress = true + static markPreflightInProgress(file) { + file._preflightInProgress = true; } - constructor(fileEl, file, view, autoUpload){ - this.ref = LiveUploader.genFileRef(file) - this.fileEl = fileEl - this.file = file - this.view = view - this.meta = null - this._isCancelled = false - this._isDone = false - this._progress = 0 - this._lastProgressSent = -1 - this._onDone = function(){ } - this._onElUpdated = this.onElUpdated.bind(this) - this.fileEl.addEventListener(PHX_LIVE_FILE_UPDATED, this._onElUpdated) - this.autoUpload = autoUpload + constructor(fileEl, file, view, autoUpload) { + this.ref = LiveUploader.genFileRef(file); + this.fileEl = fileEl; + this.file = file; + this.view = view; + this.meta = null; + this._isCancelled = false; + this._isDone = false; + this._progress = 0; + this._lastProgressSent = -1; + this._onDone = function () {}; + this._onElUpdated = this.onElUpdated.bind(this); + this.fileEl.addEventListener(PHX_LIVE_FILE_UPDATED, this._onElUpdated); + this.autoUpload = autoUpload; } - metadata(){ return this.meta } + metadata() { + return this.meta; + } - progress(progress){ - this._progress = Math.floor(progress) - if(this._progress > this._lastProgressSent){ - if(this._progress >= 100){ - this._progress = 100 - this._lastProgressSent = 100 - this._isDone = true + progress(progress) { + this._progress = Math.floor(progress); + if (this._progress > this._lastProgressSent) { + if (this._progress >= 100) { + this._progress = 100; + this._lastProgressSent = 100; + this._isDone = true; this.view.pushFileProgress(this.fileEl, this.ref, 100, () => { - LiveUploader.untrackFile(this.fileEl, this.file) - this._onDone() - }) + LiveUploader.untrackFile(this.fileEl, this.file); + this._onDone(); + }); } else { - this._lastProgressSent = this._progress - this.view.pushFileProgress(this.fileEl, this.ref, this._progress) + this._lastProgressSent = this._progress; + this.view.pushFileProgress(this.fileEl, this.ref, this._progress); } } } - isCancelled(){ return this._isCancelled } + isCancelled() { + return this._isCancelled; + } - cancel(){ - this.file._preflightInProgress = false - this._isCancelled = true - this._isDone = true - this._onDone() + cancel() { + this.file._preflightInProgress = false; + this._isCancelled = true; + this._isDone = true; + this._onDone(); } - isDone(){ return this._isDone } + isDone() { + return this._isDone; + } - error(reason = "failed"){ - this.fileEl.removeEventListener(PHX_LIVE_FILE_UPDATED, this._onElUpdated) - this.view.pushFileProgress(this.fileEl, this.ref, {error: reason}) - if(!this.isAutoUpload()){ LiveUploader.clearFiles(this.fileEl) } + error(reason = "failed") { + this.fileEl.removeEventListener(PHX_LIVE_FILE_UPDATED, this._onElUpdated); + this.view.pushFileProgress(this.fileEl, this.ref, { error: reason }); + if (!this.isAutoUpload()) { + LiveUploader.clearFiles(this.fileEl); + } } - isAutoUpload(){ return this.autoUpload } + isAutoUpload() { + return this.autoUpload; + } //private - onDone(callback){ + onDone(callback) { this._onDone = () => { - this.fileEl.removeEventListener(PHX_LIVE_FILE_UPDATED, this._onElUpdated) - callback() - } + this.fileEl.removeEventListener(PHX_LIVE_FILE_UPDATED, this._onElUpdated); + callback(); + }; } - onElUpdated(){ - const activeRefs = this.fileEl.getAttribute(PHX_ACTIVE_ENTRY_REFS).split(",") - if(activeRefs.indexOf(this.ref) === -1){ - LiveUploader.untrackFile(this.fileEl, this.file) - this.cancel() + onElUpdated() { + const activeRefs = this.fileEl + .getAttribute(PHX_ACTIVE_ENTRY_REFS) + .split(","); + if (activeRefs.indexOf(this.ref) === -1) { + LiveUploader.untrackFile(this.fileEl, this.file); + this.cancel(); } } - toPreflightPayload(){ + toPreflightPayload() { return { last_modified: this.file.lastModified, name: this.file.name, @@ -113,21 +125,28 @@ export default class UploadEntry { size: this.file.size, type: this.file.type, ref: this.ref, - meta: typeof(this.file.meta) === "function" ? this.file.meta() : undefined - } + meta: typeof this.file.meta === "function" ? this.file.meta() : undefined, + }; } - uploader(uploaders){ - if(this.meta.uploader){ - const callback = uploaders[this.meta.uploader] || logError(`no uploader configured for ${this.meta.uploader}`) - return {name: this.meta.uploader, callback: callback} + uploader(uploaders) { + if (this.meta.uploader) { + const callback = + uploaders[this.meta.uploader] || + logError(`no uploader configured for ${this.meta.uploader}`); + return { name: this.meta.uploader, callback: callback }; } else { - return {name: "channel", callback: channelUploader} + return { name: "channel", callback: channelUploader }; } } - zipPostFlight(resp){ - this.meta = resp.entries[this.ref] - if(!this.meta){ logError(`no preflight upload response returned with ref ${this.ref}`, {input: this.fileEl, response: resp}) } + zipPostFlight(resp) { + this.meta = resp.entries[this.ref]; + if (!this.meta) { + logError(`no preflight upload response returned with ref ${this.ref}`, { + input: this.fileEl, + response: resp, + }); + } } } diff --git a/assets/js/phoenix_live_view/utils.js b/assets/js/phoenix_live_view/utils.js index 3b47cefb8d..b358a73781 100644 --- a/assets/js/phoenix_live_view/utils.js +++ b/assets/js/phoenix_live_view/utils.js @@ -1,74 +1,96 @@ -import { - PHX_VIEW_SELECTOR -} from "./constants" +import { PHX_VIEW_SELECTOR } from "./constants"; -import EntryUploader from "./entry_uploader" +import EntryUploader from "./entry_uploader"; -export const logError = (msg, obj) => console.error && console.error(msg, obj) +export const logError = (msg, obj) => console.error && console.error(msg, obj); export const isCid = (cid) => { - const type = typeof(cid) - return type === "number" || (type === "string" && /^(0|[1-9]\d*)$/.test(cid)) -} + const type = typeof cid; + return type === "number" || (type === "string" && /^(0|[1-9]\d*)$/.test(cid)); +}; -export function detectDuplicateIds(){ - const ids = new Set() - const elems = document.querySelectorAll("*[id]") - for(let i = 0, len = elems.length; i < len; i++){ - if(ids.has(elems[i].id)){ - console.error(`Multiple IDs detected: ${elems[i].id}. Ensure unique element ids.`) +export function detectDuplicateIds() { + const ids = new Set(); + const elems = document.querySelectorAll("*[id]"); + for (let i = 0, len = elems.length; i < len; i++) { + if (ids.has(elems[i].id)) { + console.error( + `Multiple IDs detected: ${elems[i].id}. Ensure unique element ids.`, + ); } else { - ids.add(elems[i].id) + ids.add(elems[i].id); } } } -export function detectInvalidStreamInserts(inserts){ - const errors = new Set() +export function detectInvalidStreamInserts(inserts) { + const errors = new Set(); Object.keys(inserts).forEach((id) => { - const streamEl = document.getElementById(id) - if(streamEl && streamEl.parentElement && streamEl.parentElement.getAttribute("phx-update") !== "stream"){ - errors.add(`The stream container with id "${streamEl.parentElement.id}" is missing the phx-update="stream" attribute. Ensure it is set for streams to work properly.`) + const streamEl = document.getElementById(id); + if ( + streamEl && + streamEl.parentElement && + streamEl.parentElement.getAttribute("phx-update") !== "stream" + ) { + errors.add( + `The stream container with id "${streamEl.parentElement.id}" is missing the phx-update="stream" attribute. Ensure it is set for streams to work properly.`, + ); } - }) - errors.forEach(error => console.error(error)) + }); + errors.forEach((error) => console.error(error)); } export const debug = (view, kind, msg, obj) => { - if(view.liveSocket.isDebugEnabled()){ - console.log(`${view.id} ${kind}: ${msg} - `, obj) + if (view.liveSocket.isDebugEnabled()) { + console.log(`${view.id} ${kind}: ${msg} - `, obj); } -} +}; // wraps value in closure or returns closure -export const closure = (val) => typeof val === "function" ? val : function (){ return val } +export const closure = (val) => + typeof val === "function" + ? val + : function () { + return val; + }; -export const clone = (obj) => { return JSON.parse(JSON.stringify(obj)) } +export const clone = (obj) => { + return JSON.parse(JSON.stringify(obj)); +}; export const closestPhxBinding = (el, binding, borderEl) => { do { - if(el.matches(`[${binding}]`) && !el.disabled){ return el } - el = el.parentElement || el.parentNode - } while(el !== null && el.nodeType === 1 && !((borderEl && borderEl.isSameNode(el)) || el.matches(PHX_VIEW_SELECTOR))) - return null -} + if (el.matches(`[${binding}]`) && !el.disabled) { + return el; + } + el = el.parentElement || el.parentNode; + } while ( + el !== null && + el.nodeType === 1 && + !((borderEl && borderEl.isSameNode(el)) || el.matches(PHX_VIEW_SELECTOR)) + ); + return null; +}; export const isObject = (obj) => { - return obj !== null && typeof obj === "object" && !(obj instanceof Array) -} + return obj !== null && typeof obj === "object" && !(obj instanceof Array); +}; -export const isEqualObj = (obj1, obj2) => JSON.stringify(obj1) === JSON.stringify(obj2) +export const isEqualObj = (obj1, obj2) => + JSON.stringify(obj1) === JSON.stringify(obj2); export const isEmpty = (obj) => { - for(const x in obj){ return false } - return true -} + for (const x in obj) { + return false; + } + return true; +}; -export const maybe = (el, callback) => el && callback(el) +export const maybe = (el, callback) => el && callback(el); -export const channelUploader = function (entries, onError, resp, liveSocket){ - entries.forEach(entry => { - const entryUploader = new EntryUploader(entry, resp.config, liveSocket) - entryUploader.upload() - }) -} +export const channelUploader = function (entries, onError, resp, liveSocket) { + entries.forEach((entry) => { + const entryUploader = new EntryUploader(entry, resp.config, liveSocket); + entryUploader.upload(); + }); +}; diff --git a/assets/js/phoenix_live_view/view.js b/assets/js/phoenix_live_view/view.js index e3c7b96da0..04b86f4664 100644 --- a/assets/js/phoenix_live_view/view.js +++ b/assets/js/phoenix_live_view/view.js @@ -35,8 +35,8 @@ import { PUSH_TIMEOUT, PHX_VIEWPORT_TOP, PHX_VIEWPORT_BOTTOM, - MAX_CHILD_JOIN_ATTEMPTS -} from "./constants" + MAX_CHILD_JOIN_ATTEMPTS, +} from "./constants"; import { clone, @@ -46,137 +46,154 @@ import { logError, maybe, isCid, -} from "./utils" +} from "./utils"; -import Browser from "./browser" -import DOM from "./dom" -import ElementRef from "./element_ref" -import DOMPatch from "./dom_patch" -import LiveUploader from "./live_uploader" -import Rendered from "./rendered" -import {ViewHook} from "./view_hook" -import JS from "./js" +import Browser from "./browser"; +import DOM from "./dom"; +import ElementRef from "./element_ref"; +import DOMPatch from "./dom_patch"; +import LiveUploader from "./live_uploader"; +import Rendered from "./rendered"; +import { ViewHook } from "./view_hook"; +import JS from "./js"; export const prependFormDataKey = (key, prefix) => { - const isArray = key.endsWith("[]") + const isArray = key.endsWith("[]"); // Remove the "[]" if it's an array - let baseKey = isArray ? key.slice(0, -2) : key + let baseKey = isArray ? key.slice(0, -2) : key; // Replace last occurrence of key before a closing bracket or the end with key plus suffix - baseKey = baseKey.replace(/([^\[\]]+)(\]?$)/, `${prefix}$1$2`) + baseKey = baseKey.replace(/([^\[\]]+)(\]?$)/, `${prefix}$1$2`); // Add back the "[]" if it was an array - if(isArray){ baseKey += "[]" } - return baseKey -} + if (isArray) { + baseKey += "[]"; + } + return baseKey; +}; const serializeForm = (form, opts, onlyNames = []) => { - const {submitter} = opts + const { submitter } = opts; // We must inject the submitter in the order that it exists in the DOM // relative to other inputs. For example, for checkbox groups, the order must be maintained. - let injectedElement - if(submitter && submitter.name){ - const input = document.createElement("input") - input.type = "hidden" + let injectedElement; + if (submitter && submitter.name) { + const input = document.createElement("input"); + input.type = "hidden"; // set the form attribute if the submitter has one; // this can happen if the element is outside the actual form element - const formId = submitter.getAttribute("form") - if(formId){ - input.setAttribute("form", formId) + const formId = submitter.getAttribute("form"); + if (formId) { + input.setAttribute("form", formId); } - input.name = submitter.name - input.value = submitter.value - submitter.parentElement.insertBefore(input, submitter) - injectedElement = input + input.name = submitter.name; + input.value = submitter.value; + submitter.parentElement.insertBefore(input, submitter); + injectedElement = input; } - const formData = new FormData(form) - const toRemove = [] + const formData = new FormData(form); + const toRemove = []; formData.forEach((val, key, _index) => { - if(val instanceof File){ toRemove.push(key) } - }) + if (val instanceof File) { + toRemove.push(key); + } + }); // Cleanup after building fileData - toRemove.forEach(key => formData.delete(key)) + toRemove.forEach((key) => formData.delete(key)); - const params = new URLSearchParams() + const params = new URLSearchParams(); - const {inputsUnused, onlyHiddenInputs} = Array.from(form.elements).reduce((acc, input) => { - const {inputsUnused, onlyHiddenInputs} = acc - const key = input.name - if(!key){ return acc } - - if(inputsUnused[key] === undefined){ inputsUnused[key] = true } - if(onlyHiddenInputs[key] === undefined){ onlyHiddenInputs[key] = true } - - const isUsed = DOM.private(input, PHX_HAS_FOCUSED) || DOM.private(input, PHX_HAS_SUBMITTED) - const isHidden = input.type === "hidden" - inputsUnused[key] = inputsUnused[key] && !isUsed - onlyHiddenInputs[key] = onlyHiddenInputs[key] && isHidden + const { inputsUnused, onlyHiddenInputs } = Array.from(form.elements).reduce( + (acc, input) => { + const { inputsUnused, onlyHiddenInputs } = acc; + const key = input.name; + if (!key) { + return acc; + } - return acc - }, {inputsUnused: {}, onlyHiddenInputs: {}}) + if (inputsUnused[key] === undefined) { + inputsUnused[key] = true; + } + if (onlyHiddenInputs[key] === undefined) { + onlyHiddenInputs[key] = true; + } - for(const [key, val] of formData.entries()){ - if(onlyNames.length === 0 || onlyNames.indexOf(key) >= 0){ - const isUnused = inputsUnused[key] - const hidden = onlyHiddenInputs[key] - if(isUnused && !(submitter && submitter.name == key) && !hidden){ - params.append(prependFormDataKey(key, "_unused_"), "") + const isUsed = + DOM.private(input, PHX_HAS_FOCUSED) || + DOM.private(input, PHX_HAS_SUBMITTED); + const isHidden = input.type === "hidden"; + inputsUnused[key] = inputsUnused[key] && !isUsed; + onlyHiddenInputs[key] = onlyHiddenInputs[key] && isHidden; + + return acc; + }, + { inputsUnused: {}, onlyHiddenInputs: {} }, + ); + + for (const [key, val] of formData.entries()) { + if (onlyNames.length === 0 || onlyNames.indexOf(key) >= 0) { + const isUnused = inputsUnused[key]; + const hidden = onlyHiddenInputs[key]; + if (isUnused && !(submitter && submitter.name == key) && !hidden) { + params.append(prependFormDataKey(key, "_unused_"), ""); } - if(typeof val === "string"){ - params.append(key, val) + if (typeof val === "string") { + params.append(key, val); } } } // remove the injected element again // (it would be removed by the next dom patch anyway, but this is cleaner) - if(submitter && injectedElement){ - submitter.parentElement.removeChild(injectedElement) + if (submitter && injectedElement) { + submitter.parentElement.removeChild(injectedElement); } - return params.toString() -} + return params.toString(); +}; export default class View { - static closestView(el){ - const liveViewEl = el.closest(PHX_VIEW_SELECTOR) - return liveViewEl ? DOM.private(liveViewEl, "view") : null - } - - constructor(el, liveSocket, parentView, flash, liveReferer){ - this.isDead = false - this.liveSocket = liveSocket - this.flash = flash - this.parent = parentView - this.root = parentView ? parentView.root : this - this.el = el - DOM.putPrivate(this.el, "view", this) - this.id = this.el.id - this.ref = 0 - this.lastAckRef = null - this.childJoins = 0 - this.loaderTimer = null - this.disconnectedTimer = null - this.pendingDiffs = [] - this.pendingForms = new Set() - this.redirect = false - this.href = null - this.joinCount = this.parent ? this.parent.joinCount - 1 : 0 - this.joinAttempts = 0 - this.joinPending = true - this.destroyed = false - this.joinCallback = function(onDone){ onDone && onDone() } - this.stopCallback = function(){ } - this.pendingJoinOps = this.parent ? null : [] - this.viewHooks = {} - this.formSubmits = [] - this.children = this.parent ? null : {} - this.root.children[this.id] = {} - this.formsForRecovery = {} + static closestView(el) { + const liveViewEl = el.closest(PHX_VIEW_SELECTOR); + return liveViewEl ? DOM.private(liveViewEl, "view") : null; + } + + constructor(el, liveSocket, parentView, flash, liveReferer) { + this.isDead = false; + this.liveSocket = liveSocket; + this.flash = flash; + this.parent = parentView; + this.root = parentView ? parentView.root : this; + this.el = el; + DOM.putPrivate(this.el, "view", this); + this.id = this.el.id; + this.ref = 0; + this.lastAckRef = null; + this.childJoins = 0; + this.loaderTimer = null; + this.disconnectedTimer = null; + this.pendingDiffs = []; + this.pendingForms = new Set(); + this.redirect = false; + this.href = null; + this.joinCount = this.parent ? this.parent.joinCount - 1 : 0; + this.joinAttempts = 0; + this.joinPending = true; + this.destroyed = false; + this.joinCallback = function (onDone) { + onDone && onDone(); + }; + this.stopCallback = function () {}; + this.pendingJoinOps = this.parent ? null : []; + this.viewHooks = {}; + this.formSubmits = []; + this.children = this.parent ? null : {}; + this.root.children[this.id] = {}; + this.formsForRecovery = {}; this.channel = this.liveSocket.channel(`lv:${this.id}`, () => { - const url = this.href && this.expandURL(this.href) + const url = this.href && this.expandURL(this.href); return { redirect: this.redirect ? url : undefined, url: this.redirect ? undefined : url || undefined, @@ -184,108 +201,127 @@ export default class View { session: this.getSession(), static: this.getStatic(), flash: this.flash, - sticky: this.el.hasAttribute(PHX_STICKY) - } - }) + sticky: this.el.hasAttribute(PHX_STICKY), + }; + }); } - setHref(href){ this.href = href } + setHref(href) { + this.href = href; + } - setRedirect(href){ - this.redirect = true - this.href = href + setRedirect(href) { + this.redirect = true; + this.href = href; } - isMain(){ return this.el.hasAttribute(PHX_MAIN) } + isMain() { + return this.el.hasAttribute(PHX_MAIN); + } - connectParams(liveReferer){ - const params = this.liveSocket.params(this.el) - const manifest = - DOM.all(document, `[${this.binding(PHX_TRACK_STATIC)}]`) - .map(node => node.src || node.href).filter(url => typeof (url) === "string") + connectParams(liveReferer) { + const params = this.liveSocket.params(this.el); + const manifest = DOM.all(document, `[${this.binding(PHX_TRACK_STATIC)}]`) + .map((node) => node.src || node.href) + .filter((url) => typeof url === "string"); - if(manifest.length > 0){ params["_track_static"] = manifest } - params["_mounts"] = this.joinCount - params["_mount_attempts"] = this.joinAttempts - params["_live_referer"] = liveReferer - this.joinAttempts++ + if (manifest.length > 0) { + params["_track_static"] = manifest; + } + params["_mounts"] = this.joinCount; + params["_mount_attempts"] = this.joinAttempts; + params["_live_referer"] = liveReferer; + this.joinAttempts++; - return params + return params; } - isConnected(){ return this.channel.canPush() } + isConnected() { + return this.channel.canPush(); + } - getSession(){ return this.el.getAttribute(PHX_SESSION) } + getSession() { + return this.el.getAttribute(PHX_SESSION); + } - getStatic(){ - const val = this.el.getAttribute(PHX_STATIC) - return val === "" ? null : val + getStatic() { + const val = this.el.getAttribute(PHX_STATIC); + return val === "" ? null : val; } - destroy(callback = function (){ }){ - this.destroyAllChildren() - this.destroyed = true - delete this.root.children[this.id] - if(this.parent){ delete this.root.children[this.parent.id][this.id] } - clearTimeout(this.loaderTimer) + destroy(callback = function () {}) { + this.destroyAllChildren(); + this.destroyed = true; + delete this.root.children[this.id]; + if (this.parent) { + delete this.root.children[this.parent.id][this.id]; + } + clearTimeout(this.loaderTimer); const onFinished = () => { - callback() - for(const id in this.viewHooks){ - this.destroyHook(this.viewHooks[id]) + callback(); + for (const id in this.viewHooks) { + this.destroyHook(this.viewHooks[id]); } - } + }; - DOM.markPhxChildDestroyed(this.el) + DOM.markPhxChildDestroyed(this.el); - this.log("destroyed", () => ["the child has been removed from the parent"]) - this.channel.leave() + this.log("destroyed", () => ["the child has been removed from the parent"]); + this.channel + .leave() .receive("ok", onFinished) .receive("error", onFinished) - .receive("timeout", onFinished) + .receive("timeout", onFinished); } - setContainerClasses(...classes){ + setContainerClasses(...classes) { this.el.classList.remove( PHX_CONNECTED_CLASS, PHX_LOADING_CLASS, PHX_ERROR_CLASS, PHX_CLIENT_ERROR_CLASS, - PHX_SERVER_ERROR_CLASS - ) - this.el.classList.add(...classes) + PHX_SERVER_ERROR_CLASS, + ); + this.el.classList.add(...classes); } - showLoader(timeout){ - clearTimeout(this.loaderTimer) - if(timeout){ - this.loaderTimer = setTimeout(() => this.showLoader(), timeout) + showLoader(timeout) { + clearTimeout(this.loaderTimer); + if (timeout) { + this.loaderTimer = setTimeout(() => this.showLoader(), timeout); } else { - for(const id in this.viewHooks){ this.viewHooks[id].__disconnected() } - this.setContainerClasses(PHX_LOADING_CLASS) + for (const id in this.viewHooks) { + this.viewHooks[id].__disconnected(); + } + this.setContainerClasses(PHX_LOADING_CLASS); } } - execAll(binding){ - DOM.all(this.el, `[${binding}]`, el => this.liveSocket.execJS(el, el.getAttribute(binding))) + execAll(binding) { + DOM.all(this.el, `[${binding}]`, (el) => + this.liveSocket.execJS(el, el.getAttribute(binding)), + ); } - hideLoader(){ - clearTimeout(this.loaderTimer) - clearTimeout(this.disconnectedTimer) - this.setContainerClasses(PHX_CONNECTED_CLASS) - this.execAll(this.binding("connected")) + hideLoader() { + clearTimeout(this.loaderTimer); + clearTimeout(this.disconnectedTimer); + this.setContainerClasses(PHX_CONNECTED_CLASS); + this.execAll(this.binding("connected")); } - triggerReconnected(){ - for(const id in this.viewHooks){ this.viewHooks[id].__reconnected() } + triggerReconnected() { + for (const id in this.viewHooks) { + this.viewHooks[id].__reconnected(); + } } - log(kind, msgCallback){ - this.liveSocket.log(this, kind, msgCallback) + log(kind, msgCallback) { + this.liveSocket.log(this, kind, msgCallback); } - transition(time, onStart, onDone = function(){}){ - this.liveSocket.transition(time, onStart, onDone) + transition(time, onStart, onDone = function () {}) { + this.liveSocket.transition(time, onStart, onDone); } // calls the callback with the view and target element for the given phxTarget @@ -294,267 +330,327 @@ export default class View { // * a CID (Component ID), then we first search the component's element in the DOM // * a selector, then we search the selector in the DOM and call the callback // for each element found with the corresponding owner view - withinTargets(phxTarget, callback, dom = document, viewEl){ + withinTargets(phxTarget, callback, dom = document, viewEl) { // in the form recovery case we search in a template fragment instead of // the real dom, therefore we optionally pass dom and viewEl - if(phxTarget instanceof HTMLElement || phxTarget instanceof SVGElement){ - return this.liveSocket.owner(phxTarget, view => callback(view, phxTarget)) + if (phxTarget instanceof HTMLElement || phxTarget instanceof SVGElement) { + return this.liveSocket.owner(phxTarget, (view) => + callback(view, phxTarget), + ); } - if(isCid(phxTarget)){ - const targets = DOM.findComponentNodeList(viewEl || this.el, phxTarget) - if(targets.length === 0){ - logError(`no component found matching phx-target of ${phxTarget}`) + if (isCid(phxTarget)) { + const targets = DOM.findComponentNodeList(viewEl || this.el, phxTarget); + if (targets.length === 0) { + logError(`no component found matching phx-target of ${phxTarget}`); } else { - callback(this, parseInt(phxTarget)) + callback(this, parseInt(phxTarget)); } } else { - const targets = Array.from(dom.querySelectorAll(phxTarget)) - if(targets.length === 0){ logError(`nothing found matching the phx-target selector "${phxTarget}"`) } - targets.forEach(target => this.liveSocket.owner(target, view => callback(view, target))) + const targets = Array.from(dom.querySelectorAll(phxTarget)); + if (targets.length === 0) { + logError( + `nothing found matching the phx-target selector "${phxTarget}"`, + ); + } + targets.forEach((target) => + this.liveSocket.owner(target, (view) => callback(view, target)), + ); } } - applyDiff(type, rawDiff, callback){ - this.log(type, () => ["", clone(rawDiff)]) - const {diff, reply, events, title} = Rendered.extract(rawDiff) - callback({diff, reply, events}) - if(typeof title === "string" || type == "mount"){ window.requestAnimationFrame(() => DOM.putTitle(title)) } + applyDiff(type, rawDiff, callback) { + this.log(type, () => ["", clone(rawDiff)]); + const { diff, reply, events, title } = Rendered.extract(rawDiff); + callback({ diff, reply, events }); + if (typeof title === "string" || type == "mount") { + window.requestAnimationFrame(() => DOM.putTitle(title)); + } } - onJoin(resp){ - const {rendered, container, liveview_version} = resp - if(container){ - const [tag, attrs] = container - this.el = DOM.replaceRootContainer(this.el, tag, attrs) + onJoin(resp) { + const { rendered, container, liveview_version } = resp; + if (container) { + const [tag, attrs] = container; + this.el = DOM.replaceRootContainer(this.el, tag, attrs); } - this.childJoins = 0 - this.joinPending = true - this.flash = null - if(this.root === this){ - this.formsForRecovery = this.getFormsForRecovery() + this.childJoins = 0; + this.joinPending = true; + this.flash = null; + if (this.root === this) { + this.formsForRecovery = this.getFormsForRecovery(); } - if(this.isMain() && window.history.state === null){ + if (this.isMain() && window.history.state === null) { // set initial history entry if this is the first page load (no history) Browser.pushState("replace", { type: "patch", id: this.id, - position: this.liveSocket.currentHistoryPosition - }) + position: this.liveSocket.currentHistoryPosition, + }); } - if(liveview_version !== this.liveSocket.version()){ - console.error(`LiveView asset version mismatch. JavaScript version ${this.liveSocket.version()} vs. server ${liveview_version}. To avoid issues, please ensure that your assets use the same version as the server.`) + if (liveview_version !== this.liveSocket.version()) { + console.error( + `LiveView asset version mismatch. JavaScript version ${this.liveSocket.version()} vs. server ${liveview_version}. To avoid issues, please ensure that your assets use the same version as the server.`, + ); } - Browser.dropLocal(this.liveSocket.localStorage, window.location.pathname, CONSECUTIVE_RELOADS) - this.applyDiff("mount", rendered, ({diff, events}) => { - this.rendered = new Rendered(this.id, diff) - const [html, streams] = this.renderContainer(null, "join") - this.dropPendingRefs() - this.joinCount++ - this.joinAttempts = 0 + Browser.dropLocal( + this.liveSocket.localStorage, + window.location.pathname, + CONSECUTIVE_RELOADS, + ); + this.applyDiff("mount", rendered, ({ diff, events }) => { + this.rendered = new Rendered(this.id, diff); + const [html, streams] = this.renderContainer(null, "join"); + this.dropPendingRefs(); + this.joinCount++; + this.joinAttempts = 0; this.maybeRecoverForms(html, () => { - this.onJoinComplete(resp, html, streams, events) - }) - }) + this.onJoinComplete(resp, html, streams, events); + }); + }); } - dropPendingRefs(){ - DOM.all(document, `[${PHX_REF_SRC}="${this.refSrc()}"]`, el => { - el.removeAttribute(PHX_REF_LOADING) - el.removeAttribute(PHX_REF_SRC) - el.removeAttribute(PHX_REF_LOCK) - }) + dropPendingRefs() { + DOM.all(document, `[${PHX_REF_SRC}="${this.refSrc()}"]`, (el) => { + el.removeAttribute(PHX_REF_LOADING); + el.removeAttribute(PHX_REF_SRC); + el.removeAttribute(PHX_REF_LOCK); + }); } - onJoinComplete({live_patch}, html, streams, events){ + onJoinComplete({ live_patch }, html, streams, events) { // In order to provide a better experience, we want to join // all LiveViews first and only then apply their patches. - if(this.joinCount > 1 || (this.parent && !this.parent.isJoinPending())){ - return this.applyJoinPatch(live_patch, html, streams, events) + if (this.joinCount > 1 || (this.parent && !this.parent.isJoinPending())) { + return this.applyJoinPatch(live_patch, html, streams, events); } // One downside of this approach is that we need to find phxChildren // in the html fragment, instead of directly on the DOM. The fragment // also does not include PHX_STATIC, so we need to copy it over from // the DOM. - const newChildren = DOM.findPhxChildrenInFragment(html, this.id).filter(toEl => { - const fromEl = toEl.id && this.el.querySelector(`[id="${toEl.id}"]`) - const phxStatic = fromEl && fromEl.getAttribute(PHX_STATIC) - if(phxStatic){ toEl.setAttribute(PHX_STATIC, phxStatic) } - // set PHX_ROOT_ID to prevent events from being dispatched to the root view - // while the child join is still pending - if(fromEl){ fromEl.setAttribute(PHX_ROOT_ID, this.root.id) } - return this.joinChild(toEl) - }) - - if(newChildren.length === 0){ - if(this.parent){ - this.root.pendingJoinOps.push([this, () => this.applyJoinPatch(live_patch, html, streams, events)]) - this.parent.ackJoin(this) + const newChildren = DOM.findPhxChildrenInFragment(html, this.id).filter( + (toEl) => { + const fromEl = toEl.id && this.el.querySelector(`[id="${toEl.id}"]`); + const phxStatic = fromEl && fromEl.getAttribute(PHX_STATIC); + if (phxStatic) { + toEl.setAttribute(PHX_STATIC, phxStatic); + } + // set PHX_ROOT_ID to prevent events from being dispatched to the root view + // while the child join is still pending + if (fromEl) { + fromEl.setAttribute(PHX_ROOT_ID, this.root.id); + } + return this.joinChild(toEl); + }, + ); + + if (newChildren.length === 0) { + if (this.parent) { + this.root.pendingJoinOps.push([ + this, + () => this.applyJoinPatch(live_patch, html, streams, events), + ]); + this.parent.ackJoin(this); } else { - this.onAllChildJoinsComplete() - this.applyJoinPatch(live_patch, html, streams, events) + this.onAllChildJoinsComplete(); + this.applyJoinPatch(live_patch, html, streams, events); } } else { - this.root.pendingJoinOps.push([this, () => this.applyJoinPatch(live_patch, html, streams, events)]) + this.root.pendingJoinOps.push([ + this, + () => this.applyJoinPatch(live_patch, html, streams, events), + ]); } } - attachTrueDocEl(){ - this.el = DOM.byId(this.id) - this.el.setAttribute(PHX_ROOT_ID, this.root.id) + attachTrueDocEl() { + this.el = DOM.byId(this.id); + this.el.setAttribute(PHX_ROOT_ID, this.root.id); } // this is invoked for dead and live views, so we must filter by // by owner to ensure we aren't duplicating hooks across disconnect // and connected states. This also handles cases where hooks exist // in a root layout with a LV in the body - execNewMounted(parent = this.el){ - const phxViewportTop = this.binding(PHX_VIEWPORT_TOP) - const phxViewportBottom = this.binding(PHX_VIEWPORT_BOTTOM) - DOM.all(parent, `[${phxViewportTop}], [${phxViewportBottom}]`, hookEl => { - if(this.ownsElement(hookEl)){ - DOM.maintainPrivateHooks(hookEl, hookEl, phxViewportTop, phxViewportBottom) - this.maybeAddNewHook(hookEl) - } - }) - DOM.all(parent, `[${this.binding(PHX_HOOK)}], [data-phx-${PHX_HOOK}]`, hookEl => { - if(this.ownsElement(hookEl)){ - this.maybeAddNewHook(hookEl) + execNewMounted(parent = this.el) { + const phxViewportTop = this.binding(PHX_VIEWPORT_TOP); + const phxViewportBottom = this.binding(PHX_VIEWPORT_BOTTOM); + DOM.all(parent, `[${phxViewportTop}], [${phxViewportBottom}]`, (hookEl) => { + if (this.ownsElement(hookEl)) { + DOM.maintainPrivateHooks( + hookEl, + hookEl, + phxViewportTop, + phxViewportBottom, + ); + this.maybeAddNewHook(hookEl); } - }) - DOM.all(parent, `[${this.binding(PHX_MOUNTED)}]`, el => { - if(this.ownsElement(el)){ - this.maybeMounted(el) + }); + DOM.all( + parent, + `[${this.binding(PHX_HOOK)}], [data-phx-${PHX_HOOK}]`, + (hookEl) => { + if (this.ownsElement(hookEl)) { + this.maybeAddNewHook(hookEl); + } + }, + ); + DOM.all(parent, `[${this.binding(PHX_MOUNTED)}]`, (el) => { + if (this.ownsElement(el)) { + this.maybeMounted(el); } - }) + }); } - applyJoinPatch(live_patch, html, streams, events){ - this.attachTrueDocEl() - const patch = new DOMPatch(this, this.el, this.id, html, streams, null) - patch.markPrunableContentForRemoval() - this.performPatch(patch, false, true) - this.joinNewChildren() - this.execNewMounted() + applyJoinPatch(live_patch, html, streams, events) { + this.attachTrueDocEl(); + const patch = new DOMPatch(this, this.el, this.id, html, streams, null); + patch.markPrunableContentForRemoval(); + this.performPatch(patch, false, true); + this.joinNewChildren(); + this.execNewMounted(); - this.joinPending = false - this.liveSocket.dispatchEvents(events) - this.applyPendingUpdates() + this.joinPending = false; + this.liveSocket.dispatchEvents(events); + this.applyPendingUpdates(); - if(live_patch){ - const {kind, to} = live_patch - this.liveSocket.historyPatch(to, kind) + if (live_patch) { + const { kind, to } = live_patch; + this.liveSocket.historyPatch(to, kind); } - this.hideLoader() - if(this.joinCount > 1){ this.triggerReconnected() } - this.stopCallback() - } - - triggerBeforeUpdateHook(fromEl, toEl){ - this.liveSocket.triggerDOM("onBeforeElUpdated", [fromEl, toEl]) - const hook = this.getHook(fromEl) - const isIgnored = hook && DOM.isIgnored(fromEl, this.binding(PHX_UPDATE)) - if(hook && !fromEl.isEqualNode(toEl) && !(isIgnored && isEqualObj(fromEl.dataset, toEl.dataset))){ - hook.__beforeUpdate() - return hook + this.hideLoader(); + if (this.joinCount > 1) { + this.triggerReconnected(); + } + this.stopCallback(); + } + + triggerBeforeUpdateHook(fromEl, toEl) { + this.liveSocket.triggerDOM("onBeforeElUpdated", [fromEl, toEl]); + const hook = this.getHook(fromEl); + const isIgnored = hook && DOM.isIgnored(fromEl, this.binding(PHX_UPDATE)); + if ( + hook && + !fromEl.isEqualNode(toEl) && + !(isIgnored && isEqualObj(fromEl.dataset, toEl.dataset)) + ) { + hook.__beforeUpdate(); + return hook; } } - maybeMounted(el){ - const phxMounted = el.getAttribute(this.binding(PHX_MOUNTED)) - const hasBeenInvoked = phxMounted && DOM.private(el, "mounted") - if(phxMounted && !hasBeenInvoked){ - this.liveSocket.execJS(el, phxMounted) - DOM.putPrivate(el, "mounted", true) + maybeMounted(el) { + const phxMounted = el.getAttribute(this.binding(PHX_MOUNTED)); + const hasBeenInvoked = phxMounted && DOM.private(el, "mounted"); + if (phxMounted && !hasBeenInvoked) { + this.liveSocket.execJS(el, phxMounted); + DOM.putPrivate(el, "mounted", true); } } - maybeAddNewHook(el){ - const newHook = this.addHook(el) - if(newHook){ newHook.__mounted() } + maybeAddNewHook(el) { + const newHook = this.addHook(el); + if (newHook) { + newHook.__mounted(); + } } - performPatch(patch, pruneCids, isJoinPatch = false){ - const removedEls = [] - let phxChildrenAdded = false - const updatedHookIds = new Set() + performPatch(patch, pruneCids, isJoinPatch = false) { + const removedEls = []; + let phxChildrenAdded = false; + const updatedHookIds = new Set(); - this.liveSocket.triggerDOM("onPatchStart", [patch.targetContainer]) + this.liveSocket.triggerDOM("onPatchStart", [patch.targetContainer]); - patch.after("added", el => { - this.liveSocket.triggerDOM("onNodeAdded", [el]) - const phxViewportTop = this.binding(PHX_VIEWPORT_TOP) - const phxViewportBottom = this.binding(PHX_VIEWPORT_BOTTOM) - DOM.maintainPrivateHooks(el, el, phxViewportTop, phxViewportBottom) - this.maybeAddNewHook(el) - if(el.getAttribute){ this.maybeMounted(el) } - }) + patch.after("added", (el) => { + this.liveSocket.triggerDOM("onNodeAdded", [el]); + const phxViewportTop = this.binding(PHX_VIEWPORT_TOP); + const phxViewportBottom = this.binding(PHX_VIEWPORT_BOTTOM); + DOM.maintainPrivateHooks(el, el, phxViewportTop, phxViewportBottom); + this.maybeAddNewHook(el); + if (el.getAttribute) { + this.maybeMounted(el); + } + }); - patch.after("phxChildAdded", el => { - if(DOM.isPhxSticky(el)){ - this.liveSocket.joinRootViews() + patch.after("phxChildAdded", (el) => { + if (DOM.isPhxSticky(el)) { + this.liveSocket.joinRootViews(); } else { - phxChildrenAdded = true + phxChildrenAdded = true; } - }) + }); patch.before("updated", (fromEl, toEl) => { - const hook = this.triggerBeforeUpdateHook(fromEl, toEl) - if(hook){ updatedHookIds.add(fromEl.id) } + const hook = this.triggerBeforeUpdateHook(fromEl, toEl); + if (hook) { + updatedHookIds.add(fromEl.id); + } // trigger JS specific update logic (for example for JS.ignore_attributes) - JS.onBeforeElUpdated(fromEl, toEl) - }) + JS.onBeforeElUpdated(fromEl, toEl); + }); - patch.after("updated", el => { - if(updatedHookIds.has(el.id)){ this.getHook(el).__updated() } - }) + patch.after("updated", (el) => { + if (updatedHookIds.has(el.id)) { + this.getHook(el).__updated(); + } + }); patch.after("discarded", (el) => { - if(el.nodeType === Node.ELEMENT_NODE){ removedEls.push(el) } - }) - - patch.after("transitionsDiscarded", els => this.afterElementsRemoved(els, pruneCids)) - patch.perform(isJoinPatch) - this.afterElementsRemoved(removedEls, pruneCids) - - this.liveSocket.triggerDOM("onPatchEnd", [patch.targetContainer]) - return phxChildrenAdded - } - - afterElementsRemoved(elements, pruneCids){ - const destroyedCIDs = [] - elements.forEach(parent => { - const components = DOM.all(parent, `[${PHX_COMPONENT}]`) - const hooks = DOM.all(parent, `[${this.binding(PHX_HOOK)}], [data-phx-hook]`) - components.concat(parent).forEach(el => { - const cid = this.componentID(el) - if(isCid(cid) && destroyedCIDs.indexOf(cid) === -1){ destroyedCIDs.push(cid) } - }) - hooks.concat(parent).forEach(hookEl => { - const hook = this.getHook(hookEl) - hook && this.destroyHook(hook) - }) - }) + if (el.nodeType === Node.ELEMENT_NODE) { + removedEls.push(el); + } + }); + + patch.after("transitionsDiscarded", (els) => + this.afterElementsRemoved(els, pruneCids), + ); + patch.perform(isJoinPatch); + this.afterElementsRemoved(removedEls, pruneCids); + + this.liveSocket.triggerDOM("onPatchEnd", [patch.targetContainer]); + return phxChildrenAdded; + } + + afterElementsRemoved(elements, pruneCids) { + const destroyedCIDs = []; + elements.forEach((parent) => { + const components = DOM.all(parent, `[${PHX_COMPONENT}]`); + const hooks = DOM.all( + parent, + `[${this.binding(PHX_HOOK)}], [data-phx-hook]`, + ); + components.concat(parent).forEach((el) => { + const cid = this.componentID(el); + if (isCid(cid) && destroyedCIDs.indexOf(cid) === -1) { + destroyedCIDs.push(cid); + } + }); + hooks.concat(parent).forEach((hookEl) => { + const hook = this.getHook(hookEl); + hook && this.destroyHook(hook); + }); + }); // We should not pruneCids on joins. Otherwise, in case of // rejoins, we may notify cids that no longer belong to the // current LiveView to be removed. - if(pruneCids){ - this.maybePushComponentsDestroyed(destroyedCIDs) + if (pruneCids) { + this.maybePushComponentsDestroyed(destroyedCIDs); } } - joinNewChildren(){ - DOM.findPhxChildren(this.el, this.id).forEach(el => this.joinChild(el)) + joinNewChildren() { + DOM.findPhxChildren(this.el, this.id).forEach((el) => this.joinChild(el)); } - maybeRecoverForms(html, callback){ - const phxChange = this.binding("change") - const oldForms = this.root.formsForRecovery + maybeRecoverForms(html, callback) { + const phxChange = this.binding("change"); + const oldForms = this.root.formsForRecovery; // So why do we create a template element here? // One way to recover forms would be to immediately apply the mount // patch and then afterwards recover the forms. However, this would @@ -562,16 +658,16 @@ export default class View { // until it is restored. Therefore LV decided to do form recovery with the // raw HTML before it is applied and delay the mount patch until the form // recovery events are done. - const template = document.createElement("template") - template.innerHTML = html + const template = document.createElement("template"); + template.innerHTML = html; // because we work with a template element, we must manually copy the attributes // otherwise the owner / target helpers don't work properly - const rootEl = template.content.firstElementChild - rootEl.id = this.id - rootEl.setAttribute(PHX_ROOT_ID, this.root.id) - rootEl.setAttribute(PHX_SESSION, this.getSession()) - rootEl.setAttribute(PHX_STATIC, this.getStatic()) - rootEl.setAttribute(PHX_PARENT_ID, this.parent ? this.parent.id : null) + const rootEl = template.content.firstElementChild; + rootEl.id = this.id; + rootEl.setAttribute(PHX_ROOT_ID, this.root.id); + rootEl.setAttribute(PHX_SESSION, this.getSession()); + rootEl.setAttribute(PHX_STATIC, this.getStatic()); + rootEl.setAttribute(PHX_PARENT_ID, this.parent ? this.parent.id : null); // we go over all form elements in the new HTML for the LV // and look for old forms in the `formsForRecovery` object; @@ -581,503 +677,688 @@ export default class View { // view, we can be sure that all forms are owned by this view: DOM.all(template.content, "form") // only recover forms that have an id and are in the old DOM - .filter(newForm => newForm.id && oldForms[newForm.id]) + .filter((newForm) => newForm.id && oldForms[newForm.id]) // abandon forms we already tried to recover to prevent looping a failed state - .filter(newForm => !this.pendingForms.has(newForm.id)) + .filter((newForm) => !this.pendingForms.has(newForm.id)) // only recover if the form has the same phx-change value - .filter(newForm => oldForms[newForm.id].getAttribute(phxChange) === newForm.getAttribute(phxChange)) - .map(newForm => { - return [oldForms[newForm.id], newForm] - }) - - if(formsToRecover.length === 0){ - return callback() + .filter( + (newForm) => + oldForms[newForm.id].getAttribute(phxChange) === + newForm.getAttribute(phxChange), + ) + .map((newForm) => { + return [oldForms[newForm.id], newForm]; + }); + + if (formsToRecover.length === 0) { + return callback(); } formsToRecover.forEach(([oldForm, newForm], i) => { - this.pendingForms.add(newForm.id) + this.pendingForms.add(newForm.id); // it is important to use the firstElementChild of the template content // because when traversing a documentFragment using parentNode, we won't ever arrive at // the fragment; as the template is always a LiveView, we can be sure that there is only // one child on the root level - this.pushFormRecovery(oldForm, newForm, template.content.firstElementChild, () => { - this.pendingForms.delete(newForm.id) - // we only call the callback once all forms have been recovered - if(i === formsToRecover.length - 1){ - callback() - } - }) - }) + this.pushFormRecovery( + oldForm, + newForm, + template.content.firstElementChild, + () => { + this.pendingForms.delete(newForm.id); + // we only call the callback once all forms have been recovered + if (i === formsToRecover.length - 1) { + callback(); + } + }, + ); + }); } - getChildById(id){ return this.root.children[this.id][id] } + getChildById(id) { + return this.root.children[this.id][id]; + } - getDescendentByEl(el){ - if(el.id === this.id){ - return this + getDescendentByEl(el) { + if (el.id === this.id) { + return this; } else { - return this.children[el.getAttribute(PHX_PARENT_ID)]?.[el.id] + return this.children[el.getAttribute(PHX_PARENT_ID)]?.[el.id]; } } - destroyDescendent(id){ - for(const parentId in this.root.children){ - for(const childId in this.root.children[parentId]){ - if(childId === id){ return this.root.children[parentId][childId].destroy() } + destroyDescendent(id) { + for (const parentId in this.root.children) { + for (const childId in this.root.children[parentId]) { + if (childId === id) { + return this.root.children[parentId][childId].destroy(); + } } } } - joinChild(el){ - const child = this.getChildById(el.id) - if(!child){ - const view = new View(el, this.liveSocket, this) - this.root.children[this.id][view.id] = view - view.join() - this.childJoins++ - return true + joinChild(el) { + const child = this.getChildById(el.id); + if (!child) { + const view = new View(el, this.liveSocket, this); + this.root.children[this.id][view.id] = view; + view.join(); + this.childJoins++; + return true; } } - isJoinPending(){ return this.joinPending } + isJoinPending() { + return this.joinPending; + } - ackJoin(_child){ - this.childJoins-- + ackJoin(_child) { + this.childJoins--; - if(this.childJoins === 0){ - if(this.parent){ - this.parent.ackJoin(this) + if (this.childJoins === 0) { + if (this.parent) { + this.parent.ackJoin(this); } else { - this.onAllChildJoinsComplete() + this.onAllChildJoinsComplete(); } } } - onAllChildJoinsComplete(){ + onAllChildJoinsComplete() { // we can clear pending form recoveries now that we've joined. // They either all resolved or were abandoned - this.pendingForms.clear() + this.pendingForms.clear(); // we can also clear the formsForRecovery object to not keep old form elements around - this.formsForRecovery = {} + this.formsForRecovery = {}; this.joinCallback(() => { this.pendingJoinOps.forEach(([view, op]) => { - if(!view.isDestroyed()){ op() } - }) - this.pendingJoinOps = [] - }) + if (!view.isDestroyed()) { + op(); + } + }); + this.pendingJoinOps = []; + }); } - update(diff, events){ - if(this.isJoinPending() || (this.liveSocket.hasPendingLink() && this.root.isMain())){ - return this.pendingDiffs.push({diff, events}) + update(diff, events) { + if ( + this.isJoinPending() || + (this.liveSocket.hasPendingLink() && this.root.isMain()) + ) { + return this.pendingDiffs.push({ diff, events }); } - this.rendered.mergeDiff(diff) - let phxChildrenAdded = false + this.rendered.mergeDiff(diff); + let phxChildrenAdded = false; // When the diff only contains component diffs, then walk components // and patch only the parent component containers found in the diff. // Otherwise, patch entire LV container. - if(this.rendered.isComponentOnlyDiff(diff)){ + if (this.rendered.isComponentOnlyDiff(diff)) { this.liveSocket.time("component patch complete", () => { - const parentCids = DOM.findExistingParentCIDs(this.el, this.rendered.componentCIDs(diff)) - parentCids.forEach(parentCID => { - if(this.componentPatch(this.rendered.getComponent(diff, parentCID), parentCID)){ phxChildrenAdded = true } - }) - }) - } else if(!isEmpty(diff)){ + const parentCids = DOM.findExistingParentCIDs( + this.el, + this.rendered.componentCIDs(diff), + ); + parentCids.forEach((parentCID) => { + if ( + this.componentPatch( + this.rendered.getComponent(diff, parentCID), + parentCID, + ) + ) { + phxChildrenAdded = true; + } + }); + }); + } else if (!isEmpty(diff)) { this.liveSocket.time("full patch complete", () => { - const [html, streams] = this.renderContainer(diff, "update") - const patch = new DOMPatch(this, this.el, this.id, html, streams, null) - phxChildrenAdded = this.performPatch(patch, true) - }) + const [html, streams] = this.renderContainer(diff, "update"); + const patch = new DOMPatch(this, this.el, this.id, html, streams, null); + phxChildrenAdded = this.performPatch(patch, true); + }); } - this.liveSocket.dispatchEvents(events) - if(phxChildrenAdded){ this.joinNewChildren() } + this.liveSocket.dispatchEvents(events); + if (phxChildrenAdded) { + this.joinNewChildren(); + } } - renderContainer(diff, kind){ + renderContainer(diff, kind) { return this.liveSocket.time(`toString diff (${kind})`, () => { - const tag = this.el.tagName + const tag = this.el.tagName; // Don't skip any component in the diff nor any marked as pruned // (as they may have been added back) - const cids = diff ? this.rendered.componentCIDs(diff) : null - const {buffer: html, streams} = this.rendered.toString(cids) - return [`<${tag}>${html}`, streams] - }) + const cids = diff ? this.rendered.componentCIDs(diff) : null; + const { buffer: html, streams } = this.rendered.toString(cids); + return [`<${tag}>${html}`, streams]; + }); } - componentPatch(diff, cid){ - if(isEmpty(diff)) return false - const {buffer: html, streams} = this.rendered.componentToString(cid) - const patch = new DOMPatch(this, this.el, this.id, html, streams, cid) - const childrenAdded = this.performPatch(patch, true) - return childrenAdded + componentPatch(diff, cid) { + if (isEmpty(diff)) return false; + const { buffer: html, streams } = this.rendered.componentToString(cid); + const patch = new DOMPatch(this, this.el, this.id, html, streams, cid); + const childrenAdded = this.performPatch(patch, true); + return childrenAdded; } - getHook(el){ return this.viewHooks[ViewHook.elementID(el)] } + getHook(el) { + return this.viewHooks[ViewHook.elementID(el)]; + } - addHook(el){ - const hookElId = ViewHook.elementID(el) + addHook(el) { + const hookElId = ViewHook.elementID(el); // only ever try to add hooks to elements owned by this view - if(el.getAttribute && !this.ownsElement(el)){ return } + if (el.getAttribute && !this.ownsElement(el)) { + return; + } - if(hookElId && !this.viewHooks[hookElId]){ + if (hookElId && !this.viewHooks[hookElId]) { // hook created, but not attached (createHook for web component) - const hook = DOM.getCustomElHook(el) || logError(`no hook found for custom element: ${el.id}`) - this.viewHooks[hookElId] = hook - hook.__attachView(this) - return hook - } - else if(hookElId || !el.getAttribute){ + const hook = + DOM.getCustomElHook(el) || + logError(`no hook found for custom element: ${el.id}`); + this.viewHooks[hookElId] = hook; + hook.__attachView(this); + return hook; + } else if (hookElId || !el.getAttribute) { // no hook found - return + return; } else { // new hook found with phx-hook attribute - const hookName = el.getAttribute(`data-phx-${PHX_HOOK}`) || el.getAttribute(this.binding(PHX_HOOK)) - const hookDefinition = this.liveSocket.getHookCallbacks(hookName) + const hookName = + el.getAttribute(`data-phx-${PHX_HOOK}`) || + el.getAttribute(this.binding(PHX_HOOK)); + const hookDefinition = this.liveSocket.getHookCallbacks(hookName); + + if (hookDefinition) { + if (!el.id) { + logError( + `no DOM ID for hook "${hookName}". Hooks require a unique ID on each element.`, + el, + ); + return; + } - if(hookDefinition){ - if(!el.id){ logError(`no DOM ID for hook "${hookName}". Hooks require a unique ID on each element.`, el); return } - - let hookInstance + let hookInstance; try { - if(typeof hookDefinition === "function" && hookDefinition.prototype instanceof ViewHook){ + if ( + typeof hookDefinition === "function" && + hookDefinition.prototype instanceof ViewHook + ) { // It's a class constructor (subclass of ViewHook) - hookInstance = new hookDefinition(this, el) // `this` is the View instance - } else if(typeof hookDefinition === "object" && hookDefinition !== null){ + hookInstance = new hookDefinition(this, el); // `this` is the View instance + } else if ( + typeof hookDefinition === "object" && + hookDefinition !== null + ) { // It's an object literal, pass it to the ViewHook constructor for wrapping - hookInstance = new ViewHook(this, el, hookDefinition) + hookInstance = new ViewHook(this, el, hookDefinition); } else { - logError(`Invalid hook definition for "${hookName}". Expected a class extending ViewHook or an object definition.`, el) - return + logError( + `Invalid hook definition for "${hookName}". Expected a class extending ViewHook or an object definition.`, + el, + ); + return; } - } catch (e){ - const errorMessage = e instanceof Error ? e.message : String(e) - logError(`Failed to create hook "${hookName}": ${errorMessage}`, el) - return + } catch (e) { + const errorMessage = e instanceof Error ? e.message : String(e); + logError(`Failed to create hook "${hookName}": ${errorMessage}`, el); + return; } - this.viewHooks[ViewHook.elementID(hookInstance.el)] = hookInstance - return hookInstance - } else if(hookName !== null){ - logError(`unknown hook found for "${hookName}"`, el) + this.viewHooks[ViewHook.elementID(hookInstance.el)] = hookInstance; + return hookInstance; + } else if (hookName !== null) { + logError(`unknown hook found for "${hookName}"`, el); } } } - destroyHook(hook){ + destroyHook(hook) { // __destroyed clears the elementID from the hook, therefore // we need to get it before calling __destroyed - const hookId = ViewHook.elementID(hook.el) - hook.__destroyed() - hook.__cleanup__() - delete this.viewHooks[hookId] + const hookId = ViewHook.elementID(hook.el); + hook.__destroyed(); + hook.__cleanup__(); + delete this.viewHooks[hookId]; } - applyPendingUpdates(){ + applyPendingUpdates() { // prevent race conditions where we might still be pending a new // navigation after applying the current one; // if we call update and a pendingDiff is not applied, it would // be silently dropped otherwise, as update would push it back to // pendingDiffs, but we clear it immediately after - if(this.liveSocket.hasPendingLink() && this.root.isMain()){ return } - this.pendingDiffs.forEach(({diff, events}) => this.update(diff, events)) - this.pendingDiffs = [] - this.eachChild(child => child.applyPendingUpdates()) + if (this.liveSocket.hasPendingLink() && this.root.isMain()) { + return; + } + this.pendingDiffs.forEach(({ diff, events }) => this.update(diff, events)); + this.pendingDiffs = []; + this.eachChild((child) => child.applyPendingUpdates()); } - eachChild(callback){ - const children = this.root.children[this.id] || {} - for(const id in children){ callback(this.getChildById(id)) } + eachChild(callback) { + const children = this.root.children[this.id] || {}; + for (const id in children) { + callback(this.getChildById(id)); + } } - onChannel(event, cb){ - this.liveSocket.onChannel(this.channel, event, resp => { - if(this.isJoinPending()){ - this.root.pendingJoinOps.push([this, () => cb(resp)]) + onChannel(event, cb) { + this.liveSocket.onChannel(this.channel, event, (resp) => { + if (this.isJoinPending()) { + this.root.pendingJoinOps.push([this, () => cb(resp)]); } else { - this.liveSocket.requestDOMUpdate(() => cb(resp)) + this.liveSocket.requestDOMUpdate(() => cb(resp)); } - }) + }); } - bindChannel(){ + bindChannel() { // The diff event should be handled by the regular update operations. // All other operations are queued to be applied only after join. this.liveSocket.onChannel(this.channel, "diff", (rawDiff) => { this.liveSocket.requestDOMUpdate(() => { - this.applyDiff("update", rawDiff, ({diff, events}) => this.update(diff, events)) - }) - }) - this.onChannel("redirect", ({to, flash}) => this.onRedirect({to, flash})) - this.onChannel("live_patch", (redir) => this.onLivePatch(redir)) - this.onChannel("live_redirect", (redir) => this.onLiveRedirect(redir)) - this.channel.onError(reason => this.onError(reason)) - this.channel.onClose(reason => this.onClose(reason)) + this.applyDiff("update", rawDiff, ({ diff, events }) => + this.update(diff, events), + ); + }); + }); + this.onChannel("redirect", ({ to, flash }) => + this.onRedirect({ to, flash }), + ); + this.onChannel("live_patch", (redir) => this.onLivePatch(redir)); + this.onChannel("live_redirect", (redir) => this.onLiveRedirect(redir)); + this.channel.onError((reason) => this.onError(reason)); + this.channel.onClose((reason) => this.onClose(reason)); } - destroyAllChildren(){ this.eachChild(child => child.destroy()) } + destroyAllChildren() { + this.eachChild((child) => child.destroy()); + } - onLiveRedirect(redir){ - const {to, kind, flash} = redir - const url = this.expandURL(to) - const e = new CustomEvent("phx:server-navigate", {detail: {to, kind, flash}}) - this.liveSocket.historyRedirect(e, url, kind, flash) + onLiveRedirect(redir) { + const { to, kind, flash } = redir; + const url = this.expandURL(to); + const e = new CustomEvent("phx:server-navigate", { + detail: { to, kind, flash }, + }); + this.liveSocket.historyRedirect(e, url, kind, flash); } - onLivePatch(redir){ - const {to, kind} = redir - this.href = this.expandURL(to) - this.liveSocket.historyPatch(to, kind) + onLivePatch(redir) { + const { to, kind } = redir; + this.href = this.expandURL(to); + this.liveSocket.historyPatch(to, kind); } - expandURL(to){ - return to.startsWith("/") ? `${window.location.protocol}//${window.location.host}${to}` : to + expandURL(to) { + return to.startsWith("/") + ? `${window.location.protocol}//${window.location.host}${to}` + : to; } /** * @param {{to: string, flash?: string, reloadToken?: string}} redirect */ - onRedirect({to, flash, reloadToken}){ this.liveSocket.redirect(to, flash, reloadToken) } + onRedirect({ to, flash, reloadToken }) { + this.liveSocket.redirect(to, flash, reloadToken); + } - isDestroyed(){ return this.destroyed } + isDestroyed() { + return this.destroyed; + } - joinDead(){ this.isDead = true } + joinDead() { + this.isDead = true; + } - joinPush(){ - this.joinPush = this.joinPush || this.channel.join() - return this.joinPush + joinPush() { + this.joinPush = this.joinPush || this.channel.join(); + return this.joinPush; } - join(callback){ - this.showLoader(this.liveSocket.loaderTimeout) - this.bindChannel() - if(this.isMain()){ - this.stopCallback = this.liveSocket.withPageLoading({to: this.href, kind: "initial"}) + join(callback) { + this.showLoader(this.liveSocket.loaderTimeout); + this.bindChannel(); + if (this.isMain()) { + this.stopCallback = this.liveSocket.withPageLoading({ + to: this.href, + kind: "initial", + }); } this.joinCallback = (onDone) => { - onDone = onDone || function(){} - callback ? callback(this.joinCount, onDone) : onDone() - } + onDone = onDone || function () {}; + callback ? callback(this.joinCount, onDone) : onDone(); + }; this.wrapPush(() => this.channel.join(), { ok: (resp) => this.liveSocket.requestDOMUpdate(() => this.onJoin(resp)), error: (error) => this.onJoinError(error), - timeout: () => this.onJoinError({reason: "timeout"}) - }) - } - - onJoinError(resp){ - if(resp.reason === "reload"){ - this.log("error", () => [`failed mount with ${resp.status}. Falling back to page reload`, resp]) - this.onRedirect({to: this.root.href, reloadToken: resp.token}) - return - } else if(resp.reason === "unauthorized" || resp.reason === "stale"){ - this.log("error", () => ["unauthorized live_redirect. Falling back to page request", resp]) - this.onRedirect({to: this.root.href, flash: this.flash}) - return - } - if(resp.redirect || resp.live_redirect){ - this.joinPending = false - this.channel.leave() - } - if(resp.redirect){ return this.onRedirect(resp.redirect) } - if(resp.live_redirect){ return this.onLiveRedirect(resp.live_redirect) } - this.log("error", () => ["unable to join", resp]) - if(this.isMain()){ - this.displayError([PHX_LOADING_CLASS, PHX_ERROR_CLASS, PHX_SERVER_ERROR_CLASS]) - if(this.liveSocket.isConnected()){ this.liveSocket.reloadWithJitter(this) } + timeout: () => this.onJoinError({ reason: "timeout" }), + }); + } + + onJoinError(resp) { + if (resp.reason === "reload") { + this.log("error", () => [ + `failed mount with ${resp.status}. Falling back to page reload`, + resp, + ]); + this.onRedirect({ to: this.root.href, reloadToken: resp.token }); + return; + } else if (resp.reason === "unauthorized" || resp.reason === "stale") { + this.log("error", () => [ + "unauthorized live_redirect. Falling back to page request", + resp, + ]); + this.onRedirect({ to: this.root.href, flash: this.flash }); + return; + } + if (resp.redirect || resp.live_redirect) { + this.joinPending = false; + this.channel.leave(); + } + if (resp.redirect) { + return this.onRedirect(resp.redirect); + } + if (resp.live_redirect) { + return this.onLiveRedirect(resp.live_redirect); + } + this.log("error", () => ["unable to join", resp]); + if (this.isMain()) { + this.displayError([ + PHX_LOADING_CLASS, + PHX_ERROR_CLASS, + PHX_SERVER_ERROR_CLASS, + ]); + if (this.liveSocket.isConnected()) { + this.liveSocket.reloadWithJitter(this); + } } else { - if(this.joinAttempts >= MAX_CHILD_JOIN_ATTEMPTS){ + if (this.joinAttempts >= MAX_CHILD_JOIN_ATTEMPTS) { // put the root review into permanent error state, but don't destroy it as it can remain active - this.root.displayError([PHX_LOADING_CLASS, PHX_ERROR_CLASS, PHX_SERVER_ERROR_CLASS]) - this.log("error", () => [`giving up trying to mount after ${MAX_CHILD_JOIN_ATTEMPTS} tries`, resp]) - this.destroy() + this.root.displayError([ + PHX_LOADING_CLASS, + PHX_ERROR_CLASS, + PHX_SERVER_ERROR_CLASS, + ]); + this.log("error", () => [ + `giving up trying to mount after ${MAX_CHILD_JOIN_ATTEMPTS} tries`, + resp, + ]); + this.destroy(); } - const trueChildEl = DOM.byId(this.el.id) - if(trueChildEl){ - DOM.mergeAttrs(trueChildEl, this.el) - this.displayError([PHX_LOADING_CLASS, PHX_ERROR_CLASS, PHX_SERVER_ERROR_CLASS]) - this.el = trueChildEl + const trueChildEl = DOM.byId(this.el.id); + if (trueChildEl) { + DOM.mergeAttrs(trueChildEl, this.el); + this.displayError([ + PHX_LOADING_CLASS, + PHX_ERROR_CLASS, + PHX_SERVER_ERROR_CLASS, + ]); + this.el = trueChildEl; } else { - this.destroy() + this.destroy(); } } } - onClose(reason){ - if(this.isDestroyed()){ return } - if(this.isMain() && this.liveSocket.hasPendingLink() && reason !== "leave"){ - return this.liveSocket.reloadWithJitter(this) + onClose(reason) { + if (this.isDestroyed()) { + return; + } + if ( + this.isMain() && + this.liveSocket.hasPendingLink() && + reason !== "leave" + ) { + return this.liveSocket.reloadWithJitter(this); } - this.destroyAllChildren() - this.liveSocket.dropActiveElement(this) - if(this.liveSocket.isUnloaded()){ - this.showLoader(BEFORE_UNLOAD_LOADER_TIMEOUT) + this.destroyAllChildren(); + this.liveSocket.dropActiveElement(this); + if (this.liveSocket.isUnloaded()) { + this.showLoader(BEFORE_UNLOAD_LOADER_TIMEOUT); } } - onError(reason){ - this.onClose(reason) - if(this.liveSocket.isConnected()){ this.log("error", () => ["view crashed", reason]) } - if(!this.liveSocket.isUnloaded()){ - if(this.liveSocket.isConnected()){ - this.displayError([PHX_LOADING_CLASS, PHX_ERROR_CLASS, PHX_SERVER_ERROR_CLASS]) + onError(reason) { + this.onClose(reason); + if (this.liveSocket.isConnected()) { + this.log("error", () => ["view crashed", reason]); + } + if (!this.liveSocket.isUnloaded()) { + if (this.liveSocket.isConnected()) { + this.displayError([ + PHX_LOADING_CLASS, + PHX_ERROR_CLASS, + PHX_SERVER_ERROR_CLASS, + ]); } else { - this.displayError([PHX_LOADING_CLASS, PHX_ERROR_CLASS, PHX_CLIENT_ERROR_CLASS]) + this.displayError([ + PHX_LOADING_CLASS, + PHX_ERROR_CLASS, + PHX_CLIENT_ERROR_CLASS, + ]); } } } - displayError(classes){ - if(this.isMain()){ DOM.dispatchEvent(window, "phx:page-loading-start", {detail: {to: this.href, kind: "error"}}) } - this.showLoader() - this.setContainerClasses(...classes) - this.delayedDisconnected() + displayError(classes) { + if (this.isMain()) { + DOM.dispatchEvent(window, "phx:page-loading-start", { + detail: { to: this.href, kind: "error" }, + }); + } + this.showLoader(); + this.setContainerClasses(...classes); + this.delayedDisconnected(); } - delayedDisconnected(){ + delayedDisconnected() { this.disconnectedTimer = setTimeout(() => { - this.execAll(this.binding("disconnected")) - }, this.liveSocket.disconnectedTimeout) + this.execAll(this.binding("disconnected")); + }, this.liveSocket.disconnectedTimeout); } - wrapPush(callerPush, receives){ - const latency = this.liveSocket.getLatencySim() - const withLatency = latency ? - (cb) => setTimeout(() => !this.isDestroyed() && cb(), latency) : - (cb) => !this.isDestroyed() && cb() + wrapPush(callerPush, receives) { + const latency = this.liveSocket.getLatencySim(); + const withLatency = latency + ? (cb) => setTimeout(() => !this.isDestroyed() && cb(), latency) + : (cb) => !this.isDestroyed() && cb(); withLatency(() => { callerPush() - .receive("ok", resp => withLatency(() => receives.ok && receives.ok(resp))) - .receive("error", reason => withLatency(() => receives.error && receives.error(reason))) - .receive("timeout", () => withLatency(() => receives.timeout && receives.timeout())) - }) - } - - pushWithReply(refGenerator, event, payload){ - if(!this.isConnected()){ return Promise.reject(new Error("no connection")) } + .receive("ok", (resp) => + withLatency(() => receives.ok && receives.ok(resp)), + ) + .receive("error", (reason) => + withLatency(() => receives.error && receives.error(reason)), + ) + .receive("timeout", () => + withLatency(() => receives.timeout && receives.timeout()), + ); + }); + } + + pushWithReply(refGenerator, event, payload) { + if (!this.isConnected()) { + return Promise.reject(new Error("no connection")); + } - const [ref, [el], opts] = refGenerator ? refGenerator() : [null, [], {}] - const oldJoinCount = this.joinCount - let onLoadingDone = function(){} - if(opts.page_loading){ - onLoadingDone = this.liveSocket.withPageLoading({kind: "element", target: el}) + const [ref, [el], opts] = refGenerator ? refGenerator() : [null, [], {}]; + const oldJoinCount = this.joinCount; + let onLoadingDone = function () {}; + if (opts.page_loading) { + onLoadingDone = this.liveSocket.withPageLoading({ + kind: "element", + target: el, + }); } - if(typeof (payload.cid) !== "number"){ delete payload.cid } + if (typeof payload.cid !== "number") { + delete payload.cid; + } return new Promise((resolve, reject) => { this.wrapPush(() => this.channel.push(event, payload, PUSH_TIMEOUT), { ok: (resp) => { - if(ref !== null){ this.lastAckRef = ref } - const finish = (hookReply) => { - if(resp.redirect){ this.onRedirect(resp.redirect) } - if(resp.live_patch){ this.onLivePatch(resp.live_patch) } - if(resp.live_redirect){ this.onLiveRedirect(resp.live_redirect) } - onLoadingDone() - resolve({resp: resp, reply: hookReply}) + if (ref !== null) { + this.lastAckRef = ref; } - if(resp.diff){ + const finish = (hookReply) => { + if (resp.redirect) { + this.onRedirect(resp.redirect); + } + if (resp.live_patch) { + this.onLivePatch(resp.live_patch); + } + if (resp.live_redirect) { + this.onLiveRedirect(resp.live_redirect); + } + onLoadingDone(); + resolve({ resp: resp, reply: hookReply }); + }; + if (resp.diff) { this.liveSocket.requestDOMUpdate(() => { - this.applyDiff("update", resp.diff, ({diff, reply, events}) => { - if(ref !== null){ - this.undoRefs(ref, payload.event) + this.applyDiff("update", resp.diff, ({ diff, reply, events }) => { + if (ref !== null) { + this.undoRefs(ref, payload.event); } - this.update(diff, events) - finish(reply) - }) - }) + this.update(diff, events); + finish(reply); + }); + }); } else { - if(ref !== null){ this.undoRefs(ref, payload.event) } - finish(null) + if (ref !== null) { + this.undoRefs(ref, payload.event); + } + finish(null); } }, error: (reason) => reject(new Error(`failed with reason: ${reason}`)), timeout: () => { - reject(new Error("timeout")) - if(this.joinCount === oldJoinCount){ + reject(new Error("timeout")); + if (this.joinCount === oldJoinCount) { this.liveSocket.reloadWithJitter(this, () => { - this.log("timeout", () => ["received timeout while communicating with server. Falling back to hard refresh for recovery"]) - }) + this.log("timeout", () => [ + "received timeout while communicating with server. Falling back to hard refresh for recovery", + ]); + }); } - } - }) - }) + }, + }); + }); } - undoRefs(ref, phxEvent, onlyEls){ - if(!this.isConnected()){ return } // exit if external form triggered - const selector = `[${PHX_REF_SRC}="${this.refSrc()}"]` + undoRefs(ref, phxEvent, onlyEls) { + if (!this.isConnected()) { + return; + } // exit if external form triggered + const selector = `[${PHX_REF_SRC}="${this.refSrc()}"]`; - if(onlyEls){ - onlyEls = new Set(onlyEls) - DOM.all(document, selector, parent => { - if(onlyEls && !onlyEls.has(parent)){ return } + if (onlyEls) { + onlyEls = new Set(onlyEls); + DOM.all(document, selector, (parent) => { + if (onlyEls && !onlyEls.has(parent)) { + return; + } // undo any child refs within parent first - DOM.all(parent, selector, child => this.undoElRef(child, ref, phxEvent)) - this.undoElRef(parent, ref, phxEvent) - }) + DOM.all(parent, selector, (child) => + this.undoElRef(child, ref, phxEvent), + ); + this.undoElRef(parent, ref, phxEvent); + }); } else { - DOM.all(document, selector, el => this.undoElRef(el, ref, phxEvent)) + DOM.all(document, selector, (el) => this.undoElRef(el, ref, phxEvent)); } } - undoElRef(el, ref, phxEvent){ - const elRef = new ElementRef(el) + undoElRef(el, ref, phxEvent) { + const elRef = new ElementRef(el); - elRef.maybeUndo(ref, phxEvent, clonedTree => { + elRef.maybeUndo(ref, phxEvent, (clonedTree) => { // we need to perform a full patch on unlocked elements // to perform all the necessary logic (like calling updated for hooks, etc.) - const patch = new DOMPatch(this, el, this.id, clonedTree, [], null, {undoRef: ref}) - const phxChildrenAdded = this.performPatch(patch, true) - DOM.all(el, `[${PHX_REF_SRC}="${this.refSrc()}"]`, child => this.undoElRef(child, ref, phxEvent)) - if(phxChildrenAdded){ this.joinNewChildren() } - }) + const patch = new DOMPatch(this, el, this.id, clonedTree, [], null, { + undoRef: ref, + }); + const phxChildrenAdded = this.performPatch(patch, true); + DOM.all(el, `[${PHX_REF_SRC}="${this.refSrc()}"]`, (child) => + this.undoElRef(child, ref, phxEvent), + ); + if (phxChildrenAdded) { + this.joinNewChildren(); + } + }); } - refSrc(){ return this.el.id } + refSrc() { + return this.el.id; + } - putRef(elements, phxEvent, eventType, opts = {}){ - const newRef = this.ref++ - const disableWith = this.binding(PHX_DISABLE_WITH) - if(opts.loading){ - const loadingEls = DOM.all(document, opts.loading).map(el => { - return {el, lock: true, loading: true} - }) - elements = elements.concat(loadingEls) + putRef(elements, phxEvent, eventType, opts = {}) { + const newRef = this.ref++; + const disableWith = this.binding(PHX_DISABLE_WITH); + if (opts.loading) { + const loadingEls = DOM.all(document, opts.loading).map((el) => { + return { el, lock: true, loading: true }; + }); + elements = elements.concat(loadingEls); } - for(const {el, lock, loading} of elements){ - if(!lock && !loading){ throw new Error("putRef requires lock or loading") } - el.setAttribute(PHX_REF_SRC, this.refSrc()) - if(loading){ el.setAttribute(PHX_REF_LOADING, newRef) } - if(lock){ el.setAttribute(PHX_REF_LOCK, newRef) } - - if(!loading || (opts.submitter && !(el === opts.submitter || el === opts.form))){ continue } - - const lockCompletePromise = new Promise(resolve => { - el.addEventListener(`phx:undo-lock:${newRef}`, () => resolve(detail), {once: true}) - }) + for (const { el, lock, loading } of elements) { + if (!lock && !loading) { + throw new Error("putRef requires lock or loading"); + } + el.setAttribute(PHX_REF_SRC, this.refSrc()); + if (loading) { + el.setAttribute(PHX_REF_LOADING, newRef); + } + if (lock) { + el.setAttribute(PHX_REF_LOCK, newRef); + } - const loadingCompletePromise = new Promise(resolve => { - el.addEventListener(`phx:undo-loading:${newRef}`, () => resolve(detail), {once: true}) - }) + if ( + !loading || + (opts.submitter && !(el === opts.submitter || el === opts.form)) + ) { + continue; + } - el.classList.add(`phx-${eventType}-loading`) - const disableText = el.getAttribute(disableWith) - if(disableText !== null){ - if(!el.getAttribute(PHX_DISABLE_WITH_RESTORE)){ - el.setAttribute(PHX_DISABLE_WITH_RESTORE, el.innerText) + const lockCompletePromise = new Promise((resolve) => { + el.addEventListener(`phx:undo-lock:${newRef}`, () => resolve(detail), { + once: true, + }); + }); + + const loadingCompletePromise = new Promise((resolve) => { + el.addEventListener( + `phx:undo-loading:${newRef}`, + () => resolve(detail), + { once: true }, + ); + }); + + el.classList.add(`phx-${eventType}-loading`); + const disableText = el.getAttribute(disableWith); + if (disableText !== null) { + if (!el.getAttribute(PHX_DISABLE_WITH_RESTORE)) { + el.setAttribute(PHX_DISABLE_WITH_RESTORE, el.innerText); + } + if (disableText !== "") { + el.innerText = disableText; } - if(disableText !== ""){ el.innerText = disableText } // PHX_DISABLED could have already been set in disableForm - el.setAttribute(PHX_DISABLED, el.getAttribute(PHX_DISABLED) || el.disabled) - el.setAttribute("disabled", "") + el.setAttribute( + PHX_DISABLED, + el.getAttribute(PHX_DISABLED) || el.disabled, + ); + el.setAttribute("disabled", ""); } const detail = { @@ -1086,157 +1367,220 @@ export default class View { ref: newRef, isLoading: loading, isLocked: lock, - lockElements: elements.filter(({lock}) => lock).map(({el}) => el), - loadingElements: elements.filter(({loading}) => loading).map(({el}) => el), + lockElements: elements.filter(({ lock }) => lock).map(({ el }) => el), + loadingElements: elements + .filter(({ loading }) => loading) + .map(({ el }) => el), unlock: (els) => { - els = Array.isArray(els) ? els : [els] - this.undoRefs(newRef, phxEvent, els) + els = Array.isArray(els) ? els : [els]; + this.undoRefs(newRef, phxEvent, els); }, lockComplete: lockCompletePromise, loadingComplete: loadingCompletePromise, lock: (lockEl) => { - return new Promise(resolve => { - if(this.isAcked(newRef)){ return resolve(detail) } - lockEl.setAttribute(PHX_REF_LOCK, newRef) - lockEl.setAttribute(PHX_REF_SRC, this.refSrc()) - lockEl.addEventListener(`phx:lock-stop:${newRef}`, () => resolve(detail), {once: true}) - }) - } - } - el.dispatchEvent(new CustomEvent("phx:push", { - detail: detail, - bubbles: true, - cancelable: false - })) - if(phxEvent){ - el.dispatchEvent(new CustomEvent(`phx:push:${phxEvent}`, { + return new Promise((resolve) => { + if (this.isAcked(newRef)) { + return resolve(detail); + } + lockEl.setAttribute(PHX_REF_LOCK, newRef); + lockEl.setAttribute(PHX_REF_SRC, this.refSrc()); + lockEl.addEventListener( + `phx:lock-stop:${newRef}`, + () => resolve(detail), + { once: true }, + ); + }); + }, + }; + el.dispatchEvent( + new CustomEvent("phx:push", { detail: detail, bubbles: true, - cancelable: false - })) + cancelable: false, + }), + ); + if (phxEvent) { + el.dispatchEvent( + new CustomEvent(`phx:push:${phxEvent}`, { + detail: detail, + bubbles: true, + cancelable: false, + }), + ); } } - return [newRef, elements.map(({el}) => el), opts] + return [newRef, elements.map(({ el }) => el), opts]; } - isAcked(ref){ return this.lastAckRef !== null && this.lastAckRef >= ref } + isAcked(ref) { + return this.lastAckRef !== null && this.lastAckRef >= ref; + } - componentID(el){ - const cid = el.getAttribute && el.getAttribute(PHX_COMPONENT) - return cid ? parseInt(cid) : null + componentID(el) { + const cid = el.getAttribute && el.getAttribute(PHX_COMPONENT); + return cid ? parseInt(cid) : null; } - targetComponentID(target, targetCtx, opts = {}){ - if(isCid(targetCtx)){ return targetCtx } + targetComponentID(target, targetCtx, opts = {}) { + if (isCid(targetCtx)) { + return targetCtx; + } - const cidOrSelector = opts.target || target.getAttribute(this.binding("target")) - if(isCid(cidOrSelector)){ - return parseInt(cidOrSelector) - } else if(targetCtx && (cidOrSelector !== null || opts.target)){ - return this.closestComponentID(targetCtx) + const cidOrSelector = + opts.target || target.getAttribute(this.binding("target")); + if (isCid(cidOrSelector)) { + return parseInt(cidOrSelector); + } else if (targetCtx && (cidOrSelector !== null || opts.target)) { + return this.closestComponentID(targetCtx); } else { - return null + return null; } } - closestComponentID(targetCtx){ - if(isCid(targetCtx)){ - return targetCtx - } else if(targetCtx){ - return maybe(targetCtx.closest(`[${PHX_COMPONENT}]`), el => this.ownsElement(el) && this.componentID(el)) + closestComponentID(targetCtx) { + if (isCid(targetCtx)) { + return targetCtx; + } else if (targetCtx) { + return maybe( + targetCtx.closest(`[${PHX_COMPONENT}]`), + (el) => this.ownsElement(el) && this.componentID(el), + ); } else { - return null + return null; } } - pushHookEvent(el, targetCtx, event, payload){ - if(!this.isConnected()){ - this.log("hook", () => ["unable to push hook event. LiveView not connected", event, payload]) - return Promise.reject(new Error("unable to push hook event. LiveView not connected")) + pushHookEvent(el, targetCtx, event, payload) { + if (!this.isConnected()) { + this.log("hook", () => [ + "unable to push hook event. LiveView not connected", + event, + payload, + ]); + return Promise.reject( + new Error("unable to push hook event. LiveView not connected"), + ); } - let [ref, els, opts] = this.putRef([{el, loading: true, lock: true}], event, "hook") - + let [ref, els, opts] = this.putRef( + [{ el, loading: true, lock: true }], + event, + "hook", + ); + return this.pushWithReply(() => [ref, els, opts], "event", { type: "hook", event: event, value: payload, - cid: this.closestComponentID(targetCtx) - }).then(({resp: _resp, reply}) => ({reply, ref})) + cid: this.closestComponentID(targetCtx), + }).then(({ resp: _resp, reply }) => ({ reply, ref })); } - extractMeta(el, meta, value){ - const prefix = this.binding("value-") - for(let i = 0; i < el.attributes.length; i++){ - if(!meta){ meta = {} } - const name = el.attributes[i].name - if(name.startsWith(prefix)){ meta[name.replace(prefix, "")] = el.getAttribute(name) } + extractMeta(el, meta, value) { + const prefix = this.binding("value-"); + for (let i = 0; i < el.attributes.length; i++) { + if (!meta) { + meta = {}; + } + const name = el.attributes[i].name; + if (name.startsWith(prefix)) { + meta[name.replace(prefix, "")] = el.getAttribute(name); + } } - if(el.value !== undefined && !(el instanceof HTMLFormElement)){ - if(!meta){ meta = {} } - meta.value = el.value - - if(el.tagName === "INPUT" && CHECKABLE_INPUTS.indexOf(el.type) >= 0 && !el.checked){ - delete meta.value + if (el.value !== undefined && !(el instanceof HTMLFormElement)) { + if (!meta) { + meta = {}; + } + meta.value = el.value; + + if ( + el.tagName === "INPUT" && + CHECKABLE_INPUTS.indexOf(el.type) >= 0 && + !el.checked + ) { + delete meta.value; } } - if(value){ - if(!meta){ meta = {} } - for(const key in value){ meta[key] = value[key] } + if (value) { + if (!meta) { + meta = {}; + } + for (const key in value) { + meta[key] = value[key]; + } } - return meta + return meta; } - pushEvent(type, el, targetCtx, phxEvent, meta, opts = {}, onReply){ - this.pushWithReply(() => this.putRef([{el, loading: true, lock: true}], phxEvent, type, opts), "event", { - type: type, - event: phxEvent, - value: this.extractMeta(el, meta, opts.value), - cid: this.targetComponentID(el, targetCtx, opts) - }) - .then(({reply}) => onReply && onReply(reply)) - .catch((error) => logError("Failed to push event", error)) + pushEvent(type, el, targetCtx, phxEvent, meta, opts = {}, onReply) { + this.pushWithReply( + () => + this.putRef([{ el, loading: true, lock: true }], phxEvent, type, opts), + "event", + { + type: type, + event: phxEvent, + value: this.extractMeta(el, meta, opts.value), + cid: this.targetComponentID(el, targetCtx, opts), + }, + ) + .then(({ reply }) => onReply && onReply(reply)) + .catch((error) => logError("Failed to push event", error)); } - pushFileProgress(fileEl, entryRef, progress, onReply = function (){ }){ + pushFileProgress(fileEl, entryRef, progress, onReply = function () {}) { this.liveSocket.withinOwners(fileEl.form, (view, targetCtx) => { - view.pushWithReply(null, "progress", { - event: fileEl.getAttribute(view.binding(PHX_PROGRESS)), - ref: fileEl.getAttribute(PHX_UPLOAD_REF), - entry_ref: entryRef, - progress: progress, - cid: view.targetComponentID(fileEl.form, targetCtx) - }) + view + .pushWithReply(null, "progress", { + event: fileEl.getAttribute(view.binding(PHX_PROGRESS)), + ref: fileEl.getAttribute(PHX_UPLOAD_REF), + entry_ref: entryRef, + progress: progress, + cid: view.targetComponentID(fileEl.form, targetCtx), + }) .then(() => onReply()) - .catch((error) => logError("Failed to push file progress", error)) - }) + .catch((error) => logError("Failed to push file progress", error)); + }); } - pushInput(inputEl, targetCtx, forceCid, phxEvent, opts, callback){ - if(!inputEl.form){ - throw new Error("form events require the input to be inside a form") + pushInput(inputEl, targetCtx, forceCid, phxEvent, opts, callback) { + if (!inputEl.form) { + throw new Error("form events require the input to be inside a form"); } - let uploads - const cid = isCid(forceCid) ? forceCid : this.targetComponentID(inputEl.form, targetCtx, opts) + let uploads; + const cid = isCid(forceCid) + ? forceCid + : this.targetComponentID(inputEl.form, targetCtx, opts); const refGenerator = () => { - return this.putRef([ - {el: inputEl, loading: true, lock: true}, - {el: inputEl.form, loading: true, lock: true} - ], phxEvent, "change", opts) - } - let formData - const meta = this.extractMeta(inputEl.form, {}, opts.value) - const serializeOpts = {} - if(inputEl instanceof HTMLButtonElement){ serializeOpts.submitter = inputEl } - if(inputEl.getAttribute(this.binding("change"))){ - formData = serializeForm(inputEl.form, serializeOpts, [inputEl.name]) + return this.putRef( + [ + { el: inputEl, loading: true, lock: true }, + { el: inputEl.form, loading: true, lock: true }, + ], + phxEvent, + "change", + opts, + ); + }; + let formData; + const meta = this.extractMeta(inputEl.form, {}, opts.value); + const serializeOpts = {}; + if (inputEl instanceof HTMLButtonElement) { + serializeOpts.submitter = inputEl; + } + if (inputEl.getAttribute(this.binding("change"))) { + formData = serializeForm(inputEl.form, serializeOpts, [inputEl.name]); } else { - formData = serializeForm(inputEl.form, serializeOpts) + formData = serializeForm(inputEl.form, serializeOpts); } - if(DOM.isUploadInput(inputEl) && inputEl.files && inputEl.files.length > 0){ - LiveUploader.trackFiles(inputEl, Array.from(inputEl.files)) + if ( + DOM.isUploadInput(inputEl) && + inputEl.files && + inputEl.files.length > 0 + ) { + LiveUploader.trackFiles(inputEl, Array.from(inputEl.files)); } - uploads = LiveUploader.serializeUploads(inputEl) + uploads = LiveUploader.serializeUploads(inputEl); const event = { type: "form", @@ -1248,359 +1592,473 @@ export default class View { // to passing it directly in the event, but the JSON encode would drop keys with // undefined values. _target: opts._target || "undefined", - ...meta + ...meta, }, uploads: uploads, - cid: cid - } - this.pushWithReply(refGenerator, "event", event).then(({resp}) => { - if(DOM.isUploadInput(inputEl) && DOM.isAutoUpload(inputEl)){ - // the element could be inside a locked parent for other unrelated changes; - // we can only start uploads when the tree is unlocked and the - // necessary data attributes are set in the real DOM - ElementRef.onUnlock(inputEl, () => { - if(LiveUploader.filesAwaitingPreflight(inputEl).length > 0){ - const [ref, _els] = refGenerator() - this.undoRefs(ref, phxEvent, [inputEl.form]) - this.uploadFiles(inputEl.form, phxEvent, targetCtx, ref, cid, (_uploads) => { - callback && callback(resp) - this.triggerAwaitingSubmit(inputEl.form, phxEvent) - this.undoRefs(ref, phxEvent) - }) - } - }) - } else { - callback && callback(resp) - } - }).catch((error) => logError("Failed to push input event", error)) + cid: cid, + }; + this.pushWithReply(refGenerator, "event", event) + .then(({ resp }) => { + if (DOM.isUploadInput(inputEl) && DOM.isAutoUpload(inputEl)) { + // the element could be inside a locked parent for other unrelated changes; + // we can only start uploads when the tree is unlocked and the + // necessary data attributes are set in the real DOM + ElementRef.onUnlock(inputEl, () => { + if (LiveUploader.filesAwaitingPreflight(inputEl).length > 0) { + const [ref, _els] = refGenerator(); + this.undoRefs(ref, phxEvent, [inputEl.form]); + this.uploadFiles( + inputEl.form, + phxEvent, + targetCtx, + ref, + cid, + (_uploads) => { + callback && callback(resp); + this.triggerAwaitingSubmit(inputEl.form, phxEvent); + this.undoRefs(ref, phxEvent); + }, + ); + } + }); + } else { + callback && callback(resp); + } + }) + .catch((error) => logError("Failed to push input event", error)); } - triggerAwaitingSubmit(formEl, phxEvent){ - const awaitingSubmit = this.getScheduledSubmit(formEl) - if(awaitingSubmit){ - const [_el, _ref, _opts, callback] = awaitingSubmit - this.cancelSubmit(formEl, phxEvent) - callback() + triggerAwaitingSubmit(formEl, phxEvent) { + const awaitingSubmit = this.getScheduledSubmit(formEl); + if (awaitingSubmit) { + const [_el, _ref, _opts, callback] = awaitingSubmit; + this.cancelSubmit(formEl, phxEvent); + callback(); } } - getScheduledSubmit(formEl){ - return this.formSubmits.find(([el, _ref, _opts, _callback]) => el.isSameNode(formEl)) - } - - scheduleSubmit(formEl, ref, opts, callback){ - if(this.getScheduledSubmit(formEl)){ return true } - this.formSubmits.push([formEl, ref, opts, callback]) - } - - cancelSubmit(formEl, phxEvent){ - this.formSubmits = this.formSubmits.filter(([el, ref, _opts, _callback]) => { - if(el.isSameNode(formEl)){ - this.undoRefs(ref, phxEvent) - return false - } else { - return true - } - }) + getScheduledSubmit(formEl) { + return this.formSubmits.find(([el, _ref, _opts, _callback]) => + el.isSameNode(formEl), + ); } - disableForm(formEl, phxEvent, opts = {}){ - const filterIgnored = el => { - const userIgnored = closestPhxBinding(el, `${this.binding(PHX_UPDATE)}=ignore`, el.form) - return !(userIgnored || closestPhxBinding(el, "data-phx-update=ignore", el.form)) - } - const filterDisables = el => { - return el.hasAttribute(this.binding(PHX_DISABLE_WITH)) + scheduleSubmit(formEl, ref, opts, callback) { + if (this.getScheduledSubmit(formEl)) { + return true; } - const filterButton = el => el.tagName == "BUTTON" - - const filterInput = el => ["INPUT", "TEXTAREA", "SELECT"].includes(el.tagName) - - const formElements = Array.from(formEl.elements) - const disables = formElements.filter(filterDisables) - const buttons = formElements.filter(filterButton).filter(filterIgnored) - const inputs = formElements.filter(filterInput).filter(filterIgnored) + this.formSubmits.push([formEl, ref, opts, callback]); + } - buttons.forEach(button => { - button.setAttribute(PHX_DISABLED, button.disabled) - button.disabled = true - }) - inputs.forEach(input => { - input.setAttribute(PHX_READONLY, input.readOnly) - input.readOnly = true - if(input.files){ - input.setAttribute(PHX_DISABLED, input.disabled) - input.disabled = true + cancelSubmit(formEl, phxEvent) { + this.formSubmits = this.formSubmits.filter( + ([el, ref, _opts, _callback]) => { + if (el.isSameNode(formEl)) { + this.undoRefs(ref, phxEvent); + return false; + } else { + return true; + } + }, + ); + } + + disableForm(formEl, phxEvent, opts = {}) { + const filterIgnored = (el) => { + const userIgnored = closestPhxBinding( + el, + `${this.binding(PHX_UPDATE)}=ignore`, + el.form, + ); + return !( + userIgnored || closestPhxBinding(el, "data-phx-update=ignore", el.form) + ); + }; + const filterDisables = (el) => { + return el.hasAttribute(this.binding(PHX_DISABLE_WITH)); + }; + const filterButton = (el) => el.tagName == "BUTTON"; + + const filterInput = (el) => + ["INPUT", "TEXTAREA", "SELECT"].includes(el.tagName); + + const formElements = Array.from(formEl.elements); + const disables = formElements.filter(filterDisables); + const buttons = formElements.filter(filterButton).filter(filterIgnored); + const inputs = formElements.filter(filterInput).filter(filterIgnored); + + buttons.forEach((button) => { + button.setAttribute(PHX_DISABLED, button.disabled); + button.disabled = true; + }); + inputs.forEach((input) => { + input.setAttribute(PHX_READONLY, input.readOnly); + input.readOnly = true; + if (input.files) { + input.setAttribute(PHX_DISABLED, input.disabled); + input.disabled = true; } - }) - const formEls = disables.concat(buttons).concat(inputs).map(el => { - return {el, loading: true, lock: true} - }) + }); + const formEls = disables + .concat(buttons) + .concat(inputs) + .map((el) => { + return { el, loading: true, lock: true }; + }); // we reverse the order so form children are already locked by the time // the form is locked - const els = [{el: formEl, loading: true, lock: false}].concat(formEls).reverse() - return this.putRef(els, phxEvent, "submit", opts) - } - - pushFormSubmit(formEl, targetCtx, phxEvent, submitter, opts, onReply){ - const refGenerator = () => this.disableForm(formEl, phxEvent, { - ...opts, - form: formEl, - submitter: submitter - }) - const cid = this.targetComponentID(formEl, targetCtx) - if(LiveUploader.hasUploadsInProgress(formEl)){ - const [ref, _els] = refGenerator() - const push = () => this.pushFormSubmit(formEl, targetCtx, phxEvent, submitter, opts, onReply) - return this.scheduleSubmit(formEl, ref, opts, push) - } else if(LiveUploader.inputsAwaitingPreflight(formEl).length > 0){ - const [ref, els] = refGenerator() - const proxyRefGen = () => [ref, els, opts] + const els = [{ el: formEl, loading: true, lock: false }] + .concat(formEls) + .reverse(); + return this.putRef(els, phxEvent, "submit", opts); + } + + pushFormSubmit(formEl, targetCtx, phxEvent, submitter, opts, onReply) { + const refGenerator = () => + this.disableForm(formEl, phxEvent, { + ...opts, + form: formEl, + submitter: submitter, + }); + const cid = this.targetComponentID(formEl, targetCtx); + if (LiveUploader.hasUploadsInProgress(formEl)) { + const [ref, _els] = refGenerator(); + const push = () => + this.pushFormSubmit( + formEl, + targetCtx, + phxEvent, + submitter, + opts, + onReply, + ); + return this.scheduleSubmit(formEl, ref, opts, push); + } else if (LiveUploader.inputsAwaitingPreflight(formEl).length > 0) { + const [ref, els] = refGenerator(); + const proxyRefGen = () => [ref, els, opts]; this.uploadFiles(formEl, phxEvent, targetCtx, ref, cid, (_uploads) => { // if we still having pending preflights it means we have invalid entries // and the phx-submit cannot be completed - if(LiveUploader.inputsAwaitingPreflight(formEl).length > 0){ - return this.undoRefs(ref, phxEvent) + if (LiveUploader.inputsAwaitingPreflight(formEl).length > 0) { + return this.undoRefs(ref, phxEvent); } - const meta = this.extractMeta(formEl, {}, opts.value) - const formData = serializeForm(formEl, {submitter}) + const meta = this.extractMeta(formEl, {}, opts.value); + const formData = serializeForm(formEl, { submitter }); this.pushWithReply(proxyRefGen, "event", { type: "form", event: phxEvent, value: formData, meta: meta, - cid: cid + cid: cid, }) - .then(({resp}) => onReply(resp)) - .catch((error) => logError("Failed to push form submit", error)) - }) - } else if(!(formEl.hasAttribute(PHX_REF_SRC) && formEl.classList.contains("phx-submit-loading"))){ - const meta = this.extractMeta(formEl, {}, opts.value) - const formData = serializeForm(formEl, {submitter}) + .then(({ resp }) => onReply(resp)) + .catch((error) => logError("Failed to push form submit", error)); + }); + } else if ( + !( + formEl.hasAttribute(PHX_REF_SRC) && + formEl.classList.contains("phx-submit-loading") + ) + ) { + const meta = this.extractMeta(formEl, {}, opts.value); + const formData = serializeForm(formEl, { submitter }); this.pushWithReply(refGenerator, "event", { type: "form", event: phxEvent, value: formData, meta: meta, - cid: cid + cid: cid, }) - .then(({resp}) => onReply(resp)) - .catch((error) => logError("Failed to push form submit", error)) + .then(({ resp }) => onReply(resp)) + .catch((error) => logError("Failed to push form submit", error)); } } - uploadFiles(formEl, phxEvent, targetCtx, ref, cid, onComplete){ - const joinCountAtUpload = this.joinCount - const inputEls = LiveUploader.activeFileInputs(formEl) - let numFileInputsInProgress = inputEls.length + uploadFiles(formEl, phxEvent, targetCtx, ref, cid, onComplete) { + const joinCountAtUpload = this.joinCount; + const inputEls = LiveUploader.activeFileInputs(formEl); + let numFileInputsInProgress = inputEls.length; // get each file input - inputEls.forEach(inputEl => { + inputEls.forEach((inputEl) => { const uploader = new LiveUploader(inputEl, this, () => { - numFileInputsInProgress-- - if(numFileInputsInProgress === 0){ onComplete() } - }) + numFileInputsInProgress--; + if (numFileInputsInProgress === 0) { + onComplete(); + } + }); - const entries = uploader.entries().map(entry => entry.toPreflightPayload()) + const entries = uploader + .entries() + .map((entry) => entry.toPreflightPayload()); - if(entries.length === 0){ - numFileInputsInProgress-- - return + if (entries.length === 0) { + numFileInputsInProgress--; + return; } const payload = { ref: inputEl.getAttribute(PHX_UPLOAD_REF), entries: entries, - cid: this.targetComponentID(inputEl.form, targetCtx) - } - - this.log("upload", () => ["sending preflight request", payload]) - - this.pushWithReply(null, "allow_upload", payload).then(({resp}) => { - this.log("upload", () => ["got preflight response", resp]) - // the preflight will reject entries beyond the max entries - // so we error and cancel entries on the client that are missing from the response - uploader.entries().forEach(entry => { - if(resp.entries && !resp.entries[entry.ref]){ - this.handleFailedEntryPreflight(entry.ref, "failed preflight", uploader) + cid: this.targetComponentID(inputEl.form, targetCtx), + }; + + this.log("upload", () => ["sending preflight request", payload]); + + this.pushWithReply(null, "allow_upload", payload) + .then(({ resp }) => { + this.log("upload", () => ["got preflight response", resp]); + // the preflight will reject entries beyond the max entries + // so we error and cancel entries on the client that are missing from the response + uploader.entries().forEach((entry) => { + if (resp.entries && !resp.entries[entry.ref]) { + this.handleFailedEntryPreflight( + entry.ref, + "failed preflight", + uploader, + ); + } + }); + // for auto uploads, we may have an empty entries response from the server + // for form submits that contain invalid entries + if (resp.error || Object.keys(resp.entries).length === 0) { + this.undoRefs(ref, phxEvent); + const errors = resp.error || []; + errors.map(([entry_ref, reason]) => { + this.handleFailedEntryPreflight(entry_ref, reason, uploader); + }); + } else { + const onError = (callback) => { + this.channel.onError(() => { + if (this.joinCount === joinCountAtUpload) { + callback(); + } + }); + }; + uploader.initAdapterUpload(resp, onError, this.liveSocket); } }) - // for auto uploads, we may have an empty entries response from the server - // for form submits that contain invalid entries - if(resp.error || Object.keys(resp.entries).length === 0){ - this.undoRefs(ref, phxEvent) - const errors = resp.error || [] - errors.map(([entry_ref, reason]) => { - this.handleFailedEntryPreflight(entry_ref, reason, uploader) - }) - } else { - const onError = (callback) => { - this.channel.onError(() => { - if(this.joinCount === joinCountAtUpload){ callback() } - }) - } - uploader.initAdapterUpload(resp, onError, this.liveSocket) - } - }).catch((error) => logError("Failed to push upload", error)) - }) + .catch((error) => logError("Failed to push upload", error)); + }); } - handleFailedEntryPreflight(uploadRef, reason, uploader){ - if(uploader.isAutoUpload()){ + handleFailedEntryPreflight(uploadRef, reason, uploader) { + if (uploader.isAutoUpload()) { // uploadRef may be top level upload config ref or entry ref - const entry = uploader.entries().find(entry => entry.ref === uploadRef.toString()) - if(entry){ entry.cancel() } + const entry = uploader + .entries() + .find((entry) => entry.ref === uploadRef.toString()); + if (entry) { + entry.cancel(); + } } else { - uploader.entries().map(entry => entry.cancel()) + uploader.entries().map((entry) => entry.cancel()); + } + this.log("upload", () => [`error for entry ${uploadRef}`, reason]); + } + + dispatchUploads(targetCtx, name, filesOrBlobs) { + const targetElement = this.targetCtxElement(targetCtx) || this.el; + const inputs = DOM.findUploadInputs(targetElement).filter( + (el) => el.name === name, + ); + if (inputs.length === 0) { + logError(`no live file inputs found matching the name "${name}"`); + } else if (inputs.length > 1) { + logError(`duplicate live file inputs found matching the name "${name}"`); + } else { + DOM.dispatchEvent(inputs[0], PHX_TRACK_UPLOADS, { + detail: { files: filesOrBlobs }, + }); } - this.log("upload", () => [`error for entry ${uploadRef}`, reason]) - } - - dispatchUploads(targetCtx, name, filesOrBlobs){ - const targetElement = this.targetCtxElement(targetCtx) || this.el - const inputs = DOM.findUploadInputs(targetElement).filter(el => el.name === name) - if(inputs.length === 0){ logError(`no live file inputs found matching the name "${name}"`) } - else if(inputs.length > 1){ logError(`duplicate live file inputs found matching the name "${name}"`) } - else { DOM.dispatchEvent(inputs[0], PHX_TRACK_UPLOADS, {detail: {files: filesOrBlobs}}) } } - targetCtxElement(targetCtx){ - if(isCid(targetCtx)){ - const [target] = DOM.findComponentNodeList(this.el, targetCtx) - return target - } else if(targetCtx){ - return targetCtx + targetCtxElement(targetCtx) { + if (isCid(targetCtx)) { + const [target] = DOM.findComponentNodeList(this.el, targetCtx); + return target; + } else if (targetCtx) { + return targetCtx; } else { - return null + return null; } } - pushFormRecovery(oldForm, newForm, templateDom, callback){ + pushFormRecovery(oldForm, newForm, templateDom, callback) { // we are only recovering forms inside the current view, therefore it is safe to // skip withinOwners here and always use this when referring to the view - const phxChange = this.binding("change") - const phxTarget = newForm.getAttribute(this.binding("target")) || newForm - const phxEvent = newForm.getAttribute(this.binding(PHX_AUTO_RECOVER)) || newForm.getAttribute(this.binding("change")) - const inputs = Array.from(oldForm.elements).filter(el => DOM.isFormInput(el) && el.name && !el.hasAttribute(phxChange)) - if(inputs.length === 0){ return } + const phxChange = this.binding("change"); + const phxTarget = newForm.getAttribute(this.binding("target")) || newForm; + const phxEvent = + newForm.getAttribute(this.binding(PHX_AUTO_RECOVER)) || + newForm.getAttribute(this.binding("change")); + const inputs = Array.from(oldForm.elements).filter( + (el) => DOM.isFormInput(el) && el.name && !el.hasAttribute(phxChange), + ); + if (inputs.length === 0) { + return; + } // we must clear tracked uploads before recovery as they no longer have valid refs - inputs.forEach(input => input.hasAttribute(PHX_UPLOAD_REF) && LiveUploader.clearFiles(input)) + inputs.forEach( + (input) => + input.hasAttribute(PHX_UPLOAD_REF) && LiveUploader.clearFiles(input), + ); // pushInput assumes that there is a source element that initiated the change; // because this is not the case when we recover forms, we provide the first input we find - const input = inputs.find(el => el.type !== "hidden") || inputs[0] + const input = inputs.find((el) => el.type !== "hidden") || inputs[0]; // in the case that there are multiple targets, we count the number of pending recovery events // and only call the callback once all events have been processed - let pending = 0 + let pending = 0; // withinTargets(phxTarget, callback, dom, viewEl) - this.withinTargets(phxTarget, (targetView, targetCtx) => { - const cid = this.targetComponentID(newForm, targetCtx) - pending++ - const e = new CustomEvent("phx:form-recovery", {detail: {sourceElement: oldForm}}) - JS.exec(e, "change", phxEvent, this, input, ["push", { - _target: input.name, - targetView, - targetCtx, - newCid: cid, - callback: () => { - pending-- - if(pending === 0){ callback() } - } - }]) - }, templateDom, templateDom) + this.withinTargets( + phxTarget, + (targetView, targetCtx) => { + const cid = this.targetComponentID(newForm, targetCtx); + pending++; + const e = new CustomEvent("phx:form-recovery", { + detail: { sourceElement: oldForm }, + }); + JS.exec(e, "change", phxEvent, this, input, [ + "push", + { + _target: input.name, + targetView, + targetCtx, + newCid: cid, + callback: () => { + pending--; + if (pending === 0) { + callback(); + } + }, + }, + ]); + }, + templateDom, + templateDom, + ); } - pushLinkPatch(e, href, targetEl, callback){ - const linkRef = this.liveSocket.setPendingLink(href) + pushLinkPatch(e, href, targetEl, callback) { + const linkRef = this.liveSocket.setPendingLink(href); // only add loading states if event is trusted (it was triggered by user, such as click) and // it's not a forward/back navigation from popstate - const loading = e.isTrusted && e.type !== "popstate" - const refGen = targetEl ? () => this.putRef([{el: targetEl, loading: loading, lock: true}], null, "click") : null - const fallback = () => this.liveSocket.redirect(window.location.href) - const url = href.startsWith("/") ? `${location.protocol}//${location.host}${href}` : href - - this.pushWithReply(refGen, "live_patch", {url}).then( - ({resp}) => { + const loading = e.isTrusted && e.type !== "popstate"; + const refGen = targetEl + ? () => + this.putRef( + [{ el: targetEl, loading: loading, lock: true }], + null, + "click", + ) + : null; + const fallback = () => this.liveSocket.redirect(window.location.href); + const url = href.startsWith("/") + ? `${location.protocol}//${location.host}${href}` + : href; + + this.pushWithReply(refGen, "live_patch", { url }).then( + ({ resp }) => { this.liveSocket.requestDOMUpdate(() => { - if(resp.link_redirect){ - this.liveSocket.replaceMain(href, null, callback, linkRef) + if (resp.link_redirect) { + this.liveSocket.replaceMain(href, null, callback, linkRef); } else { - if(this.liveSocket.commitPendingLink(linkRef)){ - this.href = href + if (this.liveSocket.commitPendingLink(linkRef)) { + this.href = href; } - this.applyPendingUpdates() - callback && callback(linkRef) + this.applyPendingUpdates(); + callback && callback(linkRef); } - }) + }); }, - ({error: _error, timeout: _timeout}) => fallback() - ) + ({ error: _error, timeout: _timeout }) => fallback(), + ); } - getFormsForRecovery(){ - if(this.joinCount === 0){ return {} } + getFormsForRecovery() { + if (this.joinCount === 0) { + return {}; + } - const phxChange = this.binding("change") + const phxChange = this.binding("change"); return DOM.all(this.el, `form[${phxChange}]`) - .filter(form => form.id) - .filter(form => form.elements.length > 0) - .filter(form => form.getAttribute(this.binding(PHX_AUTO_RECOVER)) !== "ignore") - .map(form => form.cloneNode(true)) + .filter((form) => form.id) + .filter((form) => form.elements.length > 0) + .filter( + (form) => + form.getAttribute(this.binding(PHX_AUTO_RECOVER)) !== "ignore", + ) + .map((form) => form.cloneNode(true)) .reduce((acc, form) => { - acc[form.id] = form - return acc - }, {}) + acc[form.id] = form; + return acc; + }, {}); } - maybePushComponentsDestroyed(destroyedCIDs){ - const willDestroyCIDs = destroyedCIDs.filter(cid => { - return DOM.findComponentNodeList(this.el, cid).length === 0 - }) + maybePushComponentsDestroyed(destroyedCIDs) { + const willDestroyCIDs = destroyedCIDs.filter((cid) => { + return DOM.findComponentNodeList(this.el, cid).length === 0; + }); - if(willDestroyCIDs.length > 0){ + if (willDestroyCIDs.length > 0) { // we must reset the render change tracking for cids that // could be added back from the server so we don't skip them - willDestroyCIDs.forEach(cid => this.rendered.resetRender(cid)) - - this.pushWithReply(null, "cids_will_destroy", {cids: willDestroyCIDs}).then(() => { - // we must wait for pending transitions to complete before determining - // if the cids were added back to the DOM in the meantime (#3139) - this.liveSocket.requestDOMUpdate(() => { - // See if any of the cids we wanted to destroy were added back, - // if they were added back, we don't actually destroy them. - const completelyDestroyCIDs = willDestroyCIDs.filter(cid => { - return DOM.findComponentNodeList(this.el, cid).length === 0 - }) - - if(completelyDestroyCIDs.length > 0){ - this.pushWithReply(null, "cids_destroyed", {cids: completelyDestroyCIDs}).then(({resp}) => { - this.rendered.pruneCIDs(resp.cids) - }).catch((error) => logError("Failed to push components destroyed", error)) - } + willDestroyCIDs.forEach((cid) => this.rendered.resetRender(cid)); + + this.pushWithReply(null, "cids_will_destroy", { cids: willDestroyCIDs }) + .then(() => { + // we must wait for pending transitions to complete before determining + // if the cids were added back to the DOM in the meantime (#3139) + this.liveSocket.requestDOMUpdate(() => { + // See if any of the cids we wanted to destroy were added back, + // if they were added back, we don't actually destroy them. + const completelyDestroyCIDs = willDestroyCIDs.filter((cid) => { + return DOM.findComponentNodeList(this.el, cid).length === 0; + }); + + if (completelyDestroyCIDs.length > 0) { + this.pushWithReply(null, "cids_destroyed", { + cids: completelyDestroyCIDs, + }) + .then(({ resp }) => { + this.rendered.pruneCIDs(resp.cids); + }) + .catch((error) => + logError("Failed to push components destroyed", error), + ); + } + }); }) - }).catch((error) => logError("Failed to push components destroyed", error)) + .catch((error) => + logError("Failed to push components destroyed", error), + ); } } - ownsElement(el){ - const parentViewEl = el.closest(PHX_VIEW_SELECTOR) - return el.getAttribute(PHX_PARENT_ID) === this.id || + ownsElement(el) { + const parentViewEl = el.closest(PHX_VIEW_SELECTOR); + return ( + el.getAttribute(PHX_PARENT_ID) === this.id || (parentViewEl && parentViewEl.id === this.id) || (!parentViewEl && this.isDead) + ); } - submitForm(form, targetCtx, phxEvent, submitter, opts = {}){ - DOM.putPrivate(form, PHX_HAS_SUBMITTED, true) - const inputs = Array.from(form.elements) - inputs.forEach(input => DOM.putPrivate(input, PHX_HAS_SUBMITTED, true)) - this.liveSocket.blurActiveElement(this) + submitForm(form, targetCtx, phxEvent, submitter, opts = {}) { + DOM.putPrivate(form, PHX_HAS_SUBMITTED, true); + const inputs = Array.from(form.elements); + inputs.forEach((input) => DOM.putPrivate(input, PHX_HAS_SUBMITTED, true)); + this.liveSocket.blurActiveElement(this); this.pushFormSubmit(form, targetCtx, phxEvent, submitter, opts, () => { - this.liveSocket.restorePreviouslyActiveFocus() - }) + this.liveSocket.restorePreviouslyActiveFocus(); + }); } - binding(kind){ return this.liveSocket.binding(kind) } + binding(kind) { + return this.liveSocket.binding(kind); + } } diff --git a/assets/js/phoenix_live_view/view_hook.ts b/assets/js/phoenix_live_view/view_hook.ts index 302527a783..0738edd271 100644 --- a/assets/js/phoenix_live_view/view_hook.ts +++ b/assets/js/phoenix_live_view/view_hook.ts @@ -1,15 +1,15 @@ -import jsCommands, {HookJSCommands} from "./js_commands" -import DOM from "./dom" -import LiveSocket from "./live_socket" -import View from "./view" +import jsCommands, { HookJSCommands } from "./js_commands"; +import DOM from "./dom"; +import LiveSocket from "./live_socket"; +import View from "./view"; -const HOOK_ID = "hookId" -let viewHookID = 1 +const HOOK_ID = "hookId"; +let viewHookID = 1; -export type OnReply = (reply: any, ref: number) => any -export type CallbackRef = {event: string, callback: (payload: any) => any} +export type OnReply = (reply: any, ref: number) => any; +export type CallbackRef = { event: string; callback: (payload: any) => any }; -export type PhxTarget = string | number | HTMLElement +export type PhxTarget = string | number | HTMLElement; export interface HookInterface { /** @@ -24,14 +24,14 @@ export interface HookInterface { /** * The mounted callback. - * + * * Called when the element has been added to the DOM and its server LiveView has finished mounting. */ mounted?: () => void; /** * The beforeUpdate callback. - * + * * Called when the element is about to be updated in the DOM. * Note: any call here must be synchronous as the operation cannot be deferred or cancelled. */ @@ -39,28 +39,28 @@ export interface HookInterface { /** * The updated callback. - * + * * Called when the element has been updated in the DOM by the server */ updated?: () => void; /** * The destroyed callback. - * + * * Called when the element has been removed from the page, either by a parent update, or by the parent being removed entirely */ destroyed?: () => void; /** * The disconnected callback. - * + * * Called when the element's parent LiveView has disconnected from the server. */ disconnected?: () => void; /** * The reconnected callback. - * + * * Called when the element's parent LiveView has reconnected to the server. */ reconnected?: () => void; @@ -73,12 +73,12 @@ export interface HookInterface { /** * Pushes an event to the server. - * + * * @param event - The event name. * @param [payload] - The payload to send to the server. Defaults to an empty object. * @param [onReply] - A callback to handle the server's reply. - * - * When onReply is not provided, the method returns a Promise that + * + * When onReply is not provided, the method returns a Promise that * When onReply is provided, the method returns void. */ pushEvent(event: string, payload: any, onReply: OnReply): void; @@ -86,50 +86,59 @@ export interface HookInterface { /** * Pushed a targeted event to the server. - * + * * It sends the event to the LiveComponent or LiveView the `selectorOrTarget` is defined in, * where its value can be either a query selector, an actual DOM element, or a CID (component id) * returned by the `@myself` assign. - * + * * If the query selector returns more than one element it will send the event to all of them, * even if all the elements are in the same LiveComponent or LiveView. Because of this, * if no callback is passed, a promise is returned that matches the return value of * [`Promise.allSettled()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/allSettled#return_value). * Individual fulfilled values are of the format `{ reply, ref }`, where `reply` is the server's reply. - * + * * @param selectorOrTarget - The selector, element, or CID to target. * @param event - The event name. * @param [payload] - The payload to send to the server. Defaults to an empty object. * @param [onReply] - A callback to handle the server's reply. - * + * * When onReply is not provided, the method returns a Promise. * When onReply is provided, the method returns void. */ - pushEventTo(selectorOrTarget: PhxTarget, event: string, payload: object, onReply: OnReply): void; - pushEventTo(selectorOrTarget: PhxTarget, event: string, payload?: object): Promise[]>; + pushEventTo( + selectorOrTarget: PhxTarget, + event: string, + payload: object, + onReply: OnReply, + ): void; + pushEventTo( + selectorOrTarget: PhxTarget, + event: string, + payload?: object, + ): Promise[]>; /** * Allows to register a callback to be called when an event is received from the server. - * + * * This is used to handle `pushEvent` calls from the server. The callback is called with the payload from the server. - * + * * @param event - The event name. * @param callback - The callback to call when the event is received. - * + * * @returns A reference to the callback, which can be used in `removeHandleEvent` to remove the callback. */ handleEvent(event: string, callback: (payload: any) => any): CallbackRef; /** * Removes a callback registered with `handleEvent`. - * + * * @param callbackRef - The reference to the callback to remove. */ removeHandleEvent(ref: CallbackRef): void; /** * Allows to trigger a live file upload. - * + * * @param name - The upload name corresponding to the `Phoenix.LiveView.allow_upload/3` call. * @param files - The files to upload. */ @@ -137,7 +146,7 @@ export interface HookInterface { /** * Allows to trigger a live file upload to a specific target. - * + * * @param selectorOrTarget - The target to upload the files to. * @param name - The upload name corresponding to the `Phoenix.LiveView.allow_upload/3` call. * @param files - The files to upload. @@ -153,14 +162,14 @@ export interface HookInterface { export interface Hook { /** * The mounted callback. - * + * * Called when the element has been added to the DOM and its server LiveView has finished mounting. */ mounted?: (this: T & HookInterface) => void; /** * The beforeUpdate callback. - * + * * Called when the element is about to be updated in the DOM. * Note: any call here must be synchronous as the operation cannot be deferred or cancelled. */ @@ -168,28 +177,28 @@ export interface Hook { /** * The updated callback. - * + * * Called when the element has been updated in the DOM by the server */ updated?: (this: T & HookInterface) => void; /** * The destroyed callback. - * + * * Called when the element has been removed from the page, either by a parent update, or by the parent being removed entirely */ destroyed?: (this: T & HookInterface) => void; /** * The disconnected callback. - * + * * Called when the element's parent LiveView has disconnected from the server. */ disconnected?: (this: T & HookInterface) => void; /** * The reconnected callback. - * + * * Called when the element's parent LiveView has reconnected to the server. */ reconnected?: (this: T & HookInterface) => void; @@ -228,56 +237,99 @@ export interface Hook { * `this.pushEvent()`, etc., as well as any properties or methods defined on the subclass. */ export class ViewHook implements HookInterface { - el: HTMLElement - liveSocket: LiveSocket + el: HTMLElement; + liveSocket: LiveSocket; - private __listeners: Set - private __isDisconnected: boolean - private __view: () => View + private __listeners: Set; + private __isDisconnected: boolean; + private __view: () => View; - static makeID(){ return viewHookID++ } - static elementID(el: HTMLElement){ return DOM.private(el, HOOK_ID) } + static makeID() { + return viewHookID++; + } + static elementID(el: HTMLElement) { + return DOM.private(el, HOOK_ID); + } - constructor(view: View | null, el: HTMLElement, callbacks?: Hook){ - this.el = el - this.__attachView(view) - this.__listeners = new Set() - this.__isDisconnected = false - DOM.putPrivate(this.el, HOOK_ID, ViewHook.makeID()) + constructor(view: View | null, el: HTMLElement, callbacks?: Hook) { + this.el = el; + this.__attachView(view); + this.__listeners = new Set(); + this.__isDisconnected = false; + DOM.putPrivate(this.el, HOOK_ID, ViewHook.makeID()); - if(callbacks){ + if (callbacks) { // This instance is for an object-literal hook. Copy methods/properties. // These are properties that should NOT be overridden by the callbacks object. const protectedProps = new Set([ - "el", "liveSocket", "__view", "__listeners", "__isDisconnected", + "el", + "liveSocket", + "__view", + "__listeners", + "__isDisconnected", "constructor", // Standard object properties // Core ViewHook API methods - "js", "pushEvent", "pushEventTo", "handleEvent", "removeHandleEvent", "upload", "uploadTo", + "js", + "pushEvent", + "pushEventTo", + "handleEvent", + "removeHandleEvent", + "upload", + "uploadTo", // Internal lifecycle callers - "__mounted", "__updated", "__beforeUpdate", "__destroyed", "__reconnected", "__disconnected", "__cleanup__" - ]) - - for(const key in callbacks){ - if(Object.prototype.hasOwnProperty.call(callbacks, key)){ - if(protectedProps.has(key)){ + "__mounted", + "__updated", + "__beforeUpdate", + "__destroyed", + "__reconnected", + "__disconnected", + "__cleanup__", + ]); + + for (const key in callbacks) { + if (Object.prototype.hasOwnProperty.call(callbacks, key)) { + if (protectedProps.has(key)) { // Optionally log a warning if a user tries to overwrite a protected property/method // For now, we silently prioritize the ViewHook's own properties/methods. - if(typeof (this as any)[key] === "function" && typeof callbacks[key] !== "function" && !["mounted", "beforeUpdate", "updated", "destroyed", "disconnected", "reconnected"].includes(key) ){ + if ( + typeof (this as any)[key] === "function" && + typeof callbacks[key] !== "function" && + ![ + "mounted", + "beforeUpdate", + "updated", + "destroyed", + "disconnected", + "reconnected", + ].includes(key) + ) { // If core method is a function and callback is not, likely an error from user. - console.warn(`Hook object for element #${el.id} attempted to overwrite core method '${key}' with a non-function value. This is not allowed.`) + console.warn( + `Hook object for element #${el.id} attempted to overwrite core method '${key}' with a non-function value. This is not allowed.`, + ); } } else { - (this as any)[key] = callbacks[key] + (this as any)[key] = callbacks[key]; } } } - const lifecycleMethods: (keyof Hook)[] = ["mounted", "beforeUpdate", "updated", "destroyed", "disconnected", "reconnected"] - lifecycleMethods.forEach(methodName => { - if(callbacks[methodName] && typeof callbacks[methodName] === "function"){ - (this as any)[methodName] = callbacks[methodName] + const lifecycleMethods: (keyof Hook)[] = [ + "mounted", + "beforeUpdate", + "updated", + "destroyed", + "disconnected", + "reconnected", + ]; + lifecycleMethods.forEach((methodName) => { + if ( + callbacks[methodName] && + typeof callbacks[methodName] === "function" + ) { + (this as any)[methodName] = callbacks[methodName]; } - }) + }); } // If 'callbacks' is not provided, this is an instance of a user-defined class (e.g., MyHook). // Its methods (mounted, updated, custom) are already part of its prototype or instance, @@ -285,117 +337,147 @@ export class ViewHook implements HookInterface { } /** @internal */ - __attachView(view: View | null){ - if(view){ - this.__view = () => view - this.liveSocket = view.liveSocket + __attachView(view: View | null) { + if (view) { + this.__view = () => view; + this.liveSocket = view.liveSocket; } else { this.__view = () => { - throw new Error(`hook not yet attached to a live view: ${this.el.outerHTML}`) - } - this.liveSocket = null + throw new Error( + `hook not yet attached to a live view: ${this.el.outerHTML}`, + ); + }; + this.liveSocket = null; } } // Default lifecycle methods - mounted(): void{ } - beforeUpdate(): void{ } - updated(): void{ } - destroyed(): void{ } - disconnected(): void{ } - reconnected(): void{ } + mounted(): void {} + beforeUpdate(): void {} + updated(): void {} + destroyed(): void {} + disconnected(): void {} + reconnected(): void {} // Internal lifecycle callers - called by the View /** @internal */ - __mounted(){ this.mounted() } + __mounted() { + this.mounted(); + } /** @internal */ - __updated(){ this.updated() } + __updated() { + this.updated(); + } /** @internal */ - __beforeUpdate(){ this.beforeUpdate() } + __beforeUpdate() { + this.beforeUpdate(); + } /** @internal */ - __destroyed(){ - this.destroyed() - DOM.deletePrivate(this.el, HOOK_ID) // https://github.com/phoenixframework/phoenix_live_view/issues/3496 + __destroyed() { + this.destroyed(); + DOM.deletePrivate(this.el, HOOK_ID); // https://github.com/phoenixframework/phoenix_live_view/issues/3496 } /** @internal */ - __reconnected(){ - if(this.__isDisconnected){ - this.__isDisconnected = false - this.reconnected() + __reconnected() { + if (this.__isDisconnected) { + this.__isDisconnected = false; + this.reconnected(); } } /** @internal */ - __disconnected(){ - this.__isDisconnected = true - this.disconnected() + __disconnected() { + this.__isDisconnected = true; + this.disconnected(); } - js(): HookJSCommands{ + js(): HookJSCommands { return { ...jsCommands(this.__view().liveSocket, "hook"), exec: (encodedJS: string) => { - this.__view().liveSocket.execJS(this.el, encodedJS, "hook") - } - } + this.__view().liveSocket.execJS(this.el, encodedJS, "hook"); + }, + }; } - pushEvent(event: string, payload?: any, onReply?: OnReply){ - const promise = this.__view().pushHookEvent(this.el, null, event, payload || {}) - if(onReply === undefined){ - return promise.then(({reply}) => reply) + pushEvent(event: string, payload?: any, onReply?: OnReply) { + const promise = this.__view().pushHookEvent( + this.el, + null, + event, + payload || {}, + ); + if (onReply === undefined) { + return promise.then(({ reply }) => reply); } - promise.then(({reply, ref}) => onReply(reply, ref)).catch(() => {}) - return + promise.then(({ reply, ref }) => onReply(reply, ref)).catch(() => {}); + return; } - pushEventTo(selectorOrTarget: PhxTarget, event: string, payload?: object, onReply?: OnReply){ - if(onReply === undefined){ - const targetPair: {view: View, targetCtx: any}[] = [] + pushEventTo( + selectorOrTarget: PhxTarget, + event: string, + payload?: object, + onReply?: OnReply, + ) { + if (onReply === undefined) { + const targetPair: { view: View; targetCtx: any }[] = []; this.__view().withinTargets(selectorOrTarget, (view, targetCtx) => { - targetPair.push({view, targetCtx}) - }) - const promises = targetPair.map(({view, targetCtx}) => { - return view.pushHookEvent(this.el, targetCtx, event, payload || {}) - }) - return Promise.allSettled(promises) + targetPair.push({ view, targetCtx }); + }); + const promises = targetPair.map(({ view, targetCtx }) => { + return view.pushHookEvent(this.el, targetCtx, event, payload || {}); + }); + return Promise.allSettled(promises); } this.__view().withinTargets(selectorOrTarget, (view, targetCtx) => { - view.pushHookEvent(this.el, targetCtx, event, payload || {}) - .then(({reply, ref}) => onReply(reply, ref)) - .catch(() => {}) - }) - return + view + .pushHookEvent(this.el, targetCtx, event, payload || {}) + .then(({ reply, ref }) => onReply(reply, ref)) + .catch(() => {}); + }); + return; } - handleEvent(event: string, callback: (payload: any) => any): CallbackRef{ - const callbackRef: CallbackRef = {event, callback: (customEvent: CustomEvent) => callback(customEvent.detail)} - window.addEventListener(`phx:${event}`, callbackRef.callback as EventListener) - this.__listeners.add(callbackRef) - return callbackRef + handleEvent(event: string, callback: (payload: any) => any): CallbackRef { + const callbackRef: CallbackRef = { + event, + callback: (customEvent: CustomEvent) => callback(customEvent.detail), + }; + window.addEventListener( + `phx:${event}`, + callbackRef.callback as EventListener, + ); + this.__listeners.add(callbackRef); + return callbackRef; } - removeHandleEvent(ref: CallbackRef): void{ - window.removeEventListener(`phx:${ref.event}`, ref.callback as EventListener) - this.__listeners.delete(ref) + removeHandleEvent(ref: CallbackRef): void { + window.removeEventListener( + `phx:${ref.event}`, + ref.callback as EventListener, + ); + this.__listeners.delete(ref); } - upload(name: string, files: FileList): any{ - return this.__view().dispatchUploads(null, name, files) + upload(name: string, files: FileList): any { + return this.__view().dispatchUploads(null, name, files); } - uploadTo(selectorOrTarget: PhxTarget, name: string, files: FileList): any{ + uploadTo(selectorOrTarget: PhxTarget, name: string, files: FileList): any { return this.__view().withinTargets(selectorOrTarget, (view, targetCtx) => { - view.dispatchUploads(targetCtx, name, files) - }) + view.dispatchUploads(targetCtx, name, files); + }); } /** @internal */ - __cleanup__(){ - this.__listeners.forEach(callbackRef => this.removeHandleEvent(callbackRef)) + __cleanup__() { + this.__listeners.forEach((callbackRef) => + this.removeHandleEvent(callbackRef), + ); } } -export type HooksOptions = Record +export type HooksOptions = Record; -export default ViewHook +export default ViewHook; diff --git a/assets/test/browser_test.ts b/assets/test/browser_test.ts index f44ac080d3..d18a9790c7 100644 --- a/assets/test/browser_test.ts +++ b/assets/test/browser_test.ts @@ -1,62 +1,62 @@ -import Browser from "phoenix_live_view/browser" +import Browser from "phoenix_live_view/browser"; describe("Browser", () => { beforeEach(() => { - clearCookies() - }) + clearCookies(); + }); describe("setCookie", () => { test("sets a cookie", () => { - Browser.setCookie("apple", 1234) - Browser.setCookie("orange", "5678") - expect(document.cookie).toContain("apple") - expect(document.cookie).toContain("1234") - expect(document.cookie).toContain("orange") - expect(document.cookie).toContain("5678") - }) - }) + Browser.setCookie("apple", 1234); + Browser.setCookie("orange", "5678"); + expect(document.cookie).toContain("apple"); + expect(document.cookie).toContain("1234"); + expect(document.cookie).toContain("orange"); + expect(document.cookie).toContain("5678"); + }); + }); describe("getCookie", () => { test("returns the value for a cookie", () => { - document.cookie = "apple=1234" - document.cookie = "orange=5678" - expect(Browser.getCookie("apple")).toEqual("1234") - }) + document.cookie = "apple=1234"; + document.cookie = "orange=5678"; + expect(Browser.getCookie("apple")).toEqual("1234"); + }); test("returns an empty string for a non-existent cookie", () => { - document.cookie = "apple=1234" - document.cookie = "orange=5678" - expect(Browser.getCookie("plum")).toEqual("") - }) - }) + document.cookie = "apple=1234"; + document.cookie = "orange=5678"; + expect(Browser.getCookie("plum")).toEqual(""); + }); + }); describe("redirect", () => { - let originalLocation: Location - let mockHrefSetter: jest.Mock - let currentHref: string + let originalLocation: Location; + let mockHrefSetter: jest.Mock; + let currentHref: string; beforeAll(() => { - originalLocation = window.location - }) + originalLocation = window.location; + }); beforeEach(() => { - currentHref = "https://example.com" // Initial mocked URL + currentHref = "https://example.com"; // Initial mocked URL mockHrefSetter = jest.fn((newHref: string) => { - currentHref = newHref - }) + currentHref = newHref; + }); Object.defineProperty(window, "location", { writable: true, configurable: true, value: { - get href(){ - return currentHref + get href() { + return currentHref; + }, + set href(url: string) { + mockHrefSetter(url); }, - set href(url: string){ - mockHrefSetter(url) - } }, - }) - }) + }); + }); afterAll(() => { // Restore the original window.location object @@ -64,37 +64,37 @@ describe("Browser", () => { writable: true, configurable: true, value: originalLocation, - }) - }) + }); + }); test("redirects to a new URL", () => { - const targetUrl = "https://phoenixframework.com" - Browser.redirect(targetUrl) - expect(mockHrefSetter).toHaveBeenCalledWith(targetUrl) - expect(window.location.href).toEqual(targetUrl) - }) + const targetUrl = "https://phoenixframework.com"; + Browser.redirect(targetUrl); + expect(mockHrefSetter).toHaveBeenCalledWith(targetUrl); + expect(window.location.href).toEqual(targetUrl); + }); test("sets a flash cookie before redirecting", () => { - const targetUrl = "https://phoenixframework.com" - const flashMessage = "mango" - Browser.redirect(targetUrl, flashMessage) + const targetUrl = "https://phoenixframework.com"; + const flashMessage = "mango"; + Browser.redirect(targetUrl, flashMessage); - expect(document.cookie).toContain("__phoenix_flash__") - expect(document.cookie).toContain(flashMessage) - expect(mockHrefSetter).toHaveBeenCalledWith(targetUrl) - expect(window.location.href).toEqual(targetUrl) - }) - }) -}) + expect(document.cookie).toContain("__phoenix_flash__"); + expect(document.cookie).toContain(flashMessage); + expect(mockHrefSetter).toHaveBeenCalledWith(targetUrl); + expect(window.location.href).toEqual(targetUrl); + }); + }); +}); // Adapted from https://stackoverflow.com/questions/179355/clearing-all-cookies-with-javascript/179514#179514 -function clearCookies(){ - const cookies = document.cookie.split(";") +function clearCookies() { + const cookies = document.cookie.split(";"); - for(let i = 0; i < cookies.length; i++){ - const cookie = cookies[i] - const eqPos = cookie.indexOf("=") - const name = eqPos > -1 ? cookie.substr(0, eqPos) : cookie - document.cookie = name + "=;expires=Thu, 01 Jan 1970 00:00:00 GMT" + for (let i = 0; i < cookies.length; i++) { + const cookie = cookies[i]; + const eqPos = cookie.indexOf("="); + const name = eqPos > -1 ? cookie.substr(0, eqPos) : cookie; + document.cookie = name + "=;expires=Thu, 01 Jan 1970 00:00:00 GMT"; } } diff --git a/assets/test/debounce_test.ts b/assets/test/debounce_test.ts index 2ed8cfd0c1..c1265a269e 100644 --- a/assets/test/debounce_test.ts +++ b/assets/test/debounce_test.ts @@ -1,19 +1,19 @@ -import DOM from "phoenix_live_view/dom" +import DOM from "phoenix_live_view/dom"; -const after = (time, func) => setTimeout(func, time) +const after = (time, func) => setTimeout(func, time); const simulateInput = (input, val) => { - input.value = val - DOM.dispatchEvent(input, "input") -} + input.value = val; + DOM.dispatchEvent(input, "input"); +}; const simulateKeyDown = (input, val) => { - input.value = input.value + val - DOM.dispatchEvent(input, "input") -} + input.value = input.value + val; + DOM.dispatchEvent(input, "input"); +}; const container = () => { - const div = document.createElement("div") + const div = document.createElement("div"); div.innerHTML = `
@@ -30,324 +30,460 @@ const container = () => { />
- ` - return div -} + `; + return div; +}; -describe("debounce", function (){ +describe("debounce", function () { test("triggers once on input blur", async () => { - let calls = 0 - const el = container().querySelector("input[name=blur]") - - DOM.debounce(el, {}, "phx-debounce", 100, "phx-throttle", 200, () => true, () => calls++) - DOM.dispatchEvent(el, "blur") - expect(calls).toBe(1) - - DOM.dispatchEvent(el, "blur") - DOM.dispatchEvent(el, "blur") - DOM.dispatchEvent(el, "blur") - expect(calls).toBe(1) - }) + let calls = 0; + const el = container().querySelector("input[name=blur]"); + + DOM.debounce( + el, + {}, + "phx-debounce", + 100, + "phx-throttle", + 200, + () => true, + () => calls++, + ); + DOM.dispatchEvent(el, "blur"); + expect(calls).toBe(1); + + DOM.dispatchEvent(el, "blur"); + DOM.dispatchEvent(el, "blur"); + DOM.dispatchEvent(el, "blur"); + expect(calls).toBe(1); + }); test("triggers debounce on input blur", async () => { - let calls = 0 - const el: HTMLInputElement = container().querySelector("input[name=debounce-200]") - - el.addEventListener("input", e => { - DOM.debounce(el, e, "phx-debounce", 0, "phx-throttle", 0, () => true, () => calls++) - }) - simulateInput(el, "one") - simulateInput(el, "two") - simulateInput(el, "three") - DOM.dispatchEvent(el, "blur") - DOM.dispatchEvent(el, "blur") - DOM.dispatchEvent(el, "blur") - expect(calls).toBe(1) - expect(el.value).toBe("three") - }) + let calls = 0; + const el: HTMLInputElement = container().querySelector( + "input[name=debounce-200]", + ); + + el.addEventListener("input", (e) => { + DOM.debounce( + el, + e, + "phx-debounce", + 0, + "phx-throttle", + 0, + () => true, + () => calls++, + ); + }); + simulateInput(el, "one"); + simulateInput(el, "two"); + simulateInput(el, "three"); + DOM.dispatchEvent(el, "blur"); + DOM.dispatchEvent(el, "blur"); + DOM.dispatchEvent(el, "blur"); + expect(calls).toBe(1); + expect(el.value).toBe("three"); + }); test("triggers debounce on input blur caused by tab", async () => { - let calls = 0 - const el: HTMLInputElement = container().querySelector("input[name=debounce-200]") - - el.addEventListener("input", e => { - DOM.debounce(el, e, "phx-debounce", 0, "phx-throttle", 0, () => true, () => calls++) - }) - simulateInput(el, "one") - simulateInput(el, "two") - el.dispatchEvent(new KeyboardEvent("keydown", {bubbles: true, cancelable: true, key: "Tab"})) - DOM.dispatchEvent(el, "blur") - expect(calls).toBe(1) - expect(el.value).toBe("two") - }) - - test("triggers on timeout", done => { - let calls = 0 - const el: HTMLInputElement = container().querySelector("input[name=debounce-200]") - - el.addEventListener("input", e => { - DOM.debounce(el, e, "phx-debounce", 100, "phx-throttle", 200, () => true, () => calls++) - }) - simulateKeyDown(el, "1") - simulateKeyDown(el, "2") - simulateKeyDown(el, "3") + let calls = 0; + const el: HTMLInputElement = container().querySelector( + "input[name=debounce-200]", + ); + + el.addEventListener("input", (e) => { + DOM.debounce( + el, + e, + "phx-debounce", + 0, + "phx-throttle", + 0, + () => true, + () => calls++, + ); + }); + simulateInput(el, "one"); + simulateInput(el, "two"); + el.dispatchEvent( + new KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + key: "Tab", + }), + ); + DOM.dispatchEvent(el, "blur"); + expect(calls).toBe(1); + expect(el.value).toBe("two"); + }); + + test("triggers on timeout", (done) => { + let calls = 0; + const el: HTMLInputElement = container().querySelector( + "input[name=debounce-200]", + ); + + el.addEventListener("input", (e) => { + DOM.debounce( + el, + e, + "phx-debounce", + 100, + "phx-throttle", + 200, + () => true, + () => calls++, + ); + }); + simulateKeyDown(el, "1"); + simulateKeyDown(el, "2"); + simulateKeyDown(el, "3"); after(100, () => { - expect(calls).toBe(0) - simulateKeyDown(el, "4") + expect(calls).toBe(0); + simulateKeyDown(el, "4"); after(75, () => { - expect(calls).toBe(0) + expect(calls).toBe(0); after(250, () => { - expect(calls).toBe(1) - expect(el.value).toBe("1234") - simulateKeyDown(el, "5") - simulateKeyDown(el, "6") - simulateKeyDown(el, "7") + expect(calls).toBe(1); + expect(el.value).toBe("1234"); + simulateKeyDown(el, "5"); + simulateKeyDown(el, "6"); + simulateKeyDown(el, "7"); after(250, () => { - expect(calls).toBe(2) - expect(el.value).toBe("1234567") - done() - }) - }) - }) - }) - }) - - test("uses default when value is blank", done => { - let calls = 0 - const el: HTMLInputElement = container().querySelector("input[name=debounce-200]") - el.setAttribute("phx-debounce", "") - - el.addEventListener("input", e => { - DOM.debounce(el, e, "phx-debounce", 500, "phx-throttle", 200, () => true, () => calls++) - }) - simulateInput(el, "one") - simulateInput(el, "two") - simulateInput(el, "three") + expect(calls).toBe(2); + expect(el.value).toBe("1234567"); + done(); + }); + }); + }); + }); + }); + + test("uses default when value is blank", (done) => { + let calls = 0; + const el: HTMLInputElement = container().querySelector( + "input[name=debounce-200]", + ); + el.setAttribute("phx-debounce", ""); + + el.addEventListener("input", (e) => { + DOM.debounce( + el, + e, + "phx-debounce", + 500, + "phx-throttle", + 200, + () => true, + () => calls++, + ); + }); + simulateInput(el, "one"); + simulateInput(el, "two"); + simulateInput(el, "three"); after(100, () => { - expect(calls).toBe(0) - expect(el.value).toBe("three") - simulateInput(el, "four") - simulateInput(el, "five") - simulateInput(el, "six") + expect(calls).toBe(0); + expect(el.value).toBe("three"); + simulateInput(el, "four"); + simulateInput(el, "five"); + simulateInput(el, "six"); after(1200, () => { - expect(calls).toBe(1) - expect(el.value).toBe("six") - done() - }) - }) - }) - - test("cancels trigger on submit", done => { - let calls = 0 - const parent = container() - const el: HTMLInputElement = parent.querySelector("input[name=debounce-200]") - - el.addEventListener("input", e => { - DOM.debounce(el, e, "phx-debounce", 100, "phx-throttle", 200, () => true, () => calls++) - }) + expect(calls).toBe(1); + expect(el.value).toBe("six"); + done(); + }); + }); + }); + + test("cancels trigger on submit", (done) => { + let calls = 0; + const parent = container(); + const el: HTMLInputElement = parent.querySelector( + "input[name=debounce-200]", + ); + + el.addEventListener("input", (e) => { + DOM.debounce( + el, + e, + "phx-debounce", + 100, + "phx-throttle", + 200, + () => true, + () => calls++, + ); + }); el.form.addEventListener("submit", () => { - el.value = "submitted" - }) - simulateInput(el, "changed") - DOM.dispatchEvent(el.form, "submit") + el.value = "submitted"; + }); + simulateInput(el, "changed"); + DOM.dispatchEvent(el.form, "submit"); after(100, () => { - expect(calls).toBe(0) - expect(el.value).toBe("submitted") - simulateInput(el, "changed again") + expect(calls).toBe(0); + expect(el.value).toBe("submitted"); + simulateInput(el, "changed again"); after(250, () => { - expect(calls).toBe(1) - expect(el.value).toBe("changed again") - done() - }) - }) - }) -}) - -describe("throttle", function (){ - test("triggers immediately, then on timeout", done => { - let calls = 0 - const el: HTMLButtonElement = container().querySelector("#throttle-200") - - el.addEventListener("click", e => { - DOM.debounce(el, e, "phx-debounce", 100, "phx-throttle", 200, () => true, () => { - calls++ - el.innerText = `now:${calls}` - }) - }) - DOM.dispatchEvent(el, "click") - DOM.dispatchEvent(el, "click") - DOM.dispatchEvent(el, "click") - expect(calls).toBe(1) - expect(el.innerText).toBe("now:1") + expect(calls).toBe(1); + expect(el.value).toBe("changed again"); + done(); + }); + }); + }); +}); + +describe("throttle", function () { + test("triggers immediately, then on timeout", (done) => { + let calls = 0; + const el: HTMLButtonElement = container().querySelector("#throttle-200"); + + el.addEventListener("click", (e) => { + DOM.debounce( + el, + e, + "phx-debounce", + 100, + "phx-throttle", + 200, + () => true, + () => { + calls++; + el.innerText = `now:${calls}`; + }, + ); + }); + DOM.dispatchEvent(el, "click"); + DOM.dispatchEvent(el, "click"); + DOM.dispatchEvent(el, "click"); + expect(calls).toBe(1); + expect(el.innerText).toBe("now:1"); after(250, () => { - expect(calls).toBe(1) - expect(el.innerText).toBe("now:1") - DOM.dispatchEvent(el, "click") - DOM.dispatchEvent(el, "click") - DOM.dispatchEvent(el, "click") + expect(calls).toBe(1); + expect(el.innerText).toBe("now:1"); + DOM.dispatchEvent(el, "click"); + DOM.dispatchEvent(el, "click"); + DOM.dispatchEvent(el, "click"); after(250, () => { - expect(calls).toBe(2) - expect(el.innerText).toBe("now:2") - done() - }) - }) - }) - - test("uses default when value is blank", done => { - let calls = 0 - const el: HTMLButtonElement = container().querySelector("#throttle-200") - el.setAttribute("phx-throttle", "") - - el.addEventListener("click", e => { - DOM.debounce(el, e, "phx-debounce", 100, "phx-throttle", 500, () => true, () => { - calls++ - el.innerText = `now:${calls}` - }) - }) - DOM.dispatchEvent(el, "click") - DOM.dispatchEvent(el, "click") - DOM.dispatchEvent(el, "click") - expect(calls).toBe(1) - expect(el.innerText).toBe("now:1") + expect(calls).toBe(2); + expect(el.innerText).toBe("now:2"); + done(); + }); + }); + }); + + test("uses default when value is blank", (done) => { + let calls = 0; + const el: HTMLButtonElement = container().querySelector("#throttle-200"); + el.setAttribute("phx-throttle", ""); + + el.addEventListener("click", (e) => { + DOM.debounce( + el, + e, + "phx-debounce", + 100, + "phx-throttle", + 500, + () => true, + () => { + calls++; + el.innerText = `now:${calls}`; + }, + ); + }); + DOM.dispatchEvent(el, "click"); + DOM.dispatchEvent(el, "click"); + DOM.dispatchEvent(el, "click"); + expect(calls).toBe(1); + expect(el.innerText).toBe("now:1"); after(200, () => { - expect(calls).toBe(1) - expect(el.innerText).toBe("now:1") - DOM.dispatchEvent(el, "click") - DOM.dispatchEvent(el, "click") - DOM.dispatchEvent(el, "click") + expect(calls).toBe(1); + expect(el.innerText).toBe("now:1"); + DOM.dispatchEvent(el, "click"); + DOM.dispatchEvent(el, "click"); + DOM.dispatchEvent(el, "click"); after(250, () => { - expect(calls).toBe(1) - expect(el.innerText).toBe("now:1") - done() - }) - }) - }) - - test("cancels trigger on submit", done => { - let calls = 0 - const el: HTMLInputElement = container().querySelector("input[name=throttle-200]") - - el.addEventListener("input", e => { - DOM.debounce(el, e, "phx-debounce", 100, "phx-throttle", 200, () => true, () => calls++) - }) + expect(calls).toBe(1); + expect(el.innerText).toBe("now:1"); + done(); + }); + }); + }); + + test("cancels trigger on submit", (done) => { + let calls = 0; + const el: HTMLInputElement = container().querySelector( + "input[name=throttle-200]", + ); + + el.addEventListener("input", (e) => { + DOM.debounce( + el, + e, + "phx-debounce", + 100, + "phx-throttle", + 200, + () => true, + () => calls++, + ); + }); el.form.addEventListener("submit", () => { - el.value = "submitted" - }) - simulateInput(el, "changed") - simulateInput(el, "changed2") - DOM.dispatchEvent(el.form, "submit") - expect(calls).toBe(1) - expect(el.value).toBe("submitted") - simulateInput(el, "changed3") + el.value = "submitted"; + }); + simulateInput(el, "changed"); + simulateInput(el, "changed2"); + DOM.dispatchEvent(el.form, "submit"); + expect(calls).toBe(1); + expect(el.value).toBe("submitted"); + simulateInput(el, "changed3"); after(100, () => { - expect(calls).toBe(2) - expect(el.value).toBe("changed3") - done() - }) - }) - - test("triggers only once when there is only one event", done => { - let calls = 0 - const el: HTMLButtonElement = container().querySelector("#throttle-200") - - el.addEventListener("click", e => { - DOM.debounce(el, e, "phx-debounce", 100, "phx-throttle", 200, () => true, () => { - calls++ - el.innerText = `now:${calls}` - }) - }) - DOM.dispatchEvent(el, "click") - expect(calls).toBe(1) - expect(el.innerText).toBe("now:1") + expect(calls).toBe(2); + expect(el.value).toBe("changed3"); + done(); + }); + }); + + test("triggers only once when there is only one event", (done) => { + let calls = 0; + const el: HTMLButtonElement = container().querySelector("#throttle-200"); + + el.addEventListener("click", (e) => { + DOM.debounce( + el, + e, + "phx-debounce", + 100, + "phx-throttle", + 200, + () => true, + () => { + calls++; + el.innerText = `now:${calls}`; + }, + ); + }); + DOM.dispatchEvent(el, "click"); + expect(calls).toBe(1); + expect(el.innerText).toBe("now:1"); after(250, () => { - expect(calls).toBe(1) - done() - }) - }) - - test("sends value on blur when phx-blur dispatches change", done => { - let calls = 0 - const el: HTMLInputElement = container().querySelector("input[name=throttle-range-with-blur]") - - el.addEventListener("input", e => { - DOM.debounce(el, e, "phx-debounce", 100, "phx-throttle", 200, () => true, () => { - calls++ - el.innerText = `now:${calls}` - }) - }) - el.value = "500" - DOM.dispatchEvent(el, "input") + expect(calls).toBe(1); + done(); + }); + }); + + test("sends value on blur when phx-blur dispatches change", (done) => { + let calls = 0; + const el: HTMLInputElement = container().querySelector( + "input[name=throttle-range-with-blur]", + ); + + el.addEventListener("input", (e) => { + DOM.debounce( + el, + e, + "phx-debounce", + 100, + "phx-throttle", + 200, + () => true, + () => { + calls++; + el.innerText = `now:${calls}`; + }, + ); + }); + el.value = "500"; + DOM.dispatchEvent(el, "input"); // these will be throttled - for(let i = 0; i < 100; i++){ - el.value = i.toString() - DOM.dispatchEvent(el, "input") + for (let i = 0; i < 100; i++) { + el.value = i.toString(); + DOM.dispatchEvent(el, "input"); } - expect(calls).toBe(1) - expect(el.innerText).toBe("now:1") + expect(calls).toBe(1); + expect(el.innerText).toBe("now:1"); // when using phx-blur={JS.dispatch("change")} we would trigger another // input event immediately after the blur // therefore starting a new throttle cycle - DOM.dispatchEvent(el, "blur") + DOM.dispatchEvent(el, "blur"); // simulate phx-blur - DOM.dispatchEvent(el, "input") - expect(calls).toBe(2) - expect(el.innerText).toBe("now:2") + DOM.dispatchEvent(el, "input"); + expect(calls).toBe(2); + expect(el.innerText).toBe("now:2"); after(250, () => { - expect(calls).toBe(2) - expect(el.innerText).toBe("now:2") - done() - }) - }) -}) - - -describe("throttle keydown", function (){ - test("when the same key is pressed triggers immediately, then on timeout", done => { - const keyPresses = {} - const el: HTMLDivElement = container().querySelector("#throttle-keydown") - - el.addEventListener("keydown", e => { - DOM.debounce(el, e, "phx-debounce", 100, "phx-throttle", 200, () => true, () => { - keyPresses[e.key] = (keyPresses[e.key] || 0) + 1 - }) - }) - - const pressA = new KeyboardEvent("keydown", {key: "a"}) - el.dispatchEvent(pressA) - el.dispatchEvent(pressA) - el.dispatchEvent(pressA) - - expect(keyPresses["a"]).toBe(1) + expect(calls).toBe(2); + expect(el.innerText).toBe("now:2"); + done(); + }); + }); +}); + +describe("throttle keydown", function () { + test("when the same key is pressed triggers immediately, then on timeout", (done) => { + const keyPresses = {}; + const el: HTMLDivElement = container().querySelector("#throttle-keydown"); + + el.addEventListener("keydown", (e) => { + DOM.debounce( + el, + e, + "phx-debounce", + 100, + "phx-throttle", + 200, + () => true, + () => { + keyPresses[e.key] = (keyPresses[e.key] || 0) + 1; + }, + ); + }); + + const pressA = new KeyboardEvent("keydown", { key: "a" }); + el.dispatchEvent(pressA); + el.dispatchEvent(pressA); + el.dispatchEvent(pressA); + + expect(keyPresses["a"]).toBe(1); after(250, () => { - expect(keyPresses["a"]).toBe(1) - el.dispatchEvent(pressA) - el.dispatchEvent(pressA) - el.dispatchEvent(pressA) - expect(keyPresses["a"]).toBe(2) - done() - }) - }) - - test("when different key is pressed triggers immediately", done => { - const keyPresses = {} - const el: HTMLDivElement = container().querySelector("#throttle-keydown") - - el.addEventListener("keydown", e => { - DOM.debounce(el, e, "phx-debounce", 100, "phx-throttle", 200, () => true, () => { - keyPresses[e.key] = (keyPresses[e.key] || 0) + 1 - }) - }) - - const pressA = new KeyboardEvent("keydown", {key: "a"}) - const pressB = new KeyboardEvent("keydown", {key: "b"}) - - el.dispatchEvent(pressA) - el.dispatchEvent(pressB) - el.dispatchEvent(pressA) - el.dispatchEvent(pressB) - - expect(keyPresses["a"]).toBe(2) - expect(keyPresses["b"]).toBe(2) - done() - }) -}) + expect(keyPresses["a"]).toBe(1); + el.dispatchEvent(pressA); + el.dispatchEvent(pressA); + el.dispatchEvent(pressA); + expect(keyPresses["a"]).toBe(2); + done(); + }); + }); + + test("when different key is pressed triggers immediately", (done) => { + const keyPresses = {}; + const el: HTMLDivElement = container().querySelector("#throttle-keydown"); + + el.addEventListener("keydown", (e) => { + DOM.debounce( + el, + e, + "phx-debounce", + 100, + "phx-throttle", + 200, + () => true, + () => { + keyPresses[e.key] = (keyPresses[e.key] || 0) + 1; + }, + ); + }); + + const pressA = new KeyboardEvent("keydown", { key: "a" }); + const pressB = new KeyboardEvent("keydown", { key: "b" }); + + el.dispatchEvent(pressA); + el.dispatchEvent(pressB); + el.dispatchEvent(pressA); + el.dispatchEvent(pressB); + + expect(keyPresses["a"]).toBe(2); + expect(keyPresses["b"]).toBe(2); + done(); + }); +}); diff --git a/assets/test/dom_test.ts b/assets/test/dom_test.ts index f0ec4a1723..6d40348bac 100644 --- a/assets/test/dom_test.ts +++ b/assets/test/dom_test.ts @@ -1,141 +1,178 @@ -import DOM from "phoenix_live_view/dom" -import {appendTitle, tag} from "./test_helpers" +import DOM from "phoenix_live_view/dom"; +import { appendTitle, tag } from "./test_helpers"; const e = (href: string) => { - const anchor = document.createElement("a") - anchor.setAttribute("href", href) + const anchor = document.createElement("a"); + anchor.setAttribute("href", href); const event = { target: anchor, defaultPrevented: false, - } as unknown as Event & {target: HTMLAnchorElement} - return event -} + } as unknown as Event & { target: HTMLAnchorElement }; + return event; +}; describe("DOM", () => { beforeEach(() => { - const curTitle = document.querySelector("title") - curTitle && curTitle.remove() - }) + const curTitle = document.querySelector("title"); + curTitle && curTitle.remove(); + }); - describe ("wantsNewTab", () => { + describe("wantsNewTab", () => { test("case insensitive target", () => { - const event = e("https://test.local") - expect(DOM.wantsNewTab(event)).toBe(false) + const event = e("https://test.local"); + expect(DOM.wantsNewTab(event)).toBe(false); // lowercase - event.target.setAttribute("target", "_blank") - expect(DOM.wantsNewTab(event)).toBe(true) + event.target.setAttribute("target", "_blank"); + expect(DOM.wantsNewTab(event)).toBe(true); // uppercase - event.target.setAttribute("target", "_BLANK") - expect(DOM.wantsNewTab(event)).toBe(true) - }) - }) + event.target.setAttribute("target", "_BLANK"); + expect(DOM.wantsNewTab(event)).toBe(true); + }); + }); describe("isNewPageClick", () => { test("identical locations", () => { - let currentLoc - currentLoc = new URL("https://test.local/foo") - expect(DOM.isNewPageClick(e("/foo"), currentLoc)).toBe(true) - expect(DOM.isNewPageClick(e("https://test.local/foo"), currentLoc)).toBe(true) - expect(DOM.isNewPageClick(e("//test.local/foo"), currentLoc)).toBe(true) + let currentLoc; + currentLoc = new URL("https://test.local/foo"); + expect(DOM.isNewPageClick(e("/foo"), currentLoc)).toBe(true); + expect(DOM.isNewPageClick(e("https://test.local/foo"), currentLoc)).toBe( + true, + ); + expect(DOM.isNewPageClick(e("//test.local/foo"), currentLoc)).toBe(true); // with hash - expect(DOM.isNewPageClick(e("/foo#hash"), currentLoc)).toBe(false) - expect(DOM.isNewPageClick(e("https://test.local/foo#hash"), currentLoc)).toBe(false) - expect(DOM.isNewPageClick(e("//test.local/foo#hash"), currentLoc)).toBe(false) + expect(DOM.isNewPageClick(e("/foo#hash"), currentLoc)).toBe(false); + expect( + DOM.isNewPageClick(e("https://test.local/foo#hash"), currentLoc), + ).toBe(false); + expect(DOM.isNewPageClick(e("//test.local/foo#hash"), currentLoc)).toBe( + false, + ); // different paths - expect(DOM.isNewPageClick(e("/foo2#hash"), currentLoc)).toBe(true) - expect(DOM.isNewPageClick(e("https://test.local/foo2#hash"), currentLoc)).toBe(true) - expect(DOM.isNewPageClick(e("//test.local/foo2#hash"), currentLoc)).toBe(true) - }) + expect(DOM.isNewPageClick(e("/foo2#hash"), currentLoc)).toBe(true); + expect( + DOM.isNewPageClick(e("https://test.local/foo2#hash"), currentLoc), + ).toBe(true); + expect(DOM.isNewPageClick(e("//test.local/foo2#hash"), currentLoc)).toBe( + true, + ); + }); test("identical locations with query", () => { - let currentLoc - currentLoc = new URL("https://test.local/foo?query=1") - expect(DOM.isNewPageClick(e("/foo"), currentLoc)).toBe(true) - expect(DOM.isNewPageClick(e("https://test.local/foo?query=1"), currentLoc)).toBe(true) - expect(DOM.isNewPageClick(e("//test.local/foo?query=1"), currentLoc)).toBe(true) + let currentLoc; + currentLoc = new URL("https://test.local/foo?query=1"); + expect(DOM.isNewPageClick(e("/foo"), currentLoc)).toBe(true); + expect( + DOM.isNewPageClick(e("https://test.local/foo?query=1"), currentLoc), + ).toBe(true); + expect( + DOM.isNewPageClick(e("//test.local/foo?query=1"), currentLoc), + ).toBe(true); // with hash - expect(DOM.isNewPageClick(e("/foo?query=1#hash"), currentLoc)).toBe(false) - expect(DOM.isNewPageClick(e("https://test.local/foo?query=1#hash"), currentLoc)).toBe(false) - expect(DOM.isNewPageClick(e("//test.local/foo?query=1#hash"), currentLoc)).toBe(false) + expect(DOM.isNewPageClick(e("/foo?query=1#hash"), currentLoc)).toBe( + false, + ); + expect( + DOM.isNewPageClick( + e("https://test.local/foo?query=1#hash"), + currentLoc, + ), + ).toBe(false); + expect( + DOM.isNewPageClick(e("//test.local/foo?query=1#hash"), currentLoc), + ).toBe(false); // different query - expect(DOM.isNewPageClick(e("/foo?query=2#hash"), currentLoc)).toBe(true) - expect(DOM.isNewPageClick(e("https://test.local/foo?query=2#hash"), currentLoc)).toBe(true) - expect(DOM.isNewPageClick(e("//test.local/foo?query=2#hash"), currentLoc)).toBe(true) - }) + expect(DOM.isNewPageClick(e("/foo?query=2#hash"), currentLoc)).toBe(true); + expect( + DOM.isNewPageClick( + e("https://test.local/foo?query=2#hash"), + currentLoc, + ), + ).toBe(true); + expect( + DOM.isNewPageClick(e("//test.local/foo?query=2#hash"), currentLoc), + ).toBe(true); + }); test("empty hash href", () => { - const currentLoc = new URL("https://test.local/foo") - expect(DOM.isNewPageClick(e("#"), currentLoc)).toBe(false) - }) + const currentLoc = new URL("https://test.local/foo"); + expect(DOM.isNewPageClick(e("#"), currentLoc)).toBe(false); + }); test("local hash", () => { - const currentLoc = new URL("https://test.local/foo") - expect(DOM.isNewPageClick(e("#foo"), currentLoc)).toBe(false) - }) + const currentLoc = new URL("https://test.local/foo"); + expect(DOM.isNewPageClick(e("#foo"), currentLoc)).toBe(false); + }); test("with defaultPrevented return sfalse", () => { - let currentLoc - currentLoc = new URL("https://test.local/foo") + let currentLoc; + currentLoc = new URL("https://test.local/foo"); const event = e("/foo"); - (event as any).defaultPrevented = true - expect(DOM.isNewPageClick(event, currentLoc)).toBe(false) - }) + (event as any).defaultPrevented = true; + expect(DOM.isNewPageClick(event, currentLoc)).toBe(false); + }); test("ignores mailto and tel links", () => { - expect(DOM.isNewPageClick(e("mailto:foo"), new URL("https://test.local/foo"))).toBe(false) - expect(DOM.isNewPageClick(e("tel:1234"), new URL("https://test.local/foo"))).toBe(false) - }) + expect( + DOM.isNewPageClick(e("mailto:foo"), new URL("https://test.local/foo")), + ).toBe(false); + expect( + DOM.isNewPageClick(e("tel:1234"), new URL("https://test.local/foo")), + ).toBe(false); + }); test("ignores contenteditable", () => { - let currentLoc - currentLoc = new URL("https://test.local/foo") + let currentLoc; + currentLoc = new URL("https://test.local/foo"); const event = e("/bar"); - (event.target as any).isContentEditable = true - expect(DOM.isNewPageClick(event, currentLoc)).toBe(false) - }) - }) + (event.target as any).isContentEditable = true; + expect(DOM.isNewPageClick(event, currentLoc)).toBe(false); + }); + }); describe("putTitle", () => { test("with no attributes", () => { - appendTitle({}) - DOM.putTitle("My Title") - expect(document.title).toBe("My Title") - }) + appendTitle({}); + DOM.putTitle("My Title"); + expect(document.title).toBe("My Title"); + }); test("with prefix", () => { - appendTitle({prefix: "PRE "}) - DOM.putTitle("My Title") - expect(document.title).toBe("PRE My Title") - }) + appendTitle({ prefix: "PRE " }); + DOM.putTitle("My Title"); + expect(document.title).toBe("PRE My Title"); + }); test("with suffix", () => { - appendTitle({suffix: " POST"}) - DOM.putTitle("My Title") - expect(document.title).toBe("My Title POST") - }) + appendTitle({ suffix: " POST" }); + DOM.putTitle("My Title"); + expect(document.title).toBe("My Title POST"); + }); test("with prefix and suffix", () => { - appendTitle({prefix: "PRE ", suffix: " POST"}) - DOM.putTitle("My Title") - expect(document.title).toBe("PRE My Title POST") - }) + appendTitle({ prefix: "PRE ", suffix: " POST" }); + DOM.putTitle("My Title"); + expect(document.title).toBe("PRE My Title POST"); + }); test("with default", () => { - appendTitle({default: "DEFAULT", prefix: "PRE ", suffix: " POST"}) - DOM.putTitle(null) - expect(document.title).toBe("PRE DEFAULT POST") + appendTitle({ default: "DEFAULT", prefix: "PRE ", suffix: " POST" }); + DOM.putTitle(null); + expect(document.title).toBe("PRE DEFAULT POST"); - DOM.putTitle(undefined) - expect(document.title).toBe("PRE DEFAULT POST") + DOM.putTitle(undefined); + expect(document.title).toBe("PRE DEFAULT POST"); - DOM.putTitle("") - expect(document.title).toBe("PRE DEFAULT POST") - }) - }) + DOM.putTitle(""); + expect(document.title).toBe("PRE DEFAULT POST"); + }); + }); describe("findExistingParentCIDs", () => { test("returns only parent cids", () => { - const view = tag("div", {}, ` + const view = tag( + "div", + {}, + `
{ class="phx-connected" data-phx-root-id="phx-FgFpFf-J8Gg-jEnh">
- `) - document.body.appendChild(view) - - view.appendChild(tag("div", {"data-phx-component": 1}, ` + `, + ); + document.body.appendChild(view); + + view.appendChild( + tag( + "div", + { "data-phx-component": 1 }, + `
- `)) - expect(DOM.findExistingParentCIDs(view, [1, 2])).toEqual(new Set([1])) - - view.appendChild(tag("div", {"data-phx-component": 1}, ` + `, + ), + ); + expect(DOM.findExistingParentCIDs(view, [1, 2])).toEqual(new Set([1])); + + view.appendChild( + tag( + "div", + { "data-phx-component": 1 }, + `
- `)) - expect(DOM.findExistingParentCIDs(view, [1, 2, 3])).toEqual(new Set([1])) - }) + `, + ), + ); + expect(DOM.findExistingParentCIDs(view, [1, 2, 3])).toEqual(new Set([1])); + }); test("ignores elements in child LiveViews #3626", () => { - const view = tag("div", {}, ` + const view = tag( + "div", + {}, + `
{ class="phx-connected" data-phx-root-id="phx-FgFpFf-J8Gg-jEnh">
- `) - document.body.appendChild(view) - - view.appendChild(tag("div", {"data-phx-component": 1}, ` + `, + ); + document.body.appendChild(view); + + view.appendChild( + tag( + "div", + { "data-phx-component": 1 }, + `
- `)) - expect(DOM.findExistingParentCIDs(view, [1])).toEqual(new Set([1])) - }) - }) + `, + ), + ); + expect(DOM.findExistingParentCIDs(view, [1])).toEqual(new Set([1])); + }); + }); describe("findComponentNodeList", () => { test("returns nodes with cid ID (except indirect children)", () => { - const component1 = tag("div", {"data-phx-component": 0}, "Hello") - const component2 = tag("div", {"data-phx-component": 0}, "World") - const component3 = tag("div", {"data-phx-session": "123"}, ` + const component1 = tag("div", { "data-phx-component": 0 }, "Hello"); + const component2 = tag("div", { "data-phx-component": 0 }, "World"); + const component3 = tag( + "div", + { "data-phx-session": "123" }, + `
- `) - document.body.appendChild(component1) - document.body.appendChild(component2) - document.body.appendChild(component3) - - expect(DOM.findComponentNodeList(document, 0)).toEqual([component1, component2]) - }) + `, + ); + document.body.appendChild(component1); + document.body.appendChild(component2); + document.body.appendChild(component3); + + expect(DOM.findComponentNodeList(document, 0)).toEqual([ + component1, + component2, + ]); + }); test("returns empty list with no matching cid", () => { - expect(DOM.findComponentNodeList(document, 123)).toEqual([]) - }) - }) + expect(DOM.findComponentNodeList(document, 123)).toEqual([]); + }); + }); test("isNowTriggerFormExternal", () => { - let form - form = tag("form", {"phx-trigger-external": ""}, "") - document.body.appendChild(form) - expect(DOM.isNowTriggerFormExternal(form, "phx-trigger-external")).toBe(true) - - form = tag("form", {}, "") - document.body.appendChild(form) - expect(DOM.isNowTriggerFormExternal(form, "phx-trigger-external")).toBe(false) + let form; + form = tag("form", { "phx-trigger-external": "" }, ""); + document.body.appendChild(form); + expect(DOM.isNowTriggerFormExternal(form, "phx-trigger-external")).toBe( + true, + ); + + form = tag("form", {}, ""); + document.body.appendChild(form); + expect(DOM.isNowTriggerFormExternal(form, "phx-trigger-external")).toBe( + false, + ); // not in the DOM -> false - form = tag("form", {"phx-trigger-external": ""}, "") - expect(DOM.isNowTriggerFormExternal(form, "phx-trigger-external")).toBe(false) - }) + form = tag("form", { "phx-trigger-external": "" }, ""); + expect(DOM.isNowTriggerFormExternal(form, "phx-trigger-external")).toBe( + false, + ); + }); describe("cleanChildNodes", () => { test("only cleans when phx-update is append or prepend", () => { @@ -221,13 +294,13 @@ describe("DOM", () => {
no id
some test - `.trim() + `.trim(); - const div = tag("div", {}, content) - DOM.cleanChildNodes(div, "phx-update") + const div = tag("div", {}, content); + DOM.cleanChildNodes(div, "phx-update"); - expect(div.innerHTML).toBe(content) - }) + expect(div.innerHTML).toBe(content); + }); test("silently removes empty text nodes", () => { const content = ` @@ -235,13 +308,13 @@ describe("DOM", () => {
2
- `.trim() + `.trim(); - const div = tag("div", {"phx-update": "append"}, content) - DOM.cleanChildNodes(div, "phx-update") + const div = tag("div", { "phx-update": "append" }, content); + DOM.cleanChildNodes(div, "phx-update"); - expect(div.innerHTML).toBe("
1
2
") - }) + expect(div.innerHTML).toBe('
1
2
'); + }); test("emits warning when removing elements without id", () => { const content = ` @@ -249,16 +322,16 @@ describe("DOM", () => {
no id
some test - `.trim() + `.trim(); - const div = tag("div", {"phx-update": "append"}, content) + const div = tag("div", { "phx-update": "append" }, content); - let errorCount = 0 - jest.spyOn(console, "error").mockImplementation(() => errorCount += 1) - DOM.cleanChildNodes(div, "phx-update") + let errorCount = 0; + jest.spyOn(console, "error").mockImplementation(() => (errorCount += 1)); + DOM.cleanChildNodes(div, "phx-update"); - expect(div.innerHTML).toBe("
1
") - expect(errorCount).toBe(2) - }) - }) -}) + expect(div.innerHTML).toBe('
1
'); + expect(errorCount).toBe(2); + }); + }); +}); diff --git a/assets/test/event_test.ts b/assets/test/event_test.ts index bf0ae46395..d6cb151921 100644 --- a/assets/test/event_test.ts +++ b/assets/test/event_test.ts @@ -1,314 +1,384 @@ -import {Socket} from "phoenix" -import LiveSocket from "phoenix_live_view/live_socket" -import View from "phoenix_live_view/view" +import { Socket } from "phoenix"; +import LiveSocket from "phoenix_live_view/live_socket"; +import View from "phoenix_live_view/view"; -import {version as liveview_version} from "../../package.json" +import { version as liveview_version } from "../../package.json"; -let containerId = 0 +let containerId = 0; let simulateView = (liveSocket, events, innerHTML) => { - let el = document.createElement("div") - el.setAttribute("data-phx-session", "abc123") - el.setAttribute("id", `container${containerId++}`) - el.innerHTML = innerHTML - document.body.appendChild(el) + let el = document.createElement("div"); + el.setAttribute("data-phx-session", "abc123"); + el.setAttribute("id", `container${containerId++}`); + el.innerHTML = innerHTML; + document.body.appendChild(el); - let view = new View(el, liveSocket) - view.onJoin({rendered: {e: events, s: [innerHTML]}, liveview_version}) - view.isConnected = () => true - return view -} + let view = new View(el, liveSocket); + view.onJoin({ rendered: { e: events, s: [innerHTML] }, liveview_version }); + view.isConnected = () => true; + return view; +}; let stubNextChannelReply = (view, replyPayload) => { - let oldPush = view.channel.push + let oldPush = view.channel.push; view.channel.push = () => { return { receives: [], - receive(kind, cb){ - if(kind === "ok"){ - cb({diff: {r: replyPayload}}) - view.channel.push = oldPush + receive(kind, cb) { + if (kind === "ok") { + cb({ diff: { r: replyPayload } }); + view.channel.push = oldPush; } - return this - } - } - } -} + return this; + }, + }; + }; +}; let stubNextChannelReplyWithError = (view, reason) => { - let oldPush = view.channel.push + let oldPush = view.channel.push; view.channel.push = () => { return { receives: [], - receive(kind, cb){ - if(kind === "error"){ - cb(reason) - view.channel.push = oldPush + receive(kind, cb) { + if (kind === "error") { + cb(reason); + view.channel.push = oldPush; } - return this - } - } - } -} + return this; + }, + }; + }; +}; describe("events", () => { - let processedEvents + let processedEvents; beforeEach(() => { - document.body.innerHTML = "" - processedEvents = [] - }) + document.body.innerHTML = ""; + processedEvents = []; + }); test("events on join", () => { let liveSocket = new LiveSocket("/live", Socket, { hooks: { Map: { - mounted(){ - this.handleEvent("points", data => processedEvents.push({event: "points", data: data})) - } - } - } - }) - let _view = simulateView(liveSocket, [["points", {values: [1, 2, 3]}]], ` + mounted() { + this.handleEvent("points", (data) => + processedEvents.push({ event: "points", data: data }), + ); + }, + }, + }, + }); + let _view = simulateView( + liveSocket, + [["points", { values: [1, 2, 3] }]], + `
- `) + `, + ); - expect(processedEvents).toEqual([{event: "points", data: {values: [1, 2, 3]}}]) - }) + expect(processedEvents).toEqual([ + { event: "points", data: { values: [1, 2, 3] } }, + ]); + }); test("events on update", () => { let liveSocket = new LiveSocket("/live", Socket, { hooks: { Game: { - mounted(){ - this.handleEvent("scores", data => processedEvents.push({event: "scores", data: data})) - } - } - } - }) - let view = simulateView(liveSocket, [], ` + mounted() { + this.handleEvent("scores", (data) => + processedEvents.push({ event: "scores", data: data }), + ); + }, + }, + }, + }); + let view = simulateView( + liveSocket, + [], + `
- `) + `, + ); - expect(processedEvents).toEqual([]) + expect(processedEvents).toEqual([]); - view.update({}, [["scores", {values: [1, 2, 3]}]]) - expect(processedEvents).toEqual([{event: "scores", data: {values: [1, 2, 3]}}]) - }) + view.update({}, [["scores", { values: [1, 2, 3] }]]); + expect(processedEvents).toEqual([ + { event: "scores", data: { values: [1, 2, 3] } }, + ]); + }); test("events handlers are cleaned up on destroy", () => { - let destroyed = [] + let destroyed = []; let liveSocket = new LiveSocket("/live", Socket, { hooks: { Handler: { - mounted(){ - this.handleEvent("my-event", data => processedEvents.push({id: this.el.id, event: "my-event", data: data})) + mounted() { + this.handleEvent("my-event", (data) => + processedEvents.push({ + id: this.el.id, + event: "my-event", + data: data, + }), + ); }, - destroyed(){ destroyed.push(this.el.id) } - } - } - }) - let view = simulateView(liveSocket, [], ` + destroyed() { + destroyed.push(this.el.id); + }, + }, + }, + }); + let view = simulateView( + liveSocket, + [], + `
- `) + `, + ); - expect(processedEvents).toEqual([]) + expect(processedEvents).toEqual([]); - view.update({}, [["my-event", {val: 1}]]) + view.update({}, [["my-event", { val: 1 }]]); expect(processedEvents).toEqual([ - {id: "handler1", event: "my-event", data: {val: 1}}, - {id: "handler2", event: "my-event", data: {val: 1}} - ]) + { id: "handler1", event: "my-event", data: { val: 1 } }, + { id: "handler2", event: "my-event", data: { val: 1 } }, + ]); - let newHTML = "
" - view.update({s: [newHTML]}, [["my-event", {val: 2}]]) + let newHTML = '
'; + view.update({ s: [newHTML] }, [["my-event", { val: 2 }]]); - expect(destroyed).toEqual(["handler2"]) + expect(destroyed).toEqual(["handler2"]); expect(processedEvents).toEqual([ - {id: "handler1", event: "my-event", data: {val: 1}}, - {id: "handler2", event: "my-event", data: {val: 1}}, - {id: "handler1", event: "my-event", data: {val: 2}} - ]) - }) + { id: "handler1", event: "my-event", data: { val: 1 } }, + { id: "handler2", event: "my-event", data: { val: 1 } }, + { id: "handler1", event: "my-event", data: { val: 2 } }, + ]); + }); test("removeHandleEvent", () => { let liveSocket = new LiveSocket("/live", Socket, { hooks: { Remove: { - mounted(){ - let ref = this.handleEvent("remove", data => { - this.removeHandleEvent(ref) - processedEvents.push({event: "remove", data: data}) - }) - } - } - } - }) - let view = simulateView(liveSocket, [], ` + mounted() { + let ref = this.handleEvent("remove", (data) => { + this.removeHandleEvent(ref); + processedEvents.push({ event: "remove", data: data }); + }); + }, + }, + }, + }); + let view = simulateView( + liveSocket, + [], + `
- `) + `, + ); - expect(processedEvents).toEqual([]) + expect(processedEvents).toEqual([]); - view.update({}, [["remove", {val: 1}]]) - expect(processedEvents).toEqual([{event: "remove", data: {val: 1}}]) + view.update({}, [["remove", { val: 1 }]]); + expect(processedEvents).toEqual([{ event: "remove", data: { val: 1 } }]); - view.update({}, [["remove", {val: 1}]]) - expect(processedEvents).toEqual([{event: "remove", data: {val: 1}}]) - }) -}) + view.update({}, [["remove", { val: 1 }]]); + expect(processedEvents).toEqual([{ event: "remove", data: { val: 1 } }]); + }); +}); describe("pushEvent replies", () => { - let processedReplies + let processedReplies; beforeEach(() => { - processedReplies = [] - }) + processedReplies = []; + }); test("reply", (done) => { - let view + let view; let liveSocket = new LiveSocket("/live", Socket, { hooks: { Gateway: { - mounted(){ - stubNextChannelReply(view, {transactionID: "1001"}) - this.pushEvent("charge", {amount: 123}, (resp, ref) => { - processedReplies.push({resp, ref}) - view.el.dispatchEvent(new CustomEvent("replied", {detail: {resp, ref}})) - }) - } - } - } - }) - view = simulateView(liveSocket, [], "") - view.update({ - s: [` + mounted() { + stubNextChannelReply(view, { transactionID: "1001" }); + this.pushEvent("charge", { amount: 123 }, (resp, ref) => { + processedReplies.push({ resp, ref }); + view.el.dispatchEvent( + new CustomEvent("replied", { detail: { resp, ref } }), + ); + }); + }, + }, + }, + }); + view = simulateView(liveSocket, [], ""); + view.update( + { + s: [ + `
- `] - }, []) + `, + ], + }, + [], + ); view.el.addEventListener("replied", () => { - expect(processedReplies).toEqual([{resp: {transactionID: "1001"}, ref: 0}]) - done() - }) - }) + expect(processedReplies).toEqual([ + { resp: { transactionID: "1001" }, ref: 0 }, + ]); + done(); + }); + }); test("promise", (done) => { - let view + let view; let liveSocket = new LiveSocket("/live", Socket, { hooks: { Gateway: { - mounted(){ - stubNextChannelReply(view, {transactionID: "1001"}) - this.pushEvent("charge", {amount: 123}).then((reply) => { - processedReplies.push(reply) - view.el.dispatchEvent(new CustomEvent("replied", {detail: reply})) - }) - } - } - } - }) - view = simulateView(liveSocket, [], "") - view.update({ - s: [` + mounted() { + stubNextChannelReply(view, { transactionID: "1001" }); + this.pushEvent("charge", { amount: 123 }).then((reply) => { + processedReplies.push(reply); + view.el.dispatchEvent( + new CustomEvent("replied", { detail: reply }), + ); + }); + }, + }, + }, + }); + view = simulateView(liveSocket, [], ""); + view.update( + { + s: [ + `
- `] - }, []) + `, + ], + }, + [], + ); view.el.addEventListener("replied", () => { - expect(processedReplies).toEqual([{transactionID: "1001"}]) - done() - }) - }) + expect(processedReplies).toEqual([{ transactionID: "1001" }]); + done(); + }); + }); test("rejects with error", (done) => { - let view + let view; let liveSocket = new LiveSocket("/live", Socket, { hooks: { Gateway: { - mounted(){ - stubNextChannelReplyWithError(view, "error") - this.pushEvent("charge", {amount: 123}).catch((error) => { - expect(error).toEqual(expect.any(Error)) - done() - }) - } - } - } - }) - view = simulateView(liveSocket, [], "") - view.update({ - s: [` + mounted() { + stubNextChannelReplyWithError(view, "error"); + this.pushEvent("charge", { amount: 123 }).catch((error) => { + expect(error).toEqual(expect.any(Error)); + done(); + }); + }, + }, + }, + }); + view = simulateView(liveSocket, [], ""); + view.update( + { + s: [ + `
- `] - }, []) - }) + `, + ], + }, + [], + ); + }); test("pushEventTo - promise with multiple targets", (done) => { - let view + let view; let liveSocket = new LiveSocket("/live", Socket, { hooks: { Gateway: { - mounted(){ - stubNextChannelReply(view, {transactionID: "1001"}) - this.pushEventTo("[data-foo]", "charge", {amount: 123}).then((result) => { - expect(result).toEqual([ - {status: "fulfilled", value: {ref: 0, reply: {transactionID: "1001"}}}, - // we only stubbed one reply - {status: "rejected", reason: expect.any(Error)} - ]) - done() - }) - } - } - } - }) - view = simulateView(liveSocket, [], "") - liveSocket.main = view - view.update({ - s: [` + mounted() { + stubNextChannelReply(view, { transactionID: "1001" }); + this.pushEventTo("[data-foo]", "charge", { amount: 123 }).then( + (result) => { + expect(result).toEqual([ + { + status: "fulfilled", + value: { ref: 0, reply: { transactionID: "1001" } }, + }, + // we only stubbed one reply + { status: "rejected", reason: expect.any(Error) }, + ]); + done(); + }, + ); + }, + }, + }, + }); + view = simulateView(liveSocket, [], ""); + liveSocket.main = view; + view.update( + { + s: [ + `
- `] - }, []) - }) + `, + ], + }, + [], + ); + }); test("pushEvent without connection noops", (done) => { - let view - const spy = jest.fn() + let view; + const spy = jest.fn(); let liveSocket = new LiveSocket("/live", Socket, { hooks: { Gateway: { - mounted(){ - stubNextChannelReply(view, {transactionID: "1001"}) - this.pushEvent("charge", {amount: 1233433}).then(spy).catch(() => { - view.el.dispatchEvent(new CustomEvent("pushed")) - }) - } - } - } - }) - view = simulateView(liveSocket, [], "") - view.isConnected = () => false - view.update({ - s: [` + mounted() { + stubNextChannelReply(view, { transactionID: "1001" }); + this.pushEvent("charge", { amount: 1233433 }) + .then(spy) + .catch(() => { + view.el.dispatchEvent(new CustomEvent("pushed")); + }); + }, + }, + }, + }); + view = simulateView(liveSocket, [], ""); + view.isConnected = () => false; + view.update( + { + s: [ + `
- `] - }, []) + `, + ], + }, + [], + ); view.el.addEventListener("pushed", () => { - expect(spy).not.toHaveBeenCalled() - done() - }) - }) -}) + expect(spy).not.toHaveBeenCalled(); + done(); + }); + }); +}); diff --git a/assets/test/globals.d.ts b/assets/test/globals.d.ts index a6ca30d664..121ac29cfc 100644 --- a/assets/test/globals.d.ts +++ b/assets/test/globals.d.ts @@ -1,7 +1,7 @@ declare global { - function setStartSystemTime(): void - function advanceTimersToNextFrame(): void - let LV_VSN: string + function setStartSystemTime(): void; + function advanceTimersToNextFrame(): void; + let LV_VSN: string; } -export {} +export {}; diff --git a/assets/test/index_test.ts b/assets/test/index_test.ts index 36b8902bc8..150b289322 100644 --- a/assets/test/index_test.ts +++ b/assets/test/index_test.ts @@ -1,24 +1,24 @@ -import {LiveSocket, isUsedInput, ViewHook} from "phoenix_live_view" -import * as LiveSocket2 from "phoenix_live_view/live_socket" -import ViewHook2 from "phoenix_live_view/view_hook" -import DOM from "phoenix_live_view/dom" +import { LiveSocket, isUsedInput, ViewHook } from "phoenix_live_view"; +import * as LiveSocket2 from "phoenix_live_view/live_socket"; +import ViewHook2 from "phoenix_live_view/view_hook"; +import DOM from "phoenix_live_view/dom"; describe("Named Imports", () => { test("LiveSocket is equal to the actual LiveSocket", () => { - expect(LiveSocket).toBe(LiveSocket2.default) - }) + expect(LiveSocket).toBe(LiveSocket2.default); + }); test("ViewHook is equal to the actual ViewHook", () => { - expect(ViewHook).toBe(ViewHook2) - }) -}) + expect(ViewHook).toBe(ViewHook2); + }); +}); describe("isUsedInput", () => { test("returns true if the input is used", () => { - const input = document.createElement("input") - input.type = "text" - expect(isUsedInput(input)).toBeFalsy() - DOM.putPrivate(input, "phx-has-focused", true) - expect(isUsedInput(input)).toBe(true) - }) -}) + const input = document.createElement("input"); + input.type = "text"; + expect(isUsedInput(input)).toBeFalsy(); + DOM.putPrivate(input, "phx-has-focused", true); + expect(isUsedInput(input)).toBe(true); + }); +}); diff --git a/assets/test/integration/event_test.ts b/assets/test/integration/event_test.ts index 92a39cd8e8..e68f43244a 100644 --- a/assets/test/integration/event_test.ts +++ b/assets/test/integration/event_test.ts @@ -1,11 +1,25 @@ -import {Socket} from "phoenix" -import LiveSocket from "phoenix_live_view/live_socket" +import { Socket } from "phoenix"; +import LiveSocket from "phoenix_live_view/live_socket"; const stubViewPushInput = (view, callback) => { - view.pushInput = (sourceEl, targetCtx, newCid, event, pushOpts, originalCallback) => { - return callback(sourceEl, targetCtx, newCid, event, pushOpts, originalCallback) - } -} + view.pushInput = ( + sourceEl, + targetCtx, + newCid, + event, + pushOpts, + originalCallback, + ) => { + return callback( + sourceEl, + targetCtx, + newCid, + event, + pushOpts, + originalCallback, + ); + }; +}; const prepareLiveViewDOM = (document, rootId) => { document.body.innerHTML = ` @@ -22,41 +36,44 @@ const prepareLiveViewDOM = (document, rootId) => {
- ` -} + `; +}; describe("events", () => { beforeEach(() => { - prepareLiveViewDOM(global.document, "root") - }) + prepareLiveViewDOM(global.document, "root"); + }); test("send change event to correct target", () => { - const liveSocket = new LiveSocket("/live", Socket) - liveSocket.connect() - const view = liveSocket.getViewByEl(document.getElementById("root")) - view.isConnected = () => true - const input = view.el.querySelector("#first_name") + const liveSocket = new LiveSocket("/live", Socket); + liveSocket.connect(); + const view = liveSocket.getViewByEl(document.getElementById("root")); + view.isConnected = () => true; + const input = view.el.querySelector("#first_name"); let meta = { event: null, target: null, changed: null, - } + }; - stubViewPushInput(view, (sourceEl, targetCtx, newCid, event, pushOpts, _callback) => { - meta = { - event, - target: targetCtx, - changed: pushOpts["_target"] - } - }) + stubViewPushInput( + view, + (sourceEl, targetCtx, newCid, event, pushOpts, _callback) => { + meta = { + event, + target: targetCtx, + changed: pushOpts["_target"], + }; + }, + ); - input.value = "John Doe" - input.dispatchEvent(new Event("change", {bubbles: true})) + input.value = "John Doe"; + input.dispatchEvent(new Event("change", { bubbles: true })); expect(meta).toEqual({ event: "validate", target: 2, - changed: "user[first_name]" - }) - }) -}) + changed: "user[first_name]", + }); + }); +}); diff --git a/assets/test/integration/metadata_test.ts b/assets/test/integration/metadata_test.ts index 6960cc465a..a9b3963d82 100644 --- a/assets/test/integration/metadata_test.ts +++ b/assets/test/integration/metadata_test.ts @@ -1,11 +1,11 @@ -import {Socket} from "phoenix" -import LiveSocket from "phoenix_live_view/live_socket" +import { Socket } from "phoenix"; +import LiveSocket from "phoenix_live_view/live_socket"; const stubViewPushEvent = (view, callback) => { view.pushEvent = (type, el, targetCtx, phxEvent, meta, opts = {}) => { - return callback(type, el, targetCtx, phxEvent, meta, opts) - } -} + return callback(type, el, targetCtx, phxEvent, meta, opts); + }; +}; const prepareLiveViewDOM = (document, rootId) => { document.body.innerHTML = ` @@ -16,27 +16,30 @@ const prepareLiveViewDOM = (document, rootId) => {
- ` -} + `; +}; describe("metadata", () => { beforeEach(() => { - prepareLiveViewDOM(global.document, "root") - }) + prepareLiveViewDOM(global.document, "root"); + }); test("is empty by default", () => { - const liveSocket = new LiveSocket("/live", Socket) - liveSocket.connect() - const view = liveSocket.getViewByEl(document.getElementById("root")) - const btn = view.el.querySelector("button") - let meta = {} - stubViewPushEvent(view, (type, el, target, targetCtx, phxEvent, metadata) => { - meta = metadata - }) - btn.dispatchEvent(new Event("click", {bubbles: true})) + const liveSocket = new LiveSocket("/live", Socket); + liveSocket.connect(); + const view = liveSocket.getViewByEl(document.getElementById("root")); + const btn = view.el.querySelector("button"); + let meta = {}; + stubViewPushEvent( + view, + (type, el, target, targetCtx, phxEvent, metadata) => { + meta = metadata; + }, + ); + btn.dispatchEvent(new Event("click", { bubbles: true })); - expect(meta).toEqual({}) - }) + expect(meta).toEqual({}); + }); test("can be user defined", () => { const liveSocket = new LiveSocket("/live", Socket, { @@ -45,23 +48,24 @@ describe("metadata", () => { return { id: el.id, altKey: e.altKey, - } - } - } - }) - liveSocket.connect() - liveSocket.isConnected = () => true - const view = liveSocket.getViewByEl(document.getElementById("root")) - view.isConnected = () => true - const btn = view.el.querySelector("button") - let meta = {} + }; + }, + }, + }); + liveSocket.connect(); + liveSocket.isConnected = () => true; + const view = liveSocket.getViewByEl(document.getElementById("root")); + view.isConnected = () => true; + const btn = view.el.querySelector("button"); + let meta = {}; stubViewPushEvent(view, (type, el, target, phxEvent, metadata, _opts) => { - meta = metadata - }) - btn.dispatchEvent(new Event("click", {bubbles: true})) + meta = metadata; + }); + btn.dispatchEvent(new Event("click", { bubbles: true })); expect(meta).toEqual({ - id: "btn", altKey: undefined - }) - }) -}) + id: "btn", + altKey: undefined, + }); + }); +}); diff --git a/assets/test/js_test.ts b/assets/test/js_test.ts index b4ddd505ff..62455cc1ba 100644 --- a/assets/test/js_test.ts +++ b/assets/test/js_test.ts @@ -1,641 +1,665 @@ -import {Socket} from "phoenix" -import LiveSocket from "phoenix_live_view/live_socket" -import JS from "phoenix_live_view/js" -import ViewHook from "phoenix_live_view/view_hook" -import {simulateJoinedView, simulateVisibility, liveViewDOM} from "./test_helpers" -import {HookJSCommands} from "phoenix_live_view/js_commands" +import { Socket } from "phoenix"; +import LiveSocket from "phoenix_live_view/live_socket"; +import JS from "phoenix_live_view/js"; +import ViewHook from "phoenix_live_view/view_hook"; +import { + simulateJoinedView, + simulateVisibility, + liveViewDOM, +} from "./test_helpers"; +import { HookJSCommands } from "phoenix_live_view/js_commands"; const setupView = (content) => { - const el = liveViewDOM(content) - global.document.body.appendChild(el) - const liveSocket = new LiveSocket("/live", Socket) - return simulateJoinedView(el, liveSocket) -} + const el = liveViewDOM(content); + global.document.body.appendChild(el); + const liveSocket = new LiveSocket("/live", Socket); + return simulateJoinedView(el, liveSocket); +}; -const event = new CustomEvent("phx:exec") +const event = new CustomEvent("phx:exec"); describe("JS", () => { beforeEach(() => { - global.document.body.innerHTML = "" - jest.useFakeTimers() - setStartSystemTime() - }) + global.document.body.innerHTML = ""; + jest.useFakeTimers(); + setStartSystemTime(); + }); afterEach(() => { - jest.useRealTimers() - }) + jest.useRealTimers(); + }); describe("hook.js()", () => { - let js: HookJSCommands - let view, modal + let js: HookJSCommands; + let view, modal; beforeEach(() => { - view = setupView("
modal
") - modal = view.el.querySelector("#modal") - const hook = new ViewHook(view, view.el, {}) - js = hook.js() - }) - - test("exec", done => { - simulateVisibility(modal) - expect(modal.style.display).toBe("") - js.exec("[[\"toggle\", {\"to\": \"#modal\"}]]") - jest.advanceTimersByTime(100) - expect(modal.style.display).toBe("none") - done() - }) - - test("show and hide", done => { - simulateVisibility(modal) - expect(modal.style.display).toBe("") - js.hide(modal) - jest.advanceTimersByTime(100) - expect(modal.style.display).toBe("none") - js.show(modal) - jest.advanceTimersByTime(100) - expect(modal.style.display).toBe("block") - done() - }) - - test("toggle", done => { - simulateVisibility(modal) - expect(modal.style.display).toBe("") - js.toggle(modal) - jest.advanceTimersByTime(100) - expect(modal.style.display).toBe("none") - js.toggle(modal) - jest.advanceTimersByTime(100) - expect(modal.style.display).toBe("block") - done() - }) - - test("addClass and removeClass", done => { - expect(Array.from(modal.classList)).toEqual([]) - js.addClass(modal, "class1 class2") - jest.advanceTimersByTime(100) - expect(Array.from(modal.classList)).toEqual(["class1", "class2"]) - jest.advanceTimersByTime(100) - js.removeClass(modal, "class1") - jest.advanceTimersByTime(100) - expect(Array.from(modal.classList)).toEqual(["class2"]) - js.addClass(modal, ["class3", "class4"]) - jest.advanceTimersByTime(100) - expect(Array.from(modal.classList)).toEqual(["class2", "class3", "class4"]) - js.removeClass(modal, ["class3", "class4"]) - jest.advanceTimersByTime(100) - expect(Array.from(modal.classList)).toEqual(["class2"]) - done() - }) - - test("toggleClass", done => { - expect(Array.from(modal.classList)).toEqual([]) - js.toggleClass(modal, "class1 class2") - jest.advanceTimersByTime(100) - expect(Array.from(modal.classList)).toEqual(["class1", "class2"]) - js.toggleClass(modal, ["class1"]) - jest.advanceTimersByTime(100) - expect(Array.from(modal.classList)).toEqual(["class2"]) - done() - }) - - test("transition", done => { - js.transition(modal, "shake", {time: 150}) - jest.advanceTimersByTime(100) - expect(Array.from(modal.classList)).toEqual(["shake"]) - jest.advanceTimersByTime(100) - expect(Array.from(modal.classList)).toEqual([]) - js.transition(modal, ["shake", "opacity-50", "opacity-100"], {time: 150}) - jest.advanceTimersByTime(10) - expect(Array.from(modal.classList)).toEqual(["opacity-50"]) - jest.advanceTimersByTime(200) - expect(Array.from(modal.classList)).toEqual(["opacity-100"]) - done() - }) - - test("setAttribute and removeAttribute", done => { - js.removeAttribute(modal, "works") - js.setAttribute(modal, "works", "123") - expect(modal.getAttribute("works")).toBe("123") - js.removeAttribute(modal, "works") - expect(modal.getAttribute("works")).toBe(null) - done() - }) - - test("toggleAttr", done => { - js.toggleAttribute(modal, "works", "on", "off") - expect(modal.getAttribute("works")).toBe("on") - js.toggleAttribute(modal, "works", "on", "off") - expect(modal.getAttribute("works")).toBe("off") - js.toggleAttribute(modal, "works", "on", "off") - expect(modal.getAttribute("works")).toBe("on") - done() - }) - - test("push", done => { - const originalWithinOwners = view.liveSocket.withinOwners + view = setupView(''); + modal = view.el.querySelector("#modal"); + const hook = new ViewHook(view, view.el, {}); + js = hook.js(); + }); + + test("exec", (done) => { + simulateVisibility(modal); + expect(modal.style.display).toBe(""); + js.exec('[["toggle", {"to": "#modal"}]]'); + jest.advanceTimersByTime(100); + expect(modal.style.display).toBe("none"); + done(); + }); + + test("show and hide", (done) => { + simulateVisibility(modal); + expect(modal.style.display).toBe(""); + js.hide(modal); + jest.advanceTimersByTime(100); + expect(modal.style.display).toBe("none"); + js.show(modal); + jest.advanceTimersByTime(100); + expect(modal.style.display).toBe("block"); + done(); + }); + + test("toggle", (done) => { + simulateVisibility(modal); + expect(modal.style.display).toBe(""); + js.toggle(modal); + jest.advanceTimersByTime(100); + expect(modal.style.display).toBe("none"); + js.toggle(modal); + jest.advanceTimersByTime(100); + expect(modal.style.display).toBe("block"); + done(); + }); + + test("addClass and removeClass", (done) => { + expect(Array.from(modal.classList)).toEqual([]); + js.addClass(modal, "class1 class2"); + jest.advanceTimersByTime(100); + expect(Array.from(modal.classList)).toEqual(["class1", "class2"]); + jest.advanceTimersByTime(100); + js.removeClass(modal, "class1"); + jest.advanceTimersByTime(100); + expect(Array.from(modal.classList)).toEqual(["class2"]); + js.addClass(modal, ["class3", "class4"]); + jest.advanceTimersByTime(100); + expect(Array.from(modal.classList)).toEqual([ + "class2", + "class3", + "class4", + ]); + js.removeClass(modal, ["class3", "class4"]); + jest.advanceTimersByTime(100); + expect(Array.from(modal.classList)).toEqual(["class2"]); + done(); + }); + + test("toggleClass", (done) => { + expect(Array.from(modal.classList)).toEqual([]); + js.toggleClass(modal, "class1 class2"); + jest.advanceTimersByTime(100); + expect(Array.from(modal.classList)).toEqual(["class1", "class2"]); + js.toggleClass(modal, ["class1"]); + jest.advanceTimersByTime(100); + expect(Array.from(modal.classList)).toEqual(["class2"]); + done(); + }); + + test("transition", (done) => { + js.transition(modal, "shake", { time: 150 }); + jest.advanceTimersByTime(100); + expect(Array.from(modal.classList)).toEqual(["shake"]); + jest.advanceTimersByTime(100); + expect(Array.from(modal.classList)).toEqual([]); + js.transition(modal, ["shake", "opacity-50", "opacity-100"], { + time: 150, + }); + jest.advanceTimersByTime(10); + expect(Array.from(modal.classList)).toEqual(["opacity-50"]); + jest.advanceTimersByTime(200); + expect(Array.from(modal.classList)).toEqual(["opacity-100"]); + done(); + }); + + test("setAttribute and removeAttribute", (done) => { + js.removeAttribute(modal, "works"); + js.setAttribute(modal, "works", "123"); + expect(modal.getAttribute("works")).toBe("123"); + js.removeAttribute(modal, "works"); + expect(modal.getAttribute("works")).toBe(null); + done(); + }); + + test("toggleAttr", (done) => { + js.toggleAttribute(modal, "works", "on", "off"); + expect(modal.getAttribute("works")).toBe("on"); + js.toggleAttribute(modal, "works", "on", "off"); + expect(modal.getAttribute("works")).toBe("off"); + js.toggleAttribute(modal, "works", "on", "off"); + expect(modal.getAttribute("works")).toBe("on"); + done(); + }); + + test("push", (done) => { + const originalWithinOwners = view.liveSocket.withinOwners; view.liveSocket.withinOwners = (el, callback) => { - callback(view) - } - - const originalExec = JS.exec - JS.exec = jest.fn() - - js.push(modal, "custom-event", {value: {key: "value"}}) - - expect(JS.exec).toHaveBeenCalled() - - view.liveSocket.withinOwners = originalWithinOwners - JS.exec = originalExec - done() - }) - - test("navigate", done => { - const originalHistoryRedirect = view.liveSocket.historyRedirect - view.liveSocket.historyRedirect = jest.fn() - - js.navigate("/test-url") + callback(view); + }; + + const originalExec = JS.exec; + JS.exec = jest.fn(); + + js.push(modal, "custom-event", { value: { key: "value" } }); + + expect(JS.exec).toHaveBeenCalled(); + + view.liveSocket.withinOwners = originalWithinOwners; + JS.exec = originalExec; + done(); + }); + + test("navigate", (done) => { + const originalHistoryRedirect = view.liveSocket.historyRedirect; + view.liveSocket.historyRedirect = jest.fn(); + + js.navigate("/test-url"); expect(view.liveSocket.historyRedirect).toHaveBeenCalledWith( expect.any(CustomEvent), "/test-url", "push", null, - null - ) - - js.navigate("/test-url", {replace: true}) + null, + ); + + js.navigate("/test-url", { replace: true }); expect(view.liveSocket.historyRedirect).toHaveBeenCalledWith( expect.any(CustomEvent), "/test-url", "replace", null, - null - ) - - view.liveSocket.historyRedirect = originalHistoryRedirect - done() - }) - - test("patch", done => { - const originalPushHistoryPatch = view.liveSocket.pushHistoryPatch - view.liveSocket.pushHistoryPatch = jest.fn() - - js.patch("/test-url") + null, + ); + + view.liveSocket.historyRedirect = originalHistoryRedirect; + done(); + }); + + test("patch", (done) => { + const originalPushHistoryPatch = view.liveSocket.pushHistoryPatch; + view.liveSocket.pushHistoryPatch = jest.fn(); + + js.patch("/test-url"); expect(view.liveSocket.pushHistoryPatch).toHaveBeenCalledWith( expect.any(CustomEvent), "/test-url", "push", - null - ) - - js.patch("/test-url", {replace: true}) + null, + ); + + js.patch("/test-url", { replace: true }); expect(view.liveSocket.pushHistoryPatch).toHaveBeenCalledWith( expect.any(CustomEvent), "/test-url", "replace", - null - ) - - view.liveSocket.pushHistoryPatch = originalPushHistoryPatch - done() - }) - }) + null, + ); + + view.liveSocket.pushHistoryPatch = originalPushHistoryPatch; + done(); + }); + }); describe("exec_toggle", () => { - test("with defaults", done => { + test("with defaults", (done) => { const view = setupView(`
- `) - const modal = simulateVisibility(document.querySelector("#modal")) - const click = document.querySelector("#click") - let showEndCalled = false - let hideEndCalled = false - let showStartCalled = false - let hideStartCalled = false - modal.addEventListener("phx:show-end", () => showEndCalled = true) - modal.addEventListener("phx:hide-end", () => hideEndCalled = true) - modal.addEventListener("phx:show-start", () => showStartCalled = true) - modal.addEventListener("phx:hide-start", () => hideStartCalled = true) - - expect(modal.style.display).toEqual("") - JS.exec(event, "click", click.getAttribute("phx-click"), view, click) - jest.runAllTimers() - - expect(modal.style.display).toEqual("none") - - JS.exec(event, "click", click.getAttribute("phx-click"), view, click) - jest.runAllTimers() - - expect(modal.style.display).toEqual("block") - expect(showEndCalled).toBe(true) - expect(hideEndCalled).toBe(true) - expect(showStartCalled).toBe(true) - expect(hideStartCalled).toBe(true) - - done() - }) - - test("with display", done => { + `); + const modal = simulateVisibility(document.querySelector("#modal")); + const click = document.querySelector("#click"); + let showEndCalled = false; + let hideEndCalled = false; + let showStartCalled = false; + let hideStartCalled = false; + modal.addEventListener("phx:show-end", () => (showEndCalled = true)); + modal.addEventListener("phx:hide-end", () => (hideEndCalled = true)); + modal.addEventListener("phx:show-start", () => (showStartCalled = true)); + modal.addEventListener("phx:hide-start", () => (hideStartCalled = true)); + + expect(modal.style.display).toEqual(""); + JS.exec(event, "click", click.getAttribute("phx-click"), view, click); + jest.runAllTimers(); + + expect(modal.style.display).toEqual("none"); + + JS.exec(event, "click", click.getAttribute("phx-click"), view, click); + jest.runAllTimers(); + + expect(modal.style.display).toEqual("block"); + expect(showEndCalled).toBe(true); + expect(hideEndCalled).toBe(true); + expect(showStartCalled).toBe(true); + expect(hideStartCalled).toBe(true); + + done(); + }); + + test("with display", (done) => { const view = setupView(`
- `) - const modal = simulateVisibility(document.querySelector("#modal")) - const click = document.querySelector("#click") - let showEndCalled = false - let hideEndCalled = false - let showStartCalled = false - let hideStartCalled = false - modal.addEventListener("phx:show-end", () => showEndCalled = true) - modal.addEventListener("phx:hide-end", () => hideEndCalled = true) - modal.addEventListener("phx:show-start", () => showStartCalled = true) - modal.addEventListener("phx:hide-start", () => hideStartCalled = true) - - expect(modal.style.display).toEqual("") - JS.exec(event, "click", click.getAttribute("phx-click"), view, click) - jest.runAllTimers() - - expect(modal.style.display).toEqual("none") - - JS.exec(event, "click", click.getAttribute("phx-click"), view, click) - jest.runAllTimers() - - expect(modal.style.display).toEqual("inline-block") - expect(showEndCalled).toBe(true) - expect(hideEndCalled).toBe(true) - expect(showStartCalled).toBe(true) - expect(hideStartCalled).toBe(true) - done() - }) + `); + const modal = simulateVisibility(document.querySelector("#modal")); + const click = document.querySelector("#click"); + let showEndCalled = false; + let hideEndCalled = false; + let showStartCalled = false; + let hideStartCalled = false; + modal.addEventListener("phx:show-end", () => (showEndCalled = true)); + modal.addEventListener("phx:hide-end", () => (hideEndCalled = true)); + modal.addEventListener("phx:show-start", () => (showStartCalled = true)); + modal.addEventListener("phx:hide-start", () => (hideStartCalled = true)); + + expect(modal.style.display).toEqual(""); + JS.exec(event, "click", click.getAttribute("phx-click"), view, click); + jest.runAllTimers(); + + expect(modal.style.display).toEqual("none"); + + JS.exec(event, "click", click.getAttribute("phx-click"), view, click); + jest.runAllTimers(); + + expect(modal.style.display).toEqual("inline-block"); + expect(showEndCalled).toBe(true); + expect(hideEndCalled).toBe(true); + expect(showStartCalled).toBe(true); + expect(hideStartCalled).toBe(true); + done(); + }); test("with in and out classes", async () => { const view = setupView(`
- `) - const modal = simulateVisibility(document.querySelector("#modal")) - const click = document.querySelector("#click") - let showEndCalled = false - let hideEndCalled = false - let showStartCalled = false - let hideStartCalled = false - modal.addEventListener("phx:show-end", () => showEndCalled = true) - modal.addEventListener("phx:hide-end", () => hideEndCalled = true) - modal.addEventListener("phx:show-start", () => showStartCalled = true) - modal.addEventListener("phx:hide-start", () => hideStartCalled = true) - - expect(modal.style.display).toEqual("") - expect(modal.classList.contains("fade-out")).toBe(false) - expect(modal.classList.contains("fade-in")).toBe(false) + `); + const modal = simulateVisibility(document.querySelector("#modal")); + const click = document.querySelector("#click"); + let showEndCalled = false; + let hideEndCalled = false; + let showStartCalled = false; + let hideStartCalled = false; + modal.addEventListener("phx:show-end", () => (showEndCalled = true)); + modal.addEventListener("phx:hide-end", () => (hideEndCalled = true)); + modal.addEventListener("phx:show-start", () => (showStartCalled = true)); + modal.addEventListener("phx:hide-start", () => (hideStartCalled = true)); + + expect(modal.style.display).toEqual(""); + expect(modal.classList.contains("fade-out")).toBe(false); + expect(modal.classList.contains("fade-in")).toBe(false); // toggle out - JS.exec(event, "click", click.getAttribute("phx-click"), view, click) - expect(hideStartCalled).toBe(true) + JS.exec(event, "click", click.getAttribute("phx-click"), view, click); + expect(hideStartCalled).toBe(true); // first tick: waiting for start classes to be set - advanceTimersToNextFrame() - expect(modal.classList.contains("fade-out-start")).toBe(true) - expect(modal.classList.contains("fade-out")).toBe(false) + advanceTimersToNextFrame(); + expect(modal.classList.contains("fade-out-start")).toBe(true); + expect(modal.classList.contains("fade-out")).toBe(false); // second tick: waiting for out classes to be set - advanceTimersToNextFrame() - expect(modal.classList.contains("fade-out-start")).toBe(true) - expect(modal.classList.contains("fade-out")).toBe(true) + advanceTimersToNextFrame(); + expect(modal.classList.contains("fade-out-start")).toBe(true); + expect(modal.classList.contains("fade-out")).toBe(true); // third tick: waiting for outEndClasses - advanceTimersToNextFrame() - expect(modal.classList.contains("fade-out-start")).toBe(false) - expect(modal.classList.contains("fade-out")).toBe(true) - expect(modal.classList.contains("fade-out-end")).toBe(true) + advanceTimersToNextFrame(); + expect(modal.classList.contains("fade-out-start")).toBe(false); + expect(modal.classList.contains("fade-out")).toBe(true); + expect(modal.classList.contains("fade-out-end")).toBe(true); // wait for onEnd - jest.runAllTimers() - advanceTimersToNextFrame() + jest.runAllTimers(); + advanceTimersToNextFrame(); // fifth tick: display: none - advanceTimersToNextFrame() - expect(hideEndCalled).toBe(true) - expect(modal.style.display).toEqual("none") + advanceTimersToNextFrame(); + expect(hideEndCalled).toBe(true); + expect(modal.style.display).toEqual("none"); // sixth tick, removed end classes - advanceTimersToNextFrame() - expect(modal.classList.contains("fade-out-start")).toBe(false) - expect(modal.classList.contains("fade-out")).toBe(false) - expect(modal.classList.contains("fade-out-end")).toBe(false) + advanceTimersToNextFrame(); + expect(modal.classList.contains("fade-out-start")).toBe(false); + expect(modal.classList.contains("fade-out")).toBe(false); + expect(modal.classList.contains("fade-out-end")).toBe(false); // toggle in - JS.exec(event, "click", click.getAttribute("phx-click"), view, click) - expect(showStartCalled).toBe(true) + JS.exec(event, "click", click.getAttribute("phx-click"), view, click); + expect(showStartCalled).toBe(true); // first tick: waiting for start classes to be set - advanceTimersToNextFrame() - expect(modal.classList.contains("fade-in-start")).toBe(true) - expect(modal.classList.contains("fade-in")).toBe(false) - expect(modal.style.display).toEqual("none") + advanceTimersToNextFrame(); + expect(modal.classList.contains("fade-in-start")).toBe(true); + expect(modal.classList.contains("fade-in")).toBe(false); + expect(modal.style.display).toEqual("none"); // second tick: waiting for in classes to be set - advanceTimersToNextFrame() - expect(modal.classList.contains("fade-in-start")).toBe(true) - expect(modal.classList.contains("fade-in")).toBe(true) - expect(modal.classList.contains("fade-in-end")).toBe(false) - expect(modal.style.display).toEqual("block") + advanceTimersToNextFrame(); + expect(modal.classList.contains("fade-in-start")).toBe(true); + expect(modal.classList.contains("fade-in")).toBe(true); + expect(modal.classList.contains("fade-in-end")).toBe(false); + expect(modal.style.display).toEqual("block"); // third tick: waiting for inEndClasses - advanceTimersToNextFrame() - expect(modal.classList.contains("fade-in-start")).toBe(false) - expect(modal.classList.contains("fade-in")).toBe(true) - expect(modal.classList.contains("fade-in-end")).toBe(true) + advanceTimersToNextFrame(); + expect(modal.classList.contains("fade-in-start")).toBe(false); + expect(modal.classList.contains("fade-in")).toBe(true); + expect(modal.classList.contains("fade-in-end")).toBe(true); // wait for onEnd - jest.runAllTimers() - advanceTimersToNextFrame() - expect(showEndCalled).toBe(true) + jest.runAllTimers(); + advanceTimersToNextFrame(); + expect(showEndCalled).toBe(true); // sixth tick, removed end classes - expect(modal.classList.contains("fade-in-start")).toBe(false) - expect(modal.classList.contains("fade-in")).toBe(false) - expect(modal.classList.contains("fade-in-end")).toBe(false) - }) - }) + expect(modal.classList.contains("fade-in-start")).toBe(false); + expect(modal.classList.contains("fade-in")).toBe(false); + expect(modal.classList.contains("fade-in-end")).toBe(false); + }); + }); describe("exec_transition", () => { - test("with defaults", done => { + test("with defaults", (done) => { const view = setupView(`
- `) - const modal = simulateVisibility(document.querySelector("#modal")) - const click = document.querySelector("#click")! + `); + const modal = simulateVisibility(document.querySelector("#modal")); + const click = document.querySelector("#click")!; - expect(Array.from(modal.classList)).toEqual(["modal"]) + expect(Array.from(modal.classList)).toEqual(["modal"]); - JS.exec(event, "click", click.getAttribute("phx-click"), view, click) - jest.advanceTimersByTime(100) + JS.exec(event, "click", click.getAttribute("phx-click"), view, click); + jest.advanceTimersByTime(100); - expect(Array.from(modal.classList)).toEqual(["modal", "fade-out"]) - jest.runAllTimers() + expect(Array.from(modal.classList)).toEqual(["modal", "fade-out"]); + jest.runAllTimers(); - expect(Array.from(modal.classList)).toEqual(["modal"]) - done() - }) + expect(Array.from(modal.classList)).toEqual(["modal"]); + done(); + }); - test("with multiple selector", done => { + test("with multiple selector", (done) => { const view = setupView(`
- `) - const modal1 = document.querySelector("#modal1")! - const modal2 = document.querySelector("#modal2")! - const click = document.querySelector("#click")! + `); + const modal1 = document.querySelector("#modal1")!; + const modal2 = document.querySelector("#modal2")!; + const click = document.querySelector("#click")!; - expect(Array.from(modal1.classList)).toEqual(["modal"]) - expect(Array.from(modal2.classList)).toEqual(["modal"]) + expect(Array.from(modal1.classList)).toEqual(["modal"]); + expect(Array.from(modal2.classList)).toEqual(["modal"]); - JS.exec(event, "click", click.getAttribute("phx-click"), view, click) - jest.advanceTimersByTime(100) + JS.exec(event, "click", click.getAttribute("phx-click"), view, click); + jest.advanceTimersByTime(100); - expect(Array.from(modal1.classList)).toEqual(["modal", "fade-out"]) - expect(Array.from(modal2.classList)).toEqual(["modal", "fade-out"]) + expect(Array.from(modal1.classList)).toEqual(["modal", "fade-out"]); + expect(Array.from(modal2.classList)).toEqual(["modal", "fade-out"]); - jest.runAllTimers() + jest.runAllTimers(); - expect(Array.from(modal1.classList)).toEqual(["modal"]) - expect(Array.from(modal2.classList)).toEqual(["modal"]) + expect(Array.from(modal1.classList)).toEqual(["modal"]); + expect(Array.from(modal2.classList)).toEqual(["modal"]); - done() - }) - }) + done(); + }); + }); describe("exec_dispatch", () => { - test("with defaults", done => { + test("with defaults", (done) => { const view = setupView(`
- `) - const modal = simulateVisibility(document.querySelector("#modal")) - const click = document.querySelector("#click")! + `); + const modal = simulateVisibility(document.querySelector("#modal")); + const click = document.querySelector("#click")!; modal.addEventListener("click", () => { - done() - }) - JS.exec(event, "click", click.getAttribute("phx-click"), view, click) - }) + done(); + }); + JS.exec(event, "click", click.getAttribute("phx-click"), view, click); + }); - test("with to scope inner", done => { + test("with to scope inner", (done) => { const view = setupView(`
- `) - const modal = simulateVisibility(document.querySelector(".modal")) - const click = document.querySelector("#click")! + `); + const modal = simulateVisibility(document.querySelector(".modal")); + const click = document.querySelector("#click")!; - modal.addEventListener("click", () => done()) - JS.exec(event, "click", click.getAttribute("phx-click"), view, click) - }) + modal.addEventListener("click", () => done()); + JS.exec(event, "click", click.getAttribute("phx-click"), view, click); + }); - test("with to scope closest", done => { + test("with to scope closest", (done) => { const view = setupView(` - `) - const modal = simulateVisibility(document.querySelector(".modal")) - const click = document.querySelector("#click")! - - modal.addEventListener("click", () => done()) - JS.exec(event, "click", click.getAttribute("phx-click"), view, click) - }) - test("with details", done => { + `); + const modal = simulateVisibility(document.querySelector(".modal")); + const click = document.querySelector("#click")!; + + modal.addEventListener("click", () => done()); + JS.exec(event, "click", click.getAttribute("phx-click"), view, click); + }); + test("with details", (done) => { const view = setupView(`
- `) - const modal = simulateVisibility(document.querySelector("#modal")) - const click = document.querySelector("#click")! - const close = document.querySelector("#close")! - - modal.addEventListener("close", e => { - expect(e.detail).toEqual({id: 1, dispatcher: close}) - modal.addEventListener("click", e => { - expect(e.detail).toEqual(0) - done() - }) - JS.exec(event, "click", click.getAttribute("phx-click"), view, click) - }) - JS.exec(event, "close", close.getAttribute("phx-click"), view, close) - }) - - test("with multiple selector", done => { + `); + const modal = simulateVisibility(document.querySelector("#modal")); + const click = document.querySelector("#click")!; + const close = document.querySelector("#close")!; + + modal.addEventListener("close", (e) => { + expect(e.detail).toEqual({ id: 1, dispatcher: close }); + modal.addEventListener("click", (e) => { + expect(e.detail).toEqual(0); + done(); + }); + JS.exec(event, "click", click.getAttribute("phx-click"), view, click); + }); + JS.exec(event, "close", close.getAttribute("phx-click"), view, close); + }); + + test("with multiple selector", (done) => { const view = setupView(`
- `) - let modal1Clicked = false - const modal1 = document.querySelector("#modal1") - const modal2 = document.querySelector("#modal2")! - const close = document.querySelector("#close")! + `); + let modal1Clicked = false; + const modal1 = document.querySelector("#modal1"); + const modal2 = document.querySelector("#modal2")!; + const close = document.querySelector("#close")!; modal1.addEventListener("close", (e: CustomEventInit) => { - modal1Clicked = true - expect(e.detail).toEqual({id: 123, dispatcher: close}) - }) + modal1Clicked = true; + expect(e.detail).toEqual({ id: 123, dispatcher: close }); + }); modal2.addEventListener("close", (e: CustomEventInit) => { - expect(modal1Clicked).toBe(true) - expect(e.detail).toEqual({id: 123, dispatcher: close}) - done() - }) + expect(modal1Clicked).toBe(true); + expect(e.detail).toEqual({ id: 123, dispatcher: close }); + done(); + }); - JS.exec(event, "close", close.getAttribute("phx-click"), view, close) - }) - }) + JS.exec(event, "close", close.getAttribute("phx-click"), view, close); + }); + }); describe("exec_add_class and exec_remove_class", () => { - test("with defaults", done => { + test("with defaults", (done) => { const view = setupView(`
- `) - const modal = simulateVisibility(document.querySelector("#modal")) - const add = document.querySelector("#add")! - const remove = document.querySelector("#remove")! + `); + const modal = simulateVisibility(document.querySelector("#modal")); + const add = document.querySelector("#add")!; + const remove = document.querySelector("#remove")!; - JS.exec(event, "click", add.getAttribute("phx-click"), view, add) - JS.exec(event, "click", add.getAttribute("phx-click"), view, add) - JS.exec(event, "click", add.getAttribute("phx-click"), view, add) - jest.runAllTimers() + JS.exec(event, "click", add.getAttribute("phx-click"), view, add); + JS.exec(event, "click", add.getAttribute("phx-click"), view, add); + JS.exec(event, "click", add.getAttribute("phx-click"), view, add); + jest.runAllTimers(); - expect(Array.from(modal.classList)).toEqual(["modal", "class1"]) + expect(Array.from(modal.classList)).toEqual(["modal", "class1"]); - JS.exec(event, "click", remove.getAttribute("phx-click"), view, remove) - jest.runAllTimers() + JS.exec(event, "click", remove.getAttribute("phx-click"), view, remove); + jest.runAllTimers(); - expect(Array.from(modal.classList)).toEqual(["modal"]) - done() - }) + expect(Array.from(modal.classList)).toEqual(["modal"]); + done(); + }); - test("with multiple selector", done => { + test("with multiple selector", (done) => { const view = setupView(`
- `) - const modal1 = document.querySelector("#modal1")! - const modal2 = document.querySelector("#modal2")! - const add = document.querySelector("#add")! - const remove = document.querySelector("#remove")! + `); + const modal1 = document.querySelector("#modal1")!; + const modal2 = document.querySelector("#modal2")!; + const add = document.querySelector("#add")!; + const remove = document.querySelector("#remove")!; - JS.exec(event, "click", add.getAttribute("phx-click"), view, add) - jest.runAllTimers() + JS.exec(event, "click", add.getAttribute("phx-click"), view, add); + jest.runAllTimers(); - expect(Array.from(modal1.classList)).toEqual(["modal", "class1"]) - expect(Array.from(modal2.classList)).toEqual(["modal", "class1"]) + expect(Array.from(modal1.classList)).toEqual(["modal", "class1"]); + expect(Array.from(modal2.classList)).toEqual(["modal", "class1"]); - JS.exec(event, "click", remove.getAttribute("phx-click"), view, remove) - jest.runAllTimers() + JS.exec(event, "click", remove.getAttribute("phx-click"), view, remove); + jest.runAllTimers(); - expect(Array.from(modal1.classList)).toEqual(["modal"]) - expect(Array.from(modal2.classList)).toEqual(["modal"]) - done() - }) - }) + expect(Array.from(modal1.classList)).toEqual(["modal"]); + expect(Array.from(modal2.classList)).toEqual(["modal"]); + done(); + }); + }); describe("exec_toggle_class", () => { - test("with defaults", done => { + test("with defaults", (done) => { const view = setupView(`
- `) - const modal = simulateVisibility(document.querySelector("#modal")) - const toggle = document.querySelector("#toggle")! + `); + const modal = simulateVisibility(document.querySelector("#modal")); + const toggle = document.querySelector("#toggle")!; - JS.exec(event, "click", toggle.getAttribute("phx-click"), view, toggle) - jest.runAllTimers() + JS.exec(event, "click", toggle.getAttribute("phx-click"), view, toggle); + jest.runAllTimers(); - expect(Array.from(modal.classList)).toEqual(["modal", "class1"]) + expect(Array.from(modal.classList)).toEqual(["modal", "class1"]); - JS.exec(event, "click", toggle.getAttribute("phx-click"), view, toggle) - jest.runAllTimers() + JS.exec(event, "click", toggle.getAttribute("phx-click"), view, toggle); + jest.runAllTimers(); - expect(Array.from(modal.classList)).toEqual(["modal"]) - done() - }) + expect(Array.from(modal.classList)).toEqual(["modal"]); + done(); + }); - test("with multiple selector", done => { + test("with multiple selector", (done) => { const view = setupView(`
- `) - const modal1 = document.querySelector("#modal1")! - const modal2 = document.querySelector("#modal2")! - const toggle = document.querySelector("#toggle")! + `); + const modal1 = document.querySelector("#modal1")!; + const modal2 = document.querySelector("#modal2")!; + const toggle = document.querySelector("#toggle")!; - JS.exec(event, "click", toggle.getAttribute("phx-click"), view, toggle) - jest.runAllTimers() + JS.exec(event, "click", toggle.getAttribute("phx-click"), view, toggle); + jest.runAllTimers(); - expect(Array.from(modal1.classList)).toEqual(["modal", "class1"]) - expect(Array.from(modal2.classList)).toEqual(["modal", "class1"]) - JS.exec(event, "click", toggle.getAttribute("phx-click"), view, toggle) - jest.runAllTimers() + expect(Array.from(modal1.classList)).toEqual(["modal", "class1"]); + expect(Array.from(modal2.classList)).toEqual(["modal", "class1"]); + JS.exec(event, "click", toggle.getAttribute("phx-click"), view, toggle); + jest.runAllTimers(); - expect(Array.from(modal1.classList)).toEqual(["modal"]) - expect(Array.from(modal2.classList)).toEqual(["modal"]) - done() - }) + expect(Array.from(modal1.classList)).toEqual(["modal"]); + expect(Array.from(modal2.classList)).toEqual(["modal"]); + done(); + }); - test("with transition", done => { + test("with transition", (done) => { const view = setupView(` - `) - const button = document.querySelector("button")! + `); + const button = document.querySelector("button")!; - expect(Array.from(button.classList)).toEqual([]) + expect(Array.from(button.classList)).toEqual([]); - JS.exec(event, "click", button.getAttribute("phx-click"), view, button) + JS.exec(event, "click", button.getAttribute("phx-click"), view, button); - jest.advanceTimersByTime(100) - expect(Array.from(button.classList)).toEqual(["a", "c"]) + jest.advanceTimersByTime(100); + expect(Array.from(button.classList)).toEqual(["a", "c"]); - jest.runAllTimers() - expect(Array.from(button.classList)).toEqual(["c", "t"]) + jest.runAllTimers(); + expect(Array.from(button.classList)).toEqual(["c", "t"]); - done() - }) - }) + done(); + }); + }); describe("push", () => { - test("regular event", done => { + test("regular event", (done) => { const view = setupView(`
- `) - const click = document.querySelector("#click")! + `); + const click = document.querySelector("#click")!; view.pushEvent = (eventType, sourceEl, targetCtx, event, meta) => { - expect(eventType).toBe("click") - expect(event).toBe("clicked") - expect(meta).toBeUndefined() - done() - } - JS.exec(event, "click", click.getAttribute("phx-click"), view, click) - }) - - test("form change event with JS command", done => { + expect(eventType).toBe("click"); + expect(event).toBe("clicked"); + expect(meta).toBeUndefined(); + done(); + }; + JS.exec(event, "click", click.getAttribute("phx-click"), view, click); + }); + + test("form change event with JS command", (done) => { const view = setupView(`
- `) - const form = document.querySelector("#my-form")! - const input: HTMLInputElement = document.querySelector("#username")! - view.pushInput = (sourceEl, _targetCtx, _newCid, phxEvent, {_target}, _callback) => { - expect(phxEvent).toBe("validate") - expect(sourceEl.isSameNode(input)).toBe(true) - expect(_target).toBe(input.name) - done() - } - const args = ["push", {_target: input.name, dispatcher: input}] - JS.exec(event, "change", form.getAttribute("phx-change"), view, input, args) - }) - - test("form change event with phx-value and JS command value", done => { + `); + const form = document.querySelector("#my-form")!; + const input: HTMLInputElement = document.querySelector("#username")!; + view.pushInput = ( + sourceEl, + _targetCtx, + _newCid, + phxEvent, + { _target }, + _callback, + ) => { + expect(phxEvent).toBe("validate"); + expect(sourceEl.isSameNode(input)).toBe(true); + expect(_target).toBe(input.name); + done(); + }; + const args = ["push", { _target: input.name, dispatcher: input }]; + JS.exec( + event, + "change", + form.getAttribute("phx-change"), + view, + input, + args, + ); + }); + + test("form change event with phx-value and JS command value", (done) => { const view = setupView(`
{ >
- `) - const form = document.querySelector("#my-form")! - const input: HTMLInputElement = document.querySelector("#username")! + `); + const form = document.querySelector("#my-form")!; + const input: HTMLInputElement = document.querySelector("#username")!; view.pushWithReply = (refGen, event, payload) => { expect(payload).toEqual({ - "cid": null, - "event": "validate", - "type": "form", - "value": "_unused_username=&username=", - "meta": { - "_target": "username", - "command_value": "command", - "nested": { - "array": [1, 2] + cid: null, + event: "validate", + type: "form", + value: "_unused_username=&username=", + meta: { + _target: "username", + command_value: "command", + nested: { + array: [1, 2], }, - "attribute_value": "attribute" + attribute_value: "attribute", }, - "uploads": {} - }) - return Promise.resolve({resp: done()}) - } - const args = ["push", {_target: input.name, dispatcher: input}] - JS.exec(event, "change", form.getAttribute("phx-change"), view, input, args) - }) + uploads: {}, + }); + return Promise.resolve({ resp: done() }); + }; + const args = ["push", { _target: input.name, dispatcher: input }]; + JS.exec( + event, + "change", + form.getAttribute("phx-change"), + view, + input, + args, + ); + }); test("form change event prefers JS.push value over phx-value-* over input value", (done) => { const view = setupView(`
- `) - const form: HTMLFormElement = document.querySelector("#my-form")! - const input: HTMLInputElement = document.querySelector("#textField")! + `); + const form: HTMLFormElement = document.querySelector("#my-form")!; + const input: HTMLInputElement = document.querySelector("#textField")!; view.pushWithReply = (refGen, event, payload) => { expect(payload).toEqual({ - "cid": null, - "event": "change", - "type": "form", - "value": "_unused_name=&name=input+value", - "meta": { - "_target": "name", - "name": "value from push opts" + cid: null, + event: "change", + type: "form", + value: "_unused_name=&name=input+value", + meta: { + _target: "name", + name: "value from push opts", }, - "uploads": {} - }) - return Promise.resolve({resp: done()}) - } - const args = ["push", {_target: input.name, dispatcher: input}] - JS.exec(event, "change", form.getAttribute("phx-change"), view, input, args) - }) - + uploads: {}, + }); + return Promise.resolve({ resp: done() }); + }; + const args = ["push", { _target: input.name, dispatcher: input }]; + JS.exec( + event, + "change", + form.getAttribute("phx-change"), + view, + input, + args, + ); + }); + test("form change event prefers phx-value-* over input value", (done) => { const view = setupView(`
- `) - const form: HTMLFormElement = document.querySelector("#my-form")! - const input: HTMLInputElement = document.querySelector("#textField")! + `); + const form: HTMLFormElement = document.querySelector("#my-form")!; + const input: HTMLInputElement = document.querySelector("#textField")!; view.pushWithReply = (refGen, event, payload) => { expect(payload).toEqual({ - "cid": null, - "event": "change", - "type": "form", - "value": "_unused_name=&name=input+value", - "meta": { - "_target": "name", - "name": "value from phx-value param" + cid: null, + event: "change", + type: "form", + value: "_unused_name=&name=input+value", + meta: { + _target: "name", + name: "value from phx-value param", }, - "uploads": {} - }) - return Promise.resolve({resp: done()}) - } - const args = ["push", {_target: input.name, dispatcher: input}] - JS.exec(event, "change", form.getAttribute("phx-change"), view, input, args) - }) - - test("form change event with string event", done => { + uploads: {}, + }); + return Promise.resolve({ resp: done() }); + }; + const args = ["push", { _target: input.name, dispatcher: input }]; + JS.exec( + event, + "change", + form.getAttribute("phx-change"), + view, + input, + args, + ); + }); + + test("form change event with string event", (done) => { const view = setupView(`
- `) - const form: HTMLFormElement = document.querySelector("#my-form")! - const input: HTMLInputElement = document.querySelector("#username")! - const oldPush = view.pushInput.bind(view) - view.pushInput = (sourceEl, targetCtx, newCid, phxEvent, opts, callback) => { - const {_target} = opts - expect(phxEvent).toBe("validate") - expect(sourceEl.isSameNode(input)).toBe(true) - expect(_target).toBe(input.name) - oldPush(sourceEl, targetCtx, newCid, phxEvent, opts, callback) - } + `); + const form: HTMLFormElement = document.querySelector("#my-form")!; + const input: HTMLInputElement = document.querySelector("#username")!; + const oldPush = view.pushInput.bind(view); + view.pushInput = ( + sourceEl, + targetCtx, + newCid, + phxEvent, + opts, + callback, + ) => { + const { _target } = opts; + expect(phxEvent).toBe("validate"); + expect(sourceEl.isSameNode(input)).toBe(true); + expect(_target).toBe(input.name); + oldPush(sourceEl, targetCtx, newCid, phxEvent, opts, callback); + }; view.pushWithReply = (_refGen, _event, payload) => { expect(payload).toEqual({ cid: null, @@ -747,31 +799,45 @@ describe("JS", () => { type: "form", uploads: {}, value: "_unused_username=&username=&_unused_other=&other=", - meta: {"_target": "username"} - }) - return Promise.resolve({resp: done()}) - } - const args = ["push", {_target: input.name, dispatcher: input}] - JS.exec(event, "change", form.getAttribute("phx-change"), view, input, args) - }) - - test("input change event with JS command", done => { + meta: { _target: "username" }, + }); + return Promise.resolve({ resp: done() }); + }; + const args = ["push", { _target: input.name, dispatcher: input }]; + JS.exec( + event, + "change", + form.getAttribute("phx-change"), + view, + input, + args, + ); + }); + + test("input change event with JS command", (done) => { const view = setupView(`
- `) - const input: HTMLInputElement = document.querySelector("#username1")! - const oldPush = view.pushInput.bind(view) - view.pushInput = (sourceEl, targetCtx, newCid, phxEvent, opts, callback) => { - const {_target} = opts - expect(phxEvent).toBe("username_changed") - expect(sourceEl.isSameNode(input)).toBe(true) - expect(_target).toBe(input.name) - oldPush(sourceEl, targetCtx, newCid, phxEvent, opts, callback) - } + `); + const input: HTMLInputElement = document.querySelector("#username1")!; + const oldPush = view.pushInput.bind(view); + view.pushInput = ( + sourceEl, + targetCtx, + newCid, + phxEvent, + opts, + callback, + ) => { + const { _target } = opts; + expect(phxEvent).toBe("username_changed"); + expect(sourceEl.isSameNode(input)).toBe(true); + expect(_target).toBe(input.name); + oldPush(sourceEl, targetCtx, newCid, phxEvent, opts, callback); + }; view.pushWithReply = (_refGen, _event, payload) => { expect(payload).toEqual({ cid: null, @@ -779,31 +845,45 @@ describe("JS", () => { type: "form", uploads: {}, value: "_unused_username=&username=", - meta: {"_target": "username"} - }) - return Promise.resolve({resp: done()}) - } - const args = ["push", {_target: input.name, dispatcher: input}] - JS.exec(event, "change", input.getAttribute("phx-change"), view, input, args) - }) - - test("input change event with string event", done => { + meta: { _target: "username" }, + }); + return Promise.resolve({ resp: done() }); + }; + const args = ["push", { _target: input.name, dispatcher: input }]; + JS.exec( + event, + "change", + input.getAttribute("phx-change"), + view, + input, + args, + ); + }); + + test("input change event with string event", (done) => { const view = setupView(`
- `) - const input: HTMLInputElement = document.querySelector("#username")! - const oldPush = view.pushInput.bind(view) - view.pushInput = (sourceEl, targetCtx, newCid, phxEvent, opts, callback) => { - const {_target} = opts - expect(phxEvent).toBe("username_changed") - expect(sourceEl.isSameNode(input)).toBe(true) - expect(_target).toBe(input.name) - oldPush(sourceEl, targetCtx, newCid, phxEvent, opts, callback) - } + `); + const input: HTMLInputElement = document.querySelector("#username")!; + const oldPush = view.pushInput.bind(view); + view.pushInput = ( + sourceEl, + targetCtx, + newCid, + phxEvent, + opts, + callback, + ) => { + const { _target } = opts; + expect(phxEvent).toBe("username_changed"); + expect(sourceEl.isSameNode(input)).toBe(true); + expect(_target).toBe(input.name); + oldPush(sourceEl, targetCtx, newCid, phxEvent, opts, callback); + }; view.pushWithReply = (refGen, event, payload) => { expect(payload).toEqual({ cid: null, @@ -811,38 +891,48 @@ describe("JS", () => { type: "form", uploads: {}, value: "_unused_username=&username=", - meta: {"_target": "username"} - }) - return Promise.resolve({resp: done()}) - } - const args = ["push", {_target: input.name, dispatcher: input}] - JS.exec(event, "change", input.getAttribute("phx-change"), view, input, args) - }) - - test("submit event", done => { + meta: { _target: "username" }, + }); + return Promise.resolve({ resp: done() }); + }; + const args = ["push", { _target: input.name, dispatcher: input }]; + JS.exec( + event, + "change", + input.getAttribute("phx-change"), + view, + input, + args, + ); + }); + + test("submit event", (done) => { const view = setupView(`
- `) - const form: HTMLFormElement = document.querySelector("#my-form")! + `); + const form: HTMLFormElement = document.querySelector("#my-form")!; view.pushWithReply = (refGen, event, payload) => { expect(payload).toEqual({ - "cid": null, - "event": "save", - "type": "form", - "value": "username=&desc=", - "meta": {} - }) - return Promise.resolve({resp: done()}) - } - JS.exec(event, "submit", form.getAttribute("phx-submit"), view, form, ["push", {}]) - }) - - test("submit event with phx-value and JS command value", done => { + cid: null, + event: "save", + type: "form", + value: "username=&desc=", + meta: {}, + }); + return Promise.resolve({ resp: done() }); + }; + JS.exec(event, "submit", form.getAttribute("phx-submit"), view, form, [ + "push", + {}, + ]); + }); + + test("submit event with phx-value and JS command value", (done) => { const view = setupView(`
{
- `) - const form: HTMLFormElement = document.querySelector("#my-form")! + `); + const form: HTMLFormElement = document.querySelector("#my-form")!; view.pushWithReply = (refGen, event, payload) => { expect(payload).toEqual({ - "cid": null, - "event": "save", - "type": "form", - "value": "username=&desc=", - "meta": { - "command_value": "command", - "nested": { - "array": [1, 2] + cid: null, + event: "save", + type: "form", + value: "username=&desc=", + meta: { + command_value: "command", + nested: { + array: [1, 2], }, - "attribute_value": "attribute" - } - }) - return Promise.resolve({resp: done()}) - } - JS.exec(event, "submit", form.getAttribute("phx-submit"), view, form, ["push", {}]) - }) - - test("page_loading", done => { + attribute_value: "attribute", + }, + }); + return Promise.resolve({ resp: done() }); + }; + JS.exec(event, "submit", form.getAttribute("phx-submit"), view, form, [ + "push", + {}, + ]); + }); + + test("page_loading", (done) => { const view = setupView(`
- `) - const click = document.querySelector("#click")! + `); + const click = document.querySelector("#click")!; view.pushEvent = (eventType, sourceEl, targetCtx, event, meta, opts) => { - expect(opts).toEqual({page_loading: true}) - done() - } - JS.exec(event, "click", click.getAttribute("phx-click"), view, click) - }) + expect(opts).toEqual({ page_loading: true }); + done(); + }; + JS.exec(event, "click", click.getAttribute("phx-click"), view, click); + }); test("loading", () => { const view = setupView(`
- `) - const click = document.querySelector("#click")! - const modal = document.getElementById("modal")! - JS.exec(event, "click", click.getAttribute("phx-click"), view, click) - expect(Array.from(modal.classList)).toEqual(["modal", "phx-click-loading"]) - expect(Array.from(click.classList)).toEqual(["phx-click-loading"]) - }) - - test("value", done => { + `); + const click = document.querySelector("#click")!; + const modal = document.getElementById("modal")!; + JS.exec(event, "click", click.getAttribute("phx-click"), view, click); + expect(Array.from(modal.classList)).toEqual([ + "modal", + "phx-click-loading", + ]); + expect(Array.from(click.classList)).toEqual(["phx-click-loading"]); + }); + + test("value", (done) => { const view = setupView(`
- `) - const click = document.querySelector("#click")! + `); + const click = document.querySelector("#click")!; view.pushWithReply = (refGenerator, event, payload) => { - expect(payload.value).toEqual({"one": 1, "two": 2, "three": "3"}) - return Promise.resolve({resp: done()}) - } - JS.exec(event, "click", click.getAttribute("phx-click"), view, click) - }) - }) + expect(payload.value).toEqual({ one: 1, two: 2, three: "3" }); + return Promise.resolve({ resp: done() }); + }; + JS.exec(event, "click", click.getAttribute("phx-click"), view, click); + }); + }); describe("multiple instructions", () => { - test("push and toggle", done => { + test("push and toggle", (done) => { const view = setupView(`
- `) - const modal = simulateVisibility(document.querySelector("#modal")) - const click = document.querySelector("#click")! + `); + const modal = simulateVisibility(document.querySelector("#modal")); + const click = document.querySelector("#click")!; view.pushEvent = (_eventType, _sourceEl, _targetCtx, event, _data) => { - expect(event).toEqual("clicked") - done() - } + expect(event).toEqual("clicked"); + done(); + }; - expect(modal.style.display).toEqual("") - JS.exec(event, "click", click.getAttribute("phx-click"), view, click) - jest.runAllTimers() + expect(modal.style.display).toEqual(""); + JS.exec(event, "click", click.getAttribute("phx-click"), view, click); + jest.runAllTimers(); - expect(modal.style.display).toEqual("none") - }) - }) + expect(modal.style.display).toEqual("none"); + }); + }); describe("exec_set_attr and exec_remove_attr", () => { test("with defaults", () => { @@ -943,81 +1039,87 @@ describe("JS", () => {
- `) - const modal = document.querySelector("#modal")! - const set = document.querySelector("#set")! - const remove = document.querySelector("#remove")! + `); + const modal = document.querySelector("#modal")!; + const set = document.querySelector("#set")!; + const remove = document.querySelector("#remove")!; - expect(modal.getAttribute("aria-expanded")).toEqual(null) - JS.exec(event, "click", set.getAttribute("phx-click"), view, set) - expect(modal.getAttribute("aria-expanded")).toEqual("true") + expect(modal.getAttribute("aria-expanded")).toEqual(null); + JS.exec(event, "click", set.getAttribute("phx-click"), view, set); + expect(modal.getAttribute("aria-expanded")).toEqual("true"); - JS.exec(event, "click", remove.getAttribute("phx-click"), view, remove) - expect(modal.getAttribute("aria-expanded")).toEqual(null) - }) + JS.exec(event, "click", remove.getAttribute("phx-click"), view, remove); + expect(modal.getAttribute("aria-expanded")).toEqual(null); + }); test("with no selector", () => { const view = setupView(`
- `) - const set = document.querySelector("#set")! - const remove = document.querySelector("#remove")! + `); + const set = document.querySelector("#set")!; + const remove = document.querySelector("#remove")!; - expect(set.getAttribute("aria-expanded")).toEqual(null) - JS.exec(event, "click", set.getAttribute("phx-click"), view, set) - expect(set.getAttribute("aria-expanded")).toEqual("true") + expect(set.getAttribute("aria-expanded")).toEqual(null); + JS.exec(event, "click", set.getAttribute("phx-click"), view, set); + expect(set.getAttribute("aria-expanded")).toEqual("true"); - expect(remove.getAttribute("class")).toEqual("here") - JS.exec(event, "click", remove.getAttribute("phx-click"), view, remove) - expect(remove.getAttribute("class")).toEqual(null) - }) + expect(remove.getAttribute("class")).toEqual("here"); + JS.exec(event, "click", remove.getAttribute("phx-click"), view, remove); + expect(remove.getAttribute("class")).toEqual(null); + }); test("setting a pre-existing attribute updates its value", () => { const view = setupView(`
- `) - const set = document.querySelector("#set")! - const modal = document.querySelector("#modal")! + `); + const set = document.querySelector("#set")!; + const modal = document.querySelector("#modal")!; - expect(modal.getAttribute("aria-expanded")).toEqual("false") - JS.exec(event, "click", set.getAttribute("phx-click"), view, set) - expect(modal.getAttribute("aria-expanded")).toEqual("true") - }) + expect(modal.getAttribute("aria-expanded")).toEqual("false"); + JS.exec(event, "click", set.getAttribute("phx-click"), view, set); + expect(modal.getAttribute("aria-expanded")).toEqual("true"); + }); test("setting a dynamically added attribute updates its value", () => { const view = setupView(`
- `) - const setFalse = document.querySelector("#set-false")! - const setTrue = document.querySelector("#set-true")! - const modal = document.querySelector("#modal")! - - expect(modal.getAttribute("aria-expanded")).toEqual(null) - JS.exec(event, "click", setFalse.getAttribute("phx-click"), view, setFalse) - expect(modal.getAttribute("aria-expanded")).toEqual("false") - JS.exec(event, "click", setTrue.getAttribute("phx-click"), view, setTrue) - expect(modal.getAttribute("aria-expanded")).toEqual("true") - }) - }) + `); + const setFalse = document.querySelector("#set-false")!; + const setTrue = document.querySelector("#set-true")!; + const modal = document.querySelector("#modal")!; + + expect(modal.getAttribute("aria-expanded")).toEqual(null); + JS.exec( + event, + "click", + setFalse.getAttribute("phx-click"), + view, + setFalse, + ); + expect(modal.getAttribute("aria-expanded")).toEqual("false"); + JS.exec(event, "click", setTrue.getAttribute("phx-click"), view, setTrue); + expect(modal.getAttribute("aria-expanded")).toEqual("true"); + }); + }); describe("exec", () => { - test("executes command", done => { + test("executes command", (done) => { const view = setupView(`
- `) - const click = document.querySelector("#click")! + `); + const click = document.querySelector("#click")!; view.pushEvent = (eventType, sourceEl, targetCtx, event, _meta) => { - expect(eventType).toBe("exec") - expect(event).toBe("clicked") - done() - } - JS.exec(event, "exec", click.getAttribute("phx-click"), view, click) - }) + expect(eventType).toBe("exec"); + expect(event).toBe("clicked"); + done(); + }; + JS.exec(event, "exec", click.getAttribute("phx-click"), view, click); + }); test("with no selector", () => { const view = setupView(` @@ -1026,147 +1128,147 @@ describe("JS", () => { phx-click='[["exec", {"attr": "data-toggle"}]]'' data-toggle='[["toggle_attr", {"attr": ["open", "true"]}]]' > - `) - const click = document.querySelector("#click")! + `); + const click = document.querySelector("#click")!; - expect(click.getAttribute("open")).toEqual(null) - JS.exec(event, "click", click.getAttribute("phx-click"), view, click) - expect(click.getAttribute("open")).toEqual("true") - }) + expect(click.getAttribute("open")).toEqual(null); + JS.exec(event, "click", click.getAttribute("phx-click"), view, click); + expect(click.getAttribute("open")).toEqual("true"); + }); test("with to scope inner", () => { const view = setupView(`
- `) - const modal = document.querySelector("#modal")! - const click = document.querySelector("#click")! + `); + const modal = document.querySelector("#modal")!; + const click = document.querySelector("#click")!; - expect(modal.getAttribute("open")).toEqual(null) - JS.exec(event, "click", click.getAttribute("phx-click"), view, click) - expect(modal.getAttribute("open")).toEqual("true") - }) + expect(modal.getAttribute("open")).toEqual(null); + JS.exec(event, "click", click.getAttribute("phx-click"), view, click); + expect(modal.getAttribute("open")).toEqual("true"); + }); test("with to scope closest", () => { const view = setupView(` - `) - const modal = document.querySelector("#modal")! - const click = document.querySelector("#click")! + `); + const modal = document.querySelector("#modal")!; + const click = document.querySelector("#click")!; - expect(modal.getAttribute("open")).toEqual(null) - JS.exec(event, "click", click.getAttribute("phx-click"), view, click) - expect(modal.getAttribute("open")).toEqual("true") - }) + expect(modal.getAttribute("open")).toEqual(null); + JS.exec(event, "click", click.getAttribute("phx-click"), view, click); + expect(modal.getAttribute("open")).toEqual("true"); + }); test("with multiple selector", () => { const view = setupView(`
modal
modal
- `) - const modal1 = document.querySelector("#modal1")! - const modal2 = document.querySelector("#modal2")! - const click = document.querySelector("#click")! - - expect(modal1.getAttribute("open")).toEqual(null) - expect(modal2.getAttribute("open")).toEqual("true") - JS.exec(event, "click", click.getAttribute("phx-click"), view, click) - expect(modal1.getAttribute("open")).toEqual("true") - expect(modal2.getAttribute("open")).toEqual(null) - }) - }) + `); + const modal1 = document.querySelector("#modal1")!; + const modal2 = document.querySelector("#modal2")!; + const click = document.querySelector("#click")!; + + expect(modal1.getAttribute("open")).toEqual(null); + expect(modal2.getAttribute("open")).toEqual("true"); + JS.exec(event, "click", click.getAttribute("phx-click"), view, click); + expect(modal1.getAttribute("open")).toEqual("true"); + expect(modal2.getAttribute("open")).toEqual(null); + }); + }); describe("exec_toggle_attr", () => { test("with defaults", () => { const view = setupView(`
- `) - const modal = document.querySelector("#modal")! - const toggle = document.querySelector("#toggle")! + `); + const modal = document.querySelector("#modal")!; + const toggle = document.querySelector("#toggle")!; - expect(modal.getAttribute("open")).toEqual(null) - JS.exec(event, "click", toggle.getAttribute("phx-click"), view, toggle) - expect(modal.getAttribute("open")).toEqual("true") + expect(modal.getAttribute("open")).toEqual(null); + JS.exec(event, "click", toggle.getAttribute("phx-click"), view, toggle); + expect(modal.getAttribute("open")).toEqual("true"); - JS.exec(event, "click", toggle.getAttribute("phx-click"), view, toggle) - expect(modal.getAttribute("open")).toEqual(null) - }) + JS.exec(event, "click", toggle.getAttribute("phx-click"), view, toggle); + expect(modal.getAttribute("open")).toEqual(null); + }); test("with no selector", () => { const view = setupView(`
- `) - const toggle = document.querySelector("#toggle")! + `); + const toggle = document.querySelector("#toggle")!; - expect(toggle.getAttribute("open")).toEqual(null) - JS.exec(event, "click", toggle.getAttribute("phx-click"), view, toggle) - expect(toggle.getAttribute("open")).toEqual("true") - }) + expect(toggle.getAttribute("open")).toEqual(null); + JS.exec(event, "click", toggle.getAttribute("phx-click"), view, toggle); + expect(toggle.getAttribute("open")).toEqual("true"); + }); test("with multiple selector", () => { const view = setupView(`
modal
modal
- `) - const modal1 = document.querySelector("#modal1")! - const modal2 = document.querySelector("#modal2")! - const toggle = document.querySelector("#toggle")! - - expect(modal1.getAttribute("open")).toEqual(null) - expect(modal2.getAttribute("open")).toEqual("true") - JS.exec(event, "click", toggle.getAttribute("phx-click"), view, toggle) - expect(modal1.getAttribute("open")).toEqual("true") - expect(modal2.getAttribute("open")).toEqual(null) - }) + `); + const modal1 = document.querySelector("#modal1")!; + const modal2 = document.querySelector("#modal2")!; + const toggle = document.querySelector("#toggle")!; + + expect(modal1.getAttribute("open")).toEqual(null); + expect(modal2.getAttribute("open")).toEqual("true"); + JS.exec(event, "click", toggle.getAttribute("phx-click"), view, toggle); + expect(modal1.getAttribute("open")).toEqual("true"); + expect(modal2.getAttribute("open")).toEqual(null); + }); test("toggling a pre-existing attribute updates its value", () => { const view = setupView(`
- `) - const toggle = document.querySelector("#toggle")! - const modal = document.querySelector("#modal")! + `); + const toggle = document.querySelector("#toggle")!; + const modal = document.querySelector("#modal")!; - expect(modal.getAttribute("open")).toEqual("true") - JS.exec(event, "click", toggle.getAttribute("phx-click"), view, toggle) - expect(modal.getAttribute("open")).toEqual(null) - }) + expect(modal.getAttribute("open")).toEqual("true"); + JS.exec(event, "click", toggle.getAttribute("phx-click"), view, toggle); + expect(modal.getAttribute("open")).toEqual(null); + }); test("toggling a dynamically added attribute updates its value", () => { const view = setupView(`
- `) - const toggle1 = document.querySelector("#toggle1")! - const toggle2 = document.querySelector("#toggle2")! - const modal = document.querySelector("#modal")! - - expect(modal.getAttribute("open")).toEqual(null) - JS.exec(event, "click", toggle1.getAttribute("phx-click"), view, toggle1) - expect(modal.getAttribute("open")).toEqual("true") - JS.exec(event, "click", toggle2.getAttribute("phx-click"), view, toggle2) - expect(modal.getAttribute("open")).toEqual(null) - }) + `); + const toggle1 = document.querySelector("#toggle1")!; + const toggle2 = document.querySelector("#toggle2")!; + const modal = document.querySelector("#modal")!; + + expect(modal.getAttribute("open")).toEqual(null); + JS.exec(event, "click", toggle1.getAttribute("phx-click"), view, toggle1); + expect(modal.getAttribute("open")).toEqual("true"); + JS.exec(event, "click", toggle2.getAttribute("phx-click"), view, toggle2); + expect(modal.getAttribute("open")).toEqual(null); + }); test("toggling between two values", () => { const view = setupView(`
- `) - const toggle = document.querySelector("#toggle")! + `); + const toggle = document.querySelector("#toggle")!; - JS.exec(event, "click", toggle.getAttribute("phx-click"), view, toggle) - expect(toggle.getAttribute("aria-expanded")).toEqual("true") - JS.exec(event, "click", toggle.getAttribute("phx-click"), view, toggle) - expect(toggle.getAttribute("aria-expanded")).toEqual("false") - }) - }) + JS.exec(event, "click", toggle.getAttribute("phx-click"), view, toggle); + expect(toggle.getAttribute("aria-expanded")).toEqual("true"); + JS.exec(event, "click", toggle.getAttribute("phx-click"), view, toggle); + expect(toggle.getAttribute("aria-expanded")).toEqual("false"); + }); + }); describe("focus", () => { test("works like a stack", () => { @@ -1176,25 +1278,25 @@ describe("JS", () => {
- `) - const modal1 = document.querySelector("#modal1")! - const modal2 = document.querySelector("#modal2")! - const push1 = document.querySelector("#push1")! - const push2 = document.querySelector("#push2")! - const pop = document.querySelector("#pop")! - - JS.exec(event, "click", push1.getAttribute("phx-click"), view, push1) - JS.exec(event, "click", push2.getAttribute("phx-click"), view, push2) - - JS.exec(event, "click", pop.getAttribute("phx-click"), view, pop) - jest.runAllTimers() - expect(document.activeElement).toBe(modal2) - - JS.exec(event, "click", pop.getAttribute("phx-click"), view, pop) - jest.runAllTimers() - expect(document.activeElement).toBe(modal1) - }) - }) + `); + const modal1 = document.querySelector("#modal1")!; + const modal2 = document.querySelector("#modal2")!; + const push1 = document.querySelector("#push1")!; + const push2 = document.querySelector("#push2")!; + const pop = document.querySelector("#pop")!; + + JS.exec(event, "click", push1.getAttribute("phx-click"), view, push1); + JS.exec(event, "click", push2.getAttribute("phx-click"), view, push2); + + JS.exec(event, "click", pop.getAttribute("phx-click"), view, pop); + jest.runAllTimers(); + expect(document.activeElement).toBe(modal2); + + JS.exec(event, "click", pop.getAttribute("phx-click"), view, pop); + jest.runAllTimers(); + expect(document.activeElement).toBe(modal1); + }); + }); describe("exec_focus_first", () => { test("focuses div with tabindex 0", () => { @@ -1205,14 +1307,14 @@ describe("JS", () => {
- `) - const modal2 = document.querySelector("#modal2")! - const push = document.querySelector("#push")! + `); + const modal2 = document.querySelector("#modal2")!; + const push = document.querySelector("#push")!; - JS.exec(event, "click", push.getAttribute("phx-click"), view, push) + JS.exec(event, "click", push.getAttribute("phx-click"), view, push); - jest.runAllTimers() - expect(document.activeElement).toBe(modal2) - }) - }) -}) + jest.runAllTimers(); + expect(document.activeElement).toBe(modal2); + }); + }); +}); diff --git a/assets/test/live_socket_test.ts b/assets/test/live_socket_test.ts index e4123fdb06..a8e68048c1 100644 --- a/assets/test/live_socket_test.ts +++ b/assets/test/live_socket_test.ts @@ -1,438 +1,450 @@ -import {Socket} from "phoenix" -import LiveSocket from "phoenix_live_view/live_socket" -import JS from "phoenix_live_view/js" -import {simulateJoinedView, simulateVisibility} from "./test_helpers" +import { Socket } from "phoenix"; +import LiveSocket from "phoenix_live_view/live_socket"; +import JS from "phoenix_live_view/js"; +import { simulateJoinedView, simulateVisibility } from "./test_helpers"; -const container = (num) => global.document.getElementById(`container${num}`) +const container = (num) => global.document.getElementById(`container${num}`); const prepareLiveViewDOM = (document) => { - const div = document.createElement("div") - div.setAttribute("data-phx-session", "abc123") - div.setAttribute("data-phx-root-id", "container1") - div.setAttribute("id", "container1") + const div = document.createElement("div"); + div.setAttribute("data-phx-session", "abc123"); + div.setAttribute("data-phx-root-id", "container1"); + div.setAttribute("id", "container1"); div.innerHTML = ` - ` - const button = div.querySelector("button") - const input = div.querySelector("input") + `; + const button = div.querySelector("button"); + const input = div.querySelector("input"); button.addEventListener("click", () => { setTimeout(() => { - input.value += 1 - }, 200) - }) - document.body.appendChild(div) -} + input.value += 1; + }, 200); + }); + document.body.appendChild(div); +}; describe("LiveSocket", () => { beforeEach(() => { - prepareLiveViewDOM(global.document) - }) + prepareLiveViewDOM(global.document); + }); afterAll(() => { - global.document.body.innerHTML = "" - }) + global.document.body.innerHTML = ""; + }); test("sets defaults", async () => { - const liveSocket = new LiveSocket("/live", Socket) - expect(liveSocket.socket).toBeDefined() - expect(liveSocket.socket.onOpen).toBeDefined() - expect(liveSocket.viewLogger).toBeUndefined() - expect(liveSocket.unloaded).toBe(false) - expect(liveSocket.bindingPrefix).toBe("phx-") - expect(liveSocket.prevActive).toBe(null) - }) + const liveSocket = new LiveSocket("/live", Socket); + expect(liveSocket.socket).toBeDefined(); + expect(liveSocket.socket.onOpen).toBeDefined(); + expect(liveSocket.viewLogger).toBeUndefined(); + expect(liveSocket.unloaded).toBe(false); + expect(liveSocket.bindingPrefix).toBe("phx-"); + expect(liveSocket.prevActive).toBe(null); + }); test("sets defaults with socket", async () => { - const liveSocket = new LiveSocket(new Socket("//example.org/chat"), Socket) - expect(liveSocket.socket).toBeDefined() - expect(liveSocket.socket.onOpen).toBeDefined() - expect(liveSocket.unloaded).toBe(false) - expect(liveSocket.bindingPrefix).toBe("phx-") - expect(liveSocket.prevActive).toBe(null) - }) + const liveSocket = new LiveSocket(new Socket("//example.org/chat"), Socket); + expect(liveSocket.socket).toBeDefined(); + expect(liveSocket.socket.onOpen).toBeDefined(); + expect(liveSocket.unloaded).toBe(false); + expect(liveSocket.bindingPrefix).toBe("phx-"); + expect(liveSocket.prevActive).toBe(null); + }); test("viewLogger", async () => { const viewLogger = (view, kind, msg, obj) => { - expect(view.id).toBe("container1") - expect(kind).toBe("updated") - expect(msg).toBe("") - expect(obj).toBe("\"
\"") - } - const liveSocket = new LiveSocket("/live", Socket, {viewLogger}) - expect(liveSocket.viewLogger).toBe(viewLogger) - liveSocket.connect() - const view = liveSocket.getViewByEl(container(1)) - liveSocket.log(view, "updated", () => ["", JSON.stringify("
")]) - }) + expect(view.id).toBe("container1"); + expect(kind).toBe("updated"); + expect(msg).toBe(""); + expect(obj).toBe('"
"'); + }; + const liveSocket = new LiveSocket("/live", Socket, { viewLogger }); + expect(liveSocket.viewLogger).toBe(viewLogger); + liveSocket.connect(); + const view = liveSocket.getViewByEl(container(1)); + liveSocket.log(view, "updated", () => ["", JSON.stringify("
")]); + }); test("connect", async () => { - const liveSocket = new LiveSocket("/live", Socket) - const _socket = liveSocket.connect() - expect(liveSocket.getViewByEl(container(1))).toBeDefined() - }) + const liveSocket = new LiveSocket("/live", Socket); + const _socket = liveSocket.connect(); + expect(liveSocket.getViewByEl(container(1))).toBeDefined(); + }); test("disconnect", async () => { - const liveSocket = new LiveSocket("/live", Socket) + const liveSocket = new LiveSocket("/live", Socket); - liveSocket.connect() - liveSocket.disconnect() + liveSocket.connect(); + liveSocket.disconnect(); - expect(liveSocket.getViewByEl(container(1)).destroy).toBeDefined() - }) + expect(liveSocket.getViewByEl(container(1)).destroy).toBeDefined(); + }); test("channel", async () => { - const liveSocket = new LiveSocket("/live", Socket) + const liveSocket = new LiveSocket("/live", Socket); - liveSocket.connect() - const channel = liveSocket.channel("lv:def456", function(){ - return {session: this.getSession()} - }) + liveSocket.connect(); + const channel = liveSocket.channel("lv:def456", function () { + return { session: this.getSession() }; + }); - expect(channel).toBeDefined() - }) + expect(channel).toBeDefined(); + }); test("getViewByEl", async () => { - const liveSocket = new LiveSocket("/live", Socket) + const liveSocket = new LiveSocket("/live", Socket); - liveSocket.connect() + liveSocket.connect(); - expect(liveSocket.getViewByEl(container(1)).destroy).toBeDefined() - }) + expect(liveSocket.getViewByEl(container(1)).destroy).toBeDefined(); + }); test("destroyAllViews", async () => { - const secondLiveView = document.createElement("div") - secondLiveView.setAttribute("data-phx-session", "def456") - secondLiveView.setAttribute("data-phx-root-id", "container1") - secondLiveView.setAttribute("id", "container2") + const secondLiveView = document.createElement("div"); + secondLiveView.setAttribute("data-phx-session", "def456"); + secondLiveView.setAttribute("data-phx-root-id", "container1"); + secondLiveView.setAttribute("id", "container2"); secondLiveView.innerHTML = ` - ` - document.body.appendChild(secondLiveView) + `; + document.body.appendChild(secondLiveView); - const liveSocket = new LiveSocket("/live", Socket) - liveSocket.connect() + const liveSocket = new LiveSocket("/live", Socket); + liveSocket.connect(); - const el = container(1) - expect(liveSocket.getViewByEl(el)).toBeDefined() + const el = container(1); + expect(liveSocket.getViewByEl(el)).toBeDefined(); - liveSocket.destroyAllViews() - expect(liveSocket.roots).toEqual({}) + liveSocket.destroyAllViews(); + expect(liveSocket.roots).toEqual({}); // Simulate a race condition which may attempt to // destroy an element that no longer exists - liveSocket.destroyViewByEl(el) - expect(liveSocket.roots).toEqual({}) - }) + liveSocket.destroyViewByEl(el); + expect(liveSocket.roots).toEqual({}); + }); test("binding", async () => { - const liveSocket = new LiveSocket("/live", Socket) + const liveSocket = new LiveSocket("/live", Socket); - expect(liveSocket.binding("value")).toBe("phx-value") - }) + expect(liveSocket.binding("value")).toBe("phx-value"); + }); test("getBindingPrefix", async () => { - const liveSocket = new LiveSocket("/live", Socket) + const liveSocket = new LiveSocket("/live", Socket); - expect(liveSocket.getBindingPrefix()).toEqual("phx-") - }) + expect(liveSocket.getBindingPrefix()).toEqual("phx-"); + }); test("getBindingPrefix custom", async () => { - const liveSocket = new LiveSocket("/live", Socket, {bindingPrefix: "company-"}) + const liveSocket = new LiveSocket("/live", Socket, { + bindingPrefix: "company-", + }); - expect(liveSocket.getBindingPrefix()).toEqual("company-") - }) + expect(liveSocket.getBindingPrefix()).toEqual("company-"); + }); test("owner", async () => { - const liveSocket = new LiveSocket("/live", Socket) - liveSocket.connect() + const liveSocket = new LiveSocket("/live", Socket); + liveSocket.connect(); - const _view = liveSocket.getViewByEl(container(1)) - const btn = document.querySelector("button") + const _view = liveSocket.getViewByEl(container(1)); + const btn = document.querySelector("button"); const _callback = (view) => { - expect(view.id).toBe(view.id) - } - liveSocket.owner(btn, (view) => view.id) - }) + expect(view.id).toBe(view.id); + }; + liveSocket.owner(btn, (view) => view.id); + }); test("getActiveElement default before LiveSocket activeElement is set", async () => { - const liveSocket = new LiveSocket("/live", Socket) + const liveSocket = new LiveSocket("/live", Socket); - const input = document.querySelector("input") - input.focus() + const input = document.querySelector("input"); + input.focus(); - expect(liveSocket.getActiveElement()).toEqual(input) - }) + expect(liveSocket.getActiveElement()).toEqual(input); + }); test("blurActiveElement", async () => { - const liveSocket = new LiveSocket("/live", Socket) + const liveSocket = new LiveSocket("/live", Socket); - const input = document.querySelector("input") - input.focus() + const input = document.querySelector("input"); + input.focus(); - expect(liveSocket.prevActive).toBeNull() + expect(liveSocket.prevActive).toBeNull(); - liveSocket.blurActiveElement() + liveSocket.blurActiveElement(); // sets prevActive - expect(liveSocket.prevActive).toEqual(input) - expect(liveSocket.getActiveElement()).not.toEqual(input) - }) + expect(liveSocket.prevActive).toEqual(input); + expect(liveSocket.getActiveElement()).not.toEqual(input); + }); test("restorePreviouslyActiveFocus", async () => { - const liveSocket = new LiveSocket("/live", Socket) + const liveSocket = new LiveSocket("/live", Socket); - const input = document.querySelector("input") - input.focus() + const input = document.querySelector("input"); + input.focus(); - liveSocket.blurActiveElement() - expect(liveSocket.prevActive).toEqual(input) - expect(liveSocket.getActiveElement()).not.toEqual(input) + liveSocket.blurActiveElement(); + expect(liveSocket.prevActive).toEqual(input); + expect(liveSocket.getActiveElement()).not.toEqual(input); // focus() - liveSocket.restorePreviouslyActiveFocus() - expect(liveSocket.prevActive).toEqual(input) - expect(liveSocket.getActiveElement()).toEqual(input) - expect(document.activeElement).toEqual(input) - }) + liveSocket.restorePreviouslyActiveFocus(); + expect(liveSocket.prevActive).toEqual(input); + expect(liveSocket.getActiveElement()).toEqual(input); + expect(document.activeElement).toEqual(input); + }); test("dropActiveElement unsets prevActive", async () => { - const liveSocket = new LiveSocket("/live", Socket) + const liveSocket = new LiveSocket("/live", Socket); - liveSocket.connect() + liveSocket.connect(); - const input = document.querySelector("input") - input.focus() - liveSocket.blurActiveElement() - expect(liveSocket.prevActive).toEqual(input) + const input = document.querySelector("input"); + input.focus(); + liveSocket.blurActiveElement(); + expect(liveSocket.prevActive).toEqual(input); - const view = liveSocket.getViewByEl(container(1)) - liveSocket.dropActiveElement(view) - expect(liveSocket.prevActive).toBeNull() + const view = liveSocket.getViewByEl(container(1)); + liveSocket.dropActiveElement(view); + expect(liveSocket.prevActive).toBeNull(); // this fails. Is this correct? // expect(liveSocket.getActiveElement()).not.toEqual(input) - }) + }); test("storage can be overridden", async () => { - let getItemCalls = 0 + let getItemCalls = 0; const override = { - getItem: function (_keyName){ getItemCalls = getItemCalls + 1 } - } + getItem: function (_keyName) { + getItemCalls = getItemCalls + 1; + }, + }; - const liveSocket = new LiveSocket("/live", Socket, {sessionStorage: override}) - liveSocket.getLatencySim() + const liveSocket = new LiveSocket("/live", Socket, { + sessionStorage: override, + }); + liveSocket.getLatencySim(); // liveSocket constructor reads nav history position from sessionStorage - expect(getItemCalls).toEqual(2) - }) -}) + expect(getItemCalls).toEqual(2); + }); +}); describe("liveSocket.js()", () => { - let view, liveSocket, js - + let view, liveSocket, js; + beforeEach(() => { - global.document.body.innerHTML = "" - prepareLiveViewDOM(global.document) - jest.useFakeTimers() - - liveSocket = new LiveSocket("/live", Socket) - view = simulateJoinedView(document.getElementById("container1"), liveSocket) - js = liveSocket.js() - }) + global.document.body.innerHTML = ""; + prepareLiveViewDOM(global.document); + jest.useFakeTimers(); + + liveSocket = new LiveSocket("/live", Socket); + view = simulateJoinedView( + document.getElementById("container1"), + liveSocket, + ); + js = liveSocket.js(); + }); afterEach(() => { - jest.useRealTimers() - }) + jest.useRealTimers(); + }); afterAll(() => { - global.document.body.innerHTML = "" - }) + global.document.body.innerHTML = ""; + }); test("exec", () => { - const el = document.createElement("div") - el.setAttribute("id", "test-exec") - el.setAttribute("data-test", "[[\"toggle_attr\", {\"attr\": [\"open\", \"true\"]}]]") - view.el.appendChild(el) - - expect(el.getAttribute("open")).toBeNull() - js.exec(el, el.getAttribute("data-test")) - jest.runAllTimers() - expect(el.getAttribute("open")).toEqual("true") - }) - - test("show and hide", done => { - const el = document.createElement("div") - el.setAttribute("id", "test-visibility") - view.el.appendChild(el) - simulateVisibility(el) - - expect(el.style.display).toBe("") - js.hide(el) - jest.runAllTimers() - expect(el.style.display).toBe("none") - - js.show(el) - jest.runAllTimers() - expect(el.style.display).toBe("block") - done() - }) - - test("toggle", done => { - const el = document.createElement("div") - el.setAttribute("id", "test-toggle") - view.el.appendChild(el) - simulateVisibility(el) - - expect(el.style.display).toBe("") - js.toggle(el) - jest.runAllTimers() - expect(el.style.display).toBe("none") - - js.toggle(el) - jest.runAllTimers() - expect(el.style.display).toBe("block") - done() - }) - - test("addClass, removeClass and toggleClass", done => { - const el = document.createElement("div") - el.setAttribute("id", "test-classes") - el.className = "initial-class" - view.el.appendChild(el) - - js.addClass(el, "test-class") - jest.runAllTimers() - expect(el.classList.contains("test-class")).toBe(true) - expect(el.classList.contains("initial-class")).toBe(true) - - js.addClass(el, ["multiple", "classes"]) - jest.runAllTimers() - expect(el.classList.contains("multiple")).toBe(true) - expect(el.classList.contains("classes")).toBe(true) - - js.removeClass(el, "test-class") - jest.runAllTimers() - expect(el.classList.contains("test-class")).toBe(false) - expect(el.classList.contains("initial-class")).toBe(true) - - js.removeClass(el, ["multiple", "classes"]) - jest.runAllTimers() - expect(el.classList.contains("multiple")).toBe(false) - expect(el.classList.contains("classes")).toBe(false) - - js.toggleClass(el, "toggle-class") - jest.runAllTimers() - expect(el.classList.contains("toggle-class")).toBe(true) - - js.toggleClass(el, "toggle-class") - jest.runAllTimers() - expect(el.classList.contains("toggle-class")).toBe(false) - done() - }) - - test("transition", done => { - const el = document.createElement("div") - el.setAttribute("id", "test-transition") - view.el.appendChild(el) - - js.transition(el, "fade-in") - jest.advanceTimersByTime(100) - expect(el.classList.contains("fade-in")).toBe(true) - - js.transition(el, ["ease-out duration-300", "opacity-0", "opacity-100"]) - jest.advanceTimersByTime(100) - expect(el.classList.contains("ease-out")).toBe(true) - expect(el.classList.contains("duration-300")).toBe(true) - expect(el.classList.contains("opacity-100")).toBe(true) - done() - }) - + const el = document.createElement("div"); + el.setAttribute("id", "test-exec"); + el.setAttribute( + "data-test", + '[["toggle_attr", {"attr": ["open", "true"]}]]', + ); + view.el.appendChild(el); + + expect(el.getAttribute("open")).toBeNull(); + js.exec(el, el.getAttribute("data-test")); + jest.runAllTimers(); + expect(el.getAttribute("open")).toEqual("true"); + }); + + test("show and hide", (done) => { + const el = document.createElement("div"); + el.setAttribute("id", "test-visibility"); + view.el.appendChild(el); + simulateVisibility(el); + + expect(el.style.display).toBe(""); + js.hide(el); + jest.runAllTimers(); + expect(el.style.display).toBe("none"); + + js.show(el); + jest.runAllTimers(); + expect(el.style.display).toBe("block"); + done(); + }); + + test("toggle", (done) => { + const el = document.createElement("div"); + el.setAttribute("id", "test-toggle"); + view.el.appendChild(el); + simulateVisibility(el); + + expect(el.style.display).toBe(""); + js.toggle(el); + jest.runAllTimers(); + expect(el.style.display).toBe("none"); + + js.toggle(el); + jest.runAllTimers(); + expect(el.style.display).toBe("block"); + done(); + }); + + test("addClass, removeClass and toggleClass", (done) => { + const el = document.createElement("div"); + el.setAttribute("id", "test-classes"); + el.className = "initial-class"; + view.el.appendChild(el); + + js.addClass(el, "test-class"); + jest.runAllTimers(); + expect(el.classList.contains("test-class")).toBe(true); + expect(el.classList.contains("initial-class")).toBe(true); + + js.addClass(el, ["multiple", "classes"]); + jest.runAllTimers(); + expect(el.classList.contains("multiple")).toBe(true); + expect(el.classList.contains("classes")).toBe(true); + + js.removeClass(el, "test-class"); + jest.runAllTimers(); + expect(el.classList.contains("test-class")).toBe(false); + expect(el.classList.contains("initial-class")).toBe(true); + + js.removeClass(el, ["multiple", "classes"]); + jest.runAllTimers(); + expect(el.classList.contains("multiple")).toBe(false); + expect(el.classList.contains("classes")).toBe(false); + + js.toggleClass(el, "toggle-class"); + jest.runAllTimers(); + expect(el.classList.contains("toggle-class")).toBe(true); + + js.toggleClass(el, "toggle-class"); + jest.runAllTimers(); + expect(el.classList.contains("toggle-class")).toBe(false); + done(); + }); + + test("transition", (done) => { + const el = document.createElement("div"); + el.setAttribute("id", "test-transition"); + view.el.appendChild(el); + + js.transition(el, "fade-in"); + jest.advanceTimersByTime(100); + expect(el.classList.contains("fade-in")).toBe(true); + + js.transition(el, ["ease-out duration-300", "opacity-0", "opacity-100"]); + jest.advanceTimersByTime(100); + expect(el.classList.contains("ease-out")).toBe(true); + expect(el.classList.contains("duration-300")).toBe(true); + expect(el.classList.contains("opacity-100")).toBe(true); + done(); + }); + test("setAttribute, removeAttribute and toggleAttribute", () => { - const el = document.createElement("div") - el.setAttribute("id", "test-attributes") - view.el.appendChild(el) - - js.setAttribute(el, "data-test", "value") - expect(el.getAttribute("data-test")).toBe("value") - - js.removeAttribute(el, "data-test") - expect(el.getAttribute("data-test")).toBeNull() - - js.toggleAttribute(el, "aria-expanded", "true", "false") - expect(el.getAttribute("aria-expanded")).toBe("true") - - js.toggleAttribute(el, "aria-expanded", "true", "false") - expect(el.getAttribute("aria-expanded")).toBe("false") - }) - + const el = document.createElement("div"); + el.setAttribute("id", "test-attributes"); + view.el.appendChild(el); + + js.setAttribute(el, "data-test", "value"); + expect(el.getAttribute("data-test")).toBe("value"); + + js.removeAttribute(el, "data-test"); + expect(el.getAttribute("data-test")).toBeNull(); + + js.toggleAttribute(el, "aria-expanded", "true", "false"); + expect(el.getAttribute("aria-expanded")).toBe("true"); + + js.toggleAttribute(el, "aria-expanded", "true", "false"); + expect(el.getAttribute("aria-expanded")).toBe("false"); + }); + test("push", () => { - const el = document.createElement("div") - el.setAttribute("id", "test-push") - view.el.appendChild(el) - - const originalWithinOwners = liveSocket.withinOwners + const el = document.createElement("div"); + el.setAttribute("id", "test-push"); + view.el.appendChild(el); + + const originalWithinOwners = liveSocket.withinOwners; liveSocket.withinOwners = (el, callback) => { - callback(view) - } - - const originalExec = JS.exec - JS.exec = jest.fn() - - js.push(el, "custom-event", {value: {key: "value"}}) - - expect(JS.exec).toHaveBeenCalled() - - liveSocket.withinOwners = originalWithinOwners - JS.exec = originalExec - }) - + callback(view); + }; + + const originalExec = JS.exec; + JS.exec = jest.fn(); + + js.push(el, "custom-event", { value: { key: "value" } }); + + expect(JS.exec).toHaveBeenCalled(); + + liveSocket.withinOwners = originalWithinOwners; + JS.exec = originalExec; + }); + test("navigate", () => { - const originalHistoryRedirect = liveSocket.historyRedirect - liveSocket.historyRedirect = jest.fn() - - js.navigate("/test-url") + const originalHistoryRedirect = liveSocket.historyRedirect; + liveSocket.historyRedirect = jest.fn(); + + js.navigate("/test-url"); expect(liveSocket.historyRedirect).toHaveBeenCalledWith( expect.any(CustomEvent), "/test-url", "push", null, - null - ) - - js.navigate("/test-url", {replace: true}) + null, + ); + + js.navigate("/test-url", { replace: true }); expect(liveSocket.historyRedirect).toHaveBeenCalledWith( expect.any(CustomEvent), "/test-url", "replace", null, - null - ) - - liveSocket.historyRedirect = originalHistoryRedirect - }) - + null, + ); + + liveSocket.historyRedirect = originalHistoryRedirect; + }); + test("patch", () => { - const originalPushHistoryPatch = liveSocket.pushHistoryPatch - liveSocket.pushHistoryPatch = jest.fn() - - js.patch("/test-url") + const originalPushHistoryPatch = liveSocket.pushHistoryPatch; + liveSocket.pushHistoryPatch = jest.fn(); + + js.patch("/test-url"); expect(liveSocket.pushHistoryPatch).toHaveBeenCalledWith( expect.any(CustomEvent), "/test-url", "push", - null - ) - - js.patch("/test-url", {replace: true}) + null, + ); + + js.patch("/test-url", { replace: true }); expect(liveSocket.pushHistoryPatch).toHaveBeenCalledWith( expect.any(CustomEvent), "/test-url", "replace", - null - ) - - liveSocket.pushHistoryPatch = originalPushHistoryPatch - }) -}) + null, + ); + + liveSocket.pushHistoryPatch = originalPushHistoryPatch; + }); +}); diff --git a/assets/test/modify_root_test.ts b/assets/test/modify_root_test.ts index b5def9196c..379ce1d8ec 100644 --- a/assets/test/modify_root_test.ts +++ b/assets/test/modify_root_test.ts @@ -1,4 +1,4 @@ -import {modifyRoot} from "phoenix_live_view/rendered" +import { modifyRoot } from "phoenix_live_view/rendered"; describe("modifyRoot stripping comments", () => { test("starting comments", () => { @@ -7,115 +7,146 @@ describe("modifyRoot stripping comments", () => {
- ` - const [strippedHTML, commentBefore, commentAfter] = modifyRoot(html, {}) - expect(strippedHTML).toEqual("
MENU
") + `; + const [strippedHTML, commentBefore, commentAfter] = modifyRoot(html, {}); + expect(strippedHTML).toEqual( + '
', + ); expect(commentBefore).toEqual(` - `) + `); expect(commentAfter).toEqual(` - `) - }) + `); + }); test("ending comments", () => { const html = `
- ` - const [strippedHTML, commentBefore, commentAfter] = modifyRoot(html, {}) - expect(strippedHTML).toEqual("
MENU
") + `; + const [strippedHTML, commentBefore, commentAfter] = modifyRoot(html, {}); + expect(strippedHTML).toEqual( + '
', + ); expect(commentBefore).toEqual(` - `) + `); expect(commentAfter).toEqual(` - `) - }) + `); + }); test("starting and ending comments", () => { const html = `
- ` - const [strippedHTML, commentBefore, commentAfter] = modifyRoot(html, {}) - expect(strippedHTML).toEqual("
MENU
") + `; + const [strippedHTML, commentBefore, commentAfter] = modifyRoot(html, {}); + expect(strippedHTML).toEqual( + '
', + ); expect(commentBefore).toEqual(` - `) + `); expect(commentAfter).toEqual(` - `) - }) + `); + }); test("merges new attrs", () => { const html = `
- ` - expect(modifyRoot(html, {id: 123})[0]).toEqual("
MENU
") - expect(modifyRoot(html, {id: 123, another: ""})[0]).toEqual("
MENU
") + `; + expect(modifyRoot(html, { id: 123 })[0]).toEqual( + '
', + ); + expect(modifyRoot(html, { id: 123, another: "" })[0]).toEqual( + '
', + ); // clearing innerHTML - expect(modifyRoot(html, {id: 123, another: ""}, true)[0]).toEqual("
") + expect(modifyRoot(html, { id: 123, another: "" }, true)[0]).toEqual( + '
', + ); // self closing const selfClose = ` - ` - expect(modifyRoot(selfClose, {id: 123, another: ""})[0]).toEqual("") - }) + `; + expect(modifyRoot(selfClose, { id: 123, another: "" })[0]).toEqual( + '', + ); + }); test("mixed whitespace", () => { const html = `
- ` - expect(modifyRoot(html, {id: 123})[0]).toEqual(`
`) - expect(modifyRoot(html, {id: 123, another: ""})[0]).toEqual(`
`) + `; + expect(modifyRoot(html, { id: 123 })[0]).toEqual(`
`); + expect(modifyRoot(html, { id: 123, another: "" })[0]) + .toEqual(`
`); // clearing innerHTML - expect(modifyRoot(html, {id: 123, another: ""}, true)[0]).toEqual("
") - }) + expect(modifyRoot(html, { id: 123, another: "" }, true)[0]).toEqual( + '
', + ); + }); test("self closed", () => { - let html = `` - expect(modifyRoot(html, {id: 123, another: ""})[0]).toEqual(``) + let html = ``; + expect(modifyRoot(html, { id: 123, another: "" })[0]).toEqual( + ``, + ); - html = "" - expect(modifyRoot(html, {id: 123})[0]).toEqual("") + html = ''; + expect(modifyRoot(html, { id: 123 })[0]).toEqual( + '', + ); - html = "" - expect(modifyRoot(html, {id: 123})[0]).toEqual("") + html = ""; + expect(modifyRoot(html, { id: 123 })[0]).toEqual(''); - html = "" - expect(modifyRoot(html, {id: 123})[0]).toEqual("") + html = ""; + expect(modifyRoot(html, { id: 123 })[0]).toEqual(''); - html = "" - let result = modifyRoot(html, {id: 123}) - expect(result[0]).toEqual("") - expect(result[1]).toEqual("") - expect(result[2]).toEqual("") + html = ''; + let result = modifyRoot(html, { id: 123 }); + expect(result[0]).toEqual(''); + expect(result[1]).toEqual(""); + expect(result[2]).toEqual(""); // unclosed self closed - html = "" - expect(modifyRoot(html, {id: 123})[0]).toEqual("") + html = ''; + expect(modifyRoot(html, { id: 123 })[0]).toEqual( + '', + ); - html = "" - result = modifyRoot(html, {id: 123}) - expect(result[0]).toEqual("") - expect(result[1]).toEqual("") - expect(result[2]).toEqual("") - }) + html = + ''; + result = modifyRoot(html, { id: 123 }); + expect(result[0]).toEqual(''); + expect(result[1]).toEqual(""); + expect(result[2]).toEqual(""); + }); test("does not extract id from inner element", () => { - const html = "
\n
\n
" + const html = + '
\n
\n
'; const attrs = { "data-phx-id": "c3-phx-F6AZf4FwSR4R50pB", "data-phx-component": 3, - "data-phx-skip": true - } + "data-phx-skip": true, + }; - const [strippedHTML, _commentBefore, _commentAfter] = modifyRoot(html, attrs, true) + const [strippedHTML, _commentBefore, _commentAfter] = modifyRoot( + html, + attrs, + true, + ); - expect(strippedHTML).toEqual("
") - }) -}) + expect(strippedHTML).toEqual( + '
', + ); + }); +}); diff --git a/assets/test/rendered_test.ts b/assets/test/rendered_test.ts index fe21ae042b..d86724f728 100644 --- a/assets/test/rendered_test.ts +++ b/assets/test/rendered_test.ts @@ -1,123 +1,139 @@ -import Rendered from "phoenix_live_view/rendered" +import Rendered from "phoenix_live_view/rendered"; -const STATIC = "s" -const DYNAMICS = "d" -const COMPONENTS = "c" -const TEMPLATES = "p" +const STATIC = "s"; +const DYNAMICS = "d"; +const COMPONENTS = "c"; +const TEMPLATES = "p"; describe("Rendered", () => { describe("mergeDiff", () => { test("recursively merges two diffs", () => { - const simple = new Rendered("123", simpleDiff1) - simple.mergeDiff(simpleDiff2) - expect(simple.get()).toEqual({...simpleDiffResult, [COMPONENTS]: {}, newRender: true}) + const simple = new Rendered("123", simpleDiff1); + simple.mergeDiff(simpleDiff2); + expect(simple.get()).toEqual({ + ...simpleDiffResult, + [COMPONENTS]: {}, + newRender: true, + }); - const deep = new Rendered("123", deepDiff1) - deep.mergeDiff(deepDiff2) - expect(deep.get()).toEqual({...deepDiffResult, [COMPONENTS]: {}}) - }) + const deep = new Rendered("123", deepDiff1); + deep.mergeDiff(deepDiff2); + expect(deep.get()).toEqual({ ...deepDiffResult, [COMPONENTS]: {} }); + }); test("merges the latter diff if it contains a `static` key", () => { - const diff1 = {0: ["a"], 1: ["b"]} - const diff2 = {0: ["c"], [STATIC]: ["c"]} - const rendered = new Rendered("123", diff1) - rendered.mergeDiff(diff2) - expect(rendered.get()).toEqual({...diff2, [COMPONENTS]: {}}) - }) + const diff1 = { 0: ["a"], 1: ["b"] }; + const diff2 = { 0: ["c"], [STATIC]: ["c"] }; + const rendered = new Rendered("123", diff1); + rendered.mergeDiff(diff2); + expect(rendered.get()).toEqual({ ...diff2, [COMPONENTS]: {} }); + }); test("merges the latter diff if it contains a `static` key even when nested", () => { - const diff1 = {0: {0: ["a"], 1: ["b"]}} - const diff2 = {0: {0: ["c"], [STATIC]: ["c"]}} - const rendered = new Rendered("123", diff1) - rendered.mergeDiff(diff2) - expect(rendered.get()).toEqual({...diff2, [COMPONENTS]: {}}) - }) + const diff1 = { 0: { 0: ["a"], 1: ["b"] } }; + const diff2 = { 0: { 0: ["c"], [STATIC]: ["c"] } }; + const rendered = new Rendered("123", diff1); + rendered.mergeDiff(diff2); + expect(rendered.get()).toEqual({ ...diff2, [COMPONENTS]: {} }); + }); test("merges components considering links", () => { - const diff1 = {} - const diff2 = {[COMPONENTS]: {1: {[STATIC]: ["c"]}, 2: {[STATIC]: 1}}} - const rendered = new Rendered("123", diff1) - rendered.mergeDiff(diff2) - expect(rendered.get()).toEqual({[COMPONENTS]: {1: {[STATIC]: ["c"]}, 2: {[STATIC]: ["c"]}}}) - }) + const diff1 = {}; + const diff2 = { + [COMPONENTS]: { 1: { [STATIC]: ["c"] }, 2: { [STATIC]: 1 } }, + }; + const rendered = new Rendered("123", diff1); + rendered.mergeDiff(diff2); + expect(rendered.get()).toEqual({ + [COMPONENTS]: { 1: { [STATIC]: ["c"] }, 2: { [STATIC]: ["c"] } }, + }); + }); test("merges components considering old and new links", () => { - const diff1 = {[COMPONENTS]: {1: {[STATIC]: ["old"]}}} - const diff2 = {[COMPONENTS]: {1: {[STATIC]: ["new"]}, 2: {newRender: true, [STATIC]: -1}, 3: {newRender: true, [STATIC]: 1}}} - const rendered = new Rendered("123", diff1) - rendered.mergeDiff(diff2) + const diff1 = { [COMPONENTS]: { 1: { [STATIC]: ["old"] } } }; + const diff2 = { + [COMPONENTS]: { + 1: { [STATIC]: ["new"] }, + 2: { newRender: true, [STATIC]: -1 }, + 3: { newRender: true, [STATIC]: 1 }, + }, + }; + const rendered = new Rendered("123", diff1); + rendered.mergeDiff(diff2); expect(rendered.get()).toEqual({ [COMPONENTS]: { - 1: {[STATIC]: ["new"]}, - 2: {[STATIC]: ["old"]}, - 3: {[STATIC]: ["new"]} - } - }) - }) + 1: { [STATIC]: ["new"] }, + 2: { [STATIC]: ["old"] }, + 3: { [STATIC]: ["new"] }, + }, + }); + }); test("merges components whole tree considering old and new links", () => { - const diff1 = {[COMPONENTS]: {1: {0: {[STATIC]: ["nested"]}, [STATIC]: ["old"]}}} + const diff1 = { + [COMPONENTS]: { 1: { 0: { [STATIC]: ["nested"] }, [STATIC]: ["old"] } }, + }; const diff2 = { [COMPONENTS]: { - 1: {0: {[STATIC]: ["nested"]}, [STATIC]: ["new"]}, - 2: {0: {[STATIC]: ["replaced"]}, [STATIC]: -1}, - 3: {0: {[STATIC]: ["replaced"]}, [STATIC]: 1}, - 4: {[STATIC]: -1}, - 5: {[STATIC]: 1} - } - } + 1: { 0: { [STATIC]: ["nested"] }, [STATIC]: ["new"] }, + 2: { 0: { [STATIC]: ["replaced"] }, [STATIC]: -1 }, + 3: { 0: { [STATIC]: ["replaced"] }, [STATIC]: 1 }, + 4: { [STATIC]: -1 }, + 5: { [STATIC]: 1 }, + }, + }; - const rendered1 = new Rendered("123", diff1) - rendered1.mergeDiff(diff2) + const rendered1 = new Rendered("123", diff1); + rendered1.mergeDiff(diff2); expect(rendered1.get()).toEqual({ [COMPONENTS]: { - 1: {0: {[STATIC]: ["nested"]}, [STATIC]: ["new"]}, - 2: {0: {[STATIC]: ["replaced"]}, [STATIC]: ["old"]}, - 3: {0: {[STATIC]: ["replaced"]}, [STATIC]: ["new"]}, - 4: {0: {[STATIC]: ["nested"]}, [STATIC]: ["old"]}, - 5: {0: {[STATIC]: ["nested"]}, [STATIC]: ["new"]}, - } - }) + 1: { 0: { [STATIC]: ["nested"] }, [STATIC]: ["new"] }, + 2: { 0: { [STATIC]: ["replaced"] }, [STATIC]: ["old"] }, + 3: { 0: { [STATIC]: ["replaced"] }, [STATIC]: ["new"] }, + 4: { 0: { [STATIC]: ["nested"] }, [STATIC]: ["old"] }, + 5: { 0: { [STATIC]: ["nested"] }, [STATIC]: ["new"] }, + }, + }); const diff3 = { [COMPONENTS]: { - 1: {0: {[STATIC]: ["newRender"]}, [STATIC]: ["new"]}, - 2: {0: {[STATIC]: ["replaced"]}, [STATIC]: -1}, - 3: {0: {[STATIC]: ["replaced"]}, [STATIC]: 1}, - 4: {[STATIC]: -1}, - 5: {[STATIC]: 1} - } - } + 1: { 0: { [STATIC]: ["newRender"] }, [STATIC]: ["new"] }, + 2: { 0: { [STATIC]: ["replaced"] }, [STATIC]: -1 }, + 3: { 0: { [STATIC]: ["replaced"] }, [STATIC]: 1 }, + 4: { [STATIC]: -1 }, + 5: { [STATIC]: 1 }, + }, + }; - const rendered2 = new Rendered("123", diff1) - rendered2.mergeDiff(diff3) + const rendered2 = new Rendered("123", diff1); + rendered2.mergeDiff(diff3); expect(rendered2.get()).toEqual({ [COMPONENTS]: { - 1: {0: {[STATIC]: ["newRender"]}, [STATIC]: ["new"]}, - 2: {0: {[STATIC]: ["replaced"]}, [STATIC]: ["old"]}, - 3: {0: {[STATIC]: ["replaced"]}, [STATIC]: ["new"]}, - 4: {0: {[STATIC]: ["nested"]}, [STATIC]: ["old"]}, - 5: {0: {[STATIC]: ["newRender"]}, [STATIC]: ["new"]}, - } - }) - }) + 1: { 0: { [STATIC]: ["newRender"] }, [STATIC]: ["new"] }, + 2: { 0: { [STATIC]: ["replaced"] }, [STATIC]: ["old"] }, + 3: { 0: { [STATIC]: ["replaced"] }, [STATIC]: ["new"] }, + 4: { 0: { [STATIC]: ["nested"] }, [STATIC]: ["old"] }, + 5: { 0: { [STATIC]: ["newRender"] }, [STATIC]: ["new"] }, + }, + }); + }); test("replaces a string when a map is returned", () => { - const diff1 = {0: {0: "", [STATIC]: ""}} - const diff2 = {0: {0: {0: "val", [STATIC]: ""}, [STATIC]: ""}} - const rendered = new Rendered("123", diff1) - rendered.mergeDiff(diff2) - expect(rendered.get()).toEqual({...diff2, [COMPONENTS]: {}}) - }) + const diff1 = { 0: { 0: "", [STATIC]: "" } }; + const diff2 = { 0: { 0: { 0: "val", [STATIC]: "" }, [STATIC]: "" } }; + const rendered = new Rendered("123", diff1); + rendered.mergeDiff(diff2); + expect(rendered.get()).toEqual({ ...diff2, [COMPONENTS]: {} }); + }); test("replaces a map when a string is returned", () => { - const diff1 = {0: {0: {0: "val", [STATIC]: ""}, [STATIC]: ""}} - const diff2 = {0: {0: "", [STATIC]: ""}} - const rendered = new Rendered("123", diff1) - rendered.mergeDiff(diff2) - expect(rendered.get()).toEqual({...diff2, [COMPONENTS]: {}}) - }) + const diff1 = { 0: { 0: { 0: "val", [STATIC]: "" }, [STATIC]: "" } }; + const diff2 = { 0: { 0: "", [STATIC]: "" } }; + const rendered = new Rendered("123", diff1); + rendered.mergeDiff(diff2); + expect(rendered.get()).toEqual({ ...diff2, [COMPONENTS]: {} }); + }); test("expands shared static from cids", () => { const mountDiff = { @@ -127,12 +143,12 @@ describe("Rendered", () => { "0": "new post", "1": "", "2": { - "d": [[1], [2]], - "s": ["", ""] + d: [[1], [2]], + s: ["", ""], }, - "s": ["h1", "h2", "h3", "h4"] + s: ["h1", "h2", "h3", "h4"], }, - "c": { + c: { "1": { "0": "1008", "1": "chris_mccord", @@ -143,7 +159,7 @@ describe("Rendered", () => { "6": "0", "7": "edit", "8": "delete", - "s": ["s0", "s1", "s2", "s3", "s4", "s5", "s6", "s7", "s8", "s9"] + s: ["s0", "s1", "s2", "s3", "s4", "s5", "s6", "s7", "s8", "s9"], }, "2": { "0": "1007", @@ -155,20 +171,20 @@ describe("Rendered", () => { "6": "0", "7": "edit", "8": "delete", - "s": 1 - } + s: 1, + }, }, - "s": ["f1", "f2", "f3", "f4"], - "title": "Listing Posts" - } + s: ["f1", "f2", "f3", "f4"], + title: "Listing Posts", + }; const updateDiff = { "2": { "2": { - "d": [[3]] - } + d: [[3]], + }, }, - "c": { + c: { "3": { "0": "1009", "1": "chris_mccord", @@ -179,57 +195,64 @@ describe("Rendered", () => { "6": "0", "7": "edit", "8": "delete", - "s": -2 - } - } - } + s: -2, + }, + }, + }; - const rendered = new Rendered("123", mountDiff) - expect(rendered.getComponent(rendered.get(), 1)[STATIC]).toEqual(rendered.getComponent(rendered.get(), 2)[STATIC]) - rendered.mergeDiff(updateDiff) - const sharedStatic = rendered.getComponent(rendered.get(), 1)[STATIC] + const rendered = new Rendered("123", mountDiff); + expect(rendered.getComponent(rendered.get(), 1)[STATIC]).toEqual( + rendered.getComponent(rendered.get(), 2)[STATIC], + ); + rendered.mergeDiff(updateDiff); + const sharedStatic = rendered.getComponent(rendered.get(), 1)[STATIC]; - expect(sharedStatic).toBeTruthy() - expect(sharedStatic).toEqual(rendered.getComponent(rendered.get(), 2)[STATIC]) - expect(sharedStatic).toEqual(rendered.getComponent(rendered.get(), 3)[STATIC]) - }) - }) + expect(sharedStatic).toBeTruthy(); + expect(sharedStatic).toEqual( + rendered.getComponent(rendered.get(), 2)[STATIC], + ); + expect(sharedStatic).toEqual( + rendered.getComponent(rendered.get(), 3)[STATIC], + ); + }); + }); describe("isNewFingerprint", () => { test("returns true if `diff.static` is truthy", () => { - const diff = {[STATIC]: ["

"]} - const rendered = new Rendered("123", {}) - expect(rendered.isNewFingerprint(diff)).toEqual(true) - }) + const diff = { [STATIC]: ["

"] }; + const rendered = new Rendered("123", {}); + expect(rendered.isNewFingerprint(diff)).toEqual(true); + }); test("returns false if `diff.static` is falsy", () => { - const diff = {[STATIC]: undefined} - const rendered = new Rendered("123", {}) - expect(rendered.isNewFingerprint(diff)).toEqual(false) - }) + const diff = { [STATIC]: undefined }; + const rendered = new Rendered("123", {}); + expect(rendered.isNewFingerprint(diff)).toEqual(false); + }); test("returns false if `diff` is undefined", () => { - const rendered = new Rendered("123", {}) - expect(rendered.isNewFingerprint()).toEqual(false) - }) - }) + const rendered = new Rendered("123", {}); + expect(rendered.isNewFingerprint()).toEqual(false); + }); + }); describe("toString", () => { test("stringifies a diff", () => { - const rendered = new Rendered("123", simpleDiffResult) - const {buffer: str} = rendered.toString() + const rendered = new Rendered("123", simpleDiffResult); + const { buffer: str } = rendered.toString(); expect(str.trim()).toEqual( `
cooling 07:15:04 PM
-
`.trim()) - }) +

`.trim(), + ); + }); test("reuses static in components and comprehensions", () => { - const rendered = new Rendered("123", staticReuseDiff) - const {buffer: str} = rendered.toString() + const rendered = new Rendered("123", staticReuseDiff); + const { buffer: str } = rendered.toString(); expect(str.trim()).toEqual( `

@@ -241,53 +264,61 @@ describe("Rendered", () => { bar 0: FROM index_1 world1: FROM index_2 world

-
`.trim()) - }) - }) -}) +
`.trim(), + ); + }); + }); +}); const simpleDiff1 = { "0": "cooling", "1": "cooling", "2": "07:15:03 PM", [STATIC]: [ - "
\n
\n ", + '
\n
\n ', "\n ", "\n
\n
\n", ], - "r": 1 -} + r: 1, +}; const simpleDiff2 = { "2": "07:15:04 PM", -} +}; const simpleDiffResult = { "0": "cooling", "1": "cooling", "2": "07:15:04 PM", [STATIC]: [ - "
\n
\n ", + '
\n
\n ', "\n ", "\n
\n
\n", ], - "r": 1 -} + r: 1, +}; const deepDiff1 = { "0": { "0": { - [DYNAMICS]: [["user1058", "1"], ["user99", "1"]], - [STATIC]: [" \n ", " (", ")\n \n"], - "r": 1 + [DYNAMICS]: [ + ["user1058", "1"], + ["user99", "1"], + ], + [STATIC]: [ + " \n ", + " (", + ")\n \n", + ], + r: 1, }, [STATIC]: [ " \n \n \n \n \n \n \n \n", " \n
Username
\n", ], - "r": 1 + r: 1, }, "1": { [DYNAMICS]: [ @@ -295,9 +326,9 @@ const deepDiff1 = { "asdf_asdf", "asdf@asdf.com", "123-456-7890", - "Show", - "Edit", - "Delete", + 'Show', + 'Edit', + 'Delete', ], ], [STATIC]: [ @@ -309,32 +340,36 @@ const deepDiff1 = { "\n", " \n \n", ], - "r": 1 - } -} + r: 1, + }, +}; const deepDiff2 = { "0": { "0": { [DYNAMICS]: [["user1058", "2"]], }, - } -} + }, +}; const deepDiffResult = { "0": { "0": { newRender: true, [DYNAMICS]: [["user1058", "2"]], - [STATIC]: [" \n ", " (", ")\n \n"], - "r": 1 + [STATIC]: [ + " \n ", + " (", + ")\n \n", + ], + r: 1, }, [STATIC]: [ " \n \n \n \n \n \n \n \n", " \n
Username
\n", ], newRender: true, - "r": 1, + r: 1, }, "1": { [DYNAMICS]: [ @@ -342,9 +377,9 @@ const deepDiffResult = { "asdf_asdf", "asdf@asdf.com", "123-456-7890", - "Show", - "Edit", - "Delete", + 'Show', + 'Edit', + 'Delete', ], ], [STATIC]: [ @@ -356,26 +391,49 @@ const deepDiffResult = { "\n", " \n \n", ], - "r": 1 - } -} + r: 1, + }, +}; const staticReuseDiff = { "0": { [DYNAMICS]: [ - ["foo", {[DYNAMICS]: [["0", 1], ["1", 2]], [STATIC]: 0}], - ["bar", {[DYNAMICS]: [["0", 3], ["1", 4]], [STATIC]: 0}] + [ + "foo", + { + [DYNAMICS]: [ + ["0", 1], + ["1", 2], + ], + [STATIC]: 0, + }, + ], + [ + "bar", + { + [DYNAMICS]: [ + ["0", 3], + ["1", 4], + ], + [STATIC]: 0, + }, + ], ], [STATIC]: ["\n

\n ", "\n ", "\n

\n"], - "r": 1, - [TEMPLATES]: {"0": ["", ": ", ""]} + r: 1, + [TEMPLATES]: { "0": ["", ": ", ""] }, }, [COMPONENTS]: { - "1": {"0": "index_1", "1": "world", [STATIC]: ["FROM ", " ", ""], "r": 1}, - "2": {"0": "index_2", "1": "world", [STATIC]: 1, "r": 1}, - "3": {"0": "index_1", "1": "world", [STATIC]: 1, "r": 1}, - "4": {"0": "index_2", "1": "world", [STATIC]: 3, "r": 1} + "1": { + "0": "index_1", + "1": "world", + [STATIC]: ["FROM ", " ", ""], + r: 1, + }, + "2": { "0": "index_2", "1": "world", [STATIC]: 1, r: 1 }, + "3": { "0": "index_1", "1": "world", [STATIC]: 1, r: 1 }, + "4": { "0": "index_2", "1": "world", [STATIC]: 3, r: 1 }, }, [STATIC]: ["
", "
"], - "r": 1 -} + r: 1, +}; diff --git a/assets/test/test_helpers.ts b/assets/test/test_helpers.ts index 37162957ab..13a5015d60 100644 --- a/assets/test/test_helpers.ts +++ b/assets/test/test_helpers.ts @@ -1,70 +1,82 @@ -import View from "phoenix_live_view/view" -import {version as liveview_version} from "../../package.json" +import View from "phoenix_live_view/view"; +import { version as liveview_version } from "../../package.json"; export const appendTitle = (opts, innerHTML?: string) => { - Array.from(document.head.querySelectorAll("title")).forEach(el => el.remove()) - const title = document.createElement("title") - const {prefix, suffix, default: defaultTitle} = opts - if(prefix){ title.setAttribute("data-prefix", prefix) } - if(suffix){ title.setAttribute("data-suffix", suffix) } - if(defaultTitle){ - title.setAttribute("data-default", defaultTitle) + Array.from(document.head.querySelectorAll("title")).forEach((el) => + el.remove(), + ); + const title = document.createElement("title"); + const { prefix, suffix, default: defaultTitle } = opts; + if (prefix) { + title.setAttribute("data-prefix", prefix); + } + if (suffix) { + title.setAttribute("data-suffix", suffix); + } + if (defaultTitle) { + title.setAttribute("data-default", defaultTitle); } else { - title.removeAttribute("data-default") + title.removeAttribute("data-default"); } - if(innerHTML){ title.innerHTML = innerHTML } - document.head.appendChild(title) -} + if (innerHTML) { + title.innerHTML = innerHTML; + } + document.head.appendChild(title); +}; export const rootContainer = (content) => { - const div = tag("div", {id: "root"}, content) - document.body.appendChild(div) - return div -} + const div = tag("div", { id: "root" }, content); + document.body.appendChild(div); + return div; +}; export const tag = (tagName, attrs, innerHTML) => { - const el = document.createElement(tagName) - el.innerHTML = innerHTML - for(const key in attrs){ el.setAttribute(key, attrs[key]) } - return el -} + const el = document.createElement(tagName); + el.innerHTML = innerHTML; + for (const key in attrs) { + el.setAttribute(key, attrs[key]); + } + return el; +}; export const simulateJoinedView = (el, liveSocket) => { - const view = new View(el, liveSocket) - stubChannel(view) - liveSocket.roots[view.id] = view - view.isConnected = () => true - view.onJoin({rendered: {s: [el.innerHTML]}, liveview_version}) - return view -} + const view = new View(el, liveSocket); + stubChannel(view); + liveSocket.roots[view.id] = view; + view.isConnected = () => true; + view.onJoin({ rendered: { s: [el.innerHTML] }, liveview_version }); + return view; +}; -export const simulateVisibility = el => { +export const simulateVisibility = (el) => { el.getClientRects = () => { - const style = window.getComputedStyle(el) - const visible = !(style.opacity === "0" || style.display === "none") - return visible ? {length: 1} : {length: 0} - } - return el -} + const style = window.getComputedStyle(el); + const visible = !(style.opacity === "0" || style.display === "none"); + return visible ? { length: 1 } : { length: 0 }; + }; + return el; +}; -export const stubChannel = view => { +export const stubChannel = (view) => { const fakePush = { receives: [], - receive(kind, cb){ - this.receives.push([kind, cb]) - return this - } - } - view.channel.push = () => fakePush -} + receive(kind, cb) { + this.receives.push([kind, cb]); + return this; + }, + }; + view.channel.push = () => fakePush; +}; -export function liveViewDOM(content?: string){ - const div = document.createElement("div") - div.setAttribute("data-phx-view", "User.Form") - div.setAttribute("data-phx-session", "abc123") - div.setAttribute("id", "container") - div.setAttribute("class", "user-implemented-class") - div.innerHTML = content || ` +export function liveViewDOM(content?: string) { + const div = document.createElement("div"); + div.setAttribute("data-phx-view", "User.Form"); + div.setAttribute("data-phx-session", "abc123"); + div.setAttribute("id", "container"); + div.setAttribute("class", "user-implemented-class"); + div.innerHTML = + content || + `
@@ -80,8 +92,8 @@ export function liveViewDOM(content?: string){ disconnected!
- ` - document.body.innerHTML = "" - document.body.appendChild(div) - return div + `; + document.body.innerHTML = ""; + document.body.appendChild(div); + return div; } diff --git a/assets/test/tsconfig.json b/assets/test/tsconfig.json index 66d193e19a..60e67ca824 100644 --- a/assets/test/tsconfig.json +++ b/assets/test/tsconfig.json @@ -7,17 +7,10 @@ "resolveJsonModule": true, "baseUrl": ".", "paths": { - "phoenix_live_view": [ - "../js/phoenix_live_view/index.ts" - ], - "phoenix_live_view*": [ - "../js/phoenix_live_view/*" - ] + "phoenix_live_view": ["../js/phoenix_live_view/index.ts"], + "phoenix_live_view*": ["../js/phoenix_live_view/*"] } }, - "include": [ - "./**/*" - ], - "exclude": [ - ] -} \ No newline at end of file + "include": ["./**/*"], + "exclude": [] +} diff --git a/assets/test/utils_test.ts b/assets/test/utils_test.ts index dade303ab9..d35951b63c 100644 --- a/assets/test/utils_test.ts +++ b/assets/test/utils_test.ts @@ -1,14 +1,14 @@ -import {Socket} from "phoenix" -import {closestPhxBinding} from "phoenix_live_view/utils" -import LiveSocket from "phoenix_live_view/live_socket" -import {simulateJoinedView, liveViewDOM} from "./test_helpers" +import { Socket } from "phoenix"; +import { closestPhxBinding } from "phoenix_live_view/utils"; +import LiveSocket from "phoenix_live_view/live_socket"; +import { simulateJoinedView, liveViewDOM } from "./test_helpers"; const setupView = (content) => { - const el = liveViewDOM(content) - global.document.body.appendChild(el) - const liveSocket = new LiveSocket("/live", Socket) - return simulateJoinedView(el, liveSocket) -} + const el = liveViewDOM(content); + global.document.body.appendChild(el); + const liveSocket = new LiveSocket("/live", Socket); + return simulateJoinedView(el, liveSocket); +}; describe("utils", () => { describe("closestPhxBinding", () => { @@ -17,21 +17,20 @@ describe("utils", () => { - `) - const element = global.document.querySelector("#innerContent") - const parent = global.document.querySelector("#button") - expect(closestPhxBinding(element, "phx-click")).toBe(parent) - }) + `); + const element = global.document.querySelector("#innerContent"); + const parent = global.document.querySelector("#button"); + expect(closestPhxBinding(element, "phx-click")).toBe(parent); + }); test("if an element's parent is disabled, return null", () => { const _view = setupView(` - `) - const element = global.document.querySelector("#innerContent") - expect(closestPhxBinding(element, "phx-click")).toBe(null) - }) - }) -}) - + `); + const element = global.document.querySelector("#innerContent"); + expect(closestPhxBinding(element, "phx-click")).toBe(null); + }); + }); +}); diff --git a/assets/test/view_test.ts b/assets/test/view_test.ts index e0786179df..aa8b263802 100644 --- a/assets/test/view_test.ts +++ b/assets/test/view_test.ts @@ -1,248 +1,282 @@ -import {Socket} from "phoenix" -import {createHook} from "phoenix_live_view/index" -import LiveSocket from "phoenix_live_view/live_socket" -import DOM from "phoenix_live_view/dom" -import View from "phoenix_live_view/view" -import ViewHook from "phoenix_live_view/view_hook" +import { Socket } from "phoenix"; +import { createHook } from "phoenix_live_view/index"; +import LiveSocket from "phoenix_live_view/live_socket"; +import DOM from "phoenix_live_view/dom"; +import View from "phoenix_live_view/view"; +import ViewHook from "phoenix_live_view/view_hook"; -import {version as liveview_version} from "../../package.json" +import { version as liveview_version } from "../../package.json"; import { PHX_LOADING_CLASS, PHX_ERROR_CLASS, PHX_SERVER_ERROR_CLASS, - PHX_HAS_FOCUSED -} from "phoenix_live_view/constants" + PHX_HAS_FOCUSED, +} from "phoenix_live_view/constants"; -import {tag, simulateJoinedView, stubChannel, rootContainer, liveViewDOM, simulateVisibility, appendTitle} from "./test_helpers" +import { + tag, + simulateJoinedView, + stubChannel, + rootContainer, + liveViewDOM, + simulateVisibility, + appendTitle, +} from "./test_helpers"; const simulateUsedInput = (input) => { - DOM.putPrivate(input, PHX_HAS_FOCUSED, true) -} + DOM.putPrivate(input, PHX_HAS_FOCUSED, true); +}; -describe("View + DOM", function(){ +describe("View + DOM", function () { beforeEach(() => { - submitBefore = HTMLFormElement.prototype.submit - global.Phoenix = {Socket} - global.document.body.innerHTML = liveViewDOM().outerHTML - }) + submitBefore = HTMLFormElement.prototype.submit; + global.Phoenix = { Socket }; + global.document.body.innerHTML = liveViewDOM().outerHTML; + }); afterAll(() => { - global.document.body.innerHTML = "" - }) + global.document.body.innerHTML = ""; + }); test("update", async () => { - const liveSocket = new LiveSocket("/live", Socket) - const el = liveViewDOM() + const liveSocket = new LiveSocket("/live", Socket); + const el = liveViewDOM(); const updateDiff = { s: ["

", "

"], - fingerprint: 123 - } + fingerprint: 123, + }; - const view = simulateJoinedView(el, liveSocket) - view.update(updateDiff, []) + const view = simulateJoinedView(el, liveSocket); + view.update(updateDiff, []); - expect(view.el.firstChild.tagName).toBe("H2") - expect(view.rendered.get()).toEqual(updateDiff) - }) + expect(view.el.firstChild.tagName).toBe("H2"); + expect(view.rendered.get()).toEqual(updateDiff); + }); test("applyDiff with empty title uses default if present", async () => { - appendTitle({}, "Foo") + appendTitle({}, "Foo"); - const titleEl = document.querySelector("title") - const liveSocket = new LiveSocket("/live", Socket) - const el = liveViewDOM() + const titleEl = document.querySelector("title"); + const liveSocket = new LiveSocket("/live", Socket); + const el = liveViewDOM(); const updateDiff = { s: ["

", "

"], fingerprint: 123, - t: "" - } - - const view = simulateJoinedView(el, liveSocket) - view.applyDiff("mount", updateDiff, ({diff, events}) => view.update(diff, events)) - - expect(view.el.firstChild.tagName).toBe("H2") - expect(view.rendered.get()).toEqual(updateDiff) - - await new Promise(requestAnimationFrame) - expect(document.title).toBe("Foo") - titleEl.setAttribute("data-default", "DEFAULT") - view.applyDiff("mount", updateDiff, ({diff, events}) => view.update(diff, events)) - await new Promise(requestAnimationFrame) - expect(document.title).toBe("DEFAULT") - }) - - test("pushWithReply", function(){ - expect.assertions(1) - - const liveSocket = new LiveSocket("/live", Socket) - const el = liveViewDOM() - - const view = simulateJoinedView(el, liveSocket) + t: "", + }; + + const view = simulateJoinedView(el, liveSocket); + view.applyDiff("mount", updateDiff, ({ diff, events }) => + view.update(diff, events), + ); + + expect(view.el.firstChild.tagName).toBe("H2"); + expect(view.rendered.get()).toEqual(updateDiff); + + await new Promise(requestAnimationFrame); + expect(document.title).toBe("Foo"); + titleEl.setAttribute("data-default", "DEFAULT"); + view.applyDiff("mount", updateDiff, ({ diff, events }) => + view.update(diff, events), + ); + await new Promise(requestAnimationFrame); + expect(document.title).toBe("DEFAULT"); + }); + + test("pushWithReply", function () { + expect.assertions(1); + + const liveSocket = new LiveSocket("/live", Socket); + const el = liveViewDOM(); + + const view = simulateJoinedView(el, liveSocket); const channelStub = { - push(_evt, payload, _timeout){ - expect(payload.value).toBe("increment=1") + push(_evt, payload, _timeout) { + expect(payload.value).toBe("increment=1"); return { - receive(){ return this } - } - } - } - view.channel = channelStub + receive() { + return this; + }, + }; + }, + }; + view.channel = channelStub; - view.pushWithReply(null, {target: el.querySelector("form")}, {value: "increment=1"}) - }) + view.pushWithReply( + null, + { target: el.querySelector("form") }, + { value: "increment=1" }, + ); + }); - test("pushWithReply with update", function(){ - const liveSocket = new LiveSocket("/live", Socket) - const el = liveViewDOM() + test("pushWithReply with update", function () { + const liveSocket = new LiveSocket("/live", Socket); + const el = liveViewDOM(); - const view = simulateJoinedView(el, liveSocket) + const view = simulateJoinedView(el, liveSocket); const channelStub = { - leave(){ + leave() { return { - receive(_status, _cb){ return this } - } + receive(_status, _cb) { + return this; + }, + }; }, - push(_evt, payload, _timeout){ - expect(payload.value).toBe("increment=1") + push(_evt, payload, _timeout) { + expect(payload.value).toBe("increment=1"); return { - receive(_status, cb){ + receive(_status, cb) { const diff = { s: ["

", "

"], - fingerprint: 123 - } - cb(diff) - return this - } - } - } - } - view.channel = channelStub + fingerprint: 123, + }; + cb(diff); + return this; + }, + }; + }, + }; + view.channel = channelStub; - view.pushWithReply(null, {target: el.querySelector("form")}, {value: "increment=1"}) + view.pushWithReply( + null, + { target: el.querySelector("form") }, + { value: "increment=1" }, + ); - expect(view.el.querySelector("form")).toBeTruthy() - }) + expect(view.el.querySelector("form")).toBeTruthy(); + }); - test("pushEvent", function(){ - expect.assertions(3) + test("pushEvent", function () { + expect.assertions(3); - const liveSocket = new LiveSocket("/live", Socket) - const el = liveViewDOM() - const input = el.querySelector("input") + const liveSocket = new LiveSocket("/live", Socket); + const el = liveViewDOM(); + const input = el.querySelector("input"); - const view = simulateJoinedView(el, liveSocket) + const view = simulateJoinedView(el, liveSocket); const channelStub = { - push(_evt, payload, _timeout){ - expect(payload.type).toBe("keyup") - expect(payload.event).toBeDefined() - expect(payload.value).toEqual({"value": "1"}) + push(_evt, payload, _timeout) { + expect(payload.type).toBe("keyup"); + expect(payload.event).toBeDefined(); + expect(payload.value).toEqual({ value: "1" }); return { - receive(){ return this } - } - } - } - view.channel = channelStub + receive() { + return this; + }, + }; + }, + }; + view.channel = channelStub; - view.pushEvent("keyup", input, el, "click", {}) - }) + view.pushEvent("keyup", input, el, "click", {}); + }); - test("pushEvent as checkbox not checked", function(){ - expect.assertions(1) + test("pushEvent as checkbox not checked", function () { + expect.assertions(1); - const liveSocket = new LiveSocket("/live", Socket) - const el = liveViewDOM() - const input = el.querySelector("input[type=\"checkbox\"]") + const liveSocket = new LiveSocket("/live", Socket); + const el = liveViewDOM(); + const input = el.querySelector('input[type="checkbox"]'); - const view = simulateJoinedView(el, liveSocket) + const view = simulateJoinedView(el, liveSocket); const channelStub = { - push(_evt, payload, _timeout){ - expect(payload.value).toEqual({}) + push(_evt, payload, _timeout) { + expect(payload.value).toEqual({}); return { - receive(){ return this } - } - } - } - view.channel = channelStub + receive() { + return this; + }, + }; + }, + }; + view.channel = channelStub; - view.pushEvent("click", input, el, "toggle_me", {}) - }) + view.pushEvent("click", input, el, "toggle_me", {}); + }); - test("pushEvent as checkbox when checked", function(){ - expect.assertions(1) + test("pushEvent as checkbox when checked", function () { + expect.assertions(1); - const liveSocket = new LiveSocket("/live", Socket) - const el = liveViewDOM() - const input: HTMLInputElement = el.querySelector("input[type=\"checkbox\"]") - const view = simulateJoinedView(el, liveSocket) + const liveSocket = new LiveSocket("/live", Socket); + const el = liveViewDOM(); + const input: HTMLInputElement = el.querySelector('input[type="checkbox"]'); + const view = simulateJoinedView(el, liveSocket); - input.checked = true + input.checked = true; const channelStub = { - push(_evt, payload, _timeout){ - expect(payload.value).toEqual({"value": "on"}) + push(_evt, payload, _timeout) { + expect(payload.value).toEqual({ value: "on" }); return { - receive(){ return this } - } - } - } - view.channel = channelStub + receive() { + return this; + }, + }; + }, + }; + view.channel = channelStub; - view.pushEvent("click", input, el, "toggle_me", {}) - }) + view.pushEvent("click", input, el, "toggle_me", {}); + }); - test("pushEvent as checkbox with value", function(){ - expect.assertions(1) + test("pushEvent as checkbox with value", function () { + expect.assertions(1); - const liveSocket = new LiveSocket("/live", Socket) - const el = liveViewDOM() - const input: HTMLInputElement = el.querySelector("input[type=\"checkbox\"]") - const view = simulateJoinedView(el, liveSocket) + const liveSocket = new LiveSocket("/live", Socket); + const el = liveViewDOM(); + const input: HTMLInputElement = el.querySelector('input[type="checkbox"]'); + const view = simulateJoinedView(el, liveSocket); - input.value = "1" - input.checked = true + input.value = "1"; + input.checked = true; const channelStub = { - push(_evt, payload, _timeout){ - expect(payload.value).toEqual({"value": "1"}) + push(_evt, payload, _timeout) { + expect(payload.value).toEqual({ value: "1" }); return { - receive(){ return this } - } - } - } - view.channel = channelStub + receive() { + return this; + }, + }; + }, + }; + view.channel = channelStub; - view.pushEvent("click", input, el, "toggle_me", {}) - }) + view.pushEvent("click", input, el, "toggle_me", {}); + }); - test("pushInput", function(){ - expect.assertions(4) + test("pushInput", function () { + expect.assertions(4); - const liveSocket = new LiveSocket("/live", Socket) - const el = liveViewDOM() - const input = el.querySelector("input") - simulateUsedInput(input) - const view = simulateJoinedView(el, liveSocket) + const liveSocket = new LiveSocket("/live", Socket); + const el = liveViewDOM(); + const input = el.querySelector("input"); + simulateUsedInput(input); + const view = simulateJoinedView(el, liveSocket); const channelStub = { - push(_evt, payload, _timeout){ - expect(payload.type).toBe("form") - expect(payload.event).toBeDefined() - expect(payload.value).toBe("increment=1&_unused_note=¬e=2") - expect(payload.meta).toEqual({"_target": "increment"}) + push(_evt, payload, _timeout) { + expect(payload.type).toBe("form"); + expect(payload.event).toBeDefined(); + expect(payload.value).toBe("increment=1&_unused_note=¬e=2"); + expect(payload.meta).toEqual({ _target: "increment" }); return { - receive(){ return this } - } - } - } - view.channel = channelStub + receive() { + return this; + }, + }; + }, + }; + view.channel = channelStub; - view.pushInput(input, el, null, "validate", {_target: input.name}) - }) + view.pushInput(input, el, null, "validate", { _target: input.name }); + }); - test("pushInput with with phx-value and JS command value", function(){ - expect.assertions(4) + test("pushInput with with phx-value and JS command value", function () { + expect.assertions(4); - const liveSocket = new LiveSocket("/live", Socket) + const liveSocket = new LiveSocket("/live", Socket); const el = liveViewDOM(`
@@ -251,122 +285,137 @@ describe("View + DOM", function(){
- `) - const input = el.querySelector("input") - simulateUsedInput(input) - const view = simulateJoinedView(el, liveSocket) + `); + const input = el.querySelector("input"); + simulateUsedInput(input); + const view = simulateJoinedView(el, liveSocket); const channelStub = { - push(_evt, payload, _timeout){ - expect(payload.type).toBe("form") - expect(payload.event).toBeDefined() - expect(payload.value).toBe("increment=1&_unused_note=¬e=2") + push(_evt, payload, _timeout) { + expect(payload.type).toBe("form"); + expect(payload.event).toBeDefined(); + expect(payload.value).toBe("increment=1&_unused_note=¬e=2"); expect(payload.meta).toEqual({ - "_target": "increment", - "attribute_value": "attribute", - "nested": { - "command_value": "command", - "array": [1, 2] - } - }) + _target: "increment", + attribute_value: "attribute", + nested: { + command_value: "command", + array: [1, 2], + }, + }); return { - receive(){ return this } - } - } - } - view.channel = channelStub - const optValue = {nested: {command_value: "command", array: [1, 2]}} - view.pushInput(input, el, null, "validate", {_target: input.name, value: optValue}) - }) - - test("pushInput with nameless input", function(){ - expect.assertions(4) - - const liveSocket = new LiveSocket("/live", Socket) - const el = liveViewDOM() - const input = el.querySelector("input") - input.removeAttribute("name") - simulateUsedInput(input) - const view = simulateJoinedView(el, liveSocket) + receive() { + return this; + }, + }; + }, + }; + view.channel = channelStub; + const optValue = { nested: { command_value: "command", array: [1, 2] } }; + view.pushInput(input, el, null, "validate", { + _target: input.name, + value: optValue, + }); + }); + + test("pushInput with nameless input", function () { + expect.assertions(4); + + const liveSocket = new LiveSocket("/live", Socket); + const el = liveViewDOM(); + const input = el.querySelector("input"); + input.removeAttribute("name"); + simulateUsedInput(input); + const view = simulateJoinedView(el, liveSocket); const channelStub = { - push(_evt, payload, _timeout){ - expect(payload.type).toBe("form") - expect(payload.event).toBeDefined() - expect(payload.value).toBe("_unused_note=¬e=2") - expect(payload.meta).toEqual({"_target": "undefined"}) + push(_evt, payload, _timeout) { + expect(payload.type).toBe("form"); + expect(payload.event).toBeDefined(); + expect(payload.value).toBe("_unused_note=¬e=2"); + expect(payload.meta).toEqual({ _target: "undefined" }); return { - receive(){ return this } - } - } - } - view.channel = channelStub - - view.pushInput(input, el, null, "validate", {_target: input.name}) - }) - - test("getFormsForRecovery", function(){ - let view, html, liveSocket = new LiveSocket("/live", Socket) - - html = "
" - view = new View(liveViewDOM(html), liveSocket) - expect(view.joinCount).toBe(0) - expect(Object.keys(view.getFormsForRecovery()).length).toBe(0) - - view.joinCount++ - expect(Object.keys(view.getFormsForRecovery()).length).toBe(1) - - view.joinCount++ - expect(Object.keys(view.getFormsForRecovery()).length).toBe(1) - - html = "
" - view = new View(liveViewDOM(html), liveSocket) - view.joinCount = 2 - expect(Object.keys(view.getFormsForRecovery()).length).toBe(0) - - html = "
" - view = new View(liveViewDOM(), liveSocket) - view.joinCount = 2 - expect(Object.keys(view.getFormsForRecovery()).length).toBe(0) - - html = "
" - view = new View(liveViewDOM(html), liveSocket) - view.joinCount = 2 - expect(Object.keys(view.getFormsForRecovery()).length).toBe(0) - - html = "
" - view = new View(liveViewDOM(html), liveSocket) - view.joinCount = 1 - const newForms = view.getFormsForRecovery() - expect(Object.keys(newForms).length).toBe(1) - expect(newForms["my-form"].getAttribute("phx-change")).toBe("[[\"push\",{\"event\":\"update\",\"target\":1}]]") - }) - - describe("submitForm", function(){ - test("submits payload", function(){ - expect.assertions(3) - - const liveSocket = new LiveSocket("/live", Socket) - const el = liveViewDOM() - const form = el.querySelector("form") - - const view = simulateJoinedView(el, liveSocket) + receive() { + return this; + }, + }; + }, + }; + view.channel = channelStub; + + view.pushInput(input, el, null, "validate", { _target: input.name }); + }); + + test("getFormsForRecovery", function () { + let view, + html, + liveSocket = new LiveSocket("/live", Socket); + + html = '
'; + view = new View(liveViewDOM(html), liveSocket); + expect(view.joinCount).toBe(0); + expect(Object.keys(view.getFormsForRecovery()).length).toBe(0); + + view.joinCount++; + expect(Object.keys(view.getFormsForRecovery()).length).toBe(1); + + view.joinCount++; + expect(Object.keys(view.getFormsForRecovery()).length).toBe(1); + + html = + '
'; + view = new View(liveViewDOM(html), liveSocket); + view.joinCount = 2; + expect(Object.keys(view.getFormsForRecovery()).length).toBe(0); + + html = '
'; + view = new View(liveViewDOM(), liveSocket); + view.joinCount = 2; + expect(Object.keys(view.getFormsForRecovery()).length).toBe(0); + + html = '
'; + view = new View(liveViewDOM(html), liveSocket); + view.joinCount = 2; + expect(Object.keys(view.getFormsForRecovery()).length).toBe(0); + + html = + '
'; + view = new View(liveViewDOM(html), liveSocket); + view.joinCount = 1; + const newForms = view.getFormsForRecovery(); + expect(Object.keys(newForms).length).toBe(1); + expect(newForms["my-form"].getAttribute("phx-change")).toBe( + '[["push",{"event":"update","target":1}]]', + ); + }); + + describe("submitForm", function () { + test("submits payload", function () { + expect.assertions(3); + + const liveSocket = new LiveSocket("/live", Socket); + const el = liveViewDOM(); + const form = el.querySelector("form"); + + const view = simulateJoinedView(el, liveSocket); const channelStub = { - push(_evt, payload, _timeout){ - expect(payload.type).toBe("form") - expect(payload.event).toBeDefined() - expect(payload.value).toBe("increment=1¬e=2") + push(_evt, payload, _timeout) { + expect(payload.type).toBe("form"); + expect(payload.event).toBeDefined(); + expect(payload.value).toBe("increment=1¬e=2"); return { - receive(){ return this } - } - } - } - view.channel = channelStub - view.submitForm(form, form, {target: form}) - }) + receive() { + return this; + }, + }; + }, + }; + view.channel = channelStub; + view.submitForm(form, form, { target: form }); + }); - test("payload includes phx-value and JS command value", function(){ - expect.assertions(4) + test("payload includes phx-value and JS command value", function () { + expect.assertions(4); - const liveSocket = new LiveSocket("/live", Socket) + const liveSocket = new LiveSocket("/live", Socket); const el = liveViewDOM(`
@@ -375,103 +424,118 @@ describe("View + DOM", function(){
- `) - const form = el.querySelector("form") + `); + const form = el.querySelector("form"); - const view = simulateJoinedView(el, liveSocket) + const view = simulateJoinedView(el, liveSocket); const channelStub = { - push(_evt, payload, _timeout){ - expect(payload.type).toBe("form") - expect(payload.event).toBeDefined() - expect(payload.value).toBe("increment=1¬e=2") + push(_evt, payload, _timeout) { + expect(payload.type).toBe("form"); + expect(payload.event).toBeDefined(); + expect(payload.value).toBe("increment=1¬e=2"); expect(payload.meta).toEqual({ - "attribute_value": "attribute", - "nested": { - "command_value": "command", - "array": [1, 2] - } - }) + attribute_value: "attribute", + nested: { + command_value: "command", + array: [1, 2], + }, + }); return { - receive(){ return this } - } - } - } - view.channel = channelStub - const opts = {value: {nested: {command_value: "command", array: [1, 2]}}} - view.submitForm(form, form, {target: form}, undefined, opts) - }) - - test("payload includes submitter when name is provided", function(){ - const btn = document.createElement("button") - btn.setAttribute("type", "submit") - btn.setAttribute("name", "btnName") - btn.setAttribute("value", "btnValue") - submitWithButton(btn, "increment=1¬e=2&btnName=btnValue") - }) - - test("payload includes submitter when name is provided (submitter outside form)", function(){ - const btn = document.createElement("button") - btn.setAttribute("form", "my-form") - btn.setAttribute("type", "submit") - btn.setAttribute("name", "btnName") - btn.setAttribute("value", "btnValue") - submitWithButton(btn, "increment=1¬e=2&btnName=btnValue", document.body) - }) - - test("payload does not include submitter when name is not provided", function(){ - const btn = document.createElement("button") - btn.setAttribute("type", "submit") - btn.setAttribute("value", "btnValue") - submitWithButton(btn, "increment=1¬e=2") - }) - - function submitWithButton(btn, queryString, appendTo?: HTMLElement, opts={}){ - const liveSocket = new LiveSocket("/live", Socket) - const el = liveViewDOM() - const form = el.querySelector("form") - if(appendTo){ - appendTo.appendChild(btn) + receive() { + return this; + }, + }; + }, + }; + view.channel = channelStub; + const opts = { + value: { nested: { command_value: "command", array: [1, 2] } }, + }; + view.submitForm(form, form, { target: form }, undefined, opts); + }); + + test("payload includes submitter when name is provided", function () { + const btn = document.createElement("button"); + btn.setAttribute("type", "submit"); + btn.setAttribute("name", "btnName"); + btn.setAttribute("value", "btnValue"); + submitWithButton(btn, "increment=1¬e=2&btnName=btnValue"); + }); + + test("payload includes submitter when name is provided (submitter outside form)", function () { + const btn = document.createElement("button"); + btn.setAttribute("form", "my-form"); + btn.setAttribute("type", "submit"); + btn.setAttribute("name", "btnName"); + btn.setAttribute("value", "btnValue"); + submitWithButton( + btn, + "increment=1¬e=2&btnName=btnValue", + document.body, + ); + }); + + test("payload does not include submitter when name is not provided", function () { + const btn = document.createElement("button"); + btn.setAttribute("type", "submit"); + btn.setAttribute("value", "btnValue"); + submitWithButton(btn, "increment=1¬e=2"); + }); + + function submitWithButton( + btn, + queryString, + appendTo?: HTMLElement, + opts = {}, + ) { + const liveSocket = new LiveSocket("/live", Socket); + const el = liveViewDOM(); + const form = el.querySelector("form"); + if (appendTo) { + appendTo.appendChild(btn); } else { - form.appendChild(btn) + form.appendChild(btn); } - const view = simulateJoinedView(el, liveSocket) + const view = simulateJoinedView(el, liveSocket); const channelStub = { - push(_evt, payload, _timeout){ - expect(payload.type).toBe("form") - expect(payload.event).toBeDefined() - expect(payload.value).toBe(queryString) + push(_evt, payload, _timeout) { + expect(payload.type).toBe("form"); + expect(payload.event).toBeDefined(); + expect(payload.value).toBe(queryString); return { - receive(){ return this } - } - } - } + receive() { + return this; + }, + }; + }, + }; - view.channel = channelStub - view.submitForm(form, form, {target: form}, btn, opts) + view.channel = channelStub; + view.submitForm(form, form, { target: form }, btn, opts); } - test("disables elements after submission", function(){ - const liveSocket = new LiveSocket("/live", Socket) - const el = liveViewDOM() - const form = el.querySelector("form") - - const view = simulateJoinedView(el, liveSocket) - stubChannel(view) - - view.submitForm(form, form, {target: form}) - expect(DOM.private(form, "phx-has-submitted")).toBeTruthy() - Array.from(form.elements).forEach(input => { - expect(DOM.private(input, "phx-has-submitted")).toBeTruthy() - }) - expect(form.classList.contains("phx-submit-loading")).toBeTruthy() - expect(form.querySelector("button").dataset.phxDisabled).toBeTruthy() - expect(form.querySelector("input").dataset.phxReadonly).toBeTruthy() - expect(form.querySelector("textarea").dataset.phxReadonly).toBeTruthy() - }) - - test("disables elements outside form", function(){ - const liveSocket = new LiveSocket("/live", Socket) + test("disables elements after submission", function () { + const liveSocket = new LiveSocket("/live", Socket); + const el = liveViewDOM(); + const form = el.querySelector("form"); + + const view = simulateJoinedView(el, liveSocket); + stubChannel(view); + + view.submitForm(form, form, { target: form }); + expect(DOM.private(form, "phx-has-submitted")).toBeTruthy(); + Array.from(form.elements).forEach((input) => { + expect(DOM.private(input, "phx-has-submitted")).toBeTruthy(); + }); + expect(form.classList.contains("phx-submit-loading")).toBeTruthy(); + expect(form.querySelector("button").dataset.phxDisabled).toBeTruthy(); + expect(form.querySelector("input").dataset.phxReadonly).toBeTruthy(); + expect(form.querySelector("textarea").dataset.phxReadonly).toBeTruthy(); + }); + + test("disables elements outside form", function () { + const liveSocket = new LiveSocket("/live", Socket); const el = liveViewDOM(`
@@ -480,738 +544,879 @@ describe("View + DOM", function(){ - `) - const form = el.querySelector("form") - - const view = simulateJoinedView(el, liveSocket) - stubChannel(view) - - view.submitForm(form, form, {target: form}) - expect(DOM.private(form, "phx-has-submitted")).toBeTruthy() - expect(form.classList.contains("phx-submit-loading")).toBeTruthy() - expect(el.querySelector("button").dataset.phxDisabled).toBeTruthy() - expect(el.querySelector("input").dataset.phxReadonly).toBeTruthy() - expect(el.querySelector("textarea").dataset.phxReadonly).toBeTruthy() - }) - - test("disables elements", function(){ - const liveSocket = new LiveSocket("/live", Socket) + `); + const form = el.querySelector("form"); + + const view = simulateJoinedView(el, liveSocket); + stubChannel(view); + + view.submitForm(form, form, { target: form }); + expect(DOM.private(form, "phx-has-submitted")).toBeTruthy(); + expect(form.classList.contains("phx-submit-loading")).toBeTruthy(); + expect(el.querySelector("button").dataset.phxDisabled).toBeTruthy(); + expect(el.querySelector("input").dataset.phxReadonly).toBeTruthy(); + expect(el.querySelector("textarea").dataset.phxReadonly).toBeTruthy(); + }); + + test("disables elements", function () { + const liveSocket = new LiveSocket("/live", Socket); const el = liveViewDOM(` - `) - const button = el.querySelector("button") + `); + const button = el.querySelector("button"); - const view = simulateJoinedView(el, liveSocket) - stubChannel(view) + const view = simulateJoinedView(el, liveSocket); + stubChannel(view); - expect(button.disabled).toEqual(false) - view.pushEvent("click", button, el, "inc", {}) - expect(button.disabled).toEqual(true) - }) - }) + expect(button.disabled).toEqual(false); + view.pushEvent("click", button, el, "inc", {}); + expect(button.disabled).toEqual(true); + }); + }); describe("phx-trigger-action", () => { test("triggers external submit on updated DOM el", (done) => { - const liveSocket = new LiveSocket("/live", Socket) - const el = liveViewDOM() - const view = simulateJoinedView(el, liveSocket) - const html = "
" - - stubChannel(view) - view.onJoin({rendered: {s: [html], fingerprint: 123}, liveview_version}) - expect(view.el.innerHTML).toBe(html) - - const formEl = document.getElementById("form") - Object.getPrototypeOf(formEl).submit = done - const updatedHtml = "
" - view.update({s: [updatedHtml]}, []) - - expect(liveSocket.socket.closeWasClean).toBe(true) - expect(view.el.innerHTML).toBe("
") - }) + const liveSocket = new LiveSocket("/live", Socket); + const el = liveViewDOM(); + const view = simulateJoinedView(el, liveSocket); + const html = + '
'; + + stubChannel(view); + view.onJoin({ + rendered: { s: [html], fingerprint: 123 }, + liveview_version, + }); + expect(view.el.innerHTML).toBe(html); + + const formEl = document.getElementById("form"); + Object.getPrototypeOf(formEl).submit = done; + const updatedHtml = + '
'; + view.update({ s: [updatedHtml] }, []); + + expect(liveSocket.socket.closeWasClean).toBe(true); + expect(view.el.innerHTML).toBe( + '
', + ); + }); test("triggers external submit on added DOM el", (done) => { - const liveSocket = new LiveSocket("/live", Socket) - const el = liveViewDOM() - const view = simulateJoinedView(el, liveSocket) - const html = "
not a form
" - HTMLFormElement.prototype.submit = done - - stubChannel(view) - view.onJoin({rendered: {s: [html], fingerprint: 123}, liveview_version}) - expect(view.el.innerHTML).toBe(html) - - const updatedHtml = "
" - view.update({s: [updatedHtml]}, []) - - expect(liveSocket.socket.closeWasClean).toBe(true) - expect(view.el.innerHTML).toBe("
") - }) - }) - - describe("phx-update", function(){ - const childIds = () => Array.from(document.getElementById("list").children).map(child => parseInt(child.id)) - const countChildNodes = () => document.getElementById("list").childNodes.length + const liveSocket = new LiveSocket("/live", Socket); + const el = liveViewDOM(); + const view = simulateJoinedView(el, liveSocket); + const html = "
not a form
"; + HTMLFormElement.prototype.submit = done; + + stubChannel(view); + view.onJoin({ + rendered: { s: [html], fingerprint: 123 }, + liveview_version, + }); + expect(view.el.innerHTML).toBe(html); + + const updatedHtml = + '
'; + view.update({ s: [updatedHtml] }, []); + + expect(liveSocket.socket.closeWasClean).toBe(true); + expect(view.el.innerHTML).toBe( + '
', + ); + }); + }); + + describe("phx-update", function () { + const childIds = () => + Array.from(document.getElementById("list").children).map((child) => + parseInt(child.id), + ); + const countChildNodes = () => + document.getElementById("list").childNodes.length; const createView = (updateType, initialDynamics) => { - const liveSocket = new LiveSocket("/live", Socket) - const el = liveViewDOM() - const view = simulateJoinedView(el, liveSocket) + const liveSocket = new LiveSocket("/live", Socket); + const el = liveViewDOM(); + const view = simulateJoinedView(el, liveSocket); - stubChannel(view) + stubChannel(view); const joinDiff = { - "0": {"d": initialDynamics, "s": ["\n
", "
\n"]}, - "s": [`
`, "
"] - } + "0": { d: initialDynamics, s: ['\n
', "
\n"] }, + s: [`
`, "
"], + }; - view.onJoin({rendered: joinDiff, liveview_version}) + view.onJoin({ rendered: joinDiff, liveview_version }); - return view - } + return view; + }; const updateDynamics = (view, dynamics) => { const updateDiff = { "0": { - "d": dynamics - } - } + d: dynamics, + }, + }; - view.update(updateDiff, []) - } + view.update(updateDiff, []); + }; test("replace", async () => { - const view = createView("replace", [["1", "1"]]) - expect(childIds()).toEqual([1]) + const view = createView("replace", [["1", "1"]]); + expect(childIds()).toEqual([1]); - updateDynamics(view, - [["2", "2"], ["3", "3"]] - ) - expect(childIds()).toEqual([2, 3]) - }) + updateDynamics(view, [ + ["2", "2"], + ["3", "3"], + ]); + expect(childIds()).toEqual([2, 3]); + }); test("append", async () => { - const view = createView("append", [["1", "1"]]) - expect(childIds()).toEqual([1]) + const view = createView("append", [["1", "1"]]); + expect(childIds()).toEqual([1]); // Append two elements - updateDynamics(view, - [["2", "2"], ["3", "3"]] - ) - expect(childIds()).toEqual([1, 2, 3]) + updateDynamics(view, [ + ["2", "2"], + ["3", "3"], + ]); + expect(childIds()).toEqual([1, 2, 3]); // Update the last element - updateDynamics(view, - [["3", "3"]] - ) - expect(childIds()).toEqual([1, 2, 3]) + updateDynamics(view, [["3", "3"]]); + expect(childIds()).toEqual([1, 2, 3]); // Update the first element - updateDynamics(view, - [["1", "1"]] - ) - expect(childIds()).toEqual([1, 2, 3]) + updateDynamics(view, [["1", "1"]]); + expect(childIds()).toEqual([1, 2, 3]); // Update before new elements - updateDynamics(view, - [["4", "4"], ["5", "5"]] - ) - expect(childIds()).toEqual([1, 2, 3, 4, 5]) + updateDynamics(view, [ + ["4", "4"], + ["5", "5"], + ]); + expect(childIds()).toEqual([1, 2, 3, 4, 5]); // Update after new elements - updateDynamics(view, - [["6", "6"], ["7", "7"], ["5", "modified"]] - ) - expect(childIds()).toEqual([1, 2, 3, 4, 5, 6, 7]) + updateDynamics(view, [ + ["6", "6"], + ["7", "7"], + ["5", "modified"], + ]); + expect(childIds()).toEqual([1, 2, 3, 4, 5, 6, 7]); // Sandwich an update between two new elements - updateDynamics(view, - [["8", "8"], ["7", "modified"], ["9", "9"]] - ) - expect(childIds()).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9]) + updateDynamics(view, [ + ["8", "8"], + ["7", "modified"], + ["9", "9"], + ]); + expect(childIds()).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9]); // Update all elements in reverse order - updateDynamics(view, - [["9", "9"], ["8", "8"], ["7", "7"], ["6", "6"], ["5", "5"], ["4", "4"], ["3", "3"], ["2", "2"], ["1", "1"]] - ) - expect(childIds()).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9]) + updateDynamics(view, [ + ["9", "9"], + ["8", "8"], + ["7", "7"], + ["6", "6"], + ["5", "5"], + ["4", "4"], + ["3", "3"], + ["2", "2"], + ["1", "1"], + ]); + expect(childIds()).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9]); // Make sure we don't have a memory leak when doing updates - const initialCount = countChildNodes() - updateDynamics(view, - [["1", "1"], ["2", "2"], ["3", "3"]] - ) - updateDynamics(view, - [["1", "1"], ["2", "2"], ["3", "3"]] - ) - updateDynamics(view, - [["1", "1"], ["2", "2"], ["3", "3"]] - ) - updateDynamics(view, - [["1", "1"], ["2", "2"], ["3", "3"]] - ) - - expect(countChildNodes()).toBe(initialCount) - }) + const initialCount = countChildNodes(); + updateDynamics(view, [ + ["1", "1"], + ["2", "2"], + ["3", "3"], + ]); + updateDynamics(view, [ + ["1", "1"], + ["2", "2"], + ["3", "3"], + ]); + updateDynamics(view, [ + ["1", "1"], + ["2", "2"], + ["3", "3"], + ]); + updateDynamics(view, [ + ["1", "1"], + ["2", "2"], + ["3", "3"], + ]); + + expect(countChildNodes()).toBe(initialCount); + }); test("prepend", async () => { - const view = createView("prepend", [["1", "1"]]) - expect(childIds()).toEqual([1]) + const view = createView("prepend", [["1", "1"]]); + expect(childIds()).toEqual([1]); // Append two elements - updateDynamics(view, - [["2", "2"], ["3", "3"]] - ) - expect(childIds()).toEqual([2, 3, 1]) + updateDynamics(view, [ + ["2", "2"], + ["3", "3"], + ]); + expect(childIds()).toEqual([2, 3, 1]); // Update the last element - updateDynamics(view, - [["3", "3"]] - ) - expect(childIds()).toEqual([2, 3, 1]) + updateDynamics(view, [["3", "3"]]); + expect(childIds()).toEqual([2, 3, 1]); // Update the first element - updateDynamics(view, - [["1", "1"]] - ) - expect(childIds()).toEqual([2, 3, 1]) + updateDynamics(view, [["1", "1"]]); + expect(childIds()).toEqual([2, 3, 1]); // Update before new elements - updateDynamics(view, - [["4", "4"], ["5", "5"]] - ) - expect(childIds()).toEqual([4, 5, 2, 3, 1]) + updateDynamics(view, [ + ["4", "4"], + ["5", "5"], + ]); + expect(childIds()).toEqual([4, 5, 2, 3, 1]); // Update after new elements - updateDynamics(view, - [["6", "6"], ["7", "7"], ["5", "modified"]] - ) - expect(childIds()).toEqual([6, 7, 4, 5, 2, 3, 1]) + updateDynamics(view, [ + ["6", "6"], + ["7", "7"], + ["5", "modified"], + ]); + expect(childIds()).toEqual([6, 7, 4, 5, 2, 3, 1]); // Sandwich an update between two new elements - updateDynamics(view, - [["8", "8"], ["7", "modified"], ["9", "9"]] - ) - expect(childIds()).toEqual([8, 9, 6, 7, 4, 5, 2, 3, 1]) + updateDynamics(view, [ + ["8", "8"], + ["7", "modified"], + ["9", "9"], + ]); + expect(childIds()).toEqual([8, 9, 6, 7, 4, 5, 2, 3, 1]); // Update all elements in reverse order - updateDynamics(view, - [["1", "1"], ["3", "3"], ["2", "2"], ["5", "5"], ["4", "4"], ["7", "7"], ["6", "6"], ["9", "9"], ["8", "8"]] - ) - expect(childIds()).toEqual([8, 9, 6, 7, 4, 5, 2, 3, 1]) + updateDynamics(view, [ + ["1", "1"], + ["3", "3"], + ["2", "2"], + ["5", "5"], + ["4", "4"], + ["7", "7"], + ["6", "6"], + ["9", "9"], + ["8", "8"], + ]); + expect(childIds()).toEqual([8, 9, 6, 7, 4, 5, 2, 3, 1]); // Make sure we don't have a memory leak when doing updates - const initialCount = countChildNodes() - updateDynamics(view, - [["1", "1"], ["2", "2"], ["3", "3"]] - ) - updateDynamics(view, - [["1", "1"], ["2", "2"], ["3", "3"]] - ) - updateDynamics(view, - [["1", "1"], ["2", "2"], ["3", "3"]] - ) - updateDynamics(view, - [["1", "1"], ["2", "2"], ["3", "3"]] - ) - - expect(countChildNodes()).toBe(initialCount) - }) + const initialCount = countChildNodes(); + updateDynamics(view, [ + ["1", "1"], + ["2", "2"], + ["3", "3"], + ]); + updateDynamics(view, [ + ["1", "1"], + ["2", "2"], + ["3", "3"], + ]); + updateDynamics(view, [ + ["1", "1"], + ["2", "2"], + ["3", "3"], + ]); + updateDynamics(view, [ + ["1", "1"], + ["2", "2"], + ["3", "3"], + ]); + + expect(countChildNodes()).toBe(initialCount); + }); test("ignore", async () => { - const view = createView("ignore", [["1", "1"]]) - expect(childIds()).toEqual([1]) + const view = createView("ignore", [["1", "1"]]); + expect(childIds()).toEqual([1]); // Append two elements - updateDynamics(view, - [["2", "2"], ["3", "3"]] - ) - expect(childIds()).toEqual([1]) - }) - }) + updateDynamics(view, [ + ["2", "2"], + ["3", "3"], + ]); + expect(childIds()).toEqual([1]); + }); + }); describe("JS integration", () => { test("ignore_attributes skips attributes on update", () => { - let liveSocket = new LiveSocket("/live", Socket) - let el = liveViewDOM() + let liveSocket = new LiveSocket("/live", Socket); + let el = liveViewDOM(); let updateDiff = { - "0": " phx-mounted=\"[["ignore_attrs",{"attrs":["open"]}]]\"", + "0": ' phx-mounted="[["ignore_attrs",{"attrs":["open"]}]]"', "1": "0", - "s": [ + s: [ "\n A\n ", - "" - ] - } + "", + ], + }; - let view = simulateJoinedView(el, liveSocket) - view.applyDiff("update", updateDiff, ({diff, events}) => view.update(diff, events)) + let view = simulateJoinedView(el, liveSocket); + view.applyDiff("update", updateDiff, ({ diff, events }) => + view.update(diff, events), + ); - expect(view.el.firstChild.tagName).toBe("DETAILS") - expect(view.el.firstChild.open).toBe(false) - view.el.firstChild.open = true - view.el.firstChild.setAttribute("data-foo", "bar") + expect(view.el.firstChild.tagName).toBe("DETAILS"); + expect(view.el.firstChild.open).toBe(false); + view.el.firstChild.open = true; + view.el.firstChild.setAttribute("data-foo", "bar"); // now update, the HTML patch would normally reset the open attribute - view.applyDiff("update", {"1": "1"}, ({diff, events}) => view.update(diff, events)) + view.applyDiff("update", { "1": "1" }, ({ diff, events }) => + view.update(diff, events), + ); // open is ignored, so it is kept as is - expect(view.el.firstChild.open).toBe(true) + expect(view.el.firstChild.open).toBe(true); // foo is not ignored, so it is reset - expect(view.el.firstChild.getAttribute("data-foo")).toBe(null) - expect(view.el.firstChild.textContent.replace(/\s+/g, "")).toEqual("A1") - }) + expect(view.el.firstChild.getAttribute("data-foo")).toBe(null); + expect(view.el.firstChild.textContent.replace(/\s+/g, "")).toEqual("A1"); + }); test("ignore_attributes wildcard", () => { - let liveSocket = new LiveSocket("/live", Socket) - let el = liveViewDOM() + let liveSocket = new LiveSocket("/live", Socket); + let el = liveViewDOM(); let updateDiff = { - "0": " phx-mounted=\"[["ignore_attrs",{"attrs":["open","data-*"]}]]\"", - "1": " data-foo=\"foo\" data-bar=\"bar\"", + "0": ' phx-mounted="[["ignore_attrs",{"attrs":["open","data-*"]}]]"', + "1": ' data-foo="foo" data-bar="bar"', "2": "0", - "s": [ + s: [ "\n A\n ", - "" - ] - } - - let view = simulateJoinedView(el, liveSocket) - view.applyDiff("update", updateDiff, ({diff, events}) => view.update(diff, events)) - - expect(view.el.firstChild.tagName).toBe("DETAILS") - expect(view.el.firstChild.open).toBe(false) - view.el.firstChild.open = true - view.el.firstChild.setAttribute("data-foo", "bar") - view.el.firstChild.setAttribute("data-other", "also kept") + "", + ], + }; + + let view = simulateJoinedView(el, liveSocket); + view.applyDiff("update", updateDiff, ({ diff, events }) => + view.update(diff, events), + ); + + expect(view.el.firstChild.tagName).toBe("DETAILS"); + expect(view.el.firstChild.open).toBe(false); + view.el.firstChild.open = true; + view.el.firstChild.setAttribute("data-foo", "bar"); + view.el.firstChild.setAttribute("data-other", "also kept"); // apply diff - view.applyDiff("update", {"1": "data-foo=\"foo\" data-bar=\"bar\" data-new=\"new\"", "2": "1"}, ({diff, events}) => view.update(diff, events)) - expect(view.el.firstChild.open).toBe(true) - expect(view.el.firstChild.getAttribute("data-foo")).toBe("bar") - expect(view.el.firstChild.getAttribute("data-bar")).toBe("bar") - expect(view.el.firstChild.getAttribute("data-other")).toBe("also kept") - expect(view.el.firstChild.getAttribute("data-new")).toBe("new") - expect(view.el.firstChild.textContent.replace(/\s+/g, "")).toEqual("A1") - }) - }) -}) - -let submitBefore -describe("View", function(){ + view.applyDiff( + "update", + { "1": 'data-foo="foo" data-bar="bar" data-new="new"', "2": "1" }, + ({ diff, events }) => view.update(diff, events), + ); + expect(view.el.firstChild.open).toBe(true); + expect(view.el.firstChild.getAttribute("data-foo")).toBe("bar"); + expect(view.el.firstChild.getAttribute("data-bar")).toBe("bar"); + expect(view.el.firstChild.getAttribute("data-other")).toBe("also kept"); + expect(view.el.firstChild.getAttribute("data-new")).toBe("new"); + expect(view.el.firstChild.textContent.replace(/\s+/g, "")).toEqual("A1"); + }); + }); +}); + +let submitBefore; +describe("View", function () { beforeEach(() => { - submitBefore = HTMLFormElement.prototype.submit - global.Phoenix = {Socket} - global.document.body.innerHTML = liveViewDOM().outerHTML - }) + submitBefore = HTMLFormElement.prototype.submit; + global.Phoenix = { Socket }; + global.document.body.innerHTML = liveViewDOM().outerHTML; + }); afterEach(() => { - HTMLFormElement.prototype.submit = submitBefore - jest.useRealTimers() - }) + HTMLFormElement.prototype.submit = submitBefore; + jest.useRealTimers(); + }); afterAll(() => { - global.document.body.innerHTML = "" - }) + global.document.body.innerHTML = ""; + }); test("sets defaults", async () => { - const liveSocket = new LiveSocket("/live", Socket) - const el = liveViewDOM() - const view = simulateJoinedView(el, liveSocket) - expect(view.liveSocket).toBe(liveSocket) - expect(view.parent).toBeUndefined() - expect(view.el).toBe(el) - expect(view.id).toEqual("container") - expect(view.getSession).toBeDefined() - expect(view.channel).toBeDefined() - expect(view.loaderTimer).toBeDefined() - }) + const liveSocket = new LiveSocket("/live", Socket); + const el = liveViewDOM(); + const view = simulateJoinedView(el, liveSocket); + expect(view.liveSocket).toBe(liveSocket); + expect(view.parent).toBeUndefined(); + expect(view.el).toBe(el); + expect(view.id).toEqual("container"); + expect(view.getSession).toBeDefined(); + expect(view.channel).toBeDefined(); + expect(view.loaderTimer).toBeDefined(); + }); test("binding", async () => { - const liveSocket = new LiveSocket("/live", Socket) - const el = liveViewDOM() - const view = simulateJoinedView(el, liveSocket) - expect(view.binding("submit")).toEqual("phx-submit") - }) + const liveSocket = new LiveSocket("/live", Socket); + const el = liveViewDOM(); + const view = simulateJoinedView(el, liveSocket); + expect(view.binding("submit")).toEqual("phx-submit"); + }); test("getSession", async () => { - const liveSocket = new LiveSocket("/live", Socket) - const el = liveViewDOM() - const view = simulateJoinedView(el, liveSocket) - expect(view.getSession()).toEqual("abc123") - }) + const liveSocket = new LiveSocket("/live", Socket); + const el = liveViewDOM(); + const view = simulateJoinedView(el, liveSocket); + expect(view.getSession()).toEqual("abc123"); + }); test("getStatic", async () => { - const liveSocket = new LiveSocket("/live", Socket) - const el = liveViewDOM() - let view = simulateJoinedView(el, liveSocket) - expect(view.getStatic()).toEqual(null) + const liveSocket = new LiveSocket("/live", Socket); + const el = liveViewDOM(); + let view = simulateJoinedView(el, liveSocket); + expect(view.getStatic()).toEqual(null); - el.setAttribute("data-phx-static", "foo") - view = simulateJoinedView(el, liveSocket) - expect(view.getStatic()).toEqual("foo") - }) + el.setAttribute("data-phx-static", "foo"); + view = simulateJoinedView(el, liveSocket); + expect(view.getStatic()).toEqual("foo"); + }); test("showLoader and hideLoader", async () => { - const liveSocket = new LiveSocket("/live", Socket) - const el = document.querySelector("[data-phx-session]") - - const view = simulateJoinedView(el, liveSocket) - view.showLoader() - expect(el.classList.contains("phx-loading")).toBeTruthy() - expect(el.classList.contains("phx-connected")).toBeFalsy() - expect(el.classList.contains("user-implemented-class")).toBeTruthy() - - view.hideLoader() - expect(el.classList.contains("phx-loading")).toBeFalsy() - expect(el.classList.contains("phx-connected")).toBeTruthy() - }) - - test("displayError and hideLoader", done => { - jest.useFakeTimers() - const liveSocket = new LiveSocket("/live", Socket) - const loader = document.createElement("span") - const phxView = document.querySelector("[data-phx-session]") - phxView.parentNode.insertBefore(loader, phxView.nextSibling) - const el = document.querySelector("[data-phx-session]") - const status: HTMLElement = el.querySelector("#status") - - const view = simulateJoinedView(el, liveSocket) - - expect(status.style.display).toBe("none") - view.displayError([PHX_LOADING_CLASS, PHX_ERROR_CLASS, PHX_SERVER_ERROR_CLASS]) - expect(el.classList.contains("phx-loading")).toBeTruthy() - expect(el.classList.contains("phx-error")).toBeTruthy() - expect(el.classList.contains("phx-connected")).toBeFalsy() - expect(el.classList.contains("user-implemented-class")).toBeTruthy() - jest.runAllTimers() - expect(status.style.display).toBe("block") - simulateVisibility(status) - view.hideLoader() - jest.runAllTimers() - expect(status.style.display).toBe("none") - done() - }) + const liveSocket = new LiveSocket("/live", Socket); + const el = document.querySelector("[data-phx-session]"); + + const view = simulateJoinedView(el, liveSocket); + view.showLoader(); + expect(el.classList.contains("phx-loading")).toBeTruthy(); + expect(el.classList.contains("phx-connected")).toBeFalsy(); + expect(el.classList.contains("user-implemented-class")).toBeTruthy(); + + view.hideLoader(); + expect(el.classList.contains("phx-loading")).toBeFalsy(); + expect(el.classList.contains("phx-connected")).toBeTruthy(); + }); + + test("displayError and hideLoader", (done) => { + jest.useFakeTimers(); + const liveSocket = new LiveSocket("/live", Socket); + const loader = document.createElement("span"); + const phxView = document.querySelector("[data-phx-session]"); + phxView.parentNode.insertBefore(loader, phxView.nextSibling); + const el = document.querySelector("[data-phx-session]"); + const status: HTMLElement = el.querySelector("#status"); + + const view = simulateJoinedView(el, liveSocket); + + expect(status.style.display).toBe("none"); + view.displayError([ + PHX_LOADING_CLASS, + PHX_ERROR_CLASS, + PHX_SERVER_ERROR_CLASS, + ]); + expect(el.classList.contains("phx-loading")).toBeTruthy(); + expect(el.classList.contains("phx-error")).toBeTruthy(); + expect(el.classList.contains("phx-connected")).toBeFalsy(); + expect(el.classList.contains("user-implemented-class")).toBeTruthy(); + jest.runAllTimers(); + expect(status.style.display).toBe("block"); + simulateVisibility(status); + view.hideLoader(); + jest.runAllTimers(); + expect(status.style.display).toBe("none"); + done(); + }); test("join", async () => { - const liveSocket = new LiveSocket("/live", Socket) - const el = liveViewDOM() - const _view = simulateJoinedView(el, liveSocket) + const liveSocket = new LiveSocket("/live", Socket); + const el = liveViewDOM(); + const _view = simulateJoinedView(el, liveSocket); // view.join() // still need a few tests - }) + }); test("sends _track_static and _mounts on params", () => { - const liveSocket = new LiveSocket("/live", Socket) - const el = liveViewDOM() - const view = new View(el, liveSocket) - stubChannel(view) + const liveSocket = new LiveSocket("/live", Socket); + const el = liveViewDOM(); + const view = new View(el, liveSocket); + stubChannel(view); expect(view.channel.params()).toEqual({ - "flash": undefined, "params": {"_mounts": 0, "_mount_attempts": 0, "_live_referer": undefined}, - "session": "abc123", "static": null, "url": undefined, "redirect": undefined, "sticky": false} - ) - - el.innerHTML += "" - el.innerHTML += "" - el.innerHTML += "" - el.innerHTML += "" + flash: undefined, + params: { _mounts: 0, _mount_attempts: 0, _live_referer: undefined }, + session: "abc123", + static: null, + url: undefined, + redirect: undefined, + sticky: false, + }); + + el.innerHTML += + ''; + el.innerHTML += ''; + el.innerHTML += ''; + el.innerHTML += ''; expect(view.channel.params()).toEqual({ - "flash": undefined, "session": "abc123", "static": null, "url": undefined, - "redirect": undefined, - "params": { - "_mounts": 0, - "_mount_attempts": 1, - "_live_referer": undefined, - "_track_static": [ + flash: undefined, + session: "abc123", + static: null, + url: undefined, + redirect: undefined, + params: { + _mounts: 0, + _mount_attempts: 1, + _live_referer: undefined, + _track_static: [ "http://localhost/css/app-123.css?vsn=d", "http://localhost/img/tracked.png", - ] + ], }, - "sticky": false - }) - }) -}) + sticky: false, + }); + }); +}); -describe("View Hooks", function(){ +describe("View Hooks", function () { beforeEach(() => { - global.document.body.innerHTML = liveViewDOM().outerHTML - }) + global.document.body.innerHTML = liveViewDOM().outerHTML; + }); afterAll(() => { - global.document.body.innerHTML = "" - }) + global.document.body.innerHTML = ""; + }); - test("phx-mounted", done => { - const liveSocket = new LiveSocket("/live", Socket) - const el = liveViewDOM() + test("phx-mounted", (done) => { + const liveSocket = new LiveSocket("/live", Socket); + const el = liveViewDOM(); - const html = "

test mounted

" - el.innerHTML = html + const html = + '

test mounted

'; + el.innerHTML = html; - const view = simulateJoinedView(el, liveSocket) + const view = simulateJoinedView(el, liveSocket); view.onJoin({ rendered: { s: [html], - fingerprint: 123 + fingerprint: 123, }, - liveview_version - }) + liveview_version, + }); window.requestAnimationFrame(() => { - expect(document.getElementById("test").getAttribute("class")).toBe("new-class") - view.update({ - s: [html + "

test mounted

"], - fingerprint: 123 - }, []) + expect(document.getElementById("test").getAttribute("class")).toBe( + "new-class", + ); + view.update( + { + s: [ + html + + '

test mounted

', + ], + fingerprint: 123, + }, + [], + ); window.requestAnimationFrame(() => { - expect(document.getElementById("test").getAttribute("class")).toBe("new-class") - expect(document.getElementById("test2").getAttribute("class")).toBe("new-class2") - done() - }) - }) - }) + expect(document.getElementById("test").getAttribute("class")).toBe( + "new-class", + ); + expect(document.getElementById("test2").getAttribute("class")).toBe( + "new-class2", + ); + done(); + }); + }); + }); test("hooks", async () => { - let upcaseWasDestroyed = false - let upcaseBeforeUpdate = false - let hookLiveSocket + let upcaseWasDestroyed = false; + let upcaseBeforeUpdate = false; + let hookLiveSocket; const Hooks = { Upcase: { - mounted(){ - hookLiveSocket = this.liveSocket - this.el.innerHTML = this.el.innerHTML.toUpperCase() + mounted() { + hookLiveSocket = this.liveSocket; + this.el.innerHTML = this.el.innerHTML.toUpperCase(); }, - beforeUpdate(){ upcaseBeforeUpdate = true }, - updated(){ this.el.innerHTML = this.el.innerHTML + " updated" }, - disconnected(){ this.el.innerHTML = "disconnected" }, - reconnected(){ this.el.innerHTML = "connected" }, - destroyed(){ upcaseWasDestroyed = true }, - } - } - const liveSocket = new LiveSocket("/live", Socket, {hooks: Hooks}) - const el = liveViewDOM() + beforeUpdate() { + upcaseBeforeUpdate = true; + }, + updated() { + this.el.innerHTML = this.el.innerHTML + " updated"; + }, + disconnected() { + this.el.innerHTML = "disconnected"; + }, + reconnected() { + this.el.innerHTML = "connected"; + }, + destroyed() { + upcaseWasDestroyed = true; + }, + }, + }; + const liveSocket = new LiveSocket("/live", Socket, { hooks: Hooks }); + const el = liveViewDOM(); - const view = simulateJoinedView(el, liveSocket) + const view = simulateJoinedView(el, liveSocket); view.onJoin({ rendered: { - s: ["

test mount

"], - fingerprint: 123 + s: ['

test mount

'], + fingerprint: 123, + }, + liveview_version, + }); + expect(view.el.firstChild.innerHTML).toBe("TEST MOUNT"); + expect(Object.keys(view.viewHooks)).toHaveLength(1); + + view.update( + { + s: ['

test update

'], + fingerprint: 123, }, - liveview_version - }) - expect(view.el.firstChild.innerHTML).toBe("TEST MOUNT") - expect(Object.keys(view.viewHooks)).toHaveLength(1) - - view.update({ - s: ["

test update

"], - fingerprint: 123 - }, []) - expect(upcaseBeforeUpdate).toBe(true) - expect(view.el.firstChild.innerHTML).toBe("test update updated") - - view.showLoader() - expect(view.el.firstChild.innerHTML).toBe("disconnected") - - view.triggerReconnected() - expect(view.el.firstChild.innerHTML).toBe("connected") - - view.update({s: ["
"], fingerprint: 123}, []) - expect(upcaseWasDestroyed).toBe(true) - expect(hookLiveSocket).toBeDefined() - expect(Object.keys(view.viewHooks)).toEqual([]) - }) + [], + ); + expect(upcaseBeforeUpdate).toBe(true); + expect(view.el.firstChild.innerHTML).toBe("test update updated"); + + view.showLoader(); + expect(view.el.firstChild.innerHTML).toBe("disconnected"); + + view.triggerReconnected(); + expect(view.el.firstChild.innerHTML).toBe("connected"); + + view.update({ s: ["
"], fingerprint: 123 }, []); + expect(upcaseWasDestroyed).toBe(true); + expect(hookLiveSocket).toBeDefined(); + expect(Object.keys(view.viewHooks)).toEqual([]); + }); test("class based hook", async () => { - let upcaseWasDestroyed = false - let upcaseBeforeUpdate = false - let hookLiveSocket + let upcaseWasDestroyed = false; + let upcaseBeforeUpdate = false; + let hookLiveSocket; const Hooks = { Upcase: class extends ViewHook { - mounted(){ - hookLiveSocket = this.liveSocket - this.el.innerHTML = this.el.innerHTML.toUpperCase() + mounted() { + hookLiveSocket = this.liveSocket; + this.el.innerHTML = this.el.innerHTML.toUpperCase(); } - beforeUpdate(){ upcaseBeforeUpdate = true } - updated(){ this.el.innerHTML = this.el.innerHTML + " updated" } - disconnected(){ this.el.innerHTML = "disconnected" } - reconnected(){ this.el.innerHTML = "connected" } - destroyed(){ upcaseWasDestroyed = true } - } - } - const liveSocket = new LiveSocket("/live", Socket, {hooks: Hooks}) - const el = liveViewDOM() + beforeUpdate() { + upcaseBeforeUpdate = true; + } + updated() { + this.el.innerHTML = this.el.innerHTML + " updated"; + } + disconnected() { + this.el.innerHTML = "disconnected"; + } + reconnected() { + this.el.innerHTML = "connected"; + } + destroyed() { + upcaseWasDestroyed = true; + } + }, + }; + const liveSocket = new LiveSocket("/live", Socket, { hooks: Hooks }); + const el = liveViewDOM(); - const view = simulateJoinedView(el, liveSocket) + const view = simulateJoinedView(el, liveSocket); view.onJoin({ rendered: { - s: ["

test mount

"], - fingerprint: 123 + s: ['

test mount

'], + fingerprint: 123, }, - liveview_version - }) - expect(view.el.firstChild.innerHTML).toBe("TEST MOUNT") - expect(Object.keys(view.viewHooks)).toHaveLength(1) - - view.update({ - s: ["

test update

"], - fingerprint: 123 - }, []) - expect(upcaseBeforeUpdate).toBe(true) - expect(view.el.firstChild.innerHTML).toBe("test update updated") - - view.showLoader() - expect(view.el.firstChild.innerHTML).toBe("disconnected") - - view.triggerReconnected() - expect(view.el.firstChild.innerHTML).toBe("connected") - - view.update({s: ["
"], fingerprint: 123}, []) - expect(upcaseWasDestroyed).toBe(true) - expect(hookLiveSocket).toBeDefined() - expect(Object.keys(view.viewHooks)).toEqual([]) - }) + liveview_version, + }); + expect(view.el.firstChild.innerHTML).toBe("TEST MOUNT"); + expect(Object.keys(view.viewHooks)).toHaveLength(1); + + view.update( + { + s: ['

test update

'], + fingerprint: 123, + }, + [], + ); + expect(upcaseBeforeUpdate).toBe(true); + expect(view.el.firstChild.innerHTML).toBe("test update updated"); + + view.showLoader(); + expect(view.el.firstChild.innerHTML).toBe("disconnected"); + + view.triggerReconnected(); + expect(view.el.firstChild.innerHTML).toBe("connected"); + + view.update({ s: ["
"], fingerprint: 123 }, []); + expect(upcaseWasDestroyed).toBe(true); + expect(hookLiveSocket).toBeDefined(); + expect(Object.keys(view.viewHooks)).toEqual([]); + }); test("createHook", (done) => { - const liveSocket = new LiveSocket("/live", Socket, {}) - const el = liveViewDOM() - customElements.define("custom-el", class extends HTMLElement { - hook: ViewHook - connectedCallback(){ - this.hook = createHook(this, {mounted: () => { - expect(this.hook.liveSocket).toBeTruthy() - done() - }}) - expect(this.hook.liveSocket).toBe(null) - } - }) - const customEl = document.createElement("custom-el") - el.appendChild(customEl) - simulateJoinedView(el, liveSocket) - }) + const liveSocket = new LiveSocket("/live", Socket, {}); + const el = liveViewDOM(); + customElements.define( + "custom-el", + class extends HTMLElement { + hook: ViewHook; + connectedCallback() { + this.hook = createHook(this, { + mounted: () => { + expect(this.hook.liveSocket).toBeTruthy(); + done(); + }, + }); + expect(this.hook.liveSocket).toBe(null); + } + }, + ); + const customEl = document.createElement("custom-el"); + el.appendChild(customEl); + simulateJoinedView(el, liveSocket); + }); test("view destroyed", async () => { - const values = [] + const values = []; const Hooks = { Check: { - destroyed(){ values.push("destroyed") }, - } - } - const liveSocket = new LiveSocket("/live", Socket, {hooks: Hooks}) - const el = liveViewDOM() + destroyed() { + values.push("destroyed"); + }, + }, + }; + const liveSocket = new LiveSocket("/live", Socket, { hooks: Hooks }); + const el = liveViewDOM(); - const view = simulateJoinedView(el, liveSocket) + const view = simulateJoinedView(el, liveSocket); view.onJoin({ rendered: { - s: ["

test mount

"], - fingerprint: 123 + s: ['

test mount

'], + fingerprint: 123, }, - liveview_version - }) - expect(view.el.firstChild.innerHTML).toBe("test mount") + liveview_version, + }); + expect(view.el.firstChild.innerHTML).toBe("test mount"); - view.destroy() + view.destroy(); - expect(values).toEqual(["destroyed"]) - }) + expect(values).toEqual(["destroyed"]); + }); test("view reconnected", async () => { - const values = [] + const values = []; const Hooks = { Check: { - mounted(){ values.push("mounted") }, - disconnected(){ values.push("disconnected") }, - reconnected(){ values.push("reconnected") }, - } - } - const liveSocket = new LiveSocket("/live", Socket, {hooks: Hooks}) - const el = liveViewDOM() + mounted() { + values.push("mounted"); + }, + disconnected() { + values.push("disconnected"); + }, + reconnected() { + values.push("reconnected"); + }, + }, + }; + const liveSocket = new LiveSocket("/live", Socket, { hooks: Hooks }); + const el = liveViewDOM(); - const view = simulateJoinedView(el, liveSocket) + const view = simulateJoinedView(el, liveSocket); view.onJoin({ rendered: { - s: ["

"], - fingerprint: 123 + s: ['

'], + fingerprint: 123, }, - liveview_version - }) - expect(values).toEqual(["mounted"]) + liveview_version, + }); + expect(values).toEqual(["mounted"]); - view.triggerReconnected() + view.triggerReconnected(); // The hook hasn't disconnected, so it shouldn't receive "reconnected" message - expect(values).toEqual(["mounted"]) + expect(values).toEqual(["mounted"]); - view.showLoader() - expect(values).toEqual(["mounted", "disconnected"]) + view.showLoader(); + expect(values).toEqual(["mounted", "disconnected"]); - view.triggerReconnected() - expect(values).toEqual(["mounted", "disconnected", "reconnected"]) - }) + view.triggerReconnected(); + expect(values).toEqual(["mounted", "disconnected", "reconnected"]); + }); test("dispatches uploads", async () => { - const hooks = {Recorder: {}} - const liveSocket = new LiveSocket("/live", Socket, {hooks}) - const el = liveViewDOM() - const view = simulateJoinedView(el, liveSocket) + const hooks = { Recorder: {} }; + const liveSocket = new LiveSocket("/live", Socket, { hooks }); + const el = liveViewDOM(); + const view = simulateJoinedView(el, liveSocket); const template = `
- ` + `; view.onJoin({ rendered: { s: [template], - fingerprint: 123 + fingerprint: 123, }, - liveview_version - }) - - const recorderHook = view.getHook(view.el.querySelector("#rec")) - const fileEl = view.el.querySelector("#uploads0") - const dispatchEventSpy = jest.spyOn(fileEl, "dispatchEvent") - - const contents = {hello: "world"} - const blob = new Blob([JSON.stringify(contents, null, 2)], {type : "application/json"}) - recorderHook.upload("doc", [blob]) - - expect(dispatchEventSpy).toHaveBeenCalledWith(new CustomEvent("track-uploads", { - bubbles: true, - cancelable: true, - detail: {files: [blob]} - })) - }) + liveview_version, + }); + + const recorderHook = view.getHook(view.el.querySelector("#rec")); + const fileEl = view.el.querySelector("#uploads0"); + const dispatchEventSpy = jest.spyOn(fileEl, "dispatchEvent"); + + const contents = { hello: "world" }; + const blob = new Blob([JSON.stringify(contents, null, 2)], { + type: "application/json", + }); + recorderHook.upload("doc", [blob]); + + expect(dispatchEventSpy).toHaveBeenCalledWith( + new CustomEvent("track-uploads", { + bubbles: true, + cancelable: true, + detail: { files: [blob] }, + }), + ); + }); test("dom hooks", async () => { - let fromHTML, toHTML = null + let fromHTML, + toHTML = null; const liveSocket = new LiveSocket("/live", Socket, { dom: { - onBeforeElUpdated(from, to){ fromHTML = from.innerHTML; toHTML = to.innerHTML } - } - }) - const el = liveViewDOM() - const view = simulateJoinedView(el, liveSocket) - - view.onJoin({rendered: {s: ["
initial
"], fingerprint: 123}, liveview_version}) - expect(view.el.firstChild.innerHTML).toBe("initial") - - view.update({s: ["
updated
"], fingerprint: 123}, []) - expect(fromHTML).toBe("initial") - expect(toHTML).toBe("updated") - expect(view.el.firstChild.innerHTML).toBe("updated") - }) -}) - -function liveViewComponent(){ - const div = document.createElement("div") - div.setAttribute("data-phx-session", "abc123") - div.setAttribute("id", "container") - div.setAttribute("class", "user-implemented-class") + onBeforeElUpdated(from, to) { + fromHTML = from.innerHTML; + toHTML = to.innerHTML; + }, + }, + }); + const el = liveViewDOM(); + const view = simulateJoinedView(el, liveSocket); + + view.onJoin({ + rendered: { s: ["
initial
"], fingerprint: 123 }, + liveview_version, + }); + expect(view.el.firstChild.innerHTML).toBe("initial"); + + view.update({ s: ["
updated
"], fingerprint: 123 }, []); + expect(fromHTML).toBe("initial"); + expect(toHTML).toBe("updated"); + expect(view.el.firstChild.innerHTML).toBe("updated"); + }); +}); + +function liveViewComponent() { + const div = document.createElement("div"); + div.setAttribute("data-phx-session", "abc123"); + div.setAttribute("id", "container"); + div.setAttribute("class", "user-implemented-class"); div.innerHTML = `
@@ -1221,119 +1426,124 @@ function liveViewComponent(){
- ` - return div + `; + return div; } -describe("View + Component", function(){ +describe("View + Component", function () { beforeEach(() => { - global.Phoenix = {Socket} - global.document.body.innerHTML = liveViewComponent().outerHTML - }) + global.Phoenix = { Socket }; + global.document.body.innerHTML = liveViewComponent().outerHTML; + }); afterAll(() => { - global.document.body.innerHTML = "" - }) + global.document.body.innerHTML = ""; + }); test("targetComponentID", async () => { - const liveSocket = new LiveSocket("/live", Socket) - const el = liveViewComponent() - const view = simulateJoinedView(el, liveSocket) - const form = el.querySelector("input[type=\"checkbox\"]") - const targetCtx = el.querySelector(".form-wrapper") - expect(view.targetComponentID(el, targetCtx)).toBe(null) - expect(view.targetComponentID(form, targetCtx)).toBe(0) - }) + const liveSocket = new LiveSocket("/live", Socket); + const el = liveViewComponent(); + const view = simulateJoinedView(el, liveSocket); + const form = el.querySelector('input[type="checkbox"]'); + const targetCtx = el.querySelector(".form-wrapper"); + expect(view.targetComponentID(el, targetCtx)).toBe(null); + expect(view.targetComponentID(form, targetCtx)).toBe(0); + }); test("pushEvent", (done) => { - expect.assertions(17) + expect.assertions(17); - const liveSocket = new LiveSocket("/live", Socket) - const el = liveViewComponent() - const targetCtx = el.querySelector(".form-wrapper") + const liveSocket = new LiveSocket("/live", Socket); + const el = liveViewComponent(); + const targetCtx = el.querySelector(".form-wrapper"); - const view = simulateJoinedView(el, liveSocket) - const input = view.el.querySelector("input[id=plus]") + const view = simulateJoinedView(el, liveSocket); + const input = view.el.querySelector("input[id=plus]"); const channelStub = { - push(_evt, payload, _timeout){ - expect(payload.type).toBe("keyup") - expect(payload.event).toBeDefined() - expect(payload.value).toEqual({"value": "1"}) - expect(payload.cid).toEqual(0) + push(_evt, payload, _timeout) { + expect(payload.type).toBe("keyup"); + expect(payload.event).toBeDefined(); + expect(payload.value).toEqual({ value: "1" }); + expect(payload.cid).toEqual(0); return { - receive(status, callback){ - callback({ref: payload.ref}) - return this - } - } - } - } - view.channel = channelStub + receive(status, callback) { + callback({ ref: payload.ref }); + return this; + }, + }; + }, + }; + view.channel = channelStub; input.addEventListener("phx:push:myevent", (e) => { - const {ref, lockComplete, loadingComplete} = e.detail - expect(ref).toBe(0) - expect(e.target).toBe(input) + const { ref, lockComplete, loadingComplete } = e.detail; + expect(ref).toBe(0); + expect(e.target).toBe(input); loadingComplete.then((detail) => { - expect(detail.event).toBe("myevent") - expect(detail.ref).toBe(0) + expect(detail.event).toBe("myevent"); + expect(detail.ref).toBe(0); lockComplete.then((detail) => { - expect(detail.event).toBe("myevent") - expect(detail.ref).toBe(0) - done() - }) - }) - }) + expect(detail.event).toBe("myevent"); + expect(detail.ref).toBe(0); + done(); + }); + }); + }); input.addEventListener("phx:push", (e) => { - const {lock, unlock, lockComplete} = e.detail - expect(typeof lock).toBe("function") - expect(view.el.getAttribute("data-phx-ref-lock")).toBe(null) + const { lock, unlock, lockComplete } = e.detail; + expect(typeof lock).toBe("function"); + expect(view.el.getAttribute("data-phx-ref-lock")).toBe(null); // lock accepts unlock function to fire, which will done() the test - lockComplete.then(detail => { - expect(detail.event).toBe("myevent") - }) - lock(view.el).then(detail => { - expect(detail.event).toBe("myevent") - }) - expect(e.target).toBe(input) - expect(input.getAttribute("data-phx-ref-lock")).toBe("0") - expect(view.el.getAttribute("data-phx-ref-lock")).toBe("0") - unlock(view.el) - expect(view.el.getAttribute("data-phx-ref-lock")).toBe(null) - }) - - view.pushEvent("keyup", input, targetCtx, "myevent", {}) - }) - - test("pushInput", function(done){ - const html = - `
+ lockComplete.then((detail) => { + expect(detail.event).toBe("myevent"); + }); + lock(view.el).then((detail) => { + expect(detail.event).toBe("myevent"); + }); + expect(e.target).toBe(input); + expect(input.getAttribute("data-phx-ref-lock")).toBe("0"); + expect(view.el.getAttribute("data-phx-ref-lock")).toBe("0"); + unlock(view.el); + expect(view.el.getAttribute("data-phx-ref-lock")).toBe(null); + }); + + view.pushEvent("keyup", input, targetCtx, "myevent", {}); + }); + + test("pushInput", function (done) { + const html = ` -
` - const liveSocket = new LiveSocket("/live", Socket) - const el = liveViewDOM(html) - const view = simulateJoinedView(el, liveSocket) - Array.from(view.el.querySelectorAll("input")).forEach(input => simulateUsedInput(input)) + `; + const liveSocket = new LiveSocket("/live", Socket); + const el = liveViewDOM(html); + const view = simulateJoinedView(el, liveSocket); + Array.from(view.el.querySelectorAll("input")).forEach((input) => + simulateUsedInput(input), + ); const channelStub = { validate: "", - nextValidate(payload, meta){ - this.meta = meta + nextValidate(payload, meta) { + this.meta = meta; this.validate = Object.entries(payload) - .map(([key, value]) => `${encodeURIComponent(key)}=${value ? encodeURIComponent(value as string) : ""}`) - .join("&") + .map( + ([key, value]) => + `${encodeURIComponent(key)}=${value ? encodeURIComponent(value as string) : ""}`, + ) + .join("&"); }, - push(_evt, payload, _timeout){ - expect(payload.value).toBe(this.validate) - expect(payload.meta).toEqual(this.meta) + push(_evt, payload, _timeout) { + expect(payload.value).toBe(this.validate); + expect(payload.meta).toEqual(this.meta); return { - receive(status, cb){ - if(status === "ok"){ + receive(status, cb) { + if (status === "ok") { const diff = { - s: [` + s: [ + `
@@ -1343,109 +1553,126 @@ describe("View + Component", function(){
- `], - fingerprint: 345 - } - cb({diff: diff}) - return this + `, + ], + fingerprint: 345, + }; + cb({ diff: diff }); + return this; } else { - return this + return this; } - } - } - } - } - view.channel = channelStub - - const first_name = view.el.querySelector("#first_name") - const last_name = view.el.querySelector("#last_name") - view.channel.nextValidate({"user[first_name]": null, "user[last_name]": null}, {"_target": "user[first_name]"}) + }, + }; + }, + }; + view.channel = channelStub; + + const first_name = view.el.querySelector("#first_name"); + const last_name = view.el.querySelector("#last_name"); + view.channel.nextValidate( + { "user[first_name]": null, "user[last_name]": null }, + { _target: "user[first_name]" }, + ); // we have to set this manually since it's set by a change event that would require more plumbing with the liveSocket in the test to hook up - DOM.putPrivate(first_name, "phx-has-focused", true) - view.pushInput(first_name, el, null, "validate", {_target: first_name.name}) + DOM.putPrivate(first_name, "phx-has-focused", true); + view.pushInput(first_name, el, null, "validate", { + _target: first_name.name, + }); window.requestAnimationFrame(() => { - view.channel.nextValidate({"user[first_name]": null, "user[last_name]": null}, {"_target": "user[last_name]"}) - view.pushInput(last_name, el, null, "validate", {_target: last_name.name}) + view.channel.nextValidate( + { "user[first_name]": null, "user[last_name]": null }, + { _target: "user[last_name]" }, + ); + view.pushInput(last_name, el, null, "validate", { + _target: last_name.name, + }); window.requestAnimationFrame(() => { - done() - }) - }) - }) + done(); + }); + }); + }); test("adds auto ID to prevent teardown/re-add", () => { - const liveSocket = new LiveSocket("/live", Socket) - const el = liveViewDOM() - const view = simulateJoinedView(el, liveSocket) + const liveSocket = new LiveSocket("/live", Socket); + const el = liveViewDOM(); + const view = simulateJoinedView(el, liveSocket); - stubChannel(view) + stubChannel(view); const joinDiff = { - "0": {"0": "", "1": 0, "s": ["", "", "

2

\n"]}, - "c": { - "0": {"s": ["
Menu
\n"], "r": 1} + "0": { "0": "", "1": 0, s: ["", "", "

2

\n"] }, + c: { + "0": { s: ['
Menu
\n'], r: 1 }, }, - "s": ["", ""] - } + s: ["", ""], + }; const updateDiff = { "0": { - "0": {"s": ["

1

\n"], "r": 1} - } - } + "0": { s: ["

1

\n"], r: 1 }, + }, + }; - view.onJoin({rendered: joinDiff, liveview_version}) - expect(view.el.innerHTML.trim()).toBe("
Menu
\n

2

") + view.onJoin({ rendered: joinDiff, liveview_version }); + expect(view.el.innerHTML.trim()).toBe( + '
Menu
\n

2

', + ); - view.update(updateDiff, []) - expect(view.el.innerHTML.trim().replace("\n", "")).toBe("

1

Menu
\n

2

") - }) + view.update(updateDiff, []); + expect(view.el.innerHTML.trim().replace("\n", "")).toBe( + '

1

Menu
\n

2

', + ); + }); test("respects nested components", () => { - const liveSocket = new LiveSocket("/live", Socket) - const el = liveViewDOM() - const view = simulateJoinedView(el, liveSocket) + const liveSocket = new LiveSocket("/live", Socket); + const el = liveViewDOM(); + const view = simulateJoinedView(el, liveSocket); - stubChannel(view) + stubChannel(view); const joinDiff = { "0": 0, - "c": { - "0": {"0": 1, "s": ["
Hello
", ""], "r": 1}, - "1": {"s": ["
World
"], "r": 1} + c: { + "0": { "0": 1, s: ["
Hello
", ""], r: 1 }, + "1": { s: ["
World
"], r: 1 }, }, - "s": ["", ""] - } + s: ["", ""], + }; - view.onJoin({rendered: joinDiff, liveview_version}) - expect(view.el.innerHTML.trim()).toBe("
Hello
World
") - }) + view.onJoin({ rendered: joinDiff, liveview_version }); + expect(view.el.innerHTML.trim()).toBe( + '
Hello
World
', + ); + }); test("destroys children when they are removed by an update", () => { - const id = "root" - const childHTML = `
` - const newChildHTML = `
` - const el = document.createElement("div") - el.setAttribute("data-phx-session", "abc123") - el.setAttribute("id", id) - document.body.appendChild(el) + const id = "root"; + const childHTML = `
`; + const newChildHTML = `
`; + const el = document.createElement("div"); + el.setAttribute("data-phx-session", "abc123"); + el.setAttribute("id", id); + document.body.appendChild(el); - const liveSocket = new LiveSocket("/live", Socket) + const liveSocket = new LiveSocket("/live", Socket); - const view = simulateJoinedView(el, liveSocket) + const view = simulateJoinedView(el, liveSocket); - const joinDiff = {"s": [childHTML]} + const joinDiff = { s: [childHTML] }; - const updateDiff = {"s": [newChildHTML]} + const updateDiff = { s: [newChildHTML] }; - view.onJoin({rendered: joinDiff, liveview_version}) - expect(view.el.innerHTML.trim()).toEqual(childHTML) - expect(view.getChildById("bar")).toBeDefined() + view.onJoin({ rendered: joinDiff, liveview_version }); + expect(view.el.innerHTML.trim()).toEqual(childHTML); + expect(view.getChildById("bar")).toBeDefined(); - view.update(updateDiff, []) - expect(view.el.innerHTML.trim()).toEqual(newChildHTML) - expect(view.getChildById("baz")).toBeDefined() - expect(view.getChildById("bar")).toBeUndefined() - }) + view.update(updateDiff, []); + expect(view.el.innerHTML.trim()).toEqual(newChildHTML); + expect(view.getChildById("baz")).toBeDefined(); + expect(view.getChildById("bar")).toBeUndefined(); + }); describe("undoRefs", () => { test("restores phx specific attributes awaiting a ref", () => { @@ -1457,13 +1684,14 @@ describe("View + Component", function(){ - `.trim() - const liveSocket = new LiveSocket("/live", Socket) - const el = rootContainer(content) - const view = simulateJoinedView(el, liveSocket) - - view.undoRefs(1) - expect(el.innerHTML).toBe(` + `.trim(); + const liveSocket = new LiveSocket("/live", Socket); + const el = rootContainer(content); + const view = simulateJoinedView(el, liveSocket); + + view.undoRefs(1); + expect(el.innerHTML).toBe( + `
@@ -1471,10 +1699,12 @@ describe("View + Component", function(){
- `.trim()) + `.trim(), + ); - view.undoRefs(38) - expect(el.innerHTML).toBe(` + view.undoRefs(38); + expect(el.innerHTML).toBe( + `
@@ -1482,93 +1712,116 @@ describe("View + Component", function(){
- `.trim()) - }) + `.trim(), + ); + }); test("replaces any previous applied component", () => { - const liveSocket = new LiveSocket("/live", Socket) - const el = rootContainer("") + const liveSocket = new LiveSocket("/live", Socket); + const el = rootContainer(""); - const fromEl = tag("span", {"data-phx-ref-src": el.id, "data-phx-ref-lock": "1"}, "hello") - const toEl = tag("span", {"class": "new"}, "world") + const fromEl = tag( + "span", + { "data-phx-ref-src": el.id, "data-phx-ref-lock": "1" }, + "hello", + ); + const toEl = tag("span", { class: "new" }, "world"); - DOM.putPrivate(fromEl, "data-phx-ref-lock", toEl) + DOM.putPrivate(fromEl, "data-phx-ref-lock", toEl); - el.appendChild(fromEl) - const view = simulateJoinedView(el, liveSocket) + el.appendChild(fromEl); + const view = simulateJoinedView(el, liveSocket); - view.undoRefs(1) - expect(el.innerHTML).toBe("world") - }) + view.undoRefs(1); + expect(el.innerHTML).toBe('world'); + }); test("triggers beforeUpdate and updated hooks", () => { - global.document.body.innerHTML = "" - let beforeUpdate = false - let updated = false + global.document.body.innerHTML = ""; + let beforeUpdate = false; + let updated = false; const Hooks = { MyHook: { - beforeUpdate(){ beforeUpdate = true }, - updated(){ updated = true }, - } - } - const liveSocket = new LiveSocket("/live", Socket, {hooks: Hooks}) - const el = liveViewDOM() - const view = simulateJoinedView(el, liveSocket) - stubChannel(view) - view.onJoin({rendered: {s: ["Hello"]}, liveview_version}) - - view.update({s: ["Hello"]}, []) - - const toEl = tag("span", {"id": "myhook", "phx-hook": "MyHook"}, "world") - DOM.putPrivate(el.querySelector("#myhook"), "data-phx-ref-lock", toEl) - - view.undoRefs(1) - - expect(el.querySelector("#myhook").outerHTML).toBe("Hello") - view.undoRefs(2) - expect(el.querySelector("#myhook").outerHTML).toBe("world") - expect(beforeUpdate).toBe(true) - expect(updated).toBe(true) - }) - }) -}) - -describe("DOM", function(){ - it("mergeAttrs attributes", function(){ - const target = document.createElement("input") - target.type = "checkbox" - target.id = "foo" - target.setAttribute("checked", "true") - - const source = document.createElement("input") - source.type = "checkbox" - source.id = "bar" - - expect(target.getAttribute("checked")).toEqual("true") - expect(target.id).toEqual("foo") - - DOM.mergeAttrs(target, source) - - expect(target.getAttribute("checked")).toEqual(null) - expect(target.id).toEqual("bar") - }) - - it("mergeAttrs with properties", function(){ - const target = document.createElement("input") - target.type = "checkbox" - target.id = "foo" - target.checked = true - - const source = document.createElement("input") - source.type = "checkbox" - source.id = "bar" - - expect(target.checked).toEqual(true) - expect(target.id).toEqual("foo") - - DOM.mergeAttrs(target, source) - - expect(target.checked).toEqual(true) - expect(target.id).toEqual("bar") - }) -}) + beforeUpdate() { + beforeUpdate = true; + }, + updated() { + updated = true; + }, + }, + }; + const liveSocket = new LiveSocket("/live", Socket, { hooks: Hooks }); + const el = liveViewDOM(); + const view = simulateJoinedView(el, liveSocket); + stubChannel(view); + view.onJoin({ + rendered: { s: ['Hello'] }, + liveview_version, + }); + + view.update( + { + s: [ + 'Hello', + ], + }, + [], + ); + + const toEl = tag("span", { id: "myhook", "phx-hook": "MyHook" }, "world"); + DOM.putPrivate(el.querySelector("#myhook"), "data-phx-ref-lock", toEl); + + view.undoRefs(1); + + expect(el.querySelector("#myhook").outerHTML).toBe( + 'Hello', + ); + view.undoRefs(2); + expect(el.querySelector("#myhook").outerHTML).toBe( + 'world', + ); + expect(beforeUpdate).toBe(true); + expect(updated).toBe(true); + }); + }); +}); + +describe("DOM", function () { + it("mergeAttrs attributes", function () { + const target = document.createElement("input"); + target.type = "checkbox"; + target.id = "foo"; + target.setAttribute("checked", "true"); + + const source = document.createElement("input"); + source.type = "checkbox"; + source.id = "bar"; + + expect(target.getAttribute("checked")).toEqual("true"); + expect(target.id).toEqual("foo"); + + DOM.mergeAttrs(target, source); + + expect(target.getAttribute("checked")).toEqual(null); + expect(target.id).toEqual("bar"); + }); + + it("mergeAttrs with properties", function () { + const target = document.createElement("input"); + target.type = "checkbox"; + target.id = "foo"; + target.checked = true; + + const source = document.createElement("input"); + source.type = "checkbox"; + source.id = "bar"; + + expect(target.checked).toEqual(true); + expect(target.id).toEqual("foo"); + + DOM.mergeAttrs(target, source); + + expect(target.checked).toEqual(true); + expect(target.id).toEqual("bar"); + }); +}); diff --git a/eslint.config.js b/eslint.config.js index 41089ce87c..dc30aecc68 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -2,60 +2,9 @@ import playwright from "eslint-plugin-playwright" import jest from "eslint-plugin-jest" import globals from "globals" import js from "@eslint/js" -import stylistic from "@stylistic/eslint-plugin" import tseslint from "typescript-eslint" const sharedRules = { - "@stylistic/indent": ["error", 2, { - SwitchCase: 1, - }], - - "@stylistic/linebreak-style": ["error", "unix"], - "@stylistic/quotes": ["error", "double"], - "@stylistic/semi": ["error", "never"], - - "@stylistic/object-curly-spacing": ["error", "never", { - objectsInObjects: false, - arraysInObjects: false, - }], - - "@stylistic/array-bracket-spacing": ["error", "never"], - - "@stylistic/comma-spacing": ["error", { - before: false, - after: true, - }], - - "@stylistic/computed-property-spacing": ["error", "never"], - - "@stylistic/space-before-blocks": ["error", { - functions: "never", - keywords: "never", - classes: "always", - }], - - "@stylistic/keyword-spacing": ["error", { - overrides: { - if: { - after: false, - }, - - for: { - after: false, - }, - - while: { - after: false, - }, - - switch: { - after: false, - }, - }, - }], - - "@stylistic/eol-last": ["error", "always"], - "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_", varsIgnorePattern: "^_", @@ -89,7 +38,6 @@ export default tseslint.config([ plugins: { ...playwright.configs["flat/recommended"].plugins, - "@stylistic": stylistic, }, rules: { @@ -104,7 +52,6 @@ export default tseslint.config([ plugins: { jest, - "@stylistic": stylistic, }, languageOptions: { diff --git a/package.json b/package.json index 37e204dc81..001f044061 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,6 @@ "@babel/preset-typescript": "^7.27.1", "@eslint/js": "^9.24.0", "@playwright/test": "^1.51.1", - "@stylistic/eslint-plugin": "^4.2.0", "@types/jest": "^29.5.14", "@types/phoenix": "^1.6.6", "css.escape": "^1.5.1", @@ -48,6 +47,7 @@ "jest-monocart-coverage": "^1.1.1", "monocart-reporter": "^2.9.17", "phoenix": "1.7.21", + "prettier": "3.5.3", "ts-jest": "^29.3.2", "typescript": "^5.8.3", "typescript-eslint": "^8.32.0" @@ -60,7 +60,9 @@ "js:test": "npm run build && jest", "js:test.coverage": "npm run build && jest --coverage", "js:test.watch": "npm run build && jest --watch", - "js:lint": "eslint --fix && cd assets && eslint --fix", + "js:lint": "eslint", + "js:format": "prettier --write assets --log-level warn && prettier --write test/e2e --log-level warn", + "js:format.check": "prettier --check assets --log-level warn && prettier --check test/e2e --log-level warn", "test": "npm run js:test && npm run e2e:test", "typecheck:tests": "tsc -p assets/test/tsconfig.json", "cover:merge": "node test/e2e/merge-coverage.js", diff --git a/test/e2e/.prettierignore b/test/e2e/.prettierignore new file mode 100644 index 0000000000..51511d1f8f --- /dev/null +++ b/test/e2e/.prettierignore @@ -0,0 +1 @@ +test-results/ diff --git a/test/e2e/merge-coverage.js b/test/e2e/merge-coverage.js index 85c52bb755..a0827ff1ea 100644 --- a/test/e2e/merge-coverage.js +++ b/test/e2e/merge-coverage.js @@ -1,22 +1,16 @@ -import {CoverageReport} from "monocart-coverage-reports" +import { CoverageReport } from "monocart-coverage-reports"; const coverageOptions = { name: "Phoenix LiveView JS Coverage", - inputDir: [ - "./coverage/raw", - "./test/e2e/test-results/coverage/raw" - ], + inputDir: ["./coverage/raw", "./test/e2e/test-results/coverage/raw"], outputDir: "./cover/merged-js", - reports: [ - ["v8"], - ["console-summary"] - ], + reports: [["v8"], ["console-summary"]], sourcePath: (filePath) => { - if(!filePath.startsWith("assets")){ - return "assets/js/phoenix_live_view/" + filePath + if (!filePath.startsWith("assets")) { + return "assets/js/phoenix_live_view/" + filePath; } else { - return filePath + return filePath; } }, -} -await new CoverageReport(coverageOptions).generate() +}; +await new CoverageReport(coverageOptions).generate(); diff --git a/test/e2e/playwright.config.js b/test/e2e/playwright.config.js index 355c7304db..b106b2c754 100644 --- a/test/e2e/playwright.config.js +++ b/test/e2e/playwright.config.js @@ -1,29 +1,32 @@ // playwright.config.js // @ts-check -import {devices} from "@playwright/test" -import {dirname, resolve} from "node:path" -import {fileURLToPath} from "node:url" +import { devices } from "@playwright/test"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; -const __dirname = dirname(fileURLToPath(import.meta.url)) +const __dirname = dirname(fileURLToPath(import.meta.url)); /** @type {import("@playwright/test").ReporterDescription} */ -const monocartReporter = ["monocart-reporter", { - name: "Phoenix LiveView", - outputFile: "./test-results/report.html", - coverage: { - reports: [ - ["raw", {outputDir: "./raw"}], - ["v8"], - ], - entryFilter: (entry) => entry.url.indexOf("phoenix_live_view.esm.js") !== -1, - } -}] +const monocartReporter = [ + "monocart-reporter", + { + name: "Phoenix LiveView", + outputFile: "./test-results/report.html", + coverage: { + reports: [["raw", { outputDir: "./raw" }], ["v8"]], + entryFilter: (entry) => + entry.url.indexOf("phoenix_live_view.esm.js") !== -1, + }, + }, +]; /** @type {import("@playwright/test").PlaywrightTestConfig} */ const config = { forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, - reporter: process.env.CI ? [["github"], ["html"], ["dot"], monocartReporter] : [["list"], monocartReporter], + reporter: process.env.CI + ? [["github"], ["html"], ["dot"], monocartReporter] + : [["list"], monocartReporter], use: { trace: "retain-on-failure", screenshot: "only-on-failure", @@ -40,19 +43,19 @@ const config = { projects: [ { name: "chromium", - use: {...devices["Desktop Chrome"]}, + use: { ...devices["Desktop Chrome"] }, }, { name: "firefox", - use: {...devices["Desktop Firefox"]}, + use: { ...devices["Desktop Firefox"] }, }, { name: "webkit", - use: {...devices["Desktop Safari"]}, - } + use: { ...devices["Desktop Safari"] }, + }, ], outputDir: "test-results", - globalTeardown: resolve(__dirname, "./teardown.js") -} + globalTeardown: resolve(__dirname, "./teardown.js"), +}; -export default config +export default config; diff --git a/test/e2e/teardown.js b/test/e2e/teardown.js index 96ab2a2a57..27929f7b07 100644 --- a/test/e2e/teardown.js +++ b/test/e2e/teardown.js @@ -1,13 +1,15 @@ -import {request} from "@playwright/test" +import { request } from "@playwright/test"; export default async () => { try { - const context = await request.newContext({baseURL: "http://localhost:4004"}) + const context = await request.newContext({ + baseURL: "http://localhost:4004", + }); // gracefully stops the e2e script to export coverage - await context.post("/halt") + await context.post("/halt"); } catch { // we expect the request to fail because the request // actually stops the server - return + return; } -} +}; diff --git a/test/e2e/test-fixtures.js b/test/e2e/test-fixtures.js index 7786f51283..ca307618b8 100644 --- a/test/e2e/test-fixtures.js +++ b/test/e2e/test-fixtures.js @@ -1,55 +1,64 @@ // see https://github.com/cenfun/monocart-reporter?tab=readme-ov-file#global-coverage-report -import {test as testBase, expect} from "@playwright/test" -import {addCoverageReport} from "monocart-reporter" +import { test as testBase, expect } from "@playwright/test"; +import { addCoverageReport } from "monocart-reporter"; -import fs from "node:fs" -import path from "node:path" -import {fileURLToPath} from "node:url" +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const liveViewSourceMap = JSON.parse(fs.readFileSync(path.resolve(__dirname + "../../../priv/static/phoenix_live_view.esm.js.map")).toString("utf-8")) +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const liveViewSourceMap = JSON.parse( + fs + .readFileSync( + path.resolve( + __dirname + "../../../priv/static/phoenix_live_view.esm.js.map", + ), + ) + .toString("utf-8"), +); const test = testBase.extend({ - autoTestFixture: [async ({page, browserName}, use) => { + autoTestFixture: [ + async ({ page, browserName }, use) => { + // NOTE: it depends on your project name + const isChromium = browserName === "chromium"; - // NOTE: it depends on your project name - const isChromium = browserName === "chromium" + // console.log("autoTestFixture setup..."); + // coverage API is chromium only + if (isChromium) { + await Promise.all([ + page.coverage.startJSCoverage({ + resetOnNavigation: false, + }), + page.coverage.startCSSCoverage({ + resetOnNavigation: false, + }), + ]); + } - // console.log("autoTestFixture setup..."); - // coverage API is chromium only - if(isChromium){ - await Promise.all([ - page.coverage.startJSCoverage({ - resetOnNavigation: false - }), - page.coverage.startCSSCoverage({ - resetOnNavigation: false - }) - ]) - } + await use("autoTestFixture"); - await use("autoTestFixture") - - // console.log("autoTestFixture teardown..."); - if(isChromium){ - const [jsCoverage, cssCoverage] = await Promise.all([ - page.coverage.stopJSCoverage(), - page.coverage.stopCSSCoverage() - ]) - jsCoverage.forEach((entry) => { - // read sourcemap for the phoenix_live_view.esm.js manually - if(entry.url.endsWith("phoenix_live_view.esm.js")){ - entry.sourceMap = liveViewSourceMap - } - }) - const coverageList = [...jsCoverage, ...cssCoverage] - // console.log(coverageList.map((item) => item.url)); - await addCoverageReport(coverageList, test.info()) - } - - }, { - scope: "test", - auto: true - }] -}) -export {test, expect} + // console.log("autoTestFixture teardown..."); + if (isChromium) { + const [jsCoverage, cssCoverage] = await Promise.all([ + page.coverage.stopJSCoverage(), + page.coverage.stopCSSCoverage(), + ]); + jsCoverage.forEach((entry) => { + // read sourcemap for the phoenix_live_view.esm.js manually + if (entry.url.endsWith("phoenix_live_view.esm.js")) { + entry.sourceMap = liveViewSourceMap; + } + }); + const coverageList = [...jsCoverage, ...cssCoverage]; + // console.log(coverageList.map((item) => item.url)); + await addCoverageReport(coverageList, test.info()); + } + }, + { + scope: "test", + auto: true, + }, + ], +}); +export { test, expect }; diff --git a/test/e2e/tests/errors.spec.js b/test/e2e/tests/errors.spec.js index bcfc4a069e..858772467a 100644 --- a/test/e2e/tests/errors.spec.js +++ b/test/e2e/tests/errors.spec.js @@ -1,38 +1,44 @@ -import {test, expect} from "../test-fixtures" -import {syncLV} from "../utils" +import { test, expect } from "../test-fixtures"; +import { syncLV } from "../utils"; /** * https://hexdocs.pm/phoenix_live_view/error-handling.html */ test.describe("exception handling", () => { - let webSocketEvents = [] - let networkEvents = [] - let consoleMessages = [] - - test.beforeEach(async ({page}) => { - networkEvents = [] - webSocketEvents = [] - consoleMessages = [] - - page.on("request", request => networkEvents.push({method: request.method(), url: request.url()})) - - page.on("websocket", ws => { - ws.on("framesent", event => webSocketEvents.push({type: "sent", payload: event.payload})) - ws.on("framereceived", event => webSocketEvents.push({type: "received", payload: event.payload})) - ws.on("close", () => webSocketEvents.push({type: "close"})) - }) - - page.on("console", msg => consoleMessages.push(msg.text())) - }) + let webSocketEvents = []; + let networkEvents = []; + let consoleMessages = []; + + test.beforeEach(async ({ page }) => { + networkEvents = []; + webSocketEvents = []; + consoleMessages = []; + + page.on("request", (request) => + networkEvents.push({ method: request.method(), url: request.url() }), + ); + + page.on("websocket", (ws) => { + ws.on("framesent", (event) => + webSocketEvents.push({ type: "sent", payload: event.payload }), + ); + ws.on("framereceived", (event) => + webSocketEvents.push({ type: "received", payload: event.payload }), + ); + ws.on("close", () => webSocketEvents.push({ type: "close" })); + }); + + page.on("console", (msg) => consoleMessages.push(msg.text())); + }); test.describe("during HTTP mount", () => { - test("500 error when dead mount fails", async ({page}) => { - page.on("response", response => { - expect(response.status()).toBe(500) - }) - await page.goto("/errors?dead-mount=raise") - }) - }) + test("500 error when dead mount fails", async ({ page }) => { + page.on("response", (response) => { + expect(response.status()).toBe(500); + }); + await page.goto("/errors?dead-mount=raise"); + }); + }); test.describe("during connected mount", () => { /** @@ -46,225 +52,286 @@ test.describe("exception handling", () => { * the page will be reloaded without giving up, but the duration is set to 30s * by default. */ - test("reloads the page when connected mount fails", async ({page}) => { - await page.goto("/errors?connected-mount=raise") + test("reloads the page when connected mount fails", async ({ page }) => { + await page.goto("/errors?connected-mount=raise"); // the page was loaded once expect(networkEvents).toEqual([ - {method: "GET", url: "http://localhost:4004/errors?connected-mount=raise"}, - {method: "GET", url: "http://localhost:4004/assets/phoenix/phoenix.min.js"}, - {method: "GET", url: "http://localhost:4004/assets/phoenix_live_view/phoenix_live_view.esm.js"}, - ]) - - networkEvents = [] - - await page.waitForTimeout(2000) - - // the page was reloaded 5 times - expect(networkEvents).toEqual(expect.arrayContaining([ { - "method": "GET", - "url": "http://localhost:4004/errors?connected-mount=raise" + method: "GET", + url: "http://localhost:4004/errors?connected-mount=raise", }, { - "method": "GET", - "url": "http://localhost:4004/errors?connected-mount=raise" + method: "GET", + url: "http://localhost:4004/assets/phoenix/phoenix.min.js", }, { - "method": "GET", - "url": "http://localhost:4004/errors?connected-mount=raise" + method: "GET", + url: "http://localhost:4004/assets/phoenix_live_view/phoenix_live_view.esm.js", }, - { - "method": "GET", - "url": "http://localhost:4004/errors?connected-mount=raise" - }, - { - "method": "GET", - "url": "http://localhost:4004/errors?connected-mount=raise" - }, - { - "method": "GET", - "url": "http://localhost:4004/errors?connected-mount=raise" - }, - { - "method": "GET", - "url": "http://localhost:4004/errors?connected-mount=raise" - } - ])) + ]); - expect(consoleMessages).toEqual(expect.arrayContaining([ - expect.stringMatching(/consecutive reloads. Entering failsafe mode/) - ])) - }) + networkEvents = []; + + await page.waitForTimeout(2000); + + // the page was reloaded 5 times + expect(networkEvents).toEqual( + expect.arrayContaining([ + { + method: "GET", + url: "http://localhost:4004/errors?connected-mount=raise", + }, + { + method: "GET", + url: "http://localhost:4004/errors?connected-mount=raise", + }, + { + method: "GET", + url: "http://localhost:4004/errors?connected-mount=raise", + }, + { + method: "GET", + url: "http://localhost:4004/errors?connected-mount=raise", + }, + { + method: "GET", + url: "http://localhost:4004/errors?connected-mount=raise", + }, + { + method: "GET", + url: "http://localhost:4004/errors?connected-mount=raise", + }, + { + method: "GET", + url: "http://localhost:4004/errors?connected-mount=raise", + }, + ]), + ); + + expect(consoleMessages).toEqual( + expect.arrayContaining([ + expect.stringMatching(/consecutive reloads. Entering failsafe mode/), + ]), + ); + }); /** * TBD: if the connected mount of the main LV succeeds, but a child LV fails * on mount, we only try to rejoin the child LV instead of reloading the page. */ - test("rejoin instead of reload when child LV fails on connected mount", async ({page}) => { - await page.goto("/errors?connected-child-mount-raise=2") - await page.waitForTimeout(2000) - - expect(consoleMessages).toEqual(expect.arrayContaining([ - expect.stringMatching(/mount/), - expect.stringMatching(/child error: unable to join/), - expect.stringMatching(/child error: unable to join/), - // third time's the charm - expect.stringMatching(/child mount/), - ])) + test("rejoin instead of reload when child LV fails on connected mount", async ({ + page, + }) => { + await page.goto("/errors?connected-child-mount-raise=2"); + await page.waitForTimeout(2000); + + expect(consoleMessages).toEqual( + expect.arrayContaining([ + expect.stringMatching(/mount/), + expect.stringMatching(/child error: unable to join/), + expect.stringMatching(/child error: unable to join/), + // third time's the charm + expect.stringMatching(/child mount/), + ]), + ); // page was not reloaded expect(networkEvents).toEqual([ - {method: "GET", url: "http://localhost:4004/errors?connected-child-mount-raise=2"}, - {method: "GET", url: "http://localhost:4004/assets/phoenix/phoenix.min.js"}, - {method: "GET", url: "http://localhost:4004/assets/phoenix_live_view/phoenix_live_view.esm.js"}, - ]) - }) + { + method: "GET", + url: "http://localhost:4004/errors?connected-child-mount-raise=2", + }, + { + method: "GET", + url: "http://localhost:4004/assets/phoenix/phoenix.min.js", + }, + { + method: "GET", + url: "http://localhost:4004/assets/phoenix_live_view/phoenix_live_view.esm.js", + }, + ]); + }); /** * TBD: if the connected mount of the main LV succeeds, but a child LV fails * repeatedly, we reload the page. Maybe we should give up without reloading the page? */ - test("abandons child remount if child LV fails multiple times", async ({page}) => { - await page.goto("/errors?connected-child-mount-raise=5") + test("abandons child remount if child LV fails multiple times", async ({ + page, + }) => { + await page.goto("/errors?connected-child-mount-raise=5"); // maybe we can find a better way than waiting for a fixed amount of time - await page.waitForTimeout(1000) + await page.waitForTimeout(1000); - expect(consoleMessages.filter(m => m.startsWith("child "))).toEqual([ + expect(consoleMessages.filter((m) => m.startsWith("child "))).toEqual([ expect.stringContaining("child error: unable to join"), expect.stringContaining("child error: unable to join"), expect.stringContaining("child error: unable to join"), // maxChildJoinTries is 3, we count from 0, so the 4th try is the last expect.stringContaining("child error: giving up"), expect.stringContaining("child destroyed"), - ]) + ]); // page remained loaded without parent failsafe reload expect(networkEvents).toEqual([ // initial load - {method: "GET", url: "http://localhost:4004/errors?connected-child-mount-raise=5"}, - {method: "GET", url: "http://localhost:4004/assets/phoenix/phoenix.min.js"}, - {method: "GET", url: "http://localhost:4004/assets/phoenix_live_view/phoenix_live_view.esm.js"}, - ]) - }) - }) + { + method: "GET", + url: "http://localhost:4004/errors?connected-child-mount-raise=5", + }, + { + method: "GET", + url: "http://localhost:4004/assets/phoenix/phoenix.min.js", + }, + { + method: "GET", + url: "http://localhost:4004/assets/phoenix_live_view/phoenix_live_view.esm.js", + }, + ]); + }); + }); test.describe("after connected mount", () => { /** * When a child LV crashes after the connected mount, the parent LV is not * affected. The child LV is simply remounted. */ - test("page does not reload if child LV crashes (handle_event)", async ({page}) => { - await page.goto("/errors?child") - await syncLV(page) + test("page does not reload if child LV crashes (handle_event)", async ({ + page, + }) => { + await page.goto("/errors?child"); + await syncLV(page); - const parentTime = await page.locator("#render-time").innerText() - const childTime = await page.locator("#child-render-time").innerText() + const parentTime = await page.locator("#render-time").innerText(); + const childTime = await page.locator("#child-render-time").innerText(); // both lvs mounted, no other messages - expect(consoleMessages).toEqual(expect.arrayContaining([ - expect.stringMatching(/mount/), - expect.stringMatching(/child mount/), - ])) - consoleMessages = [] + expect(consoleMessages).toEqual( + expect.arrayContaining([ + expect.stringMatching(/mount/), + expect.stringMatching(/child mount/), + ]), + ); + consoleMessages = []; - await page.getByRole("button", {name: "Crash child"}).click() - await syncLV(page) + await page.getByRole("button", { name: "Crash child" }).click(); + await syncLV(page); // child crashed and re-rendered - const newChildTime = page.locator("#child-render-time") - await expect(newChildTime).not.toHaveText(childTime) + const newChildTime = page.locator("#child-render-time"); + await expect(newChildTime).not.toHaveText(childTime); expect(consoleMessages).toEqual([ expect.stringMatching(/child error: view crashed/), expect.stringMatching(/child mount/), - ]) + ]); // parent did not re-render - const newParentTiem = page.locator("#render-time") - await expect(newParentTiem).toHaveText(parentTime) + const newParentTiem = page.locator("#render-time"); + await expect(newParentTiem).toHaveText(parentTime); // page was not reloaded expect(networkEvents).toEqual([ - {method: "GET", url: "http://localhost:4004/errors?child"}, - {method: "GET", url: "http://localhost:4004/assets/phoenix/phoenix.min.js"}, - {method: "GET", url: "http://localhost:4004/assets/phoenix_live_view/phoenix_live_view.esm.js"}, - ]) - }) + { method: "GET", url: "http://localhost:4004/errors?child" }, + { + method: "GET", + url: "http://localhost:4004/assets/phoenix/phoenix.min.js", + }, + { + method: "GET", + url: "http://localhost:4004/assets/phoenix_live_view/phoenix_live_view.esm.js", + }, + ]); + }); /** * When the main LV crashes after the connected mount, the page is not reloaded. * The main LV is simply remounted over the existing transport. */ - test("page does not reload if main LV crashes (handle_event)", async ({page}) => { - await page.goto("/errors?child") - await syncLV(page) + test("page does not reload if main LV crashes (handle_event)", async ({ + page, + }) => { + await page.goto("/errors?child"); + await syncLV(page); - const parentTime = await page.locator("#render-time").innerText() - const childTime = await page.locator("#child-render-time").innerText() + const parentTime = await page.locator("#render-time").innerText(); + const childTime = await page.locator("#child-render-time").innerText(); // both lvs mounted, no other messages - expect(consoleMessages).toEqual(expect.arrayContaining([ - expect.stringMatching(/mount/), - expect.stringMatching(/child mount/), - ])) - consoleMessages = [] + expect(consoleMessages).toEqual( + expect.arrayContaining([ + expect.stringMatching(/mount/), + expect.stringMatching(/child mount/), + ]), + ); + consoleMessages = []; - await page.getByRole("button", {name: "Crash main"}).click() - await syncLV(page) + await page.getByRole("button", { name: "Crash main" }).click(); + await syncLV(page); // main and child re-rendered (full page refresh) - const newChildTime = page.locator("#child-render-time") - await expect(newChildTime).not.toHaveText(childTime) - const newParentTiem = page.locator("#render-time") - await expect(newParentTiem).not.toHaveText(parentTime) + const newChildTime = page.locator("#child-render-time"); + await expect(newChildTime).not.toHaveText(childTime); + const newParentTiem = page.locator("#render-time"); + await expect(newParentTiem).not.toHaveText(parentTime); expect(consoleMessages).toEqual([ expect.stringMatching(/child destroyed/), expect.stringMatching(/error: view crashed/), expect.stringMatching(/mount/), expect.stringMatching(/child mount/), - ]) + ]); // page was not reloaded expect(networkEvents).toEqual([ - {method: "GET", url: "http://localhost:4004/errors?child"}, - {method: "GET", url: "http://localhost:4004/assets/phoenix/phoenix.min.js"}, - {method: "GET", url: "http://localhost:4004/assets/phoenix_live_view/phoenix_live_view.esm.js"}, - ]) - }) + { method: "GET", url: "http://localhost:4004/errors?child" }, + { + method: "GET", + url: "http://localhost:4004/assets/phoenix/phoenix.min.js", + }, + { + method: "GET", + url: "http://localhost:4004/assets/phoenix_live_view/phoenix_live_view.esm.js", + }, + ]); + }); /** * When the main LV mounts successfully, but a child LV crashes which is linked * to the parent, the parent LV crashed too, triggering a remount of both. */ - test("parent crashes and reconnects when linked child LV crashes", async ({page}) => { - await page.goto("/errors?connected-child-mount-raise=link") - await syncLV(page) + test("parent crashes and reconnects when linked child LV crashes", async ({ + page, + }) => { + await page.goto("/errors?connected-child-mount-raise=link"); + await syncLV(page); // child crashed on mount, linked to parent -> parent crashed too // second mounts are successful - expect(consoleMessages).toEqual(expect.arrayContaining([ - expect.stringMatching(/mount/), - expect.stringMatching(/child error: unable to join/), - expect.stringMatching(/child destroyed/), - expect.stringMatching(/error: view crashed/), - expect.stringMatching(/mount/), - expect.stringMatching(/child mount/), - ])) - consoleMessages = [] - - const parentTime = await page.locator("#render-time").innerText() - const childTime = await page.locator("#child-render-time").innerText() + expect(consoleMessages).toEqual( + expect.arrayContaining([ + expect.stringMatching(/mount/), + expect.stringMatching(/child error: unable to join/), + expect.stringMatching(/child destroyed/), + expect.stringMatching(/error: view crashed/), + expect.stringMatching(/mount/), + expect.stringMatching(/child mount/), + ]), + ); + consoleMessages = []; + + const parentTime = await page.locator("#render-time").innerText(); + const childTime = await page.locator("#child-render-time").innerText(); // the processes are still linked, crashing the child again crashes the parent - await page.getByRole("button", {name: "Crash child"}).click() - await syncLV(page) + await page.getByRole("button", { name: "Crash child" }).click(); + await syncLV(page); // main and child re-rendered (full page refresh) - const newChildTime = page.locator("#child-render-time") - await expect(newChildTime).not.toHaveText(childTime) - const newParentTiem = page.locator("#render-time") - await expect(newParentTiem).not.toHaveText(parentTime) + const newChildTime = page.locator("#child-render-time"); + await expect(newChildTime).not.toHaveText(childTime); + const newParentTiem = page.locator("#render-time"); + await expect(newParentTiem).not.toHaveText(parentTime); expect(consoleMessages).toEqual([ expect.stringMatching(/child error: view crashed/), @@ -272,14 +339,23 @@ test.describe("exception handling", () => { expect.stringMatching(/error: view crashed/), expect.stringMatching(/mount/), expect.stringMatching(/child mount/), - ]) + ]); // page was not reloaded expect(networkEvents).toEqual([ - {method: "GET", url: "http://localhost:4004/errors?connected-child-mount-raise=link"}, - {method: "GET", url: "http://localhost:4004/assets/phoenix/phoenix.min.js"}, - {method: "GET", url: "http://localhost:4004/assets/phoenix_live_view/phoenix_live_view.esm.js"}, - ]) - }) - }) -}) + { + method: "GET", + url: "http://localhost:4004/errors?connected-child-mount-raise=link", + }, + { + method: "GET", + url: "http://localhost:4004/assets/phoenix/phoenix.min.js", + }, + { + method: "GET", + url: "http://localhost:4004/assets/phoenix_live_view/phoenix_live_view.esm.js", + }, + ]); + }); + }); +}); diff --git a/test/e2e/tests/forms.spec.js b/test/e2e/tests/forms.spec.js index 163afc9e0b..10aae7fe68 100644 --- a/test/e2e/tests/forms.spec.js +++ b/test/e2e/tests/forms.spec.js @@ -1,229 +1,297 @@ -import {test, expect} from "../test-fixtures" -import {syncLV, evalLV, evalPlug, attributeMutations} from "../utils" +import { test, expect } from "../test-fixtures"; +import { syncLV, evalLV, evalPlug, attributeMutations } from "../utils"; -for(const path of ["/form/nested", "/form"]){ +for (const path of ["/form/nested", "/form"]) { // see also https://github.com/phoenixframework/phoenix_live_view/issues/1759 // https://github.com/phoenixframework/phoenix_live_view/issues/2993 test.describe("restores disabled and readonly states", () => { - test(`${path} - readonly state is restored after submits`, async ({page}) => { - await page.goto(path) - await syncLV(page) - await expect(page.locator("input[name=a]")).toHaveAttribute("readonly") - const changesA = attributeMutations(page, "input[name=a]") - const changesB = attributeMutations(page, "input[name=b]") + test(`${path} - readonly state is restored after submits`, async ({ + page, + }) => { + await page.goto(path); + await syncLV(page); + await expect(page.locator("input[name=a]")).toHaveAttribute("readonly"); + const changesA = attributeMutations(page, "input[name=a]"); + const changesB = attributeMutations(page, "input[name=b]"); // can submit multiple times and readonly input stays readonly - await page.locator("#submit").click() - await syncLV(page) + await page.locator("#submit").click(); + await syncLV(page); // a is readonly and should stay readonly - expect(await changesA()).toEqual(expect.arrayContaining([ - {attr: "data-phx-readonly", oldValue: null, newValue: "true"}, - {attr: "readonly", oldValue: "", newValue: ""}, - {attr: "data-phx-readonly", oldValue: "true", newValue: null}, - {attr: "readonly", oldValue: "", newValue: ""}, - ])) + expect(await changesA()).toEqual( + expect.arrayContaining([ + { attr: "data-phx-readonly", oldValue: null, newValue: "true" }, + { attr: "readonly", oldValue: "", newValue: "" }, + { attr: "data-phx-readonly", oldValue: "true", newValue: null }, + { attr: "readonly", oldValue: "", newValue: "" }, + ]), + ); // b is not readonly, but LV will set it to readonly while submitting - expect(await changesB()).toEqual(expect.arrayContaining([ - {attr: "data-phx-readonly", oldValue: null, newValue: "false"}, - {attr: "readonly", oldValue: null, newValue: ""}, - {attr: "data-phx-readonly", oldValue: "false", newValue: null}, - {attr: "readonly", oldValue: "", newValue: null}, - ])) - await expect(page.locator("input[name=a]")).toHaveAttribute("readonly") - await page.locator("#submit").click() - await syncLV(page) - await expect(page.locator("input[name=a]")).toHaveAttribute("readonly") - }) - - test(`${path} - button disabled state is restored after submits`, async ({page}) => { - await page.goto(path) - await syncLV(page) - const changes = attributeMutations(page, "#submit") - await page.locator("#submit").click() - await syncLV(page) + expect(await changesB()).toEqual( + expect.arrayContaining([ + { attr: "data-phx-readonly", oldValue: null, newValue: "false" }, + { attr: "readonly", oldValue: null, newValue: "" }, + { attr: "data-phx-readonly", oldValue: "false", newValue: null }, + { attr: "readonly", oldValue: "", newValue: null }, + ]), + ); + await expect(page.locator("input[name=a]")).toHaveAttribute("readonly"); + await page.locator("#submit").click(); + await syncLV(page); + await expect(page.locator("input[name=a]")).toHaveAttribute("readonly"); + }); + + test(`${path} - button disabled state is restored after submits`, async ({ + page, + }) => { + await page.goto(path); + await syncLV(page); + const changes = attributeMutations(page, "#submit"); + await page.locator("#submit").click(); + await syncLV(page); // submit button is disabled while submitting, but then restored - expect(await changes()).toEqual(expect.arrayContaining([ - {attr: "data-phx-disabled", oldValue: null, newValue: "false"}, - {attr: "disabled", oldValue: null, newValue: ""}, - {attr: "class", oldValue: null, newValue: "phx-submit-loading"}, - {attr: "data-phx-disabled", oldValue: "false", newValue: null}, - {attr: "disabled", oldValue: "", newValue: null}, - {attr: "class", oldValue: "phx-submit-loading", newValue: null}, - ])) - }) - - test(`${path} - non-form button (phx-disable-with) disabled state is restored after click`, async ({page}) => { - await page.goto(path) - await syncLV(page) - const changes = attributeMutations(page, "button[type=button]") - await page.locator("button[type=button]").click() - await syncLV(page) + expect(await changes()).toEqual( + expect.arrayContaining([ + { attr: "data-phx-disabled", oldValue: null, newValue: "false" }, + { attr: "disabled", oldValue: null, newValue: "" }, + { attr: "class", oldValue: null, newValue: "phx-submit-loading" }, + { attr: "data-phx-disabled", oldValue: "false", newValue: null }, + { attr: "disabled", oldValue: "", newValue: null }, + { attr: "class", oldValue: "phx-submit-loading", newValue: null }, + ]), + ); + }); + + test(`${path} - non-form button (phx-disable-with) disabled state is restored after click`, async ({ + page, + }) => { + await page.goto(path); + await syncLV(page); + const changes = attributeMutations(page, "button[type=button]"); + await page.locator("button[type=button]").click(); + await syncLV(page); // submit button is disabled while submitting, but then restored - expect(await changes()).toEqual(expect.arrayContaining([ - {attr: "data-phx-disabled", oldValue: null, newValue: "false"}, - {attr: "disabled", oldValue: null, newValue: ""}, - {attr: "class", oldValue: null, newValue: "phx-click-loading"}, - {attr: "data-phx-disabled", oldValue: "false", newValue: null}, - {attr: "disabled", oldValue: "", newValue: null}, - {attr: "class", oldValue: "phx-click-loading", newValue: null}, - ])) - }) - }) - - for(const additionalParams of ["live-component", ""]){ - const append = additionalParams.length ? ` ${additionalParams}` : "" + expect(await changes()).toEqual( + expect.arrayContaining([ + { attr: "data-phx-disabled", oldValue: null, newValue: "false" }, + { attr: "disabled", oldValue: null, newValue: "" }, + { attr: "class", oldValue: null, newValue: "phx-click-loading" }, + { attr: "data-phx-disabled", oldValue: "false", newValue: null }, + { attr: "disabled", oldValue: "", newValue: null }, + { attr: "class", oldValue: "phx-click-loading", newValue: null }, + ]), + ); + }); + }); + + for (const additionalParams of ["live-component", ""]) { + const append = additionalParams.length ? ` ${additionalParams}` : ""; test.describe(`${path}${append} - form recovery`, () => { - test("form state is recovered when socket reconnects", async ({page}) => { - let webSocketEvents = [] - page.on("websocket", ws => { - ws.on("framesent", event => webSocketEvents.push({type: "sent", payload: event.payload})) - ws.on("framereceived", event => webSocketEvents.push({type: "received", payload: event.payload})) - ws.on("close", () => webSocketEvents.push({type: "close"})) - }) - - await page.goto(path + "?" + additionalParams) - await syncLV(page) - - await page.locator("input[name=b]").fill("test") - await page.locator("input[name=c]").fill("hello world") - await expect(page.locator("input[name=c]")).toBeFocused() - await syncLV(page) - - await page.evaluate(() => new Promise((resolve) => window.liveSocket.disconnect(resolve))) - await expect(page.locator(".phx-loading")).toHaveCount(1) - - expect(webSocketEvents).toEqual(expect.arrayContaining([ - {type: "sent", payload: expect.stringContaining("phx_join")}, - {type: "received", payload: expect.stringContaining("phx_reply")}, - {type: "close"}, - ])) - - webSocketEvents = [] - - await page.evaluate(() => window.liveSocket.connect()) - await syncLV(page) - await expect(page.locator(".phx-loading")).toHaveCount(0) - - await expect(page.locator("input[name=b]")).toHaveValue("test") + test("form state is recovered when socket reconnects", async ({ + page, + }) => { + let webSocketEvents = []; + page.on("websocket", (ws) => { + ws.on("framesent", (event) => + webSocketEvents.push({ type: "sent", payload: event.payload }), + ); + ws.on("framereceived", (event) => + webSocketEvents.push({ type: "received", payload: event.payload }), + ); + ws.on("close", () => webSocketEvents.push({ type: "close" })); + }); + + await page.goto(path + "?" + additionalParams); + await syncLV(page); + + await page.locator("input[name=b]").fill("test"); + await page.locator("input[name=c]").fill("hello world"); + await expect(page.locator("input[name=c]")).toBeFocused(); + await syncLV(page); + + await page.evaluate( + () => new Promise((resolve) => window.liveSocket.disconnect(resolve)), + ); + await expect(page.locator(".phx-loading")).toHaveCount(1); + + expect(webSocketEvents).toEqual( + expect.arrayContaining([ + { type: "sent", payload: expect.stringContaining("phx_join") }, + { type: "received", payload: expect.stringContaining("phx_reply") }, + { type: "close" }, + ]), + ); + + webSocketEvents = []; + + await page.evaluate(() => window.liveSocket.connect()); + await syncLV(page); + await expect(page.locator(".phx-loading")).toHaveCount(0); + + await expect(page.locator("input[name=b]")).toHaveValue("test"); // c should still be focused (at least when not using a nested LV) - if(path === "/form"){ - await expect(page.locator("input[name=c]")).toBeFocused() + if (path === "/form") { + await expect(page.locator("input[name=c]")).toBeFocused(); } - expect(webSocketEvents).toEqual(expect.arrayContaining([ - {type: "sent", payload: expect.stringContaining("phx_join")}, - {type: "received", payload: expect.stringContaining("phx_reply")}, - {type: "sent", payload: expect.stringMatching(/event.*_unused_a=&a=foo&_unused_b=&b=test/)}, - ])) - }) - - test("JS command in phx-change works during recovery", async ({page}) => { - await page.goto(path + "?" + additionalParams + "&js-change=1") - await syncLV(page) - - await page.locator("input[name=b]").fill("test") + expect(webSocketEvents).toEqual( + expect.arrayContaining([ + { type: "sent", payload: expect.stringContaining("phx_join") }, + { type: "received", payload: expect.stringContaining("phx_reply") }, + { + type: "sent", + payload: expect.stringMatching( + /event.*_unused_a=&a=foo&_unused_b=&b=test/, + ), + }, + ]), + ); + }); + + test("JS command in phx-change works during recovery", async ({ + page, + }) => { + await page.goto(path + "?" + additionalParams + "&js-change=1"); + await syncLV(page); + + await page.locator("input[name=b]").fill("test"); // blur, otherwise the input would not be morphed anyway - await page.locator("input[name=b]").blur() - await expect(page.locator("form")).toHaveAttribute("phx-change", /push/) - await syncLV(page) - - await page.evaluate(() => new Promise((resolve) => window.liveSocket.disconnect(resolve))) - await expect(page.locator(".phx-loading")).toHaveCount(1) - - await page.evaluate(() => window.liveSocket.connect()) - await syncLV(page) - await expect(page.locator(".phx-loading")).toHaveCount(0) - await expect(page.locator("input[name=b]")).toHaveValue("test") - }) - - test("does not recover when form is missing id", async ({page}) => { - await page.goto(`${path}?no-id&${additionalParams}`) - await syncLV(page) - - await page.locator("input[name=b]").fill("test") + await page.locator("input[name=b]").blur(); + await expect(page.locator("form")).toHaveAttribute( + "phx-change", + /push/, + ); + await syncLV(page); + + await page.evaluate( + () => new Promise((resolve) => window.liveSocket.disconnect(resolve)), + ); + await expect(page.locator(".phx-loading")).toHaveCount(1); + + await page.evaluate(() => window.liveSocket.connect()); + await syncLV(page); + await expect(page.locator(".phx-loading")).toHaveCount(0); + await expect(page.locator("input[name=b]")).toHaveValue("test"); + }); + + test("does not recover when form is missing id", async ({ page }) => { + await page.goto(`${path}?no-id&${additionalParams}`); + await syncLV(page); + + await page.locator("input[name=b]").fill("test"); // blur, otherwise the input would not be morphed anyway - await page.locator("input[name=b]").blur() - await syncLV(page) + await page.locator("input[name=b]").blur(); + await syncLV(page); - await page.evaluate(() => new Promise((resolve) => window.liveSocket.disconnect(resolve))) - await expect(page.locator(".phx-loading")).toHaveCount(1) + await page.evaluate( + () => new Promise((resolve) => window.liveSocket.disconnect(resolve)), + ); + await expect(page.locator(".phx-loading")).toHaveCount(1); - await page.evaluate(() => window.liveSocket.connect()) - await syncLV(page) - await expect(page.locator(".phx-loading")).toHaveCount(0) + await page.evaluate(() => window.liveSocket.connect()); + await syncLV(page); + await expect(page.locator(".phx-loading")).toHaveCount(0); - await expect(page.locator("input[name=b]")).toHaveValue("bar") - }) + await expect(page.locator("input[name=b]")).toHaveValue("bar"); + }); - test("does not recover when form is missing phx-change", async ({page}) => { - await page.goto(`${path}?no-change-event&${additionalParams}`) - await syncLV(page) + test("does not recover when form is missing phx-change", async ({ + page, + }) => { + await page.goto(`${path}?no-change-event&${additionalParams}`); + await syncLV(page); - await page.locator("input[name=b]").fill("test") + await page.locator("input[name=b]").fill("test"); // blur, otherwise the input would not be morphed anyway - await page.locator("input[name=b]").blur() - await syncLV(page) + await page.locator("input[name=b]").blur(); + await syncLV(page); - await page.evaluate(() => new Promise((resolve) => window.liveSocket.disconnect(resolve))) - await expect(page.locator(".phx-loading")).toHaveCount(1) + await page.evaluate( + () => new Promise((resolve) => window.liveSocket.disconnect(resolve)), + ); + await expect(page.locator(".phx-loading")).toHaveCount(1); - await page.evaluate(() => window.liveSocket.connect()) - await syncLV(page) - await expect(page.locator(".phx-loading")).toHaveCount(0) + await page.evaluate(() => window.liveSocket.connect()); + await syncLV(page); + await expect(page.locator(".phx-loading")).toHaveCount(0); - await expect(page.locator("input[name=b]")).toHaveValue("bar") - }) + await expect(page.locator("input[name=b]")).toHaveValue("bar"); + }); - test("phx-auto-recover", async ({page}) => { - await page.goto(`${path}?phx-auto-recover=custom-recovery&${additionalParams}`) - await syncLV(page) + test("phx-auto-recover", async ({ page }) => { + await page.goto( + `${path}?phx-auto-recover=custom-recovery&${additionalParams}`, + ); + await syncLV(page); - await page.locator("input[name=b]").fill("test") + await page.locator("input[name=b]").fill("test"); // blur, otherwise the input would not be morphed anyway - await page.locator("input[name=b]").blur() - await syncLV(page) - - await page.evaluate(() => new Promise((resolve) => window.liveSocket.disconnect(resolve))) - await expect(page.locator(".phx-loading")).toHaveCount(1) - - const webSocketEvents = [] - page.on("websocket", ws => { - ws.on("framesent", event => webSocketEvents.push({type: "sent", payload: event.payload})) - ws.on("framereceived", event => webSocketEvents.push({type: "received", payload: event.payload})) - ws.on("close", () => webSocketEvents.push({type: "close"})) - }) - - await page.evaluate(() => window.liveSocket.connect()) - await syncLV(page) - await expect(page.locator(".phx-loading")).toHaveCount(0) - - await expect(page.locator("input[name=b]")).toHaveValue("custom value from server") - - expect(webSocketEvents).toEqual(expect.arrayContaining([ - {type: "sent", payload: expect.stringContaining("phx_join")}, - {type: "received", payload: expect.stringContaining("phx_reply")}, - {type: "sent", payload: expect.stringMatching(/event.*_unused_a=&a=foo&_unused_b=&b=test/)}, - ])) - }) - }) + await page.locator("input[name=b]").blur(); + await syncLV(page); + + await page.evaluate( + () => new Promise((resolve) => window.liveSocket.disconnect(resolve)), + ); + await expect(page.locator(".phx-loading")).toHaveCount(1); + + const webSocketEvents = []; + page.on("websocket", (ws) => { + ws.on("framesent", (event) => + webSocketEvents.push({ type: "sent", payload: event.payload }), + ); + ws.on("framereceived", (event) => + webSocketEvents.push({ type: "received", payload: event.payload }), + ); + ws.on("close", () => webSocketEvents.push({ type: "close" })); + }); + + await page.evaluate(() => window.liveSocket.connect()); + await syncLV(page); + await expect(page.locator(".phx-loading")).toHaveCount(0); + + await expect(page.locator("input[name=b]")).toHaveValue( + "custom value from server", + ); + + expect(webSocketEvents).toEqual( + expect.arrayContaining([ + { type: "sent", payload: expect.stringContaining("phx_join") }, + { type: "received", payload: expect.stringContaining("phx_reply") }, + { + type: "sent", + payload: expect.stringMatching( + /event.*_unused_a=&a=foo&_unused_b=&b=test/, + ), + }, + ]), + ); + }); + }); } - test(`${path} - can submit form with button that has phx-click`, async ({page}) => { - await page.goto(`${path}?phx-auto-recover=custom-recovery`) - await syncLV(page) - - await expect(page.getByText("Form was submitted!")).toBeHidden() - - await page.getByRole("button", {name: "Submit with JS"}).click() - await syncLV(page) - - await expect(page.getByText("Form was submitted!")).toBeVisible() - }) - - test(`${path} - loading and locked states with latency`, async ({page, request}) => { - const nested = !!path.match(/nested/) - await page.goto(`${path}?phx-change=validate`) - await syncLV(page) - const {lv_pid} = await evalLV(page, ` + test(`${path} - can submit form with button that has phx-click`, async ({ + page, + }) => { + await page.goto(`${path}?phx-auto-recover=custom-recovery`); + await syncLV(page); + + await expect(page.getByText("Form was submitted!")).toBeHidden(); + + await page.getByRole("button", { name: "Submit with JS" }).click(); + await syncLV(page); + + await expect(page.getByText("Form was submitted!")).toBeVisible(); + }); + + test(`${path} - loading and locked states with latency`, async ({ + page, + request, + }) => { + const nested = !!path.match(/nested/); + await page.goto(`${path}?phx-change=validate`); + await syncLV(page); + const { lv_pid } = await evalLV( + page, + ` <<"#PID"::binary, pid::binary>> = inspect(self()) pid_parts = @@ -233,10 +301,18 @@ for(const path of ["/form/nested", "/form"]){ |> String.split(".") %{lv_pid: pid_parts} - `, nested ? "#nested" : undefined) - const ack = (event) => evalPlug(request, `send(IEx.Helpers.pid(${lv_pid[0]}, ${lv_pid[1]}, ${lv_pid[2]}), {:sync, "${event}"}); nil`) + `, + nested ? "#nested" : undefined, + ); + const ack = (event) => + evalPlug( + request, + `send(IEx.Helpers.pid(${lv_pid[0]}, ${lv_pid[1]}, ${lv_pid[2]}), {:sync, "${event}"}); nil`, + ); // we serialize the test by letting each event handler wait for a {:sync, event} message - await evalLV(page, ` + await evalLV( + page, + ` attach_hook(socket, :sync, :handle_event, fn event, _params, socket -> if event == "ping" do {:cont, socket} @@ -244,55 +320,69 @@ for(const path of ["/form/nested", "/form"]){ receive do {:sync, ^event} -> {:cont, socket} end end end) - `, nested ? "#nested" : undefined) - await expect(page.getByText("Form was submitted!")).toBeHidden() - const testForm = page.locator("#test-form") - const submitBtn = page.locator("#test-form #submit") - await page.locator("#test-form input[name=b]").fill("test") - await expect(testForm).toHaveClass("myformclass phx-change-loading") - await expect(testForm).toHaveAttribute("data-phx-ref-loading") + `, + nested ? "#nested" : undefined, + ); + await expect(page.getByText("Form was submitted!")).toBeHidden(); + const testForm = page.locator("#test-form"); + const submitBtn = page.locator("#test-form #submit"); + await page.locator("#test-form input[name=b]").fill("test"); + await expect(testForm).toHaveClass("myformclass phx-change-loading"); + await expect(testForm).toHaveAttribute("data-phx-ref-loading"); // form is locked on phx-change for any changed input - await expect(testForm).toHaveAttribute("data-phx-ref-lock") - await expect(testForm).toHaveAttribute("data-phx-ref-src") - await submitBtn.click() + await expect(testForm).toHaveAttribute("data-phx-ref-lock"); + await expect(testForm).toHaveAttribute("data-phx-ref-src"); + await submitBtn.click(); // change-loading and submit-loading classes exist simultaneously - await expect(testForm).toHaveClass("myformclass phx-change-loading phx-submit-loading") + await expect(testForm).toHaveClass( + "myformclass phx-change-loading phx-submit-loading", + ); // phx-change ack arrives and is removed - await ack("validate") - await expect(testForm).toHaveClass("myformclass phx-submit-loading") - await expect(submitBtn).toHaveClass("phx-submit-loading") - await expect(submitBtn).toHaveAttribute("data-phx-disable-with-restore", "Submit") - await expect(submitBtn).toHaveAttribute("data-phx-ref-loading") - await expect(testForm).toHaveAttribute("data-phx-ref-loading") - await expect(testForm).toHaveAttribute("data-phx-ref-src") - await expect(submitBtn).toHaveAttribute("data-phx-ref-lock") + await ack("validate"); + await expect(testForm).toHaveClass("myformclass phx-submit-loading"); + await expect(submitBtn).toHaveClass("phx-submit-loading"); + await expect(submitBtn).toHaveAttribute( + "data-phx-disable-with-restore", + "Submit", + ); + await expect(submitBtn).toHaveAttribute("data-phx-ref-loading"); + await expect(testForm).toHaveAttribute("data-phx-ref-loading"); + await expect(testForm).toHaveAttribute("data-phx-ref-src"); + await expect(submitBtn).toHaveAttribute("data-phx-ref-lock"); // form is not locked on submit - await expect(testForm).not.toHaveAttribute("data-phx-ref-lock") - await expect(submitBtn).toHaveAttribute("data-phx-ref-src") - await expect(submitBtn).toHaveAttribute("disabled", "") - await expect(submitBtn).toHaveAttribute("phx-disable-with", "Submitting") - await ack("save") - await expect(page.getByText("Form was submitted!")).toBeVisible() + await expect(testForm).not.toHaveAttribute("data-phx-ref-lock"); + await expect(submitBtn).toHaveAttribute("data-phx-ref-src"); + await expect(submitBtn).toHaveAttribute("disabled", ""); + await expect(submitBtn).toHaveAttribute("phx-disable-with", "Submitting"); + await ack("save"); + await expect(page.getByText("Form was submitted!")).toBeVisible(); // all refs are cleaned up - await expect(testForm).toHaveClass("myformclass") - await expect(submitBtn).toHaveClass("") - await expect(submitBtn).not.toHaveAttribute("data-phx-disable-with-restore") - await expect(submitBtn).not.toHaveAttribute("data-phx-ref-loading") - await expect(submitBtn).not.toHaveAttribute("data-phx-ref-lock") - await expect(submitBtn).not.toHaveAttribute("data-phx-ref-src") - await expect(submitBtn).not.toHaveAttribute("data-phx-ref-loading") - await expect(submitBtn).not.toHaveAttribute("data-phx-ref-lock") - await expect(submitBtn).not.toHaveAttribute("data-phx-ref-src") - await expect(submitBtn).not.toHaveAttribute("disabled") - await expect(submitBtn).toHaveAttribute("phx-disable-with", "Submitting") - }) + await expect(testForm).toHaveClass("myformclass"); + await expect(submitBtn).toHaveClass(""); + await expect(submitBtn).not.toHaveAttribute( + "data-phx-disable-with-restore", + ); + await expect(submitBtn).not.toHaveAttribute("data-phx-ref-loading"); + await expect(submitBtn).not.toHaveAttribute("data-phx-ref-lock"); + await expect(submitBtn).not.toHaveAttribute("data-phx-ref-src"); + await expect(submitBtn).not.toHaveAttribute("data-phx-ref-loading"); + await expect(submitBtn).not.toHaveAttribute("data-phx-ref-lock"); + await expect(submitBtn).not.toHaveAttribute("data-phx-ref-src"); + await expect(submitBtn).not.toHaveAttribute("disabled"); + await expect(submitBtn).toHaveAttribute("phx-disable-with", "Submitting"); + }); } -test("loading and locked states with latent clone", async ({page, request}) => { - await page.goto("/form/stream") - const formHook = page.locator("#form-stream-hook") - await syncLV(page) - const {lv_pid} = await evalLV(page, ` +test("loading and locked states with latent clone", async ({ + page, + request, +}) => { + await page.goto("/form/stream"); + const formHook = page.locator("#form-stream-hook"); + await syncLV(page); + const { lv_pid } = await evalLV( + page, + ` <<"#PID"::binary, pid::binary>> = inspect(self()) pid_parts = @@ -302,11 +392,18 @@ test("loading and locked states with latent clone", async ({page, request}) => { |> String.split(".") %{lv_pid: pid_parts} - `) - const ack = (event) => evalPlug(request, `send(IEx.Helpers.pid(${lv_pid[0]}, ${lv_pid[1]}, ${lv_pid[2]}), {:sync, "${event}"}); nil`) + `, + ); + const ack = (event) => + evalPlug( + request, + `send(IEx.Helpers.pid(${lv_pid[0]}, ${lv_pid[1]}, ${lv_pid[2]}), {:sync, "${event}"}); nil`, + ); // we serialize the test by letting each event handler wait for a {:sync, event} message // excluding the ping messages from our hook - await evalLV(page, ` + await evalLV( + page, + ` attach_hook(socket, :sync, :handle_event, fn event, _params, socket -> if event == "ping" do {:cont, socket} @@ -314,38 +411,39 @@ test("loading and locked states with latent clone", async ({page, request}) => { receive do {:sync, ^event} -> {:cont, socket} end end end) - `) - await expect(formHook).toHaveText("pong") - const testForm = page.locator("#test-form") - const testInput = page.locator("#test-form input[name=myname]") - const submitBtn = page.locator("#test-form button") + `, + ); + await expect(formHook).toHaveText("pong"); + const testForm = page.locator("#test-form"); + const testInput = page.locator("#test-form input[name=myname]"); + const submitBtn = page.locator("#test-form button"); // initial 3 stream items - await expect(page.locator("#form-stream li")).toHaveCount(3) - await testInput.fill("1") - await testInput.fill("2") + await expect(page.locator("#form-stream li")).toHaveCount(3); + await testInput.fill("1"); + await testInput.fill("2"); // form is locked on phx-change and stream remains unchanged - await expect(testForm).toHaveClass("phx-change-loading") - await expect(testInput).toHaveClass("phx-change-loading") - await expect(testForm).toHaveAttribute("data-phx-ref-loading") - await expect(testForm).toHaveAttribute("data-phx-ref-src") - await expect(testInput).toHaveAttribute("data-phx-ref-loading") - await expect(testInput).toHaveAttribute("data-phx-ref-src") + await expect(testForm).toHaveClass("phx-change-loading"); + await expect(testInput).toHaveClass("phx-change-loading"); + await expect(testForm).toHaveAttribute("data-phx-ref-loading"); + await expect(testForm).toHaveAttribute("data-phx-ref-src"); + await expect(testInput).toHaveAttribute("data-phx-ref-loading"); + await expect(testInput).toHaveAttribute("data-phx-ref-src"); // now we submit - await submitBtn.click() - await expect(testForm).toHaveClass("phx-change-loading phx-submit-loading") - await expect(submitBtn).toHaveText("Saving...") - await expect(testInput).toHaveClass("phx-change-loading") - await expect(testForm).toHaveAttribute("data-phx-ref-loading") - await expect(testForm).toHaveAttribute("data-phx-ref-src") - await expect(testInput).toHaveAttribute("data-phx-ref-loading") - await expect(testInput).toHaveAttribute("data-phx-ref-src") + await submitBtn.click(); + await expect(testForm).toHaveClass("phx-change-loading phx-submit-loading"); + await expect(submitBtn).toHaveText("Saving..."); + await expect(testInput).toHaveClass("phx-change-loading"); + await expect(testForm).toHaveAttribute("data-phx-ref-loading"); + await expect(testForm).toHaveAttribute("data-phx-ref-src"); + await expect(testInput).toHaveAttribute("data-phx-ref-loading"); + await expect(testInput).toHaveAttribute("data-phx-ref-src"); // now we ack the two change events - await ack("validate") + await ack("validate"); // the form is still locked, therefore we still have 3 elements - await expect(page.locator("#form-stream li")).toHaveCount(3) - await ack("validate") + await expect(page.locator("#form-stream li")).toHaveCount(3); + await ack("validate"); // on unlock, cloned stream items that are added on each phx-change are applied to DOM - await expect(page.locator("#form-stream li")).toHaveCount(5) + await expect(page.locator("#form-stream li")).toHaveCount(5); // after clones are applied, the stream item hooks are mounted // note that the form still awaits the submit ack, but it is not locked, // therefore the updates from the phx-change are already applied @@ -354,21 +452,21 @@ test("loading and locked states with latent clone", async ({page, request}) => { "*%{id: 2}pong", "*%{id: 3}pong", "*%{id: 4}", - "*%{id: 5}" - ]) + "*%{id: 5}", + ]); // still saving - await expect(submitBtn).toHaveText("Saving...") - await expect(testForm).toHaveClass("phx-submit-loading") - await expect(testInput).toHaveAttribute("readonly", "") - await expect(submitBtn).toHaveClass("phx-submit-loading") - await expect(testForm).toHaveAttribute("data-phx-ref-loading") - await expect(testForm).toHaveAttribute("data-phx-ref-src") - await expect(testInput).toHaveAttribute("data-phx-ref-loading") - await expect(testInput).toHaveAttribute("data-phx-ref-src") - await expect(submitBtn).toHaveAttribute("data-phx-ref-loading") - await expect(submitBtn).toHaveAttribute("data-phx-ref-src") + await expect(submitBtn).toHaveText("Saving..."); + await expect(testForm).toHaveClass("phx-submit-loading"); + await expect(testInput).toHaveAttribute("readonly", ""); + await expect(submitBtn).toHaveClass("phx-submit-loading"); + await expect(testForm).toHaveAttribute("data-phx-ref-loading"); + await expect(testForm).toHaveAttribute("data-phx-ref-src"); + await expect(testInput).toHaveAttribute("data-phx-ref-loading"); + await expect(testInput).toHaveAttribute("data-phx-ref-src"); + await expect(submitBtn).toHaveAttribute("data-phx-ref-loading"); + await expect(submitBtn).toHaveAttribute("data-phx-ref-src"); // now we ack the submit - await ack("save") + await ack("save"); // submit adds 1 more stream item and new hook is mounted await expect(page.locator("#form-stream li")).toHaveText([ "*%{id: 1}pong", @@ -376,160 +474,180 @@ test("loading and locked states with latent clone", async ({page, request}) => { "*%{id: 3}pong", "*%{id: 4}pong", "*%{id: 5}pong", - "*%{id: 6}pong" - ]) - await expect(submitBtn).toHaveText("Submit") - await expect(submitBtn).toHaveAttribute("phx-disable-with", "Saving...") - await expect(testForm).not.toHaveClass("phx-submit-loading") - await expect(testInput).not.toHaveAttribute("readonly") - await expect(submitBtn).not.toHaveClass("phx-submit-loading") - await expect(testForm).not.toHaveAttribute("data-phx-ref") - await expect(testForm).not.toHaveAttribute("data-phx-ref-src") - await expect(testInput).not.toHaveAttribute("data-phx-ref") - await expect(testInput).not.toHaveAttribute("data-phx-ref-src") - await expect(submitBtn).not.toHaveAttribute("data-phx-ref") - await expect(submitBtn).not.toHaveAttribute("data-phx-ref-src") -}) - -test("can dynamically add/remove inputs (ecto sort_param/drop_param)", async ({page}) => { - await page.goto("/form/dynamic-inputs") - await syncLV(page) - - const formData = () => page.locator("form").evaluate(form => Object.fromEntries(new FormData(form).entries())) + "*%{id: 6}pong", + ]); + await expect(submitBtn).toHaveText("Submit"); + await expect(submitBtn).toHaveAttribute("phx-disable-with", "Saving..."); + await expect(testForm).not.toHaveClass("phx-submit-loading"); + await expect(testInput).not.toHaveAttribute("readonly"); + await expect(submitBtn).not.toHaveClass("phx-submit-loading"); + await expect(testForm).not.toHaveAttribute("data-phx-ref"); + await expect(testForm).not.toHaveAttribute("data-phx-ref-src"); + await expect(testInput).not.toHaveAttribute("data-phx-ref"); + await expect(testInput).not.toHaveAttribute("data-phx-ref-src"); + await expect(submitBtn).not.toHaveAttribute("data-phx-ref"); + await expect(submitBtn).not.toHaveAttribute("data-phx-ref-src"); +}); + +test("can dynamically add/remove inputs (ecto sort_param/drop_param)", async ({ + page, +}) => { + await page.goto("/form/dynamic-inputs"); + await syncLV(page); + + const formData = () => + page + .locator("form") + .evaluate((form) => Object.fromEntries(new FormData(form).entries())); expect(await formData()).toEqual({ "my_form[name]": "", - "my_form[users_drop][]": "" - }) - - await page.locator("#my-form_name").fill("Test") - await page.getByRole("button", {name: "add more"}).click() - - expect(await formData()).toEqual(expect.objectContaining({ - "my_form[name]": "Test", - "my_form[users][0][name]": "", - })) - - await page.locator("#my-form_users_0_name").fill("User A") - await page.getByRole("button", {name: "add more"}).click() - await page.getByRole("button", {name: "add more"}).click() - - await page.locator("#my-form_users_1_name").fill("User B") - await page.locator("#my-form_users_2_name").fill("User C") - - expect(await formData()).toEqual(expect.objectContaining({ - "my_form[name]": "Test", "my_form[users_drop][]": "", - "my_form[users][0][name]": "User A", - "my_form[users][1][name]": "User B", - "my_form[users][2][name]": "User C" - })) + }); + + await page.locator("#my-form_name").fill("Test"); + await page.getByRole("button", { name: "add more" }).click(); + + expect(await formData()).toEqual( + expect.objectContaining({ + "my_form[name]": "Test", + "my_form[users][0][name]": "", + }), + ); + + await page.locator("#my-form_users_0_name").fill("User A"); + await page.getByRole("button", { name: "add more" }).click(); + await page.getByRole("button", { name: "add more" }).click(); + + await page.locator("#my-form_users_1_name").fill("User B"); + await page.locator("#my-form_users_2_name").fill("User C"); + + expect(await formData()).toEqual( + expect.objectContaining({ + "my_form[name]": "Test", + "my_form[users_drop][]": "", + "my_form[users][0][name]": "User A", + "my_form[users][1][name]": "User B", + "my_form[users][2][name]": "User C", + }), + ); // remove User B - await page.locator("button[name=\"my_form[users_drop][]\"][value=\"1\"]").click() - - expect(await formData()).toEqual(expect.objectContaining({ - "my_form[name]": "Test", - "my_form[users_drop][]": "", - "my_form[users][0][name]": "User A", - "my_form[users][1][name]": "User C" - })) -}) - -test("can dynamically add/remove inputs using checkboxes", async ({page}) => { - await page.goto("/form/dynamic-inputs?checkboxes=1") - await syncLV(page) - - const formData = () => page.locator("form").evaluate(form => Object.fromEntries(new FormData(form).entries())) + await page.locator('button[name="my_form[users_drop][]"][value="1"]').click(); + + expect(await formData()).toEqual( + expect.objectContaining({ + "my_form[name]": "Test", + "my_form[users_drop][]": "", + "my_form[users][0][name]": "User A", + "my_form[users][1][name]": "User C", + }), + ); +}); + +test("can dynamically add/remove inputs using checkboxes", async ({ page }) => { + await page.goto("/form/dynamic-inputs?checkboxes=1"); + await syncLV(page); + + const formData = () => + page + .locator("form") + .evaluate((form) => Object.fromEntries(new FormData(form).entries())); expect(await formData()).toEqual({ "my_form[name]": "", - "my_form[users_drop][]": "" - }) - - await page.locator("#my-form_name").fill("Test") - await page.locator("label", {hasText: "add more"}).click() - - expect(await formData()).toEqual(expect.objectContaining({ - "my_form[name]": "Test", - "my_form[users][0][name]": "", - })) - - await page.locator("#my-form_users_0_name").fill("User A") - await page.locator("label", {hasText: "add more"}).click() - await page.locator("label", {hasText: "add more"}).click() - - await page.locator("#my-form_users_1_name").fill("User B") - await page.locator("#my-form_users_2_name").fill("User C") - - expect(await formData()).toEqual(expect.objectContaining({ - "my_form[name]": "Test", "my_form[users_drop][]": "", - "my_form[users][0][name]": "User A", - "my_form[users][1][name]": "User B", - "my_form[users][2][name]": "User C" - })) + }); + + await page.locator("#my-form_name").fill("Test"); + await page.locator("label", { hasText: "add more" }).click(); + + expect(await formData()).toEqual( + expect.objectContaining({ + "my_form[name]": "Test", + "my_form[users][0][name]": "", + }), + ); + + await page.locator("#my-form_users_0_name").fill("User A"); + await page.locator("label", { hasText: "add more" }).click(); + await page.locator("label", { hasText: "add more" }).click(); + + await page.locator("#my-form_users_1_name").fill("User B"); + await page.locator("#my-form_users_2_name").fill("User C"); + + expect(await formData()).toEqual( + expect.objectContaining({ + "my_form[name]": "Test", + "my_form[users_drop][]": "", + "my_form[users][0][name]": "User A", + "my_form[users][1][name]": "User B", + "my_form[users][2][name]": "User C", + }), + ); // remove User B - await page.locator("input[name=\"my_form[users_drop][]\"][value=\"1\"]").click() - - expect(await formData()).toEqual(expect.objectContaining({ - "my_form[name]": "Test", - "my_form[users_drop][]": "", - "my_form[users][0][name]": "User A", - "my_form[users][1][name]": "User C" - })) -}) + await page.locator('input[name="my_form[users_drop][]"][value="1"]').click(); + + expect(await formData()).toEqual( + expect.objectContaining({ + "my_form[name]": "Test", + "my_form[users_drop][]": "", + "my_form[users][0][name]": "User A", + "my_form[users][1][name]": "User C", + }), + ); +}); // phx-feedback-for was removed in LiveView 1.0, but we still test the shim applied in // test_helper.exs layout for backwards compatibility -test("phx-no-feedback is applied correctly for backwards-compatible-shims", async ({page}) => { - await page.goto("/form/feedback") - await syncLV(page) - - await expect(page.locator("[phx-feedback-for=myfeedback]")).toBeHidden() - await page.getByRole("button", {name: "+"}).click() - await syncLV(page) - await expect(page.locator("[phx-feedback-for=myfeedback]")).toBeHidden() - await expect(page.getByText("Validate count")).toContainText("0") - - await page.locator("input[name=name]").fill("Test") - await syncLV(page) - await expect(page.locator("[phx-feedback-for=myfeedback]")).toBeHidden() - await expect(page.getByText("Validate count")).toContainText("1") - - await page.locator("input[name=myfeedback]").fill("Test") - await syncLV(page) - await expect(page.getByText("Validate count")).toContainText("2") - await expect(page.locator("[phx-feedback-for=myfeedback]")).toBeVisible() +test("phx-no-feedback is applied correctly for backwards-compatible-shims", async ({ + page, +}) => { + await page.goto("/form/feedback"); + await syncLV(page); + + await expect(page.locator("[phx-feedback-for=myfeedback]")).toBeHidden(); + await page.getByRole("button", { name: "+" }).click(); + await syncLV(page); + await expect(page.locator("[phx-feedback-for=myfeedback]")).toBeHidden(); + await expect(page.getByText("Validate count")).toContainText("0"); + + await page.locator("input[name=name]").fill("Test"); + await syncLV(page); + await expect(page.locator("[phx-feedback-for=myfeedback]")).toBeHidden(); + await expect(page.getByText("Validate count")).toContainText("1"); + + await page.locator("input[name=myfeedback]").fill("Test"); + await syncLV(page); + await expect(page.getByText("Validate count")).toContainText("2"); + await expect(page.locator("[phx-feedback-for=myfeedback]")).toBeVisible(); // feedback appears on submit - await page.reload() - await syncLV(page) - await expect(page.locator("[phx-feedback-for=myfeedback]")).toBeHidden() + await page.reload(); + await syncLV(page); + await expect(page.locator("[phx-feedback-for=myfeedback]")).toBeHidden(); - await page.getByRole("button", {name: "Submit"}).click() - await syncLV(page) - await expect(page.getByText("Submit count")).toContainText("1") - await expect(page.locator("[phx-feedback-for=myfeedback]")).toBeVisible() + await page.getByRole("button", { name: "Submit" }).click(); + await syncLV(page); + await expect(page.getByText("Submit count")).toContainText("1"); + await expect(page.locator("[phx-feedback-for=myfeedback]")).toBeVisible(); // feedback hides on reset - await page.getByRole("button", {name: "Reset"}).click() - await syncLV(page) - await expect(page.locator("[phx-feedback-for=myfeedback]")).toBeHidden() + await page.getByRole("button", { name: "Reset" }).click(); + await syncLV(page); + await expect(page.locator("[phx-feedback-for=myfeedback]")).toBeHidden(); // can toggle feedback visibility - await page.reload() - await syncLV(page) - await expect(page.locator("[data-feedback-container]")).toBeHidden() - - await page.getByRole("button", {name: "Toggle feedback"}).click() - await syncLV(page) - await expect(page.locator("[data-feedback-container]")).toBeVisible() - - await page.getByRole("button", {name: "Toggle feedback"}).click() - await syncLV(page) - await expect(page.locator("[data-feedback-container]")).toBeHidden() -}) - - + await page.reload(); + await syncLV(page); + await expect(page.locator("[data-feedback-container]")).toBeHidden(); + + await page.getByRole("button", { name: "Toggle feedback" }).click(); + await syncLV(page); + await expect(page.locator("[data-feedback-container]")).toBeVisible(); + + await page.getByRole("button", { name: "Toggle feedback" }).click(); + await syncLV(page); + await expect(page.locator("[data-feedback-container]")).toBeHidden(); +}); diff --git a/test/e2e/tests/issues/2787.spec.js b/test/e2e/tests/issues/2787.spec.js index ce0ee677d1..cb91be64ff 100644 --- a/test/e2e/tests/issues/2787.spec.js +++ b/test/e2e/tests/issues/2787.spec.js @@ -1,38 +1,53 @@ -import {test, expect} from "../../test-fixtures" -import {syncLV} from "../../utils" +import { test, expect } from "../../test-fixtures"; +import { syncLV } from "../../utils"; -const selectOptions = (locator) => locator.evaluateAll(list => list.map(option => option.value)) +const selectOptions = (locator) => + locator.evaluateAll((list) => list.map((option) => option.value)); -test("select is properly cleared on submit", async ({page}) => { - await page.goto("/issues/2787") - await syncLV(page) +test("select is properly cleared on submit", async ({ page }) => { + await page.goto("/issues/2787"); + await syncLV(page); - const select1 = page.locator("#demo_select1") - const select2 = page.locator("#demo_select2") + const select1 = page.locator("#demo_select1"); + const select2 = page.locator("#demo_select2"); // at the beginning, both selects are empty - await expect(select1).toHaveValue("") - expect(await selectOptions(select1.locator("option"))).toEqual(["", "greetings", "goodbyes"]) - await expect(select2).toHaveValue("") - expect(await selectOptions(select2.locator("option"))).toEqual([""]) + await expect(select1).toHaveValue(""); + expect(await selectOptions(select1.locator("option"))).toEqual([ + "", + "greetings", + "goodbyes", + ]); + await expect(select2).toHaveValue(""); + expect(await selectOptions(select2.locator("option"))).toEqual([""]); // now we select greetings in the first select - await select1.selectOption("greetings") - await syncLV(page) + await select1.selectOption("greetings"); + await syncLV(page); // now the second select should have some greeting options - expect(await selectOptions(select2.locator("option"))).toEqual(["", "hello", "hallo", "hei"]) - await select2.selectOption("hei") - await syncLV(page) + expect(await selectOptions(select2.locator("option"))).toEqual([ + "", + "hello", + "hallo", + "hei", + ]); + await select2.selectOption("hei"); + await syncLV(page); // now we submit the form - await page.locator("button").click() + await page.locator("button").click(); // now, both selects should be empty again (this was the bug in #2787) - await expect(select1).toHaveValue("") - await expect(select2).toHaveValue("") + await expect(select1).toHaveValue(""); + await expect(select2).toHaveValue(""); // now we select goodbyes in the first select - await select1.selectOption("goodbyes") - await syncLV(page) - expect(await selectOptions(select2.locator("option"))).toEqual(["", "goodbye", "auf wiedersehen", "ha det bra"]) -}) + await select1.selectOption("goodbyes"); + await syncLV(page); + expect(await selectOptions(select2.locator("option"))).toEqual([ + "", + "goodbye", + "auf wiedersehen", + "ha det bra", + ]); +}); diff --git a/test/e2e/tests/issues/2965.spec.js b/test/e2e/tests/issues/2965.spec.js index c2e1fbccd6..a1fe627f87 100644 --- a/test/e2e/tests/issues/2965.spec.js +++ b/test/e2e/tests/issues/2965.spec.js @@ -1,30 +1,30 @@ -import {test, expect} from "../../test-fixtures" -import {syncLV} from "../../utils" -import {randomBytes} from "node:crypto" +import { test, expect } from "../../test-fixtures"; +import { syncLV } from "../../utils"; +import { randomBytes } from "node:crypto"; -test("can upload files with custom chunk hook", async ({page}) => { - await page.goto("/issues/2965") - await syncLV(page) +test("can upload files with custom chunk hook", async ({ page }) => { + await page.goto("/issues/2965"); + await syncLV(page); - const files = [] - for(let i = 1; i <= 20; i++){ + const files = []; + for (let i = 1; i <= 20; i++) { files.push({ name: `file${i}.txt`, mimeType: "text/plain", // random 100 kb buffer: randomBytes(100 * 1024), - }) + }); } - await page.locator("#fileinput").setInputFiles(files) - await syncLV(page) + await page.locator("#fileinput").setInputFiles(files); + await syncLV(page); // wait for uploads to finish - for(let i = 0; i < 20; i++){ - const row = page.locator("tbody tr").nth(i) - await expect(row).toContainText(`file${i + 1}.txt`) - await expect(row.locator("progress")).toHaveAttribute("value", "100") + for (let i = 0; i < 20; i++) { + const row = page.locator("tbody tr").nth(i); + await expect(row).toContainText(`file${i + 1}.txt`); + await expect(row.locator("progress")).toHaveAttribute("value", "100"); } // all uploads are finished! -}) +}); diff --git a/test/e2e/tests/issues/3026.spec.js b/test/e2e/tests/issues/3026.spec.js index 104e0a069c..4b4b312f99 100644 --- a/test/e2e/tests/issues/3026.spec.js +++ b/test/e2e/tests/issues/3026.spec.js @@ -1,39 +1,39 @@ -import {test, expect} from "../../test-fixtures" -import {syncLV} from "../../utils" +import { test, expect } from "../../test-fixtures"; +import { syncLV } from "../../utils"; -test("LiveComponent is re-rendered when racing destory", async ({page}) => { - const errors = [] +test("LiveComponent is re-rendered when racing destory", async ({ page }) => { + const errors = []; page.on("pageerror", (err) => { - errors.push(err) - }) + errors.push(err); + }); - await page.goto("/issues/3026") - await syncLV(page) + await page.goto("/issues/3026"); + await syncLV(page); - await expect(page.locator("input[name='name']")).toHaveValue("John") + await expect(page.locator("input[name='name']")).toHaveValue("John"); // submitting the form unloads the LiveComponent, but it is re-added shortly after - await page.locator("button").click() - await syncLV(page) + await page.locator("button").click(); + await syncLV(page); // the form elements inside the LC should still be visible - await expect(page.locator("input[name='name']")).toBeVisible() - await expect(page.locator("input[name='name']")).toHaveValue("John") + await expect(page.locator("input[name='name']")).toBeVisible(); + await expect(page.locator("input[name='name']")).toHaveValue("John"); // quickly toggle status - for(let i = 0; i < 5; i++){ - await page.locator("select[name='status']").selectOption("connecting") - await syncLV(page) + for (let i = 0; i < 5; i++) { + await page.locator("select[name='status']").selectOption("connecting"); + await syncLV(page); // now the form is not rendered as status is connecting - await expect(page.locator("input[name='name']")).toBeHidden() + await expect(page.locator("input[name='name']")).toBeHidden(); // set back to loading - await page.locator("select[name='status']").selectOption("loaded") - await syncLV(page) + await page.locator("select[name='status']").selectOption("loaded"); + await syncLV(page); // now the form is not rendered as status is connecting - await expect(page.locator("input[name='name']")).toBeVisible() + await expect(page.locator("input[name='name']")).toBeVisible(); } // no js errors should be thrown - expect(errors).toEqual([]) -}) + expect(errors).toEqual([]); +}); diff --git a/test/e2e/tests/issues/3040.spec.js b/test/e2e/tests/issues/3040.spec.js index 657cfa92e5..8881e6dc16 100644 --- a/test/e2e/tests/issues/3040.spec.js +++ b/test/e2e/tests/issues/3040.spec.js @@ -1,63 +1,67 @@ -import {test, expect} from "../../test-fixtures" -import {syncLV} from "../../utils" +import { test, expect } from "../../test-fixtures"; +import { syncLV } from "../../utils"; -test("click-away does not fire when triggering form submit", async ({page}) => { - await page.goto("/issues/3040") - await syncLV(page) +test("click-away does not fire when triggering form submit", async ({ + page, +}) => { + await page.goto("/issues/3040"); + await syncLV(page); - await page.getByRole("link", {name: "Add new"}).click() - await syncLV(page) + await page.getByRole("link", { name: "Add new" }).click(); + await syncLV(page); - const modal = page.locator("#my-modal-container") - await expect(modal).toBeVisible() + const modal = page.locator("#my-modal-container"); + await expect(modal).toBeVisible(); // focusFirst should have focused the input - await expect(page.locator("input[name='name']")).toBeFocused() + await expect(page.locator("input[name='name']")).toBeFocused(); // submit the form - await page.keyboard.press("Enter") - await syncLV(page) + await page.keyboard.press("Enter"); + await syncLV(page); - await expect(page.locator("form")).toHaveText("Form was submitted!") - await expect(modal).toBeVisible() + await expect(page.locator("form")).toHaveText("Form was submitted!"); + await expect(modal).toBeVisible(); // now click outside - await page.mouse.click(0, 0) - await syncLV(page) + await page.mouse.click(0, 0); + await syncLV(page); - await expect(modal).toBeHidden() -}) + await expect(modal).toBeHidden(); +}); // see also https://github.com/phoenixframework/phoenix_live_view/issues/1920 -test("does not close modal when moving mouse outside while held down", async ({page}) => { - await page.goto("/issues/3040") - await syncLV(page) +test("does not close modal when moving mouse outside while held down", async ({ + page, +}) => { + await page.goto("/issues/3040"); + await syncLV(page); - await page.getByRole("link", {name: "Add new"}).click() - await syncLV(page) + await page.getByRole("link", { name: "Add new" }).click(); + await syncLV(page); - const modal = page.locator("#my-modal-container") - await expect(modal).toBeVisible() + const modal = page.locator("#my-modal-container"); + await expect(modal).toBeVisible(); - await expect(page.locator("input[name='name']")).toBeFocused() - await page.locator("input[name='name']").fill("test") + await expect(page.locator("input[name='name']")).toBeFocused(); + await page.locator("input[name='name']").fill("test"); // we move the mouse inside the input field and then drag it outside // while holding the mouse button down - await page.mouse.move(434, 350) - await page.mouse.down() - await page.mouse.move(143, 350) - await page.mouse.up() + await page.mouse.move(434, 350); + await page.mouse.down(); + await page.mouse.move(143, 350); + await page.mouse.up(); // we expect the modal to still be visible because the mousedown happened // inside, not triggering phx-click-away - await expect(modal).toBeVisible() - await page.keyboard.press("Backspace") + await expect(modal).toBeVisible(); + await page.keyboard.press("Backspace"); - await expect(page.locator("input[name='name']")).toHaveValue("") - await expect(modal).toBeVisible() + await expect(page.locator("input[name='name']")).toHaveValue(""); + await expect(modal).toBeVisible(); // close modal with escape - await page.keyboard.press("Escape") - await expect(modal).toBeHidden() -}) + await page.keyboard.press("Escape"); + await expect(modal).toBeHidden(); +}); diff --git a/test/e2e/tests/issues/3047.spec.js b/test/e2e/tests/issues/3047.spec.js index 5c8c0a93cb..cfc0d38708 100644 --- a/test/e2e/tests/issues/3047.spec.js +++ b/test/e2e/tests/issues/3047.spec.js @@ -1,31 +1,60 @@ -import {test, expect} from "../../test-fixtures" -import {syncLV} from "../../utils" +import { test, expect } from "../../test-fixtures"; +import { syncLV } from "../../utils"; -const listItems = async (page) => page.locator("[phx-update=\"stream\"] > span").evaluateAll(list => list.map(el => el.id)) +const listItems = async (page) => + page + .locator('[phx-update="stream"] > span') + .evaluateAll((list) => list.map((el) => el.id)); -test("streams are not cleared in sticky live views", async ({page}) => { - await page.goto("/issues/3047/a") - await syncLV(page) - await expect(page.locator("#page")).toContainText("Page A") +test("streams are not cleared in sticky live views", async ({ page }) => { + await page.goto("/issues/3047/a"); + await syncLV(page); + await expect(page.locator("#page")).toContainText("Page A"); expect(await listItems(page)).toEqual([ - "items-1", "items-2", "items-3", "items-4", "items-5", - "items-6", "items-7", "items-8", "items-9", "items-10" - ]) + "items-1", + "items-2", + "items-3", + "items-4", + "items-5", + "items-6", + "items-7", + "items-8", + "items-9", + "items-10", + ]); - await page.getByRole("button", {name: "Reset"}).click() + await page.getByRole("button", { name: "Reset" }).click(); expect(await listItems(page)).toEqual([ - "items-5", "items-6", "items-7", "items-8", "items-9", "items-10", - "items-11", "items-12", "items-13", "items-14", "items-15" - ]) + "items-5", + "items-6", + "items-7", + "items-8", + "items-9", + "items-10", + "items-11", + "items-12", + "items-13", + "items-14", + "items-15", + ]); - await page.getByRole("link", {name: "Page B"}).click() - await syncLV(page) + await page.getByRole("link", { name: "Page B" }).click(); + await syncLV(page); // stream items should still be visible - await expect(page.locator("#page")).toContainText("Page B") + await expect(page.locator("#page")).toContainText("Page B"); expect(await listItems(page)).toEqual([ - "items-5", "items-6", "items-7", "items-8", "items-9", "items-10", - "items-11", "items-12", "items-13", "items-14", "items-15" - ]) -}) + "items-5", + "items-6", + "items-7", + "items-8", + "items-9", + "items-10", + "items-11", + "items-12", + "items-13", + "items-14", + "items-15", + ]); +}); diff --git a/test/e2e/tests/issues/3083.spec.js b/test/e2e/tests/issues/3083.spec.js index 0a92ed4c20..fe9381510b 100644 --- a/test/e2e/tests/issues/3083.spec.js +++ b/test/e2e/tests/issues/3083.spec.js @@ -1,30 +1,30 @@ -import {test, expect} from "../../test-fixtures" -import {syncLV, evalLV} from "../../utils" +import { test, expect } from "../../test-fixtures"; +import { syncLV, evalLV } from "../../utils"; -test("select multiple handles option updates properly", async ({page}) => { - await page.goto("/issues/3083?auto=false") - await syncLV(page) +test("select multiple handles option updates properly", async ({ page }) => { + await page.goto("/issues/3083?auto=false"); + await syncLV(page); - await expect(page.locator("select")).toHaveValues([]) + await expect(page.locator("select")).toHaveValues([]); - await evalLV(page, "send(self(), {:select, [1,2]}); nil") - await expect(page.locator("select")).toHaveValues(["1", "2"]) - await evalLV(page, "send(self(), {:select, [2,3]}); nil") - await expect(page.locator("select")).toHaveValues(["2", "3"]) + await evalLV(page, "send(self(), {:select, [1,2]}); nil"); + await expect(page.locator("select")).toHaveValues(["1", "2"]); + await evalLV(page, "send(self(), {:select, [2,3]}); nil"); + await expect(page.locator("select")).toHaveValues(["2", "3"]); // now focus the select by interacting with it - await page.locator("select").click({position: {x: 1, y: 1}}) - await expect(page.locator("select")).toHaveValues(["1"]) - await evalLV(page, "send(self(), {:select, [1,2]}); nil") + await page.locator("select").click({ position: { x: 1, y: 1 } }); + await expect(page.locator("select")).toHaveValues(["1"]); + await evalLV(page, "send(self(), {:select, [1,2]}); nil"); // because the select is focused, we do not expect the values to change - await expect(page.locator("select")).toHaveValues(["1"]) + await expect(page.locator("select")).toHaveValues(["1"]); // now blur the select by clicking on the body - await page.locator("body").click() - await expect(page.locator("select")).toHaveValues(["1"]) + await page.locator("body").click(); + await expect(page.locator("select")).toHaveValues(["1"]); // now update the selected values again - await evalLV(page, "send(self(), {:select, [3,4]}); nil") + await evalLV(page, "send(self(), {:select, [3,4]}); nil"); // we had a bug here, where the select was focused, despite the blur - await expect(page.locator("select")).not.toBeFocused() - await expect(page.locator("select")).toHaveValues(["3", "4"]) - await page.waitForTimeout(1000) -}) + await expect(page.locator("select")).not.toBeFocused(); + await expect(page.locator("select")).toHaveValues(["3", "4"]); + await page.waitForTimeout(1000); +}); diff --git a/test/e2e/tests/issues/3107.spec.js b/test/e2e/tests/issues/3107.spec.js index 25a34cf1f6..f071185a3c 100644 --- a/test/e2e/tests/issues/3107.spec.js +++ b/test/e2e/tests/issues/3107.spec.js @@ -1,14 +1,14 @@ -import {test, expect} from "../../test-fixtures" -import {syncLV} from "../../utils" +import { test, expect } from "../../test-fixtures"; +import { syncLV } from "../../utils"; -test("keeps value when updating select", async ({page}) => { - await page.goto("/issues/3107") - await syncLV(page) +test("keeps value when updating select", async ({ page }) => { + await page.goto("/issues/3107"); + await syncLV(page); - await expect(page.locator("select")).toHaveValue("ONE") + await expect(page.locator("select")).toHaveValue("ONE"); // focus the element and change the value, like a user would - await page.locator("select").focus() - await page.locator("select").selectOption("TWO") - await syncLV(page) - await expect(page.locator("select")).toHaveValue("TWO") -}) + await page.locator("select").focus(); + await page.locator("select").selectOption("TWO"); + await syncLV(page); + await expect(page.locator("select")).toHaveValue("TWO"); +}); diff --git a/test/e2e/tests/issues/3117.spec.js b/test/e2e/tests/issues/3117.spec.js index abad986bfc..a551f75a16 100644 --- a/test/e2e/tests/issues/3117.spec.js +++ b/test/e2e/tests/issues/3117.spec.js @@ -1,23 +1,23 @@ -import {test, expect} from "../../test-fixtures" -import {syncLV} from "../../utils" +import { test, expect } from "../../test-fixtures"; +import { syncLV } from "../../utils"; -test("LiveComponent with static FC root is not reset", async ({page}) => { - const errors = [] - page.on("pageerror", (err) => errors.push(err)) +test("LiveComponent with static FC root is not reset", async ({ page }) => { + const errors = []; + page.on("pageerror", (err) => errors.push(err)); - await page.goto("/issues/3117") - await syncLV(page) + await page.goto("/issues/3117"); + await syncLV(page); // clicking the button performs a live navigation - await page.locator("#navigate").click() - await syncLV(page) + await page.locator("#navigate").click(); + await syncLV(page); // the FC root should still be visible and not empty/skipped - await expect(page.locator("#row-1 .static")).toBeVisible() - await expect(page.locator("#row-2 .static")).toBeVisible() - await expect(page.locator("#row-1 .static")).toHaveText("static content") - await expect(page.locator("#row-2 .static")).toHaveText("static content") + await expect(page.locator("#row-1 .static")).toBeVisible(); + await expect(page.locator("#row-2 .static")).toBeVisible(); + await expect(page.locator("#row-1 .static")).toHaveText("static content"); + await expect(page.locator("#row-2 .static")).toHaveText("static content"); // no js errors should be thrown - expect(errors).toEqual([]) -}) + expect(errors).toEqual([]); +}); diff --git a/test/e2e/tests/issues/3169.spec.js b/test/e2e/tests/issues/3169.spec.js index a4bbe55ae9..11d77123fb 100644 --- a/test/e2e/tests/issues/3169.spec.js +++ b/test/e2e/tests/issues/3169.spec.js @@ -1,26 +1,30 @@ -import {test, expect} from "../../test-fixtures" -import {syncLV} from "../../utils" +import { test, expect } from "../../test-fixtures"; +import { syncLV } from "../../utils"; const inputVals = async (page) => { - return page.locator("input[type=\"text\"]").evaluateAll(list => list.map(i => i.value)) -} + return page + .locator('input[type="text"]') + .evaluateAll((list) => list.map((i) => i.value)); +}; -test("updates which add cids back on page are properly magic id change tracked", async ({page}) => { - await page.goto("/issues/3169") - await syncLV(page) +test("updates which add cids back on page are properly magic id change tracked", async ({ + page, +}) => { + await page.goto("/issues/3169"); + await syncLV(page); - await page.locator("#select-a").click() - await syncLV(page) - await expect(page.locator("body")).toContainText("FormColumn (c3)") - expect(await inputVals(page)).toEqual(["Record a", "Record a", "Record a"]) + await page.locator("#select-a").click(); + await syncLV(page); + await expect(page.locator("body")).toContainText("FormColumn (c3)"); + expect(await inputVals(page)).toEqual(["Record a", "Record a", "Record a"]); - await page.locator("#select-b").click() - await syncLV(page) - await expect(page.locator("body")).toContainText("FormColumn (c3)") - expect(await inputVals(page)).toEqual(["Record b", "Record b", "Record b"]) + await page.locator("#select-b").click(); + await syncLV(page); + await expect(page.locator("body")).toContainText("FormColumn (c3)"); + expect(await inputVals(page)).toEqual(["Record b", "Record b", "Record b"]); - await page.locator("#select-z").click() - await syncLV(page) - await expect(page.locator("body")).toContainText("FormColumn (c3)") - expect(await inputVals(page)).toEqual(["Record z", "Record z", "Record z"]) -}) + await page.locator("#select-z").click(); + await syncLV(page); + await expect(page.locator("body")).toContainText("FormColumn (c3)"); + expect(await inputVals(page)).toEqual(["Record z", "Record z", "Record z"]); +}); diff --git a/test/e2e/tests/issues/3194.spec.js b/test/e2e/tests/issues/3194.spec.js index ed00bb8510..4a35f6087d 100644 --- a/test/e2e/tests/issues/3194.spec.js +++ b/test/e2e/tests/issues/3194.spec.js @@ -1,24 +1,34 @@ -import {test, expect} from "../../test-fixtures" -import {syncLV} from "../../utils" +import { test, expect } from "../../test-fixtures"; +import { syncLV } from "../../utils"; -test("does not send event to wrong LV when submitting form with debounce blur", async ({page}) => { - const logs = [] - page.on("console", (e) => logs.push(e.text())) +test("does not send event to wrong LV when submitting form with debounce blur", async ({ + page, +}) => { + const logs = []; + page.on("console", (e) => logs.push(e.text())); - await page.goto("/issues/3194") - await syncLV(page) + await page.goto("/issues/3194"); + await syncLV(page); + + await page.locator("input").focus(); + await page.keyboard.type("hello"); + await page.keyboard.press("Enter"); + await expect(page).toHaveURL("/issues/3194/other"); - await page.locator("input").focus() - await page.keyboard.type("hello") - await page.keyboard.press("Enter") - await expect(page).toHaveURL("/issues/3194/other") - // give it some time for old events to reach the new LV // (this is the failure case!) - await page.waitForTimeout(50) + await page.waitForTimeout(50); // we navigated to another LV - expect(logs).toEqual(expect.arrayContaining([expect.stringMatching("destroyed: the child has been removed from the parent")])) + expect(logs).toEqual( + expect.arrayContaining([ + expect.stringMatching( + "destroyed: the child has been removed from the parent", + ), + ]), + ); // it should not have crashed - expect(logs).not.toEqual(expect.arrayContaining([expect.stringMatching("view crashed")])) -}) + expect(logs).not.toEqual( + expect.arrayContaining([expect.stringMatching("view crashed")]), + ); +}); diff --git a/test/e2e/tests/issues/3200.spec.js b/test/e2e/tests/issues/3200.spec.js index 6ebe2cc53b..9ffcb90b6e 100644 --- a/test/e2e/tests/issues/3200.spec.js +++ b/test/e2e/tests/issues/3200.spec.js @@ -1,27 +1,31 @@ -import {test, expect} from "../../test-fixtures" -import {syncLV} from "../../utils" +import { test, expect } from "../../test-fixtures"; +import { syncLV } from "../../utils"; // https://github.com/phoenixframework/phoenix_live_view/issues/3200 -test("phx-target='selector' is used correctly for form recovery", async ({page}) => { - const errors = [] - page.on("pageerror", (err) => errors.push(err)) +test("phx-target='selector' is used correctly for form recovery", async ({ + page, +}) => { + const errors = []; + page.on("pageerror", (err) => errors.push(err)); - await page.goto("/issues/3200/settings") - await syncLV(page) + await page.goto("/issues/3200/settings"); + await syncLV(page); - await page.getByRole("button", {name: "Messages"}).click() - await syncLV(page) - await expect(page).toHaveURL("/issues/3200/messages") + await page.getByRole("button", { name: "Messages" }).click(); + await syncLV(page); + await expect(page).toHaveURL("/issues/3200/messages"); - await page.locator("#new_message_input").fill("Hello") - await syncLV(page) + await page.locator("#new_message_input").fill("Hello"); + await syncLV(page); - await page.evaluate(() => new Promise((resolve) => window.liveSocket.disconnect(resolve))) - await expect(page.locator(".phx-loading")).toHaveCount(1) - - await page.evaluate(() => window.liveSocket.connect()) - await syncLV(page) + await page.evaluate( + () => new Promise((resolve) => window.liveSocket.disconnect(resolve)), + ); + await expect(page.locator(".phx-loading")).toHaveCount(1); - await expect(page.locator("#new_message_input")).toHaveValue("Hello") - expect(errors).toEqual([]) -}) + await page.evaluate(() => window.liveSocket.connect()); + await syncLV(page); + + await expect(page.locator("#new_message_input")).toHaveValue("Hello"); + expect(errors).toEqual([]); +}); diff --git a/test/e2e/tests/issues/3378.spec.js b/test/e2e/tests/issues/3378.spec.js index bbc0c968d1..a07ef4be87 100644 --- a/test/e2e/tests/issues/3378.spec.js +++ b/test/e2e/tests/issues/3378.spec.js @@ -1,21 +1,23 @@ -import {test, expect} from "../../test-fixtures" -import {syncLV} from "../../utils" +import { test, expect } from "../../test-fixtures"; +import { syncLV } from "../../utils"; -test("can rejoin with nested streams without errors", async ({page}) => { - const errors = [] +test("can rejoin with nested streams without errors", async ({ page }) => { + const errors = []; page.on("pageerror", (err) => { - errors.push(err) - }) + errors.push(err); + }); - await page.goto("/issues/3378") - await syncLV(page) + await page.goto("/issues/3378"); + await syncLV(page); - await expect(page.locator("#notifications")).toContainText("big") - await page.evaluate(() => new Promise((resolve) => window.liveSocket.disconnect(resolve))) + await expect(page.locator("#notifications")).toContainText("big"); + await page.evaluate( + () => new Promise((resolve) => window.liveSocket.disconnect(resolve)), + ); - await page.evaluate(() => window.liveSocket.connect()) - await syncLV(page) + await page.evaluate(() => window.liveSocket.connect()); + await syncLV(page); // no js errors should be thrown - expect(errors).toEqual([]) -}) + expect(errors).toEqual([]); +}); diff --git a/test/e2e/tests/issues/3448.spec.js b/test/e2e/tests/issues/3448.spec.js index bd6bd12689..519e71615f 100644 --- a/test/e2e/tests/issues/3448.spec.js +++ b/test/e2e/tests/issues/3448.spec.js @@ -1,17 +1,19 @@ -import {test, expect} from "../../test-fixtures" -import {syncLV} from "../../utils" +import { test, expect } from "../../test-fixtures"; +import { syncLV } from "../../utils"; // https://github.com/phoenixframework/phoenix_live_view/issues/3448 -test("focus is handled correctly when patching locked form", async ({page}) => { - await page.goto("/issues/3448") - await syncLV(page) +test("focus is handled correctly when patching locked form", async ({ + page, +}) => { + await page.goto("/issues/3448"); + await syncLV(page); - await page.evaluate(() => window.liveSocket.enableLatencySim(500)) + await page.evaluate(() => window.liveSocket.enableLatencySim(500)); - await page.locator("input[type=checkbox]").first().check() - await expect(page.locator("input#search")).toBeFocused() - await syncLV(page) + await page.locator("input[type=checkbox]").first().check(); + await expect(page.locator("input#search")).toBeFocused(); + await syncLV(page); // after the patch is applied, the input should still be focused - await expect(page.locator("input#search")).toBeFocused() -}) + await expect(page.locator("input#search")).toBeFocused(); +}); diff --git a/test/e2e/tests/issues/3496.spec.js b/test/e2e/tests/issues/3496.spec.js index 5a3bce0ad9..9ec0b0fc34 100644 --- a/test/e2e/tests/issues/3496.spec.js +++ b/test/e2e/tests/issues/3496.spec.js @@ -1,21 +1,27 @@ -import {test, expect} from "../../test-fixtures" -import {syncLV} from "../../utils" +import { test, expect } from "../../test-fixtures"; +import { syncLV } from "../../utils"; // https://github.com/phoenixframework/phoenix_live_view/issues/3496 -test("hook is initialized properly when reusing id between sticky and non sticky LiveViews", async ({page}) => { - const logs = [] - page.on("console", (e) => logs.push(e.text())) - const errors = [] - page.on("pageerror", (err) => errors.push(err)) +test("hook is initialized properly when reusing id between sticky and non sticky LiveViews", async ({ + page, +}) => { + const logs = []; + page.on("console", (e) => logs.push(e.text())); + const errors = []; + page.on("pageerror", (err) => errors.push(err)); - await page.goto("/issues/3496/a") - await syncLV(page) + await page.goto("/issues/3496/a"); + await syncLV(page); - await page.getByRole("link", {name: "Go to page B"}).click() - await syncLV(page) + await page.getByRole("link", { name: "Go to page B" }).click(); + await syncLV(page); - expect(logs.filter(e => e.includes("Hook mounted!"))).toHaveLength(2) - expect(logs).not.toEqual(expect.arrayContaining([expect.stringMatching("no hook found for custom element")])) + expect(logs.filter((e) => e.includes("Hook mounted!"))).toHaveLength(2); + expect(logs).not.toEqual( + expect.arrayContaining([ + expect.stringMatching("no hook found for custom element"), + ]), + ); // no uncaught exceptions - expect(errors).toEqual([]) -}) + expect(errors).toEqual([]); +}); diff --git a/test/e2e/tests/issues/3529.spec.js b/test/e2e/tests/issues/3529.spec.js index 30f257140e..84656c2bae 100644 --- a/test/e2e/tests/issues/3529.spec.js +++ b/test/e2e/tests/issues/3529.spec.js @@ -1,45 +1,48 @@ -import {test, expect} from "../../test-fixtures" -import {syncLV} from "../../utils" +import { test, expect } from "../../test-fixtures"; +import { syncLV } from "../../utils"; -const pageText = async (page) => await page.evaluate(() => document.querySelector("h1").innerText) +const pageText = async (page) => + await page.evaluate(() => document.querySelector("h1").innerText); // https://github.com/phoenixframework/phoenix_live_view/issues/3529 // https://github.com/phoenixframework/phoenix_live_view/pull/3625 -test("forward and backward navigation is handled properly (replaceRootHistory)", async ({page}) => { - await page.goto("/issues/3529") - await syncLV(page) +test("forward and backward navigation is handled properly (replaceRootHistory)", async ({ + page, +}) => { + await page.goto("/issues/3529"); + await syncLV(page); - let text = await pageText(page) - await page.getByRole("link", {name: "Navigate"}).click() - await syncLV(page) + let text = await pageText(page); + await page.getByRole("link", { name: "Navigate" }).click(); + await syncLV(page); // navigate remounts and changes the text - expect(await pageText(page)).not.toBe(text) - text = await pageText(page) + expect(await pageText(page)).not.toBe(text); + text = await pageText(page); - await page.getByRole("link", {name: "Patch"}).click() - await syncLV(page) + await page.getByRole("link", { name: "Patch" }).click(); + await syncLV(page); // patch does not remount - expect(await pageText(page)).toBe(text) + expect(await pageText(page)).toBe(text); // now we go back (should be patch again) - await page.goBack() - await syncLV(page) - expect(await pageText(page)).toBe(text) + await page.goBack(); + await syncLV(page); + expect(await pageText(page)).toBe(text); // and then we back to the initial page and use back/forward // this should be a navigate -> remount! - await page.goBack() - await syncLV(page) - expect(await pageText(page)).not.toBe(text) + await page.goBack(); + await syncLV(page); + expect(await pageText(page)).not.toBe(text); // navigate - await page.goForward() - await syncLV(page) - text = await pageText(page) + await page.goForward(); + await syncLV(page); + text = await pageText(page); // now back again (navigate) - await page.goBack() - await syncLV(page) - expect(await pageText(page)).not.toBe(text) -}) + await page.goBack(); + await syncLV(page); + expect(await pageText(page)).not.toBe(text); +}); diff --git a/test/e2e/tests/issues/3530.spec.js b/test/e2e/tests/issues/3530.spec.js index c8d058ac3c..82372d1c4e 100644 --- a/test/e2e/tests/issues/3530.spec.js +++ b/test/e2e/tests/issues/3530.spec.js @@ -1,46 +1,52 @@ -import {test, expect} from "../../test-fixtures" -import {syncLV} from "../../utils" +import { test, expect } from "../../test-fixtures"; +import { syncLV } from "../../utils"; // https://github.com/phoenixframework/phoenix_live_view/issues/3530 -test("hook is initialized properly when using a stream of nested LiveViews", async ({page}) => { - let logs = [] - page.on("console", (e) => logs.push(e.text())) - const errors = [] - page.on("pageerror", (err) => errors.push(err)) - - await page.goto("/issues/3530") - await syncLV(page) - - expect(errors).toEqual([]) - expect(logs.filter(e => e.includes("item-1 mounted"))).toHaveLength(1) - expect(logs.filter(e => e.includes("item-2 mounted"))).toHaveLength(1) - expect(logs.filter(e => e.includes("item-3 mounted"))).toHaveLength(1) - logs = [] - - await page.getByRole("link", {name: "patch a"}).click() - await syncLV(page) - - expect(errors).toEqual([]) - expect(logs.filter(e => e.includes("item-2 destroyed"))).toHaveLength(1) - expect(logs.filter(e => e.includes("item-1 destroyed"))).toHaveLength(0) - expect(logs.filter(e => e.includes("item-3 destroyed"))).toHaveLength(0) - logs = [] - - await page.getByRole("link", {name: "patch b"}).click() - await syncLV(page) - - expect(errors).toEqual([]) - expect(logs.filter(e => e.includes("item-1 destroyed"))).toHaveLength(1) - expect(logs.filter(e => e.includes("item-2 destroyed"))).toHaveLength(0) - expect(logs.filter(e => e.includes("item-3 destroyed"))).toHaveLength(0) - expect(logs.filter(e => e.includes("item-2 mounted"))).toHaveLength(1) - logs = [] - - await page.locator("div[phx-click=inc]").click() - await syncLV(page) - expect(logs.filter(e => e.includes("item-4 mounted"))).toHaveLength(1) - - expect(logs).not.toEqual(expect.arrayContaining([expect.stringMatching("no hook found for custom element")])) +test("hook is initialized properly when using a stream of nested LiveViews", async ({ + page, +}) => { + let logs = []; + page.on("console", (e) => logs.push(e.text())); + const errors = []; + page.on("pageerror", (err) => errors.push(err)); + + await page.goto("/issues/3530"); + await syncLV(page); + + expect(errors).toEqual([]); + expect(logs.filter((e) => e.includes("item-1 mounted"))).toHaveLength(1); + expect(logs.filter((e) => e.includes("item-2 mounted"))).toHaveLength(1); + expect(logs.filter((e) => e.includes("item-3 mounted"))).toHaveLength(1); + logs = []; + + await page.getByRole("link", { name: "patch a" }).click(); + await syncLV(page); + + expect(errors).toEqual([]); + expect(logs.filter((e) => e.includes("item-2 destroyed"))).toHaveLength(1); + expect(logs.filter((e) => e.includes("item-1 destroyed"))).toHaveLength(0); + expect(logs.filter((e) => e.includes("item-3 destroyed"))).toHaveLength(0); + logs = []; + + await page.getByRole("link", { name: "patch b" }).click(); + await syncLV(page); + + expect(errors).toEqual([]); + expect(logs.filter((e) => e.includes("item-1 destroyed"))).toHaveLength(1); + expect(logs.filter((e) => e.includes("item-2 destroyed"))).toHaveLength(0); + expect(logs.filter((e) => e.includes("item-3 destroyed"))).toHaveLength(0); + expect(logs.filter((e) => e.includes("item-2 mounted"))).toHaveLength(1); + logs = []; + + await page.locator("div[phx-click=inc]").click(); + await syncLV(page); + expect(logs.filter((e) => e.includes("item-4 mounted"))).toHaveLength(1); + + expect(logs).not.toEqual( + expect.arrayContaining([ + expect.stringMatching("no hook found for custom element"), + ]), + ); // no uncaught exceptions - expect(errors).toEqual([]) -}) + expect(errors).toEqual([]); +}); diff --git a/test/e2e/tests/issues/3612.spec.js b/test/e2e/tests/issues/3612.spec.js index 85da95642b..872e4277c5 100644 --- a/test/e2e/tests/issues/3612.spec.js +++ b/test/e2e/tests/issues/3612.spec.js @@ -1,15 +1,17 @@ -import {test, expect} from "../../test-fixtures" -import {syncLV} from "../../utils" +import { test, expect } from "../../test-fixtures"; +import { syncLV } from "../../utils"; // https://github.com/phoenixframework/phoenix_live_view/issues/3612 -test("sticky LiveView stays connected when using push_navigate", async ({page}) => { - await page.goto("/issues/3612/a") - await syncLV(page) - await expect(page.locator("h1")).toHaveText("Page A") - await page.getByRole("link", {name: "Go to page B"}).click() - await syncLV(page) - await expect(page.locator("h1")).toHaveText("Page B") - await page.getByRole("link", {name: "Go to page A"}).click() - await syncLV(page) - await expect(page.locator("h1")).toHaveText("Page A") -}) +test("sticky LiveView stays connected when using push_navigate", async ({ + page, +}) => { + await page.goto("/issues/3612/a"); + await syncLV(page); + await expect(page.locator("h1")).toHaveText("Page A"); + await page.getByRole("link", { name: "Go to page B" }).click(); + await syncLV(page); + await expect(page.locator("h1")).toHaveText("Page B"); + await page.getByRole("link", { name: "Go to page A" }).click(); + await syncLV(page); + await expect(page.locator("h1")).toHaveText("Page A"); +}); diff --git a/test/e2e/tests/issues/3647.spec.js b/test/e2e/tests/issues/3647.spec.js index 238b58e300..fb09e994e9 100644 --- a/test/e2e/tests/issues/3647.spec.js +++ b/test/e2e/tests/issues/3647.spec.js @@ -1,17 +1,19 @@ -import {test, expect} from "../../test-fixtures" -import {syncLV} from "../../utils" +import { test, expect } from "../../test-fixtures"; +import { syncLV } from "../../utils"; // https://github.com/phoenixframework/phoenix_live_view/issues/3647 -test("upload works when input event follows immediately afterwards", async ({page}) => { - await page.goto("/issues/3647") - await syncLV(page) +test("upload works when input event follows immediately afterwards", async ({ + page, +}) => { + await page.goto("/issues/3647"); + await syncLV(page); - await expect(page.locator("ul li")).toHaveCount(0) - await expect(page.locator("input[name=\"user[name]\"]")).toHaveValue("") + await expect(page.locator("ul li")).toHaveCount(0); + await expect(page.locator('input[name="user[name]"]')).toHaveValue(""); - await page.getByRole("button", {name: "Upload then Input"}).click() - await syncLV(page) + await page.getByRole("button", { name: "Upload then Input" }).click(); + await syncLV(page); - await expect(page.locator("ul li")).toHaveCount(1) - await expect(page.locator("input[name=\"user[name]\"]")).toHaveValue("0") -}) + await expect(page.locator("ul li")).toHaveCount(1); + await expect(page.locator('input[name="user[name]"]')).toHaveValue("0"); +}); diff --git a/test/e2e/tests/issues/3651.spec.js b/test/e2e/tests/issues/3651.spec.js index e98792ab5f..b46fad30ee 100644 --- a/test/e2e/tests/issues/3651.spec.js +++ b/test/e2e/tests/issues/3651.spec.js @@ -1,14 +1,18 @@ -import {test, expect} from "../../test-fixtures" -import {syncLV} from "../../utils" +import { test, expect } from "../../test-fixtures"; +import { syncLV } from "../../utils"; // https://github.com/phoenixframework/phoenix_live_view/issues/3651 -test("locked hook with dynamic id is properly cleared", async ({page}) => { - await page.goto("/issues/3651") - await syncLV(page) +test("locked hook with dynamic id is properly cleared", async ({ page }) => { + await page.goto("/issues/3651"); + await syncLV(page); - await expect(page.locator("#notice")).toBeHidden() + await expect(page.locator("#notice")).toBeHidden(); // we want to wait for some events to have been pushed - await page.waitForTimeout(100) - expect(await page.evaluate(() => parseInt(document.querySelector("#total").textContent))).toBeLessThanOrEqual(50) -}) + await page.waitForTimeout(100); + expect( + await page.evaluate(() => + parseInt(document.querySelector("#total").textContent), + ), + ).toBeLessThanOrEqual(50); +}); diff --git a/test/e2e/tests/issues/3656.spec.js b/test/e2e/tests/issues/3656.spec.js index f6f31efdfb..4a02cc78c1 100644 --- a/test/e2e/tests/issues/3656.spec.js +++ b/test/e2e/tests/issues/3656.spec.js @@ -1,21 +1,25 @@ -import {test, expect} from "../../test-fixtures" -import {syncLV, attributeMutations} from "../../utils" +import { test, expect } from "../../test-fixtures"; +import { syncLV, attributeMutations } from "../../utils"; // https://github.com/phoenixframework/phoenix_live_view/issues/3656 -test("phx-click-loading is removed from links in sticky LiveViews", async ({page}) => { - await page.goto("/issues/3656") - await syncLV(page) +test("phx-click-loading is removed from links in sticky LiveViews", async ({ + page, +}) => { + await page.goto("/issues/3656"); + await syncLV(page); - const changes = attributeMutations(page, "nav a") + const changes = attributeMutations(page, "nav a"); - const link = page.getByRole("link", {name: "Link 1"}) - await link.click() + const link = page.getByRole("link", { name: "Link 1" }); + await link.click(); - await syncLV(page) - await expect(link).not.toHaveClass("phx-click-loading") + await syncLV(page); + await expect(link).not.toHaveClass("phx-click-loading"); - expect(await changes()).toEqual(expect.arrayContaining([ - {attr: "class", oldValue: null, newValue: "phx-click-loading"}, - {attr: "class", oldValue: "phx-click-loading", newValue: ""}, - ])) -}) + expect(await changes()).toEqual( + expect.arrayContaining([ + { attr: "class", oldValue: null, newValue: "phx-click-loading" }, + { attr: "class", oldValue: "phx-click-loading", newValue: "" }, + ]), + ); +}); diff --git a/test/e2e/tests/issues/3658.spec.js b/test/e2e/tests/issues/3658.spec.js index c432d3f956..a305ffbf3d 100644 --- a/test/e2e/tests/issues/3658.spec.js +++ b/test/e2e/tests/issues/3658.spec.js @@ -1,15 +1,17 @@ -import {test, expect} from "../../test-fixtures" -import {syncLV} from "../../utils" +import { test, expect } from "../../test-fixtures"; +import { syncLV } from "../../utils"; // https://github.com/phoenixframework/phoenix_live_view/issues/3658 -test("phx-remove elements inside sticky LiveViews are not removed when navigating", async ({page}) => { - await page.goto("/issues/3658") - await syncLV(page) +test("phx-remove elements inside sticky LiveViews are not removed when navigating", async ({ + page, +}) => { + await page.goto("/issues/3658"); + await syncLV(page); - await expect(page.locator("#foo")).toBeVisible() - await page.getByRole("link", {name: "Link 1"}).click() + await expect(page.locator("#foo")).toBeVisible(); + await page.getByRole("link", { name: "Link 1" }).click(); - await syncLV(page) + await syncLV(page); // the bug would remove the element - await expect(page.locator("#foo")).toBeVisible() -}) + await expect(page.locator("#foo")).toBeVisible(); +}); diff --git a/test/e2e/tests/issues/3681.spec.js b/test/e2e/tests/issues/3681.spec.js index bacea06cfe..98f977ce59 100644 --- a/test/e2e/tests/issues/3681.spec.js +++ b/test/e2e/tests/issues/3681.spec.js @@ -1,22 +1,34 @@ -import {test, expect} from "../../test-fixtures" -import {syncLV} from "../../utils" +import { test, expect } from "../../test-fixtures"; +import { syncLV } from "../../utils"; // https://github.com/phoenixframework/phoenix_live_view/issues/3681 -test("streams in nested LiveViews are not reset when they share the same stream ref", async ({page, request}) => { -// this was a separate bug where child LiveViews accidentally shared the parent streams +test("streams in nested LiveViews are not reset when they share the same stream ref", async ({ + page, + request, +}) => { + // this was a separate bug where child LiveViews accidentally shared the parent streams // check that the initial render does not contain the messages-4 element twice - expect((await (await request.get("/issues/3681/away")).text()).match(/messages-4/g).length).toBe(1) + expect( + (await (await request.get("/issues/3681/away")).text()).match(/messages-4/g) + .length, + ).toBe(1); - await page.goto("/issues/3681") - await syncLV(page) + await page.goto("/issues/3681"); + await syncLV(page); - await expect(page.locator("#msgs-sticky > div")).toHaveCount(3) + await expect(page.locator("#msgs-sticky > div")).toHaveCount(3); - await page.getByRole("link", {name: "Go to a different LV with a (funcky) stream"}).click() - await syncLV(page) - await expect(page.locator("#msgs-sticky > div")).toHaveCount(3) + await page + .getByRole("link", { name: "Go to a different LV with a (funcky) stream" }) + .click(); + await syncLV(page); + await expect(page.locator("#msgs-sticky > div")).toHaveCount(3); - await page.getByRole("link", {name: "Go back to (the now borked) LV without a stream"}).click() - await syncLV(page) - await expect(page.locator("#msgs-sticky > div")).toHaveCount(3) -}) + await page + .getByRole("link", { + name: "Go back to (the now borked) LV without a stream", + }) + .click(); + await syncLV(page); + await expect(page.locator("#msgs-sticky > div")).toHaveCount(3); +}); diff --git a/test/e2e/tests/issues/3684.spec.js b/test/e2e/tests/issues/3684.spec.js index eecbd10aca..de000143ab 100644 --- a/test/e2e/tests/issues/3684.spec.js +++ b/test/e2e/tests/issues/3684.spec.js @@ -1,15 +1,15 @@ -import {test, expect} from "../../test-fixtures" -import {syncLV} from "../../utils" +import { test, expect } from "../../test-fixtures"; +import { syncLV } from "../../utils"; // https://github.com/phoenixframework/phoenix_live_view/issues/3684 -test("nested clones are correctly applied", async ({page}) => { - await page.goto("/issues/3684") - await syncLV(page) +test("nested clones are correctly applied", async ({ page }) => { + await page.goto("/issues/3684"); + await syncLV(page); - await expect(page.locator("#dewey")).not.toHaveAttribute("checked") + await expect(page.locator("#dewey")).not.toHaveAttribute("checked"); - await page.locator("#dewey").click() - await syncLV(page) + await page.locator("#dewey").click(); + await syncLV(page); - await expect(page.locator("#dewey")).toHaveAttribute("checked") -}) + await expect(page.locator("#dewey")).toHaveAttribute("checked"); +}); diff --git a/test/e2e/tests/issues/3686.spec.js b/test/e2e/tests/issues/3686.spec.js index 2e328c4af0..2065054d75 100644 --- a/test/e2e/tests/issues/3686.spec.js +++ b/test/e2e/tests/issues/3686.spec.js @@ -1,21 +1,21 @@ -import {test, expect} from "../../test-fixtures" -import {syncLV} from "../../utils" +import { test, expect } from "../../test-fixtures"; +import { syncLV } from "../../utils"; // https://github.com/phoenixframework/phoenix_live_view/issues/3686 -test("flash is copied across fallback redirect", async ({page}) => { - await page.goto("/issues/3686/a") - await syncLV(page) - await expect(page.locator("#flash")).toHaveText("%{}") +test("flash is copied across fallback redirect", async ({ page }) => { + await page.goto("/issues/3686/a"); + await syncLV(page); + await expect(page.locator("#flash")).toHaveText("%{}"); - await page.getByRole("button", {name: "To B"}).click() - await syncLV(page) - await expect(page.locator("#flash")).toContainText("Flash from A") + await page.getByRole("button", { name: "To B" }).click(); + await syncLV(page); + await expect(page.locator("#flash")).toContainText("Flash from A"); - await page.getByRole("button", {name: "To C"}).click() - await syncLV(page) - await expect(page.locator("#flash")).toContainText("Flash from B") + await page.getByRole("button", { name: "To C" }).click(); + await syncLV(page); + await expect(page.locator("#flash")).toContainText("Flash from B"); - await page.getByRole("button", {name: "To A"}).click() - await syncLV(page) - await expect(page.locator("#flash")).toContainText("Flash from C") -}) + await page.getByRole("button", { name: "To A" }).click(); + await syncLV(page); + await expect(page.locator("#flash")).toContainText("Flash from C"); +}); diff --git a/test/e2e/tests/issues/3709.spec.js b/test/e2e/tests/issues/3709.spec.js index b3e504027b..09f7d0252f 100644 --- a/test/e2e/tests/issues/3709.spec.js +++ b/test/e2e/tests/issues/3709.spec.js @@ -1,28 +1,40 @@ -import {test, expect} from "../../test-fixtures" -import {syncLV} from "../../utils" +import { test, expect } from "../../test-fixtures"; +import { syncLV } from "../../utils"; // https://github.com/phoenixframework/phoenix_live_view/issues/3709 -test("pendingDiffs don't race with navigation", async ({page}) => { - const logs = [] - page.on("console", (e) => logs.push(e.text())) - const errors = [] - page.on("pageerror", (err) => errors.push(err)) +test("pendingDiffs don't race with navigation", async ({ page }) => { + const logs = []; + page.on("console", (e) => logs.push(e.text())); + const errors = []; + page.on("pageerror", (err) => errors.push(err)); - await page.goto("/issues/3709/1") - await syncLV(page) - await expect(page.locator("body")).toContainText("id: 1") + await page.goto("/issues/3709/1"); + await syncLV(page); + await expect(page.locator("body")).toContainText("id: 1"); - await page.getByRole("button", {name: "Break Stuff"}).click() - await syncLV(page) + await page.getByRole("button", { name: "Break Stuff" }).click(); + await syncLV(page); - expect(logs).not.toEqual(expect.arrayContaining([expect.stringMatching("Cannot read properties of undefined (reading 's')")])) + expect(logs).not.toEqual( + expect.arrayContaining([ + expect.stringMatching( + "Cannot read properties of undefined (reading 's')", + ), + ]), + ); - await page.getByRole("link", {name: "Link 5"}).click() - await syncLV(page) - await expect(page.locator("body")).toContainText("id: 5") + await page.getByRole("link", { name: "Link 5" }).click(); + await syncLV(page); + await expect(page.locator("body")).toContainText("id: 5"); - expect(logs).not.toEqual(expect.arrayContaining([expect.stringMatching("Cannot set properties of undefined (setting 'newRender')")])) + expect(logs).not.toEqual( + expect.arrayContaining([ + expect.stringMatching( + "Cannot set properties of undefined (setting 'newRender')", + ), + ]), + ); // no uncaught exceptions - expect(errors).toEqual([]) -}) + expect(errors).toEqual([]); +}); diff --git a/test/e2e/tests/issues/3719.spec.js b/test/e2e/tests/issues/3719.spec.js index 826f52ce37..40c9c17047 100644 --- a/test/e2e/tests/issues/3719.spec.js +++ b/test/e2e/tests/issues/3719.spec.js @@ -1,20 +1,22 @@ -import {test, expect} from "../../test-fixtures" -import {syncLV} from "../../utils" +import { test, expect } from "../../test-fixtures"; +import { syncLV } from "../../utils"; // https://github.com/phoenixframework/phoenix_live_view/issues/3719 -test("target is properly decoded", async ({page}) => { - const logs = [] - page.on("console", (e) => logs.push(e.text())) +test("target is properly decoded", async ({ page }) => { + const logs = []; + page.on("console", (e) => logs.push(e.text())); - await page.goto("/issues/3719") - await syncLV(page) - await page.locator("#a").fill("foo") - await syncLV(page) - await expect(page.locator("#target")).toHaveText("[\"foo\"]") + await page.goto("/issues/3719"); + await syncLV(page); + await page.locator("#a").fill("foo"); + await syncLV(page); + await expect(page.locator("#target")).toHaveText('["foo"]'); - await page.locator("#b").fill("foo") - await syncLV(page) - await expect(page.locator("#target")).toHaveText("[\"foo\", \"bar\"]") + await page.locator("#b").fill("foo"); + await syncLV(page); + await expect(page.locator("#target")).toHaveText('["foo", "bar"]'); - expect(logs).not.toEqual(expect.arrayContaining([expect.stringMatching("view crashed")])) -}) + expect(logs).not.toEqual( + expect.arrayContaining([expect.stringMatching("view crashed")]), + ); +}); diff --git a/test/e2e/tests/js.spec.js b/test/e2e/tests/js.spec.js index 3c8dba43ba..6552356744 100644 --- a/test/e2e/tests/js.spec.js +++ b/test/e2e/tests/js.spec.js @@ -1,94 +1,130 @@ -import {test, expect} from "../test-fixtures" -import {syncLV, attributeMutations} from "../utils" +import { test, expect } from "../test-fixtures"; +import { syncLV, attributeMutations } from "../utils"; -test("toggle_attribute", async ({page}) => { - await page.goto("/js") - await syncLV(page) +test("toggle_attribute", async ({ page }) => { + await page.goto("/js"); + await syncLV(page); - await expect(page.locator("#my-modal")).toBeHidden() + await expect(page.locator("#my-modal")).toBeHidden(); - let changes = attributeMutations(page, "#my-modal") - await page.getByRole("button", {name: "toggle modal"}).click() + let changes = attributeMutations(page, "#my-modal"); + await page.getByRole("button", { name: "toggle modal" }).click(); // wait for the transition time (set to 50) - await page.waitForTimeout(100) - expect(await changes()).toEqual(expect.arrayContaining([ - {attr: "style", oldValue: "display: none;", newValue: "display: block;"}, - {attr: "aria-expanded", oldValue: "false", newValue: "true"}, - {attr: "open", oldValue: null, newValue: "true"}, - // chrome and firefox first transition from null to "" and then to "fade-in"; - // safari goes straight from null to "fade-in", therefore we do not perform an exact match - expect.objectContaining({attr: "class", newValue: "fade-in"}), - expect.objectContaining({attr: "class", oldValue: "fade-in"}), - ])) - await expect(page.locator("#my-modal")).not.toHaveClass("fade-in") - await expect(page.locator("#my-modal")).toHaveAttribute("aria-expanded", "true") - await expect(page.locator("#my-modal")).toHaveAttribute("open", "true") - await expect(page.locator("#my-modal")).toBeVisible() + await page.waitForTimeout(100); + expect(await changes()).toEqual( + expect.arrayContaining([ + { + attr: "style", + oldValue: "display: none;", + newValue: "display: block;", + }, + { attr: "aria-expanded", oldValue: "false", newValue: "true" }, + { attr: "open", oldValue: null, newValue: "true" }, + // chrome and firefox first transition from null to "" and then to "fade-in"; + // safari goes straight from null to "fade-in", therefore we do not perform an exact match + expect.objectContaining({ attr: "class", newValue: "fade-in" }), + expect.objectContaining({ attr: "class", oldValue: "fade-in" }), + ]), + ); + await expect(page.locator("#my-modal")).not.toHaveClass("fade-in"); + await expect(page.locator("#my-modal")).toHaveAttribute( + "aria-expanded", + "true", + ); + await expect(page.locator("#my-modal")).toHaveAttribute("open", "true"); + await expect(page.locator("#my-modal")).toBeVisible(); - changes = attributeMutations(page, "#my-modal") - await page.getByRole("button", {name: "toggle modal"}).click() + changes = attributeMutations(page, "#my-modal"); + await page.getByRole("button", { name: "toggle modal" }).click(); // wait for the transition time (set to 50) - await page.waitForTimeout(100) - expect(await changes()).toEqual(expect.arrayContaining([ - {attr: "style", oldValue: "display: block;", newValue: "display: none;"}, - {attr: "aria-expanded", oldValue: "true", newValue: "false"}, - {attr: "open", oldValue: "true", newValue: null}, - expect.objectContaining({attr: "class", newValue: "fade-out"}), - expect.objectContaining({attr: "class", oldValue: "fade-out"}), - ])) - await expect(page.locator("#my-modal")).not.toHaveClass("fade-out") - await expect(page.locator("#my-modal")).toHaveAttribute("aria-expanded", "false") - await expect(page.locator("#my-modal")).not.toHaveAttribute("open") - await expect(page.locator("#my-modal")).toBeHidden() -}) + await page.waitForTimeout(100); + expect(await changes()).toEqual( + expect.arrayContaining([ + { + attr: "style", + oldValue: "display: block;", + newValue: "display: none;", + }, + { attr: "aria-expanded", oldValue: "true", newValue: "false" }, + { attr: "open", oldValue: "true", newValue: null }, + expect.objectContaining({ attr: "class", newValue: "fade-out" }), + expect.objectContaining({ attr: "class", oldValue: "fade-out" }), + ]), + ); + await expect(page.locator("#my-modal")).not.toHaveClass("fade-out"); + await expect(page.locator("#my-modal")).toHaveAttribute( + "aria-expanded", + "false", + ); + await expect(page.locator("#my-modal")).not.toHaveAttribute("open"); + await expect(page.locator("#my-modal")).toBeHidden(); +}); -test("set and remove_attribute", async ({page}) => { - await page.goto("/js") - await syncLV(page) +test("set and remove_attribute", async ({ page }) => { + await page.goto("/js"); + await syncLV(page); - await expect(page.locator("#my-modal")).toBeHidden() + await expect(page.locator("#my-modal")).toBeHidden(); - let changes = attributeMutations(page, "#my-modal") - await page.getByRole("button", {name: "show modal"}).click() + let changes = attributeMutations(page, "#my-modal"); + await page.getByRole("button", { name: "show modal" }).click(); // wait for the transition time (set to 50) - await page.waitForTimeout(100) - expect(await changes()).toEqual(expect.arrayContaining([ - {attr: "style", oldValue: "display: none;", newValue: "display: block;"}, - {attr: "aria-expanded", oldValue: "false", newValue: "true"}, - {attr: "open", oldValue: null, newValue: "true"}, - expect.objectContaining({attr: "class", newValue: "fade-in"}), - expect.objectContaining({attr: "class", oldValue: "fade-in"}), - ])) - await expect(page.locator("#my-modal")).not.toHaveClass("fade-in") - await expect(page.locator("#my-modal")).toHaveAttribute("aria-expanded", "true") - await expect(page.locator("#my-modal")).toHaveAttribute("open", "true") - await expect(page.locator("#my-modal")).toBeVisible() + await page.waitForTimeout(100); + expect(await changes()).toEqual( + expect.arrayContaining([ + { + attr: "style", + oldValue: "display: none;", + newValue: "display: block;", + }, + { attr: "aria-expanded", oldValue: "false", newValue: "true" }, + { attr: "open", oldValue: null, newValue: "true" }, + expect.objectContaining({ attr: "class", newValue: "fade-in" }), + expect.objectContaining({ attr: "class", oldValue: "fade-in" }), + ]), + ); + await expect(page.locator("#my-modal")).not.toHaveClass("fade-in"); + await expect(page.locator("#my-modal")).toHaveAttribute( + "aria-expanded", + "true", + ); + await expect(page.locator("#my-modal")).toHaveAttribute("open", "true"); + await expect(page.locator("#my-modal")).toBeVisible(); - changes = attributeMutations(page, "#my-modal") - await page.getByRole("button", {name: "hide modal"}).click() + changes = attributeMutations(page, "#my-modal"); + await page.getByRole("button", { name: "hide modal" }).click(); // wait for the transition time (set to 50) - await page.waitForTimeout(100) - expect(await changes()).toEqual(expect.arrayContaining([ - {attr: "style", oldValue: "display: block;", newValue: "display: none;"}, - {attr: "aria-expanded", oldValue: "true", newValue: "false"}, - {attr: "open", oldValue: "true", newValue: null}, - expect.objectContaining({attr: "class", newValue: "fade-out"}), - expect.objectContaining({attr: "class", oldValue: "fade-out"}), - ])) - await expect(page.locator("#my-modal")).not.toHaveClass("fade-out") - await expect(page.locator("#my-modal")).toHaveAttribute("aria-expanded", "false") - await expect(page.locator("#my-modal")).not.toHaveAttribute("open") - await expect(page.locator("#my-modal")).toBeHidden() -}) + await page.waitForTimeout(100); + expect(await changes()).toEqual( + expect.arrayContaining([ + { + attr: "style", + oldValue: "display: block;", + newValue: "display: none;", + }, + { attr: "aria-expanded", oldValue: "true", newValue: "false" }, + { attr: "open", oldValue: "true", newValue: null }, + expect.objectContaining({ attr: "class", newValue: "fade-out" }), + expect.objectContaining({ attr: "class", oldValue: "fade-out" }), + ]), + ); + await expect(page.locator("#my-modal")).not.toHaveClass("fade-out"); + await expect(page.locator("#my-modal")).toHaveAttribute( + "aria-expanded", + "false", + ); + await expect(page.locator("#my-modal")).not.toHaveAttribute("open"); + await expect(page.locator("#my-modal")).toBeHidden(); +}); -test("ignore_attributes", async ({page}) => { - await page.goto("/js") - await syncLV(page) - await expect(page.locator("details")).not.toHaveAttribute("open") - await page.locator("details").click() - await expect(page.locator("details")).toHaveAttribute("open") +test("ignore_attributes", async ({ page }) => { + await page.goto("/js"); + await syncLV(page); + await expect(page.locator("details")).not.toHaveAttribute("open"); + await page.locator("details").click(); + await expect(page.locator("details")).toHaveAttribute("open"); // without ignore_attributes, the open attribute would be reset to false - await page.locator("details button").click() - await syncLV(page) - await expect(page.locator("details")).toHaveAttribute("open") -}) + await page.locator("details button").click(); + await syncLV(page); + await expect(page.locator("details")).toHaveAttribute("open"); +}); diff --git a/test/e2e/tests/navigation.spec.js b/test/e2e/tests/navigation.spec.js index 2cdaf15549..ecfb655791 100644 --- a/test/e2e/tests/navigation.spec.js +++ b/test/e2e/tests/navigation.spec.js @@ -1,361 +1,449 @@ -import {test, expect} from "../test-fixtures" -import {syncLV} from "../utils" - -let webSocketEvents = [] -let networkEvents = [] - -test.beforeEach(async ({page}) => { - networkEvents = [] - webSocketEvents = [] - - page.on("request", request => networkEvents.push({method: request.method(), url: request.url()})) - - page.on("websocket", ws => { - ws.on("framesent", event => webSocketEvents.push({type: "sent", payload: event.payload})) - ws.on("framereceived", event => webSocketEvents.push({type: "received", payload: event.payload})) - ws.on("close", () => webSocketEvents.push({type: "close"})) - }) -}) - -test("can navigate between LiveViews in the same live session over websocket", async ({page}) => { - await page.goto("/navigation/a") - await syncLV(page) +import { test, expect } from "../test-fixtures"; +import { syncLV } from "../utils"; + +let webSocketEvents = []; +let networkEvents = []; + +test.beforeEach(async ({ page }) => { + networkEvents = []; + webSocketEvents = []; + + page.on("request", (request) => + networkEvents.push({ method: request.method(), url: request.url() }), + ); + + page.on("websocket", (ws) => { + ws.on("framesent", (event) => + webSocketEvents.push({ type: "sent", payload: event.payload }), + ); + ws.on("framereceived", (event) => + webSocketEvents.push({ type: "received", payload: event.payload }), + ); + ws.on("close", () => webSocketEvents.push({ type: "close" })); + }); +}); + +test("can navigate between LiveViews in the same live session over websocket", async ({ + page, +}) => { + await page.goto("/navigation/a"); + await syncLV(page); expect(networkEvents).toEqual([ - {method: "GET", url: "http://localhost:4004/navigation/a"}, - {method: "GET", url: "http://localhost:4004/assets/phoenix/phoenix.min.js"}, - {method: "GET", url: "http://localhost:4004/assets/phoenix_live_view/phoenix_live_view.esm.js"}, - ]) + { method: "GET", url: "http://localhost:4004/navigation/a" }, + { + method: "GET", + url: "http://localhost:4004/assets/phoenix/phoenix.min.js", + }, + { + method: "GET", + url: "http://localhost:4004/assets/phoenix_live_view/phoenix_live_view.esm.js", + }, + ]); expect(webSocketEvents).toEqual([ - expect.objectContaining({type: "sent", payload: expect.stringContaining("phx_join")}), - expect.objectContaining({type: "received", payload: expect.stringContaining("phx_reply")}), - ]) + expect.objectContaining({ + type: "sent", + payload: expect.stringContaining("phx_join"), + }), + expect.objectContaining({ + type: "received", + payload: expect.stringContaining("phx_reply"), + }), + ]); // clear events - networkEvents = [] - webSocketEvents = [] + networkEvents = []; + webSocketEvents = []; // patch the LV - const length = await page.evaluate(() => window.history.length) - await page.getByRole("link", {name: "Patch this LiveView"}).click() - await syncLV(page) - expect(networkEvents).toEqual([]) + const length = await page.evaluate(() => window.history.length); + await page.getByRole("link", { name: "Patch this LiveView" }).click(); + await syncLV(page); + expect(networkEvents).toEqual([]); expect(webSocketEvents).toEqual([ - expect.objectContaining({type: "sent", payload: expect.stringContaining("live_patch")}), - expect.objectContaining({type: "received", payload: expect.stringContaining("phx_reply")}), - ]) - expect(await page.evaluate(() => window.history.length)).toEqual(length + 1) - - webSocketEvents = [] + expect.objectContaining({ + type: "sent", + payload: expect.stringContaining("live_patch"), + }), + expect.objectContaining({ + type: "received", + payload: expect.stringContaining("phx_reply"), + }), + ]); + expect(await page.evaluate(() => window.history.length)).toEqual(length + 1); + + webSocketEvents = []; // live navigation to other LV - await page.getByRole("link", {name: "LiveView B"}).click() - await syncLV(page) + await page.getByRole("link", { name: "LiveView B" }).click(); + await syncLV(page); - expect(networkEvents).toEqual([]) + expect(networkEvents).toEqual([]); // we don't assert the order of the events here, because they are not deterministic - expect(webSocketEvents).toEqual(expect.arrayContaining([ - {type: "sent", payload: expect.stringContaining("phx_leave")}, - {type: "sent", payload: expect.stringContaining("phx_join")}, - {type: "received", payload: expect.stringContaining("phx_close")}, - {type: "received", payload: expect.stringContaining("phx_reply")}, - {type: "received", payload: expect.stringContaining("phx_reply")}, - ])) -}) - -test("handles live redirect loops", async ({page}) => { - await page.goto("/navigation/redirectloop") - await syncLV(page) - - await page.getByRole("link", {name: "Redirect Loop"}).click() + expect(webSocketEvents).toEqual( + expect.arrayContaining([ + { type: "sent", payload: expect.stringContaining("phx_leave") }, + { type: "sent", payload: expect.stringContaining("phx_join") }, + { type: "received", payload: expect.stringContaining("phx_close") }, + { type: "received", payload: expect.stringContaining("phx_reply") }, + { type: "received", payload: expect.stringContaining("phx_reply") }, + ]), + ); +}); + +test("handles live redirect loops", async ({ page }) => { + await page.goto("/navigation/redirectloop"); + await syncLV(page); + + await page.getByRole("link", { name: "Redirect Loop" }).click(); await expect(async () => { - expect(webSocketEvents).toEqual(expect.arrayContaining([ - expect.objectContaining({type: "received", payload: expect.stringContaining("phx_error")}), - ])) - }).toPass() + expect(webSocketEvents).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "received", + payload: expect.stringContaining("phx_error"), + }), + ]), + ); + }).toPass(); // We need to wait for the LV to reconnect - await syncLV(page) - const message = page.locator("#message") - await expect(message).toHaveText("Too many redirects") -}) + await syncLV(page); + const message = page.locator("#message"); + await expect(message).toHaveText("Too many redirects"); +}); -test("popstate", async ({page}) => { - await page.goto("/navigation/a") - await syncLV(page) +test("popstate", async ({ page }) => { + await page.goto("/navigation/a"); + await syncLV(page); // clear network events - networkEvents = [] + networkEvents = []; - await page.getByRole("link", {name: "Patch this LiveView"}).click() - await syncLV(page) - await expect(page).toHaveURL(/\/navigation\/a\?/) - expect(networkEvents).toEqual([]) + await page.getByRole("link", { name: "Patch this LiveView" }).click(); + await syncLV(page); + await expect(page).toHaveURL(/\/navigation\/a\?/); + expect(networkEvents).toEqual([]); - await page.getByRole("link", {name: "LiveView B"}).click(), - await syncLV(page) - await expect(page).toHaveURL("/navigation/b") - expect(networkEvents).toEqual([]) + await page.getByRole("link", { name: "LiveView B" }).click(), + await syncLV(page); + await expect(page).toHaveURL("/navigation/b"); + expect(networkEvents).toEqual([]); - await page.goBack() - await syncLV(page) - expect(networkEvents).toEqual([]) - await expect(page).toHaveURL(/\/navigation\/a\?/) + await page.goBack(); + await syncLV(page); + expect(networkEvents).toEqual([]); + await expect(page).toHaveURL(/\/navigation\/a\?/); - await page.goBack() - await syncLV(page) - expect(networkEvents).toEqual([]) - await expect(page).toHaveURL("/navigation/a") + await page.goBack(); + await syncLV(page); + expect(networkEvents).toEqual([]); + await expect(page).toHaveURL("/navigation/a"); // and forward again - await page.goForward() - await page.goForward() - await syncLV(page) - await expect(page).toHaveURL("/navigation/b") + await page.goForward(); + await page.goForward(); + await syncLV(page); + await expect(page).toHaveURL("/navigation/b"); // everything was sent over the websocket, no network requests - expect(networkEvents).toEqual([]) -}) + expect(networkEvents).toEqual([]); +}); -test("patch with replace replaces history", async ({page}) => { - await page.goto("/navigation/a") - await syncLV(page) - const url = page.url() +test("patch with replace replaces history", async ({ page }) => { + await page.goto("/navigation/a"); + await syncLV(page); + const url = page.url(); - const length = await page.evaluate(() => window.history.length) + const length = await page.evaluate(() => window.history.length); - await page.getByRole("link", {name: "Patch (Replace)"}).click() - await syncLV(page) + await page.getByRole("link", { name: "Patch (Replace)" }).click(); + await syncLV(page); - expect(await page.evaluate(() => window.history.length)).toEqual(length) - expect(page.url()).not.toEqual(url) -}) + expect(await page.evaluate(() => window.history.length)).toEqual(length); + expect(page.url()).not.toEqual(url); +}); -test("falls back to http navigation when navigating between live sessions", async ({page, browserName}) => { - await page.goto("/navigation/a") - await syncLV(page) +test("falls back to http navigation when navigating between live sessions", async ({ + page, + browserName, +}) => { + await page.goto("/navigation/a"); + await syncLV(page); - networkEvents = [] - webSocketEvents = [] + networkEvents = []; + webSocketEvents = []; // live navigation to page in another live session - await page.getByRole("link", {name: "LiveView (other session)"}).click() - await syncLV(page) - - expect(networkEvents).toEqual(expect.arrayContaining([{method: "GET", url: "http://localhost:4004/stream"}])) - expect(webSocketEvents).toEqual(expect.arrayContaining([ - {type: "sent", payload: expect.stringContaining("phx_leave")}, - {type: "sent", payload: expect.stringContaining("phx_join")}, - {type: "received", payload: expect.stringMatching(/error.*unauthorized/)}, - ].concat(browserName === "webkit" ? [] : [{type: "close"}]))) + await page.getByRole("link", { name: "LiveView (other session)" }).click(); + await syncLV(page); + + expect(networkEvents).toEqual( + expect.arrayContaining([ + { method: "GET", url: "http://localhost:4004/stream" }, + ]), + ); + expect(webSocketEvents).toEqual( + expect.arrayContaining( + [ + { type: "sent", payload: expect.stringContaining("phx_leave") }, + { type: "sent", payload: expect.stringContaining("phx_join") }, + { + type: "received", + payload: expect.stringMatching(/error.*unauthorized/), + }, + ].concat(browserName === "webkit" ? [] : [{ type: "close" }]), + ), + ); // ^ webkit doesn't always seem to emit websocket close events -}) +}); -test("restores scroll position after navigation", async ({page}) => { - await page.goto("/navigation/b") - await syncLV(page) +test("restores scroll position after navigation", async ({ page }) => { + await page.goto("/navigation/b"); + await syncLV(page); - await expect(page.locator("#items")).toContainText("Item 42") + await expect(page.locator("#items")).toContainText("Item 42"); - expect(await page.evaluate(() => document.documentElement.scrollTop)).toEqual(0) - const offset = (await page.locator("#items-item-42").evaluate((el) => el.offsetTop)) - 200 - await page.evaluate((offset) => window.scrollTo(0, offset), offset) + expect(await page.evaluate(() => document.documentElement.scrollTop)).toEqual( + 0, + ); + const offset = + (await page.locator("#items-item-42").evaluate((el) => el.offsetTop)) - 200; + await page.evaluate((offset) => window.scrollTo(0, offset), offset); // LiveView only updates the scroll position every 100ms - await page.waitForTimeout(150) + await page.waitForTimeout(150); - await page.getByRole("link", {name: "Item 42"}).click() - await syncLV(page) + await page.getByRole("link", { name: "Item 42" }).click(); + await syncLV(page); - await page.goBack() - await syncLV(page) + await page.goBack(); + await syncLV(page); // scroll position is restored - await expect.poll( - async () => { - return await page.evaluate(() => document.documentElement.scrollTop) - }, - {message: "scrollTop not restored", timeout: 5000} - ).toBe(offset) -}) - -test("does not restore scroll position on custom container after navigation", async ({page}) => { - await page.goto("/navigation/b?container=1") - await syncLV(page) - - await expect(page.locator("#items")).toContainText("Item 42") - - expect(await page.locator("#my-scroll-container").evaluate((el) => el.scrollTop)).toEqual(0) - const offset = (await page.locator("#items-item-42").evaluate((el) => el.offsetTop)) - 200 - await page.locator("#my-scroll-container").evaluate((el, offset) => el.scrollTo(0, offset), offset) - - await page.getByRole("link", {name: "Item 42"}).click() - await syncLV(page) - - await page.goBack() - await syncLV(page) + await expect + .poll( + async () => { + return await page.evaluate(() => document.documentElement.scrollTop); + }, + { message: "scrollTop not restored", timeout: 5000 }, + ) + .toBe(offset); +}); + +test("does not restore scroll position on custom container after navigation", async ({ + page, +}) => { + await page.goto("/navigation/b?container=1"); + await syncLV(page); + + await expect(page.locator("#items")).toContainText("Item 42"); + + expect( + await page.locator("#my-scroll-container").evaluate((el) => el.scrollTop), + ).toEqual(0); + const offset = + (await page.locator("#items-item-42").evaluate((el) => el.offsetTop)) - 200; + await page + .locator("#my-scroll-container") + .evaluate((el, offset) => el.scrollTo(0, offset), offset); + + await page.getByRole("link", { name: "Item 42" }).click(); + await syncLV(page); + + await page.goBack(); + await syncLV(page); // scroll position is not restored - await expect.poll( - async () => { - return await page.locator("#my-scroll-container").evaluate((el) => el.scrollTop) - }, - {message: "scrollTop not restored", timeout: 5000} - ).toBe(0) -}) - -test("scrolls hash el into view", async ({page}) => { - await page.goto("/navigation/b") - await syncLV(page) - - await expect(page.locator("#items")).toContainText("Item 42") - - expect(await page.locator("#my-scroll-container").evaluate((el) => el.scrollTop)).toEqual(0) - const offset = (await page.locator("#items-item-42").evaluate((el) => el.offsetTop)) - 200 - - await page.getByRole("link", {name: "Go to 42"}).click() - await expect(page).toHaveURL("/navigation/b#items-item-42") - - let scrollTop = await page.evaluate(() => document.documentElement.scrollTop) - expect(scrollTop).not.toBe(0) - expect(scrollTop).toBeGreaterThanOrEqual(offset - 500) - expect(scrollTop).toBeLessThanOrEqual(offset + 500) - - await page.goto("/navigation/a") - await page.goto("/navigation/b#items-item-42") - - scrollTop = await page.evaluate(() => document.documentElement.scrollTop) - expect(scrollTop).not.toBe(0) - expect(scrollTop).toBeGreaterThanOrEqual(offset - 500) - expect(scrollTop).toBeLessThanOrEqual(offset + 500) -}) - -test("scrolls hash el into view after live navigation (issue #3452)", async ({page}) => { - await page.goto("/navigation/a") - await syncLV(page) - - await page.getByRole("link", {name: "Navigate to 42"}).click() - await expect(page).toHaveURL("/navigation/b#items-item-42") - const scrollTop = await page.evaluate(() => document.documentElement.scrollTop) - const offset = (await page.locator("#items-item-42").evaluate((el) => el.offsetTop)) - 200 - expect(scrollTop).not.toBe(0) - expect(scrollTop).toBeGreaterThanOrEqual(offset - 500) - expect(scrollTop).toBeLessThanOrEqual(offset + 500) -}) - -test("restores scroll position when navigating from dead view", async ({page}) => { - await page.goto("/navigation/b") - await syncLV(page) - - await expect(page.locator("#items")).toContainText("Item 42") - - expect(await page.evaluate(() => document.documentElement.scrollTop)).toEqual(0) - const offset = (await page.locator("#items-item-42").evaluate((el) => el.offsetTop)) - 200 - await page.evaluate((offset) => window.scrollTo(0, offset), offset) + await expect + .poll( + async () => { + return await page + .locator("#my-scroll-container") + .evaluate((el) => el.scrollTop); + }, + { message: "scrollTop not restored", timeout: 5000 }, + ) + .toBe(0); +}); + +test("scrolls hash el into view", async ({ page }) => { + await page.goto("/navigation/b"); + await syncLV(page); + + await expect(page.locator("#items")).toContainText("Item 42"); + + expect( + await page.locator("#my-scroll-container").evaluate((el) => el.scrollTop), + ).toEqual(0); + const offset = + (await page.locator("#items-item-42").evaluate((el) => el.offsetTop)) - 200; + + await page.getByRole("link", { name: "Go to 42" }).click(); + await expect(page).toHaveURL("/navigation/b#items-item-42"); + + let scrollTop = await page.evaluate(() => document.documentElement.scrollTop); + expect(scrollTop).not.toBe(0); + expect(scrollTop).toBeGreaterThanOrEqual(offset - 500); + expect(scrollTop).toBeLessThanOrEqual(offset + 500); + + await page.goto("/navigation/a"); + await page.goto("/navigation/b#items-item-42"); + + scrollTop = await page.evaluate(() => document.documentElement.scrollTop); + expect(scrollTop).not.toBe(0); + expect(scrollTop).toBeGreaterThanOrEqual(offset - 500); + expect(scrollTop).toBeLessThanOrEqual(offset + 500); +}); + +test("scrolls hash el into view after live navigation (issue #3452)", async ({ + page, +}) => { + await page.goto("/navigation/a"); + await syncLV(page); + + await page.getByRole("link", { name: "Navigate to 42" }).click(); + await expect(page).toHaveURL("/navigation/b#items-item-42"); + const scrollTop = await page.evaluate( + () => document.documentElement.scrollTop, + ); + const offset = + (await page.locator("#items-item-42").evaluate((el) => el.offsetTop)) - 200; + expect(scrollTop).not.toBe(0); + expect(scrollTop).toBeGreaterThanOrEqual(offset - 500); + expect(scrollTop).toBeLessThanOrEqual(offset + 500); +}); + +test("restores scroll position when navigating from dead view", async ({ + page, +}) => { + await page.goto("/navigation/b"); + await syncLV(page); + + await expect(page.locator("#items")).toContainText("Item 42"); + + expect(await page.evaluate(() => document.documentElement.scrollTop)).toEqual( + 0, + ); + const offset = + (await page.locator("#items-item-42").evaluate((el) => el.offsetTop)) - 200; + await page.evaluate((offset) => window.scrollTo(0, offset), offset); // LiveView only updates the scroll position every 100ms - await page.waitForTimeout(150) + await page.waitForTimeout(150); - await page.getByRole("link", {name: "Dead"}).click() - await page.waitForURL("/navigation/dead") + await page.getByRole("link", { name: "Dead" }).click(); + await page.waitForURL("/navigation/dead"); - await page.goBack() - await syncLV(page) + await page.goBack(); + await syncLV(page); // scroll position is restored - await expect.poll( - async () => { - return await page.evaluate(() => document.documentElement.scrollTop) - }, - {message: "scrollTop not restored", timeout: 5000} - ).toBe(offset) -}) - -test("navigating all the way back works without remounting (only patching)", async ({page}) => { - await page.goto("/navigation/a") - await syncLV(page) - networkEvents = [] - await page.getByRole("link", {name: "Patch this LiveView"}).click() - await syncLV(page) - await page.goBack() - await syncLV(page) - expect(networkEvents).toEqual([]) + await expect + .poll( + async () => { + return await page.evaluate(() => document.documentElement.scrollTop); + }, + { message: "scrollTop not restored", timeout: 5000 }, + ) + .toBe(offset); +}); + +test("navigating all the way back works without remounting (only patching)", async ({ + page, +}) => { + await page.goto("/navigation/a"); + await syncLV(page); + networkEvents = []; + await page.getByRole("link", { name: "Patch this LiveView" }).click(); + await syncLV(page); + await page.goBack(); + await syncLV(page); + expect(networkEvents).toEqual([]); // we only expect patch navigation - expect(webSocketEvents.filter(e => e.payload.indexOf("phx_leave") !== -1)).toHaveLength(0) + expect( + webSocketEvents.filter((e) => e.payload.indexOf("phx_leave") !== -1), + ).toHaveLength(0); // we patched 2 times - expect(webSocketEvents.filter(e => e.payload.indexOf("live_patch") !== -1)).toHaveLength(2) -}) + expect( + webSocketEvents.filter((e) => e.payload.indexOf("live_patch") !== -1), + ).toHaveLength(2); +}); // see https://github.com/phoenixframework/phoenix_live_view/pull/3513 // see https://github.com/phoenixframework/phoenix_live_view/issues/3536 -test("back and forward navigation types are tracked", async ({page}) => { - let consoleMessages = [] - page.on("console", msg => consoleMessages.push(msg.text())) +test("back and forward navigation types are tracked", async ({ page }) => { + let consoleMessages = []; + page.on("console", (msg) => consoleMessages.push(msg.text())); const getNavigationEvent = () => { - const ev = consoleMessages.find((e) => e.startsWith("navigate event")) - consoleMessages = [] - return JSON.parse(ev.slice(15)) - } + const ev = consoleMessages.find((e) => e.startsWith("navigate event")); + consoleMessages = []; + return JSON.parse(ev.slice(15)); + }; // initial page visit - await page.goto("/navigation/b") - await syncLV(page) - consoleMessages = [] - networkEvents = [] + await page.goto("/navigation/b"); + await syncLV(page); + consoleMessages = []; + networkEvents = []; // type: redirect - await page.getByRole("link", {name: "LiveView A"}).click() - await syncLV(page) + await page.getByRole("link", { name: "LiveView A" }).click(); + await syncLV(page); expect(getNavigationEvent()).toEqual({ href: "http://localhost:4004/navigation/a", patch: false, pop: false, - direction: "forward" - }) + direction: "forward", + }); // type: patch - await page.getByRole("link", {name: "Patch this LiveView"}).click() - await syncLV(page) + await page.getByRole("link", { name: "Patch this LiveView" }).click(); + await syncLV(page); expect(getNavigationEvent()).toEqual({ href: expect.stringMatching(/\/navigation\/a\?param=.*/), patch: true, pop: false, - direction: "forward" - }) + direction: "forward", + }); // back should also be type: patch - await page.goBack() - await expect(page).toHaveURL("/navigation/a") + await page.goBack(); + await expect(page).toHaveURL("/navigation/a"); expect(getNavigationEvent()).toEqual({ href: "http://localhost:4004/navigation/a", patch: true, pop: true, - direction: "backward" - }) - await page.goBack() - await expect(page).toHaveURL("/navigation/b") + direction: "backward", + }); + await page.goBack(); + await expect(page).toHaveURL("/navigation/b"); expect(getNavigationEvent()).toEqual({ href: "http://localhost:4004/navigation/b", patch: false, pop: true, - direction: "backward" - }) - await page.goForward() - await expect(page).toHaveURL("/navigation/a") + direction: "backward", + }); + await page.goForward(); + await expect(page).toHaveURL("/navigation/a"); expect(getNavigationEvent()).toEqual({ href: "http://localhost:4004/navigation/a", patch: false, pop: true, - direction: "forward" - }) - await page.goForward() - await expect(page).toHaveURL(/\/navigation\/a\?param=.*/) + direction: "forward", + }); + await page.goForward(); + await expect(page).toHaveURL(/\/navigation\/a\?param=.*/); expect(getNavigationEvent()).toEqual({ href: expect.stringMatching(/\/navigation\/a\?param=.*/), patch: true, pop: true, - direction: "forward" - }) + direction: "forward", + }); // we don't expect any full page reloads - expect(networkEvents).toEqual([]) + expect(networkEvents).toEqual([]); // we only expect 3 navigate navigations (from b to a, back from a to b, back to a) - expect(webSocketEvents.filter(e => e.payload.indexOf("phx_leave") !== -1)).toHaveLength(3) + expect( + webSocketEvents.filter((e) => e.payload.indexOf("phx_leave") !== -1), + ).toHaveLength(3); // we expect 3 patches (a to a with param, back to a, back to a with param) - expect(webSocketEvents.filter(e => e.payload.indexOf("live_patch") !== -1)).toHaveLength(3) -}) + expect( + webSocketEvents.filter((e) => e.payload.indexOf("live_patch") !== -1), + ).toHaveLength(3); +}); diff --git a/test/e2e/tests/select.spec.js b/test/e2e/tests/select.spec.js index f9fe5ab6c9..75208f3ddb 100644 --- a/test/e2e/tests/select.spec.js +++ b/test/e2e/tests/select.spec.js @@ -1,23 +1,23 @@ -import {test, expect} from "../test-fixtures" -import {syncLV} from "../utils" +import { test, expect } from "../test-fixtures"; +import { syncLV } from "../utils"; // this tests issue #2659 // https://github.com/phoenixframework/phoenix_live_view/pull/2659 -test("select shows error when invalid option is selected", async ({page}) => { - await page.goto("/select") - await syncLV(page) - - const select3 = page.locator("#select_form_select3") - await expect(select3).toHaveValue("2") - await expect(select3).not.toHaveClass("has-error") +test("select shows error when invalid option is selected", async ({ page }) => { + await page.goto("/select"); + await syncLV(page); + + const select3 = page.locator("#select_form_select3"); + await expect(select3).toHaveValue("2"); + await expect(select3).not.toHaveClass("has-error"); // 5 or below should be invalid - await select3.selectOption("3") - await syncLV(page) - await expect(select3).toHaveClass("has-error") + await select3.selectOption("3"); + await syncLV(page); + await expect(select3).toHaveClass("has-error"); // 6 or above should be valid - await select3.selectOption("6") - await syncLV(page) - await expect(select3).not.toHaveClass("has-error") -}) + await select3.selectOption("6"); + await syncLV(page); + await expect(select3).not.toHaveClass("has-error"); +}); diff --git a/test/e2e/tests/streams.spec.js b/test/e2e/tests/streams.spec.js index ee009cca8d..7bcddad335 100644 --- a/test/e2e/tests/streams.spec.js +++ b/test/e2e/tests/streams.spec.js @@ -1,841 +1,923 @@ -import {test, expect} from "../test-fixtures" -import {syncLV, evalLV} from "../utils" +import { test, expect } from "../test-fixtures"; +import { syncLV, evalLV } from "../utils"; const usersInDom = async (page, parent) => { - return await page.locator(`#${parent} > *`) - .evaluateAll(list => list.map(el => ({id: el.id, text: el.childNodes[0].nodeValue.trim()}))) -} - -test("renders properly", async ({page}) => { - await page.goto("/stream") - await syncLV(page) + return await page.locator(`#${parent} > *`).evaluateAll((list) => + list.map((el) => ({ + id: el.id, + text: el.childNodes[0].nodeValue.trim(), + })), + ); +}; + +test("renders properly", async ({ page }) => { + await page.goto("/stream"); + await syncLV(page); expect(await usersInDom(page, "users")).toEqual([ - {id: "users-1", text: "chris"}, - {id: "users-2", text: "callan"} - ]) + { id: "users-1", text: "chris" }, + { id: "users-2", text: "callan" }, + ]); expect(await usersInDom(page, "c_users")).toEqual([ - {id: "c_users-1", text: "chris"}, - {id: "c_users-2", text: "callan"} - ]) + { id: "c_users-1", text: "chris" }, + { id: "c_users-2", text: "callan" }, + ]); expect(await usersInDom(page, "admins")).toEqual([ - {id: "admins-1", text: "chris-admin"}, - {id: "admins-2", text: "callan-admin"} - ]) -}) + { id: "admins-1", text: "chris-admin" }, + { id: "admins-2", text: "callan-admin" }, + ]); +}); -test("elements can be updated and deleted (LV)", async ({page}) => { - await page.goto("/stream") - await syncLV(page) +test("elements can be updated and deleted (LV)", async ({ page }) => { + await page.goto("/stream"); + await syncLV(page); - await page.locator("#users-1").getByRole("button", {name: "update"}).click() - await syncLV(page) + await page + .locator("#users-1") + .getByRole("button", { name: "update" }) + .click(); + await syncLV(page); expect(await usersInDom(page, "users")).toEqual([ - {id: "users-1", text: "updated"}, - {id: "users-2", text: "callan"} - ]) + { id: "users-1", text: "updated" }, + { id: "users-2", text: "callan" }, + ]); expect(await usersInDom(page, "c_users")).toEqual([ - {id: "c_users-1", text: "chris"}, - {id: "c_users-2", text: "callan"} - ]) + { id: "c_users-1", text: "chris" }, + { id: "c_users-2", text: "callan" }, + ]); expect(await usersInDom(page, "admins")).toEqual([ - {id: "admins-1", text: "chris-admin"}, - {id: "admins-2", text: "callan-admin"} - ]) + { id: "admins-1", text: "chris-admin" }, + { id: "admins-2", text: "callan-admin" }, + ]); - await page.locator("#users-2").getByRole("button", {name: "update"}).click() - await syncLV(page) + await page + .locator("#users-2") + .getByRole("button", { name: "update" }) + .click(); + await syncLV(page); expect(await usersInDom(page, "users")).toEqual([ - {id: "users-1", text: "updated"}, - {id: "users-2", text: "updated"} - ]) + { id: "users-1", text: "updated" }, + { id: "users-2", text: "updated" }, + ]); expect(await usersInDom(page, "c_users")).toEqual([ - {id: "c_users-1", text: "chris"}, - {id: "c_users-2", text: "callan"} - ]) + { id: "c_users-1", text: "chris" }, + { id: "c_users-2", text: "callan" }, + ]); expect(await usersInDom(page, "admins")).toEqual([ - {id: "admins-1", text: "chris-admin"}, - {id: "admins-2", text: "callan-admin"} - ]) + { id: "admins-1", text: "chris-admin" }, + { id: "admins-2", text: "callan-admin" }, + ]); - await page.locator("#users-1").getByRole("button", {name: "delete"}).click() - await syncLV(page) + await page + .locator("#users-1") + .getByRole("button", { name: "delete" }) + .click(); + await syncLV(page); expect(await usersInDom(page, "users")).toEqual([ - {id: "users-2", text: "updated"} - ]) -}) + { id: "users-2", text: "updated" }, + ]); +}); -test("elements can be updated and deleted (LC)", async ({page}) => { - await page.goto("/stream") - await syncLV(page) +test("elements can be updated and deleted (LC)", async ({ page }) => { + await page.goto("/stream"); + await syncLV(page); - await page.locator("#c_users-1").getByRole("button", {name: "update"}).click() - await syncLV(page) + await page + .locator("#c_users-1") + .getByRole("button", { name: "update" }) + .click(); + await syncLV(page); expect(await usersInDom(page, "c_users")).toEqual([ - {id: "c_users-1", text: "updated"}, - {id: "c_users-2", text: "callan"} - ]) + { id: "c_users-1", text: "updated" }, + { id: "c_users-2", text: "callan" }, + ]); expect(await usersInDom(page, "users")).toEqual([ - {id: "users-1", text: "chris"}, - {id: "users-2", text: "callan"} - ]) + { id: "users-1", text: "chris" }, + { id: "users-2", text: "callan" }, + ]); expect(await usersInDom(page, "admins")).toEqual([ - {id: "admins-1", text: "chris-admin"}, - {id: "admins-2", text: "callan-admin"} - ]) + { id: "admins-1", text: "chris-admin" }, + { id: "admins-2", text: "callan-admin" }, + ]); - await page.locator("#c_users-2").getByRole("button", {name: "update"}).click() - await syncLV(page) + await page + .locator("#c_users-2") + .getByRole("button", { name: "update" }) + .click(); + await syncLV(page); expect(await usersInDom(page, "c_users")).toEqual([ - {id: "c_users-1", text: "updated"}, - {id: "c_users-2", text: "updated"} - ]) + { id: "c_users-1", text: "updated" }, + { id: "c_users-2", text: "updated" }, + ]); expect(await usersInDom(page, "users")).toEqual([ - {id: "users-1", text: "chris"}, - {id: "users-2", text: "callan"} - ]) + { id: "users-1", text: "chris" }, + { id: "users-2", text: "callan" }, + ]); expect(await usersInDom(page, "admins")).toEqual([ - {id: "admins-1", text: "chris-admin"}, - {id: "admins-2", text: "callan-admin"} - ]) + { id: "admins-1", text: "chris-admin" }, + { id: "admins-2", text: "callan-admin" }, + ]); - await page.locator("#c_users-1").getByRole("button", {name: "delete"}).click() - await syncLV(page) + await page + .locator("#c_users-1") + .getByRole("button", { name: "delete" }) + .click(); + await syncLV(page); expect(await usersInDom(page, "c_users")).toEqual([ - {id: "c_users-2", text: "updated"} - ]) -}) - -test("move-to-first moves the second element to the first position (LV)", async ({page}) => { + { id: "c_users-2", text: "updated" }, + ]); +}); - await page.goto("/stream") - await syncLV(page) +test("move-to-first moves the second element to the first position (LV)", async ({ + page, +}) => { + await page.goto("/stream"); + await syncLV(page); expect(await usersInDom(page, "c_users")).toEqual([ - {id: "c_users-1", text: "chris"}, - {id: "c_users-2", text: "callan"} - ]) - - await page.locator("#c_users-2").getByRole("button", {name: "make first"}).click() + { id: "c_users-1", text: "chris" }, + { id: "c_users-2", text: "callan" }, + ]); + + await page + .locator("#c_users-2") + .getByRole("button", { name: "make first" }) + .click(); expect(await usersInDom(page, "c_users")).toEqual([ - {id: "c_users-2", text: "updated"}, - {id: "c_users-1", text: "chris"} - ]) -}) + { id: "c_users-2", text: "updated" }, + { id: "c_users-1", text: "chris" }, + ]); +}); -test("stream reset removes items", async ({page}) => { - await page.goto("/stream") - await syncLV(page) +test("stream reset removes items", async ({ page }) => { + await page.goto("/stream"); + await syncLV(page); - expect(await usersInDom(page, "users")).toEqual([{id: "users-1", text: "chris"}, {id: "users-2", text: "callan"}]) + expect(await usersInDom(page, "users")).toEqual([ + { id: "users-1", text: "chris" }, + { id: "users-2", text: "callan" }, + ]); - await page.getByRole("button", {name: "Reset"}).click() - await syncLV(page) + await page.getByRole("button", { name: "Reset" }).click(); + await syncLV(page); - expect(await usersInDom(page, "users")).toEqual([]) -}) + expect(await usersInDom(page, "users")).toEqual([]); +}); -test("stream reset properly reorders items", async ({page}) => { - await page.goto("/stream") - await syncLV(page) +test("stream reset properly reorders items", async ({ page }) => { + await page.goto("/stream"); + await syncLV(page); expect(await usersInDom(page, "users")).toEqual([ - {id: "users-1", text: "chris"}, - {id: "users-2", text: "callan"} - ]) + { id: "users-1", text: "chris" }, + { id: "users-2", text: "callan" }, + ]); - await page.getByRole("button", {name: "Reorder"}).click() - await syncLV(page) + await page.getByRole("button", { name: "Reorder" }).click(); + await syncLV(page); expect(await usersInDom(page, "users")).toEqual([ - {id: "users-3", text: "peter"}, - {id: "users-1", text: "chris"}, - {id: "users-4", text: "mona"} - ]) -}) + { id: "users-3", text: "peter" }, + { id: "users-1", text: "chris" }, + { id: "users-4", text: "mona" }, + ]); +}); -test("stream reset updates attributes", async ({page}) => { - await page.goto("/stream") - await syncLV(page) +test("stream reset updates attributes", async ({ page }) => { + await page.goto("/stream"); + await syncLV(page); expect(await usersInDom(page, "users")).toEqual([ - {id: "users-1", text: "chris"}, - {id: "users-2", text: "callan"} - ]) - - await await expect(page.locator("#users-1")).toHaveAttribute("data-count", "0") - await await expect(page.locator("#users-2")).toHaveAttribute("data-count", "0") - - await page.getByRole("button", {name: "Reorder"}).click() - await syncLV(page) + { id: "users-1", text: "chris" }, + { id: "users-2", text: "callan" }, + ]); + + await await expect(page.locator("#users-1")).toHaveAttribute( + "data-count", + "0", + ); + await await expect(page.locator("#users-2")).toHaveAttribute( + "data-count", + "0", + ); + + await page.getByRole("button", { name: "Reorder" }).click(); + await syncLV(page); expect(await usersInDom(page, "users")).toEqual([ - {id: "users-3", text: "peter"}, - {id: "users-1", text: "chris"}, - {id: "users-4", text: "mona"} - ]) - - await await expect(page.locator("#users-1")).toHaveAttribute("data-count", "1") - await await expect(page.locator("#users-3")).toHaveAttribute("data-count", "1") - await await expect(page.locator("#users-4")).toHaveAttribute("data-count", "1") -}) + { id: "users-3", text: "peter" }, + { id: "users-1", text: "chris" }, + { id: "users-4", text: "mona" }, + ]); + + await await expect(page.locator("#users-1")).toHaveAttribute( + "data-count", + "1", + ); + await await expect(page.locator("#users-3")).toHaveAttribute( + "data-count", + "1", + ); + await await expect(page.locator("#users-4")).toHaveAttribute( + "data-count", + "1", + ); +}); test.describe("Issue #2656", () => { - test("stream reset works when patching", async ({page}) => { - await page.goto("/healthy/fruits") - await syncLV(page) + test("stream reset works when patching", async ({ page }) => { + await page.goto("/healthy/fruits"); + await syncLV(page); - await expect(page.locator("h1")).toContainText("Fruits") - await expect(page.locator("ul")).toContainText("Apples") - await expect(page.locator("ul")).toContainText("Oranges") + await expect(page.locator("h1")).toContainText("Fruits"); + await expect(page.locator("ul")).toContainText("Apples"); + await expect(page.locator("ul")).toContainText("Oranges"); - await page.getByRole("link", {name: "Switch"}).click() - await expect(page).toHaveURL("/healthy/veggies") - await syncLV(page) + await page.getByRole("link", { name: "Switch" }).click(); + await expect(page).toHaveURL("/healthy/veggies"); + await syncLV(page); - await expect(page.locator("h1")).toContainText("Veggies") + await expect(page.locator("h1")).toContainText("Veggies"); - await expect(page.locator("ul")).toContainText("Carrots") - await expect(page.locator("ul")).toContainText("Tomatoes") - await expect(page.locator("ul")).not.toContainText("Apples") - await expect(page.locator("ul")).not.toContainText("Oranges") + await expect(page.locator("ul")).toContainText("Carrots"); + await expect(page.locator("ul")).toContainText("Tomatoes"); + await expect(page.locator("ul")).not.toContainText("Apples"); + await expect(page.locator("ul")).not.toContainText("Oranges"); - await page.getByRole("link", {name: "Switch"}).click() - await expect(page).toHaveURL("/healthy/fruits") - await syncLV(page) + await page.getByRole("link", { name: "Switch" }).click(); + await expect(page).toHaveURL("/healthy/fruits"); + await syncLV(page); - await expect(page.locator("ul")).not.toContainText("Carrots") - await expect(page.locator("ul")).not.toContainText("Tomatoes") - await expect(page.locator("ul")).toContainText("Apples") - await expect(page.locator("ul")).toContainText("Oranges") - }) -}) + await expect(page.locator("ul")).not.toContainText("Carrots"); + await expect(page.locator("ul")).not.toContainText("Tomatoes"); + await expect(page.locator("ul")).toContainText("Apples"); + await expect(page.locator("ul")).toContainText("Oranges"); + }); +}); // helper function used below -const listItems = async (page) => page.locator("ul > li").evaluateAll(list => list.map(el => ({id: el.id, text: el.innerText}))) +const listItems = async (page) => + page + .locator("ul > li") + .evaluateAll((list) => + list.map((el) => ({ id: el.id, text: el.innerText })), + ); test.describe("Issue #2994", () => { - test("can filter and reset a stream", async ({page}) => { - await page.goto("/stream/reset") - await syncLV(page) + test("can filter and reset a stream", async ({ page }) => { + await page.goto("/stream/reset"); + await syncLV(page); expect(await listItems(page)).toEqual([ - {id: "items-a", text: "A"}, - {id: "items-b", text: "B"}, - {id: "items-c", text: "C"}, - {id: "items-d", text: "D"} - ]) + { id: "items-a", text: "A" }, + { id: "items-b", text: "B" }, + { id: "items-c", text: "C" }, + { id: "items-d", text: "D" }, + ]); - await page.getByRole("button", {name: "Filter"}).click() - await syncLV(page) + await page.getByRole("button", { name: "Filter" }).click(); + await syncLV(page); expect(await listItems(page)).toEqual([ - {id: "items-b", text: "B"}, - {id: "items-c", text: "C"}, - {id: "items-d", text: "D"} - ]) + { id: "items-b", text: "B" }, + { id: "items-c", text: "C" }, + { id: "items-d", text: "D" }, + ]); - await page.getByRole("button", {name: "Reset"}).click() - await syncLV(page) + await page.getByRole("button", { name: "Reset" }).click(); + await syncLV(page); expect(await listItems(page)).toEqual([ - {id: "items-a", text: "A"}, - {id: "items-b", text: "B"}, - {id: "items-c", text: "C"}, - {id: "items-d", text: "D"} - ]) - }) + { id: "items-a", text: "A" }, + { id: "items-b", text: "B" }, + { id: "items-c", text: "C" }, + { id: "items-d", text: "D" }, + ]); + }); - test("can reorder stream", async ({page}) => { - await page.goto("/stream/reset") - await syncLV(page) + test("can reorder stream", async ({ page }) => { + await page.goto("/stream/reset"); + await syncLV(page); expect(await listItems(page)).toEqual([ - {id: "items-a", text: "A"}, - {id: "items-b", text: "B"}, - {id: "items-c", text: "C"}, - {id: "items-d", text: "D"} - ]) + { id: "items-a", text: "A" }, + { id: "items-b", text: "B" }, + { id: "items-c", text: "C" }, + { id: "items-d", text: "D" }, + ]); - await page.getByRole("button", {name: "Reorder"}).click() - await syncLV(page) + await page.getByRole("button", { name: "Reorder" }).click(); + await syncLV(page); expect(await listItems(page)).toEqual([ - {id: "items-b", text: "B"}, - {id: "items-a", text: "A"}, - {id: "items-c", text: "C"}, - {id: "items-d", text: "D"} - ]) - }) + { id: "items-b", text: "B" }, + { id: "items-a", text: "A" }, + { id: "items-c", text: "C" }, + { id: "items-d", text: "D" }, + ]); + }); - test("can filter and then prepend / append stream", async ({page}) => { - await page.goto("/stream/reset") - await syncLV(page) + test("can filter and then prepend / append stream", async ({ page }) => { + await page.goto("/stream/reset"); + await syncLV(page); expect(await listItems(page)).toEqual([ - {id: "items-a", text: "A"}, - {id: "items-b", text: "B"}, - {id: "items-c", text: "C"}, - {id: "items-d", text: "D"} - ]) + { id: "items-a", text: "A" }, + { id: "items-b", text: "B" }, + { id: "items-c", text: "C" }, + { id: "items-d", text: "D" }, + ]); - await page.getByRole("button", {name: "Filter"}).click() - await syncLV(page) + await page.getByRole("button", { name: "Filter" }).click(); + await syncLV(page); expect(await listItems(page)).toEqual([ - {id: "items-b", text: "B"}, - {id: "items-c", text: "C"}, - {id: "items-d", text: "D"} - ]) + { id: "items-b", text: "B" }, + { id: "items-c", text: "C" }, + { id: "items-d", text: "D" }, + ]); - await page.getByRole("button", {name: "Prepend", exact: true}).click() - await syncLV(page) + await page.getByRole("button", { name: "Prepend", exact: true }).click(); + await syncLV(page); expect(await listItems(page)).toEqual([ - {id: expect.stringMatching(/items-a-.*/), text: expect.any(String)}, - {id: "items-b", text: "B"}, - {id: "items-c", text: "C"}, - {id: "items-d", text: "D"} - ]) + { id: expect.stringMatching(/items-a-.*/), text: expect.any(String) }, + { id: "items-b", text: "B" }, + { id: "items-c", text: "C" }, + { id: "items-d", text: "D" }, + ]); - await page.getByRole("button", {name: "Reset"}).click() - await syncLV(page) + await page.getByRole("button", { name: "Reset" }).click(); + await syncLV(page); expect(await listItems(page)).toEqual([ - {id: "items-a", text: "A"}, - {id: "items-b", text: "B"}, - {id: "items-c", text: "C"}, - {id: "items-d", text: "D"} - ]) + { id: "items-a", text: "A" }, + { id: "items-b", text: "B" }, + { id: "items-c", text: "C" }, + { id: "items-d", text: "D" }, + ]); - await page.getByRole("button", {name: "Append", exact: true}).click() - await syncLV(page) + await page.getByRole("button", { name: "Append", exact: true }).click(); + await syncLV(page); expect(await listItems(page)).toEqual([ - {id: "items-a", text: "A"}, - {id: "items-b", text: "B"}, - {id: "items-c", text: "C"}, - {id: "items-d", text: "D"}, - {id: expect.stringMatching(/items-a-.*/), text: expect.any(String)} - ]) - }) -}) + { id: "items-a", text: "A" }, + { id: "items-b", text: "B" }, + { id: "items-c", text: "C" }, + { id: "items-d", text: "D" }, + { id: expect.stringMatching(/items-a-.*/), text: expect.any(String) }, + ]); + }); +}); test.describe("Issue #2982", () => { - test("can reorder a stream with LiveComponents as direct stream children", async ({page}) => { - await page.goto("/stream/reset-lc") - await syncLV(page) + test("can reorder a stream with LiveComponents as direct stream children", async ({ + page, + }) => { + await page.goto("/stream/reset-lc"); + await syncLV(page); expect(await listItems(page)).toEqual([ - {id: "items-a", text: "A"}, - {id: "items-b", text: "B"}, - {id: "items-c", text: "C"}, - {id: "items-d", text: "D"} - ]) + { id: "items-a", text: "A" }, + { id: "items-b", text: "B" }, + { id: "items-c", text: "C" }, + { id: "items-d", text: "D" }, + ]); - await page.getByRole("button", {name: "Reorder"}).click() - await syncLV(page) + await page.getByRole("button", { name: "Reorder" }).click(); + await syncLV(page); expect(await listItems(page)).toEqual([ - {id: "items-e", text: "E"}, - {id: "items-a", text: "A"}, - {id: "items-f", text: "F"}, - {id: "items-g", text: "G"}, - ]) - }) -}) + { id: "items-e", text: "E" }, + { id: "items-a", text: "A" }, + { id: "items-f", text: "F" }, + { id: "items-g", text: "G" }, + ]); + }); +}); test.describe("Issue #3023", () => { - test("can bulk insert items at a specific index", async ({page}) => { - await page.goto("/stream/reset") - await syncLV(page) + test("can bulk insert items at a specific index", async ({ page }) => { + await page.goto("/stream/reset"); + await syncLV(page); expect(await listItems(page)).toEqual([ - {id: "items-a", text: "A"}, - {id: "items-b", text: "B"}, - {id: "items-c", text: "C"}, - {id: "items-d", text: "D"} - ]) + { id: "items-a", text: "A" }, + { id: "items-b", text: "B" }, + { id: "items-c", text: "C" }, + { id: "items-d", text: "D" }, + ]); - await page.getByRole("button", {name: "Bulk insert"}).click() - await syncLV(page) + await page.getByRole("button", { name: "Bulk insert" }).click(); + await syncLV(page); expect(await listItems(page)).toEqual([ - {id: "items-a", text: "A"}, - {id: "items-e", text: "E"}, - {id: "items-f", text: "F"}, - {id: "items-g", text: "G"}, - {id: "items-b", text: "B"}, - {id: "items-c", text: "C"}, - {id: "items-d", text: "D"} - ]) - }) -}) + { id: "items-a", text: "A" }, + { id: "items-e", text: "E" }, + { id: "items-f", text: "F" }, + { id: "items-g", text: "G" }, + { id: "items-b", text: "B" }, + { id: "items-c", text: "C" }, + { id: "items-d", text: "D" }, + ]); + }); +}); test.describe("stream limit - issue #2686", () => { - test("limit is enforced on mount, but not dead render", async ({page, request}) => { - const html = await (request.get("/stream/limit").then(r => r.text())) - for(let i = 1; i <= 10; i++){ - expect(html).toContain(`id="items-${i}"`) + test("limit is enforced on mount, but not dead render", async ({ + page, + request, + }) => { + const html = await request.get("/stream/limit").then((r) => r.text()); + for (let i = 1; i <= 10; i++) { + expect(html).toContain(`id="items-${i}"`); } - await page.goto("/stream/limit") - await syncLV(page) + await page.goto("/stream/limit"); + await syncLV(page); expect(await listItems(page)).toEqual([ - {id: "items-6", text: "6"}, - {id: "items-7", text: "7"}, - {id: "items-8", text: "8"}, - {id: "items-9", text: "9"}, - {id: "items-10", text: "10"} - ]) - }) - - test("removes item at front when appending and limit is negative", async ({page}) => { - await page.goto("/stream/limit") - await syncLV(page) + { id: "items-6", text: "6" }, + { id: "items-7", text: "7" }, + { id: "items-8", text: "8" }, + { id: "items-9", text: "9" }, + { id: "items-10", text: "10" }, + ]); + }); + + test("removes item at front when appending and limit is negative", async ({ + page, + }) => { + await page.goto("/stream/limit"); + await syncLV(page); // these are the defaults in the LV - await expect(page.locator("input[name='at']")).toHaveValue("-1") - await expect(page.locator("input[name='limit']")).toHaveValue("-5") + await expect(page.locator("input[name='at']")).toHaveValue("-1"); + await expect(page.locator("input[name='limit']")).toHaveValue("-5"); - await page.getByRole("button", {name: "add 1", exact: true}).click() - await syncLV(page) + await page.getByRole("button", { name: "add 1", exact: true }).click(); + await syncLV(page); expect(await listItems(page)).toEqual([ - {id: "items-7", text: "7"}, - {id: "items-8", text: "8"}, - {id: "items-9", text: "9"}, - {id: "items-10", text: "10"}, - {id: "items-11", text: "11"} - ]) - await page.getByRole("button", {name: "add 10", exact: true}).click() - await syncLV(page) + { id: "items-7", text: "7" }, + { id: "items-8", text: "8" }, + { id: "items-9", text: "9" }, + { id: "items-10", text: "10" }, + { id: "items-11", text: "11" }, + ]); + await page.getByRole("button", { name: "add 10", exact: true }).click(); + await syncLV(page); expect(await listItems(page)).toEqual([ - {id: "items-17", text: "17"}, - {id: "items-18", text: "18"}, - {id: "items-19", text: "19"}, - {id: "items-20", text: "20"}, - {id: "items-21", text: "21"} - ]) - }) - - test("removes item at back when prepending and limit is positive", async ({page}) => { - await page.goto("/stream/limit") - await syncLV(page) - - await page.locator("input[name='at']").fill("0") - await page.locator("input[name='limit']").fill("5") - await page.getByRole("button", {name: "recreate stream"}).click() - await syncLV(page) + { id: "items-17", text: "17" }, + { id: "items-18", text: "18" }, + { id: "items-19", text: "19" }, + { id: "items-20", text: "20" }, + { id: "items-21", text: "21" }, + ]); + }); + + test("removes item at back when prepending and limit is positive", async ({ + page, + }) => { + await page.goto("/stream/limit"); + await syncLV(page); + + await page.locator("input[name='at']").fill("0"); + await page.locator("input[name='limit']").fill("5"); + await page.getByRole("button", { name: "recreate stream" }).click(); + await syncLV(page); expect(await listItems(page)).toEqual([ - {id: "items-10", text: "10"}, - {id: "items-9", text: "9"}, - {id: "items-8", text: "8"}, - {id: "items-7", text: "7"}, - {id: "items-6", text: "6"} - ]) + { id: "items-10", text: "10" }, + { id: "items-9", text: "9" }, + { id: "items-8", text: "8" }, + { id: "items-7", text: "7" }, + { id: "items-6", text: "6" }, + ]); - await page.getByRole("button", {name: "add 1", exact: true}).click() - await syncLV(page) + await page.getByRole("button", { name: "add 1", exact: true }).click(); + await syncLV(page); expect(await listItems(page)).toEqual([ - {id: "items-11", text: "11"}, - {id: "items-10", text: "10"}, - {id: "items-9", text: "9"}, - {id: "items-8", text: "8"}, - {id: "items-7", text: "7"} - ]) + { id: "items-11", text: "11" }, + { id: "items-10", text: "10" }, + { id: "items-9", text: "9" }, + { id: "items-8", text: "8" }, + { id: "items-7", text: "7" }, + ]); - await page.getByRole("button", {name: "add 10", exact: true}).click() - await syncLV(page) + await page.getByRole("button", { name: "add 10", exact: true }).click(); + await syncLV(page); expect(await listItems(page)).toEqual([ - {id: "items-21", text: "21"}, - {id: "items-20", text: "20"}, - {id: "items-19", text: "19"}, - {id: "items-18", text: "18"}, - {id: "items-17", text: "17"} - ]) - }) - - test("does nothing if appending and positive limit is reached", async ({page}) => { - await page.goto("/stream/limit") - await syncLV(page) - - await page.locator("input[name='at']").fill("-1") - await page.locator("input[name='limit']").fill("5") - await page.getByRole("button", {name: "recreate stream"}).click() - await syncLV(page) - - await page.getByRole("button", {name: "clear"}).click() - await syncLV(page) - - expect(await listItems(page)).toEqual([]) - - const items = [] - for(let i = 1; i <= 5; i++){ - await page.getByRole("button", {name: "add 1", exact: true}).click() - await syncLV(page) - items.push({id: `items-${i}`, text: i.toString()}) - expect(await listItems(page)).toEqual(items) + { id: "items-21", text: "21" }, + { id: "items-20", text: "20" }, + { id: "items-19", text: "19" }, + { id: "items-18", text: "18" }, + { id: "items-17", text: "17" }, + ]); + }); + + test("does nothing if appending and positive limit is reached", async ({ + page, + }) => { + await page.goto("/stream/limit"); + await syncLV(page); + + await page.locator("input[name='at']").fill("-1"); + await page.locator("input[name='limit']").fill("5"); + await page.getByRole("button", { name: "recreate stream" }).click(); + await syncLV(page); + + await page.getByRole("button", { name: "clear" }).click(); + await syncLV(page); + + expect(await listItems(page)).toEqual([]); + + const items = []; + for (let i = 1; i <= 5; i++) { + await page.getByRole("button", { name: "add 1", exact: true }).click(); + await syncLV(page); + items.push({ id: `items-${i}`, text: i.toString() }); + expect(await listItems(page)).toEqual(items); } // now adding new items should do nothing, as the limit is reached - await page.getByRole("button", {name: "add 1", exact: true}).click() - await syncLV(page) + await page.getByRole("button", { name: "add 1", exact: true }).click(); + await syncLV(page); expect(await listItems(page)).toEqual([ - {id: "items-1", text: "1"}, - {id: "items-2", text: "2"}, - {id: "items-3", text: "3"}, - {id: "items-4", text: "4"}, - {id: "items-5", text: "5"} - ]) + { id: "items-1", text: "1" }, + { id: "items-2", text: "2" }, + { id: "items-3", text: "3" }, + { id: "items-4", text: "4" }, + { id: "items-5", text: "5" }, + ]); // same when bulk inserting - await page.getByRole("button", {name: "add 10", exact: true}).click() - await syncLV(page) + await page.getByRole("button", { name: "add 10", exact: true }).click(); + await syncLV(page); expect(await listItems(page)).toEqual([ - {id: "items-1", text: "1"}, - {id: "items-2", text: "2"}, - {id: "items-3", text: "3"}, - {id: "items-4", text: "4"}, - {id: "items-5", text: "5"} - ]) - }) - - test("does nothing if prepending and negative limit is reached", async ({page}) => { - await page.goto("/stream/limit") - await syncLV(page) - - await page.locator("input[name='at']").fill("0") - await page.locator("input[name='limit']").fill("-5") - await page.getByRole("button", {name: "recreate stream"}).click() - await syncLV(page) - - await page.getByRole("button", {name: "clear"}).click() - await syncLV(page) - - expect(await listItems(page)).toEqual([]) - - const items = [] - for(let i = 1; i <= 5; i++){ - await page.getByRole("button", {name: "add 1", exact: true}).click() - await syncLV(page) - items.unshift({id: `items-${i}`, text: i.toString()}) - expect(await listItems(page)).toEqual(items) + { id: "items-1", text: "1" }, + { id: "items-2", text: "2" }, + { id: "items-3", text: "3" }, + { id: "items-4", text: "4" }, + { id: "items-5", text: "5" }, + ]); + }); + + test("does nothing if prepending and negative limit is reached", async ({ + page, + }) => { + await page.goto("/stream/limit"); + await syncLV(page); + + await page.locator("input[name='at']").fill("0"); + await page.locator("input[name='limit']").fill("-5"); + await page.getByRole("button", { name: "recreate stream" }).click(); + await syncLV(page); + + await page.getByRole("button", { name: "clear" }).click(); + await syncLV(page); + + expect(await listItems(page)).toEqual([]); + + const items = []; + for (let i = 1; i <= 5; i++) { + await page.getByRole("button", { name: "add 1", exact: true }).click(); + await syncLV(page); + items.unshift({ id: `items-${i}`, text: i.toString() }); + expect(await listItems(page)).toEqual(items); } // now adding new items should do nothing, as the limit is reached - await page.getByRole("button", {name: "add 1", exact: true}).click() - await syncLV(page) + await page.getByRole("button", { name: "add 1", exact: true }).click(); + await syncLV(page); expect(await listItems(page)).toEqual([ - {id: "items-5", text: "5"}, - {id: "items-4", text: "4"}, - {id: "items-3", text: "3"}, - {id: "items-2", text: "2"}, - {id: "items-1", text: "1"} - ]) + { id: "items-5", text: "5" }, + { id: "items-4", text: "4" }, + { id: "items-3", text: "3" }, + { id: "items-2", text: "2" }, + { id: "items-1", text: "1" }, + ]); // same when bulk inserting - await page.getByRole("button", {name: "add 10", exact: true}).click() - await syncLV(page) + await page.getByRole("button", { name: "add 10", exact: true }).click(); + await syncLV(page); expect(await listItems(page)).toEqual([ - {id: "items-5", text: "5"}, - {id: "items-4", text: "4"}, - {id: "items-3", text: "3"}, - {id: "items-2", text: "2"}, - {id: "items-1", text: "1"} - ]) - }) - - test("arbitrary index", async ({page}) => { - await page.goto("/stream/limit") - await syncLV(page) - - await page.locator("input[name='at']").fill("1") - await page.locator("input[name='limit']").fill("5") - await page.getByRole("button", {name: "recreate stream"}).click() - await syncLV(page) + { id: "items-5", text: "5" }, + { id: "items-4", text: "4" }, + { id: "items-3", text: "3" }, + { id: "items-2", text: "2" }, + { id: "items-1", text: "1" }, + ]); + }); + + test("arbitrary index", async ({ page }) => { + await page.goto("/stream/limit"); + await syncLV(page); + + await page.locator("input[name='at']").fill("1"); + await page.locator("input[name='limit']").fill("5"); + await page.getByRole("button", { name: "recreate stream" }).click(); + await syncLV(page); // we tried to insert 10 items expect(await listItems(page)).toEqual([ - {id: "items-1", text: "1"}, - {id: "items-10", text: "10"}, - {id: "items-9", text: "9"}, - {id: "items-8", text: "8"}, - {id: "items-7", text: "7"} - ]) - - await page.getByRole("button", {name: "add 10", exact: true}).click() - await syncLV(page) + { id: "items-1", text: "1" }, + { id: "items-10", text: "10" }, + { id: "items-9", text: "9" }, + { id: "items-8", text: "8" }, + { id: "items-7", text: "7" }, + ]); + + await page.getByRole("button", { name: "add 10", exact: true }).click(); + await syncLV(page); expect(await listItems(page)).toEqual([ - {id: "items-1", text: "1"}, - {id: "items-20", text: "20"}, - {id: "items-19", text: "19"}, - {id: "items-18", text: "18"}, - {id: "items-17", text: "17"} - ]) - - await page.locator("input[name='at']").fill("1") - await page.locator("input[name='limit']").fill("-5") - await page.getByRole("button", {name: "recreate stream"}).click() - await syncLV(page) + { id: "items-1", text: "1" }, + { id: "items-20", text: "20" }, + { id: "items-19", text: "19" }, + { id: "items-18", text: "18" }, + { id: "items-17", text: "17" }, + ]); + + await page.locator("input[name='at']").fill("1"); + await page.locator("input[name='limit']").fill("-5"); + await page.getByRole("button", { name: "recreate stream" }).click(); + await syncLV(page); // we tried to insert 10 items expect(await listItems(page)).toEqual([ - {id: "items-10", text: "10"}, - {id: "items-5", text: "5"}, - {id: "items-4", text: "4"}, - {id: "items-3", text: "3"}, - {id: "items-2", text: "2"} - ]) - - await page.getByRole("button", {name: "add 10", exact: true}).click() - await syncLV(page) + { id: "items-10", text: "10" }, + { id: "items-5", text: "5" }, + { id: "items-4", text: "4" }, + { id: "items-3", text: "3" }, + { id: "items-2", text: "2" }, + ]); + + await page.getByRole("button", { name: "add 10", exact: true }).click(); + await syncLV(page); expect(await listItems(page)).toEqual([ - {id: "items-20", text: "20"}, - {id: "items-5", text: "5"}, - {id: "items-4", text: "4"}, - {id: "items-3", text: "3"}, - {id: "items-2", text: "2"} - ]) - }) -}) - -test("any stream insert for elements already in the DOM does not reorder", async ({page}) => { - await page.goto("/stream/reset") - await syncLV(page) + { id: "items-20", text: "20" }, + { id: "items-5", text: "5" }, + { id: "items-4", text: "4" }, + { id: "items-3", text: "3" }, + { id: "items-2", text: "2" }, + ]); + }); +}); + +test("any stream insert for elements already in the DOM does not reorder", async ({ + page, +}) => { + await page.goto("/stream/reset"); + await syncLV(page); expect(await listItems(page)).toEqual([ - {id: "items-a", text: "A"}, - {id: "items-b", text: "B"}, - {id: "items-c", text: "C"}, - {id: "items-d", text: "D"} - ]) - - await page.getByRole("button", {name: "Prepend C"}).click() - await syncLV(page) + { id: "items-a", text: "A" }, + { id: "items-b", text: "B" }, + { id: "items-c", text: "C" }, + { id: "items-d", text: "D" }, + ]); + + await page.getByRole("button", { name: "Prepend C" }).click(); + await syncLV(page); expect(await listItems(page)).toEqual([ - {id: "items-a", text: "A"}, - {id: "items-b", text: "B"}, - {id: "items-c", text: "C"}, - {id: "items-d", text: "D"} - ]) - - await page.getByRole("button", {name: "Append C"}).click() - await syncLV(page) + { id: "items-a", text: "A" }, + { id: "items-b", text: "B" }, + { id: "items-c", text: "C" }, + { id: "items-d", text: "D" }, + ]); + + await page.getByRole("button", { name: "Append C" }).click(); + await syncLV(page); expect(await listItems(page)).toEqual([ - {id: "items-a", text: "A"}, - {id: "items-b", text: "B"}, - {id: "items-c", text: "C"}, - {id: "items-d", text: "D"} - ]) - - await page.getByRole("button", {name: "Insert C at 1"}).click() - await syncLV(page) + { id: "items-a", text: "A" }, + { id: "items-b", text: "B" }, + { id: "items-c", text: "C" }, + { id: "items-d", text: "D" }, + ]); + + await page.getByRole("button", { name: "Insert C at 1" }).click(); + await syncLV(page); expect(await listItems(page)).toEqual([ - {id: "items-a", text: "A"}, - {id: "items-b", text: "B"}, - {id: "items-c", text: "C"}, - {id: "items-d", text: "D"} - ]) - - await page.getByRole("button", {name: "Insert at 1", exact: true}).click() - await syncLV(page) + { id: "items-a", text: "A" }, + { id: "items-b", text: "B" }, + { id: "items-c", text: "C" }, + { id: "items-d", text: "D" }, + ]); + + await page.getByRole("button", { name: "Insert at 1", exact: true }).click(); + await syncLV(page); expect(await listItems(page)).toEqual([ - {id: "items-a", text: "A"}, - {id: expect.stringMatching(/items-a-.*/), text: expect.any(String)}, - {id: "items-b", text: "B"}, - {id: "items-c", text: "C"}, - {id: "items-d", text: "D"} - ]) - - await page.getByRole("button", {name: "Reset"}).click() - await syncLV(page) + { id: "items-a", text: "A" }, + { id: expect.stringMatching(/items-a-.*/), text: expect.any(String) }, + { id: "items-b", text: "B" }, + { id: "items-c", text: "C" }, + { id: "items-d", text: "D" }, + ]); + + await page.getByRole("button", { name: "Reset" }).click(); + await syncLV(page); expect(await listItems(page)).toEqual([ - {id: "items-a", text: "A"}, - {id: "items-b", text: "B"}, - {id: "items-c", text: "C"}, - {id: "items-d", text: "D"} - ]) - - await page.getByRole("button", {name: "Delete C and insert at 1"}).click() - await syncLV(page) + { id: "items-a", text: "A" }, + { id: "items-b", text: "B" }, + { id: "items-c", text: "C" }, + { id: "items-d", text: "D" }, + ]); + + await page.getByRole("button", { name: "Delete C and insert at 1" }).click(); + await syncLV(page); expect(await listItems(page)).toEqual([ - {id: "items-a", text: "A"}, - {id: "items-c", text: "C"}, - {id: "items-b", text: "B"}, - {id: "items-d", text: "D"} - ]) -}) - -test("stream nested in a LiveComponent is properly restored on reset", async ({page}) => { - await page.goto("/stream/nested-component-reset") - await syncLV(page) - - const childItems = async (page, id) => page.locator(`#${id} div[phx-update=stream] > *`).evaluateAll(div => div.map(el => ({id: el.id, text: el.innerText}))) + { id: "items-a", text: "A" }, + { id: "items-c", text: "C" }, + { id: "items-b", text: "B" }, + { id: "items-d", text: "D" }, + ]); +}); + +test("stream nested in a LiveComponent is properly restored on reset", async ({ + page, +}) => { + await page.goto("/stream/nested-component-reset"); + await syncLV(page); + + const childItems = async (page, id) => + page + .locator(`#${id} div[phx-update=stream] > *`) + .evaluateAll((div) => + div.map((el) => ({ id: el.id, text: el.innerText })), + ); expect(await listItems(page)).toEqual([ - {id: "items-a", text: expect.stringMatching(/A/)}, - {id: "items-b", text: expect.stringMatching(/B/)}, - {id: "items-c", text: expect.stringMatching(/C/)}, - {id: "items-d", text: expect.stringMatching(/D/)} - ]) + { id: "items-a", text: expect.stringMatching(/A/) }, + { id: "items-b", text: expect.stringMatching(/B/) }, + { id: "items-c", text: expect.stringMatching(/C/) }, + { id: "items-d", text: expect.stringMatching(/D/) }, + ]); - for(const id of ["a", "b", "c", "d"]){ + for (const id of ["a", "b", "c", "d"]) { expect(await childItems(page, `items-${id}`)).toEqual([ - {id: `nested-items-${id}-a`, text: "N-A"}, - {id: `nested-items-${id}-b`, text: "N-B"}, - {id: `nested-items-${id}-c`, text: "N-C"}, - {id: `nested-items-${id}-d`, text: "N-D"}, - ]) + { id: `nested-items-${id}-a`, text: "N-A" }, + { id: `nested-items-${id}-b`, text: "N-B" }, + { id: `nested-items-${id}-c`, text: "N-C" }, + { id: `nested-items-${id}-d`, text: "N-D" }, + ]); } // now reorder the nested stream of items-a - await page.locator("#items-a button").click() - await syncLV(page) + await page.locator("#items-a button").click(); + await syncLV(page); expect(await childItems(page, "items-a")).toEqual([ - {id: "nested-items-a-e", text: "N-E"}, - {id: "nested-items-a-a", text: "N-A"}, - {id: "nested-items-a-f", text: "N-F"}, - {id: "nested-items-a-g", text: "N-G"}, - ]) + { id: "nested-items-a-e", text: "N-E" }, + { id: "nested-items-a-a", text: "N-A" }, + { id: "nested-items-a-f", text: "N-F" }, + { id: "nested-items-a-g", text: "N-G" }, + ]); // unchanged - for(const id of ["b", "c", "d"]){ + for (const id of ["b", "c", "d"]) { expect(await childItems(page, `items-${id}`)).toEqual([ - {id: `nested-items-${id}-a`, text: "N-A"}, - {id: `nested-items-${id}-b`, text: "N-B"}, - {id: `nested-items-${id}-c`, text: "N-C"}, - {id: `nested-items-${id}-d`, text: "N-D"}, - ]) + { id: `nested-items-${id}-a`, text: "N-A" }, + { id: `nested-items-${id}-b`, text: "N-B" }, + { id: `nested-items-${id}-c`, text: "N-C" }, + { id: `nested-items-${id}-d`, text: "N-D" }, + ]); } // now reorder the parent stream - await page.locator("#parent-reorder").click() - await syncLV(page) + await page.locator("#parent-reorder").click(); + await syncLV(page); expect(await listItems(page)).toEqual([ - {id: "items-e", text: expect.stringMatching(/E/)}, - {id: "items-a", text: expect.stringMatching(/A/)}, - {id: "items-f", text: expect.stringMatching(/F/)}, - {id: "items-g", text: expect.stringMatching(/G/)}, - ]) + { id: "items-e", text: expect.stringMatching(/E/) }, + { id: "items-a", text: expect.stringMatching(/A/) }, + { id: "items-f", text: expect.stringMatching(/F/) }, + { id: "items-g", text: expect.stringMatching(/G/) }, + ]); // the new children's stream items have the correct order - for(const id of ["e", "f", "g"]){ + for (const id of ["e", "f", "g"]) { expect(await childItems(page, `items-${id}`)).toEqual([ - {id: `nested-items-${id}-a`, text: "N-A"}, - {id: `nested-items-${id}-b`, text: "N-B"}, - {id: `nested-items-${id}-c`, text: "N-C"}, - {id: `nested-items-${id}-d`, text: "N-D"}, - ]) + { id: `nested-items-${id}-a`, text: "N-A" }, + { id: `nested-items-${id}-b`, text: "N-B" }, + { id: `nested-items-${id}-c`, text: "N-C" }, + { id: `nested-items-${id}-d`, text: "N-D" }, + ]); } // Item A has the same children as before, still reordered expect(await childItems(page, "items-a")).toEqual([ - {id: "nested-items-a-e", text: "N-E"}, - {id: "nested-items-a-a", text: "N-A"}, - {id: "nested-items-a-f", text: "N-F"}, - {id: "nested-items-a-g", text: "N-G"}, - ]) -}) - -test("phx-remove is handled correctly when restoring nodes", async ({page}) => { - await page.goto("/stream/reset?phx-remove") - await syncLV(page) + { id: "nested-items-a-e", text: "N-E" }, + { id: "nested-items-a-a", text: "N-A" }, + { id: "nested-items-a-f", text: "N-F" }, + { id: "nested-items-a-g", text: "N-G" }, + ]); +}); + +test("phx-remove is handled correctly when restoring nodes", async ({ + page, +}) => { + await page.goto("/stream/reset?phx-remove"); + await syncLV(page); expect(await listItems(page)).toEqual([ - {id: "items-a", text: "A"}, - {id: "items-b", text: "B"}, - {id: "items-c", text: "C"}, - {id: "items-d", text: "D"} - ]) + { id: "items-a", text: "A" }, + { id: "items-b", text: "B" }, + { id: "items-c", text: "C" }, + { id: "items-d", text: "D" }, + ]); - await page.getByRole("button", {name: "Filter"}).click() - await syncLV(page) + await page.getByRole("button", { name: "Filter" }).click(); + await syncLV(page); expect(await listItems(page)).toEqual([ - {id: "items-b", text: "B"}, - {id: "items-c", text: "C"}, - {id: "items-d", text: "D"} - ]) + { id: "items-b", text: "B" }, + { id: "items-c", text: "C" }, + { id: "items-d", text: "D" }, + ]); - await page.getByRole("button", {name: "Reset"}).click() - await syncLV(page) + await page.getByRole("button", { name: "Reset" }).click(); + await syncLV(page); expect(await listItems(page)).toEqual([ - {id: "items-a", text: "A"}, - {id: "items-b", text: "B"}, - {id: "items-c", text: "C"}, - {id: "items-d", text: "D"} - ]) -}) - -test("issue #3129 - streams asynchronously assigned and rendered inside a comprehension", async ({page}) => { - await page.goto("/stream/inside-for") - await syncLV(page) + { id: "items-a", text: "A" }, + { id: "items-b", text: "B" }, + { id: "items-c", text: "C" }, + { id: "items-d", text: "D" }, + ]); +}); + +test("issue #3129 - streams asynchronously assigned and rendered inside a comprehension", async ({ + page, +}) => { + await page.goto("/stream/inside-for"); + await syncLV(page); expect(await listItems(page)).toEqual([ - {id: "items-a", text: "A"}, - {id: "items-b", text: "B"}, - {id: "items-c", text: "C"}, - {id: "items-d", text: "D"} - ]) -}) - -test("issue #3260 - supports non-stream items with id in stream container", async ({page}) => { - await page.goto("/stream?empty_item") + { id: "items-a", text: "A" }, + { id: "items-b", text: "B" }, + { id: "items-c", text: "C" }, + { id: "items-d", text: "D" }, + ]); +}); + +test("issue #3260 - supports non-stream items with id in stream container", async ({ + page, +}) => { + await page.goto("/stream?empty_item"); expect(await usersInDom(page, "users")).toEqual([ - {id: "users-1", text: "chris"}, - {id: "users-2", text: "callan"}, - {id: "users-empty", text: "Empty!"} - ]) - - await expect(page.getByText("Empty")).toBeHidden() - await evalLV(page, "socket.view.handle_event(\"reset-users\", %{}, socket)") - await expect(page.getByText("Empty")).toBeVisible() + { id: "users-1", text: "chris" }, + { id: "users-2", text: "callan" }, + { id: "users-empty", text: "Empty!" }, + ]); + + await expect(page.getByText("Empty")).toBeHidden(); + await evalLV(page, 'socket.view.handle_event("reset-users", %{}, socket)'); + await expect(page.getByText("Empty")).toBeVisible(); expect(await usersInDom(page, "users")).toEqual([ - {id: "users-empty", text: "Empty!"} - ]) + { id: "users-empty", text: "Empty!" }, + ]); - await evalLV(page, "socket.view.handle_event(\"append-users\", %{}, socket)") - await expect(page.getByText("Empty")).toBeHidden() + await evalLV(page, 'socket.view.handle_event("append-users", %{}, socket)'); + await expect(page.getByText("Empty")).toBeHidden(); expect(await usersInDom(page, "users")).toEqual([ - {id: "users-4", text: "foo"}, - {id: "users-3", text: "last_user"}, - {id: "users-empty", text: "Empty!"} - ]) -}) + { id: "users-4", text: "foo" }, + { id: "users-3", text: "last_user" }, + { id: "users-empty", text: "Empty!" }, + ]); +}); -test("JS commands are applied when re-joining", async ({page}) => { - await page.goto("/stream") - await syncLV(page) +test("JS commands are applied when re-joining", async ({ page }) => { + await page.goto("/stream"); + await syncLV(page); expect(await usersInDom(page, "users")).toEqual([ - {id: "users-1", text: "chris"}, - {id: "users-2", text: "callan"} - ]) - await expect(page.locator("#users-1")).toBeVisible() - await page.locator("#users-1").getByRole("button", {name: "JS Hide"}).click() - await expect(page.locator("#users-1")).toBeHidden() - await page.evaluate(() => new Promise((resolve) => window.liveSocket.disconnect(resolve))) + { id: "users-1", text: "chris" }, + { id: "users-2", text: "callan" }, + ]); + await expect(page.locator("#users-1")).toBeVisible(); + await page + .locator("#users-1") + .getByRole("button", { name: "JS Hide" }) + .click(); + await expect(page.locator("#users-1")).toBeHidden(); + await page.evaluate( + () => new Promise((resolve) => window.liveSocket.disconnect(resolve)), + ); // not reconnect - await page.evaluate(() => window.liveSocket.connect()) - await syncLV(page) + await page.evaluate(() => window.liveSocket.connect()); + await syncLV(page); // should still be hidden - await expect(page.locator("#users-1")).toBeHidden() -}) + await expect(page.locator("#users-1")).toBeHidden(); +}); diff --git a/test/e2e/tests/uploads.spec.js b/test/e2e/tests/uploads.spec.js index fcef224999..0d8fca3a17 100644 --- a/test/e2e/tests/uploads.spec.js +++ b/test/e2e/tests/uploads.spec.js @@ -1,292 +1,325 @@ -import {test, expect} from "../test-fixtures" -import {syncLV, attributeMutations} from "../utils" +import { test, expect } from "../test-fixtures"; +import { syncLV, attributeMutations } from "../utils"; // https://stackoverflow.com/questions/10623798/how-do-i-read-the-contents-of-a-node-js-stream-into-a-string-variable -const readStream = (stream) => new Promise((resolve) => { - const chunks = [] +const readStream = (stream) => + new Promise((resolve) => { + const chunks = []; - stream.on("data", function (chunk){ - chunks.push(chunk) - }) + stream.on("data", function (chunk) { + chunks.push(chunk); + }); - // Send the buffer or you can put it into a var - stream.on("end", function (){ - resolve(Buffer.concat(chunks)) - }) -}) + // Send the buffer or you can put it into a var + stream.on("end", function () { + resolve(Buffer.concat(chunks)); + }); + }); -test("can upload a file", async ({page}) => { - await page.goto("/upload") - await syncLV(page) +test("can upload a file", async ({ page }) => { + await page.goto("/upload"); + await syncLV(page); - const changesForm = attributeMutations(page, "#upload-form") - const changesInput = attributeMutations(page, "#upload-form input") + const changesForm = attributeMutations(page, "#upload-form"); + const changesInput = attributeMutations(page, "#upload-form input"); // wait for the change listeners to be ready - await page.waitForTimeout(50) + await page.waitForTimeout(50); await page.locator("#upload-form input").setInputFiles({ name: "file.txt", mimeType: "text/plain", - buffer: Buffer.from("this is a test") - }) - await syncLV(page) - await expect(page.locator("progress")).toHaveAttribute("value", "0") - await page.getByRole("button", {name: "Upload"}).click() + buffer: Buffer.from("this is a test"), + }); + await syncLV(page); + await expect(page.locator("progress")).toHaveAttribute("value", "0"); + await page.getByRole("button", { name: "Upload" }).click(); // we should see one uploaded file in the list - await expect(page.locator("ul li")).toBeVisible() + await expect(page.locator("ul li")).toBeVisible(); - expect(await changesForm()).toEqual(expect.arrayContaining([ - {attr: "class", oldValue: null, newValue: "phx-submit-loading"}, - {attr: "class", oldValue: "phx-submit-loading", newValue: null}, - ])) + expect(await changesForm()).toEqual( + expect.arrayContaining([ + { attr: "class", oldValue: null, newValue: "phx-submit-loading" }, + { attr: "class", oldValue: "phx-submit-loading", newValue: null }, + ]), + ); - expect(await changesInput()).toEqual(expect.arrayContaining([ - {attr: "class", oldValue: null, newValue: "phx-change-loading"}, - {attr: "class", oldValue: "phx-change-loading", newValue: null}, - ])) + expect(await changesInput()).toEqual( + expect.arrayContaining([ + { attr: "class", oldValue: null, newValue: "phx-change-loading" }, + { attr: "class", oldValue: "phx-change-loading", newValue: null }, + ]), + ); // now download the file to see if it contains the expected content - const downloadPromise = page.waitForEvent("download") - await page.locator("ul li a").click() - const download = await downloadPromise - - await expect(download.createReadStream().then(readStream).then(buf => buf.toString())) - .resolves.toEqual("this is a test") -}) - -test("can drop a file", async ({page}) => { - await page.goto("/upload") - await syncLV(page) + const downloadPromise = page.waitForEvent("download"); + await page.locator("ul li a").click(); + const download = await downloadPromise; + + await expect( + download + .createReadStream() + .then(readStream) + .then((buf) => buf.toString()), + ).resolves.toEqual("this is a test"); +}); + +test("can drop a file", async ({ page }) => { + await page.goto("/upload"); + await syncLV(page); // https://github.com/microsoft/playwright/issues/10667 // Create the DataTransfer and File const dataTransfer = await page.evaluateHandle((data) => { - const dt = new DataTransfer() + const dt = new DataTransfer(); // Convert the buffer to a hex array - const file = new File([data], "file.txt", {type: "text/plain"}) - dt.items.add(file) - return dt - }, "this is a test") + const file = new File([data], "file.txt", { type: "text/plain" }); + dt.items.add(file); + return dt; + }, "this is a test"); // Now dispatch - await page.dispatchEvent("section", "drop", {dataTransfer}) + await page.dispatchEvent("section", "drop", { dataTransfer }); - await syncLV(page) - await page.getByRole("button", {name: "Upload"}).click() + await syncLV(page); + await page.getByRole("button", { name: "Upload" }).click(); // we should see one uploaded file in the list - await expect(page.locator("ul li")).toBeVisible() + await expect(page.locator("ul li")).toBeVisible(); // now download the file to see if it contains the expected content - const downloadPromise = page.waitForEvent("download") - await page.locator("ul li a").click() - const download = await downloadPromise - - await expect(download.createReadStream().then(readStream).then(buf => buf.toString())) - .resolves.toEqual("this is a test") -}) - -test("can upload multiple files", async ({page}) => { - await page.goto("/upload") - await syncLV(page) + const downloadPromise = page.waitForEvent("download"); + await page.locator("ul li a").click(); + const download = await downloadPromise; + + await expect( + download + .createReadStream() + .then(readStream) + .then((buf) => buf.toString()), + ).resolves.toEqual("this is a test"); +}); + +test("can upload multiple files", async ({ page }) => { + await page.goto("/upload"); + await syncLV(page); await page.locator("#upload-form input").setInputFiles([ { name: "file.txt", mimeType: "text/plain", - buffer: Buffer.from("this is a test") + buffer: Buffer.from("this is a test"), }, { name: "file.md", mimeType: "text/markdown", - buffer: Buffer.from("## this is a markdown file") - } - ]) - await syncLV(page) - await page.getByRole("button", {name: "Upload"}).click() + buffer: Buffer.from("## this is a markdown file"), + }, + ]); + await syncLV(page); + await page.getByRole("button", { name: "Upload" }).click(); // we should see two uploaded files in the list - await expect(page.locator("ul li")).toHaveCount(2) -}) + await expect(page.locator("ul li")).toHaveCount(2); +}); -test("shows error when there are too many files", async ({page}) => { - await page.goto("/upload") - await syncLV(page) +test("shows error when there are too many files", async ({ page }) => { + await page.goto("/upload"); + await syncLV(page); await page.locator("#upload-form input").setInputFiles([ { name: "file.txt", mimeType: "text/plain", - buffer: Buffer.from("this is a test") + buffer: Buffer.from("this is a test"), }, { name: "file.md", mimeType: "text/markdown", - buffer: Buffer.from("## this is a markdown file") + buffer: Buffer.from("## this is a markdown file"), }, { name: "file2.txt", mimeType: "text/plain", - buffer: Buffer.from("another file") - } - ]) - await syncLV(page) + buffer: Buffer.from("another file"), + }, + ]); + await syncLV(page); - await expect(page.locator(".alert")).toContainText("You have selected too many files") -}) + await expect(page.locator(".alert")).toContainText( + "You have selected too many files", + ); +}); -test("shows error for invalid mimetype", async ({page}) => { - await page.goto("/upload") - await syncLV(page) +test("shows error for invalid mimetype", async ({ page }) => { + await page.goto("/upload"); + await syncLV(page); await page.locator("#upload-form input").setInputFiles([ { name: "file.html", mimeType: "text/html", - buffer: Buffer.from("

Hi

") - } - ]) - await syncLV(page) + buffer: Buffer.from("

Hi

"), + }, + ]); + await syncLV(page); - await expect(page.locator(".alert")).toContainText("You have selected an unacceptable file type") -}) + await expect(page.locator(".alert")).toContainText( + "You have selected an unacceptable file type", + ); +}); -test("auto upload", async ({page}) => { - await page.goto("/upload?auto_upload=1") - await syncLV(page) +test("auto upload", async ({ page }) => { + await page.goto("/upload?auto_upload=1"); + await syncLV(page); - const changes = attributeMutations(page, "#upload-form input") + const changes = attributeMutations(page, "#upload-form input"); // wait for the change listeners to be ready - await page.waitForTimeout(50) + await page.waitForTimeout(50); await page.locator("#upload-form input").setInputFiles([ { name: "file.txt", mimeType: "text/plain", - buffer: Buffer.from("this is a test") - } - ]) - await syncLV(page) - await expect(page.locator("progress")).toHaveAttribute("value", "100") + buffer: Buffer.from("this is a test"), + }, + ]); + await syncLV(page); + await expect(page.locator("progress")).toHaveAttribute("value", "100"); - expect(await changes()).toEqual(expect.arrayContaining([ - {attr: "class", oldValue: null, newValue: "phx-change-loading"}, - {attr: "class", oldValue: "phx-change-loading", newValue: null}, - ])) + expect(await changes()).toEqual( + expect.arrayContaining([ + { attr: "class", oldValue: null, newValue: "phx-change-loading" }, + { attr: "class", oldValue: "phx-change-loading", newValue: null }, + ]), + ); - await page.getByRole("button", {name: "Upload"}).click() + await page.getByRole("button", { name: "Upload" }).click(); - await expect(page.locator("ul li")).toBeVisible() -}) + await expect(page.locator("ul li")).toBeVisible(); +}); -test("issue 3115 - cancelled upload is not re-added", async ({page}) => { - await page.goto("/upload") - await syncLV(page) +test("issue 3115 - cancelled upload is not re-added", async ({ page }) => { + await page.goto("/upload"); + await syncLV(page); await page.locator("#upload-form input").setInputFiles([ { name: "file.txt", mimeType: "text/plain", - buffer: Buffer.from("this is a test") - } - ]) - await syncLV(page) + buffer: Buffer.from("this is a test"), + }, + ]); + await syncLV(page); // cancel the file - await page.getByLabel("cancel").click() + await page.getByLabel("cancel").click(); // add other file await page.locator("#upload-form input").setInputFiles([ { name: "file.md", mimeType: "text/markdown", - buffer: Buffer.from("## this is a markdown file") - } - ]) - await syncLV(page) - await page.getByRole("button", {name: "Upload"}).click() + buffer: Buffer.from("## this is a markdown file"), + }, + ]); + await syncLV(page); + await page.getByRole("button", { name: "Upload" }).click(); // we should see one uploaded file in the list - await expect(page.locator("ul li")).toHaveCount(1) -}) + await expect(page.locator("ul li")).toHaveCount(1); +}); -test("submitting invalid form multiple times doesn't crash", async ({page}) => { +test("submitting invalid form multiple times doesn't crash", async ({ + page, +}) => { // https://github.com/phoenixframework/phoenix_live_view/pull/3133#issuecomment-1962439904 - await page.goto("/upload") - await syncLV(page) + await page.goto("/upload"); + await syncLV(page); - const logs = [] - page.on("console", (e) => logs.push(e.text())) + const logs = []; + page.on("console", (e) => logs.push(e.text())); await page.locator("#upload-form input").setInputFiles([ { name: "file.txt", mimeType: "text/plain", - buffer: Buffer.from("this is a test") + buffer: Buffer.from("this is a test"), }, { name: "file.md", mimeType: "text/markdown", - buffer: Buffer.from("## this is a markdown file") + buffer: Buffer.from("## this is a markdown file"), }, { name: "file2.md", mimeType: "text/markdown", - buffer: Buffer.from("## this is another markdown file") - } - ]) - await syncLV(page) - await page.getByRole("button", {name: "Upload"}).click() - await page.getByRole("button", {name: "Upload"}).click() - await syncLV(page) - - expect(logs).not.toEqual(expect.arrayContaining([expect.stringMatching("view crashed")])) - await expect(page.locator(".alert")).toContainText("You have selected too many files") -}) - -test("auto upload - can submit files after fixing too many files error", async ({page}) => { + buffer: Buffer.from("## this is another markdown file"), + }, + ]); + await syncLV(page); + await page.getByRole("button", { name: "Upload" }).click(); + await page.getByRole("button", { name: "Upload" }).click(); + await syncLV(page); + + expect(logs).not.toEqual( + expect.arrayContaining([expect.stringMatching("view crashed")]), + ); + await expect(page.locator(".alert")).toContainText( + "You have selected too many files", + ); +}); + +test("auto upload - can submit files after fixing too many files error", async ({ + page, +}) => { // https://github.com/phoenixframework/phoenix_live_view/commit/80ddf356faaded097358a784c2515c50c345713e - await page.goto("/upload?auto_upload=1") - await syncLV(page) + await page.goto("/upload?auto_upload=1"); + await syncLV(page); - const logs = [] - page.on("console", (e) => logs.push(e.text())) + const logs = []; + page.on("console", (e) => logs.push(e.text())); await page.locator("#upload-form input").setInputFiles([ { name: "file.txt", mimeType: "text/plain", - buffer: Buffer.from("this is a test") + buffer: Buffer.from("this is a test"), }, { name: "file.md", mimeType: "text/markdown", - buffer: Buffer.from("## this is a markdown file") + buffer: Buffer.from("## this is a markdown file"), }, { name: "file2.md", mimeType: "text/markdown", - buffer: Buffer.from("## this is another markdown file") - } - ]) + buffer: Buffer.from("## this is another markdown file"), + }, + ]); // before the fix, this already failed because the phx-change-loading class was not removed // because undoRefs failed - await syncLV(page) + await syncLV(page); expect(logs).not.toEqual( expect.arrayContaining([ - expect.stringMatching("no preflight upload response returned with ref") - ]) - ) + expect.stringMatching("no preflight upload response returned with ref"), + ]), + ); // too many files - await expect(page.locator("ul li")).toHaveCount(0) - await expect(page.locator(".alert")).toContainText("You have selected too many files") + await expect(page.locator("ul li")).toHaveCount(0); + await expect(page.locator(".alert")).toContainText( + "You have selected too many files", + ); // now remove the file that caused the error - await page.locator("article").filter({hasText: "file2.md"}).getByLabel("cancel").click() + await page + .locator("article") + .filter({ hasText: "file2.md" }) + .getByLabel("cancel") + .click(); // now we can upload the files - await page.getByRole("button", {name: "Upload"}).click() - await syncLV(page) + await page.getByRole("button", { name: "Upload" }).click(); + await syncLV(page); - await expect(page.locator(".alert")).toBeHidden() - await expect(page.locator("ul li")).toHaveCount(2) -}) + await expect(page.locator(".alert")).toBeHidden(); + await expect(page.locator("ul li")).toHaveCount(2); +}); diff --git a/test/e2e/utils.js b/test/e2e/utils.js index c29a3d4ad6..7fa156cded 100644 --- a/test/e2e/utils.js +++ b/test/e2e/utils.js @@ -1,7 +1,8 @@ -import {expect} from "@playwright/test" -import Crypto from "node:crypto" +import { expect } from "@playwright/test"; +import Crypto from "node:crypto"; -export const randomString = (size = 21) => Crypto.randomBytes(size).toString("base64").slice(0, size) +export const randomString = (size = 21) => + Crypto.randomBytes(size).toString("base64").slice(0, size); // a helper function to wait until the LV has no pending events export const syncLV = async (page) => { @@ -10,73 +11,82 @@ export const syncLV = async (page) => { expect(page.locator(".phx-change-loading")).toHaveCount(0), expect(page.locator(".phx-click-loading")).toHaveCount(0), expect(page.locator(".phx-submit-loading")).toHaveCount(0), - ] - return Promise.all(promises) -} + ]; + return Promise.all(promises); +}; // this function executes the given code inside the liveview that is responsible // for the given selector; it uses private phoenix live view js functions, so it could // break in the future // we handle the evaluation in a LV hook -export const evalLV = async (page, code, selector = "[data-phx-main]") => await page.evaluate(([code, selector]) => { - return new Promise((resolve) => { - window.liveSocket.main.withinTargets(selector, (targetView, targetCtx) => { - targetView.pushEvent( - "event", - document.body, - targetCtx, - "sandbox:eval", - {value: code}, - {}, - ({result}) => resolve(result) - ) - }) - }) -}, [code, selector]) +export const evalLV = async (page, code, selector = "[data-phx-main]") => + await page.evaluate( + ([code, selector]) => { + return new Promise((resolve) => { + window.liveSocket.main.withinTargets( + selector, + (targetView, targetCtx) => { + targetView.pushEvent( + "event", + document.body, + targetCtx, + "sandbox:eval", + { value: code }, + {}, + ({ result }) => resolve(result), + ); + }, + ); + }); + }, + [code, selector], + ); // executes the given code inside a new process // (in context of a plug request) export const evalPlug = async (request, code) => { - return await request.post("/eval", { - data: {code} - }).then(resp => resp.json()) -} + return await request + .post("/eval", { + data: { code }, + }) + .then((resp) => resp.json()); +}; export const attributeMutations = (page, selector) => { // this is a bit of a hack, basically we create a MutationObserver on the page // that will record any changes to a selector until the promise is awaited // // we use a random id to store the resolve function in the window object - const id = randomString(24) + const id = randomString(24); // this promise resolves to the mutation list const promise = page.locator(selector).evaluate((target, id) => { return new Promise((resolve) => { - const mutations = [] - let observer + const mutations = []; + let observer; window[id] = () => { - if(observer) observer.disconnect() - resolve(mutations) - delete window[id] - } + if (observer) observer.disconnect(); + resolve(mutations); + delete window[id]; + }; // https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver observer = new MutationObserver((mutationsList, _observer) => { - mutationsList.forEach(mutation => { - if(mutation.type === "attributes"){ + mutationsList.forEach((mutation) => { + if (mutation.type === "attributes") { mutations.push({ attr: mutation.attributeName, oldValue: mutation.oldValue, - newValue: mutation.target.getAttribute(mutation.attributeName) - }) + newValue: mutation.target.getAttribute(mutation.attributeName), + }); } - }) - }).observe(target, {attributes: true, attributeOldValue: true}) - }) - }, id) + }); + }).observe(target, { attributes: true, attributeOldValue: true }); + }); + }, id); return () => { // we want to stop observing! - page.locator(selector).evaluate((_target, id) => window[id](), id) + page.locator(selector).evaluate((_target, id) => window[id](), id); // return the result of the initial promise - return promise - } -} + return promise; + }; +};