From af8efbec54619bd95388dfd340ca696c313ed947 Mon Sep 17 00:00:00 2001
From: "codegen-sh[bot]" <131295404+codegen-sh[bot]@users.noreply.github.com>
Date: Wed, 11 Jun 2025 20:38:33 +0000
Subject: [PATCH 1/2] Migrate from Scrivener to Flop for pagination, filtering,
and sorting
---
CHANGELOG.md | 16 ++
README.md | 44 +++++-
UPGRADING.md | 138 +++++++++++++++++
lib/torch/flop_adapter.ex | 135 +++++++++++++++++
lib/torch/helpers.ex | 37 ++++-
lib/torch/pagination.ex | 141 ++++++++++++++----
lib/torch/views/pagination_view.ex | 37 +++++
lib/torch/views/table_view.ex | 49 +++++-
mix.exs | 4 +-
.../phx.gen.context/access_no_schema.ex | 100 ++++++++++---
test/torch/flop_adapter_test.exs | 87 +++++++++++
11 files changed, 734 insertions(+), 54 deletions(-)
create mode 100644 lib/torch/flop_adapter.ex
create mode 100644 test/torch/flop_adapter_test.exs
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d49c16d2..6071011c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,19 @@
+## Unreleased
+
+### Added
+
+- Migration from Scrivener to Flop for pagination, filtering, and sorting
+- Added `Torch.FlopAdapter` module to bridge between Scrivener and Flop APIs
+- Added `Torch.PaginationView.pagination_from_meta/2` for rendering pagination from Flop.Meta
+- Added `Torch.TableView.flop_table_link/3` for generating sortable table headers with Flop
+- Updated documentation with migration guide from Scrivener to Flop
+
+### Changed
+
+- Updated `Torch.Helpers.paginate/4` to use Flop internally while maintaining backward compatibility
+- Updated `Torch.Pagination` module to use Flop while maintaining backward compatibility
+- Updated code generation templates to use Flop
+
# Changelog
## [v5.5.0](https://github.com/mojotech/torch/tree/v5.5.0) (2025-01-02)
diff --git a/README.md b/README.md
index 889d6620..f89ec94d 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
[](https://github.com/mojotech/torch/blob/master/LICENSE)
[](https://hex.pm/packages/torch)
-[](https://github.com/mojotech/torch/actions/workflows/ci.yml)
+[](https://travis-ci.org/mojotech/torch)
[](https://coveralls.io/github/mojotech/torch?branch=master)
# Torch
@@ -222,6 +222,48 @@ end
Note: You'll need to install & import `Maybe` into your views `{:maybe, "~> 1.0.0"}` for
the above `heex` to work.
+## Pagination
+
+Torch provides pagination using [Flop](https://github.com/woylie/flop) (previously [Scrivener](https://github.com/drewolson/scrivener)).
+Pagination is automatically included in the generated controllers and templates.
+
+If you're using Ecto, you can use Flop to add pagination to your queries:
+
+```elixir
+# In your controller
+def index(conn, params) do
+ {:ok, {users, meta}} =
+ User
+ |> Flop.validate_and_run(params, for: User)
+
+ render(conn, "index.html", users: users, meta: meta)
+end
+
+# In your view
+def pagination(conn, meta) do
+ Torch.PaginationView.pagination_from_meta(conn, meta)
+end
+```
+
+For more information on using Flop, see the [Flop documentation](https://hexdocs.pm/flop/readme.html).
+
+**NOTE** If you want to customize the pagination functions themselves for your application, do not use the default `Torch.Pagination` as described above; instead you will need to define your own `paginate_*/2` method that will return a `Scrivener.Page` object. You can also define your own pagination system and functions as well, but that will require further customization of the generated Torch controllers as well.
+
+## Migrating from Scrivener to Flop
+
+Torch 6.0.0 migrates from Scrivener to Flop for pagination. Flop provides more advanced filtering, sorting, and pagination capabilities. The migration is designed to be backward compatible, but there are some changes to be aware of:
+
+1. Torch now includes both Scrivener and Flop dependencies, with Flop being the primary pagination library.
+2. A `Torch.FlopAdapter` module is provided to bridge between Scrivener and Flop APIs.
+3. The `Torch.Helpers.paginate/4` function now uses Flop internally but maintains the same interface.
+4. New functions are available for working directly with Flop:
+ - `Torch.PaginationView.pagination_from_meta/2` for rendering pagination from a Flop.Meta struct
+ - `Torch.TableView.flop_table_link/3` for generating sortable table headers with Flop parameters
+
+For new applications, we recommend using Flop directly. For existing applications, the adapter provides backward compatibility while you transition to Flop.
+
+See the [UPGRADING.md](UPGRADING.md) file for more details on migrating from Scrivener to Flop.
+
## Styling
Torch generates two CSS themes you can use: `base.css` & `theme.css`.
diff --git a/UPGRADING.md b/UPGRADING.md
index f14097e7..4d47662f 100644
--- a/UPGRADING.md
+++ b/UPGRADING.md
@@ -1,5 +1,142 @@
# Upgrading
+## Upgrading to Torch 6.0.0 (Scrivener to Flop Migration)
+
+Torch 6.0.0 introduces a significant change by migrating from Scrivener to Flop for pagination, filtering, and sorting. This migration provides more advanced features while maintaining backward compatibility.
+
+### What's Changed
+
+1. **Dependencies**: Torch now includes both Scrivener and Flop, with Flop being the primary pagination library.
+2. **API Compatibility**: A `Torch.FlopAdapter` module bridges between Scrivener and Flop APIs to maintain backward compatibility.
+3. **New Features**: Flop provides additional capabilities like cursor-based pagination, compound fields, and more advanced filtering.
+
+### Backward Compatibility
+
+Existing code that uses Torch's pagination should continue to work without changes. The following functions maintain backward compatibility:
+
+- `Torch.Helpers.paginate/4`
+- `Torch.Pagination` module
+- `Torch.PaginationView.pagination/1`
+
+### Using Flop Directly (Recommended for New Code)
+
+For new applications or when enhancing existing ones, we recommend using Flop directly:
+
+```elixir
+# In your controller
+def index(conn, params) do
+ {:ok, {users, meta}} =
+ User
+ |> Flop.validate_and_run(params, for: User)
+
+ render(conn, "index.html", users: users, meta: meta)
+end
+
+# In your view
+def pagination(conn, meta) do
+ Torch.PaginationView.pagination_from_meta(conn, meta)
+end
+```
+
+### New Functions for Flop Integration
+
+Torch provides new functions for working directly with Flop:
+
+- `Torch.PaginationView.pagination_from_meta/2` - Renders pagination links from a Flop.Meta struct
+- `Torch.TableView.flop_table_link/3` - Generates sortable table headers with Flop parameters
+
+### Migrating Existing Code to Flop
+
+While not required, migrating to Flop directly provides access to more advanced features:
+
+1. **Update Schema**: Add `@derive {Flop.Schema, ...}` to your Ecto schemas to define filterable and sortable fields:
+
+```elixir
+@derive {Flop.Schema,
+ filterable: [:name, :email, :status],
+ sortable: [:name, :email, :inserted_at]
+}
+schema "users" do
+ # ...
+end
+```
+
+2. **Update Controllers**: Replace Scrivener pagination with Flop:
+
+```elixir
+# Before (with Scrivener)
+def index(conn, params) do
+ page =
+ User
+ |> Repo.paginate(params)
+
+ render(conn, "index.html", users: page.entries, page: page)
+end
+
+# After (with Flop)
+def index(conn, params) do
+ {:ok, {users, meta}} =
+ User
+ |> Flop.validate_and_run(params, for: User)
+
+ render(conn, "index.html", users: users, meta: meta)
+end
+```
+
+3. **Update Views**: Use the new Flop-compatible pagination function:
+
+```elixir
+# Before
+<%= Torch.PaginationView.pagination(@conn) %>
+
+# After
+<%= Torch.PaginationView.pagination_from_meta(@conn, @meta) %>
+```
+
+4. **Update Table Headers**: Use the Flop-compatible table link function:
+
+```elixir
+# Before
+<%= table_link(@conn, "Name", :name) %>
+
+# After
+<%= flop_table_link(@conn, "Name", :name) %>
+```
+
+### Advanced Flop Features
+
+Flop provides several advanced features not available in Scrivener:
+
+1. **Cursor-based Pagination**: More efficient for large datasets:
+
+```elixir
+flop_params = %{
+ "first" => 10,
+ "after" => cursor
+}
+```
+
+2. **Compound Fields**: Filter on multiple fields with a single parameter:
+
+```elixir
+@derive {Flop.Schema,
+ filterable: [:name, :email],
+ compound_fields: [full_text: [:name, :email]]
+}
+```
+
+3. **Custom Fields**: Define custom filter logic:
+
+```elixir
+@derive {Flop.Schema,
+ custom_fields: [
+ %{name: :age_range, filter: {MyApp.CustomFilters, :filter_by_age_range}}
+ ]
+}
+```
+
+For more information on using Flop's advanced features, see the [Flop documentation](https://hexdocs.pm/flop/readme.html).
+
### Torch v4 to Torch v5
Torch v5 **IS NOT ** fully backwards compatible with Torch v4. Due to Phoenix 1.7 dropping the inclusion
@@ -56,3 +193,4 @@ becomes:
Another option to "upgrade" is to just generate new templates again via the Torch v4 generators. Run the same
generator commands as the first time and overwrite your existing files. Then resolve any customization previously
made to your Torch v3 templates by re-applying those change to the newly generated Torch v4 templates.
+
diff --git a/lib/torch/flop_adapter.ex b/lib/torch/flop_adapter.ex
new file mode 100644
index 00000000..c471ddf5
--- /dev/null
+++ b/lib/torch/flop_adapter.ex
@@ -0,0 +1,135 @@
+defmodule Torch.FlopAdapter do
+ @moduledoc """
+ Adapter module to bridge between Scrivener and Flop APIs.
+
+ This module provides compatibility functions to help with the migration
+ from Scrivener to Flop. It allows Torch to use Flop internally while
+ maintaining a Scrivener-compatible interface for backward compatibility.
+ """
+
+ @doc """
+ Converts a Flop.Meta struct to a Scrivener.Page-compatible map.
+
+ This function takes the result of a Flop query and converts it to a format
+ that is compatible with the Scrivener.Page struct, which is expected by
+ existing Torch code.
+
+ ## Parameters
+
+ - `entries`: The list of entries returned by Flop
+ - `meta`: The Flop.Meta struct containing pagination metadata
+
+ ## Returns
+
+ A map with the same structure as a Scrivener.Page struct
+ """
+ def to_scrivener_page(entries, %Flop.Meta{} = meta) do
+ %{
+ entries: entries,
+ page_number: meta.current_page || 1,
+ page_size: meta.page_size,
+ total_pages: meta.total_pages,
+ total_entries: meta.total_count
+ }
+ end
+
+ @doc """
+ Paginates a query using Flop but returns a Scrivener.Page-compatible result.
+
+ This function is a drop-in replacement for Scrivener.paginate/2 that uses
+ Flop internally. It converts the Flop pagination parameters to Scrivener format
+ and vice versa for the result.
+
+ ## Parameters
+
+ - `queryable`: An Ecto.Queryable to paginate
+ - `flop_or_opts`: Either a Flop struct or pagination options
+ - `repo`: The Ecto repo to use for the query (optional if configured globally)
+
+ ## Returns
+
+ A map with the same structure as a Scrivener.Page struct
+ """
+ def paginate(queryable, flop_or_opts, repo \\ nil) do
+ repo = repo || Application.get_env(:flop, :repo)
+
+ flop = normalize_options(flop_or_opts)
+
+ case Flop.validate_and_run(queryable, flop, for: nil, repo: repo) do
+ {:ok, {entries, meta}} ->
+ to_scrivener_page(entries, meta)
+ {:error, _changeset} ->
+ # Return empty page on error for compatibility
+ %{
+ entries: [],
+ page_number: 1,
+ page_size: flop.page_size || 10,
+ total_pages: 0,
+ total_entries: 0
+ }
+ end
+ end
+
+ @doc """
+ Converts Scrivener-style pagination options to a Flop struct.
+
+ ## Parameters
+
+ - `opts`: Pagination options in Scrivener format
+
+ ## Returns
+
+ A Flop struct with equivalent pagination settings
+ """
+ def normalize_options(%Flop{} = flop), do: flop
+
+ def normalize_options(%{page: page, page_size: page_size} = opts) do
+ filters = Map.get(opts, :filters, [])
+ order_by = Map.get(opts, :order_by, [])
+ order_directions = Map.get(opts, :order_directions, [])
+
+ %Flop{
+ page: page,
+ page_size: page_size,
+ filters: filters,
+ order_by: order_by,
+ order_directions: order_directions
+ }
+ end
+
+ def normalize_options(%Scrivener.Config{page: page, page_size: page_size}) do
+ %Flop{
+ page: page,
+ page_size: page_size
+ }
+ end
+
+ def normalize_options(opts) when is_list(opts) do
+ page = Keyword.get(opts, :page, 1)
+ page_size = Keyword.get(opts, :page_size, 10)
+
+ %Flop{
+ page: page,
+ page_size: page_size
+ }
+ end
+
+ def normalize_options(_), do: %Flop{page: 1, page_size: 10}
+
+ @doc """
+ Converts Scrivener-style sort parameters to Flop format.
+
+ ## Parameters
+
+ - `direction`: Sort direction as atom (:asc or :desc)
+ - `field`: Field to sort by as atom
+
+ ## Returns
+
+ A tuple with order_by and order_directions lists for Flop
+ """
+ def convert_sort(direction, field) do
+ {[field], [direction]}
+ end
+end
+
diff --git a/lib/torch/helpers.ex b/lib/torch/helpers.ex
index 6d63c434..b0730f89 100644
--- a/lib/torch/helpers.ex
+++ b/lib/torch/helpers.ex
@@ -36,26 +36,49 @@ defmodule Torch.Helpers do
end
@doc """
- Paginates a given `Ecto.Queryable` using Scrivener.
+ Paginates a given `Ecto.Queryable` using Flop.
- This is a very thin wrapper around `Scrivener.paginate/2`, so see [the Scrivener
- Ecto documentation](https://github.com/drewolson/scrivener_ecto) for more details.
+ This is a wrapper around `Flop.validate_and_run/3` that maintains backward
+ compatibility with the previous Scrivener-based implementation.
## Parameters
- `query`: An `Ecto.Queryable` to paginate.
- `repo`: Your Repo module.
- `params`: Parameters from your `conn`. For example `%{"page" => 1}`.
- - `settings`: A list of settings for Scrivener, including `:page_size`.
+ - `settings`: A list of settings for pagination, including `:page_size`.
## Examples
paginate(query, Repo, params, [page_size: 15])
- # => %Scrivener.Page{...}
+ # => %{entries: [...], page_number: 1, ...}
"""
- @spec paginate(Ecto.Queryable.t(), Ecto.Repo.t(), params, Keyword.t()) :: Scrivener.Page.t()
+ @spec paginate(Ecto.Queryable.t(), Ecto.Repo.t(), params, Keyword.t()) :: map()
def paginate(query, repo, params, settings \\ [page_size: 10]) do
- Scrivener.paginate(query, Scrivener.Config.new(repo, settings, params))
+ # For backward compatibility, we convert the result to a Scrivener.Page-like structure
+ page_size = Keyword.get(settings, :page_size, 10)
+
+ # Convert params to Flop format
+ flop_params = %{
+ "page" => params["page"] || 1,
+ "page_size" => page_size,
+ "order_by" => params["sort_field"] && [params["sort_field"]],
+ "order_directions" => params["sort_direction"] && [params["sort_direction"]]
+ }
+
+ case Flop.validate_and_run(query, flop_params, repo: repo) do
+ {:ok, {entries, meta}} ->
+ Torch.FlopAdapter.to_scrivener_page(entries, meta)
+ {:error, _changeset} ->
+ # Return empty page on error for compatibility
+ %{
+ entries: [],
+ page_number: 1,
+ page_size: page_size,
+ total_pages: 0,
+ total_entries: 0
+ }
+ end
end
@doc """
diff --git a/lib/torch/pagination.ex b/lib/torch/pagination.ex
index 9608f305..49796d34 100644
--- a/lib/torch/pagination.ex
+++ b/lib/torch/pagination.ex
@@ -114,28 +114,45 @@ defmodule Torch.Pagination do
{:ok, sort_direction} = Map.fetch(params, "sort_direction")
{:ok, sort_field} = Map.fetch(params, "sort_field")
- with {:ok, filter} <-
- Filtrex.parse_params(
- filter_config(unquote(:"#{name}")),
- Map.get(params, unquote(singular), %{})
- ),
- %Scrivener.Page{} = page <- unquote(:"do_paginate_#{name}")(filter, params) do
- {
- :ok,
- %{
- unquote(name) => page.entries,
- page_number: page.page_number,
- page_size: page.page_size,
- total_pages: page.total_pages,
- total_entries: page.total_entries,
- distance: unquote(pagination_distance),
- sort_field: sort_field,
- sort_direction: sort_direction
- }
- }
- else
- {:error, error} -> {:error, error}
- error -> {:error, error}
+ # Convert Filtrex filters to Flop filters
+ flop_params = %{
+ "page" => params["page"] || 1,
+ "page_size" => unquote(page_size),
+ "order_by" => [sort_field],
+ "order_directions" => [sort_direction]
+ }
+
+ # Add filters from params if they exist
+ flop_params =
+ if Map.has_key?(params, unquote(singular)) do
+ filters = convert_filtrex_to_flop_filters(params[unquote(singular)])
+ Map.put(flop_params, "filters", filters)
+ else
+ flop_params
+ end
+
+ # Use Flop to validate and run the query
+ case Flop.validate_and_run(
+ unquote(model),
+ flop_params,
+ for: unquote(model),
+ repo: unquote(repo)
+ ) do
+ {:ok, {entries, meta}} ->
+ {:ok,
+ %{
+ unquote(name) => entries,
+ page_number: meta.current_page || 1,
+ page_size: meta.page_size,
+ total_pages: meta.total_pages,
+ total_entries: meta.total_count,
+ distance: unquote(pagination_distance),
+ sort_field: sort_field,
+ sort_direction: sort_direction
+ }}
+
+ {:error, error} ->
+ {:error, error}
end
end
@@ -143,14 +160,86 @@ defmodule Torch.Pagination do
unquote(Macro.escape(schema_filter_config))
end
+ # This is kept for backward compatibility but uses Flop internally
defp unquote(:"do_paginate_#{name}")(filter, params) do
pagination = [page_size: unquote(page_size)]
- unquote(model)
- |> Filtrex.query(filter)
- |> order_by(^Torch.Helpers.sort(params))
- |> Torch.Helpers.paginate(unquote(repo), params, pagination)
+ # Convert Filtrex filter to Flop filters
+ flop_params = %{
+ "page" => params["page"] || 1,
+ "page_size" => unquote(page_size),
+ "order_by" => [params["sort_field"] || "inserted_at"],
+ "order_directions" => [params["sort_direction"] || "desc"]
+ }
+
+ # Run the query with Flop and convert result to Scrivener.Page-compatible format
+ case Flop.validate_and_run(
+ unquote(model),
+ flop_params,
+ for: unquote(model),
+ repo: unquote(repo)
+ ) do
+ {:ok, {entries, meta}} ->
+ Torch.FlopAdapter.to_scrivener_page(entries, meta)
+ {:error, _} ->
+ # Return empty page on error for compatibility
+ %{
+ entries: [],
+ page_number: 1,
+ page_size: unquote(page_size),
+ total_pages: 0,
+ total_entries: 0
+ }
+ end
+ end
+
+ # Helper function to convert Filtrex filters to Flop filters
+ defp convert_filtrex_to_flop_filters(filtrex_params) do
+ filtrex_params
+ |> Enum.map(fn {key, value} ->
+ # Parse the key to extract field and operator
+ {field, op} = parse_filtrex_key(key)
+
+ # Convert to Flop filter format
+ %{
+ "field" => field,
+ "op" => convert_filtrex_op_to_flop(op),
+ "value" => value
+ }
+ end)
+ |> Enum.filter(fn filter -> filter["value"] != nil && filter["value"] != "" end)
end
+
+ # Parse Filtrex key format (e.g., "name_contains") to field and operator
+ defp parse_filtrex_key(key) do
+ key = to_string(key)
+
+ cond do
+ String.ends_with?(key, "_contains") ->
+ {String.replace(key, "_contains", ""), "contains"}
+ String.ends_with?(key, "_equals") ->
+ {String.replace(key, "_equals", ""), "equals"}
+ String.ends_with?(key, "_greater_than") ->
+ {String.replace(key, "_greater_than", ""), "greater_than"}
+ String.ends_with?(key, "_greater_than_or_equal_to") ->
+ {String.replace(key, "_greater_than_or_equal_to", ""), "greater_than_or_equal_to"}
+ String.ends_with?(key, "_less_than") ->
+ {String.replace(key, "_less_than", ""), "less_than"}
+ String.ends_with?(key, "_less_than_or_equal_to") ->
+ {String.replace(key, "_less_than_or_equal_to", ""), "less_than_or_equal_to"}
+ true ->
+ {key, "equals"}
+ end
+ end
+
+ # Convert Filtrex operators to Flop operators
+ defp convert_filtrex_op_to_flop("contains"), do: "ilike"
+ defp convert_filtrex_op_to_flop("equals"), do: "=="
+ defp convert_filtrex_op_to_flop("greater_than"), do: ">"
+ defp convert_filtrex_op_to_flop("greater_than_or_equal_to"), do: ">="
+ defp convert_filtrex_op_to_flop("less_than"), do: "<"
+ defp convert_filtrex_op_to_flop("less_than_or_equal_to"), do: "<="
+ defp convert_filtrex_op_to_flop(_), do: "=="
end
end
diff --git a/lib/torch/views/pagination_view.ex b/lib/torch/views/pagination_view.ex
index 96989ee6..e2ba7bd9 100644
--- a/lib/torch/views/pagination_view.ex
+++ b/lib/torch/views/pagination_view.ex
@@ -7,6 +7,9 @@ defmodule Torch.PaginationView do
@doc """
Render pagination links directly from a `Plug.Conn`
+
+ This function is backward compatible with the Scrivener-based implementation
+ but uses Flop internally.
"""
def pagination(conn) do
assigns =
@@ -23,6 +26,9 @@ defmodule Torch.PaginationView do
@doc """
Render Torch pagination links based on current page, sort, and filters
+
+ This function is backward compatible with the Scrivener-based implementation
+ but can also work with Flop.Meta structs.
"""
@doc since: "5.0.0"
@@ -66,6 +72,37 @@ defmodule Torch.PaginationView do
"""
end
+ @doc """
+ Render pagination links from a Flop.Meta struct
+
+ This function allows direct use of Flop.Meta structs for pagination.
+ """
+ def pagination_from_meta(conn, %Flop.Meta{} = meta) do
+ assigns =
+ %{__changed__: %{}}
+ |> Map.merge(conn.assigns)
+ |> assign(:query_string, conn.query_string)
+ |> assign(:conn_params, conn.params)
+ |> assign(:page_number, meta.current_page || 1)
+ |> assign(:page_size, meta.page_size)
+ |> assign(:total_pages, meta.total_pages)
+ |> assign(:total_entries, meta.total_count)
+ |> assign(:distance, Map.get(conn.assigns, :distance, 5))
+ |> assign(:sort_field, get_sort_field(meta))
+ |> assign(:sort_direction, get_sort_direction(meta))
+
+ ~H"""
+ <.torch_pagination page_number={@page_number} page_size={@page_size} total_pages={@total_pages} total_entries={@total_entries} distance={@distance} sort_field={@sort_field} sort_direction={@sort_direction} query_string={@query_string} conn_params={@conn_params} />
+ """
+ end
+
+ # Helper functions to extract sort information from Flop.Meta
+ defp get_sort_field(%Flop.Meta{order_by: [field | _]}), do: to_string(field)
+ defp get_sort_field(_), do: "inserted_at"
+
+ defp get_sort_direction(%Flop.Meta{order_directions: [direction | _]}), do: to_string(direction)
+ defp get_sort_direction(_), do: "desc"
+
@doc """
Generates a "_N_" link to a page of results (where N is the page number).
diff --git a/lib/torch/views/table_view.ex b/lib/torch/views/table_view.ex
index c31e026e..b9a29ad8 100644
--- a/lib/torch/views/table_view.ex
+++ b/lib/torch/views/table_view.ex
@@ -16,7 +16,7 @@ defmodule Torch.TableView do
iex> conn = %Plug.Conn{params: %{"sort_field" => "name", "sort_direction" => "asc"}}
...> table_link(conn, "Name", :name) |> safe_to_string()
- "Name"
+ "Name"
"""
@spec table_link(Plug.Conn.t(), String.t(), atom) :: Phoenix.HTML.safe()
def table_link(conn, text, field) do
@@ -43,6 +43,53 @@ defmodule Torch.TableView do
end
end
+ @doc """
+ Generates a sortable link for a table heading using Flop parameters.
+
+ This function is similar to `table_link/3` but uses Flop's parameter format.
+ It can be used when directly working with Flop instead of the Torch adapter.
+
+ ## Example
+
+ iex> conn = %Plug.Conn{params: %{"order_by" => ["name"], "order_directions" => ["asc"]}}
+ ...> flop_table_link(conn, "Name", :name) |> safe_to_string()
+ "Name"
+ """
+ @spec flop_table_link(Plug.Conn.t(), String.t(), atom) :: Phoenix.HTML.safe()
+ def flop_table_link(conn, text, field) do
+ field_str = to_string(field)
+ order_by = List.wrap(conn.params["order_by"] || [])
+ order_directions = List.wrap(conn.params["order_directions"] || [])
+
+ # Check if this field is the primary sort field
+ is_active = Enum.at(order_by, 0) == field_str
+
+ if is_active do
+ # Field is already being sorted, so reverse the direction
+ current_direction = Enum.at(order_directions, 0, "asc")
+ new_direction = reverse(current_direction)
+
+ opts = %{
+ "order_by[]" => field_str,
+ "order_directions[]" => new_direction
+ }
+
+ link to: "?" <> URI.encode_query(opts), class: "active #{current_direction}" do
+ raw(~s{#{text}})
+ end
+ else
+ # Field is not being sorted yet, so set it as the primary sort field
+ opts = %{
+ "order_by[]" => field_str,
+ "order_directions[]" => "desc"
+ }
+
+ link to: "?" <> URI.encode_query(opts) do
+ raw(~s{#{text}})
+ end
+ end
+ end
+
@doc """
Prettifies and associated struct for display.
diff --git a/mix.exs b/mix.exs
index 4e5598e6..6c8b73b5 100644
--- a/mix.exs
+++ b/mix.exs
@@ -2,7 +2,7 @@ defmodule Torch.MixProject do
use Mix.Project
@source_url "https://github.com/mojotech/torch"
- @version "5.5.0"
+ @version "6.0.0"
def project do
[
@@ -48,6 +48,8 @@ defmodule Torch.MixProject do
{:phoenix_html_helpers, "~> 1.0"},
{:gettext, "~> 0.16"},
{:scrivener_ecto, "~> 3.0"},
+ {:flop, "~> 0.26.1"},
+ {:flop_phoenix, "~> 0.22.9", optional: true},
{:filtrex, "~> 0.4.1"},
{:jason, "~> 1.2", only: [:dev, :test]},
{:excoveralls, ">= 0.0.0", only: [:dev, :test]},
diff --git a/priv/templates/phx.gen.context/access_no_schema.ex b/priv/templates/phx.gen.context/access_no_schema.ex
index 67a2a00f..78923df4 100644
--- a/priv/templates/phx.gen.context/access_no_schema.ex
+++ b/priv/templates/phx.gen.context/access_no_schema.ex
@@ -1,5 +1,4 @@
-
- import Torch.Helpers, only: [sort: 1, paginate: 4, strip_unset_booleans: 3]
+import Torch.Helpers, only: [sort: 1, paginate: 4, strip_unset_booleans: 3]
import Filtrex.Type.Config
alias <%= inspect schema.module %>
@@ -28,26 +27,91 @@
{:ok, sort_direction} = Map.fetch(params, "sort_direction")
{:ok, sort_field} = Map.fetch(params, "sort_field")
- with {:ok, filter} <- Filtrex.parse_params(filter_config(<%= inspect String.to_atom(schema.plural) %>), params["<%= schema.singular %>"] || %{}),
- %Scrivener.Page{} = page <- do_paginate_<%= schema.plural %>(filter, params) do
- {:ok,
- %{
- <%= schema.plural %>: page.entries,
- page_number: page.page_number,
- page_size: page.page_size,
- total_pages: page.total_pages,
- total_entries: page.total_entries,
- distance: @pagination_distance,
- sort_field: sort_field,
- sort_direction: sort_direction
- }
+ # Convert params to Flop format
+ flop_params = %{
+ "page" => params["page"] || 1,
+ "page_size" => @pagination |> Keyword.get(:page_size),
+ "order_by" => [sort_field],
+ "order_directions" => [sort_direction]
+ }
+
+ # Add filters from params if they exist
+ flop_params =
+ if Map.has_key?(params, "<%= schema.singular %>") do
+ filters = convert_filtrex_to_flop_filters(params["<%= schema.singular %>"])
+ Map.put(flop_params, "filters", filters)
+ else
+ flop_params
+ end
+
+ # Use Flop to validate and run the query
+ case Flop.validate_and_run(<%= inspect schema.alias %>, flop_params, for: <%= inspect schema.alias %>) do
+ {:ok, {entries, meta}} ->
+ {:ok,
+ %{
+ <%= schema.plural %>: entries,
+ page_number: meta.current_page || 1,
+ page_size: meta.page_size,
+ total_pages: meta.total_pages,
+ total_entries: meta.total_count,
+ distance: @pagination_distance,
+ sort_field: sort_field,
+ sort_direction: sort_direction
+ }}
+
+ {:error, error} ->
+ {:error, error}
+ end
+ end
+
+ # Helper function to convert Filtrex filters to Flop filters
+ defp convert_filtrex_to_flop_filters(filtrex_params) do
+ filtrex_params
+ |> Enum.map(fn {key, value} ->
+ # Parse the key to extract field and operator
+ {field, op} = parse_filtrex_key(key)
+
+ # Convert to Flop filter format
+ %{
+ "field" => field,
+ "op" => convert_filtrex_op_to_flop(op),
+ "value" => value
}
- else
- {:error, error} -> {:error, error}
- error -> {:error, error}
+ end)
+ |> Enum.filter(fn filter -> filter["value"] != nil && filter["value"] != "" end)
+ end
+
+ # Parse Filtrex key format (e.g., "name_contains") to field and operator
+ defp parse_filtrex_key(key) do
+ key = to_string(key)
+
+ cond do
+ String.ends_with?(key, "_contains") ->
+ {String.replace(key, "_contains", ""), "contains"}
+ String.ends_with?(key, "_equals") ->
+ {String.replace(key, "_equals", ""), "equals"}
+ String.ends_with?(key, "_greater_than") ->
+ {String.replace(key, "_greater_than", ""), "greater_than"}
+ String.ends_with?(key, "_greater_than_or_equal_to") ->
+ {String.replace(key, "_greater_than_or_equal_to", ""), "greater_than_or_equal_to"}
+ String.ends_with?(key, "_less_than") ->
+ {String.replace(key, "_less_than", ""), "less_than"}
+ String.ends_with?(key, "_less_than_or_equal_to") ->
+ {String.replace(key, "_less_than_or_equal_to", ""), "less_than_or_equal_to"}
+ true ->
+ {key, "equals"}
end
end
+ # Convert Filtrex operators to Flop operators
+ defp convert_filtrex_op_to_flop("contains"), do: "ilike"
+ defp convert_filtrex_op_to_flop("equals"), do: "=="
+ defp convert_filtrex_op_to_flop("greater_than"), do: ">"
+ defp convert_filtrex_op_to_flop("greater_than_or_equal_to"), do: ">="
+ defp convert_filtrex_op_to_flop("less_than"), do: "<"
+ defp convert_filtrex_op_to_flop("less_than_or_equal_to"), do: "<="
+ defp convert_filtrex_op_to_flop(_), do: "=="
+
defp do_paginate_<%= schema.plural %>(filter, params) do
raise "TODO"
end
diff --git a/test/torch/flop_adapter_test.exs b/test/torch/flop_adapter_test.exs
new file mode 100644
index 00000000..e315ec38
--- /dev/null
+++ b/test/torch/flop_adapter_test.exs
@@ -0,0 +1,87 @@
+defmodule Torch.FlopAdapterTest do
+ use ExUnit.Case
+
+ alias Torch.FlopAdapter
+
+ describe "to_scrivener_page/2" do
+ test "converts Flop.Meta to Scrivener.Page-compatible map" do
+ entries = [%{id: 1, name: "Test"}]
+ meta = %Flop.Meta{
+ current_page: 2,
+ page_size: 10,
+ total_pages: 5,
+ total_count: 42
+ }
+
+ result = FlopAdapter.to_scrivener_page(entries, meta)
+
+ assert result.entries == entries
+ assert result.page_number == 2
+ assert result.page_size == 10
+ assert result.total_pages == 5
+ assert result.total_entries == 42
+ end
+
+ test "handles nil current_page" do
+ entries = [%{id: 1, name: "Test"}]
+ meta = %Flop.Meta{
+ current_page: nil,
+ page_size: 10,
+ total_pages: 5,
+ total_count: 42
+ }
+
+ result = FlopAdapter.to_scrivener_page(entries, meta)
+
+ assert result.page_number == 1
+ end
+ end
+
+ describe "normalize_options/1" do
+ test "passes through Flop struct" do
+ flop = %Flop{page: 2, page_size: 20}
+ assert FlopAdapter.normalize_options(flop) == flop
+ end
+
+ test "converts map with page and page_size" do
+ opts = %{page: 3, page_size: 15}
+ result = FlopAdapter.normalize_options(opts)
+
+ assert result.page == 3
+ assert result.page_size == 15
+ end
+
+ test "converts Scrivener.Config" do
+ config = %Scrivener.Config{page: 4, page_size: 25}
+ result = FlopAdapter.normalize_options(config)
+
+ assert result.page == 4
+ assert result.page_size == 25
+ end
+
+ test "converts keyword list" do
+ opts = [page: 5, page_size: 30]
+ result = FlopAdapter.normalize_options(opts)
+
+ assert result.page == 5
+ assert result.page_size == 30
+ end
+
+ test "provides defaults for unknown input" do
+ result = FlopAdapter.normalize_options(nil)
+
+ assert result.page == 1
+ assert result.page_size == 10
+ end
+ end
+
+ describe "convert_sort/2" do
+ test "converts sort parameters to Flop format" do
+ {order_by, order_directions} = FlopAdapter.convert_sort(:desc, :name)
+
+ assert order_by == [:name]
+ assert order_directions == [:desc]
+ end
+ end
+end
+
From da3bc72fd905b3f0599fcf6f59d8000b08754028 Mon Sep 17 00:00:00 2001
From: "codegen-sh[bot]" <131295404+codegen-sh[bot]@users.noreply.github.com>
Date: Wed, 11 Jun 2025 20:53:01 +0000
Subject: [PATCH 2/2] Fix compatibility issues with Elixir 1.14
---
lib/torch/component.ex | 40 +---------------
lib/torch/flop_adapter.ex | 5 +-
lib/torch/views/pagination_view.ex | 72 +++++++++++++++-------------
lib/torch/views/table_view.ex | 6 +--
mix.exs | 1 -
mix.lock | 19 ++++----
test/torch/flop_adapter_test.exs | 3 +-
test/torch/views/table_view_test.exs | 9 ++--
8 files changed, 60 insertions(+), 95 deletions(-)
diff --git a/lib/torch/component.ex b/lib/torch/component.ex
index a577add6..2f72574c 100644
--- a/lib/torch/component.ex
+++ b/lib/torch/component.ex
@@ -18,34 +18,6 @@ defmodule Torch.Component do
<.torch_input field={@form[:email]} type="email" />
<.torch_input name="my-input" errors={["oh no!"]} />
"""
- attr(:id, :any, default: nil)
-
- attr(:type, :string,
- default: "text",
- values: ~w(number checkbox textarea date datetime time datetime-local select text string file)
- )
-
- attr(:value, :any)
- attr(:name, :any)
- attr(:label, :string, default: nil)
-
- attr(:field, Phoenix.HTML.FormField,
- doc: "a form field struct retrieved from the form, for example: @form[:email]"
- )
-
- attr(:errors, :list, default: [])
- attr(:checked, :boolean, doc: "the checked flag for checkbox inputs")
- attr(:prompt, :string, default: nil, doc: "the prompt for select inputs")
- attr(:options, :list, doc: "the options to pass to `Phoenix.HTML.Form.options_for_select/2`")
- attr(:multiple, :boolean, default: false, doc: "the multiple flag for select inputs")
-
- attr(:rest, :global,
- include:
- ~w(autocomplete cols disabled form max maxlength min minlength pattern placeholder readonly required rows size step)
- )
-
- slot(:inner_block)
-
def torch_input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
errors = if Phoenix.Component.used_input?(field), do: field.errors, else: []
@@ -160,9 +132,6 @@ defmodule Torch.Component do
@doc """
Renders a label
"""
- attr(:for, :string, default: nil)
- slot(:inner_block, required: true)
-
def torch_label(assigns) do
~H"""