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/demo/lib/demo_web/live/user_live.ex b/demo/lib/demo_web/live/user_live.ex index 4f9387f3..0b52edd1 100644 --- a/demo/lib/demo_web/live/user_live.ex +++ b/demo/lib/demo_web/live/user_live.ex @@ -64,7 +64,6 @@ defmodule DemoWeb.UserLive do label: "Avatar", upload_key: :avatar, accept: ~w(.jpg .jpeg .png), - max_entries: 1, max_file_size: 512_000, put_upload_change: &put_upload_change/6, consume_upload: &consume_upload/4, diff --git a/demo/lib/demo_web/resource_actions/upload.ex b/demo/lib/demo_web/resource_actions/upload.ex index 1783793c..ce421651 100644 --- a/demo/lib/demo_web/resource_actions/upload.ex +++ b/demo/lib/demo_web/resource_actions/upload.ex @@ -18,7 +18,6 @@ defmodule DemoWeb.ResourceActions.Upload do label: "Upload", upload_key: :upload, accept: ~w(.jpg .jpeg .png), - max_entries: 1, put_upload_change: &put_upload_change/6, consume_upload: &consume_upload/4, remove_uploads: &remove_uploads/3, 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/guides/upgrading/v0.9.md b/guides/upgrading/v0.9.md index 26bd01d5..27e292ef 100644 --- a/guides/upgrading/v0.9.md +++ b/guides/upgrading/v0.9.md @@ -71,6 +71,38 @@ 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 +``` + +## Removed string support on `throttle` field options + +The fields that allow the `throttle` options previously supported a string value (e.g. `"500"`). +Please change it to an integer value (e.g. `500`). + ## Resource Action and Item Action `init_change/1` is renamed The term `init_change` was confusing because the result is being used as the base schema / item for the changeset function. Therefore we renamed the function to `base_schema/1` for both Item Actions and Resource Actions. diff --git a/lib/backpex/adapters/ecto.ex b/lib/backpex/adapters/ecto.ex index d02742c7..134715ef 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..7eef91a1 100644 --- a/lib/backpex/field.ex +++ b/lib/backpex/field.ex @@ -1,9 +1,121 @@ # 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 + ], + default: [ + doc: """ + A function to assign default values to fields. Also see the [field defaults](/guides/fields/defaults.md) guide. + """, + type: {:fun, 1} + ], + render: [ + doc: "A function to overwrite the template used . It should take `assigns` and return a HEEX template.", + 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: "A custom alias for the field.", + type: :atom + ], + align: [ + 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: {:or, [{:in, [:top, :center, :bottom]}, {:fun, 1}]} + ], + searchable: [ + doc: "Define wether this field should be searchable on the index view.", + type: :boolean + ], + orderable: [ + doc: "Define wether this field should be orderable on the index view.", + type: :boolean + ], + visible: [ + doc: + "Function to change the visibility of a field for all views except index. Receives the assigns and has to return a boolean.", + type: {:fun, 1} + ], + can?: [ + doc: + "Function to change the visibility of a field for all views. Receives the assigns and has to return a boolean.", + type: {:fun, 1} + ], + panel: [ + doc: "Group field into panel. Also see the [panels](/guides/authorization/panels.md) guide.", + type: :atom + ], + index_editable: [ + doc: """ + Define wether this field should be editable on the index view. Also see the + [index edit](/guides/authorization/index-edit.md) guide. + """, + type: {:or, [:boolean, {:fun, 1}]} + ], + index_column_class: [ + 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: """ + 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: "Define the only views where this field should be visible.", + type: {:list, {:in, [:new, :edit, :show, :index, :resource_action]}} + ], + except: [ + doc: "Define the views where this field should not be visible.", + type: {:list, {:in, [:new, :edit, :show, :index, :resource_action]}} + ], + 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 +127,7 @@ defmodule Backpex.Field do } ] end - ''' + """ import Phoenix.Component, only: [assign: 3] @doc """ @@ -97,13 +209,38 @@ 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) + + 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..f152e01a 100644 --- a/lib/backpex/fields/belongs_to.ex +++ b/lib/backpex/fields/belongs_to.ex @@ -1,15 +1,48 @@ 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 + ], + 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: {: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 `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 +59,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..0ad96fde 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, config_schema: @config_schema 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..55b43845 100644 --- a/lib/backpex/fields/date.ex +++ b/lib/backpex/fields/date.ex @@ -1,17 +1,37 @@ # credo:disable-for-this-file Credo.Check.Design.DuplicatedCode 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: "%Y-%m-%d" + ], + 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}]} + ], + readonly: [ + doc: "Sets the field to readonly. Also see the [panels](/guides/fields/readonly.md) guide.", + type: {:or, [:boolean, {:fun, 1}]} + ] + ] - # credo:disable-for-next-line Credo.Check.Readability.StrictModuleLayout @moduledoc """ A field for handling a date value. - ## Options + ## Field-specific 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. + See `Backpex.Field` for general field options. + + #{NimbleOptions.docs(@config_schema)} ## Examples @@ -53,11 +73,11 @@ defmodule Backpex.Fields.Date do ] end """ - use BackpexWeb, :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 e3a73380..3b435dba 100644 --- a/lib/backpex/fields/date_time.ex +++ b/lib/backpex/fields/date_time.ex @@ -1,17 +1,37 @@ # 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: """ + 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: "%Y-%m-%d %I:%M %p" + ], + 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}]} + ], + readonly: [ + doc: "Sets the field to readonly. Also see the [panels](/guides/fields/readonly.md) guide.", + type: {:or, [:boolean, {:fun, 1}]} + ] + ] - # credo:disable-for-next-line Credo.Check.Readability.StrictModuleLayout @moduledoc """ A field for handling a date time value. - ## Options + ## Field-specific 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. + See `Backpex.Field` for general field options. + + #{NimbleOptions.docs(@config_schema)} ## Examples @@ -53,11 +73,11 @@ defmodule Backpex.Fields.DateTime do ] end """ - use BackpexWeb, :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/has_many.ex b/lib/backpex/fields/has_many.ex index 8a6b87d0..5ef7282a 100644 --- a/lib/backpex/fields/has_many.ex +++ b/lib/backpex/fields/has_many.ex @@ -1,24 +1,64 @@ 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. + + The default value is `"Select options..."`. + """, + type: {:or, [:string, {:fun, 1}]} + ], + not_found_text: [ + 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.", + 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 +75,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 +92,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 +100,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] || 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) @@ -232,7 +272,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} @@ -355,8 +395,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) @@ -504,8 +543,6 @@ 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)) @@ -516,10 +553,4 @@ defmodule Backpex.Fields.HasMany do 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..3bfc7ae6 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.", + type: {:in, [:embed, :assoc]}, + 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..f66f03f6 100644 --- a/lib/backpex/fields/multi_select.ex +++ b/lib/backpex/fields/multi_select.ex @@ -1,16 +1,34 @@ defmodule Backpex.Fields.MultiSelect do + @config_schema [ + options: [ + doc: "List of options or function that receives the assigns.", + type: {:or, [{:list, :any}, {:fun, 1}]}, + required: true + ], + prompt: [ + doc: "The text to be displayed when no option is selected or function that receives the assigns.", + type: {:or, [:string, {:fun, 1}]} + ], + not_found_text: [ + doc: """ + The text to be displayed when no options are found. + + The default value is `"No options found"`. + """, + type: :string + ] + ] + @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 +42,7 @@ defmodule Backpex.Fields.MultiSelect do }, ] """ - use BackpexWeb, :field + use Backpex.Field, config_schema: @config_schema import Backpex.HTML.Form @@ -33,7 +51,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] || Backpex.translate("No options found")) |> assign(:prompt, prompt(assigns, assigns.field_options)) |> assign(:search_input, "") |> assign_options() @@ -216,9 +234,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..77962201 100644 --- a/lib/backpex/fields/number.ex +++ b/lib/backpex/fields/number.ex @@ -1,15 +1,33 @@ 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}]} + ], + readonly: [ + doc: "Sets the field to readonly. Also see the [panels](/guides/fields/readonly.md) guide.", + type: {:or, [:boolean, {: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, config_schema: @config_schema import Ecto.Query @impl Backpex.Field @@ -32,6 +50,7 @@ defmodule Backpex.Fields.Number do #### 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, + required: true + ], + accept: [ + doc: "List of filetypes that will be accepted or `:any`.", + type: {:or, [{:list, :string}, :atom]}, + default: :any + ], + max_entries: [ + doc: "Number of max files that can be uploaded.", + type: :non_neg_integer, + default: 1 + ], + 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 @@ -171,7 +213,6 @@ defmodule Backpex.Fields.Upload do label: "Avatar", upload_key: :avatar, accept: ~w(.jpg .jpeg .png), - max_entries: 1, max_file_size: 512_000, put_upload_change: &put_upload_change/6, consume_upload: &consume_upload/4, @@ -241,7 +282,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 +396,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..20bd778a 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 @@ -32,6 +47,7 @@ defmodule Backpex.Fields.URL do filtered_fields_by_action(socket.assigns, :index) saved_fields = get_in(session, ["backpex", "column_toggle", "#{socket.assigns.live_resource}"]) || %{} @@ -601,6 +603,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) @@ -611,7 +625,7 @@ defmodule Backpex.LiveResource do defp apply_action(socket, :edit) do %{live_resource: live_resource, singular_name: singular_name} = socket.assigns - fields = live_resource.fields |> filtered_fields_by_action(socket.assigns, :edit) + fields = live_resource.validated_fields() |> filtered_fields_by_action(socket.assigns, :edit) primary_value = URI.decode(socket.assigns.params["backpex_id"]) item = Resource.get!(primary_value, socket.assigns, live_resource) @@ -630,7 +644,7 @@ defmodule Backpex.LiveResource do defp apply_action(socket, :show) do %{live_resource: live_resource, 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) @@ -648,7 +662,7 @@ defmodule Backpex.LiveResource do if not 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__() changeset_function = live_resource.config(:adapter_config)[:create_changeset] @@ -719,7 +733,7 @@ defmodule Backpex.LiveResource do if not 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 diff --git a/mix.exs b/mix.exs index 32746d55..95ec7863 100644 --- a/mix.exs +++ b/mix.exs @@ -109,7 +109,10 @@ defmodule Backpex.MixProject do """ end - end + end, + skip_code_autolink_to: [ + "Ecto.Query.DynamicExpr" + ] ] end