diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 906986e13a..c9ad9cf079 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -142,11 +142,17 @@ 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: check lint and format + run: npm run js:lint && npm run js:format.check - - name: eslint - run: npx eslint + - name: test + run: npm run js:test - uses: actions/upload-artifact@v4 if: always() diff --git a/.gitignore b/.gitignore index 357011434e..3b2b069ca3 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,4 @@ node_modules /test/e2e/test-results/ /playwright-report/ /coverage/ +/assets/js/types/ 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 25ecf74ab3..ee0fd7e2f1 100644 --- a/assets/js/phoenix_live_view/aria.js +++ b/assets/js/phoenix_live_view/aria.js @@ -1,49 +1,66 @@ -let ARIA = { - anyOf(instance, classes){ return classes.find(name => instance instanceof name) }, +const ARIA = { + 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)){ try { el.focus() } catch {} } - return !!document.activeElement && document.activeElement.isSameNode(el) + attemptFocus(el, interactiveOnly) { + 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)){ - 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 2284113693..1cf64a31f8 100644 --- a/assets/js/phoenix_live_view/browser.js +++ b/assets/js/phoenix_live_view/browser.js @@ -1,84 +1,105 @@ -let Browser = { - canPushState(){ return (typeof (history.pushState) !== "undefined") }, +const Browser = { + 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){ - let current = this.getLocal(localStorage, namespace, subkey) - let key = this.localKey(namespace, subkey) - let 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. - let 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(() => { - let 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){ - let 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 = 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){ - let 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 f078bed71c..4dfbd62a69 100644 --- a/assets/js/phoenix_live_view/dom.js +++ b/assets/js/phoenix_live_view/dom.js @@ -20,391 +20,516 @@ import { PHX_STICKY, PHX_EVENT_CLASSES, THROTTLED, -} from "./constants" +} from "./constants"; -import { - logError -} from "./utils" +import { logError } from "./utils"; -let DOM = { - byId(id){ return document.getElementById(id) || logError(`no id found for ${id}`) }, +const DOM = { + 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 [] } - let array = Array.from(node.querySelectorAll(query)) - return callback ? array.forEach(callback) : array + all(node, query, callback) { + if (!node) { + return []; + } + const array = Array.from(node.querySelectorAll(query)); + if (callback) { + array.forEach(callback); + } + return array; }, - childNodeLength(html){ - let 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){ - 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("_") - 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 - let 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){ - let 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){ - let 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) - let parentCids = new Set() - let 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){ - let 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){ - let titleEl = document.querySelector("title") - if(titleEl){ - let {prefix, suffix, default: defaultTitle} = titleEl.dataset - let 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; + } - let 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 } - let 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: - let timeout = parseInt(value) - let trigger = () => throttle ? this.deletePrivate(el, THROTTLED) : callback() - let 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) - 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); } - let 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]) => { - let 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){ - let [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 - let 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) - 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 { - let 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 = {}){ - let exclude = new Set(opts.exclude || []) - let isIgnored = opts.isIgnored - let sourceAttrs = source.attributes - for(let i = sourceAttrs.length - 1; i >= 0; i--){ - let 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 @@ -414,144 +539,194 @@ let 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)); } } } - let targetAttrs = target.attributes - for(let i = targetAttrs.length - 1; i >= 0; i--){ - let 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; + } - let 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"])){ - let 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) - let 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){ - let 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 { - let 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){ - let op = (DOM.private(el, "sticky") || []).find(([existingName,]) => name === existingName) - if(op){ - let [_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){ - let stashedResult = op(el) - this.updatePrivate(el, "sticky", [], ops => { - let 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){ - let 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 2cf9eb5868..174368cb9e 100644 --- a/assets/js/phoenix_live_view/dom_patch.js +++ b/assets/js/phoenix_live_view/dom_patch.js @@ -14,477 +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) } - - trackBefore(kind, ...args){ - this.callbacks[`before${kind}`].forEach(callback => callback(...args)) + before(kind, callback) { + this.callbacks[`before${kind}`].push(callback); } - - trackAfter(kind, ...args){ - this.callbacks[`after${kind}`].forEach(callback => callback(...args)) + after(kind, callback) { + this.callbacks[`after${kind}`].push(callback); } - markPrunableContentForRemoval(){ - let phxUpdate = this.liveSocket.binding(PHX_UPDATE) - DOM.all(this.container, `[${phxUpdate}=append] > *, [${phxUpdate}=prepend] > *`, el => { - el.setAttribute(PHX_PRUNE, "") - }) + trackBefore(kind, ...args) { + this.callbacks[`before${kind}`].forEach((callback) => callback(...args)); } - perform(isJoinPatch){ - let {view, liveSocket, html, container, targetContainer} = this - if(this.isCIDPatch() && !targetContainer){ return } + trackAfter(kind, ...args) { + this.callbacks[`after${kind}`].forEach((callback) => callback(...args)); + } - 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 = [] + markPrunableContentForRemoval() { + const phxUpdate = this.liveSocket.binding(PHX_UPDATE); + DOM.all( + this.container, + `[${phxUpdate}=append] > *, [${phxUpdate}=prepend] > *`, + (el) => { + el.setAttribute(PHX_PRUNE, ""); + }, + ); + } - let externalFormTriggered = null + perform(isJoinPatch) { + const { view, liveSocket, html, container, targetContainer } = this; + if (this.isCIDPatch() && !targetContainer) { + return; + } - function morph(targetContainer, source, withChildren=this.withChildren){ - let morphCallbacks = { + 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) { + 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) => { - let {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){ - let lastChild = parent.lastElementChild - if(lastChild && !lastChild.hasAttribute(PHX_STREAM_REF)){ - let 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){ - let 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){ - el.srcset = el.srcset - } else if(el instanceof HTMLVideoElement && el.autoplay){ - el.play() + 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(); } - 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. - let isFocusedFormEl = focused && fromEl.isSameNode(focused) && DOM.isFormInput(fromEl) - let 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) - let isLocked = fromEl.hasAttribute(PHX_REF_LOCK) - let 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)){ - let 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 => { - let 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.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){ - let 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){ - let {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){ - let children = Array.from(el.parentElement.children) - let 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 { - let 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){ - let {limit} = this.getStreamInsert(el) - let 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(){ - let {pendingRemoves, liveSocket} = this - if(pendingRemoves.length > 0){ + transitionPendingRemoves() { + const { pendingRemoves, liveSocket } = this; + if (pendingRemoves.length > 0) { liveSocket.transitionRemoves(pendingRemoves, () => { - pendingRemoves.forEach(el => { - let 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 } - let [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 2fa2ea429b..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){ - let idsBefore = new Set() - let 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), + ); - 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 - 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,33 +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(){ - let container = DOM.byId(this.containerId) - 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 - 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 => { - let 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 550d4e393d..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 } - - let 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) - let 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) - let disabledVal = this.el.getAttribute(PHX_DISABLED) - let 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 - let 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); } - let 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 a7535434f1..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){ - let {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(){ - let reader = new window.FileReader() - let 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 += e.target.result.byteLength - this.pushChunk(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 new file mode 100644 index 0000000000..6a4a227e8c --- /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..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"; -let Hooks = { +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(){ - let 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); } } - } - } -} + }, + }, +}; -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 - if(["scroll", "auto"].indexOf(getComputedStyle(el).overflowY) >= 0) return el - return findScrollContainer(el.parentElement) -} - -let scrollTop = (scrollContainer) => { - if(scrollContainer){ - return scrollContainer.scrollTop + 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; } else { - return document.documentElement.scrollTop || document.body.scrollTop + return document.documentElement.scrollTop || document.body.scrollTop; } -} +}; -let bottom = (scrollContainer) => { - if(scrollContainer){ - return scrollContainer.getBoundingClientRect().bottom +const bottom = (scrollContainer) => { + 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; } -} +}; -let top = (scrollContainer) => { - if(scrollContainer){ - return scrollContainer.getBoundingClientRect().top +const top = (scrollContainer) => { + 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; } -} +}; -let isAtViewportTop = (el, scrollContainer) => { - let rect = el.getBoundingClientRect() - return Math.ceil(rect.top) >= top(scrollContainer) && Math.ceil(rect.left) >= 0 && Math.floor(rect.top) <= bottom(scrollContainer) -} +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() - return Math.ceil(rect.bottom) >= top(scrollContainer) && Math.ceil(rect.left) >= 0 && Math.floor(rect.bottom) <= 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) + ); +}; -let isWithinViewport = (el, scrollContainer) => { - let rect = el.getBoundingClientRect() - return Math.ceil(rect.top) >= top(scrollContainer) && Math.ceil(rect.left) >= 0 && Math.floor(rect.top) <= 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) + ); +}; Hooks.InfiniteScroll = { - mounted(){ - this.scrollContainer = findScrollContainer(this.el) - let scrollBefore = scrollTop(this.scrollContainer) - let topOverran = false - let throttleInterval = 500 - let pendingOp = null - - let 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) => { - 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"}) - } - }) - }}) - }) - - let 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) => { - let scrollNow = scrollTop(this.scrollContainer) + const scrollNow = scrollTop(this.scrollContainer); - if(pendingOp){ - scrollBefore = scrollNow - return pendingOp() + 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){ - 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) => { - 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){ - 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.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..b97b28f280 --- /dev/null +++ b/assets/js/phoenix_live_view/index.ts @@ -0,0 +1,299 @@ +/* +================================================================================ +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 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. + */ +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: 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; +} + +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 1bd6c7a42e..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"; -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) === "[" ? - 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}){ - let 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){ - 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 + 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"){ - let {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 - let [inClasses, inStartClasses, inEndClasses] = ins || [[], [], []] - let [outClasses, outStartClasses, outEndClasses] = outs || [[], [], []] - if(inClasses.length > 0 || outClasses.length > 0){ - if(this.isVisible(el)){ - let onStart = () => { - this.addOrRemoveClasses(el, outStartClasses, inClasses.concat(inStartClasses).concat(inEndClasses)) + 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), + ); window.requestAnimationFrame(() => { - this.addOrRemoveClasses(el, outClasses, []) - window.requestAnimationFrame(() => this.addOrRemoveClasses(el, outEndClasses, outStartClasses)) - }) - } - let 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, []); + 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); } else { - view.transition(time, onStart, onEnd) + view.transition(time, onStart, onEnd); } } else { - if(eventType === "remove"){ return } - let onStart = () => { - this.addOrRemoveClasses(el, inStartClasses, outClasses.concat(outStartClasses).concat(outEndClasses)) - const stickyDisplay = display || this.defaultDisplay(el) + if (eventType === "remove") { + return; + } + const onStart = () => { + 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) - }) - }) - } - let 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) + 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); } 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")) - let 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(() => { - 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)) - 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 - let [transitionRun, transitionStart, transitionEnd] = transition || [[], [], []] - if(transitionRun.length > 0){ - let onStart = () => { - this.addOrRemoveClasses(el, transitionStart, [].concat(transitionRun).concat(transitionEnd)) + 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), + ); 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)) - 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(() => { - 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) - - DOM.putSticky(el, "classes", currentEl => { - currentEl.classList.remove(...newRemoves) - currentEl.classList.add(...newAdds) - return [newAdds, newRemoves] - }) - }) - }, - - setOrRemoveAttrs(el, sets, removes){ - let [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) - - DOM.putSticky(el, "attrs", currentEl => { - newRemoves.forEach(attr => currentEl.removeAttribute(attr)) - newSets.forEach(([attr, val]) => currentEl.setAttribute(attr, val)) - return [newSets, newRemoves] - }) + 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); + } + }; + return to + ? liveSocket.jsQuerySelectorAll(sourceEl, to, defaultQuery) + : [sourceEl]; }, - hasAllClasses(el, classes){ return classes.every(name => el.classList.contains(name)) }, - - isToggledOut(el, outClasses){ - return !this.isVisible(el) || this.hasAllClasses(el, outClasses) + defaultDisplay(el) { + return ( + { tr: "table-row", td: "table-cell" }[el.tagName.toLowerCase()] || "block" + ); }, - filterToEls(liveSocket, sourceEl, {to}){ - let defaultQuery = () => { - if(typeof(to) === "string"){ - return document.querySelectorAll(to) - } else if(to.closest){ - let toEl = sourceEl.closest(to.closest) - return toEl ? [toEl] : [] - } else if(to.inner){ - return sourceEl.querySelectorAll(to.inner) - } + transitionClasses(val) { + if (!val) { + return null; } - return to ? liveSocket.jsQuerySelectorAll(sourceEl, to, defaultQuery) : [sourceEl] - }, - defaultDisplay(el){ - return {tr: "table-row", td: "table-cell"}[el.tagName.toLowerCase()] || "block" + 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]; }, +}; - 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] - } -} - -export default JS +export default JS; 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..f71b3cf811 --- /dev/null +++ b/assets/js/phoenix_live_view/js_commands.ts @@ -0,0 +1,390 @@ +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..0f44b7bed8 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, @@ -102,930 +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 let 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(){ - let 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() } - let doConnect = () => { - this.resetReloadStatus() - if(this.joinRootViews()){ - this.bindTopLevelEvents() - this.socket.connect() - } else if(this.main){ - this.socket.connect() + 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(); } 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){ - let 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)); } - js(){ - return jsCommands(this, "js") + /** + * 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"); } // 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() - } - - triggerDOM(kind, args){ this.domCallbacks[kind](...args) } - - time(name, func){ - if(!this.isProfileEnabled() || !console.time){ return func() } - console.time(name) - let result = func() - console.timeEnd(name) - return result - } - - log(view, kind, msgCallback){ - if(this.viewLogger){ - let [msg, obj] = msgCallback() - this.viewLogger(view, kind, msg, obj) - } else if(this.isDebugEnabled()){ - let [msg, obj] = msgCallback() - debug(view, kind, msg, obj) + + 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); + } + + 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); } } - 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 => { - let 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() - let minMs = this.reloadJitterMin - let 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) - 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; } - if(this.hasPendingLink()){ - window.location = this.pendingLink + 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; } 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(){ - let body = document.body - if(body && !this.isPhxView(body) && !this.isPhxView(document.firstElementChild)){ - let 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)){ - let 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){ - let removeAttr = this.binding("remove") - let silenceEvents = (e) => { - e.preventDefault() - e.stopImmediatePropagation() - } - elements.forEach(el => { + transitionRemoves(elements, callback) { + 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){ - 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(let 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){ - let 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){ - let 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){ - let 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(let 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){ - let 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.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.blur() } + blurActiveElement() { + this.prevActive = this.getActiveElement(); + if ( + this.prevActive !== document.body && + this.prevActive instanceof HTMLElement + ) { + this.prevActive.blur(); + } } - bindTopLevelEvents({dead} = {}){ - if(this.boundTopLevelEvents){ return } + /** + * @param {{dead?: boolean}} [options={}] + */ + 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) => { - let matchKey = targetEl.getAttribute(this.binding(PHX_KEY)) - let 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)} - 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)} - 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"){ - let 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() - let 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 } - - LiveUploader.trackFiles(dropTarget, files, e.dataTransfer) - dropTarget.dispatchEvent(new Event("input", {bubbles: true})) - }) - this.on(PHX_TRACK_UPLOADS, e => { - let uploadTarget = e.target - if(!DOM.isUploadInput(uploadTarget)){ return } - let 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] - 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(let event in events){ - let browserEventName = events[event] + bind(events, callback) { + 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) - 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 => { - let 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){ - let 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 - // 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) - if(!phxEvent){ - if(DOM.isNewPageClick(e, window.location)){ this.unload() } - return - } + 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; + } - if(target.getAttribute("href") === "#"){ e.preventDefault() } + 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){ - let 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) - 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 } - let {type, backType, id, scroll, position} = event.state || {} - let href = window.location.href - - // Compare positions to determine direction - let isForward = position > this.currentHistoryPosition - - type = 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"}}) - this.requestDOMUpdate(() => { - const callback = () => { this.maybeScroll(scroll) } - if(this.main.isConnected() && (type === "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 => { - let target = closestPhxBinding(e.target, PHX_LIVE_LINK) - let 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 - - let 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; } - let 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}) - let 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)){ - let {protocol, host} = window.location - href = `${protocol}//${host}${href}` + if (/^\/$|^\/[^\/]+.*$/.test(href)) { + const { protocol, host } = window.location; + href = `${protocol}//${host}${href}`; } - let 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){ - let {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 => { - let phxSubmit = e.target.getAttribute(this.binding("submit")) - let 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 => { - let 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(let type of ["change", "input"]){ - this.on(type, e => { - if(e instanceof CustomEvent && 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; } - 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 // 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; } - let inputEvent = input.getAttribute(phxChange) - let formEvent = input.form && input.form.getAttribute(phxChange) - let 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 - iterations++ - let {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) => { - let form = e.target - DOM.resetForm(form) - let 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() } - - let phxDebounce = this.binding(PHX_DEBOUNCE) - let phxThrottle = this.binding(PHX_THROTTLE) - let defaultDebounce = this.defaults.debounce.toString() - let defaultThrottle = this.defaults.throttle.toString() + debounce(el, event, eventType, callback) { + if (eventType === "blur" || eventType === "focusout") { + return callback(); + } - this.withinOwners(el, view => { - let 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){ - let 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() - let timer = setTimeout(() => { - this.transitions.delete(timer) - onDone() - this.flushPendingOps() - }, time) - this.transitions.add(timer) + addTransition(time, onStart, onDone) { + onStart(); + const timer = setTimeout(() => { + 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 } - let 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 42c67abaa4..34b46b9bf6 100644 --- a/assets/js/phoenix_live_view/live_uploader.js +++ b/assets/js/phoenix_live_view/live_uploader.js @@ -1,151 +1,191 @@ 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){ - let 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){ - let 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++; + } + }); + return active > 0; } - static hasUploadsInProgress(formEl){ - let active = 0 - DOM.findUploadInputs(formEl).forEach(input => { - if(input.getAttribute(PHX_PREFLIGHTED_REFS) !== input.getAttribute(PHX_DONE_REFS)){ - active++ + 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(); } - }) - return active > 0 - } - - static serializeUploads(inputEl){ - let files = this.activeFiles(inputEl) - let fileData = {} - files.forEach(file => { - let entry = {path: inputEl.name} - let 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 untrackFile(inputEl, file){ - DOM.putPrivate(inputEl, "files", DOM.private(inputEl, "files").filter(f => !Object.is(f, file))) - } - - static trackFiles(inputEl, files, dataTransfer){ - if(inputEl.getAttribute("multiple") !== null){ - let newFiles = files.filter(file => !this.activeFiles(inputEl).find(f => Object.is(f, file))) - DOM.updatePrivate(inputEl, "files", [], (existing) => existing.concat(newFiles)) - inputEl.value = null + fileData[uploadRef].push(entry); + }); + return fileData; + } + + 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)), + ); + } + + /** + * @param {HTMLInputElement} inputEl + * @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; } 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){ - let 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){ - let 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 - }) - - let groupedEntries = this._entries.reduce((acc, entry) => { - if(!entry.meta){ return acc } - let {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] - callback(entries, onError, resp, liveSocket) + } 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); } } } diff --git a/assets/js/phoenix_live_view/rendered.js b/assets/js/phoenix_live_view/rendered.js index 3af461634f..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(["'", "\""]) - -export let modifyRoot = (html, attrs, clearInnerHTML) => { - let i = 0 - let insideComment = false - let beforeTag, afterTag, tag, tagNameEndsAt, id, newHTML - - let lookahead = html.match(/^(\s*(?:\s*)*)<([^\s\/>]+)/) - if(lookahead === null){ throw new Error(`malformed html ${html}`) } + "wbr", +]); +const quoteChars = new Set(["'", '"']); + +export const modifyRoot = (html, attrs, clearInnerHTML) => { + 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}`); + } - 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) === "="){ - let isId = html.slice(i - 3, i) === " id" - i++ - let char = html.charAt(i) - if(quoteChars.has(char)){ - let 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){ - let 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); - let 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 - let 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 { - let 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){ - let {[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){ - let [str, streams] = this.recursiveToString(this.rendered, this.rendered[COMPONENTS], onlyCids, true, {}) - return [str, 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 - let output = {buffer: "", components: components, onlyCids: onlyCids, streams: new Set()} - this.toOutputBuffer(rendered, null, output, changeTracking, rootAttrs) - return [output.buffer, 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){ - let newc = diff[COMPONENTS] - let 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){ - let oldc = this.rendered[COMPONENTS] + if (newc) { + const oldc = this.rendered[COMPONENTS]; - for(let 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(let 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(let key in source){ - let val = source[key] - let targetVal = target[key] - let 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){ - let merged = {...target, ...source} - for(let key in merged){ - let val = source[key] - let 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){ - let [str, streams] = this.recursiveCIDToString(this.rendered[COMPONENTS], cid, null) - let [strippedHTML, _before, _after] = modifyRoot(str, {}) - return [strippedHTML, 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) - let isRoot = rendered[ROOT] - let 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 } - let [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 - let [_ref, _inserts, deleteIds, reset] = stream || [null, {}, [], null] - statics = this.templateStatic(statics, templates) - let compTemplates = templates || rendered[TEMPLATES] - for(let d = 0; d < dynamics.length; d++){ - let 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. - let 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"){ - let [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){ - let component = components[cid] || logError(`no component for CID ${cid}`, components) - let attrs = {[PHX_COMPONENT]: cid} - let 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 - let changeTracking = !component.reset - let [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 [html, 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 0a6a96275b..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){ - let isNew = file._phxRef === undefined - let activeRefs = fileEl.getAttribute(PHX_ACTIVE_ENTRY_REFS).split(",") - let 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){ - let preflightedRefs = fileEl.getAttribute(PHX_PREFLIGHTED_REFS).split(",") - let 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(){ - let 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){ - let 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 221a6d8b01..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 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) - return type === "number" || (type === "string" && /^(0|[1-9]\d*)$/.test(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]") - 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 let debug = (view, kind, msg, obj) => { - if(view.liveSocket.isDebugEnabled()){ - console.log(`${view.id} ${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 - } 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 let isObject = (obj) => { - return obj !== null && typeof obj === "object" && !(obj instanceof Array) -} +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 } - return true -} +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){ - entries.forEach(entry => { - let 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 a6c247f9e9..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,135 +46,154 @@ import { logError, maybe, isCid, -} 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" - -export let prependFormDataKey = (key, prefix) => { - let isArray = key.endsWith("[]") +} 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"; + +export const prependFormDataKey = (key, prefix) => { + 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; +}; -let serializeForm = (form, opts, onlyNames = []) => { - const {submitter} = opts +const serializeForm = (form, opts, onlyNames = []) => { + 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(let [key, val] of formData.entries()){ - if(onlyNames.length === 0 || onlyNames.indexOf(key) >= 0){ - let isUnused = inputsUnused[key] - let 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); } - 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){ - let 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}`, () => { - 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, @@ -182,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){ - let params = this.liveSocket.params(this.el) - let 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(){ - let 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) - let onFinished = () => { - callback() - for(let id in this.viewHooks){ - this.destroyHook(this.viewHooks[id]) - } + 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]); + } + }; - 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(let 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(let 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 @@ -292,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)){ - let 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 { - let 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)]) - let {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){ - let {rendered, container, liveview_version} = resp - if(container){ - let [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) - let [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. - 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) - 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){ - let phxViewportTop = this.binding(PHX_VIEWPORT_TOP) - let 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() - let 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){ - let {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]) - let hook = this.getHook(fromEl) - let 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){ - let phxMounted = el.getAttribute(this.binding(PHX_MOUNTED)) - let 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){ - let newHook = this.addHook(el) - if(newHook){ newHook.__mounted() } + maybeAddNewHook(el) { + const newHook = this.addHook(el); + if (newHook) { + newHook.__mounted(); + } } - performPatch(patch, pruneCids, isJoinPatch = false){ - let removedEls = [] - let phxChildrenAdded = false - let 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]) - let phxViewportTop = this.binding(PHX_VIEWPORT_TOP) - let 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) => { - let 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){ - let destroyedCIDs = [] - elements.forEach(parent => { - let components = DOM.all(parent, `[${PHX_COMPONENT}]`) - let hooks = DOM.all(parent, `[${this.binding(PHX_HOOK)}], [data-phx-hook]`) - components.concat(parent).forEach(el => { - let cid = this.componentID(el) - if(isCid(cid) && destroyedCIDs.indexOf(cid) === -1){ destroyedCIDs.push(cid) } - }) - hooks.concat(parent).forEach(hookEl => { - let 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 @@ -560,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. - let 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; @@ -579,644 +677,912 @@ 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(let parentId in this.root.children){ - for(let 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){ - let child = this.getChildById(el.id) - if(!child){ - let 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", () => { - let 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", () => { - let [html, streams] = this.renderContainer(diff, "update") - let 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})`, () => { - 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) - 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 - 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) - 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){ - let 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) - let 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 - 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 - } else if(hookName !== null){ - logError(`unknown hook found for "${hookName}"`, el) + 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); } } } - 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){ - let children = this.root.children[this.id] || {} - for(let 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){ - let {to, kind, flash} = redir - let url = this.expandURL(to) - let 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){ - let {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; } - onRedirect({to, flash, reloadToken}){ this.liveSocket.redirect(to, flash, reloadToken) } + /** + * @param {{to: string, flash?: string, reloadToken?: string}} redirect + */ + 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(); } - let 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; } - this.destroyAllChildren() - this.liveSocket.dropActiveElement(this) - if(this.liveSocket.isUnloaded()){ - this.showLoader(BEFORE_UNLOAD_LOADER_TIMEOUT) + 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); } } - 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){ - let latency = this.liveSocket.getLatencySim() - let 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")); + } - let [ref, [el], opts] = refGenerator ? refGenerator() : [null, [], {}] - let 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 } - let 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 - let 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){ - let 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.) - let 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 = {}){ - let newRef = this.ref++ - let disableWith = this.binding(PHX_DISABLE_WITH) - if(opts.loading){ - let 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(let {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 } - - let 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); + } - let 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`) - let 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", ""); } - let detail = { + const detail = { event: phxEvent, eventType: eventType, 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){ - let 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; + } - let 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, onReply){ - if(!this.isConnected()){ - this.log("hook", () => ["unable to push hook event. LiveView not connected", event, payload]) - return false + 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") - this.pushWithReply(() => [ref, els, opts], "event", { + 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: hookReply}) => onReply(hookReply, ref)) - - return ref + cid: this.closestComponentID(targetCtx), + }).then(({ resp: _resp, reply }) => ({ reply, ref })); } - extractMeta(el, meta, value){ - let prefix = this.binding("value-") - for(let i = 0; i < el.attributes.length; i++){ - if(!meta){ meta = {} } - let 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(let 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) - }) - .then(({resp}) => onReply(resp)) - .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") - } - - let uploads - let cid = isCid(forceCid) ? forceCid : this.targetComponentID(inputEl.form, targetCtx, opts) - let 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 = {} - if(inputEl instanceof HTMLButtonElement){ serializeOpts.submitter = inputEl } - if(inputEl.getAttribute(this.binding("change"))){ - formData = serializeForm(inputEl.form, serializeOpts, [inputEl.name]) + 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)); + }); + } + + 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); + 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]); } 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); - let event = { + const event = { type: "form", event: phxEvent, value: formData, @@ -1226,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){ - let [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){ - let awaitingSubmit = this.getScheduledSubmit(formEl) - if(awaitingSubmit){ - let [_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]) + getScheduledSubmit(formEl) { + return this.formSubmits.find(([el, _ref, _opts, _callback]) => + el.isSameNode(formEl), + ); } - 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 = {}){ - let filterIgnored = el => { - let userIgnored = closestPhxBinding(el, `${this.binding(PHX_UPDATE)}=ignore`, el.form) - return !(userIgnored || closestPhxBinding(el, "data-phx-update=ignore", el.form)) - } - let filterDisables = el => { - return el.hasAttribute(this.binding(PHX_DISABLE_WITH)) + scheduleSubmit(formEl, ref, opts, callback) { + if (this.getScheduledSubmit(formEl)) { + return true; } - let filterButton = el => el.tagName == "BUTTON" - - let 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) + 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; } - }) - let 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 - let 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, { - ...opts, - form: formEl, - submitter: submitter - }) - let cid = this.targetComponentID(formEl, targetCtx) - if(LiveUploader.hasUploadsInProgress(formEl)){ - let [ref, _els] = refGenerator() - let 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 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); } - 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, 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"))){ - let meta = this.extractMeta(formEl, {}, opts.value) - let 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){ - let joinCountAtUpload = this.joinCount - let 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 => { - let uploader = new LiveUploader(inputEl, this, () => { - numFileInputsInProgress-- - if(numFileInputsInProgress === 0){ onComplete() } - }) + inputEls.forEach((inputEl) => { + 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 + if (entries.length === 0) { + numFileInputsInProgress--; + return; } - let payload = { + 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) - let errors = resp.error || [] - errors.map(([entry_ref, reason]) => { - this.handleFailedEntryPreflight(entry_ref, reason, uploader) - }) - } else { - let 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 - let 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){ - let targetElement = this.targetCtxElement(targetCtx) || this.el - let 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)){ - let [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 - 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 - let pending = 0 + let pending = 0; // withinTargets(phxTarget, callback, dom, viewEl) - this.withinTargets(phxTarget, (targetView, targetCtx) => { - const cid = this.targetComponentID(newForm, targetCtx) - pending++ - let 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){ - let 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 - 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 - - 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 {}; + } - let 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){ - let 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. - let 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){ - let 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.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..0738edd271 --- /dev/null +++ b/assets/js/phoenix_live_view/view_hook.ts @@ -0,0 +1,483 @@ +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; +} + +// based on https://github.com/DefinitelyTyped/DefinitelyTyped/blob/fac1aa75acdddbf4f1a95e98ee2297b54ce4b4c9/types/phoenix_live_view/hooks.d.ts#L26 +// licensed under MIT +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. + */ + 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?: Hook) { + 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 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, + // 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/browser_test.js b/assets/test/browser_test.js deleted file mode 100644 index 0b8485e3ba..0000000000 --- a/assets/test/browser_test.js +++ /dev/null @@ -1,67 +0,0 @@ -import Browser from "phoenix_live_view/browser" - -describe("Browser", () => { - beforeEach(() => { - 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") - }) - }) - - describe("getCookie", () => { - test("returns the value for a cookie", () => { - 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("") - }) - }) - - describe("redirect", () => { - const originalWindowLocation = global.window.location - - beforeEach(() => { - delete global.window.location - global.window.location = "https://example.com" - }) - - afterAll(() => { - global.window.location = originalWindowLocation - }) - - test("redirects to a new URL", () => { - Browser.redirect("https://phoenixframework.com") - expect(window.location).toEqual("https://phoenixframework.com") - }) - - test("sets a flash cookie before redirecting", () => { - Browser.redirect("https://phoenixframework.com", "mango") - expect(document.cookie).toContain("__phoenix_flash__") - expect(document.cookie).toContain("mango") - }) - }) -}) - -// Adapted from https://stackoverflow.com/questions/179355/clearing-all-cookies-with-javascript/179514#179514 -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" - } -} diff --git a/assets/test/browser_test.ts b/assets/test/browser_test.ts new file mode 100644 index 0000000000..d18a9790c7 --- /dev/null +++ b/assets/test/browser_test.ts @@ -0,0 +1,100 @@ +import Browser from "phoenix_live_view/browser"; + +describe("Browser", () => { + beforeEach(() => { + 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"); + }); + }); + + describe("getCookie", () => { + test("returns the value for a cookie", () => { + 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(""); + }); + }); + + describe("redirect", () => { + let originalLocation: Location; + let mockHrefSetter: jest.Mock; + let currentHref: string; + + beforeAll(() => { + originalLocation = window.location; + }); + + beforeEach(() => { + 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(() => { + // Restore the original window.location object + Object.defineProperty(window, "location", { + 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); + }); + + test("sets a flash cookie before redirecting", () => { + 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); + }); + }); +}); + +// Adapted from https://stackoverflow.com/questions/179355/clearing-all-cookies-with-javascript/179514#179514 +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"; + } +} diff --git a/assets/test/debounce_test.js b/assets/test/debounce_test.js deleted file mode 100644 index e1a5bd7071..0000000000 --- a/assets/test/debounce_test.js +++ /dev/null @@ -1,353 +0,0 @@ -import DOM from "phoenix_live_view/dom" - -let after = (time, func) => setTimeout(func, time) - -let simulateInput = (input, val) => { - input.value = val - DOM.dispatchEvent(input, "input") -} - -let simulateKeyDown = (input, val) => { - input.value = input.value + val - DOM.dispatchEvent(input, "input") -} - -let container = () => { - let div = document.createElement("div") - div.innerHTML = ` -
- - - - - -
-
- ` - return div -} - -describe("debounce", function (){ - test("triggers once on input blur", async () => { - let calls = 0 - let 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 - let el = 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 - let el = 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 - let el = 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") - after(75, () => { - expect(calls).toBe(0) - after(250, () => { - 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 - let el = 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") - after(1200, () => { - expect(calls).toBe(1) - expect(el.value).toBe("six") - done() - }) - }) - }) - - test("cancels trigger on submit", done => { - let calls = 0 - let parent = container() - let el = 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") - after(100, () => { - 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 - let el = 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") - after(250, () => { - expect(calls).toBe(2) - expect(el.innerText).toBe("now:2") - done() - }) - }) - }) - - test("uses default when value is blank", done => { - let calls = 0 - let el = 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") - after(250, () => { - expect(calls).toBe(1) - expect(el.innerText).toBe("now:1") - done() - }) - }) - }) - - test("cancels trigger on submit", done => { - let calls = 0 - let el = 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") - 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 - let el = 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 - let el = 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 - DOM.dispatchEvent(el, "input") - } - 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") - // simulate phx-blur - 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 => { - let keyPresses = {} - let el = 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 - }) - }) - - let 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 => { - let keyPresses = {} - let el = 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 - }) - }) - - let pressA = new KeyboardEvent("keydown", {key: "a"}) - let 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/debounce_test.ts b/assets/test/debounce_test.ts new file mode 100644 index 0000000000..c1265a269e --- /dev/null +++ b/assets/test/debounce_test.ts @@ -0,0 +1,489 @@ +import DOM from "phoenix_live_view/dom"; + +const after = (time, func) => setTimeout(func, time); + +const simulateInput = (input, val) => { + input.value = val; + DOM.dispatchEvent(input, "input"); +}; + +const simulateKeyDown = (input, val) => { + input.value = input.value + val; + DOM.dispatchEvent(input, "input"); +}; + +const container = () => { + const div = document.createElement("div"); + div.innerHTML = ` +
+ + + + + +
+
+ `; + return div; +}; + +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); + }); + + 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"); + }); + + 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"); + after(100, () => { + expect(calls).toBe(0); + simulateKeyDown(el, "4"); + after(75, () => { + expect(calls).toBe(0); + after(250, () => { + 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"); + after(100, () => { + 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++, + ); + }); + el.form.addEventListener("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"); + 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"); + after(250, () => { + 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"); + after(200, () => { + 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++, + ); + }); + 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"); + 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"); + 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"); + // these will be throttled + 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"); + // 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"); + // simulate phx-blur + 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); + 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(); + }); +}); diff --git a/assets/test/dom_test.js b/assets/test/dom_test.js deleted file mode 100644 index e9cc5a1ef4..0000000000 --- a/assets/test/dom_test.js +++ /dev/null @@ -1,263 +0,0 @@ -import DOM from "phoenix_live_view/dom" -import {appendTitle, tag} from "./test_helpers" - -let e = (href) => { - let event = {} - let anchor = document.createElement("a") - anchor.setAttribute("href", href) - event.target = anchor - event.defaultPrevented = false - return event -} - -describe("DOM", () => { - beforeEach(() => { - let curTitle = document.querySelector("title") - curTitle && curTitle.remove() - }) - - describe ("wantsNewTab", () => { - test("case insensitive target", () => { - let event = e("https://test.local") - expect(DOM.wantsNewTab(event)).toBe(false) - // lowercase - event.target.setAttribute("target", "_blank") - expect(DOM.wantsNewTab(event)).toBe(true) - // uppercase - 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) - // 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) - // 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) - }) - - 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) - // 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) - // 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) - }) - - test("empty hash href", () => { - let 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") - 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") - event.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) - }) - - test("ignores contenteditable", () => { - let currentLoc - currentLoc = new URL("https://test.local/foo") - let event = e("/bar") - event.target.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") - }) - - test("with prefix", () => { - 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") - }) - - test("with prefix and suffix", () => { - 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") - - DOM.putTitle(undefined) - expect(document.title).toBe("PRE DEFAULT POST") - - DOM.putTitle("") - expect(document.title).toBe("PRE DEFAULT POST") - }) - }) - - describe("findExistingParentCIDs", () => { - test("returns only parent cids", () => { - let view = tag("div", {}, ` -
-
- `) - 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, 3])).toEqual(new Set([1])) - }) - - test("ignores elements in child LiveViews #3626", () => { - let view = tag("div", {}, ` -
-
- `) - document.body.appendChild(view) - - view.appendChild(tag("div", {"data-phx-component": 1}, ` -
-
-
- `)) - expect(DOM.findExistingParentCIDs(view, [1])).toEqual(new Set([1])) - }) - }) - - 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"}, ` -
- `) - 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([]) - }) - }) - - 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) - - // not in the DOM -> 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", () => { - let content = ` -
1
-
no id
- - some test - `.trim() - - let div = tag("div", {}, content) - DOM.cleanChildNodes(div, "phx-update") - - expect(div.innerHTML).toBe(content) - }) - - test("silently removes empty text nodes", () => { - let content = ` -
1
- - -
2
- `.trim() - - let 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 = ` -
1
-
no id
- - some test - `.trim() - - let div = tag("div", {"phx-update": "append"}, content) - - let errorCount = 0 - jest.spyOn(console, "error").mockImplementation(() => errorCount += 1) - DOM.cleanChildNodes(div, "phx-update") - - expect(div.innerHTML).toBe("
1
") - expect(errorCount).toBe(2) - }) - }) -}) diff --git a/assets/test/dom_test.ts b/assets/test/dom_test.ts new file mode 100644 index 0000000000..6d40348bac --- /dev/null +++ b/assets/test/dom_test.ts @@ -0,0 +1,337 @@ +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 event = { + target: anchor, + defaultPrevented: false, + } as unknown as Event & { target: HTMLAnchorElement }; + return event; +}; + +describe("DOM", () => { + beforeEach(() => { + const curTitle = document.querySelector("title"); + curTitle && curTitle.remove(); + }); + + describe("wantsNewTab", () => { + test("case insensitive target", () => { + const event = e("https://test.local"); + expect(DOM.wantsNewTab(event)).toBe(false); + // lowercase + event.target.setAttribute("target", "_blank"); + expect(DOM.wantsNewTab(event)).toBe(true); + // uppercase + 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); + // 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, + ); + // 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, + ); + }); + + 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); + // 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); + // 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); + }); + + test("empty hash href", () => { + 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); + }); + + test("with defaultPrevented return sfalse", () => { + 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); + }); + + 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); + }); + + test("ignores contenteditable", () => { + 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); + }); + }); + + describe("putTitle", () => { + test("with no attributes", () => { + 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"); + }); + + test("with suffix", () => { + 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"); + }); + + test("with default", () => { + 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(""); + expect(document.title).toBe("PRE DEFAULT POST"); + }); + }); + + describe("findExistingParentCIDs", () => { + test("returns only parent cids", () => { + const view = tag( + "div", + {}, + ` +
+
+ `, + ); + 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, 3])).toEqual(new Set([1])); + }); + + test("ignores elements in child LiveViews #3626", () => { + const view = tag( + "div", + {}, + ` +
+
+ `, + ); + document.body.appendChild(view); + + view.appendChild( + tag( + "div", + { "data-phx-component": 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" }, + ` +
+ `, + ); + 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([]); + }); + }); + + 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, + ); + + // not in the DOM -> 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", () => { + const content = ` +
1
+
no id
+ + some test + `.trim(); + + const div = tag("div", {}, content); + DOM.cleanChildNodes(div, "phx-update"); + + expect(div.innerHTML).toBe(content); + }); + + test("silently removes empty text nodes", () => { + const content = ` +
1
+ + +
2
+ `.trim(); + + 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", () => { + const content = ` +
1
+
no id
+ + some test + `.trim(); + + const div = tag("div", { "phx-update": "append" }, content); + + let errorCount = 0; + jest.spyOn(console, "error").mockImplementation(() => (errorCount += 1)); + DOM.cleanChildNodes(div, "phx-update"); + + expect(div.innerHTML).toBe('
1
'); + expect(errorCount).toBe(2); + }); + }); +}); diff --git a/assets/test/event_test.js b/assets/test/event_test.js deleted file mode 100644 index 0f4c7fa3b1..0000000000 --- a/assets/test/event_test.js +++ /dev/null @@ -1,238 +0,0 @@ -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" - -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 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 - view.channel.push = () => { - return { - receives: [], - receive(kind, cb){ - if(kind === "ok"){ - cb({diff: {r: replyPayload}}) - view.channel.push = oldPush - } - return this - } - } - } -} - -describe("events", () => { - let processedEvents - beforeEach(() => { - 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]}]], ` -
-
- `) - - 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, [], ` -
-
- `) - - expect(processedEvents).toEqual([]) - - 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 liveSocket = new LiveSocket("/live", Socket, { - hooks: { - Handler: { - 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, [], ` -
-
- `) - - expect(processedEvents).toEqual([]) - - 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}} - ]) - - let newHTML = "
" - view.update({s: [newHTML]}, [["my-event", {val: 2}]]) - - 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}} - ]) - }) - - 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, [], ` -
- `) - - 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}}]) - }) -}) - -describe("pushEvent replies", () => { - let processedReplies - beforeEach(() => { - processedReplies = [] - }) - - 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) => { - processedReplies.push({resp, ref}) - view.el.dispatchEvent(new CustomEvent("replied", {detail: {resp, ref}})) - }) - } - } - } - }) - view = simulateView(liveSocket, [], "") - view.update({ - s: [` -
-
- `] - }, []) - - view.el.addEventListener("replied", () => { - expect(pushedRef).toEqual(0) - expect(processedReplies).toEqual([{resp: {transactionID: "1001"}, ref: 0}]) - done() - }) - }) - - test("promise", (done) => { - 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: [` -
-
- `] - }, []) - - view.el.addEventListener("replied", () => { - expect(processedReplies).toEqual([{transactionID: "1001"}]) - done() - }) - }) - - test("pushEvent without connection noops", () => { - let view - let pushedRef = "before" - let liveSocket = new LiveSocket("/live", Socket, { - hooks: { - Gateway: { - mounted(){ - stubNextChannelReply(view, {transactionID: "1001"}) - pushedRef = this.pushEvent("charge", {amount: 123}, () => {}) - } - } - } - }) - view = simulateView(liveSocket, [], "") - view.isConnected = () => false - view.update({ - s: [` -
-
- `] - }, []) - - expect(pushedRef).toEqual(false) - }) -}) diff --git a/assets/test/event_test.ts b/assets/test/event_test.ts new file mode 100644 index 0000000000..d6cb151921 --- /dev/null +++ b/assets/test/event_test.ts @@ -0,0 +1,384 @@ +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"; + +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 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; + view.channel.push = () => { + return { + receives: [], + receive(kind, cb) { + if (kind === "ok") { + cb({ diff: { r: replyPayload } }); + view.channel.push = oldPush; + } + return this; + }, + }; + }; +}; + +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(() => { + 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] }]], + ` +
+
+ `, + ); + + 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, + [], + ` +
+
+ `, + ); + + expect(processedEvents).toEqual([]); + + 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 liveSocket = new LiveSocket("/live", Socket, { + hooks: { + Handler: { + 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, + [], + ` +
+
+ `, + ); + + expect(processedEvents).toEqual([]); + + 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 } }, + ]); + + let newHTML = '
'; + view.update({ s: [newHTML] }, [["my-event", { val: 2 }]]); + + 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 } }, + ]); + }); + + 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, + [], + ` +
+ `, + ); + + 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 } }]); + }); +}); + +describe("pushEvent replies", () => { + let processedReplies; + beforeEach(() => { + processedReplies = []; + }); + + test("reply", (done) => { + 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: [ + ` +
+
+ `, + ], + }, + [], + ); + + view.el.addEventListener("replied", () => { + expect(processedReplies).toEqual([ + { resp: { transactionID: "1001" }, ref: 0 }, + ]); + done(); + }); + }); + + test("promise", (done) => { + 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: [ + ` +
+
+ `, + ], + }, + [], + ); + + view.el.addEventListener("replied", () => { + expect(processedReplies).toEqual([{ transactionID: "1001" }]); + done(); + }); + }); + + 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; + 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: [ + ` +
+
+ `, + ], + }, + [], + ); + + view.el.addEventListener("pushed", () => { + expect(spy).not.toHaveBeenCalled(); + done(); + }); + }); +}); diff --git a/assets/test/globals.d.ts b/assets/test/globals.d.ts new file mode 100644 index 0000000000..121ac29cfc --- /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..150b289322 --- /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.js deleted file mode 100644 index 981b69116b..0000000000 --- a/assets/test/integration/event_test.js +++ /dev/null @@ -1,62 +0,0 @@ -import {Socket} from "phoenix" -import LiveSocket from "phoenix_live_view/live_socket" - -let stubViewPushInput = (view, callback) => { - view.pushInput = (sourceEl, targetCtx, newCid, event, pushOpts, originalCallback) => { - return callback(sourceEl, targetCtx, newCid, event, pushOpts, originalCallback) - } -} - -let prepareLiveViewDOM = (document, rootId) => { - document.body.innerHTML = ` -
-
-
- - - - - -
-
-
- ` -} - -describe("events", () => { - beforeEach(() => { - prepareLiveViewDOM(global.document, "root") - }) - - test("send change event to correct target", () => { - let liveSocket = new LiveSocket("/live", Socket) - liveSocket.connect() - let view = liveSocket.getViewByEl(document.getElementById("root")) - view.isConnected = () => true - let 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"] - } - }) - - input.value = "John Doe" - input.dispatchEvent(new Event("change", {bubbles: true})) - - expect(meta).toEqual({ - event: "validate", - target: 2, - changed: "user[first_name]" - }) - }) -}) diff --git a/assets/test/integration/event_test.ts b/assets/test/integration/event_test.ts new file mode 100644 index 0000000000..e68f43244a --- /dev/null +++ b/assets/test/integration/event_test.ts @@ -0,0 +1,79 @@ +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, + ); + }; +}; + +const prepareLiveViewDOM = (document, rootId) => { + document.body.innerHTML = ` +
+
+
+ + + + + +
+
+
+ `; +}; + +describe("events", () => { + beforeEach(() => { + 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"); + let meta = { + event: null, + target: null, + changed: null, + }; + + 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 })); + + expect(meta).toEqual({ + event: "validate", + target: 2, + changed: "user[first_name]", + }); + }); +}); diff --git a/assets/test/integration/metadata_test.js b/assets/test/integration/metadata_test.js deleted file mode 100644 index 38b0be70d9..0000000000 --- a/assets/test/integration/metadata_test.js +++ /dev/null @@ -1,67 +0,0 @@ -import {Socket} from "phoenix" -import LiveSocket from "phoenix_live_view/live_socket" - -let stubViewPushEvent = (view, callback) => { - view.pushEvent = (type, el, targetCtx, phxEvent, meta, opts = {}) => { - return callback(type, el, targetCtx, phxEvent, meta, opts) - } -} - -let prepareLiveViewDOM = (document, rootId) => { - document.body.innerHTML = ` -
- - - -
- ` -} - -describe("metadata", () => { - beforeEach(() => { - prepareLiveViewDOM(global.document, "root") - }) - - test("is empty by default", () => { - let liveSocket = new LiveSocket("/live", Socket) - liveSocket.connect() - let view = liveSocket.getViewByEl(document.getElementById("root")) - let 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({}) - }) - - test("can be user defined", () => { - let liveSocket = new LiveSocket("/live", Socket, { - metadata: { - click: (e, el) => { - return { - id: el.id, - altKey: e.altKey, - } - } - } - }) - liveSocket.connect() - liveSocket.isConnected = () => true - let view = liveSocket.getViewByEl(document.getElementById("root")) - view.isConnected = () => true - let btn = view.el.querySelector("button") - let meta = {} - stubViewPushEvent(view, (type, el, target, phxEvent, metadata, _opts) => { - meta = metadata - }) - btn.dispatchEvent(new Event("click", {bubbles: true})) - - expect(meta).toEqual({ - id: "btn", altKey: undefined - }) - }) -}) diff --git a/assets/test/integration/metadata_test.ts b/assets/test/integration/metadata_test.ts new file mode 100644 index 0000000000..a9b3963d82 --- /dev/null +++ b/assets/test/integration/metadata_test.ts @@ -0,0 +1,71 @@ +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); + }; +}; + +const prepareLiveViewDOM = (document, rootId) => { + document.body.innerHTML = ` +
+ + + +
+ `; +}; + +describe("metadata", () => { + beforeEach(() => { + 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 })); + + expect(meta).toEqual({}); + }); + + test("can be user defined", () => { + const liveSocket = new LiveSocket("/live", Socket, { + metadata: { + click: (e, el) => { + 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 = {}; + stubViewPushEvent(view, (type, el, target, phxEvent, metadata, _opts) => { + meta = metadata; + }); + btn.dispatchEvent(new Event("click", { bubbles: true })); + + expect(meta).toEqual({ + id: "btn", + altKey: undefined, + }); + }); +}); diff --git a/assets/test/js_test.js b/assets/test/js_test.js deleted file mode 100644 index e3b494a594..0000000000 --- a/assets/test/js_test.js +++ /dev/null @@ -1,1216 +0,0 @@ -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" - -let setupView = (content) => { - let el = liveViewDOM(content) - global.document.body.appendChild(el) - let liveSocket = new LiveSocket("/live", Socket) - return simulateJoinedView(el, liveSocket) -} - -let event = new CustomEvent("phx:exec") - -describe("JS", () => { - beforeEach(() => { - global.document.body.innerHTML = "" - jest.useFakeTimers() - setStartSystemTime() - }) - - afterEach(() => { - jest.useRealTimers() - }) - - describe("hook.js()", () => { - let js, view, modal - beforeEach(() => { - view = setupView("
modal
") - modal = view.el.querySelector("#modal") - let 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") - expect(view.liveSocket.historyRedirect).toHaveBeenCalledWith( - expect.any(CustomEvent), - "/test-url", - "push", - null, - 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") - expect(view.liveSocket.pushHistoryPatch).toHaveBeenCalledWith( - expect.any(CustomEvent), - "/test-url", - "push", - null - ) - - js.patch("/test-url", {replace: true}) - expect(view.liveSocket.pushHistoryPatch).toHaveBeenCalledWith( - expect.any(CustomEvent), - "/test-url", - "replace", - null - ) - - view.liveSocket.pushHistoryPatch = originalPushHistoryPatch - done() - }) - }) - - describe("exec_toggle", () => { - test("with defaults", done => { - let view = setupView(` - -
- `) - let modal = simulateVisibility(document.querySelector("#modal")) - let 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 => { - let view = setupView(` - -
- `) - let modal = simulateVisibility(document.querySelector("#modal")) - let 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 () => { - let view = setupView(` - -
- `) - let modal = simulateVisibility(document.querySelector("#modal")) - let 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) - // 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) - // 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) - // 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) - // wait for onEnd - jest.runAllTimers() - advanceTimersToNextFrame() - // fifth tick: display: 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) - - // toggle in - 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") - // 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") - // 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) - // wait for onEnd - 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) - }) - }) - - describe("exec_transition", () => { - test("with defaults", done => { - let view = setupView(` - -
- `) - let modal = simulateVisibility(document.querySelector("#modal")) - let click = document.querySelector("#click") - - expect(Array.from(modal.classList)).toEqual(["modal"]) - - 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"]) - done() - }) - - test("with multiple selector", done => { - let view = setupView(` - - -
- `) - let modal1 = document.querySelector("#modal1") - let modal2 = document.querySelector("#modal2") - let click = document.querySelector("#click") - - 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) - - expect(Array.from(modal1.classList)).toEqual(["modal", "fade-out"]) - expect(Array.from(modal2.classList)).toEqual(["modal", "fade-out"]) - - jest.runAllTimers() - - expect(Array.from(modal1.classList)).toEqual(["modal"]) - expect(Array.from(modal2.classList)).toEqual(["modal"]) - - done() - }) - }) - - describe("exec_dispatch", () => { - test("with defaults", done => { - let view = setupView(` - -
- `) - let modal = simulateVisibility(document.querySelector("#modal")) - let click = document.querySelector("#click") - - modal.addEventListener("click", () => { - done() - }) - JS.exec(event, "click", click.getAttribute("phx-click"), view, click) - }) - - test("with to scope inner", done => { - let view = setupView(` -
- -
- `) - let modal = simulateVisibility(document.querySelector(".modal")) - let 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(` - - `) - let modal = simulateVisibility(document.querySelector(".modal")) - let 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(` - -
-
- `) - let modal = simulateVisibility(document.querySelector("#modal")) - let click = document.querySelector("#click") - let 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 => { - let view = setupView(` - - -
- `) - let modal1Clicked = false - let modal1 = document.querySelector("#modal1") - let modal2 = document.querySelector("#modal2") - let close = document.querySelector("#close") - - modal1.addEventListener("close", (e) => { - modal1Clicked = true - expect(e.detail).toEqual({id: 123, dispatcher: close}) - }) - - modal2.addEventListener("close", (e) => { - expect(modal1Clicked).toBe(true) - expect(e.detail).toEqual({id: 123, dispatcher: close}) - done() - }) - - JS.exec(event, "close", close.getAttribute("phx-click"), view, close) - }) - }) - - describe("exec_add_class and exec_remove_class", () => { - test("with defaults", done => { - let view = setupView(` - -
-
- `) - let modal = simulateVisibility(document.querySelector("#modal")) - let add = document.querySelector("#add") - let 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() - - expect(Array.from(modal.classList)).toEqual(["modal", "class1"]) - - JS.exec(event, "click", remove.getAttribute("phx-click"), view, remove) - jest.runAllTimers() - - expect(Array.from(modal.classList)).toEqual(["modal"]) - done() - }) - - test("with multiple selector", done => { - let view = setupView(` - - -
-
- `) - let modal1 = document.querySelector("#modal1") - let modal2 = document.querySelector("#modal2") - let add = document.querySelector("#add") - let remove = document.querySelector("#remove") - - 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"]) - - 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() - }) - }) - - describe("exec_toggle_class", () => { - test("with defaults", done => { - let view = setupView(` - -
- `) - let modal = simulateVisibility(document.querySelector("#modal")) - let toggle = document.querySelector("#toggle") - - JS.exec(event, "click", toggle.getAttribute("phx-click"), view, toggle) - jest.runAllTimers() - - expect(Array.from(modal.classList)).toEqual(["modal", "class1"]) - - JS.exec(event, "click", toggle.getAttribute("phx-click"), view, toggle) - jest.runAllTimers() - - expect(Array.from(modal.classList)).toEqual(["modal"]) - done() - }) - - test("with multiple selector", done => { - let view = setupView(` - - -
- `) - let modal1 = document.querySelector("#modal1") - let modal2 = document.querySelector("#modal2") - let toggle = document.querySelector("#toggle") - - 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() - }) - - test("with transition", done => { - let view = setupView(` - - `) - let button = document.querySelector("button") - - expect(Array.from(button.classList)).toEqual([]) - - JS.exec(event, "click", button.getAttribute("phx-click"), view, button) - - jest.advanceTimersByTime(100) - expect(Array.from(button.classList)).toEqual(["a", "c"]) - - jest.runAllTimers() - expect(Array.from(button.classList)).toEqual(["c", "t"]) - - done() - }) - }) - - describe("push", () => { - test("regular event", done => { - let view = setupView(` - -
- `) - let 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 => { - let view = setupView(` - -
- -
- `) - let form = document.querySelector("#my-form") - let 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}] - 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(` - -
- -
- `) - let form = document.querySelector("#my-form") - let input = 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] - }, - "attribute_value": "attribute" - }, - "uploads": {} - }) - return Promise.resolve({resp: done()}) - } - let 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(` -
- -
- `) - let form = document.querySelector("#my-form") - let input = 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" - }, - "uploads": {} - }) - return Promise.resolve({resp: done()}) - } - let 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(` -
- -
- `) - let form = document.querySelector("#my-form") - let input = 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" - }, - "uploads": {} - }) - return Promise.resolve({resp: done()}) - } - let 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(` - -
- - -
- `) - let form = document.querySelector("#my-form") - let input = document.querySelector("#username") - let oldPush = view.pushInput.bind(view) - view.pushInput = (sourceEl, targetCtx, newCid, phxEvent, opts, callback) => { - let {_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, - event: "validate", - type: "form", - uploads: {}, - value: "_unused_username=&username=&_unused_other=&other=", - meta: {"_target": "username"} - }) - return Promise.resolve({resp: done()}) - } - let 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(` - -
- - -
- `) - let input = document.querySelector("#username1") - let oldPush = view.pushInput.bind(view) - view.pushInput = (sourceEl, targetCtx, newCid, phxEvent, opts, callback) => { - let {_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, - event: "username_changed", - type: "form", - uploads: {}, - value: "_unused_username=&username=", - meta: {"_target": "username"} - }) - return Promise.resolve({resp: done()}) - } - let 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(` - -
- - -
- `) - let input = document.querySelector("#username") - let oldPush = view.pushInput.bind(view) - view.pushInput = (sourceEl, targetCtx, newCid, phxEvent, opts, callback) => { - let {_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, - event: "username_changed", - type: "form", - uploads: {}, - value: "_unused_username=&username=", - meta: {"_target": "username"} - }) - return Promise.resolve({resp: done()}) - } - let 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(` - -
- - -
- `) - let form = 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 => { - let view = setupView(` - -
- - -
- `) - let form = 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] - }, - "attribute_value": "attribute" - } - }) - return Promise.resolve({resp: done()}) - } - JS.exec(event, "submit", form.getAttribute("phx-submit"), view, form, ["push", {}]) - }) - - test("page_loading", done => { - let view = setupView(` - -
- `) - let 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) - }) - - test("loading", () => { - let view = setupView(` - -
- `) - let click = document.querySelector("#click") - let 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(` - -
- `) - let click = document.querySelector("#click") - - view.pushWithReply = (refGenerator, event, payload, _onReply) => { - 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 => { - let view = setupView(` - -
- `) - let modal = simulateVisibility(document.querySelector("#modal")) - let click = document.querySelector("#click") - - view.pushEvent = (eventType, sourceEl, targetCtx, event, _data) => { - 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("none") - }) - }) - - describe("exec_set_attr and exec_remove_attr", () => { - test("with defaults", () => { - let view = setupView(` - -
-
- `) - let modal = document.querySelector("#modal") - let set = document.querySelector("#set") - let 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") - - JS.exec(event, "click", remove.getAttribute("phx-click"), view, remove) - expect(modal.getAttribute("aria-expanded")).toEqual(null) - }) - - test("with no selector", () => { - let view = setupView(` -
-
- `) - let set = document.querySelector("#set") - let 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(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", () => { - let view = setupView(` - -
- `) - let set = document.querySelector("#set") - let 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") - }) - - test("setting a dynamically added attribute updates its value", () => { - let view = setupView(` - -
-
- `) - let setFalse = document.querySelector("#set-false") - let setTrue = document.querySelector("#set-true") - let 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 => { - let view = setupView(` - -
- `) - let 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) - }) - - test("with no selector", () => { - let view = setupView(` -
- `) - let 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") - }) - - test("with to scope inner", () => { - let view = setupView(` -
- -
- `) - let modal = document.querySelector("#modal") - let 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") - }) - - test("with to scope closest", () => { - let view = setupView(` - - `) - let modal = document.querySelector("#modal") - let 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") - }) - - test("with multiple selector", () => { - let view = setupView(` -
modal
-
modal
-
- `) - let modal1 = document.querySelector("#modal1") - let modal2 = document.querySelector("#modal2") - let 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", () => { - let view = setupView(` - -
- `) - let modal = document.querySelector("#modal") - let 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") - - JS.exec(event, "click", toggle.getAttribute("phx-click"), view, toggle) - expect(modal.getAttribute("open")).toEqual(null) - }) - - test("with no selector", () => { - let view = setupView(` -
- `) - let 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") - }) - - test("with multiple selector", () => { - let view = setupView(` -
modal
-
modal
-
- `) - let modal1 = document.querySelector("#modal1") - let modal2 = document.querySelector("#modal2") - let 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", () => { - let view = setupView(` - -
- `) - let toggle = document.querySelector("#toggle") - let 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) - }) - - test("toggling a dynamically added attribute updates its value", () => { - let view = setupView(` - -
-
- `) - let toggle1 = document.querySelector("#toggle1") - let toggle2 = document.querySelector("#toggle2") - let 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", () => { - let view = setupView(` -
- `) - let 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") - }) - }) - - describe("focus", () => { - test("works like a stack", () => { - let 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") - - 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", () => { - let view = setupView(` -
- - - -
-
- `) - let modal2 = document.querySelector("#modal2") - let push = document.querySelector("#push") - - JS.exec(event, "click", push.getAttribute("phx-click"), view, push) - - jest.runAllTimers() - expect(document.activeElement).toBe(modal2) - }) - }) -}) diff --git a/assets/test/js_test.ts b/assets/test/js_test.ts new file mode 100644 index 0000000000..62455cc1ba --- /dev/null +++ b/assets/test/js_test.ts @@ -0,0 +1,1320 @@ +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 event = new CustomEvent("phx:exec"); + +describe("JS", () => { + beforeEach(() => { + global.document.body.innerHTML = ""; + jest.useFakeTimers(); + setStartSystemTime(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe("hook.js()", () => { + let js: HookJSCommands; + let view, modal; + beforeEach(() => { + 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"); + expect(view.liveSocket.historyRedirect).toHaveBeenCalledWith( + expect.any(CustomEvent), + "/test-url", + "push", + null, + 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"); + expect(view.liveSocket.pushHistoryPatch).toHaveBeenCalledWith( + expect.any(CustomEvent), + "/test-url", + "push", + null, + ); + + js.patch("/test-url", { replace: true }); + expect(view.liveSocket.pushHistoryPatch).toHaveBeenCalledWith( + expect.any(CustomEvent), + "/test-url", + "replace", + null, + ); + + view.liveSocket.pushHistoryPatch = originalPushHistoryPatch; + done(); + }); + }); + + describe("exec_toggle", () => { + 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 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(); + }); + + 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); + + // toggle out + 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); + // 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); + // 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); + // wait for onEnd + jest.runAllTimers(); + advanceTimersToNextFrame(); + // fifth tick: display: 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); + + // toggle in + 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"); + // 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"); + // 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); + // wait for onEnd + 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); + }); + }); + + describe("exec_transition", () => { + test("with defaults", (done) => { + const view = setupView(` + +
+ `); + const modal = simulateVisibility(document.querySelector("#modal")); + const click = document.querySelector("#click")!; + + expect(Array.from(modal.classList)).toEqual(["modal"]); + + 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"]); + done(); + }); + + test("with multiple selector", (done) => { + const view = setupView(` + + +
+ `); + 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"]); + + 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"]); + + jest.runAllTimers(); + + expect(Array.from(modal1.classList)).toEqual(["modal"]); + expect(Array.from(modal2.classList)).toEqual(["modal"]); + + done(); + }); + }); + + describe("exec_dispatch", () => { + test("with defaults", (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 to scope inner", (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 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 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 view = setupView(` + + +
+ `); + 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 }); + }); + + modal2.addEventListener("close", (e: CustomEventInit) => { + expect(modal1Clicked).toBe(true); + expect(e.detail).toEqual({ id: 123, dispatcher: close }); + done(); + }); + + JS.exec(event, "close", close.getAttribute("phx-click"), view, close); + }); + }); + + describe("exec_add_class and exec_remove_class", () => { + test("with defaults", (done) => { + const view = setupView(` + +
+
+ `); + 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(); + + expect(Array.from(modal.classList)).toEqual(["modal", "class1"]); + + JS.exec(event, "click", remove.getAttribute("phx-click"), view, remove); + jest.runAllTimers(); + + expect(Array.from(modal.classList)).toEqual(["modal"]); + 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")!; + + 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"]); + + 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(); + }); + }); + + describe("exec_toggle_class", () => { + test("with defaults", (done) => { + const view = setupView(` + +
+ `); + const modal = simulateVisibility(document.querySelector("#modal")); + const toggle = document.querySelector("#toggle")!; + + JS.exec(event, "click", toggle.getAttribute("phx-click"), view, toggle); + jest.runAllTimers(); + + expect(Array.from(modal.classList)).toEqual(["modal", "class1"]); + + JS.exec(event, "click", toggle.getAttribute("phx-click"), view, toggle); + jest.runAllTimers(); + + expect(Array.from(modal.classList)).toEqual(["modal"]); + done(); + }); + + test("with multiple selector", (done) => { + const view = setupView(` + + +
+ `); + 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(); + + 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(); + }); + + test("with transition", (done) => { + const view = setupView(` + + `); + const button = document.querySelector("button")!; + + expect(Array.from(button.classList)).toEqual([]); + + JS.exec(event, "click", button.getAttribute("phx-click"), view, button); + + jest.advanceTimersByTime(100); + expect(Array.from(button.classList)).toEqual(["a", "c"]); + + jest.runAllTimers(); + expect(Array.from(button.classList)).toEqual(["c", "t"]); + + done(); + }); + }); + + describe("push", () => { + test("regular event", (done) => { + const view = setupView(` + +
+ `); + 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) => { + 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 view = setupView(` + +
+ +
+ `); + 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], + }, + 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, + ); + }); + + 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")!; + 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", + }, + 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")!; + 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", + }, + 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); + }; + view.pushWithReply = (_refGen, _event, payload) => { + expect(payload).toEqual({ + cid: null, + event: "validate", + 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) => { + 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); + }; + view.pushWithReply = (_refGen, _event, payload) => { + expect(payload).toEqual({ + cid: null, + event: "username_changed", + 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) => { + 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); + }; + view.pushWithReply = (refGen, event, payload) => { + expect(payload).toEqual({ + cid: null, + event: "username_changed", + 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) => { + const view = setupView(` + +
+ + +
+ `); + 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) => { + const view = setupView(` + +
+ + +
+ `); + 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], + }, + 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")!; + 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); + }); + + 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 view = setupView(` + +
+ `); + 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); + }); + }); + + describe("multiple instructions", () => { + test("push and toggle", (done) => { + const view = setupView(` + +
+ `); + const modal = simulateVisibility(document.querySelector("#modal")); + const click = document.querySelector("#click")!; + + view.pushEvent = (_eventType, _sourceEl, _targetCtx, event, _data) => { + 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("none"); + }); + }); + + describe("exec_set_attr and exec_remove_attr", () => { + test("with defaults", () => { + const view = setupView(` + +
+
+ `); + 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"); + + 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")!; + + 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); + }); + + test("setting a pre-existing attribute updates its value", () => { + const view = setupView(` + +
+ `); + 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"); + }); + + 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"); + }); + }); + + describe("exec", () => { + test("executes command", (done) => { + const view = setupView(` + +
+ `); + 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); + }); + + test("with no selector", () => { + const view = setupView(` +
+ `); + 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"); + }); + + test("with to scope inner", () => { + const view = setupView(` +
+ +
+ `); + 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"); + }); + + test("with to scope closest", () => { + const view = setupView(` + + `); + 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"); + }); + + 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); + }); + }); + + describe("exec_toggle_attr", () => { + test("with defaults", () => { + const view = setupView(` + +
+ `); + 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"); + + 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")!; + + 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); + }); + + test("toggling a pre-existing attribute updates its value", () => { + const view = setupView(` + +
+ `); + 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); + }); + + 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); + }); + + test("toggling between two values", () => { + const view = setupView(` +
+ `); + 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"); + }); + }); + + describe("focus", () => { + test("works like a stack", () => { + const view = setupView(` + + +
+
+
+ `); + 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", () => { + const view = setupView(` +
+ + + +
+
+ `); + const modal2 = document.querySelector("#modal2")!; + const push = document.querySelector("#push")!; + + JS.exec(event, "click", push.getAttribute("phx-click"), view, push); + + jest.runAllTimers(); + expect(document.activeElement).toBe(modal2); + }); + }); +}); diff --git a/assets/test/live_socket_test.js b/assets/test/live_socket_test.js deleted file mode 100644 index e4dce3ec51..0000000000 --- a/assets/test/live_socket_test.js +++ /dev/null @@ -1,438 +0,0 @@ -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" - -let container = (num) => global.document.getElementById(`container${num}`) - -let prepareLiveViewDOM = (document) => { - 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") - button.addEventListener("click", () => { - setTimeout(() => { - input.value += 1 - }, 200) - }) - document.body.appendChild(div) -} - -describe("LiveSocket", () => { - beforeEach(() => { - prepareLiveViewDOM(global.document) - }) - - afterAll(() => { - global.document.body.innerHTML = "" - }) - - test("sets defaults", async () => { - let 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 () => { - let 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 () => { - let 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}) - expect(liveSocket.viewLogger).toBe(viewLogger) - liveSocket.connect() - let view = liveSocket.getViewByEl(container(1)) - liveSocket.log(view, "updated", () => ["", JSON.stringify("
")]) - }) - - test("connect", async () => { - let liveSocket = new LiveSocket("/live", Socket) - let _socket = liveSocket.connect() - expect(liveSocket.getViewByEl(container(1))).toBeDefined() - }) - - test("disconnect", async () => { - let liveSocket = new LiveSocket("/live", Socket) - - liveSocket.connect() - liveSocket.disconnect() - - expect(liveSocket.getViewByEl(container(1)).destroy).toBeDefined() - }) - - test("channel", async () => { - let liveSocket = new LiveSocket("/live", Socket) - - liveSocket.connect() - let channel = liveSocket.channel("lv:def456", () => { - return {session: this.getSession()} - }) - - expect(channel).toBeDefined() - }) - - test("getViewByEl", async () => { - let liveSocket = new LiveSocket("/live", Socket) - - liveSocket.connect() - - 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") - secondLiveView.innerHTML = ` - - - - ` - document.body.appendChild(secondLiveView) - - let liveSocket = new LiveSocket("/live", Socket) - liveSocket.connect() - - let el = container(1) - expect(liveSocket.getViewByEl(el)).toBeDefined() - - 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({}) - }) - - test("binding", async () => { - let liveSocket = new LiveSocket("/live", Socket) - - expect(liveSocket.binding("value")).toBe("phx-value") - }) - - test("getBindingPrefix", async () => { - let liveSocket = new LiveSocket("/live", Socket) - - expect(liveSocket.getBindingPrefix()).toEqual("phx-") - }) - - test("getBindingPrefix custom", async () => { - let liveSocket = new LiveSocket("/live", Socket, {bindingPrefix: "company-"}) - - expect(liveSocket.getBindingPrefix()).toEqual("company-") - }) - - test("owner", async () => { - let liveSocket = new LiveSocket("/live", Socket) - liveSocket.connect() - - let _view = liveSocket.getViewByEl(container(1)) - let btn = document.querySelector("button") - let _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) - - let input = document.querySelector("input") - input.focus() - - expect(liveSocket.getActiveElement()).toEqual(input) - }) - - test("blurActiveElement", async () => { - let liveSocket = new LiveSocket("/live", Socket) - - let input = document.querySelector("input") - input.focus() - - expect(liveSocket.prevActive).toBeNull() - - liveSocket.blurActiveElement() - // sets prevActive - expect(liveSocket.prevActive).toEqual(input) - expect(liveSocket.getActiveElement()).not.toEqual(input) - }) - - test("restorePreviouslyActiveFocus", async () => { - let liveSocket = new LiveSocket("/live", Socket) - - let input = document.querySelector("input") - input.focus() - - 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) - }) - - test("dropActiveElement unsets prevActive", async () => { - let liveSocket = new LiveSocket("/live", Socket) - - liveSocket.connect() - - let input = document.querySelector("input") - input.focus() - liveSocket.blurActiveElement() - expect(liveSocket.prevActive).toEqual(input) - - let 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 override = { - getItem: function (_keyName){ getItemCalls = getItemCalls + 1 } - } - - let liveSocket = new LiveSocket("/live", Socket, {sessionStorage: override}) - liveSocket.getLatencySim() - - // liveSocket constructor reads nav history position from sessionStorage - expect(getItemCalls).toEqual(2) - }) -}) - -describe("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() - }) - - afterEach(() => { - jest.useRealTimers() - }) - - afterAll(() => { - 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() - }) - - 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") - }) - - test("push", () => { - 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 - }) - - test("navigate", () => { - 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}) - expect(liveSocket.historyRedirect).toHaveBeenCalledWith( - expect.any(CustomEvent), - "/test-url", - "replace", - null, - null - ) - - liveSocket.historyRedirect = originalHistoryRedirect - }) - - test("patch", () => { - 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}) - expect(liveSocket.pushHistoryPatch).toHaveBeenCalledWith( - expect.any(CustomEvent), - "/test-url", - "replace", - null - ) - - liveSocket.pushHistoryPatch = originalPushHistoryPatch - }) -}) diff --git a/assets/test/live_socket_test.ts b/assets/test/live_socket_test.ts new file mode 100644 index 0000000000..a8e68048c1 --- /dev/null +++ b/assets/test/live_socket_test.ts @@ -0,0 +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"; + +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"); + div.innerHTML = ` + + + + `; + const button = div.querySelector("button"); + const input = div.querySelector("input"); + button.addEventListener("click", () => { + setTimeout(() => { + input.value += 1; + }, 200); + }); + document.body.appendChild(div); +}; + +describe("LiveSocket", () => { + beforeEach(() => { + prepareLiveViewDOM(global.document); + }); + + afterAll(() => { + 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); + }); + + 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); + }); + + 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("
")]); + }); + + test("connect", async () => { + 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); + + liveSocket.connect(); + liveSocket.disconnect(); + + expect(liveSocket.getViewByEl(container(1)).destroy).toBeDefined(); + }); + + test("channel", async () => { + const liveSocket = new LiveSocket("/live", Socket); + + liveSocket.connect(); + const channel = liveSocket.channel("lv:def456", function () { + return { session: this.getSession() }; + }); + + expect(channel).toBeDefined(); + }); + + test("getViewByEl", async () => { + const liveSocket = new LiveSocket("/live", Socket); + + liveSocket.connect(); + + 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"); + secondLiveView.innerHTML = ` + + + + `; + document.body.appendChild(secondLiveView); + + const liveSocket = new LiveSocket("/live", Socket); + liveSocket.connect(); + + const el = container(1); + expect(liveSocket.getViewByEl(el)).toBeDefined(); + + 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({}); + }); + + test("binding", async () => { + const liveSocket = new LiveSocket("/live", Socket); + + expect(liveSocket.binding("value")).toBe("phx-value"); + }); + + test("getBindingPrefix", async () => { + const liveSocket = new LiveSocket("/live", Socket); + + expect(liveSocket.getBindingPrefix()).toEqual("phx-"); + }); + + test("getBindingPrefix custom", async () => { + const liveSocket = new LiveSocket("/live", Socket, { + bindingPrefix: "company-", + }); + + expect(liveSocket.getBindingPrefix()).toEqual("company-"); + }); + + test("owner", async () => { + const liveSocket = new LiveSocket("/live", Socket); + liveSocket.connect(); + + 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 () => { + const liveSocket = new LiveSocket("/live", Socket); + + const input = document.querySelector("input"); + input.focus(); + + expect(liveSocket.getActiveElement()).toEqual(input); + }); + + test("blurActiveElement", async () => { + const liveSocket = new LiveSocket("/live", Socket); + + const input = document.querySelector("input"); + input.focus(); + + expect(liveSocket.prevActive).toBeNull(); + + liveSocket.blurActiveElement(); + // sets prevActive + expect(liveSocket.prevActive).toEqual(input); + expect(liveSocket.getActiveElement()).not.toEqual(input); + }); + + test("restorePreviouslyActiveFocus", async () => { + const liveSocket = new LiveSocket("/live", Socket); + + const input = document.querySelector("input"); + input.focus(); + + 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); + }); + + test("dropActiveElement unsets prevActive", async () => { + const liveSocket = new LiveSocket("/live", Socket); + + liveSocket.connect(); + + 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(); + // this fails. Is this correct? + // expect(liveSocket.getActiveElement()).not.toEqual(input) + }); + + test("storage can be overridden", async () => { + let getItemCalls = 0; + const override = { + getItem: function (_keyName) { + getItemCalls = getItemCalls + 1; + }, + }; + + const liveSocket = new LiveSocket("/live", Socket, { + sessionStorage: override, + }); + liveSocket.getLatencySim(); + + // liveSocket constructor reads nav history position from sessionStorage + expect(getItemCalls).toEqual(2); + }); +}); + +describe("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(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + afterAll(() => { + 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(); + }); + + 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"); + }); + + test("push", () => { + 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; + }); + + test("navigate", () => { + 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 }); + expect(liveSocket.historyRedirect).toHaveBeenCalledWith( + expect.any(CustomEvent), + "/test-url", + "replace", + null, + null, + ); + + liveSocket.historyRedirect = originalHistoryRedirect; + }); + + test("patch", () => { + 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 }); + expect(liveSocket.pushHistoryPatch).toHaveBeenCalledWith( + expect.any(CustomEvent), + "/test-url", + "replace", + null, + ); + + liveSocket.pushHistoryPatch = originalPushHistoryPatch; + }); +}); diff --git a/assets/test/modify_root_test.js b/assets/test/modify_root_test.js deleted file mode 100644 index 390fc96299..0000000000 --- a/assets/test/modify_root_test.js +++ /dev/null @@ -1,121 +0,0 @@ -import {modifyRoot} from "phoenix_live_view/rendered" - -describe("modifyRoot stripping comments", () => { - test("starting comments", () => { - // starting comments - let html = ` - - -
- ` - let [strippedHTML, commentBefore, commentAfter] = modifyRoot(html, {}) - expect(strippedHTML).toEqual("
MENU
") - expect(commentBefore).toEqual(` - - - `) - expect(commentAfter).toEqual(` - `) - }) - - test("ending comments", () => { - let html = ` -
- - ` - let [strippedHTML, commentBefore, commentAfter] = modifyRoot(html, {}) - expect(strippedHTML).toEqual("
MENU
") - expect(commentBefore).toEqual(` - `) - expect(commentAfter).toEqual(` - - `) - }) - - test("starting and ending comments", () => { - let html = ` - -
- - ` - let [strippedHTML, commentBefore, commentAfter] = modifyRoot(html, {}) - expect(strippedHTML).toEqual("
MENU
") - expect(commentBefore).toEqual(` - - `) - expect(commentAfter).toEqual(` - - `) - }) - - test("merges new attrs", () => { - let html = ` -
- ` - expect(modifyRoot(html, {id: 123})[0]).toEqual("
MENU
") - expect(modifyRoot(html, {id: 123, another: ""})[0]).toEqual("
MENU
") - // clearing innerHTML - expect(modifyRoot(html, {id: 123, another: ""}, true)[0]).toEqual("
") - // self closing - let selfClose = ` - - ` - expect(modifyRoot(selfClose, {id: 123, another: ""})[0]).toEqual("") - }) - - test("mixed whitespace", () => { - let html = ` -
- ` - 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("
") - }) - - test("self closed", () => { - 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 = "" - 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 = "" - 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 attrs = { - "data-phx-id": "c3-phx-F6AZf4FwSR4R50pB", - "data-phx-component": 3, - "data-phx-skip": true - } - - let [strippedHTML, _commentBefore, _commentAfter] = modifyRoot(html, attrs, true) - - expect(strippedHTML).toEqual("
") - }) -}) diff --git a/assets/test/modify_root_test.ts b/assets/test/modify_root_test.ts new file mode 100644 index 0000000000..379ce1d8ec --- /dev/null +++ b/assets/test/modify_root_test.ts @@ -0,0 +1,152 @@ +import { modifyRoot } from "phoenix_live_view/rendered"; + +describe("modifyRoot stripping comments", () => { + test("starting comments", () => { + // starting comments + const html = ` + + +
+ `; + 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( + '
', + ); + expect(commentBefore).toEqual(` + `); + expect(commentAfter).toEqual(` + + `); + }); + + test("starting and ending comments", () => { + const html = ` + +
+ + `; + 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( + '
', + ); + expect(modifyRoot(html, { id: 123, another: "" })[0]).toEqual( + '
', + ); + // clearing innerHTML + expect(modifyRoot(html, { id: 123, another: "" }, true)[0]).toEqual( + '
', + ); + // self closing + const selfClose = ` + + `; + 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(`
`); + // clearing innerHTML + expect(modifyRoot(html, { id: 123, another: "" }, true)[0]).toEqual( + '
', + ); + }); + + test("self closed", () => { + 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 = ''; + 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 = + ''; + 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 attrs = { + "data-phx-id": "c3-phx-F6AZf4FwSR4R50pB", + "data-phx-component": 3, + "data-phx-skip": 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 deleted file mode 100644 index 59d64ad688..0000000000 --- a/assets/test/rendered_test.js +++ /dev/null @@ -1,381 +0,0 @@ -import Rendered from "phoenix_live_view/rendered" - -const STATIC = "s" -const DYNAMICS = "d" -const COMPONENTS = "c" -const TEMPLATES = "p" - -describe("Rendered", () => { - describe("mergeDiff", () => { - test("recursively merges two diffs", () => { - let simple = new Rendered("123", simpleDiff1) - simple.mergeDiff(simpleDiff2) - expect(simple.get()).toEqual({...simpleDiffResult, [COMPONENTS]: {}, newRender: true}) - - let 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"]} - let 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"]}} - let 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}}} - let 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}}} - let rendered = new Rendered("123", diff1) - rendered.mergeDiff(diff2) - expect(rendered.get()).toEqual({ - [COMPONENTS]: { - 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 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} - } - } - - let 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"]}, - } - }) - - 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} - } - } - - let 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"]}, - } - }) - }) - - 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) - 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]: ""}} - let rendered = new Rendered("123", diff1) - rendered.mergeDiff(diff2) - expect(rendered.get()).toEqual({...diff2, [COMPONENTS]: {}}) - }) - - test("expands shared static from cids", () => { - const mountDiff = { - "0": "", - "1": "", - "2": { - "0": "new post", - "1": "", - "2": { - "d": [[1], [2]], - "s": ["", ""] - }, - "s": ["h1", "h2", "h3", "h4"] - }, - "c": { - "1": { - "0": "1008", - "1": "chris_mccord", - "2": "My post", - "3": "1", - "4": "0", - "5": "1", - "6": "0", - "7": "edit", - "8": "delete", - "s": ["s0", "s1", "s2", "s3", "s4", "s5", "s6", "s7", "s8", "s9"] - }, - "2": { - "0": "1007", - "1": "chris_mccord", - "2": "My post", - "3": "2", - "4": "0", - "5": "2", - "6": "0", - "7": "edit", - "8": "delete", - "s": 1 - } - }, - "s": ["f1", "f2", "f3", "f4"], - "title": "Listing Posts" - } - - const updateDiff = { - "2": { - "2": { - "d": [[3]] - } - }, - "c": { - "3": { - "0": "1009", - "1": "chris_mccord", - "2": "newnewnewnewnewnewnewnew", - "3": "3", - "4": "0", - "5": "3", - "6": "0", - "7": "edit", - "8": "delete", - "s": -2 - } - } - } - - let 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] - - 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]: ["

"]} - let 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", {}) - expect(rendered.isNewFingerprint(diff)).toEqual(false) - }) - - test("returns false if `diff` is undefined", () => { - let 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() - expect(str.trim()).toEqual( - `
-
- cooling - 07:15:04 PM -
-
`.trim()) - }) - - test("reuses static in components and comprehensions", () => { - let rendered = new Rendered("123", staticReuseDiff) - let [str, _streams] = rendered.toString() - expect(str.trim()).toEqual( - `
-

- foo - 0: FROM index_1 world1: FROM index_2 world -

- -

- bar - 0: FROM index_1 world1: FROM index_2 world -

-
`.trim()) - }) - }) -}) - -const simpleDiff1 = { - "0": "cooling", - "1": "cooling", - "2": "07:15:03 PM", - [STATIC]: [ - "
\n
\n ", - "\n ", - "\n
\n
\n", - ], - "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", - ], - "r": 1 -} - -const deepDiff1 = { - "0": { - "0": { - [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 - }, - "1": { - [DYNAMICS]: [ - [ - "asdf_asdf", - "asdf@asdf.com", - "123-456-7890", - "Show", - "Edit", - "Delete", - ], - ], - [STATIC]: [ - " \n ", - "\n ", - "\n ", - "\n\n \n", - " ", - "\n", - " \n \n", - ], - "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 \n \n \n \n \n", - " \n
Username
\n", - ], - newRender: true, - "r": 1, - }, - "1": { - [DYNAMICS]: [ - [ - "asdf_asdf", - "asdf@asdf.com", - "123-456-7890", - "Show", - "Edit", - "Delete", - ], - ], - [STATIC]: [ - " \n ", - "\n ", - "\n ", - "\n\n \n", - " ", - "\n", - " \n \n", - ], - "r": 1 - } -} - -const staticReuseDiff = { - "0": { - [DYNAMICS]: [ - ["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": ["", ": ", ""]} - }, - [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} - }, - [STATIC]: ["
", "
"], - "r": 1 -} diff --git a/assets/test/rendered_test.ts b/assets/test/rendered_test.ts new file mode 100644 index 0000000000..d86724f728 --- /dev/null +++ b/assets/test/rendered_test.ts @@ -0,0 +1,439 @@ +import Rendered from "phoenix_live_view/rendered"; + +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 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]: {} }); + }); + + 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]: {} }); + }); + + 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"] } }, + }); + }); + + 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); + expect(rendered.get()).toEqual({ + [COMPONENTS]: { + 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 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 }, + }, + }; + + 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"] }, + }, + }); + + 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 }, + }, + }; + + 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"] }, + }, + }); + }); + + 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]: {} }); + }); + + 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]: {} }); + }); + + test("expands shared static from cids", () => { + const mountDiff = { + "0": "", + "1": "", + "2": { + "0": "new post", + "1": "", + "2": { + d: [[1], [2]], + s: ["", ""], + }, + s: ["h1", "h2", "h3", "h4"], + }, + c: { + "1": { + "0": "1008", + "1": "chris_mccord", + "2": "My post", + "3": "1", + "4": "0", + "5": "1", + "6": "0", + "7": "edit", + "8": "delete", + s: ["s0", "s1", "s2", "s3", "s4", "s5", "s6", "s7", "s8", "s9"], + }, + "2": { + "0": "1007", + "1": "chris_mccord", + "2": "My post", + "3": "2", + "4": "0", + "5": "2", + "6": "0", + "7": "edit", + "8": "delete", + s: 1, + }, + }, + s: ["f1", "f2", "f3", "f4"], + title: "Listing Posts", + }; + + const updateDiff = { + "2": { + "2": { + d: [[3]], + }, + }, + c: { + "3": { + "0": "1009", + "1": "chris_mccord", + "2": "newnewnewnewnewnewnewnew", + "3": "3", + "4": "0", + "5": "3", + "6": "0", + "7": "edit", + "8": "delete", + 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]; + + 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); + }); + + test("returns false if `diff.static` is falsy", () => { + 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); + }); + }); + + describe("toString", () => { + test("stringifies a diff", () => { + const rendered = new Rendered("123", simpleDiffResult); + const { buffer: str } = rendered.toString(); + expect(str.trim()).toEqual( + `
+
+ cooling + 07:15:04 PM +
+
`.trim(), + ); + }); + + test("reuses static in components and comprehensions", () => { + const rendered = new Rendered("123", staticReuseDiff); + const { buffer: str } = rendered.toString(); + expect(str.trim()).toEqual( + `
+

+ foo + 0: FROM index_1 world1: FROM index_2 world +

+ +

+ bar + 0: FROM index_1 world1: FROM index_2 world +

+
`.trim(), + ); + }); + }); +}); + +const simpleDiff1 = { + "0": "cooling", + "1": "cooling", + "2": "07:15:03 PM", + [STATIC]: [ + '
\n
\n ', + "\n ", + "\n
\n
\n", + ], + 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", + ], + r: 1, +}; + +const deepDiff1 = { + "0": { + "0": { + [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, + }, + "1": { + [DYNAMICS]: [ + [ + "asdf_asdf", + "asdf@asdf.com", + "123-456-7890", + 'Show', + 'Edit', + 'Delete', + ], + ], + [STATIC]: [ + " \n ", + "\n ", + "\n ", + "\n\n \n", + " ", + "\n", + " \n \n", + ], + 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 \n \n \n \n \n", + " \n
Username
\n", + ], + newRender: true, + r: 1, + }, + "1": { + [DYNAMICS]: [ + [ + "asdf_asdf", + "asdf@asdf.com", + "123-456-7890", + 'Show', + 'Edit', + 'Delete', + ], + ], + [STATIC]: [ + " \n ", + "\n ", + "\n ", + "\n\n \n", + " ", + "\n", + " \n \n", + ], + r: 1, + }, +}; + +const staticReuseDiff = { + "0": { + [DYNAMICS]: [ + [ + "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": ["", ": ", ""] }, + }, + [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 }, + }, + [STATIC]: ["
", "
"], + r: 1, +}; diff --git a/assets/test/test_helpers.js b/assets/test/test_helpers.js deleted file mode 100644 index e2d22639fd..0000000000 --- a/assets/test/test_helpers.js +++ /dev/null @@ -1,87 +0,0 @@ -import View from "phoenix_live_view/view" -import {version as liveview_version} from "../../package.json" - -export let appendTitle = (opts, innerHTML) => { - Array.from(document.head.querySelectorAll("title")).forEach(el => el.remove()) - let title = document.createElement("title") - let {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") - } - if(innerHTML){ title.innerHTML = innerHTML } - document.head.appendChild(title) -} - -export let rootContainer = (content) => { - let div = tag("div", {id: "root"}, content) - document.body.appendChild(div) - return div -} - -export let tag = (tagName, attrs, innerHTML) => { - let el = document.createElement(tagName) - el.innerHTML = innerHTML - for(let key in attrs){ el.setAttribute(key, attrs[key]) } - return el -} - -export let simulateJoinedView = (el, liveSocket) => { - let 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 let simulateVisibility = el => { - el.getClientRects = () => { - let style = window.getComputedStyle(el) - let visible = !(style.opacity === 0 || style.display === "none") - return visible ? {length: 1} : {length: 0} - } - return el -} - -export let stubChannel = view => { - let fakePush = { - receives: [], - receive(kind, cb){ - this.receives.push([kind, cb]) - return this - } - } - view.channel.push = () => fakePush -} - -export function liveViewDOM(content){ - 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 || ` -
- - - - - -
- disconnected! -
-
- ` - document.body.innerHTML = "" - document.body.appendChild(div) - return div -} diff --git a/assets/test/test_helpers.ts b/assets/test/test_helpers.ts new file mode 100644 index 0000000000..13a5015d60 --- /dev/null +++ b/assets/test/test_helpers.ts @@ -0,0 +1,99 @@ +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); + } else { + title.removeAttribute("data-default"); + } + 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; +}; + +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; +}; + +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; +}; + +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; +}; + +export const stubChannel = (view) => { + const fakePush = { + receives: [], + 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 || + ` +
+ + + + + +
+ disconnected! +
+
+ `; + document.body.innerHTML = ""; + document.body.appendChild(div); + return div; +} diff --git a/assets/test/tsconfig.json b/assets/test/tsconfig.json new file mode 100644 index 0000000000..60e67ca824 --- /dev/null +++ b/assets/test/tsconfig.json @@ -0,0 +1,16 @@ +{ + "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": [] +} diff --git a/assets/test/utils_test.js b/assets/test/utils_test.js deleted file mode 100644 index 427f365618..0000000000 --- a/assets/test/utils_test.js +++ /dev/null @@ -1,37 +0,0 @@ -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" - -let setupView = (content) => { - let el = liveViewDOM(content) - global.document.body.appendChild(el) - let 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(` - - `) - let element = global.document.querySelector("#innerContent") - let 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(` - - `) - let element = global.document.querySelector("#innerContent") - expect(closestPhxBinding(element, "phx-click")).toBe(null) - }) - }) -}) - diff --git a/assets/test/utils_test.ts b/assets/test/utils_test.ts new file mode 100644 index 0000000000..d35951b63c --- /dev/null +++ b/assets/test/utils_test.ts @@ -0,0 +1,36 @@ +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); +}; + +describe("utils", () => { + describe("closestPhxBinding", () => { + test("if an element's parent has a phx-click binding and is not disabled, return the parent", () => { + const _view = setupView(` + + `); + 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); + }); + }); +}); diff --git a/assets/test/view_test.js b/assets/test/view_test.js deleted file mode 100644 index 5df3825b21..0000000000 --- a/assets/test/view_test.js +++ /dev/null @@ -1,1520 +0,0 @@ -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 {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" - -import {tag, simulateJoinedView, stubChannel, rootContainer, liveViewDOM, simulateVisibility, appendTitle} from "./test_helpers" - -let simulateUsedInput = (input) => { - DOM.putPrivate(input, PHX_HAS_FOCUSED, true) -} - -describe("View + DOM", function(){ - beforeEach(() => { - submitBefore = HTMLFormElement.prototype.submit - global.Phoenix = {Socket} - global.document.body.innerHTML = liveViewDOM().outerHTML - }) - - afterAll(() => { - global.document.body.innerHTML = "" - }) - - test("update", async () => { - let liveSocket = new LiveSocket("/live", Socket) - let el = liveViewDOM() - let updateDiff = { - s: ["

", "

"], - fingerprint: 123 - } - - let view = simulateJoinedView(el, liveSocket) - view.update(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") - - let titleEl = document.querySelector("title") - let liveSocket = new LiveSocket("/live", Socket) - let el = liveViewDOM() - let updateDiff = { - s: ["

", "

"], - fingerprint: 123, - t: "" - } - - let 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) - - let liveSocket = new LiveSocket("/live", Socket) - let el = liveViewDOM() - - let view = simulateJoinedView(el, liveSocket) - let channelStub = { - push(_evt, payload, _timeout){ - expect(payload.value).toBe("increment=1") - return { - receive(){ return this } - } - } - } - view.channel = channelStub - - view.pushWithReply(null, {target: el.querySelector("form")}, {value: "increment=1"}) - }) - - test("pushWithReply with update", function(){ - let liveSocket = new LiveSocket("/live", Socket) - let el = liveViewDOM() - - let view = simulateJoinedView(el, liveSocket) - let channelStub = { - leave(){ - return { - receive(_status, _cb){ return this } - } - }, - push(_evt, payload, _timeout){ - expect(payload.value).toBe("increment=1") - return { - receive(_status, cb){ - let diff = { - s: ["

", "

"], - fingerprint: 123 - } - cb(diff) - return this - } - } - } - } - view.channel = channelStub - - view.pushWithReply(null, {target: el.querySelector("form")}, {value: "increment=1"}) - - expect(view.el.querySelector("form")).toBeTruthy() - }) - - test("pushEvent", function(){ - expect.assertions(3) - - let liveSocket = new LiveSocket("/live", Socket) - let el = liveViewDOM() - let input = el.querySelector("input") - - let view = simulateJoinedView(el, liveSocket) - let channelStub = { - 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 - - view.pushEvent("keyup", input, el, "click", {}) - }) - - 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\"]") - - let view = simulateJoinedView(el, liveSocket) - let channelStub = { - push(_evt, payload, _timeout){ - expect(payload.value).toEqual({}) - return { - receive(){ return this } - } - } - } - view.channel = channelStub - - view.pushEvent("click", input, el, "toggle_me", {}) - }) - - 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) - - input.checked = true - - let channelStub = { - push(_evt, payload, _timeout){ - expect(payload.value).toEqual({"value": "on"}) - return { - receive(){ return this } - } - } - } - view.channel = channelStub - - view.pushEvent("click", input, el, "toggle_me", {}) - }) - - 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) - - input.value = "1" - input.checked = true - - let channelStub = { - push(_evt, payload, _timeout){ - expect(payload.value).toEqual({"value": "1"}) - return { - receive(){ return this } - } - } - } - view.channel = channelStub - - view.pushEvent("click", input, el, "toggle_me", {}) - }) - - test("pushInput", function(){ - expect.assertions(4) - - let liveSocket = new LiveSocket("/live", Socket) - let el = liveViewDOM() - let input = el.querySelector("input") - simulateUsedInput(input) - let view = simulateJoinedView(el, liveSocket) - let 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"}) - return { - receive(){ return this } - } - } - } - view.channel = channelStub - - view.pushInput(input, el, null, "validate", {_target: input.name}) - }) - - test("pushInput with with phx-value and JS command value", function(){ - expect.assertions(4) - - let liveSocket = new LiveSocket("/live", Socket) - let el = liveViewDOM(` -
- - - - - -
- `) - let input = el.querySelector("input") - simulateUsedInput(input) - let view = simulateJoinedView(el, liveSocket) - let 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", - "attribute_value": "attribute", - "nested": { - "command_value": "command", - "array": [1, 2] - } - }) - return { - receive(){ return this } - } - } - } - view.channel = channelStub - let 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") - input.removeAttribute("name") - simulateUsedInput(input) - let view = simulateJoinedView(el, liveSocket) - let 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"}) - 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) - - let liveSocket = new LiveSocket("/live", Socket) - let el = liveViewDOM() - let form = el.querySelector("form") - - let view = simulateJoinedView(el, liveSocket) - let channelStub = { - 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}) - }) - - test("payload includes phx-value and JS command value", function(){ - expect.assertions(4) - - let liveSocket = new LiveSocket("/live", Socket) - let el = liveViewDOM(` -
- - - - - -
- `) - let form = el.querySelector("form") - - let view = simulateJoinedView(el, liveSocket) - let channelStub = { - 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] - } - }) - return { - receive(){ return this } - } - } - } - view.channel = channelStub - let 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") - 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(){ - let 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(){ - let 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") - if(appendTo){ - appendTo.appendChild(btn) - } else { - form.appendChild(btn) - } - - let view = simulateJoinedView(el, liveSocket) - let channelStub = { - push(_evt, payload, _timeout){ - expect(payload.type).toBe("form") - expect(payload.event).toBeDefined() - expect(payload.value).toBe(queryString) - return { - receive(){ return this } - } - } - } - - view.channel = channelStub - view.submitForm(form, form, {target: form}, btn, opts) - } - - test("disables elements after submission", function(){ - let liveSocket = new LiveSocket("/live", Socket) - let el = liveViewDOM() - let form = el.querySelector("form") - - let 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(){ - let liveSocket = new LiveSocket("/live", Socket) - let el = liveViewDOM(` -
-
- - - - - - `) - let form = el.querySelector("form") - - let 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(){ - let liveSocket = new LiveSocket("/live", Socket) - let el = liveViewDOM(` - - `) - let button = el.querySelector("button") - - let view = simulateJoinedView(el, liveSocket) - stubChannel(view) - - 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) => { - let liveSocket = new LiveSocket("/live", Socket) - let el = liveViewDOM() - let view = simulateJoinedView(el, liveSocket) - let html = "
" - - stubChannel(view) - view.onJoin({rendered: {s: [html], fingerprint: 123}, liveview_version}) - expect(view.el.innerHTML).toBe(html) - - let formEl = document.getElementById("form") - Object.getPrototypeOf(formEl).submit = done - let 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) => { - let liveSocket = new LiveSocket("/live", Socket) - let el = liveViewDOM() - let view = simulateJoinedView(el, liveSocket) - let 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 = "
" - view.update({s: [updatedHtml]}, []) - - expect(liveSocket.socket.closeWasClean).toBe(true) - expect(view.el.innerHTML).toBe("
") - }) - }) - - describe("phx-update", function(){ - let childIds = () => Array.from(document.getElementById("list").children).map(child => parseInt(child.id)) - let countChildNodes = () => document.getElementById("list").childNodes.length - - let createView = (updateType, initialDynamics) => { - let liveSocket = new LiveSocket("/live", Socket) - let el = liveViewDOM() - let view = simulateJoinedView(el, liveSocket) - - stubChannel(view) - - let joinDiff = { - "0": {"d": initialDynamics, "s": ["\n
", "
\n"]}, - "s": [`
`, "
"] - } - - view.onJoin({rendered: joinDiff, liveview_version}) - - return view - } - - let updateDynamics = (view, dynamics) => { - let updateDiff = { - "0": { - "d": dynamics - } - } - - view.update(updateDiff, []) - } - - test("replace", async () => { - let view = createView("replace", [["1", "1"]]) - expect(childIds()).toEqual([1]) - - updateDynamics(view, - [["2", "2"], ["3", "3"]] - ) - expect(childIds()).toEqual([2, 3]) - }) - - test("append", async () => { - let view = createView("append", [["1", "1"]]) - expect(childIds()).toEqual([1]) - - // Append two elements - 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]) - - // Update the first element - 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]) - - // Update after new elements - 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]) - - // 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]) - - // Make sure we don't have a memory leak when doing updates - let 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 () => { - let view = createView("prepend", [["1", "1"]]) - expect(childIds()).toEqual([1]) - - // Append two elements - 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]) - - // Update the first element - 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]) - - // Update after new elements - 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]) - - // 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]) - - // Make sure we don't have a memory leak when doing updates - let 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 () => { - let view = createView("ignore", [["1", "1"]]) - expect(childIds()).toEqual([1]) - - // Append two elements - 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 updateDiff = { - "0": " phx-mounted=\"[["ignore_attrs",{"attrs":["open"]}]]\"", - "1": "0", - "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") - - // now update, the HTML patch would normally reset the open attribute - 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) - // 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") - }) - - test("ignore_attributes wildcard", () => { - 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\"", - "2": "0", - "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") - // 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(){ - beforeEach(() => { - submitBefore = HTMLFormElement.prototype.submit - global.Phoenix = {Socket} - global.document.body.innerHTML = liveViewDOM().outerHTML - }) - - afterEach(() => { - HTMLFormElement.prototype.submit = submitBefore - jest.useRealTimers() - }) - - afterAll(() => { - global.document.body.innerHTML = "" - }) - - test("sets defaults", async () => { - let liveSocket = new LiveSocket("/live", Socket) - let el = liveViewDOM() - let 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 () => { - let liveSocket = new LiveSocket("/live", Socket) - let el = liveViewDOM() - let 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) - expect(view.getSession()).toEqual("abc123") - }) - - test("getStatic", async () => { - let liveSocket = new LiveSocket("/live", Socket) - let 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") - }) - - test("showLoader and hideLoader", async () => { - let liveSocket = new LiveSocket("/live", Socket) - let el = document.querySelector("[data-phx-session]") - - let 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() - let liveSocket = new LiveSocket("/live", Socket) - let loader = document.createElement("span") - let phxView = document.querySelector("[data-phx-session]") - phxView.parentNode.insertBefore(loader, phxView.nextSibling) - let el = document.querySelector("[data-phx-session]") - let status = el.querySelector("#status") - - let 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 () => { - let liveSocket = new LiveSocket("/live", Socket) - let el = liveViewDOM() - let _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) - 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 += "" - - 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": [ - "http://localhost/css/app-123.css?vsn=d", - "http://localhost/img/tracked.png", - ] - }, - "sticky": false - }) - }) -}) - -describe("View Hooks", function(){ - beforeEach(() => { - global.document.body.innerHTML = liveViewDOM().outerHTML - }) - - afterAll(() => { - global.document.body.innerHTML = "" - }) - - test("phx-mounted", done => { - let liveSocket = new LiveSocket("/live", Socket) - let el = liveViewDOM() - - let html = "

test mounted

" - el.innerHTML = html - - let view = simulateJoinedView(el, liveSocket) - - view.onJoin({ - rendered: { - s: [html], - fingerprint: 123 - }, - liveview_version - }) - window.requestAnimationFrame(() => { - 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() - }) - }) - }) - - test("hooks", async () => { - let upcaseWasDestroyed = false - let upcaseBeforeUpdate = false - let hookLiveSocket - let Hooks = { - Upcase: { - 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 }, - } - } - let liveSocket = new LiveSocket("/live", Socket, {hooks: Hooks}) - let el = liveViewDOM() - - let 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("createHook", (done) => { - let liveSocket = new LiveSocket("/live", Socket, {}) - let el = liveViewDOM() - customElements.define("custom-el", class extends HTMLElement { - connectedCallback(){ - this.hook = createHook(this, {mounted: () => { - expect(this.hook.liveSocket).toBeTruthy() - done() - }}) - expect(this.hook.liveSocket).toBe(null) - } - }) - let customEl = document.createElement("custom-el") - el.appendChild(customEl) - simulateJoinedView(el, liveSocket) - }) - - test("view destroyed", async () => { - let values = [] - let Hooks = { - Check: { - destroyed(){ values.push("destroyed") }, - } - } - let liveSocket = new LiveSocket("/live", Socket, {hooks: Hooks}) - let el = liveViewDOM() - - let view = simulateJoinedView(el, liveSocket) - - view.onJoin({ - rendered: { - s: ["

test mount

"], - fingerprint: 123 - }, - liveview_version - }) - expect(view.el.firstChild.innerHTML).toBe("test mount") - - view.destroy() - - expect(values).toEqual(["destroyed"]) - }) - - test("view reconnected", async () => { - let values = [] - let 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() - - let view = simulateJoinedView(el, liveSocket) - - view.onJoin({ - rendered: { - s: ["

"], - fingerprint: 123 - }, - liveview_version - }) - expect(values).toEqual(["mounted"]) - - view.triggerReconnected() - // The hook hasn't disconnected, so it shouldn't receive "reconnected" message - expect(values).toEqual(["mounted"]) - - view.showLoader() - expect(values).toEqual(["mounted", "disconnected"]) - - view.triggerReconnected() - expect(values).toEqual(["mounted", "disconnected", "reconnected"]) - }) - - test("dispatches uploads", async () => { - let hooks = {Recorder: {}} - let liveSocket = new LiveSocket("/live", Socket, {hooks}) - let el = liveViewDOM() - let view = simulateJoinedView(el, liveSocket) - - let template = ` -
- -
- ` - view.onJoin({ - rendered: { - s: [template], - fingerprint: 123 - }, - liveview_version - }) - - let recorderHook = view.getHook(view.el.querySelector("#rec")) - let fileEl = view.el.querySelector("#uploads0") - let dispatchEventSpy = jest.spyOn(fileEl, "dispatchEvent") - - let contents = {hello: "world"} - let 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 liveSocket = new LiveSocket("/live", Socket, { - dom: { - onBeforeElUpdated(from, to){ fromHTML = from.innerHTML; toHTML = to.innerHTML } - } - }) - let el = liveViewDOM() - let 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 = ` -
-
- - - - -
-
- ` - return div -} - -describe("View + Component", function(){ - beforeEach(() => { - global.Phoenix = {Socket} - global.document.body.innerHTML = liveViewComponent().outerHTML - }) - - afterAll(() => { - global.document.body.innerHTML = "" - }) - - 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") - expect(view.targetComponentID(el, targetCtx)).toBe(null) - expect(view.targetComponentID(form, targetCtx)).toBe(0) - }) - - test("pushEvent", (done) => { - expect.assertions(17) - - let liveSocket = new LiveSocket("/live", Socket) - let el = liveViewComponent() - let targetCtx = el.querySelector(".form-wrapper") - - let view = simulateJoinedView(el, liveSocket) - let input = view.el.querySelector("input[id=plus]") - let 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) - return { - receive(status, callback){ - callback({ref: payload.ref}) - return this - } - } - } - } - view.channel = channelStub - - input.addEventListener("phx:push:myevent", (e) => { - let {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) - lockComplete.then((detail) => { - expect(detail.event).toBe("myevent") - expect(detail.ref).toBe(0) - done() - }) - }) - }) - input.addEventListener("phx:push", (e) => { - let {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){ - let html = - `
- - - - - -
` - let liveSocket = new LiveSocket("/live", Socket) - let el = liveViewDOM(html) - let view = simulateJoinedView(el, liveSocket, html) - Array.from(view.el.querySelectorAll("input")).forEach(input => simulateUsedInput(input)) - let channelStub = { - validate: "", - nextValidate(payload, meta){ - this.meta = meta - this.validate = Object.entries(payload) - .map(([key, value]) => `${encodeURIComponent(key)}=${value ? encodeURIComponent(value) : ""}`) - .join("&") - }, - push(_evt, payload, _timeout){ - expect(payload.value).toBe(this.validate) - expect(payload.meta).toEqual(this.meta) - return { - receive(status, cb){ - if(status === "ok"){ - let diff = { - s: [` -
- - - - - - - -
- `], - fingerprint: 345 - } - cb({diff: diff}) - return this - } else { - return this - } - } - } - } - } - view.channel = channelStub - - let first_name = view.el.querySelector("#first_name") - let 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}) - 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}) - window.requestAnimationFrame(() => { - done() - }) - }) - }) - - test("adds auto ID to prevent teardown/re-add", () => { - let liveSocket = new LiveSocket("/live", Socket) - let el = liveViewDOM() - let view = simulateJoinedView(el, liveSocket) - - stubChannel(view) - - let joinDiff = { - "0": {"0": "", "1": 0, "s": ["", "", "

2

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

1

\n"], "r": 1} - } - } - - 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

") - }) - - test("respects nested components", () => { - let liveSocket = new LiveSocket("/live", Socket) - let el = liveViewDOM() - let view = simulateJoinedView(el, liveSocket) - - stubChannel(view) - - let joinDiff = { - "0": 0, - "c": { - "0": {"0": 1, "s": ["
Hello
", ""], "r": 1}, - "1": {"s": ["
World
"], "r": 1} - }, - "s": ["", ""] - } - - view.onJoin({rendered: joinDiff, liveview_version}) - expect(view.el.innerHTML.trim()).toBe("
Hello
World
") - }) - - test("destroys children when they are removed by an update", () => { - let id = "root" - let childHTML = `
` - let newChildHTML = `
` - let el = document.createElement("div") - el.setAttribute("data-phx-session", "abc123") - el.setAttribute("id", id) - document.body.appendChild(el) - - let liveSocket = new LiveSocket("/live", Socket) - - let view = simulateJoinedView(el, liveSocket) - - let joinDiff = {"s": [childHTML]} - - let updateDiff = {"s": [newChildHTML]} - - 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() - }) - - describe("undoRefs", () => { - test("restores phx specific attributes awaiting a ref", () => { - let content = ` - -
- - - - -
- `.trim() - let liveSocket = new LiveSocket("/live", Socket) - let el = rootContainer(content) - let view = simulateJoinedView(el, liveSocket) - - view.undoRefs(1) - expect(el.innerHTML).toBe(` - -
- - - - -
- `.trim()) - - view.undoRefs(38) - expect(el.innerHTML).toBe(` - -
- - - - -
- `.trim()) - }) - - test("replaces any previous applied component", () => { - let liveSocket = new LiveSocket("/live", Socket) - let 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") - - DOM.putPrivate(fromEl, "data-phx-ref-lock", toEl) - - el.appendChild(fromEl) - let view = simulateJoinedView(el, liveSocket) - - view.undoRefs(1) - expect(el.innerHTML).toBe("world") - }) - - test("triggers beforeUpdate and updated hooks", () => { - global.document.body.innerHTML = "" - let beforeUpdate = false - let updated = false - let Hooks = { - MyHook: { - beforeUpdate(){ beforeUpdate = true }, - updated(){ updated = true }, - } - } - let liveSocket = new LiveSocket("/live", Socket, {hooks: Hooks}) - let el = liveViewDOM() - let 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") - 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("target") - target.type = "checkbox" - target.id = "foo" - target.setAttribute("checked", "true") - - const source = document.createElement("source") - 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("target") - target.type = "checkbox" - target.id = "foo" - target.checked = true - - const source = document.createElement("source") - 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/assets/test/view_test.ts b/assets/test/view_test.ts new file mode 100644 index 0000000000..aa8b263802 --- /dev/null +++ b/assets/test/view_test.ts @@ -0,0 +1,1827 @@ +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 { + PHX_LOADING_CLASS, + PHX_ERROR_CLASS, + PHX_SERVER_ERROR_CLASS, + PHX_HAS_FOCUSED, +} from "phoenix_live_view/constants"; + +import { + tag, + simulateJoinedView, + stubChannel, + rootContainer, + liveViewDOM, + simulateVisibility, + appendTitle, +} from "./test_helpers"; + +const simulateUsedInput = (input) => { + DOM.putPrivate(input, PHX_HAS_FOCUSED, true); +}; + +describe("View + DOM", function () { + beforeEach(() => { + submitBefore = HTMLFormElement.prototype.submit; + global.Phoenix = { Socket }; + global.document.body.innerHTML = liveViewDOM().outerHTML; + }); + + afterAll(() => { + global.document.body.innerHTML = ""; + }); + + test("update", async () => { + const liveSocket = new LiveSocket("/live", Socket); + const el = liveViewDOM(); + const updateDiff = { + s: ["

", "

"], + fingerprint: 123, + }; + + const view = simulateJoinedView(el, liveSocket); + view.update(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"); + + 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); + const channelStub = { + push(_evt, payload, _timeout) { + expect(payload.value).toBe("increment=1"); + return { + receive() { + return this; + }, + }; + }, + }; + view.channel = channelStub; + + view.pushWithReply( + null, + { target: el.querySelector("form") }, + { value: "increment=1" }, + ); + }); + + test("pushWithReply with update", function () { + const liveSocket = new LiveSocket("/live", Socket); + const el = liveViewDOM(); + + const view = simulateJoinedView(el, liveSocket); + const channelStub = { + leave() { + return { + receive(_status, _cb) { + return this; + }, + }; + }, + push(_evt, payload, _timeout) { + expect(payload.value).toBe("increment=1"); + return { + receive(_status, cb) { + const diff = { + s: ["

", "

"], + fingerprint: 123, + }; + cb(diff); + return this; + }, + }; + }, + }; + view.channel = channelStub; + + view.pushWithReply( + null, + { target: el.querySelector("form") }, + { value: "increment=1" }, + ); + + expect(view.el.querySelector("form")).toBeTruthy(); + }); + + test("pushEvent", function () { + expect.assertions(3); + + const liveSocket = new LiveSocket("/live", Socket); + const el = liveViewDOM(); + const input = el.querySelector("input"); + + 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" }); + return { + receive() { + return this; + }, + }; + }, + }; + view.channel = channelStub; + + view.pushEvent("keyup", input, el, "click", {}); + }); + + 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 view = simulateJoinedView(el, liveSocket); + const channelStub = { + push(_evt, payload, _timeout) { + expect(payload.value).toEqual({}); + return { + receive() { + return this; + }, + }; + }, + }; + view.channel = channelStub; + + view.pushEvent("click", input, el, "toggle_me", {}); + }); + + 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); + + input.checked = true; + + const channelStub = { + push(_evt, payload, _timeout) { + expect(payload.value).toEqual({ value: "on" }); + return { + receive() { + return this; + }, + }; + }, + }; + view.channel = channelStub; + + view.pushEvent("click", input, el, "toggle_me", {}); + }); + + 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); + + input.value = "1"; + input.checked = true; + + const channelStub = { + push(_evt, payload, _timeout) { + expect(payload.value).toEqual({ value: "1" }); + return { + receive() { + return this; + }, + }; + }, + }; + view.channel = channelStub; + + view.pushEvent("click", input, el, "toggle_me", {}); + }); + + 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 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" }); + return { + receive() { + return this; + }, + }; + }, + }; + view.channel = channelStub; + + view.pushInput(input, el, null, "validate", { _target: input.name }); + }); + + test("pushInput with with phx-value and JS command value", 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 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", + 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); + 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" }); + 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); + const channelStub = { + 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 }); + }); + + test("payload includes phx-value and JS command value", function () { + expect.assertions(4); + + 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"); + expect(payload.meta).toEqual({ + 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); + } else { + form.appendChild(btn); + } + + 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); + return { + receive() { + return this; + }, + }; + }, + }; + + 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); + 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(); + 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 view = simulateJoinedView(el, liveSocket); + stubChannel(view); + + 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( + '
', + ); + }); + + 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 createView = (updateType, initialDynamics) => { + const liveSocket = new LiveSocket("/live", Socket); + const el = liveViewDOM(); + const view = simulateJoinedView(el, liveSocket); + + stubChannel(view); + + const joinDiff = { + "0": { d: initialDynamics, s: ['\n
', "
\n"] }, + s: [`
`, "
"], + }; + + view.onJoin({ rendered: joinDiff, liveview_version }); + + return view; + }; + + const updateDynamics = (view, dynamics) => { + const updateDiff = { + "0": { + d: dynamics, + }, + }; + + view.update(updateDiff, []); + }; + + test("replace", async () => { + const view = createView("replace", [["1", "1"]]); + expect(childIds()).toEqual([1]); + + updateDynamics(view, [ + ["2", "2"], + ["3", "3"], + ]); + expect(childIds()).toEqual([2, 3]); + }); + + test("append", async () => { + 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]); + + // Update the last element + updateDynamics(view, [["3", "3"]]); + expect(childIds()).toEqual([1, 2, 3]); + + // Update the first element + 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]); + + // Update after new elements + 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]); + + // 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]); + + // 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); + }); + + test("prepend", async () => { + 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]); + + // Update the last element + updateDynamics(view, [["3", "3"]]); + expect(childIds()).toEqual([2, 3, 1]); + + // Update the first element + 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]); + + // Update after new elements + 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]); + + // 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]); + + // 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); + }); + + test("ignore", async () => { + const view = createView("ignore", [["1", "1"]]); + expect(childIds()).toEqual([1]); + + // Append two elements + 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 updateDiff = { + "0": ' phx-mounted="[["ignore_attrs",{"attrs":["open"]}]]"', + "1": "0", + 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"); + + // now update, the HTML patch would normally reset the open attribute + 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); + // 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"); + }); + + test("ignore_attributes wildcard", () => { + 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"', + "2": "0", + 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"); + // 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 () { + beforeEach(() => { + submitBefore = HTMLFormElement.prototype.submit; + global.Phoenix = { Socket }; + global.document.body.innerHTML = liveViewDOM().outerHTML; + }); + + afterEach(() => { + HTMLFormElement.prototype.submit = submitBefore; + jest.useRealTimers(); + }); + + afterAll(() => { + 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(); + }); + + test("binding", async () => { + 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"); + }); + + test("getStatic", async () => { + 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"); + }); + + 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(); + }); + + test("join", async () => { + 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); + + 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 += ''; + + 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: [ + "http://localhost/css/app-123.css?vsn=d", + "http://localhost/img/tracked.png", + ], + }, + sticky: false, + }); + }); +}); + +describe("View Hooks", function () { + beforeEach(() => { + global.document.body.innerHTML = liveViewDOM().outerHTML; + }); + + afterAll(() => { + global.document.body.innerHTML = ""; + }); + + test("phx-mounted", (done) => { + const liveSocket = new LiveSocket("/live", Socket); + const el = liveViewDOM(); + + const html = + '

test mounted

'; + el.innerHTML = html; + + const view = simulateJoinedView(el, liveSocket); + + view.onJoin({ + rendered: { + s: [html], + fingerprint: 123, + }, + liveview_version, + }); + window.requestAnimationFrame(() => { + 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(); + }); + }); + }); + + test("hooks", async () => { + let upcaseWasDestroyed = false; + let upcaseBeforeUpdate = false; + let hookLiveSocket; + const Hooks = { + Upcase: { + 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: { + 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: { + 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("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); + }); + + test("view destroyed", async () => { + const values = []; + const Hooks = { + Check: { + destroyed() { + values.push("destroyed"); + }, + }, + }; + const liveSocket = new LiveSocket("/live", Socket, { hooks: Hooks }); + const el = liveViewDOM(); + + const view = simulateJoinedView(el, liveSocket); + + view.onJoin({ + rendered: { + s: ['

test mount

'], + fingerprint: 123, + }, + liveview_version, + }); + expect(view.el.firstChild.innerHTML).toBe("test mount"); + + view.destroy(); + + expect(values).toEqual(["destroyed"]); + }); + + test("view reconnected", async () => { + 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(); + + const view = simulateJoinedView(el, liveSocket); + + view.onJoin({ + rendered: { + s: ['

'], + fingerprint: 123, + }, + liveview_version, + }); + expect(values).toEqual(["mounted"]); + + view.triggerReconnected(); + // The hook hasn't disconnected, so it shouldn't receive "reconnected" message + expect(values).toEqual(["mounted"]); + + view.showLoader(); + expect(values).toEqual(["mounted", "disconnected"]); + + 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 template = ` +
+ +
+ `; + view.onJoin({ + rendered: { + s: [template], + 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] }, + }), + ); + }); + + test("dom hooks", async () => { + 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"); + div.innerHTML = ` +
+
+ + + + +
+
+ `; + return div; +} + +describe("View + Component", function () { + beforeEach(() => { + global.Phoenix = { Socket }; + global.document.body.innerHTML = liveViewComponent().outerHTML; + }); + + afterAll(() => { + 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); + }); + + test("pushEvent", (done) => { + expect.assertions(17); + + 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 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); + return { + 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); + loadingComplete.then((detail) => { + expect(detail.event).toBe("myevent"); + expect(detail.ref).toBe(0); + lockComplete.then((detail) => { + 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); + // 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 = `
+ + + + + +
`; + 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; + this.validate = Object.entries(payload) + .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); + return { + receive(status, cb) { + if (status === "ok") { + const diff = { + s: [ + ` +
+ + + + + + + +
+ `, + ], + fingerprint: 345, + }; + cb({ diff: diff }); + return this; + } else { + 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]" }, + ); + // 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, + }); + 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, + }); + window.requestAnimationFrame(() => { + done(); + }); + }); + }); + + test("adds auto ID to prevent teardown/re-add", () => { + const liveSocket = new LiveSocket("/live", Socket); + const el = liveViewDOM(); + const view = simulateJoinedView(el, liveSocket); + + stubChannel(view); + + const joinDiff = { + "0": { "0": "", "1": 0, s: ["", "", "

2

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

1

\n"], r: 1 }, + }, + }; + + 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

', + ); + }); + + test("respects nested components", () => { + const liveSocket = new LiveSocket("/live", Socket); + const el = liveViewDOM(); + const view = simulateJoinedView(el, liveSocket); + + stubChannel(view); + + const joinDiff = { + "0": 0, + c: { + "0": { "0": 1, s: ["
Hello
", ""], r: 1 }, + "1": { s: ["
World
"], r: 1 }, + }, + s: ["", ""], + }; + + 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 liveSocket = new LiveSocket("/live", Socket); + + const view = simulateJoinedView(el, liveSocket); + + const joinDiff = { s: [childHTML] }; + + const updateDiff = { s: [newChildHTML] }; + + 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(); + }); + + describe("undoRefs", () => { + test("restores phx specific attributes awaiting a ref", () => { + const content = ` + +
+ + + + +
+ `.trim(); + const liveSocket = new LiveSocket("/live", Socket); + const el = rootContainer(content); + const view = simulateJoinedView(el, liveSocket); + + view.undoRefs(1); + expect(el.innerHTML).toBe( + ` + +
+ + + + +
+ `.trim(), + ); + + view.undoRefs(38); + expect(el.innerHTML).toBe( + ` + +
+ + + + +
+ `.trim(), + ); + }); + + test("replaces any previous applied component", () => { + 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"); + + DOM.putPrivate(fromEl, "data-phx-ref-lock", toEl); + + el.appendChild(fromEl); + const view = simulateJoinedView(el, liveSocket); + + view.undoRefs(1); + expect(el.innerHTML).toBe('world'); + }); + + test("triggers beforeUpdate and updated hooks", () => { + 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"); + }); +}); 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.js b/eslint.config.js new file mode 100644 index 0000000000..dc30aecc68 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,71 @@ +import playwright from "eslint-plugin-playwright" +import jest from "eslint-plugin-jest" +import globals from "globals" +import js from "@eslint/js" +import tseslint from "typescript-eslint" + +const sharedRules = { + "@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 tseslint.config([ + { + ignores: [ + "assets/js/types/", + "test/e2e/test-results/", + "coverage/", + "cover/", + "priv/", + "deps/", + "doc/" + ] + }, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ["*.js", "*.ts", "test/e2e/**"], + ignores: ["assets/**"], + + plugins: { + ...playwright.configs["flat/recommended"].plugins, + }, + + rules: { + ...playwright.configs["flat/recommended"].rules, + ...sharedRules + }, + }, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ["assets/**/*.{js,ts}"], + ignores: ["test/e2e/**"], + + plugins: { + jest, + }, + + languageOptions: { + globals: { + ...globals.browser, + ...jest.environments.globals.globals, + global: "writable", + }, + + ecmaVersion: 12, + sourceType: "module", + }, + + rules: { + ...sharedRules, + }, + }]) diff --git a/eslint.config.mjs b/eslint.config.mjs deleted file mode 100644 index f3b6df0042..0000000000 --- a/eslint.config.mjs +++ /dev/null @@ -1,119 +0,0 @@ -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" - -const sharedRules = { - "@stylistic/js/indent": ["error", 2, { - SwitchCase: 1, - }], - - "@stylistic/js/linebreak-style": ["error", "unix"], - "@stylistic/js/quotes": ["error", "double"], - "@stylistic/js/semi": ["error", "never"], - - "@stylistic/js/object-curly-spacing": ["error", "never", { - objectsInObjects: false, - arraysInObjects: false, - }], - - "@stylistic/js/array-bracket-spacing": ["error", "never"], - - "@stylistic/js/comma-spacing": ["error", { - before: false, - after: true, - }], - - "@stylistic/js/computed-property-spacing": ["error", "never"], - - "@stylistic/js/space-before-blocks": ["error", { - functions: "never", - keywords: "never", - classes: "always", - }], - - "@stylistic/js/keyword-spacing": ["error", { - overrides: { - if: { - after: false, - }, - - for: { - after: false, - }, - - while: { - after: false, - }, - - switch: { - after: false, - }, - }, - }], - - "@stylistic/js/eol-last": ["error", "always"], - - "no-unused-vars": ["error", { - argsIgnorePattern: "^_", - varsIgnorePattern: "^_", - }], - - "no-useless-escape": "off", - "no-cond-assign": "off", - "no-case-declarations": "off", -} - -export default [ - { - ignores: [ - "test/e2e/test-results/", - "coverage/", - "cover/", - "priv/", - "deps/", - "doc/" - ] - }, - { - ...js.configs.recommended, - files: ["*.js", "*.mjs", "test/e2e/**"], - ignores: ["assets/**"], - - plugins: { - ...playwright.configs["flat/recommended"].plugins, - "@stylistic/js": stylisticJs, - }, - - rules: { - ...playwright.configs["flat/recommended"].rules, - ...sharedRules - }, - }, - { - ...js.configs.recommended, - - files: ["assets/**/*.js", "assets/**/*.mjs"], - ignores: ["test/e2e/**"], - - plugins: { - jest, - "@stylistic/js": stylisticJs, - }, - - languageOptions: { - globals: { - ...globals.browser, - ...jest.environments.globals.globals, - global: "writable", - }, - - ecmaVersion: 12, - sourceType: "module", - }, - - rules: { - ...sharedRules, - }, - }] diff --git a/guides/client/js-interop.md b/guides/client/js-interop.md index 7a372f0d27..b6acf2e601 100644 --- a/guides/client/js-interop.md +++ b/guides/client/js-interop.md @@ -237,11 +237,29 @@ 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 diff --git a/jest.config.js b/jest.config.js index 699e6d3238..fcb4ea832d 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/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 @@ -175,7 +177,7 @@ module.exports = { // ], // 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/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..f05092f494 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,15 +9,18 @@ "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/jest": "^29.5.14", + "@types/phoenix": "^1.6.6", "css.escape": "^1.5.1", "eslint": "9.24.0", "eslint-plugin-jest": "28.11.0", @@ -27,7 +30,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 +110,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 +172,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 +188,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 +225,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 +297,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 +340,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 +378,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 +395,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 +462,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 +673,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 +790,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 +1193,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 +1553,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 +1741,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 +1773,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 +1814,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 +1833,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 +2605,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 +2624,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", @@ -2666,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", @@ -2692,6 +2761,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 +2794,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 +2864,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 +2901,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 +2923,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 +2951,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 +2970,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 +3163,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 +3424,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 +3934,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 +4367,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 +4396,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 +4637,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 +5052,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 +5959,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 +6001,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 +6145,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 +6820,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 +6891,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 +7179,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 +7190,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 +7307,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 +7319,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..001f044061 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/types/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", + "@types/jest": "^29.5.14", + "@types/phoenix": "^1.6.6", "css.escape": "^1.5.1", "eslint": "9.24.0", "eslint-plugin-jest": "28.11.0", @@ -42,18 +46,26 @@ "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", + "prettier": "3.5.3", + "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:lint": "eslint --fix && cd assets && eslint --fix", + "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", + "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", - "cover:merge": "node test/e2e/merge-coverage.mjs", + "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" } 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 new file mode 100644 index 0000000000..a0827ff1ea --- /dev/null +++ b/test/e2e/merge-coverage.js @@ -0,0 +1,16 @@ +import { CoverageReport } from "monocart-coverage-reports"; + +const coverageOptions = { + name: "Phoenix LiveView JS Coverage", + inputDir: ["./coverage/raw", "./test/e2e/test-results/coverage/raw"], + outputDir: "./cover/merged-js", + reports: [["v8"], ["console-summary"]], + sourcePath: (filePath) => { + if (!filePath.startsWith("assets")) { + return "assets/js/phoenix_live_view/" + filePath; + } else { + return filePath; + } + }, +}; +await new CoverageReport(coverageOptions).generate(); diff --git a/test/e2e/merge-coverage.mjs b/test/e2e/merge-coverage.mjs deleted file mode 100644 index 85c52bb755..0000000000 --- a/test/e2e/merge-coverage.mjs +++ /dev/null @@ -1,22 +0,0 @@ -import {CoverageReport} from "monocart-coverage-reports" - -const coverageOptions = { - name: "Phoenix LiveView JS Coverage", - inputDir: [ - "./coverage/raw", - "./test/e2e/test-results/coverage/raw" - ], - outputDir: "./cover/merged-js", - reports: [ - ["v8"], - ["console-summary"] - ], - sourcePath: (filePath) => { - if(!filePath.startsWith("assets")){ - return "assets/js/phoenix_live_view/" + filePath - } else { - return filePath - } - }, -} -await new CoverageReport(coverageOptions).generate() diff --git a/test/e2e/playwright.config.js b/test/e2e/playwright.config.js index d0a39eea51..b106b2c754 100644 --- a/test/e2e/playwright.config.js +++ b/test/e2e/playwright.config.js @@ -1,25 +1,32 @@ // 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", { - 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", @@ -36,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: 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..27929f7b07 100644 --- a/test/e2e/teardown.js +++ b/test/e2e/teardown.js @@ -1,13 +1,15 @@ -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"}) + 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 fc93057848..ca307618b8 100644 --- a/test/e2e/test-fixtures.js +++ b/test/e2e/test-fixtures.js @@ -1,53 +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 fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node: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 37d9930bbf..858772467a 100644 --- a/test/e2e/tests/errors.spec.js +++ b/test/e2e/tests/errors.spec.js @@ -1,38 +1,44 @@ -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 */ 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 305f82803a..10aae7fe68 100644 --- a/test/e2e/tests/forms.spec.js +++ b/test/e2e/tests/forms.spec.js @@ -1,229 +1,297 @@ -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", () => { - 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") - let changesA = attributeMutations(page, "input[name=a]") - let 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) - let 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) - let 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(let additionalParams of ["live-component", ""]){ - let 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) - - 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.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(let 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(let 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() - let testForm = page.locator("#test-form") - let 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") - let 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") - let testForm = page.locator("#test-form") - let testInput = page.locator("#test-form input[name=myname]") - let 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 81289c7790..cb91be64ff 100644 --- a/test/e2e/tests/issues/2787.spec.js +++ b/test/e2e/tests/issues/2787.spec.js @@ -1,38 +1,53 @@ -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)) +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 b944a0de72..a1fe627f87 100644 --- a/test/e2e/tests/issues/2965.spec.js +++ b/test/e2e/tests/issues/2965.spec.js @@ -1,30 +1,30 @@ -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") - 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 1439e7a58d..4b4b312f99 100644 --- a/test/e2e/tests/issues/3026.spec.js +++ b/test/e2e/tests/issues/3026.spec.js @@ -1,39 +1,39 @@ -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 = [] +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 00891387b4..8881e6dc16 100644 --- a/test/e2e/tests/issues/3040.spec.js +++ b/test/e2e/tests/issues/3040.spec.js @@ -1,63 +1,67 @@ -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") - 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 a9fd399a6e..cfc0d38708 100644 --- a/test/e2e/tests/issues/3047.spec.js +++ b/test/e2e/tests/issues/3047.spec.js @@ -1,31 +1,60 @@ -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)) +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 0284c697e8..fe9381510b 100644 --- a/test/e2e/tests/issues/3083.spec.js +++ b/test/e2e/tests/issues/3083.spec.js @@ -1,30 +1,30 @@ -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") - 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 7b2cd6bb2f..f071185a3c 100644 --- a/test/e2e/tests/issues/3107.spec.js +++ b/test/e2e/tests/issues/3107.spec.js @@ -1,14 +1,14 @@ -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") - 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 39c8bbc538..a551f75a16 100644 --- a/test/e2e/tests/issues/3117.spec.js +++ b/test/e2e/tests/issues/3117.spec.js @@ -1,23 +1,23 @@ -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 = [] - 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 217f06a52e..11d77123fb 100644 --- a/test/e2e/tests/issues/3169.spec.js +++ b/test/e2e/tests/issues/3169.spec.js @@ -1,26 +1,30 @@ -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)) -} + 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 8ae45b9bfe..4a35f6087d 100644 --- a/test/e2e/tests/issues/3194.spec.js +++ b/test/e2e/tests/issues/3194.spec.js @@ -1,24 +1,34 @@ -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 = [] - 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 b5c02817f6..9ffcb90b6e 100644 --- a/test/e2e/tests/issues/3200.spec.js +++ b/test/e2e/tests/issues/3200.spec.js @@ -1,27 +1,31 @@ -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}) => { - 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 5ae52d6565..a07ef4be87 100644 --- a/test/e2e/tests/issues/3378.spec.js +++ b/test/e2e/tests/issues/3378.spec.js @@ -1,21 +1,23 @@ -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 = [] +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 954a339a78..519e71615f 100644 --- a/test/e2e/tests/issues/3448.spec.js +++ b/test/e2e/tests/issues/3448.spec.js @@ -1,17 +1,19 @@ -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}) => { - 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 2e638b6582..9ec0b0fc34 100644 --- a/test/e2e/tests/issues/3496.spec.js +++ b/test/e2e/tests/issues/3496.spec.js @@ -1,21 +1,27 @@ -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}) => { - 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 f2af4cadcb..84656c2bae 100644 --- a/test/e2e/tests/issues/3529.spec.js +++ b/test/e2e/tests/issues/3529.spec.js @@ -1,45 +1,48 @@ -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) +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 a39bd6ea3b..82372d1c4e 100644 --- a/test/e2e/tests/issues/3530.spec.js +++ b/test/e2e/tests/issues/3530.spec.js @@ -1,46 +1,52 @@ -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}) => { - 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 63497ca677..872e4277c5 100644 --- a/test/e2e/tests/issues/3612.spec.js +++ b/test/e2e/tests/issues/3612.spec.js @@ -1,15 +1,17 @@ -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}) => { - 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 698e2efe08..fb09e994e9 100644 --- a/test/e2e/tests/issues/3647.spec.js +++ b/test/e2e/tests/issues/3647.spec.js @@ -1,17 +1,19 @@ -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}) => { - 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 04a656ea86..b46fad30ee 100644 --- a/test/e2e/tests/issues/3651.spec.js +++ b/test/e2e/tests/issues/3651.spec.js @@ -1,14 +1,18 @@ -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}) => { - 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 cf7fffbe4a..4a02cc78c1 100644 --- a/test/e2e/tests/issues/3656.spec.js +++ b/test/e2e/tests/issues/3656.spec.js @@ -1,21 +1,25 @@ -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) +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() + 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 450d64fd43..a305ffbf3d 100644 --- a/test/e2e/tests/issues/3658.spec.js +++ b/test/e2e/tests/issues/3658.spec.js @@ -1,15 +1,17 @@ -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}) => { - 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 38a3a5f808..98f977ce59 100644 --- a/test/e2e/tests/issues/3681.spec.js +++ b/test/e2e/tests/issues/3681.spec.js @@ -1,22 +1,34 @@ -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}) => { -// 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 26cb302545..de000143ab 100644 --- a/test/e2e/tests/issues/3684.spec.js +++ b/test/e2e/tests/issues/3684.spec.js @@ -1,15 +1,15 @@ -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}) => { - 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 10ce107c22..2065054d75 100644 --- a/test/e2e/tests/issues/3686.spec.js +++ b/test/e2e/tests/issues/3686.spec.js @@ -1,21 +1,21 @@ -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}) => { - 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 53dceedb27..09f7d0252f 100644 --- a/test/e2e/tests/issues/3709.spec.js +++ b/test/e2e/tests/issues/3709.spec.js @@ -1,28 +1,40 @@ -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}) => { - 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 4e49583244..40c9c17047 100644 --- a/test/e2e/tests/issues/3719.spec.js +++ b/test/e2e/tests/issues/3719.spec.js @@ -1,20 +1,22 @@ -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}) => { - 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 af40f2e4e8..6552356744 100644 --- a/test/e2e/tests/js.spec.js +++ b/test/e2e/tests/js.spec.js @@ -1,94 +1,130 @@ -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") - 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 f6dc3d7630..ecfb655791 100644 --- a/test/e2e/tests/navigation.spec.js +++ b/test/e2e/tests/navigation.spec.js @@ -1,361 +1,449 @@ -const {test, expect} = require("../test-fixtures") -const {syncLV} = require("../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") - let 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 8ba89e276d..75208f3ddb 100644 --- a/test/e2e/tests/select.spec.js +++ b/test/e2e/tests/select.spec.js @@ -1,23 +1,23 @@ -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 -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 e4c786a67f..7bcddad335 100644 --- a/test/e2e/tests/streams.spec.js +++ b/test/e2e/tests/streams.spec.js @@ -1,841 +1,923 @@ -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} > *`) - .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(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"}, - {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(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"}, - {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(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"}, - {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 34aa71e2be..0d8fca3a17 100644 --- a/test/e2e/tests/uploads.spec.js +++ b/test/e2e/tests/uploads.spec.js @@ -1,292 +1,325 @@ -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) => { - 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); - 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) + 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); - 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.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 ca74f681e1..7fa156cded 100644 --- a/test/e2e/utils.js +++ b/test/e2e/utils.js @@ -1,84 +1,92 @@ -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), 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 -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) -const evalPlug = async (request, code) => { - return await request.post("/eval", { - data: {code} - }).then(resp => resp.json()) -} +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 // // 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 - } -} - -module.exports = {randomString, syncLV, evalLV, evalPlug, attributeMutations} + return promise; + }; +}; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000000..6d80cd0ced --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,32 @@ +{ + "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, + "emitDeclarationOnly": true, + "outDir": "./assets/js/types" + }, + "include": [ + "./assets/js/phoenix_live_view/*.js", + "./assets/js/phoenix_live_view/*.ts" + ], + "exclude": [ + "node_modules", + "assets/test/**/*" + ] +} \ No newline at end of file