MishkaGervaz.Helpers (MishkaGervaz v0.0.1-alpha.3)

Copy Markdown View Source

Shared helper functions for MishkaGervaz.

Summary

Functions

Checks if an entity (filter, action, etc.) is accessible based on visibility and restrictions.

Drops nil values from a map and returns nil if the result is empty, otherwise returns the cleaned map.

Unwrap a preload entry to its source atom. Preloads can be plain atoms or {source, alias} tuples — this returns the atom either way.

Unwraps a Spark singleton sub-entity wrapper into the entity itself.

Finds an entity in a list by its name field.

Formats a file size in bytes to a human-readable string.

Look up the Ash domain for a resource.

Fetch the domain-side defaults map for a given section (:form or :table). Returns %{} when the resource has no domain or the domain has no mishka_gervaz block.

Returns the resource's Ash attributes as a %{name => attribute_struct} map.

Extracts and resolves a label from a UI structure, with fallback to humanized name.

Filters columns based on their visibility setting.

Checks if a value is present (not nil, empty string, or empty list).

Converts an atom or string to a human-readable label.

Injects preload alias values into a record or list of records.

Invalidates dependent filter values and relation_filter_state when parent filters change.

Checks if a string name is a known entity name in the given state.

Fetch a key from a possibly-nil map, returning default when the key is missing or the value is nil.

Puts a key-value pair into a map only if the value is present and valid.

Returns true when the user has no tenant — i.e. is a master user.

Conditionally assigns a key-value pair to assigns only if the value is not nil.

Merges form-state relation field values into a params map.

Converts a module name to its snake_case short name.

Normalizes an Ash primary-key type to one of :uuid, :uuid_v7, :integer, or :string. Falls back to :uuid for anything else, matching the conservative default Phoenix uses for <input> shapes.

Normalizes a list of options for HTML select elements.

Normalizes selected values for multi-select components.

Returns the normalized type of resource's primary key as one of :uuid, :uuid_v7, :integer, or :string. Defaults to :uuid when introspection fails or the resource has no primary key.

Returns the normalized id-type for a :relation entity, defaulting to :uuid when the related resource cannot be found.

Resolves the destination resource of a relation field/filter.

Resolves a label that may be a string or a zero-arity function.

Resolves a display value from a record using either an atom field or a function.

Resolves options that may be a list or a zero-arity function returning a list.

Resolves a label from a nested UI structure.

Converts string boolean representations to actual booleans.

Extracts the tenant value from a user map/struct (currently :site_id).

Validates a form and returns per-field errors for the currently-changing field only.

Functions

accessible?(arg1, state)

@spec accessible?(map(), map()) :: boolean()

Checks if an entity (filter, action, etc.) is accessible based on visibility and restrictions.

Handles:

  • restricted: true with non-master user → not accessible
  • visible: false → not accessible
  • visible: fn state -> boolean end → calls function

Examples

iex> MishkaGervaz.Helpers.accessible?(%{restricted: true}, %{master_user?: false})
false

iex> MishkaGervaz.Helpers.accessible?(%{restricted: true}, %{master_user?: true})
true

iex> MishkaGervaz.Helpers.accessible?(%{visible: false}, %{})
false

iex> MishkaGervaz.Helpers.accessible?(%{visible: true}, %{})
true

compact_to_nil(map)

@spec compact_to_nil(map() | nil) :: map() | nil

Drops nil values from a map and returns nil if the result is empty, otherwise returns the cleaned map.

Used by transformers that build optional sub-config blocks where "no overrides set" should compile down to nil rather than an empty map. Idempotent on nil input.

dynamic_component(assigns)

@spec dynamic_component(map()) :: Phoenix.LiveView.Rendered.t()

Dynamic component wrapper using apply/3 for proper Phoenix lifecycle. See: https://hexdocs.pm/phoenix_live_view/Phoenix.Component.html#module-dynamic-component-rendering

extract_preload_source(source)

@spec extract_preload_source(atom() | {atom(), atom()}) :: atom()

Unwrap a preload entry to its source atom. Preloads can be plain atoms or {source, alias} tuples — this returns the atom either way.

extract_singleton_entity(map, key)

@spec extract_singleton_entity(map(), atom()) :: map()

Unwraps a Spark singleton sub-entity wrapper into the entity itself.

Spark stores DSL singleton entities under key as a single-element list during parse. After the parent entity's transform/1 runs, the list is collapsed to the entity struct (or nil if absent). Used by every entity that owns one or more singleton sub-entities (e.g. :ui, :preload, plus submit's :create / :update / :cancel).

Idempotent: if the value is already a struct (transform already ran) or absent, the map is returned unchanged.

find_by_name(list, name)

@spec find_by_name(list() | nil, atom()) :: map() | nil

Finds an entity in a list by its name field.

Examples

iex> MishkaGervaz.Helpers.find_by_name([%{name: :foo}, %{name: :bar}], :bar)
%{name: :bar}

iex> MishkaGervaz.Helpers.find_by_name([%{name: :foo}], :missing)
nil

iex> MishkaGervaz.Helpers.find_by_name(nil, :foo)
nil

format_filesize(size)

@spec format_filesize(integer() | nil) :: String.t()

Formats a file size in bytes to a human-readable string.

Examples

iex> MishkaGervaz.Helpers.format_filesize(500)
"500 B"

iex> MishkaGervaz.Helpers.format_filesize(1024)
"1.0 KB"

iex> MishkaGervaz.Helpers.format_filesize(1048576)
"1.0 MB"

iex> MishkaGervaz.Helpers.format_filesize(nil)
"-"

get_domain(resource)

@spec get_domain(module()) :: {:ok, module()} | :error

Look up the Ash domain for a resource.

get_domain_defaults(resource, section)

@spec get_domain_defaults(module(), atom()) :: map()

Fetch the domain-side defaults map for a given section (:form or :table). Returns %{} when the resource has no domain or the domain has no mishka_gervaz block.

get_resource_attributes(resource)

@spec get_resource_attributes(module()) :: map()

Returns the resource's Ash attributes as a %{name => attribute_struct} map.

Used by builders that need O(1) attribute lookup by name (FieldBuilder, ColumnBuilder).

get_ui_label(entity)

@spec get_ui_label(map() | struct()) :: String.t()

Extracts and resolves a label from a UI structure, with fallback to humanized name.

Similar to resolve_ui_label/1 but falls back to humanizing the :name field when no UI label is found.

Examples

iex> MishkaGervaz.Helpers.get_ui_label(%{ui: %{label: "Custom"}, name: :field})
"Custom"

iex> MishkaGervaz.Helpers.get_ui_label(%{name: :user_name})
"User Name"

iex> MishkaGervaz.Helpers.get_ui_label(%{ui: nil, name: :created_at})
"Created At"

get_visible_columns(columns, state)

@spec get_visible_columns([map()], map()) :: [map()]

Filters columns based on their visibility setting.

Evaluates the visible field of each column:

  • Function with arity 1: calls with state and uses the result
  • Boolean: uses the value directly
  • Any other value: defaults to true (visible)

Examples

iex> columns = [%{name: :id, visible: true}, %{name: :secret, visible: false}]
iex> MishkaGervaz.Helpers.get_visible_columns(columns, %{})
[%{name: :id, visible: true}]

iex> columns = [%{name: :admin_only, visible: fn state -> state.master_user? end}]
iex> MishkaGervaz.Helpers.get_visible_columns(columns, %{master_user?: true})
[%{name: :admin_only, visible: _}]

iex> MishkaGervaz.Helpers.get_visible_columns(columns, %{master_user?: false})
[]

has_value?(_)

@spec has_value?(any()) :: boolean()

Checks if a value is present (not nil, empty string, or empty list).

Examples

iex> MishkaGervaz.Helpers.has_value?(nil)
false

iex> MishkaGervaz.Helpers.has_value?("")
false

iex> MishkaGervaz.Helpers.has_value?([])
false

iex> MishkaGervaz.Helpers.has_value?("test")
true

iex> MishkaGervaz.Helpers.has_value?(["a", "b"])
true

humanize(atom)

@spec humanize(atom() | String.t()) :: String.t()

Converts an atom or string to a human-readable label.

Examples

iex> MishkaGervaz.Helpers.humanize(:first_name)
"First Name"

iex> MishkaGervaz.Helpers.humanize(:user_id)
"User Id"

iex> MishkaGervaz.Helpers.humanize("already_formatted")
"already_formatted"

inject_preload_aliases(record_or_records, aliases)

@spec inject_preload_aliases(struct() | [struct()], map() | nil) ::
  struct() | [struct()]

Injects preload alias values into a record or list of records.

When using master/tenant preload patterns (e.g., master_media_category aliased to media_category), this function copies the loaded relationship data from the source field to the alias field.

Examples

iex> record = %{id: 1, master_media_category: %{name: "Photos"}}
iex> MishkaGervaz.Helpers.inject_preload_aliases(record, %{media_category: :master_media_category})
%{id: 1, master_media_category: %{name: "Photos"}, media_category: %{name: "Photos"}}

iex> MishkaGervaz.Helpers.inject_preload_aliases([%{id: 1, source: "val"}], %{alias: :source})
[%{id: 1, source: "val", alias: "val"}]

iex> MishkaGervaz.Helpers.inject_preload_aliases(%{id: 1}, nil)
%{id: 1}

iex> MishkaGervaz.Helpers.inject_preload_aliases(%{id: 1}, %{})
%{id: 1}

invalidate_dependents(new_filter_values, old_filter_values, state)

@spec invalidate_dependents(map(), map(), map()) :: {map(), map()}

Invalidates dependent filter values and relation_filter_state when parent filters change.

Compares old vs new filter values to find changed parents, then recursively finds all children whose depends_on points to a changed filter. Removes those children from both filter_values and relation_filter_state.

Returns {cleaned_filter_values, cleaned_relation_filter_state}.

Examples

iex> filters = [%{name: :region, depends_on: nil}, %{name: :city, depends_on: :region}]
iex> old = %{region: "us", city: "ny"}
iex> new = %{region: "eu", city: "ny"}
iex> state = %{static: %{filters: filters}, relation_filter_state: %{}}
iex> {cleaned_fv, cleaned_rfs} = MishkaGervaz.Helpers.invalidate_dependents(new, old, state)
iex> cleaned_fv
%{region: "eu"}

known_name?(name, arg2)

@spec known_name?(String.t(), map()) :: boolean()

Checks if a string name is a known entity name in the given state.

Pattern matches on the state structure to auto-detect the context:

  • Form state (static.fields) — checks field names
  • Table state (static.columns) — checks column names
  • Table state (static.filters) — checks filter names (with explicit :filters)
  • Form state (static.steps) — checks step names (with explicit :steps)
  • Form state (static.uploads) — checks upload names (with explicit :uploads)

Avoids String.to_existing_atom/1 and rescue blocks for safe user input validation.

Examples

iex> state = %{static: %{fields: [%{name: :title}, %{name: :tags}]}}
iex> MishkaGervaz.Helpers.known_name?("tags", state)
true

iex> MishkaGervaz.Helpers.known_name?("unknown", state)
false

iex> state = %{static: %{columns: [%{name: :id}, %{name: :status}]}}
iex> MishkaGervaz.Helpers.known_name?("status", state)
true

known_name?(name, arg2, kind)

@spec known_name?(String.t(), map(), :filters | :steps | :uploads) :: boolean()

map_get(map, key, default)

@spec map_get(map() | nil, atom(), term()) :: term()

Fetch a key from a possibly-nil map, returning default when the key is missing or the value is nil.

Used by the Resource.Info.{Form,Table} and Domain.Info.{Form,Table} introspection modules to absorb the repeating case map do %{key: v} -> v; _ -> default end shape.

map_put_if_set(map, key, value)

@spec map_put_if_set(map(), atom(), {:ok, any()} | {:error, any()} | :error | any()) ::
  map()

Puts a key-value pair into a map only if the value is present and valid.

Designed for building configuration maps where optional values should only be included when explicitly set. Supports multiple input formats commonly used with Spark DSL introspection functions.

Supported Formats

  • {:ok, value} - Spark/Ash introspection result (value must not be nil)
  • {:error, _} - Ignored, returns original map
  • :error - Ignored, returns original map
  • Direct value - Added if not nil

Examples

iex> %{} |> MishkaGervaz.Helpers.map_put_if_set(:name, {:ok, "John"})
%{name: "John"}

iex> %{} |> MishkaGervaz.Helpers.map_put_if_set(:name, {:ok, nil})
%{}

iex> %{} |> MishkaGervaz.Helpers.map_put_if_set(:name, :error)
%{}

iex> %{} |> MishkaGervaz.Helpers.map_put_if_set(:name, {:error, :not_found})
%{}

iex> %{} |> MishkaGervaz.Helpers.map_put_if_set(:name, "John")
%{name: "John"}

iex> %{} |> MishkaGervaz.Helpers.map_put_if_set(:name, nil)
%{}

iex> %{a: 1} |> MishkaGervaz.Helpers.map_put_if_set(:b, {:ok, 2}) |> MishkaGervaz.Helpers.map_put_if_set(:c, {:ok, 3})
%{a: 1, b: 2, c: 3}

master_user?(_)

@spec master_user?(map() | struct() | nil) :: boolean()

Returns true when the user has no tenant — i.e. is a master user.

Canonical default for master_user?/1 across the framework. The tenant attribute is currently :site_id.

maybe_assign(assigns, key, value)

@spec maybe_assign(map(), atom(), any()) :: map()

Conditionally assigns a key-value pair to assigns only if the value is not nil.

This is useful when building assigns for components where you want to allow upstream defaults (via assign_new) to take effect when no value is provided.

Examples

iex> %{} |> Phoenix.Component.assign(:foo, "bar") |> MishkaGervaz.Helpers.maybe_assign(:icon, nil)
%{foo: "bar"}

iex> %{} |> Phoenix.Component.assign(:foo, "bar") |> MishkaGervaz.Helpers.maybe_assign(:icon, "hero-trash")
%{foo: "bar", icon: "hero-trash"}

merge_relation_field_values(params, state)

@spec merge_relation_field_values(map(), map()) :: map()

Merges form-state relation field values into a params map.

For each relation field with a value in state.field_values, places the value at the string-keyed slot in params. The sentinel "__nil__" is rewritten to a real nil so AshPhoenix can clear the relation. Empty string and nil values are ignored.

Used by validation/submit handlers to ensure relation selections survive param re-merging.

module_to_snake(module, suffix \\ "")

@spec module_to_snake(module(), String.t()) :: String.t()

Converts a module name to its snake_case short name.

Takes the last part of a module name and converts it to snake_case. Optionally appends a suffix.

Examples

iex> MishkaGervaz.Helpers.module_to_snake(MyApp.Users.User)
"user"

iex> MishkaGervaz.Helpers.module_to_snake(MyApp.BlogPost)
"blog_post"

iex> MishkaGervaz.Helpers.module_to_snake(MyApp.Users.User, "_stream")
"user_stream"

iex> MishkaGervaz.Helpers.module_to_snake(MyApp.BlogPost, "_table")
"blog_post_table"

normalize_id_type(type)

@spec normalize_id_type(any()) :: :uuid | :uuid_v7 | :integer | :string

Normalizes an Ash primary-key type to one of :uuid, :uuid_v7, :integer, or :string. Falls back to :uuid for anything else, matching the conservative default Phoenix uses for <input> shapes.

Recognises both the bare atom forms (:uuid, :integer, :string) and the Ash.Type.* modules. Future Ash.Type.UUIDv7 / UUID7 / UUID / Integer modules are matched by name string so a Spark release that adds new vendor variants (Ash.Type.UUIDv7Foo) still routes correctly.

normalize_options(options)

@spec normalize_options(list() | nil) :: [{String.t(), String.t()}]

Normalizes a list of options for HTML select elements.

Converts various option formats to {label, value} tuples with string values, ensuring compatibility with Phoenix HTML attributes.

Examples

iex> MishkaGervaz.Helpers.normalize_options([{"API Only", :api_only}, {"Hybrid", :hybrid}])
[{"API Only", "api_only"}, {"Hybrid", "hybrid"}]

iex> MishkaGervaz.Helpers.normalize_options([:active, :inactive])
[{"Active", "active"}, {"Inactive", "inactive"}]

iex> MishkaGervaz.Helpers.normalize_options(["foo", "bar"])
[{"foo", "foo"}, {"bar", "bar"}]

iex> MishkaGervaz.Helpers.normalize_options(nil)
[]

normalize_selected_values(values)

@spec normalize_selected_values(list() | any() | nil) :: [String.t()]

Normalizes selected values for multi-select components.

Converts various input formats to a list of non-empty string values, filtering out empty strings and "nil" string representations.

Examples

iex> MishkaGervaz.Helpers.normalize_selected_values(nil)
[]

iex> MishkaGervaz.Helpers.normalize_selected_values(["a", "b", "c"])
["a", "b", "c"]

iex> MishkaGervaz.Helpers.normalize_selected_values([:foo, :bar])
["foo", "bar"]

iex> MishkaGervaz.Helpers.normalize_selected_values(["valid", "", nil, "nil"])
["valid"]

iex> MishkaGervaz.Helpers.normalize_selected_values("single")
["single"]

primary_key_type(resource)

@spec primary_key_type(module()) :: :uuid | :uuid_v7 | :integer | :string

Returns the normalized type of resource's primary key as one of :uuid, :uuid_v7, :integer, or :string. Defaults to :uuid when introspection fails or the resource has no primary key.

relation_id_type(entity, parent)

@spec relation_id_type(map(), module() | nil) ::
  :uuid | :uuid_v7 | :integer | :string | nil

Returns the normalized id-type for a :relation entity, defaulting to :uuid when the related resource cannot be found.

Combines relation_target_resource/2 and primary_key_type/1. For non-relation entities returns nil.

relation_target_resource(arg1, parent)

@spec relation_target_resource(map(), module() | nil) :: module() | nil

Resolves the destination resource of a relation field/filter.

Accepts any map with :name / :source / :resource keys (the shape Form.Field and Table.Filter both have):

  • If :resource is set on the entity, returns it directly.
  • Otherwise looks up parent_resource's Ash relationships for the one whose source_attribute matches entity.source || entity.name and returns its :destination.
  • Returns nil when no match is found, when parent_resource is nil, or on any introspection failure.

resolve_label(label)

@spec resolve_label(String.t() | (-> String.t()) | nil) :: String.t() | nil

Resolves a label that may be a string or a zero-arity function.

This enables i18n support in DSL labels by allowing users to pass fn -> gettext("...") end which defers execution to runtime.

Examples

iex> MishkaGervaz.Helpers.resolve_label("Static Label")
"Static Label"

iex> MishkaGervaz.Helpers.resolve_label(fn -> "Dynamic Label" end)
"Dynamic Label"

iex> MishkaGervaz.Helpers.resolve_label(nil)
nil

resolve_label(record, display_field)

@spec resolve_label(
  struct(),
  atom() | (struct() -> String.t()) | (struct(), map() -> String.t())
) :: String.t()

Resolves a display value from a record using either an atom field or a function.

Supports:

  • 2-arity: resolve_label(record, display_field) - for atom or 1-arity function
  • 3-arity: resolve_label(record, display_field, state) - for 2-arity function

Examples

iex> MishkaGervaz.Helpers.resolve_label(%{name: "Test"}, :name)
"Test"

iex> MishkaGervaz.Helpers.resolve_label(%{name: "Cat", site: %{name: "Site1"}}, fn r -> "#{r.name} - #{r.site.name}" end)
"Cat - Site1"

iex> MishkaGervaz.Helpers.resolve_label(%{id: 123, name: nil}, :name)
"123"

resolve_label(record, display_field, state)

@spec resolve_label(struct(), (struct(), map() -> String.t()), map()) :: String.t()

resolve_options(opts)

@spec resolve_options(list() | (-> list()) | nil) :: list()

Resolves options that may be a list or a zero-arity function returning a list.

This enables dynamic options (e.g., from a database query) in DSL fields by allowing users to pass fn -> query_options() end which defers execution to runtime.

Examples

iex> MishkaGervaz.Helpers.resolve_options([{"A", "a"}, {"B", "b"}])
[{"A", "a"}, {"B", "b"}]

iex> MishkaGervaz.Helpers.resolve_options(fn -> [{"X", "x"}] end)
[{"X", "x"}]

iex> MishkaGervaz.Helpers.resolve_options(nil)
[]

resolve_ui_label(_)

@spec resolve_ui_label(map() | struct() | nil) :: String.t() | nil

Resolves a label from a nested UI structure.

Extracts the label from entities that have a ui field containing a label. Supports both map and struct formats, with labels that can be strings or zero-arity functions (for i18n support).

Examples

iex> MishkaGervaz.Helpers.resolve_ui_label(%{ui: %{label: "Name"}})
"Name"

iex> MishkaGervaz.Helpers.resolve_ui_label(%{ui: %{label: fn -> "Dynamic" end}})
"Dynamic"

iex> MishkaGervaz.Helpers.resolve_ui_label(%{ui: nil})
nil

iex> MishkaGervaz.Helpers.resolve_ui_label(%{other: "field"})
nil

iex> MishkaGervaz.Helpers.resolve_ui_label(nil)
nil

to_boolean(_)

@spec to_boolean(String.t() | boolean() | nil) :: boolean() | nil

Converts string boolean representations to actual booleans.

Safely handles values coming from URL params, form inputs, or other string-based sources. Follows Phoenix's normalize_value/2 pattern.

Examples

iex> MishkaGervaz.Helpers.to_boolean("true")
true

iex> MishkaGervaz.Helpers.to_boolean("false")
false

iex> MishkaGervaz.Helpers.to_boolean(true)
true

iex> MishkaGervaz.Helpers.to_boolean(nil)
nil

iex> MishkaGervaz.Helpers.to_boolean("")
nil

user_tenant(_)

@spec user_tenant(map() | struct() | nil) :: any()

Extracts the tenant value from a user map/struct (currently :site_id).

Returns nil for nil users or users without the tenant attribute.

validate_field_errors(ash_form, params, current_errors \\ %{})

@spec validate_field_errors(AshPhoenix.Form.t(), map(), map() | nil) :: {map(), map()}

Validates a form and returns per-field errors for the currently-changing field only.

Designed for use in on_validate hooks to provide live inline errors without showing unrelated required-field errors for untouched fields.

Extracts _target from params to identify the current field, validates via AshPhoenix.Form.validate/3, then filters errors to only that field.

Returns {params, errors} ready to return directly from an on_validate hook.

Examples

hooks do
  on_validate fn params, state ->
    MishkaGervaz.Helpers.validate_field_errors(state.form.source, params)
  end
end

# With param mutation before validation:
hooks do
  on_validate fn params, state ->
    updated = put_in(params, ["form", "slug"], slugify(params["form"]["title"]))
    MishkaGervaz.Helpers.validate_field_errors(state.form.source, updated)
  end
end