├── .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 | [](https://github.com/cblavier/phx_component_helpers/actions/workflows/elixir.yml)
4 | [](https://codecov.io/gh/cblavier/phx_component_helpers)
5 | [](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 |
--------------------------------------------------------------------------------