From 9aaf1f4527dddde817b713a8a1f614d45afe5785 Mon Sep 17 00:00:00 2001 From: Phil-Bastian Berndt Date: Fri, 8 Nov 2024 18:00:38 +0100 Subject: [PATCH 1/9] introduce option validation for all backpex fields --- lib/backpex/adapters/ecto.ex | 2 +- lib/backpex/field.ex | 113 ++++++++++- lib/backpex/fields/belongs_to.ex | 47 ++++- lib/backpex/fields/boolean.ex | 20 +- lib/backpex/fields/currency.ex | 22 ++- lib/backpex/fields/date.ex | 31 ++- lib/backpex/fields/date_time.ex | 31 ++- lib/backpex/fields/has_many.ex | 82 +++++--- lib/backpex/fields/has_many_through.ex | 58 ++++-- lib/backpex/fields/inline_crud.ex | 32 ++- lib/backpex/fields/multi_select.ex | 34 ++-- lib/backpex/fields/number.ex | 26 ++- lib/backpex/fields/select.ex | 21 +- lib/backpex/fields/text.ex | 25 ++- lib/backpex/fields/textarea.ex | 17 +- lib/backpex/fields/upload.ex | 262 ++++++++++++++----------- lib/backpex/fields/url.ex | 25 ++- lib/backpex/live_resource.ex | 22 ++- lib/backpex_web.ex | 1 - 19 files changed, 634 insertions(+), 237 deletions(-) diff --git a/lib/backpex/adapters/ecto.ex b/lib/backpex/adapters/ecto.ex index c93a1cf7..5df382e5 100644 --- a/lib/backpex/adapters/ecto.ex +++ b/lib/backpex/adapters/ecto.ex @@ -343,7 +343,7 @@ defmodule Backpex.Adapters.Ecto do end defp record_query(id, schema, item_query, live_resource) do - fields = live_resource.fields() + fields = live_resource.validated_fields() schema_name = name_by_schema(schema) primary_key = live_resource.config(:primary_key) primary_type = schema.__schema__(:type, primary_key) diff --git a/lib/backpex/field.ex b/lib/backpex/field.ex index d2ef4dac..1925bee4 100644 --- a/lib/backpex/field.ex +++ b/lib/backpex/field.ex @@ -1,9 +1,85 @@ # credo:disable-for-this-file Credo.Check.Refactor.CyclomaticComplexity defmodule Backpex.Field do - @moduledoc ~S''' + @config_schema [ + module: [ + doc: "The field module.", + type: :atom, + required: true + ], + label: [ + doc: "The field label.", + type: :string, + required: true + ], + render: [ + doc: "", + type: {:fun, 1} + ], + custom_alias: [ + doc: "", + type: :atom + ], + align: [ + doc: "", + type: :atom + ], + searchable: [ + doc: "", + type: :boolean + ], + orderable: [ + doc: "", + type: :boolean + ], + visible: [ + doc: "Function to change the visibility of a field. Receives the assigns and has to return a boolean.", + type: {:fun, 1} + ], + panel: [ + doc: "", + type: :atom + ], + index_editable: [ + doc: "", + type: :boolean + ], + index_column_class: [ + doc: "", + type: :string + ], + select: [ + doc: "", + type: {:struct, Ecto.Query.DynamicExpr} + ], + only: [ + doc: "", + type: {:list, :atom} + ], + except: [ + doc: "", + type: {:list, :atom} + ], + translate_error: [ + doc: """ + Function to customize error messages for a field. The function receives the error tuple and must return a tuple + with the message and metadata. + """, + type: {:fun, 1} + ] + ] + + @moduledoc """ Behaviour implemented by all fields. - A field defines how a column is rendered on index, show and edit views. In the resource configuration file you can configure a list of fields. You may create your own field by implementing this behaviour. A field has to be a [LiveComponent](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveComponent.html). + A field defines how a column is rendered on index, show and edit views. In the resource configuration file you can + configure a list of fields. You may create your own field by implementing this behaviour. A field has to be a + [LiveComponent](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveComponent.html). + + ### Options + + These are general field options which can be used on every field. Check the field modules for field-specific options. + + #{NimbleOptions.docs(@config_schema)} ### Example @@ -15,7 +91,7 @@ defmodule Backpex.Field do } ] end - ''' + """ import Phoenix.Component, only: [assign: 3] @doc """ @@ -97,13 +173,40 @@ defmodule Backpex.Field do @optional_callbacks render_form_readonly: 1, render_index_form: 1 + @doc """ + Returns the default config schema. + """ + def default_config_schema(), do: @config_schema + @doc """ Defines `Backpex.Field` behaviour and provides default implementations. """ - defmacro __using__(_) do - quote do + defmacro __using__(opts) do + quote bind_quoted: [opts: opts] do + @config_schema opts[:config_schema] || [] + @before_compile Backpex.Field @behaviour Backpex.Field + + use BackpexWeb, :field + + def validate_config!({name, options} = _field, live_resource) do + field_options = Keyword.new(options) + + dbg(field_options) + + case NimbleOptions.validate(field_options, Backpex.Field.default_config_schema() ++ @config_schema) do + {:ok, validated_options} -> + validated_options + + {:error, error} -> + raise """ + Configuration error for field "#{name}" in "#{live_resource}". + + #{error.message} + """ + end + end end end diff --git a/lib/backpex/fields/belongs_to.ex b/lib/backpex/fields/belongs_to.ex index 32853c37..c92561c9 100644 --- a/lib/backpex/fields/belongs_to.ex +++ b/lib/backpex/fields/belongs_to.ex @@ -1,15 +1,44 @@ defmodule Backpex.Fields.BelongsTo do + @config_schema [ + display_field: [ + doc: "The field of the relation to be used for searching, ordering and displaying values.", + type: :atom, + required: true + ], + display_field_form: [ + doc: "Field to be used to display form values.", + type: :atom + ], + live_resource: [ + doc: "The live resource of the association. Used to generate links navigating to the association.", + type: :atom + ], + source: [ + doc: "", + type: :atom + ], + options_query: [ + doc: """ + Manipulates the list of available options in the select. + + Defaults to `fn (query, _field) -> query end` which returns all entries. + """, + type: {:fun, 2} + ], + prompt: [ + doc: "The text to be displayed when no option is selected or function that receives the assigns.", + type: :string + ] + ] + @moduledoc """ A field for handling a `belongs_to` relation. - ## Options + ## Field-specific options - * `:display_field` - The field of the relation to be used for searching, ordering and displaying values. - * `:display_field_form` - Optional field to be used to display form values. - * `:live_resource` - The live resource of the association. Used to generate links navigating to the association. - * `:options_query` - Manipulates the list of available options in the select. - Defaults to `fn (query, _field) -> query end` which returns all entries. - * `:prompt` - The text to be displayed when no option is selected or function that receives the assigns. + See `Backpex.Field` for general field options. + + #{NimbleOptions.docs(@config_schema)} ## Example @@ -26,10 +55,8 @@ defmodule Backpex.Fields.BelongsTo do ] end """ - use BackpexWeb, :field - + use Backpex.Field, config_schema: @config_schema import Ecto.Query - alias Backpex.Router @impl Phoenix.LiveComponent diff --git a/lib/backpex/fields/boolean.ex b/lib/backpex/fields/boolean.ex index 77680363..7d3c6499 100644 --- a/lib/backpex/fields/boolean.ex +++ b/lib/backpex/fields/boolean.ex @@ -1,13 +1,25 @@ defmodule Backpex.Fields.Boolean do + @config_schema [ + debounce: [ + doc: "Timeout value (in milliseconds), \"blur\" or function that receives the assigns.", + type: {:or, [:pos_integer, :string, {:fun, 1}]} + ], + throttle: [ + doc: "Timeout value (in milliseconds) or function that receives the assigns.", + type: {:or, [:pos_integer, {:fun, 1}]} + ] + ] + @moduledoc """ A field for handling a boolean value. - ## Options + ## Field-specific options + + See `Backpex.Field` for general field options. - * `:debounce` - Optional integer timeout value (in milliseconds), "blur" or function that receives the assigns. - * `:throttle` - Optional integer timeout value (in milliseconds) or function that receives the assigns. + #{NimbleOptions.docs(@config_schema)} """ - use BackpexWeb, :field + use Backpex.Field, config_schema: @config_schema @impl Backpex.Field def render_value(assigns) do diff --git a/lib/backpex/fields/currency.ex b/lib/backpex/fields/currency.ex index bfff0e6a..5ca9690b 100644 --- a/lib/backpex/fields/currency.ex +++ b/lib/backpex/fields/currency.ex @@ -1,11 +1,23 @@ defmodule Backpex.Fields.Currency do + @config_schema [ + debounce: [ + doc: "Timeout value (in milliseconds), \"blur\" or function that receives the assigns.", + type: {:or, [:pos_integer, :string, {:fun, 1}]} + ], + throttle: [ + doc: "Timeout value (in milliseconds) or function that receives the assigns.", + type: {:or, [:pos_integer, {:fun, 1}]} + ] + ] + @moduledoc """ A field for handling a currency value. - ## Options + ## Field-specific options + + See `Backpex.Field` for general field options. - * `:debounce` - Optional integer timeout value (in milliseconds), "blur" or function that receives the assigns. - * `:throttle` - Optional integer timeout value (in milliseconds) or function that receives the assigns. + #{NimbleOptions.docs(@config_schema)} ## Schema @@ -34,10 +46,8 @@ defmodule Backpex.Fields.Currency do ] end """ - use BackpexWeb, :field - + use Backpex.Field import Ecto.Query - alias Backpex.Ecto.Amount.Type @impl Backpex.Field diff --git a/lib/backpex/fields/date.ex b/lib/backpex/fields/date.ex index eca388c0..592f2042 100644 --- a/lib/backpex/fields/date.ex +++ b/lib/backpex/fields/date.ex @@ -2,16 +2,35 @@ defmodule Backpex.Fields.Date do @default_format "%Y-%m-%d" + @config_schema [ + format: [ + doc: """ + Format string which will be used to format the date time value or function that formats the date time. + + Can also be a function wich receives a `DateTime` and must return a string. + """, + type: {:or, [:string, {:fun, 1}]}, + default: @default_format + ], + debounce: [ + doc: "Timeout value (in milliseconds), \"blur\" or function that receives the assigns.", + type: {:or, [:pos_integer, :string, {:fun, 1}]} + ], + throttle: [ + doc: "Timeout value (in milliseconds) or function that receives the assigns.", + type: {:or, [:pos_integer, {:fun, 1}]} + ] + ] + # credo:disable-for-next-line Credo.Check.Readability.StrictModuleLayout @moduledoc """ A field for handling a date value. - ## Options + ## Field-specific options + + See `Backpex.Field` for general field options. - * `:format` - Format string which will be used to format the date value or function that formats the date. - Defaults to `#{@default_format}`. If a function, must receive a `Date` and return a string. - * `:debounce` - Optional integer timeout value (in milliseconds), "blur" or function that receives the assigns. - * `:throttle` - Optional integer timeout value (in milliseconds) or function that receives the assigns. + #{NimbleOptions.docs(@config_schema)} ## Examples @@ -53,7 +72,7 @@ defmodule Backpex.Fields.Date do ] end """ - use BackpexWeb, :field + use Backpex.Field @impl Backpex.Field def render_value(assigns) do diff --git a/lib/backpex/fields/date_time.ex b/lib/backpex/fields/date_time.ex index e3a73380..5331b2f2 100644 --- a/lib/backpex/fields/date_time.ex +++ b/lib/backpex/fields/date_time.ex @@ -2,16 +2,35 @@ defmodule Backpex.Fields.DateTime do @default_format "%Y-%m-%d %I:%M %p" + @config_schema [ + format: [ + doc: """ + Format string which will be used to format the date time value or function that formats the date time. + + Can also be a function wich receives a `DateTime` and must return a string. + """, + type: {:or, [:string, {:fun, 1}]}, + default: @default_format + ], + debounce: [ + doc: "Timeout value (in milliseconds), \"blur\" or function that receives the assigns.", + type: {:or, [:pos_integer, :string, {:fun, 1}]} + ], + throttle: [ + doc: "Timeout value (in milliseconds) or function that receives the assigns.", + type: {:or, [:pos_integer, {:fun, 1}]} + ] + ] + # credo:disable-for-next-line Credo.Check.Readability.StrictModuleLayout @moduledoc """ A field for handling a date time value. - ## Options + ## Field-specific options + + See `Backpex.Field` for general field options. - * `:format` - Format string which will be used to format the date time value or function that formats the date time. - Defaults to `#{@default_format}`. If a function, must receive a `DateTime` and return a string. - * `:debounce` - Optional integer timeout value (in milliseconds), "blur" or function that receives the assigns. - * `:throttle` - Optional integer timeout value (in milliseconds) or function that receives the assigns. + #{NimbleOptions.docs(@config_schema)} ## Examples @@ -53,7 +72,7 @@ defmodule Backpex.Fields.DateTime do ] end """ - use BackpexWeb, :field + use Backpex.Field @impl Backpex.Field def render_value(assigns) do diff --git a/lib/backpex/fields/has_many.ex b/lib/backpex/fields/has_many.ex index 3c9f1422..eac715f7 100644 --- a/lib/backpex/fields/has_many.ex +++ b/lib/backpex/fields/has_many.ex @@ -1,24 +1,58 @@ defmodule Backpex.Fields.HasMany do + @config_schema [ + display_field: [ + doc: "The field of the relation to be used for searching, ordering and displaying values.", + type: :atom, + required: true + ], + display_field_form: [ + doc: "The field to be used to display form values.", + type: :atom + ], + live_resource: [ + doc: "The live resource of the association.", + type: :atom + ], + link_assocs: [ + doc: "Whether to automatically generate links to the association items.", + type: :boolean, + default: true + ], + options_query: [ + doc: """ + Manipulates the list of available options in the multi select. + + Defaults to `fn (query, _field) -> query end` which returns all entries. + """, + type: {:fun, 2} + ], + prompt: [ + doc: "The text to be displayed when no options are selected or function that receives the assigns.", + type: :string, + default: Backpex.translate("Select options...") + ], + not_found_text: [ + doc: "The text to be displayed when no options are found.", + type: :string, + default: Backpex.translate("No options found") + ], + query_limit: [ + doc: "Limit passed to the query to fetch new items. Set to `nil` to have no limit.", + type: {:or, [:non_neg_integer, nil]}, + default: 10 + ] + ] + @moduledoc """ A field for handling a `has_many` or `many_to_many` relation. This field can not be orderable or searchable. - ## Options + ## Field-specific options - * `:display_field` - The field of the relation to be used for searching, ordering and displaying values. - * `:display_field_form` - Optional field to be used to display form values. - * `:live_resource` - The live resource of the association. - * `:link_assocs` - Whether to automatically generate links to the association items. - Defaults to true. - * `:options_query` - Manipulates the list of available options in the multi select. - Defaults to `fn (query, _field) -> query end` which returns all entries. - * `:prompt` - The text to be displayed when no options are selected or function that receives the assigns. - Defaults to "Select options...". - * `:not_found_text` - The text to be displayed when no options are found. - Defaults to "No options found". - * `:query_limit` - Optional limit passed to the query to fetch new items. Set to `nil` to have no limit. - Defaults to 10. + See `Backpex.Field` for general field options. + + #{NimbleOptions.docs(@config_schema)} ## Example @@ -35,7 +69,7 @@ defmodule Backpex.Fields.HasMany do ] end """ - use BackpexWeb, :field + use Backpex.Field, config_schema: @config_schema import Ecto.Query import Backpex.HTML.Form alias Backpex.Adapters.Ecto, as: EctoAdapter @@ -52,7 +86,7 @@ defmodule Backpex.Fields.HasMany do end defp apply_action(socket, :index) do - assign_new(socket, :link_assocs, fn -> link_assocs(socket.assigns.field_options) end) + assign_new(socket, :link_assocs, fn -> socket.assigns.field_options[:link_assocs] end) end defp apply_action(socket, :form) do @@ -60,7 +94,7 @@ defmodule Backpex.Fields.HasMany do socket |> assign_new(:prompt, fn -> prompt(assigns, field_options) end) - |> assign_new(:not_found_text, fn -> not_found_text(field_options) end) + |> assign_new(:not_found_text, fn -> field_options[:not_found_text] end) |> assign_new(:search_input, fn -> "" end) |> assign_new(:offset, fn -> 0 end) |> assign_new(:options_count, fn -> count_options(assigns) end) @@ -232,7 +266,7 @@ defmodule Backpex.Fields.HasMany do socket = socket - |> assign(:offset, query_limit(field_options) + offset) + |> assign(:offset, field_options[:query_limit] + offset) |> assign_options(options) {:noreply, socket} @@ -356,8 +390,7 @@ defmodule Backpex.Fields.HasMany do defp assign_options(socket, other_options \\ []) do %{assigns: %{field_options: field_options, search_input: search_input, offset: offset} = assigns} = socket - - limit = query_limit(field_options) + limit = field_options[:query_limit] options = other_options ++ options(assigns, offset: offset, limit: limit, search: search_input) @@ -505,22 +538,13 @@ defmodule Backpex.Fields.HasMany do assign(socket, :errors, translate_form_errors(form[name], translate_error_fun)) end - defp query_limit(field_options), do: Map.get(field_options, :query_limit, 10) - defp display_field_form({_name, field_options} = field), do: Map.get(field_options, :display_field_form, display_field(field)) defp prompt(assigns, field_options) do case Map.get(field_options, :prompt) do - nil -> Backpex.translate("Select options...") prompt when is_function(prompt) -> prompt.(assigns) prompt -> prompt end end - - defp not_found_text(%{not_found_text: not_found_text} = _field_options), do: not_found_text - defp not_found_text(_field_options), do: Backpex.translate("No options found") - - defp link_assocs(%{link_assocs: link_assocs} = _field_options) when is_boolean(link_assocs), do: link_assocs - defp link_assocs(_field_options), do: true end diff --git a/lib/backpex/fields/has_many_through.ex b/lib/backpex/fields/has_many_through.ex index 71c24a2a..2ef6ede3 100644 --- a/lib/backpex/fields/has_many_through.ex +++ b/lib/backpex/fields/has_many_through.ex @@ -1,4 +1,42 @@ defmodule Backpex.Fields.HasManyThrough do + @config_schema [ + display_field: [ + doc: "The field of the relation to be used for displaying options in the select.", + type: :atom, + required: true + ], + live_resource: [ + doc: """ + The corresponding live resource of the association. Used to display the title of the modal and generate defaults + for `:child_fields` fields. + """, + type: :atom, + required: true + ], + sort_by: [ + doc: """ + A list of columns by which the child element output will be sorted. The sorting takes place in ascending order. + """, + type: {:list, :atom} + ], + child_fields: [ + doc: "WIP", + type: :keyword_list + ], + pivot_fields: [ + doc: "List to map additional data of the pivot table to Backpex fields.", + type: :keyword_list + ], + options_query: [ + doc: """ + Manipulates the list of available options in the select. Can be used to select additional data for the `display_field` option or to limit the available entries.", + + Defaults to `fn (query, _field) -> query end` which returns all entries. + """, + type: {:fun, 2} + ] + ] + @moduledoc """ A field for handling a `has_many` (`through`) relation. @@ -8,15 +46,11 @@ defmodule Backpex.Fields.HasManyThrough do > > This field is in beta state. Use at your own risk. - ## Options + ## Field-specific options - * `:display_field` - The field of the relation to be used for displaying options in the select. - * `:live_resource` - The corresponding live resource of the association. Used to display the title of the modal and generate defaults for `:child_fields` fields. - * `:sort_by` - A list of columns by which the child element output will be sorted. The sorting takes place in ascending order. - * `:child_fields` - WIP - * `:pivot_fields` - List to map additional data of the pivot table to Backpex fields. - * `:options_query` - Manipulates the list of available options in the select. Can be used to select additional data for the `display_field` option or to limit the available entries. - Defaults to `fn (query, _field) -> query end` which returns all entries. + See `Backpex.Field` for general field options. + + #{NimbleOptions.docs(@config_schema)} ## Example @@ -42,13 +76,10 @@ defmodule Backpex.Fields.HasManyThrough do The field requires a [`Ecto.Schema.has_many/3`](https://hexdocs.pm/ecto/Ecto.Schema.html#has_many/3) relation with a mandatory `through` option in the main schema. Any extra column in the pivot table besides the relational id's must be mapped in the `pivot_fields` option or given a default value. """ - - use BackpexWeb, :field - + use Backpex.Field, config_schema: @config_schema import Ecto.Query import Backpex.HTML.Layout, only: [modal: 1] import PhoenixHTMLHelpers.Form, only: [hidden_inputs_for: 1] - alias Backpex.LiveResource alias Ecto.Changeset @@ -420,7 +451,8 @@ defmodule Backpex.Fields.HasManyThrough do assigns false -> - new_field_options = Map.put(assigns.field_options, :child_fields, assigns.field_options.live_resource.fields()) + fields = assigns.field_options.live_resource.validated_fields() + new_field_options = Map.put(assigns.field_options, :child_fields, fields) assigns |> assign(:field, {assigns.name, new_field_options}) diff --git a/lib/backpex/fields/inline_crud.ex b/lib/backpex/fields/inline_crud.ex index d50b8350..0d1a7807 100644 --- a/lib/backpex/fields/inline_crud.ex +++ b/lib/backpex/fields/inline_crud.ex @@ -1,16 +1,32 @@ defmodule Backpex.Fields.InlineCRUD do + @config_schema [ + type: [ + doc: "The type of the field. Either `:embed` or `:assoc`.", + type: :atom, + required: true + ], + child_fields: [ + doc: """ + A list of input fields to be used. Currently only support `Backpex.Fields.Text` fields. + + You can add additional classes to child field inputs by setting the class option in the list of `child_fields`. + The class can be a string or a function that takes the assigns and must return a string. In addition, you can + optionally specify the input type of child field inputs with the `input_type` option. We currently support `:text` + and `:textarea`. The `input_type` defaults to `:text`. + """, + type: :keyword_list, + required: true + ] + ] + @moduledoc """ A field to handle inline CRUD operations. It can be used with either an `embeds_many` or `has_many` (association) type column. - ## Options + ## Field-specific options - * `:type` - The type of the field. Either `:embed` or `:assoc`. - * `:child_fields` - A list of input fields to be used. Currently only support `Backpex.Fields.Text` fields. + See `Backpex.Field` for general field options. - You can add additional classes to child field inputs by setting the class option in the list of `child_fields`. - The class can be a string or a function that takes the assigns and must return a string. - In addition, you can optionally specify the input type of child field inputs with the `input_type` option. We currently - support `:text` and `:textarea`. The `input_type` defaults to `:text`. + #{NimbleOptions.docs(@config_schema)} > #### Important {: .info} > @@ -69,7 +85,7 @@ defmodule Backpex.Fields.InlineCRUD do ] end """ - use BackpexWeb, :field + use Backpex.Field, config_schema: @config_schema @impl Phoenix.LiveComponent def update(assigns, socket) do diff --git a/lib/backpex/fields/multi_select.ex b/lib/backpex/fields/multi_select.ex index 9cf0de87..c5c9d6a4 100644 --- a/lib/backpex/fields/multi_select.ex +++ b/lib/backpex/fields/multi_select.ex @@ -1,16 +1,31 @@ defmodule Backpex.Fields.MultiSelect do + @config_schema [ + options: [ + doc: "List of options or function that receives the assigns.", + type: {:or, [:keyword_list, {:fun, 1}]}, + required: true + ], + prompt: [ + doc: "The text to be displayed when no option is selected or function that receives the assigns.", + type: :string + ], + not_found_text: [ + doc: "The text to be displayed when no options are found.", + type: :string, + default: Backpex.translate("No options found") + ] + ] + @moduledoc """ A field for handling a multi select with predefined options. This field can not be searchable. - ## Options + ## Field-specific options + + See `Backpex.Field` for general field options. - * `:options` - Required (keyword) list of options to be used for the select. - * `:prompt` - The text to be displayed when no options are selected or function that receives the assigns. - Defaults to "Select options...". - * `:not_found_text` - The text to be displayed when no options are found. - Defaults to "No options found". + #{NimbleOptions.docs(@config_schema)} ## Example @@ -24,7 +39,7 @@ defmodule Backpex.Fields.MultiSelect do }, ] """ - use BackpexWeb, :field + use Backpex.Field, config_schema: @config_schema import Backpex.HTML.Form @@ -33,7 +48,7 @@ defmodule Backpex.Fields.MultiSelect do socket = socket |> assign(assigns) - |> assign(:not_found_text, not_found_text(assigns.field_options)) + |> assign(:not_found_text, assigns.field_options[:not_found_text]) |> assign(:prompt, prompt(assigns, assigns.field_options)) |> assign(:search_input, "") |> assign_options() @@ -216,9 +231,6 @@ defmodule Backpex.Fields.MultiSelect do end end - defp not_found_text(%{not_found_text: not_found_text} = _field_options), do: not_found_text - defp not_found_text(_field_options), do: Backpex.translate("No options found") - defp prompt(assigns, field_options) do case Map.get(field_options, :prompt) do nil -> Backpex.translate("Select options...") diff --git a/lib/backpex/fields/number.ex b/lib/backpex/fields/number.ex index 7e73b24a..67a5e2d1 100644 --- a/lib/backpex/fields/number.ex +++ b/lib/backpex/fields/number.ex @@ -1,15 +1,29 @@ defmodule Backpex.Fields.Number do + @config_schema [ + placeholder: [ + doc: "Placeholder value or function that receives the assigns.", + type: {:or, [:string, {:fun, 1}]} + ], + debounce: [ + doc: "Timeout value (in milliseconds), \"blur\" or function that receives the assigns.", + type: {:or, [:pos_integer, :string, {:fun, 1}]} + ], + throttle: [ + doc: "Timeout value (in milliseconds) or function that receives the assigns.", + type: {:or, [:pos_integer, {:fun, 1}]} + ] + ] + @moduledoc """ A field for handling a number value. - ## Options + ## Field-specific options - * `:placeholder` - Optional placeholder value or function that receives the assigns. - * `:debounce` - Optional integer timeout value (in milliseconds), "blur" or function that receives the assigns. - * `:throttle` - Optional integer timeout value (in milliseconds) or function that receives the assigns. - """ - use BackpexWeb, :field + See `Backpex.Field` for general field options. + #{NimbleOptions.docs(@config_schema)} + """ + use Backpex.Field import Ecto.Query @impl Backpex.Field diff --git a/lib/backpex/fields/select.ex b/lib/backpex/fields/select.ex index 17ec32d5..c8b52e79 100644 --- a/lib/backpex/fields/select.ex +++ b/lib/backpex/fields/select.ex @@ -1,11 +1,24 @@ defmodule Backpex.Fields.Select do + @config_schema [ + options: [ + doc: "List of options or function that receives the assigns.", + type: {:or, [:keyword_list, {:fun, 1}]}, + required: true + ], + prompt: [ + doc: "The text to be displayed when no option is selected or function that receives the assigns.", + type: :string + ] + ] + @moduledoc """ A field for handling a select value. - ## Options + ## Field-specific options + + See `Backpex.Field` for general field options. - * `:options` - Required (keyword) list of options or function that receives the assigns. - * `:prompt` - The text to be displayed when no option is selected or function that receives the assigns. + #{NimbleOptions.docs(@config_schema)} ## Example @@ -20,7 +33,7 @@ defmodule Backpex.Fields.Select do ] end """ - use BackpexWeb, :field + use Backpex.Field, config_schema: @config_schema @impl Backpex.Field def render_value(assigns) do diff --git a/lib/backpex/fields/text.ex b/lib/backpex/fields/text.ex index 378054e1..d69b1e0f 100644 --- a/lib/backpex/fields/text.ex +++ b/lib/backpex/fields/text.ex @@ -1,14 +1,29 @@ defmodule Backpex.Fields.Text do + @config_schema [ + placeholder: [ + doc: "Placeholder value or function that receives the assigns.", + type: {:or, [:string, {:fun, 1}]} + ], + debounce: [ + doc: "Timeout value (in milliseconds), \"blur\" or function that receives the assigns.", + type: {:or, [:pos_integer, :string, {:fun, 1}]} + ], + throttle: [ + doc: "Timeout value (in milliseconds) or function that receives the assigns.", + type: {:or, [:pos_integer, {:fun, 1}]} + ] + ] + @moduledoc """ A field for handling a text value. - ## Options + ## Field-specific options + + See `Backpex.Field` for general field options. - * `:placeholder` - Optional placeholder value or function that receives the assigns. - * `:debounce` - Optional integer timeout value (in milliseconds), "blur" or function that receives the assigns. - * `:throttle` - Optional integer timeout value (in milliseconds) or function that receives the assigns. + #{NimbleOptions.docs(@config_schema)} """ - use BackpexWeb, :field + use Backpex.Field, config_schema: @config_schema @impl Backpex.Field def render_value(assigns) do diff --git a/lib/backpex/fields/textarea.ex b/lib/backpex/fields/textarea.ex index a0c8cebf..b290a421 100644 --- a/lib/backpex/fields/textarea.ex +++ b/lib/backpex/fields/textarea.ex @@ -1,4 +1,19 @@ defmodule Backpex.Fields.Textarea do + @config_schema [ + placeholder: [ + doc: "Placeholder value or function that receives the assigns.", + type: {:or, [:string, {:fun, 1}]} + ], + debounce: [ + doc: "Timeout value (in milliseconds), \"blur\" or function that receives the assigns.", + type: {:or, [:pos_integer, :string, {:fun, 1}]} + ], + throttle: [ + doc: "Timeout value (in milliseconds) or function that receives the assigns.", + type: {:or, [:pos_integer, {:fun, 1}]} + ] + ] + @moduledoc """ A field for handling long text values. @@ -8,7 +23,7 @@ defmodule Backpex.Fields.Textarea do * `:debounce` - Optional integer timeout value (in milliseconds), "blur" or function that receives the assigns. * `:throttle` - Optional integer timeout value (in milliseconds) or function that receives the assigns. """ - use BackpexWeb, :field + use Backpex.Field, config_schema: @config_schema @impl Backpex.Field def render_value(assigns) do diff --git a/lib/backpex/fields/upload.ex b/lib/backpex/fields/upload.ex index f92ef9f9..490098b6 100644 --- a/lib/backpex/fields/upload.ex +++ b/lib/backpex/fields/upload.ex @@ -1,124 +1,163 @@ defmodule Backpex.Fields.Upload do - @moduledoc ~S""" - A field for handling uploads. - - > #### Warning {: .warning} - > - > This field does **not** currently support `Phoenix.LiveView.UploadWriter` and direct / external uploads. - - ## Options - - * `:upload_key` (atom) - Required identifier for the upload field (the name of the upload). - * `:accept` (list) - Required filetypes that will be accepted. - * `:max_entries` (integer) - Required number of max files that can be uploaded. - * `:max_file_size` (integer) - Optional maximum file size in bytes to be allowed to uploaded. Defaults 8 MB (`8_000_000`). - * `:list_existing_files` (function) - Required function that returns a list of all uploaded files based on an item. - * `:file_label` (function) - Optional function to get the label of a single file. - * `:consume_upload` (function) - Required function to consume file uploads. - * `:put_upload_change` (function) - Required function to add file paths to the params. - * `:remove_uploads` (function) - Required function that is being called after saving an item to be able to delete removed files - - - > #### Info {: .info} - > - > The following examples copy uploads to a static folder in the application. In a production environment, you should consider uploading files to an appropriate object store. - - ## Options in detail - - The `upload_key`, `accept`, `max_entries` and `max_file_size` options are forwarded to https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#allow_upload/3. See the documentation for more information. - - ### `list_existing_files` - - **Parameters** - * `:socket` - The socket. - * `:item` (struct) - The item without its changes. - - The function is being used to display existing uploads. The function receives the socket and the item and has to return a list of strings. Removed files during an edit of an item are automatically removed from the list. This option is required. - - **Example** - - def list_existing_files(_socket, item), do: item.files - - ### `file_label` - - **Parameters** - * `:file` (string) - The file. - - The function can be used to modify a file label based on a file. In the following example each file will have an "_upload" suffix. This option is optional. - - **Example** - - def file_label(file), do: file <> "_upload" - - ### `consume_upload` - - **Parameters** - * `:socket` - The socket. - * `:item` (struct) - The saved item (with its changes). - * `:meta` - The upload meta. - * `:entry` - The upload entry. + @config_schema [ + upload_key: [ + doc: "Required identifier for the upload field (the name of the upload).", + type: :atom + ], + accept: [ + doc: "Required filetypes that will be accepted.", + type: {:list, :string} + ], + max_entries: [ + doc: "Required number of max files that can be uploaded.", + type: :non_neg_integer + ], + max_file_size: [ + doc: "Optional maximum file size in bytes to be allowed to uploaded.", + type: :pos_integer, + default: 8_000_000 + ], + list_existing_files: [ + doc: """ + A function being used to display existing uploads. It has to return a list of all uploaded files as strings. + Removed files during an edit of an item are automatically removed from the list. + + **Parameters** + + * `:item` (struct) - The item without its changes. + + **Example** + + def list_existing_files(item), do: item.files + """, + type: {:fun, 1}, + required: true + ], + file_label: [ + doc: """ + A function to be used to modify a file label of a single file. In the following example each file will have an + `_upload` suffix. + + **Parameters** + + * `:file` (string) - The file. + + **Example** + + def file_label(file), do: file <> "_upload" + """, + type: {:fun, 1} + ], + consume_upload: [ + doc: """ + Required function to consume file uploads. + A function to consume uploads. It is called after the item has been saved and is used to copy the files to a + specific destination. Backpex will use this function as a callback for `consume_uploaded_entries`. See + https://hexdocs.pm/phoenix_live_view/uploads.html#consume-uploaded-entries for more details. + + **Parameters** + + * `:socket` - The socket. + * `:item` (struct) - The saved item (with its changes). + * `:meta` - The upload meta. + * `:entry` - The upload entry. + + **Example** - The function is used to consume uploads. It is called after the item has been saved and is used to copy the files to a specific destination. Backpex will use this function as a callback for `consume_uploaded_entries`. See https://hexdocs.pm/phoenix_live_view/uploads.html#consume-uploaded-entries for more details. This option is required. - - **Example** - - defp consume_upload(_socket, _item, %{path: path} = _meta, entry) do - file_name = ... - file_url = ... - static_dir = ... - dest = Path.join([:code.priv_dir(:demo), "static", static_dir, file_name]) - - File.cp!(path, dest) - - {:ok, file_url} - end - - ### `put_upload_change` - - **Parameters** - * `:socket` - The socket. - * `:params` (map) - The current params that will be passed to the changeset function. - * `:item` (struct) - The item without its changes. On create will this will be an empty map. - * `uploaded_entries` (tuple) - The completed and in progress entries for the upload. - * `removed_entries` (list) - A list of removed uploads during edit. - * `action` (atom) - The action (`:validate` or `:insert`) - - This function is used to modify the params based on certain parameters. It is important because it ensures that file paths are added to the item change and therefore persisted in the database. This option is required. + defp consume_upload(_socket, _item, %{path: path} = _meta, entry) do + file_name = ... + file_url = ... + static_dir = ... + dest = Path.join([:code.priv_dir(:demo), "static", static_dir, file_name]) - **Example** + File.cp!(path, dest) - def put_upload_change(_socket, params, item, uploaded_entries, removed_entries, action) do - existing_files = item.files -- removed_entries + {:ok, file_url} + end + """, + type: {:fun, 4}, + required: true + ], + put_upload_change: [ + doc: """ + A function to modify the params based on certain parameters. It is important because it ensures that file paths + are added to the item change and therefore persisted in the database. + + **Parameters** + + * `:socket` - The socket. + * `:params` (map) - The current params that will be passed to the changeset function. + * `:item` (struct) - The item without its changes. On create will this will be an empty map. + * `uploaded_entries` (tuple) - The completed and in progress entries for the upload. + * `removed_entries` (list) - A list of removed uploads during edit. + * `action` (atom) - The action (`:validate` or `:insert`) + + **Example** + + def put_upload_change(_socket, params, item, uploaded_entries, removed_entries, action) do + existing_files = item.files -- removed_entries + + new_entries = + case action do + :validate -> + elem(uploaded_entries, 1) + + :insert -> + elem(uploaded_entries, 0) + end - new_entries = - case action do - :validate -> - elem(uploaded_entries, 1) + files = existing_files ++ Enum.map(new_entries, fn entry -> file_name(entry) end) - :insert -> - elem(uploaded_entries, 0) + Map.put(params, "images", files) end + """, + type: {:fun, 6}, + required: true + ], + remove_uploads: [ + doc: """ + A function that is being called after saving an item to be able to delete removed files. + + **Parameters** + + * `:socket` - The socket. + * `:item` (struct) - The item without its changes. + * `removed_entries` (list) - A list of removed uploads during edit. + + **Example** + + defp remove_uploads(_socket, _item, removed_entries) do + for file <- removed_entries do + file_path = ... + File.rm!(file_path) + end + end + """, + type: {:fun, 3}, + required: true + ] + ] - files = existing_files ++ Enum.map(new_entries, fn entry -> file_name(entry) end) + @moduledoc """ + A field for handling uploads. - Map.put(params, "images", files) - end + > #### Warning {: .warning} + > + > This field does **not** currently support `Phoenix.LiveView.UploadWriter` and direct / external uploads. - ### `remove_uploads` + ## Field-specific options - **Parameters** - * `:socket` - The socket. - * `:item` (struct) - The item without its changes. - * `removed_entries` (list) - A list of removed uploads during edit. + See `Backpex.Field` for general field options. - **Example** + The `upload_key`, `accept`, `max_entries` and `max_file_size` options are forwarded to + https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#allow_upload/3. See the documentation for more information. - defp remove_uploads(_socket, _item, removed_entries) do - for file <- removed_entries do - file_path = ... - File.rm!(file_path) - end - end + #{NimbleOptions.docs(@config_schema)} + + > #### Info {: .info} + > + > The following examples copy uploads to a static folder in the application. In a production environment, you should + consider uploading files to an appropriate object store. ## Full Single File Example @@ -241,7 +280,7 @@ defmodule Backpex.Fields.Upload do defp file_name(entry) do [ext | _] = MIME.extensions(entry.client_type) - "#{entry.uuid}.#{ext}" + entry.uuid <> "." <> ext end defp upload_dir, do: Path.join(["uploads", "user", "avatar"]) @@ -355,14 +394,13 @@ defmodule Backpex.Fields.Upload do defp file_name(entry) do [ext | _] = MIME.extensions(entry.client_type) - "#{entry.uuid}.#{ext}" + entry.uuid <> "." <> ext end defp upload_dir, do: Path.join(["uploads", "product", "images"]) end """ - use BackpexWeb, :field - + use Backpex.Field, config_schema: @config_schema alias Backpex.HTML.Form, as: BackpexForm @impl Backpex.Field diff --git a/lib/backpex/fields/url.ex b/lib/backpex/fields/url.ex index 5760c84a..b2c6c3ef 100644 --- a/lib/backpex/fields/url.ex +++ b/lib/backpex/fields/url.ex @@ -1,14 +1,29 @@ defmodule Backpex.Fields.URL do + @config_schema [ + placeholder: [ + doc: "Placeholder value or function that receives the assigns.", + type: {:or, [:string, {:fun, 1}]} + ], + debounce: [ + doc: "Timeout value (in milliseconds), \"blur\" or function that receives the assigns.", + type: {:or, [:pos_integer, :string, {:fun, 1}]} + ], + throttle: [ + doc: "Timeout value (in milliseconds) or function that receives the assigns.", + type: {:or, [:pos_integer, {:fun, 1}]} + ] + ] + @moduledoc """ A field for handling an URL value. - ## Options + ## Field-specific options + + See `Backpex.Field` for general field options. - * `:placeholder` - Optional placeholder value or function that receives the assigns. - * `:debounce` - Optional integer timeout value (in milliseconds), "blur" or function that receives the assigns. - * `:throttle` - Optional integer timeout value (in milliseconds) or function that receives the assigns. + #{NimbleOptions.docs(@config_schema)} """ - use BackpexWeb, :field + use Backpex.Field, config_schema: @config_schema @impl Backpex.Field def render_value(assigns) do diff --git a/lib/backpex/live_resource.ex b/lib/backpex/live_resource.ex index f1164fc4..67cb6ce8 100644 --- a/lib/backpex/live_resource.ex +++ b/lib/backpex/live_resource.ex @@ -257,6 +257,8 @@ defmodule Backpex.LiveResource do def config(key), do: Keyword.fetch!(@resource_opts, key) + def validated_fields, do: LiveResource.validated_fields(__MODULE__) + @impl Phoenix.LiveView def mount(params, session, socket), do: LiveResource.mount(params, session, socket) @@ -480,7 +482,7 @@ defmodule Backpex.LiveResource do defp assign_active_fields(socket, session) do fields = - socket.assigns.live_resource.fields() + socket.assigns.live_resource.validated_fields() |> filtered_fields_by_action(socket.assigns, :index) saved_fields = get_in(session, ["backpex", "column_toggle", "#{socket.assigns.live_resource}"]) || %{} @@ -611,6 +613,18 @@ defmodule Backpex.LiveResource do {:noreply, socket} end + @doc """ + Returns the fields of the given `Backpex.LiveResource` validated against each fields config schema. + """ + def validated_fields(live_resource) do + live_resource.fields() + |> Enum.map(fn {name, options} = field -> + options.module.validate_config!(field, live_resource) + |> Enum.into(%{}) + |> then(&{name, &1}) + end) + end + defp apply_action(socket, :index) do socket |> assign(:page_title, socket.assigns.plural_name) @@ -644,7 +658,7 @@ defmodule Backpex.LiveResource do singular_name: singular_name } = socket.assigns - fields = live_resource.fields() |> filtered_fields_by_action(socket.assigns, :show) + fields = live_resource.validated_fields() |> filtered_fields_by_action(socket.assigns, :show) primary_value = URI.decode(socket.assigns.params["backpex_id"]) item = Resource.get!(primary_value, socket.assigns, live_resource) @@ -666,7 +680,7 @@ defmodule Backpex.LiveResource do unless live_resource.can?(socket.assigns, :new, nil), do: raise(Backpex.ForbiddenError) - fields = live_resource.fields() |> filtered_fields_by_action(socket.assigns, :new) + fields = live_resource.validated_fields() |> filtered_fields_by_action(socket.assigns, :new) empty_item = schema.__struct__() socket @@ -732,7 +746,7 @@ defmodule Backpex.LiveResource do unless live_resource.can?(socket.assigns, :index, nil), do: raise(Backpex.ForbiddenError) - fields = live_resource.fields() |> filtered_fields_by_action(socket.assigns, :index) + fields = live_resource.validated_fields() |> filtered_fields_by_action(socket.assigns, :index) per_page_options = live_resource.config(:per_page_options) per_page_default = live_resource.config(:per_page_default) diff --git a/lib/backpex_web.ex b/lib/backpex_web.ex index f1964253..d0a6c69b 100644 --- a/lib/backpex_web.ex +++ b/lib/backpex_web.ex @@ -24,7 +24,6 @@ defmodule BackpexWeb do def field do quote do use Phoenix.Component, global_prefixes: ~w(x-) - use Backpex.Field use Phoenix.LiveComponent alias Backpex.HTML alias Backpex.HTML.Form, as: BackpexForm From d8b6e216d78e145dfd4e7e2021613025ae811310 Mon Sep 17 00:00:00 2001 From: Phil-Bastian Berndt Date: Fri, 8 Nov 2024 18:29:12 +0100 Subject: [PATCH 2/9] fix and improve config_schema handling --- lib/backpex/field.ex | 4 +--- lib/backpex/fields/currency.ex | 2 +- lib/backpex/fields/date.ex | 9 +++------ lib/backpex/fields/date_time.ex | 9 +++------ lib/backpex/fields/number.ex | 2 +- lib/backpex/fields/textarea.ex | 8 ++++---- 6 files changed, 13 insertions(+), 21 deletions(-) diff --git a/lib/backpex/field.ex b/lib/backpex/field.ex index 1925bee4..91976513 100644 --- a/lib/backpex/field.ex +++ b/lib/backpex/field.ex @@ -176,7 +176,7 @@ defmodule Backpex.Field do @doc """ Returns the default config schema. """ - def default_config_schema(), do: @config_schema + def default_config_schema, do: @config_schema @doc """ Defines `Backpex.Field` behaviour and provides default implementations. @@ -193,8 +193,6 @@ defmodule Backpex.Field do def validate_config!({name, options} = _field, live_resource) do field_options = Keyword.new(options) - dbg(field_options) - case NimbleOptions.validate(field_options, Backpex.Field.default_config_schema() ++ @config_schema) do {:ok, validated_options} -> validated_options diff --git a/lib/backpex/fields/currency.ex b/lib/backpex/fields/currency.ex index 5ca9690b..0ad96fde 100644 --- a/lib/backpex/fields/currency.ex +++ b/lib/backpex/fields/currency.ex @@ -46,7 +46,7 @@ defmodule Backpex.Fields.Currency do ] end """ - use Backpex.Field + use Backpex.Field, config_schema: @config_schema import Ecto.Query alias Backpex.Ecto.Amount.Type diff --git a/lib/backpex/fields/date.ex b/lib/backpex/fields/date.ex index 592f2042..e7db8b93 100644 --- a/lib/backpex/fields/date.ex +++ b/lib/backpex/fields/date.ex @@ -1,7 +1,5 @@ # credo:disable-for-this-file Credo.Check.Design.DuplicatedCode defmodule Backpex.Fields.Date do - @default_format "%Y-%m-%d" - @config_schema [ format: [ doc: """ @@ -10,7 +8,7 @@ defmodule Backpex.Fields.Date do Can also be a function wich receives a `DateTime` and must return a string. """, type: {:or, [:string, {:fun, 1}]}, - default: @default_format + default: "%Y-%m-%d" ], debounce: [ doc: "Timeout value (in milliseconds), \"blur\" or function that receives the assigns.", @@ -22,7 +20,6 @@ defmodule Backpex.Fields.Date do ] ] - # credo:disable-for-next-line Credo.Check.Readability.StrictModuleLayout @moduledoc """ A field for handling a date value. @@ -72,11 +69,11 @@ defmodule Backpex.Fields.Date do ] end """ - use Backpex.Field + use Backpex.Field, config_schema: @config_schema @impl Backpex.Field def render_value(assigns) do - format = Map.get(assigns.field_options, :format, @default_format) + format = assigns.field_options[:format] value = cond do diff --git a/lib/backpex/fields/date_time.ex b/lib/backpex/fields/date_time.ex index 5331b2f2..1da16c34 100644 --- a/lib/backpex/fields/date_time.ex +++ b/lib/backpex/fields/date_time.ex @@ -1,7 +1,5 @@ # credo:disable-for-this-file Credo.Check.Design.DuplicatedCode defmodule Backpex.Fields.DateTime do - @default_format "%Y-%m-%d %I:%M %p" - @config_schema [ format: [ doc: """ @@ -10,7 +8,7 @@ defmodule Backpex.Fields.DateTime do Can also be a function wich receives a `DateTime` and must return a string. """, type: {:or, [:string, {:fun, 1}]}, - default: @default_format + default: "%Y-%m-%d %I:%M %p" ], debounce: [ doc: "Timeout value (in milliseconds), \"blur\" or function that receives the assigns.", @@ -22,7 +20,6 @@ defmodule Backpex.Fields.DateTime do ] ] - # credo:disable-for-next-line Credo.Check.Readability.StrictModuleLayout @moduledoc """ A field for handling a date time value. @@ -72,11 +69,11 @@ defmodule Backpex.Fields.DateTime do ] end """ - use Backpex.Field + use Backpex.Field, config_schema: @config_schema @impl Backpex.Field def render_value(assigns) do - format = Map.get(assigns.field_options, :format, @default_format) + format = assigns.field_options[:format] value = cond do diff --git a/lib/backpex/fields/number.ex b/lib/backpex/fields/number.ex index 67a5e2d1..c536b924 100644 --- a/lib/backpex/fields/number.ex +++ b/lib/backpex/fields/number.ex @@ -23,7 +23,7 @@ defmodule Backpex.Fields.Number do #{NimbleOptions.docs(@config_schema)} """ - use Backpex.Field + use Backpex.Field, config_schema: @config_schema import Ecto.Query @impl Backpex.Field diff --git a/lib/backpex/fields/textarea.ex b/lib/backpex/fields/textarea.ex index b290a421..0173007a 100644 --- a/lib/backpex/fields/textarea.ex +++ b/lib/backpex/fields/textarea.ex @@ -17,11 +17,11 @@ defmodule Backpex.Fields.Textarea do @moduledoc """ A field for handling long text values. - ## Options + ## Field-specific options - * `:placeholder` - Optional placeholder value or function that receives the assigns. - * `:debounce` - Optional integer timeout value (in milliseconds), "blur" or function that receives the assigns. - * `:throttle` - Optional integer timeout value (in milliseconds) or function that receives the assigns. + See `Backpex.Field` for general field options. + + #{NimbleOptions.docs(@config_schema)} """ use Backpex.Field, config_schema: @config_schema From 122a9433831265956120d28d40d3d810bc1e39e0 Mon Sep 17 00:00:00 2001 From: Phil-Bastian Berndt Date: Thu, 14 Nov 2024 18:44:19 +0100 Subject: [PATCH 3/9] do not translate on compile --- lib/backpex/fields/has_many.ex | 21 ++++++++++++++------- lib/backpex/fields/multi_select.ex | 11 +++++++---- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/lib/backpex/fields/has_many.ex b/lib/backpex/fields/has_many.ex index 93bd0550..3bd3fdb7 100644 --- a/lib/backpex/fields/has_many.ex +++ b/lib/backpex/fields/has_many.ex @@ -27,14 +27,20 @@ defmodule Backpex.Fields.HasMany do type: {:fun, 2} ], prompt: [ - doc: "The text to be displayed when no options are selected or function that receives the assigns.", - type: :string, - default: Backpex.translate("Select options...") + doc: """ + The text to be displayed when no options are selected or function that receives the assigns. + + The default value is `"Select options..."`. + """, + type: :string ], not_found_text: [ - doc: "The text to be displayed when no options are found.", - type: :string, - default: Backpex.translate("No options found") + doc: """ + The text to be displayed when no options are found. + + The default value is `"No options found"`. + """, + type: :string ], query_limit: [ doc: "Limit passed to the query to fetch new items. Set to `nil` to have no limit.", @@ -94,7 +100,7 @@ defmodule Backpex.Fields.HasMany do socket |> assign_new(:prompt, fn -> prompt(assigns, field_options) end) - |> assign_new(:not_found_text, fn -> field_options[:not_found_text] end) + |> assign_new(:not_found_text, fn -> field_options[:not_found_text] || Backpex.translate("No options found") end) |> assign_new(:search_input, fn -> "" end) |> assign_new(:offset, fn -> 0 end) |> assign_new(:options_count, fn -> count_options(assigns) end) @@ -542,6 +548,7 @@ defmodule Backpex.Fields.HasMany do defp prompt(assigns, field_options) do case Map.get(field_options, :prompt) do + nil -> Backpex.translate("Select options...") prompt when is_function(prompt) -> prompt.(assigns) prompt -> prompt end diff --git a/lib/backpex/fields/multi_select.ex b/lib/backpex/fields/multi_select.ex index c5c9d6a4..4d49e9ed 100644 --- a/lib/backpex/fields/multi_select.ex +++ b/lib/backpex/fields/multi_select.ex @@ -10,9 +10,12 @@ defmodule Backpex.Fields.MultiSelect do type: :string ], not_found_text: [ - doc: "The text to be displayed when no options are found.", - type: :string, - default: Backpex.translate("No options found") + doc: """ + The text to be displayed when no options are found. + + The default value is `"No options found"`. + """, + type: :string ] ] @@ -48,7 +51,7 @@ defmodule Backpex.Fields.MultiSelect do socket = socket |> assign(assigns) - |> assign(:not_found_text, assigns.field_options[:not_found_text]) + |> assign(:not_found_text, assigns.field_options[:not_found_text] || Backpex.translate("No options found")) |> assign(:prompt, prompt(assigns, assigns.field_options)) |> assign(:search_input, "") |> assign_options() From bdd23563be33229829f60788f180b164d32a40c0 Mon Sep 17 00:00:00 2001 From: Phil-Bastian Berndt Date: Thu, 14 Nov 2024 18:48:27 +0100 Subject: [PATCH 4/9] add render_form field option --- lib/backpex/field.ex | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/backpex/field.ex b/lib/backpex/field.ex index 91976513..b30a3676 100644 --- a/lib/backpex/field.ex +++ b/lib/backpex/field.ex @@ -15,6 +15,10 @@ defmodule Backpex.Field do doc: "", type: {:fun, 1} ], + render_form: [ + doc: "A function to overwrite the template used in forms. It should take `assigns` and return a HEEX template.", + type: {:fun, 1} + ], custom_alias: [ doc: "", type: :atom From be22ffeb13454ebbbdfb70d4a88aaed043756fa7 Mon Sep 17 00:00:00 2001 From: Phil-Bastian Berndt Date: Thu, 14 Nov 2024 18:54:43 +0100 Subject: [PATCH 5/9] add upgrade guide for field usage --- guides/upgrading/v0.9.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/guides/upgrading/v0.9.md b/guides/upgrading/v0.9.md index 08b270a4..85a78cc9 100644 --- a/guides/upgrading/v0.9.md +++ b/guides/upgrading/v0.9.md @@ -70,3 +70,30 @@ end Although the change is relatively small, if you are using public functions of the `Backpex.LiveResource` directly, check the updated function definitions in the module documentation. + +## Refactor custom fields + +In case you built your own custom fields: We changed the way how to use the `Backpex.Field`. + +Before: + +```elixir + use BackpexWeb, :field +``` + +After: + +```elixir + use Backpex.Field +``` + +In case your field has field-specific configuration options, you need to provide those when using `Backpex.Field`: + +```elixir + @config_schema [ + # see https://hexdocs.pm/nimble_options/NimbleOptions.html + # or any other core backpex field for examples... + ] + + use Backpex.Field, config_schema: @config_schema +``` From da27dc3f6550bc19e595df0bcfe06da3269532b7 Mon Sep 17 00:00:00 2001 From: Phil-Bastian Berndt Date: Thu, 14 Nov 2024 18:58:23 +0100 Subject: [PATCH 6/9] fix typo --- lib/backpex/fields/textarea.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/backpex/fields/textarea.ex b/lib/backpex/fields/textarea.ex index 8841f379..106200d1 100644 --- a/lib/backpex/fields/textarea.ex +++ b/lib/backpex/fields/textarea.ex @@ -13,7 +13,7 @@ defmodule Backpex.Fields.Textarea do type: {:or, [:pos_integer, {:fun, 1}]} ], rows: [ - docs: "Number of visible text lines for the control. If it is not specified.", + doc: "Number of visible text lines for the control. If it is not specified.", type: :non_neg_integer, default: 2 ] From 333da2b2d032bfcde7ae8c268d3e604a54715913 Mon Sep 17 00:00:00 2001 From: Phil-Bastian Berndt Date: Thu, 14 Nov 2024 18:58:34 +0100 Subject: [PATCH 7/9] remove deprecated option --- demo/lib/demo_web/live/short_link_live.ex | 1 - lib/backpex/fields/belongs_to.ex | 4 ---- 2 files changed, 5 deletions(-) diff --git a/demo/lib/demo_web/live/short_link_live.ex b/demo/lib/demo_web/live/short_link_live.ex index 3c0ad817..9506f4d9 100644 --- a/demo/lib/demo_web/live/short_link_live.ex +++ b/demo/lib/demo_web/live/short_link_live.ex @@ -47,7 +47,6 @@ defmodule DemoWeb.ShortLinkLive do product: %{ module: Backpex.Fields.BelongsTo, label: "Product", - source: Demo.Product, display_field: :name, prompt: "Choose product..." } diff --git a/lib/backpex/fields/belongs_to.ex b/lib/backpex/fields/belongs_to.ex index c92561c9..00b52428 100644 --- a/lib/backpex/fields/belongs_to.ex +++ b/lib/backpex/fields/belongs_to.ex @@ -13,10 +13,6 @@ defmodule Backpex.Fields.BelongsTo do doc: "The live resource of the association. Used to generate links navigating to the association.", type: :atom ], - source: [ - doc: "", - type: :atom - ], options_query: [ doc: """ Manipulates the list of available options in the select. From 3009a32cf91267562b82c6d1ce9da25e323e0a21 Mon Sep 17 00:00:00 2001 From: Phil-Bastian Berndt Date: Thu, 14 Nov 2024 19:32:06 +0100 Subject: [PATCH 8/9] improve field option docs --- guides/fields/custom-fields.md | 2 +- lib/backpex/field.ex | 50 ++++++++++++++++++++++++---------- lib/backpex/fields/textarea.ex | 2 +- 3 files changed, 37 insertions(+), 17 deletions(-) diff --git a/guides/fields/custom-fields.md b/guides/fields/custom-fields.md index b95e4e5e..c9ad41c0 100644 --- a/guides/fields/custom-fields.md +++ b/guides/fields/custom-fields.md @@ -13,7 +13,7 @@ When creating your own custom field, you can use the `field` macro from the `Bac The simplest version of a custom field would look like this: ```elixir -use BackpexWeb, :field +use Backpex.Field @impl Backpex.Field def render_value(assigns) do diff --git a/lib/backpex/field.ex b/lib/backpex/field.ex index b30a3676..ecbf4af4 100644 --- a/lib/backpex/field.ex +++ b/lib/backpex/field.ex @@ -12,7 +12,7 @@ defmodule Backpex.Field do required: true ], render: [ - doc: "", + doc: "A function to overwrite the template used . It should take `assigns` and return a HEEX template.", type: {:fun, 1} ], render_form: [ @@ -20,19 +20,23 @@ defmodule Backpex.Field do type: {:fun, 1} ], custom_alias: [ - doc: "", + doc: "A custom alias for the field.", type: :atom ], align: [ - doc: "", - type: :atom + doc: "Align the fields of a resource in the index view.", + type: {:in, [:left, :center, :right]} + ], + align_label: [ + doc: "Align the labels of the fields in the edit view.", + type: {:in, [:top, :center, :bottom]} ], searchable: [ - doc: "", + doc: "Define wether this field should be searchable on the index view.", type: :boolean ], orderable: [ - doc: "", + doc: "Define wether this field should be orderable on the index view.", type: :boolean ], visible: [ @@ -40,28 +44,44 @@ defmodule Backpex.Field do type: {:fun, 1} ], panel: [ - doc: "", + doc: "Group field into panel. Also see the [panels](/guides/authorization/panels.md) guide.", type: :atom ], index_editable: [ - doc: "", + doc: """ + Define wether this field should be editable on the index view. Also see the + [index edit](/guides/authorization/index-edit.md) guide. + """, type: :boolean ], index_column_class: [ - doc: "", - type: :string + doc: """ + Add additional class(es) to the index column. + In case of a function it takes the `assigns` and should return a string. + """, + type: {:or, [:string, {:fun, 1}]} ], select: [ - doc: "", + doc: """ + Define a dynamic select query expression for this field. + + ### Example + + full_name: %{ + module: Backpex.Fields.Text, + label: "Full Name", + select: dynamic([user: u], fragment("concat(?, ' ', ?)", u.first_name, u.last_name)), + } + """, type: {:struct, Ecto.Query.DynamicExpr} ], only: [ - doc: "", - type: {:list, :atom} + doc: "Define the only views where this field should be visible.", + type: {:list, {:in, [:new, :edit, :show, :index, :resource_action]}} ], except: [ - doc: "", - type: {:list, :atom} + doc: "Define the views where this field should not be visible.", + type: {:list, {:in, [:new, :edit, :show, :index, :resource_action]}} ], translate_error: [ doc: """ diff --git a/lib/backpex/fields/textarea.ex b/lib/backpex/fields/textarea.ex index 106200d1..578e72c4 100644 --- a/lib/backpex/fields/textarea.ex +++ b/lib/backpex/fields/textarea.ex @@ -13,7 +13,7 @@ defmodule Backpex.Fields.Textarea do type: {:or, [:pos_integer, {:fun, 1}]} ], rows: [ - doc: "Number of visible text lines for the control. If it is not specified.", + doc: "Number of visible text lines for the control.", type: :non_neg_integer, default: 2 ] From 616afd44ed33ca8d206f773a3efbbd530d52fe1b Mon Sep 17 00:00:00 2001 From: Phil-Bastian Berndt Date: Thu, 14 Nov 2024 19:48:30 +0100 Subject: [PATCH 9/9] improve type option validation --- lib/backpex/fields/inline_crud.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/backpex/fields/inline_crud.ex b/lib/backpex/fields/inline_crud.ex index 0d1a7807..3bfc7ae6 100644 --- a/lib/backpex/fields/inline_crud.ex +++ b/lib/backpex/fields/inline_crud.ex @@ -1,8 +1,8 @@ defmodule Backpex.Fields.InlineCRUD do @config_schema [ type: [ - doc: "The type of the field. Either `:embed` or `:assoc`.", - type: :atom, + doc: "The type of the field.", + type: {:in, [:embed, :assoc]}, required: true ], child_fields: [