defmodule LiveFilter.Bar do @moduledoc """ LiveComponent that renders the filter bar UI. Supports two modes: - `:basic` (default) - Simple chips without operator selection - `:command` - Full chips with inline operator dropdown (Linear/Notion style) Manages local state for which filter is being edited and whether the field picker dropdown is open. Notifies the parent via `{:livefilter, :updated, params}` when filters change. """ use Phoenix.LiveComponent alias LiveFilter.{DateUtils, Filter, Inputs, Operators, OptionHelpers, Params.Serializer, Theme} alias LiveFilter.Components.{ AsyncSelect, Boolean, Calendar, DateRange, Datetime, Helpers, MultiSelect, RadioGroup, Select } import OptionHelpers, only: [resolve_options: 1, opt_value_string: 1, opt_label: 1] import Helpers, only: [values_match?: 2, x_icon: 1] import DaisyUIComponents.Button @impl true def mount(socket) do {:ok, assign(socket, editing_filter_id: nil, local_filters: [], filter_menu_search: "", select_search: %{}, newly_added_filter_id: nil, # Date range calendar state date_calendar_filter_id: nil, date_selecting_start: true, date_temp_start: nil, date_temp_end: nil, date_current_month: Date.utc_today(), # Async select state async_search_text: %{}, async_options: %{}, resolved_labels: %{} )} end @impl true def update(assigns, socket) do {:ok, socket |> assign(assigns) |> assign_defaults() |> assign_filter_state() |> hydrate_async_labels()} end defp assign_defaults(socket) do socket |> assign_new(:mode, fn -> :basic end) |> assign_new(:theme, fn -> :neutral end) |> assign_new(:variant, fn -> :neutral end) end defp assign_filter_state(socket) do %{config: configs, filters: parent_filters} = socket.assigns.filter local_filters = socket.assigns[:local_filters] || [] parent_fields = MapSet.new(parent_filters, & &1.field) local_fields = MapSet.new(local_filters, & &1.field) # Keep local filters that aren't overridden by parent new_local = Enum.reject(local_filters, &MapSet.member?(parent_fields, &1.field)) # Add default_visible filters that aren't already present all_active_fields = MapSet.union(parent_fields, MapSet.new(new_local, & &1.field)) default_visible_filters = build_default_visible_filters(configs, all_active_fields) # Combine all filters all_filters = parent_filters ++ new_local ++ default_visible_filters # Sort filters by their config order to maintain consistent positioning filters = sort_filters_by_config(all_filters, configs) available_fields = available_fields(configs, filters) # Ensure default_visible filters are always tracked locally (even if from parent) # This preserves them across clear_all when parent doesn't include them default_visible_from_parent = parent_filters |> Enum.filter(fn f -> f.config.default_visible and not MapSet.member?(local_fields, f.field) end) all_local = new_local ++ default_visible_filters ++ default_visible_from_parent assign(socket, configs: configs, filters: filters, local_filters: all_local, available_fields: available_fields ) end defp build_default_visible_filters(configs, active_fields) do configs |> Enum.filter(& &1.default_visible) |> Enum.reject(&MapSet.member?(active_fields, &1.field)) |> Enum.map(&Filter.new/1) end # Sort filters: default_visible filters come first (in config order), then user-added filters (append order) defp sort_filters_by_config(filters, configs) do default_visible_fields = configs |> Enum.filter(& &1.default_visible) |> Enum.map(& &1.field) |> MapSet.new() config_order = configs |> Enum.filter(& &1.default_visible) |> Enum.with_index() |> Enum.map(fn {config, idx} -> {config.field, idx} end) |> Map.new() {default_filters, user_filters} = Enum.split_with(filters, fn filter -> MapSet.member?(default_visible_fields, filter.field) end) # Sort default_visible filters by config order, keep user filters in their current order sorted_default = Enum.sort_by(default_filters, fn filter -> Map.get(config_order, filter.field, 999) end) sorted_default ++ user_filters end defp available_fields(configs, filters) do active_fields = MapSet.new(filters, & &1.field) Enum.reject(configs, fn config -> config.always_on or config.default_visible or MapSet.member?(active_fields, config.field) end) end @impl true def render(assigns) do ~H"""
<.always_on_filter :for={filter <- always_on_filters(@filters)} filter={filter} myself={@myself} /> <.filter_chip :for={filter <- removable_filters(@filters)} filter={filter} mode={filter_mode(filter, @mode)} theme={filter_theme(filter, @theme)} variant={@variant} editing={@editing_filter_id == filter.id} select_search={@select_search} newly_added={@newly_added_filter_id == filter.id} show_calendar={@date_calendar_filter_id == filter.id} date_current_month={@date_current_month} date_selecting_start={@date_selecting_start} date_temp_start={@date_temp_start} date_temp_end={@date_temp_end} async_search_text={@async_search_text} async_options={@async_options} resolved_labels={@resolved_labels} myself={@myself} /> <.add_filter_dropdown :if={@available_fields != []} id={@id} available_fields={@available_fields} search={@filter_menu_search} myself={@myself} /> <.button :if={has_clearable_filters?(@filters)} ghost size="sm" class="cursor-pointer" phx-click="clear_all" phx-target={@myself}> Clear all
""" end defp filter_mode(%{config: %{mode: mode}}, _bar_mode) when not is_nil(mode), do: mode defp filter_mode(_filter, bar_mode), do: bar_mode defp filter_theme(%{config: %{theme: theme}}, _bar_theme) when not is_nil(theme), do: theme defp filter_theme(_filter, bar_theme), do: bar_theme defp variant_class(:outline), do: "btn-outline" defp variant_class(:ghost), do: "btn-ghost" defp variant_class(:soft), do: "btn-soft" defp variant_class(:neutral), do: "" defp variant_class(custom) when is_binary(custom), do: custom defp variant_class(_), do: "btn-outline" # --- Sub-components --- defp always_on_filter(assigns) do ~H"""
{@filter.config.label} <.filter_input filter={@filter} myself={@myself} />
""" end defp filter_chip(assigns) do filter_type = assigns.filter.config.type # RadioGroup: inline pills when options <= threshold, otherwise dropdown is_radio_group_dropdown = radio_group_needs_dropdown?(assigns.filter.config) is_async_select = filter_type == :async_select is_dropdown = filter_type in [ :select, :multi_select, :boolean, :date_range, :datetime_range, :datetime, :async_select ] or is_radio_group_dropdown # Select with :in/:not_in operator should render as multi-select is_select_multi = filter_type == :select and assigns.filter.operator in [:in, :not_in] assigns = assign(assigns, %{ theme_classes: Theme.get_theme(assigns.theme), variant_class: variant_class(assigns.variant), is_dropdown: is_dropdown, is_select: filter_type == :select and not is_select_multi, is_select_multi: is_select_multi, is_multi_select: filter_type == :multi_select, is_boolean: filter_type == :boolean, is_date_range: filter_type in [:date_range, :datetime_range], is_datetime_range: filter_type == :datetime_range, is_datetime: filter_type == :datetime, is_radio_group_dropdown: is_radio_group_dropdown, is_async_select: is_async_select }) |> assign_new(:show_calendar, fn -> false end) |> assign_new(:date_current_month, fn -> Date.utc_today() end) |> assign_new(:date_selecting_start, fn -> true end) |> assign_new(:date_temp_start, fn -> nil end) |> assign_new(:date_temp_end, fn -> nil end) |> assign_new(:datetime_current_month, fn -> Date.utc_today() end) |> assign_new(:async_search_text, fn -> %{} end) |> assign_new(:async_options, fn -> %{} end) |> assign_new(:resolved_labels, fn -> %{} end) # Command mode with dropdown filters needs separate dropdown triggers for operator and value if assigns.mode == :command and is_dropdown do render_command_chip(assigns) else render_basic_chip(assigns) end end # Command mode: operator and value are separate independent dropdowns defp render_command_chip(assigns) do ~H"""
<.filter_icon filter={@filter} theme_classes={@theme_classes} /> {@filter.config.label}
<.operator_dropdown filter={@filter} theme_classes={@theme_classes} myself={@myself} /> <.value_dropdown filter={@filter} theme_classes={@theme_classes} select_search={@select_search} newly_added={@newly_added} is_select={@is_select} is_select_multi={@is_select_multi} is_multi_select={@is_multi_select} is_boolean={@is_boolean} is_radio_group_dropdown={@is_radio_group_dropdown} is_datetime={@is_datetime} is_date_range={@is_date_range} is_async_select={@is_async_select} show_calendar={@show_calendar} date_current_month={@date_current_month} date_selecting_start={@date_selecting_start} date_temp_start={@date_temp_start} date_temp_end={@date_temp_end} datetime_current_month={@datetime_current_month} async_search_text={@async_search_text} async_options={@async_options} resolved_labels={@resolved_labels} myself={@myself} />
""" end # Basic mode: entire chip is one dropdown (or no dropdown for non-dropdown types) defp render_basic_chip(assigns) do ~H"""
<.filter_icon filter={@filter} theme_classes={@theme_classes} /> {@filter.config.label}
<.operator_section :if={@mode == :command} filter={@filter} theme_classes={@theme_classes} myself={@myself} />
<.value_display filter={@filter} theme_classes={@theme_classes} mode={@mode} resolved_labels={@resolved_labels} myself={@myself} />
<.select_dropdown :if={@is_select} filter={@filter} theme_classes={@theme_classes} select_search={@select_search} myself={@myself} /> <.multi_select_dropdown :if={@is_multi_select} filter={@filter} theme_classes={@theme_classes} select_search={@select_search} myself={@myself} /> <.boolean_dropdown :if={@is_boolean} filter={@filter} theme_classes={@theme_classes} myself={@myself} /> <.radio_group_dropdown :if={@is_radio_group_dropdown} filter={@filter} theme_classes={@theme_classes} myself={@myself} /> <.datetime_picker :if={@is_datetime} filter={@filter} current_month={@datetime_current_month} myself={@myself} /> <.date_range_dropdown :if={@is_date_range && !@show_calendar} filter={@filter} theme_classes={@theme_classes} myself={@myself} /> <.calendar_picker :if={@is_date_range && @show_calendar} filter={@filter} current_month={@date_current_month} selecting_start={@date_selecting_start} temp_start={@date_temp_start} temp_end={@date_temp_end} myself={@myself} /> <.async_select_dropdown :if={@is_async_select} filter={@filter} async_search_text={@async_search_text} async_options={@async_options} myself={@myself} /> <.inline_editor :if={@editing} filter={@filter} myself={@myself} />
""" end # Independent operator dropdown for command mode defp operator_dropdown(assigns) do # Use filter's configured operators with labels from Operators module options = Enum.map(assigns.filter.config.operators, fn op -> {op, Operators.label(op)} end) assigns = assign(assigns, :options, options) ~H""" """ end # Independent value dropdown for command mode defp value_dropdown(assigns) do ~H""" """ end # Simplified value display for command mode (no cursor-pointer class, parent handles it) # Select with :in/:not_in operators - show multiple badges defp value_display_command( %{filter: %{config: %{type: :select}, operator: op, value: values}} = assigns ) when op in [:in, :not_in] and is_list(values) and values != [] do ~H"""
<%= for val <- @filter.value do %> {display_option_label(val, @filter.config)} <% end %>
""" end # Standard single-value select defp value_display_command(%{filter: %{config: %{type: :select}, value: value}} = assigns) when not is_nil(value) and value != "" do ~H""" {display_option_label(@filter.value, @filter.config)} """ end defp value_display_command( %{filter: %{config: %{type: :multi_select}, value: values}} = assigns ) when is_list(values) and values != [] do ~H"""
<%= for val <- @filter.value do %> {display_option_label(val, @filter.config)} <% end %>
""" end defp value_display_command(%{filter: %{config: %{type: :boolean}, value: value}} = assigns) when is_boolean(value) do config = assigns.filter.config assigns = assign(assigns, :label, if(value, do: config.true_label, else: config.false_label)) ~H""" {@label} """ end defp value_display_command( %{filter: %{config: %{type: :boolean, nullable: true}, value: nil}} = assigns ) do assigns = assign(assigns, :any_label, assigns.filter.config.any_label) ~H""" {@any_label} """ end defp value_display_command( %{filter: %{config: %{type: type}, value: {start_val, end_val}}} = assigns ) when type in [:date_range, :datetime_range] and (not is_nil(start_val) or not is_nil(end_val)) do assigns = assign(assigns, :display_range, DateUtils.format_range({start_val, end_val})) ~H""" {@display_range} """ end defp value_display_command( %{filter: %{config: %{type: :datetime} = config, value: value}} = assigns ) when not is_nil(value) and value != "" do formatted = Datetime.format_display(value, config.time_format) assigns = assign(assigns, :formatted, formatted) ~H""" {@formatted} """ end defp value_display_command( %{filter: %{config: %{type: :radio_group} = config, value: value}} = assigns ) when not is_nil(value) and value != "" do assigns = assign(assigns, :label, display_option_label(value, config)) ~H""" {@label} """ end # AsyncSelect with value - show resolved label defp value_display_command( %{filter: %{config: %{type: :async_select}, value: value}, resolved_labels: labels} = assigns ) when not is_nil(value) and value != "" do assigns = assign(assigns, :display_label, Map.get(labels, assigns.filter.id, value)) ~H""" {@display_label} """ end defp value_display_command(%{filter: %{value: value}} = assigns) when not is_nil(value) and value != "" do ~H""" {display_value_simple(@filter.value)} """ end defp value_display_command(assigns) do ~H""" Select """ end defp async_select_dropdown(assigns) do AsyncSelect.render(assigns) end defp select_dropdown(assigns) do Select.render(assigns) end defp multi_select_dropdown(assigns) do MultiSelect.render(assigns) end defp boolean_dropdown(assigns) do Boolean.render(assigns) end defp radio_group_dropdown(assigns) do RadioGroup.render(assigns) end defp radio_group_needs_dropdown?(%{type: :radio_group, style: :radios}), do: true defp radio_group_needs_dropdown?(%{type: :radio_group, style: :pills} = config) do options = resolve_options(config) length(options) > config.inline_threshold end defp radio_group_needs_dropdown?(_), do: false defp datetime_picker(assigns) do Datetime.render(assigns) end defp date_range_dropdown(assigns) do DateRange.render(assigns) end defp calendar_picker(assigns) do Calendar.render(assigns) end defp value_display(%{filter: %{config: %{type: :select}, value: value}} = assigns) when not is_nil(value) and value != "" do ~H""" {display_option_label(@filter.value, @filter.config)} """ end defp value_display(%{filter: %{config: %{type: :multi_select}, value: values}} = assigns) when is_list(values) and values != [] do ~H"""
<%= for val <- @filter.value do %> {display_option_label(val, @filter.config)} <% end %>
""" end defp value_display(%{filter: %{config: %{type: :multi_select}}} = assigns) do ~H""" Select """ end # RadioGroup - inline pills when options <= inline_threshold defp value_display( %{filter: %{config: %{type: :radio_group, style: :pills} = config}} = assigns ) do options = resolve_options(config) inline_threshold = config.inline_threshold if length(options) <= inline_threshold do assigns = assign(assigns, :options, options) ~H"""
<%= for opt <- @options do %> <% end %>
""" else assigns = assign(assigns, :label, display_option_label(assigns.filter.value, config)) ~H""" {@label} """ end end # RadioGroup - radios style always shows label (dropdown rendering) defp value_display(%{filter: %{config: %{type: :radio_group} = config, value: value}} = assigns) when not is_nil(value) and value != "" do assigns = assign(assigns, :label, display_option_label(value, config)) ~H""" {@label} """ end defp value_display(%{filter: %{config: %{type: :radio_group}}} = assigns) do ~H""" Select """ end defp value_display(%{filter: %{config: %{type: :boolean}, value: value}} = assigns) when is_boolean(value) do config = assigns.filter.config assigns = assign(assigns, :label, if(value, do: config.true_label, else: config.false_label)) ~H""" {@label} """ end # Nullable boolean with nil value - show "Any" label defp value_display( %{filter: %{config: %{type: :boolean, nullable: true}, value: nil}} = assigns ) do assigns = assign(assigns, :any_label, assigns.filter.config.any_label) ~H""" {@any_label} """ end defp value_display(%{filter: %{config: %{type: :boolean}}} = assigns) do ~H""" Select """ end defp value_display(%{filter: %{config: %{type: type}, value: {start_val, end_val}}} = assigns) when type in [:date_range, :datetime_range] and (not is_nil(start_val) or not is_nil(end_val)) do assigns = assign(assigns, :display_range, DateUtils.format_range({start_val, end_val})) ~H""" {@display_range} """ end defp value_display(%{filter: %{config: %{type: type}}} = assigns) when type in [:date_range, :datetime_range] do ~H""" Select """ end # DateTime with value - format nicely defp value_display(%{filter: %{config: %{type: :datetime} = config, value: value}} = assigns) when not is_nil(value) and value != "" do formatted = Datetime.format_display(value, config.time_format) assigns = assign(assigns, :formatted, formatted) ~H""" {@formatted} """ end defp value_display(%{filter: %{config: %{type: :datetime}}} = assigns) do ~H""" Select """ end # AsyncSelect with value - show resolved label defp value_display( %{filter: %{config: %{type: :async_select}, value: value}, resolved_labels: labels} = assigns ) when not is_nil(value) and value != "" do assigns = assign(assigns, :display_label, Map.get(labels, assigns.filter.id, value)) ~H""" {@display_label} """ end defp value_display(%{filter: %{config: %{type: :async_select}}} = assigns) do ~H""" Select """ end # Number in command mode: show inline input # Hide native spinners for cleaner look - user types value directly defp value_display(%{filter: %{config: %{type: :number}}, mode: :command} = assigns) do ~H"""
""" end # Text in command mode: show inline input defp value_display(%{filter: %{config: %{type: :text}}, mode: :command} = assigns) do ~H"""
""" end defp value_display(assigns) do ~H""" <.value_badges filter={@filter} theme_classes={@theme_classes} myself={assigns[:myself]} /> """ end defp operator_section(assigns) do options = Operators.options_for_type(assigns.filter.config.type) assigns = assign(assigns, :options, options) ~H""" """ end defp filter_icon(%{filter: %{config: %{icon: icon}}} = assigns) when is_function(icon) do icon.(assigns) end defp filter_icon(%{filter: %{config: %{icon: icon}}} = assigns) when is_binary(icon) do ~H""" <.render_filter_icon icon={@filter.config.icon} class="size-4 shrink-0" /> """ end defp filter_icon(%{filter: %{config: %{icon: nil}}} = assigns) do ~H"" end defp filter_icon(assigns) do ~H"" end # Render filter icon - supports nil (no icon) or string (heroicon class name like "hero-folder") defp render_filter_icon(%{icon: nil} = assigns) do ~H"" end defp render_filter_icon(%{icon: icon} = assigns) when is_binary(icon) do ~H"""