├── .formatter.exs ├── .github └── workflows │ └── elixir.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── config └── config.exs ├── lib ├── phx_component_helpers.ex ├── phx_component_helpers │ ├── css.ex │ ├── forms.ex │ ├── forward.ex │ └── set_attributes.ex └── phx_view_helpers.ex ├── mix.exs ├── mix.lock └── test ├── phx_component_helpers_test.exs ├── phx_view_helpers_test.exs └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.github/workflows/elixir.yml: -------------------------------------------------------------------------------- 1 | name: Elixir CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | build: 11 | name: Build and test 12 | runs-on: ubuntu-22.04 13 | 14 | env: 15 | MIX_ENV: test 16 | 17 | steps: 18 | - uses: actions/checkout@v2 19 | 20 | - name: Set up Elixir 21 | uses: erlef/setup-beam@v1 22 | with: 23 | otp-version: "27.0.1" 24 | elixir-version: "1.17.2-otp-27" 25 | 26 | - name: Restore dependencies cache 27 | uses: actions/cache@v2 28 | with: 29 | path: | 30 | deps 31 | _build 32 | key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} 33 | restore-keys: ${{ runner.os }}-mix- 34 | 35 | - name: Install dependencies 36 | run: mix deps.get 37 | 38 | - name: Compilation 39 | run: mix compile --warnings-as-errors 40 | 41 | - name: Check formatting 42 | run: mix format --check-formatted 43 | 44 | - name: Credo 45 | run: mix credo 46 | 47 | - name: Run tests 48 | run: mix coveralls.json 49 | 50 | - name: Download codecov uploader 51 | run: curl -Os https://uploader.codecov.io/latest/linux/codecov && chmod +x codecov 52 | 53 | - name: Upload test coverage 54 | run: ./codecov 55 | 56 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | phx_component_helpers-*.tar 24 | 25 | 26 | # Temporary files for e.g. tests 27 | /tmp 28 | 29 | .elixir_ls 30 | .tool-versions -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 1.4.1 2 | 3 | - fixed `String.slice` warning 4 | - fixed `PhxViewHelpers` documentation 5 | 6 | # 1.4.0 7 | 8 | - `extend_class/3` can now take default classes as list (contribution by @dsrees 🙏) 9 | 10 | # 1.3.0 11 | 12 | - CSS prefix based replacement, deprecated in 1.1.0, has been removed. 13 | - added a new `:from` option on `set_attributes`, `set_prefixed_attributes` and `set_phx_attributes` 14 | meant to work with new `:global` component attributes. 15 | 16 | # 1.2.0 17 | 18 | - switched to LiveView 0.18.0. 19 | 20 | # 1.1.0 21 | 22 | - `extend_class/3` behavior has been updated and will soon no longer replace default css 23 | classes based on their prefix (this behavior is still working but deprecated). To switch to 24 | the new behavior and suppress warning messages, pass the `prefix_replace: false` option and 25 | use the new `!` based syntax to explicitly remove default CSS classes. (ex: `!border-* border-red-500`) 26 | 27 | # 1.0.3 28 | 29 | - another fix on `validate_required_attributes` not handling well `false` values 30 | 31 | # 1.0.2 32 | 33 | - @seb3s fixed a bug, that make `validate_required_attributes` not raising with some 34 | undefined attributes. 35 | 36 | # 1.0.1 37 | 38 | - fixed a nasty bug where `extend_class/3` was not updating assigns `__changed__` key 39 | 40 | # 1.0.0 41 | 42 | 🎉 `phx_component_helpers` is stable for months and complete feature-wise. 43 | It's ready for 1.0.0 :) 🔥 44 | 45 | # 0.14.0 46 | 47 | - removed interpolation of `raw` attributes as HEEX is now the only templating 48 | engine target by `phx_component_helpers` 49 | - removed support of `phx_*` attributes in favor of `phx-*` attributes 50 | 51 | # 0.13.1 52 | 53 | - fixed an issue of duplicate attributes when combining `set_phx_attributes` with `set_attributes` 54 | 55 | # 0.13.0 56 | 57 | - using `Phoenix LiveView.assign/2` instead of `Map.put/3`. Big thanks to @thenrio! 58 | 59 | # 0.12.0 60 | 61 | - now injecting heex_attributes into assigns that can be used from heex templates 62 | - switched examples to `HEEX` templating 63 | - new `PhxComponentHelpers.has_errors?/1` function 64 | - `validate_required_attributes/2` now raises a more comprehensive exception 65 | 66 | # 0.11.0 67 | 68 | - `PhxComponentHelpers.forward_assigns/2` with prefix :icon will now forward all `:icon_*` keys and `:icon` as well 69 | - removed `error_class` option on `PhxComponentHelpers.extend_class/2` which can now be handled 70 | by using a function as the first parameter 71 | - merged [first PR](https://github.com/cblavier/phx_component_helpers/pull/2) (mainly english mistakes ... 🇫🇷) ;-) 72 | 73 | # 0.10.0 74 | 75 | - new `:merge` option on `PhxComponentHelpers.forward_assigns/2` 76 | - `PhxComponentHelpers.extend_class/2` can now take defaults as a function 77 | 78 | # 0.9.0 79 | 80 | - renamed `PhxComponentHelpers.set_component_attributes/3` into `PhxComponentHelpers.set_attributes/3` 81 | - removed `PhxComponentHelpers.set_data_attributes/3` which has been replaced by a `data: true` option passed to `PhxComponentHelpers.set_attributes/3` 82 | - new `PhxComponentHelpers.forward_assigns/2` to pass assigns to child components 83 | 84 | # 0.8.1 85 | 86 | - fixed default attributes behavior 87 | 88 | # 0.8.0 89 | 90 | - `:into` option of `PhxComponentHelpers.extend_class/2` is renamed in `:attribute` 91 | - `PhxComponentHelpers.extend_class/2` will overwrite input assign class with extended class 92 | - `PhxComponentHelpers.set_form_attributes/1` will now set default form attributes when keys 93 | exist but are nil 94 | - `PhxComponentHelpers.set_attributes/3` and PhxComponentHelpers.set_data_attributes/3 95 | can now take default values 96 | 97 | # 0.7.0 98 | 99 | - `PhxComponentHelpers.set_form_attributes/1` will now init form data with nil values 100 | when no form/field is provided 101 | - `PhxComponentHelpers.set_form_attributes/1` retrieves and assigns form errors 102 | - `PhxComponentHelpers.extend_class/2` now supports new `:error_class` option to 103 | extend CSS classes when a form field is faulty 104 | 105 | # 0.6.0 106 | 107 | - added `PhxViewHelpers` than can be used within templates 108 | - added `PhxComponentHelpers.set_form_attributes/1` to fetch `Phoenix.HTML.Form` data 109 | 110 | # 0.5.0 111 | 112 | - all assigns are no longer prefixed by `html_` but by `raw_` 113 | - new `:into` option is 114 | - `set_phx_attributes/2` has a default `:into` option 115 | - `extend_class/2` changes its signature to also use `into` 116 | 117 | # 0.4.0 118 | 119 | - `set_attributes/3` will set absent assigns by default 120 | - removed `:init` option from `set_attributes/3` 121 | - added `validate_required_attributes/2` 122 | 123 | # 0.3.0 124 | 125 | - New `set_prefixed_attributes/3` function that can be used to map alpinejs attributes 126 | 127 | # 0.2.0 128 | 129 | - Fixed issue when `Jason` library is not available 130 | - Removed hardcoded list of `phx_*` attributes 131 | 132 | # 0.1.0 133 | 134 | Initial release :) 135 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Christian Blavier 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PhxComponentHelpers 2 | 3 | [![github](https://github.com/cblavier/phx_component_helpers/actions/workflows/elixir.yml/badge.svg)](https://github.com/cblavier/phx_component_helpers/actions/workflows/elixir.yml) 4 | [![codecov](https://codecov.io/gh/cblavier/phx_component_helpers/branch/main/graph/badge.svg)](https://codecov.io/gh/cblavier/phx_component_helpers) 5 | [![Hex pm](http://img.shields.io/hexpm/v/phx_component_helpers.svg?style=flat)](https://hex.pm/packages/phx_component_helpers) 6 | 7 | 👉 [Demonstration & Code samples](https://phx-component-helpers.onrender.com) 8 | 9 | ## Presentation 10 | 11 | `PhxComponentHelpers` provides helper functions meant to be used within Phoenix LiveView to make your components more configurable and extensible from templates. 12 | 13 | It provides the following features: 14 | 15 | - set HTML, data or phx attributes from component assigns 16 | - set a bunch of attributes at once with any custom prefix such as `@click` or `x-bind:` (for [alpinejs](https://github.com/alpinejs/alpine) users) 17 | - validate mandatory attributes 18 | - set and extend CSS classes from component assigns 19 | - forward a subset of assigns to child components 20 | 21 | ## Motivation 22 | 23 | Writing a library of stateless components is a great way to improve consistency in both your UI and code and also to get a significant productivity boost. 24 | 25 | The best components can be used _as-is_ without any further configuration, but are versatile enough to be customized from templates or higher level components. 26 | 27 | Writing such components is not difficult, but involves a lot of boilerplate code. `PhxComponentHelpers` is here to alleviate the pain. 28 | 29 | ## Example 30 | 31 | A lot of code samples are available [on this site](https://phx-component-helpers.onrender.com), but basically `PhxComponentHelpers` allows you to write components as such: 32 | 33 | ```elixir 34 | defmodule Forms.Button do 35 | use Phoenix.Component 36 | import PhxComponentHelpers 37 | 38 | def button(assigns) do 39 | assigns = 40 | assigns 41 | |> extend_class("bg-blue-700 hover:bg-blue-900 ...") 42 | |> set_attributes([:type, :id, :label], required: [:id]) 43 | |> set_phx_attributes() 44 | 45 | ~H""" 46 | 49 | """ 50 | end 51 | end 52 | ``` 53 | 54 | From templates, it looks like this: 55 | 56 | ```heex 57 | <.form id="form" phx-submit="form_submit" class="divide-none"> 58 | 59 | <.input_group> 60 | <.label for="name" label="Name"/> 61 | <.text_input name="name" value={@my.name}/> 62 | 63 | 64 | <.button_group class="pt-2"> 65 | <.button type="submit" phx-click="btn-click" label="Save"/> 66 | 67 | 68 | 69 | ``` 70 | 71 | ## How does it play with the PETAL stack? 72 | 73 | [PETAL](https://thinkingelixir.com/petal-stack-in-elixir/) stands for Phoenix - Elixir - TailwindCSS - Alpine.js - LiveView. In recent months it has become quite popular in the Elixir ecosystem and `PhxComponentHelpers` is meant to fit in. 74 | 75 | - [TailwindCSS](https://tailwindcss.com) provides a new way to structure CSS, but keeping good HTML hygiene requires to rely on a component-oriented library. 76 | - [Alpine.js](https://github.com/alpinejs/alpine) is the Javascript counterpart of Tailwind. It lets you define dynamic behaviour right from your templates using HTML attributes. 77 | 78 | The point of developing good components is to provide strong defaults in the component so that they can be used _as_-is, but also to let these defaults be overridden right from the templates. 79 | 80 | Here is the definition of a typical Form button, with `Tailwind` & `Alpine`: 81 | 82 | ```elixir 83 | defmodule Forms.Button do 84 | use Phoenix.Component 85 | import PhxComponentHelpers 86 | 87 | @css_class "inline-flex items-center justify-center p-3 w-5 h-5 border \ 88 | border-transparent text-2xl leading-4 font-medium rounded-md \ 89 | text-white bg-primary hover:bg-primary-hover" 90 | 91 | def button(assigns) do 92 | assigns = 93 | assigns 94 | |> extend_class(@css_class) 95 | |> set_phx_attributes() 96 | |> set_prefixed_attributes(["@click", "x-bind:"], 97 | into: :alpine_attributes, 98 | required: ["@click"] 99 | ) 100 | 101 | ~H""" 102 | 105 | """ 106 | end 107 | end 108 | ``` 109 | 110 | Then in your `html.heex` template you can imagine the following code, providing `@click` behaviour and overriding just the few tailwind css classes you need (only `p-*`, `w-*` and `h-*` will be replaced). No `phx` behaviour here, but it's ok, it won't break ;-) 111 | 112 | ```elixir 113 | <.button class="!p-* p-0 !w-* w-7 !h-* h-7" "@click"="$dispatch('closeslideover')"> 114 | <.icon icon={:plus_circle}/> 115 | 116 | ``` 117 | 118 | ## Forms 119 | 120 | This library also provides `Phoenix.HTML.Form` related functions so you can easily write your own `my_form_for` function with your css defaults. 121 | 122 | ```elixir 123 | def my_form_for(options) when is_list(options) do 124 | options 125 | |> extend_form_class("mt-4 space-y-2") 126 | |> Phoenix.LiveView.Helpers.form() 127 | end 128 | ``` 129 | 130 | Then you only need to use `PhxComponentHelpers.set_form_attributes/1` within your own form components in order to fetch names & values from the form. Your template will then look like this: 131 | 132 | ```heex 133 | <.my_form_for let={f} for={@changeset} phx-submit="form_submit" class="divide-none"> 134 | <.input_group> 135 | <.label form={f} field={:name} label="Name"/> 136 | <.text_input form={f} field={:name}/> 137 | 138 | 139 | <.button_group class="pt-2"> 140 | <.button type="submit" label="Save"/> 141 | 142 | 143 | ``` 144 | 145 | ## Compared to Surface 146 | 147 | [Surface](https://github.com/surface-ui/surface) is a library built on top of Phoenix LiveView. Surface is much more ambitious and complex than `PhxComponentHelpers` (which obviously isn't a framework, just helpers ...). 148 | 149 | `Surface` really changes the way you code user interfaces and components (you almost won't be using HTML templates anymore) whereas `PhxComponentHelpers` is just some syntactic sugar to help you use raw `phoenix_live_view`. 150 | 151 | ## Documentation 152 | 153 | Available on [https://hexdocs.pm](https://hexdocs.pm/phx_component_helpers) 154 | 155 | ## Installation 156 | 157 | Add the following to your `mix.exs`. 158 | 159 | ```elixir 160 | def deps do 161 | [ 162 | {:phx_component_helpers, "~> 1.4.0"}, 163 | {:jason, "~> 1.0"} # only required if you want to use json encoding options 164 | ] 165 | end 166 | ``` 167 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | if Mix.env() == :test do 4 | config :phoenix, :json_library, Jason 5 | end 6 | -------------------------------------------------------------------------------- /lib/phx_component_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule PhxComponentHelpers do 2 | @moduledoc """ 3 | `PhxComponentHelpers` are helper functions meant to be used within Phoenix 4 | LiveView live_components to make your components more configurable and extensible 5 | from your templates. 6 | 7 | It provides following features: 8 | 9 | * set HTML or data attributes from component assigns 10 | * set phx-* attributes from component assigns 11 | * set attributes with any custom prefix such as `@click` or `x-bind:` from 12 | [alpinejs](https://github.com/alpinejs/alpine) 13 | * encode attributes as JSON from an Elixir structure assign 14 | * validate mandatory attributes 15 | * set and extend CSS classes from component assigns 16 | """ 17 | 18 | import PhxComponentHelpers.{SetAttributes, CSS, Forms, Forward} 19 | import Phoenix.HTML.Form, only: [input_id: 2, input_name: 2, input_value: 2] 20 | 21 | @doc ~S""" 22 | Extends assigns with heex_* attributes that can be interpolated within your component markup. 23 | 24 | ## Parameters 25 | * `assigns` - your component assigns 26 | * `attributes` - a list of attributes (atoms) that will be fetched from assigns. 27 | Attributes can either be single atoms or tuples in the form `{:atom, default}` to provide 28 | default values. 29 | 30 | ## Options 31 | * `:required` - raises if required attributes are absent from assigns 32 | * `:json` - when true, will JSON encode the assign value 33 | * `:data` - when true, HTML attributes are prefixed with `data-` 34 | * `:from` - instead of fetching from all assigns, fetch from one or several :global attributes 35 | * `:into` - merges all assigns in a single one that can be interpolated at once 36 | 37 | ## Example 38 | ``` 39 | assigns 40 | |> set_attributes( 41 | [:id, :name, label: "default label"], 42 | required: [:id, :name], 43 | into: :attributes 44 | ) 45 | |> set_attributes([:value], json: true) 46 | ``` 47 | 48 | `assigns` now contains : 49 | - `@heex_id`, `@heex_name`, `@heex_label` and `@heex_value`. 50 | - `@heex_attributes` which holds the values of `:id`, `:name` and `:label`. 51 | """ 52 | def set_attributes(assigns, attributes, opts \\ []) do 53 | assigns 54 | |> do_set_attributes(attributes, opts) 55 | |> validate_required_attributes(opts[:required]) 56 | end 57 | 58 | @doc ~S""" 59 | Extends assigns with prefixed attributes that can be interpolated within 60 | your component markup. It will automatically detect any attribute prefixed by 61 | any of the given prefixes from input assigns. 62 | 63 | Can be used for intance to easily map `alpinejs` html attributes. 64 | 65 | ## Parameters 66 | * `assigns` - your component assigns 67 | * `prefixes` - a list of prefix as binaries 68 | 69 | ## Options 70 | * `:init` - a list of attributes that will be initialized if absent from assigns 71 | * `:required` - raises if required attributes are absent from assigns 72 | * `:from` - instead of fetching from all assigns, fetch from one or several :global attributes 73 | * `:into` - merges all assigns in a single one that can be interpolated at once 74 | 75 | ## Example 76 | ``` 77 | assigns 78 | |> set_prefixed_attributes( 79 | ["@click", "x-bind:"], 80 | required: ["x-bind:class"], 81 | into: :alpine_attributes 82 | ) 83 | |> set_prefixed_attributes( 84 | ["aria-"], 85 | from: :rest, 86 | into: :aria_attributes 87 | ) 88 | ``` 89 | 90 | `assigns` now contains `@heex_click`, `@heex_x-bind:class` 91 | and `@heex_alpine_attributes`. 92 | """ 93 | def set_prefixed_attributes(assigns, prefixes, opts \\ []) do 94 | phx_attributes = 95 | prefixes 96 | |> Enum.flat_map(&find_assigns_with_prefix(assigns, opts[:from], &1)) 97 | |> Enum.uniq() 98 | 99 | assigns 100 | |> do_set_attributes(phx_attributes, opts) 101 | |> set_empty_attributes(opts[:init]) 102 | |> validate_required_attributes(opts[:required]) 103 | end 104 | 105 | @doc ~S""" 106 | Just a convenient method built on top of `set_prefixed_attributes/3` for phx attributes. 107 | It will automatically detect any attribute prefixed by `phx-` from input assigns. 108 | By default, the `:into` option of `set_prefixed_attributes/3` is `:phx_attributes` 109 | 110 | ## Example 111 | ``` 112 | assigns 113 | |> set_phx_attributes(required: [:"phx-submit"], init: [:"phx-change"]) 114 | 115 | assigns 116 | |> set_phx_attributes(from: :rest) 117 | ``` 118 | 119 | `assigns` now contains `@heex_phx_change`, `@heex_phx_submit` and `@heex_phx_attributes`. 120 | """ 121 | def set_phx_attributes(assigns, opts \\ []) do 122 | opts = Keyword.put_new(opts, :into, :phx_attributes) 123 | set_prefixed_attributes(assigns, ["phx-"], opts) 124 | end 125 | 126 | @doc ~S""" 127 | Validates that attributes are present in assigns. 128 | Raises an `ArgumentError` if any attribute is missing. 129 | 130 | ## Example 131 | ``` 132 | assigns 133 | |> validate_required_attributes([:id, :label]) 134 | ``` 135 | """ 136 | def validate_required_attributes(assigns, required) 137 | def validate_required_attributes(assigns, nil), do: assigns 138 | 139 | def validate_required_attributes(assigns, required) do 140 | missing = for attr <- required, is_nil(Map.get(assigns, attr)), do: attr 141 | 142 | if Enum.any?(missing) do 143 | raise ArgumentError, "missing required attributes #{inspect(missing)}" 144 | else 145 | assigns 146 | end 147 | end 148 | 149 | @doc ~S""" 150 | Provides default css classes and extend them from assigns. 151 | 152 | The class attribute will take provided `default_classes` as a default value and will 153 | extend them, on a class-by-class basis, with your assigns. 154 | 155 | Any CSS class provided in the assigns (by default under the `:class` attribute) will be 156 | added to the `default_classes`. You can also remove classes from the `default_classes` 157 | by using the `!` prefix. 158 | - `"!bg-gray-400 bg-blue-200"` will remove `"bg-gray-400"` from default component classes 159 | and replace it with `"bg-blue-200"` 160 | - `"!block flex"` will replace `"block"` by `"flex"` layout in your component classes. 161 | - `"!border* border-2 border-red-400"` will replace all border classes by 162 | `"border-2 border-red-400"`. 163 | 164 | ## Parameters 165 | * `assigns` - your component assigns 166 | * `default_classes` - the default classes that will be overridden by your assigns. 167 | This parameter can be a binary or a single parameter function that receives all assigns and 168 | returns a binary 169 | 170 | ## Options 171 | * `:attribute` - read & write css classes from & into this key 172 | 173 | ## Example 174 | ``` 175 | assigns 176 | |> extend_class("bg-blue-500 mt-8") 177 | |> extend_class("py-4 px-2 divide-y-8 divide-gray-200", attribute: :wrapper_class) 178 | |> extend_class(fn assigns -> 179 | default = "p-2 m-4 text-sm " 180 | if assigns[:active], do: default <> "bg-indigo-500", else: default <> "bg-gray-200" 181 | end) 182 | ``` 183 | 184 | `assigns` now contains `@heex_class` and `@heex_wrapper_class`. 185 | 186 | If your input assigns were `%{class: "!mt-8 mt-2", wrapper_class: "!divide* divide-none"}` then: 187 | * `@heex_class` would contain `"bg-blue-500 mt-2"` 188 | * `@heex_wrapper_class` would contain `"py-4 px-2 divide-none"` 189 | """ 190 | def extend_class(assigns, default_classes, opts \\ []) do 191 | class_attribute_name = Keyword.get(opts, :attribute, :class) 192 | new_class = do_css_extend_class(assigns, default_classes, class_attribute_name) 193 | 194 | assigns 195 | |> assign(:"#{class_attribute_name}", new_class) 196 | |> assign(:"heex_#{class_attribute_name}", class: new_class) 197 | end 198 | 199 | @doc ~S""" 200 | Extends assigns with form related attributes. 201 | 202 | If assigns contain `:form` and `:field` keys then it will set `:id`, `:name`, `:for`, 203 | `:value`, and `:errors` from received `Phoenix.HTML.Form`. 204 | 205 | ## Parameters 206 | * `assigns` - your component assigns 207 | 208 | ## Example 209 | ``` 210 | assigns 211 | |> set_form_attributes() 212 | ``` 213 | """ 214 | def set_form_attributes(assigns) do 215 | with_form_fields( 216 | assigns, 217 | fn assigns, form, field -> 218 | assigns 219 | |> put_if_new_or_nil(:id, input_id(form, field)) 220 | |> put_if_new_or_nil(:name, input_name(form, field)) 221 | |> put_if_new_or_nil(:for, input_name(form, field)) 222 | |> put_if_new_or_nil(:value, input_value(form, field)) 223 | |> put_if_new_or_nil(:errors, form_errors(form, field)) 224 | end, 225 | fn assigns -> 226 | assigns 227 | |> put_if_new_or_nil(:form, nil) 228 | |> put_if_new_or_nil(:field, nil) 229 | |> put_if_new_or_nil(:id, nil) 230 | |> put_if_new_or_nil(:name, nil) 231 | |> put_if_new_or_nil(:for, nil) 232 | |> put_if_new_or_nil(:value, nil) 233 | |> put_if_new_or_nil(:errors, []) 234 | end 235 | ) 236 | end 237 | 238 | @doc ~S""" 239 | Forward and filter assigns to sub components. 240 | By default it doesn't forward anything unless you provide it with any combination 241 | of the options described below. 242 | 243 | ## Parameters 244 | * `assigns` - your component assigns 245 | 246 | ## Options 247 | * `prefix` - will only forward assigns prefixed by the given prefix. Forwarded assign key will no 248 | longer have the prefix 249 | * `take`- is a list of key (without prefix) that will be picked from assigns to be forwarded 250 | * `merge`- takes a map that will be merged as-is to the output assigns 251 | 252 | If both options are given at the same time, the resulting assigns will be the union of the two. 253 | 254 | 255 | ## Example 256 | Following will forward an assign map containing `%{button_id: 42, button_label: "label", phx_click: "save"}` 257 | as `%{id: 42, label: "label", phx_click: "save"}` 258 | 259 | ``` 260 | forward_assigns(assigns, prefix: :button, take: [:phx_click]) 261 | ``` 262 | """ 263 | def forward_assigns(assigns, opts) do 264 | for option <- opts, reduce: %{} do 265 | acc -> 266 | assigns = handle_forward_option(assigns, option) 267 | assign(assigns, acc) 268 | end 269 | end 270 | 271 | @doc ~S""" 272 | If assigns include form and field entries, this function will let you 273 | know if the given field is in error or not. 274 | Returns true or false. 275 | 276 | ## Parameters 277 | * `assigns` - your component assigns, which should have `form` and `field` keys. 278 | """ 279 | def has_errors?(_assigns = %{form: form, field: field}) 280 | when not is_nil(form) and not is_nil(field) do 281 | errors = form_errors(form, field) 282 | errors && !Enum.empty?(errors) 283 | end 284 | 285 | def has_errors?(_assigns), do: false 286 | 287 | defp put_if_new_or_nil(map, key, val) do 288 | Map.update(map, key, val, fn 289 | nil -> val 290 | current -> current 291 | end) 292 | end 293 | 294 | defp find_assigns_with_prefix(assigns, nil = _from, prefix) do 295 | for key <- Map.keys(assigns), 296 | key_s = to_string(key), 297 | String.starts_with?(key_s, prefix), 298 | do: key 299 | end 300 | 301 | defp find_assigns_with_prefix(assigns, from, prefix) when is_list(from) do 302 | from_assigns = 303 | for {_key, attrs} <- Map.take(assigns, from), {k, v} <- attrs, into: %{}, do: {k, v} 304 | 305 | find_assigns_with_prefix(from_assigns, nil, prefix) 306 | end 307 | 308 | defp find_assigns_with_prefix(assigns, from, prefix) do 309 | find_assigns_with_prefix(assigns, [from], prefix) 310 | end 311 | end 312 | -------------------------------------------------------------------------------- /lib/phx_component_helpers/css.ex: -------------------------------------------------------------------------------- 1 | defmodule PhxComponentHelpers.CSS do 2 | @moduledoc false 3 | 4 | require Logger 5 | 6 | @doc false 7 | def do_css_extend_class(assigns, default_classes, class_attribute_name) 8 | when is_map(assigns) do 9 | input_class = Map.get(assigns, class_attribute_name) || "" 10 | do_extend_class(assigns, input_class, default_classes) 11 | end 12 | 13 | @doc false 14 | def do_css_extend_class(options, default_classes, class_attribute_name) 15 | when is_list(options) do 16 | input_class = Keyword.get(options, class_attribute_name) || "" 17 | do_extend_class(options, input_class, default_classes) 18 | end 19 | 20 | defp do_extend_class(assigns_or_options, input_class, default_classes) do 21 | default_classes = 22 | cond do 23 | is_function(default_classes) -> 24 | default_classes.(assigns_or_options) 25 | 26 | is_list(default_classes) -> 27 | default_classes 28 | |> List.flatten() 29 | |> Enum.filter(&is_binary/1) 30 | |> Enum.join(" ") 31 | 32 | true -> 33 | default_classes 34 | end 35 | 36 | default_classes = String.split(default_classes, [" ", "\n"], trim: true) 37 | extend_classes = String.split(input_class, [" ", "\n"], trim: true) 38 | target_classes = Enum.reject(extend_classes, &String.starts_with?(&1, "!")) 39 | 40 | for class <- Enum.reverse(default_classes), 41 | !class_should_be_removed?(class, extend_classes), 42 | reduce: target_classes do 43 | acc -> 44 | [class | acc] 45 | end 46 | |> Enum.join(" ") 47 | end 48 | 49 | defp class_should_be_removed?(class, extend_classes) do 50 | Enum.any?(extend_classes, fn 51 | "!" <> ^class -> 52 | true 53 | 54 | "!" <> pattern -> 55 | String.ends_with?(pattern, "*") and 56 | String.starts_with?(class, String.slice(pattern, 0..-2//1)) 57 | 58 | _ -> 59 | false 60 | end) 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/phx_component_helpers/forms.ex: -------------------------------------------------------------------------------- 1 | defmodule PhxComponentHelpers.Forms do 2 | @moduledoc false 3 | 4 | @doc false 5 | def with_form_fields(assigns, fun, fallback \\ & &1) do 6 | form = assigns[:form] 7 | field = assigns[:field] 8 | 9 | if form && field do 10 | fun.(assigns, form, field) 11 | else 12 | fallback.(assigns) 13 | end 14 | end 15 | 16 | @doc false 17 | def form_errors(form, field) when not is_nil(form) and not is_nil(field) do 18 | case form.errors do 19 | nil -> [] 20 | errors -> Keyword.get_values(errors, field) 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/phx_component_helpers/forward.ex: -------------------------------------------------------------------------------- 1 | defmodule PhxComponentHelpers.Forward do 2 | @moduledoc false 3 | 4 | def handle_forward_option(assigns, {:prefix, prefix}) do 5 | prefix_ = "#{prefix}_" 6 | prefix_s = to_string(prefix) 7 | 8 | for {key, val} <- assigns, reduce: %{} do 9 | acc -> 10 | key = to_string(key) 11 | 12 | if key == prefix_s || String.starts_with?(key, prefix_) do 13 | forwarded_key = key |> String.replace_leading(prefix_, "") |> String.to_atom() 14 | Map.put(acc, forwarded_key, val) 15 | else 16 | acc 17 | end 18 | end 19 | end 20 | 21 | def handle_forward_option(assigns, {:take, attributes}) do 22 | Map.take(assigns, attributes) 23 | end 24 | 25 | def handle_forward_option(_assigns, {:merge, assigns}) do 26 | assigns 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/phx_component_helpers/set_attributes.ex: -------------------------------------------------------------------------------- 1 | defmodule PhxComponentHelpers.SetAttributes do 2 | @moduledoc false 3 | @json_library Jason 4 | 5 | @doc false 6 | def do_set_attributes(assigns, attributes, opts \\ []) do 7 | new_assigns = 8 | attributes 9 | |> Enum.reduce(Map.take(assigns, [:__changed__]), fn attr, acc -> 10 | {attr, default} = attribute_key_and_default(attr) 11 | heex_attr_key = heex_attribute_key(attr) 12 | heex_attribute_fun = heex_attribute_fun(opts) 13 | 14 | case {get_from_assigns(assigns, attr, opts[:from]), default} do 15 | {nil, nil} -> 16 | acc 17 | |> assign(attr, nil) 18 | |> assign(heex_attr_key, []) 19 | 20 | {nil, default} -> 21 | acc 22 | |> assign(attr, default) 23 | |> assign(heex_attr_key, [{heex_attribute_fun.(attr), heex_escaped(default, opts)}]) 24 | 25 | {val, _} -> 26 | assign(acc, heex_attr_key, [{heex_attribute_fun.(attr), heex_escaped(val, opts)}]) 27 | end 28 | end) 29 | |> handle_into_option(opts[:into]) 30 | 31 | Map.merge(assigns, new_assigns) 32 | end 33 | 34 | defp get_from_assigns(assigns, attr, nil = _from) do 35 | Map.get(assigns, attr) 36 | end 37 | 38 | defp get_from_assigns(assigns, attr, from) when is_list(from) do 39 | from_assigns = 40 | for {_key, attrs} <- Map.take(assigns, from), {k, v} <- attrs, into: %{}, do: {k, v} 41 | 42 | Map.get(from_assigns, attr) 43 | end 44 | 45 | defp get_from_assigns(assigns, attr, from) do 46 | get_from_assigns(assigns, attr, [from]) 47 | end 48 | 49 | @doc false 50 | def set_empty_attributes(assigns, attributes) 51 | 52 | def set_empty_attributes(assigns, nil), do: assigns 53 | 54 | def set_empty_attributes(assigns, attributes) do 55 | for attr <- attributes, reduce: assigns do 56 | acc -> 57 | heex_attr_key = heex_attribute_key(attr) 58 | assign_new(acc, heex_attr_key, fn -> [] end) 59 | end 60 | end 61 | 62 | # support both live view assigns and mere map 63 | def assign(%{__changed__: _changes} = assigns, key, value), 64 | do: Phoenix.Component.assign(assigns, key, value) 65 | 66 | def assign(assigns, key, value), do: Map.put(assigns, key, value) 67 | 68 | def assign(%{__changed__: _changes} = assigns, keyword_or_map), 69 | do: Phoenix.Component.assign(assigns, keyword_or_map) 70 | 71 | def assign(assigns, keyword_or_map), do: Map.merge(assigns, keyword_or_map) 72 | 73 | def assign_new(%{__changed__: _changes} = assigns, key, fun), 74 | do: Phoenix.Component.assign_new(assigns, key, fun) 75 | 76 | def assign_new(assigns, key, fun), do: Map.put_new_lazy(assigns, key, fun) 77 | 78 | defp heex_escaped(val, opts) do 79 | if opts[:json] do 80 | @json_library.encode!(val) 81 | else 82 | val 83 | end 84 | end 85 | 86 | defp heex_attribute_fun(opts) do 87 | if opts[:data] do 88 | &heex_data_attribute/1 89 | else 90 | &heex_attribute/1 91 | end 92 | end 93 | 94 | defp heex_attribute(attr), 95 | do: attr |> to_string() |> String.replace("_", "-") |> String.to_atom() 96 | 97 | defp heex_data_attribute(attr), do: String.to_atom("data-#{heex_attribute(attr)}") 98 | 99 | defp attribute_key_and_default({attr, default}), do: {attr, default} 100 | defp attribute_key_and_default(attr), do: {attr, nil} 101 | 102 | defp heex_attribute_key(attr) do 103 | "heex_#{attr}" |> String.replace("@", "") |> String.to_atom() 104 | end 105 | 106 | defp handle_into_option(assigns, nil), do: assigns 107 | 108 | defp handle_into_option(assigns, into) do 109 | heex_into_assign = 110 | for({key, attr} <- assigns, key |> to_string() |> String.starts_with?("heex"), do: attr) 111 | |> List.flatten() 112 | 113 | heex_attr_key = heex_attribute_key(into) 114 | assign(assigns, heex_attr_key, heex_into_assign) 115 | end 116 | end 117 | -------------------------------------------------------------------------------- /lib/phx_view_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule PhxViewHelpers do 2 | @moduledoc """ 3 | `PhxComponentHelpers` are helper functions meant to be used within Phoenix your views to 4 | facilitate usage of live_components inside templates. 5 | """ 6 | 7 | import PhxComponentHelpers.CSS 8 | 9 | @doc ~S""" 10 | Extends `Phoenix.Component.form/1` options to merge css class as with 11 | `PhxComponentHelpers.extend_class/2`. 12 | 13 | It's useful to define your own `my_form` function with default css classes that still can be 14 | overriden from the template. 15 | 16 | ## Example 17 | ``` 18 | def my_form(options) do 19 | new_options = extend_form_class(options, "mt-4 space-y-2") 20 | Component.form(new_options) 21 | end 22 | ``` 23 | """ 24 | def extend_form_class(options, default_classes) when is_list(options) do 25 | extended_classes = do_css_extend_class(options, default_classes, :class) 26 | Keyword.put(options, :class, extended_classes) 27 | end 28 | 29 | def extend_form_class(options, default_classes) when is_map(options) do 30 | extended_classes = do_css_extend_class(options, default_classes, :class) 31 | Map.put(options, :class, extended_classes) 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule PhxComponentHelpers.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :phx_component_helpers, 7 | version: "1.4.1", 8 | elixir: "~> 1.7", 9 | start_permanent: Mix.env() == :prod, 10 | deps: deps(), 11 | test_coverage: [tool: ExCoveralls], 12 | preferred_cli_env: [ 13 | coveralls: :test, 14 | "coveralls.detail": :test, 15 | "coveralls.post": :test, 16 | "coveralls.html": :test 17 | ], 18 | description: "Making development of Phoenix LiveView live_components easier.", 19 | package: package(), 20 | name: "phx_component_helpers", 21 | source_url: "https://github.com/cblavier/phx_component_helpers", 22 | docs: [ 23 | main: "PhxComponentHelpers", 24 | extras: ["README.md"], 25 | nest_modules_by_prefix: [PhxComponentHelpers] 26 | ] 27 | ] 28 | end 29 | 30 | # Run "mix help compile.app" to learn about applications. 31 | def application do 32 | [ 33 | extra_applications: [:logger] 34 | ] 35 | end 36 | 37 | # Run "mix help deps" to learn about dependencies. 38 | defp deps do 39 | [ 40 | {:credo, "~> 1.5", only: [:dev, :test], runtime: false}, 41 | {:ex_doc, "~> 0.24", only: :dev, runtime: false}, 42 | {:excoveralls, "~> 0.14", only: :test}, 43 | {:jason, "~> 1.0", optional: true}, 44 | {:mix_test_watch, "~> 1.0", only: :dev, runtime: false}, 45 | {:phoenix_html, ">= 4.0.0"}, 46 | {:phoenix_live_view, ">= 0.18.0"} 47 | ] 48 | end 49 | 50 | defp package do 51 | [ 52 | files: ~w(lib .formatter.exs mix.exs README* LICENSE* CHANGELOG*), 53 | licenses: ["MIT"], 54 | links: %{"GitHub" => "https://github.com/cblavier/phx_component_helpers"} 55 | ] 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, 3 | "castore": {:hex, :castore, "1.0.8", "dedcf20ea746694647f883590b82d9e96014057aff1d44d03ec90f36a5c0dc6e", [:mix], [], "hexpm", "0b2b66d2ee742cb1d9cb8c8be3b43c3a70ee8651f37b75a8b982e036752983f1"}, 4 | "certifi": {:hex, :certifi, "2.12.0", "2d1cca2ec95f59643862af91f001478c9863c2ac9cb6e2f89780bfd8de987329", [:rebar3], [], "hexpm", "ee68d85df22e554040cdb4be100f33873ac6051387baf6a8f6ce82272340ff1c"}, 5 | "credo": {:hex, :credo, "1.7.7", "771445037228f763f9b2afd612b6aa2fd8e28432a95dbbc60d8e03ce71ba4446", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8bc87496c9aaacdc3f90f01b7b0582467b69b4bd2441fe8aae3109d843cc2f2e"}, 6 | "earmark_parser": {:hex, :earmark_parser, "1.4.41", "ab34711c9dc6212dda44fcd20ecb87ac3f3fce6f0ca2f28d4a00e4154f8cd599", [:mix], [], "hexpm", "a81a04c7e34b6617c2792e291b5a2e57ab316365c2644ddc553bb9ed863ebefa"}, 7 | "ex_doc": {:hex, :ex_doc, "0.34.2", "13eedf3844ccdce25cfd837b99bea9ad92c4e511233199440488d217c92571e8", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "5ce5f16b41208a50106afed3de6a2ed34f4acfd65715b82a0b84b49d995f95c1"}, 8 | "excoveralls": {:hex, :excoveralls, "0.18.2", "86efd87a0676a3198ff50b8c77620ea2f445e7d414afa9ec6c4ba84c9f8bdcc2", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "230262c418f0de64077626a498bd4fdf1126d5c2559bb0e6b43deac3005225a4"}, 9 | "file_system": {:hex, :file_system, "1.0.1", "79e8ceaddb0416f8b8cd02a0127bdbababe7bf4a23d2a395b983c1f8b3f73edd", [:mix], [], "hexpm", "4414d1f38863ddf9120720cd976fce5bdde8e91d8283353f0e31850fa89feb9e"}, 10 | "hackney": {:hex, :hackney, "1.20.1", "8d97aec62ddddd757d128bfd1df6c5861093419f8f7a4223823537bad5d064e2", [:rebar3], [{:certifi, "~> 2.12.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "fe9094e5f1a2a2c0a7d10918fee36bfec0ec2a979994cff8cfe8058cd9af38e3"}, 11 | "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, 12 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 13 | "makeup": {:hex, :makeup, "1.1.2", "9ba8837913bdf757787e71c1581c21f9d2455f4dd04cfca785c70bbfff1a76a3", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cce1566b81fbcbd21eca8ffe808f33b221f9eee2cbc7a1706fc3da9ff18e6cac"}, 14 | "makeup_elixir": {:hex, :makeup_elixir, "0.16.2", "627e84b8e8bf22e60a2579dad15067c755531fea049ae26ef1020cad58fe9578", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "41193978704763f6bbe6cc2758b84909e62984c7752b3784bd3c218bb341706b"}, 15 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.1", "c7f58c120b2b5aa5fd80d540a89fdf866ed42f1f3994e4fe189abebeab610839", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "8a89a1eeccc2d798d6ea15496a6e4870b75e014d1af514b1b71fa33134f57814"}, 16 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, 17 | "mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"}, 18 | "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, 19 | "mix_test_watch": {:hex, :mix_test_watch, "1.2.0", "1f9acd9e1104f62f280e30fc2243ae5e6d8ddc2f7f4dc9bceb454b9a41c82b42", [:mix], [{:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "278dc955c20b3fb9a3168b5c2493c2e5cffad133548d307e0a50c7f2cfbf34f6"}, 20 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, 21 | "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, 22 | "phoenix": {:hex, :phoenix, "1.7.14", "a7d0b3f1bc95987044ddada111e77bd7f75646a08518942c72a8440278ae7825", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "c7859bc56cc5dfef19ecfc240775dae358cbaa530231118a9e014df392ace61a"}, 23 | "phoenix_html": {:hex, :phoenix_html, "4.1.1", "4c064fd3873d12ebb1388425a8f2a19348cef56e7289e1998e2d2fa758aa982e", [:mix], [], "hexpm", "f2f2df5a72bc9a2f510b21497fd7d2b86d932ec0598f0210fed4114adc546c6f"}, 24 | "phoenix_live_view": {:hex, :phoenix_live_view, "0.20.17", "f396bbdaf4ba227b82251eb75ac0afa6b3da5e509bc0d030206374237dfc9450", [:mix], [{:floki, "~> 0.36", [hex: :floki, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a61d741ffb78c85fdbca0de084da6a48f8ceb5261a79165b5a0b59e5f65ce98b"}, 25 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, 26 | "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, 27 | "phoenix_view": {:hex, :phoenix_view, "1.1.2", "1b82764a065fb41051637872c7bd07ed2fdb6f5c3bd89684d4dca6e10115c95a", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "7ae90ad27b09091266f6adbb61e1d2516a7c3d7062c6789d46a7554ec40f3a56"}, 28 | "plug": {:hex, :plug, "1.16.1", "40c74619c12f82736d2214557dedec2e9762029b2438d6d175c5074c933edc9d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a13ff6b9006b03d7e33874945b2755253841b238c34071ed85b0e86057f8cddc"}, 29 | "plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"}, 30 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, 31 | "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, 32 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, 33 | "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, 34 | "websock_adapter": {:hex, :websock_adapter, "0.5.7", "65fa74042530064ef0570b75b43f5c49bb8b235d6515671b3d250022cb8a1f9e", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "d0f478ee64deddfec64b800673fd6e0c8888b079d9f3444dd96d2a98383bdbd1"}, 35 | } 36 | -------------------------------------------------------------------------------- /test/phx_component_helpers_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhxComponentHelpersTest do 2 | @moduledoc false 3 | use ExUnit.Case, async: true 4 | alias PhxComponentHelpers, as: Helpers 5 | alias Phoenix.HTML.Form 6 | 7 | defp assigns(input), do: Map.merge(input, %{__changed__: %{}}) 8 | 9 | describe "set_attributes" do 10 | test "with unknown attributes it let the assigns unchanged" do 11 | assigns = assigns(%{foo: "foo", bar: "bar"}) 12 | new_assigns = Helpers.set_attributes(assigns, []) 13 | assert new_assigns == assigns 14 | end 15 | 16 | test "with known attributes it set the heex attributes" do 17 | assigns = assigns(%{foo: "foo", bar: "bar"}) 18 | 19 | assert %{heex_foo: [foo: "foo"]} = Helpers.set_attributes(assigns, [:foo]) 20 | end 21 | 22 | test "with liveview assigns" do 23 | assigns = assigns(%{__changed__: [], foo: "foo", bar: "bar"}) 24 | 25 | assert %{heex_foo: [foo: "foo"]} = Helpers.set_attributes(assigns, [:foo]) 26 | end 27 | 28 | test "absent assigns are set as empty attributes" do 29 | assigns = assigns(%{foo: "foo", bar: "bar"}) 30 | 31 | assert %{baz: nil, heex_baz: []} = Helpers.set_attributes(assigns, [:baz]) 32 | end 33 | 34 | test "with known attributes and json opt, it set the attribute as json" do 35 | assigns = assigns(%{foo: %{here: "some json"}, bar: "bar"}) 36 | new_assigns = Helpers.set_attributes(assigns, [:foo], json: true) 37 | assert new_assigns[:heex_foo] == [foo: "{\"here\":\"some json\"}"] 38 | end 39 | 40 | test "validates required attributes" do 41 | assigns = assigns(%{foo: "foo", bar: "bar"}) 42 | new_assigns = Helpers.set_attributes(assigns, [], required: [:foo]) 43 | assert new_assigns == assigns 44 | end 45 | 46 | test "with missing required attributes" do 47 | assigns = assigns(%{foo: "foo", bar: "bar"}) 48 | 49 | assert_raise ArgumentError, "missing required attributes [:baz]", fn -> 50 | Helpers.set_attributes(assigns, [], required: [:baz]) 51 | end 52 | end 53 | 54 | test "with missing required attributes filled" do 55 | assigns = assigns(%{foo: "foo", bar: "bar"}) 56 | 57 | assert_raise ArgumentError, "missing required attributes [:baz]", fn -> 58 | Helpers.set_attributes(assigns, [:baz], required: [:baz]) 59 | end 60 | end 61 | 62 | test "with required attributes filled with false, it shoud not raise" do 63 | assigns = assigns(%{foo: "foo", bar: "bar"}) 64 | 65 | Helpers.set_attributes(assigns, [baz: false], required: [:baz]) 66 | end 67 | 68 | test "with into option, it merges all in a single assign" do 69 | assigns = assigns(%{foo: "foo", bar: "bar"}) 70 | 71 | %{ 72 | heex_attributes: [foo: "foo", bar: "bar"], 73 | heex_bar: [bar: "bar"], 74 | heex_foo: [foo: "foo"] 75 | } = Helpers.set_attributes(assigns, [:foo, :bar], into: :attributes) 76 | end 77 | 78 | test "set default values" do 79 | assigns = assigns(%{foo: "foo"}) 80 | 81 | assert %{ 82 | bar: "bar", 83 | heex_bar: [bar: "bar"], 84 | heex_foo: [foo: "foo"] 85 | } = Helpers.set_attributes(assigns, [:foo, bar: "bar"]) 86 | end 87 | 88 | test "set default nil value" do 89 | assigns = Helpers.set_attributes(%{}, foo: nil) 90 | assert %{foo: nil} = assigns 91 | end 92 | 93 | test "set default json values" do 94 | assigns = assigns(%{foo: %{here: "some json"}}) 95 | 96 | assert %{ 97 | bar: %{there: "also json"}, 98 | heex_bar: [bar: "{\"there\":\"also json\"}"], 99 | heex_foo: [foo: "{\"here\":\"some json\"}"] 100 | } = Helpers.set_attributes(assigns, [:foo, bar: %{there: "also json"}], json: true) 101 | end 102 | 103 | test "set from attributes" do 104 | assigns = assigns(%{data: [here: "foo", there: "bar"]}) 105 | assert %{heex_here: [here: "foo"]} = Helpers.set_attributes(assigns, [:here], from: :data) 106 | 107 | assert %{heex_here: [here: "foo"], heex_data: [here: "foo"]} = 108 | Helpers.set_attributes(assigns, [:here], from: :data, into: :data) 109 | end 110 | 111 | test "detects assign changes" do 112 | assert %{__changed__: %{id: true}} = Helpers.set_attributes(assigns(%{}), id: 1) 113 | refute Map.has_key?(Helpers.set_attributes(%{}, id: 1), :__changed__) 114 | end 115 | end 116 | 117 | describe "set data attributes" do 118 | test "with unknown attributes it let the assigns unchanged" do 119 | assigns = assigns(%{foo: "foo", bar: "bar"}) 120 | new_assigns = Helpers.set_attributes(assigns, [], data: true) 121 | assert new_assigns == assigns 122 | end 123 | 124 | test "with known attributes it set the heex attribute" do 125 | assigns = assigns(%{foo: "foo", bar: "bar"}) 126 | 127 | assert %{heex_foo: ["data-foo": "foo"]} = 128 | Helpers.set_attributes(assigns, [:foo], data: true) 129 | end 130 | 131 | test "with known attributes and json opt, it set the attribute as json" do 132 | assigns = %{foo: %{here: "some json"}, bar: "bar"} 133 | new_assigns = Helpers.set_attributes(assigns, [:foo], data: true, json: true) 134 | 135 | assert new_assigns == 136 | Map.put( 137 | assigns, 138 | :heex_foo, 139 | "data-foo": "{\"here\":\"some json\"}" 140 | ) 141 | end 142 | 143 | test "validates required attributes" do 144 | assigns = %{foo: "foo", bar: "bar"} 145 | new_assigns = Helpers.set_attributes(assigns, [], required: [:foo], data: true) 146 | assert new_assigns == assigns 147 | end 148 | 149 | test "with missing required attributes" do 150 | assigns = %{foo: "foo", bar: "bar"} 151 | 152 | assert_raise ArgumentError, fn -> 153 | Helpers.set_attributes(assigns, [], required: [:baz], data: true) 154 | end 155 | end 156 | 157 | test "set default values" do 158 | assigns = %{foo: "foo"} 159 | new_assigns = Helpers.set_attributes(assigns, [:foo, bar: "bar"], data: true) 160 | 161 | assert new_assigns == 162 | assigns 163 | |> Map.put(:bar, "bar") 164 | |> Map.put(:heex_foo, "data-foo": "foo") 165 | |> Map.put(:heex_bar, "data-bar": "bar") 166 | end 167 | 168 | test "set default json values" do 169 | assigns = %{foo: %{here: "some json"}} 170 | 171 | new_assigns = 172 | Helpers.set_attributes(assigns, [:foo, bar: %{there: "also json"}], 173 | json: true, 174 | data: true 175 | ) 176 | 177 | assert new_assigns == 178 | assigns 179 | |> Map.put( 180 | :bar, 181 | %{there: "also json"} 182 | ) 183 | |> Map.put( 184 | :heex_foo, 185 | "data-foo": "{\"here\":\"some json\"}" 186 | ) 187 | |> Map.put( 188 | :heex_bar, 189 | "data-bar": "{\"there\":\"also json\"}" 190 | ) 191 | end 192 | end 193 | 194 | describe "set_prefixed_attributes" do 195 | test "without phx assigns it let the assigns unchanged" do 196 | assigns = %{foo: "foo", bar: "bar"} 197 | new_assigns = Helpers.set_prefixed_attributes(assigns, []) 198 | assert new_assigns == assigns 199 | end 200 | 201 | test "with alpinejs assigns it adds the heex attributes" do 202 | assigns = %{"@click" => "open = true", "x-bind:class" => "open", foo: "foo"} 203 | new_assigns = Helpers.set_prefixed_attributes(assigns, ["@click", "x-bind:"]) 204 | 205 | assert new_assigns == 206 | assigns 207 | |> Map.put(:heex_click, "@click": "open = true") 208 | |> Map.put(:"heex_x-bind:class", "x-bind:class": "open") 209 | end 210 | 211 | test "with init attributes it adds empty attribute" do 212 | assigns = %{foo: "foo", bar: "bar"} 213 | 214 | new_assigns = 215 | Helpers.set_prefixed_attributes(assigns, ["@click", "x-bind:"], init: ["@click.away"]) 216 | 217 | assert new_assigns == Map.put(assigns, :"heex_click.away", []) 218 | end 219 | 220 | test "validates required attributes" do 221 | assigns = %{"@click.away" => "open = false"} 222 | 223 | new_assigns = 224 | Helpers.set_prefixed_attributes(assigns, ["@click", "x-bind:"], required: ["@click.away"]) 225 | 226 | assert new_assigns == Map.put(assigns, :"heex_click.away", "@click.away": "open = false") 227 | end 228 | 229 | test "with missing required attributes" do 230 | assigns = %{foo: "foo", bar: "bar"} 231 | 232 | assert_raise ArgumentError, fn -> 233 | Helpers.set_prefixed_attributes(assigns, ["@click", "x-bind:"], required: ["@click.away"]) 234 | end 235 | end 236 | 237 | test "with single from attribute" do 238 | assigns = %{rest: [foo: "foo", "phx-click": "click", "phx-update": "ignore"]} 239 | new_assigns = Helpers.set_prefixed_attributes(assigns, ["phx-"], from: :rest, into: :phx) 240 | 241 | assert new_assigns == 242 | assigns 243 | |> Map.put(:"heex_phx-click", "phx-click": "click") 244 | |> Map.put(:"heex_phx-update", "phx-update": "ignore") 245 | |> Map.put(:heex_phx, "phx-click": "click", "phx-update": "ignore") 246 | end 247 | 248 | test "with multiple from attribute" do 249 | assigns = %{ 250 | data: [foo: "foo", "data-text": "text"], 251 | aria: [bar: "bar", "aria-title": "title"] 252 | } 253 | 254 | new_assigns = 255 | Helpers.set_prefixed_attributes(assigns, ["data-", "aria-"], 256 | from: [:data, :aria], 257 | into: :rest 258 | ) 259 | 260 | assert new_assigns == 261 | assigns 262 | |> Map.put(:"heex_aria-title", "aria-title": "title") 263 | |> Map.put(:"heex_data-text", "data-text": "text") 264 | |> Map.put(:heex_rest, "aria-title": "title", "data-text": "text") 265 | end 266 | end 267 | 268 | describe "set_phx_attributes" do 269 | test "with phx assigns it adds the phx-attribute" do 270 | assigns = %{:"phx-change" => "foo", :"phx-click" => "bar", baz: "baz"} 271 | new_assigns = Helpers.set_phx_attributes(assigns) 272 | 273 | assert new_assigns == 274 | assigns 275 | |> Map.put(:heex_phx_attributes, "phx-click": "bar", "phx-change": "foo") 276 | |> Map.put(:"heex_phx-change", "phx-change": "foo") 277 | |> Map.put(:"heex_phx-click", "phx-click": "bar") 278 | end 279 | 280 | test "with init attributes it adds empty attribute" do 281 | assigns = %{foo: "foo", bar: "bar"} 282 | new_assigns = Helpers.set_phx_attributes(assigns, init: [:phx_submit], into: nil) 283 | assert new_assigns == Map.put(assigns, :heex_phx_submit, []) 284 | end 285 | 286 | test "validates required attributes" do 287 | assigns = %{:"phx-click" => "click"} 288 | new_assigns = Helpers.set_phx_attributes(assigns, required: [:"phx-click"], into: nil) 289 | assert new_assigns == Map.put(assigns, :"heex_phx-click", "phx-click": "click") 290 | end 291 | 292 | test "with missing required attributes" do 293 | assigns = %{foo: "foo", bar: "bar"} 294 | 295 | assert_raise ArgumentError, fn -> 296 | Helpers.set_phx_attributes(assigns, required: [:phx_click]) 297 | end 298 | end 299 | 300 | test "with from attributes" do 301 | assigns = %{rest: ["phx-change": "foo", "phx-click": "bar", baz: "baz"]} 302 | new_assigns = Helpers.set_phx_attributes(assigns, from: :rest) 303 | 304 | assert new_assigns == 305 | assigns 306 | |> Map.put(:heex_phx_attributes, "phx-click": "bar", "phx-change": "foo") 307 | |> Map.put(:"heex_phx-change", "phx-change": "foo") 308 | |> Map.put(:"heex_phx-click", "phx-click": "bar") 309 | end 310 | end 311 | 312 | describe "validate_required_attributes" do 313 | test "validates required attributes" do 314 | assigns = %{phx_click: "click"} 315 | new_assigns = Helpers.validate_required_attributes(assigns, [:phx_click]) 316 | assert new_assigns == assigns 317 | end 318 | 319 | test "with missing required attributes" do 320 | assigns = %{foo: "foo", bar: "bar"} 321 | 322 | assert_raise ArgumentError, fn -> 323 | Helpers.validate_required_attributes(assigns, [:phx_click]) 324 | end 325 | end 326 | end 327 | 328 | describe "extend_class" do 329 | @tag :capture_log 330 | test "without class keeps the default class attribute" do 331 | assigns = %{c: "foo", bar: "bar"} 332 | new_assigns = Helpers.extend_class(assigns, "bg-blue-500 mt-8") 333 | 334 | assert new_assigns == 335 | assigns 336 | |> Map.put(:class, "bg-blue-500 mt-8") 337 | |> Map.put(:heex_class, class: "bg-blue-500 mt-8") 338 | end 339 | 340 | test "with class extends the default class attribute" do 341 | assigns = %{class: "!mt* mt-2"} 342 | new_assigns = Helpers.extend_class(assigns, "bg-blue-500 mt-8 ") 343 | 344 | assert new_assigns == 345 | %{ 346 | class: "bg-blue-500 mt-2", 347 | heex_class: [class: "bg-blue-500 mt-2"] 348 | } 349 | end 350 | 351 | test "can extend other class attribute" do 352 | assigns = %{wrapper_class: "!mt* mt-2"} 353 | new_assigns = Helpers.extend_class(assigns, "bg-blue-500 mt-8 ", attribute: :wrapper_class) 354 | 355 | assert new_assigns == 356 | %{ 357 | wrapper_class: "bg-blue-500 mt-2", 358 | heex_wrapper_class: [class: "bg-blue-500 mt-2"] 359 | } 360 | end 361 | 362 | test "default classes can be a function" do 363 | assigns = %{class: "!mt* mt-2", active: true} 364 | 365 | new_assigns = 366 | Helpers.extend_class(assigns, fn 367 | %{active: true} -> "bg-blue-500 mt-8" 368 | _ -> "bg-gray-200 mt-8" 369 | end) 370 | 371 | assert new_assigns == 372 | assigns 373 | |> Map.put(:class, "bg-blue-500 mt-2") 374 | |> Map.put(:heex_class, class: "bg-blue-500 mt-2") 375 | end 376 | 377 | test "default class can be a list" do 378 | assigns = %{class: "!mt* mt-2", active: true, circle: false} 379 | 380 | new_assigns = 381 | Helpers.extend_class(assigns, [ 382 | "bg-blue-500 mt-8", 383 | assigns[:active] == true && "text-green-500", 384 | nil, 385 | ["flex"], 386 | if(assigns[:circle] == true, do: "rounded-full", else: "rounded-md") 387 | ]) 388 | 389 | assert new_assigns == 390 | assigns 391 | |> Map.put(:class, "bg-blue-500 text-green-500 flex rounded-md mt-2") 392 | |> Map.put(:heex_class, class: "bg-blue-500 text-green-500 flex rounded-md mt-2") 393 | end 394 | 395 | test "does not extend with error_class when a form field is not faulty" do 396 | assigns = %{ 397 | class: "!mt* mt-2", 398 | form: %Form{data: %{my_field: "42"}, source: %{errors: []}}, 399 | field: :my_field 400 | } 401 | 402 | new_assigns = 403 | Helpers.extend_class(assigns, "bg-blue-500 mt-8", error_class: "form-input-error") 404 | 405 | assert new_assigns == 406 | assigns 407 | |> Map.put( 408 | :class, 409 | "bg-blue-500 mt-2" 410 | ) 411 | |> Map.put( 412 | :heex_class, 413 | class: "bg-blue-500 mt-2" 414 | ) 415 | end 416 | 417 | test "removes classes prefixed by !" do 418 | assigns = %{class: "!mt-8 mt-2"} 419 | new_assigns = Helpers.extend_class(assigns, "bg-blue-500 mt-8", prefix_replace: false) 420 | 421 | assert new_assigns == 422 | %{ 423 | class: "bg-blue-500 mt-2", 424 | heex_class: [class: "bg-blue-500 mt-2"] 425 | } 426 | end 427 | 428 | test "removes classes prefixed by ! with * patterns" do 429 | assigns = %{class: "!border* mt-2"} 430 | 431 | new_assigns = 432 | Helpers.extend_class(assigns, "border-2 border-gray-400", prefix_replace: false) 433 | 434 | assert new_assigns == 435 | %{ 436 | class: "mt-2", 437 | heex_class: [class: "mt-2"] 438 | } 439 | end 440 | 441 | test "removes everything with !* " do 442 | assigns = %{class: "!* mt-2"} 443 | 444 | new_assigns = 445 | Helpers.extend_class(assigns, "border-2 border-gray-400", prefix_replace: false) 446 | 447 | assert new_assigns == 448 | %{ 449 | class: "mt-2", 450 | heex_class: [class: "mt-2"] 451 | } 452 | end 453 | end 454 | 455 | describe "set_form_attributes" do 456 | test "without form keeps the input assigns" do 457 | assigns = %{c: "foo", bar: "bar"} 458 | new_assigns = Helpers.set_form_attributes(assigns) 459 | 460 | assert new_assigns == 461 | assigns 462 | |> Map.put(:form, nil) 463 | |> Map.put(:field, nil) 464 | |> Map.put(:for, nil) 465 | |> Map.put(:id, nil) 466 | |> Map.put(:name, nil) 467 | |> Map.put(:value, nil) 468 | |> Map.put(:errors, []) 469 | end 470 | 471 | test "with form, field and value, set the form assigns" do 472 | assigns = %{ 473 | c: "foo", 474 | form: %Form{source: %{}, data: %{my_field: "42"}, impl: Phoenix.HTML.FormData}, 475 | field: :my_field 476 | } 477 | 478 | new_assigns = Helpers.set_form_attributes(assigns) 479 | 480 | assert new_assigns == 481 | assigns 482 | |> Map.put(:for, "my_field") 483 | |> Map.put(:id, "my_field") 484 | |> Map.put(:name, "my_field") 485 | |> Map.put(:value, "42") 486 | |> Map.put(:errors, []) 487 | end 488 | 489 | test "with form, field and without value, set the form assigns" do 490 | assigns = %{ 491 | c: "foo", 492 | form: %Form{source: %{}, data: %{}, impl: Phoenix.HTML.FormData}, 493 | field: :my_field 494 | } 495 | 496 | new_assigns = Helpers.set_form_attributes(assigns) 497 | 498 | assert new_assigns == 499 | assigns 500 | |> Map.put(:for, "my_field") 501 | |> Map.put(:id, "my_field") 502 | |> Map.put(:name, "my_field") 503 | |> Map.put(:value, nil) 504 | |> Map.put(:errors, []) 505 | end 506 | 507 | test "with form, does not overwrite set values, but overwrite nil values" do 508 | assigns = %{ 509 | c: "foo", 510 | for: "already_set", 511 | id: nil, 512 | form: %Form{source: %{}, data: %{}, impl: Phoenix.HTML.FormData}, 513 | field: :my_field 514 | } 515 | 516 | new_assigns = Helpers.set_form_attributes(assigns) 517 | 518 | assert new_assigns == 519 | assigns 520 | |> Map.put(:id, "my_field") 521 | |> Map.put(:name, "my_field") 522 | |> Map.put(:value, nil) 523 | |> Map.put(:errors, []) 524 | end 525 | end 526 | 527 | describe "forward_assigns" do 528 | test "without options, it returns empty assigns" do 529 | assigns = %{foo: "foo", bar: "bar"} 530 | new_assigns = Helpers.forward_assigns(assigns, []) 531 | assert new_assigns == %{} 532 | end 533 | 534 | test "with take option" do 535 | assigns = %{foo: "foo", bar: "bar", baz: "baz"} 536 | new_assigns = Helpers.forward_assigns(assigns, take: [:bar, :baz]) 537 | assert new_assigns == %{bar: "bar", baz: "baz"} 538 | end 539 | 540 | test "with prefix option" do 541 | assigns = %{ 542 | foo: "foo", 543 | prefix: "prefix", 544 | prefix_bar: "bar", 545 | prefix_baz: "baz", 546 | prefix_nested_prefix_baz: "baz" 547 | } 548 | 549 | new_assigns = Helpers.forward_assigns(assigns, prefix: :prefix) 550 | assert new_assigns == %{prefix: "prefix", bar: "bar", baz: "baz", nested_prefix_baz: "baz"} 551 | end 552 | 553 | test "with take and prefix option" do 554 | assigns = %{ 555 | foo: "foo", 556 | bar: "bar", 557 | prefix_bar: "bar", 558 | prefix_baz: "baz" 559 | } 560 | 561 | new_assigns = Helpers.forward_assigns(assigns, prefix: :prefix, take: [:foo]) 562 | assert new_assigns == %{foo: "foo", bar: "bar", baz: "baz"} 563 | end 564 | 565 | test "only with merge option" do 566 | assigns = %{ 567 | foo: "foo", 568 | bar: "bar" 569 | } 570 | 571 | new_assigns = Helpers.forward_assigns(assigns, merge: %{hello: "world"}) 572 | assert new_assigns == %{hello: "world"} 573 | end 574 | 575 | test "with prefix & merge options" do 576 | assigns = %{ 577 | foo: "foo", 578 | bar: "bar", 579 | prefix_bar: "bar", 580 | prefix_baz: "baz" 581 | } 582 | 583 | new_assigns = Helpers.forward_assigns(assigns, prefix: :prefix, merge: %{hello: "world"}) 584 | assert new_assigns == %{bar: "bar", baz: "baz", hello: "world"} 585 | end 586 | end 587 | 588 | describe "has_errors" do 589 | test "without form it returns false" do 590 | refute Helpers.has_errors?(%{field: :foo}) 591 | end 592 | 593 | test "without field it returns false" do 594 | form = %Form{data: %{foo: "42"}, source: %{errors: []}} 595 | refute Helpers.has_errors?(%{form: form}) 596 | end 597 | 598 | test "with field & form, but no error it returns false" do 599 | form = %Form{data: %{foo: "42"}} 600 | refute Helpers.has_errors?(%{form: form, field: :foo}) 601 | end 602 | 603 | test "with field, form, and error it returns true" do 604 | form = %Form{data: %{foo: "42"}, errors: [foo: [{:some_error, "some error"}]]} 605 | assert Helpers.has_errors?(%{form: form, field: :foo}) 606 | end 607 | end 608 | end 609 | -------------------------------------------------------------------------------- /test/phx_view_helpers_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhxViewHelpersTest do 2 | @moduledoc false 3 | use ExUnit.Case, async: true 4 | alias PhxViewHelpers, as: Helpers 5 | 6 | describe "extend_form_class" do 7 | test "without class keeps the default class attribute" do 8 | opts = [c: "foo", bar: "bar"] 9 | new_opts = Helpers.extend_form_class(opts, "bg-blue-500 mt-8") 10 | assert new_opts == Keyword.put(opts, :class, "bg-blue-500 mt-8") 11 | end 12 | 13 | test "with class extends the default class attribute" do 14 | opts = [class: "!mt-8 mt-2"] 15 | new_opts = Helpers.extend_form_class(opts, "bg-blue-500 mt-8") 16 | assert new_opts == Keyword.put(opts, :class, "bg-blue-500 mt-2") 17 | end 18 | 19 | test "with class extends the default class attribute as map" do 20 | assigns = %{class: "!mt-8 mt-2"} 21 | new_assigns = Helpers.extend_form_class(assigns, "bg-blue-500 mt-8") 22 | assert new_assigns == Map.put(assigns, :class, "bg-blue-500 mt-2") 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------