` element.
```elixir
<:caption>
Posts
```
"""
slot :col,
required: true,
doc: """
For each column to render, add one `<:col>` element.
```elixir
<:col :let={post} label="Name" field={:name} col_style="width: 20%;">
<%= post.name %>
```
Any additional assigns will be added as attributes to the `` elements.
""" do
attr :label, :any, doc: "The content for the header column."
attr :field, :atom,
doc: """
The field name for sorting. If set and the field is configured as sortable
in the resource, the column header will be clickable, allowing the user to
sort by that column. If the field is not marked as sortable or if the
`field` attribute is omitted or set to `nil` or `false`, the column header
will not be clickable.
"""
attr :directions, :any,
doc: """
An optional 2-element tuple used for custom ascending and descending sort
behavior for the column, i.e. `{:asc_nils_last, :desc_nils_first}`
"""
attr :col_style, :string,
doc: """
If set, a `` element is rendered and the value of the
`col_style` assign is set as `style` attribute for the `` element of
the respective column. You can set the `width`, `background`, `border`,
and `visibility` of a column this way.
"""
attr :col_class, :string,
doc: """
If set, a `` element is rendered and the value of the
`col_class` assign is set as `class` attribute for the `` element of
the respective column. You can set the `width`, `background`, `border`,
and `visibility` of a column this way.
"""
attr :class, :string,
doc: """
Additional classes to add to the `| ` and ` | ` element. Will be merged with the
`thead_attr_attrs` and `tbody_td_attrs` attributes.
"""
attr :thead_th_attrs, :list,
doc: """
Additional attributes to pass to the ` | ` element as a static keyword
list. Note that these attributes will override any conflicting
`thead_th_attrs` that are set at the table level.
"""
attr :th_wrapper_attrs, :list,
doc: """
Additional attributes for the `` element that wraps the
header link and the order direction symbol. Note that these attributes
will override any conflicting `th_wrapper_attrs` that are set at the table
level.
"""
attr :tbody_td_attrs, :any,
doc: """
Additional attributes to pass to the ` | ` element. May be provided as a
static keyword list, or as a 1-arity function to dynamically generate the
list using row data. Note that these attributes will override any
conflicting `tbody_td_attrs` that are set at the table level.
"""
end
slot :action,
doc: """
The slot for showing user actions in the last table column. These columns
do not receive the `row_click` attribute.
```elixir
<:action :let={user}>
<.link navigate={~p"/users/\#{user}"}>Show
```
""" do
attr :label, :string, doc: "The content for the header column."
attr :show, :boolean,
doc: "Boolean value to conditionally show the column. Defaults to `true`."
attr :hide, :boolean,
doc: "Boolean value to conditionally hide the column. Defaults to `false`."
attr :col_style, :string,
doc: """
If set, a `` element is rendered and the value of the
`col_style` assign is set as `style` attribute for the `` element of
the respective column. You can set the `width`, `background`, `border`,
and `visibility` of a column this way.
"""
attr :col_class, :string,
doc: """
If set, a `` element is rendered and the value of the
`col_class` assign is set as `class` attribute for the `` element of
the respective column. You can set the `width`, `background`, `border`,
and `visibility` of a column this way.
"""
attr :class, :string,
doc: """
Additional classes to add to the `| ` and ` | ` element. Will be merged with the
`thead_attr_attrs` and `tbody_td_attrs` attributes.
"""
attr :thead_th_attrs, :list,
doc: """
Any additional attributes to pass to the ` | ` as a keyword list.
"""
attr :tbody_td_attrs, :any,
doc: """
Any additional attributes to pass to the ` | `. Can be a keyword list or
a function that takes the current row item as an argument and returns a
keyword list.
"""
end
slot :foot,
doc: """
You can optionally add a `foot`. The inner block will be rendered inside
a `tfoot` element.
<:foot>
| Total: <%= @total %> |
"""
def table_pagify(%{meta: %Meta{}, path: nil, on_sort: nil}) do
raise PathOrJSError, component: :table
end
def table_pagify(%{meta: nil} = assigns) do
assigns =
assigns
|> assign(id: Map.get(assigns, :id, "table"))
|> assign(meta: %Meta{})
|> assign(on_sort: %JS{})
table_pagify(assigns)
end
def table_pagify(%{error: true, opts: opts} = assigns) do
assigns =
assign(assigns, :opts, Table.merge_opts(opts))
~H"""
{@opts[:error_content]}
"""
end
def table_pagify(%{meta: meta, opts: opts} = assigns) do
assigns =
assigns
|> assign(:opts, Table.merge_opts(opts))
|> assign_new(:id, fn -> table_id(meta.resource) end)
~H"""
<%= if !@loading and empty?(@items) do %>
{@opts[:no_results_content]}
<% else %>
<%= if @opts[:container] do %>
"_container"} {@opts[:container_attrs]}>
<% else %>
<% end %>
<% end %>
"""
end
defp empty?(items)
defp empty?([]), do: true
defp empty?(%Phoenix.LiveView.LiveStream{inserts: [], deletes: []}), do: true
defp empty?(_), do: false
defp table_id(nil), do: "sortable_table"
defp table_id(resource) do
module_name = resource |> Module.split() |> List.last() |> Macro.underscore()
module_name <> "_table"
end
@doc """
Converts a AshPagify struct into a keyword list that can be used as a query with
Phoenix verified routes or route helper functions.
## Encoded parameters
The following parameters are encoded as strings:
- `:search`
- `:scopes`
- `:filter_form`
- `:order_by`
- `:limit`
- `:offset`
## Default parameters
Default parameters for the limit, scopes, filter_form and order parameters
are omitted. The defaults are determined by calling `AshPagify.Misc.get_option/3`.
- Pass the `:for` option to pick up the default values from an `Ash.Resource`.
- If the `Ash.Resource` has no default options set, the function will fall
back to the application environment.
## Encoding queries
To encode the returned query as a string, you will need to use
`Plug.Conn.Query.encode/1`. `URI.encode_query/1` does not support bracket
notation for arrays and maps.
## Examples
iex> to_query(%AshPagify{})
[]
iex> f = %AshPagify{offset: 40, limit: 20}
iex> to_query(f)
[limit: 20, offset: 40]
iex> f = %AshPagify{offset: 40, limit: 20}
iex> to_query(f, default_limit: 20)
[offset: 40]
iex> f = %AshPagify{order_by: [name: :asc]}
iex> to_query(f, for: AshPagify.Factory.Post)
[]
iex> f = %AshPagify{scopes: %{status: :active}}
iex> to_query(f, for: AshPagify.Factory.Post)
[scopes: %{status: :active}]
iex> f = %AshPagify{search: "foo"}
iex> to_query(f, for: AshPagify.Factory.Post)
[search: "foo"]
Encoding the query as a string:
iex> f = %AshPagify{order_by: [name: :desc, age: :asc]}
iex> to_query(f)
[order_by: ["-name", "age"]]
iex> f |> to_query |> Plug.Conn.Query.encode()
"order_by[]=-name&order_by[]=age"
iex> f = %AshPagify{filter_form: %{"field" => "comments_count", "operator" => "gt", "value" => 2}}
iex> to_query(f)
[filter_form: %{"field" => "comments_count", "operator" => "gt", "value" => 2}]
iex> f |> to_query |> Plug.Conn.Query.encode()
"filter_form[field]=comments_count&filter_form[operator]=gt&filter_form[value]=2"
iex> f = %AshPagify{scopes: %{status: :active}}
iex> to_query(f)
[scopes: %{status: :active}]
iex> f |> to_query |> Plug.Conn.Query.encode()
"scopes[status]=active"
iex> f = %AshPagify{search: "foo"}
iex> to_query(f)
[search: "foo"]
iex> f |> to_query |> Plug.Conn.Query.encode()
"search=foo"
"""
@spec to_query(AshPagify.t(), Keyword.t()) :: Keyword.t()
def to_query(%AshPagify{} = ash_pagify, opts \\ []) do
default_limit = Misc.get_option(:default_limit, opts)
default_order = :default_order |> Misc.get_option(opts, nil) |> AshPagify.concat_sort()
current_order = AshPagify.concat_sort(ash_pagify.order_by)
[]
|> Misc.maybe_put(:offset, ash_pagify.offset, 0)
|> Misc.maybe_put(:limit, ash_pagify.limit, default_limit)
|> Misc.maybe_put(:order_by, current_order, default_order)
|> Misc.maybe_put(:filter_form, ash_pagify.filter_form)
|> Misc.maybe_put(:search, ash_pagify.search)
|> Misc.maybe_put_scopes(ash_pagify, opts)
end
@doc """
Builds a path that includes query parameters for the given `AshPagify` struct
using the referenced Components path helper function.
The first argument can be either one of:
- an MFA tuple (module, function name as atom, arguments)
- a 2-tuple (function, arguments)
- a URL string, usually produced with a verified route (e.g. `~p"/some/path"`)
- a function that takes the AshPagify parameters as a keyword list as an argument
Default values for `scopes`, `limit` and `order_by` are omitted from the query parameters.
To pick up the default parameters from an `Ash.Resource`, you need to pass the
`:for` option. If you pass a `AshPagify.Meta` struct as the second argument,
these options are retrieved from the struct automatically.
## Examples
### With a verified route
The examples below use plain URL strings without the p-sigil, so that the
doc tests work, but in your application, you can use verified routes or
anything else that produces a URL.
iex> ash_pagify = %AshPagify{offset: 20, limit: 10}
iex> path = build_path("/posts", ash_pagify)
iex> %URI{path: parsed_path, query: parsed_query} = URI.parse(path)
iex> {parsed_path, URI.decode_query(parsed_query)}
{"/posts", %{"offset" => "20", "limit" => "10"}}
The AshPagify query parameters will be merged into existing query parameters.
iex> ash_pagify = %AshPagify{offset: 20, limit: 10}
iex> path = build_path("/posts?category=A", ash_pagify)
iex> %URI{path: parsed_path, query: parsed_query} = URI.parse(path)
iex> {parsed_path, URI.decode_query(parsed_query)}
{"/posts", %{"offset" => "20", "limit" => "10", "category" => "A"}}
### With an MFA tuple
iex> ash_pagify = %AshPagify{offset: 20, limit: 10}
iex> build_path(
...> {AshPagify.ComponentsTest, :route_helper, [%Plug.Conn{}, :posts]},
...> ash_pagify
...> )
"/posts?limit=10&offset=20"
### With a function/arguments tuple
iex> post_path = fn _conn, :index, query ->
...> "/posts?" <> Plug.Conn.Query.encode(query)
...> end
iex> ash_pagify = %AshPagify{offset: 20, limit: 10}
iex> build_path({post_path, [%Plug.Conn{}, :index]}, ash_pagify)
"/posts?limit=10&offset=20"
We're defining fake path helpers for the scope of the doctests. In a real
Phoenix application, you would pass something like
`{Routes, :post_path, args}` or `{&Routes.post_path/3, args}` as the
first argument.
### Passing a `AshPagify.Meta` struct or a keyword list
You can also pass a `AshPagify.Meta` struct or a keyword list as the third
argument.
iex> post_path = fn _conn, :index, query ->
...> "/posts?" <> Plug.Conn.Query.encode(query)
...> end
iex> ash_pagify = %AshPagify{offset: 20, limit: 10}
iex> meta = %AshPagify.Meta{ash_pagify: ash_pagify, resource: AshPagify.Factory.Post}
iex> build_path({post_path, [%Plug.Conn{}, :index]}, meta)
"/posts?limit=10&offset=20"
iex> query_params = to_query(ash_pagify)
iex> build_path({post_path, [%Plug.Conn{}, :index]}, query_params)
"/posts?limit=10&offset=20"
### Additional path parameters
If the path helper takes additional path parameters, just add them to the
second argument.
iex> user_post_path = fn _conn, :index, id, query ->
...> "/users/\#{id}/posts?" <> Plug.Conn.Query.encode(query)
...> end
iex> ash_pagify = %AshPagify{offset: 20, limit: 10}
iex> build_path({user_post_path, [%Plug.Conn{}, :index, 123]}, ash_pagify)
"/users/123/posts?limit=10&offset=20"
### Additional query parameters
If the last path helper argument is a query parameter list, the AshPagify
parameters are merged into it.
iex> post_url = fn _conn, :index, query ->
...> "https://posts.ash_pagify/posts?" <> Plug.Conn.Query.encode(query)
...> end
iex> ash_pagify = %AshPagify{order_by: [name: :desc]}
iex> build_path({post_url, [%Plug.Conn{}, :index, [user_id: 123]]}, ash_pagify)
"https://posts.ash_pagify/posts?user_id=123&order_by[]=-name"
iex> build_path(
...> {post_url,
...> [%Plug.Conn{}, :index, [category: "small", user_id: 123]]},
...> ash_pagify
...> )
"https://posts.ash_pagify/posts?category=small&user_id=123&order_by[]=-name"
### Set page as path parameter
Finally, you can also pass a function that takes the AshPagify parameters as
a keyword list as an argument. Default values will not be included in the
parameters passed to the function. You can use this if you need to set some
of the parameters as path parameters instead of query parameters.
iex> ash_pagify = %AshPagify{offset: 20, limit: 10}
iex> build_path(
...> fn params ->
...> {offset, params} = Keyword.pop(params, :offset)
...> query = Plug.Conn.Query.encode(params)
...> if offset, do: "/posts/page/\#{offset}?\#{query}", else: "/posts?\#{query}"
...> end,
...> ash_pagify
...> )
"/posts/page/20?limit=10"
Note that in this example, the anonymous function just returns a string. With
Phoenix 1.7, you will be able to use verified routes.
build_path(
fn params ->
{offset, query} = Keyword.pop(params, :offset)
if offset, do: ~p"/posts/page/\#{offset}?\#{query}", else: ~p"/posts?\#{query}"
end,
ash_pagify
)
Note that the keyword list passed to the path builder function is built using
`Plug.Conn.Query.encode/2`, which means filter_forms are formatted as maps.
### Set filter_form value as path parameter
iex> ash_pagify = %AshPagify{
...> offset: 20,
...> order_by: [:updated_at],
...> filter_form: %{
...> "field" => "author",
...> "operator" => "eq",
...> "value" => "John"
...> }
...> }
iex> build_path(
...> fn params ->
...> {offset, params} = Keyword.pop(params, :offset)
...> filter_form = Keyword.get(params, :filter_form, %{})
...> author = Map.get(filter_form, "value", nil)
...> params = Keyword.put(params, :filter_form, %{})
...> query = Plug.Conn.Query.encode(params)
...>
...> case {offset, author} do
...> {nil, nil} -> "/posts?\#{query}"
...> {offset, nil} -> "/posts/page/\#{offset}?\#{query}"
...> {nil, author} -> "/posts/author/\#{author}?\#{query}"
...> {offset, author} -> "/posts/author/\#{author}/page/\#{offset}?\#{query}"
...> end
...> end,
...> ash_pagify
...> )
"/posts/author/John/page/20?order_by[]=updated_at"
### If only path is set
If only the path is set, it is returned as is.
iex> build_path("/posts", nil)
"/posts"
"""
@spec build_path(pagination_path(), Meta.t() | AshPagify.t() | Keyword.t(), Keyword.t()) ::
String.t()
def build_path(path, meta_or_ash_pagify_or_params, opts \\ [])
def build_path(
path,
%Meta{ash_pagify: ash_pagify, resource: resource, default_scopes: default_scopes},
opts
)
when is_atom(resource) and resource != nil do
opts =
opts
|> Keyword.put(:for, resource)
|> Keyword.put(:default_scopes, default_scopes)
build_path(path, ash_pagify, opts)
end
def build_path(path, %AshPagify{} = ash_pagify, opts) do
build_path(path, to_query(ash_pagify, opts))
end
def build_path({module, func, args}, ash_pagify_params, _opts)
when is_atom(module) and is_atom(func) and is_list(args) and is_list(ash_pagify_params) do
final_args = build_final_args(args, ash_pagify_params)
apply(module, func, final_args)
end
def build_path({func, args}, ash_pagify_params, _opts)
when is_function(func) and is_list(args) and is_list(ash_pagify_params) do
final_args = build_final_args(args, ash_pagify_params)
apply(func, final_args)
end
def build_path(func, ash_pagify_params, _opts)
when is_function(func, 1) and is_list(ash_pagify_params) do
func.(ash_pagify_params)
end
def build_path(uri, ash_pagify_params, _opts)
when is_binary(uri) and is_list(ash_pagify_params) do
ash_pagify_params_map = Map.new(ash_pagify_params)
build_path(uri, ash_pagify_params_map)
end
def build_path(uri, ash_pagify_params, _opts)
when is_binary(uri) and is_map(ash_pagify_params) do
uri = URI.parse(uri)
query =
(uri.query || "")
|> Query.decode()
|> Map.merge(Misc.remove_nil_values(ash_pagify_params))
query = if query != %{}, do: Query.encode(query)
uri
|> Map.put(:query, query)
|> URI.to_string()
end
def build_path(uri, nil, _opts) when is_binary(uri) do
uri
end
defp build_final_args(args, ash_pagify_params) do
case Enum.reverse(args) do
[last_arg | rest] when is_list(last_arg) ->
query_arg = Keyword.merge(last_arg, ash_pagify_params)
Enum.reverse([query_arg | rest])
_ ->
args ++ [ash_pagify_params]
end
end
@doc """
Wrapper around `build_path/3` that builds a path with the updated scope.
Examples
iex> ash_pagify = %AshPagify{offset: 20, limit: 10}
iex> meta = %AshPagify.Meta{ash_pagify: ash_pagify, resource: AshPagify.Factory.Post}
iex> build_scope_path("/posts", meta, %{status: :active})
"/posts?limit=10&scopes[status]=active"
"""
@spec build_scope_path(pagination_path(), Meta.t() | nil, map(), Keyword.t()) :: String.t()
def build_scope_path(path, meta, scope, opts \\ [])
def build_scope_path(
path,
%Meta{ash_pagify: ash_pagify, resource: resource, default_scopes: default_scopes},
scope,
opts
)
when is_atom(resource) and resource != nil do
opts =
opts
|> Keyword.put(:for, resource)
|> Keyword.put(:default_scopes, default_scopes)
ash_pagify = AshPagify.set_scope(ash_pagify, scope)
build_path(path, ash_pagify, opts)
end
def build_scope_path(path, nil, scope, opts) do
build_path(path, [scopes: scope], opts)
end
end
| | |