From 1b9ebf70292e17e3a6d1fef5df33315598ae0701 Mon Sep 17 00:00:00 2001 From: Leif Metcalf Date: Tue, 12 Nov 2024 17:50:56 +1100 Subject: [PATCH] _ --- assets/js/app.js | 19 +- assets/tailwind.config.js | 15 +- .../vendor/{Sortable.min.js => Sortable.js} | 0 assets/vendor/phoenix_live_view.js | 5479 +++++++++++++++++ lib/munch/accounts/user.ex | 2 + lib/munch/errors.ex | 3 + lib/munch/lists.ex | 32 +- lib/munch/lists/item.ex | 7 +- lib/munch/lists/list.ex | 4 +- lib/munch/profile.ex | 114 + lib/munch/profile/featured_restaurant.ex | 22 + lib/munch/restaurants/restaurant.ex | 7 +- lib/munch_web/components/core_components.ex | 100 +- .../controllers/page_html/home.html.heex | 3 + lib/munch_web/live/item_live/form.ex | 93 - lib/munch_web/live/item_live/index.ex | 57 - lib/munch_web/live/item_live/show.ex | 42 - lib/munch_web/live/list_live/form.ex | 27 +- lib/munch_web/live/list_live/index.ex | 8 +- lib/munch_web/live/list_live/show.ex | 24 +- lib/munch_web/live/restaurant_live/form.ex | 5 +- lib/munch_web/live/restaurant_live/index.ex | 6 +- .../live/restaurant_live/select_component.ex | 53 +- lib/munch_web/live/restaurant_live/show.ex | 5 +- .../confirmation.ex} | 2 +- .../confirmation_instructions.ex} | 2 +- .../forgot_password.ex} | 2 +- .../login.ex} | 2 +- lib/munch_web/live/user_live/profile.ex | 48 + lib/munch_web/live/user_live/profile_form.ex | 48 + .../registration.ex} | 2 +- .../reset_password.ex} | 2 +- .../settings.ex} | 2 +- lib/munch_web/router.ex | 56 +- mix.exs | 3 +- mix.lock | 4 +- .../20241008001216_create_restaurants.exs | 7 +- .../20241008002021_create_lists.exs | 4 +- .../20241008010010_create_list_items.exs | 8 +- ...1112014159_create_featured_restaurants.exs | 19 + priv/repo/seeds.exs | 25 +- priv/static/images/logo.svg | 6 - test/munch/profile_test.exs | 59 + test/support/fixtures/profile_fixtures.ex | 20 + 44 files changed, 6061 insertions(+), 387 deletions(-) rename assets/vendor/{Sortable.min.js => Sortable.js} (100%) create mode 100644 assets/vendor/phoenix_live_view.js create mode 100644 lib/munch/errors.ex create mode 100644 lib/munch/profile.ex create mode 100644 lib/munch/profile/featured_restaurant.ex delete mode 100644 lib/munch_web/live/item_live/form.ex delete mode 100644 lib/munch_web/live/item_live/index.ex delete mode 100644 lib/munch_web/live/item_live/show.ex rename lib/munch_web/live/{user_confirmation_live.ex => user_live/confirmation.ex} (97%) rename lib/munch_web/live/{user_confirmation_instructions_live.ex => user_live/confirmation_instructions.ex} (96%) rename lib/munch_web/live/{user_forgot_password_live.ex => user_live/forgot_password.ex} (96%) rename lib/munch_web/live/{user_login_live.ex => user_live/login.ex} (97%) create mode 100644 lib/munch_web/live/user_live/profile.ex create mode 100644 lib/munch_web/live/user_live/profile_form.ex rename lib/munch_web/live/{user_registration_live.ex => user_live/registration.ex} (98%) rename lib/munch_web/live/{user_reset_password_live.ex => user_live/reset_password.ex} (98%) rename lib/munch_web/live/{user_settings_live.ex => user_live/settings.ex} (99%) create mode 100644 priv/repo/migrations/20241112014159_create_featured_restaurants.exs delete mode 100644 priv/static/images/logo.svg create mode 100644 test/munch/profile_test.exs create mode 100644 test/support/fixtures/profile_fixtures.ex diff --git a/assets/js/app.js b/assets/js/app.js index 9489fb4..9168074 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -19,21 +19,14 @@ import "phoenix_html" // Establish Phoenix Socket and LiveView configuration. import { Socket } from "../../../../" -import { LiveSocket } from "phoenix_live_view" +import { LiveSocket } from "../vendor/phoenix_live_view" import topbar from "../vendor/topbar" -import Sortable from "../vendor/Sortable.min" +import Sortable from "../vendor/Sortable" let Hooks = { SortableInputs: { mounted() { - // SortableJS triggers change events on the parent element, but LiveView errors if a - // change event is triggered on a non-input element, so we suppress the change event - // and use a proxy input element to send the change event to LiveView. - this.el.addEventListener('change', (e) => { - e.stopPropagation() - e.preventDefault() - }); let proxy = document.createElement('input'); proxy.type = 'hidden'; proxy.name = 'sortable-change-proxy'; @@ -49,6 +42,14 @@ let Hooks = { } } +window.addEventListener("munch:show-modal", (e) => { + e.target.showModal() +}) + +window.addEventListener("munch:close-modal", (e) => { + e.target.close() +}) + let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") let liveSocket = new LiveSocket("/live", Socket, { longPollFallbackMs: 2500, diff --git a/assets/tailwind.config.js b/assets/tailwind.config.js index ef27be0..70aff0b 100644 --- a/assets/tailwind.config.js +++ b/assets/tailwind.config.js @@ -14,7 +14,6 @@ module.exports = { theme: { extend: { colors: { - brand: "#FD4F00", } }, }, @@ -25,14 +24,14 @@ module.exports = { // //
// - plugin(({addVariant}) => addVariant("phx-click-loading", [".phx-click-loading&", ".phx-click-loading &"])), - plugin(({addVariant}) => addVariant("phx-submit-loading", [".phx-submit-loading&", ".phx-submit-loading &"])), - plugin(({addVariant}) => addVariant("phx-change-loading", [".phx-change-loading&", ".phx-change-loading &"])), + plugin(({ addVariant }) => addVariant("phx-click-loading", [".phx-click-loading&", ".phx-click-loading &"])), + plugin(({ addVariant }) => addVariant("phx-submit-loading", [".phx-submit-loading&", ".phx-submit-loading &"])), + plugin(({ addVariant }) => addVariant("phx-change-loading", [".phx-change-loading&", ".phx-change-loading &"])), // Embeds Heroicons (https://heroicons.com) into your app.css bundle // See your `CoreComponents.icon/1` for more information. // - plugin(function({matchComponents, theme}) { + plugin(function ({ matchComponents, theme }) { let iconsDir = path.join(__dirname, "../deps/heroicons/optimized") let values = {} let icons = [ @@ -44,11 +43,11 @@ module.exports = { icons.forEach(([suffix, dir]) => { fs.readdirSync(path.join(iconsDir, dir)).forEach(file => { let name = path.basename(file, ".svg") + suffix - values[name] = {name, fullPath: path.join(iconsDir, dir, file)} + values[name] = { name, fullPath: path.join(iconsDir, dir, file) } }) }) matchComponents({ - "hero": ({name, fullPath}) => { + "hero": ({ name, fullPath }) => { let content = fs.readFileSync(fullPath).toString().replace(/\r?\n|\r/g, "") let size = theme("spacing.6") if (name.endsWith("-mini")) { @@ -68,7 +67,7 @@ module.exports = { "height": size } } - }, {values}) + }, { values }) }) ] } diff --git a/assets/vendor/Sortable.min.js b/assets/vendor/Sortable.js similarity index 100% rename from assets/vendor/Sortable.min.js rename to assets/vendor/Sortable.js diff --git a/assets/vendor/phoenix_live_view.js b/assets/vendor/phoenix_live_view.js new file mode 100644 index 0000000..0f4ca5f --- /dev/null +++ b/assets/vendor/phoenix_live_view.js @@ -0,0 +1,5479 @@ +// js/phoenix_live_view/constants.js +var CONSECUTIVE_RELOADS = "consecutive-reloads"; +var MAX_RELOADS = 10; +var RELOAD_JITTER_MIN = 5e3; +var RELOAD_JITTER_MAX = 1e4; +var FAILSAFE_JITTER = 3e4; +var 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" +]; +var PHX_COMPONENT = "data-phx-component"; +var PHX_LIVE_LINK = "data-phx-link"; +var PHX_TRACK_STATIC = "track-static"; +var PHX_LINK_STATE = "data-phx-link-state"; +var PHX_REF_LOADING = "data-phx-ref-loading"; +var PHX_REF_SRC = "data-phx-ref-src"; +var PHX_REF_LOCK = "data-phx-ref-lock"; +var PHX_TRACK_UPLOADS = "track-uploads"; +var PHX_UPLOAD_REF = "data-phx-upload-ref"; +var PHX_PREFLIGHTED_REFS = "data-phx-preflighted-refs"; +var PHX_DONE_REFS = "data-phx-done-refs"; +var PHX_DROP_TARGET = "drop-target"; +var PHX_ACTIVE_ENTRY_REFS = "data-phx-active-refs"; +var PHX_LIVE_FILE_UPDATED = "phx:live-file:updated"; +var PHX_SKIP = "data-phx-skip"; +var PHX_MAGIC_ID = "data-phx-id"; +var PHX_PRUNE = "data-phx-prune"; +var PHX_CONNECTED_CLASS = "phx-connected"; +var PHX_LOADING_CLASS = "phx-loading"; +var PHX_ERROR_CLASS = "phx-error"; +var PHX_CLIENT_ERROR_CLASS = "phx-client-error"; +var PHX_SERVER_ERROR_CLASS = "phx-server-error"; +var PHX_PARENT_ID = "data-phx-parent-id"; +var PHX_MAIN = "data-phx-main"; +var PHX_ROOT_ID = "data-phx-root-id"; +var PHX_VIEWPORT_TOP = "viewport-top"; +var PHX_VIEWPORT_BOTTOM = "viewport-bottom"; +var PHX_TRIGGER_ACTION = "trigger-action"; +var PHX_HAS_FOCUSED = "phx-has-focused"; +var FOCUSABLE_INPUTS = ["text", "textarea", "number", "email", "password", "search", "tel", "url", "date", "time", "datetime-local", "color", "range"]; +var CHECKABLE_INPUTS = ["checkbox", "radio"]; +var PHX_HAS_SUBMITTED = "phx-has-submitted"; +var PHX_SESSION = "data-phx-session"; +var PHX_VIEW_SELECTOR = `[${PHX_SESSION}]`; +var PHX_STICKY = "data-phx-sticky"; +var PHX_STATIC = "data-phx-static"; +var PHX_READONLY = "data-phx-readonly"; +var PHX_DISABLED = "data-phx-disabled"; +var PHX_DISABLE_WITH = "disable-with"; +var PHX_DISABLE_WITH_RESTORE = "data-phx-disable-with-restore"; +var PHX_HOOK = "hook"; +var PHX_DEBOUNCE = "debounce"; +var PHX_THROTTLE = "throttle"; +var PHX_UPDATE = "update"; +var PHX_STREAM = "stream"; +var PHX_STREAM_REF = "data-phx-stream"; +var PHX_KEY = "key"; +var PHX_PRIVATE = "phxPrivate"; +var PHX_AUTO_RECOVER = "auto-recover"; +var PHX_LV_DEBUG = "phx:live-socket:debug"; +var PHX_LV_PROFILE = "phx:live-socket:profiling"; +var PHX_LV_LATENCY_SIM = "phx:live-socket:latency-sim"; +var PHX_PROGRESS = "progress"; +var PHX_MOUNTED = "mounted"; +var PHX_RELOAD_STATUS = "__phoenix_reload_status__"; +var LOADER_TIMEOUT = 1; +var MAX_CHILD_JOIN_ATTEMPTS = 3; +var BEFORE_UNLOAD_LOADER_TIMEOUT = 200; +var BINDING_PREFIX = "phx-"; +var PUSH_TIMEOUT = 3e4; +var DEBOUNCE_TRIGGER = "debounce-trigger"; +var THROTTLED = "throttled"; +var DEBOUNCE_PREV_KEY = "debounce-prev-key"; +var DEFAULTS = { + debounce: 300, + throttle: 300 +}; +var PHX_PENDING_ATTRS = [PHX_REF_LOADING, PHX_REF_SRC, PHX_REF_LOCK]; +var DYNAMICS = "d"; +var STATIC = "s"; +var ROOT = "r"; +var COMPONENTS = "c"; +var EVENTS = "e"; +var REPLY = "r"; +var TITLE = "t"; +var TEMPLATES = "p"; +var STREAM = "stream"; + +// js/phoenix_live_view/entry_uploader.js +var EntryUploader = class { + constructor(entry, chunkSize, liveSocket) { + this.liveSocket = liveSocket; + this.entry = entry; + this.offset = 0; + this.chunkSize = chunkSize; + 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); + } + 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; + } + readNextChunk() { + let reader = new window.FileReader(); + let 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); + } else { + return logError("Read error: " + e.target.error); + } + }; + reader.readAsArrayBuffer(blob); + } + pushChunk(chunk) { + if (!this.uploadChannel.isJoined()) { + return; + } + this.uploadChannel.push("chunk", chunk).receive("ok", () => { + 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)); + } +}; + +// js/phoenix_live_view/utils.js +var logError = (msg, obj) => console.error && console.error(msg, obj); +var isCid = (cid) => { + let type = typeof cid; + return type === "number" || type === "string" && /^(0|[1-9]\d*)$/.test(cid); +}; +function detectDuplicateIds() { + let ids = /* @__PURE__ */ 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.`); + } else { + ids.add(elems[i].id); + } + } +} +var debug = (view, kind, msg, obj) => { + if (view.liveSocket.isDebugEnabled()) { + console.log(`${view.id} ${kind}: ${msg} - `, obj); + } +}; +var closure = (val) => typeof val === "function" ? val : function() { + return val; +}; +var clone = (obj) => { + return JSON.parse(JSON.stringify(obj)); +}; +var 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; +}; +var isObject = (obj) => { + return obj !== null && typeof obj === "object" && !(obj instanceof Array); +}; +var isEqualObj = (obj1, obj2) => JSON.stringify(obj1) === JSON.stringify(obj2); +var isEmpty = (obj) => { + for (let x in obj) { + return false; + } + return true; +}; +var maybe = (el, callback) => el && callback(el); +var channelUploader = function(entries, onError, resp, liveSocket) { + entries.forEach((entry) => { + let entryUploader = new EntryUploader(entry, resp.config.chunk_size, liveSocket); + entryUploader.upload(); + }); +}; + +// js/phoenix_live_view/browser.js +var Browser = { + canPushState() { + return typeof history.pushState !== "undefined"; + }, + 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; + }, + 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); + }, + pushState(kind, meta, to) { + if (this.canPushState()) { + if (to !== window.location.href) { + if (meta.type == "redirect" && meta.scroll) { + let currentState = history.state || {}; + currentState.scroll = meta.scroll; + history.replaceState(currentState, "", window.location.href); + } + delete meta.scroll; + history[kind + "State"](meta, "", to || null); + window.requestAnimationFrame(() => { + let hashEl = this.getHashTargetEl(window.location.hash); + if (hashEl) { + hashEl.scrollIntoView(); + } else if (meta.type === "redirect") { + window.scroll(0, 0); + } + }); + } + } else { + this.redirect(to); + } + }, + setCookie(name, value, maxAgeSeconds) { + let 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"); + }, + deleteCookie(name) { + document.cookie = `${name}=; max-age=-1; path=/`; + }, + redirect(toURL, flash) { + if (flash) { + this.setCookie("__phoenix_flash__", flash, 60); + } + window.location = toURL; + }, + 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}"]`); + } +}; +var browser_default = Browser; + +// js/phoenix_live_view/aria.js +var ARIA = { + anyOf(instance, classes) { + return classes.find((name) => instance instanceof name); + }, + isFocusable(el, interactiveOnly) { + return el instanceof HTMLAnchorElement && el.rel !== "ignore" || el instanceof HTMLAreaElement && el.href !== void 0 || !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 (e) { + } + } + 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; + } + child = child.nextElementSibling; + } + }, + focusFirst(el) { + let child = el.firstElementChild; + while (child) { + if (this.attemptFocus(child) || this.focusFirst(child)) { + return true; + } + child = child.nextElementSibling; + } + }, + focusLast(el) { + let child = el.lastElementChild; + while (child) { + if (this.attemptFocus(child) || this.focusLast(child)) { + return true; + } + child = child.previousElementSibling; + } + } +}; +var aria_default = ARIA; + +// js/phoenix_live_view/js.js +var focusStack = []; +var default_transition_time = 200; +var 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]]; + commands.forEach(([kind, args]) => { + if (kind === defaultKind) { + 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); + }); + }); + }, + 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; + 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 nodes = to ? dom_default.all(document, to) : [sourceEl]; + nodes.forEach((node) => { + let encodedJS = node.getAttribute(attr); + if (!encodedJS) { + throw new Error(`expected ${attr} to contain JS command on "${to}"`); + } + view.liveSocket.execJS(node, encodedJS, eventType); + }); + }, + exec_dispatch(e, eventType, phxEvent, view, sourceEl, el, { to, event, detail, bubbles }) { + detail = detail || {}; + detail.dispatcher = sourceEl; + dom_default.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; + view.withinTargets(phxTarget, (targetView, targetCtx) => { + if (!targetView.isConnected()) { + return; + } + if (eventType === "change") { + let { newCid, _target } = args; + _target = _target || (dom_default.isFormInput(sourceEl) ? sourceEl.name : void 0); + 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); + } else { + targetView.pushEvent(eventType, sourceEl, targetCtx, event || phxEvent, data, pushOpts, callback); + } + }); + }, + 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_focus(e, eventType, phxEvent, view, sourceEl, el) { + window.requestAnimationFrame(() => aria_default.attemptFocus(el)); + }, + exec_focus_first(e, eventType, phxEvent, view, sourceEl, el) { + window.requestAnimationFrame(() => aria_default.focusFirstInteractive(el) || aria_default.focusFirst(el)); + }, + exec_push_focus(e, eventType, phxEvent, view, sourceEl, el) { + window.requestAnimationFrame(() => focusStack.push(el || sourceEl)); + }, + exec_pop_focus(e, eventType, phxEvent, view, sourceEl, el) { + window.requestAnimationFrame(() => { + const el2 = focusStack.pop(); + if (el2) { + el2.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, { to, 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_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]); + }, + // 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); + } + }, + 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)); + window.requestAnimationFrame(() => { + this.addOrRemoveClasses(el, outClasses, []); + window.requestAnimationFrame(() => this.addOrRemoveClasses(el, outEndClasses, outStartClasses)); + }); + }; + let onEnd = () => { + this.addOrRemoveClasses(el, [], outClasses.concat(outEndClasses)); + dom_default.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); + } + } else { + if (eventType === "remove") { + return; + } + let onStart = () => { + this.addOrRemoveClasses(el, inStartClasses, outClasses.concat(outStartClasses).concat(outEndClasses)); + let stickyDisplay = display || this.defaultDisplay(el); + dom_default.putSticky(el, "toggle", (currentEl) => currentEl.style.display = stickyDisplay); + window.requestAnimationFrame(() => { + this.addOrRemoveClasses(el, inClasses, []); + window.requestAnimationFrame(() => 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); + } else { + view.transition(time, onStart, onEnd); + } + } + } else { + if (this.isVisible(el)) { + window.requestAnimationFrame(() => { + el.dispatchEvent(new Event("phx:hide-start")); + dom_default.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_default.putSticky(el, "toggle", (currentEl) => currentEl.style.display = stickyDisplay); + el.dispatchEvent(new Event("phx:show-end")); + }); + } + } + }, + toggleClasses(el, classes, transition, time, view, blocking) { + window.requestAnimationFrame(() => { + let [prevAdds, prevRemoves] = dom_default.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 !== void 0) { + if (el.getAttribute(attr) === val1) { + this.setOrRemoveAttrs(el, [[attr, val2]], []); + } else { + this.setOrRemoveAttrs(el, [[attr, val1]], []); + } + } else { + this.setOrRemoveAttrs(el, [], [attr]); + } + } else { + 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)); + 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); + } else { + view.transition(time, onStart, onDone); + } + return; + } + window.requestAnimationFrame(() => { + let [prevAdds, prevRemoves] = dom_default.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_default.putSticky(el, "classes", (currentEl) => { + currentEl.classList.remove(...newRemoves); + currentEl.classList.add(...newAdds); + return [newAdds, newRemoves]; + }); + }); + }, + setOrRemoveAttrs(el, sets, removes) { + let [prevSets, prevRemoves] = dom_default.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_default.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 }) { + 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); + } + }; + return to ? liveSocket.jsQuerySelectorAll(sourceEl, to, defaultQuery) : [sourceEl]; + }, + defaultDisplay(el) { + return { tr: "table-row", td: "table-cell" }[el.tagName.toLowerCase()] || "block"; + }, + 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]; + } +}; +var js_default = JS; + +// js/phoenix_live_view/dom.js +var 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"); + } + }, + all(node, query, callback) { + if (!node) { + return []; + } + let array = Array.from(node.querySelectorAll(query)); + return callback ? array.forEach(callback) : array; + }, + childNodeLength(html) { + let template = document.createElement("template"); + template.innerHTML = html; + return template.content.childElementCount; + }, + isUploadInput(el) { + return el.type === "file" && el.getAttribute(PHX_UPLOAD_REF) !== null; + }, + 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); + }, + findComponentNodeList(node, cid) { + return this.filterWithinSameLiveView(this.all(node, `[${PHX_COMPONENT}="${cid}"]`), node); + }, + 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; + }, + isUnloadableFormSubmit(e) { + let isDialogSubmit = e.target && e.target.getAttribute("method") === "dialog" || e.submitter && e.submitter.getAttribute("formmethod") === "dialog"; + if (isDialogSubmit) { + return false; + } else { + return !e.defaultPrevented && !this.wantsNewTab(e); + } + }, + isNewPageClick(e, currentLocation) { + let 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; + } + try { + url = new URL(href); + } catch (e2) { + try { + url = new URL(href, currentLocation); + } catch (e3) { + 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("#"); + } + } + return url.protocol.startsWith("http"); + }, + 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); + }, + 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; + }, + findPhxSticky(el) { + return this.all(el, `[${PHX_STICKY}]`); + }, + findPhxChildren(el, parentId) { + return this.all(el, `${PHX_VIEW_SELECTOR}[${PHX_PARENT_ID}="${parentId}"]`); + }, + findExistingParentCIDs(node, cids) { + let parentCids = /* @__PURE__ */ new Set(); + let childrenCids = /* @__PURE__ */ new Set(); + cids.forEach((cid) => { + this.filterWithinSameLiveView(this.all(node, `[${PHX_COMPONENT}="${cid}"]`), node).forEach((parent) => { + parentCids.add(cid); + this.all(parent, `[${PHX_COMPONENT}]`).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; + } + }, + 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]; + }, + putPrivate(el, key, value) { + if (!el[PHX_PRIVATE]) { + el[PHX_PRIVATE] = {}; + } + el[PHX_PRIVATE][key] = value; + }, + updatePrivate(el, key, defaultVal, updateFunc) { + let existing = this.private(el, key); + if (existing === void 0) { + this.putPrivate(el, key, updateFunc(defaultVal)); + } else { + this.putPrivate(el, key, updateFunc(existing)); + } + }, + 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 } = titleEl.dataset; + document.title = `${prefix || ""}${str}${suffix || ""}`; + } else { + 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(); + case "blur": + if (this.once(el, "debounce-blur")) { + el.addEventListener("blur", () => { + if (asyncFilter()) { + callback(); + } + }); + } + 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; + } + if (!newKeyDown && this.private(el, THROTTLED)) { + return false; + } else { + callback(); + const t = setTimeout(() => { + if (asyncFilter()) { + this.triggerCycle(el, DEBOUNCE_TRIGGER); + } + }, timeout); + this.putPrivate(el, THROTTLED, t); + } + } else { + setTimeout(() => { + if (asyncFilter()) { + this.triggerCycle(el, DEBOUNCE_TRIGGER, currentCycle); + } + }, timeout); + } + let 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); + }); + }); + } + if (this.once(el, "bind-debounce")) { + el.addEventListener("blur", () => { + 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(); + } + }, + 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; + }, + // 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) { + if (fromEl.hasAttribute && fromEl.hasAttribute("data-phx-hook") && !toEl.hasAttribute("data-phx-hook")) { + toEl.setAttribute("data-phx-hook", fromEl.getAttribute("data-phx-hook")); + } + 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", ""); + } 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); + }, + 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)); + }, + resetForm(form) { + Array.from(form.elements).forEach((input) => { + this.deletePrivate(input, PHX_HAS_FOCUSED); + this.deletePrivate(input, PHX_HAS_SUBMITTED); + }); + }, + isPhxChild(node) { + return node.getAttribute && node.getAttribute(PHX_PARENT_ID); + }, + isPhxSticky(node) { + return node.getAttribute && node.getAttribute(PHX_STICKY) !== null; + }, + isChildOfAny(el, parents) { + return !!parents.find((parent) => parent.contains(el)); + }, + firstPhxChild(el) { + return this.isPhxChild(el) ? el : this.all(el, `[${PHX_PARENT_ID}]`)[0]; + }, + 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 === void 0 ? defaultBubble : !!opts.bubbles; + let eventOpts = { bubbles, cancelable: true, detail: opts.detail || {} }; + let 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; + } + }, + // 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); + } + } else { + if (name === "value" && target.value === source.value) { + 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); + } + } else { + if (!source.hasAttribute(name)) { + target.removeAttribute(name); + } + } + } + }, + mergeFocusedInput(target, source) { + if (!(target instanceof HTMLSelectElement)) { + DOM.mergeAttrs(target, source, { exclude: ["value"] }); + } + if (source.readOnly) { + target.setAttribute("readonly", true); + } else { + target.removeAttribute("readonly"); + } + }, + 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; + } + let 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"; + }, + 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; + }, + isNowTriggerFormExternal(el, phxTriggerExternal) { + return el.getAttribute && el.getAttribute(phxTriggerExternal) !== null; + }, + cleanChildNodes(container, phxUpdate) { + if (DOM.isPhxUpdate(container, phxUpdate, ["append", "prepend"])) { + let toRemove = []; + container.childNodes.forEach((childNode) => { + if (!childNode.id) { + 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. + +removing illegal node: "${(childNode.outerHTML || childNode.nodeValue).trim()}" + +`); + } + toRemove.push(childNode); + } + }); + toRemove.forEach((childNode) => childNode.remove()); + } + }, + replaceRootContainer(container, tagName, attrs) { + let retainedAttrs = /* @__PURE__ */ 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)); + Object.keys(attrs).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; + } + }, + getSticky(el, name, defaultVal) { + let op = (DOM.private(el, "sticky") || []).find(([existingName]) => name === existingName); + if (op) { + let [_name, _op, stashedResult] = op; + return stashedResult; + } else { + return typeof defaultVal === "function" ? defaultVal() : defaultVal; + } + }, + 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]; + } else { + ops.push([name, op, stashedResult]); + } + return ops; + }); + }, + applyStickyOperations(el) { + let ops = DOM.private(el, "sticky"); + if (!ops) { + return; + } + ops.forEach(([name, op, _stashed]) => this.putSticky(el, name, op)); + } +}; +var dom_default = DOM; + +// js/phoenix_live_view/upload_entry.js +var UploadEntry = class { + static isActive(fileEl, file) { + let isNew = file._phxRef === void 0; + let activeRefs = fileEl.getAttribute(PHX_ACTIVE_ENTRY_REFS).split(","); + let 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 isPreflightInProgress(file) { + return 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; + } + 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; + this.view.pushFileProgress(this.fileEl, this.ref, 100, () => { + LiveUploader.untrackFile(this.fileEl, this.file); + this._onDone(); + }); + } else { + this._lastProgressSent = this._progress; + this.view.pushFileProgress(this.fileEl, this.ref, this._progress); + } + } + } + isCancelled() { + return this._isCancelled; + } + cancel() { + this.file._preflightInProgress = false; + this._isCancelled = true; + this._isDone = true; + this._onDone(); + } + 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); + } + } + isAutoUpload() { + return this.autoUpload; + } + //private + onDone(callback) { + this._onDone = () => { + 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(); + } + } + toPreflightPayload() { + return { + last_modified: this.file.lastModified, + name: this.file.name, + relative_path: this.file.webkitRelativePath, + size: this.file.size, + type: this.file.type, + ref: this.ref, + meta: typeof this.file.meta === "function" ? this.file.meta() : void 0 + }; + } + 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 }; + } else { + 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 }); + } + } +}; + +// js/phoenix_live_view/live_uploader.js +var liveUploaderFileRef = 0; +var LiveUploader = class _LiveUploader { + static genFileRef(file) { + let ref = file._phxRef; + if (ref !== void 0) { + return ref; + } else { + file._phxRef = (liveUploaderFileRef++).toString(); + return file._phxRef; + } + } + static getEntryDataURL(inputEl, ref, callback) { + let file = this.activeFiles(inputEl).find((file2) => this.genFileRef(file2) === ref); + callback(URL.createObjectURL(file)); + } + static hasUploadsInProgress(formEl) { + let active = 0; + dom_default.findUploadInputs(formEl).forEach((input) => { + if (input.getAttribute(PHX_PREFLIGHTED_REFS) !== input.getAttribute(PHX_DONE_REFS)) { + active++; + } + }); + 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_default.putPrivate(inputEl, "files", []); + } + static untrackFile(inputEl, file) { + dom_default.putPrivate(inputEl, "files", dom_default.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_default.updatePrivate(inputEl, "files", [], (existing) => existing.concat(newFiles)); + inputEl.value = null; + } else { + if (dataTransfer && dataTransfer.files.length > 0) { + inputEl.files = dataTransfer.files; + } + dom_default.putPrivate(inputEl, "files", files); + } + } + static activeFileInputs(formEl) { + let fileInputs = dom_default.findUploadInputs(formEl); + return Array.from(fileInputs).filter((el) => el.files && this.activeFiles(el).length > 0); + } + static activeFiles(input) { + return (dom_default.private(input, "files") || []).filter((f) => UploadEntry.isActive(input, f)); + } + static inputsAwaitingPreflight(formEl) { + let fileInputs = dom_default.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 markPreflightInProgress(entries) { + entries.forEach((entry) => UploadEntry.markPreflightInProgress(entry.file)); + } + constructor(inputEl, view, onComplete) { + this.autoUpload = dom_default.isAutoUpload(inputEl); + this.view = view; + this.onComplete = onComplete; + this._entries = Array.from(_LiveUploader.filesAwaitingPreflight(inputEl) || []).map((file) => new UploadEntry(inputEl, file, view, this.autoUpload)); + _LiveUploader.markPreflightInProgress(this._entries); + this.numEntriesInProgress = this._entries.length; + } + isAutoUpload() { + return this.autoUpload; + } + 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(); + } + }); + } + 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, entries: [] }; + acc[name].entries.push(entry); + return acc; + }, {}); + for (let name in groupedEntries) { + let { callback, entries } = groupedEntries[name]; + callback(entries, onError, resp, liveSocket); + } + } +}; + +// js/phoenix_live_view/hooks.js +var Hooks = { + LiveFileUpload: { + activeRefs() { + return this.el.getAttribute(PHX_ACTIVE_ENTRY_REFS); + }, + preflightedRefs() { + return this.el.getAttribute(PHX_PREFLIGHTED_REFS); + }, + 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); + } + } + 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; + }); + }, + destroyed() { + URL.revokeObjectURL(this.url); + } + }, + FocusWrap: { + mounted() { + this.focusStart = this.el.firstElementChild; + this.focusEnd = this.el.lastElementChild; + this.focusStart.addEventListener("focus", () => aria_default.focusLast(this.el)); + this.focusEnd.addEventListener("focus", () => aria_default.focusFirst(this.el)); + this.el.addEventListener("phx:show-end", () => this.el.focus()); + if (window.getComputedStyle(this.el).display !== "none") { + aria_default.focusFirst(this.el); + } + } + } +}; +var findScrollContainer = (el) => { + if (["HTML", "BODY"].indexOf(el.nodeName.toUpperCase()) >= 0) + return null; + if (["scroll", "auto"].indexOf(getComputedStyle(el).overflowY) >= 0) + return el; + return findScrollContainer(el.parentElement); +}; +var scrollTop = (scrollContainer) => { + if (scrollContainer) { + return scrollContainer.scrollTop; + } else { + return document.documentElement.scrollTop || document.body.scrollTop; + } +}; +var bottom = (scrollContainer) => { + if (scrollContainer) { + return scrollContainer.getBoundingClientRect().bottom; + } else { + return window.innerHeight || document.documentElement.clientHeight; + } +}; +var top = (scrollContainer) => { + if (scrollContainer) { + return scrollContainer.getBoundingClientRect().top; + } else { + return 0; + } +}; +var 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); +}; +var 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); +}; +var 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); +}; +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.execJSHookPush(this.el, topEvent, { id: firstChild.id, _overran: true }, () => { + pendingOp = null; + }); + }); + let onFirstChildAtTop = this.throttle(throttleInterval, (topEvent, firstChild) => { + pendingOp = () => firstChild.scrollIntoView({ block: "start" }); + this.liveSocket.execJSHookPush(this.el, topEvent, { id: firstChild.id }, () => { + pendingOp = null; + 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.execJSHookPush(this.el, bottomEvent, { id: lastChild.id }, () => { + pendingOp = null; + window.requestAnimationFrame(() => { + if (!isWithinViewport(lastChild, this.scrollContainer)) { + lastChild.scrollIntoView({ block: "end" }); + } + }); + }); + }); + this.onScroll = (_e) => { + let scrollNow = scrollTop(this.scrollContainer); + if (pendingOp) { + scrollBefore = scrollNow; + return pendingOp(); + } + let rect = this.el.getBoundingClientRect(); + let topEvent = this.el.getAttribute(this.liveSocket.binding("viewport-top")); + let bottomEvent = this.el.getAttribute(this.liveSocket.binding("viewport-bottom")); + let lastChild = this.el.lastElementChild; + let firstChild = this.el.firstElementChild; + let isScrollingUp = scrollNow < scrollBefore; + let isScrollingDown = scrollNow > scrollBefore; + 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); + } + scrollBefore = scrollNow; + }; + if (this.scrollContainer) { + this.scrollContainer.addEventListener("scroll", this.onScroll); + } else { + window.addEventListener("scroll", this.onScroll); + } + }, + destroyed() { + if (this.scrollContainer) { + this.scrollContainer.removeEventListener("scroll", this.onScroll); + } else { + window.removeEventListener("scroll", this.onScroll); + } + }, + throttle(interval, callback) { + let lastCallAt = 0; + let timer; + return (...args) => { + let now = Date.now(); + let remainingTime = interval - (now - lastCallAt); + if (remainingTime <= 0 || remainingTime > interval) { + if (timer) { + clearTimeout(timer); + timer = null; + } + lastCallAt = now; + callback(...args); + } else if (!timer) { + timer = setTimeout(() => { + lastCallAt = Date.now(); + timer = null; + callback(...args); + }, remainingTime); + } + }; + } +}; +var hooks_default = Hooks; + +// js/phoenix_live_view/element_ref.js +var ElementRef = class { + 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; + } + this.undoLocks(ref, phxEvent, eachCloneCallback); + this.undoLoading(ref, phxEvent); + 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)); + } + // Check for cloned PHX_REF_LOCK element that has been morphed behind + // the scenes while this element was locked in the DOM. + // When we apply the cloned tree to the active DOM element, we must + // + // 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_default.private(this.el, PHX_REF_LOCK); + if (clonedTree) { + eachCloneCallback(clonedTree); + dom_default.deletePrivate(this.el, PHX_REF_LOCK); + } + this.el.removeAttribute(PHX_REF_LOCK); + let opts = { detail: { 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"); + } + 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 (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); + } + let 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, event: phxEvent }, bubbles: true, cancelable: false }; + this.el.dispatchEvent(new CustomEvent(`phx:undo-loading:${this.loadingRef}`, opts)); + } + PHX_EVENT_CLASSES.forEach((name) => { + if (name !== "phx-submit-loading" || this.canUndoLoading(ref)) { + dom_default.removeClass(this.el, name); + } + }); + } + 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); + } + // only remove the phx-submit-loading class if we are not locked + canUndoLoading(ref) { + return this.lockRef === null || this.lockRef <= ref; + } +}; + +// js/phoenix_live_view/dom_post_morph_restorer.js +var DOMPostMorphRestorer = class { + constructor(containerBefore, containerAfter, updateType) { + let idsBefore = /* @__PURE__ */ new Set(); + let idsAfter = new Set([...containerAfter.children].map((child) => child.id)); + let elementsToModify = []; + Array.from(containerBefore.children).forEach((child) => { + if (child.id) { + idsBefore.add(child.id); + if (idsAfter.has(child.id)) { + let previousElementId = child.previousElementSibling && child.previousElementSibling.id; + elementsToModify.push({ elementId: child.id, previousElementId }); + } + } + }); + 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: + // 1) Track ids of modified elements & of new elements + // 2) All the modified elements are put back in the correct position in the DOM tree + // 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_default.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); + } + }); + }); + } else { + maybe(document.getElementById(elementToModify.elementId), (elem) => { + let 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)); + }); + } + } +}; + +// node_modules/morphdom/dist/morphdom-esm.js +var DOCUMENT_FRAGMENT_NODE = 11; +function morphAttrs(fromNode, toNode) { + var toNodeAttrs = toNode.attributes; + var attr; + var attrName; + var attrNamespaceURI; + var attrValue; + var fromValue; + if (toNode.nodeType === DOCUMENT_FRAGMENT_NODE || fromNode.nodeType === DOCUMENT_FRAGMENT_NODE) { + return; + } + for (var i = toNodeAttrs.length - 1; i >= 0; i--) { + attr = toNodeAttrs[i]; + attrName = attr.name; + attrNamespaceURI = attr.namespaceURI; + attrValue = attr.value; + if (attrNamespaceURI) { + attrName = attr.localName || attrName; + fromValue = fromNode.getAttributeNS(attrNamespaceURI, attrName); + if (fromValue !== attrValue) { + if (attr.prefix === "xmlns") { + attrName = attr.name; + } + fromNode.setAttributeNS(attrNamespaceURI, attrName, attrValue); + } + } else { + fromValue = fromNode.getAttribute(attrName); + if (fromValue !== attrValue) { + fromNode.setAttribute(attrName, attrValue); + } + } + } + var fromNodeAttrs = fromNode.attributes; + for (var d = fromNodeAttrs.length - 1; d >= 0; d--) { + attr = fromNodeAttrs[d]; + attrName = attr.name; + attrNamespaceURI = attr.namespaceURI; + if (attrNamespaceURI) { + attrName = attr.localName || attrName; + if (!toNode.hasAttributeNS(attrNamespaceURI, attrName)) { + fromNode.removeAttributeNS(attrNamespaceURI, attrName); + } + } else { + if (!toNode.hasAttribute(attrName)) { + fromNode.removeAttribute(attrName); + } + } + } +} +var range; +var NS_XHTML = "http://www.w3.org/1999/xhtml"; +var doc = typeof document === "undefined" ? void 0 : document; +var HAS_TEMPLATE_SUPPORT = !!doc && "content" in doc.createElement("template"); +var HAS_RANGE_SUPPORT = !!doc && doc.createRange && "createContextualFragment" in doc.createRange(); +function createFragmentFromTemplate(str) { + var template = doc.createElement("template"); + template.innerHTML = str; + return template.content.childNodes[0]; +} +function createFragmentFromRange(str) { + if (!range) { + range = doc.createRange(); + range.selectNode(doc.body); + } + var fragment = range.createContextualFragment(str); + return fragment.childNodes[0]; +} +function createFragmentFromWrap(str) { + var fragment = doc.createElement("body"); + fragment.innerHTML = str; + return fragment.childNodes[0]; +} +function toElement(str) { + str = str.trim(); + if (HAS_TEMPLATE_SUPPORT) { + return createFragmentFromTemplate(str); + } else if (HAS_RANGE_SUPPORT) { + return createFragmentFromRange(str); + } + return createFragmentFromWrap(str); +} +function compareNodeNames(fromEl, toEl) { + var fromNodeName = fromEl.nodeName; + var toNodeName = toEl.nodeName; + var fromCodeStart, toCodeStart; + if (fromNodeName === toNodeName) { + return true; + } + fromCodeStart = fromNodeName.charCodeAt(0); + toCodeStart = toNodeName.charCodeAt(0); + if (fromCodeStart <= 90 && toCodeStart >= 97) { + return fromNodeName === toNodeName.toUpperCase(); + } else if (toCodeStart <= 90 && fromCodeStart >= 97) { + return toNodeName === fromNodeName.toUpperCase(); + } else { + return false; + } +} +function createElementNS(name, namespaceURI) { + return !namespaceURI || namespaceURI === NS_XHTML ? doc.createElement(name) : doc.createElementNS(namespaceURI, name); +} +function moveChildren(fromEl, toEl) { + var curChild = fromEl.firstChild; + while (curChild) { + var nextChild = curChild.nextSibling; + toEl.appendChild(curChild); + curChild = nextChild; + } + return toEl; +} +function syncBooleanAttrProp(fromEl, toEl, name) { + if (fromEl[name] !== toEl[name]) { + fromEl[name] = toEl[name]; + if (fromEl[name]) { + fromEl.setAttribute(name, ""); + } else { + fromEl.removeAttribute(name); + } + } +} +var specialElHandlers = { + OPTION: function(fromEl, toEl) { + var parentNode = fromEl.parentNode; + if (parentNode) { + var parentName = parentNode.nodeName.toUpperCase(); + if (parentName === "OPTGROUP") { + parentNode = parentNode.parentNode; + parentName = parentNode && parentNode.nodeName.toUpperCase(); + } + if (parentName === "SELECT" && !parentNode.hasAttribute("multiple")) { + if (fromEl.hasAttribute("selected") && !toEl.selected) { + fromEl.setAttribute("selected", "selected"); + fromEl.removeAttribute("selected"); + } + parentNode.selectedIndex = -1; + } + } + syncBooleanAttrProp(fromEl, toEl, "selected"); + }, + /** + * The "value" attribute is special for the element since it sets + * the initial value. Changing the "value" attribute without changing the + * "value" property will have no effect since it is only used to the set the + * initial value. Similar for the "checked" attribute, and "disabled". + */ + INPUT: function(fromEl, toEl) { + syncBooleanAttrProp(fromEl, toEl, "checked"); + syncBooleanAttrProp(fromEl, toEl, "disabled"); + if (fromEl.value !== toEl.value) { + fromEl.value = toEl.value; + } + if (!toEl.hasAttribute("value")) { + fromEl.removeAttribute("value"); + } + }, + TEXTAREA: function(fromEl, toEl) { + var newValue = toEl.value; + if (fromEl.value !== newValue) { + fromEl.value = newValue; + } + var firstChild = fromEl.firstChild; + if (firstChild) { + var oldValue = firstChild.nodeValue; + if (oldValue == newValue || !newValue && oldValue == fromEl.placeholder) { + return; + } + firstChild.nodeValue = newValue; + } + }, + SELECT: function(fromEl, toEl) { + if (!toEl.hasAttribute("multiple")) { + var selectedIndex = -1; + var i = 0; + var curChild = fromEl.firstChild; + var optgroup; + var nodeName; + while (curChild) { + nodeName = curChild.nodeName && curChild.nodeName.toUpperCase(); + if (nodeName === "OPTGROUP") { + optgroup = curChild; + curChild = optgroup.firstChild; + } else { + if (nodeName === "OPTION") { + if (curChild.hasAttribute("selected")) { + selectedIndex = i; + break; + } + i++; + } + curChild = curChild.nextSibling; + if (!curChild && optgroup) { + curChild = optgroup.nextSibling; + optgroup = null; + } + } + } + fromEl.selectedIndex = selectedIndex; + } + } +}; +var ELEMENT_NODE = 1; +var DOCUMENT_FRAGMENT_NODE$1 = 11; +var TEXT_NODE = 3; +var COMMENT_NODE = 8; +function noop() { +} +function defaultGetNodeKey(node) { + if (node) { + return node.getAttribute && node.getAttribute("id") || node.id; + } +} +function morphdomFactory(morphAttrs2) { + return function morphdom2(fromNode, toNode, options) { + if (!options) { + options = {}; + } + if (typeof toNode === "string") { + if (fromNode.nodeName === "#document" || fromNode.nodeName === "HTML" || fromNode.nodeName === "BODY") { + var toNodeHtml = toNode; + toNode = doc.createElement("html"); + toNode.innerHTML = toNodeHtml; + } else { + toNode = toElement(toNode); + } + } else if (toNode.nodeType === DOCUMENT_FRAGMENT_NODE$1) { + toNode = toNode.firstElementChild; + } + var getNodeKey = options.getNodeKey || defaultGetNodeKey; + var onBeforeNodeAdded = options.onBeforeNodeAdded || noop; + var onNodeAdded = options.onNodeAdded || noop; + var onBeforeElUpdated = options.onBeforeElUpdated || noop; + var onElUpdated = options.onElUpdated || noop; + var onBeforeNodeDiscarded = options.onBeforeNodeDiscarded || noop; + var onNodeDiscarded = options.onNodeDiscarded || noop; + var onBeforeElChildrenUpdated = options.onBeforeElChildrenUpdated || noop; + var skipFromChildren = options.skipFromChildren || noop; + var addChild = options.addChild || function(parent, child) { + return parent.appendChild(child); + }; + var childrenOnly = options.childrenOnly === true; + var fromNodesLookup = /* @__PURE__ */ Object.create(null); + var keyedRemovalList = []; + function addKeyedRemoval(key) { + keyedRemovalList.push(key); + } + function walkDiscardedChildNodes(node, skipKeyedNodes) { + if (node.nodeType === ELEMENT_NODE) { + var curChild = node.firstChild; + while (curChild) { + var key = void 0; + if (skipKeyedNodes && (key = getNodeKey(curChild))) { + addKeyedRemoval(key); + } else { + onNodeDiscarded(curChild); + if (curChild.firstChild) { + walkDiscardedChildNodes(curChild, skipKeyedNodes); + } + } + curChild = curChild.nextSibling; + } + } + } + function removeNode(node, parentNode, skipKeyedNodes) { + if (onBeforeNodeDiscarded(node) === false) { + return; + } + if (parentNode) { + parentNode.removeChild(node); + } + onNodeDiscarded(node); + walkDiscardedChildNodes(node, skipKeyedNodes); + } + function indexTree(node) { + if (node.nodeType === ELEMENT_NODE || node.nodeType === DOCUMENT_FRAGMENT_NODE$1) { + var curChild = node.firstChild; + while (curChild) { + var key = getNodeKey(curChild); + if (key) { + fromNodesLookup[key] = curChild; + } + indexTree(curChild); + curChild = curChild.nextSibling; + } + } + } + indexTree(fromNode); + function handleNodeAdded(el) { + onNodeAdded(el); + var curChild = el.firstChild; + while (curChild) { + var nextSibling = curChild.nextSibling; + var key = getNodeKey(curChild); + if (key) { + var unmatchedFromEl = fromNodesLookup[key]; + if (unmatchedFromEl && compareNodeNames(curChild, unmatchedFromEl)) { + curChild.parentNode.replaceChild(unmatchedFromEl, curChild); + morphEl(unmatchedFromEl, curChild); + } else { + handleNodeAdded(curChild); + } + } else { + handleNodeAdded(curChild); + } + curChild = nextSibling; + } + } + function cleanupFromEl(fromEl, curFromNodeChild, curFromNodeKey) { + while (curFromNodeChild) { + var fromNextSibling = curFromNodeChild.nextSibling; + if (curFromNodeKey = getNodeKey(curFromNodeChild)) { + addKeyedRemoval(curFromNodeKey); + } else { + removeNode( + curFromNodeChild, + fromEl, + true + /* skip keyed nodes */ + ); + } + curFromNodeChild = fromNextSibling; + } + } + function morphEl(fromEl, toEl, childrenOnly2) { + var toElKey = getNodeKey(toEl); + if (toElKey) { + delete fromNodesLookup[toElKey]; + } + if (!childrenOnly2) { + var beforeUpdateResult = onBeforeElUpdated(fromEl, toEl); + if (beforeUpdateResult === false) { + return; + } else if (beforeUpdateResult instanceof HTMLElement) { + fromEl = beforeUpdateResult; + indexTree(fromEl); + } + morphAttrs2(fromEl, toEl); + onElUpdated(fromEl); + if (onBeforeElChildrenUpdated(fromEl, toEl) === false) { + return; + } + } + if (fromEl.nodeName !== "TEXTAREA") { + morphChildren(fromEl, toEl); + } else { + specialElHandlers.TEXTAREA(fromEl, toEl); + } + } + function morphChildren(fromEl, toEl) { + var skipFrom = skipFromChildren(fromEl, toEl); + var curToNodeChild = toEl.firstChild; + var curFromNodeChild = fromEl.firstChild; + var curToNodeKey; + var curFromNodeKey; + var fromNextSibling; + var toNextSibling; + var matchingFromEl; + outer: + while (curToNodeChild) { + toNextSibling = curToNodeChild.nextSibling; + curToNodeKey = getNodeKey(curToNodeChild); + while (!skipFrom && curFromNodeChild) { + fromNextSibling = curFromNodeChild.nextSibling; + if (curToNodeChild.isSameNode && curToNodeChild.isSameNode(curFromNodeChild)) { + curToNodeChild = toNextSibling; + curFromNodeChild = fromNextSibling; + continue outer; + } + curFromNodeKey = getNodeKey(curFromNodeChild); + var curFromNodeType = curFromNodeChild.nodeType; + var isCompatible = void 0; + if (curFromNodeType === curToNodeChild.nodeType) { + if (curFromNodeType === ELEMENT_NODE) { + if (curToNodeKey) { + if (curToNodeKey !== curFromNodeKey) { + if (matchingFromEl = fromNodesLookup[curToNodeKey]) { + if (fromNextSibling === matchingFromEl) { + isCompatible = false; + } else { + fromEl.insertBefore(matchingFromEl, curFromNodeChild); + if (curFromNodeKey) { + addKeyedRemoval(curFromNodeKey); + } else { + removeNode( + curFromNodeChild, + fromEl, + true + /* skip keyed nodes */ + ); + } + curFromNodeChild = matchingFromEl; + curFromNodeKey = getNodeKey(curFromNodeChild); + } + } else { + isCompatible = false; + } + } + } else if (curFromNodeKey) { + isCompatible = false; + } + isCompatible = isCompatible !== false && compareNodeNames(curFromNodeChild, curToNodeChild); + if (isCompatible) { + morphEl(curFromNodeChild, curToNodeChild); + } + } else if (curFromNodeType === TEXT_NODE || curFromNodeType == COMMENT_NODE) { + isCompatible = true; + if (curFromNodeChild.nodeValue !== curToNodeChild.nodeValue) { + curFromNodeChild.nodeValue = curToNodeChild.nodeValue; + } + } + } + if (isCompatible) { + curToNodeChild = toNextSibling; + curFromNodeChild = fromNextSibling; + continue outer; + } + if (curFromNodeKey) { + addKeyedRemoval(curFromNodeKey); + } else { + removeNode( + curFromNodeChild, + fromEl, + true + /* skip keyed nodes */ + ); + } + curFromNodeChild = fromNextSibling; + } + if (curToNodeKey && (matchingFromEl = fromNodesLookup[curToNodeKey]) && compareNodeNames(matchingFromEl, curToNodeChild)) { + if (!skipFrom) { + addChild(fromEl, matchingFromEl); + } + morphEl(matchingFromEl, curToNodeChild); + } else { + var onBeforeNodeAddedResult = onBeforeNodeAdded(curToNodeChild); + if (onBeforeNodeAddedResult !== false) { + if (onBeforeNodeAddedResult) { + curToNodeChild = onBeforeNodeAddedResult; + } + if (curToNodeChild.actualize) { + curToNodeChild = curToNodeChild.actualize(fromEl.ownerDocument || doc); + } + addChild(fromEl, curToNodeChild); + handleNodeAdded(curToNodeChild); + } + } + curToNodeChild = toNextSibling; + curFromNodeChild = fromNextSibling; + } + cleanupFromEl(fromEl, curFromNodeChild, curFromNodeKey); + var specialElHandler = specialElHandlers[fromEl.nodeName]; + if (specialElHandler) { + specialElHandler(fromEl, toEl); + } + } + var morphedNode = fromNode; + var morphedNodeType = morphedNode.nodeType; + var toNodeType = toNode.nodeType; + if (!childrenOnly) { + if (morphedNodeType === ELEMENT_NODE) { + if (toNodeType === ELEMENT_NODE) { + if (!compareNodeNames(fromNode, toNode)) { + onNodeDiscarded(fromNode); + morphedNode = moveChildren(fromNode, createElementNS(toNode.nodeName, toNode.namespaceURI)); + } + } else { + morphedNode = toNode; + } + } else if (morphedNodeType === TEXT_NODE || morphedNodeType === COMMENT_NODE) { + if (toNodeType === morphedNodeType) { + if (morphedNode.nodeValue !== toNode.nodeValue) { + morphedNode.nodeValue = toNode.nodeValue; + } + return morphedNode; + } else { + morphedNode = toNode; + } + } + } + if (morphedNode === toNode) { + onNodeDiscarded(fromNode); + } else { + if (toNode.isSameNode && toNode.isSameNode(morphedNode)) { + return; + } + morphEl(morphedNode, toNode, childrenOnly); + if (keyedRemovalList) { + for (var i = 0, len = keyedRemovalList.length; i < len; i++) { + var elToRemove = fromNodesLookup[keyedRemovalList[i]]; + if (elToRemove) { + removeNode(elToRemove, elToRemove.parentNode, false); + } + } + } + } + if (!childrenOnly && morphedNode !== fromNode && fromNode.parentNode) { + if (morphedNode.actualize) { + morphedNode = morphedNode.actualize(fromNode.ownerDocument || doc); + } + fromNode.parentNode.replaceChild(morphedNode, fromNode); + } + return morphedNode; + }; +} +var morphdom = morphdomFactory(morphAttrs); +var morphdom_esm_default = morphdom; + +// js/phoenix_live_view/dom_patch.js +var DOMPatch = class { + static patchWithClonedTree(container, clonedTree, liveSocket) { + let activeElement = liveSocket.getActiveElement(); + let phxUpdate = liveSocket.binding(PHX_UPDATE); + morphdom_esm_default(container, clonedTree, { + childrenOnly: false, + onBeforeElUpdated: (fromEl, toEl) => { + dom_default.syncPendingAttrs(fromEl, toEl); + if (!container.isSameNode(fromEl) && fromEl.hasAttribute(PHX_REF_LOCK)) { + return false; + } + if (dom_default.isIgnored(fromEl, phxUpdate)) { + return false; + } + if (activeElement && activeElement.isSameNode(fromEl) && dom_default.isFormInput(fromEl)) { + dom_default.mergeFocusedInput(fromEl, toEl); + return false; + } + } + }); + } + constructor(view, container, id, html, streams, targetCID) { + 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: [] + }; + } + 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)); + } + trackAfter(kind, ...args) { + this.callbacks[`after${kind}`].forEach((callback) => callback(...args)); + } + markPrunableContentForRemoval() { + let phxUpdate = this.liveSocket.binding(PHX_UPDATE); + dom_default.all(this.container, `[${phxUpdate}=append] > *, [${phxUpdate}=prepend] > *`, (el) => { + el.setAttribute(PHX_PRUNE, ""); + }); + } + perform(isJoinPatch) { + let { view, liveSocket, html, container, targetContainer } = this; + if (this.isCIDPatch() && !targetContainer) { + return; + } + let focused = liveSocket.getActiveElement(); + let { selectionStart, selectionEnd } = focused && dom_default.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 = []; + let externalFormTriggered = null; + function morph(targetContainer2, source, withChildren = false) { + let 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: targetContainer2.getAttribute(PHX_COMPONENT) === null && !withChildren, + getNodeKey: (node) => { + if (dom_default.isPhxDestroyed(node)) { + return null; + } + 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; + }, + // tell morphdom how to add a child + addChild: (parent, child) => { + let { ref, streamAt } = this.getStreamInsert(child); + if (ref === void 0) { + return parent.appendChild(child); + } + this.setStreamRef(child, ref); + 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); + } else { + parent.appendChild(child); + } + } else if (streamAt > 0) { + let sibling = Array.from(parent.children)[streamAt]; + parent.insertBefore(child, sibling); + } + }, + onBeforeNodeAdded: (el) => { + dom_default.maintainPrivateHooks(el, el, phxViewportTop, phxViewportBottom); + this.trackBefore("added", el); + let morphedEl = el; + if (this.streamComponentRestore[el.id]) { + morphedEl = this.streamComponentRestore[el.id]; + delete this.streamComponentRestore[el.id]; + morph.call(this, morphedEl, el, true); + } + return morphedEl; + }, + onNodeAdded: (el) => { + if (el.getAttribute) { + this.maybeReOrderStream(el, true); + } + if (el instanceof HTMLImageElement && el.srcset) { + el.srcset = el.srcset; + } else if (el instanceof HTMLVideoElement && el.autoplay) { + el.play(); + } + if (dom_default.isNowTriggerFormExternal(el, phxTriggerExternal)) { + externalFormTriggered = el; + } + if (dom_default.isPhxChild(el) && view.ownsElement(el) || dom_default.isPhxSticky(el) && view.ownsElement(el.parentNode)) { + this.trackAfter("phxChildAdded", 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_default.isPhxUpdate(el.parentElement, phxUpdate, [PHX_STREAM, "append", "prepend"])) { + return false; + } + if (this.maybePendingRemove(el)) { + return false; + } + if (this.skipCIDSibling(el)) { + return false; + } + return true; + }, + onElUpdated: (el) => { + if (dom_default.isNowTriggerFormExternal(el, phxTriggerExternal)) { + externalFormTriggered = el; + } + updates.push(el); + this.maybeReOrderStream(el, false); + }, + onBeforeElUpdated: (fromEl, toEl) => { + if (fromEl.id && fromEl.isSameNode(targetContainer2) && fromEl.id !== toEl.id) { + morphCallbacks.onNodeDiscarded(fromEl); + fromEl.replaceWith(toEl); + return morphCallbacks.onNodeAdded(toEl); + } + dom_default.syncPendingAttrs(fromEl, toEl); + dom_default.maintainPrivateHooks(fromEl, toEl, phxViewportTop, phxViewportBottom); + dom_default.cleanChildNodes(toEl, phxUpdate); + if (this.skipCIDSibling(toEl)) { + this.maybeReOrderStream(fromEl); + return false; + } + if (dom_default.isPhxSticky(fromEl)) { + [PHX_SESSION, PHX_STATIC, PHX_ROOT_ID].map((attr) => [attr, fromEl.getAttribute(attr), toEl.getAttribute(attr)]).forEach(([attr, fromVal, toVal]) => { + if (toVal && fromVal !== toVal) { + fromEl.setAttribute(attr, toVal); + } + }); + return false; + } + if (dom_default.isIgnored(fromEl, phxUpdate) || fromEl.form && fromEl.form.isSameNode(externalFormTriggered)) { + this.trackBefore("updated", fromEl, toEl); + dom_default.mergeAttrs(fromEl, toEl, { isIgnored: dom_default.isIgnored(fromEl, phxUpdate) }); + updates.push(fromEl); + dom_default.applyStickyOperations(fromEl); + return false; + } + if (fromEl.type === "number" && (fromEl.validity && fromEl.validity.badInput)) { + return false; + } + let isFocusedFormEl = focused && fromEl.isSameNode(focused) && dom_default.isFormInput(fromEl); + let focusedSelectChanged = isFocusedFormEl && this.isChangedSelect(fromEl, toEl); + if (fromEl.hasAttribute(PHX_REF_SRC)) { + if (dom_default.isUploadInput(fromEl)) { + dom_default.mergeAttrs(fromEl, toEl, { isIgnored: true }); + this.trackBefore("updated", fromEl, toEl); + updates.push(fromEl); + } + dom_default.applyStickyOperations(fromEl); + let isLocked = fromEl.hasAttribute(PHX_REF_LOCK); + let clone2 = isLocked ? dom_default.private(fromEl, PHX_REF_LOCK) || fromEl.cloneNode(true) : null; + if (clone2) { + dom_default.putPrivate(fromEl, PHX_REF_LOCK, clone2); + if (!isFocusedFormEl) { + fromEl = clone2; + } + } + } + if (dom_default.isPhxChild(toEl)) { + let prevSession = fromEl.getAttribute(PHX_SESSION); + dom_default.mergeAttrs(fromEl, toEl, { exclude: [PHX_STATIC] }); + if (prevSession !== "") { + fromEl.setAttribute(PHX_SESSION, prevSession); + } + fromEl.setAttribute(PHX_ROOT_ID, this.rootID); + dom_default.applyStickyOperations(fromEl); + return false; + } + dom_default.copyPrivates(toEl, fromEl); + if (isFocusedFormEl && fromEl.type !== "hidden" && !focusedSelectChanged) { + this.trackBefore("updated", fromEl, toEl); + dom_default.mergeFocusedInput(fromEl, toEl); + dom_default.syncAttrsToProps(fromEl); + updates.push(fromEl); + dom_default.applyStickyOperations(fromEl); + return false; + } else { + if (focusedSelectChanged) { + fromEl.blur(); + } + if (dom_default.isPhxUpdate(toEl, phxUpdate, ["append", "prepend"])) { + appendPrependUpdates.push(new DOMPostMorphRestorer(fromEl, toEl, toEl.getAttribute(phxUpdate))); + } + dom_default.syncAttrsToProps(toEl); + dom_default.applyStickyOperations(toEl); + this.trackBefore("updated", fromEl, toEl); + return fromEl; + } + } + }; + morphdom_esm_default(targetContainer2, source, morphCallbacks); + } + 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 !== void 0) { + dom_default.all(container, `[${PHX_STREAM_REF}="${ref}"]`, (child) => { + this.removeStreamChildElement(child); + }); + } + deleteIds.forEach((id) => { + let child = container.querySelector(`[id="${id}"]`); + if (child) { + this.removeStreamChildElement(child); + } + }); + }); + if (isJoinPatch) { + dom_default.all(this.container, `[${phxUpdate}=${PHX_STREAM}]`, (el) => { + this.liveSocket.owner(el, (view2) => { + if (view2 === this.view) { + Array.from(el.children).forEach((child) => { + this.removeStreamChildElement(child); + }); + } + }); + }); + } + morph.call(this, targetContainer, html); + }); + if (liveSocket.isDebugEnabled()) { + detectDuplicateIds(); + 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); + } + }); + } + if (appendPrependUpdates.length > 0) { + liveSocket.time("post-morph append/prepend restoration", () => { + appendPrependUpdates.forEach((update) => update.perform()); + }); + } + liveSocket.silenceEvents(() => dom_default.restoreFocus(focused, selectionStart, selectionEnd)); + dom_default.dispatchEvent(document, "phx:update"); + added.forEach((el) => this.trackAfter("added", el)); + updates.forEach((el) => this.trackAfter("updated", el)); + this.transitionPendingRemoves(); + if (externalFormTriggered) { + liveSocket.unload(); + Object.getPrototypeOf(externalFormTriggered).submit.call(externalFormTriggered); + } + return true; + } + onNodeDiscarded(el) { + if (dom_default.isPhxChild(el) || dom_default.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; + } else { + return false; + } + } + removeStreamChildElement(child) { + if (this.streamInserts[child.id]) { + this.streamComponentRestore[child.id] = child; + child.remove(); + } else { + if (!this.maybePendingRemove(child)) { + child.remove(); + this.onNodeDiscarded(child); + } + } + } + getStreamInsert(el) { + let insert = el.id ? this.streamInserts[el.id] : {}; + return insert || {}; + } + setStreamRef(el, ref) { + dom_default.putSticky(el, PHX_STREAM_REF, (el2) => el2.setAttribute(PHX_STREAM_REF, ref)); + } + maybeReOrderStream(el, isNew) { + let { ref, streamAt, reset } = this.getStreamInsert(el); + if (streamAt === void 0) { + return; + } + this.setStreamRef(el, ref); + if (!reset && !isNew) { + return; + } + 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); + } else { + let sibling = children[streamAt]; + if (oldIndex > streamAt) { + el.parentElement.insertBefore(el, sibling); + } else { + el.parentElement.insertBefore(el, sibling.nextElementSibling); + } + } + } + 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)); + } + } + transitionPendingRemoves() { + let { pendingRemoves, liveSocket } = this; + if (pendingRemoves.length > 0) { + liveSocket.transitionRemoves(pendingRemoves, false, () => { + pendingRemoves.forEach((el) => { + let child = dom_default.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; + } + toEl.value = fromEl.value; + return !fromEl.isEqualNode(toEl); + } + isCIDPatch() { + return this.cidPatch; + } + skipCIDSibling(el) { + return el.nodeType === Node.ELEMENT_NODE && el.hasAttribute(PHX_SKIP); + } + targetCIDContainer(html) { + if (!this.isCIDPatch()) { + return; + } + let [first, ...rest] = dom_default.findComponentNodeList(this.container, this.targetCID); + if (rest.length === 0 && dom_default.childNodeLength(html) === 1) { + return first; + } else { + return first && first.parentNode; + } + } + indexOf(parent, child) { + return Array.from(parent.children).indexOf(child); + } +}; + +// js/phoenix_live_view/rendered.js +var VOID_TAGS = /* @__PURE__ */ new Set([ + "area", + "base", + "br", + "col", + "command", + "embed", + "hr", + "img", + "input", + "keygen", + "link", + "meta", + "param", + "source", + "track", + "wbr" +]); +var quoteChars = /* @__PURE__ */ new Set(["'", '"']); +var 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}`); + } + i = lookahead[0].length; + beforeTag = lookahead[1]; + tag = lookahead[2]; + tagNameEndsAt = i; + 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; + } + } + 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) === "" && html.slice(closeAt - 2, closeAt) === "--") { + insideComment = true; + closeAt -= 3; + } else if (char === ">") { + break; + } else { + closeAt -= 1; + } + } + afterTag = html.slice(closeAt + 1, html.length); + let attrsStr = Object.keys(attrs).map((attr) => attrs[attr] === true ? attr : `${attr}="${attrs[attr]}"`).join(" "); + if (clearInnerHTML) { + let idAttrStr = id ? ` id="${id}"` : ""; + if (VOID_TAGS.has(tag)) { + newHTML = `<${tag}${idAttrStr}${attrsStr === "" ? "" : " "}${attrsStr}/>`; + } else { + newHTML = `<${tag}${idAttrStr}${attrsStr === "" ? "" : " "}${attrsStr}>`; + } + } else { + let rest = html.slice(tagNameEndsAt, closeAt + 1); + newHTML = `<${tag}${attrsStr === "" ? "" : " "}${attrsStr}${rest}`; + } + return [newHTML, beforeTag, afterTag]; +}; +var Rendered = class { + 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 || [] }; + } + constructor(viewId, rendered) { + this.viewId = viewId; + this.rendered = {}; + this.magicId = 0; + this.mergeDiff(rendered); + } + parentViewId() { + return this.viewId; + } + toString(onlyCids) { + let [str, streams] = this.recursiveToString(this.rendered, this.rendered[COMPONENTS], onlyCids, true, {}); + return [str, streams]; + } + recursiveToString(rendered, components = rendered[COMPONENTS], onlyCids, changeTracking, rootAttrs) { + onlyCids = onlyCids ? new Set(onlyCids) : null; + let output = { buffer: "", components, onlyCids, streams: /* @__PURE__ */ new Set() }; + this.toOutputBuffer(rendered, null, output, changeTracking, rootAttrs); + return [output.buffer, output.streams]; + } + componentCIDs(diff) { + return Object.keys(diff[COMPONENTS] || {}).map((i) => parseInt(i)); + } + isComponentOnlyDiff(diff) { + if (!diff[COMPONENTS]) { + return false; + } + return Object.keys(diff).length === 1; + } + getComponent(diff, cid) { + return diff[COMPONENTS][cid]; + } + resetRender(cid) { + 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] || {}; + if (newc) { + let oldc = this.rendered[COMPONENTS]; + for (let 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; + } + } + cachedFindComponent(cid, cdiff, oldc, newc, cache) { + if (cache[cid]) { + return cache[cid]; + } else { + let ndiff, stat, scid = cdiff[STATIC]; + if (isCid(scid)) { + let tdiff; + if (scid > 0) { + tdiff = this.cachedFindComponent(scid, newc[scid], oldc, newc, cache); + } else { + tdiff = oldc[-scid]; + } + stat = tdiff[STATIC]; + ndiff = this.cloneMerge(tdiff, cdiff, true); + ndiff[STATIC] = stat; + } else { + ndiff = cdiff[STATIC] !== void 0 || oldc[cid] === void 0 ? cdiff : this.cloneMerge(oldc[cid], cdiff, false); + } + cache[cid] = ndiff; + return ndiff; + } + } + mutableMerge(target, source) { + if (source[STATIC] !== void 0) { + return source; + } else { + 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] === void 0 && isObject(targetVal)) { + this.doMutableMerge(targetVal, val); + } else { + target[key] = val; + } + } + if (target[ROOT]) { + target.newRender = true; + } + } + // Merges cid trees together, copying statics from source tree. + // + // The `pruneMagicId` is passed to control pruning the magicId of the + // target. We must always prune the magicId when we are sharing statics + // from another component. If not pruning, we replicate the logic from + // 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] === void 0 && isObject(targetVal)) { + merged[key] = this.cloneMerge(targetVal, val, pruneMagicId); + } else if (val === void 0 && isObject(targetVal)) { + merged[key] = this.cloneMerge(targetVal, {}, pruneMagicId); + } + } + if (pruneMagicId) { + delete merged.magicId; + delete merged.newRender; + } else if (target[ROOT]) { + merged.newRender = true; + } + return merged; + } + componentToString(cid) { + let [str, streams] = this.recursiveCIDToString(this.rendered[COMPONENTS], cid, null); + let [strippedHTML, _before, _after] = modifyRoot(str, {}); + return [strippedHTML, streams]; + } + pruneCIDs(cids) { + cids.forEach((cid) => delete this.rendered[COMPONENTS][cid]); + } + // private + get() { + return this.rendered; + } + isNewFingerprint(diff = {}) { + return !!diff[STATIC]; + } + templateStatic(part, templates) { + if (typeof part === "number") { + return templates[part]; + } else { + return part; + } + } + nextMagicID() { + this.magicId++; + return `m${this.magicId}-${this.parentViewId()}`; + } + // Converts rendered tree to output buffer. + // + // 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 = ""; + } + 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]; + } + if (isRoot) { + let skip = false; + let attrs; + if (changeTracking || rendered.magicId) { + skip = changeTracking && !rendered.newRender; + attrs = { [PHX_MAGIC_ID]: rendered.magicId, ...rootAttrs }; + } else { + attrs = rootAttrs; + } + if (skip) { + attrs[PHX_SKIP] = true; + } + let [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++) { + let changeTracking = false; + this.dynamicToBuffer(dynamic[i - 1], compTemplates, output, changeTracking); + output.buffer += statics[i]; + } + } + if (stream !== void 0 && (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 = /* @__PURE__ */ new Set([...output.streams, ...streams]); + } else if (isObject(rendered)) { + this.toOutputBuffer(rendered, templates, output, changeTracking, {}); + } else { + 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); + component.newRender = !skip; + component.magicId = `c${cid}-${this.parentViewId()}`; + let changeTracking = !component.reset; + let [html, streams] = this.recursiveToString(component, components, onlyCids, changeTracking, attrs); + delete component.reset; + return [html, streams]; + } +}; + +// js/phoenix_live_view/view_hook.js +var HOOK_ID = "hookId"; +var viewHookID = 1; +var ViewHook = class { + static makeID() { + return viewHookID++; + } + static elementID(el) { + return dom_default.private(el, HOOK_ID); + } + constructor(view, el, callbacks) { + this.el = el; + this.__attachView(view); + this.__callbacks = callbacks; + this.__listeners = /* @__PURE__ */ new Set(); + this.__isDisconnected = false; + dom_default.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(); + } + __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. + * + * @param {ViewHook} hook - The ViewHook instance to bind. + * + * @returns {Object} An object with methods to manipulate the DOM and execute JavaScript. + */ + js() { + let hook = this; + return { + /** + * Executes encoded JavaScript in the context of the hook element. + * + * @param {string} encodedJS - The encoded JavaScript string to execute. + */ + exec(encodedJS) { + hook.__view().liveSocket.execJS(hook.el, encodedJS, "hook"); + }, + /** + * 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 = hook.__view().liveSocket.owner(el); + js_default.show("hook", 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 = hook.__view().liveSocket.owner(el); + js_default.hide("hook", 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 = hook.__view().liveSocket.owner(el); + opts.in = js_default.transitionClasses(opts.in); + opts.out = js_default.transitionClasses(opts.out); + js_default.toggle("hook", 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 = hook.__view().liveSocket.owner(el); + js_default.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_default.transitionClasses(opts.transition); + names = Array.isArray(names) ? names : names.split(" "); + let owner = hook.__view().liveSocket.owner(el); + js_default.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_default.transitionClasses(opts.transition); + names = Array.isArray(names) ? names : names.split(" "); + let owner = hook.__view().liveSocket.owner(el); + js_default.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 = hook.__view().liveSocket.owner(el); + js_default.addOrRemoveClasses(el, [], [], js_default.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_default.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_default.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_default.toggleAttr(el, attr, val1, val2); + } + }; + } + pushEvent(event, payload = {}, onReply = function() { + }) { + return this.__view().pushHookEvent(this.el, null, event, payload, onReply); + } + pushEventTo(phxTarget, event, payload = {}, onReply = function() { + }) { + 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)); + } +}; + +// js/phoenix_live_view/view.js +var prependFormDataKey = (key, prefix) => { + let isArray = key.endsWith("[]"); + let baseKey = isArray ? key.slice(0, -2) : key; + baseKey = baseKey.replace(/([^\[\]]+)(\]?$)/, `${prefix}$1$2`); + if (isArray) { + baseKey += "[]"; + } + return baseKey; +}; +var serializeForm = (form, metadata, onlyNames = []) => { + const { submitter, ...meta } = metadata; + let injectedElement; + if (submitter && submitter.name) { + const input = document.createElement("input"); + input.type = "hidden"; + 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; + } + const formData = new FormData(form); + const toRemove = []; + formData.forEach((val, key, _index) => { + if (val instanceof File) { + toRemove.push(key); + } + }); + toRemove.forEach((key) => formData.delete(key)); + const params = new URLSearchParams(); + let elements = Array.from(form.elements); + for (let [key, val] of formData.entries()) { + if (onlyNames.length === 0 || onlyNames.indexOf(key) >= 0) { + let inputs = elements.filter((input) => input.name === key); + let isUnused = !inputs.some((input) => dom_default.private(input, PHX_HAS_FOCUSED) || dom_default.private(input, PHX_HAS_SUBMITTED)); + let hidden = inputs.every((input) => input.type === "hidden"); + if (isUnused && !(submitter && submitter.name == key) && !hidden) { + params.append(prependFormDataKey(key, "_unused_"), ""); + } + params.append(key, val); + } + } + if (submitter && injectedElement) { + submitter.parentElement.removeChild(injectedElement); + } + for (let metaKey in meta) { + params.append(metaKey, meta[metaKey]); + } + return params.toString(); +}; +var View = class _View { + static closestView(el) { + let liveViewEl = el.closest(PHX_VIEW_SELECTOR); + return liveViewEl ? dom_default.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_default.putPrivate(this.el, "view", this); + this.id = this.el.id; + this.ref = 0; + this.lastAckRef = null; + this.childJoins = 0; + this.loaderTimer = null; + this.pendingDiffs = []; + this.pendingForms = /* @__PURE__ */ 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); + return { + redirect: this.redirect ? url : void 0, + url: this.redirect ? void 0 : url || void 0, + params: this.connectParams(liveReferer), + session: this.getSession(), + static: this.getStatic(), + flash: this.flash + }; + }); + } + setHref(href) { + this.href = href; + } + setRedirect(href) { + this.redirect = true; + this.href = href; + } + isMain() { + return this.el.hasAttribute(PHX_MAIN); + } + connectParams(liveReferer) { + let params = this.liveSocket.params(this.el); + let manifest = dom_default.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++; + return params; + } + isConnected() { + return this.channel.canPush(); + } + getSession() { + return this.el.getAttribute(PHX_SESSION); + } + getStatic() { + let 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]); + } + }; + dom_default.markPhxChildDestroyed(this.el); + this.log("destroyed", () => ["the child has been removed from the parent"]); + this.channel.leave().receive("ok", onFinished).receive("error", onFinished).receive("timeout", onFinished); + } + 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); + } + 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); + } + } + execAll(binding) { + dom_default.all(this.el, `[${binding}]`, (el) => this.liveSocket.execJS(el, el.getAttribute(binding))); + } + hideLoader() { + clearTimeout(this.loaderTimer); + this.setContainerClasses(PHX_CONNECTED_CLASS); + this.execAll(this.binding("connected")); + } + triggerReconnected() { + for (let id in this.viewHooks) { + this.viewHooks[id].__reconnected(); + } + } + log(kind, msgCallback) { + this.liveSocket.log(this, kind, msgCallback); + } + transition(time, onStart, onDone = function() { + }) { + this.liveSocket.transition(time, onStart, onDone); + } + // calls the callback with the view and target element for the given phxTarget + // targets can be: + // * an element itself, then it is simply passed to liveSocket.owner; + // * 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) { + if (phxTarget instanceof HTMLElement || phxTarget instanceof SVGElement) { + return this.liveSocket.owner(phxTarget, (view) => callback(view, phxTarget)); + } + if (isCid(phxTarget)) { + let targets = dom_default.findComponentNodeList(viewEl || this.el, phxTarget); + if (targets.length === 0) { + logError(`no component found matching phx-target of ${phxTarget}`); + } else { + callback(this, parseInt(phxTarget)); + } + } else { + let targets = Array.from(dom.querySelectorAll(phxTarget)); + 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") { + window.requestAnimationFrame(() => dom_default.putTitle(title)); + } + } + onJoin(resp) { + let { rendered, container, liveview_version } = resp; + if (container) { + let [tag, attrs] = container; + this.el = dom_default.replaceRootContainer(this.el, tag, attrs); + } + this.childJoins = 0; + this.joinPending = true; + this.flash = null; + if (this.root === this) { + this.formsForRecovery = this.getFormsForRecovery(); + } + if (this.isMain()) { + this.liveSocket.replaceRootHistory(); + } + 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_default.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; + this.maybeRecoverForms(html, () => { + this.onJoinComplete(resp, html, streams, events); + }); + }); + } + dropPendingRefs() { + dom_default.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) { + if (this.joinCount > 1 || this.parent && !this.parent.isJoinPending()) { + return this.applyJoinPatch(live_patch, html, streams, events); + } + let newChildren = dom_default.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); + } + 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); + } + } else { + this.root.pendingJoinOps.push([this, () => this.applyJoinPatch(live_patch, html, streams, events)]); + } + } + attachTrueDocEl() { + this.el = dom_default.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_default.all(parent, `[${phxViewportTop}], [${phxViewportBottom}]`, (hookEl) => { + if (this.ownsElement(hookEl)) { + dom_default.maintainPrivateHooks(hookEl, hookEl, phxViewportTop, phxViewportBottom); + this.maybeAddNewHook(hookEl); + } + }); + dom_default.all(parent, `[${this.binding(PHX_HOOK)}], [data-phx-${PHX_HOOK}]`, (hookEl) => { + if (this.ownsElement(hookEl)) { + this.maybeAddNewHook(hookEl); + } + }); + dom_default.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(); + this.joinPending = false; + this.liveSocket.dispatchEvents(events); + this.applyPendingUpdates(); + if (live_patch) { + let { 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_default.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_default.private(el, "mounted"); + if (phxMounted && !hasBeenInvoked) { + this.liveSocket.execJS(el, phxMounted); + dom_default.putPrivate(el, "mounted", true); + } + } + maybeAddNewHook(el) { + let newHook = this.addHook(el); + if (newHook) { + newHook.__mounted(); + } + } + performPatch(patch, pruneCids, isJoinPatch = false) { + let removedEls = []; + let phxChildrenAdded = false; + let updatedHookIds = /* @__PURE__ */ new Set(); + this.liveSocket.triggerDOM("onPatchStart", [patch.targetContainer]); + patch.after("added", (el) => { + this.liveSocket.triggerDOM("onNodeAdded", [el]); + let phxViewportTop = this.binding(PHX_VIEWPORT_TOP); + let phxViewportBottom = this.binding(PHX_VIEWPORT_BOTTOM); + dom_default.maintainPrivateHooks(el, el, phxViewportTop, phxViewportBottom); + this.maybeAddNewHook(el); + if (el.getAttribute) { + this.maybeMounted(el); + } + }); + patch.after("phxChildAdded", (el) => { + if (dom_default.isPhxSticky(el)) { + this.liveSocket.joinRootViews(); + } else { + phxChildrenAdded = true; + } + }); + patch.before("updated", (fromEl, toEl) => { + let hook = this.triggerBeforeUpdateHook(fromEl, toEl); + if (hook) { + updatedHookIds.add(fromEl.id); + } + }); + 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_default.all(parent, `[${PHX_COMPONENT}]`); + let hooks = dom_default.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 (pruneCids) { + this.maybePushComponentsDestroyed(destroyedCIDs); + } + } + joinNewChildren() { + dom_default.findPhxChildren(this.el, this.id).forEach((el) => this.joinChild(el)); + } + maybeRecoverForms(html, callback) { + const phxChange = this.binding("change"); + const oldForms = this.root.formsForRecovery; + let template = document.createElement("template"); + template.innerHTML = html; + 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 formsToRecover = ( + // we go over all forms in the new DOM; because this is only the HTML for the current + // view, we can be sure that all forms are owned by this view: + dom_default.all(template.content, "form").filter((newForm) => newForm.id && oldForms[newForm.id]).filter((newForm) => !this.pendingForms.has(newForm.id)).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.pushFormRecovery(oldForm, newForm, template.content.firstElementChild, () => { + this.pendingForms.delete(newForm.id); + if (i === formsToRecover.length - 1) { + callback(); + } + }); + }); + } + getChildById(id) { + return this.root.children[this.id][id]; + } + getDescendentByEl(el) { + if (el.id === this.id) { + return this; + } else { + 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(); + } + } + } + } + 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; + } + } + isJoinPending() { + return this.joinPending; + } + ackJoin(_child) { + this.childJoins--; + if (this.childJoins === 0) { + if (this.parent) { + this.parent.ackJoin(this); + } else { + this.onAllChildJoinsComplete(); + } + } + } + onAllChildJoinsComplete() { + this.pendingForms.clear(); + this.formsForRecovery = {}; + this.joinCallback(() => { + this.pendingJoinOps.forEach(([view, op]) => { + if (!view.isDestroyed()) { + op(); + } + }); + this.pendingJoinOps = []; + }); + } + 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; + if (this.rendered.isComponentOnlyDiff(diff)) { + this.liveSocket.time("component patch complete", () => { + let parentCids = dom_default.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); + }); + } + this.liveSocket.dispatchEvents(events); + if (phxChildrenAdded) { + this.joinNewChildren(); + } + } + renderContainer(diff, kind) { + return this.liveSocket.time(`toString diff (${kind})`, () => { + let tag = this.el.tagName; + let cids = diff ? this.rendered.componentCIDs(diff) : null; + let [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; + } + getHook(el) { + return this.viewHooks[ViewHook.elementID(el)]; + } + addHook(el) { + let hookElId = ViewHook.elementID(el); + if (hookElId && !this.viewHooks[hookElId]) { + let hook = dom_default.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) { + return; + } else { + let hookName = el.getAttribute(`data-phx-${PHX_HOOK}`) || el.getAttribute(this.binding(PHX_HOOK)); + if (hookName && !this.ownsElement(el)) { + return; + } + 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); + } + } + } + destroyHook(hook) { + hook.__destroyed(); + hook.__cleanup__(); + delete this.viewHooks[ViewHook.elementID(hook.el)]; + } + applyPendingUpdates() { + 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)); + } + } + 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)); + } + }); + } + bindChannel() { + 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)); + } + 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); + } + onLivePatch(redir) { + let { 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; + } + onRedirect({ to, flash, reloadToken }) { + this.liveSocket.redirect(to, flash, reloadToken); + } + isDestroyed() { + return this.destroyed; + } + joinDead() { + this.isDead = true; + } + 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" }); + } + this.joinCallback = (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 }); + 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) { + 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_default.byId(this.el.id); + if (trueChildEl) { + dom_default.mergeAttrs(trueChildEl, this.el); + this.displayError([PHX_LOADING_CLASS, PHX_ERROR_CLASS, PHX_SERVER_ERROR_CLASS]); + this.el = trueChildEl; + } else { + this.destroy(); + } + } + } + onClose(reason) { + if (this.isDestroyed()) { + return; + } + if (this.isMain() && this.liveSocket.hasPendingLink() && reason !== "leave") { + return this.liveSocket.reloadWithJitter(this); + } + this.destroyAllChildren(); + this.liveSocket.dropActiveElement(this); + if (document.activeElement) { + document.activeElement.blur(); + } + 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]); + } else { + this.displayError([PHX_LOADING_CLASS, PHX_ERROR_CLASS, PHX_CLIENT_ERROR_CLASS]); + } + } + } + displayError(classes) { + if (this.isMain()) { + dom_default.dispatchEvent(window, "phx:page-loading-start", { detail: { to: this.href, kind: "error" } }); + } + this.showLoader(); + this.setContainerClasses(...classes); + this.execAll(this.binding("disconnected")); + } + wrapPush(callerPush, receives) { + let latency = this.liveSocket.getLatencySim(); + let 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({ error: "noconnection" }); + } + 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 }); + } + 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, 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.update(diff, events); + finish(reply); + }); + }); + } else { + if (ref !== null) { + this.undoRefs(ref, payload.event); + } + finish(null); + } + }, + error: (reason) => reject({ error: reason }), + timeout: () => { + reject({ timeout: true }); + if (this.joinCount === oldJoinCount) { + this.liveSocket.reloadWithJitter(this, () => { + this.log("timeout", () => ["received timeout while communicating with server. Falling back to hard refresh for recovery"]); + }); + } + } + }); + }); + } + undoRefs(ref, phxEvent, onlyEls) { + if (!this.isConnected()) { + return; + } + let selector = `[${PHX_REF_SRC}="${this.refSrc()}"]`; + if (onlyEls) { + onlyEls = new Set(onlyEls); + dom_default.all(document, selector, (parent) => { + if (onlyEls && !onlyEls.has(parent)) { + return; + } + dom_default.all(parent, selector, (child) => this.undoElRef(child, ref, phxEvent)); + this.undoElRef(parent, ref, phxEvent); + }); + } else { + dom_default.all(document, selector, (el) => this.undoElRef(el, ref, phxEvent)); + } + } + undoElRef(el, ref, phxEvent) { + let elRef = new ElementRef(el); + elRef.maybeUndo(ref, phxEvent, (clonedTree) => { + let hook = this.triggerBeforeUpdateHook(el, clonedTree); + DOMPatch.patchWithClonedTree(el, clonedTree, this.liveSocket); + dom_default.all(el, `[${PHX_REF_SRC}="${this.refSrc()}"]`, (child) => this.undoElRef(child, ref, phxEvent)); + this.execNewMounted(el); + if (hook) { + hook.__updated(); + } + }); + } + 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_default.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 }); + }); + let loadingCompletePromise = new Promise((resolve) => { + el.addEventListener(`phx:undo-loading:${newRef}`, () => resolve(detail), { once: true }); + }); + el.classList.add(`phx-${eventType}-loading`); + let disableText = el.getAttribute(disableWith); + if (disableText !== null) { + if (!el.getAttribute(PHX_DISABLE_WITH_RESTORE)) { + el.setAttribute(PHX_DISABLE_WITH_RESTORE, el.innerText); + } + if (disableText !== "") { + el.innerText = disableText; + } + el.setAttribute(PHX_DISABLED, el.getAttribute(PHX_DISABLED) || el.disabled); + el.setAttribute("disabled", ""); + } + let detail = { + event: phxEvent, + eventType, + ref: newRef, + isLoading: loading, + isLocked: lock, + lockElements: elements.filter(({ lock: lock2 }) => lock2).map(({ el: el2 }) => el2), + loadingElements: elements.filter(({ loading: loading2 }) => loading2).map(({ el: el2 }) => el2), + unlock: (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, + bubbles: true, + cancelable: false + })); + if (phxEvent) { + el.dispatchEvent(new CustomEvent(`phx:push:${phxEvent}`, { + detail, + bubbles: true, + cancelable: false + })); + } + } + return [newRef, elements.map(({ el }) => el), opts]; + } + isAcked(ref) { + return this.lastAckRef !== null && this.lastAckRef >= ref; + } + componentID(el) { + let cid = el.getAttribute && el.getAttribute(PHX_COMPONENT); + return cid ? parseInt(cid) : null; + } + targetComponentID(target, targetCtx, opts = {}) { + if (isCid(targetCtx)) { + return targetCtx; + } + let cidOrSelector = opts.target || target.getAttribute(this.binding("target")); + if (isCid(cidOrSelector)) { + return parseInt(cidOrSelector); + } else if (targetCtx && (cidOrSelector !== null || opts.target)) { + return this.closestComponentID(targetCtx); + } else { + 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)); + } else { + 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; + } + let [ref, els, opts] = this.putRef([{ el, loading: true, lock: true }], event, "hook"); + this.pushWithReply(() => [ref, els, opts], "event", { + type: "hook", + event, + value: payload, + cid: this.closestComponentID(targetCtx) + }).then(({ resp: _resp, reply: hookReply }) => onReply(hookReply, ref)); + return 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); + } + } + if (el.value !== void 0 && !(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]; + } + } + return meta; + } + pushEvent(type, el, targetCtx, phxEvent, meta, opts = {}, onReply) { + this.pushWithReply(() => this.putRef([{ el, loading: true, lock: true }], phxEvent, type, opts), "event", { + type, + event: phxEvent, + value: this.extractMeta(el, meta, opts.value), + cid: this.targetComponentID(el, targetCtx, opts) + }).then(({ resp, reply }) => onReply && onReply(reply)); + } + 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, + cid: view.targetComponentID(fileEl.form, targetCtx) + }).then(({ resp }) => onReply(resp)); + }); + } + 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); + if (inputEl instanceof HTMLButtonElement) { + meta.submitter = inputEl; + } + if (inputEl.getAttribute(this.binding("change"))) { + formData = serializeForm(inputEl.form, { _target: opts._target, ...meta }, [inputEl.name]); + } else { + formData = serializeForm(inputEl.form, { _target: opts._target, ...meta }); + } + if (dom_default.isUploadInput(inputEl) && inputEl.files && inputEl.files.length > 0) { + LiveUploader.trackFiles(inputEl, Array.from(inputEl.files)); + } + uploads = LiveUploader.serializeUploads(inputEl); + let event = { + type: "form", + event: phxEvent, + value: formData, + uploads, + cid + }; + this.pushWithReply(refGenerator, "event", event).then(({ resp }) => { + if (dom_default.isUploadInput(inputEl) && dom_default.isAutoUpload(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); + } + }); + } + triggerAwaitingSubmit(formEl, phxEvent) { + let awaitingSubmit = this.getScheduledSubmit(formEl); + if (awaitingSubmit) { + let [_el, _ref, _opts, callback] = awaitingSubmit; + this.cancelSubmit(formEl, phxEvent); + callback(); + } + } + getScheduledSubmit(formEl) { + return this.formSubmits.find(([el, _ref, _opts, _callback]) => el.isSameNode(formEl)); + } + scheduleSubmit(formEl, ref, opts, callback) { + if (this.getScheduledSubmit(formEl)) { + return true; + } + this.formSubmits.push([formEl, ref, opts, callback]); + } + cancelSubmit(formEl, phxEvent) { + this.formSubmits = this.formSubmits.filter(([el, ref, _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)); + }; + 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); + 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 }; + }); + 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 + }); + 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]; + this.uploadFiles(formEl, phxEvent, targetCtx, ref, cid, (uploads) => { + if (LiveUploader.inputsAwaitingPreflight(formEl).length > 0) { + return this.undoRefs(ref, phxEvent); + } + let meta = this.extractMeta(formEl); + let formData = serializeForm(formEl, { submitter, ...meta }); + this.pushWithReply(proxyRefGen, "event", { + type: "form", + event: phxEvent, + value: formData, + cid + }).then(({ resp }) => onReply(resp)); + }); + } else if (!(formEl.hasAttribute(PHX_REF_SRC) && formEl.classList.contains("phx-submit-loading"))) { + let meta = this.extractMeta(formEl); + let formData = serializeForm(formEl, { submitter, ...meta }); + this.pushWithReply(refGenerator, "event", { + type: "form", + event: phxEvent, + value: formData, + cid + }).then(({ resp }) => onReply(resp)); + } + } + uploadFiles(formEl, phxEvent, targetCtx, ref, cid, onComplete) { + let joinCountAtUpload = this.joinCount; + let inputEls = LiveUploader.activeFileInputs(formEl); + let numFileInputsInProgress = inputEls.length; + inputEls.forEach((inputEl) => { + let uploader = new LiveUploader(inputEl, this, () => { + numFileInputsInProgress--; + if (numFileInputsInProgress === 0) { + onComplete(); + } + }); + let entries = uploader.entries().map((entry) => entry.toPreflightPayload()); + if (entries.length === 0) { + numFileInputsInProgress--; + return; + } + let payload = { + ref: inputEl.getAttribute(PHX_UPLOAD_REF), + 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]); + uploader.entries().forEach((entry) => { + if (resp.entries && !resp.entries[entry.ref]) { + this.handleFailedEntryPreflight(entry.ref, "failed preflight", uploader); + } + }); + 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); + } + }); + }); + } + handleFailedEntryPreflight(uploadRef, reason, uploader) { + if (uploader.isAutoUpload()) { + let entry = uploader.entries().find((entry2) => entry2.ref === uploadRef.toString()); + if (entry) { + entry.cancel(); + } + } else { + uploader.entries().map((entry) => entry.cancel()); + } + this.log("upload", () => [`error for entry ${uploadRef}`, reason]); + } + dispatchUploads(targetCtx, name, filesOrBlobs) { + let targetElement = this.targetCtxElement(targetCtx) || this.el; + let inputs = dom_default.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_default.dispatchEvent(inputs[0], PHX_TRACK_UPLOADS, { detail: { files: filesOrBlobs } }); + } + } + targetCtxElement(targetCtx) { + if (isCid(targetCtx)) { + let [target] = dom_default.findComponentNodeList(this.el, targetCtx); + return target; + } else if (targetCtx) { + return targetCtx; + } else { + return null; + } + } + pushFormRecovery(oldForm, newForm, templateDom, callback) { + 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_default.isFormInput(el) && el.name && !el.hasAttribute(phxChange)); + if (inputs.length === 0) { + return; + } + inputs.forEach((input2) => input2.hasAttribute(PHX_UPLOAD_REF) && LiveUploader.clearFiles(input2)); + let input = inputs.find((el) => el.type !== "hidden") || inputs[0]; + let pending = 0; + this.withinTargets(phxTarget, (targetView, targetCtx) => { + const cid = this.targetComponentID(newForm, targetCtx); + pending++; + targetView.pushInput(input, targetCtx, cid, phxEvent, { _target: input.name }, () => { + pending--; + if (pending === 0) { + callback(); + } + }); + }, templateDom, templateDom); + } + pushLinkPatch(e, href, targetEl, callback) { + let linkRef = this.liveSocket.setPendingLink(href); + let loading = e.isTrusted && e.type !== "popstate"; + let refGen = targetEl ? () => this.putRef([{ el: targetEl, 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 }) => { + this.liveSocket.requestDOMUpdate(() => { + if (resp.link_redirect) { + this.liveSocket.replaceMain(href, null, callback, linkRef); + } else { + if (this.liveSocket.commitPendingLink(linkRef)) { + this.href = href; + } + this.applyPendingUpdates(); + callback && callback(linkRef); + } + }); + }, + ({ error: _error, timeout: _timeout }) => fallback() + ); + } + getFormsForRecovery() { + if (this.joinCount === 0) { + return {}; + } + let phxChange = this.binding("change"); + return dom_default.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)).reduce((acc, form) => { + acc[form.id] = form; + return acc; + }, {}); + } + maybePushComponentsDestroyed(destroyedCIDs) { + let willDestroyCIDs = destroyedCIDs.filter((cid) => { + return dom_default.findComponentNodeList(this.el, cid).length === 0; + }); + if (willDestroyCIDs.length > 0) { + willDestroyCIDs.forEach((cid) => this.rendered.resetRender(cid)); + this.pushWithReply(null, "cids_will_destroy", { cids: willDestroyCIDs }).then(() => { + this.liveSocket.requestDOMUpdate(() => { + let completelyDestroyCIDs = willDestroyCIDs.filter((cid) => { + return dom_default.findComponentNodeList(this.el, cid).length === 0; + }); + if (completelyDestroyCIDs.length > 0) { + this.pushWithReply(null, "cids_destroyed", { cids: completelyDestroyCIDs }).then(({ resp }) => { + this.rendered.pruneCIDs(resp.cids); + }); + } + }); + }); + } + } + ownsElement(el) { + let 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_default.putPrivate(form, PHX_HAS_SUBMITTED, true); + const inputs = Array.from(form.elements); + inputs.forEach((input) => dom_default.putPrivate(input, PHX_HAS_SUBMITTED, true)); + this.liveSocket.blurActiveElement(this); + this.pushFormSubmit(form, targetCtx, phxEvent, submitter, opts, () => { + this.liveSocket.restorePreviouslyActiveFocus(); + }); + } + binding(kind) { + return this.liveSocket.binding(kind); + } +}; + +// js/phoenix_live_view/live_socket.js +var isUsedInput = (el) => dom_default.isUsedInput(el); +var LiveSocket = class { + 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.activeElement = null; + 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.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 = /* @__PURE__ */ 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(); + window.addEventListener("pagehide", (_e) => { + this.unloaded = true; + }); + this.socket.onOpen(() => { + if (this.isUnloaded()) { + window.location.reload(); + } + }); + } + // public + version() { + return "1.0.0-rc.7"; + } + isProfileEnabled() { + return this.sessionStorage.getItem(PHX_LV_PROFILE) === "true"; + } + isDebugEnabled() { + return this.sessionStorage.getItem(PHX_LV_DEBUG) === "true"; + } + isDebugDisabled() { + return this.sessionStorage.getItem(PHX_LV_DEBUG) === "false"; + } + enableDebug() { + this.sessionStorage.setItem(PHX_LV_DEBUG, "true"); + } + enableProfiling() { + this.sessionStorage.setItem(PHX_LV_PROFILE, "true"); + } + disableDebug() { + this.sessionStorage.setItem(PHX_LV_DEBUG, "false"); + } + 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); + } + disableLatencySim() { + this.sessionStorage.removeItem(PHX_LV_LATENCY_SIM); + } + getLatencySim() { + let str = this.sessionStorage.getItem(PHX_LV_LATENCY_SIM); + return str ? parseInt(str) : null; + } + getSocket() { + return this.socket; + } + connect() { + 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(); + } else { + this.bindTopLevelEvents({ dead: true }); + } + this.joinDeadView(); + }; + if (["complete", "loaded", "interactive"].indexOf(document.readyState) >= 0) { + doConnect(); + } else { + document.addEventListener("DOMContentLoaded", () => doConnect()); + } + } + disconnect(callback) { + clearTimeout(this.reloadWithJitterTimer); + if (this.serverCloseRef) { + this.socket.off(this.serverCloseRef); + this.serverCloseRef = null; + } + this.socket.disconnect(callback); + } + 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_default.exec(e, eventType, encodedJS, view, el)); + } + // private + execJSHookPush(el, phxEvent, data, callback) { + this.withinOwners(el, (view) => { + let e = new CustomEvent("phx:exec", { detail: { sourceElement: el } }); + js_default.exec(e, "hook", phxEvent, view, el, ["push", { data, callback }]); + }); + } + 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); + } + } + requestDOMUpdate(callback) { + this.transitions.after(callback); + } + 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); + } else { + 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_default.updateLocal(this.localStorage, window.location.pathname, CONSECUTIVE_RELOADS, 0, (count) => count + 1); + if (tries >= this.maxReloads) { + afterMs = this.failsafeJitter; + } + this.reloadWithJitterTimer = setTimeout(() => { + if (view.isDestroyed() || view.isConnected()) { + return; + } + view.destroy(); + log ? log() : this.log(view, "join", () => [`encountered ${tries} consecutive reloads`]); + if (tries >= this.maxReloads) { + this.log(view, "join", () => [`exceeded ${this.maxReloads} consecutive reloads. Entering failsafe mode`]); + } + if (this.hasPendingLink()) { + window.location = this.pendingLink; + } else { + window.location.reload(); + } + }, afterMs); + } + getHookCallbacks(name) { + return name && name.startsWith("Phoenix.") ? hooks_default[name.split(".")[1]] : this.hooks[name]; + } + isUnloaded() { + return this.unloaded; + } + isConnected() { + return this.socket.isConnected(); + } + getBindingPrefix() { + return this.bindingPrefix; + } + binding(kind) { + return `${this.getBindingPrefix()}${kind}`; + } + 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; + } + window.requestAnimationFrame(() => view.execNewMounted()); + } + } + joinRootViews() { + let rootsFound = false; + dom_default.all(document, `${PHX_VIEW_SELECTOR}:not([${PHX_PARENT_ID}])`, (rootEl) => { + if (!this.getRootById(rootEl.id)) { + let view = this.newRootView(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_default.setCookie(PHX_RELOAD_STATUS, reloadToken, 60); + } + this.unload(); + browser_default.redirect(to, flash); + } + replaceMain(href, flash, callback = null, linkRef = this.setPendingLink(href)) { + let liveReferer = this.currentLocation.href; + this.outgoingMainEl = this.outgoingMainEl || this.main.el; + let removeEls = dom_default.all(this.outgoingMainEl, `[${this.binding("remove")}]`); + let newMainEl = dom_default.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, true); + this.main.join((joinCount, onDone) => { + if (joinCount === 1 && this.commitPendingLink(linkRef)) { + this.requestDOMUpdate(() => { + removeEls.forEach((el) => el.remove()); + dom_default.findPhxSticky(document).forEach((el) => newMainEl.appendChild(el)); + this.outgoingMainEl.replaceWith(newMainEl); + this.outgoingMainEl = null; + callback && callback(linkRef); + onDone(); + }); + } + }); + } + transitionRemoves(elements, skipSticky, callback) { + let removeAttr = this.binding("remove"); + if (skipSticky) { + const stickies = dom_default.findPhxSticky(document) || []; + elements = elements.filter((el) => !dom_default.isChildOfAny(el, stickies)); + } + let silenceEvents = (e) => { + e.preventDefault(); + e.stopImmediatePropagation(); + }; + elements.forEach((el) => { + for (let event of this.boundEventNames) { + el.addEventListener(event, silenceEvents, true); + } + this.execJS(el, el.getAttribute(removeAttr), "remove"); + }); + this.requestDOMUpdate(() => { + elements.forEach((el) => { + for (let event of this.boundEventNames) { + el.removeEventListener(event, silenceEvents, true); + } + }); + callback && callback(); + }); + } + 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; + } + owner(childEl, callback) { + let 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)); + } + getViewByEl(el) { + let rootId = el.getAttribute(PHX_ROOT_ID); + return maybe(this.getRootById(rootId), (root) => root.getDescendentByEl(el)); + } + getRootById(id) { + return this.roots[id]; + } + destroyAllViews() { + for (let id in this.roots) { + this.roots[id].destroy(); + delete this.roots[id]; + } + 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); + } + } + getActiveElement() { + return document.activeElement; + } + dropActiveElement(view) { + if (this.prevActive && view.ownsElement(this.prevActive)) { + this.prevActive = null; + } + } + restorePreviouslyActiveFocus() { + if (this.prevActive && this.prevActive !== document.body) { + this.prevActive.focus(); + } + } + blurActiveElement() { + this.prevActive = this.getActiveElement(); + if (this.prevActive !== document.body) { + this.prevActive.blur(); + } + } + bindTopLevelEvents({ dead } = {}) { + if (this.boundTopLevelEvents) { + return; + } + this.boundTopLevelEvents = true; + this.serverCloseRef = this.socket.onClose((event) => { + if (event && event.code === 1e3 && this.main) { + return this.reloadWithJitter(this.main); + } + }); + document.body.addEventListener("click", function() { + }); + window.addEventListener("pageshow", (e) => { + if (e.persisted) { + 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) => { + let matchKey = targetEl.getAttribute(this.binding(PHX_KEY)); + let pressedKey = e.key && e.key.toLowerCase(); + if (matchKey && matchKey.toLowerCase() !== pressedKey) { + return; + } + let data = { key: e.key, ...this.eventMeta(type, e, targetEl) }; + js_default.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_default.exec(e, type, phxEvent, view, targetEl, ["push", { data }]); + } + }); + this.bind({ blur: "blur", focus: "focus" }, (e, type, view, targetEl, phxEvent, phxTarget) => { + if (phxTarget === "window") { + let data = this.eventMeta(type, e, targetEl); + js_default.exec(e, type, phxEvent, view, targetEl, ["push", { data }]); + } + }); + this.on("dragover", (e) => e.preventDefault()); + this.on("drop", (e) => { + e.preventDefault(); + let dropTargetId = maybe(closestPhxBinding(e.target, this.binding(PHX_DROP_TARGET)), (trueTarget) => { + 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_default.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; + } + // 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_default.deleteCookie(PHX_RELOAD_STATUS); + } + commitPendingLink(linkRef) { + if (this.linkRef !== linkRef) { + return false; + } else { + this.href = this.pendingLink; + this.pendingLink = null; + return true; + } + } + getHref() { + return this.href; + } + hasPendingLink() { + return !!this.pendingLink; + } + bind(events, callback) { + for (let event in events) { + let 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.debounce(e.target, e, browserEventName, () => { + this.withinOwners(e.target, (view) => { + callback(e, event, view, e.target, targetPhxEvent, null); + }); + }); + } else { + dom_default.all(document, `[${windowBinding}]`, (el) => { + let phxEvent = el.getAttribute(windowBinding); + this.debounce(el, e, browserEventName, () => { + 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; + if (e.detail === 0) + this.clickStartedAtTarget = e.target; + let clickStartedAtTarget = this.clickStartedAtTarget || e.target; + target = closestPhxBinding(e.target, click); + this.dispatchClickAway(e, clickStartedAtTarget); + this.clickStartedAtTarget = null; + let phxEvent = target && target.getAttribute(click); + if (!phxEvent) { + if (dom_default.isNewPageClick(e, window.location)) { + this.unload(); + } + return; + } + if (target.getAttribute("href") === "#") { + e.preventDefault(); + } + if (target.hasAttribute(PHX_REF_SRC)) { + return; + } + this.debounce(target, e, "click", () => { + this.withinOwners(target, (view) => { + js_default.exec(e, "click", phxEvent, view, target, ["push", { data: this.eventMeta("click", e, target) }]); + }); + }); + }, false); + } + dispatchClickAway(e, clickStartedAt) { + let phxClickAway = this.binding("click-away"); + dom_default.all(document, `[${phxClickAway}]`, (el) => { + if (!(el.isSameNode(clickStartedAt) || el.contains(clickStartedAt))) { + this.withinOwners(el, (view) => { + let phxEvent = el.getAttribute(phxClickAway); + if (js_default.isVisible(el) && js_default.isInViewport(el)) { + js_default.exec(e, "click", phxEvent, view, el, ["push", { data: this.eventMeta("click", e, e.target) }]); + } + }); + } + }); + } + bindNav() { + if (!browser_default.canPushState()) { + return; + } + if (history.scrollRestoration) { + history.scrollRestoration = "manual"; + } + let scrollTimer = null; + window.addEventListener("scroll", (_e) => { + clearTimeout(scrollTimer); + scrollTimer = setTimeout(() => { + browser_default.updateCurrentState((state) => Object.assign(state, { scroll: window.scrollY })); + }, 100); + }); + window.addEventListener("popstate", (event) => { + if (!this.registerNewLocation(window.location)) { + return; + } + let { type, id, root, scroll } = event.state || {}; + let href = window.location.href; + dom_default.dispatchEvent(window, "phx:navigate", { detail: { href, patch: type === "patch", pop: true } }); + this.requestDOMUpdate(() => { + if (this.main.isConnected() && (type === "patch" && id === this.main.id)) { + this.main.pushLinkPatch(event, href, null, () => { + this.maybeScroll(scroll); + }); + } else { + this.replaceMain(href, null, () => { + if (root) { + this.replaceRootHistory(); + } + this.maybeScroll(scroll); + }); + } + }); + }, 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_default.wantsNewTab(e)) { + return; + } + let href = target.href instanceof SVGAnimatedString ? target.href.baseVal : target.href; + let linkState = target.getAttribute(PHX_LINK_STATE); + e.preventDefault(); + e.stopImmediatePropagation(); + 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}`); + } + let phxClick = target.getAttribute(this.binding("click")); + if (phxClick) { + this.requestDOMUpdate(() => this.execJS(target, phxClick, "click")); + } + }); + }, false); + } + maybeScroll(scroll) { + if (typeof scroll === "number") { + requestAnimationFrame(() => { + window.scrollTo(0, scroll); + }); + } + } + dispatchEvent(event, payload = {}) { + dom_default.dispatchEvent(window, `phx:${event}`, { detail: payload }); + } + dispatchEvents(events) { + events.forEach(([event, payload]) => this.dispatchEvent(event, payload)); + } + withPageLoading(info, callback) { + dom_default.dispatchEvent(window, "phx:page-loading-start", { detail: info }); + let done = () => dom_default.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_default.redirect(href); + } + 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; + } + browser_default.pushState(linkState, { type: "patch", id: this.main.id }, href); + dom_default.dispatchEvent(window, "phx:navigate", { detail: { patch: true, href, pop: false } }); + this.registerNewLocation(window.location); + } + historyRedirect(e, href, linkState, flash, targetEl) { + if (targetEl && e.isTrusted && e.type !== "popstate") { + targetEl.classList.add("phx-click-loading"); + } + if (!this.isConnected() || !this.main.isMain()) { + return browser_default.redirect(href, flash); + } + if (/^\/$|^\/[^\/]+.*$/.test(href)) { + let { protocol, host } = window.location; + href = `${protocol}//${host}${href}`; + } + let scroll = window.scrollY; + this.withPageLoading({ to: href, kind: "redirect" }, (done) => { + this.replaceMain(href, flash, (linkRef) => { + if (linkRef === this.linkRef) { + browser_default.pushState(linkState, { type: "redirect", id: this.main.id, scroll }, href); + dom_default.dispatchEvent(window, "phx:navigate", { detail: { href, patch: false, pop: false } }); + this.registerNewLocation(window.location); + } + done(); + }); + }); + } + replaceRootHistory() { + browser_default.pushState("replace", { root: true, type: "patch", id: this.main.id }); + } + registerNewLocation(newLocation) { + let { pathname, search } = this.currentLocation; + if (pathname + search === newLocation.pathname + newLocation.search) { + return false; + } else { + this.currentLocation = clone(newLocation); + return true; + } + } + bindForms() { + let iterations = 0; + let externalFormSubmitted = false; + 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); + window.requestAnimationFrame(() => { + if (dom_default.isUnloadableFormSubmit(e)) { + this.unload(); + } + e.target.submit(); + }); + }); + } + }); + this.on("submit", (e) => { + let phxEvent = e.target.getAttribute(this.binding("submit")); + if (!phxEvent) { + if (dom_default.isUnloadableFormSubmit(e)) { + this.unload(); + } + return; + } + e.preventDefault(); + e.target.disabled = true; + this.withinOwners(e.target, (view) => { + js_default.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 === void 0) { + throw new Error(`dispatching a custom ${type} event is only supported on input elements inside a form`); + } + let phxChange = this.binding("change"); + let input = e.target; + if (e.isComposing) { + const key = `composition-listener-${type}`; + if (!dom_default.private(input, key)) { + dom_default.putPrivate(input, key, true); + input.addEventListener("compositionend", () => { + input.dispatchEvent(new Event(type, { bubbles: true })); + dom_default.deletePrivate(input, key); + }, { once: true }); + } + 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, type: lastType } = dom_default.private(input, "prev-iteration") || {}; + if (at === currentIterations - 1 && type === "change" && lastType === "input") { + return; + } + dom_default.putPrivate(input, "prev-iteration", { at: currentIterations, type }); + this.debounce(input, e, type, () => { + this.withinOwners(dispatcher, (view) => { + dom_default.putPrivate(input, PHX_HAS_FOCUSED, true); + js_default.exec(e, "change", phxEvent, view, input, ["push", { _target: e.target.name, dispatcher }]); + }); + }); + }); + } + this.on("reset", (e) => { + let form = e.target; + dom_default.resetForm(form); + let input = Array.from(form.elements).find((el) => el.type === "reset"); + if (input) { + window.requestAnimationFrame(() => { + 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(); + this.withinOwners(el, (view) => { + let asyncFilter = () => !view.isDestroyed() && document.body.contains(el); + dom_default.debounce(el, event, phxDebounce, defaultDebounce, phxThrottle, defaultThrottle, asyncFilter, () => { + callback(); + }); + }); + } + 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); + } + }); + } + jsQuerySelectorAll(sourceEl, query, defaultQuery) { + let all = this.domCallbacks.jsQuerySelectorAll; + return all ? all(sourceEl, query, defaultQuery) : defaultQuery(); + } +}; +var TransitionSet = class { + constructor() { + this.transitions = /* @__PURE__ */ new Set(); + this.pendingOps = []; + } + reset() { + this.transitions.forEach((timer) => { + clearTimeout(timer); + this.transitions.delete(timer); + }); + this.flushPendingOps(); + } + after(callback) { + if (this.size() === 0) { + callback(); + } else { + this.pushPendingOp(callback); + } + } + addTransition(time, onStart, onDone) { + onStart(); + let timer = setTimeout(() => { + this.transitions.delete(timer); + onDone(); + this.flushPendingOps(); + }, time); + this.transitions.add(timer); + } + pushPendingOp(op) { + this.pendingOps.push(op); + } + size() { + return this.transitions.size; + } + flushPendingOps() { + if (this.size() > 0) { + return; + } + let op = this.pendingOps.shift(); + if (op) { + op(); + this.flushPendingOps(); + } + } +}; + +// js/phoenix_live_view/index.js +var createHook = (el, callbacks = {}) => { + let existingHook = dom_default.getCustomElHook(el); + if (existingHook) { + return existingHook; + } + let hook = new ViewHook(View.closestView(el), el, callbacks); + dom_default.putCustomElHook(el, hook); + return hook; +}; +export { + LiveSocket, + createHook, + isUsedInput +}; +//# sourceMappingURL=phoenix_live_view.esm.js.map diff --git a/lib/munch/accounts/user.ex b/lib/munch/accounts/user.ex index db9141f..9546f96 100644 --- a/lib/munch/accounts/user.ex +++ b/lib/munch/accounts/user.ex @@ -9,6 +9,8 @@ defmodule Munch.Accounts.User do field :hashed_password, :string, redact: true field :current_password, :string, virtual: true, redact: true field :confirmed_at, :utc_datetime + has_many :lists, Munch.Lists.List + has_many :featured_restaurants, Munch.Profile.FeaturedRestaurant timestamps(type: :utc_datetime) end diff --git a/lib/munch/errors.ex b/lib/munch/errors.ex new file mode 100644 index 0000000..5daa70d --- /dev/null +++ b/lib/munch/errors.ex @@ -0,0 +1,3 @@ +defmodule Munch.NotAuthorizedError do + defexception message: "User is not authorized to access this resource." +end diff --git a/lib/munch/lists.ex b/lib/munch/lists.ex index 14c6889..7a2b4c6 100644 --- a/lib/munch/lists.ex +++ b/lib/munch/lists.ex @@ -22,6 +22,10 @@ defmodule Munch.Lists do Repo.all(List) end + def user_lists(user) do + Repo.all(from(l in List, where: l.user_id == ^user.id)) + end + @doc """ Gets a single list. @@ -53,8 +57,8 @@ defmodule Munch.Lists do {:error, %Ecto.Changeset{}} """ - def create_list(attrs \\ %{}) do - %List{} + def create_list(user, attrs \\ %{}) do + %List{user_id: user.id} |> List.changeset(attrs) |> Repo.insert() end @@ -71,10 +75,14 @@ defmodule Munch.Lists do {:error, %Ecto.Changeset{}} """ - def update_list(%List{} = list, attrs) do - list - |> List.changeset(attrs) - |> Repo.update() + def update_list(user, %List{} = list, attrs) do + if list.user_id == user.id do + list + |> List.changeset(attrs) + |> Repo.update() + else + raise Munch.NotAuthorizedError + end end @doc """ @@ -89,8 +97,12 @@ defmodule Munch.Lists do {:error, %Ecto.Changeset{}} """ - def delete_list(%List{} = list) do - Repo.delete(list) + def delete_list(user, %List{} = list) do + if list.user_id == user.id do + Repo.delete(list) + else + raise Munch.NotAuthorizedError + end end @doc """ @@ -109,8 +121,8 @@ defmodule Munch.Lists do @doc """ Creates a changeset and prepends a restaurant to the list items. """ - def change_list_prepend_restaurant(restaurant_id, %List{} = list, attrs \\ %{}) do - List.prepend_restaurant_changeset(list, attrs, restaurant_id) + def changeset_prepend_restaurant(changeset, restaurant_id) do + List.prepend_restaurant(changeset, restaurant_id) end alias Munch.Lists.Item diff --git a/lib/munch/lists/item.ex b/lib/munch/lists/item.ex index a1a96e3..057e868 100644 --- a/lib/munch/lists/item.ex +++ b/lib/munch/lists/item.ex @@ -16,14 +16,15 @@ defmodule Munch.Lists.Item do def changeset(item, attrs) do item |> cast(attrs, [:position, :list_id, :restaurant_id]) - |> validate_required([:position, :list_id, :restaurant_id]) + |> validate_required([:position]) + |> assoc_constraint(:list) + |> assoc_constraint(:restaurant) end @doc false def changeset(item, attrs, position) do item - |> cast(attrs, [:list_id, :restaurant_id]) - |> validate_required([:restaurant_id]) + |> cast(attrs, [:restaurant_id]) |> assoc_constraint(:restaurant) |> put_change(:position, position) end diff --git a/lib/munch/lists/list.ex b/lib/munch/lists/list.ex index cca4687..b8b7c95 100644 --- a/lib/munch/lists/list.ex +++ b/lib/munch/lists/list.ex @@ -24,9 +24,7 @@ defmodule Munch.Lists.List do ) end - def prepend_restaurant_changeset(list, attrs, restaurant_id) do - changeset = changeset(list, attrs) - + def prepend_restaurant(changeset, restaurant_id) do changeset |> put_assoc(:items, [ %Munch.Lists.Item{restaurant_id: restaurant_id} diff --git a/lib/munch/profile.ex b/lib/munch/profile.ex new file mode 100644 index 0000000..98a84f2 --- /dev/null +++ b/lib/munch/profile.ex @@ -0,0 +1,114 @@ +defmodule Munch.Profile do + @moduledoc """ + The Profile context. + """ + + import Ecto.Query, warn: false + alias Munch.Repo + + alias Munch.Profile.FeaturedRestaurant + + @doc """ + Returns the list of featured_restaurants for a user. + + ## Examples + + iex> list_featured_restaurants(user) + [%FeaturedRestaurant{}, ...] + + """ + def list_featured_restaurants(user) do + Repo.all( + from(fr in FeaturedRestaurant, where: fr.user_id == ^user.id, order_by: [asc: fr.position]) + ) + end + + @doc """ + Gets a single featured_restaurant. + + Raises `Ecto.NoResultsError` if the Featured restaurant does not exist. + + ## Examples + + iex> get_featured_restaurant!(123) + %FeaturedRestaurant{} + + iex> get_featured_restaurant!(456) + ** (Ecto.NoResultsError) + + """ + def get_featured_restaurant!(id), do: Repo.get!(FeaturedRestaurant, id) + + @doc """ + Creates a featured_restaurant. + + ## Examples + + iex> create_featured_restaurant(%{field: value}) + {:ok, %FeaturedRestaurant{}} + + iex> create_featured_restaurant(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_featured_restaurant(user, attrs \\ %{}) do + %FeaturedRestaurant{user_id: user.id} + |> FeaturedRestaurant.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a featured_restaurant. + + ## Examples + + iex> update_featured_restaurant(featured_restaurant, %{field: new_value}) + {:ok, %FeaturedRestaurant{}} + + iex> update_featured_restaurant(featured_restaurant, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_featured_restaurant(user, %FeaturedRestaurant{} = featured_restaurant, attrs) do + if featured_restaurant.user_id == user.id do + featured_restaurant + |> FeaturedRestaurant.changeset(attrs) + |> Repo.update() + else + {:error, Munch.NotAuthorizedError} + end + end + + @doc """ + Deletes a featured_restaurant. + + ## Examples + + iex> delete_featured_restaurant(featured_restaurant) + {:ok, %FeaturedRestaurant{}} + + iex> delete_featured_restaurant(featured_restaurant) + {:error, %Ecto.Changeset{}} + + """ + def delete_featured_restaurant(user, %FeaturedRestaurant{} = featured_restaurant) do + if featured_restaurant.user_id == user.id do + Repo.delete(featured_restaurant) + else + {:error, Munch.NotAuthorizedError} + end + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking featured_restaurant changes. + + ## Examples + + iex> change_featured_restaurant(featured_restaurant) + %Ecto.Changeset{data: %FeaturedRestaurant{}} + + """ + def change_featured_restaurant(%FeaturedRestaurant{} = featured_restaurant, attrs \\ %{}) do + FeaturedRestaurant.changeset(featured_restaurant, attrs) + end +end diff --git a/lib/munch/profile/featured_restaurant.ex b/lib/munch/profile/featured_restaurant.ex new file mode 100644 index 0000000..e8f425e --- /dev/null +++ b/lib/munch/profile/featured_restaurant.ex @@ -0,0 +1,22 @@ +defmodule Munch.Profile.FeaturedRestaurant do + use Ecto.Schema + import Ecto.Changeset + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + schema "featured_restaurants" do + field :position, :integer + belongs_to :user, Munch.Accounts.User + belongs_to :restaurant, Munch.Restaurants.Restaurant + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(featured_restaurant, attrs) do + featured_restaurant + |> cast(attrs, [:position, :restaurant_id]) + |> validate_required([:position, :restaurant_id]) + |> assoc_constraint(:restaurant) + end +end diff --git a/lib/munch/restaurants/restaurant.ex b/lib/munch/restaurants/restaurant.ex index 7b56b6a..366b2c6 100644 --- a/lib/munch/restaurants/restaurant.ex +++ b/lib/munch/restaurants/restaurant.ex @@ -7,6 +7,9 @@ defmodule Munch.Restaurants.Restaurant do schema "restaurants" do field :name, :string field :address, :string + field :country, :string + field :city, :string + field :neighbourhood, :string timestamps(type: :utc_datetime) end @@ -14,7 +17,7 @@ defmodule Munch.Restaurants.Restaurant do @doc false def changeset(restaurant, attrs) do restaurant - |> cast(attrs, [:name, :address]) - |> validate_required([:name, :address]) + |> cast(attrs, [:name, :address, :country, :city, :neighbourhood]) + |> validate_required([:name, :address, :country, :city, :neighbourhood]) end end diff --git a/lib/munch_web/components/core_components.ex b/lib/munch_web/components/core_components.ex index 555e468..bb8e5ac 100644 --- a/lib/munch_web/components/core_components.ex +++ b/lib/munch_web/components/core_components.ex @@ -318,74 +318,60 @@ defmodule MunchWeb.CoreComponents do """ end - attr :id, :any, default: nil - attr :name, :any - attr :label, :string, default: nil - attr :value, :any + attr :id, :string, required: true + slot :inner_block, required: true - attr :field, Phoenix.HTML.FormField, - doc: "a form field struct retrieved from the form, for example: @form[:email]" + def modal(assigns) do + ~H""" + + <%= render_slot(@inner_block) %> + + """ + end - attr :rest, :global, - include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength - multiple pattern placeholder readonly required rows size step) + def show_modal(js \\ %JS{}, id) do + js + |> JS.dispatch("munch:show-modal", to: id) + end - attr :results, :list - attr :result_name, :string - attr :new_link, :string - attr :new_label, :string + def close_modal(js \\ %JS{}, id) do + js + |> JS.dispatch("munch:close-modal", to: id) + end - def remote_datalist(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do - assigns - |> assign(field: nil, id: assigns.id || field.id) - |> assign_new(:name, fn -> field.name end) - |> assign_new(:value, fn -> field.value end) - |> remote_datalist() + @doc """ + Renders a restaurant card + """ + attr :restaurant, :map, required: true + + def restaurant_card(assigns) do + ~H""" +
+

<%= @restaurant.name %>

+

<%= @restaurant.neighbourhood %>

+

<%= @restaurant.city %>

+

<%= @restaurant.country %>

+
+ """ end - def remote_datalist(assigns) do + def restaurant_card_add(assigns) do ~H""" -
- <.label for={@id}><%= @label %> - "-search"} - value={Phoenix.HTML.Form.normalize_value("search", @value)} - class={[ - "mt-2 w-full block border-t border-l border-r border-zinc-300 rounded-t-lg focus:border-zinc-400 focus:ring-0", - !@results && "rounded-b-lg" - ]} - autocomplete="off" - {@rest} - /> -
    "-results"} - class="border-b border-l border-r border-zinc-300 rounded-b-lg divide-y bg-zinc-50" - > -
  • - -
  • -
  • - - <%= @new_label %> - -
  • -
+
+ <.icon name="hero-plus" class="h-4 w-4" />
""" end + @doc """ + Renders a restaurant card skeleton + """ + def restaurant_card_skeleton(assigns) do + ~H""" +
Placeholder
+ """ + end + @doc """ Renders a label. """ diff --git a/lib/munch_web/controllers/page_html/home.html.heex b/lib/munch_web/controllers/page_html/home.html.heex index 9ee99fd..57cd1eb 100644 --- a/lib/munch_web/controllers/page_html/home.html.heex +++ b/lib/munch_web/controllers/page_html/home.html.heex @@ -1,2 +1,5 @@

Restaurants

Lists

+

+ Profile +

diff --git a/lib/munch_web/live/item_live/form.ex b/lib/munch_web/live/item_live/form.ex deleted file mode 100644 index eda8852..0000000 --- a/lib/munch_web/live/item_live/form.ex +++ /dev/null @@ -1,93 +0,0 @@ -defmodule MunchWeb.ItemLive.Form do - use MunchWeb, :live_view - - alias Munch.Lists - alias Munch.Lists.Item - - @impl true - def render(assigns) do - ~H""" - <.header> - <%= @page_title %> - <:subtitle>Use this form to manage item records in your database. - - - <.simple_form for={@form} id="item-form" phx-change="validate" phx-submit="save"> - <.input field={@form[:position]} type="number" label="Position" /> - <:actions> - <.button phx-disable-with="Saving...">Save Item - - - - <.back navigate={return_path(@return_to, @item)}>Back - """ - end - - @impl true - def mount(params, _session, socket) do - {:ok, - socket - |> assign(:return_to, return_to(params["return_to"])) - |> apply_action(socket.assigns.live_action, params)} - end - - defp return_to("show"), do: "show" - defp return_to(_), do: "index" - - defp apply_action(socket, :edit, %{"id" => id}) do - item = Lists.get_item!(id) - - socket - |> assign(:page_title, "Edit Item") - |> assign(:item, item) - |> assign(:form, to_form(Lists.change_item(item))) - end - - defp apply_action(socket, :new, _params) do - item = %Item{} - - socket - |> assign(:page_title, "New Item") - |> assign(:item, item) - |> assign(:form, to_form(Lists.change_item(item))) - end - - @impl true - def handle_event("validate", %{"item" => item_params}, socket) do - changeset = Lists.change_item(socket.assigns.item, item_params) - {:noreply, assign(socket, form: to_form(changeset, action: :validate))} - end - - def handle_event("save", %{"item" => item_params}, socket) do - save_item(socket, socket.assigns.live_action, item_params) - end - - defp save_item(socket, :edit, item_params) do - case Lists.update_item(socket.assigns.item, item_params) do - {:ok, item} -> - {:noreply, - socket - |> put_flash(:info, "Item updated successfully") - |> push_navigate(to: return_path(socket.assigns.return_to, item))} - - {:error, %Ecto.Changeset{} = changeset} -> - {:noreply, assign(socket, form: to_form(changeset))} - end - end - - defp save_item(socket, :new, item_params) do - case Lists.create_item(item_params) do - {:ok, item} -> - {:noreply, - socket - |> put_flash(:info, "Item created successfully") - |> push_navigate(to: return_path(socket.assigns.return_to, item))} - - {:error, %Ecto.Changeset{} = changeset} -> - {:noreply, assign(socket, form: to_form(changeset))} - end - end - - defp return_path("index", _item), do: ~p"/list_items" - defp return_path("show", item), do: ~p"/list_items/#{item}" -end diff --git a/lib/munch_web/live/item_live/index.ex b/lib/munch_web/live/item_live/index.ex deleted file mode 100644 index dd75a2a..0000000 --- a/lib/munch_web/live/item_live/index.ex +++ /dev/null @@ -1,57 +0,0 @@ -defmodule MunchWeb.ItemLive.Index do - use MunchWeb, :live_view - - alias Munch.Lists - - @impl true - def render(assigns) do - ~H""" - <.header> - Listing List items - <:actions> - <.link navigate={~p"/list_items/new"}> - <.button>New Item - - - - - <.table - id="list_items" - rows={@streams.list_items} - row_click={fn {_id, item} -> JS.navigate(~p"/list_items/#{item}") end} - > - <:col :let={{_id, item}} label="Position"><%= item.position %> - <:action :let={{_id, item}}> -
- <.link navigate={~p"/list_items/#{item}"}>Show -
- <.link navigate={~p"/list_items/#{item}/edit"}>Edit - - <:action :let={{id, item}}> - <.link - phx-click={JS.push("delete", value: %{id: item.id}) |> hide("##{id}")} - data-confirm="Are you sure?" - > - Delete - - - - """ - end - - @impl true - def mount(_params, _session, socket) do - {:ok, - socket - |> assign(:page_title, "Listing List items") - |> stream(:list_items, Lists.list_list_items())} - end - - @impl true - def handle_event("delete", %{"id" => id}, socket) do - item = Lists.get_item!(id) - {:ok, _} = Lists.delete_item(item) - - {:noreply, stream_delete(socket, :list_items, item)} - end -end diff --git a/lib/munch_web/live/item_live/show.ex b/lib/munch_web/live/item_live/show.ex deleted file mode 100644 index 1286be1..0000000 --- a/lib/munch_web/live/item_live/show.ex +++ /dev/null @@ -1,42 +0,0 @@ -defmodule MunchWeb.ItemLive.Show do - use MunchWeb, :live_view - - alias Munch.Lists - - @impl true - def render(assigns) do - ~H""" - <.header> - Item <%= @item.id %> - <:subtitle>This is a item record from your database. - <:actions> - <.link navigate={~p"/list_items/#{@item}/edit?return_to=show"}> - <.button>Edit item - - - - - <.list> - <:item title="Position"><%= @item.position %> - <:item title="Restaurant ID"><%= @item.restaurant_id %> - <:item title="List ID"><%= @item.list_id %> - <:item title="ID"><%= @item.id %> - - - <.back navigate={~p"/list_items"}>Back to list_items - """ - end - - @impl true - def mount(_params, _session, socket) do - {:ok, socket} - end - - @impl true - def handle_params(%{"id" => id}, _, socket) do - {:noreply, - socket - |> assign(:page_title, "Show Item") - |> assign(:item, Lists.get_item!(id))} - end -end diff --git a/lib/munch_web/live/list_live/form.ex b/lib/munch_web/live/list_live/form.ex index eb328b0..7be1a33 100644 --- a/lib/munch_web/live/list_live/form.ex +++ b/lib/munch_web/live/list_live/form.ex @@ -36,15 +36,22 @@ defmodule MunchWeb.ListLive.Form do
+ <:actions> + <.button type="button" phx-click={show_modal("#restaurant-select-modal")}> + Add item + <.button phx-disable-with="Saving...">Save List - <.live_component - module={MunchWeb.RestaurantLive.SelectComponent} - id="restaurant-select-component" - /> + <.modal id="restaurant-select-modal"> + <.live_component + module={MunchWeb.RestaurantLive.SelectComponent} + id="restaurant-select-component" + submit_action={fn js -> close_modal(js, "#restaurant-select-modal") end} + /> + <.back navigate={return_path(@return_to, @list)}>Back """ @@ -94,7 +101,7 @@ defmodule MunchWeb.ListLive.Form do end defp save_list(socket, :edit, list_params) do - case Lists.update_list(socket.assigns.list, list_params) do + case Lists.update_list(socket.assigns.current_user, socket.assigns.list, list_params) do {:ok, list} -> {:noreply, socket @@ -107,7 +114,7 @@ defmodule MunchWeb.ListLive.Form do end defp save_list(socket, :new, list_params) do - case Lists.create_list(list_params) do + case Lists.create_list(list_params |> Map.put("user_id", socket.assigns.current_user.id)) do {:ok, list} -> {:noreply, socket @@ -120,16 +127,12 @@ defmodule MunchWeb.ListLive.Form do end defp return_path("index", _list), do: ~p"/lists" - defp return_path("show", list), do: ~p"/lists/#{list}" + defp return_path("show", list), do: ~p"/list/#{list}" @impl true def handle_info({:restaurant_selected, restaurant_id}, socket) do changeset = - Lists.change_list_prepend_restaurant( - restaurant_id, - socket.assigns.list, - socket.assigns.form.params - ) + Lists.changeset_prepend_restaurant(socket.assigns.form.source, restaurant_id) {:noreply, socket diff --git a/lib/munch_web/live/list_live/index.ex b/lib/munch_web/live/list_live/index.ex index 2ea17ea..75474ad 100644 --- a/lib/munch_web/live/list_live/index.ex +++ b/lib/munch_web/live/list_live/index.ex @@ -18,14 +18,14 @@ defmodule MunchWeb.ListLive.Index do <.table id="lists" rows={@streams.lists} - row_click={fn {_id, list} -> JS.navigate(~p"/lists/#{list}") end} + row_click={fn {_id, list} -> JS.navigate(~p"/list/#{list}") end} > <:col :let={{_id, list}} label="Name"><%= list.name %> <:action :let={{_id, list}}>
- <.link navigate={~p"/lists/#{list}"}>Show + <.link navigate={~p"/list/#{list}"}>Show
- <.link navigate={~p"/lists/#{list}/edit"}>Edit + <.link navigate={~p"/list/#{list}/edit"}>Edit <:action :let={{id, list}}> <.link @@ -50,7 +50,7 @@ defmodule MunchWeb.ListLive.Index do @impl true def handle_event("delete", %{"id" => id}, socket) do list = Lists.get_list!(id) - {:ok, _} = Lists.delete_list(list) + {:ok, _} = Lists.delete_list(socket.assigns.current_user, list) {:noreply, stream_delete(socket, :lists, list)} end diff --git a/lib/munch_web/live/list_live/show.ex b/lib/munch_web/live/list_live/show.ex index 5e6f4de..9050749 100644 --- a/lib/munch_web/live/list_live/show.ex +++ b/lib/munch_web/live/list_live/show.ex @@ -7,19 +7,29 @@ defmodule MunchWeb.ListLive.Show do def render(assigns) do ~H""" <.header> - List <%= @list.id %> - <:subtitle>This is a list record from your database. + <%= @list.name %> + <:subtitle>Inserted <%= @list.inserted_at %> <:actions> - <.link navigate={~p"/lists/#{@list}/edit?return_to=show"}> + <.link navigate={~p"/list/#{@list}/edit?return_to=show"}> <.button>Edit list - <.list> - <:item title="Name"><%= @list.name %> - <:item title="ID"><%= @list.id %> - +

Details

+ + +

Items

+ <.back navigate={~p"/lists"}>Back to lists """ diff --git a/lib/munch_web/live/restaurant_live/form.ex b/lib/munch_web/live/restaurant_live/form.ex index 7bb6fcb..9ffcde0 100644 --- a/lib/munch_web/live/restaurant_live/form.ex +++ b/lib/munch_web/live/restaurant_live/form.ex @@ -15,6 +15,9 @@ defmodule MunchWeb.RestaurantLive.Form do <.simple_form for={@form} id="restaurant-form" phx-change="validate" phx-submit="save"> <.input field={@form[:name]} type="text" label="Name" /> <.input field={@form[:address]} type="text" label="Address" /> + <.input field={@form[:country]} type="text" label="Country" /> + <.input field={@form[:city]} type="text" label="City" /> + <.input field={@form[:neighbourhood]} type="text" label="Neighbourhood" /> <:actions> <.button phx-disable-with="Saving...">Save Restaurant @@ -90,5 +93,5 @@ defmodule MunchWeb.RestaurantLive.Form do end defp return_path("index", _restaurant), do: ~p"/restaurants" - defp return_path("show", restaurant), do: ~p"/restaurants/#{restaurant}" + defp return_path("show", restaurant), do: ~p"/restaurant/#{restaurant}" end diff --git a/lib/munch_web/live/restaurant_live/index.ex b/lib/munch_web/live/restaurant_live/index.ex index 7ee1bdf..9e54103 100644 --- a/lib/munch_web/live/restaurant_live/index.ex +++ b/lib/munch_web/live/restaurant_live/index.ex @@ -18,15 +18,15 @@ defmodule MunchWeb.RestaurantLive.Index do <.table id="restaurants" rows={@streams.restaurants} - row_click={fn {_id, restaurant} -> JS.navigate(~p"/restaurants/#{restaurant}") end} + row_click={fn {_id, restaurant} -> JS.navigate(~p"/restaurant/#{restaurant}") end} > <:col :let={{_id, restaurant}} label="Name"><%= restaurant.name %> <:col :let={{_id, restaurant}} label="Address"><%= restaurant.address %> <:action :let={{_id, restaurant}}>
- <.link navigate={~p"/restaurants/#{restaurant}"}>Show + <.link navigate={~p"/restaurant/#{restaurant}"}>Show
- <.link navigate={~p"/restaurants/#{restaurant}/edit"}>Edit + <.link navigate={~p"/restaurant/#{restaurant}/edit"}>Edit <:action :let={{id, restaurant}}> <.link diff --git a/lib/munch_web/live/restaurant_live/select_component.ex b/lib/munch_web/live/restaurant_live/select_component.ex index 2baa2f6..2afca72 100644 --- a/lib/munch_web/live/restaurant_live/select_component.ex +++ b/lib/munch_web/live/restaurant_live/select_component.ex @@ -12,24 +12,45 @@ defmodule MunchWeb.RestaurantLive.SelectComponent do def render(assigns) do ~H""" -
- <.simple_form +
+ <.form for={@form} id="restaurant-select-form" phx-change="validate" - phx-submit="save" + phx-submit={JS.push("save") |> @submit_action.()} phx-target={@myself} > - <.remote_datalist - field={@form[:search]} - label="Add a restaurant" - value="" - result_name="restaurant_id" - results={@restaurants} - new_link={~p"/restaurants/new"} - new_label="Add new restaurant..." + - + +
""" end @@ -46,15 +67,17 @@ defmodule MunchWeb.RestaurantLive.SelectComponent do _ -> Restaurants.search_restaurants(search) - |> Enum.map(fn restaurant -> - %{value: restaurant.id, pretty: "#{restaurant.name} (#{restaurant.address})"} - end) end )} end def handle_event("save", %{"restaurant_id" => restaurant_id}, socket) do send(self(), {:restaurant_selected, restaurant_id}) + {:noreply, socket |> assign(:form, to_form(%{})) |> assign(:restaurants, nil)} + end + + def handle_event("save", params, socket) do + IO.inspect(params) {:noreply, socket} end end diff --git a/lib/munch_web/live/restaurant_live/show.ex b/lib/munch_web/live/restaurant_live/show.ex index a571db9..2fd2b0f 100644 --- a/lib/munch_web/live/restaurant_live/show.ex +++ b/lib/munch_web/live/restaurant_live/show.ex @@ -10,7 +10,7 @@ defmodule MunchWeb.RestaurantLive.Show do Restaurant <%= @restaurant.id %> <:subtitle>This is a restaurant record from your database. <:actions> - <.link navigate={~p"/restaurants/#{@restaurant}/edit?return_to=show"}> + <.link navigate={~p"/restaurant/#{@restaurant}/edit?return_to=show"}> <.button>Edit restaurant @@ -18,6 +18,9 @@ defmodule MunchWeb.RestaurantLive.Show do <.list> <:item title="Name"><%= @restaurant.name %> + <:item title="Country"><%= @restaurant.country %> + <:item title="City"><%= @restaurant.city %> + <:item title="Neighbourhood"><%= @restaurant.neighbourhood %> <:item title="Address"><%= @restaurant.address %> diff --git a/lib/munch_web/live/user_confirmation_live.ex b/lib/munch_web/live/user_live/confirmation.ex similarity index 97% rename from lib/munch_web/live/user_confirmation_live.ex rename to lib/munch_web/live/user_live/confirmation.ex index 18a797b..c04dd24 100644 --- a/lib/munch_web/live/user_confirmation_live.ex +++ b/lib/munch_web/live/user_live/confirmation.ex @@ -1,4 +1,4 @@ -defmodule MunchWeb.UserConfirmationLive do +defmodule MunchWeb.UserLive.Confirmation do use MunchWeb, :live_view alias Munch.Accounts diff --git a/lib/munch_web/live/user_confirmation_instructions_live.ex b/lib/munch_web/live/user_live/confirmation_instructions.ex similarity index 96% rename from lib/munch_web/live/user_confirmation_instructions_live.ex rename to lib/munch_web/live/user_live/confirmation_instructions.ex index 2bd5686..5e338f0 100644 --- a/lib/munch_web/live/user_confirmation_instructions_live.ex +++ b/lib/munch_web/live/user_live/confirmation_instructions.ex @@ -1,4 +1,4 @@ -defmodule MunchWeb.UserConfirmationInstructionsLive do +defmodule MunchWeb.UserLive.ConfirmationInstructions do use MunchWeb, :live_view alias Munch.Accounts diff --git a/lib/munch_web/live/user_forgot_password_live.ex b/lib/munch_web/live/user_live/forgot_password.ex similarity index 96% rename from lib/munch_web/live/user_forgot_password_live.ex rename to lib/munch_web/live/user_live/forgot_password.ex index 2ddf98d..4bcec8f 100644 --- a/lib/munch_web/live/user_forgot_password_live.ex +++ b/lib/munch_web/live/user_live/forgot_password.ex @@ -1,4 +1,4 @@ -defmodule MunchWeb.UserForgotPasswordLive do +defmodule MunchWeb.UserLive.ForgotPassword do use MunchWeb, :live_view alias Munch.Accounts diff --git a/lib/munch_web/live/user_login_live.ex b/lib/munch_web/live/user_live/login.ex similarity index 97% rename from lib/munch_web/live/user_login_live.ex rename to lib/munch_web/live/user_live/login.ex index 24e7c46..3a87b04 100644 --- a/lib/munch_web/live/user_login_live.ex +++ b/lib/munch_web/live/user_live/login.ex @@ -1,4 +1,4 @@ -defmodule MunchWeb.UserLoginLive do +defmodule MunchWeb.UserLive.Login do use MunchWeb, :live_view def render(assigns) do diff --git a/lib/munch_web/live/user_live/profile.ex b/lib/munch_web/live/user_live/profile.ex new file mode 100644 index 0000000..6190230 --- /dev/null +++ b/lib/munch_web/live/user_live/profile.ex @@ -0,0 +1,48 @@ +defmodule MunchWeb.UserLive.Profile do + use MunchWeb, :live_view + + alias Munch.Accounts + alias Munch.Profile + alias Munch.Lists + + @impl true + def render(assigns) do + ~H""" + <.header> + Profile + + + <%= if @user == @current_user do %> + Edit Profile + <% end %> + +

Featured Restaurants

+ <%= for featured_restaurant <- @featured_restaurants do %> + <.restaurant_card restaurant={featured_restaurant.restaurant} /> + <% end %> + <%= for _ <- 0..(3 - length(@featured_restaurants)) do %> + <.restaurant_card_skeleton /> + <% end %> + +

Lists

+ <%= for list <- @lists do %> +
<%= list.name %>
+ <% end %> + """ + end + + @impl true + def mount(_params, _session, socket) do + {:ok, socket} + end + + @impl true + def handle_params(%{"id" => id}, _, socket) do + user = Accounts.get_user!(id) + featured_restaurants = Profile.list_featured_restaurants(user) + lists = Lists.user_lists(user) + + {:noreply, + assign(socket, user: user, featured_restaurants: featured_restaurants, lists: lists)} + end +end diff --git a/lib/munch_web/live/user_live/profile_form.ex b/lib/munch_web/live/user_live/profile_form.ex new file mode 100644 index 0000000..90018d2 --- /dev/null +++ b/lib/munch_web/live/user_live/profile_form.ex @@ -0,0 +1,48 @@ +defmodule MunchWeb.UserLive.ProfileForm do + use MunchWeb, :live_view + + alias Munch.Profile + + @impl true + def render(assigns) do + ~H""" + <.header> + Profile + + +

Featured Restaurants

+ <%= for featured_restaurant <- @featured_restaurants do %> + <.restaurant_card restaurant={featured_restaurant.restaurant} /> + <% end %> + <%= if length(@featured_restaurants) < 4 do %> +
+ <.restaurant_card_add /> +
+ <% end %> + <%= for _ <- 0..(2 - length(@featured_restaurants)) do %> + <.restaurant_card_skeleton /> + <% end %> + + <.modal id="add-featured-restaurant"> + <.live_component + module={MunchWeb.RestaurantLive.SelectComponent} + id="add-featured-restaurant-select" + submit_action={fn js -> close_modal(js, "#add-featured-restaurant") end} + /> + + """ + end + + @impl true + def mount(_params, _session, socket) do + user = socket.assigns.current_user + featured_restaurants = Profile.list_featured_restaurants(user) + {:ok, assign(socket, user: user, featured_restaurants: featured_restaurants)} + end + + @impl true + def handle_info({:restaurant_selected, restaurant_id}, socket) do + IO.inspect(restaurant_id) + {:noreply, socket} + end +end diff --git a/lib/munch_web/live/user_registration_live.ex b/lib/munch_web/live/user_live/registration.ex similarity index 98% rename from lib/munch_web/live/user_registration_live.ex rename to lib/munch_web/live/user_live/registration.ex index 0a031a1..d7dc7a5 100644 --- a/lib/munch_web/live/user_registration_live.ex +++ b/lib/munch_web/live/user_live/registration.ex @@ -1,4 +1,4 @@ -defmodule MunchWeb.UserRegistrationLive do +defmodule MunchWeb.UserLive.Registration do use MunchWeb, :live_view alias Munch.Accounts diff --git a/lib/munch_web/live/user_reset_password_live.ex b/lib/munch_web/live/user_live/reset_password.ex similarity index 98% rename from lib/munch_web/live/user_reset_password_live.ex rename to lib/munch_web/live/user_live/reset_password.ex index a8e433c..d47e2fd 100644 --- a/lib/munch_web/live/user_reset_password_live.ex +++ b/lib/munch_web/live/user_live/reset_password.ex @@ -1,4 +1,4 @@ -defmodule MunchWeb.UserResetPasswordLive do +defmodule MunchWeb.UserLive.ResetPassword do use MunchWeb, :live_view alias Munch.Accounts diff --git a/lib/munch_web/live/user_settings_live.ex b/lib/munch_web/live/user_live/settings.ex similarity index 99% rename from lib/munch_web/live/user_settings_live.ex rename to lib/munch_web/live/user_live/settings.ex index 807d3f0..79f1a34 100644 --- a/lib/munch_web/live/user_settings_live.ex +++ b/lib/munch_web/live/user_live/settings.ex @@ -1,4 +1,4 @@ -defmodule MunchWeb.UserSettingsLive do +defmodule MunchWeb.UserLive.Settings do use MunchWeb, :live_view alias Munch.Accounts diff --git a/lib/munch_web/router.ex b/lib/munch_web/router.ex index 3799f0a..9e15275 100644 --- a/lib/munch_web/router.ex +++ b/lib/munch_web/router.ex @@ -17,27 +17,6 @@ defmodule MunchWeb.Router do plug :accepts, ["json"] end - scope "/", MunchWeb do - pipe_through :browser - - get "/", PageController, :home - - live "/restaurants", RestaurantLive.Index, :index - live "/restaurants/new", RestaurantLive.Form, :new - live "/restaurants/:id", RestaurantLive.Show, :show - live "/restaurants/:id/edit", RestaurantLive.Form, :edit - - live "/lists", ListLive.Index, :index - live "/lists/new", ListLive.Form, :new - live "/lists/:id", ListLive.Show, :show - live "/lists/:id/edit", ListLive.Form, :edit - - live "/list_items", ItemLive.Index, :index - live "/list_items/new", ItemLive.Form, :new - live "/list_items/:id", ItemLive.Show, :show - live "/list_items/:id/edit", ItemLive.Form, :edit - end - # Other scopes may use custom stacks. # scope "/api", MunchWeb do # pipe_through :api @@ -60,17 +39,15 @@ defmodule MunchWeb.Router do end end - ## Authentication routes - scope "/", MunchWeb do pipe_through [:browser, :redirect_if_user_is_authenticated] live_session :redirect_if_user_is_authenticated, on_mount: [{MunchWeb.UserAuth, :redirect_if_user_is_authenticated}] do - live "/users/register", UserRegistrationLive, :new - live "/users/log_in", UserLoginLive, :new - live "/users/reset_password", UserForgotPasswordLive, :new - live "/users/reset_password/:token", UserResetPasswordLive, :edit + live "/users/register", UserLive.Registration, :new + live "/users/log_in", UserLive.Login, :new + live "/users/reset_password", UserLive.ForgotPassword, :new + live "/users/reset_password/:token", UserLive.ResetPassword, :edit end post "/users/log_in", UserSessionController, :create @@ -81,20 +58,37 @@ defmodule MunchWeb.Router do live_session :require_authenticated_user, on_mount: [{MunchWeb.UserAuth, :ensure_authenticated}] do - live "/users/settings", UserSettingsLive, :edit - live "/users/settings/confirm_email/:token", UserSettingsLive, :confirm_email + live "/users/settings", UserLive.Settings, :edit + live "/users/settings/confirm_email/:token", UserLive.Settings, :confirm_email + live "/users/edit", UserLive.ProfileForm, :edit + + live "/restaurants/new", RestaurantLive.Form, :new + live "/restaurant/:id/edit", RestaurantLive.Form, :edit + + live "/lists/new", ListLive.Form, :new + live "/list/:id/edit", ListLive.Form, :edit end end scope "/", MunchWeb do pipe_through [:browser] + get "/", PageController, :home + delete "/users/log_out", UserSessionController, :delete live_session :current_user, on_mount: [{MunchWeb.UserAuth, :mount_current_user}] do - live "/users/confirm/:token", UserConfirmationLive, :edit - live "/users/confirm", UserConfirmationInstructionsLive, :new + live "/users/confirm/:token", UserLive.Confirmation, :edit + live "/users/confirm", UserLive.ConfirmationInstructions, :new + + live "/restaurants", RestaurantLive.Index, :index + live "/restaurant/:id", RestaurantLive.Show, :show + + live "/lists", ListLive.Index, :index + live "/list/:id", ListLive.Show, :show + + live "/user/:id", UserLive.Profile, :show end end end diff --git a/mix.exs b/mix.exs index 2c58994..5e8edb4 100644 --- a/mix.exs +++ b/mix.exs @@ -39,8 +39,7 @@ defmodule Munch.MixProject do {:postgrex, ">= 0.0.0"}, {:phoenix_html, "~> 4.1"}, {:phoenix_live_reload, "~> 1.2", only: :dev}, - # TODO bump on release to {:phoenix_live_view, "~> 1.0.0"}, - {:phoenix_live_view, "~> 1.0.0-rc.1", override: true}, + {:phoenix_live_view, "~> 1.0.0-rc.7"}, {:floki, ">= 0.30.0", only: :test}, {:phoenix_live_dashboard, "~> 0.8.3"}, {:esbuild, "~> 0.8", runtime: Mix.env() == :dev}, diff --git a/mix.lock b/mix.lock index f2ba43e..dc6a253 100644 --- a/mix.lock +++ b/mix.lock @@ -24,7 +24,7 @@ "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.6.3", "f686701b0499a07f2e3b122d84d52ff8a31f5def386e03706c916f6feddf69ef", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "909502956916a657a197f94cc1206d9a65247538de8a5e186f7537c895d95764"}, "phoenix_html": {:hex, :phoenix_html, "4.1.1", "4c064fd3873d12ebb1388425a8f2a19348cef56e7289e1998e2d2fa758aa982e", [:mix], [], "hexpm", "f2f2df5a72bc9a2f510b21497fd7d2b86d932ec0598f0210fed4114adc546c6f"}, - "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.4", "4508e481f791ce62ec6a096e13b061387158cbeefacca68c6c1928e1305e23ed", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "2984aae96994fbc5c61795a73b8fb58153b41ff934019cfb522343d2d3817d59"}, + "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.4-rc.0", "3af26124c9bea60253db50360912cb59668580ede2e702e7324536fb9d1cb523", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0-rc", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "66885624771f1a59f5b0ed6c24cbcec70fb2544356cb8085fd85320e6593720a"}, "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.5.3", "f2161c207fda0e4fb55165f650f7f8db23f02b29e3bff00ff7ef161d6ac1f09d", [:mix], [{:file_system, "~> 0.3 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "b4ec9cd73cb01ff1bd1cac92e045d13e7030330b74164297d1aee3907b54803c"}, "phoenix_live_view": {:hex, :phoenix_live_view, "1.0.0-rc.7", "d2abca526422adea88896769529addb6443390b1d4f1ff9cbe694312d8875fb2", [:mix], [{:floki, "~> 0.36", [hex: :floki, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b82a4575f6f3eb5b97922ec6874b0c52b3ca0cc5dcb4b14ddc478cbfa135dd01"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, @@ -33,7 +33,7 @@ "plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"}, "postgrex": {:hex, :postgrex, "0.19.2", "34d6884a332c7bf1e367fc8b9a849d23b43f7da5c6e263def92784d03f9da468", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "618988886ab7ae8561ebed9a3c7469034bf6a88b8995785a3378746a4b9835ec"}, "req": {:hex, :req, "0.5.7", "b722680e03d531a2947282adff474362a48a02aa54b131196fbf7acaff5e4cee", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "c6035374615120a8923e8089d0c21a3496cf9eda2d287b806081b8f323ceee29"}, - "swoosh": {:hex, :swoosh, "1.17.2", "73611f08fc7cb9fa15f4909db36eeb12b70727d5c8b6a7fa0d4a31c6575db29e", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "de914359f0ddc134dc0d7735e28922d49d0503f31e4bd66b44e26039c2226d39"}, + "swoosh": {:hex, :swoosh, "1.17.3", "5cda7bff6bc1121cc5b58db8ed90ef33261b373425ae3e32dd599688037a0482", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "14ad57cfbb70af57323e17f569f5840a33c01f8ebc531dd3846beef3c9c95e55"}, "tailwind": {:hex, :tailwind, "0.2.4", "5706ec47182d4e7045901302bf3a333e80f3d1af65c442ba9a9eed152fb26c2e", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "c6e4a82b8727bab593700c998a4d98cf3d8025678bfde059aed71d0000c3e463"}, "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, "telemetry_metrics": {:hex, :telemetry_metrics, "1.0.0", "29f5f84991ca98b8eb02fc208b2e6de7c95f8bb2294ef244a176675adc7775df", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f23713b3847286a534e005126d4c959ebcca68ae9582118ce436b521d1d47d5d"}, diff --git a/priv/repo/migrations/20241008001216_create_restaurants.exs b/priv/repo/migrations/20241008001216_create_restaurants.exs index 6074579..96c456e 100644 --- a/priv/repo/migrations/20241008001216_create_restaurants.exs +++ b/priv/repo/migrations/20241008001216_create_restaurants.exs @@ -4,8 +4,11 @@ defmodule Munch.Repo.Migrations.CreateRestaurants do def change do create table(:restaurants, primary_key: false) do add :id, :binary_id, primary_key: true - add :name, :string - add :address, :string + add :name, :string, null: false + add :address, :string, null: false + add :country, :string, null: false + add :city, :string, null: false + add :neighbourhood, :string, null: false timestamps(type: :utc_datetime) end diff --git a/priv/repo/migrations/20241008002021_create_lists.exs b/priv/repo/migrations/20241008002021_create_lists.exs index 402eb25..981fb97 100644 --- a/priv/repo/migrations/20241008002021_create_lists.exs +++ b/priv/repo/migrations/20241008002021_create_lists.exs @@ -4,8 +4,8 @@ defmodule Munch.Repo.Migrations.CreateLists do def change do create table(:lists, primary_key: false) do add :id, :binary_id, primary_key: true - add :name, :string - add :user_id, references(:users, on_delete: :nothing, type: :binary_id) + add :name, :string, null: false + add :user_id, references(:users, on_delete: :delete_all, type: :binary_id), null: false timestamps(type: :utc_datetime) end diff --git a/priv/repo/migrations/20241008010010_create_list_items.exs b/priv/repo/migrations/20241008010010_create_list_items.exs index bd78ab2..2b94a16 100644 --- a/priv/repo/migrations/20241008010010_create_list_items.exs +++ b/priv/repo/migrations/20241008010010_create_list_items.exs @@ -4,9 +4,11 @@ defmodule Munch.Repo.Migrations.CreateListItems do def change do create table(:list_items, primary_key: false) do add :id, :binary_id, primary_key: true - add :position, :integer - add :list_id, references(:lists, on_delete: :delete_all, type: :binary_id) - add :restaurant_id, references(:restaurants, on_delete: :nothing, type: :binary_id) + add :position, :integer, null: false + add :list_id, references(:lists, on_delete: :delete_all, type: :binary_id), null: false + + add :restaurant_id, references(:restaurants, on_delete: :restrict, type: :binary_id), + null: false timestamps(type: :utc_datetime) end diff --git a/priv/repo/migrations/20241112014159_create_featured_restaurants.exs b/priv/repo/migrations/20241112014159_create_featured_restaurants.exs new file mode 100644 index 0000000..66c073f --- /dev/null +++ b/priv/repo/migrations/20241112014159_create_featured_restaurants.exs @@ -0,0 +1,19 @@ +defmodule Munch.Repo.Migrations.CreateFeaturedRestaurants do + use Ecto.Migration + + def change do + create table(:featured_restaurants, primary_key: false) do + add :id, :binary_id, primary_key: true + add :position, :integer, null: false + add :user_id, references(:users, on_delete: :nothing, type: :binary_id), null: false + + add :restaurant_id, references(:restaurants, on_delete: :nothing, type: :binary_id), + null: false + + timestamps(type: :utc_datetime) + end + + create index(:featured_restaurants, [:user_id]) + create index(:featured_restaurants, [:restaurant_id]) + end +end diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index 8000a3f..353f09d 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -12,25 +12,40 @@ Munch.Repo.insert!(%Munch.Restaurants.Restaurant{ name: "Mappen", - address: "shop 11/537-551 George St, Sydney NSW 2000, Australia" + address: "shop 11/537-551 George St, Sydney NSW 2000, Australia", + country: "Australia", + city: "Sydney", + neighbourhood: "Haymarket" }) Munch.Repo.insert!(%Munch.Restaurants.Restaurant{ name: "Chaco Ramen", - address: "238 Crown St, Darlinghurst NSW 2010, Australia" + address: "238 Crown St, Darlinghurst NSW 2010, Australia", + country: "Australia", + city: "Sydney", + neighbourhood: "Darlinghurst" }) Munch.Repo.insert!(%Munch.Restaurants.Restaurant{ name: "Emperor's Garden Cakes & Bakery", - address: "75 Dixon St, Haymarket NSW 2000, Australia" + address: "75 Dixon St, Haymarket NSW 2000, Australia", + country: "Australia", + city: "Sydney", + neighbourhood: "Haymarket" }) Munch.Repo.insert!(%Munch.Restaurants.Restaurant{ name: "Ice Kirin Bar", - address: "486/488 Kent St, Sydney NSW 2000, Australia" + address: "486/488 Kent St, Sydney NSW 2000, Australia", + country: "Australia", + city: "Sydney", + neighbourhood: "Haymarket" }) Munch.Repo.insert!(%Munch.Restaurants.Restaurant{ name: "Pho Pasteur", - address: "709 George St, Haymarket NSW 2000, Australia" + address: "709 George St, Haymarket NSW 2000, Australia", + country: "Australia", + city: "Sydney", + neighbourhood: "Haymarket" }) diff --git a/priv/static/images/logo.svg b/priv/static/images/logo.svg deleted file mode 100644 index 9f26bab..0000000 --- a/priv/static/images/logo.svg +++ /dev/null @@ -1,6 +0,0 @@ - diff --git a/test/munch/profile_test.exs b/test/munch/profile_test.exs new file mode 100644 index 0000000..b0a2d57 --- /dev/null +++ b/test/munch/profile_test.exs @@ -0,0 +1,59 @@ +defmodule Munch.ProfileTest do + use Munch.DataCase + + alias Munch.Profile + + describe "featured_restaurants" do + alias Munch.Profile.FeaturedRestaurant + + import Munch.ProfileFixtures + + @invalid_attrs %{position: nil} + + test "list_featured_restaurants/0 returns all featured_restaurants" do + featured_restaurant = featured_restaurant_fixture() + assert Profile.list_featured_restaurants() == [featured_restaurant] + end + + test "get_featured_restaurant!/1 returns the featured_restaurant with given id" do + featured_restaurant = featured_restaurant_fixture() + assert Profile.get_featured_restaurant!(featured_restaurant.id) == featured_restaurant + end + + test "create_featured_restaurant/1 with valid data creates a featured_restaurant" do + valid_attrs = %{position: 42} + + assert {:ok, %FeaturedRestaurant{} = featured_restaurant} = Profile.create_featured_restaurant(valid_attrs) + assert featured_restaurant.position == 42 + end + + test "create_featured_restaurant/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = Profile.create_featured_restaurant(@invalid_attrs) + end + + test "update_featured_restaurant/2 with valid data updates the featured_restaurant" do + featured_restaurant = featured_restaurant_fixture() + update_attrs = %{position: 43} + + assert {:ok, %FeaturedRestaurant{} = featured_restaurant} = Profile.update_featured_restaurant(featured_restaurant, update_attrs) + assert featured_restaurant.position == 43 + end + + test "update_featured_restaurant/2 with invalid data returns error changeset" do + featured_restaurant = featured_restaurant_fixture() + assert {:error, %Ecto.Changeset{}} = Profile.update_featured_restaurant(featured_restaurant, @invalid_attrs) + assert featured_restaurant == Profile.get_featured_restaurant!(featured_restaurant.id) + end + + test "delete_featured_restaurant/1 deletes the featured_restaurant" do + featured_restaurant = featured_restaurant_fixture() + assert {:ok, %FeaturedRestaurant{}} = Profile.delete_featured_restaurant(featured_restaurant) + assert_raise Ecto.NoResultsError, fn -> Profile.get_featured_restaurant!(featured_restaurant.id) end + end + + test "change_featured_restaurant/1 returns a featured_restaurant changeset" do + featured_restaurant = featured_restaurant_fixture() + assert %Ecto.Changeset{} = Profile.change_featured_restaurant(featured_restaurant) + end + end +end diff --git a/test/support/fixtures/profile_fixtures.ex b/test/support/fixtures/profile_fixtures.ex new file mode 100644 index 0000000..88c7abb --- /dev/null +++ b/test/support/fixtures/profile_fixtures.ex @@ -0,0 +1,20 @@ +defmodule Munch.ProfileFixtures do + @moduledoc """ + This module defines test helpers for creating + entities via the `Munch.Profile` context. + """ + + @doc """ + Generate a featured_restaurant. + """ + def featured_restaurant_fixture(attrs \\ %{}) do + {:ok, featured_restaurant} = + attrs + |> Enum.into(%{ + position: 42 + }) + |> Munch.Profile.create_featured_restaurant() + + featured_restaurant + end +end