diff --git a/.formatter.exs b/.formatter.exs index 47616780b5..9fe16a0d0d 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -1,4 +1,17 @@ +locals_without_parens = [ + attr: 2, + attr: 3, + embed_templates: 1, + embed_templates: 2, + slot: 1, + slot: 2, + slot: 3 +] + [ import_deps: [:phoenix], - inputs: ["*.{ex,exs}", "{config,lib,test}/**/*.{ex,exs}"] + inputs: ["*.{ex,exs}", "{config,lib,test}/**/*.{ex,exs}"], + # TODO: Remove these on Phoenix v1.7 since Phoenix provides them already + locals_without_parens: locals_without_parens, + export: [locals_without_parens: locals_without_parens] ] diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 9b771c287c..8f4faada97 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -19,7 +19,10 @@ assignees: '' ### Actual behavior ### Expected behavior diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 543059ee7e..0186031faf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,36 +13,25 @@ jobs: strategy: matrix: include: - - elixir: 1.7.4 - otp: 21.3.8.16 - - - elixir: 1.8.2 - otp: 21.3.8.16 - - - elixir: 1.9.4 - otp: 21.3.8.16 - - - elixir: 1.10.4 - otp: 21.3.8.16 - - - elixir: 1.11.4 - otp: 21.3.8.16 + - elixir: 1.12.0 + otp: 24.2 - - elixir: 1.11.4 - otp: 23.3 + - elixir: 1.13.2 + otp: 24.2 lint: lint - - elixir: 1.12.0 - otp: 23.3 - runs-on: ubuntu-latest steps: + - name: Install inotify-tools + run: | + sudo apt update + sudo apt install -y inotify-tools - name: Checkout uses: actions/checkout@v2 - name: Set up Elixir - uses: erlef/setup-elixir@v1 + uses: erlef/setup-beam@v1 with: elixir-version: ${{ matrix.elixir }} otp-version: ${{ matrix.otp }} @@ -90,10 +79,10 @@ jobs: uses: actions/checkout@v2 - name: Set up Elixir - uses: erlef/setup-elixir@v1 + uses: erlef/setup-beam@v1 with: - elixir-version: 1.11.4 - otp-version: 23.3.1 + elixir-version: 1.13.2 + otp-version: 24.2 - name: Restore deps and _build cache uses: actions/cache@v2 diff --git a/CHANGELOG.md b/CHANGELOG.md index fc1e7c07a8..bc1ece282a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,304 @@ # Changelog -## 0.17.0 +## 0.18.17 + +### Enhancements + * Support [`submitter`](https://developer.mozilla.org/en-US/docs/Web/API/SubmitEvent/submitter) on form submit events. + +## 0.18.16 (2023-02-23) + +### Enhancements + * Support streams in Live Components + * Optimize plug error translation when a Plug.Exception is raised over connected LiveView + +## Bug Fixes + * Fix formatter issues when there are multiple HTML comments + +## 0.18.15 (2023-02-16) + +### Bug Fixes + * Fix `JS.transition` applying incorrect classes + +### Enhancements + * Reset phx-feedback-for errors on `type="reset"` inputs and buttons + +## 0.18.14 (2023-02-14) + +### Bug Fixes + * Fix LiveViewTest failing to find main live view + +## 0.18.13 (2023-02-10) + +### Enhancements + * Improve error message when failing to use Phoenix.Component + +## 0.18.12 (2023-02-10) + +### Enhancements + * Introduce streams for efficiently handling large collections + * Allow replies from `:handle_event` lifecycle hooks + * Add `<.inputs_for>` component to `Phoenix.Component` + * Support replies on lifecycle `:handle_event` hooks + +### Bug Fixes + * Fix change tracking when re-assigning a defaulted attribute to same default value + * Fix upload drag and drop failing to worka after using file select dialog + * Fix form recovery when form's first input is phx-change + +## 0.18.11 (2023-01-19) + +### Bug Fixes + * Fix socket unloading connection for forms that have defaulted prevented + +## 0.18.10 (2023-01-18) + +### Bug Fixes + * Fix svg tags with href incorrectly unloading socket on click + * Fix form submits with `target="_blank"` incorrectly unloading socket on submit + +## 0.18.9 (2023-01-17) + +### Bug Fixes + * Fix regular form submits failing to be dispatched + +## 0.18.8 (2023-01-16) + +### Enhancements + * Restore scroll position on back when previous navigation was live patch + +### Bug Fixes + * Fix live layout not being applied until connected render + +## 0.18.7 (2023-01-13) + +### Bug Fixes + * Fix live layout not being applied when passed to `:live_session` during disconnect render + * Fix external anchor clicks and links with hashes incorrectly unloading socket + +## 0.18.6 (2023-01-09) + +### Bug Fixes + * Fix external anchor click unloading on external click + +## 0.18.5 (2023-01-09) + +### Bug Fixes + * Fix external anchor click unloading socket + +## 0.18.4 (2023-01-05) + +### Enhancements + * Support string upload name to support dynamically generated `allow_upload`'s + +### Bug Fixes + * Fix nested LiveView race condition on live patch causing nested child to skip updates in some cases + * Fix browser history showing incorrect title when using live navigation with `@page_title` + * Fix undefined _target param when using `JS.push` for form changes + * Fix `phx-no-feedback` missing from inputs added after a form submit + * Fix `phx-disconnected` events firing when navigating away or submitting external forms + +## 0.18.3 (2022-10-26) + +### Enhancements + * Add `embed_templates` to `Phoenix.Component` for embedding template files as function components + * Raise on global slot attributes + +### Bug Fixes + * Fix bug on slots when passing multiple slot entries with mix if/for syntax + +## 0.18.2 (2022-10-04) + +### Bug Fixes + * Fix match error when defining `:values` before `:default` + * Allow tuples in external redirects + * Fix race condition on dispatching click away when enter is pressed + * Fix formatter breaking inline blocks when surrounded by text without whitespace + +### Enhancements + * Add `intersperse` component for rendering a separator between an enumerable + +## 0.18.1 (2022-09-28) + +### Bug Fixes + * Fix phx-loading class being applied to dead views + * Fix `<.live_img_preview />` causing invalid attribute errors on uploads + * Do not fire phx events when element is disabled + +### Enhancements + * Support `:include` option to extend global attributes on a case-by-case basis + * Warn when accessing a variable binding defined outside of `~H` + +## 0.18.0 (2022-09-20) + +LiveView v0.18 includes a major new feature in the form of declarative assigns with new `attr` +and `slot` APIs for specifying which attributes a function component supports, the type, +and default values. Attributes and slots are compile-time verified and emit warnings (requires Elixir v1.14.0+). + +v0.18 includes a number of new function components which replace their EEx expression +counterparts `<%= ... %>`. For example, `live_redirect`, `live_patch`, and Phoenix.HTML's +`link` have been replaced by a unified `Phoenix.Component.link/1` function component: + + <.link href="https://myapp.com">my app + <.link navigate={@path}>remount + <.link patch={@path}>patch + +Those new components live in the `Phoenix.Component` module. `Phoenix.LiveView.Helpers` +itself has been soft deprecated and all relevant functionality has been migrated. +You must `import Phoenix.Component` where you previously imported `Phoenix.LiveView.Helpers` +when upgrading. You may also need to `import Phoenix.Component` where you also imported `Phoenix.LiveView` and some of its functions have been moved to `Phoenix.Component`. + +Additionally, the special `let` attribute on function components have been deprecated by +a `:let` usage. + +### Deprecations + - `live_redirect` - deprecate in favor of new `<.link navigate={..}>` component of `Phoenix.Component` + - `live_patch` - deprecate in favor of new `<.link patch={..}>` component of `Phoenix.Component` + - `push_redirect` - deprecate in favor of new `push_navigate` function on `Phoenix.LiveView` + +### Enhancements + - [Component] Add declarative assigns with compile-time verifications and warnings via `attr`/`slot` + - [Component] Add new attrs `:let` and `:for`, and `:if` with HTML tag, function component, and slot support. We still support `let` but the formatter will convert it to `:let` and soon it will be deprecated. + - [Component] Add `dynamic_tag` function component + - [Component] Add `link` function component + - [Component] Add `focus_wrap` function component to wrap focus around content like modals and dialogs for accessibility + - [Logger] Add new LiveView logger with telemetry instrumentation for lifecycle events + - [JS] Add new JS commands for `focus`, `focus_first`, `push_focus`, and `pop_focus` for accessibility + - [Socket] Support sharing `Phoenix.LiveView.Socket` with regular channels via `use Phoenix.LiveView.Socket` + - Add `_live_referer` connect param for handling `push_navigate` referal URL + - Add new `phx-connected` and `phx-disconnected` bindings for reacting to lifecycle changes + - Add dead view support for JS commands + - Add dead view support for hooks + +### Bug fixes + - Fix external upload issue where listeners are not cleaned up when an external failure happens on the client + - Do not debounce `phx-blur` + +## 0.17.12 (2022-09-20) + +### Enhancements + - Add support for upcoming Phoenix 1.7 flash interface + +## 0.17.11 (2022-07-11) + +### Enhancements + - Add `replaceTransport` to LiveSocket + +### Bug fixes + - Cancel debounced events from firing after a live navigation event + - Fix hash anchor failing to scroll to anchor element on live navigation + - Do not debounce `phx-blur` events + +## 0.17.10 (2022-05-25) + +### Bug fixes + - [Formatter] Preserve single quote delimiter on attrs + - [Formatter] Do not format inline elements surrounded by texts without whitespaces + - [Formatter] Keep text and eex along when there isn't a whitespace + - [Formatter] Fix intentional line breaks after eex expressions + - [Formatter] Handle self close tags as inline + - [Formatter] Do not format inline elements without whitespaces among them + - [Formatter] Do not format when attr contenteditable is present + +### Enhancements + - [Formatter] Introduce special attr phx-no-format to skip formatting + +## 0.17.9 (2022-04-07) + +### Bug fixes + - Fix sticky LiveViews failing to be patched during live navigation + - Do not raise on dynamic `phx-update` value + +## 0.17.8 (2022-04-06) + +### Enhancements + - Add HEEx formatter + - Support `phx-change` on individual inputs + - Dispatch `MouseEvent` on client + - Add `:bubbles` option to `JS.dispatch` to control event bubbling + - Expose underlying `liveSocket` instance on hooks + - Enable client debug by default on localhost + +### Bug fixes + - Fix hook and sticky LiveView issues caused by back-to-back live redirects from mount + - Fix hook destroyed callback failing to be invoked for children of phx-remove in some cases + - Do not failsafe reload the page on push timeout if disconnected + - Do not bubble navigation click events to regular phx-click's + - No longer generate `csrf_token` for forms without action, reducing the payload during phx-change/phx-submit events + +## 0.17.7 (2022-02-07) + +### Enhancements + - Optimize nested for comprehension diffs + +### Bug fixes + - Fix error when `live_redirect` links are clicked when not connected in certain cases + +## 0.17.6 (2022-01-18) + +### Enhancements + - Add `JS.set_attribute` and `JS.remove_attribute` + - Add `sticky: true` option to `live_render` to maintain a nested child on across live redirects + - Dispatch `phx:show-start`, `phx:show-end`, `phx:hide-start` and `phx:hide-end` on `JS.show|hide|toggle` + - Add `get_connect_info/2` that also works on disconnected render + - Add `LiveSocket` constructor options for configuration failsafe behavior via new `maxReloads`, `reloadJitterMin`, `reloadJitterMax`, `failsafeJitter` options + +### Bug fixes + - Show form errors after submit even when no changes occur on server + - Fix `phx-disable-with` failing to disable elements outside of forms + - Fix phx ref tracking leaving elements in awaiting state when targeting an external LiveView + - Fix diff on response failing to await for active transitions in certain cases + - Fix `phx-click-away` not respecting `phx-target` + - Fix "disconnect" broadcast failing to failsafe refresh the page + - Fix `JS.push` with `:target` failing to send to correct component in certain cases + +### Deprecations + - Deprecate `Phoenix.LiveView.get_connect_info/1` in favor of `get_connect_info/2` + - Deprecate `Phoenix.LiveViewTest.put_connect_info/2` in favor of calling the relevant functions in `Plug.Conn` + - Deprecate returning "raw" values from upload callbacks on `Phoenix.LiveView.consume_uploaded_entry/3` and `Phoenix.LiveView.consume_uploaded_entries/3`. The callback must return either `{:ok, value}` or `{:postpone, value}`. Returning any other value will emit a warning. + +## 0.17.5 (2021-11-02) + +### Bug fixes + - Do not trigger `phx-click-away` if element is not visible + - Fix `phx-remove` failing to tear down nested live children + +## 0.17.4 (2021-11-01) + +### Bug fixes + - Fix variable scoping issues causing various content block or duplication rendering bugs + +## 0.17.3 (2021-10-28) + +### Enhancements + - Support 3-tuple for JS class transitions to support staged animations where a transition class is applied with a starting and ending class + - Allow JS commands to be executed on DOM nodes outside of the LiveView container + +### Optimization + - Avoid duplicate statics inside comprehension. In previous versions, comprehensions were able to avoid duplication only in the content of their root. Now we recursively traverse all comprehension nodes and send the static only once for the whole comprehension. This should massively reduce the cost of sending comprehensions over the wire + +### Bug fixes + - Fix HTML engine bug causing expressions to be duplicated or not rendered correctly + - Fix HTML engine bug causing slots to not be re-rendered when they should have + - Fix form recovery being sent to wrong target + +## 0.17.2 (2021-10-22) + +### Bug fixes + - Fix HTML engine bug causing attribute expressions to be incorrectly evaluated in certain cases + - Fix show/hide/toggle custom display not being restored + - Fix default `to` target for `JS.show|hide|dispatch` + - Fix form input targeting + +## 0.17.1 (2021-10-21) + +### Bug fixes + - Fix SVG element support for `phx` binding interactions + +## 0.17.0 (2021-10-21) ### Breaking Changes -#### on_mount changes +#### `on_mount` changes The hook API introduced in LiveView 0.16 has been improved based on feedback. LiveView 0.17 removes the custom module-function callbacks for the @@ -50,10 +344,18 @@ atom `:default`. #### LEEx templates in stateful LiveComponents Stateful LiveComponents (where an `:id` is given) must now return HEEx templates -(`~H` sigil or `.heex` extension). LEEx temlates (`~L` sigil or `.leex` extension) +(`~H` sigil or `.heex` extension). LEEx templates (`~L` sigil or `.leex` extension) are no longer supported. This addresses bugs and allows stateful components to be rendered more efficiently client-side. +#### `phx-disconnected` class has been replaced with `phx-loading` + +Due to a bug in the newly released Safari 15, the previously used `.phx-disconnected` class has been replaced by a new `.phx-loading` class. The reason for the change is `phx.new` included a `.phx-disconnected` rule in the generated `app.css` which triggers the Safari bug. Renaming the class avoids applying the erroneous rule for existing applications. Folks can upgrade by simply renaming their `.phx-disconnected` rules to `.phx-loading`. + +#### `phx-capture-click` has been deprecated in favor of `phx-click-away` + +The new `phx-click-away` binding replaces `phx-capture-click` and is much more versatile because it can detect "click focus" being lost on containers. + #### Removal of previously deprecated functionality Some functionality that was previously deprecated has been removed: @@ -63,15 +365,23 @@ Some functionality that was previously deprecated has been removed: ### Enhancements - Allow slots in function components: they are marked as `<:slot_name>` and can be rendered with `<%= render_slot @slot_name %>` + - Add `JS` command for executing JavaScript utility operations on the client with an extended push API - Optimize string attributes: - If the attribute is a string interpolation, such as `
`, only the interpolation part is marked as dynamic - If the attribute can be empty, such as "class" and "style", keep the attribute name as static - Add a function component for rendering `Phoenix.LiveComponent`. Instead of `<%= live_component FormComponent, id: "form" %>`, you must now do: `<.live_component module={FormComponent} id="form" />` ### Bug fixes - - Add workaround for Safari bug causing img tags with srcset and video with autoplay to fail to render + - Fix LiveViews with form recovery failing to properly mount following a reconnect when preceded by a live redirect + - Fix stale session causing full redirect fallback when issuing a `push_redirect` from mount + - Add workaround for Safari bug causing `` tags with srcset and video with autoplay to fail to render - Support EEx interpolation inside HTML comments in HEEx templates + - Support HTML tags inside script tags (as in regular HTML) + - Raise if using quotes in attribute names + - Include the filename in error messages when it is not possible to parse interpolated attributes - Make sure the test client always sends the full URL on `live_patch`/`live_redirect`. This mirrors the behaviour of the JavaScript client + - Do not reload flash from session on `live_redirect`s + - Fix select drop-down flashes in Chrome when the DOM is patched during focus ### Deprecations - `<%= live_component MyModule, id: @user.id, user: @user %>` is deprecated in favor of `<.live_component module={MyModule} id={@user.id} user={@user} />`. Notice the new API requires using HEEx templates. This change allows us to further improve LiveComponent and bring new features such as slots to them. @@ -83,7 +393,7 @@ Some functionality that was previously deprecated has been removed: - Improve HEEx error messages - Relax HTML tag validation to support mixed case tags - Support self closing HTML tags - - Remove requirement for handle_params to be defined for lifecycle hooks + - Remove requirement for `handle_params` to be defined for lifecycle hooks ### Bug fixes - Fix pushes failing to include channel `join_ref` on messages @@ -97,10 +407,10 @@ Some functionality that was previously deprecated has been removed: ### Enhancements - Improve error messages on tokenization - - Improve error message if inner_block is missing + - Improve error message if `@inner_block` is missing ### Bug fixes - - Fix phx-change form recovery event being sent to wrong component on reconnect when component order changes + - Fix `phx-change` form recovery event being sent to wrong component on reconnect when component order changes ## 0.16.1 (2021-08-26) @@ -111,11 +421,11 @@ Some functionality that was previously deprecated has been removed: ### Bug fixes - Do not generate CSRF tokens for non-POST forms - - Do not add compile-time dependencies on on_mount declarations + - Do not add compile-time dependencies on `on_mount` declarations ## 0.16.0 (2021-08-10) -## # Security Considerations Upgrading from 0.15 +### Security Considerations Upgrading from 0.15 LiveView v0.16 optimizes live redirects by supporting navigation purely over the existing WebSocket connection. This is accomplished by the new @@ -149,7 +459,7 @@ and example usage. ### New HTML Engine -LiveView v0.16 introduces HEEx (HTML+EEx) templates and the concept of function +LiveView v0.16 introduces HEEx (HTML + EEx) templates and the concept of function components via `Phoenix.Component`. The new HEEx templates validate the markup in the template while also providing smarter change tracking as well as syntax conveniences to make it easier to build composable components. @@ -218,7 +528,7 @@ component named `form`: ```elixir ~H""" -<.form let={f} for={@changeset}> +<.form :let={f} for={@changeset}> <%= input f, :foo %> """ @@ -269,6 +579,13 @@ Change it to: import { LiveSocket } from "phoenix_live_view" ``` +Additionally on the client, the root LiveView element no longer exposes the +LiveView module name, therefore the `phx-view` attribute is never set. +Similarly, the `viewName` property of client hooks has been removed. + +Codebases calling a custom function `component/3` should rename it or specify its module to avoid a conflict, +as LiveView introduces a macro with that name and it is special cased by the underlying engine. + ### Enhancements - Introduce HEEx templates - Introduce `Phoenix.Component` @@ -287,12 +604,12 @@ import { LiveSocket } from "phoenix_live_view" ### Bug fixes - Make sure components are loaded on `render_component` to ensure all relevant callbacks are invoked - - Fix `Phoenix.LiveViewTest.page_title` returning nil in some cases + - Fix `Phoenix.LiveViewTest.page_title` returning `nil` in some cases - Fix buttons being re-enabled when explicitly set to disabled on server - Fix live patch failing to update URL when live patch link is patched again via `handle_params` within the same callback lifecycle - Fix `phx-no-feedback` class not applied when page is live-patched - - Fix `DOMException, querySelector, not a valid selector` when performing DOM lookups on non-stanard IDs - - Fix select dropdown flashing close/opened when assigns are updated on Chrome/MacOS + - Fix `DOMException, querySelector, not a valid selector` when performing DOM lookups on non-standard IDs + - Fix select dropdown flashing close/opened when assigns are updated on Chrome/macOS - Fix error with multiple `live_file_input` in one form - Fix race condition in `showError` causing null `querySelector` - Fix statics not resolving correctly across recursive diffs @@ -317,7 +634,7 @@ import { LiveSocket } from "phoenix_live_view" - Fix live patch failing to update URL when live patch link is patched again from `handle_params` - Fix regression in `LiveViewTest.render_upload/3` when using channel uploads and progress callback - Fix component uploads not being cleaned up on remove - - Fix KeyError on LiveView reconnect when an active upload was previously in progress + - Fix `KeyError` on LiveView reconnect when an active upload was previously in progress ### Enhancements - Support function components via `component/3` @@ -343,8 +660,8 @@ import { LiveSocket } from "phoenix_live_view" - Fix nested `live_render`'s causing remound of child LiveView even when ID does not change - Do not attempt push hook events unless connected - Fix preflighted refs causing `auto_upload: true` to fail to submit form - - Replace single upload entry when max_entires is 1 instead of accumulating multiple file selections - - Fix static_path in open_browser failing to load stylesheets + - Replace single upload entry when `max_entries` is 1 instead of accumulating multiple file selections + - Fix `static_path` in `open_browser` failing to load stylesheets ## 0.15.3 (2021-01-02) @@ -354,11 +671,11 @@ import { LiveSocket } from "phoenix_live_view" ## 0.15.2 (2021-01-01) ### Backwards incompatible changes - - Remove `beforeDestroy` from phx-hook callbacks + - Remove `beforeDestroy` from `phx-hook` callbacks ### Bug fixes - Fix form recovery failing to send input on first connection failure - - Fix hooks not getting remounted after liveview reconnect + - Fix hooks not getting remounted after LiveView reconnect - Fix hooks `reconnected` callback being fired with no prior disconnect ## 0.15.1 (2020-12-20) @@ -368,12 +685,12 @@ import { LiveSocket } from "phoenix_live_view" - Run `consume_uploaded_entries` in LiveView caller process ### Bug fixes - - Fix hooks not getting remounted after liveview recovery + - Fix hooks not getting remounted after LiveView recovery - Fix bug causing reload with jitter on timeout from previously closed channel - Fix component child nodes being lost when component patch goes from single root node to multiple child siblings - Fix `phx-capture-click` triggering on mouseup during text selection - Fix LiveView `push_event`'s not clearing up in components - - Fix textarea being patched by LV while focused + - Fix ` + + +
+ disconnected! +
+ + ` + document.body.innerHTML = "" + document.body.appendChild(div) + return div +} + + diff --git a/assets/test/utils_test.js b/assets/test/utils_test.js new file mode 100644 index 0000000000..08461c87d3 --- /dev/null +++ b/assets/test/utils_test.js @@ -0,0 +1,37 @@ +import {Socket} from "phoenix" +import { closestPhxBinding } from "phoenix_live_view/utils" +import LiveSocket from "phoenix_live_view/live_socket" +import { simulateJoinedView, liveViewDOM } from "./test_helpers" + +let setupView = (content) => { + let el = liveViewDOM(content) + global.document.body.appendChild(el) + let liveSocket = new LiveSocket("/live", Socket) + return simulateJoinedView(el, liveSocket) +} + +describe("utils", () => { + describe("closestPhxBinding", () => { + test("if an element's parent has a phx-click binding and is not disabled, return the parent", () => { + let view = setupView(` + + `) + let element = global.document.querySelector("#innerContent") + let parent = global.document.querySelector("#button") + expect(closestPhxBinding(element, "phx-click")).toBe(parent) + }) + + test("if an element's parent is disabled, return null", () => { + let view = setupView(` + + `) + let element = global.document.querySelector("#innerContent") + expect(closestPhxBinding(element, "phx-click")).toBe(null) + }) + }) +}) + diff --git a/assets/test/view_test.js b/assets/test/view_test.js index 3c5aa8c3e8..eaf330cd16 100644 --- a/assets/test/view_test.js +++ b/assets/test/view_test.js @@ -3,29 +3,9 @@ import LiveSocket from "phoenix_live_view/live_socket" import DOM from "phoenix_live_view/dom" import View from "phoenix_live_view/view" -import {tag, simulateJoinedView, stubChannel, rootContainer} from "./test_helpers" +import {tag, simulateJoinedView, stubChannel, rootContainer, liveViewDOM, simulateVisibility} from "./test_helpers" -function liveViewDOM(content){ - const div = document.createElement("div") - div.setAttribute("data-phx-view", "User.Form") - div.setAttribute("data-phx-session", "abc123") - div.setAttribute("id", "container") - div.setAttribute("class", "user-implemented-class") - div.innerHTML = content || ` -
- - - - - -
- ` - document.body.innerHTML = "" - document.body.appendChild(div) - return div -} - -describe("View + DOM", function (){ +describe("View + DOM", function(){ beforeEach(() => { submitBefore = HTMLFormElement.prototype.submit global.Phoenix = {Socket} @@ -51,7 +31,7 @@ describe("View + DOM", function (){ expect(view.rendered.get()).toEqual(updateDiff) }) - test("pushWithReply", function (){ + test("pushWithReply", function(){ expect.assertions(1) let liveSocket = new LiveSocket("/live", Socket) @@ -71,7 +51,7 @@ describe("View + DOM", function (){ view.pushWithReply(null, {target: el.querySelector("form")}, {value: "increment=1"}) }) - test("pushWithReply with update", function (){ + test("pushWithReply with update", function(){ let liveSocket = new LiveSocket("/live", Socket) let el = liveViewDOM() @@ -103,7 +83,7 @@ describe("View + DOM", function (){ expect(view.el.querySelector("form")).toBeTruthy() }) - test("pushEvent", function (){ + test("pushEvent", function(){ expect.assertions(3) let liveSocket = new LiveSocket("/live", Socket) @@ -126,7 +106,7 @@ describe("View + DOM", function (){ view.pushEvent("keyup", input, el, "click", {}) }) - test("pushEvent as checkbox not checked", function (){ + test("pushEvent as checkbox not checked", function(){ expect.assertions(1) let liveSocket = new LiveSocket("/live", Socket) @@ -147,7 +127,7 @@ describe("View + DOM", function (){ view.pushEvent("click", input, el, "toggle_me", {}) }) - test("pushEvent as checkbox when checked", function (){ + test("pushEvent as checkbox when checked", function(){ expect.assertions(1) let liveSocket = new LiveSocket("/live", Socket) @@ -170,7 +150,7 @@ describe("View + DOM", function (){ view.pushEvent("click", input, el, "toggle_me", {}) }) - test("pushEvent as checkbox with value", function (){ + test("pushEvent as checkbox with value", function(){ expect.assertions(1) let liveSocket = new LiveSocket("/live", Socket) @@ -194,30 +174,7 @@ describe("View + DOM", function (){ view.pushEvent("click", input, el, "toggle_me", {}) }) - test("pushKey", function (){ - expect.assertions(3) - - let liveSocket = new LiveSocket("/live", Socket) - let el = liveViewDOM() - let input = el.querySelector("input") - - let view = simulateJoinedView(el, liveSocket) - let channelStub = { - push(_evt, payload, _timeout){ - expect(payload.type).toBe("keydown") - expect(payload.event).toBeDefined() - expect(payload.value).toEqual({"key": "A", "value": "1"}) - return { - receive(){ return this } - } - } - } - view.channel = channelStub - - view.pushKey(input, el, "keydown", "move", {key: "A"}) - }) - - test("pushInput", function (){ + test("pushInput", function(){ expect.assertions(3) let liveSocket = new LiveSocket("/live", Socket) @@ -237,10 +194,10 @@ describe("View + DOM", function (){ } view.channel = channelStub - view.pushInput(input, el, null, "validate", input) + view.pushInput(input, el, null, "validate", {_target: input.name}) }) - test("formsForRecovery", function (){ + test("formsForRecovery", function(){ let view, html, liveSocket = new LiveSocket("/live", Socket) html = "
" @@ -270,8 +227,8 @@ describe("View + DOM", function (){ expect(view.formsForRecovery().length).toBe(0) }) - describe("submitForm", function (){ - test("submits payload", function (){ + describe("submitForm", function(){ + test("submits payload", function(){ expect.assertions(3) let liveSocket = new LiveSocket("/live", Socket) @@ -293,7 +250,33 @@ describe("View + DOM", function (){ view.submitForm(form, form, {target: form}) }) - test("disables elements after submission", function (){ + test("payload includes submitter when provided", function(){ + let liveSocket = new LiveSocket("/live", Socket) + let el = liveViewDOM() + let form = el.querySelector("form") + let btn = document.createElement("button") + btn.setAttribute("type", "submit") + btn.setAttribute("name", "btnName") + btn.setAttribute("value", "btnValue") + form.appendChild(btn) + + let view = simulateJoinedView(el, liveSocket) + let channelStub = { + push(_evt, payload, _timeout){ + expect(payload.type).toBe("form") + expect(payload.event).toBeDefined() + expect(payload.value).toBe("increment=1¬e=2&btnName=btnValue") + return { + receive(){ return this } + } + } + } + + view.channel = channelStub + view.submitForm(form, form, {target: form}, btn) + }) + + test("disables elements after submission", function(){ let liveSocket = new LiveSocket("/live", Socket) let el = liveViewDOM() let form = el.querySelector("form") @@ -303,13 +286,16 @@ describe("View + DOM", function (){ view.submitForm(form, form, {target: form}) expect(DOM.private(form, "phx-has-submitted")).toBeTruthy() + Array.from(form.elements).forEach(input => { + expect(DOM.private(input, "phx-has-submitted")).toBeTruthy() + }) expect(form.classList.contains("phx-submit-loading")).toBeTruthy() expect(form.querySelector("button").dataset.phxDisabled).toBeTruthy() expect(form.querySelector("input").dataset.phxReadonly).toBeTruthy() expect(form.querySelector("textarea").dataset.phxReadonly).toBeTruthy() }) - test("disables elements outside form", function (){ + test("disables elements outside form", function(){ let liveSocket = new LiveSocket("/live", Socket) let el = liveViewDOM(`
@@ -332,6 +318,21 @@ describe("View + DOM", function (){ expect(el.querySelector("input").dataset.phxReadonly).toBeTruthy() expect(el.querySelector("textarea").dataset.phxReadonly).toBeTruthy() }) + + test("disables elements", function(){ + let liveSocket = new LiveSocket("/live", Socket) + let el = liveViewDOM(` + + `) + let button = el.querySelector("button") + + let view = simulateJoinedView(el, liveSocket) + stubChannel(view) + + expect(button.disabled).toEqual(false) + view.pushEvent("click", button, el, "inc", {}) + expect(button.disabled).toEqual(true) + }) }) describe("phx-trigger-action", () => { @@ -373,7 +374,7 @@ describe("View + DOM", function (){ }) }) - describe("phx-update", function (){ + describe("phx-update", function(){ let childIds = () => Array.from(document.getElementById("list").children).map(child => parseInt(child.id)) let countChildNodes = () => document.getElementById("list").childNodes.length @@ -461,7 +462,7 @@ describe("View + DOM", function (){ expect(childIds()).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9]) // Make sure we don't have a memory leak when doing updates - let initalCount = countChildNodes() + let initialCount = countChildNodes() updateDynamics(view, [["1", "1"], ["2", "2"], ["3", "3"]] ) @@ -475,7 +476,7 @@ describe("View + DOM", function (){ [["1", "1"], ["2", "2"], ["3", "3"]] ) - expect(countChildNodes()).toBe(initalCount) + expect(countChildNodes()).toBe(initialCount) }) test("prepend", async () => { @@ -525,7 +526,7 @@ describe("View + DOM", function (){ expect(childIds()).toEqual([8, 9, 6, 7, 4, 5, 2, 3, 1]) // Make sure we don't have a memory leak when doing updates - let initalCount = countChildNodes() + let initialCount = countChildNodes() updateDynamics(view, [["1", "1"], ["2", "2"], ["3", "3"]] ) @@ -539,7 +540,7 @@ describe("View + DOM", function (){ [["1", "1"], ["2", "2"], ["3", "3"]] ) - expect(countChildNodes()).toBe(initalCount) + expect(countChildNodes()).toBe(initialCount) }) test("ignore", async () => { @@ -556,7 +557,7 @@ describe("View + DOM", function (){ }) let submitBefore -describe("View", function (){ +describe("View", function(){ beforeEach(() => { submitBefore = HTMLFormElement.prototype.submit global.Phoenix = {Socket} @@ -611,32 +612,44 @@ describe("View", function (){ test("showLoader and hideLoader", async () => { let liveSocket = new LiveSocket("/live", Socket) - let el = document.querySelector("[data-phx-view]") + let el = document.querySelector("[data-phx-session]") let view = simulateJoinedView(el, liveSocket) view.showLoader() - expect(el.classList.contains("phx-disconnected")).toBeTruthy() + expect(el.classList.contains("phx-loading")).toBeTruthy() expect(el.classList.contains("phx-connected")).toBeFalsy() expect(el.classList.contains("user-implemented-class")).toBeTruthy() view.hideLoader() - expect(el.classList.contains("phx-disconnected")).toBeFalsy() + expect(el.classList.contains("phx-loading")).toBeFalsy() expect(el.classList.contains("phx-connected")).toBeTruthy() }) - test("displayError", async () => { + test("displayError and hideLoader", done => { let liveSocket = new LiveSocket("/live", Socket) let loader = document.createElement("span") - let phxView = document.querySelector("[data-phx-view]") + let phxView = document.querySelector("[data-phx-session]") phxView.parentNode.insertBefore(loader, phxView.nextSibling) - let el = document.querySelector("[data-phx-view]") + let el = document.querySelector("[data-phx-session]") + let status = el.querySelector("#status") let view = simulateJoinedView(el, liveSocket) + + expect(status.style.display).toBe("none") view.displayError() - expect(el.classList.contains("phx-disconnected")).toBeTruthy() + expect(el.classList.contains("phx-loading")).toBeTruthy() expect(el.classList.contains("phx-error")).toBeTruthy() expect(el.classList.contains("phx-connected")).toBeFalsy() expect(el.classList.contains("user-implemented-class")).toBeTruthy() + window.requestAnimationFrame(() => { + expect(status.style.display).toBe("block") + simulateVisibility(status) + view.hideLoader() + window.requestAnimationFrame(() => { + expect(status.style.display).toBe("none") + done() + }) + }) }) test("join", async () => { @@ -678,7 +691,7 @@ describe("View", function (){ }) }) -describe("View Hooks", function (){ +describe("View Hooks", function(){ beforeEach(() => { global.document.body.innerHTML = liveViewDOM().outerHTML }) @@ -687,12 +700,45 @@ describe("View Hooks", function (){ global.document.body.innerHTML = "" }) + test("phx-mounted", done => { + let liveSocket = new LiveSocket("/live", Socket) + let el = liveViewDOM() + + let html = "

test mounted

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

test mounted

"], + fingerprint: 123 + }, []) + window.requestAnimationFrame(() => { + expect(document.getElementById("test").getAttribute("class")).toBe("new-class") + expect(document.getElementById("test2").getAttribute("class")).toBe("new-class2") + done() + }) + }) + }) + test("hooks", async () => { let upcaseWasDestroyed = false let upcaseBeforeUpdate = false + let hookLiveSocket let Hooks = { Upcase: { - mounted(){ this.el.innerHTML = this.el.innerHTML.toUpperCase() }, + mounted(){ + hookLiveSocket = this.liveSocket + this.el.innerHTML = this.el.innerHTML.toUpperCase() + }, beforeUpdate(){ upcaseBeforeUpdate = true }, updated(){ this.el.innerHTML = this.el.innerHTML + " updated" }, disconnected(){ this.el.innerHTML = "disconnected" }, @@ -728,6 +774,7 @@ describe("View Hooks", function (){ view.update({s: ["
"], fingerprint: 123}, []) expect(upcaseWasDestroyed).toBe(true) + expect(hookLiveSocket).toBeDefined() }) test("view destroyed", async () => { @@ -843,7 +890,6 @@ describe("View Hooks", function (){ function liveViewComponent(){ const div = document.createElement("div") - div.setAttribute("data-phx-view", "User.Form") div.setAttribute("data-phx-session", "abc123") div.setAttribute("id", "container") div.setAttribute("class", "user-implemented-class") @@ -860,7 +906,7 @@ function liveViewComponent(){ return div } -describe("View + Component", function (){ +describe("View + Component", function(){ beforeEach(() => { global.Phoenix = {Socket} global.document.body.innerHTML = liveViewComponent().outerHTML @@ -880,7 +926,7 @@ describe("View + Component", function (){ expect(view.targetComponentID(form, targetCtx)).toBe(0) }) - test("pushEvent", function (){ + test("pushEvent", function(){ expect.assertions(4) let liveSocket = new LiveSocket("/live", Socket) @@ -905,7 +951,7 @@ describe("View + Component", function (){ view.pushEvent("keyup", input, targetCtx, "click", {}) }) - test("pushInput", function (){ + test("pushInput", function(){ expect.assertions(6) let html = ` @@ -960,17 +1006,82 @@ describe("View + Component", function (){ view.channel.nextValidate({"user[first_name]": null, "user[last_name]": null, "_target": "user[first_name]"}) // we have to set this manually since it's set by a change event that would require more plumbing with the liveSocket in the test to hook up DOM.putPrivate(first_name, "phx-has-focused", true) - view.pushInput(first_name, el, null, "validate", first_name) + view.pushInput(first_name, el, null, "validate", {_target: first_name.name}) expect(el.querySelector(`[phx-feedback-for="${first_name.name}"`).classList.contains("phx-no-feedback")).toBeFalsy() expect(el.querySelector(`[phx-feedback-for="${last_name.name}"`).classList.contains("phx-no-feedback")).toBeTruthy() view.channel.nextValidate({"user[first_name]": null, "user[last_name]": null, "_target": "user[last_name]"}) DOM.putPrivate(last_name, "phx-has-focused", true) - view.pushInput(last_name, el, null, "validate", last_name) + view.pushInput(last_name, el, null, "validate", {_target: last_name.name}) expect(el.querySelector(`[phx-feedback-for="${first_name.name}"`).classList.contains("phx-no-feedback")).toBeFalsy() expect(el.querySelector(`[phx-feedback-for="${last_name.name}"`).classList.contains("phx-no-feedback")).toBeFalsy() }) + test("pushInput sets phx-no-feedback class on feedback elements for multiple select", function(){ + // a multiple select name attribute contains trailing square brackets [] to capture multiple options + let multiple_select_name = "user[allergies][]" + // the phx-feedback-for attribute typically doesn't contain trailing brackets + // because its value is often set with the result of Phoenix.HTML.input_name/2 + let multiple_select_phx_feedback_for_name = "user[allergies]" + let html = + ` + + + + + +
` + let liveSocket = new LiveSocket("/live", Socket) + let el = liveViewDOM(html) + let view = simulateJoinedView(el, liveSocket, html) + let channelStub = { + push(_evt, _payload, _timeout){ + return { + receive(_status, cb){ + let diff = { + s: [` +
+ + + + + + + +
+ `], + fingerprint: 345 + } + cb({diff}) + return this + } + } + } + } + view.channel = channelStub + + let first_name_input = view.el.querySelector("input#first_name") + let allergies_select = view.el.querySelector("select#allergies") + // we have to set this manually since it's set by a change event that would require more plumbing with the liveSocket in the test to hook up + DOM.putPrivate(first_name_input, "phx-has-focused", true) + view.pushInput(first_name_input, el, null, "validate", {_target: "user[first_name]"}) + expect(el.querySelector(`span[phx-feedback-for="user[first_name]"`).classList.contains("phx-no-feedback")).toBeFalsy() + expect(el.querySelector(`span[phx-feedback-for="user[allergies]"`).classList.contains("phx-no-feedback")).toBeTruthy() + + DOM.putPrivate(allergies_select, "phx-has-focused", true) + view.pushInput(allergies_select, el, null, "validate", {_target: "user[allergies][]"}) + expect(el.querySelector(`span[phx-feedback-for="user[first_name]"`).classList.contains("phx-no-feedback")).toBeFalsy() + expect(el.querySelector(`span[phx-feedback-for="user[allergies]"`).classList.contains("phx-no-feedback")).toBeFalsy() + }) + test("adds auto ID to prevent teardown/re-add", () => { let liveSocket = new LiveSocket("/live", Socket) let el = liveViewDOM() @@ -1057,10 +1168,9 @@ describe("View + Component", function (){ test("destroys children when they are removed by an update", () => { let id = "root" - let childHTML = `
` - let newChildHTML = `
` + let childHTML = `
` + let newChildHTML = `
` let el = document.createElement("div") - el.setAttribute("data-phx-view", "Root") el.setAttribute("data-phx-session", "abc123") el.setAttribute("id", id) document.body.appendChild(el) @@ -1086,12 +1196,12 @@ describe("View + Component", function (){ describe("undoRefs", () => { test("restores phx specific attributes awaiting a ref", () => { let content = ` - -
- + + + - +
`.trim() let liveSocket = new LiveSocket("/live", Socket) @@ -1101,11 +1211,11 @@ describe("View + Component", function (){ view.undoRefs(1) expect(el.innerHTML).toBe(` -
- + + - +
`.trim()) @@ -1125,10 +1235,11 @@ describe("View + Component", function (){ let liveSocket = new LiveSocket("/live", Socket) let el = rootContainer("") - let fromEl = tag("span", {"data-phx-ref": "1"}, "hello") + let fromEl = tag("span", {"data-phx-ref-src": el.id, "data-phx-ref": "1"}, "hello") let toEl = tag("span", {"class": "new"}, "world") DOM.putPrivate(fromEl, "data-phx-ref", toEl) + el.appendChild(fromEl) let view = simulateJoinedView(el, liveSocket) @@ -1151,7 +1262,8 @@ describe("View + Component", function (){ let view = simulateJoinedView(el, liveSocket) stubChannel(view) view.onJoin({rendered: {s: ["Hello"]}}) - view.update({s: ["Hello"]}, []) + + view.update({s: ["Hello"]}, []) let toEl = tag("span", {"id": "myhook", "phx-hook": "MyHook"}, "world") DOM.putPrivate(el.querySelector("#myhook"), "data-phx-ref", toEl) @@ -1165,8 +1277,8 @@ describe("View + Component", function (){ }) }) -describe("DOM", function (){ - it("mergeAttrs attributes", function (){ +describe("DOM", function(){ + it("mergeAttrs attributes", function(){ const target = document.createElement("target") target.type = "checkbox" target.id = "foo" @@ -1185,7 +1297,7 @@ describe("DOM", function (){ expect(target.id).toEqual("bar") }) - it("mergeAttrs with properties", function (){ + it("mergeAttrs with properties", function(){ const target = document.createElement("target") target.type = "checkbox" target.id = "foo" diff --git a/config/config.exs b/config/config.exs index cec6a3f648..6bdde661d0 100644 --- a/config/config.exs +++ b/config/config.exs @@ -1,4 +1,4 @@ -use Mix.Config +import Config config :phoenix, :json_library, Jason config :phoenix, :trim_on_html_eex_engine, false diff --git a/guides/client/bindings.md b/guides/client/bindings.md index 2a8d7ee64b..ae91e20736 100644 --- a/guides/client/bindings.md +++ b/guides/client/bindings.md @@ -16,15 +16,15 @@ callback, for example: | Binding | Attributes | |------------------------|------------| | [Params](#click-events) | `phx-value-*` | -| [Click Events](#click-events) | `phx-click`, `phx-capture-click` | +| [Click Events](#click-events) | `phx-click`, `phx-click-away` | | [Form Events](form-bindings.md) | `phx-change`, `phx-submit`, `phx-feedback-for`, `phx-disable-with`, `phx-trigger-action`, `phx-auto-recover` | -| [Focus/Blur Events](#focus-and-blur-events) | `phx-blur`, `phx-focus`, `phx-window-blur`, `phx-window-focus` | +| [Focus Events](#focus-and-blur-events) | `phx-blur`, `phx-focus`, `phx-window-blur`, `phx-window-focus` | | [Key Events](#key-events) | `phx-keydown`, `phx-keyup`, `phx-window-keydown`, `phx-window-keyup`, `phx-key` | -| [DOM Patching](dom-patching.md) | `phx-update` | +| [DOM Patching](dom-patching.md) | `phx-mounted`, `phx-update`, `phx-remove` | | [JS Interop](js-interop.md#client-hooks) | `phx-hook` | +| [Lifecycle Events](#lifecycle-events) | `phx-mounted`, `phx-disconnected`, `phx-connected` | | [Rate Limiting](#rate-limiting-events-with-debounce-and-throttle) | `phx-debounce`, `phx-throttle` | -| [Static tracking](`Phoenix.LiveView.static_changed?/1) | `phx-track-static` | -| [Loading states](js-interop.md#loading-state-and-errors) | `phx-page-loading` | +| [Static tracking](`Phoenix.LiveView.static_changed?/1`) | `phx-track-static` | ## Click Events @@ -32,6 +32,12 @@ The `phx-click` binding is used to send click events to the server. When any client event, such as a `phx-click` click is pushed, the value sent to the server will be chosen with the following priority: + * The `:value` specified in `Phoenix.LiveView.JS.push/3`, such as: + + ```heex +
+ ``` + * Any number of optional `phx-value-` prefixed attributes, such as:
@@ -43,9 +49,9 @@ sent to the server will be chosen with the following priority: If the `phx-value-` prefix is used, the server payload will also contain a `"value"` if the element's value attribute exists. - * When receiving a map on the server, the payload will also include user defined metadata - of the client event, or an empty map if none is set. For example, the following `LiveSocket` - client option would send the coordinates and `altKey` information for all clicks: + * The payload will also include any additional user defined metadata of the client event. + For example, the following `LiveSocket` client option would send the coordinates and + `altKey` information for all clicks: let liveSocket = new LiveSocket("/live", Socket, { params: {_csrf_token: csrfToken}, @@ -60,20 +66,17 @@ sent to the server will be chosen with the following priority: } }) - -The `phx-capture-click` event is just like `phx-click`, but instead of the click event -being dispatched to the closest `phx-click` element as it bubbles up through the DOM, the event -is dispatched as it propagates from the top of the DOM tree down to the target element. This is -useful when wanting to bind click events without receiving bubbled events from child UI elements. -Since capturing happens before bubbling, this can also be important for preparing or preventing -behaviour that will be applied during the bubbling phase. +The `phx-click-away` event is fired when a click event happens outside of the element. +This is useful for hiding toggled containers like drop-downs. ## Focus and Blur Events Focus and blur events may be bound to DOM elements that emit such events, using the `phx-blur`, and `phx-focus` bindings, for example: - +```heex + +``` To detect when the page itself has received focus or blur, `phx-window-focus` and `phx-window-blur` may be specified. These window @@ -82,19 +85,14 @@ level events may also be necessary if the element in consideration bindings, `phx-value-*` can be provided on the bound element, and those values will be sent as part of the payload. For example: -
- ... -
- -The following window-level bindings are supported: - - * `phx-window-focus` - * `phx-window-blur` - * `phx-window-keydown` - * `phx-window-keyup` +```heex +
+ ... +
+``` ## Key Events @@ -128,6 +126,9 @@ available options can be found on [MDN](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key/Key_Values) or via the [Key Event Viewer](https://w3c.github.io/uievents/tools/key-event-viewer.html). +*Note*: it is possible for certain browser features like autofill to trigger key events +with no `"key"` field present in the value map sent to the server. For this reason, we +recommend always having a fallback catch-all event handler for LiveView key bindings. By default, the bound element will be the event listener, but a window-level binding may be provided via `phx-window-keydown` or `phx-window-keyup`, for example: @@ -150,14 +151,17 @@ for example: {:noreply, assign(socket, :temperature, new_temp)} end - def handle_event("update_temp", _key, socket) do + def handle_event("update_temp", _, socket) do {:noreply, socket} end ## Rate limiting events with Debounce and Throttle All events can be rate-limited on the client by using the -`phx-debounce` and `phx-throttle` bindings, with the following behavior: +`phx-debounce` and `phx-throttle` bindings, with the exception of the `phx-blur` +binding, which is fired immediately. + +Rate limited and debounced events have the following behavior: * `phx-debounce` - Accepts either an integer timeout value (in milliseconds), or `"blur"`. When an integer is provided, emitting the event is delayed by @@ -173,10 +177,12 @@ All events can be rate-limited on the client by using the For example, to avoid validating an email until the field is blurred, while validating the username at most every 2 seconds after a user changes the field: -
- - -
+```heex +
+ + +
+``` And to rate limit a volume up click to once every second: @@ -184,9 +190,11 @@ And to rate limit a volume up click to once every second: Likewise, you may throttle held-down keydown: -
- ... -
+```heex +
+ ... +
+``` Unless held-down keys are required, a better approach is generally to use `phx-keyup` bindings which only trigger on key up, thereby being self-limiting. @@ -203,17 +211,173 @@ The following specialized behavior is performed for forms and keydown bindings: * A `phx-keydown` binding is only throttled for key repeats. Unique keypresses back-to-back will dispatch the pressed key events. +## JS Commands + +LiveView bindings support a JavaScript command interface via the `Phoenix.LiveView.JS` module, which allows you to specify utility operations that execute on the client when firing `phx-` binding events, such as `phx-click`, `phx-change`, etc. Commands compose together to allow you to push events, add classes to elements, transition elements in and out, and more. +See the `Phoenix.LiveView.JS` documentation for full usage. + +For a small example of what's possible, imagine you want to show and hide a modal on the page without needing to make the round trip to the server to render the content: + +```heex + + + + + + + +``` + +Or if your UI library relies on classes to perform the showing or hiding: + +```heex + + + + + +``` + +Commands compose together. For example, you can push an event to the server and +immediately hide the modal on the client: + +```heex + + + +``` + +It is also useful to extract commands into their own functions: + +```elixir +alias Phoenix.LiveView.JS + +def hide_modal(js \\ %JS{}, selector) do + js + |> JS.push("modal-closed") + |> JS.remove_class("show", to: selector, transition: "fade-out") +end +``` + +```heex + +``` + +The `Phoenix.LiveView.JS.push/3` command is particularly powerful in allowing you to customize the event being pushed to the server. For example, imagine you start with a familiar `phx-click` which pushes a message to the server when clicked: + +```heex + +``` + +Now imagine you want to customize what happens when the `"clicked"` event is pushed, such as which component should be targeted, which element should receive css loading state classes, etc. This can be accomplished with options on the JS push command. For example: + +```heex + +``` + +See `Phoenix.LiveView.JS.push/3` for all supported options. + +## Lifecycle Events + +LiveView supports the `phx-mounted`, `phx-connected`, and `phx-disconnected` events to react to +different lifecycle events with JS commands. + +To execute commands when an element first appears on the page, you can leverage `phx-mounted`, +such as to animate a notice into view: + +```heex + +``` + +If `phx-mounted` is used on the initial page render, it will be invoked only after the initial WebSocket connection is established. + +To manage the connection lifecycle, you can combine `phx-disconnected` and `phx-connected` to show an element when the LiveView has lost its connection, and hide it when the connection recovers: + +```heex + +``` + +### LiveView vs static view + +`phx-connected` and `phx-disconnected` are only executed when operating +inside a LiveView container. For static templates, they will have no effect. + +For LiveView, the `phx-mounted` binding is executed as soon as the LiveView is +mounted with a connection. When using `phx-mounted` in static views, it is executed +as soon as the DOM is ready. + ## LiveView Specific Events The `lv:` event prefix supports LiveView specific features that are handled by LiveView without calling the user's `handle_event/3` callbacks. Today, the following events are supported: - - `lv:clear-flash` – clears the flash when sent to the server. If a + - `lv:clear-flash` – clears the flash when sent to the server. If a `phx-value-key` is provided, the specific key will be removed from the flash. For example: -

- <%= live_flash(@flash, :info) %> -

+```heex +

+ <%= live_flash(@flash, :info) %> +

+``` + +## Loading states and errors + +All `phx-` event bindings apply their own css classes when pushed. For example +the following markup: + +```heex + +``` + +On click, would receive the `phx-click-loading` class, and on keydown would receive +the `phx-keydown-loading` class. The css loading classes are maintained until an +acknowledgement is received on the client for the pushed event. + +In the case of forms, when a `phx-change` is sent to the server, the input element +which emitted the change receives the `phx-change-loading` class, along with the +parent form tag. The following events receive css loading classes: + + - `phx-click` - `phx-click-loading` + - `phx-change` - `phx-change-loading` + - `phx-submit` - `phx-submit-loading` + - `phx-focus` - `phx-focus-loading` + - `phx-blur` - `phx-blur-loading` + - `phx-window-keydown` - `phx-keydown-loading` + - `phx-window-keyup` - `phx-keyup-loading` + +Additionally, the following classes are applied to the LiveView's parent +container: + + - `"phx-connected"` - applied when the view has connected to the server + - `"phx-loading"` - applied when the view is not connected to the server + - `"phx-error"` - applied when an error occurs on the server. Note, this + class will be applied in conjunction with `"phx-loading"` if connection + to the server is lost. + +For navigation related loading states (both automatic and manual), see `phx-page-loading` as described in +[JavaScript interoperability: Live navigation events](js-interop.html#live-navigation-events). diff --git a/guides/client/dom-patching.md b/guides/client/dom-patching.md index 0def919c04..efdb087ee4 100644 --- a/guides/client/dom-patching.md +++ b/guides/client/dom-patching.md @@ -7,23 +7,34 @@ is useful for client-side interop with existing libraries that do their own DOM operations. The following `phx-update` values are supported: * `replace` - the default operation. Replaces the element with the contents + * `stream` - supports stream operations. Streams are used to manage large + collections in the UI without having to store the collection on the server * `ignore` - ignores updates to the DOM regardless of new content changes - * `append` - append the new DOM contents instead of replacing - * `prepend` - prepend the new DOM contents instead of replacing When using `phx-update`, a unique DOM ID must always be set in the -container. If using "append" or "prepend", a DOM ID must also be set -for each child. When appending or prepending elements containing an +container. If using "stream", a DOM ID must also be set +for each child. When inserting stream elements containing an ID already present in the container, LiveView will replace the existing -element with the new content instead appending or prepending a new -element. +element with the new content. See `Phoenix.LiveView.stream/3` for more +information. The "ignore" behaviour is frequently used when you need to integrate with another JS library. Note only the element contents are ignored, its attributes can still be updated. -The "append" and "prepend" feature is often used with "Temporary assigns" -to work with large amounts of data. Let's learn more. +To react to elements being mounted to the DOM, the `phx-mounted` binding +can be used. For example, to animate an element on mount: + +
+ +If `phx-mounted` is used on the initial page render, it will be invoked only +after the initial WebSocket connection is established. + +To react to elements being removed from the DOM, the `phx-remove` binding +may be specified, which can contain a `Phoenix.LiveView.JS` command to execute. + +*Note*: The `phx-remove` command is only executed for the removed parent element. +It does not cascade to children. ## Temporary assigns @@ -36,9 +47,11 @@ to be discarded after the client has been patched. Imagine you want to implement a chat application with LiveView. You could render each message like this: - <%= for message <- @messages do %> -

<%= message.username %>: <%= message.text %>

- <% end %> +```heex +<%= for message <- @messages do %> +

<%= message.username %>: <%= message.text %>

+<% end %> +``` Every time there is a new message, you would append it to the `@messages` assign and re-render all messages. @@ -70,13 +83,15 @@ In the template, we want to wrap all of the messages in a container and tag this content with `phx-update`. Remember, we must add an ID to the container as well as to each child: -
- <%= for message <- @messages do %> -

- <%= message.username %>: <%= message.text %> -

- <% end %> -
+```heex +
+ <%= for message <- @messages do %> +

+ <%= message.username %>: <%= message.text %> +

+ <% end %> +
+``` When the client receives new messages, it now knows to append to the old content rather than replace it. @@ -87,69 +102,12 @@ that is being sent to your LiveView like this: def handle_info({:update_message, message}, socket) do {:noreply, update(socket, :messages, fn messages -> [message | messages] end)} end - + You can add it to the list like you do with new messages. LiveView is aware that this -message was rendered on the client, even though the message itself is discarded on the +message was rendered on the client, even though the message itself is discarded on the server after it is rendered. -LiveView uses DOM ids to check if a message is rendered before or not. If an id is -rendered before, the DOM element is updated rather than appending or prepending a new node. +LiveView uses DOM ids to check if a message is rendered before or not. If an id is +rendered before, the DOM element is updated rather than appending or prepending a new node. Also, the order of elements is not changed. You can use it to show edited messages, show likes, or anything that would require an update to a rendered message. - -## Pitfall: temporary assigns to reset or control UI state - -Temporary assigns are useful when you want to render some data and -then discard it so LiveView no longer needs to keep it in memory. - -For this reason, a temporary assign is not re-rendered until it is -set again. This means that temporary assigns should not be used to -reset or control UI state. Let's see an example. - -Imagine you want to show an error message when the input is less than -3 chars. You can write this code: - -```elixir - def render(assigns) do - ~H""" - <%= if @too_short do %> - Input too short... - <% end %> - - Searched for: <%= @search %> -
- """ - end - - def mount(_params, _session, socket) do - {:ok, - assign(socket, too_short: false, search: ""), - temporary_assigns: [too_short: false]} - end - - def handle_event("search", %{"term" => term}, socket) do - # do not search if user provides less then 3 chars - if String.length(term) >= 3 do - {:noreply, assign(socket, search: term)} - else - {:noreply, assign(socket, too_short: true, search: term)} - end - end -``` - -The idea here is that, while the term is less than 3 characters, -we will set `@too_short` to true and show an error message in the -UI accordingly. We also set `@too_short` as a temporary assign, -so that it resets to `false` after every render. - -However, once a temporary assign resets to its original value, -it won't be re-rendered, unless we explicitly assign it to something -else. This means that the LiveView will never re-render the -`if` block and we will continue to show "Input too short..." even -after the input has 3 or more characters. - -The mistake here is using `:temporary_assigns` to reset or control -UI state, while `:temporary_assigns` should rather be used when we -don't have (or don't want to keep) certain data around. The fix is -to set `too_short: false` on the `if` branch, making sure it is -reset whenever the search input changes. diff --git a/guides/client/form-bindings.md b/guides/client/form-bindings.md index 88e3be018f..1d3f6d8aa5 100644 --- a/guides/client/form-bindings.md +++ b/guides/client/form-bindings.md @@ -1,58 +1,62 @@ # Form bindings -## A note about form helpers - -LiveView works with the existing `Phoenix.HTML` form helpers. -If you want to use helpers such as [`text_input/2`](`Phoenix.HTML.Form.text_input/2`), -etc. be sure to `use Phoenix.HTML` at the top of your LiveView. -If your application was generated with Phoenix v1.6, then `mix phx.new` -automatically uses `Phoenix.HTML` when you `use MyAppWeb, :live_view` or -`use MyAppWeb, :live_component` in your modules. - -Using the generated `:live_view` and `:live_component` helpers will also -`import MyAppWeb.ErrorHelpers`, a generated module where `error_tag/2` -resides (usually located at `lib/my_app_web/views/error_helpers.ex`). - -Since `ErrorHelpers` is generated into your app, it is yours -to modify – you may add additional helper functions here, such -as those recommended when rendering feedback for -[`upload_errors/1,2`](`Phoenix.LiveView.Helpers.upload_errors/2`). - ## Form Events To handle form changes and submissions, use the `phx-change` and `phx-submit` events. In general, it is preferred to handle input changes at the form level, where all form fields are passed to the LiveView's callback given any single input change. For example, to handle real-time form validation and -saving, your template would use both `phx_change` and `phx_submit` bindings: - - <.form let={f} for={@changeset} phx-change="validate" phx-submit="save"> - <%= label f, :username %> - <%= text_input f, :username %> - <%= error_tag f, :username %> - - <%= label f, :email %> - <%= text_input f, :email %> - <%= error_tag f, :email %> +saving, your form would use both `phx-change` and `phx-submit` bindings. +Let's get started with an example: + +```heex +<.form for={@form} phx-change="validate" phx-submit="save"> + <.input type="text" field={@form[:username]} /> + <.input type="email" field={@form[:email]} /> + + +``` + +`.form` is the function component defined in `Phoenix.Component.form/1`, +we recommend reading its documentation for more details on how it works +and all supported options. `.form` expects a `@form` assign, which can +be created from a changeset or user parameters via `Phoenix.Component.to_form/1`. + +`input/1` is a function component for rendering inputs, most often +defined in your own application, often encapsulating labelling, +error handling, and more. Here is a simple version to get started with: + + attr :field, Phoenix.HTML.FormField + attr :rest, include: ~w(type) + def input(assigns) do + ~H""" + + """ + end - <%= submit "Save" %> - +> ### The `CoreComponents` module {.info} +> +> If your application was generated with Phoenix v1.7, then `mix phx.new` +> automatically imports many ready-to-use function components, such as +> `.input` component with built-in features and styles. -Next, your LiveView picks up the events in `handle_event` callbacks: +With the form rendered, your LiveView picks up the events in `handle_event` +callbacks, to validate and attempt to save the parameter accordingly: def render(assigns) ... def mount(_params, _session, socket) do - {:ok, assign(socket, %{changeset: Accounts.change_user(%User{})})} + {:ok, assign(socket, form: to_form(Accounts.change_user(%User{}))} end def handle_event("validate", %{"user" => params}, socket) do - changeset = + form = %User{} |> Accounts.change_user(params) |> Map.put(:action, :insert) + |> to_form() - {:noreply, assign(socket, changeset: changeset)} + {:noreply, assign(socket, form: form)} end def handle_event("save", %{"user" => user_params}, socket) do @@ -61,16 +65,16 @@ Next, your LiveView picks up the events in `handle_event` callbacks: {:noreply, socket |> put_flash(:info, "user created") - |> redirect(to: Routes.user_path(MyAppWeb.Endpoint, MyAppWeb.User.ShowView, user))} + |> redirect(to: ~p"/users/#{user}")} {:error, %Ecto.Changeset{} = changeset} -> - {:noreply, assign(socket, changeset: changeset)} + {:noreply, assign(socket, form: to_form(changeset))} end end The validate callback simply updates the changeset based on all form input -values, then assigns the new changeset to the socket. If the changeset -changes, such as generating new errors, [`render/1`](`c:Phoenix.LiveView.render/1`) +values, then convert the changeset to a form and assign it to the socket. +If the form changes, such as generating new errors, [`render/1`](`c:Phoenix.LiveView.render/1`) is invoked and the form is re-rendered. Likewise for `phx-submit` bindings, the same callback is invoked and @@ -79,6 +83,27 @@ socket is annotated for redirect with `Phoenix.LiveView.redirect/2` to the new user page, otherwise the socket assigns are updated with the errored changeset to be re-rendered for the client. +You may wish for an individual input to use its own change event or to target +a different component. This can be accomplished by annotating the input itself +with `phx-change`, for example: + +``` +<.form for={@form} phx-change="validate" phx-submit="save"> + ... + <.input field={@form[:email]} phx-change="email_changed" phx-target={@myself} /> + +``` + +Then your LiveView or LiveComponent would handle the event: + +```elixir +def handle_event("email_changed", %{"user" => %{"email" => email}}, socket) do + ... +end +``` + +_Note_: only the individual input is sent as params for an input marked with `phx-change`. + ## `phx-feedback-for` For proper form error tag updates, the error tag must specify which @@ -88,27 +113,40 @@ Failing to add the `phx-feedback-for` attribute will result in displaying error messages for form fields that the user has not changed yet (e.g. required fields further down on the page). -For example, your `MyAppWeb.ErrorHelpers` may use this function: - - def error_tag(form, field) do - form.errors - |> Keyword.get_values(field) - |> Enum.map(fn error -> - content_tag(:span, translate_error(error), - class: "invalid-feedback", - phx_feedback_for: input_name(form, field) - ) - end) +For example, your `MyAppWeb.CoreComponents` may use this function: + + def input(assigns) do + ~H""" +
+ + <.error :for={msg <- @errors}><%%= msg %> +
+ """ + end + + def error(assigns) do + ~H""" +

+ + <%= render_slot(@inner_block) %> +

+ """ end Now, any DOM container with the `phx-feedback-for` attribute will receive a `phx-no-feedback` class in cases where the form fields has yet to receive -user input/focus. The following css rules are generated in new projects -to hide the errors: - - .phx-no-feedback.invalid-feedback, .phx-no-feedback .invalid-feedback { - display: none; - } +user input/focus. Using new CSS rules or tailwindcss variants allows you +errors to be shown, hidden, and styled as feedback changes. ## Number inputs @@ -118,7 +156,9 @@ from the client when an input is invalid, instead allowing the browser's native validation UI to drive user interaction. Once the input becomes valid, change and submit events will be sent normally. - +```heex + +``` This is known to have a plethora of problems including accessibility, large numbers are converted to exponential notation, and scrolling can accidentally increase or @@ -128,7 +168,9 @@ One alternative is the `inputmode` attribute, which may serve your application's and users much better. According to [Can I Use?](https://caniuse.com/#search=inputmode), the following is supported by 86% of the global market (as of Sep 2021): - +```heex + +``` ## Password inputs @@ -136,10 +178,21 @@ Password inputs are also special cased in `Phoenix.HTML`. For security reasons, password field values are not reused when rendering a password input tag. This requires explicitly setting the `:value` in your markup, for example: - <%= password_input f, :password, value: input_value(f, :password) %> - <%= password_input f, :password_confirmation, value: input_value(f, :password_confirmation) %> - <%= error_tag f, :password %> - <%= error_tag f, :password_confirmation %> +```heex +<.input field={f[:password]} value={input_value(f[:password].value)} /> +<.input field={f[:password_confirmation]} value={input_value(f[:password_confirmation].value)} /> +``` + +## Nested inputs + +Nested inputs are handled using `.inputs_for` function component. By default +it will add the necessary hidden input fields for tracking ids of Ecto associations. + +```heex +<.inputs_for :let={fp} field={f[:friends]}> + <.input field={fp[:name]} type="text"> + +``` ## File inputs @@ -147,12 +200,14 @@ LiveView forms support [reactive file inputs](uploads.md), including drag and drop support via the `phx-drop-target` attribute: -
- ... - <%= live_file_input @uploads.avatar %> -
+```heex +
+ ... + <.live_file_input upload={@uploads.avatar} /> +
+``` -See `Phoenix.LiveView.Helpers.live_file_input/2` for more. +See `Phoenix.Component.live_file_input/1` for more. ## Submitting the form action over HTTP @@ -163,10 +218,12 @@ submit before posting to a controller route for operations that require Plug session mutation. For example, in your LiveView template you can annotate the `phx-trigger-action` with a boolean assign: - <.form let={f} for={@changeset} - action={Routes.reset_password_path(@socket, :create)} - phx-submit="save", - phx-trigger-action={@trigger_submit}> +```heex +<.form :let={f} for={@changeset} + action={Routes.reset_password_path(@socket, :create)} + phx-submit="save" + phx-trigger-action={@trigger_submit}> +``` Then in your LiveView, you can toggle the assign to trigger the form with the current fields on next render: @@ -178,17 +235,18 @@ fields on next render: {:error, changeset} -> {:noreply, assign(socket, changeset: changeset)} - end + end end Once `phx-trigger-action` is true, LiveView disconnects and then submits the form. ## Recovery following crashes or disconnects -By default, all forms marked with `phx-change` will recover input values -automatically after the user has reconnected or the LiveView has remounted -after a crash. This is achieved by the client triggering the same `phx-change` -to the server as soon as the mount has been completed. +By default, all forms marked with `phx-change` and having `id` +attribute will recover input values automatically after the user has +reconnected or the LiveView has remounted after a crash. This is +achieved by the client triggering the same `phx-change` to the server +as soon as the mount has been completed. **Note:** if you want to see form recovery working in development, please make sure to disable live reloading in development by commenting out the @@ -206,7 +264,7 @@ to trigger for recovery, which will receive the form params as usual. For exampl imagine a LiveView wizard form where the form is stateful and built based on what step the user is on and by prior selections: -
+ On the server, the `"validate_wizard_step"` event is only concerned with the current client form data, but the server maintains the entire state of the wizard. @@ -225,6 +283,29 @@ above, which would wire up to the following server callbacks in your LiveView: To forgo automatic form recovery, set `phx-auto-recover="ignore"`. +## Resetting Forms + +To reset a LiveView form, you can use the standard `type="reset"` on a +form button or input. When clicked, the form inputs will be reset to their +original values, and Phoenix will hide errors for `phx-fedback-for` elements. +After the form is reset, a `phx-change` event is emitted with the `_target` param +containing the reset `name`. For example, the following element: + + + ... + +
+ +Can be handled on the server differently from your regular change function: + + def handle_event("changed", %{"_target" => ["reset"]} = params, socket) do + # handle form reset + end + + def handle_event("changed", params, socket) do + # handle regular form change + end + ## JavaScript client specifics The JavaScript client is always the source of truth for current input values. @@ -236,13 +317,15 @@ errors, or additive UX around the user's input values as they fill out a form. For these use cases, the `phx-change` input does not concern itself with disabling input editing while an event to the server is in flight. When a `phx-change` event is sent to the server, the input tag and parent form tag receive the -`phx-change-loading` css class, then the payload is pushed to the server with a +`phx-change-loading` CSS class, then the payload is pushed to the server with a `"_target"` param in the root payload containing the keyspace of the input name which triggered the change event. For example, if the following input triggered a change event: - +```heex + +``` The server's `handle_event/3` would receive a payload: @@ -264,12 +347,14 @@ On completion of server processing of the `phx-submit` event: 2. The last input with focus is restored (unless another input has received focus) 3. Updates are patched to the DOM as usual -To handle latent events, any HTML tag can be annotated with +To handle latent events, the ` +```heex + +``` You may also take advantage of LiveView's CSS loading state classes to swap out your form content while the form is submitting. For example, @@ -278,20 +363,47 @@ with the following rules in your `app.css`: .while-submitting { display: none; } .inputs { display: block; } - .phx-submit-loading { - .while-submitting { display: block; } - .inputs { display: none; } - } + .phx-submit-loading .while-submitting { display: block; } + .phx-submit-loading .inputs { display: none; } You can show and hide content with the following markup: -
-
Please wait while we save our content...
-
- -
-
+```heex +
+
Please wait while we save our content...
+
+ +
+
+``` Additionally, we strongly recommend including a unique HTML "id" attribute on the form. When DOM siblings change, elements without an ID will be replaced rather than moved, which can cause issues such as form fields losing focus. + +## Triggering `phx-` form events with JavaScript + +Often it is desirable to trigger an event on a DOM element without explicit +user interaction on the element. For example, a custom form element such as a +date picker or custom select input which utilizes a hidden input element to +store the selected state. + +In these cases, the event functions on the DOM API can be used, for example +to trigger a `phx-change` event: + +``` +document.getElementById("my-select").dispatchEvent( + new Event("input", {bubbles: true}) +) +``` + +When using a client hook, `this.el` can be used to determine the element as +outlined in the "Client hooks" documentation. + +It is also possible to trigger a `phx-submit` using a "submit" event: + +``` +document.getElementById("my-form").dispatchEvent( + new Event("submit", {bubbles: true, cancelable: true}) +) +``` diff --git a/guides/client/js-interop.md b/guides/client/js-interop.md index 700e23ab19..e90f77eb34 100644 --- a/guides/client/js-interop.md +++ b/guides/client/js-interop.md @@ -1,14 +1,15 @@ # JavaScript interoperability -As seen earlier, you start by instantiating a single LiveSocket to enable LiveView -client/server interaction, for example: +To enable LiveView client/server interaction, we instantiate a LiveSocket. For example: - import {Socket} from "phoenix" - import {LiveSocket} from "phoenix_live_view" +``` +import {Socket} from "phoenix" +import {LiveSocket} from "phoenix_live_view" - let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") - let liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}}) - liveSocket.connect() +let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") +let liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}}) +liveSocket.connect() +``` All options are passed directly to the `Phoenix.Socket` constructor, except for the following LiveView specific options: @@ -16,11 +17,11 @@ except for the following LiveView specific options: * `bindingPrefix` - the prefix to use for phoenix bindings. Defaults `"phx-"` * `params` - the `connect_params` to pass to the view's mount callback. May be a literal object or closure returning an object. When a closure is provided, - the function receives the view's phx-view name. - * `hooks` – a reference to a user-defined hooks namespace, containing client - callbacks for server/client interop. See the [Client hooks](#client-hooks) + the function receives the view's element. + * `hooks` – a reference to a user-defined hooks namespace, containing client + callbacks for server/client interop. See the [Client hooks](#client-hooks-via-phx-hook) section below for details. - * `uploaders` – a reference to a user-defined uploaders namespace, containing + * `uploaders` – a reference to a user-defined uploaders namespace, containing client callbacks for client-side direct-to-cloud uploads. See the [External Uploads guide](uploads-external.md) for details. @@ -32,13 +33,15 @@ Calling `enableDebug()` turns on debug logging which includes LiveView life-cycl payload events as they come and go from client to server. In practice, you can expose your instance on `window` for quick access in the browser's web console, for example: - // app.js - let liveSocket = new LiveSocket(...) - liveSocket.connect() - window.liveSocket = liveSocket +``` +// app.js +let liveSocket = new LiveSocket(...) +liveSocket.connect() +window.liveSocket = liveSocket - // in the browser's web console - >> liveSocket.enableDebug() +// in the browser's web console +>> liveSocket.enableDebug() +``` The debug state uses the browser's built-in `sessionStorage`, so it will remain in effect for as long as your browser session lasts. @@ -54,58 +57,38 @@ the `LiveSocket` instance includes `enableLatencySim(milliseconds)` and `disable functions which apply throughout the current browser session. The `enableLatencySim` function accepts an integer in milliseconds for the round-trip-time to the server. For example: - // app.js - let liveSocket = new LiveSocket(...) - liveSocket.connect() - window.liveSocket = liveSocket - - // in the browser's web console - >> liveSocket.enableLatencySim(1000) - [Log] latency simulator enabled for the duration of this browser session. - Call disableLatencySim() to disable - -## Loading state and errors - -By default, the following classes are applied to the LiveView's parent -container: - - - `"phx-connected"` - applied when the view has connected to the server - - `"phx-disconnected"` - applied when the view is not connected to the server - - `"phx-error"` - applied when an error occurs on the server. Note, this - class will be applied in conjunction with `"phx-disconnected"` if connection - to the server is lost. - -All `phx-` event bindings apply their own css classes when pushed. For example -the following markup: - - +``` +// app.js +let liveSocket = new LiveSocket(...) +liveSocket.connect() +window.liveSocket = liveSocket + +// in the browser's web console +>> liveSocket.enableLatencySim(1000) +[Log] latency simulator enabled for the duration of this browser session. + Call disableLatencySim() to disable +``` -On click, would receive the `phx-click-loading` class, and on keydown would receive -the `phx-keydown-loading` class. The css loading classes are maintained until an -acknowledgement is received on the client for the pushed event. +## Event listeners -In the case of forms, when a `phx-change` is sent to the server, the input element -which emitted the change receives the `phx-change-loading` class, along with the -parent form tag. The following events receive css loading classes: +LiveView emits several events to the browsers and allows developers to submit +their own events too. - - `phx-click` - `phx-click-loading` - - `phx-change` - `phx-change-loading` - - `phx-submit` - `phx-submit-loading` - - `phx-focus` - `phx-focus-loading` - - `phx-blur` - `phx-blur-loading` - - `phx-window-keydown` - `phx-keydown-loading` - - `phx-window-keyup` - `phx-keyup-loading` +### Live navigation events -For live page navigation via `live_redirect` and `live_patch`, as well as form +For live page navigation via `<.link navigate={...}>` and `<.link patch={...}>`, +their server-side equivalents `push_redirect` and `push_patch`, as well as form submits via `phx-submit`, the JavaScript events `"phx:page-loading-start"` and `"phx:page-loading-stop"` are dispatched on window. Additionally, any `phx-` event may dispatch page loading events by annotating the DOM element with `phx-page-loading`. This is useful for showing main page loading status, for example: - // app.js - import topbar from "topbar" - window.addEventListener("phx:page-loading-start", info => topbar.show()) - window.addEventListener("phx:page-loading-stop", info => topbar.hide()) +``` +// app.js +import topbar from "topbar" +window.addEventListener("phx:page-loading-start", info => topbar.show()) +window.addEventListener("phx:page-loading-stop", info => topbar.hide()) +``` Within the callback, `info.detail` will be an object that contains a `kind` key, with a value that depends on the triggering event: @@ -122,38 +105,70 @@ In the case of an `"element"` page loading event, the info will contain a `"target"` key containing the DOM element which triggered the page loading state. -## Triggering phx form events with JavaScript +### Handling server-pushed events -Often it is desirable to trigger an event on a DOM element without explicit -user interaction on the element. For example, a custom form element such as a -date picker or custom select input which utilizes a hidden input element to -store the selected state. +When the server uses `Phoenix.LiveView.push_event/3`, the event name +will be dispatched in the browser with the `phx:` prefix. For example, +imagine the following template where you want to highlight an existing +element from the server to draw the user's attention: + +```heex +
+ <%= item.title %> +
+``` -In these cases, the event functions on the DOM API can be used, for example -to trigger a `phx-change` event: +Next, the server can issue a highlight using the standard `push_event`: -```javascript -document.getElementById("my-select").dispatchEvent( - new Event("input", {bubbles: true}) -) +```elixir +def handle_info({:item_updated, item}, socket) do + {:noreply, push_event(socket, "highlight", %{id: "item-#{item.id}"})} +end ``` -When using a client hook, `this.el` can be used to determine the element as -outlined in the "Client hooks" documentation. +Finally, a window event listener can listen for the event and conditionally +execute the highlight command if the element matches: -It is also possible to trigger a `phx-submit` using a "submit" event: +``` +let liveSocket = new LiveSocket(...) +window.addEventListener(`phx:highlight`, (e) => { + let el = document.getElementById(e.detail.id) + if(el) { + // logic for highlighting + } +}) +``` + +If you desire, you can also integrate this functionality with Phoenix' +JS commands, executing JS commands for the given element whenever highlight +is triggered. First, update the element to embed the JS command into a data +attribute: + +```heex +
+ <%= item.title %> +
+``` + +Now, in the event listener, use `LiveSocket.execJS` to trigger all JS +commands in the new attribute: -```javascript -document.getElementById("my-form").dispatchEvent( - new Event("submit", {bubbles: true}) -) +``` +let liveSocket = new LiveSocket(...) +window.addEventListener(`phx:highlight`, (e) => { + document.querySelectorAll(`[data-highlight]`).forEach(el => { + if(el.id == e.detail.id){ + liveSocket.execJS(el, el.getAttribute("data-highlight")) + } + }) +}) ``` -## Client hooks +## Client hooks via `phx-hook` To handle custom client-side JavaScript when an element is added, updated, -or removed by the server, a hook object may be provided with the following -life-cycle callbacks: +or removed by the server, a hook object may be provided via `phx-hook`. +`phx-hook` must point to an object with the following life-cycle callbacks: * `mounted` - the element has been added to the DOM and its server LiveView has finished mounting @@ -166,14 +181,19 @@ life-cycle callbacks: * `disconnected` - the element's parent LiveView has disconnected from the server * `reconnected` - the element's parent LiveView has reconnected to the server +*Note:* When using hooks outside the context of a LiveView, `mounted` is the only +callback invoked, and only those elements on the page at DOM ready will be tracked. +For dynamic tracking of the DOM as elements are added, removed, and updated, a LiveView +should be used. + The above life-cycle callbacks have in-scope access to the following attributes: - * `el` - attribute referencing the bound DOM node, - * `viewName` - attribute matching the DOM node's phx-view value + * `el` - attribute referencing the bound DOM node + * `liveSocket` - the reference to the underlying `LiveSocket` instance * `pushEvent(event, payload, (reply, ref) => ...)` - method to push an event from the client to the LiveView server * `pushEventTo(selectorOrTarget, event, payload, (reply, ref) => ...)` - method to push targeted events from the client to LiveViews and LiveComponents. It sends the event to the LiveComponent or LiveView the `selectorOrTarget` is - defined in, where it's value can be either a query selector or an actual DOM element. If the query selector returns + defined in, where its value can be either a query selector or an actual DOM element. If the query selector returns more than one element it will send the event to all of them, even if all the elements are in the same LiveComponent or LiveView. * `handleEvent(event, (payload) => ...)` - method to handle an event pushed from the server @@ -181,7 +201,7 @@ The above life-cycle callbacks have in-scope access to the following attributes: * `uploadTo(selectorOrTarget, name, files)` - method to inject a list of file-like objects into an uploader. The hook will send the files to the uploader with `name` defined by [`allow_upload/3`](`Phoenix.LiveView.allow_upload/3`) on the server-side. Dispatching new uploads triggers an input change event which will be sent to the - LiveComponent or LiveView the `selectorOrTarget` is defined in, where it's value can be either a query selector or an + LiveComponent or LiveView the `selectorOrTarget` is defined in, where its value can be either a query selector or an actual DOM element. If the query selector returns more than one live file input, an error will be logged. For example, the markup for a controlled input for phone-number formatting could be written @@ -214,7 +234,9 @@ DOM management, the `LiveSocket` constructor accepts a `dom` option with an function just before the DOM patch operations occurs in LiveView. This allows external libraries to (re)initialize DOM elements or copy attributes as necessary as LiveView performs its own patch operations. The update operation cannot be cancelled or deferred, -and the return value is ignored. For example, the following option could be used to add +and the return value is ignored. + +For example, the following option could be used to add [Alpine.js](https://github.com/alpinejs/alpine) support to your project: let liveSocket = new LiveSocket("/live", Socket, { @@ -226,6 +248,17 @@ and the return value is ignored. For example, the following option could be used }, }) +You could also use the same approach to guarantee that some attributes set on the client-side are kept intact. +In the following example, all attributes starting with `data-js-` won't be replaced when the DOM is patched by LiveView: + + onBeforeElUpdated(from, to){ + for (const attr of from.attributes){ + if (attr.name.startsWith("data-js-")){ + to.setAttribute(attr.name, attr.value); + } + } + } + ### Client-server communication A hook can push events to the LiveView by using the `pushEvent` function and receive a @@ -237,7 +270,7 @@ hook element or by using `Phoenix.LiveView.push_event/3` on the server and `hand For example, to implement infinite scrolling, one can pass the current page using data attributes: -
+
And then in the client: @@ -268,7 +301,7 @@ And then on the client: } } -*Note*: events pushed from the server via `push_event` are global and will be dispatched +*Note*: remember events pushed from the server via `push_event` are global and will be dispatched to all active hooks on the client who are handling that event. *Note*: In case a LiveView pushes events and renders content, `handleEvent` callbacks are invoked after the page is updated. Therefore, if the LiveView redirects at the same time it pushes events, callbacks won't be invoked on the old page's elements. Callbacks would be invoked on the redirected page's newly mounted hook elements. diff --git a/guides/client/uploads-external.md b/guides/client/uploads-external.md index 8373783fd2..b74b554228 100644 --- a/guides/client/uploads-external.md +++ b/guides/client/uploads-external.md @@ -106,6 +106,26 @@ In order to enforce all of your file constraints when uploading to S3, it is necessary to perform a multipart form POST with your file data. +This guide assumes an existing S3 bucket with the correct CORS configuration +which allows uploading directly to the bucket. + +An example CORS config is: + +```js +[ + { + "AllowedHeaders": [ "*" ], + "AllowedMethods": [ "PUT", "POST" ], + "AllowedOrigins": [ your_domain_or_*_here ], + "ExposeHeaders": [] + } +] +``` + +More information on configuring CORS for S3 buckets is available at: + +https://docs.aws.amazon.com/AmazonS3/latest/userguide/ManageCorsUsing.html + > The following example uses a zero-dependency module > called [`SimpleS3Upload`](https://gist.github.com/chrismccord/37862f1f8b1f5148644b75d20d1cb073) > written by Chris McCord to generate pre-signed URLs for S3. @@ -133,7 +153,7 @@ defp presign_upload(entry, socket) do SimpleS3Upload.sign_form_upload(config, bucket, key: key, content_type: entry.client_type, - max_file_size: uploads.avatar.max_file_size, + max_file_size: uploads[entry.upload_config].max_file_size, expires_in: :timer.hours(1) ) diff --git a/guides/introduction/installation.md b/guides/introduction/installation.md index 50addf961d..85433bf105 100644 --- a/guides/introduction/installation.md +++ b/guides/introduction/installation.md @@ -1,8 +1,18 @@ # Installation -**Note:** Phoenix v1.5 comes with built-in support for LiveView apps. Just create -your application with `mix phx.new my_app --live`. If you are using earlier Phoenix -versions or your app already exists, keep on reading. +## New projects + +Phoenix v1.5+ comes with built-in support for LiveView apps. Just create +your application with `mix phx.new my_app --live`. The `--live` flag has +become the default on Phoenix v1.6. + +Once you've created a LiveView project, refer to [LiveView documentation](`Phoenix.LiveView`) +for further information on how to use it. + +## Existing projects + +If you are using a Phoenix version earlier than v1.5 or your app already exists, continue +with the following steps. The instructions below will serve if you are installing the latest stable version from Hex. To start using LiveView, add one of the following dependencies to your `mix.exs` @@ -13,7 +23,7 @@ If installing from Hex, use the latest version from there: ```elixir def deps do [ - {:phoenix_live_view, "~> 0.16.3"}, + {:phoenix_live_view, "~> 0.18"}, {:floki, ">= 0.30.0", only: :test} ] end @@ -46,26 +56,40 @@ Next, add the following imports to your web file in `lib/my_app_web.ex`: def view do quote do - ... - import Phoenix.LiveView.Helpers + # ... + import Phoenix.Component end end def router do quote do - ... + # ... import Phoenix.LiveView.Router end end ``` +In that same file, update your live_view layout configuration: + +```diff +# lib/my_app_web.ex + +def live_view do + use Phoenix.LiveView, +- layout: {MyAppWeb.LayoutView, "live.html"} ++ layout: {MyAppWeb.LayoutView, :live} + + unquote(view_helpers()) +end +``` + Then add the `Phoenix.LiveView.Router.fetch_live_flash/2` plug to your browser pipeline, in place of `:fetch_flash`: ```diff # lib/my_app_web/router.ex pipeline :browser do - ... + # ... plug :fetch_session - plug :fetch_flash + plug :fetch_live_flash @@ -118,14 +142,14 @@ Where `@session_options` are the options given to `plug Plug.Session` by using a Finally, ensure you have placed a CSRF meta tag inside the `` tag in your layout (`lib/my_app_web/templates/layout/app.html.*`) before `app.js` is included, like so: -```html -<%= csrf_meta_tag() %> +```heex + ``` and enable connecting to a LiveView socket in your `app.js` file. -```javascript +```js // assets/js/app.js import {Socket} from "phoenix" import {LiveSocket} from "phoenix_live_view" @@ -173,7 +197,7 @@ However, if you're adding `phoenix_live_view` to an umbrella project, the depend } ``` -Now run the next commands from your web app root: +Now run the next commands from the root of your web app project: ```bash npm install --prefix assets @@ -191,22 +215,23 @@ npm install --force phoenix_live_view --prefix assets LiveView does not use the default app layout. Instead, you typically call `put_root_layout` in your router to specify a layout that is used by both "regular" views and live views. In your router, do: ```elixir +# lib/my_app_web/router.ex + pipeline :browser do - ... + # ... plug :put_root_layout, {MyAppWeb.LayoutView, :root} - ... + # ... end ``` -The layout given to `put_root_layout` must use `<%= @inner_content %>` instead of `<%= render(@view_module, @view_template, assigns) %>`. It is typically very barebones, with mostly -`` and `` tags. For example: +The layout given to `put_root_layout` is typically very barebones, with mostly `` and `` tags. For example: -```elixir +```heex - <%= csrf_meta_tag() %> - <%= live_title_tag assigns[:page_title] || "MyApp" %> + + <%= assigns[:page_title] || "MyApp" %> "/> @@ -216,7 +241,7 @@ The layout given to `put_root_layout` must use `<%= @inner_content %>` instead o ``` -Once you have specified a root layout, "app.html.heex" will be rendered within your root layout for all non-LiveViews. You may also optionally define a "live.html.heex" layout to be used across all LiveViews, as we will describe in the next section. +Once you have specified a root layout, `app.html.heex` will be rendered within your root layout for all non-LiveViews. You may also optionally define a `live.html.heex` layout to be used across all LiveViews, as we will describe in the next section. Optionally, you can add a [`phx-track-static`](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#static_changed?/1) to all `script` and `link` elements in your layout that use `src` and `href`. This way you can detect when new assets have been deployed by calling `static_changed?`. @@ -225,72 +250,6 @@ Optionally, you can add a [`phx-track-static`](https://hexdocs.pm/phoenix_live_v ``` -## phx.gen.live support - -While the above instructions are enough to install LiveView in a Phoenix app, if you want to use the `phx.gen.live` generators that come as part of Phoenix v1.5, you need to do one more change, as those generators assume your application was created with `mix phx.new --live`. - -The change is to define the `live_view` and `live_component` functions in your `my_app_web.ex` file, while refactoring the `view` function. At the end, they will look like this: - -```elixir - def view do - quote do - use Phoenix.View, - root: "lib/<%= lib_web_name %>/templates", - namespace: <%= web_namespace %> - - # Import convenience functions from controllers - import Phoenix.Controller, - only: [get_flash: 1, get_flash: 2, view_module: 1, view_template: 1] - - # Include shared imports and aliases for views - unquote(view_helpers()) - end - end - - def live_view do - quote do - use Phoenix.LiveView, - layout: {<%= web_namespace %>.LayoutView, "live.html"} - - unquote(view_helpers()) - end - end - - def live_component do - quote do - use Phoenix.LiveComponent - - unquote(view_helpers()) - end - end - - defp view_helpers do - quote do - # Use all HTML functionality (forms, tags, etc) - use Phoenix.HTML - - # Import LiveView helpers (live_render, live_component, live_patch, etc) - import Phoenix.LiveView.Helpers - - # Import basic rendering functionality (render, render_layout, etc) - import Phoenix.View - - import MyAppWeb.ErrorHelpers - import MyAppWeb.Gettext - alias MyAppWeb.Router.Helpers, as: Routes - end - end -``` - -Note that LiveViews are automatically configured to use a "live.html.heex" layout in this line: - -```elixir -use Phoenix.LiveView, - layout: {<%= web_namespace %>.LayoutView, "live.html"} -``` - -"layouts/root.html.heex" is shared by regular and live views, "app.html.heex" is rendered inside the root layout for regular views, and "live.html.heex" is rendered inside the root layout for LiveViews. "live.html.heex" typically starts out as a copy of "app.html.heex", but using the `@socket` assign instead of `@conn`. Check the [Live Layouts](live-layouts.md) guide for more information. - ## Progress animation If you want to show a progress bar as users perform live actions, we recommend using [`topbar`](https://github.com/buunguyen/topbar). @@ -304,14 +263,34 @@ $ npm install --prefix assets --save topbar Then customize LiveView to use it in your `assets/js/app.js`, right before the `liveSocket.connect()` call: ```js -import topbar from "topbar" - // Show progress bar on live navigation and form submits +import topbar from "topbar" topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"}) window.addEventListener("phx:page-loading-start", info => topbar.show()) window.addEventListener("phx:page-loading-stop", info => topbar.hide()) ``` +Alternatively, you can also delay showing the `topbar` and wait if the results do not appear within 200ms: + +```js +// Show progress bar on live navigation and form submits +import topbar from "topbar" +topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"}) +let topBarScheduled = undefined + +window.addEventListener("phx:page-loading-start", () => { + if(!topBarScheduled) { + topBarScheduled = setTimeout(() => topbar.show(), 200) + } +}) + +window.addEventListener("phx:page-loading-stop", () => { + clearTimeout(topBarScheduled) + topBarScheduled = undefined + topbar.hide() +}) +``` + ## Location for LiveView modules By convention your LiveView modules and `heex` templates should be placed in `lib/my_app_web/live/` directory. diff --git a/guides/server/assigns-eex.md b/guides/server/assigns-eex.md index 502f618d77..60623219d1 100644 --- a/guides/server/assigns-eex.md +++ b/guides/server/assigns-eex.md @@ -1,21 +1,18 @@ # Assigns and HEEx templates -All of the data in a LiveView is stored in the socket as assigns. -The `Phoenix.LiveView.assign/2` and `Phoenix.LiveView.assign/3` +All of the data in a LiveView is stored in the socket as assigns, which +is a server side struct in `Phoenix.LiveView.Socket`. Socket state is +never shared with the client beyond what your template renders. + +The `Phoenix.Component.assign/2` and `Phoenix.Component.assign/3` functions help store those values. Those values can be accessed in the LiveView as `socket.assigns.name` but they are accessed inside LiveView templates as `@name`. -`Phoenix.LiveView`'s built-in templates are identified by the `.heex` -extension (HTML EEx) or `~H` sigil. You can learn more about them -by checking the docs for `Phoenix.LiveView.Helpers.sigil_H/2`. -They are an extension of regular `.eex` templates with additional -features such as: - - * validation of HTML - * friendly-syntax for defining HTML components - * minimize the amount of data sent over the wire by splitting static and dynamic parts - * provide change tracking to avoid recomputing parts of the template that did not change +Phoenix template language is called HEEx (HTML+EEx). Those templates +are either files with the `.heex` extension or they are created +directly in source files via the `~H` sigil. You can learn more about +the HEEx syntax by checking the docs for [the `~H` sigil](`Phoenix.Component.sigil_H/2`). In this section, we are going to cover how LiveView minimizes the payload over the wire by understanding the interplay between @@ -27,7 +24,9 @@ When you first render a `.heex` template, it will send all of the static and dynamic parts of the template to the client. Imagine the following template: -

<%= expand_title(@title) %>

+```heex +

<%= expand_title(@title) %>

+``` It has two static parts, `

` and `

` and one dynamic part made of `expand_title(@title)`. Further rendering of this template @@ -42,9 +41,12 @@ nothing is sent. Change tracking also works when accessing map/struct fields. Take this template: -
- <%= @user.name %> -
+ +```heex +
+ <%= @user.name %> +
+``` If the `@user.name` changes but `@user.id` doesn't, then LiveView will re-render only `@user.name` and it will not execute or resend `@user.id` @@ -53,19 +55,26 @@ at all. The change tracking also works when rendering other templates as long as they are also `.heex` templates: - <%= render "child_template.html", assigns %> + +```heex +<%= render "child_template.html", assigns %> +``` Or when using function components: - <.show_name name={@user.name} /> +```heex +<.show_name name={@user.name} /> +``` The assign tracking feature also implies that you MUST avoid performing direct operations in the template. For example, if you perform a database query in your template: - <%= for user <- Repo.all(User) do %> - <%= user.name %> - <% end %> +```heex +<%= for user <- Repo.all(User) do %> + <%= user.name %> +<% end %> +``` Then Phoenix will never re-render the section above, even if the number of users in the database changes. Instead, you need to store the users as @@ -88,20 +97,24 @@ If the do/end block is given to a library function or user function, such as `content_tag`, change tracking won't work. For example, imagine the following template that renders a `div`: - <%= content_tag :div, id: "user_#{@id}" do %> - <%= @name %> - <%= @description %> - <% end %> +```heex +<%= content_tag :div, id: "user_#{@id}" do %> + <%= @name %> + <%= @description %> +<% end %> +``` LiveView knows nothing about `content_tag`, which means the whole `div` will be sent whenever any of the assigns change. Luckily, HEEx templates provide a nice syntax for building tags, so there is rarely a need to use `content_tag` inside `.heex` templates: -
- <%= @name %> - <%= @description %> -
+```heex +
+ <%= @name %> + <%= @description %> +
+``` The next pitfall is related to variables. Due to the scope of variables, LiveView has to disable change tracking whenever variables are used in the @@ -109,12 +122,16 @@ template, with the exception of variables introduced by Elixir basic `case`, `for`, and other block constructs. Therefore, you **must avoid** code like this in your LiveView templates: - <% some_var = @x + @y %> - <%= some_var %> +```heex +<% some_var = @x + @y %> +<%= some_var %> +``` Instead, use a function: - <%= sum(@x, @y) %> +```heex +<%= sum(@x, @y) %> +``` Similarly, **do not** define variables at the top of your `render` function: @@ -136,9 +153,11 @@ access variables is always executed on every render. This also applies to the constructs. For example, accessing the `post` variable defined by the comprehension below works as expected: - <%= for post <- @posts do %> - ... - <% end %> +```heex +<%= for post <- @posts do %> + ... +<% end %> +``` To sum up: diff --git a/guides/server/deployments.md b/guides/server/deployments.md new file mode 100644 index 0000000000..4f39be9323 --- /dev/null +++ b/guides/server/deployments.md @@ -0,0 +1,17 @@ +# Deployments + +One of the questions that arise from LiveView stateful model is what considerations are necessary when deploying a new version of LiveView. + +First off, whenever LiveView disconnects, it will automatically attempt to reconnect to the server using exponential back-off. This means it will try immediately, then wait 2s and try again, then 5s and so on. If you are deploying, this typically means the next reconnection will immediately succeed and your load balancer will automatically redirect to the new servers. + +However, your LiveView _may_ still have state that will be lost in this transition. How to deal with it? The good news is that there are a series of practices you can follow that will not only help with deployments but it will improve your application in general. + +1. Keep state in the query parameters when appropriate. For example, if your application has tabs and the user clicked a tab, instead of using `phx-click` and `c:handle_event/3` to manage it, you should implement it using `<.link patch={...}>` passing the tab name as parameter. You will then receive the new tab name `c:handle_params/3` which will set the relevant assign to choose which tab to display. You can even define specific URLs for each tab in your application router. By doing this, you will reduce the amount of server state, make tab navigation sharable via links, improving search engine indexing, and more. + +2. Consider storing other relevant state in the database. For example, if you are building a chat app and you want to store which messages have been read, you can store so in the database. Once the page is loaded, you retrieve the index of the last read message. This makes the application more robust, allow data to be synchronized across devices, etc. + +3. If your application uses forms (which is most likely true), keep in mind that Phoenix perform automatic form recovery: in case of disconnections, Phoenix will collect the form data and resubmit it on reconnection. This mechanism works out of the box for most forms but you may want to customize it or test it for your most complex forms. See the relevant section [in the "Form bindings" document](guides/client/form-bindings.md) to learn more. + +The idea is that: if you follow the practices above, most of your state is already handled within your app and therefore deployments should not bring additional concerns. Not only that, it will bring overall benefits to your app such as indexing, link sharing, device sharing, and so on. + +If you really have complex state that cannot be immediately handled, then you may need to resort to special strategies. This may be persisting "old" state to Redis/S3/Database and loading the new state on the new connections. Or you may take special care when migrating connections (for example, if you are building a game, you may want to wait for on-going sessions to finish before turning down the old server while routing new sessions to the new ones). Such cases will depend on your requirements (and they would likely exist regardless of which application stack you are using). diff --git a/guides/server/error-handling.md b/guides/server/error-handling.md index 157ea5d9a4..3b447308ec 100644 --- a/guides/server/error-handling.md +++ b/guides/server/error-handling.md @@ -66,7 +66,7 @@ check that the user has access to it like this: The code above builds a query that returns all organizations that belongs to the current user and then validates that the given "org_id" belongs to the user. If there is no such "org_id" or if the user has no access to it, an -`Ecto.NotFoundError` exception is raised. +`Ecto.NoResultsError` exception is raised. During a regular controller request, this exception will be converted to a 404 exception and rendered as a custom error page, as diff --git a/guides/server/live-layouts.md b/guides/server/live-layouts.md index b628e16962..ad74a31100 100644 --- a/guides/server/live-layouts.md +++ b/guides/server/live-layouts.md @@ -1,80 +1,38 @@ # Live layouts -*NOTE:* Make sure you've read the [Assigns and LiveEEx templates](assigns-eex.md) +*NOTE:* Make sure you've read the [Assigns and HEEx templates](assigns-eex.md) guide before moving forward. -When working with LiveViews, there are usually three layouts to be -considered: +From Phoenix v1.7, your application is made of two layouts: * the root layout - this is a layout used by both LiveView and regular views. This layout typically contains the `` definition alongside the head and body tags. Any content defined in the root layout will remain the same, even as you live navigate - across LiveViews. All LiveViews defined at the router must have - a root layout. The root layout is typically declared on the + across LiveViews. The root layout is typically declared on the router with `put_root_layout` and defined as "root.html.heex" - in your `MyAppWeb.LayoutView`. It may also be given via the - `:layout` option to the router's `live` macro. + in your layouts folder. It may also be given via the + `:root_layout` option to a `live_session` macro in the router. * the app layout - this is the default application layout which - is not included or used by LiveViews. It defaults to "app.html.heex" - in your `MyAppWeb.LayoutView`. + is rendered on both regular HTTP requests and LiveViews. + It defaults to "app.html.heex" - * the live layout - this is the layout which wraps a LiveView and - is rendered as part of the LiveView life-cycle. It must be opt-in - by passing the `:layout` option on `use Phoenix.LiveView`. It is - typically set to "live.html.heex"in your `MyAppWeb.LayoutView`. - -Overall, those layouts are found in `templates/layout` with the -following names: - - * root.html.heex - * app.html.heex - * live.html.heex - -> Note: if you are using earlier Phoenix versions, those layouts -> may use `.eex` and `.leex` extensions instead of `.heex`, but -> we have since then normalized on the latter. +Overall, those layouts are found in `components/layouts` and are +embedded within `MyAppWeb.Layouts`. All layouts must call `<%= @inner_content %>` to inject the content rendered by the layout. -The "root" layout is shared by both "app" and "live" layouts. -It is rendered only on the initial request and therefore it -has access to the `@conn` assign. The root layout must be defined -in your router: +The "root" layout is rendered only on the initial request and +therefore it has access to the `@conn` assign. The root layout +is typically defined in your router: plug :put_root_layout, {MyAppWeb.LayoutView, :root} -The "app" and "live" layouts are often small and similar to each -other, but the "app" layout uses the `@conn` and is used as part -of the regular request life-cycle. The "live" layout is part of -the LiveView and therefore has direct access to the `@socket`. - -For example, you can define a new `live.html.heex` layout with -dynamic content. You must use `@inner_content` where the output -of the actual template will be placed at: - -

<%= live_flash(@flash, :notice) %>

-

<%= live_flash(@flash, :error) %>

- <%= @inner_content %> - -To use the live layout, update your LiveView to pass the `:layout` -option to `use Phoenix.LiveView`: - - use Phoenix.LiveView, layout: {MyAppWeb.LayoutView, "live.html"} - -If you are using Phoenix v1.5, the layout is automatically set -when generating apps with the `mix phx.new --live` flag. - -The `:layout` option on `use` does not apply to LiveViews rendered -within other LiveViews. If you want to render child live views or -opt-in to a layout, use `:layout` as an option in mount: - - def mount(_params, _session, socket) do - socket = assign(socket, new_message_count: 0) - {:ok, socket, layout: {MyAppWeb.LayoutView, "live.html"}} - end +The "app.html.heex" layout is rendered with either `@conn` or +`@socket`. See the `def controller` and `def live_view` definitions +in your `MyAppWeb` to learn how it is included. *Note*: The live layout is always wrapped by the LiveView's `:container` tag. @@ -96,13 +54,19 @@ mount: Then access `@page_title` in the root layout: - <%= @page_title %> +```heex +<%= @page_title %> +``` -You can also use `Phoenix.LiveView.Helpers.live_title_tag/2` to support +You can also use the `Phoenix.Component.live_title/1` component to support adding automatic prefix and suffix to the page title when rendered and on subsequent updates: - <%= live_title_tag assigns[:page_title] || "Welcome", prefix: "MyApp – " %> +```heex + + <%= assigns[:page_title] || "Welcome" %> + +``` Although the root layout is not updated by LiveView, by simply assigning to `page_title`, LiveView knows you want the title to be updated: diff --git a/guides/server/live-navigation.md b/guides/server/live-navigation.md index 50d9d807f1..ca7fbb272a 100644 --- a/guides/server/live-navigation.md +++ b/guides/server/live-navigation.md @@ -6,20 +6,28 @@ With live navigation, the page is updated without a full page reload. You can trigger live navigation in two ways: - * From the client - this is done by replacing `Phoenix.HTML.Link.link/2` - by `Phoenix.LiveView.Helpers.live_patch/2` or - `Phoenix.LiveView.Helpers.live_redirect/2` + * From the client - this is done by passing either `patch={url}` or `navigate={url}` + to the `Phoenix.Component.link/1` component - * From the server - this is done by replacing `Phoenix.Controller.redirect/2` calls - by `Phoenix.LiveView.push_patch/2` or `Phoenix.LiveView.push_redirect/2`. + * From the server - this is done by by `Phoenix.LiveView.push_patch/2` or `Phoenix.LiveView.push_navigate/2`. -For example, in a template you may write: +For example, instead of writing the following in a template: - <%= live_patch "next", to: Routes.live_path(@socket, MyLive, @page + 1) %> +```heex +<.link href={~p"/pages/#{@page + 1}"}>Next +``` -or in a LiveView: +You would write: - {:noreply, push_patch(socket, to: Routes.live_path(socket, MyLive, page + 1))} +```heex +<.link patch={~p"/pages/#{@page + 1}"}>Next +``` + +Or in a LiveView: + +```elixir +{:noreply, push_patch(socket, to: ~p"/pages/#{@page + 1}")} +``` The "patch" operations must be used when you want to navigate to the current LiveView, simply updating the URL and the current parameters, @@ -28,45 +36,34 @@ without mounting a new LiveView. When patch is used, the invoked and the minimal set of changes are sent to the client. See the next section for more information. -The "redirect" operations must be used when you want to dismount the -current LiveView and mount a new one. In those cases, an Ajax request -is made to fetch the necessary information about the new LiveView, -which is mounted in place of the current one within the current layout. -While redirecting, a `phx-disconnected` class is added to the LiveView, -which can be used to indicate to the user a new page is being loaded. - -At the end of the day, regardless if you invoke [`link/2`](`Phoenix.HTML.Link.link/2`), -[`live_patch/2`](`Phoenix.LiveView.Helpers.live_patch/2`), -and [`live_redirect/2`](`Phoenix.LiveView.Helpers.live_redirect/2`) from the client, -or [`redirect/2`](`Phoenix.Controller.redirect/2`), -[`push_patch/2`](`Phoenix.LiveView.push_patch/2`), -and [`push_redirect/2`](`Phoenix.LiveView.push_redirect/2`) from the server, -the user will end-up on the same page. The difference between those is mostly -the amount of data sent over the wire: - - * [`link/2`](`Phoenix.HTML.Link.link/2`) and - [`redirect/2`](`Phoenix.Controller.redirect/2`) do full page reloads - - * [`live_redirect/2`](`Phoenix.LiveView.Helpers.live_redirect/2`) and - [`push_redirect/2`](`Phoenix.LiveView.push_redirect/2`) mounts a new LiveView while - keeping the current layout - - * [`live_patch/2`](`Phoenix.LiveView.Helpers.live_patch/2`) and - [`push_patch/2`](`Phoenix.LiveView.push_patch/2`) updates the current LiveView - and sends only the minimal diff while also maintaining the scroll position - -An easy rule of thumb is to stick with -[`live_redirect/2`](`Phoenix.LiveView.Helpers.live_redirect/2`) and -[`push_redirect/2`](`Phoenix.LiveView.push_redirect/2`) and use the patch -helpers only in the cases where you want to minimize the -amount of data sent when navigating within the same LiveView (for example, -if you want to change the sorting of a table while also updating the URL). +The "navigate" operations must be used when you want to dismount the +current LiveView and mount a new one. You can only "navigate" between +LiveViews in the same session. While redirecting, a `phx-loading` class +is added to the LiveView, which can be used to indicate to the user a +new page is being loaded. + +If you attempt to patch to another LiveView or navigate across live sessions, +a full page reload is triggered. This means your application continues to work, +in case your application structure changes and that's not reflected in the navigation. + +Here is a quick breakdown: + + * `<.link href={...}>` and [`redirect/2`](`Phoenix.Controller.redirect/2`) + are HTTP-based, work everywhere, and perform full page reloads + + * `<.link navigate={...}>` and [`push_navigate/2`](`Phoenix.LiveView.push_navigate/2`) + work across LiveViews in the same session. They mount a new LiveView + while keeping the current layout + + * `<.link patch={...}>` and [`push_patch/2`](`Phoenix.LiveView.push_patch/2`) + updates the current LiveView and sends only the minimal diff while also + maintaining the scroll position ## `handle_params/3` The [`handle_params/3`](`c:Phoenix.LiveView.handle_params/3`) callback is invoked after [`mount/3`](`c:Phoenix.LiveView.mount/3`) and before the initial render. -It is also invoked every time [`live_patch/2`](`Phoenix.LiveView.Helpers.live_patch/2`) +It is also invoked every time `<.link patch={...}>` or [`push_patch/2`](`Phoenix.LiveView.push_patch/2`) are used. It receives the request parameters as first argument, the url as second, and the socket as third. @@ -78,7 +75,9 @@ the system and you define it in the router as: Now to add live sorting, you could do: - <%= live_patch "Sort by name", to: Routes.live_path(@socket, UserTable, %{sort_by: "name"}) %> +```heex +<.link patch={path(~p"/users", sort_by: "name")}>Sort by name +``` When clicked, since we are navigating to the current LiveView, [`handle_params/3`](`c:Phoenix.LiveView.handle_params/3`) will be invoked. @@ -88,15 +87,18 @@ validate the user input and change the state accordingly: def handle_params(params, _uri, socket) do socket = case params["sort_by"] do - sort_by when sort_by in ~w(name company) -> assign(socket, sort_by: sort) + sort_by when sort_by in ~w(name company) -> assign(socket, sort_by: sort_by) _ -> socket end {:noreply, load_users(socket)} end -As with other `handle_*` callbacks, changes to the state inside -[`handle_params/3`](`c:Phoenix.LiveView.handle_params/3`) will trigger a server render. +Note we returned `{:noreply, socket}`, where `:noreply` means no +additional information is sent to the client. As with other `handle_*` +callbacks, changes to the state inside +[`handle_params/3`](`c:Phoenix.LiveView.handle_params/3`) will trigger +a new server render. Note the parameters given to [`handle_params/3`](`c:Phoenix.LiveView.handle_params/3`) are the same as the ones given to [`mount/3`](`c:Phoenix.LiveView.mount/3`). @@ -104,55 +106,27 @@ So how do you decide which callback to use to load data? Generally speaking, data should always be loaded on [`mount/3`](`c:Phoenix.LiveView.mount/3`), since [`mount/3`](`c:Phoenix.LiveView.mount/3`) is invoked once per LiveView life-cycle. Only the params you expect to be changed via -[`live_patch/2`](`Phoenix.LiveView.Helpers.live_patch/2`) or +`<.link patch={...}>` or [`push_patch/2`](`Phoenix.LiveView.push_patch/2`) must be loaded on [`handle_params/3`](`c:Phoenix.LiveView.handle_params/3`). For example, imagine you have a blog. The URL for a single post is: "/blog/posts/:post_id". In the post page, you have comments and they are paginated. -You use [`live_patch/2`](`Phoenix.LiveView.Helpers.live_patch/2`) to update the shown +You use `<.link patch={...}>` to update the shown comments every time the user paginates, updating the URL to "/blog/posts/:post_id?page=X". In this example, you will access `"post_id"` on [`mount/3`](`c:Phoenix.LiveView.mount/3`) and the page of comments on [`handle_params/3`](`c:Phoenix.LiveView.handle_params/3`). -Furthermore, it is very important to not access the same parameters on both -[`mount/3`](`c:Phoenix.LiveView.mount/3`) and -[`handle_params/3`](`c:Phoenix.LiveView.handle_params/3`). -For example, do NOT do this: - - def mount(%{"post_id" => post_id}, session, socket) do - # do something with post_id - end - - def handle_params(%{"post_id" => post_id, "page" => page}, url, socket) do - # do something with post_id and page - end - -If you do that, because [`mount/3`](`c:Phoenix.LiveView.mount/3`) is called once and -[`handle_params/3`](`c:Phoenix.LiveView.handle_params/3`) multiple times, the "post_id" -read on mount can get out of sync with the one in -[`handle_params/3`](`c:Phoenix.LiveView.handle_params/3`). -So once a parameter is read on mount, it should not be read elsewhere. Instead, do this: - - def mount(%{"post_id" => post_id}, session, socket) do - # do something with post_id - end - - def handle_params(%{"sort_by" => sort_by}, url, socket) do - post_id = socket.assigns.post.id - # do something with sort_by - end - ## Replace page address LiveView also allows the current browser URL to be replaced. This is useful when you want certain events to change the URL but without polluting the browser's history. -This can be done by passing the `replace: true` option to any of the navigation helpers. +This can be done by passing the `<.link replace>` option to any of the navigation helpers. ## Multiple LiveViews in the same page LiveView allows you to have multiple LiveViews in the same page by calling -`Phoenix.LiveView.Helpers.live_render/3` in your templates. However, only +`Phoenix.Component.live_render/3` in your templates. However, only the LiveViews defined directly in your router can use the "Live Navigation" functionality described here. This is important because LiveViews work closely with your router, guaranteeing you can only navigate to known diff --git a/guides/server/security-model.md b/guides/server/security-model.md index e994bc03a2..b5ecb12f3f 100644 --- a/guides/server/security-model.md +++ b/guides/server/security-model.md @@ -2,7 +2,7 @@ LiveView begins its life-cycle as a regular HTTP request. Then a stateful connection is established. Both the HTTP request and the stateful connection -receives the client data via parameters and session. +receive the client data via parameters and session. This means that any session validation must happen both in the HTTP request (plug pipeline) and the stateful connection (LiveView mount). @@ -15,7 +15,7 @@ a user. Authorization is about telling if a user has access to a certain resource or feature in the system. In a regular web application, once a user is authenticated, for example by -entering his email and password, or by using a third-party service such as +entering their email and password, or by using a third-party service such as Google, Twitter, or Facebook, a token identifying the user is stored in the session, which is a cookie (a key-value pair) stored in the user's browser. @@ -37,8 +37,12 @@ sessions on the `mount` callback. Authorization rules generally happen on ## Mounting considerations -If you perform user authentication and confirmation on every HTTP request -via Plugs, such as this: +The [`mount/3`](`c:Phoenix.LiveView.mount/3`) callback is invoked both on +the initial HTTP mount and when LiveView is connected. Therefore, any +authentication performed during mount will cover all scenarios. + +If you perform user authentication and confirmation exclusively on HTTP +requests via Plugs, such as this: plug :ensure_user_authenticated plug :ensure_user_confirmed @@ -46,7 +50,7 @@ via Plugs, such as this: Then the [`mount/3`](`c:Phoenix.LiveView.mount/3`) callback of your LiveView should execute those same verifications: - def mount(params, %{"user_id" => user_id} = _session, socket) do + def mount(_params, %{"user_id" => user_id} = _session, socket) do socket = assign(socket, current_user: Accounts.get_user!(user_id)) socket = @@ -59,17 +63,20 @@ should execute those same verifications: {:ok, socket} end -LiveView v0.16 includes the `on_mount` (`Phoenix.LiveView.on_mount/1`) hook, +LiveView v0.17 includes the `on_mount` (`Phoenix.LiveView.on_mount/1`) hook, which allows you to encapsulate this logic and execute it on every mount, as you would with plug: defmodule MyAppWeb.UserLiveAuth do + import Phoenix.Component import Phoenix.LiveView + alias MyAppWeb.Accounts # from `mix phx.gen.auth` - def mount(params, %{"user_id" => user_id} = _session, socket) do - socket = assign_new(socket, :current_user, fn -> - Accounts.get_user!(user_id) - end) + def on_mount(:default, _params, %{"user_token" => user_token} = _session, socket) do + socket = + assign_new(socket, :current_user, fn -> + Accounts.get_user_by_session_token(user_token) + end) if socket.assigns.current_user.confirmed_at do {:cont, socket} @@ -79,18 +86,31 @@ as you would with plug: end end -and then use it on all relevant LiveViews: +We use [`assign_new/3`](`Phoenix.Component.assign_new/3`). This is a +convenience to avoid fetching the `current_user` multiple times across +LiveViews. + +Now we can use the hook whenever relevant: defmodule MyAppWeb.PageLive do - use Phoenix.LiveView + use MyAppWeb, :live_view on_mount MyAppWeb.UserLiveAuth ... end -Note in the snippet above we used [`assign_new/3`](`Phoenix.LiveView.assign_new/3`). -This is a convenience to avoid fetching the `current_user` multiple times across -LiveViews. +If you prefer, you can add the hook to `def live_view` under `MyAppWeb`, +to run it on all LiveViews by default: + + def live_view do + quote do + use Phoenix.LiveView, + layout: {MyAppWeb.LayoutView, :live} + + on_mount MyAppWeb.UserLiveAuth + unquote(html_helpers()) + end + end ## Events considerations @@ -108,7 +128,7 @@ described, one might implement this: on_mount MyAppWeb.UserLiveAuth - def mount(_params, session, socket) do + def mount(_params, _session, socket) do {:ok, load_projects(socket)} end @@ -255,7 +275,7 @@ a live session, then the `pipe_through` checks are not necessary. Declaring the `on_mount` on `live_session` is exactly the same as declaring it in each LiveView inside the `live_session`. It will be executed every time a LiveView is mounted, even after `live_redirect`s. -The important to keep in mind is: +The important concepts to keep in mind are: * If you have both LiveViews and regular web requests, then you must always authorize and authenticate your LiveViews (using diff --git a/guides/server/telemetry.md b/guides/server/telemetry.md index d5abb9d12a..73679aed3e 100644 --- a/guides/server/telemetry.md +++ b/guides/server/telemetry.md @@ -14,10 +14,10 @@ LiveView currently exposes the following [`telemetry`](https://hexdocs.pm/teleme %{ socket: Phoenix.LiveView.Socket.t, params: unsigned_params | :not_mounted_at_router, - session: map + session: map, + uri: String.t() | nil } - * `[:phoenix, :live_view, :mount, :stop]` - Dispatched by a `Phoenix.LiveView` when the [`mount/3`](`c:Phoenix.LiveView.mount/3`) callback completes successfully. @@ -30,10 +30,10 @@ LiveView currently exposes the following [`telemetry`](https://hexdocs.pm/teleme %{ socket: Phoenix.LiveView.Socket.t, params: unsigned_params | :not_mounted_at_router, - session: map + session: map, + uri: String.t() | nil } - * `[:phoenix, :live_view, :mount, :exception]` - Dispatched by a `Phoenix.LiveView` when an exception is raised in the [`mount/3`](`c:Phoenix.LiveView.mount/3`) callback. @@ -46,7 +46,8 @@ LiveView currently exposes the following [`telemetry`](https://hexdocs.pm/teleme kind: atom, reason: term, params: unsigned_params | :not_mounted_at_router, - session: map + session: map, + uri: String.t() | nil } * `[:phoenix, :live_view, :handle_params, :start]` - Dispatched by a `Phoenix.LiveView` @@ -64,7 +65,6 @@ LiveView currently exposes the following [`telemetry`](https://hexdocs.pm/teleme uri: String.t() } - * `[:phoenix, :live_view, :handle_params, :stop]` - Dispatched by a `Phoenix.LiveView` when the [`handle_params/3`](`c:Phoenix.LiveView.handle_params/3`) callback completes successfully. @@ -81,7 +81,7 @@ LiveView currently exposes the following [`telemetry`](https://hexdocs.pm/teleme } * `[:phoenix, :live_view, :handle_params, :exception]` - Dispatched by a `Phoenix.LiveView` - when the when an exception is raised in the [`handle_params/3`](`c:Phoenix.LiveView.handle_params/3`) callback. + when an exception is raised in the [`handle_params/3`](`c:Phoenix.LiveView.handle_params/3`) callback. * Measurement: @@ -112,7 +112,6 @@ LiveView currently exposes the following [`telemetry`](https://hexdocs.pm/teleme params: unsigned_params } - * `[:phoenix, :live_view, :handle_event, :stop]` - Dispatched by a `Phoenix.LiveView` when the [`handle_event/3`](`c:Phoenix.LiveView.handle_event/3`) callback completes successfully. @@ -144,7 +143,7 @@ LiveView currently exposes the following [`telemetry`](https://hexdocs.pm/teleme event: String.t(), params: unsigned_params } - + * `[:phoenix, :live_component, :handle_event, :start]` - Dispatched by a `Phoenix.LiveComponent` immediately before [`handle_event/3`](`c:Phoenix.LiveComponent.handle_event/3`) is invoked. @@ -161,7 +160,6 @@ LiveView currently exposes the following [`telemetry`](https://hexdocs.pm/teleme params: unsigned_params } - * `[:phoenix, :live_component, :handle_event, :stop]` - Dispatched by a `Phoenix.LiveComponent` when the [`handle_event/3`](`c:Phoenix.LiveComponent.handle_event/3`) callback completes successfully. diff --git a/guides/server/uploads.md b/guides/server/uploads.md index 4b416a0b88..8d416c8fbc 100644 --- a/guides/server/uploads.md +++ b/guides/server/uploads.md @@ -4,7 +4,7 @@ LiveView supports interactive file uploads with progress for both direct to server uploads as well as direct-to-cloud [external uploads](uploads-external.html) on the client. -### Built-in Features +## Built-in Features * Accept specification - Define accepted file types, max number of entries, max file size, etc. When the client @@ -17,7 +17,7 @@ both direct to server uploads as well as direct-to-cloud respond to progress, errors, cancellation, etc. * Drag and drop - Use the `phx-drop-target` attribute to - enable. See `Phoenix.LiveView.Helpers.live_file_input/2`. + enable. See `Phoenix.Component.live_file_input/1`. ## Allow uploads @@ -40,21 +40,21 @@ template. ## Render reactive elements -Use the `Phoenix.LiveView.Helpers.live_file_input/2` file -input generator to render a file input for the upload: +Use the `Phoenix.Component.live_file_input/1` component +to render a file input for the upload: -```elixir -# lib/my_app_web/live/upload_live.html.heex +```heex +<%!-- lib/my_app_web/live/upload_live.html.heex --%>
- <%= live_file_input @uploads.avatar %> + <.live_file_input upload={@uploads.avatar} />
``` > **Important:** You must bind `phx-submit` and `phx-change` on the form. -Note that while [`live_file_input/2`] +Note that while [`live_file_input/1`] allows you to set additional attributes on the file input, many attributes such as `id`, `accept`, and `multiple` will be set automatically based on the [`allow_upload/3`] spec. @@ -73,29 +73,28 @@ info, errors, etc. Let's look at an annotated example: -```elixir -# lib/my_app_web/live/upload_live.html.heex +```heex +<%!-- lib/my_app_web/live/upload_live.html.heex --%> -<%# use phx-drop-target with the upload ref to enable file drag and drop %> +<%!-- use phx-drop-target with the upload ref to enable file drag and drop --%>
-<%# render each avatar entry %> +<%!-- render each avatar entry --%> <%= for entry <- @uploads.avatar.entries do %>
- <%# Phoenix.LiveView.Helpers.live_img_preview/2 renders a client-side preview %> - <%= live_img_preview entry %> + <.live_img_preview entry={entry} />
<%= entry.client_name %>
- <%# entry.progress will update automatically for in-flight entries %> + <%!-- entry.progress will update automatically for in-flight entries --%> <%= entry.progress %>% - <%# a regular click event whose handler will invoke Phoenix.LiveView.cancel_upload/3 %> - + <%!-- a regular click event whose handler will invoke Phoenix.LiveView.cancel_upload/3 --%> + - <%# Phoenix.LiveView.Helpers.upload_errors/2 returns a list of error atoms %> + <%!-- Phoenix.Component.upload_errors/2 returns a list of error atoms --%> <%= for err <- upload_errors(@uploads.avatar, entry) do %>

<%= error_to_string(err) %>

<% end %> @@ -103,6 +102,11 @@ Let's look at an annotated example:
<% end %> +<%!-- Phoenix.Component.upload_errors/1 returns a list of error atoms --%> +<%= for err <- upload_errors(@uploads.avatar) do %> +

<%= error_to_string(err) %>

+<% end %> +
``` @@ -132,15 +136,22 @@ end Entries for files that do not match the [`allow_upload/3`] spec will contain errors. Use -`Phoenix.LiveView.Helpers.upload_errors/2` and your own +`Phoenix.Component.upload_errors/2` and your own helper function to render a friendly error message: ```elixir def error_to_string(:too_large), do: "Too large" -def error_to_string(:too_many_files), do: "You have selected too many files" def error_to_string(:not_accepted), do: "You have selected an unacceptable file type" ``` +For error messages that affect all entries, use +`Phoenix.Component.upload_errors/1`, and your own +helper function to render a friendly error message: + +```elixir +def error_to_string(:too_many_files), do: "You have selected too many files" +``` + ### Cancel an entry Upload entries may also be canceled, either programmatically @@ -156,7 +167,7 @@ end ## Consume uploaded entries -When the end-user submits a form containing a [`live_file_input/2`], +When the end-user submits a form containing a [`live_file_input/1`], the JavaScript client first uploads the file(s) before invoking the callback for the form's `phx-submit` event. @@ -171,8 +182,10 @@ def handle_event("save", _params, socket) do uploaded_files = consume_uploaded_entries(socket, :avatar, fn %{path: path}, _entry -> dest = Path.join([:code.priv_dir(:my_app), "static", "uploads", Path.basename(path)]) + # The `static/uploads` directory must exist for `File.cp!/2` + # and MyAppWeb.static_paths/0 should contain uploads to work,. File.cp!(path, dest) - Routes.static_path(socket, "/uploads/#{Path.basename(dest)}") + {:ok, ~p"/uploads/#{Path.basename(dest)}"} end) {:noreply, update(socket, :uploaded_files, &(&1 ++ uploaded_files))} @@ -219,7 +232,7 @@ defmodule MyAppWeb.UploadLive do consume_uploaded_entries(socket, :avatar, fn %{path: path}, _entry -> dest = Path.join([:code.priv_dir(:my_app), "static", "uploads", Path.basename(path)]) File.cp!(path, dest) - Routes.static_path(socket, "/uploads/#{Path.basename(dest)}") + {:ok, ~p"/uploads/#{Path.basename(dest)}"} end) {:noreply, update(socket, :uploaded_files, &(&1 ++ uploaded_files))} @@ -232,4 +245,4 @@ end ``` [`allow_upload/3`]: `Phoenix.LiveView.allow_upload/3` -[`live_file_input/2`]: `Phoenix.LiveView.Helpers.live_file_input/2` +[`live_file_input/1`]: `Phoenix.Component.live_file_input/1` diff --git a/guides/server/using-gettext.md b/guides/server/using-gettext.md index b5902e67a4..5adfa2e71e 100644 --- a/guides/server/using-gettext.md +++ b/guides/server/using-gettext.md @@ -1,22 +1,115 @@ # Using Gettext for internationalization For internationalization with [gettext](https://hexdocs.pm/gettext/Gettext.html), -the locale used within your Plug pipeline can be stored in the Plug session and -restored within your LiveView mount. For example, after user signs in or preference -changes, you can write the locale to the session: +you must call `Gettext.put_locale/2` on the LiveView mount callback to instruct +the LiveView which locale should be used for rendering the page. - def put_user_session(conn, current_user) do - locale = get_locale_for_user(current_user) +However, one question that has to be answered is how to retrieve the locale in +the first place. There are many approaches to solve this problem: + +1. The locale could be stored in the URL as a parameter +2. The locale could be stored in the session +3. The locale could be stored in the database + +We will briefly cover these approaches to provide some direction. + +## Locale from parameters + +You can say all URLs have a locale parameter. In your router: + + scope "/:locale" do + live ... + get ... + end + +Accessing a page without a locale should automatically redirect +to a URL with locale (the best locale could be fetched from +HTTP headers, which is outside of the scope of this guide). + +Then, assuming all URLs have a locale, you can set the Gettext +locale accordingly: + + def mount(%{"locale" => locale}, _session, socket) do Gettext.put_locale(MyApp.Gettext, locale) + {:ok, socket} + end + + +You can also use the [`on_mount`](`Phoenix.LiveView.on_mount/1`) hook to +automatically restore the locale for every LiveView in your application: + + defmodule MyAppWeb.RestoreLocale do + def on_mount(:default, %{"locale" => locale}, _session, socket) do + Gettext.put_locale(MyApp.Gettext, locale) + {:cont, socket} + end + + # catch-all case + def on_mount(:default, _params, _session, socket), do: {:cont, socket} + end + +Then, add this hook to `def live_view` under `MyAppWeb`, to run it on all +LiveViews by default: + + def live_view do + quote do + use Phoenix.LiveView, + layout: {MyAppWeb.LayoutView, :live} + + on_mount MyAppWeb.RestoreLocale + unquote(view_helpers()) + end + end + +Note that, because the Gettext locale is not stored in the assigns, if you +want to change the locale, you must use `<.link navigate={...} />`, instead +of simply patching the page. + +## Locale from session + +You may also store the locale in the Plug session. For example, in a controller +you might do: + + def put_user_session(conn, current_user) do + Gettext.put_locale(MyApp.Gettext, current_user.locale) conn |> put_session(:user_id, current_user.id) - |> put_session(:locale, locale) + |> put_session(:locale, current_user.locale) end -Then in your LiveView `mount/3`, you can restore the locale: +and then restore the locale from session within your LiveView mount: def mount(_params, %{"locale" => locale}, socket) do Gettext.put_locale(MyApp.Gettext, locale) {:ok, socket} end + +You can also encapsulate this in a hook, as done in the previous section. + +However, if the locale is stored in the session, you can only change it +by using regular controller requests. Therefore you should always use +`<.link to={...} />` to point to a controller that change the session +accordingly, reloading any LiveView. + +## Locale from database + +You may also allow users to store their locale configuration in the database. +Then, on `mount/3`, you can retrieve the user id from the session and load +the locale: + + def mount(_params, %{"user_id" => user_id}, socket) do + user = Users.get_user!(user_id) + Gettext.put_locale(MyApp.Gettext, user.locale) + {:ok, socket} + end + +In practice, you may end-up mixing more than one approach listed here. +For example, reading from the database is great once the user is logged in +but, before that happens, you may need to store the locale in the session +or in the URL. + +Similarly, you can keep the locale in the URL, but change the URL accordingly +to the user preferred locale once they sign in. Hopefully this guide gives +some suggestions on how to move forward and explore the best approach for your +application. diff --git a/lib/phoenix_component.ex b/lib/phoenix_component.ex index eb9b7efeff..bd7a3101e3 100644 --- a/lib/phoenix_component.ex +++ b/lib/phoenix_component.ex @@ -1,193 +1,363 @@ defmodule Phoenix.Component do @moduledoc ~S''' - API for function components. + Define reusable function components with HEEx templates. - A function component is any function that receives - an assigns map as argument and returns a rendered - struct built with [the `~H` sigil](`Phoenix.LiveView.Helpers.sigil_H/2`). - - Here is an example: + A function component is any function that receives an assigns + map as an argument and returns a rendered struct built with + [the `~H` sigil](`sigil_H/2`): defmodule MyComponent do use Phoenix.Component - # Optionally also bring the HTML helpers - # use Phoenix.HTML - def greet(assigns) do ~H""" -

Hello, <%= assigns.name %>

+

Hello, <%= @name %>!

""" end end - The component can be invoked as a regular function: + When invoked within a `~H` sigil or HEEx template file: - MyComponent.greet(%{name: "Jane"}) + ```heex + + ``` - But it is typically invoked using the function component - syntax from the `~H` sigil: - - ~H""" - - """ + The following HTML is rendered: - If the `MyComponent` module is imported or if the function - is defined locally, you can skip the module name: + ```html +

Hello, Jane!

+ ``` - ~H""" - <.greet name="Jane" /> - """ + If the function component is defined locally, or its module is imported, + then the caller can invoke the function directly without specifying the module: - Similar to any HTML tag inside the `~H` sigil, you can - interpolate attributes values too: + ```heex + <.greet name="Jane" /> + ``` - ~H""" - <.greet name={@user.name} /> - """ + For dynamic values, you can interpolate Elixir expressions into a function component: - You can learn more about the `~H` sigil [in its documentation](`Phoenix.LiveView.Helpers.sigil_H/2`). + ```heex + <.greet name={@user.name} /> + ``` - ## `use Phoenix.Component` + Function components can also accept blocks of HEEx content (more on this later): - Modules that define function components should call - `use Phoenix.Component` at the top. Doing so will import - the functions from both `Phoenix.LiveView` and - `Phoenix.LiveView.Helpers` modules. `Phoenix.LiveView` - and `Phoenix.LiveComponent` automatically invoke - `use Phoenix.Component` for you. + ```heex + <.card> +

This is the body of my card!

+ + ``` - You must avoid defining a module for each component. Instead, - we should use modules to group side-by-side related function - components. + Note how the `name` attribute automatically becomes the `@name` assign inside + function components. This can be further leveraged by using two higher-level + abstractions for us: attributes and slots. - ## Assigns + ## Attributes - While inside a function component, you must use `Phoenix.LiveView.assign/3` - and `Phoenix.LiveView.assign_new/3` to manipulate assigns, - so that LiveView can track changes to the assigns values. - For example, let's imagine a component that receives the first - name and last name and must compute the name assign. One option - would be: + `Phoenix.Component` provides the `attr/3` macro to declare what attributes the proceeding function + component expects to receive when invoked: - def show_name(assigns) do - assigns = assign(assigns, :name, assigns.first_name <> assigns.last_name) + attr :name, :string, required: true + def greet(assigns) do ~H""" -

Your name is: <%= @name %>

+

Hello, <%= @name %>!

""" end - However, when possible, it may be cleaner to break the logic over function - calls instead of precomputed assigns: + By calling `attr/3`, it is now clear that `greet/1` requires a string attribute called `name` + present in its assigns map to properly render. Failing to do so will result in a compilation + warning: + + ```heex + + + ``` + + Attributes can provide default values that are automatically merged into the assigns map: + + attr :name, :string, default: "Bob" + + Now you can invoke the function component without providing a value for `name`: + + ```heex + <.greet /> + ``` + + Rendering the following HTML: + + ```html +

Hello, Bob!

+ ``` - def show_name(assigns) do + Accessing an attribute which is required and does not have a default value will fail. + You must explicitly declare `default: nil` or assign a value programmatically with the + `assign_new/3` function. + + Multiple attributes can be declared for the same function component: + + attr :name, :string, required: true + attr :age, :integer, required: true + + def celebrate(assigns) do ~H""" -

Your name is: <%= full_name(@first_name, @last_name) %>

+

+ Happy birthday <%= @name %>! + You are <%= @age %> years old. +

""" end - defp full_name(first_name, last_name), do: first_name <> last_name + Allowing the caller to pass multiple values: - Another example is making an assign optional by providing - a default value: + ```heex + <.celebrate name={"Genevieve"} age={34} /> + ``` - def field_label(assigns) do - assigns = assign_new(assigns, :help, fn -> nil end) + Rendering the following HTML: - ~H""" - + Multiple function components can be defined in the same module, with different attributes. In the + following example, `` requires a `name`, but *does not* require a `title`, and + `` requires a `title`, but *does not* require a `name`. + + defmodule Components do + use Phoenix.Component + + attr :title, :string, required: true + + def heading(assigns) do + ~H""" +

<%= @title %>

+ """ + end + + attr :name, :string, required: true + + def greet(assigns) do + ~H""" +

Hello <%= @name %>

+ """ + end + end + + With the `attr/3` macro you have the core ingredients to create reusable function components. + But what if you need your function components to support dynamic attributes, such as common HTML + attributes to mix into a component's container? + + ## Global attributes + + Global attributes are a set of attributes that a function component can accept when it + declares an attribute of type `:global`. By default, the set of attributes accepted are those + attributes common to all standard HTML tags. + See [Global attributes](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes) + for a complete list of attributes. + + Once a global attribute is declared, any number of attributes in the set can be passed by + the caller without having to modify the function component itself. + + Below is an example of a function component that accepts a dynamic number of global attributes: + + attr :message, :string, required: true + attr :rest, :global + + def notification(assigns) do + ~H""" + <%= @message %> """ end - ## Slots + The caller can pass multiple global attributes (such as `phx-*` bindings or the `class` attribute): + + ```heex + <.notification message="You've got mail!" class="bg-green-200" phx-click="close" /> + ``` + + Rendering the following HTML: + + ```html + You've got mail! + ``` + + Note that the function component did not have to explicitly declare a `class` or `phx-click` + attribute in order to render. + + Global attributes can define defaults which are merged with attributes provided by the caller. + For example, you may declare a default `class` if the caller does not provide one: + + attr :rest, :global, default: %{class: "bg-blue-200"} + + Now you can call the function component without a `class` attribute: + + ```heex + <.notification message="You've got mail!" phx-click="close" /> + ``` + + Rendering the following HTML: + + ```html + You've got mail! + ``` + + Note that the global attribute cannot be provided directly and doing so will emit + a warning. In other words, this is invalid: + + ```heex + <.notification message="You've got mail!" rest={%{"phx-click" => "close"}} /> + ``` + + ### Included globals + + You may also specify which attributes are included in addition to the known globals + with the `:include` option. For example to support the `form` attribute on a button + component: + + ```elixir + # <.button form="my-form"/> + attr :rest, :global, include: ~w(form) + slot :inner_block + def button(assigns) do + ~H""" + + """ + end + ``` + + The `:include` option is useful to apply global additions on a case-by-case basis, + but sometimes you want to extend existing components with new global attributes, + such as Alpine.js' `x-` prefixes, which we'll outline next. + + ### Custom global attribute prefixes + + You can extend the set of global attributes by providing a list of attribute prefixes to + `use Phoenix.Component`. Like the default attributes common to all HTML elements, + any number of attributes that start with a global prefix will be accepted by function + components invoked by the current module. By default, the following prefixes are supported: + `phx-`, `aria-`, and `data-`. For example, to support the `x-` prefix used by + [Alpine.js](https://alpinejs.dev/), you can pass the `:global_prefixes` option to + `use Phoenix.Component`: + + use Phoenix.Component, global_prefixes: ~w(x-) - Slots is a mechanism to give HTML blocks to function components - as in regular HTML tags. + Now all function components invoked by this module will accept any number of attributes + prefixed with `x-`, in addition to the default global prefixes. - ### Default slots + You can learn more about attributes by reading the documentation for `attr/3`. - Any content you pass inside a component is assigned to a default slot - called `@inner_block`. For example, imagine you want to create a button - component like this: + ## Slots - <.button> - This renders inside the button! - + In addition to attributes, function components can accept blocks of HEEx content, referred to + as slots. Slots enable further customization of the rendered HTML, as the caller can pass the + function component HEEx content they want the component to render. `Phoenix.Component` provides + the `slot/3` macro used to declare slots for function components: - It is quite simple to do so. Simply define your component and call - `render_slot(@inner_block)` where you want to inject the content: + slot :inner_block, required: true def button(assigns) do ~H""" - """ end - In a nutshell, the contents given to the component is assigned to - the `@inner_block` assign and then we use `Phoenix.LiveView.Helpers.render_slot/2` - to render it. + The expression `render_slot(@inner_block)` renders the HEEx content. You can invoke this function + component like so: + + ```heex + <.button> + This renders inside the button! + + ``` + + Which renders the following HTML: + + ```html + + ``` + + Like the `attr/3` macro, using the `slot/3` macro will provide compile-time validations. + For example, invoking `button/1` without a slot of HEEx content will result in a compilation + warning being emitted: + + ```heex + <.button /> + + ``` + + ### The default slot + + The example above uses the default slot, accessible as an assign named `@inner_block`, to render + HEEx content via the `render_slot/2` function. - You can even have the component give a value back to the caller, - by using `let`. Imagine this component: + If the values rendered in the slot need to be dynamic, you can pass a second value back to the + HEEx content by calling `render_slot/2`: + + slot :inner_block, required: true + + attr :entries, :list, default: [] def unordered_list(assigns) do ~H"""
    <%= for entry <- @entries do %> -
  • <%= render_block(@inner_block, entry) %>
  • +
  • <%= render_slot(@inner_block, entry) %>
  • <% end %>
""" end - And now you can invoke it as: + When invoking the function component, you can use the special attribute `:let` to take the value + that the function component passes back and bind it to a variable: - <.unordered_list let={entry} entries={~w(apple banana cherry)}> - I like <%= entry %> - + ```heex + <.unordered_list :let={fruit} entries={~w(apples bananas cherries)}> + I like <%= fruit %>! + + ``` - ### Named slots + Rendering the following HTML: - Besides `@inner_block`, it is also possible to pass named slots - to the component. For example, imagine that you want to create - a modal component. The modal component has a header, a footer, - and the body of the modal, which we would use like this: + ```html +
    +
  • I like apples!
  • +
  • I like bananas!
  • +
  • I like cherries!
  • +
+ ``` - <.modal> - <:header> - This is the top of the modal. - + Now the separation of concerns is maintained: the caller can specify multiple values in a list + attribute without having to specify the HEEx content that surrounds and separates them. - This is the body - everything not in a - named slot goes to @inner_block. + ### Named slots - <:footer> - - - + In addition to the default slot, function components can accept multiple, named slots of HEEx + content. For example, imagine you want to create a modal that has a header, body, and footer: - The component itself could be implemented like this: + slot :header + slot :inner_block, required: true + slot :footer, required: true def modal(assigns) do ~H"""